Opening
In the last video we talked about race conditions, where two threads interleave their operations and produce wrong results. But there is an even more subtle problem lurking in concurrent code. Even if you carefully avoid race conditions, even if your operations do not interleave, one thread can still see stale, outdated data that another thread has already changed. This is the memory visibility problem, and it catches experienced developers off guard because it violates a basic assumption: that when one thread writes a value, other threads can immediately read it. On modern hardware, that assumption is simply false. Today we are going to understand why, explore the Java Memory Model that defines the rules, and learn exactly what volatile and synchronized really guarantee at the hardware level.
Why Would a Thread See Stale Data?
Let us start with a concrete example. Imagine you have a boolean variable called running, set to true. On one thread, you have a while loop that keeps spinning as long as running is true. On another thread, you set running to false, expecting the loop to stop. But it does not stop. The loop runs forever. How is this possible? The answer lies in how modern CPUs work. Each core has its own L1 and L2 cache. When core one reads the variable running, it loads the value into its cache and then reads from cache on subsequent accesses. When core two sets running to false, it writes to its own cache. But that new value has not necessarily propagated to core one's cache yet. Core one is still reading the old cached value of true. This is not a bug in the hardware. This is a deliberate design decision to make CPUs faster. If every core had to go to main memory for every read, CPUs would be orders of magnitude slower.
The Three Villains of Visibility
There are actually three distinct mechanisms that can cause visibility problems, and I want you to know all of them. The first is CPU caching, which we just discussed. Each core caches data locally and may not see writes from other cores immediately. The second is store buffers. When a CPU core writes a value, it does not necessarily write it to cache immediately. It places the write in a store buffer, a small queue that batches writes for efficiency. The write will eventually make it to cache and then to main memory, but there is a delay. Other cores cannot see values sitting in another core's store buffer. The third villain is compiler and JIT reordering. The Kotlin compiler, the JVM JIT compiler, and even the CPU itself are allowed to reorder instructions for performance, as long as the result is the same from the perspective of a single thread. But reordering that is invisible to one thread can be visible to another. For example, the compiler might decide to move a write above a read if it thinks they are independent. In a single-threaded world, this is fine. In a multi-threaded world, it can make one thread see writes in a different order than they were written.
The Java Memory Model
This is where the Java Memory Model, or JMM, comes in. The JMM is a specification that defines exactly when one thread is guaranteed to see writes made by another thread. It does not describe hardware. It defines a contract between your code and the runtime. And the central concept in the JMM is the happens-before relationship. If action A happens-before action B, then the effects of A are guaranteed to be visible to B. If there is no happens-before relationship between two actions, the runtime is free to show stale data, reorder operations, or do whatever it wants. The JMM is intentionally permissive. It allows the compiler and CPU to optimize aggressively. It only requires correct behavior when you explicitly establish happens-before relationships. If you do not bother to establish them, you are on your own.
The Happens-Before Rules
So what creates a happens-before relationship? There are several rules defined by the JMM, and these are the ones every Android developer should know. First, program order: within a single thread, every action happens-before the next action in program order. This is why single-threaded code always works as expected. Second, monitor lock: an unlock on a monitor happens-before every subsequent lock on that same monitor. This is the synchronized guarantee. If thread A writes a variable inside a synchronized block and then exits, thread B will see that write when it enters a synchronized block on the same lock. Third, volatile: a write to a volatile field happens-before every subsequent read of that same volatile field. This is the key guarantee of volatile. Fourth, thread start: a call to thread.start happens-before any action in the started thread. Fifth, thread join: any action in a thread happens-before another thread returns from join on that thread. These rules compose. If A happens-before B, and B happens-before C, then A happens-before C. This is called transitivity and it is essential for building correct concurrent programs.
What Volatile Actually Guarantees
Let us go deep on volatile because it is widely misunderstood. When you mark a field as volatile in Java, or use the @Volatile annotation in Kotlin, you get two guarantees. First, visibility. Every write to a volatile field is immediately flushed from the writing core's cache to main memory, and every read goes to main memory rather than the local cache. So when one thread writes to a volatile field, all other threads are guaranteed to see the new value on their next read. Second, ordering. A volatile write acts as a memory barrier. All writes that happen before the volatile write in program order are guaranteed to be visible to any thread that reads the volatile field and sees the new value. This is incredibly powerful. It means a volatile field does not just guarantee visibility of itself. It guarantees visibility of everything written before it. But here is what volatile does not guarantee: atomicity of compound operations. Writing to a volatile variable is atomic. Reading a volatile variable is atomic. But reading a volatile, incrementing it, and writing it back is still three steps and still has the race condition we discussed last time. For that you need synchronized or an atomic class.
Synchronized: Mutex AND Memory Visibility
Now let us revisit synchronized with our new understanding. Most developers think of synchronized as a lock, a way to prevent two threads from executing the same code at the same time. And it is that. But it is also a memory visibility mechanism. When a thread exits a synchronized block, all of the writes it performed inside that block are flushed to main memory. When another thread enters a synchronized block on the same lock, it refreshes its view of memory and sees all of those writes. So synchronized gives you two things for the price of one: mutual exclusion, preventing concurrent access, and memory visibility, ensuring that all writes are seen. This is why synchronized is often the simpler and safer choice compared to volatile. With synchronized, you do not have to reason about which individual fields need visibility guarantees. Everything inside the block is covered. The tradeoff is performance, because acquiring and releasing a lock has overhead, especially under contention when multiple threads are competing for the same lock.
The val Trap in Kotlin
Here is something that trips up many Kotlin developers. You might think that because val is immutable, it is automatically thread-safe. And for primitive values and truly immutable objects, that is true. But val does not mean the object it points to cannot be mutated. Consider a val holding a mutableListOf. The reference cannot change, but the list contents absolutely can. Two threads can both call add on that same list, and you have a race condition and a visibility problem, even though the variable was declared with val. Similarly, a val pointing to an object with var properties inside it offers no thread safety at all. Thread A might modify a property of the object, and thread B reading the same property through the same val reference might see the old value due to caching. The lesson is that val protects the reference, not the referent. For true thread safety, you need either deeply immutable data structures, or proper synchronization, or both.
@Volatile in Kotlin
Kotlin provides the @Volatile annotation, which compiles to the same thing as Java's volatile keyword. You place it on a var property, and it guarantees that reads and writes to that property go through main memory with proper memory barriers. Here is when you should use it. Use @Volatile for simple flags or state variables that are read by one thread and written by another. Our running flag example from earlier? Making it @Volatile fixes the infinite loop problem. Thread B writes false, and thread A is guaranteed to see it. Use @Volatile when you need a lightweight visibility guarantee without the overhead of a lock. But do not use @Volatile for anything that requires compound operations. If you are reading a value, doing a calculation, and writing back, @Volatile will not protect you from race conditions. You need synchronized or an atomic class for that. And if you are already using synchronized consistently, you probably do not need @Volatile at all, because synchronized provides visibility guarantees on its own.
Practical Patterns for Android
Let me give you some practical guidance for Android development. For UI state, use StateFlow or LiveData. Both enforce main-thread observation and internally handle all the visibility concerns. You do not need @Volatile on your UI state. For shared configuration flags, like a feature flag or an initialized boolean, @Volatile is a good fit. Simple reads and writes, no compound operations. For shared collections or complex data, use synchronized or better yet, use thread confinement. Confine mutation to a single coroutine and expose immutable snapshots via Flow. For counters and metrics, use AtomicInteger or AtomicLong. They give you both atomicity and visibility. And as a general rule, prefer immutable data classes with val properties for anything shared across threads. Copy-on-write is almost always safer and easier to reason about than synchronized mutation in place. The fewer mutable shared variables you have, the fewer visibility and race condition bugs you will encounter.
Recap and What is Next
Let us recap. Memory visibility is a separate problem from race conditions. Even without interleaving, threads can see stale data because of CPU caches, store buffers, and instruction reordering. The Java Memory Model defines when visibility is guaranteed through happens-before relationships. volatile guarantees visibility of a single field plus everything written before it, but not atomicity of compound operations. synchronized provides both mutual exclusion and memory visibility. Kotlin's val protects the reference, not the contents of the object, so it is not a thread-safety guarantee. @Volatile in Kotlin is the equivalent of Java's volatile keyword. In the next video, we are going to shift gears entirely and look at something magical: what the Kotlin compiler actually does to your suspend functions. We are going to decompile coroutines and see the state machine that makes suspension and resumption work. See you there.