• JVM 2022.04.22
반응형

※ 본 게시물은 네이버 테크 블로그와 2015년에 출간된 〖네이버를 만든 기술, 읽으면서 배운다 - 자바 편〗 중 05. JVM 이해하기 챕터를 정리한 내용입니다.

 

JVM의 특징

  • 스택 기반의 가상 머신 : x86이나 ARM 아키텍처가 레지스터 기반인데 비해 JVM은 스택 기반으로 작동한다.
  • 심벌릭 레퍼런스 : Primitive data type을 제외한 모든 타입(클래스와 인터페이스)을 메모리 주소 기반이 아닌, 심벌릭 레퍼런스를 통해 참조한다.
  • 가비지 컬렉션 : 인스턴스는 코드에 의해 명시적으로 생성되고 GC에 의해 자동 파괴된다.
  • 기본 자료형을 명확하게 정의해 플랫폼 독립성 보장 : 전통적인 언어(C/C++)는 플랫폼에 따라 기본 자료형의 크기가 달라지나, JVM은 기본 자료형을 명확하게 정의해 호환성을 유지하고 플랫폼 독립성을 보장한다.
  • 네트워크 바이트 순서(network byte order) : x86의 리틀 엔디안이나 RISC 아키텍처의 빅 엔디안 사이에서 플랫폼 독립성을 유지하려면 고정된 바이트 순서를 유지해야 하므로 네트워크로 데이터를 전송할 때 사용하는 네트워크 바이트 순서를 사용한다. 네트워크 바이트 순서는 빅 엔디안이다.

 

자바 바이트코드

  • 자바 컴파일러는 고수준 언어를 JVM이 이해하는 자바 바이트코드로 번역한다.
  • 자바 바이트코드는 플랫폼 의존적인 코드가 없기 때문에 JVM이 설치된 장비라면 CPU나 운영체제에 상관 없이 실행할 수 있다.
  • 컴파일 결과물의 크기가 소스 코드와 크게 다르지 않기 때문에 네트워크로 전송하여 실행하기 쉽다.
  • 클래스 파일 자체는 바이너리 파일이어서, javap라는 역어셈블러(diassembler)를 제공한다. (결과물을 자바 어셈블리라고 부른다.) 
  • 어셈블리 형식 
    • index : 각 메서드 기준으로 바이트 오프셋
    • opcode : 명령어 OpCode. 1바이트의 바이트 번호로 표현된다. (aload_0=0x2a, getfield=0xb4, invokevirtual=0xb6, ...) 최대 256개
    • operandN : 피연산자 (0개 이상). getfield, invokevirtual 등은 2바이트의 피연산자가 필요하다.
    • comment : 주석 (라인 마지막에 위치)
<index> <opcode> [ <operand1> [ <operand2>... ]] [<comment>]

 

  • 자바 바이트코드의 명령어는 1바이트의 OpCode(최대 256개)와 2바이트 피연산자(Operand)로 분리할 수 있다.
  • 메서드를 호출하는 명령어 OpCode는 다음의 4가지가 있다.
    • invokeinterface : 인터페이스 메서드 호출
    • invokespecial : 생성자, private 메서드, 슈퍼 클래스의 메서드 호출
    • invokestatic : static 메서드 호출
    • invokevirtual : 인스턴스 메서드 호출

 

클래스 파일

ClassFile {
 u4 magic;
 u2 minor_version;
 u2 major_version;
 u2 constant_pool_count;
 cp_info constant_pool[constant_pool_count-1];
 u2 access_flags;
 u2 this_class;
 u2 super_class;
 u2 interfaces_count;
 u2 interfaces[interfaces_count];
 u2 fields_count;
 field_info fields[fields_count];
 u2 methods_count;
 method_info methods[methods_count];
 u2 attributes_count;
 attribute_info attributes[attributes_count];
}

 

  • 클래스 파일 포맷
    • magic : 자바 클래스 파일 구별을 위한 매직 넘버 (0xCAFEBABE)
    • minor_version, major_version : 클래스 파일의 버전 지정
    • constant_pool_count : constant_pool의 개수 + 1
    • constant_pool[] : 런타임 상수 풀 영역에 들어갈 정보(문자열 상수, 클래스명, 인터페이스명, 필드명, 기타 상수)
    • access_flags : 클래스의 변경자 정보(public, final, abstract, enum 등) 또는 인터페이스 여부를 나타내는 플래그
    • this_class, super_class : 각각 this, super 클래스에 대한 constant_pool 내 인덱스
    • interfaces_count, interfaces[] : 클래스가 구현한 인터페이스의 개수와 인터페이스에 대한 constant_pool 내 인덱스
    • fields_count, fields[] : 클래스의 필드 개수와 필드 정보(필드 이름, 타입 정보, modifier, constant_pool 내 인덱스 등)
    • methods_count, methods[] : 클래스의 메서드 개수와 메서드 정보(메서드 이름, 파라미터 타입과 개수, 반환 타입, modifier, constant_pool 내 인덱스, 메서드 자체 실행 코드, 예외 정보 등)
    • attributes_count, attributes[] : attribute의 개수와 attribute_info 구조체
  • javap -verbose 명령으로 클래스 파일 포맷을 사용자가 읽을 수 있는 형태로 간략하게 보여준다.

 

JVM 구조

  • 자바로 작성한 코드는 클래스 로더가 컴파일된 자바 바이트코드를 런타임 데이터 영역에 로드하고, 실행엔진이 자바 바이트코드를 실행한다.

클래스 로더

  • 자바는 동적 로드, 즉 런타임에 클래스를 처음으로 참조할 때 해당 클래스를 로드하고 링크한다. 이 동적 로드를 담당하는 부분이 클래스 로더이다.
  • 클래스 로더의 특징
    • 계층 구조 : 클래스 로더끼리 부모-자식 관계를 이룬다. 최상위 클래스 로더는 부트스트랩 클래스 로더이다.
    • 위임 모델 : 클래스 로더끼리 로드를 위임하는 구조로 작동한다. 클래스를 로드할 때 먼저 상위 클래스 로더를 확인해 상위에 있으면 해당 클래스를 사용하고 없다면 요청 받은 클래스 로더가 클래스를 로드한다.
    • 가시성 제한 : 하위 클래스 로더는 상위 클래스 로더의 클래스를 찾을 수 있지만 상위는 하위의 클래스를 찾을 수 없다.
    • 언로드 불가 : 클래스 로더는 클래스를 로드할 수는 있지만 언로드 할 수는 없다. 대신 현재 클래스 로더를 삭제하고 새로운 클래스 로더를 생성할 수 있다.
  • 클래스 로더가 클래스 로드를 요청받으면, 이전에 로드된 클래스인지 클래스 로더 캐시를 확인하고 없으면 상위로 거슬러 올라가며 확인한다. 부트스트랩 클래스 로더까지 확인해도 없으면 요청 받은 클래스 로더가 파일 시스템에서 해당 클래스를 찾는다.
    • 부트스트랩 클래스 로더(bootstrap class loader) : JVM 가동할 때 생성되며 Object 클래스를 비롯해 자바 API를 로드한다. 자바가 아닌 네이티브 코드로 구현돼 있다.
    • 확장 클래스 로더(extension class loader) : 기본 자바 API를 제외한 확장 클래스를 로드한다. (다양한 보안 확장 기능)
    • 시스템 클래스 로더(system class loader) : 애플리케이션의 클래스를 로드한다. 사용자가 지정한 CLASSPATH 내 클래스를 로드한다.
    • 사용자 정의 클래스 로더(user-defined class loader) : 애플리케이션 사용자가 직접 코드 상에서 생성해서 사용하는 클래스 로더이다.
  • WAS와 같은 컨테이너는 웹 애플리케이션과 엔터프라이즈 애플리케이션이 서로 독립적으로 작동하도록 사용자 정의 클래스 로더를 사용하여, 애플리케이션의 독립성을 보장한다.
  • 클래스 로드 단계
    • 로드(loading) : 클래스 파일을 JVM 메모리에 로드한다.
    • 검증(verifying) : 읽어 들인 클래스가 자바 언어 명세 및 JVM 명세에 명시된 대로 잘 구성됐는지 검사한다.
    • 준비(preparing) : 클래스가 필요로 하는 메모리를 할당하고 클래스에 정의된 필드, 메서드, 인터페이스를 나타내는 데이터 구조를 준비한다.
    • 분석(resolving) : 클래스의 상수 풀 내 모든 심벌릭 레퍼런스를 다이렉트 레퍼런스로 변경한다.
    • 초기화(initializing) : 클래스 변수를 적절한 값으로 초기화한다. static 필드를 설정된 값으로 초기화 한다.

 

런타임 데이터 영역

  • 런타임 데이터 영역은 JVM이 운영체제 위에서 실행되면서 할당받는 메모리 영역이다.

  • PC(Program Counter) 레지스터 : 각 스레드마다 하나씩 존재하며 스레드가 시작될 때 생성된다. 현재 실행 중인 JVM 명령의 주소가 저장된다.
  • JVM 스택 : 각 스레드마다 하나씩 존재하며 스레드가 시작될 때 생성된다. 스택 프레임이라는 구조체를 저장하는 스택으로, JVM은 JVM에 스택 프레임을 추가하고 꺼내는 작업만 실행한다. 예외 발생 시 Stack Trace의 각 라인은 하나의 스택 프레임을 표현한다.
    • 스택 프레임 : 메서드가 실행될 때마다 하나의 스택 프레임이 생성돼 해당 스레드의 JVM 스택에 push되고 메서드가 종료되면 스택 프레임이 pop 된다. 각 스택 프레임은 지역 변수 배열, 피연산자 스택, 현재 실행 중인 메서드가 속한 클래스의 런타임 상수 풀에 대한 레퍼런스를 갖는다.
    • 지역 변수 배열 : 인덱스 0은 메소드가 속한 클래스 인스턴스의 this 레퍼런스이고, 인덱스 1부터는 메서드에 전달된 파라미터, 메서드 파라미터 이후에는 메서드의 지역 변수가 저장된다.
    • 피연산자 스택 : 메서드의 실제 작업 공간이다. 각 메서드는 피연산자 스택과 지역 변수 배열 사이에서 데이터를 교환하고, 다른 메서드 호출 결과를 push 하거나 pop 한다.
  • 네이티브 메서드 스택 : JNI(Java Native Interface)를 통해 호출하는 C/C++ 등의 코드를 실행하기 위한 스택이다.
  • 메서드 영역 : 모든 스레드가 공유하는 영역으로 JVM이 시작될 때 생성된다. 각 클래스와 인터페이스에 대한 런타임 상수 풀, 필드와 메서드 정보, Static 변수, 메서드의 바이트코드 등을 보관한다. 메서드 영역은 JVM 제조사마다 다양한 형태로 구현할 수 있으며, 오라클 핫스폿 VM에서는 Permanent Generation(Perm Gen)이라고 한다.
  • 런타임 상수 풀 : 클래스 파일 포맷에서 constant_pool 테이블에 해당하는 영역이다. JVM 작동에서 가장 핵심적인 역할을 실행하는 곳이다. 각 클래스와 인터페이스의 상수 뿐만 아니라 메서드와 필드에 대한 모든 레퍼런스까지 담고 있는 테이블이다. 어떤 메서드나 필드를 참조할 때 JVM은 런타임 상수 풀을 통해 해당 메서드나 필드의 실제 메로리 상의 주소를 찾아서 참조한다.
  • : 인스턴스 또는 객체를 저장하는 공간으로 가비지 컬렉션 대상이다. JVM 성능 이슈에서 가장 많이 언급되는 공간이다.

 

실행 엔진

  • JVM 내 런타임 데이터 영역에 배치된 바이트코드는 실행 엔진에 의해 실행된다. 실행 엔진은 바이트코드를 명령어 단위로 읽어 실행한다.
  • 실행 엔진은 자바 바이트코드를 실제로 JVM 내부에서 기계가 실행할 수 있는 형태로 변경한다.
    • 인터프리터 : 바이트 코드 명령어를 하나씩 읽어서 해석하고 실행한다. 해석은 빠른 대신 결과의 실행은 느리다. 바이트코드는 기본적으로 인터프리터 방식으로 작동한다.
    • JIT (Just-In-Time) 컴파일러 : 인터프리터 방식으로 실행하다 적절한 시점에 바이트코드를 컴파일해 네이티브 코드로 변경하고 이후 네이티브 코드로 직접 실행하는 방식이다. 인터프리팅 방식보다 빠르고 네이티브 코드를 캐시에 보관하기 때문에 한 번 컴파일된 코드는 계속 빠르게 실행된다.

 

JIT 컴파일러

  • JIT 컴파일러는 바이트코드를 중간 단계의 표현인 IR (Intermediate representation)로 변환해 최적화하고, 그 다음에 네이티브 코드를 생성한다.

  • 오라클 핫스폿 VM은 HotSpot 컴파일러라 불리는 JIT 컴파일러를 사용한다. 내부적으로 프로파일링을 통해 가장 컴파일이 필요한 부분 HotSpot을 찾아낸 다음, 이 HotSpot을 네이티브 컴파일한다. HotSpot VM은 컴파일된 바이트코드라도 자주 호출되지 않으면 캐시에서 네이티브 코드를 덜어내고 다시 인터프리터 모드로 작동한다.
  • IBM JVM은 JIT 컴파일러 뿐만 아니라 AOT(Ahead-On-Time) 컴파일러를 도입했다. 이미 컴파일된 네이티브 코드를 여러 JVM이 공유 캐시를 통해 공유한다. AOT 컴파일러를 통해 컴파일된 코드는 다른 JVM에서 컴파일하지 않고 사용할 수 있다. 또한 AOT 컴파일러를 통해 JXE(Java Executable) 파일 포맷으로 프리컴파일된 코드를 작성해 빠르게 실행하는 방법도 제공한다.
  • 자바 성능 개선의 많은 부분은 이 실행 엔진을 개선해 이뤄지고 있다.

 

출처

  • 강경태, 강운덕, 구태진, 김민수, 김택수, 박세훈, 송기선, 이상민, 정상혁, 최동순, 〖네이버를 만든 기술, 읽으면서 배운다 - 자바 편〗, 위키북스(2015) p.123 - 150
  • 네이버 D2 테크 블로그, JVM Internal https://d2.naver.com/helloworld/1230

 

 

반응형

+ Recent posts