Por que o fluxo paralelo com lambda no inicializador estático causa um deadlock?

86

Eu me deparei com uma situação estranha em que usar um fluxo paralelo com um lambda em um inicializador estático leva uma eternidade sem a utilização da CPU. Aqui está o código:

class Deadlock {
    static {
        IntStream.range(0, 10000).parallel().map(i -> i).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

Este parece ser um caso de teste de reprodução mínima para esse comportamento. Se eu:

  • coloque o bloco no método principal em vez de um inicializador estático,
  • remova a paralelização ou
  • remova o lambda,

o código é concluído instantaneamente. Alguém pode explicar esse comportamento? É um bug ou é intencional?

Estou usando o OpenJDK versão 1.8.0_66-internal.

Reintegrar Monica
fonte
4
Com intervalo (0, 1), o programa termina normalmente. Com (0, 2) ou mais trava.
Laszlo Hirdi
5
pergunta semelhante: stackoverflow.com/questions/34222669/…
Alex - GlassEditor.com
2
Na verdade, é exatamente a mesma questão / problema, apenas com uma API diferente.
Didier L de
3
Você está tentando usar uma classe, em um thread de segundo plano, quando não terminou de inicializar a classe, portanto, ela não pode ser usada em um thread de segundo plano.
Peter Lawrey
4
@Solomonoff'sSecret como i -> inão é uma referência de método, é static methodimplementado na classe Deadlock. Se substituir i -> ipor Function.identity()este código deve estar bem.
Peter Lawrey

Respostas:

71

Encontrei um relatório de bug de um caso muito semelhante ( JDK-8143380 ) que foi fechado como "Não é um problema" por Stuart Marks:

Este é um impasse de inicialização de classe. O thread principal do programa de teste executa o inicializador estático da classe, que define o sinalizador de inicialização em andamento para a classe; este sinalizador permanece definido até que o inicializador estático seja concluído. O inicializador estático executa um fluxo paralelo, que faz com que as expressões lambda sejam avaliadas em outros threads. Esses threads bloqueiam esperando que a classe conclua a inicialização. No entanto, o encadeamento principal é bloqueado, aguardando a conclusão das tarefas paralelas, resultando em um conflito.

O programa de teste deve ser alterado para mover a lógica do fluxo paralelo para fora do inicializador estático da classe. Fechamento como não é um problema.


Consegui encontrar outro relatório de bug ( JDK-8136753 ), também fechado como "Not an Issue" por Stuart Marks:

Este é um deadlock que está ocorrendo porque o inicializador estático do Fruit enum está interagindo mal com a inicialização da classe.

Consulte a Especificação da linguagem Java, seção 12.4.2 para obter detalhes sobre a inicialização da classe.

http://docs.oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.4.2

Resumidamente, o que está acontecendo é o seguinte.

  1. O thread principal faz referência à classe Fruit e inicia o processo de inicialização. Isso define o sinalizador de inicialização em andamento e executa o inicializador estático no thread principal.
  2. O inicializador estático executa algum código em outro encadeamento e aguarda sua conclusão. Este exemplo usa fluxos paralelos, mas isso não tem nada a ver com fluxos em si. Executar o código em outra thread por qualquer meio e aguardar a conclusão desse código terá o mesmo efeito.
  3. O código no outro thread faz referência à classe Fruit, que verifica o sinalizador de inicialização em andamento. Isso faz com que o outro thread seja bloqueado até que o sinalizador seja apagado. (Consulte a etapa 2 de JLS 12.4.2.)
  4. O encadeamento principal está bloqueado esperando que o outro encadeamento termine, portanto, o inicializador estático nunca é concluído. Como o sinalizador de inicialização em andamento não é limpo até que o inicializador estático seja concluído, os threads estão em conflito.

Para evitar esse problema, certifique-se de que a inicialização estática de uma classe seja concluída rapidamente, sem fazer com que outros threads executem o código que exige que a inicialização dessa classe seja concluída.

Fechamento como não é um problema.


Observe que FindBugs tem um problema aberto para adicionar um aviso para esta situação.

Tunaki
fonte
20
"Isto foi considerado quando projetou o recurso" e "Nós sabemos o que causa esse bug, mas não como corrigi-lo" fazer não média "este não é um erro" . Isso é absolutamente um bug.
BlueRaja - Danny Pflughoeft
13
@ bayou.io O principal problema é usar threads em inicializadores estáticos, não lambdas.
Stuart Marks de
5
BTW Tunaki, obrigado por desenterrar meus relatórios de bug. :-)
Stuart Marks
13
@ bayou.io: é a mesma coisa em nível de classe que seria em um construtor, deixando thisescapar durante a construção do objeto. A regra básica é: não use operações multithread nos inicializadores. Não acho que seja difícil de entender. Seu exemplo de registrar uma função implementada lambda em um registro é uma coisa diferente, ele não cria deadlocks a menos que você vá esperar por um desses threads de fundo bloqueados. No entanto, eu desencorajo fortemente a realização de tais operações em um inicializador de classe. Não é para isso que eles foram feitos.
Holger
9
Acho que a lição de estilo de programação é: mantenha os inicializadores estáticos simples.
Raedwald
16

Para aqueles que estão se perguntando onde estão os outros threads que fazem referência à Deadlockprópria classe, os lambdas do Java se comportam como você escreveu isto:

public class Deadlock {
    public static int lambda1(int i) {
        return i;
    }
    static {
        IntStream.range(0, 10000).parallel().map(new IntUnaryOperator() {
            @Override
            public int applyAsInt(int operand) {
                return lambda1(operand);
            }
        }).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

Com classes anônimas regulares, não há impasse:

public class Deadlock {
    static {
        IntStream.range(0, 10000).parallel().map(new IntUnaryOperator() {
            @Override
            public int applyAsInt(int operand) {
                return operand;
            }
        }).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}
Tamas Hegedus
fonte
5
@Solomonoff'sSecret É uma escolha de implementação. O código no lambda precisa ir para algum lugar. Javac o compila em um método estático na classe contida (análogo a lambda1i neste exemplo). Colocar cada lambda em sua própria classe teria sido consideravelmente mais caro.
Stuart Marks de
1
@StuartMarks Dado que o lambda cria uma classe implementando a interface funcional, não seria tão eficiente colocar a implementação do lambda na implementação do lambda da interface funcional como no segundo exemplo deste post? Essa é certamente a maneira óbvia de fazer as coisas, mas tenho certeza de que há uma razão pela qual elas são feitas do jeito que são.
Reintegrar Monica em
6
@Solomonoff'sSecret O lambda pode criar uma classe em tempo de execução (via java.lang.invoke.LambdaMetafactory ), mas o corpo do lambda deve ser colocado em algum lugar em tempo de compilação. As classes lambda podem, portanto, tirar vantagem de alguma mágica da VM para serem menos caras do que as classes normais carregadas de arquivos .class.
Jeffrey Bosboom
1
@Solomonoff'sSecret Sim, a resposta de Jeffrey Bosboom está correta. Se em uma JVM futura for possível adicionar um método a uma classe existente, a metafábrica pode fazer isso em vez de girar uma nova classe. (Pura especulação.)
Stuart Marks
3
@Segredo de Solomonoff: não julgue olhando para expressões lambda tão triviais como a sua i -> i; eles não serão a norma. As expressões lambda podem usar todos os membros de sua classe circundante, incluindo privatealguns, e isso torna a própria classe definidora seu lugar natural. Deixar todos esses casos de uso sofrerem de uma implementação otimizada para o caso especial de inicializadores de classe com uso multi-thread de expressões lambda triviais, não usando membros de sua classe definidora, não é uma opção viável.
Holger
14

Há uma excelente explicação desse problema por Andrei Pangin , datada de 07 de abril de 2015. Está disponível aqui , mas está escrita em russo (sugiro revisar os exemplos de código de qualquer maneira - eles são internacionais). O problema geral é um bloqueio durante a inicialização da classe.

Aqui estão algumas citações do artigo:


De acordo com o JLS , cada classe possui um bloqueio de inicialização exclusivo que é capturado durante a inicialização. Quando outro encadeamento tenta acessar essa classe durante a inicialização, ele será bloqueado no bloqueio até que a inicialização seja concluída. Quando as classes são inicializadas simultaneamente, é possível obter um deadlock.

Escrevi um programa simples que calcula a soma de inteiros, o que deve imprimir?

public class StreamSum {
    static final int SUM = IntStream.range(0, 100).parallel().reduce((n, m) -> n + m).getAsInt();

    public static void main(String[] args) {
        System.out.println(SUM);
    }
} 

Agora remova parallel()ou substitua lambda por Integer::sumchamada - o que mudará?

Aqui, vemos o deadlock novamente [houve alguns exemplos de deadlocks em inicializadores de classe anteriormente no artigo]. Por causa das parallel()operações de fluxo executadas em um conjunto de encadeamentos separado. Esses threads tentam executar o corpo lambda, que é escrito em bytecode como um private staticmétodo dentro da StreamSumclasse. Mas este método não pode ser executado antes da conclusão do inicializador estático da classe, que aguarda os resultados da conclusão do fluxo.

O que é mais impressionante: este código funciona de maneira diferente em ambientes diferentes. Ele funcionará corretamente em uma máquina com uma única CPU e provavelmente travará em uma máquina com várias CPUs. Essa diferença vem da implementação do pool Fork-Join. Você pode verificar você mesmo alterando o parâmetro-Djava.util.concurrent.ForkJoinPool.common.parallelism=N

AdamSkywalker
fonte