Java Atomic Variables

In the last lesson you learned about Java ReadWriteLock. That is a great tool, but for something as small as a single counter, a lock is more than you need. Java gives you atomic variables that make a single value safe across threads without any lock at all.

πŸ€” The problem: count++ is not one step

This looks like one simple action:

count++;

It reads like one thing. But the computer sees three separate steps, called a read-modify-write operation:

  • Read the current value of count from memory.
  • Add one to that value.
  • Write the new value back to memory.

The trouble starts when two threads run these steps at the same time:

  • Say count is 5. Thread A reads 5. Before A writes, Thread B also reads 5.
  • Both add one, both get 6, both write 6.
  • The answer should be 7, but you got 6. One increment vanished.
  • This is a race condition: the result depends on who wins the race.

The code below shows the bug. Many threads add to a plain int, and the total comes out wrong:

class BadCounter {
int count = 0; // ❌ plain int, not safe across threads
void increment() {
count++; // ❌ read-modify-write, can be interrupted
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
BadCounter counter = new BadCounter();
Runnable job = () -> {
for (int i = 0; i < 100000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(job);
Thread t2 = new Thread(job);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Expected: 200000");
System.out.println("Got: " + counter.count);
}
}

Two threads each add a hundred thousand times, so the total should be two hundred thousand. But it almost never is. Some increments get lost in the race:

Output

Expected: 200000
Got: 143928

The number changes every run. Sometimes close, sometimes way off, once in a while correct by luck. A bug that hides most of the time is the worst kind.

πŸ’‘ Why volatile does not fix this

A common first guess is to mark the field volatile. It does not work here:

  • volatile makes every thread see the latest value. It fixes visibility.
  • It does not make the three steps of count++ happen as one.
  • A thread can still read, get paused, and another sneaks in before the write.
  • So volatile is right for a flag one thread sets and another reads, wrong for a shared counter.
volatile int count = 0; // ❌ still broken for count++

For a read-modify-write action you need something stronger: all three steps as one unbreakable unit. That is what atomic variables give you.

🧩 What is an atomic variable?

The word atomic means β€œcannot be split.” An atomic operation either happens completely or not at all, and no thread can see it half-done. So no thread can slip in between the read and the write.

Java keeps these tools in the java.util.concurrent.atomic package. The main ones:

  • AtomicInteger wraps an int.
  • AtomicLong wraps a long.
  • AtomicBoolean wraps a boolean.
  • AtomicReference wraps an object reference.

Each holds a single value and gives you methods to read, set, and update it safely from many threads. No lock, no synchronized, no finally to remember.

The example below creates an AtomicInteger and reads its starting value:

import java.util.concurrent.atomic.AtomicInteger;
AtomicInteger count = new AtomicInteger(0); // starts at 0
System.out.println(count.get()); // prints 0

You do not write count++ on an AtomicInteger. You call a method like incrementAndGet(), which does the whole read-add-write as one atomic step.

βš™οΈ How it works: compare-and-swap

Atomic variables use a trick called compare-and-swap (CAS). The CPU supports it as a single hardware instruction, so it cannot be interrupted partway. To update a value, the thread:

  • Reads the current value, say 5.
  • Works out the new value, say 6.
  • Tells the CPU: β€œset this to 6, but only if it is still 5.”
  • If it is still 5, the swap happens and the thread is done.
  • If another thread changed it, the swap is refused. The thread reads the fresh value and tries again.

This retry loop is why atomics are called lock-free: no thread ever waits for a lock, it just loops on a clash. The method compareAndSet exposes this directly:

import java.util.concurrent.atomic.AtomicInteger;
AtomicInteger value = new AtomicInteger(5);
boolean ok = value.compareAndSet(5, 6); // "if it's 5, make it 6"
System.out.println(ok); // true, it was 5
System.out.println(value.get()); // 6
boolean ok2 = value.compareAndSet(5, 99); // it's 6 now, not 5
System.out.println(ok2); // false, no change made
System.out.println(value.get()); // still 6

The first call works because the value was 5. The second fails because the value is 6, so nothing changes. Methods like incrementAndGet are built on this same CAS loop, so you rarely call compareAndSet yourself.

βœ… The fix: AtomicInteger gives correct totals

Now fix the broken counter. Swap the plain int for an AtomicInteger and call incrementAndGet() instead of count++:

import java.util.concurrent.atomic.AtomicInteger;
class GoodCounter {
AtomicInteger count = new AtomicInteger(0); // βœ… thread-safe
void increment() {
count.incrementAndGet(); // βœ… one atomic step
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
GoodCounter counter = new GoodCounter();
Runnable job = () -> {
for (int i = 0; i < 100000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(job);
Thread t2 = new Thread(job);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Expected: 200000");
System.out.println("Got: " + counter.count.get());
}
}

Same two threads, same increments. But now the increment is atomic, so no update gets lost. Run it as many times as you like and the answer is always right:

Output

Expected: 200000
Got: 200000

The total is correct every time, with no lock and no synchronized anywhere. The AtomicInteger handled all the safety for you.

πŸ”§ The common methods

AtomicInteger and AtomicLong share a small set of methods. The example below walks through the ones you will reach for most:

import java.util.concurrent.atomic.AtomicInteger;
public class Main {
public static void main(String[] args) {
AtomicInteger n = new AtomicInteger(10);
System.out.println(n.get()); // 10 -> read the value
n.set(20); // set to 20
System.out.println(n.incrementAndGet()); // 21 -> add 1, return NEW value
System.out.println(n.getAndIncrement()); // 21 -> return OLD value, then add 1
System.out.println(n.get()); // 22 -> proof it did add
System.out.println(n.addAndGet(8)); // 30 -> add 8, return new value
System.out.println(n.decrementAndGet()); // 29 -> subtract 1, return new
}
}

Notice the naming pattern:

  • incrementAndGet changes first, then gives you the value after.
  • getAndIncrement gives you the value before, then changes it.
  • Want the new number? Use the ...AndGet form. Want the old one? Use the getAnd... form.

The output makes the difference clear:

Output

10
21
21
22
30
29

Both lines print 21. incrementAndGet added first. getAndIncrement gave the value before adding, then bumped it to 22. Same number, different reason.

🚦 AtomicBoolean for one-time flags

AtomicBoolean holds a true/false value safely. It shines when you want exactly one thread to do something once, even when many race to do it. The example below has several threads trying to initialize, but compareAndSet lets only the first win:

import java.util.concurrent.atomic.AtomicBoolean;
class Service {
private final AtomicBoolean started = new AtomicBoolean(false);
void start() {
if (started.compareAndSet(false, true)) { // only the first wins
System.out.println(Thread.currentThread().getName() + " is starting the service");
} else {
System.out.println(Thread.currentThread().getName() + " saw it already started");
}
}
}
public class Main {
public static void main(String[] args) {
Service service = new Service();
Runnable job = service::start;
new Thread(job, "thread-1").start();
new Thread(job, "thread-2").start();
new Thread(job, "thread-3").start();
}
}

All three call start. The compareAndSet(false, true) means β€œif it is still false, flip it to true.” Only one thread wins that flip and runs the setup. The others skip it. The order can shift, but exactly one starts the service:

Output

thread-1 is starting the service
thread-2 saw it already started
thread-3 saw it already started

This is cleaner than a lock plus a boolean, and two threads can never both run the setup. The atomic flip guarantees a single winner.

πŸ“¦ AtomicReference for objects

Sometimes you need to swap a whole object safely. AtomicReference holds a reference to any object and updates it atomically with the same CAS idea. The example below keeps the current best score holder. Threads update it only if the value they read is still current:

import java.util.concurrent.atomic.AtomicReference;
class Player {
final String name;
final int score;
Player(String name, int score) { this.name = name; this.score = score; }
public String toString() { return name + "=" + score; }
}
public class Main {
public static void main(String[] args) {
AtomicReference<Player> leader = new AtomicReference<>(new Player("Alex", 50));
Player old = leader.get(); // read current leader
Player better = new Player("Sam", 80);
boolean updated = leader.compareAndSet(old, better); // swap only if unchanged
System.out.println("Updated: " + updated);
System.out.println("Leader: " + leader.get());
}
}

The thread reads the current leader, builds a new one, and swaps only if the leader is still the object it read. If another thread had changed it, the swap fails and you retry. Here nobody interfered, so it succeeds:

Output

Updated: true
Leader: Sam=80

AtomicReference builds lock-free updates for whole objects, not just numbers. It is the tool behind many advanced concurrent data structures.

Atomics protect one value, not a group

An atomic variable keeps one value safe. If two values must change together and stay in sync, a single atomic cannot do that. For that you need a lock, or you must pack both values into one object and swap it with an AtomicReference.

⚑ Why atomics beat locks for simple counters

For a plain counter, an atomic is usually faster than a lock:

  • A lock makes threads block. Waiting threads go to sleep and wait to be woken.
  • Putting a thread to sleep and waking it is slow work for the operating system.
  • An atomic never blocks. On a clash it just loops and tries again, staying awake.
  • For a quick action like adding one, retrying beats sleeping and waking.
  • The catch is heavy contention: when many threads hammer the same atomic, CAS retries pile up.

πŸ“Š LongAdder for high contention (brief)

For the heavy-contention case, Java offers LongAdder in the same package:

  • Instead of one shared value, it keeps several internal cells.
  • Different threads add to different cells, so they rarely clash.
  • When you want the total, it adds the cells together.

The example below counts with a LongAdder:

import java.util.concurrent.atomic.LongAdder;
LongAdder counter = new LongAdder();
counter.increment(); // add 1
counter.add(5); // add 5
System.out.println(counter.sum()); // 6, the combined total

For most counters, an AtomicInteger or AtomicLong is perfect. Reach for LongAdder only when very many threads hammer the same counter and you have measured a slowdown.

⚠️ Common Mistakes

A few atomic traps to avoid.

  • Using a plain int or volatile for a compound action. count++ and similar read-modify-write actions are never safe on a plain or volatile int. Use an atomic.
// ❌ Wrong: race condition, increments get lost
volatile int count = 0;
count++;
// βœ… Right: one unbreakable step
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();
  • Mixing atomic and non-atomic access to the same value. If some code uses the atomic methods and other code reaches around them, the safety is gone. Every update must go through the atomic.
// ❌ Wrong: pulling the value out and updating it by hand
AtomicInteger count = new AtomicInteger(0);
int local = count.get();
count.set(local + 1); // another thread can change it between these lines
// βœ… Right: let the atomic method do read-modify-write as one step
count.incrementAndGet();
  • Expecting one atomic to guard two values. An atomic protects a single value. Two related fields that must change together need a lock or one combined object in an AtomicReference.

  • Forgetting to call get(). Printing the atomic object itself shows its value via toString(), but to use the number in math or a comparison you call get() (or intValue()). Treat it as a holder, not a raw int.

βœ… Best Practices

Habits for safe, fast atomic code.

  • Use an atomic for any single value that many threads change. Counters, IDs, running totals, and one-time flags are all perfect fits.
  • Prefer the built-in methods over your own loop. incrementAndGet, addAndGet, and friends already do the CAS retry for you; do not hand-roll it.
  • Pick the method that matches before or after. Use ...AndGet when you need the new value, getAnd... when you need the old one.
  • Use AtomicBoolean with compareAndSet for run-once logic. It guarantees a single winner with no lock.
  • Use AtomicReference to swap whole objects when several fields must move together as one unit.
  • Reach for LongAdder only under proven heavy contention. For normal use, AtomicInteger or AtomicLong is simpler and plenty fast.
  • Do not mix atomic and plain access. Every read and write of the value should go through the atomic.

🧩 What You’ve Learned

Nicely done. Let’s recap atomic variables.

  • βœ… count++ is a read-modify-write action of three steps, so it is not safe across threads.
  • βœ… volatile fixes visibility but not the race; you need a truly atomic operation.
  • βœ… Atomic classes live in java.util.concurrent.atomic: AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference.
  • βœ… They use compare-and-swap (CAS), a single CPU step, to update lock-free with a retry loop.
  • βœ… Key methods: get, set, incrementAndGet, getAndIncrement, addAndGet, and compareAndSet.
  • βœ… For a simple counter, atomics are usually faster than a lock, because no thread ever blocks.
  • βœ… AtomicReference swaps whole objects; LongAdder suits very high contention.

Check Your Knowledge

Test what you learned. Pick an answer for each question, then click Check.

  1. 1

    Why is a plain int's count++ unsafe across threads?

    Why: count++ reads, adds, and writes as three separate steps, so two threads can overlap and lose an update.

  2. 2

    Does marking the int as volatile fix a multi-thread counter?

    Why: volatile guarantees the latest value is seen, but it does not make the three steps of count++ happen as one unit.

  3. 3

    What does compare-and-swap (CAS) do?

    Why: CAS sets the new value only if the value still matches what the thread expected; otherwise it fails and the thread retries.

  4. 4

    When are atomics usually faster than a lock?

    Why: Atomics never put a thread to sleep; they retry with CAS, which beats the cost of blocking for simple updates.

πŸš€ What’s Next?

Atomic variables make a single value safe with no lock. But real apps share whole collections like lists, maps, and queues. Wrapping every access in a lock yourself is easy to get wrong. Java ships ready-made thread-safe collections that handle it for you. Next you will learn about concurrent collections like ConcurrentHashMap and CopyOnWriteArrayList, how they work, and when to use each. Let’s learn it.

Java Concurrent Collections

Share & Connect