OutOfMemoryException pomimo użycia WeakHashMap

9

Jeśli nie zadzwonisz System.gc(), system zgłosi wyjątek OutOfMemoryException. Nie wiem, dlaczego muszę dzwonić System.gc()wprost; JVM powinien się nazywać gc(), prawda? Proszę doradź.

Oto mój kod testowy:

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();
    }
}

W następujący sposób dodaj, -XX:+PrintGCDetailsaby wydrukować informacje GC; jak widać, JVM próbuje wykonać pełne uruchomienie GC, ale kończy się niepowodzeniem; I nadal nie wiem dlaczego. To bardzo dziwne, że jeśli odkomentuję System.gc();linię, wynik będzie pozytywny:

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
źródło
jaka wersja jdk? używasz jakichś parametrów -Xms i -Xmx? na którym etapie masz OOM?
Vladislav Kysliy
1
Nie mogę tego odtworzyć w moim systemie. W trybie debugowania widzę, że GC wykonuje swoją pracę. Czy możesz sprawdzić w trybie debugowania, czy mapa faktycznie jest czyszczona, czy nie?
magicmn
jre 1.8.0_212-b10 -Xmx200m Możesz zobaczyć więcej szczegółów z logu gc, który załączyłem; dzięki
Dominic Peng

Odpowiedzi:

7

JVM zadzwoni do GC samodzielnie, ale w tym przypadku będzie za mało za późno. W tym przypadku nie tylko GC jest odpowiedzialna za czyszczenie pamięci. Wartości map są łatwo osiągalne i są usuwane przez samą mapę, gdy wywoływane są na niej pewne operacje.

Oto wynik, jeśli włączysz zdarzenia 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 nie jest uruchamiane do ostatniej próby wprowadzenia wartości do mapy.

WeakHashMap nie może usunąć starych wpisów, dopóki klucze map nie pojawią się w kolejce referencyjnej. A klucze mapy nie pojawiają się w kolejce referencyjnej, dopóki nie zostaną wyrzucone. Przydział pamięci dla nowej wartości mapy jest uruchamiany, zanim mapa ma szansę się wyczyścić. Gdy alokacja pamięci kończy się niepowodzeniem i wyzwala GC, klucze mapy są gromadzone. Ale jest już za późno - nie zwolniono wystarczającej ilości pamięci, aby przydzielić nową wartość mapy. Jeśli zmniejszysz ładunek, prawdopodobnie skończy się wystarczająca ilość pamięci, aby przydzielić nową wartość mapy, a stare wpisy zostaną usunięte.

Innym rozwiązaniem może być zawijanie samych wartości do WeakReference. Pozwoli to GC wyczyścić zasoby bez czekania, aż mapa zrobi to sama. Oto wynik:

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)

Dużo lepiej.

macka
źródło
Dziękuję za odpowiedź, wydaje się, że twój wniosek jest poprawny; podczas gdy staram się zmniejszyć ładowność z 1024 * 10000 do 1024 * 1000; kod może działać poprawnie; ale Wciąż nie rozumiem twojego wyjaśnienia; jako twoje znaczenie, jeśli musisz zwolnić miejsce z WeakHashMap, powinieneś zrobić gc co najmniej dwa razy; po raz pierwszy należy zebrać klucze z mapy i dodać je do kolejki referencyjnej; po raz drugi zbierać wartości? ale z pierwszego dziennika, który dostarczyłeś, JVM już dwa razy pobrał pełną gc;
Dominic Peng
Mówisz, że „Wartości mapy są łatwo osiągalne i są usuwane przez samą mapę, gdy wywoływane są na niej pewne operacje.”. Skąd są osiągalne?
Andronicus
1
Nie wystarczy mieć w twoim przypadku tylko dwa przebiegi GC. Najpierw potrzebujesz jednego przebiegu GC, to prawda. Ale następny krok będzie wymagał interakcji z samą mapą. Powinieneś szukać metody, java.util.WeakHashMap.expungeStaleEntriesktóra odczytuje kolejkę referencyjną i usuwa wpisy z mapy, czyniąc wartości niedostępnymi i podlegającymi gromadzeniu. Dopiero po tym drugie przejście GC zwolni trochę pamięci. expungeStaleEntriesjest wywoływany w wielu przypadkach, takich jak get / put / size lub prawie wszystko, co zwykle robisz z mapą. To jest haczyk.
macka
1
@Andronicus, jest to najbardziej myląca część WeakHashMap. Było to pokrywane wiele razy. stackoverflow.com/questions/5511279/…
macka
2
@Andronicus ta odpowiedź , szczególnie druga połowa, również może być pomocna. Także to pytania i odpowiedzi
Holger
5

Druga odpowiedź jest rzeczywiście poprawna, zredagowałem moją. Jako mały dodatek, G1GCnie wykaże tego zachowania, w przeciwieństwie do ParallelGC; która jest domyślna pod java-8.

Jak myślisz, co się stanie, jeśli nieznacznie zmienię twój program na (działam jdk-8z -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
    }
}

Będzie dobrze działać. Dlaczego? Ponieważ daje Twojemu programowi wystarczającą swobodę, aby mogły zostać wprowadzone nowe przydziały, zanim WeakHashMapwyczyści wpisy. A druga odpowiedź już wyjaśnia, jak to się dzieje.

Teraz G1GCsprawy potoczyłyby się trochę inaczej. Gdy przydzielany jest tak duży obiekt ( zwykle ponad 1/2 MB ), nazywa się to ahumongous allocation . Kiedy tak się stanie, uruchomione zostanie współbieżne GC. W ramach tego cyklu: uruchomiona zostanie młoda kolekcja i Cleanup phasezostanie zainicjowana taka, która zajmie się wysłaniem wydarzenia do ReferenceQueue, aby WeakHashMapwyczyścić wpisy.

Więc dla tego kodu:

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
    }
}

że uruchamiam z jdk-13 (gdzie G1GCjest domyślna)

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

Oto część dzienników:

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

To już robi coś innego. Zaczyna się concurrent cycle(zrobione podczas działania aplikacji), ponieważ byłoG1 Humongous Allocation . W ramach tego współbieżnego cyklu wykonuje młody cykl GC (który zatrzymuje aplikację podczas działania)

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

W ramach tego młodego GC usuwa także ogromne obszary , oto wada .


Teraz możesz to zobaczyć jdk-13 nie czeka na gromadzenie się śmieci w starym regionie, gdy przydzielane są naprawdę duże obiekty, ale uruchamia współbieżny cykl GC, który uratował dzień; w przeciwieństwie do jdk-8.

Możesz przeczytać, co DisableExplicitGCi / lub ExplicitGCInvokesConcurrentznaczy, w połączeniu z System.gci zrozumieć, dlaczego dzwonienie System.gcnaprawdę tutaj pomaga.

Eugene
źródło
1
Java 8 domyślnie nie używa G1GC. Dzienniki GC OP również wyraźnie pokazują, że używa on równoległego GC dla starej generacji. A dla takiego niebieżnego kolektora jest to tak proste, jak opisano w tej odpowiedzi
Holger
@Holger Recenzowałem tę odpowiedź dziś rano tylko po to, aby zdać sobie sprawę, że tak ParalleGC, edytowałem ją i przepraszam (i dziękuję) za udowodnienie, że się mylę.
Eugene
1
„Ogromny przydział” nadal stanowi właściwą wskazówkę. W przypadku niebieżącego kolektora oznacza to, że pierwszy GC będzie działał, gdy stara generacja będzie pełna, więc brak odzyskania wystarczającej ilości miejsca spowoduje, że będzie to fatalne. W przeciwieństwie do tego, gdy zmniejszysz rozmiar tablicy, młody GC zostanie wyzwolony, gdy pozostanie jeszcze pamięć w starej generacji, więc kolektor może promować obiekty i kontynuować. Z drugiej strony, dla współbieżnego modułu zbierającego, normalne jest uruchamianie gc przed wyczerpaniem stosu, więc pozwól -XX:+UseG1GCmu działać w Javie 8, tak jak -XX:+UseParallelOldGCw przypadku nowych JVM.
Holger