É importante entender que existem dois aspectos para a segurança do encadeamento.
- controle de execução e
- visibilidade da memória
O primeiro tem a ver com o controle de quando o código é executado (incluindo a ordem na qual as instruções são executadas) e se ele pode ser executado simultaneamente, e o segundo com o momento em que os efeitos na memória do que foi feito são visíveis para outros threads. Como cada CPU possui vários níveis de cache entre ela e a memória principal, os segmentos em execução em diferentes CPUs ou núcleos podem ver a "memória" de maneira diferente a qualquer momento, porque os segmentos têm permissão para obter e trabalhar em cópias particulares da memória principal.
O uso synchronizedimpede que qualquer outro encadeamento obtenha o monitor (ou bloqueio) para o mesmo objeto , impedindo a execução simultânea de todos os blocos de código protegidos pela sincronização no mesmo objeto . A sincronização também cria uma barreira de memória "acontece antes", causando uma restrição de visibilidade da memória, de modo que qualquer coisa feita até o ponto em que um thread libera um bloqueio apareça em outro segmento, adquirindo posteriormente o mesmo bloqueio que ocorreu antes de o bloqueio ser adquirido. Em termos práticos, no hardware atual, isso geralmente causa a liberação dos caches da CPU quando um monitor é adquirido e grava na memória principal quando é lançado, sendo ambos (relativamente) caros.
O uso volatile, por outro lado, força todos os acessos (leitura ou gravação) à variável volátil a ocorrerem na memória principal, mantendo efetivamente a variável volátil fora dos caches da CPU. Isso pode ser útil para algumas ações em que é simplesmente necessário que a visibilidade da variável esteja correta e a ordem dos acessos não seja importante. O uso volatiletambém altera o tratamento de longe ). Para fins de visibilidade, cada acesso a um campo volátil atua como meia sincronização.double exige que os acessos sejam atômicos; em alguns hardwares (mais antigos), isso pode exigir bloqueios, embora não no hardware moderno de 64 bits. Sob o novo modelo de memória (JSR-133) para Java 5+, a semântica do volátil foi reforçada para ser quase tão forte quanto a sincronizada em relação à visibilidade da memória e à ordem das instruções (consulte http://www.cs.umd.edu /users/pugh/java/memoryModel/jsr-133-faq.html#volatile
Sob o novo modelo de memória, ainda é verdade que variáveis voláteis não podem ser reordenadas entre si. A diferença é que agora não é mais tão fácil reordenar acessos de campo normais ao seu redor. Gravar em um campo volátil tem o mesmo efeito de memória que uma liberação do monitor, e a leitura de um campo volátil tem o mesmo efeito de memória que uma aquisição do monitor. Com efeito, como o novo modelo de memória impõe restrições mais rígidas à reordenação de acessos voláteis de campo com outros acessos voláteis, voláteis ou não, qualquer coisa visível ao encadeamento Aquando ele grava no campo volátil fse torna visível ao encadeamento Bquando lê f.
- Perguntas frequentes sobre JSR 133 (Java Memory Model)
Portanto, agora as duas formas de barreira à memória (sob o JMM atual) causam uma barreira para reordenar as instruções que impede que o compilador ou o tempo de execução reordenem as instruções através da barreira. No antigo JMM, o volátil não impediu o pedido novamente. Isso pode ser importante, porque, além das barreiras de memória, a única limitação imposta é que, para qualquer thread em particular , o efeito líquido do código seja o mesmo que seria se as instruções fossem executadas exatamente na ordem em que aparecem no fonte.
Um uso de volátil é para um objeto compartilhado, mas imutável, ser recriado em tempo real, com muitos outros threads fazendo referência ao objeto em um ponto específico do ciclo de execução. É necessário que os outros encadeamentos comecem a usar o objeto recriado após a publicação, mas não precisam da sobrecarga adicional da sincronização completa e de sua contenção e liberação do cache.
// Declaration
public class SharedLocation {
static public SomeObject someObject=new SomeObject(); // default object
}
// Publishing code
// Note: do not simply use SharedLocation.someObject.xxx(), since although
// someObject will be internally consistent for xxx(), a subsequent
// call to yyy() might be inconsistent with xxx() if the object was
// replaced in between calls.
SharedLocation.someObject=new SomeObject(...); // new object is published
// Using code
private String getError() {
SomeObject myCopy=SharedLocation.someObject; // gets current copy
...
int cod=myCopy.getErrorCode();
String txt=myCopy.getErrorText();
return (cod+" - "+txt);
}
// And so on, with myCopy always in a consistent state within and across calls
// Eventually we will return to the code that gets the current SomeObject.
Falando especificamente sobre sua pergunta de leitura, atualização e gravação. Considere o seguinte código não seguro:
public void updateCounter() {
if(counter==1000) { counter=0; }
else { counter++; }
}
Agora, com o método updateCounter () não sincronizado, dois threads podem inseri-lo ao mesmo tempo. Entre as muitas permutações do que poderia acontecer, uma é que o thread-1 faz o teste para o contador == 1000 e descobre que é verdadeiro e é suspenso. Em seguida, o thread-2 faz o mesmo teste e também o considera verdadeiro e está suspenso. Em seguida, o thread-1 continua e define o contador como 0. Em seguida, o thread-2 continua e define o contador novamente como 0 porque perdeu a atualização do thread-1. Isso também pode acontecer mesmo que a troca de encadeamento não ocorra como descrevi, mas simplesmente porque duas cópias em cache diferentes do contador estavam presentes em dois núcleos de CPU diferentes e os encadeamentos eram executados em um núcleo separado. Nesse caso, um encadeamento pode ter contador em um valor e o outro pode ter contador em algum valor completamente diferente apenas por causa do armazenamento em cache.
O importante neste exemplo é que o contador de variáveis foi lido da memória principal no cache, atualizado no cache e gravado apenas na memória principal em algum momento indeterminado posteriormente, quando ocorreu uma barreira de memória ou quando a memória cache era necessária para outra coisa. Fazer o contador volatileé insuficiente para a segurança do encadeamento desse código, porque o teste para o máximo e as atribuições são operações discretas, incluindo o incremento, que é um conjunto de read+increment+writeinstruções não atômicas da máquina, algo como:
MOV EAX,counter
INC EAX
MOV counter,EAX
Variáveis voláteis são úteis apenas quando todas as operações executadas nelas são "atômicas", como no meu exemplo, em que uma referência a um objeto totalmente formado é apenas lida ou gravada (e, de fato, normalmente é escrita apenas a partir de um único ponto). Outro exemplo seria uma referência de matriz volátil apoiando uma lista de cópia na gravação, desde que a matriz fosse lida apenas pela primeira cópia local da referência.