O encadeamento Java que executa a operação restante em um loop bloqueia todos os outros encadeamentos

123

O seguinte snippet de código executa dois threads, um é um timer simples que registra a cada segundo, o segundo é um loop infinito que executa uma operação restante:

public class TestBlockingThread {
    private static final Logger LOGGER = LoggerFactory.getLogger(TestBlockingThread.class);

    public static final void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            int i = 0;
            while (true) {
                i++;
                if (i != 0) {
                    boolean b = 1 % i == 0;
                }
            }
        };

        new Thread(new LogTimer()).start();
        Thread.sleep(2000);
        new Thread(task).start();
    }

    public static class LogTimer implements Runnable {
        @Override
        public void run() {
            while (true) {
                long start = System.currentTimeMillis();
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // do nothing
                }
                LOGGER.info("timeElapsed={}", System.currentTimeMillis() - start);
            }
        }
    }
}

Isso fornece o seguinte resultado:

[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1004
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1003
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=13331
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1006
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1003
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1004
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1004

Não entendo por que a tarefa infinita bloqueia todos os outros threads por 13,3 segundos. Tentei alterar as prioridades do thread e outras configurações, nada funcionou.

Se você tiver alguma sugestão para corrigir isso (incluindo ajustar as configurações de alternância de contexto do SO), entre em contato.

kms333
fonte
8
@Marthin Não GC. É JIT. Executando com -XX:+PrintCompilation, recebo o seguinte no momento em que o atraso estendido termina: TestBlockingThread :: lambda $ 0 @ 2 (24 bytes) COMPILE SKIPPED: loop infinito trivial (tente novamente em camada diferente)
Andreas
4
Ele é reproduzido no meu sistema com a única alteração, substituindo a chamada de log por System.out.println. Parece um problema do agendador, porque se você introduzir um sono de 1ms dentro do loop while (true) do Runnable, a pausa no outro thread desaparecerá.
JJF 02/09
3
Não que eu recomende, mas se você desativar o JIT -Djava.compiler=NONE, isso não acontecerá.
Andreas
3
Você pode desativar o JIT para um único método. Consulte Desativar Java JIT para um método / classe específico?
Andreas
3
Não há divisão inteira nesse código. Corrija seu título e pergunta.
Marquês de Lorne

Respostas:

94

Depois de todas as explicações aqui (graças a Peter Lawrey ), descobrimos que a principal fonte dessa pausa é que o ponto seguro dentro do loop é atingido muito raramente, portanto leva muito tempo para parar todos os threads para a substituição do código compilado pelo JIT.

Mas decidi ir mais fundo e descobrir por que raramente o ponto seguro é alcançado. Achei um pouco confuso por que o salto traseiro do whileloop não é "seguro" nesse caso.

Então, convoco -XX:+PrintAssemblytoda a sua glória para ajudar

-XX:+UnlockDiagnosticVMOptions \
-XX:+TraceClassLoading \
-XX:+DebugNonSafepoints \
-XX:+PrintCompilation \
-XX:+PrintGCDetails \
-XX:+PrintStubCode \
-XX:+PrintAssembly \
-XX:PrintAssemblyOptions=-Mintel

Após alguma investigação, descobri que, após a terceira recompilação do C2compilador lambda, jogamos fora as pesquisas do safepoint dentro do loop completamente.

ATUALIZAR

Durante o estágio de criação de perfil, a variável inunca foi vista igual a 0. É por isso que C2otimizou especulativamente esse ramo, para que o loop fosse transformado em algo como

for (int i = OSR_value; i != 0; i++) {
    if (1 % i == 0) {
        uncommon_trap();
    }
}
uncommon_trap();

Observe que o loop infinito originalmente foi remodelado para um loop finito regular com um contador! Devido à otimização do JIT para eliminar pesquisas de ponto seguro em loops finitos, também não havia pesquisa de ponto seguro nesse loop.

Depois de algum tempo, iembrulhado de volta para 0, e a armadilha incomum foi tomada. O método foi desoptimizado e a execução continuou no intérprete. Durante a recompilação com um novo conhecimento, C2reconheceu o loop infinito e desistiu da compilação. O restante do método prosseguiu no intérprete com pontos de segurança adequados.

Há uma excelente postagem de blog de leitura obrigatória, "Pontos seguros: significado, efeitos colaterais e despesas gerais", de Nitsan Wakart, que aborda pontos seguros e esse problema em particular.

A eliminação do Safepoint em loops contados por muito tempo é conhecida por ser um problema. O bug JDK-5014723(graças a Vladimir Ivanov ) soluciona esse problema.

A solução alternativa estará disponível até que o bug seja finalmente corrigido.

  1. Você pode tentar usar -XX:+UseCountedLoopSafepoints(ele irá causar pena de desempenho global e pode levar a acidente JVM JDK-8161147 ). Após o uso, o C2compilador continua mantendo os pontos seguros nos saltos traseiros e a pausa original desaparece completamente.
  2. Você pode desabilitar explicitamente a compilação do método problemático usando
    -XX:CompileCommand='exclude,binary/class/Name,methodName'

  3. Ou você pode reescrever seu código adicionando o ponto seguro manualmente. Por exemplo, Thread.yield()ligar no final do ciclo ou até mudar int ipara long i(obrigado, Nitsan Wakart ) também corrigirá a pausa.

vsminkov
fonte
7
Esta é a verdadeira resposta para a questão de como corrigir .
Andreas
AVISO: Não use -XX:+UseCountedLoopSafepointsem produção, pois pode travar a JVM . Até agora, a melhor solução alternativa é dividir manualmente o loop longo em outros mais curtos.
Apangin
@apangin aah. Entendi! obrigado :) é por isso que c2remove safepoints! mas mais uma coisa que eu não entendi é o que está acontecendo a seguir. Até onde eu vejo, não há pontos seguros após o desenrolar do loop (?) e parece que não há como executar o stw. para que ocorra algum tempo limite e a des otimização ocorre?
Vsminkov 5/09
2
Meu comentário anterior não foi exato. Agora está completamente claro o que acontece. No estágio de criação de perfil inunca é 0, então o loop é especulativamente transformado em algo como for (int i = osr_value; i != 0; i++) { if (1 % i == 0) uncommon_trap(); } uncommon_trap();um loop regular finito contado. Depois que ivolta para 0, a captura incomum é feita, o método é desoptimizado e prosseguido no intérprete. Durante a recompilação com o novo conhecimento, o JIT reconhece o loop infinito e desiste da compilação. O restante do método é executado no intérprete com pontos de segurança adequados.
apangin 6/09/16
1
Você poderia apenas fazer um ia longo em vez de um int, que tornaria o loop "incontável" e resolveria o problema.
Nitsan Wakart
64

Em resumo, o loop que você possui não tem um ponto seguro dentro dele, exceto quando i == 0é atingido. Quando esse método é compilado e aciona o código a ser substituído, ele precisa levar todos os encadeamentos para um ponto seguro, mas isso leva muito tempo, bloqueando não apenas o encadeamento que executa o código, mas todos os encadeamentos na JVM.

Eu adicionei as seguintes opções de linha de comando.

-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime -XX:+PrintCompilation

Também modifiquei o código para usar o ponto flutuante que parece levar mais tempo.

boolean b = 1.0 / i == 0;

E o que eu vejo na saída é

timeElapsed=100
Application time: 0.9560686 seconds
  41423  280 %     4       TestBlockingThread::lambda$main$0 @ -2 (27 bytes)   made not entrant
Total time for which application threads were stopped: 40.3971116 seconds, Stopping threads took: 40.3967755 seconds
Application time: 0.0000219 seconds
Total time for which application threads were stopped: 0.0005840 seconds, Stopping threads took: 0.0000383 seconds
  41424  281 %     3       TestBlockingThread::lambda$main$0 @ 2 (27 bytes)
timeElapsed=40473
  41425  282 %     4       TestBlockingThread::lambda$main$0 @ 2 (27 bytes)
  41426  281 %     3       TestBlockingThread::lambda$main$0 @ -2 (27 bytes)   made not entrant
timeElapsed=100

Nota: para que o código seja substituído, os threads precisam ser interrompidos em um ponto seguro. No entanto, parece aqui que esse ponto seguro é alcançado muito raramente (possivelmente apenas quando i == 0Alterando a tarefa para

Runnable task = () -> {
    for (int i = 1; i != 0 ; i++) {
        boolean b = 1.0 / i == 0;
    }
};

Eu vejo um atraso semelhante.

timeElapsed=100
Application time: 0.9587419 seconds
  39044  280 %     4       TestBlockingThread::lambda$main$0 @ -2 (28 bytes)   made not entrant
Total time for which application threads were stopped: 38.0227039 seconds, Stopping threads took: 38.0225761 seconds
Application time: 0.0000087 seconds
Total time for which application threads were stopped: 0.0003102 seconds, Stopping threads took: 0.0000105 seconds
timeElapsed=38100
timeElapsed=100

Ao adicionar código ao loop com cuidado, você recebe um atraso maior.

for (int i = 1; i != 0 ; i++) {
    boolean b = 1.0 / i / i == 0;
}

fica

 Total time for which application threads were stopped: 59.6034546 seconds, Stopping threads took: 59.6030773 seconds

No entanto, altere o código para usar um método nativo que sempre tenha um ponto seguro (se não for um intrínseco)

for (int i = 1; i != 0 ; i++) {
    boolean b = Math.cos(1.0 / i) == 0;
}

impressões

Total time for which application threads were stopped: 0.0001444 seconds, Stopping threads took: 0.0000615 seconds

Nota: adicionar if (Thread.currentThread().isInterrupted()) { ... }a um loop adiciona um ponto seguro.

Nota: Isso aconteceu em uma máquina com 16 núcleos, portanto, não há falta de recursos da CPU.

Peter Lawrey
fonte
1
Então é um bug da JVM, certo? Onde "bug" significa qualidade severa do problema de implementação e não violação das especificações.
usr
1
O @vsminkov poder parar o mundo por vários minutos devido à falta de pontos seguros parece que deve ser tratado como bug. O tempo de execução é responsável por introduzir pontos seguros para evitar longas esperas.
Voo 02/09
1
@Voo, mas por outro lado, manter pontos de segurança em todos os saltos traseiros pode custar muitos ciclos de CPU e causar uma degradação notável no desempenho de toda a aplicação. mas eu concordo com você. nesse caso em particular, parece legítimo manter o ponto seguro
vsminkov 02/09
9
@Voo bem ... Eu sempre lembrar esta imagem quando se trata de otimizações de desempenho: D
vsminkov
1
O .NET insere pontos seguros aqui (mas o .NET gera código lento). Uma solução possível é dividir o loop. Divida em dois loops, faça o interior não verificar lotes de 1024 elementos e o loop externo conduz lotes e pontos seguros. Reduz a sobrecarga conceitualmente em 1024x, menos na prática.
usr
26

Encontrou a resposta do porquê . Eles são chamados de pontos seguros e são mais conhecidos como o Stop-The-World que acontece por causa do GC.

Consulte este artigo: Registrando pausas de interrupção do mundo na JVM

Eventos diferentes podem fazer com que a JVM pause todos os encadeamentos de aplicativos. Essas pausas são chamadas pausas do tipo Stop-The-World (STW). A causa mais comum de uma pausa do STW ser acionada é a coleta de lixo (exemplo no github), mas ações JIT diferentes (exemplo), revogação de bloqueio parcial (exemplo), certas operações da JVMTI e muitas outras também exigem que o aplicativo seja parado.

Os pontos nos quais os threads do aplicativo podem ser interrompidos com segurança são chamados, surpresa, pontos seguros . Este termo também é frequentemente usado para se referir a todas as pausas do STW.

É mais ou menos comum que os logs do GC estejam ativados. No entanto, isso não captura informações sobre todos os pontos seguros. Para obter tudo, use estas opções da JVM:

-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime

Se você está se perguntando sobre a nomeação explicitamente referente ao GC, não se assuste - ativar essas opções registra todos os pontos seguros, não apenas pausas na coleta de lixo. Se você executar um exemplo a seguir (fonte no github) com os sinalizadores especificados acima.

Lendo o Glossário de Termos do HotSpot , ele define o seguinte:

ponto seguro

Um ponto durante a execução do programa no qual todas as raízes do GC são conhecidas e todo o conteúdo do objeto de heap é consistente. Do ponto de vista global, todos os encadeamentos devem ser bloqueados em um ponto seguro antes que o GC possa ser executado. (Como um caso especial, os threads que executam o código JNI podem continuar sendo executados, porque usam apenas identificadores. Durante um ponto seguro, eles devem bloquear em vez de carregar o conteúdo do identificador.) Do ponto de vista local, um ponto seguro é um ponto distinto em um bloco de código em que o encadeamento em execução pode bloquear o GC. A maioria dos sites de chamadas é qualificada como pontos seguros.Existem fortes invariantes que são verdadeiros em todos os pontos seguros, que podem ser desconsiderados em pontos não seguros. Tanto o código Java compilado quanto o código C / C ++ podem ser otimizados entre pontos seguros, mas menos entre pontos seguros. O compilador JIT emite um mapa de GC em cada ponto seguro. O código C / C ++ na VM usa convenções estilizadas baseadas em macro (por exemplo, TRAPS) para marcar possíveis pontos seguros.

Correndo com os sinalizadores mencionados acima, recebo esta saída:

Application time: 0.9668750 seconds
Total time for which application threads were stopped: 0.0000747 seconds, Stopping threads took: 0.0000291 seconds
timeElapsed=1015
Application time: 1.0148568 seconds
Total time for which application threads were stopped: 0.0000556 seconds, Stopping threads took: 0.0000168 seconds
timeElapsed=1015
timeElapsed=1014
Application time: 2.0453971 seconds
Total time for which application threads were stopped: 10.7951187 seconds, Stopping threads took: 10.7950774 seconds
timeElapsed=11732
Application time: 1.0149263 seconds
Total time for which application threads were stopped: 0.0000644 seconds, Stopping threads took: 0.0000368 seconds
timeElapsed=1015

Observe o terceiro evento STW:
Tempo total parado: 10.7951187 segundos Os
threads de parada demoraram: 10.7950774 segundos

O JIT em si praticamente não demorou, mas depois que a JVM decidiu executar uma compilação do JIT, entrou no modo STW, no entanto, como o código a ser compilado (o loop infinito) não possui um site de chamada , nenhum ponto seguro foi alcançado.

O STW termina quando o JIT acaba desistindo de esperar e conclui que o código está em um loop infinito.

Andreas
fonte
"Ponto seguro - um ponto durante a execução do programa no qual todas as raízes do GC são conhecidas e todo o conteúdo do objeto heap é consistente" - Por que isso não seria verdade em um loop que apenas define / lê variáveis ​​locais do tipo de valor?
BlueRaja - Danny Pflughoeft 02/02
@ BlueRaja-DannyPflughoeft Eu tentei responder a esta pergunta na minha resposta
vsminkov 2/16/16
5

Depois de seguir os tópicos de comentários e alguns testes por conta própria, acredito que a pausa é causada pelo compilador JIT. Por que o compilador JIT está demorando tanto tempo está além da minha capacidade de depurar.

No entanto, como você só pediu como evitar isso, eu tenho uma solução:

Puxe seu loop infinito para um método em que ele possa ser excluído do compilador JIT

public class TestBlockingThread {
    private static final Logger LOGGER = Logger.getLogger(TestBlockingThread.class.getName());

    public static final void main(String[] args) throws InterruptedException     {
        Runnable task = () -> {
            infLoop();
        };
        new Thread(new LogTimer()).start();
        Thread.sleep(2000);
        new Thread(task).start();
    }

    private static void infLoop()
    {
        int i = 0;
        while (true) {
            i++;
            if (i != 0) {
                boolean b = 1 % i == 0;
            }
        }
    }

Execute seu programa com este argumento da VM:

-XX: CompileCommand = exclude, PACKAGE.TestBlockingThread :: infLoop (substitua PACKAGE pelas informações do pacote)

Você deve receber uma mensagem como esta para indicar quando o método teria sido compilado por JIT:
### Excluindo a compilação: bloqueio estático.TestBlockingThread :: infLoop
você pode perceber que eu coloquei a classe em um pacote chamado blocking

Jeutnarg
fonte
1
O compilador não está tomando muito tempo, o problema é o código não está chegando a um ponto seguro, porque não há ninguém dentro do loop exceto quandoi == 0
Peter Lawrey
@ PeterLawrey, mas por que o fim do ciclo em whileloop não é um ponto seguro?
Vsminkov 02/09
@vsminkov Parece que existe um ponto seguro, if (i != 0) { ... } else { safepoint(); }mas isso é muito raro. ie se você sair / interromper o ciclo, obtém os mesmos tempos.
Peter Lawrey
@ PeterLawrey, depois de um pouco de investigação, descobri que é prática comum fazer um ponto seguro no salto traseiro do loop. Só estou curioso qual é a diferença neste caso particular. talvez eu seja ingênuo, mas eu não vejo nenhuma razão pela qual volta salto não é "seguro"
vsminkov
@vsminkov Eu suspeito que o JIT vê um ponto seguro no loop, por isso não adiciona um no final.
Peter Lawrey