Java 8 — Optional: Pense bem! — parte 2
Aqui vimos alguns exemplos de como usar e como não utilizar a classe optional do Java e conseguimos chegar a conclusão de que um dos principais beneficios de sua adoção é deixar o código mais “bonito”. Bom, mas qual será o custo de adotá-la?
Em que moeda vai ser pago?
Costumo pregar que qualquer coisa que você decide utilizar em seu projeto tem um preço. Seja usar uma lib que vai gerar getters e setters para não gera-los manualmente (UM ATALHO NO TECLADO) ou uma classe que vai “acabar com null pointer” para não fazer null check, quando você opta por deixar de fazer coisas, não é com mágica que o computador vai faze-las né?
Não só para este caso, mas no geral é bastante importante observar como esses códigos/libs mágicas impactam no processamento e no uso de memória. Mesmo que hoje recursos computacionais sejam muito mais baratos que já foram um dia, estas escolhas vão impactar diretamente na performance de nossas aplicações e uma aplicação que esbanja recursos para executar suas operações pode custar muito caro financeiramente falando (ninguém quer rasgar dinheiro $$$).
Custo de processamento
Na academia aprendemos que em computação é um tanto quanto simplista mensurar a performance de uma aplicação através da variável tempo, pois existem uma série de outras variáveis que podem contradizer o resultado (ambientes diferentes executando o mesmo código podem ter tempo de execução diferentes). Se não podemos avaliar puramente através de tempo, como faremos? Você deve ter aprendido que um processador nada mais é do que uma central de cálculos certo? Pois bem, em computação podemos chegar ao mesmo resultado de várias formas diferentes, algumas precisarão que o processador realize mais “cálculos”, outras menos. Você concorda comigo que quanto menos operações melhor?
Neste sentido, vou resumir pra você: avaliando os métodos da classe Optional não temos ressalvas negativas, pois como falei no artigo anterior ela nada mais é do que uma classe wrapper e seus métodos simplesmente auxiliam nas operações que são realizadas sobre o objeto guardado. Por exemplo o método orElse: ele simplesmente verifica se o valor é diferente de nulo e o retorna, caso seja nulo retorna outro. Essa implementação é bem simples e comum…
public T orElse(T other) {
return value != null ? value : other;
}
Custo de memória
Antes de falar de como os métodos da classe Optional vão impactar no uso de memória, vamos recapitular de forma sucinta como funciona o gerenciamento de memória no Java. Por que como diriam Henrique e Juliano: Aí que mora o perigo…
Toda vez que instânciamos um objeto, quando escrevemos AlgumaCoisa algumaCoisa = new AlgumaCoisa() , básicamente estamos solicitando que seja “reservado” um espaço em memória para um objeto e esse espaço de memória é refenciado dali em diante para o objeto “algumaCoisa”. Até ai tá tudo bem, o problema é quando este espaço de memória perde a utilidade (formação de memory leaks). Isso geralmente acontece quando atribuímos nulo a variável depois de tê-la inicializado ou então quando atribuimos um novo valor. Como no exemplo abaixo onde na primeira linha atribuimos new Integer(19) e na segunda new Integer(29).
Integer meuInt = new Integer(19);
meuInt = new Integer(29);
O que acontece com o espaço de memória que foi reservado na primeira linha? Ele vira lixo e fica lá ocupando memória até que o coletor de lixo (Garbage Collector — GC) tenha a boa vontade de limpá-lo.
Sabe quando sua mãe reclama que o computador está lento e quando você vai ver se depara com 300 abas do Chrome abertas então você vai lá e fecha todas abas que não estão sendo utilizadas? Você está basicamente sendo o GC da sua mãe!
Mesmo a JVM tendo o GC para fazer a limpeza do lixo, esse processo não tem hora pra acontecer (não determinístico) então pode acontecer de sua aplicação ficar lenta por conta do uso excessivo de memória. Por isso, não podemos nos dar ao luxo de agir como se tivessemos recursos computacionais ilimitados, devemos cuidar para nossas implementações deixem o minimo possível de objetos lixo em memória.
Optional e a alocação de memória
Bom, acho que consegui introduzir o necessário para refletirmos sobre como algumas implementações da classe Optional vão afetar o uso de memória.
Vou direto ao ponto que mais chama atenção na classe neste sentido, que é a implementação do método empty. Perceba na implementação, que toda vez que o método empty for executado será executado um “new Optional<>()”, ou seja, é alocado um novo espaço em memória para um objeto opcional:
private static final Optional<?> EMPTY = new Optional<>();
public static<T> Optional<T> empty() {
@SuppressWarnings("unchecked")
Optional<T> t = (Optional<T>) EMPTY;
return t;
}
Geralmente quando explicitamente utilizamos este método é porque queremos inicializar o opcional vazio, para posteriormente guardar um valor condicionalmente. Voltemos à um exemplo que utilizei no primeiro artigo:
Optional<MinhaClasse> meuObjetoOpcional = Optional.empty();
if(minhaCondicao){ MinhaClasse meuObjeto = buscarUmObjeto();
meuObjetoOpcional = Optional.ofNullable(meuObjeto);}else{
MinhaClasse meuOutroObjeto = buscarOutroObjeto();
meuObjetoOpcional = Optional.ofNullable(meuObjetoOpcional);
}
Perceba, que neste código automaticamente terei no minimo um objeto lixo. Sim, escolhi criar um lixo!
Se as coisas parassem por ai, até tudo bem por que bastaria evitar o uso do método empty. A porca torce o rabo de verdade quando passamos os olhos nos outros métodos e percebemos que este método é utilizado outros métodos da Optional:
No método ofNullable que está no exemplo anterior:
public static <T> Optional<T> ofNullable(T value) {
return value == null ? empty() : of(value);
}
Veja que caso o objeto recebido por parâmetro seja nulo, é executado o método empty, que por sua vez cria aloca um novo espaço em memória que provavelmente mais a frente vai virar lixo.
No filter, caso a condição aplicada sobre o valor “guardado” não seja satisfeita (false), executa-se o método empty, que por sua vez reserva um novo espaço em memória e ai temos mais um lixinho…
public Optional<T> filter(Predicate<? super T> predicate) {
Objects.requireNonNull(predicate);
if (!isPresent())
return this;
else
return predicate.test(value) ? this : empty();
}
Agora os dois que acho mais bizarros: map e flatMap. Quando verifica-se que não há valor guardado, ao invés de fazer como é feito no método filter que retorna uma autoreferência (return this) é chamado o bendito método empty, que vai alocar MAIS um espaço em memória! (PARA QUÊ SENHOR??)
public<U> Optional<U> map(Function<? super T, ? extends U> mapper) {
Objects.requireNonNull(mapper);
if (!isPresent())
return empty();
else {
return Optional.ofNullable(mapper.apply(value));
}
}
public<U> Optional<U> flatMap(Function<? super T, Optional<U>> mapper) {
Objects.requireNonNull(mapper);
if (!isPresent())
return empty();
else {
return Objects.requireNonNull(mapper.apply(value));
}
}
Imagine um cenário real com esses métodos todos encadeados, num emaranhado só para evitar de ter um null em seu código. Que viagem né?
Ainda assim, pode não estar claro para todo mundo como isso impacta na performance. Pois bem, vamos tentar um experimento!
Vamos ver na prática!
Benchmark com JMH
Hoje em dia quando vamos comprar um celular ou um computador, costumamos buscar na internet por comparativos. No que tange performance, geralmente esses comparativos baseam-se em ferramentas de benchmark, que basicamente apresentam através de números como o dispositivo se comportou diante de diferentes implementações. E se eu te falar que existem ferramentas de benchmark para testar a performance de nossas implementações?
Para o nosso estudo, vamos utilizar o Java Microbenchmark Harness (JMH) para atribuir um score e poder comparar cenários com e sem o uso de Optional.
Analisando consumo de recursos com Visual VM
Como a própria doumentação do JMH indica: “Do not assume the numbers tell you what you want them to tell” (não assuma que os números dizem o que você quer que eles digam). Então para excluir a possibilidade de resultados tendenciosos, adicionalmente vamos utilizar uma ferramenta que nos possibilita visualizar o comportamento de nossa JVM durante a execução dos testes: a Visual VM.
A Visual VM é uma ferramenta que gosto bastante, pois nos permite facilmente acompanhar o uso de recursos durante a execução de nossos códigos. Com ela também é possivel facilmente obter memory e thread dumps, para analisar aqueles casos mais cabulosos.
Métodologia
Como temos uma classe alvo a ser testada, precisamos evitar o uso de agentes externos, pois seria muito facil mascarar o resultado. Então vamos utilizar implementações simples, como eram feitas na época dos Incas (sim jovem gafanhoto amante do clean code, nada de streams!).
Para que haja tempo de acompanhar o comportamento da JVM na VisualVM, vamos executar 1 milhão de iterações sobre os algoritmos testados. Enquanto o JMH realiza suas operações, vamos abrir a Visual VM, analisar CPU, como está o consumo de memória e o histograma do heap (ele nos permite verificar quais objetos estão ocupando mais memória).
Testando o método empty
Como pudemos perceber no código, o grande suspeito de ser o “vilão” da história é o método empty, pois a cada vez que ele é invocado é alocado um novo espaço em memória. Bom, para testa-lo vamos implementar um algoritmo que simplesmente conta quantos números pares tem entre 0 e 1 milhão. Este algoritmo consegue nos dar uma idéia do que acontece por exemplo, em um método onde 50% das vezes o Optional assume o valor de empty simplesmente para evitar o uso de null (lembro de vários casos onde vi isso em produção).
Em nosso primeiro teste, vamos avaliar o
Vamos executar cálculos utilizando 1 milhão de iterações sobre os algoritmos para que possamos estressar minimamente a JVM, podendo observar algum impacto.
Na implementação sem Optional, criamos uma variável numérica (parcela), que toda vez que o indice (i) for par assume o valor 1 a ser somado posteriormente. Perceba também que trata-se de um algoritmo O(n): para um laço de repetição de 0 a 10 ou de 0 a 1 milhão, será realizada uma quantidade linear de operações.
@org.openjdk.jmh.annotations.Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
public long somaCondicionalSemOptional() {
long val = 0L;
for (long i = 0; i < 1_000_000; ++i) {
Long parcela = null;
if(i % 2 == 0) {
parcela = 1L;
} if(null != parcela){
val += parcela;
}
}
return val;
}
Eu sei que existem formas matemáticamente mais inteligentes de contar quantos números pares existem entre 0 e 1 milhão, mas lembre-se que esse não é o objetivo! O objetivo é confrontar uma implementação simples, com e sem o uso de Optional, para podermos analisar a performance.
Agora vamos a implementação com o uso da Optional, basicamente o mesmo algoritmo porém substituindo null por Optional:
@org.openjdk.jmh.annotations.Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
public long somaCondicionalComOptional() {
long val = 0L;
for (long i = 0; i < 1_000_000; ++i) {
Optional<Long> parcelaOpcional = Optional.empty();
if(i % 2 == 0) {
parcelaOpcional = Optional.of(1L);
} if(parcelaOpcional.isPresent()){
val += parcelaOpcional.get();
}
}
return val;
}
Resultados — Uso de CPU
Os gráficos demonstram que ambas execuções demandaram um consumo similar de CPU, ou seja, como haviamos percebido anteriormente e agora comprovado, não há um grande problema no que tange o uso de processador:
Resultados — Heap
Aqui são armazenados objetos e a Optional nada mais é que um objeto, então…
Perceba que o algoritmo que não implementa o uso de Optional mantém um padrão de alocação de memória no Heap, enquanto o que utiliza Optional tem picos. Além disso, a implementação com Optional alocou 147.989.368 B enquanto a sem utilizou 41.682.488 B, uma diferença gritante.
Resultados — Metaspace
O metaspace (antigo permgen) da JVM é o espaço da memória onde são armazenados entre outros, dados estáticos (variaveis, métodos, referências, etc). Como o método empty é estático, imaginava que haveria uma alteração aqui, porém não imaginava tanto!
Gráficamente, acredito que aqui temos a maior diferença. Perceba que na primeira imagem, o uso de memória do metaspace pela implementação que utiliza Optional bateu 10.391.824 B dos 11.141.120 B disponíveis naquele momento. Enquanto isso, a implementação que não utilizou Optional, manteve-se estável nos 800.432 B.
Resultados — Hitograma do Heap (Heap histogram) — Ordenado por live bytes
Pode ser que ainda haja uma pulga atrás da orelha de quem tá lendo, para de fato colocar uma pedra sobre essas dúvidas, podemos analisar o histograma do heap, que nos mostra detalhadamente por objeto qual a quantidade de memória alocada.
Bom, aqui temos evidênciado o motivo das diferenças vistas nos gráficos anteriores. Perceba que foram alocados 189.116.304 B (96,2%) somente para opcionais equanto na execução sem Optional o objeto que mais alocou memória foi int[], alocando somente 12.172.424 B (32,3%).
Resultados — benchmark com JMH
Por fim, vamos analisar se os resultados do benchmark condizem com a analise que fizemos da JVM:
Bom, acho que não temos mais dúvidas! Aqui quanto menor o score melhor e para quem é fissurado em mensurar performance por tempo, perceba que o score sem optional é menor que a metade do que foi atribuido à implementação com Optional.
Conclusão
Emiliano, você está dizendo para não usar Optional? Não!
Eu canso de utilizar Optional em projetos, principalmente quando são projetos pequenos, quando sei que o cenário de opcional vazio é uma pequena excessão ou então quando estou atuando em um time onde existe a cultura do “Clean code e hype acima de tudo” (as vezes aceitar dói menos).
Realmente um código bem feito com Optional fica bonito aos olhos! Me dá quase a mesma sensação dos códigos reativos. Entretanto, na minha opinião utilizar Optional para tudo, ou em projetos muito grandes é mais ou menos como duas pessoas apostarem corrida uma delas calçando um tênis confortável e outra um salto alto cravejado de Swarovski. É bem provavel que ambas cheguem ao destino, mas com certeza quem vai ganhar a corrida é quem usou o sapato mais adequado e o melhor com seus pés intactos. Será que a beleza conta mais que a performance? Assim como o salto, uma hora a Optional pode vir a te deixar descalço, com bolhas e você não vai querer isso!
Repositório: https://github.com/efagundes93/about-optional-benchmarks
JMH: https://github.com/openjdk/jmh
Visual VM: https://visualvm.github.io/
Artigo interessante sobre memory leaks: https://www.toptal.com/java/hunting-memory-leaks-in-java
Sobre gerenciamento de memória: https://www.oracle.com/technetwork/java/javase/memorymanagement-whitepaper-150215.pdf
Por: Emiliano Thomas Fagundes — Dev Java na SOUTH SYSTEM