Skip to content

Commit dff0fb5

Browse files
authored
Fix #1622: size base64 encoding buffer from binary length hint (#1624)
1 parent 7669590 commit dff0fb5

7 files changed

Lines changed: 155 additions & 4 deletions

File tree

release-notes/CREDITS-2.x

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,3 +520,13 @@ Mike Pedersen (@mpdncrwd)
520520
* Reported #1581: `NonBlockingByteBufferParser`: Unexpected Illegal surrogate
521521
character when parsing field names
522522
(2.21.3)
523+
524+
Patrick Strawderman (@kilink)
525+
* Requested #1622: `UTF8JsonGenerator.writeBinary()` should allocate buffer
526+
based on supplied length
527+
(2.23.0)
528+
529+
@seonwooj0810
530+
* Contributed #1622: `UTF8JsonGenerator.writeBinary()` should allocate buffer
531+
based on supplied length
532+
(2.23.0)

release-notes/VERSION-2.x

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ a pure JSON library.
1616

1717
2.23.0 (not yet released)
1818

19-
No changes since 2.22
19+
#1622: `UTF8JsonGenerator.writeBinary()` should allocate buffer
20+
based on supplied length
21+
(requested by @kilink)
22+
(contributed by @seonwooj0810)
2023

2124
2.22.0 (03-Jun-2026)
2225

src/main/java/com/fasterxml/jackson/core/json/JsonGeneratorImpl.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ public abstract class JsonGeneratorImpl extends GeneratorBase
3030
*/
3131
protected final static int[] sOutputEscapes = CharTypes.get7BitOutputEscapes();
3232

33+
/**
34+
* Maximum size, in bytes, of the recyclable base64 encoding buffer to
35+
* allocate when a binary content length hint is available (see
36+
* {@code writeBinary(Base64Variant, InputStream, int)}). Allocating a
37+
* larger buffer for big content reduces the number of
38+
* {@link java.io.InputStream} reads required, but the size is capped to
39+
* limit retention of large {@code ThreadLocal}-recycled buffers.
40+
*
41+
* @since 2.23
42+
*/
43+
protected final static int MAX_BASE64_ENCODE_BUFFER_LENGTH = 64 * 1024;
44+
3345
/**
3446
* Default capabilities for JSON generator implementations which do not
3547
* different from "general textual" defaults

src/main/java/com/fasterxml/jackson/core/json/UTF8JsonGenerator.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -917,7 +917,11 @@ public int writeBinary(Base64Variant b64variant,
917917
_flushBuffer();
918918
}
919919
_outputBuffer[_outputTail++] = _quoteChar;
920-
byte[] encodingBuffer = _ioContext.allocBase64Buffer();
920+
// [core#1622]: when length is known, size the read buffer accordingly
921+
// (capped) so large content needs fewer InputStream reads
922+
byte[] encodingBuffer = (dataLength > 0)
923+
? _ioContext.allocBase64Buffer(Math.min(dataLength, MAX_BASE64_ENCODE_BUFFER_LENGTH))
924+
: _ioContext.allocBase64Buffer();
921925
int bytes;
922926
try {
923927
if (dataLength < 0) { // length unknown

src/main/java/com/fasterxml/jackson/core/json/WriterBasedJsonGenerator.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,11 @@ public int writeBinary(Base64Variant b64variant,
695695
_flushBuffer();
696696
}
697697
_outputBuffer[_outputTail++] = _quoteChar;
698-
byte[] encodingBuffer = _ioContext.allocBase64Buffer();
698+
// [core#1622]: when length is known, size the read buffer accordingly
699+
// (capped) so large content needs fewer InputStream reads
700+
byte[] encodingBuffer = (dataLength > 0)
701+
? _ioContext.allocBase64Buffer(Math.min(dataLength, MAX_BASE64_ENCODE_BUFFER_LENGTH))
702+
: _ioContext.allocBase64Buffer();
699703
int bytes;
700704
try {
701705
if (dataLength < 0) { // length unknown

src/main/java/com/fasterxml/jackson/core/util/BufferRecycler.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,11 @@ public interface Gettable {
9191
public final static int CHAR_NAME_COPY_BUFFER = 3;
9292

9393
// Buffer lengths
94+
// 22-Jun-2026, [core#1622]: bumped default base64 codec buffer (index
95+
// BYTE_BASE64_CODEC_BUFFER) from 2000 to 4000 to reduce InputStream
96+
// reads when encoding binary content of unknown/large length.
9497

95-
private final static int[] BYTE_BUFFER_LENGTHS = new int[] { 8000, 8000, 2000, 2000 };
98+
private final static int[] BYTE_BUFFER_LENGTHS = new int[] { 8000, 8000, 2000, 4000 };
9699
private final static int[] CHAR_BUFFER_LENGTHS = new int[] { 4000, 4000, 200, 200 };
97100

98101
// Note: changed from simple array in 2.10:
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package com.fasterxml.jackson.core.base64;
2+
3+
import java.io.ByteArrayInputStream;
4+
import java.io.ByteArrayOutputStream;
5+
import java.io.StringWriter;
6+
7+
import org.junit.jupiter.api.Test;
8+
9+
import com.fasterxml.jackson.core.Base64Variant;
10+
import com.fasterxml.jackson.core.Base64Variants;
11+
import com.fasterxml.jackson.core.JsonEncoding;
12+
import com.fasterxml.jackson.core.JsonFactory;
13+
import com.fasterxml.jackson.core.JsonGenerator;
14+
import com.fasterxml.jackson.core.JsonParser;
15+
import com.fasterxml.jackson.core.JsonToken;
16+
17+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
18+
import static org.junit.jupiter.api.Assertions.assertEquals;
19+
import static org.junit.jupiter.api.Assertions.assertTrue;
20+
21+
// [core#1622]: When a binary content length is known, the encoding/read buffer
22+
// should be sized from that hint (capped) so large content needs far fewer
23+
// InputStream reads than the small default buffer would require.
24+
class BinaryWriteBufferSize1622Test
25+
extends com.fasterxml.jackson.core.JUnit5TestBase
26+
{
27+
// Cap mirrored from JsonGeneratorImpl.MAX_BASE64_ENCODE_BUFFER_LENGTH
28+
private final static int MAX_BUFFER = 64 * 1024;
29+
30+
private final JsonFactory JSON_F = new JsonFactory();
31+
32+
private final Base64Variant VARIANT = Base64Variants.MIME;
33+
34+
/**
35+
* {@link ByteArrayInputStream} that records the largest {@code len} ever
36+
* requested via {@link #read(byte[], int, int)}, which equals the size of
37+
* the read buffer the generator allocated.
38+
*/
39+
static class ReadSizeRecordingInputStream extends ByteArrayInputStream {
40+
int maxRequestedRead = 0;
41+
42+
ReadSizeRecordingInputStream(byte[] buf) {
43+
super(buf);
44+
}
45+
46+
@Override
47+
public synchronized int read(byte[] b, int off, int len) {
48+
if (len > maxRequestedRead) {
49+
maxRequestedRead = len;
50+
}
51+
return super.read(b, off, len);
52+
}
53+
}
54+
55+
@Test
56+
void sizeHintAppliedByteBacked() throws Exception {
57+
// 50_000 is below the 64kB cap, so the read buffer should be sized to it
58+
_testSizeHint(true, 50_000, 50_000);
59+
}
60+
61+
@Test
62+
void sizeHintAppliedCharBacked() throws Exception {
63+
_testSizeHint(false, 50_000, 50_000);
64+
}
65+
66+
@Test
67+
void sizeHintCappedByteBacked() throws Exception {
68+
// 200_000 exceeds the cap, so the read buffer should be limited to it
69+
_testSizeHint(true, 200_000, MAX_BUFFER);
70+
}
71+
72+
@Test
73+
void sizeHintCappedCharBacked() throws Exception {
74+
_testSizeHint(false, 200_000, MAX_BUFFER);
75+
}
76+
77+
private void _testSizeHint(boolean useBytes, int dataLength, int expectedMaxRead)
78+
throws Exception
79+
{
80+
byte[] input = new byte[dataLength];
81+
for (int i = 0; i < input.length; ++i) {
82+
input[i] = (byte) (i * 31 + 7);
83+
}
84+
ReadSizeRecordingInputStream in = new ReadSizeRecordingInputStream(input);
85+
86+
byte[] rawJson;
87+
if (useBytes) {
88+
ByteArrayOutputStream out = new ByteArrayOutputStream();
89+
try (JsonGenerator g = JSON_F.createGenerator(out, JsonEncoding.UTF8)) {
90+
g.writeBinary(VARIANT, in, dataLength);
91+
}
92+
rawJson = out.toByteArray();
93+
} else {
94+
StringWriter sw = new StringWriter();
95+
try (JsonGenerator g = JSON_F.createGenerator(sw)) {
96+
g.writeBinary(VARIANT, in, dataLength);
97+
}
98+
rawJson = sw.toString().getBytes("UTF-8");
99+
}
100+
101+
// The generator should have requested reads as large as the (capped) hint,
102+
// which is much bigger than the small default buffer used before the fix.
103+
assertEquals(expectedMaxRead, in.maxRequestedRead,
104+
"read buffer should be sized from the length hint (capped)");
105+
assertTrue(in.maxRequestedRead <= MAX_BUFFER,
106+
"read buffer must never exceed the cap");
107+
108+
// ...and the produced base64 must still decode back to the original bytes.
109+
try (JsonParser p = JSON_F.createParser(rawJson)) {
110+
assertEquals(JsonToken.VALUE_STRING, p.nextToken());
111+
byte[] decoded = p.getBinaryValue(VARIANT);
112+
assertArrayEquals(input, decoded);
113+
}
114+
}
115+
}

0 commit comments

Comments
 (0)