synchronized and Locks
Prevent race conditions with synchronized and ReentrantLock, avoid deadlocks, and keep locks short.
synchronized (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
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.
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: truetryLock 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.
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: falseA 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.
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