AQS框架简介与Java中的锁机制

  1. 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:

  1. Cenário 1 (com fila): Usar uma classe baseada no AQS, como ReentrantLock. Em caso de contenção, a thread é adicionada à fila de bloqueio.
  2. 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(), opcionalmente isHeldExclusively().
  • 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.

  1. 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:

  1. 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.
  2. 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.
  3. 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.

Tags: java AQS synchronized Locks Concurrency

Publicado em 7-9 05:43