Opening
Every Kotlin developer knows the difference between val and var. Val means you cannot reassign it. Var means you can. Simple, right? But here is the thing — that is the surface-level story. What actually happens in memory when you choose one over the other? What does it mean for an object to be immutable — not just at the language level, but at the byte level? And why do people keep telling you that immutability makes threading safe? Today we are going to dig into what mutability and immutability really mean — in memory, in the runtime, and in your Android apps. By the end, you will understand not just the what, but the why. And you will know when immutability is your best friend, and when it is silently costing you performance.
val vs var: What the Bytecode Actually Shows
Let us start with the most basic question. When you write val x = 42 versus var x = 42, what is different in memory? Here is the answer that might surprise you: at the moment of creation, nothing. Both of them store the number 42 in the same way. The difference is what the compiler allows you to do afterward. Val means the reference cannot be reassigned. The compiler simply refuses to generate bytecode for any reassignment. Var means the compiler will happily generate that reassignment bytecode. But here is the critical distinction: val does not mean the object itself is immutable. Val means the reference is final — like a final field in Java. The sticky note with the address on it cannot be changed to point somewhere else. But the object at that address? It can absolutely change. If you write val list = mutableListOf(1, 2, 3) and then call list.add(4), that works perfectly fine. The reference did not change. The object did. This is the number one source of confusion around mutability in Kotlin, and it matters enormously.
Immutable Objects: Same Bytes Forever
So what does a truly immutable object look like in memory? Think about a Kotlin String. When you create the string "Hello", the runtime allocates a block of memory, writes the bytes for H-e-l-l-o into it, and from that moment on, those bytes never change. There is no method on String that modifies the characters in place. When you call uppercase() or replace() or plus(), you are not changing the original — you are creating an entirely new String object at a different memory address with different bytes. The original sits there untouched. This is immutability at the memory level. The bytes at that address are frozen. No operation, no thread, no reflection trick will alter them. And this property is incredibly powerful. If you hand that String to five different threads, none of them can corrupt it. They are all reading the same frozen bytes. There is no race condition possible on data that cannot change.
Mutable Objects: Bytes Change in Place
Now contrast that with a mutable object. Take a MutableList backed by an ArrayList. When you call add(), what happens? The runtime goes to the memory where the list's internal array lives and writes new data into it. The object at the same address changes its contents. Its bytes are rewritten in place. This is fundamentally different from the String case. And it is where problems start. If thread A is iterating through the list reading elements, and thread B calls add() at the same time, thread A might read half-old, half-new data. You get a ConcurrentModificationException if you are lucky. You get corrupted silent data if you are not. The bytes are changing under your feet. That is the core problem with mutability in a concurrent world. It is not an abstract concept — it is literally about whether the bytes at a memory address can change while someone else is reading them.
Copy-on-Write: The Clever Middle Ground
There is an elegant technique that sits between fully mutable and fully immutable, and it is called copy-on-write. The idea is simple: share a single copy of the data for as long as everyone is just reading it. But the moment someone wants to modify it, make a copy first, then modify the copy. The original stays untouched. You see this pattern everywhere. When you call toMutableList() on a Kotlin List, you get a copy. The original list is unaffected. When you use persistentListOf from the Kotlinx collections library, insertions and removals create new versions of the data structure while sharing most of the underlying nodes. Copy-on-write gives you the safety of immutability with the flexibility of mutation — at the cost of the copy. And that cost matters. If your data structure has ten thousand elements and you need to change one, copying all ten thousand is expensive. Persistent data structures solve this by sharing structure — only the changed parts get new nodes. But even then, there is overhead. Nothing is free.
Why Immutability Makes Threading Safe
Let me make the threading argument crystal clear, because this is one of the most important concepts in concurrent programming. A data race requires three things: two threads accessing the same memory, at least one of them writing, and no synchronization between them. If you remove any one of those conditions, you cannot have a data race. Immutability removes the second condition entirely. If nobody writes, there is no race. Period. This is why functional programming emphasizes immutability so heavily. This is why Kotlin's coroutines documentation recommends sharing immutable data between coroutines. This is why data class with val properties is the default recommendation for state objects. You are not just following a style preference. You are eliminating an entire category of bugs — at the memory level. No locks needed. No synchronization. No volatile. Just frozen bytes that everyone can read safely.
The String Pool
Here is a fascinating optimization that the JVM does with immutable Strings. Because Strings cannot change, the runtime can safely share them. When you write val a = "Hello" and val b = "Hello", the JVM does not necessarily create two separate objects. It can point both references to the exact same object in a special area called the String pool. Same memory address. Same bytes. Two references. This works precisely because Strings are immutable. If "Hello" could be modified, sharing it would be catastrophic — changing it through reference a would silently change what reference b sees. But since the bytes are frozen, sharing is perfectly safe. You can force this sharing yourself by calling String.intern(), which says: if this exact String already exists in the pool, give me a reference to that one instead. This saves memory when you have many duplicate strings — which happens more than you might think in JSON parsing, database results, and configuration data.
data class copy(): What Actually Happens
Kotlin data classes give you a copy() function that is one of the most elegant patterns for working with immutable state. When you have a data class User with val name and val age, and you write user.copy(age = 31), what happens in memory? A brand new User object is allocated. The name reference is copied — meaning both the old and new User point to the same String object. The age value is set to 31. The old User object is completely untouched. This is sometimes called a shallow copy. The references are copied, not the objects they point to. And because the properties are val, this is safe — the shared String cannot change. This pattern is the backbone of state management in modern Android. Your ViewModel holds a StateFlow of an immutable data class. When something changes, you create a new copy with the updated field and emit it. The UI observes the new value. The old state is never modified. It is clean, predictable, and thread-safe by construction.
When Immutability Costs Too Much
Now I need to be honest with you. Immutability is not free. Every time you create a modified copy of an object, you are allocating memory. The garbage collector has to clean up the old copies. For a simple data class with a few fields, this is negligible. But consider a large object graph — say, a deeply nested UI state with lists inside lists inside objects. Creating a new copy of the entire graph just to change one boolean flag deep inside it means allocating a lot of objects. In hot paths — say, something running in onDraw or in a tight animation loop — this allocation pressure can cause garbage collection pauses and frame drops. This is where you need judgment. For your screen-level state that changes a few times per second? Immutable copies are perfect. For pixel-level calculations running sixty times per second? You might need mutable buffers that you reuse. The right answer depends on how often the data changes and how large it is. Immutability is the default. Mutability is the optimization.
Kotlin Collections: The Interface Lie
Here is something that trips up even experienced Kotlin developers. When you write val items: List
Putting It All Together
Let me bring this all together with a practical mental model. Think of memory as a city grid. Each address is a building. Val means you have taped the building's address to your wall and you cannot change which building the address points to. But what happens inside that building — that depends on the building. An immutable object is a sealed building. You can look through the windows, but nothing inside ever moves. A mutable object is a building where the furniture gets rearranged constantly. If two people try to rearrange the furniture at the same time without talking to each other, things break. Copy-on-write is like taking a photograph of the building, modifying the photograph, and building a new structure from it — the original building is untouched. In your Android apps, default to val and immutable data classes for state. Use MutableList and var when you genuinely need in-place mutation for performance. And always remember: the List interface in Kotlin is a promise from the compiler, not a guarantee from the runtime.
Recap and What is Next
Let us review what we covered. Val makes a reference final but does not make the object immutable. True immutability means the bytes in memory never change, which eliminates data races entirely. The JVM exploits this with the String pool. Copy-on-write gives you immutability semantics with manageable cost. Data class copy() creates new objects with shared references. But immutability has a price — large object graphs pay for it in allocations. And Kotlin's List interface is read-only, not truly immutable. In the next video, we are going to look at what happens when objects point to other objects — object graphs and references — and why that is the root cause of memory leaks in Android. See you there.