Java Thread Safety: synchronized, Locks, volatile and Atomic Variables (1Z0-831)
Thread-safe code is code that stays correct when several threads use the same data at once, and Java builds it from a small toolkit: synchronized monitors, volatile fields, atomic variables, explicit locks from java.util.concurrent.locks, and synchronizers such as CountDownLatch, CyclicBarrier and Semaphore. Oracle 1Z0-831 objective 8.2 asks you to develop thread-safe code using locking mechanisms and the concurrent API. This lesson explains why count++ loses updates, which monitor each form of synchronized locks, reentrancy, what volatile does and does not guarantee, the atomic classes' return values, ReentrantLock and its four acquisition methods, read-write locks, wait/notify and Condition, the three synchronizers, and how deadlock, livelock and starvation arise and are prevented. Creating threads, executors, virtual threads and scoped values belong to objective 8.1; concurrent collections and parallel streams to 8.3.
On this page10 sections
- What makes code thread-safe: race conditions and lost updates
- synchronized: which monitor each form locks, and reentrancy
- volatile and happens-before: visibility without atomicity
- Atomic variables: getAndX, xAndGet and compareAndSet
- The Lock interface and ReentrantLock: lock, unlock in finally, ownership
- lock, tryLock, timed tryLock and lockInterruptibly compared
- ReadWriteLock: shared readers, one writer, downgrade but no upgrade
- wait/notify and Condition: waiting for a state change
- CountDownLatch, CyclicBarrier and Semaphore
- Deadlock, livelock and starvation
- Explain why read-modify-write and check-then-act code loses updates, and predict that a racy result can differ between runs.
- Identify the monitor locked by a synchronized instance method, static method or block, and decide whether two calls exclude each other.
- Apply reentrancy and the happens-before rules to nested synchronized code, wait(), volatile flags and safe publication.
- Trace AtomicInteger and AtomicLong calls by stored and returned value, including compareAndSet, updateAndGet and accumulateAndGet.
- Use ReentrantLock and ReentrantReadWriteLock correctly: unlock in finally, ownership and hold counts, lock versus tryLock versus lockInterruptibly, and downgrade without upgrade.
- Coordinate threads with wait/notify, Condition, CountDownLatch, CyclicBarrier and Semaphore, predicting their results and exceptions.
- Recognise deadlock, livelock and starvation and choose the remedy that removes the cause.
What makes code thread-safe: race conditions and lost updates
Code is thread-safe when it behaves correctly no matter how the scheduler interleaves the threads that call it, with no extra coordination by the callers. It breaks when two threads touch the same mutable state and at least one of them writes without a shared lock or other happens-before guarantee: that is a data race, and the classic symptom is a lost update.
class Counter {
private int count;
void increment() {
count++; // read, add 1, write: three steps
}
int get() {
return count;
}
}count++ looks like one action but is a read-modify-write: read count, add 1, store the result. Two threads can both read 41 and both store 42, so one increment disappears. Run two threads that each call increment() a million times and the total is often below two million, and different on every run. The result of racy code is not determined by the program: it can be any value up to the maximum and can change from run to run.
A second shape of the same bug is check-then-act: if (x == null) x = create(); or if (!map.containsKey(k)) map.put(k, v);. Another thread can act between the check and the act. Writing count = count + 1 instead of count++ changes nothing — it is the same three steps.
There are three ways to make shared state safe:
- Don't share it — keep data confined to one thread (local variables are always thread-confined).
- Don't mutate it — immutable objects (records with immutable components,
String,List.of) can be shared freely once safely published. - Coordinate access — guard every read and write of the state with the same lock (
synchronizedor aLock), or use a class that does the compound action atomically (AtomicInteger, the concurrent collections).
The rest of this lesson is the third option. Thread-safe collections and parallel streams are covered in the objective 8.3 lesson.
synchronized: which monitor each form locks, and reentrancy
Every Java object has one intrinsic lock, called its monitor, and synchronized acquires exactly one monitor on entry and releases it on exit. Two pieces of synchronized code exclude each other only if they lock the same object; what matters is which monitor each form uses.
| Form | Monitor locked | Excludes |
|---|---|---|
synchronized void m() (instance method) | this — the object the method was called on | Other synchronized instance methods on the same instance, and synchronized (this) blocks |
static synchronized void m() | The Class object, e.g. Main.class | Other static synchronized methods of the class and synchronized (Main.class) blocks — never instance-method locks |
synchronized (expr) { … } | The object expr evaluates to, once, on entry | Any code locking that same object |
public class Main {
private static final Object LOCK = new Object();
private int instanceCount;
private static int classCount;
synchronized void a() {
System.out.println("a: this=" + Thread.holdsLock(this)
+ " class=" + Thread.holdsLock(Main.class));
}
static synchronized void b() {
System.out.println("b: class=" + Thread.holdsLock(Main.class)
+ " LOCK=" + Thread.holdsLock(LOCK));
}
void c() {
synchronized (LOCK) {
System.out.println("c: LOCK=" + Thread.holdsLock(LOCK)
+ " this=" + Thread.holdsLock(this));
}
}
public static void main(String[] args) {
var m = new Main();
m.a();
b();
m.c();
}
}Output (run on JDK 25):
a: this=true class=false
b: class=true LOCK=false
c: LOCK=true this=falseThread.holdsLock(obj) reports whether the current thread owns obj's monitor. Calls on two different instances of a class can run a synchronized instance method at the same time, and an instance method and a static method never block each other. That is why a synchronized instance method does not protect a static field when each thread uses its own instance — the field needs a static lock (static synchronized or synchronized (Main.class)).
Rules for the block form:
- The expression must be a reference type; a primitive does not compile, and
nullthrowsNullPointerExceptionat run time. - Lock a
private finaldedicated object. Locking a field that is reassigned (for example a boxedIntegerthatcount++replaces with a new object) means threads can lock different objects and race; javac only warns about synchronizing on a value-based class such asInteger. - Locking a fresh object (
synchronized (new Object())) orThread.currentThread()gives each thread its own monitor and protects nothing. synchronizedis a modifier for methods and a statement for blocks; it is not allowed on fields, constructors or classes. An overriding method can add or dropsynchronizedfreely — it is not part of the signature.
The monitor is released however the block exits — normally, by return, or by an exception — so synchronized never leaks a lock. A thread that waits for a monitor is in state BLOCKED and cannot be interrupted out of that wait.
Reentrancy: re-entering a lock you already hold
Intrinsic locks are reentrant: a thread that already owns a monitor can enter any other code synchronized on the same monitor without blocking. The JVM keeps a hold count per monitor — it goes up on each entry and down on each exit, and the monitor is free only when it reaches zero.
public class Main {
private final Object lock = new Object();
void outer() {
synchronized (lock) {
middle();
}
}
void middle() {
synchronized (lock) {
try {
throw new IllegalStateException("boom");
} catch (IllegalStateException e) {
System.out.println("caught, held=" + Thread.holdsLock(lock));
}
}
}
public static void main(String[] args) {
var m = new Main();
m.outer();
System.out.println("after, held=" + Thread.holdsLock(m.lock));
}
}Output (run on JDK 25):
caught, held=true
after, held=falseReentrancy is what makes these ordinary patterns safe instead of self-deadlocking:
- a synchronized method that calls another synchronized method on
this; - a synchronized override that calls
super.m(), which is synchronized on the same object; - nested
synchronized (lock)blocks on the same lock, directly or through method calls.
A thread that calls wait() on a monitor it holds several times releases all its holds while waiting, so another thread can enter; when it wakes it reacquires the monitor with its previous hold count restored. ReentrantLock is reentrant in the same way (see below), and so are both locks of a ReentrantReadWriteLock.
volatile and happens-before: visibility without atomicity
A volatile field guarantees that every read sees the most recent write by any thread, and that writes made before a volatile write are visible to a thread that later reads that volatile value. It does not make compound actions atomic: v++ on a volatile field still loses updates.
Without any synchronization, the Java Memory Model lets the JIT keep a plain field in a register or hoist its read out of a loop. A worker spinning on while (running) with a plain boolean may never see another thread set it to false. Marking the field volatile fixes that because the JMM defines a happens-before edge from each volatile write to every later read of the same field. The edges you need to know:
- An unlock of a monitor or
Lockhappens-before every later lock of the same monitor orLock. - A write to a volatile field happens-before every later read of that field.
Thread.start()happens-before every action in the started thread.- Every action in a thread happens-before another thread returns from
join()on it. - Program order inside one thread, and transitivity: if A happens-before B and B before C, then A before C.
Transitivity is what makes the safe publication idiom work: a thread writes plain fields, then writes volatile boolean ready = true; any thread that reads ready as true is guaranteed to see the plain writes made before it. A volatile read never blocks — it just reads the current value.
| Tool | Visibility | Atomic compound update | Mutual exclusion |
|---|---|---|---|
volatile field | Yes | No (single reads and writes only) | No |
AtomicInteger / AtomicLong / AtomicReference | Yes | Yes, for one variable | No |
synchronized / Lock | Yes (for code under the same lock) | Yes, for any number of variables | Yes |
volatile is a field modifier only — not for local variables, methods or classes. It is the right tool for a flag one thread writes and others read; the moment a value depends on its old value, use an atomic class or a lock.
Atomic variables: getAndX, xAndGet and compareAndSet
The classes in java.util.concurrent.atomic — AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference and the array variants — update a single variable atomically without a lock, using hardware compare-and-set. Their reads and writes also have volatile visibility.
The method name tells you which value comes back: getAndX returns the value before the change, xAndGet returns the value after it.
| Method | Stores | Returns |
|---|---|---|
get() / set(v) / getAndSet(v) | — / v / v | current / nothing / old |
getAndIncrement(), getAndDecrement(), getAndAdd(d) | old ± 1, old + d | old value |
incrementAndGet(), decrementAndGet(), addAndGet(d) | old ± 1, old + d | new value |
compareAndSet(expected, v) | v, only if current == expected | true if it stored, else false (nothing changes) |
getAndUpdate(f) / updateAndGet(f) | f(old) | old / new |
getAndAccumulate(x, op) / accumulateAndGet(x, op) | op(old, x) | old / new |
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
public class Main {
public static void main(String[] args) {
var n = new AtomicInteger(10);
System.out.println(n.getAndAdd(5) + " " + n.get());
System.out.println(n.addAndGet(5) + " " + n.get());
System.out.println(n.decrementAndGet() + " "
+ n.getAndDecrement() + " " + n.get());
System.out.println(n.compareAndSet(18, 0) + " "
+ n.compareAndSet(17, 0) + " " + n.get());
var max = new AtomicLong(4);
System.out.println(max.accumulateAndGet(9, Math::max) + " "
+ max.getAndUpdate(x -> x + 1) + " "
+ max.updateAndGet(x -> x * 2));
}
}Output (run on JDK 25):
10 15
20 20
19 19 18
true false 0
9 9 20Trace these one call at a time, writing down the stored value and the returned value separately. The update and accumulate functions may be re-applied when another thread wins the race, so they must be side-effect-free. Replacing a racy count++ with count.incrementAndGet() makes the counter exact. For a counter that many threads hit constantly and that is read rarely, LongAdder (increment(), sum()) scales better than AtomicLong. Atomic classes cover one variable: keeping two variables consistent with each other still needs a lock.
The Lock interface and ReentrantLock: lock, unlock in finally, ownership
java.util.concurrent.locks.Lock is an explicit lock: you call lock() to acquire and unlock() to release, and nothing releases it for you. ReentrantLock is its standard implementation, with the same mutual exclusion, reentrancy and memory effects as synchronized, plus non-blocking, timed and interruptible acquisition, fairness and several conditions per lock.
Because an exception does not release a Lock, the only correct shape is acquire, then try, then unlock() in finally:
lock.lock();
try {
// guarded work, which may throw
} finally {
lock.unlock();
}
if (lock.tryLock()) { // only unlock what you acquired
try {
// guarded work
} finally {
lock.unlock();
}
} else {
// do something else
}Variants that look similar fail on one path: unlock() as a plain statement after the work (or after an empty finally) is skipped when the work throws; unlock() only in a catch is skipped when the work succeeds. Call lock() before the try, so a failed acquisition never reaches an unlock().
Ownership is checked. unlock() by a thread that does not hold the lock — one that never called lock(), whose tryLock() returned false, or that is simply a different thread — throws IllegalMonitorStateException (an unchecked exception; it compiles fine). A thread that ends while holding a Lock does not release it: the lock stays held and other threads wait forever. Each lock() by the owner adds one to the hold count and each unlock() removes one; the lock is free only at zero.
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
public static void main(String[] args) throws Exception {
var lock = new ReentrantLock();
lock.lock();
lock.lock();
System.out.println(lock.getHoldCount() + " "
+ lock.isHeldByCurrentThread() + " " + lock.isLocked());
Thread other = new Thread(() -> {
try {
boolean got = lock.tryLock(20, TimeUnit.MILLISECONDS);
System.out.println("other got: " + got);
lock.unlock();
} catch (InterruptedException e) {
System.out.println("interrupted");
} catch (IllegalMonitorStateException e) {
System.out.println("other: IllegalMonitorStateException");
}
});
other.start();
other.join();
lock.unlock();
lock.unlock();
System.out.println(lock.getHoldCount() + " " + lock.isLocked());
}
}Output (run on JDK 25):
2 true true
other got: false
other: IllegalMonitorStateException
0 falseUseful ReentrantLock queries: getHoldCount() (holds by the current thread, 0 if it is not the owner), isHeldByCurrentThread(), isLocked() (held by any thread), isFair() and hasQueuedThreads(). new ReentrantLock(true) creates a fair lock that grants access roughly in arrival order, which reduces starvation at a throughput cost; it does not prevent deadlock, and the untimed tryLock() barges in even on a fair lock if the lock happens to be free.
lock, tryLock, timed tryLock and lockInterruptibly compared
The four acquisition methods differ in whether they wait, for how long, and whether an interrupt can stop the wait. Only two of them declare the checked InterruptedException, so they must be inside a try/catch or a method that declares it — including inside a Runnable lambda, whose run() declares nothing (unlike Callable.call(), which declares throws Exception, so a Callable lambda may let it propagate).
| Method | If the lock is held by another thread | Returns | Interrupt while waiting | Throws checked InterruptedException |
|---|---|---|---|---|
lock() | Waits until it is free, however long | void | Ignored — keeps waiting | No |
tryLock() | Returns at once | false (or true if it got the lock) | Not applicable | No |
tryLock(long time, TimeUnit unit) | Waits up to the timeout | true if acquired in time, else false | Throws InterruptedException | Yes |
lockInterruptibly() | Waits until it is free | void | Throws InterruptedException | Yes |
There is exactly one timed overload, tryLock(long, TimeUnit): tryLock(100) and tryLock(Duration.ofMillis(100)) do not compile. A thread parked in lock() or lockInterruptibly() shows state WAITING (TIMED_WAITING in a timed tryLock), unlike the BLOCKED state of a thread waiting for a monitor. Interrupting a thread stuck in lockInterruptibly() makes it throw InterruptedException without acquiring the lock, so it must not call unlock() afterwards; the same interrupt sent to a thread in lock() only sets its interrupt flag. These choices are the tools for responsive code: fail fast with tryLock(), bound the wait with the timed form, or let a supervisor cancel the wait with lockInterruptibly().
wait/notify and Condition: waiting for a state change
wait(), notify() and notifyAll() are Object methods that let a thread sleep until another thread changes some shared state; they can be called only by a thread that owns that object's monitor, otherwise they throw IllegalMonitorStateException at run time. The code still compiles — the compiler does not check lock ownership — but wait declares the checked InterruptedException, so the call must be caught or declared.
What happens step by step:
- The waiter enters
synchronized (lock), tests its condition and callslock.wait(). That releases the monitor (all holds) and parks the thread in stateWAITING. - Another thread enters
synchronized (lock), changes the state, and callsnotify()(wakes one waiting thread, chosen arbitrarily) ornotifyAll()(wakes all of them). - The woken thread cannot continue until the notifier leaves its synchronized block; it then competes for the monitor, reacquires it and returns from
wait().
Always wait in a while loop over the condition, never an if: a thread can wake spuriously, another woken thread may have consumed the state first, and a notification sent when nobody is waiting is simply lost — notify has no memory, so a waiter that checks the flag first never waits for a signal that already happened.
Condition is the same mechanism for a Lock: create it with lock.newCondition(), and call await(), signal() and signalAll() while holding that lock (a ReentrantLock condition throws IllegalMonitorStateException otherwise). One lock can have several conditions, such as notEmpty and notFull, so you wake only the threads that care.
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Deque<Integer> items = new ArrayDeque<>();
void put(int x) {
lock.lock();
try {
items.addLast(x);
notEmpty.signal();
} finally {
lock.unlock();
}
}
int take() throws InterruptedException {
lock.lock();
try {
while (items.isEmpty()) {
notEmpty.await();
}
return items.removeFirst();
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws Exception {
var box = new Main();
Thread consumer = new Thread(() -> {
try {
int sum = 0;
for (int i = 0; i < 3; i++) {
sum += box.take();
}
System.out.println("sum " + sum);
} catch (InterruptedException e) {
System.out.println("interrupted");
}
});
consumer.start();
for (int i = 1; i <= 3; i++) {
box.put(i * 10);
}
consumer.join();
}
}Output (run on JDK 25):
sum 60| Monitor | Lock + Condition | Notes |
|---|---|---|
obj.wait() | cond.await() | Releases the lock while waiting; throws InterruptedException |
obj.wait(ms) | cond.await(time, unit) / awaitNanos | await(time, unit) returns false if the time elapsed |
| — | cond.awaitUninterruptibly() | No InterruptedException |
obj.notify() / notifyAll() | cond.signal() / signalAll() | Caller must hold the lock |
Do not mix them up: calling wait() on a Condition object uses the condition's own monitor, not the lock, and throws IllegalMonitorStateException unless you synchronized on the condition itself.
CountDownLatch, CyclicBarrier and Semaphore
The three synchronizers coordinate threads without guarding data: a CountDownLatch lets threads wait until a count reaches zero once, a CyclicBarrier makes a fixed group of threads wait for each other repeatedly, and a Semaphore limits how many threads use a resource at once.
| CountDownLatch | CyclicBarrier | Semaphore | |
|---|---|---|---|
| Created with | new CountDownLatch(count) | new CyclicBarrier(parties) or (parties, action) | new Semaphore(permits) or (permits, fair) |
| Main calls | countDown(), await(), getCount() | await(), reset(), isBroken(), getNumberWaiting() | acquire(), release(), tryAcquire(), availablePermits() |
| Reusable | No — once at zero it stays open | Yes — resets after every trip | Yes |
| Who signals | Any thread, any number of times | The parties themselves, by arriving | Any thread — permits have no owner |
CountDownLatch. countDown() on a latch already at zero does nothing — the count never goes negative and nothing is thrown. await() returns immediately at zero and otherwise blocks; it throws InterruptedException only when interrupted. await(time, unit) returns false on timeout (no exception) and true when the count reaches zero.
CyclicBarrier. Each await() blocks until parties threads have called it. The last thread to arrive runs the barrier action once (if any), then all are released and the barrier resets for the next round — so n rounds trip it n times. await() declares InterruptedException and BrokenBarrierException; the timed form adds TimeoutException. If a waiting thread times out or is interrupted, the barrier becomes broken: the other waiters, and every later await(), throw BrokenBarrierException at once until reset() is called.
Semaphore. acquire() takes a permit, blocking if none is left (throws InterruptedException); acquireUninterruptibly() waits without it. tryAcquire() and tryAcquire(n) return false at once if not enough permits are available and declare no checked exception, while the timed tryAcquire(time, unit) declares InterruptedException. Permits are just a count: any thread may release(), even one that never acquired, and releasing more than you acquired raises the count above its initial value. That is the key difference from a lock — Semaphore(1) works as a mutex but with no ownership check and no reentrancy.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.Semaphore;
public class Main {
public static void main(String[] args) throws Exception {
var ready = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
new Thread(ready::countDown).start();
}
ready.await();
System.out.println("latch " + ready.getCount());
var barrier = new CyclicBarrier(2,
() -> System.out.println("trip"));
Thread t = new Thread(() -> {
try {
barrier.await();
barrier.await();
} catch (Exception e) {
System.out.println("broken");
}
});
t.start();
barrier.await();
barrier.await();
t.join();
System.out.println("waiting " + barrier.getNumberWaiting());
var pool = new Semaphore(2);
System.out.println(pool.tryAcquire(2) + " "
+ pool.tryAcquire() + " " + pool.availablePermits());
pool.release(3);
System.out.println(pool.availablePermits());
}
}Output (run on JDK 25):
latch 0
trip
trip
waiting 0
true false 0
3Deadlock, livelock and starvation
Liveness failures are bugs where threads stop making progress even though nothing is corrupted: in a deadlock threads are blocked forever waiting for each other, in a livelock they keep running but keep undoing each other's attempts, and in starvation one thread rarely or never gets a resource that others keep winning.
| Problem | Thread state | Typical cause | Remedy |
|---|---|---|---|
| Deadlock | BLOCKED or WAITING, forever | Threads take the same locks in different orders (A then B vs B then A), each holding one and waiting for the other | One global lock order; or tryLock (with a timeout) and back off |
| Livelock | RUNNABLE / busy, no progress | Threads that back off and retry in lockstep, e.g. identical fixed retry delays | Randomized back-off; or one global lock order |
| Starvation | One thread rarely runs; others progress | Unfair locks, greedy threads holding a lock for long periods | Fair locks (new ReentrantLock(true)), shorter critical sections |
A deadlock needs a cycle of threads each holding a lock and waiting for the next. Breaking the cycle is the fix; changing timing is not. Anything that only changes timing or scheduling — shorter sleeps, priorities, fairness — makes the hang rarer at best, because the threads still wait for each other. Two designs that do work:
- Consistent lock ordering — every thread acquires the locks in the same order (for example by an id), so a cycle cannot form.
- Non-blocking acquisition — take the second lock with
tryLock()or a timedtryLock; on failure release what you hold and try again later, after a random pause so that two threads don't collide again (that collision is livelock).
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
record Account(int id, Lock lock) {}
static int moves;
static void transfer(Account from, Account to) {
Account first = from.id() < to.id() ? from : to;
Account second = first == from ? to : from;
first.lock().lock();
try {
second.lock().lock();
try {
moves++;
} finally {
second.lock().unlock();
}
} finally {
first.lock().unlock();
}
}
public static void main(String[] args) throws Exception {
var a = new Account(1, new ReentrantLock());
var b = new Account(2, new ReentrantLock());
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) transfer(a, b);
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) transfer(b, a);
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("moves " + moves);
}
}Output (run on JDK 25):
moves 200000Both threads move money between the same two accounts in opposite directions, but because each always locks the lower id first, they cannot deadlock and every one of the 200,000 guarded updates is kept. Keep critical sections short and avoid calling unknown code (callbacks, listeners) while holding a lock — both reduce the chance of contention, starvation and accidental lock cycles.
Tip. Objective 8.2 is examined mostly through short, complete programs that share data between threads, so expect to state what is printed, which exception is thrown, whether the code compiles, or whether it can hang. Monitor items ask which object a synchronized method or block locks, whether two calls on given instances can overlap, what Thread.holdsLock reports, and what reentrancy allows in nested blocks and super calls. Race items show unsynchronized or wrongly locked counters and ask for the possible results or the changes that guarantee a correct total. Atomic items trace getAndX versus xAndGet, compareAndSet, updateAndGet and accumulateAndGet. Lock items check the try/finally idiom, hold counts, IllegalMonitorStateException on an unowned unlock, the return values and checked exceptions of lock, tryLock, timed tryLock and lockInterruptibly, and read-write lock sharing, upgrade and downgrade. Coordination items cover wait/notify and Condition ownership and ordering, latch counts and timed awaits, barrier trips and broken barriers, semaphore permit arithmetic and ownership, and the difference between deadlock, livelock and starvation and their remedies.
- count++ and x = x + 1 are read-modify-write: without a shared lock or an atomic class, concurrent updates are lost and the result can change from run to run.
- A synchronized instance method locks this, a static synchronized method locks the Class object, a block locks the object its expression evaluates to — two calls exclude each other only on the same object.
- Lock a private final dedicated object; locking a reassigned field, a boxed Integer, new Object() or Thread.currentThread() protects nothing.
- Intrinsic locks and ReentrantLock are reentrant (hold counts); wait() releases every hold and restores them when the thread wakes.
- volatile gives visibility and happens-before ordering for single reads and writes, never atomicity of compound actions.
- getAndX returns the old value, xAndGet the new one; compareAndSet stores only when the current value equals the expected one and returns whether it did.
- Always lock() then try { … } finally { unlock(); }; unlock() by a non-owner throws IllegalMonitorStateException, and a Lock is never released automatically.
- lock() ignores interrupts, tryLock() returns at once, tryLock(long, TimeUnit) and lockInterruptibly() throw InterruptedException; there is no tryLock(long) or tryLock(Duration).
- Read locks are shared and the write lock is exclusive; a writer may downgrade to a read lock, a reader cannot upgrade.
- wait/notify and Condition need the lock held and a while-loop guard; a latch never goes below zero, a barrier resets each trip and breaks on timeout, and semaphore permits have no owner, and release() can raise the count above its initial value.
Frequently asked questions
What is the difference between a synchronized instance method and a static synchronized method in Java?
A synchronized instance method locks the monitor of the object it is called on (this), so it only excludes other synchronized code on the same instance. A static synchronized method locks the class's Class object (for example Main.class), shared by all instances. The two locks are independent: an instance method and a static method of the same class can run at the same time, and an instance method does not protect a static field when threads use different instances.
Does volatile make count++ thread-safe in Java?
No. volatile guarantees that each read sees the latest write and orders writes before it, but count++ is three steps — read, add, write — and two threads can still read the same value and both store the same result. Use AtomicInteger.incrementAndGet(), a LongAdder, or guard every access with the same lock. volatile alone is right for a flag that one thread sets and others only read.
Why must ReentrantLock.unlock() be in a finally block?
Unlike synchronized, a Lock is not released when a block ends or an exception passes through it. If the guarded code throws and unlock() is not in finally, the lock stays held and every other thread that calls lock() waits forever. The standard idiom is lock.lock(); try { work } finally { lock.unlock(); }, with lock() before the try so a failed acquisition never reaches unlock().
What happens if a thread calls unlock() on a ReentrantLock it does not hold?
It throws IllegalMonitorStateException, an unchecked exception, so the code compiles and fails at run time. That happens when the thread never called lock(), when its tryLock() returned false, or when a different thread acquired the lock. The same exception is thrown by wait(), notify() or Condition.await() called without holding the matching lock.
What is the difference between tryLock() and lockInterruptibly()?
tryLock() never waits: it returns true if it acquired the lock and false at once if another thread holds it, and it throws no checked exception. lockInterruptibly() waits until the lock is free, like lock(), but stops and throws InterruptedException if the thread is interrupted while waiting. The timed tryLock(long, TimeUnit) waits up to the timeout and then returns false; it also throws InterruptedException.
Can a thread upgrade a read lock to a write lock with ReentrantReadWriteLock?
No. While any thread, including the current one, holds the read lock, the write lock cannot be acquired: writeLock().tryLock() returns false and writeLock().lock() blocks forever. Release the read lock first, then take the write lock and re-check the data. The reverse, downgrading, is allowed: a thread holding the write lock may take the read lock and then release the write lock.
What is the difference between CountDownLatch and CyclicBarrier?
A CountDownLatch is a one-shot gate: any thread calls countDown(), the count never goes below zero, and once it reaches zero await() always returns immediately and the latch cannot be reset. A CyclicBarrier makes a fixed number of parties wait for each other with await(); when the last arrives it runs the optional barrier action and resets for the next round. A timeout or interrupt breaks the barrier, after which await() throws BrokenBarrierException until reset() is called.
How do you prevent deadlock in Java?
Deadlock happens when threads each hold one lock and wait for a lock another holds, forming a cycle. Prevent it by acquiring locks in one global order in every thread (for example by an id), or by taking the extra lock with tryLock() or a timed tryLock and releasing what you hold on failure, retrying after a random pause so threads don't fall into a livelock. Changing only timing or scheduling does not remove the cycle.
Source
This lesson covers the "Implementing Multithreading for Concurrent Code Execution" domain of the official 1Z0-831 exam guide. Vendors revise their guides — check the source for the current version.
- Java SE 25 Developer Professional (1Z0-831) exam page and topics — Oracle University
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.