Reduce garbage of repeated WritableJson.toByteArray() calls

Update the default implementation of `WritableJson.toByteArray()`
to reduce the amount of garbage created from repeated calls.

Prior to this commit, each call would create a new
`ByteArrayOutputStream` and `OutputStreamWriter` to create the
byte array. Writing structured JSON results in many calls to
the `toByteArray()` method, which means we repeatedly create
and destroy the `ByteArrayOutputStream` and `OutputStreamWriter`
objects. Furthermore, both contain buffers that are often
expanded and will overlap with each other.

The updated implementation uses a custom `Appendable`
implementation that uses a single `ByteBuffer` buffer. It also
has a `ThreadLocal` cache so that repeated calls from the same
thread can reuse the buffer. The cache uses a `SoftReference`
to ensure that the JVM can reclaim space if needed (for example,
if a large JSON line was written).

Closes gh-49428
This commit is contained in:
Phillip Webb
2026-04-30 12:04:39 -07:00
parent 72b176e85b
commit 2fffebe214
3 changed files with 228 additions and 4 deletions
@@ -0,0 +1,133 @@
/*
* Copyright 2012-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.json;
import java.io.IOException;
import java.lang.ref.SoftReference;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.CoderResult;
import java.nio.charset.CodingErrorAction;
import org.springframework.util.Assert;
/**
* {@link Appendable} implementation that can be used to return a byte array. Designed to
* reduce memory pressure for {@link WritableJson#toByteArray(Charset)} by using a single
* cached buffer scoped to the thread.
*
* @author Phillip Webb
*/
class AppendableByteArray implements Appendable {
private static ThreadLocal<SoftReference<AppendableByteArray>> cache = new ThreadLocal<>();
private static final int DEFAULT_INITIAL_SIZE = 8192;
private static final int DEFAULT_EXPANSION_SIZE = 8192;
private static final byte[] NO_BYTES = {};
private final Charset charset;
private final CharsetEncoder encoder;
private final int expansionSize;
private ByteBuffer out;
AppendableByteArray(Charset charset) {
this(charset, DEFAULT_INITIAL_SIZE, DEFAULT_EXPANSION_SIZE);
}
AppendableByteArray(Charset charset, int initialSize, int expansionSize) {
this.charset = charset;
this.encoder = charset.newEncoder()
.onMalformedInput(CodingErrorAction.REPLACE)
.onUnmappableCharacter(CodingErrorAction.REPLACE);
this.out = ByteBuffer.allocate(initialSize);
this.expansionSize = expansionSize;
}
@Override
public AppendableByteArray append(CharSequence charSequence, int start, int end) throws IOException {
return append(((charSequence != null) ? charSequence : "null").subSequence(start, end));
}
@Override
public AppendableByteArray append(CharSequence charSequence) throws IOException {
return append(String.valueOf(charSequence).toCharArray());
}
@Override
public AppendableByteArray append(char ch) throws IOException {
return append(new char[] { ch });
}
private AppendableByteArray append(char[] chars) throws IOException {
return (chars.length != 0) ? append(CharBuffer.wrap(chars)) : this;
}
private AppendableByteArray append(CharBuffer in) throws IOException {
CoderResult result = this.encoder.encode(in, this.out, false);
if (result.isUnderflow()) {
return this;
}
if (result.isOverflow()) {
ByteBuffer out = this.out;
this.out = ByteBuffer.allocate(out.capacity() + this.expansionSize);
out.flip();
this.out.put(out);
return append(in);
}
result.throwException();
return this;
}
byte[] toByteArray() {
this.out.flip();
int limit = this.out.limit();
int position = this.out.position();
int size = limit - position;
if (size <= 0) {
return NO_BYTES;
}
byte[] result = new byte[size];
System.arraycopy(this.out.array(), this.out.arrayOffset() + position, result, 0, size);
reset();
return result;
}
private void reset() {
this.out.clear();
this.encoder.reset();
}
static AppendableByteArray get(Charset charset) {
Assert.notNull(charset, "'charset' must not be null");
SoftReference<AppendableByteArray> cached = cache.get();
AppendableByteArray result = (cached != null) ? cached.get() : null;
if (result == null || !result.charset.equals(charset)) {
result = new AppendableByteArray(charset);
cache.set(new SoftReference<>(result));
}
return result;
}
}
@@ -16,7 +16,6 @@
package org.springframework.boot.json;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
@@ -76,9 +75,10 @@ public interface WritableJson {
*/
default byte[] toByteArray(Charset charset) {
Assert.notNull(charset, "'charset' must not be null");
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
toWriter(new OutputStreamWriter(out, charset));
return out.toByteArray();
try {
AppendableByteArray appendable = AppendableByteArray.get(charset);
to(appendable);
return appendable.toByteArray();
}
catch (IOException ex) {
throw new UncheckedIOException(ex);
@@ -0,0 +1,91 @@
/*
* Copyright 2012-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.json;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.function.Function;
import org.junit.jupiter.api.Test;
import org.springframework.util.function.ThrowingConsumer;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link AppendableByteArray}.
*
* @author Phillip Webb
*/
class AppendableByteArrayTests {
private static String string = """
This is a long(ish) string.
At least it's longer that the initial size and the overflow size.
We can write it out and test if the bytes match.
""";
@Test
void writesLargeStringWithExpandingBuffer() throws Exception {
assertByteArray(StandardCharsets.UTF_8, (appendable) -> appendable.append(string));
assertByteArray(StandardCharsets.UTF_16, (appendable) -> appendable.append(string));
}
@Test
void writesLargeStringWithLargeBuffer() throws Exception {
assertByteArray(string.length() * 10, 10, StandardCharsets.UTF_8, (appendable) -> appendable.append(string));
assertByteArray(string.length() * 10, 10, StandardCharsets.UTF_16, (appendable) -> appendable.append(string));
}
@Test
void writesMultipleSmallStrings() throws Exception {
assertByteArray(StandardCharsets.UTF_8, (appendable) -> appendable.append("{").append("hello").append("}"));
}
@Test
void writeUsingCache() throws IOException {
assertByteArray(StandardCharsets.UTF_8, AppendableByteArray::get, (appendable) -> appendable.append(string));
assertByteArray(StandardCharsets.UTF_8, AppendableByteArray::get, (appendable) -> appendable.append(string));
assertByteArray(StandardCharsets.UTF_16, AppendableByteArray::get, (appendable) -> appendable.append(string));
assertByteArray(StandardCharsets.UTF_16, AppendableByteArray::get, (appendable) -> appendable.append(string));
assertByteArray(StandardCharsets.UTF_8, AppendableByteArray::get, (appendable) -> appendable.append(string));
}
private void assertByteArray(Charset charset, ThrowingConsumer<Appendable> action) throws Exception {
assertByteArray(4, 4, charset, action);
}
private void assertByteArray(int initialSize, int expansionSize, Charset charset,
ThrowingConsumer<Appendable> action) throws IOException {
assertByteArray(charset, (cs) -> new AppendableByteArray(charset, initialSize, expansionSize), action);
}
private void assertByteArray(Charset charset, Function<Charset, AppendableByteArray> factory,
ThrowingConsumer<Appendable> action) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (OutputStreamWriter writer = new OutputStreamWriter(out, charset)) {
action.accept(writer);
}
AppendableByteArray appendableByteArray = factory.apply(charset);
action.accept(appendableByteArray);
assertThat(appendableByteArray.toByteArray()).isEqualTo(out.toByteArray());
}
}