자바 실행은 다음과 같은 단계를 거친다.
.java 작성
↓
javac 컴파일
↓
.class 생성 (바이트코드)
↓
java 실행
↓
JVM 내부 동작
├─ ClassLoader
├─ Bytecode Verification
├─ Execution Engine (Interpreter + JIT)
└─ GC
자바는 단순한 컴파일 언어가 아니다.
javac Main.java
javac는 다음을 수행한다.
결과는 .class 파일이다.
java Main
이 명령은 JVM을 실행하는 명령이다.
javap -c Main
바이트코드는 CPU 기계어가 아니다.
JVM이 이해하는 중간 명령어 집합이다.
자바는 스택 기반 가상 머신이다.
예:
iconst_1
iconst_2
iadd
모든 연산은 Operand Stack 위에서 수행된다.
JVM Memory
├─ Heap
├─ Stack (Thread별)
├─ Method Area (Metaspace)
├─ PC Register
└─ Native Method Stack
Stack Frame
├─ Local Variables
└─ Operand Stack
astore_1의 숫자는 로컬 변수 슬롯 번호다.
Execution Engine은 두 부분으로 구성된다.
자바는 “해석 후 동적 최적화” 구조다.
JIT는 단순히 바이트코드를 기계어로 번역하는 것이 아니다.
실행 중 코드를 분석하고 최적화한다.
int add(int a, int b) {
return a + b;
}
int result = add(1, 2);
add가 자주 호출되면 JIT는:
int add(int a, int b) {
if (System.nanoTime() % 2 == 0) {
return a + b;
}
return a - b;
}
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는:
Point create() {
return new Point(1, 2);
}
for (int i = 0; i < 1_000_000; i++) {
sum += i;
}
JIT는:
GC는 Heap만 관리한다.
GC Root 예:
static List<Object> list = new ArrayList<>();
자바의 메모리 누수는 참조를 끊지 않아서 발생한다.
GC가 객체 그래프를 분석할 때 애플리케이션 스레드를 잠시 멈춘다.
이를 Stop-The-World라 한다.
전제:
대부분의 객체는 금방 죽는다.
Eden이 차면 Minor GC 발생.
HashSet은 Set 구현체지만, 실제 저장은 HashMap이 한다.
HashSet의 내부 필드는 이런 형태다.
public class HashSet<E> {
private transient HashMap<E, Object> map;
}
즉, HashSet에 값을 넣는다는 말은 곧
HashSet이 내부적으로 쓰는 value는 의미 없는 더미 값이다(주로 PRESENT 같은 단일 객체).
set.add(x);
이건 내부적으로 거의 다음과 같다.
map.put(x, PRESENT);
중요한 포인트:
HashMap은 내부에 버킷 테이블을 가진다.
Node<K,V>[] table;
이 배열이 의미하는 것은 단순 배열이 아니다.
Node 구조는 대략 다음과 같다.
static class Node<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
핵심은 해시값으로 인덱스를 계산하는 것이다.
index = hash(key) & (table.length - 1)
table 길이가 2의 거듭제곱이라서 이런 비트 연산을 쓴다.
이 계산 결과 index가 바로 “버킷 번호”다.
두 key가 같은 index로 가면 충돌이 발생한다.
HashMap은 기본적으로 이렇게 처리한다.
table[i] → Node → Node → Node
즉, 같은 버킷에는 Node들이 next로 연결된다.
Java 8 이후에는 버킷에 Node가 너무 많이 쌓이면 연결 리스트 대신 Red-Black Tree로 변환하여 최악을 줄인다.
다음 코드를 보자.
Set<Integer> set = new HashSet<>();
set.add(1);
set.add(2);
여기서 Heap에 생기는 객체를 구조적으로 보면 대략 이렇다.
HashSet 객체 1개
내부 HashMap 객체 1개
HashMap의 table(Node[] 배열) 1개 (처음엔 보통 16칸에서 시작)
각 요소마다 Node 객체 1개씩
Integer 객체
1, 2는 int가 아니라 Integer로 저장된다 (오토박싱)정리하면, 요소 2개만 넣어도:
객체가 꽤 생긴다.
Set<Integer> set = new HashSet<>();
for (int i = 0; i < 1_000_000; i++) {
set.add(i);
}
대략 Heap에 이런 객체들이 생긴다.
즉, 최소 2,000,000개 이상의 객체가 생성된다.
그리고 table도 커진다.
HashSet/HashMap이 단순히 “빠르다(O(1))”로 끝나지 않는 이유다.
그래서 대량 데이터를 다룰 때는
이 성능적으로 유리한 경우가 많다.