Two threads reading the same variable is harmless. Two threads writing the same variable, or one reading while another writes, is where concurrency turns dangerous. The problem is not the operation itself — it is the gap between reading a value and writing it back. Thread A reads count as 5, Thread B reads count as 5 in the same instant, both add one, and both write back 6. The correct result should be 7, but one increment was lost. This is a race condition: the outcome depends on the invisible timing of the scheduler, so it passes tests one day and fails in production the next.
Race conditions are vicious because they are rare. A counter might lose only one update in a million, which makes the bug nearly impossible to reproduce locally and devastating when it finally surfaces under load.
flowchart TD
subgraph "Thread A"
A1["read count = 5"] --> A2["add 1 -> 6"] --> A3["write 6"]
end
subgraph "Thread B"
B1["read count = 5"] --> B2["add 1 -> 6"] --> B3["write 6"]
end
C["count = 5"] --> A1
C --> B1
A3 --> R["count = 6<br/>LOST UPDATE"]
B3 --> R
style R fill:#b45309,color:#fff
The synchronized keyword
Java's simplest tool for preventing races is synchronized. When a thread enters a synchronized method or block, it acquires the intrinsic lock (also called the monitor) on the specified object. No other thread can enter any synchronized region on that same object until the first thread releases the lock by exiting. This turns the critical section — the code that touches shared state — into a sequential bottleneck, which is exactly what you want when correctness matters more than raw speed.
Every object in Java has an intrinsic lock. A synchronized instance method uses this as its lock; a synchronized static method uses the class object. You can also write synchronized(someObject) { ... } to lock on an arbitrary object, which is useful when multiple independent variables need separate guards.
Java locks are reentrant: if a thread already holds a lock, it can enter another synchronized block on the same object without deadlocking itself. This is essential when one synchronized method calls another on the same object — without reentrancy, every such call would freeze the program.
public class Main {
static class Counter {
private int count = 0;
synchronized void increment() {
count = count + 1;
}
int getCount() {
return count;
}
}
public static void main(String[] args) {
Counter c = new Counter();
c.increment();
c.increment();
System.out.println(c.getCount());
}
}
Deadlock: when locks fight back
Synchronization prevents races but introduces a new hazard. A deadlock occurs when Thread A holds Lock 1 and waits for Lock 2, while Thread B holds Lock 2 and waits for Lock 1. Both wait forever. The program does not crash — it simply stops making progress. Deadlocks are harder to spot than races because the system looks idle rather than wrong.
The classic cure is lock ordering: always acquire multiple locks in the same global order. If both threads always try to lock Account A before Account B, one thread gets A first, the other waits, and the second lock is always free when the first is released. Another strategy is to avoid holding multiple locks at once — finish with one resource before reaching for another.
flowchart TD A["Thread A"] -->|"holds"| L1["Lock 1"] A -->|"waits for"| L2["Lock 2"] B["Thread B"] -->|"holds"| L2 B -->|"waits for"| L1 L1 -.->|"circular wait"| B L2 -.->|"circular wait"| A style A fill:#b45309,color:#fff style B fill:#b45309,color:#fff
public class Main {
static class Account {
private int balance;
Account(int balance) {
this.balance = balance; }
int getBalance() { return balance; }
void adjust(int delta) { balance += delta; }
}
public static void main(String[] args) {
Account a = new Account(100);
Account b = new Account(100);
synchronized (a) {
synchronized (b) {
a.adjust(-30);
b.adjust(30);
}
}
System.out.println(a.getBalance());
System.out.println(b.getBalance());
}
}
Atomic variables: synchronization without locking
java.util.concurrent.atomic provides classes like AtomicInteger, AtomicLong, and AtomicReference that offer thread-safe operations without synchronized. Instead of locking the whole object, they use compare-and-swap (CAS) hardware instructions: the CPU checks whether the current value still matches what the thread originally read, and if so, atomically replaces it.
AtomicInteger.incrementAndGet() is faster than a synchronized increment under low contention because no thread is ever blocked. Under high contention, threads spin-retry instead of sleeping, which can waste CPU. For simple counters and flags, atomic classes are the modern default; for complex multi-step updates, synchronized or higher-level constructs like ConcurrentHashMap are still appropriate.
Do not confuse atomic classes with the volatile keyword. volatile guarantees that reads and writes go straight to main memory, preventing one thread from seeing a stale cached value, but it does not make compound operations like i++ atomic. An atomic class gives you both visibility and atomicity; volatile gives you only visibility.
flowchart LR R["Read value = 5"] --> C["Compare: still 5?"] C -->|"yes"| W["Write 6<br/>success"] C -->|"no"| NR["Another thread changed it<br/>re-read and retry"] NR --> C style W fill:#3776ab,color:#fff style NR fill:#c2410c,color:#fff
Best practices for safe concurrency
- Prefer immutability. An immutable object never changes after creation, so many threads can read it safely without any locks.
String,Integer, and records are your friends. If you need to change state, build a new object rather than mutating the old one. - Favour atomic classes over synchronized for simple counters and flags. They are faster and less error-prone because the JVM handles the hard parts.
- Keep critical sections tiny. Hold a lock only for the fewest statements possible; never perform I/O, sleep, or call unknown code while holding a lock. The longer you hold a lock, the more you serialise your program and the higher the deadlock risk.
- Avoid sharing mutable state across threads when possible. Give each thread its own copy, or use thread-local variables. If nothing is shared, nothing can race.
- Always acquire multiple locks in the same order to prevent deadlock. Document the ordering in comments so future maintainers do not accidentally reverse it.
Two unsynchronized threads repeatedly call counter++. What is the likely result?
Without synchronization, both threads can read the same value, increment it, and write back the same result — one update is lost. This is a race condition, and it is intermittent by nature.
What does the synchronized keyword do when a thread enters a synchronized method?
synchronized acquires the monitor (intrinsic lock) associated with the object. Other threads trying to enter any synchronized block on that same object must wait until the lock is released.
Thread A holds Lock X and waits for Lock Y. Thread B holds Lock Y and waits for Lock X. What is this situation called?
This circular waiting is a deadlock. Neither thread can proceed because each holds a resource the other needs. Prevention strategies include consistent lock ordering and holding locks for the shortest possible time.
Why is AtomicInteger.incrementAndGet() often preferred over a synchronized increment method?
Atomic classes use lock-free CAS instructions provided by the CPU. Under low contention, they are faster than synchronized because threads do not block each other.
Which of the following is the safest approach to sharing data across threads?
Immutable objects can be read by many threads simultaneously with no locks, no races, and no deadlocks. This is the simplest and safest concurrency strategy.
Recap
- A race condition occurs when multiple threads access shared mutable state and at least one writes, with no synchronization.
- synchronized acquires an object's intrinsic lock, turning a critical section into a sequential bottleneck.
- Deadlock is circular waiting for locks; prevent it with consistent ordering and short lock duration.
- Atomic classes use hardware compare-and-swap for lock-free thread safety on simple variables.
- Immutability is the safest concurrency strategy: if nothing changes, nothing can race.
With these concepts you now understand how Java handles concurrency safely — from raw threads to executors, from locks to lock-free atomics, and from mutable shared state to immutable design.