Prefácio
Em 22/09/2022, foi lançado o JDK19. O maior destaque dessa versão é o suporte a threads virtuais, acrescentando mais um integrante de peso à família das threads leves. As threads virtuais libertam a JVM da dependência do escalonamento de threads pelo sistema operacional, permitindo que a própria JVM as escalone. Na verdade, nos primeiros tempos, a Sun chegou a implementar o escalonamento de threads pela JVM na máquina virtual do sistema operacional Solaris, mas, por questões de complexidade e manutenção, acabou retornando ao modelo de escalonamento pelo sistema operacional.
“长安归来锦衣客,昨日城南起新宅。” — De Chang'an voltou o viajante vestido de brocado; ontem, ao sul da cidade, ergueu uma nova residência. Ao relembrar esse percurso, os conceitos de multithreading deixam a cabeça girando, e há inúmeras explicações sobre o assunto na internet, mas sempre senti falta de uma visão global. Por isso, organizei sistematicamente a história da evolução do multithreading em Java, na esperança de ajudar você a dominar esse conhecimento.
O que este artigo não aborda:
1 Não explico em detalhes os princípios de implementação de certos pontos técnicos, não analiso o código-fonte nem faço diagramas;se encontrar neste artigo conceitos e tecnologias que despertem seu interesse, você pode pesquisá-los por conta própria 2 Não abordo bibliotecas e frameworks que oferecem suporte à concorrência, como Quasar, Akka, Guava etc.
Do que trata este artigo
1 Apresentar a história da evolução do multithreading no JDK 2 Explicar as funções, os princípios e o contexto de certos aspectos técnicos dessa evolução, além dos problemas que resolveram 3 Apresentar a opinião do autor sobre alguns aspectos técnicos;quem tiver opiniões diferentes está convidado a discuti-las nos comentários
Marco
Como de costume, vamos começar com uma tabela de levantamento. Ela organiza os principais conceitos relacionados a threads nas diferentes gerações do JDK. Aqui, fazendo uma analogia talvez não muito apropriada, podemos comparar a evolução da programação multithread à dos automóveis: ela passou pela era do câmbio manual (JDK1.4 e anteriores), pela era do câmbio automático (JDK5-JDK18) e pela era da direção autônoma (JDK19 em diante). Essa analogia serve apenas para mostrar ao leitor que, a partir do JDK5, é possível lidar com múltiplas threads de maneira mais confortável. A partir do JDK19, o avanço vai além do conforto: ele proporciona um salto qualitativo no desempenho de serviços com uso intensivo de E/S.
| Era | Versão | Data de publicação | Conceitos centrais |
|---|---|---|---|
| Modo manual | JDK1.0 | 1996-01-23 | Thread e Runnable |
| Modo manual | JDK1.2 | 1998-12-04 | ThreadLocal、Collections |
| Modo automático | JDK1.5/5.0 | 2004-09-30 | Definição clara do modelo de memória Java e introdução do pacote de concorrência |
| Modo automático | JDK1.6/6.0 | 2006-12-11 | Otimização de synchronized |
| Modo automático | JDK1.7/7.0 | 2011-07-28 | Framework Fork/Join |
| Modo automático | JDK1.8/8.0 | 2014-03-18 | CompletableFuture、Stream |
| Modo automático | JDK1.9/9.0 | 2014-09-08 | Melhorar o mecanismo de contenção de bloqueios |
| Modo automático | JDK10 | 2018-03-21 | Controle local de threads |
| Modo automático | JDK15 | 2020-09-15 | Desativação e descontinuação do bloqueio tendencioso |
| Direção autônoma | JDK19 | 2022-09-22 | Threads virtuais |

A era do câmbio manual
Chamo o período do JDK1.4 e das versões anteriores de era do multithreading com “câmbio manual”, também conhecida como era do multithreading nativo. As operações com threads ainda eram relativamente primitivas, sem pools de threads disponíveis. Os desenvolvedores precisavam escrever suas próprias ferramentas para evitar o desperdício de recursos causado pela criação frequente de threads e aplicar bloqueios manualmente aos recursos compartilhados. Foi também nessa época que surgiram muitos frameworks excelentes de multithreading, e o mais conhecido foi incorporado ao JDK5.0.
JDK 1.0 Thread e Runnable
A versão JDK1.0, de janeiro (1) de 1996, estabeleceu desde o início o modelo mais básico de threads do Java. Esse modelo não sofreu alterações substanciais nas correções e ajustes posteriores, podendo ser considerado um bom projeto que preservou sua continuidade. Preempção e cooperação são duas formas comuns de escalonamento de processos/threads. O sistema operacional é muito adequado para escalonar seus processos de forma preemptiva: atribui fatias de tempo a diferentes processos e pode retirar recursos de processos que não respondem por muito tempo, ou até encerrá-los à força. A abordagem cooperativa exige que os processos liberem recursos de forma consciente e voluntária. Nesse tipo de escalonamento, uma thread com execução muito longa pode deixar todas as outras threads que precisam de CPU em inanição. A máquina virtual Java hotspot usa escalonamento preemptivo; por isso, a linguagem Java adotou o escalonamento preemptivo de threads desde o início. No JDK 1.0, as principais formas de criar threads eram herdar da classe Thread ou implementar a interface Runnable. A thread era iniciada pelo método start da instância do objeto, e o código que precisava ser processado em paralelo ficava no método run. A comunicação e a cooperação entre threads usavam métodos simples e bruscos como stop/resume/suspend.

Como explicar os conceitos de stop/resume/suspend? A thread principal pode chamar diretamente os métodos de encerramento, pausa e retomada das threads filhas. Se você usava um aparelho de som portátil na infância, ele tinha três botões: parar, pausar e continuar. Imagine que você está ouvindo 3 aparelhos ao mesmo tempo: os três aparelhos são três threads filhas, e você é a thread principal, que pode controlar livremente quando esses três dispositivos param e voltam a funcionar.
Esse mecanismo tem um problema fatal: pode facilmente causar um deadlock. Isso ocorre quando a thread A bloqueia um recurso e, antes de liberá-lo, é suspensa pela thread principal (o método suspend não libera o bloqueio). Nesse momento, se a thread B quiser obter esse recurso, só poderá esperar que a thread A retome a execução (resume) e libere o recurso; caso contrário, nunca conseguirá obtê-lo, causando um deadlock.
JDK 1.2
O mecanismo rudimentar de stop/resume/suspend foi proibido nesta versão, dando lugar à coordenação entre várias threads por meio de wait/notify/sleep. Vale mencionar que, nesta versão, os objetos atômicos AtomicityXXX já haviam sido projetados, principalmente para resolver o problema de i++ não ser uma operação atômica. A inclusão de ThreadLocal e Collections ampliou as formas de usar múltiplas threads; por causa dessas duas tecnologias, chamo essa fase de era do turbo do Java.
ThreadLocal
ThreadLocal é uma solução que usa uma abordagem sem bloqueios para permitir que várias threads utilizem objetos que não são seguros para uso concorrente. Ele não resolve problemas como “aumentar ou descontar o saldo de uma conta bancária ou o estoque”; sua especialidade é executar com segurança métodos de classes que funcionam como “ferramentas”, por meio de cópias. Exemplos típicos são SimpleDateFormat e conexões com bancos de dados. Seu projeto é muito engenhoso. Imagine como você o projetaria: uma ideia simples e comum seria manter em ThreadLocal um Map global seguro para concorrência, com a thread como key e o objeto compartilhado como value. Esse projeto tem uma desvantagem: vazamento de memória, pois o Map cresce indefinidamente à medida que novas threads são adicionadas. Para resolver esse vazamento, seria preciso limpar o Map ao término de cada thread, o que exigiria reforçar a capacidade do GC; claramente, o custo não compensaria. Por isso, ThreadLocal foi projetado para que o Map pertença à própria Thread, e não a ThreadLocal. A key é a variável ThreadLocal e o value é o valor. Cada Thread coloca nele os ThreadLocal que utiliza (claro que esse projeto também traz outros problemas decorrentes, que não serão discutidos aqui; quem tiver interesse pode pesquisar por conta própria).
Collections
A classe utilitária Collections foi criada nessa versão e oferecia encapsulamentos de coleções seguras para threads, como SynchronizedList. Naquela época, em que só havia coleções seguras para threads como Hashtable, Vector e Stack, seu surgimento também marcou um avanço histórico. A ideia básica do utilitário Collections é encapsular uma coleção que não é segura para threads e torná-la segura, para que você não precise gastar muito tempo atualizando e adaptando o código existente: basta inicializar a coleção com os métodos fornecidos ao criá-la. É parecido com a tecnologia de turbocompressão dos carros, que aumenta a potência e o torque do motor sem alterar sua cilindrada. Chegou a era do turbo no Java^\_^
A era da transmissão automática
JDK 5.0
Importar o pacote de concorrência
Doug Lea, cujo nome em chinês é 道格·利, é professor universitário nos Estados Unidos e uma figura de altíssimo nível; o J.U.C é obra dele. Antes do JDK1.5, só podíamos usar synchronized para controlar o acesso concorrente ao código sincronizado. Naquela época, o desempenho de synchronized ainda não havia sido bem otimizado e era ruim; para controlar threads, também só podíamos usar os métodos wait e notify de Object. Foi então que Doug Lea apresentou ao JCP a proposta JSR-166. Antes de apresentar a JSR-166, Doug Lea já utilizava havia mais de três anos um código com funcionalidades semelhantes às do pacote J.U.C; esse código foi o protótipo do J.U.C.
J.U.C oferece várias categorias de recursos, como objetos atômicos, bloqueios e conjuntos de utilitários, pools de threads e contêineres seguros para threads. Os desenvolvedores podem usar essas capacidades com flexibilidade para construir seus produtos: tanto podem empregar ReentrantLock para criar estruturas de base quanto usar diretamente utilitários ou contêineres prontos para escrever código de negócio. Do ponto de vista histórico, em 2004, J.U.C podia indiscutivelmente ser chamado de “produto de tecnologia de ponta”. Sua contribuição para a popularização do Java foi enorme. Chegava a era do câmbio automático do Java: assim como um carro automático facilita a vida de quem dirige, J.U.C reduziu muito a barreira para que os programadores usassem múltiplas threads. Foi um produto que inaugurou uma era.
Claro, a J.U.C também tem algumas falhas:
Alto consumo de CPU:Se o CAS em espera ativa não tiver sucesso por muito tempo, isso imporá uma sobrecarga muito grande à CPU.
Solução:em alguns pontos da JUC, o número de tentativas em laço de CAS é limitado, como em SynchronousQueue de BlockingQueue.
Problema ABA:se um valor era originalmente A, passou a B e depois voltou a A, a verificação CAS concluirá que ele não mudou, embora de fato tenha mudado. Esse é o problema ABA. Na maioria dos casos, o problema ABA não afeta a correção da execução concorrente do programa.
Solução:adicionar um número de versão a cada variável e incrementá-lo em 1 a cada alteração, ou seja, A —> B —> A passa a ser 1A —> 2B —> 3A. O Java oferece AtomicStampedReference para resolver isso. AtomicStampedReference encapsula uma tupla [E,Integer] para marcar o objeto com um identificador de versão(stamp), evitando assim o problema ABA.
Só pode garantir operações atômicas sobre uma única variável compartilhada:O mecanismo CAS garante apenas a atomicidade da operação sobre uma variável, sem garantir a atomicidade de todo o bloco de código.
Solução:por exemplo, se for necessário garantir a atualização atômica conjunta de 3 variáveis, será preciso usar Synchronized. Também é possível considerar o uso de AtomicReference para encapsular várias variáveis, lidando assim com situações que envolvem múltiplas variáveis compartilhadas.
Compreender claramente o modelo de memória Java
Essa versão do JDK redefiniu com clareza o modelo de memória Java. Antes disso, entre os modelos de memória comuns estavam o modelo de consistência sequencial e o modelo happens-before. No modelo de consistência sequencial, a ordem de execução do programa é exatamente igual à ordem apresentada no código. Isso é difícil de garantir em CPUs modernas com múltiplos núcleos e otimizações na execução de instruções. Além disso, garantir a consistência sequencial limita severamente as otimizações que a JVM pode realizar no código durante a execução.
Mas a relação de precedência(Happens-before)definida pela especificação JSR 133 desta versão torna flexível a ordem de execução das instruções:
Dentro da mesma thread, seguindo a ordem de execução do código (isto é, a ordem semântica do código), uma operação anterior acontece antes de uma operação posterior Uma operação de desbloqueio de um objeto monitor acontece antes de uma operação posterior de bloqueio desse mesmo objeto monitor Uma escrita em um campo volatile acontece antes de uma leitura posterior desse campo A operação start de uma thread (a chamada ao método start() do objeto da thread) acontece antes de qualquer outra operação dessa thread Todas as operações de uma thread acontecem antes de qualquer outra thread chamar o método join() nessa thread Se a operação A acontece antes de B, e a operação B acontece antes de C, então a operação A acontece antes de C
Quanto à alocação de memória, separar a memória de trabalho de cada thread da memória principal dá à JVM ainda mais margem para otimizar a execução das instruções dentro de cada thread. As variáveis da memória principal podem ser copiadas para a memória de trabalho de uma thread e processadas de forma independente. Após a execução, os resultados podem ser gravados de volta na memória principal em algum momento: mas como garantir a consistência dos dados entre as threads? A solução dada pela JLS(Java Language Specification, Especificação da Linguagem Java)é que, por padrão, não se garante a consistência dos dados a qualquer momento, mas ela pode ser obtida com o uso das palavras-chave synchronized, volatile e final, cujas semânticas foram reforçadas.
Otimização de synchronized no JDK 6.0
A palavra-chave synchronized, como o “filho primogênito da República”, teve seu protagonismo ofuscado pelo ReentrantLock na versão 5.0. Nesta versão, era preciso recuperar terreno, por isso o JDK 6.0 trouxe algumas otimizações de locks, como espera ativa, eliminação de locks, agrupamento de locks, locks leves e locks tendenciosos. Essa otimização foi uma interpretação do conceito de “gestão minuciosa”. Antes dela, um objeto bloqueado com synchronized só tinha dois estados: sem lock e com lock (lock pesado). Após a otimização, passaram a existir 4 estados de lock, em ordem crescente de nível: sem lock, lock tendencioso, lock leve e lock pesado. Esses estados são elevados gradualmente conforme a disputa pelo lock, mas não podem regredir, com o objetivo de melhorar a eficiência da aquisição e da liberação de locks (na opinião deste autor, na verdade isso era complexo demais e os desenvolvedores da JVM recuaram diante da dificuldade).
Essa otimização permitiu ao synchronized recuperar o prestígio: a partir daí, ninguém mais poderia dizer que seu desempenho era inferior ao de ReentrantLock. Mas a melhor parte ainda estava por vir: o bloqueio tendencioso foi descontinuado no JDK 15(─.─||). Na minha opinião, synchronized sai perdendo por ser apenas uma palavra-chave: a JVM é responsável pelas operações subjacentes e precisa “adivinhar” qual é a forma mais adequada de a aplicação adquirir o bloqueio. ReentrantLock é diferente: entrega essa decisão diretamente ao programador. Se você quer equidade, usa um bloqueio justo; se quer ausência de equidade, usa um bloqueio não justo. No nível do projeto, isso pode ser considerado uma forma de poupar trabalho, mas também oferece flexibilidade.
Framework Fork/Join do JDK 7.0
O surgimento do Fork/Join também trouxe um produto relativamente avançado. Seu principal diferencial é permitir a decomposição recursiva de tarefas e a combinação dos resultados de cada uma. Mas é uma tecnologia ao mesmo tempo familiar e desconhecida: familiar porque é aplicada em vários lugares, como CompletableFuture e Stream, que abordaremos a seguir no JDK8.0; desconhecida porque parece que raramente a usamos no desenvolvimento de aplicações de negócio.
Há até quem a considere pouco útil. Na minha opinião, se você trabalha com desenvolvimento voltado a necessidades de negócio, ela é pouco útil, porque praticamente não será usada: para grandes volumes de dados, existem as ferramentas de data warehouse; em outros cenários, é possível usar pools de threads. Se você desenvolve frameworks de middleware, ela tem utilidade e talvez venha a ser usada. Poucas pessoas na internet em chinês questionam essa tecnologia, mas no exterior já há discussões a respeito. Quem tiver interesse pode acessar diretamente para consultar O framework Fork-Join do Java está com defeito?
JDK 8.0
O lançamento dessa versão marcou uma época para o Java, a ponto de ela representar mais da metade dos programas Java em execução no mundo atualmente. Porém, as atualizações relacionadas a múltiplas threads não foram tão revolucionárias quanto as do JDK5.0. Além da adição de alguns objetos atômicos, os maiores destaques dessa versão foram as duas atualizações a seguir.
CompletableFuture
Há muitas apresentações sobre CompletableFuture na internet, e a maioria explica seu funcionamento e como usá-lo. Mas uma questão sempre me intrigou:por que o CompletableFuture surgiu se já existem tantas ferramentas de pools de threads? Que dificuldade ele resolve? Qual é, afinal, seu principal diferencial? Acredito que, se você já refletiu sobre isso, também tenha feito essa pergunta. Tudo bem, já encontrei a resposta para você.
Conclusão: o principal diferencial do CompletableFuture é Orquestração de tarefas。CompletableFuture herda as características da interface Future e oferece recursos como a execução concorrente de tarefas, que podem ser substituídos por outras soluções. Mas sua capacidade de orquestrar tarefas é insubstituível: suas APIs principais, incluindo a construção de cadeias de tarefas e a combinação de resultados, foram projetadas para essa orquestração. Assim, o principal motivo para o JDK introduzir esse framework nessa versão foi atender à necessidade cada vez mais premente de orquestração de tarefas no desenvolvimento de aplicações de negócio.
Por fim, mais uma observação: a implementação interna de CompletableFuture usa o framework Fork/Join.
Stream
O livro 《架构整洁之道》 (Arquitetura Limpa) menciona três paradigmas de programação: programação estruturada (procedural), programação orientada a objetos e programação funcional. Stream é uma manifestação da programação funcional na linguagem Java. Na minha opinião, dominar Stream é uma etapa obrigatória para um programador júnior avançar ao nível pleno. No primeiro contato, Stream certamente causa bastante estranhamento, mas, depois de se familiarizar, você descobrirá uma nova maneira de pensar a programação. Como desenvolvedores, frequentemente confundimos três conceitos: programação funcional, Stream e expressões Lambda, achando que os três são a mesma coisa. A seguir, apresento meu entendimento:
•A programação funcional é uma abordagem de programação que encontra aplicações em diversas linguagens
•Stream é um novo recurso do JDK8.0, que também pode ser entendido como a criação de um novo conceito, cujo objetivo é se alinhar à ideia de programação funcional, permitindo implementá-la nas classes de coleções por meio de Stream
•Uma expressão lambda(lambda expression)é uma função anônima que permite expressar a programação funcional de maneira mais concisa e eficiente
Depois de tudo isso, qual é a relação entre Stream e multithreading? A implementação interna dos métodos de paralelismo de Stream usa o framework Fork/Join. O livro 《Effective Java》 traz a recomendação “use o paralelismo de Stream com cautela”, pois todas as operações paralelas são executadas em um pool comum de Fork/Join, e uma execução anormal de um pipeline pode prejudicar o desempenho de outras partes sem relação com ele.
JDK 9.0
Melhorar o mecanismo de contenção de bloqueios
A contenção de locks limita o desempenho de muitos aplicativos Java com múltiplas threads. O novo mecanismo de contenção de locks melhora o desempenho dos monitores de objetos Java e foi validado por diversos benchmarks (como o Volano), que permitem estimar a vazão máxima da JVM. Na prática, o novo mecanismo obteve excelentes resultados em 22 benchmarks diferentes. Se esse novo mecanismo puder ser aplicado no Java 9, o desempenho dos aplicativos melhorará muito. Uma explicação simples é que, quando várias threads disputam um lock, antes da otimização: todas as threads que chegavam depois seguiam o mesmo procedimento padrão para aguardar o lock. Depois da otimização: quando a JVM identifica situações que podem ser otimizadas, permite diretamente que as threads que chegam depois disputem a aquisição do lock por uma espécie de “acesso VIP”.
Para uma explicação detalhada, consulte: Explicando a disputa por locks – uma abordagem voltada ao desempenho
Fluxos reativos
Reactive Streams é um padrão para processar fluxos assíncronos de dados com contrapressão não bloqueante. Ele fornece um conjunto mínimo de interfaces, métodos e protocolos para descrever as operações e entidades necessárias.
O que é contrapressão não bloqueante? Contrapressão é uma forma abreviada de se referir a back pressure. Em termos simples, o produtor envia dados ao consumidor e, quando este não consegue mais processá-los, avisa ao produtor, que então reduz a taxa de produção. A implementação mais simples desse mecanismo usa bloqueio, retornando diretamente informações de pressão no momento do envio. Uma implementação não bloqueante aumenta a complexidade do projeto, mas também melhora o desempenho. PS: Acho que a tradução chinesa “背压” não é boa; não dá para entender o sentido apenas pelos caracteres. Será que “反压”, que sugere uma pressão em sentido contrário, seria melhor? ^\_^
Para evitar que o consumidor entre em colapso devido à enorme pressão sobre seus recursos, é necessário controlar a velocidade do fluxo de dados, isto é, aplicar controle de fluxo, impedindo que um fluxo rápido sobrecarregue o destino. Por isso, é necessária a contrapressão: produtor e consumidor precisam interoperar por meio de um mecanismo de contrapressão. A implementação desse mecanismo deve ser assíncrona e não bloqueante. Se for síncrona e bloqueante, o produtor terá de esperar enquanto o consumidor processa os dados, causando problemas de desempenho.
Reactive Streams define um conjunto de entidades, interfaces e métodos de interoperabilidade, estabelecendo um padrão para implementar contrapressão não bloqueante. Terceiros seguem esse padrão para implementar soluções concretas, entre as quais são comuns Reactor, RxJava, Akka Streams e Ratpack.
JDK 10 Controle local de threads
Safepoint e suas limitações:
Safepoint é um mecanismo da JVM Hotspot que permite interromper todas as aplicações. Para realizar certas tarefas de baixo nível, a JVM precisa fazer um Stop The World, suspendendo todas as threads da aplicação. Mas ela não pode simplesmente interrompê-las de forma abrupta: envia um sinal de instrução às threads, informando que devem parar. Quando uma thread da aplicação chega a um Safepoint, ela obedece à instrução e responde. É por isso que se chama Safepoint. O termo safe enfatiza que a JVM precisa realizar certas operações globais de forma segura, daí o uso de safe no nome desse ponto.
As operações que exigem segurança global incluem as seguintes: 1、pausas para coleta de lixo 2、desotimização de código (Code deoptimization). 3、limpeza do cache de código. 4、redefinição de arquivos de classe (Class redefinition, por exemplo, atualização a quente ou instrumentação). 5、revogação de bloqueios tendenciosos (Biased lock revocation). 6、diversas operações de depuração (por exemplo: detecção de deadlocks ou despejo de rastreamentos de pilha, entre outras).
No entanto, fazer com que todas as threads parem no safepoint mais próximo já leva bastante tempo. Além disso, fazer todas as threads pararem não parece uma medida excessivamente precipitada e autoritária? Por isso, o Java10 introduziu uma forma de evitar parar todas as threads: o controle local de threads (Thread Local Handshake).
Por exemplo, os seguintes cenários podem ser resolvidos sem parar todas as threads: 1、Revogação de bloqueios tendenciosos. Para revogar um bloqueio tendencioso, basta interromper uma única thread, sem precisar parar todas elas. 2、Redução do impacto geral na latência da VM causado por diferentes tipos de consultas de diagnóstico; por exemplo, obter os rastreamentos de pilha de todas as threads em uma VM com muitas threads Java pode ser uma operação lenta. 3、Amostragem mais segura dos rastreamentos de pilha, reduzindo a dependência de sinais (signals). 4、Eliminação de algumas barreiras de memória por meio de handshakes com as threads Java, usando a chamada técnica de sincronização assimétrica de Dekker. Por exemplo, o “código de marcação condicional de cartões” (conditional card mark code) usado em G1 e CMS deixará de precisar dessas “barreiras de memória”. Assim, a “barreira de escrita (write barrier)” emitida pelo G1 poderá ser otimizada, e as ramificações que tentam evitar as “barreiras de memória” também poderão ser removidas.
O JDK 15 desativa e descontinua o bloqueio tendencioso
Por que descontinuar o bloqueio tendencioso? Os ganhos de desempenho que ele proporcionava no passado já não parecem tão expressivos hoje. As aplicações que se beneficiam do bloqueio tendencioso costumam usar as primeiras APIs de coleções do Java(JDK 1.1), que fazem sincronização a cada acesso(Hashtable e Vector). O JDK 1.2 introduziu coleções sem sincronização para cenários com uma única thread(HashMap e ArrayList), e o JDK 1.5 trouxe estruturas de dados concorrentes com melhor desempenho para cenários com múltiplas threads. Isso significa que, se o código for atualizado para usar classes mais recentes, as aplicações que se beneficiavam do bloqueio tendencioso devido à sincronização desnecessária poderão apresentar uma grande melhoria de desempenho. Além disso, aplicações construídas em torno de filas de pools de threads e threads de trabalho geralmente apresentam melhor desempenho quando o bloqueio tendencioso está desativado.
A seguir, uma implementação usando as APIs de Hashtable e Vector: java.lang.Classloader usa Vector java.util.Properties extends Hashtable java.security.Provider extends Properties java.net.URL usa Hashtable java.net.URConnection usa Hashtable java.util.ZipOutputStream usa Vector javax.management.timer.TimerMBean tem Vector na interface
A era da direção autônoma
As threads virtuais levaram Java à era da direção autônoma. Muitas linguagens possuem tecnologias semelhantes às “threads virtuais”, como Go, C#, Erlang e Lua, que as chamam de “corrotinas”. Desta vez, Java não acrescentou nenhuma palavra-chave e nem sequer introduziu conceitos novos. As threads virtuais são muito mais fáceis de entender do que goroutines e corrotinas: pelo nome, já dá para ter uma ideia do que fazem.
Threads virtuais do JDK 19
No Java tradicional, as threads correspondem às threads do sistema operacional na proporção 1:1. Criar threads e alternar entre elas tem um custo alto, e as limitações do sistema operacional permitem criar apenas uma quantidade limitada. Quando há muita concorrência, não é possível criar uma thread para cada requisição. Usar um pool de threads pode amenizar o problema, pois reduz o custo de criação de threads, mas também não permite aumentar sua quantidade. Se houver 2000 requisições simultâneas e o pool tiver apenas 1000 threads, só será possível processar 1000 requisições ao mesmo tempo. As outras 1000 não poderão ser processadas naquele momento:podem ser rejeitadas ou ficar aguardando até que uma thread seja liberada.
Antes das threads virtuais, a solução era adotar um estilo assíncrono. Muitos frameworks já implementaram programação concorrente nesse estilo (como o Reactor do Spring5), alcançando maior disponibilidade por meio do compartilhamento de threads. O princípio é reduzir as trocas de contexto e o consumo de recursos compartilhando threads, além de evitar bloqueios: as threads são usadas apenas enquanto o programa executa e não ficam ocupadas quando ele precisa esperar. O estilo assíncrono realmente traz várias melhorias, mas também tem desvantagens. A maioria dos frameworks assíncronos usa uma sintaxe encadeada, dividindo o programa em muitas etapas, cada uma das quais pode executar em uma thread diferente. Você já não pode usar APIs conhecidas de programação concorrente, como ThreadLocal, pois isso pode causar erros. O estilo de programação também muda bastante, tornando-se muito mais complexo que o modelo tradicional, com um alto custo de aprendizado. Além disso, pode ser necessário adaptar muitos módulos existentes do projeto ao modelo assíncrono.
O princípio de implementação das threads virtuais é semelhante ao de alguns frameworks assíncronos: também há compartilhamento de threads e, naturalmente, não é necessário criar um pool. Ao utilizá-las, você pode considerar que há threads virtuais em abundância ilimitada: crie quantas quiser, sem precisar se preocupar com problemas. Além disso, as threads virtuais permitem depuração e são compatíveis com as ferramentas de monitoramento de Java, o que é muito importante. As threads virtuais reduzem bastante o consumo de memória do programa. Todas as aplicações com uso intensivo de E/S, como servidores web, podem aumentar muito a taxa de transferência de E/S nas mesmas condições de hardware. Antes, 1 GB de memória permitia atender a 1000 acessos simultâneos; com threads virtuais, segundo a afirmação oficial, é possível lidar facilmente com 100 dezenas de milhares de acessos simultâneos. A capacidade em um cenário de negócio específico ainda precisa ser verificada por testes de carga, mas, mesmo com uma estimativa mais conservadora, 10 dezenas de milhares devem ser facilmente alcançáveis, sem que você precise arcar com qualquer custo para isso; talvez nem precise alterar o código. Isso porque as threads virtuais permitem manter o estilo tradicional de programação, ou seja, uma thread por requisição. Basta usar threads virtuais como se usam threads, com pouquíssimas alterações no programa. As threads virtuais também não introduziram sintaxe nova, de modo que o custo de aprendizado e migração pode ser considerado extremamente baixo.
Vale mencionar que os threads virtuais ainda usam o framework Fork/Join em sua implementação subjacente.