Blog

Java Threads and Shared State: Races, synchronized and Atomics

Two Java threads that update the same variable can silently lose writes. See why count++ is a race, how synchronized, volatile, atomics and locks fix it, and how to force and detect a deadlock.

A thread runs code alongside the rest of your program. Threads that only touch their own data are easy. The trouble starts when two of them change the same variable: nothing crashes, nothing warns you, and some of the changes vanish.

This post starts threads, shows that bug, and forces it to happen on every run. Then it covers synchronized, volatile, atomics, deadlock, ReentrantLock, thread-safe collections and immutable data. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java.

Starting a thread: start, not run

A Thread object does nothing until you call start(), which asks the JVM for a new thread and runs your code on it. Calling run() looks similar, but it just calls the method on the thread you’re already on:

void main() throws InterruptedException {
    Thread mainThread = Thread.currentThread();
    boolean[] onMain = new boolean[2];

    Thread first = new Thread(() -> onMain[0] = Thread.currentThread() == mainThread);
    first.run();

    Thread second = new Thread(() -> onMain[1] = Thread.currentThread() == mainThread);
    second.start();
    second.join();

    IO.println("run()   ran the task on the main thread: " + onMain[0]);
    IO.println("start() ran the task on the main thread: " + onMain[1]);
    IO.println("state of the thread we called run() on: " + first.getState());
}

It prints:

run()   ran the task on the main thread: true
start() ran the task on the main thread: false
state of the thread we called run() on: NEW

first.run() ran the lambda on the main thread, and first never became a thread at all. Its state is still NEW. Calling run() by mistake compiles, works, and quietly gives you no concurrency.

second.join() makes the main thread wait until second has finished. Without it, main could read onMain[1] before the other thread had written it.

A daemon thread is one the JVM doesn’t wait for: when only daemon threads are left, the program exits. The deadlock example below uses that.

Most code doesn’t create threads by hand. The part on java.util.concurrent covers executors, and the part on virtual threads covers the cheap threads Java 21 added. Everything in this post about shared state applies to both.

Threads with their own data need no locks

The builder Thread.ofPlatform().start(...), final since Java 21, creates and starts a thread in one call. Here, four threads each write to their own array slot:

void main() throws InterruptedException {
    String[] words = {"apple", "banana", "cherry", "date"};
    int[] lengths = new int[words.length];

    var threads = new ArrayList<Thread>();
    for (int i = 0; i < words.length; i++) {
        int slot = i;
        threads.add(Thread.ofPlatform().start(() -> lengths[slot] = words[slot].length()));
    }
    for (Thread t : threads) {
        t.join();
    }

    IO.println(Arrays.toString(lengths));
}

It prints:

[5, 6, 6, 4]

No two threads write the same slot, so there’s nothing to coordinate, and joining every thread before reading makes the result reliable.

A lost update you can’t pin down

A lost update happens when two threads change the same variable and one change overwrites the other. Here four threads each add 1 to a shared count a hundred thousand times:

int count = 0;

void main() throws InterruptedException {
    var threads = new ArrayList<Thread>();
    for (int i = 0; i < 4; i++) {
        threads.add(Thread.ofPlatform().start(() -> {
            for (int n = 0; n < 100_000; n++) {
                count++;
            }
        }));
    }
    for (Thread t : threads) {
        t.join();
    }
    IO.println("count = " + count);
}

You’d expect 400000. We ran it five times in a row, and the numbers change on every run, so this output is an example, not something you’ll match:

$ for i in 1 2 3 4 5; do java Main.java; done
count = 135423
count = 149972
count = 120394
count = 165135
count = 199862

More than half the increments vanished. On another machine, or a lucky run, you might lose none, so a passing test proves nothing.

Go has a race detector that flags this kind of code. Java doesn’t ship one. So instead of hoping the bug shows up, the next program makes it happen every time.

Forcing the lost update

count++ isn’t one step. It reads count, adds 1, and writes the result back. A CountDownLatch lets us hold both threads between the read and the write:

int count = 5;

void main() throws InterruptedException {
    var bothHaveRead = new CountDownLatch(2);

    Runnable increment = () -> {
        int seen = count;            // 1. read
        bothHaveRead.countDown();
        awaitQuietly(bothHaveRead);  // wait until the other thread has read too
        count = seen + 1;            // 2. add one and write
    };

    Thread t1 = Thread.ofPlatform().start(increment);
    Thread t2 = Thread.ofPlatform().start(increment);
    t1.join();
    t2.join();

    IO.println("two increments ran, starting from 5");
    IO.println("count = " + count);
}

void awaitQuietly(CountDownLatch latch) {
    try {
        latch.await();
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
}

It prints:

two increments ran, starting from 5
count = 6

A CountDownLatch(2) starts at 2. countDown() lowers it, and await() blocks until it reaches 0. Each thread reads 5, counts down, and then waits for the other thread to count down too. So both threads always hold 5 before either writes, and both write 6. We ran it 20 times and got 6 every time.

To be honest about it, the latch exaggerates. Without it, the scheduler only sometimes stops a thread between the read and the write. Across 400,000 increments, “sometimes” is often, as the five runs above showed. The latch picks the bad timing on purpose so you can see it every time.

Watching the lost update

Two threads each run count++ once on a shared count that starts at 5:

T1 count T2 count++ next read 5 wrote 6 count++ next read 5 wrote 6 5 6 seen = 5 seen = 5 6 6 count is 6, not 7: one increment lost count is 5, and T1 and T2 each run count++ once T1 reads count and keeps 5 in its own variable before T1 writes, T2 also reads count and keeps 5 T1 adds 1 to its 5 and writes 6 T2 adds 1 to its own 5 and writes 6 over T1's 6 two increments ran, but count only went from 5 to 6

Two threads each run count++ once on a shared count of 5. Both read 5 before either writes, so both write 6, and one increment is lost. The latch in the program above forces this order; without it, the scheduler picks this order only some of the time.

Here are those steps in words, in case the animation doesn’t play for you:

  1. count is 5. T1 and T2 each run count++ once.
  2. T1 reads count and keeps 5 in its own local copy.
  3. Before T1 writes anything, T2 also reads count and keeps 5.
  4. T1 adds 1 to its 5 and writes 6.
  5. T2 adds 1 to its 5 and writes 6 as well, over T1’s 6.
  6. Two increments ran, but count went from 5 to 6. One increment is lost.

Explain it like I’m ten

Two kids keep score on one scoreboard. Each time their team scores, a kid reads the board, adds 1 in their head, and writes the new number.

Both teams score at once. Kid A reads 5 and thinks “6”. Kid B reads 5 too and thinks “6”. Kid A writes 6. Kid B rubs it out and writes 6. Two points were scored, and the board went up by one. A point vanished, and the board looks perfectly normal.

synchronized is a single marker pen. Only the kid holding the pen may read the board and write on it. The other kid waits for the pen, then reads 6 and writes 7.

The precise version

count++ on a field compiles to three bytecode steps: read the field, add 1, store the field. Another thread can run between any two of them. When two read-modify-write sequences overlap, the second store overwrites the first, and it’s based on a stale read.

Java makes no promise about how threads interleave, so a program like this has no fixed answer. The JIT compiler can widen the gap too. It’s allowed to keep count in a CPU register for a stretch of iterations and store it back later, and that’s one way a run can lose far more increments than you’d guess.

Where the analogy breaks: kids can see each other reach for the board. Threads can’t. count++ never checks for another thread, and it only waits if you add a lock. And the pen has to be the same pen for both kids: two threads holding two different locks don’t wait for each other at all.

synchronized: one thread at a time

Every Java object has a built-in lock, called its monitor. A synchronized method takes the monitor of this before it runs and releases it when it returns, even by an exception. A second thread that calls it on the same object waits.

Here’s the forced program again, with increment marked synchronized. The latch now waits at most 300 milliseconds, because the other thread can’t get in to count down:

int count = 5;
int timedOut = 0;

synchronized void increment(CountDownLatch bothHaveRead) {
    int seen = count;
    bothHaveRead.countDown();
    if (!awaitBriefly(bothHaveRead)) {
        timedOut++;  // the other thread never got in to read
    }
    count = seen + 1;
}

void main() throws InterruptedException {
    var bothHaveRead = new CountDownLatch(2);

    Thread t1 = Thread.ofPlatform().start(() -> increment(bothHaveRead));
    Thread t2 = Thread.ofPlatform().start(() -> increment(bothHaveRead));
    t1.join();
    t2.join();

    IO.println("count = " + count);
    IO.println("waits that gave up: " + timedOut);
}

boolean awaitBriefly(CountDownLatch latch) {
    try {
        return latch.await(300, TimeUnit.MILLISECONDS);
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
}

It prints:

count = 7
waits that gave up: 1

Whichever thread gets the monitor first reads 5 and counts down. Then it waits for a second read that can’t happen, because the other thread is blocked outside increment. After 300 ms it gives up and writes 6. Only then can the second thread enter. It reads 6, finds the latch already at 0, and writes 7. The bad interleaving is now impossible, not just unlikely.

With a plain latch.await() and no timeout, this program would hang forever. The first thread would hold the lock while it waited for the second one. That’s a deadlock, and it gets its own section below.

A static synchronized method locks the class’s Class object instead of this. The monitor is also reentrant: a thread that holds it can call another synchronized method on the same object without blocking itself.

Choosing the lock object

A synchronized block names the object whose monitor it takes. The usual choice is a private final field that exists only to be locked:

class Counter {
    private final Object lock = new Object();
    private int count;

    void increment() {
        synchronized (lock) {
            count++;
        }
    }

    int get() {
        synchronized (lock) {
            return count;
        }
    }
}

void main() throws InterruptedException {
    var counter = new Counter();
    var threads = new ArrayList<Thread>();
    for (int i = 0; i < 4; i++) {
        threads.add(Thread.ofPlatform().start(() -> {
            for (int n = 0; n < 100_000; n++) {
                counter.increment();
            }
        }));
    }
    for (Thread t : threads) {
        t.join();
    }
    IO.println("count = " + counter.get());
}

It prints:

count = 400000

That’s the flaky program from earlier, fixed. We ran it 20 times and got 400000 every time.

Three reasons to prefer a private lock over synchronized methods:

  • Nobody else can take it. Any code holding a Counter can write synchronized (counter) and block your methods. No outside code can reach lock.
  • The block can be small. Lock only the lines that touch shared state, and do slow work outside.
  • Reads need the lock too. get() locks as well, so it never sees a half-finished update, and it always sees the latest write.

Don’t lock on an object that changes. synchronized (count) on an Integer field locks a different object after every count++, because boxing creates a new Integer. javac catches it: warning: [identity] attempt to synchronize on an instance of a value-based class. That’s the category name in Java 25, and -Werror turns the warning into a failed build.

volatile: seeing another thread’s writes

Visibility is the second problem with shared state. One thread writes a field, and another thread may keep seeing the old value. A volatile field fixes that: every read sees the most recent write. The classic use is a stop flag:

volatile boolean running = true;

void main() throws InterruptedException {
    var started = new CountDownLatch(1);
    long[] loops = new long[1];

    Thread worker = Thread.ofPlatform().start(() -> {
        started.countDown();
        while (running) {
            loops[0]++;
        }
    });

    started.await();
    running = false;
    worker.join();
    IO.println("worker stopped: " + !worker.isAlive());
}

It prints:

worker stopped: true

The worker spins until running is false, and join returns once it notices.

Here’s what surprised us. We removed volatile, had main sleep half a second before clearing the flag, and ran it five times with a five-second timeout. The worker never stopped, not once. The likely reason is the JIT: nothing inside the loop writes running, so the compiled loop is allowed to stop reading the field. Without volatile or a lock, that’s legal.

volatile doesn’t make count++ safe, though. A volatile int count makes each read see the latest value, but the read, add and write are still three steps. Two threads can both read 5 and both write 6, exactly as in the animation. volatile fixes visibility, not atomicity. Use it for a flag that one thread sets and others read.

Atomics: compare-and-set

java.util.concurrent.atomic has classes whose updates are single, indivisible steps. They’re built on compare-and-set: “set the value to 6, but only if it’s still 5”. If another thread changed it in between, the call fails and returns false, and you try again. Here’s the forced interleaving once more, with an AtomicInteger:

void main() throws InterruptedException {
    var count = new AtomicInteger(5);
    var retries = new AtomicInteger();
    var bothHaveRead = new CountDownLatch(2);

    Runnable increment = () -> {
        int seen = count.get();
        bothHaveRead.countDown();
        awaitQuietly(bothHaveRead);
        while (!count.compareAndSet(seen, seen + 1)) {
            retries.incrementAndGet();  // someone changed it: read again
            seen = count.get();
        }
    };

    Thread t1 = Thread.ofPlatform().start(increment);
    Thread t2 = Thread.ofPlatform().start(increment);
    t1.join();
    t2.join();

    IO.println("count = " + count.get());
    IO.println("failed compare-and-sets: " + retries.get());
}

void awaitQuietly(CountDownLatch latch) {
    try {
        latch.await();
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
}

It prints:

count = 7
failed compare-and-sets: 1

Both threads still read 5. One wins compareAndSet(5, 6). The other’s compareAndSet(5, 6) fails, because the value is 6 now. It rereads 6 and sets 7. Nothing is lost, and nobody waited on a lock.

You rarely write that loop yourself. incrementAndGet() does exactly this internally, and updateAndGet(x -> x * 2) does it for any function:

void main() throws InterruptedException {
    var count = new AtomicInteger();
    var total = new LongAdder();

    var threads = new ArrayList<Thread>();
    for (int i = 0; i < 4; i++) {
        threads.add(Thread.ofPlatform().start(() -> {
            for (int n = 0; n < 100_000; n++) {
                count.incrementAndGet();
                total.increment();
            }
        }));
    }
    for (Thread t : threads) {
        t.join();
    }

    IO.println("AtomicInteger: " + count.get());
    IO.println("LongAdder:     " + total.sum());
}

It prints:

AtomicInteger: 400000
LongAdder:     400000

LongAdder is for counters that many threads bump constantly. When many threads fight over one AtomicInteger, compare-and-sets keep failing and retrying. A LongAdder gives threads separate cells to add into and adds the cells up when you call sum(). Increments get cheaper and reads get a little more expensive. And sum() isn’t a snapshot while threads are still adding, so read it after they’ve finished, or when an approximate value is fine.

Atomics protect one value. When two fields have to change together, such as a balance and a transaction count, use a lock.

Deadlock: two locks in opposite order

A deadlock happens when two threads each hold a lock the other one needs, so both wait forever. The usual cause is two locks taken in opposite orders. This program forces it with a latch, detects it, and exits:

import java.lang.management.ManagementFactory;

final Object accountA = new Object();
final Object accountB = new Object();

void main() throws InterruptedException {
    var bothHoldOne = new CountDownLatch(2);

    Thread t1 = Thread.ofPlatform().daemon().start(() -> {
        synchronized (accountA) {
            bothHoldOne.countDown();
            awaitQuietly(bothHoldOne);
            synchronized (accountB) {
                IO.println("t1 moved money from A to B");
            }
        }
    });
    Thread t2 = Thread.ofPlatform().daemon().start(() -> {
        synchronized (accountB) {
            bothHoldOne.countDown();
            awaitQuietly(bothHoldOne);
            synchronized (accountA) {
                IO.println("t2 moved money from B to A");
            }
        }
    });

    var threadBean = ManagementFactory.getThreadMXBean();
    long[] stuck = threadBean.findDeadlockedThreads();
    while (stuck == null) {
        Thread.sleep(10);
        stuck = threadBean.findDeadlockedThreads();
    }
    IO.println("deadlock detected: " + stuck.length + " threads");
    IO.println("t1 state: " + t1.getState() + ", t2 state: " + t2.getState());
}

void awaitQuietly(CountDownLatch latch) {
    try {
        latch.await();
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
}

It prints:

deadlock detected: 2 threads
t1 state: BLOCKED, t2 state: BLOCKED

t1 holds A and waits for B. t2 holds B and waits for A. Neither transfer message ever prints. findDeadlockedThreads() asks the JVM for threads stuck in a cycle like this, and returns null while there are none. The loop polls until the cycle forms.

A thread blocked on synchronized can’t be interrupted, so nothing can unstick these two. They’re daemon threads, so the JVM exits anyway when main returns. We ran it 20 times: it printed the same two lines each time and exited in about a second and a half.

On a real server you’d see it from outside instead. jcmd <pid> Thread.print dumps every thread, and for this program it included Found one Java-level deadlock:, followed by which thread holds which monitor.

The fix is a rule: every thread takes the locks in the same order.

final Object accountA = new Object();
final Object accountB = new Object();
int transfers = 0;

void transfer() {
    synchronized (accountA) {      // always A first
        synchronized (accountB) {  // then B
            transfers++;
        }
    }
}

void main() throws InterruptedException {
    Runnable work = () -> {
        for (int n = 0; n < 50_000; n++) {
            transfer();
        }
    };
    Thread t1 = Thread.ofPlatform().start(work);
    Thread t2 = Thread.ofPlatform().start(work);
    t1.join();
    t2.join();
    IO.println("transfers = " + transfers);
}

It prints:

transfers = 100000

A thread that holds B while waiting for A can’t exist, so the cycle can’t form. With real accounts, pick the order from something stable, such as the account id: lock the smaller id first.

ReentrantLock: a lock you can give up on

java.util.concurrent.locks.ReentrantLock does what synchronized does, with explicit lock() and unlock() calls. The unlock always goes in a finally, so an exception can’t leave the lock held:

class Counter {
    private final ReentrantLock lock = new ReentrantLock();
    private int count;

    void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    int get() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

void main() throws InterruptedException {
    var counter = new Counter();
    Runnable work = () -> {
        for (int n = 0; n < 100_000; n++) {
            counter.increment();
        }
    };
    Thread t1 = Thread.ofPlatform().start(work);
    Thread t2 = Thread.ofPlatform().start(work);
    t1.join();
    t2.join();
    IO.println("count = " + counter.get());
}

It prints:

count = 200000

That’s more code than synchronized for the same result. ReentrantLock earns its place when you need something synchronized can’t do, and the main one is giving up. tryLock with a timeout waits a limited time and returns false if the lock never came free:

void main() throws InterruptedException {
    var lock = new ReentrantLock();
    boolean[] gotIt = new boolean[1];

    lock.lock();  // main holds the lock for the whole test
    try {
        Thread other = Thread.ofPlatform().start(() -> {
            try {
                gotIt[0] = lock.tryLock(100, TimeUnit.MILLISECONDS);
                if (gotIt[0]) {
                    lock.unlock();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        other.join();
    } finally {
        lock.unlock();
    }
    IO.println("other thread got the lock: " + gotIt[0]);
}

It prints:

other thread got the lock: false

In the deadlock program, tryLock on the second lock would let a thread back off, release its first lock and retry, instead of waiting forever. lockInterruptibly() gives you a wait that interrupt() can end, which synchronized never allows.

A ReadWriteLock, usually ReentrantReadWriteLock, has two sides. Any number of threads can hold the read lock together, but the write lock is exclusive and waits for every reader to leave. It helps when reads vastly outnumber writes and each read takes a while. For short critical sections it’s often no faster, so start with a plain lock.

Thread-safe collections, and check-then-act

A thread-safe collection makes each single call safe, but not a sequence of calls. Collections.synchronizedList wraps a list so every method takes one lock:

void main() throws InterruptedException {
    List<Integer> numbers = Collections.synchronizedList(new ArrayList<>());

    var threads = new ArrayList<Thread>();
    for (int i = 0; i < 4; i++) {
        threads.add(Thread.ofPlatform().start(() -> {
            for (int n = 0; n < 1_000; n++) {
                numbers.add(n);
            }
        }));
    }
    for (Thread t : threads) {
        t.join();
    }

    long sum = 0;
    synchronized (numbers) {  // iterating needs the list's own lock
        for (int n : numbers) {
            sum += n;
        }
    }
    IO.println("size = " + numbers.size() + ", sum = " + sum);
}

It prints:

size = 4000, sum = 1998000

Each add is safe. The loop isn’t one call, though: it’s many next() calls, so you have to hold the list’s lock yourself, as the documentation says. We swapped in a plain ArrayList and ran it 30 times. 29 runs came up short of 4000, and two of them also threw ArrayIndexOutOfBoundsException inside a thread.

ConcurrentHashMap goes further. Reads don’t block, writers lock only a small part of the map, and iterating never throws ConcurrentModificationException. It still can’t protect code that checks and then acts in two calls:

void main() throws InterruptedException {
    var sessions = new ConcurrentHashMap<String, String>();
    var created = new AtomicInteger();
    var bothHaveChecked = new CountDownLatch(2);

    Runnable login = () -> {
        if (!sessions.containsKey("ana")) {          // check
            bothHaveChecked.countDown();
            awaitQuietly(bothHaveChecked);
            int n = created.incrementAndGet();
            sessions.put("ana", "session-" + n);     // then act
        }
    };

    Thread t1 = Thread.ofPlatform().start(login);
    Thread t2 = Thread.ofPlatform().start(login);
    t1.join();
    t2.join();

    IO.println("sessions created for ana: " + created.get());
    IO.println("entries in the map: " + sessions.size());
}

void awaitQuietly(CountDownLatch latch) {
    try {
        latch.await();
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
}

It prints:

sessions created for ana: 2
entries in the map: 1

The latch holds both threads between the check and the put, so both see no session and both create one. The map has one entry, but two sessions were created, and the second put silently replaced the first. This is the same lost update as count++, one level up.

The fix is to hand the whole decision to the map, in one call:

void main() throws InterruptedException {
    var sessions = new ConcurrentHashMap<String, String>();
    var created = new AtomicInteger();
    var wordCounts = new ConcurrentHashMap<String, Integer>();
    var words = List.of("red", "blue", "red", "green", "red", "blue");

    var threads = new ArrayList<Thread>();
    for (int i = 0; i < 8; i++) {
        threads.add(Thread.ofPlatform().start(() -> {
            sessions.computeIfAbsent("ana", user -> "session-" + created.incrementAndGet());
            for (String w : words) {
                wordCounts.merge(w, 1, Integer::sum);
            }
        }));
    }
    for (Thread t : threads) {
        t.join();
    }

    IO.println("sessions created for ana: " + created.get());
    IO.println("word counts: " + new TreeMap<>(wordCounts));
}

It prints:

sessions created for ana: 1
word counts: {blue=16, green=8, red=24}

Eight threads asked for Ana’s session, and exactly one was created. ConcurrentHashMap.computeIfAbsent runs the function at most once per key, and another thread updating that key can be made to wait while it runs. merge(w, 1, Integer::sum) is the atomic “add one, or start at one”. The program copies the map into a TreeMap before printing it, so the keys come out sorted.

We tried to force the bad timing here too, with a latch inside the function and a timeout. In five runs the second thread never got inside. It waited, then found Ana’s session already there. Because other threads can wait on it, keep that function short, and don’t update the same map from inside it.

Immutable data needs no lock

The easiest thread safety is data that can’t change. If nothing writes, there’s no race to lose. A record with a List.copyOf in its compact constructor is safe to hand to any number of threads:

record Order(String customer, List<String> items) {
    Order {
        items = List.copyOf(items);
    }
}

void main() throws InterruptedException {
    var items = new ArrayList<>(List.of("tea", "cake"));
    var order = new Order("ana", items);
    items.add("soup");  // changes our list, not the order's copy

    int[] sizes = new int[4];
    var threads = new ArrayList<Thread>();
    for (int i = 0; i < sizes.length; i++) {
        int slot = i;
        threads.add(Thread.ofPlatform().start(() -> sizes[slot] = order.items().size()));
    }
    for (Thread t : threads) {
        t.join();
    }
    IO.println("every thread saw: " + Arrays.toString(sizes));
    IO.println(order);
}

It prints:

every thread saw: [2, 2, 2, 2]
Order[customer=ana, items=[tea, cake]]

The record’s fields are final, and List.copyOf gave it an unmodifiable list of its own. The caller’s later add can’t reach it. The part on records covers why the copy matters. To “change” an immutable value, build a new one and publish it through a single AtomicReference or volatile field. Then the only shared state left is one reference.

What to remember

  • start() runs code on a new thread, and run() just calls it on yours. join() waits for a thread to finish before you read its results.
  • count++ is read, add, write. Two threads can interleave those steps and lose an update, and Java has no race detector to catch it.
  • synchronized lets one thread at a time hold an object’s monitor. Lock on a private final object, and lock reads as well as writes.
  • volatile makes a write visible to other threads. It doesn’t make count++ atomic.
  • AtomicInteger updates one value with compare-and-set. Use LongAdder for busy counters, and a lock when several values must change together.
  • Deadlock comes from taking locks in different orders. Always take them in one order, and use tryLock with a timeout when you need a way out.
  • A thread-safe collection makes single calls safe. Check-then-act still races, so use computeIfAbsent and merge, and prefer immutable data when you can.

Shared mutable state needs one rule about who may touch it, and without that rule the scheduler decides.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.