Concurrency
Synchronising on a boxed Integer or an interned String
Written and reviewed by Sahil Srivastav
Found one Java-level deadlock:
=============================
"billing-worker-2":
waiting to lock monitor 0x00007f6c1400b420 (object 0x00000006c0004e30, a java.lang.String),
which is held by "report-worker-5"
"report-worker-5":
waiting to lock monitor 0x00007f6c1400a118 (object 0x00000006c0068840, a java.lang.Integer),
which is held by "billing-worker-2"
# Neither class references the other. The shared monitors are "acme" from the
# string pool and Integer.valueOf(42) from the autobox cache.What this error actually means
A monitor belongs to an *object identity*, not to a variable or a value. Two references that point at the same object share one lock, whether or not the code that wrote them knows the other exists. Boxed primitives and interned strings are the two places where the JVM deliberately hands out shared identities, which turns a lock that reads as private into one that is effectively global.
`Integer.valueOf` is required to cache and return the same instance for values between −128 and 127, and autoboxing goes through `valueOf`. So `synchronized (userId)` where `userId` is an `Integer` of 42 locks the *same object* as every other piece of code in the JVM — including library code — that boxes 42. `Boolean.valueOf` is worse: there are exactly two instances in existence, so locking on a `Boolean` serialises everything in the process that does the same.
String literals are interned into a pool shared by the whole JVM, and `String.intern()` puts runtime-computed strings there too. `synchronized ("acme")` in your billing code and `synchronized (tenantName.intern())` in a reporting job acquire one monitor. Unlike the boxed case there is no small range limiting the damage — any two literals with the same characters collide, across modules, across libraries, across code you did not write.
The failures this produces are the hardest kind to attribute, because the deadlock cycle or the contention runs between classes that have no relationship in the source. You read both sides, neither has any business blocking the other, and the dump tells you they are fighting over a `java.lang.Integer`. The second, quieter failure mode is the mirror image: locking on a boxed value *inside* the cache range works while values are small and silently stops providing mutual exclusion at 128, when `valueOf` starts returning fresh instances and every thread locks its own object.
Causes, most common first
- 1Locking on a boxed `Integer` or `Long` key. The intent is a per-entity lock — `synchronized (accountId)`. Inside the cache range every thread with the same id gets the same monitor, which happens to work, and additionally collides with unrelated code. Outside the range each boxing creates a new object, so the mutual exclusion silently disappears. Both halves are wrong and they fail in opposite directions.
- 2Locking on a `String` literal or an interned string. Common in "lock per tenant" or "lock per name" code, and in older singleton idioms. The pool is JVM-wide and permanent for literals, so the lock is shared with every other user of that character sequence anywhere in the process.
- 3Locking on a `Boolean` or a cached small value. Rare in intent, easy to reach by accident when the lock target is a flag or a status field. There are two `Boolean` instances, so this is close to a process-wide mutex with a name that suggests otherwise.
- 4Locking on a field that is reassigned. The monitor is the object the field pointed at when the block was entered. Reassigning the field mid-flight means later threads lock a different object, so two threads are in the "same" critical section simultaneously with no error reported.
- 5Locking on `this` in a class with public instances. A milder version of the same problem: any outside code holding your object can synchronise on it, including library code and frameworks, so your critical section can be blocked by something you cannot see. Your lock is part of your public API whether you documented it or not.
- 6Locking on a value object with an `equals`-based cache. Any factory that returns shared instances for equal values — interning enums-by-name, cached `BigDecimal` constants, flyweight value types — reproduces the boxed-Integer problem. The rule is about identity sharing, not about `Integer` specifically.
When you see it
- A deadlock whose monitors are `java.lang.Integer`, `java.lang.String`, `java.lang.Boolean`, or `java.lang.Long` rather than your own types
- Two components with no code dependency blocking each other in the dump
- Contention on a lock that the code appears to scope per request or per key
- Mutual exclusion that works in tests with small ids and fails in production with large ones — the autobox-cache boundary at 128
- Throughput that collapses when an unrelated library is upgraded, because it began locking on the same interned literal
- Lost updates despite a `synchronized` block, because each thread locked a different boxed instance for the same numeric value
- A monitor held across an unexpectedly long time, because the other holder is doing something entirely unrelated
How to diagnose it
Step 1
Read the monitor’s class in the dump
The dump names the type of every locked object. A JDK value type appearing as a monitor is the finding — no application should be synchronising on `Integer`, `String`, `Long`, or `Boolean`, so seeing one ends the investigation.
jcmd <pid> Thread.print | grep -E "locked <0x|waiting to lock" | grep -E "java.lang.(String|Integer|Long|Boolean|Short|Byte|Character)"Step 2
Grep for the pattern statically
This is a source-level defect, so it is findable without reproducing anything. Any `synchronized` whose target is a boxed type, a literal, or an `intern()` call is a bug.
grep -rn -E "synchronized *\( *(\"|[a-zA-Z_]+\.intern\(\))" src/main/java
grep -rn -E "synchronized *\( *[a-zA-Z_]*([Ii]d|[Cc]ount|[Ff]lag) *\)" src/main/javaStep 3
Prove identity sharing at runtime
A two-line check settles any argument about whether two locks are the same lock. Reference equality, not `equals`, is what determines the monitor.
System.out.println(Integer.valueOf(42) == Integer.valueOf(42)); // true
System.out.println(Integer.valueOf(200) == Integer.valueOf(200)); // false
System.out.println("acme" == "ac" + "me"); // true (compile-time constant)Step 4
Test across the autobox boundary
Run the concurrency test with ids below 128 and again with ids above it. Passing at 42 and failing at 200 is the signature of a boxed lock target, and it explains why the bug appeared when a table’s ids grew.
./gradlew test --tests '*ConcurrencyTest*' -Dtest.accountId=200Step 5
Check whether the lock target can be reassigned
Confirm the field holding the lock is `final`. A non-final lock field is a separate bug with an identical symptom: two threads legitimately inside the same critical section.
grep -rn -B 2 "synchronized (" src/main/java | grep -E "private .* (Object|Lock) " | grep -v finalThe fix
Lock on a dedicated, private, final object that exists for no other purpose: `private final Object lock = new Object();`. It has one identity, nobody else can obtain it, and it cannot be reassigned. Every other choice of lock target is an argument you have to win; this one needs no argument.
For per-key locking, get the lock from a map keyed by the key rather than locking the key itself: `locks.computeIfAbsent(accountId, k -> new Object())`. The `computeIfAbsent` guarantees one lock object per key, and the identity is yours rather than shared with the JVM. Bound the map or use weak values, otherwise the lock map becomes an unbounded cache keyed by everything you have ever seen.
Better for most per-key cases: use lock striping and skip the per-key map entirely. A fixed array of `N` locks indexed by `Math.floorMod(key.hashCode(), N)` gives bounded memory, no lifecycle problem, and contention only between keys that collide. Guava’s `Striped` provides this directly if you would rather not hand-roll it.
Where the critical section is a single read-modify-write on a map entry, delete the lock and use `ConcurrentHashMap.compute` or `merge`. Per-key atomicity without any lock object is strictly better than per-key locking done carefully, because there is nothing to get wrong.
Never synchronise on `this` in a class whose instances are handed out. Use a private lock so your mutual exclusion is not part of your public surface, and so a framework proxying or caching your object cannot interfere with it.
Make every lock field `final` and declare it next to the state it guards, ideally with a comment naming that state. Reassignment bugs and "which lock protects this field" confusion both disappear when the pairing is local and immutable.
// Shares a monitor with any code in the JVM that boxes the same value,
// AND stops excluding anything at all once ids exceed 127.
void credit(Integer accountId, BigDecimal amt) {
synchronized (accountId) { ... }
}
// Shares a monitor with every user of the literal "acme" in the process
synchronized (tenantName.intern()) { ... }
// Correct: a private identity that nothing else can reach
private final Object lock = new Object();
void credit(long accountId, BigDecimal amt) {
synchronized (lock) { ... }
}
// Correct per-key locking: the identity comes from a map you own
private final ConcurrentHashMap<Long, Object> locks = new ConcurrentHashMap<>();
void credit(long accountId, BigDecimal amt) {
Object keyLock = locks.computeIfAbsent(accountId, k -> new Object());
synchronized (keyLock) { ... }
}
// Usually better: bounded striping, no lock lifecycle to manage
private final Object[] stripes = IntStream.range(0, 64)
.mapToObj(i -> new Object()).toArray();
void credit(long accountId, BigDecimal amt) {
synchronized (stripes[(int) Math.floorMod(accountId, stripes.length)]) { ... }
}
// Best where the section is one read-modify-write: no lock at all
balances.merge(accountId, amt, BigDecimal::add);How to stop it coming back
- Ban `synchronized` on boxed types, `String`, and `Boolean` with a static-analysis rule — SpotBugs flags these directly
- Declare every lock as `private final Object` beside the state it guards, and name the guarded state in a one-line comment
- Never synchronise on `this` or on any object that leaves the class
- Run per-key concurrency tests with key values on both sides of 128 so autobox-cache behaviour cannot hide a broken lock
- Prefer striping or atomic map operations over per-key lock maps; a lock map with no bound is an unbounded cache
- When reading a deadlock dump, check the monitor types first — a JDK value type as a monitor is an immediate finding
FAQ
Why does locking on an Integer work for small ids and fail for large ones?
`Integer.valueOf` must return cached instances for −128 to 127, so equal small values are the same object and the monitor is shared as intended. Above 127 each call allocates a new `Integer`, so two threads working on id 200 lock two different objects and neither excludes the other. The code provides mutual exclusion for exactly the range your tests probably use.
Is the autobox cache range configurable?
The upper bound can be raised with `-XX:AutoBoxCacheMax`, and the lower bound of −128 is fixed. Raising it does not make locking on boxed values correct — it widens the range over which you accidentally share a monitor with the rest of the JVM. Treat the flag as irrelevant to this bug.
Are string literals really shared beyond my own classes?
Yes. The string pool is per-JVM, not per-classloader for literals with the same characters, and `intern()` adds runtime strings to the same pool. Any library in the process that synchronises on the same literal shares your monitor. This is why deadlock dumps here name classes that have no relationship to each other.
What is wrong with `synchronized (this)`?
It makes your lock part of your public API. Any holder of the reference — a framework, a cache, a test, a library that synchronises on objects it receives — can acquire it and block your critical section, and you cannot see those call sites. A private lock removes the whole category of interference at no cost.
Should I use a per-key lock map or striping?
Striping unless you need strict per-key isolation. A per-key map gives maximum parallelism and an unbounded memory footprint plus a removal problem — deleting a lock while a thread holds it reintroduces the identity bug. Striping has a fixed cost, no lifecycle, and its only downside is contention between keys that hash to the same stripe.
Related
Other errors engineers hit next to this one
- SettingWithCopyWarning: A value is trying to be set on a copy of a slice
- celery.exceptions.WorkerLostError: Worker exited prematurely
- requests.exceptions.ReadTimeout: HTTPSConnectionPool read timed out
- UnicodeDecodeError: 'utf-8' codec can't decode byte
- AssertionError: daemonic processes are not allowed to have children
- [CRITICAL] WORKER TIMEOUT (pid:1234)
- Mutable default argument retains state across calls
- CommitFailedException: Commit cannot be completed since the group has already rebalanced