Você está na página 1de 17

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

Mark Stoodley 25/Set/2009


Advisory Software Developer
IBM Toronto Lab

Charlie Gracie
J9 Garbage Collection Team Lead
IBM

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.
Visualizar mais conteúdo nesta série

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

© Copyright IBM Corporation 2009. Todos os direitos reservados. Marcas Registradas


Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 1 de 17
de serviço
developerWorks® ibm.com/developerWorks/br/

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á

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 2 de 17


de serviço
ibm.com/developerWorks/br/ developerWorks®

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

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 3 de 17


de serviço
developerWorks® ibm.com/developerWorks/br/

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);
}

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 4 de 17


de serviço
ibm.com/developerWorks/br/ developerWorks®

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.

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 5 de 17


de serviço
developerWorks® ibm.com/developerWorks/br/

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.

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 6 de 17


de serviço
ibm.com/developerWorks/br/ developerWorks®

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

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 7 de 17


de serviço
developerWorks® ibm.com/developerWorks/br/

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.

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 8 de 17


de serviço
ibm.com/developerWorks/br/ developerWorks®

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

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 9 de 17


de serviço
developerWorks® ibm.com/developerWorks/br/

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:

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 10 de 17


de serviço
ibm.com/developerWorks/br/ developerWorks®

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;
}
}

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 11 de 17


de serviço
developerWorks® ibm.com/developerWorks/br/

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:

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 12 de 17


de serviço
ibm.com/developerWorks/br/ developerWorks®

$ 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:

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 13 de 17


de serviço
developerWorks® ibm.com/developerWorks/br/

$ 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.

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 14 de 17


de serviço
ibm.com/developerWorks/br/ developerWorks®

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).

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 15 de 17


de serviço
developerWorks® ibm.com/developerWorks/br/

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.

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 16 de 17


de serviço
ibm.com/developerWorks/br/ developerWorks®

Sobre os autores
Mark Stoodley

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

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.

© Copyright IBM Corporation 2009. Todos os direitos reservados.


(www.ibm.com/legal/copytrade.shtml)
Marcas Registradas
(www.ibm.com/developerworks/br/ibm/trademarks/)

Desenvolvendo com Java em tempo real, Parte 2: Melhore a qualidade Página 17 de 17


de serviço

Você também pode gostar