패스트터틀

Java 에서 "Hello World" 가 출력되기까지의 과정 본문

Development language/java

Java 에서 "Hello World" 가 출력되기까지의 과정

SudekY 2022. 6. 12. 15:59

 

 

INTRO


Hello World

Java를 처음 접했다면 누구나 거치는 "Hello World" 출력 과정이 있다.
우선 자바를 구글에 검색해서 설치한다. 그리고 환경변수를 설정한다. 파일을 하나 만들고 티(T) 이(E) 에스(S) 티(T) 점(.) (제이) j (에이) a (브이) v (에이) a로 만든다. 그리고 아래 영어로 된 코드를 똑같이 입력한다. 다 적었다면 파일을 저장하고 밖으로 나와 javac Test.java를 한다. 그러면 Test.class 가 만들어진다. 그러고 나서 java Test 하면 "Hello World"가 출력된다. 뭐가 안된다면 철자가 틀린 것이 없는지 확인해본다. 오늘 수업은 여기까지다. 다음 수업 때까지 코드를 외워오는 것이 숙제다.

// Test.java
class Test {
    public static void main(String args[]){
        System.out.println("Hello World");
    }
}
// Test.kt
fun main(){
	println("Hello World")
}

 

Hello World는 단순하지 않다.

이 세상에 그냥은 없다. 과정이 하나하나 있다. 모든 언어도 CPU 한테 명령을 전달하기까지의 과정이 있다. 그래서 그 과정을 모른다면 이해하기가 힘들다. 물론 그 과정은 단순하지 않다. 근데 이제는 어떤 과정으로 Hello World로 출력되는지 알아야 한다. 왜냐하면 kotlin이라는 언어를 학습하면서 Java를 깊게 알 필요가 있었기 때문이다. kotlin 도 jvm 위에서 동작하는 언어이다. 근데 jvm 이 매우 중요했다. java라는 언어 위에서 객체지향이니 디자인 패턴이니 클린 아키텍처보다 jvm을 먼저 아는 것이 더 중요했다. 그래서 Hello World를 공부하기로 맘먹었다.

 

 


010101000 가 되기까지

컴퓨터는 "010101000"과 같은 형태로만 명령을 처리한다. (사실 "010101000" 은 전압(Voltage)을 조정하는 것이지 실제로 있는 값은 아니다.)
근데 "010101000"를 만드는데도 약속이 있다. 어떤 컴퓨터는 A라는 약속을 사용하고 어떤 컴퓨터는 B라는 약속을 사용한다.
우리는 이러한 약속을 '명령어 집합'이라고 부른다. 전공자라면 명령어 집합을 수업시간에 배웠을 것이다.(기억은 안 나겠지만..) 그렇다면 내가 프로그래밍을 할 때마다 '명령어 집합'에 따라 다르게 작성을 해야 하는 건가?
맞다.
왜냐하면 내가 A라는 컴퓨터에서 작성한 프로그램 파일을 B라는 컴퓨터에서 실행시키면 에러가 날것이다. 왜냐하면 B라는 컴퓨터는 A라는 컴퓨터에서 '명령어 집합'으로 컴파일된 파일을 읽을 수 없기 때문이다.
그렇다면 OS는 어떨까? OS에서도 다르다. Window '전용' 프로그램을 Mac에서 사용 불가능하다. '전용'이라는 말은 '프로그램'이 '종속적'이라는 말과 동일하다. 

그리고 이런 프로그래밍 막일(?)을 극복하기 위해서 자바가 등장했다.

Write Once Read Anywhere

자바는 크로스 플랫폼에서도 사용 가능한 언어이다. 그 이유는 JVM(자바 가상 머신)이 있기 때문인이다. 자바를 설치할 때 JRE를 보았을 것인데 JRE는 Java Runtime Environment의 약자로서 JVM을 포함하여 자바로 만들 프로 그래이 동작하기 위한 라이브러리를 가지고 있다. 근데 이것을 플랫폼별로 설치할 수 있게 제공해준다. 어? 크로스 플랫폼이 아닌데? 그렇다.

그러니까 Java라는 언어는 JVM에 종속적이라고 할 수 있고 JVM 은 여전히 플랫폼 종속적이라는 이야기다. 하지만 오직 JVM 만 플랫폼 종속이기 때문에 Java로 생성되는 결과 코드(Java Bytescode)는 어느 플랫폼에서나 동일하다. 그렇기 때문에 JVM 만 지원해준다면 어느 플랫폼에서나 동일한 결과물을 확인 가능하다.  
그리고 "Write Once Read Anywhere" 은 위에서 설명한 플랫폼 종속성이 거의 없다는 것을 장점으로 내세우기 위해 Sun Microsystems에서 만든 1995 슬로건이다.

( ✻ Sun Microsystems  에서 제임스 고슬링이 자바를 만들었다.)

Hello World에서 010101000까지의 전체 과정

전체 과정을 살펴보자. 우선 우리가 작성한 코드는 Java Compiler 가 Java Bytecode로 변환한다. 위에서 말했듯이 Java Bytecode는 어느 플랫폼에서도 똑같은 결과로 나온다. 그리고 이 Bytecode를 실행하면 JRE의 classloader 가 JVM 적재 후 Bytecode를 JIT 방식으로 각 플랫폼에 맞게 컴파일한다. 즉 컴퓨터가 알아들을 수 있는 기계어의 형태로 해석한다. 그리고 컴퓨터는 이를 알아듣고 "Hello World"를 출력한다. 아래는 전체 과정이다.

 

우선 개념 정리 한번 하고 갑시다

  • Compile
    • 컴퓨터의 말로 바꾸는 것
  • Interpreter
  •  JVM
    • 자바 가상 머신이며 벤더들에 의해 성능 및 특징이 바뀐다. 오라클에서 사용하는 JVM 은 Hotstop JIT을 사용하여 Hotspot JVM 이라고도 부른다.
    • Android는 Java 지만 저작권 문제로 JVM 이 아닌 이름이 'J'를 'Dalvik'으로 바꾸어 개발한 Dalvik VM을 사용한다. 하지만  배터리/성능 문제로 Dalvik VM을 버리고 JIT과 AOT 둘 다 사용하는 ART(Android RunTime) VM을 사용한다.
  • JIT(Just In Time)
    • 일반적으로 실행 시점에 인터프리터 방식처럼 실시간으로 컴파일하는 방식의 특징을 통틀어서 얘기하는 용어다.
    • JIT 컴파일은 실시간으로 컴파일하는 동시에 캐싱하여 다시 컴파일하는 것을 방지한다. 즉, 실행 시점에는 인터프리터 방식처럼 동작하지만 캐싱을 통해 정적 컴파일처럼 동작도 한다. 근데 인터프리터 방식이어도 바이트코드 컴파일러가 바이트코드(중간 언어)로 변환하며 최적화를 해주기 때문에 속도가 인터프리터보다 빠르다.
    • 경우에 따라서 메모리와 CPU 자원이 충분한 경우 런타임에서 사용 가능한 정보를 활용하여 최적화를 진행하기 때문에 AOT 컴파일 방식보다 더 빠를 수도 있다.
    • Android에서는 일반적으로 AOT 방식보다 용량을 적게 쓰고 메모리/CPU/배터리 사용량이 크다.
  • AOT(Ahead Of Time)
    • 일반적으로 설치 시점에 미리 컴파일하는 방식인 static compilation'에 해당하며 일반적으로는 정적 컴파일이 가지는 이점에 대한 설명을 할 때 static compilation 이 아닌 AOT라는 의미를 사용한다. 
    • Java에서는 Java Bytecode로 변환하는 것은 기본적인 요구사항과 관련되었기 때문에 이를 AOT라고 부르지는 않는다.
    • Android에서는 일반적으로 JIT 방식보다 용량을 많이 쓰고 메모리/CPU/배터리 사용량이 적다.
  • Hotspot
    • 오라클에서 만든 JIT 컴파일러다.
    • 자주 사용되는 바이트 코드를(hotspot)을 실시간으로 프로파일링 하여 성능을 최적화한다.
  • Hotspot JVM
    • Hotspot 방식의 컴파일러를 사용하는 가상 머신이다.
    • 서버와 클라이언트 두 개의 모드가 있으며 클라이언트는 모드는 빠른 로드를 목적으로 메모리 사용량이 낮고 서버 모드는 적극적으로 JIT 컴파일 사용을 위해 전체적인 최적화가 중점이며 더 많은 메모리를 사용한다.
  • Execution Engine 에는 JIT Compiler와 Garbage Collector 가 들어간다.

출처 : Wiki

. java 파일이 .class 파일이 된다?

. java 파일을 javac 명령어로 컴파일을 진행할 경우. class 파일이 생성된다. 이것이 위에서 말한 bytecode이다. 즉 중간 언어이다. 그렇다면 이를 한번 살펴보자.

우선 Hello World를 작성한다. Test.java로 생성했다.

// Test.java
class Test {
    public static void main(String args[]){
        System.out.println("Hello World");
    }
}

그리고 다음 명령어를 입력한다.

javac Test.java

그리고 살펴볼 경우 Test.class 파일이 생성된다. 이 코드를 vi 또는 텍스트 뷰어로 열어본다. 아래와 사진과 같다.

위와 같이 알 수 없는 문자들이 나타나는 것을 볼 수 있다. 이것은 bytecode 이기 때문이다. 

java bytecode 뜯어보기

java bytecode를 볼 수 있는 명령어가 있다. 역 어셈블러화 하는 것이다. 필요 옵션에 따라 -verbose 말고 다른 옵션을 넣을 경우 조금 더 간단하게 볼 수도 있지만 여기서는 -verbose를 통해 전부 다 살펴보기로 한다.

javap -verbose Test.class

위 명령어로 컴파일을 통해 생성된 class 파일을 뒤에 파라미터로 주고 입력하면 아래와 같이 길게 내용이 출력된다.

Classfile /Users/seongdaegyeong/Downloads/개인/Test.class
  Last modified 2022. 6. 11.; size 413 bytes
  MD5 checksum 6f003301ff18a0054056c9a462496248
  Compiled from "Test.java"
class Test
  minor version: 0
  major version: 55
  flags: (0x0020) ACC_SUPER
  this_class: #5                          // Test
  super_class: #6                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #6.#15         // java/lang/Object."<init>":()V
   #2 = Fieldref           #16.#17        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #18            // Hello World
   #4 = Methodref          #19.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #21            // Test
   #6 = Class              #22            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               SourceFile
  #14 = Utf8               Test.java
  #15 = NameAndType        #7:#8          // "<init>":()V
  #16 = Class              #23            // java/lang/System
  #17 = NameAndType        #24:#25        // out:Ljava/io/PrintStream;
  #18 = Utf8               Hello World
  #19 = Class              #26            // java/io/PrintStream
  #20 = NameAndType        #27:#28        // println:(Ljava/lang/String;)V
  #21 = Utf8               Test
  #22 = Utf8               java/lang/Object
  #23 = Utf8               java/lang/System
  #24 = Utf8               out
  #25 = Utf8               Ljava/io/PrintStream;
  #26 = Utf8               java/io/PrintStream
  #27 = Utf8               println
  #28 = Utf8               (Ljava/lang/String;)V
{
  Test();
    descriptor: ()V
    flags: (0x0000)
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello World
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 6: 0
        line 7: 8
}
SourceFile: "Test.java"

 

중점적으로 볼 것은 constant pool과 아래 opcode이다.

  • opcode
    • 바이너리를 구성하는 코드들로, CPU가 실제로 수행할 작업을 나타내는 숫자 코드이다. opcode를 해석하여 기계어로 컴퓨터에게 전달한다. 
  • constant pool
    • java bytecode에는 데이터가 필요한데 bytecode에 저장하기에는 값이 너무 크기 때문에 이를 저장하는 공간이다. 그리고 참조를 가지고 있는다. 아래는 그 constant pool에 저장될 목록들이며 dynamic linking에 사용된다고 한다.
      • numeric literals
      • string literals
      • class references
      • field references
      • method references
  • operand
    • 피연산자이며 계산할 대상이다. Operand Stack 은 피연산자 계산을 위한 작업 공간(Stack 구조)이다.
  • stack frame
    • 메모리의 Stack 영역에 스레드당 하나의 Stack을 가지고 있다. 메서드의 호출과 관계되는 지역 변수와 매개변수가 덩어리를  Frame이라고 한다. Frame 은 Stack에 들어가서 Stack 순서에 맞게 호출된다.
    • Frame 은 아래처럼 구성되어있으며 위에서 설명한 것들을 포함한다.
      • Local Variables
      • Operand Stack
      • Constant Pool Reference

한번 Bytecode 의 흐름을 따라가 보자

우선 아래 opcode를 살펴보자. ( 각각의 opcode 가 무엇을 의미하는지는 자바 스펙 사이트를 참고 - 클릭 시 이동 )

 0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
 3: ldc           #3                  // String Hello World
 5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
 8: return

constant pool 2번 참조를 getstatic 한다. PrintStream이라는 static field을 Operand Stack에 넣는 것이다.

그리고 "Hello World"를 Operand Stack에 넣는다. 그리고 println 메서드 인스턴스를 실행한다.

( ✻ invokevirutal: invokes a method based on the class of the object )

 

첫 번째로 getstatic이다. 상수 풀 내 2 번째(정적 필드의 정적 값)를 Operand Stack으로 Push 한다.

0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;

두 번째는 ldc이다. 상수 풀 내 3 번째 값인 상수를 Operand Stack에 Push 한다.

3: ldc           #3                  // String Hello World

 

세 번째는invokevirtual이다. 상수 풀 내 4 번째 메서드 인스턴스를 호출한다. 그리고 현재 스레드에 대한 Frame으로 만들어 Stack 메모리에 집어넣는다.

 5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V

이제 프로그램이 실행되면 Bytecode 가 위 의 흐름대로 JIT 컴파일러가 기계어로 번역을 한다. 메인 스레드 Stack 에는 System.out(== PrintStream)의 println에 "Hello World"라는 String 객체를 인자를 넣어 인스턴스를 호출하는 프레임이 들어가고 이를 CPU 가 처리하게 되며 "Hello World"가 출력된다.

살짝 아쉽다..  equals 랑 ==  의 차이도 찾아볼까?

class Test {

        public static void main(String args[]){
                String a = "A";
                String b = "B";
                if(a == b){
                        System.out.println("A == B");
                }

                if(a.equals(b)){
                        System.out.println("A equals B");
                }

                if(a.equals("B")){
                        System.out.println("A equals ''B''");
                }

                if("A".equals("B")){
                        System.out.println("''A'' equals ''B''");
                }
        }
}

java bytecode

Classfile /Users/seongdaegyeong/Downloads/개인/Test.class
  Last modified 2022. 6. 12.; size 681 bytes
  MD5 checksum eb993c2881c3b8663c40f44c53b6d395
  Compiled from "Test.java"
class Test
  minor version: 0
  major version: 55
  flags: (0x0020) ACC_SUPER
  this_class: #11                         // Test
  super_class: #12                        // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #12.#23        // java/lang/Object."<init>":()V
   #2 = String             #24            // A
   #3 = String             #25            // B
   #4 = Fieldref           #26.#27        // java/lang/System.out:Ljava/io/PrintStream;
   #5 = String             #28            // A == B
   #6 = Methodref          #29.#30        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #7 = Methodref          #20.#31        // java/lang/String.equals:(Ljava/lang/Object;)Z
   #8 = String             #32            // A equals B
   #9 = String             #33            // A equals \'\'B\'\'
  #10 = String             #34            // \'\'A\'\' equals \'\'B\'\'
  #11 = Class              #35            // Test
  #12 = Class              #36            // java/lang/Object
  #13 = Utf8               <init>
  #14 = Utf8               ()V
  #15 = Utf8               Code
  #16 = Utf8               LineNumberTable
  #17 = Utf8               main
  #18 = Utf8               ([Ljava/lang/String;)V
  #19 = Utf8               StackMapTable
  #20 = Class              #37            // java/lang/String
  #21 = Utf8               SourceFile
  #22 = Utf8               Test.java
  #23 = NameAndType        #13:#14        // "<init>":()V
  #24 = Utf8               A
  #25 = Utf8               B
  #26 = Class              #38            // java/lang/System
  #27 = NameAndType        #39:#40        // out:Ljava/io/PrintStream;
  #28 = Utf8               A == B
  #29 = Class              #41            // java/io/PrintStream
  #30 = NameAndType        #42:#43        // println:(Ljava/lang/String;)V
  #31 = NameAndType        #44:#45        // equals:(Ljava/lang/Object;)Z
  #32 = Utf8               A equals B
  #33 = Utf8               A equals \'\'B\'\'
  #34 = Utf8               \'\'A\'\' equals \'\'B\'\'
  #35 = Utf8               Test
  #36 = Utf8               java/lang/Object
  #37 = Utf8               java/lang/String
  #38 = Utf8               java/lang/System
  #39 = Utf8               out
  #40 = Utf8               Ljava/io/PrintStream;
  #41 = Utf8               java/io/PrintStream
  #42 = Utf8               println
  #43 = Utf8               (Ljava/lang/String;)V
  #44 = Utf8               equals
  #45 = Utf8               (Ljava/lang/Object;)Z
{
  Test();
    descriptor: ()V
    flags: (0x0000)
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #2                  // String A
         2: astore_1
         3: ldc           #3                  // String B
         5: astore_2
         6: aload_1
         7: aload_2
         8: if_acmpne     19
        11: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        14: ldc           #5                  // String A == B
        16: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        19: aload_1
        20: aload_2
        21: invokevirtual #7                  // Method java/lang/String.equals:(Ljava/lang/Object;)Z
        24: ifeq          35
        27: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        30: ldc           #8                  // String A equals B
        32: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        35: aload_1
        36: ldc           #3                  // String B
        38: invokevirtual #7                  // Method java/lang/String.equals:(Ljava/lang/Object;)Z
        41: ifeq          52
        44: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        47: ldc           #9                  // String A equals \'\'B\'\'
        49: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        52: ldc           #2                  // String A
        54: ldc           #3                  // String B
        56: invokevirtual #7                  // Method java/lang/String.equals:(Ljava/lang/Object;)Z
        59: ifeq          70
        62: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        65: ldc           #10                 // String \'\'A\'\' equals \'\'B\'\'
        67: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        70: return
      LineNumberTable:
        line 6: 0
        line 7: 3
        line 8: 6
        line 9: 11
        line 12: 19
        line 13: 27
        line 16: 35
        line 17: 44
        line 20: 52
        line 21: 62
        line 23: 70
      StackMapTable: number_of_entries = 4
        frame_type = 253 /* append */
          offset_delta = 19
          locals = [ class java/lang/String, class java/lang/String ]
        frame_type = 15 /* same */
        frame_type = 16 /* same */
        frame_type = 17 /* same */
}
SourceFile: "Test.java"

역시나 중점적으로 볼 것은 Constant pool 이랑 opcode의 흐름이다.

  • if_acmp <cond>
    • Branch if reference comparison succeeds
    • if_acmpeq
      • succeeds if and only if value1 = value2
    • if_acmpne
      • succeeds if and only if value1  value2
    • 참조 비교(주소가 저장된 값의 주소를 비교, 쉽게 말해 주소 비교)를 진행한 후에 성공에 따른 분기 처리를 진행한다.
  • astore_<n>
    • operand stack에서 값을 참조하여 frame local variable(지역변수)에 저장한다.
  • aload_<n>
    • frame local variable(지역변수)에 참조(주소)를 로드한다.
  • if <cond>
    • value를 비교하여 분기 
    • ifeq
      • succeeds if and only if value = 0
  • ldc
    • 상수 풀과 operand stack에 넣는다.

첫 번째 String 선언 부분이다. A 랑 B를 Frame 지역변수에 저장한다.

String a = "A";
String b = "B";
0: ldc           #2                  // String A
2: astore_1
3: ldc           #3                  // String B
5: astore_2

두 번째 참조 비교 부분이다. 둘의 주소를 비교하여 다를 경우 19번으로 넘어간다.

8: if_acmpne     19
11: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
14: ldc           #5                  // String A == B
16: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
19: aload_1

세 번째 equals를 통한 비교 부분이다. 우선 equals의 코드는 다음과 같다. char로 하나하나씩 비교하여 boolean을 return 한다.

public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = length();
        if (n == anotherString.length()) {
            int i = 0;
            while (n-- != 0) {
                if (charAt(i) != anotherString.charAt(i))
                        return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

ifeq에서는 retrun boolean 이기 컴퓨터 읽을 수 있도록 0과 1로 처리되어 결괏값에 따라서 분기한다. 아래 코드를 해석해보면 equals를 통해 결괏값이 틀리다면 35번으로 분기한다.

19: aload_1
20: aload_2
21: invokevirtual #7                  // Method java/lang/String.equals:(Ljava/lang/Object;)Z
24: ifeq          35
27: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
30: ldc           #8                  // String A equals B
32: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
35: aload_1

 

네 번째 다섯 번째 ""을 통한 equals 구문이다. 나머지는 전부 다 동일하다. 그런데 여기서 발견한 점은 "A"라는 String을 다시 생성하지 않는 것이다. 상수 풀에 전부 넣고 이미 있다면 해당 상수를 재사용한다

35: aload_1
36: ldc           #3                  // String B
38: invokevirtual #7                  // Method java/lang/String.equals:(Ljava/lang/Object;)Z
41: ifeq          52
44: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
47: ldc           #9                  // String A equals \'\'B\'\'
49: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
52: ldc           #2                  // String A
54: ldc           #3                  // String B
56: invokevirtual #7                  // Method java/lang/String.equals:(Ljava/lang/Object;)Z
59: ifeq          70
62: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
65: ldc           #10                 // String \'\'A\'\' equals \'\'B\'\'
67: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V

예를 들어 36번, 52번 53번을 보면은 #2, #3을 상수 풀에서 가져다 쓰는데 상수 풀 #2, #3 번은 위의 첫 번째 에서 String a = "A", String b= "B"에서도 사용했던 String 값이다. 만약에 값을 C로 바꾼다면 어떻게 될까? 예를 들어서 다음과 같이 수정한다.

if(a.equals("C")){
        System.out.println("A equals ''C''");
}

if("A".equals("D")){
        System.out.println("''A'' equals ''D''");
}

그런 다음 상수 풀을 확인할 경우 아래처럼 "C"와 "D"의 String 이 상수 풀에 추가된 것을 확인 가능하며 이를 가져다 쓰는 것을 확인 가능하다.

#9 = String             #35            // C
#10 = String             #36            // A equals \'\'B\'\'
#11 = String             #37            // D
.
.
.
#35 = Utf8               C
#36 = Utf8               A equals \'\'B\'\'
#37 = Utf8               D

결론적으로 알고 있던 내용이긴 하지만 내부에서 "=="는 참조(주소) 비교를 사용하고 equals는 equals 메서드에서 반환받은 값을 사용하여 분기 처리되는 것을 확인할 수 있었다. 그리고 상수 풀에 String 값이 이미 존재한다면 해당 값을 다시 재활용한다는 것을 알 수 있었다.

String a = "A" 랑 String a = new String("A")는 무슨 차이일까?

확인해보면 그만이다. 다시 java 코드를 작성하고 bytecode로 확인해보자. 

class Test {

        public static void main(String args[]){
                String a = "A";
                String ab = new String("AB");
        }
}
Classfile /Users/seongdaegyeong/Downloads/개인/Test.class
  Last modified 2022. 6. 12.; size 345 bytes
  MD5 checksum 4b4299e7c2b9f188c969fc350d02d43c
  Compiled from "Test.java"
class Test
  minor version: 0
  major version: 55
  flags: (0x0020) ACC_SUPER
  this_class: #6                          // Test
  super_class: #7                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #7.#16         // java/lang/Object."<init>":()V
   #2 = String             #17            // A
   #3 = Class              #18            // java/lang/String
   #4 = String             #19            // AB
   #5 = Methodref          #3.#20         // java/lang/String."<init>":(Ljava/lang/String;)V
   #6 = Class              #21            // Test
   #7 = Class              #22            // java/lang/Object
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               main
  #13 = Utf8               ([Ljava/lang/String;)V
  #14 = Utf8               SourceFile
  #15 = Utf8               Test.java
  #16 = NameAndType        #8:#9          // "<init>":()V
  #17 = Utf8               A
  #18 = Utf8               java/lang/String
  #19 = Utf8               AB
  #20 = NameAndType        #8:#23         // "<init>":(Ljava/lang/String;)V
  #21 = Utf8               Test
  #22 = Utf8               java/lang/Object
  #23 = Utf8               (Ljava/lang/String;)V
{
  Test();
    descriptor: ()V
    flags: (0x0000)
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=3, args_size=1
         0: ldc           #2                  // String A
         2: astore_1
         3: new           #3                  // class java/lang/String
         6: dup
         7: ldc           #4                  // String AB
         9: invokespecial #5                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
        12: astore_2
        13: return
      LineNumberTable:
        line 6: 0
        line 7: 3
        line 8: 13
}
SourceFile: "Test.java"
  • new
    • 새로운 Object를 생성한다.
    • 아래 Format을 보면 알겠지만 indexbyte1과 indexbyte2 가 object를 구성하는 데 사용된다.
    • Format
      • new 
      • indexbyte1 
      • indexbyte2
  •  dup
    • Duplicate the top operand stack value
    • operand stack에서 값을 복제하고 다시 넣는다

 

첫 번째 부분을 보자. operand stack 넣고 지역변수에 저장한다. 이게 끝이다.

 0: ldc           #2                  // String A
 2: astore_1

두 번째 부분을 보자. 우선 new를 통해 dup와 ldc 한 결과를 operand stack에 넣는다. 그리고 dup를 사용한다. 왜 그럴까?

일단 invokespeacial을 보면 생성된 값을 사용해버린다. 그러면 추후에는 operand stack에서 생성된 String 객체를 참조할 방법이 없다. 그렇기 때문에 dup를 통해 참조를 복사해놓는 것이다. 즉, 하나의 참조는 생성되는 데 사용 하나의 참조는 추후에 사용되기 위해 복사하는 것이다. 그리고 invokespeacial을 통해 인스턴스 메서드를 호출하고 astore_2에 해당 인스턴스 메서드의 참조를 저장한다.

 3: new           #3                  // class java/lang/String
 6: dup
 7: ldc           #4                  // String AB
 9: invokespecial #5                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
12: astore_2

결국 new를 통한 생성이 안티 패턴에 해당하는 것을 볼 수 있다. 즉 literal을 통한 선언이 new를 통한 선언보다 더 나은 것은 literal을 통한 선언은 그저 Constant pool에서 값을 꺼내오면 되지만 new를 통한 선언은 새롭게 Object를 만들고 새로운 주소를 할당받기 때문이다. 물론 실제로 크게 차이는 없겠지만 내부적으로는 더 오래 걸리고 복잡하며 메모리를 더 차지한다.

 


 

포스팅을 마치며..

사실 이전까지도 내부적으로 이런 식으로 처리가 될 것 같은데?라는 느낌으로 알고 있었는데 직접 자세히 살펴보니까 왜 그런지를 알 수 있었던 것 같다. 그리고 이 번 포스팅을 통해 몰랐던 부분들을 다시 정리하고 java 가 돌아가는 전체적인 과정과 jvm과 jit, aot, bytecode 등 조금 더 깊게 공부할 수 있었던 것 같다. 그리고 GC와 자바 성능 튜닝에 대해서 관심이 생겨서 몇 권을 책을 주문했다.

 

 

 

[ 해당 포스팅은 잘못된 정보가 포함되어있을 수 있으니 참고만 부탁드리며 잘못된 부분을 댓글로 남겨주시면 수정하겠습니다.]


[ 참고 사이트 ]

jamesdbloom blog (jvmInternal):  https://blog.jamesdbloom.com/JVMInternals.html#constant_pool

java spec_opcode: https://en.wikipedia.org/wiki/List_of_Java_bytecode_instructions

wiki: https://en.wikipedia.org/wiki/

quora: https://www.quora.com/What-is-the-reason-behind-the-use-of-JIT-instead-of-AOT-in-the- JVM

stackoverflow:  https://stackoverflow.com/questions/31237855/a-concrete-example-of-the-effect-of-the-jit-in-java

나모의 노트님의 블로그 : https://namocom.tistory.com/804

Comments