Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade de serviço

Utilize Java em tempo real para reduzir a variabilidade em aplicativos Java

Alguns aplicativos Java™ falham ao fornecer qualidade razoável de serviço apesar de alcançar outros objetivos de desempenho, como latência média ou rendimento geral. Ao introduzir pausas ou interrupções que estão fora do controle do aplicativo, a linguagem Java e o sistema de runtime podem algumas vezes ser responsáveis por uma incapacidade do serviço de atender às métricas de desempenho de serviço. Este artigo, segundo de uma série de três, explica as origens do problema de atrasos e interrupções em uma JVM e descreve técnicas que podem ser utilizadas para mitigá-las para que seus aplicativos entreguem qualidade de serviço mais consistente.

Mark Stoodley, Advisory Software Developer, IBM Toronto Lab

Mark Stoodley received his Ph.D. in computer engineering from the University of Toronto in 2001 and joined the IBM Toronto Lab in 2002 to work on the Java JIT compilation technologies developed there. Since early 2005, he has worked on the JIT technology for IBM WebSphere Real Time by adapting the existing JIT compiler to operate in real-time environments. He is now the team lead of the Java compilation control team, where he works to improve the effectiveness of native code compilation for its execution environment. Outside of IBM, he enjoys renovating his home.



Charlie Gracie, J9 Garbage Collection Team Lead, IBM  

Charlie Gracie joined the IBM Ottawa Lab in 2004 to work on the J9 Virtual Machine team, after graduating with a BCS degree from the University of New Brunswick.



25/Set/2009

A variabilidade em um aplicativo Java —normalmente causa pausas ou atrasos que ocorrem em momentos imprevisíveis —e que podem ocorrer em toda a pilha de software. Os atrasos podem ser causados por:

  • Hardware (durante processos como armazenamento em cache)
  • Firmware (processamento de interrupções de gerenciamento de sistema como dados de temperatura da CPU)
  • Sistema operacional (respondendo a uma interrupção ou executando uma atividade daemon regularmente planejada)
  • Outros programas em execução no mesmo sistema
  • A JVM (coleta de lixo, compilação Just-in-time e carga de classe)
  • O aplicativo Java em si

Você raramente pode compensar em um nível superior os atrasos introduzidos por um nível inferior, portanto, se você tentar solucionar a variabilidade apenas no nível do aplicativo, pode simplesmente alternar entre os atrasos da JVM ou do SO sem solucionar o problema real. Felizmente, as latências para níveis inferiores tendem a ser relativamente menores do que as dos níveis superiores, assim, somente se o seu requisito para reduzir a variabilidade for extremamente alto você precisa olhar abaixo da JVM ou do SO. Se seus requisitos não forem tão altos, você provavelmente pode focar seus esforços no nível da JVM e em seu aplicativo.

Java em tempo real oferece a você as ferramentas necessárias para combater as fontes de variabilidade em uma JVM e em seus aplicativos para oferecer a qualidade de serviço que seus usuários precisam. Este artigo cobre as fontes de variabilidade nos níveis da JVM e do aplicativo em detalhes e descreve as ferramentas e técnicas que você pode utilizar para mitigar seus efeitos. Então ele apresenta um aplicativo do servidor Java simples que demonstra alguns desses conceitos.

Indicando as fontes de variabilidade

As fontes primárias de variabilidade em uma JVM têm origem na natureza dinâmica da linguagem Java:

  • A memória nunca é explicitamente liberada pelo aplicativo, mas é periodicamente reclamada pelo coletor de lixo.
  • As classes são resolvidas quando o aplicativo as utiliza pela primeira vez.
  • O código nativo é compilado (e pode ser recompilado) por um compilador Just-in-time (JIT) enquanto o aplicativo é executado com base em quais classes e métodos são frequentemente invocados.

No nível do aplicativo Java, o gerenciamento de encadeamento é a área chave relacionada à variabilidade.

Pausas na coleta de lixo

Quando o coletor de lixo é executado para reclamar a memória que não é mais utilizada pelo programa, ele pode parar todos os encadeamentos de aplicativo. (Esse tipo de coletor é conhecido como coletor Stop-the-world ou STW.) Ou pode realizar certa quantidade de seu trabalho simultaneamente com o aplicativo. Em qualquer caso, os recursos que o coletor de lixo precisa não estão disponíveis para o aplicativo, assim, a coleta de lixo (GC) é um a origem de pausas e variabilidade no desempenho do aplicativo Java, como em geral já se sabe. Embora cada um dos vários modelos GC tenham seus prós e contras, quando o objetivo para um aplicativo é pausas curtas de GC, as duas principais escolhas são coletores de geração e de tempo real.

Os coletores de geração organizam o heap em pelo menos duas seções normalmente chamadas de espaço novo e antigo (às vezes chamado de permanente). Novos objetos sempre são alocados no espaço novo. Quando o espaço novo fica sem memória livre, o lixo é coletado somente naquele espaço. O uso de um espaço novo relativamente pequeno pode manter o ciclo operacional comum de GC bastante pequeno. Os objetos que sobrevivem a algum número de coletas de novo espaço são promovidos para o espaço antigo. As coletas no espaço antigo normalmente ocorrem com muito menos frequência do que as coletas no novo espaço, mas como o espaço antigo é muito maior que o novo, esses ciclos de GC demoram muito mais. Os coletores de lixo de geração oferecem pausas de GC em média relativamente curtas, mas o custo das coletas no espaço antigo pode fazer com que o desvio do padrão desses períodos de pausa seja muito grande. Os coletores de geração são os mais efetivos em aplicativos para os quais o conjunto de dados ativos não muda ao longo do tempo, mas que geram muito lixo. Nesse cenário, as coletas no espaço antigo são extremamente raras, logo os tempos de pausa de GC devem-se às curtas coletas no espaço novo.

Em contraste aos coletores de geração, os coletores de lixo em tempo real controlam seu comportamento para reduzir consideravelmente o comprimento dos ciclos de GC (explorando ciclos quando o aplicativo está ocioso) ou para reduzir o impacto desses ciclos sobre o desempenho do aplicativo (realizando o trabalho em pequenos incrementos de acordo com um "contrato" com o aplicativo). Utilizar um desses coletores permite que você antecipe o pior caso para concluir uma tarefa específica. Por exemplo, o coletor de lixo no IBM® WebSphere® Real-Time JVMs divide os ciclos de GC em pequenas porções de trabalho— chamadas GC quanta — que podem ser concluídas de maneira incremental. O planejamento de quanta tem um impacto extremamente baixo sobre o desempenho do aplicativo, o que gera atrasos de centenas de microssegundos, mas normalmente menos que 1 milissegundo. Para conseguir esses tipos de atrasos, o coletor de lixo deve ser capaz de planejar seu trabalho introduzindo o conceito de um contrato de utilização de aplicativo. Esse contrato rege a frequência com que o GC pode interromper o aplicativo para realizar seu trabalho. Por exemplo, o contrato de utilização padrão é 70% que permite que o GC utilize somente até 3 ms de cada 10 ms, com pausas típicas de cerca de 500 microssegundos ao ser executado em um sistema operacional em tempo real. (Veja em "Real-time Java, Part 4: Real-time garbage collection" para uma descrição detalhada da operação do coletor de lixo do IBM WebSphere Real Time.)

O tamanho do heap e utilização do aplicativo são importantes opções de ajuste para considerar ao executar um aplicativo em um coletor de lixo em tempo real. Como a utilização do aplicativo é aumentada, o coletor de lixo recebe menos tempo para concluir seu trabalho, assim, um heap maior é necessário para assegurar que o ciclo de GC seja concluído de maneira incremental. Se o coletor de lixo não puder acompanhar a taxa de alocação, o GC volta à coleta síncrona.

Por exemplo, um aplicativo executado no IBM WebSphere Real-Time JVMs, com sua utilização de aplicativo padrão de 70%, requer mais heap por padrão do que se estivesse executando em uma JVM utilizando um coletor de lixo de geração (que não oferece contrato de utilização). Como os coletores de lixo em tempo real controlam o comprimento das pausas de GC, aumentar o tamanho do heap diminui a frequência de GC sem tornar os tempos individuais de pausa mais longos. Em coletores de lixo que não sejam em tempo real, por outro lado, aumentar o tamanho do heap normalmente reduz a frequência dos ciclos de GC, que baixa o impacto geral do coletor de lixo; quando ocorre um ciclo de GC, as pausas são geralmente maiores (porque há mais heap para examinar).

No IBM WebSphere Real Time JVMs, é possível ajustar o tamanho do heap com a opção -Xmx<size>. Por exemplo, -Xmx512m especifica um heap de 512MB. Também é possível ajustar a utilização do aplicativo. Por exemplo, -Xgc:targetUtilization=80 ajusta a 80%.

Pausas de carregamento da classe Java

A especificação da linguagem Java requer que as classes sejam resolvidas, carregadas, verificadas e inicializadas quando um aplicativo faz a primeira referência a elas. Se a primeira referência a uma classe C ocorrer durante uma operação crítica de tempo, então o tempo para resolver, verificar, carregar e inicializar C pode fazer com que a operação demore mais que o esperado. Como o carregamento de C inclui a verificação dessa classe, —que pode exigir que outras classes sejam carregadas, —o atraso total a que um aplicativo Java incorre para ser capaz de usar uma classe particular pela primeira vez pode ser significativamente mais longo que o esperado.

Por que uma classe deve ser referenciada pela primeira vez mais tarde em um aplicativo em execução? Raramente os caminhos executados são uma razão comum para o carregamento de uma nova classe. Por exemplo, o código na Listagem 1 contém uma condição if que pode executar raramente. (Exceção e tratamento de erro foram omitidos em sua maior parte, por questões de concisão, de todas as listagens do artigo.)

Listagem 1. Exemplo de uma condição raramente executada carregando uma nova classe
Iterator<MyClass> cursor = list.iterator();
while (cursor.hasNext()) {
    MyClass o = cursor.next();
    if (o.getID() == 17) {
        NeverBeforeLoadedClass o2 = new NeverBeforeLoadedClass(o);
        // do something with o2
    }
    else {
        // do something with o
    }
}

Classes de exceção são outro exemplo de classes que podem não carregar até que estejam bem dentro de uma execução do aplicativo, pois as exceções são idealmente (embora nem sempre) ocorrências raras. Como as exceções raramente são processos rápidos, o gasto adicional de carregar classes extras pode elevar a latência da operação acima do limite crítico. Em geral, as exceções lançadas durante operações de tempo crítico devem ser evitadas sempre que possível.

Novas classes também podem ser carregadas quando alguns serviços, como reflexo, são utilizados na biblioteca de classes Java. Com a implementação subjacente das classes de reflexo gera novas classes durante a execução para serem carregadas na JVM. O uso repetido de classes de reflexo em código sensível à sincronização pode resultar em atividade contínua de carregamento de classe, o que gera atrasos. A utilização da opção -verbose:class é a melhor forma de detectar as classes sendo criadas. Provavelmente, a melhor forma de evitar sua criação durante o programa é evitar usar os serviços de reflexo para mapear classe, campo ou métodos de cadeias de caracteres durante partes críticas de tempo de seu aplicativo. Pelo contrário, chame esses serviços antes em seu aplicativo e armazene os resultados para uso posterior para evitar que a maioria desses tipos de classes seja criada na execução quando você não quer que isso aconteça.

Uma técnica genérica para evitar atrasos de carregamento de classe durante partes sensíveis ao tempo de seu aplicativo é pré-carregar as classes durante a partida ou inicialização do aplicativo. Embora essa fase de pré-carregamento acarrete algum atraso adicional na inicialização (infelizmente, aperfeiçoar uma métrica normalmente traz consequências negativas para outras métricas), se utilizada com cuidado, ela pode eliminar o carregamento de classe indesejado posteriormente. Esse processo de inicialização é simples de implementar, como demonstrado na Listagem 2:

Listagem 2. Carregamento de classe controlada a partir de uma lista de classes
Iterator<String> classIt = listOfClassNamesToLoad.iterator();
while (classIt.hasNext()) {
    String className = classIt.next();
    try {
        Class clazz = Class.forName(className);
        String n=clazz.getName();
    } catch (Exception e) {
    System.err.println("Could not load class: " + className);
    System.err.println(e);
}

Observe a chamada de clazz.getName(), que força a inicialização da classe. Construir a lista de classes requer a coleta de informações de seu aplicativo enquanto ele é executado ou a utilização de um utilitário que possa determinar quais classes seu aplicativo carregará. Por exemplo, você pode capturar a saída de seu programa enquanto executa a opção -verbose:class. A Listagem 3 mostra como pode parecer a saída desse comando se você utilizar um produto IBM WebSphere Real Time:

Listagem 3. Extrato da saída da execução java com -verbose:class
    ...
    class load: java/util/zip/ZipConstants
    class load: java/util/zip/ZipFile
    class load: java/util/jar/JarFile
    class load: sun/misc/JavaUtilJarAccess
    class load: java/util/jar/JavaUtilJarAccessImpl
    class load: java/util/zip/ZipEntry
    class load: java/util/jar/JarEntry
    class load: java/util/jar/JarFile$JarFileEntry
    class load: java/net/URLConnection
    class load: java/net/JarURLConnection
    class load: sun/net/www/protocol/jar/JarURLConnection
    ...

Ao armazenar a lista de classes carregadas pelo seu aplicativo durante uma execução e utilizar essa lista para preencher a lista de nomes de classe para o ciclo apresentado na Listagem 2, é possível ter certeza de que essas classes são carregadas antes que seu aplicativo comece a ser executado. Obviamente, execuções diferentes de seu aplicativo podem tomar caminhos diferentes, portanto, a lista de uma execução pode não ser completa. No topo da lista, se seu aplicativo estiver sob desenvolvimento ativo, código recentemente escrito ou modificado pode depender de novas classes que não são parte da lista (ou classes que estão na lista podem não ser mais necessárias). Infelizmente, a manutenção da lista de classes pode ser uma parte extremamente incômoda de se utilizar essa abordagem de pré-carregamento de classe. Se utilizar essa abordagem, lembre-se que o nome da saída da classe por -verbose:class não corresponde ao formato necessário para Class.forName(): A saída detalhada separa pacotes de classe com barras, enquanto Class.forName() espera que eles sejam separados por pontos.

Para aplicativos para os quais o carregamento de classe é um problema, algumas ferramentas podem ajudar no pré-carregamento, incluindo o Real Time Class Analysis Tool (RATCAT) e o IBM Real Time Application Execution Optimizer para Java (veja em Recursos). Essas ferramentas certo grau de automação para identificar a lista correta de classes para pré-carregar e incorporar o código de pré-carregamento em seu aplicativo.

Pausas de compilação de código JIT

Uma terceira fonte de atrasos na JVM em si é o compilador JIT. Ele atua enquanto seu aplicativo está em execução para converter os métodos do programa de bytecodes gerados pelo compilador javac em instruções nativas da CPU em que o aplicativo é executado. O compilador JIT é fundamental para o sucesso da plataforma Java porque permite alto desempenho do aplicativo sem sacrificar a neutralidade da plataforma dos bytecodes Java. Há mais de uma década, os engenheiros do compilador JIT têm feito tremendos avanços para aperfeiçoar o rendimento e a latência de aplicativos Java.

Um exemplo de otimização de JIT

Um bom exemplo de otimização de JIT é a especialização das arraycopies. Para um método frequentemente executado, o compilador JIT pode determinar o perfil de comprimento de uma chamada de arraycopy específica para verificar se certos comprimentos são os mais comuns. Após estabelecer o perfil da chamada por algum tempo, o compilador JIT pode achar que o comprimento é quase sempre 12 bytes. Com esse conhecimento, o JIT pode gerar um atalho extremo para a chamada de arraycopy que copia diretamente 12 bytes necessários da forma mais eficiente para o processador de destino. O JIT insere uma verificação condicional para ver se o comprimento é 12 bytes e, se for, então a cópia ultraeficiente do caminho rápido é realizada. Se o comprimento não for 12, então um ocorre um caminho diferente que realiza a cópia da forma padrão, que pode levar envolver muito mais gasto adicional, pois pode lidar com qualquer comprimento de array. Se a maioria das operações no aplicativo utilizar o atalho, então a latência comum da operação será baseada no tempo que leva para copiar os 12 bytes diretamente. Mas qualquer operação que exija uma cópia de um comprimento diferente, parecerá atrasada com relação ao tempo comum de operação.

Infelizmente, tais melhorias são acompanhadas de pausas no desempenho do aplicativo Java, pois o compilador JIT "rouba" ciclos do programa aplicativo para gerar código compilado (ou mesmo para recompilar) para um método particular. Dependendo do tamanho do método que é compilado e da agressividade com que o JIT decide compilar, o tempo de compilação pode levar de menos de um milissegundo a mais de um segundo (para métodos particularmente grandes observados pelo compilados JIT como contribuindo significativamente com o tempo de execução do aplicativo). Mas a atividade do compilador JIT em si não é a única fonte de variações inesperadas nas sincronizações no nível do aplicativo. Como os engenheiros de compilador JIT têm se focado quase que exclusivamente no desempenho médio de caso para aperfeiçoar o rendimento e latência de desempenho com mais eficiência, os compiladores JIT normalmente realizam uma série de otimizações "normalmente" certas ou "na maioria das vezes" de alto desempenho. No caso comum, essas otimizações são extremamente efetivas e a heurística foi desenvolvida para realizar um belo trabalho de ajustar a otimização às situações mais comuns enquanto um aplicativo é executado. Em alguns casos, entretanto, tais otimizações podem introduzir muita variabilidade de desempenho.

Além do pré-carregamento de todas as classes, também é possível solicitar ao compilador JIT para compilar explicitamente os métodos dessas classes durante a inicialização do aplicativo. A Listagem 4 estende o código de pré-carregamento de classe na Listagem 2 para controlar a compilação de método:

Listagem 4. Compilação de método controlada
Iterator<String> classIt = listOfClassNamesToLoad.iterator();
while (classIt.hasNext()) {
    String className = classIt.next();
    try {
        Class clazz = Class.forName(className);
        String n = clazz.name();
        java.lang.Compiler.compileClass(clazz);
    } catch (Exception e) {
        System.err.println("Could not load class: " + className);
        System.err.println(e);
    }
}
java.lang.Compiler.disable();  // optional

Esse código faz com que um conjunto de classes seja carregado e os métodos dessas classes sejam todos compilados pelo compilador JIT. A última linha desativa o compilador JIT para o restante da execução do aplicativo.

Essa abordagem geralmente resulta em menor rendimento geral ou latência de desempenho do que quando se dá total liberdade ao compilador JIT para selecionar quais métodos serão compilados. Como os métodos não foram invocados antes da execução do compilador JIT, ele tem muito menos informação sobre como melhor aprimorar os métodos que ele compila—, portanto se espera que esses métodos sejam executados mais lentamente. Também, como o compilador está desativado, nenhum método será recompilado mesmo se forem responsáveis por uma ampla fração do tempo de execução do programa, então as estruturas adaptativas de compilação JIT como essas utilizadas em JVMs mais modernas não estarão ativas. O comando Compiler.disable() não é necessário para reduzir um grande número de pausas induzidas pelo compilador JIT, mas as pausas que permanecem serão recompilações mais agressivas nos métodos mais utilizados do aplicativo, que normalmente precisam de tempo maior de compilação com maior potencial de causar impacto nas sincronizações do aplicativo. O compilador JIT em uma JVM específica pode não ser descarregado quando o método disable() é invocado, portanto, pode ainda haver memória consumida, bibliotecas compartilhadas carregadas e outros artefatos do compilador JIT apresentados durante a fase de tempo de execução do programa de aplicativo.

O grau no qual uma compilação de código nativo afeta o desempenho de um aplicativo varia de acordo com o aplicativo, é claro. Sua melhor abordagem para ver se uma compilação pode ser um problema é acionar a saída detalhada, indicando quando ocorrerem compilações para verificar se elas podem afetar suas sincronizações de aplicativo. Por exemplo, com o IBM WebSphere Real Time JVM, é possível acionar a criação de log detalhado JIT com a opção da linha de comando -Xjit:verbose.

Além dessa pré-carga e abordagem de compilação antecipada, não há muito o que um desenvolvedor de aplicação possa fazer para evitar pausas incorridas pelo compilador JIT, exceto opções de linha de comando do compilador JIT exótico específico do fornecedor — uma abordagem arriscada. Os fornecedores de JVM raramente suportam essas opções em cenários de produção. Como elas não são configurações padrão, elas não são testadas a fundo pelos fornecedores, e eles podem mudar em nome e significado de um release para o próximo.

Entretanto, algumas JVMs podem lhe oferecer algumas opções, dependendo da importância que as pausas induzidas pelo compilador JIT têm para você. As JVMs de tempo real projetadas para uso em sistemas Java pesados em tempo real geralmente oferecem mais opções. O IBM WebSphere Real Time For Real Time Linux® JVM, por exemplo, tem cinco estratégias de compilação de código disponíveis com capacidade variável de reduzir pausas do compilador JIT:

  • Compilação JIT padrão, considerando que o encadeamento do compilador JIT é executado em baixa prioridade
  • Compilação JIT padrão em baixa prioridade com código compilado Ahead-of-time (AOT) usado inicialmente
  • Compilação controlada por programa na inicialização com a recompilação ativada
  • Compilação controlada por programa na inicialização com a recompilação desativada
  • Somente código compilado AOT

Essas opções são listadas em ordem decrescente geral do nível esperado de rendimento/latência de desempenho e tempos esperados de pausa. Portanto, a opção de compilação JIT padrão, que utiliza um encadeamento de compilação JIT em execução na mais baixa prioridade (que pode ser inferior aos encadeamentos do aplicativo), fornece o desempenho de rendimento mais elevado esperado, mas também se espera que demonstre as maiores pausas devido à compilação JIT (dessas cinco opções). As primeiras duas opções utilizam compilação assíncrona, o que significa que um encadeamento de aplicativo que tenta invocar um método que tenha sido selecionado para (re)compilação não precisa esperar até que a compilação esteja concluída. A última opção tem o mais baixo desempenho de rendimento/latência esperado, mas nenhuma pausa do compilador JIT, pois este está totalmente desabilitado neste cenário.

O IBM WebSphere Real Time para Real Time Linux JVM oferece uma ferramenta chamada admincache que permite que você crie um cache de classe compartilhado contendo arquivos de classe de um conjunto de arquivos JAR e, opcionalmente, armazene código compilado AOT para essas classes no mesmo cache. É possível estabelecer uma opção em sua linha de comando java que faça com que as classes armazenadas no cache de classe compartilhado sejam carregadas a partir do cache e código AOT para serem automaticamente carregados na JVM quando a classe é carregada. Um loop de pré-carregamento de classe como o demonstrado na Listagem 2 é tudo o que se precisa para assegurar que você tenha todos os benefícios do código compilado AOT. Veja em Recursos um link para a documentação admincache.

Gerenciamento de encadeamento

Controlar a execução de encadeamentos em um aplicativo multiencadeado como um servidor de transação é fundamental para eliminar a variabilidade nos tempos de transação. Embora a linguagem de programação Java defina um modelo de encadeamento que inclui uma noção de prioridades de encadeamento, o comportamento dos encadeamentos em uma JVM real é amplamente definido pela implementação, com algumas regras em que o programa Java pode confiar. Por exemplo, embora os encadeamentos Java possam receber 1 de 10 prioridades de encadeamento, o mapeamento dessas prioridades no nível do aplicativo para os valores de prioridade do SO é definido pela implementação. (É perfeitamente válido para uma JVM mapear todas as prioridades de encadeamento Java no mesmo valor de prioridade do SO.) No topo disso, a política de planejamento para encadeamentos Java também é definida pela implementação, mas normalmente termina sendo fatiada pelo tempo, para que mesmo os encadeamentos de alta prioridade terminem compartilhando recursos da CPU com encadeamentos de prioridade inferior. Compartilhar recursos com encadeamentos de prioridade inferior pode fazer com que os encadeamentos de alta prioridade sofram atrasos quanto ao seu planejamento para que outras tarefas obtenham uma fatia de tempo. Lembre-se de que a quantidade de CPU que um encadeamento obtém depende não somente da prioridade, mas também do número total de encadeamentos que precisam ser planejados. A menos que você controle rigorosamente quantos encadeamentos estão ativos em um determinado momento, o tempo que até seus encadeamentos da mais alta prioridade leva para executar uma operação pode está incluído em uma gama relativamente ampla.

Portanto, mesmo se você especificar a mais alta prioridade de encadeamento Java (java.lang.Thread.MAX_PRIORITY) para suas linhas do trabalhador, isso pode não oferecer muito isolamento das tarefas de prioridade inferior no sistema. Infelizmente, ao invés de usar um conjunto fixo de linhas do trabalhador (não continuar a alocar novas linhas enquanto depender do GC para coletar inutilizadas ou estender e comprimir seu pool de linhas) e tentar minimizar o número de atividades de baixa prioridade no sistema enquanto seu aplicativo é executado, pode não haver muito mais o que você possa fazer porque o modelo de encadeamento Java padrão não oferece as ferramentas necessárias para controlar o comportamento do encadeamento. Mesmo uma JVM em tempo real leve, se depender do modelo de encadeamento Java padrão, normalmente não pode oferecer muita ajuda aqui.

Uma JVM em tempo real pesada que suporta Real Time Specification for Java (RTSJ), entretanto, —como o IBM WebSphere Real Time for Real Time Linux V2.0 ou Sun's RTS 2 — pode oferecer comportamento de encadeamento notavelmente melhor em relação ao Java padrão. Entre esses aprimoramentos da linguagem padrão Java e especificações VM, o RTSJ apresenta dois novos tipos de linhas, RealtimeThread e NoHeapRealtimeThread, que são muito mais rigorosamente definidas do que o modelo de encadeamento padrão Java. Esses tipos de linhas oferecem um planejamento preventivo real baseado em prioridade: Se uma tarefa de alta prioridade precisa executar e uma tarefa de prioridade inferior estiver atualmente planejada em um núcleo processador, então a tarefa de prioridade inferior é evitada para que a tarefa de alta prioridade possa ser executada.

A maioria dos SOs em tempo real pode realizar essa prevenção na ordem de dezenas de microssegundos, o que afeta somente os aplicativos com requisitos extremamente sensíveis de sincronização. Os dois novos tipos de linhas também normalmente utilizam a política de planejamento FIFO (first-in, first out) ao invés do conhecido planejamento de método round-robin usado pelas JVMs executadas na maioria dos SOs. A diferença mais óbvia entre as políticas de planejamento round-robin e FIFO é que, entre as linhas da mesma prioridade, quando planejada, uma linha continua a executar até que bloqueie ou libere voluntariamente o processador. A vantagem deste modelo é que o tempo para executar uma determinada tarefa pode ser mais previsível, pois o processador não é compartilhado, mesmo se houver diversas tarefas com a mesma prioridade. No topo disso, se você puder evitar que a linha bloqueie eliminando a sincronização e atividade de E/S, o SO não interferirá na tarefa quando ela começar. Na prática, entretanto, eliminar toda a sincronização é extremamente difícil, portanto pode ser difícil alcançar esse ideal para tarefas reais. Entretanto, o planejamento FIFO oferece uma importante ajuda para um editor de telas que tenta evitar atrasos.

É possível pensar no RTSJ como uma grande caixa de ferramentas que pode ajudá-lo a projetar aplicativos com comportamento em tempo real. É possível utilizar apenas algumas ferramentas ou reescrever totalmente seu aplicativo para oferecer desempenho extremamente previsível. Normalmente não é difícil modificar seu aplicativo para utilizar RealtimeThreads e isso é possível sem nem mesmo ter que acessar uma JVM em tempo real para compilar seu código Java, através do uso cuidadoso dos serviços de reflexo Java.

Tirar proveito dos benefícios de variabilidade do planejamento FIFO, entretanto, pode exigir algumas mudanças adicionais em seu aplicativo. O planejamento FIFO comporta-se de maneira diferente do planejamento round-robin e as diferenças podem causar interrupções em alguns programas Java. Por exemplo, se seu aplicativo depende do Thread.yield() para permitir que outras linhas sejam executadas em um núcleo, —uma técnica frequentemente utilizada para sondar alguma condição sem utilizar um núcleo completo para fazê-lo — o efeito desejado não ocorrerá porque, com o planejamento FIFO, Thread.yield() não bloqueia a linha atual. Como a linha atual permanece planejável e já é a linha na frente da fila de planejamento no SO kernel, ela simplesmente continuará a ser executada. Portanto, um padrão de código destinado a oferecer acesso aos recursos da CPU enquanto espera uma condição de tornar-se real, na verdade consome 100% de qualquer núcleo de CPU que comece a ser executado. E esse é o melhor resultado possível. Se a linha que precisa estabelecer essa condição tiver uma prioridade inferior, ela pode nunca ser capaz de acessar um núcleo para configurar a condição. Com os processadores atuais de múltiplos núcleos, esse problema tem menor probabilidade de ocorrer, mas enfatiza que é necessário pensar cuidadosamente em quais prioridades você utiliza se empregar RealtimeThreads. A abordagem mais segura é fazer todas as linhas utilizarem um único valor de prioridade e eliminar o uso do Thread.yield() e outros tipos de loop de spin que consumirão totalmente uma CPU porque nunca bloqueiam. Obviamente, tirar total proveito dos valores de prioridade disponíveis para RealtimeThreads lhe oferecerá a melhor chance de alcançar seus objetivos de qualidade de serviço. (Para mais dicas sobre o uso de RealtimeThreads em seu aplicativo, consulte "Real-time Java, Part 3: Threading and synchronization.")


Um exemplo de servidor Java

No restante deste artigo, aplicaremos algumas das ideias apresentadas nas seções anteriores a um aplicativo de servidor Java relativamente simples construído utilizando o serviço de Executores na Biblioteca de Classes Java. Com apenas uma pequena quantidade de código de aplicativo, o serviço de Executores é possível criar um servidor gerenciando um pool de linhas do trabalhador, como demonstrado na Listagem 5:

Listagem 5. Classes Servidor e TaskHandler utilizando serviço de Executores
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadFactory;

class Server {
    private ExecutorService threadPool;
    Server(int numThreads) {
        ThreadFactory theFactory = new ThreadFactory();
        this.threadPool = Executors.newFixedThreadPool(numThreads, theFactory);
    }

    public void start() {
        while (true) {
            // main server handling loop, find a task to do
            // create a "TaskHandler" object to complete this operation
            TaskHandler task = new TaskHandler();
            this.threadPool.execute(task);
        }
        this.threadPool.shutdown();
    }

    public static void main(String[] args) {
        int serverThreads = Integer.parseInt(args[0]);
        Server theServer = new Server(serverThreads);
        theServer.start();
    }
}

class TaskHandler extends Runnable {
    public void run() {
        // code to handle a "task"
    }
}

Esse servidor cria tantas linhas do trabalhador quanto forem necessárias até o máximo especificado quando o servidor é criado (decodificado da linha de comando neste exemplo específico). Cada linha do trabalhador executa uma quantidade de trabalho utilizando a classe TaskHandler. Para nossa proposta, criaremos um método TaskHandler.run() que deve levar o mesmo tempo cada vez que é executado. Qualquer variabilidade no tempo medido para executar o TaskHandler.run(), portanto, é devido a pausas ou variabilidade na JVM subjacente, algum problema de encadeamento ou pausas introduzidas em um nível inferior da pilha. A Listagem 6 exibe a classe TaskHandler:

Listagem 6. Classe TaskHandler com desempenho previsível
import java.lang.Runnable;
class TaskHandler implements Runnable {
    static public int N=50000;
    static public int M=100;
    static long result=0L;
    
    // constant work per transaction
    public void run() {
        long dispatchTime = System.nanoTime();
        long x=0L;
        for (int j=0;j < M;j++) {
            for (int i=0;i < N;i++) {
                x = x + i;
            }
        }
        result = x;
        long endTime = System.nanoTime();
        Server.reportTiming(dispatchTime, endTime);
    }
}

Os loops neste método run() computam M (100) vezes a soma dos primeiros N (50.000) números inteiros. Os valores de M e N foram escolhidos para que os tempos de transação na máquina em que executamos medissem cerca de 10 ms para que uma única operação pudesse ser interrompida por um quantum de planejamento do SO (que normalmente dura cerca de 10 ms). Construímos os loops nesse cálculo para que um compilador JIT pudesse gerar código excelente que fosse executado por uma quantidade de tempo extremamente previsível: o método run() não bloqueia explicitamente quando duas chamadas para System.nanoTime() usadas para cronometrar quanto tempo leva para executar. Como o código medido é altamente previsível, podemos utilizá-lo para mostrar como fontes significativas de atrasos e variabilidade não se originam necessariamente do código que está sendo medido.

Vamos tornar este aplicativo um pouco mais real forçando a ativação do subsistema coletor de lixo enquanto o código TaskHandler está sendo executado. A Listagem 7 mostra essa classe GCStressThread:

Listagem 7. Classe GCStressThread para gerar lixo continuamente
class GCStressThread extends Thread {
    HashMap<Integer,BinaryTree> map;
    volatile boolean stop = false;

    class BinaryTree {
        public BinaryTree left;
        public BinaryTree right;
        public Long value;
    }
    private void allocateSomeData(boolean useSleep) {
        try {
            for (int i=0;i < 125;i++) {
                if (useSleep)
                    Thread.sleep(100);
                BinaryTree newTree = createNewTree(15); // create full 15-level BinaryTree
                this.map.put(new Integer(i), newTree);
            }
        } catch (InterruptedException e) {
            stop = true;
        }
    }

    public void initialize() {
        this.map = new HashMap<Integer,BinaryTree>();
        allocateSomeData(false);
        System.out.println("\nFinished initializing\n");
    }

    public void run() {
        while (!stop) {
            allocateSomeData(true);
        }
    }
}

O GCStressThread mantém um conjunto de BinaryTrees através de um HashMap. Ele itera sobre o mesmo conjunto de chaves de Número inteiro para o HashMap armazenando novas estruturas de BinaryTree, se são simplesmente BinaryTrees de 15 níveis totalmente preenchidas. (Portanto há 215 = 32.768 nós em cada BinaryTree armazenada dentro do HashMap.) O HashMap suporta 125 BinaryTrees de uma só vez (dados ativos) e a cada 100 ms ele substitui uma delas por uma nova BinaryTree. Dessa forma, esta estrutura de dados mantém um conjunto bastante complicado de objetos ativos, além de gerar lixo em uma certa taxa. O HashMap é inicializado pela primeira vez com um conjunto completo de 125 BinaryTrees utilizando a rotina initialize(), que não se importa em realizar pausas entre as alocações de cada árvore. Quando o GCStressThread tiver sido iniciado (imediatamente antes de o servidor ser iniciado), ele opera através do tratamento das operações do TaskHandler pelas linhas do trabalhador do servidor.

Não utilizaremos um cliente para acionar este servidor. Simplesmente criaremos operações NUM_OPERATIONS == 10000 diretamente dentro do loop principal do servidor (no método Server.start()). A Listagem 8 mostra o método Server.start():

Listagem 8. Operações de despacho dentro do servidor
public void start() {
    for (int m=0; m < NUM_OPERATIONS;m++) {
        TaskHandler task = new TaskHandler();
        threadPool.execute(task);
    }
    try {
        while (!serverShutdown) { // boolean set to true when done
            Thread.sleep(1000);
        }
    }
    catch (InterruptedException e) {
    }
}

Se coletarmos estatísticas dos tempos para concluir cada invocação do TaskHandler.run(), poderemos ver quanta variabilidade é introduzida pela JVM e pelo design do aplicativo. Utilizamos um IBM xServer e5440 com oito núcleos físicos com o sistema operacional em tempo real Red Hat RHEL MRG. (Hyperthreading desativado. Observe que embora a tecnologia hyperthreading possa oferecer alguma melhora de rendimento em uma avaliação de desempenho, porque seus núcleos virtuais não estão cheios, o desempenho físico do núcleo das operações nos processadores com a tecnologia hyperthreading ativada pode ter sincronizações acentuadamente diferentes.) Quando executamos esse servidor com seis linhas na máquina de oito núcleos (deixaremos generosamente um núcleo para a linha principal do Servidor e uma para o GCStressorThread utilizar) com o IBM Java6 SR3 JVM, obtivemos os seguintes (representativos) resultados:

$ java -Xms700m -Xmx700m -Xgcpolicy:optthruput Server 6
10000 operations in 16582 ms
Throughput is 603 operations / second
Histogram of operation times:
9ms - 10ms      9942    99 %
10ms - 11ms     2       0 %
11ms - 12ms     32      0 %
30ms - 40ms     4       0 %
70ms - 80ms     1       0 %
200ms - 300ms   6       0 %
400ms - 500ms   6       0 %
500ms - 542ms   6       0 %

Você pode ver que quase todas as operações são concluídas em 10 ms, mas algumas demoram mais de meio segundo (50 vezes mais demorado). É uma variação e tanto! Vamos ver como podemos eliminar um pouco dessa variabilidade eliminando os atrasos incorridos pelo carregamento da classe Java, compilação do código nativo JIT, GC e encadeamento.

Inicialmente coletamos uma lista de classes carregadas pelo aplicativo através de uma execução completa com -verbose:class. Armazenamos a saída para um arquivo e então a modificamos para que tivesse um nome adequadamente formatado em cada linha do arquivo. Incluímos um método preload() na classe Servidor para carregar cada uma das classes, realizar compilação JIT de todos os métodos dessas classes e então desativar o compilador JIT, como demonstrado na Listagem 9:

Listagem 9. Pré-carregamento de classes e métodos para o servidor
private void preload(String classesFileName) {
    try {
        FileReader fReader = new FileReader(classesFileName);
        BufferedReader reader = new BufferedReader(fReader);
        String className = reader.readLine();
        while (className != null) {
            try {
                Class clazz = Class.forName(className);
                String n = clazz.getName();
                Compiler.compileClass(clazz);
            } catch (Exception e) {
            }
            className = reader.readLine();
        }
    } catch (Exception e) {
    }
    Compiler.disable();
}

O carregamento de classe não é um problema significativo em nosso servidor simples porque nosso método TaskHandler.run() é muito simples: quando a classe é carregada, não ocorre mais carregamento de classe na execução do Servidor, o que pode ser verificado pela execução com -verbose:class. O principal benefício deriva da compilação dos métodos antes de executar qualquer operação TaskHandler medida. Embora pudéssemos ter usado um loop de aquecimento, esta abordagem tende a ser específica para JVM porque a heurística utilizada pelo compilador JIT para selecionar métodos para compilar difere entre as implementações JVM. Utilizar o serviço Compiler.compile() oferece atividade de compilação mais controlável, mas, conforme mencionado anteriormente no artigo, devemos esperar uma queda no rendimento ao utilizar esta abordagem. Os resultados da execução do aplicativo com essas opções são:

$ java -Xms700m -Xmx700m -Xgcpolicy:optthruput Server 6
10000 operations in 20936 ms
Throughput is 477 operations / second
Histogram of operation times:
11ms - 12ms     9509    95 %
12ms - 13ms     478     4 %
13ms - 14ms     1       0 %
400ms - 500ms   6       0 %
500ms - 527ms   6       0 %

Observe que embora os atrasos mais longos não tenham mudado muito, o histograma é muito mais curto do que era inicialmente. Muitos dos atrasos mais curtos foram claramente introduzidos pelo compilador JIT, portanto, realizar as compilações antes e então desativar o compilador JIT foi certamente um avanço. Outra observação interessante é que os tempos comuns de operação foram, de alguma forma, mais longos (de cerca de 9 a 10 ms para 11 a 12 ms). As operações foram desaceleradas porque a qualidade do código gerado por uma compilação JIT forçada antes de os métodos terem sido invocados é normalmente inferior do que a do código totalmente exercitado. Este não é um resultado surpreendente, pois uma das grandes vantagens do compilador JIT é explorar as características dinâmicas do aplicativo que está executando para que seja executado de forma mais eficiente.

Continuaremos a utilizar este código de pré-carregamento de carga e pré-compilação de método no restante do artigo.

Como nosso GCStressThread gera um conjunto de dados ativos em constante mudança, não se espera que o uso de uma política de geração de GC ofereça muito benefício quanto ao tempo de pausa. Ao invés disso, experimentamos o coletor de lixo em tempo real no produto IBM WebSphere Real Time for Real Time Linux V2.0 SR1. Os resultados inicialmente desapontaram, mesmo após adicionarmos a opção -Xgcthreads8, que permite que o coletor utilize oito linhas de GC ao invés do padrão de uma linha. (Se o coletor não puder acompanhar a taxa de alocação desse aplicativo de forma confiável com apenas uma linha de GC.)

$ java -Xms700m -Xmx700m -Xgcpolicy:metronome -Xgcthreads8 Server 6
10000 operations in 72024 ms
Throughput is 138 operations / second
Histogram of operation times:
11ms - 12ms     82      0 %
12ms - 13ms     250     2 %
13ms - 14ms     19      0 %
14ms - 15ms     50      0 %
15ms - 16ms     339     3 %
16ms - 17ms     889     8 %
17ms - 18ms     730     7 %
18ms - 19ms     411     4 %
19ms - 20ms     287     2 %
20ms - 30ms     1051    10 %
30ms - 40ms     504     5 %
40ms - 50ms     846     8 %
50ms - 60ms     1168    11 %
60ms - 70ms     1434    14 %
70ms - 80ms     980     9 %
80ms - 90ms     349     3 %
90ms - 100ms    28      0 %
100ms - 112ms   7       0 %

Utilizar o coletor em tempo real tem diminuído substancialmente o tempo máximo de operação, mas também aumentou a expansão dos tempos de operação. E pior, a taxa de rendimento caiu consideravelmente.

A etapa final é para utilizar RealtimeThreads — ao invés de linhas regulares Java — para as linhas do trabalhador. Criamos uma classe RealtimeThreadFactory à qual podemos atribuir o serviço Executors, como demonstrado na Listagem 10:

Listagem 10. Classe RealtimeThreadFactory
import java.util.concurrent.ThreadFactory;
import javax.realtime.PriorityScheduler;
import javax.realtime.RealtimeThread;
import javax.realtime.Scheduler;
import javax.realtime.PriorityParameters;

class RealtimeThreadFactory implements ThreadFactory {
    public Thread newThread(Runnable r) {
        RealtimeThread rtThread = new RealtimeThread(null, null, null, null, null, r);

        // adjust parameters as needed
        PriorityParameters pp = (PriorityParameters) rtThread.getSchedulingParameters();
        PriorityScheduler scheduler = PriorityScheduler.instance();
        pp.setPriority(scheduler.getMaxPriority());

        return rtThread;
    }
}

Passar uma instância da classe RealtimeThreadFactory para o serviço Executors.newFixedThreadPool() faz com que as linhas do trabalhador sejam RealtimeThreads utilizando planejamento FIFO com a mais alta prioridade disponível. O coletor de lixo ainda interromperá essas linhas quando precisar realizar o trabalho, mas nenhuma outra tarefa de prioridade inferior interferirá nas linhas do trabalhador:

$ java -Xms700m -Xmx700m -Xgcpolicy:metronome -Xgcthreads8 Server 6
Handled 10000 operations in 27975 ms
Throughput is 357 operations / second
Histogram of operation times:
11ms - 12ms     159     1 %
12ms - 13ms     61      0 %
13ms - 14ms     17      0 %
14ms - 15ms     63      0 %
15ms - 16ms     1613    16 %
16ms - 17ms     4249    42 %
17ms - 18ms     2862    28 %
18ms - 19ms     975     9 %
19ms - 20ms     1       0 %

Com essa última mudança, aperfeiçoamos significativamente tanto o pior tempo de operação (baixando-o para apenas 19 ms), bem como o rendimento geral (até 357 operações por segundo). Portanto, tivemos uma melhora substancial na variabilidade dos tempos de operação, mas pagamos um preço alto no desempenho de rendimento. A operação do coletor de lixo utilizando até 3 ms de cada 10 ms, explica por que uma operação que normalmente leva mais ou menos 12 ms pode ser estendida em até 4 a 5 ms, que é a razão pela qual a maior parte das operações agora leva cerca de 16 a 17 ms. A queda do rendimento é provavelmente maior que o esperado porque a JVM em tempo real, além do uso do coletor de lixo em tempo real Metronome, também modificou os travamentos primitivos que protegem contra inversão de prioridade, um problema importante quando se utiliza o planejamento FIFO (consulte "Real-time Java, Part 1: Using Java code to program real-time systems"). Infelizmente, a sincronização entre a linha principal e as linhas do trabalhador contribui mais com o custo adicional que, no final, tem um impacto no rendimento, embora não seja medido como parte de qualquer tempo de operação (portanto não aparece no histograma).

Então, embora nosso servidor se beneficie das modificações realizadas para melhorar a previsibilidade, ele certamente sofre uma grande queda no rendimento. Entretanto, se aqueles poucos tempos de operação incrivelmente longos representam um nível inaceitável de qualidade de serviço, a utilização de RealtimeThreads com uma JVM em tempo real pode ser a solução ideal.


Fechamento

No mundo dos aplicativos Java, o rendimento e a latência têm sido as métricas tradicionalmente escolhidas pelos designers de aplicativo e de avaliações de desempenho para relatório e otimização. Essa escolha tem tido um amplo impacto na evolução dos Java Runtimes construídos para melhorar o desempenho. Embora os Java runtimes tenham começado como intérpretes com latência e rendimento de tempo de execução extremamente lentos, as JVMs modernas podem competir em igualdade com outras linguagens nessas métricas para muitos aplicativos. Até relativamente pouco tempo, entretanto, o mesmo não podia ser dito sobre outras métricas que podiam ter um grande impacto sobre o desempenho observado de um aplicativo —, especialmente a variabilidade, que afeta a qualidade do serviço.

A introdução de Java em tempo real ofereceu aos designers de aplicativos as ferramentas necessárias para abordar as fontes de variabilidade em uma JVM e em seus aplicativos e oferecer a qualidade de serviço que os clientes e consumidores esperam. Este artigo apresentou uma série de técnicas que podem ser utilizadas para modificar um aplicativo Java para reduzir pausas e variabilidade que surgem da JVM e do planejamento de linha. Reduzir a variabilidade normalmente resulta em uma queda no desempenho de latência e rendimento. O nível de aceitação da queda determina quais ferramentas são apropriadas para um determinado aplicativo.

Recursos

Aprender

Obter produtos e tecnologias

Discutir

Comentários

developerWorks: Conecte-se

Los campos obligatorios están marcados con un asterisco (*).


Precisa de um ID IBM?
Esqueceu seu ID IBM?


Esqueceu sua senha?
Alterar sua senha

Ao clicar em Enviar, você concorda com os termos e condições do developerWorks.

 


A primeira vez que você entrar no developerWorks, um perfil é criado para você. Informações no seu perfil (seu nome, país / região, e nome da empresa) é apresentado ao público e vai acompanhar qualquer conteúdo que você postar, a menos que você opte por esconder o nome da empresa. Você pode atualizar sua conta IBM a qualquer momento.

Todas as informações enviadas são seguras.

Elija su nombre para mostrar



Ao se conectar ao developerWorks pela primeira vez, é criado um perfil para você e é necessário selecionar um nome de exibição. O nome de exibição acompanhará o conteúdo que você postar no developerWorks.

Escolha um nome de exibição de 3 - 31 caracteres. Seu nome de exibição deve ser exclusivo na comunidade do developerWorks e não deve ser o seu endereço de email por motivo de privacidade.

Los campos obligatorios están marcados con un asterisco (*).

(Escolha um nome de exibição de 3 - 31 caracteres.)

Ao clicar em Enviar, você concorda com os termos e condições do developerWorks.

 


Todas as informações enviadas são seguras.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=80
Zone=Tecnologia Java
ArticleID=432429
ArticleTitle=Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade de serviço
publish-date=09252009