Kotlin value classes: when the abstraction is not free

A Kotlin service exposes a user profile API. To keep latency low, it keeps a cache in memory: two million entries, indexed by user id. The cache grows with traffic. The heap grows with it, and the container gets close to its memory limit. Before raising the limit, we analyze the heap.

The class histogram gives a first lead:

 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:       5012440      160398080  [B (java.base@25.0.1)
   2:       4821880      115725120  java.lang.String (java.base@25.0.1)
   3:       2000000       64000000  java.util.concurrent.ConcurrentHashMap$Node (java.base@25.0.1)
   4:       2000000       32000000  UserId

Two million live instances of UserId. That is surprising, because UserId is declared like this:

@JvmInline
value class UserId(val value: String)

With @JvmInline, this class is not supposed to have instances at runtime. That is what “zero-cost abstraction” means. Yet there are two million of them, 32 MB in total.

There is nothing unusual in the code. Identifiers and amounts are wrapped in value classes, and that is good practice. But the inlined form applies in fewer cases than people think. It does not apply to the places where a lot of data is kept.

This article measures each case. All tests were run on OpenJDK 25.0.1 with Kotlin 2.4.10, on a Mac. Each run puts two million entries in a structure, with -XX:+UseSerialGC, and one JVM process per variant.

Two representations

A value class exists only for the compiler. The compiler removes the wrapper and uses the underlying value directly. It also generates methods on a class that does exist: constructor-impl, box-impl and unbox-impl.

So there are two representations. In the inlined form, a UserId is only a String. In the boxed form, it is a real object on the heap that wraps that String.

The compiler picks the inlined form when it can. The question is when it cannot.

The free cases: fields and signatures

In most of the code, the value class is inlined as promised. That is the case for fields and function signatures.

A value class used as a field costs nothing. Here, Cents wraps a Long:

@JvmInline
value class Cents(val value: Long)

class AccountRaw(val userId: String, val balance: Long)
class AccountVc(val userId: UserId, val balance: Cents)

Two million of each:

field-raw           54.1 MB    28.4 bytes/entry
field-vc            54.1 MB    28.4 bytes/entry

Both variants have the same size. javap shows why:

class AccountVc {
  private final java.lang.String userId;
  private final long balance;
  public final java.lang.String getUserId-w0BlNpU();
  public final long getBalance-oFSpMh4();
}

The fields are declared with the underlying types, a String and a long. There is no wrapper anywhere. The Kotlin compiler checks the type, then erases it.

The same holds for local variables, parameters and return values of ordinary functions. In fields and signatures, value classes cost nothing.

The method names carry an odd suffix. It is explained below.

The boxed cases: collections and map keys

A generic type argument has to be a reference type. A long cannot go into an ArrayList. And the compiler has to keep UserId distinct from a plain String. So in any generic position, the wrapper comes back.

In practice, this mostly means collections. That is where the cost appears:

element-raw          7.7 MB     4.1 bytes/entry     ArrayList<String>
element-vc          38.7 MB    20.3 bytes/entry     ArrayList<UserId>

Five times the memory. The strings are the same in both runs. So the whole difference comes from the overhead. The array slot costs four bytes in both cases. The value class version adds a 16-byte wrapper per element.

Two million elements, 16 bytes each. That is the 32 MB from the histogram.

When the value class wraps a primitive, the problem is different:

prim-array          15.5 MB     8.1 bytes/entry     LongArray
prim-boxed          54.1 MB    28.4 bytes/entry     ArrayList<Long>
prim-vc             54.1 MB    28.4 bytes/entry     ArrayList<Cents>

ArrayList<Cents> costs the same as ArrayList<Long>. So the value class costs no more than plain boxing. But both cost three and a half times as much as a LongArray.

The real problem is elsewhere. Wrapping the type in Cents hides the LongArray. Nobody thinks of a primitive array anymore, because the type is no longer a primitive.

The typical case is a cache:

cache-raw          127.2 MB    63.6 bytes/entry     ConcurrentHashMap<String, Long>
cache-vc           159.2 MB    79.6 bytes/entry     ConcurrentHashMap<UserId, Long>

That is 25% more, for the same data. Each key is boxed when it is inserted. Each wrapper lives as long as its entry. On a few million entries, that is tens of megabytes.

Nothing flags it in a code review. The code is correct and type-safe. But it is not free.

Nullability: primitive or reference

It is often said that a nullable value class is boxed. That is partly true. It depends on what the value class wraps:

class PrimNullable(val c: Cents?)     // Cents wraps a Long
class RefNullable(val u: UserId?)     // UserId wraps a String
prim-nonnull        54.1 MB    28.4 bytes/entry
prim-nullable       85.0 MB    44.6 bytes/entry     <- boxed
ref-nonnull         38.7 MB    20.3 bytes/entry
ref-nullable        38.7 MB    20.3 bytes/entry     <- not boxed

The field declarations show the same thing:

PrimNullable   private final Cents c;               boxed
RefNullable    private final java.lang.String u;    not boxed

The reason is simple. A long cannot be null. So Cents? needs a real object. A String can already be null. So UserId? is only a nullable String, and there is nothing to box.

Making a value class that wraps a primitive nullable costs 16 bytes per instance. Making a value class that wraps a reference nullable costs nothing, as long as that reference is not itself nullable. It is useful to know this before removing question marks for performance reasons.

Suspend functions

This case matters on a Kotlin backend, and it is rarely mentioned.

class Api {
    fun plain(id: Long): Cents = Cents(id)
    suspend fun suspended(id: Long): Cents = Cents(id)
}

javap on the compiled class:

public final long plain-CX4V5Mk(long);
public final java.lang.Object suspended-BxMNKqE(long, kotlin.coroutines.Continuation<? super Cents>);

The ordinary function returns a long. The suspend function returns java.lang.Object.

It has no choice. The return value can also be the COROUTINE_SUSPENDED marker, which tells the caller that the function is suspended. A long cannot carry that marker. So the return type becomes Object, and the value class is boxed to fit in it.

The bytecode confirms it:

  plain-CX4V5Mk:
         4: lreturn

  suspended-BxMNKqE:
         1: invokestatic  #17    // Method Cents."constructor-impl":(J)J
         4: invokestatic  #28    // Method Cents."box-impl":(J)LCents;
         7: areturn

There is an explicit box-impl call on every return.

A reference-backed value class does not have this problem. Here is the same function with UserId, in the same class:

class Api {
    suspend fun suspendedRef(s: String): UserId = UserId(s)
}
  suspendedRef-Ecf0uAc:
         1: invokestatic  #44    // Method UserId."constructor-impl":(Ljava/lang/String;)Ljava/lang/String;
         4: areturn

The return type is still Object, but a String already fits in it. The compiler returns the String as is, with no box-impl.

Take a service built on coroutines. Every suspend function that returns Cents allocates a wrapper on every call. The same holds for any value class over a Long, an Int or a Boolean. The ordinary function does not allocate it.

These objects are small and short-lived. The GC collects them in the young generation. So they do not appear in a heap dump. They appear in the allocation rate and in the GC frequency, a signal that is harder to trace back to its cause.

This is not a reason to stop returning value classes from suspend functions. But the “zero cost” is no longer true once a function is suspend. If you are looking for the source of allocation pressure in a coroutine-based service, start there.

Method names

This case costs no memory. It causes surprises when Java code calls a Kotlin library.

Look again at the method names of AccountVc:

public final java.lang.String getUserId-w0BlNpU();
public final long getBalance-oFSpMh4();

Every function that takes a value class as a parameter, and every method that returns one, gets a suffix: a hyphen and a hash.

This suffix comes from the type erasure. Once UserId has become String, two functions that differ only by the wrapper type would have the same JVM signature. So the compiler gives them different names.

This has two consequences. First, that method cannot be called from Java, because a hyphen is not allowed in a Java identifier. Second, the suffix depends on the value class types in the signature. If they change, the method name changes too. That matters if code uses reflection on your API, or if you need to keep binary compatibility.

Neither is a reason to avoid value classes. But it is better to keep them out of an API used from Java. If they have to stay there, set the name with @JvmName, or @get:JvmName for a getter.

Compact headers in JDK 25

With JEP 519, compact object headers are an official feature of JDK 25. The feature is off by default and is enabled with -XX:+UseCompactObjectHeaders. The object header goes from 12 bytes to 8. So every boxed value class should cost less.

The measurement is more nuanced:

prim-vc   default headers    54.1 MB    28.4 bytes/entry
prim-vc   compact headers    38.7 MB    20.3 bytes/entry     -29%

element-vc default headers   38.7 MB    20.3 bytes/entry
element-vc compact headers   38.7 MB    20.3 bytes/entry     no change

The wrapper of a primitive goes from 24 bytes to 16. Per entry, the saving is 29%.

The wrapper of a reference does not change size. Eight bytes of header plus a four-byte compressed reference make 12. Eight-byte alignment rounds that size up to 16. The padding absorbs the saving.

So compact headers are worth enabling. But they only help the wrappers of primitives. They do not reduce the wrapper cost in a Map whose keys are a value class that wraps a String.

What to do

The solution is not to remove value classes. It is to convert them at the entry of the large structures.

Domain types stay value classes in signatures and fields. That is where they are free, and where the type safety is useful. The cache keys and the big collections go back to plain types, String and Long, with a conversion at the entry.

On the application from the beginning, this change freed roughly a fifth of the memory held by live objects. The data model and the hardware did not change. That figure comes from one application. The mechanism is general, and the numbers above give the detail per structure.

The practical rules:

That last point is the diagnostic to run first. A value class in a heap histogram is a clear signal. It is a class that, in theory, has no instances at runtime.

In short

Kotlin value classes have two representations. The cases where the inlined form applies are narrower than the “zero cost” promise suggests.

They are free as fields, parameters, local variables and return types of ordinary functions. That covers the bulk of the code. Keep them there.

They are boxed in any generic position, so in every collection and every map key. Count 16 bytes per instance for a class that wraps a reference or an Int, and 24 for a class that wraps a Long. That is where a large cache grows by a fifth with no benefit.

Nullability only costs on value classes that wrap a primitive. Those that wrap a non-null reference already have a way to represent null.

Every suspend function that returns a value class that wraps a primitive boxes it. The return value has to be able to carry the suspension marker, and a long cannot. In a coroutine-based service, that is allocation pressure that does not show up in a heap dump.

One command shows the main problem. Run the class histogram and look for your own domain types. If they are there by the million, they are boxed.

What next?

The histogram shows what accumulates, not what holds it. To trace back to the reference that keeps a structure alive, see Diagnosing a memory leak with a heap dump. And for the other problems of coroutines in production, see Kotlin coroutines in production.