Java를 이용하여 개발을 할 때 JDK, JRE, JVM을 많이 들어봤을 것이다. 추상적으로는 알고 있지만 더이상 추상적으로 아는 것이 아니라 정확히 알기 위해 정리한다.
오늘은 그 중에서 JVM에 대해 알아보기를 원한다.
JVM (Java Virtual Machine)은 자바 바이트코드를 해석하고 실행하는 역할을 한다.
JVM의 역할은 자바 애플리케이션을 클래스 로더(Class Loader) 를 통해 읽어들여서 자바 API와 함께 실행하는 역할을 한다는 것이다.
JVM을 사용하게 되면 자바를 OS에 독립적으로 실행될 수 있도록 추상층을 제공해준다. 이해하기 편하게 말하면 JVM이 바이트 코드를 OS에 맞게 변환하여 준다는 것이다.
.java
파일을 생성하여 컴파일러(compiler)를 이용해 바이트코드로 변환을 시켰을 때 OS는 바이트 코드를 바로 읽지 못한다.
그렇기 때문에 JVM이 OS와 바이트 코드 사이에서 OS가 바이트코드를 읽을 수 있도록 내부에서 인터프리터, JIT Compiler 를 이용하여 OS가 읽을 수 있도록 해석해주는 역할을 한다. (인터프리터 및 JIT는 아래에서 다루겠다.)
위와 같은 과정을 통하여 자바가 OS에 종속적이지 않고 JVM이 있는 환경에서 .class
파일만 있다면 실행이 가능한 이유다. (자바에만 한정되는 것이 아닌 다른 JVM언어들도 동일하다.)
JVM 내부는 아래와 같이 되어있다.
참조: 나무위키-JVM
자바의 동적 클래스 로딩기능은 클래스 로더 시스템(class loader system)에 의해 처리된다.
즉 .class 파일을
컴파일 타임이 아닌 클래스를 처음 참조하는 런타임 에 클래스 파일을 로딩, 연결, 초기화 하는 작업이 이루어진다.
로딩은 클래스 로더 시스템의 가장 첫번재 순서이며 이 안에서도 3단계로 나뉘어 진다.
클래스 로더의 흐름은 다음과 같다.
.class
파일이 존재하면 해당 파일을 읽는다.
JVM의 클래스 로더에서 .class
내용에 따라 적절한 바이너리 데이터를 만든다.
해당 .class
에 대한 정보를 JVM 내부의 메소드 영역에 저장한다.
클래스로더 중 최상위 클래스로더이다.
JAVA_HOME/jre/lib/rt.jar
에 담긴 JDK class파일들을 로딩한다. Native C로 구현이 되어 있다.
익스텐션 클래스로더는 jre/lib/ext
폴더나 java.ext.dirs
환경 변수로 지정된 폴더에 있는 class파일을 로딩한다.
개발자가 애플리케이션 구동을 위해 직접 작성한 대부분의 클래스는 이 애플리케이션 클래스로더에 의해 로딩된다.
참조: 뒤태지존의 끄적거림 - Java class로더 훑어보기
클래스를 읽어들일 때 JVM은 내부적으로 위임 원칙(Delegation Principle)을 사용한다.
위임 원칙은 클래스 로딩이 필요할 때 3가지 기본 클래스 로더의 윗방향으로 클래스 로딩을 위임하는 것을 이야기하는데 위의 그림을 정리하면 다음과 같다.
class 로딩 요청이 들어오면 기본 클래스로더가 상위로 계속해서 위임을 하여 부트스트랩 클래스 로더까지 올라간다.
이 후 위에서 부터 찾지 못하면 부트스트랩 -> 익스텐션 -> 애플리케이션까지 내려가게된다.
이렇게 위로 올라갔다가 아래까지 다시 내려왔을 때에도 class를 찾지 못하면 런타임 예외인 java.lang.ClassNotFoundException
이 발생하게 된다.
링크에도 총 3가지 단계가 존재한다.
.class
파일의 정확성을 보장합니다. 즉, 해당 클래스 파일이 올바른 형식으로 지정되어 있는지, 컴파일러에 의해 생성되었는지에 대한 여부를 확인한다.
이 단게에서 검증에 실패하게 되면 java.lang.VerifyError
가 발생하게 된다.
클래스, 스태틱 변수에게 필요한 메모리를 준비한다.
즉 메모리를 할당하고 기본값으로 초기화 하는 과정을 진행한다. (method 영역에 준비)
심볼릭 메모리 레퍼런스를 실제 메모리 인스턴스 레퍼런스로 교체하는 작업을 진행한다.
.class
파일이 JVM에 올라가게 되면 심볼릭 레퍼런스는 그 이름에 맞는 객체의 주소를 찾아 연결하는 작업을 수행하게 된다.
심볼릭 레퍼런스: 참조하는 클래스의 특정 메모리 주소를 참조 관계로 구성한 것이 아니라, 참조하는 대상의 이름만을 지칭한 것이다.
클래스 로딩의 마지막 단계이며 링크 단계에서 준비한 메모리에 스태틱 변수의 값을 할당하게 된다. (method영역에 준비된 메모리에 값이 할당)
JVM 스팩에서 정의된 메모리 구조는 아래와 같다.
메소드 영역에는 정적 변수를 포함하여 클래스 이름, 직계 부모 클래스 이름, 메서드 및 변수 정보 등과 같은 class 수준의 정보가 저장된다.
JVM 내부에서 메서드 영역은 하나만 존재하며 메서드 영역의 자원을 공유 한다. (GC의 관리 대상이 아니다.)
힙 영역에는 인스턴스화 된 클래스들을 저장하게 된다.
메서드 영역과 동일하게 JVM당 하나만 존재하며 힙 영역의 자원을 공유 한다.
LIFO(Last In First Out)방식으로 동작하며, 쓰레드 마다 스택을 생성하게 된다.
간단하게 이야기 하면 쓰레드 마다 method call stack을 만든다고 생각하면 된다.
쓰레드의 현재 실행 명령의 주소(IP - Instruction point)를 저장한다.
쓰레드 마다 PC 레지스터 를 가지고 있다.
모든 쓰레드에 대해 별도의 네이티브 메서드 스택이 생성되며 자바가 아닌 다른 언어로 작성 된 네이트브 메서드를 지원하기 위해 사용되는 스택이다.
런타임 영역에 할당 된 바이트 코드는 실행 엔진에 의하여 실행된다. 실행 엔진은 바이트 코드를 읽고 조각별로 실행을 한다.
.java
파일을 컴파일하여 .class
로 변환되어 바이트 코드를 생성하여도 아직 기계어가 아니기 때문에 바로 사용을 할 수 없다.
자바는 컴파일 된 .class
파일을 인터프리터를 이용하여 한번 더 변환작업을 거치게 된다.
인터프리터 방식은 컴파일 타임이 아닌 런타임 시 코드를 한줄 한줄 읽어나가며 변환을 하는 것이기 때문에 성능상 느린것은 사실이다. (컴파일 언어는 컴파일 시 한번에 기계어로 변환을 하기 때문에 인터프리터 언어보다 빠르다.)
여기서 인터프리터의 단점을 보완하기 위해 JVM은 내부적으로 JIT컴파일러를 가지고 있다.
실행 엔진은 바이트 코드를 변환할 때 인터프리터의 도움을 받지만 읽어야 되는 바이트 코드에서 중복이 일어나면 전체 바이트 코드를 컴파일하고 네이티브 코드로 변경하는 JIT 컴파일러를 사용합니다.
정리하자면 아래와 같다.
컴파일이 완료된 .class
(바이트 코드)를 바로 읽을 수 없기 때문에 JVM은 인터프리터를 이용하여 바이트 코드를 한줄씩 매번 변환하여 실행을 하게 된다.
바이트 코드에 중복이 되는 부분을 찾으면 JIT 컴파일러를 이용하여 모두 컴파일 하여 기계어로 변경한다.
이 후 인터프리터가 해당 부분을 읽어야 할 때 새로 변환 작업을 하는 것이 아니라 이미 JIT 컴파일러로 변환작업을 해놓은 것을 바로 사용하게 되는 것이다.
참조되지 않은 개체를 수집하고 제거합니다.
전반적으로 JVM의 구조를 확인해보았다. java를 공부하면서 내부적으로 어떻게 .java
가 .class
가 되며,
.class
는 어떻게 읽히며 실행되는지를 알아보았다. JVM을 시작으로 java의 기초를 탄탄히 쌓아 나아갈 수 있는 시간들을 가지기 원한다.