“LGTM: both accounts are locked before the balance changes.” Picture that review comment on the wallet transfer method below; everything it says is true. Yet in our test project, this Java deadlock example stopped all 8 threads moving money between 10 wallets after 902 transfers.
Nothing finished in the next half second. There was no exception, no error log and no busy CPU, just a thread dump with one line for it: Found one Java-level deadlock. So what did the review miss in about fifteen lines of ordinary code, and how do you make the freeze impossible?
Short answer: two transfers that lock the same two accounts in opposite orders can each hold one lock while waiting for the other, forever. Make every code path take locks in one global order (lowest account id first). Where you can’t, wait with a timeout and back off, or give each account a single owner thread so no transfer needs two locks at once.
Does this transfer method look correct to you?
The service comes from our test project (Java 25, Spring Boot 4.1.1). Each Account is one shared object per id, and the transfer locks it with synchronized before touching the balance:
public void transfer(long fromId, long toId, long amountCents) {
if (fromId == toId) {
throw new IllegalArgumentException("Cannot transfer to the same account");
}
Account from = accounts.require(fromId);
Account to = accounts.require(toId);
synchronized (from) {
if (from.balanceCents() < amountCents) {
throw new InsufficientFundsException(fromId, amountCents, from.balanceCents());
}
synchronized (to) {
from.debit(amountCents);
to.credit(amountCents);
}
}
}Line 24 locks the source and checks its funds early, so a doomed transfer never touches the destination. Line 28 locks the destination before any money moves. Both balances change under both locks, which is exactly what a reviewer looks for.
What the code never decides is which lock comes first. The caller does: whoever is from gets locked first.
What happens when 1 to 2 meets 2 to 1?
Picture two drivers entering a single-lane bridge from opposite ends. Each has gone far enough to block the other, and neither will back up.
In Java, every object has a monitor: a lock built into the object, which synchronized (account) takes and releases. The Java Language Specification says a thread that asks for a monitor “does not proceed further until the lock action has successfully completed”. There is no timeout and no way to give up.
Now run transfer(1, 2) and transfer(2, 1) on two threads at once. The first takes account 1’s monitor, the second takes account 2’s, and each then asks for the account the other holds. Neither can finish, so neither releases anything.
That cycle is a deadlock: threads that each wait for a lock another one in the group holds. Here it comes from two code paths taking the same two locks in opposite orders. JLS chapter 17 is blunt about whose problem that is: Java “neither prevents nor requires detection of deadlock conditions”.
transfer-1-to-2 transfer-2-to-1
--------------- ---------------
lock account 1 (held)
lock account 2 (held)
check balance of 1: ok
check balance of 2: ok
lock account 2 -> BLOCKED (2 is held)
lock account 1 -> BLOCKED (1 is held)
both wait forever; no timeout, no exceptionThe break is the fifth line; until then, neither thread did anything wrong on its own.
Forcing the freeze with a barrier
Two threads rarely land in that window by chance, so a test that just starts them would pass most of the time. We wanted this Java deadlock example to fail on every run, so we forced the timing. The test’s accounts are a small subclass whose first balance check waits at a CyclicBarrier shared by both transfers:
@Override
public long balanceCents() {
if (paused.compareAndSet(false, true)) {
try {
bothHoldTheirFirstLock.await(maxPauseMillis, TimeUnit.MILLISECONDS);
} catch (TimeoutException | BrokenBarrierException e) {
// the other transfer did not reach its first lock; carry on
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return super.balanceCents();
}The balance check runs inside the first lock, so when the barrier opens, each thread already holds its first account. The test then gives both threads two seconds and asks the JVM what it sees:
void oppositeTransfersThatOverlapFreezeForever() throws Exception {
CyclicBarrier bothHoldTheirFirstLock = new CyclicBarrier(2);
AccountRepository accounts = new AccountRepository();
accounts.save(new PausingAccount(1, 100_000, bothHoldTheirFirstLock, 5_000));
accounts.save(new PausingAccount(2, 100_000, bothHoldTheirFirstLock, 5_000));
TransferService service = new TransferService(accounts);
Thread aliceToBob = Thread.ofPlatform().daemon().name("transfer-1-to-2").start(() -> service.transfer(1, 2, 2_500));
Thread bobToAlice = Thread.ofPlatform().daemon().name("transfer-2-to-1").start(() -> service.transfer(2, 1, 4_000));
aliceToBob.join(2_000);
bobToAlice.join(10);
assertThat(aliceToBob.isAlive()).isTrue();
assertThat(bobToAlice.isAlive()).isTrue();
assertThat(aliceToBob.getState()).isEqualTo(Thread.State.BLOCKED);
assertThat(bobToAlice.getState()).isEqualTo(Thread.State.BLOCKED);
List<DeadlockedThread> cycle = mine(detector.findDeadlocks(), "transfer-");
assertThat(cycle).extracting(DeadlockedThread::threadName)
.containsExactlyInAnyOrder("transfer-1-to-2", "transfer-2-to-1");The run printed this (on our 4-core cloud build machine; your timings will differ):
[bug] after 2 s: transfer-1-to-2 BLOCKED, transfer-2-to-1 BLOCKED; findDeadlockedThreads() returned 2 of them
[bug] transfer-1-to-2 waits for dev.gradient.deadlock.PausingAccount@38831718, held by transfer-2-to-1The same test ran jstack against its own process. Trimmed, the dump names the cycle and the method each thread is stuck in:
Found one Java-level deadlock:
=============================
"transfer-1-to-2":
waiting to lock monitor 0x00007fed30000e00 (object 0x0000000731e1e580, a dev.gradient.deadlock.PausingAccount),
which is held by "transfer-2-to-1"
"transfer-1-to-2":
at dev.gradient.deadlock.buggy.TransferService.transfer(TransferService.java:29)
- waiting to lock <0x0000000731e1e580> (a dev.gradient.deadlock.PausingAccount)
- locked <0x0000000731e1e4a0> (a dev.gradient.deadlock.PausingAccount)You don’t need the barrier if you have load. A second test ran 8 threads doing random transfers between 10 plain accounts and polled the JVM every millisecond. The very first check, 1 ms in, already found a cycle. Within 200 ms the other six threads had walked into the two locked accounts too: 8 of 8 threads BLOCKED after 902 transfers in all, and 0 transfers in the next 500 ms.
Why does a Java deadlock example like this survive review?
Every single-threaded test passes. Ours ran 100,000 alternating transfers, 1 to 2 then 2 to 1, on one thread, and all of them finished. With one thread, nobody else ever holds the second lock, so there is nothing to wait for.
Code review reads one method at a time, and this one is locally correct. The bug lives in a pair of calls, transfer(a, b) and transfer(b, a), that never sit side by side in a diff. In our view, opposite transfers are rare in test environments and common in production: refunds, split bills.
And because the freeze is quiet, your first symptom is a slow service, then a hung one.

How do you spot a deadlock in a running service?
The JVM can find the cycle even though it won’t break it. ThreadMXBean.findDeadlockedThreads() returns the platform threads stuck in a cycle on monitors or on ownable synchronizers, the JDK’s name for locks like ReentrantLock that a thread owns until it unlocks. Our test wraps it in a small Spring component:
private final ThreadMXBean threads = ManagementFactory.getThreadMXBean();
public List<DeadlockedThread> findDeadlocks() {
long[] ids = threads.findDeadlockedThreads();
if (ids == null) {
return List.of();
}
return Arrays.stream(threads.getThreadInfo(ids))
.filter(Objects::nonNull)
.map(DeadlockDetector::describe)
.toList();
}
private static DeadlockedThread describe(ThreadInfo info) {
return new DeadlockedThread(info.getThreadId(), info.getThreadName(), info.getLockName(), info.getLockOwnerName());
}Line 21 is the whole check; line 25 turns ids into names and lock owners you can log. The Javadoc calls it a tool “for troubleshooting use, but not for synchronization control” that “might be an expensive operation”. Run it from a health check or a metric every few seconds, not before each transfer.
Spring Boot gives you two helpers. Actuator’s /actuator/threaddump returns each thread’s state, lockOwnerName and held monitors. Micrometer 1.17.1, the version Boot 4.1.1 manages, has a JvmThreadDeadlockMetrics binder with a jvm.threads.deadlocked gauge. Boot’s JVM metrics auto-configuration doesn’t register it, so declare new JvmThreadDeadlockMetrics() as a MeterBinder bean.
On virtual threads, the Javadoc warns that “cycles that include virtual threads are not found by this method”. Since JEP 491 (Java 24), a virtual thread blocked on synchronized releases its carrier, but the cycle itself remains, unnamed; request timeouts become your alarm.
Fix 1: one global lock order, lowest id first
Make the cycle impossible. If every thread takes locks in the same order, nobody can hold a “later” lock while waiting for an “earlier” one. That rule is lock ordering, and here the order is the account id:
public void transfer(long fromId, long toId, long amountCents) {
if (fromId == toId) {
throw new IllegalArgumentException("Cannot transfer to the same account");
}
Account from = accounts.require(fromId);
Account to = accounts.require(toId);
Account first = fromId < toId ? from : to;
Account second = fromId < toId ? to : from;
synchronized (first) {
synchronized (second) {
if (from.balanceCents() < amountCents) {
throw new InsufficientFundsException(fromId, amountCents, from.balanceCents());
}
from.debit(amountCents);
to.credit(amountCents);
}
}
}Lines 24 and 25 pick the lower id as first whatever the direction, so transfer(1, 2) and transfer(2, 1) both lock account 1 first. The funds check moves inside both locks, where it checks the balance that will really be debited.

Under the forced interleaving, both opposite transfers finished, with balances of 101,500 and 98,500 cents. Under load, 8 threads ran 50,000 random transfers each: 390,389 moved and 9,611 were declined for funds, in 105 ms. The total was 1,000,000 cents before and after.
What you pay is discipline. Every method that takes two of these locks must use the same order, and the order needs a key no two accounts share. Ordering also says nothing about locks you take indirectly, such as calling a listener while holding a monitor.
Fix 2: when you can’t control the order, give up and retry
Sometimes the second lock isn’t yours to order: it sits in a library, or you only learn which locks you need halfway through. Then make the wait finite. ReentrantLock.tryLock(timeout, unit) returns false instead of waiting forever, and the thread can release what it holds and try again.
Account from = accounts.require(fromId);
Account to = accounts.require(toId);
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(DEADLINE_MILLIS);
while (true) {
if (from.lock().tryLock(LOCK_WAIT_MILLIS, TimeUnit.MILLISECONDS)) {
try {
if (from.balanceCents() < amountCents) {
throw new InsufficientFundsException(fromId, amountCents, from.balanceCents());
}
if (to.lock().tryLock(LOCK_WAIT_MILLIS, TimeUnit.MILLISECONDS)) {
try {
from.debit(amountCents);
to.credit(amountCents);
return;
} finally {
to.lock().unlock();
}
}
} finally {
from.lock().unlock();
}
}
if (System.nanoTime() - deadline > 0) {
throw new TransferTimeoutException(fromId, toId);
}
backOffs.increment();
Thread.sleep(ThreadLocalRandom.current().nextLong(1, 6));
}This keeps the buggy order on purpose: source first (line 33), then destination (line 38). When the second tryLock fails, the finally on line 48 releases the source so the other transfer can finish. Line 55 sleeps a random 1 to 5 ms first. Without that jitter, both threads could retry in lockstep forever, a livelock: threads that are busy, not blocked, yet make no progress.
Against the same barrier, both transfers finished in 15 ms after 1 back-off. Under the full load, all 400,000 transfers ended with money conserved and 0 timeouts, but they took 16,835 ms and 5,263 back-offs. That’s a steep bill for a fix whose only job is to cover the cases fix 1 can’t.
It also adds a failure. A caller can get TransferTimeoutException after two seconds, so the transfer must be safe to retry. Export the back-off count; a climbing count is your early warning.
Fix 3: give each account one owner thread
The third design removes shared locks altogether. Each account belongs to one single-threaded partition, picked from its id, and only that thread touches its balance. A transfer becomes two messages: debit on the source’s thread, then credit on the destination’s.
public CompletableFuture<Void> transfer(long fromId, long toId, long amountCents) {
if (fromId == toId) {
return CompletableFuture.failedFuture(new IllegalArgumentException("Cannot transfer to the same account"));
}
return CompletableFuture.runAsync(() -> {
var from = accounts.require(fromId);
if (from.balanceCents() < amountCents) {
throw new InsufficientFundsException(fromId, amountCents, from.balanceCents());
}
from.debit(amountCents);
}, ownerOf(fromId)).thenRunAsync(() -> accounts.require(toId).credit(amountCents), ownerOf(toId));
}
public long balanceOf(long accountId) {
return CompletableFuture.supplyAsync(() -> accounts.require(accountId).balanceCents(), ownerOf(accountId)).join();
}
private ExecutorService ownerOf(long accountId) {
return partitions[(int) Math.floorMod(accountId, partitions.length)];
}Lines 33 to 38 run the check and the debit on the source’s owner; line 39 hands the credit to the destination’s owner. No thread holds one account while waiting for another, so there is nothing to order. Executors.newSingleThreadExecutor guarantees its tasks “execute sequentially”, which makes the unlocked balance safe.
With 4 ledger threads, the same load moved 391,904 transfers and declined 8,096, in 3,334 ms, with the total conserved. Our callers waited on each transfer’s future, which dominates that time.
Between debit and credit the money is in flight, so reading both balances at that moment can come up short. A crash in that gap loses the credit unless the queue is durable. Each executor’s queue is unbounded, so a hot account can build an endless backlog.
Which fix should a wallet service use?
| Fix 1: lock ordering | Fix 2: timed tryLock + back-off | Fix 3: one owner per account | |
|---|---|---|---|
| Can it deadlock? | No, by construction | No; it times out instead | No locks to order |
| 400,000 transfers, our run | 105 ms | 16,835 ms, 5,263 back-offs | 3,334 ms (waiting per transfer) |
| New failure mode | None | TransferTimeoutException after 2 s | Money in flight; queue backlog |
| Code change | Two lines | A retry loop and its tests | An async API for callers |
| Choose it when | You own every lock and can sort them | The lock set is dynamic or partly in a library | Accounts already live behind a queue |
Our default: fix 1 whenever the locks are yours. It’s the cheapest change and leaves the happy path untouched. Use fix 2 at the edges you can’t order, with a short timeout and an idempotent retry. We’d only reach for fix 3 when designing a ledger; it’s too big to be a patch for one method.
What not to do: don’t poll findDeadlockedThreads() and interrupt threads to “recover”. The Thread.interrupt() Javadoc lists the waits it can end, and waiting for a monitor isn’t one of them. Don’t swap in one global lock for all accounts either: it can’t deadlock, but it turns every transfer into one queue.
Does the database have the same problem?
Yes, and most real wallets keep balances in a database, where JVM locks would only protect one server.
In Spring Boot, the database version is a @Transactional transfer that loads the source with @Lock(LockModeType.PESSIMISTIC_WRITE), then the destination. On H2, our oversell test saw Hibernate run that lock as select ... for update; on PostgreSQL, Hibernate 7.4 sends for no key update, which still makes a second locker wait. Two opposite transfers then lock the two rows in opposite orders.
Here the database notices. The PostgreSQL manual says it “automatically detects deadlock situations and resolves them by aborting one of the transactions involved”. It waits for deadlock_timeout, one second by default, before checking. One transfer fails and has to be retried.
So does fix 1: lock the two rows in id order, whichever is the source. PostgreSQL’s advice is to “acquire locks on multiple objects in a consistent order”. We covered SELECT ... FOR UPDATE and its costs in The Spring Boot race condition that sells your last unit twice.
What changes with more servers, threads and retries?
More servers make in-memory locks the wrong tool, since two instances never see each other’s monitors. Balances move to database rows, and the deadlock moves with them, where it surfaces as an error.
Add threads and the freeze grows. In our run, a cycle of two threads stopped all eight, because the stuck pair held two of the ten accounts. Request threads pile up the same way until the pool is empty, much like the pool lock-up in Hikari connection is not available? Your REQUIRES_NEW is starving the pool.
Client retries during a freeze only add blocked threads. Retries inside fix 2, or after a database deadlock error, are what make those fixes work.
Which metric goes red first?

jvm.threads.deadlocked above zero is the alarm with no false positives. Before it, jvm.threads.states with state=blocked climbing while CPU falls is the shape of a freeze. In PostgreSQL, a rising deadlocks count in pg_stat_database means some path still locks rows in two orders.
Back to that review comment
The LGTM was right about everything it checked. It just never asked who picks the first lock. Add that question to any review of a method that takes two locks, and if the answer is “the caller”, sort them.
What should you search your codebase for?
- Nested
synchronizedblocks, or asynchronizedmethod that calls another object’ssynchronizedmethod. - Two
lock()ortryLock()calls in one method, especially on parameters such asfromandto. @Lock(LockModeType.PESSIMISTIC_WRITE)orFOR UPDATEqueries run twice in one transaction on rows the caller chose.- Listeners, callbacks or repository calls made while a monitor is held.
- Thread dumps from past incidents: search them for “Java-level deadlock”.
Frequently asked questions
How do I detect a deadlock in Java at runtime?
Call ManagementFactory.getThreadMXBean().findDeadlockedThreads(), which returns the ids of platform threads in a lock cycle, or null if there is none. From outside the process, jstack <pid> prints a “Found one Java-level deadlock” section with the stack of each waiting thread.
Does ReentrantLock prevent deadlocks?
Not by itself. lock() waits forever, just like synchronized. Only tryLock with a timeout, or lockInterruptibly() plus an interrupt, gives a waiting thread a way out.
Can a deadlock happen with only one lock?
Not a lock cycle, which needs two locks. A thread can still wait forever on one ReentrantLock that another thread never unlocked, for example when unlock() isn’t in a finally block. That is a leak, and findDeadlockedThreads() won’t report it.
Does Spring’s @Transactional prevent deadlocks?
No. It opens and commits a database transaction, and the database takes row locks in the order your code touches the rows. PostgreSQL breaks the cycle by aborting one transaction; Spring then throws, and nothing retries it unless you add a retry.
What we do not know yet
We ran every test of this Java deadlock example in one JVM on a 4-core cloud build machine in October 2026, so the timings come from one run. We didn’t test the row-lock version on PostgreSQL or MySQL; that section rests on the PostgreSQL manual, and MySQL’s InnoDB pages were unavailable when we checked. We also didn’t test virtual threads or the Micrometer gauge in a running service.
Read next: another story of threads handing work to each other, in Spring Boot MDC async logs lose the request id, then log the wrong one.
Researched and drafted with AI assistance; facts checked against the sources linked in this article.
References
- The Java Language Specification, Java SE 25 Edition, Chapter 17: Threads and Locks, Oracle: docs.oracle.com
- ThreadMXBean, Java SE 25 API documentation: docs.oracle.com
- Thread, Java SE 25 API documentation: docs.oracle.com
- ReentrantLock, Java SE 25 API documentation: docs.oracle.com
- Lock, Java SE 25 API documentation: docs.oracle.com
- Executors, Java SE 25 API documentation: docs.oracle.com
- JEP 491: Synchronize Virtual Threads without Pinning, OpenJDK, JDK 24: openjdk.org
- Deadlock, The Java Tutorials, Oracle: docs.oracle.com
- Starvation and Livelock, The Java Tutorials, Oracle: docs.oracle.com
- Thread Dump (threaddump) endpoint, Spring Boot Actuator REST API, 4.1.1: docs.spring.io
- Metrics, Spring Boot reference, 4.1.1: docs.spring.io
- JvmMetricsAutoConfiguration.java, Spring Boot source code, 4.1.1: github.com
- JVM Metrics, Micrometer reference, 1.17.1: docs.micrometer.io
- JvmThreadDeadlockMetrics.java, Micrometer source code, 1.17.1: github.com
- Locking, Spring Data JPA reference, 4.1.1: docs.spring.io
- PostgreSQLDialect.java, Hibernate ORM source code, 7.4.5: github.com
- Explicit Locking (13.3.4 Deadlocks), PostgreSQL 18 documentation: postgresql.org
- Lock Management (deadlock_timeout), PostgreSQL 18 documentation: postgresql.org
- The Cumulative Statistics System (pg_stat_database), PostgreSQL 18 documentation: postgresql.org


