Skip to content

Commit 4ab1768

Browse files
oschwaldclaude
andcommitted
Avoid off-heap buffer cache growth in MEMORY mode
`FileChannel.read(ByteBuffer)` with a heap-backed buffer causes the JDK to substitute a temporary direct buffer obtained from a per-thread cache (`sun.nio.ch.Util.BufferCache`). With chunk sizes near `Integer.MAX_VALUE`, a single MEMORY-mode database load leaves up to ~2 GB of direct memory cached on the loading thread for that thread's lifetime. Repeated loads on different threads compound the growth. Open the database via `FileInputStream` and delegate to the existing chunked `InputStream` read path. `FileInputStream.read(byte[])` is implemented natively without going through the NIO buffer cache, so it avoids the leak entirely. The MMAP path is unchanged, since `FileChannel.map()` does not use the cache. Note: `Files.readAllBytes()` and `Files.newInputStream()` would NOT fix this, as both are backed by `Channels.newInputStream(FileChannel)` internally and still trigger the cache. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aaa1c12 commit 4ab1768

2 files changed

Lines changed: 62 additions & 46 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
CHANGELOG
22
=========
33

4+
4.1.0
5+
------------------
6+
7+
* Fixed unbounded off-heap memory growth when initializing the reader in
8+
`FileMode.MEMORY`. The previous implementation read the database via
9+
`FileChannel.read()` into a heap buffer, which causes the JDK to cache
10+
temporary direct ByteBuffers in per-thread storage
11+
(`sun.nio.ch.Util.BufferCache`). Repeated initialization across different
12+
threads could grow this cache without bound. The reader now uses
13+
`FileInputStream` for `MEMORY` mode, which bypasses the cache.
14+
`FileMode.MEMORY_MAPPED` was unaffected.
15+
416
4.0.2 (2025-12-08)
517
------------------
618

src/main/java/com/maxmind/db/BufferHolder.java

Lines changed: 50 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.maxmind.db.Reader.FileMode;
44
import java.io.ByteArrayOutputStream;
55
import java.io.File;
6+
import java.io.FileInputStream;
67
import java.io.IOException;
78
import java.io.InputStream;
89
import java.io.RandomAccessFile;
@@ -23,53 +24,36 @@ final class BufferHolder {
2324
}
2425

2526
BufferHolder(File database, FileMode mode, int chunkSize) throws IOException {
26-
try (RandomAccessFile file = new RandomAccessFile(database, "r");
27-
FileChannel channel = file.getChannel()) {
28-
long size = channel.size();
29-
if (mode == FileMode.MEMORY) {
27+
if (mode == FileMode.MEMORY) {
28+
// FileInputStream avoids the per-thread direct ByteBuffer cache that
29+
// FileChannel.read() populates when reading into a heap buffer. That cache
30+
// retains the largest direct buffer ever requested — under chunked MEMORY
31+
// mode that would mean chunkSize bytes of off-heap memory held per loader
32+
// thread for the JVM's lifetime.
33+
try (FileInputStream stream = new FileInputStream(database)) {
34+
long size = database.length();
35+
var name = database.getName();
3036
if (size <= chunkSize) {
31-
// Allocate, read, and make read-only
32-
ByteBuffer buffer = ByteBuffer.allocate((int) size);
33-
if (channel.read(buffer) != size) {
34-
throw new IOException("Unable to read "
35-
+ database.getName()
36-
+ " into memory. Unexpected end of stream.");
37-
}
38-
buffer.flip();
39-
this.buffer = new SingleBuffer(buffer);
37+
this.buffer = SingleBuffer.wrap(readFully(stream, (int) size, name));
4038
} else {
41-
// Allocate chunks, read, and make read-only
4239
var fullChunks = (int) (size / chunkSize);
4340
var remainder = (int) (size % chunkSize);
4441
var totalChunks = fullChunks + (remainder > 0 ? 1 : 0);
4542
var buffers = new ByteBuffer[totalChunks];
46-
4743
for (int i = 0; i < fullChunks; i++) {
48-
buffers[i] = ByteBuffer.allocate(chunkSize);
44+
buffers[i] = ByteBuffer.wrap(readFully(stream, chunkSize, name));
4945
}
5046
if (remainder > 0) {
51-
buffers[totalChunks - 1] = ByteBuffer.allocate(remainder);
47+
buffers[totalChunks - 1] = ByteBuffer.wrap(
48+
readFully(stream, remainder, name));
5249
}
53-
54-
var totalRead = 0L;
55-
for (var buffer : buffers) {
56-
var read = channel.read(buffer);
57-
if (read == -1) {
58-
break;
59-
}
60-
totalRead += read;
61-
buffer.flip();
62-
}
63-
64-
if (totalRead != size) {
65-
throw new IOException("Unable to read "
66-
+ database.getName()
67-
+ " into memory. Unexpected end of stream.");
68-
}
69-
7050
this.buffer = new MultiBuffer(buffers, chunkSize);
7151
}
72-
} else {
52+
}
53+
} else {
54+
try (RandomAccessFile file = new RandomAccessFile(database, "r");
55+
FileChannel channel = file.getChannel()) {
56+
long size = channel.size();
7357
if (size <= chunkSize) {
7458
this.buffer = SingleBuffer.mapFromChannel(channel);
7559
} else {
@@ -79,11 +63,32 @@ final class BufferHolder {
7963
}
8064
}
8165

82-
BufferHolder(InputStream stream, int chunkSize) throws IOException {
66+
BufferHolder(InputStream stream, int chunkSize) throws IOException {
8367
if (null == stream) {
8468
throw new NullPointerException("Unable to use a NULL InputStream");
8569
}
70+
this.buffer = readFromStream(stream, chunkSize);
71+
}
72+
73+
// Pre-allocates exactly len bytes. Used by file-backed MEMORY mode where the size is
74+
// known up front, avoiding the transient peak from ByteArrayOutputStream.grow() and
75+
// the defensive copy in toByteArray().
76+
private static byte[] readFully(InputStream stream, int len, String name) throws IOException {
77+
var data = new byte[len];
78+
var totalRead = 0;
79+
while (totalRead < len) {
80+
var n = stream.read(data, totalRead, len - totalRead);
81+
if (n < 0) {
82+
throw new IOException("Unable to read "
83+
+ name
84+
+ " into memory. Unexpected end of stream.");
85+
}
86+
totalRead += n;
87+
}
88+
return data;
89+
}
8690

91+
private static Buffer readFromStream(InputStream stream, int chunkSize) throws IOException {
8792
// Read data from the stream in chunks to support databases >2GB.
8893
// Invariant: All chunks except the last are exactly chunkSize bytes.
8994
var chunks = new ArrayList<byte[]>();
@@ -116,17 +121,16 @@ final class BufferHolder {
116121

117122
if (chunks.size() == 1) {
118123
// For databases that fit in a single chunk, use SingleBuffer
119-
this.buffer = SingleBuffer.wrap(chunks.get(0));
120-
} else {
121-
// For large databases, wrap chunks in ByteBuffers and use MultiBuffer
122-
// Guaranteed: chunks[0..n-2] all have length == chunkSize
123-
// chunks[n-1] may have length < chunkSize
124-
var buffers = new ByteBuffer[chunks.size()];
125-
for (var i = 0; i < chunks.size(); i++) {
126-
buffers[i] = ByteBuffer.wrap(chunks.get(i));
127-
}
128-
this.buffer = new MultiBuffer(buffers, chunkSize);
124+
return SingleBuffer.wrap(chunks.get(0));
125+
}
126+
// For large databases, wrap chunks in ByteBuffers and use MultiBuffer
127+
// Guaranteed: chunks[0..n-2] all have length == chunkSize
128+
// chunks[n-1] may have length < chunkSize
129+
var buffers = new ByteBuffer[chunks.size()];
130+
for (var i = 0; i < chunks.size(); i++) {
131+
buffers[i] = ByteBuffer.wrap(chunks.get(i));
129132
}
133+
return new MultiBuffer(buffers, chunkSize);
130134
}
131135

132136
/*

0 commit comments

Comments
 (0)