É 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 synchronized
impede 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 volatile
também altera o tratamento de long
e ). 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 A
quando ele grava no campo volátil f
se torna visível ao encadeamento B
quando 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+write
instruçõ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.