-
[JVM] 가상 머신 실행 서브시스템 (2) - 클래스 로딩 메커니즘DevBook/JVM 밑바닥까지 파헤치기 2025. 4. 1. 00:35
들어가기 전,
- 클래스 파일에 서술된 정보를 가상 머신이 사용하려면 먼저 로드해야 함
- 가상 머신이 클래스 파일을 로드하는 방법, 그 정보를 가상 머신 안에서 활용하는 방법에 대해 설명함
- JVM은 클래스를 설명하는 데이터를 클래스 파일로부터 메모리로 읽어 들이고 그 데이터를 검증, 변환, 초기화하고 나서 최종적으로 가상 머신이 곧바로 사용할 수 있는 자바 타입을 생성함
- 이 과정을 가상 머신의 클래스 로딩 메커니즘이라고 함
- 자바에서는 클래스 로딩, 링킹, 초기화가 모두 '프로그램 실행 중에' 이루어짐
- 자바가 동적 확장 언어 기능을 제공할 수 있는 것은 런타임에 이루어지는 동적 로딩과 동적 링킹 덕분임
- 이와 같이 어플리케이션을 동적으로 조합하는 기법은 가장 기초적인 예인 JSP부터 비교적 복잡한 OSGi 기술에 이르기까지 자바 프로그램에서 널리 사용되어 왔음. 이는 전부 자바 언어의 런타임 클래스 로딩 기술 덕분임
두 가지 규칙
- 실질적으로 클래스 파일 하나는 자바 언어에서 말하는 클래스 하나 또는 인터페이스 하나를 나타낼 수 있다.
- 여기서 '타입'이라는 표현을 쓸 때는 클래스와 인터페이스 하나를 나타낼 수 있음
- '클래스 파일'이라고 하면 디스크에 존재하는 특정 파일이 아니라 일련의 바이너리 바이트 스트림을 뜻한다.
- 즉, 클래스 파일은 SSD, 네트워크, 데이터베이스, 메모리 등 어디에도 존재할 수 있음
클래스 로딩 시점
타입의 생애 주기는 아래와 같음
- 가상 머신의 메모리에 로드되는 걸 시작으로 다시 언로드될 때까지 위와 같은 과정을 거침
- 이 중 검증, 준비, 해석 단계를 묶어 링킹이라고 함
- 로딩, 검증, 준비, 초기화, 언로딩은 반드시 순서대로 진행해야 함
- 해석 단계는 때에 따라서는 초기화 후에 시작할 수 있음
- 자바의 런타임 바인딩(동적 바인딩)을 지원하기 위함
- 단계별 순서의 기준은 '시작' 시점임
- 때로는 한 단계를 진행하는 중간에 다음 단계를 호출해 시작시키는 등 여러 단계가 병렬로 진행되기도 함
추가) 초기화 단계가 즉시 시작되어야 하는 상황
더보기- <자바 가상 머신 명세>는 클래스 로딩 과정의 첫 단계인 '로딩'을 정확히 어떤 상황에서 시작해야 하는지 명시하지 않았음
- 반면 초기화 단계는 즉시 시작되어야 하는 여섯 가지를 엄격히 규정함
- 이 시나리오들이 설명하는 동작을 타입에 대한 능동 참조(active reference)라고 함
- 반대로 타입 초기화를 촉발하지 않는 그 외의 모든 참조 방식은 수동 참조(passive refernce)라고 함
수동 참조 예시
1)
- 정적 필드를 참조할 때는 필드를 직접 정의한 클래스만 초기화됨
- 상위 클래스에 정의된 필드를 하위 클래스를 통해 참조하면 하위 클래스는 초기화되지 않음
- 이때 하위 클래스의 로딩과 검증 단계 촉발 여부는 가상 머신 구현하기 나름
2)
- 위 코드를 실행해도 상위 클래스 초기화가 수행되지 않음
- org.fenixsoft.jvm.chapter7.SuperClass 클래스의 초기화 단계가 촉발되지 않음
- 하지만 이 코드는 [Lorg.fenixsoft.jvm.chapter7.SuperClass라는 다른 클래스의 초기화 단계를 촉발함
- 사용자 코드에서는 유효하지 않은 이름
- JVM이 java.lang.Object로부터 곧바로 상속하여 자동으로 생성한 하위 클래스
- 이 클래스 생성을 촉발하는 바이트코드 명령어는 anewarray
- 이 클래스의 정체는 원소 타입(element type)이 org.fenixsoft.jvm.chapter7.SuperClass인 '일차원 배열'임
- 자바 소스 코드에서 배열이 제공하는 속성과 메서드를 구현한 실체가 바로 이 클래스
- public으로 설정된 length 속성과 clone() 메서드만 사용자 코드에서 직접 이용할 수 있음
- 자바 언어의 배열이 C/C++ 배열보다 안전한 주된 이유는 배열 원소로 직접 접근하지 못하도록 이 클래스가 감싸주기 때문임
- 자바 언어는 배열 범위를 벗어나 접근하련느 코드를 발견하면 ArrayIndexOutOfBoundsException을 던져 잘못된 메모리 접근을 막음
3)
- 코드를 실행해도 "ConstClass 초기화!"는 출력되지 않음
- 자바 소스 코드에서는 ConstClass 클래스에 정의된 상수인 HELLO_WORLD를 이용하고 있지만, 컴파일 과정에서 상수 전파 최적화가 이루어지기 때문임
- 그 결과 상수의 값 "hello world"는 NotInitialization_3 클래스의 상수 풀에 직접 저장되고, ConstClass.HELLO_WORLD를 참조하는 코드는 NotInitialization_3 클래스 자체의 상수 풀을 참조하도록 변경됨
- 즉, NotInitialization_3 클래스 파일에는 ConstClass 클래스의 내용을 가리키는 심벌 참조가 만들어지지 않음
추가) 인터페이스 로딩
더보기- 인터페이스 로딩 과정은 클래스 로딩과는 살짝 달라서 몇 가지 전용 명령어가 준비되어 있음
- 인터페이스 초기화 역시 클래스 초기화와 공통된 부분이 있음
- 컴파일러는 인터페이스가 정의한 멤버 변수들을 초기화하는 용도로 여전히 클래스 생성자인 <clinit>() 메서드를 생성함
- 인터페이스 초기화에는 상위 인터페이스 초기화가 필요 없음
- 클래스를 초기화하려면 먼저 상위 클래스를 모두 초기화해야 함
- 상위 인터페이스 초기화는 해당 상위 인터페이스가 실제로 사용될 때 이루어짐
클래스 로딩 처리 과정
1) 로딩
- '클래스 로딩'의 전체 과정 중 한 단계
- JVM은 로딩 단계에서 다음 세 가지 작업을 수행해야 함
- 완전한 이름을 보고 해당 클래스를 정의하는 바이너리 바이트 스트림(클래스 파일)을 가져온다.
- 바이트 스트림으로 표현된 정적인 저장 구조를 메서드 영역(Method Area)에서 사용하는 런타임 데이터 구조로 변환한다.
- 로딩 대상 클래스를 표현하는 java.lang.Class 객체를 힙 메모리에 생성한다.
- 이 Class 객체는 어플리케이션이 메서드 영역에 저장된 타입 데이터를 활용할 수 있게 하는 통로가 됨
- <자바 가상 머신 명세>에서 로딩 단계의 구현 방식을 구체적으로 명시하지 않았기 때문에 JVM은 로딩 단계에서 광범위한 작업을 수행할 수 있게 되었음
- 예시) 동적 프록시 기술
- java.lang.reflect.Proxy의 ProxyGenerator.generateProxyClass() 메서드는 지정한 인터페이스에 대해 "*$Proxy" 형태의 프록시 클래스에 해당하는 바이너리 바이트 스트림을 생성해준다.
- 예시) 동적 프록시 기술
- 참고)
- 로딩 단계와 링킹 단계의 일부 동작은 서로 중첩되어 진행될 수 있음 (로깅 단계에서 시작한 작업들이 완료되기 전에 링킹 단계 작업이 시작될 수 있음)
- 하지만 시작 시간 기준으로는 항상 로딩 단계가 먼저 일어남
로딩 방식
- 배열 외 타입(클래스, 인터페이스)의 로딩
- 로딩 단계는 JVM에 내장된 부트스트랩 클래스 로더를 사용하거나 사용자 정의 클래스 로더를 사용해 수행할 수 있음
- ClassLoader의 findClass() 또는 loadClass() 메서드를 오버라이딩하면 바이트 스트림을 얻는 방법을 통제할 수 있음
- 배열 로딩
- 배열 클래스는 클래스 로더가 생성하지 않고 JVM이 직접 메모리에 동적으로 생성함
- 단, 배열 클래스의 원소 타입은 클래스 로더를 통해 로드됨
- ex) int[][] 타입 배열의 원소 타입은 int
- 단, 배열 클래스의 원소 타입은 클래스 로더를 통해 로드됨
- 배열 클래스(이하 C로 칭함) 생성 규칙
- 배열의 컴포넌트 타입이 참조 타입이면, 로딩 과정을 재귀적으로 수행하여 컴포넌트 타입을 로딩한다.
- 배열 C는 컴포넌트 타입을 로드하는 클래스 로더의 이름 공간(namespace)에 자리하게 됨
- 타입은 클래스 로더에서 유일해야 함
- *컴포넌트 타입: int[][] 타입 배열의 컴포넌트 타입은 int[]이며 원소 타입은 여전히 int
- int[] 타입 배열에서는 원소 타입과 컴포넌트 타입이 똑같이 int
- 배열 C는 컴포넌트 타입을 로드하는 클래스 로더의 이름 공간(namespace)에 자리하게 됨
- 배열의 컴포넌트 타입이 참조 타입이 아니면, JVM은 배열 C를 부트스트랩 클래스 로더에 맡긴다.
- 배열 클래스의 접근성은 해당 컴포넌트 타입과 같다.
- 컴포넌트 타입이 참조 타입이 아닌 배열 클래스라면, 기본적으로 public이라서 모든 클래스와 인터페이스에서 접근할 수 있음
- 배열의 컴포넌트 타입이 참조 타입이면, 로딩 과정을 재귀적으로 수행하여 컴포넌트 타입을 로딩한다.
- 배열 클래스는 클래스 로더가 생성하지 않고 JVM이 직접 메모리에 동적으로 생성함
정리하면,
- 로딩 단계가 끝나면 바이너리 바이트 스트림(클래스 파일)은 JVM이 정의한 형식에 맞게 메서드 영역에 저장됨
- Method Area에 저장되는 데이터 구조는 아래의 정보를 포함함
- 클래스 이름, 부모 클래스
- 필드 정의, 메서드 정의, 접근 제어자, 인터페이스 목록
- 런타임 상수 풀 등
- *클래스 파일의 상수 풀 --> JVM 내부 구조로 파싱 --> Method Area의 런타임 상수 풀로 저장
- *런타임 상수 풀은 클래스마다 하나씩 존재함
- Method Area는 클래스 메타데이터 저장소
- Method Area에 저장되는 데이터 구조는 아래의 정보를 포함함
- 타입 정보(클래스, 인터페이스 정보)를 메서드 영역에 올바르게 저장한 다음에는 해당 java.lang.Class 객체를 자바 힙에 초기화 함
- JVM은 로딩한 클래스마다 java.lang.Class 타입의 객체를 하나 생성함. 이 객체는 자바 힙에 올라가고, 해당 클래스에 대한 메타 정보를 Method Area로부터 참조함
- Class 객체는 프로그램에서 메서드 영역 안의 타입 데이터에 접근하기 위한 통로 역할을 함
2) 검증
- 링킹 과정 중 첫 번째 단계
- 두 가지 목적
- 클래스 파일의 바이트 스트림에 담긴 정보가 <자바 가상 머신 명세>에서 규정한 모든 제약을 만족하는지 확인한다.
- 이 정보를 코드로 변환해 실행했을 때 JVM 자체의 보안을 위협하지 않는지 확인한다.
- 검증은 크게 4단계를 수행함 (자바 SE 7용 <자바 가상 머신 명세>에서부터 제약 조건과 검증 규칙이 구체화 됨)
- 파일 형식 검증
- 메타데이터 검증
- 바이트코드 검증
- 심벌 참조 검증
- 검증 단계는 매우 중요하지만 필수는 아님
- 프로덕션 환경에서 실행할 때는 프로그램에서 실행하는 모든 코드를 신뢰할 수 있다면 검증을 건너뛰기도 함
- -Xverify:none 매개변수 지정
- 프로덕션 환경에서 실행할 때는 프로그램에서 실행하는 모든 코드를 신뢰할 수 있다면 검증을 건너뛰기도 함
추가) 검증 주요 4단계
더보기- 파일 형식 검증
- 바이트 스트림이 클래스 파일 형식에 부합하고 현재 버전의 가상 머신에서 처리될 수 있는지 확인
- 검증 단계의 주된 목적은 입력 바이트 스트림이 올바르게 해석되어 메서드 영역에 저장되어 있는지, 파일 형태가 자바 타입 정보 설명에 대한 요구사항을 준수하는지 확인하는 것
- 이 단계의 검증은 바이너리 바이트 스트림을 대상으로 이루어지며, 검증을 통과하면 바이트 스트림이 JVM 메모리 중 Method Area에 저장됨
- 이어지는 3개 단계는 모두 메서드 영역에 저장된 구조가 대상이 됨 (즉, 이후 단계에서는 바이트 스트림을 직접 읽지 않는다는 뜻)
- 메타데이터 검증
- 바이트 코드로 설명된 정보의 의미를 분석하여 서술된 정보가 <자바 가상 머신 명세>의 요구사항을 충족하는지 확인
- 주된 목적은 클래스의 메타데이터 정보에 대한 의미론적 검증을 수행하여 <자바 언어 명세>와 일치하지 않는 메타데이터가 섞여 있지 않은지 확인하는 것
- 바이트코드 검증
- 전체 검증 과정에서 가장 복잡한 단계
- 주된 목적은 데이터 흐름과 제어 흐름을 분석하여 프로그램의 의미가 적법하고 논리적인지 확인하는 것
- 앞서 메타데이터 검증에서 데이터 타입 관련 검증을 마친 후, 이번 단계에서는 클래스의 메서드 본문(클래스 파일의 Code 속성)을 분석함
- 메서드가 런타임에 가상 머신의 보안을 위협하는 동작을 하지 않는지 확인하는 것
- 데이터 흐름 분석과 제어 흐름 분석은 굉장히 복잡하기 때문에 바이트코드 검증 단계가 자칫 길어질 수 있어, JDK 6 이후로는 가능한한 많은 유효성 검사를 javac 컴파일러로 옮김
- 심벌 참조 검증
- 가상 머신이 심벌 참조를 직접 참조로 변환할 때 수행됨
- 이 변환은 링킹의 세 번째 단계인 해석 단계에서 일어남
- 현재 클래스가 참조하는 특정 외부 클래스, 메서드, 필드, 그 외 자원들에 접근할 권한이 있는지 확인함
- JDK 9부터 자바에 모듈 개념이 도입되면서 public 타입이어도 프로그램 어디에서든 접근할 수는 없게 됨. 따라서 모듈 간 접근 권한까지 확인해야 함
- 주된 목적은 해석을 제대로 수행할 수 있는지 확인하는 것
- 해당 검증을 통과하지 못하면 JVM이 IncompatibleClassChangeError의 하위 예외를 던짐
- 가상 머신이 심벌 참조를 직접 참조로 변환할 때 수행됨
3) 준비
- 클래스 변수(정적 변수)를 메모리에 할당하고 초기값을 설정하는 단계
- 개념적으로는 클래스 변수들이 사용하는 메모리를 메서드 영역에 할당해야 하지만, 메서드 영역 자체는 JVM이 정의한 논리적 영역임
- JDK 8부터 클래스 변수는 클래스 객체와 함께 자바 힙에 저장됨 - 참고
- 논리적으로는 메서드 영역에 저장되지만, 실제 저장은 자바 힙에 저장되는 것
- "클래스 변수는 메서드 영역에 존재한다"라는 말은 논리적으로 그렇다는 뜻
- 주의
- 인스턴스 변수(멤버 변수)가 아닌 클래스 변수만 할당된다.
- 인스턴스 변수는 객체가 인스턴스화될 때 객체와 함께 자바 힙에 할당됨
- 준비 단계에서 클래스 변수에 할당하는 초기값은 해당 데이터 타입의 제로값이다.
- ex) public static int value = 123;
- 준비 단계를 마친 직후 value 변수에 할당된 초기값은 123이 아닌 0
- 123을 할당하는 putstatic 명령어는 클래스 생성자인 <clinit>() 메서드에 포함되어 있고, 준비 단계에서는 어떠한 자바 메서드로 아직 실행되지 않은 상태이기 때문
- 123을 할당하는 일은 '클래스 초기화 단계'에 가서야 이루어짐
- 예외)
- 클래스 필드의 필드 속성 테이블에 ConstantValue 속성이 존재한다면, 준비 단계에서 변수의 초기값으로 ConstantValue 속성이 지정한 값을 할당함
- ex) public static final int value = 123;
- 해당 코드를 컴파일하면 javac가 value 변수를 위한 ConstantValue 속성을 생성함
- 가상 머신은 준비 단계에서 ConstantValue에 설정된 값을 value에 할당함
- ex) public static int value = 123;
- 인스턴스 변수(멤버 변수)가 아닌 클래스 변수만 할당된다.
4) 해석
- JVM이 (클래스 메타데이터 내) 상수 풀의 심벌 참조를 직접 참조로 대체하는 과정
- *심벌 참조: 클래스 파일에서 CONSTANT_Class_info, CONSTANT_Fieldref_info, CONSTANT_Methodref_info 등
- 주로 7가지 타입의 심벌 참조에 대해 수행함 (클래스/인터페이스, 필드, 클래스 메서드, 인터페이스 메서드, 메서드 타입, 메서드 핸들, 호출 사이트 지정자)
- <자바 가상 머신 명세>는 해석 단계를 수행하는 시간을 특정하지 않고, 그 대신 심벌 참조를 다루는 바이트코드 명령어들에 대해 실행하도록 규정함
- 따라서 클래스를 로드할 때 심벌 참조를 언제 해석할 지는 가상 머신이 결정함
- 상수 풀에 있는 심벌 참조를 바로 해석할지 또는 심벌 참조가 실제로 사용될 때까지 기다릴지
- 심벌 참조를 다루는 바이트코드 명령어는 총 17개
- anewarray, checkcast, getfield, getstatic, instanceof, invokedynamic, invokeinterface, invokespecial, invokestatic, invokevirtual, ldc, ldc_w, ldc2_w, multianewarray, new putfield, putstatic
- 따라서 클래스를 로드할 때 심벌 참조를 언제 해석할 지는 가상 머신이 결정함
- 규칙
- 동일한 심벌 참조에 대해 해석 요청이 여러 번 이루어지므로, 가상 머신은 첫 번째 해석 결과를 캐시함
- 실행 중에 상수 풀의 내용을 직접 참조했다면, 그 상수는 '해석되었다'고 표시하여 같은 해석을 반복하지 않도록 함
- 해석을 반복해 수행하더라도 JVM은 같은 대상에 대해서는 항상 같은 결과를 내야 함
- 특정 심벌 참조에 대한 처음 해석이 성공하면 다음번 요청도 반드시 성공해야 하고, 처음 해석이 실패했다면 다른 코드에서 요청한 해석 역시 실패해야 함(해당 심벌이 나중에 JVM 메모리에 성공적으로 로드되었어도)
- 주의) invokedynamic 명령어에는 해당 규칙들이 적용되지 않음
- 즉, invokedynamic에 의해 해석된 심벌 참조를 다른 invokedynamic이 다시 요청하면 이전 해석과는 결과가 다를 수 있음
- invokedynamic의 목적은 원래 동적 언어 지원이므로 이 명령어가 사용하는 참조는 '동적으로 계산된 호출 사이트 지정자(dynamically computed call site specifier)'라고 함
- 여기서 '동적'은 프로그램이 이 명령어를 실행할 때까지 해석을 수행할 수 없다는 뜻
- 동일한 심벌 참조에 대해 해석 요청이 여러 번 이루어지므로, 가상 머신은 첫 번째 해석 결과를 캐시함
추가) 해석 단계에서 이야기하는 심벌 참조와 직접 참조
더보기심벌 참조
- 몇 가지 심벌로 참조 대상을 설명한다.
- 여기서 심벌은 대상을 명확하게 지칭하는 데 이용될 수 있는 모든 형태의 리터럴이 될 수 있다.
- 심벌 참조는 가상 머신이 구현한 메모리 레이아웃과 관련이 없고, 참조 대상이 반드시 가상 머신의 메모리에 로드되어 있을 필요도 없다.
- 메모리 레이아웃은 가상 머신 구현에 따라 달라질 수 있지만, 심벌 참조는 달라지지 않는다.
- 심벌 참조에 쓰일 수 있는 리터럴 형태는 <자바 가상 머신 명세>의 클래스 파일 구조에 명확하게 정의되어 있기 때문
직접 참조
- 포인터, 상대적 위치(오프셋) 또는 대상의 위치를 간접적으로 가리키는 핸들
- 가상 머신에 구현된 메모리 레이아웃과 밀접하게 관련된다.
- 똑같은 심벌 참조로부터 변환했어도 가상 머신에 따라 직접 참조는 달라질 수 있다.
- 참조 대상이 가상 머신의 메모리에 이미 로드되어 존재해야 한다.
5) 초기화
- 클래스 로딩의 마지막 단계
- JVM이 드디어 사용자 클래스에 작성된 자바 프로그램 코드를 실행하기 시작함
- 준비 단계에서는 모든 변수(클래스 변수 해당, final 키워드로 정의된 상수 제외)에 시스템이 정의한 초기값인 0(데이터 타입별 제로값)을 할당함
- 초기화 단계에서는 클래스 변수와 기타 자원을 프로그램 코드에 기술한 대로 초기화함
- 즉, 초기화 단계란 클래스 생성자인 <clinit>() 메서드를 실행하는 단계임
- *해당 메서드는 자바 컴파일러가 자동으로 생성하는 메서드
- <clinit>() 생성 방식 및 프로그램 동작에 영향을 주는 방식
- 모든 클래스 변수 할당과 정적 문장 블록(static {})의 내용을 취합하여 컴파일러가 자동으로 생성한다.
- 컴파일러가 수집하는 순서는 문장이 소스 파일에 등장하는 순서에 영향을 받음
- 따라서 정적 문장 블록에서는 정적 문장 블록보다 먼저 정의된 변수에만 접근할 수 있음
- 단, 나중에 정의된 변수라도 정적 문장 블록에서 값을 할당하는 것은 가능함
- 자바 언어에서 말하는 클래스의 생성자(<init>())와는 다르다.
- 부모 클래스의 생성자를 명시적으로 호출하지 않아도 JVM은 하위 클래스의 <clinit>()가 실행되기 전에 부모 클래스의 <clinit>()부터 실행함
- JVM이 실행하는 첫 번째 <clinit>()는 java.lang.Object의 <clinit>()임
- 부모 클래스의 <clinit>()가 먼저 실행되므로 자연스럽게 부모 클래스의 정의된 정적 문장 블록이 자식 클래스의 변수 할당 연산자보다 먼저 실행된다.
- <clinit>()가 클래스나 인터페이스에 반드시 필요한 것은 아니다.
- 정적 문장 블록이 없거나 정적 변수에 값을 할당하지 않는 클래스라면 컴파일러가 해당 메서드를 생성하지 않을 수 있음
- 인터페이스도 <clinit>() 메서드가 있을 수 있다. (정적 문장 블록은 사용할 수 없지만, 정적 변수에 초기값 할당은 가능)
- 부모 인터페이스의 <clinit>() 메서드를 먼저 실행할 필요 없음
- 부모 인터페이스는 부모 인터페이스가 실제로 사용되는 시점에 비로소 초기화 됨
- 클래스를 초기화할 때도 클래스가 구현한 인터페이스의 <clinit>()를 실행하지 않음
- JVM은 클래스의 <clinit>()가 멀티스레드 환경에서 적절히 동기화되록 해야 한다.
- 여러 스레드가 한 클래스를 동시에 초기화하려 시도하면, 그중 한 스레드만 <clinit>()를 실행하고, 다른 스레드는 모두 대기하게 됨
- 따라서 클래스의 <clinit>()에 시간이 오래 걸리는 작업이 포함되어 있다면 여러 스레드가 장기간 블록될 수 있음
- 블록된 스레드는 <clinit>()를 실행하는 스레드가 실행을 끝마치면 깨어나는데, 깨어난 이후에는 <clinit>()로 진입하지 않고 건너뜀. 이런 식으로 같은 클래스 로더하에서 한 클래스는 오직 한 번만 초기화 됨
- 모든 클래스 변수 할당과 정적 문장 블록(static {})의 내용을 취합하여 컴파일러가 자동으로 생성한다.
클래스 로더
- JVM 설계진은 필요한 클래스를 얻는 방법을 어플리케이션이 정할 수 있기를 원했음
- 따라서 클래스 로딩 단계 중 '완전한 이름을 보고 해당 클래스를 정의하는 바이너리 바이트 스트림 가져오기'를 가상 머신 외부에서 수행하도록 했음
- 이 역할을 맡은 코드를 '클래스 로더'라고 함
1. 클래스와 클래스 로더
- 각 클래스 로더는 독립적인 클래스 이름 공간을 지니기 때문에 클래스 로더를 빼놓고는 특정 클래스가 JVM에서 유일한지 판단할 수 없음
- 두 클래스가 '동치인가' 여부는 두 클래스 모두 같은 클래스 로더로 로드했을 때만 의미가 있음
- 여기서 말하는 '동치인가'는 해당 클래스 객체의 equals(), isAssignableFrom(), isInstance() 메서드의 반환값 또는 instanceof 키워드로 객체들의 관계를 결정하는 상황과 관련 있음
- 서로 다른 클래스 로더로 읽어 들였다면, 비록 같은 가상 머신이고 똑같은 클래스 파일로부터 로드했더라도 다른 클래스로 인식됨
예시) 다른 클래스 로더를 사용했을 때 instanceof 키워드의 동작 결과
더보기package org.fenixsoft.jvm.chapter7; import java.io.IOException; import java.io.InputStream; /** * 클래스로더가 instanceof 키워드 동작에 미치는 영향 * * @author zzm */ public class ClassLoaderTest { public static void main(String[] args) throws Exception { // 1) 고유한 클래스 로더 생성 ClassLoader myLoader = new ClassLoader() { @Override public Class<?> loadClass(String name) throws ClassNotFoundException { try { String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class"; InputStream is = getClass().getResourceAsStream(fileName); if (is == null) { return super.loadClass(name); } byte[] b = new byte[is.available()]; is.read(b); return defineClass(name, b, 0, b.length); } catch (IOException e) { throw new ClassNotFoundException(name); } } }; // 2) 방금 만든 클래스 로더로 현재 클래스(ClassLoaderTest)의 인스턴스 생성 Object obj = myLoader.loadClass("org.fenixsoft.jvm.chapter7.ClassLoaderTest").newInstance(); // 3) 실행 결과 System.out.println(obj.getClass()); // org.fenixsoft.jvm.chapter7.ClassLoaderTest System.out.println(obj instanceof org.fenixsoft.jvm.chapter7.ClassLoaderTest); // false } }
- 1) 간단한 클래스 로더를 직접 구현한다. 자신과 같은 경로에 있는 클래스 파일을 로드할 수 있는 클래스 로더이다.
- 2) 이 로더를 이용해 이름이 "org.fenixsoft.jvm.chapter7.ClassLoaderTest"인 클래스를 로드하고 객체 인스턴스를 생성한다.
- 3) 실행 결과의 첫 번째 줄에서는 이 객체가 실제로 org.fenixsoft.jvm.chapter7.ClassLoaderTest 클래스를 인스턴스화한 것임을 알 수 있다. 그러나 두 번째 줄에서는 org.fenixsoft.jvm.chapter7.ClassLoaderTest의 인스턴스가 아니라고 한다.
- 이유)
- JVM에 ClassLoaderTest 클래스가 두 개 존재하기 때문
- 하나는 JVM의 어플리케이션 클래스 로더가 로드한 것, 다른 하나는 직접 구현한 사용자 정의 클래스 로더로 로드한 것
- 둘 다 동일한 클래스 파일로부터 만들어졌지만 JVM은 별개의 클래스로 봄
- JVM 입장에서는 서로 다른 타입을 비교한 것이므로 false를 반환한 것임
- 이유)
2. 부모 위임 모델
JVM 관점
- 클래스 로더의 종류는 다음과 같이 두 가지뿐
- JVM 자체의 일부인 부트스트랩 클래스 로더
- 핫스팟 가상 머신에서는 C++로 구현함
- 그 외 모든 클래스 로더
- 추상 클래스인 java.lang.ClassLoader를 상속하여 자바로 구현하며, 가상 머신 외부에 독립적으로 존재함
- JVM 자체의 일부인 부트스트랩 클래스 로더
자바 개발자 관점
- JDK 1.2부터 8까지 3계층 클래스 로더인 부모 위임 클래스 로딩 아키텍처를 유지해옴
- 이 시기 자바 어플리케이션은 대부분 시스템이 제공하는 다음 세 가지 클래스 로더를 통해 로드되었음
- 부트스트랩 클래스 로더
- JAVA_HOME/lib 디렉토리나 -Xbootclasspath 매개 변수로 지정한 경로에 위치한 파일들과 JVM이 클래스 라이브러리로 인식하는 파일들(rt.jar, tools.jar 같은 파일을 말하며 파일 이름으로 식별함)을 로드함
- 자바 프로그램에서 직접 참조할 수 없으므로, 커스텀 클래스 로더 작성 시 로딩을 부트스트랩 클래스 로더에 위임하고자 할 때는 참조 대신 null을 사용함
- 확장 클래스 로더
- sun.misc.Launcher$ExtClassLoader를 말하며 자바로 구현되어 있어 개발자 프로그램 안에서 직접 사용할 수 있음
- JAVA_HOME/lib/ext 디렉토리 또는 java.ext.dirs 시스템 변수로 지정한 경로의 클래스 라이브러리들을 로드함
- 어플리케이션 클래스 로더
- sun.misc.Launcher$AppClassLoader를 말함
- ClassLoader 클래스의 getSystemClassLoader() 메서드가 반환하는 클래스 로더라는 의미에서 '시스템 클래스 로더'라고도 함
- classpath상의 클래스 라이브러리들을 로드하며, 개발자가 자바 코드에서 직접 사용할 수 있음
- 어플리케이션에서 클래스 로더를 따로 만들어 사용하지 않는 경우 이 로더가 기본 클래스 로더가 됨
- 필요시 사용자가 직접 만든 클래스 로더를 추가할 수 있음
- 부트스트랩 클래스 로더
추가) 클래스 로더 부모 위임 모델
더보기- 위와 같은 클래스 로더 간 계층 관계를 클래스 로더들의 부모 위임 모델이라고 함
- 최상위 부트스트랩 클래스 로더 외에는 모두 부모가 있어야 함
- 부모-자식 관계는 상속보다는 컴포지션 관계로 구현하며 부모 로더의 코드를 재사용함
- 참고) 위와 같은 계층 관계는 필수가 아니라 자바 설계자들이 개발자들에게 권장하는 모범 사례임
- 부모 위임 모델의 동작 방식
- 모든 로드 요청은 우선 최상위인 부트스트랩 클래스 로더로 넘겨진다.
- 클래스 로딩을 요청받은 클래스 로더는 처음부터 클래스 자체를 로드하려 시도하지 않음
- 대신 수준에 맞는 상위 클래스 로더로 요청을 위임함
- 상위 로더가 자신이 처리할 요청이 아니라고 판단하면(요청 받은 클래스가 자신의 검색 범위에 없다면) 비로소 하위 로더가 시도한다.
- 모든 로드 요청은 우선 최상위인 부트스트랩 클래스 로더로 넘겨진다.
- 장점
- 클래스 로더를 부모 위임 모델로 구성하면 자바 클래스들이 자연스럽게 클래스 로더의 계층 구조를 따르게 됨
- ex) rt.jar에 포함된 java.lang.Object 클래스의 로딩은 어떤 클래스 로더에 요청하더라도 최상위인 부트스트랩 클래스 로더가 처리함
- 즉, 프로그램이 아무리 많은 클래스 로더를 활용하더라도 Object 클래스는 모두 동일한 클래스임이 보장됨(항상 동일한 클래스 로더가 로딩하니까)
- ex) rt.jar에 포함된 java.lang.Object 클래스의 로딩은 어떤 클래스 로더에 요청하더라도 최상위인 부트스트랩 클래스 로더가 처리함
- 클래스 로더를 부모 위임 모델로 구성하면 자바 클래스들이 자연스럽게 클래스 로더의 계층 구조를 따르게 됨
참고) 부모 위임 모델의 구현
더보기protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader // 부모 클래스 로더가 ClassNotFoundException을 던진다는 것은 부모 클래스 로더는 요청을 처리할 수 없다는 뜻 } if (c == null) { // If still not found, then invoke findClass in order // to find the class. long t1 = System.nanoTime(); // 부모 클래스 로더가 실패하면 자신의 findClass() 메서드를 호출해 직접 시도함 c = findClass(name); // this is the defining class loader; record the stats PerfCounter.getParentDelegationTime().addTime(t1 - t0); PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }
- 요청받은 클래스가 이미 로드되어 있는지 확인하고, 아직이라면 부모 로더의 loadClass를 호출함
- 부모 로더가 null이면 부트스트랩 클래스 로더를 부모로 사용함
- 부모 로더가 로드에 실패하여 ClassNotFoundException을 던지면 비로소 자신의 findClass()를 호출함
자바 모듈 시스템
- JDK 9에 도입된 모듈 시스템(JPMS)은 자바 기술에 있어 중요한 개선
- 모듈화라는 '자유롭게 설정 가능한 캡슐화 격리 메커니즘'을 만들기 위해 JVM은 클래스 로딩 아키텍처를 적절히 변형했음
- 자바 모듈 정의에는 코드 외에도 다음 내용이 포함됨
- requires: 다른 모듈에 대한 의존성 목록
- exports: 다른 모듈에서 사용할 수 있는 패키지 목록
- open: 다른 모듈에서 리플렉션 API로 접근할 수 있는 패키지
- uses: 현재 모듈이 사용할 서비스 목록
- provides: 다른 모듈에 제공하는 서비스 목록
- 특징
- JDK 9부터는 모듈이 의존하는 다른 모듈들을 명시할 수 있어, 필요한 의존성이 모두 갖춰졌는지 어플리케이션 개발 단계에서 확인할 수 있음
- 자바 모듈은 public 타입 중에서도 외부 모듈에 공개할 타입을 따로 명시하여 접근 권한을 더 세분화해 관리함
- 이러한 접근 통제는 주로 클래스 로딩 과정 > 해석 단계에서 이루어짐
모듈화 시대의 클래스 로더는 어떻게 바뀌었을까?
- 3계층 클래스 로더 아키텍처와 부모 위임 모델의 근간은 바뀌지 않았지만 주목할 만한 주요 변경점이 존재함
- 확장 클래스 로더가 플랫폼 클래스 로더로 대체되었다.
- JDK 전체가 모듈화되면서 모듈들 안의 클래스 라이브러리들은 자연스럽게 확장성 요구 사항을 충족하게 되었음
- 그 결과 JDK의 기능을 확장하기 위해 쓰이던 JAVA_HOME/lib/ext 디렉토리나 java.ext.dirs 시스템 변수를 사용하는 이전 방식을 유지할 필요가 없어짐
- 플랫폼 클래스 로더와 어플리케이션 클래스 로더가 더는 java.net.URLClassLoader로부터 파생되지 않는다.
- 모두 jdk.internal.loader.BuiltinClassLoader에서 파생됨
- 새로운 모듈식 아키텍처에서는 모듈에서 클래스를 로드하는 방법과 모듈 간 접근 처리 로직이 BuiltinClassLoader에 구현되어 있음
- 클래스 로더와 부모 위임 모델은 여전히 유지하지만 클래스 로딩의 위임 관계에 변화를 주었다.
- 클래스 로딩을 요청받은 플랫폼 및 어플리케이션 클래스 로더는 부모 로더에 위임하기 전에 해당 클래스가 특정 시스템 모듈에 속하는지 확인함
- 특정 시스템 모듈에 속한다면 (부모 로더가 아닌) 해당 모듈을 담당하는 로더에 위임함
- 확장 클래스 로더가 플랫폼 클래스 로더로 대체되었다.
추가) 클래스 로더 상속 구조 변화
더보기- BuiltinClassLoader의 하위에 존재함
- 부트스트랩 클래스 로더를 자바 클래스로 구현함
- 이전까지는 C++로 따로 구현하여 자바 클래스 형태로는 존재하지 않았음
- 이전 코드 하위호환을 위해 Object.class.getClassLoader()처럼 부트스트랩 클래스 로더를 얻는 모든 시나리오에서는 여전히 BootClassLoader 객체가 아닌 null을 반환함
추가) JDK 9 이후의 클래스 로더 위임 방식
더보기- 자바 모듈 시스템에서 세 가지 클래스 로더는 각각 다음 모듈을 담당함
'DevBook > JVM 밑바닥까지 파헤치기' 카테고리의 다른 글
[JVM] 컴파일과 최적화 (1) - 프론트엔드 컴파일과 최적화 (0) 2025.04.06 [JVM] 바이트코드 실행 엔진 (0) 2025.04.05 [JVM] 가상 머신 실행 서브시스템 (1) - 클래스 파일 구조 (0) 2025.03.29 [JVM] 가비지 컬렉터와 메모리 할당 전략 - 메모리 할당과 회수 전략 (0) 2025.03.27 [JVM] 가비지 컬렉터와 메모리 할당 전략 (2) - 클래식 가비지 컬렉터 > G1 GC (0) 2025.03.18