Two transfers, two locks: a Java deadlock example that passes code review

“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:

TransferService.java (buggy)
    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.

Each transfer holds one account and waits for the other. Nothing times out.

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 exception

The 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:

PausingAccount.java (test only)
    @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:

TransferDeadlockBugTest.java
    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-1

The 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.

The same transfer code: 100,000 alternating transfers on one thread all finished; with 8 threads on 10 accounts the first check found a cycle at 1 ms, 8 of 8 threads were BLOCKED within 200 ms though the cycle held only 2, and 0 transfers finished in the next 500 ms
Source: Gradient verify run, October 2026

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:

DeadlockDetector.java
    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:

OrderedLockTransferService.java (fix 1)
    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.

Three fixes compared: lock ordering takes both locks lowest id first and ran 400,000 transfers in 105 ms; timed tryLock backs off 1 to 5 ms and took 16,835 ms with 5,263 back-offs; one owner thread per account takes no locks and took 3,334 ms
Source: Gradient test project, 8 threads x 50,000 transfers

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.

TryLockTransferService.java (fix 2)
        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.

PartitionedTransferService.java (fix 3)
    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 orderingFix 2: timed tryLock + back-offFix 3: one owner per account
Can it deadlock?No, by constructionNo; it times out insteadNo locks to order
400,000 transfers, our run105 ms16,835 ms, 5,263 back-offs3,334 ms (waiting per transfer)
New failure modeNoneTransferTimeoutException after 2 sMoney in flight; queue backlog
Code changeTwo linesA retry loop and its testsAn async API for callers
Choose it whenYou own every lock and can sort themThe lock set is dynamic or partly in a libraryAccounts 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?

Where a lock cycle shows up: blocked threads climbing while CPU falls is the hint, jvm.threads.deadlocked above zero is the alarm, PostgreSQL counts deadlocks in pg_stat_database after aborting one transaction, and cycles with virtual threads show up only as timeouts
Sources: Micrometer 1.17.1, ThreadMXBean Javadoc, PostgreSQL 18 docs

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 synchronized blocks, or a synchronized method that calls another object’s synchronized method.
  • Two lock() or tryLock() calls in one method, especially on parameters such as from and to.
  • @Lock(LockModeType.PESSIMISTIC_WRITE) or FOR UPDATE queries 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

  1. The Java Language Specification, Java SE 25 Edition, Chapter 17: Threads and Locks, Oracle: docs.oracle.com
  2. ThreadMXBean, Java SE 25 API documentation: docs.oracle.com
  3. Thread, Java SE 25 API documentation: docs.oracle.com
  4. ReentrantLock, Java SE 25 API documentation: docs.oracle.com
  5. Lock, Java SE 25 API documentation: docs.oracle.com
  6. Executors, Java SE 25 API documentation: docs.oracle.com
  7. JEP 491: Synchronize Virtual Threads without Pinning, OpenJDK, JDK 24: openjdk.org
  8. Deadlock, The Java Tutorials, Oracle: docs.oracle.com
  9. Starvation and Livelock, The Java Tutorials, Oracle: docs.oracle.com
  10. Thread Dump (threaddump) endpoint, Spring Boot Actuator REST API, 4.1.1: docs.spring.io
  11. Metrics, Spring Boot reference, 4.1.1: docs.spring.io
  12. JvmMetricsAutoConfiguration.java, Spring Boot source code, 4.1.1: github.com
  13. JVM Metrics, Micrometer reference, 1.17.1: docs.micrometer.io
  14. JvmThreadDeadlockMetrics.java, Micrometer source code, 1.17.1: github.com
  15. Locking, Spring Data JPA reference, 4.1.1: docs.spring.io
  16. PostgreSQLDialect.java, Hibernate ORM source code, 7.4.5: github.com
  17. Explicit Locking (13.3.4 Deadlocks), PostgreSQL 18 documentation: postgresql.org
  18. Lock Management (deadlock_timeout), PostgreSQL 18 documentation: postgresql.org
  19. The Cumulative Statistics System (pg_stat_database), PostgreSQL 18 documentation: postgresql.org