[Java] JVM의 구조와 동작 방식

clean·2024년 1월 3일
0
post-thumbnail

JDK, JRE, JVM

JDK

  • 자바 개발 환경으로 자바 어플리케이션을 개발하기 위해 필요한 도구를 제공한다.
  • 자바 언어를 바이트 코드로 컴파일 해주는 자바 컴파일러(javac), 자바 클래스 파일을 해석해주는 역어셈블리어(javap) 등이 있다.

JRE

  • JRE는 자바 실행 환경이다. JVM, 자바 클래스 라이브러리, 기타 자바 어플리케이션 실행에 필요한 파일들을 포함한다.

JVM

  • 자바 가상 머신(Java Virtual Machine)으로 자바 어플리케이션을 실행하는 가상머신이다. 컴퓨터로부터 java 어플리케이션 실행을 위한 메모리를 할당 받아 Runtime Data Area를 구성한다.
    JVM은 인터프리터와 JIT 컴파일러를 통해 바이트 코드를 각 운영체제에 맞는 기계어로 해석시켜 실행시키고, 가비지 컬렉션(GC)를 통해 어플리케이션의 동적 메모리를 관리한다.

자바와 운영체제 사이에서 중개자 역할을 한다. 어떤 운영체제에서 작성해서 컴파일한 class 파일도 JVM을 통해서 다른 운영체제에서도 실행이 가능하다.

다른 하드웨어와 다르게 레지스터 기반이 아닌 스택 기반으로 동작한다.

JVM은 운영체제에 종속적이기에 운영체제에 맞는 JVM을 필요로한다.

참고:
VM(Virtual Machine)에는 레지스터 기반과 스택 기반 두 가지가 존재한다. 두 방법은 피연산자를 저장하고 다시 가져오는 매커니즘이 다르다.

  • Stack Based:

    예를 들어 13+7+20이라는 연산을 하고 싶을 때, ADD 연산의 피연산자는 2개이기 때문에 20과 7을 먼저 더해야한다. 20+7이라는 연산을 하기 위해서 20을 스택에서 꺼내고, 7을 스택에서 꺼낸 뒤 둘을 더한 27을 다시 스택에 저장한다. 이러한 방식으로 동작하는 것이 Stack Based VM이다.
  • Register Based:

    레지스터 기반의 VM은 피연산자를 레지스터에서 가져와 연산하고 그 연산의 결과를 다시 어떤 레지스터에 저장한다.

레지스터 기반의 방식을 쓰면 스택 기반보다 명령어의 수가 줄어든다. 대신 명령어의 사이즈는 커진다고 한다. 하지만 VM 명령어 디스패처의 동작이 비싼 연산이기 때문에 명령어의 수를 줄이는 것이 성능에 더 유리하고, 일반적으로 레지스터 기반이 스택 기반보다 더 빠르다고 한다.
그럼에도 JVM이 스택 기반을 선택한 이유는 여러 OS와 하드웨어에서 작동하는 VM을 만들기 위함일 것이다. 레지스터 기반은 피연산자를 레지스터에 저장하므로 레지스터의 수, 레지스터의 사이즈에 더 의존적이다. 하지만 스택 기반은 하드웨어에 덜 의존적이므로 같은 바이트 코드를 여러 OS 위에서 실행할 수 있는 JVM을 개발하기 위해 스택 기반을 선택했을 것이다.
출처: https://www.korecmblog.com/blog/jvm-stack-and-register

JVM의 동작 방식

  1. javac(자바 컴파일러)가 .java 파일을 .class(자바 바이트코드)로 컴파일한다.
  2. class 파일을 실행하면 JVM은 OS로부터 메모리를 할당한다.
  3. Class Loader가 class 파일들을 JVM Runtime Data Area로 로딩한다.
  4. Runtime Data Area로 로딩된 클래스 파일들은 Execution Engine을 통해서 해석된다.
  5. 해석된 바이트 코드는 Runtime Data Area의 각 영역에 배치되어 수행. 이 과정에서 Execution Engine에 의해 GC의 작동과 스레드 동기화가 이루어진다.

JVM의 구조

JVM은 크게 Class Loader, Runtime Data Area, Execution Engine, JNI(Native Method Interface), Native Method Library로 나눌 수 있다.

Class Loader

자바는 동적으로 클래스를 읽어오므로, 프로그램이 실행 중인 런타임시 동적으로 클래스를 로드하여 자바 바이트 코드가 JVM과 연결된다. 런타임 시점에 클래스를 로딩해주는 역할을 하는 것이 Class Loader이다. Class Loader는 이처럼 JVM 내로 클래스 파일을 로드하고, 링크를 통해 배치하는 작업을 수행하는 모듈이다.
자바에서 소스를 작성하면 .java 파일이 생성되고, .java 파일을 컴파일하면 .class 파일이 생성되는데 클래스 로더는 .class 파일을 묶어서 JVM이 운영체제로부터 할당받은 메모리 영역인 Runtime Data Area로 적재한다. 주의할 점은 모든 클래스 파일을 이 시점에 Runtime Data Area로 로드하지 않는다는 것이다. JVM은 기본적으로 Lazy Loading이다. main() 메소드가 위치한 클래스를 로드한 후 그 바이트 코드를 해석하다가 필요시에 그 클래스를 메모리에 적재한다.

Class Loader는 1. Loading, 2. Linking, 3. Initialization 세 단계를 거쳐 수행된다.

  1. Loading: 클래스 파일을 가져와서 JVM의 메모리에 로드한다.
  2. Linking: 클래스 파일을 사용하기 위해 검증하고 클래스 파일의 정보를 method area에 올리는 과정이다.
    (1) Verify: 읽어들인 클래스가 JVM 명세에 명시된 대로 구성되어 있는지 검사한다.
    (2) Prepare: 클래스가 필요로하는 메모리를 할당한다.
    (3) Resolve: 클래스의 상수 풀 내 모든 심볼릭 래퍼런스를 다이렉트 래퍼런스로 변경한다.
  3. Initialization: 클래스 필드들을 모두 적절한 값으로 초기화한다.

Execution Engine

Execution Engine, 즉 실행 엔진은 Class Loader에 의해 Runtime Data Area에 배치된 자바 바이트 코드(.class 파일)를 명령어 단위로 읽어서 실행한다. 자바 바이트 코드는 기계어가 아닌 JVM이 이해할 수 있는 중간 단계의 코드이다. 실행 엔진은 이 자바 바이트 코드를 기계가 이해할 수 있는 언어로 바꾸는 것이다.

이 과정에서 실행 엔진은 인터프리터 방식JIT(Just-In-Time) 컴파일러 방식을 혼합하여 사용한다. 인터프리터 방식을 사용하다가 일정한 기준을 충족하면 JIT 컴파일러로 바이트 코드를 네이티브 코드로 변환한 후 캐시에 올려놓는 식으로 동작한다.

  • 인터프리터 방식: 바이트 코드 명령어를 한 줄씩 읽어서 해석하고 바로 실행한다. JVM 안에서 실행 엔진은 기본적으로는 이 방식으로 동작한다. 하지만 여러 번 호출되는 메소드일지라도 계속 같은 방식으로 변환을 해주어야 하기 때문에 실행 속도가 느려진다.

  • JIT 컴파일러 방식: 인터프리터의 성능을 개선하기 위해 등장한 방식이다. 바이트 코드 전체를 네이티브 코드(Native Code)로 변환하여 이를 캐시에 올려놓았다가 그 코드 부분을 다시 만나면 네이티브 코드를 바로 실행하는 방식이다. 이 경우, 그 부분을 컴파일하여 실행하기 때문에 인터프리트 방식보다 빠르지만 JIT 컴파일러 역시 바이트 코드를 네이티브 코드로 바꾸는 오버헤드가 존재한다. 따라서 실행 엔진은 인터프리트와 JIT 컴파일을 혼합하여 쓴다.

네이티브 코드(Native Code): java의 부모가 되는 C언어 또는 C++, 어셈블리어로 구성된 코드를 의미한다.

Garbage Collection(GC)

JVM은 Garbage Collection(GC)를 이용해서 사용하지 않는 Heap 메모리 공간을 자동으로 회수해준다. 힙 메모리 영역에 생성된 객체들 중에 참조되지 않은 객체들을 탐색 후 제거하는 역할을 해주는 것이다.

GC가 메모리 공간을 알아서 관리를 해주기 때문에 편리하지만, GC가 실행되는 동안 GC 관련 스레드를 제외한 다른 스레드들이 모두 멈추는 Stop-the-World라는 현상이 발생하기 때문에 애플리케이션 성능에 영향을 줄 수 있다. 따라서 GC의 실행 빈도와 시간을 줄이기 위한 최적화가 중요한데 이를 GC 튜닝이라고 한다.

  • 참고: C, C++ 에는 Garbage Collection이 없어서 프로그래머가 수동으로 메모리 할당/해제를 해주어야하지만, java에서는 JVM이 관리해주는 것이다.

GC에 대한 자세한 내용은 여기에 정리하였다.

Runtime Data Area

Runtime Data Area는 JVM의 메모리 영역으로 자바 애플리케이션을 실행할 때 사용되는 데이터들을 적재하는 영역이다.
이 영역은 크게 Method Area, Stack Area, PC Register, Native Method Stack으로 나눌 수 있다.

  • PC register: PC Register는 스레드가 생성될 때마다 생기는 공간으로, 스레드가 어떤 명령을 실행할지(실행해야하는 JVM의 명령어 주소)에 대한 부분을 기록한다. JVM은 Stack-Based 방식으로 작동한다. 즉 CPU에 직접 instruction을 수행하지 않고, 현재 작업하는 내용을 CPU에게 알려주어야 하는데 이런 정보를 저장하는 하는 버퍼 공간으로 PC Register를 사용하는 것이다. 예를 들어 20+13+5를 더해야 한다고 하면 우선 20+13을 한 33을 잠시 저장할 공간이 필요하다. 이를 PC Register에 저장해둔다.

    만약에 스레드가 자바 메소드를 수행하고 있으면 JVM 명령어(Instruction)의 주소를 PC Register에 저장한다.
    그러다 만약 자바가 아닌 다른 언어(C언어, 어셈블리)의 메소드를 수행하고 있다면, undefined 상태가 된다. 그리고 이 때(네이티브 코드가 실행되는 동안)의 정보는 Native Method Stack에 저장된다.

  • Method Area: 프로그램 실행 중 클래스가 사용되면 JVM은 해당 클래스 파일을 읽어서 분석하여 클래스의 인스턴스 변수, 메소드 코드등을 Method Area에 저장한다. 이 때 클래스 변수(static variable)도 이 영역에 함께 생성된다. 즉 정리하면 클래스 구조와 static field가 저장되는 곳이라고 볼 수 있다. 모든 스레드가 공유하는 영역이다.

  • Heap Area: 프로그램 상에서 데이터를 저장하기 위해 런타임 시에 동적으로 할당하는 메모리 공간이다. 이 공간은 JVM이 관리(GC의 스캔 대상)하며, new 연산자를 사용하여 만든 객체, 배열 등의 Reference type 객체가 저장된다. Heap 공간 또한 모든 스레드가 공유하는 공간이다. Heap 공간은 GC의 효율적인 스캔을 위해 young generation(eden, s0, s1), old generation 공간으로 나뉘는데, 자세한 것은 이 글에서 확인할 수 있다.

  • Stack Area: Stack은 int 같은 primitive type 변수들이 저장되는 공간으로 임시적으로 사용되는 변수나 정보들이 저장되는 공간이다. Method가 호출되면 Stack Frame(호출된 메소드를 위한 공간) 이 할당되는데 메소드 안에서 사용되는 변수들이 스택 공간 안에 쌓이게 된다. Method 정보는 해당 Method의 매개변수, 지역변수, 임시변수 그리고 어드레스(메소드 호출 한 주소)등을 저장하고 Method 종료 시에 스택 프레임이 사라진다.

  • Native Method Stack: 자바 외 언어로 작성된 네이티브 코드(C,C++ 코드)를 위한 메모리이다. native 메소드의 매개변수, 지역변수 등을 바이트코드로 저장한다. 자바 외의 언어(C, C++ 등)로 된 코드를 실행할 때 이 Native Method Stack이 할당되고, 자바 스택과 동일한 기준으로 'StackOverflowError'와 'OutOfMemoryError'가 발생한다.

모든 스레드가 공유하는 공간: Method Area, Heap Area
스레드마다 따로 생성되는 공간: Stack Area, PC Register, Native Method Stack

멀티 스레드 프로그램인 경우 Method Area와 Heap Area는 동시에 여러 스레드가 접근할 가능성이 있는데, 참조 자료형 객체들은 Heap 영역에, 클래스의 필드와 static 변수들은 Method Area에 저장돼있기에 여러 스레드가 동시 접근하여 의도치 않은 결과가 발생할 가능성이 있다.
따라서 멀티 스레드 환경에서는 thread-safe한 자료구조(또는 객체)를 사용하거나 앞으로 값이 바뀔 가능성이 희박하다면 Immutable Object를 쓰는 것이 좋다.

JNI (Java Native Interface)

JNI는 자바가 다른 언어로 만들어진 애플리케이션과 상호작용할 수 있는 인터페이스를 제공한다. JNI는 JVM이 Native Method를 적재하고 수행할수 있도록 한다.

Native Method Library

C, C++로 작성된 라이브러리를 칭한다. 만일 헤더가 필요하면 JNI는 이 라이브러리를 로딩해 실행한다.

Reference

https://inpa.tistory.com/entry/JAVA-%E2%98%95-JVM-%EB%82%B4%EB%B6%80-%EA%B5%AC%EC%A1%B0-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%98%81%EC%97%AD-%EC%8B%AC%ED%99%94%ED%8E%B8

https://steadiness.dev/jvm-basics/

https://www.holaxprogramming.com/2013/07/16/java-jvm-runtime-data-area/

https://hongsii.github.io/2018/12/20/jvm-memory-structure/

https://www.korecmblog.com/blog/jvm-stack-and-register

profile
블로그 이전하려고 합니다! 👉 https://onfonf.tistory.com 🍀

0개의 댓글