Large-message heap regression fixed
v0.8.0's serialization rewrite (#12) introduced a heap regression for large messages written to direct buffers, and its O(1) clear() (#11) let reused message instances retain the previous message's data. Together they OOMed Apache Pulsar's proxy back-pressure test (ProxyPatternConsumerBackPressureMultipleConsumersTest: ~4.6 MB topic-list responses, 500 concurrent requests through broker and proxy in one JVM) on the upgrade to 0.8.0 (apache/pulsar#26256). Both are fixed in this release; with 0.8.1 that test passes 500/500 with tens of MB of heap to spare, where 0.8.0 ran out of heap at 275/500.
Performance vs v0.7.3
JDK 26, Apple M-series, interleaved A/B rounds, pooled direct buffers as targets. Time per operation, lower is better; v0.7.3 is the last release before the serialization rewrite.
| v0.7.3 | v0.8.1 | Δ | |
|---|---|---|---|
| Serialize (small) | |||
| Pulsar BaseCommand (~50 B) | 52.3 ns | 23.0 ns | -56% |
| Pulsar MessageMetadata (~70 B) | 81.2 ns | 70.4 ns | -13% |
| AddressBook | 47.5 ns | 42.2 ns | -11% |
| AddressBook fill + serialize | 79.5 ns | 69.0 ns | -13% |
| Frame (tiny) | 17.2 ns | 6.5 ns | -62% |
| Deserialize | |||
| Pulsar BaseCommand | 28.5 ns | 21.9 ns | -23% |
| Pulsar MessageMetadata | 72.5 ns | 43.0 ns | -41% |
| AddressBook | 52.4 ns | 38.2 ns | -27% |
| Frame (tiny) | 10.3 ns | 10.9 ns | +6% |
| Frame + read strings | 26.0 ns | 19.7 ns | -24% |
| Serialize (large, to a direct buffer) | |||
| topic list 600 B | 28.4 ns | 27.1 ns | -5% |
| topic list 1 KB | 42.5 ns | 40.3 ns | -5% |
| topic list 3 KB | 97.5 ns | 95.2 ns | -2% |
| varint-dense 2 KB | 0.62 µs | 0.75 µs | +22% |
| topic list 16 KB | 0.41 µs | 0.41 µs | -2% |
| topic list 100 KB | 3.99 µs | 3.86 µs | -3% |
| bytes payload 2 MB | 41 µs | 40 µs | -2% |
| topic list 4.6 MB | 175 µs | 166 µs | -5% |
Small messages. v0.8.0's gains over v0.7.3 are retained (this release does not touch the small-message path): Pulsar BaseCommand serialize −56%, MessageMetadata deserialize −41%, BaseCommand deserialize −23%, AddressBook deserialize −27%. Part of the gap is that on JDK 24+ every sun.misc.Unsafe memory access carries a per-call check that v0.7.3's generated code pays and v0.8.x's does not.
Large messages. Serialization into direct buffers is back to in-place, zero-copy, zero-allocation writes, as in v0.7.3 but without sun.misc.Unsafe: from 600 B to 4.6 MB v0.8.1 is at parity to slightly faster than v0.7.3 (−2…−8%). The one exception is the varint-dense shape (+22%): a per-byte ByteBuffer.put still costs more than a raw Unsafe.putByte, which bulk-data-heavy messages don't notice. Against v0.8.0 — the release this one fixes — the same rows are 21–44% faster from 600 B to 100 KB and 54–70% faster on the multi-MB cases, which no longer allocate anything.
Write direct buffers in place through their NIO view above 512 bytes (#20)
Since #12, writeTo() to a non-array buffer staged the message in a heap byte[] scratch and bulk-copied it; above the 1 MiB retention cap that meant a fresh full-size array on every write (multi-MB humongous allocations, invisible to any accounting sized to the target buffer), and below it the copy itself was still paid. A single-region direct buffer exposes its memory as a java.nio.ByteBuffer (ByteBuf.internalNioBuffer); absolute puts on it compile to a bounds check plus a jdk.internal.misc.Unsafe store — no JDK 24+ deprecation tax — so messages larger than NIO_WRITE_MIN (512 bytes) are now written in place. Smaller messages keep the scratch path (the view's fixed per-put cost outweighs the copy it saves there), as do composite buffers. The field emitters are parameterized over the write sink, so one emitter produces both the array and the NIO-view writer.
clear() releases data references for messages above 64 KiB (#19)
Since #11, clear() only reset counts and presence bits, so a reused instance — one per connection in Pulsar's decoder, thread-locals in Commands — pinned the previous message's strings and buffers until the same fields were overwritten. clear() now gates on the previous message's size (known in O(1) from _cachedSize): at or below CLEAR_RETAIN_MAX (64 KiB) it is unchanged; above it, or when the size is unknown, a generated _clearAndRelease() nulls the retained references and recurses into nested messages unconditionally (so a large message spread over many small children releases everything). Messages with no reference-bearing fields skip the gate entirely; elsewhere it is a single unsigned compare.
Also included
NonArrayTargetIdentityTestsweeps sizes byte by byte across every internal boundary on direct, offset and multi-component composite targets;NioWriteTestandClearReleaseTestpin the new routing, allocation and release behavior (allocation viaThreadMXBean, retention viaWeakReferences).LargeMessageBenchmarkcovers 600 B to 4.6 MB topic lists, varint-dense messages and a 2 MB payload.- central-publishing-maven-plugin 0.11.0 (#17).