You are viewing a plain text version of this content. The canonical link for it is here.
Posted to oak-issues@jackrabbit.apache.org by "Nuno Santos (Jira)" <ji...@apache.org> on 2026/07/20 14:15:00 UTC
[jira] [Created] (OAK-12325) High-frequency direct ByteBuffer allocation in AbstractBlob.equals() may cause large bursts of native memory usage leading to OOMKill risk
Nuno Santos created OAK-12325:
---------------------------------
Summary: High-frequency direct ByteBuffer allocation in AbstractBlob.equals() may cause large bursts of native memory usage leading to OOMKill risk
Key: OAK-12325
URL: https://issues.apache.org/jira/browse/OAK-12325
Project: Jackrabbit Oak
Issue Type: Improvement
Components: store-spi
Reporter: Nuno Santos
h2. Problem
{{AbstractBlob.equal(Blob, Blob)}} falls back to a full byte-wise content comparison ({{IOUtils.contentEquals(InputStream, InputStream)}}) whenever two blobs have the same length but no matching content identity (e.g. different blob IDs, or one side not a {{SegmentBlob}}). Since Oak's Commons IO dependency was upgraded from 2.18.0 to 2.20.0 ([OAK commit upgrading commons-io|https://github.com/apache/jackrabbit-oak/commit/4994014580]), this call has silently changed behavior: Commons IO's {{IOUtils.contentEquals()}}, when both streams can be adapted to a {{ReadableByteChannel}} (true whenever the underlying stream is file-backed, e.g. a FileStore-backed blob), now delegates to {{FileChannels.contentEquals(ReadableByteChannel, ReadableByteChannel, int)}}, which allocates *two direct ByteBuffers per call* to do the chunked comparison. Previously (both in the Guava {{ByteSource.contentEquals()}} this replaced, and in Commons IO versions prior to 2.19), the comparison was done with in-heap byte arrays.
This comparison sits on hot paths in any workload with a high commit/write rate:
* The segment writer ({{DefaultSegmentWriter$SegmentWriteOperation.writeNodeUncached()}}) calls {{property.equals(beforeProperty)}} to detect whether a property is unchanged, so it can reuse the existing record instead of writing a new one.
* Diff/rebase logic ({{ModifiedNodeState.compareAgainstBaseState}} / {{SegmentNodeState.compareProperties}}) uses the same equality check to compute which properties changed before building a commit.
Both paths funnel into {{SegmentBlob.equals()}} → {{AbstractBlob.equal()}} whenever one side of the comparison isn't itself a {{SegmentBlob}} — which is the common case whenever a freshly-received, in-memory blob is compared against an already-persisted one (a fresh write, or an incoming diff). This bypasses {{SegmentBlob}}'s cheaper blobId-based fast-equals shortcut entirely, since that shortcut requires *both* sides to be {{SegmentBlob}} instances.
h2. Impact
Under sustained high-throughput writes/commits, native-level allocation profiling attributed roughly 1GB of direct buffer allocation to this single call path within a 5-minute window (~450 calls/sec) — by far the largest source of native allocation observed in the process.
This isn't a classic leak (the buffers are reachable and eventually collected), but it behaves like one under load:
# A direct {{ByteBuffer}}'s backing native memory is only released when the JVM's GC determines the Java-heap-side wrapper object is unreachable and runs its {{Cleaner}} (a {{PhantomReference}}-based mechanism) — this happens _asynchronously_, lagging behind allocation by however long it takes for that object to be collected.
# Under sustained load, new buffers are allocated faster than GC-driven cleanup can reclaim the old ones, so native memory usage climbs steadily even though each individual buffer is short-lived.
# This memory is allocated via {{Unsafe.allocateMemory}}, which HotSpot implements as a call to its own {{os::malloc}} (tagged {{mtInternal}}), and sits outside the JVM heap/{{-Xmx}} budget — it's governed instead by {{-XX:MaxDirectMemorySize}} (defaults to {{-Xmx}} if unset). {{jcmd <pid> VM.native_memory summary}} lumps this growth into the generic *Internal* category, which doesn't by itself distinguish direct-buffer growth from other native allocation sources without switching to NMT detail mode with call-site tracking.
# Since JVM heap and NMT accounting don't reflect this growth clearly, and the OS-level OOM killer acts on actual process RSS, there is no JVM-internal backpressure to slow allocation or force reclamation before the process is killed for exceeding its memory limit.
The net effect: process memory climbs until the process is killed, with nothing in standard JVM tooling (heap dumps, NMT summary view) clearly pointing at the cause, even though NMT detail mode and direct-buffer-specific metrics ({{jvm_buffers_direct_count/used/capacity}}) do capture it.
h2. Why direct buffers are the wrong tool here
Direct buffers exist to avoid an extra copy when doing actual native I/O — e.g. handing a buffer straight to a socket or file read/write syscall. There is no such benefit here: this is a transient, sequential, byte-for-byte comparison of two streams, entirely internal to the JVM. Using off-heap memory just adds native allocation/deallocation overhead and the GC-lag problem above, with no offsetting I/O gain.
h2. History
* {{AbstractBlob.equals()}} originally used Guava's {{ByteSource.contentEquals()}}, which does an in-memory comparison using temporary byte arrays.
* A [2025-01-17 PR|https://github.com/apache/jackrabbit-oak/commit/c0c114631f66861a53ec193972784ec4d21c95f] replaced this with Apache Commons IO's {{IOUtils.contentEquals()}}. At that time, the Commons IO version in use also did in-heap comparison, so behavior was unchanged.
* A [2025-03-04 Commons IO change|https://github.com/apache/commons-io/commit/c832eb20796b25d8ee2437a8663616b1112cd7bd] replaced Commons IO's in-heap comparison with {{FileChannels.contentEquals()}}, landing in Commons IO 2.19.
* A [2025-07-28 Oak PR|https://github.com/apache/jackrabbit-oak/commit/4994014580] upgraded the Commons IO dependency from 2.18.0 to 2.20.0, pulling in the direct-buffer-based comparison and silently changing {{AbstractBlob.equals()}}'s memory behavior.
h2. Fix
Replace the direct-buffer-based comparison with a heap-based one: two reusable heap {{byte\[\]}} buffers, {{InputStream.readNBytes()}} to correctly handle short reads, and {{Arrays.equals()}} for the actual comparison. Heap allocations are TLAB-allocated and reclaimed promptly by ordinary generational GC — no {{Cleaner}}/{{PhantomReference}} lag, no native memory footprint, same comparison semantics.
Also wrap the streams obtained from {{Blob.getNewStream()}} in try-with-resources, fixing a related pre-existing resource leak: the original code never closed either stream, leaking a file descriptor on every comparison whenever the underlying blob stream was file-backed.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)