자바를 이용하고 있음에도 불구하고 정작 나는 내부적으로 어떻게 돌아가는지에 대해 깊이 생각해본적이 없어 정리해보고자 한다. 😝
JDK(Java Development kit) 는 자바를 사용하기 위해 필요한 모든 기능을 갖춘 자바용 SDK다. JDK는 JRE를 포함하고 있고 JRE 외에도 컴파일러, jdb, javadoc 등과 같은 도구가 있다.
그럼 JRE는 뭘까? JRE(Java Runtime Environment) 는 JVM과 자바 클래스 라이브러리 등으로 구성되어 있다. 즉, 컴파일된 자바 프로그램을 실행하는데 필요한 패키지다.
출처: https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=saseo90&logNo=221322910150
(💡 참고로 JDK는 플랫폼마다 내부 동작 구조가 상이하다. 자바는 오라클만이 아닌 IBM, HP에서 만든 서버와 예전 Sun에서 만든 Solaris에는 각각 별도의 운영체제를 가지고 있기 때문에 운영체제에 맞는 JDK를 개발하여 사용하고 있어 각 벤더에 맞는 JDK 동작 구조가 다르다.)
JVM(Java Virtual Machine) 은 자바를 실행하기 위한 가상 기계다. 주 목적은 운영체제에 종속받지 않고 CPU가 자바를 인식하고 실행할 수 있게 하는 역할을 수행한다.
자바로 작성된 코드는 원시코드(.java) 라고 불리는데, 이 코드는 CPU가 인식을 하지 못하므로 기계어로 컴파일해주는 과정을 거치게 된다. 하지만 자바는 JVM이라는 가상 머신을 거쳐 운영체제에 도달하기 때문에 운영체제가 인식할 수 있는 기계어로 바로 컴파일 되는 것이 아닌 JVM이 인식할 수 있는 바이트 코드(.class) 로 변환된다. 즉, 자바 컴파일러가 ".java" 파일을 자바 바이트 코드로 이루어진 ".class" 파일로 변환한다.
변환된 바이트 코드는 기계어가 아니기에 운영체제에서 바로 실행되지 않는다. 하지만 이를 JVM이 운영체제가 바이트 코드를 이해할 수 있도록 해석해준다. 결론은 JVM 덕분에 운영체제에 관계없이 실행될 수 있다는 것이다.👊
바이트 코드는 JVM이 이해할 수 있는 언어로 변환된 자바 소스코드를 의미한다. 자바 컴파일러에 의해 변환된 코드의 명령어 크기가 1 바이트이기에 자바 바이트 코드라고 불린다. 바이트 코드는 다시 실시간 번역기 또는 JIT 컴파일러에 의해 바이너리 코드로 변환된다.
JIT(Just-In-Time)은 프로그램을 실제로 실행하는 시점에 기계어로 번역하는 컴파일러다. 프로그램을 보다 빠르게 하기 위해서 만들어졌으며 명칭은 컴파일러지만 실행시에 적용되는 기술이다.
실행할 때마다 컴퓨터가 알아들을 수 있는 언어로 변환하는 인터프리터 방식은 간편하지만 성능이 느리다. 반면 정적 컴파일 방식은 실행하기 전에 컴퓨터가 알아 들을 수 있는 언어로 변환하는 작업을 미리 실행하기 때문에 변환 작업은 한 번만 이루어진다. JIT은 이 두 가지 방식을 혼합한 것으로 변환 작업은 인터프리터에 의해 지속적으로 수행되지만, 필요한 코드의 정보는 캐시(메모리)에 담아두었다가 재사용하게 된다.
출처: https://m.blog.naver.com/ki630808/221844888233
JIT 컴파일러가 컴파일하는 과정은 바이트 코드를 인터프리팅하는 것보다 더 오래걸리므로 한 번만 실행되는 코드라면 컴파일하지 않고 인터프리팅하는 것이 유리하다. 따라서 JIT 컴파일러를 사용하는 JVM은 내부적으로 해당 메소드가 얼마나 자주 수행되는지 확인하고 일정 정도를 넣을 때에만 컴파일을 수행한다.
전체적인 흐름을 이어서 본다면, 자바에서는 자바 컴파일러가 자바 프로그램 코드를 바이트 코드로 변환한 다음, 실제 바이트 코드를 실행하는 시점에서 JVM이 바이트 코드를 JIT 컴파일을 통해 기계어로 변환한다.
용어에 대해 이해했다면, 이미지를 통해 전체적인 동작을 다시 정리해보자.
우리가 작성한 자바 코드 파일은(.java)은 자바 컴파일러에 의해 자바 바이트 코드 파일(.class)로 변환이 되고, 변환된 파일은 JVM에 의해 운영체제가 이해할 수 있는 기계어로 만들어 실행하는 것을 볼 수 있다.
그럼 이제 JVM의 내부를 살펴보자. 🔍
출처: https://limkydev.tistory.com/51
자바 바이트 코드는 클래스 로더에 의해 읽혀진다. 정확히 말하자면, 자바 클래스들은 시작 시 한번에 로드되지 않고, 애플리케이션에서 필요할 때 로드되는데, 이 때 클래스 로더는 런타임에 클래스를 동적으로 JVM에 로드하는 역할을 수행한다. 즉, 클래스 로더는 자바 바이트 코드를 읽어 JVM의 실행 엔진이 사용할 수 있도록 Runtime Data Area의 메소드 영역에 적재해준다.
클래스 로더의 내부 동작 과정은 다음과 같이 이루어진다.
static
변수 초기화출처: https://javatutorial.net/jvm-explained
로딩은 클래스 파일을 읽어서 바이너리 코드로 만들고 이를 메모리의 메소드 영역에 저장하는 과정이다. 저장하는 데이터는 다음과 같다.
로딩이 완료되면 해당 클래스 타입의 객체를 생성하여 메모리의 힙 영역에 저장한다. 로딩할 때는 상하 관계에 있는 클래스 로더들이 정해진 순서에 따라 클래스를 로딩하는데, 이를 Delegation Model(위임 모델) 이라고 한다.
위임 모델 동작 과정을 이해해보자.
0. ClassLoaderRunner에서 <code>loadClass()</code>로 Internal 클래스 로딩 요청
1. Application 클래스 로더에 로딩 요청 위임
2. Extension 클래스 로더에 로딩 요청 위임
3. Bootstrap 클래스 로더에 로딩 요청을 위임
4. 기본 라이브러리인 "rt.jar" 로딩
5. 외부 라이브러리인 "ext" 로딩
6. JVM이 프로그램을 실행할 때 클래스를 찾기 위한 기준이 되는 경로 "classPath" 로딩
7. 없을 경우 <code>ClassNotFoundException</code> 에러 발생
8. 전부 존재할 경우 Internal.class 타입의 객체를 생성하여 메모리 힙 영역에 저장
이 과정에서 보이는 3가지 클래스 로더는 뭘까? 간단하게 코드로 확인해보자.
public void printClassLoaders() throws ClassNotFoundException {
// 개발자가 직접 작성한 클래스 (PrintClassLoader)
System.out.println("해당 클래스의 클래스 로더 :"
+ PrintClassLoader.class.getClassLoader());
// "jre/lib/ext" 혹은 "java.ext.dir" 환경 변수로 지정된 폴더에 있는 클래스 (Logging)
System.out.println("Logging 클래스의 클래스 로더 :"
+ Logging.class.getClassLoader());
// "rt.jar"에 담긴 클래스 (ArrayList)
System.out.println("ArrayList 클래스의 클래스 로더 :"
+ ArrayList.class.getClassLoader());
}
링크 과정은 코드 내부의 레퍼런스를 연결하는 과정이다.
초기화 과정은 static
변수를 초기화하고 값을 할당하는 과정이다.
다음으로는 클래스 로더가 바이트 코드를 읽어 적재해두는 메모리 영역을 살펴보자. 메모리 영역은 크게 5개로 나뉜다.
메소드 영역은 클래스 정보를 처음 메모리 공간에 올릴 때 초기화되는 대상을 저장하기 위한 메모리 공간이다. 이 영역에는 Runtime Constant Pool이 존재하는데, 스태틱 영역에 존재하는 별도의 관리 영역으로 상수 자료형을 저장하고 참조하고 중복을 막는 역할을 수행한다.
힙 영역은 주로 긴 생명주기를 가지는 데이터들이 저장되는데, new
연산자로 생성되는 모든 객체와 배열을 저장한다. 힙 영역에 있는 오브젝트들을 가르키는 레퍼런스 변수는 스택 영역에 올라가게 된다.
즉, 힙 영역은 모든 JVM 스레드에 공유되는 공유 자원이며, 힙에 할당된 메모리는 GC에 의해 회수가 된다.
(💡 몇 개의 스레드가 존재하든 상관없이 단 하나의 힙 영역만 존재한다는 것을 기억하자!)
스택 영역은 LIFO로 동작하는 영역으로, 프로그램 실행 과정에서 임시로 할당되었다가 메소드 호출이 정상적으로 완료될 때, 예외가 던져질 때, 스레드가 종료될 때 바로 소멸되는 특성의 데이터를 저장하기 위한 영역이다. 대표적으로 변수나 임시 데이터, 스레드나 메소드의 정보를 저장한다. 메소드의 매개 변수, 지역 변수, 리턴 값 및 연산시 일어나는 값들을 임시로 저장한다.
(💡 각 스레드는 자신만의 스택 영역을 가진다는 것을 기억하자!)
스레드가 생성될 때마다 해당 스레드가 어떤 명령을 실행하게 될지에 대한 부분을 기록하는 메모리 공간이다. 각 스레드마다 하나씩 존재하며 현재 수행 중인 JVM 명령의 주소를 가진다.
JVM에서는 C, C++, 어셈블리어로 구축한 네이티브 함수를 네이티브 메소드라고 한다. 그리고 JNI(Java Native Interface) 는 네이티브 메소드를 호출할 수 있는 방법을 제공하는 인터페이스다.
네이티브 메소드를 사용하는 이유는 다음과 같다.
이렇게 네이티브 메소드를 저장한 것을 네이티브 메소드 라이브러리라고 한다.
그리고 네이티브 메소드 스택 영역은 자바가 아닌 다른 언어로 작성된 네이티브 메소드를 지원하기 위해 사용되는 스택 영역을 의미한다. 이 영역은 자바 프로그램이 컴파일되어 생성되는 바이트 코드가 아닌 실제 실행할 수 있는 기계어로 작성된 프로그램을 실행을 시킨다.
(💡 마찬가지로 스레드 단위의 자료구조임을 기억해두자!)
JVM에서 프로세스와 스레드는 뭘까? 바로 Runtime Data Area 영역과 깊게 연관되어있다.
우선 프로세스를 살펴보자. 하나의 Runtime Data Area가 하나의 프로세스를 의미하고 해당 프로세스가 여러개 있을 경우, 이를 멀티프로세스라고 한다.
출처: https://jerry92k.tistory.com/58
다음은 스레드를 살펴보자. 하나의 Runtime Data Area에 여러개의 스레드가 있을 경우를 멀티스레드라고 한다.
출처: https://jerry92k.tistory.com/58
실행 엔진은 클래스를 실행시키는 역할이다. 클래스 로더가 JVM 내의 런타임 데이터 영역에 바이트 코드를 배치시키고 이것은 실행 엔진에 의해 실행된다. 자바 바이트 코드로 이루어진 ".class" 파일은 기계가 바로 수행할 수 있는 언어보다는 비교적 인간이 보기 편한 형태로 기술된 것이다. 따라서 실행 엔진은 이와 같은 바이트 코드를 실제로 JVM 내부에서 기계가 실행할 수 있는 형태로 변경해준다.
인터프리터는 자바 바이트 코드를 명령어 단위로 읽어서 실행한다. 한 줄씩 수행하기 때문에 느리다.
앞서 언급했듯이, 인터프리터 방식으로 실행하다 적절한 시점에 바이트 코드 전체를 컴파일하여 기계어로 변경하고 이후에는 더 이상 인터프리팅하지 않고 기계어로 직접 실행해주는 컴파일러다.
GC에 대해서는 이 후에 다시 정리하겠지만, 간단히 말하자면 더 이상 사용되지 않는 인스턴스를 찾아 메모리에서 삭제해주는 역할을 수행한다.