OutOfMemoryException apesar de usar WeakHashMap

9

Se não ligar System.gc(), o sistema lançará uma OutOfMemoryException. Não sei por que preciso ligar System.gc()explicitamente; a JVM deve se chamar gc(), certo? Por favor informar.

A seguir está o meu código de teste:

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

A seguir, adicione -XX:+PrintGCDetailspara imprimir as informações do GC; como você vê, na verdade, a JVM tenta executar uma execução completa do GC, mas falha; Eu ainda não sei o motivo. É muito estranho que, se eu descomentar a System.gc();linha, o resultado é positivo:

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
fonte
qual versão do jdk? você usa algum parâmetro -Xms e -Xmx? em qual etapa você obteve OOM?
Vladislav Kysliy
11
Não consigo reproduzir isso no meu sistema. No modo de depuração, posso ver que o GC está fazendo seu trabalho. Você pode verificar no modo de depuração se o Mapa está realmente sendo limpo ou não?
magicmn
jre 1.8.0_212-b10 -Xmx200m Você pode ver mais detalhes do log do gc que eu anexei; thx
Dominic Peng

Respostas:

7

A JVM ligará para a GC por conta própria, mas, neste caso, será tarde demais. Não é apenas a GC que é responsável por limpar a memória neste caso. Os valores do mapa são fortemente alcançáveis ​​e são limpos pelo próprio mapa quando determinadas operações são invocadas nele.

Aqui está a saída se você ativar eventos do 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

O GC não é acionado até a última tentativa de colocar valor no mapa.

O WeakHashMap não pode limpar entradas obsoletas até que as chaves do mapa ocorram em uma fila de referência. E as chaves do mapa não ocorrem em uma fila de referência até serem coletadas como lixo. A alocação de memória para o novo valor do mapa é acionada antes que o mapa tenha alguma chance de se limpar. Quando a alocação de memória falha e aciona o GC, as chaves do mapa são coletadas. Mas é tarde demais - não foi liberada memória suficiente para alocar novo valor do mapa. Se você reduzir a carga útil, provavelmente terá memória suficiente para alocar novo valor do mapa e as entradas obsoletas serão removidas.

Outra solução poderia ser agrupar os valores em WeakReference. Isso permitirá que o GC limpe os recursos sem esperar que o mapa faça isso por conta própria. Aqui está a saída:

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)

Muito melhor.

tentáculo
fonte
Thx pela sua resposta, parece que sua conclusão está correta; enquanto eu tento reduzir a carga útil de 1024 * 10000 para 1024 * 1000; o código pode funcionar bem; mas ainda não entendo muito sua explicação; como seu significado, se precisar liberar espaço do WeakHashMap, faça gc duas vezes pelo menos; o primeiro momento é coletar chaves do mapa e adicioná-las à fila de referência; a segunda vez é coletar valores? mas, a partir do primeiro registro que você forneceu, na verdade, a JVM já recebeu o gc completo duas vezes;
Dominic Peng
Você está dizendo que "os valores do mapa são fortemente alcançáveis ​​e são limpos pelo próprio mapa quando determinadas operações são invocadas nele". De onde eles são acessíveis?
Andronicus
11
Não será suficiente ter apenas duas execuções de GC no seu caso. Primeiro, você precisa de uma execução de GC, isso está correto. Mas o próximo passo exigirá alguma interação com o próprio mapa. O que você deve procurar é um método java.util.WeakHashMap.expungeStaleEntriesque leia a fila de referência e remova as entradas do mapa, tornando os valores inacessíveis e sujeitos à coleção. Somente depois disso, a segunda passagem do GC liberará alguma memória. expungeStaleEntriesé chamado em vários casos, como get / put / size ou praticamente tudo o que você costuma fazer com um mapa. Essa é a pegadinha.
tentacle
11
@ Andrronicus, esta é de longe a parte mais confusa do WeakHashMap. Foi coberto várias vezes. stackoverflow.com/questions/5511279/…
tentacle
2
@ Andrronicus, esta resposta , especialmente a segunda metade, também pode ser útil. Também este Q & A ...
Holger
5

A outra resposta está realmente correta, editei a minha. Como um pequeno adendo, G1GCnão exibirá esse comportamento, ao contrário ParallelGC; qual é o padrão em java-8.

O que você acha que vai acontecer se eu mudar um pouco o seu programa (RUN sob jdk-8com -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
    }
}

Funcionará muito bem. Por que é que? Como ele oferece ao seu programa espaço suficiente para novas alocações, antes de WeakHashMaplimpar suas entradas. E a outra resposta já explica como isso acontece.

Agora, as G1GCcoisas seriam um pouco diferentes. Quando um objeto tão grande é alocado ( geralmente mais de 1/2 a MB ), isso seria chamado de humongous allocation. Quando isso acontece, um GC simultâneo será acionado. Como parte desse ciclo: uma coleção jovem será acionada e Cleanup phaseserá iniciado um que cuidará da publicação do evento no ReferenceQueue, para que WeakHashMapapague suas entradas.

Então, para este código:

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 eu corro com jdk-13 (onde G1GCé o padrão)

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

Aqui está uma parte dos logs:

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

Isso já faz algo diferente. Ele inicia um concurrent cycle(feito enquanto o aplicativo está sendo executado), porque havia um G1 Humongous Allocation. Como parte desse ciclo simultâneo, ele executa um ciclo de GC jovem (que interrompe seu aplicativo durante a execução)

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

Como parte desse jovem CG, ele também limpa regiões enormes , eis o defeito .


Agora você pode ver que jdk-13não espera o lixo se acumular na região antiga quando objetos realmente grandes são alocados, mas dispara um ciclo simultâneo de GC, que salvou o dia; ao contrário do jdk-8.

Você pode ler o que DisableExplicitGCe / ou o que ExplicitGCInvokesConcurrentsignifica, juntamente com System.gce entender por que a chamada System.gcrealmente ajuda aqui.

Eugene
fonte
11
O Java 8 não usa G1GC por padrão. E os logs do GC do OP também mostram claramente que ele está usando o GC paralelo para a geração antiga. E para um coletor não concorrente, é tão simples como descrito nesta resposta
Holger
@ Holger Eu estava revendo esta resposta hoje de manhã apenas para perceber que é verdade ParalleGC. Eu editei e desculpe (e obrigado) por provar que estou errado.
Eugene
11
A "alocação imensa" ainda é uma dica correta. Com um coletor não simultâneo, isso implica que o primeiro GC será executado quando a geração antiga estiver cheia; portanto, a falha na recuperação de espaço suficiente a tornará fatal. Por outro lado, quando você reduz o tamanho da matriz, um GC jovem é acionado quando ainda resta memória na geração antiga, para que o coletor possa promover objetos e continuar. Para um coletor simultâneo, por outro lado, é normal disparar o gc antes que o heap esteja esgotado; portanto, -XX:+UseG1GCfaça-o funcionar no Java 8, assim como -XX:+UseParallelOldGCfalha nas novas JVMs.
Holger