
Java 코드를 실행하면 JVM은 .class 파일을 발견하는 즉시 그대로 실행하는 것이 아니다.
클래스를 JVM 내부로 가져오고, 바이트코드가 안전한지 검사하고, 정적 필드를 준비하고, 다른 클래스와의 참조 관계를 해석한 뒤 초기화를 수행한다.
전체적인 흐름은 다음과 같다.
Loading
↓
Linking
├─ Verification
├─ Preparation
└─ Resolution
↓
Initialization
↓
Using
↓
Unloading
이 흐름을 이해하면 다음과 같은 상황을 이해하는 데 도움이 된다.
static 블록은 언제 실행되는가?Class.forName()은 왜 static 초기화까지 실행하는가?Member.class를 참조했는데 왜 static 블록은 실행되지 않는가?JVM의 클래스 처리 과정은 크게 다음과 같이 나눌 수 있다.
.class 파일
↓
Loading
↓
Linking
├─ Verification
├─ Preparation
└─ Resolution
↓
Initialization
↓
클래스 사용
↓
Unloading
핵심은 Loading과 Initialization이 같은 과정이 아니라는 것이다.
클래스가 JVM에 로딩되었다고 해서 반드시 static 초기화 코드까지 실행된 것은 아니다.
Loading 단계에서는 JVM이 .class의 바이트코드를 읽어 클래스 정보를 JVM 내부에 생성한다.
이 작업을 담당하는 핵심 구성요소가 ClassLoader다.
예를 들어 다음 코드가 있다고 하자.
Class.forName("com.example.Member");
JVM은 해당 클래스가 아직 로딩되지 않았다면 Member.class를 찾아 JVM으로 가져온다.
일반적으로 클래스 로딩 과정에서 JVM은 클래스에 대한 메타데이터를 관리하고 Java 코드에서 사용할 수 있는 Class<?> 객체도 생성한다.
Class<?> clazz = Member.class;
여기서 clazz는 Member 클래스 자체의 정보를 표현하는 객체다.
Java에서는 클래스를 아무 곳에서나 무작정 가져오는 것이 아니라 ClassLoader를 통해 로딩한다.
일반적인 Java 환경에서는 대표적으로 다음 ClassLoader들이 존재한다.
Bootstrap ClassLoader
↓
Platform ClassLoader
↓
Application ClassLoader
Java의 핵심 클래스를 로딩한다.
예를 들어 다음과 같은 클래스들이다.
java.lang.String
java.lang.Object
java.util.List
JVM과 매우 가까운 영역에서 동작한다.
Java 플랫폼 관련 라이브러리를 로딩한다.
Java 9 이후 기존 Extension ClassLoader의 역할을 대신한다.
우리가 작성한 애플리케이션 클래스와 classpath에 포함된 라이브러리를 주로 로딩한다.
예를 들어
com.example.Member
com.example.MemberService
org.springframework.*
같은 클래스들이 여기에 해당할 수 있다.
클래스가 로딩됐다고 바로 실행할 수 있는 것은 아니다.
Loading 이후에는 Linking 과정이 진행된다.
Linking
├─ Verification
├─ Preparation
└─ Resolution
각 단계의 역할이 다르다.
Verification 단계에서는 로딩된 바이트코드가 JVM 규칙에 맞는지 검사한다.
예를 들어 다음과 같은 내용을 확인한다.
.class 파일 형식이 올바른가?Java는 JVM 위에서 실행되기 때문에 JVM이 바이트코드를 신뢰할 수 있는지 검사하는 과정이 필요하다.
즉,
.class 파일을 찾았다
에서 끝나는 것이 아니라,
이 바이트코드를 JVM에서 안전하게 실행해도 되는가?
를 확인한다.
Preparation 단계에서는 클래스가 사용할 static 필드를 위한 메모리가 준비된다.
예를 들어 다음 코드가 있다고 하자.
public class Member {
static int count = 100;
}
보통 이해하기 쉽게 표현하면 Preparation 단계에서 먼저 다음 상태가 준비된다.
count = 0
Java의 int 기본값이 0이기 때문이다.
그리고 이후 Initialization 단계에서
count = 100
이 된다.
즉 일반적인 static 필드는
Preparation
count = 0
↓
Initialization
count = 100
순서로 처리된다.
여기서 중요한 예외가 있다.
다음과 같이 컴파일 타임 상수가 존재하는 경우다.
static final int MAX_COUNT = 100;
이런 값은 클래스 파일의 ConstantValue 속성을 통해 Preparation 단계에서 값이 설정될 수 있다.
따라서 단순히
모든 static 변수는 Preparation에서 0이고 Initialization에서 개발자가 지정한 값으로 변경된다.
라고 외우면 정확하지 않다.
일반적인 static 변수와 compile-time constant를 구분할 필요가 있다.
예를 들어
static int count = 100;
와
static final int MAX_COUNT = 100;
은 JVM에서 똑같이 처리되는 것이 아니다.
Java 바이트코드는 다른 클래스나 메서드, 필드를 직접적인 형태가 아니라 Symbolic Reference 형태로 가지고 있을 수 있다.
예를 들어
public class MemberService {
private MemberRepository repository;
}
라는 코드가 있다면 MemberService는 MemberRepository라는 클래스를 참조하고 있다.
개념적으로는
MemberService
↓
MemberRepository라는 symbolic reference
↓
JVM에서 사용할 수 있는 direct reference
형태로 해석된다.
이를 Resolution이라고 한다.
흔히
실제 메모리 주소로 바꾼다.
라고 설명하기도 하지만 조금 단순화된 표현이다.
좀 더 정확하게는
Constant Pool에 존재하는 Symbolic Reference를 JVM이 사용할 수 있는 Direct Reference로 해석하는 과정
이라고 보는 것이 좋다.
또한 JVM 구현에 따라 Resolution은 필요한 시점까지 지연될 수도 있다.
즉 JVM은 필요하지 않은 참조까지 무조건 처음부터 전부 해석해야 하는 것은 아니다.
Initialization 단계부터 우리가 작성한 static 초기화 코드가 실제로 실행된다.
예를 들어 다음 클래스가 있다고 하자.
public class Member {
static int count = 100;
static {
System.out.println("Member initialized");
}
}
Initialization 과정에서는
count = 100;
같은 static 필드 초기화 코드와
static {
System.out.println("Member initialized");
}
같은 static initializer가 실행된다.
컴파일 이후에는 이러한 static 초기화 작업들이 JVM이 사용하는 클래스 초기화 메서드인
<clinit>()
형태로 구성될 수 있다.
개발자가 직접 호출하는 메서드는 아니다.
JVM이 클래스 초기화를 위해 자동으로 실행한다.
static 필드와 static 블록은 기본적으로 코드에 작성된 순서대로 처리된다.
예를 들어
public class Member {
static int a = initA();
static {
System.out.println("static block");
}
static int b = initB();
static int initA() {
System.out.println("initA");
return 10;
}
static int initB() {
System.out.println("initB");
return 20;
}
}
클래스 초기화가 발생하면 개념적으로 다음 순서로 실행된다.
initA
static block
initB
그래서 static 초기화 코드 사이에 서로 의존 관계가 있다면 작성 순서를 조심해야 한다.
모든 클래스 참조가 Initialization을 발생시키는 것은 아니다.
대표적으로 클래스 초기화를 발생시키는 경우를 살펴보자.
new Member();
Member가 아직 초기화되지 않았다면 클래스 초기화가 먼저 실행되고 그 이후 객체가 생성된다.
즉
Member 클래스 Initialization
↓
객체 메모리 할당
↓
생성자 실행
순서가 된다.
Member.count;
count가 일반 static 필드라면 Member 클래스 초기화가 발생한다.
Member.findAll();
해당 클래스가 아직 초기화되지 않았다면 클래스 초기화가 발생한다.
Class.forName("com.example.Member");
Class.forName(String)은 기본적으로 해당 클래스를 로딩하면서 Initialization까지 수행한다.
그래서 JDBC를 오래 사용해 본 사람이라면 과거 다음 코드를 본 적이 있을 것이다.
Class.forName("com.mysql.cj.jdbc.Driver");
클래스 초기화를 이용하여 JDBC Driver를 등록하던 방식과 관련이 있다.
다음 코드를 보자.
Class<Member> clazz = Member.class;
Member.class를 사용한다고 해서 반드시 Member의 static 초기화가 실행되는 것은 아니다.
예를 들어
public class Member {
static {
System.out.println("Member Init");
}
}
그리고
public class Main {
public static void main(String[] args) {
System.out.println("Start");
Class<Member> clazz = Member.class;
System.out.println("End");
}
}
실행하면 Member Init이 출력되지 않을 수 있다.
즉
클래스 정보를 참조하는 것
과
클래스를 초기화하는 것
은 구분해야 한다.
클래스 로딩을 공부할 때 꽤 중요한 차이다.
다음 코드는
Class.forName("com.example.Member");
기본적으로
Loading
+
Initialization
까지 진행한다.
반면 ClassLoader의
classLoader.loadClass("com.example.Member");
는 기본적으로 클래스를 로딩하지만 바로 초기화하지 않는다.
개념적으로 비교하면 다음과 같다.
Class.forName()
↓
Loading
↓
Initialization
ClassLoader.loadClass()
↓
Loading
물론 세부 동작과 Linking 시점은 JVM 구현과 상황에 따라 지연될 수 있다.
다음 코드를 보자.
public class Member {
static {
System.out.println("Member Init");
}
public static final int MAX_COUNT = 100;
}
그리고
public class Main {
public static void main(String[] args) {
System.out.println(Member.MAX_COUNT);
}
}
MAX_COUNT가 compile-time constant라면 컴파일 과정에서 호출 코드에 값이 들어갈 수 있다.
즉 실제로 Member.MAX_COUNT를 런타임에 읽을 필요가 없어질 수 있다.
그래서
Member Init
이 출력되지 않을 수 있다.
이 부분 때문에 다음 두 코드는 겉보기에는 비슷하지만 동작이 달라질 수 있다.
public static final int A = 100;
public static final Integer B = Integer.valueOf(100);
첫 번째는 compile-time constant가 될 수 있지만 두 번째는 그렇지 않다.
다음 클래스를 만들어보자.
public class User {
static {
System.out.println("Static Init");
}
public User() {
System.out.println("Constructor");
}
}
그리고 객체를 여러 번 생성한다.
public class Main {
public static void main(String[] args) {
System.out.println("Start");
User user1 = new User();
User user2 = new User();
User user3 = new User();
System.out.println("End");
}
}
실행 결과는 다음과 같다.
Start
Static Init
Constructor
Constructor
Constructor
End
생성자는 객체가 생성될 때마다 실행되지만
static {
}
은 해당 클래스의 Initialization 과정에서 한 번만 실행된다.
즉
클래스 초기화 : 1번
객체 생성 : 여러 번 가능
이다.
이 둘을 구분하는 것이 중요하다.
상속 관계에서는 부모 클래스가 먼저 초기화된다.
예를 들어
public class Parent {
static {
System.out.println("Parent Init");
}
}
public class Child extends Parent {
static {
System.out.println("Child Init");
}
}
그리고
new Child();
를 실행하면 일반적으로
Parent Init
Child Init
순서로 초기화된다.
즉 자식 클래스를 Initialization하기 전에 부모 클래스의 Initialization이 먼저 이루어진다.
Initialization이 끝나면 해당 클래스는 애플리케이션에서 정상적으로 사용할 수 있다.
예를 들어
Member member = new Member();
member.save();
Member.findAll();
같은 작업이 수행된다.
실제 Java 애플리케이션의 대부분 시간은 이 Using 단계라고 볼 수 있다.
클래스도 조건이 충족되면 JVM에서 제거될 수 있다.
다만 객체처럼
참조 없음 → 바로 GC
라고 단순하게 생각하면 안 된다.
클래스가 Unloading되려면 일반적으로 해당 클래스를 로딩했던 ClassLoader 자체가 더 이상 참조되지 않는 상황 등이 필요하다.
예를 들어 애플리케이션 서버나 플러그인 구조에서 별도 ClassLoader를 사용한다면
Custom ClassLoader
↓
Plugin A 클래스들
Plugin A를 제거하고 해당 ClassLoader까지 참조되지 않게 되면 관련 클래스들이 Unloading 대상이 될 수 있다.
Spring Boot 애플리케이션에서 일반적인 Application ClassLoader로 로딩된 애플리케이션 클래스들이 실행 중에 하나씩 쉽게 사라지는 구조는 아니다.
JVM에서 클래스의 정체성은 단순히 클래스 이름만으로 결정되지 않는다.
개념적으로
클래스 이름
+
해당 클래스를 로딩한 ClassLoader
가 중요하다.
예를 들어
ClassLoader A
→ com.example.Member
ClassLoader B
→ com.example.Member
처럼 동일한 이름의 클래스를 서로 다른 ClassLoader가 로딩했다면 JVM에서는 서로 다른 클래스로 취급될 수 있다.
이 특성은
등을 이해할 때 중요해진다.
간혹
분명 같은 클래스인데 ClassCastException이 발생한다.
같은 이상한 문제가 발생하는 이유가 ClassLoader 차이일 수도 있다.
JVM이 실제로 어떤 클래스를 로딩하는지 직접 확인할 수 있다.
기본적인 방법은 다음과 같다.
java -verbose:class -jar app.jar
출력에는 JVM 실행 과정에서 어떤 클래스들이 로딩되는지 나타난다.
JDK 9 이후에는 Unified Logging을 사용할 수도 있다.
java -Xlog:class+load=info -jar app.jar
조금 더 자세히 보고 싶다면
java -Xlog:class+load=debug -jar app.jar
처럼 사용할 수도 있다.
Spring Boot 애플리케이션을 실행하면 생각보다 엄청나게 많은 클래스가 로딩되는 것을 볼 수 있다.
예를 들어
java.lang.Object
java.lang.String
org.springframework.boot.SpringApplication
org.springframework.context.ApplicationContext
...
등이 출력된다.
실행 중인 Java 프로세스를 먼저 확인한다.
jps -l
예를 들어
12345 com.example.Application
이라는 프로세스가 있다면 다음과 같이 확인할 수 있다.
jcmd 12345 VM.classloaders
사용 중인 JDK가 해당 jcmd 명령을 지원한다면 JVM 내부의 ClassLoader 계층을 확인할 수 있다.
먼저 지원되는 명령 목록을 보고 싶다면
jcmd 12345 help
를 사용하면 된다.
다음 코드가 있다고 하자.
public class User {
static int count = 100;
static {
System.out.println("Static Init");
}
public User() {
System.out.println("Constructor");
}
}
그리고
public class Main {
public static void main(String[] args) {
System.out.println("Start");
User user = new User();
System.out.println("End");
}
}
실행 결과는 다음과 같다.
Start
Static Init
Constructor
End
내부적으로 단순화해서 생각하면 다음 흐름이다.
Main 실행
↓
new User() 발견
↓
User 클래스가 로딩되어 있는가?
↓
No
↓
Loading
↓
Verification
↓
Preparation
count = 0
↓
Resolution
↓
Initialization
count = 100
static {
System.out.println("Static Init");
}
↓
User 객체 생성
↓
생성자 실행
Constructor
그래서 결과가
Start
Static Init
Constructor
End
가 되는 것이다.
클래스 로딩에서 가장 중요한 포인트 중 하나다.
Loading != Initialization
Loading은
JVM이 클래스를 가져오는 과정
이고,
Initialization은
해당 클래스의 static 초기화 로직을 실제로 실행하는 과정
이다.
따라서 다음 코드들은 결과가 다를 수 있다.
Member.class;
ClassLoader.getSystemClassLoader()
.loadClass("com.example.Member");
Class.forName("com.example.Member");
new Member();
각 코드가 클래스 로딩과 초기화를 어디까지 발생시키는지 구분할 수 있어야 클래스 로딩 동작을 제대로 이해할 수 있다.
.class 파일의 바이트코드를 JVM으로 가져온다.
담당:
ClassLoader
바이트코드가 JVM 규칙에 맞고 안전한지 검사한다.
static 필드 등을 위한 메모리 영역을 준비하고
기본값을 설정한다.
일반적인 예:
static int count = 100;
count = 0
단, compile-time constant는 예외가 존재한다.
Symbolic Reference를 JVM이 실제 사용할 수 있는
Direct Reference로 해석한다.
static 필드 초기화 코드와
static initializer를 실행한다.
예:
static int count = 100;
static {
System.out.println("Init");
}
초기화된 클래스를 애플리케이션에서 사용한다.
ClassLoader 등이 더 이상 참조되지 않는 조건에서
클래스 메타데이터도 제거 대상이 될 수 있다.
아니다.
Loading
과
Initialization
은 별개의 단계다.
new Member()는 초기화를 발생시키는가?그렇다.
클래스가 아직 초기화되지 않았다면 클래스 초기화 이후 객체가 생성된다.
Member.class는 초기화를 발생시키는가?일반적으로 클래스 초기화를 발생시키지 않는다.
Class.forName()은 초기화를 발생시키는가?기본적인
Class.forName(String)
호출은 초기화를 수행한다.
ClassLoader.loadClass()도 똑같은가?아니다.
일반적으로 클래스는 로딩하지만 바로 초기화하지 않는다.
해당 클래스가 해당 ClassLoader에서 초기화될 때 한 번 실행된다.
new User();
new User();
new User();
라고 해도 static 블록은 매번 실행되지 않는다.
일반적인 static 필드는 기본값이 설정된다.
static int count = 100;
이라면 Preparation 시점에서는
0
이고 Initialization 시점에서
100
이 된다.
하지만
static final int MAX_COUNT = 100;
같은 compile-time constant는 다르게 처리될 수 있으므로 예외를 기억해야 한다.
클래스 초기화 여부를 확인해보기 위해 다음 클래스를 작성한다.
public class User {
static {
System.out.println("### User Initialization");
}
}
그리고 각각 따로 실행해본다.
Class<User> clazz = User.class;
ClassLoader.getSystemClassLoader()
.loadClass("com.example.User");
Class.forName("com.example.User");
new User();
그리고
### User Initialization
이 언제 출력되는지 비교하면 Loading과 Initialization의 차이를 직접 확인할 수 있다.
여기에
java -Xlog:class+load=info ...
까지 함께 사용하면
클래스는 로딩됐는데 초기화는 안 된 상태
도 비교해볼 수 있다.
JVM의 클래스 로딩 과정은 단순히 .class 파일을 읽는 과정이 아니다.
Loading
↓
Verification
↓
Preparation
↓
Resolution
↓
Initialization
과정을 거쳐 클래스가 실행 가능한 상태로 준비된다.
특히 기억해야 할 것은 다음 세 가지다.
1. Loading과 Initialization은 다르다.
2. 일반 static 필드는 Preparation에서 기본값이 준비되고
Initialization에서 초기화 코드가 실행된다.
3. 클래스 참조 방식에 따라 Initialization 발생 여부가 달라진다.
예를 들어
Member.class
와
Class.forName("com.example.Member")
와
new Member()
는 모두 Member라는 클래스를 다루지만 JVM 내부에서 일어나는 동작은 같지 않다.
결국 클래스 로딩을 제대로 이해하려면 단순히
클래스가 메모리에 올라간다.
정도로 끝내면 안 된다.
클래스가 어떻게 로딩되고, 검증되고, 준비되고, 참조가 해석되고, 어느 순간 실제 static 초기화 코드가 실행되는지까지 연결해서 이해하는 것이 핵심이다.
JVM은 클래스를 Loading한 뒤 Verification → Preparation → Resolution 과정을 거쳐 연결하고, 실제 사용이 필요한 시점에 Initialization을 수행한다.
따라서 '클래스가 로딩됐다'와 '클래스가 초기화됐다'는 같은 의미가 아니다.