1. Introduction
If you’ve only just become friends with Mutex, a logical question may arise: “Why do I need yet another synchronization tool? I already know how to lock everything up.” And indeed, Mutex is like a big, beautiful, reliable lock on a door. But sometimes you don’t need to lock the whole apartment—you just need to… prevent someone from pressing a single button at the same time. In such cases, dragging in a “door lock” can be overkill.
An atomic type is a container that provides operations like “read → modify → write” as one indivisible operation (in terms of concurrent access). That is, other threads/coroutines won’t see the “middle” of an update.
It’s important to keep two words clearly in mind:
- atomicity — an operation either happens completely or doesn’t happen (and not “halfway”);
- invariant — an integrity rule that must not be violated.
If your invariant can be expressed as a single number (“count of processed tasks”), an atomic is ideal. If the invariant includes multiple values (“balance never goes negative” plus “operation log is consistent”), an atomic may not be enough—and that’s normal.
2. AtomicInteger: a thread-safe counter without Mutex
Let’s start with the nicest part: AtomicInteger solves the classic “counter across multiple coroutines” problem as simply as you wish counter++ would work… but it doesn’t.
It is imported from a Java package:
import java.util.concurrent.atomic.AtomicInteger
Mini example: 1000 coroutines increment a counter
This example is similar to the “race” from the lecture about race condition, except now we’ll replace var counter = 0 with an atomic.
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val counter = AtomicInteger(0)
val jobs = List(1_000) {
launch(Dispatchers.Default) {
counter.incrementAndGet()
}
}
jobs.forEach { it.join() }
println(counter.get()) // 1000
}
Here incrementAndGet() is an atomic operation. No one will “lose” an increment.
Note the style: we do not write counter++. Atomics have a different API: get(), set(...), incrementAndGet(), and friends.
How incrementAndGet() differs from get() + set()
It’s very important to understand the boundary: what’s atomic is a specific atomic operation, not your three lines next to it.
Compare:
import java.util.concurrent.atomic.AtomicInteger
fun main() {
val c = AtomicInteger(0)
c.incrementAndGet()
println(c.get()) // 1
}
and this (usually you should not do this):
import java.util.concurrent.atomic.AtomicInteger
fun main() {
val c = AtomicInteger(0)
val current = c.get()
c.set(current + 1)
println(c.get()) // 1 (in a single thread it's ok, but under contention this is already "composite logic")
}
In multithreading/multi-coroutine scenarios, the second variant turns back into “read → compute → write”, and between the read and the write another participant may manage to perform its own update.
3. When an atomic is not enough: atomic value vs. atomic logic
In this section we approach the moment that most often breaks beginners’ brains: “But I’m using an atomic—why is it still sometimes wrong?”
Because an atomic makes updating a single value atomic, but it is not obliged to make your business logic atomic if it consists of multiple steps.
To feel this, let’s model a small part of our learning app.
Imagine we have an “event handler”, and we want to:
1) increase the count of processed events,
2) but not exceed a limit (for example, 100).
Naive check-then-act with an atomic: it’s still a race
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val processed = AtomicInteger(99)
val limit = 100
val jobs = List(2) {
launch(Dispatchers.Default) {
if (processed.get() < limit) {
processed.incrementAndGet()
}
}
}
jobs.forEach { it.join() }
println(processed.get()) // can become 101
}
Yes, you heard that right: it can become 101. Why? Because the condition processed.get() < limit and the increment are already “checked → acted”. Each step is “safe” on its own, but together they are not glued into one atomic block.
This is the classic check‑then‑act pattern we’ve already seen, except now the “race participants” are not a var, but a sequence of operations.
CAS loop: make “checked → updated” atomic
To fix such logic without Mutex, a CAS approach is used: Compare-And-Set. It looks like: “set a new value, but only if the current value is the one I expect”.
AtomicInteger has the method compareAndSet(expected, new).
Conceptually, you can represent it like this:
flowchart TD
A[read current value] --> B[compute new one]
B --> C{compareAndSet expected?}
C -- yes --> D[success: value replaced]
C -- no --> A[repeat: someone changed it]
And here is working code: short, but very illustrative.
import java.util.concurrent.atomic.AtomicInteger
fun tryIncrementUpToLimit(counter: AtomicInteger, limit: Int): Boolean {
while (true) {
val current = counter.get()
if (current >= limit) return false
val next = current + 1
if (counter.compareAndSet(current, next)) return true
// otherwise someone updated the value — retry
}
}
And a small check:
import java.util.concurrent.atomic.AtomicInteger
fun main() {
val c = AtomicInteger(0)
println(tryIncrementUpToLimit(c, 1)) // true
println(tryIncrementUpToLimit(c, 1)) // false
println(c.get()) // 1
}
A CAS loop is “optimistic” synchronization: we assume no one will interfere, try to update, and if someone interfered—retry.
4. AtomicReference<T>: atomically swap a reference to state
After AtomicInteger it’s natural to ask: “OK, what if I need to atomically store not a number, but something more complex?”
That’s where AtomicReference<T> comes in—a container for a reference.
Import:
import java.util.concurrent.atomic.AtomicReference
Mini example: atomic state transition
Let’s make a mini “state machine”:
import java.util.concurrent.atomic.AtomicReference
fun main() {
val state = AtomicReference("IDLE")
val ok = state.compareAndSet("IDLE", "RUNNING")
println("changed=$ok, state=${state.get()}") // changed=true, state=RUNNING
}
The idea is simple: if you want the transition "IDLE" → "RUNNING" to happen strictly once, CAS gives a very clear tool.
Application state as a data class
Now an example closer to reality. Suppose our learning app has processing stats:
- processed — how many events were processed,
- lastError — the last error (if any).
We can store this as an immutable object and replace it as a whole.
import java.util.concurrent.atomic.AtomicReference
data class ProcessorState(
val processed: Int,
val lastError: String?
)
fun main() {
val ref = AtomicReference(ProcessorState(processed = 0, lastError = null))
ref.set(ProcessorState(processed = 10, lastError = "Timeout"))
println(ref.get()) // ProcessorState(processed=10, lastError=Timeout)
}
The advantage of this approach is that you can atomically replace the entire state at once: with a single set(...) or a CAS operation. This can sometimes simplify your life a lot compared to “two vars next to each other”.
5. CAS on AtomicReference: update state without locks
Now comes the part where many people first feel the “power” of AtomicReference: we can do atomic updates of complex state if we change it by copying, not by “mutating it internally”.
And this is where copy() on a data class fits perfectly: we take the old state, build a new one, and swap the reference.
“Write an error” function via a CAS loop
import java.util.concurrent.atomic.AtomicReference
data class ProcessorState(val processed: Int, val lastError: String?)
fun setError(ref: AtomicReference<ProcessorState>, message: String) {
while (true) {
val current = ref.get()
val next = current.copy(lastError = message)
if (ref.compareAndSet(current, next)) return
}
}
Check:
import java.util.concurrent.atomic.AtomicReference
data class ProcessorState(val processed: Int, val lastError: String?)
fun main() {
val ref = AtomicReference(ProcessorState(0, null))
setError(ref, "Disk is full")
println(ref.get()) // ProcessorState(processed=0, lastError=Disk is full)
}
Updating a counter inside the state
Similarly, you can “increment” the processed field without resorting to AtomicInteger:
import java.util.concurrent.atomic.AtomicReference
data class ProcessorState(val processed: Int, val lastError: String?)
fun incProcessed(ref: AtomicReference<ProcessorState>) {
while (true) {
val current = ref.get()
val next = current.copy(processed = current.processed + 1)
if (ref.compareAndSet(current, next)) return
}
}
This works correctly, but remember: this strategy creates new state objects (i.e., allocations). Sometimes that’s a fair price for simplicity and integrity; sometimes it’s not. Our goal here is not to “optimize down to the last byte”, but to learn to choose the right tool for the task.
6. An important trap: AtomicReference does not make an object thread-safe
Now comes the moment that regularly breaks projects (and morale). AtomicReference protects replacing the reference, but it doesn’t turn the object that the reference points to into something magically thread-safe.
That is, you can hurt yourself badly like this:
import java.util.concurrent.atomic.AtomicReference
data class MutableBox(var x: Int)
fun main() {
val ref = AtomicReference(MutableBox(0))
val box = ref.get()
box.x++ // this operation did not become thread-safe "because of the atomic"
println(ref.get().x) // 1
}
In a single thread everything is “kind of fine”. But if multiple coroutines do ref.get().x++, you’ll get a race again—just not on ref, but on the x field.
So the practical rule sounds like this: AtomicReference works well when you store immutable objects inside and replace them entirely. For mutable objects, an atomic is only a “safe reference holder”, nothing more.
7. Applicability limits of atomics
To avoid turning synchronization into a religion (“solve everything with atomics!”), it’s useful to fix a simple selection map.
| Task | Usually better | Why |
|---|---|---|
| Event counter, “how many processed” metric | |
Cheap, simple, atomic increment |
| Single state flag “started/not started” | AtomicReference or AtomicBoolean | CAS lets you enforce “only one start” |
| Invariant across multiple fields (“A consistent with B”) | Mutex or a different design | Need atomicity of a group of operations, not one value |
| You want to store an object and mutate its fields “inside” | Be careful: an atomic won’t save you | Internal mutation remains shared mutable state |
| “Checked a condition → performed an action” | CAS loop or Mutex | This is check‑then‑act; you need a single atomic block |
Separately, note: in the Kotlin ecosystem there are other approaches to atomicity (for example, separate libraries and a discussed common API for multiplatform). But within Kotlin/JVM we stick to the practical path: java.util.concurrent.atomic.*, because it’s widely used and integrates well with coroutines on the JVM.
8. How to use atomics in an app: counters and status
Let’s do a small “practical” piece that you can mentally continue from the lectures about races and Mutex. Imagine we have a simple task processor, and we want to track two things:
- processedCount — how many tasks were processed,
- status — a status string: "IDLE", "RUNNING", "STOPPED".
“Before” version: it would have been var
We won’t write the full “bad” code (we already saw it in races), but the idea is: two vars and a bunch of coroutines around them—an almost guaranteed race.
“After” version: AtomicInteger + AtomicReference
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
class ProcessorStats {
val processedCount = AtomicInteger(0)
val status = AtomicReference("IDLE")
}
Now let’s create functions used by our “engine”:
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
fun start(status: AtomicReference<String>): Boolean {
return status.compareAndSet("IDLE", "RUNNING")
}
fun markProcessed(counter: AtomicInteger) {
counter.incrementAndGet()
}
And a small coroutine-based “simulator”:
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val processed = AtomicInteger(0)
val status = AtomicReference("IDLE")
println(start(status)) // true
println(status.get()) // RUNNING
val jobs = List(1_000) {
launch(Dispatchers.Default) { markProcessed(processed) }
}
jobs.forEach { it.join() }
println(processed.get()) // 1000
}
This yields a simple rule: “change status with CAS; update the counter with an atomic increment”. No Mutex, because our invariants here really are “one value = one atomic operation”.
9. Common mistakes when working with AtomicInteger and AtomicReference
Mistake №1: assuming that “if I use an atomic, races no longer happen”.
An atomic protects specific operations on a specific value, but if you build logic out of multiple steps (especially check‑then‑act), the race comes back at the algorithm level. In such places, either use a CAS loop or honestly take a Mutex.
Mistake №2: writing get() → “think” → set() and expecting atomicity.
This pattern looks decent, but in a concurrent environment between get() and set() someone else may update the value. If you need an update “based on the current value”, use incrementAndGet(), addAndGet(), compareAndSet(), or a CAS loop.
Mistake №3: using AtomicReference and mutating the object inside.
AtomicReference guarantees atomicity of swapping the reference, but it does not make the object’s fields thread-safe. If there is a MutableList inside, var fields, and you mutate them directly, you’re back in shared mutable state. The reliable style with AtomicReference is to store immutable state and replace it entirely via copy() + CAS.
Mistake №4: trying to “glue” an invariant across multiple values with atomics.
If you have two atomics (AtomicInteger and AtomicReference) and you update them separately, the invariant “they are always consistent” won’t appear by itself. Here you either need a single state object (one AtomicReference<State>), or a Mutex, or a redesign so consistency is ensured by program structure rather than hope.
Mistake №5: writing a CAS loop with no exit or without understanding that it may retry.
A CAS loop is not a “magic replacement for a lock”, but an honest “retry if it didn’t work” strategy. If you forget to handle exit conditions (for example, a limit) or you do heavy work inside, you can get unpleasant effects: extra retries, CPU load, and odd behavior. A CAS loop should be short, and computing next should be fast and deterministic.
Mistake №6: trying to optimize too early and replacing Mutex with atomics “just because it’s faster”.
Yes, atomics are often lighter than locks. But the cost of a mistake in concurrent code is usually higher than the cost of a microscopic optimization. If you can’t clearly express the invariant as “one atomic value and one atomic operation”, write it with Mutex first, and only then think about whether it can be simplified down to atomics.
GO TO FULL VERSION