Hi,

I converted 149 carriers to value classes, taking a Java host layer from 6 to
155, and measured the result on 28-ea+10-569 with --enable-preview, six
A/B-counterbalanced pairs per leg against its own preceding tag.

One scoping note, because it would otherwise be the first thing asked. The
measured application is a benchmark target, not a production deployment.

What is unusual is the host layer itself. Its object model was designed after JEP 401 rather than retrofitted to it: 155 value carriers, buffers held as Panama MemorySegments, request paths on virtual threads. Exactly one identity record survives the conversion anywhere in its own modules -- a bootstrap dependency graph whose Map components are mutated in place, so it cannot be a value class. It is reached once at startup and never on the request path, but I mention it because "mutable state requires identity" turned out to be the
only limit the sweep actually hit.

One caveat worth reporting, one question I cannot answer from outside.

1. jdk.CodeCacheStatistics adaptorCount is a coverage measure, not a code
   property -- which cost me two wrong readings before I noticed.

Per-run counts are not stable for a fixed binary. Diffing class-load sets
   between two runs of one arm that differed by 2 adapters: the raw sets
differ by 932 absent and 931 extra (java.lang.invoke LambdaForm and hidden classes, whose names carry a per-run address), for a net of -1 that means nothing. After excluding generated and hidden classes, the two runs differ by exactly two classes and none extra -- a pair of conditionally-loaded JFR
   event classes for one code path. Runs that agree on adaptorCount have
   identical stable class sets.

Separately, the count also depends on the recording configuration. Two campaigns of the same two binaries, differing only in whether the host layer's own JFR event types were enabled (application-defined jdk.jfr.Event subclasses, not jdk.* events), give an E->F adapter delta of 150 and of
   144, against 149 converted carriers either way. So there is no stable
adapters-per-carrier relationship to read off; the same conversion looks like +1 or -5 depending on what the recording enabled. I have not shown that this is the same mechanism as the class-loading difference above -- the classes load either way once their call site is reached, so if the two
   are related it is through instrumentation rather than loading.

I am not reporting this as a defect. I am reporting it because adaptorCount
   is the obvious metric to reach for when costing a scalarised calling
convention, and it appears to answer a broader question than that. Chasing
   it into the source: jfrPeriodic.cpp sets the field from
CodeCache::adapter_count(bt), which delegates to CodeHeap::adapter_count() -- a count of adapter blobs in the code cache, alongside blob_count and nmethod_count. So it is the general i2c/c2i signature-adapter machinery, rather than a counter added for value classes. The field has no documented semantics that I could find, which is how I ended up reading it as one.

   The one thing I could not determine, and it may be the more useful
   question. The two classes above are plain final classes extending
jdk.jfr.Event, with String and boolean fields only -- not value classes, and JFR's event model does not admit value-typed fields anyway. They are also classes the JVM rewrites: jdk.jfr.Event's commit(), shouldCommit() and isEnabled() are final and empty in the source, so a registered subclass is bytecode-instrumented and gains generated methods. Loading one is therefore
   not a plain class load.

Following that through does not demolish the measurement, though it moves the ground under it. AdapterHandlerLibrary generates the i2c/c2i stubs and
   AdapterBlob is where they live, and under Valhalla those adapters are
   precisely where a scalarised convention is translated to and from a
buffered one -- unpacking fields into registers inbound, re-buffering them outbound. So the counter is general in category, but a delta between two builds with identical class sets and identical call sites should still be attributable to the convention change. If that reading is wrong, that is
   the thing I would most like corrected.

What I cannot settle is the keying. Blobs are cached per signature, not per class, so 150 blobs against 149 converted carriers may be structure or may be coincidence, and my two-classes-two-adapters case does not distinguish the two. I also have a separate seven-adapter step whose class cluster I
   never isolated.

2. What does an absent NULL_FREE_NON_ATOMIC field layout mean?

With -XX:+UnlockDiagnosticVMOptions -XX:+PrintFieldLayout, a value record with two int components (8-byte payload) prints no NULL_FREE_NON_ATOMIC layout but does print NULL_FREE_ATOMIC 8/8. That reads inverted, since non-atomic is the weaker guarantee. The column is empty for every carrier I probed, including ones where NULLABLE_NON_ATOMIC has no size limit at all (33/8 for a 32-byte payload), which suggests a generation rule rather than
   a capability limit.

My indirect evidence: ValueClass.newNullRestrictedNonAtomicArray on an
   8-byte two-String carrier returns an array whose layout kind is
NULL_FREE_ATOMIC_FLAT -- a non-atomic request served by an atomic layout. But that is array allocation, a different path from field-layout emission,
   so it makes supersession plausible without establishing it.

So: for a value class whose payload fits one word, is the absence of a
   NULL_FREE_NON_ATOMIC field layout because no valid layout exists, or
   because the atomic layout supersedes it?

The durable number from all this, for whatever it is worth as a data point: at an unchanged loaded-class count, 149 conversions cost +0.18 MB of Metaspace, with compressed class space flat to slightly negative and non-class metadata carrying the growth. Throughput, CPU per request and p99 did not move outside
the noise band. The host layer keeps its payload off-heap behind
MemorySegment, so I expected little layout upside; the metadata cost is the part I had not seen quantified on a codebase whose object model was designed
around the JEP.
If such a measurement exists I would be glad to read it.

Full method and raw figures:

https://github.com/exeris-systems/exeris-benchmarks/blob/main/results/reports/2026-08-26-valhalla-carrier-sweep-on-an-off-heap-runtime.md#4-the-measurable-effect-is-a-cost

Arkadiusz Przychocki

Reply via email to