Allocation et récupération mémoire
La plupart des langages permettent d'allouer dynamiquement de la
mémoire. On peut citer C, Pascal, Lisp, ML, SmallTalk,
C++, Java, ADA.
Allocation explicite
On distingue deux types d'allocation :
-
une allocation simple qui réserve une zone mémoire d'une
certaine taille sans se préoccuper de son contenu ;
- une allocation qui joint à la réservation d'un espace
mémoire une initialisation pour créer des valeurs.
Le premier cas est illustré par les fonction new de Pascal ou
malloc de C. Elles retournent un pointeur sur une zone
mémoire (i.e. son adresse), on peut alors accéder à cette zone
pour consulter ou modifier la valeur qu'elle contient. Le deuxième
cas correspond aux fonctions de construction de valeurs d'Objective CAML ou
celle des langages à objets. Les instances de classes des langages
objets sont construites en passant à un opérateur new, la
fonction de construction particulière à la classe qui, elle-même
attend, en général, un certains nombre de paramètres
d'initialisation. Pour les langages fonctionnels, les fonctions de
constructions sont immédiatement utilisées dès que l'on définit une
valeur structurée (un couple, une liste, un enregistrement, un
vecteur, une fermeture).
Examinons la construction de valeurs Objective CAML sur un exemple. Leur
représentation mémoire est illustrée par le schéma de la figure
9.1.
Figure 9.1 : représentation mémoire de valeurs
# let
u
=
let
l
=
[
'c'
;
'a'
;
'm'
]
in
List.tl
l
;;
val u : char list = ['a'; 'm']
# let
v
=
let
r
=
(
[
'z'
]
,
u
)
in
match
r
with
p
->
(fst
p)
@
(snd
p)
;;
val v : char list = ['z'; 'a'; 'm']
On a représenté un élément de liste par un doublet de deux
mots, le premier contenant un caractère et le second un pointeur
vers l'élément suivant de la liste. La représentation réelle est
légèrement différente. Elle est détaillée dans le chapitre sur
l'interface avec C (voir page ??).
La première phrase construit une valeur, nommée l, en
allouant une cellule (constructeur ::) pour chaque élément de la liste
[
'c'
;'a'
;'m'
]
. La déclaration globale u correspond à la
queue de l. Il y a ici un partage entre l et
u, c'est-à-dire entre l'argument et le résultat de
l'application de List.tl. Seule la déclaration u est
connue après l'évaluation de cette première phrase (l cesse
d'exister).
La deuxième phrase construit une liste à un seul élément, puis un
couple nommé r contenant cette liste et la liste
u. Ce couple est filtré et renommé p par le
filtrage. Ensuite, le premier élément de p est concaténé
avec son second élément, ce qui crée une valeur
[
'z'
;'a'
;'m'
]
liée à l'identificateur global v.
On observe que le résultat de snd (la liste
[
'a'
;'m'
]
) est partagé avec son argument p alors que le
résultat de fst (le caractère 'z') est copié.
Dans tous les cas l'allocation mémoire est explicite. C'est-à-dire
qu'elle est demandée par le programmeur (comme instruction ou
expression du langage).
Remarque
L'allocation mémoire conserve une information sur la taille de l'objet
alloué dans le but de pouvoir le libérer par la suite.
Récupération explicite
Les langages dont la récupération mémoire est explicite possèdent un
opérateur de libération (free en C ou dispose en
Pascal) qui reçoit l'adresse (un pointeur) de la zone à désallouer.
En utilisant les informations conservées au moment de l'allocation,
le programme libère cette zone et pourra la réutiliser par la suite.
L'allocation dynamique est en général utilisée pour manipuler
des données structurées évolutives telles les listes, les
arbres, etc. Libérer l'espace occupé par de telles données ne se
fait pas en << un coup de cuillère à pot >> mais réclame une
fonction de parcours de la donnée. On appelle de telles fonctions
des destructeurs.
Si la définition correcte des fonctions de destruction ne pose pas
trop de difficulté, leur usage est, en revanche, plus délicat. En
effet, pour libérer l'espace occupée par une structure, il faut
en parcourir les pointeurs et appliquer l'opérateur de
libération mémoire du langage. Laisser la libération de la mémoire à
la charge du programmeur présente l'avantage que ce dernier est sûr
des actions effectuées. Néanmoins une mauvaise utilisation de ces
opérateurs peut provoquer une erreur à l'exécution du programme.
Les principaux dangers de la récupération explicite sont :
-
les pointeurs << fantômes >> (dandling pointers) :
Une zone mémoire a été libérée alors qu'il subsiste des pointeurs qui
référencent toujours cette zone. Si celle-ci est réutilisée, l'accès à
cette zone par ces pointeurs risque d'être incohérent.
- les zones mémoire inaccessibles (cas de << fuite >> ou leak) :
Une zone mémoire toujours allouée qui n'est plus référencée par aucun
pointeur. Il n'y a plus de possibilités de la libérer. Il y a une
perte sèche de mémoire.
C'est toute la difficulté de la récupération explicite de mémoire
que de connaître la durée de vie de l'ensemble des valeurs d'un
programme.
Récupération implicite
Les langages avec récupération implicite ne possèdent pas d'opérateurs
de libération mémoire. Il n'est pas possible pour le programmeur de
décréter la disparition d'une valeur allouée. Néanmoins, un mécanisme de
récupération automatique est déclenché quand une valeur n'est plus
référencée, ou lors d'un échec de l'allocation, c'est-à-dire quand
le tas est plein.
Un algorithme de récupération automatique de mémoire est en
quelque sorte un destructeur global. Ce
caractère rend sans doute plus difficile sa conception et
son implantation que le seraient celles d'un destructeur dédié
à une structure de données particulière. Mais, une fois cette
difficulté surmontée, la fonction de récupération obtenue accroît
considérablement la sûreté de la gestion mémoire. En particulier le risque
de pointeurs fantômes disparaît.
D'autre part, une fonction de récupération automatique de
mémoire peut apporter de bonnes propriétés sur le tas :
-
compaction : toute la mémoire récupérée tient en un seul
bloc, évitant ainsi la granularité du tas et permettant d'allouer des
objets de la taille du tas libre ;
- localisation : les différents composants d'une même valeur
sont proches du point de vue adressage mémoire, ce qui permet lors de
leur parcours de rester dans les mêmes pages mémoire, évitant ainsi de
sortir de la mémoire-cache.
Les choix de conception d'un GC doivent tenir compte d'un certain
nombre de critères et de contraintes :
-
facteur de récupération : quel pourcentage de la mémoire
inutilisée est disponible ?
- fragmentation de la mémoire : peut-on allouer un bloc de la taille de
la mémoire libre ;
- ralentissement de l'allocation et de la récupération ;
- compilation : la représentation des valeurs est-elle totalement
libre ?
En pratique, le critère de sûreté reste primordial, et les GC trouvent
un compromis concernant les autres contraintes.