Desde que a plataforma Java adotou um ciclo de lançamento de seis meses, deixamos para trás as perguntas perenes como “O Java vai morrer este ano?” ou “Vale a pena migrar para a nova versão?”. Apesar de já se passarem 28 anos desde seu primeiro lançamento, o Java continua a prosperar e permanece uma escolha popular como a principal linguagem de programação para muitos novos projetos.
O Java 17 foi um marco significativo, mas o Java 21 agora ocupa o lugar do 17 como o próximo lançamento de suporte a longo prazo (LTS). É essencial que os desenvolvedores Java se mantenham informados sobre as mudanças e novos recursos que esta versão traz. Inspirado por meu colega Darek, que detalhou os recursos do Java 17 em seu artigo, decidi discutir o JDK 21 de maneira semelhante (também analisei os recursos do Java 23 em um artigo de acompanhamento, então confira também).
O JDK 21 compreende um total de 15 JEPs (Propostas de Melhoria do JDK). Você pode revisar a lista completa no site oficial do Java. Neste artigo, destacarei vários JEPs do Java 21 que acredito serem particularmente notáveis. Ou seja:
- Templates de String
- Coleções Sequenciadas
- Correspondência de Padrões para
switch
e Padrões de Registro - Threads Virtuais
Sem mais delongas, vamos mergulhar no código e explorar essas atualizações.
Modelos de String (Pré-visualização)
A funcionalidade de Modelos do Spring ainda está em modo de pré-visualização. Para usá-la, você precisa adicionar a flag --enable-preview
aos argumentos do seu compilador. No entanto, decidi mencioná-la apesar de seu status de pré-visualização. Por quê? Porque fico muito irritado toda vez que preciso escrever uma mensagem de log ou uma instrução SQL que contém muitos argumentos ou decifrar qual espaço reservado será substituído por um argumento dado. E os Modelos do Spring prometem me ajudar (e a você) com isso.
Como diz a documentação do JEP, o propósito dos Modelos do Spring é “simplificar a escrita de programas Java tornando fácil expressar strings que incluem valores computados em tempo de execução.”
Vamos verificar se realmente é mais simples.
A “velha maneira” seria usar o método formatted()
em um objeto String:
var msg = "Log message param1: %s, pram2: %s".formatted(p1, p2);
Agora, com StringTemplate.Processor
(STR), fica assim:
var interpolated = STR."Log message param1: \{p1}, param2: \{p2}";
Com um texto curto como o acima, o lucro pode não ser tão visível — mas acredite, quando se trata de grandes blocos de texto (JSONs, instruções SQL, etc.), parâmetros nomeados vão te ajudar bastante.
Coleções Sequenciadas
Java 21 introduziu uma nova Hierarquia de Coleções Java. Olhe para o diagrama abaixo e compare-o com o que você provavelmente aprendeu durante suas aulas de programação. Você notará que três novas estruturas foram adicionadas (destacadas pela cor verde).
Coleções sequenciadas introduzem uma nova API Java incorporada, aprimorando operações em conjuntos de dados ordenados. Esta API permite não apenas o acesso conveniente aos primeiros e últimos elementos de uma coleção, mas também possibilita a travessia eficiente, inserção em posições específicas e recuperação de subsequências. Esses aprimoramentos tornam operações que dependem da ordem dos elementos mais simples e intuitivas, melhorando tanto o desempenho quanto a legibilidade do código ao trabalhar com listas e estruturas de dados semelhantes.
Esta é a listagem completa da interface SequencedCollection
:
public interface SequencedCollection<E> extends Collection<E> {
SequencedCollection<E> reversed();
default void addFirst(E e) {
throw new UnsupportedOperationException();
}
default void addLast(E e) {
throw new UnsupportedOperationException();
}
default E getFirst() {
return this.iterator().next();
}
default E getLast() {
return this.reversed().iterator().next();
}
default E removeFirst() {
var it = this.iterator();
E e = it.next();
it.remove();
return e;
}
default E removeLast() {
var it = this.reversed().iterator();
E e = it.next();
it.remove();
return e;
}
}
Então, agora, em vez de:
var first = myList.stream().findFirst().get();
var anotherFirst = myList.get(0);
var last = myList.get(myList.size() - 1);
Podemos apenas escrever:
var first = sequencedCollection.getFirst();
var last = sequencedCollection.getLast();
var reversed = sequencedCollection.reversed();
Esta é uma pequena mudança, mas na minha opinião, é um recurso tão conveniente e utilizável.
Correspondência de Padrões e Padrões de Registro
Devido à semelhança entre a Correspondência de Padrões para switch
e Padrões de Registro, vou descrevê-los juntos. Os padrões de registro são um recurso novo: foram introduzidos no Java 19 (como uma prévia). Por outro lado, a Correspondência de Padrões para switch
é uma espécie de continuação da expressão instanceof
estendida. Isso traz uma nova sintaxe possível para declarações switch
, que permite expressar consultas orientadas a dados complexas de forma mais fácil.
Vamos esquecer os fundamentos da OOP para o bem deste exemplo e desconstruir o objeto funcionário manualmente (employee
é uma classe POJO).
Antes do Java 21, parecia assim:
if (employee instanceof Manager e) {
System.out.printf("I’m dealing with manager of %s department%n", e.department);
} else if (employee instanceof Engineer e) {
System.out.printf("I’m dealing with %s engineer.%n", e.speciality);
} else {
throw new IllegalStateException("Unexpected value: " + employee);
}
E se pudéssemos nos livrar do feio instanceof
? Bem, agora podemos, graças ao poder da Correspondência de Padrões do Java 21:
switch (employee) {
case Manager m -> printf("Manager of %s department%n", m.department);
case Engineer e -> printf("I%s engineer.%n", e.speciality);
default -> throw new IllegalStateException("Unexpected value: " + employee);
}
Ao falar sobre a declaração switch
, também podemos discutir o recurso de Padrões de Registro. Ao lidar com um Registro Java, ele nos permite fazer muito mais do que uma classe Java padrão:
switch (shape) { // shape is a record
case Rectangle(int a, int b) -> System.out.printf("Area of rectangle [%d, %d] is: %d.%n", a, b, shape.calculateArea());
case Square(int a) -> System.out.printf("Area of square [%d] is: %d.%n", a, shape.calculateArea());
default -> throw new IllegalStateException("Unexpected value: " + shape);
}
Como o código mostra, com essa sintaxe, os campos do registro são facilmente acessíveis. Além disso, podemos adicionar alguma lógica adicional às nossas instruções de caso:
switch (shape) {
case Rectangle(int a, int b) when a < 0 || b < 0 -> System.out.printf("Incorrect values for rectangle [%d, %d].%n", a, b);
case Square(int a) when a < 0 -> System.out.printf("Incorrect values for square [%d].%n", a);
default -> System.out.println("Created shape is correct.%n");
}
Podemos usar uma sintaxe semelhante para as instruções if
. Além disso, no exemplo abaixo, podemos ver que os Padrões de Registro também funcionam para registros aninhados:
if (r instanceof Rectangle(ColoredPoint(Point p, Color c),
ColoredPoint lr)) {
//sth
}
Threads Virtuais
O recurso de Threads Virtuais é provavelmente o mais empolgante entre todos os Java 21 — ou pelo menos um dos que os desenvolvedores Java mais esperavam. Como a documentação do JEP (linkada na frase anterior) diz, um dos objetivos das threads virtuais era “permitir que aplicações de servidor escritas no estilo simples de uma thread por requisição escalassem com uma utilização de hardware quase ideal”. No entanto, isso significa que devemos migrar todo o nosso código que usa java.lang.Thread
?
Primeiro, vamos examinar o problema com a abordagem que existia antes do Java 21 (na verdade, praticamente desde o primeiro lançamento do Java). Podemos aproximar que uma java.lang.Thread
consome (dependendo do SO e da configuração) cerca de 2 a 8 MB de memória. No entanto, o importante aqui é que uma Thread Java está mapeada 1:1 para uma thread do kernel. Para aplicativos web simples que utilizam a abordagem de “uma thread por requisição”, podemos facilmente calcular que ou nossa máquina será “morta” quando o tráfego aumentar (não conseguirá lidar com a carga) ou seremos forçados a comprar um dispositivo com mais RAM, e nossas contas da AWS aumentarão como resultado.
Claro, threads virtuais não são a única maneira de lidar com esse problema. Temos programação assíncrona (frameworks como WebFlux ou API nativa do Java como CompletableFuture
). No entanto, por algum motivo — talvez por causa da “API pouco amigável” ou do alto limiar de entrada — essas soluções não são tão populares.
Threads Virtuais não são supervisionadas ou agendadas pelo sistema operacional. Em vez disso, seu agendamento é gerenciado pela JVM. Enquanto tarefas reais devem ser executadas em uma thread de plataforma, a JVM utiliza chamadas threads transportadoras — essencialmente threads de plataforma — para “carregar” qualquer thread virtual quando está pronta para execução. Threads Virtuais são projetadas para serem leves e usam muito menos memória do que threads de plataforma padrão.
O diagrama abaixo mostra como Threads Virtuais estão conectadas a threads de plataforma e do sistema operacional:
Assim, para ver como Threads Virtuais são usadas por Threads de Plataforma, vamos executar um código que inicia (1 + número de CPUs que a máquina possui, no meu caso 8 núcleos) threads virtuais.
var numberOfCores = 8; //
final ThreadFactory factory = Thread.ofVirtual().name("vt-", 0).factory();
try (var executor = Executors.newThreadPerTaskExecutor(factory)) {
IntStream.range(0, numberOfCores + 1)
.forEach(i -> executor.submit(() -> {
var thread = Thread.currentThread();
System.out.println(STR."[\{thread}] VT number: \{i}");
try {
sleep(Duration.ofSeconds(1L));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}));
}
A saída se parece com isto:
[VirtualThread[#29,vt-6]/runnable@ForkJoinPool-1-worker-7] VT number: 6
[VirtualThread[#26,vt-4]/runnable@ForkJoinPool-1-worker-5] VT number: 4
[VirtualThread[#30,vt-7]/runnable@ForkJoinPool-1-worker-8] VT number: 7
[VirtualThread[#24,vt-2]/runnable@ForkJoinPool-1-worker-3] VT number: 2
[VirtualThread[#23,vt-1]/runnable@ForkJoinPool-1-worker-2] VT number: 1
[VirtualThread[#27,vt-5]/runnable@ForkJoinPool-1-worker-6] VT number: 5
[VirtualThread[#31,vt-8]/runnable@ForkJoinPool-1-worker-6] VT number: 8
[VirtualThread[#25,vt-3]/runnable@ForkJoinPool-1-worker-4] VT number: 3
[VirtualThread[#21,vt-0]/runnable@ForkJoinPool-1-worker-1] VT number: 0
Assim, ForkJonPool-1-worker-X
Threads de Plataforma são nossas threads transportadoras que gerenciam nossas threads virtuais. Observamos que as Threads Virtuais número 5 e 8 estão usando a mesma thread transportadora número 6.
A última coisa sobre Threads Virtuais que quero mostrar a você é como elas podem ajudar com operações de I/O bloqueantes.
Sempre que uma Thread Virtual encontra uma operação bloqueante, como tarefas de I/O, a JVM a desanexa de forma eficiente da thread física subjacente (a thread transportadora). Esse desanexo é crítico porque libera a thread transportadora para executar outras Threads Virtuais em vez de ficar ociosa, aguardando a conclusão da operação bloqueante. Como resultado, uma única thread transportadora pode multiplexar muitas Threads Virtuais, que podem chegar a milhares ou até milhões, dependendo da memória disponível e da natureza das tarefas executadas.
Vamos tentar simular esse comportamento. Para isso, vamos forçar nosso código a usar apenas um núcleo de CPU, com apenas 2 threads virtuais — para melhor clareza.
System.setProperty("jdk.virtualThreadScheduler.parallelism", "1");
System.setProperty("jdk.virtualThreadScheduler.maxPoolSize", "1");
System.setProperty("jdk.virtualThreadScheduler.minRunnable", "1");
Thread 1:
Thread v1 = Thread.ofVirtual().name("long-running-thread").start(
() -> {
var thread = Thread.currentThread();
while (true) {
try {
Thread.sleep(250L);
System.out.println(STR."[\{thread}] - Handling http request ....");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
);
Thread 2:
Thread v2 = Thread.ofVirtual().name("entertainment-thread").start(
() -> {
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
var thread = Thread.currentThread();
System.out.println(STR."[\{thread}] - Executing when 'http-thread' hit 'sleep' function");
}
);
Execução:
v1.join(); v2.join();
Resultado:
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#23,entertainment-thread]/runnable@ForkJoinPool-1-worker-1] - Executing when 'http-thread' hit 'sleep' function
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
Observamos que ambas as Threads Virtuais (long-running-thread
e entertainment-thread
) estão sendo geridas por apenas uma Thread de Plataforma, que é ForkJoinPool-1-worker-1
.
Para resumir, este modelo permite que aplicações Java alcancem altos níveis de concorrência e escalabilidade com muito menos sobrecarga do que modelos tradicionais de threads, onde cada thread mapeia diretamente para uma única thread do sistema operacional. Vale a pena notar que threads virtuais são um tópico vasto, e o que descrevi é apenas uma pequena fração. Eu encorajo fortemente você a aprender mais sobre o escalonamento, threads fixas e os internos das Threads Virtuais.
Resumo: O Futuro da Linguagem de Programação Java
As características descritas acima são as que considero mais importantes no Java 21. A maioria delas não é tão inovadora quanto algumas das coisas introduzidas no JDK 17, mas ainda são muito úteis e boas mudanças de QOL (Qualidade de Vida).
No entanto, você não deve desconsiderar outras melhorias do JDK 21 também — eu recomendo fortemente que você analise a lista completa e explore todos os recursos mais a fundo. Por exemplo, uma coisa que considero particularmente notável é a API Vector, que permite cálculos vetoriais em algumas arquiteturas de CPU suportadas — algo que não era possível antes. Atualmente, ainda está em fase de incubação/experimental (por isso não destaquei em mais detalhes aqui), mas promete muito para o futuro do Java.
No geral, o avanço que o Java fez em várias áreas sinaliza o compromisso contínuo da equipe em melhorar a eficiência e o desempenho em aplicações de alta demanda.
Source:
https://dzone.com/articles/java-21-features-a-detailed-look