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:
- Read
counter - Add
1 - 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;
}
}
}
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()
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
}
}
}
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:
- Read the current snapshot.
- Build the new snapshot you want.
- Replace it only if the old snapshot is still the same one you saw.
- 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 == 0soldOut == 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:
- A volatile counter can still lose one increment.
- Exactly one buyer can claim the last ticket when we use compare-and-set.
- 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
AtomicIntegerorAtomicLongfor exact counters,AtomicReferencefor replacing an immutable state snapshot, andLongAdderfor highly contended metrics. - Keep the value held by an
AtomicReferenceimmutable. 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?
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?
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:
- Java 21 AtomicTicketOffice
- Java 21 AtomicReferenceTicketOffice
- Java 21 SplitAtomicTicketOffice
- Java 21 TicketSnapshot
- Scala 3 AtomicOperationsExamples
- Kotlin AtomicTicketOffice
- Kotlin SplitAtomicTicketOffice
- Kotlin TicketSnapshot
- Java 21 tests
- Scala 3 tests
- Kotlin tests
This is part of our Java 21 Interview Preparation Guide - Your Roadmap to Success. Next related posts: Stream API Advanced Operations.