[Deep in Java] JVM

이재훈·2023년 11월 1일
1

DEEPINJAVA

목록 보기
1/4

JVM (Java virtual machine)

JVM은 컴퓨터에서 Java 프로그램은 물론 Java 바이트코드로 컴파일된 다른 언어로 작성된 프로그램을 실행할 수 있게 해주는 가상 머신입니다. JVM은 플랫폼에 상관 없이 어떤 플랫폼에서도 Java 클래스 파일이 실행되도록 하는 바이트코드 인터프리터 및 런타임으로 구성됩니다.

JDK ? JRE ?

JDK (Java Development Kit)

  • JDK는 Java 애플리케이션을 개발, 컴파일 및 실행하는데 필요한 도구, 실행 파일 및 바이너리를 제공하는 소프트웨어 패키지입니다.
  • Java 소스 코드를 바이트 코드로 변환하는데 사용되는 Java 컴파일러 ("javac")가 포함되어 있습니다.
  • 개발자가 Java 애플리케이션을 생성하려면 시스템에 JDK가 설치되어 있어야 합니다.

JRE (Java Runtime Enviroment)

  • JRE는 JDK의 하위 집합이며, Java 애플리케이션을 실행하는데 사용되지만 Java 컴파일러와 같은 개발 도구로는 포함하지 않습니다.
  • Java 애플리케이션을 실행하는데 필요한 JVM, Java 클래스 라이브러리가 포함되어 있습니다.
  • Java 애플리케이션을 실행하려는 최종 사용자는 시스템에 JRE만 설치하면 됩니다.

JVM (Java virtual machine)

  • JVM은 Java 애플리케이션을 실행하기 위한 런타임 환경입니다. JDK와 JRE 모두의 필수적인 부분입니다.
  • Java 소스 코드에서 생성된 Java 바이트코드를 호스트 운영 체제에서 실행되는 기계어 코드로 해석하거나 컴파일 합니다.
  • JVM은 메모리 관리, 가비지 수집 및 Java 애플리케이션에 대한 플랫폼 독립적인 실행 환경 제공을 담당합니다.
"한번 작성하면 어디에서나 실행 가능하다."

개발자가 자바 소스코드(.java)를 작성하면 JDK는 자바 컴파일러로(javac)로 컴파일을 해 클래스 파일로 만듭니다. 클래스 파일은 클래스로더에 의해 JVM으로 로드되고 실행됩니다. JVM은 클래스 로딩, 바이트 코드 해석 및 실행, 메모리 관리, 가비지 컬렉션과 같은 다양한 작업을 수행합니다.

클래스 로더

자바는 동적 로드, 즉 컴파일 타임이 아니라 런타임에 클래스를 처음으로 참조할 때 해당 클래스를 로드하고 링크하는 특징이 있습니다. 이 동적 로드를 담당하는 부분이 JVM의 클래스 로더입니다.

클래스 로더 특징

  • 계층구조 : 클래스 로더끼리 부모-자식 관계를 이루어 계층 구조로 생성됩니다. 최상위 클래스 로더는 부트 스트랩 클래스 로더(Bootstrap class Loader)입니다.
  • 위임 모델 : 계층 구조를 바탕으로 클래스 로더끼리 로드를 위임하는 구조로 동작합니다. 클래스를 로드할 때 상위 클래스 로더를 확인하여 상위 클래스 로더에 있다면 해당 클래스를 사용하고, 없다면 로드를 요청 받은 클래스 로더가 클래스를 로드합니다.
  • 가시성 제한 : 하위 클래스 로더는 상위 클래스 로더에 클래스를 찾을 수 있지만, 상위 클래스 로더는 하위 클래스 로더의 클래스를 찾을 수 없습니다.
  • 언로드 불가 : 클래스 로더는 클래스를 로드할 수는 있지만 언로드 할 수는 없습니다. 언로드 대신, 현재 클래스 로더를 삭제하고 아예 새로운 클래스 로더를 생성하는 방법을 사용할 수 있습니다.

각 클래스 로더는 로드된 클래스들을 보관하는 네임스페이스를 가집니다. 클래스를 로드할 때 이미 로드된 클래스인지 확인하기 위해서 네임스페이스에 보관된 FQCN(Fully Qualfied Class Name)을 기준으로 클래스를 찾습니다. 비록 FQCN이 같더라도 네임 스페이스가 다르면, 즉 다른 클래스 로더가 로드한 클래스이면 다른 클래스로 간주 됩니다.

클래스 로더가 클래스 로드를 요청 받으면, 클래스 로더 캐시, 상위 클래스 상위 클래스 로더, 자기 자신의 순서로 해당 클래스가 있는지 확인합니다. 이전에 로드된 클래스인지 클래스 로더 캐시를 확인하고, 없으면 상위 클래스 로더를 거슬러 올라가며 확인합니다. 부트 스트랩 클래스 로더까지 확인해도 없으면 요청 받은 클래스 로더가 파일 시스템에서 해당 클래스를 찾습니다.

  • 부트스트랩 클래스 로더 : JVM을 기동할 때 생성되며, Object 클래스들을 비롯하여 자바 API들을 로드합니다. 다른 클래스 로더와 달리 자바가 아니라 네이티브 코드로 구현되어 있습니다.
  • 익스텐션 클래스 로더 : 기본 자바 API를 제외한 확장 클래스들을 로드합니다. 다양한 보안 확장 기능들을 여기서 로드하게 됩니다.
  • 시스템 클래스 로더(애플리케이션 클래스 로더) : 부트 스트랩 클래스 로더와 익스텐션 클래스 로더가 JVM 자체의 구성 요소들을 로드하는 것이라고 한다면, 시스템 클래스 로더는 애플리케이션의 클래스들을 로드한다고 할 수 있습니다. 사용자가 지정한 $CLASSPATH 내의 클래스들을 로드합니다.
  • 사용자 정의 클래스 로더 : 애플리케이션 사용자가 직접 코드상에서 생성해서 사용하는 클래스 로더 입니다. (직접 추가 가능)

웹 어플리케이션 서버(WAS)와 같은 프레임워크는 웹 어플리케이션들, 엔터프라이즈 애플리케이션이 서로 독립적으로 동작하게 하기 위해 사용자 정의 클래스 로더를 사용합니다. 즉 클래스 로더의 위임 모델을 통해 애플리케이션의 독립성을 보장합니다.

클래스 로더가 아직 로드되지 않은 클래스를 찾으면, 다음 그림과 같은 과정을 거쳐 클래스를 로드하고, 링크하고 초기화 합니다.

  • 로드 : 클래스를 파일에서 가져와서 JVM 메모리에 로드합니다.
  • 검증(Verifying) : 읽어 들인 클래스가 자바 언어 명세 및 JVM 명세에 명시된 대로 잘 구성되어 있는지 검사합니다. 클래스 로드의 전 과정 중 가장 까다로운 검사를 수행하는 과정으로서 가장 복잡하고 시간이 많이 걸립니다. JVM TCK의 테스트 케이스 중에서 가장 많은 부분이 잘못된 클래스를 로드하여 정상적으로 검증 오류를 발생시키는지 테스트하는 부분입니다.
  • 준비 : 클래스가 필요로 하는 메모리를 할당하고, 클래스에 정의된 필드, 메서드, 인터페이스들을 나타내는 데이터 구조를 준비합니다.
  • 분석 : 클래스의 상수 풀 내 모든 심볼릭 래퍼런스를 다이렉트 래퍼런스로 변경합니다.
상수 풀은 JVM 내부의 중요한 데이터 구조로서 클래스 파일에서 심볼릭 래퍼런스가 
저장되는 곳입니다. 여기서, "심볼릭 래퍼런스를 다이렉트 래퍼런스로 변경한다"는 
개념은 클래스 파일을 JVM에서 실제로 사용될 수 있도록 로드하고 링크하는 과정을 설명합니다.

상수 풀(Constant Pool): 클래스 파일은 상수 풀이라고 불리는 데이터 구조를 포함합니다. 
이 풀에는 클래스, 필드, 메서드, 문자열 리터럴 등의 정보가 저장됩니다. 
이 정보는 심볼릭 래퍼런스로 표현됩니다. 상수 풀은 클래스 파일의 일부로 저장되며, 
클래스 파일이 로드될 때 메모리에 로드됩니다.

심볼릭 래퍼런스(Symbolic References): 상수 풀에 저장된 정보는 심볼릭 래퍼런스로 표현됩니다. 
이것은 실제로 클래스, 필드 또는 메서드의 위치 또는 주소를 가리키지 않고, 
그들을 참조하는 데 필요한 메타 정보를 나타냅니다. 
JVM은 이러한 심볼릭 래퍼런스를 이용하여 클래스와 멤버를 실제로 사용하기 전에 필요한 정보를 결정합니다.

다이렉트 래퍼런스(Direct References): 클래스와 멤버가 처음으로 사용될 때 
JVM은 심볼릭 래퍼런스를 실제로 로드되고 링크된 클래스, 
필드 또는 메서드의 주소 또는 위치로 변환합니다. 
이러한 주소 또는 위치를 다이렉트 래퍼런스라고 합니다. 
이렇게 변환된 래퍼런스를 사용하여 클래스와 멤버에 접근하고 실행합니다.

따라서 "심볼릭 래퍼런스를 다이렉트 래퍼런스로 변경한다"는 것은 
클래스 파일의 상수 풀에 저장된 클래스, 필드, 메서드 등을 처음 사용되기 전에 
JVM이 이러한 심볼릭 래퍼런스를 실제로 로드되고 링크된 클래스 및 멤버의 주소로 
변환하는 과정을 의미합니다. 이것은 클래스 파일을 실행 가능한 바이트 코드로 변환하고, 
Java 애플리케이션을 실행하는 데 필요한 단계 중 하나입니다.
  • 초기화 : 클래스 변수들을 적절한 값으로 초기화 합니다. 즉 static initializer들을 수행하고, static 필드들을 설정된 값으로 초기화 합니다.

JVM 명세는 이들 작업들에 대해 명시하고 있으나 작업에 따라 수행 시점은 유연하게 적용할 수 있다고 합니다.

런타임 데이터 영역

런타임 데이터 영역은 JVM이라는 프로그램이 운영 체제 위에서 실행되면서 할당 받은 메모리 영역입니다. 런타임 데이터 영역은 6개의 영역으로 나눌 수 있습니다. 이중 PC 레지스터, JVM 스택, 네이티브 스택은 스레드마다 하나씩 생성되며 힙, 메서드 영역, 런타임 상수 풀은 모든 스레드가 공유해서 사용합니다.

  • PC 레지스터 : PC(Program Counter) 레지스터는 각 스레드마다 하나씩 존재하며 스레드가 시작될 때 생성됩니다. PC 레지스터는 현재 수행 중인 JVM 명령의 주소를 갖습니다.

  • JVM 스택 : JVM 스택은 각 스레드마다 하나씩 존재하며 스레드가 시작될 때 생성됩니다. 스택 프레임이라는 구조체를 저장하는 스택으로, JVM은 오직 JVM 스택 프레임을 추가(push)하고 제거(pop)하는 동작만 수행합니다. 예외 발생 시 printStackTrace() 등의 메서드를 보여주는 Stack Trace의 각 라인은 하나의 스택 프레임을 표현합니다.

  • 스택 프레임 : JVM 내에서 메서드가 수행될 때마다 하나의 스택 프레임이 생성되어 해당 스레드의 JVM 스택에 추가되고 메서드가 종료되면 스택프레임이 제거됩니다. 각 스택프레임은 지역 변수 배열, 피 연산자 스택, 현재 실행 중인 메서드가 속한 클래스의 런타임 상수 풀에 대한 레퍼런스를 갖습니다. 지역 변수 배열, 피연산자 스택의 크기는 컴파일 시에 결정되기 때문에 스택 프레임 크기도 메서드에 따라 크기가 고정됩니다.

  • 지역변수 배열 : 0부터 시작하는 인덱스를 가진 배열입니다. 0은 메서드가 속한 클래스 인스턴스의 this 레퍼런스이고, 1부터는 메서드에 전달된 파라미터들이 저장되며, 메서드 파라미터 이후에는 메서드의 지역 변수들이 저장됩니다.

  • 피연산자 스택 : 메서드의 실제 작업 공간입니다. 각 메서드는 피연산자 스택과 지역변수 배열 사이에서 데이터를 교환하고, 다른 메서드 호출 결과를 추가하거나 꺼냅니다. 피연산자 스택 공간이 얼마나 필요한지는 컴파일 할 때 결정할 수 있으므로, 피연산자 스택의 크기도 컴파일 시에 결정됩니다.

  • 네이티브 메서드 스택 : 자바 외에 언어로 작성된 네이티브 코드를 위한 스택입니다. 즉 JNI (Java Native Interface)를 통해 호출하는 C/C++ 등으 코드를 수행하기 위한 스택으로, 언어에 맞게 C 스택이나 C++ 스택이 생성됩니다.

  • 메서드 영역 : 메서드 영역은 모든 스레드가 공유하는 영역으로 JVM이 시작될 때 생성됩니다. JVM이 읽어들인 각각의 클래스와 인터페이스에 대한 런타임 상수 풀, 필드와 메서드 정보, Static 변수, 메서드의 바이트 코드 등을 보관합니다. 메서드 영역은 JVM 벤더마다 다양한 형태로 구현할 수 있으며 오라클 JVM (HotSpot JVM)에서는 흔히 Permanent Area, 혹은 Permanent Generation(PermGen)이라고 불린다. 메서드 영역에 대한 가비지 컬랙션은 JVM 벤더의 선택 사항입니다.

  • 런타임 상수 풀 : 클래스 파일 포맷에서 constant_pool 테이블에 해당하는 영역입니다. 메서드 영역에 포함되는 영역이긴 하지만 JVM 동작에서 가장 핵심적인 역할을 수행하는 곳이기 때문에 JVM 명세에서도 따로 중요하게 기술합니다. 즉, 어떤 메서드나 필드를 참조할 때 JVM은 런타임 상수 풀을 통해 해당 메서드나 필드의 실제 메모리상 주소를 찾아서 참조합니다.

  • 힙 : 인스턴스 또는 객체를 저장하는 공간으로 가비지 컬렉션 대상이다. JVM 성능 이슈에서 가장 많이 언급되는 공간이다. 힙 구성 방식이나 가비지 컬렉션 방법 등은 JVM 벤더의 재량이다.

실제 동작에서는 각각의 클래스 인스턴스들이 힙에 할당되고, 클래스 정보는 메서드 영역에 보관이 된다.

실행 엔진

클래스 로더를 통해 JVM 내의 런타임 데이터 영역에 배치된 바이트 코드는 실행 엔진에 의해 실행됩니다. 실행 엔진은 자바 바이트 코드를 명령어 단위로 읽어서 실행합니다. CPU가 기계 명령어를 하나씩 실행하는 것과 비슷합니다. 바이트 코드의 각 명령어는 1바이트 짜리 OpCode와 추가 피연산자로 이루어져 있으며, 실행 엔진은 하나의 OpCode를 가져와서 피 연산자와 함께 작업을 수행한 다음, 다음 OpCode를 수행하는 식으로 동작합니다.

Opcode는 "Operation Code"의 줄임말로, 컴퓨터 프로세서가 실행해야 하는 
기본적인 연산 또는 명령어를 나타내는 이진 코드입니다. 

자바 바이트 코드는 기계가 바로 수행할 수 있는 언어보다는 비교적 인간이 보기 편한 형태로 기술되었습니다. 그래서 실행엔진은 이와 같은 바이트 코드를 실제로 JVM 내부에서 기계가 실행할 수 있는 형태로 변경하며, 그 방식은 두가지가 있습니다.

  • 인터프리터 : 바이트 코드 명령어를 하나씩 읽어서 해석하고 실행합니다. 하나씩 해석하고 실행하기 때문에 바이트 코드 하나 하나의 해석은 빠른 대신 인터프리팅 결과의 실행은 느리다는 단점을 가지고 있습니다. 흔히 얘기하는 인터프리터 언어의 단점을 그대로 가지는 것 입니다. 즉, 바이트 코드라는 언어는 기본적으로 인터프리터 방식으로 동작합니다.
  • JIT(Just-In-Time) 컴파일러 : 인터프리터의 단점을 보안하기 위해 도입된 것이 JIT 컴파일러 입니다. 인터프리터 방식으로 실행하다가 적절한 시점에 바이트코드 전체를 컴파일하여 네이티브 코드로 변경하고, 이후에는 해당 메서드를 더 이상 인터프리팅하지 않고 네이티브 코드로 직접 실행하는 방식이다. 네이티브 코드를 실행하는 것이 하나씩 인터프리팅 하는 것보다 빠르고, 네이티브 코드는 캐시에 보관하기 때문에 한번 컴파일된 코드는 계속 빠르게 수행되게 됩니다.

JIT 컴파일러가 컴파일 하는 과정은 바이트코드를 하나씩 인터프리팅 하는 것보다 훨씬 오래 걸리므로, 만약 한번만 실행되는 코드라면 컴파일 하지 않고 인터프리팅하는 것이 훨씬 유리합니다. 따라서, JIT 컴파일러를 사용하는 JVM들은 내부적으로 해당 메서드가 얼마나 자주 수행되는지 체크하고 일정 정도를 넘을 때에만 컴파일을 수행합니다.

가비지 컬렉터

Java의 가비지 컬렉터는 크게 2가지 작업을 합니다.

  1. 힙 내의 객체 중에서 가비지를 찾아낸다.
  2. 찾아낸 가비지를 처리해서 힙의 메모리를 회수한다.

    위의 그림은 런타임 데이터 영역 (Oracle Hotspot VM 기준) 입니다.
    런타임 영역은 스레드가 차지하는 영역들과 객체를 생성 및 보관하는 하나의 큰 힙, 클래스 정보가 차지하는 영역인 메서드 영역, 크게 세 부분으로 나눌 수 있다.위 그림에서 객체에 대한 참조는 화살표 방향으로 표시되어 있습니다.

힙에 있는 객체들에 대한 참조는 다음 4가지 종류중 하나입니다.

  • 힙 내 다른 객체에 의한 참조
  • Java 스택, 즉 Java 메서드 실행 시에 사용하는 지역 변수와 파라미터들에 의한 참조
  • 네이티브 스택, 즉 JNI (Java Native Interface)에 의한 생성된 객체에 대한 참조
  • 메서드 영역의 정적 변수에 의한 참조

Java GC는 객체가 가비지인지 판별하기 위해서 reachability라는 개념을 사용합니다. 어떤 객체에 유효한 참조가 있으면 'reachable'로 없으면 'unreachable'로 구별하고, unreachable 객체를 가비지로 간주해 GC를 수행합니다. 한 객체는 여러 다른 객체를 참조하고, 참조된 다른 객체들도 마찬가지로 또 다른 객체들을 참조할 수 있으므로 객체들은 참조 사슬을 이룹니다. 이런 상황에서 유효한 참조 여부를 판단하려면 항상 유효한 최초의 참조가 있어야 하는데 이를 객체 참조의 root set이라고 합니다.

힙 내에 다른 객체에 의한 참조를 제외한 나머지 3개가 root set으로, reachability를 판가름하는 기준이 됩니다.

위 그림에서 보듯이 root set으로 시작한 참조 사슬에 속한 객체들은 reachable 객체이고, 이 참조 사슬과 무관한 객체들이 unreachable 객체로 GC 대상입니다. 오른쪽 아래 객체처럼 reachable 객체를 참조하더라도, 다른 reachable 객체가 이 객체를 참조하지 않는다면 이 객체는 unreachable 객체입니다.


일부 발췌, 참조 링크, 그림 출처
정말 좋은 글들이니 읽어보시는 것을 추천드립니다.
https://d2.naver.com/helloworld/1230
https://d2.naver.com/helloworld/329631
https://en.wikipedia.org/wiki/Java_virtual_machine
https://wonsjung.tistory.com/574
https://asfirstalways.tistory.com/158
https://velog.io/@jifrozen/JVM-%EA%B5%AC%EC%84%B1%EC%9A%94%EC%86%8C-1-%ED%81%B4%EB%9E%98%EC%8A%A4-%EB%A1%9C%EB%8D%94
추가 읽을 게시글
https://velog.io/@ariul-dev/%EC%B0%A8%EA%B7%BC%EC%B0%A8%EA%B7%BC-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-Java-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%A8-%EC%8B%A4%ED%96%89-%EA%B3%BC%EC%A0%95

profile
부족함을 인정하고 노력하자

0개의 댓글