Skip to content

Commit 74815f0

Browse files
authored
Merge pull request #368 from maxmind/greg/stf-383
STF-383: Fix off-heap memory growth in FileMode.MEMORY
2 parents d09c292 + 232dd73 commit 74815f0

6 files changed

Lines changed: 147 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
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+
* Fixed a latent short-read bug in the multi-chunk `FileMode.MEMORY`
16+
load path introduced in 4.0.0. `FileChannel.read(ByteBuffer)` is not
17+
contractually obligated to fully fill the destination buffer; a
18+
short read could have caused silent truncation of an in-memory
19+
database. Affects databases larger than ~2GB (the default chunk
20+
size). The new chunked read loop retries until each chunk is fully
21+
populated.
22+
423
4.0.2 (2025-12-08)
524
------------------
625

mise.lock

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mise.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[settings]
2+
experimental = true
3+
lockfile = true
4+
disable_backends = [
5+
"asdf",
6+
"vfox",
7+
]
8+
9+
[tools]
10+
java = "latest"
11+
maven = "latest"
12+
13+
[hooks]
14+
enter = "mise install --quiet --locked"
15+
16+
[[watch_files]]
17+
patterns = ["mise.toml", "mise.lock"]
18+
run = "mise install --quiet --locked"

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

Lines changed: 52 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,38 @@ 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+
// Size from the open fd (fstat) so it's atomic with the open;
35+
// getChannel().size() does not populate the buffer cache.
36+
long size = stream.getChannel().size();
37+
var name = database.getName();
3038
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);
39+
this.buffer = SingleBuffer.wrap(readFully(stream, (int) size, name));
4040
} else {
41-
// Allocate chunks, read, and make read-only
4241
var fullChunks = (int) (size / chunkSize);
4342
var remainder = (int) (size % chunkSize);
4443
var totalChunks = fullChunks + (remainder > 0 ? 1 : 0);
4544
var buffers = new ByteBuffer[totalChunks];
46-
4745
for (int i = 0; i < fullChunks; i++) {
48-
buffers[i] = ByteBuffer.allocate(chunkSize);
46+
buffers[i] = ByteBuffer.wrap(readFully(stream, chunkSize, name));
4947
}
5048
if (remainder > 0) {
51-
buffers[totalChunks - 1] = ByteBuffer.allocate(remainder);
49+
buffers[totalChunks - 1] = ByteBuffer.wrap(
50+
readFully(stream, remainder, name));
5251
}
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-
7052
this.buffer = new MultiBuffer(buffers, chunkSize);
7153
}
72-
} else {
54+
}
55+
} else {
56+
try (RandomAccessFile file = new RandomAccessFile(database, "r");
57+
FileChannel channel = file.getChannel()) {
58+
long size = channel.size();
7359
if (size <= chunkSize) {
7460
this.buffer = SingleBuffer.mapFromChannel(channel);
7561
} else {
@@ -79,11 +65,32 @@ final class BufferHolder {
7965
}
8066
}
8167

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

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

117124
if (chunks.size() == 1) {
118125
// 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);
126+
return SingleBuffer.wrap(chunks.get(0));
127+
}
128+
// For large databases, wrap chunks in ByteBuffers and use MultiBuffer
129+
// Guaranteed: chunks[0..n-2] all have length == chunkSize
130+
// chunks[n-1] may have length < chunkSize
131+
var buffers = new ByteBuffer[chunks.size()];
132+
for (var i = 0; i < chunks.size(); i++) {
133+
buffers[i] = ByteBuffer.wrap(chunks.get(i));
129134
}
135+
return new MultiBuffer(buffers, chunkSize);
130136
}
131137

132138
/*

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,12 @@ public Reader(File database) throws IOException {
5959
}
6060

6161
Reader(File database, int chunkSize) throws IOException {
62+
this(database, FileMode.MEMORY_MAPPED, chunkSize);
63+
}
64+
65+
Reader(File database, FileMode fileMode, int chunkSize) throws IOException {
6266
this(
63-
new BufferHolder(database, FileMode.MEMORY_MAPPED, chunkSize),
67+
new BufferHolder(database, fileMode, chunkSize),
6468
database.getName(),
6569
NoCache.getInstance()
6670
);

src/test/java/com/maxmind/db/ReaderTest.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import static org.junit.jupiter.api.Assertions.assertThrows;
1313
import static org.junit.jupiter.api.Assertions.assertTrue;
1414

15+
import com.maxmind.db.Reader.FileMode;
1516
import java.io.File;
1617
import java.io.IOException;
1718
import java.io.InputStream;
@@ -83,6 +84,24 @@ public void test(int chunkSize) throws IOException {
8384
}
8485
}
8586

87+
@ParameterizedTest
88+
@MethodSource("chunkSizes")
89+
public void testMemoryMode(int chunkSize) throws IOException {
90+
for (long recordSize : new long[] {24, 28, 32}) {
91+
for (int ipVersion : new int[] {4, 6}) {
92+
var file = getFile("MaxMind-DB-test-ipv" + ipVersion + "-" + recordSize + ".mmdb");
93+
try (var reader = new Reader(file, FileMode.MEMORY, chunkSize)) {
94+
this.testMetadata(reader, ipVersion, recordSize);
95+
if (ipVersion == 4) {
96+
this.testIpV4(reader, file);
97+
} else {
98+
this.testIpV6(reader, file);
99+
}
100+
}
101+
}
102+
}
103+
}
104+
86105
static class GetRecordTest {
87106
InetAddress ip;
88107
File db;

0 commit comments

Comments
 (0)