본문 바로가기

엔지니어링(TA, AA, SA)/성능과 튜닝

[엔지니어링] 자바 최적화 - GC 로깅

GC 로그는 시스템이 내려간 원인의 단서를 찾는 '콜드 케이스(cold case)' 분석을 할때 매우 유용하다. 파일에 씌여진 로그를 분석하는 작업이므로 애플리케이션 프로세스가 살아있지 않아도 된다. 콜드 케이스(cold case)는 진상이 명확히 밝혀지지 않은 범죄나 사고를 가리키는 용어로 '원인을 알 수 없는 현상' 정도를 비유하는 용도로 쓰였다.

 

모든 중요한 애플리케이션에는 다음 두가지를 설정해야 한다.

  - GC 로그를 생성한다.

  - 애플리케이션 출력과는 별도로 특정 파일에 GC 로그를 보관한다.

 

특히, 운영계 애플리케이션에서는 필수 사항이다. GC 로깅은 사실 오버헤드가 거의 없는 것이나 다름없으니 주요 JVM 프로세스는 항상 로깅을 켜놓아야 한다.


GC 로깅

1) GC 로깅 켜기

애플리케이션 시작시 다음 스위치를 추가한다.

-Xloggc:gc.log -XX:+PrintGCDetails -XX:+PrintTenuringDistribution
-XX:+PrintGCTimeStamps -XX:+PrintGCDataStamps
플래그 작용
-Xloggc:gc.log GC 이벤트에 로깅할 파일을 지정한다.
-XX:+PrintGCDetails GC 이벤트 세부 정보를 로깅한다.
-XX:+PrintTenuringDistribution 툴링에 꼭 필요한, 부가적인 GC 이벤트 세부 정보를 추가한다. 
-XX:+PrintGCTimeStamps GC 이벤트 발생 시간을 (VM 시작 이후 경과한 시간을 초 단위로) 출력한다.
-XX:+PrintGCDateStamps GC 이벤트 발생 시간을 (벽시계 시간 기준으로) 출력한다.

 

다음은 필수 플래그에서 성능 엔지니어가 주의해야 할 사항이다.

  - 기존 플래스 verbose:gc는 지우고 대신 PrintGCDetails를 사용한다.

  - PrintTenuringDistribution은 다소 독특한 플래그로, 이 플래그가 제공하는 정보를 사람이 이용하기는 어렵다. 중요한 메모리압(memory pressure: 메모리 할당 압박) 효과, 조기 승격 등의 이벤트 계산시 필요한 기초 데이터를 제공한다.

  - PrintGCDateStamps와 PrintGCTimeStamps는 둘다 필요하다. 전자는 GC 이벤트와 애플리케이션 이벤트(로그파일)를, 후자는 GC와 다른 내부 JVM 이벤트를 각각 연관짓는 용도로 쓰인다.

 

로그를 이 정도로 세세히 남겨도 JVM 성능에 이렇다할 영향은 없다. 물론, 생성되는 로그량은 할당률, 사용 중인 수집기, 힙 크기(힙이 작으면 더 자주 GC하므로 로그가 더 자주 쌓임)에 따라 달라진다. 할당기 샘플을 초당 50메가바이트 할당하는 조건으로 30분 돌려도 로그는 600킬로바이트 이하로 쌓인다.

 

필수 플래그 이외에도 로그 순환(rotation) 관련 플래그가 있고, 운영계 환경에 유용하다.

플래그 작용
-XX:+UseGCLogFileRotation 로그 순환 기능을 켠다.
-XX:+NumberOfGCLogFiles=<n> 보관 가능한 최대 로그파일 갯수를 설정한다.
-XX:+GCLogFileSize=<size> 순환 직전 각 파일의 최대 크기를 설정한다.

 

로그 순환 정책은 (데브옵스를 비롯한) 운영팀과 협의해서 합리적으로 수립해야 한다. 로그 정책에 관한 옵션이나 적절한 로깅 툴을 선정하는 문제는 따로 찾아보도록 한다.


2) GC 로그 vs JMX

VisualGC는 JVM 힙 상태를 실시간 표시하는 툴이다. 실제로 자바 관리 확장(JMX: Java Management eXtensions) 인터페이스를 통해 JVM 데이터를 수집한다. JMX가 GC에 영향을 주기때문에 성능엔지니어는 다음 사항을 숙지해야한다.

  - GC 로그 데이터는 실제로 가비지 수집 이벤트가 발생해서 쌓이지만, JMX는 데이터를 샘플링하여 얻는다.

  - GC 로그 데이터는 캡처 영향도가 거의 없지만, JMX는 프록시 및 원격 메서드 호출(RMI: Remote Method Invocation) 과정에서도 암묵적인 비용이 든다.

  - GC 로그 데이터에는 자바 메모리 관리에 연관된 성능 데이터가 50가지 이상있지만, JMX는 10가지도 안된다.

 

JMX는 성능 데이터 원천으로서 스트리밍된 데이터를 즉시 제공한다는 점에서 GC 로그보다 낫지만, 요즘은 jClarity 센섬같은 툴도 GC 로그 데이터를 스트리밍하는 API를 제공하므로 별반 차이가 없다. 기본적인 힙 사용 실태를 파악하는 용도로는 JMX가 제격이지만, 더 깊이있는 진단을 하려고 하면 금세 부족함을 느끼게 된다.

 

JMX로 가져온 빈(JMX 명세서에는 이런 빈을 MBean이라고 부른다. 이름 그대로, 각종 디바이스, 애프리케이션을 비롯한 관리되어야 할 리소스를 빈으로 나타낸 것이다)은 표준 빈이고 쉽게 액세스 할 수 있다. 데이터 시각화 툴은 VisualVM 외에도 다양한 상용 제품들이 있다.


3) JMX의 단점

JMX를 이용해 애프리케이션을 모니터링하는 클라이언트는 대부분 런타임을 샘플링하여 현재 상태를 업데이트 받는다. 클라이언트는 데이터를 계속 넘겨받기 위해 런타임에 있는 JMX 빈을 폴링하게 된다. 문제는 가비지 수집이다. 수집기가 언제 실행될지 클라이언트는 알 도리가 없다. 다시 말해, 각 수집 사이클 전후의 메모리 상태 역시 알 수가 없으므로 GC 데이터를 깊이있게, 정확하게 분석하기가 어렵다.

 

물론, JMX로 얻은 데이터가 분석할 만한 가치가 없는 건 아니지만, 장기적 추이를 파악하는 정도로 쓸수밖에 없다. 가비지 수집기를 정확하게 튜닝하려면 정보가 더 필요하다. 특히, 각 수집 전후의 힙 상태 정보가 대단히 중요하다.

 

또 메모리압(할당률)을 분석하는 활동이 매우 중요한데, JMX는 데이터를 수집하는 방식때문에 이마저도 아예 불가능하다. 그뿐만 아니라, JMXConnector 명세를 구현한 코드는 내부적으로 RMI에 의존하므로 RMI 기반 통신 채널의 고질적인 문제점에 취약하다.

  - 방화벽에 포트를 열어야 하기 때문에 부차 소켓 접속(secondary socket connection)이 맺어질 수 있다.

  - 프록시 객체를 이용해 remove() 메서드 호출을 대행한다.

  - 자바 종료화(finalization: 어떤 객체를 참조하는 객체가 더 이상 없어 GC 대상이 되었을때 가비지 수집기가 finalizer를 호출해 Finalize() 메서드로 해당 객체를 정리하는 작업)에 의존한다.

 

접속을 해제하는 작업량이 매우 적은 RMI 접속도 있지만, 정리 작업은 종료화에 의존한다. 즉, 가비지 수집기를 돌려 객체를 회수해야 한다. JMS 접속은 수명주기 특성상, 풀 GC를 하기 전에는 RMI 객체가 수집되지 않고 남아있을 가능성이 크다. 종료화가 미치는 영향과 종료화를 삼가해야 하는 이유는 이후에 살펴본다.

 

RMI를 사용하는 애플리케이션은 기본 1시간에 한번씩 풀 GC가 발생한다. 이미 RMI를 사용중이라면 JMX를 붙인다고 더 나빠질건 없겠지만, 그 외에는 JMX를 사용하는 순간부터 추가 부하는 피할 수 없다.


4) GC 로그 데이터의 장점

최신 가비지 수집기는 수많은 부품이 한데 조립된, 엄청나게 복잡한 구현체이다. 수집기의 성능 역시 불가능하진 않지만 그만큼 예측하기가 힘들다. 이처럼 전체 구성 컴포넌트가 서로 맞물려 작동하면서 최종적 동작, 성능이 귀결되는 소프트웨어를 발현적(emergent)이라고 한다. 상이한 압력이 각기 다른 컴포넌트에 서로 다른 방식으로 작용하므로 매우 유동적인 비용 모델이다.

 

처음에 자바 GC를 개발한 사람들은 GC 로깅을 JVM 구현체 디버깅 용도로 추가했다. 결국, 60개의 가까운 GC 플래그로 생성된 데이터 상당수가 성능 디버깅 목적으로 쓰이게 되었다.

 

시간이 흐르며 애플리케이션에서 가비지 수집 프로세스 튜닝을 맡았던 사람들은, GC 튜닝의 복잡함을 감안하더라도 런타임에서 무슨일이 발생했느지 정확히 파악하는데 GC 로그가 아주 유용하다는 사실을 깨달았다. GC 로그는 핫스팟 JVM 내부에서 논블로킹 쓰기 메커니즘을 이용해 남긴다. 로깅이 애플리케이션 성능에 미치는 영향은 거의 0이므로 운영계는 무조건 켜두어야 한다.

 

GC 로그에 쌓인 기초 데이터는 특정 GC 이벤트와 연관 지을 수 있어서 모든 의미있는 분석 작업(어느 지점에서 수집 비용이 발생하는지, 어떻게 튜닝해야 긍정적인 결과를 얻을 수 있을지 등)을 수행할 수 있다.


로그 파싱 툴

GC 로그 메시지는 어떤 정해진 언어나 VM 명세 표준 포맷이 따로 없다. 로그에 무슨 메시지를 남길지는 핫스팟 GC 개발팀에 의한거여서 마이너 릴리즈 간에도 포맷이 조금씩 다르다.

 

단순한 로그 포맷을 파싱하는건 어렵지 않지만, GC 로그 플래그가 하나둘 추가되면서 실제 출력되는 로그도 엄청나게 복잡해지게 된다. 특히, 동시 수집기가 생성하는 로그는 더욱 복잡하다.

 

GC 설정을 변경해서 로그 출력 포맷이 달라지면 수동 GC 로그 파서를 쓰는 시스템에서 로깅이 끊어지는 사태가 곧잘 벌어진다. GC 로그를 자세히 조사해보면 로그가 가장 필요한때에 수동 파서가 변경된 로그 포맷을 처리하지 못했다는 사실을 뒤늦게 발견하게 된다. 스스로 GC 로그를 파싱하기보다는 툴을 사용할 것을 권장한다.


1) GCViewer

GCViewer(GC뷰어)는 GC 로그 파싱 및 그래프 출력 등 기본 기능을 갖춘 데스크톱 툴이다. 오픈 소스라서 무료인 점이 가장 큰 장점이지만 상용 툴에 비해 빈약한 기능은 감수해야 한다. 소스(https://github.com/chewiebug/GCViewer)를 내려받아 컴파일/빌드 후 실행 가능한 JAR 파일로 패키징하면 된다. GC 로그 파일은 GCViewer 메인 UI에서 열어볼 수 있다. GC 뷰어는 분석 기능은 없고 특정 GC 핫스팟 로그 포맷만 파싱할 수 있다. GCViewer를 파싱 라이브러리로 쓰고 결과 데이터를 시각화 툴로 내보내는 방법도 있지만, 기존 오픈 소스 코드 베이스에 추가 개발을 해야하는 부담이 있다.

 

똑같은 데이터라도 시각화한 모습은 툴마다 다를 수 있다. 단순 톱니형 패턴은 전체 힙크기를 측정해서 그랜 샘플뷰이다. 동일한 GC 로그 데이터를 GCViewer에서 'Heap Occupancy after GC(GC 이후 힙점유)' 뷰로 보면 아래처럼 나타나게 된다.


GC 기본 튜닝

"GC는 언제 튜닝해야 할까?" JVM 튜닝 전략을 수립하는 엔지니어가 자주 물어보는 질문이다. GC 튜닝 역시 다른 튜닝 기법처럼 전체 진단 과정의 일부여야 한다. 다음 내용은 실무에서 GC 튜닝을 할때 도움이 될 것이다.

  1. GC가 성능에 문제를 일으키는 근원이라고 확인하거나 그렇지 않다고 배제하는 행위는 저렴하다.

  2. UAT에서 GC 플래그를 켜는 것도 저렴한 행위이다.

  3. 메모리 프로파일러, 실행 프로파일러를 설정하는 작업은 결코 저렴하지 않다.

 

엔지니어는 튜닝을 수행하면서 다음 네가지 주요 인자(할당 / 중단 민감도 / 처리율 추이 / 객체 수명)를 면밀히 측정해야 한다. 이 중에서 가장 중요한 요인은 할당이다. 처리율에는 여러 가지 요인이 영향을 미친다. 가령, 동시 수집기는 실행시 여러 코어를 점유하게 된다.

 

다음은 힙크기를 조정하는 기본 플래그이다.

플래그 작용
-Xms<size> 힙 메모리의 최소 크기를 설정한다.
-Xmx<size> 힙 메모리의 최대 크기를 설정한다.
-XX:MaxPermSize=<size> 펨젠 메모리의 최대 크기를 설정한다. (자바 7 이후)
-XX:MaxMetaspaceSize=<size> 메타스페이스 메모리의 최대 크기를 설정한다. (자바 8 이후)

 

MaxPermSize는 자바 7 이전에만 적용되는 레거시 플래그이다. 자바 8부터는 펨젠이 사라지고 메타스페이스로 교체되었다. 자바 8 애플리케이션에 MaxPermSize 설정값이 있으면 지우도 되며, JVM이 그냥 무시하게때문에 있어도 애플리케이션에 영향을 주지는 않는다.

 

튜닝시 GC 플래그는 다음과 같이 추가하도록한다.

  - 한번에 한 플래그씩 추가한다.

  - 각 플래그가 무슨 작용을 하는지 숙지해야 한다.

  - 부수 효과를 일으키는 플래그 조합도 있음을 명심한다.

 

현재 이벤트가 발생 중이라면, 성능 문제를 일으키는 원인이 GC인지 아닌지 판단하는건 어렵지 않다. 먼저, vmstat 같은 툴로 고수준의 머신 지표를 체크하고 성능이 떨어진 시스템에 로그인해서 다음을 확인해본다.

  - CPU 사용률이 100%에 가까운가?

  - 대부분의 시간(90% 이상)이 유저 공간에서 소비되는가?

  - GC 로그가 쌓이고 있다면 현재 GC가 실행중이라는 증거다.

 

위 내용은 지금 문제가 발생 중이고 에니지니어가 실시간 관측할 수 있다는 전제하에 가능하다. 지난 이벤트를 조사하려면 충분한 (CPU 사용률, 타임스탬프가 찍힌 GC 로그 등) 모니터링 이력 데이터가 쌓여 있어야 한다.

 

세가지 조건이 다 맞는다면 GC가 성능 이슈를 일으키고 있을 가능성이 크고 철저한 조사와 튜닝이 필요하다. 테스트는 지극히 간단하고 결과는 분명하다. GC가 성능 문제의 출처라고 밝힌 다음에는 할당과 중단 시간 양상을 파악한 다음, GC를 튜닝하고 필요시 메모리 프로파일러를 활용하면 된다.


1) 할당이란?

할당률 분석은 튜닝 방법뿐만 아니라, 실제로 가비지 수집기를 튜닝하면 성능이 개선될지 여부를 판단하는데 꼭 필요한 과정이다. 영세대 수집 이벤트 데이터를 활용하면 할당된 데이터양, 단위 수집 시간을 계산할 수 있고 일정 시간 동안의 평균 할당률을 산출할 수 있다.

 

수작업으로 할당률을 계산하느라 시간, 노력을 낭비하지 말고 툴을 써서 계산할 것을 권장한다. 경험상, 할당률의 수치가 1GB/s 이상으로 일정 시간 지속한다면 십중팔구 가비지 수집기 튜닝만으로는 해결할 수 없는 성능 문제가 터진 것이다. 이 경우 성능을 향상시키려면 애플리케이션 핵심부의 할당 로직을 제거하는 리팩토링을 수행하여 메모리 효율을 개선하는 방법 밖에 없다.

 

VisualVM, jmap 으로 단순 메모리 히스토그램만 보아도 메모리가 어떤 식으로 할당되는지 파악할 수 있다. 초기 할당 전략은 다음 네가지 단순 영역에 집중하는 것이 좋다.

  - 굳이 없어도 그만인, 사소한 객체 할당 (예: 로그 디버깅 메시지)

  - 박싱 비용

  - 도메인 객체

  - 엄청나게 많은 논JDK 프레임워크 객체

 

먼저 첫번째 항목은, 불필요한 객체를 생성하는 부위를 찾아 그냥 제거하면 된다. 과도한 박싱도 그중 하나지만, 이밖에도 쓸데없이 객체를 생성하는 출처(예: JSON 직렬화/역직렬화용 자동 생성 코드나 ORM 코드)는 다양하다.

 

드물지만 도메인 객체가 메모리를 많이 차지하는 일도 있다. 주로 다음 타입이 문제이다.

  - char[]: 스트링을 구성하는 문자 character(캐릭터)

  - byte[]: 바이너리 데이터

  - double[]: 계산 데이터

  - 맵 엔트리

  - Object[]

  - 내부 자료 구조(예: methodOop, klassOop)

 

단순 힙 히스토그램을 그려보면 불필요한 도메인 객체가 히스토그램의 상위권을 점유하면서 과하게 생성되는 모습을 지켜볼 수 있다. 이럴 때는 도메인 객체의 예상 데이터양을 재빨리 계산해 실제 측정된 데이터양과 비슷한 수준인지 확인한다.

 

스레드 로컬 할당 기법은 스레드마다 객체를 할당할 공간을 개별 발급하여 O(1) 할당을 달성하게된다. TLAB은 스레드 당 크기가 동적 조정되며, 일반 객체는 남은 TLAB 공간에 할당된다. 여유 공간이 없으면 스레드는 VM에게 새 TLAB을 달라고 요청한 다음 재시도한다.

 

객체가 너무 뚱뚱해서 빈 TLAB에 안들어가면 VM은 TLAB 외부 영역에 위치한 에덴에 직접 객체 할당을 시도하게 된다. 이것도 실패하면 영 GC를 수행하는 다음 단계로 넘어가게 된다. (힙크기가 재조정 된다.) 그래도 공간이 부족하면 최후의 방법으로 테뉴어드 영역에 객체를 직접 할당한다.

 

따라서 결국 덩치 큰 배열(특히 byte[], char[])만 곧바로 테뉴어드에 할당될 가능성이 크다.

 

핫스팟은 TLAB 및 큰 객체의 조기 승격에 고나한 튜닝 플래그를 제공한다.

-XX:PretenureSizeThreshold=<n>
-XX:MinTLABSize=<n>

다른 스위치도 마찬가지지만, 각 스위치가 어떤 영향을 미치는지 제대로 벤치마킹도 안하고 확실한 근거없이 막연히 사용하면 안된다. 대부분 기본 내장된 동적 기능만 이용해도 충분하며, 설정을 바꿔도 크게 눈에 띄는 영향은 없다.

 

또 할당률은 테뉴어드로 승격되는 객체 수에 영향을 끼친다. 단명 자바 객체의 (벽시계 시간으로 나타낸) 수명이 불변이라고 가정하면 할당률이 높을수록 영 GC 발생주기는 짧아진다. 너무 자주 수집이 일어나면 단명 객체가 테뉴어드로 잘못 승격될 가능성이 크다.

 

즉, 할당이 폭주하면 조기 승격 문제가 불거질 것이다. JVM은 이런 일이 없도록 테뉴어드 승격없이 엄청난 양의 생존 데이터를 담을 서바이버 공간을 동적 조정한다. 조기 승격문제에는 다음 스위치가 유용하다.

-XX:MaxTenuringThreshold=<n>

테뉴어드 영역으로 승격되기 전까지 객체가 통과해야 할 가비지 수집 횟수를 설정하는 것이다. 디폴트 값은 4회이고 1~15 사이의 한계치를 설정할 수 있다. 이 값을 바꿀때는 다음 두가지가 상충되는 관심사를 잘 따져봐야 한다.

  - 한계치가 높을수록 진짜 장수한 객체를 더 많이 복사한다.

  - 한계치가 너무 낮으면 단명 객체가 승격되어 테뉴어드에 메모리압을 가중시킨다.

 

한계치를 너무 낮게 잡으면 테뉴어드로 승격되는 객체가 증가하고 그만큼 더 빨리 공간을 차지하게되어 풀 수집이 더 자주 발생한다. 매사 그렇듯, 논디폴트 값으로 성능이 확실히 나아진 벤치마킹 사례가 없는한 스위치를 함부로 변경하면 안된다.


3) 중단 시간이란?

개발자는 중단 시간에 대한 인지 편향에 종종 시달린다. 대부분의 애플리케이션에서 100밀리초 정도의 중단 시간은 무시할 만하다. 인간의 눈은 하나의 데이터 항목을 초당 5회밖에 처리하지 못하므로, 인간이 조작하는 (웹 애플리케이션 등의) 애플리케이션에서 100~200 밀리초 정도의 중단은 눈치채기 어렵다.

 

중단 시간 튜닝시 유용한 휴리스틱을 소개한다. 애플리케이션의 응답 요건에 따라 허용 가능한 중단 시간을 다음 세 대역으로 나누어 표현하는 것이다.

  1. > 1초: 1초 이상 걸려도 괜찮다.

  2. 1초 ~ 100밀리초: 100밀리초 이상 1초 이하 정도는 괜찮다.

  3. < 100밀리초: 100밀리초까지는 괜찮다.

 

중단 민감도를 애플리케이션 힙 크기와 대략 연관 지어보면 어던 수집기가 가장 적합한지 가늠할 수 있다.

허용 중단 시간
>1s 1s - 100ms < 100ms < 2 GB
Parallel Parallel CMS < 4 GB
Parallel Parallel/G1 CMS < 4 GB
Parallel Parallel/G1 CMS < 10 GB
Parallel/G1 Parallel/G1 CMS < 20 GB
Parallel/G1 G1 CMS > 20 GB

물론, 어디까지나 튜닝의 시초로 삼을만한 경험적 가이드일뿐, 100% 정확한 규칙은 아니다. 향후 G1이 수집기로 굳혀지면 현재 ParallelOld 수집기로 처리하는 것보다 더 폭넓은 유스케이스를 커버할 수 있을 것이다. CMS 유스케이스까지 처리할 정도로 확장될 수도 있지만, 가능성은 크지 않다.

 

동시 수집기를 사용할 경우, 중단 시간을 튜닝하려고 하기 전에 할당률부터 줄여야 한다. 할당률이 낮아지면 동시 수집기에 가해지는 메모리압도 낮아지므로 수집 사이클이 스레드 할당 속도를 따라가기 쉬워진다. 또한 중단 시간에 민감한 애플리케이션에서 반드시 방지해야할 CMF 이벤트 발생확률이 감소한다.


3) 수집기 스레드와 GC 루트

스스로 'GC 스레드처럼 생각'하려고 마음먹으면 갖가지 환경에서 수집기가 어떻게 작동하는지 파악할 수 있다. 그러나 GC의 다른 영역과 마찬가지로, 여기에도 근본적인 트레이드오프가 도사리고 있다. 예를 들어, GC 루트 탐색 시간은 다음과 같은 요인의 영향을 받는다.

  - 애플리케이션 스레드 갯수

  - 코드 캐시에 쌓인 컴파일드 코드량

  - 힙 크기

 

이 셋은 GC 루트 탐색에 큰 영향을 끼친다. 런타임 조건 및 적용 가능한 병렬화 정도에 따라서도 달라진다.

 

예를 들어, 마킹 단계에서 엄청나게 큰 Object[]를 발견되었다. 탐색은 단일 스레드로 수행하므로 작업 훔쳐오기(work stealing: 멀티스레드 프로그래밍에서 흔히 쓰이는 스케줄링 전략. 스레드마다 전용 큐를 두고 스레드 풀에서 일감을 가져다 자신의 큐에 넣고 실행하다 보면 특정 스레드에 일감이 몰리게 되는 현상이 발생한다. 따라서 놀고 있는 스레드가 다른 바쁜 스레드의 큐에 있는 일감을 훔쳐와 자신의 큐에 넣고 실행시킴으로써 전체적인 부하를 분산시키는 것이다.)는 불가능하다. 따라서 극단적으로는 이 단일 스레드의 탐색 시간이 전체 마킹 시간을 결정짓게 된다.

 

객체 그래프가 복잡해질수록 이런 현상은 더욱 심해진다. 그래프 내부에 객체 체인이 길게 늘어지면서 마킹 시간도 점점 더 길어지게 된다.

 

애플리케이션 스레드가 너무 많아도 스택 프레임을 더 많이 탐색해야 하고 세이프포인트에 도달하는 시간도 길어지는 등 GC 시간에 영향을 끼친다. 베어 메탈과 가상 환경에 존재하는 스레드 스케줄러도 압박하게 된다. JNI 프레임과 JIT 컴파일드 코드용 캐시 등 다른 GC 루트 원천들도 있다. 코드 캐시에서 GC 루트를 탐색하는 작업 역시 싱글스레드로 동작된다.

 

세가지 중 스택, 힙 탐색은 비교적 병렬화가 잘된다. 세대 수집기 역시 (Parallel GC와 CMS는 카드테이블, G1은 RSet 같은 장치로써) 다른 메모리 풀에서 넘어온 루트를 추적한다.

 

이를테면, 카드 테이블은 올드 세대에서 영 세대를 되참조하는 메모리 블록을 가리킨다. 1바이트가 512바이트의 올드 세대를 나타내므로 올드 세대 1기가바이트는 2메가바이트의 카드 테이블을 탐색해야 한다.

 

20기가바이트 힙의 카드 테이블을 탐색하는 장면을 시뮬레이션하는 단순 벤치마크 코드를 작성해보자.

@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throuput)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(1)
public class SimulateCardTable {
    
    // 올드 세대는 힙의 3/4를 차지하며, 1G 올드 세대에 카드 테이블은 2M 필요.
    private static final int SIZE_FOR_20_GIG_HEAP = 15 * 2 * 1024 * 1024;
    
    private static final byte[] cards = new byte[SIZE_FOR_20_GIG_HEAP];
    
    @Setup
    public static final void setup() {
        final Random r = new Random(System.nanoTime());
        for (int i=0; i<100000; i++) {
            cards[r.nextInt(SIZE_FOR_20_GIG_HEAP)] = 1;
        }
    }
    
    @Benchmark
    public int scanCardTable() {
        int found = 0;
        for (int i=0; i<SIZE_FOR_20_GIG_HEAP; i++) {
            if (cards[i] > 0) {
                found++;
            }
        }
        return found;
    }
}

Parallel GC 튜닝

Parallel GC는 가장 단순한 수집기라서 튜닝 역시 제일 쉽다. 사실, 최소한의 튜닝만으로도 충분하다. 이 수집기의 목표와 트레이드오프는 뚜렷하다.

  - 풀 STW

  - GC 처리율이 높고 계산 비용이 싸다.

  - 부분 수집이 일어날 가능성은 없다.

  - 중단 시간은 힙 크기에 비례하여 늘어난다.

 

이와 같은 특성들이 별 문제가 안되는 애플리케이션에서는 (특히, 힙이 4기가바이트 이하로 작을 경우) Parallel GC가 아주 효과적인 선택이다. 과거에는 다음과 같은 플래그를 적용해 다양한 메모리 풀의 상대적 크기를 조정했던 애플리케이션도 있다.

플래그 작용
-XX:NewRatio=<n> 영 세대/전체 힙 비율
-XX:SurvivorRatio=<n> 서바이버 공간/영 세대 비율
-XX:NewSize=<n> 최소 영 세대 크기
-XX:MaxNewSize=<n> 최대 영 세대 크기
-XX:MinHeapFreeRatio 팽창을 막기 위한 GC 이후 최소 힙 여유 공간 비율(%)
-XX:MaxHeapFreeRatio 수축을 막기 위한 GC 이후 최대 힙 여유 공간 비율(%)
플래그 세트:

-XX:NewRatio=N
-XX:SurvivorRatio=K

영 세대 = 1 / (N + 1) x 힙
올드 세대 = N / (N + 1) x 힙

에덴 = (K - 2) / K x 힙
서바이버1 = 1 / K x 힙
서바이버2 = 1 / K x 힙

 

대부분의 최신 애플리케이션은 사람보다 프로그램이 크기를 알아서 잘 결정하기 때문에 이렇게 명시적으로 크기를 설정하는 일은 삼가는게 좋다. 이런 스위치는 Parallel GC에서 어쩔수 없는 경우, 최후의 수단으로만 사용한다.


CMS 튜닝

CMS는 튜닝이 까다롭기로 소문난 수집기이다. 그도 그럴것이, CMS로 최상의 성능을 얻는 과정에는 여러가지 복잡성과 트레이드오프가 존재한다.

 

'중단 시간은 나쁘다, 고로 동시 마킹 수집기가 좋다'는 단순 선입관에 사로잡힌 개발자가 많다. CMS처럼 중단 시간이 짧은 수집기는 정말로 STW 중단 시간을 단축시켜야 하는 유스케이스에 한해 어쩔수 없을때만 사용해야 한다. 안 그러면 딱히 이렇다할 애플리케이션 성능 향상도 못본째, 팀원 모두 튜닝하기 힘든 수집기를 붙들고 고생하게 된다.

 

CMS 플래그의 가짓수는 실로 방대하다. 어찌됐건 플래그 값을 바꾸면 성능이 좋아지지 않을까 유혹에 빠지기 쉽지만, 안티패턴의 늪에 빠질 우려가 있다.

  - 스위치 만지작거리기

  - 민간 튜닝

  - 숲은 못보고 나무만 본다.

 

분별있는 성능 엔지니어라면 이러한 인지 함정의 희생양이 되지 않아야 한다. 실제로 CMS를 사용하는 대부분의 애플리케이션에서 플래그 값을 바꾼다고 눈에 띄게 성능이 개선되는 경우는 흔치 않다.

 

그럼에도 CMS 성능을 개선하기(또는 수용할 만한 성능을 얻기) 위해 위험을 무릅쓰고 튜닝을 감행해야 할때도 있을 수 있다.

 

CMS 수집이 일어나면 기본적으로 코어 절반은 GC에 할당되므로 애플리케이션 처리율은 그만큼 반토막 난다. 여기서 한가지 유용한 경험 법칙은 CMF 발생 직전의 수집기 상태를 살펴보는 것이다.

 

CMS 수집이 끝나자마자 곧바로 새 CMS 수집이 시작되는 백투백 수집 현상은 동시 수집기가 얼마 못 가 고장날 거라는 신호이다. 애플리케이션의 메모리 할당 속도가 회수 속도를 능가하면서 결국 CMF가 일어나게 된다.

 

백투백 현상이 일어나면 사실상 전체 애플리케이션 실행 처리율은 50%나 떨어지게 된다. 성능 엔지니어는 튜닝할 때 이런 최악의 상황이 발생해도 괜찮은지 일단 고민해보고, 괜찮지 ㅇ낳다면 호스트에 코어 수를 늘리는 해결 방안을 모색해야 한다.

 

CMS 수집 중 GC에 할당된 코어 수를 줄이는 방법도 있다. 물론 그만큼 수집 수행 CPU 시간이 줄어들고 무하 급증시 애플리케이션의 회복력이 떨어지는 위험은 감수해야 한다. 동시 GC 스레드 갯수는 다음 스위치로 조정한다.

-XX:ConcGCThreads=<n>

 

디폴트 설정 상태에서 애플리케이션이 충분히 신속하게 메모리를 회수하지 못하는 경우에 GC 스레드 수를 줄이면 상황이 더욱 악화된다.

 

CMS에서 STW는 두 단계에서 발생한다.

  1. 초기 마킹: GC 루트가 직접 가리키는 내부 노드를 마킹한다.

  2. 재마킹: 카드 테이블을 이용해 조정 작업이 필요한 객체를 식별한다.

 

따라서 모든 애플리케이션 스레드는 CMS가 한번 일어날때마다 반드시 2회 멈추는데, 세이프 포인트에 예민한 저지연 애플리케이션에서는 중요한 영향을 미칠 수 있다.

 

다음 두 플래그를 함께 적용하면 도움이 된다.

-XX:CMSInitiatingOccupancyFraction=<n>
-XX:+UserCMSInitiatingOccupancyOnly

할당률이 오락가락하는 상황에서 겪게될 딜레마를 잘 나타낸 플래그이다.

 

CMSInitiatingOccupancyFraction(CMS 초기 점유율)는 CMS가 언제 수집을 시작할지 설정하는 플래그이다. CMS가 실행되면 영 수집을 통해 올드 영역으로 승격되는 객체들을 수용할 여유 공간이 필요하다.

 

다른 핫스팟 GC도 마찬가지지만, 여유 공간 역시 JVM 자체 수집한 통계치에 따라 그 크기가 조정되지만, 첫번째 CMS를 가동시킬 추정치를 CMSInitiatingOccupancyFraction 플래그에 미리 정해놓는 것이다. 기본적으로 최초의 CMS 풀 GC는 힙이 75% 찼을때 시작한다.

 

UseCMSInitiatingOccupancyOnly 플래그를 함께 설정하면 초기 점유 공간을 동적 크기 조정하는 기능이 꺼진다. 이 플래그는 함부로 켜면 안된다. 그런데 실제로 (매개변수 값 75 이상으로) 여유 공간을 줄일일은 거의 없다.

 

할당률이 심하게 튀는 CMS 애플리케이션이라면 여유 공간을 늘리고(매개변수 값을 줄이고) 능동적 크기 조정 기능을 끄는 전략을 구사한다. CMS의 동시 GC를 더 자주 일으키는 대가를 치르더라도 CMF를 줄여보자는 의미이다.