Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 3 additions & 1 deletion release-notes/VERSION-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ a pure JSON library.

2.23.0 (not yet released)

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

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 16000 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, 16000 };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know the answer here but is there a reason not to keep this ordered? 8000 > 2000 so maybe the 16000 should go first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. This array isn't sorted by value — it's positional: each slot is addressed by the named index constants just above it, and byteBufferLength(int ix) returns BYTE_BUFFER_LENGTHS[ix]. So the entries are in index order:

  • [0] BYTE_READ_IO_BUFFER = 8000
  • [1] BYTE_WRITE_ENCODING_BUFFER = 8000
  • [2] BYTE_WRITE_CONCAT_BUFFER = 2000
  • [3] BYTE_BASE64_CODEC_BUFFER = 16000 ← the slot this PR bumps

The 16000 lands at the end only because the base64 codec buffer happens to be index 3; reordering the array would silently remap every buffer's length to the wrong purpose. Happy to add a brief inline comment naming each slot if you think that'd make the positional intent clearer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah these must not be ordered by size, they are indexed by position as @seonwooj0810 pointed out.

However, I realized something: this will also affect parser-side, if changed; Base64-decoding buffer (in addition to generator-size encoding buffer).

And in fact, not quite sure default really needs changing: if and when actual size is indicated, we'll be using that anyway. I think I'll change default to 4000 as compromise.

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);
}
}
}