Skip to content

Commit 6f5bf03

Browse files
committed
Optimize byte buffer/output stream
1 parent a837836 commit 6f5bf03

5 files changed

Lines changed: 303 additions & 7 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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.ByteArrayOutputStream;
19+
import software.amazon.awssdk.annotations.SdkInternalApi;
20+
21+
/**
22+
* A thin subclass of {@link ByteArrayOutputStream} that exposes the internal buffer and count
23+
* without copying. This allows {@link SdkJsonGenerator} to create a {@code ContentStreamProvider}
24+
* that wraps the buffer directly via {@code ByteArrayInputStream(buf, 0, count)}, avoiding the
25+
* contiguous copy that {@link ByteArrayOutputStream#toByteArray()} performs.
26+
*
27+
* <p>The write path is identical to {@code ByteArrayOutputStream} — no overhead is added.
28+
* Only the final "get the bytes" step is optimized.
29+
*
30+
* <p>This class is not thread-safe.
31+
*/
32+
@SdkInternalApi
33+
final class ExposedByteArrayOutputStream extends ByteArrayOutputStream {
34+
35+
ExposedByteArrayOutputStream(int size) {
36+
super(size);
37+
}
38+
39+
/**
40+
* Returns the internal buffer. The valid data is in {@code buf[0..count-1]}.
41+
* The returned array may be larger than {@link #size()}; callers must use
42+
* {@link #size()} to determine the valid range.
43+
*
44+
* <p><b>Warning:</b> The returned array is the live internal buffer. Do not modify it,
45+
* and do not write to this stream after capturing the reference — the buffer may be
46+
* replaced by a larger one on the next write if growth is needed.
47+
*/
48+
byte[] buf() {
49+
return buf;
50+
}
51+
}

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

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

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

18-
import java.io.ByteArrayOutputStream;
18+
import java.io.ByteArrayInputStream;
1919
import java.io.IOException;
2020
import java.math.BigDecimal;
2121
import java.math.BigInteger;
2222
import java.nio.ByteBuffer;
2323
import java.time.Instant;
2424
import software.amazon.awssdk.annotations.SdkProtectedApi;
2525
import software.amazon.awssdk.core.exception.SdkClientException;
26+
import software.amazon.awssdk.http.ContentStreamProvider;
2627
import software.amazon.awssdk.thirdparty.jackson.core.JsonFactory;
2728
import software.amazon.awssdk.thirdparty.jackson.core.JsonGenerator;
2829
import software.amazon.awssdk.utils.BinaryUtils;
@@ -39,7 +40,7 @@ public class SdkJsonGenerator implements StructuredJsonGenerator {
3940
* prevent frequent resizings but small enough to avoid wasted allocations for small requests.
4041
*/
4142
private static final int DEFAULT_BUFFER_SIZE = 1024;
42-
private final ByteArrayOutputStream baos = new ByteArrayOutputStream(DEFAULT_BUFFER_SIZE);
43+
private final ExposedByteArrayOutputStream baos = new ExposedByteArrayOutputStream(DEFAULT_BUFFER_SIZE);
4344
private final JsonGenerator generator;
4445
private final String contentType;
4546

@@ -277,6 +278,28 @@ public byte[] getBytes() {
277278
return baos.toByteArray();
278279
}
279280

281+
/**
282+
* Returns the size of the generated content in bytes without copying.
283+
*/
284+
public int contentSize() {
285+
close();
286+
return baos.size();
287+
}
288+
289+
/**
290+
* Returns a {@link ContentStreamProvider} that wraps the internal buffer directly,
291+
* avoiding the contiguous copy that {@link #getBytes()} performs via
292+
* {@code ByteArrayOutputStream.toByteArray()}. Each call to
293+
* {@link ContentStreamProvider#newStream()} creates a fresh {@code ByteArrayInputStream}
294+
* over the same buffer for retry safety.
295+
*/
296+
public ContentStreamProvider contentStreamProvider() {
297+
close();
298+
byte[] buf = baos.buf();
299+
int count = baos.size();
300+
return () -> new ByteArrayInputStream(buf, 0, count);
301+
}
302+
280303
@Override
281304
public String getContentType() {
282305
return contentType;

core/protocols/aws-json-protocol/src/main/java/software/amazon/awssdk/protocols/json/internal/marshall/JsonProtocolMarshaller.java

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import software.amazon.awssdk.core.traits.RequiredTrait;
4444
import software.amazon.awssdk.core.traits.TimestampFormatTrait;
4545
import software.amazon.awssdk.core.traits.TraitType;
46+
import software.amazon.awssdk.http.ContentStreamProvider;
4647
import software.amazon.awssdk.http.SdkHttpFullRequest;
4748
import software.amazon.awssdk.protocols.core.InstantToString;
4849
import software.amazon.awssdk.protocols.core.OperationInfo;
@@ -52,6 +53,7 @@
5253
import software.amazon.awssdk.protocols.json.AwsJsonProtocol;
5354
import software.amazon.awssdk.protocols.json.AwsJsonProtocolMetadata;
5455
import software.amazon.awssdk.protocols.json.BaseAwsJsonProtocolFactory;
56+
import software.amazon.awssdk.protocols.json.SdkJsonGenerator;
5557
import software.amazon.awssdk.protocols.json.StructuredJsonGenerator;
5658
import software.amazon.awssdk.protocols.json.internal.ProtocolFact;
5759

@@ -288,12 +290,24 @@ private SdkHttpFullRequest finishMarshalling() {
288290
jsonGenerator.writeEndObject();
289291
}
290292

291-
byte[] content = jsonGenerator.getBytes();
293+
if (jsonGenerator instanceof SdkJsonGenerator) {
294+
// Optimized path: stream directly from chunked buffers, avoiding a single
295+
// contiguous byte[] allocation that can cause G1GC humongous allocations.
296+
SdkJsonGenerator sdkGenerator = (SdkJsonGenerator) jsonGenerator;
297+
ContentStreamProvider contentProvider = sdkGenerator.contentStreamProvider();
298+
request.contentStreamProvider(contentProvider);
299+
int contentSize = sdkGenerator.contentSize();
300+
if (contentSize > 0) {
301+
request.putHeader(CONTENT_LENGTH, Integer.toString(contentSize));
302+
}
303+
} else {
304+
byte[] content = jsonGenerator.getBytes();
292305

293-
if (content != null) {
294-
request.contentStreamProvider(() -> new ByteArrayInputStream(content));
295-
if (content.length > 0) {
296-
request.putHeader(CONTENT_LENGTH, Integer.toString(content.length));
306+
if (content != null) {
307+
request.contentStreamProvider(() -> new ByteArrayInputStream(content));
308+
if (content.length > 0) {
309+
request.putHeader(CONTENT_LENGTH, Integer.toString(content.length));
310+
}
297311
}
298312
}
299313
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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 static org.assertj.core.api.Assertions.assertThat;
19+
20+
import java.util.Arrays;
21+
import org.junit.jupiter.api.Test;
22+
23+
class ExposedByteArrayOutputStreamTest {
24+
25+
@Test
26+
void emptyStream_hasZeroSize() {
27+
ExposedByteArrayOutputStream stream = new ExposedByteArrayOutputStream(64);
28+
assertThat(stream.size()).isEqualTo(0);
29+
assertThat(stream.toByteArray()).isEmpty();
30+
}
31+
32+
@Test
33+
void buf_returnsInternalBuffer() {
34+
ExposedByteArrayOutputStream stream = new ExposedByteArrayOutputStream(64);
35+
byte[] data = {1, 2, 3, 4, 5};
36+
stream.write(data, 0, data.length);
37+
38+
byte[] buf = stream.buf();
39+
// buf is the live internal buffer — it may be larger than size()
40+
assertThat(buf.length).isGreaterThanOrEqualTo(stream.size());
41+
// The valid data in buf[0..size()-1] matches what was written
42+
assertThat(Arrays.copyOf(buf, stream.size())).isEqualTo(data);
43+
}
44+
45+
@Test
46+
void buf_reflectsWrittenData_afterGrowth() {
47+
// Start with a tiny buffer to force growth
48+
ExposedByteArrayOutputStream stream = new ExposedByteArrayOutputStream(4);
49+
byte[] data = new byte[100];
50+
for (int i = 0; i < data.length; i++) {
51+
data[i] = (byte) i;
52+
}
53+
stream.write(data, 0, data.length);
54+
55+
byte[] buf = stream.buf();
56+
assertThat(stream.size()).isEqualTo(100);
57+
assertThat(Arrays.copyOf(buf, stream.size())).isEqualTo(data);
58+
}
59+
60+
@Test
61+
void toByteArray_returnsCopy_notSameReference() {
62+
ExposedByteArrayOutputStream stream = new ExposedByteArrayOutputStream(64);
63+
stream.write(new byte[]{1, 2, 3}, 0, 3);
64+
65+
byte[] copy = stream.toByteArray();
66+
byte[] buf = stream.buf();
67+
// toByteArray returns a copy, buf returns the live buffer
68+
assertThat(copy).isNotSameAs(buf);
69+
assertThat(copy).isEqualTo(Arrays.copyOf(buf, stream.size()));
70+
}
71+
72+
@Test
73+
void singleByteWrite_worksCorrectly() {
74+
ExposedByteArrayOutputStream stream = new ExposedByteArrayOutputStream(64);
75+
stream.write(0x42);
76+
assertThat(stream.size()).isEqualTo(1);
77+
assertThat(stream.buf()[0]).isEqualTo((byte) 0x42);
78+
}
79+
}

core/protocols/aws-json-protocol/src/test/java/software/amazon/awssdk/protocols/json/SdkJsonGeneratorTest.java

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121

2222
import java.io.ByteArrayInputStream;
2323
import java.io.IOException;
24+
import java.io.InputStream;
2425
import java.nio.ByteBuffer;
2526
import java.nio.charset.Charset;
2627
import java.time.Instant;
2728
import org.junit.jupiter.api.BeforeEach;
2829
import org.junit.jupiter.api.Test;
30+
import software.amazon.awssdk.http.ContentStreamProvider;
2931
import software.amazon.awssdk.protocols.jsoncore.JsonNode;
3032
import software.amazon.awssdk.thirdparty.jackson.core.JsonFactory;
3133
import software.amazon.awssdk.thirdparty.jackson.core.StreamReadFeature;
@@ -178,4 +180,131 @@ private JsonNode toJsonNode() throws IOException {
178180
return JsonNode.parser().parse(new ByteArrayInputStream(jsonGenerator.getBytes()));
179181
}
180182

183+
@Test
184+
public void contentSize_matchesGetBytesLength() {
185+
SdkJsonGenerator gen = newSdkJsonGenerator();
186+
gen.writeStartObject();
187+
gen.writeFieldName("key").writeValue("value");
188+
gen.writeFieldName("num").writeValue(42);
189+
gen.writeEndObject();
190+
191+
byte[] bytes = gen.getBytes();
192+
193+
SdkJsonGenerator gen2 = newSdkJsonGenerator();
194+
gen2.writeStartObject();
195+
gen2.writeFieldName("key").writeValue("value");
196+
gen2.writeFieldName("num").writeValue(42);
197+
gen2.writeEndObject();
198+
199+
assertEquals(bytes.length, gen2.contentSize());
200+
}
201+
202+
@Test
203+
public void contentStreamProvider_producesSameBytesAsGetBytes() throws IOException {
204+
SdkJsonGenerator gen = newSdkJsonGenerator();
205+
gen.writeStartObject();
206+
gen.writeFieldName("hello").writeValue("world");
207+
gen.writeFieldName("count").writeValue(123);
208+
gen.writeEndObject();
209+
210+
byte[] expected = gen.getBytes();
211+
212+
SdkJsonGenerator gen2 = newSdkJsonGenerator();
213+
gen2.writeStartObject();
214+
gen2.writeFieldName("hello").writeValue("world");
215+
gen2.writeFieldName("count").writeValue(123);
216+
gen2.writeEndObject();
217+
218+
ContentStreamProvider provider = gen2.contentStreamProvider();
219+
byte[] actual = readAllBytes(provider.newStream());
220+
221+
assertTrue(java.util.Arrays.equals(expected, actual),
222+
"contentStreamProvider should produce identical bytes to getBytes");
223+
}
224+
225+
@Test
226+
public void contentStreamProvider_isResettable() throws IOException {
227+
SdkJsonGenerator gen = newSdkJsonGenerator();
228+
gen.writeStartObject();
229+
gen.writeFieldName("data").writeValue("test");
230+
gen.writeEndObject();
231+
232+
ContentStreamProvider provider = gen.contentStreamProvider();
233+
byte[] first = readAllBytes(provider.newStream());
234+
byte[] second = readAllBytes(provider.newStream());
235+
236+
assertTrue(java.util.Arrays.equals(first, second),
237+
"Multiple calls to newStream() should produce identical content");
238+
assertTrue(first.length > 0, "Content should not be empty");
239+
}
240+
241+
@Test
242+
public void emptyGenerator_contentSizeIsZero() throws IOException {
243+
SdkJsonGenerator gen = newSdkJsonGenerator();
244+
assertEquals(0, gen.contentSize());
245+
246+
ContentStreamProvider provider = gen.contentStreamProvider();
247+
assertTrue(provider != null, "Provider should not be null even for empty content");
248+
byte[] content = readAllBytes(provider.newStream());
249+
assertEquals(0, content.length, "Empty generator should produce empty stream");
250+
}
251+
252+
@Test
253+
public void largePayload_contentStreamProviderStreamsCorrectData() throws IOException {
254+
// Generate JSON exceeding 64 KB to verify contentStreamProvider works for large payloads
255+
SdkJsonGenerator gen = newSdkJsonGenerator();
256+
gen.writeStartObject();
257+
gen.writeFieldName("items");
258+
gen.writeStartArray();
259+
for (int i = 0; i < 2000; i++) {
260+
gen.writeStartObject();
261+
gen.writeFieldName("index").writeValue(i);
262+
gen.writeFieldName("description").writeValue(
263+
"This is a moderately long string value for item number " + i +
264+
" that helps push the total payload size beyond the 64KB chunk boundary.");
265+
gen.writeEndObject();
266+
}
267+
gen.writeEndArray();
268+
gen.writeEndObject();
269+
270+
byte[] expected = gen.getBytes();
271+
assertTrue(expected.length > 64 * 1024, "Payload should exceed 64 KB");
272+
273+
SdkJsonGenerator gen2 = newSdkJsonGenerator();
274+
gen2.writeStartObject();
275+
gen2.writeFieldName("items");
276+
gen2.writeStartArray();
277+
for (int i = 0; i < 2000; i++) {
278+
gen2.writeStartObject();
279+
gen2.writeFieldName("index").writeValue(i);
280+
gen2.writeFieldName("description").writeValue(
281+
"This is a moderately long string value for item number " + i +
282+
" that helps push the total payload size beyond the 64KB chunk boundary.");
283+
gen2.writeEndObject();
284+
}
285+
gen2.writeEndArray();
286+
gen2.writeEndObject();
287+
288+
assertEquals(expected.length, gen2.contentSize());
289+
byte[] actual = readAllBytes(gen2.contentStreamProvider().newStream());
290+
assertTrue(java.util.Arrays.equals(expected, actual),
291+
"Large payload should stream correctly via contentStreamProvider");
292+
}
293+
294+
private SdkJsonGenerator newSdkJsonGenerator() {
295+
return new SdkJsonGenerator(JsonFactory.builder()
296+
.enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION)
297+
.build(), "application/json");
298+
}
299+
300+
private static byte[] readAllBytes(InputStream is) throws IOException {
301+
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
302+
byte[] buf = new byte[1024];
303+
int n;
304+
while ((n = is.read(buf)) != -1) {
305+
bos.write(buf, 0, n);
306+
}
307+
return bos.toByteArray();
308+
}
309+
181310
}

0 commit comments

Comments
 (0)