Gerenciamento de memória em Java vs Go: um comparativo técnico

1. Modelo de memória: pilha e heap

A organização da memória é a primeira grande diferença entre Java e Go. Em Java, a JVM gerencia um heap único onde todos os objetos são alocados, enquanto cada thread possui sua própria pilha (stack) para variáveis locais e chamadas de método. Objetos criados com new vão sempre para o heap, e a pilha armazena apenas referências para esses objetos.

Em Go, a alocação é decidida pelo compilador através de escape analysis. Se o compilador determina que uma variável não escapa do escopo da função, ela é alocada na pilha. Caso contrário, vai para o heap. Isso permite que structs pequenos sejam alocados na pilha com frequência, reduzindo a pressão sobre o garbage collector.

// Exemplo Java: objeto sempre no heap
public class Exemplo {
    public void metodo() {
        Objeto obj = new Objeto(); // heap
        int x = 10;               // pilha
    }
}
// Exemplo Go: alocação decidida por escape analysis
func exemplo() {
    x := 10          // pilha
    obj := Objeto{}  // pilha (se não escapar)
    return &obj      // heap (escapou)
}

A diferença fundamental: Java aloca objetos no heap por padrão, enquanto Go tenta manter structs na pilha sempre que possível, resultando em menos alocações dinâmicas e menor latência.

2. Garbage Collection: abordagens e algoritmos

Java oferece múltiplos coletores de lixo, cada um com trade-offs específicos:

  • G1 (Garbage First): coletor generacional com pausas previsíveis, ideal para heaps grandes (4GB+)
  • ZGC: coletor de baixa latência (pausas < 10ms) para heaps de até 16TB
  • Shenandoah: similar ao ZGC, com pausas reduzidas e compactação concorrente

Go utiliza um GC concorrente não-generacional baseado em mark-sweep com três fases: marcação, varredura e liberação. O GC de Go prioriza baixa latência, com pausas típicas abaixo de 500μs, mas sacrifica throughput em troca de previsibilidade.

// Configuração Java para GC de baixa latência
-XX:+UseZGC -Xms4g -Xmx4g

// Go: GC configurado via GOGC (padrão 100%)
// GOGC=100 significa que o GC é acionado quando o heap dobra de tamanho

Os trade-offs são claros: Java permite escolher entre throughput (G1) e latência (ZGC), enquanto Go oferece um único coletor otimizado para latência consistente, mas com maior overhead de CPU em heaps muito grandes.

3. Escape Analysis e otimizações de alocação

Tanto Java quanto Go realizam escape analysis, mas com resultados diferentes. Em Java, o escape analysis da JIT (Just-In-Time) pode alocar objetos na pilha se eles não escaparem do método, mas isso é uma otimização agressiva que nem sempre é aplicada. Objetos que escapam via retorno ou atribuição a campos globais vão para o heap.

Em Go, o escape analysis é feito em tempo de compilação e é mais determinístico. O compilador decide com precisão onde alocar, e essa decisão é visível ao desenvolvedor através do comando go build -gcflags="-m".

// Java: escape analysis pode alocar na pilha
public class EscapeAnalysis {
    public static int soma() {
        Ponto p = new Ponto(1, 2); // pode ser alocado na pilha
        return p.x + p.y;
    }
}
// Go: escape analysis explícito
func soma() int {
    p := Ponto{x: 1, y: 2} // alocado na pilha (não escapa)
    return p.x + p.y
}

// Para verificar o escape analysis:
// go build -gcflags="-m -l" arquivo.go

O impacto prático: Go reduz significativamente a pressão no GC ao alocar structs na pilha, enquanto Java depende mais do heap e, consequentemente, de coletores eficientes.

4. Gerenciamento manual vs automático: ponteiros e referências

Java aboliu ponteiros explícitos, utilizando apenas referências. O desenvolvedor não tem controle direto sobre alocação ou desalocação. Java oferece quatro tipos de referências:

  • Forte: impede a coleta
  • Soft: coletada antes de OutOfMemoryError
  • Weak: coletada na próxima GC
  • Phantom: para ações pós-morte do objeto

Go mantém ponteiros explícitos, mas sem aritmética de ponteiros. O desenvolvedor pode usar new e make para alocar, e tem controle sobre onde a alocação ocorre (heap vs stack) através da decisão de retornar ou não ponteiros.

// Java: referências fracas para caches
WeakReference<MinhaClasse> ref = new WeakReference<>(new MinhaClasse());

// Go: ponteiros explícitos
type Cache struct {
    data map[string]*MeuStruct
}
func (c *Cache) Get(key string) *MeuStruct {
    return c.data[key] // retorna ponteiro, forçando heap
}

Os vazamentos de memória em Java geralmente ocorrem por referências não liberadas em coleções estáticas. Em Go, vazamentos são mais raros, mas podem ocorrer por goroutines que nunca finalizam ou por slices que mantêm referências a arrays subjacentes.

5. Fragmentação de memória e alocação de objetos

Java sofre de fragmentação interna (objetos com alinhamento) e externa (espaços livres entre objetos). O G1 collector realiza compactação durante as pausas, movendo objetos para reduzir fragmentação. Heaps muito fragmentados podem causar falhas de alocação mesmo com memória livre suficiente.

Go utiliza um alocador inspirado no TCMalloc (Thread-Caching Malloc), que mantém caches de memória por thread e por tamanho de objeto. Isso reduz drasticamente a fragmentação e melhora a localidade de referência. O GC de Go também compacta o heap durante a varredura.

// Java: monitorando fragmentação
// jcmd <pid> GC.heap_info

// Go: verificando estatísticas de memória
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("HeapAlloc: %d, HeapFrag: %d\n", m.HeapAlloc, m.HeapFrag)

Sob carga intensa, Go tende a ter menor fragmentação devido ao alocador thread-caching, enquanto Java pode exigir ajustes finos de GC para evitar pausas longas causadas por compactação.

6. Ferramentas de profiling e debugging de memória

Java possui um ecossistema maduro de ferramentas:

  • VisualVM: monitoramento visual de heap, threads e GC
  • Eclipse MAT: análise de heap dumps para encontrar vazamentos
  • jcmd/jmap: ferramentas de linha de comando para diagnóstico rápido
  • JProfiler: profiling avançado com alocação de objetos

Go oferece ferramentas integradas e eficientes:

  • pprof: profiling de CPU, memória e goroutines
  • go tool trace: análise de execução concorrente
  • runtime.ReadMemStats: acesso programático a estatísticas de memória
  • pprof HTTP: exposição de perfis via servidor HTTP
// Java: gerando heap dump
jmap -dump:live,file=heap.hprof <pid>

// Go: profiling de memória
import _ "net/http/pprof"
// Acessar: /debug/pprof/heap?debug=1

Ambos os ecossistemas permitem identificar vazamentos, picos de alocação e gargalos, mas Go oferece ferramentas mais integradas e de mais fácil acesso para iniciantes.

7. Casos práticos e recomendações de escolha

Aplicações com alta taxa de alocações (microserviços, APIs REST): Go se destaca pelo GC de baixa latência e alocação eficiente na pilha. O overhead mínimo de memória (binários menores, heap mais controlado) torna Go ideal para containers e ambientes com recursos limitados.

Sistemas de baixa latência (trading, jogos): Go é frequentemente a melhor escolha devido a pausas de GC abaixo de 500μs, contra pausas de 1-10ms em Java mesmo com ZGC. Para requisitos de latência abaixo de 100μs, Go é praticamente a única opção entre linguagens com GC.

Aplicações legadas e ecossistema maduro: Java ainda vence em cenários que exigem:
- Heaps muito grandes (>100GB)
- Bibliotecas e frameworks maduros (Spring, Hibernate)
- Equipes com expertise em tuning de JVM
- Requisitos de throughput máximo (G1 collector)

A decisão final deve considerar:
- Perfil de alocação: Go para alocações frequentes de objetos pequenos
- Requisitos de pausa: Go para latência <1ms, Java ZGC para <10ms
- Tamanho do heap: Java para heaps >50GB, Go para heaps <20GB
- Ecossistema: Java para aplicações enterprise, Go para cloud-native

Referências