Pre-check
Search before asking
Apache Dubbo Component
Java SDK (apache/dubbo)
Dubbo Version
Environment
- Dubbo: 3.3.6
- fastjson2: 2.0.61 / 2.0.62
- JDK: 25.0.3 (also verified on JDK 17/21)
- OS: Windows / Linux
- serialization=fastjson2
Steps to reproduce this issue
Test Code
package com.example.dubbo.bug;
import org.apache.dubbo.common.URL;
import org.apache.dubbo.common.beans.factory.ScopeBeanFactory;
import org.apache.dubbo.common.serialize.fastjson2.FastJson2Serialization;
import org.apache.dubbo.common.serialize.ObjectInput;
import org.apache.dubbo.common.serialize.ObjectOutput;
import org.apache.dubbo.common.utils.SerializeSecurityManager;
import org.apache.dubbo.rpc.model.FrameworkModel;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.Serializable;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Reproduces a bug in Dubbo 3.3.6 FastJson2Serialization where concurrent
* deserialization of bytes containing $ref references produces null fields.
*
* <p>Self-contained test, no business classes required.
*
* <p>Environment: Dubbo 3.3.6, fastjson2 2.0.61+, JDK 17+.
*/
public class DubboFastjson2ConcurrentRefBugTest {
// ==================== Self-contained POJOs (no business classes) ====================
public static class Outer implements Serializable {
private List<Inner> items;
public List<Inner> getItems() { return items; }
public void setItems(List<Inner> items) { this.items = items; }
}
public static class Inner implements Serializable {
private String name;
private List<Long> ids;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public List<Long> getIds() { return ids; }
public void setIds(List<Long> ids) { this.ids = ids; }
}
// ==================== Environment setup ====================
/**
* Simulate production: STRICT mode + register allowlist.
* Production Dubbo defaults to STRICT mode; POJO classes must be added to the allowlist
* before they can be deserialized.
*/
@BeforeAll
static void simulateProductionEnvironment() throws Exception {
FrameworkModel frameworkModel = FrameworkModel.defaultModel();
ScopeBeanFactory beanFactory = frameworkModel.getBeanFactory();
SerializeSecurityManager securityManager = beanFactory.getBean(SerializeSecurityManager.class);
// Simulate production: checkStatus=null (allow deserialization, skip STRICT check)
Field checkStatusField = SerializeSecurityManager.class.getDeclaredField("checkStatus");
checkStatusField.setAccessible(true);
checkStatusField.set(securityManager, null);
// Register POJO classes to the allowlist
securityManager.addToAllowed(Outer.class.getName());
securityManager.addToAllowed(Inner.class.getName());
securityManager.addToAllowed("java.util.ImmutableCollections$ListN");
securityManager.addToAllowed("java.util.ImmutableCollections$List12");
securityManager.addToAllowed("java.util.ArrayList");
securityManager.addToAllowed("java.lang.Long");
securityManager.addToAllowed("java.lang.String");
}
// ==================== Helpers ====================
/**
* Build test data: one Outer containing 20 Inner objects, all referencing the same
* List.of() singleton for their ids field.
* During serialization, only items[0].ids is written as the actual value;
* items[1..19].ids are written as $ref references.
*/
private static byte[] buildDubboBytes() throws Exception {
Outer outer = new Outer();
List<Inner> items = new ArrayList<>();
List<Long> sharedIds = List.of(); // shared singleton → produces $ref
for (int i = 0; i < 20; i++) {
Inner inner = new Inner();
inner.setName("item-" + i);
inner.setIds(sharedIds);
items.add(inner);
}
outer.setItems(items);
FastJson2Serialization serialization = new FastJson2Serialization();
URL dubboUrl = URL.valueOf("dubbo://127.0.0.1:20880/DemoService?serialization=fastjson2");
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutput output = serialization.serialize(dubboUrl, baos);
output.writeObject(outer);
output.flushBuffer();
return baos.toByteArray();
}
/**
* Clear fastjson2 ObjectReaderProvider caches via reflection to force a cache miss.
*/
@SuppressWarnings("unchecked")
private static void clearObjectReaderCache() throws Exception {
com.alibaba.fastjson2.reader.ObjectReaderProvider provider =
com.alibaba.fastjson2.JSONFactory.getDefaultObjectReaderProvider();
for (String fieldName : new String[]{"cache", "cacheFieldBased"}) {
Field f = com.alibaba.fastjson2.reader.ObjectReaderProvider.class.getDeclaredField(fieldName);
f.setAccessible(true);
Object cache = f.get(provider);
if (cache instanceof Map) {
((Map<?, ?>) cache).clear();
}
}
Field readerCacheField =
com.alibaba.fastjson2.reader.ObjectReaderProvider.class.getDeclaredField("readerCache");
readerCacheField.setAccessible(true);
readerCacheField.set(null, null);
}
/**
* Deserialize using Dubbo FastJson2Serialization and count the number of null fields.
*/
private static int countNullFields(byte[] data) throws Exception {
FastJson2Serialization serialization = new FastJson2Serialization();
URL dubboUrl = URL.valueOf("dubbo://127.0.0.1:20880/DemoService?serialization=fastjson2");
ObjectInput input = serialization.deserialize(dubboUrl, new ByteArrayInputStream(data));
Object result = input.readObject(Outer.class);
if (!(result instanceof Outer outer)) {
return -1;
}
List<Inner> items = outer.getItems();
if (items == null) {
return -1;
}
int nullCount = 0;
for (Inner inner : items) {
if (inner == null || inner.getIds() == null) {
nullCount++;
}
}
return nullCount;
}
// ==================== Test cases ====================
/**
* Baseline: single-thread deserialization produces no null.
*/
@Test
void baseline_singleThread_noNull() throws Exception {
byte[] data = buildDubboBytes();
int nullCount = countNullFields(data);
System.out.println("[Baseline] single-thread null fields: " + nullCount);
assertTrue(nullCount == 0,
"Single-thread deserialization should not produce null fields, actual: " + nullCount);
}
/**
* Core reproduction test: platform threads + CyclicBarrier + cache cleared each round.
*
* <p>All platform threads are pre-created and synchronized with a CyclicBarrier so they
* start deserializing at the same time. The ObjectReader cache is cleared each round
* to force a cache miss and trigger concurrent createObjectReader.
*
* <p>The bug is probabilistic; typically reproduced within 3-5 rounds.
*/
@Test
void reproduce_platformThread_concurrentRefNpe() throws Exception {
byte[] data = buildDubboBytes();
int rounds = 10;
int threadCount = 200;
AtomicInteger totalNullTasks = new AtomicInteger(0);
AtomicInteger totalNullFields = new AtomicInteger(0);
AtomicInteger totalSuccessTasks = new AtomicInteger(0);
List<String> details = Collections.synchronizedList(new ArrayList<>());
System.out.println("==================== Platform Threads + CyclicBarrier + Dubbo FastJson2 Concurrent $ref NPE Reproduction ====================");
System.out.println("Dubbo: " + org.apache.dubbo.common.Version.getVersion());
System.out.println("fastjson2: " + com.alibaba.fastjson2.JSON.VERSION);
System.out.println("JDK: " + System.getProperty("java.version"));
System.out.println("Rounds: " + rounds + ", threads per round: " + threadCount);
System.out.println("ObjectReader cache cleared each round to force cache miss");
System.out.println();
for (int roundIdx = 1; roundIdx <= rounds; roundIdx++) {
final int round = roundIdx;
clearObjectReaderCache();
AtomicInteger roundNullTasks = new AtomicInteger(0);
AtomicInteger roundNullFields = new AtomicInteger(0);
AtomicInteger roundSuccessTasks = new AtomicInteger(0);
CyclicBarrier barrier = new CyclicBarrier(threadCount);
CountDownLatch endLatch = new CountDownLatch(threadCount);
Thread[] threads = new Thread[threadCount];
for (int t = 0; t < threadCount; t++) {
final int taskId = t;
threads[t] = new Thread(() -> {
try {
barrier.await(); // all threads start together
int nullCount = countNullFields(data);
if (nullCount > 0) {
roundNullTasks.incrementAndGet();
roundNullFields.addAndGet(nullCount);
if (details.size() < 30) {
details.add("round" + round + "-task[" + taskId + "] null=" + nullCount);
}
} else {
roundSuccessTasks.incrementAndGet();
}
} catch (Throwable e) {
if (details.size() < 10) {
details.add("round" + round + "-task[" + taskId + "] exception: " + e);
}
} finally {
endLatch.countDown();
}
}, "platform-worker-" + t);
}
for (Thread thread : threads) {
thread.start();
}
endLatch.await();
int nt = roundNullTasks.get();
totalNullTasks.addAndGet(nt);
totalNullFields.addAndGet(roundNullFields.get());
totalSuccessTasks.addAndGet(roundSuccessTasks.get());
System.out.println("Round " + round + ": success=" + roundSuccessTasks.get()
+ " nullTasks=" + nt + " nullFields=" + roundNullFields.get());
}
System.out.println();
System.out.println("========== Summary ==========");
System.out.println("Total tasks: " + (rounds * threadCount));
System.out.println("Success tasks: " + totalSuccessTasks.get());
System.out.println("Null tasks: " + totalNullTasks.get());
System.out.println("Null fields: " + totalNullFields.get());
if (!details.isEmpty()) {
System.out.println();
System.out.println("--- null details (first 30) ---");
details.forEach(System.out::println);
}
System.out.println();
if (totalNullTasks.get() > 0) {
System.out.println("Conclusion: Reproduced! Dubbo FastJson2 concurrent deserialization of $ref references has a bug");
} else {
System.out.println("Conclusion: Not reproduced this run (the bug is probabilistic, please run a few more times)");
}
}
/**
* Control test: concurrent but cache hit (cache not cleared), confirms no null.
* Proves the root cause is concurrent createObjectReader on cache miss.
*/
@Test
void control_concurrentCacheHit_noNull() throws Exception {
byte[] data = buildDubboBytes();
// Warm up the cache: deserialize once in a single thread
countNullFields(data);
int rounds = 5;
int threadCount = 200;
AtomicInteger totalNullTasks = new AtomicInteger(0);
System.out.println("==================== Control: concurrent + cache hit ====================");
System.out.println("After warming up the cache, concurrent deserialization should not produce null");
for (int roundIdx = 1; roundIdx <= rounds; roundIdx++) {
final int round = roundIdx;
AtomicInteger roundNullTasks = new AtomicInteger(0);
AtomicInteger roundSuccessTasks = new AtomicInteger(0);
CyclicBarrier barrier = new CyclicBarrier(threadCount);
CountDownLatch endLatch = new CountDownLatch(threadCount);
Thread[] threads = new Thread[threadCount];
for (int t = 0; t < threadCount; t++) {
threads[t] = new Thread(() -> {
try {
barrier.await();
int nullCount = countNullFields(data);
if (nullCount > 0) {
roundNullTasks.incrementAndGet();
} else {
roundSuccessTasks.incrementAndGet();
}
} catch (Throwable e) {
// ignore
} finally {
endLatch.countDown();
}
});
}
for (Thread thread : threads) {
thread.start();
}
endLatch.await();
totalNullTasks.addAndGet(roundNullTasks.get());
System.out.println("Round " + round + ": success=" + roundSuccessTasks.get()
+ " nullTasks=" + roundNullTasks.get());
}
System.out.println();
System.out.println("Total null tasks: " + totalNullTasks.get());
assertTrue(totalNullTasks.get() == 0,
"Cache hit should not produce null, actual: " + totalNullTasks.get());
}
}
What you expected to happen
Summary
When using Dubbo FastJson2Serialization to deserialize JSONB bytes that contain $ref references, some fields of the deserialized result become null under concurrent load (platform threads synchronized with CyclicBarrier). The issue is probabilistic — deserializing the same bytes multiple times succeeds in most cases, but a small number of attempts produce null fields.
Root Cause
The FastJson2ObjectInput constructor calls fastjson2CreatorManager.setCreator(classLoader) on every invocation, which sets a fastjson2 ThreadLocal via JSONFactory.setContextReaderCreator(). Combined with fastjson2's ObjectReaderProvider.getObjectReaderInternal, which on a cache miss first calls createObjectReader then putIfAbsent (a non-atomic check-then-act), concurrent cache misses across multiple threads produce corrupted ObjectReader instances. When $ref references are subsequently resolved via fieldReader.accept(), the write fails silently and the field remains null.
Bug Chain
1. Multiple objects reference the same shared singleton (e.g. List.of())
→ During serialization, fastjson2 writes the actual value for items[0].ids
and $ref references for items[1..n].ids
2. Dubbo FastJson2ObjectInput constructor calls setCreator
→ JSONFactory.setContextReaderCreator(creator) → ThreadLocal.set(creator)
3. Under concurrent deserialization, an ObjectReader cache miss occurs for some type
→ ObjectReaderProvider.getObjectReaderInternal line 1151: if (objectReader == null)
→ Multiple threads enter createObjectReader simultaneously (non-atomic, not thread-safe)
4. Concurrent createObjectReader produces a corrupted ObjectReader
(FieldReader array is incomplete or incorrect)
5. handleResolveTasks resolves $ref, but fieldReader.accept() fails
→ the field keeps its initial value (null)
Trigger Conditions
- Serialization produces
$ref references: multiple objects reference the same shared singleton (e.g. List.of(), Collections.emptyList(), or the same List instance referenced by multiple fields)
- Dubbo FastJson2Serialization deserialization: goes through
FastJson2ObjectInput → setCreator
- Concurrency + ObjectReader cache miss: cold start or first deserialization of a given type
- Platform threads synchronized with
CyclicBarrier are sufficient to reproduce (virtual threads trigger it more easily due to faster startup, but are not required)
Reproduce
Test Output
==================== Platform Threads + CyclicBarrier ====================
Dubbo: 3.3.6
fastjson2: 2.0.61
JDK: 25.0.3
Rounds: 10, threads per round: 200
ObjectReader cache is cleared each round to force cache miss
Round 1: success=200 nullTasks=0 nullFields=0
Round 2: success=198 nullTasks=2 nullFields=38
Round 3: success=198 nullTasks=2 nullFields=38
Round 4: success=197 nullTasks=3 nullFields=57
Round 5: success=198 nullTasks=2 nullFields=38
Round 6: success=198 nullTasks=2 nullFields=38
Round 7: success=199 nullTasks=1 nullFields=19
Round 8: success=199 nullTasks=1 nullFields=19
Round 9: success=198 nullTasks=2 nullFields=38
Round 10: success=197 nullTasks=3 nullFields=47
Total tasks: 2000
Success tasks: 1982
Null tasks: 18
Null fields: 332
Conclusion: Reproduced! Dubbo FastJson2 concurrent deserialization of $ref references has a bug
Null pattern: out of 20 items, items[0] is always correct (actual value, no $ref), while items[1..19] are all null ($ref resolution failed).
Test Cases
| Test |
Scenario |
Result |
Proves |
baseline_singleThread_noNull |
single thread |
0 null |
bytes are correct |
reproduce_platformThread_concurrentRefNpe |
platform threads + CyclicBarrier + cache miss |
18/2000 null |
reproduces the bug |
control_concurrentCacheHit_noNull |
concurrent + cache hit |
0 null |
root cause is concurrent cache miss |
Suggested Fix
Either:
- Dubbo's
FastJson2ObjectInput constructor should add concurrency protection around the setCreator call, or
- fastjson2's
ObjectReaderProvider.getObjectReaderInternal cache-miss path should be atomized using ConcurrentHashMap.computeIfAbsent (to prevent multiple threads from concurrently invoking createObjectReader).
Workaround
At the application layer, eliminate the source of $ref references: replace shared singletons such as List.of() / Collections.emptyList() with new ArrayList<>().
Anything else
No response
Do you have a (mini) reproduction demo?
Are you willing to submit a pull request to fix on your own?
Code of Conduct
Pre-check
Search before asking
Apache Dubbo Component
Java SDK (apache/dubbo)
Dubbo Version
Environment
Steps to reproduce this issue
Test Code
What you expected to happen
Summary
When using Dubbo
FastJson2Serializationto deserialize JSONB bytes that contain$refreferences, some fields of the deserialized result becomenullunder concurrent load (platform threads synchronized withCyclicBarrier). The issue is probabilistic — deserializing the same bytes multiple times succeeds in most cases, but a small number of attempts produce null fields.Root Cause
The
FastJson2ObjectInputconstructor callsfastjson2CreatorManager.setCreator(classLoader)on every invocation, which sets a fastjson2 ThreadLocal viaJSONFactory.setContextReaderCreator(). Combined with fastjson2'sObjectReaderProvider.getObjectReaderInternal, which on a cache miss first callscreateObjectReaderthenputIfAbsent(a non-atomic check-then-act), concurrent cache misses across multiple threads produce corruptedObjectReaderinstances. When$refreferences are subsequently resolved viafieldReader.accept(), the write fails silently and the field remainsnull.Bug Chain
Trigger Conditions
$refreferences: multiple objects reference the same shared singleton (e.g.List.of(),Collections.emptyList(), or the same List instance referenced by multiple fields)FastJson2ObjectInput→setCreatorCyclicBarrierare sufficient to reproduce (virtual threads trigger it more easily due to faster startup, but are not required)Reproduce
Test Output
Null pattern: out of 20 items,
items[0]is always correct (actual value, no$ref), whileitems[1..19]are all null ($refresolution failed).Test Cases
baseline_singleThread_noNullreproduce_platformThread_concurrentRefNpecontrol_concurrentCacheHit_noNullSuggested Fix
Either:
FastJson2ObjectInputconstructor should add concurrency protection around thesetCreatorcall, orObjectReaderProvider.getObjectReaderInternalcache-miss path should be atomized usingConcurrentHashMap.computeIfAbsent(to prevent multiple threads from concurrently invokingcreateObjectReader).Workaround
At the application layer, eliminate the source of
$refreferences: replace shared singletons such asList.of()/Collections.emptyList()withnew ArrayList<>().Anything else
No response
Do you have a (mini) reproduction demo?
Are you willing to submit a pull request to fix on your own?
Code of Conduct