Threads
Threads
permitem que várias atividades aconteçam
simultaneamente em um mesmo programa. Como
a programação multitarefa
é
mais complicada, caso exista uma classe de biblioteca pronta que poupe
o
trabalho da programação em baixo nível,
utilize-a. Porém, mesmo usando essas bibliotecas onde
aplicável, ainda será necessário trabalhar com
código para multitarefa de vez em
quando.
Item 48: Sincronize o acesso a dados mutáveis
compartilhados
A palavra synchronized
garante que apenas
uma thread irá executar certa parte do código de cada
vez. Isso evita que um objeto seja observado em um estado
inconsistente, enquanto ainda está sendo modificado por outra
thread. Assim, o objeto é criado em um estado consistente e
travado pelos métodos que o acessam. Esses métodos
observam o estado e opcionalmente causam uma transição
de estado, transformando o objeto de um estado consistente para
outro. O uso apropriado de sincronização garante que
nenhum método irá observar o objeto em estado
inconsistente.
Além disso, a sincronização
garante que um objeto progrida de um estado consistente para outro
através de uma seqüência ordenada de
transição de
estados que parecem executar seqüencialmente. Cada thread
entrando em um bloco sincronizado vê os efeitos de todas as
transições de estado anteriores controladas pela mesma
trava.
A linguagem garante que ler ou escrever em uma variável
(exceto long ou double) é um processo atômico. Portanto,
o valor retornado ao ler uma variável é, com certeza, o
valor exato que foi gravado por alguma thread, ainda que threads
múltiplas
modifiquem a variável ao mesmo tempo sem
sincronização.
Porém, mesmo assim a sincronização é
importante para garantir uma comunicação confiável
entre as threads e conseguir a exclusão mútua. Veja
como exemplo o seguinte código:
private
static int nextSerialNumber = 0;
public
static int generateSerialNumber() {
return nextSerialNumber++;
}
O
método acima não é confiável sem a
sincronização, pois o operador de incremento (++)
lê e escreve no campo nextSerialNumber,
portanto não é atômico. As operações
de ler e escrever são independentes, executadas em
seqüência.
Múltiplas threads simultâneas podem observar o campo com
o mesmo valor e portanto retornar o mesmo número serial.
Além
disso, é possível que uma thread chame o método
generateSerialNumber
repetidamente,
obtendo uma seqüência de números seriais de zero
até n, e depois disso uma outra thread chame o
generateSerialNumber e
obtenha um número serial zero novamente, pois sem a
sincronização a segunda thread pode não ver as
atualizações feitas pela anterior devido ao modelo de
memória do Java. Para consertar o código basta
adicionar o modificador synchronized
na
declaração
do método.
Para finalizar uma thread é recomendado
que ela verifique regularmente uma variável (normalmente um booleano
ou uma referência a um objeto) cujo valor indique quando ela
deve parar. Apesar da leitura da variável ser atômica,
também
é necessária a sincronização neste caso,
pois caso contrário não é garantido quando a
thread irá ver a mudança no
valor da variável causada por outra thread. Assim, é
utilizado um
método sincronizado para mudar o valor da variável e
outro para
verificar este valor, que pode retornar um booleano
indicando se a thread deve ser finalizada. Note que neste caso a
sincronização é usada apenas por seus efeitos na
comunicação, não para
a exclusão mútua, já que as ações
de cada método são atômicas. O custo dessa
sincronização é bem baixo, mas para um
código
mais limpo e uma pequena melhora de performance a
sincronização
pode ser omitida declarando o campo como volatile,
que garante que qualquer thread que leia o campo verá o valor
mais recente.
No caso de não sincronizar corretamente o
acesso a dados mutáveis o problema é ainda maior.
Considere a técnica de double-check para lazy
initialization:
private static
Foo foo = null;
public static Foo
getFoo() {
if
(foo == null) {
synchronized (Foo.class) {
if (foo == null)
foo =
new Foo();
}
}
return
foo;
}
A
idéia desta técnica é evitar o custo da
sincronização no caso mais comum, que é acessar
o campo (foo)
depois dele ser inicializado. A sincronização é
usada apenas para evitar que múltiplas threads
inicializem o campo. Isso garante que a inicialização
ocorra no máximo uma vez e que todas as threads chamando o
getFoo irão
obter o valor correto da referência ao objeto. Porém, a
referência ao objeto pode não funcionar corretamente. Se
uma thread lê a referência sem sincronização
e então chama um método no objeto referenciado, o
método pode observar o objeto em um estado intermediário
da inicialização, o que pode causar sérios
problemas. Isso pode acontecer porque apesar da referência
só
ser "publicada" no campo foo
após o objeto
ser inteiramente construído, sem a sincronização
não é garantido que a thread verá todos os dados
que foram armazenados na memória antes da
publicação
da referência do objeto. Em geral, ler a referência de um
objeto não garante que a thread irá ver os valores mais
recentes dos dados que constituem internamente o objeto referenciado,
e portanto a técnica de double-check não
funciona.
Para corrigir este problema há três
alternativas: dispensar a lazy initialization instanciando o
objeto diretamente, sincronizar o método getFoo
ou utilizar a
técnica initialize-on-demand holder class, que é
apropriada quando um campo estático gasta muitos recursos para
ser inicializado e pode não ser necessário, mas
será
usado intensivamente se for necessário. A técnica
é
mostrada abaixo:
private static
class FooHolder {
static
final Foo foo = new Foo();
}
public
static Foo getFoo() { return FooHolder.foo; }
Esta
técnica explora o fato de que uma classe não é
inicializada até que seja usada. Quando o método getFoo
é chamado
pela primeira vez, ele lê o campo FooHolder.foo,
fazendo com que a classe FooHolder seja
inicializada. A beleza desta técnica é que o
método
getFoo não
é sincronizado e executa apenas o acesso a um campo, não
aumentando o seu custo. O único inconveniente é que
esta técnica funciona apenas para campos estáticos,
não
para campos de instância.
Item 49: Evite uso
excessivo de sincronização
Este item trata do
problema oposto ao anterior. Dependendo da situação,
sincronização excessiva pode causar redução
de performance, deadlock ou até mesmo comportamento
não determinístico.
Para evitar risco
de deadlock, nunca ceda controle ao cliente dentro de um método
ou bloco sincronizado. Em outras palavras, dentro de uma região
sincronizada, não chame métodos public ou protected que
foram feitos para serem sobrescritos, pois a classe que contém
a região sincronizada não tem conhecimento de sua
implementação e esta pode causar deadlock.
Portanto,
coloque a chamada a métodos externos fora da região
sincronizada. Essa técnica é chamada de open call.
Além de prevenir deadlocks, open
calls propiciam um aumento na simultaneidade.
Assim o método pode rodar por um período longo durante
o qual outras threads não terão acesso negado a objetos
compartilhados com esse método.
Como regra geral, faça
o mínimo possível dentro de regiões
sincronizadas. Outro problema grave que pode acontecer é o
método externo chamar de volta a região sincronizada,
na mesma thread, quando seus dados internos estão em estado
inconsistente. Como o mecanismo de travas é recursivo, isso
não causa deadlock, e como a thread já possui a trava
ela vai conseguir obter a trava uma segunda vez apesar de já
existir uma operação não relacionada em
progresso nos dados protegidos dentro do bloco sincronizado. Dessa
forma, o mecanismo de trava torna-se inútil.
Além
dos problemas na confiabilidade do código, o uso excessivo de
sincronização
pode diminuir a performance. De forma geral não é uma
redução muito significativa e cai nas "pequenas
otimizações" que devem ser evitadas segundo o
Knuth (item 37), mas alguns casos devem ser analisados.
Se você
estiver escrevendo uma abstração de baixo nível,
que normalmente será usada por uma única thread ou como
um componente em um objeto sincronizado maior, não costuma ser
necessário sincronizar a classe internamente. Qualquer que
seja sua decisão, é importante que você documente
suas propriedades em relação à segurança de
threads.
Mas se você estiver escrevendo uma classe que será
muito usada em circunstâncias que
requerem sincronização e também em
circunstâncias em que ela não
é necessária, uma abordagem razoável é
prover os dois tipos, sincronizada (thread-safe) e não
sincronizada (thread-compatible). Uma forma de fazer isso
é
criar uma classe wrapper (item 14) que implementa uma
interface que descreve a classe e realiza a sincronização
apropriada antes de realizar a chamada do método no objeto.
Outra forma mais simples, que pode ser usada em classes que não
serão estendidas ou reimplementadas, é prover uma
classe não sincronizada e uma subclasse consistindo apenas em
métodos sincronizados que chamam seus equivalentes na
superclasse.
Uma boa razão para sincronizar uma classe
internamente é quando ela será muito usada por threads
simultâneas e é possível conseguir uma maior
simultaneidade através de um ajuste fino na
sincronização.
Assim, ao invés de ter que sincronizar todo o objeto, apenas
algumas partes serão sincronizadas internamente. Além
disso, se uma classe ou método estático depende de um
campo estático mutável é necessário fazer
o sincronismo interno, pois pode não ser possível para
o cliente realizar o sincronismo externamente porque não
é
garantido que os outros clientes farão o mesmo.
Item
50: Nunca chame o método wait
fora de
um loop
O
método Object.wait
é
usado para fazer a thread esperar determinada
condição.
Ele precisa ser chamado dentro de uma região sincronizada que
trava o objeto em que é chamado. Esta é a técnica
padrão para usar o método wait:
synchronized
(obj) {
while
(<condição não atendida>)
obj.wait();
// executa ação
apropriada à condição
}
Sempre utilize-a para chamar o método wait.
O loop serve para testar a condição antes e depois do
wait.
Testar a condição antes de esperar e pular a espera se
a condição já for atendida é
necessário
para garantir a vida da thread. Se a condição já
tiver sido atendida e o método notify
já tiver sido
chamado antes do wait não
há garantias de que a thread volte a ser executada. Testar a
condição depois de esperar e esperar novamente se a
condição não for atendida é importante
para garantir a segurança, já que se a thread continuar
sem que a condição tenha sido atendida ela pode
destruir o estado protegido pela trava.
Outra questão
relacionada é o uso de notify ou
notifyAll.
De forma geral, use sempre o notifyAll.
Este é o conselho mais conservador considerando que todas as
chamadas ao wait estão
dentro de loops, pois garante que as threads que precisam ser
reativadas sempre serão reativadas. Dessa forma também
serão reativadas outras
threads, mas isso não influi no resultado do
programa, já que será testada a condição
antes de continuar qualquer execução.
Para otimizar,
você pode decidir usar o notify
ao invés do
notifyAll se
todas as threads estão esperando a mesma condição
e apenas uma thread de cada vez pode se beneficiar da
condição
se tornar verdadeira. Porém, mesmo assim pode ser recomendado
utilizar o notifyAll,
pois isto protege o código de uma chamada indevida ao wait
por outra thread
não
relacionada, o que é particularmente importante se o objeto
for público.
Por outro lado, ainda que o notifyAll
garanta que o
código
sempre executará corretamente, ele pode prejudicar a
performance. Isso ocorre especialmente em certas estruturas de dados
em que algumas threads possuem um status especial e as outras precisam
esperar, o que torna quadrático o número de threads
sendo tiradas do modo de espera. Se apenas algumas threads são
elegíveis ao status especial, você deve usar o pattern
Specific
Notification.
Item 51: Nunca dependa do agendador de
threads
Quando múltiplas threads são
executáveis, o agendador de threads determina qual irá
rodar e por quanto tempo. A política que será utilizada
para essa decisão varia muito de acordo com a
implementação
da JVM, portanto, para que o programa seja portável, é
importante que ele não dependa do agendador de threads para
executar corretamente ou ter uma boa performance. Para isso, uma
aplicação multitarefa deve
ter o mínimo de threads executáveis em qualquer
momento.
A principal forma de atingir esse objetivo é
possuir threads que executam uma quantidade de trabalho pequena e
então esperam por alguma condição (wait)
ou pela passagem de certo intervalo de tempo (sleep).
Deve ser evitado o busy-wait, em que a thread fica checando
repetidamente certa estrutura de dados enquanto espera algo
acontecer, pois isso pode aumentar muito a carga no
processador.
Quando encontrar um programa que funciona mal porque
algumas threads não recebem tempo de CPU suficiente em
relação
a outras, resista à tentação de consertar o
programa incluindo chamadas ao Thread.yield.
Isso pode funcionar, mas tornará o programa
não-portável
do ponto de vista de performance. Outra técnica similar e
ainda menos portável é o ajuste de prioridade das
threads. Uma melhor abordagem é reestruturar o programa para
reduzir o número de threads executáveis ao mesmo
tempo.
O único uso válido para Thread.yield é
para aumentar artificialmente a simultaneidade durante os testes,
ajudando a explorar uma maior parte do programa.
Item 52:
Documente a segurança em relação a threads
O
comportamento de uma classe quando seus métodos estão
sujeitos a uso simultâneo é uma parte importante do
contrato que ela estabelece com seus clientes.
Para permitir seu
uso seguro de forma multitarefa, a classe
deve documentar claramente o nível de segurança que ela
suporta, de acordo com a lista a seguir:
- immutable:
instâncias desta classe parecem constantes para os clientes.
Não é necessária sincronização
externa.
- thread-safe: as
instâncias são
mutáveis, mas todos os métodos possuem
sincronização
interna suficiente. Chamadas simultâneas parecerão ser
executadas serialmente, em uma ordem consistente.
- conditionally
thread-safe: similar a anterior, porém alguns métodos
precisam ser chamados em seqüência e sem interferência
de outras threads. O cliente precisa
obter a trava apropriada durante a execução da
seqüência.
- thread-compatible:
instâncias
dessa classe podem ser usadas seguramente de forma simultânea
se cada chamada de método (ou seqüência) estiver
dentro de uma região sincronizada.
- thread-hostile:
o uso dessa classe por múltiplas threads não
é
seguro, mesmo que todos os métodos sejam sincronizados
externamente. Normalmente isso ocorre quando os métodos
modificam dados estáticos que afetam outras threads.
Item
53: Evite grupos de threads
Junto com threads, travas e
monitores, uma abstração básica oferecida
pelo sistema de threads é a de grupos de threads. Eles servem
para aplicar primitivos de Thread para
várias threads ao mesmo tempo. A maioria desses primitivos
foi depreciada e os restantes são pouco usados, portanto
grupos de threads não fornecem muita funcionalidade útil.
A API ThreadGroup é
fraca em termos de segurança de threads e possui diversas
falhas. Como ela está
obsoleta, não há previsões de que seja melhorada:
ela foi um experimento mal sucedido que pode ser ignorado. Se
você precisar escrever uma classe que lida com grupos
lógicos
de threads, armazene as referências em uma array ou collection.
O único caso que justifica o uso de ThreadGroup
é o
método ThreadGroup.uncaughtException,
que é chamado automaticamente quando uma thread do grupo
lança
uma exceção que não é tratada. O
comportamento padrão mostra a stack trace no standard error
stream, mas você pode sobrescrever essa
implementação.
Bibliografia:
Bloch, Joshua. Effective Java.
Resumo por: Vanessa Sabino