Quebrando otimizações JIT com reflexão

9

Ao brincar com testes de unidade para uma classe singleton altamente concorrente, deparei-me com o seguinte comportamento estranho (testado no JDK 1.8.0_162):

private static class SingletonClass {
    static final SingletonClass INSTANCE = new SingletonClass(0);
    final int value;

    static SingletonClass getInstance() {
        return INSTANCE;
    }

    SingletonClass(int value) {
        this.value = value;
    }
}

public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {

    System.out.println(SingletonClass.getInstance().value); // 0

    // Change the instance to a new one with value 1
    setSingletonInstance(new SingletonClass(1));
    System.out.println(SingletonClass.getInstance().value); // 1

    // Call getInstance() enough times to trigger JIT optimizations
    for(int i=0;i<100_000;++i){
        SingletonClass.getInstance();
    }

    System.out.println(SingletonClass.getInstance().value); // 1

    setSingletonInstance(new SingletonClass(2));
    System.out.println(SingletonClass.INSTANCE.value); // 2
    System.out.println(SingletonClass.getInstance().value); // 1 (2 expected)
}

private static void setSingletonInstance(SingletonClass newInstance) throws NoSuchFieldException, IllegalAccessException {
    // Get the INSTANCE field and make it accessible
    Field field = SingletonClass.class.getDeclaredField("INSTANCE");
    field.setAccessible(true);

    // Remove the final modifier
    Field modifiersField = Field.class.getDeclaredField("modifiers");
    modifiersField.setAccessible(true);
    modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);

    // Set new value
    field.set(null, newInstance);
}

As duas últimas linhas do método main () discordam do valor de INSTANCE - meu palpite é que o JIT se livrou completamente do método, pois o campo é estático final. A remoção da palavra-chave final faz com que o código produza os valores corretos.

Deixando de lado sua simpatia (ou falta dela) por singletons e esquecendo por um minuto que usar uma reflexão como esta está causando problemas - é minha suposição correta que as otimizações do JIT são as culpadas? Em caso afirmativo - esses são limitados apenas a campos finais estáticos?

Kelm
fonte
11
Um singleton é uma classe para a qual apenas uma instância pode existir. Portanto, você não tem um singleton, apenas uma classe com um static finalcampo. Além disso, não importa se esse hack de reflexão é interrompido devido a JIT ou simultaneidade.
Holger
@Holger esse hack foi feito em testes de unidade apenas como uma tentativa de zombar do singleton para vários casos de teste de uma classe que o usa. Não vejo como a concorrência poderia ter causado isso (não há nenhum no código acima) e eu realmente gostaria de saber o que aconteceu.
Kelm
11
Bem, você disse "classe singleton altamente concorrente" em sua pergunta e eu digo " não importa " o que faz com que ela se quebre. Portanto, se o seu código de exemplo específico for interrompido devido ao JIT e você encontrar uma solução alternativa para isso, o código real será alterado de interrupção devido ao JIT para interrupção devido à simultaneidade, o que você ganhou?
Holger
@ Holger ok, o texto era um pouco forte demais, desculpe por isso. O que eu quis dizer foi isso - se não entendermos por que algo dá tão errado, estamos propensos a ser mordidos pela mesma coisa no futuro, então prefiro saber o motivo do que assumir que "simplesmente acontece". De qualquer forma, obrigado por dedicar seu tempo para responder!
Kelm 8/01

Respostas:

7

Tomando sua pergunta literalmente: “ … minha suposição está correta em que as otimizações do JIT são as culpadas? ”, A resposta é sim, é muito provável que as otimizações do JIT sejam responsáveis ​​por esse comportamento neste exemplo específico.

Mas como a alteração de static finalcampos está completamente fora da especificação, há outras coisas que podem quebrá-lo da mesma forma. Por exemplo, o JMM não possui uma definição para a visibilidade da memória de tais alterações, portanto, não é completamente especificado se ou quando outros threads notam essas alterações. Eles nem precisam notá-lo de forma consistente, ou seja, podem usar o novo valor, seguido pelo valor antigo novamente, mesmo na presença de primitivas de sincronização.

No entanto, é difícil separar o JMM e o otimizador aqui.

Sua pergunta “ … são limitados apenas a campos finais estáticos? ”É muito mais difícil de responder, pois as otimizações, obviamente, não se limitam a static finalcampos, mas o comportamento de, por exemplo, não estáticofinal , campos , não é o mesmo e também possui diferenças entre teoria e prática.

Para finalcampos não estáticos , modificações via Reflexão são permitidas em determinadas circunstâncias. Isso é indicado pelo fato de que setAccessible(true)é suficiente para possibilitar essa modificação, sem invadir a Fieldinstância para alterar o modifierscampo interno .

A especificação diz:

17.5.3 Modificação subsequente de finalcampos

Em alguns casos, como desserialização, o sistema precisará alterar os finalcampos de um objeto após a construção. finalos campos podem ser alterados via reflexão e outros meios dependentes da implementação. O único padrão no qual isso tem semântica razoável é aquele no qual um objeto é construído e, em seguida, os finalcampos do objeto são atualizados. O objeto não deve ficar visível para outros threads, nem os finalcampos devem ser lidos, até que todas as atualizações nos finalcampos do objeto estejam completas. Os congelamentos de um finalcampo ocorrem no final do construtor em que o finalcampo está definido e imediatamente após cada modificação de um finalcampo por meio de reflexão ou outro mecanismo especial.

Outro problema é que a especificação permite otimização agressiva dos finalcampos. Dentro de um encadeamento, é permitido reordenar leituras de um finalcampo com as modificações de um finalcampo que não ocorrem no construtor.

Exemplo 17.5.3-1. Otimização agressiva de finalcampos
class A {
    final int x;
    A() { 
        x = 1; 
    } 

    int f() { 
        return d(this,this); 
    } 

    int d(A a1, A a2) { 
        int i = a1.x; 
        g(a1); 
        int j = a2.x; 
        return j - i; 
    }

    static void g(A a) { 
        // uses reflection to change a.x to 2 
    } 
}

No dmétodo, o compilador pode reordenar as leituras xe a chamada glivremente. Assim, new A().f()poderia voltar -1, 0ou 1.

Na prática, determinar os locais certos onde são possíveis otimizações agressivas sem violar os cenários legais descritos acima, é um problema em aberto ; portanto, a menos que -XX:+TrustFinalNonStaticFieldstenha sido especificado, a JVM do HotSpot não otimizará os finalcampos não estáticos da mesma maneira que os static finalcampos.

Obviamente, quando você não declara o campo como final, o JIT não pode assumir que ele nunca será alterado. Porém, na ausência de primitivas de sincronização de encadeamento, ele pode considerar as modificações reais acontecendo no caminho do código que otimiza (incluindo o reflexivos). Portanto, ele ainda pode otimizar agressivamente o acesso, mas apenas como se as leituras e gravações ainda acontecessem na ordem do programa no encadeamento em execução. Portanto, você só notaria as otimizações ao analisá-lo de um thread diferente sem construções de sincronização adequadas.

Holger
fonte
parece que muitas pessoas tentam explorar isso final, mas, embora algumas tenham se mostrado com melhor desempenho, algumas economias nsnão valem a pena quebrar muitos outros códigos. Razão pela qual Shenandoah está recuando em algumas de suas bandeiras, por exemplo
Eugene