Thumbnail image

Descubra os Benefícios das Virtual Threads no Java

12/10/2024 12-minute read

Entenda como as Virtual Threads no Java revolucionam a concorrência e paralelismo, otimizando o desempenho de aplicações modernas.

Acredito que muitos desenvolvedores, assim como eu, após a faculdade, já se viram obrigados a voltar a estudar assuntos, que são parte das bases fundamentais no desenvolvimento de software. Seja para entrevistas de emprego, seja para melhorar o desempenho em suas funções, ou mesmo para relembrar assuntos tão importantes, que muitas vezes na rotina do dia a dia, acabamos por esquecer alguns detalhes importantes presentes nessas bases fundamentais.

E muitas vezes, uma novidade no mundo de uma linguagem, nesse caso as Virtuais threads do mundo java, nos fazem revisitar esses temas. Em razão disso, resolvi escrever esse artigo, com base na minha experiência própria de estudos, ao revisitar esses temas fundamentais, tais como:

  • Concorrência
  • Paralelismo
  • Processos
  • Threads
  • E por fim, começar a mergulhar no mundo das Virtuais Threads, presentes no java a partir da versão 21.

Esses assuntos são extensos e densos, por isso, o artigo é um breve resumo, mas se você que estiver lendo, quiser mergulhar mais a fundo, deixarei ao final do artigo, fontes e sugestões para que possa aprender mais sobre todos os assuntos mencionados aqui.

A maior parte deste artigo é baseada na palestra do grande Java Champion Elder Moraes, e uma boa parte também em um artigo do excelente Hugo Marques da Netflix.

Concorrência e Paralelismo

Simplificando as definições, chamamos de Concorrência a capacidade do nosso sistema operacional em lidar com múltiplas tarefas ao mesmo tempo, “concorrendo” pelos recursos da máquina. Isso não significa que essas tarefas estão sendo executadas simultaneamente, mas sim que o Sistema Operacional pode alternar rapidamente entre as tarefas, fazendo parecer que estão sendo executadas ao mesmo tempo.

Exemplo de Concorrência: Imagine um sistema operacional que está rodando um navegador web, um editor de texto e um reprodutor de música ao mesmo tempo. O sistema alterna rapidamente entre essas aplicações, permitindo que você ouça música enquanto digita um documento e navega na web, sem que essas tarefas sejam executadas simultaneamente.

Em contrapartida, o Paralelismo trata de executar múltiplas tarefas ao mesmo tempo. Isso requer múltiplos núcleos de CPU, onde cada núcleo pode executar uma tarefa diferente simultaneamente. Ou dividir uma tarefa em tarefas menores que utilizam múltiplos recursos disponíveis.

Exemplo de Paralelismo: Suponha que você está realizando um processamento de imagens onde cada imagem é processada por um núcleo diferente da CPU. Se você tiver quatro núcleos, quatro imagens podem ser processadas ao mesmo tempo, cada uma em seu próprio núcleo, aumentando a eficiência do processamento.

  • Medida de performance: Algumas literaturas medem a performance de concorrência pelo Throughput (tarefa/unidade de tempo), já no paralelismo medimos pela latência (tempo).

Processos e Threads

Processos são instâncias de programas em execução. Cada processo opera de forma independente, com seu próprio espaço de memória e recursos alocados pelo sistema operacional. Como os processos são isolados uns dos outros, eles não podem acessar diretamente a memória uns dos outros.

Já as Threads, são a menor unidade de execução dentro de um processo. Várias threads podem existir dentro de um único processo, compartilhando a mesma memória e recursos. Esse ambiente compartilhado permite que as threads se comuniquem mais facilmente em comparação com processos, mas também requer um gerenciamento cuidadoso para evitar problemas como “race conditions” e “deadlocks”.

Diferenças entre Processos e Threads

  • Isolamento: Processos são isolados uns dos outros, enquanto threads dentro do mesmo processo compartilham memória e recursos.

  • Gerenciamento de Recursos: Criar e gerenciar processos é mais custoso em termos de recursos do que gerenciar threads, devido à necessidade de espaços de memória e recursos separados.

  • Comunicação: As threads podem se comunicar mais facilmente dentro do mesmo processo, enquanto processos requerem mecanismos de comunicação entre processos (IPC).

Se sabemos que as threads compartilham memória e recursos, sabemos que elas concorrem por esses recursos. Logo no modelo tradicional de Threads (Platform Threads), também chamado de thread-per-request, podemos usar a “Little’s Law” para medir essa performance de concorrência.

  • Que seria basicamente: Concorrência = Threads x Tempo/Thread.

Onde a Concorrência é igual ao Throughput.

Então podemos concluir duas coisas:

  • Em threads de Sistemas operacionais, quanto maior o número de threads possíveis de serem executadas, maior será o throughput.
  • Quanto mais poderoso for o meu hardware, ou hardware do seu ambiente, maior será o número de threads possível de serem executadas.

Logo, em um exemplo hipotético, o número de concorrência seria o número máximo de threads possíveis.

Threads no Java

Quando falamos de Threads no java, estamos falando do modelo tradicional conhecido como Platform Threads ou Threads de SO, utilizado no Java a mais de 20 anos. Utilizamos o pacote java.lang.Thread, que nada mais é, que um wrapper em cima das Threads de Sistemas Operacionais. Embora criar e gerenciar threads no Java seja algo relativamente simples, lidar com um grande número de threads pode se tornar trabalhoso e custoso em termos de recursos.

Exemplo de código usando Thread Tradicional em Java

    public class TraditionalThreadExample {
    
        public static void main(String[] args) {
        
            Thread thread = new Thread(() ->
                System.out.println("Executando em uma thread tradicional!");
            );
            thread.start();
            try {
                thread.join(); // Espera a thread terminar
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

Threads de sistemas operacionais são custosos, e requerem alto consumo de memória ram. O que nos levaria a ter que investir em recursos, e parte desses recursos acabariam por ser subutilizados, porque em sistemas operacionais modernos, obviamente, todos os recursos não são alocados apenas para o Java.

Podemos concluir então, que Platform Threads são custosas, caras e limitadas para nossos recursos. Para solucionar alguns desses problemas, muitos desenvolvedores optaram pela programação assíncrona, que de fato resolvia alguns, inclusive a subutilização dos recursos. Já que com a programação assíncrona conseguimos enviar um número de requests maior que o número de threads disponíveis(quando existe uma espera de I/O a thread volta para o pool).

Mas em contrapartida isso gerava outros problemas, tais como:

  • Programação fora do tradicional (nova curva de aprendizado)
  • Set específico de Apis (outra nova curva de aprendizado)
  • Por fim, um impacto muito maior na observabilidade do sistema.

Virtual Threads do Java

Baseado no parágrafo anterior, podemos concluir que a melhor solução para termos um melhor thoughput não seria adotar um novo estilo de programação, e sim tornar as threads um recurso mais barato. Ou melhor, fazer com que as threads deixem de fato de ser um recurso!

E é aí que entram as Virtual Threads.

Usamos o mesmo package. java.lang.Thread

Embora usamos a mesma API, não usamos mais as platform threads do sistema operacional. O próprio runtime do Java que cuida da alocação e gerenciamento das Virtual Threads.

E pode parecer óbvio, mas não existe sistema operacional que gerencie uma thread em Java melhor do que a própria JVM.

A JVM é o ambiente que melhor sabe executar um código java!

E é por causa disso que as Virtual Threads funcionam tão bem se comparadas a uma Thread tradicional.

Um exemplo bem comum, as virtual threads conseguem trabalhar com “resizable stacks” (Stacks que podem ter o seu tamanho modificadas). Isso porque elas trabalham dentro da JVM, e o “ambiente java” permite resizable stacks. Já as Platform Threads, precisam de uma “previsão” de tamanho dessa stack no Sistema operacional, e é comum falharmos nisso e receber o famoso erro StackOverFlow.

Exemplo do mesmo código de tradicional thread, agora com Virtual Threads

    
    public class VirtualThreadExample {
    
        public static void main(String[] args) {
    
            Thread virtualThread = Thread.ofVirtual().start(
                    () -> System.out.println("Executando em uma thread virtual!")
            );
    
            try {
                virtualThread.join(); // Espera a thread terminar
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

Mais a fundo nas Virtual Threads

Embora praticamente qualquer código Java pode vir a ser executado usando as Virtual Threads, precisamos seguir certas boas praticas para conseguir extrair todos os beneficios que ela nos traz.

Primeira coisa, volte a fazer o bom uso das blocking apis.

Exemplo de API não bloqueante (CompletableFuture - Unblocking)

Aqui, as chamadas assíncronas são encadeadas para obter uma página da web e sua imagem associada sem bloquear a thread principal.

    
    CompletableFuture.supplyAsync(() -> getBody(info.getUrl(), HttpResponse.BodyHandlers.ofString()))
        .thenCompose(page -> CompletableFuture.supplyAsync(() -> info.findImage(page)))
        .thenCompose(imageUrl -> CompletableFuture.supplyAsync(() -> getBody(imageUrl, HttpResponse.BodyHandlers.ofByteArray())))
        .thenApply(data -> {
            info.setImageData(data);
            return info;
        })
        .thenAcceptAsync(info -> process(info))
        .exceptionally(t -> {
            t.printStackTrace();
            return null;
    });

Exemplo de API bloqueante (Código tradicional bloqueante)

Este exemplo funciona melhor com virtual threads, já que são leves e podem ser usadas para lidar eficientemente com um grande número de operações bloqueantes.

    try {
        // Chamada bloqueante para obter o conteúdo da página
        String page = getBody(info.getUrl(), HttpResponse.BodyHandlers.ofString());
        
            // Encontrar a URL da imagem na página
            String imageUrl = info.findImage(page);
            
            // Chamada bloqueante para obter os dados da imagem
            byte[] data = getBody(imageUrl, HttpResponse.BodyHandlers.ofByteArray());
            
            // Definir os dados da imagem
            info.setImageData(data);
            
            // Processar as informações
            process(info);
        
    } catch (Exception e) {
            e.printStackTrace();
    }

Outra coisa, transformar uma SO thread em Virtual Thread não traz ganho. O que traz ganhos é o uso de threads por tarefa.

Uso de Thread Pools vs Uso de Thread por Tarefa

Um erro comum ao se trabalhar com virtual threads é simplesmente transformar uma thread de SO (thread do sistema operacional) em uma virtual thread sem mudar o modelo de execução. Isso não trará benefícios, como mencionei anteriormente. O verdadeiro ganho com virtual threads vem quando você adota o padrão de “uma thread por tarefa”.

Thread Pools = Poucas Threads

Em vez de usar um thread pool para gerenciar um número limitado de threads, o ideal com virtual threads é criar uma nova thread para cada tarefa.

Isso permite que sua aplicação seja escalável e aproveite ao máximo o paralelismo sem sobrecarregar o sistema. Cada tarefa, seja ela uma instância de Runnable ou Callable, pode ser executada em sua própria virtual thread.

    TimerTask task1 = new MyTimerTask("task1"); // Várias coisas sendo feitas em um cenário fechado
    TimerTask task2 = new MyTimerTask("task2"); // Várias coisas sendo feitas em um cenário fechado
    
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        executor.submit(task1);  // Cada tarefa em sua própria virtual thread
        executor.submit(task2);
    }

Explicação:

Tarefa como unidade de trabalho: No exemplo acima, cada tarefa (task1 e task2) é encapsulada dentro de um TimerTask, que representa uma unidade de trabalho. Essas tarefas são submetidas a um executor, que cria uma nova virtual thread para cada tarefa.

Não compartilhe virtual threads: Ao contrário das SO threads, as virtual threads são projetadas para não serem reutilizadas. Elas são leves o suficiente para que a melhor prática seja criar uma nova virtual thread para cada tarefa, em vez de compartilhar uma entre várias tarefas.

Virtual threads como objetos de lógica de negócio: Aqui, as virtual threads não são mais vistas como um recurso limitado. Elas passam a ser parte da lógica de execução do seu sistema, permitindo que cada tarefa rode independentemente em sua própria thread.

Atenção ao Pinning

Esse problema aparece quando:

  • Uma operação leva muito tempo para ser concluída (geralmente por causa de operações bloqueantes de I/O);
  • O código roda dentro de um bloco synchronized ou algum outro mecanismo de sincronização;
  • E essa operação é executada com muita frequência.

Para que ocorra pinning, esses três fatores precisam estar presentes. Se essas três combinações ocorrerem, a Virtual Thread pode ser “bloqueada”, impactando a escalabilidade e eficiência inerente das virtual threads.

Solução para evitar o pinning:

Utilizar ReentrantLock em vez de blocos synchronized pode ajudar a evitar esse problema, permitindo que o lock seja mais flexível e evitando a associação direta com uma carrier thread.

    
    Lock lock = new ReentrantLock();
    lock.lock();
    try {
        somethingLongSynchronizedAndFrequent();  // Operação longa e frequente que requer sincronização
    } finally {
        lock.unlock();
    }

Em versões futuras não precisaremos mais da interface ReentrantLock para evitar o esse problema.

Limitando a Concorrência com Virtual Threads

Mesmo com a leveza das virtual threads, às vezes é necessário controlar o acesso simultâneo a recursos limitados, como conexões de banco de dados. Para isso, usamos um semaphore, que permite gerenciar quantas threads podem acessar um recurso ao mesmo tempo.

Exemplo com Semaphore:

    Semaphore sem = new Semaphore(10);  // Permite 10 acessos simultâneos
    sem.acquire();  
    try {
        return somethingVeryLimited();  // Recurso com acesso limitado
    } finally {
        sem.release();  // Libera o acesso
    }

Como funciona:

O Semaphore limita o número de threads que podem acessar um recurso. No exemplo, até 10 virtual threads podem acessar o recurso ao mesmo tempo. Threads esperam por uma permissão (sem.acquire()) antes de acessar o recurso e liberam a permissão (sem.release()) ao terminar, garantindo que o limite de acessos seja respeitado.

Uso de Virtual Threads com Quarkus

Com o suporte às virtual threads no Quarkus, você pode facilmente aproveitar os benefícios de maior escalabilidade e menor consumo de recursos ao lidar com tarefas bloqueantes, como I/O. A anotação @RunOnVirtualThread permite que uma classe ou método seja executado em uma virtual thread, tornando o código mais eficiente sem mudanças significativas.

Exemplo de uso em uma classe de Resource:

    import io.smallrye.common.annotation.RunOnVirtualThread;
    import javax.ws.rs.GET;
    import javax.ws.rs.Path;
    import javax.ws.rs.Produces;
    import javax.ws.rs.core.MediaType;
    
    @Path("/example")
    @RunOnVirtualThread  // Todas as requisições aqui serão executadas em virtual threads
    public class ExampleResource {
    
        @GET
        @Produces(MediaType.TEXT_PLAIN)
        public String getExample() {
            // Código aqui será executado em uma virtual thread
            return "Hello from Virtual Thread!";
        }
    }

Como funciona:

Ao adicionar @RunOnVirtualThread na classe ExampleResource, todas as requisições HTTP que chegam a essa classe serão automaticamente tratadas em virtual threads. Isso melhora o desempenho em situações que envolvem operações bloqueantes, como chamadas de APIs externas ou interações com bancos de dados.

Você também pode usar essa anotação em métodos específicos, caso não queira que toda a classe utilize virtual threads.

Conclusão e Benefícios:

Escalabilidade: Virtual threads permitem lidar com milhares de requisições simultâneas de forma eficiente. Simplicidade: Com a anotação @RunOnVirtualThread, você habilita o uso de virtual threads de forma simples, sem a necessidade de reestruturar seu código.

As virtual threads no Java representam um grande avanço para a concorrência, permitindo uma utilização muito mais eficiente dos recursos de hardware ao alocar um número enorme de threads leves com poucas threads tradicionais.

A melhor parte é que não há necessidade de aprender novas APIs ou mudar o modelo de programação, pois continuamos usando, o familiar java.lang.Thread, o que facilita a adoção.

Além disso, evitamos a complexidade da programação assíncrona, mantendo a facilidade de depuração e monitoramento.

Com as virtual threads, o Java agora oferece uma solução poderosa para escalabilidade, sem sacrificar a simplicidade.

E em artigos futuros, explorarei mais sobre o uso dessas threads em conjunto com Quarkus, aproveitando ao máximo sua capacidade em aplicações modernas..

Principais fontes do artigo

Praticamente todo esse artigo é baseado na palestra do grande Java champion Elder Moraes

A parte de Concorrência e Paralelismo é baseada no artigo do excelente Hugo Marques da Netflix

JEP 425 (Preview in JDK 19):
https://openjdk.org/jeps/425

Project Loom:
https://cr.openjdk.java.net/~rpressler/loom/Loom-Proposal.html

Virtual Threads: New Foundations for High-Scale Java Applications (Brian Goetz, Daniel Briant):
https://www.infoq.com/articles/java-virtual-threads/

Writing simpler reactive REST services with Quarkus Virtual Thread support:
https://quarkus.io/guides/virtual-threads

The Age of Virtual Threads (Ron Pressler, Alan Bateman):
https://youtu.be/YQ6EpIk7KgY