skip to content

Do T and T? differ at the JVM bytecode level, and what are the consequences for nullability enforcement at runtime?

level: seniorimportance: nice to knowfreq 26%

answer

  1. Reference T and T? erase to same JVM type
  2. Intrinsics.checkNotNullParameter guards public params
  3. Null from Java throws NPE at the boundary
  4. Int -> int, Int? -> boxed Integer
  5. Nullability stored in @Metadata + annotations

basics

~20 s

For object references, T and T? look the same at runtime — nullability is a compile-time rule. The compiler adds runtime null checks at public boundaries so callers from Java or reflection still fail fast.

solid answer

~50 s

Nullability is mostly a compile-time concept. For reference types, String and String? erase to the same JVM type (java.lang.String); the JVM has no notion of a non-null reference, so the distinction lives in metadata and the Kotlin compiler's checks. To keep the guarantee honest when callers bypass the type checker (Java, reflection, deserialization), the compiler inserts runtime intrinsics — Intrinsics.checkNotNullParameter on non-null public function parameters and null checks on non-null returns — so a null arriving from outside throws NullPointerException immediately rather than corrupting state. Nullability is also recorded in @Nullable/@NotNull-style annotations and Kotlin metadata so other Kotlin modules see it. A key exception is primitives: Int maps to the primitive int, but Int? must box to java.lang.Integer because a primitive slot can't hold null — so there the nullable type has a genuinely different runtime representation.

code

kotlin · 7 lines
kotlin
// Public non-null param gets a runtime guard
fun process(input: String): Int = input.length
// ~ compiles to: Intrinsics.checkNotNullParameter(input, "input")
//                return input.length();

val boxed: Int? = 5    // java.lang.Integer
val prim: Int = 5      // primitive int

go deeper

for a junior

Understands nullability is a Kotlin compile-time concept and may not know JVM details.

for a middle

Knows references erase the same way and that NPEs can still occur via interop.

for a senior

Explains Intrinsics.checkNotNullParameter guards, the @Metadata storage, and Int vs Int? boxing.

for a principal

Reasons about boundary safety design, where guards are emitted vs omitted, and performance implications of boxing in hot paths.

## Reference types: same erasure On the JVM there is no such thing as a 'non-null reference' — any object reference can be `null`. So for reference types, `String` and `String?` both compile to `java.lang.String`. The non-null/nullable distinction is **compile-time only**, recorded in: - **Kotlin metadata** (the `@Metadata` annotation) so other Kotlin code sees the real signatures. - **Nullability annotations** on the bytecode boundary so Java tooling sees intent. ## Runtime guards at public boundaries Because Java callers, reflection, and deserialization can bypass Kotlin's type checker, the compiler **inserts runtime checks** to keep the non-null contract honest: ```kotlin fun greet(name: String) = "Hi $name" ``` For a public function, this compiles to roughly: ``` Intrinsics.checkNotNullParameter(name, "name") ``` If a `null` arrives anyway (e.g. from Java), it throws `NullPointerException` **at the boundary**, failing fast instead of producing a confusing error deep inside. Non-null **return** values from external calls are similarly checked (`checkNotNullExpressionValue` / `checkNotNull`). These guards are generally **not** emitted for private/internal code paths the compiler already proved safe. ## Primitives: a real representation difference Here `T` vs `T?` genuinely differ at runtime: - `Int` → JVM primitive `int` (no null possible). - `Int?` → boxed `java.lang.Integer` (a reference, which can be null). ```kotlin val a: Int = 5 // int 5 val b: Int? = 5 // Integer.valueOf(5) — boxed val c: Int? = null // null reference ``` So making a primitive nullable forces **boxing**, with allocation and identity consequences. This is the one place where the nullable suffix changes the actual runtime type, not just metadata. ## Consequences - **Trust but verify at the edge**: inside pure Kotlin you can rely on the types; at Java/reflection/JSON boundaries the inserted intrinsics are your safety net — but a `lateinit` or platform-typed value can still slip a null past you. - **Don't `!!` away platform types blindly**: the runtime guard fires only where the compiler emitted it; a `String!` from Java carries no guard until it hits a non-null slot. - **Performance**: nullable primitives box; prefer non-null primitives or value classes in hot paths.

  • If nullability is compile-time only for references, how can a null still cause a NullPointerException in Kotlin?
    Through boundaries the checker can't see: Java interop (platform types), reflection, deserialization, or !!/lateinit misuse. The inserted Intrinsics checks throw at the public boundary precisely to surface these early.
  • Why might using Int? in a tight loop hurt performance?
    Int? boxes to java.lang.Integer, adding allocation and pointer indirection versus the primitive int that Int uses.

saying these in an interview costs you the question

  • Believing the JVM enforces non-nullability natively
  • Saying Int and Int? have identical runtime representation (they don't — boxing)
  • Not knowing the compiler inserts Intrinsics null checks
  • Claiming Kotlin can never throw an NPE

context