Java Synchronized Methods and Blocks
Use synchronized methods and synchronized blocks in Java to protect critical sections — and pick the right lock object.
Java Synchronized Methods and Blocks
The previous chapter established what synchronized does. This one is the syntactic chapter — the three forms the keyword can take, what lock each form uses, and how to pick the right one. The form you choose has performance and correctness consequences; "just slap synchronized on the method" works in trivial cases and fails when the class grows.
Three forms, three lock objects
| Form | Lock object | When to use |
|---|---|---|
synchronized void method() | this | Quick, small classes. Public lock is OK. |
synchronized static void method() | ClassName.class | Mutating per-class state from any instance. |
synchronized (obj) { ... } | obj | Almost everything else. Use a private lock for safety. |
The third form is the most flexible. The first two are sugar for it.
synchronized on an instance method
public synchronized void deposit(int x) {
balance += x;
}Compiles to a block that locks on this. Only one thread at a time may be executing any synchronised instance method on this specific object. (Different Account instances have different this references and therefore different monitors.) Static methods and unsynchronised methods are unaffected.
The pitfall. this is part of the public reference. Any code that has a handle to the object can do synchronized (account) { ... } and hold the same lock as account.deposit(). That includes test harnesses, debuggers, framework code, and any other call site you don't control. A misbehaving caller can hold your lock for as long as they want and you'll be starved.
In small classes you'll be the only caller — fine. In libraries, in code other people will use, or in classes you might later refactor, prefer a private lock object.
synchronized on a static method
public class Counters {
private static int total;
public static synchronized void bump() {
total++;
}
}Compiles to a block that locks on Counters.class. The monitor is global per class — every thread, every instance, contends for the same lock when calling bump(). The same caveat as this applies: any other code can also synchronized (Counters.class) { ... } and hold the lock.
For per-class state, this form is fine in small utility classes. For larger ones, prefer a private static lock:
public class Counters {
private static final Object LOCK = new Object();
private static int total;
public static void bump() {
synchronized (LOCK) { total++; }
}
}synchronized on an explicit object — the production form
public class Cache {
private final Object lock = new Object();
private final Map<String, String> data = new HashMap<>();
public String get(String k) {
synchronized (lock) {
return data.get(k);
}
}
public void put(String k, String v) {
synchronized (lock) {
data.put(k, v);
}
}
}Two properties this form gives you:
- Private lock. No caller can acquire it; nobody can starve you.
- Surgical scope. Only the inside of the block holds the lock. Everything outside — argument validation, return-value formatting, logging — runs without contention.
For the same reason you keep private final fields private, you keep your lock private. The lock object is part of your implementation, not your interface.
Rule: keep the critical section tight
The more code that runs while holding a lock, the more contention you create. The right pattern is to do the minimum necessary inside the block:
// Bad: I/O inside the lock — everybody waits while one thread talks to disk
public synchronized void load(String k) {
String v = Files.readString(Path.of("/tmp/" + k)); // bad
cache.put(k, v);
}
// Good: read outside the lock, lock only the mutation
public void load(String k) throws IOException {
String v = Files.readString(Path.of("/tmp/" + k));
synchronized (lock) {
cache.put(k, v);
}
}The general principle: lock only while modifying shared state, never while doing arbitrary work that could block.
Compound actions and double-locking
synchronized protects one block. If two operations together must be atomic, both must be in the same block:
// Wrong: the if and the put are individually synchronised by HashMap... no they're not,
// but even if they were, the gap between them is not.
if (!map.containsKey(k)) { // someone else could insert here
map.put(k, v);
}
// Right: one block protects both ops
synchronized (lock) {
if (!map.containsKey(k)) {
map.put(k, v);
}
}
// Even better: a single atomic operation
map.putIfAbsent(k, v); // for ConcurrentHashMap, fully atomicThe race between containsKey and put — known as the check-then-act race — is the source of more concurrency bugs than locking itself. Whenever you write if (...) doThing(), ask: between the if and the doThing, can another thread change the answer? If yes, atomicise.
Locks don't compose — beware lock ordering
Two synchronized blocks acquired in different orders by different threads can deadlock:
// Thread A
synchronized (account1) {
synchronized (account2) { transfer(account1, account2, 100); }
}
// Thread B simultaneously
synchronized (account2) {
synchronized (account1) { transfer(account2, account1, 100); }
}Each thread holds one lock and waits for the other. Both threads are BLOCKED forever. The fix is consistent ordering — always acquire locks in the same global order:
void transfer(Account a, Account b, int x) {
Account first = a.id() < b.id() ? a : b; // ordering by stable key
Account second = a.id() < b.id() ? b : a;
synchronized (first) {
synchronized (second) {
a.debit(x);
b.credit(x);
}
}
}Hash-based ordering, System.identityHashCode-based ordering, or a tie-breaker lock are the three usual approaches. The deadlock chapter covers them in depth.
What about synchronized on a primitive?
You can't. synchronized requires an object — a long or int has no monitor. Box it (Long/Integer) and you can syntactically lock on it, but never do this: boxed primitives in the auto-boxing cache are shared. Two pieces of code that lock Integer.valueOf(1) are locking the same object — even if they have nothing to do with each other.
synchronized (Integer.valueOf(1)) { // never do this
...
}For lock objects, always allocate a private Object. The whole point of a monitor is identity, not value.
synchronized and exceptions
If the body of a synchronised block throws, the monitor is released as the exception unwinds. You don't need a finally for the unlock — the JVM handles it. That's one of the main reasons synchronized is hard to misuse: there's no "lock leak" the way there is with Lock.lock() (where the unlock is a separate method call).
The flip side: any shared state you've half-modified inside the block is visible to the next acquirer. If the exception leaves your invariants broken, the lock alone doesn't save you — restore invariants in the catch or design the mutation so it can't half-complete.
A worked example: the four forms side by side
The program below uses each form against the same shared state and ends with a side-by-side comparison.
What to take from the run:
- All three counter forms produced the expected count
800,000. Each form chose a different lock object (this, a privateObject, theClass) but each protected the read-modify-write the same way.synchronizeddoesn't care what the lock object is — only that every contending thread uses the same one. - The static-method form (V3) used the
V3.classmonitor as the lock. Every thread, every test, every other piece of code that synchronises onV3.classwould contend for the same lock. That's appropriate for per-class state; using it for per-instance state is a contention bug — you'd be locking unrelated work against itself. - The static and instance-method forms are convenient but lock on a publicly accessible object (
thisor theClass). Anybody cansynchronized (someObject)and hold the same monitor. The private-lock-object form (V2) is what production code uses precisely because nobody outside the class can reach the lock. - The V4 class (defined but not benchmarked above) shows the wrong shape: I/O-like work inside the critical section. The next-correct version pulls the formatting and the (simulated) blocking call outside the
synchronizedblock so contention is only over the actualput. The same correctness, with much higher throughput under load. - The double-lock block at the end acquired two unrelated locks in the order determined by
System.identityHashCode. That ordering rule, applied everywhere in the program, is the simplest deadlock-prevention strategy when you must hold two locks at once. We'll see it again in the deadlock chapter.
What's next
The next chapter, Java Inter-Thread Communication, introduces the other half of the intrinsic-monitor API — wait, notify, and notifyAll — the way threads signal each other inside a critical section.
Practice
You write `public synchronized void deposit(int x)` on a method of `class Account`. Which monitor does the method acquire?