Avoid per-checksum boxing in Java9IntHash by using MethodHandle.invokeExact - #4876
Open
lhotari wants to merge 1 commit into
Open
Avoid per-checksum boxing in Java9IntHash by using MethodHandle.invokeExact#4876lhotari wants to merge 1 commit into
lhotari wants to merge 1 commit into
Conversation
…eExact
Java9IntHash reaches java.util.zip.CRC32C's private updateBytes and
updateDirectByteBuffer through java.lang.reflect.Method.invoke, which takes its
arguments as an Object[]. Every checksum therefore boxes the running CRC, the
buffer address and both offsets, and allocates the array to hold them — 24-40
bytes per call, once per checksummed buffer.
This is the path taken whenever the SSE 4.2 native library is unavailable, which
is every platform except x86-64 Linux: the published circe-checksum jar contains
a single native library, lib/libcirce-checksum.so, built from crc32c_sse42.cpp
and linked for x86-64. ARM hosts, macOS and Windows all fall through to this
class and pay the boxing on every entry.
Bind the two methods to method handles instead and call them with invokeExact.
The arguments are passed as primitives, nothing is allocated, and the JIT can
inline through a static final handle. Lookup.unreflect skips its own access check
for a Method whose accessible flag is already set, so this needs no Java 9+
lookup API and still compiles at source level 8.
DigestTypeBenchmark, CRC32_C over a pooled direct buffer, JDK 21 on aarch64:
entry size throughput (ops/ms) allocation (B/op)
64 350,622 -> 510,521 (+46%) 24 -> ~0
1024 20,765 -> 21,050 (+1%) 40 -> ~0
Larger entries are dominated by the checksum itself, so the win there is the
allocation rather than the throughput: 8.0 GB/s of garbage at 64-byte entries
becomes none at all.
Also covers the direct-buffer path, which no test reached before — it is the only
caller of updateDirectByteBuffer, whose (int, long, int, int)int shape has to be
matched exactly by invokeExact, and a mismatch fails at runtime rather than at
compile time.
lhotari
requested review from
StevenLuMT,
eolivelli,
hangc0276,
hezhangjian,
merlimat and
zymap
September 5, 2026 10:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Java9IntHashreachesjava.util.zip.CRC32C's privateupdateBytesandupdateDirectByteBufferthrough
java.lang.reflect.Method.invoke.Method.invoketakes its arguments as anObject[], soevery checksum boxes the running CRC, the buffer address and both offsets, and allocates the array to
hold them. That happens once per checksummed buffer, on the ledger write path.
This is the fallback used on every platform without the SSE 4.2 native library, which is every
platform except x86-64 Linux.
Crc32cIntChecksumpicksJniIntHashonly whenSse42Crc32C.isSupported(), and the publishedcirce-checksumjar contains exactly one nativelibrary:
It is built from
crc32c_sse42.cpp, so it is x86-only by construction. On ARM — Graviton,Ampere, Apple Silicon — the load fails on the wrong architecture; on macOS and Windows there
is no
.dylib/.dllin the jar at all. All of them fall through toJava9IntHashand pay the boxingon every entry. Deployments on x86-64 Linux take the native path and never see this.
I found it profiling a Pulsar broker on aarch64 under a write-heavy workload: allocation sampling put
9% of all broker allocation in
Java9IntHash.updateDirectByteBuffer→Long.valueOf/Integer.valueOf, and the broker was GC-bound.Changes
Bind the two methods to
MethodHandles and call them withinvokeExact. The arguments are passed asprimitives, nothing is allocated, and the JIT can inline through a
static finalhandle.Lookup.unreflectskips its own access check for aMethodwhose accessible flag is already set,which is what the existing code establishes with
setAccessible(true). So this needs no Java 9+lookup API (no
privateLookupIn) and still compiles at source level 8, which matters forbranch-4.17 and earlier if this is worth backporting. Behaviour when
java.util.zip.CRC32Ccannot bereached is unchanged —
HAS_JAVA9_CRC32Cstays false andCrc32cIntChecksumfalls back toJava8IntHash.One behavioural difference worth noting:
invokeExactdoes not wrap an exception raised by thetarget the way
Method.invokedoes withInvocationTargetException, so an unchecked exception nowpropagates unchanged instead of nested inside a
RuntimeException.Results
DigestTypeBenchmark,digest=CRC32_C,bufferType=BYTE_BUF_DEFAULT_ALLOC, JDK 21.0.11 onaarch64 (so the JNI path is unavailable and
Java9IntHashis in use),-f 2 -wi 5 -i 5:Larger entries are dominated by the checksum computation itself, so there the win is the allocation
rather than the throughput — 8 GB/s of garbage at 64-byte entries becomes none at all, and
gc.countfor that run goes from 240 to 0.Tests
Java9IntHashTestgains a case that checksJava9IntHashagainstJava8IntHash— an independentimplementation of the same algorithm — over a direct buffer, a heap buffer and a buffer with neither
an array nor a memory address, plus the incremental
resumeform.The direct-buffer case is the one that needed adding: it is the only caller of
updateDirectByteBuffer, whose(int, long, int, int)intshape has to be matched exactly byinvokeExact, and a mismatch fails at runtime withWrongMethodTypeExceptionrather than at compiletime. No existing test reached it — I confirmed that by swapping the two offset arguments, which the
new test catches and the pre-existing tests do not.
mvn -pl circe-checksum testpasses (79 tests), as do thebookkeeper-serverdigest tests.