Apply Java Memory Model instincts to Clojure by separating immutable values, safe publication, atom visibility, volatile interop, and the few places where JVM memory rules still dominate correctness.
Clojure does not exempt you from the Java Memory Model (JMM). It runs on the JVM, calls Java code, and uses JVM synchronization mechanisms under its reference types. What Clojure changes is the amount of mutable shared memory you expose directly.
Java Memory Model: The JVM-level set of rules that defines when writes by one thread become visible to another thread and which reorderings are allowed.
For Java engineers, the right mindset is not “forget volatile and synchronization.” It is “use the JMM knowledge to keep mutable publication points explicit.”
Java memory visibility issues usually appear when a thread reads shared mutable state that another thread wrote without a happens-before relationship. Clojure’s immutable values make many reads simpler, but stateful references and Java interop still need clear publication semantics.
| Concern | Java symptom | Clojure shape | Practical rule |
|---|---|---|---|
| Visibility | Thread sees stale field value | Dereference atom/ref/agent, promise, future, or delay | Share through a synchronization-aware abstraction |
| Atomicity | counter++ loses updates |
swap!, alter, send |
Update through the owning reference |
| Ordering | Writes appear out of expected order | Explicit handoff through queues, futures, promises, refs, or agents | Do not rely on timing or sleep |
| Safe publication | Object escapes before fully constructed | Immutable value returned or installed behind a reference | Construct first, publish once |
| Java interop | Mutable Java object read unsafely | Boundary wrapper | Follow the Java API’s thread-safety contract |
The JMM is still the floor. Clojure’s design gives you better defaults above that floor.
This Java example has no reliable visibility guarantee for running:
1public final class Worker {
2 private boolean running = true;
3
4 public void stop() {
5 running = false;
6 }
7
8 public void runLoop() {
9 while (running) {
10 doWork();
11 }
12 }
13}
Java developers typically fix that by using volatile, synchronization, or a higher-level concurrency primitive:
1public final class Worker {
2 private volatile boolean running = true;
3
4 public void stop() {
5 running = false;
6 }
7
8 public void runLoop() {
9 while (running) {
10 doWork();
11 }
12 }
13}
That fixes visibility for the flag, but it does not make compound updates atomic. volatile is not a substitute for protecting an invariant.
In Clojure, an atom can make the state boundary and visibility point explicit:
1(def running? (atom true))
2
3(defn stop! []
4 (reset! running? false))
5
6(defn run-loop! []
7 (while @running?
8 (do-work!)))
The dereference of the atom reads through Clojure’s synchronization-aware reference. The update happens through reset!. The shared flag is not a plain mutable field hidden somewhere in an object graph.
For a counter, the important distinction is atomicity:
1(def counter (atom 0))
2
3(defn increment! []
4 (swap! counter inc))
The Java mistake would be to read, compute, and write through separate operations. In Clojure, swap! owns the read-modify-write loop.
The simplest Clojure publication strategy is to build immutable data completely before another thread can see it.
1(defn build-config [env]
2 {:endpoint (get env "SERVICE_URL")
3 :timeout-ms 2500
4 :retries 3})
5
6(def config (delay (build-config (System/getenv))))
Consumers dereference config:
1(defn endpoint []
2 (:endpoint @config))
The reader gets a fully constructed immutable map. No consumer needs to coordinate access to individual fields.
The JMM becomes visible again when Clojure code holds mutable Java objects, calls Java callbacks, or crosses executor boundaries. Treat those places as concurrency edges.
| Boundary | Risk | Safer pattern |
|---|---|---|
| Mutable Java bean stored in an atom | Atom protects the reference, not internal field mutation | Store immutable Clojure data or replace the bean as a whole |
| Java collection mutated after publication | Readers may observe changing internals | Convert with into before sharing |
| Callback invoked on Java thread pool | Dynamic vars and request context may not follow automatically | Pass explicit immutable context |
Thread/sleep used for coordination |
Timing is not a happens-before rule | Use promise, future, latch, queue, or executor API |
| Lazy sequence crosses threads | Work may run later on a different thread than expected | Realize at the boundary when timing matters |
An atom holding a mutable Java object is a common trap:
1(def users (atom (java.util.ArrayList.)))
2
3(defn add-user! [user]
4 (.add @users user))
The atom protects only replacement of the ArrayList reference. It does not make .add safe. Prefer storing an immutable vector and updating it through swap!:
1(def users (atom []))
2
3(defn add-user! [user]
4 (swap! users conj user))
When reviewing Clojure concurrency code with Java memory-model instincts, ask:
| Review question | Why it matters |
|---|---|
| Is shared state behind an atom, ref, agent, promise, future, channel, queue, or documented Java abstraction? | Plain shared mutation needs a visibility story. |
| Does the update need atomic read-modify-write semantics? | Use swap!, alter, or a Java atomic/concurrent class rather than separate read and write steps. |
| Are mutable Java objects converted before publication? | Clojure references do not protect internal mutation of Java objects. |
| Are lazy values crossing thread boundaries intentionally? | Realization timing can move work and side effects. |
| Is coordination expressed with a real handoff primitive? | Sleeping and polling are not reliable synchronization. |
The strongest Clojure code makes the happens-before story boring: immutable data is safe to share, and mutable publication points are small enough to audit.