Clique aqui para ler esse artigo em PDF.![]()
Byte Code
Concorrência no Java 5
A nova API do Tiger para Multithreading
As novidades do pacote java.util.concurrent revolucionam o desenvolvimento de aplicações trazendo capacidades espantosas de execução concorrente
Programação Concorrente (PC) é um recurso fundamental do Java. Existe desde a versão 1.0, teve pouquíssimas mudanças até o J2SE 1.4 e é uma das partes mais simples e estáveis da plataforma. A classe Thread cria contextos de execução concorrentes; todos os objetos têm monitores úteis para notificação e exclusão mútua (facilitada pela sintaxe synchronized); e o modificador volatile evita problemas com variáveis acessadas sem sincronização por múltiplos threads. Até há pouco, isso resumia tudo o que um programador Java precisava saber em matéria de threads e concorrência.
Ou será que não? A API e os recursos da linguagem podem ser simples, mas a complexidade da programação concorrente não está nas APIs e sintaxes. Está em outro nível. Por exemplo, como evitar deadlocks, tomando cuidado com a ordem de sincronizações; ou como criar um pool de threads; ou ainda como estruturar o código de um componente concorrente para obter o máximo de desempenho, sincronizando somente trechos mínimos de código.
Em muitos casos a simplicidade do modelo de programação concorrente do Java era uma desvantagem. Tínhamos um número muito pequeno de primitivas (threads e monitores) servindo como “pau para toda obra”, sendo às vezes necessário implementar recursos mais especializados sobre estas primitivas. Mas isso não era fácil – e devido às restrições de segurança da JVM, nem sempre podia ser feito com eficiência.
Uma das principais APIs introduzidas pelo J2SE 5.0, a java.util.concurrent (JUC), vem resolver estes problemas, acrescentando muitas novas facilidades de programação concorrente embutidas na plataforma Java. Veremos neste artigo o que fazem e como tirar proveito dessas novas funcionalidades.
Planejamento
Na elaboração deste artigo foi difícil decidir a ordem de exposição dos assuntos. Optei pela seqüência de mais baixo nível para mais alto nível, que evita problemas de dependência, pois os últimos são implementados sobre os primeiros. Por outro lado, os recursos de mais baixo nível são mais “hardcore”, e seu uso direto será mais comum em códigos de infra-estrutura. Os últimos, de mais alto nível, são mais acessíveis e orientados à programação de aplicações. Dependendo da sua familiaridade com técnicas de programação concorrente, pode ser mais fácil começar pelo tópico “Utilitários de alto nível” e depois ler os tópicos anteriores.
Também não houve espaço para uma exposição dos recursos tradicionais de PC do Java – criação de threads e uso de monitores – mas isso não deve ser um problema. A API java.util.concurrent apresenta um modelo quase totalmente independente de programação; você nunca precisará criar ou controlar threads diretamente (os “executores” farão isso com vantagens), e as funcionalidades dos monitores são totalmente substituídas por novas APIs de sincronização que são muito superiores.
Operações atômicas
O pacote java.util.concurrent.atomic oferece objetos que encapsulam todos os tipos primitivos, semelhantes aos wrappers de java.lang (Integer, Double etc.), com a diferença que os objetos da nova API são mutáveis, como mostra o exemplo:
AtomicInteger x = new AtomicInteger(10);
x.set(-5); // x é mutável – a mesma instância agora representa outro valor!
Integer y = new Integer(10);
y = new Integer(-5); // y é imutável – para representar outro valor deve-se criar outra instância
Estas novas classes também não podem substituir totalmente os wrappers tradicionais, como java.lang.Integer, pois o design imutável destas classes também tem suas vantagens. Os wrappers mutáveis não podem ser usados como chaves de Map ou em outras situações onde o comportamento de hashCode() e equals() precise ser estável (veja o artigo “Coleções de ponta a ponta” na Edição 18).
Mas as maiores vantagens das novas classes são seus métodos adicionais, que suportam uma série de operações interessantes com comportamento atômico:
Classes AtomicXXX: Wrappers mutáveis com operações atômicas
· get(), set(value): Permitem ler ou modificar o valor encapsulado .
· addAndGet(delta): Adiciona delta ao valor encapsulado, retornando o novo valor.
· getAndAdd(delta): Adiciona delta ao valor encapsulado, retornando o valor anterior.
· decrementAndGet(): Igual a addAndGet(-1), ou x = --y.
· incrementAndGet(): Igual a addAndGet(1), ou x = ++y.
· getAndDecrement(): Igual a getAndAdd(-1), ou x = y--.
· getAndIncrement(): Igual a getAndAdd(1), ou x = y++.
· getAndSet(newValue): Modifica o valor para newValue, mas retorna o valor anterior.
· compareAndSet(expect, update): Compara o valor atual com expect e somente se for igual, modifica-o para o valor de update.
· weakCompareAndSet(expect, update): Similar a compareAndSet(), mas pode não funcionar. Algumas CPUs não têm instruções para implementar compareAndSet() sem sincronização (veja o quadro “Extensões da JVM e desempenho”). Nesses casos, este método alternativo será mais eficiente, mas só pode ser usado em algoritmos que tolerem falhas aleatórias.
As classes AtomicInteger e AtomicLong implementam todos os métodos acima; AtomicBoolean, AtomicReference e AtomicStampedReference só não suportam os métodos aritméticos. Esta última classe é um caso especial, encapsulando dois valores – uma referência e um inteiro (a “estampa”) –, portanto equivale à união de uma AtomicReference com um AtomicInteger.
As classes AtomicIntegerArray, AtomicLongArray e AtomicReferenceArray suportam métodos semelhantes, mas encapsulam um array de cada tipo, e todos os métodos têm um parâmetro adicional para o índice, como addAndGet(i, delta).
AtomicIntegerFieldUpdater, AtomicLongFieldUpdater e AtomicReferenceFieldUpdater usam reflection para permitir executar as operações atômicas sobre atributos de qualquer objeto. Por exemplo, AtomicIntegerFieldUpdater.newUpdater(classe, atrib) retorna um Updater para o atributo “int atrib” de classe. Então, pode-se executar updater.addAndGet(objeto, expect, update) onde objeto é uma instância de classe. São suportados os mesmos métodos da lista anterior, sempre com um argumento adicional indicando qual instância manipular.
Esta API é extensa, mas é simples, com poucas operações fundamentais e diversas variações sobre estas operações – para cada tipo de valor encapsulado, ou para arrays ou para reflection.
Utilizando o pacote atômico
A vantagem dos objetos de java.util.concurrent.atomic é implementar operações críticas de forma atômica, sem nenhum custo. Todos os métodos são livres de sincronização, código nativo, ou qualquer outro recurso de alto overhead. Veja o quadro “Extensões da JVM e desempenho” para ver como isso é feito.
Já vimos anteriormente, em “Concorrência e a JVM” na Edição 11, que as JVMs modernas implementam sincronização de forma muito eficiente. Mas eficiência é sempre algo relativo. Uma operação synchronized no HotSpot 5.0 pode ser dezenas de vezes mais rápida do que na VM “clássica” do JDK 1.1, mas ainda tem um custo. Quando o bloco de código protegido pelo synchronized é relativamente grande e lento, por exemplo um método que faz uma consulta JDBC, o custo do synchronized é tão minúsculo que nem aparecerá numa análise de desempenho. Mas se o synchronized estiver protegendo um método trivial, seu custo relativo será alto..
O exemplo na Listagem 1 implementa uma “fábrica de IDs”, que produz uma seqüência de inteiros únicos, úteis por exemplo como chave primária artificial de objetos persistentes. O synchronized evita que invocações concorrentes a nextId() retornem o mesmo ID, pois o código “return ++counter” não é atômico. Este é decomposto em (1) leitura do valor de counter, (2) incremento deste valor, (3) atualização de counter para o novo valor, e (4) retorno do novo valor. A concorrência com outros threads seria desastrosa em qualquer ponto após (1) e antes de (4).
Fiz um benchmark e constatei que o método nextId(), sem sincronização, executa em 5,02 nanossegundos, mas a versão sincronizada executou em 19,32ns sem nenhuma concorrência e 4.270ns com dez threads. Ou seja, de 3,8 a 850 vezes mais lento! Os tempos absolutos são pequenos; mesmo 4270ns são apenas 4 milionésimos de segundo. Mas é absurdo gastar 99% do tempo de um método com sincronização. Isso pode fazer diferença se este método for invocado com muita freqüência, por muitos threads. Como não estamos satisfeitos, vamos à próxima tentativa.
A Listagem 2 usa um AtomicBoolean como um lock ultra-leve. O booleano encapsulado é true se a seção crítica (o código que não tolera concorrência, no caso “return ++counter”) está sendo executada por algum thread. Tentamos adquirir o lock com compareAndSwap(true, false). Se estava livre, este método retorna true e modifica o lock para false (ocupado) e podemos executar a seção crítica. Também não nos esquecemos de liberar o lock, usando um bloco try / finally para garantir que isso será feito. E se não tivermos conseguido o lock, pois outro thread estava na seção crítica? Daí usamos a técnica de spinning (“giro”), tentando de novo em loop até conseguir. O spinning desperdiça algum tempo de CPU para cada nova tentativa, mas como a seção crítica é extremamente pequena, isso será estatisticamente raríssimo, mesmo sob enorme concorrência.
O benchmark revela sucesso: 14,25ns (para dez threads), ainda 2,8 vezes mais lento que sem sincronização, mas 300 vezes melhor que usando synchronized. Veja o quadro “Extensões da JVM e desempenho” para detalhes desse benchmark.
A estratégia que utilizamos acima serve ao caso geral, para qualquer algoritmo que precise restringir acesso concorrente a um trecho muito pequeno de código. Mas neste problema específico da fábrica de IDs, existe uma solução ainda mais simples e eficiente.
A Listagem 3 é a implementação mais eficiente possível da fábrica de IDs em Java (ou em qualquer linguagem). Quando todos os nossos dados compartilhados cabem dentro de um único valor boolean, int, long, referência, ou referência+int, podemos usar um objeto atômico para a sincronização e também para conter estes dados. O benchmark executa em 10,92ns (novamente para dez threads): cerca de 2,1 vezes mais lento que o método não sincronizado e quase 400 vezes melhor que synchronized.
Observe também que, enquanto a versão com synchronized usou um contador volatile, nossa terceira versão não precisa fazer isso, pois os objetos atômicos usam volatile internamente se necessário, para garantir que o valor encapsulado seja lido e atualizado de forma consistente por múltiplos threads. Declarar as instâncias de objetos atômicos com um volatile adicional só acrescentaria overhead.
Locks e notificações
As operações atômicas são excepcionalmente eficientes, mas são APIs de baixo nível, mais dirigida aos implementadores de infra-estrutura do que a programadores de aplicações. Vamos subir o nível um pouco. No pacote java.util.concurrent.locks, temos um conjunto de APIs que substituem completamente os tradicionais monitores do Java, com vantagens de desempenho e funcionalidade:
Interface Lock: Sincronizador de exclusão mútua.
· lock(): Adquire o lock; se não estiver disponível bloqueia até conseguir adquiri-lo.
· tryLock(): Tenta adquirir o lock. Retorna true se conseguir, false ...