Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -202,15 +202,24 @@ public void writeImmutableJsonObjectWritesExpectedSimple() throws IOException {

@Test
public void validateImmutableJsonObjectInternalCachingBehaviour() throws IOException {
final JsonObject objectWithSelfGeneratedCache = JsonFactory.newObjectBuilder(KNOWN_FIELDS.values()).build();
assertInternalCachesAreAsExpected(objectWithSelfGeneratedCache, true, false);

final ByteBuffer byteBuffer = cborFactory.toByteBuffer(objectWithSelfGeneratedCache);
// After the lazy-encoding refactor, a JsonObject produced by the builder holds neither
// representation until the first toString() / writeValue() / size-validating call.
final JsonObject objectFromBuilder = JsonFactory.newObjectBuilder(KNOWN_FIELDS.values()).build();
assertInternalCachesAreAsExpected(objectFromBuilder, false, false);

// toByteBuffer triggers two materialisations on the same object:
// determineSize -> getUpperBoundForStringSize -> asJsonObjectString (string rep)
// writeToOutputStream -> writeValue -> createCborRepresentation (cbor rep)
// Both are cached on the source object as a side effect of serialising it.
final ByteBuffer byteBuffer = cborFactory.toByteBuffer(objectFromBuilder);
final JsonObject objectWithCborCache = cborFactory.readFrom(byteBuffer).asObject();
assertInternalCachesAreAsExpected(objectWithSelfGeneratedCache, true, false);
final JsonObject objectWithJsonCache = JsonFactory.newObject(objectWithSelfGeneratedCache.toString());
assertInternalCachesAreAsExpected(objectWithSelfGeneratedCache, true, true);
assertInternalCachesAreAsExpected(objectFromBuilder, true, true);
// toString() now hits the already-cached string rep, no additional materialisation.
final JsonObject objectWithJsonCache = JsonFactory.newObject(objectFromBuilder.toString());
assertInternalCachesAreAsExpected(objectFromBuilder, true, true);

// Constructing from CBOR / JSON inputs continues to pass that representation through
// to the field map unchanged (no lazy materialisation needed).
assertInternalCachesAreAsExpected(objectWithCborCache, true, false);
assertInternalCachesAreAsExpected(objectWithJsonCache, false, true);
}
Expand Down
24 changes: 24 additions & 0 deletions json/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,34 @@
<artifactId>jsonassert</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths combine.children="append">
<path>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>

<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
Expand Down
106 changes: 76 additions & 30 deletions json/src/main/java/org/eclipse/ditto/json/ImmutableJsonObject.java
Original file line number Diff line number Diff line change
Expand Up @@ -608,30 +608,38 @@ static final class SoftReferencedFieldMap {
}


private String jsonObjectStringRepresentation;
private byte[] cborObjectRepresentation;
// The three mutable fields below participate in the lazy-encoding invariant:
// whenever fieldsRef holds a SoftReference, at least one representation must be
// non-null so recoverFields() can rebuild the map after a soft-clear. Without
// memory barriers, a thread that observes the SoftReference may not yet see the
// representation write that preceded it, which could trip the IllegalStateException
// in recoverFields. volatile gives us the cross-thread happens-before we need
// without taking a lock.
private volatile String jsonObjectStringRepresentation;
private volatile byte[] cborObjectRepresentation;
private int hashCode;
private SoftReference<Map<String, JsonField>> fieldsReference;
// Either a Map<String, JsonField> held strongly (when no serialised representation
// exists yet and we would have no way to recover the fields if the reference were
// cleared) or a SoftReference<Map<String, JsonField>> once a representation is
// available. {@link #fields()} unwraps both cases.
private volatile Object fieldsRef;

private SoftReferencedFieldMap(final Map<String, JsonField> jsonFieldMap,
@Nullable final String stringRepresentation, @Nullable final byte[] cborObjectRepresentation) {

requireNonNull(jsonFieldMap, "The fields of JSON object must not be null!");
fieldsReference = new SoftReference<>(Collections.unmodifiableMap(new LinkedHashMap<>(jsonFieldMap)));
jsonObjectStringRepresentation = stringRepresentation;
final Map<String, JsonField> immutable =
Collections.unmodifiableMap(new LinkedHashMap<>(jsonFieldMap));
this.jsonObjectStringRepresentation = stringRepresentation;
this.cborObjectRepresentation = cborObjectRepresentation;
if (jsonObjectStringRepresentation == null && cborObjectRepresentation == null) {
if (CBOR_FACTORY.isCborAvailable()) {
try {
this.cborObjectRepresentation = CBOR_FACTORY.createCborRepresentation(jsonFieldMap,
guessSerializedSize());
} catch (final IOException e) {
assert false; // this should not happen, so assertions will throw during testing
jsonObjectStringRepresentation = createStringRepresentation(jsonFieldMap);
}
} else {
jsonObjectStringRepresentation = createStringRepresentation(jsonFieldMap);
}
// Soft-reference the field map only when a serialised representation already
// exists; the representation is what {@link #recoverFields()} parses back when
// the soft reference is cleared under GC pressure. Without one, we must hold
// the map strongly to avoid data loss.
if (stringRepresentation != null || cborObjectRepresentation != null) {
this.fieldsRef = new SoftReference<>(immutable);
} else {
this.fieldsRef = immutable;
}
hashCode = 0;
}
Expand Down Expand Up @@ -722,22 +730,41 @@ Iterator<JsonField> getIterator() {
}

private Map<String, JsonField> fields() {
Map<String, JsonField> result = fieldsReference.get();
if (null == result) {
result = recoverFields();
fieldsReference = new SoftReference<>(result);
final Object ref = fieldsRef;
if (ref instanceof SoftReference) {
@SuppressWarnings("unchecked")
final SoftReference<Map<String, JsonField>> softRef =
(SoftReference<Map<String, JsonField>>) ref;
Map<String, JsonField> result = softRef.get();
if (null == result) {
result = recoverFields();
fieldsRef = new SoftReference<>(result);
}
Comment thread
thjaeckle marked this conversation as resolved.
return result;
}
@SuppressWarnings("unchecked")
final Map<String, JsonField> strong = (Map<String, JsonField>) ref;
return strong;
}

private void softenFieldsRef(final Map<String, JsonField> fields) {
if (!(fieldsRef instanceof SoftReference)) {
fieldsRef = new SoftReference<>(fields);
}
return result;
}
Comment thread
thjaeckle marked this conversation as resolved.

private Map<String, JsonField> recoverFields() {
final Map<String, JsonField> recovered;
if (CBOR_FACTORY.isCborAvailable() && cborObjectRepresentation != null) {
return parseToMap(cborObjectRepresentation);
}
if (jsonObjectStringRepresentation != null) {
return parseToMap(jsonObjectStringRepresentation);
recovered = parseToMap(cborObjectRepresentation);
} else if (jsonObjectStringRepresentation != null) {
recovered = parseToMap(jsonObjectStringRepresentation);
} else {
throw new IllegalStateException("Fatal cache miss on JsonObject");
}
throw new IllegalStateException("Fatal cache miss on JsonObject");
// Wrap so callers using getIterator() / values().iterator().remove() cannot
// mutate the recovered field map and break this object's immutability.
return Collections.unmodifiableMap(recovered);
}

private static Map<String, JsonField> parseToMap(final String jsonObjectString) {
Expand Down Expand Up @@ -777,7 +804,16 @@ public boolean equals(final Object o) {
Arrays.equals(cborObjectRepresentation, that.cborObjectRepresentation)) {
return true;
}
return Objects.equals(fields(), that.fields());
// Two JsonObjects with the same field set are equal regardless of insertion
// order (Map.equals is set-based). When that returns false, fall back to
// comparing the rendered JSON string form: this catches cases where the field
// maps hold semantically-equivalent but class-incompatible JsonValue instances
// (e.g. NullAttributes vs JsonValue.nullLiteral(), both rendering as "null").
// Pre-refactor, the eager CBOR encoding masked this asymmetry because both
// null kinds encode to the same byte; the lazy refactor preserves master's
// observable equality contract by combining both checks here.
return Objects.equals(fields(), that.fields())
|| asJsonObjectString().equals(that.asJsonObjectString());
}

@Override
Expand All @@ -792,14 +828,18 @@ public int hashCode() {

String asJsonObjectString() {
if (jsonObjectStringRepresentation == null) {
jsonObjectStringRepresentation = createStringRepresentation(this.fields());
final Map<String, JsonField> currentFields = fields();
jsonObjectStringRepresentation = createStringRepresentation(currentFields);
softenFieldsRef(currentFields);
}
return jsonObjectStringRepresentation;
}

void writeValue(final SerializationContext serializationContext) throws IOException {
if (CBOR_FACTORY.isCborAvailable() && cborObjectRepresentation == null) {
cborObjectRepresentation = CBOR_FACTORY.createCborRepresentation(this.fields(), guessSerializedSize());
final Map<String, JsonField> currentFields = fields();
cborObjectRepresentation = CBOR_FACTORY.createCborRepresentation(currentFields, guessSerializedSize());
softenFieldsRef(currentFields);
}
serializationContext.writeCachedElement(cborObjectRepresentation);
}
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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}.
*
* <h2>Scenarios</h2>
* <p>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.</p>
*
* <ul>
* <li><b>buildSmallNoSerialize</b> &mdash; 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).</li>
* <li><b>buildLargeNoSerialize</b> &mdash; same, but 50 fields. Larger field map = more
* eager work being eliminated.</li>
* <li><b>buildSmallThenToString</b> &mdash; build then call {@code toString()}. After
* the refactor this should match the baseline (string is materialised lazily on
* first call) &mdash; serves as a no-regression check for the case where the cache
* <em>is</em> consumed.</li>
* <li><b>buildLargeThenToString</b> &mdash; same, 50 fields.</li>
* <li><b>buildSmallAccessFields</b> &mdash; build then call {@code getField} 5 times.
* Should be substantially faster after the refactor since no encoding happens.</li>
* </ul>
*
* <h2>How to run</h2>
* <pre>
* 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
* </pre>
*/
@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();
}
}
Loading
Loading