java.lang
패키지의 System
클래스에 PrintStream
타입의 out
필드가 static final
키워드로 선언되어 있습니다.
이 필드의 흥미로운 점은 null
로 초기화 되어있다는 것입니다.
저는 이에 궁금증이 생겼습니다. null
인 객체가 println()
메서드를 호출하면 보통 NullPointerException
이 발생하지 않나요?
왜 System.out.println()
은 예외를 발생시키지 않을까요?
자바의 클래스와 static
키워드를 떠올리며 System.out.println()
메서드를 분석해 보겠습니다.
System
클래스의 객체 생성 없이 out
필드를 호출 하였다.out
필드에는 static
키워드가 붙어있을 것이다.out
필드를 이용하여 println()
메서드를 호출하였다.실제로 맞는지 OpenJDK 깃허브 에서 System
클래스 코드를 확인해 볼까요?
// System.java
public final class System {
public static final PrintStream out = null;
}
제 생각이 맞았습니다. 실제로 static
키워드와 out
필드가 존재합니다.
추가로 PrintStream
클래스가 새롭게 등장하였으니 같이 살펴봅시다.
// PrintStream.java
public class PrintStream extends FilterOutputStream implements Appendable, Closeable {
public void println() {
newLine();
}
아하! println()
메서드는 PrintStream
클래스에서 구현한 것이였군요.
실제 코드를 살펴보며 얻은 정보는 다음과 같습니다.
out
필드는 static
키워드 외에 final
키워드도 같이 선언되어 있어 상수이다.PrintStream
클래스에 println()
메서드가 정의되어 있다.다음과 같이 MySystem
클래스와 MyPrintStream
클래스를 생성하였습니다.
// MySystem.java
public class MySystem {
public static final MyPrintStream out = null;
}
// MyPrintStream.java
public class MyPrintStream {
public void println() {
// 구현
}
}
MySystem
클래스는 System
클래스와 유사한 형태를 갖추고 있으며, 호출 방식 또한 System.out.println()
처럼 사용할 수 있습니다.
Client
클래스를 생성하여 테스트 해볼까요?
// Client.java
public class Client {
public static void main(String[] args) {
MySystem.out.println();
}
}
이 코드를 실행하면 NullPointerException
이 발생합니다.
Exception in thread "main" java.lang.NullPointerException
기대한 대로, null
객체가 메서드를 호출했기 때문에 당연한 결과입니다.
하지만 System
클래스의 out
필드는 null
로 초기화 되었음에도, 왜 System.out.println()
메서드 호출 시 NullPointerException
이 발생하지 않을까요?
out
필드의 초기화를 static
블록에서 진행하도록 MyPrintStream
클래스를 다음과 같이 수정해보겠습니다.
// MyStream.java
public class MySystem {
static {
out = new MyPrintStream();
}
public static final MyPrintStream out;
}
메인 메서드를 실행해보면 정상적으로 실행됩니다.
// Client.java
public class Client {
public static void main(String[] args) {
MySystem.out.println();
}
}
하지만 System
클래스를 다시 살펴보면 out
은 분명히 null
로 초기화 되어있습니다.
// System.java
public final class System {
public static final PrintStream out = null;
}
그렇다면 MyPrintStream
의 out
또한 null
로 변경해보겠습니다.
// MySystem.java
public class MySystem {
static {
out = new MyPrintStream();
}
public static final MyPrintStream out = null;
}
이 코드는 컴파일 에러를 일으킵니다.
java: cannot assign a value to final variable out
final
키워드를 이용하여 상수값을 선언하였기에, 새로운 값의 할당이 불가능 하므로 당연한 결과입니다.
System
클래스의 out
필드는 null
로 초기화 되었고 final
키워드로 static
영역에서 값을 재할당 받지도 못하는데, 어떻게 println()
메서드를 호출할 수 있을까요?
static
필드를 초기화 하는 방법은 두 가지 방법 밖에 없습니다.
=
을 이용한 직접 초기화
public class MyClass {
public static final int MY_CONSTANT = 42;
}
static
블록을 이용한 초기화
public class MyClass {
public static final int MY_CONSTANT;
static {
MY_CONSTANT = 42;
}
}
System
클래스의 out
필드는 이미 =
를 이용하여 null
로 초기화되어 있습니다.
// System.java
public final class System {
public static final PrintStream out = null;
}
그렇다면, static
블록에는 어떠한 코드가 있는지 확인해봅시다.
// System.java
public final class System {
private static native void registerNatives();
static {
registerNatives();
}
}
살펴보니 native
키워드가 붙은 registerNatives()
메서드를 실행하고 있습니다.
자바에서 친절하게 작성해준 주석을 살펴볼까요?
Register the natives via the static initializer.
정적 초기화 프로그램을 통해 네이티브를 등록합니다.
잘은 모르겠지만 주석과 메서드명으로 추측해 보면 registerNatives()
메서드는 네이티브 메서드들을 등록하는 역할을 하는 것 같습니다.
📚 네이티브 메서드(Native Method) ?
- 자바 언어로 작성된 코드가 아닌, 특정 플랫폼의 네이티브 코드로 작성된 메소드
- 일반적으로 C, C++ 등의 언어로 작성된 코드를 의미한다.
복잡하니 정리해봅시다.
System
클래스의 static
블록에서 registerNatives()
메서드가 호출된다.registerNatives()
메서드는 네이티브 메서드
들을 등록한다.네이티브 메서드
는 자바로 작성된 코드가 아니고 C
, C++
등으로 작성된 코드다.System.out.println()
메서드와 NullPointerException
에 대한 의문을 해소하기 위해 자바의 내부 동작 원리를 간단하게 알아봅시다.
자바 가상 머신(JVM)의 내부에는 클래스 로더(Class Loader)라는 것이 있습니다. 클래스가 처음 참조되거나 호출되는 시점에 해당하는 클래스 파일을 찾아 메모리에 로드하는 역할을 수행하는데요.
이 로드 과정은 로딩, 링크, 초기화의 세 단계로 이뤄지며, 특히 초기화 단계에서 static
블록에 있는 코드들이 실행됩니다.
갑자기 뭔가 어려워진 것 같으니, '클래스 로더라는 놈이 클래스를 메모리에 올릴 때 static
블록이 실행 되는구나' 정도로 정리합시다.
자바 프로그램이 실행될 때는 몇 가지 핵심 클래스들이 자동으로 로드됩니다. 몇 가지 살펴보면 다음과 같습니다.
java.lang.System
즉 System
클래스는 자바 프로그램이 실행될 때 자동으로 메모리에 로드되고, static
블록이 실행됩니다. 따라서 registerNatives()
메서드가 실행되어 네이티브 메서드가 등록되는 것이죠. (아쉽게도 해당 메서드는 자바로 구현되어 있지 않아 확인은 불가합니다.)
또한 이렇게 등록된 네이티브 메서드는 자바 코드에서 사용자가 직접 호출하지 않고, 내부적으로 JVM이 필요에 따라 호출하게 됩니다.
위 내용을 전부 정리해보겠습니다.
System
클래스가 자동으로 로드된다.static
블록의 registerNatives()
메서드가 실행된다.registerNatives()
메서드가 네이티브 메서드들을 등록한다.System.out
을 호출할 때, 네이티브 메서드가 내부적으로 실행된다.out
필드는 네이티브 메서드를 통해 PrintStream
객체를 할당받는다.드디어 의문이 풀렸습니다!
out
필드는 null
로 선언되어 있지만 네이티브 메서드를 이용하여 PrintStream
객체를 특별한 방법으로 할당받기 때문에 NullPointerException
이 발생하지 않았던 것입니다.
저도 처음에 정말 이해가 가지 않았던 부분입니다. 불변의 객체가 재할당이 된다니, 말이 안되는 소리라고 생각했으니까요.
하지만, static final
은 일반적인 자바 코드 수준에서 재할당이 불가능하지만, 네이티브 메서드를 통해 특별한 방식으로는 가능합니다.
다시 말해, 어떤 상황에서든 완벽하게 불변하는 상수가 아니었던 것입니다.
굳이 왜 null
로 초기화 하여 사람을 헷갈리게 했을까요?
저는 자바에서 "이 필드는 특수한 방법으로 초기화 할거야!" 라고 나타낸 부분이라고 이해했습니다.
개인적으로는 조금 더 자세한 주석이 있다면 좋겠다고 생각했는데, 언급이 없어서 아쉽습니다.
처음에는 out
필드가 null
로 초기화 되어 있음에도 불구하고 왜 NullPointerException
이 발생하지 않는지 의아했습니다.
이 궁금증을 해소하기 위한 과정에서 자바의 내부 동작 원리 및 네이티브 메서드 호출이라는 새로운 지식, static final
변수의 초기화 과정과 완벽한 불변의 상수가 아니라는 흥미로운 내용을 알게 됐습니다.
이 글이 저와 같은 궁금증을 가진 다른 이들에게 도움이 되기를 바랍니다.
감사합니다.