Reproducing Three Classic Spring Boot Concurrency Deadlocks in Just 10 Lines

The article explains three typical deadlock scenarios in Spring Boot 3.5—circular‑wait, connection‑pool starvation, and implicit DML deadlocks—shows minimal 10‑line code reproductions, demonstrates the resulting errors, and provides concrete fixes such as ordered locking, removing REQUIRES_NEW propagation, and using asynchronous events.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Reproducing Three Classic Spring Boot Concurrency Deadlocks in Just 10 Lines

Environment

Spring Boot 3.5.0

1. Introduction

Declarative transaction @Transactional simplifies database access but improper handling of its lock mechanism and connection management can cause hidden deadlocks under high concurrency. Deadlocks may arise from circular wait on row locks, connection‑pool starvation, and nested‑transaction DML lock conflicts.

2.1 Circular‑wait deadlock

Two transactions each lock a different account (A and B) and then try to lock the other, forming a closed‑loop wait. Example:

private final AccountRepository accountRepository;

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    accountRepository.lockAndGet(fromId);
    sleep(1L);
    accountRepository.lockAndGet(toId);
    // ...
}

public interface AccountRepository extends JpaRepository<Account, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Optional<Account> lockAndGet(@Param("id") Long id);
}

Running this code produces a deadlock error (screenshot). The fix orders lock acquisition by ID, breaking the circular‑wait condition:

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    long minId = Math.min(fromId, toId);
    long maxId = Math.max(fromId, toId);
    accountRepository.lockAndGet(minId);
    sleep(1L);
    accountRepository.lockAndGet(maxId);
    // ...
}

All transactions acquire locks in the same ascending order, eliminating the cycle.

2.2 Resource‑pool starvation deadlock

If an outer transaction holds a DB connection and invokes a method with propagation REQUIRES_NEW, the inner transaction needs a second connection. Under high load the connection pool can be exhausted, causing the inner transaction to wait indefinitely while the outer transaction cannot release its connection, forming a deadlock.

private final RiskAssessmentService riskService;

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    riskService.checkRisk(fromId);
}

@Service
public class RiskAssessmentService {
    private final ConcurrentMap<Long, RiskStatus> riskCache = new ConcurrentHashMap<>();
    private final RiskRecordRepository riskRecordRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public RiskStatus checkRisk(Long accountId) {
        return riskCache.computeIfAbsent(accountId,
            id -> queryRiskStatusFromDb(id));
    }
    // ...
}

Execution results in a timeout while waiting for a connection from the pool (screenshot).

Solutions:

Remove the nested transaction by changing propagation to REQUIRED or moving the call out of the outer transaction.

Decouple the logic asynchronously (e.g., message queue or @Async) to avoid holding the outer connection.

@Transactional
public RiskStatus checkRisk(Long accountId) {
    // ...
}

2.3 Implicit DML deadlock

A REQUIRES_NEW transaction updates a row (setting lastRiskCheckAt) that is already locked exclusively by the outer transaction, creating a deadlock loop.

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    riskService.checkRisk(fromId);
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public RiskStatus checkRisk(Long accountId) {
    return riskCache.computeIfAbsent(accountId,
        id -> queryRiskStatusFromDb(id));
}

private RiskStatus queryRiskStatusFromDb(Long accountId) {
    return riskRecordRepository.findByAccountId(accountId)
        .map(rr -> {
            this.accountRepository.findById(accountId)
                .ifPresent(a -> a.setLastRiskCheckAt(LocalDateTime.now()));
            return rr.getStatus();
        }).orElse(RiskStatus.LOW);
}

A test thread invoking transfer concurrently reproduces the deadlock (screenshot).

@Test
public void testTransfer3() throws Exception {
    Thread a = new Thread(() -> {
        transferService.transfer(1L, 2L, BigDecimal.valueOf(100));
    }, "A");
    a.start();
    a.join();
}

Solution: merge the inner operation into the outer transaction or defer the update via an asynchronous event after commit.

public class AccountTxEvent extends ApplicationEvent {
    private final Long accountId;
    public AccountTxEvent(Long accountId) {
        super(accountId);
        this.accountId = accountId;
    }
    public Long getAccountId() { return accountId; }
}

@Component
public class AccountTxListener {
    private final AccountRepository accountRepository;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void updateAccount(AccountTxEvent event) {
        this.accountRepository.findById(event.getAccountId())
            .ifPresent(account -> {
                account.setLastRiskCheckAt(LocalDateTime.now());
                this.accountRepository.save(account);
            });
    }
}

@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    this.context.publishEvent(new AccountTxEvent(fromId));
    // ...
}

By avoiding new connections inside a held transaction and by using ordered locking or asynchronous processing, the three classic deadlock patterns can be reliably reproduced and resolved.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaconcurrencydeadlockSpring Boottransactionalspring-data-jpa
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.