Atomic Operations: Defuse the Race Condition

java java21 scala scala3 kotlin atomicity atomics concurrency compare-and-set longadder

Two customers press Buy at almost the same moment, and there is only one ticket left. If both requests read “1 remaining” before either one writes back “0 remaining,” you have a race condition: two happy customers, one unhappy support team, and a database row that now tells a lie.

The Problem / Context

For Scala developers, this is the classic shared-mutable-state problem in JVM clothing. In Java, Kotlin, and Scala, the dangerous part is not reading a value or writing a value by itself. The dangerous part is doing a read, then change, then write sequence while another thread is doing the same thing.

A tiny example is counter++:

volatile int counter = 0;
counter++;

That looks like one action, but the JVM treats it as three steps:

  1. Read counter
  2. Add 1
  3. Write the new value back

If two threads both read 0, both compute 1, and both write 1, one update is lost.

Thread A Thread B Shared value
reads 0 reads 0 0
computes 1 computes 1 0
writes 1 writes 1 1

volatile helps with visibility: one thread sees another thread’s latest write. It does not make a compound action like counter++ atomic. That is why the tests for this post include a deterministic demo that still loses one increment even with a volatile field.

Basic Atomic Tools

The core JVM atomics solve slightly different problems. In the ticket example, each one plays a different role.

Type Good for Ticket example use
AtomicInteger Small mutable numeric state A displayed queue size or tickets remaining counter
AtomicLong Exact numeric totals Total revenue in cents
AtomicBoolean On/off state A soldOut flag
AtomicReference<T> Replacing an immutable snapshot safely Publishing a new TicketSnapshot
LongAdder Hot counters with heavy contention Claim-attempt metrics

The key mindset is simple: atomics protect one value at a time. If your whole business change can be represented as “replace the old snapshot with this new snapshot only if nobody changed it first,” AtomicReference becomes very powerful.

The Solution / Implementation

The repository examples use one immutable TicketSnapshot value and publish updates with compareAndSet. That keeps the running example the same in Java 21, Scala 3, and Kotlin.

public boolean claimTicket(String buyer) {
    var normalizedBuyer = normalizeBuyer(buyer);
    claimAttempts.increment();
    while (true) {
        var observed = ticketState.get();
        if (!observed.sellingOpen() || observed.ticketsRemaining() == 0) {
            soldOut.set(observed.ticketsRemaining() == 0);
            return false;
        }
        var updated = observed.sellTo(normalizedBuyer);
        if (ticketState.compareAndSet(observed, updated)) {
            totalRevenueInCents.addAndGet(ticketPriceInCents);
            displayedQueueSize.set(updated.ticketsRemaining());
            soldOut.set(updated.ticketsRemaining() == 0);
            return true;
        }
    }
}

View full Java example

def claimTicket(buyer: String): Boolean =
  val normalizedBuyer = normalizeBuyer(buyer)
  claimAttempts.increment()
  @tailrec
  def attempt(): Boolean =
    val observed = ticketState.get()
    if !observed.sellingOpen || observed.ticketsRemaining == 0 then
      soldOut.set(observed.ticketsRemaining == 0)
      false
    else
      val updated = observed.sellTo(normalizedBuyer)
      if ticketState.compareAndSet(observed, updated) then
        totalRevenueInCents.addAndGet(ticketPriceInCents)
        displayedQueueSize.set(updated.ticketsRemaining)
        soldOut.set(updated.ticketsRemaining == 0)
        true
      else attempt()
  attempt()

View full Scala example

fun claimTicket(buyer: String): Boolean {
    val normalizedBuyer = normalizeBuyer(buyer)
    claimAttempts.increment()
    while (true) {
        val observed = ticketState.get()
        if (!observed.sellingOpen || observed.ticketsRemaining == 0) {
            soldOut.set(observed.ticketsRemaining == 0)
            return false
        }
        val updated = observed.sellTo(normalizedBuyer)
        if (ticketState.compareAndSet(observed, updated)) {
            totalRevenueInCents.addAndGet(ticketPriceInCents)
            displayedQueueSize.set(updated.ticketsRemaining)
            soldOut.set(updated.ticketsRemaining == 0)
            return true
        }
    }
}

View full Kotlin example

A Complete Java AtomicReference Example

The complete Java version keeps the ticket count, selling status, and last buyer in one immutable TicketSnapshot. Each buyer reads the current snapshot, creates a replacement, and publishes it only when compareAndSet confirms that no other buyer changed the state first.

import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;

record TicketSnapshot(int ticketsRemaining, boolean sellingOpen, String lastBuyer) {
    TicketSnapshot sellTo(String buyer) {
        var updatedRemaining = ticketsRemaining - 1;
        return new TicketSnapshot(updatedRemaining, updatedRemaining > 0, buyer);
    }
}

public final class AtomicReferenceTicketOffice {
    private final AtomicReference<TicketSnapshot> state;

    public AtomicReferenceTicketOffice(int initialTickets) {
        if (initialTickets < 0) {
            throw new IllegalArgumentException("initialTickets cannot be negative");
        }
        state = new AtomicReference<>(
                new TicketSnapshot(initialTickets, initialTickets > 0, null));
    }

    public boolean claimTicket(String buyer) {
        Objects.requireNonNull(buyer, "buyer cannot be null");
        var normalizedBuyer = buyer.trim();
        if (normalizedBuyer.isBlank()) {
            throw new IllegalArgumentException("buyer cannot be blank");
        }

        while (true) {
            var observed = state.get();
            if (!observed.sellingOpen()) {
                return false;
            }

            var updated = observed.sellTo(normalizedBuyer);
            if (state.compareAndSet(observed, updated)) {
                return true;
            }
        }
    }

    public TicketSnapshot snapshot() {
        return state.get();
    }
}

Here, TicketSnapshot is a Java record, so the state is replaced rather than mutated. The full runnable implementation is available in the repository.

Compare-and-Set: “Only If Nothing Changed”

compareAndSet is the crucial idea here:

  1. Read the current snapshot.
  2. Build the new snapshot you want.
  3. Replace it only if the old snapshot is still the same one you saw.
  4. If another thread got there first, retry with the newer state.

That retry loop is why two buyers cannot both win the last ticket in the repository tests. One thread swaps the snapshot from 1 remaining to 0 remaining; the other thread re-reads the newer snapshot and immediately sees that the sale is already over.

The Boundary of Atomicity

One atomic variable does not make a whole workflow atomic.

In the example code, SplitAtomicTicketOffice uses one atomic counter and one atomic flag. That still leaves a gap where another thread can observe:

  • remainingTickets == 0
  • soldOut == false

That sounds impossible in business terms, but it is perfectly possible in code if you update those two atomics in separate steps. This is the boundary of atomicity: one protected variable is not the same thing as one protected transaction.

If several fields must change together, model them as one immutable value and swap that value atomically with AtomicReference, or move the whole update into a stronger coordination mechanism.

Choosing a Counter: AtomicLong or LongAdder?

Both work, but they are optimized for slightly different goals.

Counter type Best when Trade-off
AtomicLong You need an exact running total after every update Contended updates all hit the same memory location
LongAdder Many threads hammer a metric such as attempts, retries, or requests sum() is not a single compare-and-set style value update

In the ticket example, revenue is a good AtomicLong: it is a business total. Claim attempts are a good LongAdder: they are a hot metric, not the source of truth for ticket ownership.

Scala and Kotlin Mental Model

Scala and Kotlin are not magically free from JVM atomicity rules. They both use the same underlying memory model, so immutable values still need a safe publication mechanism when multiple threads replace shared state.

Language Typical style What still matters
Java 21 AtomicReference, AtomicLong, LongAdder Be explicit about safe publication and contention
Scala 3 Immutable case classes plus Java atomics Immutability helps reasoning, but shared updates still need coordination
Kotlin Data classes plus Java atomics from java.util.concurrent.atomic Read-only data does not prevent races on shared references

So the cross-language lesson is the same: immutable snapshots make state easier to reason about, and atomics make replacing those snapshots safe.

What the Tests Prove

The runnable tests in all three modules check three practical claims:

  1. A volatile counter can still lose one increment.
  2. Exactly one buyer can claim the last ticket when we use compare-and-set.
  3. Two separate atomic fields do not turn a whole sequence into one atomic action.

That gives you a compact interview-ready story with real code instead of vague concurrency folklore.

Best Practices for Atomic Operations

  • Choose the smallest atomic type that matches the job: use AtomicInteger or AtomicLong for exact counters, AtomicReference for replacing an immutable state snapshot, and LongAdder for highly contended metrics.
  • Keep the value held by an AtomicReference immutable. Build a new snapshot for each successful update instead of mutating the object that other threads may already be reading.
  • Put the complete business decision inside the compare-and-set retry loop. Check the current state, calculate the next state, and publish it only if the state has not changed since you read it.
  • Do not combine several independent atomic variables and assume the whole sequence is atomic. If readers must see fields change together, store them in one immutable object and update it through one AtomicReference.
  • Use locks or higher-level concurrency tools when the operation involves many resources, blocking work, or coordination that is difficult to express as a short atomic update.
  • Treat atomic classes as coordination tools, not a replacement for good domain design. Give shared state clear ownership, keep critical sections small, and test the failure path where compare-and-set loses a race.

Atomicity in Practice:

Why doesn't volatile fix counter++?
volatile makes writes visible to other threads, but it does not glue a read and a write into one indivisible step. With counter++, two threads can still read the same old value before either one writes back the incremented value. You solve that with an atomic update primitive, not with visibility alone.
When should I reach for AtomicReference instead of several smaller atomics?
Use AtomicReference when several fields together form one business fact. In the ticket example, the remaining count and last buyer make more sense as one snapshot than as scattered mutable pieces. That way a reader never sees a half-updated version of the state.
How does compare-and-set actually prevent double selling?
Each buyer tries to replace the same old snapshot. Only one thread succeeds, because the atomic variable checks that the old value is still exactly the one that thread observed. The losing thread retries, sees the new snapshot with zero tickets left, and backs out cleanly.
When is LongAdder better than AtomicLong?
LongAdder is better for noisy metrics that many threads update all the time, like attempt counts or request totals. It spreads contention across internal cells, which usually scales better under heavy write pressure. For exact business totals that you conceptually treat as one shared value, AtomicLong is often the better fit.

Conclusion

For Scala developers learning Java 21, atomicity is the reminder that visibility, immutability, and thread safety are related but not identical ideas. Once you see shared updates as “replace this snapshot only if nobody changed it first,” atomics become much easier to reason about in Java, Scala, and Kotlin.

Code Samples

All examples in this post are runnable. Find them in the repository:


This is part of our Java 21 Interview Preparation Guide - Your Roadmap to Success. Next related posts: Stream API Advanced Operations.