On This Page
In a real-time payment platform, dozens of threads hit the same account balance every second — and a single unsynchronized read-modify-write is all it takes to corrupt a ledger without throwing a single exception. Java's synchronized keyword is the foundational mechanism for ensuring that only one thread modifies shared mutable state at a time. Let me walk you through it — step by step.
Prerequisites
- Java 21 LTS recommended; all examples compile and run on Java 11 or later
- IntelliJ IDEA 2024.x or later
- No external dependencies — this tutorial uses only the Java standard library
- Assumed knowledge: basic Java classes,
Thread,Runnable, andExecutorService(if you need a refresher onExecutorService, see How to configure a thread pool with ExecutorService)
What you'll build
By the end of this tutorial you'll have a bank account class that handles concurrent deposits, withdrawals, and cross-account transfers correctly under heavy multi-threaded load. You'll start from a broken, race-condition-prone version to see the problem with your own eyes, then progressively harden it — with synchronized methods, then with synchronized blocks scoped to a private lock object, and finally with a deadlock-safe transfer between two accounts. Along the way you'll also see how synchronized applies to static methods and how it appears in the double-checked locking pattern for lazy initialization.
Step 1 — Reproduce the race condition
Before fixing a concurrency bug you need to see it. Race conditions in financial systems are the worst kind: no exceptions, no obvious errors — just a silently wrong balance at end-of-day reconciliation.
In IntelliJ IDEA: File → New → Java Class (⌘N on Mac / Alt+Insert on Windows/Linux). Set the package to dev.ggorantala.howto.synchronization and the class name to UnsafeBankAccount.
Write this code:
package dev.ggorantala.howto.synchronization;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class UnsafeBankAccount {
private double balance;
public UnsafeBankAccount(double initialBalance) {
this.balance = initialBalance;
}
public void deposit(double amount) {
double current = balance; // (1) read
balance = current + amount; // (2) write — NOT atomic with (1)
}
public double getBalance() {
return balance;
}
public static void main(String[] args) throws InterruptedException {
UnsafeBankAccount account = new UnsafeBankAccount(0.0);
ExecutorService executor = Executors.newFixedThreadPool(10);
// 1000 deposits of £1 each — expected final balance: £1000.0
for (int i = 0; i < 1000; i++) {
executor.submit(() -> account.deposit(1.0));
}
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("Expected balance: 1000.0");
System.out.println("Actual balance: " + account.getBalance());
}
}What's happening here: The deposit method reads balance into a local variable, then writes the incremented value back. Between those two lines, ten threads are doing the exact same thing. Thread A reads balance = 400; Thread B also reads balance = 400 before Thread A has written its result; Thread A writes 401; Thread B writes 401 — and one deposit evaporates. This is a lost update.
Notice the use of double for balance. In production financial code you'd use BigDecimal or integer cents to avoid floating-point imprecision. For this tutorial double is enough to demonstrate the threading issue without adding noise.
Run it: Right-click UnsafeBankAccount in the Project panel → Run 'UnsafeBankAccount.main()', or click the green ▶ gutter icon. Run it three or four times and watch the output change:
Expected balance: 1000.0
Actual balance: 843.0The exact wrong number changes every run — that's the nature of a race condition. You'll never reproduce it deterministically in a debugger, which is why getting synchronized right the first time matters.
Step 2 — Fix it with synchronized methods
Adding synchronized to a method tells the JVM that only one thread may execute that method on a given instance at a time. Every Java object has an intrinsic lock (also called a monitor). When a thread enters a synchronized method it acquires that lock; when it exits — normally or by throwing an exception — it releases it.
In IntelliJ IDEA: File → New → Java Class. Package: dev.ggorantala.howto.synchronization. Class name: SynchronizedBankAccount.
Write this code:
package dev.ggorantala.howto.synchronization;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class SynchronizedBankAccount {
private double balance;
public SynchronizedBankAccount(double initialBalance) {
this.balance = initialBalance;
}
public synchronized void deposit(double amount) {
balance += amount;
}
public synchronized void withdraw(double amount) {
if (balance < amount) {
throw new IllegalStateException(
"Insufficient funds: balance=" + balance + ", required=" + amount
);
}
balance -= amount;
}
public synchronized double getBalance() {
return balance;
}
public static void main(String[] args) throws InterruptedException {
SynchronizedBankAccount account = new SynchronizedBankAccount(0.0);
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
executor.submit(() -> account.deposit(1.0));
}
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("Expected balance: 1000.0");
System.out.println("Actual balance: " + account.getBalance());
}
}What's happening here: synchronized on an instance method is shorthand for synchronized(this) — it uses the intrinsic lock of the current instance. All three methods share that same lock. A thread calling deposit blocks any other thread from calling withdraw or getBalance on the same instance until the deposit completes.
Notice that getBalance() is also synchronized. This is the part engineers most often forget. Without it, a thread reading balance could see a stale or partially-updated value — a visibility problem guaranteed by the Java Memory Model. If you synchronize the writes but not the reads, you've only solved half the problem.
Run it:
Expected balance: 1000.0
Actual balance: 1000.0Every run gives 1000.0 now. The race is gone.
Step 3 — Reduce lock contention with synchronized blocks
Synchronized methods lock the entire method body. In a real payment platform, deposit might do fraud validation, fee calculation, audit logging, and a Kafka event publication — most of which doesn't touch shared mutable state. Holding the lock for all of that serializes threads unnecessarily and tanks throughput under load.
A synchronized block lets you lock only the critical mutation. Using a dedicated private lock object — instead of this — is also safer: it prevents external callers from accidentally acquiring your object's intrinsic lock and stalling it.
In IntelliJ IDEA: File → New → Java Class. Package: dev.ggorantala.howto.synchronization. Class name: BankAccountWithBlock.
Write this code:
package dev.ggorantala.howto.synchronization;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class BankAccountWithBlock {
private double balance;
// Private, final lock object — never exposed, never reassignable
private final Object balanceLock = new Object();
public BankAccountWithBlock(double initialBalance) {
this.balance = initialBalance;
}
public void deposit(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
// Pre-processing runs WITHOUT holding the lock.
// Other threads can still call deposit/withdraw while this runs.
validateWithFraudService(amount);
synchronized (balanceLock) {
// Only the state mutation is inside the block
balance += amount;
}
// Post-processing also runs WITHOUT holding the lock.
publishDepositEvent(amount);
}
public void withdraw(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
synchronized (balanceLock) {
if (balance < amount) {
throw new IllegalStateException(
"Insufficient funds: balance=" + balance + ", required=" + amount
);
}
balance -= amount;
}
publishWithdrawEvent(amount);
}
public double getBalance() {
synchronized (balanceLock) {
return balance;
}
}
// Simulate slow outbound calls that don't touch shared state
private void validateWithFraudService(double amount) { /* remote HTTP call */ }
private void publishDepositEvent(double amount) { /* Kafka publish */ }
private void publishWithdrawEvent(double amount) { /* Kafka publish */ }
public static void main(String[] args) throws InterruptedException {
BankAccountWithBlock account = new BankAccountWithBlock(0.0);
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
executor.submit(() -> account.deposit(1.0));
}
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
System.out.printf("Final balance: %.1f%n", account.getBalance());
}
}What's happening here: The synchronized(balanceLock) block is narrow — only the two or three lines that actually mutate balance are inside it. validateWithFraudService and publishDepositEvent run outside the lock, so ten threads calling deposit concurrently can all be in the validation or publishing phase simultaneously. They only queue up when they reach the synchronized block.
The balanceLock is private and final. Private means external code can't acquire it. Final means you can never accidentally reassign it — because if you did, threads holding the old reference and threads holding the new reference would use different locks, and your protection would silently vanish. That's the kind of bug that survives code review and only appears in production at 3am.
Run it:
Final balance: 1000.0Step 4 — Synchronize static methods (class-level lock)
When you put synchronized on a static method, the lock is the Class object itself (e.g., TransactionIdGenerator.class), not an instance. All static synchronized methods of a class share this one class-level lock, regardless of how many instances of that class exist.
In IntelliJ IDEA: File → New → Java Class. Package: dev.ggorantala.howto.synchronization. Class name: TransactionIdGenerator.
Write this code:
package dev.ggorantala.howto.synchronization;
public class TransactionIdGenerator {
private static long nextId = 0L;
// Lock: TransactionIdGenerator.class — not any instance's monitor
public static synchronized long nextTransactionId() {
return ++nextId;
}
public static void main(String[] args) throws InterruptedException {
Thread[] workers = new Thread[5];
for (int i = 0; i < 5; i++) {
final int workerIndex = i;
workers[i] = new Thread(() -> {
for (int j = 0; j < 3; j++) {
long txnId = TransactionIdGenerator.nextTransactionId();
System.out.println("Worker-" + workerIndex + " → TXN-" + txnId);
}
});
}
for (Thread worker : workers) worker.start();
for (Thread worker : workers) worker.join();
System.out.println("Last issued ID: " + nextId);
}
}What's happening here: static synchronized on nextTransactionId() is exactly equivalent to writing:
public static long nextTransactionId() {
synchronized (TransactionIdGenerator.class) {
return ++nextId;
}
}The lock is the Class object, which exists once in the JVM regardless of how many TransactionIdGenerator instances there are. This is the right choice when the state you're protecting is static — shared across all instances and across the entire JVM.
Here's a trap to be aware of: a synchronized instance method on this same class would use the instance's lock — a completely different lock from TransactionIdGenerator.class. Static and instance synchronized methods on the same class do not mutually exclude each other. If you have both static and instance synchronized code touching the same mutable state, you need to be explicit about which lock they share.
Run it:
Worker-0 → TXN-1
Worker-2 → TXN-2
Worker-1 → TXN-3
Worker-4 → TXN-4
Worker-3 → TXN-5
Worker-0 → TXN-6
Worker-2 → TXN-7
...
Last issued ID: 15Thread order varies between runs, but the IDs are always unique, always sequential, and always cover 1 to 15 without gaps or duplicates.
Step 5 — Thread-safe lazy initialization with double-checked locking
Double-checked locking is one of the most commonly misimplemented patterns in Java concurrency. It appears whenever you need a singleton that's expensive to initialize — a connection pool, a schema registry client, a configuration object — and you want to initialize it lazily on first use without synchronizing every access.
In IntelliJ IDEA: File → New → Java Class. Package: dev.ggorantala.howto.synchronization. Class name: SchemaRegistryClient.
Write this code:
package dev.ggorantala.howto.synchronization;
public class SchemaRegistryClient {
// volatile is REQUIRED — without it, a partially-constructed object
// can be visible to other threads before its constructor finishes
private static volatile SchemaRegistryClient instance;
private final String registryUrl;
private SchemaRegistryClient(String registryUrl) {
this.registryUrl = registryUrl;
System.out.println("Connecting to schema registry: " + registryUrl);
// Imagine: cache warm-up, auth handshake, schema pre-fetch
}
public static SchemaRegistryClient getInstance() {
if (instance == null) { // (1) first check — no lock, fast path
synchronized (SchemaRegistryClient.class) {
if (instance == null) { // (2) second check — under the lock
instance = new SchemaRegistryClient(
"https://schema-registry.prod.internal:8081"
);
}
}
}
return instance;
}
public String lookupSchema(String subject) {
return "Avro schema for [" + subject + "] from " + registryUrl;
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[20];
for (int i = 0; i < 20; i++) {
threads[i] = new Thread(() -> {
SchemaRegistryClient client = SchemaRegistryClient.getInstance();
System.out.println(
Thread.currentThread().getName()
+ " → instance@" + System.identityHashCode(client)
);
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
}
}What's happening here: The first if (instance == null) check at line (1) runs without any lock. When instance is already initialized — which is true for every call after startup — the check fails immediately and the method returns. No lock acquisition, no contention. That's the fast path.
Only when instance is null does the code acquire the lock and check again at line (2). The second check is critical: between line (1) and actually acquiring the lock, another thread might have already constructed the instance. Without the second check, two threads could both pass the first null check and both construct the object — and you'd have two schema registry clients fighting over connections in production.
The volatile keyword on instance is what makes this work on modern CPUs. Without it, the JVM or the hardware is permitted to reorder the write to instance before the constructor has fully finished initializing all its fields. Another thread could see a non-null instance that is only half-constructed. volatile establishes a happens-before relationship that prevents that reordering.
Run it:
Connecting to schema registry: https://schema-registry.prod.internal:8081
Thread-1 → instance@1829164700
Thread-3 → instance@1829164700
Thread-7 → instance@1829164700
Thread-5 → instance@1829164700
...All twenty threads get the same identity hash code. The constructor runs exactly once.
Step 6 — Compound operations and deadlock-free transfers
A transfer between two accounts is a compound operation: debit one, credit the other — atomically. Locking one account's lock and then the other creates a classic deadlock when two threads initiate opposite transfers simultaneously. The fix is to always acquire the two locks in a consistent, predetermined order.
In IntelliJ IDEA: File → New → Java Class. Package: dev.ggorantala.howto.synchronization. Class name: SafeTransferAccount.
Write this code:
package dev.ggorantala.howto.synchronization;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class SafeTransferAccount {
private final String id;
private double balance;
private final List<String> ledger = new ArrayList<>();
private final Object lock = new Object();
public SafeTransferAccount(String id, double initialBalance) {
this.id = id;
this.balance = initialBalance;
ledger.add(String.format("OPEN id=%s balance=%.2f", id, initialBalance));
}
public void deposit(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
synchronized (lock) {
balance += amount;
ledger.add(String.format("DEPOSIT +%.2f balance=%.2f", amount, balance));
}
}
/**
* Transfers {@code amount} from this account to {@code target}.
*
* <p>Lock-ordering rule: always acquire the lock with the lexicographically
* smaller account ID first. This eliminates deadlock regardless of which
* direction the transfer goes or which thread initiates it.
*/
public void transferTo(SafeTransferAccount target, double amount) {
if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
if (this == target) throw new IllegalArgumentException("Cannot transfer to self");
// Determine acquisition order by account ID — consistent across ALL callers
boolean thisFirst = this.id.compareTo(target.id) < 0;
Object firstLock = thisFirst ? this.lock : target.lock;
Object secondLock = thisFirst ? target.lock : this.lock;
synchronized (firstLock) {
synchronized (secondLock) {
if (balance < amount) {
throw new IllegalStateException(
"Insufficient funds: id=" + id
+ " balance=" + balance + " required=" + amount
);
}
balance -= amount;
this.ledger.add(String.format(
"TRANSFER_OUT -%.2f to=%s balance=%.2f", amount, target.id, balance));
target.balance += amount;
target.ledger.add(String.format(
"TRANSFER_IN +%.2f from=%s balance=%.2f", amount, this.id, target.balance));
}
}
}
public double getBalance() {
synchronized (lock) {
return balance;
}
}
public List<String> getLedger() {
synchronized (lock) {
return Collections.unmodifiableList(new ArrayList<>(ledger));
}
}
public static void main(String[] args) throws InterruptedException {
SafeTransferAccount alice = new SafeTransferAccount("ALICE-EUR", 1_000.0);
SafeTransferAccount bob = new SafeTransferAccount("BOB-EUR", 500.0);
ExecutorService executor = Executors.newFixedThreadPool(8);
// 100 concurrent deposits to Alice — +£10 each
for (int i = 0; i < 100; i++) {
executor.submit(() -> alice.deposit(10.0));
}
// 50 concurrent transfers Alice → Bob — £5 each
for (int i = 0; i < 50; i++) {
executor.submit(() -> alice.transferTo(bob, 5.0));
}
// 50 concurrent counter-transfers Bob → Alice — £2 each (opposite direction)
for (int i = 0; i < 50; i++) {
executor.submit(() -> bob.transferTo(alice, 2.0));
}
// 100 concurrent deposits to Bob — +£3 each
for (int i = 0; i < 100; i++) {
executor.submit(() -> bob.deposit(3.0));
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.SECONDS);
// Alice: 1000 + 100×10 − 50×5 + 50×2 = 1850.0
// Bob: 500 + 50×5 − 50×2 + 100×3 = 950.0
System.out.printf("Alice balance: %.2f (expected 1850.00)%n", alice.getBalance());
System.out.printf("Bob balance: %.2f (expected 950.00)%n", bob.getBalance());
System.out.printf("Alice ledger entries: %d%n", alice.getLedger().size());
System.out.printf("Bob ledger entries: %d%n", bob.getLedger().size());
}
}What's happening here: The critical insight is the lock-ordering rule. Whether Thread A calls alice.transferTo(bob, ...) or Thread B calls bob.transferTo(alice, ...), both threads will try to acquire the lock for "ALICE-EUR" first — because "ALICE-EUR" < "BOB-EUR" lexicographically. Thread A gets "ALICE-EUR"'s lock and waits for "BOB-EUR"'s lock. Thread B blocks trying to acquire "ALICE-EUR"'s lock. No circular wait — no deadlock.
Without this ordering, you'd have a classic dining-philosopher-style deadlock: Thread A holds alice.lock and waits for bob.lock; Thread B holds bob.lock and waits for alice.lock. Both threads freeze. No exception is thrown, no timeout fires, your payment pipeline stalls silently at midnight during settlement.
The ledger entries work out to 201 per account: 1 OPEN + 100 deposits + 50 TRANSFER_OUTs + 50 TRANSFER_INs = 201.
Run it:
Alice balance: 1850.00 (expected 1850.00)
Bob balance: 950.00 (expected 950.00)
Alice ledger entries: 201
Bob ledger entries: 201Does this change across Java versions?
Java 8
synchronized has been in Java since version 1.0, so Java 8 behavior is identical to what you've seen above. The volatile-plus-double-checked-locking pattern was broken before Java 5 (pre-JSR-133 memory model) but has been correct since Java 5. If you're reading legacy code that avoids double-checked locking entirely and uses an inner holder class instead, that's a pre-Java-5 workaround:
// Java 8 — inner holder pattern (works, but double-checked locking is fine too)
public class SchemaRegistryClientLegacy {
private SchemaRegistryClientLegacy() { }
// Class is loaded and initialized lazily — JVM guarantees thread-safety
private static class Holder {
static final SchemaRegistryClientLegacy INSTANCE = new SchemaRegistryClientLegacy();
}
public static SchemaRegistryClientLegacy getInstance() {
return Holder.INSTANCE;
}
}This still works in Java 21. It's more lines but eliminates the need for volatile. Neither approach is wrong; double-checked locking with volatile is fine and more commonly seen in modern codebases.
Java 11
No meaningful change to synchronized itself. Java 11 added var, String::strip, HttpClient, and other APIs — none of which affect how synchronized works.
Java 17
No change to synchronized. Java 17 introduced sealed classes, records, and pattern matching for instanceof — but synchronized is unaffected by any of these. You can synchronize on a record instance (records still have intrinsic locks), but records are immutable by design and rarely need synchronization unless you're guarding some external shared state.
Java 21
⚠️ This is important for anyone adopting virtual threads (Project Loom, JEP 444).
When a virtual thread enters a synchronized block or method and then blocks — waiting for a lock, sleeping, or performing blocking I/O — the JVM pins the virtual thread to its carrier (platform) thread. The carrier thread cannot be reassigned to run other virtual threads while it is pinned. This means synchronized combined with any blocking operation inside virtual thread code can negate the scalability benefits of virtual threads entirely.
You can detect pinning with the JVM flag:
-Djdk.tracePinnedThreads=fullThe mitigation is to replace synchronized with ReentrantLock in code that runs on virtual threads:
// Java 21 — prefer ReentrantLock in virtual-thread code to avoid pinning
import java.util.concurrent.locks.ReentrantLock;
public class VirtualFriendlyAccount {
private double balance;
private final ReentrantLock lock = new ReentrantLock();
public void deposit(double amount) {
lock.lock();
try {
balance += amount;
} finally {
lock.unlock(); // always unlock in finally — never forget this
}
}
public double getBalance() {
lock.lock();
try {
return balance;
} finally {
lock.unlock();
}
}
}ReentrantLock doesn't pin virtual threads. JEP 491 ("Synchronize Virtual Threads without Pinning"), targeted for Java 24, is addressing this limitation for synchronized directly — but until you're running on Java 24+, use ReentrantLock in virtual-thread code on hot paths.
Common Mistakes to Avoid
Mistake 1 — Forgetting to synchronize the read
// WRONG — write is synchronized but read is not
public synchronized void deposit(double amount) {
balance += amount;
}
public double getBalance() { // ← unsynchronized read
return balance;
}// CORRECT — both the write and the read must be synchronized
public synchronized void deposit(double amount) {
balance += amount;
}
public synchronized double getBalance() {
return balance;
}Without synchronizing getBalance(), a reading thread may see a stale cached value of balance from its CPU register or L1 cache — not the value another thread just wrote. The Java Memory Model makes no guarantee about visibility across threads unless there's a happens-before relationship, which synchronized provides.
Mistake 2 — Synchronizing on a reassignable field
// WRONG — if 'list' is ever reassigned, threads holding the old reference
// and threads holding the new one use different locks
private List<String> events = new ArrayList<>();
public void addEvent(String event) {
synchronized (events) { // ← DO NOT lock on a reassignable field
events.add(event);
}
}// CORRECT — lock object is final and dedicated
private final Object eventsLock = new Object();
private List<String> events = new ArrayList<>();
public void addEvent(String event) {
synchronized (eventsLock) {
events.add(event);
}
}If the field is ever reassigned (even once, during initialization refactoring), threads that captured a reference to the old object and threads that use the new one will synchronize on different monitors. No compile error, no warning — just broken thread safety.
Mistake 3 — Assuming synchronized collections make compound operations atomic
// WRONG — each individual call is thread-safe, but the compound check-then-act is NOT
List<String> payments = Collections.synchronizedList(new ArrayList<>());
if (!payments.contains("PAY-001")) { // (1) another thread can insert between (1) and (2)
payments.add("PAY-001"); // (2) — duplicate payment!
}// CORRECT — synchronize on the list object for compound operations
List<String> payments = Collections.synchronizedList(new ArrayList<>());
synchronized (payments) {
if (!payments.contains("PAY-001")) {
payments.add("PAY-001");
}
}Collections.synchronizedList wraps each individual method call in a synchronized block, but check-then-act sequences — like contains-then-add — require external synchronization around the entire compound operation.
Mistake 4 — Deadlock from inconsistent lock ordering
// WRONG — Thread A acquires account1.lock then waits for account2.lock
// Thread B acquires account2.lock then waits for account1.lock → DEADLOCK
public void transferTo(BankAccount target, double amount) {
synchronized (this.lock) {
synchronized (target.lock) { // ← lock order depends on who calls whom
// ...
}
}
}// CORRECT — always acquire the lock with the lower ID first (or any consistent rule)
public void transferTo(BankAccount target, double amount) {
boolean thisFirst = this.id.compareTo(target.id) < 0;
Object first = thisFirst ? this.lock : target.lock;
Object second = thisFirst ? target.lock : this.lock;
synchronized (first) {
synchronized (second) {
// ...
}
}
}Deadlock from inconsistent lock ordering produces no exception and no stack trace in production — just threads that silently stop making progress. A thread dump (jstack <pid>) will show threads in BLOCKED state waiting on each other's monitor.
Mistake 5 — Exposing this as the lock
// RISKY — any external code that holds a reference to your account
// can synchronize on it, potentially stalling all its methods
SynchronizedBankAccount account = new SynchronizedBankAccount(1000.0);
synchronized (account) { // ← external caller holding the SAME lock
doUnrelatedWork(); // deposit(), withdraw(), getBalance() all blocked
}// CORRECT — use a private lock object that external code can never reach
private final Object lock = new Object();Exposing this as the lock is acceptable for simple classes used in tightly controlled contexts. In a library or shared infrastructure class — like a bank account used by many callsites — a private lock object eliminates a whole category of accidental interference.
When NOT to use this
When you only need atomic operations on a single variable — reach for AtomicInteger, AtomicLong, or AtomicReference. They use hardware-level compare-and-swap (CAS) and avoid lock acquisition entirely, which is faster under low-to-medium contention.
When you need try-lock, timed waiting, or interruptible waiting — synchronized blocks your thread indefinitely and cannot be interrupted. ReentrantLock gives you tryLock(), tryLock(timeout, unit), and lockInterruptibly(). In a payment timeout scenario where you want "give up after 200ms rather than deadlock", ReentrantLock is the right tool.
In virtual-thread-heavy code (Java 21+) — as covered in the Java 21 section above, synchronized can pin virtual threads to their carrier threads. Prefer ReentrantLock in code that runs on virtual threads and performs any blocking operation inside the lock.
When you have many concurrent readers and few writers — synchronized serializes everyone, readers included. A ReadWriteLock (or StampedLock for optimistic reads) allows multiple threads to read concurrently and only blocks them when a write is in progress. In a read-heavy configuration cache, the difference is dramatic.
Quick Reference Cheat-Sheet
// ── Instance method — locks on 'this' ─────────────────────────────────────
public synchronized void mutate() { /* ... */ }
// ── Static method — locks on MyClass.class ────────────────────────────────
public static synchronized void mutateStatic() { /* ... */ }
// ── Synchronized block on 'this' (same lock as synchronized method) ───────
public void mutate() {
synchronized (this) { /* critical section */ }
}
// ── Synchronized block on a private lock (preferred for libraries) ────────
private final Object lock = new Object();
public void mutate() {
synchronized (lock) { /* critical section */ }
}
// ── Double-checked locking (lazy init) ────────────────────────────────────
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) instance = new Singleton();
}
}
return instance;
}
// ── Deadlock-safe dual-lock acquisition ───────────────────────────────────
boolean aFirst = a.id.compareTo(b.id) < 0;
synchronized (aFirst ? a.lock : b.lock) {
synchronized (aFirst ? b.lock : a.lock) {
// compound operation on both accounts
}
}
// ── Java 21: prefer ReentrantLock in virtual-thread code ──────────────────
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try { /* critical section */ }
finally { lock.unlock(); }
| Scenario | Use |
|---|---|
| One shared mutable int/long | AtomicInteger / AtomicLong |
| Simple shared mutable object | synchronized method or block |
| Need try-lock / timeout | ReentrantLock |
| Many readers, few writers | ReadWriteLock / StampedLock |
| Compound check-then-act | synchronized block around the entire compound op |
| Virtual-thread hot path (Java 21) | ReentrantLock (avoids pinning) |
| Static global state | static synchronized or synchronized(MyClass.class) |
Was this helpful?
If something in this tutorial didn't work for you — wrong output, a compile error I didn't cover, or a step that wasn't clear — drop a comment below and tell me exactly where it broke. I read every comment and I'll fix the article.
And if you're working through a specific Java concurrency problem you'd love a step-by-step guide on, let me know. I write from what engineers are actually stuck on.