Otimizar aplicações Java

Este guia descreve as otimizações para serviços de publicação do Knative escritos na linguagem de programação Java, juntamente com informações gerais para ajudar a compreender as concessões envolvidas em algumas das otimizações. As informações nesta página complementam as sugestões de otimização gerais, que também se aplicam ao Java.

As aplicações tradicionais baseadas na Web Java são concebidas para publicar pedidos com alta concorrência e baixa latência, e tendem a ser aplicações de execução prolongada. A própria JVM também otimiza o código de execução ao longo do tempo com o JIT, para que os caminhos frequentes sejam otimizados e as aplicações sejam executadas de forma mais eficiente ao longo do tempo.

Muitas das práticas recomendadas e otimizações nesta aplicação Web baseada em Java tradicional giram em torno do seguinte:

  • Processar pedidos simultâneos (com base em threads e E/S sem bloqueio)
  • Reduzir a latência de resposta através da pool de ligações e do processamento em lote de funções não críticas, por exemplo, o envio de rastreios e métricas para tarefas em segundo plano.

Embora muitas destas otimizações tradicionais funcionem bem para aplicações de execução prolongada, podem não funcionar tão bem num serviço do Knative serving, que é executado apenas quando está a publicar ativamente pedidos. Esta página explica algumas otimizações e compromissos diferentes para a publicação do Knative que pode usar para reduzir o tempo de arranque e a utilização de memória.

Otimizar a imagem do contentor

Ao otimizar a imagem do contentor, pode reduzir os tempos de carregamento e arranque. Pode otimizar a imagem das seguintes formas:

  • Minimizar a imagem do contentor
  • Evitar a utilização de JARs de arquivos de bibliotecas aninhados
  • Usar o Jib

Minimizar a imagem do contentor

Consulte a página de sugestões gerais sobre como minimizar o contentor para ter mais contexto sobre este problema. A página de sugestões gerais recomenda reduzir o conteúdo de imagens de contentores apenas ao necessário. Por exemplo, certifique-se de que a imagem do contentor não contém :

  • Código fonte
  • Artefactos de compilação do Maven
  • Ferramentas de compilação
  • Diretórios Git
  • Binários/utilitários não usados

Se estiver a criar o código a partir de um Dockerfile, use a criação de várias fases do Docker para que a imagem do contentor final tenha apenas o JRE e o próprio ficheiro JAR da aplicação.

Evitar JARs de arquivos de bibliotecas aninhados

Algumas frameworks populares, como o Spring Boot, criam um ficheiro de arquivo de aplicação (JAR) que contém ficheiros JAR de biblioteca adicionais (JARs aninhados). Estes ficheiros têm de ser descomprimidos durante o tempo de arranque e podem aumentar a velocidade de arranque no serviço Knative. Sempre que possível, crie um JAR simples com bibliotecas externalizadas: isto pode ser automatizado usando o Jib para colocar a sua aplicação num contentor

Usar o Jib

Use o plug-in Jib para criar um contentor mínimo e reduzir o arquivo da aplicação automaticamente. O Jib funciona com o Maven e o Gradle, e funciona com aplicações Spring Boot prontas a usar. Algumas frameworks de aplicações podem exigir configurações adicionais do Jib.

Otimizações da JVM

A otimização da JVM para um serviço de fornecimento do Knative pode resultar num melhor desempenho e utilização da memória.

Usar versões da JVM sensíveis ao contentor

Nas VMs e nas máquinas, para as atribuições de CPU e memória, a JVM compreende a CPU e a memória que pode usar a partir de localizações conhecidas, por exemplo, no Linux, /proc/cpuinfo e /proc/meminfo. No entanto, quando executado num contentor, as restrições de CPU e memória são armazenadas em/proc/cgroups/.... As versões mais antigas do JDK continuam a procurar em /proc em vez de /proc/cgroups, o que pode resultar numa utilização da memória e da CPU superior à atribuída. Isto pode causar:

  • Um número excessivo de threads porque o tamanho do conjunto de threads é configurado por Runtime.availableProcessors()
  • Um limite máximo de memória dinâmico predefinido que excede o limite de memória do contentor. A JVM usa a memória de forma agressiva antes de recolher o lixo. Isto pode facilmente fazer com que o contentor exceda o limite de memória do contentor e seja terminado por falta de memória.

Por isso, use uma versão da JVM compatível com contentores. As versões do OpenJDK iguais ou superiores à versão 8u192 são compatíveis com contentores por predefinição.

Compreender a utilização de memória da JVM

A utilização de memória da JVM é composta pela utilização de memória nativa e pela utilização de memória heap. Normalmente, a memória de trabalho da aplicação está na heap. O tamanho da área de memória dinâmica está limitado pela configuração de área de memória dinâmica máxima. Com uma instância do Knative serving de 256 MB de RAM, não pode atribuir todos os 256 MB ao Max Heap, porque a JVM e o SO também requerem memória nativa, por exemplo, pilha de threads, caches de código, identificadores de ficheiros, buffers, etc. Se a sua aplicação estiver a receber OOMKilled e precisar de saber a utilização de memória da JVM (memória nativa + heap), ative o acompanhamento de memória nativa para ver as utilizações após uma saída bem-sucedida da aplicação. Se a sua aplicação for terminada por falta de memória, não vai poder imprimir as informações. Nesse caso, execute primeiro a aplicação com mais memória para que possa gerar o resultado com êxito.

Não é possível ativar o acompanhamento de memória nativa através da variável de ambiente JAVA_TOOL_OPTIONS. Tem de adicionar o argumento de inicialização da linha de comandos Java ao ponto de entrada da imagem do contentor para que a sua aplicação seja iniciada com estes argumentos:

java -XX:NativeMemoryTracking=summary \
  -XX:+UnlockDiagnosticVMOptions \
  -XX:+PrintNMTStatistics \
  ...

A utilização de memória nativa pode ser estimada com base no número de classes a carregar. Considere usar uma calculadora de memória Java de código aberto para estimar as necessidades de memória.

Desativar o compilador de otimização

Por predefinição, a JVM tem várias fases de compilação JIT. Embora estas fases melhorem a eficiência da sua aplicação ao longo do tempo, também podem adicionar sobrecarga à utilização de memória e aumentar o tempo de arranque.

Para aplicações sem servidor de execução curta (por exemplo, funções), considere desativar as fases de otimização para trocar a eficiência a longo prazo por um tempo de início reduzido.

Para um serviço de publicação do Knative, configure a variável de ambiente:

JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"

Usar a partilha de dados de classe de aplicações

Para reduzir ainda mais o tempo JIT e a utilização de memória, considere usar a partilha de dados de classes de aplicações (AppCDS) para partilhar as classes Java compiladas antecipadamente como um arquivo. O arquivo AppCDS pode ser reutilizado quando iniciar outra instância da mesma aplicação Java. A JVM pode reutilizar os dados pré-calculados do arquivo, o que reduz o tempo de arranque.

As seguintes considerações aplicam-se à utilização do AppCDS:

  • O arquivo AppCDS a ser reutilizado tem de ser reproduzido exatamente pela mesma distribuição, versão e arquitetura do OpenJDK que foi originalmente usado para o produzir.
  • Tem de executar a sua aplicação, pelo menos, uma vez para gerar a lista de classes a partilhar e, em seguida, usar essa lista para gerar o arquivo AppCDS.
  • A cobertura das classes depende do caminho de código executado durante a execução da aplicação. Para aumentar a cobertura, acione programaticamente mais caminhos de código.
  • A aplicação tem de ser fechada com êxito para gerar esta lista de turmas. Considere implementar uma flag de aplicação que seja usada para indicar a geração do arquivo AppCDS e, assim, poder sair imediatamente.
  • O arquivo AppCDS só pode ser reutilizado se iniciar novas instâncias exatamente da mesma forma que o arquivo foi gerado.
  • O arquivo AppCDS só funciona com um pacote de ficheiros JAR normal. Não pode usar JARs aninhados.

Exemplo do Spring Boot com um ficheiro JAR sombreado

As aplicações Spring Boot usam um JAR abrangente aninhado por predefinição, que não funciona para o AppCDS. Assim, se estiver a usar o AppCDS, tem de criar um JAR sombreado. Por exemplo, usando o Maven e o Maven Shade Plugin:

<build>
  <finalName>helloworld</finalName>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <configuration>
        <keepDependenciesWithProvidedScope>true</keepDependenciesWithProvidedScope>
        <createDependencyReducedPom>true</createDependencyReducedPom>
        <filters>
          <filter>
            <artifact>*:*</artifact>
            <excludes>
              <exclude>META-INF/*.SF</exclude>
              <exclude>META-INF/*.DSA</exclude>
              <exclude>META-INF/*.RSA</exclude>
            </excludes>
          </filter>
        </filters>
      </configuration>
      <executions>
        <execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <transformers>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.handlers</resource>
              </transformer>
              <transformer implementation="org.springframework.boot.maven.PropertiesMergingResourceTransformer">
                <resource>META-INF/spring.factories</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.schemas</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
              <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>${mainClass}</mainClass>
              </transformer>
            </transformers>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

Se o seu JAR sombreado contiver todas as dependências, pode produzir um arquivo simples durante a compilação do contentor através de um Dockerfile:

# Use Docker's multi-stage build
FROM adoptopenjdk:11-jre-hotspot as APPCDS

COPY target/helloworld.jar /helloworld.jar

# Run the application, but with a custom trigger that exits immediately.
# In this particular example, the application looks for the '--appcds' flag.
# You can implement a similar flag in your own application.
RUN java -XX:DumpLoadedClassList=classes.lst -jar helloworld.jar --appcds=true

# From the captured list of classes (based on execution coverage),
# generate the AppCDS archive file.
RUN java -Xshare:dump -XX:SharedClassListFile=classes.lst -XX:SharedArchiveFile=appcds.jsa --class-path helloworld.jar

FROM adoptopenjdk:11-jre-hotspot

# Copy both the JAR file and the AppCDS archive file to the runtime container.
COPY --from=APPCDS /helloworld.jar /helloworld.jar
COPY --from=APPCDS /appcds.jsa /appcds.jsa

# Enable Application Class-Data sharing
ENTRYPOINT java -Xshare:on -XX:SharedArchiveFile=appcds.jsa -jar helloworld.jar

Desativar a validação de turmas

Quando a JVM carrega classes na memória para execução, verifica se a classe não foi adulterada e não tem edições nem corrupções maliciosas. Se a sua pipeline de entrega de software for fidedigna (por exemplo, pode verificar e validar todas as saídas), se puder confiar totalmente no bytecode na sua imagem de contentor e a sua aplicação não carregar classes de origens remotas arbitrárias, pode considerar desativar a validação. Desativar a validação pode melhorar a velocidade de arranque se um grande número de classes for carregado no momento do arranque.

Para um serviço de publicação do Knative, configure a variável de ambiente:

JAVA_TOOL_OPTIONS="-noverify"

Reduzir o tamanho da pilha de threads

A maioria das aplicações Web Java baseia-se em threads por ligação. Cada thread Java consome memória nativa (não na heap). Isto é conhecido como a pilha de threads e tem um valor predefinido de 1 MB por thread. Se a sua aplicação processar 80 pedidos em simultâneo, pode ter, pelo menos, 80 threads, o que se traduz em 80 MB de espaço de pilha de threads usado. A memória é adicional ao tamanho da área dinâmica para dados. O valor predefinido pode ser superior ao necessário. Pode reduzir o tamanho da pilha de threads.

Se reduzir demasiado, é apresentado o erro java.lang.StackOverflowError. Pode criar um perfil da sua aplicação e encontrar o tamanho da pilha de threads ideal para configurar.

Para um serviço de publicação do Knative, configure a variável de ambiente:

JAVA_TOOL_OPTIONS="-Xss256k"

Reduzir discussões

Pode otimizar a memória reduzindo o número de threads, usando estratégias reativas sem bloqueio e evitando atividades em segundo plano.

Reduzir o número de discussões

Cada thread Java pode aumentar a utilização de memória devido à pilha de threads. O Knative serving permite um máximo de 80 pedidos simultâneos. Com o modelo de um thread por ligação, precisa de, no máximo, 80 threads para processar todos os pedidos simultâneos. A maioria dos servidores Web e frameworks permite configurar o número máximo de threads e ligações. Por exemplo, no Spring Boot, pode limitar o número máximo de ligações no ficheiro applications.properties:

server.tomcat.max-threads=80

Escrever código reativo não bloqueador para otimizar a memória e o arranque

Para reduzir verdadeiramente o número de threads, considere adotar um modelo de programação reativo sem bloqueio, para que o número de threads possa ser significativamente reduzido ao processar mais pedidos simultâneos. As frameworks de aplicações, como o Spring Boot, com Webflux, Micronaut e Quarkus, suportam aplicações Web reativas.

As frameworks reativas, como o Spring Boot com Webflux, o Micronaut e o Quarkus, têm geralmente tempos de arranque mais rápidos.

Se continuar a escrever código de bloqueio numa framework sem bloqueio, o débito e as taxas de erro serão significativamente piores num serviço do Knative Serving. Isto deve-se ao facto de as frameworks não bloqueadoras terem apenas alguns threads, por exemplo, 2 ou 4. Se o seu código estiver a bloquear, só pode processar muito poucos pedidos em simultâneo.

Estas frameworks não bloqueadoras também podem transferir código de bloqueio para um conjunto de threads ilimitado, o que significa que, embora possa aceitar muitos pedidos simultâneos, o código de bloqueio é executado em novas threads. Se as threads se acumularem de forma ilimitada, esgota o recurso de CPU e começa a ter um desempenho instável. A latência é gravemente afetada. Se usar uma framework não bloqueadora, certifique-se de que compreende os modelos de conjunto de threads e limita os conjuntos em conformidade.

Evitar atividades em segundo plano

O Knative Serving limita a CPU de uma instância quando esta deixa de receber pedidos. As cargas de trabalho tradicionais que têm tarefas em segundo plano requerem consideração especial quando executadas no Knative Serving.

Por exemplo, se estiver a recolher métricas de aplicações e a processá-las em lote em segundo plano para envio periódico, essas métricas não são enviadas quando a CPU está limitada. Se a sua aplicação estiver constantemente a receber pedidos, pode ver menos problemas. Se a sua aplicação tiver um QPS baixo, a tarefa em segundo plano pode nunca ser executada.

Alguns padrões bem conhecidos que são executados em segundo plano e aos quais tem de prestar atenção:

  • JDBC Connection Pools - clean ups and connection checks usually happens in the background
  • Remetentes de rastreio distribuído: normalmente, os rastreios distribuídos são processados em lote e enviados periodicamente ou quando o buffer está cheio em segundo plano.
  • Remetentes de métricas: normalmente, as métricas são processadas em lote e enviadas periodicamente em segundo plano.
  • Para o Spring Boot, todos os métodos anotados com a anotação @Async
  • Temporizadores: quaisquer acionadores baseados em temporizadores (por exemplo, ScheduledThreadPoolExecutor, Quartz ou @Scheduled Spring annotation) podem não ser executados quando as CPUs estão limitadas.
  • Recetores de mensagens: por exemplo, os clientes de obtenção de streaming do Pub/Sub, os clientes JMS ou os clientes Kafka são normalmente executados em threads em segundo plano sem necessidade de pedidos. Estas não funcionam quando a sua aplicação não tem pedidos. A receção de mensagens desta forma não é recomendada no Knative serving.

Otimizações de aplicações

No código do serviço Knative serving, também pode otimizar para tempos de arranque e utilização de memória mais rápidos.

Reduzir as tarefas de arranque

As aplicações Web baseadas em Java tradicionais podem ter muitas tarefas a concluir durante o arranque, por exemplo, o pré-carregamento de dados, o aquecimento da cache, o estabelecimento de pools de ligações, etc. Quando executadas sequencialmente, estas tarefas podem ser lentas. No entanto, se quiser que sejam executados em paralelo, deve aumentar o número de núcleos do CPU.

Atualmente, o Knative serving envia um pedido de utilizador real para acionar uma instância de arranque a frio. Os utilizadores que têm um pedido atribuído a uma instância iniciada recentemente podem sofrer longos atrasos. Atualmente, o Knative serving não tem uma verificação de "prontidão" para evitar o envio de pedidos a aplicações não prontas.

Usar o agrupamento de ligações

Se usar conjuntos de ligações, tenha em atenção que estes podem remover ligações desnecessárias em segundo plano (consulte a secção Evitar tarefas em segundo plano). Se a sua aplicação tiver um QPS baixo e puder tolerar uma latência elevada, considere abrir e fechar ligações por pedido. Se a sua aplicação tiver um QPS elevado, as remoções em segundo plano podem continuar a ser executadas desde que existam pedidos ativos.

Em ambos os casos, o acesso à base de dados da aplicação é limitado pelo número máximo de ligações permitidas pela base de dados. Calcule o número máximo de ligações que pode estabelecer por instância do Knative serving e configure o número máximo de instâncias do Knative serving de modo que o número máximo de instâncias multiplicado pelas ligações por instância seja inferior ao número máximo de ligações permitidas.

Usar o Spring Boot

Se usar o Spring Boot, tem de considerar as seguintes otimizações

Usar a versão 2.2 ou superior do Spring Boot

A partir da versão 2.2, o Spring Boot foi fortemente otimizado para a velocidade de arranque. Se estiver a usar versões do Spring Boot inferiores a 2.2, pondere fazer a atualização ou aplicar otimizações individuais manualmente.

Usar a inicialização em diferido

Existe uma flag de inicialização tardia global que pode ser ativada no Spring Boot 2.2 e superior. Isto melhora a velocidade de arranque, mas com a desvantagem de que o primeiro pedido pode ter uma latência mais longa porque tem de aguardar que os componentes sejam inicializados pela primeira vez.

Pode ativar a inicialização em diferido em application.properties:

spring.main.lazy-initialization=true

Em alternativa, através de uma variável de ambiente:

SPRING_MAIN_LAZY_INITIALIZATIION=true

No entanto, se estiver a usar min-instances, a inicialização tardia não vai ajudar, uma vez que a inicialização deve ter ocorrido quando a min-instance foi iniciada.

Evitar a leitura de turmas

A análise de classes provoca leituras de disco adicionais no Knative serving porque, no Knative serving, o acesso ao disco é geralmente mais lento do que numa máquina normal. Certifique-se de que a análise de componentes é limitada ou totalmente evitada. Considere usar o Spring Context Indexer para pré-gerar um índice. Se isto vai melhorar a velocidade de início, varia consoante a sua aplicação.

Por exemplo, no Maven, pom.xmladicione a dependência do indexador (na verdade, é um processador de anotações):

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context-indexer</artifactId>
  <optional>true</optional>
</dependency>

Usar as ferramentas para programadores do Spring Boot que não estão em produção

Se usar a ferramenta para programadores do Spring Boot durante o desenvolvimento, certifique-se de que não está incluída na imagem do contentor de produção. Isto pode acontecer se tiver criado a aplicação Spring Boot sem os plug-ins de compilação do Spring Boot (por exemplo, usando o plug-in Shade ou usando o Jib para criar contentores).

Nestes casos, certifique-se de que a ferramenta de compilação exclui explicitamente a ferramenta de programador do Spring Boot. Em alternativa, desative explicitamente a ferramenta para programadores do Spring Boot.

O que se segue?

Para mais sugestões, consulte