Sincronização de Threads em Java: O Bloco e Método synchronized
Quando múltiplas threads acessam e modificam simultaneamente um recurso compartilhado, como um objeto em memória, o resultado pode se tornar incorreto e imprevisível. Esse cenário, onde o resultado da operação depende da ordem de execução imprevisível das threads, é conhecido como condição de corrida (race condition).
Considere o seguinte exemplo:
class CommonResource {
int x = 0;
}
class CountThread implements Runnable {
CommonResource res;
CountThread(CommonResource res) {
this.res = res;
}
public void run() {
res.x = 1;
for (int i = 1; i < 5; i++) {
System.out.printf("%s %d \n", Thread.currentThread().getName(), res.x);
res.x++;
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
}
}
}
public class Program {
public static void main(String[] args) {
CommonResource commonResource = new CommonResource();
for (int i = 1; i < 6; i++) {
Thread t = new Thread(new CountThread(commonResource));
t.setName("Thread " + i);
t.start();
}
}
}
Neste código, a intenção é que cada uma das cinco threads reinicie o contador res.x
para 1 e o incremente quatro vezes. Contudo, o resultado da execução é caótico:
Thread 1 1 Thread 2 1 Thread 3 1 Thread 5 1 Thread 4 1 Thread 5 6 Thread 2 6 ...
O problema ocorre porque a operação res.x++
não é atômica (ou seja, não é executada como uma única instrução indivisível). Ela, na verdade, consiste em três passos distintos:
- ler o valor atual de
x
, - somar 1 ao valor lido, e
- escrever o novo valor de volta em
x
.
Uma thread pode ser pausada pelo sistema operacional entre qualquer um desses passos. Por exemplo, a Thread A lê x
(valor 5), mas antes de escrever o novo valor 6, a Thread B também lê x
(que ainda é 5). Ambas as threads acabarão escrevendo 6 de volta, resultando em um incremento perdido, já que o valor final deveria ser 7.
O Bloco synchronized
Para garantir a execução atômica de um trecho de código, Java oferece o bloco synchronized
. Ele cria uma seção crítica, um bloco de código que apenas uma thread por vez pode executar.
Vamos modificar a classe CountThread
:
class CountThread implements Runnable {
CommonResource res;
CountThread(CommonResource res) {
this.res = res;
}
public void run() {
synchronized (res) {
res.x = 1;
for (int i = 1; i < 5; i++) {
System.out.printf("%s %d \n", Thread.currentThread().getName(), res.x);
res.x++;
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
}
}
}
}
A palavra-chave synchronized
é seguida por um objeto entre parênteses (res
, neste caso), que atua como um objeto de bloqueio (lock).
Tecnicamente, cada objeto em Java possui um monitor intrínseco. Para executar o código dentro de um bloco synchronized
, uma thread deve primeiro adquirir o lock do monitor associado ao objeto especificado.
Apenas uma thread pode possuir o lock de um determinado monitor por vez. Se uma thread adquire o lock de res
, qualquer outra thread que tente entrar em um bloco sincronizado pelo mesmo objeto res
será bloqueada e colocada em um estado de espera. O lock é liberado automaticamente quando a thread que o detém conclui a execução do bloco, permitindo que uma das threads em espera o adquira e prossiga.
Com essa alteração, a saída se torna ordenada e previsível:
Thread 1 1 Thread 1 2 Thread 1 3 Thread 1 4 Thread 3 1 Thread 3 2 Thread 3 3 Thread 3 4 ...
O Método synchronized
Uma alternativa conveniente é declarar um método inteiro como sincronizado. Isso é útil quando toda a lógica do método precisa de exclusão mútua.
Vamos refatorar o código, movendo a lógica para um método synchronized
na classe do recurso compartilhado:
class CommonResource {
int x = 0;
synchronized void increment() {
x = 1;
for (int i = 1; i < 5; i++) {
System.out.printf("%s %d \n", Thread.currentThread().getName(), x);
x++;
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
}
}
}
class CountThread implements Runnable {
CommonResource res;
CountThread(CommonResource res) {
this.res = res;
}
public void run() {
res.increment();
}
}
// A classe Program e o método main permanecem os mesmos
Declarar um método de instância como synchronized
é um atalho para envolver todo o seu corpo em um bloco synchronized(this)
.
Isso significa que o bloqueio é adquirido no monitor da própria instância do objeto (commonResource
, neste caso) em que o método foi chamado. Como todas as threads compartilham a mesma instância de commonResource
, elas competem pelo mesmo lock antes de executar o método increment()
, garantindo o mesmo comportamento de exclusão mútua visto anteriormente.
Resumo
- Condições de corrida (race conditions) ocorrem quando múltiplas threads manipulam dados compartilhados simultaneamente, levando a resultados inconsistentes.
- A palavra-chave
synchronized
é usada para criar seções críticas, garantindo que apenas uma thread por vez execute um trecho de código ou método. - A sincronização utiliza um monitor de objeto como um bloqueio (lock) para garantir a exclusão mútua.
- Métodos de instância
synchronized
usam o monitor da própria instância (this
) como o objeto de bloqueio.