Por que esse programa Java termina, apesar de aparentemente não ter (e não ter)?

205

Uma operação sensível no meu laboratório hoje deu completamente errado. Um atuador em um microscópio eletrônico ultrapassou seus limites e, após uma cadeia de eventos, perdi US $ 12 milhões em equipamentos. Eu reduzi mais de 40 mil linhas no módulo defeituoso para isso:

import java.util.*;

class A {
    static Point currentPos = new Point(1,2);
    static class Point {
        int x;
        int y;
        Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }
    public static void main(String[] args) {
        new Thread() {
            void f(Point p) {
                synchronized(this) {}
                if (p.x+1 != p.y) {
                    System.out.println(p.x+" "+p.y);
                    System.exit(1);
                }
            }
            @Override
            public void run() {
                while (currentPos == null);
                while (true)
                    f(currentPos);
            }
        }.start();
        while (true)
            currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}

Algumas amostras da saída que estou recebendo:

$ java A
145281 145282
$ java A
141373 141374
$ java A
49251 49252
$ java A
47007 47008
$ java A
47427 47428
$ java A
154800 154801
$ java A
34822 34823
$ java A
127271 127272
$ java A
63650 63651

Como não há aritmética de ponto flutuante aqui, e todos sabemos que números inteiros assinados se comportam bem no estouro de Java, acho que não há nada de errado com esse código. No entanto, apesar da saída indicar que o programa não atingiu a condição de saída, ele atingiu a condição de saída (foi atingido e não atingido?). Por quê?


Notei que isso não acontece em alguns ambientes. Estou no OpenJDK 6 no Linux de 64 bits.

Cão
fonte
41
12 milhões de equipamentos? Estou realmente curioso para saber como isso poderia acontecer ... por que você está usando o bloco de sincronização vazio: synchronized (this) {}?
Martin V.
84
Isso não é nem remotamente seguro para threads.
Matt Bola
8
Interessante notar: adicionar o finalqualificador (que não afeta o código de código produzido) aos campos xe y"resolver" o erro. Embora não afete o bytecode, os campos são sinalizados com ele, o que me leva a pensar que esse é um efeito colateral de uma otimização da JVM.
Niv Steingarten
9
@Eugene: Deve não acabar. A questão é "por que termina?". A Point pé construído que satisfaz p.x+1 == p.y, então uma referência é passada para o encadeamento de polling. Eventualmente, o encadeamento de polling decide sair porque acha que a condição não é atendida para um dos Points que recebe, mas a saída do console mostra que deveria ter sido atendida. A falta volatiledaqui simplesmente significa que o tópico de pesquisa pode ficar preso, mas esse claramente não é o problema aqui.
Erma K. Pizarro
21
@ JohnNicholas: O código real (que obviamente não é esse) tinha 100% de cobertura e milhares de testes, muitos dos quais testaram coisas em milhares de várias ordens e permutações ... O teste não encontra magicamente todos os casos extremos causados ​​por fatores não determinísticos JIT / cache / planejador. O verdadeiro problema é que o desenvolvedor que escreveu esse código não sabia que a construção não acontecia antes de usar o objeto. Observe como remover o vazio synchronizedfaz com que o erro não aconteça? Isso porque eu tive que escrever código aleatoriamente até encontrar um que reproduzisse esse comportamento deterministicamente.
Dog

Respostas:

140

Obviamente, a gravação no currentPos não acontece antes da leitura, mas não vejo como isso pode ser o problema.

currentPos = new Point(currentPos.x+1, currentPos.y+1);faz algumas coisas, incluindo a gravação de valores padrão em xe y(0) e a gravação de seus valores iniciais no construtor. Como seu objeto não é publicado com segurança, essas 4 operações de gravação podem ser reordenadas livremente pelo compilador / JVM.

Portanto, da perspectiva do encadeamento de leitura, é uma execução legal ler xcom seu novo valor, mas ycom seu valor padrão 0, por exemplo. Quando você alcança a printlninstrução (que por sinal é sincronizada e, portanto, influencia as operações de leitura), as variáveis ​​têm seus valores iniciais e o programa imprime os valores esperados.

Marcação currentPos como volatilegarantirá uma publicação segura, pois seu objeto é efetivamente imutável - se, no seu caso de uso real, o objeto volatilesofrer uma mutação após a construção, as garantias não serão suficientes e você poderá ver um objeto inconsistente novamente.

Como alternativa, você pode tornar o Pointimutável, o que também garantirá uma publicação segura, mesmo sem usarvolatile . Para alcançar a imutabilidade, basta marcar xe yfinalizar.

Como uma observação lateral e, como já mencionado, synchronized(this) {}pode ser tratado como não operacional pela JVM (eu entendo que você o incluiu para reproduzir o comportamento).

assylias
fonte
4
Não tenho certeza, mas a finalização de xey não teria o mesmo efeito, evitando a barreira da memória?
Michael Böckling
3
Um design mais simples é um objeto pontual imutável que testa invariantes na construção. Portanto, você nunca corre o risco de publicar uma configuração perigosa.
Ron
@BuddyCasino Sim, de fato - eu adicionei isso. Para ser sincero, não me lembro de toda a discussão há três meses (o uso final foi proposto nos comentários, por isso não sei por que não o incluí como opção).
assylias 15/08
2
A imutabilidade em si não garante uma publicação segura (se x an y fossem particulares, mas expostos apenas a getters, o mesmo problema de publicação ainda existiria). final ou volátil garante isso. Eu preferiria final do que volátil.
21813 Steve Kuo
A imutabilidade do @SteveKuo requer final - sem final, o melhor que você pode obter é a imutabilidade eficaz, que não possui a mesma semântica.
Assylias
29

Como currentPosestá sendo alterado fora do segmento, deve ser marcado como volatile:

static volatile Point currentPos = new Point(1,2);

Sem volátil, não é garantido que o encadeamento leia as atualizações do currentPos que estão sendo feitas no encadeamento principal. Portanto, novos valores continuam sendo gravados para o currentPos, mas o encadeamento continua a usar as versões em cache anteriores por motivos de desempenho. Como apenas um thread modifica o currentPos, você pode fugir sem bloqueios, o que melhorará o desempenho.

Os resultados parecerão muito diferentes se você ler os valores apenas uma única vez no encadeamento para uso na comparação e exibição subsequente deles. Quando eu faço o seguintex sempre é exibido como 1e yvaria entre 0e algum número inteiro grande. Eu acho que o comportamento dele neste momento é um pouco indefinido sem a volatilepalavra-chave e é possível que a compilação JIT do código esteja contribuindo para que ele atue dessa maneira. Além disso, se eu comentar o synchronized(this) {}bloco vazio , o código funcionará bem e suspeito que seja porque o bloqueio causa atraso suficiente currentPose que seus campos sejam relidos em vez de usados ​​no cache.

int x = p.x + 1;
int y = p.y;

if (x != y) {
    System.out.println(x+" "+y);
    System.exit(1);
}
Ed Plese
fonte
2
Sim, e eu também poderia simplesmente trancar tudo. Onde você quer chegar?
Dog
Eu adicionei algumas explicações adicionais para o uso de volatile.
Ed Plese 23/04
19

Você tem memória comum, a referência 'currentpos' e o objeto Point e seus campos atrás dele, compartilhados entre 2 threads, sem sincronização. Portanto, não há uma ordem definida entre as gravações que ocorrem nessa memória no encadeamento principal e as leituras no encadeamento criado (chame-o de T).

O thread principal está fazendo as seguintes gravações (ignorando a configuração inicial do point, resultará em px e py com valores padrão):

  • para px
  • fazer py
  • to currentpos

Como não há nada de especial nessas gravações em termos de sincronização / barreiras, o tempo de execução é livre para permitir que o encadeamento T os veja ocorrerem em qualquer ordem (o encadeamento principal sempre vê gravações e leituras ordenadas de acordo com a ordem do programa) e ocorre a qualquer momento entre as leituras em T.

Então, T está fazendo:

  1. lê currentpos para p
  2. leia px e py (em qualquer ordem)
  3. comparar e assumir o ramo
  4. leia px e py (por ordem) e chame System.out.println

Dado que não há relações de ordenação entre as gravações em main e as leituras em T, existem claramente várias maneiras de gerar esse resultado, pois T pode ver a gravação de main em currentpos antes das gravações em currentpos.y ou currentpos.x:

  1. Ele lê currentpos.x primeiro, antes que a gravação x ocorra - obtém 0 e depois lê currentpos.y antes que a gravação y ocorra - obtém 0. Compare evals com true. As gravações tornam-se visíveis para T. System.out.println é chamado.
  2. Ele lê currentpos.x primeiro, depois que a gravação x ocorreu, depois lê currentpos.y antes da gravação y - obtém 0. Compare evals com true. As gravações ficam visíveis para T ... etc.
  3. Ele lê currentpos.y primeiro, antes que a gravação y tenha ocorrido (0), depois lê currentpos.x após a gravação x, evolui para true. etc.

e assim por diante ... Há várias corridas de dados aqui.

Eu suspeito que a suposição defeituosa aqui está pensando que as gravações resultantes dessa linha são visíveis em todos os threads na ordem do programa que o executa:

currentPos = new Point(currentPos.x+1, currentPos.y+1);

Java não oferece essa garantia (seria terrível para o desempenho). Algo mais deve ser adicionado se o seu programa precisar de uma ordem garantida das gravações em relação às leituras em outros threads. Outros sugeriram que os campos x, y sejam finais ou, alternativamente, tornem os pos de corrente voláteis.

  • Se você finalizar os campos x, y, o Java garantirá que as gravações de seus valores ocorrerão antes do retorno do construtor, em todos os encadeamentos. Assim, como a atribuição ao currentpos é posterior ao construtor, o encadeamento T é garantido para ver as gravações na ordem correta.
  • Se você tornar os posicionamentos atuais voláteis, o Java garantirá que este seja um ponto de sincronização que terá ordem total em relação a outros pontos de sincronização. Como, principalmente, as gravações em xey devem ocorrer antes da gravação em currentpos, então qualquer leitura de currentpos em outro encadeamento deve também ver as gravações de x, y que ocorreram antes.

O uso final tem a vantagem de tornar os campos imutáveis ​​e, assim, permitir que os valores sejam armazenados em cache. O uso de leads voláteis para sincronização em todas as gravações e leituras dos registros atuais, o que pode prejudicar o desempenho.

Consulte o capítulo 17 do Java Language Spec para obter detalhes sórdidos: http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html

(A resposta inicial assumiu um modelo de memória mais fraco, pois eu não tinha certeza de que o volátil garantido pelo JLS era suficiente. Resposta editada para refletir o comentário das assilias, apontando que o modelo Java é mais forte - acontece - antes é transitivo - e tão volátil no currentpos também é suficiente )

paulj
fonte
2
Esta é a melhor explicação na minha opinião. Muito obrigado!
06
1
@skyde, mas errado na semântica dos voláteis. garantias voláteis de que as leituras de uma variável volátil verão a última gravação disponível de uma variável volátil , bem como qualquer gravação anterior . Nesse caso, se currentPosfor tornada volátil, a atribuição garante a publicação segura do currentPosobjeto e de seus membros, mesmo que eles próprios não sejam voláteis.
Assylias 16/08/13
Bem, eu estava dizendo que não podia, por mim mesmo, ver exatamente como o JLS garantiu que o volátil formava uma barreira com outras leituras e gravações normais. Tecnicamente, não posso estar errado nisso;). Quando se trata de modelos de memória, é prudente supor que um pedido não seja garantido e esteja errado (você ainda está seguro) do que o contrário e que esteja errado e inseguro. É ótimo se o volátil fornecer essa garantia. Você pode explicar como o capítulo 17 do JLS o fornece?
paulj
2
Em suma, em Point currentPos = new Point(x, y), você tem 3 gravações: (w1) this.x = x, (w2) this.y = ye (w3) currentPos = the new point. A ordem do programa garante que hb (w1, w3) e hb (w2, w3). Mais tarde no programa, você lê (r1) currentPos. Se currentPosnão é volátil, não há hb entre r1 e w1, w2, w3; portanto, r1 pode observar qualquer um (ou nenhum) deles. Com volátil, você apresenta hb (w3, r1). E o relacionamento hb é transitivo, então você também introduz hb (w1, r1) e hb (w2, r1). Isso está resumido em Concorrência Java na Prática (3.5.3. Idiomas de Publicação Segura).
Assylias 16/08/13
2
Ah, se hb é transitivo dessa maneira, então isso é uma 'barreira' forte o suficiente, sim. Devo dizer que não é fácil determinar que 17.4.5 do JLS define hb para ter essa propriedade. Certamente não está na lista de propriedades dada perto do início de 17.4.5. O fechamento transitivo só é mencionado mais adiante depois de algumas notas explicativas! Enfim, bom saber, obrigado pela resposta! :). Nota: atualizarei minha resposta para refletir o comentário de assylias.
paulj
-2

Você pode usar um objeto para sincronizar as gravações e leituras. Caso contrário, como outros disseram anteriormente, uma gravação no currentPos ocorrerá no meio das duas leituras p.x + 1 e py

new Thread() {
    void f(Point p) {
        if (p.x+1 != p.y) {
            System.out.println(p.x+" "+p.y);
            System.exit(1);
        }
    }
    @Override
    public void run() {
        while (currentPos == null);
        while (true)
            f(currentPos);
    }
}.start();
Object sem = new Object();
while (true) {
    synchronized(sem) {
        currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}
Germano Fronza
fonte
Na verdade, isso faz o trabalho. Na minha primeira tentativa, coloquei a leitura dentro do bloco sincronizado, mas depois percebi que não era realmente necessário.
Germano Fronza
1
-1 A JVM pode provar que semnão é compartilhada e tratar a instrução sincronizada como não operacional ... O fato de resolver o problema é pura sorte.
Assylias 16/08/13
4
Eu odeio programação multithread, muitas coisas funcionam por causa da sorte.
Jonathan Allen
-3

Você está acessando currentPos duas vezes e não oferece garantia de que não seja atualizado entre esses dois acessos.

Por exemplo:

  1. x = 10, y = 11
  2. thread de trabalho avalia px como 10
  3. thread principal executa a atualização, agora x = 11 e y = 12
  4. thread de trabalho avalia py como 12
  5. O segmento de trabalho percebe que 10 + 1! = 12; portanto, imprime e sai.

Você está basicamente comparando dois diferentes pontos .

Observe que mesmo tornar o currentPos volátil não o protegerá disso, pois são duas leituras separadas pelo thread de trabalho.

Adicione um

boolean IsValid() { return x+1 == y; }

método para sua classe de pontos. Isso garantirá que apenas um valor de currentPos seja usado ao marcar x + 1 == y.

user2686913
fonte
currentPos é lido apenas uma vez, seu valor copiado para p. p é lido duas vezes, mas sempre aponta para o mesmo local.
Jonathan Allen