JDK 7까지는 PermGen(Permanent Generation)에 런타임 상수 풀이 저장되었고, JDK 8부터는 네이티브 메모리 기반의 Metaspace로 이동
JDK 7에서는 "hello".intern() 하면 PermGen에 저장.
JDK 8에서는 "hello".intern() 하면 Java Heap에 저장됨.
다이렉트 메모리 (Direct Memory: JVM 관리 영역X)
JVM의 힙 영역이 아니라 네이티브 메모리를 직접 할당하는 방식으로 사용
사용 목적
DirectByteBuffer를 활용한 고속 I/O 처리
데이터 복사를 최소화하여 성능 향상
메모리 할당 과정
ByteBuffer.allocateDirect()를 호출 -> OS에서 직접 네이티브 메모리를 할당
JVM 힙이 아닌 네이티브 메모리에 저장
JVM 내부적으로 DirectByteBuffer 객체를 생성하여 참조
사용 후 명시적으로 해제해야함
장점
빠른 성능
힙 메모리의 객체를 사용할 경우, 네이티브 코드로 데이터를 복사하는 과정이 필요.
다이렉트 메모리는 OS의 네이티브 메모리를 직접 사용하므로 즉시 접근 가능(I/O 최적화)
GC 영향없음
GC로 인한 성능 저하 없음
대용량 데이터 처리 최적화
네트워크 통신, 파일 입출력, 데이터베이스 연동 등 대용량 데이터를 처리할 때 효율적.
제한 사항
-Xmx 등의 설정으로 JVM의 힙 크기를 조절할 수 있지만, 다이렉트 메모리는 JVM의 관리 대상이 아니므로 메모리 누수 방지를 위해 개발자가 명시적으로 해제해야함.
운영 체제의 물리 메모리 한계를 초과할 경우 OutOfMemoryError 발생 가능
DirectByteBuffer란?
DirectByteBuffer는 JVM 힙이 아닌 OS의 네이티브 메모리를 직접 사용하는 버퍼를 관리하는 Java NIO(ByteBuffer) 구현 클래스
// DirectByteBuffer를 이용한 파일 I/O 성능 최적화
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class DirectByteBufferFileIO {
public static void main(String[] args) throws Exception {
// 파일 채널 생성
FileInputStream fis = new FileInputStream("input.txt");
FileOutputStream fos = new FileOutputStream("output.txt");
FileChannel inputChannel = fis.getChannel();
FileChannel outputChannel = fos.getChannel();
// DirectByteBuffer 할당 (네이티브 메모리 사용)
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
while (inputChannel.read(buffer) != -1) {
buffer.flip();
outputChannel.write(buffer);
buffer.clear();
}
inputChannel.close();
outputChannel.close();
fis.close();
fos.close();
}
}
JVM 힙을 거치지 않고 다이렉트 메모리를 사용하여 파일 데이터를 빠르게 복사
네이티브 메모리를 활용하므로 I/O 성능이 향상됨
2-3. 핫스팟 가상 머신에서의 객체 들여다보기
new 키워드를 사용하여 객체를 생성하지만, 내부에서는 추가 과정들이 존재.
1. 객체 생성 과정
클래스 로드 확인
해당 객체의 클래스가 이미 메모리에 로드되었는지 확인
로드되지 않았다면 클래스 로딩 -> 해석 -> 초기화 단계를 거침
힙 메모리 할당
객체의 크기를 결정하고 적절한 힙 공간을 찾아 메모리를 할당
메모리 할당 방식에는 포인터 증가(Bump-the-pointer)와 Free List 방식이 있음
객체 헤더 설정
객체의 기본 정보를 설정 (해시값, GC 연령 정보 등)
필드 초기화
객체의 모든 필드를 기본값으로 설정
생성자 호출
init 메서드를 실행하여 객체를 최종적으로 초기화
힙 메모리 할당 방식
객체용 메모리 공간 할당은 자바 힙에서 특정 크기의 메모리 블록을 잘라 주는 일. 객체에 필요한 메모리 크기는 클래스를 로딩하고 나면 완벽히 알 수 있음.
포인터 증가 방식 (Bump-the-pointer)
자바 힙이 연속적인 메모리 블록으로 관리될 때 사용
사용중인 메모리와 여유 메모리 사이의 경계를 포인터로 추적
새로운 객체를 생성하면 포인터를 해당 크기만큼 이동하여 메모리를 할당
GC가 한 번 실행될 때마다 메모리를 재정렬 해야함(Compact 필요)
단편화되지 않을 시에 매우 빠른 할당 속도.
Free List 방식
조각난 메모리 공간이 존재할 떄 사용(Compact 불필요)
사용 가능한 빈 메모리 블록 목록을 유지하고 거기에서 적절한 크기의 공간을 찾아 객체를 할당
메모리 관리가 유연하지만, 할당 속도가 상대적으로 느림
Compact를 수행하는 GC, 수행하지 않는 GC
✅ Serial GC
단일 스레드 기반의 GC이며, Stop-the-World(전체 애플리케이션 일시 중지) 동안 Compact를 수행
Young GC(Minor GC)에서 Copying GC 방식을 사용하여 Compact 불필요
Old GC(Major/Full GC)에서 Mark-Sweep-Compact 방식으로 압축 수행
✅ Parallel GC (Throughput GC)
여러 개의 스레드를 사용하여 GC 성능을 향상
Old GC에서 Mark-Sweep-Compact 방식을 사용하여 단편화 해소
Young GC는 Eden → Survivor로 이동하는 과정에서 Compact 불필요
✅ G1 GC (Garbage-First GC)
G1 GC는 Region 기반 GC로 동작하며, Mixed GC 단계에서 Compact 수행 가능
사용되지 않는 Region을 정리하고, 유효한 객체를 새로운 Region으로 이동하여 단편화 해소
Full GC 발생 시에도 압축을 수행하지만, Full GC 자체는 가급적 피하도록 설계됨
❌ CMS GC (Concurrent Mark-Sweep)
Stop-the-World 시간을 줄이기 위해 압축을 수행하지 않음
단점: 메모리 단편화(Fragmentation)가 발생할 수 있음
단편화를 해결하기 위해 Full GC가 강제 발생할 수도 있음
JDK 9부터 G1 GC가 CMS GC를 대체
❌ ZGC
Heap을 조각난 상태로 유지한 채 메모리를 관리하여 Compact 불필요
Large Page 메모리 모델과 색인 기반 메모리 매핑을 사용
즉, ZGC는 단편화 발생을 방지하는 방식으로 설계되어 Compact 자체가 필요 없음
❌ Shenandoah GC
Concurrent Compaction을 수행하여 Stop-the-World 없이 단편화를 해결
기존 GC처럼 모든 객체를 이동하는 압축 방식이 아니라, 필요할 때만 일부 객체를 이동
기본적으로는 Compact가 필요 없는 구조
멀티스레드 환경에서의 객체 생성 문제
여유 메모리의 시작 포인터 위치를 수정하는 작업도 thread-safe 하지 않기 떄문에 race condition 발생 가능.
해결 방법은 두 가지 방법이 있다.
CAS
객체를 할당하는 과정에서 CAS 연산을 활용하여 포인터 갱신을 원자적으로 처리
포인터 값을 비교 후 안전하게 변경
TLAB (Thread-local allocation buffer)
각 스레드가 자신만의 작은 힙 영역을 별도로 할당하여 객체를 생성
스레드 간 경쟁 없이 독립적인 공간에서 객체를 빠르게 생성 가능
TLAB이 가득 차면 새로우 ㄴ힙 영역을 요청하여 사용
JVM 옵션으로 -XX;+UserTLAB을 설정하면 활성화 가능
JVM은 TLAB을 기본적으로 활성하여 사용
TLab에서 스레드에게 충분한 영역을 줄 수 있는지 판단 방법
결론:
TLAB이 파편화된 힙에서 충분한 공간을 제공할 수 있는지는 다음과 같은 기준을 통해 판단된다.
TLAB이 최소 크기(-XX:MinTLABSize) 이상인지 확인
남은 TLAB 공간이 설정된 비율(-XX:TLABWasteTargetPercent) 이하인지 확인
힙에 충분한 연속적인 공간이 존재하는지 확인
필요한 경우, 기존 TLAB 크기를 축소하거나 글로벌 힙을 활용
즉, TLAB이 충분한 공간을 찾지 못하면 글로벌 힙을 활용하여 객체를 할당하게 되고, JVM은 GC를 통해 단편화를 해결하며 TLAB 크기를 동적으로 조정한다.
👍 핵심 요약
TLAB을 할당할 수 없는 경우, 최소 크기 이하인지 확인 후 글로벌 힙에서 객체를 할당
GC가 실행될 때 단편화를 줄이고 TLAB 크기를 조정
JVM은 TLAB 크기를 동적으로 조절하여 최적화
TLAB 크기 조정은 -XX:TLABSize, -XX:TLABWasteTargetPercent, -XX:MinTLABSize 등의 옵션으로 튜닝 가능
자세히:
핫스팟 JVM에서는 TLAB 크기 결정 및 확장 정책을 사용
TLAB 초기 크기 설정 - TLAB 크기는 기본적으로 -XX:TLABSize 옵션을 통해 설정할 수 있지만, JVM은 다음을 고려하여 자동으로 TLAB 크기를 결정
현재 힙 메모리 상태
스레드 수
Young Generation의 크기 (TLAB은 Young Generation에 할당됨)
스레드별 객체 생성 빈도
스레드마다 동일한 크기의 TLAB을 할당하는 것이 아니라, JVM이 동적으로 크기를 조정
TLAB 할당 시 충분한 공간이 있는지 판단하는 기준
기본적인 최소 크기 조건
TLAB이 할당될 최소 크기는 객체 크기보다 커야 함
-XX:MinTLABSize 옵션으로 최소 크기를 조정 가능
만약 현재 힙에 TLAB 최소 크기보다 작은 연속적인 공간만 남아 있다면, 해당 스레드는 TLAB을 할당받지 않고 글로벌 힙에서 직접 할당하게 됨
TLAB 잔여 공간이 설정된 임계치보다 작다면 재할당
JVM은 TLAB 내에서 남아 있는 공간이 일정 비율 이하로 줄어들면, 새로운 TLAB을 요청하게 됨
-XX:TLABWasteTargetPercent 옵션을 통해 이 임계치를 조정할 수 있음
기본적으로 TLAB의 1% 미만의 공간만 남아 있으면 새로운 TLAB을 할당받음
힙 단편화 고려
JVM은 Young Generation 내의 가용 공간이 충분하지 않거나 단편화가 심한 경우, 기존의 TLAB을 축소하여 작은 크기로 재할당할 수도 있음
만약 연속된 여유 공간이 없다면, 기존 TLAB을 해제하고 글로벌 힙에서 객체를 직접 할당하는 방식으로 전환
TLAB이 부족할 경우의 동작 방식
새로운 TLAB을 요청
연속된 여유 공간이 충분하다면 새로운 TLAB을 할당
TLAB 크기 축소 후 재할당
Young Generation의 단편화가 심하면, TLAB 크기를 축소한 후 재할당 시도
TLAB을 포기하고 글로벌 힙 사용
단편화가 너무 심해서 TLAB을 할당할 수 없는 경우, 스레드는 글로벌 힙에서 직접 객체를 할당
**TLAB 최적화 관련 JVM 옵션
생성자 호출과 객체의 완전한 초기화
객체는 new 키워드와 함께 생성되지만, 실제로 객체가 초기화되는 것은 생성자(<init> 메서드)가 실행된 이후이다.
new 키워드 → 힙 메모리 할당
invokespecial → 생성자 호출 (초기화 수행)
init 메서드 실행 → 완전한 객체 생성 완료
2. 객체의 메모리 레이아웃
핫스팟 가상 머신은 객체를 세 부분으로 나누어 힙에 저장한다.
객체 헤더 - 객체의 메타데이터를 포함
인스턴스 데이터 - 실제 객체 필드 값들이 저장됨
정렬 패딩 - 메모리 정렬을 위해 추가되는 공간
객체 헤더 (Object Header)
객체 헤더는 JVM이 객체를 관리하는 데 필요한 정보를 저장하는 영역
마크 워드 (Mark Word)
객체의 해시 코드 (hashCode)
GC 연령 (Age)
락 플래그 (Lock Flag, 동기화 정보)
스레드 경합 상태 (Thread Contention State)
평활화 스레드 타임스탬프 (Bias Locking Timestamp)
객체 헤더 크기
32비트 JVM에서는 8바이트
64비트 JVM에서는 16바이트
배열의 경우 추가적인 4바이트 필요
왜 객체 헤더에 동기화 정보가 필요한가?
객체 헤더에 동기화 정보가 들어가는 건 모니터 락(Monitor Lock) 때문.
VM은 synchronized 키워드나 wait(), notify() 같은 동기화 기능을 사용할 때 객체 단위로 락을 관리하는데,
이때 락 정보가 객체의 헤더(Mark Word)에 저장
🔹 객체 락의 종류 (3가지 락 단계)
자바의 동기화 방식은 최적화를 위해 세 가지 락 단계를 가지고 있어.
1️⃣ Bias Lock (편향 락)
한 개의 스레드가 계속 객체를 사용할 때 최적화
Mark Word에 스레드 ID를 저장하여 불필요한 동기화 제거
락 해제 과정 없이 같은 스레드가 계속 사용 가능
단, 다른 스레드가 접근하면 편향 락이 해제됨 (Revoke)
🔹 성능 최적화 목적
2️⃣ Lightweight Lock (경량 락)
여러 스레드가 경쟁하지만 락 충돌이 심하지 않을 때 사용
CAS(Compare-And-Swap) 연산을 활용하여 빠른 락 획득 및 해제
Mark Word에 Lock Record의 포인터 저장
🔹 빠른 락 전환 및 충돌 최소화
3️⃣ Heavyweight Lock (무거운 락)
경합이 심해서 경량 락을 사용할 수 없을 때
OS 모니터(커널 수준의 락) 사용 → 성능 저하 발생
Mark Word에 Monitor Object의 주소 저장
🔹 성능이 낮지만, 확실한 동기화 보장
객체 헤더의 마크워드에는 락 플래그가 있는데, 그 외에, 현재 락을 획득한 스레드는 누구고, 다음 깨울 스레드는 누구고, 어떤 스레드들이 기다리고 있는지에 대한 정보는?
자바 객체의 헤더(Mark Word)에는 동기화 관련 정보가 저장되지만,
"어떤 스레드가 현재 락을 가지고 있는지", "어떤 스레드들이 기다리고 있는지", "다음에 락을 얻을 스레드는 누구인지" 같은 상세한 정보는 Mark Word 자체에는 저장되지 않음
이 정보들은 객체의 "Monitor Record" 또는 "ObjectMonitor" 구조체에 저장
락 플래그 vs 스레드 경합 상태
락 플래그
객체가 현재 어떤 락 상태인지를 나타내는 객체 헤더(Object Header)의 일부이다.
이 정보는 **마크 워드(Mark Word)**에 저장되며, 객체 동기화(synchronized) 여부에 따라 값이 달라진다.
스레드 경합 상태 (Thread Contention State)
스레드 경합 상태는 JVM이 현재 실행 중인 여러 스레드 간에 어떤 경합이 발생하는지 나타내는 상태 정보이다.
즉, 락이 필요할 때 스레드 간 충돌이 발생했는지 여부를 추적하는 정보라고 볼 수 있다.
스레드 경합 상태의 역할
여러 스레드가 같은 객체를 동시에 접근하려고 할 때 동기화 충돌이 발생하는지 확인
특정 스레드가 락을 얼마나 오래 기다렸는지 추적하여 최적화 가능
필요하면 Bias Lock을 해제하고 Lightweight 또는 Heavyweight Lock으로 전환
평활화 스레드 타임스탬프 (Bias Locking Timestamp)
JVM은 Bias Lock을 유지할지, 해제할지를 결정해야 하는데,
이를 위해 객체가 언제 Bias Lock을 가졌는지를 Bias Locking Timestamp 값으로 저장한다.
Bias Locking Timestamp가 하는 일
Bias Lock이 활성화될 때, 객체의 Mark Word에 해당 스레드 ID와 함께 "타임스탬프"를 저장
이후 JVM은 주기적으로 해당 객체가 여전히 같은 스레드에서만 사용되고 있는지 검사
객체가 일정 시간 동안 다른 스레드에서 사용되지 않았다면 Bias Lock을 계속 유지
다른 스레드가 해당 객체를 사용하려고 하면 Bias Lock을 해제하고 Lightweight Lock(경량 락) 또는 Heavyweight Lock(중량 락)으로 전환
이때, Bias Locking Timestamp를 참고하여 얼마나 오랫동안 편향되어 있었는지를 기준으로 결정
즉, Bias Locking Timestamp는 JVM이 Bias Lock을 유지할지, 해제할지를 판단하는 기준이 되는 값이다.
인스턴스 데이터
객체의 실제 데이터(필드 값들)가 저장되는 공간.
객체의 모든 필드(primitive + reference types) 저장
부모 클래스의 필드도 함께 저장
저장 순서는 JVM의 필드 할당 전략에 따라 달라질 수 있음
JVM 옵션 -XX:FieldsAllocationStyle을 통해 필드 정렬 방식 변경 가능
핫스팟 JVM은 기본적으로 같은 크기의 필드를 묶어 정렬(padding을 최소화)하는 전략을 사용한다.
-XX:CompactFields=true 옵션을 설정하면 작은 필드를 빈 공간 없이 압축하여 저장
정렬 패딩
정렬 패딩은 메모리 정렬(alignment)을 맞추기 위해 추가되는 공간이다.
JVM은 8바이트 단위로 메모리를 정렬하는 경우가 많다.
인스턴스 데이터 크기가 8바이트 단위로 정렬되지 않는 경우, 패딩을 추가하여 정렬을 맞춘다.
CPU가 메모리에 빠르게 접근하도록 최적화하기 위한 기술.
3. 객체에 접근하기
자바 객체는 **참조(reference)**를 통해 접근한다. 참조 방식은 JVM 구현에 따라 다르지만, 핫스팟 JVM은 두 가지 방식을 제공한다.
핸들(Handle) 방식
핸들 방식에서는 힙(heap) 외부에 별도의 핸들 풀(handle pool)을 유지
핸들은 객체의 실제 주소가 아닌 핸들 풀의 주소를 저장하며, 핸들 풀은 객체의 실제 데이터 위치를 가리키는 포인터를 가진다.