Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions release-notes/CREDITS-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -520,3 +520,13 @@ Mike Pedersen (@mpdncrwd)
* Reported #1581: `NonBlockingByteBufferParser`: Unexpected Illegal surrogate
character when parsing field names
(2.21.3)

Patrick Strawderman (@kilink)
* Requested #1622: `UTF8JsonGenerator.writeBinary()` should allocate buffer
based on supplied length
(2.23.0)

@seonwooj0810
* Contributed #1622: `UTF8JsonGenerator.writeBinary()` should allocate buffer
based on supplied length
(2.23.0)
5 changes: 4 additions & 1 deletion release-notes/VERSION-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ a pure JSON library.

2.23.0 (not yet released)

No changes since 2.22
#1622: `UTF8JsonGenerator.writeBinary()` should allocate buffer
based on supplied length
(requested by @kilink)
(contributed by @seonwooj0810)

2.22.0 (03-Jun-2026)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ public abstract class JsonGeneratorImpl extends GeneratorBase
*/
protected final static int[] sOutputEscapes = CharTypes.get7BitOutputEscapes();

/**
* Maximum size, in bytes, of the recyclable base64 encoding buffer to
* allocate when a binary content length hint is available (see
* {@code writeBinary(Base64Variant, InputStream, int)}). Allocating a
* larger buffer for big content reduces the number of
* {@link java.io.InputStream} reads required, but the size is capped to
* limit retention of large {@code ThreadLocal}-recycled buffers.
*
* @since 2.23
*/
protected final static int MAX_BASE64_ENCODE_BUFFER_LENGTH = 64 * 1024;

/**
* Default capabilities for JSON generator implementations which do not
* different from "general textual" defaults
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -917,7 +917,11 @@ public int writeBinary(Base64Variant b64variant,
_flushBuffer();
}
_outputBuffer[_outputTail++] = _quoteChar;
byte[] encodingBuffer = _ioContext.allocBase64Buffer();
// [core#1622]: when length is known, size the read buffer accordingly
// (capped) so large content needs fewer InputStream reads
byte[] encodingBuffer = (dataLength > 0)
? _ioContext.allocBase64Buffer(Math.min(dataLength, MAX_BASE64_ENCODE_BUFFER_LENGTH))
: _ioContext.allocBase64Buffer();
int bytes;
try {
if (dataLength < 0) { // length unknown
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,11 @@ public int writeBinary(Base64Variant b64variant,
_flushBuffer();
}
_outputBuffer[_outputTail++] = _quoteChar;
byte[] encodingBuffer = _ioContext.allocBase64Buffer();
// [core#1622]: when length is known, size the read buffer accordingly
// (capped) so large content needs fewer InputStream reads
byte[] encodingBuffer = (dataLength > 0)
? _ioContext.allocBase64Buffer(Math.min(dataLength, MAX_BASE64_ENCODE_BUFFER_LENGTH))
: _ioContext.allocBase64Buffer();
int bytes;
try {
if (dataLength < 0) { // length unknown
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,11 @@ public interface Gettable {
public final static int CHAR_NAME_COPY_BUFFER = 3;

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

private final static int[] BYTE_BUFFER_LENGTHS = new int[] { 8000, 8000, 2000, 2000 };
private final static int[] BYTE_BUFFER_LENGTHS = new int[] { 8000, 8000, 2000, 4000 };
private final static int[] CHAR_BUFFER_LENGTHS = new int[] { 4000, 4000, 200, 200 };

// Note: changed from simple array in 2.10:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.fasterxml.jackson.core.base64;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.StringWriter;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.core.Base64Variant;
import com.fasterxml.jackson.core.Base64Variants;
import com.fasterxml.jackson.core.JsonEncoding;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

// [core#1622]: When a binary content length is known, the encoding/read buffer
// should be sized from that hint (capped) so large content needs far fewer
// InputStream reads than the small default buffer would require.
class BinaryWriteBufferSize1622Test
extends com.fasterxml.jackson.core.JUnit5TestBase
{
// Cap mirrored from JsonGeneratorImpl.MAX_BASE64_ENCODE_BUFFER_LENGTH
private final static int MAX_BUFFER = 64 * 1024;

private final JsonFactory JSON_F = new JsonFactory();

private final Base64Variant VARIANT = Base64Variants.MIME;

/**
* {@link ByteArrayInputStream} that records the largest {@code len} ever
* requested via {@link #read(byte[], int, int)}, which equals the size of
* the read buffer the generator allocated.
*/
static class ReadSizeRecordingInputStream extends ByteArrayInputStream {
int maxRequestedRead = 0;

ReadSizeRecordingInputStream(byte[] buf) {
super(buf);
}

@Override
public synchronized int read(byte[] b, int off, int len) {
if (len > maxRequestedRead) {
maxRequestedRead = len;
}
return super.read(b, off, len);
}
}

@Test
void sizeHintAppliedByteBacked() throws Exception {
// 50_000 is below the 64kB cap, so the read buffer should be sized to it
_testSizeHint(true, 50_000, 50_000);
}

@Test
void sizeHintAppliedCharBacked() throws Exception {
_testSizeHint(false, 50_000, 50_000);
}

@Test
void sizeHintCappedByteBacked() throws Exception {
// 200_000 exceeds the cap, so the read buffer should be limited to it
_testSizeHint(true, 200_000, MAX_BUFFER);
}

@Test
void sizeHintCappedCharBacked() throws Exception {
_testSizeHint(false, 200_000, MAX_BUFFER);
}

private void _testSizeHint(boolean useBytes, int dataLength, int expectedMaxRead)
throws Exception
{
byte[] input = new byte[dataLength];
for (int i = 0; i < input.length; ++i) {
input[i] = (byte) (i * 31 + 7);
}
ReadSizeRecordingInputStream in = new ReadSizeRecordingInputStream(input);

byte[] rawJson;
if (useBytes) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (JsonGenerator g = JSON_F.createGenerator(out, JsonEncoding.UTF8)) {
g.writeBinary(VARIANT, in, dataLength);
}
rawJson = out.toByteArray();
} else {
StringWriter sw = new StringWriter();
try (JsonGenerator g = JSON_F.createGenerator(sw)) {
g.writeBinary(VARIANT, in, dataLength);
}
rawJson = sw.toString().getBytes("UTF-8");
}

// The generator should have requested reads as large as the (capped) hint,
// which is much bigger than the small default buffer used before the fix.
assertEquals(expectedMaxRead, in.maxRequestedRead,
"read buffer should be sized from the length hint (capped)");
assertTrue(in.maxRequestedRead <= MAX_BUFFER,
"read buffer must never exceed the cap");

// ...and the produced base64 must still decode back to the original bytes.
try (JsonParser p = JSON_F.createParser(rawJson)) {
assertEquals(JsonToken.VALUE_STRING, p.nextToken());
byte[] decoded = p.getBinaryValue(VARIANT);
assertArrayEquals(input, decoded);
}
}
}