JVM의 GC와 멀티스레드를 알아보자

크리링·2025년 5월 6일
0

JVM

목록 보기
2/2
post-thumbnail

JVM 밑바닥까지 파헤치기 책의 GC와 멀티스레드를 이해한 내용을 정리한 글이며
JVM의 객체 생성에서 메모리 관리를 알아보자 글의 후속편입니다.




GC

  • 프로그램 카운터, JVM 스택 영역, 네이티브 메서드 영역은
    스레드와 생명 주기를 같이하기에 메모리 할당과 회수는 고민하지 않아도 된다.
  • 반면에 힙과 메서드 영역은 불확실한게 아주 많다.
    이 메모리 영역들의 할당과 회수는 동적으로 이뤄진다.



대상이 죽었는가?

GC가 힙을 청소하려면 어떤 객체가 살아있고, 어떤 객체가 죽었는지 판단해야 한다.

오늘날 주류 프로그래밍 언어들은 객체 생사 판단에 도달 가능성 분석 알고리즘을 이용한다.

기본 아이디어는 GC루트 라고 하는 루트 객체들을 시작 노드 집합으로 쓰는 것이다.
시작 노드들에서 출발하여 참조하는 다른 객체들로 탐색해 들어간다.
탐색 과정에서 만들어지는 경로를 참조 체인이라 한다.
그리고 GC 루트로부터 도달 불가능한 객체는 더 이상 사용할 수 없는게 확실해진다.

GC 루트로 이용할 수 있는 객체의 대표적인 예

  • 가상 머신 스택에서 참조하는 객체
    • 현재 실행 중인 메소드에서 쓰는 매개 변수, 지역 변수, 임시 변수 등
  • 메서드 영역에서 클래스가 정적 필드로 참조하는 객체
    • 자바 클래스의 참조 타입 정적 변수
  • 메서드 영역에서 상수로 참조되는 객체
    • 문자열 테이블 안의 참조



참조

  • 강한 참조
    • 가장 전통적인 참조
    • Object obj = new Object()와 같이 프로그램 코드에서 참조를 할당하는 것
    • 절대 회수하지 않음
  • 부드러운 참조
    • 유용하지만 필수는 아닌 객체
    • 부드러운 참조만 남은 객체라면 오버플로가 나기 직전에 두번째 회수를 위한 회수 목록에 추가됨
  • 약한 참조
    • 다음번 가비지 컬렉션 까지만 살아있다.
    • GC가 동작하기 시작하면 메모리가 넉넉하더라도 약하게 참조된 객체는 모두 회수
  • 유령 참조
    • 참조 중 가장 약함
    • 객체 수명에 아무런 영향을 주지 않으며, 객체 인스턴스를 가져오는 것 마저 불가능
    • 유령 참조의 유일한 목적은 대상 객체가 회수될 때 알림을 받기 위해서

도달 불가능 으로 판단한 객체가 반드시 죽는건 아님. 두번의 표시 과정의 유예 단계를 거치는데 이때 빠져 나오지 못한 객체는 회수

ps. finalize()는 사용하지 말자.
초기 자바에서 C, C++ 개발자들을 쉽게 끌어들이기 위한 기능이지 실행하는 비용이 높고 불확실성이 크다. (JDK 9부터 폐기)



GC 알고리즘

현재 사용 가상 머신들의 GC는 세대 단위 컬렉션 이론에 기초해 설계되어있다.
(대다수 객체는 일찍 죽는다, 살아남은 횟수가 늘어날수록 더 오래 살 가능성이 커진다.)

JDK 8의 기본 가비지 컬렉터인 패러럴 컬렉터와 JDK 9 이후의 기본 가비지 컬렉터인 G1

패러럴 올드 컬렉터

  • 멀티스레드를 이용한 병렬 회수 지원 + 총 처리량을 중심의 목적
  • 동작 순서
    1. Stop-the-World 발생 (GC 실행을 위해 모든 애플리케이션 멈춤)
    2. Mark 단계 (살아있는 객체 추적)
    3. 힙 공간 재정렬 준비
    4. 객체를 한쪽으로 밀어붙이며 조각난 공간 정리
  • 장점 : 모든 단계 병렬 실행으로 멀티코어 환경 성능 우수
  • 단점 : Stop-the-World 발생



G1 컬렉터

  • Stop-the-World 시간 사용자 설정 가능, 동시 처리량 증가
  • 최초 표시 (매우 짧게 Stop-the-World)
  • 동시 표시 (GC 도달 가능성 분석 + 전체 힙의 객체 그래프 재귀적 스캔)
  • 재표시 (변경된 소수의 객체만 스레드 멈춤)
  • 복사 및 청소






컴파일러

런타임에는 실행 효율을 높이는 최적화를 JIT 컴파일러가 지속 수행
컴파일타임에는 개발자의 코딩 효율을 높이는 최적화를 프런트엔드 컴파일러가 수행

  • 프런트엔드 컴파일러
    • 문법 검사
    • 심볼 테이블 생성
    • 타입 검사
  • JIT 컴파일러
    • 자주 실행되는 메서드나 코드 블록(핫스팟 코드, 핫 코드)이 발견되면 해당 코드를 네이티브 코드로 컴파일하고 다양한 최적화를 적용해 실행 효율을 높인다.






멀티스레드

스레드 안정성

스레드 안정성을 이해하려면 안전하냐 아니냐 이분법적 사고에서 벗어나야 한다.

  • 불변
    • 불변 객체는 객체 자체의 메서드 구현과 호출자 모두에서 아무런 안전장치 없이도 안전
    • final로 불변성 보장
    • Long, Double, BigInteger, `BigDecimal 등
  • 절대적 스레드 안전
    • "어떤 런타임 환경에서든 호출자가 추가적인 동기화 조치를 할 필요 없다."
    • 매우 엄격한 조건, 비현실적일 수 있음
    • 스레드 안전하다고 표시된 클래스 대부분이 절대적 스레드 안전을 의미하지는 않음
  • 조건부 스레드 안전
    • 일반적인 ""스레드 안전하다 말할 수준
    • 단일한 작업을 별도 보호 조치 없이 스레드로부터 안전하게 수행
    • 특정 순서로 연달아 호출하는 상황에서도 정확성을 보장하려면 호출자에서 추가로 동기화
    • Vector, HashTable, Collections 클래스의 synchronized Collection()
  • 스레드 호환
    • 객체 자체는 스레드로부터 안전하지 않지만 호출자가 적절히 조치하면 멀티스레드 환경에서 안전
    • 자바 클래스 대다수의 분류
  • 스레드 적대적
    • 호출자가 동기화 조치를 취하더라도 멀티스레드 환경에서 안전하게 사용할 수 없음
    • Thread 클래스의 suspend(), resume() 메서드

synchronized 가 조건부 스레드 안전이고 절대적 스레드 안전은 아닌 이유

public class Main {

  private static Vector<Integer> vector = new Vector<>();

  public static void main(String[] args) {
    while (true) {
      for (int i = 0; i < 10; i++) {
        vector.add(i);
      }

      Thread removeThread = new Thread(new Runnable() {
        @Override
        public void run() {
          for (int i = 0; i < vector.size(); i++) {
            vector.remove(i);
          }
        }
      });

      Thread printThread = new Thread(new Runnable() {
        @Override
        public void run() {
          for (int i = 0; i < vector.size(); i++) {
            System.out.println(vector.get(i));
          }
        }
      });

      removeThread.start();
      printThread.start();

      while (Thread.activeCount() > 20);
    }
  }
}

Vector는 내부 메서드 하나하나는 synchronized로 안전하지만, 다수의 메서드가 조합된 연산은 안전하지 않음



스레드 안정성 구현

상호 배제 동기화

  • synchronized
    • 매우 주의해서 사용 + 실행 비용 측면에서 상당히 무거움
    • 같은 스레드라면 synchronized 된 블록에 여러번 다시 진입할 수 있다.
    • 락을 소유한 스레드가 락을 해제 강제할 방법이 없다.
  • Lock 인터페이스
    • concurrent 패키지에서 사용
    • 논 블록 구조의 상호 배제 동기화 구현 가능
    • ReentrantLock이 대표적
      • 재진입 가능 락
      • synchronized와 비슷하지만 진보된 기능 제공
        • 대기중 인터럽트 : 락을 소유한 스레드가 오랜 시간 락을 해제하지 않을 때 같은 락을 얻기 위해 대기 중인 다른 스레드들은 락을 포기하고 다른 일 진행 (매우 긴 동기화 블록 다루는데 유용)
        • 페어락 : 락을 얻기 위해 대기하는 스레드가 많을 때 락 획득을 시도한 시간 순서대로 락을 얻음. (synchronized는 언페어락, ReentrantLock도 기본적으로는 언페어락이지만 페어락 설정 가능)
        • 둘 이상의 조건 지정 : ReentrantLock은 동시에 여러 개의 Condition 객체와 연결 지을 수 있다.
    • 락은 finally 블록에서 해제해야 한다.
      동기화로 보호한 코드 블록에서 예외 발생 시 소유 중인 락이 해제되지 않을 수 있다.
      -> 락 해제는 개발자가 직접 보장해야 함
    • 어느 스레드가 어느 락을 소유하고 있는지 알기 어렵다.
    • 블로킹 동기화 : 스레드 일시 정지와 깨우기가 초래하는 성능 저하 -> 상호 배제 동기화의 큰 문제

동기화와 락의 스레드 개수별 처리량 비교




0개의 댓글