Skip to content

Java

2 posts with the tag “Java”

System.out.println()의 동작 원리와 성능 이슈

PrintStream 클래스는 OutputStream을 상속받아 출력 스트림을 구현하며, 다양한 타입의 데이터를 출력할 수 있는 메서드를 제공한다.

public class Main {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}

System.outPrintStream 타입의 static 객체이며, println()을 포함한 다양한 출력 메서드를 제공하여 간편하게 콘솔에 데이터를 출력할 수 있다.

PrintStream을 import하거나 인스턴스를 생성 없이 System.out.println()을 바로 사용할 수 있는 데, 그 이유는 다음과 같다.

  • System 클래스는 java.lang 패키지에 포함되어 있으며, 기본적으로 import되는 패키지이기 때문에 별도 import 없이 사용 가능
  • System 클래스 내부에 PrintStream 타입의 static 필드 out이 정의되어 있음
public final class System {
/* Register the natives via the static initializer.
*
* The VM will invoke the initPhase1 method to complete the initialization
* of this class separate from <clinit>.
*/
private static native void registerNatives();
static {
registerNatives();
}
private System() {
}
public static final PrintStream out = null;
// ...
}

out은 처음에는 null로 선언되어 있지만, JVM이 초기화 과정에서 실제 PrintStream 객체로 할당하게 된다.

  1. System.out은 Java 코드 상으로는 null로 선언되어 있으나, 이는 컴파일 시점 값
  2. registerNatives()라는 native 메서드가 System 클래스 초기화 블록에서 호출
  3. JVM의 initPhase1()에서 입출력 스트림을 직접 설정

이 작업은 Java 코드가 아닌 JVM 내부 native 코드에서 수행되며, 실제로는 메모리 상의 System.out 필드에 객체가 강제로 할당된다.

System.out.println() 내부 구현 코드 분석

Section titled “System.out.println() 내부 구현 코드 분석”

일반적으로 사용하는 System.out.println() 호출은 PrintStream 클래스 구현을 그대로 사용하는 경우가 대부분이며, 이 경우 다음과 같은 흐름으로 동작한다.

public void println(Object x) {
String s = String.valueOf(x);
if (getClass() == PrintStream.class) {
// need to apply String.valueOf again since first invocation
// might return null
writeln(String.valueOf(s));
} else {
// 하위 클래스 확장 시의 예외적 경로
synchronized (this) {
print(s);
newLine();
}
}
}
  • System.out은 JVM이 직접 생성한 PrintStream 인스턴스이므로, 일반적으로는 writeln() 경로로 실행
  • writeln()은 내부적으로 출력 버퍼에 문자열을 쓰고, 줄바꿈 후 flush까지 수행하는 방식으로 구현

writeln(String s)의 상세 구현을 살펴보면 다음과 같다.

private void writeln(String s) {
try {
synchronized (this) {
ensureOpen();
textOut.write(s);
textOut.newLine();
textOut.flushBuffer();
charOut.flushBuffer();
if (autoFlush)
out.flush();
}
} catch (InterruptedIOException x) {
Thread.currentThread().interrupt();
} catch (IOException x) {
trouble = true;
}
}
  1. ensureOpen()
    • 스트림이 닫히지 않았는지 확인
    • 닫힌 경우 IOException 발생시켜 출력 중단
  2. textOut.write(s) / textOut.newLine()
    • 문자열 및 줄바꿈 문자를 내부 문자 버퍼(StreamEncoder)에 쓰기 수행
    • 개행은 플랫폼에 맞는 \n, \r\n 등 줄바꿈 문자로 추가
    • 실제 출력은 하지 않고, 버퍼에만 저장
  3. textOut.flushBuffer()
    • 문자 버퍼에 저장된 내용을 지정된 Charset으로 인코딩하여 바이트 배열로 변환
    • 인코딩된 바이트 배열 데이터를 StreamEncoder 내부의 OutputStream에 저장
  4. charOut.flushBuffer()
    • StreamEncoder 내부 OutputStream에 저장된 바이트 데이터를 실제 출력 스트림으로 전달
    • 최종적으로 native 메서드를 호출하게 되며, 실제 OS 단에서 출력이 이루어짐
  5. if (autoFlush) out.flush()
    • 기본 true로 설정되어 있어 자동으로 flush() 수행
    • 일반적으로 위 과정으로 이미 flush된 상태이기 때문에 추가 동작은 없음
    • 명시적으로 flush 호출을 통해 출력 스트림을 강제로 비우는 역할

모든 과정이 synchronized 블록 안에서 수행되기 때문에, 여러 쓰레드가 System.out.println()을 호출해도 출력 순서를 보장한다.
하지만 내부적으로 동기화와 IO 작업을 수반하기 때문에 성능 저하를 유발할 수 있다.

  • println() 호출 시, 내부적으로 write()flush()가 함께 수행
  • 출력 스트림은 기본적으로 블로킹 IO이기 때문에, 호출 시점마다 시스템 콜을 발생시키고 쓰레드는 출력 완료까지 대기
  • 특히 반복문 내에서 출력이 빈번하게 발생하는 경우, 다음과 같은 문제 발생
    • 출력 버퍼가 자주 flush되어 성능 저하
    • synchronized/lock 경쟁으로 인한 쓰레드 병목 현상 발생
    • 콘솔 IO 속도는 CPU 연산보다 훨씬 느림

`null`은 오버 로딩된 메서드 중 어떤 메서드를 호출할까?

실행 환경: Java 17

우선 해당 주제를 본격적으로 다루기 전에 아래의 코드를 보자. 아래 코드는 null을 참조하는 변수를 사용했을 때와, 리터럴 null을 사용했을 때 String.valueOf() 메서드의 동작을 보여준다.

class NullTest {
public static void main(String[] args) {
String s = null;
String nullValue = String.valueOf(s);
System.out.println(nullValue); // null, 정상 출력 ---- 1
nullValue = String.valueOf(null); // NullPointerException ---- 2
System.out.println(nullValue);
}
}

1번 코드는 정상적으로 null을 출력하지만, 2번 코드는 NullPointerException이 발생하는 것을 확인할 수 있다. 디버깅 모드를 통해 호출 된 메서드를 추적했을 때, 두 라인은 서로 다른 메서드를 호출하고 있음을 알 수 있었다.

java.lang.String
public final class String {
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}
}

String.valueOf(s)String.valueOf(Object obj)를 호출하는데, 이 메서드는 null 체크를 하는 것을 확인할 수 있다. 때문에 넘겨 받은 obj 값이 null이기 때문에 자연스럽게 "null"을 반환하게 된다.

하지만 직접 null을 넘겨 받은 경우에는 String.valueOf(char data[])를 호출하게 된다.

java.lang.String
public final class String {
public String(char value[]) {
this(value, 0, value.length, null); // 3. value.length에서 NullPointerException 발생
// Exception in thread "main" java.lang.NullPointerException: Cannot read the array length because "value" is null
}
// 1. 메서드 호출 받음
public static String valueOf(char data[]) {
return new String(data); // 2. 위의 String(char value[])를 호출
}
}

주석의 순번대로 코드가 실행되는데, 결국 3번에서 null인 값에서 length를 읽으려고 하기 때문에 NullPointerException이 발생하게 된다. 그렇다면 왜 nullchar data[] 타입이 아닌데 해당 메서드가 호출되는 것일까?

호출 메서드는 어떻게 결정되는가?

Section titled “호출 메서드는 어떻게 결정되는가?”

위 상황과 비슷하게 char data[] 타입과 Object o 타입을 파라미터로 갖는 메서드를 호출했을 때 어떤 메서드가 호출되는지 확인해보자.

class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // char[] Param Method Called
}
public static void testMethod(char data[]) {
System.out.println("char[] Param Method Called");
}
public static void testMethod(Object o) {
System.out.println("Object Param Method Called");
}
}

처음의 예제 코드와 같이 char[] Param Method Called가 출력된다. 그럼 nullchar[]과 특수한 관계가 있는 것일까? 그것도 아니다.

class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // Object Param Method Called
}
// public static void testMethod(char data[]) {
// System.out.println("char[] Param Method Called");
// }
public static void testMethod(Object o) {
System.out.println("Object Param Method Called");
}
}

char data[] 타입의 메서드를 주석 처리하고 실행해보면 Object Param Method Called가 출력된다. 즉, nullchar[]과 특수한 관계가 있는 것이 아니라, Object 보다는 char[] 타입이 더 높은 우선순위를 가진다고 추측해 볼 수 있다. 다른 타입들도 더 살펴보자.

class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // no suitable method found for testMethod(<nulltype>)
}
public static void testMethod(int i) {
System.out.println("int Param Method Called");
}
public static void testMethod(long i) {
System.out.println("Integer Param Method Called");
}
// ...
// char, byte, short, int, long, float, double, boolean
}

당연하게도 원시 타입은 null을 가질 수 없기 때문에 일치하는 메서드가 없다는 에러가 발생한다. 그럼 null이 호출할 수 있는 메서드는 참조(주소) 타입 인자의 메서드만 호출할 수 있는 것으로 추측할 수 있다.

class Test {
int x;
int y;
}
class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // reference to testMethod is ambiguous
}
public static void testMethod(Object o) {
System.out.println("Object Param Method Called");
}
public static void testMethod(char data[]) {
System.out.println("char[] Param Method Called");
}
public static void testMethod(Test t) {
System.out.println("Test Param Method Called");
}
public static void testMethod(String s) {
System.out.println("String Param Method Called");
}
public static void testMethod(int... i) {
System.out.println("int... Param Method Called");
}
public static void testMethod(Integer i) {
System.out.println("Integer Param Method Called");
}
}

위의 메서드들은 단일로 존재했을 때 전부 호출 될 수 있는 메서드들인데, 동시에 존재하는 경우 호출할 수 있는 메서드가 많아 모호하다는 에러 메시지가 발생한다.

reference to testMethod is ambiguous
both method testMethod(int...) in MethodCallTest and method testMethod(java.lang.Integer) in MethodCallTest match

그 중 int ...i 타입과 Integer i 두 개의 메서드에 대해 언급하면서 에러 메시지가 발생했는데, 두 타입이 더 null과 관련이 있는 것일까? 아니다, 그 이유는 다시 아래의 코드와 에러 메시지를 보면 알 수 있다.

class Test {
int x;
int y;
}
class MethodCallTest {
public static void main(String[] args) {
testMethod(null); // reference to testMethod is ambiguous
}
public static void testMethod(Object o) {
System.out.println("Object Param Method Called");
}
public static void testMethod(int... i) {
System.out.println("int... Param Method Called");
}
public static void testMethod(Integer i) {
System.out.println("Integer Param Method Called");
}
public static void testMethod(char data[]) {
System.out.println("char[] Param Method Called");
}
public static void testMethod(Test t) {
System.out.println("Test Param Method Called");
}
public static void testMethod(String s) {
System.out.println("String Param Method Called");
}
}
reference to testMethod is ambiguous
both method testMethod(Test) in MethodCallTest and method testMethod(java.lang.String) in MethodCallTest match

에러 메시지를 다시 살펴 보면 Test 타입과 String 타입이 언급되는 것을 볼 수 있다. 결국 특정 타입이 아닌, 완전히 일치하는 타입이 없어 null을 호출 할 수 있는 메서드를 탐색하게되고, 마지막 두 타입에 대해 모호하다는 에러 메시지가 발생한 것으로 추측해 볼 수 있다.

  1. null은 원시 타입을 제외한 모든 타입의 인자로 호출 당할 수 있다.
  2. nullObject 타입으로도 호출 당할 수 있다.
  3. Object가 아닌 참조 타입 메서드가 존재하면 더 높은 우선순위를 가진다.
  4. 만약 Object 타입을 제외한 참조 타입 메서드가 두 개 이상 존재하면 refrence to method is ambiguous 에러가 발생하게 된다.
  5. 에러 메시지는 null을 호출 할 수 있는 메서드를 탐색하다가 마지막 두 가지 타입에 대해 모호하다는 에러 메시지가 발생한 것으로 추측해 볼 수 있다.

다소 애매한 결과라고 볼 수 있지만, 사실 null 타입을 그대로 넣는 일은 거의 없기 때문에 이러한 상황은 잘 발생하지 않을 것이라고 생각한다. 다시 한 번 null을 사용할 때는 주의 해야 한다는 것을 체감할 수 있었고, null 자체를 파라미터로 넘기는 것을 지양하는 것이 좋을 것 같다.