Fix #6227: guard POJOPropertiesCollector.collectAll() against concurrent calls - #6228
Conversation
|
A note on the choice of
Alternatives if a reviewer prefers: a private |
…nst concurrent calls `BasicBeanDescription.findProperties()` lazily triggers `POJOPropertiesCollector.collectAll()`, which mutates collector instance state (`_potentialCreators`, any-getter/setter lists, ...). If a `BeanDescription` is shared across threads, concurrent first calls corrupt that state, surfacing as `ConcurrentModificationException` or "Conflicting property-based creators". Make `collectAll()` synchronized with a double-check on a now-volatile `_collected` flag, and synchronize `BasicBeanDescription._properties()`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e29ac36 to
1ab80e4
Compare
…)` thread-safe `findDefaultViews()` set `_defaultViewsResolved = true` BEFORE computing and assigning `_defaultViews`, so a second thread calling it concurrently on a shared `BeanDescription` could observe the flag and return `null` views, silently changing `@JsonView` filtering. Assign the value before the flag, and synchronize the method to match `_properties()`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Code Review ✅ Approved🟡 Medium risk · Synchronization and lazy-state changes alter concurrent property and view introspection behavior Adds OptionsAuto-apply is off → Gitar will not commit updates to this branch. Comment with these commands to change the behavior for this request:
Was this helpful? React with 👍 / 👎 | Powered by Gitar — free for open source |
|
Fixed in 2.21 for 2.21.8; merged forward to 2.22, 2.x, 3.1, 3.2, 3.x. |
Fixes #6227
Three races on a shared
BeanDescription, each with a test that fails without the fix.1.
findProperties()→POJOPropertiesCollector.collectAll()collectAll()is not just building the localpropsmap: it also mutates collector instance state (_potentialCreators = new PotentialCreators(),_fieldRenameMappings, the any-getter/any-setterLinkedLists,_creatorProperties, ...). Two threads that both observe_collected == falserun it concurrently on the same instance:IllegalArgumentException: Conflicting property-based creators— both threads register the same creator constructor into the samePotentialCreatorsConcurrentModificationException—_sortProperties()iterates a list the other thread is still appending toFix:
_collectedbecomesvolatileandcollectAll()synchronized, with an early return if another thread finished collection while we waited on the lock. The existingif (!_collected) collectAll();accessors thus become a standard double-checked pattern.BasicBeanDescription._properties()is synchronized too, closing the (benign) race where two threads each fetched their own list copy. This only guards the lazy initialization: the returnedListitself is not thread-safe (concurrentaddProperty()/removeProperty()vs. readers is out of scope here).2.
findDefaultViews()returnsnullviewsSeparate bug in the same class, found while stress-testing the above:
A second thread sees the flag set and returns
null. This is a plain ordering bug rather than a memory-visibility one, so it does not depend on the memory model — and it fails silently:@JsonViewfiltering is then applied as if the class had no view annotation, instead of throwing.Fix: assign the value before the flag, and synchronize the method to match
_properties().3.
findJsonValueAccessor()/findJsonKeyAccessor()→_resolveFieldVsGetter()When both a field and its getter carry
@JsonValue(or@JsonKey),POJOPropertiesCollector.getJsonValueAccessor()/getJsonKeyAccessor()resolve the conflict lazily, on each call, by removing entries from the shared accessor list (accessors.remove(0)/remove(1)). This runs after collection and outside thecollectAll()lock, so two threads that both seesize() > 1remove entries concurrently:IndexOutOfBoundsException: Index: 1, Size: 1— one thread removed an entry between the other's size check andget(1)IndexOutOfBoundsException: Index: 0, Size: 0— both threads removed an entry, leaving the list emptyFix: both getters are
synchronized(same monitor ascollectAll(), which is reentrant). Resolution stays lazy, so the "Multiple 'as-value' properties" error is still reported on access rather than during collection.Scope
Jackson does not share
BeanDescriptions across threads itself (BasicClassIntrospectorconstructs them per call in 2.x; in 3.xClassIntrospector.forOperation()gives each context its own per-operation cache), so both bugs need user code or a framework to hold one and use it from several threads — exactly the issue's repro. Nothing documents that as unsupported, and both fixes are cheap.synchronizedis not part of the method descriptor, so there is no API/binary change.Tests
BeanDescriptionConcurrent6227Test— ports the issue's repro (16 threads, 50 rounds,CyclicBarrier), using a@JsonCreatorPOJO rather than a record so it runs on Java 8. Fails on the first round without the fix.BeanDescriptionConcurrent6227Test.testConcurrentFindJsonValueAccessor()/testConcurrentFindJsonKeyAccessor()— same harness, with the annotation on both a field and its getter; asserts the getter wins. Fails on every run without the fix.BeanDescriptionDefaultViewsRaceTest— deterministic, not probabilistic: anAnnotationIntrospectorblocks inside the firstfindViews()call, holding the window open, so the second thread observesnullon every run without the fix.Full
PrimarySuitegreen with all three changes (3903 tests).Beyond these, I stress-tested ordinary shared-
ObjectMapperuse under load (~250k ops): cyclic/self-referencing graphs, polymorphic (Id.CLASS,EXTERNAL_PROPERTY,defaultImpl), generics/TypeReference, root-name wrapping, dates, object ids, views, filters, any-getter/setter, builders, tree model,convertValue,MappingIterator, sharedObjectReader/ObjectWriter, and LRU eviction pressure — including comparing 72,000 concurrent outputs against single-threaded golden strings to catch silent corruption. No further failures surfaced, so nothing else is changed here.Branches
Targeted at 2.21 for merge-up. The issue was reported against 3.1.5 and all changes apply to 3.1 as well (the first was originally developed and verified there). The javadoc notes deliberately cite only
[databind#6227]and not a version, so the forward merge should be conflict-free apart fromrelease-notes/VERSION.🤖 Generated with Claude Code