On This Page
Here's the scan station when it's finished
Four scanner threads feed one parcel-scan station. Every scan gets a unique event ID, the totals add up, the heaviest parcel is tracked, the manifest closes exactly once, and a loading dock never lets in more trucks than it has bays. This is what the finished program printed on my machine:
expected 4000000
plain int 3773215
AtomicInteger 4000000
scans 1000000
unique ids 1000000
heaviest (g) 999
totals Totals[count=1000000, grams=549220000]
manifest closed by 1 thread(s)
broken dock highest seen 3
fixed dock highest seen 2
Look at the second line. plain int is what you get from count++ on four threads: about 227,000 scans vanished. The fix is one class swap. The last two lines are the more interesting lesson, because the "broken" dock is built entirely from atomic calls and still lets three trucks into a two-bay dock.
If you want to learn how to use atomic classes in Java without memorising the whole java.util.concurrent.atomic package, this is the shape of it: five small classes, each for one job, and one rule about when they stop helping. I'll show the five, then the rule.
This one is for Java developers who already know what a thread is and have seen synchronized. There's no framework or database here, and no build tool either. You need a JDK and a terminal.
scanner-0 ─┐
scanner-1 ─┤ ┌─ AtomicLong → next event id
scanner-2 ─┼─ ScanStats ─ LongAdder → scan count
scanner-3 ─┘ ├─ LongAccumulator → heaviest parcel
├─ AtomicReference → (count, grams) pair
└─ AtomicBoolean → close manifest once
trucks ────────── Dock ─ AtomicInteger + compareAndSet → bay limit
Check your setup
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 (Ubuntu build) | java -version |
| Build tool | none, plain javac |
javac -version |
| CPU cores | 2 | nproc (Linux/macOS), Task Manager on Windows |
Any JDK from 17 up compiles this. I only ran it on 21. I didn't run it on 25, and I didn't run it on ARM, where contended atomics can behave differently from x86.
Core count matters for the demos. On a single core the broken counter and the broken dock often look fine, because only one thread runs at a time. If your numbers come out perfect on the broken versions, you're on one core or you have too few threads; raise THREADS in Main.
Need a JDK? SDKMAN works on Linux and macOS (sdk install java 21.0.12-tem, where the exact identifier depends on what sdk list java shows you). On Windows, install a Temurin 21 MSI.
I couldn't pull JUnit from Maven Central in the environment I built this in, so the tests here are plain Java with a tiny check helper. They run the same way everywhere, and I link to the JUnit version of this at the end.
Create the parcel-scanner project
I'm using the command line as the primary path, because it removes every variable. The IDE routes are below.
mkdir parcel-scanner && cd parcel-scanner
mkdir -p src/com/example/parcels out
The project tree when you're done:
parcel-scanner/
├── out/
└── src/com/example/parcels/
├── BrokenDock.java
├── Checks.java
├── Dock.java
├── Main.java
├── Reapply.java
├── ScanStats.java
├── Threads.java
├── TornPair.java
└── Totals.java
In IntelliJ IDEA
New Project → Java → pick JDK 21 → leave "Add sample code" off. Create the package com.example.parcels under src and add the classes there. To run, click the green arrow next to main in Main or Checks. I didn't click through this in this run; it's the standard Java project wizard.
In Eclipse
File → New → Java Project, set the execution environment to JavaSE-21, and add the package under src. Run with Run As → Java Application. Also not clicked through this run.
With Maven or Gradle
Nothing here needs a dependency, so a plain pom.xml with maven.compiler.release set to 21 works, as does gradle init with a Java application template. Put the files under src/main/java/com/example/parcels instead. I didn't run either build tool for this article.
How to use atomic classes in Java: one job per class
Eight small steps. Each has a checkpoint.
Step 1: Write a helper that starts all threads at the same moment
Goal: make races show up on demand. Threads that start one after another rarely collide, so we hold them at a gate and open it for everyone together.
src/com/example/parcels/Threads.java
package com.example.parcels;
import java.util.concurrent.CountDownLatch;
final class Threads {
private Threads() {}
/** Runs the task on n threads that all start at the same moment, and waits for them. */
static void race(int n, Runnable task) throws InterruptedException {
CountDownLatch gate = new CountDownLatch(1);
Thread[] ts = new Thread[n];
for (int i = 0; i < n; i++) {
ts[i] = new Thread(() -> {
try { gate.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
task.run();
}, "scanner-" + i);
ts[i].start();
}
gate.countDown();
for (Thread t : ts) t.join();
}
}
Why: every thread blocks on gate.await() until the main thread calls countDown(). That's all a start gate is.
Checkpoint: compile it.
javac -Xlint:all -d out src/com/example/parcels/Threads.java
No output means success.
Step 2: Watch a plain counter lose scans, then swap in AtomicInteger
Goal: see the bug that atomics exist to fix.
src/com/example/parcels/Main.java (first version)
package com.example.parcels;
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
static final int THREADS = 4;
static final int SCANS_PER_THREAD = 1_000_000;
static int plainCounter; // not thread-safe, on purpose
public static void main(String[] args) throws Exception {
Threads.race(THREADS, () -> { for (int i = 0; i < SCANS_PER_THREAD; i++) plainCounter++; });
AtomicInteger atomicCounter = new AtomicInteger();
Threads.race(THREADS, () -> { for (int i = 0; i < SCANS_PER_THREAD; i++) atomicCounter.incrementAndGet(); });
System.out.printf("expected %d%nplain int %d%nAtomicInteger %d%n",
(long) THREADS * SCANS_PER_THREAD, plainCounter, atomicCounter.get());
}
}
Why: plainCounter++ is three steps (read, add one, write). Two threads can both read 41 and both write 42. An atomic class does that read-modify-write as a single indivisible operation. incrementAndGet() returns the new value.
Checkpoint:
javac -Xlint:all -d out src/com/example/parcels/*.java
java -cp out com.example.parcels.Main
expected 4000000
plain int 3773215
AtomicInteger 4000000
I ran the full program three times; the plain counter came out at 3,773,215, 1,227,112 and 2,945,176. The atomic one was 4,000,000 every time. Your plain number will differ, and that's the point. If it says exactly 4,000,000, see "Check your setup" about cores.
Step 3: Hand out event IDs with AtomicLong
Goal: give every scan a unique, increasing ID, using the value that incrementAndGet() hands back.
src/com/example/parcels/ScanStats.java (first version)
package com.example.parcels;
import java.util.concurrent.atomic.AtomicLong;
public final class ScanStats {
private final AtomicLong lastEventId = new AtomicLong(1000); // need the new value back
/** Records one scan and returns the event id it was given. */
public long recordScan(int weightGrams) {
return lastEventId.incrementAndGet();
}
}
Why AtomicLong and not a counter class built for speed: you need the number back, so each caller gets its own ID. LongAdder can't do this, as the next step shows.
Checkpoint: compile again with the same javac command. IDs start at 1001, because incrementAndGet adds first and returns after. The checks later in the article assert it.
Step 4: Count scans with LongAdder and track the heaviest parcel with LongAccumulator
Goal: add two things that many threads update and nobody needs back immediately.
src/com/example/parcels/ScanStats.java (second version)
package com.example.parcels;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAccumulator;
import java.util.concurrent.atomic.LongAdder;
public final class ScanStats {
private final AtomicLong lastEventId = new AtomicLong(1000);
private final LongAdder scans = new LongAdder(); // hot counter, read rarely
private final LongAccumulator heaviestGrams =
new LongAccumulator(Long::max, Long.MIN_VALUE); // running maximum
public long recordScan(int weightGrams) {
long id = lastEventId.incrementAndGet();
scans.increment();
heaviestGrams.accumulate(weightGrams);
return id;
}
public long scanCount() { return scans.sum(); }
public long heaviestGrams() { return heaviestGrams.get(); }
}
Why: LongAdder spreads updates across several internal cells so threads stop fighting over one memory location, and sum() adds them up when you ask. It has no incrementAndGet, so it can't tell you "which number was I". LongAccumulator is the same idea with a function you choose; Long::max with an identity of Long.MIN_VALUE gives a running maximum. The function must be commutative and associative, because the order of combination isn't defined.
One caveat that bites people: sum() isn't a snapshot. If threads are still updating while you call it, the answer may or may not include their latest increments. For a dashboard that's fine. For "stop when we hit exactly 1,000," it isn't. The sibling article on AtomicInteger, AtomicLong and LongAdder goes into the cells and the contention numbers.
Checkpoint: recompile; no output means it compiled.
Step 5: Keep two numbers consistent with one AtomicReference
Goal: track scan count and total weight so that a reader can compute an average without ever seeing half an update.
First, the immutable pair:
src/com/example/parcels/Totals.java
package com.example.parcels;
/** Count and weight that must always move together. Immutable, so it can sit behind one AtomicReference. */
public record Totals(long count, long grams) {
public static final Totals EMPTY = new Totals(0, 0);
public Totals plus(int weightGrams) {
return new Totals(count + 1, grams + weightGrams);
}
}
Then add it to ScanStats.
src/com/example/parcels/ScanStats.java (third version)
package com.example.parcels;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.LongAccumulator;
import java.util.concurrent.atomic.LongAdder;
public final class ScanStats {
private final AtomicLong lastEventId = new AtomicLong(1000);
private final LongAdder scans = new LongAdder();
private final LongAccumulator heaviestGrams =
new LongAccumulator(Long::max, Long.MIN_VALUE);
private final AtomicReference<Totals> totals = new AtomicReference<>(Totals.EMPTY);
public long recordScan(int weightGrams) {
long id = lastEventId.incrementAndGet();
scans.increment();
heaviestGrams.accumulate(weightGrams);
totals.updateAndGet(t -> t.plus(weightGrams)); // pure function: safe to retry
return id;
}
public long scanCount() { return scans.sum(); }
public long heaviestGrams() { return heaviestGrams.get(); }
public Totals totals() { return totals.get(); }
}
Why: two separate atomics (count and grams) are each correct and still disagree for a moment between the two updates. A reader that grabs both can compute garbage. Putting both in one immutable record behind one AtomicReference means the swap is one step: you see the old pair or the new pair. Records are a natural fit; I covered why in how to use Java records and how to create an immutable class in Java.
The lambda in updateAndGet has a rule: no side effects. It can run more than once when threads collide. I'll show you the number in the troubleshooting section.
Checkpoint: recompile.
Step 6: Close the manifest exactly once with AtomicBoolean
Goal: when eight threads all notice "end of shift," exactly one of them should do the closing work.
Add these two things to ScanStats, a field and a method.
src/com/example/parcels/ScanStats.java (final version)
package com.example.parcels;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.LongAccumulator;
import java.util.concurrent.atomic.LongAdder;
public final class ScanStats {
private final AtomicLong lastEventId = new AtomicLong(1000); // need the new value back
private final LongAdder scans = new LongAdder(); // hot counter, read rarely
private final LongAccumulator heaviestGrams =
new LongAccumulator(Long::max, Long.MIN_VALUE); // running maximum
private final AtomicReference<Totals> totals = new AtomicReference<>(Totals.EMPTY);
private final AtomicBoolean manifestClosed = new AtomicBoolean(); // do-once flag
/** Records one scan and returns the event id it was given. */
public long recordScan(int weightGrams) {
long id = lastEventId.incrementAndGet();
scans.increment();
heaviestGrams.accumulate(weightGrams);
totals.updateAndGet(t -> t.plus(weightGrams)); // pure function: safe to retry
return id;
}
public long scanCount() { return scans.sum(); }
public long heaviestGrams() { return heaviestGrams.get(); }
public Totals totals() { return totals.get(); }
/** Returns true for exactly one caller, however many threads race. */
public boolean closeManifest() {
return manifestClosed.compareAndSet(false, true);
}
}
Why: compareAndSet(false, true) means "flip it to true only if it's still false, and tell me whether I was the one who flipped it." Exactly one caller sees true. Compare that with if (!closed) { closed = true; doWork(); }, where two threads can both pass the if.
Checkpoint: recompile. The station is finished; the dock is next.
Step 7: Build the dock the obvious way, then fix it
Goal: limit a dock to N trucks without blocking. First the version everyone writes.
src/com/example/parcels/BrokenDock.java
package com.example.parcels;
import java.util.concurrent.atomic.AtomicInteger;
/** The version everyone writes first. Every call is atomic; the pair is not. */
public final class BrokenDock {
private final AtomicInteger trucks = new AtomicInteger();
private final int cap;
public BrokenDock(int cap) { this.cap = cap; }
/** Returns the count after entering, or -1 if full. */
public int tryEnter() {
if (trucks.get() >= cap) return -1; // look...
return trucks.incrementAndGet(); // ...then leap
}
public void leave() { trucks.decrementAndGet(); }
}
get() is atomic. incrementAndGet() is atomic. Together they aren't: between the look and the leap, another truck can take the last bay. Now the fix.
src/com/example/parcels/Dock.java
package com.example.parcels;
import java.util.concurrent.atomic.AtomicInteger;
/** Loading dock that admits at most {@code cap} trucks at once. Never blocks. */
public final class Dock {
private final AtomicInteger trucks = new AtomicInteger();
private final int cap;
public Dock(int cap) { this.cap = cap; }
public boolean tryEnter() {
int current;
do {
current = trucks.get();
if (current >= cap) return false; // check...
} while (!trucks.compareAndSet(current, current + 1)); // ...bound to the write
return true;
}
public void leave() { trucks.decrementAndGet(); }
public int trucksInside() { return trucks.get(); }
}
Why: compareAndSet(current, current + 1) only writes if the value is still current. If another truck got in first, it returns false, you loop, re-read, and re-check. The check can't go stale, because it's welded to the write. updateAndGet with a cap inside the lambda works too, but you can't return "full" from it cleanly, so I prefer the explicit loop here.
If you want blocking, fairness or timeouts, use a Semaphore instead; I'd reach for the Semaphore article first and this loop only when tryEnter must never block.
Checkpoint: recompile.
Step 8: Put it all together in Main
Goal: run the whole station and both docks.
src/com/example/parcels/Main.java (final version)
package com.example.parcels;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
static final int THREADS = 4;
static final int SCANS_PER_THREAD = 1_000_000;
static int plainCounter; // not thread-safe, on purpose
public static void main(String[] args) throws Exception {
// 1. The counter everyone writes first
Threads.race(THREADS, () -> { for (int i = 0; i < SCANS_PER_THREAD; i++) plainCounter++; });
AtomicInteger atomicCounter = new AtomicInteger();
Threads.race(THREADS, () -> { for (int i = 0; i < SCANS_PER_THREAD; i++) atomicCounter.incrementAndGet(); });
System.out.printf("expected %d%nplain int %d%nAtomicInteger %d%n",
(long) THREADS * SCANS_PER_THREAD, plainCounter, atomicCounter.get());
// 2. The whole scan station
ScanStats stats = new ScanStats();
Set<Long> ids = ConcurrentHashMap.newKeySet();
Threads.race(THREADS, () -> {
for (int i = 0; i < 250_000; i++) ids.add(stats.recordScan(100 + (i % 900)));
});
System.out.println("scans " + stats.scanCount());
System.out.println("unique ids " + ids.size());
System.out.println("heaviest (g) " + stats.heaviestGrams());
System.out.println("totals " + stats.totals());
// 3. Close the manifest: eight threads try, one wins
AtomicInteger winners = new AtomicInteger();
Threads.race(8, () -> { if (stats.closeManifest()) winners.incrementAndGet(); });
System.out.println("manifest closed by " + winners.get() + " thread(s)");
// 4. The dock with a cap of 2
System.out.println("broken dock highest seen " + hammerBroken(2));
System.out.println("fixed dock highest seen " + hammerFixed(2));
}
static int hammerBroken(int cap) throws InterruptedException {
BrokenDock dock = new BrokenDock(cap);
AtomicInteger highest = new AtomicInteger();
Threads.race(THREADS, () -> {
for (int i = 0; i < 2_000_000; i++) {
int now = dock.tryEnter();
if (now < 0) continue;
highest.accumulateAndGet(now, Math::max);
dock.leave();
}
});
return highest.get();
}
static int hammerFixed(int cap) throws InterruptedException {
Dock dock = new Dock(cap);
AtomicInteger highest = new AtomicInteger();
Threads.race(THREADS, () -> {
for (int i = 0; i < 2_000_000; i++) {
if (!dock.tryEnter()) continue;
highest.accumulateAndGet(dock.trucksInside(), Math::max);
dock.leave();
}
});
return highest.get();
}
}
Why: Threads.race appears four times, and each one is "same code, many threads, same moment." The ConcurrentHashMap.newKeySet() is a thread-safe set; we use it to prove no two scans got the same ID (more on that class in how to use ConcurrentHashMap).
Checkpoint: next section.
Take it for a spin
javac -Xlint:all -d out src/com/example/parcels/*.java
java -cp out com.example.parcels.Main
expected 4000000
plain int 3773215
AtomicInteger 4000000
scans 1000000
unique ids 1000000
heaviest (g) 999
totals Totals[count=1000000, grams=549220000]
manifest closed by 1 thread(s)
broken dock highest seen 3
fixed dock highest seen 2
I ran it three times. The plain int line changed every run (3,773,215, 1,227,112, 2,945,176). Every other line was identical, including broken dock highest seen 3 three times out of three on two cores with four threads. That one's a probability, not a promise. If yours says 2, add threads or more iterations.
Check the grams figure by hand, because it proves no update was lost. Each thread's weights cycle through 100 to 999. A thread does 250,000 scans, which is 277 full cycles plus 700 more (weights 100 to 799). That's 277 × 494,550 + 314,650 = 137,305,000 grams per thread, and four threads make 549,220,000. It matches.
"Heaviest 999" is the top of the 100 + (i % 900) range, so that's correct too.
Prove it with 19 checks
JUnit isn't available where I built this, so here's a self-contained checker. When you add JUnit, each check becomes an @Test with assertThat, and the unit-testing article shows the setup.
src/com/example/parcels/Checks.java
package com.example.parcels;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
public class Checks {
static int passed, failed;
static void check(String name, boolean ok) {
if (ok) passed++; else failed++;
System.out.println((ok ? "PASS " : "FAIL ") + name);
}
public static void main(String[] args) throws Exception {
// single-threaded behaviour
ScanStats s = new ScanStats();
check("first id is 1001", s.recordScan(300) == 1001);
check("second id is 1002", s.recordScan(700) == 1002);
check("scanCount is 2", s.scanCount() == 2);
check("heaviest is 700", s.heaviestGrams() == 700);
check("totals are 2 / 1000", s.totals().equals(new Totals(2, 1000)));
check("empty heaviest is Long.MIN_VALUE", new ScanStats().heaviestGrams() == Long.MIN_VALUE);
check("closeManifest true then false", new ScanStats().closeManifest() && !closeTwice());
Dock d = new Dock(2);
check("dock admits 1st", d.tryEnter());
check("dock admits 2nd", d.tryEnter());
check("dock refuses 3rd", !d.tryEnter());
d.leave();
check("dock admits after leave", d.tryEnter());
// concurrent behaviour
ScanStats shared = new ScanStats();
Set<Long> ids = ConcurrentHashMap.newKeySet();
Threads.race(4, () -> { for (int i = 0; i < 100_000; i++) ids.add(shared.recordScan(250)); });
check("400k ids, all unique", ids.size() == 400_000);
check("scanCount is 400k", shared.scanCount() == 400_000);
check("totals count is 400k", shared.totals().count() == 400_000);
check("totals grams is 100M", shared.totals().grams() == 100_000_000L);
check("ids span 1001..401000",
ids.stream().min(Long::compare).get() == 1001 && ids.stream().max(Long::compare).get() == 401_000);
AtomicInteger winners = new AtomicInteger();
ScanStats once = new ScanStats();
Threads.race(16, () -> { if (once.closeManifest()) winners.incrementAndGet(); });
check("exactly one thread closes the manifest", winners.get() == 1);
// a reader must never see count and grams out of step
ScanStats pair = new ScanStats();
AtomicBoolean torn = new AtomicBoolean();
AtomicBoolean done = new AtomicBoolean();
Thread reader = new Thread(() -> {
while (!done.get()) {
Totals t = pair.totals();
if (t.grams() != t.count() * 250) torn.set(true);
}
});
reader.start();
Threads.race(3, () -> { for (int i = 0; i < 200_000; i++) pair.recordScan(250); });
done.set(true);
reader.join();
check("reader never sees count and grams out of step", !torn.get());
// the dock never goes over cap, measured by every thread, many runs
int worst = 0;
for (int run = 0; run < 5; run++) {
Dock dock = new Dock(2);
AtomicInteger inside = new AtomicInteger();
AtomicInteger highest = new AtomicInteger();
Threads.race(4, () -> {
for (int i = 0; i < 500_000; i++) {
if (!dock.tryEnter()) continue;
int now = inside.incrementAndGet();
highest.accumulateAndGet(now, Math::max);
inside.decrementAndGet();
dock.leave();
}
});
worst = Math.max(worst, highest.get());
}
check("dock never admitted more than 2 across 5 runs (worst=" + worst + ")", worst <= 2);
System.out.println(passed + " passed, " + failed + " failed");
if (failed > 0) System.exit(1);
}
static boolean closeTwice() { ScanStats x = new ScanStats(); x.closeManifest(); return x.closeManifest(); }
}
javac -Xlint:all -d out src/com/example/parcels/*.java
java -cp out com.example.parcels.Checks
PASS first id is 1001
PASS second id is 1002
PASS scanCount is 2
PASS heaviest is 700
PASS totals are 2 / 1000
PASS empty heaviest is Long.MIN_VALUE
PASS closeManifest true then false
PASS dock admits 1st
PASS dock admits 2nd
PASS dock refuses 3rd
PASS dock admits after leave
PASS 400k ids, all unique
PASS scanCount is 400k
PASS totals count is 400k
PASS totals grams is 100M
PASS ids span 1001..401000
PASS exactly one thread closes the manifest
PASS reader never sees count and grams out of step
PASS dock never admitted more than 2 across 5 runs (worst=2)
19 passed, 0 failed
A passing concurrency test is evidence, not proof. The reason these hold is the argument in the next section, not the fact that they went green once.
Why one call is safe and two aren't
An atomic class wraps one variable and offers operations that happen as a single step: incrementAndGet, compareAndSet, updateAndGet. Under the hood, most of them rest on a CPU instruction called compare-and-swap, "set this to the new value only if it still holds the old one, and tell me if you did." No lock is taken, and no thread is put to sleep. Memory visibility comes with it: a successful atomic update has the effect of a volatile write, so what you did before it is visible to a thread that later reads the result. The happens-before article has the exact rules, and the volatile article explains why volatile alone doesn't fix a counter.
The rule the dock taught us: atomicity covers exactly one call on exactly one variable. trucks.get() followed by trucks.incrementAndGet() is two calls, and anything can happen between them. AtomicLong count plus AtomicLong grams is two variables, so the pair can be seen half-updated. The cures are the ones in the steps: one call that does the check and the write (compareAndSet, updateAndGet), or one variable that holds the whole state (AtomicReference to an immutable record).
updateAndGet(f) is a loop around compare-and-set. If another thread changed the value while f was running, the swap fails and f runs again on the new value. That's why f has to be free of side effects. I measured how often it re-runs: four threads, a million updates each, on two cores:
updates=4000000 lambdaCalls=5486742 extra=1486742
updates=4000000 lambdaCalls=5464250 extra=1464250
updates=4000000 lambdaCalls=5802298 extra=1802298
About 37% to 45% of the calls were retries in this worst-case loop. A log line or an ID allocation inside that lambda would have fired thousands of times for results nobody kept. Real code collides far less than this loop, which is why it can run fine for months and then misbehave on a busy day. The retry mechanics and the LongAdder internals are in the atomic classes explainer; AtomicReference has its own trap, ABA, in this article; and the broader family of bug is in the race condition article.
Errors I hit, and the fixes
Two atomics that need to agree. I wanted to watch the torn read happen instead of asserting it. Count and grams as separate AtomicLongs, a reader thread comparing them while three threads write:
// TornPair.java (body of main, abbreviated)
count.incrementAndGet();
grams.addAndGet(250);
// reader: long c = count.get(); long g = grams.get(); if (g != c * 250) torn++;
inconsistent reads: 643767
inconsistent reads: 1082586
inconsistent reads: 918581
Hundreds of thousands of reads saw a count that didn't match the weight. The reader loop is tight, so the number is inflated, but even one is a bug. Fix: Step 5, one AtomicReference to a Totals record. The check "reader never sees count and grams out of step" passes against that design.
Compile error when you mutate a local in the lambda. I tried to count calls with a local long total inside updateAndGet:
Bad.java:2: error: local variables referenced from a lambda expression must be final or effectively final
Cause: lambdas can read locals but not change them. Fix: keep the count in an atomic (a LongAdder for a counter), not in a local. And remember the lambda shouldn't have side effects at all; a counter in there will over-count, as the table above shows.
cannot find symbol for LongAdder.
Bad2.java:1: error: cannot find symbol
symbol: class LongAdder
Cause: it lives in java.util.concurrent.atomic, not java.util.concurrent. A wildcard import of java.util.concurrent.* won't pick it up, and neither will java.util.concurrent.atomic.AtomicLong alone. IDEs fix this with Alt+Enter (IntelliJ IDEA) or Ctrl+1 (Eclipse); I didn't run those here.
The broken dock looks fine. If it prints highest seen 2 on your machine, you're on one core, a very light machine, or too few threads. Raise THREADS and the loop count. Don't take it as proof the code is right. Review any get() followed by an update with suspicion.
Plain int counter prints exactly 4,000,000. Same cause as above: too few cores or threads. Run it a few times before you trust it.
Variations worth knowing
- Gradle or Maven: copy the sources under
src/main/java/.... No dependencies needed. I didn't run either. - When not to use atomics: if two fields must change together and the logic has branches, a lock or an
AtomicReferenceto an immutable record is easier to reason about. I'd usesynchronizedthe moment the update path grows past one comparison. See synchronized vs ReentrantLock. - Fields in objects you can't change:
AtomicIntegerFieldUpdaterand friends give atomic operations on avolatilefield without a wrapper. Setup is fiddly; skip unless you're allocating millions of objects. weakCompareAndSet: deprecated since JDK 9 in favour ofweakCompareAndSetPlainandweakCompareAndSetVolatile. If you have a hand-writtendo { ... } while (!cas)loop in a newer codebase, it's usually habit, not need.- Pipelines: when the work is handing items between threads rather than counting, a BlockingQueue is the better building block.
- Version note: the
updateAndGetfamily andLongAdderappeared in Java 8; the basicAtomicInteger/AtomicReferencehave been there since Java 5. Records need Java 16; on Java 11 writeTotalsas a final class withequals,hashCodeandtoString.
What you did
- Showed a plain
intcounter losing about 6% to 69% of its increments across three runs, and fixed it withAtomicInteger. - Used
AtomicLongfor IDs (you need the new value),LongAdderfor a hot counter you read rarely,LongAccumulatorfor a maximum,AtomicReferenceplus an immutable record for a pair that moves together, andAtomicBoolean.compareAndSetfor a do-once flag. - Wrote a dock gate whose check and write are one
compareAndSetloop, after watching the obvious version let three trucks into two bays. - Remembered the rules: atomicity covers one call on one variable, and the function in
updateAndGetcan run more than once.
The build, start to green checks, took me one sitting. Writing the classes was quick; the time went into getting the broken versions to fail reliably, which needed the start gate and four threads on two cores.
Atomic class questions
What is the difference between AtomicInteger and volatile int?volatile makes reads and writes visible across threads, but count++ is still read, add, write, so updates get lost. AtomicInteger.incrementAndGet() does the whole thing as one step. In my run, 4 threads times 1,000,000 plain increments lost between roughly 6% and 69%.
When should I use LongAdder instead of AtomicLong?
When many threads update a total and you read it rarely, like request or scan counts. Use AtomicLong when you need the updated value back (IDs) or need compareAndSet. LongAdder.sum() isn't an atomic snapshot.
Is AtomicInteger thread-safe for check-then-update?
Each method is. A get() followed by an update isn't, as the broken dock shows. Use compareAndSet in a loop, or updateAndGet when a failed check can leave the value unchanged.
Does updateAndGet use a lock?
No. It retries a compare-and-set, so the function may run several times and must have no side effects. I measured up to 45% extra runs in a tight four-thread loop.
References
java.util.concurrent.atomicpackage summary, Java SE 21: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/atomic/package-summary.htmlAtomicInteger,AtomicLong,AtomicReference,AtomicBoolean,LongAdder,LongAccumulatorJavadoc, Java SE 21 (same package, linked from the summary above)- JEP 395, Records: https://openjdk.org/jeps/395