[Java] JVM / 자바 프로그램의 실행흐름
- 9 minsDescription:
자바 프로그램이 JVM에서 어떻게 실행되는지, 어떤식으로 메모리를 사용하는 지 알아보자.
Java 프로그래밍을 하기위한 구성요소
Java 프로그래밍을 하기위해선 JDK(Java Development Kit) 가 필요하다. JDK는 자바 프로그램 구동을 위한 요소(JRE)와 자바 프로그램 개발을 위한 요소(javac 및 각종 개발 관련 library)을 포함한 패키지를 지칭한다.
자바 프로그램 실행은 JRE만 있어도 된다.
- JDK (Java Development Kit)
- JRE와 개발용 라이브러리들을 포함하고 있다.
- JRE (Java Runtime Environment)
- JVM이 자바 프로그램을 실행하기위해 필요한 라이브러리와 파일들을 갖고있다.
- JVM (Java Virtual Machine)
- 자바가상머신
- JAVA 프로그램용 운영체제(OS 역할)
- .java(소스코드)로부터 컴파일된 .class(자바 바이너리파일)을 실행한다.
- 자바 바이너리 파일을 플랫폼에 관계없이 독립적으로 실행 가능하도록 JVM이 각 OS 위에서 동작한다.
- JVM은 각 플랫폼에 의존한다.(Window, Mac, Linux, etc)
JVM의 구조
Class Loader
자바프로그램을 실행하면 .java
파일은 컴파일러를 통해 .class
(자바 바이트코드)파일로 변환된다. Class Loader
는 해당 파일을 Runtime(실행)하는 시점에 Runtime Data Areas
에 로딩시킨다. 즉, 컴파일하는 시점이 아니라 클래스를 처음으로 참조하는 시점(클래스의 인스턴스를 만들 때) Class Loader
를 통해 메모리(Runtime Data Areas)에 로드한다.
이 때 모든 Class는 참조되는 순간 동적 으로 load와 link가 이루어진다. 이 방식을 Dynamic Loading이라고 한다.
Runtime Data Areas
런타임 데이터 영역은 JVM이 OS 위에서 실행될 때 할당받는 메모리 영역이다.
- PC Register
- JVM Stack
- Native Method Stack
모든 Thread가 공유- Heap
- Method Area >> Runtime Constant Pool
- PC Register
현재 수행중인 JVM 명령의 주소가 저장되어있다. CPU의 Register와는 다름, 연산을 위해 필요한 피연산자를 임시로 저장하기 위한 용도로 사용한다.
- JVM Stack
Stack Frame을 저장하는 스택. JVM은 오직 Stack Frame을 push하고 pop하는 작업만 함. 예외 발생 시 printStackTrace()
등의 메서드로 보여주는 Stack Trace의 각 라인은 하나의 스택 프레임을 표현
-
Stack Frame(스택프레임) : 메서드가 수행될 때마다 하나의 스택 프레임이 생성되어 해당 스레드의 JVM 스택에 추가되고 메서드가 종료되면 스택 프레임이 제거된다. 각 스택 프레임은 Local Variable Array, Operand Stack, 현재 실행 중인 메서드가 속한 클래스의 Runtime Constant Pool 에 대한 레퍼런스를 갖는다. 지역 변수 배열, 피연산자 스택의 크기는 컴파일 시에 결정되기 때문에 스택 프레임의 크기도 메서드에 따라 크기가 고정된다.
-
Local Variable Array(지역변수 배열) : 0부터 시작하는 인덱스를 가진 배열. 0은 메서드가 속한 클래스 인스턴스의 this 레퍼런스이고, 1부터는 메서드에 전달된 파라미터들이 저장되며, 메서드 파라미터 이후에는 메서드의 지역 변수들이 저장된다.
-
Operand Stack(피연산자 스택) : 메서드의 실제 작업 공간. 각 메서드는 피연산자 스택과 지역 변수 배열 사이에서 데이터를 교환하고, 다른 메서드 호출 결과를 push하거나 pop한다. 피연산자 스택 공간이 얼마나 필요한지는 컴파일할 때 결정할 수 있으므로, 피연산자 스택의 크기도 컴파일 시에 결정된다.
- Native Method Stack
자바 외의 언어로 작성된 네이티브 코드를 위한 스택이다. 즉, JNI(Java Native Interface)를 통해 호출하는 C/C++ 등의 코드를 수행하기 위한 스택으로, 언어에 맞게 C 스택이나 C++ 스택이 생성된다.
- Heap
인스턴스(객체)를 저장하는 공간. GC(Garbage Collection)의 대상이다. 성능 이슈를 일으키는 공간
- Method Area
JVM이 읽어 들인 각각의 클래스와 인터페이스에 대한 Runtime Constant Pool, 필드와 메서드 정보, Static 변수, 메서드의 바이트코드 등을 보관한다. 이 영역에 있는 클래스 정보로 Heap 영역에 객체를 생성한다. 즉, 매우 중요한 영역!
메서드 영역은 JVM 벤더마다 다양한 형태로 구현할 수 있으며, 오라클 핫스팟 JVM(HotSpot JVM)에서는 흔히 Permanent Area, 혹은 Permanent Generation(PermGen)이라고 불린다. 메서드 영역에 대한 가비지 컬렉션은 JVM 벤더의 선택 사항이다.
- Runtime Constant Pool(런타임 상수 풀) : 클래스 파일 포맷에서 constant_pool 테이블에 해당하는 영역이다. Method Area 에 포함되는 영역이긴 하지만, JVM 동작에서 가장 핵심적인 역할을 수행하는 곳이기 때문에 JVM 명세에서도 따로 중요하게 기술한다. 각 클래스와 인터페이스의 상수뿐만 아니라, 메서드와 필드에 대한 모든 레퍼런스까지 담고 있는 테이블이다. 즉, 어떤 메서드나 필드를 참조할 때 JVM은 런타임 상수 풀을 통해 해당 메서드나 필드의 실제 메모리상 주소를 찾아서 참조한다.
Execution Engine
Execution Engine 은 Class Loader
를 통해 JVM 내의 Runtime Data Areas
에 배치된 바이트코드를 실행한다. Execution Engine
은 자바 바이트코드를 명령어 단위로 읽어서 실행한다
그런데 자바 바이트코드는 기계가 바로 수행할 수 있는 언어보다는 비교적 인간이 보기 편한 형태로 기술된 것이다. 그래서 실행 엔진은 이와 같은 바이트코드를 실제로 JVM 내부에서 기계가 실행할 수 있는 형태로 변경하며, 그 방식은 다음 두 가지가 있다.
-
Interpreter : 바이트코드 명령어를 하나씩 읽어서 해석하고 실행한다. 하나씩 해석하고 실행하기 때문에 바이트코드 하나하나의 해석은 빠른 대신 인터프리팅 결과의 실행은 느리다는 단점을 가지고 있다. 흔히 얘기하는 인터프리터 언어의 단점을 그대로 가지는 것이다. 즉, 바이트코드라는 ‘언어’는 기본적으로 인터프리터 방식으로 동작한다.
-
JIT(Just-In-Time) Compiler : 인터프리터의 단점을 보완하기 위해 도입된 것이 JIT 컴파일러이다. 인터프리터 방식으로 실행하다가 적절한 시점에 바이트코드 전체를 컴파일하여 네이티브 코드로 변경하고, 이후에는 해당 메서드를 더 이상 인터프리팅하지 않고 네이티브 코드로 직접 실행하는 방식이다. 네이티브 코드를 실행하는 것이 하나씩 인터프리팅하는 것보다 빠르고, 네이티브 코드는 캐시에 보관하기 때문에 한 번 컴파일된 코드는 계속 빠르게 수행되게 된다.
JIT 컴파일러 가 컴파일하는 과정은 바이트코드를 하나씩 인터프리팅하는 것보다 훨씬 오래 걸리므로, 만약 한 번만 실행되는 코드라면 컴파일하지 않고 인터프리팅하는 것이 훨씬 유리하다. 따라서, JIT 컴파일러를 사용하는 JVM들은 내부적으로 해당 메서드가 얼마나 자주 수행되는지 체크하고, 일정 정도를 넘을 때에만 컴파일을 수행한다.
GC
Garbage Collection은 메모리 관리를 위한 방법 중의 하나로, 프로그램이 동적으로 할당했던 메모리 영역 중에서 필요없게 된 영역[Heap]을 해제하는 기능. (new 연산자를 이용한 객체)
Java GC는 객체가 garbage인지 판단하기 위해 reachability 개념을 사용하여 객체의 유효한 참조가 있다면 ‘reachable’ 로 없으면 ‘unreachable’ 로 구별하고, unreachable 객체를 Garbage로 간주하여 GC를 실행한다. 한 객체가 다른 객체를 참조하며, 다른 객체는 또다른 객체를 참조할 경우에는 유효한 최초의 참조가 무엇인기 파악해야 되는데, 이를 객체 참조의 root set이라고 한다.
Heap
영역에 있는 객체들에 대한 참조의 종류는 4가지이다.
- Heap 내의 다른 객체에 의한 참조
- JVM Stack, Java 메서드 실행 시에 사용하는 지역 변수와 파라미터들에 의한 참조
- Native Stack, JNI(Java Native Interface)에 의해 생성된 객체에 대한 참조
- Method Area 의 static 변수에 의한 참조
이들 중 Heap 내의 다른 객체에 의한 참조를 제외한 나머지 3개(JVM Stack, Native Stack, Method Area)가 root set으로, reachability를 판가름하는 기준이 된다. 즉 root set으로부터 시작한 객체들은 reachable이며, root set과 무관한 객체들이 unreachable 객체로 GC의 대상이 된다.
자바프로그램의 실행흐름
JVM의 메모리 구조는 한번에 이해하기 너무 어렵기 때문에, 이해하기 쉽도록 추상화하여 코드실행영역(Runtime Engine을 추상화)과 T 메모리 영역(Runtime Data Areas를 추상화) 이라 지칭하겠다.
T 메모리구조는 “스프링 입문을 위한 자바 객체지향의 원리와 이해”의 저자 김종민님이 설명을 위해 추상화시킨 용어
코드 실행 영역(Runtime Engine)은 자바 개발자들이 알 필요는 없다.(초보 기준) 데이터 저장 영역(Runtime Data Areas)을 알아야한다.
T 메모리 구조
static
영역 : Runtime Data Areas의 Method Areastack
영역 : Runtime Data Areas의 JVM Stackheap
영역 : Runtime Data Areas의 Heap
실행흐름
public class Start4 {
public static void main(String[] args) {
int k = 5;
int m;
m = square(k);
}
private static int square(int k) {
int result;
k = 25;
result = k;
return result;
}
}
- JRE가 자바프로그램을 실행시킬 때 맨처음으로 main()를 찾는다.
- 있으면
Class Loader
가 목적파일(.class)을 실행시킴 static 영역
에 java.lang 패키지, import한 패키지들을 위치시킨다. 프로그램에 있는 모든클래스
와필드
,메소드
가 올라감stack
영역에main()
의Stack Frame
이 위치하고 변수 영역에 인자를 위치시킨다.- 지역변수의 경우 선언이 아닌 초기화 될 때 위치된다.
- 클래스 선언 {}을 제외하고 메소드의 {}, if의 {}이 생길때 마다
Stack Frame
이 생긴다.
- 메소드를 실행
- ”}”괄호를 만나게 되면
Stack Frame
이 사라진다. - JRE는 JVM을 종료시키고 위치했던 메모리들이 모두 없어진다.
두 개의 Stack Frame 사이는 서로 접근할 수 없다. 단 스택프레임안에 내부에 스택프레임이 생성된다면 내부에서 외부 스택프레임으로 접근할 수 있다.(메소드 안에 또 if 블록이 있을 경우)
tip : 멤버변수는 초기화 해주는데 왜 지역변수는 초기화 해주지 않을까? 멤버변수는 어디서 먼저 쓸지 모르고 지역변수는 해당지역에서 쓰기때문에 거기서 초기화해서 쓰라는 것이다.
메모리상 변수의 위치
public Tester {
static int classInt = 10;
int instanceInt;
Tester() {
this.instanceInt = 20;
}
int getLocalInt() {
int localInt = 30;
return localInt;
}
}
public Main {
public static void main(String[] args) {
Tester newTester = Tester();
System.out.println(Tester.classInt); // 10
System.out.println(newTester.instanceInt); // 20
System.out.println(newTester.getLocalInt()); // 30
}
}
- 로컬 변수(localInt)는
stack 영역
(JVM Stack)에 위치한다. 그리고Stack Frame
이 사라지면 함께 사라진다. - 클래스 변수(classInt)는
static 영역
(Method Area)에 위치한다. 그리 JVM이 종료될 때 사라진다. (혹 JVM에 따라 GC가 알아서 처리할 수 있음) - 인스턴스 변수(instanceInt)는
heap 영역
(Heap)에 위치한다. GC가 힙 메모리 회수 시 사라진다.
멀티스레드와 멀티프로세스
멀티스레드
멀티스레드는 stack 영역
(JVM stack)을 스레드 갯수만큼 분할
- Spring Framework는 클라이언트의 요청 만큼 스레드를 만든다.
- 공유영역(
static
,heap
)에 있는 값들을 스레드별로 변경하려다 문제가 생길 수 있으니 주의해야한다.
멀티프로세스
멀티프로세스는 데이터 저장영역
(Runtime Data Areas)을 프로세스 갯수만큼 분할
- 공유영역에 있는 값들이 변할 위험이 줄어들어 안정성은 높아지지만, 그만큼 메모리 사용량이 늘어난다.