RizTech Academy logo
RizTech Academy
ConcurrencyLesson 5 of 835 min

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

  1. Run four threads incrementing a plain int, a volatile int, a synchronized counter and an AtomicLong 50,000 times each. Record all four totals.
  2. Replace AtomicLong with LongAdder and time both under eight threads.
  3. Write the broken containsKey-then-put counting loop and run it on eight threads until you can show a lost update.
  4. Rewrite it with computeIfAbsent and confirm the total is exact.
  5. Rewrite it again with merge and one line.
  6. Put a null value into a ConcurrentHashMap and read the exception.
  7. Share a plain HashMap between four writing threads and see what breaks.
  8. Iterate a CopyOnWriteArrayList while another thread adds to it. Then do the same with ArrayList.
  9. Build a producer and a consumer around a LinkedBlockingQueue with capacity
    1. Confirm the producer blocks.
  10. 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