JVM은 Java Virtual Machine
의 약자로, 자바 가상 머신이라는 뜻을 가진다.
가상 머신이란 물리적 컴퓨터의 디지털 버전으로, 물리적으로 존재하는 컴퓨터가 아닌 다른 컴퓨터가 만들어낸 가상의 컴퓨터를 말한다.
자바는 OS에 종속적이지 않다는 특징을 가지고있는데, 이를 가능케 하려면 OS 위에서 자바를 실행시킬 무언가가 필요하다. OS에 종속받지 않으며 CPU가 자바를 인식하고 실행하게 하는 역할을 맡은게 자바 가상 머신, 즉 JVM이다.
Java의 소스코드(*.java)는 원시코드라고도 하는데, CPU는 이를 인식하지 못한다. 그래서 CPU가 인식할 수 있는 기계어로 컴파일을 해주어야 한다.
컴파일이란 사람이 이해할 수 있는 고수준 언어(C, Java 등)를 기계가 이해할 수 있는 저수준 언어인 기계어로 바꾸는 과정이다.
우선 이 원시코드(*.java)를 JVM이 인식할 수 있는 Java bytecode(*.class)로 변환한다. 이 작업은 Java compiler가 하며, Java compiler는 JDK를 설치하면 bin에 존재하는 javac.exe를 말한다.
즉 JDK를 설치하면 Java compiler가 포함되어 있으며, javac 명령어로 원시코드(*.java)를 Java bytecode(*.class)로 컴파일 할 수 있다.
하지만 변환된 Java bytecode는 기계어가 아니며, 단지 JVM이 인식할 수 있을 뿐이다. 이제 JVM이 이 변환된 Java bytecode를 OS가 이해할 수 있도록 해석하여 넘겨준다.
Bytecode를 각 OS에 맞게 해석하는 JVM 덕분에 Java code는 OS에 종속되지 않고 실행될 수 있는 것이다.
Bytecode란 VM에서 실행되는 프로그램을 위한 이진 표현법이다.
Java bytecode는 JVM이 이해할 수 있는 언어로 변환된 자바 소스코드를 의미한다.
자바 컴파일러에 의해 변환된 코드의 명령어 크기가 1Byte라서 자바 바이트 코드라고 불린다.
Javaa bytecode는 JIT 컴파일러에 의해 바이너리 코드로 변환된다.
JVM의 구성을 표현하면 아래와 같다.
자바 소스코드(Java source code)가 자바 컴파일러(Java compiler)를 통해 자바 바이트코드(Java bytecode)로 변환되는 과정을 지금까지 알아봤다.
이제 차근차근 구성 요소를 짚어보자.
자바는 동적 로드, 즉 컴파일 타임이 아닌 런타임에 클래스를 링크하고 로드하는 특징이 있다. 자바의 클래스 로딩은 클래스 참조 시점에 JVM에 코드가 링크되고, 실제 런타임 시점에 로딩되는 동적 로딩을 거친다.
클래스를 로딩한다는 것은 런타임 데이터 영역에 클래스를 저장하는 것이고, 클래스를 링크한다는 것은 로딩된 클래스 파일에 대해 검증, 준비, 해석의 세 단계를 거치는 것을 의미한다.
JVM은 모든 클래스에 대한 정보를 미리 메서드 영역에 로드하는 것이 아닌, 그 클래스가 사용될 때 로드하도록 해주는 소프트웨어다. 클래스 로더는 런타임 중에 JVM의 메소드 영역으로 자바 클래스를 동적으로 로드하는 역할을 한다.
클래스 로더의 내부 구조를 표현하면 아래와 같다.
자바 바이트 코드(*.class)를 메서드 영역에 저장한다.
자바 바이트코드는 JVM에 의해 메서드 영역에 로드된 클래스를 비롯한 그의 부모 클래스의 정보, 클래스 파일과 Class, Interface, Enum의 관련 여부, 변수나 메서드 등의 정보를 메서드 영역에 저장한다.
검증(Verify), 읽어 들인 클래스가 자바 언어 명세 및 JVM 명세에 명시된대로 잘 구성되어 있는지 검사한다.
준비(perpare),클래스가 필요로 하는 메모리를 할당하고, 클래스에 정의된 필드와 메서드, 인터페이스를 나타내는 데이터구조를 준비한다.
마지막으로 분석(resolve), 심볼릭 메모리 레퍼런스를 메서드 영역에 있는 실제 레퍼런스로 교체한다.
클래스 변수들(static 변수)을 적절한 값으로 초기화 한다. static 필드들이 설정된 값으로 초기화된다.
클래스 로더의 종류는 부트스트랩 클래스로더, 확장 클래스 로더, 어플리케이션 클래스 로더로 총 세가지가 있다.
부트스트랩 클래스 로더는 JVm 시작 시 가장 최초로 실행되는 클래스 로더이다. 부트스트랩 클래스 로더는 자바 클래스를 로드하는 것이 아닌 자바 클래스를 로드할 수 있는 자바 자체의 클래스 로더와 최소한의 자바 클래스들을 로드한다.
확장 클래스 로더는 부트 스트랩 클래스 로더를 부모로 갖는 클래스 로더이다. 부트스트랩 클래스 로더와는 달리 확장 자바 클래스들을 로드한다.
애플리케이션 클래스 로더는 자바 프로그램을 실행할 때 지정한 클래스패스에 있는 클래스 파일 또는 jar에 속한 클래스들을 로드한다.
쉽게말해 *.class 확장자 파일인 자바 바이트코드 파일을 로드한다.
시스템 클래스 로더라고도 불린다.
JIT 컴파일러는 프로그램을 실제 실행하는 시점에 기계어로 번역하는 컴파일러이다.
이 컴파일러는 JIT 컴파일 (Just In Time Compliation) 또는 동적 번역 (Dynamic teanslation)이라는 작업을 수행하는데, 인터프리터 방식의 단점을 보완하기 위해 도입된 인터프리터 방식으로 실행하다가 적절한 시점에 바이트 코드 전체를 컴파일하여 기계어로 변환하고, 이후에는 인터프리팅하지 않고 변환된 기계어로 실행한다.
기계어, 즉 컴파일된 코드는 태시에 저장되며 한 번 컴파일된 코드는 빠르게 수행하게 된다. 하지만 JIT 컴파일러가 바이트 코드를 컴파일하는 하는 과정이 바이트 코드를 인터프리팅하는 것보다 훠~얼씬 오래걸리므로 한 번만 실행되는 코드라면 컴파일하여 실행하는 것보다 인터프리팅하여 실행하는게 성능 상으로 유리하다.
JIT 컴파일러를 사용하는 JVM은 내부적으로 해당 메서드가 얼마나 자주 수행되는지 체크하며, 일정 정도를 넘을 때 컴파일을 수행한다.
자바에선 Java compiler가 자바 프로그램 코드를 바이트 코드로 변환하고, 실제 바이트 코드를 실행하는 시점에서 JVM이 바이트코드를 JIT 컴파일을 통해 기계어로 변환한다.
자바 바이트 코드 명령어를 하나씩 읽어서 해석하고 바로 실행한다.
JVM 안에서 자바 바이트코드느느 기본적으로 인터프리터 방식으로 동작한다.
프로그램을 개발하다 보면 유효하지 않은 메모리인 가비지(Garbage)가 발생하게 된다. C언어를 이용하면 free()라는 메서드를 통해 직접 이 메몰리를 비워줘야 하지만 Kotlin이나 Java를 이용해 개발하게 되면 신경쓰지 않아도 되는데, 그 이유는 JVM의 가비지 컬렉터가 불필요한 메모리를 알아서 정리해주기 때문이다.
보통 Java에서 쓸모없는 데이터를 표현할 때는 Null을 사용한다.
Person person = new Person();
person.setName("ani");
person = null;
ani 객체는 더이상 참조를 하지 않고 사용이 되지 않아 가비지가 되었다. Kotlin이나 Java에서는 이러한 메모리 누수를 방지하기 위해 가비지 컬렉터가 주기적으로 검사하여 메모리를 청소한다.
런타임 데이터 영역(Runtime data area)은 JVM의 메모리 영역으로 자바 애플리케이션을 실행할 때 사용되는 데이터들을 적재하는 영역이다.
메서드 영역은 JVM이 시작될 때 생성되는 공간으로 바이트 코드(*.class)를 처음 메모리 공간에 올릴 때 초기화되는 대상을 저장하기 위한 메모리 공간이다.
JVM이 동작하고 클래스가 로드될 때 적재되어 프로그램이 종료될 때까지 저장된다.
메서드 영역은 Class Area나 Static Area로도 불린다.
메서드 영역은 모든 쓰레드가 공유하는 영역이다. 그래서 멤버 변수의 이름과 데이터타입, 접근 제어자의 정보인 Field Info, 메서드 이름과 return 타입, 함수 매개변수 및 접근 제어자의 정보인 Method Info, Class인지 Interface인지 여부를 저장gkrh type의 속성 및 이름, Super Class 의 이름인 Type Info가 저장된다.
간단히 말해 메서드 영역은 정적 필드와 클래스 구조만을 가지게 된다.
메서드 영역에 존재하는 별도의 관리 영역이다. 상수 자료형을 저장하고 중복을 막는 역할을 한다.
각 클래스 및 인터페이스마다 별도의 constant pool 테이블이 존재하며 클래스를 생성할 때 참조해야 할 정보들을 상수로 가지고 있는 영역이다.
JVM은 이 Constant Pool을 통해 해당 메서드나 필드의 실제 메모리 상 주소를 찾아 참조한다.
힙 영역은 메서드 영역과 같이 모든 쓰레드가 공유하며, JVM이 관리하는 프로그램 상에서 데이터를 저장하기 위해 런타임 시 동적으로 할당되어 사용하는 영역이다.
즉 new 연산자로 생성되는 클래스와 인스턴스 변수, 배열 타입 등 Reference Type이 저장되는 곳이다.
Method Area 영역에 저장된 클래스만 생성이 되어 적재된다.
힙 영역에 생성된 객체와 배열은 Reference Type으로 후술한 JVM 스택 영역의 변수나 다른 객체의 필드에서 참조된다. 즉 힙의 참조 주소는 스택이 가지고 있으며 스택을 통해야만 힙 영역에 있는 인스턴스를 핸들링할 수 있다.
만일 참조하는 변수나 필드가 없다면 의미없는 객체로 판단하여 JVM은 GC를 실행시킨다.
이처럼 힙 영역은 CG의 대상이 되는 공간이다. 때문에 효율적인 GC를 실행하기 위해 세부적으로 5가지 영역으로 나눈다. 다음 두가지를 전제로 걸고!
정리하자면 객체는 대부분 일회성이며 메모리에 오랫동안 남아있을 이유는 드물다는 것이다. 따라서 객체의 생존 기간에 따라 물리적인 Heap 영역을 나누게 되었다.
Young 영역은 새롭게 생성된 객체가 할당되며 대부분의 객체가 이 영역에서 생성되었다가 사라진다. Young 역역에 대한 가비지 컬렉션을 Minor GC라고 부른다.
Old 영역은 Young 영역에서 일정 시간동안 살아남은 객체가 복사되는 영역이다. Young 영역보다 크게 할당되며 영역의 크기가 큰 만큼 가비지는 적게 발생한다. Old 영역에 대한 가비지 컬렉션을 Major GC 라고 부른다.
Old 영역은 Young 영역보다 상대적으로 크게 할당된다. 이유는 Young 영역의 수명이 짧은 객체들은 큰 공간을 필요로 하지 않고, 크기가 큰 객체들은 처음부터 Old 영역에 할당되기 때문이다.
다시 한번 설계 전제의 두 번째를 보자, 오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다. 이 조건을 불복하는 경우가 더러 있다. 이러한 경우를 대비하여 Old 영역에서 카드 테이블(card table)이 존재한다.
카드 테이블에는 Old 영역의 객체가 Young 영역의 객체를 참조할 때마다 그에 대한 정보를 담는다. 이는 Minor GC가 실행될 때 Old 영역에 존재하는 객체를 검사하여 참조되지 않는 Young 영역의 객체를 식별하는 것이 비효율적이기 때문에 Minor GC 가 실행될 때 카드 테이블만 조회하여 GC의 대상인지 식별하도록 하는 것이다.
Young 영역과 Old 영역은 서로 다른 메모리 구조로 되어있어 세부적인 동작 방식은 다르다. 하지만 기본적으로 가비지 컬렉션이 실행되면 아래의 공통적인 단계를 따른다.
Stop The World은 가비지 컬렉션을 실행하기 위해 JVM이 애플리케이션의 실행을 멈추는 작업이다. GC를 실행하는 쓰레드를 제외한 모든 쓰레드의 작업을 중단하고, GC가 완료되면 작업이 재개되는 것이다.
모든 쓰레드들의 작업이 중단되면 애플리케이션은 멈추게 된다(당연히). GC의 성능 개선을 위해 튜닝을 한다 -> Stop The World의 시간을 줄인다~를 말한다.
JVM에서도 튜닝을 위한 다양한 실행 옵션을 제공한다.
Mark and Sweep에서 Mark는 사용되는 메모리와 사용되지 않는 메모리를 식별하는 작업이며, Sweep은 Mark 단계에서 사용되지 않음으로 식별된 메모리를 해제하는 작업이다.
자세하게, 1번 단계에서 모든 작업이 중단죄면 GC는 스택의 모든 변수와 객체를 스캔하며 각각이 어떤 객체를 참고하고 있는지를 탐색한다. 그리고 사용되고 있는 메모리를 식별하는데, 이러한 단계를 Mark라고 한다. 이후 Mark가 되지 않은 객체들을 메모리에서 제거하고, 이러한 과정을 Sweep이라고 한다.
Young 영역은 Eden 영역 1개와 Survivor 영역 2개 총 3개로 나뉜다.
Eden 영역은 새로 생성된 객체가 할당되는 영역이고, Survivor GC 실행 후 최소 한 번 이상 살아남은 객체가 존재하는 영역이다.
객체가 계속 생성되어 Eden 영역이 가득 차게 되면 Minor GC 가 실행된다. 이때 Eden 영역에서 사용되지 않는 객체는 메모리가 해제되고, 사용되는 객체는 1개의 Survivor 영역으로 이동된다. 이 과정이 반복되다 1개의 Survivor 영역이 가득 차게 되면 또 다른 Survivor 영역으로 이동하게 되며 기존 객체가 할당됐던 Survivor 영역은 빈 상태가 된다. 이는 Survivor 영역 중 하나는 반드시 사용되어야 한다는 것을 의미하며, 만약 두 Survivor 영역에 모두 데이터가 있거나 없다면 현재 프로그램이 비정상적인 상황임을 알 수 있다.
Minor GC는 객체의 생존 횟수를 카운트한 age라는 값을 Object Header에 기록하는데, 이는 일정 횟수만큼 살아남은 객체를 Old 영역으로 이동시키기 위함이다.
Young 영역에서 살아님은 객체는 Old 영역으로 이동된다. Minor GC가 반복되며 Old 영역이 가득 차게 되면 비로소 Major GC가 실행된다. Young 영역은 Old 영역보다 크기가 상대적으로 작으므로 Minor GC가 0.5~1초 정도 걸리는 반면 Old 영역은 앞서 언급했듯 Young 영역보다 커다랗고, Young 영역을 참조할 수도 있어 Major GC가 Minor GC에 비해 10배 이상의 시간을 사용한다.
GC가 실행되면 GC가 실행되는 쓰레드가 아닌 다른 쓰레드들은 모두 중지되기 때문에 Major GC가 실행되면 사용자가 체감할 수 있을 만큼의 시간동안 프로그램이 멈춘다....(히히)
스택 영역은 int, long, boolean 등 기본 자료형을 생성할 때 저장되는 공간으로 임시적으로 사용되는 변수나 정보들이 저장되는 영역이다.
자료구조 Stack은 마지막에 들어온 값이 먼저 나가는 LIFO 구조로 push와 pop 으로 동작한다. 메서드 호출 시마다 각각의 스택 프레임이 생성되고, 메서드 안에서 사용되는 값들을 저장하고 호출된 메서드의 매개변수, 지역변수, 리턴값과 연산 시 일어나는 값들을 임시로 저장한다.
메서드 수행이 끝나면 프레임 별로 삭제된다.
스택 프레임은 메서드가 호출될 때마다 프레임이 만들어지며, 현재 실행중인 멤소드 상태 정보를 저장하는 곳이다. 메서드 호출 범위가 종료되면 스택에서 제거된다.
데이터의 타입에 따라 스택과 힙에 저장되는 방식이 다르다. 기본 타입 변수는 스택 영역에 직접 값을 가지는 반면 참조타입 변수는 힙 영역이나 메소드 영역의 객체 주소를 가진다.
스택 영역은 각 스레드마다 하나씩 존재하며 스레드가 시작될 때 할당된다. 프로세스가 메모리에 로드될 때 스택 사이즈가 고정되어 있기에 런타임 시에 스택 사이즈를 바꿀 수 는 없다.
프로그램 실행 중 메모리 크기가 충분하지 않다면 StackOverFlowError가 발생하게 된다.
쓰레드를 종료하면 런타임 스택도 사라진다.
PC 레지스터는 쓰레드가 시작될 때 생성되며, 현재 수행중인 JVM 명령어 주소를 저장하는 공간이다. JVM 명령의 주소는 쓰레드가 어떤 부분을 무슨 명령으로 실행해야 할 지에 대한 기록을 가지고 있다.
일반적으로 프로그램의 실행은 CPU에서 명령어를 수행하는 과정으로 이루어진다. 이때 CPU는 연산을 수행하는 동안 필요한 정보를 레지스터라고 하는 CPU 내의 기억장치에 저장하여 이용하게 된다.
하지만 자바의 PC Register는 위와 다르다. 자바는 OS와 CPU 입장에서는 하나의 프로세스이기 때문에 가상 머신의 리소스를 이용해야 한다. 그래서 자바는 CPU가 직접 연산을 수행하도록 하는 것이 아니라 현재 작업하는 내용을 CPU에게 연산으로 제공해야 한다. 이를 위한 버퍼 공간으로 PC Register라는 메모리 영역을 만들게 된 것이다.
따라서 JVM은 스택에서 비연산값을 뽑아 별도의 메모리 공간인 PC Register에 저장하는 방식을 취한다. 만약 스레드가 자바 메소드를 수행하고 있느면 JVM 명령의 주소를 PC Register에 저장한다. 만약 자바가 아닌 다른 언어(C언어나 어셈블리)의 메소드를 수행하고 있다면 undefined 상태가 된다.
왜냐?! 자바에서는 이 경우를 따로 처리하기 때문이다.
네이티브 메서드 스택은 자바 코드가 컴파일되어 생성되는 바이트 코드가 아닌 실제 실행할 수 있는 기계어로 작성된 작성된 걸 실행시키는 영역이다.
또한 자바 이외의 언어인 (C/C++, 어셈블리 등)으로 작성된 네이티브 코드를 실행하기 위한 공간이기도 하다.
사용되는 메모리 영역으로는 일반적인 C 스택을 사용한다.
JIT 컴파일러에 의해 변환된 Native Code도 여기서 실행된다.
일반적으로 메서드를 실행하는 경우 JVM 스택이 쌓이다가 해당 메서드 내부에 네이티브 방식을 사용하는 메서드가 있다면 해당 메서드는 네이티브 스택에 쌓인다. 그리고 네이티브 메서드의 수행이 끝나면 다시 자바 스택으로 돌아와 다시 작업을 수행한다.
그래서 네이티브 코드로 되어있는 함수의 호출을 자바 프로그램 내에서도 직접 수행ㅇ할 수 있고, 그 결과를 받아올 수도 있는 것이다.
JNI는 자바가 다른 언어로 만들어진 어플리케이션과 상호작용할 수 있는 인터페이스를 제공하는 프로그램이다.
JNI가 사용되면 네이티브 메서드 스택에 바이트 코드로 전환되어 저장되게 된다. 이렇듯 JNI는 JVM이 Native Method를 적재하고 수행할 수 있도록 한다.
하지만 실질적으로 제대로 동작하는 언어는 C/C++정도 밖에 없다고 한다.
C/C++로 작성된 라이브러리를 칭한다.
만일 헤더가 필요하면 JNI는 이 라이브러리를 로딩해 실행한다.
[JAVA] JVM이란? 개념 및 구조 (JDK, JRE, JIT, 가비지 콜렉터...)
☕ JVM 내부 구조 & 메모리 영역 💯 총정리
[Java] 클래스 로더란?
[Java] Garbage Collection(가비지 컬렉션)의 개념 및 동작 원리