Concurrent Collections and Synchronizers
Purpose
Once "use a concurrent collection", "use a queue" or "use a limit" is the decision, this is the next one: which member of the family, with which parameter, and what breaks when it is wrong. These failures are rarely exceptions — a consumer that idles with work queued, a limit of 8 that admits 12, a latch nobody counts down, a heap dump full of queue nodes. The rule that a thread-safe collection does not make a sequence atomic belongs to java-thread-safety-contracts; the mechanism it implies is here. Baseline Java 25; vendor support status and version-sensitive claims must be checked separately.
Before applying that authoring baseline, inspect the target compiler release/toolchain,
deployed JDK/vendor/build and existing dependencies. Do not upgrade or enable preview merely
to apply this skill. StructuredTaskScope is preview in Java 25; route to
structured-concurrency for its version-specific API and compiler/runtime flags, and use
existing stable coordination APIs when preview is not authorized.
Workflow
- Name the admission policy before choosing an implementation. If overload can occur, state the capacity or explain why an intrinsically unbounded structure is safe and bounded elsewhere.
- Pick the member from the tables below and write down the cost accepted. A choice with no stated cost was not made.
- Pick the exact method form.
offer(e, timeout, unit)notadd;whilenotif;awaitNanos(remaining)not the original timeout. The form is where the correctness is. - Make cleanup exception-safe. For owned locks/permits, acquire immediately before
tryand release infinallyonly after successful acquisition. Latches and phasers need their own party accounting rather than a mechanical lock template. - Verify with the section below, not by re-reading the code; these failures are silent.
Selecting a queue
Start with the required bound, ordering and handoff semantics; ArrayBlockingQueue(n) and
LinkedBlockingQueue(n) are common bounded choices, not universal defaults. Mechanism, symptom
chains and code: references/queues.md.
| You need | Pick | Cost accepted |
|---|---|---|
| a hard, pre-allocated bound; predictable memory | ArrayBlockingQueue(n) |
producers and consumers share one lock — a throughput ceiling; capacity cannot change |
| a bound with high producer/consumer concurrency | LinkedBlockingQueue(n) |
separate put/take locks, but a node allocation per element and less predictable timing |
| a rendezvous — the producer waits for a real taker | SynchronousQueue |
zero capacity; size(), peek() and iterator() are present and all report empty — every monitoring hook lies |
handoff and buffering (transfer, tryTransfer, hasWaitingConsumer) |
LinkedTransferQueue |
unbounded; size() O(n); poll() may return null on a non-empty queue on JDK 21–25 (JDK-8371740, fixed in 26) |
| consumer-side priority ordering | PriorityBlockingQueue |
unbounded; iteration, toArray and forEach are not in priority order; equal priorities unordered — add a sequence number |
| work that becomes due at a time (retry, TTL, expiry) | DelayQueue |
unbounded; poll/take/remove return only the expired head while size() counts the future too |
| LIFO processing, put-back-on-failure, hand-rolled stealing | LinkedBlockingDeque |
a single lock — work stealing's ordering, none of its contention benefit; remove/contains/bulk ops are linear |
| no blocking at all — a buffer drained by a live loop | ConcurrentLinkedQueue |
unbounded; size() is O(n) |
Selecting a coordinator
Failure modes and worked code: references/synchronizers-and-conditions.md.
| Situation | Pick | Cost accepted |
|---|---|---|
| one thread must know N others finished; used once | CountDownLatch(n) |
one-shot, the count cannot be reset; nobody rendezvouses |
| N threads must meet repeatedly; something runs per round | CyclicBarrier(n, r) |
party count fixed; without timeout/interruption, too few arrivals can wait indefinitely; one early leaver breaks the generation |
| parties join and leave between rounds | Phaser |
≤ 65535 parties (IllegalStateException beyond — tier it); awaitAdvance ignores interruption; a negative return means terminated |
| at most N in flight against a scarce resource | Semaphore(n, fair) |
no ownership — an extra release() silently raises the limit and nothing reports it |
| two threads swap buffers | Exchanger |
pairs exactly two; exchange(v) with no partner blocks forever — use the timed overload |
| wait for results, not for arrivals | StructuredTaskScope |
a different model — route to structured-concurrency |
Replacing a compound action on a ConcurrentHashMap
Leaving the compound form in place produces duplicate initialisation — two connections, two schedulers, a doubled counter — invisible in tests and load-dependent in production.
| What the caller wrote | Atomic replacement |
|---|---|
containsKey then put |
putIfAbsent(k, v) |
get, null check, put |
computeIfAbsent(k, loader) |
get, mutate, put |
compute(k, fn) or merge(k, seed, fn) |
get, compare, put |
replace(k, expected, updated) |
get, compare, remove |
remove(k, expected) |
| counter increment | merge(k, 1L, Long::sum) |
| hot counter | CHM<K, LongAdder> + computeIfAbsent |
Rules
- Prefer an explicit finite capacity where the queue is the admission boundary — no-arg
LinkedBlockingQueueusesInteger.MAX_VALUE. Structures without a useful finite capacity do not provide overload control;remainingCapacity()is contract data, not proof of safety. - A timed
offerfits request paths that must bound admission delay and handlefalse; it is not automatically a 503 or spill policy.put(e)fits deliberate producer throttling;offer(e)fits designed and counted drop/retry.add(e)is rarely useful in a producer loop because it makes a capacity condition anIllegalStateException("Queue full")and on an unbounded queue can never fire. Anofferwhose boolean is discarded is silent data loss. - Concurrent
size()is monitoring information, not admission control.if (map.size() < LIMIT) map.put(…)is a race. UsemappingCount()when an approximatelongcount is appropriate. Avoid hot-path or high-frequency scrape calls tosize()onConcurrentLinkedQueueorLinkedTransferQueuebecause it traverses. - OpenJDK bug JDK-8371740 reports
LinkedTransferQueue.poll()returning null despite a non-empty queue in releases 21–25, fixed in 26. Check the deployed build/backports before relying on the fix; do not use queue emptiness as a durable completion protocol. compute*andmergemay block some updates while the function executes. Keep it short and do not modify the map from the function, as required by the API. Current OpenJDK uses per-bin coordination, but application correctness must not depend on its exact monitor layout. For a loader that can block, use the failure-evicting memoiser inreferences/collections.md.IllegalStateException("Recursive update")is only required for a detectable recursive update that would otherwise not complete. It is not an enforcement boundary. Any map mutation from a remapping function violates the API constraint even when a particular build does not throw.- Distinguish two iterator contracts. Weakly consistent (CHM, skip lists,
ConcurrentLinkedQueue) never throwsConcurrentModificationExceptionand may reflect later writes; snapshot (copy-on-write) captures a consistent sequence of element references at creation, but not a deep snapshot of mutable element state. It ignores later list changes; a listener registered during dispatch waits for a later traversal, and iterator mutation throws. - Copy-on-write cost is writeRate × size, not the read:write ratio. Use it for
configuration-shaped state whose write rate is bounded by human or control-plane action, never
for request-scoped data; batch with
addAll.CopyOnWriteArraySet.containsis a linear scan. - For locks and permits, acquire immediately before
tryand release infinally; put no throwing work between them. A missingcountDown()can park a waiter indefinitely; a missingrelease()erodes capacity; a leakedunlock()is permanent, because aReentrantLockis not released when its holder dies. - The untimed
tryAcquire()andtryLock()ignore the fairness setting and barge;tryAcquire(0, unit)honours it and also detects interruption. Whether the limit should be fair at all is a sizing decision — concurrency-limiting-and-bulkheads. - Wait on a
Conditionin awhiletesting the predicate, never anif. Spurious wakeups are only one of the three reasons, and not the one that makesifunconditionally wrong. Symptom: a negative count or an item consumed twice, under load only. signal()is appropriate only when every waiter on that condition uses a compatible predicate and progress is preserved if the selected waiter cannot proceed; otherwise consider separate conditions orsignalAll(). A wrong selection can leave an eligible waiter parked — one thread parked forever while everything else runs, and the dump looks like ordinary parking.- In a re-wait loop carry the remaining time:
nanosRemaining = cond.awaitNanos(nanosRemaining). Re-passing the original turns N wakeups into N × timeout — a "5-second timeout" that occasionally takes minutes and never reports one.await(t, unit)returnsfalsebut no remaining time. - Choose
ReentrantLockoversynchronizedon capability: timed and interruptible acquisition,tryLock, fairness, non-block-structured locking, more than one condition queue. Pinning has not been a reason since JEP 491 (JDK 24) — virtual-threads-internals owns that diagnosis. - A read-only
ReentrantReadWriteLockholder cannot upgrade while retaining its read hold: blocking acquisition may wait indefinitely, whereastryLockcan fail or time out. A thread already owning the write lock may reenter it. Standard deadlock detection may miss read-hold stalls; inspect stacks and ownership. Downgrade is legal;readLock().newCondition()throws. The reader cap is 65535 on JDK 21 andInteger.MAX_VALUEon JDK 25; measure against a plain lock before adding an RRWL. StampedLockis not reentrant, has no ownership and no fairness policy. Re-entry through a callback, listener or guarded object's method can self-deadlock and is not represented as an ownable-lock cycle. An optimistic read must not act on a potentially inconsistent snapshot before successful validation; copy only safe fields into locals, validate, then use them.- Reach for
AbstractQueuedSynchronizerlast:BlockingQueue→Semaphore→ latch/barrier/phaser →ReentrantLock+ oneConditionper predicate →StructuredTaskScope→ atomics. Only a blocking synchronizer with a novel acquisition predicate justifies it.
Verification
- jcstress for the substitution table. Two
@Actors racingcontainsKey+putagainstputIfAbsenton one key, an@Arbiterreading the result, the interleaved outcomeFORBIDDEN; run both shapes and record observed outcomes; a finite run need not expose every race. Bounded liveness tests and jcstress termination tests can expose hangs; a stale outcome is evidence of non-termination under that test, not proof of a particular lost signal. - Invariant checks in tests and diagnostics: fixed-limit semaphores should never exceed their configured permit count; queue construction should expose its admission policy; non-reentrant designs should test callback/re-entry. Java assertions are disabled unless enabled and cannot be the production enforcement mechanism.
- Review queue construction against the admission policy — flag effectively unbounded
queues and require an explicit external bound or workload argument. Encode that repository
policy in a gate where useful;
remainingCapacity()alone does not prove absence of a bound. The executor factories that hide queues are executors-and-task-lifecycle's; how to write the rule is architecture-testing's. - JFR with an explicit recording configuration. AQS-based waits commonly surface through park events, while monitor contention has monitor events. Event enablement and thresholds vary by JDK and recording template; inspect the active settings before treating absence as evidence.
- Metrics with the right shape: bounded queue depth as a fraction of capacity plus a counter
of
offerrejections (the rejection is the signal, depth is not); enqueue-to-dequeue latency timestamped on the item;availablePermits()alerted on a trend.getQueueLength()is a lock method — threads waiting to acquire, not queue depth — documented as monitoring only. A startup deployment build/backport status recorded when theLinkedTransferQueue.poll()issue is relevant.
References
- Concurrent collections — the bin lock, which recursions are detected
and which are silent, the failure-evicting memoiser,
keySetvariants, bulk ops, the wrapper decision, copy-on-write, skip lists. Read before putting anything inside acompute*function, and when choosing between CHM, a synchronized wrapper, copy-on-write and a skip list. - Blocking queues — the four insert/remove/examine forms, the implementation
comparison, the unbounded-queue failure chain,
drainTobatching, theLinkedTransferQueuebug,DelayQueue, poison pills. Read when adding, sizing or replacing a queue. - Synchronizers and conditions — latch, barrier,
phaser, semaphore and exchanger failure modes, the permit-leak and over-release shapes, the
Conditionprotocol and a correct bounded buffer. Read when threads must coordinate. - Explicit locks — the capability table against
synchronized, the JEP 491 reframing, thetryLockrecipe, RRWL upgrade/downgrade and the reader-cap change,StampedLockwith the canonical optimistic read, when AQS is justified. Read when a lock is chosen or blamed. - Java 25 concurrent collections and synchronizers
- Java 25 lock package
- OpenJDK JDK-8371740:
LinkedTransferQueue.poll()issue