Skip to content

Commit 874c995

Browse files
franz1981normanmaurerchrisvest
authored
Reduce allocations on DefaultHeaders::containsValue (#15843)
Motivation: DefaultHeaders::containsValue is used heavily to detect if a request contains close or keep-alive header values, but the default implementations always allocates iterators Modification: Implements a specialized garbage-free method using optimized predicates which don't need to allocate any iterator Result: cheaper HTTP keep alive checks --------- Co-authored-by: Norman Maurer <norman_maurer@apple.com> Co-authored-by: Chris Vest <mr.chrisvest@gmail.com> Co-authored-by: Chris Vest <christianvest_hansen@apple.com>
1 parent e0fe794 commit 874c995

7 files changed

Lines changed: 282 additions & 1 deletion

File tree

codec-base/src/main/java/io/netty/handler/codec/DefaultHeaders.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import java.util.Map.Entry;
2727
import java.util.NoSuchElementException;
2828
import java.util.Set;
29+
import java.util.function.BiPredicate;
2930

3031
import static io.netty.util.HashingStrategy.JAVA_HASHER;
3132
import static io.netty.util.internal.MathUtil.findNextPositivePowerOfTwo;
@@ -147,6 +148,33 @@ public DefaultHeaders(HashingStrategy<K> nameHashingStrategy, ValueConverter<V>
147148
head = new HeaderEntry<K, V>();
148149
}
149150

151+
/**
152+
* Returns {@code true} if there exists a header with the given {@code name} for which the
153+
* supplied {@code valuePredicate} returns {@code true} when invoked with the stored header
154+
* value as the first argument and {@code predicateArg} as the second argument.
155+
* <p>
156+
* Matching is performed by invoking {@code valuePredicate.test(storedValue, predicateArg)}
157+
* on each stored header value for {@code name}.
158+
*
159+
* @param name the header name to search for (must not be {@code null})
160+
* @param predicateArg argument passed as the second parameter to {@code valuePredicate} (may be {@code null})
161+
* @param valuePredicate predicate used to test stored header values (must not be {@code null})
162+
*/
163+
public boolean containsAny(K name, V predicateArg, BiPredicate<? super V, ? super V> valuePredicate) {
164+
checkNotNull(name, "name");
165+
checkNotNull(valuePredicate, "valuePredicate");
166+
int h = hashingStrategy.hashCode(name);
167+
int i = index(h);
168+
HeaderEntry<K, V> e = entries[i];
169+
while (e != null) {
170+
if (e.hash == h && hashingStrategy.equals(name, e.key) && valuePredicate.test(e.value, predicateArg)) {
171+
return true;
172+
}
173+
e = e.next;
174+
}
175+
return false;
176+
}
177+
150178
@Override
151179
public V get(K name) {
152180
checkNotNull(name, "name");

codec-base/src/test/java/io/netty/handler/codec/DefaultHeadersTest.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import java.util.Map;
2626
import java.util.Map.Entry;
2727
import java.util.NoSuchElementException;
28+
import java.util.function.BiPredicate;
2829

2930
import static io.netty.util.AsciiString.of;
3031
import static java.util.Arrays.asList;
@@ -828,4 +829,66 @@ static void simulateCookieSplitting(TestDefaultHeaders headers) {
828829
headers.add("Cookie", crumb);
829830
}
830831
}
832+
833+
@Test
834+
public void testContainsAnyEquality() {
835+
TestDefaultHeaders headers = newInstance();
836+
headers.add(of("name"), of("value1"), of("value2"));
837+
838+
BiPredicate<CharSequence, CharSequence> equalsPredicate = Object::equals;
839+
840+
assertTrue(headers.containsAny(of("name"), of("value2"), equalsPredicate));
841+
assertFalse(headers.containsAny(of("name"), of("value3"), equalsPredicate));
842+
// Unknown name should return false
843+
assertFalse(headers.containsAny(of("unknown"), of("value1"), equalsPredicate));
844+
}
845+
846+
@Test
847+
public void testContainsAnySubstringPredicate() {
848+
TestDefaultHeaders headers = newInstance();
849+
headers.add(of("h1"), of("prefix-value"));
850+
headers.add(of("h1"), of("other"));
851+
852+
BiPredicate<CharSequence, CharSequence> containsPredicate = (stored, arg) ->
853+
stored.toString().contains(arg);
854+
855+
assertTrue(headers.containsAny(of("h1"), of("value"), containsPredicate));
856+
assertFalse(headers.containsAny(of("h1"), of("missing"), containsPredicate));
857+
}
858+
859+
@Test
860+
public void containsAnyHandlesHashCollisions() {
861+
TestDefaultHeaders headers = new TestDefaultHeaders(new HashingStrategy<CharSequence>() {
862+
@Override
863+
public int hashCode(CharSequence obj) {
864+
return 0; // force collisions
865+
}
866+
867+
@Override
868+
public boolean equals(CharSequence a, CharSequence b) {
869+
return a.equals(b);
870+
}
871+
});
872+
873+
headers.add(of("name1"), of("value1"));
874+
headers.add(of("name2"), of("value2"));
875+
876+
BiPredicate<CharSequence, CharSequence> eq = Object::equals;
877+
878+
assertTrue(headers.containsAny(of("name1"), of("value1"), eq));
879+
assertTrue(headers.containsAny(of("name2"), of("value2"), eq));
880+
assertFalse(headers.containsAny(of("name1"), of("no"), eq));
881+
assertFalse(headers.containsAny(of("name1"), of("value2"), eq));
882+
assertFalse(headers.containsAny(of("name2"), of("value1"), eq));
883+
}
884+
885+
@Test
886+
public void containsAnyShouldValidateArguments() {
887+
final TestDefaultHeaders headers = newInstance();
888+
headers.add(of("n"), of("v"));
889+
890+
assertThrows(NullPointerException.class, () -> headers.containsAny(null, of("v"), (stored, arg) -> false));
891+
892+
assertThrows(NullPointerException.class, () -> headers.containsAny(of("n"), of("v"), null));
893+
}
831894
}

codec-http/src/main/java/io/netty/handler/codec/http/CombinedHttpHeaders.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import java.util.Iterator;
2828
import java.util.List;
2929
import java.util.Map;
30+
import java.util.function.BiPredicate;
3031

3132
import static io.netty.handler.codec.http.HttpHeaderNames.SET_COOKIE;
3233
import static io.netty.util.AsciiString.CASE_INSENSITIVE_HASHER;
@@ -145,6 +146,18 @@ public Iterator<CharSequence> valueIterator(CharSequence name) {
145146
return unescapedItr;
146147
}
147148

149+
@Override
150+
public boolean containsAny(CharSequence name, CharSequence predicateArg,
151+
BiPredicate<? super CharSequence, ? super CharSequence> valuePredicate) {
152+
Iterator<CharSequence> itr = valueIterator(name);
153+
while (itr.hasNext()) {
154+
if (valuePredicate.test(itr.next(), predicateArg)) {
155+
return true;
156+
}
157+
}
158+
return false;
159+
}
160+
148161
@Override
149162
public List<CharSequence> getAll(CharSequence name) {
150163
List<CharSequence> values = super.getAll(name);

codec-http/src/main/java/io/netty/handler/codec/http/DefaultHttpHeaders.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import java.util.Map;
3535
import java.util.Map.Entry;
3636
import java.util.Set;
37+
import java.util.function.BiPredicate;
3738

3839
import static io.netty.util.AsciiString.CASE_INSENSITIVE_HASHER;
3940
import static io.netty.util.AsciiString.CASE_SENSITIVE_HASHER;
@@ -42,6 +43,13 @@
4243
* Default implementation of {@link HttpHeaders}.
4344
*/
4445
public class DefaultHttpHeaders extends HttpHeaders {
46+
private static final BiPredicate<CharSequence, CharSequence> CASE_INSENSITIVE_CONTAINS =
47+
(value, expected) ->
48+
HttpHeaders.containsCommaSeparatedTrimmed(value, expected, true);
49+
private static final BiPredicate<CharSequence, CharSequence> CASE_SENSITIVE_CONTAINS =
50+
(value, expected) ->
51+
HttpHeaders.containsCommaSeparatedTrimmed(value, expected, false);
52+
4553
private final DefaultHeaders<CharSequence, CharSequence, ?> headers;
4654

4755
/**
@@ -366,6 +374,11 @@ public Iterator<CharSequence> valueCharSequenceIterator(CharSequence name) {
366374
return headers.valueIterator(name);
367375
}
368376

377+
@Override
378+
public boolean containsValue(CharSequence name, CharSequence value, boolean ignoreCase) {
379+
return headers.containsAny(name, value, ignoreCase ? CASE_INSENSITIVE_CONTAINS : CASE_SENSITIVE_CONTAINS);
380+
}
381+
369382
@Override
370383
public boolean contains(String name) {
371384
return contains((CharSequence) name);

codec-http/src/main/java/io/netty/handler/codec/http/HttpHeaders.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1607,7 +1607,7 @@ public boolean containsValue(CharSequence name, CharSequence value, boolean igno
16071607
return false;
16081608
}
16091609

1610-
private static boolean containsCommaSeparatedTrimmed(CharSequence rawNext, CharSequence expected,
1610+
static boolean containsCommaSeparatedTrimmed(CharSequence rawNext, CharSequence expected,
16111611
boolean ignoreCase) {
16121612
int begin = 0;
16131613
int end;

microbench/src/main/java/io/netty/microbench/headers/HeadersBenchmark.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import io.netty.util.AsciiString;
2323
import org.openjdk.jmh.annotations.Benchmark;
2424
import org.openjdk.jmh.annotations.BenchmarkMode;
25+
import org.openjdk.jmh.annotations.CompilerControl;
2526
import org.openjdk.jmh.annotations.Fork;
2627
import org.openjdk.jmh.annotations.Level;
2728
import org.openjdk.jmh.annotations.Measurement;
@@ -65,6 +66,7 @@ static String toHttp2Name(String name) {
6566
AsciiString[] httpNames;
6667
AsciiString[] http2Names;
6768
AsciiString[] httpValues;
69+
AsciiString[] httpWrongValues;
6870

6971
DefaultHttpHeaders httpHeaders;
7072
DefaultHttp2Headers http2Headers;
@@ -80,6 +82,7 @@ public void setup() {
8082
httpNames = new AsciiString[headers.size()];
8183
http2Names = new AsciiString[headers.size()];
8284
httpValues = new AsciiString[headers.size()];
85+
httpWrongValues = new AsciiString[headers.size()];
8386
httpHeaders = new DefaultHttpHeaders(false);
8487
http2Headers = new DefaultHttp2Headers(false);
8588
int idx = 0;
@@ -91,6 +94,8 @@ public void setup() {
9194
httpNames[idx] = new AsciiString(httpName);
9295
http2Names[idx] = new AsciiString(http2Name);
9396
httpValues[idx] = new AsciiString(value);
97+
// make it wrong by appending "wrong"
98+
httpWrongValues[idx] = new AsciiString(value + "wrong");
9499
httpHeaders.add(httpNames[idx], httpValues[idx]);
95100
http2Headers.add(http2Names[idx], httpValues[idx]);
96101
idx++;
@@ -118,6 +123,31 @@ public void httpGet(Blackhole bh) {
118123
}
119124
}
120125

126+
@Benchmark
127+
@BenchmarkMode(Mode.AverageTime)
128+
public void httpContainsValueIgnoreCase(Blackhole bh) {
129+
AsciiString[] names = httpNames;
130+
AsciiString[] values = httpValues;
131+
for (int i = 0; i < names.length; i++) {
132+
bh.consume(containsValue(httpHeaders, names[i], values[i]));
133+
}
134+
}
135+
136+
@Benchmark
137+
@BenchmarkMode(Mode.AverageTime)
138+
public void httpContainsValueIgnoreCaseNonExisting(Blackhole bh) {
139+
AsciiString[] names = httpNames;
140+
AsciiString[] values = httpWrongValues;
141+
for (int i = 0; i < names.length; i++) {
142+
bh.consume(containsValue(httpHeaders, names[i], values[i]));
143+
}
144+
}
145+
146+
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
147+
private static boolean containsValue(DefaultHttpHeaders headers, AsciiString name, AsciiString value) {
148+
return headers.containsValue(name, value, true);
149+
}
150+
121151
@Benchmark
122152
@BenchmarkMode(Mode.AverageTime)
123153
public DefaultHttpHeaders httpPut() {
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
* Copyright 2025 The Netty Project
3+
*
4+
* The Netty Project licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
package io.netty.microbench.headers;
17+
18+
import io.netty.handler.codec.http.DefaultFullHttpRequest;
19+
import io.netty.handler.codec.http.FullHttpRequest;
20+
import io.netty.handler.codec.http.HttpHeaderNames;
21+
import io.netty.handler.codec.http.HttpHeaders;
22+
import io.netty.handler.codec.http.HttpMethod;
23+
import io.netty.handler.codec.http.HttpRequest;
24+
import io.netty.handler.codec.http.HttpUtil;
25+
import io.netty.handler.codec.http.HttpVersion;
26+
import io.netty.microbench.util.AbstractMicrobenchmark;
27+
import org.openjdk.jmh.annotations.Benchmark;
28+
import org.openjdk.jmh.annotations.BenchmarkMode;
29+
import org.openjdk.jmh.annotations.CompilerControl;
30+
import org.openjdk.jmh.annotations.Fork;
31+
import org.openjdk.jmh.annotations.Measurement;
32+
import org.openjdk.jmh.annotations.Mode;
33+
import org.openjdk.jmh.annotations.OutputTimeUnit;
34+
import org.openjdk.jmh.annotations.Param;
35+
import org.openjdk.jmh.annotations.Scope;
36+
import org.openjdk.jmh.annotations.Setup;
37+
import org.openjdk.jmh.annotations.State;
38+
import org.openjdk.jmh.annotations.Warmup;
39+
import org.openjdk.jmh.infra.Blackhole;
40+
41+
import java.util.concurrent.TimeUnit;
42+
43+
@State(Scope.Benchmark)
44+
@Fork(2)
45+
@Warmup(iterations = 10, time = 200, timeUnit = TimeUnit.MILLISECONDS)
46+
@Measurement(iterations = 10, time = 200, timeUnit = TimeUnit.MILLISECONDS)
47+
@BenchmarkMode(Mode.AverageTime)
48+
@OutputTimeUnit(TimeUnit.NANOSECONDS)
49+
public class IsKeepAliveBenchmark extends AbstractMicrobenchmark {
50+
51+
public enum ConnectionClose {
52+
KeepAlive,
53+
Close,
54+
None
55+
}
56+
57+
@Param
58+
public ConnectionClose close;
59+
// this is distributing equally branching for warmup purposes, although in the real world it likely
60+
// happens to be skewed towards one of the options!
61+
@Param({"false", "true"})
62+
public boolean warmup;
63+
64+
FullHttpRequest request;
65+
66+
@Setup
67+
public void setup(Blackhole bh) {
68+
request = new DefaultFullHttpRequest(
69+
HttpVersion.HTTP_1_1,
70+
HttpMethod.GET,
71+
"/index.html");
72+
HttpHeaders headers = request.headers();
73+
// values are usually strings decoded out of network buffers
74+
headers.set(HttpHeaderNames.HOST, "localhost");
75+
headers.set(HttpHeaderNames.USER_AGENT,
76+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
77+
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
78+
+ "Chrome/131.0.0.0 Safari/537.36");
79+
headers.set(HttpHeaderNames.ACCEPT,
80+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8");
81+
headers.set(HttpHeaderNames.ACCEPT_LANGUAGE, "en-US,en;q=0.9");
82+
headers.set(HttpHeaderNames.ACCEPT_ENCODING, "gzip, deflate");
83+
headers.set(HttpHeaderNames.UPGRADE_INSECURE_REQUESTS, "1");
84+
headers.set(HttpHeaderNames.CACHE_CONTROL, "max-age=0");
85+
86+
if (warmup) {
87+
// create 3 request variations for warmup based on the ConnectionClose values
88+
ConnectionClose[] cases = ConnectionClose.values();
89+
FullHttpRequest[] requests = new FullHttpRequest[cases.length];
90+
for (int i = 0; i < requests.length; i++) {
91+
requests[i] = request.copy();
92+
setConnectionClose(requests[i].headers(), cases[i]);
93+
}
94+
// warmup with mixed requests
95+
for (int i = 0; i < 100_000; i++) {
96+
for (FullHttpRequest httpRequest : requests) {
97+
bh.consume(isKeepAlive(httpRequest));
98+
}
99+
}
100+
}
101+
// set the actual test case
102+
setConnectionClose(headers, close);
103+
}
104+
105+
private static void setConnectionClose(HttpHeaders headers, ConnectionClose close) {
106+
switch (close) {
107+
case KeepAlive:
108+
headers.set(HttpHeaderNames.CONNECTION, "keep-alive");
109+
break;
110+
case Close:
111+
headers.set(HttpHeaderNames.CONNECTION, "close");
112+
break;
113+
case None:
114+
// omit header
115+
break;
116+
}
117+
}
118+
119+
@Benchmark
120+
public boolean isKeepAlive() {
121+
return isKeepAlive(request);
122+
}
123+
124+
@Benchmark
125+
@Fork(value = 2, jvmArgsAppend = "-XX:-DoEscapeAnalysis")
126+
public boolean isKeepAliveWithGarbage() {
127+
return isKeepAlive(request);
128+
}
129+
130+
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
131+
public static boolean isKeepAlive(HttpRequest request) {
132+
return HttpUtil.isKeepAlive(request);
133+
}
134+
}

0 commit comments

Comments
 (0)