자바 실행 구조 완전 정복

revo·2026년 2월 17일

자바

목록 보기
16/30
post-thumbnail

1. 자바 실행의 전체 구조

자바 실행은 다음과 같은 단계를 거친다.

.java 작성
    ↓
javac 컴파일
    ↓
.class 생성 (바이트코드)
    ↓
java 실행
    ↓
JVM 내부 동작
    ├─ ClassLoader
    ├─ Bytecode Verification
    ├─ Execution Engine (Interpreter + JIT)
    └─ GC

자바는 단순한 컴파일 언어가 아니다.

  • 소스는 바이트코드로 컴파일된다.
  • 실행은 JVM이 담당한다.
  • 반복되는 코드는 JIT가 기계어로 재컴파일한다.

2. javac, java, javap의 역할

javac

javac Main.java

javac는 다음을 수행한다.

  • 문법 검사
  • 타입 검사
  • 심볼 참조 해결
  • 바이트코드 생성

결과는 .class 파일이다.


java

java Main

이 명령은 JVM을 실행하는 명령이다.

  • JVM 프로세스를 시작한다.
  • Main.class를 로딩한다.
  • main()을 호출한다.

javap

javap -c Main
  • .class 파일 내부의 바이트코드를 보여준다.
  • 향상된 for문이 실제로 Iterator 코드로 변환되는 것을 확인할 수 있다.

3. 바이트코드의 본질

바이트코드는 CPU 기계어가 아니다.

JVM이 이해하는 중간 명령어 집합이다.

자바는 스택 기반 가상 머신이다.

예:

iconst_1
iconst_2
iadd
  • 1을 Operand Stack에 push
  • 2를 push
  • iadd 수행

모든 연산은 Operand Stack 위에서 수행된다.


4. JVM 메모리 구조

JVM Memory
├─ Heap
├─ Stack (Thread별)
├─ Method Area (Metaspace)
├─ PC Register
└─ Native Method Stack

Heap

  • new로 생성된 객체 저장
  • GC가 관리

Stack

  • 스레드마다 존재
  • 메서드 호출 시 Stack Frame 생성
  • 지역 변수 저장
  • 메서드 종료 시 제거
  • GC와 무관

Stack Frame 구조

Stack Frame
├─ Local Variables
└─ Operand Stack

astore_1의 숫자는 로컬 변수 슬롯 번호다.


5. Execution Engine

Execution Engine은 두 부분으로 구성된다.

Interpreter

  • 바이트코드를 한 줄씩 해석 실행
  • 시작이 빠르다
  • 반복 실행 시 비효율적

JIT (Just-In-Time Compiler)

  • 자주 실행되는 코드(HotSpot)를 감지
  • 기계어로 컴파일
  • 이후에는 기계어로 직접 실행

자바는 “해석 후 동적 최적화” 구조다.


6. JIT 최적화 예제

JIT는 단순히 바이트코드를 기계어로 번역하는 것이 아니다.
실행 중 코드를 분석하고 최적화한다.

메서드 인라인

최적화 가능한 코드

int add(int a, int b) {
    return a + b;
}

int result = add(1, 2);

add가 자주 호출되면 JIT는:

  • add 호출을 제거
  • 호출 코드를 직접 삽입
  • 스택 프레임 생성 제거

최적화가 어려운 코드

int add(int a, int b) {
    if (System.nanoTime() % 2 == 0) {
        return a + b;
    }
    return a - b;
}
  • 외부 상태 의존
  • 분기 예측 어려움

Escape Analysis

최적화 가능한 코드

class Point {
    int x, y;
    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

int test() {
    Point p = new Point(1, 2);
    return p.x + p.y;
}

p가 메서드 밖으로 나가지 않는다.

JIT는:

  • Heap 생성 제거
  • Stack에 배치
  • 객체 생성 자체 제거 가능

최적화 불가 코드

Point create() {
    return new Point(1, 2);
}
  • 객체가 외부로 반환됨
  • Heap 생성 필요

루프 최적화

for (int i = 0; i < 1_000_000; i++) {
    sum += i;
}

JIT는:

  • 루프 전개
  • 범위 체크 제거
  • 레지스터 할당 최적화

7. GC (Garbage Collection)

GC는 Heap만 관리한다.

Reachability 기준

  • GC Root에서 도달 가능하면 생존
  • 도달 불가능하면 제거

GC Root 예:

  • 스택 지역 변수
  • static 필드
  • 실행 중인 스레드

static이 GC를 막는 이유

static List<Object> list = new ArrayList<>();
  • static은 클래스 생명주기와 동일
  • GC Root에서 항상 도달 가능
  • 내부 객체 참조 유지
  • 제거 불가

자바의 메모리 누수는 참조를 끊지 않아서 발생한다.


Stop-The-World(STW)

GC가 객체 그래프를 분석할 때 애플리케이션 스레드를 잠시 멈춘다.

이를 Stop-The-World라 한다.


8. 세대별 GC 구조

전제:

대부분의 객체는 금방 죽는다.

Young Generation

  • Eden
  • Survivor (S0, S1)

Eden이 차면 Minor GC 발생.

  • 살아있는 객체만 Survivor로 복사
  • 일정 횟수 생존하면 Old로 승격

Old Generation

  • 오래 생존한 객체 저장
  • 공간 부족 시 Major/Full GC

9. JDK 21 기준 GC

기본 GC: G1 (Garbage First)

  • JDK 9 이후 기본 GC
  • 세대별 구조 사용
  • Heap을 Region 단위로 관리
  • Mixed GC 수행

ZGC

  • 저지연 GC
  • JDK 21에서 Generational ZGC 옵션 제공

10. primitive와 객체의 차이

primitive

  • int, long 등
  • Heap 객체 아님
  • GC 대상 아님

Wrapper

  • Integer 등
  • Heap 객체
  • GC 대상

11. HashSet과 HashMap 내부 구조

11-1. HashSet은 실제로 무엇을 저장하나

HashSet은 Set 구현체지만, 실제 저장은 HashMap이 한다.

HashSet의 내부 필드는 이런 형태다.

public class HashSet<E> {
    private transient HashMap<E, Object> map;
}

즉, HashSet에 값을 넣는다는 말은 곧

  • HashMap의 key에 값을 넣는 것과 동일하다.

HashSet이 내부적으로 쓰는 value는 의미 없는 더미 값이다(주로 PRESENT 같은 단일 객체).


11-2. HashSet.add가 내부적으로 하는 일

set.add(x);

이건 내부적으로 거의 다음과 같다.

map.put(x, PRESENT);

중요한 포인트:

  • HashSet은 “배열에 값 저장”이 아니라 HashMap의 해시 테이블 구조에 엔트리(Node)를 만든다.

11-3. HashMap의 핵심 구조: Node[] table

HashMap은 내부에 버킷 테이블을 가진다.

Node<K,V>[] table;

이 배열이 의미하는 것은 단순 배열이 아니다.

  • table의 각 칸은 “데이터 시작점”이다.
  • 각 칸은 Node(엔트리)의 첫 번째를 가리킨다.

Node 구조는 대략 다음과 같다.

static class Node<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
}

11-4. key가 들어갈 위치는 어떻게 정하나

핵심은 해시값으로 인덱스를 계산하는 것이다.

index = hash(key) & (table.length - 1)

table 길이가 2의 거듭제곱이라서 이런 비트 연산을 쓴다.

이 계산 결과 index가 바로 “버킷 번호”다.


11-5. 충돌이 발생하면 어떻게 되나

두 key가 같은 index로 가면 충돌이 발생한다.

HashMap은 기본적으로 이렇게 처리한다.

table[i] → Node → Node → Node

즉, 같은 버킷에는 Node들이 next로 연결된다.

Java 8 이후에는 버킷에 Node가 너무 많이 쌓이면 연결 리스트 대신 Red-Black Tree로 변환하여 최악을 줄인다.


11-6. Heap에는 객체가 몇 개 생기는가

다음 코드를 보자.

Set<Integer> set = new HashSet<>();
set.add(1);
set.add(2);

여기서 Heap에 생기는 객체를 구조적으로 보면 대략 이렇다.

  1. HashSet 객체 1개

  2. 내부 HashMap 객체 1개

  3. HashMap의 table(Node[] 배열) 1개 (처음엔 보통 16칸에서 시작)

  4. 각 요소마다 Node 객체 1개씩

    • 1을 넣으면 Node 1개
    • 2를 넣으면 Node 1개
  5. Integer 객체

    • 1, 2는 int가 아니라 Integer로 저장된다 (오토박싱)

정리하면, 요소 2개만 넣어도:

  • Node 2개
  • Integer 2개
  • HashSet/HashMap/table까지

객체가 꽤 생긴다.


11-7. 1,000,000개를 넣으면 무엇이 폭발하나

Set<Integer> set = new HashSet<>();
for (int i = 0; i < 1_000_000; i++) {
    set.add(i);
}

대략 Heap에 이런 객체들이 생긴다.

  • HashSet 객체 1개
  • HashMap 객체 1개
  • Node[] table 배열 1개 (확장되며 커짐)
  • Node 객체 1,000,000개
  • Integer 객체 1,000,000개 (대부분 -128~127 범위를 넘어서 새로 생성)

즉, 최소 2,000,000개 이상의 객체가 생성된다.

그리고 table도 커진다.

  • HashMap은 load factor(기본 0.75)를 기준으로 배열을 확장한다.
  • 1,000,000개를 담으려면 table은 수백만 크기로 커질 수 있다.
  • 커질 때마다 resize가 일어나며, 기존 엔트리들이 재배치된다.

11-8. 이 구조가 성능에 주는 영향

HashSet/HashMap이 단순히 “빠르다(O(1))”로 끝나지 않는 이유다.

  • Node와 Integer 같은 Heap 객체 수가 많아진다.
  • 객체가 많아질수록 GC 부담이 커진다.
  • Node는 메모리에 흩어져 있어 포인터 추적이 많다.
  • CPU 캐시 효율이 떨어질 수 있다.

그래서 대량 데이터를 다룰 때는

  • primitive 기반 구조(배열 등)
  • 또는 primitive 전용 컬렉션

이 성능적으로 유리한 경우가 많다.

0개의 댓글