• Nenhum resultado encontrado

Descritores de Tipos

No documento Java 122 (páginas 45-47)

Em geral, os maiores culpados pela “fama” de má performance atribuída a API de serialização são os chamados descritores de tipo (type descriptors), que são representados pela classe java.

io.ObjectStreamClass (OSC).

A classe OSC é responsável por armazenar informações a respei- to do formato de uma classe. Assim como os objetos do tipo java.

lang.Class, essa classe é um contêiner de metadados, que arquiva

“dados a respeito de dados” de uma classe a ser serializada. Dado um tipo T, o objeto OSC associado a T guarda as infor- mações como o nome da classe que representa T e uma coleção de informações de cada campo não transiente, que são encapsu- ladas em instâncias da classe ObjectStreamField. Por exemplo, a instância de OSC associada à classe java.lang.Integer pode ser pensada como a tupla [Integer.class,[value],[+ outras informa-

ções]]. Nesta tupla, value é o campo da classe Integer que de fato

retém o valor numérico de 32 bits e está associado a uma instância de ObjectStreamField.

Durante o processo de serialização de um objeto, na primeira vez que um determinado tipo é encontrado, seu descritor é escrito no stream de destino (OutputStream) e um identificador numérico é atribuído ao mesmo. Nas próximas vezes que esse tipo for en- contrado, apenas esse “id” será escrito no stream, ao invés do des- critor completo. Por exemplo, os trechos de código da Listagem 1 produzem, respectivamente, 81 bytes e 91 bytes.

Ou seja, para serializar uma única instância de Integer são necessários 81 bytes! Desses 81 bytes, aproximadamente 95% dos conteúdos são os metadados contidos no descritor da classe java.

lang.Integer e o que realmente interessa (valor do número, 4 bytes)

Listagem 1. Overhead de type descriptors.

Integer first=1; Integer second=2;

ByteArrayOutputStream bb = new ByteArrayOutputStream();

try(ObjectOutputStream oos = new ObjectOutputStream(bb)){

oos.writeObject(first); } //81 bytes byte[] b = bb.toByteArray(); bb.reset();

try(ObjectOutputStream oos = new ObjectOutputStream(bb)){

oos.writeObject(first); oos.writeObject(second); }

//91 bytes b = bb.toByteArray();

corresponde apenas a 5%. Esquematicamente poderíamos repre- sentar a serialização de um Integer como mostrado na Figura 3. Note que à medida que um descritor é serializado, um identifi- cador numérico (7) é atribuído ao mesmo. O número 7 é apenas um exemplo, pois a atribuição número - descritor é realizada de maneira ad hoc e controlada pela API do Java, podendo variar de acordo com a ordem em que objetos são serializados. Quando um novo Integer for serializado no mesmo contexto, isto é, antes do método close() de ObjectOutputStream ser invocado, o identi- ficador numérico será escrito ao invés de toda a informação do descritor (Figura 4). Por isso há um acréscimo de apenas 10 bytes quando se serializa dois Integers, totalizando 91 bytes.

Figura 3. Serialização de um Integer

Figura 4. Serialização de dois Integers

Este é um esquema simplificado do processo de serialização padrão do Java. Na verdade a API escreve além do descritor da classe, todos os descritores da hierarquia, até chegar a uma classe que herde diretamente de java.lang.Object! No caso, como Integer é filha de java.lang.Number, o descritor associado à mesma também seria escrito no Stream. O número ‘Flags’ é utilizado como um controle interno da API, que marca, por exemplo, se a serialização é customizada. Como Integer só tem um campo, a serialização demarcará apenas um ObjectStreamField, representado pelo par [I,value]. Este par é composto por um caractere, conhecido como typeCode e por uma String que corresponde ao nome do campo associado. No exemplo, o caractere “I” corresponde ao typeCode do tipo primitivo int e value corresponde ao nome do campo encapsulado na classe Integer. No total existem apenas 10 typeCodes, um para cada tipo primitivo, um para objetos (‘L’) e um para arrays (‘[‘).

Nota

Voltando ao mundo do C2N no Hibernate, se recordarmos da estrutura de CacheEntry (veja a Figura 3 da Parte 1) e da forma em que é constituída (processo de disassembling), percebemos que uma instância de CacheEntry pode conter um array de

objetos serializáveis de diversos tipos, o que significa que todos os descritores distintos deverão ser integralmente serializados! Para termos uma ideia da dimensão deste custo, consideremos a entidade RichTypeEntity descrita pela Listagem 2.

Ignorando o campo id (que é atribuído à classe CacheKey), a estrutura gerada pelo disassembling do Hibernate ao colocar a

CacheEntry associada a uma instância de RichTypeEntity no C2N

conterá cinco tipos distintos. Se repetirmos o código da Listagem 1, utilizando, ao invés de inteiros, arrays com valores dos tipos encapsulados por RichTypeEntity, verificamos que serializar um objeto custa 275 bytes e dois objetos, no mesmo contexto, 344 bytes (considerando para o campo b strings com apenas um caractere). A conclusão que chegamos é similar à verificada na serialização de Integers, isto é, a maior parte dos dados que são serializados em uma invocação do método writeObject() são, na verdade, metadados!

Antes de mencionarmos como podemos otimizar os custos de serialização, vamos mostrar e analisar algumas evidências que indicam que, de fato, há um custo razoável atribuído à serialização de objetos. A Tabela 1 apresenta algumas medições que eviden- ciam o efeito causado pela serialização de descritores.

Os resultados desta tabela medem o tempo médio para efetuar 10.000 operações do tipo session.get(RichTypeEntity.class, id) em diferentes configurações de cache e hardware (disco). O que é interessante notar é que mesmo com um disco de estado sólido muito superior a um disco mecânico (de 10 a 100 vezes mais rá- pido, dependendo do cenário de operações de IO), os resultados para leituras de cache em disco são muito próximos. Isso indica que o cache em disco é muito mais limitado pela qualidade da CPU do que do disco em si e, portanto otimizações no nível da aplicação podem ser relevantes, resultando em ganhos da ordem de 80% neste caso.

Listagem 2. Entidade ‘rica’ em tipos diferentes.

@Entity

public class RichTypeEntity {

@Id long id; @Column int a; @Column String b; @Temporal(TemporalType.DATE) @Column Date c; @Column Long d; @Column Double e; //gets e sets }

HDD Sem Cache Cache em Memória Cache em Disco Cache em Disco, otimizado

Toshiba, mecânico, 5400rpm 6.1s 60ms 1093ms 593ms Corsair Neutron GTX, SSD 5.1s 62ms 1030ms 572ms

Tabela 1. Microbenchmarks de operações ‘get’ no C2N utilizando diferentes camadas de armazenamento

Microbenchmarks são medidas de desempenho de uma de forma isolada, normalmente executada diversas vezes para ter uma média. Em Java os resultados de um microbenchmark podem variar bastante dependendo das opções de inicialização da JVM (e.g. -XX:+AggressiveOpts), do garbage collector e principalmente do compilador JIT. É uma boa prática quando se realiza um microbenchmark deixar a JVM ser “aquecida” antes de começar a tomar medições, pois algumas otimizações do compilador da JVM só surtem efeito após certo número de vezes que determinado método é invocado. Neste artigo, todos os testes foram realizados utilizando o Java 7 update 25 (jdk 7u25) com a opção -server. Os caches em disco foram realizados no sistema operacional OSX Mountain Lion, processadores Intel core i7 2.7GHz e 4GB de memória à 1333MHz. Os testes envolvendo replicação utilizaram servidores com sistema operacional Red Hat Enterprise Linux versão 5.5, processadores Intel Xeon E7440(x4) 2.4GHz e 32GB de memória à 2400MHz.

Nota

Figura 5. Heaps com estruturas padrão do C2N (esq.) e otimizadas (dir.)

O que é interessante observar também é o formato do Heap ao longo da execu- ção desses testes. A Figura 5 mostra um comparativo dos picos de utilização de memória nas duas execuções

É notável o que um teste simples como esse pode revelar em termos de utilização de memória.

As montanhas nos gráficos mostram a evolução das regiões do Heap ao longo do tempo. Os gráficos superiores referem-se à utilização do GC padrão (PS Scavenge/ MarkSweep), ondeas montanhas em azul representam o espaço ocupado na região do Eden (novos objetos). Para esse coletor, as alocações atingem picos de 150MB para a versão default e 65MB para a versão com classes do C2N otimizadas.

Os gráficos inferiores mostram a evo- lução do Heap com o coletor G1, que é ativado com a opção de inicialização da VM: -XX:+UseG1GC, disponível desde o update 14 do JDK 6. Nesses gráficos, as montanhasverdes correspondem ao “G1 Eden”, e embora os picos de utilização de memória sejam similares em ambas as versões, o teste na versão não otimizada rodou por muito mais tempo(~45s contra ~33s da versão otimizada) e foi o único que constatou um tempo de GC superior a 1 segundo, ocasionado pela soma de todas as “minor collections”, que podem ser observadas na Figura 6. Embora essas medidas tenham sido obtidas com um profiler, é muito simples obter estatísticas de GC pela API de JMX, como mostra o trecho de código da Listagem 3.

No teste não foram registradas coleções do tipo “stop the world” (full gc), que para todas as threads da aplicação para tentar liberar memória de todas as regiões do heap. A função principal de uma coleção completa é tentar liberar memória de regi- ões que armazenam objetos “antigos”, que sobreviveram a diversos ciclos de minor collections, apresentadas nos gráficos.

Note que o tempo total de execução ficou muito próximo com estruturas otimizadas para ambos os GCs, enquanto que com as estruturas “padrão” a diferença entre MarkSweep (~39s) e G1(~45s) é da ordem de 6s. Essa diferença pode ser atribuída à natureza dos coletores. O MarkSweep é

Listagem 3. Obtendo estatísticas de GC programaticamente. public void dumpGCStatistics(){

List<GarbageCollectorMXBean> gcs = ManagementFactory.getGarbageCollectorMXBeans(); for (GarbageCollectorMXBean gc : gcs) {

logger().info(“GC: {}. Regiões Coletadas: {}. # de Coleções: {}. Tempo de Coleção: {}”, new Object[]{ gc.getName(),Arrays.toString(gc.getMemoryPoolNames()),

gc.getCollectionCount(),gc.getCollectionTime() });

} }

um coletor do tipo “throughput”, isto é, enquanto há espaço, ele prioriza a aloca- ção, adiando a coleta de lixo. Já o G1 é um coletor que prima por reutilizar o espaço disponível, compactando e rearranjando as regiões do Heap. Como veremos, as estruturas otimizadas reduzem a criação

de objetos que migram do disco para a memória, o que “facilitou a vida” do G1 no teste.

No documento Java 122 (páginas 45-47)

Documentos relacionados