From e9fdac13a6545176bfc7462888369cde9825b13f Mon Sep 17 00:00:00 2001 From: Ashay Rane <253344819+raneashay@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:59:24 -0500 Subject: [PATCH] Reuse buffer for encoding headers instead of allocating one per request Prior to this patch, every HTTP request created a new 16KB buffer for encoding the header, which are typically only a few hundred bytes long. Consequently, this increased pressure on the garbage collector when lots of requests streamed in. This patch instead makes the header encoder reuse the header encoder buffer. The caveat, however, is that the downstream consumers of the header are asynchronous, so the encoder needs to take special care to ensure that it doesn't modify or invalidate the buffer after it hands the buffer over to the downstream asynchronous pipeline. To resolve this, this patch snapshots the buffer data into compact copies sized to the actual encoded length. The cached buffer is then immediately available for reuse via `clear()` and `limit()`. For typical requests, this reduces per-request allocation from ~16KB to a few hundred bytes (i.e. the size of the compact copy of the encoded headers), with the 16KB encoding buffer allocated once per connection instead of once per request. --- .../internal/net/http/Http2Connection.java | 34 +++-- .../http2/HeaderEncodingBufferReuseTest.java | 119 ++++++++++++++++++ 2 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 test/jdk/java/net/httpclient/http2/HeaderEncodingBufferReuseTest.java diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/Http2Connection.java b/src/java.net.http/share/classes/jdk/internal/net/http/Http2Connection.java index 94b8505da47bd..bd92508725632 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/Http2Connection.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/Http2Connection.java @@ -54,6 +54,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; @@ -1614,12 +1615,18 @@ private List encodeHeaders(OutgoingHeaders> frame) { // There can be no concurrent access to this buffer as all access to this buffer // and its content happen within a single critical code block section protected // by the sendLock. / (see sendFrame()) - // private final ByteBufferPool headerEncodingPool = new ByteBufferPool(); + // The cached buffer is never passed to the I/O layer directly. Instead, + // `encodeHeadersImpl()` snapshots the encoded data into compact copies, so + // the cached buffer is safe to reuse immediately. + private ByteBuffer cachedHeaderBuffer; private ByteBuffer getHeaderBuffer(int size) { - ByteBuffer buf = ByteBuffer.allocate(size); - buf.limit(size); - return buf; + if (cachedHeaderBuffer == null || cachedHeaderBuffer.capacity() < size) { + cachedHeaderBuffer = ByteBuffer.allocate(size); + } + cachedHeaderBuffer.clear(); + cachedHeaderBuffer.limit(size); + return cachedHeaderBuffer; } /* @@ -1634,8 +1641,16 @@ private ByteBuffer getHeaderBuffer(int size) { * encoding in HTTP/2... */ private List encodeHeadersImpl(int bufferSize, HttpHeaders... headers) { - ByteBuffer buffer = getHeaderBuffer(bufferSize); List buffers = new ArrayList<>(); + Consumer captureAndAddToBuffers = (this_buffer) -> { + this_buffer.flip(); + ByteBuffer copy = ByteBuffer.allocate(this_buffer.remaining()); + copy.put(this_buffer); + copy.flip(); + buffers.add(copy); + }; + + ByteBuffer buffer = getHeaderBuffer(bufferSize); for (HttpHeaders header : headers) { for (Map.Entry> e : header.map().entrySet()) { String lKey = e.getKey().toLowerCase(Locale.US); @@ -1644,16 +1659,15 @@ private List encodeHeadersImpl(int bufferSize, HttpHeaders... header hpackOut.header(lKey, value); while (!hpackOut.encode(buffer)) { if (!buffer.hasRemaining()) { - buffer.flip(); - buffers.add(buffer); - buffer = getHeaderBuffer(bufferSize); + captureAndAddToBuffers.accept(buffer); + buffer.clear(); + buffer.limit(bufferSize); } } } } } - buffer.flip(); - buffers.add(buffer); + captureAndAddToBuffers.accept(buffer); return buffers; } diff --git a/test/jdk/java/net/httpclient/http2/HeaderEncodingBufferReuseTest.java b/test/jdk/java/net/httpclient/http2/HeaderEncodingBufferReuseTest.java new file mode 100644 index 0000000000000..cf7b9d3e99b5c --- /dev/null +++ b/test/jdk/java/net/httpclient/http2/HeaderEncodingBufferReuseTest.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +/* + * @test + * @summary Verifies that Http2Connection.cachedHeaderBuffer is reused + * across multiple requests on the same connection. + * @library /test/lib /test/jdk/java/net/httpclient/lib + * @build jdk.httpclient.test.lib.http2.Http2TestServer + * @run main/othervm + * --add-opens java.net.http/jdk.internal.net.http=ALL-UNNAMED + * HeaderEncodingBufferReuseTest + */ + +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.ByteBuffer; +import java.util.Map; + +import jdk.httpclient.test.lib.http2.Http2Handler; +import jdk.httpclient.test.lib.http2.Http2TestExchange; +import jdk.httpclient.test.lib.http2.Http2TestServer; +import jdk.test.lib.Asserts; + +public class HeaderEncodingBufferReuseTest { + public static void main(String[] args) throws Exception { + Http2TestServer server = new Http2TestServer("localhost", false, 0); + server.addHandler(new OkHandler(), "/test"); + server.start(); + String uri = "http://localhost:" + server.getAddress().getPort() + "/test"; + + try (HttpClient client = HttpClient.newBuilder() + .proxy(HttpClient.Builder.NO_PROXY) + .version(HttpClient.Version.HTTP_2) + .build()) { + + HttpResponse warmup = send(client, uri, 2); + Asserts.assertEquals(warmup.version(), HttpClient.Version.HTTP_2); + + Object conn = getHttp2Connection(client); + Asserts.assertEquals(send(client, uri, 2).statusCode(), 200); + ByteBuffer cached = (ByteBuffer) getField(conn, "cachedHeaderBuffer"); + Asserts.assertNotNull(cached); + + Asserts.assertEquals(send(client, uri, 2).statusCode(), 200); + Asserts.assertEquals(cached, getField(conn, "cachedHeaderBuffer")); + + Asserts.assertEquals(send(client, uri, 300).statusCode(), 200); + Asserts.assertEquals(cached, getField(conn, "cachedHeaderBuffer")); + + Asserts.assertEquals(send(client, uri, 2).statusCode(), 200); + Asserts.assertEquals(cached, getField(conn, "cachedHeaderBuffer")); + } finally { + server.stop(); + } + } + + static Object getField(Object obj, String name) throws Exception { + Field field = obj.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(obj); + } + + static Object getHttp2Connection(HttpClient client) throws Exception { + Object clientImpl = getField(client, "impl"); + + var method = clientImpl.getClass().getDeclaredMethod("client2"); + method.setAccessible(true); + Object client2 = method.invoke(clientImpl); + + Object conns = getField(client2, "connections"); + Map connections = (Map) conns; + Asserts.assertEquals(1, connections.size()); + return connections.values().iterator().next(); + } + + static HttpResponse send(HttpClient client, String uri, int headerCount) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(uri)) + .POST(BodyPublishers.ofString("test")); + for (int i = 0; i < headerCount; i++) { + builder.header("X-Header-" + i, "value-" + "x".repeat(50) + "-" + i); + } + return client.send(builder.build(), BodyHandlers.discarding()); + } + + static class OkHandler implements Http2Handler { + @Override + public void handle(Http2TestExchange exchange) throws IOException { + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().close(); + } + } +}