JVM의 클래스 로더 (Class Loader)
자바의 클래스들이 언제 어디서 메모리에 올라가고 클래스 멤버들이 초기화되는지, 원리를 알기위해선 우선 JVM(자바 가상 머신)의 클래스 로더(Class Loader)의 진행 방식에 대해 알 필요가 있다.
특히나 다음과 같이 내부(중첩) 클래스 중에 static 키워드가 붙고 안붙고의 유무에 따른 메모리 로드 차이와 쓰레드에 세이프하다는 등, 이러한 부분은 꽤나 고수준의 지식이기 때문에 글 로만 훑어보고 넘어가기 다반사라 이를 이해하기 위해선 클래스 로더 부터 살펴볼 필요가 있다.
class Outer {
class Inner {
}
static class Holder {
}
}
클래스 로더는 컴파일 된 자바의 클래스 파일(*.class)을 동적으로 로드하고, JVM의 메모리 영역인 Runtime Data Areas에 배치하는 작업을 수행한다.
클래스 로더에서 class 파일을 로딩하는 순서는 다음과 같이 3단계로 구성된다. (Loading → Linking → Initialization)
- Loading(로드) : 클래스 파일을 가져와서 JVM의 메모리에 로드한다.
- Linking(링크) : 클래스 파일을 사용하기 위해 검증하는 과정이다.
- Initialization(초기화) : 클래스 변수들을 적절한 값으로 초기화한다.
유의할점은, 클래스를 메모리에 올리는 Loading 기능은 한번에 메모리에 올리지 않고, 어플리케이션에서 필요한 경우 동적으로 메모리에 적재하게 된다는 점이다.
사람들이 많이들 착각하는 점이 위의 3가지 과정이 거의 동시에 이루어져서 같이 묶어 생각하는데, 엄연히 클래스 로드(Loading)와 초기화(Initialization)은 다른 작업이다. (이는 뒤에서 자세히 다룰 것이다)
그리고 클래스나 클래스 내의 static 멤버들을 소스를 실행하자마자 한번에 메모리에 모두 올라가는줄 착각하는데, 곰곰히 생각해보면 언제 어디서 사용될지 모르는 static 멤버들을 처음에 전부 메모리에 올린다는건 비효율적이기 때문에, 클래스 내의 멤버를 호출하게 되면 그때서야 클래스가 동적으로 메모리에 로드한다.
즉, JVM은 실행될때 모든 클래스를 메모리에 올려놓지 않고, 그때 마다 필요한 클래스를 메모리에 올려 효율적으로 관리하는 것이다.
역시 이론만으로는 머릿속으로나 마음속으로나 완벽히 이해가 되지 않는다.
실제 자바 코드를 실행하면서 정말로 클래스가 언제 어디서 어느때에 호출되는지 직접 알아보자.
클래스 로드 시점 파헤치기
클래스 로딩은 class loader가 .class 파일을 찾고 JVM 메모리에 올려놓는 것을 의미한다.
클래스가 로드 되었는지 안되었는지 확인하는 방법은, 콘솔에 java 명령어에 -verbose:class 옵션을 사용하면 클래스 로딩을 디버그 할 수 있다.
> java -classpath 클래스파일위치 -verbose:class 클래스명
1. 아무것도 호출 하지 않음
- 메인 메소드를 실행하였으니, 메인 메소드가 위치하고 있는 Main 클래스만 로드 된다.
- 아무리 static 멤버들이 있더라도 직접 가져와 사용하지 않는 경우 Outer 클래스는 로드되지 않는다.
class Outer {
// static 변수
static String value = "> Outer 클래스의 static 필드 입니다.";
// static final 상수
static final String VALUE = "> Outer 클래스의 static final 필드 입니다.";
Outer() { System.out.println("> Outer 생성자 초기화"); }
// static 메서드
static void getInstance() {
System.out.println("> Outer 클래스의 static 메서드 호출");
}
// inner 클래스
class Inner {
Inner() { System.out.println("> Inner 생성자 초기화"); }
}
// static inner 클래스
static class Holder {
static String value = "> Holder 클래스의 static 필드 입니다.";
static final String VALUE = "> Holder 클래스의 static final 필드 입니다.";
Holder() { System.out.println("> Holder 생성자 초기화"); }
}
}
public class Main {
public static void main(String[] args) {
}
}
2. 인스턴스 생성
- 클래스를 인스턴스화 하면 예상대로 당연히 클래스가 로드된다.
- 그러나 내부 클래스는 직접 인스턴스를 생성하지 않으니 로드되지 않는다.
class Outer {
// static 변수
static String value = "> Outer 클래스의 static 필드 입니다.";
// static final 상수
static final String VALUE = "> Outer 클래스의 static final 필드 입니다.";
Outer() { System.out.println("> Outer 생성자 초기화"); }
// static 메서드
static void getInstance() {
System.out.println("> Outer 클래스의 static 메서드 호출");
}
// inner 클래스
class Inner {
Inner() { System.out.println("> Inner 생성자 초기화"); }
}
// static inner 클래스
static class Holder {
static String value = "> Holder 클래스의 static 필드 입니다.";
static final String VALUE = "> Holder 클래스의 static final 필드 입니다.";
Holder() { System.out.println("> Holder 생성자 초기화"); }
}
}
public class Main {
public static void main(String[] args) {
new Outer(); // 클래스의 인스턴스 생성
}
}
3. static 변수 호출
- 클래스 내부의 static 멤버를 호출하면, 인스턴스화 하지 않아도 클래스가 로드 된다.
class Outer {
// static 변수
static String value = "> Outer 클래스의 static 필드 입니다.";
// static final 상수
static final String VALUE = "> Outer 클래스의 static final 필드 입니다.";
Outer() { System.out.println("> Outer 생성자 초기화"); }
// static 메서드
static void getInstance() {
System.out.println("> Outer 클래스의 static 메서드 호출");
}
// inner 클래스
class Inner {
Inner() { System.out.println("> Inner 생성자 초기화"); }
}
// static inner 클래스
static class Holder {
static String value = "> Holder 클래스의 static 필드 입니다.";
static final String VALUE = "> Holder 클래스의 static final 필드 입니다.";
Holder() { System.out.println("> Holder 생성자 초기화"); }
}
}
public class Main {
public static void main(String[] args) {
System.out.println(Outer.value); // 정적 변수 호출
}
}
4. static final 상수 호출
- 단, static final 상수를 호출할 경우 static 변수와 다르게 Outer 클래스가 로드되지 않는다.
- 왜냐하면 상수는 JVM의 Method Area에 Constant Pool에 따로 저장되어 관리되기 때문이다.
class Outer {
// static 변수
static String value = "> Outer 클래스의 static 필드 입니다.";
// static final 상수
static final String VALUE = "> Outer 클래스의 static final 필드 입니다.";
Outer() { System.out.println("> Outer 생성자 초기화"); }
// static 메서드
static void getInstance() {
System.out.println("> Outer 클래스의 static 메서드 호출");
}
// inner 클래스
class Inner {
Inner() { System.out.println("> Inner 생성자 초기화"); }
}
// static inner 클래스
static class Holder {
static String value = "> Holder 클래스의 static 필드 입니다.";
static final String VALUE = "> Holder 클래스의 static final 필드 입니다.";
Holder() { System.out.println("> Holder 생성자 초기화"); }
}
}
public class Main {
public static void main(String[] args) {
System.out.println(Outer.VALUE); // 정적 final 상수 호출
}
}
5. static 메소드 호출
- static 변수 호출한 것과 같이 Outer 클래스가 로드된다.
class Outer {
// static 변수
static String value = "> Outer 클래스의 static 필드 입니다.";
// static final 상수
static final String VALUE = "> Outer 클래스의 static final 필드 입니다.";
Outer() { System.out.println("> Outer 생성자 초기화"); }
// static 메서드
static void getInstance() {
System.out.println("> Outer 클래스의 static 메서드 호출");
}
// inner 클래스
class Inner {
Inner() { System.out.println("> Inner 생성자 초기화"); }
}
// static inner 클래스
static class Holder {
static String value = "> Holder 클래스의 static 필드 입니다.";
static final String VALUE = "> Holder 클래스의 static final 필드 입니다.";
Holder() { System.out.println("> Holder 생성자 초기화"); }
}
}
public class Main {
public static void main(String[] args) {
Outer.getInstance(); // static 메소드 호출
}
}
6. 내부 클래스 호출
- 내부 클래스를 생성하기 위해선 외부 클래스를 먼저 생성하고 인스턴스화 해야하기 때문에 Outer 클래스와 Inner 클래스 둘다 로드가 된다.
- 이러한 특징 때문에 내부 클래스를 static으로 선언하지 않고 인스턴스 멤버 클래스로서 사용하면 메모리 누수가 발생하게 된다. 이에 대해선 다음 글을 참고하길 바란다.
class Outer {
// static 변수
static String value = "> Outer 클래스의 static 필드 입니다.";
// static final 상수
static final String VALUE = "> Outer 클래스의 static final 필드 입니다.";
Outer() { System.out.println("> Outer 생성자 초기화"); }
// static 메서드
static void getInstance() {
System.out.println("> Outer 클래스의 static 메서드 호출");
}
// inner 클래스
class Inner {
Inner() { System.out.println("> Inner 생성자 초기화"); }
}
// static inner 클래스
static class Holder {
static String value = "> Holder 클래스의 static 필드 입니다.";
static final String VALUE = "> Holder 클래스의 static final 필드 입니다.";
Holder() { System.out.println("> Holder 생성자 초기화"); }
}
}
public class Main {
public static void main(String[] args) {
new Outer().new Inner(); // 내부 클래스 인스턴스화
}
}
7. static 내부 클래스 호출
- static inner 클래스는 외부 클래스를 생성하지 않고 바로 직접 인스턴스화가 가능하다.
- 그래서 일반 내부 클래스와는 달리 외부 클래스 Outer를 로드하지 않는다는 차이점이 있다.
※ 참고
static이 붙었다고 해서 static inner 클래스를 static 멤버나 static 메서드처럼 취급해서 생각하면 안된다.
inner 클래스와 static inner 클래스의 차이는 외부클래스를 생성해야 내부 클래스를 인스턴스를 할수있느냐 없느냐의 차이일뿐, 클래스를 초기화해서 사용하는 것은 같다.
class Outer {
// static 변수
static String value = "> Outer 클래스의 static 필드 입니다.";
// static final 상수
static final String VALUE = "> Outer 클래스의 static final 필드 입니다.";
Outer() { System.out.println("> Outer 생성자 초기화"); }
// static 메서드
static void getInstance() {
System.out.println("> Outer 클래스의 static 메서드 호출");
}
// inner 클래스
class Inner {
Inner() { System.out.println("> Inner 생성자 초기화"); }
}
// static inner 클래스
static class Holder {
static String value = "> Holder 클래스의 static 필드 입니다.";
static final String VALUE = "> Holder 클래스의 static final 필드 입니다.";
Holder() { System.out.println("> Holder 생성자 초기화"); }
}
}
public class Main {
public static void main(String[] args) {
new Outer.Holder(); // static 내부 클래스 인스턴스화
}
}
8. static 내부 클래스의 static 변수 호출
- 마찬가지로 클래스를 인스턴스화 하지않아도 static 멤버를 호출하면 Holder 클래스가 로드된다.
※ 참고
그리고 외부 Outer 클래스는 로드되지 않는다.이어서 static 내부 클래스의 static final 상수를 호출하면, 위에서 봤듯이 Outer 클래스와 그의 내부 static final 클래스는 호출되지 않는다. (상수는 따로 관리)
class Outer {
// static 변수
static String value = "> Outer 클래스의 static 필드 입니다.";
// static final 상수
static final String VALUE = "> Outer 클래스의 static final 필드 입니다.";
Outer() { System.out.println("> Outer 생성자 초기화"); }
// static 메서드
static void getInstance() {
System.out.println("> Outer 클래스의 static 메서드 호출");
}
// inner 클래스
class Inner {
Inner() { System.out.println("> Inner 생성자 초기화"); }
}
// static inner 클래스
static class Holder {
static String value = "> Holder 클래스의 static 필드 입니다.";
static final String VALUE = "> Holder 클래스의 static final 필드 입니다.";
Holder() { System.out.println("> Holder 생성자 초기화"); }
}
}
public class Main {
public static void main(String[] args) {
System.out.println(Outer.Holder.value);// static 내부 클래스 static 변수 호출
}
}
클래스 초기화 시점 파헤치기
클래스 초기화는 static 블록과 static 멤버 변수의 값을 할당하는 것을 의미한다. 꼭 new 생성자로 클래스를 인스턴스화 해야 클래스가 초기화 되는 것이 아니다.
클래스 로더에서 클래스 초기화 과정을 3단계로 나눠 설명하였지만, 사실 클래스 초기화(Initialization)는 클래스 로드(Loading) 시점과 거의 동시에 일어나기 때문에 같다고 보면 된다.
그래서 클래스 초기화 시점은 위의 클래스 로드 시점과 거의 똑같다. 위에서 클래스 로드 시점을 보여 주기위해 나눠 설명했지만 사실 클래스 초기화도 동시에 진행 된 것이라고 보면 된다.
- 클래스의 인스턴스 생성
- 클래스의 정적 메소드 호출
- 클래스의 정적 변수 할당
- 클래스의 정적 변수 사용 (final x)
예시를 들자면, 다음 코드를 보면 Outer 클래스의 static 메소드를 호출하면, new 생성자로 인스턴스화 하지 않아도 클래스가 메모리에 로드되어 초기화되는 것을 볼 수 있다.
※ 참고
static 블록 : 클래스가 로딩되고 클래스 변수가 준비된 후 자동으로 실행되는 블록
class Outer {
// static 블록
static {
System.out.println("> Initializing class Outer");
}
// 생성자
Outer() {
System.out.println("> Outer 생성자 호출");
}
// 정적 메소드
static void getInstance() {
System.out.println("> Outer 클래스의 static 메서드 호출");
}
}
public class Main {
public static void main(String[] args) {
Outer.getInstance(); // 정적 메소드 호출
}
}
클래스 초기화 진행 순서
클래스 초기화 시점을 알았으니, 이번에는 클래스가 초기화 되면 클래스 내부에서 어떤 멤버들이 어떤 순서대로 초기화가 순차적으로 진행되는지 알아보자.
※ 참고
코드상으로는 정적 변수 선언문이 static 블록 보다 위에 위치해 있어서 먼저 초기화가 되었지만, 정확히 말하면 static 필드 변수와 static 블록의 초기화 순서는 코드 선언 순서에 따라 다르다.
class Outer {
public static Object obj = new Print(); // "1. 정적 변수"
static {
System.out.println("2. 정적 블록");
}
Outer() {
System.out.println("3. 생성자");
}
}
// 정적 변수가 초기화 됬음을 출력해주는 용도
class Print {
Print() {
System.out.println("1. 정적 변수");
}
}
public class Main {
public static void main(String[] args) {
new Outer();
}
}
클래스 로드와 초기화 분리하기
위에서 클래스가 로드와 초기화가 워낙 순식간에 일어나 동시에 실행한다고 하였지만, 그래도 클래스 로더(Class Loader)에서 봤다시피 엄연히 클래스가 메모리에 적재하는 과정은 나눠져 있다.
이를 코드에서 확인하려면, Object 클래스의 getClass() 메서드를 통해 Class 클래스 객체를 가져오면 된다.
import java.lang.reflect.Constructor;
class Outer {
public String field = "> Outer 클래스의 필드 입니다!";
// static 블록
static {
System.out.println("> Initializing class Outer");
}
// 생성자
Outer() {
System.out.println("> Outer 생성자 호출");
}
// 정적 메소드
static void getInstance() {
System.out.println("> Outer 클래스의 static 메서드 호출");
}
}
public class Main {
public static void main(String[] args) throws Exception {
System.out.println("1. 클래스 load만 : ");
// Outer 클래스의 Class 객체 리터럴로 가져오기
Class<? extends Outer> outerClass = Outer.class;
System.out.println("\n---------------------------------------------------------------------\n");
System.out.println("2. 클래스 initialization : ");
// Reflection API로 클래스 생성 하기
Outer outer = outerClass.getDeclaredConstructor().newInstance();
System.out.println(outer.field);
}
}
위의 코드에서 Outer 클래스를 콘솔에서 로드했음에도 static 블럭이나 생성자가 실행되지 않아 초기화 과정 메세지가 출력이 안된것을 확인 할 수 있다.
즉, Outer.class 클래스 객체만 가져올 경우 클래스가 loading만 되며, 클래스 객체를 이용해 인스턴스화 하면 그제서야 클래스가 initialization이 되는걸, 점선 구분선을 통해 클래스 로딩 과정이 분리되었음을 볼 수 있다.
클래스 초기화는 오직 한번만 수행
클래스 초기화 작업은 오직 한번만 이행된다.
만일 멀티 쓰레드 환경에서 여러개의 쓰레드가 동시에 클래스를 인스턴스화 하여도 클래스 초기화는 오직 한번만 수행된다.
정확히 말하면 클래스 로딩이 최초로 될때, 그때 한번만 초기화를 수행하고 그 이후에는 초기화를 Skip한다고 보면 된다.
이 의미는 멀티 스레드 환경에서 클래스 초기화 동작 자체는 스레드 세이프함을 의미한다.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
class Outer {
static {
System.out.println("static 블록 호출");
}
public Outer() {
System.out.println("생성자 호출");
}
}
public class Main {
public static void main(String[] args) {
// 1. 스레드 풀 생성
ExecutorService service = Executors.newCachedThreadPool();
// 2. 반복문을 통해 10개의 스레드가 동시에 인스턴스 생성
for (int i = 0; i < 10; i++) {
service.submit(() -> {
new Outer();
});
}
// 3. 종료
service.shutdown();
}
}
싱글톤 패턴의 응용 사례
싱글톤 클래스는 인스턴스를 오직 1개만 가지는 클래스를 말한다.
싱글톤 패턴의 코드 예제를 보면, 다음과 같이 static inner 클래스를 이용해 지연 초기화를 실현한다.
// 싱글톤 객체
class Singleton {
private Singleton() {}
// static 내부 클래스를 이용
private static class SingleInstanceHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingleInstanceHolder.INSTANCE;
}
}
내부 클래스도 결국은 클래스이기 때문에 클래스가 로드될때 딱 한번만 초기화되는 특성을 이용하여 static final 상수에 싱글톤 객체를 할당하는 기법이다.
거기다 바로 위에서 살펴봤듯이 클래스 로딩 및 초기화 과정이 스레드 세이프함을 이용하여 멀티 스레드 환경에서도 문제없이 싱글톤 인스턴스를 만들 수 있는 것이다.
아래는 실제로 멀티스레드 환경에서 싱글톤 객체가 하나만 생성되는지 확인하는 코드이다.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
// 싱글톤 객체
class Singleton {
private Singleton() {}
// static 내부 클래스를 이용
private static class SingleInstanceHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingleInstanceHolder.INSTANCE;
}
}
public class Main {
public static void main(String[] args) {
// 싱글톤 객체를 담을 배열
Singleton[] singleton = new Singleton[10];
// 1. 스레드 풀 생성
ExecutorService service = Executors.newCachedThreadPool();
// 2. 반복문을 통해 10개의 스레드가 동시에 인스턴스 생성
for (int i = 0; i < 10; i++) {
final int num = i;
service.submit(() -> {
singleton[num] = Singleton.getInstance();
});
}
// 3. 종료
service.shutdown();
for(Singleton s : singleton) {
System.out.println(s);
}
}
}
참고