This is the central lesson of the module. Everything so far was preparation: you know what a thread is, you know how to create it, cancel it and observe what state it is in. Now it is time to solve the problem that gives the whole topic its point, and that has been open since 08-01: two threads adding one to a counter two million times and getting one million three hundred thousand.
The lesson has two halves. The first is theory you cannot skip: why counter++ is not one operation but three, what exactly an atomic operation is, and —the deepest thing in the module— the Java memory model, which explains why one thread can write a value and another never see it, even after minutes. That part is uncomfortable because it contradicts the intuition that memory is a place you write to and read from; but without it, volatile and synchronized are magic applied out of superstition.
The second half is the toolbox: volatile for visibility, synchronized for mutual exclusion, ReentrantLock when synchronized is not enough, ReadWriteLock when reads far outnumber writes, and the two strategies that beat them all: immutability and confinement. In between, the reproducible deadlock and its solutions.
By the end, Catalog and LoanRegistry will be thread-safe, and case C of 08-01 —Marta Ruiz and Diego Alonso registering loans at the same time— will stop being a threat.
How to study this lesson. It is long and dense on purpose: it holds up the rest of the module and a good part of your career writing services. Run the examples that fail. Especially the one in section 5, the stop flag that without
volatileis never seen: the first time you watch it hang you will understand the memory model better than from ten pages of text.
Contents
- The race condition at bytecode level
- What an atomic operation is
- The Java memory model
- The happens-before relationship
volatile: what it guaranteesvolatile: what it does NOT guaranteesynchronized: the intrinsic monitor- The forms of
synchronized - What to synchronise and what not to
- Deadlock: the reproducible example
- The solutions to deadlock
- Starvation and livelock
ReentrantLockversussynchronizedCondition: a replacement forwait/notifyReadWriteLock: read a lot, write a little- Safe publication and immutability
- Confinement: the best synchronisation is not sharing
- BiblioTech: a thread-safe catalogue
- Common Mistakes and Tips
- Exercises
- The race condition at bytecode level
counter++ looks like one operation. It is not. Compile it and look:
public void increment();
Code:
0: aload_0 // push the 'this' reference
1: dup // duplicate it (needed twice)
2: getfield #7 // READ the 'value' field <-- operation 1
5: iconst_1 // push the constant 1
6: iadd // ADD <-- operation 2
7: putfield #7 // WRITE the 'value' field <-- operation 3
10: returnThree separate operations: read, add, write. Between any of them the scheduler can take the thread off and put another one on. That gap is the race condition.
The interleaving that loses an increment, step by step, starting from value = 10:
sequenceDiagram
participant A as adder-1
participant M as memory (value)
participant B as adder-2
Note over M: value = 10
A->>M: getfield -> reads 10
Note over A: register A = 10
B->>M: getfield -> reads 10
Note over B: register B = 10
Note over A: iadd -> A = 11
Note over B: iadd -> B = 11
A->>M: putfield 11
Note over M: value = 11
B->>M: putfield 11
Note over M: value = 11 (again!)
Note over A,B: Two increments, value only went up by 1.<br/>One increment LOST.
Both threads read 10, both computed 11, both wrote 11. Two increments were executed and the counter went up by one. Repeat this a million times with random interleavings and you get exactly the 08-01 output: hundreds of thousands of lost increments.
This pattern has a name: read-modify-write. It shows up in many more forms than you would think, and all of them are unsafe without protection:
value++; // read, add, write
value--; // same
value += 5; // same
balance = balance - amount; // same
if (!map.containsKey(k)) { // CHECK...
map.put(k, v); // ...THEN ACT: another thread can slip in between
}
if (instance == null) { // the classic lazy singleton
instance = new Service();
}
list.add(list.size(), x); // read the size, then use itThe last two families —check-then-act and read-modify-write— are the two canonical forms of race condition. If you see either of them on shared state, there is a bug.
- What an atomic operation is
An operation is atomic if, from the point of view of the other threads, it either happens in full or does not happen: there is no observable intermediate state.
In Java, the following are atomic:
- Reading and writing any primitive variable except
longanddouble, and any reference. - Reading and writing a
longordoubledeclaredvolatile. - The operations of the
java.util.concurrent.atomicclasses (08-06).
The following are not atomic:
value++,value--,value += n(three operations).- Any sequence of two or more operations that must be seen as one.
- Reading or writing a non-
volatilelong/double.
The curious case of long and double. The specification allows the JVM to implement writing a 64-bit value as two 32-bit writes. On a 32-bit JVM that genuinely happened, and it produced an unsettling phenomenon: a thread could read a long whose upper half came from one write and lower half from another, obtaining a value no thread ever wrote. It was called word tearing.
public class TornLong {
// Without volatile, on a 32-bit JVM, a reader could see
// 0x00000000FFFFFFFF: half of one value and half of another.
private long loanCounter = 0;
// With volatile, the 64-bit write is atomic by specification.
private volatile long safeCounter = 0;
}On today's 64-bit JVMs this no longer happens in practice, but the specification still permits it, so correct code does not rely on that: if you share a long or a double, declare it volatile or protect it.
The point to take away: the atomicity of an individual operation is not enough. Even though every read and every write of value is atomic, value++ is still incorrect because it is three separate atomic operations. The atomicity you need is that of the compound operation, and that one you have to build.
- The Java memory model
Here comes the part that contradicts intuition. So far you have assumed memory is a shared notebook: one thread writes a line and another reads it. That picture is false on any processor of the last thirty years.
3.1 The three reasons memory does not behave as you expect
Reason 1: every core has its own caches.
Reaching main memory costs a few hundred cycles. Reaching the core's L1 cache costs a handful. That is why every core keeps local copies of the data it uses. When core 1 writes counter = 5, that 5 can sit in its L1 cache for an indeterminate time before reaching a level visible to core 2.
flowchart TB
subgraph CPU["Processor"]
direction LR
subgraph N1["Core 1 - thread A"]
R1["Registers"] --- L11["L1 cache<br/>counter = 5"]
end
subgraph N2["Core 2 - thread B"]
R2["Registers"] --- L12["L1 cache<br/>counter = 0"]
end
end
L11 --- L2["Shared L2/L3 cache"]
L12 --- L2
L2 --- RAM["Main memory<br/>counter = 0"]
In that picture, thread A has written 5 and thread B is still reading 0. Both are right according to their own cache. And there is no timing guarantee about when —or whether— B will see the 5.
Reason 2: the compiler reorders instructions.
Both javac and, above all, the JVM's JIT compiler freely reorder code as long as the result is the same for a single thread. That clause is the key: the guarantee is called as-if-serial and it only applies within a thread.
// What you write:
data = loadCatalogue(); // (1)
ready = true; // (2)
// What the compiler may generate, because for THIS thread
// the result is indistinguishable:
ready = true; // (2)
data = loadCatalogue(); // (1)For another thread doing if (ready) use(data), that reordering is catastrophic: it can see ready == true with data == null.
Reason 3: the CPU reorders too.
Modern processors execute out of order and have write buffers. Even if the compiler emits the instructions in order, the hardware can make them effective in another. On x86 the model is relatively strong; on ARM —your phone, many servers— it is much weaker and the reorderings are easily observed.
3.2 The consequence: two threads can see different histories
Without synchronisation, there is no guarantee that one thread sees another's writes, nor in the same order, nor ever. It is not that it is delayed: it may never see them at all.
This program proves it, and it is the most striking example in the module:
public class FlagWithoutVolatile {
// NO volatile. There is the problem.
private static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long rounds = 0;
// The JIT can turn this into 'while (true)' because
// inside THIS thread 'stop' never changes: it is a
// legal optimisation called hoisting.
while (!stop) {
rounds++;
}
System.out.println("[worker] stopped after " + rounds + " rounds");
}, "bibliotech-worker");
worker.start();
Thread.sleep(1000);
System.out.println("[main] writing stop = true");
stop = true;
worker.join(3000);
if (worker.isAlive()) {
System.out.println("[main] THE WORKER HAS NOT NOTICED. Still alive.");
System.exit(1);
}
}
}Typical output with the JIT running (compile with javac and run normally, with no debugger):
The worker thread never stops. main wrote true and the worker went on reading false indefinitely. It is not a delay of microseconds: it is forever.
Why it happens: the JIT compiler sees that nobody modifies stop inside the loop, so it hoists the read out of the loop —a standard optimisation, perfectly legal under the as-if-serial guarantee— and turns it into the equivalent of:
If it does stop when you run it, try a longer warm-up (the JIT takes a few thousand iterations to compile), or add
-XX:+PrintCompilationto see when the method is compiled. Under a debugger or with-Xint(interpreter only) it almost always stops, because the interpreter does not apply that optimisation. That is precisely the danger: the bug disappears under the debugger.
Change boolean stop to volatile boolean stop and run again:
It stops immediately. That keyword is the difference between a correct program and one that hangs.
- The happens-before relationship
The Java memory model (JSR 133, incorporated in Java 5) does not describe caches or reorderings: it defines a partial ordering relationship called happens-before between actions, and a single guarantee:
If action A happens-before action B, then A's effects are visible to B, and B sees A as having occurred first.
And its contrapositive, which is the one you will use in practice:
If two actions are not related by happens-before and at least one writes, there is a data race and there is no guarantee whatsoever about what is observed.
The rules that establish happens-before —memorise them, they are the entire toolbox:
| Rule | It establishes that… |
|---|---|
| Program order | Within a single thread, each action happens-before the ones that follow it in the code |
| Monitor | Releasing a monitor happens-before any subsequent acquisition of the same monitor |
volatile |
Writing a volatile variable happens-before any subsequent read of that same variable |
Thread.start() |
Everything done before start() happens-before the new thread's first action |
Thread.join() |
Everything done by the thread happens-before join() returning |
final fields |
Initialising a final field in the constructor happens-before another thread seeing the correctly constructed object |
| Transitivity | If A hb B and B hb C, then A hb C |
j.u.c. utilities |
Putting into a concurrent queue hb taking from it; countDown hb await; etc. (08-05, 08-06) |
Applied to the cases you already know:
// start() RULE: main writes, the new thread is guaranteed to see it.
catalog.load(); // A
Thread t = new Thread(task);
t.start(); // A happens-before everything in the new thread
// inside 'task': it sees the loaded catalogue, guaranteed.
// join() RULE: the thread writes, main is guaranteed to see it.
t.start();
t.join(); // everything in the thread hb join returning
System.out.println(task.result()); // correct value guaranteed
// MONITOR RULE: visibility transfer between critical sections.
synchronized (lock) { x = 42; } // releasing hb...
// ... another thread:
synchronized (lock) { read(x); } // ...acquiring: sees x == 42
// volatile RULE + transitivity: the "safe publication" idiom.
data = loadCatalogue(); // (1) ordinary write
ready = true; // (2) VOLATILE write
// ... another thread:
if (ready) { // (3) VOLATILE read
use(data); // (4) sees data correctly. Why?
}
// (1) hb (2) by program order;
// (2) hb (3) by the volatile rule;
// (3) hb (4) by program order;
// by transitivity: (1) hb (4). Accessing 'data' is safe
// EVEN THOUGH 'data' is not volatile.That last case is important and surprising: a single volatile variable can make visible the ordinary writes that precede it. This is what is called safe publication, and it is the basis of the pattern in section 16.
The practical way to use all this, without memorising the specification: every time two threads touch the same data and at least one writes, ask yourself "which happens-before rule connects those two actions?". If you cannot answer, there is none, and you have a data race.
volatile: what it guarantees
volatile: what it guaranteesvolatile is Java's lightest synchronisation mechanism. It provides two guarantees, not one more:
Guarantee 1: visibility. A volatile write becomes visible immediately to any thread that later reads that variable. In practice, the JVM emits memory barriers: the write flushes the buffer to shared memory and the read invalidates the cached copy.
Guarantee 2: no reordering. volatile reads and writes are not reordered with respect to each other, and they act as a barrier for those around them: nothing before a volatile write can move after it, and nothing after a volatile read can move before it.
In addition, as a derived effect: reads and writes of volatile long/double are atomic (goodbye word tearing).
The canonical use case, and practically the only one you will need: a status flag written by one thread and read by others.
package com.nexussoftware.bibliotech.persistence;
/**
* Import service with a clean stop through a volatile flag.
* It complements the interruption of 08-02: an own flag allows
* an orderly stop without depending on the interrupt status.
*/
public class ImportService implements Runnable {
// volatile: written by the menu thread, read by the worker thread.
// WITHOUT volatile, the worker might NEVER notice (section 3.2).
private volatile boolean stop = false;
// Progress published towards the menu thread. Only THIS thread writes it,
// so there is no concurrent read-modify-write: volatile is enough.
private volatile int linesProcessed = 0;
public void stop() {
stop = true;
}
@Override
public void run() {
while (!stop && !Thread.currentThread().isInterrupted()) {
processBatch();
linesProcessed += 1000; // safe: a SINGLE writer
}
}
public int linesProcessed() { return linesProcessed; }
private void processBatch() { /* ... */ }
}Note the comment on linesProcessed: += 1000 is read-modify-write and in general would be unsafe… but there is only one writer. With a single thread writing, no interleaving is possible between that thread's read and write with itself, and volatile gives readers the visibility they need. This reasoning is valid and common; what would invalidate it is a second writer.
When volatile is enough, in summary:
- The write does not depend on the current value (or there is a single writer).
- It is not part of an invariant together with other variables.
- No lock is needed for any other reason.
If all three hold, volatile is the right choice and it is far cheaper than a lock: it does not block, it creates no contention and it cannot cause a deadlock.
volatile: what it does NOT guarantee
volatile: what it does NOT guaranteeHere is the most widespread confusion on the topic, and it is worth destroying with a demonstration.
volatiledoes NOT make compound operations atomic.
public class VolatileCounterStillBroken {
// volatile DOES guarantee both threads see the most recent value.
// volatile does NOT guarantee that 'value++' is indivisible.
private volatile int value = 0;
public void increment() {
value++; // it is still READ, ADD, WRITE
}
public int value() { return value; }
public static void main(String[] args) throws InterruptedException {
final int ROUNDS = 1_000_000;
for (int attempt = 1; attempt <= 3; attempt++) {
VolatileCounterStillBroken c = new VolatileCounterStillBroken();
Thread t1 = new Thread(() -> { for (int i = 0; i < ROUNDS; i++) c.increment(); });
Thread t2 = new Thread(() -> { for (int i = 0; i < ROUNDS; i++) c.increment(); });
t1.start(); t2.start();
t1.join(); t2.join();
System.out.printf("Attempt %d: expected %d, actual %d, lost %d%n",
attempt, ROUNDS * 2, c.value(), ROUNDS * 2 - c.value());
}
}
}Output:
Attempt 1: expected 2000000, actual 1223981, lost 776019
Attempt 2: expected 2000000, actual 1341102, lost 658898
Attempt 3: expected 2000000, actual 1189447, lost 810553It still loses nearly 40% of the increments. volatile solved visibility —both threads now see the most recent value on every read— but the gap between reading and writing is still there, and it is still the race of section 1.
The two examples together are the complete lesson:
| Example | Without volatile |
With volatile |
|---|---|---|
| Stop flag (section 3.2) | Hangs: it never sees the change | Works: it stops immediately |
value++ counter |
Loses increments | Still loses increments |
Summary table of guarantees:
| Property | volatile |
synchronized |
AtomicInteger (08-06) |
|---|---|---|---|
| Visibility | Yes | Yes | Yes |
| No reordering | Yes | Yes | Yes |
| Atomicity of a simple read/write | Yes (incl. long/double) |
Yes | Yes |
| Atomicity of read-modify-write | No | Yes | Yes |
| Mutual exclusion of a block | No | Yes | No |
| Can cause deadlock | No | Yes | No |
| Cost | Very low | Medium | Low |
The other frequent mistake with volatile: believing it protects the object a reference points to.
// A volatile 'catalog' guarantees you see the most recent reference...
private volatile List<Material> catalog = new ArrayList<>();
// ...but it does NOT protect the CONTENTS of the list.
catalog.add(newBook); // still a data racevolatile protects the variable, not the object. For the contents you need locks, a concurrent collection (08-06) or immutability.
synchronized: the intrinsic monitor
synchronized: the intrinsic monitorEvery Java object has an associated monitor —also called an intrinsic lock—. You do not declare it: it exists by virtue of being an object. synchronized is the keyword that acquires and releases it.
Exact semantics:
- On entering, the thread acquires
object's monitor. If another thread holds it, it goesBLOCKED(08-03) until it is released. - On leaving —through the end of the block, through a
return, or through an exception—, the monitor is released. That last one matters:synchronizedis exception-safe by construction, unlikeReentrantLock. - The happens-before relationships of the monitor rule are established: releasing hb acquiring later.
synchronized gives two things at once, and that is its virtue:
- Mutual exclusion: only one thread at a time runs the block.
- Visibility: on entering, the thread sees everything the previous thread that released that same monitor did.
The second one is constantly forgotten and it is half the value. A synchronized not only stops two threads from trampling the data: it guarantees the second one sees what the first did.
The fixed counter:
public class SynchronizedCounter {
private int value = 0;
// Synchronized instance method: it acquires 'this's monitor.
public synchronized void increment() {
value++; // now the three operations are indivisible
// with respect to other threads using THIS monitor
}
// ALSO synchronized. If it were not, a reader could see
// a stale value: mutual exclusion without visibility is not enough.
public synchronized int value() {
return value;
}
public static void main(String[] args) throws InterruptedException {
final int ROUNDS = 1_000_000;
for (int attempt = 1; attempt <= 3; attempt++) {
SynchronizedCounter c = new SynchronizedCounter();
Thread t1 = new Thread(() -> { for (int i = 0; i < ROUNDS; i++) c.increment(); });
Thread t2 = new Thread(() -> { for (int i = 0; i < ROUNDS; i++) c.increment(); });
t1.start(); t2.start();
t1.join(); t2.join();
System.out.printf("Attempt %d: expected %d, actual %d%n",
attempt, ROUNDS * 2, c.value());
}
}
}Output:
Attempt 1: expected 2000000, actual 2000000
Attempt 2: expected 2000000, actual 2000000
Attempt 3: expected 2000000, actual 2000000Exact, always, on every run. The problem open since 08-01 is solved.
The detail that gets forgotten: the getter is synchronized too. If
value()were not, a reader could see a stale value from its cache, even though the writers were perfectly correct. Every access to shared data —reads included— must use the same synchronisation mechanism. It is the most broken rule after theifinstead of thewhile.
Reentrancy. Java's monitors are reentrant: if a thread already holds the monitor, it can acquire it again without blocking. The JVM keeps an acquisition count and only releases when it reaches zero.
public class Reentrancy {
public synchronized void registerLoan(String isbn) {
validate(isbn);
// Call to ANOTHER synchronized method on the SAME object.
// Without reentrancy, the thread would block itself: an instant
// deadlock with itself.
updateIndex(isbn);
}
public synchronized void updateIndex(String isbn) { /* ... */ }
private void validate(String isbn) { /* ... */ }
}Without reentrancy, inheritance would be unworkable: a synchronized subclass method calling super.synchronizedMethod() would block against itself. Reentrancy makes this simply work.
- The forms of
synchronized
synchronizedThere are three, and they are not equivalent.
Form 1: synchronized instance method. It locks this.
public synchronized void add(Material m) { /* ... */ }
// It is EXACTLY equivalent to:
public void add(Material m) {
synchronized (this) { /* ... */ }
}Form 2: synchronized static method. It locks the Class object.
public static synchronized void logGlobally(String event) { /* ... */ }
// Equivalent to:
public static void logGlobally(String event) {
synchronized (Catalog.class) { /* ... */ }
}A critical consequence that surprises many people: a synchronized instance method and a synchronized static method of the same class use different monitors and do not exclude each other. If both touch the same static state, there is a race.
public class TwoDifferentMonitors {
private static int globalCounter = 0;
// Locks 'this'
public synchronized void incrementWrong() { globalCounter++; }
// Locks 'TwoDifferentMonitors.class'
public static synchronized void incrementStatic() { globalCounter++; }
// These two methods do NOT exclude each other: they use different locks.
// The static state they share is NOT protected. BUG.
}Form 3: synchronized block with an explicit lock object.
private final Object lock = new Object();
public void add(Material m) {
// Work that does NOT need protection, outside the lock:
validate(m);
String key = normalise(m.isbn());
synchronized (lock) {
// Critical section: the bare minimum
materials.add(m);
index.put(key, m);
}
// More non-critical work
recordInAudit(m);
}This third form is the preferable one, for two reasons:
Reason A: granularity. The synchronized method locks the whole method, including work that does not need it. If validate() takes 50 ms, every other thread waits 50 ms for nothing. The block protects only what is essential.
Reason B: control over the lock. synchronized (this) exposes the monitor to the world: any external code can do synchronized (myCatalog) and lock your class from outside, deliberately or by accident. It is not theoretical: Vector and Hashtable synchronise on this and that is one of the reasons they are discouraged.
public class Catalog {
// PRIVATE and FINAL lock: nobody outside can acquire it,
// and nobody can reassign it (a reassigned lock stops
// protecting: two threads could lock different objects).
private final Object lock = new Object();
// And NEVER use something like this as a lock:
// private final Integer lock = 42; // <- may be interned
// private final String lock = "cat"; // <- interned literal: SHARED
// private final Boolean lock = false; // <- Boolean.FALSE, shared
// A String literal or a small Integer is a UNIQUE object across the JVM:
// another class using the same literal shares your lock without knowing.
}Comparison table:
| Form | Lock | Granularity | Exposed | Recommendation |
|---|---|---|---|---|
Instance synchronized |
this |
The whole method | Yes | Only in small, controlled classes |
Static synchronized |
Class.class |
The whole method | Yes | Only for static state |
synchronized (privateLock) |
Private object | Whatever you decide | No | Preferred |
- What to synchronise and what not to
Over-synchronising is as bad as under-synchronising: it turns a concurrent program into a sequential one with added overhead.
Rule 1: keep the critical section as short as possible.
// WRONG: 300 ms of I/O inside the lock. Every other thread waits.
public void registerLoan(Loan l) {
synchronized (lock) {
loans.put(l.id(), l);
writeToDisk(l); // 300 ms of I/O
sendNotification(l.employee()); // another 200 ms
}
}
// RIGHT: only the in-memory state change is protected.
public void registerLoan(Loan l) {
synchronized (lock) {
loans.put(l.id(), l); // microseconds
}
writeToDisk(l); // outside the lock
sendNotification(l.employee()); // outside the lock
}Rule 2: never do I/O inside a lock. It is a special case of rule 1, but it deserves its own entry because it is the most frequent performance mistake. A disk read can take 10 ms; a remote query, hundreds. Holding a lock for that long serialises the whole application.
Rule 3: never call foreign code holding a lock. Foreign code is anything you do not control: an overridable method, a callback, a listener registered by the user.
// DANGEROUS: 'listeners' contains code you do not control.
synchronized (lock) {
for (CatalogListener l : listeners) {
l.materialAdded(m); // what does it do? does it block? call back?
}
}
// SAFE: copy under the lock, notify outside.
List<CatalogListener> copy;
synchronized (lock) {
copy = new ArrayList<>(listeners);
}
for (CatalogListener l : copy) {
l.materialAdded(m); // no lock: it cannot block us
}A listener that tries to acquire another lock from there can cause a deadlock; one that calls back into your class, an unexpected recursion. The copy under the lock and notify outside pattern is standard, and in 08-06 you will see that CopyOnWriteArrayList does it for you.
Rule 4: do not synchronise what is not shared. Synchronising a local variable or a thread-confined object is pure cost. (The JVM sometimes detects this and removes the lock —lock elision—, but do not count on it.)
Rule 5: document the synchronisation policy. A comment on the field saying what protects it is worth half an hour of reading code:
public class LoanRegistry {
private final Object lock = new Object();
/** Guarded by 'lock'. */
private final Map<String, Loan> byId = new HashMap<>();
/** Guarded by 'lock'. Invariant: contains the same loans as 'byId'. */
private final Map<Employee, List<Loan>> byEmployee = new HashMap<>();
}That /** Guarded by 'lock'. */ is the @GuardedBy annotation from the book Java Concurrency in Practice written by hand. It costs one line and stops somebody —you, in six months— touching the field without the lock.
- Deadlock: the reproducible example
You saw a deadlock in 08-03 from the diagnostic point of view. Now from the design one.
A deadlock needs four simultaneous conditions (the Coffman conditions):
- Mutual exclusion: the resources are not shared.
- Hold and wait: a thread holds one and asks for another.
- No pre-emption: a lock cannot be taken away from a thread.
- Circular wait: there is a cycle of threads waiting for each other.
With synchronized, the first three are given by the language semantics themselves. The only one you can act on is the fourth, and that is the whole strategy.
A realistic BiblioTech example: transferring a loan between two employees requires locking both accounts.
package com.nexussoftware.bibliotech.service;
import java.util.concurrent.TimeUnit;
public class LoanTransfer {
/** Each employee has their own lock and their loan count. */
static class EmployeeAccount {
final String name;
final Object lock = new Object();
int loans;
EmployeeAccount(String name, int loans) {
this.name = name;
this.loans = loans;
}
}
/**
* DEADLOCKING VERSION.
* Locks 'source' and then 'target'. If two threads transfer
* in opposite directions, the cycle forms.
*/
static void transferWrong(EmployeeAccount source, EmployeeAccount target, int n) {
synchronized (source.lock) {
pause(100); // widens the failure window
synchronized (target.lock) {
source.loans -= n;
target.loans += n;
System.out.printf(" %s -> %s : %d loans%n",
source.name, target.name, n);
}
}
}
public static void main(String[] args) throws InterruptedException {
EmployeeAccount marta = new EmployeeAccount("Marta Ruiz", 5);
EmployeeAccount diego = new EmployeeAccount("Diego Alonso", 5);
// Thread 1: marta -> diego (locks marta, then diego)
Thread t1 = new Thread(() -> transferWrong(marta, diego, 1), "thread-marta-diego");
// Thread 2: diego -> marta (locks diego, then marta) <-- REVERSE ORDER
Thread t2 = new Thread(() -> transferWrong(diego, marta, 1), "thread-diego-marta");
t1.start(); t2.start();
t1.join(3000);
t2.join(3000);
if (t1.isAlive() || t2.isAlive()) {
System.out.println("DEADLOCK: both threads are still alive and blocked.");
System.out.println("State t1: " + t1.getState());
System.out.println("State t2: " + t2.getState());
System.exit(1);
}
}
}Output:
Both threads BLOCKED forever, no exception, no trace, no CPU usage. Diagram of what happened:
sequenceDiagram
participant T1 as thread-marta-diego
participant CM as Marta's lock
participant CD as Diego's lock
participant T2 as thread-diego-marta
T1->>CM: acquires OK
T2->>CD: acquires OK
Note over T1,T2: both sleep for 100 ms
T1->>CD: asks - T2 holds it
Note over T1: BLOCKED
T2->>CM: asks - T1 holds it
Note over T2: BLOCKED
Note over T1,T2: circular wait: neither releases.<br/>PERMANENT DEADLOCK
- The solutions to deadlock
Solution A: global acquisition order
Define a total ordering over all the locks and always acquire them in that order. With no circular wait there is no deadlock; it is a mathematical guarantee, not a probability.
The ordering can be anything as long as it is total and consistent. Here System.identityHashCode is used, which gives a stable integer per object:
/**
* CORRECT VERSION: global acquisition order.
* The locks are ALWAYS taken in increasing identityHashCode order,
* regardless of the direction of the transfer.
*/
static void transferRight(EmployeeAccount source, EmployeeAccount target, int n) {
int hs = System.identityHashCode(source);
int ht = System.identityHashCode(target);
if (hs < ht) {
synchronized (source.lock) {
synchronized (target.lock) {
move(source, target, n);
}
}
} else if (hs > ht) {
synchronized (target.lock) { // order REVERSED with respect to the
synchronized (source.lock) { // business, but CONSISTENT across threads
move(source, target, n);
}
}
} else {
// Extremely rare case: identityHashCode collision between two
// different objects. A third global lock breaks the tie and
// guarantees only one thread takes this path at a time.
synchronized (TIE_BREAK_LOCK) {
synchronized (source.lock) {
synchronized (target.lock) {
move(source, target, n);
}
}
}
}
}
private static final Object TIE_BREAK_LOCK = new Object();
private static void move(EmployeeAccount source, EmployeeAccount target, int n) {
source.loans -= n;
target.loans += n;
}With this, the two threads of section 10 acquire the locks in the same order, one of the two wins, does its work and releases. No deadlock is possible, not on your laptop, not in production, not in ten years' time.
If your objects have a natural identifier —an ISBN, an employee identifier—, use it: it is more readable and stable than identityHashCode.
// With a natural identifier, far clearer:
EmployeeAccount first = source.id().compareTo(target.id()) < 0 ? source : target;
EmployeeAccount second = (first == source) ? target : source;
synchronized (first.lock) {
synchronized (second.lock) {
move(source, target, n);
}
}Solution B: tryLock with a time limit
If you cannot impose an ordering —because the locks are chosen by components you do not control—, the alternative is not to wait indefinitely: try to acquire with a deadline and, if it fails, release everything and retry. It requires ReentrantLock (section 13), because synchronized has no tryLock.
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class TransferWithTryLock {
static class EmployeeAccount {
final String name;
final ReentrantLock lock = new ReentrantLock();
int loans;
EmployeeAccount(String name, int loans) {
this.name = name; this.loans = loans;
}
}
/**
* Acquires both locks with a deadline. If it fails to get both,
* it releases whatever it holds, waits a RANDOM time and retries.
* The randomness is essential: without it, two threads could retry
* in lockstep forever (livelock, section 12).
*/
static boolean transfer(EmployeeAccount source, EmployeeAccount target,
int n, int maxAttempts) throws InterruptedException {
for (int attempt = 0; attempt < maxAttempts; attempt++) {
boolean haveSource = false;
boolean haveTarget = false;
try {
haveSource = source.lock.tryLock(50, TimeUnit.MILLISECONDS);
if (haveSource) {
haveTarget = target.lock.tryLock(50, TimeUnit.MILLISECONDS);
}
if (haveSource && haveTarget) {
source.loans -= n;
target.loans += n;
return true; // success
}
} finally {
// ALWAYS release what was acquired, in reverse order.
if (haveTarget) target.lock.unlock();
if (haveSource) source.lock.unlock();
}
// Random backoff before retrying.
TimeUnit.MILLISECONDS.sleep(
ThreadLocalRandom.current().nextInt(10, 60));
}
return false; // attempts exhausted
}
}Comparison of the two solutions:
| Global ordering | tryLock with deadline |
|
|---|---|---|
| Guarantee | Absolute: deadlock is impossible | Probabilistic: avoided, not forbidden |
| Cost | None at run time | Retries and waits |
| Requirement | Being able to order the locks | Being able to abandon and retry the operation |
| Code complexity | Low | Medium-high |
| When to use it | Whenever possible | When ordering cannot be imposed |
The recommendation is clear: global ordering whenever you can. tryLock is plan B.
Solution C: the best of all, a single lock
Before getting complicated: do you really need two locks? Very often a single coarser lock solves the problem with a perfectly acceptable contention cost. A lock cannot deadlock against itself.
// If transfers are not 90% of the load, this is
// simpler, safer and probably just as fast.
private static final Object TRANSFER_LOCK = new Object();
static void transfer(EmployeeAccount source, EmployeeAccount target, int n) {
synchronized (TRANSFER_LOCK) {
source.loans -= n;
target.loans += n;
}
}Measure before optimising. Fine granularity is an optimisation, and like every optimisation it has a cost in complexity and risk.
- Starvation and livelock
Starvation. A thread never gets the resource it needs because others always take it first. Common causes:
- Very unequal priorities (08-02: do not use them).
- An unfair lock that systematically favours the thread that just released it, because its cache is still warm.
- A thread holding a lock for too long (I/O inside the lock, section 9).
Solution: short critical sections and, if it is genuinely needed, a fair lock:
// The true parameter enables the FIFO policy: the lock is granted
// to the thread that has been waiting longest.
// COST: much lower performance (often 10x or more), because it prevents
// opportunistic barging and forces context switches.
// Use it only if you have measured real starvation.
ReentrantLock fair = new ReentrantLock(true);Livelock. The threads are active and running, but none makes progress: they react to each other continually. It is the case of two people meeting in a corridor and both stepping the same way, over and over.
In code, tryLock without a random backoff is the classic example:
// LIVELOCK: if the two threads get in phase, they can retry
// forever, always yielding at the same time.
while (true) {
if (a.tryLock()) {
if (b.tryLock()) { doWork(); return; }
a.unlock(); // yields
}
// with no random wait: both threads try again
// at exactly the same moment, again, and again
}The solution is randomized backoff, as in the example in section 11: sleeping a random amount of time before retrying breaks the lockstep. It is the same idea Ethernet uses for collisions.
The difference from deadlock, which matters for diagnosis: in a deadlock the threads are BLOCKED and consume 0% CPU; in a livelock they are RUNNABLE and consume CPU flat out. If your application is not progressing but the CPU is at 100%, suspect livelock, not deadlock — and jstack will not tell you with a Found one Java-level deadlock message.
ReentrantLock versus synchronized
ReentrantLock versus synchronizedjava.util.concurrent.locks.ReentrantLock does the same as synchronized and adds things synchronized cannot do. In exchange, you have to release it by hand.
The mandatory pattern, with no exceptions:
import java.util.concurrent.locks.ReentrantLock;
public class LockCounter {
private final ReentrantLock lock = new ReentrantLock();
private int value = 0;
public void increment() {
lock.lock();
try {
value++;
} finally {
// MANDATORY in finally: if the body throws an exception
// and the unlock is outside, the lock is NEVER released
// and the whole application blocks. It is the number-one
// ReentrantLock mistake, and the reason synchronized is
// still preferable when you need nothing more.
lock.unlock();
}
}
}
lock()goes OUTSIDE thetry. If it were inside and the acquisition failed, thefinallywouldunlock()a lock that is not held:IllegalMonitorStateException. The correct order islock(); try { ... } finally { unlock(); }.
What ReentrantLock adds:
1. tryLock(): acquire without waiting.
if (lock.tryLock()) { // never blocks
try { operation(); } finally { lock.unlock(); }
} else {
// plan B: queue it, tell the user, retry later
}2. tryLock(time, unit): acquire with a deadline. The basis of solution B to deadlock.
3. lockInterruptibly(): a cancellable wait.
try {
lock.lockInterruptibly(); // responds to interrupt() while waiting
try { operation(); } finally { lock.unlock(); }
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}This is impossible with synchronized: a thread BLOCKED waiting for a monitor does not respond to interrupt(). If the operation can be slow and you want to be able to cancel it, ReentrantLock is the only option.
4. Optional fairness: new ReentrantLock(true).
5. Several Conditions per lock (section 14).
6. Introspection: isLocked(), getHoldCount(), getQueueLength() — useful for diagnosis and metrics.
Decision table:
| Feature | synchronized |
ReentrantLock |
|---|---|---|
| Automatic release | Yes, even on exception | No: finally mandatory |
| Syntax | A keyword, impossible to forget | Explicit code |
| Try without blocking | No | tryLock() |
| Waiting deadline | No | tryLock(t, u) |
| Interruptible wait | No | lockInterruptibly() |
| Fairness | No | Optional |
| Wait conditions | One (wait/notify) |
Several (newCondition()) |
| Visible in dumps | Yes, - locked <0x...> |
Yes (with jcmd Thread.print -l) |
| Performance | Equal since Java 6 | Equal |
| Readability | Higher | Lower |
The recommendation: use synchronized by default. It is shorter, impossible to forget to release and perfectly fast since Java 6 introduced biased locking and lock coarsening. Move to ReentrantLock only when you need tryLock, a deadline, interruptibility, fairness or several conditions.
Condition: a replacement for wait/notify
Condition: a replacement for wait/notifyA ReentrantLock can create several Condition objects, each with its own wait queue. It is the elegant solution to the notify versus notifyAll problem of 08-03: you can signal exactly the right group.
Object (with synchronized) |
Condition (with Lock) |
|---|---|
wait() |
await() |
wait(ms) |
await(t, unit) |
notify() |
signal() |
notifyAll() |
signalAll() |
| A single queue per object | As many as you want |
| — | awaitUninterruptibly() |
A bounded queue with two separate conditions:
package com.nexussoftware.bibliotech.service;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/**
* Bounded reservation queue with two separate conditions.
*
* Advantage over wait/notifyAll (08-03): freeing a slot wakes only
* a PRODUCER, and adding an element only a CONSUMER.
* With notifyAll everybody woke up and most went straight back to sleep.
*
* NOTE: in production you would use ArrayBlockingQueue (08-06), which is
* exactly this, already implemented and tested.
*/
public class ReservationQueueWithCondition {
private final ReentrantLock lock = new ReentrantLock();
// TWO conditions over the SAME lock.
private final Condition spaceAvailable = lock.newCondition();
private final Condition itemAvailable = lock.newCondition();
private final Deque<String> queue = new ArrayDeque<>();
private final int capacity;
public ReservationQueueWithCondition(int capacity) {
this.capacity = capacity;
}
public void put(String reservation) throws InterruptedException {
lock.lock();
try {
// WHILE, just as with wait(): the same three reasons from 08-03.
while (queue.size() == capacity) {
spaceAvailable.await(); // releases the lock and waits
}
queue.addLast(reservation);
itemAvailable.signal(); // wakes ONE consumer.
// Safe: only consumers wait on
// this queue, and they are all
// interchangeable.
} finally {
lock.unlock();
}
}
public String take() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
itemAvailable.await();
}
String r = queue.pollFirst();
spaceAvailable.signal(); // wakes ONE producer
return r;
} finally {
lock.unlock();
}
}
public int size() {
lock.lock();
try {
return queue.size();
} finally {
lock.unlock();
}
}
}With separate Conditions, signal() is safe, because every thread on that queue waits for exactly the same condition and they are interchangeable. It is the difference between waking the ten threads that are waiting —nine of which go back to sleep— and waking the only one that can make progress.
ReadWriteLock: read a lot, write a little
ReadWriteLock: read a lot, write a littleAn exclusive lock treats readers and writers the same. But two readers do not get in each other's way: reading modifies nothing. If your structure is read a hundred times for every write —exactly the case of BiblioTech's Catalog—, an exclusive lock wastes nearly all the available parallelism.
ReentrantReadWriteLock offers two coordinated locks:
- Read lock: shared. Many threads at once.
- Write lock: exclusive. It excludes readers and other writers.
| Situation | Allowed? |
|---|---|
| Reader + reader | Yes, simultaneously |
| Reader + writer | No |
| Writer + writer | No |
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.Material;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* Thread-safe catalogue, optimised for reading.
*
* Rationale for the ReadWriteLock: in BiblioTech the catalogue is queried
* dozens of times for every insertion (find by ISBN, list, filter by type),
* and insertions arrive in batches during the import.
*/
public class SafeCatalog {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock read = lock.readLock();
private final ReentrantReadWriteLock.WriteLock write = lock.writeLock();
/** Guarded by 'lock'. */
private final List<Material> materials = new ArrayList<>();
/** Guarded by 'lock'. Invariant: one entry per material. */
private final Map<String, Material> index = new HashMap<>();
/** Guarded by 'lock'. Invariant: same ISBNs as 'index'. */
private final Set<String> registeredIsbns = new HashSet<>();
// ---------- WRITES: exclusive lock ----------
public boolean add(Material m) {
write.lock();
try {
if (!registeredIsbns.add(m.isbn())) {
return false; // it already existed
}
materials.add(m);
index.put(m.isbn(), m);
return true;
} finally {
write.unlock();
}
}
public boolean remove(String isbn) {
write.lock();
try {
Material m = index.remove(isbn);
if (m == null) return false;
materials.remove(m);
registeredIsbns.remove(isbn);
return true;
} finally {
write.unlock();
}
}
/** Complete atomic replacement: what the 08-02 import uses. */
public void replaceAll(List<Material> fresh) {
write.lock();
try {
materials.clear();
index.clear();
registeredIsbns.clear();
for (Material m : fresh) {
if (registeredIsbns.add(m.isbn())) {
materials.add(m);
index.put(m.isbn(), m);
}
}
} finally {
write.unlock();
}
}
// ---------- READS: shared lock ----------
public Material findByIsbn(String isbn) {
read.lock();
try {
return index.get(isbn);
} finally {
read.unlock();
}
}
public int size() {
read.lock();
try {
return materials.size();
} finally {
read.unlock();
}
}
/**
* Returns a COPY. Returning the internal list would be a thread-safety
* failure: whoever received it could iterate it without the lock
* while another thread modifies it -> ConcurrentModificationException
* (05-02) or something worse.
*/
public List<Material> list() {
read.lock();
try {
return new ArrayList<>(materials); // copy under the lock
} finally {
read.unlock();
}
}
}When a ReadWriteLock pays off:
| Condition | Why it matters |
|---|---|
| Reads ≫ writes (≥ 5:1, ideally 20:1) | With few reads, the lock's higher cost is not amortised |
| The reads take some time | If they take nanoseconds, the overhead dominates |
| There is real contention | With no concurrent threads it gains nothing |
When it does NOT pay off: very short operations, a balanced read/write ratio, or little concurrency. In those cases a simple synchronized is usually faster, because ReentrantReadWriteLock keeps more internal state.
Downgrading and upgrading. You can downgrade —hold the write lock and acquire the read lock before releasing it— but not upgrade: trying to acquire the write lock while holding the read lock produces an instant deadlock with yourself.
// FORBIDDEN: upgrading. It hangs forever.
read.lock();
write.lock(); // waits for ALL the read locks to be released,
// including OURS. Deadlock.
// ALLOWED: downgrading (write -> read).
write.lock();
try {
modify();
read.lock(); // acquire read BEFORE releasing write
} finally {
write.unlock(); // now we only hold read
}
try {
readWhatWeJustWrote();
} finally {
read.unlock();
}A modern alternative:
StampedLock(Java 8). It adds optimistic reading: you read without locking anything and then validate whether there were writes in the meantime; if there were, you retry with a real read lock. It is faster under heavy read load, but it is not reentrant and its API is easy to misuse. Mention it, measure it if you have a demonstrated bottleneck, and do not use it by default.
- Safe publication and immutability
Publishing an object means making it visible to other threads. Doing it wrong produces a bewildering failure: another thread can see a half-constructed object.
// UNSAFE PUBLICATION
public class Registry {
public static Catalog instance; // no volatile, no final
public static void initialise() {
instance = new Catalog(); // (1) allocate (2) construct (3) assign
// Phases (2) and (3) can be REORDERED: another thread can see
// 'instance' non-null pointing at an unconstructed object.
}
}Safe ways to publish:
- Initialise it from a static initialiser (the JVM synchronises class loading).
- Store it in a
volatilefield or anAtomicReference(08-06). - Store it in a
finalfield of a correctly constructed object. - Store it in a field guarded by a lock, and read it with the same lock.
- Put it into a concurrent collection (08-06).
The guarantee for final fields deserves its own paragraph, because it is what makes immutability work:
If an object has only
finalfields and does not letthisescape during construction, any thread obtaining a reference to it will see its fields correctly initialised, with no synchronisation whatsoever.
An immutable object is one that:
- Has all its fields
final. - Exposes no method that changes its state.
- Does not let
thisescape in the constructor. - If it holds references to mutable objects, it makes defensive copies on the way in and on the way out.
An immutable object is safe for any number of threads, with no locks, forever. It is the most powerful strategy in all of concurrency.
And here is the good part: the records of 04-07 are immutable by construction.
package com.nexussoftware.bibliotech.domain;
/**
* Immutable card: all its fields are final because it is a record.
* Shareable between any number of threads with no synchronisation.
*/
public record Card(String isbn, String title, String author, int copies) { }
/**
* Immutable SessionSummary. Careful: if a record contains a collection,
* the collection must be immutable too, or the record is only
* "shallowly" immutable.
*/
public record SessionSummary(long startMs, long endMs,
int loans, int returns,
List<String> incidents) {
/** Compact constructor with a defensive copy: it makes the list immutable. */
public SessionSummary {
incidents = List.copyOf(incidents); // IMMUTABLE copy
}
}The compact-constructor trick is important: without List.copyOf, whoever built the record could keep modifying the original list, and the record would stop being genuinely immutable.
How you change the state of an immutable object: you do not. You create a new one and replace the reference atomically:
public class BiblioTechStatistics {
/** Immutable snapshot of the statistics. */
public record Snapshot(long loans, long returns, long fines) {
Snapshot withLoan() { return new Snapshot(loans + 1, returns, fines); }
Snapshot withReturn() { return new Snapshot(loans, returns + 1, fines); }
}
// The REFERENCE is volatile: readers always see a complete
// and consistent snapshot, never a half-finished one.
private volatile Snapshot current = new Snapshot(0, 0, 0);
/** A lock-free read: consistent through immutability. */
public Snapshot snapshot() {
return current;
}
/**
* The write DOES need synchronisation: 'current = current.withLoan()'
* is read-modify-write, and volatile does not make it atomic (section 6).
* In 08-06 this will be solved with AtomicReference.updateAndGet, lock-free.
*/
public synchronized void registerLoan() {
current = current.withLoan();
}
}This pattern —immutable state + volatile reference— gives completely lock-free reads that are always consistent. It is enormously valuable when reads far outnumber writes.
- Confinement: the best synchronisation is not sharing
All the previous techniques manage sharing. The best strategy is to eliminate it.
Stack confinement. A local variable lives on the thread's stack: it is private by construction (08-01). If you build an object inside a method, use it and do not let it escape, there is nothing to synchronise.
public Report processBatch(List<String> lines) {
// ALL local: each thread has its own list and its own counter.
// No locks, no possible race.
List<Material> accepted = new ArrayList<>();
int discarded = 0;
for (String l : lines) {
try {
accepted.add(reader.toMaterial(l));
} catch (InvalidFormatException e) {
discarded++;
}
}
// The only point of contact with shared state, and it is protected:
catalog.addAll(accepted);
return new Report(accepted.size(), discarded);
}Thread confinement with ThreadLocal. Each thread has its own copy of the variable:
import java.text.NumberFormat;
import java.util.Locale;
public class FineFormat {
// NumberFormat is NOT thread-safe: sharing one instance
// produces corrupt results under load (a classic that is very hard to
// diagnose because it fails rarely and silently).
// ThreadLocal gives one instance per thread: safe and lock-free.
private static final ThreadLocal<NumberFormat> FORMAT =
ThreadLocal.withInitial(() -> NumberFormat.getCurrencyInstance(
Locale.forLanguageTag("en-GB")));
public static String format(double amount) {
return FORMAT.get().format(amount);
}
/**
* IMPORTANT: in a thread pool (08-05) the threads are reused
* indefinitely, so a ThreadLocal is never released on its own.
* If it holds anything large or sensitive, it must be cleared:
*/
public static void clear() {
FORMAT.remove();
}
}A warning about
ThreadLocaland pools. It is a known source of memory leaks and of data contamination between requests: a pool thread that served Marta Ruiz and did not clear itsThreadLocalcan expose that data to Diego Alonso's request. Rule: if you useThreadLocalin a pool, clear it in afinally.
Confinement by design: split, process separately and combine at the end.
// Each thread processes ITS chunk and produces ITS partial result.
// There is no shared state during the computation, only when combining.
// It is the model of ForkJoinPool (08-05) and of parallel streams (10-04).
List<Report> partials = new ArrayList<>();
// ... each thread returns its Report, and at the end:
Report total = combine(partials); // a single thread, no locksThe hierarchy of strategies, from best to worst:
| Strategy | Synchronisation cost | Complexity | When |
|---|---|---|---|
| 1. Do not share (confinement) | None | Minimal | Whenever possible |
| 2. Share immutable | None | Low | Data that does not change |
3. Share with volatile |
Very low | Low | Flags, a single writer |
| 4. Share with atomics (08-06) | Low | Low | Counters, references |
| 5. Share with a concurrent collection (08-06) | Low-medium | Low | Maps, lists, queues |
| 6. Share with a lock | Medium | Medium | Invariants across several fields |
| 7. Share with nothing | — | — | Bug |
Always start at the top and move down only when necessary. Most of the concurrent code written in the industry is at level 6 when it could be at level 1 or 2.
- BiblioTech: a thread-safe catalogue
The complete application. LoanRegistry has two maps that must be kept consistent with each other, which forces a lock —an invariant spanning two structures cannot be maintained with atomics or with independent concurrent collections—.
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.Employee;
import com.nexussoftware.bibliotech.domain.Loan;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* Thread-safe loan registry.
*
* SYNCHRONISATION POLICY
* ----------------------
* All the mutable state is guarded by 'lock'.
* Queries use the read lock (shared);
* modifications, the write lock (exclusive).
*
* INVARIANT: 'byId' and 'byEmployee' contain exactly the same
* set of loans. This invariant spans TWO structures, and that
* is why two ConcurrentHashMaps (08-06) are not enough: the two
* updates would have to be a single atomic operation, and only
* a lock gives that.
*/
public class SafeLoanRegistry {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock read = lock.readLock();
private final ReentrantReadWriteLock.WriteLock write = lock.writeLock();
/** Guarded by 'lock'. */
private final Map<String, Loan> byId = new HashMap<>();
/** Guarded by 'lock'. Invariant: consistent with 'byId'. */
private final Map<Employee, List<Loan>> byEmployee = new HashMap<>();
// ---------- WRITING ----------
/**
* Registers a loan. Both updates happen under the same lock,
* so no reader can see the intermediate state in which the
* loan is in 'byId' but not yet in 'byEmployee'.
*/
public void register(Loan l) {
write.lock();
try {
if (byId.putIfAbsent(l.id(), l) != null) {
throw new IllegalStateException("Duplicate loan: " + l.id());
}
byEmployee.computeIfAbsent(l.employee(), e -> new ArrayList<>()).add(l);
} finally {
write.unlock();
}
}
public Loan returnItem(String loanId) {
write.lock();
try {
Loan l = byId.remove(loanId);
if (l == null) {
throw new LoanNotFoundException(loanId);
}
List<Loan> ofEmployee = byEmployee.get(l.employee());
if (ofEmployee != null) {
ofEmployee.remove(l);
if (ofEmployee.isEmpty()) {
byEmployee.remove(l.employee()); // do not leave empty lists
}
}
return l;
} finally {
write.unlock();
}
}
// ---------- READING ----------
public Loan find(String loanId) {
read.lock();
try {
return byId.get(loanId);
} finally {
read.unlock();
}
}
/** Returns a COPY: the internal list never leaves the lock. */
public List<Loan> loansOf(Employee e) {
read.lock();
try {
List<Loan> l = byEmployee.get(e);
return l == null ? List.of() : new ArrayList<>(l);
} finally {
read.unlock();
}
}
public int active() {
read.lock();
try {
return byId.size();
} finally {
read.unlock();
}
}
/**
* ATOMIC COMPOUND OPERATION.
*
* This method exists precisely because doing it from outside would be
* an unsafe check-then-act:
*
* if (registry.active() < MAX) registry.register(l); // BUG
*
* Between the check and the action, another thread can register and
* exceed the limit. Encapsulating the complete operation inside the
* lock is the only correct way.
*/
public boolean registerIfWithinAllowance(Loan l, int maxPerEmployee) {
write.lock();
try {
List<Loan> currentOnes = byEmployee.get(l.employee());
if (currentOnes != null && currentOnes.size() >= maxPerEmployee) {
return false;
}
byId.put(l.id(), l);
byEmployee.computeIfAbsent(l.employee(), e -> new ArrayList<>()).add(l);
return true;
} finally {
write.unlock();
}
}
}And the stress test that proves it works:
package com.nexussoftware.bibliotech.service;
import java.util.concurrent.TimeUnit;
public class RegistryConcurrencyTest {
public static void main(String[] args) throws InterruptedException {
final int THREADS = 8;
final int PER_THREAD = 5_000;
SafeLoanRegistry registry = new SafeLoanRegistry();
Employee marta = new Employee("EMP-001", "Marta Ruiz");
Thread[] threads = new Thread[THREADS];
long start = System.nanoTime();
for (int h = 0; h < THREADS; h++) {
final int threadId = h;
threads[h] = new Thread(() -> {
for (int i = 0; i < PER_THREAD; i++) {
registry.register(new Loan(
"LN-" + threadId + "-" + i, marta, "978-0000000001"));
}
}, "bibliotech-registrar-" + h);
threads[h].start();
}
for (Thread t : threads) t.join();
long ms = (System.nanoTime() - start) / 1_000_000;
int expected = THREADS * PER_THREAD;
int actual = registry.active();
int inList = registry.loansOf(marta).size();
System.out.println("Expected : " + expected);
System.out.println("In byId : " + actual);
System.out.println("In byEmployee : " + inList);
System.out.println("Invariant consistent: " + (actual == inList));
System.out.println("Time : " + ms + " ms");
System.out.println(actual == expected && actual == inList
? "CORRECT"
: "FAILURE: there is a data race");
}
}Output:
Expected : 40000
In byId : 40000
In byEmployee : 40000
Invariant consistent: true
Time : 187 ms
CORRECTRun this test ten times. It must say CORRECT all ten. Then remove the lock()/unlock() from register and run it again: you will see different counts on every run, the two maps out of step, and if you are lucky the odd ConcurrentModificationException or even a corrupt HashMap with an infinite loop in get() —a real phenomenon explained in 08-06—.
Common Mistakes and Tips
Mistake 1: believing volatile makes an increment atomic. The number-one conceptual mistake of the topic. volatile gives visibility, not atomicity. For counters: synchronized or AtomicInteger (08-06).
Mistake 2: synchronising the writers but not the readers. Mutual exclusion without visibility is useless: an unsynchronised reader can see a stale value indefinitely. Every access, reads included, must use the same mechanism.
Mistake 3: using different locks for the same data. A synchronized instance method and a synchronized static one use different monitors and do not exclude each other. If they touch the same state, there is a race.
Mistake 4: synchronising on a reassignable or unintentionally shared object. synchronized (this) exposes the lock; synchronized on a String literal, a small Integer or a Boolean uses an interned object another class can share without knowing. Use private final Object lock = new Object().
Mistake 5: unlock() outside the finally. If the body throws, the lock is never released and the application blocks. It is the main reason to prefer synchronized.
Mistake 6: lock() inside the try. If the acquisition fails, the finally tries to release a lock that is not held: an IllegalMonitorStateException that masks the real error.
Mistake 7: doing I/O inside a lock. It serialises the whole application. Prepare the data inside, write outside.
Mistake 8: calling foreign code holding the lock. Listeners, callbacks, overridable methods: they can block, call back or deadlock. Copy under the lock, notify outside.
Mistake 9: acquiring two locks in a different order depending on the path. The cause of 90% of deadlocks. Impose a global ordering and document it.
Mistake 10: upgrading from read to write on a ReadWriteLock. An instant deadlock with yourself. Downgrading is allowed; upgrading is not.
Mistake 11: returning the internal collection from a synchronized method. The lock protects the access, but whoever receives the reference can iterate it without the lock. Return a copy or an immutable view.
Mistake 12: using synchronized when not sharing would have been enough. Before putting a lock in, check whether the object can be local, immutable or confined to a thread.
Tip 1: document every shared field with /** Guarded by 'X'. */. One line that saves hours of archaeology.
Tip 2: encapsulate compound operations inside the class. registerIfWithinAllowance exists because if (active() < MAX) register(l) from outside is an unsafe check-then-act. If your API forces the client to compose two calls, your API has a bug.
Tip 3: test under real pressure. Eight threads, tens of thousands of operations, repeated ten times. A test with two threads and ten operations detects nothing.
Tip 4: prefer synchronized by default. Move to ReentrantLock only when you need tryLock, a deadline, interruptibility, fairness or several Conditions.
Tip 5: measure before tuning the granularity. A coarse lock is simpler and safer. Split it only when you have proved it is the bottleneck.
Tip 6: when in doubt, make it immutable. A record with final fields needs no synchronisation, cannot be corrupted and cannot deadlock.
Exercises
Exercise 1: From the broken counter to the correct counter
Write CounterComparison with four implementations of a counter supporting increment() and value():
UnsafeCounter: a plainint.VolatileCounter: avolatile int.SynchronizedCounter:synchronized.LockCounter:ReentrantLock.
Subject each one to 8 threads × 500,000 increments, check whether the result is correct and measure the time with System.nanoTime(). Print a table with implementation, result, error and increments per millisecond. Comment on why the first two fail and why the last two have similar times.
Exercise 2: Deadlock and its correction
Model two BiblioTech meeting rooms (MeetingRoom with its own lock). Write DoubleRoomBooking with:
- A method
bookWrong(MeetingRoom a, MeetingRoom b)that acquires the two locks in the order they are passed, with asleep(100)between them, and amainthat deadlocks it reproducibly with two threads booking in opposite directions, detecting it withjoin(3000)+isAlive(). - A method
bookRight(...)that applies the global acquisition order using the room identifier, and demonstrates that it no longer deadlocks even in 1,000 attempts with 8 threads. - A method
bookWithTryLock(...)usingReentrantLock.tryLock(50, MILLISECONDS), random backoff and a maximum number of attempts, reporting how many times it had to retry.
Exercise 3: Card cache with ReadWriteLock and measurement
Reimplement BiblioTech's CardCache as a thread-safe cache with ReentrantReadWriteLock, with Card get(String isbn) (which checks the cache and, on a miss, computes it with a simulated sleep(20) and stores it), void invalidate(String isbn) and int size(). Add hit and miss counters guarded by the same lock.
Then write a test with 10 threads each performing 2,000 lookups over a set of 50 ISBNs, measuring the total time and the hit percentage, and compare the result with a version that uses a single synchronized on all the methods. Explain the result obtained.
Solutions
Solution to Exercise 1
import java.util.concurrent.locks.ReentrantLock;
public class CounterComparison {
interface Counter {
void increment();
long value();
String name();
}
/** 1. UNSAFE: value++ is read-modify-write with no protection. */
static class UnsafeCounter implements Counter {
private long value = 0;
public void increment() { value++; }
public long value() { return value; }
public String name() { return "plain int"; }
}
/** 2. VOLATILE: solves visibility, NOT atomicity. */
static class VolatileCounter implements Counter {
private volatile long value = 0;
public void increment() { value++; }
public long value() { return value; }
public String name() { return "volatile"; }
}
/** 3. SYNCHRONIZED: mutual exclusion + visibility. Correct. */
static class SynchronizedCounter implements Counter {
private long value = 0;
public synchronized void increment() { value++; }
// The getter is ALSO synchronized: without it, a reader could
// see a stale value from its cache.
public synchronized long value() { return value; }
public String name() { return "synchronized"; }
}
/** 4. REENTRANTLOCK: equivalent, with unlock in finally. */
static class LockCounter implements Counter {
private final ReentrantLock lock = new ReentrantLock();
private long value = 0;
public void increment() {
lock.lock(); // OUTSIDE the try
try { value++; }
finally { lock.unlock(); } // ALWAYS in finally
}
public long value() {
lock.lock();
try { return value; }
finally { lock.unlock(); }
}
public String name() { return "ReentrantLock"; }
}
static long measure(Counter c, int threads, int perThread) throws InterruptedException {
Thread[] ts = new Thread[threads];
long start = System.nanoTime();
for (int i = 0; i < threads; i++) {
ts[i] = new Thread(() -> {
for (int j = 0; j < perThread; j++) c.increment();
}, "adder-" + i);
ts[i].start();
}
for (Thread t : ts) t.join();
return System.nanoTime() - start;
}
public static void main(String[] args) throws InterruptedException {
final int THREADS = 8;
final int PER_THREAD = 500_000;
final long EXPECTED = (long) THREADS * PER_THREAD;
Counter[] counters = {
new UnsafeCounter(), new VolatileCounter(),
new SynchronizedCounter(), new LockCounter()
};
// Warm-up: without it, the first implementations pay for
// the JIT compilation and the comparison is not fair.
for (Counter c : counters) measure(c, 2, 10_000);
System.out.printf("%-16s | %12s | %10s | %8s | %12s%n",
"Implementation", "Result", "Lost", "ms", "inc/ms");
System.out.println("-----------------|--------------|------------|----------|-------------");
for (Counter template : counters) {
// A fresh instance so as not to carry the warm-up over.
Counter c = switch (template.name()) {
case "plain int" -> new UnsafeCounter();
case "volatile" -> new VolatileCounter();
case "synchronized" -> new SynchronizedCounter();
default -> new LockCounter();
};
long ns = measure(c, THREADS, PER_THREAD);
long ms = ns / 1_000_000;
long lost = EXPECTED - c.value();
System.out.printf("%-16s | %12d | %10d | %8d | %12d%s%n",
c.name(), c.value(), lost, ms,
ms == 0 ? 0 : c.value() / ms,
lost == 0 ? "" : " <-- INCORRECT");
}
}
}Indicative output:
Implementation | Result | Lost | ms | inc/ms
-----------------|--------------|------------|----------|-------------
plain int | 1284471 | 2715529 | 31 | 41434 <-- INCORRECT
volatile | 1893204 | 2106796 | 142 | 13332 <-- INCORRECT
synchronized | 4000000 | 0 | 213 | 18779
ReentrantLock | 4000000 | 0 | 205 | 19512Analysis:
plain intis the fastest and the most incorrect. It is fast precisely because it synchronises nothing: each core works on its own cache. Speed without correctness is worth nothing.volatileis slower and still fails. The worst of both worlds: it pays for memory barriers on every access and gains no atomicity. It is the practical demonstration of section 6.synchronizedandReentrantLockgive the exact result and have almost identical times. Since Java 6,synchronizedis as optimised asReentrantLock; the choice is about expressiveness, not performance.- The four million is exact, every time. Repeat the program:
synchronizedandReentrantLocknever fail. Correctness is not probabilistic.
Solution to Exercise 2
package com.nexussoftware.bibliotech.service;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
public class DoubleRoomBooking {
/** Room with an intrinsic lock and an explicit lock, for the three versions. */
static class MeetingRoom {
final String id;
final Object monitor = new Object();
final ReentrantLock lock = new ReentrantLock();
int bookings = 0;
MeetingRoom(String id) { this.id = id; }
}
// ---------- 1. DEADLOCKING VERSION ----------
static void bookWrong(MeetingRoom a, MeetingRoom b) {
synchronized (a.monitor) {
pause(100); // widens the failure window
synchronized (b.monitor) {
a.bookings++;
b.bookings++;
}
}
}
// ---------- 2. GLOBAL ACQUISITION ORDER ----------
/**
* The locks are ALWAYS taken in increasing order of 'id'.
* With no circular wait there can be no deadlock: it is a
* structural guarantee, not a probability.
*/
static void bookRight(MeetingRoom a, MeetingRoom b) {
MeetingRoom first = a.id.compareTo(b.id) <= 0 ? a : b;
MeetingRoom second = (first == a) ? b : a;
synchronized (first.monitor) {
pause(1);
synchronized (second.monitor) {
a.bookings++;
b.bookings++;
}
}
}
// ---------- 3. TRYLOCK WITH RANDOM BACKOFF ----------
static int bookWithTryLock(MeetingRoom a, MeetingRoom b, int maxAttempts)
throws InterruptedException {
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
boolean haveA = false, haveB = false;
try {
haveA = a.lock.tryLock(50, TimeUnit.MILLISECONDS);
if (haveA) {
haveB = b.lock.tryLock(50, TimeUnit.MILLISECONDS);
}
if (haveA && haveB) {
a.bookings++;
b.bookings++;
return attempt; // success: return the attempt
}
} finally {
if (haveB) b.lock.unlock();
if (haveA) a.lock.unlock();
}
// RANDOM backoff: without it, two threads in phase would retry
// together forever (livelock, section 12).
TimeUnit.MILLISECONDS.sleep(ThreadLocalRandom.current().nextInt(5, 40));
}
return -1; // not achieved
}
// ---------- DEMONSTRATION ----------
public static void main(String[] args) throws InterruptedException {
// --- 1. Provoke the deadlock ---
System.out.println("=== 1. DEADLOCKING VERSION ===");
MeetingRoom r1 = new MeetingRoom("ROOM-A");
MeetingRoom r2 = new MeetingRoom("ROOM-B");
Thread t1 = new Thread(() -> bookWrong(r1, r2), "thread-A-B");
Thread t2 = new Thread(() -> bookWrong(r2, r1), "thread-B-A");
t1.start(); t2.start();
t1.join(3000); t2.join(3000);
if (t1.isAlive() || t2.isAlive()) {
System.out.println(" DEADLOCKED. t1=" + t1.getState()
+ " t2=" + t2.getState());
System.out.println(" (jstack would say: Found one Java-level deadlock)");
} else {
System.out.println(" It did not happen this time; try again. "
+ "The intermittency is exactly the problem.");
}
// --- 2. Global ordering: 1,000 attempts with 8 threads ---
System.out.println();
System.out.println("=== 2. GLOBAL ACQUISITION ORDER ===");
MeetingRoom a = new MeetingRoom("ROOM-A");
MeetingRoom b = new MeetingRoom("ROOM-B");
Thread[] threads = new Thread[8];
for (int i = 0; i < 8; i++) {
final boolean forward = (i % 2 == 0);
threads[i] = new Thread(() -> {
for (int k = 0; k < 125; k++) {
if (forward) bookRight(a, b);
else bookRight(b, a); // opposite direction
}
}, "booker-" + i);
threads[i].start();
}
for (Thread t : threads) t.join(20_000);
boolean anyoneAlive = false;
for (Thread t : threads) anyoneAlive |= t.isAlive();
System.out.println(" Bookings ROOM-A: " + a.bookings);
System.out.println(" Bookings ROOM-B: " + b.bookings);
System.out.println(" Any thread blocked: " + anyoneAlive);
System.out.println(anyoneAlive ? " FAILURE" : " CORRECT: 1,000 bookings with no deadlock");
// --- 3. tryLock with backoff ---
System.out.println();
System.out.println("=== 3. TRYLOCK WITH RANDOM BACKOFF ===");
MeetingRoom c = new MeetingRoom("ROOM-C");
MeetingRoom d = new MeetingRoom("ROOM-D");
AtomicInteger totalRetries = new AtomicInteger();
AtomicInteger failures = new AtomicInteger();
Thread[] ts = new Thread[4];
for (int i = 0; i < 4; i++) {
final boolean forward = (i % 2 == 0);
ts[i] = new Thread(() -> {
for (int k = 0; k < 50; k++) {
try {
int attempts = forward
? bookWithTryLock(c, d, 10)
: bookWithTryLock(d, c, 10);
if (attempts < 0) failures.incrementAndGet();
else totalRetries.addAndGet(attempts - 1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}, "trylock-" + i);
ts[i].start();
}
for (Thread t : ts) t.join();
System.out.println(" Bookings ROOM-C : " + c.bookings);
System.out.println(" Bookings ROOM-D : " + d.bookings);
System.out.println(" Total retries : " + totalRetries.get());
System.out.println(" Failures : " + failures.get());
System.out.println(" (the retries are the price of not imposing an order)");
}
static void pause(long ms) {
try { TimeUnit.MILLISECONDS.sleep(ms); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}Indicative output:
=== 1. DEADLOCKING VERSION ===
DEADLOCKED. t1=BLOCKED t2=BLOCKED
(jstack would say: Found one Java-level deadlock)
=== 2. GLOBAL ACQUISITION ORDER ===
Bookings ROOM-A: 1000
Bookings ROOM-B: 1000
Any thread blocked: false
CORRECT: 1,000 bookings with no deadlock
=== 3. TRYLOCK WITH RANDOM BACKOFF ===
Bookings ROOM-C : 200
Bookings ROOM-D : 200
Total retries : 37
Failures : 0The comparison between 2 and 3 is the moral: the global ordering needed not a single retry, not a single wait, not one extra line of code at run time. The tryLock worked but paid 37 retries and a great deal of complexity. When you can order the locks, order them.
Solution to Exercise 3
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.Card;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* Thread-safe card cache, optimised for reading.
*
* POLICY: all the state is guarded by 'lock'.
* Lookups that hit use the READ lock (shared);
* only misses escalate to the WRITE lock (exclusive).
*/
public class SafeCardCache {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock read = lock.readLock();
private final ReentrantReadWriteLock.WriteLock write = lock.writeLock();
/** Guarded by 'lock'. */
private final Map<String, Card> cache = new HashMap<>();
/** Guarded by 'lock'. */
private long hits = 0;
private long misses = 0;
public Card get(String isbn) {
// PHASE 1: optimistic attempt with the SHARED lock.
// Several threads can be here at once, which is 96% of the traffic.
read.lock();
try {
Card c = cache.get(isbn);
if (c != null) {
// CAREFUL: 'hits++' under the READ lock would be a
// race: several simultaneous readers would do
// read-modify-write at once. We count it in phase 2.
return c;
}
} finally {
read.unlock();
}
// PHASE 2: a miss. We compute OUTSIDE any lock (rule 2 of
// section 9: never slow operations inside a lock).
Card computed = compute(isbn);
// PHASE 3: publication with the EXCLUSIVE lock.
write.lock();
try {
// We re-check: between phase 1 and phase 3 another thread may
// have computed the same card. putIfAbsent keeps the first one
// and preserves the consistency of the counts.
Card alreadyThere = cache.putIfAbsent(isbn, computed);
if (alreadyThere != null) {
hits++; // another thread beat us to it
return alreadyThere;
}
misses++;
return computed;
} finally {
write.unlock();
}
}
public void invalidate(String isbn) {
write.lock();
try { cache.remove(isbn); }
finally { write.unlock(); }
}
public int size() {
read.lock();
try { return cache.size(); }
finally { read.unlock(); }
}
public String statistics() {
read.lock();
try {
long total = hits + misses;
return String.format("hits=%d misses=%d rate=%.1f%%",
hits, misses, total == 0 ? 0.0 : 100.0 * hits / total);
} finally {
read.unlock();
}
}
/** Simulates the real cost of building a card (query + formatting). */
private Card compute(String isbn) {
try { TimeUnit.MILLISECONDS.sleep(20); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return new Card(isbn, "Title of " + isbn, "Author", 3);
}
// ---------- CONTRAST VERSION: a single exclusive lock ----------
public static class SynchronizedCardCache {
private final Map<String, Card> cache = new HashMap<>();
// The WHOLE method synchronized, INCLUDING the 20 ms computation.
// It is the mistake of rule 2 of section 9, on purpose.
public synchronized Card get(String isbn) {
Card c = cache.get(isbn);
if (c == null) {
try { TimeUnit.MILLISECONDS.sleep(20); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
c = new Card(isbn, "Title of " + isbn, "Author", 3);
cache.put(isbn, c);
}
return c;
}
public synchronized int size() { return cache.size(); }
}
// ---------- TEST ----------
public static void main(String[] args) throws InterruptedException {
final int THREADS = 10;
final int LOOKUPS = 2_000;
final int ISBNS = 50;
// --- ReadWriteLock version ---
SafeCardCache rw = new SafeCardCache();
long msRw = run(THREADS, LOOKUPS, ISBNS, isbn -> rw.get(isbn));
System.out.println("=== ReadWriteLock ===");
System.out.println(" Time : " + msRw + " ms");
System.out.println(" Entries : " + rw.size());
System.out.println(" " + rw.statistics());
// --- Fully synchronized version ---
SynchronizedCardCache sy = new SynchronizedCardCache();
long msSy = run(THREADS, LOOKUPS, ISBNS, isbn -> sy.get(isbn));
System.out.println();
System.out.println("=== fully synchronized ===");
System.out.println(" Time : " + msSy + " ms");
System.out.println(" Entries : " + sy.size());
System.out.println();
System.out.printf("Ratio: %.1fx in favour of the ReadWriteLock%n",
(double) msSy / msRw);
}
interface Lookup { Card get(String isbn); }
static long run(int threads, int lookups, int isbns, Lookup l)
throws InterruptedException {
Thread[] ts = new Thread[threads];
long start = System.nanoTime();
for (int i = 0; i < threads; i++) {
ts[i] = new Thread(() -> {
for (int k = 0; k < lookups; k++) {
l.get("978-" + String.format("%010d", k % isbns));
}
}, "lookup-" + i);
ts[i].start();
}
for (Thread t : ts) t.join();
return (System.nanoTime() - start) / 1_000_000;
}
}Indicative output:
=== ReadWriteLock ===
Time : 152 ms
Entries : 50
hits=19 misses=50 rate=27.5%
=== fully synchronized ===
Time : 1043 ms
Entries : 50
Ratio: 6.9x in favour of the ReadWriteLockAnalysis of the result:
- The 7x difference does not come from the
ReadWriteLockitself, but from moving the computation outside the lock. In the synchronized version, the 20 ms of computation happen while the exclusive lock is held, so the 50 initial misses are serialised: 50 × 20 ms = 1 second minimum, with nine threads sitting idle. It is rule 2 of section 9 costing a factor of seven. - The
ReadWriteLocksupplies the second half: once the cache is full, the remaining 19,950 lookups are hits served in parallel under the shared lock. With an exclusive lock they would be serialised too, even though each one takes microseconds. - The 19 "hits" counted in phase 3 are the benign race: two threads missed on the same ISBN at once and both computed it;
putIfAbsentkeeps only one. Duplicate work is done but the result is correct. Avoiding it entirely would require locking during the computation —exactly what we want to avoid— or aConcurrentHashMap.computeIfAbsent, which solves it elegantly and is 08-06.
Conclusion
This was the lesson that holds up the module, and with it you close the problem you opened in 08-01.
You know why the counter fails. counter++ is not one operation: it is three bytecode instructions —getfield, iadd, putfield— and any interleaving among them loses work. You recognise the two canonical patterns —read-modify-write and check-then-act— and you know that seeing either of them on shared state is seeing a bug. You know which operations are atomic in Java and which are not, including the case of long and double, whose write the specification allows to be split into two halves.
You know the Java memory model, which is what separates understanding concurrency from applying it out of superstition. You know memory is not a shared notebook: there are per-core caches, the compiler reorders under a guarantee that only holds within a thread, and the CPU reorders too. Hence the consequence hardest to accept and that you saw running: one thread can write stop = true and another never notice, because the JIT hoisted the read out of the loop. And you know the real guarantee is expressed through the happens-before relationship, with its rules —program order, monitor, volatile, start, join, final fields, transitivity—, and with the question to ask about every shared piece of data: "which happens-before rule connects these two actions?". If there is no answer, there is a data race.
You know exactly what volatile does and does not do. It guarantees visibility and no reordering, and it makes 64-bit reads and writes atomic. It does not make an increment atomic. The two examples prove it beyond argument: the stop flag that without volatile hangs the program forever, and the counter that with volatile still loses 40% of the increments —slower and just as incorrect—.
You have mastered synchronized. The intrinsic monitor every object carries inside, the automatic release even in the face of exceptions, the reentrancy that makes inheritance possible, and the two guarantees it gives at once: mutual exclusion and visibility, the latter as important as the former and systematically forgotten —which is why the getter is synchronized too—. You know the three forms and why the one with a private, final lock block beats the other two: granularity and not exposing the lock to the world. And you know the traps: the instance method and the static one use different monitors, and a String literal or a small Integer as a lock is an object shared with the whole JVM.
You have the five rules for what to synchronise: short critical section, never I/O inside the lock, never foreign code holding the lock —copy inside, notify outside—, do not synchronise what is not shared, and document the policy with a /** Guarded by 'lock'. */ that costs one line and saves hours.
You know how to provoke and solve a deadlock. The four Coffman conditions, of which only the circular wait is in your hands; the reproducible example of two transfers in opposite directions, with both threads BLOCKED forever, no exception and no CPU usage; and the three solutions in order of preference: global acquisition order —absolute guarantee, zero cost, the right answer nearly always—, tryLock with a deadline and random backoff when you cannot order them, and a single coarser lock when contention allows. With the two neighbouring pathologies: starvation, fought with short sections and, if genuinely needed, an expensive fair lock; and livelock, told apart from deadlock because it burns 100% CPU and jstack does not declare it.
You know the rest of the toolbox: ReentrantLock with its mandatory lock(); try { } finally { unlock(); }, and the five things it brings —tryLock, deadline, lockInterruptibly, fairness and several conditions—, with the recommendation to use synchronized by default and move to Lock only when you need one of them. Condition as a replacement for wait/notify, with the decisive advantage of having several wait queues per lock, which makes signal() safe and eliminates the mass wake-up of notifyAll. And ReadWriteLock, with its compatibility table, the at-least-5:1 ratio that justifies it, and the rule that gets forgotten: downgrading is allowed, upgrading is an instant deadlock with yourself.
And, above all, you have the two strategies that beat all the previous ones. Immutability: an object with all its fields final that does not let this escape is safe for any number of threads, with no locks, forever —and the records of 04-07 are so by construction, with the compact-constructor detail that copies the collections—. And confinement: the best synchronisation is not sharing, whether on the stack, with ThreadLocal —with its warning about pools and leaks— or by splitting the work and combining at the end. With the hierarchy you should always walk from top to bottom: do not share, share immutable, volatile, atomic, concurrent collection, lock.
BiblioTech now supports two employees at once. SafeCatalog protects its three structures with a ReadWriteLock that lets all the readers through simultaneously and serialises only the insertions, and returns copies instead of its internal collections. SafeLoanRegistry maintains the invariant between byId and byEmployee under a single lock —because an invariant spanning two structures cannot be held with independent pieces—, and exposes registerIfWithinAllowance as an atomic compound operation, because forcing the client to write if (active() < MAX) register(l) would be handing them a race. Eight threads and forty thousand operations give the exact result, ten times out of ten. Case C of 08-01 is closed.
But look at how you got here: hand-written locks, finally blocks that must not be forgotten, acquisition orders that have to be documented and respected, and —the most expensive thing of all— a thread created by hand for every task. BiblioTech's 200 notices still need 200 threads, each with its megabyte of stack. Writing correct concurrency at this level is possible, but it is artisanal and fragile, and the whole industry has spent twenty years not doing it this way.
In the next lesson, Concurrency Utilities, you finally move up a level. You will see ExecutorService and the Executors factories —fixed, elastic, single-thread and scheduled pools—, with the table of when to use each and the warning about unbounded queues that take down applications; how to build a bespoke ThreadPoolExecutor with its ThreadFactory for naming threads and its rejection policy; the correct lifecycle of an executor with shutdown, shutdownNow and awaitTermination; Callable and Future, which at last let a task return a result and propagate its exception, with a blocking get() and one with a deadline, and cancel(true) which is nothing other than the interruption of 08-02; ScheduledExecutorService for the periodic due-date notices; and the synchronisers —CountDownLatch, CyclicBarrier, Semaphore— that replace hand-rolled coordination with tested parts. By the end, BiblioTech's two hundred notices will be sent with a bounded pool, with a progress bar and with clean cancellation, in a few seconds and with eight threads instead of two hundred.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
