Developer.

[멋사 백엔드 19기] TIL 5일차

📂 목차


📚 본문

참고: JVM 밑바닥까지 파헤치기 참고: Dev Uni 블로그

Parameter

parameter 는 메서드 선언부에 정의된 변수이며, 그 매개 변수 내의 값을 argument 라고 한다.

Pass-by-value

모든 매개변수 전달 시 값에 의한 전달 방식을 사용한다. 매개체에 따라 두 가지 전달 유형이 있는데

  1. Primitive type 을 전달할 때에는 값 자체가 복사되어 메서드로 전달하며, 메서드 내부에서 변경해도 원본에 영향이 없다.

  2. Reference type 을 전달할 때에는 주소가 복사되어 전달되므로 객체 내부 상태는 수정 가능하며, 참조 자체를 다르게 바꾸면 원본에는 영향이 없게 된다.

가변 길이 매개변수 받기

자바에서 메서드의 파라미터에는 매개변수를 가변길이로 받을 수 있도록 … 기능을 넣어주셨는데, 다음 규칙을 따라야 한다:

  • String… args 형태로 선언할 수 있다. 이는 내부적으로 String Array 와 동일하여 컴파일러가 자동으로 처리해준다.
  • 한 메서드에서 가변 매개변수는 매개변수들 맨 뒤에 선언해주어야 한다.
  • 한 메서드에서 가변 매개변수는 오로지 하나만 가능하다.
  • null 값이 들어갈 수 있으므로 NPE 예외처리를 해주어야 한다.
  • 성능을 고려하여 빈번한 호출 시 성능에 부담을 줄 수 있으므로 가급적 primitive 변수를 넘겨주는게 좋다.
  • 만약 같은 타입의 가변 길이 메서드와 그냥 여러 개 매개변수 받는 메서드가 있다면, 개수에 따라 여러 개 매개변수를 우선으로 한다.

초보자는 가변 길이 매개변수를 쓰지 말자. [] 로도 충분히 인자를 받을 수 있다.

Static

static 은 특별한 키워드이다. 정적의 의미를 가지며, 정적 이라는 소리는 동적과는 반대되는 의미이다. 동적이라는 것은 움직이는, 변하는의 의미를 가진다. 정적은 그 메모리 그대로 변하지 않는 이라는 의미를 가진다 바뀔 수 있지만 가르키는 곳은 변화하지 않는다.

따라서 정적이라는 것은 동적과는 다르게 변하지 않을 것이며, 항상 메모리에서 유일하게 하나로 존재할 것이다. 그것이 JVM 이 loading 될 때의 그 세상에 하나 메모리에 하나의 의미를 지니게 될 것이다.

더 깊이있게 나아가자면, static 변수 정의 정보가 Method Area에 올라가며, JVM 이 올라가고 나서 Heap 에 로딩된다.

Static Field

필드는 클래스가 가지는 변수이다. 이 변수가 유일하다는 것이다.

이런 변수들은 classpublic 하고 fieldpublic static 이면 다른 객체가 변경을 할 수 있게 된다.

이미 메모리에 올라와져 있는 이 static 키워드가 붙은 field 값을 굳이 instance initializing 을 하지 않아도 접근이 가능하다.

public class C { public static final int N = 42; }
// C.N 접근 가능

주의! 공유 상태이기 때문에 volatile / Atomic / Lock 으로 가시성, 원자성 보장이 필요하다.

Static Method

이 또한 마찬가지이다. static 이 붙었기에 메모리에 미리 올라와 있으며, 굳이 instance 를 선언해주지 않아도 유틸 기능을 가지는 함수들을 이렇게 정의하여 사용할 수 있도록 한다.

다만 extends 할 때를 보자. 이때는 오버라이딩을 하면 선언된 변수의 타입에 맞춰서 함수를 실행하게 된다. 이해가 안되면 밑을 보자.

class P { static void hi(){ System.out.println("P"); } }
class C extends P { static void hi(){ System.out.println("C"); } }

P p = new C();
p.hi();      // 컴파일타임 타입 P 기준으로 "P" 출력
C.hi();      // "C"

즉, 오버라이딩이 아닌 메서드 숨김(hiding) 이 일어나게 된다. 이 또한 공유이기 때문에 만약 해당 객체의 데이터를 수정한다, 삭제한다 등의 변경 사항이 일어날 때 synchronized static 을 붙여줘야 한다.

static 초기화 블록

클래스 초기화 시에 딱 한 번만 시행한다(JVM 이 올라갈 때 말하는 것).

클래스에 대해 딱 한 번 실행하고 싶은 코드가 있다면 이를 사용할 수 있다. 이 코드 스코프에서 던져지는 예외는 ExceptionalInInitializerError 로 래핑되어 던져지며, 이 이후에 클래스를 다시 쓰면 NoClassDefFoundError 가 일어나게 된다. 이 상황을 만들어보자.

class Bed {
    static {
        if (true) {
            throw new RuntimeException("boom");
        }
    }
}

클래스는 무조건 Exception 을 띄운다. 이때 JVM 이 Bed 클래스를 처음 로드 & 초기화를 할 때 static 블록이 실행되는데,

public class Main{
    public static void main(String[] args) {
        Bed bed = new Bed(); // Exception 발생
    }
}

여기서 처음 ExceptionInIntializerError 가 발생되며 이는 초기화가 제대로 수행되지 않았다는 소리가 된다. 당연히 Exception 이 발생하여 트랩을 발생시키면 제어권을 OS가 가져가게 되고, 이는 명령을 그때부터 더이상 수행할 수 없게 되는 것이다.

이제 이 클래스는 초기화 실패한 클래스로 JVM에 표시되어 있고, 이를 new Bed() 를 하거나 Bed.(static 메서드)() 를 하면 ExceptionInInitializerError 가 발생하게 된다.

public class Main{
    public static void main(String[] args) {
        Bed bed = new Bed(); // Exception 발생
    }
}

Nested Class

Nested(중복) 클래스는 클래스 안에 클래스를 넣는 설계이다. 보통은 두 종류가 있다(함수 내부에서 선언하는 class 도 있음):

  • static 키워드가 붙은 Static Nested Class

  • Inner Class(static 없음)

Static Nested Class

static 키워드가 붙은 중첩 클래스이며, 바깥 클래스의 인스턴스 멤버에는 접근이 불가한 형태이다. 바깥 클래스의 static 멤버에는 대신 접근이 가능하다.

이것 또한 JVM 을 심도 있게 안다면 바로 알 수 있는 내용인데 static 자체는 JVM 이 올라가고 나서 인스턴스 및 변수가 Heap 영역에 로드, 위치하게 된다.

따라서 Static Nested Class 는 바깥 클래스의 인스턴스에 종속된게 아니다. static 으로 되어진 변수나 함수가 어떻게 값이 들어가게 되는지는 나중에 나온다.

어쨋든 바깥 클래스의 객체 상태(this)와는 독립적으로 존재하기 때문에(initializing 을 안하여도 존재하기 때문에), 바깥 클래스의 인스턴스 필드에 접근할 수 없고 오직 정적 멤버만 참조 가능하다는 것이다(아직 초기화하지 않았기 때문). <- 이거는 public static void main 에서 static 이 아닌 method 를 불러올 때도 마찬가지로 에러가 뜸을 볼 수 있는 것과 동일한 원리다.

만약 Static Nested Class 가 설계된 .class 파일을 javac 로 컴파일 하게 되면 Outer$StaticNestedClass.class 와 같은 별도의 클래스 파일이 생성됨을 볼 수 있을 것이다(중요).

또한 이때 GC 가 이 Static Nested Class 에 대해서도 이미 컴파일된 파일 자체가 독립적으로 되었기 때문에 이는 바깥 클래스 인스턴스의 생명주기와 얽히지 않고 독립적으로 관리하는 것으로 유추해볼 수 있다.

따라서 다음 특징으로 정리해볼 수 있겠다:

  • Memory Independence
  • 역할과 책임의 분리
Inner Class

static 키워드가 없다. 이는 바깥 클래스의 모든 멤버에 접근이 가능하다는 것이며, 대신 바깥 클래스의 인스턴스가 반드시 존재할 때 이 또한 비로소 접근을 할 수 있을 터이다.

우선 초기화를 해야 메모리에 적재가 되기 때문

하지만 이 Inner Class 는 많이 사용하지는 않는데, 숨은 참조가 발생하여 바깥 클래스를 암묵적으로 참조하여 GCOuter 인스턴스를 수거해갈 수 없는 상황이 발생할 수 있기 때문에 메모리 누수가 발생할 수 있다. 진짜 그 클래스와 의미론적으로 강한 연관(NodeList 간의 관계)이 있는게 아닌 이상 굳이 사용하지 않는다.

익명 Nested Class

그때 딱 한 번만 사용할 경우 유용하다. 그것을 제외하고는 X

// 4. 익명 내부 클래스 (anonymous inner class)
public void createAnonymousClass() {
    // 인터페이스를 구현하는 익명 클래스
    Runnable runnable = new Runnable() {
        @Override
        public void run() {
            System.out.println("익명 클래스 실행");
        }
    };

    // Java 8+ 람다 표현식으로 대체 가능
    Runnable lambdaRunnable = () -> System.out.println("람다 실행");
}

⭐️ JVM 의 메모리 공간

JVM7-8-Memory

더 깊이 있게 들어가보자. JVM 은 프로그램을 실행할 때 OS 위에서 자바 프로세스만의 메모리 공간을 따로 확보해서 운영한다.

마크 서버를 운영해보면 알 수 있다.

이 할당된 JVM 의 메모리 구조는 다음 영역으로 다시 구분되게 되는데:

  • Runtime 객체
    • JVM 머신
      • ClassLoader
      • Runtime Data Areas(JVM Memory Areas)
        • Method Area(Static Area)
          • Runtime Constant Pool
        • Heap Area
        • Thread
          • PC Register
          • Stack Area
          • Native Method Stack

Method Area

클래스 로딩 정보(ClassLoader 가 읽어들인 바이트 코드) 들을 여기 메모리에 저장하게 된다. 모든 스레드가 다음을 공유하게 된다.

저장 요소

  • Runtime Constant Pool: Java Compiler 에 의해 만들어지는 Symbol Table 을 사용하여 클래스나 인터페이스 별 Constant Pool 을 만들고, 만들어지는 상수풀은 아래 요소들을 가진다. 이런 상수 풀이 메모리에 올라갈 때 비로소 Runtime Constant Pool 이라고 한다.
    • Literal Constant: String literal 이나 숫자 리터럴 등등
    • Type Field(Local Variable, Class Variable): 필드에 선언된 변수 타입들
    • Class 및 Method 로의 모든 Symbolic Reference: 가져온 클래스나 메서드 들의 참조를 말한다.

Java 7 까지는 PermGen 이라는 명칭이었다.

역어셈블러를 통한 class 파일 살펴보기

위 Constant Pool 을 보기 위해 컴파일 된 Main.class 를 다시 역어셈블러(javap) 를 통해 살펴볼 수 있다.

javap -v Main.class

필자는 다음을 컴파일 후 역어셈블러를 통해 얻었다.


public class Main {
    static final int staticFinalInt = 100;
    static final String staticFinalString = "HELLO";

    int notStaticInt = 999;
    String notStaticString = "Bye";

    public static void main(String[] args) {
        String str = "new";
        int i = 100;

        System.out.println(staticFinalInt);

        System.out.println(staticFinalString);

        System.out.println("HELLO");

        System.out.println(i);

        System.out.println(str);
    }
}
헤더 부분 해석
...
  Compiled from "Main.java"


public class Main
  minor version: 0
  major version: 65
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #8                          // Main
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 4, methods: 2, attributes: 1

맨 위는 민감한 정보같아 가렸다. 구분이 잘되도록 줄바꿈을 좀 했다.

맨 위의 public class Main 이 헤더 부분이다. 해석하자면 public 접근제어자를 가진 Main 클래스를 명시하고, major 65 는 JDK 21에서 컴파일 된 class 파일이라는 뜻이다. 즉 컴파일 컴포넌트의 버전을 암시하는 듯하다. flags 는 접근에 대한 플래그이며, ACC_PUBLIC 을 통해 public 클래스임을 나타내며, ACC_SUPER 를 통해 invokespecial 명령어 동작을 최신 방식(상위 메서드 호출 시 올바른 메서드 선택) 을 따르게 한다.

  • this_class: #8 // Main : Constant Pool 에서의 #8 항목이 현재 Main 클래스를 가르키는 것을 명시
  • super_class: #2 // java/lang/Object : 상위 클래스가 java.lang.Object 임을 의미
  • interfaces: 0 : 구현한 인터페이스 개수는 0개
  • field: 4: 클래스에 선언된 필드(변수) 개수가 총 4개, 이를 통해 static 도 포함되는 것을 볼 수 있다.
  • methods: 2: 클래스에 선언된 메서드 개수이며, 생성자 + main 메서드 로 총 2개이다.
  • attriubtes: 1: 이게 중요한 요소인데, 클래스 파일 수준에서 추가된 속성(attribute)의 개수이다. SourceFile 속성이 일반적으로 들어가며 어떤 소스 파일에서 컴파일 된 것인지 등을 나타낸다.
Attributes 구성요소

attribute 를 보자. 클래스 파일은 기본 구조가 다음과 같다(순서는 신경 안썼다).

  • magic number
  • version
  • constant pool
  • access flags
  • this_class / super_class
  • interface
  • fields
  • methods
  • attributes

이때 attributes 는 추가 설명서 역할을 한다. 클래스, 필드, 메서드 모두에 붙을 수 있고, JVM 사양에 기본적으로 정의된 것도 있고 컴파일러가 자동으로 붙여주는 것도 있다. JVM 이 실행을 하는데 있어서 꼭 필요한 거는 아니지만 메타데이터로서 존재한다.

이러한 메타데이터는

  1. JVM 런타임
  2. 개발 도구(Compiler, Debugger, IDE, java.lang.reflect 등)

에게 제공되게 된다.

위 구성요소들을 분류해보면 다음과 같다.

클래스 수준에서의 Attribute

  • SourceFile: 어떤 .java 소스 파일에서 컴파일되었는지
  • InnerClasses: 내부 클래스 정보들
  • BootstrapMethods: Lambda, invokedynamic

위 예제 코드에서는 안보이지만 역어셈블러 된 파일의 맨 아래 줄에 SourceFile: “Main.java” 라는 줄이 들어가 있고, 이는 SourceFile 한 개(Main.java) 가 컴파일 됐으므로 1이라는 값이 들어가게 되는 것이다.

메서드 수준에서의 Attribute

  • Code: 실제 바이트 코드 명령어
  • LineNumberTable: 바이트 코드, 소스코드 줄 번호 매핑(디버깅 용)
  • LocalVariableTable: 지역 변수 이름과 슬롯 매핑 정보(디버깅 용)

필드 수준에서의 Attribute

  • ConstantValue: 상수 풀에 고정된 값 저장
Constant Pool 해석
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // Main.notStaticInt:I
   #8 = Class              #10            // Main
   #9 = NameAndType        #11:#12        // notStaticInt:I
  #10 = Utf8               Main
  #11 = Utf8               notStaticInt
  #12 = Utf8               I
  #13 = String             #14            // Bye
  #14 = Utf8               Bye
  #15 = Fieldref           #8.#16         // Main.notStaticString:Ljava/lang/String;
  #16 = NameAndType        #17:#18        // notStaticString:Ljava/lang/String;
  #17 = Utf8               notStaticString
  #18 = Utf8               Ljava/lang/String;
  #19 = String             #20            // new
  #20 = Utf8               new
  #21 = Fieldref           #22.#23        // java/lang/System.out:Ljava/io/PrintStream;
  #22 = Class              #24            // java/lang/System
  #23 = NameAndType        #25:#26        // out:Ljava/io/PrintStream;
  #24 = Utf8               java/lang/System
  #25 = Utf8               out
  #26 = Utf8               Ljava/io/PrintStream;
  #27 = Methodref          #28.#29        // java/io/PrintStream.println:(I)V
  #28 = Class              #30            // java/io/PrintStream
  #29 = NameAndType        #31:#32        // println:(I)V
  #30 = Utf8               java/io/PrintStream
  #31 = Utf8               println
  #32 = Utf8               (I)V
  #33 = String             #34            // HELLO
  #34 = Utf8               HELLO
  #35 = Methodref          #28.#36        // java/io/PrintStream.println:(Ljava/lang/String;)V
  #36 = NameAndType        #31:#37        // println:(Ljava/lang/String;)V
  #37 = Utf8               (Ljava/lang/String;)V
  #38 = Utf8               staticFinalInt
  #39 = Utf8               ConstantValue
  #40 = Integer            100
  #41 = Utf8               staticFinalString
  #42 = Utf8               Code
  #43 = Utf8               LineNumberTable
  #44 = Utf8               main
  #45 = Utf8               ([Ljava/lang/String;)V
  #46 = Utf8               SourceFile
  #47 = Utf8               Main.java

해석

  1. #1 = Methodref: 메서드 참조이며 참조 대상은 #2.#3(#2 클래스의 #3 메서드 시그니처)

java/lang/Object.()V Object 클래스의 기본 생성자 호출

  1. #2 = Class: 클래스 참조이며 #4 를 참조하고 있음. #4는 문자열 java/lang/Object 임

“java/lang/Object” 를 가르키는 중

  1. #3 = NameAndType: 이름과 타입 묶음을 말하고 #5:#6 을 참조 중이다.

":()V 를 통해 보면 이름은 , 타입은 ()V 이고 이 타입의 의미는 파라미터가 없고, void 를 반환한다는 뜻이다.

  1. #4 = Utf8 java/lang/Object
  2. #5 = Utf8
  3. #6 = Utf8 ()V

Utf8 문자열 상수들을 말한다.

  1. #7 = Fieldref #8.#9 // Main.notStaticInt:I
  2. #8 = Class #10 // Main
  3. #9 = NameAndType #11:#12 // notStaticInt:I

위 7-9 는 우리가 선언한 notStaticInt 라는 변수명이 int 라는 타입을 가진다는 것을 메타데이터로 알려주고 있음을 볼 수 있다.

나머지는 유사하기에 넘어간다. 이처럼 모든 변수와 메서드 호출과 클래스 명들을 참조 혹은 String 상수를 저장하고 있음을 볼 수 있다.

클래스의 필드 정의 해석
{
  static final int staticFinalInt;
    descriptor: I
    flags: (0x0018) ACC_STATIC, ACC_FINAL
    ConstantValue: int 100

  static final java.lang.String staticFinalString;
    descriptor: Ljava/lang/String;
    flags: (0x0018) ACC_STATIC, ACC_FINAL
    ConstantValue: String HELLO

  int notStaticInt;
    descriptor: I
    flags: (0x0000)

  java.lang.String notStaticString;
    descriptor: Ljava/lang/String;
    flags: (0x0000)

하나만 가지고 와보자.

클래스 변수

  static final int staticFinalInt;
    descriptor: I
    flags: (0x0018) ACC_STATIC, ACC_FINAL
    ConstantValue: int 100

위는 static final int StaticFinalInt 가 선언한 것에 대한 메타데이터 들이 저장되어 있다.

  • descriptor: I 는 int 타입을 뜻하며,
  • ACC_STATIC, ACC_FINAL 은 플래그로 해당 변수는 staticfinal 레벨의 access 가 가능하다.
  • ConstantValue: int 100 컴파일 시점에 상수 풀에 박힌 값이다.

클래스

  static final java.lang.String staticFinalString;
    descriptor: Ljava/lang/String;
    flags: (0x0018) ACC_STATIC, ACC_FINAL
    ConstantValue: String HELLO

primitive 와는 다르게 L이 붙여져있음을 볼 수 있다.

인스턴스 변수

  int notStaticInt;
    descriptor: I
    flags: (0x0000)

생략

생성자 및 메서드 바이트코드 부분 해석
  public Main();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: sipush        999
         8: putfield      #7                  // Field notStaticInt:I
        11: aload_0
        12: ldc           #13                 // String Bye
        14: putfield      #15                 // Field notStaticString:Ljava/lang/String;
        17: return
      LineNumberTable:
        line 2: 0
        line 6: 4
        line 7: 11

여기서

  public Main();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC

의 부분은 필드 정의와 똑같음을 알 수 있다. 즉, 클래스/인스턴스 멤버 변수들은 동작하는 것이 없기에 그리 길지 않다. 하지만 함수에 있어서는 아래와 같이 Code 블럭이 있다.

    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: sipush        999
         8: putfield      #7                  // Field notStaticInt:I
        11: aload_0
        12: ldc           #13                 // String Bye
        14: putfield      #15                 // Field notStaticString:Ljava/lang/String;
        17: return
  • stack=2: 이 메서드에서 사용하는 JVM 스택의 최대 깊이 = 2
  • locals=1: 로컬 변수 슬롯 개수 = 1 (this 만 있음)
  • args_size=1: 인자 개수 = 1 (this)

그 이후는 함수형 언어로 어셈블리어와 유사한 형태로 흘러가게 된다. 이는 JVM 이 실행하는 가상 어셈블리 같은 것이다.

LineNumberTable 해석
      LineNumberTable:
        line 2: 0
        line 6: 4
        line 7: 11
}

자바 바이트 코드는 줄 번호라는 개념이 없기에 소스코드 몇 번째 줄이 필요하다 라고 가정하면, 번호가 필요하게 된다. 이때 컴파일러가 LineNumberTable Attribute 를 넣어서 바이트코드와 원래 자바 소스코드의 줄 번호를 매핑시켜주는 메타데이터이다.

결론

Compile 상태에서의 Constant Pool 을 봤을 것이다. 이 Constant Pool 이 이제 메모리에 올라가게 된다면 Method AreaRuntime Constant Pool 개념으로 바뀌게 된다.

이 말은 클래스 파일 하나하나 즉 인터페이스, 클래스 들 각각이 Constant Pool 을 가지며,
자신의 클래스 내부에 선언되어 있는 소스 코드에 관여하는 모든 클래스, 필드와 심볼릭 링크 등이 모두 저장되어 있다.
여기서는 Constant Pool 만이 Method Area 로 올라감 을 보았다.

Heap

Heap 도 Method Area 와 공통된 특징으로 모든 스레드가 공유하는 영역이며
JVM 이 실행되고 생성되는 공간이며 저장 요소는 Runtime 에 생성된 인스턴스, 객체(object)를 저장한다.

이때 Heap 영역은 객체가 생성되고 삭제되는 공간이며 이는 GC 가 이를 관리한다.

저장 요소

  • static object
  • String
  • String Constant Pool

heap-string-pool

위에서 볼 수 있듯이 String 들은 전부 String Pool 에 저장됨을 볼 수 있다. “Cat”, “Dog” 등등이 저장되고, s1, s2 의 변수는 참조에 대한 정보는 Meta Area 에 있고, 참조가 가르키는 곳이 String 이 있는 곳이 된다.

여기서 Intern String 이라는 개념이 나오는데, String Constant Pool 에 리터럴 String 이 있다면, 그 리터럴 String 을 다시 만들기 보다 String Constant Pool 에서 참조하는 형식으로 메모리 양을 최적화하고 있다. 이렇게 Constant Pool 에 하나만 저장하여서 할당하는 것을 intern 이라고 하고 interned 된 String 을 Interned String 이라고 한다(실제로 String 쪽에 intern 메서드가 있다).

다시 정리하자면,

byte code 에서의 constant pool 이 class loader 에 의해 linking 이 될 때 여기서 heap 영역에 string constant pool 이 생성되고, method area 영역에 runtime constant pool 이 생성되게 된다.
리터럴로 선언한 애들은 전부 string constant pool 에 의해 저장되어서 효율적으로 관리되게 되지만 new String() 으로 생성한 문자열은 heap 으로 따로 저장되는 메커니즘이며, 이는 new String() 자체가 함수이고 생성자이기 때문에 명시적으로 생성자를 지정해주는 것이 새로운 영역에 새로운 값을 할당하는 동적 할당의 개념과 같으므로 string constant pool 로의 정적 할당과는 다르게 string constant pool 영역은 아니지만 heap 영역에 저장되게 되는 것이다.

   /**
     * Returns a canonical representation for the string object.
     * <p>
     * A pool of strings, initially empty, is maintained privately by the
     * class {@code String}.
     * <p>
     * When the intern method is invoked, if the pool already contains a
     * string equal to this {@code String} object as determined by
     * the {@link #equals(Object)} method, then the string from the pool is
     * returned. Otherwise, this {@code String} object is added to the
     * pool and a reference to this {@code String} object is returned.
     * <p>
     * It follows that for any two strings {@code s} and {@code t},
     * {@code s.intern() == t.intern()} is {@code true}
     * if and only if {@code s.equals(t)} is {@code true}.
     * <p>
     * All literal strings and string-valued constant expressions are
     * interned. String literals are defined in section {@jls 3.10.5} of the
     * <cite>The Java Language Specification</cite>.
     *
     * @return  a string that has the same contents as this string, but is
     *          guaranteed to be from a pool of unique strings.
     */
    public native String intern();

JDK21 의 문서를 가져왔다. 이때 native 로 선언됨을 볼 수 있는데 intern 메서드는 Native Stack Area 에 생성됨을 볼 수 있다. 즉 이는 운영체제가 함수를 실행하게 만든다.

이런 특징 때문에 String 은 + 연산이 메모리 적으로 안좋다. Heap 영역에 계속해서 새로운 객체로 저장되기 때문에 안좋으며 GC 가 관리해야 할 대상이 늘어나게 된다.

  • String: 문자열 연산 자체가 적고 멀티스레드일 경우
  • StringBuffer: 문자열 연산이 많고 멀티스레드의 경우
  • StringBuilder: 문자열 연산이 많고 단일스레드이며 동기화를 고려하지 않아도 되는 경우

왠만해서는 StringBuffer 를 쓰자.

Uni Dev 참고 : Intern String 객체든 Static 객체든 메서드 영역에 있나 힙 영역에 있나는 그렇게 중요한 사실이 아니다. 어디에 있든 스레드가 공유하는 자원이고 여러 개 생성되지 않고 하나만 생성되고 여러 스레드가 이를 참조한다는 사실이 중요하다.

Heap 에서의 GC

Heap 에서 GC 가 활발하게 활동하는데 그 범위는 java 는 실행할 때 할당할 수 있는 메모리 영역으로 지정할 수 있으며 이 영역은 Heap Area 를 지정하는 옵션이다.

-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError

-Xms, -Xmx 는 JVM 가용 힙 크기
-XX 는 실험적/고급 옵션을 사용하고 싶을 때 사용(GC 방식, JIT 컴파일러, 내부 동작 등)
메모리가 overflow 됐을 때 JVM이 예외 발생 시점의 힙 메모리 snapshot 을 파일러 저장(dump)해주는 옵션

-XX:+UseG1GC      # G1 GC 사용
-XX:-UseG1GC      # G1 GC 사용 안함
-XX:MaxMetaspaceSize=256m   # Metaspace 최대 크기
-XX:NewRatio=2              # New/Old 비율
-XX:SurvivorRatio=8         # Survivor/Eden 비율

반드시 쓰이지 않는 것은 null 로 바꾸어 해제시켜주자.

Heap Area 의 Generational Collection Theory

아직 이를 공부하기에는 굳이 인 느낌이 있지만, 적어놓고 나중에 다시 보려고 한다. 옛날 이론이라 지금이랑 또 다를 수 있다.

통계학적으로 다음이 밝혀졌다:

  • 대부분 객체는 얼마 지나지 않아 사용하지 않는다
  • 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다.

메모리 회수 관점에서 GC들이 이걸 참조한다고 한다.

이 이론에 따르면 heap 을 다음과 같이 메모리 영역을 구분한다:

  • 신세대(new generation)
    • 에덴 영역(Eden): 가장 처음 객체가 메모리에 할당되는 공간, GC 가 1회 수행 후에는 Survivor 영역 중 하나로 이동
    • 생존자 영역: from, to 두 부분으로 나뉘며, 둘 중 하나는 반드시 비어있다. 여러 번 생존에 성공 시 구세대로 승격한다.
  • 구세대(old generation)
    • 신세대에서 오래 살아남은 객체들의 정보가 복사되어 있는 공간임
      GC는 적게 발생하고, 신세대에 비해 큰 메모리를 할당받게 된다.
    • Card Table: 구세대에서 신세대 영역으로의 참조 테이블을 말하며, 512 bytes chunk 로 구성된다고 한다(옛날 정보)
  • 영구세대(permanent generation)
    • 고정 메모리 크기 공간이며 -XX 옵션으로 지정한다.
    • 위 세대 영역들과 아무런 관련이 없으며, GC 가 발생 여부도 독립적이다.

PermGen 이 이 영역이며, 이 이론을 토대로 설계를 했지만 지금은 삭제하고 없음을 볼 수 있다.

Thread

우리가 운영체제에서 생각하는 그 스레드 맞다.

Stack

각 스레드 마다 독립적으로 존재하는 메모리 영역이며, 각 스레드 마다 하나씩 존재한다.

  • 메서드 호출 시마다 Stack Frame(가상 메모리 공간에서의 프로세스 하나 당 기본 단위와 유사한 개념) 이 쌓임
  • {Local Variable Array, Operand Stack, 현재 메서드의 Constant Pool 참조 등등}으로 구성
    • Local Variable Array: 0부터 시작하는 인덱스를 가지는 배열
      0: this, 1부터는 전달된 파라미터들, 그 이후 메서드 지역 변수들 저장
    • Operand Stack: 메서드 하나하나 수행되는 공간
    • Reference to Constant Pool: 필요한 데이터 및 결과를 저장함
  • 메서드 종료 시 Stack Frame 임이 제거됨
  • 참조를 할 때 동적 linking 을 함
PC Register

각 스레드 마다 하나씩 존재(여기서 주의할 점은 실제 하드웨어 PC를 말하는게 아니다… 그냥 SW의 추상화된 형태로 PC를 제공한다 Java 가 독립적인 이유에 한 몫 하는 변수이다)

  • 현재 실행 중인 JVM 명령어의 주소를 저장
  • 스레드 전환 시, 다시 돌아왔을 때 실행 위치를 잃지 않도록 함
Native Method Stack

Java 가 아닌 네이티브(C, C+) 코드 등의 JNI(Java Native Interface) 호출 을 실행할 때 사용하는 스택

  • 일반 자바 스택과 유사하지만, 네이티브 라이브러리를 위한 공간임
  • ex. System.arraycopy(), Object.hashCode(), Object.clone() 등등 네이티브로 구현됨
  • 왜 있냐? 모든 기능을 자바로 구현하기는 힘듦. OS/HW의 밀접하게 의존해야 할 때는 더 low-level 의 언어가 필요함. 이때 C, C++을 사용하지만, 언어 자체가 달라서 이를 호환시키도록 하는게 JNI

JNI 를 쓸 때는 그래서 그냥 Stack 이 아닌 Native Method Stack 이 사용된다. 이는 C 함수의 호출 처럼 동작하게 됨. 네이티브 코드 실행은 GC 의 관리영역 밖이기 때문에 사용에 주의해야 하며, 보안 검사 등도 유의해야 한다… 꼭 필요한 경우만 사용하자.

┌──────────────────────────┐
│        Method Area        │ ← 클래스 로딩 정보, Runtime Constant Pool
│        (Metaspace)        │
├──────────────────────────┤
│           Heap            │ ← 객체 저장, GC 대상, String Constant Pool, static objects
├──────────────────────────┤
│      PC Register (T)      │ ← 각 스레드별 현재 실행 주소
│        Java Stack (T)     │ ← 각 스레드별 메서드 실행
│  Native Method Stack (T)  │ ← 각 스레드별 네이티브 코드 실행
└──────────────────────────┘

jvm-memory

메모리 공간의 구조 파악은 끝났고 클래스가 어떻게 로딩되는지 보자.

ClassLoader

이제 JVM 의 메모리 개념을 이해했다면 ClassLoader 를 이해할 수 있게 되는데, 이름 그대로 자바에서 .class 바이트 코드를 JVM 메모리에 적재하는 역할을 한다.

실행 시점에서 동적으로 클래스 로딩을 하기 때문에 자바는 한 번 컴파일을 하면 어디서든 실행된다는 특징을 가질 수 있다.

Loading

클래스 로딩은 클래스를 로드하라는 요청이 왔을 때 Loading 과정이 실행된다. 주된 작업은 다음과 같다.

  • .class 파일들 JVM 에 적재
  • ClassLoader 가 파일 읽음

클래스 로더 주로 3가지가 있고, 각각의 수행 처리 순서는 Bootstrap ClassLoader -> Platform ClassLoader -> Application ClassLoader 순으로 진행되며

  1. System
  2. Platform
  3. BootStrap

순으로 클래스의 메모리 적재를 요청하게 되고(getClassLoader()),

  1. BootStrap
  2. Platform
  3. System

순으로 class 를 로딩해주게 된다. JVM 이 ByteCode 를 토대로 클래스, 인터페이스를 찾고 이를 생성하는 과정을 진행한다.

위 순서로 진행되는 이유는 충돌을 방지하기 위해서이다.

Bootstrap ClassLoader

JVM 자체에 내장되어 있고, JAVA_HOME/lib 안의 핵심 클래스(java.lang.*, java.util.*) 로딩

제일 먼저 동작하고, 클래스로더 중에 유일하게 Native C 로 구현되어 있다.

Platform ClassLoader

Bootstrap 이 찾지 못한 클래스들을 로딩하며, JDK 확장 라이브러리(lib/ext 또는 모듈)을 로드한다. 예를들면 javax.* 가 있겠다(사실 잘 모른다 그런 추측이다. javaxjava 에서 extension 이 붙어서 다양한 기능으로 서드 파티가 구현한게 자주 쓰여서 실제로 공식 java 라이브러리에도 등록되어서 쓰고 있는 것으로 안다).

Java 8까지는 Extension ClassLoader 였다.

System ClassLoader

Platform ClassLoader 가 못찾은 클래스만 로딩한다.

우리가 설계한 어플리케이션 .class 파일이 최종적으로 적재되는 것(우리는 구현되어 있는걸 가져다 쓰기 때문에 가장 최후에 선언됨).

Java 8 까지는 Application ClassLoader 였다.

User-defined ClassLoader

사용자가 직접 정의해 사용하는 클래스 로더로, java.lang.ClassLoader 로 정의할 수 있다.

이는 Spring 에서 이를 사용하는 메서드들을 자주 볼 수 있다.

클래스 로더의 원칙

클래스 로더는 다음을 따르도록 설계되어야 한다.

위임-우선 모델

  • 상위 클래스에서 찾고 싶은 클래스를 찾고
  • 없으면 하위 클래스에서 다시 찾는 다이어그램

관련 Exception 은 ClassNotFoundException 을 내뱉는다. 이는 Checked Exception 이다.

가시성 원칙

  • 하위 클래스 로더가 로딩한 클래스는 상위 클래스 로더가 볼 수 없고
  • 상위 클래스 로더가 로딩한 클래스는 하위 클래스 로더가 볼 수 있고

유일성 속성

클래스는 오로지 “한 번” 만 로드한다.

유일성 식별 기준은 위의 utf8 에서 java.lang.String 같은 클래스 명을 일컫는다.

Unload 불가

  • 클래스로더는 클래스를 로딩은 가능
  • 클래스로더는 클래스를 언로딩은 불가

이는 클래스 로더가 로드한 클래스는 보통 GC 의 수거대상이 되지 않기 때문이며, 대신 User-defined ClassLoader 는 클래스를 동적으로 로드하고 언로드 할 수 있다.

Linking

로딩 다음 거치는 링킹은 3단계에 걸쳐 진행된다.

  1. Verify: 바이트코드가 JVM 규칙 위반하는지 안하는지
    가장 시간이 많이 걸리고 복잡하다.

  2. Prepare: 클래스가 필요로 하는 메모리를 할당한다.
    이때 static variable은 defualt 값으로 설정
    (static variable 을 true 로 설정해도 default 로 설정됨)

  3. Resolve: 심볼릭 참조실제 참조로 변환

Initialize

  • static 변수에 값 할당
  • static 블록 실행
  • 최초 클래스가 사용될 때 한 번만 실행됨

이 과정을 보면 알겠지만, static 이 여기서 올라감을 볼 수 있다.


✒️ 용어

Hotspot JVM

JVM은 추상화된 개념이지 실제 있는건 아니다. 이 구현체가 바로 Hotspot JVM 이며, JVM 구현체 중 하나이다. 위 그림도 이 Hotspot JVM에서 나온 것이다.