Aller au contenu

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
  • == contre equals, et le piège du cache des Integer
  • null, la NullPointerException et comment s'en protéger
  • Le GC, l'absence de destructeur, et try-with-resources pour les ressources
  • final n'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 par new.

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

int n = 1;
int m = n;            // copie de la valeur
m = 2;
IO.println(n);        // affiche : 1

Point a = new Point(1, 1);
Point b = a;          // copie de la RÉFÉRENCE : un seul objet
b.x = 99;
IO.println(a.x);      // affiche : 99
Point a{1, 1};
Point b = a;          // copie de l'objet entier
b.x = 99;
std::cout << a.x;     // affiche : 1
Point* p = &a;        // pour partager, il faut un pointeur ou une référence
let a = Point { x: 1, y: 1 };
let mut b = a;        // déplacement : `a` n'est plus utilisable
b.x = 99;
// println!("{}", a.x);  // erreur : borrow of moved value: `a`
a = Point(1, 1)
b = a                 # même objet
b.x = 99
print(a.x)            # affiche : 99

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.

Passage.java
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 ?).
  • equals compare 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 :

Npe.java
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 Rc en 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 static ou 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 (...) :

Ressources.java
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 :

Final.java
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 :

  1. Immuabilité : record, champs final, List.of, List.copyOf.
  2. Copies défensives à l'entrée (constructeur) et à la sortie (getter) quand l'état est modifiable.
  3. 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
true    ← 100 est dans le cache : même objet
false   ← 1000 hors cache : deux objets
true    ← contenu identique
true    ← "ab" + "c" est une constante de compilation, partagée comme le littéral
false   ← new String crée toujours un nouvel objet
false   ← 5 est boxé en Integer, et Long.equals exige un Long

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
Swap.java
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.

Releve.java
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 (le is de Python) ; equals teste le contenu. Le cache Integer (-128 à 127) rend == faussement correct en test.
  • null est partout ; lis les messages de NPE, utilise Objects.requireNonNull et "constante".equals(x).
  • Le GC libère la mémoire mais jamais tes ressources : AutoCloseable + try-with-resources.
  • final fige la variable, pas l'objet ; défends-toi de l'aliasing par l'immuabilité et les copies défensives.