diff --git a/core/endpoints-spi/src/main/java/software/amazon/awssdk/endpoints/Endpoint.java b/core/endpoints-spi/src/main/java/software/amazon/awssdk/endpoints/Endpoint.java index 0db92b5a7a2d..96829aa03924 100644 --- a/core/endpoints-spi/src/main/java/software/amazon/awssdk/endpoints/Endpoint.java +++ b/core/endpoints-spi/src/main/java/software/amazon/awssdk/endpoints/Endpoint.java @@ -17,6 +17,7 @@ import java.net.URI; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -28,14 +29,21 @@ */ @SdkPublicApi public final class Endpoint { + /** + * Initial capacity for the attribute and header maps. Endpoints carry very few of either, so the + * default capacity of 16 is extra overhead. + */ + private static final int ATTRIBUTE_MAP_CAPACITY = 4; + private static final int HEADER_MAP_CAPACITY = 4; + private final EndpointUrl endpointUrl; private final Map> headers; private final Map, Object> attributes; private Endpoint(BuilderImpl b) { this.endpointUrl = b.endpointUrl; - this.headers = b.headers; - this.attributes = b.attributes; + this.headers = b.headers == null ? Collections.emptyMap() : Collections.unmodifiableMap(b.headers); + this.attributes = b.buildAttributes(); } /** @@ -137,20 +145,67 @@ default Builder endpointUrl(EndpointUrl endpointUrl) { private static class BuilderImpl implements Builder { private EndpointUrl endpointUrl; - private final Map> headers = new HashMap<>(); - private final Map, Object> attributes = new HashMap<>(); + + /** + * Most endpoints declare no headers, so the map is allocated only once a header is added. + */ + private Map> headers; + + /** + * Endpoints almost always carry zero or one attribute (typically {@code AUTH_SCHEMES}), so the first + * entry is held in these two fields and {@link #attributes} is allocated only if a second distinct + * key arrives. This keeps the common cases free of a {@code HashMap} and its backing table. + */ + private EndpointAttributeKey firstAttributeKey; + private Object firstAttributeValue; + private Map, Object> attributes; private BuilderImpl() { } private BuilderImpl(Endpoint e) { this.endpointUrl = e.endpointUrl; - if (e.headers != null) { + if (!e.headers.isEmpty()) { + this.headers = new HashMap<>(Math.max(HEADER_MAP_CAPACITY, e.headers.size())); e.headers.forEach((n, v) -> { this.headers.put(n, new ArrayList<>(v)); }); } - this.attributes.putAll(e.attributes); + e.attributes.forEach(this::putAttributeUnchecked); + } + + /** + * Collapses the staged attributes into the smallest immutable map that can hold them. + */ + private Map, Object> buildAttributes() { + if (attributes != null) { + return Collections.unmodifiableMap(attributes); + } + if (firstAttributeKey != null) { + return Collections.singletonMap(firstAttributeKey, firstAttributeValue); + } + return Collections.emptyMap(); + } + + /** + * Stores an attribute without the generic key/value pairing, for use by callers that have already + * had that relationship checked (the {@link #putAttribute} overload and the copy constructor). + */ + private void putAttributeUnchecked(EndpointAttributeKey key, Object value) { + if (attributes != null) { + attributes.put(key, value); + } else if (firstAttributeKey == null || firstAttributeKey.equals(key)) { + firstAttributeKey = key; + firstAttributeValue = value; + } else { + // Sized for the realistic maximum rather than the default 16, whose backing table alone + // costs more than every other allocation on this path combined. + attributes = new HashMap<>(ATTRIBUTE_MAP_CAPACITY); + attributes.put(firstAttributeKey, firstAttributeValue); + attributes.put(key, value); + firstAttributeKey = null; + firstAttributeValue = null; + } } @SuppressWarnings("deprecation") @@ -168,6 +223,9 @@ public Builder endpointUrl(EndpointUrl endpointUrl) { @Override public Builder putHeader(String name, String value) { + if (this.headers == null) { + this.headers = new HashMap<>(HEADER_MAP_CAPACITY); + } List values = this.headers.computeIfAbsent(name, (n) -> new ArrayList<>()); values.add(value); return this; @@ -175,7 +233,7 @@ public Builder putHeader(String name, String value) { @Override public Builder putAttribute(EndpointAttributeKey key, T value) { - this.attributes.put(key, value); + putAttributeUnchecked(key, value); return this; } diff --git a/core/endpoints-spi/src/test/java/software/amazon/awssdk/endpoints/EndpointTest.java b/core/endpoints-spi/src/test/java/software/amazon/awssdk/endpoints/EndpointTest.java index 25eb1729e846..314c0f499fe8 100644 --- a/core/endpoints-spi/src/test/java/software/amazon/awssdk/endpoints/EndpointTest.java +++ b/core/endpoints-spi/src/test/java/software/amazon/awssdk/endpoints/EndpointTest.java @@ -16,6 +16,7 @@ package software.amazon.awssdk.endpoints; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.net.URI; import java.util.Arrays; @@ -133,4 +134,105 @@ public void endpointUrlAccessor_returnsCorrectComponents() { assertThat(endpointUrl.encodedPath()).isEqualTo("/bucket"); assertThat(endpointUrl.queryAndFragment()).isEmpty(); } + + @Test + public void build_noHeadersOrAttributes_returnsEmptyMaps() { + Endpoint endpoint = Endpoint.builder() + .endpointUrl(EndpointUrl.fromString("https://example.com")) + .build(); + + assertThat(endpoint.headers()).isEmpty(); + assertThat(endpoint.attribute(TEST_STRING_ATTR)).isNull(); + } + + @Test + public void headers_isUnmodifiable() { + Endpoint noHeaders = Endpoint.builder() + .endpointUrl(EndpointUrl.fromString("https://example.com")) + .build(); + Endpoint withHeaders = Endpoint.builder() + .endpointUrl(EndpointUrl.fromString("https://example.com")) + .putHeader("foo", "bar") + .build(); + + assertThatThrownBy(() -> noHeaders.headers().put("a", Arrays.asList("b"))) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> withHeaders.headers().put("a", Arrays.asList("b"))) + .isInstanceOf(UnsupportedOperationException.class); + } + + /** + * A single attribute is staged inline rather than in a map, so exercise the read path for one, two and + * three attributes as well as overwriting the staged entry. + */ + @Test + public void putAttribute_variousArities_allReadable() { + EndpointAttributeKey second = new EndpointAttributeKey<>("Second", String.class); + EndpointAttributeKey third = new EndpointAttributeKey<>("Third", String.class); + + Endpoint one = Endpoint.builder() + .endpointUrl(EndpointUrl.fromString("https://example.com")) + .putAttribute(TEST_STRING_ATTR, "a") + .build(); + assertThat(one.attribute(TEST_STRING_ATTR)).isEqualTo("a"); + assertThat(one.attribute(second)).isNull(); + + Endpoint two = Endpoint.builder() + .endpointUrl(EndpointUrl.fromString("https://example.com")) + .putAttribute(TEST_STRING_ATTR, "a") + .putAttribute(second, "b") + .build(); + assertThat(two.attribute(TEST_STRING_ATTR)).isEqualTo("a"); + assertThat(two.attribute(second)).isEqualTo("b"); + + Endpoint three = Endpoint.builder() + .endpointUrl(EndpointUrl.fromString("https://example.com")) + .putAttribute(TEST_STRING_ATTR, "a") + .putAttribute(second, "b") + .putAttribute(third, "c") + .build(); + assertThat(three.attribute(TEST_STRING_ATTR)).isEqualTo("a"); + assertThat(three.attribute(second)).isEqualTo("b"); + assertThat(three.attribute(third)).isEqualTo("c"); + } + + @Test + public void putAttribute_sameKeyTwice_lastValueWins() { + EndpointAttributeKey second = new EndpointAttributeKey<>("Second", String.class); + + Endpoint staged = Endpoint.builder() + .endpointUrl(EndpointUrl.fromString("https://example.com")) + .putAttribute(TEST_STRING_ATTR, "first") + .putAttribute(TEST_STRING_ATTR, "second") + .build(); + assertThat(staged.attribute(TEST_STRING_ATTR)).isEqualTo("second"); + + // Same key overwritten after the builder has been promoted to a map. + Endpoint promoted = Endpoint.builder() + .endpointUrl(EndpointUrl.fromString("https://example.com")) + .putAttribute(TEST_STRING_ATTR, "first") + .putAttribute(second, "other") + .putAttribute(TEST_STRING_ATTR, "second") + .build(); + assertThat(promoted.attribute(TEST_STRING_ATTR)).isEqualTo("second"); + assertThat(promoted.attribute(second)).isEqualTo("other"); + } + + @Test + public void toBuilder_roundTripsAllAttributeArities() { + EndpointAttributeKey second = new EndpointAttributeKey<>("Second", String.class); + + Endpoint none = Endpoint.builder() + .endpointUrl(EndpointUrl.fromString("https://example.com")) + .build(); + assertThat(none.toBuilder().build()).isEqualTo(none); + + Endpoint one = none.toBuilder().putAttribute(TEST_STRING_ATTR, "a").build(); + assertThat(one.toBuilder().build()).isEqualTo(one); + + Endpoint two = one.toBuilder().putAttribute(second, "b").build(); + assertThat(two.toBuilder().build()).isEqualTo(two); + assertThat(two.attribute(TEST_STRING_ATTR)).isEqualTo("a"); + assertThat(two.attribute(second)).isEqualTo("b"); + } }