RizTech Academy logo
RizTech Academy
ConcurrencyLesson 3 of 835 min

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:

  1. No shared mutable state. Give each thread its own data. Nothing to synchronize.
  2. Immutable objects. A record with final fields can be shared freely by any number of threads, forever, with no lock.
  3. A concurrent collection or an atomic. ConcurrentHashMap, AtomicLong — two lessons from now.
  4. synchronized.
  5. 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

  1. Take the broken counter from the previous lesson, add synchronized, and run the same test. Confirm the total is now exact.
  2. Synchronize increment but not value(). Run it and decide whether you could prove the difference from the output alone.
  3. 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.
  4. Move a Thread.sleep(50) inside a synchronized block and watch throughput.
  5. Replace synchronized (this) with a private lock object. From outside, try to block the object and confirm you no longer can.
  6. Write a method that locks a String constant, call it from two unrelated classes with the same literal, and observe them contend.
  7. Call one synchronized method from another on the same object and confirm it does not deadlock. Explain why.
  8. Use ReentrantLock and deliberately omit the finally. Throw inside, then try to acquire the lock again.
  9. Use tryLock with a 100ms timeout against a thread holding the lock for a second. Log the failure.
  10. 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