Esse artigo faz parte da revista Java Magazine edição 05. Clique aqui para ler todos os artigos desta edição

Clique aqui para ler esse artigo em PDF.imagem_pdf.jpg

Memória e desempenho

Garbage Collection e técnicas e otimização

No artigo "Performance em Java" (Edição 3), analisamos parte da tecnologia empregada por JVMs para produzir alto desempenho, com foco nos compiladores JIT (Just-In-Time). Agora examinaremos outro componente crítico da JVM, conhecido tecnicamente como “memória de objetos”, ou popularmente como GC (Garbage Collector). A forma popular é menos precisa, pois a Garbage Collection (que também será abreviada como GC) é apenas uma parte do trabalho de organização da memória, que envolve gerenciamento do heap, alocação e limpeza.

O foco deste artigo é novamente na implementação em Java, embora os conceitos teóricos discutidos se apliquem também a outras plataformas. As dicas mais práticas são específicas para o Sun JDK 1.4.1, mas podem se aplicar a outras JVMs e a VMs em geral. E para não dizerem que eu só elogio o Java, veremos também alguns problemas e possíveis necessidades de melhoria.

Novatos em Java podem estranhar a ênfase dada ao assunto, em oposição a linguagens com “memória manual” como C++. Nessas linguagens, sabemos que há um espaço de memória conhecido como heap, que podemos alocar e desalocar com operações como new e delete. O heap é obtido sob demanda do sistema operacional e cresce automaticamente se necessário.

Programadores experientes sabem que alocação dinâmica pode ser um problema de desempenho. O heap é organizado como uma coleção de blocos livres ou ocupados; cada alocação individual exige localizar um endereço ideal no espaço livre, e cada desalocação individual pode exigir operações de desfragmentação do heap. Se desalocamos dois blocos de 4 Kb de endereços contíguos, é preciso combinar o espaço resultante num único bloco de 8 Kb, pois a fragmentação de espaço livre pode reduzir o desempenho e aumentar o heap desnecessariamente. Conscientes do problema, muitos programadores criam mecanismos para alocar memória com maior eficiência, como os allocators do C++; mas com isso estão aumentando a “complexidade acidental” e fazendo, à mão, um trabalho que deveria ser responsabilidade da linguagem/runtime.

No caso de Java e de outras linguagens de alto nível, a alocação é manual (usando new), mas a desalocação é automática, não existindo delete ou uma operação equivalente. A mudança pode parecer pequena, mas tem conseqüências profundas (se fosse fácil, ninguém faria de outro modo). A capacidade de desalocar memória automaticamente exige uma sofisticação muito superior à de gerenciadores de memória manual: o heap deve ser organizado de forma diferente e a linguagem siga determinadas regras; especialmente, a manipulação de ponteiros deve ser restrita.

Em Java, não podemos converter valores inteiros em ponteiros e vice-versa; não podemos executar operações aritméticas (como ++) sobre ponteiros; não podemos apontar para endereços arbitrários, nem ter ponteiros não-tipados (como void* do C); ponteiros para ponteiros, ou conversões entre ponteiros incompatíveis também são proibidas. Filosoficamente, não podemos assumir que o valor de um ponteiro tem qualquer relação com um endereço físico na memória.

Usamos o termo referência para ponteiros submetidos a essas regras. O processo de coleta de lixo precisa saber identificar todas as referências entre objetos do heap, de forma inequívoca, o que é possível quando o programa obedece às regras expostas para referências, mas inviável de outra forma.

É interessante observar que essas regras, ao mesmo tempo em que tornam a memória automática possível, também são fatores de segurança, impedindo que crashes sejam causados pelo uso de memória de forma indevida.

Como a GC funciona

Vamos começar examinando a estrutura de um objeto Java. Além dos atributos declarados pela sua classe, cada objeto possui um cabeçalho (header) com atributos especiais usados pela VM. Esses atributos não são diretamente acessíveis ao programador, nem são padronizados (dependem da implementação da VM), mas, de maneira geral, temos sempre um ponteiro para a classe do objeto (em Java, objeto.getClass() retorna desse ponteiro), além de uma coleção de flags usadas para várias operações especiais, como a coleta de lixo ou o controle de hashCodes.

Origens

No princípio, havia a contagem de referência – inserimos um contador (“int ref”) no cabeçalho dos objetos. Ao criarmos um objeto o contador é zero; é incrementado ao criar uma nova referência para o objeto, e decrementado quando uma referência é eliminada. Se o contador cai a zero, o objeto é apagado. O sistema de contagem de referências tem vários problemas. Exige mais código em cada atribuição de referências (pode ser inserido automaticamente, mas isso traria um overhead). O contador ocupa muito espaço, e pode ser enganado por referências cíclicas, resultando nos odiados “memory leaks”. Finalmente, cada desalocação ainda é individual, tão ineficiente quanto o apagamento manual.

É fácil adicionar esse mecanismo a sistemas sem suporte nativo à GC, como C/C++. Muitas vezes isso é implementado por bibliotecas ou pelo programador. Mas é tão primitivo que não chega a ser considerado um método de GC sério. Nunca acredite que linguagens como C++ suportam GC; existem até produtos comerciais, que usam abordagens superiores à descrita, mas esses produtos têm que lutar contra restrições de linguagens e bibliotecas que não são projetadas para serem amigáveis à GC. As conseqüências são a impossibilidade de adotar os algoritmos de coleta de lixo mais sofisticados, além de um código híbrido (com alguns objetos apagados manualmente e outros coletados), que reduz os benefícios da GC e deixa tudo mais confuso.

...

Quer ler esse conteúdo completo? Tenha acesso completo