EMD Blog

JVM에 대해 알아보자 본문

개발/Java

JVM에 대해 알아보자

EmaDam 2021. 5. 15. 14:24

JVM

 Java Virtual Machine의 약자로 Java Compiler(javac)를 이용해 Java 코드를 Compile하게 되면 Java 바이트코드가 되는데 이를 실행시켜주는 것이 JVM이다. Java 코드를 작성할 때는 class 단위로 코드를 작성했었고 실제로 Compile을 하게 되면 바이트 코드의 확장자가 .class인 것을 확인할 수 있는데 이런 class파일(바이트코드)들을 JVM은 클래스로더(Class Loader)통해 바이트 코드를 넘겨 받아서 메모리에 로드하고 클래스로더가 바이트 로딩을 끝내게 되면 JVM 실행 엔진이 바이트 코드를 실행하게 된다.

 

특징

 JVM은 Java 코드를 한번만 작성해서 여러 플랫폼에 동일한 실행을 보장(Write Once Run Anywhere)하기 위해 제작되었다. 기존의 몇몇 언어들은 각 플랫폼별로 플랫폼에 맞게 코드가 작성되어야 했지만 Java는 중간에 JVM을 두어서 플랫폼에 상관없이 작성한 코드가 작동하도록 한 것이다. 대표적으로 C/C++을 예로들 수 있겠다. C/C++의 경우 리눅스 용으로 코드를 작성했을 경우 이 코드를 Windows에서 컴파일 해 실행하려면 Windows에 맞게 포팅작업을 진행해야한다. 하지만 Java는 Linux든 Windows든 각 플랫폼에 맞는 JVM만 존재한다면 작성한 Java코드를 수정할 필요가 없다. 물론 JVM자체는 플랫폼에 종속적(운영체제 및 하드웨어와의 통신은 Native로 플랫폼에 맞게 구현해야한다)이지만 우리가 JVM을 만들지는 않으니 편하게 Java 코드만 작성해서 배포하기만 하면된다.

 

 VM을 구현하는데는 두 가지 방법이 존재하는데 바로 스택기반과 레지스터 기반이다. JVM은 스택기반으로 구현되어 있어 Stack Pointer가 피연산자를 가리키고 피연산자 Pop -> 연산 -> 결과 Push 과정을 통해 연산이 이루어진다. 두 구현 방식의 차이점은 이름 그대로 계산에 스택을 사용하냐 레지스터를 사용하냐의 차이며 더 자세한 것은 나중에 따로 포스팅하도록 하겠다.

 

 Java는 컴파일 할때 변수와 메서드에 대한 참조를 상수 풀에 Symbolic Reference로 저장된다. 심볼릭 레퍼런스는 메모리 주소 기반과 달리 이름을 기반으로 참조하는 방식이며, JVM에서는 런타임시 Resolving 과정에서 이름에 해당하는 객체의 주소(심볼릭)를 찾아 다이렉트 레퍼런스(물리적 주소로 link)로 변경하게 된다. 

 

 JVM은 가비지 컬렉션(Garbage Collection)통해 메모리를 관리한다. 클래스 인스턴스는 사용자 코드를 통해 생성되지만 파괴는 JVM의 가비지 컬렉션이 하게된다. 자동으로 관리를 해주기는 하나 성능적으로 완벽하다고는 할 수 없어 대규모 서비스 운영시 가비지 컬렉션 튜닝을 필수로 해야하며 JVM 가비지 컬렉팅을 모니터링할 수 있는 플러그인(VisualVM + VisualGC)도 존재한다. 

 

 JVM은 기본 자료형을 명확히 정의하여 호한성과 플랫폼 독립성을 보장한다. C언어의 경우 int형은 실행 환경에 따라 크기가 달라지는데 시스템에 따라 2Byte, 4Byte, 8Byte일수도 있다. 이러한 가변적인 특성은 플랫폼이 달라질 경우 큰 문제를 가져올 수 있기 때문에 통신 및 데이터관리 시스템에서는 int가 아닌 uint32_t, uint64_t등 명확하게 타입을 지정해 주어야하는 것이다. 하지만 자바의 경우 플랫폼에 상관없이 4byte로 고정이기 때문에 플랫폼을 신경쓰지 않고 개발(호환성과 독립성 보장)을 할 수 있게된다.

 

 JVM은 데이터를 저장할 때 네트워크 바이트 오더를 사용한다. 바이트 오더는 데이터를 CPU가 메모리에 저장할 때 순서를 말하는데 리틀엔디안(Little Endian)과 빅엔디안(Big Endian) 두 가지가 존재한다. 리틀엔디안의 경우 뒤에서부터 빅엔디안은 앞에서부터 저장하게 되는데 다음의 예를 보자 

int a = 0x12345678 

위에서 Java의 int 자료형은 4byte 고정이라고 했었다. 위 값은 0x로 시작하니 16진수이고 16진수는 한 자리수에 4비트를 차지하니 위 값을 메모리에 1byte씩 저장한다고 하면 두 자리씩해서 [0x12] [0x34] [0x56] [0x78] 이런 식으로 나뉘게 될 것이다. 그럼 먼저 리틀 엔디안부터 살펴보자. 리틀엔디안은 뒤에서 부터 차례대로 저장한다고 했다. 즉, 아래와 같이 저장된다.

0x78
0x56
0x34
0x12

빅엔디안은 앞에서 부터 저장된다고 했으니 아래와 같을 것이다.

0x12
0x34
0x56
0x78

 그럼 네트워크 바이트 오더를 사용한다는 것은 무슨 말일까? 바이트 오더라는 말 자체가 CPU가 메모리에 데이터를 저장할 때의 순서를 말한다고 했다. 그렇다면 바이트 오더는 CPU 아키텍처에 따라 달라지게 될 것인데 그 말은 플랫폼 마다 데이터 전송 시 항상 데이터를 다르게 처리해줘야 한다는 뜻이된다. 그래서 네트워크로 데이터 전송 시 바이틍 오더에 대한 규칙을 정해두었는데 그것이 바로 네트워크 바이트 오더이며 위의 경우 32비트이므로 네트워크 바이트 오더는 빅 엔디안이 된다.(다른 규약에 대한 정보는 TCP/IP 규약을 찾아보도록 하자) 즉, Java는 플랫폼에 상관없이 바이트 오더를 고정(플랫폼 독립성)하기 위해 바이트 오더를 네트워크 바이트 오더로 정한 것이다. 

 

자바 바이트코드와 Class Loader

 자바는 Java Compiler를 통해 compile하게 되면 .class 확장자를 갖는 바이트 코드로 변환된다. 하지만 이 바이트 코드는 CPU에 직접 명령을 내리기 위한 기계어가 아닌 플랫폼과의 의존성을 없애기 위해 Java와 기계어의 중간 수준의 언어로 컴파일한 것이다. 원래는 CPU 운영체제나 CPU가 처리하지 않고 JVM이 처리해야 하기때문에 기계어로 변환 되는 것이 아닌 언어(Java와 기계어 사이)로 compile된다. 이 바이트 코드들은 바이너리 파일이라 사람이 이해하기 어려워 몇몇 JVM 벤더에서는 javap라는 역어셈블러를(disassembler) 제공하기도 한다. 

 

 Class Loader는 이 바이트 코드들을 JVM 메모리에 동적로딩하는 역할을 한다. 말 그대로 동적으로 로드하는 것이기 때문에 미리 클래스들 전체를 로드하는 것이 아니라 런타임에 첫 참조시 해당 클래스를 로드한다. 그럼 클래스를 어떻게 로드하는지 과정을 살펴보자. 먼저 클래스는 단일 클래스로더로 모든 일을 처리하는 것은 아니다. 클래스 로더는 여러 클래스 로더가 계층구조로 존재하고 있으며 그중 최상위 클래스 로더를 부트스트랩 클래스 로더(Bootstrap Class Loader)라고 부른다. 이 부트스트랩 로더는 다른 클래스 로더와 달리 네이티브 코드로 구현되어 있으며 JVM이 기동될 때 생성되고 Object 클래스들과 JavaAPI들을 로드한다. 이 클래스 로더들은 클래스 로더끼리 로드를 위임하는 상위 위임 모델을 따르며 클래스 로드를 요청 받으면 클래스 로더 캐시부터 시작해서 상위 클래스 로더에 해당 클래스가 로드 되어있는지 네임 스페이스(로드된 클래스들을 보관하는)에 보관되어있는 FQCN(Fully Qualified Class/모든 계층적 구조)을 기준으로 클래스 찾고 부트 스트랩 클래스 로더까지 찾았는데도 클래스가 없으면 파일 시스템에서 클래스를 찾아 로드한다. 상위 위임 모델을 따르기 때문에 상위 클래스 로더에서 하위 클래스 로더의 클래스를 찾을 수는 없다.

 

 클래스 로더는 클래스를 로드만 할 수 있고 언로드는 불가능 하다는 특징을 가지고 있다. 만약에 언로드를 하고 싶으면 해당 클래스 로더(클래스가 보관되어있는 네임스페이스)를 삭제하고 새로운 클래스 로더를 생성하는 방법밖에 없다.

 

 클래스 로더는 크게 부트스트랩 클래스 로더(Bootstrap Class Loader), 익스텐션 클래스 로더(Extension Class Loader), 시스템 클래스 로더(System Class Loader)로 나눌 수 있다. 부트스트랩 클래스 로더는 Object 클래스와 Java API를 로드하고 익스텐션 클래스 로더는 기본 자바 API를 제외한 확장 디렉토리에 있는 표준 확장 클래스 패키지들을 로드 한다. 시스템 클래스 로더는 애플리케이션의 클래스들을 로드하는데 $CLASSPATH 내의 클래스들을 로드한다. 이외에도 사용자가 직접 클래스 로더를 생성해서 사용할 수 있다. 클래스 로더는 부트스트랩 클래스 로더를 제외하면(부트스트랩은 Native로 작성) 전부 Java로 되어있기 때문에 사용자가 JVM의 세세한 부분까지 이해하지 않더라도 클래스 로더를 작성할 수 있다. 이러한 사용자 정의 클래스 로더는 WAS같은 프레임워크에서 애플리케이션들이 서로 독립적으로 동작하게 하기 위해 사용된다.

 

 그럼 클래스 로더가 클래스를 로드하고 링크 후 초기화하는 과정까지 살펴보자. 클래스 로더는 위에서 말한 상위 위임 모델을 통해 상위 클래스 로더의 네임 스페이스 내 클래스를 스캔하고 존재하지 않을 경우 해당 클래스를 파일시스템에서 가져와 JVM의 메모리에 로드 한다. 메모리 로드 후에는 이 클래스가 자바 언어 명세 및 JVM 명세에 맞게 구성되었는지 JVM의 TCK를 통해 확인하는 검사를 진행한다. 이 검사를 통과하게 되면 이 클래스 파일은 어떤 플랫폼에서도 동일한 실행을 보장하게 된다. 이제 검증이 끝났으니 클래스가 필요로 하는 메모리를 할당 하고 클래스에서 정의된 필드, 메서드, 인터페이스들을 나타내는 데이터 구조를 준비하기 시작한다. 그리고 상수 풀 내 모든 심볼릭 레퍼런스를 다이렉트 레퍼런스로 변경하고 클래스(static) 변수들을 적절한 값으로 초기화 하게된다.

런타임 데이터 영역

 런타임 데이터 영역이란 말그대로 런타임에 할당받는 메모리 영역으로 정확히는 JVM이 실행될 때 할당받는다. 위에서 클래스 로더를 통해 클래스 파일을 로드할 때 할당하는 메모리 영역이 런타임 데이터 영역이다. 런타임 데이터 영역은 크게 PC 레지스터(PC Register), JVM 스택(JVM Stack), 네이티브 메서드 스택(Native Method Stack), 힙(Heap), 메서드 영역(Method Area)으로 구성된다. 그럼 하나씩 살펴보도록 하자.

 

 PC 레지스터란 CPU가 명령을 처리할 때 피연산자에 대한 결과를 잠시 저장해놓는 공간이다. 하지만 Java의 경우 Compile된 바이트 코드(명령어들)를 CPU가 직접 처리하는 것이 아닌 JVM을 통해 처리하기 때문에 Java에서 같은 역할을 할 메모리 영역이 필요하고 그 영역을 런타임 데이터 영역 내 PC 레지스터라고 한다. 이 영역은 스레드가 생성될 때마다 생성되며 각 스레드 별로 1개씩 생성되는 특징을 가지고 있다.

 

JVM 스택은 스택 프레임(Stack Frame)이라는 구조체를 저장하는 스택이다. JVM에서 스레드가 생성될 때 해당 스레드를 위해서 스택이 생성되는데 여기에  Frame이 들어가서 스택 프레임이라고 한다. Frame은 지역 변수(Local Variables), 오퍼랜드 스택(Operand Stack), 상수 풀 참조로 구성된다. 지역 변수는 메서드의 지역 변수들을 배열의 형태로 프레임 내에 저장하고 Operand Stack은 피연산자들을 Push하여 연산 후 Pop으로 리턴한다. 그리고 현재 실행 중인 메서드가 속한 클래스의 대한 런타임 상수 풀에 대한 레퍼런스를 가진다. 이런 특징을 가진 스택 프레임이 메서드가 수행될 때마다(스레드가 생성될 때마다) JVM 스택에 Push되는데 메서드가 종료되면 해당 스택프레임은 제거되며 만약에 해당 프레임이 계속 쌓여서 스택 허용치를 넘어가게 되면 StackOverflowError가 발생하며 스택 사이즈를 동적으로 확장하더라도 확장할 메모리가 부족하면 OutOfMemoryError가 발생하게 된다. 추가로 예외 발생 시 출력되는 Stack Trace의 각 라인은 하나의 스택 프레임을 표현한 것이다.

 

 네이티브 메서드 스택은 Java외 네이티브 코드를 위한 스택이다. JVM은 플랫폼 독립적이라는 특징을 가지고 있고 그로 인해 편리하게 Java코드를 작성해서 실행시킬 수는 있지만 다른 크로스 플랫폼 기능을 지원하는 언어나 프레임워크와 마찬가지로 네이티브 기능에 대한 제약이 존재한다. 그래서 Java에서 네이티브 기능을 사용하기 위해서는 JNI(Java Native Interface)를 사용해야한다. 이 JNI를 사용하면 C/C++ 등으로 구현된 기능들을 호출할 수 있고 해당 네이티브 코드를 수행하기 위한 스택(네이티브 메서드 스택)이 언어에 맞게 생성된다.

 

 메서드 영역은 바이트 코드를 읽어 들여서 런타임 상수 풀, 필드 및 메서드, 생성자, 정적 변수 등을 저장하는 영역이며 모든 스레드가 공유하는 영역이다. JVM은 동적 로딩을 하기 때문에 초기에 실행되는 main 메서드와 관련된 바이트 코드가 아니라면 해당 메소드가 호출될 때 메서드 영역에 정보를 저장하게 된다. 이 메서드 영역은 동적 공간으로 필요에 따라 확장되거나 줄어들고 사용자가 영역의 크기를 제어할 수 있다. 이 메서드 영역에 대한 가비지 컬렉션의 구현 명세는 정해져 있지 않기 때문에 논리적으로는 힙의 일부 영역이지만 벤더에 따라 가비지 컬렉션이 없을 수도 있다. 

 

 위 메서드 영역에 포함된 영역 중에 런타임 상수 풀(Runtime constant pool)이 존재한다. 이 영역은 클래스 파일에 존재하는 constant_pool 테이블에 해당하며 클래스 및 인터페이스의 상수나 리터럴, 메서드와 필드에 대한 레퍼런스들을 담고 있다. 그래서 JVM은 메서드나 필드, 상수, 리터럴들을 참조할 때 런타임 상수 풀 내 메모리 주소를 찾게 된다. 이 런타임 상수 풀은 클래스 또는 인터페이스가 JVM에 의해 로드될 때 생성되며 모든 스레드가 공유하는 영역이다.

 

 힙 영역은 인스턴스 및 객체, 배열을 저장하는 공간으로 사용자가 관리하며 객체가 동적으로 생성될 때 힙 메모리 영역에 저장된다. 힙은 JVM이 시작할 때 생성되며 가비지 컬렉션에 의해 자동으로 메모리가 관리되는 영역이다. 메모리를 가비지 컬렉션이 관리하기 때문에 성능에 대한 이슈가 많이 일어나며 프로그램 규모가 커질 경우 가비지 컬렉션에 대한 튜닝이 필요할 수 있다. 힙에 대한 구현은 벤더에 따라 다르기 때문에 정적인 영역일수도 있고 동적인 영역일수도 있다.

Java 실행 엔진

 클래스 로더를 통해 런타임 데이터 영역에 바이트 코드가 배치되면 Java 실행 엔진은 해당 코드들을 실행한다. 즉 CPU역할을 수행하는 것이다. 바이트 코의 각 명령어는 1바이트(8비트) 즉, 256개의 OpCode(Operator라서 Op인듯하다)를 가지고 있고 그외 추가 연산자로 이루어져 있다. 이 OpCode들을 크게 분류하면 읽고 쓰기, 산술논리 연산, 타입변환, 객체생성 및 조작, Operand Stack 관리, 제어, 함수 호출 및 반환 정도로 분류가 가능하다. 실행엔진은 하나의 OpCode를 가져와 피연산자와 함께 작업을 수행 후 다음 OpCode를 수행한다. 이 실행 엔진은 바이트 코드를 기계가 실행할 수 있는 형식으로 변경하며 그 종류는 인터프리터, JIT(Just-In-Time) 컴파일러 이렇게 두 가지로 구분된다. 

 

 인터프리터는 명령어를 즉석에서 한줄한줄 읽어서 해석 후 실행하는 방식이다. 별도의 사전 작업이 필요하지 않고 한줄 단위로 코드를 해석하기 때문에 전체 결과 실행은 느리지만 바이트코드 전체를 컴파일 하는 방식보다는 빠른 편이다. JIT 컴파일러는 인터 프린터의 단점을 보완하기 위해 도입되었으며 컴파일러라는 이름에서 알 수 있듯이 바이트 코드를 네이티브로 컴파일 후 캐싱하여 실행시키는 방식이다. 그럼 JIT 컴파일러로 컴파일하는 시점은 언제일까 JIT 방식은 컴파일 후 네이티브 코드를 캐싱해 사용하므로 해당 메서드를 재사용할 때 유리한 특징을 가지고 있고 인터프리터는 단일 실행에 있어 컴파일 시간을 포함하면 JIT방식보다 빠르기 때문에 재사용 횟수가 일정 수준을 넘어서면 JIT 컴파일러를 통해 컴파일 -> 캐싱 -> 재사용의 절차를 거치게 된다. (코드가 실행중에 실시간으로 일어난다고 해서 Just In TIme이라는 이름이 붙었다.) 이런 방식의 실행이 가능한 이유는 Java 컴파일러가 컴파일을 하는 중에 최적화를 진행해주기 때문에 바이트코드에서 기계어로 번역하는 속도가 월등히 빠르기 때문이다. 

 

 

[참고자료]

JVM 동작원리 및 기본개념

JVM Internal

자바 가상 머신

자바 바이트코드 소개

자바 동적로딩 이해

JVM stack과 frame

JIT 컴파일

'개발 > Java' 카테고리의 다른 글

HashMap에 대해서 알아보자(1)  (0) 2021.04.28