Mémoire et références¶
C'est le chapitre le plus important pour qui vient de Rust ou de C++ : en Java, tu ne choisis jamais où vit un objet ni quand il meurt, et tout objet est manipulé par référence.
Dans ce chapitre
- Pile et tas : primitives par valeur, objets toujours par référence
- Java passe toujours par valeur, y compris la valeur des références
==contreequals, et le piège du cache desIntegernull, laNullPointerExceptionet comment s'en protéger- Le GC, l'absence de destructeur, et
try-with-resourcespour les ressources finaln'est pas l'immuabilité ; sans borrow checker, l'aliasing est ton problème
Pile et tas¶
- Les variables locales et les paramètres vivent sur la pile du thread : soit une valeur primitive (
int,double...), soit une référence. - Tous les objets (y compris les tableaux et les
String) vivent sur le tas, créés parnew.
Il n'y a pas de choix comme en C++ (Point p; sur la pile contre new Point sur le tas) ou en Rust (valeur contre Box). Une variable de type objet est toujours une sorte de pointeur, sans arithmétique, sans * ni ->, et qui ne peut pas pendouiller (dangling).
flowchart LR
subgraph Pile["Pile (main)"]
n["n = 1"]
a["a : référence"]
b["b : référence"]
end
subgraph Tas
P["objet Point<br/>x = 99, y = 1"]
end
a --> P
b --> P
Sous le capot
Le JIT fait de l'escape analysis : un objet qui ne sort jamais d'une méthode peut être éclaté en variables locales, sans allocation. C'est une optimisation invisible : pour raisonner, considère que tout objet est sur le tas.
Primitives par valeur, objets par référence¶
Java se comporte comme Python pour les objets, et comme C++ pour les primitifs. Une affectation d'objet ne copie jamais, ne déplace jamais : elle crée un deuxième nom pour le même objet (un alias). Pour une vraie copie, il faut la demander : new ArrayList<>(liste), tableau.clone(), un constructeur de copie...
Java passe toujours par valeur¶
Quand tu appelles une méthode, chaque argument est copié dans le paramètre. Pour un objet, ce qui est copié, c'est la référence. Conséquence : la méthode peut modifier l'objet pointé, mais pas la variable de l'appelant.
public class Passage {
static class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
public String toString() { return "(" + x + ", " + y + ")"; }
}
static void echanger(Point a, Point b) {
Point tmp = a; // échange les copies locales
a = b; // des références...
b = tmp;
}
static void deplacer(Point p) {
p.x += 10; // ...mais l'objet pointé est partagé
}
static void remplacer(Point p) {
p = new Point(0, 0); // ne touche pas la variable de l'appelant
}
public static void main(String[] args) {
Point a = new Point(1, 1);
Point b = new Point(2, 2);
echanger(a, b);
System.out.println(a + " " + b); // affiche : (1, 1) (2, 2)
deplacer(a);
System.out.println(a); // affiche : (11, 1)
remplacer(a);
System.out.println(a); // affiche : (11, 1)
}
}
Il n'existe aucun équivalent du &mut de Rust ou du T& de C++ pour une variable : impossible d'écrire un swap(a, b) qui échange deux variables de l'appelant. Pour « renvoyer plusieurs valeurs », on renvoie un objet (un record, voir Records, enums, sealed).
Le bon modèle mental
Une référence Java, c'est un pointeur C++ passé par valeur (Point* p), qu'on ne peut pas déréférencer à la main. C'est exactement le modèle de Python (call by sharing).
== contre equals¶
==compare des valeurs pour les primitifs, et des identités pour les objets (même objet ?).equalscompare le contenu, si la classe l'a défini (voir Classes et objets).
Le parallèle avec Python est direct : == de Java sur des objets correspond au is de Python, equals correspond au == de Python. En Rust, == compare le contenu (PartialEq) et l'identité demande std::ptr::eq.
Le piège du cache Integer¶
Chaque primitif a une classe « enveloppe » (wrapper) : int → Integer, long → Long, double → Double... Les collections ne stockent que des objets, donc Java convertit automatiquement (autoboxing et unboxing). Et pour économiser la mémoire, Integer.valueOf (utilisé par l'autoboxing) réutilise des objets en cache entre -128 et 127 :
Integer a = 127, b = 127;
Integer c = 128, d = 128;
IO.println(a == b); // affiche : true (même objet, issu du cache)
IO.println(c == d); // affiche : false (deux objets distincts)
IO.println(c.equals(d)); // affiche : true
IO.println(c == 128); // affiche : true (un côté primitif : unboxing, comparaison de valeurs)
Long l = 127L;
IO.println(l.equals(127)); // affiche : false (127 devient un Integer, pas un Long)
Piège : du code qui marche en test et casse en production
Un == entre deux Integer fonctionne avec de petits identifiants de test (1, 2, 3...), puis échoue silencieusement quand les identifiants dépassent 127. La borne haute du cache est même réglable (-XX:AutoBoxCacheMax=1000 change le résultat de c == d) : ne compte jamais dessus. Règle : == uniquement pour les primitifs, les constantes d'enum et les tests de null.
Même phénomène avec les String : les littéraux et les constantes de compilation sont partagés, les chaînes calculées non.
String s = "java";
String t = "ja";
IO.println(s == "ja" + "va"); // affiche : true (constante calculée à la compilation)
IO.println(s == t + "va"); // affiche : false (chaîne construite à l'exécution)
IO.println(s.equals(t + "va")); // affiche : true
null et NullPointerException¶
Toute référence peut valoir null : c'est la valeur par défaut des champs objets et des cases d'un tableau d'objets. Le compilateur ne vérifie rien ; appeler une méthode sur null lève une NullPointerException (NPE). Pas de comportement indéfini comme en C++, mais pas d'Option vérifiée comme en Rust non plus.
Depuis Java 15, les messages de NPE (helpful NPE messages) indiquent précisément ce qui était null :
import java.util.HashMap;
import java.util.Map;
public class Npe {
record Adresse(String ville) {}
record Client(String nom, Adresse adresse) {}
public static void main(String[] args) {
var c = new Client("Ada", null);
try {
c.adresse().ville().toUpperCase();
} catch (NullPointerException e) {
System.out.println(e.getMessage());
}
Map<String, Integer> stock = new HashMap<>();
try {
int n = stock.get("pommes"); // get renvoie null, puis unboxing
} catch (NullPointerException e) {
System.out.println(e.getMessage());
}
}
}
Cannot invoke "Npe$Adresse.ville()" because the return value of "Npe$Client.adresse()" is null
Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null
Le second cas est vicieux : la ligne ne contient aucun appel de méthode visible, c'est l'unboxing caché d'un Integer null en int qui explose.
Sous le capot : <local1>
Pour une variable locale, le message cite son nom (because "nom" is null) seulement si le code a été compilé avec les informations de débogage (javac -g, ce que font Maven et les IDE). Avec java Fichier.java, tu verras because "<local1>" is null.
Pour te protéger :
// 1. Échouer tôt, à l'entrée d'un constructeur ou d'une méthode publique
this.nom = Objects.requireNonNull(nom, "nom ne doit pas être null");
// 2. Comparer sans risque
"admin".equals(role); // false si role est null, pas de NPE
Objects.equals(a, b); // gère null des deux côtés
// 3. Valeur de repli
String affiche = Objects.requireNonNullElse(surnom, "anonyme");
Pour une valeur de retour qui peut manquer, Java propose Optional<T>, un cousin d'Option<T> (voir Exceptions et Optional). Spring 7 annote aussi son API avec @Nullable (JSpecify) pour que l'IDE te prévienne.
Le ramasse-miettes (GC)¶
Pas de free, pas de delete, pas de drop : un objet devient récupérable dès qu'il n'est plus atteignable depuis les racines (variables locales des threads, champs static...). Le GC le libère quand il le décide.
- Les cycles ne posent aucun problème : pas de fuite comme avec des
Rcen Rust, pas besoin d'un collecteur de cycles en plus du comptage comme en Python. - Les fuites restent possibles : un objet gardé dans une collection
staticou un cache ne sera jamais libéré. System.gc()n'est qu'une suggestion à la JVM.- La taille maximale du tas se règle au lancement (
java -Xmx512m ...), important dans un conteneur.
Piège : pas de destructeur déterministe
Il n'y a pas d'équivalent de Drop ou d'un destructeur C++ appelé en fin de portée. L'ancienne méthode finalize() était appelée par le GC « un jour, peut-être » ; elle est dépréciée et vouée à disparaître (warning: [removal] finalize() in Object has been deprecated and marked for removal). Ne libère jamais une ressource (fichier, socket, verrou) en comptant sur le GC.
Les ressources : try-with-resources¶
Pour tout ce qui doit être fermé, une classe implémente l'interface AutoCloseable (une seule méthode : close()), et on l'ouvre dans un try (...) :
public class Ressources {
static class Connexion implements AutoCloseable {
private final String nom;
Connexion(String nom) { this.nom = nom; System.out.println("ouverture " + nom); }
void envoyer(String msg) {
if (msg.isEmpty()) throw new IllegalArgumentException("message vide");
System.out.println(nom + " <- " + msg);
}
@Override
public void close() { System.out.println("fermeture " + nom); }
}
public static void main(String[] args) {
try (var a = new Connexion("A"); var b = new Connexion("B")) {
a.envoyer("salut");
b.envoyer(""); // lève une exception
} catch (IllegalArgumentException e) {
System.out.println("erreur : " + e.getMessage());
}
}
}
// affiche :
// ouverture A
// ouverture B
// A <- salut
// fermeture B
// fermeture A
// erreur : message vide
Les ressources sont fermées dans l'ordre inverse de leur ouverture, même en cas d'exception, et avant le catch. C'est le RAII du C++, le Drop de Rust et le with de Python, mais opt-in : si tu oublies le try (...), rien ne fermera la ressource à temps. Les détails (exceptions supprimées, finally) sont dans Exceptions et Optional.
final n'est pas l'immuabilité¶
final List<String> noms = new ArrayList<>();
noms.add("Ada"); // autorisé : l'objet est modifiable
// noms = new ArrayList<>(); // interdit : la variable est final
final porte sur la variable (la référence), jamais sur l'objet. C'est l'équivalent C++ de Point* const p (pointeur constant vers un objet modifiable), pas de const Point*. Rien ne ressemble au let de Rust, qui rend toute la valeur possédée immuable, ni au const transitif du C++.
Un objet n'est immuable que si sa classe est conçue ainsi : champs final, aucune méthode qui modifie, et des champs eux-mêmes immuables. String, Integer, les record (à condition que leurs composants le soient), List.of(...) sont immuables.
Piège : non modifiable n'est pas immuable
Collections.unmodifiableList(source) renvoie une vue en lecture seule : tu ne peux pas la modifier, mais si source change, la vue change aussi. List.copyOf(source) crée une vraie copie immuable.
Pas de borrow checker : aliasing et mutation partagée¶
En Rust, le compilateur interdit d'avoir à la fois un alias et une mutation. En Java, rien ne t'empêche de garder une référence sur l'état interne d'un autre objet... et de le modifier dans son dos :
import java.util.ArrayList;
import java.util.List;
public class Final {
static final class EquipeNaive {
private final List<String> membres;
EquipeNaive(List<String> membres) { this.membres = membres; } // garde l'alias !
List<String> membres() { return membres; } // expose l'état interne !
}
static final class Equipe {
private final List<String> membres;
Equipe(List<String> membres) {
this.membres = List.copyOf(membres); // copie défensive, immuable
}
List<String> membres() { return membres; } // sans risque : déjà immuable
}
public static void main(String[] args) {
List<String> source = new ArrayList<>(List.of("Ada", "Linus"));
var naive = new EquipeNaive(source);
var sure = new Equipe(source);
source.add("Intrus");
System.out.println(naive.membres() + " " + sure.membres());
// affiche : [Ada, Linus, Intrus] [Ada, Linus]
naive.membres().clear(); // vide aussi... la liste source
System.out.println(source); // affiche : []
}
}
Le runtime détecte quelques cas, comme modifier une liste pendant qu'on la parcourt :
List<Integer> a = new ArrayList<>(List.of(1, 2, 3, 4));
for (Integer x : a) {
if (x == 2) a.remove(x); // ConcurrentModificationException
}
Rust refuserait de compiler, le C++ aurait un comportement indéfini ; Java lève une exception, mais seulement à l'exécution, et pas dans tous les cas.
Tes défenses, par ordre de préférence :
- Immuabilité :
record, champsfinal,List.of,List.copyOf. - Copies défensives à l'entrée (constructeur) et à la sortie (getter) quand l'état est modifiable.
- Ne pas exposer l'état interne : des méthodes métier (
ajouterMembre) plutôt qu'un getter qui rend la liste.
Récapitulatif¶
| Langage | Affectation d'objet | Libération | Ressources |
|---|---|---|---|
| Rust | déplacement (ou copie si Copy) |
fin de portée du propriétaire | Drop, automatique |
| C++ | copie (sauf pointeur/référence) | fin de portée ou delete |
destructeur (RAII) |
| Python | alias | compteur de références + GC | with |
| Java | alias | GC, moment imprévisible | try-with-resources |
| Langage | Absence de valeur | Immuabilité | Aliasing + mutation |
|---|---|---|---|
| Rust | Option<T>, vérifiée |
par défaut (let), profonde |
interdit à la compilation |
| C++ | nullptr, UB |
const, transitive |
permis, UB possible |
| Python | None |
par convention (tuple, dataclass frozen) |
permis |
| Java | null, NPE |
final (variable seulement), records |
permis, à toi de te défendre |
Exercices¶
1. Prédis la sortie, puis vérifie.
Integer a = 100, b = 100;
Integer c = 1000, d = 1000;
String s1 = "abc", s2 = "ab" + "c", s3 = new String("abc");
Long l = 5L;
IO.println(a == b);
IO.println(c == d);
IO.println(c.equals(d));
IO.println(s1 == s2);
IO.println(s1 == s3);
IO.println(l.equals(5));
Solution
2. Écris static void echanger(int[] t, int i, int j) qui échange deux cases d'un tableau. Pourquoi ça marche, alors qu'un echanger(Integer a, Integer b) est impossible ?
Solution
import java.util.Arrays;
public class Swap {
static void echanger(int[] t, int i, int j) {
int tmp = t[i];
t[i] = t[j];
t[j] = tmp;
}
public static void main(String[] args) {
int[] t = {1, 2, 3};
echanger(t, 0, 2);
System.out.println(Arrays.toString(t)); // affiche : [3, 2, 1]
}
}
La méthode reçoit une copie de la référence vers le même tableau : elle modifie ses cases, que l'appelant voit. Avec deux Integer, la méthode ne peut que réaffecter ses propres paramètres (invisibles de l'appelant), et un Integer est immuable : rien à modifier.
3. Cette classe prétend être immuable. Montre qu'elle ne l'est pas, puis corrige-la.
public class Releve {
private final double[] mesures;
public Releve(double[] mesures) { this.mesures = mesures; }
public double[] mesures() { return mesures; }
public double moyenne() { return Arrays.stream(mesures).average().orElse(0); }
}
Solution
Deux fuites : le constructeur garde l'alias du tableau de l'appelant, et le getter expose le tableau interne. Avec la version d'origine, ce main affiche 676.6666666666666 au lieu de 20.0. Un tableau ne peut pas être rendu immuable : il faut copier à l'entrée et à la sortie.
import java.util.Arrays;
public class Releve {
private final double[] mesures;
public Releve(double[] mesures) {
this.mesures = mesures.clone(); // copie à l'entrée
}
public double[] mesures() {
return mesures.clone(); // copie à la sortie
}
public double moyenne() {
return Arrays.stream(mesures).average().orElse(0);
}
public static void main(String[] args) {
double[] source = {10, 20, 30};
var r = new Releve(source);
source[0] = 1000; // ne touche plus le relevé
r.mesures()[1] = 1000; // modifie une copie jetable
System.out.println(r.moyenne()); // affiche : 20.0
}
}
Encore mieux : stocker une List<Double> créée avec List.copyOf, immuable, que le getter peut rendre sans copie.
À retenir¶
- Primitifs par valeur ; objets toujours sur le tas, manipulés par référence ; une affectation crée un alias, jamais une copie.
- Java passe toujours par valeur : la méthode reçoit une copie de la référence, elle peut modifier l'objet mais pas la variable de l'appelant.
==teste l'identité des objets (leisde Python) ;equalsteste le contenu. Le cacheInteger(-128 à 127) rend==faussement correct en test.nullest partout ; lis les messages de NPE, utiliseObjects.requireNonNullet"constante".equals(x).- Le GC libère la mémoire mais jamais tes ressources :
AutoCloseable+try-with-resources. finalfige la variable, pas l'objet ; défends-toi de l'aliasing par l'immuabilité et les copies défensives.