Browse Learn Clojure Foundations as a Java Developer

Translate Java Concurrent Collections to Clojure Data

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?”

Compare The Collection Intent

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.

Replacing A Shared Mutable Map

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.

Snapshot Reads Are A Feature

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.

Queues Are Coordination, Not Just Collections

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.

Atomic Collection Updates

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.

When To Keep Java Concurrent Collections

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.

Knowledge Check

### A Clojure service needs a shared table of event counts and moderate update volume. What is the simplest idiomatic starting point? - [x] An atom containing a persistent map updated with `swap!` - [ ] A global `HashMap` mutated from each request thread - [ ] A `CopyOnWriteArrayList` - [ ] A ref for every event type > **Explanation:** One independent map of counts is a natural atom use case. Refs become useful when several independent references must change together. ### What does a reader get after dereferencing an atom that contains a vector? - [x] A stable immutable snapshot of the current vector value - [ ] A lock that must be manually released - [ ] A mutable copy of the vector - [ ] A Java iterator that fails on concurrent modification > **Explanation:** Dereferencing gives the current value. Standard Clojure vectors are immutable, so that value remains stable for the reader. ### When is a Java `BlockingQueue` still a good fit in Clojure code? - [x] When bounded producer-consumer handoff is part of the runtime contract - [ ] Whenever a function returns a sequence - [ ] Whenever a map has more than one key - [ ] Whenever an atom update might happen concurrently > **Explanation:** `BlockingQueue` is an operational coordination tool, especially for capacity and handoff. It remains useful when those semantics are actually required.
Revised on Saturday, May 23, 2026