Como otimizar aplicativos em Java

Neste guia, descrevemos otimizações para serviços do Cloud Run for Anthos escritos na linguagem de programação Java, além de informações básicas para ajudar você a entender as contrapartidas envolvidas em algumas das otimizações. As informações desta página complementam as dicas gerais de otimização, que também se aplicam ao Java.

Os aplicativos tradicionais em Java baseados na Web foram projetados para atender a solicitações com alta simultaneidade e baixa latência e tendem a ser aplicativos de longa duração. A própria JVM também otimiza o código de execução ao longo do tempo com o JIT. Assim, os hot paths são otimizados e os aplicativos são executados com mais eficiência ao longo do tempo.

Muitas das práticas recomendadas e otimizações desses aplicativos tradicionais em Java baseados na Web giram em torno de:

  • como processar solicitações simultâneas (E/S com base em linhas de execução e sem bloqueio);
  • como reduzir a latência de resposta usando o pool de conexões e agrupando em lote funções não críticas, como o envio de traces e métricas para tarefas em segundo plano.

Muitas dessas otimizações tradicionais funcionam bem para aplicativos de longa duração, mas elas podem não funcionar tão bem em um serviço do Cloud Run for Anthos, que só é executado quando atende ativamente às solicitações. Nesta página, você verá algumas otimizações e compensações diferentes no Cloud Run for Anthos que podem ser usadas para reduzir o tempo de inicialização e o uso de memória.

Como otimizar a imagem do contêiner

Ao otimizar a imagem do contêiner, é possível reduzir os tempos de carregamento e de inicialização. É possível otimizar a imagem:

  • minimizando a imagem do contêiner;
  • evitando o uso de JARs de arquivos de bibliotecas aninhadas;
  • Como usar o Jib

Como minimizar a imagem do contêiner

Veja mais contexto sobre esse problema na página de dicas gerais sobre como minimizar o contêiner. A página de dicas gerais recomenda reduzir o conteúdo da imagem do contêiner a apenas o necessário. Por exemplo, verifique se a imagem do contêiner não contém:

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

Se você estiver criando o código a partir de um Dockerfile, use a versão de vários estágios do Docker para que a imagem final do contêiner tenha apenas o JRE e o próprio arquivo JAR do aplicativo.

Como evitar JARs de arquivos de bibliotecas aninhadas

Alguns frameworks conhecidos, como o Spring Boot, criam um arquivo de aplicativo (JAR, na sigla em inglês) que contém outros arquivos JAR de biblioteca (JARs aninhados). Esses arquivos precisam ser descompactados ou descomprimidos durante o tempo de inicialização e podem aumentar a velocidade de inicialização no Cloud Run for Anthos. Quando possível, crie um JAR thin com bibliotecas externas: isso pode ser automatizado usando o Jib para conteinerizar seu aplicativo.

Como usar o Jib

Use o plug-in Jib para criar um contêiner mínimo e nivelar o arquivo do aplicativo automaticamente. O Jib funciona com o Maven e o Gradle e com aplicativos Spring Boot prontos para uso. Alguns frameworks de aplicativos podem exigir outras configurações do Jib.

Otimizações da JVM

Otimizar a JVM para um serviço do Cloud Run for Anthos pode melhorar o desempenho e o uso da memória.

Como usar versões da JVM com reconhecimento de contêiner

Em VMs e máquinas, para alocações de CPU e memória, a JVM entende a CPU e a memória que podem ser usadas a partir de locais conhecidos, como no Linux, /proc/cpuinfo e /proc/meminfo. No entanto, ao executar em um contêiner, as restrições de CPU e memória ficam armazenadas em /proc/cgroups/.... Uma versão mais antiga do JDK continua verificando /proc em vez de /proc/cgroups, o que pode resultar em mais uso de CPU e memória do que foi atribuído. Isso pode causar:

  • Um número excessivo de linhas de execução porque o tamanho do pool de linhas de execução está configurado por Runtime.availableProcessors()
  • Um heap máximo padrão que excede o limite de memória do contêiner. A JVM usa agressivamente a memória antes de coletar o lixo. Isso pode facilmente fazer com que o contêiner exceda o limite de memória e cause um OOMKill.

Portanto, use uma versão da JVM com reconhecimento de contêiner. As versões do OpenJDK posteriores ou iguais à versão 8u192 têm reconhecimento de contêiner por padrão.

Como entender o uso de memória da JVM

O uso de memória da JVM é composto de uso de memória nativa e uso de heap. A memória de trabalho do aplicativo geralmente está no heap. O tamanho do heap é restringido pela configuração de heap máximo. Com uma instância de 256 MB de RAM do Cloud Run for Anthos, não é possível atribuir todos os 256 MB ao heap máximo, porque a JVM e o SO também exigem memória nativa, por exemplo, pilha de linhas de execução, caches de código, processamentos de arquivos, buffers etc. Se seu aplicativo estiver em condição OOMKilled e você precisar saber o uso da memória da JVM (memória nativa + heap), ative o rastreamento de memória nativa para ver os usos em uma saída de aplicativo bem-sucedida. Se ocorrer OOMKill no aplicativo, não será possível imprimir as informações. Nesse caso, execute o aplicativo com mais memória primeiro para que ele possa gerar a saída.

O rastreamento de memória nativa não pode ser ativado por meio da variável de ambiente JAVA_TOOL_OPTIONS. É necessário adicionar o argumento de inicialização da linha de comando do Java ao ponto de entrada da imagem do contêiner para que seu aplicativo seja iniciado com estes argumentos:

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

O uso de memória nativa pode ser estimado com base no número de classes a serem carregadas. Use uma calculadora de memória Java de código aberto para estimar as necessidades de memória.

Como desativar o compilador de otimização

Por padrão, a JVM tem várias fases da compilação JIT. Essas fases melhoram a eficiência do aplicativo ao longo do tempo, mas também podem gerar sobrecarga no uso da memória e aumentar o tempo de inicialização.

Para aplicativos de curta duração e sem servidor (por exemplo, funções), desative as fases de otimização para trocar a eficiência de longo prazo por um tempo de inicialização reduzido.

Para um serviço do Cloud Run for Anthos, configure a variável de ambiente:

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

Como usar o compartilhamento de dados de classe do aplicativo

Para reduzir ainda mais o uso de tempo e memória do JIT, considere usar o compartilhamento de dados de classe do aplicativo (AppCDS, na sigla em inglês) para compartilhar antecipadamente as classes Java compiladas como um arquivo. O arquivo AppCDS pode ser reutilizado ao iniciar outra instância do mesmo aplicativo Java. A JVM pode reutilizar os dados pré-computados do arquivo, o que reduz o tempo de inicialização.

As considerações a seguir se aplicam ao uso do AppCDS:

  • O arquivo AppCDS a ser reutilizado precisa ser reproduzido exatamente pela mesma distribuição, versão e arquitetura do OpenJDK usadas originalmente para criá-lo.
  • É preciso executar seu aplicativo pelo menos uma vez para gerar a lista de classes a serem compartilhadas e, em seguida, usar essa lista para gerar o arquivo AppCDS.
  • A cobertura das classes depende do codepath executado durante a execução do aplicativo. Para aumentar a cobertura, acione programaticamente mais codepaths.
  • O aplicativo precisa ser encerrado corretamente para gerar essa lista de classes. Considere a implementação de uma sinalização de aplicativo usada para indicar a geração do arquivo AppCDS. Assim, ela pode ser encerrada imediatamente.
  • O arquivo AppCDS só poderá ser reutilizado se você iniciar novas instâncias exatamente da mesma maneira que o arquivo foi gerado.
  • O arquivo AppCDS funciona somente com um pacote de arquivos JAR normal. Não é possível usar JARs aninhados.

Exemplo do Spring Boot usando um arquivo JAR sombreado

Por padrão, os aplicativos do Spring Boot usam um JAR uber aninhado, que não funcionará para o AppCDS. Portanto, se você estiver usando o AppCDS, será preciso criar um JAR sombreado. Por exemplo, usando o Maven e o plug-in Maven Shade:

<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 JAR sombreado contiver todas as dependências, será possível produzir um arquivo simples durante o build do contêiner usando 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

Como desativar a verificação de classe

Quando a JVM carrega classes na memória para execução, ela verifica se a classe foi alterada, corrompida ou tem edições maliciosas. Se o pipeline de entrega de software for confiável (por exemplo, é possível verificar e validar cada saída), se o bytecode da imagem do contêiner for totalmente confiável e seu aplicativo não carregar classes de fontes remotas arbitrárias, você poderá desativar a verificação. A desativação da verificação pode melhorar a velocidade de inicialização se um grande número de classes for carregado no momento da inicialização.

Para um serviço do Cloud Run for Anthos, configure a variável de ambiente:

JAVA_TOOL_OPTIONS="-noverify"

Como reduzir o tamanho da pilha de linhas de execução

A maioria dos aplicativos da Web em Java são baseados em linhas de execução por conexão. Cada linha de execução Java consome memória nativa (e não em heap). Isso é conhecido como pilha de linhas de execução, e o padrão é 1 MB por linha de execução. Se o aplicativo gerenciar 80 solicitações simultâneas, ele poderá ter pelo menos 80 linhas de execução, o que representa o uso de 80 MB de espaço da pilha de linha de execução. A memória é um acréscimo ao tamanho do heap. O padrão pode ser maior que o necessário. É possível reduzir o tamanho da pilha de linhas de execução.

Se você reduzir muito, verá java.lang.StackOverflowError. É possível criar o perfil do aplicativo e encontrar o tamanho ideal da pilha de linhas de execução para configurar.

Para um serviço do Cloud Run for Anthos, configure a variável de ambiente:

JAVA_TOOL_OPTIONS="-Xss256k"

Como reduzir as linhas de execução

Para otimizar a memória, reduza o número de linhas de execução. Para isso, use estratégias reativas sem bloqueios e evite atividades em segundo plano.

Como reduzir o número de linhas de execução

Cada linha de execução Java pode aumentar o uso de memória devido à pilha de linhas de execução. O Cloud Run for Anthos permite no máximo 80 solicitações simultâneas. Com o modelo de linhas de execução por conexão, você precisa de no máximo 80 linhas para processar todas as solicitações simultâneas. A maioria dos servidores e frameworks da Web permite configurar o número máximo de linhas de execução e conexões. Por exemplo, no Spring Boot, você pode limitar o número máximo de conexões no arquivo applications.properties:

server.tomcat.max-threads=80

Como escrever código reativo sem bloqueios para otimizar a memória e a inicialização

Para realmente reduzir o número de linhas de execução, adote um modelo de programação reativa sem bloqueios a fim de reduzir esse número de maneira significativa ao processar mais solicitações simultâneas. Frameworks de aplicativos como o Spring Boot com Webflux, Micronaut e Quarkus são compatíveis com aplicativos da Web reativos.

Frameworks reativos, como Spring Boot com Webflux, Micronaut e Quarkus costumam ter tempos de inicialização mais rápidos.

Se você continuar gravando código de bloqueio em um framework sem bloqueios, as taxas de transferência e erro serão significativamente piores em um serviço do Cloud Run for Anthos. Isso ocorre porque os frameworks sem bloqueios terão apenas algumas linhas de execução, como 2 ou 4. Se o código estiver bloqueando, ele poderá processar poucas solicitações simultâneas.

Esses frameworks sem bloqueios também podem descarregar o código de bloqueio para um pool de linhas de execução ilimitado. Isso significa que, embora possa aceitar muitas solicitações simultâneas, o código de bloqueio será executado em novas linhas de execução. Se as linhas de execução se acumularem de forma ilimitada, você esgotará o recurso da CPU e começará a sofrer sobrecarga. A latência será gravemente afetada. Se você usar um framework sem bloqueios, precisará entender os modelos de pool de linhas de execução e vincular os pools adequadamente.

Como evitar atividades em segundo plano

O Cloud Run for Anthos limita uma CPU de instância quando essa instância não recebe mais solicitações. As cargas de trabalho tradicionais que têm tarefas em segundo plano precisam ser consideradas especiais ao serem executadas no Cloud Run for Anthos.

Por exemplo, se você coletar métricas de aplicativos e enviar em lote as métricas em segundo plano periodicamente, elas não serão enviadas quando a CPU estiver limitada. Se o aplicativo receber solicitações constantemente, talvez ocorram menos problemas. Se o aplicativo tiver QPS baixa, talvez a tarefa em segundo plano não seja executada.

Preste atenção em alguns padrões conhecidos que ficam em segundo plano, como:

  • Pools de conexão JDBC: limpeza e verificações de conexão costumam ocorrer em segundo plano.
  • Remetentes de traces distribuídos: os traces distribuídos geralmente são agrupados e enviados em lote periodicamente ou quando o buffer está cheio em segundo plano.
  • Remetentes de métricas: as métricas geralmente são agrupadas e enviadas em lote periodicamente em segundo plano.
  • No caso do Spring Boot, todos os métodos anotados com a anotação @Async.
  • Timers: qualquer gatilho baseado em timer (por exemplo, ScheduledThreadPoolExecutor, Quartz ou a anotação do Spring @Scheduled) pode não ser executado quando as CPUs estão limitadas.
  • Destinatários de mensagens: por exemplo, clientes de pull de streaming do Pub/Sub, clientes do JMS ou clientes do Kafka geralmente são executados nas linhas de execução em segundo plano sem precisar de solicitações. Isso não funcionará quando seu aplicativo não tiver solicitações. Receber mensagens dessa maneira não é recomendado no Cloud Run for Anthos.

Otimizações de aplicativos

No código de serviço do Cloud Run for Anthos, também é possível otimizar para tempos de inicialização e uso de memória mais rápidos.

Como reduzir tarefas de inicialização

Os aplicativos tradicionais em Java baseados na Web podem ter muitas tarefas a serem concluídas durante a inicialização, como pré-carregamento de dados, aquecimento do cache, estabelecimento de pools de conexão etc. Essas tarefas, quando executadas sequencialmente, podem ser lentas. No entanto, se você quiser que elas sejam executadas em paralelo, aumente o número de núcleos de CPU.

O Cloud Run for Anthos atualmente envia uma solicitação de usuário real para acionar uma instância de inicialização a frio. Os usuários que têm uma solicitação atribuída a uma instância recém-iniciada podem enfrentar atrasos demorados. O Cloud Run for Anthos ainda não tem uma verificação de "prontidão" para evitar o envio de solicitações a aplicativos não lidos.

Como usar o pool de conexões

Se você estiver usando pools de conexão, lembre-se de que eles podem remover conexões desnecessárias em segundo plano. Veja Como evitar tarefas em segundo plano. Se o aplicativo tiver QPS baixa e puder tolerar alta latência, abra e feche as conexões por solicitação. Se o aplicativo tiver QPS alta, talvez as remoções em segundo plano continuem sendo executadas, desde que haja solicitações ativas.

Em ambos os casos, o acesso ao banco de dados do aplicativo sofrerá um gargalo devido ao limite de conexões permitidas pelo banco de dados. Calcule o máximo de conexões que podem ser estabelecidas por instância do Cloud Run for Anthos e configure o número máximo de instâncias do Cloud Run for Anthos para que o número máximo de instâncias multiplicado por conexões por instância seja menor que o máximo de conexões permitidas.

Como usar o Spring Boot

Se você usa o Spring Boot, precisa considerar as seguintes otimizações.

Como usar o Spring Boot versão 2.2 ou posterior

A partir da versão 2.2, o Spring Boot foi extremamente otimizado quanto à velocidade de inicialização. Se você estiver usando versões do Spring Boot anteriores à 2.2, faça um upgrade ou execute otimizações individuais manualmente.

Como usar a inicialização lenta

Há uma sinalização de inicialização lenta global que pode ser ativada no Spring Boot 2.2 e posterior. Isso melhora a velocidade de inicialização, mas com a contrapartida de que a primeira solicitação pode ter uma latência maior porque é necessário aguardar a inicialização dos componentes pela primeira vez.

Você pode ativar a inicialização lenta em application.properties:

spring.main.lazy-initialization=true

Também é possível usar uma variável de ambiente:

SPRING_MAIN_LAZY_INITIALIZATIION=true

No entanto, se você estiver usando instâncias mínimas, a inicialização lenta não ajudará, porque a inicialização deveria ter ocorrido quando as instâncias mínimas foram inicializadas.

Como evitar a verificação de classe

A verificação de classe gerará mais leituras do disco no Cloud Run for Anthos porque, nele, o acesso ao disco geralmente é mais lento que uma máquina normal. Certifique-se de que a verificação de componentes seja limitada ou completamente evitada. Use o Spring Context Indexer para pré-gerar um índice. A melhora na velocidade de inicialização dependerá do aplicativo.

Por exemplo, no pom.xml do Maven, adicione 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>

Como usar as ferramentas de desenvolvedor do Spring Boot que não estão em produção

Se você usar as Ferramentas para desenvolvedores do Spring Boot durante o desenvolvimento, certifique-se de que não sejam empacotadas na imagem do contêiner de produção. Isso pode acontecer se você tiver criado o aplicativo Spring Boot sem os plug-ins de build do Spring Boot (por exemplo, usando o plug-in Shade ou o Jib para conteinerização).

Nesses casos, verifique se a ferramenta de build exclui explicitamente a ferramenta Spring Boot Dev. Se preferir, desative explicitamente a Ferramenta para desenvolvedores do Spring Boot.

A seguir

Veja mais dicas em