May 17, 2026
Writing Thread Safe Code for Android

Writing Thread Safe Code for Android

I recognize that the above image has nothing to do with threads. But this is my website. So you're gonna have to deal.

What thread safe code actually means

Thread safe code is code that produces correct results no matter how its callers happen to schedule themselves. In a big Android app, you might have a repository that's called from all over the place. A ViewModel reads from it on the main thread, a WorkManager job writes to it on a background dispatcher, a use case on Dispatchers.IO triggers a refresh. If that repo is accessing or mutating shared state, you've got a problem to think about.
The harder question is what "correct" actually means when threads collide. It breaks down into three things threads can do to a class. Call them the three pillars of concurrency:
  1. Access state. Is the thread reading the latest value? Is it reading a value that's internally consistent with whatever else it might look at?
  1. Mutate state. Does the write actually land? Does it stay consistent with the other writes happening alongside it?
  1. Execute code. Does it run the right number of times? Once ever? Once at a time? Once per request?
We're going to walk through how each of these problems shows up in real code, and the tools you have for dealing with them.
📸 Image placeholder: Toolbox overview graphic. Could show the three pillars (Access, Mutate, Execute) as columns or layers, with the tools mapped underneath: @Volatile, Atomics, synchronized / ReentrantLock, double-checked locking, concurrent collections, Mutex, Actor + StateFlow, thread confinement. A "lightest to most structural" axis would help orient the reader for what's coming.

The canonical example: a lost update

Here's the textbook race condition:
var count = 0 fun increment() { count++ }
count++ looks like one operation. It isn't. Under the hood, it expands to three steps:
  1. Read the current value of count
  1. Add 1 to it
  1. Write the new value back
If two threads both call increment() at the same time, this can happen:
📸 Image placeholder: Timeline-style interleaving diagram. Two parallel lanes (Thread A, Thread B) running left to right. Both lanes read count: 0, then both write count: 1. Show visually that the two reads happen before either write, so the second write overwrites the first. Would make the lost update click in a way the text version can't.
Thread A reads count: 0 Thread B reads count: 0 ← B reads before A writes back Thread A writes count: 1 Thread B writes count: 1 ← B overwrites A's result
Both threads incremented. The counter only went up by 1. This is a lost update. At its core it's an Access problem: thread B read a value that was already stale by the time it acted on it. The mutation is the visible failure, but the read is where things went wrong.
The other two pillars have their own version of this bug. A thread reads a value and kicks off an operation based on it while another thread is changing the value underneath. A function that should run once gets called by two threads at the same time and fires twice. Same shape, different pillar.
Now the big question: what can we do about it? Let's walk the toolbox.

@Volatile : freshness

The first tool addresses the Access pillar in its simplest form: making sure a read returns a fresh value, not a stale one.
To see why this is even a problem you need to know a bit about how modern CPUs work. Each core has its own local cache. When a thread reads or writes a variable, that read/write often hits the core's cache, not main memory. Caches eventually sync with main memory, but "eventually" is doing a lot of work in that sentence. There's a pocket of time where one core has written a new value and another core is still reading the old one out of its own cache.
📸 Image placeholder: Two CPU cores side by side, each with its own cache holding a copy of the variable count. Core A writes count = 6 to its cache. Core B is still reading count = 5 from its own cache. Arrows showing the eventual (but not immediate) propagation down to main memory.
Any variable could be living in a cache. If it is, there's a window where another thread can change the true value and you won't know until your cache syncs. You'll be working with stale data.
@Volatile closes that window. Marking a field volatile tells the JVM: don't cache this. Reads and writes always go to main memory, so a write from one thread is immediately visible to every other thread.
@Volatile var isRunning = false
A classic use case is a flag that one thread sets and another polls in a loop. Without @Volatile, the polling thread can keep reading a stale cached false after another thread has flipped it to true, and run forever. With it, the flip is seen immediately.

What @Volatile does NOT solve

Even with @Volatile, the counter problem from earlier is still broken:
@Volatile var count = 0 fun main() { val t1 = Thread { repeat(1000) { count++ } } val t2 = Thread { repeat(1000) { count++ } } t1.start(); t2.start() t1.join(); t2.join() println(count) // Prints something like 1847, not 2000 }
Look at what can still happen:
Thread A reads count: 5 ← A sees the latest value (volatile works!) Thread B reads count: 5 ← B also sees the latest value (volatile works!) Thread A writes count: 6 ← A's write goes straight to memory Thread B writes count: 6 ← B's write overwrites A, based on the stale read
Both threads read the freshest value possible. Both wrote based on it. One increment got dropped anyway. @Volatile made the reads accurate, but it couldn't stop the two read-modify-write cycles from overlapping. That's the Mutate pillar, and it needs a different tool.

Atomics : single-value mutation

The atomicity problem is that count++ is three operations, and threads can interleave between any of them. To safely Mutate state, the whole read-modify-write cycle needs to happen as one indivisible step.
The java.util.concurrent.atomic package gives you exactly that. Classes like AtomicInteger use a low-level CPU instruction called Compare-And-Swap (CAS), a single hardware operation that reads a value, computes a new one, and writes it back, with no possibility of another thread sneaking in:
val count = AtomicInteger(0) fun main() { val t1 = Thread { repeat(1000) { count.incrementAndGet() } } val t2 = Thread { repeat(1000) { count.incrementAndGet() } } t1.start(); t2.start() t1.join(); t2.join() println(count.get()) // Always 2000 }
incrementAndGet() maps to a single CPU instruction. There's no gap between the read and the write where another thread can wedge in. It's an optimistic strategy: assume contention is rare, try the operation, and if the value changed underneath you, retry. Under typical Android load this is extremely fast.
Atomics also handle freshness internally. They behave like volatile fields, so reads and writes bypass the per-core cache and go straight to main memory. You get atomic mutation and fresh access from the same tool, which makes atomics a strict superset of @Volatile for any single value.
AtomicReference lets you apply the same idea to any object:
val cachedUser = AtomicReference<User?>(null) // Safe to call from any thread fun updateUser(user: User) = cachedUser.set(user) fun getUser(): User? = cachedUser.get() // Atomic conditional set: only writes if current value is null fun initializeUser(user: User) = cachedUser.compareAndSet(null, user)

The limit of atomics

Atomics make each individual operation indivisible. But they can't make two separate operations appear atomic together:
val userRef = AtomicReference<User?>(null) val timestampRef = AtomicReference<Long?>(null) // ❌ Two separate atomic operations, not one fun updateUser(user: User) { userRef.set(user) // A thread reading here sees the new user but the old timestamp timestampRef.set(System.currentTimeMillis()) }
Any thread that reads between those two lines gets an inconsistent state: a new user paired with a stale timestamp. The moment we need to update multiple things together, atomics aren't enough. We need a way to fence off a whole section of code so nobody else can read or write inside it while we're working.

synchronized : protecting a section of code

synchronized is the first tool that hits all three pillars at once. It gives you consistent Access and Mutate across multiple fields, and it handles Execute by guaranteeing that only one thread runs the protected block at a time.
It gives you mutual exclusion. Every Kotlin/Java object has an intrinsic monitor lock. When a thread enters a synchronized block, it acquires that lock. Any other thread trying to enter a block synchronized on the same object blocks and waits.
val lock = Any() var user: User? = null var lastUpdated: Long = 0 fun updateUser(newUser: User) { synchronized(lock) { user = newUser lastUpdated = System.currentTimeMillis() } // Both fields updated together, no thread can read between them } fun getSnapshot(): Pair<User?, Long> { synchronized(lock) { return Pair(user, lastUpdated) // Reads both fields atomically } }
A useful mental model: think of synchronized like a bathroom with one key. The first person grabs the key and locks the door. Everyone else waits in the hallway. When they leave, they put the key back, and the next person picks it up. Nobody else can be inside while you are.
This is exactly the right tool when our hypothetical repo needs to update a cached user and its timestamp together, or when a flag and a counter need to move in lockstep.

Deadlocks

synchronized brings a new risk: deadlocks. Two threads each hold a lock the other one needs.
Thread A holds Lock 1, waiting for Lock 2 Thread B holds Lock 2, waiting for Lock 1 → Neither can proceed. The app hangs forever.
There's also a subtler variant worth knowing. You might expect that a synchronized method calling another synchronized method on the same object would deadlock itself. It doesn't, because Java and Kotlin locks are reentrant by default. (The ReentrantLock class we'll see in a minute is unfortunately named because of this. It sounds like reentrance is its special trick, but plain synchronized is reentrant too. The naming is just bad.) A thread that already holds a lock can acquire it again without blocking:
val lock = Any() fun methodA() { synchronized(lock) { methodB() // Same thread, same lock, no deadlock } } fun methodB() { synchronized(lock) { // do work } }
The lock keeps a count of how many times the same thread has acquired it and only releases fully when the count hits zero. This is one of those things that just works in Java/Kotlin, but it's worth knowing so you're not surprised.

ReentrantLock : same lock, more knobs

synchronized is convenient but limited. If you want the lock, you block, period. ReentrantLock gives you the same mutual exclusion with options for how you wait.
val lock = ReentrantLock() fun doWork() { lock.withLock { // Acquires on entry, releases on exit, even if an exception throws // critical section } }
withLock is the idiomatic way to use it. It handles acquiring and releasing automatically, so you can't accidentally forget to unlock. The main reason to reach for ReentrantLock over synchronized is when you need more nuanced acquisition behavior:
// Try to get the lock, but don't wait if it's taken if (lock.tryLock()) { try { refreshCache() } finally { lock.unlock() } } else { // Lock is busy, skip the refresh and try next time } // Or wait, but only up to a limit if (lock.tryLock(100, TimeUnit.MILLISECONDS)) { try { doWork() } finally { lock.unlock() } } else { handleTimeout() }
This is useful in Android when blocking would cause more problems than skipping. A background cache refresh that can be dropped if another operation is already running, for instance. Back to the repo: maybe a refresh fires every time the user pulls to refresh, but you don't want a queue of refreshes piling up if the network is slow. tryLock lets you say "if a refresh is already running, just bail."
The tradeoff: more control means more responsibility. With synchronized, the compiler enforces correct usage. With raw ReentrantLock, you can acquire without releasing if you're not careful, which is exactly why withLock exists and should almost always be preferred over manually calling lock() and unlock().

Double-checked locking : execute exactly once

Locks handle the common version of Execute: only one thread runs the block at a time. But there's a stricter flavor worth its own section: code that should run exactly once, ever. The canonical case is lazy singleton initialization.
Let's walk through it. Anyone can call getInstance() from anywhere. We only want the init work to happen once. The obvious move is to check if the instance is null and only construct it if it is.
The problem is that another thread might have just finished setting it, and if its write is sitting in a CPU cache we haven't synced with, our null check gets a stale answer. So we mark the field @Volatile. Now if anyone has set it, we see it.
That's still not enough. Two threads can both reach the null check at exactly the same moment, both see null, and both start constructing. We need a way to say "wait in line, in case someone is already doing this." That's synchronized.
But here's the subtle bit: by the time we get into the lock, someone might have already finished initializing while we were waiting. So we check again, inside the lock.
@Volatile private var instance: Database? = null fun getInstance(): Database { if (instance == null) { // Fast path, no lock synchronized(this) { if (instance == null) { // Re-check inside the lock instance = Database() } } } return instance!! }
Three primitives, one composed answer. @Volatile keeps the outer read fresh. synchronized makes sure only one thread does the construction. The inner re-check covers the case where someone beat us to it while we were blocked.
The @Volatile is doing one extra job worth flagging. Without it, the JVM can reorder instructions such that instance appears non-null to another thread before the Database() constructor has actually finished. Thread B skips both checks and returns a reference to a half-constructed object. @Volatile establishes a memory barrier that prevents this.
In practice, Kotlin handles all of this for you:
val database by lazy { Database() } // Double-checked locking under the hood object Database { ... } // JVM guarantees single initialization
It's still worth understanding how it works. It shows up in Android SDK code and library internals, and it's the clearest example of how the primitives compose into something more sophisticated.

Thread-safe collections

Quick detour. Everything so far has been about scalar fields. A lot of real shared state in an Android app is actually a collection, and the standard ones (HashMap, ArrayList, MutableList) are not thread-safe. Concurrent reads and writes without synchronization can cause data corruption, lost updates, or even infinite loops.
You could wrap every collection access in synchronized, but for a heavily-trafficked map that means every read and write fights for the same lock. That's a bottleneck. The good news is you don't need to reinvent the wheel. The JDK and Kotlin folks already shipped thread-safe versions of the common collections, built out of the same tools we've been covering.
ConcurrentHashMap is the workhorse. Instead of one big lock for the whole map, it shards internally so reads and writes on different parts of the map don't block each other:
val cache = ConcurrentHashMap<String, User>() cache["user_1"] = user // Thread-safe val user = cache["user_1"] // Thread-safe cache.computeIfAbsent("user_1") { fetchUser() } // Atomic get-or-compute
Note computeIfAbsent: it's the collection equivalent of compareAndSet. A check and an insert as one indivisible step. Useful for "fetch this if it's not already cached" patterns, which our repo example would absolutely care about.
Collections.synchronizedList wraps any list to make individual operations thread-safe. The catch: iteration is not automatically protected and requires manual synchronization:
val list = Collections.synchronizedList(mutableListOf<String>()) // Must manually synchronize the entire iteration block synchronized(list) { for (item in list) { process(item) } }
CopyOnWriteArrayList is ideal for lists that are read far more than written. On every write it creates a full copy of the underlying array, so reads never block. Use it for small lists like a set of event listeners, where writes are infrequent and iteration performance matters:
val listeners = CopyOnWriteArrayList<EventListener>() listeners.forEach { it.onEvent(event) } // Safe to iterate listeners.add(newListener) // Safe to modify concurrently
That covers the traditional threading toolbox. Now coroutines complicate things.

The coroutine problem

Everything we've covered works in a traditional threading model: one thread does some work, acquires a lock, does more work, releases the lock. The thread stays put for the whole operation. Coroutines break this assumption.
A coroutine can suspend in the middle of execution, freeing up the thread, and then resume later, potentially on a completely different thread. This is what makes coroutines efficient. It also makes synchronized actively dangerous, and the Kotlin compiler catches it:
val lock = Any() suspend fun fetchAndUpdate() { synchronized(lock) { val data = api.fetchData() // ❌ Compile error updateState(data) } }
This is a compile error, not a runtime one. The reason: a monitor lock is thread-bound. Only the thread that acquired it can release it. If a coroutine suspends inside a synchronized block, it might resume on a different thread, and that thread can't release a lock it doesn't own. The lock gets stranded, and everything waiting on it hangs forever.
The compiler error is the right behavior. It catches a class of deadlock before it ships. But the three concerns don't go away just because we're in coroutine land. We still need a way to protect shared state across suspensions.

Mutex : the coroutine-aware lock

Mutex from kotlinx.coroutines is the coroutine-safe answer to synchronized. It does the same job (mutual exclusion across all three concerns), but instead of blocking the thread when waiting, it suspends the coroutine. The thread is freed to do other work, and when the mutex becomes available, the suspended coroutine resumes.
val mutex = Mutex() var count = 0 suspend fun increment() { mutex.withLock { count++ } }
Because Mutex is coroutine-aware rather than thread-bound, suspend calls inside the lock are perfectly fine:
val mutex = Mutex() suspend fun fetchAndUpdate() { mutex.withLock { val data = api.fetchData() // suspend, totally safe updateState(data) } }
This is exactly the tool our repo needed. Execute again, in its coalescing flavor: two callers hitting getUser() at the same time, both seeing an empty cache, both firing the network request, and racing to write the result.
class UserRepository { private val mutex = Mutex() private var cachedUser: User? = null suspend fun getUser(id: String): User { mutex.withLock { cachedUser?.let { return it } // Return cached value if available val user = api.fetchUser(id) cachedUser = user return user } } }
Without the mutex, two concurrent callers could both see cachedUser == null, both fire a network request, and race to write the result. With it, the second coroutine suspends and waits, and by the time it enters the block, the first has already populated the cache. One network call instead of two.

Actor + StateFlow : eliminating the concerns entirely

Every tool so far has been about protecting shared state from concurrent access. The actor pattern takes a different approach: get rid of shared mutable state in the first place. If only one coroutine ever touches the state, the three pillars evaporate. There's no concurrent access to make stale. There's no concurrent mutation to lose. There's no execution question, because every event is handled in order, one at a time.
An actor is a coroutine that exclusively owns a piece of state and processes incoming messages sequentially through a Channel. No other code touches the state directly. Everything goes through the channel.
In practice, the actor pattern in Android looks like a ViewModel that owns a private MutableStateFlow and exposes it read-only:
sealed class UserEvent { data class Load(val id: String) : UserEvent() object Refresh : UserEvent() object Logout : UserEvent() } class UserViewModel(private val repo: UserRepository) : ViewModel() { private val _uiState = MutableStateFlow<UiState>(UiState.Loading) val uiState: StateFlow<UiState> = _uiState.asStateFlow() private val events = Channel<UserEvent>() init { viewModelScope.launch { for (event in events) { // Process one event at a time, no concurrency on state handleEvent(event) } } } fun onEvent(event: UserEvent) { viewModelScope.launch { events.send(event) } } private suspend fun handleEvent(event: UserEvent) { when (event) { is UserEvent.Load -> { _uiState.value = UiState.Loading _uiState.value = try { UiState.Success(repo.getUser(event.id)) } catch (e: Exception) { UiState.Error(e.message) } } is UserEvent.Refresh -> { /* ... */ } is UserEvent.Logout -> { _uiState.value = UiState.LoggedOut } } } }
The UI sends events in. State flows out:
// Send events in binding.refreshButton.setOnClickListener { viewModel.onEvent(UserEvent.Refresh) } // Collect state out lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> when (state) { is UiState.Loading -> showLoading() is UiState.Success -> showUser(state.user) is UiState.Error -> showError(state.message) is UiState.LoggedOut -> navigateToLogin() } } } }
State never escapes the actor. The UI never writes anything directly. Concurrency bugs become structurally impossible, not because we locked something, but because only one thing is ever allowed to touch the state.
For cases where you need to update state based on its current value, StateFlow provides an atomic update function that uses a CAS loop internally:
_uiState.update { currentState -> if (currentState is UiState.Success) { currentState.copy(isRefreshing = true) } else { currentState } }

Thread confinement : the structural version

The actor pattern's underlying idea has a name: thread confinement. If only one thread (or one coroutine) ever touches a piece of state, you don't need locks, atomics, or volatile flags. There's no concurrency in the first place.
Android developers use this constantly without always realizing it. The main thread is the canonical example. All UI state is confined to it, which is why you get a CalledFromWrongThreadException if you try to update a View from a background thread. The framework is enforcing confinement for you.
You can apply it yourself with a single-threaded dispatcher:
val singleThread = Executors.newSingleThreadExecutor().asCoroutineDispatcher() class LocalCache { private val data = mutableMapOf<String, String>() // No sync needed suspend fun put(key: String, value: String) = withContext(singleThread) { data[key] = value } suspend fun get(key: String): String? = withContext(singleThread) { data[key] } }
Everything dispatched through singleThread is serialized. No two operations can interleave. It's the actor idea without the messaging ceremony, and it works well for stateful helpers that don't need to broadcast state changes to the outside world.

Bringing it back

Every tool in this article answers one or more of the three questions we started with.
Problem
Tool
Read freshness
@Volatile
Single-value mutation
AtomicInteger, AtomicBoolean, AtomicReference
Multi-field access and mutation
synchronized, ReentrantLock
Mutual exclusion of execution
synchronized, ReentrantLock, Mutex
Trigger exactly once
by lazy, object, double-checked locking
Collection access and mutation
ConcurrentHashMap, CopyOnWriteArrayList
Critical sections in coroutines
Mutex
All three, structurally
Actor + StateFlow, thread confinement

The reach order

When you do need a tool, pick the lightest one that solves your problem:
StateFlow / ViewModel ← First choice in most Android code Actor / Thread confinement ← When one owner should manage state exclusively Mutex ← Coroutine-safe lock ReentrantLock ← Non-coroutine lock with tryLock or timeouts synchronized ← Simple non-coroutine critical sections ConcurrentHashMap etc. ← Thread-safe collections Atomics ← Single-variable atomic operations @Volatile ← Visibility only, simple flags
Each layer exists because the one above it isn't the right fit, or because the one below it doesn't go far enough.

The real lesson

Thread safety is a design decision, not a debugging activity. The right time to think about which of these tools you need is when you're sketching out a class, not when a crash report comes in. Prefer val over var. Prefer immutable data classes. Push mutable state into a single owner (a ViewModel, an actor, a repository) and expose only read-only views. If you find yourself writing manual synchronization in application code, that's almost always a signal that ownership is wrong, not that you needed a better lock.
Next time you're designing a class that lives across threads, ask the three questions first.
  1. What can threads access here, and do they need the freshest value, a consistent snapshot, or both?
  1. What can threads mutate here, and is it a single field or a group of related ones?
  1. What can threads trigger here, and how many times should that thing happen?
Answer those three honestly and the right tool almost picks itself.