A segurança de threads é um conceito fundamental no desenvolvimento de aplicações concorrentes, garantindo que o acesso simultâneo a recursos compartilhados por múltiplas threads não resulte em estados inconsistentes ou dados corrompidos. Quando um sistema é considerado "seguro para threads", isso implica que foram implementados mecanismos de controle de acesso, como bloqueios, que permitem que apenas uma thread por vez modifique ou leia um dado específico, protegendo-o de acessos conflitantes. Em contraste, a falta de segurança de threads ocorre quando não há proteção adequada para o acesso a dados compartilhados. Nesses cenários, várias threads podem tentar alterar o mesmo dado simultaneamente, levando a condições de corrida e resultados imprevisíveis ou incorretos. A plataforma Java oferece diversas ferramentas para gerenciar a concorrência e assegurar a segurança de threads, destacando-se as palavras-chave synchronized, a API Lock e as interfaces Condition.
O Modificador synchronizedDeclarar um bloco de código ou método como synchronized tem implicações importantes para a concorrência, principalmente no que diz respeito à atomicidade e à visibilidade.
Atomicidade A atomicidade garante que uma sequência de operações seja tratada como uma unidade indivisível. Em um bloco synchronized, isso significa que em qualquer momento, apenas uma thread pode executar o código protegido por um objeto monitor. Essa exclusão mútua é essencial para prevenir que múltiplas threads alterem um estado compartilhado simultaneamente, o que poderia levar a inconsistências.
Visibilidade A visibilidade aborda um problema mais sutil relacionado a caches de memória e otimizações de compilador. Ela assegura que as modificações feitas por uma thread em dados compartilhados, dentro de um bloco synchronized, sejam imediatamente visíveis para outras threads que posteriormente adquirirem o mesmo bloqueio. Sem essa garantia, uma thread poderia operar com uma versão desatualizada ou inconsistente de um dado, gerando erros sérios. O funcionamento interno do synchronized invalida caches de CPU ao adquirir um bloqueio e força a escrita de quaisquer alterações para a memória principal antes de liberar o bloqueio, garantindo assim a consistência da memória entre threads. É importante notar que o modificador volatile também garante visibilidade, mas não assegura atomicidade. Para operações que envolvem leitura-modificação-escrita de dados compartilhados, synchronized é geralmente a escolha mais apropriada.
Quando Sincronizar? Regras básicas para aplicar a sincronização de visibilidade incluem:
- Ao ler uma variável que pode ter sido escrita por outra thread.
- Ao escrever uma variável que pode ser lida por outra thread. A sincronização de consistência é necessária quando múltiplas alterações a dados relacionados precisam ser vistas de forma atômica por outras threads — ou todas as mudanças são vistas, ou nenhuma. Isso se aplica a coleções de itens, onde a estrutura e o conteúdo são interligados.
Limitações do synchronizedEmbora synchronized seja eficaz, ele possui certas restrições:
- Não é possível interromper uma thread que está aguardando para adquirir um bloqueio.
- Não permite tentar adquirir um bloqueio e desistir se não estiver disponível imediatamente (sem esperar).
- A liberação do bloqueio deve ocorrer no mesmo escopo (frame da pilha) onde foi adquirido, o que nem sempre é ideal para padrões de bloqueio mais complexos.
ReentrantLock: Otimizando Bloqueios A estrutura de bloqueio oferecida pelo pacote java.util.concurrent.locks, em particular a interface Lock e sua implementação ReentrantLock, oferece uma alternativa mais flexível e poderosa ao synchronized. Ela permite que os bloqueios sejam implementados como classes Java, abrindo espaço para diversas estratégias de agendamento e semânticas de bloqueio. ReentrantLock oferece as mesmas garantias de concorrência e memória que synchronized, mas adiciona funcionalidades avançadas como a capacidade de tentar adquirir um bloqueio com tempo limite (try-lock com timeout), bloqueios interrompíveis e, em cenários de alta contenção, um desempenho geralmente superior devido a uma menor sobrecarga de agendamento pela JVM. Ao contrário de synchronized, que libera o bloqueio automaticamente, ReentrantLock exige a liberação explícita do bloqueio. Isso torna imperativo envolver o código da seção crítica em um bloco try-finally para garantir que o lock.unlock() seja invocado, mesmo que ocorra uma exceção.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class ProcessadorDeSaida {
private final Lock recursoBloqueio = new ReentrantLock();
public void exibirNome(String nome) {
recursoBloqueio.lock(); // Adquire o bloqueio
try {
for (int i = 0; i < nome.length(); i++) {
System.out.print(nome.charAt(i));
}
System.out.println(); // Nova linha após exibir o nome
} finally {
recursoBloqueio.unlock(); // Libera o bloqueio
}
}
}
ReadWriteLock: Aprimorando a Concorrência para Leitura e Escrita Imagine uma situação onde múltiplas threads precisam ler e escrever em um dado compartilhado. Se usarmos synchronized para proteger tanto as operações de leitura quanto as de escrita, apenas uma thread poderá acessar o dado por vez, mesmo que sejam apenas operações de leitura. Isso é ineficiente, pois múltiplas leituras simultâneas geralmente não causam problemas de consistência, desde que nenhuma escrita esteja ocorrendo. Um exemplo prático seria uma classe com métodos para definir (set) e obter (get) um valor inteiro:
import java.util.Random;
class DadosCompartilhadosSincronizados {
private int valor;
public synchronized void definirValor(int novoValor) {
System.out.println(Thread.currentThread().getName() + " -> Preparando para escrever " + novoValor);
try {
Thread.sleep(20); // Simula processamento
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
this.valor = novoValor;
System.out.println(Thread.currentThread().getName() + " -> Escreveu: " + this.valor);
}
public synchronized void obterValor() {
System.out.println(Thread.currentThread().getName() + " -> Preparando para ler");
try {
Thread.sleep(20); // Simula processamento
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(Thread.currentThread().getName() + " -> Leu: " + this.valor);
}
}
public class TesteAcessoDados {
public static void main(String[] args) {
final DadosCompartilhadosSincronizados dados = new DadosCompartilhadosSincronizados();
// Threads de escrita
for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int j = 0; j < 5; j++) {
dados.definirValor(new Random().nextInt(30));
}
}, "Escritor-" + i).start();
}
// Threads de leitura
for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int j = 0; j < 5; j++) {
dados.obterValor();
}
}, "Leitor-" + i).start();
}
}
}
No código acima, ao usar synchronized nos métodos definirValor e obterValor, notamos que threads de leitura são mutuamente exclusivas, assim como leitores e escritores. Isso significa que, mesmo se houver cinco threads apenas lendo, elas ainda esperarão umas pelas outras, um gargalo desnecessário. Para resolver essa limitação, a API java.util.concurrent.locks.ReadWriteLock (e sua implementação ReentrantReadWriteLock) oferece uma solução otimizada. Ela permite que múltiplas threads leiam um recurso simultaneamente (bloqueio de leitura compartilhado), mas exige exclusão mútua quando uma thread deseja escrever (bloqueio de escrita exclusivo).
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
class DadosCompartilhadosRW {
private int dado;
private final ReadWriteLock bloqueioRW = new ReentrantReadWriteLock();
public void definirDado(int novoDado) {
bloqueioRW.writeLock().lock(); // Adquire o bloqueio de escrita
try {
System.out.println(Thread.currentThread().getName() + " -> Preparando para escrever " + novoDado);
try {
Thread.sleep(20); // Simula processamento
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
this.dado = novoDado;
System.out.println(Thread.currentThread().getName() + " -> Escreveu: " + this.dado);
} finally {
bloqueioRW.writeLock().unlock(); // Libera o bloqueio de escrita
}
}
public void obterDado() {
bloqueioRW.readLock().lock(); // Adquire o bloqueio de leitura
try {
System.out.println(Thread.currentThread().getName() + " -> Preparando para ler");
try {
Thread.sleep(20); // Simula processamento
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(Thread.currentThread().getName() + " -> Leu: " + this.dado);
} finally {
bloqueioRW.readLock().unlock(); // Libera o bloqueio de leitura
}
}
}
Com ReadWriteLock, observamos que várias threads leitoras podem executar simultaneamente, melhorando significativamente a concorrência em sistemas com muitas operações de leitura e poucas de escrita. Os ganhos de desempenho são mais perceptíveis em ambientes multi-processador e para dados que são frequentemente lidos, mas raramente modificados (como caches ou dicionários).
Condition: Comunicação Fina Entre Threads A interface Condition, parte da API java.util.concurrent.locks, oferece um mecanismo avançado para a comunicação entre threads, atuando como um substituto mais flexível para os métodos wait(), notify() e notifyAll() da classe Object. As instâncias de Condition são sempre vinculadas a uma instância de Lock e são criadas através do método newCondition() de um objeto Lock. O poder das Conditions reside na capacidade de criar múltiplos conjuntos de espera (wait sets) para um único bloqueio. Isso permite que diferentes condições de espera sejam associadas a grupos específicos de threads, resultando em um controle de notificação mais granular e eficiente. Por exemplo, em um buffer limitado, podemos ter um conjunto de espera para threads que precisam depositar itens quando o buffer está cheio e outro conjunto para threads que precisam retirar itens quando o buffer está vazio. Considere a implementação de um buffer circular com capacidade limitada:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class BufferDelimitado {
private final Lock bloqueioPrincipal = new ReentrantLock();
private final Condition naoCheio = bloqueioPrincipal.newCondition(); // Condição para threads escritoras
private final Condition naoVazio = bloqueioPrincipal.newCondition(); // Condição para threads leitoras
private final Object[] itens = new Object[10]; // Buffer para armazenar itens
private int indiceEscrita; // Próxima posição para escrever
private int indiceLeitura; // Próxima posição para ler
private int contador; // Número de itens no buffer
public void depositar(Object item) throws InterruptedException {
bloqueioPrincipal.lock(); // Adquire o bloqueio
try {
// Enquanto o buffer estiver cheio, a thread espera na condição 'naoCheio'
while (contador == itens.length) {
naoCheio.await();
}
itens[indiceEscrita] = item;
if (++indiceEscrita == itens.length) {
indiceEscrita = 0;
}
contador++;
// Sinaliza que o buffer não está mais vazio, potencialmente acordando uma thread leitora
naoVazio.signal();
} finally {
bloqueioPrincipal.unlock(); // Libera o bloqueio
}
}
public Object retirar() throws InterruptedException {
bloqueioPrincipal.lock(); // Adquire o bloqueio
try {
// Enquanto o buffer estiver vazio, a thread espera na condição 'naoVazio'
while (contador == 0) {
naoVazio.await();
}
Object item = itens[indiceLeitura];
if (++indiceLeitura == itens.length) {
indiceLeitura = 0;
}
contador--;
// Sinaliza que o buffer não está mais cheio, potencialmente acordando uma thread escritora
naoCheio.signal();
return item;
} finally {
bloqueioPrincipal.unlock(); // Libera o bloqueio
}
}
}
Neste exemplo, se o buffer estiver cheio, apenas as threads que tentam depositar são colocadas em espera na condição naoCheio. Quando um item é retirado, apenas as threads em naoCheio são notificadas, evitando notificações desnecessárias para threads leitoras. Essa segregação das condições de espera otimiza o agendamento de threads, reduzindo o número de despertares "falsos" e melhorando a eficiência geral do sistema concorrente.