포스팅 목적
클래스가 어떤 과정을 거쳐 우리가 사용할 수 있게 되는지에 대해 이해하고, 성능 최적화를 위한 키워드를 습득할 수 있도록 하자. 무엇보다 내가 사용하는 언어의 메커니즘에 대해 이해하도록 하자.
클래스 로딩 메커니즘
클래스의 생애주기
클래스 혹은 인터페이스(이하 타입으로 통일)의 생애주기는 위와 같다.
•
해석(Resolution)을 제외하고는 모두 순서대로 진행해야 한다.
◦
해석은 자바 언어의 동적 바인딩(런타임 바인딩)을 지원하기 위해서 예외다.
•
단계별로 시작되는 순서의 기준은 진행이나 완료가 아니라 시작이다.
◦
이전 단계가 완료되지 않아도 다음 단계가 병렬로 시작될수도 있다.
참고. 클래스의 초기화 시점
클래스의 초기화 단계는 즉시 시작되어야 하는 상황 여섯 가지를 엄격히 규정한다.
→ 이는 타입에 대한 능동 참조(active reference)라하며 이 외의 모든 참조 방식은 수동 참조(passive reference)라 한다.
능동 참조 시나리오 6가지
1.
바이트코드 명령어 new, getstatic, putstatic, invokestatic을 만났을 때 해당 타입이 아직 초기화되어있지 않을 경우 초기화를 촉발한다. 해당 명령어는 자바에선 다음과 같은 시나리오에서 수행된다.
a.
new 키워드로 객체 인스턴스 생성
b.
타입의 정적 필드를 조회 혹은 수정
c.
타입의 정적 메서드 호출
2.
표준 클래스 라이브러리에서 제공하는 리플렉션 메서드를 사용할 때 해당 타입이 아직 초기화되어 있지 않은 경우
3.
클래스 초기화시 상위 클래스가 초기화되어 있지 않은 경우 상위 클래스 초기화 촉발
4.
VM은 구동 직후 사용자가 지정한 메인 타입을 찾아 실행하는데 이 때 메인 타입의 초기화
5.
REF_getStatic, REF_putStatic, REF_invokeStatic, REF_newInvokeSpecial 타입 메서드 핸들을 해석해 얻은 java.lang.invoke.MethodHandle 인스턴스를 호출할 때 해당 클래스가 초기화되어 있지 않은 경우
6.
(JDK8 이후) 인터페이스의 default method가 있는 경우 해당 인터페이스를 구현한 클래스가 초기화될 때 인터페이스부터 초기화
수동 참조 시나리오
1.
하위 클래스를 통해 부모 클래스의 정적 필드를 참조하는 경우 하위 클래스 초기화는 필요 없다.
2.
배열 정의에서 클래스를 참조하는 경우 클래스 초기화를 촉발하지 않는다.
3.
상수는 컴파일 과정에서 호출하는 클래스의 상수 풀에 저장되기에 상수를 정의한 클래스의 초기화를 촉발하지 않는다.
class ConstClass {
static { System.out.println("ConstClass초기화"); }
public static final String HELLO_WORLD = "hello world";
}
public class NotInitialization_3 {
public static void main(String[] args) {
System.out.println(ConstClass.HELLO_WORLD);
}
}
Java
복사
1. 로딩은 어떻게 동작하는가?
JVM이 클래스를 로딩하는 전체 과정에서 처음 과정인 로딩 단계에서 수행되야 하는 작업은 다음 3가지이다.
1.
완전한 이름(FQCN)을 보고 해당 클래스를 정의하는 바이너리 바이트 스트림 가져오기
→ 가져오는 방식은 추상화 되어있어 다음과 같은 다양한 방식으로 가져올 수 있다.
•
ZIP 압축 파일로부터 로딩 - Ex: JAR, EAR, WAR
•
네트워크로부터 로딩 - Ex: 웹 애플릿
•
런타임에 동적으로 생성 - Ex: 프록시 기술
•
다른 파일로부터 생성 - Ex: JSP
•
DB로부터 생성 - SAP Netweaver같은 일부 미들웨어
•
암호화된 파일로부터 로딩 - 클래스 파일 디컴파일 방어를 위한 보호 조치
2.
바이트 스트림으로 표현된 정적인 저장 구조를 메서드 영역에서 사용하는 런타임 데이터 구조로 변경
3.
로딩 대상 클래스를 표현하는 java.lang.Class 객체를 힙 메모리에 생성
•
이 객체는 애플리케이션이 메서드 영역에 저장된 타입 데이터를 활용할 수 있게 하는 통로가 된다.
2. 링킹 과정
2-1. 검증
•
목적
1.
클래스 파일의 바이트 스트림에 담긴 정보가 JVM 명세에서 규정한 모든 제약을 모두 만족하는지 확인
2.
이 정보를 코드로 변환해 실행했을 때 JVM의 보안을 위협하지 않는지 확인
이러한 검증 과정을 모두 작성할순 없지만 요약하면 다음과 같이 네 가지 단계를 거쳐 검증을 할 수 있다.
1.
파일 형식 검증
바이트 스트림이 클래스 파일 형식에 부합하고 현재 버전의 JVM에서 처리될 수 있는지 확인한다.
→ 모든 사항은 아니며 주된 목적은 올바르게 해석되어 메서드 영역에 저장되어 있고, 파일 형태가 자바 정보 설명에 대한 요구 사항을 준수하는지 확인하는 것이다.
•
매직 넘버인 0xCAFEBABE로 시작하는가?
•
메이저 버전과 마이너 버전 번호가 현재 JVM이 허용하는 범위인가?
•
지원하지 않는 타입의 상수가 상수 풀에 들어있지 않은가?
•
상수를 가리키는 다양한 인덱스 값 중 존재하지 않는 상수나 타입에 맞지 않는 상수를 가리키는 경우가 없는가?
•
CONSTANT_Utf8_info 타입 상수 중 UTF-8 인코딩에 부합하지 않는 데이터는 없는가?
•
클래스 파일 형식을 이루는 요소 중 일부 또는 파일 자체가 생략되었거나 더 추가된 정보가 있는가?
2.
메타데이터 검증
바이트 코드로 설명된 정보를 분석해 JVM 명세의 요구사항을 충족하는지 확인한다.
→ 클래스의 메타데이터 정보에 대한 의미론적 검증을 수행해 JVM 명세와 일치하지 않는 메타데이터가 섞여있는지 확인하는게 주 목적이다.
•
상위 클래스가 있는가(java.lang.Object 외의 모든 클래스는 상위 클래스가 필요하다.)
•
상위 클래스가 상속을 허용하는가
•
(추상 클래스가 아니라면) 상위 클래스 또는 인터페이스에서 정의한 필수 메서드를 모두 구현했는가?
•
필드와 메서드가 상위 클래스와 충돌하는가?
3.
바이트코드 검증
데이터 흐름과 제어 흐름을 분석해 프로그램이 논리적인지 확인한다.
•
피연산자 스택의 데이터 타입과 명령어 코드 시퀀스가 항시 어울려 동작하는지 확인
◦
피연산자 스택에는 int 타입 데이터를 넣어두고 실행 시 지역 변수 테이블에 long 타입으로 읽어 들이면 안된다.
•
점프 명령어가 메서드 본문 바깥의 바이트 코드 명령어로 점프해선 안된다.
•
메서드 본문에서 형 변환이 항상 유효함을 보장한다.
•
사실 바이트코드 검증은 완벽하게 검증하는건 불가능하다. (ex: 정지 문제)
◦
JDK 6 이후 가능한 많은 유효성 검사를 javac 컴파일러로 이전
◦
Code 속성의 속성 테이블에 StackMapTable이라는 새로운 속성을 추가 하여 지역 변수 테이블과 피연산자 스택의 상태를 설명해준다.
▪
바이트코드 검증 단계에서 상태가 유효한지 추론할 필요를 줄여준다.
▪
StackMapTable 속성도 악의적으로 변조될 수 있으니 주의해아 한다.
4.
심볼 참조 검증
가상 머신이 심벌 참조를 직접 참조로 변환할 때 수행된다.
변환은 링킹의 세 번째 단계인 해석 단계에서 일어나는데 심벌 참조 검증은 현재 클래스가 참조하는 특정 외부 클래스, 메서드, 필드, 그 외 자원들에 대한 접근 권한을 체크한다.
•
심벌 참조에서 문자열로 기술된 완전한 이름에 해당하는 클래스를 찾을 수 있는가?
•
단순 이름과 필드 서술자와 일치하는 메서드나 필드가 해당 클래스에 존재하는가?
•
심벌 참조가 기리키는 클래스, 필드, 메서드의 접근 지정자가 현재 클래스의 접근을 허용하는가?
참고: 검증은 필수가 아니다.
검증 단계는 매우 중요하다. 하지만 그렇다고 필수는 아니다.
그렇기에 모든 코드가 신뢰할 수 있다면 프로덕션 환경에서 실행할 때는 검증을 건너뛸수도 있다.
검증을 생략할 경우 가상 머신이 클래스를 로딩하는 시간이 꽤나 단축될 수 있다.
-Xverify:none 매개변수 지정
2-2 준비
•
준비 과정에선 클래스 변수(정적 변수)를 메모리에 할당하고 초기값을 설정한다.
•
준비 단계에선 인스턴스 변수가 아닌 클래스 변수만 할당된다.
◦
인스턴스 변수는 객체가 인스턴스화 될 때 함께 자바 힙에 할당된다.
•
클래스 변수에 할당하는 초기값은 해당 데이터 타입의 제로값이다.
◦
public static int value = 123; 이 코드가 준비 과정에서 value에 할당되어 있는 초기값은 123이 아니라 0이다.
▪
123이 할당되는 putstatic 명령어는 클래스 생성자인 <clinit>() 메서드에 포함된다.
2-3 해석
•
해석은 자바 가상 머신이 상수 풀의 심벌 참조를 직접참조로 대체하는 과정
•
심볼 참조(Symbolic Reference)
◦
컴파일타임에 저장되는 클래스, 필드, 메서드 등에 대한 기호적인 표현
◦
Class 파일의 Constant Pool(상수 풀)에 저장되며, 실제 메모리 주소가 아닌 클래스명, 필드명, 메서드명 등의 문자열 형태로 존재한다.
▪
클래스 참조: java/lang/String
▪
필드 참조: java/lang/System.out
▪
메서드 참조: java/io/PrintStream.println
•
직접 참조(Direct Reference)
◦
JVM이 실제 메모리 주소를 참조하는 형태
◦
심볼 참조를 해석하여 해당 메모리 위치(필드, 메서드, 클래스의 실제 주소)를 가리키는 포인터로 변환한다.
◦
똑같은 심볼 참조를 해석해도 가상 머신에 따라 직접 참조 주소는 달라질 수 있다.
◦
직접 참조는 참조 대상이 가상 머신의 메모리에 이미 존재해야 한다.
해석은 주로 7가지 타입의 심벌 참조에 대해 수행하는데, 7가지 타입이란
•
클래스/인터페이스(CONSTANT_Class_info)
•
필드(CONSTANT_Fieldref_info)
•
클래스 메서드(CONSTANT_Methodref_info)
•
인터페이스 메서드(CONSTANT_InterfaceMethodref_info)
•
메서드 타입(CONSTANT_MethodType_info)
•
메서드 핸들(CONSTANT_MethodHandle_info)
•
호출 사이트 지정자(CONSTANT_Dynamic_info)를 말한다.
3. 초기화
•
클래스 로딩의 마지막 단계
•
클래스 생성자인 <clinit>() 메서드를 실행하는 단계라고도 할 수 있다.
컴파일러에 의해 자동생성되는 <clinit>() 메서드
컴파일러는 모든 클래스 변수 할당과 정적 문장 블록(static {})의 내용을 취합해 자동으로 <clinit>() 메서드를 생성한다. 그리고 이 과정에서 문장에 작성된 순서는 컴파일러의 수집 순서에 영향을 준다. 그래서 정적 문장 블록 내에서는 이미 정의된 변수에만 접근할 수 있다.
(그하지만, 값을 할당하는 경우에는 나중에 정의된 변수도 접근할 수 있다. )
좀 더 쉽게 이해하기위해 코드를 보면 다음과 같다고 할 수 있다.
public class Test {
static {
1= 0; // 나중에 정의한 변수에 값을 할당할 수 있다.
// 다음 코드는 컴파일러가 "Cannot reference a field before it is defined"
// 메시지를 출력하며 컴파일을 거부한다.
// '정의되기 전 필드는 참고할 수 없다' 는 뜻이다.
System.out.print(i);
}
static int i = 1;
}
Java
복사
클래스 로더
클래스 로더는 다음 목적을 수행하는 역할을 담당한다.
클래스 로딩 단계 중 완전한 이름을 보고 해당 클래스를 정의하는 바이너리 바이트 스트림 가져오기를 책임지는 역할
이 클래스로더는 클래스를 로딩하는 역할이 있지만 그게 전부는 아니다.
클래스로더는 각각 독립적인 이름 공간을 지닌다. 그렇기 때문에 클래스 로더가 하나 이상인 경우 각각의 클래스 로더는 각각의 이름 공간을 가진다고 할 수 있다.
이 말인즉슨, 동일한 Apple이라는 객체를 두 클래스 로더에서 각각 로딩할 경우 같은 객체라도 동치성 여부 검사 결과가 false가 될 수 있는 것이다.
public class Apple {}
public class ClassLoaderTest {
public static void main(String[] args) throws Exception{
// 간단한 클래스로더 직접 구현
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);
}
}
};
Object obj = myLoader.loadClass(Apple.class.getPackageName() + ".Apple").newInstance();
System.out.println(obj.getClass()); // ....Apple
System.out.println(obj instanceof Apple); // false
}
}
Java
복사
위 코드를 실행해보면 Apple 클래스를 정상적으로 로드했음에도 불구하고 instanceof 를 이용한 동치성 검사는 실패한다.
그 이유는 직접 구현한 클래스로더와 기본 클래스로더가 각각 클래스를 로드했기 때문에 JVM에는 Apple이라는 클래스가 두 개가 존재하기 때문이다. 클래스 로더는 하나만 존재하는게 아니기 때문에 이렇게 사용자가 강제로 등록한 클래스로더가 아니더라도 빈번하게 이런 이슈가 발생할 수 있을 것 같다. 하지만, 실무에서 이런 경험을 하는 일은 없는데, 이는 클래스 로딩 메커니즘 중 부모 위임 모델(Parent Delegation Model)을 따르는 덕분이다.
부모 위임 모델
부모 위임 모델은 클래스로더가 클래스 로드를 요청 받으면, 먼저 부모 로더에게 위임(Delegation)하여 먼저 로드된 클래스가 있는지 확인하는 방식으로 다음 세 가지 목적을 가진다.
1.
클래스의 일관성 유지
: 부모 로더가 먼저 표준 클래스를 로드해 자식 로더가 동일한 클래스를 다시 로드하는 문제를 방지하여 일관성을 유지하게 한다.
2.
보안 강화
: 부트스트랩 클래스 로더가 핵심 라이브러리를 로드하도록 보장하여 사용자 정의 클래스 로더가 시스템 클래스를 변경해 오버라이딩 하는 것을 방지한다.
3.
성능 최적화
: 같은 클래스가 여러번 로드되지 않도록 해 메모리 낭비를 방지하고, 한 번 로드된 클래스는 캐싱되어 재사용이 가능하기에 불필요한 중복 로딩을 방지한다.
하지만, 부모 위임 모델은 필수가 아니라 권장 모델이다.
그렇기에 대부분은 이 모델을 따르지만 따르지 않는 경우도 없다고 할 수 없고, JDK9의 모듈시스템이 등장하기 전까지 3번의 부모 위임 모델에 대한 도전이 있었다.
1.
부모 위임 모델 도입 이전에 이미 클래스 로더를 확장해서 사용하던 사용자들
부모 위임 모델은 JDK1.2에서 소개되었다. 하지만 그 이전부터 클래스로더를 확장해서 사용하던 개발자들이 있었고, 이러한 개발자들이 부모 위임 모델을 도입하기 위해선 추가 작업을 해야 했다.
그 작업들은
•
ClassLoader의 loadClass()를 오버라이딩 하지 못하도록 막은 뒤
•
protected findClass()를 추가하고,
•
개발자들이 findClass()를 오버라이딩한 후 loadClass()안에서 호출하도록 했다.
즉, 부모 로더가 로딩에 실패하면 자동으로 findClass()를 호출해 로딩을 완료하도록 했다.
2.
부모 위임 모델 자체의 결함
부모 위임 모델은 여러 클래스 로더가 협력하며 기본 타입의 일관성을 지키도록 하는 방식이지만, 반대로 사용자 코드를 호출 해야하는 기본타입도 존재할 수 있다.
대표적인 예로 JNDI가 있다. JNDI는 부트스트랩 클래스 로더가 로드하지만, JNDI는 다른 업체에서 구현한 클래스패스에 배포한 JNDI 서비스 공급자 인터페이스(SPI)의 코드를 호출해야하는데, 부트스트랩 클래스 로더는 이러한 코드를 인식하고 로드할 수 없다.
이를 해결하기 위해 스레드별 콘텍스트 클래스 로더(java.lang.Thread.setContextClassLoader())를 사용한다.
JNDI는 setContextClassLoader 메서드를 통해 등록한 콘텍스트 클래스 로더를 이용해 SPI 서비스 코드를 로드한다. 그러면 부모 클래스 로더가 로딩을 자식 로더에 요청하게 된다.
3.
프로그램의 동적 능력 부여(Hot Swap, Hot Deploy, …Hot xxx)
개발자들에겐 런타임 환경에서 자바 애플리케이션 구성 요소들을 교체 및 추가할 수 있도록 하고싶어했던 욕구가 있었다.
IBM을 중심으로하는 OSGi는 이런 욕구를 해결해주어 핫 배포를 가능하도록 해줬다.
오라클에서는 직소 프로젝트를 꺼내표준화가 되었지만, OSGi의 핫 배포의 이점을 완전히 대체하기는 힘들다.
OSGi는 어떻게 핫 배포를 제공할 수 있는것일까?
핵심은 독자적인 클래스 로더 메커니즘에서 오는데, OSGi에선 프로그램 모듈을 번들이라 한다. 이 번들 각각은 자체 클래스 로더를 지니고 있는데, 번들을 교체할 때 핫 코드 교체를 위해 자체 클래스 로더와 함께 교체된다.
클래스 로더는 부모 위임 모델이 권장하는 트리 구조가 아니라 더 복잡한 네트워크 구조로 확장되는데, 클래스 검색 순서는 다음과 같다.
1.
java.*로 시작하는 클래스라면 부모 클래스 로더에 위임한다.
2.
OSGi 프레임워크의 부트 위임 목록에 포함된 클래스라면 부모 클래스 로더에 위임한다.
3.
그렇지 않으면 임포트 목록에 있는 클래스들을 익스포트된 클래스가 속한 번들의 클래스 로더에 위임한다.
4.
그렇지 않으면 현재 번들의 클래스패스를 찾아 자체 클래스 로더로 로드한다.
5.
그렇지 않으면 대상 클래스가 자체 프래그먼트 번들 안에 있는지 확인하고, 그렇다면 해당 프래그먼트 번들의 클래스 로더에 위임한다.
6.
그렇지 않으면 동적 임포트 목록에서 번들을 찾아 번들의 클래스 로더에 위임한다.
7.
그렇지 않으면 클래스 검색이 실패한다.
보면 처음 두 단계는 부모 위임 모델을 따르고 그 이후 각 상황에 맞는 클래스 로더에서 로딩이 수행된다.