synchronized, and what a lock actually guarantees
The previous lesson broke a counter on purpose. This one fixes it, and the
interesting part is not the fix — it is understanding exactly what the fix
promises, because synchronized guarantees two separate things and most people
only know about one.
The problem, restated
class Counter {
private int value = 0;
void increment() { value++; }
int value() { return value; }
}
value++ is three operations: read, add one, write back. Two threads can read
the same number, both add one, and both write the same result. One increment
vanishes.
The fix
class Counter {
private int value = 0;
synchronized void increment() { value++; }
synchronized int value() { return value; }
}
Note that the getter is synchronized too. That surprises people — reading cannot corrupt anything. It is there for the second guarantee, below, and leaving it off is the commonest way to half-fix a class.
The two guarantees
Mutual exclusion. One thread at a time inside any synchronized block on
the same lock. That is the one everybody knows.
Visibility. When a thread leaves a synchronized block, everything it wrote
becomes visible to the next thread that enters on the same lock. Without that,
a thread can hold a stale copy of a field indefinitely — the subject of the next
lesson.
Mutual exclusion without visibility would be useless, which is why the language gives you both together. And it is why the unsynchronized getter is a bug: it is mutually exclusive with nothing, so it has no visibility guarantee either.
What the lock actually is
synchronized on a method locks an object, not the method.
synchronized void increment() { … }
// is exactly
void increment() {
synchronized (this) { … }
}
Two consequences that catch people:
All synchronized methods on one object share one lock. If increment and
resetEverything are both synchronized, a thread in either blocks a thread
wanting the other — even though they may touch different fields.
A static synchronized method locks the class, not an instance:
static synchronized void register() { … } // locks Counter.class
So a static and an instance synchronized method do not exclude each other. They are different locks.
Lock the smallest thing that works
// Locks for the whole method, including the slow bit
synchronized void record(Delivery delivery) {
String line = format(delivery); // cheap
Files.writeString(path, line, APPEND); // slow, and touches no shared state
count++; // the only shared thing
}
void record(Delivery delivery) {
String line = format(delivery);
Files.writeString(path, line, APPEND);
synchronized (this) {
count++;
}
}
The second holds the lock for a few nanoseconds instead of a disk write. Under load that is the difference between a queue and a stall.
The rule is: hold a lock for as little as possible, and never across I/O or a network call. A lock held while waiting for a database is how one slow query becomes an outage.
Use a private lock object
public class Counter {
private final Object lock = new Object();
private int value;
public void increment() {
synchronized (lock) { value++; }
}
}
synchronized (this) locks an object other code can also see. Anybody holding
a reference to your Counter can write synchronized (counter) { … } and block
your internals from outside, and you will never find out why.
A private final lock cannot be acquired by anybody else. It is one line and it removes a whole class of problem.
Never lock on a String or a boxed Integer. Those are interned or cached,
so two unrelated pieces of code can end up on the same lock by accident.
synchronized is reentrant
synchronized void a() { b(); }
synchronized void b() { … }
A thread already holding the lock can take it again. Without that, a() calling
b() would deadlock against itself — which is what happens in languages whose
locks are not reentrant, and it is worth knowing Java chose otherwise.
When synchronized is not enough: ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
void record(Delivery delivery) {
lock.lock();
try {
count++;
} finally {
// In a finally block, always. An exception between lock and unlock
// without this leaves the lock held forever, and every other thread
// waits for it until the process is killed.
lock.unlock();
}
}
More typing, and it buys three things synchronized cannot do:
if (lock.tryLock(200, TimeUnit.MILLISECONDS)) { … } // give up rather than wait
lock.lockInterruptibly(); // be cancellable
new ReentrantReadWriteLock(); // many readers, one writer
tryLock is the one you will actually want first: a thread that waits forever
is indistinguishable from a hung program, and being able to give up and report a
timeout turns a mystery into a log line.
Default to synchronized. It is shorter, it cannot leak a held lock, and
the JVM optimises it well. Reach for ReentrantLock when you need timeouts,
interruptibility, or a read-write split — not by default.
Prefer not locking at all
The honest hierarchy, best first:
- No shared mutable state. Give each thread its own data. Nothing to synchronize.
- Immutable objects. A
recordwith final fields can be shared freely by any number of threads, forever, with no lock. - A concurrent collection or an atomic.
ConcurrentHashMap,AtomicLong— two lessons from now. synchronized.ReentrantLock.
Most concurrency bugs are written at levels 4 and 5 by people who could have
been at 1 or 2. The capstone's Delivery and Subscriber are records with
final fields — they are thread-safe by construction, and that is not an accident.
Check your work
The two guarantees: mutual exclusion, and visibility of everything written before the lock was released.
Why the getter is synchronized: without it there is no visibility guarantee, so a reader can see a stale value.
What synchronized locks: an object — this for an instance method, the
Class for a static one. So static and instance methods do not exclude each
other.
Why all synchronized methods on one object contend: they share one lock, even when they touch different fields.
Why to hold a lock briefly: a lock held across I/O turns one slow call into a stall.
Why a private lock object: synchronized (this) can be locked from outside
by anybody holding a reference.
Why never lock a String or boxed Integer: interning means unrelated code
can collide on the same lock.
Why unlock goes in finally: an exception in between otherwise leaves the
lock held forever.
What ReentrantLock adds: tryLock with a timeout, interruptibility, and
read-write locks.
The hierarchy: no shared state, then immutability, then concurrent
collections, then synchronized, then explicit locks.
Practice
- Take the broken counter from the previous lesson, add
synchronized, and run the same test. Confirm the total is now exact. - Synchronize
incrementbut notvalue(). Run it and decide whether you could prove the difference from the output alone. - Write a class with two synchronized methods touching different fields. Have two threads hammer one each and time it. Then give each its own lock object and time it again.
- Move a
Thread.sleep(50)inside a synchronized block and watch throughput. - Replace
synchronized (this)with a private lock object. From outside, try to block the object and confirm you no longer can. - Write a method that locks a
Stringconstant, call it from two unrelated classes with the same literal, and observe them contend. - Call one synchronized method from another on the same object and confirm it does not deadlock. Explain why.
- Use
ReentrantLockand deliberately omit thefinally. Throw inside, then try to acquire the lock again. - Use
tryLockwith a 100ms timeout against a thread holding the lock for a second. Log the failure. - Find shared mutable state in your own code and work out how to remove it rather than lock it.
Next: the change one thread never sees.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship