배경

지난번 가비지 컬렉션의 개념과 힙 영역에서의 가비지 컬렉션 프로세스에 대해 알아봤다.

beststar-1.tistory.com/15

 

 

JVM(Java Virtual Machine) - 5. 실행 엔진 - 가비지 컬렉터(Garbage Collector) - 가비지 개념과 가비지 컬렉션

목차 배경 지난번 JIT 컴파일러가 무엇 때문에 등장했고 어떻게 구동되고 어떻게 사용하는지에 대해 알아봤다. beststar-1.tistory.com/14 JVM(Java Virtual Machine) - 4. 실행 엔진 - JIT 컴파일러 목차 배경 지..

beststar-1.tistory.com

 

그런데, 지난번에도 언급했듯 가비지 컬렉션 알고리즘이 Mark and Sweep만 있는 것은 아니다.

많은 블로그 포스팅에서 이것만 언급하고 말아서 마치 이것이 기본 동작이고 이것만 있는 것처럼 오해를 일으킬 수 있다고 생각했다. 그래서 가비지 컬렉션 알고리즘에 대해 공부한 내용을 별도의 글로 정리하기로 했다.

https://www.youtube.com/watch?v=E1M3hNlhQCg&ab_channel=OracleDevelopers


과정

Young Generation

Bump-the-pointer

Eden 영역에 마지막으로 할당된 객체를 마킹하고, 그 객체 다음에 객체를 할당하는 기술을 말한다.

마지막 객체의 위치와 크기만 알면 새로운 객체를 할당할 수 있을지 알 수 있다.

객체가 할당된다면 그 객체는 Eden 영역의 맨 위에 있게 되고 그 객체가 새로운 마지막 객체로 마킹된다.

그래서 항상 마지막 객체만 알고 있으면 되기 때문에 속도가 빠르다.

 

TLABs(Thread-Local Allocation Buffers)

하지만 Bump-the-pointer가 항상 빠른 건 아니다. 문제는 멀티스레드 환경에서다.

여러 스레드에 의해 Eden 영역에 객체를 할당하기 위해서 각각의 스레드는 마지막 객체를 마킹하기 위해 Lock을 걸고 그에 따라 속도가 느려진다.

그래서 TLABs는 멀티 스레드 환경에서 스레드 하나에 메모리 영역 하나를 갖게 된다. 따라서 각각 마킹된 객체를 관리하기 때문에 Lock을 걱정할 필요가 없다.

 

아래 그림의 윗부분은 객체 T1~T5를 할당할 때 싱글 스레드라서 Lock이 걸리는 예시이고, 아래 부분은 멀티 스레드에서 TLAB를 사용해 대부분 스레드 간 간섭 없이 진행하는 예시이다. 

물론 TLABs도 최초 할당 때와 할당된 TLAB이 부족하여 새로이 할당을 받을 때에는 동기화 이슈가 발생한다. 하지만 객체 Allocation 작업 횟수에 비하면 동기화 이슈가 대폭 줄기 때문에 Allocation에 걸리는 시간은 상당히 줄어든다.

 

https://coding-start.tistory.com/206

💡 그러나 많은 수의 스레드가 영역을 할당받은 채 사용되지 않거나 영역 내의 자투리 공간이 메모리 단편화(Fragmentation)의 원인이 될 수 있다. 이 경우에는 GC 스레드 개별 사이즈를 감소시키거나 Old Generation 전체 사이즈를 늘리는 방법으로 문제를 회피할 수 있다.

 

 

Old Generation

Serial GC

단어 뜻 그대로 순차적인 GC라는 의미로, Mark-Sweep-Compaction 알고리즘이 한 번에 하나씩만 동작한다. 순차적으로 동작할 수밖에 없는 이유는 GC를 처리하는 스레드가 하나이기 때문이다. 메모리나 CPU 코어 리소스가 부족할 때 사용할 수 있을 것이다. 가장 오래된 GC이며 Stop-The-World 시간이 너무 길기에 요즘에는 사용되지 않는다.

 

💡 Mark-Sweep-Compaction

  • 사용되는 객체(Reachable Object)를 표시하는 작업 (Mark) → '사용되는 객체'의 기준은 지난 글을 참고 바란다.
  • 사용되지 않는 객체를 제거하는 작업 (Sweep)
  • 파편화(Fragmentation)된 메모리 영역을 앞에서부터 채워나가는 작업 (Compaction)

https://mirinae312.github.io/develop/2018/06/04/jvm_gc.html

 

Parallel GC

Serial GC을 사용하던 시절보다 CPU 코어와 스레드의 개수가 증가하고 성능이 향상된 때에는 Serial GC를 멀티스레드로 실행하는 게 성능에 더 좋을 것이다. Parallel GC는 Minor GC를 처리하는 스레드를 여러 개로 늘려 좀 더 빠른 동작이 가능하게 한 방식이다. 처리시간보다는 처리량에 초점을 맞춘 GC이기 때문에 Throughput GC라고도 부른다.

https://mirinae312.github.io/develop/2018/06/04/jvm_gc.html

💡 Stop the World

지난번에도 언급했지만 사용되는 객체 Marking 과정에서 전체 스레드가 멈추는데, 이것을 오라클 공식문서에서는 'Stop the World'라고 표현하고 있다. 객체 탐색과 전체 스레드 멈추는 현상을 비유한 것이다. 

 

 

Parallel Old GC (with Mark-Summary-Compaction)

Parallel GC와 비슷하나 Mark-Sweep-Compaction 알고리즘 대신 Mark-Summary-Compaction 알고리즘을 사용한다.

Major GC 수행 시 멀티 스레드로 동작한다.

  • Old Generation을 Region이라는 논리적인 단위로 균일하게 나누고, 각 스레드들은 Region 별로 사용되는 객체를 표시 (Mark)
  • Sweep 작업 중에 사용되는 객체를 식별하는 작업이 추가됨. Region 단위로 작업을 수행하며 각 Region마다 사용되는 객체의 밀도(Density)를 평가 후 Dense prefix를 설정. 이후 Compaction 대상이 되는 Region의 첫 번째로 사용되는 객체 주소를 찾아 저장하여 GC 수행 범위를 정함 (Summary)
  • 모든 스레드가 각 Region을 할당받아 Compaction을 수행

https://blog.voidmainvoid.net/320 의 이미지를 개선

 

CMS(Concurrent Mark Sweep) GC

앞서 설명한 GC에서 발전한 방식이며, GC 과정에서 발생하는 Stop the World 처리시간을 최소화하는데 초점을 맞춘 방식이다. Low Latency GC라고도 부른다.

Stop the World 처리시간에 초점을 맞추다 보니 GC 대상을 최대한 자세히 파악할 수밖에 없다. 그 과정이 복잡하기 때문에 다른 GC 대비 CPU 사용량이 높다.

 

Initial Mark → Concurrent Mark → Remark → Concurrent Sweep 과정을 거친다.

  • Initial Mark
    GC 과정에서 살아남은 객체를 탐색하는 시작 객체(GC Root)에서 참조 Tree상 가까운 객체만 1차적으로 찾아가며 객체가 GC 대상(참조가 끊긴 객체)인지를 판단한다. 이때 Stop the World 현상이 발생하지만 탐색 깊이가 얕아 처리시간은 매우 짧다.
  • Concurrent Mark
    Stop the World 현상 없이 진행되며, Initial Mark 단계에서 GC 대상으로 판별된 객체들이 참조하는 다른 객체들을 따라가며 GC 대상인지 추가적으로 확인한다.
  • Remark
    Concurrent Mark 단계의 결과를 검증한다. Concurrent Mark 단계에서 GC 대상으로 추가 확인되거나 참조가 제거되었는지 등의 확인을 한다. 이 검증과정은 Stop the World를 유발하기 때문에 그 처리시간을 최대한 줄이기 위해 멀티 스레드로 검증 작업을 수행한다.
  • Concurrent Sweep
    Stop the World 없이 Remark 단계에서 검증 완료된 GC 객체들을 메모리에서 제거한다.

https://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf

 

참고로 CMS GC는 연속적인 메모리 할당이 어려울 정도로 메모리 단편화가 심한 경우에만 Compaction 과정을 수행한다. 때문에 Stop-The-World 시간이 다른 GC보다 더 길게 걸릴 수도 있다.

 

G1 (Garbage First) GC

하드웨어가 발전되어 JVM을 가동하는 메모리의 크기도 점점 커져가는데, 이전까지의 GC들은 큰 용량의 메모리에 적합하지 않다. Root set부터 순차적으로 탐색하기 때문에 용량이 클수록 탐색 시간이 길어지기 때문이다.

 

💡 Root set

지난번에도 언급했지만 힙에 있는 객체들에 대한 참조는 다음 4가지 종류 중 하나인데,

  • 힙 내의 다른 객체에 의한 참조
  • JVM Stack 내의 Local Variable Section과 Operand Stack에서의 참조(Java 메서드 내에서 실행하는 지역 변수 또는 파라미터에 의한 참조)
  • JVM Method Area의 Constant Pool에서 참조 (정적 변수에 의한 참조)
  • 아직 메모리에 남아 있는 Native Method로 넘겨진 Object에서 참조 (JNI에 의해 생성된 객체에 대한 참조)

이들 중 힙 내의 다른 객체에 의한 참조를 제외한 나머지 3개가 Root set이다.

 

G1 GC는 이런 점을 개선하여 큰 Heap 메모리에서 짧은 GC 시간을 보장하는데 그 목적을 둔다.
G1 GC는 앞서 살펴본 Eden, Survivor, Old 영역이 존재하지만, 고정된 크기로 고정된 위치에 존재하지 않는다.

전체 Heap 영역을 Region이라는 특정한 크기로 나눠서 각 Region의 상태에 따라 역할(Eden, Survivor, Old)이 동적으로 부여된다. 보통 2048개 의 Region으로 나뉠 수 있으며 옵션을 통해 1MB~32MB 사이로 지정할 수 있다.

Java 9부터 기본 방식으로 채택됐다.

 

https://velog.io/@hygoogi/자바-GC에-대해서-#g1-gc-garbage-first

  • Humongous : Region 크기의 50%를 초과하는 큰 객체를 저장하기 위한 공간이며, 이 Region에서는 GC 동작이 최적으로 동작하지 않는다.
  • Available/Unused : 아직 사용되지 않은 Region을 의미한다.

 

Young GC
(
오라클 G1 GC 공식문서에서는 Minor GC라고 표현하지 않았다.)


Stop the World 현상이 발생하며 그 시간을 최대한 줄이기 위해 멀티스레드로 한다.

Young GC 각 Region 중 GC대상 객체가 가장 많은 Region(Eden 또는 Survivor 역할)에서 수행되며,

이 Region에서 살아남은 객체를 다른 Region(Survivor 역할)으로 옮긴 후,

비워진 Region을 사용 가능한 Region으로 돌리는 형태로 동작한다.

 

 

Full GC


Initial Mark → Root Region Scan Concurrent Mark Remark Cleanup Copy
 단계를 거치게 된다.

이를 Snapshot-At-The-Beginning (SATB) 알고리즘이라 한다. 

  • Initial Mark
    Old Region에 존재하는 객체들이 참조하는 Survivor Region을 찾는다. 이 과정에서 Stop the World 현상이 발생하게 된다.
  • Root Region Scan
    Initial Mark에서 찾은 Survivor Region에 대한 GC 대상 객체 스캔 작업을 진행한다.
  • Concurrent Mark
    전체 힙의 Region에 대해 스캔 작업을 진행하며, GC 대상 객체가 발견되지 않은 Region 은 이후 단계를 처리하는데 제외되도록 한다.
  • Remark
    애플리케이션을 멈추고(Stop the World) 최종적으로 GC 대상에서 제외될 객체(즉, 사용될 객체)를 식별해낸다. 여기까지 진행하면 사용되는 객체의 Marking이 완료된다.
  • Cleanup
    애플리케이션을 멈추고(Stop the World) 사용되는 객체가 가장 적은 Region에 대해서 사용되지 않는 객체 제거를 수행한다. 이후 Stop the World를 끝내고 앞선 GC 과정에서 완전히 비워진 Region을 별도의 리스트로 관리하고 추가해 재사용될 수 있게 한다.
  • Copy
    GC 대상 Region이었지만 Cleanup 과정에서 완전히 비워지지 않은 Region의 사용되는 객체들을 새로운(Available/Unused) Region에 복사하여 Compaction 작업을 수행한다.

https://mirinae312.github.io/develop/2018/06/04/jvm_gc.html

Z GC

Java 11부터 추가된 GC이며 Scalable 하고 Low Latency를 가진 GC이다.
다음의 목표를 충족하기 위해 설계됐다.

  • Stop the World 처리시간이 최대 10ms를 초과하지 않음
  • 힙 크기가 증가해도 Stop the World 처리시간이 증가하지 않음
  • 8MB~16TB에 이르는 스펙트럼의 힙 처리가 가능
  • G1 GC 보다 애플리케이션 처리량이 15% 이상 떨어지지 않을 것

Z GC는 ZPages라는 개념을 사용한다. G1 GC의 Region과 비슷한 영역의 개념이지만 Region은 고정된 크기인 것에 반해 ZPages는 크기가 2MB의 배수로 동적으로 생성 및 삭제될 수 있는 것이다.

https://hub.packtpub.com/getting-started-with-z-garbage-collectorzgc-in-java-11-tutorial/

 

Z GC는 목표한 속도와 안정성을 위해 Colored pointers Load barriers라는 주요한 알고리즘 2가지를 사용한다.

 

 

Colored pointers


객체를 가리키는 변수의 포인터에서 64bit 메모리를 활용해 Mark를 진행하고 객체 상태 값을 저장해 사용하는 방식이다.

때문에 64bit 운영체제에서만 작동하며 JDK11, 12까지는 4TB의 메모리만 지원하였고 JDK13에서 16TB까지 메모리 확대가 이뤄졌다.

https://hub.packtpub.com/getting-started-with-z-garbage-collectorzgc-in-java-11-tutorial/

위 이미지처럼 42bit는 객체를 가리키는 주소 값으로 사용했고,

나머지 22bit 중 4bit를 각각 Finalizable, Remapped, Marked 1, Marked 0로 나눠서 사용했다.

  • Finalizable : finalizer을 통해서만 참조되는 객체로 Garbage로 보면 된다.
  • Remapped : 재배치 여부를 판단하는 Mark이다.
  • Marked 1, 0 : 사용되는 객체를 판단하는 Mark이다.

Load barriers


스레드에서 참조 객체를 Load 할 때 실행되는 코드를 말한다.
Z GC는 재배치에 대해서 Stop the World 없이 동시적으로 재배치를 실행하기 때문에 참조를 수정해야 하는 일이 일어나게 되는데, 이때 Load barriers가 아래의 순서대로 Remap Mark bit와 Relocation Set을 확인하며 참조와 Mark를 업데이트하고 올바른 참조값으로 수정해준다.

https://renuevo.github.io/java/garbage-collection/#heap-메모리-구조-jdk9부터-jdk13까지

 

ZGC의 동작 방식

 

ZGC는 총 3번의 Pause만이 일어난다.

  1. Pause Mark Start : ZGC의 Root에서 가리키는 객체에 Marking 한다.
  2. Concurrent Mark/Remap : 객체의 참조를 탐색하며 모든 객체에 Marking 한다.
  3. Pause Mark End : 새롭게 들어온 객체들의 대해서 Marking 한다.
  4. Concurrent Pereare for Reloc : 재배치하려는 영역을 찾아 Relocation Set에 배치한다
  5. Pause Relocate Start : 모든 루트 참조의 재배치를 진행하고 업데이트한다.
  6. Concurrent Relocate : 이후 Load barriers를 사용하여 모든 객체를 재배치 및 참조를 수정한다.

 

 

 

지금까지의 내용을 간략화하면 아래와 같다.

가비지 컬렉션 종류는 하드웨어의 성능에 따라 싱글 스레드를 사용하는 Serial GC부터 지속적으로 다양하게 발전하여 Parallel GC, CMS GC, G1 GC, Z GC 등이 등장했으며
그 안에는 Stop the World 처리시간을 줄이거나 스레드의 GC 처리량을 늘리는 등 각자의 목적을 가진 Mark-Sweep-Compact, Mark-Summary-Compaction 등과 같은 가비지 컬렉션 알고리즘이 존재한다.

 


결과

가비지 컬렉터에 대해 아래 사항들을 공부하고 정리했다.

  • 가비지 컬렉션 종류
  • 가비지 컬렉션 알고리즘

성과

  • 한 번에 모든 가비지 컬렉션 종류와 알고리즘을 이해할 순 없었지만, 공부를 하면서 가비지 컬렉션 측면에서 애플리케이션의 성능을 위해 다방면으로 지속적인 노력을 하고 있다는 것을 알았다.
  • 가비지 컬렉션을 무작정 쓸 것이 아니라, 애플리케이션의 특성에 따라 면밀히 검토하고 적용할 줄 안다면 훨씬 훌륭한 개발자가 될 수 있다는 건 확실히 알게 됐다. 실제로 인스타그램에서는 가비지 컬렉션을 없애서 8GB의 서버 메모리를 절약하고 10%의 성능 향상을 이뤄냈는데, 흥미가 있다면 이 글을 읽어보시는 걸 추천한다.
  • Red Hat사의 Roman Kennke가 오라클 행사에서 발표한 Shenandoah GC를 보니 Stop the World 처리시간과 런타임 오버헤드의 trade-off관계를 중심으로 선택하는 자료가 있었다. 무슨 GC가 나오든 상관없이 애플리케이션 특성과 성능을 검토하는 시각과 능력을 기르는 게 고급 개발자로 가기 위한 숙제인 것 같다.

https://www.youtube.com/watch?v=E1M3hNlhQCg&ab_channel=OracleDevelopers

 


본 글은 JVM 글에 있던 내용을 분리해둔 것이며,

추후 자바 성능 튜닝 관련 자료를 보며 공부할 때 내용이 추가될 예정이다.

여러 자료를 취합해보니 확실히 가비지 컬렉션을 다룰 일이 있다면 책 자바 성능 튜닝으로 이어서 공부하면 좋을 것 같다.

 


출처

Bump-the-pointer, TLABs

www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf

 

가비지 컬렉터 종류와 알고리즘

mirinae312.github.io/develop/2018/06/04/jvm_gc.html

 

Serial GC, Parallel GC, CMS GC, G1 GC

www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html

www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf

 

G1 GC

velog.io/@hygoogi/자바-GC에-대해서#g1-gc-garbage-first

logonjava.blogspot.com/2015/08/java-g1-gc-full-gc.html

 

Parallel Old GC

www.slipp.net/wiki/pages/viewpage.action?pageId=30770388

https://preamtree.tistory.com/118

 

Z GC

renuevo.github.io/java/garbage-collection/#heap-메모리-구조-jdk9부터-jdk13까지

wiki.openjdk.java.net/display/zgc/Main
hub.packtpub.com/getting-started-with-z-garbage-collectorzgc-in-java-11-tutorial/

www.baeldung.com/jvm-zgc-garbage-collector

 

Shenandoah GC

www.youtube.com/watch?v=E1M3hNlhQCg&ab_channel=OracleDevelopers