Repository navigation
Provide an opt-in zero-copy response body byte-array view #2321
Description
Problem
The standard response path
executeRequest()
-> AsyncCompletionHandlerBase
-> ResponseBuilder
-> NettyResponse
copies the response body twice before returning it as a byte array:
EagerResponseBodyPartcopies every incoming NettyByteBufinto an
AHC-ownedbyte[].NettyResponse.getResponseBodyAsBytes()calls
getResponseBodyAsByteBuffer(), which allocates another array for the
complete body and copies all body parts into it.
The second copy is unnecessary when a response arrived in one chunk because
the EagerResponseBodyPart already owns one complete AHC-managed array.
#2303 removed this extra copy from getResponseBody(Charset), but deliberately
kept it in getResponseBodyAsBytes() because that method hands a mutable array
to application code. Byte-array consumers therefore cannot opt into the same
optimization even when they can guarantee that they will not mutate the body.
Goal
Add an explicit opt-in API that may return shared response-body storage. Keep
all existing behavior of getResponseBodyAsBytes() unchanged.
Proposed API
Add this default method to org.asynchttpclient.Response:
/**
* Returns the entire response body as a byte array that may share
* its storage with this response.
*
* <p>The returned array must be treated as read-only. Modifying it may
* change the content subsequently returned by this response's other
* body accessors.
*
* <p>The implementation is not required to return shared storage.
* Depending on the response representation, this method may still
* return a copy. No array identity is guaranteed between calls.
*
* <p>Use {@link #getResponseBodyAsBytes()} when an independently owned,
* mutable array is required.
*
* @return the entire response body as a possibly shared byte array
*/
default byte[] getResponseBodyAsBytesView() {
return getResponseBodyAsBytes();
}A default method keeps third-party Response implementations binary- and
source-compatible. Implementations that cannot expose shared storage preserve
the existing safe behavior. Aliasing is enabled explicitly by the caller.
The name and Javadoc must make it clear that the returned array can be shared
and must be treated as read-only.
NettyResponse implementation
NettyResponse should override the method:
@Override
public byte[] getResponseBodyAsBytesView() {
return bodyParts.size() == 1
? bodyParts.get(0).getBodyPartBytes()
: getResponseBodyAsBytes();
}Expected behavior:
| Response shape | Behavior |
|---|---|
| No body parts | Return an empty array through the normal fallback |
One EagerResponseBodyPart |
Return its internal byte[] without copying |
One LazyResponseBodyPart |
Use getBodyPartBytes(); perform the one necessary copy from the ByteBuf, but do not allocate a second aggregate array |
| Multiple parts | Use the existing concatenation through getResponseBodyAsBytes() |
The implementation must call getBodyPartBytes() and must not access
ByteBuf.array() directly. A ByteBuf can be direct, can be a slice with a
non-zero readerIndex or arrayOffset, and its backing array can contain bytes
outside the readable region.
Integration with #2303
Remove or replace the existing private sharedBodyBytes() method so the fast
path has a single implementation:
@Override
public String getResponseBody(Charset charset) {
return new String(getResponseBodyAsBytesView(), charset);
}This preserves the string-decoding optimization from #2303 while exposing it to
byte-array consumers only through the new opt-in method.
Required constraints
The change must not:
- change the behavior of
getResponseBodyAsBytes(); - change the behavior of
getResponseBodyAsByteBuffer(); - make any existing
byte[]API implicitly shared; - retain the original network
ByteBufor change its reference count; - access
ByteBuf.array()directly; - change
ResponseBodyPartFactory.EAGERorResponseBodyPartFactory.LAZY; - add synchronization or atomic operations to the response hot path;
- cache multi-part concatenation as part of this change.
getResponseBodyAsBytes() must continue to return an independently owned
array. Mutating an array returned by the existing method must not affect the
response.
Tests
One EAGER part
- Assert that the view is the exact array owned by the part.
- Assert that repeated view calls return the same array for this
NettyResponseshape. This is an implementation performance property, not a
generalResponseAPI identity guarantee.
Defensive-copy preservation
- Keep the Decode a lone response body part in place #2303 coverage proving that the old method does not return the
part's array. - Prove that two calls to
getResponseBodyAsBytes()return distinct arrays. - Prove that mutating the old method's result does not affect the response.
Lazy part over a slice/direct buffer
Create a LazyResponseBodyPart over the readable Hello World region in
XXXHello WorldYYY and assert that the view contains no surrounding bytes.
Also verify that the call does not change readerIndex, writerIndex, or
refCnt. Cover a direct buffer as well.
Multiple parts
- Verify correct concatenation order with no missing or duplicate bytes.
- Split a multi-byte UTF-8 character across two parts.
- Verify that
getResponseBody(Charset)produces the same result for one and
multiple parts.
Empty body
Verify that the view returns an empty array.
Default implementation
Add a test for a Response implementation that does not override the new
method. It must delegate to getResponseBodyAsBytes() without requiring a
third-party implementation change.
Benchmark
Compare:
response.getResponseBodyAsBytes();
response.getResponseBodyAsBytesView();Dataset matrix:
- one EAGER part: 512 B, 4 KiB, 16 KiB, and 128 KiB;
- one LAZY part: 4 KiB and 16 KiB;
- 2 and 8 EAGER parts;
- empty body.
Expected result:
- one EAGER part: the view has no body-sized allocation or copy;
- one LAZY part: one body-sized copy instead of two;
- multiple parts: performance is equivalent to the existing method;
getResponseBodyAsBytes()behavior and performance remain unchanged.
No hard numerical CI threshold is required. Include allocation benchmark
results in the pull request.
Acceptance criteria
- An opt-in shared/read-only byte-array API is available.
- Existing
Responseimplementations do not need to change. - One
EagerResponseBodyPartis returned without another copy. - Sliced and direct lazy parts preserve readable-region correctness.
getResponseBodyAsBytes()still returns an independent array.- The
getResponseBody(Charset)optimization from Decode a lone response body part in place #2303 is preserved. - Netty buffer lifecycle and reference counts are unchanged.
mvnw.cmd clean verifypasses on JDK 11, including Error Prone, NullAway,
and Revapi.- The pull request includes allocation benchmark results.
Out of scope
- Removing the initial
ByteBuf->EagerResponseBodyPartcopy. - Preallocating from
Content-Length. - A new multi-chunk accumulator.
- Caching the aggregated multi-part body.
- Changing the LAZY response lifecycle.
- A zero-copy
InputStream. - Changing the semantics of
getResponseBodyAsBytes()or
getResponseBodyAsByteBuffer().
After release, downstream byte-array consumers can opt in by replacing a method
reference with response::getResponseBodyAsBytesView. For single-part responses,
JFR should then show the aggregate allocation in
NettyResponse.getResponseBodyAsByteBuffer() disappearing; the
EagerResponseBodyPart allocation remains.
Activity
Make default response test portable
Provide an opt-in zero-copy response body view (AsyncHttpClient#2322)