From 01f8b71edd96513c1e71941b962686305e7a6310 Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Wed, 19 Aug 2026 15:18:34 -0300 Subject: [PATCH 1/2] perf: avoid boxing the memoized Expression hashCode Signed-off-by: Marvin Froeder --- .../querydsl/core/types/ExpressionBase.java | 10 +++--- .../serialization/SerializerBaseTest.java | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/querydsl-libraries/querydsl-core/src/main/java/com/querydsl/core/types/ExpressionBase.java b/querydsl-libraries/querydsl-core/src/main/java/com/querydsl/core/types/ExpressionBase.java index 7a92e11c55..27509daaef 100644 --- a/querydsl-libraries/querydsl-core/src/main/java/com/querydsl/core/types/ExpressionBase.java +++ b/querydsl-libraries/querydsl-core/src/main/java/com/querydsl/core/types/ExpressionBase.java @@ -30,7 +30,7 @@ public abstract class ExpressionBase implements Expression { @Nullable private transient volatile String toString; - @Nullable private transient volatile Integer hashCode; + private transient volatile int hashCode; public ExpressionBase(Class type) { this.type = type; @@ -43,10 +43,12 @@ public final Class getType() { @Override public final int hashCode() { - if (hashCode == null) { - hashCode = accept(HashCodeVisitor.DEFAULT, null); + var hash = hashCode; + if (hash == 0) { + hash = accept(HashCodeVisitor.DEFAULT, null); + hashCode = hash; } - return hashCode; + return hash; } @Override diff --git a/querydsl-libraries/querydsl-core/src/test/java/com/querydsl/core/serialization/SerializerBaseTest.java b/querydsl-libraries/querydsl-core/src/test/java/com/querydsl/core/serialization/SerializerBaseTest.java index 1faea6e194..a80aca759d 100644 --- a/querydsl-libraries/querydsl-core/src/test/java/com/querydsl/core/serialization/SerializerBaseTest.java +++ b/querydsl-libraries/querydsl-core/src/test/java/com/querydsl/core/serialization/SerializerBaseTest.java @@ -13,6 +13,8 @@ */ package com.querydsl.core.serialization; +import static org.assertj.core.api.Assertions.assertThat; + import com.querydsl.core.types.ConstantImpl; import com.querydsl.core.types.ExpressionUtils; import com.querydsl.core.types.JavaTemplates; @@ -38,4 +40,38 @@ void test() { // custom serializer.handle(ExpressionUtils.template(Object.class, "xxx", ConstantImpl.create(""))); } + + /** + * Constants are labelled by identity, so two equal but distinct instances are bound as two + * separate parameters. Boxed values outside the {@link Long} cache are distinct instances today; + * once the JDK migrates the wrappers to value classes (JEP 401) {@code ==} becomes state based + * and this collapses to a single label. + */ + @Test + void equalButDistinctConstantsGetDistinctLabels() { + var serializer = new DummySerializer(new JavaTemplates()); + Long first = 1000L; + Long second = 1000L; + assertThat(first).isNotSameAs(second).isEqualTo(second); + + serializer.handle((Object) first); + serializer.handle((Object) second); + + assertThat(serializer.getConstants()).containsExactly(first, second); + assertThat(serializer.getConstantToLabel()).hasSize(2); + assertThat(serializer).hasToString("a1a2"); + } + + @Test + void repeatedConstantInstanceReusesLabel() { + var serializer = new DummySerializer(new JavaTemplates()); + Long value = 1000L; + + serializer.handle((Object) value); + serializer.handle((Object) value); + + assertThat(serializer.getConstants()).containsExactly(value, value); + assertThat(serializer.getConstantToLabel()).hasSize(1); + assertThat(serializer).hasToString("a1a1"); + } } From 904d25f7bb843183e166d2366cd953cefe968bc4 Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Mon, 24 Aug 2026 09:33:31 -0300 Subject: [PATCH 2/2] test: pin the hashCode memoization contract in ExpressionBase Signed-off-by: Marvin Froeder --- .../core/types/ExpressionBaseTest.java | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 querydsl-libraries/querydsl-core/src/test/java/com/querydsl/core/types/ExpressionBaseTest.java diff --git a/querydsl-libraries/querydsl-core/src/test/java/com/querydsl/core/types/ExpressionBaseTest.java b/querydsl-libraries/querydsl-core/src/test/java/com/querydsl/core/types/ExpressionBaseTest.java new file mode 100644 index 0000000000..82f9ae65ae --- /dev/null +++ b/querydsl-libraries/querydsl-core/src/test/java/com/querydsl/core/types/ExpressionBaseTest.java @@ -0,0 +1,112 @@ +/* + * Copyright 2015, The Querydsl Team (http://www.querydsl.com/team) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.querydsl.core.types; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.querydsl.core.types.dsl.Expressions; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serial; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class ExpressionBaseTest { + + /** + * Counts visitor dispatches so the memoization in {@link ExpressionBase#hashCode()} is + * observable. + */ + private static final class CountingConstant extends ExpressionBase + implements Constant { + + @Serial private static final long serialVersionUID = 1L; + + private final Object constant; + private final AtomicInteger visits = new AtomicInteger(); + + CountingConstant(Object constant) { + super(Object.class); + this.constant = constant; + } + + @Override + public Object getConstant() { + return constant; + } + + @Override + public R accept(Visitor v, C context) { + visits.incrementAndGet(); + return v.visit(this, context); + } + } + + @Test + void hashCodeIsMemoized() { + var expr = new CountingConstant("x"); + + assertThat(expr.hashCode()).isEqualTo("x".hashCode()); + assertThat(expr.hashCode()).isEqualTo("x".hashCode()); + assertThat(expr.hashCode()).isEqualTo("x".hashCode()); + assertThat(expr.visits).hasValue(1); + } + + /** + * A computed hash of zero is indistinguishable from the "not yet computed" sentinel, so it is + * recomputed on every call. That is the deliberate trade-off for keeping the memo a primitive; + * closing it would cost a second field on every expression node. + */ + @Test + void zeroHashIsRecomputedOnEveryCall() { + var expr = new CountingConstant(0); + + for (var i = 0; i < 5; i++) { + assertThat(expr.hashCode()).isZero(); + } + assertThat(expr.visits).hasValue(5); + } + + @Test + void ordinaryConstantsCanHashToZero() { + assertThat(Expressions.constant(0).hashCode()).isZero(); + assertThat(Expressions.constant(0L).hashCode()).isZero(); + assertThat(Expressions.constant("").hashCode()).isZero(); + } + + /** + * The memo is {@code transient}, so it comes back as the zero default. Zero has to mean "not yet + * computed" for that to be safe: any other sentinel would make a deserialized expression report a + * hash of zero forever. + */ + @Test + void deserializedExpressionRecomputesItsHash() throws Exception { + var original = Expressions.constant("x"); + var expected = original.hashCode(); + + var bytes = new ByteArrayOutputStream(); + try (var out = new ObjectOutputStream(bytes)) { + out.writeObject(original); + } + Object copy; + try (var in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { + copy = in.readObject(); + } + + assertThat(copy).isEqualTo(original); + assertThat(copy.hashCode()).isEqualTo(expected); + } +}