PR에도 GC 기본 관련 추가 설명이 있습니다 (opens in a new tab)
1. 자유로운 JVM을 봐. 자유로워
오라클은 JVM에 대해 그리 많은 것을 정해두지 않는다. "스펙"으로서 존재한다.
마치 인터페이스를 선언하듯, 단지 "OS에 맞게 바이트 코드를 실행할 수 있는" 추상적인 형태의 스택 기반 해석 머신 이라고만 정해두었고, 다양한 회사들이 그 "구현체"를 만들어 두었다.
인터페이스가 그러하듯, 이러한 스펙 선언 방식은 구현에 엄청난 자유를 부여하는데, JVM은 (무려) 하드웨어의 형태여도 상관 없고, JAVA가 아닌 다른 언어를 컴파일 이전의 언어로 사용해도 된다.
내가 최근에 사용해본 Kotlin 부터 스칼라, 그루비, JRuby, 자이썬 등등.. 다양한 언어가 JVM에 의해 해석된다. 바이트 코드를 해석 가능하기만 하면 장땡이다~ 이말이란다.
2. GC Trade-Off
JVM이 그러할진데 GC 또한 사정이 별반 다르지 않다. 구현에 대한 자세한 설명이 없다..
오라클 JVM 문서엔 GC에 대한 스펙이 상세히 기술되어 있지 않는데, 단지 "객체용 힙 공간은 GC라는 자동 저장소 관리 시스템으로 회수된다. 어떤 일이 있어도 객체를 명시적으로 해제해서는 안된다." 라고만 짧게 적혀있다.
와 자유롭다! 심지어 GC가 없는 Lego Mindstorms라는 JVM도 있다;;;
GC 알고리즘 또한 매우 형태가 다양한데, 범용적으로 어떤 GC가 모든 상황에서 낫다! 이런건 없고, 결국 다 트레이드 오프다. "우리 상황에서 뭐가 가장 나은지" 를 따져야 한다.
여러가지 예를 들어보자. JVM은 GC를 수행시키기 위해 잠시 어플리케이션을 멈추는데 이를 STW(Stop-The-World) 중단이라고 부른다.
카카오톡과 같은 서비스는 실시간으로 엄청난 양의 작업을 처리하기 때문에, STW로 인해 어플리케이션이 멈추는 시간이 긴 경우 치명적인 피해를 입게 된다.
반면, 배치 잡을 수행하는 어플리케이션은 중단 시간의 길이는 크게 중요하지 않다. 물론 중요하지. 중요하지만, 중단 시간이 짧은 알고리즘 보다, CPU 효율 및 처리율이 우수한 GC 알고리즘이 있다면 그 알고리즘을 선택할 것이다.
또 그래픽이나 애니매이션 디스플레이 시스템은 프레임률이 웬만하면 고정되어 있기 때문에, 규칙적으로 GC를 수행할 수 있다.
하지만 대부분의 서비스들은 "언제" GC를 수행하는 것이 적절한지 정해져 있지 않다. GC는 그저 "필요할 때" 작동하는데, 이런 예측 불가능성이 중단으로 인한 지연보다도 더 문제인 서비스들이 있다. 그런 서비스들은 STW를 최대한 줄인 최신 GC들을 사용해야 한다.
개발자는 우리 서비스에 맞는 GC를 선택하기 위해 아래의 요소들을 고려해야 한다.
- 중단 시간
- 처리율 (Application 런타임 대비 GC의 시간 비율)
- 중단 빈도
- 메모리 회수 효율 (GC 사이클당 얼마나 많은 가비지가 수집되는지?)
- 중단 일관성 (중단 시간이 고른지?)
개발자는 위의 요소들을 고려해 우리 서비스에 가장 적절한 GC 알고리즘을 고르면 된다. 물론 가장 큰 관심사는 중단 시간과 빈도인데, 이 두 요소를 열심히 개선시킨 최신 GC들을 다음에! 알아보자 ㅋ
3. JVM Safepoint
앞서, STW 가비지 수집 때 애플리케이션을 중단시킨다고 하였다. 왜 중단시킬까? 복잡시러워서?
GC는 사용중이지 않은 메모리를 수거하는 것이 목적이기 때문에, 전체 객체들의 의존관계가 필요하다. 안정된 객체 의존관계 그래프를 확보하기 위해, 전체 애플리케이션 스레드를 중단시킬 필요가 있다.
그렇다면 Hotspot JVM은 어떻게 모든 스레드를 중단시킬 수 있을까?
그야 뭐.. JVM이 애플리케이션에서 가장 싸움을 잘 해서 preemptive하게 작업중인 스레드들에게서 키보드 마우스를 강제로 빼았는 것 아니냐? 라고 생각할 수도 있다.
하지만, JVM은 fully preemptive하지 않다! 그렇다고 cooperative 하냐? 하면, 언제든 preemptive하게 코어에서 스레드를 제거할 수 있기 때문에 cooperative 하지 않다. (스레드가 할당된 시간을 다 쓰거나, wait()로 잠드는 상황에 제거됨.)
그럼 어떻게 하냐? 멈춰달라고 "요청"한다. 그런데 망치를 들고 있는..
스레드들은 각각 "safepoint"라는 특별한 실행 지점을 두는데, 이 지점에서 스레드는 잠시 중단될 수 있다.
JVM은 아래 2가지 규칙에 따라 safepoint를 처리한다.
- JVM은 스레들를 강제로 safepoint 상태로 로 바꿀 수 없다.
- 대신, 스레드가 safepoint 상태에서 벗어나지 못하게 할 수 있다.
위 2가지 규칙에 따라 스레드는 savepoint 상태로 바뀌는데, '일반적인 경우' 아래와 같은 절차를 따른다.
- JVM이 전역
time to safepoint
flag를 set한다.
safepoint가 될 시간이야~하고 타이른다 - 각 Application 스레드들은 polling 방식으로 flag를 확인한다.
- 스레드가 flag의 set을 확인하면, 다시 깨어날 때까지 멈춘다.
time to safepoint
가 set되면 모든 어플리케이션 스레드는 반드시 멈춰야 한다.
뭐야. 그러면 fully preemptive 아닌가요? 할 수도 있는데, 폴링 시점이 상황마다 다르고, 스레드들이 각자 멈출 때까지 기다려 주기 때문에 'fully'하지 않다고 표현하는 것 같다.
마치 CountDownLatch처럼, 모든 스레드들이 완전히 중단될 때까지 기다렸다가 작업을 수행한다.
이래서 내가 "망치를 들고" 멈춰달라고 요청한다고 표현했던 것이다. 동석이 형이 야구 배트를 들고 조용히 서 있다고 생각해보자. 한 손에는 "멈춰볼까?"라고 적힌 팻말을 들고 있다.
이 모습을 먼저 발견한 사람은 먼저 행동을 멈출 것이고, 나중에 발견한 사람은 나중에 멈출 것이다. 결국 멈추는건 정해져 있는데, 각자 할일은 하면서 한번씩 힐끔 힐끔 확인하는 것이다. 우리의 JVM형은 친절하게도 time to safepoint
flag를 set 해두고, 스레드들이 멈추는 것을 기다려준다.
이러한 flag polling과 제어권 반납 작업을 위해, 인터프리터 구현체 안에는 safepoint 요청시 스레드가 제어권을 반납하도록 하는 코드가 있는데, 보통 바이트코드 2개를 실행할 때마다 flag를 확인한다.
JIT 컴파일러에 의해 컴파일된 메서드에도 이러한 '베리어'가 있다. 이런 compiled code에서는 메서드 밖으로 나가거나, 분기가 루프 시작점으로 회귀하는 지점에 polling code가 삽입되어 있다. (JIT 컴파일러에 의해)
그래서..
JVM은 이러한 time to safepoint
flag를 set하여 safepoint 요청을 보낼 수 있고, GC 작업 전에 모든 스레드들을 멈출 수 있다.
이를 통해 JVM은 안정적인 객체 의존 그래프를 확보할 수 있고, 사용중이지 않은 객체를 탐색할 수 있다.