Mémoire de la JVM et garbage collector
L'idée en une phrase
Java range tes objets dans une grande zone mémoire, la heap, et un garbage collector (GC, le « ramasse-miettes ») la nettoie tout seul. Tu n'as jamais à libérer la mémoire toi-même… à condition de ne pas garder, sans le vouloir, des objets dont tu n'as plus besoin.
Une image pour comprendre : l'entrepôt et le bureau
Imagine une entreprise.
- L'entrepôt (la heap) est partagé par tout le monde. On y stocke les cartons : ce sont tes objets.
- Chaque employé a un bureau (la stack, une par thread). Il y colle des post-its : « le carton du client Dupont est en allée 4 ». Ce sont tes variables locales et tes références.
- Quand un employé termine une tâche, il jette ses post-its.
- Une équipe de ménage (le GC) passe régulièrement dans l'entrepôt. Elle jette tous les cartons que plus aucun post-it ne désigne.
Un carton n'est jamais jeté tant qu'il reste au moins un post-it qui pointe vers lui. Retiens bien ça : c'est l'origine de toutes les fuites mémoire.
Heap et stack dans le code
public CommandeDto afficher(Long id) {
int quantite = 3; // nombre simple : stack
Commande commande = repository.findById(id) // la variable "commande" : stack
.orElseThrow(); // l'objet Commande : heap
String libelle = commande.libelle().toUpperCase(); // nouvelle String : heap
return new CommandeDto(libelle, quantite); // nouvel objet : heap
}
// fin de la méthode : quantite, commande et libelle disparaissent de la stack
// l'objet Commande n'est plus désigné par personne → le GC pourra le supprimer
| Stack | Heap | |
|---|---|---|
| Contient | variables locales, appels de méthodes en cours | tous les objets (new ...) |
| Appartient à | un seul thread | toute l'application |
| Libérée | automatiquement, à la fin de chaque méthode | par le GC, quand plus rien ne pointe vers l'objet |
| Erreur quand elle est pleine | StackOverflowError |
OutOfMemoryError |
Dans une application Spring Boot, c'est rassurant : les objets créés pendant une requête HTTP (entités, DTO, chaînes…) ne sont plus désignés par personne quand la requête se termine. Le GC les supprime. Tu n'as rien à faire.
Le garbage collector
Le GC part des points d'ancrage de l'application : les variables des méthodes en cours d'exécution et les champs static. Il suit toutes les références à partir de là. Tout ce qu'il atteint est conservé ; tout le reste est supprimé.
Ce que tu dois savoir en pratique :
- Il fonctionne tout seul. Tu n'as pas à l'appeler, et
System.gc()n'est qu'une suggestion qui ne règle rien. N'en mets pas dans ton code. - Java choisit un GC adapté par défaut (G1). Tu n'as pas à le choisir ni à le régler : c'est un sujet d'exploitation, pas de développement.
- Il est conçu pour supprimer très efficacement les objets qui vivent peu de temps, comme ceux d'une requête HTTP. Créer des objets n'est pas un problème.
StackOverflowError : la boucle sans fin
La stack déborde quand des méthodes s'appellent les unes les autres sans jamais s'arrêter. Chaque appel ajoute un étage à la pile, jusqu'à ce qu'il n'y ait plus de place.
Le cas d'école, c'est une méthode récursive qui a oublié son cas d'arrêt. Mais en Spring Boot, la cause la plus fréquente est plus sournoise : deux entités qui se référencent mutuellement.
@Entity
public class Client {
@OneToMany(mappedBy = "client")
private List<Commande> commandes; // un client connaît ses commandes
}
@Entity
public class Commande {
@ManyToOne
private Client client; // une commande connaît son client
}
@GetMapping("/clients/{id}")
public Client afficher(@PathVariable Long id) {
return clientRepository.findById(id).orElseThrow(); // ❌ renvoie l'entité directement
}
Pour transformer le client en JSON, Spring écrit ses commandes, puis le client de chaque commande, puis ses commandes, puis… à l'infini. Résultat : StackOverflowError. Le même piège existe avec un toString() généré (par exemple avec Lombok @Data) sur les deux entités.
La bonne habitude : ne renvoie jamais une entité depuis un contrôleur. Renvoie un DTO, un objet fait pour la réponse, qui ne contient que ce dont le client a besoin.
public record ClientDto(String nom, int nombreCommandes) {}
@GetMapping("/clients/{id}")
public ClientDto afficher(@PathVariable Long id) {
Client client = clientRepository.findById(id).orElseThrow();
return new ClientDto(client.getNom(), client.getCommandes().size()); // ✅
}
OutOfMemoryError : la heap est pleine
java.lang.OutOfMemoryError: Java heap space
Cette erreur signifie : « il n'y a plus de place dans l'entrepôt, et le ménage n'a rien trouvé à jeter ». Deux grandes causes dans le code d'un projet Spring Boot.
Cause n°1 : charger trop de données d'un coup
List<Commande> toutes = commandeRepository.findAll(); // ❌ 3 millions de lignes en mémoire
En développement, la table contient 50 lignes et tout va bien. En production, elle en contient des millions : chaque ligne devient un objet dans la heap, et l'application tombe.
Charge les données par pages :
Page<Commande> page = commandeRepository.findAll(PageRequest.of(0, 100)); // ✅ 100 lignes
Ou mieux encore, demande directement à la base ce dont tu as besoin : un comptage, une somme, un filtre. Une requête count ne charge aucune ligne.
long nombre = commandeRepository.countByStatut(Statut.PAYEE); // ✅ rien n'est chargé
Astuce : avant d'écrire
findAll(), demande-toi combien de lignes la table contiendra en production, pas sur ton poste.
Cause n°2 : une collection qui grossit sans fin (la fuite mémoire)
Un service Spring existe en un seul exemplaire et vit aussi longtemps que l'application (voir la fiche sur les threads). Tout ce que tu ranges dans un de ses champs est donc gardé pour toujours.
@Service
public class ProduitService {
private final Map<Long, Produit> cache = new HashMap<>(); // ❌ ne se vide jamais
public Produit trouver(Long id) {
return cache.computeIfAbsent(id, cle -> produitRepository.findById(cle).orElseThrow());
}
}
Chaque produit consulté reste dans la Map, donc reste désigné, donc n'est jamais supprimé par le GC. Au bout de quelques jours, la heap est pleine. C'est une fuite mémoire : pas de bug visible pendant les tests, un plantage en production.
La bonne habitude : ne fabrique pas de cache à la main. Utilise le cache de Spring, avec une taille maximale :
@Cacheable("produits")
public Produit trouver(Long id) {
return produitRepository.findById(id).orElseThrow();
}
⚠️ Sans configuration, le cache de Spring Boot est lui aussi sans limite. Ajoute la bibliothèque Caffeine (com.github.ben-manes.caffeine:caffeine) et fixe une taille dans application.properties :
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=10m
Le cache garde alors au maximum 1 000 produits, chacun pendant 10 minutes au plus. N'oublie pas @EnableCaching sur une classe de configuration, et garde en tête que @Cacheable passe par un proxy (voir la fiche sur les proxies Spring).
Et augmenter la mémoire ?
Tu entendras parler de -Xmx, l'option qui fixe la taille maximale de la heap (par exemple -Xmx2g). La tentation est grande de l'augmenter au premier OutOfMemoryError.
Si l'erreur vient d'une fuite ou d'un findAll() sur une grosse table, plus de mémoire ne fait que retarder le plantage. Il faut corriger le code.
Le réglage de la mémoire du serveur relève de l'équipe qui déploie. Ton rôle, si tu tombes sur un OutOfMemoryError : relever le message exact et le moment où il est apparu, regarder si le code charge beaucoup de données ou remplit une collection, et en parler à l'équipe.
Les bonnes habitudes
- Ne renvoie jamais une entité depuis un contrôleur. Passe par un DTO.
- Méfie-toi de
findAll(). Pagine, ou demande à la base un comptage, une somme ou un filtre. - Pas de collection qui grossit dans un champ de service. Un service vit toute la durée de l'application.
- Pour un cache, utilise
@Cacheableavec une taille maximale, jamais uneMapfaite main. - N'appelle jamais
System.gc()et ne touche pas aux réglages du GC. - Face à un
OutOfMemoryError, cherche la cause dans le code avant de demander plus de mémoire.
À retenir
- Les objets vont dans la heap, partagée ; les variables locales vont dans la stack, une par thread.
- Le GC supprime tout seul les objets que plus rien ne désigne. Ceux d'une requête HTTP sont nettoyés sans que tu aies rien à faire.
StackOverflowError: des appels sans fin, souvent deux entités qui se référencent et qu'on transforme en JSON. Solution : un DTO.OutOfMemoryError: trop de données chargées d'un coup, ou une collection qui ne se vide jamais.- Augmenter la mémoire ne corrige pas une fuite.