3. console
현재 실행 중인 JVM에 연관된 문자형 콘솔이 존재하는 경우 이를 리턴한다
JVM 뜯어보기
System.console() 메서드는 JVM이 콘솔 환경에서 실행될 때 해당 콘솔 객체를 리턴하고 없다면 null을 리턴한다.
// src/java.base/java/lang/System.java
public final class System {
private static volatile Console cons;
public static Console console() {
Console c;
if ((c = cons) == null) {
synchronized (System.class) {
if ((c = cons) == null) {
cons = c = SharedSecrets.getJavaIOAccess().console();
}
}
}
return c;
}
}
먼저 System 클래스에서 콘솔 객체는 다음과 같이 cons라는 멤버 변수로 선언되어 있다. 이때 volatile 키워드를 통해 이 변수의 값은 CPU 캐시가 아니라 항상 메인 메모리에서 가져오고 반영하도록 한다. 즉 멀티스레딩 환경에서 CPU의 스레드들이 동시다발적으로 cons변수에 접근하더라도 최신의 값을 보장하도록 하는 것이다. 만약 volatile 키워드가 없다면 CPU 캐싱으로 인해 한 스레드가 cons의 값을 수정한 뒤 CPU Cache에 저장할 가능성이 있다. 이러한 일이 발생할 경우 또 다른 스레드가 CPU Cache에 접근할 수 없다면 메인 메모리에서 cons 값에 접근할 것이고, 메인 메모리의 cons값은 최신 값이 아니므로 문제가 발생한다. volatile 키워드는 이러한 경합 조건(race condition)을 방지하는 상호 배제(mutual exclusion)의 효과를 가진다.
다음으로 console() 메서드를 보면 Console 타입 로컬 변수 c를 선언하고 if문 내에서 cons의 값을 c에 할당한다. 이때 cons에 null이 아닌 값이 존재한다면 if문은 종료되고 Console c를 리턴한다. 만약 cons가 null이라면 if문 블록이 실행되는데, 먼저 synchronized 키워드를 통해 이 System 클래스의 'Class'객체에 한번에 하나의 스레드만 접근해 블록 내의 코드를 실행할 수 있도록 한다.
synchronized 동기화 블록 내에서는 다시 if문을 통해 다시 cons를 검사한다. 이 if문은 동기화 블록 밖에서 했던 if문과 정확히 동일한데, 이러한 알고리즘을 더블 체크 락킹 알고리즘(Double-checked Locking)이라고 한다. 첫번째 if문을 통해 null임을 확인했더라도 그 사이에 다른 스레드가 동기화 블록에 접근해 cons 값을 초기화했을 가능성이 있으므로 두번째 if문에서 한번 더 체크하는 것이다.
최종적으로 if문 안으로 안전하게 진입한 스레드는 SharedSecrets.getJavaIOAccess().console()을 실행한 결과로 cons와 c를 업데이트하고 c를 리턴한다. 이로서 System의 멤버변수 cons와 지역변수 c가 동시에 최신값으로 업데이트된다. 이제 SharedSecrets 클래스의 getJavaIOAccess() 메서드를 알아보자.
// src/java.base/jdk/internal/access/SharedSecrets.java
public class SharedSecrets {
private static JavaIOAccess javaIOAccess;
public static void setJavaIOAccess(JavaIOAccess jia) {
javaIOAccess = jia;
}
public static JavaIOAccess getJavaIOAccess() {
var access = javaIOAccess;
if (access == null) {
ensureClassInitialized(Console.class);
access = javaIOAccess;
}
return access;
}
private static void ensureClassInitialized(Class<?> c) {
try {
MethodHandles.lookup().ensureInitialized(c);
} catch (IllegalAccessException e) {}
}
}
getJavaIOAccess() 메서드는 다음과 같이 SharedSecrets 클래스에 있다. SharedSecrets 클래스는 리플렉션 없이 JDK 내부의 여러 패키지로 나뉘어 있는 API에 접근하기 위해 사용하는 클래스다. 위의 코드에서는 Console에 대한 글을 중점적으로 다루기 위해 javaIOAccess 멤버변수만 표시하고 나머지는 생략했지만 실제 코드에는 각 API에 접근할 때 사용되는 수많은 멤버변수가 존재한다.
우선 getJavaIOAccess() 메서드를 살펴보자. 먼저 javaIOAccess 멤버변수를 access 지역변수에 할당한다. 이때 자바스크립트에서 볼 수 있었던 var 키워드가 있는데, 이는 Java 10에서 도입된 기능으로 변수를 선언할 때 타입을 명시하지 않고 'var' 키워드를 사용하면 자바 컴파일러가 컴파일 타임에 타입을 알아서 추론한다. 타입 이름이 너무 길면 코드를 읽는데 방해가 될 수 있기 때문에 이러한 불편을 줄이고 변수 이름 자체에 집중할 수 있도록 도입된 기능이라고 하는데, 굳이..? 긴 하다. 오히려 다시 타입 확인해야 하고 번거로운데..
아무튼 access 변수 즉 javaIOAccess 멤버변수의 값이 null이 아니라면 access를 리턴해주고, null이라면 if문 블록 안의 코드를 실행한다. javaIOAccess의 값이 null이라면 새로 할당해주기 위해 ensureClassInitialized() 메서드에 Console.class를 담아 실행하고 다시 javaIOAccess의 값을 access에 담은 뒤 리턴해준다.
여기에서 ensureClassInitialized() 메서드는 코드 맨 아래에 나와 있는데, MethodHandles.lookup() 메서드를 실행해 MethodHandles.Lookup 객체를 호출하고 다시 ensureInitialized() 메서드에 임의의 클래스 c를 담아 실행한다. MethodHandles.Lookup클래스의 ensureInitialized() 메서드는 넘겨받은 클래스가 완전히 초기화되었는지 보장하는 메서드로 만약 클래스가 초기화되지 않았다면 초기화하는 역할을 한다. 자세한 내용은 다음에 다시 다루기로 하고, 여기서 넘겨받은 클래스는 Console.class이므로 Console.class가 초기화된다.
javaIOAccess의 값을 얻기 위해 Console.class를 초기화하는 걸로 봐서는 javaIOAccess객체는 Console에 관련된 내용을 담고 있을 것으로 예상할 수 있다.
// src/java.base/jdk/internal/access/JavaIOAccess.java
public interface JavaIOAccess {
Console console();
Charset charset();
}
보다시피 SharedSecret의 javaIOAccess 멤버변수의 타입인 JavaIOAccess는 console()과 charset()를 가진 인터페이스다. 그럼 javaIOAccess 멤버변수의 초기화는 실제로는 Console 클래스에서 수행되는 것을 알 수 있다. Console 클래스의 해당 부분을 살펴보자.
// src/java.base/java/io/Console.java
public final class Console implements Flushable
{
private static final Charset CHARSET;
static {
String csname = encoding();
Charset cs = null;
if (csname == null) {
csname = GetPropertyAction.privilegedGetProperty("sun.stdout.encoding");
}
if (csname != null) {
try {
cs = Charset.forName(csname);
} catch (Exception ignored) { }
}
CHARSET = cs == null ? Charset.defaultCharset() : cs;
// Set up JavaIOAccess in SharedSecrets
SharedSecrets.setJavaIOAccess(new JavaIOAccess() {
public Console console() {
if (istty()) {
if (cons == null)
cons = new Console();
return cons;
}
return null;
}
public Charset charset() {
return CHARSET;
}
});
}
private static Console cons;
private static native boolean istty();
private Console() {
readLock = new Object();
writeLock = new Object();
out = StreamEncoder.forOutputStreamWriter(
new FileOutputStream(FileDescriptor.out),
writeLock,
CHARSET);
pw = new PrintWriter(out, true) { public void close() {} };
formatter = new Formatter(out);
reader = new LineReader(StreamDecoder.forInputStreamReader(
new FileInputStream(FileDescriptor.in),
readLock,
CHARSET));
rcb = new char[1024];
}
}
Console 클래스를 보면 static 블록 안에서 SharedSecrets.setJavaIOAccess() 메서드가 실행되는 것을 알 수 있다. 파라미터 안에서 new JavaIOAccess() {}를 통해 인터페이스의 멤버를 일시적으로 재정의해서 사용할 수 있는 익명 클래스를 만들어 console(), charset() 메서드를 재정의하는 동시에 사용했다. 코드는 복잡하지만 결론적으로 이러한 과정을 통해 Console 객체가 생성되고, SharedSecret의 javaIOAccess멤버변수가 초기화되는 것을 알 수 있다. 재정의된 console() 메서드를 보면 istty()가 true일 경우에만 Console객체가 생성되므로 istty() 네이티브 메서드만 조금 더 자세히 살펴보자.
// src/java.base/windows/native/libjava/Console_md.c
JNIEXPORT jboolean JNICALL
Java_java_io_Console_istty(JNIEnv *env, jclass cls)
{
HANDLE hStdOut = GetStdHandle(STD_OUTPUT_HANDLE);
HANDLE hStdIn = GetStdHandle(STD_INPUT_HANDLE);
if (hStdIn == INVALID_HANDLE_VALUE ||
hStdOut == INVALID_HANDLE_VALUE) {
return JNI_FALSE;
}
if (GetFileType(hStdIn) != FILE_TYPE_CHAR ||
GetFileType(hStdOut) != FILE_TYPE_CHAR) {
return JNI_FALSE;
}
return JNI_TRUE;
}
istty() 네이티브 메서드는 OS에 따라 적절한 Java_java_io_Console_istty() 함수에 매핑되어 있다. 이 경우는 windows 환경일 경우이며 GetStdHandle() 함수를 통해 표준 입력, 표준 출력 핸들을 가져와 첫번째 if문에서는 이 입출력 핸들이 유효한 핸들인지 검사하고, 두번째 if문에서는 입출력 핸들의 타입이 FILE_TYPE_CHAR인지 확인한다. FILE_TYPE_CHAR는 windows환경에서 TTY(TeleTYpewriter) 즉 콘솔을 나타내는 변수다. 따라서 JVM이 실행되는 환경이 콘솔일 경우에만 Console객체가 만들어지도록 한다.
사용해보기
import java.io.Console;
public class Main {
public static void main(String[] args) {
Console c = System.console();
System.out.println(c);
c.printf("김치만두\n");
String result = c.readLine();
c.printf(result);
}
}
코드를 보면 System.console() 메서드를 이용해 Console 객체를 얻고 이를 출력한다. IntelliJ와 같은 IDE에서도 내부적으로 콘솔이 있으므로 잘 실행될 것 같은데 NullPointerException이 발생한다. 이는 위의 istty() 네이티브 메서드에서 IDE 내장 콘솔이 표준 터미널로 인식되지 않아 false를 리턴하기 때문이다. 실제로 IDE 환경에서는 일반적으로 터미널을 모방한 터미널 에뮬레이터를 사용한다. 결론적으로 System.console()을 사용하고 싶다면 cmd나 리눅스의 터미널에서 실행할 수 있도록 해야겠지만, 그냥 java.util.Scanner, System.out.println() 같은 걸 사용하자
'프로그래밍 > Java' 카테고리의 다른 글
[Java] JDK 부수기 - (2) java.lang.System - 5. currentTimeMillis, nanoTime (0) | 2023.11.29 |
---|---|
[Java] JDK 부수기 - (2) java.lang.System - 4. getProperties (0) | 2023.11.28 |
[Java] JDK 부수기 - (2) java.lang.System - 2. arraycopy (0) | 2023.11.24 |
[Java] JDK 부수기 - (2) java.lang.System - 1. err, in, out (0) | 2023.11.23 |
[Java] JDK 부수기 - (1) java.lang.Object - 5. toString (0) | 2023.11.21 |