Skip to content

fix: serialize JSONB.parseObject per type to prevent concurrent $ref NPE - #16384

Closed
mmustafasenoglu wants to merge 2 commits into
apache:3.3from
mmustafasenoglu:fix/fastjson2-concurrent-ref-npe
Closed

fix: serialize JSONB.parseObject per type to prevent concurrent $ref NPE#16384
mmustafasenoglu wants to merge 2 commits into
apache:3.3from
mmustafasenoglu:fix/fastjson2-concurrent-ref-npe

Conversation

@mmustafasenoglu

Copy link
Copy Markdown

What is the purpose of the change

Fix a race condition in FastJson2ObjectInput where concurrent deserialization of JSONB bytes containing $ref references produces null fields.

Brief changelog

  • Add striped locks (64 stripes) to FastJson2ObjectInput keyed by target class
  • Wrap JSONB.parseObject calls in synchronized (getStripe(cls)) blocks
  • Add getStripe(Class<?>) helper method

Related issue

Fixes #16368

Detailed explanation

Root cause

When multiple objects reference the same shared singleton (e.g., List.of()), fastjson2 serialization writes $ref references instead of duplicating the value. During concurrent deserialization:

  1. Multiple threads encounter an ObjectReader cache miss for the same type
  2. fastjson2's ObjectReaderProvider.getObjectReaderInternal has a non-atomic check-then-act: if (objectReader == null) { createObjectReader(...); putIfAbsent(...); }
  3. Concurrent createObjectReader calls produce corrupted ObjectReader instances (incomplete FieldReader arrays)
  4. $ref resolution via fieldReader.accept() fails silently → fields remain null

Fix

Striped locks keyed by target class serialize JSONB.parseObject calls per type. Concurrent deserialization of different types is unaffected; only same-type concurrent deserialization is serialized, preventing the fastjson2 race condition.

The 64-stripe design provides sufficient concurrency for typical applications while bounding memory usage.

How to reproduce

See the reproduction test in the issue: 200 threads with CyclicBarrier, cache cleared each round, ~1% of tasks produce null fields.

When using FastJson2Serialization with  references (e.g., shared
List.of() singletons serialized by fastjson2), concurrent deserialization
under cache-miss conditions produces corrupted ObjectReader instances in
fastjson2's ObjectReaderProvider.getObjectReaderInternal (non-atomic
check-then-act). This causes $ref resolution to silently fail, leaving
fields as null.

Add striped locks (64 stripes) keyed by target class to serialize
JSONB.parseObject calls per type. Concurrent deserialization of different
types is unaffected; only same-type concurrent deserialization is
serialized, preventing the race condition.

Fixes #16368

Signed-off-by: Mustafa Senoglu <mmustafasenoglu0@gmail.com>
@codecov-commenter

codecov-commenter commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 64.28571% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.88%. Comparing base (d0bf5c3) to head (45b0cc2).

Files with missing lines Patch % Lines
...mmon/serialize/fastjson2/FastJson2ObjectInput.java 64.28% 2 Missing and 3 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##                3.3   #16384      +/-   ##
============================================
+ Coverage     60.86%   60.88%   +0.01%     
- Complexity    11763    11769       +6     
============================================
  Files          1953     1953              
  Lines         89262    89272      +10     
  Branches      13471    13472       +1     
============================================
+ Hits          54331    54350      +19     
- Misses        29329    29333       +4     
+ Partials       5602     5589      -13     
Flag Coverage Δ
integration-tests-java21 32.15% <64.28%> (+0.04%) ⬆️
integration-tests-java8 32.22% <64.28%> (+0.06%) ⬆️
samples-tests-java21 32.19% <64.28%> (+0.04%) ⬆️
samples-tests-java8 29.80% <0.00%> (+<0.01%) ⬆️
unit-tests-java11 59.13% <50.00%> (+0.02%) ⬆️
unit-tests-java17 58.59% <50.00%> (-0.01%) ⬇️
unit-tests-java21 58.56% <50.00%> (-0.04%) ⬇️
unit-tests-java25 58.52% <50.00%> (-0.02%) ⬇️
unit-tests-java8 59.13% <50.00%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Add tests for the striped lock mechanism introduced in this PR:

- testReadObjectWithType: exercises readObject(Class, Type) overload
- testReadObjectWithTypeList: exercises readObject with ParameterizedType for List
- testReadObjectNullClass: exercises getStripe(null) branch
- testConcurrentDeserializationSameType: concurrent deserialization of same type (same stripe)
- testConcurrentDeserializationDifferentTypes: concurrent deserialization of different types (different stripes)
- testReadObjectWithTypeAndString: readObject(Class, Type) with String
- testReadObjectWithTypeMap: readObject(Class, Type) with ParameterizedType for Map

These tests improve patch coverage by covering:
- getStripe() with null cls parameter
- readObject(Class<T>, Type) code path
- synchronized block execution across multiple threads

Signed-off-by: Mustafa Senoglu <mmustafasenoglu0@gmail.com>
@mmustafasenoglu

Copy link
Copy Markdown
Author

Hi, added FastJson2StripedLockTest to improve patch coverage (previously 64.28%).

New test methods:

Test Coverage target
testReadObjectWithType readObject(Class<T>, Type) overload
testReadObjectWithTypeList readObject with ParameterizedType (List)
testReadObjectNullClass getStripe(null) branch
testConcurrentDeserializationSameType synchronized block, same stripe
testConcurrentDeserializationDifferentTypes synchronized block, different stripes
testReadObjectWithTypeAndString readObject(Class, Type) with String
testReadObjectWithTypeMap readObject(Class, Type) with ParameterizedType (Map)

These should cover the previously uncovered getStripe(null) branch, the readObject(Class, Type) code path, and exercise the synchronized blocks under concurrency.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses a concurrency bug in Dubbo’s fastjson2-based deserialization path by serializing JSONB.parseObject per target type using striped locking, aiming to prevent corrupted ObjectReader instances (and ensuing $ref resolution issues) under concurrent cache-miss conditions.

Changes:

  • Added 64 striped locks to FastJson2ObjectInput and wrapped JSONB.parseObject calls with synchronized (getStripe(cls)).
  • Introduced getStripe(Class<?>) helper for type-based lock striping.
  • Added a new JUnit test class intended to exercise concurrent deserialization behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
dubbo-serialization/dubbo-serialization-fastjson2/src/main/java/org/apache/dubbo/common/serialize/fastjson2/FastJson2ObjectInput.java Adds striped locking around JSONB parsing to mitigate fastjson2 ObjectReader creation races.
dubbo-serialization/dubbo-serialization-fastjson2/src/test/java/org/apache/dubbo/common/serialize/fastjson2/FastJson2StripedLockTest.java Adds concurrency-focused tests for fastjson2 deserialization scenarios.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +29 to +39
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
Comment on lines +46 to +56
private Serialization createSerialization() {
FrameworkModel frameworkModel = new FrameworkModel();
return frameworkModel
.getExtensionLoader(Serialization.class)
.getExtension("fastjson2");
}

private URL createURL() {
FrameworkModel frameworkModel = new FrameworkModel();
return URL.valueOf("").setScopeModel(frameworkModel);
}
Comment on lines +147 to +153
void testConcurrentDeserializationSameType() throws Exception {
FrameworkModel frameworkModel = new FrameworkModel();
Serialization serialization =
frameworkModel.getExtensionLoader(Serialization.class).getExtension("fastjson2");
URL url = URL.valueOf("").setScopeModel(frameworkModel);

TrustedPojo pojo = new TrustedPojo(123.0);
Comment on lines +219 to +222
private static Object getStripe(Class<?> cls) {
int hash = cls == null ? 0 : cls.getName().hashCode();
return STRIPES[(hash & 0x7FFFFFFF) % STRIPE_COUNT];
}
@zrlw

zrlw commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

The approach that #16369 used might be more elegant

@zrlw zrlw closed this Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Concurrent deserialization with $ref references produces null fields in FastJson2Serialization

4 participants