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
floatde 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 cadaVector2estã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, chamanewObject().free(obj)põe o objeto de volta na lista de livres e chamareset()nele, se ele implementarPool.Poolable.new Pool<>(inicial, max)limita quantos livres ficam guardados. Acima disso, o objeto devolvido é descartado e vira lixo normal.fill(n)pré-crianobjetos. Faça isso numa tela de carregamento, para o primeiro tiroteio não criar tudo de uma vez.- O campo
peakmostra o máximo de livres que já houve, útil para escolher omax.
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
SistemaDeMovimentopedePosicaoeVelocidade.
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
PooledEnginepara 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
- Meça primeiro. Use
getThreadAllocatedBytesnum teste, o JFR para encontrar os locais e-Xlog:gcpara ver as pausas. Não otimize o que dá 74 bytes por passo. - No laço, prefira reaproveitar os campos. Use
Vector2como campo eset()nele, em vez denew Vector2()a cada passo, como já fazemos desde a aula do primeiro sprite. - 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 dofree. - Milhares de coisas iguais pedem arrays, e muitas coisas diferentes com comportamentos misturados pedem ECS.
- 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.
- 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
- libGDX Wiki — Memory management
- libGDX — Ashley (repositório e wiki)
- artemis-odb (repositório)
- JEP 474 — ZGC: Generational Mode by Default
- Oracle — HotSpot Virtual Machine Garbage Collection Tuning Guide (Java 25)
- dev.java — JDK Flight Recorder
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.