OutOfMemoryException malgré l'utilisation de WeakHashMap

9

Si vous n'appelez pas System.gc(), le système lèvera une OutOfMemoryException. Je ne sais pas pourquoi j'ai besoin d'appeler System.gc()explicitement; la JVM devrait s'appeler gc(), non? S'il vous plaît donnez votre avis.

Voici mon code de test:

public static void main(String[] args) throws InterruptedException {
    WeakHashMap<String, int[]> hm = new WeakHashMap<>();
    int i  = 0;
    while(true) {
        Thread.sleep(1000);
        i++;
        String key = new String(new Integer(i).toString());
        System.out.println(String.format("add new element %d", i));
        hm.put(key, new int[1024 * 10000]);
        key = null;
        //System.gc();
    }
}

Comme suit, ajoutez -XX:+PrintGCDetailspour imprimer les informations du GC; comme vous le voyez, en fait, la JVM essaie de faire une exécution complète du GC, mais échoue; Je ne connais toujours pas la raison. Il est très étrange que si je commente la System.gc();ligne, le résultat est positif:

add new element 1
add new element 2
add new element 3
add new element 4
add new element 5
[GC (Allocation Failure) --[PSYoungGen: 48344K->48344K(59904K)] 168344K->168352K(196608K), 0.0090913 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
[Full GC (Ergonomics) [PSYoungGen: 48344K->41377K(59904K)] [ParOldGen: 120008K->120002K(136704K)] 168352K->161380K(196608K), [Metaspace: 5382K->5382K(1056768K)], 0.0380767 secs] [Times: user=0.09 sys=0.03, real=0.04 secs] 
[GC (Allocation Failure) --[PSYoungGen: 41377K->41377K(59904K)] 161380K->161380K(196608K), 0.0040596 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 41377K->41314K(59904K)] [ParOldGen: 120002K->120002K(136704K)] 161380K->161317K(196608K), [Metaspace: 5382K->5378K(1056768K)], 0.0118884 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at test.DeadLock.main(DeadLock.java:23)
Heap
 PSYoungGen      total 59904K, used 42866K [0x00000000fbd80000, 0x0000000100000000, 0x0000000100000000)
  eden space 51712K, 82% used [0x00000000fbd80000,0x00000000fe75c870,0x00000000ff000000)
  from space 8192K, 0% used [0x00000000ff800000,0x00000000ff800000,0x0000000100000000)
  to   space 8192K, 0% used [0x00000000ff000000,0x00000000ff000000,0x00000000ff800000)
 ParOldGen       total 136704K, used 120002K [0x00000000f3800000, 0x00000000fbd80000, 0x00000000fbd80000)
  object space 136704K, 87% used [0x00000000f3800000,0x00000000fad30b90,0x00000000fbd80000)
 Metaspace       used 5409K, capacity 5590K, committed 5760K, reserved 1056768K
  class space    used 576K, capacity 626K, committed 640K, reserved 1048576K
Dominic Peng
la source
quelle version jdk? utilisez-vous des paramètres -Xms et -Xmx? sur quelle étape vous avez OOM?
Vladislav Kysliy
1
Je ne peux pas reproduire cela sur mon système. En mode débogage, je peux voir que le GC fait son travail. Pouvez-vous vérifier en mode débogage si la carte est réellement effacée ou non?
magicmn
jre 1.8.0_212-b10 -Xmx200m Vous pouvez voir plus de détails dans le journal gc que j'ai joint; thx
Dominic Peng

Réponses:

7

JVM appellera GC seul, mais dans ce cas, il sera trop peu trop tard. Ce n'est pas seulement GC qui est responsable de l'effacement de la mémoire dans ce cas. Les valeurs de carte sont fortement accessibles et sont effacées par la carte elle-même lorsque certaines opérations y sont appelées.

Voici la sortie si vous activez les événements GC (XX: + PrintGC):

add new element 1
add new element 2
add new element 3
add new element 4
add new element 5
add new element 6
add new element 7
[GC (Allocation Failure)  2407753K->2400920K(2801664K), 0.0123285 secs]
[GC (Allocation Failure)  2400920K->2400856K(2801664K), 0.0090720 secs]
[Full GC (Allocation Failure)  2400856K->2400805K(2590720K), 0.0302800 secs]
[GC (Allocation Failure)  2400805K->2400805K(2801664K), 0.0069942 secs]
[Full GC (Allocation Failure)  2400805K->2400753K(2620928K), 0.0146932 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

GC n'est pas déclenché jusqu'à la dernière tentative pour mettre de la valeur dans la carte.

WeakHashMap ne peut pas effacer les entrées périmées jusqu'à ce que les clés de mappage se produisent dans une file d'attente de référence. Et les clés de carte n'apparaissent pas dans une file d'attente de référence tant qu'elles n'ont pas été récupérées. L'allocation de mémoire pour la nouvelle valeur de carte est déclenchée avant que la carte n'ait la possibilité de se vider. Lorsque l'allocation de mémoire échoue et déclenche GC, les clés de carte sont collectées. Mais c'est trop peu trop tard - pas assez de mémoire a été libérée pour allouer une nouvelle valeur de carte. Si vous réduisez la charge utile, vous vous retrouverez probablement avec suffisamment de mémoire pour allouer une nouvelle valeur de carte et les entrées périmées seront supprimées.

Une autre solution pourrait être d'encapsuler les valeurs elles-mêmes dans WeakReference. Cela permettra au GC d'effacer les ressources sans attendre que la carte le fasse par lui-même. Voici la sortie:

add new element 1
add new element 2
add new element 3
add new element 4
add new element 5
add new element 6
add new element 7
[GC (Allocation Failure)  2407753K->2400920K(2801664K), 0.0133492 secs]
[GC (Allocation Failure)  2400920K->2400888K(2801664K), 0.0090964 secs]
[Full GC (Allocation Failure)  2400888K->806K(190976K), 0.1053405 secs]
add new element 8
add new element 9
add new element 10
add new element 11
add new element 12
add new element 13
[GC (Allocation Failure)  2402096K->2400902K(2801664K), 0.0108237 secs]
[GC (Allocation Failure)  2400902K->2400838K(2865664K), 0.0058837 secs]
[Full GC (Allocation Failure)  2400838K->1024K(255488K), 0.0863236 secs]
add new element 14
add new element 15
...
(and counting)

Bien mieux.

tentacule
la source
Merci pour votre réponse, votre conclusion semble correcte; pendant que j'essaye de réduire la charge utile de 1024 * 10000 à 1024 * 1000; le code peut bien fonctionner; mais je ne comprends toujours pas très bien votre explication; comme votre sens, si vous avez besoin de libérer de l'espace de WeakHashMap, devrait faire au moins deux fois gc; le premier temps est de collecter les clés de la carte et de les ajouter dans la file d'attente de référence; la deuxième fois est de collecter des valeurs? mais d'après le premier journal que vous avez fourni, en fait, JVM avait déjà pris le gc complet deux fois;
Dominic Peng
Vous dites que "les valeurs de la carte sont fortement accessibles et sont effacées par la carte elle-même lorsque certaines opérations y sont appelées". D'où sont-ils accessibles?
Andronicus
1
Il ne suffira pas d'avoir seulement deux exécutions de GC dans votre cas. D'abord, vous avez besoin d'une exécution GC, c'est correct. Mais la prochaine étape nécessitera une certaine interaction avec la carte elle-même. Ce que vous devez rechercher est une méthode java.util.WeakHashMap.expungeStaleEntriesqui lit la file d'attente de référence et supprime les entrées de la carte, rendant ainsi les valeurs inaccessibles et sujettes à la collecte. Ce n'est qu'après cela qu'une deuxième passe de GC libérera de la mémoire. expungeStaleEntriesest appelé dans un certain nombre de cas comme get / put / size ou à peu près tout ce que vous faites habituellement avec une carte. Voilà le hic.
tentacule
1
@Andronicus, c'est de loin la partie la plus déroutante de WeakHashMap. Il a été couvert plusieurs fois. stackoverflow.com/questions/5511279/…
tentacule
2
@Andronicus cette réponse , en particulier la seconde moitié, pourrait également être utile. Aussi cette Q&R
Holger
5

L'autre réponse est en effet correcte, j'ai édité la mienne. Comme un petit addenda, G1GCne présentera pas ce comportement, contrairement ParallelGC; qui est la valeur par défaut sous java-8.

Que pensez-vous qu'il se passera si je modifie légèrement votre programme en (exécuter sous jdk-8avec -Xmx20m)

public static void main(String[] args) throws InterruptedException {
    WeakHashMap<String, int[]> hm = new WeakHashMap<>();
    int i = 0;
    while (true) {
        Thread.sleep(200);
        i++;
        String key = "" + i;
        System.out.println(String.format("add new element %d", i));
        hm.put(key, new int[512 * 1024 * 1]); // <--- allocate 1/2 MB
    }
}

Cela fonctionnera très bien. Pourquoi donc? Parce qu'il donne à votre programme suffisamment de marge de manœuvre pour que de nouvelles allocations se produisent, avant d' WeakHashMapeffacer ses entrées. Et l'autre réponse explique déjà comment cela se produit.

Maintenant, en G1GC, les choses seraient un peu différentes. Quand un si gros objet est alloué (plus de 1/2 Mo en général ), cela s'appelle a humongous allocation. Lorsque cela se produit, un GC simultané sera déclenché. Dans le cadre de ce cycle: une jeune collection sera déclenchée et une Cleanup phasesera initiée qui se chargera de poster l'événement à la ReferenceQueue, afin de WeakHashMapdégager ses entrées.

Donc pour ce code:

public static void main(String[] args) throws InterruptedException {
    Map<String, int[]> hm = new WeakHashMap<>();
    int i = 0;
    while (true) {
        Thread.sleep(1000);
        i++;
        String key = "" + i;
        System.out.println(String.format("add new element %d", i));
        hm.put(key, new int[1024 * 1024 * 1]); // <--- 1 MB allocation
    }
}

que je lance avec jdk-13 (où G1GCest la valeur par défaut)

java -Xmx20m "-Xlog:gc*=debug" gc.WeakHashMapTest

Voici une partie des journaux:

[2.082s][debug][gc,ergo] Request concurrent cycle initiation (requested by GC cause). GC cause: G1 Humongous Allocation

Cela fait déjà quelque chose de différent. Il démarre un concurrent cycle(fait pendant que votre application est en cours d'exécution), car il y avait un G1 Humongous Allocation. Dans le cadre de ce cycle simultané, il effectue un cycle GC jeune (qui arrête votre application pendant son exécution)

 [2.082s][info ][gc,start] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation)

Dans le cadre de ce jeune GC, il efface également des régions gigantesques , voici le défaut .


Vous pouvez maintenant voir que jdk-13n'attend pas que les déchets s'accumulent dans l'ancienne région lorsque de très gros objets sont alloués, mais déclenche un cycle GC simultané , qui a sauvé la journée; contrairement à jdk-8.

Vous voudrez peut-être lire ce que DisableExplicitGCet / ou ExplicitGCInvokesConcurrentdire, couplé avec System.gcet comprendre pourquoi appeler System.gcréellement aide ici.

Eugène
la source
1
Java 8 n'utilise pas G1GC par défaut. Et les journaux GC de l'OP montrent également clairement qu'il utilise un GC parallèle pour l'ancienne génération. Et pour un tel collectionneur non concurrent, c'est aussi simple que décrit dans cette réponse
Holger
@ Holger Je revoyais cette réponse aujourd'hui dans la matinée pour me rendre compte que c'est effectivement le cas ParalleGC, j'ai édité et désolé (et merci) de m'avoir prouvé le contraire.
Eugene
1
L '«allocation gigantesque» est toujours un indice correct. Avec un collecteur non simultané, cela implique que le premier GC s'exécutera lorsque l'ancienne génération sera pleine, donc l'échec de la récupération de suffisamment d'espace le rendra fatal. En revanche, lorsque vous réduisez la taille du tableau, un jeune GC sera déclenché lorsqu'il restera de la mémoire dans l'ancienne génération, afin que le collecteur puisse promouvoir les objets et continuer. Pour un collecteur simultané, en revanche, il est normal de déclencher gc avant que le tas ne soit épuisé, alors -XX:+UseG1GCfaites-le fonctionner en Java 8, tout comme -XX:+UseParallelOldGCil le fait échouer dans les nouvelles JVM.
Holger