synchronized and Locks

Prevent race conditions with synchronized and ReentrantLock, avoid deadlocks, and keep locks short.

Syntaxsynchronized (lock) { /* one thread at a time */ } lock.lock(); try { /* critical section */ } finally { lock.unlock(); }

When two threads update the same object, their steps can interleave. count++ looks like one operation but is three: read, add, write. Two threads can both read 41 and both write 42, and one update is lost. This is a race condition, and it produces wrong numbers only sometimes, usually under load in production.

synchronized

A synchronized method or block lets only one thread at a time hold an object's monitor. It also guarantees visibility: changes made while holding the lock are seen by the next thread that takes it. Without that, a thread may keep reading a stale value.

synchronized void increment() { value++; }      // locks this

private final Object lock = new Object();
void add(String item) {
    synchronized (lock) { items.add(item); }    // locks a private object
}

Prefer a private lock object over this, so outside code cannot lock your object and interfere. Every read and every write of the shared state must use the same lock; synchronising only the writer is a common bug.

ReentrantLock

java.util.concurrent.locks.ReentrantLock does the same job with more control: tryLock with a timeout, interruptible waiting and optional fairness. You must unlock in a finally block, or one exception leaves the lock held forever. ReentrantReadWriteLock allows many readers or one writer, which suits caches that are read far more often than written.

On older JDKs, synchronized pinned virtual threads to their carrier, and the advice was to switch to ReentrantLock. Since Java 24 that is no longer necessary; choose based on the features you need.

Deadlocks

If thread A holds lock 1 and waits for lock 2 while thread B holds lock 2 and waits for lock 1, both wait forever. The usual fix is a global lock order, for example always locking accounts by id. tryLock with a timeout is a second line of defence.

Back-end advice

  • Never hold a lock while calling a database or another service. Every other request queues behind that network call.
  • In a cluster, a JVM lock protects only one instance. Use database constraints, row locks or optimistic versioning for data shared between instances.
  • Before reaching for a lock, check whether an atomic class, a concurrent collection or an immutable object solves the problem more simply.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Protect an in-memory rate counter shared by every request handler in one service instance.
  • Move money between two in-memory ledgers without deadlocks by always locking accounts in id order.
  • Reject a second concurrent export request with 409 by using tryLock on a report lock.

More examples

Transfers with a consistent lock order

Both threads lock acc-a before acc-b whatever the direction, so they cannot wait on each other in a cycle. Each unlock sits in its own finally block.

Example · java
class Account {
    final String id;
    final ReentrantLock lock = new ReentrantLock();
    long pence;

    Account(String id, long pence) { this.id = id; this.pence = pence; }
}

void transfer(Account from, Account to, long pence) {
    // Lock in id order so opposite transfers can never deadlock.
    Account first = from.id.compareTo(to.id) < 0 ? from : to;
    Account second = first == from ? to : from;
    first.lock.lock();
    try {
        second.lock.lock();
        try {
            if (from.pence < pence) throw new IllegalStateException("insufficient funds in " + from.id);
            from.pence -= pence;
            to.pence += pence;
        } finally {
            second.lock.unlock();
        }
    } finally {
        first.lock.unlock();
    }
}

void main() throws InterruptedException {
    Account a = new Account("acc-a", 10_000);
    Account b = new Account("acc-b", 10_000);
    Thread aToB = Thread.ofPlatform().start(() -> { for (int i = 0; i < 1_000; i++) transfer(a, b, 3); });
    Thread bToA = Thread.ofPlatform().start(() -> { for (int i = 0; i < 1_000; i++) transfer(b, a, 1); });
    aToB.join();
    bToA.join();
    IO.println(a.id + ": " + a.pence + ", " + b.id + ": " + b.pence);
    IO.println("total unchanged: " + (a.pence + b.pence == 20_000));
}

// Output:
// acc-a: 8000, acc-b: 12000
// total unchanged: true

tryLock to fail fast instead of queueing

tryLock gives up after the timeout and returns false, so a request can answer quickly instead of hanging behind a long job.

Example · java
void main() throws InterruptedException {
    ReentrantLock reportLock = new ReentrantLock();
    reportLock.lock();   // main is busy generating the nightly report
    try {
        Thread request = Thread.ofPlatform().start(() -> {
            try {
                if (reportLock.tryLock(100, TimeUnit.MILLISECONDS)) {
                    try {
                        IO.println("generated report");
                    } finally {
                        reportLock.unlock();
                    }
                } else {
                    IO.println("report busy, answer 409 and let the client retry");
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        request.join();
    } finally {
        reportLock.unlock();
    }
    IO.println("locked after unlock: " + reportLock.isLocked());
}

// Output:
// report busy, answer 409 and let the client retry
// locked after unlock: false

A read-mostly cache with ReentrantReadWriteLock

Readers do not block each other, only writers. For a simple map, ConcurrentHashMap is usually the easier choice; a read-write lock fits when several fields must change together.

Example · java
class PriceCache {
    private final Map<String, Integer> prices = new HashMap<>();
    private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();

    Integer get(String sku) {
        rw.readLock().lock();   // many readers may hold this at once
        try {
            return prices.get(sku);
        } finally {
            rw.readLock().unlock();
        }
    }

    void put(String sku, int pence) {
        rw.writeLock().lock();  // exclusive: waits for readers to leave
        try {
            prices.put(sku, pence);
        } finally {
            rw.writeLock().unlock();
        }
    }
}

void main() throws Exception {
    PriceCache cache = new PriceCache();
    cache.put("SKU-1", 1999);
    cache.put("SKU-2", 450);
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<Future<Integer>> reads = new ArrayList<>();
        for (int i = 0; i < 8; i++) {
            String sku = i % 2 == 0 ? "SKU-1" : "SKU-2";
            reads.add(executor.submit(() -> cache.get(sku)));
        }
        int total = 0;
        for (Future<Integer> f : reads) total += f.get();
        IO.println("8 concurrent reads, total pence: " + total);
    }
}

// Output:
// 8 concurrent reads, total pence: 9796

Discussion

  • Be the first to comment on this lesson.