A race condition occurs when a program's result depends on an uncontrolled order of competing operations. In Java, shared mutable state needs a coordination rule. synchronized uses an object's monitor to exclude other synchronized code using that same monitor and to establish memory visibility. This lesson starts with a counter because it exposes the important detail: incrementing a value is several logical steps even when the source code uses the compact ++ operator.
Recognize a read-modify-write race
Imagine two workers both reading a counter at five. Each computes six and stores six, so one increment is lost. An ordinary int field does not make the whole sequence atomic. Declaring the field volatile improves visibility of reads and writes but still does not make count++ atomic. The incorrect result is timing-dependent; a test can occasionally print the expected value without proving the program is safe.
Lock the complete operation
A synchronized instance method acquires the monitor of this. Only one thread at a time can execute code synchronized on that same object. Protect the complete read-modify-write operation rather than locking the read and write separately. A synchronized block lets you choose a dedicated lock object and a smaller critical section. All code participating in a shared invariant must follow the same lock rule, including readers when concurrent visibility matters.
Atomic classes solve focused updates
AtomicInteger provides operations such as incrementAndGet that atomically update one integer. It is a good fit for a standalone counter and avoids writing your own monitor-based increment. It does not automatically protect a rule involving several separate variables, such as checking stock and adjusting both stock and revenue together. Those multi-field invariants may need one lock or a different transaction design. Choose a tool that protects the actual operation, not merely individual values.
Keep critical sections small and consistent
Avoid slow network calls and lengthy file operations while holding a lock when they do not need the protected state. Waiting threads cannot proceed through that monitor. When multiple locks are necessary, acquire them in a consistent order to reduce deadlock risk. Reentrant monitors let a thread reenter a lock it already holds, but they do not prevent cycles involving several locks. Simpler ownership and fewer shared mutable objects often make concurrency easier to verify.
Distinguish safety from completion
Synchronization protects state while tasks execute; join waits for them to finish. Both are needed in the counter demonstration: safe increments avoid lost updates, and joining ensures the final print happens after all increments. A synchronized getter used too early could correctly return an intermediate value. Conversely, joining an unsafe calculation does not repair updates already lost. State precisely which guarantee each API contributes.
Practice tasks and checks
Change SynchronizedCounter so three workers each perform seven hundred increments. Predict the final value and join all three threads before checking it. Next, implement the same operation with AtomicInteger and verify the same result. Do not use a passing run of an intentionally unsafe counter as evidence that no lock is necessary. Extend Seats with a cancellation method that adds seats back under the same monitor and rejects nonpositive amounts. Consider whether cancellation can exceed the original capacity and add that invariant explicitly if your design requires it. Finally, draw the ownership relationship: one Seats object, one protected remaining field, and every operation using that object's lock. The exercise should make the protected state and critical section visible.
Java Summary
- Protect the complete read-modify-write operation with the same monitor.
- volatile alone does not make increments atomic.
- Completion waits and shared-state protection supply different guarantees.
- Continue with concurrency utilities when these examples are clear.
Java Examples
Each example shows the code, the output it produces and what each part does.
Protect an increment with a monitor
Save this program as SynchronizedCounter.java. Compile it with javac SynchronizedCounter.java and run it with java SynchronizedCounter.
Expected output:
Both workers use the same Counter instance and therefore the same monitor. Each increment holds that monitor across reading and updating value, so no increment is lost. The joins wait for both thousand-iteration loops to finish before the getter is called. The getter uses the same monitor as the writer. Removing synchronization would make the result unreliable; printing two thousand once would not establish correctness for later runs.
public class SynchronizedCounter {
static class Counter {
private int value;
synchronized void increment() { value++; }
synchronized int value() { return value; }
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable work = () -> {
for (int i = 0; i < 1000; i++) counter.increment();
};
Thread first = new Thread(work);
Thread second = new Thread(work);
first.start(); second.start();
first.join(); second.join();
System.out.println(counter.value());
}
}2000Use an atomic counter
Save this program as AtomicVisits.java. Compile it with javac AtomicVisits.java and run it with java AtomicVisits.
Expected output:
AtomicInteger starts at zero. Each incrementAndGet performs one atomic update, so the two five-hundred-iteration tasks contribute a thousand total updates. The joins still matter because atomicity does not mean the workers are already finished. get reads the final value afterward. This example has only one counter and no associated balance or stock invariant, making the atomic class a direct match for its requirements.
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicVisits {
public static void main(String[] args) throws InterruptedException {
AtomicInteger visits = new AtomicInteger();
Runnable work = () -> {
for (int i = 0; i < 500; i++) visits.incrementAndGet();
};
Thread first = new Thread(work);
Thread second = new Thread(work);
first.start(); second.start();
first.join(); second.join();
System.out.println(visits.get());
}
}1000Protect a check and update together
Save this program as SeatReservation.java. Compile it with javac SeatReservation.java and run it with java SeatReservation.
Expected output:
The first reservation checks availability and subtracts two while holding one lock. The next reservation sees no remaining seats and returns false without changing state. Synchronizing only the subtraction would allow concurrent callers to pass the availability check before either subtraction, potentially overselling seats. The example's sequential calls keep output deterministic while illustrating the complete invariant that the synchronized method protects under concurrent use.
public class SeatReservation {
static class Seats {
private int remaining = 2;
synchronized boolean reserve(int count) {
if (count <= 0 || count > remaining) return false;
remaining -= count;
return true;
}
synchronized int remaining() { return remaining; }
}
public static void main(String[] args) {
Seats seats = new Seats();
System.out.println(seats.reserve(2));
System.out.println(seats.reserve(1));
System.out.println(seats.remaining());
}
}true
false
0Java FAQ
Does volatile fix count++?
No. volatile provides visibility and ordering guarantees for the field, but the increment still consists of a read and a write with a calculation between them. Use a synchronized operation or an appropriate atomic update.
Is synchronized always slower than an atomic class?
Performance depends on contention and the protected work. Choose correctness and a clear invariant first, then measure a representative workload. Atomic operations are not a replacement for every compound multi-field rule.
What is a deadlock?
Two or more threads each hold a lock and wait for a lock the other holds, so none can continue. A common cause is acquiring two locks in different orders in different methods. Always acquire multiple locks in one consistent order, keep locked sections short, and prefer higher-level utilities that manage locking for you.
What is the difference between synchronized and ReentrantLock?
synchronized is built into the language and releases its lock automatically at the end of the block. ReentrantLock must be unlocked explicitly, normally in finally, but offers extras: tryLock with a timeout, interruptible waiting and optional fairness. Use synchronized unless you need one of those features.
Are immutable objects thread-safe?
Yes. An object whose state can't change after construction, with final fields and no leaked mutable parts, can be shared freely between threads without locks. Records, String and LocalDate are common examples. Making shared data immutable is often simpler than synchronising every access.
Java Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
What monitor does a synchronized instance method use?
Why is a volatile int insufficient for an atomic increment?
Java Quiz
2 questions to check what you learned.
What does join add to the counter examples?
- It waits until both workers finish
- It changes increments into atomic operations
- It locks the Counter forever
Which operation must a reservation lock protect?
- The availability check and subtraction together
- Only printing the result
- Only reading the requested count