JVM 이해하기

자바 코드를 작성하며 변수나 객체같은 관리 포인트가 실제 JVM 에 어느 메모리 영역에 위치 하여 동시성 이슈가 발생 여부를 판단하는 근거 기르기

자바 프로그램을 실행하면 어떤 과정을 거칠까?

자바를 실행하기 위해서 아래와 같은 순서를 따르게 된다.

1

사용자가 작성한 소스코드를 컴파일러를 통해 바이트 코드(.class) 로 생성한 뒤 JVM 런처로 바이트 코드를 실행한다.

2

ClassLoader바이트 코드(.class)를 동적으로 읽어 JVM 메모리 영역인 Runtime Data Areas 에 자원을 할당힌다.

3

Runtime Data Areas 에 저장된 바이트 코드(.class)실행 엔진 을 거쳐 바이너리 코드로 변환하는 작업을 수행하게 되고, 이 과정에서 GC 나 쓰레드 동기화 과정이 같이 수행된다.

실행 엔진에서 런타임 시 인터프리터와 JIT 컴파일러를 활용하여 실제 자바 바이트 코드 -> 네이티브 코드로 변경하여 실행함

결론적으로 소스코드 → 컴파일러 → 클래스로더 → 실행 엔진 → 컴퓨터가 명령 연산 수행 과정을 거치게된다.

클래스 로더

클래스 로더 클래스 로더는 클래스를 메모리에 로드하고 실행을 위해 사용할 수 있게 만드는 JVM의 일부다.

자바 프로그램을 실행할 때 가장 먼저 개입하며, 컴파일된 클래스 파일(바이트 코드)을 메모리에 올리는 작업을 수행한다.

  • 클래스 로더는 모든 것을 한 번에 메모리에 올리지 않고, 효율성을 높이고 메모리 사용량을 최적화 하기 위해 클래스가 호출되었을 때 메모리에 올린다.

클래스 파일을 불러오는 순서는 로딩 -> 링킹 -> 초기화 단계로 진행된다.

  • 로딩: 클래스 파일을 읽어 JVM 메모리에 할당

  • 링킹: 클래스 파일을 사용하기 위해 검증

  • 초기화: 클래스 변수들을 기본 값으로 모두 초기화

런타임 데이터 영역(메모리)

JVM 이 프로그램을 수행하기 위해 OS로 부터 할당 받은 메모리 공간이다.

런타임 데이터 영역은 JVM 의 두뇌와 같으며, 프로그램을 실행하는 데 필요한 모든 것을 담고 있다.

  • 메서드 영역

  • 스택

  • PC Register

  • Native Method Stack

OS가 할당한 메모리 공간을 사용하다보니, 프로그램 내에서 여러 쓰레드가 공유하는 공간과 쓰레드 개별적으로 할당받는 공간이 나뉘어져 있으며 이로인해 동시성이 발생한다.

모든 쓰레드가 공유하는 영역

  • 메소드 영역 또는 스태틱 영역

  • 힙 영역

쓰레드 개별적으로 할당 받는 영역

  • 스택 영역

  • PC 레지스터 영역

  • 네이티브 메소드 스택 영역

실행 엔진

실행 엔진은 바이트 코드를 해석하여 네이티브 코드로 변환 한다.

이 때, 코드 해석은 기본적으로 인터프리터로 해석하며 일정 기준이 넘어가는 경우 최적화를 위해 JIT 컴파일 방식으로 명령어를 실행한다.

  • JIT 컴파일러: 인터프리터의 단점을 보완하기 위해 도입된 방식으로 실행 시점에 인터프리터 방식으로 코드를 해석하고, 자주 사용되는 메소드의 경우 컴파일 하고 네이티브 코드를 캐싱한다.

    해당 메소드가 여러 번 호출 되는 경우 매번 새롭게 해석하지 않고, 캐싱된 네이티브 코드를 수행한다.

  • 가비지 컬렉터: Heap 메모리 영역에서 더 이상 사용하지 않는 객체의 메모리 자원을 자동으로 회수한다.

JIT 컴파일러는 메서드가 반복되는걸 언제 알아낼까?

Oracle 의 Hotspot VM 은 아래 두 가지 종류의 JIT 컴파일러를 제공한다.

  • C1(Client Compiler): 컴파일러 속도가 빠르지만, 최적화 수준은 상대적으로 낮으며 애플리케이션을 빠르게 시작하기 위해 사용하며 즉시 실행되는 데스크탑 어플리케이션에 적합

  • C2(Server Compiler): C1 컴파일러보다 컴파일 시간은 오래 걸리지만, 매우 높은 수준의 최적화를 수행하여 가장 빠른 코드를 생성하며 장시간 실행되는 서버 어플리케이션에 최대 성능에 집중

Hotspot VM 은 초기에 인터프리터를 통해 최적화 없이 모든 코드를 해석하지만, 메서드의 호출 여부를 계속 주시하다 호출 횟수가 늘어남에 따라 컴파일 수준을 점진적으로 높이는 계층적 컴파일 전략을 사용한다.

계층적 컴파일 C2 컴파일러를 사용하면 동일한 메서드를 컴파일 하는 데 더 많은 시간이 걸리고, 더 많은 메모리를 소비 하는 경우가 많지만 C1 보다 더 최적화된 네이티브 코드를 생성한다.

빠른 시작과 우수한 장기 성능을 모두 달성하기 위해서 위 C2 만을 사용하지 않고, C1과 C2 컴파일러를 혼합하여 사용하는 방식이다.

계층적 컴파일 전략

  1. C1을 사용하여 자주 실행되는 코드를 컴파일 하여 네이티브 코드 성능을 빠르게 실행한다.

  2. 더 자주 반복되는 코드 섹션은 C2 컴파일을 통해 C1 보다 시간이 많이 걸리지만 성능의 이점을 위해 최적화하여 코드를 다시 컴파일한다.

결국, C1 은 성능을 더 빠르게 향상 시킬 수 있으며 C2 는 더 많으 반복되는 코드 섹션을 기반으로 더 나은 성능 향상을 가져올 수 있다.

자바에서 기본적으로 설정된 컴파일 임계값

java -XX:+PrintFlagsFinal -version | grep Threshold | grep Tier
intx Tier3CompileThreshold                    = 2000                                      {product} {default}
intx Tier3InvocationThreshold                 = 200                                       {product} {default}
intx Tier3MinInvocationThreshold              = 100                                       {product} {default}
  • Tier3InvocationThreshold: 메서드가 C1으로 컴파일 되기 위한 최소 호출 횟수 -> "200번을 동일하게 호출한 메서드는 자주 쓰인다고 판단하여 C1으로 컴파일"

  • Tier3CompileThreshold: 메서드 호출 횟수와 메서드 내 루프가 반복 실행된 횟수(back-edge-count) 의 합이 해당 임계치를 초과하면 컴파일 -> 메서드 호출은 비록 적더라도 그 내부 루프가 반복적으로 도는 경우를 최적화 하기 위해 C1으로 컴파일

1

JVM 은 코드를 아무런 최적화 없이 인터프리터로 해석하며 메서드 호출 횟수, 반복 횟수 등 프로파일링을 수집한다.

2

메서드 호출 횟수가 Tier3CompileThreshold 또는 Tier3InvocationThreshold 도달하면 C1 컴파일을 사용하면 컴파일 대상이 된다.

3

C1 으로 컴파일된 코드가 계속 실행되어 더 높은 임계치인 Tier4CompileThreshold 또는 Tier4InvocationThreshold에 도달할 때 C2 컴파일러를 사용하여 메서드를 컴파일한다.

참고자료

GC가 관리하는 영역과 그렇지 않은 영역은 어떤 차이가 있을까?

JVM 메모리 구조를 간략하게 다시 한 번 살펴보면 메서드 영역, 스택 영역, 힙 영역으로 분류해볼 수 있겠다.

더 세분화된 메모리 구조는 아니지만 이 세가지 영역이 실제 사용자가 작성하는 코드에서 가장많이 언급되는 영역이기 때문에 간추렸다.

GC가 동작하는 메서드 영역

메서드 영역을 먼저 살펴보자면, 간단하게 전역 변수나 클래스 정보가 해당 영역에 저장되고 JVM 이 종료될 때 소멸되는 영역이다.

어플리케이션 실행 도중 자원을 사용하는 곳이 없다면(더 이상 참조 되지 않는 경우) 전역 공간에도 GC가 나서서 자원을 수거한다.

게다가, 전역 변수를 사용하는 영역에서 스레드간 자원을 공유하기 때문에 동시성 이슈가 발생할 가능성이 존재한다.

GC가 동작하지 않는 스택 영역

스택 영역은 메서드가 호출되는 정보를 담고 있고, 메서드 호출에 의해서 스택 프레임이 생성되고 스택 영역에 쌓인다.

다른 영역과 달리 스택 영역은 스레드 1개에 1개의 스택 영역이 생성되어 동시성 이슈가 발생하지 않는 영역이다.

게다가, 메서드가 호출되고 난 후 스택 자료구조는 LIFO 이기 때문에 호출된 이후 프레임이 소멸되기 때문에 여기도 마찬가지로, 자원을 회수하는 시점을 알기 때문에 GC가 동작할 필요가 없다.

GC가 동작해야만 하는 힙 영역

힙 영역은 다중 스레드 환경에서 모든 자원이 해당 메모리 공간을 공유한다. 메서드 영역과 달리 읽기만 하는 행위가 아닌, 데이터의 변경과 삭제가 빈번하게 일어나는 공간으로 다중 스레드 환경에서 동시성 이슈가 발생하는 크리티컬한 영역이기도 하다.

또한, 힙 영역에 존재하는 객체는 사용자가 직접 메모리 회수를 하지 않는 이상 지워질 일이 없기 때문에 GC가 동작하여 사용하지 않는 객체를 수거한다.

정리

GC 가 동작하는 공간은 자원이 수거되는 시점이 불명확한 경우 계속 존재하여 공간을 낭비할 수 있기 때문에 이를 최적화 하기 위해 GC가 동작한다.

GC 가 동작하는 스레드만 동작하고, 그 외에 동작중이던 다른 스레드는 일시적으로 중단되어 자원을 자원을 회수하기 때문에 GC 가 동작하는 행위 자체는 성능상 오버헤드가 발생한다.

Last updated