Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

Synchronization

When several threads change the same data, the result can come out wrong. This is called a race condition: the outcome depends on which thread gets there first. Synchronization is a set of ways to keep threads from getting in each other's way.

A race condition

Two threads each increase a shared counter 10,000 times. We expect 20,000:

int count = 0;
 
void increment() {
    int current = count;  // read
    Thread.yield();       // let another thread take the processor
    count = current + 1;  // add one and write
}
 
void main() throws InterruptedException {
    Runnable task = () -> {
        for (int i = 0; i < 10_000; i++) {
            increment();
        }
    };
 
    Thread a = new Thread(task);
    Thread b = new Thread(task);
    a.start();
    b.start();
    a.join();
    b.join();
 
    IO.println(count);
}

Output (your number will differ and changes from run to run):

10474

Even the short count++ is really the same steps: read the value, add one, write it back. If both threads read the same number, each writes "its own +1", and one increment is lost. Here the steps are written out, and Thread.yield() hints to the system to switch to another thread — so the loss shows up on almost every run. With a plain count++ the same thing happens, just less often, which is why this bug is hard to catch.

synchronized

The synchronized keyword on a method lets only one thread run it at a time. The others wait until the first one leaves the method:

int count = 0;
 
synchronized void increment() {
    int current = count;
    Thread.yield();
    count = current + 1;
}
 
void main() throws InterruptedException {
    Runnable task = () -> {
        for (int i = 0; i < 10_000; i++) {
            increment();
        }
    };
 
    Thread a = new Thread(task);
    Thread b = new Thread(task);
    a.start();
    b.start();
    a.join();
    b.join();
 
    IO.println(count);
}

Output:

20000

Every object in Java has a monitor — a built-in lock. A synchronized method takes the lock of its object (this), runs and releases it.

A synchronized block

Sometimes you need to protect a few lines rather than a whole method. Then you write a synchronized block and say whose lock to take. It's important that all threads use the same object:

final Object lock = new Object();
List<String> log = new ArrayList<>();
 
void main() throws InterruptedException {
    List<Thread> threads = new ArrayList<>();
    for (String name : List.of("Anna", "Oleg", "Ira")) {
        threads.add(Thread.ofPlatform().start(() -> {
            synchronized (lock) {
                log.add(name);
            }
        }));
    }
    for (Thread t : threads) {
        t.join();
    }
 
    IO.println(log.size() + " entries");
}

Output:

3 entries

ArrayList isn't built for writes from several threads at once, but under a shared lock the threads add elements one at a time. The order of names in the list can be anything, so we print only the size.

Atomic classes

For simple counters the atomic classes from java.util.concurrent.atomic are handier. Their operations run as a whole, with no intermediate states, and without explicit locks:

void main() throws InterruptedException {
    AtomicInteger count = new AtomicInteger();
 
    Runnable task = () -> {
        for (int i = 0; i < 10_000; i++) {
            count.incrementAndGet();
        }
    };
 
    Thread a = new Thread(task);
    Thread b = new Thread(task);
    a.start();
    b.start();
    a.join();
    b.join();
 
    IO.println(count.get());
}

Output:

20000

Besides incrementAndGet there are addAndGet, compareAndSet and others. For long numbers there is AtomicLong.

ReentrantLock

ReentrantLock is a lock as a separate object. It can do more than synchronized: for example, tryLock() tries to take the lock without waiting for it. The lock must be released in finally, otherwise after an exception it stays taken forever:

final ReentrantLock lock = new ReentrantLock();
int balance = 100;
 
void withdraw(int amount) {
    lock.lock();
    try {
        if (balance >= amount) {
            balance -= amount;
        }
    } finally {
        lock.unlock();
    }
}
 
void main() throws InterruptedException {
    Thread a = new Thread(() -> withdraw(70));
    Thread b = new Thread(() -> withdraw(70));
    a.start();
    b.start();
    a.join();
    b.join();
 
    IO.println("Balance: $" + balance);
}

Output:

Balance: $30

Without the lock both threads could see a balance of 100 at the same time and both withdraw money. With the lock the check and the withdrawal happen together, and the second withdrawal fails.

Common mistake

A lock protects data only if all threads take the same lock. Here each thread creates its own object, and synchronization achieves nothing:

int count = 0;
 
void main() throws InterruptedException {
    Runnable task = () -> {
        Object lock = new Object();
        for (int i = 0; i < 10_000; i++) {
            synchronized (lock) {
                int current = count;
                Thread.yield();
                count = current + 1;
            }
        }
    };
 
    Thread a = new Thread(task);
    Thread b = new Thread(task);
    a.start();
    b.start();
    a.join();
    b.join();
 
    IO.println(count);
}

Output (the number changes from run to run):

13973

Declare the lock object as a field shared by all threads — like lock in the examples above.

Thanks for reading!

Donate to the site — we work hard for you.