Learning outcome: Identify race conditions, protect shared state with synchronized, and use thread priorities as scheduling hints without depending on them for correctness.
Threads often share objects. If two threads read and update the same mutable data at the same time, their operations can overlap and produce an incorrect result. Synchronisation controls access to a critical section so that shared state remains consistent.
Race Condition
Consider the statement balance = balance - amount. It involves more than one action: read the balance, calculate a new value, and write it back. Two threads can read the same old balance before either writes its result.
class Counter {
private int value;
void increment() {
value++;
}
int getValue() {
return value;
}
}
If several threads call increment, some updates may be lost. The incorrect outcome depends on timing, so it may not appear in every execution. Such timing-dependent behaviour is called a race condition.
In simple terms: value++ looks like one instruction but is really three: read the value, add one, write it back. If two threads both read 7 before either writes, both write 8, and one increment disappears. What makes this bug dangerous is that it is not reproducible: the same program may print the right answer a hundred times and the wrong answer on the next run, because the fault depends on timing rather than on the input.
Critical Section and Monitor
A critical section is code that reads or modifies shared mutable state and must not be executed concurrently by multiple threads.
Every Java object has an intrinsic monitor lock. A thread must acquire the relevant monitor before it enters a synchronized section. While that monitor is held, another thread attempting to acquire the same monitor waits in the BLOCKED state.
In simple terms: every object carries exactly one key. synchronized means "take the key before entering this room and hand it back on the way out", including when an exception throws you out. Anyone else who needs that same key waits at the door in the BLOCKED state.
The lock provides:
- mutual exclusion, because only one thread holds the monitor at a time; and
- memory visibility, because changes made before releasing the monitor become visible to a thread that later acquires the same monitor.
Why visibility matters as well: without it, one thread's update could sit in a processor cache and never be seen by another thread, even if the two never ran at the same moment. Releasing and then acquiring the same monitor publishes the change. This is the reason the reader method getValue() below is synchronized too, not only the writer.
Intrinsic locks are reentrant. A thread that already holds a monitor can acquire the same monitor again, so one synchronized method may call another synchronized method on the same object without blocking itself. The JVM counts the acquisitions and releases the monitor only when the count returns to zero.
Synchronized Instance Method
Adding synchronized to an instance method locks the current object, represented by this.
class SafeCounter {
private int value;
synchronized void increment() {
value++;
}
synchronized int getValue() {
return value;
}
}
Two different SafeCounter objects have different locks and can be updated independently.
In simple terms: the lock belongs to the object, not to the method. Two threads calling increment() on the same counter take turns, while two threads working on two different counters never wait for each other.
Synchronized Block
A block limits locking to the statements that require protection.
class ResultStore {
private int total;
void add(int value) {
validate(value); // does not use shared state
synchronized (this) {
total += value; // critical section
}
}
private void validate(int value) {
if (value < 0) {
throw new IllegalArgumentException("Value must not be negative");
}
}
}
Keep critical sections small, but do not split one logical update across different locks.
In simple terms: lock the safe, not the whole shop. Validation, logging, and formatting do not touch shared data, so leaving them outside the block lets other threads keep working. But if a check and the update based on that check belong together, they must stay inside the same block.
Static Synchronisation
A static synchronized method locks the Class object, not an instance.
class RegistrationNumber {
private static int next = 1000;
static synchronized int generate() {
return next++;
}
}
This is appropriate because next belongs to the class and is shared across all instances.
In simple terms: a static field is shared by every object, so an object's key is the wrong key here. A thousand objects would mean a thousand different locks and therefore no protection at all. A static synchronized method uses the single class-level key instead.
Ticket-Booking Example
The following program protects the check-and-update operation with one synchronized method.
class TicketCounter {
private int availableSeats;
TicketCounter(int availableSeats) {
this.availableSeats = availableSeats;
}
synchronized boolean book(String passenger, int seatsRequired) {
System.out.printf("%s requested %d seat(s).%n", passenger, seatsRequired);
if (seatsRequired <= 0) {
System.out.println("Seat count must be positive.");
return false;
}
if (seatsRequired > availableSeats) {
System.out.printf("Booking failed for %s; only %d seat(s) remain.%n",
passenger, availableSeats);
return false;
}
availableSeats -= seatsRequired;
System.out.printf("Booking confirmed for %s. Remaining seats: %d%n",
passenger, availableSeats);
return true;
}
}
public class TicketBookingDemo {
public static void main(String[] args) throws InterruptedException {
TicketCounter counter = new TicketCounter(5);
Thread first = new Thread(() -> counter.book("Asha", 3), "booking-1");
Thread second = new Thread(() -> counter.book("Ravi", 3), "booking-2");
first.start();
second.start();
first.join();
second.join();
System.out.println("All booking requests have been processed.");
}
}
Sample output (either passenger may be served first):
Asha requested 3 seat(s).
Booking confirmed for Asha. Remaining seats: 2
Ravi requested 3 seat(s).
Booking failed for Ravi; only 2 seat(s) remain.
All booking requests have been processed.
Only one request can perform the availability check and seat deduction at a time. One booking succeeds and the other is rejected; the counter cannot sell more than five seats.
The order of the two passengers is deliberately unspecified. Synchronisation protects correctness but does not guarantee which waiting thread enters first.
The key point: which passenger wins is unpredictable, but the total is not. Remove synchronized and both threads can pass the seatsRequired > availableSeats check before either subtracts, so the counter sells six seats out of five. That is the classic overbooking bug.
Choosing the Lock Object
All threads that protect the same data must use the same lock.
private final Object balanceLock = new Object();
void withdraw(int amount) {
synchronized (balanceLock) {
// check and update the shared balance
}
}
A private final lock object prevents unrelated code from acquiring the lock accidentally. Avoid synchronising on string literals, boxed values, or publicly accessible objects.
Explicit Locks: ReentrantLock
synchronized is not the only lock in Java. The java.util.concurrent.locks package provides Lock objects that are acquired and released by explicit method calls. ReentrantLock has the same basic semantics as an intrinsic monitor, with additional capabilities.
Because an explicit lock is not released automatically when a block ends, it must be released in a finally clause.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class LockedCounter {
private final Lock lock = new ReentrantLock();
private int value;
void increment() {
lock.lock();
try {
value++;
} finally {
lock.unlock(); // released even if the body throws
}
}
int getValue() {
lock.lock();
try {
return value;
} finally {
lock.unlock();
}
}
}
The API documentation recommends that a call to lock() is always followed immediately by a try block whose finally clause calls unlock(). Omitting it leaks the lock: if the body throws, every other thread waiting for that lock waits permanently.
In simple terms: synchronized releases the monitor automatically when control leaves the block, including when an exception propagates. A Lock does not. The finally clause is what replaces that guarantee, which is why it is written at the same time as the call to lock().
What an Explicit Lock Adds
| Capability | Method | Why it matters |
|---|---|---|
| Acquire only if free | tryLock() |
The thread can do other work instead of waiting |
| Acquire with a deadline | tryLock(timeout, unit) |
Bounds the wait; helps avoid deadlock |
| Acquire interruptibly | lockInterruptibly() |
A waiting thread can be cancelled |
| First-come-first-served | new ReentrantLock(true) |
Optional fairness; reduces starvation at a cost in throughput |
| Several wait conditions | newCondition() |
Separate queues, such as "not full" and "not empty" |
if (lock.tryLock(150, TimeUnit.MILLISECONDS)) {
try {
// work with the protected state
} finally {
lock.unlock();
}
} else {
System.out.println("Could not acquire the lock within 150 ms; doing other work instead");
}
The timed form is interruptible and therefore declares InterruptedException.
A thread blocked while entering a synchronized region cannot be interrupted and cannot give up waiting. Those two limits are the practical reasons to choose an explicit lock.
Choosing Between Them
| Situation | Preferred |
|---|---|
| A short critical section guarding one object | synchronized |
| The lock is acquired in one method and released in another | ReentrantLock |
| The program must time out, back off, or be cancelled while waiting | ReentrantLock |
| Several distinct wait conditions on the same data | ReentrantLock with Condition |
| Automatic release and the simplest possible code | synchronized |
Prefer synchronized unless one of the additional capabilities is genuinely required.
Thread Priority
Java assigns each thread an integer priority from 1 to 10.
| Constant | Value |
|---|---|
Thread.MIN_PRIORITY |
1 |
Thread.NORM_PRIORITY |
5 |
Thread.MAX_PRIORITY |
10 |
Use setPriority before or after starting a thread, and getPriority to inspect it.
Thread report = new Thread(() -> System.out.println("Preparing report"));
report.setPriority(Thread.NORM_PRIORITY + 1);
System.out.println(report.getPriority());
report.start();
A new thread normally inherits the priority of the thread that creates it. Supplying a value outside the permitted range causes IllegalArgumentException.
Priority is only a request to the scheduler. Its effect varies by operating system and JVM implementation. A higher-priority thread is not guaranteed to start first, finish first, or receive a fixed share of processor time.
In simple terms: priority is a polite suggestion to the operating system, not an instruction. Some systems act on it, some map several Java levels onto one system level, and some ignore it completely. Never write a program whose output depends on priority.
Priority with the Ticket Example
Priority can be demonstrated, but the booking operation must remain synchronized.
Thread regular = new Thread(() -> counter.book("Regular passenger", 2));
Thread urgent = new Thread(() -> counter.book("Urgent passenger", 2));
regular.setPriority(Thread.NORM_PRIORITY);
urgent.setPriority(Thread.MAX_PRIORITY);
regular.start();
urgent.start();
It is still possible for regular to acquire the lock first. If an application requires a guaranteed order, it must use explicit coordination or a suitable queue rather than priority.
Synchronisation Does Not Replace Coordination
| Requirement | Suitable mechanism |
|---|---|
| Protect a small shared update | synchronized method or block |
| Wait for another thread to finish | join |
| Request cooperative cancellation | interrupt |
| Exchange work in production code | concurrency utilities such as a blocking queue or executor |
| Guarantee task order | an explicit queue, sequence, or dependency |
| Merely suggest scheduling importance | thread priority |
The java.util.concurrent package provides higher-level tools for larger applications. The intrinsic-lock model remains important because it explains mutual exclusion, visibility, and the behaviour of synchronized.
Deadlock Awareness
Deadlock can occur when threads hold different locks and wait indefinitely for one another.
Thread A holds lock 1 and waits for lock 2.
Thread B holds lock 2 and waits for lock 1.
In simple terms: two diners share two chopsticks. Each picks up one and politely waits for the other to release theirs. Neither eats and neither gives up. The usual cure is a rule that everybody picks up the left chopstick first, which is the same idea as always acquiring locks in one consistent order.
Reduce the risk by:
- acquiring multiple locks in one consistent order;
- avoiding unnecessary nested locking;
- keeping locked sections focused; and
- using well-tested concurrency utilities for complex coordination.
Common Mistakes
| Mistake | Correction |
|---|---|
| Synchronising different methods on different objects while protecting the same data | Use one shared lock for the complete invariant. |
| Checking availability outside the lock and updating inside it | Protect the check and update as one atomic critical section. |
Assuming volatile makes value++ atomic |
Use synchronisation or a suitable atomic class for compound updates. |
| Holding a monitor during slow input/output without need | Move unrelated slow work outside the critical section. |
| Depending on priority for execution order | Use explicit coordination. |
Calling sleep while holding a lock and expecting the lock to be released |
sleep does not release monitor locks. |
Calling lock() without releasing in finally |
An exception then leaves the lock held forever. Pair every lock() with try ... finally { unlock(); }. |
Practice
- Run an unsafe shared counter several times and compare its result with a synchronized counter.
- Implement a synchronized bank withdrawal method that prevents a negative balance.
- Modify the ticket-booking example to record every successful passenger name.
- Print the default priority of the main thread and a newly created thread.
- Explain why priority cannot replace synchronisation in the booking program.
Quick Check
1. What is a race condition?
It is a timing-dependent error that occurs when concurrent operations access shared mutable state without adequate coordination.
2. Which monitor is locked by a synchronized instance method?
It locks the monitor of the current object, represented by this.
3. Which monitor is locked by a static synchronized method?
It locks the Class object associated with the class.
4. Does maximum priority guarantee that a thread executes first?
No. Priority is a scheduler hint and its practical effect is platform-dependent.
5. Does Thread.sleep release a synchronized monitor?
No. A sleeping thread continues to hold monitors that it already owns.
6. Why must unlock() be called in a finally clause?
A Lock is not released automatically when control leaves the block. Without finally, an exception would leave the lock held and every thread waiting for it would block permanently.
7. Can a thread acquire a monitor it already holds?
Yes. Intrinsic locks are reentrant; the JVM counts acquisitions and releases the monitor only when the count returns to zero.
Lecture Quiz
Two booking threads check the remaining seats before either one subtracts them. What design error is present?
The check-and-update operation is not protected as one critical section, so both threads can act on the same old value. The entire operation should use the same lock.
Summary
Synchronisation protects shared mutable state by allowing one thread at a time to execute a critical section guarded by a monitor. A synchronized method or block must cover the complete logical update. Intrinsic locks are reentrant, and ReentrantLock offers the same mutual exclusion with timed, interruptible, and fair acquisition at the cost of releasing the lock explicitly in a finally clause. Thread priority expresses a scheduling preference only; correctness and ordering must rely on synchronisation and explicit coordination.