Skip to content

Provide an opt-in zero-copy response body byte-array view #2321

Description

@pavel-ptashyts

Problem

The standard response path

executeRequest()
  -> AsyncCompletionHandlerBase
  -> ResponseBuilder
  -> NettyResponse

copies the response body twice before returning it as a byte array:

  1. EagerResponseBodyPart copies every incoming Netty ByteBuf into an
    AHC-owned byte[].
  2. 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 ByteBuf or change its reference count;
  • access ByteBuf.array() directly;
  • change ResponseBodyPartFactory.EAGER or ResponseBodyPartFactory.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
    NettyResponse shape. This is an implementation performance property, not a
    general Response API 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 Response implementations do not need to change.
  • One EagerResponseBodyPart is 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 verify passes on JDK 11, including Error Prone, NullAway,
    and Revapi.
  • The pull request includes allocation benchmark results.

Out of scope

  • Removing the initial ByteBuf -> EagerResponseBodyPart copy.
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions