[Java] static final을 사용할 때, 클래스 로딩이 되지 않는 이유는 무엇일까?

kyle·2023년 7월 15일
3

1. 문제 상황

스터디에서 Java의 클래스 로더 동작 원리에 관해 얘기 하던 중 아래와 같은 신기한 현상을 발견하였다.

“static final로 선언된 상수를 사용할 때는, 클래스 로더가 해당 클래스를 로딩하지 않는다!”

이게 왜 신기하다는 걸까? 일단 클래스 로더에 대해 조금 더 알아보자.

어떤 클래스의 멤버를 사용하기 위해서는, 해당 클래스의 바이트코드를 JVM 메모리에 적재해야 한다.
이러한 일을 해주는 것이 바로 클래스 로더이다. 간단하게 동작 원리를 살펴보면 다음과 같다.


출처: https://inpa.tistory.com/entry/JAVA-☕-JVM-내부-구조-메모리-영역-심화편

  1. 소스코드를 작성하고 컴파일 하면, 각 클래스는 .class라는 바이트코드 파일로 변환된다.
  2. 런타임 때 어떤 클래스의 멤버를 사용해야 한다면, 클래스 로더는 해당 클래스의 바이트코드를 찾는다.
  3. 각종 검증과 초기화 단계를 거쳐 JVM의 Runtime Data Area에 바이트코드를 적재한다.

여기서 중요한 점은, 바이트코드들은 프로그램 실행 초기에 모두 적재되는 것이 아니라
런타임 때 해당 클래스가 필요한 경우, 동적으로 적재된다는 것이다.

따라서 앞서 살펴보았던 이 말은 조금 이상하게 느껴진다.

“static final로 선언된 상수를 사용할 때는, 클래스 로더가 해당 클래스를 로딩하지 않는다!”

static final도 어떤 특정 클래스 내부에 작성되기 때문에, 사용하려면 해당 클래스를 로딩해야 하는거 아닐까?

하지만 코드를 통해 살펴보면 실제로는 그렇지 않다는 것을 알 수 있다.


2. 코드 예시

먼저, 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
  • 프로그램을 실행하면 부트스트랩 클래스 로더로부터 Object 클래스를 포함한 기본적인 클래스들을 로딩한다.
  • 중간에 0이 출력된 것으로 보아 정상적으로 ZERO 상수를 읽는다는 점을 알 수 있다.
  • 하지만 Main 클래스만 로딩되었고, Test 클래스는 따로 로딩되지 않았다!

비교를 위해 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
  • Main 클래스 뿐만 아니라, Test 클래스도 로딩되었다!

결론적으로 static final이나 static이나 모두 클래스의 내부에 작성된 멤버임에도 불구하고,
static final을 사용할 때는 클래스가 로딩되지 않았고, static을 사용할 때는 클래스가 로딩되었다.

이 문제의 실마리는 자바의 상수 개념에 대해 알아보면서 찾을 수 있었다.


3. 컴파일 타임 상수와 런타임 상수

이 문제의 원인에 대해 검색해 보면서, 자바 언어 명세에서 다음과 같은 문장을 보았다.

“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? 라는 글을 보면, 상수에는 컴파일 타임 상수와 런타임 상수라는 두 가지 종류가 있다는 것을 알 수 있다.

컴파일 타임 상수(Compile-time constants)

  • 컴파일 이후 값이 절대 변경되지 않는 상수를 의미한다.
  • final 키워드가 붙은 기본 자료형 혹은 문자열 리터럴이 컴파일 타임 상수에 해당한다.
  • 예시
    public static final int MAXIMUM_NUMBER_OF_USERS = 10;
    public static final String DEFAULT_USERNAME = "unknown";

런타임 상수(Run-time constants)

  • 런타임 중에 값이 변경되지는 않지만, 프로그램을 실행할 때마다 다른 값이 할당될 수 있는 상수를 의미한다.
  • 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 키워드가 붙었다고 해서 모두 상수는 아니다.

사실 개인적으로 여태까지 final 키워드가 붙으면 불변이기 때문에 상수(constant)라고 생각했었다.

하지만 final 키워드가 붙었다고 해서 반드시 상수로 취급되는 것은 아니다.

  1. 앞서 첨부한 밸덩 글을 보면 이런 얘기가 나온다.

    “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 이지만, 객체의 상태가 변경될 수 있으므로 상수가 아니다.

  1. 자바 언어 명세에도 아래와 같은 말이 나온다.

    “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 키워드가 붙은 기본 자료형 혹은 문자열 리터럴의 경우를 상수라고 한다.

  1. 더불어 스택오버플로우에도 이런 답변이 있다. (아 그래서 logger는 대문자가 아니었나!)


    출처: https://stackoverflow.com/questions/1417190/should-a-static-final-logger-be-declared-in-upper-case

    → logger는 상수가 아니라 불변 참조(final reference)이므로 대문자로 작성하지 않는다.

[참고] 런타임 상수도 계속 값이 변하는데, 그럼 런타임 상수도 상수가 아니지 않을까?
번외에서 살펴보았듯이, final 키워드가 있다고 하더라도 내용이 변할 수 있으면 상수가 아니었다.
그런데 런타임 상수도 값이 변하기 때문에 컴파일 시점에 값이 정해지지 않는데, 그럼 똑같은거 아닐까? 라는 의문이 들 수 있다.
하지만 이는 시점이 다르다.
런타임 상수의 변경 시점은 애플리케이션을 실행하는 시점이다. 즉, 실행할 때마다 변경될 수 있다는 것이고, 실행 이후 런타임 때는 절대 변경되지 않는다. 그래서 상수라고 취급한다.
반면에, final reference의 경우에는 객체를 참조하고 있으므로 실행 이후에도 여러 번 내부가 변경될 수 있다. 그래서 상수가 아니라고 취급한다.

정리해보면 다음과 같다.

  • 상수에는 컴파일 시점에 결정되는 컴파일 타임 상수와, 런타임 시점에 결정되는 런타임 상수가 있다.
  • final 키워드가 있다고 해서 모두 상수는 아니다. (번외)
  • 컴파일 타임 상수는 컴파일 시점에 값이 정해지므로, 클래스 로더가 클래스를 로딩할 필요가 없다.
  • 반면에 런타임 상수나 불변 참조는 값이 변할 수 있으므로, 클래스 로더가 클래스 로딩을 해야한다.

이제 처음의 의문에 답을 할 수 있게 되었다.

“static final로 선언된 상수를 사용할 때는, 클래스 로더가 해당 클래스를 로딩하지 않는다!”

왜냐하면?
내가 작성했던 static final이 컴파일 타임 상수이므로, 바이트코드에 이미 값이 지정된 채로 들어갔기 때문이다.


4. 결과 확인하기

이제 직접 코드를 통해 컴파일 타임 상수가 바이트코드에서 실제 값으로 치환되는지 확인해본다.

// 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)
    }
}
  • Test 클래스에 컴파일 타임 상수, 런타임 상수, 불변 참조 변수를 모두 static final로 선언하였다.
  • 그리고 Main 클래스에서는 이들을 사용하여 출력한다.

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);
    }
}
  • 컴파일 타임 상수에 해당하는 Test.ZERO는 0으로 치환된 것을 알 수 있다.
  • 따라서 애초에 바이트코드 자체에서 값이 명시되어 있기 때문에, Test 클래스를 로딩하지 않아도 그 값을 알 수 있다는 것이다.
  • 런타임 상수와 불변 참조는 컴파일 시점에는 어떤 값인지 확정할 수 없으므로 값으로 치환되지 않는다.

클래스 로딩 내역도 한번 보자.

...
[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]
  • 우리는 이 글의 맨 처음에서 Test.ZERO만 사용했을 때, Test 클래스는 로딩되지 않았었다.
  • 하지만 지금은 Main과 Test 클래스 모두 로딩된 것을 확인할 수 있다.

5. 마무리

처음에 이 신기한 현상에 대해 소개하면서 글을 시작하였다.

“static final로 선언된 상수를 사용할 때는, 클래스 로더가 해당 클래스를 로딩하지 않는다!”

자연스럽게 “static final도 특정 클래스 내에 선언되는데, 왜 해당 클래스를 로딩하지 않을까?” 라는 의문이 들었고, 이를 해소하려 하였다.

그래서 내린 결론은 다음과 같다.

  • static final이라고 해서 반드시 모두 클래스 로딩을 안하는 것은 아니다.
  • 이 중에서도 컴파일 타임 상수를 사용할 때만 클래스 로딩을 하지 않는다.
  • 왜냐면, 컴파일 타임 상수는 컴파일 시 바이트코드 자체에 값으로 치환되기 때문이다.
  • static final이라 하더라도, 런타임 상수 혹은 불변 참조(final reference)라면 클래스 로딩을 한다.
profile
공유를 기반으로 선한 영향력을 주는 개발자가 되고 싶습니다.

0개의 댓글

관련 채용 정보