Compare ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue, and Java atomic collection patterns with Clojure's persistent data structures and explicit state references.
Java concurrent collections solve a real problem: ordinary mutable collections are not safe for unsynchronized concurrent access. Clojure starts from a different place. Its standard maps, vectors, sets, and lists are persistent immutable data structures, so readers can share values without protecting each read.
The translation question is not “Which Clojure collection is the concurrent version of ConcurrentHashMap?” It is “Do I need a shared mutable collection, or do I need an immutable value that is occasionally replaced atomically?”
| Java collection | Common Java use | Clojure-first alternative | Keep the Java tool when |
|---|---|---|---|
ConcurrentHashMap |
Shared mutable lookup table with atomic key operations | Atom holding a persistent map | Java callers require concurrent map semantics or very high mutation throughput |
CopyOnWriteArrayList |
Read-heavy observer/listener list | Atom holding a vector, replaced with conj/remove |
Java API requires listener list semantics |
BlockingQueue |
Producer-consumer handoff with backpressure | Agent, future, promise, core.async channel, or Java queue | Blocking queue semantics are operationally correct and simple |
ConcurrentLinkedQueue |
Non-blocking multi-producer queue | Explicit queue abstraction at boundary | You need lock-free Java queue behavior |
AtomicReference to a collection |
Replace collection snapshot atomically | Atom | You are already in Java code |
Clojure persistent collections are not “synchronized collections.” They are immutable values. Concurrency safety comes from not changing the value in place.
A Java cache counter often begins like this:
1import java.util.concurrent.ConcurrentHashMap;
2import java.util.concurrent.atomic.LongAdder;
3
4public final class EventCounts {
5 private final ConcurrentHashMap<String, LongAdder> counts =
6 new ConcurrentHashMap<>();
7
8 public void record(String eventType) {
9 counts.computeIfAbsent(eventType, k -> new LongAdder()).increment();
10 }
11
12 public long count(String eventType) {
13 LongAdder adder = counts.get(eventType);
14 return adder == null ? 0L : adder.sum();
15 }
16}
That design can be excellent for a hot Java counter table. But if the Clojure application only needs a simple shared map, an atom is easier to audit:
1(def event-counts (atom {}))
2
3(defn record! [event-type]
4 (swap! event-counts update event-type (fnil inc 0)))
5
6(defn count-for [event-type]
7 (get @event-counts event-type 0))
The map itself is immutable. Each swap! computes a new map version that shares structure with the old one. Readers that already dereferenced the atom keep a stable snapshot.
Java developers often reach for CopyOnWriteArrayList when iteration must not fail during modification. In Clojure, immutable snapshots are normal:
1(def listeners (atom []))
2
3(defn add-listener! [f]
4 (swap! listeners conj f))
5
6(defn publish! [event]
7 (doseq [listener @listeners]
8 (listener event)))
The doseq iterates over the vector value returned by dereferencing listeners. If another thread adds a listener during publication, the current publication still sees the old stable vector. That is often exactly what a Java CopyOnWriteArrayList was trying to provide.
If listener callbacks can block or throw, handle that as a separate policy. Do not hide callback failure inside the state update.
BlockingQueue is different from ConcurrentHashMap because it is not only storing data. It is coordinating producers and consumers.
1BlockingQueue<Job> jobs = new ArrayBlockingQueue<>(100);
2
3jobs.put(job); // blocks when full
4Job next = jobs.take(); // blocks when empty
Clojure has several possible shapes, and the right answer depends on the operational contract:
| Need | Good fit | Why |
|---|---|---|
| One ordered asynchronous state owner | Agent | Updates are queued per agent. |
| One background result | Future or promise | Simple completion handoff. |
| Bounded producer-consumer backpressure | Java BlockingQueue or core.async channel |
Capacity is part of the contract. |
| Pure transformation pipeline | Lazy/sequence/transducer pipeline | No shared mutable queue needed. |
| Interop with Java worker framework | Java queue/executor | Honor the existing framework. |
Do not be afraid to use BlockingQueue from Clojure when it is the clearest tool. The mistake is using it because every Java design had a queue, not because this design needs blocking handoff semantics.
Most collection updates in Clojure should be described as pure transformations:
1(def sessions (atom {}))
2
3(defn open-session! [id user]
4 (swap! sessions assoc id {:user user
5 :status :open}))
6
7(defn close-session! [id]
8 (swap! sessions update id assoc :status :closed))
9
10(defn active-users []
11 (->> @sessions
12 vals
13 (filter #(= :open (:status %)))
14 (map :user)
15 set))
active-users does not need a lock because it works from one immutable snapshot. If a new session opens while the function runs, that newer value belongs to a later snapshot.
Prefer Clojure data and references for application state, but keep Java concurrent collections when they match the real runtime shape.
| Keep Java collection when… | Example |
|---|---|
| Java code owns the data structure | Servlet container, scheduler, Netty, JDBC pool, or library callback registry |
| Mutation rate is extremely high and localized | Hot metrics counters, caches, or deduplication sets |
| The API contract is a blocking handoff | Bounded worker queues |
| You need weak/soft reference behavior from a Java cache | Interop with cache libraries |
| You need a specific lock-free algorithm | Specialized low-latency infrastructure |
Use the Java structure behind a small boundary and return immutable Clojure values to the rest of the code.