Atomics and the concurrent collections
Locks work. They are also easy to hold too long, easy to forget, and easy to
take in the wrong order. The classes in java.util.concurrent exist so that for
most everyday problems you do not write a lock at all.
Atomics
private final AtomicLong delivered = new AtomicLong();
void record(Delivery delivery) {
delivered.addAndGet(delivery.tiffins());
}
No synchronized, and the total is exact. Running four threads doing 50,000
increments each:
plain int++ expected 200000, got 55453
volatile int++ expected 200000, got 66348
synchronized expected 200000, got 200000
AtomicLong expected 200000, got 200000
The second row is the one worth staring at. volatile fixed visibility and
still lost two thirds of the updates, because ++ is three operations and
volatile does not join them. Atomics do.
How, briefly
Underneath is a CPU instruction — compare-and-swap. "If this location still holds the value I read, replace it; otherwise tell me it changed." When it fails, the class retries with the new value.
// roughly what incrementAndGet does
long current;
do {
current = get();
} while (!compareAndSet(current, current + 1));
No lock, no blocking, no thread ever waiting on another. Under contention it spins instead of sleeping, which is faster for short operations and worse for long ones — a useful thing to know, because it is why atomics suit counters and not complex updates.
The family
| Class | For |
|---|---|
AtomicInteger, AtomicLong |
counters, sequence numbers |
AtomicBoolean |
a flag with test-and-set |
AtomicReference<T> |
swapping an object atomically |
LongAdder |
a counter under heavy contention |
LongAdder is worth knowing about. AtomicLong under many threads has them all
retrying against one memory location; LongAdder keeps several internal cells
and sums them when you ask. Faster for counting, slower to read, and exactly
right for metrics.
private final LongAdder requests = new LongAdder();
requests.increment();
long total = requests.sum();
The one you will reach for
private final AtomicReference<Config> config = new AtomicReference<>(Config.defaults());
void reload(Config next) {
config.set(next);
}
Config current() {
return config.get();
}
Swapping an immutable object atomically is the cleanest way to hot-reload configuration. Readers never block, never see a half-built object, and there is no lock to forget.
Concurrent collections
A plain HashMap used from several threads is not merely inaccurate — it can
corrupt its internal structure and, historically, spin forever on a lookup.
Never share one.
ConcurrentHashMap
private final Map<String, LongAdder> tiffinsPerCustomer = new ConcurrentHashMap<>();
Safe for any number of threads, and much faster than
Collections.synchronizedMap because it locks small sections rather than the
whole map.
The trap is that individual operations are atomic and sequences are not:
// broken: two threads can both see absent and both put
if (!map.containsKey(customer)) {
map.put(customer, new LongAdder());
}
map.get(customer).increment();
Two threads can both pass the containsKey, both put, and one LongAdder
— along with whatever it had counted — is thrown away.
The atomic methods exist for exactly this:
map.computeIfAbsent(customer, k -> new LongAdder()).increment();
One call, atomic, and the lambda runs at most once per missing key.
The ones worth knowing:
| Method | Does |
|---|---|
putIfAbsent(k, v) |
puts only if the key is absent |
computeIfAbsent(k, fn) |
the above, but the value is built lazily |
compute(k, fn) |
atomically replace based on the current value |
merge(k, v, fn) |
put, or combine with what is there |
getOrDefault(k, d) |
read without inserting |
// counting, in one atomic line
counts.merge(customer, delivery.tiffins(), Integer::sum);
Rule: if you find yourself reading from a concurrent map and then writing based on what you read, you want one of these methods instead.
Two more things about ConcurrentHashMap: it does not allow null keys or
values — deliberately, because get returning null would be ambiguous between
"absent" and "present and null" — and its iterators are weakly consistent,
meaning they never throw ConcurrentModificationException but may or may not
reflect changes made after iteration started.
CopyOnWriteArrayList
Every write copies the whole array. That sounds appalling and is exactly right for one shape: many reads, very few writes.
private final List<DeliveryListener> listeners = new CopyOnWriteArrayList<>();
Module 12's observer used this, and the reason is now precise: iteration reads a
snapshot, so a listener unsubscribing itself mid-notification cannot throw
ConcurrentModificationException. Listeners are registered at startup and read
on every event — the ratio it is built for.
Use it for a list of handlers. Do not use it for anything you append to in a loop; copying a 10,000-element array per write is as bad as it sounds.
BlockingQueue
The classic producer–consumer tool, and the thing that makes a work queue easy:
BlockingQueue<Delivery> queue = new LinkedBlockingQueue<>(1000);
// producer — blocks if the queue is full, which is back-pressure for free
queue.put(delivery);
// consumer — blocks until something arrives
Delivery next = queue.take();
No locks, no polling, no sleeping. The bounded capacity matters: an unbounded queue under a producer faster than its consumer is a memory leak with extra steps.
Choosing
| Want | Use |
|---|---|
| a shared map | ConcurrentHashMap |
| a list of listeners | CopyOnWriteArrayList |
| hand work between threads | LinkedBlockingQueue |
| a counter | AtomicLong, or LongAdder under contention |
| a shared immutable object you swap | AtomicReference |
| a snapshot nobody mutates | List.copyOf — no concurrency needed at all |
That last row is the best answer whenever it applies.
Check your work
Why volatile did not fix the counter: ++ is three operations, and
volatile makes each see fresh memory without joining them.
How atomics work: compare-and-swap, retrying when the value changed — non-blocking, so no thread waits.
When LongAdder beats AtomicLong: heavy contention, where many threads
retrying one location costs more than summing several cells.
Why a plain HashMap must never be shared: it can corrupt its internal
structure, not merely lose updates.
The ConcurrentHashMap trap: each operation is atomic, sequences are not —
containsKey then put is a race.
What to use instead: computeIfAbsent, merge, compute, putIfAbsent.
Why it forbids null: get returning null would be ambiguous.
What CopyOnWriteArrayList is for: many reads, very few writes — a listener
list, not an accumulator.
What a bounded BlockingQueue gives you: back-pressure, and no unbounded
memory growth.
Practice
- Run four threads incrementing a plain
int, avolatile int, asynchronizedcounter and anAtomicLong50,000 times each. Record all four totals. - Replace
AtomicLongwithLongAdderand time both under eight threads. - Write the broken
containsKey-then-putcounting loop and run it on eight threads until you can show a lost update. - Rewrite it with
computeIfAbsentand confirm the total is exact. - Rewrite it again with
mergeand one line. - Put a
nullvalue into aConcurrentHashMapand read the exception. - Share a plain
HashMapbetween four writing threads and see what breaks. - Iterate a
CopyOnWriteArrayListwhile another thread adds to it. Then do the same withArrayList. - Build a producer and a consumer around a
LinkedBlockingQueuewith capacity- Confirm the producer blocks.
- Make that queue unbounded, let the producer outrun the consumer, and watch the heap.
Next: executors, and the threads you do not create yourself.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship