Codifiquei em Java desde o primeiro beta; mesmo naquela época, os tópicos estavam no topo da minha lista de recursos favoritos. Java foi a primeira linguagem a introduzir suporte a threads na própria linguagem; foi uma decisão controversa na época.
Na última década, todas as linguagens correram para incluir async/await, e até o Java tinha algum suporte de terceiros para isso … Este post não é sobre isso.
Eu acho que é maravilhoso e prova o poder central do Java. Não apenas como língua, mas como cultura. Uma cultura de mudanças deliberadas em vez de correr para a tendência da moda.
Neste post, quero revisitar as velhas formas de fazer threading em Java. Estou acostumado a synchronized
, wait
, notify
, etc. Mas já faz muito tempo desde que eles foram a abordagem superior para encadeamento em Java.
Eu sou parte do problema; Ainda estou acostumado com essas abordagens e acho difícil me acostumar com algumas APIs que existem desde o Java 5. É uma força do hábito. T
Existem muitas APIs excelentes para trabalhar com threads que discuto nos vídeos aqui, mas quero falar sobre bloqueios que são básicos, mas importantes.
Uma relutância que tive em deixar o sincronizado é que as alternativas não são muito melhores. A principal motivação para deixá-lo hoje é que, neste momento, o sincronizado pode desencadear a fixação de fios no Loom, o que não é o ideal.
O JDK 21 pode corrigir isso (quando o Loom for GA), mas ainda faz algum sentido deixá-lo.
A substituição direta para sincronizado é ReentrantLock. Infelizmente, o ReentrantLock tem muito poucas vantagens sobre o sincronizado, então o benefício da migração é duvidoso na melhor das hipóteses.
Na verdade, tem uma grande desvantagem; para entender isso, vejamos um exemplo. É assim que usaríamos sincronizado:
synchronized(LOCK) { // safe code } LOCK.lock(); try { // safe code } finally { LOCK.unlock(); }
A primeira desvantagem do ReentrantLock
é a verbosidade. Precisamos do bloco try, pois se ocorrer uma exceção dentro do bloco, o bloqueio permanecerá. O sincronizado lida com isso perfeitamente para nós.
Há um truque que algumas pessoas usam para envolver a fechadura com AutoClosable
, que se parece mais ou menos com isto:
public class ClosableLock implements AutoCloseable { private final ReentrantLock lock; public ClosableLock() { this.lock = new ReentrantLock(); } public ClosableLock(boolean fair) { this.lock = new ReentrantLock(fair); } @Override public void close() throws Exception { lock.unlock(); } public ClosableLock lock() { lock.lock(); return this; } public ClosableLock lockInterruptibly() throws InterruptedException { lock.lock(); return this; } public void unlock() { lock.unlock(); } }
Observe que não implemento a interface Lock, que seria ideal. Isso ocorre porque o método lock retorna a implementação de fechamento automático em vez de void
.
Depois de fazer isso, podemos escrever um código mais conciso como este:
try(LOCK.lock()) { // safe code }
Gosto da verbosidade reduzida, mas esse é um conceito problemático, pois o try-with-resource foi projetado para fins de limpeza e reutilizamos os bloqueios. Está invocando close, mas vamos invocar esse método novamente no mesmo objeto.
Acho que pode ser bom estender o try com sintaxe de recurso para oferecer suporte à interface de bloqueio. Mas até que isso aconteça, esse pode não ser um truque que valha a pena.
O maior motivo para usar ReentrantLock
é o suporte ao Loom. As outras vantagens são boas, mas nenhuma delas é um “recurso matador”.
Podemos usá-lo entre métodos em vez de em um bloco contínuo. Esta é provavelmente uma má ideia, pois você deseja minimizar a área de bloqueio e a falha pode ser um problema. Não considero essa característica uma vantagem.
Tem a opção de justiça. Isso significa que ele servirá primeiro ao primeiro thread que parou em um bloqueio. Tentei pensar em um caso de uso realista e não complicado em que isso seja importante e estou desenhando espaços em branco.
Se você estiver escrevendo um escalonador complexo com muitos encadeamentos constantemente enfileirados em um recurso, poderá criar uma situação em que um encadeamento está "faminto", pois outros encadeamentos continuam entrando. Mas essas situações provavelmente são melhor atendidas por outras opções no pacote de simultaneidade .
Talvez eu esteja perdendo alguma coisa aqui…
lockInterruptibly()
nos permite interromper um thread enquanto ele espera por um bloqueio. Este é um recurso interessante, mas, novamente, é difícil encontrar uma situação em que realmente faça a diferença.
Se você escrever um código que deve ser muito responsivo para interrupção, você precisará usar a API lockInterruptibly()
para obter esse recurso. Mas quanto tempo você gasta dentro do método lock()
em média?
Existem casos extremos em que isso provavelmente importa, mas não é algo que a maioria de nós encontrará, mesmo ao fazer código multi-thread avançado.
Uma abordagem muito melhor é o ReadWriteReentrantLock
. A maioria dos recursos segue o princípio de leituras frequentes e poucas operações de gravação. Como a leitura de uma variável é thread-safe, não há necessidade de bloqueio, a menos que estejamos no processo de gravação na variável.
Isso significa que podemos otimizar a leitura ao extremo, ao mesmo tempo em que tornamos as operações de gravação um pouco mais lentas.
Supondo que este seja o seu caso de uso, você pode criar um código muito mais rápido. Ao trabalhar com um bloqueio de leitura e gravação, temos dois bloqueios; um bloqueio de leitura como podemos ver na imagem a seguir. Ele permite que vários threads passem e é efetivamente um “gratuito para todos”.
Uma vez que precisamos escrever na variável, precisamos obter um bloqueio de gravação, como podemos ver na imagem a seguir. Tentamos solicitar o bloqueio de gravação, mas ainda há threads lendo a variável, portanto, devemos aguardar.
Depois que os threads terminarem a leitura, todas as leituras serão bloqueadas e a operação de gravação poderá ocorrer a partir de um único thread apenas, conforme visto na imagem a seguir. Assim que liberarmos o bloqueio de gravação, voltaremos à situação “grátis para todos” na primeira imagem.
Este é um padrão poderoso que podemos aproveitar para tornar as coleções muito mais rápidas. Uma lista sincronizada típica é notavelmente lenta. Ele sincroniza todas as operações, leitura ou gravação. Temos um CopyOnWriteArrayList que é rápido para leitura, mas qualquer gravação é muito lenta.
Supondo que você possa evitar o retorno de iteradores de seus métodos, você pode encapsular as operações de lista e usar essa API.
Por exemplo, no código a seguir, expomos a lista de nomes como somente leitura, mas, quando precisamos adicionar um nome, usamos o bloqueio de gravação. Isso pode superar listas synchronized
facilmente:
private final ReadWriteLock LOCK = new ReentrantReadWriteLock(); private Collection<String> listOfNames = new ArrayList<>(); public void addName(String name) { LOCK.writeLock().lock(); try { listOfNames.add(name); } finally { LOCK.writeLock().unlock(); } } public boolean isInList(String name) { LOCK.readLock().lock(); try { return listOfNames.contains(name); } finally { LOCK.readLock().unlock(); } }
A primeira coisa que precisamos entender sobre StampedLock
é que ele não é reentrante. Digamos que temos este bloco:
synchronized void methodA() { // … methodB(); // … } synchronized void methodB() { // … }
Isso vai funcionar. Já que sincronizado é reentrante. Já mantemos o bloqueio, portanto, entrar em methodB()
de methodA()
não bloqueará. Isso também funciona com ReentrantLock, assumindo que usamos o mesmo bloqueio ou o mesmo objeto sincronizado.
StampedLock
retorna um carimbo que usamos para liberar o bloqueio. Por isso, tem alguns limites. Mas ainda é muito rápido e poderoso. Ele também inclui um carimbo de leitura e gravação que podemos usar para proteger um recurso compartilhado.
Mas ao contrário do ReadWriteReentrantLock,
ele nos permite atualizar o bloqueio. Por que precisaríamos fazer isso?
Veja o método addName()
de antes… E se eu o invocar duas vezes com “Shai”?
Sim, eu poderia usar um Set… Mas para o objetivo deste exercício, digamos que precisamos de uma lista… Eu poderia escrever essa lógica com o ReadWriteReentrantLock
:
public void addName(String name) { LOCK.writeLock().lock(); try { if(!listOfNames.contains(name)) { listOfNames.add(name); } } finally { LOCK.writeLock().unlock(); } }
Isso é péssimo. Eu “paguei” por um bloqueio de gravação apenas para verificar contains()
em alguns casos (supondo que haja muitas duplicatas). Podemos chamar isInList(name)
antes de obter o bloqueio de gravação. Então faríamos:
Em ambos os casos de captura, podemos ficar na fila e pode não valer a pena o aborrecimento extra.
Com um StampedLock
, podemos atualizar o bloqueio de leitura para um bloqueio de gravação e fazer a alteração no local, se necessário, como tal:
public void addName(String name) { long stamp = LOCK.readLock(); try { if(!listOfNames.contains(name)) { long writeLock = LOCK.tryConvertToWriteLock(stamp); if(writeLock == 0) { throw new IllegalStateException(); } listOfNames.add(name); } } finally { LOCK.unlock(stamp); } }
É uma otimização poderosa para esses casos.
Eu abordo muitos assuntos semelhantes na série de vídeos acima; confira, e deixe-me saber o que você pensa.
Costumo buscar as coleções sincronizadas sem pensar duas vezes. Isso pode ser razoável às vezes, mas para a maioria, provavelmente não é o ideal. Ao gastar um pouco de tempo com primitivas relacionadas a threads, podemos melhorar significativamente nosso desempenho.
Isso é especialmente verdadeiro ao lidar com o Loom, onde a contenção subjacente é muito mais sensível. Imagine escalar as operações de leitura em 1 milhão de threads simultâneos... Nesses casos, a importância de reduzir a contenção de bloqueio é muito maior.
Você pode pensar, por que as coleções synchronized
não podem usar ReadWriteReentrantLock
ou mesmo StampedLock
?
Isso é problemático porque a área de superfície da API é tão grande que é difícil otimizá-la para um caso de uso genérico. É aí que o controle sobre os primitivos de baixo nível pode fazer a diferença entre alto rendimento e código de bloqueio.