Software Engineer

Explorando as Novidades do Java: Um Guia das Funcionalidades do Java 9 ao 21

Este é um compilado das funcionalidades que considerei importantes para quem utiliza Java no dia a dia. Analisei a documentação de lançamento do OpenJDK, começando da versão 9 até a versão mais recente de longo prazo (LTS), que atualmente é a versão 21.
Meu objetivo é criar uma base de conhecimento de acesso prático, para simplificar e gerar maior produtividade para nós, desenvolvedores. Assim, poderemos compreender e aproveitar rapidamente as melhorias na linguagem, aplicando-as tanto na escrita quanto na leitura do código Java.

Collection factory methods

Release 9 JEP 269
Foi introduzido factory methods que podem ser acionados diretamente da interface para a criação de coleções imutáveis, ou seja, que não permitem adição ou remoção de elementos.
// Java 9 // Exemplo de criação de lista List<String> lista = List.of("maçã", "banana", "laranja"); System.out.println("Lista: " + lista); // Exemplo de criação de coleção Set<Integer> conjunto = Set.of(1, 2, 3, 4, 5); System.out.println("Conjunto: " + conjunto); // Exemplo de criação de mapa Map<String, Integer> mapa = Map.of("um", 1, "dois", 2, "três", 3); System.out.println("Mapa: " + mapa);
Um dos benefícios desses métodos é a otimização, pois eliminam algumas camadas. Quando retornamos uma lista com argumentos específicos, uma coleção com os campos será criada diretamente, sem a necessidade de usar um array por trás, como no método Arrays.asList().

Stream API

toList()

Como sabemos podemos coletar resultados de um stream usando a Api de Coletores, um coletor muito usado é o Collectors.toList(). Por este motivo, a adição do método toList é conveniente.
// Java 8 Arrays.stream(args).collect(Collectors.toList()); // Java 9 Arrays.stream(args).toList();

dropWhile()

Durante um stream podemos remover elementos enquanto a condição for verdadeira usando dropWhile.
// Java 9 Stream.of(2, 3, -4, -5, 6).dropWhile(i -> i > 0).toList(); // [-4, -5, 6]

takeWhile()

Mantem elementos enquanto a condição for verdadeira.
// Java 9 Stream.of(2, 3, -4, -5, 6).takeWhile(i -> i > 0).toList(); // [2, 3]

mapMulti()

O operador mapMulti permite substituir elementos de um stream com zero ou mais elementos. Pode ser usado para substituir um flatMap de maneira imperativa, especialmente para pequenos ou zero elementos de stream, evitando a instanciação de um novo stream para cada grupo requerido pelo flatMap.
Exemplo com flatMap, onde precisamos de um stream para albuns e outro stream intermediário para artistas.
// Java 9 int upperCost = 9; List<Pair<String, String>> artistAlbum = albums.stream() .flatMap(album -> album.getArtists() .stream() .filter(artist -> upperCost > album.getAlbumCost()) .map(artist -> new ImmutablePair<String, String>(artist.getName(), album.getAlbumName()))) .collect(toList());
Exemplo com mapMulti, tememos melhor desempenho e reaproveitamos o mesmo fluxo de stream, sem necessidade de um stream intermediário para cada elemento.
// Java 9 int upperCost = 9; List<Pair<String, String>> artistAlbum = albums.stream() .<Pair<String, String>> mapMulti((album, consumer) -> { if (album.getAlbumCost() < upperCost) { for (Artist artist : album.getArtists()) { consumer.accept(new ImmutablePair<String, String>(artist.getName(), album.getAlbumName())); } } })

Local-Variable type inference

Release 10, JEP 286
Permite omitir a declaração do tipo em variáveis locais. Em vez de usar o tipo podemos usar a palavra var e o Java vai fazer a inferência do tipo, motivado para reduzir o boilerplate de código Java.
// Java 10 var list = new ArrayList<String>(); // infers ArrayList<String> var stream = list.stream(); // infers Stream<String> var product = new Product(); // infers Product

Text Blocks

Release 15 JEP 378
Nos permite escrever Strings com múltiplas linhas sem a necessidade de concatenação de String. Basta englobar o texto entre aspas triplas """ e quebrar a linha, tornando desnecessário escapar as aspas.
// Java 15 //"one-dimensional" string literal String query = "SELECT \"EMP_ID\", \"LAST_NAME\" FROM \"EMPLOYEE_TB\"\n" + "WHERE \"CITY\" = 'INDIANAPOLIS'\n" + "ORDER BY \"EMP_ID\", \"LAST_NAME\";\n"; //"two-dimensional" bloco de texto String query = """ SELECT "EMP_ID", "LAST_NAME" FROM "EMPLOYEE_TB" WHERE "CITY" = 'INDIANAPOLIS' ORDER BY "EMP_ID", "LAST_NAME"; """;

Records

Release 16 JEP 395
É uma forma simples e concisa de definir classes para transportadores de dados imutáveis. A declaração dos campos e tipos no corpo da classe não são necessários pois são configurados no construtor.
Os records implementam automaticamente métodos baseados em dados como acessadores, equals, hashCode, e toString. Visto que muitos desenvolvedores tendem a querer economizar código, omitem a implementação de métodos que podem levar a um comportamento indesejado ou reduzir a capacidade de depuração.
A seguir temos um exemplo de uma classe transportadora de dados (DTO), geralmente seguem este modelo de implementação:
// Java 16: Exemplo de implementação de um DTO class Point { private final int x; private final int y; Point(int x, int y) { this.x = x; this.y = y; } int x() { return x; } int y() { return y; } public boolean equals(Object o) { if (!(o instanceof Point)) return false; Point other = (Point) o; return other.x == x && other.y == y; } public int hashCode() { return Objects.hash(x, y); } public String toString() { return String.format("Point[x=%d, y=%d]", x, y); } }
A implementação anterior usando classes pode ser facilmente substituída por records. Na declaração de um record, os campos são identificados diretamente no cabeçalho e são final por padrão. Veja o exemplo:
// Java 16 // Ex 1 - Record simples record Point(int x, int y) { } // Ex 2 - Um contrutor canônico pode ser declarado explicitamente record Point(int x, int y) { Point(int x, int y) { this.x = x; this.y = y; } } // Ex 3 - É permitida declaração de métodos estáticos record Point(int x, int y) { static boolean isValid(int x, int y) { // ... } }
É uma boa prática declarar apenas atributos classificados como imutáveis, primitivos, String, LocalDate ou outros records. Ao adicionarmos objetos mutáveis, como uma coleção, podemos obter resultados diferentes em equals e hashCode. Isso pode levar a problemas de segurança ao usar esses objetos como chaves em um Map ou como membros de um Set. Portanto, para esses casos, é recomendável fazer uma cópia da coleção mutável no construtor canônico.
// Java 16 public record Receipt(long receiptId, List<Product> items) { public Receipt { items = List.copyOf(Objects.requireNonNull(items)); } }
Em comparação com uma classe normal, Records possuem algumas limitações:
  • Não podemos herdar um Record de outra classe e sua classe super é sempre java.lang.Record como acontece com classes Enum.
  • Uma classe Record é implicitamente final, não pode ser abstract.
  • Para suportar a imutabilidade, seus campos são final.
  • A declaração dos campos não é suportada no corpo da classe, somente no header.
  • Não é permitido a declaração de métodos nativos, isso faria a classe depender de um estado externo e não explícito da classe. (Métodos Java com implementação escrita em outra linguagem como C/C++).

Sealed Interfaces e Classes

Release 17 JEP 409
Classes e interfaces “seladas” restringem quais outras classes ou interfaces podem extendê-las ou implementá-las. Em termos práticos, ela conhece seus descendentes em tempo de compilação.
Há outras maneiras de restringir a extensão de uma classe. O modificador final proíbe completamente quem tem acesso à classe base de estendê-la. Tornar a classe base package-private impossibilita a herança a partir de outros pacotes, porém não permite que a classe base seja referenciada ou usada como tipo de uma variável local.
Para fechar essa lacuna, agora a verificação de instanceof para tipos selados com subclasses passa a ser bem-sucedida. Por exemplo, ao verificar uma instância de uma classe selada A, com subclasses B e C, saberemos se a instância é A, B ou C.
Ao criar uma superclasse, devemos ser capazes de expressar um conjunto específico de subclasses, para que o compilador e o leitor reconheçam as classes descendentes.
Para declarar uma classe ou interface, basta usarmos a palavra-chave sealed como modificador.
sealed class SuperClass { ... } sealed interface SuperInterface { ... }
Para declarar quais classes são permitidas, usamos a cláusula permits .
sealed interface Animal permits Dog, Cat { ... } final class Dog implements Animal { ... } final class Cat implements Animal { ... }

Pattern Matching - instanceof

Release 16 JEP 394
Pattern Matching é um conceito amplamente utilizado em linguagens funcionais. A ideia é encontrar a sequência ou construtor correspondente. Se o padrão for atendido, partes individuais desse padrão podem ser vinculados a variáveis.
// Antes do Java 16 - Necessário declaração e cast da variável if (task instanceof BatchTask) { BatchTask batchTask = (BatchTask)task; return batchTask.getSubtasks(); } // Java 16 - Vinculando o padrão correspondente diretamente em uma variável if (task instanceof BatchTask batchTask) { return batchTask.getSubtasks(); }

Pattern Matching - switch

Release 21 JEP 441
Uma extensão similar à aplicada em instanceof, permite que padrões apareçam nos rótulos de cada case. Isso significa que, em vez de apenas comparar uma variável com um valor específico em cada caso, podemos usar padrões mais complexos para realizar correspondências.
Essas mudanças devem tornar o switch mais expressivo e também melhorar a legibilidade do código, especialmente quando há uma variedade de casos tratados com lógica condicional.
Podemos usar patter matching com switch para:
Correspondência de tipos: Verificar se um objeto é de um determinado tipo.
// Antes do Java 21 static String formatter(Object obj) { String formatted = "unknown"; if (obj instanceof Integer i) { formatted = String.format("int %d", i); } else if (obj instanceof Long l) { formatted = String.format("long %d", l); } else if (obj instanceof Double d) { formatted = String.format("double %f", d); } else if (obj instanceof String s) { formatted = String.format("String %s", s); } return formatted; } // Java 21 static String formatterPatternSwitch(Object obj) { return switch (obj) { case Integer i -> String.format("int %d", i); case Long l -> String.format("long %d", l); case Double d -> String.format("double %f", d); case String s -> String.format("String %s", s); default -> obj.toString(); }; }
Desestruturação de registros: Extrair valores de campos de um registro.
// Java 21 Person person = new Person("John", 30); switch (person) { case Person(String name, int age) -> { System.out.println("Nome: " + name); System.out.println("Idade: " + age); } default -> System.out.println("Registro não reconhecido"); }
Correspondência de padrões complexos: Usar padrões mais sofisticados, como combinações de padrões, para lidar com situações complexas de forma concisa.
// Java 21 Vehicle vehicle = new Car(); double tax = switch (vehicle) { case Car c && (c.calculateTax(2010) > 1500) -> c.calculateTax(2010) * 0.9; case Car c -> c.calculateTax(2010); case Truck t -> t.calculateTax(2010); case Motorcycle m -> m.calculateTax(2010); default -> 0; };

Lidando com null

Tradicionalmente, um switch gera NullPointerException se a expressão for avaliada como nula. No entanto, agora há uma inspeção no case que permite o tratamento adequado dessa situação.
// Java 21 static void nullMatch(Object obj) { switch (obj) { case null -> System.out.println("null!"); case String s -> System.out.println("String"); default -> System.out.println("Something else"); } }
Blocos sem tratamento do case null mantêm o comportamento padrão, lançando NullPointerException. Veja os exemplos a seguir nos quais ambos são equivalentes e lançam exceção.
// Java 21 static void nullMatch1(Object obj) { switch (obj) { case String s -> System.out.println("String: " + s); case Integer i -> System.out.println("Integer"); default -> System.out.println("default"); } } // Java 21 static void nullMatch2(Object obj) { switch (obj) { case null -> throw new NullPointerException(); case String s -> System.out.println("String: " + s); case Integer i -> System.out.println("Integer"); default -> System.out.println("default"); } }

Pattern matching - records

Release 21 JEP 440
O objetivo é ampliar a correspondência de padrões para desestruturar registros e permitir consultas de dados mais avançadas, além de adicionar padrões aninhados para tornar as consultas de dados mais compostas.
// Java 16 record Point(int x, int y) {} static void printSum(Object obj) { if (obj instanceof Point p) { int x = p.x(); int y = p.y(); System.out.println(x+y); } } // Java 21 static void printSum(Object obj) { if (obj instanceof Point(int x, int y)) { System.out.println(x+y); } }

Sequenced Collections

Release 21 JEP 431
Java oferece coleções ordenadas, como LinkedHashMap e List, no entanto, não possui uma interface consistente para essas coleções. Cada implementação possui suas particularidades e não há padronização das operações que lidam com os elementos das coleções.
Primeiro elemento Último elemento List list.get(0) list.get(list.size() - 1) Deque deque.getFirst() deque.getLast() SortedSet sortedSet.first() sortedSet.last() LinkedHashSet linkedHashSet.iterator().next()
As novas interfaces foram projetadas para garantir que todos os elementos sejam organizados em uma ordem definida e ofereçam suporte a operações comuns para o processamento desses elementos, tanto do primeiro ao último quanto do último ao primeiro.
// Collection interface SequencedCollection<E> extends Collection<E> { SequencedCollection<E> reversed(); void addFirst(E); void addLast(E); E getFirst(); E getLast(); E removeFirst(); E removeLast(); } // Set interface SequencedSet<E> extends Set<E>, SequencedCollection<E> { SequencedSet<E> reversed(); } // Map interface SequencedMap<K,V> extends Map<K,V> { SequencedMap<K,V> reversed(); SequencedSet<K> sequencedKeySet(); SequencedCollection<V> sequencedValues(); SequencedSet<Entry<K,V>> sequencedEntrySet(); V putFirst(K, V); V putLast(K, V); Entry<K, V> firstEntry(); Entry<K, V> lastEntry(); Entry<K, V> pollFirstEntry(); Entry<K, V> pollLastEntry(); }

AutoCloseable HTTP Client

Release 21 JEP 321
A API do cliente HTTP existe desde o Java 11, e agora possui a capacidade de fechamento automático. Ao implementar a interface AutoCloseable, o JDK automaticamente chama o método close() após a conclusão da instrução try-with-resources, assegurando uma liberação eficiente de recursos.
try (var http = HttpClient.newHttpClient()){ var request = HttpRequest.newBuilder(URI.create(URI_NAME)) .GET() .build() ; var response = http.send(request, HttpResponse.BodyHandlers.ofString()); Assertions.assertEquals(response.statusCode(), 200); System.out.println(response.body()); }

Virtual Threads

Release 21 JEP 444
O Java 21 adicionou um novo tipo de thread chamado thread virtual. Diferentemente das threads tradicionais, que são gerenciadas pelo sistema operacional, as threads virtuais são implementadas diretamente pelo JDK..
As threads virtuais podem melhorar significativamente o rendimento do aplicativo quando há um elevado número de tarefas simultâneas (acima de milhares). E também quando a carga de trabalho não está vinculada à CPU, pois ter muito mais thread do que núcleos de processador não pode melhorar o rendimento nesse caso.
Podemos melhorar o rendimento de aplicações de servidor, porque consistem em um grande número de tarefas simultâneas que passam grande parte do tempo esperando.
void handle(Request request, Response response) { var url1 = ... var url2 = ... try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { var future1 = executor.submit(() -> fetchURL(url1)); var future2 = executor.submit(() -> fetchURL(url2)); response.send(future1.get() + future2.get()); } catch (ExecutionException | InterruptedException e) { response.fail(e); } } String fetchURL(URL url) throws IOException { try (var in = url.openStream()) { return new String(in.readAllBytes(), StandardCharsets.UTF_8); } }
Estamos acostumados a usar pool de thread para controlar o limite de concorrência e evitar exceder os recursos disponíveis. No entanto, com threads virtuais, essa preocupação não é mais necessária, pois elas não são custosas e podem ser executadas por tarefa sem a necessidade de agrupamento.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { ... }

Conclusão

Neste post, com tantas atualizações, percebemos o esforço e o contínuo comprometimento da comunidade em inovar a linguagem e manter a plataforma robusta e moderna para nós, desenvolvedores.
Analisamos as funcionalidades lançadas entre as versões 9 e 21 do Java, consideradas relevantes especialmente para os desenvolvedores que escrevem código de negócios e utilizam Java no dia a dia.
Muitas outras funcionalidades foram lançadas desde a versão 9. Obviamente, mostrar todas elas não foi minha intenção. No entanto, você pode estar interessado em mudanças na JVM, bibliotecas ou ferramentas e, quem sabe, até mesmo conhecer todas as implementações. Para isso, recomendo que avalie a documentação oficial do Projeto OpenJDK.

Fontes