currentFields = fields();
+ cborObjectRepresentation = CBOR_FACTORY.createCborRepresentation(currentFields, guessSerializedSize());
+ softenFieldsRef(currentFields);
}
serializationContext.writeCachedElement(cborObjectRepresentation);
}
@@ -816,6 +856,12 @@ private int guessSerializedSize() {
}
public long upperBoundForStringSize() {
+ // Callers (size-validation code paths in commands and CBOR sizing) need a real
+ // bound. Materialise the string representation lazily here when neither form
+ // exists; subsequent calls and the eventual serialization will reuse the cache.
+ if (jsonObjectStringRepresentation == null && cborObjectRepresentation == null) {
+ asJsonObjectString();
+ }
long max = 0L;
if (jsonObjectStringRepresentation != null) {
max = jsonObjectStringRepresentation.length();
diff --git a/json/src/test/java/org/eclipse/ditto/json/ImmutableJsonObjectBenchmark.java b/json/src/test/java/org/eclipse/ditto/json/ImmutableJsonObjectBenchmark.java
new file mode 100644
index 00000000000..ef888a419c7
--- /dev/null
+++ b/json/src/test/java/org/eclipse/ditto/json/ImmutableJsonObjectBenchmark.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.ditto.json;
+
+import java.util.concurrent.TimeUnit;
+
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.RunnerException;
+import org.openjdk.jmh.runner.options.Options;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+/**
+ * JMH micro-benchmark for {@link ImmutableJsonObject} construction, used to validate the
+ * lazy-encoding refactor in {@code SoftReferencedFieldMap}.
+ *
+ * Scenarios
+ * Each {@code @Benchmark} exercises the programmatic-build path
+ * ({@link JsonObjectBuilder}) that is the hot site in production for
+ * {@code Thing.toJson}, {@code Feature.toJson}, {@code AbstractCommandResponse.toJson}
+ * and similar emit-side flows.
+ *
+ *
+ * - buildSmallNoSerialize — build a 5-field object, discard. Measures
+ * pure construction cost; this is the maximum-saving case once eager pre-encoding is
+ * deferred (short-lived JsonObjects that never get serialised).
+ * - buildLargeNoSerialize — same, but 50 fields. Larger field map = more
+ * eager work being eliminated.
+ * - buildSmallThenToString — build then call {@code toString()}. After
+ * the refactor this should match the baseline (string is materialised lazily on
+ * first call) — serves as a no-regression check for the case where the cache
+ * is consumed.
+ * - buildLargeThenToString — same, 50 fields.
+ * - buildSmallAccessFields — build then call {@code getField} 5 times.
+ * Should be substantially faster after the refactor since no encoding happens.
+ *
+ *
+ * How to run
+ *
+ * mvn test-compile -pl json -am -Djapicmp.skip=true
+ * java -cp "$(mvn -pl json dependency:build-classpath -Dmdep.outputFile=/dev/stdout -q):json/target/classes:json/target/test-classes" \
+ * org.eclipse.ditto.json.ImmutableJsonObjectBenchmark
+ *
+ */
+@State(Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 2)
+@Fork(1)
+@Threads(1)
+public class ImmutableJsonObjectBenchmark {
+
+ private static final int LARGE_FIELD_COUNT = 50;
+
+ private String[] smallKeys;
+ private JsonValue[] smallValues;
+ private String[] largeKeys;
+ private JsonValue[] largeValues;
+
+ @Setup
+ public void setup() {
+ smallKeys = new String[]{"thingId", "policyId", "_revision", "definition", "_modified"};
+ smallValues = new JsonValue[]{
+ JsonValue.of("ns:thing-1"),
+ JsonValue.of("ns:policy-1"),
+ JsonValue.of(42L),
+ JsonValue.of("ditto:robot:1.0.0"),
+ JsonValue.of("2026-05-26T07:11:46Z"),
+ };
+
+ largeKeys = new String[LARGE_FIELD_COUNT];
+ largeValues = new JsonValue[LARGE_FIELD_COUNT];
+ for (int i = 0; i < LARGE_FIELD_COUNT; i++) {
+ largeKeys[i] = "feature_" + i;
+ largeValues[i] = JsonValue.of("value_" + i);
+ }
+ }
+
+ @Benchmark
+ public void buildSmallNoSerialize(final Blackhole bh) {
+ final JsonObjectBuilder builder = JsonFactory.newObjectBuilder();
+ for (int i = 0; i < smallKeys.length; i++) {
+ builder.set(smallKeys[i], smallValues[i]);
+ }
+ bh.consume(builder.build());
+ }
+
+ @Benchmark
+ public void buildLargeNoSerialize(final Blackhole bh) {
+ final JsonObjectBuilder builder = JsonFactory.newObjectBuilder();
+ for (int i = 0; i < largeKeys.length; i++) {
+ builder.set(largeKeys[i], largeValues[i]);
+ }
+ bh.consume(builder.build());
+ }
+
+ @Benchmark
+ public void buildSmallThenToString(final Blackhole bh) {
+ final JsonObjectBuilder builder = JsonFactory.newObjectBuilder();
+ for (int i = 0; i < smallKeys.length; i++) {
+ builder.set(smallKeys[i], smallValues[i]);
+ }
+ bh.consume(builder.build().toString());
+ }
+
+ @Benchmark
+ public void buildLargeThenToString(final Blackhole bh) {
+ final JsonObjectBuilder builder = JsonFactory.newObjectBuilder();
+ for (int i = 0; i < largeKeys.length; i++) {
+ builder.set(largeKeys[i], largeValues[i]);
+ }
+ bh.consume(builder.build().toString());
+ }
+
+ @Benchmark
+ public void buildSmallAccessFields(final Blackhole bh) {
+ final JsonObjectBuilder builder = JsonFactory.newObjectBuilder();
+ for (int i = 0; i < smallKeys.length; i++) {
+ builder.set(smallKeys[i], smallValues[i]);
+ }
+ final JsonObject obj = builder.build();
+ for (int i = 0; i < smallKeys.length; i++) {
+ bh.consume(obj.getValue(smallKeys[i]));
+ }
+ }
+
+ public static void main(final String[] args) throws RunnerException {
+ final Options opt = new OptionsBuilder()
+ .include(ImmutableJsonObjectBenchmark.class.getSimpleName())
+ .build();
+ new Runner(opt).run();
+ }
+}
diff --git a/json/src/test/java/org/eclipse/ditto/json/ImmutableJsonObjectTest.java b/json/src/test/java/org/eclipse/ditto/json/ImmutableJsonObjectTest.java
index 4c54b3b1ec7..194acc6f6c0 100644
--- a/json/src/test/java/org/eclipse/ditto/json/ImmutableJsonObjectTest.java
+++ b/json/src/test/java/org/eclipse/ditto/json/ImmutableJsonObjectTest.java
@@ -1516,43 +1516,69 @@ private static JsonFieldSelector selector(final String s) {
}
@Test
- public void validateSoftReferenceStrategy() throws IllegalAccessException, NoSuchFieldException {
+ public void buildingDoesNotEagerlyEncodeAnyRepresentation() throws Exception {
final ImmutableJsonObject jsonObject = ImmutableJsonObject.of(KNOWN_FIELDS);
- assertInternalCachesAreAsExpected(jsonObject, true);
- final Field valueListField = jsonObject.getClass().getDeclaredField("fieldMap");
- valueListField.setAccessible(true);
- final ImmutableJsonObject.SoftReferencedFieldMap
- valueList = (ImmutableJsonObject.SoftReferencedFieldMap) valueListField.get(jsonObject);
+ assertThat(getInternalField(jsonObject, "jsonObjectStringRepresentation"))
+ .as("string representation must not be eagerly created on construction")
+ .isNull();
+ assertThat(getInternalField(jsonObject, "cborObjectRepresentation"))
+ .as("CBOR representation must not be eagerly created on construction")
+ .isNull();
+ assertThat(getInternalField(jsonObject, "fieldsRef"))
+ .as("field map must be held strongly while no representation has been materialised")
+ .isInstanceOf(Map.class);
+ }
+
+ @Test
+ public void toStringMaterialisesStringRepAndSoftensFieldMap() throws Exception {
+ final ImmutableJsonObject jsonObject = ImmutableJsonObject.of(KNOWN_FIELDS);
+
+ // Trigger lazy materialisation.
+ jsonObject.toString();
+
+ assertThat(getInternalField(jsonObject, "jsonObjectStringRepresentation"))
+ .as("string representation is cached after first toString()")
+ .isNotNull();
+ assertThat(getInternalField(jsonObject, "fieldsRef"))
+ .as("field map is softened once a recoverable representation is cached")
+ .isInstanceOf(SoftReference.class);
+ }
- final Field softReferenceField = valueList.getClass().getDeclaredField("fieldsReference");
- softReferenceField.setAccessible(true);
- SoftReference softReference = (SoftReference) softReferenceField.get(valueList);
+ @Test
+ public void upperBoundForStringSizeMaterialisesStringRepLazily() throws Exception {
+ final ImmutableJsonObject jsonObject = ImmutableJsonObject.of(KNOWN_FIELDS);
+
+ // Size-validation callers (Modify commands, CBOR sizing) need a real bound.
+ assertThat(jsonObject.getUpperBoundForStringSize()).isPositive();
+ assertThat(getInternalField(jsonObject, "jsonObjectStringRepresentation"))
+ .as("upperBoundForStringSize triggers lazy materialisation of the string representation")
+ .isNotNull();
+ }
- softReference.clear();
+ @Test
+ public void softReferenceClearedAfterSerialisationRecoversFields() throws Exception {
+ final ImmutableJsonObject jsonObject = ImmutableJsonObject.of(KNOWN_FIELDS);
+ // Materialise so the field map is softened and we have a representation to recover from.
+ jsonObject.toString();
+ @SuppressWarnings("rawtypes")
+ final SoftReference softRef = (SoftReference) getInternalField(jsonObject, "fieldsRef");
+ softRef.clear();
+
+ // recoverFields() must parse the cached string representation back into a usable map.
assertThat(jsonObject.getValue(KNOWN_KEY_FOO)).isPresent();
}
- private void assertInternalCachesAreAsExpected(final JsonObject jsonObject, final boolean jsonExpected) {
- try {
- final Field valueListField = jsonObject.getClass().getDeclaredField("fieldMap");
- valueListField.setAccessible(true);
- final ImmutableJsonObject.SoftReferencedFieldMap
- fieldMap = (ImmutableJsonObject.SoftReferencedFieldMap) valueListField.get(jsonObject);
-
- final Field jsonStringField = fieldMap.getClass().getDeclaredField("jsonObjectStringRepresentation");
- jsonStringField.setAccessible(true);
- String jsonString = (String) jsonStringField.get(fieldMap);
-
- assertThat(jsonString != null).isEqualTo(jsonExpected);
- } catch (IllegalAccessException | NoSuchFieldException e) {
- System.err.println(
- "Failed to access internal caching fields in JsonObject using reflection. " +
- "This might just be a bug in the test."
- );
- e.printStackTrace();
- }
+ private static Object getInternalField(final JsonObject jsonObject, final String fieldName)
+ throws Exception {
+ final Field fieldMapField = jsonObject.getClass().getDeclaredField("fieldMap");
+ fieldMapField.setAccessible(true);
+ final ImmutableJsonObject.SoftReferencedFieldMap softFieldMap =
+ (ImmutableJsonObject.SoftReferencedFieldMap) fieldMapField.get(jsonObject);
+ final Field target = softFieldMap.getClass().getDeclaredField(fieldName);
+ target.setAccessible(true);
+ return target.get(softFieldMap);
}
}