Algumas das boas práticas mostradas aqui podem parecer micro-otimização, otimização prematura ou otimização de fatores constantes. Mas desempenho e desperdício de memória em milhares destes pequenos lugares acabam se somando rapidamente e irão "moer" uma aplicação até ela "rastejar". Aplicação aqui refere-se a uma aplicação que rode no lado servidor, dentro de um servidor de aplicação. Em aplicações desktop, as situações mencionadas aqui podem não ser tão ruins. Mas então, qual é a única plataforma relevante que roda aplicações Java no lado cliente? Android. Uma plataforma embarcada com recursos bem limitados como memória por exemplo. Neste caso, mesmo a otimização de fatores constantes valem a pena. Iterar sobre arrays em vez de listas é um exemplo.
Se você está interessado em como programar otimizado para o compilador, leia a JDK Performance Wiki (em inglês).
No fim das contas, muito do desempenho da aplicação vai depender da qualidade geral do seu código-fonte. A propósito: nunca subestime a importância das "pegadas" de memória deixadas para trás. Muitas aplicações apresentam problemas de desempenho devido a um grande overhead do Garbage Collector e erros Out of Memory. Mesmo que o Garbage Collector seja bastante rápido, a maior parte da escalabilidade de um código do lado servidor é determinada por dois números:
Reduzir ambos os números por meio da otimização de um fator constante irá diretamente aumentar a vazão.
Considere estes cenários (assumindo uma young generation de 100MB):
Cenário | Threads | Duração da Requisição (ms) | Memória usada por Requisição (KB) | Máximo de Requisições por segundo | Memória usada por segundo (KB) | Coletas por segundo com Young Generation = 100MB |
---|---|---|---|---|---|---|
Base | 30 | 100 | 50 | 300 | 15000 | 0,15 |
Mais Lento | 30 | 1000 | 50 | 30 | 1500 | 0,015 |
Mais Memória | 30 | 100 | 500 | 300 | 150000 | 1,5 |
Excesso de Memória | 30 | 100 | 5000 | 300 | 1500000 | 15 |
A seguir, cada cenário foi representado em gráfico onde o eixo X é o tempo transcorrido em milissegundos ao longo de 2 segundos e o eixo Y é a memória alocada. Em azul está a memória alocada ainda em uso e em vermelho a memória alocada não mais usada (lixo que pode ser coletado). O raio representa o evento de coleta de lixo realizado pelo Garbage Collector. Todos os gráficos estão na mesma resolução para permitir a comparação visual direta entre eles. O momento exato da coleta e como a memória usada é liberada pode não estar graficamente preciso e possui mais caráter ilustrativo.
Este cenário 'Base' serve de comparação para os demais. Dentro do período representado de 2 segundos não chegou a ocorrer o evento de coleta de lixo por que a young generation não atingiu os 100MB.
No cenário 'Mais Lento' a duração da requisição é 10 vezes maior. Isto imediatamente reduz o número máximo de requisições por segundo na proporção de 10 também.
No cenário 'Mais Memória', cada requisição usa 10 vezes mais memória. Isto aumenta diretamente o número de coleta de lixo para mais de 1 por segundo, o que causa um overhead que não pode ser negligenciado.
Usar muito mais memória como no cenário 'Excesso de Memória' pode levar a 15 coletas por segundo, deixando apenas 66 milissegundos por coleta, o que claramente não é suficiente. A sistema irá degradar-se seriamente. O tempo de 66 milissegundos é abaixo do tempo da duração da requisição que é de 100 milissegundos. Então muitas requisições em atendimento serão mantidas na memória, não podendo ser coletadas, e causando uma propagação dessa memória para gerações posteriores. Isto significa que as gerações mais velhas irão começar a crescer e irão precisar de uma grande (e consequentemente lenta) coleta em breve. A aplicação neste cenário não tem mais desempenho razoável.
Espero que isto tenha mostrado claramente o quanto ruim é um código com excesso de consumo de memória quando comparado a um código apenas lento. Todo o seu código super-rápido não poder ajudá-lo quando você aloca muita memória.
E agora, vamos ver alguns exemplos ruins de código?