Course contents

Atomic classes

Why count++ loses updates, how AtomicInteger, AtomicLong, AtomicReference and friends fix one variable without a lock, the API you use every week, and the two-variable trap atomics don't cover.

FreeMust know for interviewsLesson21 min readCoreReviewed Oct 2026 · Java 21
On this page
  1. Picture it first: a ticket dispenser at the deli
  2. Quick recap: why count++ loses updates
  3. How it works: compare, then swap
  4. Meet the family
  5. The core API, in one sitting
  6. The function may run more than once
  7. The big trap: two atomics are not one atomic
  8. Counting page views: grow the answer
  9. Real world
  10. Advanced: memory modes, intrinsics and edge cases
  11. How to say it in the interview

🎟️ What are atomic classes?

The atomic classes in java.util.concurrent.atomic (AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference and friends) wrap a single variable and give you operations on it that happen all at once or not at all. counter.incrementAndGet() reads, adds one and writes back as one indivisible step, so 32 threads calling it 100,000 times each end at exactly 3,200,000. They do it without a lock: under the hood they use a CPU instruction called compare-and-swap.

For this lesson, we'll start from the bug that makes atomics necessary, meet the family, learn the handful of methods you'll use every week, and then hit the two traps that catch experienced engineers: compound actions across two atomics, and "check, then act" on one. Interviewers like this topic because the first answer ("use AtomicInteger") is easy, and the follow-up ("so is this class thread-safe now?") is where they find out whether you understand what "atomic" actually covers.

Below the line (out of scope)

The 30-second answer

count++ is three steps (read, add, write), so two threads can interleave and lose updates; volatile doesn't help because it fixes visibility, not atomicity. The atomic classes make one read-modify-write on one variable indivisible, using hardware compare-and-swap instead of a lock, so they never block. get() and set() behave like volatile reads and writes. compareAndSet(expected, new) only writes if nobody changed the value in between; updateAndGet(fn) runs a CAS loop and may call your function more than once, so keep it side-effect free. They don't make several variables change together: for that you need a lock or one AtomicReference to an immutable object. For a write-heavy counter, prefer LongAdder.

What you’ll learn

  • Explain why count++ is three steps and why volatile gives visibility but not atomicity
  • Name the atomic family: AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference, the arrays, the field updaters and VarHandle as the modern foundation
  • Use the core API correctly: getAndIncrement vs incrementAndGet, compareAndSet, getAndSet, lazySet/setRelease
  • Write updateAndGet / accumulateAndGet functions that are side-effect free, because they may be re-run under contention
  • Describe atomics as lock-free (CAS and fetch-and-add) with volatile memory semantics for get and set
  • Spot the bug when correctness depends on two atomics or on check-then-act, and fix it with one AtomicReference to an immutable object or a lock
  • Choose between synchronized, AtomicLong and LongAdder for a counter, with the trade-off in one sentence

Interview radar

Asked very often: Why isn't count++ thread-safe, and how does AtomicInteger fix it? Core follow-up: incrementAndGet() vs getAndIncrement()? Is AtomicInteger faster than synchronized? Production angle: Your class uses two AtomicLong fields and the totals still drift. Why?

01Picture it first: a ticket dispenser at the deli

Picture a busy deli counter. Version one: the next number is written on a paper sign. Asha reads "41", Ben reads "41" a moment later, both add one, both write "42" on the sign and both think they're next. One turn just vanished. That's count++ on a plain int: read, add, write, with a gap between each step where someone else can sneak in.

Version two: a red ticket dispenser. You pull, you get a number, and the dispenser advances, all in one motion. Three customers pull and get 42, 43 and 44. Nobody can grab "half a pull". That's an atomic class.

Fig. 1

Paper sign vs ticket dispenser

Paper sign vs ticket dispenserLeft: two customers read the number 41 off a paper sign, both add one and both write 42, so two people now hold the same number and one turn is lost. Right: a ticket dispenser hands out 42, 43 and 44 to three customers, one pull at a time, so every number is unique.Paper sign: read, add one, write41next numberAshaBenreads 41reads 41writes 42writes 42two people, one number: a lost turnTicket dispenser: one pull, one numberdispenserpull = take + advance421432443every customer gets their own number
Paper sign vs ticket dispenser. The sign lets two people read the same number before either writes. The dispenser hands out and advances in one motion, so every number is unique.

Here's the thing to hold on to: the dispenser protects one number. If the deli also keeps a separate "customers served today" tally on a whiteboard, the dispenser does nothing for that. Keep that in mind; it's the big trap later in the lesson.

02Quick recap: why count++ loses updates

We covered this properly in Race conditions, so here's the short version. count++ compiles to three bytecode-level steps: read the field, add one, write it back. If thread A and thread B both read 5 before either writes, both write 6. Two increments, one recorded.

Marking the field volatile doesn't change that. volatile makes each read and write visible to other threads and stops reordering around it, but read-add-write is still three separate steps. Let's prove it.

Try it yourself first

Before running it, guess: 32 threads each increment three counters 100,000 times. One is a plain int, one is a volatile int, one is an AtomicInteger. Which ones reach 3,200,000?

import java.util.concurrent.atomic.AtomicInteger;

public class CounterRace {
    static int plain = 0;
    static volatile int vol = 0;
    static final AtomicInteger atomic = new AtomicInteger();

    public static void main(String[] args) throws Exception {
        int threads = 32, perThread = 100_000;
        Thread[] ts = new Thread[threads];
        for (int i = 0; i < threads; i++) {
            ts[i] = new Thread(() -> {
                for (int j = 0; j < perThread; j++) {
                    plain++;                    // read, add, write: three steps
                    vol++;                      // still three steps, just visible ones
                    atomic.incrementAndGet();   // one indivisible step
                }
            });
            ts[i].start();
        }
        for (Thread t : ts) t.join();
        int expected = threads * perThread;
        System.out.printf("expected       %,d%n", expected);
        System.out.printf("int            %,d  (lost %,d)%n", plain, expected - plain);
        System.out.printf("volatile int   %,d  (lost %,d)%n", vol, expected - vol);
        System.out.printf("AtomicInteger  %,d  (lost %,d)%n", atomic.get(), expected - atomic.get());
    }
}

Two real runs of java CounterRace.java on JDK 26, Apple M4 Pro (14 cores). The lost counts change every run; the atomic line never does.

expected       3,200,000
int            279,172  (lost 2,920,828)
volatile int   650,280  (lost 2,549,720)
AtomicInteger  3,200,000  (lost 0)
---
expected       3,200,000
int            461,576  (lost 2,738,424)
volatile int   664,863  (lost 2,535,137)
AtomicInteger  3,200,000  (lost 0)

Notice three things:

  1. The plain int lost over 85% of the increments. With 32 threads hammering one field, the read-add-write gap is hit constantly. (The JIT may also keep plain in a register for a while, which makes it worse.)
  2. volatile lost almost as much. Visibility isn't atomicity. If this surprises you, you're in good company; it's the most common wrong answer to this question.
  3. AtomicInteger was exact, every run, with no lock anywhere in the code.

03How it works: compare, then swap

So how does AtomicInteger add one without a lock? The core idea is compare-and-swap (CAS): one CPU instruction that says "if this memory location still holds 5, put 6 there; otherwise, tell me I lost". It's like the deli clerk saying "I'll serve number 42, but only if nobody's already called 42".

If you lose, nothing is written. You re-read the new value and try again. Nobody ever waits holding a lock, which is why we call these classes lock-free: some thread always makes progress. Here are both versions side by side.

Fig. 2

count++ loses an update; compare-and-set retries instead

count++ loses an update; compare-and-set retries insteadLeft timeline: thread A and thread B both read 5, both compute 6 and both write 6, so one increment is lost. Right timeline: both read 5, A does compareAndSet(5, 6) and wins, B does compareAndSet(5, 6) and fails because the value is now 6, so B re-reads 6 and compareAndSet(6, 7) succeeds. Final value 7.count++ on an intAtomicInteger: compare, then swapthread Athread Bthread Athread Bread 51read 52write 63write 64count = 6, expected 7read 51read 52CAS(5, 6) ok3CAS(5, 6) fails4read 6, CAS(6, 7) ok5count = 7, nothing lost
Read-add-write vs compare-and-swap. On the left, both threads write 6 and one increment disappears. On the right, B's CAS fails at step 4 because the value moved, so B re-reads and retries. Nothing is lost.

The right side is the mental model, and it's exactly how updateAndGet works. For plain counting, HotSpot goes one better: incrementAndGet and getAndAdd are JIT intrinsics that become a single hardware fetch-and-add (LOCK XADD on x86, LDADDAL on ARMv8.1+), so there isn't even a retry loop. CAS itself, the ABA problem and the retry cost get their own lesson: Compare-and-swap and the ABA problem.

04Meet the family

The package is small. Here's what's in it and when you'd reach for each.

Class Wraps Typical use
AtomicInteger, AtomicLong an int / long counters, sequence numbers, IDs
AtomicBoolean a boolean "run once" flags: if (started.compareAndSet(false, true))
AtomicReference<V> an object reference swap a whole immutable config or state object at once
AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray an array of slots per-bucket counters; each slot is atomic, the array as a whole is not
AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, AtomicReferenceFieldUpdater a volatile field in your own class atomics without one extra object per instance (saves memory when you have millions)
AtomicStampedReference, AtomicMarkableReference a reference + int stamp / boolean mark defending against ABA
LongAdder, LongAccumulator, DoubleAdder, DoubleAccumulator a striped sum hot counters with many writers (Java 8+)

Underneath, since Java 9, the public foundation is VarHandle (java.lang.invoke). A VarHandle is a typed handle to a field or array slot that lets you do the same atomic operations (compareAndSet, getAndAdd, getAcquire, setRelease...) on any field, without a wrapper object. It replaced the internal sun.misc.Unsafe tricks library authors used to rely on (JEP 193). The atomic classes' Javadoc now defines every method's memory effect in VarHandle terms; AtomicReference and the arrays use VarHandles directly, while AtomicInteger and AtomicLong still call the JDK-internal Unsafe for bootstrapping reasons. You'll rarely need VarHandle in application code, but it's good to know they're two faces of the same machinery.

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

class Hits {
    private volatile long count;                    // a plain field, no AtomicLong object
    private static final VarHandle COUNT;
    static {
        try { COUNT = MethodHandles.lookup().findVarHandle(Hits.class, "count", long.class); }
        catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e); }
    }
    long hit() { return (long) COUNT.getAndAdd(this, 1L) + 1; }   // same effect as incrementAndGet()
}

05The core API, in one sitting

You'll use about eight methods. Here's the one rule that makes the names easy: the word order tells you which value comes back. getAndIncrement gets, then increments, so it returns the old value (like i++). incrementAndGet increments, then gets, so it returns the new value (like ++i).

Try it yourself first

Start at 41. Predict each line: getAndIncrement(), incrementAndGet(), compareAndSet(43, 100), compareAndSet(43, 200), updateAndGet(x -> x * 2), accumulateAndGet(5, Math::max), getAndSet(0).

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicTour {
    public static void main(String[] args) {
        AtomicInteger ticket = new AtomicInteger(41);

        System.out.println("getAndIncrement() returned " + ticket.getAndIncrement() + ", now " + ticket.get());
        System.out.println("incrementAndGet() returned " + ticket.incrementAndGet() + ", now " + ticket.get());

        boolean ok = ticket.compareAndSet(43, 100);   // expect 43: it is 43, so swap
        System.out.println("compareAndSet(43, 100) -> " + ok + ", now " + ticket.get());
        ok = ticket.compareAndSet(43, 200);           // expect 43: it is 100, so refuse
        System.out.println("compareAndSet(43, 200) -> " + ok + ", now " + ticket.get());

        System.out.println("updateAndGet(x -> x * 2) -> " + ticket.updateAndGet(x -> x * 2));
        System.out.println("accumulateAndGet(5, Math::max) -> " + ticket.accumulateAndGet(5, Math::max));
        System.out.println("getAndSet(0) returned " + ticket.getAndSet(0) + ", now " + ticket.get());
    }
}
getAndIncrement() returned 41, now 42
incrementAndGet() returned 43, now 43
compareAndSet(43, 100) -> true, now 100
compareAndSet(43, 200) -> false, now 100
updateAndGet(x -> x * 2) -> 200
accumulateAndGet(5, Math::max) -> 200
getAndSet(0) returned 200, now 0

Here's the whole API on one card:

Method What it does Memory effect
get() / set(v) read / write like a volatile read / write
lazySet(v) = setRelease(v) write that may become visible a little later release only (cheaper, Advanced)
getAndIncrement() / incrementAndGet() add one, return old / new volatile read + write
getAndAdd(d) / addAndGet(d) add d, return old / new volatile read + write
getAndSet(v) swap, return old volatile read + write
compareAndSet(expect, v) write v only if the value is expect; returns true/false volatile read + write
updateAndGet(fn) / getAndUpdate(fn) apply fn in a CAS loop volatile read + write
accumulateAndGet(x, fn) / getAndAccumulate(x, fn) apply fn(current, x) in a CAS loop volatile read + write

That "like a volatile" column matters. An AtomicReference.set(config) publishes the object: a thread that later get()s it sees the fully built config, for the same happens-before reasons as a volatile write.

06The function may run more than once

updateAndGet(fn) is a loop: read the current value, call fn, try compareAndSet(old, fn(old)), and if another thread got there first, call fn again. The Javadoc says it directly: the function "should be side-effect-free, since it may be re-applied when attempted updates fail due to contention among threads".

Let's count how often that happens. This program deliberately breaks the rule by counting calls inside the lambda.

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

public class RetryCount {
    public static void main(String[] args) throws Exception {
        AtomicLong total = new AtomicLong();
        AtomicInteger calls = new AtomicInteger();   // how many times our lambda ran
        int threads = 8, perThread = 100_000;
        Thread[] ts = new Thread[threads];
        for (int i = 0; i < threads; i++) {
            ts[i] = new Thread(() -> {
                for (int j = 0; j < perThread; j++) {
                    total.updateAndGet(x -> {
                        calls.incrementAndGet();      // a side effect: exactly what you should NOT do
                        return x + 1;
                    });
                }
            });
            ts[i].start();
        }
        for (Thread t : ts) t.join();
        System.out.printf("updates applied  %,d%n", total.get());
        System.out.printf("lambda calls     %,d  (%,d re-runs)%n", calls.get(), calls.get() - total.get());
    }
}
updates applied  800,000
lambda calls     3,753,540  (2,953,540 re-runs)

800,000 updates, almost 3.8 million lambda calls on this run (another run gave 4.2 million). The sum is still exact, which is the point of CAS. But if that lambda had sent an email, logged an audit line or charged a card, it would have done so almost five times per update. Keep the function pure: compute a new value from the old one, nothing else.

07The big trap: two atomics are not one atomic

This is the bit that trips up people who already "know" atomics. Each atomic makes one variable safe. The moment correctness depends on two variables agreeing, you're back to a race, even if both are atomic.

Here's a real bug shape. Two wallets, Alice and Bob, start with 500 each. A mover thread shifts 10 back and forth, so the total should always be 1,000. An auditor thread keeps checking.

Fig. 3

Two atomics do not make an atomic pair

Two atomics do not make an atomic pairTop: money moves from Alice to Bob in two atomic steps. An auditor reads between step 1 and step 2 and sees a total of 990 instead of 1000. Bottom: both balances live in one immutable Wallets record behind a single AtomicReference, so every read sees a total of 1000.Broken: two AtomicLongsaliceAtomicLong 500bobAtomicLong 500mover-101+103auditorreads at step 2:alice 490 + bob 500 = 9902Fixed: one AtomicReference to an immutable recordWallets(alice, bob)record, never changed in placeswap the wholerecord, one CASWallets(490, 510)new recordtotal is always1000
Two atomic steps are still two steps. The auditor reads between step 1 and step 3 and sees 990. Putting both balances in one immutable record behind one AtomicReference makes the transfer a single swap.
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;

public class TwoAtomics {
    // Two wallets. Money only moves between them, so the total must always be 1,000.
    static final AtomicLong alice = new AtomicLong(500);
    static final AtomicLong bob = new AtomicLong(500);

    record Wallets(long alice, long bob) { }
    static final AtomicReference<Wallets> both = new AtomicReference<>(new Wallets(500, 500));

    public static void main(String[] args) throws Exception {
        long deadline = System.nanoTime() + 1_000_000_000L;      // run for 1 second

        Thread mover = new Thread(() -> {
            while (System.nanoTime() < deadline) {
                alice.addAndGet(-10); bob.addAndGet(10);          // each step atomic, the pair is not
                bob.addAndGet(-10);   alice.addAndGet(10);
                both.updateAndGet(w -> new Wallets(w.alice() - 10, w.bob() + 10));
                both.updateAndGet(w -> new Wallets(w.alice() + 10, w.bob() - 10));
            }
        });
        mover.start();

        long checks = 0, badTwo = 0, badOne = 0, example = 1_000;
        while (mover.isAlive()) {
            checks++;
            long total = alice.get() + bob.get();
            if (total != 1_000) { badTwo++; example = total; }
            Wallets w = both.get();
            if (w.alice() + w.bob() != 1_000) badOne++;
        }
        System.out.printf("audits run                  %,d%n", checks);
        System.out.printf("two AtomicLongs, wrong      %,d  (e.g. total = %d)%n", badTwo, example);
        System.out.printf("one AtomicReference, wrong  %,d%n", badOne);
    }
}
audits run                  153,978,859
two AtomicLongs, wrong      5,129,948  (e.g. total = 990)
one AtomicReference, wrong  0

Over 5 million bad audits in one second (other runs: 6.5 and 7.0 million, sometimes showing 1,010). Notice the fix: we didn't add a lock. We made the two numbers one value, an immutable Wallets record, and swapped the whole thing with one CAS. That's the go-to pattern when the state is small. When it isn't, or when you also need to wait for a condition, use synchronized (see synchronized and monitors).

The same trap hides inside a single atomic too: check-then-act. if (sold.get() < 100) sold.incrementAndGet(); reads, decides, then writes, as two separate atomic calls. With 32 threads released at once through a CountDownLatch, three of my five runs sold 101 seats out of 100. Rewriting it as sold.getAndUpdate(n -> n < 100 ? n + 1 : n) puts the check inside the CAS, and every run sold exactly 100.

08Counting page views: grow the answer

Let's solve a real one the way you'd grow into it in an interview: 32 request threads each record page views, and a dashboard reads the total every few seconds.

Naive approach: A shared int (or volatile int) with count++

It's one line and it looks fine in a unit test with one thread. Some people add volatile "for thread safety".

Challenges: read-add-write races. In the run above, the int lost 2.9 million of 3.2 million increments and the volatile int lost 2.5 million. The dashboard is confidently wrong.

Solid approach: AtomicLong.incrementAndGet()

Every view counts, there's no lock to deadlock on, and reads are a cheap volatile get(). This is the right default for most counters, IDs and sequence numbers.

Challenges: all 32 threads fight over one memory location. Each increment needs exclusive ownership of that cache line, so under heavy write contention threads queue up in hardware. In my quick run (32 threads x 1M increments, rough System.nanoTime timing, second round after warm-up) AtomicLong took 0.8 to 1.4 s, about the same as a synchronized counter.

Staff approach: LongAdder when writes dominate reads

LongAdder gives each contending thread its own cell to add into and only adds the cells up when you call sum(). Same run: about 20 ms, roughly 40 to 75 times faster. It's the right tool when you write a lot and read rarely (metrics, stats, rate counters).

When not to: sum() is not an atomic snapshot under concurrent updates, it uses more memory per counter, and it has no compareAndSet. If you need "increment and give me the exact new value" (ticket numbers, IDs), stay with AtomicLong. Details in LongAdder and striped counters.

Fig. 4

Counting page views from 32 threads: three answers

Counting page views from 32 threads: three answersThree columns. Naive: 32 threads hammer one plain int and lose most updates. Solid: 32 threads use one AtomicLong, every update counts but they all fight over one memory location. Staff: LongAdder spreads threads over several cells and sum() adds them up when someone reads.NAIVEint / volatile int32 threadsSOLIDAtomicLong32 threadsSTAFFLongAdder32 threadscountcount++ = read, add, writelost 2.9M of 3.2Min our runAtomicLongexact, lock-freebut one hot cache line:cores queue for itcell 0cell 1cell 2cell 3sum() adds cellsthreads spread outwrites almost never collidesum() is not a snapshot
Three answers to the same counter. The int loses updates, the AtomicLong is exact but has one hot spot, and LongAdder spreads the writers across cells.

The harness behind those numbers is small: start 32 threads, each calls one of these a million times, join, time it, and repeat once so the JIT has warmed up. Timings vary a lot by CPU and JVM; treat them as a shape, not a benchmark (use JMH for real numbers).

Runnable viaLock   = () -> { synchronized (lock) { syncCount++; } };
Runnable viaAtomic = atomic::incrementAndGet;   // AtomicLong
Runnable viaAdder  = adder::increment;          // LongAdder

One run, second round. Across three runs AtomicLong ranged from 766 to 1,446 ms and LongAdder from 17 to 21 ms.

synchronized  32,000,000 views in  924 ms
AtomicLong    32,000,000 views in  766 ms
LongAdder     32,000,000 views in   21 ms

09Real world

Where atomics show up in code you already run
  • Netty keeps a reference count on every pooled ByteBuf. With millions of buffers alive, one extra AtomicInteger object per buffer adds up, so Netty uses an AtomicIntegerFieldUpdater on a plain volatile int field instead (AbstractReferenceCountedByteBuf and ReferenceCountUpdater).
  • Dropwizard Metrics Counter and Meter are backed by LongAdder: request threads write constantly, the reporter reads every few seconds. Exactly the STAFF case above.
  • ConcurrentHashMap.size() uses the same striped-cell idea as LongAdder internally (CounterCell[]), which is why size() is an estimate while other threads are writing.

10Advanced: memory modes, intrinsics and edge cases

Memory modes. Since Java 9 every atomic class exposes the VarHandle access modes: plain (getPlain/setPlain), opaque, acquire/release (getAcquire/setRelease) and volatile (get/set). lazySet is the old name for setRelease: writes before it can't move after it, but a later volatile read in the same thread may be reordered before it, so it skips the expensive store-load fence on x86. It's useful for "I'm the only writer and readers can see it a few nanoseconds late", like clearing a slot in a single-producer queue. Doug Lea's "Using JDK 9 Memory Order Modes" is the reference.

Weak CAS. weakCompareAndSetPlain / weakCompareAndSetVolatile may fail spuriously (return false even when the value matched). On ARM, which implements CAS with load-linked / store-conditional pairs, that can be cheaper inside a retry loop. The old weakCompareAndSet was deprecated in Java 9 because its name hid that it had plain memory semantics.

Hardware. On x86, compareAndSet becomes LOCK CMPXCHG and getAndAdd becomes LOCK XADD, both full fences. On ARMv8.1+ with LSE (my M4 reports UseLSE = true), HotSpot emits CASAL and LDADDAL; older ARM uses LDAXR/STLXR loops. "Lock-free" means no Java lock and no blocking. It does not mean free: a contended atomic still costs a cache-line transfer between cores on every write.

False sharing. Two hot AtomicLongs allocated next to each other can share a 64-byte cache line, so threads updating different counters still slow each other down. LongAdder's cells are padded with @jdk.internal.vm.annotation.Contended (JEP 142) for exactly this reason.

Many candidates think &quot;I used atomics, so the class is thread-safe&quot;

Many candidates are quick to swap every int for an AtomicInteger and declare victory. That is frequently a mistake, because thread safety is about invariants, not fields. if (x.get() == 0) x.set(1), lower.get() <= upper.get(), or "debit one, credit the other" are all multi-step and still race. Ask: does correctness depend on more than one read or write lining up? If yes, use one CAS on an immutable object, or a lock.

Rough numbers to reason with

An uncontended incrementAndGet costs a few nanoseconds (roughly 5 to 20 ns depending on CPU), close to an uncontended synchronized. Under heavy contention both degrade toward the cost of moving one cache line between cores, and in my 32-thread run they landed within 2x of each other. LongAdder stayed around 20 ms for 32 million increments. updateAndGet under contention called its function 4 to 5 times per successful update in the retry demo above.

Need Pick Why
One counter, exact value needed on each increment (IDs, tickets) AtomicLong returns the new value atomically
One counter, many writers, read occasionally (metrics) LongAdder writers rarely collide; sum() is approximate under writes
A "do this once" flag AtomicBoolean.compareAndSet(false, true) exactly one thread wins
Swap a whole config or state AtomicReference to an immutable object readers see old or new, never half
Several fields that must change together, or waiting for a condition synchronized / a lock atomics can't group variables or block
Millions of objects each needing an atomic field field updater or VarHandle no wrapper object per instance
Fig. 5

The atomic toolbox on one board

The atomic toolbox on one boardAt the bottom, VarHandle and the CPU compare-and-swap instruction are the foundation. On top sit the atomic classes: AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference, the array versions and the field updaters. Next to them, LongAdder for hot counters and AtomicStampedReference for ABA. A note says one variable equals an atomic, several variables that must change together equals a lock.VarHandle + CPU compare-and-swap / fetch-and-addthe foundation since Java 9AtomicIntegerAtomicLongAtomicBooleanAtomicReferenceAtomic*ArrayAtomic*FieldUpdaterLongAdderhot counters, many writersAtomicStampedReferencevalue + version, against ABARule of thumbone variable: an atomicmany variables together:a lock or one immutable object
The atomic toolbox. Every class sits on the same foundation: VarHandle access modes compiled down to the CPU's compare-and-swap or fetch-and-add.

11How to say it in the interview

When I ask about atomics, I'm listening for three things. First, can you say why count++ fails and why volatile alone doesn't fix it? Second, do you know atomics cover one variable, and can you spot the multi-variable or check-then-act bug? Third, do you know the trade-off between AtomicLong, LongAdder and a lock, with a reason rather than "atomics are faster"?

Talk track

"count++ is read, add, write. Two threads can both read 5 and both write 6, so we lose an update; volatile makes the writes visible but doesn't make the three steps one. AtomicInteger.incrementAndGet() does it as one indivisible operation using the CPU's compare-and-swap or fetch-and-add, so there's no lock and no blocking. get and set have volatile semantics, so an AtomicReference also safely publishes objects. For updates like 'double it' I'd use updateAndGet, keeping the lambda pure because it can be retried. The catch is that atomics protect one variable: if two values must stay consistent, I either put them in one immutable object behind an AtomicReference or use a lock. And for a counter with heavy writes and rare reads, I'd pick LongAdder over AtomicLong."

Whiteboard summary

  • count++ = read + add + write; volatile gives visibility, not atomicity
  • Atomics = one variable, one indivisible op, lock-free via CAS / fetch-and-add
  • getAndX returns old, xAndGet returns new
  • compareAndSet(expect, new) returns false if someone moved first
  • updateAndGet(fn): fn may re-run, keep it side-effect free
  • get/set = volatile semantics; lazySet = setRelease
  • Two atomics != atomic pair: one immutable object + AtomicReference, or a lock
  • Many writers, rare reads: LongAdder
Practise this topicAtomics & CAS: interview questions & scenarios
Further reading (14)