Value classes Kotlin : quand l’abstraction n’est pas gratuite
Un service Kotlin expose une API de profils utilisateurs. Pour tenir la latence, il garde un cache en mémoire : deux millions d’entrées, indexées par identifiant utilisateur. Le cache grossit avec le trafic. La heap grossit avec lui, et le conteneur approche de sa limite mémoire. Avant d’augmenter la limite, on analyse la heap.
L’histogramme des classes donne une première piste :
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
Deux millions d’instances vivantes de UserId. C’est surprenant, parce que UserId est déclaré ainsi :
@JvmInline
value class UserId(val value: String)
Avec @JvmInline, cette classe n’est pas censée avoir d’instances à l’exécution. C’est ce que veut dire « abstraction à coût zéro ». Pourtant, il y en a deux millions, soit 32 Mo au total.
Le code n’a rien d’anormal. Les identifiants et les montants sont enveloppés dans des value classes, et c’est une bonne pratique. Mais la forme inlinée s’applique dans moins de cas qu’on ne le pense. Elle ne s’applique pas aux endroits où l’on garde beaucoup de données.
Cet article mesure chaque cas. Tous les tests ont été faits sur OpenJDK 25.0.1 avec Kotlin 2.4.10, sur un Mac. Chaque run met deux millions d’entrées dans une structure, avec -XX:+UseSerialGC, et un processus JVM par variante.
Deux représentations
Une value class n’existe que pour le compilateur. Le compilateur supprime l’enveloppe et utilise directement la valeur sous-jacente. Il génère aussi des méthodes sur une classe qui, elle, existe : constructor-impl, box-impl et unbox-impl.
Il y a donc deux représentations. Dans la forme inlinée, un UserId n’est qu’une String. Dans la forme boxée, c’est un vrai objet sur la heap qui enveloppe cette String.
Le compilateur choisit la forme inlinée quand il le peut. La question est de savoir quand il ne le peut pas.
Les cas gratuits : champs et signatures
Dans la plupart du code, la value class est bien inlinée. C’est le cas des champs et des signatures de fonctions.
Une value class utilisée comme champ ne coûte rien. Ici, Cents enveloppe un Long :
@JvmInline
value class Cents(val value: Long)
class AccountRaw(val userId: String, val balance: Long)
class AccountVc(val userId: UserId, val balance: Cents)
Deux millions de chaque :
field-raw 54.1 MB 28.4 bytes/entry
field-vc 54.1 MB 28.4 bytes/entry
Les deux variantes ont la même taille. javap montre pourquoi :
class AccountVc {
private final java.lang.String userId;
private final long balance;
public final java.lang.String getUserId-w0BlNpU();
public final long getBalance-oFSpMh4();
}
Les champs sont déclarés avec les types sous-jacents, une String et un long. Il n’y a d’enveloppe nulle part. Le compilateur Kotlin vérifie le type, puis l’efface.
C’est la même chose pour les variables locales, les paramètres et les valeurs de retour des fonctions ordinaires. Dans les champs et les signatures, les value classes ne coûtent rien.
Les noms de méthodes ont un suffixe étrange. Il est expliqué plus bas.
Les cas boxés : collections et clés de map
Un argument de type générique doit être un type référence. Un long ne peut pas aller dans une ArrayList. Et le compilateur doit garder UserId distinct d’une simple String. Donc, dans toute position générique, l’enveloppe revient.
En pratique, cela concerne surtout les collections. C’est là que le coût apparaît :
element-raw 7.7 MB 4.1 bytes/entry ArrayList<String>
element-vc 38.7 MB 20.3 bytes/entry ArrayList<UserId>
Cinq fois plus de mémoire. Les String sont les mêmes dans les deux runs. Toute la différence vient donc du surcoût. La case du tableau coûte quatre octets dans les deux cas. La version value class ajoute une enveloppe de 16 octets par élément.
Deux millions d’éléments, 16 octets chacun. Ce sont les 32 Mo de l’histogramme.
Quand la value class enveloppe une primitive, le problème est différent :
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> coûte la même chose que ArrayList<Long>. La value class ne coûte donc pas plus que le boxing classique. Mais les deux coûtent trois fois et demie plus cher qu’un LongArray.
Le vrai problème est ailleurs. Envelopper le type dans Cents fait oublier le LongArray. On ne pense plus au tableau de primitives, parce que le type n’est plus une primitive.
Le cas typique est un 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>
Soit 25 % de plus, pour les mêmes données. Chaque clé est boxée à l’insertion. Chaque enveloppe vit aussi longtemps que son entrée. Sur quelques millions d’entrées, cela fait des dizaines de Mo.
Rien ne le signale lors d’une revue de code. Le code est correct et bien typé. Mais il n’est pas gratuit.
Nullabilité : primitive ou référence
On lit souvent qu’une value class nullable est boxée. C’est en partie vrai. Cela dépend de ce que la value class enveloppe :
class PrimNullable(val c: Cents?) // Cents enveloppe un Long
class RefNullable(val u: UserId?) // UserId enveloppe une String
prim-nonnull 54.1 MB 28.4 bytes/entry
prim-nullable 85.0 MB 44.6 bytes/entry <- boxée
ref-nonnull 38.7 MB 20.3 bytes/entry
ref-nullable 38.7 MB 20.3 bytes/entry <- pas boxée
Les déclarations des champs montrent la même chose :
PrimNullable private final Cents c; boxée
RefNullable private final java.lang.String u; pas boxée
La raison est simple. Un long ne peut pas être null. Donc Cents? a besoin d’un vrai objet. Une String peut déjà être null. Donc UserId? n’est qu’une String nullable, et il n’y a rien à boxer.
Rendre nullable une value class qui enveloppe une primitive coûte 16 octets par instance. Rendre nullable une value class qui enveloppe une référence ne coûte rien, tant que cette référence n’est pas elle-même nullable. Il est utile de le savoir avant de retirer des points d’interrogation pour des raisons de performance.
Les fonctions suspend
Ce cas est important sur un backend Kotlin, et on en parle rarement.
class Api {
fun plain(id: Long): Cents = Cents(id)
suspend fun suspended(id: Long): Cents = Cents(id)
}
javap sur la classe compilée :
public final long plain-CX4V5Mk(long);
public final java.lang.Object suspended-BxMNKqE(long, kotlin.coroutines.Continuation<? super Cents>);
La fonction ordinaire retourne un long. La fonction suspend retourne java.lang.Object.
Elle n’a pas le choix. La valeur de retour peut aussi être le marqueur COROUTINE_SUSPENDED, qui indique à l’appelant que la fonction est suspendue. Un long ne peut pas porter ce marqueur. Le type de retour devient donc Object, et la value class est boxée pour y entrer.
Le bytecode le confirme :
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
Il y a un appel explicite à box-impl à chaque retour.
Une value class sur une référence n’a pas ce problème. Voici la même fonction avec UserId, dans la même classe :
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
Le type de retour reste Object, mais une String y entre déjà. Le compilateur retourne la String telle quelle, sans box-impl.
Prenons un service construit sur des coroutines. Chaque fonction suspend qui retourne Cents alloue une enveloppe à chaque appel. C’est la même chose pour toute value class sur un Long, un Int ou un Boolean. La fonction ordinaire n’alloue pas cette enveloppe.
Ces objets sont petits et vivent peu de temps. Le GC les collecte dans la jeune génération. Ils n’apparaissent donc pas dans un heap dump. Ils apparaissent dans le taux d’allocation et dans la fréquence des GC, un signal plus difficile à relier à sa cause.
Ce n’est pas une raison pour ne plus retourner de value classes dans les fonctions suspend. Mais le « coût zéro » n’est plus vrai dès qu’une fonction est suspend. Si vous cherchez d’où vient la pression d’allocation dans un service à base de coroutines, commencez par là.
Les noms de méthodes
Ce cas ne coûte pas de mémoire. Il surprend quand du code Java appelle une bibliothèque Kotlin.
Regardez à nouveau les noms de méthodes d’AccountVc :
public final java.lang.String getUserId-w0BlNpU();
public final long getBalance-oFSpMh4();
Chaque fonction qui prend une value class en paramètre, et chaque méthode qui en retourne une, reçoit un suffixe : un tiret et un hash.
Ce suffixe vient de l’effacement du type. Une fois UserId devenu String, deux fonctions qui ne diffèrent que par le type de l’enveloppe auraient la même signature JVM. Le compilateur leur donne donc des noms différents.
Cela a deux conséquences. D’abord, cette méthode ne peut pas être appelée depuis Java, parce qu’un tiret n’est pas autorisé dans un identifiant Java. Ensuite, le suffixe dépend des types de value class de la signature. Si on les change, le nom de la méthode change aussi. Cela compte si du code utilise la réflexion sur votre API, ou si vous devez garder la compatibilité binaire.
Aucune des deux n’est une raison d’éviter les value classes. Mais il vaut mieux les garder hors d’une API utilisée depuis Java. Si elles doivent y rester, fixez le nom avec @JvmName, ou @get:JvmName pour un getter.
Les compact headers de JDK 25
Avec la JEP 519, les compact object headers sont une fonctionnalité officielle de JDK 25. Cette fonctionnalité est désactivée par défaut et s’active avec -XX:+UseCompactObjectHeaders. L’en-tête des objets passe de 12 octets à 8. Chaque value class boxée devrait donc coûter moins cher.
La mesure est plus nuancée :
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 inchangé
L’enveloppe d’une primitive passe de 24 octets à 16. Par entrée, le gain est de 29 %.
L’enveloppe d’une référence ne change pas de taille. Huit octets d’en-tête plus une référence compressée de quatre octets font 12. L’alignement sur huit octets arrondit cette taille à 16. Le padding absorbe le gain.
Les compact headers valent donc la peine d’être activés. Mais ils n’aident que les enveloppes de primitives. Ils ne réduisent pas le coût de l’enveloppe dans une Map dont les clés sont une value class qui enveloppe une String.
Que faire
La solution n’est pas de retirer les value classes. C’est de les convertir à l’entrée des grandes structures.
Les types métier restent des value classes dans les signatures et les champs. C’est là qu’elles sont gratuites, et c’est là que le typage est utile. Les clés du cache et les grosses collections repassent aux types simples, String et Long, avec une conversion à l’entrée.
Sur l’application du début, ce changement a libéré environ un cinquième de la mémoire occupée par les objets vivants. Le modèle de données et le matériel n’ont pas changé. Ce chiffre vient d’une seule application. Le mécanisme, lui, est général, et les chiffres ci-dessus donnent le détail par structure.
Les règles pratiques :
- Dans les champs, les paramètres et les types de retour, gardez les value classes. Le coût mesuré est nul, et cela couvre l’essentiel du code.
- Comme éléments de collection ou clés de map, mesurez d’abord. Il y a une enveloppe boxée par entrée, gardée aussi longtemps que la structure. C’est là qu’un gros cache grossit d’un cinquième sans que personne ne le remarque.
- Pour des données numériques en masse, pensez à un tableau de primitives. Un
LongArrayprend trois fois et demie moins de mémoire, et le typeCentsfait oublier cette option. - La nullabilité ne coûte que pour les value classes qui enveloppent une primitive. Rendre nullable une value class qui enveloppe une référence non nullable ne coûte rien.
- Dans un service à base de coroutines, une value class qui enveloppe une primitive est boxée à chaque retour d’une fonction suspend. Cela n’apparaît pas dans un heap dump, seulement dans le taux d’allocation.
- Regardez l’histogramme. Lancez
jcmd <pid> GC.class_histogramet cherchez les noms de vos value classes. Par défaut, cette commande déclenche un full GC. AvecGC.class_histogram -all, il n’y a pas de full GC, mais les objets morts sont comptés aussi. Vos value classes ne devraient pas y être en grand nombre. Si elles y sont, vous avez trouvé les instances boxées, et leur nombre vous indique de quelle structure elles viennent.
Ce dernier point est le diagnostic à faire en premier. Une value class dans un histogramme de la heap est un signal clair. C’est une classe qui, en théorie, n’a pas d’instances à l’exécution.
En résumé
Les value classes Kotlin ont deux représentations. Les cas où la forme inlinée s’applique sont plus limités que la promesse du « coût zéro » ne le laisse penser.
Elles sont gratuites comme champs, paramètres, variables locales et types de retour de fonctions ordinaires. Cela couvre l’essentiel du code. Gardez-les là.
Elles sont boxées dans toute position générique, donc dans chaque collection et chaque clé de map. Comptez 16 octets par instance pour une classe qui enveloppe une référence ou un Int, et 24 pour une classe qui enveloppe un Long. C’est là qu’un gros cache grossit d’un cinquième sans aucun gain.
La nullabilité ne coûte que pour les value classes qui enveloppent une primitive. Celles qui enveloppent une référence non nullable ont déjà un moyen de représenter null.
Chaque fonction suspend qui retourne une value class qui enveloppe une primitive la boxe. La valeur de retour doit pouvoir porter le marqueur de suspension, et un long ne le peut pas. Dans un service à base de coroutines, c’est une pression d’allocation qui n’apparaît pas dans un heap dump.
Une seule commande montre le problème principal. Lancez l’histogramme des classes et cherchez vos propres types métier. S’ils y sont par millions, ils sont boxés.
Et après ?
L’histogramme montre ce qui s’accumule, pas ce qui le retient. Pour remonter jusqu’à la référence qui garde une structure en vie, voir Diagnostiquer une fuite mémoire avec un heap dump. Et pour les autres problèmes des coroutines en production, voir Les coroutines Kotlin en production.