Java

Garbage Collection

후후후하하하 2024. 4. 10. 15:11

 

GC

  • 자바의 메모리 관리 방법 중 하나로 JVM의 HEAP 영역에서 동적으로 할당했던 메모리 중 필요 없게 된 메모리 객체(Garbage)를 모아 주기적으로 제거하는 프로세스.
  • C / C++언어에서는 GC가 없어 프로그래머가 수동으로 메모리 할당과 해제를 해줘야 했었다.
  • 반면 JAVA는 GC가 메모리 관리를 대행해주기 때문에 JAVA 프로세스가 한정된 메모리를 효율적으로 사용할 수 있게 하고, 개발자 입장에서는 메모리 관리, 메모리 누수 문제에 대해 관리하지 않아도 되어 개발에만 집중할 수 있다.
  • 파이썬, 자바스크립트, Go 언어 등 많은 프로그래밍 언어에서 GC가 기본으로 내장되어 있다.
  • 하지만 단점도 존재한다.
  • 메모리가 언제 해제되는지 정확하게 알 수 없어 제어하기 힘들며, GC가 동작하는 동안에는 다른 동작을 멈추기 때문에 오버헤드가 발생되는 문제점이 있다.
  • 이를 STW(Stop - The - World)라 한다.
    • GC를 수행하기 위해 JVM이 프로그램 실행을 멈추는 현상
    • GC가 작동하는 동안 GC 관련 Thread를 제외한 모든 Thread는 멈추게 되어 서비스 이용에 차질이 생길 수 있다.
    • 따라서 이 시간을 최소화 시키는 것이 쟁점이다.
    • 이로 인해 GC가 너무 자주 실행되면 소프트웨어 성능 하락의 문제가 되기도 한다.
    • 예를 들어 익스플로러는 GC를 너무 자주 실행하여 성능 문제를 일으켰다.
    • 이로 인해, 실시간 성이 매우 강조되는 프로그램일 경우 GC에게 메모리를 맞기는 것은 맞지 않을 수 있다.
    • 따라서 어플리케이션의 사용성을 유지하면서 효율적이게 GC를 실행하는 최적화 작업이 개발자의 숙제이다.
    • 이러한 최적화 작업을 GC 튜닝이라고 한다.
  • GC는 어떤 객체를 Garbage로 판단해서 지울까?
    • 판단을 위해 도달성, 도달능력(Reachabliity) 개념을 적용한다.
    • 객체에 레퍼런스가 있다면 Reachable로 구분되고, 유효한 레퍼런스가 없다면 Unreachable로 구분해 수거한다.
    • Reachable : 객체가 참조되고 있는 상태, Unreachable : 객체가 참조되고 있지 않은 상태
    • 예를 들어 JVM 메모리에서는 객체들은 실질적으로 HEAP 영역에서 생성되고 Method Area나 Stack Area에서는 Heap 영역에 생성된 객체의 주소만 참조하는 형식으로 구성된다.
    • 하지만 이렇게 생성된 Heap의 객체들이 메서드가 끝나는 등의 특정 이벤트들로 인하여 Heap 객체의 메모리 주소를 가지고 있는 참조 변수가 삭제되는 현상이 발생하면, 위의 그림에서의 빨간색 객체와 같이 HEAP 영역에서든 어디든 참조하고 있지 않은 객체들이 발생하게 된다.

GC의 청소 방식

  • Mark And Sweep
    • 기초적인 청소 과정
    • unreachable 객체를 식별(Mark)하고 제거(Sweep)하며 객체가 제거되어 파편화된 메모리 영역을 앞에서부터 채워나가는 작업(Compaction)을 수행한다.
    • Mark : 먼저 Root Space로부터 그래프 순회를 통해 연결된 객체들을 찾아내어 각각 어떤 객체를 참조하고 있는지 찾아서 마킹한다.
    • Sweep : 참조하고 있지 않은 객체 즉 Unreachable 객체들을 Heap에서 제거한다.
    • Compact : Sweep 후에 분산된 객체들을 Heap의 시작 주소로 모아 메모리가 할당된 부분과 그렇지 않은 부분으로 압축한다.(GC 종류에 따라 하지 않는 경우도 있음)
    • 루트로부터 연결이 끊긴 순환 참조되는 객체들을 모두 지울 수 있다.
    • GC의 Root Space : Heap 메모리 영역을 참조하는 method area, static 변수, stack, native method stack이 된다.

Heap 메모리 구조

  • 동적으로 레퍼런스 데이터가 저장되는 공간으로서, GC의 대상이 되는 공간이다.
  • 처음 설계될 때 2가지를 전제로 설계되었다.
    • 대부분의 객체는 금방 접근 불가능한 상태가 된다.
    • 오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다.
    • 즉, 객체는 대부분 일회성이며, 메모리에 오랫동안 남아있는 경우는 드물다.
  • 이러한 특성을 이용해 객체의 생존 기간에 따라 물리적인 Heap 영역을 나누게 되었고 young과 old 2가지 영역으로 설계하였다.
  •  

  • Young 영역
    • 새롭게 생성된 객체가 할당
    • 대부분의 객체가 금방 Unreachable 상태가 되기 때문에, 많은 객체가 Young 영역에 생성되었다가 사라진다.
    • Young 영역에 대한 GC를 Minor GC라고 부른다.
  • Old 영역
    • Young 영역에서 Reachable 상태를 유지하여 살아남은 객체가 복사되는 영역 
    • Young 영역보다 크게 할당되며, 영역의 크기가 큰 만큼 가비지는 적게 발생한다.
    • Major GC 또는 Full GC라고 부른다.
  • Old 영역이 Young 영역보다 크게 할당되는 이유는 Young 영역의 수명이 짧은 객체들은 큰 공간을 필요로 하지 않으며 큰 객체들은 Old 영역에 할당되기 때문이다.
  • 다시 Young 영역을 3가지 영역(Eden, survivor 0, survivor 1)으로 나눈다.
  •  

  • Eden
    • new를 통해 새로 생성된 객체가 위치
    • 정기적인 Garbage 수집 후 살아남은 객체들은 Survivor 영역으로 보냄
  • Survivor 0/ Survivor 1
    • 최소 1번의 GC 이상 살아남은 객체가 존재하는 영역
    • Survivor 영역에는 특별한 규칙이 있는데, Survivor 0 또는 Survivor 1 둘 중 하나는 꼭 비어있어야 한다.
  • 이렇게 하나의 힙 영역을 세부적으로 쪼갬으로서 객체의 생존 기간을 면밀하게 제어하여 GC를 보다 정확하게 불필요한 객체를 제거하는 프로세스를 실행하도록 한다.
  • Java 8에서의 Permanent
    • 영구적인 세대, 생성된 객체들의 정보의 주소값이 저장된 공간
    • 클래스 로더에 의해 load되는 Class, Method 등에 대한 Meta 정보가 저장되는 영역이고 JVM에 의해 사용된다.
    • Java 7까지는 힙 영역에 존재했지만 Java 8 이후부터는 Native Method Stack에 편입되었다.
  • Minor GC 과정
    • Young Generation의 공간은 Old에 비해 상대적으로 작기 때문에 메모리 상의 객체를 찾아 제거하는데 적은 시간이 걸린다. = Minor GC
    • 처음 생성된 객체는 Eden 영역에 위치
    • 객체가 계속 생성되어 Eden 영역이 꽉 차게 되고 Minor GC가 실행
    • Mark 동작을 통해 reachable 객체를 탐색
    • Eden 영역에서 살아남은 객체는 1개의 Survivor 영역으로 이동
    • Eden 영역에서 사용되지 않는 객체(unreachable)의 메모리를 해제(sweep)
    • 살아남은 모든 객체들은 age값이 1씩 증가
      • age 값이란? Survivor 영역에서 객체의 객체가 살아남은 횟수를 의미한다. Object Header에 기록된다.
      • 만일 age값이 임계값에 다다르면 Promotion(old 영역으로 이동) 여부를 결정한다.
      • JVM 중 가장 일반적인 HotSpot JVM의 경우 age 기본 임계값은 31이다. 객체 헤더에 age를 기록하는 부분이 6 bit로 되어 있기 때문이다.
      • 또한 survivor 영역의 제한 조건으로 반드시 1개는 사용되어야 하고, 나머지 1개는 비어있어야 한다. 만약 두 영역에 모두 데이터가 존재하거나, 모두 사용량이 0이라면 현재 시스템이 정상적인 상황이 아니라는 반증이다.
    • 또다시 Eden 영역에 신규 객체들로 가득 차게 되면 다시 한번 Minor GC가 발생하고 mark한다.
    • marking한 객체들을 비어있는 survival 1영역으로 이동하고 sweep (survivor 0영역도 sweep)
    • 다시 살아남은 모든 객체들은 age가 1씩 증가
  • Major GC 과정
    • 객체들이 계속 Promotion 되어 old 영역의 메모리가 부족해지면 발생한다.
    • 객체의 age가 임계값에 도달하게 되면
    • 객체들은 Old Generation으로 이동된다. = Promotion
    • 상대적으로 큰 공간을 가지고 있어, 이 공간에서 메모리 상의 객체 제거에 많은 시간이 걸린다.
    • 예를 들어 Young 영역은 GC 시간이 보통 0.5초에서 1초 사이에 끝난다.
    • 하지만 Old 영역은 일반적으로 10배 이상의 시간을 사용한다. = STW 문제 발생

 

* 예외적으로 Old 영역에 있는 객체가 Young 영역의 객체를 참조하는 경우 존재.

이러한 경우를 대비하여 Old 영역에는 512 bytes의 덩어리(Chunk)로 되어 있는 카드 테이블이 존재한다.

카드 테이블에는 Old 영역에 있는 객체가 Young 영역의 객체를 참조할 때마다 그에 대한 정보가 표시된다. 만약 카드 테이블이 없다면 Young 영역에서 GC가 실행될 때 모든 Old 영역에 존재하는 객체를 검사하여 참조되지 않는 Young 영역의 객체를 식별하는 것이 비효율적이기 때문이다. 그렇기 때문에 Young 영역에서 GC가 진행될 때 카드 테이블만 조회하여 GC의 대상인지 식별할 수 있도록 한다.

 

HotSpot JVM에서는 Eden 영역에 객체를 빠르게 할당하기 위해 Bump the pointer와 TLABs 기술을 사용한다.

Bump ther Point : Eden 영역에 마지막으로 할당된 객체의 주소를 캐싱해두는 것이다. 이를 통해 새로운 객체를 위해 유효한 메모리를 탐색할 필요 없이 마지막 주소의 다음 주소를 사용하게 함으로써 속도를 높이고 있다. 이를 통해 새로운 객체를 할당할 때 객체의 크기가 Eden 영역에 적합한지만 판별하면 되므로 빠르게 메모리 할당을 할 수 있다.

TLABs(Thread-Local-Allocation Buffers) : 싱글 스레드 환경이라면 문제가 없겠지만 멀티스레드 환경이면 객체를 Eden 영역에 할당할 때 락을 걸어 동기화를 해주어야 한다. 멀티스레드 환경에서의 성능 문제를 해결하기 위해 HotSpot JVM은 추가로 TLABs 기술 도입했다. 각각의 스레드마다 Eden 영역에 객체를 할당하기 위한 주소를 부여함으로써 동기화 작업 없이 빠르게 메모리를 할당하도록 하는 기술이다. 각각의 스레드는 자신이 갖는 주소에만 객체를 할당함으로써 동기화 없이 bump the pointer를 통해 빠르게 객체를 할당하도록 하고 있다.

 

 

GC 알고리즘 종류

  • 모두 설정을 통해 Java에 적용 가능 = 상황에 따라 필요한 GC 방식을 설정해서 사용할 수 있다.
  • Serial GC
    • 서버의 CPU 코어가 1개일 때 사용하기 위해 개발된 가장 단순한 GC
    • GC를 처리하는 스레드가 1개(싱글 스레드)이기 때문에 STW 시간이 가장 길다.
    • Minor GC에는 Mark-Sweep을 사용하고 Major GC에는 Mark-Sweep-Compact를 사용한다.
    • 보통 실무에서 사용하는 경우는 없다. (디바이스 성능이 안좋아서 CPU 코어가 1개인 경우에만 사용)
    • 자바 프로그램을 실행할 때 -XX:+UseSerailGC 라는 GC 옵션을 지정하여 해당 GC알고리즘을 사용 가능하다.
    •  
    • java -XX:+UseSerialGC -jar Application.java
  •  Parallel GC
    • Java 8의 Default GC
    • Serial GC와 기본적인 알고리즘은 같지만, Young 영역의 Minor GC를 멀티 스레드로 수행(Old 영역은 여전히 싱글 스레드)
    • Serial GC에 비해 STW 시간 감소
    • GC 스레드는 기본적으로 CPU 개수만큼 할당된다.
    • 옵션을 통해 GC를 수행할 스레드의 개수 등을 설정해줄 수 있다.
    • java -XX:+UseParallelGC -jar Application.java 
      # -XX:ParallelGCThreads=N : 사용할 쓰레드의 갯수
  • Parallel Old GC(Parallel Compacting Collector)
    • Parallel GC를 개선한 버전
    • Young 영역 뿐만 아니라, Old 영역에서도 멀티스레드로 GC 수행
    • 새로운 GC 청소 방식인 Mark-Summary-Compact 방식을 이용
    • java -XX:+UseParallelOldGC -jar Application.java
      # -XX:ParallelGCThreads=N : 사용할 쓰레드의 갯수
  • CMS GC (Concurrent Mark Sweep)
    • 어플리케이션의 쓰레드와 GC 쓰레드가 동시에 실행되어 STW 시간을 최대로 줄이기 위해 고안된 GC
    • 단 GC 과정이 매우 복잡해짐
    • GC 대상을 파악하는 과정이 복잡한 여러 단계로 수행되기 때문에 다른 GC 대비 CPU 사용량이 높다.
    • 메모리 파편화 문제
    • 애플리케이션이 구동중일 때 프로세서의 자원을 공유하여 이용가능해야 한다. GC가 수행될 때 자원이 GC를 위해서도 사용되므로 응답이 느려질 수는 있지만 응답이 멈추지는 않게 된다.
    • 다른 GC 방식보다 메모리와 CPU를 더 많이 필요로 하며, Compaction 단계를 수행하지 않는다는 단점 존재. -> 시스템이 장기적으로 운영되다가 조각난 메모리들이 많아 Compaction 단계가 수행되면 오히려 STW 시간이 길어지는 문제 발생.
    • Java 14에서는 사용이 중지됨
  • G1 GC (Garbage First)
    • CMS GC를 대체하기 위해 JDK 7버전에서 최초로 release된 GC
    • Java 9+ 버전의 Default GC로 지정
    • 4GB 이상의 힙 메모리, STW 시간이 0.5초 정도 필요한 상황에 사용(Heap이 너무 작을 경우 미사용 권장)
    • 기존의 GC 알고리즘은 Heap 영역을 물리적으로 고정된 Young / Old 영역으로 나누어 사용하였지만,
    • G1 GC는 아예 개념을 뒤엎는 Region이라는 개념을 새로 도입하여 사용.
    • 전체 Heap 영역을 Region이라는 영역으로 체스같이 분할하여 상황에 따라 Eden, Survivor, Old 등 역할을 고정이 아닌 동적으로 부여.
    • Garbage로 가득찬 영역을 빠르게 회수하여 빈 공간을 확보하므로, 결국 GC 빈도가 줄어드는 효과를 얻게 되는 원리.
    • 이전의 GC들처럼 일일히 메모리를 탐색해 객체들을 제거하지 않는다.
    • 대신 메모리가 많이 차있는 영역(region)을 인식하는 기능을 통해 메모리가 많이 차있는 영역을 우선적으로 GC한다.
    • 즉, G1 GC는 Heap Memory 전체를 탐색하는 것이 아닌 영역(region)을 나눠 탐색하고 영역별로 GC가 일어난다.
    • 기존 GC들은 Young 영역에 있는 객체들이 살아남으면 Eden -> Survivor0 -> Survivor1으로 순차적으로 이동했지만, G1 GC에서는 순차적으로 이동하지 않는다. 대신 더욱 효율적이라고 생각하는 위치로 객체를 Reallocate(재할당) 시킨다. 예를 들어 Survivor 1 영역에 있는 객체가 Eden 영역으로 할당하는 것이 더 효율적이라고 판단될 경우 Eden 영역으로 이동시킨다.
    • java -XX:+UseG1GC -jar Application.java
    •  

  • Shenandoah GC
    • Java 12에 release
    • 레드햇에서 개발한 GC
    • 기존 CMS가 가진 단편화, G1이 가진 pause의 이슈를 해결
    • 강력한 Concurrency와 가벼운 GC 로직으로 HEAP 사이즈에 영향을 받지않고 일정한 pause 시간 소요가 특징
  • ZGC (Z Garbage Collector)
    • Java 15에 release
    • 대량의 메모리(8MB ~ 16TB)를 low-latency로 잘 처리하기 위해 디자인 된 GC
    • G1의 Region 처럼, ZGC는 ZPage라는 영역을 사용하며, G1의 Region은 크기가 고정인데 비해, ZPage는 2mb배수로 동적으로 운영됨. (큰 객체 들어오면 2^ 로 영역을 구성해서 처리)
    • 최대 장점 중 하나는 힙 크기가  증가하더라도 STW 시간이 절대 10ms를 넘지 않는다는 것.

 

 

 

 

 

GC의 성능을 높이는 코딩 방법

  1. Young 영역과 Old 영역의 힙 크기를 알맞게 조정하는 것.
    • 힙의 크기를 줄이면 GC 빈도수가 많아지고, 늘리면 GC의 지연시간이 길어지는 Trade-Off가 있다.
  2. 객체의 할당이나 Old 영역으로의 이동 등의 작업을 줄이는 것.
    • GC에서 살아남는 객체들의 영향이 크다.
    • Eden 영역에서 Survivor 영역으로 복사되거나 Old 영역으로 Promotion 되는 등의 작업을 줄이는 것이 성능을 높이는 방법이다.

 

  1. Collection의 크기를 예측하여 설정하라
    • 모든 Collections와 구현체들은 내부적으로 배열을 사용한다. 배열의 크기는 불변이라 초기에 할당되면 수정이 불가능하다. 그렇기에 처음 설정된 크기를 초과하여 계속 item을 담으려고 하면 내부적으로 새로운 크기의 배열을 생성하고 item을 복사한다. 그렇다면 기존 배열은 Garbage가 된다. 
    • 대부분의 Collection은 이러한 재할당 과정을 최적화하려고 노력하고 있지만 가비지가 생기는 것은 불가피하다. 그렇기에 가능하다면 Collection의 크기를 예측하여, 생성 시에 직접 설정해주자.
  2. Stream을 사용하라
    • byte[] fileData = readFileToByteArray(new File("myfile.txt"));
    • 파일로부터 데이터를 읽거나 네트워크를 통해 파일을 받는 경우, 데이터의 크기가 너무 크다면 JVM이 해당 파일의 내용을 할당할 수 없어 OutOfMemoryErrors가 발생할 수 있다. 할당이 되었다 하더라도 이후에 상당히 큰 규모의 가비지가 된다. 이러한 문제를 예방하는 가장 좋은 방법은 InputStream을 직접 사용하는 것이다.
    • InputStream은 내부적으로 Buffer를 두고 있어 일정한 크기(Chunk)만큼씩 데이터를 조회한다. 그렇기 때문에 InputStream을 사용하면 Buffer를 재사용함으로써 OutOfMemoryErrors를 방지할 수 있고, 가비지 생성을 최소화할 수 있다. 대부분 Major한 도구들은 Stream을 직접 받아 처리하도록 되어 있다.
  3. String의 사용을 최적화하라
    • 빼놓을 수 없는 부분이다. 메모리에 큰 영향을 미친다.
    • 중복된 String이 생성되는 경우, JVM 옵션을 활용하라
      • 동일한 문자열에 의해 불필요한 메모리 사용을 줄이도록 새로운 JVM 파라미터(UseStringDeduplication)을 추가하였다. 해당 옵션을 사용하면 중복되는 String 인스턴스들을 Global Single Char[]로 관리하여 힙 메모리 사용을 최적화할 수 있다.
      • java -XX:+UseStringDeduplication -jar Application.java
    • StringBuilder를 사전에 활용하라
      • +등과 같은 연산은 새로운 문자열을 할당하는 것이다. 이를 최적화하기 위해 StringBuilder를 제공. Compiler는 String을 더하는 연산을 내부적으로 StringBuilder를 사용하여 처리하고 있다.
    • 4. 불변성을 활용하라.
      • 불변성 활용하면 많은 이점 존재, 그 중 하나는 GC의 성능을 높여준다는 것이다.
      • Java에서는 final 키워드를 사용해 불변의 객체를 생성 가능하다.
    • 5. 불필요한 Collection의 생성을 피해라.
      • 불변성은 상당히 훌륭하지만 큰 규모의 시스템에서 심각한 문제 유발 가능함. 메소드를 통해 객체를 주고받을 때.