Why “same variable” stores different things
A bug that only shows up after ProGuard/R8, or only when a nullable Int? slips through Compose state, often starts with the same confusion: what does this variable actually hold? In Java and Kotlin on Android, the answer depends on whether the type is primitive or reference — and Kotlin adds nullable wrappers that look like primitives but are not.
Primitive types — the value lives in the slot
Primitive types are the built-in scalar kinds: boolean, char, byte, short, int, long, float, double in Java; Kotlin adds unsigned variants and treats Boolean, Char, etc. as primitives at the JVM level when compiled.
When you write:
int a = 5;
The memory location for a contains the value 5. No indirection. Copying a primitive copies the bits:
int b = a; // b gets its own 5; changing b does not change a
That predictability is why primitives are the default for counters, flags, and hot loops — no aliasing surprises, no NullPointerException on the slot itself.
Reference types — the slot holds an address
Class types, arrays, interfaces, and String are reference types. The variable stores a reference (address) to an object on the heap, not the object’s fields inline.
SomeClass obj = new SomeClass();
Looking at obj in memory shows a pointer; the object’s fields live elsewhere. Class type, object type, and reference type mean the same thing — different textbooks, one idea.

Figure 1. Primitives store values; reference variables store addresses to heap objects.
Assignment copies the reference, not a deep clone:
SomeClass a = new SomeClass();
SomeClass b = a; // both point at the same instance
b.setFlag(true); // visible through a
That aliasing is correct for objects; it is a common source of “I thought I copied it” bugs with mutable lists and shared ViewModels.
Kotlin on Android — what looks primitive often is not
Kotlin’s Int, Boolean, and Double compile to JVM primitives when they are non-null locals and parameters. But Int?, Boolean?, and generic type parameters are boxed — they become Integer, Boolean, etc. on the heap.
var count: Int = 0 // primitive on JVM
var maybe: Int? = null // java.lang.Integer wrapper when nullable
listOf(1, 2, 3) // List<Int> — elements boxed as Integer
Pitfalls that show up in production:
-
Nullability vs primitive.
if (maybe != null) maybe + 1unboxes safely; storingmaybein a Java API expectingintwithout a check throws NPE. Composeremember { mutableStateOf<Int?>(null) }forces you to handle absent vs zero explicitly — they are different states. -
Autoboxing in collections and generics.
List<Int>cannot use primitive arrays under the hood; each element may allocate. For large numeric buffers useIntArray/LongArray, or Android’sSparseIntArraywhen keying by id. -
Identity vs equality on boxed types.
Integer.valueOf(127) == Integer.valueOf(127)may be true (cache);128 == 128with boxed integers compared by reference can fail. Prefer===/==on primitives; for nullable wrappers use structural equality after null checks. -
Platform types from Java. Android SDK and older libraries expose
@Nullable/@NonNullinconsistently. Kotlin sees JavaStringasString!— assignable to bothStringandString?. Interop bugs cluster at those boundaries.
// Java: void setCount(Integer count)
fun updateFromJava(value: Int?) {
api.setCount(value) // null may or may not be legal — check SDK contract
}
- R8 and reflection. Code that assumes a field is
intbut is actuallyIntegerafter Kotlin compilation can break reflective access or serialization. Release-build-only crashes here often trace back to type erasure and boxing, not “random” shrinker bugs.
Quick decision guide
| Situation | Prefer |
|---|---|
| Local counter, loop index | Non-null primitive (Int, Boolean) |
| Absent vs zero distinct | Nullable (Int?) — accept boxing cost |
| Large numeric array | IntArray / FloatArray, not List<Int> |
| Shared mutable object | Reference type + clear ownership |
| Java interop parameter | Match SDK nullability; avoid !! at boundaries |
In Compose, the same distinction shows up in what triggers recomposition. A mutableIntStateOf(0) tracks a primitive-backed slot; mutableStateOf<Int?>(null) tracks a nullable wrapper and a different “empty vs zero” story for your UI. Picking the wrong one is not a style issue — it is a state contract.
Understanding the split does not make you avoid objects — it tells you where aliasing, null, and allocation happen before they become ANRs or release-only NPEs.
References
- Primitive type declaration — Java Language Specification
- Kotlin: Java to Kotlin migration guide — nullability and platform types
- Android performance patterns — avoid autoboxing (classic guidance; still relevant for hot paths)