Community

Java의 동작 원리와 JVM 구조

필자의 블로그 [커리어리 글을 작성하는 것이 다소 익숙하지 않기에, 아래 블로그로 가면 가독성이 더욱 좋을 것 입니다.] https://blog.naver.com/PostView.naver?blogId=gomets_journey&logNo=223305402218&categoryNo=28&parentCategoryNo=0&viewDate=&currentPage=2&postListTopCurrentPage=&from=postList&userTopListOpen=true&userTopListCount=5&userTopListManageOpen=false&userTopListCurrentPage=2 JVM이란? Java Virtual Machine의 줄임말 Java에서 프로그램을 실행하는 과정은 Class 파일을 컴파일하여 생성된 ByteCode를 JVM으로 로딩하고 해석하는 과정을 포함한다. JVM은 이 ByteCode를 JVM으로 로딩하고 해석하는 과정을 포함한다. JVM은 이 ByteCode를 실행하고 메모리 및 다른 리소스를 할당하며 관리한다. 또한, JVM은 멀티스레드 환경에서의 Thread 관리와 가비지 컬렉션(GC)과 같은 메모리 정리 작업을 수행하여 프로그램이 원할하게 실행될 수 있도록 한다. 이러한 과정을 통해 Java 프로그램은 안정적으로 실행되고 자원을 효율적으로 관리할 수 있게 된다. * Java Compiler * Java Compiler는 Java 소스코드(Java 파일)을 JVM이 이해하고 실행할 수 있는 Java Byte Code로 번역하는 역할을 한다. 이렇게 변환된 파일은 일반적으로 .class 확장자를 가진 파일로 저장된다. * Java Byte Code * Java Compiler에 의해 생성된 Java Byte Code는 JVM에서 실행 가능한 형태로 번역된 콛이다. 이 코드는 특정 운영체제나 하드웨어에 종속되지 않고, JVM 위에서 실행된다. * Class Loader * JVM 내부에서 Class Loader는 .class 파일들을 로드하고, 이를 Runtime Data Area에 배치하는 역할을 한다. 프로그램 실행 중에 필요한 클래스들을 동적으로 로드하여 사용할 수 있도록 준비한다. 즉, JVM이라는 프로세스가 프로그램을 수행하기 위해서 OS에서 할당받은 메모리 공간이다. * Execution Engine * Execution Engine은 Class Loader를 통해 로딩된 클래스 파일의 Bytecode를 해석하여 실행하는 역할을 한다. 이는 Bytecode를 해당 플랫폼에서 실행 가능한 기계어로 변환하고, 실행하는 과정을 수행한다. 이 과정에서 Interpreter 혹은 JIT 컴파일러 등을 사용하여 성능을 최적화할 수 있다. Execution Engine * Exectuion Engine은 Class Loader를 통해 JVM내에 Runtime Data Area에 배치된 바이트코드를 실행하는데 이때, 자바 바이트 코드를 명령어 단위로 읽어서 실행한다. 그런데 자바 바이트 코드는 기계가 바로 수행할 수 있는 기계어보다는 비교적 인간이 보기 편한 형태로 기술도니 것이다. 그래서 Execution Engine은 이와 같은 바이트 코드를 실제로 JVM 내부에서 기계가 실행할 수 있는 형태로 변경하며, 그 방식은 크게 두 가지가 있다. * Interpreter * Interpreter는 자바 바이트 코드를 명령어 단위로 읽고, 해당 명령어를 직접 실행하는 방식이다. 바이트 코드를 한 줄씩 해석하여 실행하므로 빠른 시작 속도를 제공하지만, 반복적으로 실행되는 코드의 경우 매번 해석해야 하므로 실행 속도가 느릴 수 있다. * JIT 컴파일러(Just In-Time Compiler) * JIT 컴파일러는 바이트 코드를 인터프리터로 해석하는 것이 아니라, 실행하기 전에 해당 코드를 기계어로 변환하여 캐시에 저장한다. 이후에 동일한 코드가 다시 실행될 때는 캐시된 기계어를 사용하여 실행한다. 이는 반복적으로 실행되는 코드의 성능을 향상시키며, 인터프리터의 성능 단점을 보완한다. Runtime Data Area의 구조 * Method Area(메소드 영역 또는 클래스 영역) * Method Area는 클래스에 대한 메타데이터 정보(클래스명, 메소드명, 변수명 등), 정적 변수(static fields), 상수(Constant pool) 등을 저장하는 공유 영역이다. 모든 스레드가 공유하며, JVM이 시작될 때 생성되고 프로그램이 종료될 때 소멸된다. 이 영역은 클래스 로더에 의해 로딩 된 클래스 정보가 저장되는 곳으로, 메모리 크기가 고정되어 있지 않아 OutOfMemoryError가 발생할 수 있다. * Heap Area(힙 영역) * Heap Area는 new 키워드로 생성된 객체, 인스턴스, 배열 등이 저장되는 공유 메모리 영역이다. 힙 영역은 모든 스레드가 공유하며, 가비지 컬렉션(GC)의 대상이 되는 메모리 공간이다. 객체의 동적 할당 및 해제가 이루어지는 곳으로, 크기나 구조가 동적으로 변할 수 있다. * Stack Area(스택 영역) * Stack Area는 각 스레드마다 별도로 존재하며, 메소드 호출 시 지역 변수, 매개변수, 메소드 수행 중 발생하는 임시 데이터 등을 저장한다. 메소드 호출 시마다 스택 프레임이 생성되며, 메소드의 실행이 완료되면 스택 프레임이 제거된다. LIFO(Last-In-First-Out) 구조를 가지고있다. * PC Register(프로그램 카운터 레지스터) * PC Register는 각 스레드마다 생성되며, 현재 수행 중인 JVM 명령의 주소값을 저장하는 곳이다. 스레드가 다음에 실행될 명령어의 주소를 가리키며, 스레드가 수행되는 동안 이동하면서 JVM 명령어를 실행한다. * Native Method Stack(네이티브 메서드 스택) * Native Method Stack는 Java 프로그램이 네이티브 코드(일반적으로 C 또는 C++ 등으로 작성된 코드)를 호출할 때 사용되는 스택 공간이다. 이 영역은 Java 외부의 다른 언어로 작성된 메서드를 호출할 때 사용되며, 네이티브 코드를 실행하기 위한 스택을 형성한다. Java Heap Java의 문제는 대부분 메모리 이슈에 집중되어 있다. 이는 자동으로 메모리를 해제해주는 Garbage Collection과 연관이 깊다. 이러한 이유들로 Java의 메모리 구조는 Heap 구조라고 오해를 하는 경우가 종종 있다. 하지만 Thread에서 Stack을 이용하고, Method Area를 통해 여러 정보들이 저장되기도 한다. Heap은 단지 Instance(Object)와 Array 객체 두 가지 종류만 저장되는 공간이고, 모든 Thread에서 공유되는 영역이다. 이때, 모든 Thread에서 공유되기 때문에 동기화 문제 또한 수반된다. JVM은 Java Heap에 메모리를 할당하는 Instruction (Bytecode로는 new, new array 등)만 존재하고, 해제를 위한 Java Code나 Bytecode는 존재하지 않는다. 메모리 해제는 오직 GC를 통해서만 수행된다. JVM 스펙은 이러한 원칙을 정하였고, JVM 벤더들은 이를 따라 각자의 구현방식으로 Heap의 구성과 GC를 구현한다. * 메모리 관리와 Garbage Collection * Java는 개발자가 명시적으로 메모리 할당과 해제를 관리하지 않고, Garbage Collector에 의해 더 이상 사용되지 않는 객체를 자동으로 탐지하고 해제한다. 이는 개발자가 메모리 누수(memory leaks)와 같은 문제를 피할 수 있도록 도와준다 * 메모리 구조 * Java의 메모리 구조는 Heap, Stack, Method Area 등으로 구성된다. 이 중에서도 Heap은 객체와 배열 등의 동적으로 할당된 데이터가 저장되는 공간으로, 모든 스레드에서 공유된다. Heap은 GC에 의해 관리되며, GC가 수행되는 동안 더 이상 참조되지 않는 객체는 메모리에서 해제된다. * 동기화 문제 * Heap은 여러 스레드에서 공유되므로, 여러 스레드가 동시에 접근하여 객체를 생성하거나 수정할 때 동기화 문제가 발생할 수 있다. 이를 위해 Java에서는 동기화 메커니즘을 제공하여 스레드 간의 안전한 접근을 보장한다. * JVM의 Garbage Collection * JVM(Java Virtual Machine)은 Garbage Collection을 수행하기 위한 메커니즘을 갖고 있다. JVM은 개발자가 코딩한 Java 프로그램의 Bytecode를 실행하며, Garbage Collector는 더 이상 참조되지 않는 객체를 검출하고 자동으로 해제한다. JVM 벤더들은 GC의 구현을 각자의 방식으로 수행한다. Java에서의 동기화 메커니즘 * synchronized 키워드 * synchronized 키워드는 메서드나 특정 블록을 임계 영역으로 지정하여 한 번에 하나의 스레드만 접근할 수 있도록 한다. 이를 통해 여러 스레드가 동시에 공유 자원에 접근하는 것을 막고, 상호 배제를 보장하여 스레드 간의 안전한 접근을 가능하게 한다. * ReentrantLock 및 ReentrantReadWriteLock * ReentrantLock과 ReentrantReadWriteLock은 Lock 인터페이스를 구현한 클래스로, 더 세밀한 동기화 제어를 제공한다. * ReentrantLock : 임계 영역을 블록화하는 데 사용 * ReentrantReadWriteLock : 읽기와 쓰기 작업을 다르게 관리하여 읽기 작업이 빈번한 상황에서 성능을 향상시킬 수 있다. * volatile 키워드 * volatile 키워드는 변수와 가시성을 보장하기 위해 사용된다. 변수가 volatile로 선언되면 메모리에서 직접 읽고 쓰며, 스레드 간의 값을 동기화하여 캐싱되는 문제를 해경한다. 대표적인 JVM의 Garbage Collection 알고리즘 * Serial Garbage Collector * Serial GC는 단일 스레드로 동작하며, Young Generation과 Old Generation으로 구성된 힙을 관리한다. Young Generation에서는 대부분의 객체가 생성되고, Minor GC가 주로 발생한다. Old Generation은 오랫동안 살아남은 객체들이 있는 영역으로, Major GC 또는 Full GC가 수행된다. * Parallel Garbage Collector * Parallel GC는 병렬 처리를 활용하여 GC를 수행하며, 주로 멀티코어 CPU에서 성능을 향상시키는 데 사용된다. Young Generation과 Old Generation 모두 병렬로 GC 작업을 수행하며, 여러 스레드를 사용하여 GC를 병렬로 실행한다. * CMS(Concurrent Mark-Sweep) Garbage Collector * CMS GC는 Stop-The-World 시간을 줄이기 위해 Young Generation은 병렬 GC 방식으로 처리하고, Old Generation에서는 대부분의 작업을 멈추지 않고(GC 중에도), 일부 작업을 병렬 또는 동시에 처리한다. 이를 통해 응답성이 중요한 애플리케이션에서 유용하다. * G1 (Garbage-First) Garbage Collector * G1GC는 대규모 힙에 대한 성능을 향상시키기 위해 설계되었다. 힙을 여러 개의 영역(Region)으로 분할하고, Young Generation과 Old Generation 모두를 이러한 영역으로 관리한다. 이를 통해 힙 전체를 조각화하지 않고도 GC를 수행할 수 있다. * Z Garbage Collector * Z GC는 OpenJDK 11부터 소개되었으며, 성능과 응답성을 향상시키기 위해 개발되었다. G1GC와 비슷한 접근 방식을 가지고 있지만, GC의 일시 중단 시간을 짧게 유지하고 최적화된 알고리즘을 적용하여 최신의 GC 알고리즘 중 하나이다. Method Area Method Area는 JVM의 메모리 영역 중 하나로, JVM이 클래스 정보, 인터페이스, Runtime Constant Pool, 메서드, 필드, Static 변수, 그리고 메서드의 바이트 코드 등을 보관하는 공간이다. 이 영역은 클래스 로딩 시 해당 클래스와 인터페이스에 대한 메타데이터를 저장하고 관리한다. 또한, Method Area에 있는 클래스 정보를 기반으로 Heap 영역에 객체를 생성한다. 이 과정에서 클래스의 인스턴스화 및 객체의 메모리 할당이 이루어진다. JVM에서 Method Area는 Permanent Generation(PermGen) 또는 Java 8 이후에는 Metaspace로 불린다. 이전에는 PermGen 영역이 클래스 로딩과 메타데이터에 사용되는 공간으로 사용되었으며, 해당 영역의 크기는 XX:MaxPermSize와 같은 JVM 옵션으로 조정할 수 있었다. 그러나 Java 8에서는 Metaspace가 PermGen을 대체하면서 Native 메모리를 사용하여 클래스 메타데이터를 관리한다. Metaspace는 동적으로 크기를 조정하며, 네이티브 메모리를 사용하기 때문에 고정 크기의 제한이 없다. 또한, Metaspace는 가비지 컬렉션 대상이 아니므로 클래스 로딩이 많아도 OutOfMemoryError가 발생할 가능성이 줄어든다. Runtime Constant Pool Runtime Constant Pool은 JVM이 클래스 파일 포맷에서 constant pool 테이블에 해당하는 영역으로, 클래스와 인터페이스의 상수 뿐만 아니라 메서드와 필드에 대한 레퍼런스를 담고 있는 중요한 영역이다. 이 영역은 메서드 영역에 포함되어 있지만, JVM에서 핵심적인 역할을 수행한다. 클래스 파일의 constant pool 테이블은 JVM이 클래스를 로드할 때 메모리에 올라가는데, 이는 클래스 파일에 포함된 상수, 문자열, 메서드 및 필드의 이름, 타입, 심지어는 메서드나 필드를 참조하는 레퍼런스도 포함한다. 런타임 상수 풀은 메서드나 필드를 참조할 때 중요한 역하을 한다. JVM은 런타임 중에 클래스와 인터페이스의 상수 뿐만 아니라 메서드나 필드에 대한 레퍼런스를 찾기 위해 런타임 상수 풀을 사용한다. 이를 통해 JVM은 실제 메모리상에서 해당 메서드나 필드의 주소를 찾아 참조할 수 있다. 즉, 런타임 상수 풀은 클래스 파일에 포함된 상수들과 클래스의 구조 정보를 담고 있으며, JVM이 실행 중에 이를 활용하여 클래스의 로딩, 메서드나 필드의 참조 등을 수행한다. 이는 JVM이 클래스를 실행하는 데 필요한 핵심적인 정보를 제공하며, 프로그램의 실행에 필수적인 역할을 한다. HotSpot JVM이란? HotSpot JVM은 Longview Technologies LLC에서 처음 발표되어, 이후 Sun Microsystems에 인수되어 SUN의 주요 JVM으로 발전해온 것으로, 현재는 가장 널리 사용되는 JVM 중 하나이다. HotSpot JVM에서는 핫스팟이라는 영역을 프로파일링하여 해당 영역에서 JIT(Just-In-Time) 컴파일러를 사용하는 방식으로 동작한다. 이는 프로그램 실행 중에 빈도가 높은 영역(핫스팟)을 프로파일링하고, 해당 부분에 대한 네이티브 코드를 생성하여 성능을 향상시킨다. 이때 HotSpot JVM의 네이티브 코드 생성 방식에는 Client와 Server라는 두 가지 방식이 있다. HotSpot JVM - Client Compiler 클라이언트 모드에서 동작하는 컴파일러는 프로그램 시작 시간을 최소화하는데 집중한다. 주요 단계는 다음과 같다. 1. 바이트 코드를 해석하여 정적 바이트코드 표현인 HIR을 생성한다. 2. HIR에서 플랫폼에 종속적인 중간 표현식(LIR)을 생성한다. 3. LIR을 활용하여 기계어를 생성한다 * 클라이언트 모드 JIT 컴파일러는 바이트 코드로부터 최대한 많은 정보를 추출하여 코드 블록의 최적화에 집중하며, 전체적인 최적화에는 관심을 두지 않는다. HotSpot JVM - Server Compiler 서버 모드의 JIT 컴파일러는 전체적인 성능 최적화에 초점을 맞춘다. 주요 단계는 다음과 같다. 1. 일반적인 컴파일러 최적화 기술을 사용하여 코드를 최적화한다. (죽은 코드 삭제, loop 변수의 끌어올리기, 공통 부분식 제거, 상수 지연, 전역 코드 이동 등) 2. 자바에 특화된 최적화를 수행한다. (Null Check 삭제, 배열의 Range Check 삭제, 예외처리 경로 최적화, 대단위 RICS 레지스터 활용 등) 서버 모드 JIT은 부분적인 코드 실행보다는 전체적인 성능 최적화에 관심이 있으며, 이를 위해 일반적인 최적화 기법과 자바에 특화된 최적화를 조합하여 성능을 향상시킨다.

알림

알림이 없습니다