Study · JDK · STEP 7 / 8 · 실행할 때

GC는 쓰레기를 찾지 않는다 — 살아 있는 객체만 옮기고 영역을 비운다

장면

힙 최대 크기를 32MB로 정해 두고 new를 500만 번 했다. Box와 그 byte 배열을 합치면 한 번에 약 100바이트라 모두 합치면 약 500MB인데, 힙이 넘치지 않았다. 누가, 어떻게 치웠을까?

// 클래스 안에 둔 작은 클래스(static class) Box와, main 안의 반복문만 옮겼다
static class Box {
    int total;
    byte[] payload = new byte[64];   // Box마다 64바이트 배열을 하나씩 단다
}

Box[] keep = new Box[1000];
for (int n = 0; n < 5_000_000; n++) {   // 5_000_000 = 500만. 밑줄은 읽기 쉽게 끊는 표기다
    Box tmp = new Box();
    tmp.total = n;
    if (n % 5000 == 0) keep[n / 5000] = tmp;   // 5000번에 1개만 살려 둔다
}

아래 재생기의 ▶를 누르면 한 스텝씩 진행된다. 금색 테두리가 방금 바뀐 곳이고, 캡션이 무슨 일이 일어났는지 말해 준다. 굵게 밑줄 친 말은 누르면 뜻이 나온다.

장면 · 살아 있는 것만 옮긴다 0 / 11

▶를 누르면 new가 500만 번 도는 동안 힙의 영역 32칸이 어떻게 차고 비워지는지 본다.

키보드 · ← → 한 스텝 · Space 재생/멈춤 · Home 처음

GC가 하는 일은 살아 있는 객체를 찾아 옮기는 것이라, 비용은 죽은 객체 수가 아니라 살아 있는 객체 양을 따라간다. 이번 실행은 살아 있는 양이 매번 비슷하게 작아서 멈춤도 모두 1ms 미만이었다. 살아 있는 양을 늘려 멈춤을 비교하지는 않았다.

어떻게 확인했나

  • 영역 수 · 나이 · 멈춤 시간은 GC 로그를 켜는 옵션(-Xlog:gc,gc+heap,gc+age)으로 찍은 로그에서 읽었다 (JDK 21 · G1 · 힙 32MB).
  • 끝까지 살아 있던 Box 1,000개(24,000바이트)는 실행 중인 JVM의 객체 수를 세는 명령(jcmd GC.class_histogram)으로 쟀다. 24,000바이트는 Box 객체 자체만 센 값이고, Box마다 딸린 byte 배열은 따로 세서 넣지 않았다.
  • 격자에서 칸의 위치는 그림을 위한 배치다. 어느 칸이 쓰였는지는 로그에 없다.
  • 나이 15에 Old로 옮겨 간 약 700KB 중 keep의 Box는 수 KB뿐이고, 나머지 대부분은 JVM이 main 전에 만든 객체로 보이지만 정체는 확인하지 않았다.