Java JIT·GC·메모리를 JDK 26 에서 실측하기

JIT 워밍업으로 349µs 가 46µs 로, -Xint 는 약 20 배 느림. GC 4 종을 실제 로그로 비교하고, 힙 절반에서 나는 OOM 과 static Map 누수를 jcmd 로 추적한다.

시리즈: Java 강좌 6/6 (고급). 올바른 Java 코드는 쓸 수 있고, 이제 JVM 이 그 코드를 어떻게 다루는지 알고 싶은 분을 위한 글입니다. 코드는 언제 빨라지는지, 메모리는 어디로 가는지, 왜 플래그 하나로 같은 프로그램이 다르게 움직이는지. 다 읽으면 -Xlog:gc 출력을 읽고, GC 를 소문이 아니라 측정으로 고르고, jcmd 로 누수를 찾고, 거짓말하는 벤치마크를 알아볼 수 있습니다.

"운영 서버에서만 느리다", "새벽 배치가 OOM 으로 죽었다"는 문제는 대개 세 곳 중 하나에서 옵니다. JIT 가 아직 아무것도 컴파일하지 않은 시점에 잰 경우, 습관으로 고른 GC, 그리고 계속 늘기만 하는 메모리. JVM 튜닝은 면접 단골 주제이기도 한데, 이 글은 외운 답 대신 작은 프로그램으로 하나씩 뜯어봅니다. 숫자는 모두 아래 환경에서 직접 잰 것이고, 예상 밖의 결과(힙 절반에서 OOM, 누수로 죽어 가는 JVM 에 attach 실패)는 출력을 그대로 남겼습니다.

실행 환경: OpenJDK 26 (Temurin 26+35), Apple Silicon Mac (M1 Pro, 8 코어, 메모리 16GB), macOS. 이 기기의 기본값은 최대 힙 4GB, G1 입니다. 시간은 대부분 3 회 중앙값이고, JIT 워밍업 실험만은 워밍업 자체가 주제라서 묶음별로 보여 줍니다.

JIT 워밍업 측정: 첫 묶음과 정상 상태

HotSpot 은 모든 메서드를 먼저 바이트코드 인터프리터로 실행하면서 호출 횟수와 루프 횟수를 세고, 자주 쓰이는 메서드를 단계적으로 컴파일합니다. 먼저 C1 이 프로파일링을 넣어(tier 3), 다음에 C2 가 그 프로파일로(tier 4) 컴파일합니다. 같은 작은 묶음을 2,000 번 재 봅니다.

/** Time the same small batch 2,000 times and watch the JIT kick in. */
public class Warmup {
    static int collatzSteps(long n) {                // pure CPU, no allocation
        int steps = 0;
        while (n != 1) { n = (n & 1) == 0 ? n >>> 1 : 3 * n + 1; steps++; }
        return steps;
    }
    static long batch() {                            // 1,000 calls per batch
        long total = 0;
        for (long n = 1; n <= 1_000; n++) total += collatzSteps(n);
        return total;
    }
    public static void main(String[] args) {
        int[] show = {1, 2, 3, 5, 10, 20, 50, 100, 200, 500, 1000, 2000};
        long check = 0; int k = 0;
        long start = System.nanoTime();
        for (int b = 1; b <= 2000; b++) {
            long t0 = System.nanoTime();
            check += batch();
            long us = (System.nanoTime() - t0) / 1_000;
            if (k < show.length && b == show[k]) { System.out.printf("batch %4d: %,6d us%n", b, us); k++; }
        }
        System.out.printf("total %,d ms, checksum %d%n", (System.nanoTime() - start) / 1_000_000, check);
    }
}
묶음 기본 1 회 2 회 3 회 -Xint C1 만 (-XX:TieredStopAtLevel=1)
1 334µs 349µs 373µs 919µs 258µs
2 215µs 227µs 209µs 900µs 109µs
5 82µs 81µs 79µs 908µs 142µs
50 75µs 74µs 76µs 926µs 95µs
200 47µs 49µs 47µs 1,011µs 76µs
2000 46µs 45µs 49µs 1,043µs 76µs
합계 117ms 119ms 116ms 1,940ms 174ms

계단이 세 개 보입니다. 첫 묶음이 약 350µs(일부는 인터프리터, 일부는 컴파일 중), 5 회째쯤부터 약 75µs, 200 회째쯤부터 46µs 입니다. 정상 상태는 첫 묶음보다 7.6 배 빠릅니다. -XX:+PrintCompilation 을 붙이면 각 단계가 언제 일어났는지 보입니다(열: 시작 후 ms, 컴파일 ID, % 는 OSR, tier).

20    8       3       Warmup::collatzSteps (38 bytes)
20    9 %     4       Warmup::collatzSteps @ 2 (38 bytes)
36  116       4       Warmup::collatzSteps (38 bytes)
37    8       3       Warmup::collatzSteps (38 bytes)   made not entrant: not used
...
44  127 %     4       Warmup::batch @ 4 (29 bytes)
45  128       4       Warmup::batch (29 bytes)

collatzSteps 는 36ms 에 C2(tier 4)에 도달했고 C1 버전은 버려졌습니다(made not entrant). 100~200 회째 즈음의 두 번째 단축은 호출하는 쪽인 batch() 가 45ms 에 tier 4 로 컴파일된 시점과 맞습니다. 이때부터 C2 가 collatzSteps 를 루프 안에 인라인할 수 있게 됩니다. 'C1 만' 열을 보면 C2 가 더해 주는 몫이 보입니다. C1 은 76µs, C2 는 46µs 에서 멈췄습니다.

계층형 컴파일 흐름도. 메서드는 인터프리터에서 시작하며 한 묶음에 약 910 마이크로초(-Xint 로 잰 속도). 수천 번 호출되면 C1 이 프로파일링을 넣어 컴파일하고(tier 3) 약 75 마이크로초가 된다. 그 프로파일로 C2 가 tier 4 로 컴파일해 약 46 마이크로초에서 안정된다. C2 의 가정이 깨지면 역최적화되어 인터프리터로 돌아간다.
JDK 26 에서 자주 호출되는 메서드가 거친 단계와, 1,000 회 호출 한 묶음당 실측 시간. 단계 전환은 -XX:+PrintCompilation 으로 확인했다.

-Xint 와 기본값 비교: JIT 의 값어치

-Xint 는 JIT 를 끕니다. 그러면 묶음마다 약 900µs 가 들고, 전체는 117ms 가 아니라 1,940ms 였습니다. 합계로 16.6 배, 정상 상태 기준 약 20 배(910µs 대 46µs) 느립니다. 인터프리터는 빨라지지 않으니 열이 평평합니다. -Xint 의 쓸모는 하나뿐입니다. 버그가 -Xint 에서 사라지면 JIT 관련 문제(또는 컴파일러가 드러낸 데이터 경쟁)를 의심할 수 있습니다. 측정을 '공정하게' 하려고 쓰면 안 됩니다.

이스케이프 분석 간단히

C2 는 객체가 만들어진 메서드 바깥에서 보이는지 확인합니다. 보이지 않으면 할당 자체를 생략하고 필드를 레지스터에 둘 수 있습니다(스칼라 치환). 루프 밖으로 나가지 않는 record 로 확인합니다.

record Point(double x, double y) {}

static double sumDistances(int n) {
    double s = 0;
    for (int i = 0; i < n; i++) {
        Point p = new Point(i, i + 1);            // never stored, never returned
        s += Math.sqrt(p.x() * p.x() + p.y() * p.y());
    }
    return s;
}

한 라운드에 5,000만 번 돌리면서, 스레드가 할당한 바이트 수를 com.sun.management.ThreadMXBean.getThreadAllocatedBytes 로 셌습니다.

== default (EA on)
round 1:   57 ms, allocated      7 MB
round 3:   50 ms, allocated      0 MB
round 5:   49 ms, allocated      0 MB
== -XX:-DoEscapeAnalysis
round 1:  326 ms, allocated  1,525 MB
round 3:   80 ms, allocated  1,525 MB
round 5:  117 ms, allocated  1,525 MB

1,525MB 는 정확히 5,000만 × 32 바이트, 한 번에 Point 하나입니다. 이스케이프 분석이 켜져 있으면 정상 상태의 할당은 0 이었습니다. 교훈은 '끄라'가 아니라, 핫 루프 안의 작고 수명 짧은 객체는 공짜인 경우가 많으니 재 보지도 않고 record 를 손으로 풀어 헤치지 말라는 것입니다. 객체를 필드에 저장하거나, 인라인되지 않는 메서드에 넘기거나, 메서드가 너무 커서 인라인되지 않으면 이스케이프 분석은 포기합니다.

같은 프로그램을 GC 4 종으로

할당이 많은 프로그램입니다. 16~495 바이트 byte[] 를 2,000만 개(Random(42) 고정) 만들고, 8 개 중 1 개를 20만 칸짜리 링에 보관해 약 50MB 의 살아 있는 데이터를 늘 유지합니다.

Random rnd = new Random(42);
byte[][] live = new byte[200_000][];          // the long-lived working set
for (int i = 0; i < 20_000_000; i++) {
    byte[] b = new byte[16 + rnd.nextInt(480)];
    b[0] = (byte) i;
    if ((i & 7) == 0) live[rnd.nextInt(live.length)] = b;   // 1 in 8 survives a while
    checksum += b[0];
}

java -XX:+Use<X>GC -Xmx1g -Xlog:gc:file=gc.log Churn 으로 실행했습니다. 실제 로그 줄(발췌)입니다.

G1        [0.874s][info][gc] GC(23) Pause Young (Normal) (G1 Evacuation Pause) 675M->207M(862M) 7.355ms
Serial    [1.218s][info][gc] GC(122) Pause Young (Allocation Failure) 606M->449M(622M) 17.816ms
Parallel  [0.779s][info][gc] GC(32) Pause Young (Allocation Failure) 341M->104M(342M) 5.028ms
ZGC       [0.275s][info][gc] GC(3) Minor Collection (Allocation Rate) 432M(42%)->226M(22%) 0.047s

ZGC 줄이 함정입니다. 0.047s 는 프로그램이 계속 도는 동안 진행된 동시 사이클 전체 길이이지 정지 시간이 아닙니다. 그냥 -Xlog:gc 로는 ZGC 의 정지가 아예 안 나오니 gc+phases 를 더합니다.

[0.375s][info][gc,phases] GC(5) Y: Pause Mark Start (Major) 0.009ms
[0.386s][info][gc,phases] GC(5) Y: Concurrent Mark 10.114ms
[0.386s][info][gc,phases] GC(5) Y: Pause Mark End 0.008ms
[0.387s][info][gc,phases] GC(5) Y: Pause Relocate Start 0.006ms

'Pause' 줄을 모두 합산했습니다(로그를 Python 스크립트로 집계, 각 3 회).

GC 실제 시간(중앙값) 정지 횟수 정지 합계 최대 정지 CPU 사용자 시간
G1 (기본) 990ms 26 107ms 10.8ms 1.09 초
Serial 1,283ms 125 591ms 20.7ms 1.18 초
Parallel 844ms 35 151ms 9.2ms 1.36 초
ZGC 939ms 약 60 0.4ms 0.02ms 1.69 초

CPU 사용자 시간은 같은 프로그램을 로그 없이 /usr/bin/time -p 아래에서 따로 돌려 쟀습니다.

GC 정지 시간 합계 막대그래프. Serial 은 125 회 591 밀리초, Parallel 은 35 회 151 밀리초, G1 은 26 회 107 밀리초, ZGC 는 아주 짧은 약 60 회로 0.4 밀리초. ZGC 는 정지가 가장 짧지만 CPU 시간은 가장 많이 썼다.
같은 할당이 많은 프로그램을 -Xmx1g 로 돌렸을 때 -Xlog:gc 기준 정지(Stop-the-world) 시간 합계. 3 회 중앙값. ZGC 는 gc+phases 의 Pause 줄을 합산했다.

읽는 법입니다. Parallel 은 정지할 때마다 8 코어를 모두 쓰고 동시 작업을 하지 않으니 가장 먼저 끝났습니다. ZGC 는 애플리케이션을 거의 멈추지 않았지만 일을 다른 스레드에서 하느라 G1 보다 CPU 를 55% 더 썼고, 바쁜 서버라면 그 값을 치릅니다. Serial 은 GC 스레드 하나에 기본 Young 영역이 작아서 125 번 수집했습니다. G1 은 모든 열에서 중간이고, 그래서 기본값입니다. G1 의 실행 간 편차가 커서(835~1,087ms) 이 표에서 15% 미만 차이는 잡음으로 보세요.

힙 크기, OutOfMemoryError, 그리고 G1 의 의외의 동작

-Xmx64m 힙을 1MB 블록으로 채웁니다.

List<byte[]> keep = new ArrayList<>();
while (true) {
    keep.add(new byte[1 << 20]);
    if (keep.size() % 16 == 0) System.out.println("holding " + keep.size() + " MB");
}
max heap = 64 MB
holding 16 MB
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at Oom.main(Oom.java:10)

'holding 32 MB' 는 끝내 나오지 않았습니다. 64MB 힙이 1MB 배열 31 개에서 바닥났습니다. 이유는 로그에 있습니다. Pause Young (Normal) (G1 Humongous Allocation). 이 힙에서 G1 은 리전을 1MB 로 잡았습니다(-XX:+PrintFlagsFinal 에서 G1HeapRegionSize = 1048576). 리전 절반 이상인 객체는 humongous 가 되어 리전을 통째로 차지하는데, new byte[1048576] 은 데이터 1MB 에 16 바이트 헤더가 붙으니 리전 2 개가 필요합니다. 오류까지 몇 개가 들어갔는지 셌습니다.

배열 크기 G1 Serial
1,048,576 바이트 (1MB) 31 개 (31.0MB) 59 개 (59.0MB)
1,048,560 바이트 (1MB − 16) 62 개 (62.0MB) 60 개 (60.0MB)
524,288 바이트 62 개 (31.0MB) 120 개 (60.0MB)
262,144 바이트 189 개 (47.3MB) 242 개 (60.5MB)

16 바이트 줄였을 뿐인데 G1 에 들어가는 양이 두 배가 됐습니다. 리전에 비해 큰, 정확히 2 의 거듭제곱 크기 버퍼가 최악입니다. 해결책은 힙을 키우기(리전도 커짐), -XX:G1HeapRegionSize= 지정, 버퍼를 조금 줄이기 중 하나입니다.

알아 두면 좋은 실제 출력이 두 개 더 있습니다. -XX:+ExitOnOutOfMemoryError 를 붙이면 Terminating due to java.lang.OutOfMemoryError: Java heap space 를 찍고 종료 코드 3 으로 끝나므로, 반쯤 죽은 프로세스로 남지 않고 감시 도구가 재시작할 수 있습니다. 그리고 힙이 꽉 차서 오류 처리조차 할당하지 못하면 스택 트레이스 없이 이것만 나옵니다.

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"

흔한 실수: static Map 으로 생기는 메모리 누수

실수: 요청마다 새 키를 넣는 static HashMap 을 캐시로 쓴다.

static final Map<String, Session> CACHE = new HashMap<>();          // the leak
...
cache.put("req-" + req, new Session("user" + (req % 100), new byte[1024]));

실제로 일어나는 일 (-Xmx256m):

requests=   50,000  entries=   50,001  heapUsed=  63 MB
requests=  100,000  entries=  100,001  heapUsed= 120 MB
requests=  150,000  entries=  150,001  heapUsed= 180 MB
requests=  200,000  entries=  200,001  heapUsed= 253 MB

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"

도는 동안 JDK 에 들어 있는 jstat 과 jcmd 로 밖에서 관찰했습니다.

$ jcmd -l
69893 Leak
$ jstat -gcutil <pid> 3000
  S0     S1     E      O      M     CCS    YGC     YGCT     FGC    FGCT  ...
     - 100.00   0.00  91.67  73.19  20.54      8     0.009     0     0.000
     - 100.00   0.00  98.34  73.26  20.54     37     0.037     0     0.000
     -      -  33.33  67.30  73.84  20.54     44     0.054     1     0.024
     - 100.00  12.50  99.99  73.84  20.54     55     0.086     1     0.024
$ jcmd <pid> GC.class_histogram
 num     #instances         #bytes  class name (module)
   1:        227556       79768448  [B (java.base@26)
   2:        155023        3720552  java.lang.String (java.base@26)
   3:         75104        2403328  java.util.HashMap$Node (java.base@26)
   4:         72451        1738824  Leak$Session

O 열(Old 영역 사용률 %)이 Full GC(FGC = 1) 뒤에도 99.99 까지 다시 올라갑니다. 쓰레기가 아니라 살아 있는 데이터라는 뜻입니다. 히스토그램에서 Leak$Session 과 HashMap$Node 개수가 같이 늘어나니 Map 이 원인임이 바로 보입니다. jcmd <pid> GC.heap_info 에는 86 old (86M), 1 humongous (1M) 이 나왔습니다. 함정이 하나 더 있습니다. 더 빨리 진행되는 설정으로 돌렸을 때는 JVM 이 Full GC 를 연달아 하느라 jcmd 가 AttachNotSupportedException: ... state is not ready to participate in attach handshake! 로 실패했습니다. 일찍 attach 하거나 -XX:+HeapDumpOnOutOfMemoryError 를 붙여 띄워 두세요.

고치는 법: 캐시에 상한을 둡니다. 접근 순서 LinkedHashMap 이면 다섯 줄로 LRU 가 됩니다.

static final Map<String, Session> LRU = new LinkedHashMap<>(16, 0.75f, true) {
    @Override protected boolean removeEldestEntry(Map.Entry<String, Session> e) {
        return size() > 10_000;
    }
};

같은 프로그램, 같은 힙으로 80 초 돌리자 60만 요청을 처리했고, 모든 줄에서 entries= 10,000 이었으며 힙은 17~53MB 사이였습니다(스레드 안전하지 않으니 공유한다면 감싸거나 전용 캐시 라이브러리를 쓰세요).

시작 시간: java File.java 대 컴파일된 클래스

java Hello.java 는 실행할 때마다 소스를 메모리에서 컴파일합니다. 각각 20 번 실행한 중앙값을 Python 스크립트에서 쟀습니다.

명령 중앙값
java Hello.java (소스 실행) 291ms
java -Xshare:off -cp cls Hello 64ms
java -cp hello.jar Hello 43ms
java -cp cls Hello 39ms
java -XX:AOTCache=hello.aot -cp hello.jar Hello 35ms
시작 시간 막대그래프. 메모리에서 먼저 컴파일하는 java Hello.java 는 291 밀리초. 클래스 데이터 공유를 끈 컴파일된 클래스는 64 밀리초. jar 는 43, 클래스 디렉터리는 39, AOT 캐시를 붙인 jar 는 35 밀리초.
hello world 를 실행하는 데 걸린 실제 시간. 각각 20 회 중앙값이며, 프로세스를 띄우는 Python 스크립트에서 쟀다.

소스 실행은 매번 약 250ms 를 컴파일에 씁니다. 스크립트나 학습용으로는 괜찮지만 셸 루프에서 반복 호출하는 CLI 도구에는 맞지 않습니다. -Xshare:off 는 JDK 에 들어 있는 클래스 데이터 공유 아카이브를 끄며 약 25ms 늘어납니다. AOT 캐시 첫 시도는 실제 오류로 실패했습니다.

$ java -XX:AOTCacheOutput=hello.aot -cp cls Hello
Error occurred during CDS dumping
Cannot have non-empty directory in paths

AOT 캐시는 클래스패스에 JAR 만 받습니다. JDK 의 jar 도구로 JAR 를 만들자 -XX:AOTCacheOutput 이 10MB 캐시를 썼고, 시작 시간이 43ms 에서 35ms 로 줄었습니다. 그 전에는 첫 단계가 실패해서 -XX:AOTCache=hello.aot 가 존재하지 않는 파일을 가리켰는데, JVM 은 [error][aot] Unable to map shared spaces 를 찍고도 캐시 없이 프로그램을 실행한 뒤 종료 코드 0 으로 끝났습니다. 빌드 스크립트로는 알아챌 수 없습니다.

벤치마크에 대한 솔직한 주의점 (JMH 없음)

위 숫자는 모두 System.nanoTime() 과 평범한 루프로 쟀고 JMH 는 쓰지 않았습니다(이번 환경에 없었기 때문). 그래도 성립하는 건 차이가 크기 때문입니다. 손으로 짠 측정에서 흔히 생기는 일:

  • 워밍업 없음: 첫 묶음은 정상 상태보다 7.6 배 느렸다. 초반 라운드는 버리고, 워밍업이 사용자에게 중요하면 따로 보여 준다.
  • 죽은 코드: 결과를 쓰지 않으면 C2 가 작업째 지울 수 있다. 여기 프로그램은 모두 체크섬을 출력한다.
  • 인라인이 대상을 바꾼다: 두 번째 단축은 호출하는 쪽 이 컴파일되면서 생겼다. 마이크로벤치마크는 특정 인라인 문맥 안의 메서드를 잰다.
  • 잡음: G1 의 실제 시간은 같은 조건에서 835~1,087ms 로 흔들렸다. 반복하고, 중앙값을 내고, 15% 미만 차이는 의심한다.
  • 기기: M1 Pro 는 성능 코어 6 개와 효율 코어 2 개. 어느 쪽에서 돌지는 스케줄러가 정한다.

2 배보다 작은 차이를 따지려면 JMH 를 쓰세요.

판단표와 버전 메모

목적 할 일 위의 근거
공정한 마이크로 측정 워밍업, 체크섬 출력, 반복 후 중앙값 349 → 46µs
짧게 끝나는 CLI 컴파일된 JAR, AOT 캐시도 검토 291 대 35ms
처리량 중심 배치 Parallel GC 실제 시간 최단 844ms
지연에 민감한 서비스 ZGC, 늘어나는 CPU 를 감안 최대 정지 0.02ms, CPU +55%
잘 모르겠다면 G1 (기본) 모든 열에서 중간
작은 힙에서 큰 버퍼 리전 절반 이상인 정확한 2 의 거듭제곱 크기를 피한다 31 개 대 62 개
누수가 의심될 때 jstat -gcutil, 그다음 jcmd GC.class_histogram Full GC 뒤에도 O 가 99.99%
  • JDK 26 에서만 확인했습니다. ZGC 는 JDK 23 부터 기본이 세대별(위 로그의 Y:/O:), 한 단계짜리 -XX:AOTCacheOutput 은 JDK 25 에서 추가, AOT 캐시 자체는 JDK 24(JEP 483)입니다. 예전 JDK 로 직접 확인하지는 않았습니다.
  • 소스 파일 직접 실행(java Hello.java)은 JDK 11 부터, 여러 파일로 된 소스 프로그램은 JDK 22 부터입니다. 여기서는 단일 파일 형태만 썼습니다.
  • 이 JDK 에는 jfr 명령이 없었고 jcmd 와 jstat 은 있었습니다.

이것으로 Java 강좌를 마칩니다. 같은 런타임의 스레드 쪽 이야기는 5강에서 다뤘습니다.


이전 강좌: 5강: 가상 스레드와 동시성 · 처음으로: 1강: 타입·문자열·동등성

관련: Python 강좌 6강: 프로파일링과 최적화 · VB.NET 강좌 6강: .NET 10 의 VB.NET 과 C# 연동

자주 묻는 질문

Java 의 JIT 워밍업은 얼마나 걸리나요?

이번 테스트에서 1,000 회 호출 묶음이 첫 회 349µs, 5 회째쯤부터 약 75µs(C1 컴파일 후), 200 회째쯤부터 46µs(C2 컴파일 후)였고 모두 0.1 초 안에 일어났습니다. JIT 를 끄는 -Xint 에서는 같은 묶음이 약 910µs 였습니다. 워밍업 없는 짧은 벤치마크는 대부분 인터프리터와 컴파일러를 재는 셈입니다.

JDK 26 에서는 어떤 GC 를 써야 하나요?

측정한 근거가 없다면 기본값 G1 을 그대로 쓰세요. -Xmx1g 로 할당이 많은 테스트를 돌렸을 때 실제 시간은 Parallel 이 가장 짧았고(844ms), 정지는 ZGC 가 가장 짧았지만(합계 0.4ms, 최대 0.02ms) ZGC 는 G1 보다 CPU 를 약 55% 더 썼습니다. Serial 은 125 회에 걸쳐 합계 591ms 정지했습니다.

-Xmx 의 절반밖에 안 썼는데 OutOfMemoryError 가 나는 이유는?

작은 힙의 G1 에서는 리전 절반 이상 크기의 객체가 'humongous' 로 취급되어 리전을 통째로 씁니다. -Xmx64m 에서는 리전이 1MB 라서 byte[1048576] 은 16 바이트 헤더까지 더해 리전 2 개가 필요하고, 31 개밖에 들어가지 않았습니다. 16 바이트 작은 배열은 62 개, Serial GC 에서는 1MB 배열이 59 개 들어갔습니다.

  • #Java
  • #JVM
  • #JIT
  • #GC
  • #메모리 누수
  • #jcmd
  • #JDK 26
  • #고급

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑