Skip to content

Commit 04e4b03

Browse files
authored
Optimize GC usage in JSON marshalling (#6935)
* Add SdkByteArrayOutputStream, zero-copy streaming, and writeBinaryValue optimizations Optimization 3: SdkByteArrayOutputStream - a hybrid ByteArrayOutputStream that caps buffer growth at 128 KB and overflows into fixed 64 KB chunks, eliminating G1GC humongous object allocations for large payloads. Optimization 4: Zero-copy content streaming via contentStreamProvider() and contentSize() on StructuredJsonGenerator/SdkJsonGenerator, avoiding the toByteArray() copy in finishMarshalling(). SdkJsonGenerator now uses SdkByteArrayOutputStream as its internal buffer. Also adds writeBinaryValue(byte[]) to StructuredJsonGenerator and SdkJsonGenerator, and updates the SDK_BYTES switch case to use asByteArrayUnsafe() to bypass the ByteBuffer allocation and copyBytesFrom copy. Includes the large SDK_BYTES equivalence test (200 KB payload) that exercises the SdkByteArrayOutputStream overflow path. These optimizations build on the megamorphic dispatch and registry caching changes in alexwoo/json_marshall_opt. * Minor cleanups * Add partial read unit tests
1 parent fe8f840 commit 04e4b03

9 files changed

Lines changed: 876 additions & 10 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "bugfix",
3+
"category": "AWS SDK for Java v2",
4+
"contributor": "",
5+
"description": "Optimized GC usage (specifically G1GC humongous allocations) in JSON marshalling"
6+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.awssdk.protocols.json;
17+
18+
import java.io.ByteArrayInputStream;
19+
import java.io.ByteArrayOutputStream;
20+
import java.io.IOException;
21+
import java.io.InputStream;
22+
import java.io.OutputStream;
23+
import java.io.SequenceInputStream;
24+
import java.util.ArrayList;
25+
import java.util.Collections;
26+
import java.util.List;
27+
import software.amazon.awssdk.annotations.NotThreadSafe;
28+
import software.amazon.awssdk.annotations.SdkInternalApi;
29+
import software.amazon.awssdk.http.ContentStreamProvider;
30+
31+
/**
32+
* A {@link ByteArrayOutputStream} subclass that behaves identically to the JDK implementation for
33+
* small payloads, but caps internal buffer growth to avoid large object allocations for
34+
* large payloads.
35+
*
36+
* <p>
37+
* Writes flow into the inherited {@code ByteArrayOutputStream} buffer
38+
* normally. When a write would cause the buffer to grow beyond {@link #MAX_BUFFER_SIZE}, the
39+
* current buffer contents are frozen and subsequent writes go into fixed-size chunks
40+
* ({@link #CHUNK_SIZE} bytes each). No single allocation ever exceeds {@code MAX_BUFFER_SIZE}.
41+
*
42+
* <p>
43+
* The stream is in "chunked mode" when {@code chunks != null}. This is derived from state
44+
* rather than tracked by a separate boolean, eliminating a class of state-sync bugs.
45+
*
46+
*/
47+
@NotThreadSafe
48+
@SdkInternalApi
49+
final class SdkByteArrayOutputStream extends ByteArrayOutputStream {
50+
// 128 KB, chosen to be well below 1 MB "humongous threshold" for most heap sizes
51+
static final int MAX_BUFFER_SIZE = 128 * 1024;
52+
static final int CHUNK_SIZE = 64 * 1024;
53+
54+
private List<byte[]> chunks;
55+
private int chunkOffset;
56+
private int chunkedBytes;
57+
58+
SdkByteArrayOutputStream(int initialCapacity) {
59+
super(initialCapacity);
60+
}
61+
62+
@Override
63+
public void write(int b) {
64+
if (chunks != null) {
65+
ensureChunkCapacity(1);
66+
currentChunk()[chunkOffset++] = (byte) b;
67+
chunkedBytes++;
68+
} else if (count + 1 > MAX_BUFFER_SIZE) {
69+
startChunking();
70+
write(b);
71+
} else {
72+
super.write(b);
73+
}
74+
}
75+
76+
@Override
77+
public void write(byte[] b, int off, int len) {
78+
if (chunks != null) {
79+
writeToChunks(b, off, len);
80+
} else if (count + len > MAX_BUFFER_SIZE) {
81+
// Write what fits into the base buffer, then chunk the rest
82+
int fits = MAX_BUFFER_SIZE - count;
83+
if (fits > 0) {
84+
super.write(b, off, fits);
85+
}
86+
startChunking();
87+
writeToChunks(b, off + fits, len - fits);
88+
} else {
89+
super.write(b, off, len);
90+
}
91+
}
92+
93+
/**
94+
* Returns the total number of bytes written (base buffer + chunks).
95+
*/
96+
@Override
97+
public int size() {
98+
return count + chunkedBytes;
99+
}
100+
101+
/**
102+
* Returns all written data as a single contiguous byte array. Exists for backward
103+
* compatibility via {@link #toByteArray()} but should not be used on the hot path.
104+
*/
105+
@Override
106+
public byte[] toByteArray() {
107+
if (chunks == null) {
108+
return super.toByteArray();
109+
}
110+
int total = size();
111+
byte[] result = new byte[total];
112+
// Copy base buffer
113+
System.arraycopy(buf, 0, result, 0, count);
114+
// Copy chunks
115+
int destOff = count;
116+
for (int i = 0; i < chunks.size(); i++) {
117+
int len = (i < chunks.size() - 1) ? chunks.get(i).length : chunkOffset;
118+
System.arraycopy(chunks.get(i), 0, result, destOff, len);
119+
destOff += len;
120+
}
121+
return result;
122+
}
123+
124+
/**
125+
* Resets this stream so that all currently accumulated output is discarded, including any
126+
* chunks. After calling this method, the stream can be reused as if freshly constructed.
127+
*/
128+
@Override
129+
public void reset() {
130+
super.reset();
131+
chunks = null;
132+
chunkOffset = 0;
133+
chunkedBytes = 0;
134+
}
135+
136+
/**
137+
* Writes the complete contents of this stream to the specified output stream, including
138+
* any chunks.
139+
*/
140+
@Override
141+
public void writeTo(OutputStream out) throws IOException {
142+
if (chunks == null) {
143+
super.writeTo(out);
144+
return;
145+
}
146+
// Write base buffer
147+
out.write(buf, 0, count);
148+
// Write chunks
149+
for (int i = 0; i < chunks.size(); i++) {
150+
int len = (i < chunks.size() - 1) ? chunks.get(i).length : chunkOffset;
151+
out.write(chunks.get(i), 0, len);
152+
}
153+
}
154+
155+
/**
156+
* Returns a {@link ContentStreamProvider} that streams directly from the internal buffers
157+
* without creating a contiguous copy. For small payloads this wraps the single base buffer;
158+
* for large payloads it chains the base buffer and chunks via {@link SequenceInputStream}.
159+
*/
160+
ContentStreamProvider contentStreamProvider() {
161+
if (chunks == null) {
162+
// Small payload: single buffer, wrap directly.
163+
// Safe to capture buf because callers (SdkJsonGenerator.contentStreamProvider()) close the
164+
// generator before calling this, guaranteeing no further writes will mutate buf.
165+
byte[] b = buf;
166+
int c = count;
167+
return () -> new ByteArrayInputStream(b, 0, c);
168+
}
169+
170+
// Large payload: chain base buffer + chunks
171+
byte[] baseBuf = buf;
172+
int baseCount = count;
173+
List<byte[]> capturedChunks = chunks;
174+
int lastChunkLen = chunkOffset;
175+
176+
return () -> {
177+
List<InputStream> streams = new ArrayList<>(1 + capturedChunks.size());
178+
streams.add(new ByteArrayInputStream(baseBuf, 0, baseCount));
179+
for (int i = 0; i < capturedChunks.size(); i++) {
180+
int len = (i < capturedChunks.size() - 1) ? capturedChunks.get(i).length : lastChunkLen;
181+
streams.add(new ByteArrayInputStream(capturedChunks.get(i), 0, len));
182+
}
183+
return new SequenceInputStream(Collections.enumeration(streams));
184+
};
185+
}
186+
187+
/**
188+
* Returns the content size without copying.
189+
*/
190+
int contentSize() {
191+
return size();
192+
}
193+
194+
private void startChunking() {
195+
chunks = new ArrayList<>();
196+
chunks.add(new byte[CHUNK_SIZE]);
197+
chunkOffset = 0;
198+
chunkedBytes = 0;
199+
}
200+
201+
private void writeToChunks(byte[] b, int off, int len) {
202+
int remaining = len;
203+
int srcOff = off;
204+
while (remaining > 0) {
205+
ensureChunkCapacity(1);
206+
int space = currentChunk().length - chunkOffset;
207+
int toCopy = Math.min(remaining, space);
208+
System.arraycopy(b, srcOff, currentChunk(), chunkOffset, toCopy);
209+
chunkOffset += toCopy;
210+
chunkedBytes += toCopy;
211+
srcOff += toCopy;
212+
remaining -= toCopy;
213+
}
214+
}
215+
216+
private byte[] currentChunk() {
217+
return chunks.get(chunks.size() - 1);
218+
}
219+
220+
private void ensureChunkCapacity(int needed) {
221+
if (chunkOffset + needed > currentChunk().length) {
222+
chunks.add(new byte[CHUNK_SIZE]);
223+
chunkOffset = 0;
224+
}
225+
}
226+
}

core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/SdkJsonGenerator.java

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@
1515

1616
package software.amazon.awssdk.protocols.json;
1717

18-
import java.io.ByteArrayOutputStream;
1918
import java.io.IOException;
2019
import java.math.BigDecimal;
2120
import java.math.BigInteger;
2221
import java.nio.ByteBuffer;
2322
import java.time.Instant;
2423
import software.amazon.awssdk.annotations.SdkProtectedApi;
2524
import software.amazon.awssdk.core.exception.SdkClientException;
25+
import software.amazon.awssdk.http.ContentStreamProvider;
2626
import software.amazon.awssdk.thirdparty.jackson.core.JsonFactory;
2727
import software.amazon.awssdk.thirdparty.jackson.core.JsonGenerator;
2828
import software.amazon.awssdk.utils.BinaryUtils;
@@ -39,7 +39,7 @@ public class SdkJsonGenerator implements StructuredJsonGenerator {
3939
* prevent frequent resizings but small enough to avoid wasted allocations for small requests.
4040
*/
4141
private static final int DEFAULT_BUFFER_SIZE = 1024;
42-
private final ByteArrayOutputStream baos = new ByteArrayOutputStream(DEFAULT_BUFFER_SIZE);
42+
private final SdkByteArrayOutputStream baos = new SdkByteArrayOutputStream(DEFAULT_BUFFER_SIZE);
4343
private final JsonGenerator generator;
4444
private final String contentType;
4545

@@ -206,6 +206,16 @@ public StructuredJsonGenerator writeValue(ByteBuffer bytes) {
206206
return this;
207207
}
208208

209+
@Override
210+
public StructuredJsonGenerator writeBinaryValue(byte[] bytes) {
211+
try {
212+
generator.writeBinary(bytes);
213+
} catch (IOException e) {
214+
throw new JsonGenerationException(e);
215+
}
216+
return this;
217+
}
218+
209219
@Override
210220
//TODO: This date formatting is coupled to AWS's format. Should generalize it
211221
public StructuredJsonGenerator writeValue(Instant instant) {
@@ -277,6 +287,24 @@ public byte[] getBytes() {
277287
return baos.toByteArray();
278288
}
279289

290+
/**
291+
* Returns the size of the generated content in bytes without copying.
292+
*/
293+
public int contentSize() {
294+
close();
295+
return baos.contentSize();
296+
}
297+
298+
/**
299+
* Returns a {@link ContentStreamProvider} that streams directly from the internal buffers
300+
* without creating a contiguous copy. For small payloads this wraps the single base buffer;
301+
* for large payloads it chains the base buffer and overflow chunks.
302+
*/
303+
public ContentStreamProvider contentStreamProvider() {
304+
close();
305+
return baos.contentStreamProvider();
306+
}
307+
280308
@Override
281309
public String getContentType() {
282310
return contentType;

core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/StructuredJsonGenerator.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515

1616
package software.amazon.awssdk.protocols.json;
1717

18+
import java.io.ByteArrayInputStream;
1819
import java.math.BigDecimal;
1920
import java.math.BigInteger;
2021
import java.nio.ByteBuffer;
2122
import java.time.Instant;
2223
import software.amazon.awssdk.annotations.SdkProtectedApi;
24+
import software.amazon.awssdk.http.ContentStreamProvider;
2325

2426
/**
2527
* Interface for generating a JSON
@@ -102,6 +104,11 @@ public StructuredJsonGenerator writeValue(ByteBuffer bytes) {
102104
return this;
103105
}
104106

107+
@Override
108+
public StructuredJsonGenerator writeBinaryValue(byte[] bytes) {
109+
return this;
110+
}
111+
105112
@Override
106113
public StructuredJsonGenerator writeValue(Instant instant) {
107114
return this;
@@ -169,6 +176,15 @@ default StructuredJsonGenerator writeValue(byte val) {
169176

170177
StructuredJsonGenerator writeValue(ByteBuffer bytes);
171178

179+
/**
180+
* Writes binary data directly from a byte array, avoiding the overhead of wrapping in a
181+
* {@link ByteBuffer}. The default implementation wraps the array and delegates to
182+
* {@link #writeValue(ByteBuffer)}.
183+
*/
184+
default StructuredJsonGenerator writeBinaryValue(byte[] bytes) {
185+
return writeValue(ByteBuffer.wrap(bytes));
186+
}
187+
172188
StructuredJsonGenerator writeValue(Instant instant);
173189

174190
StructuredJsonGenerator writeNumber(String number);
@@ -184,4 +200,28 @@ default StructuredJsonGenerator writeValue(byte val) {
184200
*/
185201
@Deprecated
186202
String getContentType();
203+
204+
/**
205+
* Returns the size of the generated content in bytes without copying. The default
206+
* implementation falls back to {@link #getBytes()}.length.
207+
*/
208+
default int contentSize() {
209+
byte[] bytes = getBytes();
210+
return bytes == null ? 0 : bytes.length;
211+
}
212+
213+
/**
214+
* Returns a {@link ContentStreamProvider} that streams the generated content. The default
215+
* implementation wraps the result of {@link #getBytes()} in a {@code ByteArrayInputStream}.
216+
* Implementations may override this to stream directly from internal buffers without copying.
217+
*
218+
* @return a content stream provider, or {@code null} if {@link #getBytes()} returns null
219+
*/
220+
default ContentStreamProvider contentStreamProvider() {
221+
byte[] bytes = getBytes();
222+
if (bytes == null) {
223+
return null;
224+
}
225+
return () -> new ByteArrayInputStream(bytes);
226+
}
187227
}

0 commit comments

Comments
 (0)