Atomic Variables and Concurrent Collections
Use AtomicInteger, LongAdder, ConcurrentHashMap and BlockingQueue to share state between threads without explicit locks.
AtomicLong 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 keyUse 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):putwaits when full,takewaits when empty. The standard way to hand work from producers to consumers, with a capacity that applies back-pressure.ConcurrentLinkedQueueandConcurrentSkipListMap: 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
AtomicReferenceto an immutable record holding both. Collections.synchronizedMapstill 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
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.
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: 0computeIfAbsent 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.
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.17A 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.
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