Garbage Collection Algorithm의 구성

Reference Counting Algorithm

  • 초기의 대표적인 GC 전략
  • 각 Object의 reference count를 관리
  • 특정 Object가 GC대상이 되면, 해당 Object에 의해 참조되는 Object의 count를 연쇄적으로 감소시킴

    장점 & 단점

  • (+) Garbage object 판단이 쉬움
  • (+) GC 대상인지 판단을 위한 STW가 발생하지 않음
  • (+) GC 구동 시점에 제약이 없어 실시간성 가능
  • (-) reference count 관리 비용 발생
  • (-) Circular Linked List와 같은 경우는 영원히 GC 대상이 되지 못함

Mark and Sweep Algorithm

  • Root set에서 시작해 deep dive하며 reference 관계를 추적함
  • Tracing algorithm이라고도 불림
  • Mark 단계에서는 Live object를 찾아 Mark 함. 이 때 Object 내 flag 또는 bitmap table을 이용함.
  • Sweep 단계에서는 Mark되지 않은 객체들을 제거함

    장점 & 단점

  • (+) Reference 관계를 정확히 파악할
  • (+) Reference 관계를 맺을 때 부가적인 Overhead가 없음
  • (-) STW 현상이 발생함
  • (-) Compaction의 부재로 Fragmentation이 발생함

Mark and Compaction Algorithm

  • Fragmentation 문제점과 이로인한 Memory 효율화를 개선하기 위한 알고리즘
  • Mark 단계에서는 Live object를 Mark
  • Compaction 단계에서는 Live object를 연속된 memory 공간에 차례로 쌓아둠
    • Compaction 단계에서 적재되는 Object들의 순서에 따라 Arbitrary(순서 보장x), Linear(인접한 Pointer 순), Sliding(Allocation 순) 방식이 있다.
  • 위 두 단계에서 Handle을 사용하는데, Handle은 Live/Dead를 구분하는 FlagObject가 저장되어있는 위치공간 주소로 구성된다. Mark 단계에서는 각 Obejct에 해당하는 Handle에 Object가 저장된 위치 공간을 기록하고, Compaction 단계에서는 Object 이동 후 Handle을 업데이트 시킨다.

    장점 & 단점

  • (+) Fragmentation을 방지 및 이로인한 Memory 효율성
  • (-) Reference 업데이트 시 Object를 접근하는데 따른 Overhead 존재
  • (-) STW 발생

Copying Algorithm

  • Stop the Copy algorithm이라고도 불린다.
  • Heap을 Active, Inactive영역으로 나누어 사용한다.
  • Active영역에만 Object가 생성되고, Active영역이 가득차면 Live Object만 Inactive영역으로 이동한다.

    장점 & 단점

  • (+) Framgentation을 방지 (Inactive영역으로 이동 시 차례로 적재)
  • (-) STW 발생
  • (-) Copy에 따른 Overhead 발생
  • (-) 공간활용(50% Active / Inactive 50%) 측면에서 비효율적이다.

Generational Algorithm

  • Copying Algorithm에서 Copy에 따른 Overhead 발생한다는 점, 대다수의 Object는 매우 수명이 짧다는 점, 소수의 수명이 긴 Object가 존재하고 이 Long Lived Obejct는 계속해서 복사된다는 점을 개선한 알고리즘
  • Heap 영역을 몇 개의 Sub Heap(Young Generation, Old Generation)으로 나누어 구성
  • Young Generation에서 자주 GC가 발생되고, 여기서 오래 살아남은 객체는 Old Generation으로 이동

    장점 & 단점

  • (+) 각 Sub Heap에 Mark and Sweep, Copying 등의 Algorithm을 가미할 수 있다.

Train Algorithm

  • TBD

    장점 & 단점

  • (+) TBD
  • (-) TBD

Hotspot JVM과 관련된 눈여겨 볼 점

Weak Generational Hypothesis

  • 대부분의 Obejct는 매우 수명이 짧다.
  • Old Object에서 Young Object로의 참조는 매우 적게 일어난다.

Young Generation - Speed

  • Fast Allocation & TLAB(Thread Local Allocation Buffer)
    • TLAB를 통해 Thread마다 할당할 수 있는 범위를 부여
    • Bump the Pointer 테크닉을 활용
    • Memory 할당의 효율성
    • TLAB 내에서는 동기화 이슈가 없음
    • Multi-thread 환경에서는 동기화 이슈가 발생
    • Allocation code 내에 포함되어 있으며 약 10개의 native instruction으로 구성

Old Generation - Efficiency

  • Card Table & Write Barrier
    • Old to Young reference를 구별하기 위해 사용
    • 1 Byte Card로 512 Byte의 Old generation 추적 가능
    • Write Barrier는 Bytecode Interpreter내에 포함되어 있으며 2 native instruction으로 구성

Garbage Collector의 종류

Serial Collector

Young Generation Algo' Old Generation Algo'
Generational Algo' Mark and Compacting
  • Hotspot JVM의 Default GC
  • Young 영역Eden / Survivor1 / Survivor2로 나누고 Eden 영역이 가득 찰 경우 Live Object만 Survivor1영역으로 보냄. 몇번의 반복 후 Survivor1영역이 가득차면 Live Object만 Survivor2로 보냄. 이후 Tenured 객체는 Old 영역으로 보냄
  • 관련 JVM 옵션 (이름만으로 유추 가능하므로 설명은..)
    • -XX:+UseSerialGC
    • -XX:MaxTenuringThreshold=<value>
    • -XX:+PrintTenuringDistribution

Parallel Collector

Young Generation Algo' Old Generation Algo'
Parallel Copy Mark and Compacting
  • 처리량(Throughput)을 개선하기 위한 GC
  • Young Gen'을 병렬처리해 처리량을 늘임. 방식은 Serial Collector의 Young gen과 동일하다.
  • 대용량 Memory, Multi core환경에서 처리량을 늘려 전체적인 수행속도 개선을 꾀함
  • GC를 Multithread로 수행
  • Large Young Generation의 경우 더욱 좋다.
  • Promotion Buffer 사용
    • PLAB(Parallel Local Allocation Buffer)라고도 불린다
    • Thread간 동기화 문제 회피 목적
    • Promotion을 위한 Thread별 공간이다
    • 반대급부로 Fragmentation이 발생 가능한데 이는 GC Thread를 감소시키거나 Old Generation Size를 증가시키면 어느정도 상쇄가능하다..
  • 관련 JVM 옵션 (이름만으로 유추 가능하므로 설명은..)
    • -XX:+UseParallelGC
    • -XX:ParallelGCThreads=<value_default:# of CPU>

Parallel Compacting Collector

Young Generation Algo' Old Generation Algo'
Parallel Copy Parallel Compacting
  • 기존 Parallel Collector에서 Old Gen'의 처리량도 늘이자는 목적
  • Multi CPU에서 유리하다.
  • Old Gen'의 Collection 시간을 줄여준다.
  • Old Generation은 다음과 같은 절차로 수행된다.
    • Mark
      • Multi Thread로 동작
      • STW 발생
      • Live Object를 마킹한 뒤, Generation을 몇 개의 Region으로 나누어놓는다.
    • Summary
      • Single Thread로 동작
      • Work Thread와 동시에 작업
      • 나뉘어진 Region 단위로 작업이 이루어진다.
      • 각 Region의 Density를 평가해 Dense Prefix를 설정한다.
      • Dense Prefix의 왼편에 있는 Region은 GC 대상에서 제외시킨다.
    • Compaction
      • Multi thread로 동작
      • STW 발생
      • Thread들이 각각 Region을 Collecting 한다.
      • Region들을 논리적인 Source / Destination으로 구분하여, Source의 Live Object들을 Destination의 빈 공간으로 이동시킨다.
  • 관련 JVM 옵션 (이름만으로 유추 가능하므로 설명은..)
    • -XX:+UseParallelOldGC
    • -XX:+UseParallelOldGCCompacting : Default True
    • -XX:+UseParallelDensePrefixUpdate : Default True

CMS Collector

Young Generation Algo' Old Generation Algo'
Parallel Copy Concurrent Mark and Sweep
  • 응답시간의 개선에 집중한 GC
  • Old Gen' 정리 시 최대한 STW 시간을 줄이고자 함(Low Pause GC)
  • Fast Elapsed Time에 중점을 둠
  • 빈번하지는 않지만 Old GC가 오래 수행되는 것을 개선
  • Low Latency Collector라고도 한다.
  • 대기시간을 분산해 응답시간을 개선하였다
  • 자원의 여유가 있는 상태에서 GC Pause Time을 줄이고자 할 때 적절하다.
  • Old Generation은 다음과 같은 절차로 수행된다.
    • Initial Mark
      • Single Thread로 동작한다.
      • STW 발생
      • Direct Referenced Object를 빠르게 식별한다.
    • Concurrent Mark
      • Single Thread로 동작한다.
      • Work Thread와 동시에 작업한다.
      • Direct Referenced Object에서 deep dive하며 참조되는 Object를 식별한다.
    • Remark
      • Multi thread로 동작한다.
      • STW가 발생한다.
      • Mark된 Object들을 재방문하여 Mark작업을 확정한다.
    • Concurrent Sweep
      • Single Thread로 동작한다.
      • Work Thread와 동시에 작업한다.
      • Dead Object Reclaim이 발생한다.
  • Compaction이 없어 시간절약할 수 있으나, 연속된 Free Space는 감소한다. 따라서 Allocation 시 FreeList를 탐색해 적절한 크기의 공간을 할당해야 한다. 이는 Fast Allocation의 장점을 없애버리고 Promotion과정에서 Young의 부담이 크다.
  • Concurrent Mark 중 새로운 Allocation이 발생가능한데, 이 Live Object가 바로 Garbage로 되어버리면 Floating Garbage가 발생한다. 이는 다음 GC 작업 때 reclaim되고, 이러한 현상이 빈번하면 Memory 요구량이 많아진다.
  • GC Scheduling
    • Old Gen이 가득차기 전에 작동한다.
    • Old Gen이 가득차기 까지 남은 예상 시간GC 수행에 필요한 시간과 근접해지면 작동한다.
    • Minor GC와 연달아 수행되지 않도록 Remark를 Minor GC 사이에 스케쥴링 한다.
    • 전체 Heap 점유율이 임계값(default:68%) 을 넘기면 작동한다.

Garbage First Collector

  • Train Algorithm의 아이디어를 상당부분 차용
  • Generation의 물리적인 구분이 사라짐
  • Heap을 여러개의 Region으로 나누고, 용도에 따라 Young, Old area로 간주함
  • Realtime에 가까운 Low pause 전략
  • Concurrent Marker가 각 Region 내 Live data의 양을 계산하여 Garbage가 많은 Region부터 GC를 시작한다.
  • G1GC는 다음과 같은 절차로 수행된다.
    • Young GC(Evacuation Pause)
      • Multi Thread로 동작
      • STW 발생
      • Live Object는 Survivor / Old Region으로 이동
    • Concurrent Mark - Marking
      • Single Thread로 동작
      • Work thread와 함께 작업
      • Evacuation Pause 때 넘어온 정보로 Initial Mark 수행
    • Concurrent Mark - Remarking
      • Multi Thread로 동작
      • STW 발생
      • Region당 Live Object를 계산하고 이 때 Empty Region은 즉시 reclaim한다.
    • Old Region Reclaim - Remark
      • Multi thread로 동작
      • Work thread와 함께 작업
      • Live Object가 적은 Region을 골라 몇 개의 Old Region만 collected
  • Free List를 사용하지 않고, TLAB를 사용함으로써 Fast Allocation을 지원한다.
  • Compaction 시 Linear 방식을 사용한다.
  • 관련 JVM 옵션 (이름만으로 유추 가능하므로 설명은..)
    • -XX:+UseG1GC
    • -XX:+UnlockExperimentalVMOptions

'개발언어 > JAVA' 카테고리의 다른 글

CMS 튜닝 (Java Optimization)  (0) 2021.03.30
Parallel GC 튜닝 (Java Optimization)  (0) 2021.03.28
GC 튜닝 (Java Optimization)  (0) 2021.03.22
GC Logging (Java Optimization)  (0) 2021.03.21
JVM miscellaneous (Java Optimization)  (0) 2021.03.21

CMS 튜닝

: CMS는 튜닝이 매우 까다로운 GC이며, CMS 플래그의 가짓수는 방대하므로 다음의 안티 패턴의 늪에 빠지지 않도록 유의하며 사용해야한다.

  • 스위치 만지작거리기
  • 민간 튜닝
  • 숲을 보지 못하고 나무만 보는 형태
  1. 처리율(Throughput) 관련 튜닝을 고려할 때, CMS 수집이 일어나면 코어의 절반은 GC에 할당되므로 애플리케이션의 처리율은 그만큼 절반이 날아간다. 이 때, CMF(Concurrent Mode Failure) 직전의 수집기 상태를 살펴보는 것은 중요한 지표를 가져다 준다. CMS 직후 새로운 CMS가 시작되는 Back-To-Back 현상은 CMS가 얼마 못 가 고장날거라는 신호이다. 애플리케이션 메모리 할당 속도가 회수 속도를 능가하며 CMF가 발생된다.

    이러한 Back-To-Back 현상은 전체 애플리케이션의 실행 처리율을 50%나 떨어뜨린다. 성능 엔지니어는 튜닝 시 이런 상황이 발생해도 괜찮은지 고민해보고, 괜찮지 않다면 코어 수를 늘리는 해결 방안을 모색해야 할 것이다.

    • CMS 중 GC에 할당된 코어 수는 다음의 -XX:ConGCThreads=<n> 플래그로 설정할 수 있다.
    • 디폴트 설정 상태에서 GC 스레드 수를 줄인다면, 부하 급등 시 애플리케이션의 회복력이 떨어져 CMF에 취약해지는 상황을 초래할 수 있음에 유의하자.
  2. CMS에서 STW는 다음의 두 단계,

    • Initial Mark: GC 루트가 직접 가리키는 내부 노드를 마킹한다.
    • Remark: 카드 테이블을 이용해 조정 작업이 필요한 객체를 식별한다.

    에서 이루어 진다. 따라서 CMS가 한 번 일어날 때마다 애플리케이션은 반드시 2번 멈추게 되는데, 세이프포인트에 예민한 저지연 애플리케이션에서는 중요한 영향을 미치게 된다.

    다음의 두 플래그를 동시 적용하자.

    • -XX:CMSInitiatingOccupancyFraction=<n>: CMS 초기 점유율로, CMS가 언제 수집을 시작할지 설정하는 플래그이다. 여기서 지정된 크기 (Default:75%)의 힙 영역이 찼을 때 최초의 CMS가 시작된다.
      (참고) CMS가 실행되면 영 수집을 통해 올드 영역으로 승격되는 객체들을 수용할 공간이 필요하다.
    • -XX:+UseCMSInitiatingOccupancyOnly: 이 플래그를 설정하면 초기 점유 공간을 동적 크기 조정하는 기능이 꺼지므로 위 플래그(CMSInitiatingOccupancyFraction)를 적용하지 않는다면 함부로 켜지 말아야 한다. 할당률이 심하게 튀는 CMS 애플리케이션의 경우 CMSInitiatingOccupancyFraction 매개변수 값을 줄임으로써 여유 공간을 늘이고, 능동적 크기 조정(adaptive sizing)기능을 끄는 전략을 구사함으로써 CMS GC를 자주 일으키더라도 CMF를 줄일 수 있을 것이다.

단편화로 인한 CMF

: CMS가 관리하는 프리 리스트로 인해 힙 단편화로 인한 CMF 발생 정보를 예측하는 방법이 존재한다.
: 다음의 플래그(-XX:PrintFLSStatistics=1)를 추가하면 GC 로그에 다음의 정보가 추가적으로 표기된다.

Total Free Space: xxx
(총 Free 공간)

Max Chunk Size: xxx
(최대 청크 크기)

Number of Blocks: xxx
(블록 갯수)

Av. Block Size: xxx
(평균 블록 크기)

Tree Height: xx
(트리 높이)

: 평균 블록 크기최대 청크 크기를 보고 메모리 청크의 크기 분포를 대략 짐작 할 수 있다.

또한, 크기가 큰 Live 객체Tenured 영역으로 옮길 때 그만한 크기의 청크가 바닥난 경우, GC Promotion이 악화되어 이는 CMF로 이어질 것이다. 그러면 JVM은 Parallel GC로 돌아가 Heap을 압착하고 프리 리스트를 병합하며 STW 시간은 길어진다. 이러한 정보들은 긴 STW이 임박했음을 미리 알수 있고, 센섬같은 툴을 이용하면 CMF에 근접했다는 사실을 자동 감지할 수 있다.

Parallel GC 튜닝

: 이 수집기의 특징은 다음의 항목들이다.

  • Full STW(Stop the world)
  • GC 처리율이 높고 계산 비용이 싸다.
  • 부분 수집이 일어날 가능성은 없다.
  • 중단 시간은 힙 크기에 비례하여 늘어난다.

위와 같은 특성들로 힙의 크기가 4GB 이하인 경우 Parallel GC가 아주 효과적이다.


GC 힙 크기 조정 Flag들은 다음과 같다. (과거에는 주로 이 플래그를 직접 적용하곤 했다고 한다. 요즘은 애플리케이션이 알아서 잘 결정하므로 명시적으로 크기를 설정하는 일은 잘 없다.)

플래그 설명
-XX:NewRation=<n> Young Generation/전체 힙 의 비율
-XX:SurvivorRatio=<n> Survivor / Young의 비율
-XX:NewSize=<n> 최소 Young Generation 크기
-XX:MaxNewSize=<n> 최대 Young Generation 크기
-XX:MinHeapFreeRation 팽창을 막기 위한 GC 이후 최소 힙 여유공간 비율
Default: 40%
힙 여유 공간이 정해진 수치(40%) 이하로 떨어지면 이 수치를 40%로 맞추기 위해 힙의 크기가 팽창된다.
-XX:MaxHeapFreeRatio 수축을 막기 위한 GC 이후 최대 힙 여유 공간 비율
Default: 70%
힙 여유 공간이 70% 이상으로 늘어나면 정해진 수치(70%)로 맞추기 위해 힙이 수축된다.

GC 튜닝

: GC를 튜닝하고자 마음 먹었다면 할당, 중단 민감도, 처리율 추이, 객체 수명을 면밀히 관찰해야한다.

GC 힙 크기 조정 플래그

플래그 설명
-Xms<size> 힙 메모리의 최소 크기 설정
-Xmx<size> 힙 메모리의 최대 크기 설정
-XX:MaxPermSize=<size> 펌젠 메모리의 최대 크기 설정(Java 7 이전)
-XX:MaxMetaspaceSize=<size> 메타스페이스 메모리의 최대 크기를 설정(Java 8 이후)
  • Java7 이전에만 적용되던 펌젠은 Java8 이후로 메타스페이스로 교체되었다.
  • 튜닝 시 GC플래그는 한번에 하나씩만 추가하고, 각 플래그가 무슨 작용을 하는지 숙지해야하며 부수 효과를 일으키는 플래그 조합도 있음에 명시하고 사용하여야 한다.
  • 성능 문제를 일으키는 원인이 GC인지 아닌지를 판단하기 위해서는 vmstat 같은 고수준의 머신 지표를 체크한 뒤,
    • CPU 사용률이 100%에 가까운지?
    • 대부분의 시간(90%이상)이 user space에서 소비되는지?
    • GC 로그가 쌓이고 있다면 현재 GC가 실행중이라는 증거!
    위 항목들을 체크하자. 위 세가지 조건이 다 맞다면 GC가 성능 이슈를 일으키고 있을 가능성이 크다.


할당

  • 할당률 분석은 GC를 튜닝하면 성능이 개선될지 여부를 판단하는 데 반드시 필요한 과정이다.
  • Young generation 수집 이벤트 데이터를 활용하면 할당된 데이터양, 단위 수집 시간을 계산할 수 있고, 일정 시간 동안의 평균 할당률을 산출할 수 있다.
  • 1GB/s 이상의 할당률이 일정시간 지속한다면 GC 튜닝만으로는 해결할 수 없는 성능 문제가 발생한 경우일 확률이 크므로, 애플리케이션 핵심부의 할당 로직을 제거하는 리팩토링을 수행해 메모리 효율을 개선해야 한다.

할당과 관련된 주요 관찰 지점

  1. 굳이 없어도 그만인 사소한 객체 할당
    • 단순히 불필요한 객체를 제거한다.
    • 예: 로그 디버깅 메시지, JSON (un-)serialization 자동 생성코드, ORM 코드
  2. 박싱 비용
    • 불필요한 박싱을 언박싱한다.
  3. 도메인 객체
    • 다음 타입의 도메인 객체는 메모리를 많이 먹을 수 있으므로 참고하자.
    • char[]: 스트링을 구성하는 캐릭터
    • byte[]: 바이너리 데이터
    • double[]: 계산 데이터
    • Map Entry
    • Object[]
    • 내부 자료구조(methodOop, klassOop)
  4. 엄청나게 많은 non-JDK 프레임워크 객체

TLAB(Thread Local Allocation Buffer) 관련

  • TLAB는 스레드 당 크기가 동적으로 조정되며, 일반 객체는 남은 TLAB 공간에 할당된다. 만약 여유 공간이 없다면 스레드는 VM에게 새로운 TLAB를 달라고 요청한다.
  • 객체의 크기가 너무 커서 빈 TLAB에 들어가지 않으면 VM은 Eden에 직접 객체 할당을 시도하고, 이마저도 실패하면 영 GC를 수행한다. 만일 여기서도 실패하면 Tenured 영역에 객체를 직접 할당한다.
    • 덩치가 큰 배열(byte[], char[])가 테뉴어드에 곧바로 할당된다면 Weak generation hypothesis 이론에 의해 GC 성능이 급격히 안좋아질 수 있다.
  • 관련 튜닝 플래그는 다음과 같다.
    옵션 설명
    -XX:PretenureSizeThreshold=<n> 여기서 지정한 크기를 넘어가는 객체는 Young Genreation을 거치지 않고 Old Generation으로 바로 들어간다.
    -XX:MinTLABSize=<n> TLAB의 최소 크기를 설정한다
    -XX:MaxTenuringThreshold=<n> 테뉴어드 영역으로 승격되기 전까지 객체가 통과해야할 GC 횟수다.
    디폴트는 4회이고 1~15사이의 값을 가질 수 있다.
    한계치가 높을수록 장수한 객체가 더 많이 복사된다.
    한계치가 너무 낮으면 단명 객체가 승격되어 Tenured Memory pressure를 가중시킨다. 이는 full GC를 자주 발생시켜 성능을 저하시킨다.
  • 할당이 자주 발생하면 그만큼 Young GC의 발생 주기는 짧아지고 이렇게 짧은 시간 내에 Young GC가 자주 발생하면 Tenured 영역으로 잘못 승격되는 객체가 많아지므로 할당률은 GC의 성능에 큰 영향을 준다.

중단 시간

  • 중단 시간에 대한 정량적인 가치 판단의 기준은 존재하지 않는다. 다만 중단 시간 튜닝 시 참고가 될 만한 휴리스틱이 존재하는데, 다음의 표에서 나타내는 허용 중단시간 기준과 애플리케이션 힙 크기를 연관지어 적합한 초기 GC를 선택하길 바란다.
> 1sec 1sec - 100ms < 100ms -
Parallel Parallel CMS < 2GB
Parallel Parallel/G1 CMS < 4GB
Parallel Parallel/G1 CMS < 10GB
Parallel/G1 Parallel/G1 CMS < 20GB
Parallel/G1 G1 CMS > 20GB

 

CMS를 사용할 경우, 중단 시간을 튜닝하려고 하기 전에 할당률부터 줄이는 것이 좋다. 할당률이 낮아지면 CMS에 가해지는 memory pressure 또한 낮아지면 GC 사이클이 스레드 할당 속도를 따라가기 쉽다.
또한 이는 CMF 발생확률을 감소시켜, 중단시간에 민감한 애플리케이션에서 일거양득의 효과를 준다.

 

수집기 스레드와 GC 루트

  • GC루트 탐색 시간은 다음의 요소에 영향을 받음에 주의하자.
    • 애플리케이션 스레드 갯수
    • 코드 캐시에 쌓인 Compiled code
    • Heap 크기
  • GC루트 탐색은 단일 스레드로 수행하므로, 이 단일 스레드의 탐색 시간이 전체 마킹 시간을 경정짓게 된다. 이는 객체 그래프가 복잡해질수록 더욱 심해지며, 그래프 내부에 객체 체인이 길게 늘어지면서 마킹 시간은 점점 더 길어진다.
  • 애플리케이션 스레드가 너무 많아도 스택 프레임을 더 많이 탐색해야하고 세이프포인트에 도달하는 시간도 길어지는 등 GC 시간에 많은 영향을 끼친다.
  • GC루트의 원천들에는 JNI 프레임, JIT Compiled code 도 존재한다.
  • 위 세가지 요소 중 Heap 영역, 애플리케이션 스레드의 스택 프레임은 비교적 병렬화가 잘 된다.

GC Logging

애플리케이션 실행 중 트래픽이 몰릴 때 사용자의 요청에 응답을 해 주지 못하거나 오류가 발생하는 원인은 다양하지만, 동적 메모리 할당에서 문제가 발생할 소지가 많다. 이러한 동적 메모리 할당은 GC에 의해 자동으로 메모리가 해제되는데 이러한 과정에서 GC가 적절히 동작하는지를 살펴보려면 GC 로그를 확인해 보는 것이 좋다.

GC Logging Options

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

: 위 옵션을 사용할 때 성능적인 관점에서 주의해야할 사항들이다.

  • verbose:gc는 지우고 PrintGCDetails를 사용한다.
  • PrintTenuringDistribution 옵션을 사용함으로써 이 플래그가 제공하는 정보를 사람이 사용하기에는 어렵다. 하지만 중요한 memory pressure, premature promotion 등의 이벤트 계산시 필요한 기초 데이터를 제공해 줍니다.
  • PrintGCDateStampsPrintGCTimeStamps는 둘 다 사용해 주는것이 좋은데, 전자는 GC 이벤트와 로그파일을 연관짓는 용도, 후자는 GC와 다른 내부 JVM이벤트를 연관짓는 용도로 쓸 수 있다.


JVM 모니터링 툴과 GC 로그

VisualVM

  • VisualVM은 NetBeans 플랫폼 기반의 시각화 툴로 JRE에 포함되어 있다.(Java 9 부터는 배포 파일에서 빠져있으므로 다음의 경로에서 내려받아 디폴트 경로에 visualvm 바이너리 경로를 추가해 주어야 한다.)
  • JMX(Java Management eXtension) 이 적용된 기술이라 볼 수 있는데, 이는 JVM과 그 위에서 동작하는 애플리케이션을 제어하고 모니터링하는 범용 툴로 클라이언트 애플리케이션처럼 메소드를 호출하고 매개변수를 바꿀 수 있다.
  • VisualVM은 attach mechanism을 이용해 실행 프로세스를 실시간 모니터링한다. 원격 프로세스에 연결하려면 원격지에 JMX를 통해 인바운드 접속이 허용되어야 한다(== 원격 호스트에 jstatd가 실행되어야 한다.)
  • 이 툴을 이용하면 CPU, 힙 사용량, 로드/언로드 된 클래스 갯수, 실행중인 스레드 갯수, 스레드별 상태와 짧은 변화추이(with 스레드 덤프), CPU & 메모리 사용률에 대한 단순 샘플링 결과를 볼 수 있다.


JMX vs GC 로그

  • GC 로그 데이터는 실제 가비지 수집 이벤트가 발생해 쌓이지만, JMX는 데이터를 샘플링하여 얻는다.
  • GC 로그 데이터는 캡쳐 영향도가 거의 없으나, JMX는 프록시 및 원격 메소드 호출(Remote Method Invocation) 과정에서 비용이 발생한다.
  • GC 로그 데이터는 자바 메모리 관리에 연관된 성능 데이터가 50가지 이상이지만, JMX는 10가지도 안된다.
    JMX는 스트리밍된 데이터를 즉시 제공하지만 위와 같은 이유에서 GC 로그가 성능 관점에서 조금 더 좋아보일 수 있다. 추가로 요즘은 jClarity 센섬같은 툴도 GC 로그 데이터를 스트리밍하는 API를 제공하므로 큰 차이는 없다.


JMX의 단점

  • JMX를 이용해 애플리케이션을 모니터링하는 클라이언트는 대부분 런타임을 샘플링해 현재 상태를 업데이트 한다. 클라이언트는 데이터를 계속 넘겨받기 위해 런타임에있는 JMX 빈을 폴링한다. 문제는 GC가 언제 실행될지 클라이언트는 알 수 없으므로, GC 전후의 메모리 상태 역시 알 수 없으며 따라서 GC 데이터를 깊이있게 정확히 분석하기 힘들다.
  • JMXConnector를 구현한 코드는 내부적으로 RMI에 의존하는데, RMI기반 통신은 방화벽에 포트를 열어야하므로 secondary socket connection이 발생하며, 프록시 객체를 이용해 remove() 메소드 호출을 대행하고 Java finalization에 의존한다는 고질적인 문제점이 존재한다. (다음에 추가적으로 더 알아보자..)
  • 접속을 해제하는 작업은 finalize에 의존하는데 이는 GC를 돌려 객체를 회수해야하므로 종료화가 GC에 미치는 영향을 고려하지 않을 수 없다.
  • RMI를 사용하는 애플리케이션은 기본 1시간에 한번씩 full GC가 발생하는데, JMX를 사용하는 순간부터 추가적인 부하는 피할수 없을 것이다.

About JVM

  • JVM은 스택 기반의 해석 머신으로, 실행 결과를 스택에 쌓아두며 스택의 값들을 가져와 계산한다.
  • Java Compiler(javac)는 .java파일을 읽어 바이트코드로 구성된 .class 파일을 생성하는데, 이는 특정 아키텍쳐에 종속적이지 않은 중간 표현형(Intermediate Representation)이다. JVM은 이 바이트코드를 읽어 실제 동작을 수행한다.

자바 클래스 로딩 메커니즘

: 자바 프로세스가 초기화되면 연결된 클래스 로더가 차례차례 동작한다.

  1. 첫 번째로, 부트스트랩 클래스가 자바 런타임 코어 클래스를 로드한다. (JAVA 8 이전까지는 rt.jar에서 가져오지만, JAVA 9 이후부터는 런타임이 모듈화되고 클래스로딩 개념이 많이 바뀌었다.)
  2. 부트스트랩 클래스 로더는 다른 클래스로더가 나머지 시스템에 필요한 클래스를 로드할 수 있게 최소한의 필수 클래스(java.lang.Object 등)만 로드한다.
  3. 다음으로, 부트스트랩클래스로더를 부모로 설정하는 확장 클래스로더가 생기는데 이는 특정 OS나 플랫폼에 native code를 제공하거나 기본환경을 오버라이드 할 수 있다.
  4. 마지막으로, 애플리케이션 클래스로더가 생성되고 지정한 클래스패스에 위치한 유저 클래스를 로드한다.

이 과정에서 처음보는 새로운 클래스를 dependency에 로드하고, 클래스를 찾지못하면 자신의 부모 클래스로더에게 넘기는데, 이렇게 거슬러올라 부트스트랩도 찾지 못하면 ClassNotFoundException 예외가 발생한다.

About Hotspot & JIT(Just In Time)

Zero-cost abstraction(Zero-overhead principle)
C++과 같은 언어는 사용하지 않는 것에는 대가를 치르지 않는다.
다시말해 개발자가 작성한 언어보다 더 나은 코드를 컴퓨터는 건네주지않는다.
따라서, 컴퓨터와 OS가 실제로 어떻게 작동해야하는지 개발자가 아주 세세한 저수준가지 일러주어야 한다.

: Java는 개발자의 생산성에 무게를 두고 Zero-overhead abstraction에 반대하는 방향으로 발전했다. 따라서 C/C++과 같이 AOT(Ahead Of Time) 컴파일 방식이 아닌, 인터프리터를 이용해 바이트코드를 실행한다. 이와 더불어 자바는 JIT(Just In Time) 컴파일을 이용해, 프로그램 단위(메소드, 루프)로 바이트코드에서 네이티브 코드로 컴파일한다.
핫스팟은 인터프리트 모드로 실행하는 동안 애플리케이션을 모니터링하면서 가장 자주 실행되는 코드 파트를 발견해 JIT 컴파일을 수행한다(어느 threshold를 넘어가면 profiler가 특정 코드 섹션을 컴파일 & 최적화). 이는 컴파일러가 해석단계에서 추집한 추적 정보를 근거로 최적화를 결정하므로 상황별로 핫스팟이 올바른 방향으로 최적화 할 수 있게 만들어준다는 점이 큰 장점이다. 이러한 PGO(Profile Guided Optimization) 를 사용하면 AOT에서는 불가능했던 동적 인라이닝, 가상 호출, 특정 CPU에 최적화 등으로 성능을 개선할 수 있다. 하지만 바이트 코드를 네이티브 코드로 컴파일하는 비용은 런타임에 지불됨에 유의해야 한다.

다음으로 JIT 컴파일 로깅하는 방법을 살펴보자.
java -XX:+PrintCompilation ${JavaProject}을 통해 컴파일 이벤트 로그를 확인할 수 있다. 추가적으로 -XX:+LogCompilation-XX:+UnlockDiagnosticVMOptions로 핫스팟 JIT 컴파일러가 어떤 결정을 내렸는지 자세한 정보를 얻을 수 있다.

핫스팟 JVM에는 C1, C2라는 두 JIT 컴파일러가 있는데 각각 클라이언트 컴파일러, 서버 컴파일러라고 불린다. C1, C2 모두 메소드 호출 횟수에 따라 컴파일이 트리거링 되는데, 이 한계치에 이르면 VM이 해당 메소드를 컴파일 큐에 넣고 컴파일이 진행된다.
컴파일 프로세스는 가장 먼저 메소드의 내부 표현형을 생성한 뒤, 인터프리티드 단계에서 수집한 프로파일링 정보를 바탕으로 최적화 로직을 적용한다. 여기서, C1과 C2가 생성하는 내부 표현형은 전혀 다른데, C1은 C2보다 컴파일 시간이 짧고 더 단순하계 설계된 까닭에 풀 최적화는 되지 않는다.
자바 6부터는 단계별 컴파일(tierd compile) 을 지원하는데, 이는 인터프리티드 모드로 실행되다 단순한 C1 컴파일 형식으로 바뀌고, 이를 C2가 고급 최적화 수행하는 방식으로 단계를 바꾸는 것을 말한다.

JIT compiled code코드 캐시 라는 메모리 영역에 저장되는데 JVM 시작 시 코드 캐시는 설정된 값으로 고정되어 확장이 불가능하다. 따라서 코드 캐시 영역이 가득차면 더이상 JIT 컴파일은 수행되지 않으며, 컴파일되지 않은 코드는 인터프리터에서만 실행된다. 네이티브 메소드가 새로 저장되면 compiled code를 담기에 크기가 충분한 블록을 free list에서 찾는데 만약 그런 블록이 없으면 여유 공간이 충분한 코드 캐시 사정에 따라 unallocated region에서 새 블록을 생성한다. 또한 추측정 최적화를 적용한 결과 틀린것으로 판단되어 역 최적화(deoptimization)될 때, 다른 컴파일 버전으로 교체(단계별 컴파일)되었을 때, 메소드를 지닌 클래스가 언로딩 될 대 네이티브 코드는 코드 캐시에서 제거된다. 이러한 코드 캐시의 최대 크기는 -XX:ReservedCodeCacheSize=<n>으로 설정할 수 있다.

JIT 튜닝 방법

  1. PrintCompilation 옵션을 이용해 애플리케이션을 실행한다.
  2. 어떤 메소드가 컴파일되었는지 로그를 확인한다.
  3. ReservedCodeCacheSize를 통해 코드 캐시를 늘린다.
  4. 애플리케이션을 재 실행한다.
  5. 확장된 캐시에서 컴파일드 메소드를 살펴본다.

JVM의 메모리 관리

: C/C++에서는 개발자가 직접 동적 할당된 메모리를 관리해주어야 한다. 하지만 JAVA에서는 GC(Garbage Collector)를 이용해 힙 메모리를 자동으로 관리한다.

이 GC는 non-deterministic한 방식이므로 어느 순간에 GC가 실행되는지 정확히 알 수는 없다. 일반적으로 GC가 실행되면 애플리케이션은 모두 실행 중지(STW: Stop The World)되므로, 이 중단시간은 최대 짧도록 GC 알고리즘이 발전해 왔다. GC의 알고리즘에 대해서는 다음 게시물을 통해 더욱 상세히 알아보자.

문자열 리터럴이란 무엇일까?

다음은 문자열 리터럴을 설명해주는 좋은 예시이다.

std::cout << "Hello World" << std::endl;

위 문장에서 "Hello World" 가 문자열 리터럴에 해당한다.

본 질문에 앞서 다음의 코드를 살펴보자.

#include <iostream>

using namespace std;

int main()
{
  char* ptr1 = "char array literal"; // C++14 or below 
  char* ptr2 = "char array literal";
  char* ptr3 = "char array literal";
  char* ptr4 = "char array literal";

  printf("%p %p %p %p", ptr1, ptr2, ptr3, ptr4);

  return 0;
}

위 코드의 실행결과는 다음과 같다. `0x4007e4 0x4007e4 0x4007e4 0x4007e4 ` 메모리 주소의 공간은 실행시 계속적으로 변할 수 있으니 단순히 동일한 값만을 출력한다는 것만 기억하자. 그렇다면 왜 동일한 문자열 리터럴은 동일한 주소값을 가질까?
문자열 리터럴은 **ROM**공간에 저장된다. 이러한 이유는 컴파일러가 메모리를 절약하기 위함이다. 이러한 목적에 따라 위 코드에서 `ptr1`, `ptr2`, `ptr3`, `ptr4`는 같은 주소공간을 가리키게 된 것이다. 이러한 방식을 리터럴 풀링(literal pooling)이라 부른다.

그렇다면 다음의 코드는 어떤 결과를 발생할까?

#include

using namespace std;

int main()  
{  
char\* ptr = "char array literal";  
ptr\[0\] = 'L';

cout << ptr << endl;

return 0;  
}

당연하게도 위 코드는 실행되지 않고 컴파일 에러를 발생시킨다. ptr은 ROM 공간에 저장되어 있는 문자열 리터럴이므로 문자열을 수정하거나 이어붙이거나 할 수 없다. 그렇다면 어떻게 해야 사용자가 수정할 수 있는 문자열 배열을 만들 수 있을까? 정답은 아래의 코드와 같다.

#include

using namespace std;

int main()  
{  
char\* ptr = "char array literal";

char arr\[\] = "char array";  
arr\[0\] = 'C';

cout << ptr << endl;  
cout << arr << endl;

return 0;  
}

위 코드에서 arr은 사용자가 초기화한 문자열의 크기만큼 자동으로 공간을 할당해 문자열을 담아준다.
이처럼 char* 과 char []의 미묘한 차이점을 잘 알고 실수하는 일이 없도록 해야할 것이다.

'개발언어 > C++' 카테고리의 다른 글

String class 멤버함수 c_str()에서의 주의점  (0) 2020.08.10
switch-case 문에서의 local variable 선언  (0) 2020.08.08
decltype(auto) vs auto  (0) 2020.08.04

C++에서 String class는 C에서의 char_를 손쉽게 조작할 수 있도록 도와주는 STL이다.
다음은 자주 쓰이는 String class의 멤버함수들이다.

함수 명 기능
length 문자열의 길이를 리턴한다.
clear empty 문자열로 초기화한다.
empty empty 문자열인지 확인하여 true/false를 리턴한다.
c_str C string 형식으로 반환한다.
조금 더 많은 정보를 원한다면 다음의 링크를 통해 상세한 내용을 살펴보기 바란다.
위의 설명에서와 같이 c_str은 String class에서 담고있는 문자열을 C에서의 const char* 타입으로 변환하여 반환해주는 편리한 멤버함수이다.
하지만 이 함수를 이용함에 있어 한 가지 주의를 기울여야 한다.
다음의 코드를 통해 이를 살펴보자.
#include <string>

using namespace std;

void printString(const char* str)
{
    cout << str << endl;
}

string makeString()
{
    return string("Temporary Object");
}

int main(void)
{
    string str_loc("Local variable");
    cout << str.c_str() << endl;

    const char* char_loc = str_loc.c_str();
    cout << char_loc << endl;

    str_loc = string("Local variable changed");
    cout << char_loc << endl;

    cout << makeString().c_str() << endl;

    const char* char_temp = makeString().c_str();
    cout << char_temp << endl;

    cout << "Test Done" << endl;
}

위의 코드를 실행시켜 보면 결과는 다음과 같다.

Local variable
Local variable

Temporary Object

Test Done

이는 아마 모든 문자열들이 정상적으로 출력되리라는 독자들의 예상과는 사뭇 다른 결과일 것이다. 이러한 결과가 나타난 이유는 다음과 같다.
String class의 멤버함수인 c_str()은 원본 객체가 담고있는 문자열에 대해 const char* 타입을 리턴한다. 하지만 이 값은 원본 객체인 **string이 메모리를 재할당**받거나 **string 객체가 사라지는 순간** 무효화 된다. 이러한 이유로 위에서 3번째(`Local variable changed`)와 5번째(`Temporary Object`) 출력문이 정상적으로 출력되지 않은 것이다.


따라서 우리는 c_str()을 사용할 때 원본 객체의 값이 변하진 않았는지, 원본 객체가 사라지지 않았는지에 유의하여 사용해야할 것이다. 만약 이러한 고려를 하고싶지 않다면 strdup()를 쓰는 것도 한 가지 방법일 것이다.

'개발언어 > C++' 카테고리의 다른 글

문자열 리터럴: char* vs char []  (0) 2020.08.25
switch-case 문에서의 local variable 선언  (0) 2020.08.08
decltype(auto) vs auto  (0) 2020.08.04

+ Recent posts