JNet: performance
This page reports benchmark results for the core JNet interop primitives: JVM method invocation from .NET and JVM→.NET callback latency. All benchmarks run on GitHub Actions runners and are repeated automatically on each release across supported .NET and JDK versions.
Results are reported for three JCOBridge versions — 2.6.6, 2.6.7+, and 2.6.9 — across two runtime combinations. See JCOBridge release notes for details.
Note
Benchmarks are run on shared GitHub-hosted runners. Absolute numbers reflect that environment and should be read comparatively rather than as absolute throughput figures for a dedicated host.
For the full BenchmarkDotNet results across all supported .NET versions, JDK vendors, and platforms see:
Test environments
Three environments are used across this page:
| Parameter | x86-64 EPYC (stopwatch) | x86-64 (BenchmarkDotNet) | ARM64 (BenchmarkDotNet) |
|---|---|---|---|
| Runner | GitHub Actions ubuntu-22.04, AMD EPYC 9V45 96-Core |
GitHub Actions ubuntu-24.04, AMD EPYC 7763 2-Core |
GitHub Actions ubuntu-24.04, Neoverse-N2 4-Core |
| Measurement | Stopwatch, 1 000 000 iterations | BenchmarkDotNet, statistically rigorous | BenchmarkDotNet, statistically rigorous |
| .NET versions | .NET 8 / .NET 10 | .NET 8 / .NET 10 | .NET 8 / .NET 10 |
| JDK versions | Temurin 17 / Temurin 25 | Multiple vendors — see Latest benchmark results | Multiple vendors — see Latest benchmark results |
Note
The 2.6.6 and 2.6.7+ baselines were collected on earlier ubuntu-latest runners and are preserved as historical reference. The x86-64 EPYC stopwatch results (2.6.9 section) use the AMD EPYC 9V45 dedicated runner and represent the lowest latency observed on x86-64. The BenchmarkDotNet results use shared ubuntu-24.04 runners on both platforms and are statistically rigorous; see the live dashboard for up-to-date numbers.
What is measured
JVM object creation
Measures the cost of creating a JVM object from .NET via three constructor resolution strategies:
NewEmpty— resolves the constructor by argument type matching.NewWithSignature— resolves the constructor by JNI signature string.DeclaredNewEmpty— uses a pre-declared constructor reference.
Field access
Measures the cost of getting and setting JVM instance and static fields from .NET. Primitive fields (e.g. int) are transferred as-is; reference fields (e.g. String) require marshalling between JVM and CLR representations.
JVM method invocation from .NET
Measures the round-trip latency of calling a JVM method from .NET through JNet, with two resolution strategies and two method signatures.
Resolution strategies:
- Invoke — the method is identified by .NET-side type matching against input arguments on every call. The JVM descriptor is cached after first resolution, but argument type validation is re-evaluated on the .NET side at each invocation.
- InvokeWithSignature (
IWS) — the method is identified by name and JNI signature string. Argument validation is delegated to the JVM, eliminating the .NET-side type matching cost.
Method signatures (feedback):
feedback = false— method takes no arguments and returnsvoid. Measures pure invocation overhead.feedback = true— method takes abooleanargument and returns the sameboolean. Measures the additional cost of argument passing and return value marshalling across the JNI boundary.
Both static and instance method variants are tested.
Array and varargs invocation
Measures the cost of passing arrays or varargs to JVM methods from .NET, across three element types and three sizes (10, 1 000, 100 000 elements):
InvokeIntArrayFixed— fixed-lengthint[]array.InvokeVarArgsWholeArray—int[]passed as a varargs array (single JNI call).InvokeStringArrayFixed— fixed-lengthString[]array — each element requires individual JVM↔CLR string marshalling.InvokeVarArgsSpreadElements— each element passed as an individual varargs argument — worst case for element-by-element overhead.InvokeIntParam— singleintparameter, used as baseline.
Multi-parameter invocation
Measures the overhead of passing multiple mixed-type arguments in a single JVM method call from .NET.
Callback: TestPredicateRoundTrip (.NET → JVM → .NET)
A .NET-initiated test: .NET triggers a JVM call which immediately fires a callback back into .NET. Provides a controlled start-time marker and measures the full bidirectional round-trip. In real usage the JVM initiates the event — see TestPredicateSustained for the realistic reference.
Callback: TestPredicateSustained (JVM → .NET, sustained)
A JVM-initiated test: .NET sends a single start command to JVM, which then fires 1 000 000 callback events toward the CLR autonomously without returning control to .NET. After all events are fired, the JVM returns and .NET measures the total elapsed time. Aside from the single startup call, this measures the pure cost of receiving a sustained stream of JVM-originated events — the scenario matching real-world usage (e.g. Kafka Streams functional interfaces, AWT event listeners).
Both callback tests share two configuration axes (2.6.7+ only):
byIndex — event trigger identification:
byIndex = false— the event is identified on the CLR side by a string key lookup.byIndex = true— the event is identified on the CLR side by a numeric index. In both cases, JVM object arguments are retrieved as JVM objects after the trigger is received.
Two-level early-discard filter (ListenerShallManageEvent, 2.6.7+):
JCOBridge 2.6.7+ introduces two overloads of ListenerShallManageEvent on the JNet callback base class, forming a two-gate filter applied before full event handling. Both gates receive the event as a numeric index — no string conversion is performed unless explicitly requested via the name-based delegate variants.
First gate — bool ListenerShallManageEvent(int eventIndex): called before any argument data is read from the JVM. The return value:
false(continueFirstCheck = false) — discard immediately: no data is read, the handler is not invoked.true(continueFirstCheck = true) — proceed to the second gate.
The first gate is driven by one of the following, evaluated in order:
ListenerShallManageEventIndex(Func<int, bool>) — fastest: receives the raw event index, no string conversion.ListenerShallManageEventName(Func<string, bool>) — receives the event name, resolved viaConvertListenerEventIndexToEventName.- Override of
ListenerShallManageEvent(int)— virtual, for subclass-based filtering. - Default: returns
true(all events proceed).
Second gate — bool ListenerShallManageEvent(int eventIndex, object data): called after raw argument data is available but before full event processing and handler dispatch. Allows lightweight inspection of the raw payload without paying the cost of full conversion. The return value:
false(continueSecondCheck = false) — discard after raw-data inspection: the registered handler is not invoked.true(continueSecondCheck = true) — proceed normally: full argument conversion and handler invocation.
The second gate is driven by one of the following, evaluated in order:
ListenerShallManageEventIndexWithData(Func<int, object, bool>) — receives the raw event index and raw data.ListenerShallManageEventNameWithData(Func<string, object, bool>) — receives the event name and raw data.- Override of
ListenerShallManageEvent(int, object)— virtual. - Default: returns
true.
The combination continueFirstCheck = false, continueSecondCheck = true is never reached and is not tested. Default for both gates is true (full processing).
JVM object creation (BenchmarkDotNet, ubuntu-24.04, Temurin 17 / Temurin 25)
| Method | x86-64 .NET 8 / T17 | x86-64 .NET 10 / T25 | ARM64 .NET 8 / T17 | ARM64 .NET 10 / T25 |
|---|---|---|---|---|
NewEmpty |
1.169 µs | 1.109 µs | 1.046 µs | 1.046 µs |
NewWithSignature |
1.173 µs | 1.108 µs | 1.033 µs | 1.033 µs |
DeclaredNewEmpty |
1.158 µs | 1.121 µs | 1.033 µs | 1.033 µs |
All three strategies are equivalent in cost — the JNI boundary crossing dominates over the resolution overhead. No allocation occurs on the .NET side. ARM64 constructors are ~5-10% faster than x86-64 on the same JDK version.
Field access (BenchmarkDotNet, ubuntu-24.04, Temurin 17 / Temurin 25)
| Method | x86-64 .NET 8 / T17 | x86-64 .NET 10 / T25 | ARM64 .NET 8 / T17 | ARM64 .NET 10 / T25 |
|---|---|---|---|---|
GetInstanceIntField |
100.4 ns | 76.2 ns | 100.4 ns | 100.4 ns |
SetInstanceIntField |
120.5 ns | 81.4 ns | 121.7 ns | 121.7 ns |
GetInstanceStringField |
104.5 ns | 73.6 ns | 113.8 ns | 113.8 ns |
SetInstanceStringField |
631.7 ns | 605.9 ns | 674.2 ns | 674.2 ns |
GetStaticIntField |
122.7 ns | 78.2 ns | 114.3 ns | 114.3 ns |
SetStaticIntField |
127.7 ns | 79.8 ns | 110.7 ns | 110.7 ns |
GetStaticStringField |
101.2 ns | 70.3 ns | 97.7 ns | 97.7 ns |
SetStaticStringField |
638.3 ns | 625.2 ns | 667.3 ns | 667.3 ns |
GetInstanceIntFieldGeneric |
137.1 ns | 82.2 ns | 105.9 ns | 105.9 ns |
Primitive field access is symmetric (~75–140 ns get/set across platforms). String field writes are ~6–8× more expensive than reads — setting a String field requires converting the .NET string to a JVM String object, an allocation on the JVM heap plus a full string copy. Avoid repeated writes to JVM String fields in hot paths; prefer int/long/boolean fields where performance matters.
Method invocation (BenchmarkDotNet, ubuntu-24.04, Temurin 17 / Temurin 25)
For full multi-vendor comparison see Latest benchmark results.
| Method | x86-64 .NET 8 / T17 | x86-64 .NET 10 / T25 | ARM64 .NET 8 / T17 | ARM64 .NET 10 / T25 |
|---|---|---|---|---|
InvokeStaticEmpty |
273.4 ns | 275.7 ns | 296.0 ns | 298.6 ns |
InvokeStaticEmptyWithSignature |
251.4 ns | 267.7 ns | 291.9 ns | 305.7 ns |
InvokeStaticWithFeedback |
517.6 ns | 568.0 ns | 549.6 ns | 530.6 ns |
InvokeInstanceEmpty |
278.8 ns | 283.2 ns | 300.0 ns | 310.0 ns |
InvokeInstanceWithFeedback |
533.5 ns | 587.4 ns | 534.0 ns | 547.2 ns |
On x86-64 with .NET 8 / T17, InvokeWithSignature is ~8% faster than Invoke for static methods; the gap narrows on .NET 10. On ARM64 both strategies are within 5% of each other. The advantage of InvokeWithSignature is more pronounced under load and with complex argument types — see the live dashboard for a full multi-vendor breakdown.
Array and varargs invocation (BenchmarkDotNet, ubuntu-24.04, Temurin 17 / Temurin 25)
| Method | Elements | x86-64 .NET 8 / T17 | x86-64 .NET 10 / T25 | ARM64 .NET 8 / T17 | ARM64 .NET 10 / T25 |
|---|---|---|---|---|---|
InvokeIntParam (baseline) |
— | 559 ns | 595 ns | 538 ns | 539 ns |
InvokeIntArrayFixed |
10 | 1,301 ns | 1,188 ns | 1,183 ns | 1,131 ns |
InvokeIntArrayFixed |
1 000 | 1,981 ns | 1,999 ns | 1,900 ns | 1,859 ns |
InvokeIntArrayFixed |
100 000 | 62,896 ns | 67,119 ns | 60,633 ns | 60,633 ns |
InvokeVarArgsWholeArray |
100 000 | 63,741 ns | 67,268 ns | 60,484 ns | 60,483 ns |
InvokeStringArrayFixed |
10 | 6,412 ns | 6,262 ns | 7,204 ns | 7,204 ns |
InvokeStringArrayFixed |
1 000 | 483,498 ns | 462,731 ns | 551,710 ns | 551,710 ns |
InvokeStringArrayFixed |
100 000 | 59,122,284 ns | 53,696,166 ns | 57,603,003 ns | 57,603,003 ns |
InvokeVarArgsSpreadElements |
100 000 | 102,160,469 ns | 95,098,483 ns | 80,829,867 ns | 80,829,867 ns |
int[] scales linearly with size at ~0.6–0.7 ns per element after the fixed JNI boundary cost. String[] costs ~0.5–0.75 µs per element regardless of size — each element requires an individual JVM→CLR Unicode conversion and allocation. At 100 000 elements that accumulates to ~54–59 ms vs ~60–67 µs for integers.
Note
The scaling difference between int[] and String[] reflects the fundamental cost of string marshalling: each String element requires an individual JVM→CLR Unicode conversion and allocation, while each int element is a direct memory copy. When passing large collections of string data across the boundary, consider encoding them as a single binary payload (byte[] or JCOBridgeStream<byte>) and parsing on the receiving side, or restructuring the API to avoid per-element crossings.
Preallocated JVM object invocation (BenchmarkDotNet, ubuntu-24.04, Temurin 17 / Temurin 25)
When JNet wrapper objects (Java.Lang.String, or any class generated by JNetReflector) are created in advance and reused across calls, passing them to a JVM method requires only transferring the native object reference — no marshalling, no allocation at call time. These benchmarks measure that path.
| Method | Elements | x86-64 .NET 8 / T17 | x86-64 .NET 10 / T25 | ARM64 .NET 8 / T17 | ARM64 .NET 10 / T25 |
|---|---|---|---|---|---|
InvokeSingleStringPreallocated |
1 | 577 ns | 303 ns | 589 ns | 583 ns |
InvokeStringArrayPreallocated |
10 | 2,011 ns | 1,167 ns | 1,949 ns | 1,958 ns |
InvokeStringArrayPreallocated |
1 000 | 60,540 ns | 31,922 ns | 59,528 ns | 61,262 ns |
InvokeStringArrayPreallocated |
100 000 | 7,454,725 ns | 5,217,584 ns | 9,241,561 ns | 7,947,215 ns |
Comparison at 100 000 elements (x86-64 .NET 10 / T25):
| Approach | 100 000 elements | Per-element cost |
|---|---|---|
InvokeIntArrayFixed |
67,119 ns | ~0.6 ns |
InvokeStringArrayPreallocated (preallocated JNet objects) |
5,217,584 ns | ~52 ns |
InvokeStringArrayFixed (.NET string, marshalled) |
53,696,166 ns | ~537 ns |
Key observations:
- Single preallocated object — ~300-590 ns, essentially the same cost as a no-argument method invocation. No marshalling occurs: only the native JVM object pointer is passed through the boundary.
- Preallocated array — eliminates string marshalling (~8-10× faster than passing
.NET string[]at 100 000 elements) but still pays a per-element cost for the type analysis layer: for each array element the engine runs a type dispatch chain to identify the object kind (IJVMBridgeBaseInstance), extracts the native JVM pointer, and builds the corresponding factory. No marshalling occurs, but the per-element .NET-side dispatch is not free (~52-92 ns per element depending on platform and runtime). - vs
int[]— even with preallocated objects, passing 100 000 object references costs ~80-140× more than passing 100 000 integers, because integers are transferred as a contiguous memory block while each object element must go through the type dispatch chain. - Conclusion — preallocating JNet wrapper objects is the right approach when the same JVM objects are used repeatedly across many calls (e.g. a fixed set of configuration objects, enum constants, or frequently reused strings). For truly bulk data transfer, prefer
JCOBridgeStream<T>with primitive types.
| Method | x86-64 .NET 8 / T17 | x86-64 .NET 10 / T25 | ARM64 .NET 8 / T17 | ARM64 .NET 10 / T25 |
|---|---|---|---|---|
InvokeMultiParam |
1.403 µs | 1.390 µs | 1.330 µs | 1.330 µs |
Multi-parameter invocation costs roughly 4–5× a no-argument call, reflecting the overhead of boxing and passing each additional argument across the JNI boundary. Keep argument lists short for frequently called methods.
In 2.6.6, the ListenerShallManageEvent filter and the native byIndex trigger mechanism are not yet available.
Static method invocation
| Resolution | feedback |
.NET 8 / T17 | .NET 10 / T25 |
|---|---|---|---|
Invoke |
false |
0.661 µs | 0.602 µs |
IWS |
false |
0.494 µs | 0.414 µs |
Invoke |
true |
0.901 µs | 0.803 µs |
IWS |
true |
0.686 µs | 0.522 µs |
Instance method invocation
| Resolution | feedback |
.NET 8 / T17 | .NET 10 / T25 |
|---|---|---|---|
Invoke |
false |
0.579 µs | 0.490 µs |
IWS |
false |
0.468 µs | 0.379 µs |
Invoke |
true |
0.856 µs | 0.764 µs |
IWS |
true |
0.638 µs | 0.535 µs |
Adding a boolean argument and return value (feedback = true) adds ~45–55% overhead with Invoke and ~35–40% with IWS, reflecting JNI argument marshalling cost.
Callback
| Test | byIndex |
.NET 8 / T17 | .NET 10 / T25 |
|---|---|---|---|
RoundTrip |
false |
6.945 µs | 6.338 µs |
Sustained |
false |
6.116 µs | 5.548 µs |
Sustained is the realistic reference for JVM-originated callback cost: ~6.1 µs (.NET 8 / T17) and ~5.5 µs (.NET 10 / T25).
JCOBridge 2.6.7+
JCOBridge 2.6.7+ introduces the two-level ListenerShallManageEvent filter and the native byIndex trigger mechanism. General interop improvements reduce baseline overhead across all test types.
Note
byIndex = true is still simulated on the JVM side by invoking a dedicated class method rather than the interface @Override. The CLR-side numeric index resolution is fully active; a JVM dispatch difference (class method vs interface method) remains. The byIndex = false rows use the real interface override and are directly comparable between versions.
Static method invocation
| Resolution | feedback |
.NET 8 / T17 | vs 2.6.6 | .NET 10 / T25 | vs 2.6.6 |
|---|---|---|---|---|---|
Invoke |
false |
0.517 µs | −22% | 0.480 µs | −20% |
IWS |
false |
0.356 µs | −28% | 0.335 µs | −19% |
Invoke |
true |
0.609 µs | −32% | 0.575 µs | −28% |
IWS |
true |
0.435 µs | −37% | 0.419 µs | −20% |
Instance method invocation
| Resolution | feedback |
.NET 8 / T17 | vs 2.6.6 | .NET 10 / T25 | vs 2.6.6 |
|---|---|---|---|---|---|
Invoke |
false |
0.349 µs | −40% | 0.301 µs | −39% |
IWS |
false |
0.295 µs | −37% | 0.274 µs | −28% |
Invoke |
true |
0.552 µs | −36% | 0.511 µs | −33% |
IWS |
true |
0.448 µs | −30% | 0.452 µs | −15% |
Callback: TestPredicateRoundTrip
byIndex |
continueFirstCheck |
continueSecondCheck |
.NET 8 / T17 | vs 2.6.6 | .NET 10 / T25 | vs 2.6.6 |
|---|---|---|---|---|---|---|
false |
false |
false |
1.106 µs | — | 1.040 µs | — |
true ¹ |
false |
false |
0.452 µs | — | 0.435 µs | — |
false |
true |
false |
1.126 µs | — | 1.074 µs | — |
true ¹ |
true |
false |
0.502 µs | — | 0.456 µs | — |
false |
true |
true |
5.794 µs | −16% | 5.318 µs | −16% |
true ¹ |
true |
true |
5.023 µs | −28% | 4.628 µs | −27% |
¹ byIndex = true simulated on the JVM side — see note above.
Callback: TestPredicateSustained
byIndex |
continueFirstCheck |
continueSecondCheck |
.NET 8 / T17 | vs 2.6.6 | .NET 10 / T25 | vs 2.6.6 |
|---|---|---|---|---|---|---|
false |
false |
false |
0.601 µs | −90% | 0.468 µs | −92% |
true ¹ |
false |
false |
0.045 µs | — | 0.041 µs | — |
false |
true |
false |
0.625 µs | −90% | 0.493 µs | −91% |
true ¹ |
true |
false |
0.074 µs | — | 0.067 µs | — |
false |
true |
true |
5.098 µs | −17% | 4.725 µs | −15% |
true ¹ |
true |
true |
4.467 µs | −27% | 4.141 µs | −25% |
¹ byIndex = true simulated on the JVM side — see note above.
The realistic JVM-originated callback baseline (full processing, byIndex = false) is 5.1 µs (.NET 8 / T17) and 4.7 µs (.NET 10 / T25).
JCOBridge 2.6.9
JCOBridge 2.6.9 delivers further improvements across all test types through interop layer optimizations, lazy initialization of event data (TypedEventData), and a pooled BatchState buffer that eliminates per-event array allocation in the dispose fast-scope path. Results collected on ubuntu-22.04 / AMD EPYC 9V45 96-Core.
Note
byIndex = true is still simulated on the JVM side — see note in the 2.6.7+ section above.
Static method invocation
| Resolution | feedback |
.NET 8 / T17 | vs 2.6.6 | .NET 10 / T25 | vs 2.6.6 |
|---|---|---|---|---|---|
Invoke |
false |
0.313 µs | −53% | 0.265 µs | −56% |
IWS |
false |
0.248 µs | −50% | 0.250 µs | −40% |
Invoke |
true |
0.471 µs | −48% | 0.482 µs | −40% |
IWS |
true |
0.311 µs | −55% | 0.305 µs | −42% |
Instance method invocation
| Resolution | feedback |
.NET 8 / T17 | vs 2.6.6 | .NET 10 / T25 | vs 2.6.6 |
|---|---|---|---|---|---|
Invoke |
false |
0.189 µs | −67% | 0.176 µs | −64% |
IWS |
false |
0.162 µs | −65% | 0.178 µs | −53% |
Invoke |
true |
0.389 µs | −55% | 0.390 µs | −49% |
IWS |
true |
0.287 µs | −55% | 0.307 µs | −43% |
Callback: TestPredicateRoundTrip
byIndex |
continueFirstCheck |
continueSecondCheck |
.NET 8 / T17 | vs 2.6.6 | .NET 10 / T25 | vs 2.6.6 |
|---|---|---|---|---|---|---|
false |
false |
false |
0.709 µs | −37% | 0.768 µs | −21% |
true ¹ |
false |
false |
0.240 µs | — | 0.252 µs | — |
false |
true |
false |
1.028 µs | −6% | 0.935 µs | −4% |
true ¹ |
true |
false |
0.401 µs | — | 0.451 µs | — |
false |
true |
true |
3.157 µs | −55% | 3.278 µs | −48% |
true ¹ |
true |
true |
2.607 µs | — | 2.713 µs | — |
¹ byIndex = true simulated on the JVM side — see note above.
Callback: TestPredicateSustained
byIndex |
continueFirstCheck |
continueSecondCheck |
.NET 8 / T17 | vs 2.6.6 | .NET 10 / T25 | vs 2.6.6 |
|---|---|---|---|---|---|---|
false |
false |
false |
0.379 µs | −94% | 0.366 µs | −93% |
true ¹ |
false |
false |
0.035 µs | — | 0.030 µs | — |
false |
true |
false |
0.505 µs | −92% | 0.522 µs | −91% |
true ¹ |
true |
false |
0.144 µs | — | 0.152 µs | — |
false |
true |
true |
2.674 µs | −56% | 2.750 µs | −50% |
true ¹ |
true |
true |
2.324 µs | — | 2.278 µs | — |
¹ byIndex = true simulated on the JVM side — see note above.
The realistic JVM-originated callback baseline (full processing, byIndex = false) reaches 2.7 µs on both .NET 8 and .NET 10 — a −56% reduction over 2.6.6.
Callback: BenchmarkDotNet comparison (ubuntu-24.04, Temurin 17 / Temurin 25)
TestPredicateSustained results. For full multi-vendor breakdown see Latest benchmark results.
byIndex |
continueFirstCheck |
continueSecondCheck |
x86-64 .NET 8 / T17 | x86-64 .NET 10 / T25 | ARM64 .NET 8 / T17 | ARM64 .NET 10 / T25 |
|---|---|---|---|---|---|---|
false |
false |
false |
511.5 ns | 497.2 ns | 574.8 ns | 584.7 ns |
true ¹ |
false |
false |
40.0 ns | 30.5 ns | 44.2 ns | 42.6 ns |
false |
true |
false |
689.2 ns | 692.8 ns | 781.4 ns | 800.4 ns |
true ¹ |
true |
false |
185.6 ns | 204.4 ns | 262.9 ns | 255.7 ns |
false |
true |
true |
3,917.6 ns | 4,081.8 ns | 4,059.6 ns | 4,514.7 ns |
true ¹ |
true |
true |
3,263.7 ns | 3,133.3 ns | 3,611.1 ns | 3,627.0 ns |
¹ byIndex = true simulated on the JVM side — see note in the 2.6.7+ section.
The x86-64 first-gate (byIndex = true, F,F) reaches 30.5 ns on .NET 10 / T25 — identical to the x86-64 EPYC stopwatch result (~30–35 ns) and confirming the measurement across two independent methodologies. ARM64 first-gate is 42–44 ns. Full processing on both platforms converges at ~3.1–4.5 µs (BenchmarkDotNet shared runner), with the EPYC stopwatch showing lower numbers (~2.3–2.7 µs) reflecting dedicated hardware advantage.
The three distinct operating points:
First gate only (continueFirstCheck = false) — event discarded before any data is read:
byIndex = false: ~0.38–0.37 µs — string key lookup.byIndex = true: ~35 ns (.NET 8) / ~30 ns (.NET 10) —ListenerShallManageEventIndexpath, pure integer check, no string conversion. ~28–33 M events/sec.
Second gate (continueFirstCheck = true, continueSecondCheck = false) — raw data available for inspection, handler not invoked:
byIndex = false: ~0.51–0.52 µs.byIndex = true: ~144 ns (.NET 8) / ~152 ns (.NET 10) — includes raw data retrieval from JVM.
Full processing (continueFirstCheck = true, continueSecondCheck = true): ~2.7 µs (byIndex = false), ~2.3 µs (byIndex = true).
Bulk data transfer at the JVM↔CLR boundary
JCOBridge 2.6.9 introduces JCOBridgeDirectBuffer<T> (wrapping a JVM DirectByteBuffer) and JCOBridgeStream<T> (wrapping a JVM native array), both with T : unmanaged. Both types expose ToStream() (backed by UnmanagedMemoryStream), ReadOnlySpan<T>, and .NET Framework-compatible shims.
Note
Tests run in a single process without isolation. Memory is pre-allocated once per size step; 100 iterations measure only access/transfer cost. Future benchmarks will use DotNetBenchmark with process isolation for statistically rigorous results.
Array transfer — JCOBridgeStream<T>
A JVM byte[] of the given size is pre-allocated once per size step. Each iteration retrieves the data via three APIs:
Invoke<byte[]>— allocates a .NETbyte[]and copies JVM array data into it. Behavior is the same in both standard and HPA editions; the underlying transfer path can be switched to a lower-overhead mode for small arrays (see below).AreEqualChunked— reads viaJCOBridgeStream<byte>in 4096-byte chunks without allocating a full copy.AsSpan— obtains aReadOnlySpan<byte>fromJCOBridgeStream<byte>:- Standard edition: performs an internal copy regardless of
forceRawMemory(forceRawMemoryis a no-op in standard) - HPA,
forceRawMemory=false: accesses JVM array memory directly — no copy - HPA,
forceRawMemory=true: accesses JVM array memory with GC pinned for the duration — no copy, lowest latency for large arrays; use with care (no JVM allocations or blocking operations during the pinned window)
- Standard edition: performs an internal copy regardless of
Standard edition — AsSpan latency (µs, 100 iterations, default path / optimized path)
The optimized transfer path reduces per-call overhead for small arrays dramatically. For large arrays (≥1 MB) the bottleneck shifts to memory bandwidth and the optimized path offers no advantage.
| Size | .NET 8 default |
.NET 8 optimized |
.NET 10 default |
.NET 10 optimized |
|---|---|---|---|---|
| 10 B | 6.3 | 1.4 | 6.8 | 1.5 |
| 1 KB | 3.5 | 1.4 | 2.5 | 1.1 |
| 10 KB | 4.3 | 2.6 | 3.6 | 1.6 |
| 100 KB | 77.2 | 59.6 | 79.6 | 62.6 |
| 1 MB | 289.2 | 212.8 | 248.7 | 192.2 |
| 10 MB | 2,152.6 | 2,213.4 | 1,449.3 | 1,501.9 |
| 100 MB | 35,962.8 | 59,541.3 | 14,958.8 | 20,502.4 |
The optimized path is beneficial only for small arrays (≤100 KB). For large arrays it can be slower due to GC interaction — use the default path at scale.
HPA edition — AsSpan latency (µs, 100 iterations, before optimized path)
| Size | Standard | HPA forceRaw=false |
HPA forceRaw=true |
vs Standard |
|---|---|---|---|---|
| 10 B .NET 8 | 6.3 | 5.1 | 4.7 | −25% |
| 100 KB .NET 8 | 77.2 | 10.0 | 8.3 | −89% |
| 1 MB .NET 8 | 289.2 | 84.7 | 46.8 | −84% |
| 10 MB .NET 8 | 2,152.6 | 764.2 | 270.8 | −87% |
| 100 MB .NET 8 | 35,962.8 | 17,830.0 | 6,019.2 | −83% |
| 100 KB .NET 10 | 79.6 | 12.6 | 9.2 | −88% |
| 1 MB .NET 10 | 248.7 | 134.6 | 74.4 | −70% |
| 10 MB .NET 10 | 1,449.3 | 858.2 | 314.6 | −78% |
| 100 MB .NET 10 | 14,958.8 | 15,826.7 | 5,626.6 | −62% |
Key observations:
forceRawMemory=falseeliminates the copy for ≥100 KB: −87/−88% at 100 KB, −84/−70% at 1 MB vs standard. For small arrays (≤10 KB) the difference is minor — overhead is dominated by per-call cost, not data movement.forceRawMemory=truegoes further: −83/−62% at 100 MB vs standard — the fastest path for large arrays. The additional gain overforceRawMemory=falseis largest at 10 MB+ where the pinned access eliminates all intermediate buffering.- For small arrays (≤10 KB), all HPA paths are similar to standard — per-call overhead dominates over data transfer cost. Use the optimized transfer path (see standard table above) for small sizes.
ByteBuffer transfer — JCOBridgeDirectBuffer<T>
The benchmark reflects real-world usage: each iteration calls getByteBuffer() on the JVM side, which copies the pre-allocated array into a DirectByteBuffer before returning it to .NET. This includes both the JVM-side copy cost (heap → native memory) and the .NET-side read cost.
Note
JCOBridgeDirectBuffer<T> and EnableCritical / forceRawMemory have no effect on ByteBuffer access — a DirectByteBuffer already lives in native memory and is always accessed via direct pointer in both standard and HPA editions. Standard and HPA produce identical results.
| Size | ToArray .NET 8 |
AsSpan .NET 8 |
ToArray .NET 10 |
AsSpan .NET 10 |
|---|---|---|---|---|
| 10 B | 198.5 µs | 9.8 µs | 192.5 µs | 8.9 µs |
| 100 B | 8.4 µs | 4.7 µs | 8.4 µs | 4.2 µs |
| 1 KB | 6.5 µs | 4.4 µs | 6.1 µs | 4.5 µs |
| 10 KB | 13.9 µs | 5.3 µs | 15.3 µs | 5.7 µs |
| 100 KB | 93.4 µs | 12.3 µs | 90.6 µs | 13.5 µs |
| 1 MB | 552.0 µs | 88.9 µs | 398.0 µs | 103.8 µs |
| 10 MB | 2,674.0 µs | 881.8 µs | 2,535.6 µs | 815.8 µs |
| 100 MB | 44,096.1 µs | 13,474.7 µs | 20,339.0 µs | 11,742.0 µs |
AsSpanis consistently fastest: reads the native memory pointer directly with no .NET allocation.ToArrayat 10 B is expensive (198 µs) becausegetByteBuffer()has per-call JVM overhead that dominates at small sizes —getArray()+Invoke<byte[]>is faster for small payloads.ToStream → Naive(full intermediate MemoryStream copy) is the slowest for large sizes — avoid above a few KB.
Note
ByteBuffer vs HPA array access: the AsSpan ByteBuffer cost at 100 MB (~13.5 ms on .NET 8) reflects two components: the JVM-side copy from heap to native memory (~7.5 ms) and the .NET read from native memory (~6 ms). The .NET read portion matches JCOBridgeStream AsSpan with HPA forceRawMemory=true (~6 ms), confirming that both operations ultimately read from native memory at the same speed. The ByteBuffer pattern adds the JVM-side copy overhead that HPA array access avoids entirely.
Summary
| Test | .NET 8 / T17 | .NET 10 / T25 | ||||
|---|---|---|---|---|---|---|
| 2.6.6 | 2.6.7+ | 2.6.9 | 2.6.6 | 2.6.7+ | 2.6.9 | |
Static Invoke fb=false |
0.661 | 0.517 (−22%) | 0.313 (−53%) | 0.602 | 0.480 (−20%) | 0.265 (−56%) |
Static IWS fb=false |
0.494 | 0.356 (−28%) | 0.248 (−50%) | 0.414 | 0.335 (−19%) | 0.250 (−40%) |
Static Invoke fb=true |
0.901 | 0.609 (−32%) | 0.471 (−48%) | 0.803 | 0.575 (−28%) | 0.482 (−40%) |
Static IWS fb=true |
0.686 | 0.435 (−37%) | 0.311 (−55%) | 0.522 | 0.419 (−20%) | 0.305 (−42%) |
Instance Invoke fb=false |
0.579 | 0.349 (−40%) | 0.189 (−67%) | 0.490 | 0.301 (−39%) | 0.176 (−64%) |
Instance IWS fb=false |
0.468 | 0.295 (−37%) | 0.162 (−65%) | 0.379 | 0.274 (−28%) | 0.178 (−53%) |
Instance Invoke fb=true |
0.856 | 0.552 (−36%) | 0.389 (−55%) | 0.764 | 0.511 (−33%) | 0.390 (−49%) |
Instance IWS fb=true |
0.638 | 0.448 (−30%) | 0.287 (−55%) | 0.535 | 0.452 (−15%) | 0.307 (−43%) |
Sustained: full, byIndex=false |
6.116 | 5.098 (−17%) | 2.674 (−56%) | 5.548 | 4.725 (−15%) | 2.750 (−50%) |
Sustained: full, byIndex=true ¹ |
— | 4.467 | 2.324 | — | 4.141 | 2.278 |
Sustained: 1st gate, byIndex=false |
— | 0.601 | 0.379 | — | 0.468 | 0.366 |
Sustained: 1st gate, byIndex=true ¹ |
— | 0.045 | 0.035 | — | 0.041 | 0.030 |
Sustained: 2nd gate, byIndex=false |
— | 0.625 | 0.505 | — | 0.493 | 0.522 |
Sustained: 2nd gate, byIndex=true ¹ |
— | 0.074 | 0.144 | — | 0.067 | 0.152 |
All values in µs. Percentages vs 2.6.6 where available. ¹ byIndex = true simulated on the JVM side.
Comparison with raw JNI overhead
The 30–35 ns figure for byIndex = true, first-gate discard (Sustained, 2.6.9) is worth contextualizing against published raw JNI benchmarks on dedicated hardware. Independent JMH benchmarks measure an empty JNI call at ~57 ns via JavaCPP and ~22 ns on a modern laptop for a minimal no-op native method (java-native-benchmark, Komanov 2022).
JNet's first-gate discard path (ListenerShallManageEventIndex) involves a JVM→CLR crossing, a numeric index lookup, and an immediate return — all on shared CI infrastructure. Reaching 30–35 ns per event places JNet within the range of raw JNI call overhead measured on dedicated bare-metal hardware, despite the additional CLR interop layer.
Guidance
- Prefer
InvokeWithSignature(IWS) overInvokein hot paths on x86 — it avoids .NET-side type matching and consistently delivers 20–55% lower latency with arguments involved. On ARM64 the two strategies are nearly equivalent for no-argument methods; the advantage ofIWSvaries by JDK vendor (see dashboard). - Constructor resolution strategy does not matter — all three approaches (
NewEmpty,NewWithSignature,DeclaredNewEmpty) cost ~1 µs. Choose whichever is most readable. - Primitive field access is cheap (~100–120 ns); string field writes are expensive (~670 ns, ~6–7× the getter cost). Avoid repeated JVM
Stringfield writes in hot paths — preferint/long/booleanfields or aggregate string data into a single call. - Never pass large
String[]arrays in hot paths — at 100 000 elements aString[]costs ~57 ms vs ~60 µs for an equivalentint[](1000× difference). Prefer primitive arrays, or useJCOBridgeStream<T>for binary bulk transfer. - Varargs with spread elements are the worst case for per-element overhead — each argument is individually boxed. Pass arrays as arrays, not as spread varargs, when the element count is large.
- Multi-parameter invocation costs ~1.3 µs — roughly 4× a no-argument call. Keep argument lists short for frequently called methods.
- The realistic JVM-originated callback reference is
Sustained, full processing,byIndex = false: ~2.7 µs on x86 EPYC (2.6.9), ~4.1–4.5 µs on ARM64 Neoverse-N2. - Use the two-level
ListenerShallManageEventfilter for high-event-rate sources:- First gate (
ListenerShallManageEventIndex) — discard before any data read: ~30–35 ns (x86), ~42–44 ns (ARM64). - Second gate (
ListenerShallManageEventIndexWithData) — inspect raw data before handler: ~144–152 ns (x86), ~256–263 ns (ARM64).
- First gate (
- For bulk data from JVM arrays, prefer
JCOBridgeStream<T>with HPAforceRawMemory=truefor large payloads — up to 83% faster than standard at 100 MB. For small arrays (≤10 KB), the optimized transfer path reduces per-call overhead to ~1–2 µs. - For
DirectByteBufferaccess, useAsSpan— zero-copy from native memory in all editions, ~16 GB/s at 100 MB. AvoidToStream → Naiveabove a few KB. - The
byIndex = truemechanism will deliver its full benefit on the full-processing path once the JVM-side simulation is replaced with real interface dispatch. - If your application runs callbacks at sustained high frequency, consider the JCOBridge HPA edition — it addresses GC-boundary instability under sustained JVM↔CLR call pressure and enables true zero-copy bulk array access.