Atomic Variables and Concurrent Collections

Use AtomicInteger, LongAdder, ConcurrentHashMap and BlockingQueue to share state between threads without explicit locks.

SyntaxAtomicLong count = new AtomicLong(); count.incrementAndGet(); map.merge(key, 1L, Long::sum); map.computeIfAbsent(key, k -> load(k));

Most shared state in a service is a counter, a map or a queue. The java.util.concurrent package has thread-safe versions of each that are faster and harder to misuse than wrapping your own code in synchronized.

Atomic variables

AtomicInteger, AtomicLong, AtomicBoolean and AtomicReference perform read-modify-write operations as one indivisible step: incrementAndGet(), updateAndGet(fn) and compareAndSet(expected, next). Compare-and-set only writes if the value is still what you read, and returns false otherwise so you can retry.

For counters updated by many threads and read rarely, such as metrics, LongAdder spreads updates over several cells and is faster under contention. sum() adds them when you read.

ConcurrentHashMap

A plain HashMap shared between threads can lose entries or corrupt itself. ConcurrentHashMap is safe, but only individual method calls are atomic. This is still a race:

if (!cache.containsKey(key)) {        // two threads can both see false
    cache.put(key, loadRates(key));   // and both load
}
cache.computeIfAbsent(key, k -> loadRates(k));   // atomic: loads once
hits.merge(path, 1L, Long::sum);                 // atomic increment per key

Use computeIfAbsent, compute, merge and putIfAbsent for check-then-act logic. Keep the function you pass short and never update the same map from inside it. ConcurrentHashMap does not allow null keys or values, and its iteration order is not defined, so copy into a TreeMap when you need a stable order in a response.

Other collections

  • CopyOnWriteArrayList: copies the array on every write. Great for listener lists that change rarely; terrible for frequently updated data.
  • BlockingQueue (ArrayBlockingQueue, LinkedBlockingQueue): put waits when full, take waits when empty. The standard way to hand work from producers to consumers, with a capacity that applies back-pressure.
  • ConcurrentLinkedQueue and ConcurrentSkipListMap: non-blocking queue and sorted map.

Mistakes to avoid

  • Two atomic variables that must change together are not atomic as a pair. Use a lock or an AtomicReference to an immutable record holding both.
  • Collections.synchronizedMap still needs manual locking while you iterate.
  • An in-memory counter counts one instance only. Behind a load balancer, totals belong in a database or a metrics system.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Count requests per endpoint for a /metrics response with ConcurrentHashMap.merge and LongAdder.
  • Cache exchange rates per currency with computeIfAbsent so a burst of requests calls the rates API once.
  • Hand sign-up events from request threads to a background mailer through a bounded BlockingQueue.

More examples

compareAndSet for the last seats

Five buyers race for two seats and exactly two win. The loop retries only when another thread changed the count between the read and the write.

Example · java
AtomicInteger seatsLeft = new AtomicInteger(2);

boolean book() {
    while (true) {
        int current = seatsLeft.get();
        if (current == 0) return false;
        if (seatsLeft.compareAndSet(current, current - 1)) return true;
        // another thread changed the value first: read again and retry
    }
}

void main() throws Exception {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<Future<Boolean>> attempts = new ArrayList<>();
        for (int i = 0; i < 5; i++) attempts.add(executor.submit(this::book));
        int booked = 0;
        for (Future<Boolean> f : attempts) if (f.get()) booked++;
        IO.println("booked: " + booked + ", refused: " + (5 - booked) + ", seats left: " + seatsLeft.get());
    }
}

// Output:
// booked: 2, refused: 3, seats left: 0

computeIfAbsent loads a cache entry once

Fifty concurrent requests trigger exactly one load, because computeIfAbsent runs the loader atomically for a missing key. With containsKey plus put, several threads could load.

Example · java
AtomicInteger loads = new AtomicInteger();

Map<String, Double> loadRates(String base) {
    loads.incrementAndGet();   // imagine a slow call to a rates API
    return Map.of("USD", 1.27, "EUR", 1.17);
}

void main() throws Exception {
    ConcurrentHashMap<String, Map<String, Double>> cache = new ConcurrentHashMap<>();
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<Future<Double>> lookups = new ArrayList<>();
        for (int i = 0; i < 50; i++) {
            lookups.add(executor.submit(() -> cache.computeIfAbsent("GBP", this::loadRates).get("EUR")));
        }
        for (Future<Double> f : lookups) f.get();
        IO.println("lookups: " + lookups.size() + ", loads: " + loads.get());
        IO.println("GBP to EUR: " + lookups.getFirst().get());
    }
}

// Output:
// lookups: 50, loads: 1
// GBP to EUR: 1.17

A BlockingQueue between producer and consumer

The queue's capacity of two applies back-pressure: put blocks while the mailer is behind. Items come out in the order they went in.

Example · java
void main() throws InterruptedException {
    BlockingQueue<String> emails = new ArrayBlockingQueue<>(2);   // producer waits when full
    Thread mailer = Thread.ofPlatform().name("mailer").start(() -> {
        try {
            while (true) {
                String to = emails.take();   // waits when empty
                if (to.equals("STOP")) break;
                IO.println("sent welcome email to " + to);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
    for (String user : List.of("[email protected]", "[email protected]", "[email protected]", "[email protected]")) {
        emails.put(user);
    }
    emails.put("STOP");   // a marker that tells the consumer to finish
    mailer.join();
    IO.println("queue empty: " + emails.isEmpty());
}

// Output:
// sent welcome email to [email protected]
// sent welcome email to [email protected]
// sent welcome email to [email protected]
// sent welcome email to [email protected]
// queue empty: true

Discussion

  • Be the first to comment on this lesson.