1. 대상
- JVM (자바가상머신) 에서는 어떻게 메모리를 할당하고 회수하는지 직접 눈으로 확인하고 싶은 사람
- 가상머신 매개변수를 사용해서 테스트 해보고 싶은 사람
- 기본적인 GC 관련된 키워드는 알고있는 사람 (ex. Old Generation, ….)
- 미래의 나
2. 개요
최근 회사에서 싱글코어로 배포되어 있던 서버의 코어수를 증량하면서 SerialGC -> G1GC 를 사용하게 되었다. 그에 따라 메모리 회수가 잘 이루어지는지 모니터링을 하고 있는데 각자의 공간에 메모리가 어떻게 할당되고 회수를 하는지 직접 확인하고 싶어졌다.
마침 최근 읽고 있는 JVM 밑바닥 까지 파헤치기 라는 책에서 다루고 있어 정리하고 복기할겸 글로 남기는 것이 의미있을 것 같았다. 따라서 이번 글에서는 가장 기본적인 메모리 할당 정책을 설명하고 코드를 이용해 검증해볼 것이다. 결과를 쉽게 해석하고 이해할 수 있도록 메모리 할당과 회수는 시리얼 컬렉터의 전략을 따라서 진행해보려고 한다. 운영환경에서는 일반적이지 않지만 목표는 '분석 방법 익히기' 이므로 가장 단순한 SerialGC 를 예로 살펴보겠다.
3. 검증
메모리가 어떻게 할당되는지 부터 어떻게 회수되는지 순서대로 검증을 진행해보겠다.
1. 객체는 먼저 에덴에 할당된다.
대부분의 경우 객체는 신세대의 에덴에 할당되고 에덴의 공간이 부족해지면 가상머신은 마이너 GC 를 시작한다.
핫스팟 가상머신의 -Xlog:gc* 매개변수는 가비지 컬렉션시 메모리 회수 로그를 출력하고, 프로세스 종료시 메모리 각 영역의 할당 내용을 출력한다. 실전에서는 컬렉터 로그를 파일로 출력하여 전문 분석 도구로 분석하기도 한다.
따라서 -Xlog:gc* 매개변수를 이용해서 어떻게 메모리를 할당하는지 로그를 통해 눈으로 보자!
아래 소스코드의 testAllocation() 메서드는 크기가 2MB 인 객체 3개와 4MB 인 객체 1개를 생성한다.
public class JvmTest {
private static final int _1MB = 1024 * 1024;
public static void testAllocation() {
byte[] alloc1, alloc2, alloc3, alloc4;
alloc1 = new byte[2 * _1MB];
alloc2 = new byte[2 * _1MB];
alloc3 = new byte[2 * _1MB];
alloc4 = new byte[4 * _1MB]; // 마이너 GC 발생
}
}
위 코드를 컴파일 한 뒤 가상 머신 매개변수를 붙여 실행한다.
➜ jvm javac JvmTest.java
➜ jvm java -XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -Xlog:"gc*" JvmTest
참고로 java21 버전에서 -Xlog:"gc*" 와 같이 값에 "" 를 붙여줘야 실행이 된다.
- -XX:+UseSerialGC
- -Xms20M, -Xmx20M, -Xmn10M
- 자바의 런타임 힙 크기를 20MB 로 제한한다. 확장은 불가하고 10MB 는 신세대에, 나머지 10MB 는 구세대에 배정된다.
- -XX:SurvivorRatio=8
- 신세데의 에덴과 생존자 공간 비율을 8:1 로 설정한다.
실행 결과는 다음과 같다.
1) alloc4 할당 직전의 힙 상황
[0.036s][info][gc,heap,exit] eden space 8192K, 51% used [0x00000007fe800000, 0x00000007fec14830, 0x00000007ff000000)
[0.036s][info][gc,heap,exit] from space 1024K, 6% used [0x00000007ff100000, 0x00000007ff110b68, 0x00000007ff200000)
[0.036s][info][gc,heap,exit] to space 1024K, 0% used [0x00000007ff000000, 0x00000007ff000000, 0x00000007ff100000)
우리는 위에서 신세대의 에덴과 생존자 공간 비율을 8:1 로 설정했다. 따라서 실행 결과 중간에서 설정된 결과를 확인할 수 있다.
그래서 신세대에서 사용할 수 있는 총 공간은 9216KB 이다. (에덴의 용량 + 생존자 공간 1 개의 용량)
이제 testAllocation() 에서 alloc4 객체를 생성하려고 할 때 마이너 GC 가 한차례 수행될 것이다.
alloc4 용 메모리를 할당하려고 보니 에덴에 6MB 이상이 이미 차있어서 4MB 크기인 alloc4 에 내어줄 공간이 부족하기 때문이다.
2) 마이너 GC 직후 힙 상황
생존자 공간의 크기는 겨우 1MB 이기 때문에 가비지 컬렉션 과정에서 가상머신은 에덴에 있던 2MB 객체 3개를 생존자 공간으로 옮길 수 없음을 깨닫고, 할당 보증 매커니즘을 발동해 곧바로 구세대로 옮긴다.
* 할당 보증 매커니즘
할당 보증(Allocation Guarantee)은 Young Generation에 객체를 할당할 수 없을 경우, JVM이 Old Generation에 객체를 직접 할당하는 메커니즘이다. 다음과 같은 조건에서 작동한다.
1. Eden과 Survivor 영역에 충분한 공간이 없을 때.
2.Young Generation에서 GC를 실행해도 대상 객체를 수용할 공간이 없을 때.
실행 결과 중 마이너 GC 결과와 비교해 보자.
[0.036s][info][gc,heap ] GC(0) DefNew: 6635K(9216K)->66K(9216K)
Eden: 6635K(8192K)->0K(8192K)
From: 0K(1024K)->66K(1024K)
[0.036s][info][gc,heap ] GC(0) Tenured: 1036K(14336K)->7180K(14336K)
Eden 영역을 보면 0K 로 깨끗하게 비워진 것을 볼 수 있다.
그리고 From 에서 66K 는 에덴의 기타 객체들 중 생존한 객체를 뜻한다.
Tenured 의 7180K 는 구세대 영역을 뜻하며 에덴에서 바로 넘어온 alloc1, alloc2, alloc3 를 의미한다.
(이 때, 출력 로그에서 Old Generation 을 Tenured 라고 표현한다. "남은 여생 동안 머무르는 공간" 정도로 이해하면 된다.)
마이너 GC 의 결과로 신세대 사용량은 66K 로 줄었지만 줄어든 용량 대부분이 구세대로 이동하여 총 사용량은 크게 줄지 않았다. alloc1, alloc2, alloc3 객체가 여전히 살아있어 가상 머신이 회수할 수 있는 객체가 많지 않았기 때문이다.
3. alloc4 까지 할당한 후의 힙 상황
[0.036s][info][gc,heap,exit] Heap
[0.036s][info][gc,heap,exit] def new generation total 9216K, used 4244K [0x00000007fe800000, 0x00000007ff200000, 0x00000007ff200000)
[0.036s][info][gc,heap,exit] eden space 8192K, 51% used [0x00000007fe800000, 0x00000007fec14830, 0x00000007ff000000)
[0.036s][info][gc,heap,exit] from space 1024K, 6% used [0x00000007ff100000, 0x00000007ff110b68, 0x00000007ff200000)
[0.036s][info][gc,heap,exit] to space 1024K, 0% used [0x00000007ff000000, 0x00000007ff000000, 0x00000007ff100000)
[0.036s][info][gc,heap,exit] tenured generation total 14336K, used 7180K [0x00000007ff200000, 0x0000000800000000, 0x0000000800000000)
[0.036s][info][gc,heap,exit] the space 14336K, 50% used [0x00000007ff200000, 0x00000007ff9033f0, 0x00000007ff903400, 0x0000000800000000)
최종적으로 에덴은 총 8MB 중 51% 가 차있고, 생존자 공간에는 기타 작은 객체들이 들어있다. 구세대는 6MB 정도가 차있다.
2. 큰 객체는 곧바로 구세대에 할당된다.
큰 객체란 커다란 연속된 메모리 공간을 필요로 하는 자바 객체를 말한다. 매우 긴 문자열이나 원소가 매우 많은 배열이 대표적인 예다.
메모리를 할당해야하는 가상 머신에 큰 객체의 등장은 타협이 불가능한 나쁜 소식이다. 큰 객체의 등장보다 더 나쁜 소식은 곧 사라질 큰 객체들을 떼로 만나는 것이다. 그래서 프로그램을 작성할 때 이런 코드는 피하는 것이 좋다. 큰 객체들을 담기 위한 연속된 공간을 확보하기 위해 수많은 다른 객체들을 옮겨야 하므로 심각한 메모리 복사 오버헤드를 동반한다.
이럴 때 -XX:PretenureSizeThreshold 매개 변수를 설정하면 설정값보다 큰 객체를 곧바로 구세대에 할당한다. 이 매개 변수의 목적은 에덴과 두 생존자 공간 사이의 대규모 복사를 줄이는데 있다.
테스트 코드를 통해 확인해 보자.
public class PretenureSizeThresholdTest {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
byte[] alloc;
alloc = new byte[4*_1MB];
}
}
[0.032s][info][gc,heap,exit] Heap
[0.032s][info][gc,heap,exit] def new generation total 9216K, used 655K [0x00000007fe800000, 0x00000007ff200000, 0x00000007ff200000)
[0.032s][info][gc,heap,exit] eden space 8192K, 8% used [0x00000007fe800000, 0x00000007fe8a3e60, 0x00000007ff000000)
[0.032s][info][gc,heap,exit] from space 1024K, 0% used [0x00000007ff000000, 0x00000007ff000000, 0x00000007ff100000)
[0.032s][info][gc,heap,exit] to space 1024K, 0% used [0x00000007ff100000, 0x00000007ff100000, 0x00000007ff200000)
[0.032s][info][gc,heap,exit] tenured generation total 14336K, used 5132K [0x00000007ff200000, 0x0000000800000000, 0x0000000800000000)
[0.032s][info][gc,heap,exit] the space 14336K, 35% used [0x00000007ff200000, 0x00000007ff7033d0, 0x00000007ff703400, 0x0000000800000000)
-XX:PretenureSizeThreshold=3M 로 설정해준 뒤 실행해보면
에덴은 거의 쓰이지 않고 10MB 짜리 구세대는 5132K 가 사용됐음을 확인할 수 있다. 3MB 보다 큰 4MB 크기의 alloc 객체가 곧바로 구세대에 할당되었기 때문이다.
하지만 이 매개변수는 시리얼과 파뉴 신세대 컬렉터에만 적용된다. PS 와 같은 다른 신세대 컬렉터들은 이 매개변수를 지원하지 않는다. 꼭 이 매개변수를 이용해 튜닝하고자 한다면 파뉴 + CMS 조합을 고려해보자.
(JDK 10 까지는 -XX:PretenureSizeThreshold 매개변수의 값이 3M 형태를 인식하지 못하기 때문에 3145728로 지정해야한다. )
3. 나이가 차면 구세대로 옮겨진다.
핫스팟 가상 머신의 컬렉터 대부분은 힙 메모리 관리에 세대 단위 컬렉션을 활용한다. 그래서 메모리를 청소할 때 어떤 생존 객체를 신세대에 남겨 두고 어떤 생존 객체를 구세대로 옮길지 정해야한다. 이를 위해 가상 머신은 각 객체의 객체 헤더에 세대 나이 카운터를 두도록 했다.
객체는 주로 에덴에서 태어나고 태어났을 때의 나이는 0이다. 첫번 째 마이너 GC 에서 살아남은 객체는, 생존자 공간이 충분하면 생존자 공간으로 옮겨지면서 나이가 1 증가한다. 그리고 생존자 공간에서 마이너 GC 를 한번 겪을 때마다 다시 1씩 증가한다. 그리고 특정 나이가 되면 구세대로 승격된다. 구세대로 승격되는 나이는 -XX:MaxTenuringThreshold 매개 변수로 정한다.
이 때 MaxTenuringThreshold 를 바꿔 가면서 테스트 해보자.
1) -XX:MaxTenuringThreshold=1
GC(0) Pause Young (Allocation Failure) // 첫번 째 GC 가 일어남
GC(0) Desired survivor size 524288 bytes, new threshold 1 (max threshold 1) // (1)
GC(0) Age table with threshold 1 (max threshold 1)
(1) -> 최대 나이는 1로 잘 설정 되어있다.
GC(0) DefNew: 4715K(9216K)->194K(9216K)
Eden: 4715K(8192K)->0K(8192K)
From: 0K(1024K)->194K(1024K) // (2)
GC(0) Tenured: 1036K(14336K)->5132K(14336K)
(2) -> alloc1 이 아직 생존자 공간에 남아있다.
GC(1) Pause Young (Allocation Failure) // 2번째 GC
GC(1) Desired survivor size 524288 bytes, new threshold 1 (max threshold 1)
GC(1) Age table with threshold 1 (max threshold 1)
GC(1) DefNew: 4290K(9216K)->0K(9216K)
Eden: 4096K(8192K)->0K(8192K)
From: 194K(1024K)->0K(1024K) // (3) 나이가 차서 구세대로 이동
GC(1) Tenured: 5132K(14336K)->5327K(14336K) // (4) alloc2 + alloc1
(3) -> 두번째 GC 때 생존자 공간에서 사라져 (4) -> 구세대로 이동했다. (1살이 되었기 때문이다!)
2) -XX:MaxTenuringThreshold=15 -XX:TargetSurvivorRatio=80
GC(0) Pause Young (Allocation Failure) // 1번째 GC
GC(0) Desired survivor size 838856 bytes, new threshold 15 (max threshold 15) // (1)
GC(0) Age table with threshold 15 (max threshold 15)
GC(0) - age 1: 199480 bytes, 199480 total
GC(0) DefNew: 4715K(9216K)->194K(9216K)
Eden: 4715K(8192K)->0K(8192K)
From: 0K(1024K)->194K(1024K) // alloc1
GC(0) Tenured: 1036K(14336K)->5132K(14336K) // alloc2
...
GC(1) Pause Young (Allocation Failure) // 2번째 GC
GC(1) Desired survivor size 838856 bytes, new threshold 15 (max threshold 15)
GC(1) Age table with threshold 15 (max threshold 15)
GC(1) - age 2: 199480 bytes, 199480 total
GC(1) DefNew: 4290K(9216K)->194K(9216K)
Eden: 4096K(8192K)->0K(8192K)
From: 194K(1024K)->194K(1024K) // (2) alloc1 아직도 존재하고 있다.
GC(1) Tenured: 5132K(14336K)->5132K(14336K) // alloc2
...
Heap
def new generation total 9216K, used 4372K ...
eden space 8192K, 51% used ... // alloc3
from space 1024K, 19% used ... // (3) alloc1
to space 1024K, 0% used ...
보다시피 (1) 최대 나이도 잘 설정되었고 (2) alloc1 객체는 두 번째 GC 후에도 여전히 생존자 공간에 남아있다.
그런데 이번에는 -XX:TargetSurvivorRatio 매개 변수도 함께 지정했는데 이 매개변수는 생존자 공간이 지정한 비율 이상 차면 나이를 신경쓰지 않고 살아남은 객체들을 구세대로 승격시킨다.
예시에서는 80으로 설정했는데 (1) 의 838856바이트가 생존자 공간크기의 80%에 해당한다. 그리고 (3) 에서 생존자 공간이 19% 정도 채워졌는데 목표 비율을 이보다 더 작게 설정했다면 나이가 차지 않았음에도 바로 구세대로 옮겨졌을 것이다.
이것으로 여기서 구세대로 옮기는 조건이 객체 크기와 나이뿐이 아님을 알 수 있다! 아래서 이어서 더 알아보자.
4. 공간이 비좁으면 강제로 승격시킨다.
다양한 프로그램의 메모리 사용 패턴에 더 정밀하게 대응하기 위해 핫스팟 가상 머신은 나이가 -XX:MaxTenuringThreshold 보다 적으도 구세대로 승격시키기도 한다. 앞에서 바로 이야기한 생존자 공간 점유율이 바로 그 조건이다.
기본값은 50%라서 생존 객체 전체의 크기 총합이 생존자 공간의 절반을 넘어서면 모든 객체를 구세대로 옮긴다. -XX:MaxTenuringThreshold 로 정한 나이는 무시된다.
이번에는 alloc_new 라는 객체를 추가해 80%를 넘기는 상황을 만들어보자.
- 매개변수
-XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -Xlog:gc*
-Xlog:gc+age=trace -XX:MaxTenuringThreshold=15 -XX:TargetSurvivorRatio=80
- 실행 로그
GC(0) Pause Young (Allocation Failure) // 첫번 째 GC
GC(0) Desired survivor size 838856 bytes, new threshold 15 (max threshold 15)
GC(0) Age table with threshold 15 (max threshold 15)
GC(0) - age 1: 885400 bytes, 885400 total
GC(0) DefNew: 5436K(9216K)->864K(9216K)
Eden: 5436K(8192K)->0K(8192K)
From: 0K(1024K)->864K(1024K) // (1) alloc1, alloc_new 생존자 공간에 존재
GC(0) Tenured: 0K(14336K)->4096K(14336K) // alloc2
...
GC(1) Pause Young (Allocation Failure) // 두 번째 GC
GC(1) Desired survivor size 838856 bytes, new threshold 15 (max threshold 15)
GC(1) Age table with threshold 15 (max threshold 15)
GC(1) - age 2: 885400 bytes, 885400 total
GC(1) DefNew: 4960K(9216K)->0K(9216K)
Eden: 4096K(8192K)->0K(8192K)
From: 864K(1024K)->0K(1024K) // (2) 80% 초과, 구세대로 이동
GC(1) Tenured: 4096K(10240K)->4960K(10240K) // (3) alloc2 + alloc1, alloc_new
...
Heap
def new generation total 9216K, used 4436K ...
eden space 8192K, 51% used ...
from space 1024K, 0% used ...
to space 1024K, 0% used ...
tenured generation total 14336K, used 5132K // alloc1, alloc_new, alloc2
샐행결과 첫번 째 마이너 GC 때도 (1) alloc1 객체는 똑같이 생존자 공간에 존재했다. 그런데 두 번째 GC 때는 (2) 생존자 공간 목표 비율인 80% 를 훌쩍 넘어서 (3) 나이가 차지 않았음에도 구세대로 이동되었다.
4. 마치며
이번 글에서는 자바 가상 머신의 자동 메모리 할당과 회수의 주요 규칙들을 코드 예제와 함께 직접 검증해보았다.
가비지 컬렉터는 많은 경우에 시스템의 일시 정지 시간과 처리량에서 중요한 요인으로 작용한다. 사용자가 실제 애플리케이션의 요구 조건과 구현 방식에 가장 적합한 컬렉션 방식을 선택할 수 있도록 가상 머신은 다양한 컬렉터와 많은 조율 매개 변수를 제공한다. 모든 상황에서 단 하나의 컬렉터나 매개 변수 조합이란 없다. 따라서 가상 머신 메모리의 대한 지식을 쌓고 최적화 노하우를 얻고 싶다면, 각 컬렉터의 동작 방식과 장단점 그리고 매개변수들을 이해해야한다.
느낀점
예제에서 다뤄본 매개변수는 겨우 일부에 불과한데 이렇게 많은 매개변수들이 존재하고 있는지 몰랐다. 로그를 통해 직접 메모리들이 어떻게 이동하고 회수되는지를 눈으로 보니 좀 더 잘 이해하고 다른 시선으로도 바라볼 수 있게 되었다. 실무에서 이런 매개변수들을 사용해본 경험이 전무한데 앞으로 필요한 상황이 생기면 이런 매개변수들을 활용해서 최적화를 해볼 수 도 있을 것 같다!
'Kotlin & Java' 카테고리의 다른 글
생성자보다 정적 팩터리 메서드를 고려해보자 (0) | 2023.01.11 |
---|---|
# c++과 Java의 닮은 점과 차이점은 무엇일까? (0) | 2020.02.02 |
Kotlin 설치 및 실습 환경 구축하기 (0) | 2019.12.24 |
안드로이드 apk 파일 만들기 (0) | 2019.06.20 |