본문 바로가기
Language & Tool/Java

☕JVM 메모리 동작과 객체지향의 이해

by 박수무당벌레 2026. 6. 14.

🔗 JVM에 대한 보충 설명(2번 문단)

🔗 C++의 OOP(메서드 구조)

 

1. C++ vs Java 객체지향 방식 비교

C++과 Java는 객체를 메모리에 할당하고 참조하는 방식에서 큰 차이가 있음

[C++ 방식]
Cat myCat;                  --> 스택(Stack) 영역에 직접 객체 생성 (자바는 불가능)
Cat* myCatPtr = new Cat();  --> new 연산자가 힙(Heap)의 시작 주소를 반환 (포인터 사용)

[Java 방식]
Cat myCat = new Cat();      --> 항상 JVM이 관리하는 힙(Heap) 영역에만 생성됨 (포인터 기호 -> 없음)

1) 주요 차이점 점검

  • 포인터 (C/C++): '실제 메모리 주소' 그 자체. myCatPtr + 1처럼 주소값을 직접 계산(포인터 연산)할 수 있고, 유효하지 않은 메모리 주소에 접근할 수 있는 취약성(댕글링 포인터)이 존재
  • 참조 변수 (Java): JVM에 의해 철저하게 가려져 개발자가 실제 물리적인 메모리 주소(예: 0x7FFF...)를 직접 볼 수 없고, 주소값을 가지고 더하기 연산을 하는 등 메모리 직접 조작이 불가능.
  • 메모리 위치: C++은 스택과 힙 모두 객체를 만들 수 있지만, Java는 무조건 JVM이 정의한 힙(Heap Area) 영역에만 객체를 생성
  • new 연산자의 역할: 힙 메모리 공간에 생성자를 이용해 객체를 만들고, 그 객체가 위치한 메모리의 시작 주소(참조값)를 반환
  • 객체의 구성: 힙에 생성된 객체 내부에는 멤버 변수(필드)만 실제 데이터로 존재하며, 멤버 함수(메서드)는 클래스 레벨에서 공유된다.

2) 연산자 차이

  • :: : C++의 네임스페이스 및 범위 지정 연산자
  • -> : C++의 포인터 멤버 접근 연산자 (Java에서는 지원하지 않음)
  • . : Java와 C++ 공통 멤버 액세스 연산자
💡 포인터 연산 비허용: 자바의 참조 변수는 접근은 JVM이라는 가상 머신 레이어를 한 번 거침. 오직 해당 객체의 기능을 실행(. 연산자)하는 용도로만 제어 방식이 추상화됨. 시스템의 타입 안정성이 엄격하게 유지

2. 클래스의 인스턴스화와 JVM 메모리 동작 흐름 5단계

1) 객체의 생성 vs 인스턴스화 (Instantiation)

  • 객체 (Object): 현실 세계의 사물처럼 상태(데이터)와 행위(메서드)를 가진 모든 것을 뜻하는 가장 포괄적인 개념 (설계도인 클래스로부터 만들어질 '대상' 전체)
  • 인스턴스화 (Instantiation): 추상적인 클래스를 바탕으로, 컴퓨터 메모리상에 실제 데이터가 살아 숨 쉬는 실체(Instance)를 만들어내는 '과정'을 의미
  • 객체를 스택과 힙 모두 생성 가능한 C/C++와 달리, Java는 new 연산자를 사용해 만든 모든 객체는 무조건 힙 영역에 생성됨. 스택 영역에는 오직 그 객체를 가리키는 *참조값(변수)만 존재

💡 한 줄 요약: 클래스라는 설계도로 컴퓨터 메모리에 인스턴스화라는 과정을 거쳐 만들어진 실체가 바로 객체.

더보기

✨ 자바 참조 변수의 메모리 추상화

 

1) 자바 참조 변수의 정의와 본질

  • 개념: 자바의 참조 변수는 힙(Heap) 영역에 생성된 실제 객체의 물리적 메모리 주소를 직접 가지지 않음. 대신, 이를 내부적으로 감추고(은닉) JVM이 관리하기 편하도록 추상화한 '논리적 주소 데이터'를 담는 변수
  • 공식적 정의: 기술적으로는 객체 핸들(Object Handle) 또는 심볼릭 참조(Symbolic Reference)에 가깝다고 표현
ℹ️ C/C++의 포인터는 메모리의 '실제 물리 주소'를 개발자가 직접 접근하는 것은 강력하지만 동시에 너무나 위험하다. 자바는 포인터의 핵심 기능인 '무언가를 가리키고 가리키는 대상을 변경하는 기능'은 제공하지만 런타임 환경의 메모리 안전성과 고도의 보안성을 공식적으로 확보하고자 한다.

 

2) 핵심 매커니즘: 메모리 접근의 추상화

자바는 개발자가 물리 메모리에 직접 접근하는 것을 차단하고, JVM이라는 중간 계층을 통해 객체에 접근하도록 설계

[참조 변수] ──(논리적 주소/핸들)──> [JVM 관리 계층] ──(물리적 주소)──> [힙 영역의 실제 객체]
  1. 물리 주소 은닉: 하드웨어의 복잡하고 위험한 실제 물리 주소(Physical Address)를 외부에 노출하지 않움
  2. 논리 주소 제공: JVM이 힙 영역의 객체를 식별하고 관리하기 위해 부여한 독자적인 독립적 논리 주소만 참조 변수에 할당

3) JVM을 통한 논리적 접근 구조를 사용하는 이유 (기대 효과)

  1. 메모리 안전성 보장 (Memory Safety) 및 보안
    • C/C++처럼 포인터를 이용해 OS 및 하드웨어의 실제 RAM 주소에 직접 접근하거나 조작(포인터 연산 등)하는 행동을 원천 봉쇄
    • 잘못된 메모리 참조로 인한 시스템 크래시나 보안 취약점(버퍼 오버플로우 등)을 예방
  2. 가비지 컬렉션(GC) 및 메모리 관리의 효율성
    • 힙 영역에서 객체의 위치가 바뀌더라도(GC가 메모리를 정리하며 객체를 이동시킬 때), JVM 내부의 핸들 맵만 업데이트하면 됨(자동 매핑).
    • 개발자가 가진 참조 변수의 논리적 주소는 바뀔 필요가 없으므로 시스템이 매우 안정적으로 유지된다.
더보기

ℹ️ 자바 참조 변수의 3대 안전성 메커니즘 (위 내용 보충)

 

자바는 JVM 레이어를 통해 메모리를 관리함으로써, 기존 C/C++ 방식의 포인터가 가진 위험성을 완벽히 통제한다.

  1. 직접 메모리 접근 불가 (Direct Memory Access Restricted) ── OS 및 하드웨어의 실제 RAM 주소에 직접 접근하는 것을 원천 차단
    • Memory Safety 보장: 개발자의 실수나 악의적인 공격으로 인해 운영체제(OS)의 중요한 메모리 영역을 침범하거나 오염시키는 것을 막아줌
  2. 포인터 연산의 비허용 (No Pointer Arithmetic) ── 참조 변수가 가진 주소값에 숫자를 더하거나 빼서(myCatPtr + 1 등) 다른 메모리 주소로 임의 이동하는 연산을 허용하지 않음.
    • Type Safety 유지: 컴파일과 런타임 전 과정에서 데이터 타입을 엄격하게 검증/유지. 포인터 연산 오류로 인해 엉뚱한 데이터 타입을 가리키거나 비트가 깨지는 현상을 방지한다.
  3. 불투명한 참조 (Opaque Reference) ── 객체의 실제 물리적 비트 구조나 메모리 주소 메커니즘을 개발자에게 철저히 숨김.
    • Location Transparency 확보: 가비지 컬렉터(GC)가 메모리 효율화를 위해 힙 영역의 객체 위치를 동적으로 이동(Compaction)시키더라도, JVM이 내부 참조 테이블을 알아서 업데이트한다.
    • 개발자는 객체의 실제 물리적 위치가 바뀌었는지 전혀 신경 쓰지 않고, 동일한 참조 변수로 항상 안전하게 객체에 접근할 수 있음

2) 객체 생성 메커니즘

아래는 자바에서 소스코드(.java)가 컴파일되어 바이트코드(.class)가 된 이후, MyProgram의 메인 메서드에서 User user = new User(...); 코드가 실행되는 순간 일어나는 일을 단계별로 정리한다.

  1. **클래스 로딩: JVM은 new User()라는 코드를 만나면, 먼저 이 User 클래스에 대한 정보가 메모리에 있는지 확인
    • 만약 없다면 클래스 로더(Class Loader)가 User.class 파일을 찾아 읽어 들인 후, 클래스의 메타데이터(클래스 이름, 메서드 코드, 변수 정보, static 변수 등)를 메서드 영역(Method Area)에 저장
    • 한 번 로딩된 클래스는 다시 로딩되지 않고 재사용된다.
  2. 힙 메모리 할당: 클래스 정보가 확인되면, JVM은 실제 객체(인스턴스)를 생성하기 위해 메모리 공간을 확보
    • 힙 영역(Heap Area)에 User 인스턴스의 실제 데이터(인스턴스 변수들)가 저장될 공간을 동적으로 할당
  3. 기본값 초기화: 힙 영역에 공간이 할당되면, 생성자가 실행되기 전에 자바가 정한 기본값으로 인스턴스 변수들이 초기화됨
    • 예를 들어 int 타입 변수는 0, boolean은 false, 참조 타입(String 등)은 null로 세팅됨
  4. 생성자 실행 및 명시적 초기화: 개발자가 소스코드에 작성한 명시적 초기화 코드(int age = 20;)와 User() 생성자 코드가 실행됨
    • 3단계에서 기본값으로 세팅되었던 힙 영역의 인스턴스 변수들에 실제 데이터가 덮어씌워짐
  5. 참조 변수에 주소값 할당: 힙 영역에 객체 생성이 완료되면, 그 객체가 위치한 메모리 주소값(Reference)이 반환됨
    • 메모리 변화: 현재 실행 중인 메서드의 스택 영역(Stack Area)에 있는 지역 변수 user에 그 주소값이 저장됨. 이제 변수 user는 힙 영역의 실제 객체를 가리키게(참조하게) 된다.
public class MyProgram {
    public static void main(String[] args) {
        System.out.println("MyProgram 시작!");

        // 1. 부트스트랩 클래스 로더가 로드한 String 클래스 사용
        String welcomeMessage = "안녕하세요, 자바 클래스 로더 테스트입니다.";
        System.out.println(welcomeMessage);

        System.out.println("----------------------------------------------");

        // 2. 애플리케이션 클래스 로더가 동적으로 User 클래스를 찾아 로드하는 시점!
        User user1 = new User("홍길동", 25);
        User user2 = new User("이순신", 30);

        // 메서드 호출
        user1.printInfo();
        System.out.println("[회원 정보] 이름: " + user2.getName());
        
        System.out.println("----------------------------------------------");
        System.out.println("모든 클래스가 메모리에 정상 로드되어 실행을 마침.");
    }
}

class User {
    // 힙(Heap) 영역에 객체마다 각각 독립적으로 생성될 인스턴스 변수
    private String name;
    private int age;
    
    // 생성자 (4단계: 명시적 초기화 및 생성자 실행)
    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return this.name; // 이 'this'는 대체 어떻게 작동하는 걸까?
    }
    
    // 정보 출력 메서드
    public void printInfo() {
        System.out.println("[회원 정보] 이름: " + name + ", 나이: " + age);
    }
    
    /* * 💡 JVM이 내부적으로 인스턴스 메서드(this)를 처리하는 방식 (개념적 이해를 위한 가상 코드)
     * * public static String getName(User currentObject) {
     * return currentObject.name;
     * }
     */
}
💡 한 줄 요약: User는 실제 객체 가리키는 참조 변수(힙 영역에 있는 객체의 메모리 주소값)로, 변수 자체는 힙 영역이 아니라 스택 영역에 생성됨. 대신, 뒤에 있는 new User()로 생성된 실제 객체(데이터 덩어리)가 힙 영역에 생성된다. 
ℹ️ 힙 메모리는 물리적인 공간을 새로 떼어 내어 주고(Allocate), 참조 변수는 이미 있는 변수에 주소라는 값을 지정/대입(Assign)
더보기

 JVM 클래스 로딩 및 JAR 파일 ([3-3. JVM 실행] 부가 설명)

사용자가 'java -jar 파일명.jar' 같은 명령어를 입력해 JVM을 켜는 순간 JVM이 켜지자마자 안에 있는 클래스 로더(Class Loader)가 작동함

JDK나 IED에서 제공하는 도구로  하나의 파일로 묶어서 배포.

 

1) 기준점 찾기 (Classpath)

  • JVM은 먼저 클래스 패스에 설정된 경로(디렉토리 또는 JAR 파일)를 확인
  • JVM은 무작정 컴퓨터 전체를 탐색하는 게 아닌, 사용자가 지정해 준 환경 변수(CLASSPATH)나 실행 명령어의 -classpath(-cp) 옵션에 적힌 경로를 최초의 기준점으로 삼음

2) 패키지명에 맞게 디렉터리 타고 들어가기

  • 자바에서 패키지명은 디렉터리 경로와 1:1로 매칭됨
  • 패키지명이 com.example.myproject 라면, JVM은 클래스 패스 기준점 밑에서 com/example/myproject/ 라는 디렉터리 구조를 차례대로 따라 내려감
  • JAR 파일이 있을 때 JVM의 동작: JVM은 클래스 패스에 JAR 파일 경로가 지정되어 있으면, 굳이 컴퓨터의 실제 디렉터리를 뒤지지 않고 JAR 파일 내부(압축 내부)를 하나의 디렉터리처럼 생각하고 똑같이 패키지명에 따른 디렉터리를 찾아 들어감

3) class 파일 로드

최종 목적지 디렉터리에 도착하면, 그 안에 있는 실제 실행 파일인 .class 파일을 찾아서 JVM 메모리에 올림.

💡JAR (Java Archive)
여러 개의 클래스(.class) 파일들과 그에 필요한 리소스(이미지, 설정 파일 등)를 하나의 zip 파일 형태로 압축해 놓은 파일.
디렉토리와 .class 파일들을 관리하기가 편하도록 자바에서 이 디렉터리 구조 전체를 하나의 파일로 묶어 배포하고 실행할 수 있도록 만든 아카이브 파일 포맷
더보기

 클래스 로딩(로드-링크-초기화)에서 실행하기까지의 과정 ([3-3. JVM 실행] 부가 설명)

 

[클래스 로드] ➡️ 1. 검증(Verification) ➡️ 2. 준비(Preparation) ➡️ 3. 분석(Resolution) ➡️ [초기화]
 (Loading)                      └─────── 링크(Linking) 단계 ────────┘               (Initialization)
💡 링크 단계: JVM이 .class 파일을 메모리에 올린(Load) 직후, 메모리를 할당(준비)하고 실제 물리적인 주소 값을 연결(분석)하는 JVM 내부 과정을 의미 (실제 쓸 수 있도록 조립하고 연결하는 작업)
ℹ️ C/C++ 같은 컴파일 언어는 빌드 타임(컴파일 직후)에 링커가 여러 목적 파일을 하나로 묶고 메모리 주소를 확정하는 링크 과정을 거치지만, 자바는 컴파일 시점이 아니라, 프로그램이 실행되는 도중에 JVM 내부에서 동적으로 링크(Dynamic Linking)가 일어남

 

1) 로딩 (Loading)

  • 역할: 3대 클래스 로더가 위임 모델을 통해 .class 파일을 JVM 메모리에 올리는 단계. [3-3. JVM 실행] ① ~ ③ 과정
  • 부트스트랩 클래스 로더, 플랫폼 클래스 로더, 시스템(애플리케이션) 클래스 로더가 위임 모델(위로 올라갔다가, 다시 내려옴)에 따라 특정 클래스를 필요로 할때 먼저 부모 로더로 올라가면서 찾음
  • 프로그램 시작 이후 전체 흐름:
    1. 운영체제(OS)의 JVM 구동: 명령을 받은 OS가 자바 실행 파일을 실행. OS가 JVM를 메모리에 올리고 환경 변수(이때 -classpath나 모듈 경로 등)를 확인. 
    2. 최상위 '부트스트랩 클래스 로더' 가동 (자바 프로그램 실행시 최소한의 '뼈대' 환경을 구축): JVM 구동 직후 가장 먼저 실행되는 초기화 시점. 자바 코드가 아닌, 컴퓨터가 바로 이해할 수 있는 C/C++ 원시 코드로 짜인 부트스트랩 클래스 로더(C/C++ 코드로 이루어짐)가 스스로 메모리에 생성됨. 아래 필수 바이트코드를 메모리(Method Area)에 복사
      • 최상위 조상 클래스: 모든 자바 객체의 뿌리가 되는 java.lang.Object
      • 기본 데이터 및 메타 클래스: 문자열 처리를 위한 java.lang.String, 클래스 정보 자체를 다루는 java.lang.Class
      • 클래스 로더 시스템: 이후 자바 기반 로더들이 작동할 수 있게 해주는 java.lang.ClassLoader 자체의 바이너리 파일
      • 자바 표준 패키지: java.lang.*, java.util.* 등 프로그램 언어의 기초가 되는 자바 베이스(java.base) 라이브러리 전체
    3. 플랫폼 로더 생성 ─> 애플리케이션 로더 생성 (계층 구조 셋업 완료): 핵심 클래스들이 로드되어 다음 계층의 로더들을 만듦. 아래의 순서대로 3대 계층 구조(부트스트랩 ← 플랫폼 ← 애플리케이션)인 자바 환경 구성
      • 플랫폼 클래스 로더가 생성되어 부트스트랩을 부모로 지정 (+ java.sql, java.xml, java.compiler 등 java.base를 제외한 자바 표준 플랫폼의 나머지 모듈들을 로드)
      •  이어서 애플리케이션 클래스 로더가 생성되어 플랫폼을 부모로 지정 (+ 개발자가 직접 작성한 소스 코드(.class)나 모듈패스/클래스패스에 추가하는 외부 라이브러리(JAR)들을 로드)
    4. 메인 클래스 찾아오라고 위임 (올라감): 런타임 환경이 갖춰졌으니, JVM은 사용자가 실행하라고 명령한 메인 클래스(User)를 가져오라고 제일 말단인 애플리케이션 클래스 로더에게 처음으로 요청.
      • 애플리케이션 로더는 위임 원칙에 따라 자신이 곧바로 찾지 않고 부모들에게 먼저 물어본 후, 애플리케이션 ──> 플랫폼 ──> 부트스트랩까지 요청이 올라감 (위임 모델 작동)
      • 어떤 클래스 로드 요청이든 최상위 부모인 부트스트랩 클래스 로더까지 먼저 도달
    5. 애플리케이션 로더가 직접 경로 확인/탐색/복사(내려옴): 상위 로더들(플랫폼, 부트스트랩)은 "내가 가진 순정 라이브러리에는 User가 없다"라고 답하며 다시 아래로 요청을 내림(부모가 못찾으면 자식 로더로 내려와 클래스를 찾음). 결국 바통을 다시 받은 애플리케이션 클래스 로더가 아래의 본인 역할을 시작. 
      •  기준 루트 확인: 개발자가 지정한 -classpath(보통 작성한 소스코드나 외부 Maven/Gradle 라이브러리들) 경로를 확인(클래스나 외부 라이브러리(.jar) 로드를 위한 준비 과정).
      •  클래스 로더 탐색: 하드디스크나 JAR 폴더 내부 구조를 타고 들어가 대상 파일(User.class)을 찾아냄
      • ③ 로딩: 찾아낸 파일의 바이트코드를 읽어와 분석 ─ 클래스의 메서드, 변수, 상속 구조 등의 정보를 JVM 메모리(Method Area)에 단순히 복사해 올림.(발견 시 즉시 종료)
      • 마지막에도 파일 찾지 못하면 java.lang.ClassNotFoundException 에러 발생
    6. 프로그램 시작 (main 메서드 호출): 메인 클래스가 성공적으로 메모리에 올라옴. JVM이 메모리에 로드된 User 클래스 내부에서 public static void main(String[] args) 메서드를 찾아 호출
      • 개발자가 작성한 코드가 한 줄씩 실행되기 시작 (이후 코드 실행 중 새로운 클래스를 만나면 4번~5번 탐색 과정이 동적으로 반복.)
      • JVM 내부에서는 해당 클래스 타입의 Class 객체를 생성하여 힙(Heap) 영역에 등록해 둠. (예: User.class를 로드하면 Class<User> 객체가 생성됨)
ℹ️ 부트스트랩 클래스 로더는 C/C++로 구현되어 Java 코드 내에서 부트스트랩 클래스 로더를 조회하면 null이 반환됨.
ℹ️ 플랫폼 클래스 로더(Platform Class Loader) 추가 설명
Java 9부터 도입된 클래스 로더로, 표준 Java SE 플랫폼의 확장 기능이나 가끔씩 사용되는 플랫폼 클래스들을 로드
운영체제/버전 참고: 과거 Java 8 이전에는 확장 클래스 로더(Extension Class Loader)라고 불리며 jre/lib/ext 디렉토리(혹은 시스템 프로퍼티 java.ext.dirs에 지정된 파일)의 클래스를 로드했으나, Java 9의 모듈 시스템(Project Jigsaw) 도입 이후 플랫폼 클래스 로더로 명칭과 역할이 재정립되었습니다.

 

2) 검증 (Verification)

  • 로드된 바이트코드(.class)가 자바 언어 명세 및 JVM 제약 조건(JVM Specification)을 잘 지켰는지, 보안상 안전한지 확인(검사)
  • 웹에서 다운로드한 파일이나 악의적으로 변조된 파일이 JVM을 망가뜨리지 않도록 방어하는 가장 복잡하고 시간이 오래 걸리는 단계. 컴파일러가 생성한 정상적인 파일인지 검사하는 것

3) 준비 (Preparation)

  • 클래스가 필요로 하는 메모리를 할당하고, 특히 클래스 변수(static 변수)들을 기본값(0, null 등)으로 초기화
⚠️주의할 점: 이 단계에서는 개발자가 코드에 적은 값(예: public static int a = 10;)이 들어가는 것이 아니라, 타입의 기본값(0, false, null 등)으로만 초기화됨 (변수 a에는 일단 0이 들어감.)

 

4) 분석 / 해석 (Resolution)

  • 하드디스크가 아닌, JVM이 실행 중인 컴퓨터의 RAM(메모리) 내부에서 일어남
    1. 메서드 영역에 올라온 .class 파일의 코드에는 프로그램이 실행 중 다른 클래스를 호출할 때 실제 메모리 주소가 아니라 문자열 형태(심볼릭 레퍼런스)로 저장되어 있음
      • 예: "String 클래스의 주소"가 아니라 그냥 "java/lang/String"
    2. 프로그램이 실행되어 특정 메서드가 호출되면, 스택 영역에 해당 메서드만을 위한 공간인 스택 프레임이 생성
      • 스택 프레임 내부에는 Dynamic Linking을 위한 참조 포인터가 포함되어 있음.
      • 메서드가 호출될 때마다 이 포인터를 통해 메서드 영역의 '런타임 상수 풀'을 바라보며 실제 메모리 주소를 동적으로 찾아감.
    3. 스택 프레임을 통해 찾아간 위치에서, 글자로만 되어 있던 주소를 힙 영역이나 메서드 영역의 실제 물리적인 메모리 주소(다이렉트 레퍼런스)로 런타임에 변경
  • 이 과정이 완료되어야 코드가 실제 메모리를 찾아갈 수 있음
💡심볼릭 레퍼런스: 자바 소스 코드나 바이트코드에서는 다른 클래스나 메서드를 참조할 때, 실제 주소가 아닌 단순히 이름으로만 존재하는 참조.
※ 심볼릭 레퍼런스의 대상: 클래스 내부에서 사용하는 다른 클래스 이름, 메서드 이름, 변수 이름들
💡동적 링크: 풀(Pool)에 있는 문자열 주소를 실제 주소로 바꾸는 작업
ℹ️ 동적 링크는 메서드 호출 시점(Lazy Resolution)에서도 일어난다. 코드가 실행되다가 실제로 해당 메서드나 변수를 사용하는 순간(런타임)에 상수 풀을 뒤져서 주소를 찾아 연결 (자바는 효율성을 위해 대개 이 방식을 많이 사용)

 

5) 초기화 (Initialization)

  • Main 메서드를 실행하려면, 그 메서드를 품고 있는 MyProgram 클래스가 먼저 완벽하게 깨어나야 함
    • 따라서 JVM은 Main 메서드를 호출하기 바로 직전 MyProgram 클래스의 초기화 단계를 실행 (초기화 이전까지는 Main 메서드가 있는 클래스 준비하는 과정)
    • 이때 비로소 static int a = 10; 처럼 개발자가 지정한 진짜 값이 변수에 대입되고 static 블록(static {})이 실행되면서 클래스를 사용할 모든 준비가 끝남
  • 모든 클래스가 Main 메서드 전에 초기화되는 것은 아님. 자바는 메모리를 아끼기 위해 '그 클래스가 실제로 처음 사용되는 순간'에 클래스 로딩 과정(로드-링크-초기화) 진행

6) 진입점(Main 메서드) 실행

  • 클래스의 초기화 단계가 끝나면, 클래스 사용 가능(내부 코드 실행 혹은 객체 만들기). JVM은 마침내 public static void main(String[] args) 메서드를 찾아서 실행(런타임)

📊 JVM 메모리 영역별 역할 비교

프로세스가 끝난 후, 각 메모리 영역에는 다음과 같은 데이터가 남음

  • 클래스의 구조 정보(메타 데이터): 필드 이름, 데이터 타입, 메서드 바이트코드(실제 실행해야 할 명령문))
  • 메서드 영역이 스레드간 공유하는 이유: 중복 방지 & 클래스 구조나 컴파일된 바이트코드 같은 불변 데이터를 공유
    • 메서드 영역은 프로그램의 뼈대(클래스)를 담는 곳
    • 만약 스레드가 100개이고 각 스레드가 자신만의 메서드 영역을 갖는다면, 똑같은 User 클래스 정보와 메서드 코드를 메모리에 100번이나 똑같이 올려야 함
  • 힙 영역이 스레드간 공유하는 이유: 객체의 참조 주소로 서로 데이터 공유와 협업(통신) & 객체 생성/소멸 관리의 단일화 
    • 힙 영역은 결과물(객체)를 담는 곳
    • 만약 스레드마다 독립된 힙을 가진다면, 다른 스레드로 객체를 넘길 때마다 객체 전체를 복사해야 함(오버헤드 발생).
  • 스택 영역이 스레드간 독립적인 이유: 계산 중인 임시 숫자를 옆 스레드가 훔쳐 가거나 바꾸면 안 됨
  저장되는 데이터 내용 특징
메서드 영역 (Method Area) - User 클래스의 구조, 메서드 바이트코드(실제 실행해야 할 명령문), static 변수(클래스 변수)
- 런타임 상수 풀
- 모든 스레드가 공유
- 프로그램 시작 시 딱 *한 번만 메모리에 생성(모든 객체가 공유)
✨ 클래스 로더가 .class 파일을 읽고 처음 적재하는 곳
힙 영역 (Heap Area) - new User()로 생성된 *실제 객체(데이터)
- 문자열 상수 풀 (Java 7 이후)
✨ 객체(인스턴스)와 그 안의 필드(인스턴스 변수)들
- 모든 스레드가 공유
- 가비지 컬렉터의 관리 대상
- 객체 생성 시 매번 새로운 메모리 공간이 할당(독립된 공간)
스택 영역 (Stack Area) - 참조 변수 user (힙 영역의 주소값을 가짐),
- 스택 프레임(메서드 호출 시 생성), 그 내부의 지역 변수, 매개 변수, 기본 타입 데이터, 참조 변수
- 스레드별로 독립 존재(공유 안 됨)
- 메서드 종료 시 자동으로 소멸

💡 가비지 컬렉션(GC)과의 연계
만약 메서드가 종료되어 스택 영역에 있던 참조 변수 user가 소멸하면, 힙 영역에 있는 User 객체를 가리키는 주소가 사라짐. 이렇게 "그 누구도 참조하지 않는" 힙 영역의 객체들은 향후 JVM의 가비지 컬렉터에 의해 메모리에서 자동으로 수거된다.

💡프로세스의 힙 자체는 자바 가상 머신이 사용하고 있으며, 자바 가상 머신이 OS에게 부탁한 공간을 "Heap Area"라고 함
ℹ️ 데이터가 아닌 접근을 위한 '목록(Pool)'을 관리하는 것
런타임 상수 풀: 클래스마다 하나씩 생성되어 클래스 내부의 리터럴, 심볼릭 레퍼런스(이름 참조)를, 컴파일 시점에 이미 결정된 고유한 '인덱스 번호(접근 번호)'를 사용해서 관리 (런타임에 동적 연결 방식)
문자열 상수 풀: 문자열 리터럴을 관리하는 공간. 중복 저장을 막고 메모리를 아끼기 위해 해시 테이블 사용
ℹ️ 스레드간 공유 시 발생하는 부작용이 바로 '동시성(Concurrency) 문제'. 여러 스레드가 힙 영역의 하나의 객체나 메서드 영역의 static 변수를 동시에 고치려고 할 때 데이터가 꼬일 수 있어서, 자바에서는 synchronized나 volatile, Atomic 클래스 같은 동기화 장치를 제공
더보기

💡 JVM이 this를 내부적으로 처리하는 방식 (개념적 이해)

 

필드는 힙 영역에, 메서드는 한번만 생성되어 공용으로 사용됨. this를 통해 객체마다 구분되서 사용될 수 있다.

  1. static 추가: 객체에 종속되지 않고, 메서드 영역에 단 하나만 존재하는 공용 함수라는 뜻
  2. (User currentObject) 추가: 읽고 싶은 진짜 객체의 힙 영역 주소를 던져줌
public static String getName(User currentObject) {
    return currentObject.name;
}

3. 상속의 컴파일 타임 결정 이해 & 제네릭

1) 컴파일 타임 상속의 특징과 한계

상속(클래스 간의 관계)이 컴파일 시점(Static)에 정적으로 결정되기 때문에 발생하며, 주로 오버라이딩으로 캡슐화가 깨져 문제가 됨. OPP 원칙과 디자인 패턴에서 "상속(Inheritance)보다 컴포지션(조립)을 사용하라" 권장

  • Is-a 관계 (Static): 프로그램이 실행되는 도중에 동적으로 상속 대상을 바꿀 수 없음. (예: A 클래스 말고 B 클래스를 상속) 코드를 다시 짜서 재컴파일해야만 함
  • 강결합 (Tight Coupling): 클래스 하나가 다른 클래스의 기능을 확장하여 기능을 갖는 것. 부모 클래스에 조그만 변화(메서드 추가, 필드 이름 변경 등)가 생기면, 그걸 상속받은 모든 자식 클래스도 컴파일을 다시 해야 함. 부모의 변경이 자식에게 곧바로 치명적인 영향을 주기 때문에 유연성이 떨어짐
💡조립(Composition): 여러 독립적인 객체들을 조합하여 하나의 새로운 객체를 만드는 것. 클래스 내부에 다른 클래스의 인스턴스를 참조하는 방식으로 구현. 클래스 간의 관계가 실행 시점(Runtime)에 동적으로 변경될 수 있어 유연.
위임 방식 두가지  ① 의존성 주입(DI) 방식(상속 X, 하나의 클래스가 다른 하나의 클래스를 갖고 있는 방식), ② 인터페이스 사용하는 방식(원하는 기능만)

2) 오버라이딩이 런타임에 결정되는 이유 (동적 바인딩)

상속은 컴파일 타임인데, 왜 부모 메서드를 재정의하는 오버라이딩(Overriding)은 런타임에 결정될까? (구조와 실제 실행은 다르기 때문에)

  • 컴파일 시점(소스코드상 접근): 자식 클래스가 부모의 틀을 물려받는 상속(구조) 자체는 컴파일할 때 정해짐
    • 이를 정적 바인딩이라고 함
    • 이때 컴파일러는 참조 변수의 '타입(부모)'만 보고 메서드를 호출. (이때 실제 어떤 구현체가 들어올지 알 수 없음)
  • 런타임 시점(실행 시): 부모 타입 변수에 자식 객체를 담아 메서드를 호출했을 때, 실제 자식의 재정의된 메서드가 호출되는 것은 프로그램이 실행될 때 결정됨.
    • 동적 바인딩(Dynamic Binding)이라고 함 .
    • 내부적으로 this가 가리키는 실제 객체(인스턴스)의 타입을 확인하고, JVM은 실제 객체의 최하위 자식부터 부모 방향으로 올라가며 오버라이드된 메서드가 있는지 오버라이드된 메서드를 찾아 실행.
Object obj = new ArrayList<>(); 
System.out.println(obj.toString());

3) 제네릭의 개념과 타입 소거 (Type Erasure)

  • 제네릭 없던 시절(Java 5 이전)의 문제점(Object): 여러 타입을 담는 클래스를 만들려면 모든 클래스의 조상인 Object 타입을 사용해야 했음. 형변환을 매번 필요함 + 런타임 에러 위험성
  • 제네릭은 이 문제를 해결하기 위해 컴파일 타임에 타입(Type)을 확정 지어 런타임에 생길 에러를 방지하는 목적으로 도입
  • 상속은 컴파일 타임에 객체의 구조(부모-자식)를 고정하는 것이고, 제네릭은 컴파일 타임에 객체의 타입 안정성(안에 뭐가 들어올지)을 검사하고 런타임에는 사라지는 기능
    • 타입 소거(Type Erasure): 컴파일 이후에도 구조가 유지되는 상속과 달리, 제네릭은 컴파일이 끝나면 <> 제네릭 정보를 지움
    • 컴파일 할 때: 컴파일러가 Box<String>을 보고 String만 넣어야 하는걸 검사하고, 꺼내는 코드에 자동으로 (String) 형변환 코드를 넣어줌
    • 컴파일이 끝난 후 (런타임): 검사가 끝났으니 바이너리 코드(메모리)상에서는 <String> 같은 제네릭 정보가 지워지고, 다시 옛날의 Object나 일반 클래스 타입으로 변경됨

💡 왜 지울까? 제네릭이 도입되기 전에 만들어진 예단 코드(하위 호환성)들과도 완벽하게 호환되도록 하기 위해 하이버네이트된 설계

💡 컴파일 타임에서 일어나는 일

  • 메모리 설계도 완성: 컴파일러는 자식 클래스가 부모 클래스의 필드(변수)와 메서드를 물려받는다는 것을 확인하고, 자식 객체가 생성될 때 부모의 데이터까지 포함할 수 있도록 메모리 구조(설계도)를 미리 확정함.
  • 타입 체크 (Type Checking): 부모타입 변수 = new 자식타입(); 같은 다형성 코드가 있을 때, 컴파일러는 부모-자식 상속 관계를 바탕으로 이 코드가 안전한지 실행 전에 검사함

4. JVM의 특징과 가비지 컬렉션

⚙️ JVM의 메인 업무와 속도 이슈

  • 핵심 업무: 자바 바이트 코드(컴파일러가 만든 .class)를 컴퓨터가 이해할 수 있는 기계어로 변환하여 CPU에서 실행하는 것 (자바와 연동되는 OS)
  • 자바가 상대적으로 느린 이유:
    1. 실행 중에 기계어로 변환하는 과정이 필요하다. (JIT 컴파일러 등을 사용하지만 네이티브 언어보단 오버헤드 존재)
    2. 가비지 컬렉션(GC): 메모리 누수를 막기 위해 안 쓰는 메모리를 자동으로 수거할 때, 프로그램이 일시적으로 멈추는 STW(Stop-The-World) 현상이 발생함
  • 실무 적용: 위 일시 멈춤(지연) 현상 때문에 정밀하고 즉각적인 반응이 필수적인 방산 분야 등 리얼타임 OS(RTOS) 환경에서는 자바를 사용하기 힘듦
  • JVM의 이름: 오라클의 대표적인 자바 JVM 제품 이름은 HotSpot(핫스팟) (일부 환경에서는 실행 전 미리 컴파일하는 AOT 방식도 지원)

🗑️ 가비지 컬렉션 (Garbage Collection, 줄여서 GC)

프로그램이 동적으로 할당했던 메모리 영역 중, 더 이상 사용하지 않는 영역을 자동으로 찾아내어 해제하는 메모리 관리 기법

  • C나 C++ 같은 언어에서는 개발자가 직접 메모리를 할당하고(malloc()) 해제(free() 또는 delete)해야 하지만, Java, JavaScript, Python, Go 등 현대적인 언어들은 가비지 컬렉션이 내장되어 있음
  • 런타임 단계에 JVM이 알아서 메모리를 관리하며 GC 실행. 백그라운드에서 GC 스레드가 지속적으로 돌아가며 불필요한 메모리를 자동으로 수거
    • 컴파일 단계에는 메모리 구조의 틀만 정의할뿐 실제 객체를 생성하지 않고 언제까지 사용될지 알 수 없기 때문에 불필요
    • 실제 객체(new 연산)는 런타임일때 사용자의 입력이나 로직에 따라 동적으로 힙 영역에 할당됨. 이걸 프로그램 실행되는 동안 실시간 감시.
  • 가비지 컬렉터는 어떤 메모리가 '더 이상 사용되지 않는지' 판단하기 위해 유효한 참조(Reachability)라는 개념을 사용한다.
    • Reachable (유효한 참조): 힙(Heap) 메모리에 있는 객체를 프로그램의 어딘가에서 여전히 가리키고(참조하고) 있는 상태 (사용 중인 메모리)
    • Unreachable (유효하지 않은 참조): 그 어떤 곳에서도 이 객체를 가리키는 변수나 링크가 없는 상태. 이 상태의 객체들이 가비지가 됨
💡 객체 변수에 명시적 null 처리를 하게 되면? 즉시 GC 대상(Unreachable) 전환되어 다음 가비지 컬렉션(GC) 사이클이 돌 때 메모리 회수 대상으로 분류됨
ℹ️컴파일 타임과 런타임 관점에서 정리
컴파일 단계: 위 설명한 객체(null)의 멤버 변수나 메서드에 접근하면, 컴파일 타임 에러는 발생하지 않음(컴파일러가 정상적인 문법으로 인식, 바이트코드 성공적으로 생성되며 실행 시점 직전(빌드)까지 진입가능. 대신 IDE나 컴파일러 환경에 따라 경고).
런타일 단계: null 상태인 객체를 건드리는 순간, JVM은 즉시 NullPointerException (NPE)이라는 런타임 에러를 던짐.(프로그램 비정상 종료) → 사용이 끝나 null로 고립된 메모리 파편들은 가비지 컬렉터가 수거
더보기

✨ 대표적인 가비지 컬렉션 알고리즘

가비지 컬렉터가 쓰레기를 찾는 방식은 언어와 엔진에 따라 다양하다. 대표적으로 아래 3가지 방식이 있다.

 

① 레퍼런스 카운팅 (Reference Counting)

  • 원리: 각 객체마다 자신을 가리키는 참조 횟수(카운트)를 기록해서 카운트가 0이 되면 즉시 메모리에서 해제함.
  • 단점: 두 객체가 서로를 순환 참조(A → B 이고 B → A 인 상태)하고 있으면, 실제로는 둘 다 쓰이지 않는데도 카운트가 0이 되지 않아 메모리 누수가 발생 가능 (과거 Swift나 Python의 일부 메커니즘에서 사용)

② 마크 앤 스윕 (Mark-and-Sweep)

  • 원리: 현대 GC의 기초가 되는 방식
    1. Mark (깃발 꽂기): 최상위 참조(Root Space)에서부터 출발해서 연결된 모든 객체를 방문하며 "사용 중"이라고 표시(Mark)
    2. Sweep (쓸어 담기): 표시가 되지 않은(Unreachable) 객체들을 메모리에서 전부 지움
  • 단점: GC가 실행되는 동안 프로그램이 일시적으로 멈추는 STW(Stop-The-World) 현상이 발생 가능
💡STW란? GC를 실행하기 위해 JVM이 모든 애플리케이션 실행을 멈추는 것. stop-the-world가 발생하면 GC를 실행하는 쓰레드를 제외한 나머지 쓰레드는 모두 작업을 멈춘다. GC작업을 완료한 이후에 작업을 다시 시작 (어떤 알고리즘을 사용하더라도 stop-the-world는 발생)

 

③ 세대별 가비지 컬렉션 (Generational Garbage Collection)

  • 원리: "대부분의 객체는 생성된 지 얼마 되지 않아 쓸모없어진다"는 가설(Weak Generational Hypothesis)에 기반. 메모리를 젊은 세대(Young Generation)와 노인 세대(Old Generation)로 나눔
    • 새로 생성된 객체는 젊은 세대에 배치하고, 여기서 잦은 GC(Minor GC)를 돌려 금방 쓰고 버릴 객체를 빠르게 치움
    • 여기서 오랫동안 살아남은 객체는 노인 세대로 이동시키고, 가끔씩 큰 GC(Major GC)를 돌림. Java의 JVM이나 JavaScript의 V8 엔진 등이 이 방식을 고도화하여 사용

5. Java 역사 및 주요 버전별 특징

Write Once, Run Anywhere (한 번 작성하면 어디서든 실행된다)
  • Java SE(Standard Edition): 개인 PC나 기본 연동을 위한 표준 JDK 환경
  • Java EE(Enterprise Edition): 기업형 서버 환경(Apache Tomcat 등)에서 서블릿 애플리케이션을 개발할 때 쓰이던 규격입니다. 오라클의 유료화 정책 등으로 인해 현대 백엔드 시장에서는 Spring Framework가 이를 완벽히 대체함.
  • JPA (Java Persistence API): 대표적인 ORM(Object-Relational Mapping) 모델로, SQL 문을 직접 쓰지 않고 자바 클래스 조작만으로 데이터베이스 CRUD 작업을 가능하게 해줌

📅 핵심 버전별 추가 기능 테이블

자바 역사에서 가장 중요한 LTS(Long Term Support) 및 마일스톤 버전들입니다.

버전 핵심 추가 기능 설명
Java 5 제네릭(Generics), Enum, 어노테이션 자바 문법의 현대화 기점
Java 8 람다식, Stream API 기존 명령형(한 줄씩 기술)에서 선언형 코딩 체계로 전환
Java 9 모듈 시스템(Project Jigsaw) 애플릿(Applet) 기능 등 노후 기능 제거 시작 (11에서 완전 삭제)
Java 10 var 지역 변수 추론 키워드로 타입을 자동 추론
Java 17 Sealed Class 정식 도입 상속받을 자식 클래스를 제한하여 보안 및 설계 강화
Java 21 Virtual Thread (가상 스레드) 톰캣 같은 기존 웹 서버가 지닌 '스레드 과부하' 문제를 혁신적으로 해결

💡 가상 스레드(Virtual Thread)와 웹 서버 이야기

텍스트 기반의 웹(HTTP)은 바이너리 통신과 달리 항상 인코딩/디코딩을 거쳐야 해서 태생적으로 느림. (이를 극복하기 위해 구글은 gRPC 프로토콜 등을 사용)

기존 Netty, Tomcat, Nginx 중 톰캣 서버는 요청마다 스레드를 할당하여 과부하가 걸리는 단점이 있었으나, Java 21부터 가상 스레드가 채택되면서 백엔드 처리 속도가 엄청나게 빨라졌음.