Brincando com isso por um tempo observei o seguinte:
Se a classe Words for definido assim:
public class Words {
public static final String FIRST = "the";
public static final String SECOND = null;
public static final String THIRD = "set";
}
O compilador cria o seguinte código(bytecode, a saída de “javap -c”) para o método main da classe PrintWords:
Assim, com uma referência para Words.SECOND o código fica “lendo” o valor no tempo de execução. A diferença entre os versões sendo que o 1.5 usa a classe StringBuilder que não é sincronizado e assim é mais rápido.
E isso é a resposta para sua pergunta. Porque pega o valor do Words.SECOND cada vez da classe Words, e assim, qualquer mudança no valor será refletido na execução da classe PrintWords. Mas faz optimização com FIRST e THIRD e pega os valores no tempo da compilação, copiando os valores dos Strings na classe PrintWords.
Ok… mas… se a classe Words for definido na forma seguinte:
public class Words {
public static final String FIRST = "the";
public static final String SECOND = "chemistry";
public static final String THIRD = "set";
}
O bytecode criado para a outra classe… a classe PrintWriter vai ser o sequinte:
O compilador fazendo optimização criando o String completa e não colocando referências para a classe Words. Assim, uma mudança na classe Words não será automaticamente refletido na classe PrintWords - só quando a classe PrintWords também está compilado.
Então… o compilador não está optimizando o String com uma referência null.
Receio que com meu português o assunto fica ainda mais confuso… mas é um risco que a gente tem que correr.