- Introdução ao Framework AQS
O AQS (AbstractQueuedSynchronizer) foi introduzido no JDK 1.5. Em uma época em que o synchronized era ineficiente e limitado em funcionalidade, o AQS, em certo sentido, revolucionou o desenvolvimento concorrente em Java.
1.1. Considerações Iniciais
Ao estudar um novo tópico, é benéfico abordá-lo com perguntas específicas. Ignorando o AQS por um momento, considere o seguinte cenário de obtenção de recursos:
- Pergunta: O que fazer quando uma thread não consegue obter um recurso necessário?
Resposta: Tentar novamente continuamente, para que se possa obter o recurso rapidamente assim que ele for liberado. - Pergunta: Se o recurso é mantido por um longo período, essa tentativa contínua não desperdiça ciclos de CPU?
Resposta: Sim. Nesse caso, a thread pode dormir por um tempo (ex: 1 segundo) antes de tentar novamente. - Pergunta: Mas se o recurso for liberado em 0.1 segundo, a thread esperará desnecessariamente por 0.9 segundos.
Resposta: Uma alternativa é suspender a thread. A thread que libera o recurso então notifica a thread em espera. - Pergunta: Se a duração do bloqueio for muito curta, o custo de suspensão e ativação das threads pode ser alto.
Resposta: A solução ideal depende do caso: usar spin-locking para bloqueios curtos e suspensão para bloqueios longos. - Pergunta: Como determinar a duração do bloqueio, especialmente quando é variável ou imprevisível?
- Pergunta: E se uma thread quiser desistir enquanto espera?
- Perguntas adicionais: Como adicionar recursos dinamicamente? Como evitar inanição e garantir ordem? Suportar reentrância? Monitorar threads em espera?
Um simples problema de espera por recursos desencadeia uma complexidade considerável. O framework AQS não resolve todos esses problemas universalmente. Reconhecendo essa complexidade, Doug Lea projetou o AQS como uma classe abstrata. Subclasses como ReentrantLock e Semaphore implementam soluções para cenários específicos. Quando essas subclasses não atendem a uma necessidade, podemos criar nossas próprias subclasses, sobrescrevendo poucos métodos para implementar funcionalidades poderosas.
1.2. Estrutura do Framework
O conceito central do AQS é uma fila de bloqueio (FIFO) que mantém todas as threads que estão esperando por um recurso. Isso levanta uma questão: uma fila de bloqueio é sempre eficiente?
Considere dois cenários para operações de bloqueio muito rápidas:
- Cenário 1 (com fila): Usar uma classe baseada no AQS, como
ReentrantLock. Em caso de contenção, a thread é adicionada à fila de bloqueio. - Cenário 2 (spin-lock): Quando o tempo de bloqueio é muito curto, a thread pode simplesmente tentar adquirir o recurso repetidamente sem suspensão.
O código a seguir compara essas duas abordagens. A classe SpinLock herda do AQS, mas ignora sua fila de bloqueio, implementando uma lógica de tentativa contínua.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
public class LockPerformanceTest {
// Exemplo de um "spin-lock" simples usando AQS, mas sem usar a fila de bloqueio.
private static class SpinLock extends AbstractQueuedSynchronizer {
protected final boolean tryAcquire(int permits) {
Thread currentThread = Thread.currentThread();
// Loop de tentativa contínua (spin)
while (true) {
int currentPermits = getState();
if (currentPermits == 0) {
if (compareAndSetState(0, permits)) {
setExclusiveOwnerThread(currentThread);
return true;
}
}
}
}
protected final boolean tryRelease(int permits) {
int newPermits = getState() - permits;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean isFree = false;
if (newPermits == 0) {
isFree = true;
setExclusiveOwnerThread(null);
}
setState(newPermits);
return isFree;
}
}
public static void main(String[] args) throws InterruptedException {
int taskCount = 2;
int iterations = 50_000_000;
// Teste com ReentrantLock (usa fila de bloqueio do AQS)
java.util.concurrent.locks.ReentrantLock reentrantLock = new java.util.concurrent.locks.ReentrantLock();
long startReentrant = System.currentTimeMillis();
ExecutorService executor1 = Executors.newFixedThreadPool(taskCount);
for (int i = 0; i < taskCount; i++) {
executor1.submit(() -> {
for (int j = 0; j < iterations; j++) {
reentrantLock.lock();
// Operação de negócios simulada (vazia)
reentrantLock.unlock();
}
});
}
executor1.shutdown();
executor1.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
long durationReentrant = System.currentTimeMillis() - startReentrant;
// Teste com SpinLock (não usa fila de bloqueio)
SpinLock spinLock = new SpinLock();
long startSpin = System.currentTimeMillis();
ExecutorService executor2 = Executors.newFixedThreadPool(taskCount);
for (int i = 0; i < taskCount; i++) {
executor2.submit(() -> {
for (int j = 0; j < iterations; j++) {
spinLock.tryAcquire(1);
// Operação de negócios simulada (vazia)
spinLock.tryRelease(1);
}
});
}
executor2.shutdown();
executor2.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
long durationSpin = System.currentTimeMillis() - startSpin;
System.out.println("Tempo com ReentrantLock (fila de bloqueio): " + durationReentrant + " ms");
System.out.println("Tempo com SpinLock (tentativa contínua): " + durationSpin + " ms");
}
}
Os resultados típicos mostram que o SpinLock é mais rápido em operações muito rápidas, pois evita o custo de suspensão e ativação das threads. Isso demonstra que nenhum framework é universalmente ideal; a escolha depende do padrão de uso.
1.3. Estrutura de Classes e Uso Básico
A classe AbstractQueuedSynchronizer mantém um estado inteiro volátil (int state) crucial para operações CAS (Compare-And-Swap). Para uma trava exclusiva, mudar o estado de 0 para 1 indica aquisição bem-sucedida. Para uma trava compartilhaad, o estado pode representar o número máximo de permissões concorrentes.
Para usar o AQS, definimos uma classe concreta que implementa os métodos de sincronização necessários:
- Para travas exclusivas:
tryAcquire(),tryRelease(), opcionalmenteisHeldExclusively(). - Para travas compartilhadas:
tryAcquireShared(),tryReleaseShared().
A interface pública (métodos lock/unlock, acquire/release) é definida pelo desenvolvedor com base nos requisitos de negócios.
- Conceitos de Travas (Locks) em Java
2.1. Trava Exclusiva (Exclusive Lock)
Permite que apenas uma thread execute a seção crítica por vez. Analogamente, é como uma ponte estreita que suporta apenas uma pessoa.
Exemplos no JDK: ReentrantLock, ReentrantReadWriteLock (para a parte de escrita).
2.2. Trava Compartilhada (Shared Lock)
Permite que múltiplas threads acessem o recurso simultaneamente, até um limite definido (grau de concorrência). Por exemplo, uma ponte que suporta 3 pessoas ao mesmo tempo.
Uma trava compartilhada com grau de concorrência 1 funciona como uma trava exclusiva.
Exemplos no JDK: Semaphore, ReentrantReadWriteLock (para a parte de leitura).
2.3. Trava Justa (Fair Lock)
Uma trava justa garante que as threads adquiram o bloqueio na ordem FIFO em que solicitaram. Se a fila de bloqueio não estiver vazia, uma nova thread deve se juntar à fila e esperar. Isso promove justiça, mas geralmente reduz o throughput, pois evita que uma thread recém-chegada "pule" a fila.
2.4. Trava Injusta (Unfair Lock)
Uma trava injusta permite que uma thread recém-chegada tente aduqirir o bloqueio imediatamente, mesmo que outras threads estejam esperando. Isso pode causar inanição para threads na fila, mas frequentemente oferece maior throughput. O synchronized e a implementação padrão do ReentrantLock são travas injustas.
A vantagem de desempenho surge porque evita o atraso de acordar a thread no topo da fila, uma operação que pode envolver uma troca de contexto do sistema operacional.
2.5. Trava Reentrant (Reentrant Lock)
Permite que uma thread que já possui a trava a adquira novamente sem causar um deadlock. É essencial que o número de aquisições corresponda ao número de liberações. Tanto o synchronized quanto o ReentrantLock são reentrant.
2.6. Otimizações do Synchronized
O mecanismo synchronized do JVM evoluiu significativamente, implementando uma escalação progressiva de bloqueios:
- Bloqueio Tendencioso (Biased Lock): Otimizado para o caso comum onde uma única thread acessa o recurso repetidamente. O ID da thread é armazenado no cabeçalho do objeto. Nenhuma operação CAS é necessária para a aquisição subsequente pela mesma thread.
- Bloqueio Leve (Lightweight Lock / Spin Lock): Ativado quando há disputa leve. A thread "gira" (executa um loop de tentativas) por um número limitado de vezes antes de suspender. É eficaz para bloqueios curtos.
- Bloqueio Pesado (Heavyweight Lock): A thread é suspensa e adicionada a uma fila de bloqueio no nível do sistema operacional, envolvendo uma troca de contexto completa.
Essa escalação ocorre apenas na direção de tendencioso → leve → pesado. Em benchmarks simples, como o abaixo, o synchronized moderno demonstra desempenho comparável ao ReentrantLock.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class SyncVsReentrantTest {
private static final Object lockObject = new Object();
public static void main(String[] args) throws InterruptedException {
int threadCount = 5;
int operations = 30_000_000;
// Teste com ReentrantLock
java.util.concurrent.locks.ReentrantLock lock = new java.util.concurrent.locks.ReentrantLock();
long startTime = System.currentTimeMillis();
ExecutorService service1 = Executors.newFixedThreadPool(threadCount);
for (int i = 0; i < threadCount; i++) {
service1.submit(() -> {
for (int j = 0; j < operations; j++) {
lock.lock();
try {
// Operação de negócios simulada
} finally {
lock.unlock();
}
}
});
}
service1.shutdown();
service1.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
System.out.println("Tempo com ReentrantLock: " + (System.currentTimeMillis() - startTime) + " ms");
// Teste com synchronized
startTime = System.currentTimeMillis();
ExecutorService service2 = Executors.newFixedThreadPool(threadCount);
for (int i = 0; i < threadCount; i++) {
service2.submit(() -> {
for (int j = 0; j < operations; j++) {
synchronized (lockObject) {
// Operação de negócios simulada
}
}
});
}
service2.shutdown();
service2.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
System.out.println("Tempo com synchronized: " + (System.currentTimeMillis() - startTime) + " ms");
}
}
O synchronized é a escolha padrão para bloqueio na maioria dos casos, com os desenvolvedores do JDK recomendando seu uso devido às extensivas otimizações realizadas no compilador JIT e na JVM.