Sem Lixo no Loop — Pools, ECS e o Coletor nos Jogos

Sem Lixo no Loop — Pools, ECS e o Coletor nos Jogos

Nesta aula você mede quanto um jogo aloca por quadro, compara três jeitos de guardar 30 mil partículas (objetos novos, um Pool e arrays), conhece as armadilhas de reaproveitar objetos, monta um ECS de brinquedo para entender a arquitetura e compara dois coletores de lixo do Java 25, o G1 e o ZGC, pelo quadro mais...
Java

• • 23 min de leitura

Nesta aula você mede quanto um jogo aloca por quadro, compara três jeitos de guardar 30 mil partículas (objetos novos, um Pool e arrays), conhece as armadilhas de reaproveitar objetos, monta um ECS de brinquedo para entender a arquitetura e compara dois coletores de lixo do Java 25, o G1 e o ZGC, pelo quadro mais lento de cada um.

Existe uma frase antiga: "Java não serve para jogos por causa do coletor de lixo". Ela vem de uma época real. Em 2005 um coletor podia parar o programa por centenas de milissegundos, e um jogo a 60 quadros por segundo tem 16,7 ms por quadro para fazer tudo. Minecraft, escrito em Java, é a resposta mais famosa a essa frase, como vimos em "Um Novo Território — Java nos Jogos, o Game Loop e o Caso Minecraft". Mas a frase continua tendo um fundo de verdade que vale entender: uma pausa de 10 ms no lugar errado é um "engasgo" visível. Nesta aula vamos medir, com números desta máquina, quando isso acontece e o que fazer.

A regra zero: medir antes de mexer

Antes de otimizar qualquer coisa, precisamos de um número. O Java tem uma forma direta de perguntar "quantos bytes esta thread alocou até agora?":

import br.com.exemplo.asteroides.*;

import java.lang.management.ManagementFactory;

/** Quantos bytes o Mundo do Asteroids aloca por passo, atirando sem parar por 60 s de jogo. */
public class MedirAsteroids {
    public static void main(String[] a) {
        var mx = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
        long eu = Thread.currentThread().threadId();
        for (int rodada = 0; rodada < 3; rodada++) {                 // 2 rodadas de aquecimento (JIT)
            var mundo = new Mundo(42);
            var cmd = new Comandos(0.3f, true, true);
            long antes = mx.getThreadAllocatedBytes(eu);
            int passos = 60 * 60;
            for (int i = 0; i < passos; i++) mundo.atualizar(1 / 60f, cmd);
            long bytes = mx.getThreadAllocatedBytes(eu) - antes;
            if (rodada == 2) System.out.printf("Asteroids: %,d bytes em %d passos = %,d bytes por passo%n", bytes, passos, bytes / passos);
        }
    }
}

O Mundo é o do Asteroids da aula de colisão. Ele cria um Tiro novo a cada disparo, cria listas temporárias a cada passo e percorre coleções com iteradores. Resultado:

Asteroids: 267,096 bytes em 3600 passos = 74 bytes por passo

74 bytes por passo. A 60 passos por segundo, isso dá 4,4 KB/s. Uma geração jovem de dezenas de megabytes levaria horas para encher. Isso não é um problema, e não vale a pena mexer em nada.

Por que tão pouco, se o código faz new ArrayList<>() todo passo? Por causa da análise de escape do compilador JIT: quando ele prova que um objeto não sai do método (não é guardado num campo nem devolvido), pode desmontá-lo em variáveis locais, e o objeto nunca chega a existir no heap. Muitos iteradores e listas temporárias desaparecem assim.

Para saber onde as alocações restantes acontecem, o Java traz o JFR (Java Flight Recorder):

java -XX:StartFlightRecording=filename=ast.jfr,settings=profile -cp ... MedirAsteroids
jfr print --events jdk.ObjectAllocationSample ast.jfr | grep -A3 "stackTrace"

No Asteroids, os locais mais amostrados foram ArrayList.iterator(), a lista novos em Mundo.colisoesTiroAsteroide() e o próprio Mundo.atualizar. Com esse mapa, se algum dia for preciso, sabemos exatamente o que atacar primeiro.

Onde dói de verdade: partículas

O Asteroids aloca pouco porque tem poucos objetos. Partículas são o caso oposto: milhares de objetos minúsculos nascendo e morrendo a cada quadro. Montei um teste de estresse com 30 mil partículas vivas, em que 1.500 nascem e 1.500 morrem a cada quadro, uma "explosão contínua", guardadas de três jeitos:

import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.Pool;

import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.SplittableRandom;

/**
 * 30 mil partículas vivas; a cada quadro, 1.500 morrem e 1.500 nascem (uma explosão contínua).
 * Três jeitos de guardar a mesma coisa. Uso: java Particulas objetos|pool|arrays
 */
public class Particulas {
    static final int VIVAS = 30_000, NASCEM = 1_500, QUADROS = 3_000;
    static final float DT = 1 / 60f;

    interface Sistema { void quadro(SplittableRandom r); int vivas(); float soma(); }

    // ---------- A. Objetos novos a cada nascimento ----------
    static final class Particula {
        final Vector2 posicao = new Vector2(), velocidade = new Vector2();
        float vida;
        Particula iniciar(SplittableRandom r) {
            posicao.set(0, 0);
            velocidade.set((float) r.nextDouble(-5, 5), (float) r.nextDouble(0, 10));
            vida = (float) r.nextDouble(0.1, 0.9);
            return this;
        }
    }

    static final class ComObjetos implements Sistema {
        final List<Particula> lista = new ArrayList<>();
        public void quadro(SplittableRandom r) {
            for (int i = 0; i < NASCEM; i++) lista.add(new Particula().iniciar(r));
            for (Particula p : lista) { p.velocidade.y -= 9.8f * DT; p.posicao.mulAdd(p.velocidade, DT); p.vida -= DT; }
            lista.removeIf(p -> p.vida <= 0);
        }
        public int vivas() { return lista.size(); }
        public float soma() { float s = 0; for (Particula p : lista) s += p.posicao.y; return s; }
    }

    // ---------- B. Pool do libGDX: objetos reaproveitados ----------
    static final class ParticulaReciclavel implements Pool.Poolable {
        final Vector2 posicao = new Vector2(), velocidade = new Vector2();
        float vida;
        @Override public void reset() { posicao.setZero(); velocidade.setZero(); vida = 0; }   // estado limpo ao devolver
    }

    static final class ComPool implements Sistema {
        final Pool<ParticulaReciclavel> pool = new Pool<>(64, 64_000) {
            @Override protected ParticulaReciclavel newObject() { return new ParticulaReciclavel(); }
        };
        final Array<ParticulaReciclavel> vivas = new Array<>(false, 40_000);   // false: sem ordem → remoção O(1)
        public void quadro(SplittableRandom r) {
            for (int i = 0; i < NASCEM; i++) {
                var p = pool.obtain();
                p.velocidade.set((float) r.nextDouble(-5, 5), (float) r.nextDouble(0, 10));
                p.vida = (float) r.nextDouble(0.1, 0.9);
                vivas.add(p);
            }
            for (int i = vivas.size - 1; i >= 0; i--) {               // de trás para frente: remover não pula ninguém
                var p = vivas.get(i);
                p.velocidade.y -= 9.8f * DT;
                p.posicao.mulAdd(p.velocidade, DT);
                p.vida -= DT;
                if (p.vida <= 0) { vivas.removeIndex(i); pool.free(p); }   // devolve em vez de largar para o GC
            }
        }
        public int vivas() { return vivas.size; }
        public float soma() { float s = 0; for (int i = 0; i < vivas.size; i++) s += vivas.get(i).posicao.y; return s; }
    }

    // ---------- C. Arrays por atributo (o jeito ECS/"orientado a dados") ----------
    static final class ComArrays implements Sistema {
        final int cap = 64_000;
        final float[] x = new float[cap], y = new float[cap], vx = new float[cap], vy = new float[cap], vida = new float[cap];
        int n;
        public void quadro(SplittableRandom r) {
            for (int i = 0; i < NASCEM; i++, n++) {
                x[n] = 0; y[n] = 0;
                vx[n] = (float) r.nextDouble(-5, 5); vy[n] = (float) r.nextDouble(0, 10);
                vida[n] = (float) r.nextDouble(0.1, 0.9);
            }
            for (int i = 0; i < n; i++) {                              // cada "sistema" varre arrays contíguos
                vy[i] -= 9.8f * DT;
                x[i] += vx[i] * DT;
                y[i] += vy[i] * DT;
                vida[i] -= DT;
            }
            for (int i = n - 1; i >= 0; i--) {
                if (vida[i] <= 0) {                                    // remove trocando pela última
                    n--;
                    x[i] = x[n]; y[i] = y[n]; vx[i] = vx[n]; vy[i] = vy[n]; vida[i] = vida[n];
                }
            }
        }
        public int vivas() { return n; }
        public float soma() { float s = 0; for (int i = 0; i < n; i++) s += y[i]; return s; }
    }

    public static void main(String[] args) {
        Sistema s = switch (args[0]) {
            case "objetos" -> new ComObjetos();
            case "pool" -> new ComPool();
            default -> new ComArrays();
        };
        var r = new SplittableRandom(7);
        for (int i = 0; i < 600; i++) s.quadro(r);                     // aquecimento: enche até o regime e aquece o JIT

        var mx = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
        long eu = Thread.currentThread().threadId();
        long gcsAntes = coletas(), bytesAntes = mx.getThreadAllocatedBytes(eu), t0 = System.nanoTime();
        long pior = 0;
        for (int i = 0; i < QUADROS; i++) {
            long q = System.nanoTime();
            s.quadro(r);
            pior = Math.max(pior, System.nanoTime() - q);
        }
        long ns = System.nanoTime() - t0, bytes = mx.getThreadAllocatedBytes(eu) - bytesAntes;
        System.out.printf("%-8s vivas=%,6d | %,9d bytes/quadro | média %6.3f ms/quadro | pior quadro %6.2f ms | coletas: %d (soma %.1f)%n",
                args[0], s.vivas(), bytes / QUADROS, ns / 1e6 / QUADROS, pior / 1e6, coletas() - gcsAntes, s.soma());
    }

    static long coletas() {
        long c = 0;
        for (var gc : ManagementFactory.getGarbageCollectorMXBeans()) c += Math.max(0, gc.getCollectionCount());
        return c;
    }
}

Rodando com java -Xmx256m -XX:+UseG1GC Particulas objetos|pool|arrays no JDK 25, preso a dois núcleos do processador (taskset -c 0,1, para os tempos não dependerem de quantos núcleos sobram):

objetos  vivas=44,157 |   113,739 bytes/quadro | média  0.373 ms/quadro | pior quadro   5.94 ms | coletas: 2 (soma 37286.5)
pool     vivas=44,157 |         0 bytes/quadro | média  0.346 ms/quadro | pior quadro   1.52 ms | coletas: 0 (soma 37286.6)
arrays   vivas=44,157 |         0 bytes/quadro | média  0.140 ms/quadro | pior quadro   0.23 ms | coletas: 0 (soma 37286.6)

(As partículas vivas passam de 30 mil porque a vida sorteada vai de 0,1 a 0,9 s. No regime, ficam cerca de 44 mil.)

O que os números mostram:

  • A média engana, o pior quadro não. Em média, até a versão com objetos cabe folgada nos 16,7 ms. Mas a versão com objetos teve um quadro de 5,94 ms, mais de um terço do orçamento gasto de uma vez, e foi exatamente num quadro em que o coletor entrou: marcando o instante do pior quadro e comparando com o log do coletor, ele caiu dentro de uma pausa nas três execuções que fiz. Os bytes e as coletas se repetem iguais a cada execução; os tempos variam com a máquina e com o momento. O jogador não sente a média. Ele sente o engasgo.
  • O Pool zera a alocação. Nenhum byte por quadro e nenhuma coleta em 3.000 quadros. A média quase não muda (aqui, 0,346 contra 0,373 ms; com todos os núcleos livres, o Pool chegou a ficar um pouco mais lento): o que ele elimina é o engasgo, e o pior quadro cai para 1,5 ms.
  • Os arrays por atributo são duas a três vezes mais rápidos que objetos. Aqui o motivo já não é o coletor, e sim o cache do processador. Os float de todas as partículas estão lado a lado na memória, e o processador lê blocos de 64 bytes de cada vez. Com objetos, cada partícula e cada Vector2 estão em lugares diferentes do heap, e cada acesso é uma viagem à memória.
  • A soma é a mesma (37286,5 contra 37286,6, arredondamento de float). Os três sistemas simulam exatamente a mesma coisa.

O Pool do libGDX por dentro

O com.badlogic.gdx.utils.Pool é pequeno: é um Array de objetos livres.

  • obtain() devolve um objeto livre ou, se não houver, chama newObject().
  • free(obj) põe o objeto de volta na lista de livres e chama reset() nele, se ele implementar Pool.Poolable.
  • new Pool<>(inicial, max) limita quantos livres ficam guardados. Acima disso, o objeto devolvido é descartado e vira lixo normal.
  • fill(n) pré-cria n objetos. Faça isso numa tela de carregamento, para o primeiro tiroteio não criar tudo de uma vez.
  • O campo peak mostra o máximo de livres que já houve, útil para escolher o max.

A simplicidade tem um preço: o Pool não protege você de nada. A documentação diz isso textualmente sobre o free: "o pool não verifica se um objeto já foi liberado". Este programa comete as três armadilhas clássicas de propósito:

import com.badlogic.gdx.utils.Pool;

/** Três jeitos de se machucar com um Pool — cada um mostrado de propósito. */
public class PoolArmadilhas {
    static class Bala implements Pool.Poolable {
        float x, y; boolean viva;
        @Override public void reset() { x = 0; y = 0; viva = false; }
    }
    static class Faisca { float x, y; int cor; }             // NÃO é Poolable: o reset não acontece sozinho

    public static void main(String[] args) {
        Pool<Bala> balas = new Pool<>() { protected Bala newObject() { return new Bala(); } };

        // 1) Liberar duas vezes: o Pool não confere — o mesmo objeto sai duas vezes
        Bala b = balas.obtain();
        balas.free(b);
        balas.free(b);                                        // erro silencioso
        Bala p1 = balas.obtain(), p2 = balas.obtain();
        System.out.println("1) liberar duas vezes → obtain() devolveu o MESMO objeto duas vezes? " + (p1 == p2));

        // 2) Guardar referência depois do free: o "fantasma" muda quando outro dono reaproveita
        Bala minha = balas.obtain();
        minha.x = 10; minha.viva = true;
        Bala alvoDoInimigo = minha;                           // alguém guardou a referência...
        balas.free(minha);                                    // ...e eu devolvi ao Pool
        Bala nova = balas.obtain();                           // outro dono pega o mesmo objeto
        nova.x = 99; nova.viva = true;
        System.out.println("2) referência velha agora aponta para a bala nova: x=" + alvoDoInimigo.x);

        // 3) Esquecer o reset: objeto não-Poolable volta com o estado antigo
        Pool<Faisca> faiscas = new Pool<>() { protected Faisca newObject() { return new Faisca(); } };
        Faisca f = faiscas.obtain();
        f.cor = 0xff0000;
        faiscas.free(f);
        System.out.println("3) sem reset, a faísca 'nova' já nasce vermelha: cor=" + Integer.toHexString(faiscas.obtain().cor));
    }
}
1) liberar duas vezes → obtain() devolveu o MESMO objeto duas vezes? true
2) referência velha agora aponta para a bala nova: x=99.0
3) sem reset, a faísca 'nova' já nasce vermelha: cor=ff0000

As três viram bugs difíceis de achar num jogo real:

  • Liberar duas vezes. Dois inimigos passam a dividir a mesma bala, e mover um move o outro.
  • Referência guardada depois do free. Um míssil teleguiado persegue um alvo que "morreu" e renasceu como outra coisa do outro lado da tela.
  • Esquecer o reset. Objetos "novos" nascem com o estado do anterior.

A regra que evita as três: cada objeto do Pool tem exatamente um dono, e quem devolve ao Pool apaga toda referência que tinha a ele. É o mesmo tipo de disciplina de ciclo de vida da aula do Box2D, em que usar uma junta de um corpo destruído derrubou o processo. A diferença é que aqui o erro não derruba nada: ele corrompe o jogo em silêncio.

Um Pool também tem um custo menos óbvio. Os objetos livres ficam vivos para sempre, e com o tempo o coletor os promove para a geração velha. Um Pool gigante, cheio depois de um pico, ocupa essa memória até o fim do jogo. É para isso que serve o max.

ECS: a arquitetura que nasce dos arrays

A versão com arrays foi a mais rápida, mas "cinco arrays de float" não parece uma arquitetura. O ECS (Entity–Component–System) é o jeito de organizar um jogo inteiro nessa lógica:

  • Entidade é só uma identidade, normalmente um número. Ela não tem comportamento nem dados próprios.
  • Componente é um pedaço de dado puro: Posicao, Velocidade, TempoDeVida, Sprite. Uma entidade "é" o conjunto de componentes que ela tem.
  • Sistema é um laço que processa todas as entidades que têm os componentes que ele pede: o SistemaDeMovimento pede Posicao e Velocidade.

A diferença para a orientação a objetos de todo o curso é o eixo da composição. Em vez de class Inimigo extends Personagem, um inimigo é "uma entidade com Posicao + Velocidade + Vida + IA". Para criar um inimigo que voa, basta não adicionar o componente Gravidade, sem nenhuma subclasse nova. Um ECS de brinquedo, com componentes guardados em arrays:

import java.util.BitSet;

/**
 * Um ECS de brinquedo, para enxergar a ideia:
 *  - ENTIDADE é só um número (índice);
 *  - COMPONENTE é um dado puro, guardado em arrays paralelos (um "armazém" por tipo);
 *  - SISTEMA é um laço que passa por todas as entidades que têm os componentes que ele pede.
 */
public class MiniEcs {
    static final int MAX = 10_000;

    // --- componentes (dados, sem comportamento) ---
    static final BitSet temPosicao = new BitSet(MAX), temVelocidade = new BitSet(MAX),
                        temVida = new BitSet(MAX), vivas = new BitSet(MAX);
    static final float[] px = new float[MAX], py = new float[MAX];         // Posicao
    static final float[] vx = new float[MAX], vy = new float[MAX];         // Velocidade
    static final float[] vida = new float[MAX];                            // TempoDeVida

    // --- entidades ---
    static int criar() {
        int id = vivas.nextClearBit(0);                                    // reaproveita ids livres
        vivas.set(id);
        return id;
    }
    static void destruir(int id) {
        vivas.clear(id); temPosicao.clear(id); temVelocidade.clear(id); temVida.clear(id);
    }

    // --- sistemas (comportamento, sem dados próprios) ---
    static void sistemaDeMovimento(float dt) {                            // pede Posicao + Velocidade
        for (int e = temVelocidade.nextSetBit(0); e >= 0; e = temVelocidade.nextSetBit(e + 1)) {
            if (!temPosicao.get(e)) continue;
            vy[e] -= 9.8f * dt;
            px[e] += vx[e] * dt;
            py[e] += vy[e] * dt;
        }
    }
    static void sistemaDeEnvelhecimento(float dt) {                      // pede TempoDeVida
        for (int e = temVida.nextSetBit(0); e >= 0; e = temVida.nextSetBit(e + 1)) {
            vida[e] -= dt;
            if (vida[e] <= 0) destruir(e);                                 // limpar o bit atual não atrapalha o laço
        }
    }

    public static void main(String[] args) {
        var mx = (com.sun.management.ThreadMXBean) java.lang.management.ManagementFactory.getThreadMXBean();
        var sorteio = new java.util.SplittableRandom(7);
        int parede = criar();                                              // parede: só Posicao — nenhum sistema a move
        temPosicao.set(parede); px[parede] = 5;

        long antes = 0; int passos = 0;
        for (int passo = 0; passo < 6000; passo++) {
            if (passo == 1000) antes = mx.getCurrentThreadAllocatedBytes(); // mede depois do aquecimento
            for (int i = 0; i < 20; i++) {                                 // nascem 20 faíscas por passo
                int f = criar();
                temPosicao.set(f); temVelocidade.set(f); temVida.set(f);
                px[f] = 0; py[f] = 0;
                vx[f] = (float) sorteio.nextDouble(-3, 3); vy[f] = (float) sorteio.nextDouble(2, 8);
                vida[f] = 1.5f;
            }
            sistemaDeMovimento(1 / 60f);
            sistemaDeEnvelhecimento(1 / 60f);
            if (passo >= 1000) passos++;
        }
        long bytes = mx.getCurrentThreadAllocatedBytes() - antes;
        System.out.printf("entidades vivas: %d | parede ainda em x=%.1f | %d bytes em %d passos%n",
                vivas.cardinality(), px[parede], bytes, passos);
    }
}
entidades vivas: 1801 | parede ainda em x=5.0 | 0 bytes em 5000 passos

São 1.800 faíscas vivas (20 por passo × 90 passos de vida) mais a parede. A parede só tem Posicao, então o sistema de movimento nunca a toca, e ninguém precisou escrever if (!(e instanceof Parede)). E foram zero bytes alocados em 5.000 passos.

Num projeto real ninguém escreve o próprio ECS com BitSet. Há duas bibliotecas maduras:

  • Ashley, o ECS do próprio projeto libGDX. Ele usa PooledEngine para reciclar entidades e componentes.
  • Artemis-odb, mais rápido e mais elaborado, que guarda os componentes de forma mais próxima dos nossos arrays.

Um sistema no Ashley tem esta cara:

public class Posicao implements Component { public float x, y; }
public class Velocidade implements Component { public float x, y; }

public class SistemaDeMovimento extends IteratingSystem {
    private final ComponentMapper<Posicao> pm = ComponentMapper.getFor(Posicao.class);
    private final ComponentMapper<Velocidade> vm = ComponentMapper.getFor(Velocidade.class);

    public SistemaDeMovimento() { super(Family.all(Posicao.class, Velocidade.class).get()); }

    @Override
    protected void processEntity(Entity e, float dt) {
        Posicao p = pm.get(e);
        Velocidade v = vm.get(e);
        p.x += v.x * dt;
        p.y += v.y * dt;
    }
}

// no create():
PooledEngine motor = new PooledEngine();
motor.addSystem(new SistemaDeMovimento());
Entity faisca = motor.createEntity();
faisca.add(motor.createComponent(Posicao.class));
faisca.add(motor.createComponent(Velocidade.class));
motor.addEntity(faisca);
// no render(), dentro do passo fixo:
motor.update(PASSO);

Quando vale a pena usar ECS? Quando há muitas entidades de muitos tipos que misturam comportamentos: um RPG com centenas de inimigos, itens, projéteis e efeitos, ou um simulador. Para o nosso plataforma, com um herói, algumas moedas e uma bandeira, classes normais são mais claras. O ECS resolve um problema de escala, e sem escala ele só troca clareza por indireção.

O coletor escolhido também importa

O Java 25 vem com vários coletores, e dois interessam aqui:

  • G1 é o padrão. Ele divide o heap em regiões e para a aplicação em pausas curtas para coletar a geração jovem, normalmente de alguns milissegundos.
  • ZGC faz quase todo o trabalho ao mesmo tempo que a aplicação. As pausas ficam abaixo de um milissegundo, e em troca ele usa mais CPU em segundo plano. Desde o JDK 23 o modo geracional é o padrão, e no JDK 24 o modo antigo foi removido.

Rodei a versão "objetos", a pior, com os dois coletores e o log ligado (-Xlog:gc):

G1:
[0.118s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 15M->4M(256M) 3.662ms
[0.600s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 108M->6M(256M) 3.543ms
[1.172s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 146M->6M(256M) 4.876ms
objetos  vivas=44,157 |   113,739 bytes/quadro | média  0.436 ms/quadro | pior quadro   5.69 ms | coletas: 2

ZGC (-XX:+UseZGC -Xlog:gc+phases):
[0.231s][info][gc,phases] GC(0) Y: Pause Mark Start (Major) 0.016ms
[0.237s][info][gc,phases] GC(0) Y: Pause Mark End 0.012ms
[0.238s][info][gc,phases] GC(0) Y: Pause Relocate Start 0.014ms
objetos  vivas=44,157 |   125,739 bytes/quadro | média  0.371 ms/quadro | pior quadro   6.45 ms | coletas: 16

Como ler o log do G1: 108M->6M(256M) 3.543ms significa que o heap usado caiu de 108 MB para 6 MB, num heap de 256 MB, e a aplicação ficou parada 3,5 ms. (A primeira coleta, aos 0,1 s, ainda é do aquecimento.) Quase tudo era lixo, porque partículas morrem jovens, e é exatamente o caso que a coleta geracional resolve bem. Ainda assim, pausas de 3,5 a 5 ms são um quarto de quadro.

Com o ZGC, a maior pausa registrada em três execuções foi de 0,016 a 0,034 ms. O pior quadro, porém, ficou entre 3,9 e 6,5 ms, na mesma faixa do G1: os engasgos que sobram nesta máquina não vêm de pausas do coletor. As "coletas" ficaram entre 12 e 16 porque o ZGC conta ciclos concorrentes, que não param o jogo.

Um detalhe curioso: com o ZGC o programa alocou mais bytes por quadro (125.739 contra 113.739). O ZGC não usa compressed oops, as referências de 4 bytes que o G1 usa em heaps de até 32 GB, e cada referência passa a ocupar 8 bytes. O exercício 1 faz essa conta. (No JDK 27, que já liga por padrão os cabeçalhos de objeto compactos, de 8 bytes, a mesma versão aloca 89.739 bytes por quadro com o G1.)

Qual escolher para um jogo? Para um jogo de desktop, -XX:+UseZGC é uma linha no lançador e resolve a maior parte dos engasgos de coleta. Mas não é desculpa para alocar sem pensar: a coleta ainda gasta CPU, e no Android o coletor é outro (o do ART), com outras características. Alocar pouco no laço continua sendo o hábito certo, e o coletor de baixa pausa é a rede de segurança.

Regras práticas

  1. Meça primeiro. Use getThreadAllocatedBytes num teste, o JFR para encontrar os locais e -Xlog:gc para ver as pausas. Não otimize o que dá 74 bytes por passo.
  2. No laço, prefira reaproveitar os campos. Use Vector2 como campo e set() nele, em vez de new Vector2() a cada passo, como já fazemos desde a aula do primeiro sprite.
  3. Muitos objetos de vida curta, como tiros e partículas, vão para um Pool. Um dono por objeto, reset() sempre e nenhuma referência depois do free.
  4. Milhares de coisas iguais pedem arrays, e muitas coisas diferentes com comportamentos misturados pedem ECS.
  5. Carregue tudo antes de jogar. Texturas, mapas e sons são alocações grandes, e o lugar delas é a tela de carregamento, nunca o meio da fase.
  6. Para desktop, considere o ZGC. E confira no log se as pausas sumiram de verdade.

Com isso encerramos as ferramentas da Trilha C. Temos janela e sprites, colisão, física, mapas e um plataforma com controle testado, e agora sabemos manter o laço limpo. Falta transformar tudo isso num jogo: com menu, várias fases, animação, efeitos, pausa, recordes que sobrevivem ao fechar a janela e, no fim, um programa que outra pessoa baixa e roda sem instalar Java.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Com o G1 (referências de 4 bytes, cabeçalho de objeto de 12 bytes, alinhamento em 8), estime quantos bytes ocupa uma Particula da versão "objetos", com seus dois Vector2, e multiplique pelas 1.500 criadas por quadro. Refaça a conta para o ZGC, com referências de 8 bytes. Compare com os valores medidos, 113.739 e 125.739.

Ver resposta

✓ Resposta: No G1, um Vector2 tem cabeçalho de 12 + dois float de 4 = 20 bytes, que alinham para 24. A Particula tem 12 + duas referências de 4 + um float de 4 = 24 bytes. O total é 24 + 2 × 24 = 72 bytes, e 72 × 1.500 = 108.000 bytes/quadro. No ZGC, o Vector2 continua com 24, e a Particula passa a ter 12 + 8 + 8 + 4 = 32 bytes. O total é 80 bytes, e 80 × 1.500 = 120.000 bytes/quadro. As duas estimativas ficam cerca de 5.700 bytes abaixo do medido, e a diferença é praticamente igual nos dois casos. Ela corresponde ao que não depende das partículas: o removeIf e o crescimento do ArrayList. A conta confirma que a alocação é dominada pelas partículas e que a diferença entre os coletores é só o tamanho das referências.

Exercício 2

No ComPool, troque new Pool<>(64, 64_000) por new Pool<>(64, 100). O que acontece com os bytes por quadro, e por quê?

Ver resposta

✓ Resposta: A alocação volta a aparecer. Com no máximo 100 livres guardados, cada quadro em que morrem 1.500 partículas devolve 1.500 objetos, mas o Pool guarda só 100 e descarta os outros 1.400, que viram lixo. No quadro seguinte, as 1.500 que nascem encontram só 100 livres, e as 1.400 restantes são criadas com newObject(). O Pool vira quase um new com mais passos. Medido: 100.800 bytes por quadro, exatamente 1.400 × 72 bytes, e as coletas voltam. O max precisa acompanhar o pico real de mortes por quadro, e o campo peak do Pool ajuda a medir esse pico.

Exercício 3

Por que o laço do ComPool percorre o Array de trás para frente, e por que o Array foi criado com ordered = false?

Ver resposta

✓ Resposta: Com ordered = false, o removeIndex(i) é O(1): ele coloca o último elemento na posição i e diminui o tamanho, em vez de deslocar todos os seguintes. Indo de trás para frente, o elemento trazido para a posição i já foi processado neste quadro, porque veio de um índice maior. Se o laço fosse para frente, esse elemento trocado nunca seria visitado no quadro, ou exigiria repetir o índice. A ordem das partículas não importa para o desenho de faíscas, então abrir mão dela não custa nada.

Exercício 4

No MiniEcs, adicione um componente Leve e um sistemaDeVento(dt) que empurra para a direita apenas as entidades que têm Velocidade e Leve. Que mudanças são necessárias no código que já existe?

Ver resposta

✓ Resposta: Nenhuma. Basta acrescentar um BitSet temLeve, marcar temLeve.set(f) nas entidades desejadas, limpar o bit em destruir e escrever o sistema novo: for (int e = temLeve.nextSetBit(0); e >= 0; e = temLeve.nextSetBit(e + 1)) if (temVelocidade.get(e)) vx[e] += 2f * dt;. Os sistemas existentes não mudam, e nenhuma classe ganha uma subclasse. É o argumento central do ECS: comportamentos novos entram como dados e laços novos, sem mexer na hierarquia.

Exercício 5

Dê duas situações em que um Pool deixa o programa pior do que simplesmente usar new.

Ver resposta

✓ Resposta: (a) Objetos temporários que não escapam do método, como um Vector2 usado numa conta e esquecido. A análise de escape do JIT pode eliminá-los completamente, e colocá-los num Pool obriga o objeto a existir no heap, porque o Pool guarda uma referência a ele, e o código ainda paga o obtain/free. (b) Picos raros. Um Pool que encheu com 50 mil objetos numa explosão única mantém todos vivos até o fim do jogo, e eles acabam na geração velha, ocupando memória e deixando as coletas maiores mais caras. Há outras: objetos com reset() caro, e uso por várias threads, porque o Pool do libGDX não é sincronizado.

Comentários

Mais em Java

Fazendo Duas Coisas ao Mesmo Tempo — Threads
Fazendo Duas Coisas ao Mesmo Tempo — Threads

O que é uma thread e como criar, esperar e interromper várias delas: start e…

Tarefas em Família — Structured Concurrency e Scoped Values
Tarefas em Família — Structured Concurrency e Scoped Values

Concorrência estruturada e valores com escopo no Java 25: subtarefas que não…

Extensão — Java no Android: o que Mudou com o Kotlin e um App que Consome a API
Extensão — Java no Android: o que Mudou com o Kotlin e um App que Consome a API

As Trilhas da Ilha chegam ao celular: por que o Android usa a linguagem Java…