Clique aqui para ler esse artigo em PDF.
Mini-curso
Programação Java ME
Parte 4: Portabilidade e Desempenho
Analisando implementações de Java ME, otimizando código ao máximo e trabalhando para aumentar a portabilidade e reduzir os efeitos da fragmentação
O leitor que está seguindo este mini-curso verá que nesta edição tivemos que reduzir o ritmo temporariamente, fazendo um artigo mais curto que os anteriores. O motivo é o lançamento próximo do novo Eclipse, evento que sempre pede cobertura num momento oportuno para leitores interessados neste IDE. Teremos pelo menos mais um artigo completo de Java ME neste mini-curso, onde falaremos de outras APIs fundamentais da MIDP, como a RMS e a WMA. (Posteriormente, teremos outros artigos eventuais sobre a plataforma Java ME nesta coluna.)
Todavia, para não deixar o leitor aficionado em Java ME a ver navios, apresentamos nesta edição material concentrado em dois aspectos importantes na criação de aplicações Java ME. O texto não tem dependências fortes com o artigo anterior. O projeto Java ME mencionado em alguns pontos é um gerador de fractais chamado Micromandel, cujos fontes e binários estão disponível no download da edição anterior (e também desta).
A portabilidade das JVMs ME
Como temos visto nas partes anteriores, as JVMs para dispositivos limitados são também limitadas: seus compiladores JIT, Garbage Collectors e outros componentes são menos avançados que os de JVMs Java SE, pois devem “caber” nos dispositivos. Estas limitações têm uma conseqüência. APIs, frameworks, aplicações etc. que abusam de design orientado a objetos moderno (abstração, polimorfismo, encapsulamento) e oferecem muita funcionalidade, dependem muito de JVMs eficientes. E a falta de determinadas otimizações é ainda mais grave, se lembrarmos que o hardware destes dispositivos é dezenas de vezes inferior ao de PCs.
Código nativo?
Uma solução possível seria explorar mais a opção por código nativo, para implementar com desempenho máximo pelo menos as APIs mais críticas. Isso talvez reduzisse a necessidade de simplificar ou substituir algumas APIs. Parece boa idéia, lembrando que as interfaces nativas proprietárias das JVMs para Java ME são mais eficientes que a nossa conhecida JNI. (Na Java ME, cada JVM define a interface nativa que quiser, que pode ser otimizada para a plataforma. Estas interfaces proprietárias só podem ser usadas pelo runtime Java, jamais por código de aplicações.)
O problema é que o mercado de dispositivos Java ME – basicamente, telefones celulares e PDAs – é ainda muito fragmentado. Quem acha ruim portar código C/C++ entre Windows, Mac OS X e variantes de Linux/Unix, não conhece o caos (tanto na variedade quanto na qualidade) dos sistemas operacionais e SDKs para celulares. Pior ainda, estas plataformas mudam muito rápido, algo típico do mercado de “eletrônica de consumo”. E há um agravante: as JVMs para Java ME precisam ser muito customizáveis, adaptáveis e extensíveis, para se adaptarem não só a diferentes CPUs e sistemas operacionais, mas às decisões específicas a cada fornecedor e a cada produto.
A conclusão é que, longe de poderem ser mais otimizadas para cada plataforma de hardware e sistema operacional, as JVMs ME precisam ser ainda mais portáveis que as JVMs SE! Isso é verdade, por exemplo, para a KVM + CLDC HotSpot da Sun (que é licenciada para muitos fornecedores de dispositivos – e cujo código-fonte já foi totalmente liberado sob licença GPL; por isso podemos examinar). De fato, os fontes da KVM possuem proporcionalmente menos código Assembly, menos código C/C++, e menos código específico para CPUs e sistemas operacionais particulares, do que os fontes do Sun JDK para Java SE. Isso é conseguido com opções de design muito diferentes das que são comuns em JVMs SE.
Design para portabilidade
Por exemplo, a KVM implementa threads por conta própria, sem qualquer ajuda do sistema operacional (portanto, o código que faz isso é 100% portável). As desvantagens? Praticamente nenhuma, pois (1) o suporte a threads nativos dos sistemas operacionais para dispositivos como celulares varia entre “não suportado” e “péssimo”[1]; e (2) estas plataformas nunca têm múltiplas CPUs (multi-core ou SMP), o que seria o maior motivo para se preferir threads nativos.
Pelo contrário, como a JVM é capaz de controlar e modificar facilmente todo o código executável, ela pode inserir no código (interpretado ou compilado) “pontos de yield” em locais estratégicos, dando uma chance à JVM de alternar entre vários threads. Em comparação, nas aplicações nativas estes pontos precisam ser determinados pelo programador, e um trabalho imperfeito pode resultar em problemas que vão desde desperdício de CPU e pequenas “travadas”, até problemas mais sérios como livelocks.
Otimizando o bytecode
Quando se trata de economizar bytes, na Java ME nenhuma economia é demais. Ainda mais considerando que muitos dispositivos MIDP se recusam a instalar JARs com mais de 300 Kb. Este é o tamanho máximo cujo suporte é obrigatório pela especificação Mobile Service Architecture (veja a edição anterior).
A compressão dos arquivos JAR ajuda um pouco, pois a especificação fala no tamanho do JAR e não no tamanho normal das classes descompactadas. Ainda assim, para uma aplicação de tamanho realista, 300 Kb não é uma folga muito grande, ainda mais considerando que este limite inclui todos os recursos que possamos querer incluir no JAR, como ícones, imagens e áudio.
Para avaliação, a primeira versão do projeto da edição anterior, com somente código de navegação entre dois forms vazios, gera um JAR de 1.737 bytes (já com compressão ZIP). Não é tão pouco – 0,5% do limite de 300 Kb – para um programa que ainda não faz praticamente nada!
Leitores mais experientes em Java SE lembrarão do compressor Pack200, que permite reduzir arquivos JAR mais ainda do que qualquer outro compressor de uso geral, e é muito utilizado para gerar instaladores ou em aplicações Web Start. O problema é que o Pack200 é pesado para os padrões dos dispositivos ME, e só é adequado à distribuição por rede. Quando você instala o Sun JDK ou JRE, que é um procedimento bem demorado considerando o tamanho do instalador, a maior parte do tempo é gasta pela descompressão Pack200. Mas graças a isso só precisamos fazer um download de 57 Mb para instalar um software de 162 Mb (usando o JDK 6.0u1 como exemplo). Porém, o Pack200 só se justifica pela economia de tempo de transmissão por rede. Na instalação, as classes precisam ser convertidas para o formato normal (arquivos JAR comuns utilizando somente compressão ZIP). Se fossem mantidas em formato .pack, seu carregamento pela JVM seria lento demais. Isso porque este formato é “denso”; não permite ler uma classe arbitrária no meio do arquivo sem antes descomprimir todo o arquivo até aquela posição.
Para reduzir o bytecode ainda mais, utilizamos ofuscadores de bytecode. Estas ferramentas (como sugere seu nome) foram originalmente criadas para proteção de propriedade intelectual. Elas distorcem o bytecode, por exemplo substituindo nomes de atributos e métodos por nomes sem significado. Isso torna muito mais difícil o trabalho de algum xereta com um descompilador, que queira fazer a engenharia reversa dos fontes (pois os .class normais são fáceis de ler e de transformar automaticamente em código-fonte razoavelmente próximo ao original).
Nota 1: Tanto o Symbian quanto o BREW só oferecem threads (e multitarefa) “cooperativa”, uma tecnologia dos tempos do Windows 3.1. O Windows CE é melhor, implementando scheduling preemptivo, mas o CE só é capaz de rodar numa variedade de equipamentos muito mais estreita (com requisitos mínimos bem maiores que os celulares médios atuais). Também há alguns fornecedores começando a utilizar o Linux, mas isso é incipiente e ainda não se pode dizer que o Linux esteja bem adaptado para plataformas como celulares. |
Mas as pessoas logo descobriram que um “benefício colateral” dos ofuscadores era a redução dos arquivos .class, por exemplo devido à substituição de identificadores longos (como “executaSolicitacaoDoUsuario”) por outros muito menores (como “a”). Os ofuscadores logo se tornaram uma ferramenta essencial para desenvolvedores Java ME, mesmo os que não se preocupem com ataques de piratas.
Tanto o NetBeans Mobility Pack quanto o EclipseME e o MTJ usam o ofuscador open source ProGuard para otimizar o bytecode. Sugiro o uso das seguintes opções do ProGuard (no NetBeans, ao invés de usar um dos níveis de ofuscação predefinidos, utilize o nível 1 – só opções customizadas):
-dontusemixedcaseclassnames
-defaultpackage ''
-overloadaggressively
-allowaccessmodification
-keep public class ** extends javax.microedition.midlet.MIDlet
Estas opções preservam unicamente o nome da classe principal da MIDlet e os métodos que esta classe redefine. O resultado, para a versão final do mesmo projeto criado na edição anterior, é uma redução de 9.481 bytes (16.708 bytes descompactados) para 6.814 bytes (13.684 bytes descompactados). Trata-se de um corte de 30% comparando os arquivos JAR distribuíveis. Nada mau, mesmo considerando que boa parte da redução è devida à supressão de informações de depuração (como nomes de variáveis locais).
Desempenho: Java ME X Java SE
Eu não poderia perder a oportunidade de medir o desempenho do meu gerador de fractais, agora na versão Java ME! É um benchmark interessante de aritmética em ponto flutuante. Veja os resultados na Tabela 1.
Runtime utilizado |
Precisão |
Tempo (176x176 pixels) |
Referência (Java SE 6.0, Server) PC com Pentium-D 3,4GHz |
double |
0,012s |
float |
0,012s | |
fixed |
0,040s | |
Emulador do WTK 2.5 PC com Pentium-D 3,4GHz |
double |
1,125s |
float |
0,984s | |
fixed |
1,046s | |
Dispositivo real: Sony Ericsson Z550i, ARM9 ~110MHz sem VFP[2] |
double |
13,588s |
float |
7,041s | |
fixed |
1,266s |
Nota 2: Vector Floating Point, a unidade de ponto flutuante opcional das CPUs ARM. |
Java SE
O primeiro grupo, "Referência", contém os escores para a versão em Java SE do gerador de fractais, modificado para ter um comportamento semelhante ao do Micromandel, mas rodando no HotSpot Server 6.0. Seu desempenho é de longe o melhor, o que não é nenhuma surpresa: calculamos 80 fractais de 176x176 por segundo, mesmo com a precisão aritmética máxima (double). Isso permitiria até mesmo fazer uma animação de 80 quadros por segundo, com cada quadro consistindo de um fractal gerado em tempo real[3].
Para gerar este fractal de teste, o programa executa cerca de 3,2 milhões de vezes cada "passo" (fórmula de Mandelbrot, dentro do loop interno de métodos como calcDouble()). Um desempenho de 0,012s indica que cada passo executou em 4 nanossegundos. A CPU desktop utilizada tem 3,4 ciclos de clock por nanossegundo[4], então cada passo executa em aproximadamente 14 ciclos. Como estas CPUs são superescalares (capazes de executar mais de uma instrução completa por ciclo de clock – até umas cinco instruções por ciclo, na CPU utilizada), este resultado é compatível com a quantidade de operações que temos em cada passo, supondo que temos um compilador JIT que gera um código nativo ideal ou próximo do ideal.
Java ME
No Java ME, vejamos os resultados para precisão double. O emulador do WTK 2.5 é 90 vezes mais lento que o HotSpot Server: duas ordens de magnitude pior, apesar de ambos rodarem no mesmo hardware. Esta diferença pode ter duas causas. Primeiro, o HotSpot Server é o que há de melhor em JVMs, gerando código nativo e acelerado por literalmente centenas de otimizações. Em especial, o HotSpot Server[5] é capaz de explorar as instruções aritméticas MMX/SSE disponíveis no Pentium, entre outras otimizações críticas para o desempenho de cálculos aritméticos. Outro motivo de desvantagem do emulador é que este possui um grande overhead devido às suas ferramentas, monitores, capacidades de depuração e profiling. (O HotSpot Server também tem estas capacidades, mas tem uma arquitetura mais avançada, capaz de eliminar praticamente todo o overhead associado a estas funcionalidades quando elas não estiverem sendo usadas.)
O meu telefone celular, que é um modelo recente (mas não topo de linha: ele me custou menos de R$ 200,00 num plano pós-pago), teve um desempenho 12 vezes pior do que o emulador e cerca e 1.000 vezes pior que o HotSpot Server no PC. Este celular tem uma CPU de 110 MHz[6]: 30 vezes mais lento que o PC em ciclos por segundo. Mas o PC tem outras vantagens, como velocidade da memória, execução superescalar, predicação de desvios etc. Multiplicando os fatores, o celular deve ser no mínimo 100 vezes mais lento que o PC. Ajustando para esta diferença, um escore mil vezes inferior ao do PC se traduz numa JVM cerca de dez vezes menos eficiente.
Nota 3: [1] Se você conseguir uma taxa tão alta num programa Java ME (o que não é difícil com animações simples – sem cálculos matemáticos pesados!), pode limitá-la a um máximo de 30fps, pois o olho humano não percebe variações muito acima dessa freqüência. Esta limitação pode ser obtida com timers, e é importante para poupar a CPU, e por conseqüência a bateria do dispositivo. |
Nota 4: Apesar de a CPU do PC ser dual-core, isso não beneficia o programa MicroMandel, que não é multithreaded. (Mesmo que fosse, a versão Java ME não seria beneficiada, pois a KVM não é capaz de usar múltiplas CPUs.) |
Nota 5: Assim como outras JVMs SE de topo, como o IBM JDK ou BEA JRockit. |
Nota 6: Estimado pelo programa JBenchmark ACE (www.jbenchmark.com). Os fabricantes destes dispositivos raramente publicam especificações com detalhes como modelo e velocidade de CPU. A |
Parte da desvantagem que resta (após descontada a diferença de hardware) certamente é devida ao compilador JIT muito superior do HotSpot Server. Mesmo assim, ainda achei a diferença alta. Devemos ter esquecido algum fator importante. Não será a implementação de aritmética em ponto flutuante? O Pentium possui uma FPU (unidade de ponto flutuante) que faz estes cálculos em hardware, mas a maioria das CPUs para dispositivos limitados não oferece esta capacidade, obrigando os programas a usar uma biblioteca de emulação que implementa por software a aritmética de ponto flutuante IEEE-754[7].
Para conferir, escrevi uma segunda versão do método de cálculo (disponível no download) que usa float ao invés de double. Como podemos ver na Tabela 1, os cálculos com float são quase duas vezes mais rápidos que com double no celular. Este é o resultado esperado em sistemas sem unidade de ponto flutuante: as implementações em software de operações como multiplicação executam num tempo proporcional ao tamanho dos tipos de dados. Já no PC com o HotSpot Server, esta diferença não existe: float tem o mesmo desempenho de double. No emulador há uma pequena vantagem do float, mas isso é típico de JVMs menos otimizadas (por exemplo, usam duas instruções de 32 bits ao invés de uma de 64 bits para fazer uma atribuição de double; não usam MMX/SSE etc.).
Para a prova final, escrevi outra variante do cálculo que utiliza somente aritmética de inteiros (mais precisamente, de "ponto fixo", que simulam aritmética de ponto flutuante com inteiros divididos logicamente em uma parte inteira e uma parte fracionária[8]). No emulador rodando no PC houve pouca vantagem de desempenho – o desempenho com números fixed é até um pouco mais lento que float, pois o código de ponto fixo é mais complexo. No HotSpot Server, o fixed é bem mais lento, provavelmente porque não se beneficia das instruções MMX/SSE, orientadas a cálculo em ponto flutuante. Já no celular, a vantagem foi enorme – 10,7 vezes mais rápido que a versão com ponto flutuante! Tão bom que ficou no mesmo patamar que o emulador: somente 21% mais lento. E a diferença entre a KVM do celular e o HotSpot Server no PC caiu para somente 100 vezes comparando com double/float, ou apenas 30 vezes comparando com o fixed. Isso coincide precisamente com a nossa estimativa de diferença de desempenho dos hardwares, na faixa de 30X a 100X.
Podemos tirar daí algumas conclusões:
· A KVM, ainda que tenha um compilador JIT modesto em comparação com as JVMs para Java SE, é realmente capaz de produzir desempenho similar ao de aplicações nativas no dispositivo.
· Infelizmente, o meu celular não possui FPU, o que é a regra em CPUs ARM9 (embutidos em vários celulares disponíveis no Brasil). Os chips ARM mais recentes, geralmente ARM11, costumam possuir as instruções VFP (Vector Floating Point). Mas estes chips serão mais comumente encontrados em dispositivos maiores e de maior custo, como Smartphones e PDAs.
· Os velhos tempos estão de volta. Quem, como eu, começou a programar em micros de 8 bits de 64 Kb de memória total, e há anos achava que nunca mais precisaria de técnicas extremas de otimização de código (como o uso de ponto fixo, ou mesmo de selecionar os tipos de dados de menor precisão capazes de implementar um algoritmo), terá uma sensação de "déjà vu"... É hora de desenferrujar a capacidade de programar de forma muito eficiente, utilizando recursos de forma espartana, pois isso poderá significar a diferença entre uma aplicação espetacular e uma medíocre – ou simplesmente, entre uma aplicação que consegue ser instalada na maioria dos dispositivos, e outra que simplesmente gera erros de "JAR muito grande".
Nota 7: Norma seguida pelo Java (e maioria das linguagens e CPUs) que especifica a representação binária de números em ponto flutuante, como os double e float do Java, e suas operações fundamentais. Ver “Matemática em Java”, Edição 19. |
Nota 8: No MicroMandel, usamos um int de 32 bits organizado como 8 bits inteiro + 24 bits fracionário. Isso permite representar números de -128 a +127, com uma precisão decimal de ~7 dígitos, suficiente para gerar o fractal de Mandelbrot (pelo menos com as configurações default). Esta artimanha aritmética tem pouca relação com o assunto de Java ME, mas o leitor curioso encontrará o código bem documentado no download do artigo. |
Para aplicações de tempo real como jogos ou animações, é importante fazer testes com um desempenho aproximadamente igual ao dos dispositivos reais. O emulador do WTK tem a capacidade de emular a velocidade do dispositivo. Executando o seu utilitário prefs (pelo NetBeans: Java Platform Manager>Tools and Extensions>Open Preferences), vá na página Performance, habilite Enable VM speed emulation e selecione um valor para VM speed que produza um resultado semelhante ao do seu dispositivo real. Para o meu dispositivo e o teste do MicroMandel, configurando 700 bytecodes/milissegundo, obtive um tempo de execução idêntico ao do celular.
Esta emulação de desempenho é bastante aproximada, pois só cria um limite para o número de bytecodes executados por unidade de tempo, desconsiderando as variações de desempenho entre diferentes bytecodes, as transformações de código feitas pelo compilador JIT e outros fatores. Mas já ajuda muito, especialmente para aplicações "real-time" como jogos. Será frustrante passar um mês trabalhando naquela aplicação revolucionária, que parece muito boa num emulador rodando a todo gás, e na hora de testar em dispositivos reais (o que acabamos fazendo pouco porque dá mais trabalho), vermos que o desempenho é péssimo.
Finalmente, os SDKs proprietários de alguns fornecedores de dispositivos incluem facilidades para emulação no dispositivo. Isso pode exigir algum hardware não incluso na aquisição dos dispositivos, como um cabo especial ligado à porta serial do PC, e pode exigir plug-ins de IDE e versões customizadas do WTK. Mas vale a pena, porque realizar o processo de deploy-teste-debug direto no dispositivo poderá aumentar a sua produtividade.
Conclusões
Sabemos que dispositivos Java ME, como celulares e PDAs possuem um desempenho muito inferior ao de PCs ou servidores, mas temos que lembrar que estes últimos têm performance realmente impressionante. Às vezes perdemos a noção de como as CPUs modernas são rápidas, pois temos que usá-las para executar softwares cada vez mais “gordos”, que consomem qualquer capacidade de processamento disponível. Dito isto, ter 1/30 ou 1/100 desta capacidade de processamento numa maquininha que cabe no seu bolso e capaz de trabalhar horas com uma carga de bateria, é outra maravilha da tecnologia.
O software escrito para tais plataformas precisa compensar essa diferença de velocidade de processamento, bem como outras, como capacidade de armazenagem ou de transmissão de dados. Isso valeria para qualquer linguagem ou toolkit de desenvolvimento para dispositivos móveis, mas podemos verificar que a plataforma Java ME apresenta um desempenho muito satisfatório, e que é possível produzir aplicações Java ME com ótimo desempenho e funcionalidade. Isso também exige escrever código extremamente enxuto, uma arte que parece andar um pouco esquecida até entre programadores de sistemas operacionais – mas que volta com todo o gás nos dispositivos “micro”.
Links
java.sun.com/javame
Página oficial do Java ME.
community.java.net/mobileandembedded
Projeto phoneME (implementação Java ME de código aberto da Sun, incluindo o HotSpot CLDC). O leitor avançado não pode perder os blogs de engenheiros da Sun, como Mark Lam.
proguard.sourceforge.net
ProGuard, o ofuscador de bytecode livre, preferido de três entre três IDEs/extensões para Java ME cobertos nesta série. Também útil para aplicações Java SE/EE que desejem dificultar a ação de larápios ou crackers, ou que também tenham necessidade de reduzir o tamanho dos seus JARs (ex.: para uso com Web Start).
www.jbenchmark.com
Benchmarks de VMs Java ME. Inclui versões gratuitas que vale muito a pena consultar antes de fazer um investimento no seu próximo dispositivo de última geração. Os dispositivos mais caros nem sempre são os de melhor desempenho!