스터디에서 Java의 클래스 로더 동작 원리에 관해 얘기 하던 중 아래와 같은 신기한 현상을 발견하였다.
“static final로 선언된 상수를 사용할 때는, 클래스 로더가 해당 클래스를 로딩하지 않는다!”
이게 왜 신기하다는 걸까? 일단 클래스 로더에 대해 조금 더 알아보자.
어떤 클래스의 멤버를 사용하기 위해서는, 해당 클래스의 바이트코드를 JVM 메모리에 적재해야 한다.
이러한 일을 해주는 것이 바로 클래스 로더이다. 간단하게 동작 원리를 살펴보면 다음과 같다.
출처: https://inpa.tistory.com/entry/JAVA-☕-JVM-내부-구조-메모리-영역-심화편
여기서 중요한 점은, 바이트코드들은 프로그램 실행 초기에 모두 적재되는 것이 아니라
런타임 때 해당 클래스가 필요한 경우, 동적
으로 적재된다는 것이다.
따라서 앞서 살펴보았던 이 말은 조금 이상하게 느껴진다.
“static final로 선언된 상수를 사용할 때는, 클래스 로더가 해당 클래스를 로딩하지 않는다!”
static final도 어떤 특정 클래스 내부에 작성되기 때문에, 사용하려면 해당 클래스를 로딩해야 하는거 아닐까?
하지만 코드를 통해 살펴보면 실제로는 그렇지 않다는 것을 알 수 있다.
먼저, Test라는 클래스를 생성하고 내부에 ZERO라는 상수를 선언한다.
// Test.java
public class Test {
public static final int ZERO = 0;
}
그리고 Main이라는 클래스에 Test 클래스의 ZERO 상수를 호출하는 main 메서드를 작성한다.
// Main.java
public class Main {
public static void main(String[] args) {
System.out.println(Test.ZERO);
}
}
클래스 로딩 내역을 살펴보기 위해 직접 컴파일 후 실행한다.
$ javac Main.java
$ java -verbose:class Main.java
-verbose:class
옵션을 주면 클래스 로딩 내역을 확인할 수 있다.[0.006s][info][class,load] java.lang.Object source: shared objects file
[0.006s][info][class,load] java.io.Serializable source: shared objects file
[0.006s][info][class,load] java.lang.Comparable source: shared objects file
[0.006s][info][class,load] java.lang.CharSequence source: shared objects file
...
[0.248s][info][class,load] com.sun.tools.javac.launcher.Main$MemoryClassLoader$MemoryURLStreamHandler source: jrt:/jdk.compiler
[0.249s][info][class,load] Main source: file:/Users/kyle/Desktop/kyle/classloader-test/Main.java
0
[0.249s][info][class,load] java.util.IdentityHashMap$IdentityHashMapIterator source: shared objects file
[0.249s][info][class,load] java.util.IdentityHashMap$KeyIterator source: shared objects file
[0.249s][info][class,load] java.lang.Shutdown source: shared objects file
[0.249s][info][class,load] java.lang.Shutdown$Lock source: shared objects file
비교를 위해 static final이 아닌 단순 static 멤버로도 테스트를 해보았다.
// Test.java
public class Test {
public static int zero = 0;
}
// Main.java
public class Main {
public static void main(String[] args) {
System.out.println(Test.zero);
}
}
결과는 다음과 같다.
[0.253s][info][class,load] com.sun.tools.javac.launcher.Main$MemoryClassLoader$MemoryURLStreamHandler source: jrt:/jdk.compiler
[0.253s][info][class,load] Main source: file:/Users/kyle/Desktop/kyle/classloader-test/Main.java
[0.253s][info][class,load] jdk.internal.loader.URLClassPath$FileLoader$1 source: jrt:/java.base
[0.253s][info][class,load] sun.nio.ByteBuffered source: shared objects file
[0.253s][info][class,load] Test source: file:/Users/kyle/Desktop/kyle/classloader-test/
0
[0.254s][info][class,load] java.util.IdentityHashMap$IdentityHashMapIterator source: shared objects file
[0.254s][info][class,load] java.util.IdentityHashMap$KeyIterator source: shared objects file
[0.254s][info][class,load] java.lang.Shutdown source: shared objects file
[0.254s][info][class,load] java.lang.Shutdown$Lock source: shared objects file
결론적으로 static final이나 static이나 모두 클래스의 내부에 작성된 멤버임에도 불구하고,
static final을 사용할 때는 클래스가 로딩되지 않았고, static을 사용할 때는 클래스가 로딩되었다.
이 문제의 실마리는 자바의 상수
개념에 대해 알아보면서 찾을 수 있었다.
이 문제의 원인에 대해 검색해 보면서, 자바 언어 명세에서 다음과 같은 문장을 보았다.
“A reference to a field that is a constant variable (§4.12.4) must be resolved at compile time to the value V denoted by the constant variable's initializer.”
출처 : Java Language Specification (13.1. The Form of a Binary)
“상수 필드는 컴파일 시간에 결정된다.” 라는 말이다!
따라서 상수는 런타임 때 클래스 로더를 통해 클래스를 로딩하지 않아도 그 값을 알 수 있다는 의미인 것이다.
그럼 모든 static final을 사용할 때 클래스 로딩을 하지 않을까?
아니다! 상수 중에서도 컴파일 타임 상수인 경우에만 해당
한다.
그런데 그게 무엇일까?
밸덩의 What Are Compile-Time Constants in Java? 라는 글을 보면, 상수에는 컴파일 타임 상수와 런타임 상수라는 두 가지 종류가 있다는 것을 알 수 있다.
final 키워드가 붙은 기본 자료형 혹은 문자열 리터럴
이 컴파일 타임 상수에 해당한다.public static final int MAXIMUM_NUMBER_OF_USERS = 10;
public static final String DEFAULT_USERNAME = "unknown";
final 키워드가 붙었지만 사용자의 입력, 랜덤값 등 실행 시마다 달라질 수 있으면
런타임 상수에 해당한다.public static void main(String[] args) {
Console console = System.console();
final String input = console.readLine(); // 사용자의 입력에 따라 매번 달라짐
console.writer().println(input);
final double random = Math.random(); // 랜덤하게 매번 달라짐
console.writer().println("Number: " + random);
}
사실 개인적으로 여태까지 final 키워드가 붙으면 불변이기 때문에 상수(constant)라고 생각했었다.
하지만 final 키워드가 붙었다고 해서 반드시 상수로 취급되는 것은 아니다.
앞서 첨부한 밸덩 글을 보면 이런 얘기가 나온다.
“However, not all static and final variables are constants. If a state of an object can change, it is not a constant.”
public static final Logger log = LoggerFactory.getLogger(ClassConstants.class); public static final List<String> contributorGroups = Arrays.asList("contributor", "author");
“Though these are constant references, they refer to mutable objects.”
→ static final 이지만, 객체의 상태가 변경될 수 있으므로 상수가 아니다.
자바 언어 명세에도 아래와 같은 말이 나온다.
“A constant variable is a
final variable of primitive type or type String
that is initialized with a constant expression.”
출처 : Java Language Specification (4.12.4 final Variables)
→ final 키워드가 붙은 기본 자료형 혹은 문자열 리터럴의 경우를 상수라고 한다.
더불어 스택오버플로우에도 이런 답변이 있다. (아 그래서 logger는 대문자가 아니었나!)
출처: https://stackoverflow.com/questions/1417190/should-a-static-final-logger-be-declared-in-upper-case
→ logger는 상수가 아니라 불변 참조(final reference)이므로 대문자로 작성하지 않는다.
[참고] 런타임 상수도 계속 값이 변하는데, 그럼 런타임 상수도 상수가 아니지 않을까?
번외에서 살펴보았듯이, final 키워드가 있다고 하더라도 내용이 변할 수 있으면 상수가 아니었다.
그런데 런타임 상수도 값이 변하기 때문에 컴파일 시점에 값이 정해지지 않는데, 그럼 똑같은거 아닐까? 라는 의문이 들 수 있다.
하지만 이는 시점이 다르다.
런타임 상수의 변경 시점은 애플리케이션을 실행하는 시점이다. 즉, 실행할 때마다 변경될 수 있다는 것이고, 실행 이후 런타임 때는 절대 변경되지 않는다. 그래서 상수라고 취급한다.
반면에, final reference의 경우에는 객체를 참조하고 있으므로 실행 이후에도 여러 번 내부가 변경될 수 있다. 그래서 상수가 아니라고 취급한다.
이제 처음의 의문에 답을 할 수 있게 되었다.
“static final로 선언된 상수를 사용할 때는, 클래스 로더가 해당 클래스를 로딩하지 않는다!”
왜냐하면?
내가 작성했던 static final이 컴파일 타임 상수
이므로, 바이트코드에 이미 값이 지정된 채로 들어갔기 때문이다.
이제 직접 코드를 통해 컴파일 타임 상수가 바이트코드에서 실제 값으로 치환되는지 확인해본다.
// Test.java
import java.util.Arrays;
import java.util.List;
public class Test {
// 컴파일 타임 상수
public static final int ZERO = 0;
// 런타임 상수
public static final double RANDOM_NUMBER = Math.random();
// 불변 참조(final reference)
public static final List<String> names = Arrays.asList("kyle", "alex");
}
// Main.java
public class Main {
public static void main(String[] args) {
System.out.println(Test.ZERO); // 컴파일 타임 상수
System.out.println(Test.RANDOM_NUMBER); // 런타임 상수
System.out.println(Test.names); // 불변 참조(final reference)
}
}
Main.java를 컴파일 했을 때 생성되는 Main.class 바이트코드 내용
을 보면 아래와 같다.
인텔리제이에서는 decompiler를 사용하여 바이트코드 파일을 편하게 볼 수 있는 기능을 제공한다.
// Main.class
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
public class Main {
public Main() {
}
public static void main(String[] var0) {
System.out.println(0);
System.out.println(Test.RANDOM_NUMBER);
System.out.println(Test.names);
}
}
클래스 로딩 내역도 한번 보자.
...
[0.275s][info][class,load] Main source: file:/Users/kyle/Desktop/kyle/classloader-test/Main.java
[0.275s][info][class,load] jdk.internal.loader.URLClassPath$FileLoader$1 source: jrt:/java.base
[0.275s][info][class,load] sun.nio.ByteBuffered source: shared objects file
[0.275s][info][class,load] Test source: file:/Users/kyle/Desktop/kyle/classloader-test/
[0.276s][info][class,load] java.lang.Math$RandomNumberGeneratorHolder source: jrt:/java.base
[0.276s][info][class,load] java.util.random.RandomGenerator source: shared objects file
[0.276s][info][class,load] java.util.Random source: shared objects file
[0.276s][info][class,load] jdk.internal.math.FloatingDecimal source: shared objects file
[0.276s][info][class,load] jdk.internal.math.FloatingDecimal$BinaryToASCIIConverter source: shared objects file
[0.276s][info][class,load] jdk.internal.math.FloatingDecimal$ExceptionalBinaryToASCIIBuffer source: shared objects file
[0.276s][info][class,load] jdk.internal.math.FloatingDecimal$BinaryToASCIIBuffer source: shared objects file
[0.276s][info][class,load] jdk.internal.math.FloatingDecimal$1 source: shared objects file
[0.276s][info][class,load] jdk.internal.math.FloatingDecimal$ASCIIToBinaryConverter source: shared objects file
[0.276s][info][class,load] jdk.internal.math.FloatingDecimal$PreparedASCIIToBinaryBuffer source: shared objects file
0
0.15307341311566613
[kyle, alex]
처음에 이 신기한 현상에 대해 소개하면서 글을 시작하였다.
“static final로 선언된 상수를 사용할 때는, 클래스 로더가 해당 클래스를 로딩하지 않는다!”
자연스럽게 “static final도 특정 클래스 내에 선언되는데, 왜 해당 클래스를 로딩하지 않을까?” 라는 의문이 들었고, 이를 해소하려 하였다.
그래서 내린 결론은 다음과 같다.