Learn where Clojure concurrency primitives spend time, including atom retries, STM conflicts, agent queues, blocking work, and the coordination costs Java developers should measure before tuning.
Concurrency overhead is the extra work your program performs because multiple activities must coordinate. In Java, that cost often appears as lock contention, blocked threads, volatile reads, queue waits, or context switching. In Clojure, the same JVM realities still exist, but the hot spots usually move to atom retries, STM conflicts, agent queues, futures, blocking I/O, or repeated data conversion at Java boundaries.
The first performance skill is separating real coordination cost from unfamiliar Clojure syntax.
| Clojure tool | Main overhead to watch |
|---|---|
atom with swap! |
Compare-and-set retries, similar to contended AtomicReference or AtomicInteger updates. |
ref with dosync |
Transaction retries when coordinated refs conflict, similar to optimistic retry logic. |
agent |
Queue wait time and executor saturation, similar to work submitted to an ExecutorService. |
future |
Thread-pool scheduling and blocking joins with @future, similar to Future.get. |
| Persistent collections | Allocation for new values, offset by structural sharing rather than full copying. |
| Java interop | Reflection, boxing, and conversion when adapter work sits in the hot path. |
None of these costs make Clojure “slow” by default. They tell you what to measure.
swap! may call your update function more than once if another thread wins the compare-and-set first. That is correct behavior, but it means update functions must be pure and cheap.
This helper makes retry pressure visible for teaching. It is not a replacement for ordinary swap! in production code.
1(defn counted-swap!
2 [state f & args]
3 (let [attempts (atom 0)]
4 (loop []
5 (swap! attempts inc)
6 (let [old-value @state
7 new-value (apply f old-value args)]
8 (if (compare-and-set! state old-value new-value)
9 {:value new-value
10 :attempts @attempts}
11 (recur))))))
12
13(def counters (atom {:orders 0}))
14
15(counted-swap! counters update :orders inc)
16;; => {:value {:orders 1}, :attempts 1}
If the same pattern regularly reports many attempts under load, the problem is not that atoms are broken. The problem is that too much work is fighting over one location.
Software transactional memory (STM) is useful when multiple refs must change consistently. It is not a general-purpose speed switch.
1(def inventory (ref {"sku-42" 10}))
2(def reservations (ref []))
3
4(defn reserve!
5 [sku customer-id]
6 (dosync
7 (let [available (get @inventory sku 0)]
8 (when (pos? available)
9 (alter inventory update sku dec)
10 (alter reservations conj {:sku sku
11 :customer-id customer-id})
12 true))))
The transaction should contain only the coordinated in-memory decision. Do not call a payment gateway, write a log file, or perform a slow database query inside dosync; retries can repeat the function body.
| Symptom | Better next question |
|---|---|
| CPU is high and throughput stalls | Is this retry contention, or is a pure computation hot path doing too much work? |
| Threads mostly wait | Is the workload bound by the database, network, queue backpressure, or executor capacity? |
| GC pauses grow under load | Which function allocates most, and are lazy sequences retaining more than expected? |
| Latency spikes on agent work | Is the agent queue backing up, or is an action blocking the executor? |
| Java interop dominates profiles | Can reflection, boxing, conversion, or per-item calls move outside the inner loop? |
The fastest state update is often the one you do not coordinate globally.
1(defn count-by-type
2 [events]
3 (reduce (fn [counts event]
4 (update counts (:type event) (fnil inc 0)))
5 {}
6 events))
7
8(def totals (atom {}))
9
10(defn record-batch!
11 [events]
12 (let [batch-counts (count-by-type events)]
13 (swap! totals merge-with + batch-counts)))
Each worker can summarize its own batch with ordinary immutable values, then perform one atom update. That is usually better than calling swap! once per event.
Before changing primitives, ask:
| Question | Why it matters |
|---|---|
| Is the update function pure? | Retries are safe only when the function has no side effects. |
| Is the state too centralized? | A single atom can become a global bottleneck. |
| Is the transaction too wide? | Wide STM transactions create more conflict opportunities. |
| Is blocking work inside coordination? | Blocking holds scarce executor or transaction resources. |
| Are you measuring real workload shape? | Microbenchmarks can hide queueing, I/O, and contention. |