Use Java locking knowledge to recognize where Clojure needs an atom, ref transaction, agent, queue, or plain immutable value instead of synchronized blocks and manual unlock discipline.
Java developers learn to ask, “Which lock protects this data?” Clojure pushes a different first question: “Why is this data shared and mutable at all?” That shift matters more than the syntax difference between synchronized and swap!.
Locking: Coordinating access to mutable state so that one thread at a time can observe or change a protected invariant.
In Clojure, you still run on the JVM and can still use Java locks when a Java API requires them. But idiomatic Clojure usually reduces the amount of code that needs mutual exclusion by moving ordinary work into immutable values and isolating state changes behind reference types.
Do not translate Java mechanisms one-for-one. Translate the reason the mechanism was used.
| Java mechanism | What it usually protects | Clojure replacement to consider |
|---|---|---|
synchronized method |
One object’s mutable fields | Atom holding an immutable map when the whole invariant fits in one value |
ReentrantLock |
Explicit critical section | Atom, ref transaction, or a Java lock only at an interop boundary |
Semaphore |
Limited permits for a scarce resource | Java Semaphore, bounded executor, or queue when capacity control is the real requirement |
ReadWriteLock |
Many readers, few writers | Immutable snapshots in an atom so readers do not lock |
| Multiple locks | Cross-object invariant | Refs inside one dosync transaction |
Java locking knowledge remains valuable because it helps you recognize contention, deadlock risk, visibility bugs, and accidental shared state. The Clojure move is to make those risks smaller and more visible.
This Java class uses a monitor to protect one mutable field:
1public final class Counter {
2 private int count = 0;
3
4 public synchronized void increment() {
5 count++;
6 }
7
8 public synchronized int value() {
9 return count;
10 }
11}
The important detail is not just the synchronized keyword. The important detail is the invariant: every read and write of count must go through the same monitor. If another method touches count without synchronization, the class is no longer correct.
In Clojure, the mutable cell is explicit and small. The value inside it is immutable.
1(def counter (atom {:count 0}))
2
3(defn increment! []
4 (swap! counter update :count inc))
5
6(defn value []
7 (:count @counter))
swap! applies a function to the current value and installs the result atomically. The function may be retried, so it must be pure: no logging, I/O, random UUID creation, or database writes inside the function passed to swap!.
The state boundary is now easier to review:
| Question | Java synchronized class | Clojure atom |
|---|---|---|
| Where is mutation? | Hidden inside fields | Visible at the atom |
| What protects updates? | A monitor chosen by convention | Atomic compare-and-set through swap! |
| Can readers keep a stable snapshot? | Only if they copy or keep holding a lock | Yes, dereference returns an immutable value |
| Main risk | Unsynchronized field access or lock-order bugs | Side effects or expensive work inside swap! |
Clojure does not remove every reason for Java locking. Use a Java lock or concurrency utility when the problem is genuinely about a Java resource, not Clojure application state.
Good reasons include:
tryLock, interruptible waiting, fairness, or permit accounting with exact operational behavior.Even then, keep the lock at the boundary. Convert into immutable Clojure data as soon as practical.
1(import '[java.util.concurrent.locks ReentrantLock])
2
3(def lock (ReentrantLock.))
4(def legacy-cache (java.util.HashMap.))
5
6(defn read-legacy-cache [k]
7 (.lock lock)
8 (try
9 (.get legacy-cache k)
10 (finally
11 (.unlock lock))))
This wrapper is intentionally small. The rest of the application should not know about legacy-cache, HashMap, or the lock.
The most common mistake is to write Java-style classes in Clojure and then protect them with ad hoc locks. That keeps the hardest part of Java concurrency while giving up Java’s class-level encapsulation.
| Mistake | Why it hurts | Prefer |
|---|---|---|
| Store mutable Java objects in many vars | No single owner for mutation | One atom/ref/agent that owns the boundary |
Put side effects inside swap! |
Retry can repeat the effect | Compute new state in swap!, perform effects after |
| Use locks for every shared read | Readers become serialized | Dereference immutable snapshots |
| Replace every lock with one atom | Cross-state invariants can tear | Use refs and dosync when values must change together |
| Ignore Java lock contracts in interop | Clojure cannot fix unsafe Java usage | Honor the Java API at the boundary |
Use this quick decision flow during review:
dosync.The practical win is not “no locks anywhere.” The win is that a reader can see where state is, which rule protects it, and which parts of the program are just pure value transformations.