저우즈밍(周志明) 저, "JVM 밑바닥까지 파헤치기"를 기반으로 작성한 글입니다.
실행 환경은 Windows + Ubuntu 22.04.05 LTS 기반으로 진행합니다.
1. Overview
📌 JVM Specification
JVM 명세에 따르면, JVM은 자바 프로그램을 실행하는 동안 필요한 메모리를 여러 데이터 영역으로 나누어 관리한다.
이 영역들은 목적과 라이프사이클에 따라 영역을 구분한다.
📌 세대별 컬렉션 이론(Generational Collection Theory)
💡 책에서 계속 언급은 되는데, 나중에 점진적으로 자세하게 다룬다고 나와있어서 개인적으로 조사하여 정리한 내용입니다.
이해하면 아래 내용들을 직관적으로 받아들이는데 보다 도움이 되지만, 쉬운 개념은 아닙니다.
세대별 컬렉션 이론이란 대부분 객체는 얼마 지나지 않아 사용하지 않는다는 점과 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다는 통계학적인 분석에 기반한 이론으로써,
메모리 회수 관점에서 대다수 현대적인 GC들이 이를 기초로 설계되었다.
이 이론에는 다음과 같은 영역으로 구분한다.
- 신세대(new generation)
- 구세대(old generation)
- 영구 세대( permanent generation)
- 에덴 공간(eden space)
- 생존자 공간에서부터(from survivor space)
- 생존자 공간으로(to survivor space)
💡 GC가 반드시 세대별 컬렉션 이론을 따라야한다는 것은 아니다.
오늘 날에 "JVM의 Heap memory는 신세대, 구세대, 영구 세대, 에덴, 생존자 공간 등으로 나뉜다"라고 설명하면 틀린 말이다.
현대의 GC들은 이보다 더 진보되어 있으며, 심지어 이 원칙을 철저하게 준수하던 핫스팟 JVM 마저도 JDK8에 이르러 영구 세대라는 용어를 완전히 삭제해버렸다.
이는 일반적인 설계 방식을 이야기할 뿐, 반드시 이 형태로 메모리를 구성해야 한다는 의미가 아니다.
GC 이야기가 나오면 반드시 따라오는 키워드이자, 이후 GC의 주 메모리 관리 영역인 힙(Heap) 영역을 이야기할 때 자주 등장한다.
모든 내용은 네이버 D2 기술 블로그에 나온 내용을 참고했다. (11년도에 작성된 글이라 틀린 내용이 있긴 합니다.)
여기선 개념만 정의하고 상세 동작을 설명하진 않을 예정이라, 궁금하면 위 링크를 확인하는 것이 좋다.
- 신세대(New Generation):
- 새로 생성되거나, 생성된지 얼마 안 되는 객체들이 위치하는 공간
- 대부분의 객체가 신세대에서 메모리 할당이 해제(Minor GC)된다.
- 내부적으로 에덴과 생존자 2개 영역, 총 3개 영역을 지닌다.
- 에덴 영역(Eden)
- 가장 처음 객체가 메모리에 할당(Allocate)되어 생성되는 공간
- GC가 한 번 발생한 후 살아남은 객체는 Survivor 영역 중 하나로 이동한다.
- 생존자 영역(Survivor)
- 두 개의 영역(from, to)을 가지며, 둘 중 하나는 반드시 비어 있는 상태로 남아있어야 한다.
- GC가 발생할 때마다, 살아남은 Survivor 객체와 Eden의 객체들이 반대쪽 생존자 공간으로 이동한다. (이건 확실하지 않은 내용. 네이버 기술 블로그에서는 생존자 공간이 꽉 찼을 때 이동한다고 설명하고 있다. 추후 사실을 알게 되면 수정할 예정.)
- 여러 번 생존에 성공한 객체는 구세대(Old Generation)로 승격(Promotion)된다.
- 에덴 영역(Eden)
- 구세대(Old Generation)
- 신세대에서 오래 살아남은 객체들의 정보가 복사되어 있는 공간
- 신세대에 비해 큰 메모리를 할당받으며, GC는 적게 발생한다. (여기서는 Major GC 혹은 Full GC가 발생한다고 말한다.)
- 구세대에서 신세대 영역의 객체를 참조할 때는 구세대 영역의 카드 테이블(card table)을 사용한다.
- 512 bytes chunk로 구성 (현재는 어떤지 모름. 11년도 정보 기준)
- 구세대 영역에서 참조하고 있는 신세대 객체 정보를 담고 있음.
- 신세대에서 GC를 실행할 때, 구세대의 카드 테이블만 읽어서 GC 대상을 빠르게 식별.
- 영구 세대(Permanent Generation)
- 고정 크기 메모리 공간 (-XX:MaxPemSize 매개변수로 지정. 지정하지 않아도 기본 값으로 고정됨.)
- 객체나 억류(intern)된 문자열 정보를 저장.
- 구세대 영역과 아무런 관련이 없다. GC가 발생할 수도 있으며, 영원히 객체가 남아 있는 곳도 아니다.
- JDK 8 이후로 Metaspace로 대체되었다. (더 자세히 설명하면 너무 깊어져서, "메서드 영역"을 이야기할 때 다시 이야기할 예정.)
2. The pc Register
📌 def
- CPU의 pc register가 하는 일과 비슷
- 작은 메모리 영역으로, 현재 실행 중인 스레드의 바이트코드 줄 번호 표시기 역할 수행
- JVM 개념 모형에선 bite code interpreter가 이 카운터 값을 바꾸어 다음에 실행할 bite code instruction를 선택
- 각 Thread의 counter는 독립된 영역(thread private memory)에 저장된다.
- Multi-Thread는 CPU 코어를 여러 Thread가 교대로 사용하는 방식으로 구현되어 있기 때문
- 임의 순간에 각 코어는 한 Thread의 instruction만을 수행하므로, context-switching이 발생해도 이전의 작업을 정확하게 복원할 수 있기 위함.
- thread가 자바 메서드를 실행 중일 때는 실행 중인 bite code instruction 주소가 pc counter에 기록된다.
- 단, thread가 native method를 실행 중일 때는 pc counter 값이 Undefined가 된다.
- OutMemoryError 조건이 명시되지 않은 유일한 영역
3. Java Virtual Machine Stacks
📌 def
- 자바 메서드를 실행하는 Thread의 메모리 모델을 설명
- 각 메서드가 호출될 때마다 JVM은 Stack Frame을 만들어 지역 변수 테이블, 피연산자 스택, 동적 링크, 메서드 반환값 등의 정보를 저장한다.
- 이 Stack Frame을 JVM Stacks에 push하고, 메서드가 끝나면 pop하는 일을 반복한다.
- thread-private하며, life-cycle이 연결된 thread와 완전히 동일
- 두 가지 오류가 발생할 수 있도록 정의
- StackOverflowError: Thread가 요청한 Stack 깊이가 VM이 허용하는 깊이보다 클 때
- OutOfMemoryError: Stack 용량을 동적으로 확장하려는 시점에 여유 메모리가 충분하지 않을 때
📌 Local Variation Slot
💡 Java 메모리 영역을 Heap과 Stack으로 구분하는 것은 한계가 있다.
이는 전통적인 C, C++ 프로그램 메모리 구조에서 기인한 것으로, Java의 메모리 영역은 훨씬 복잡하다.
다만, 여기서 말하는 "스택"은 JVM Stacks를 가르키는 경우가 많고, 그 중 특히 지역 변수 테이블(Local Variation Table)을 가리킬 때가 많다.
- 지역 변수 테이블: JVM이 컴파일 타임에 알 수 있는 다양한 기본 정보 저장 (필요 데이터 공간은 컴파일 과정에서 할당)
- 데이터 타입: boolean, byte, char, short, int, float, long, double
- 객체 참조
- 반환 주소 타입: bite code instruction의 주소
- 지역 변수 슬롯: 지역 변수 테이블에서 이 데이터 타입들을 저장하는 공간
- 일반적으로 slot 하나 당 32 bits (64 bits 데이터는 slot 2개를 차지)
- VM의 variation slot 구현 방법에 따라, slot 하나의 크기는 완전히 달라질 수 있다. (따라서, 컴파일 타임에 할당받은 공간의 크기는 slot 개수를 의미한다.)
4. Native Method Stacks
📌 def
- JVM Stacks와 비슷한 역할을 하지만, 대상이 다르다.
- JVM Stacks: 자바 메서드(bite code)를 실행할 때 사용
- Native Method Stakcs: 네이티브 메서드(ex. C/C++)를 실행할 때 사용
- JVM 명세에 어떤 구조로 만들어야 하는지 명시하지 않았다.
- 그래서 Hotspot JVM도 JVM Stacks와 Native Method Stacks를 합쳐놓았다.
- JVM Stacks와 마찬가지로 StackOverflowError, OutOfMemoryError를 던질 수 있다.
5. Java Heap (GC Heap)
📌 def
- 자바 애플리케이션이 가용한 메모리 중 가장 큼
- 모든 스레드가 공유하며, 가상 머신이 구동될 때 만들어짐
- 내부적으로 스레드 로컬 할당 버퍼를 여러개로 나누긴 하는데, 오직 메모리 할당/회수 효율을 높이기 위함일 뿐, 결국 데이터가 Heap에 저장되는 사실은 변함이 없다.
- 유일한 목적은 객체 인스턴스를 저장하는 것. ("거의" 모든 객체 인스턴스가 여기에 할당)
- 책에서 "거의"라고 쓴 이유는 자바 언어가 계속 발전하면서 값 타입도 지원할 것으로 보이기 때문이라고 함.
- GC가 관리하는 메모리 영역 (그래서 GC Heap이라고 부르기도 한다.)
- 메모리가 물리적으로는 떨어져도, 논리적으로는 연속되어야 한다.
- 그러나 웬만하면 대다수 VM이 큰 객체(ex. 배열 객체)는 물리적으로연속된 메모리 공간을 사용하도록 구현한다.
- 그래야 저장 효율과 구현 로직을 단순하게 유지할 수 있다.
- Heap 크기를 동적으로 조절할 수 있다. (-Xmx, -Xmx 매개 변수로 조절)
- 더 이상 객체를 할당해줄 수 없거나, 확장할 수 없을 때 OutOfMemory를 던진다.
6. Method Area (non-heap)
📌 def
- VM이 읽은 클래스 메타데이터, 메서드 코드, 타입 정보, 상수, 정적 변수, (JIT 컴파일러가 제공하는)코드 캐시 등을 저장
- 그래서인지, 이걸 Class Area라고 지칭하는 곳도 심심치 않게 찾아볼 수 있다. JVM 명세에는 분명하게 "Method Area"라고 적혀있다.
- 모든 스레드가 공유하며, 논리적으로는 힙의 한 부분 (구분하기 위해서 non-heap이라고 부르기도 함)
- 연속될 필요가 없으며, 크기를 고정할 수도 있고 확장할 수 있어도 되며, GC를 사용하지 않아도 된다. (제약이 거의 없다)
- 메모리에 공간이 없으면 OutOfMemoryError를 던짐.
📌 The Reason Why The Method Area Moved to Native Memory
메서드 영역을 영구 세대(PermGen)과 혼동하는 사람이 많은데(물론 난 두 단어를 이번에 처음 들었다), 여기엔 역사적인 이유가 존재한다.
- 핫스팟 JVM에서 GC 관리 대상을 Heap에서 Method Area까지 확장하기로 결정
- 즉, Method Area를 PermGen 영역에 구현하게 되었다. (핫스팟 JVM만)
- 통합된 메모리 관리, 클래스 언로딩 용이성, 동적 최적화 등의 이유로 관리할 수 있다고 생각했기 때문
- "모든 것을 JVM이 관리한다"라는 JVM 철학과 연결됨
- 하지만 다른 가상 머신에는 애초에 PermGen이라는 개념 자체가 없었음. (강제 사항이 아니기 때문)
- 여기서부터 이미 "메서드 영역 ≠ 영구 세대"
- 문제는 Method Area가 PermGen 영역에 포함되면서 OOM 발생 확률이 증가함.
- PermGen 영역은 고정 크기 영역
- Method Area까지 포함되면서, 이 크기를 초과하기가 더 쉬워졌기 때문
- 애초에 Method Area는 대부분 Constant Pool과 Type 정보들이라서 회수 효과가 매우 작기 때문에, GC 오버 헤드만 증가시킴 (물론 영구적인 데이터는 아니며, 회수 대충하면 메모리 누수 일으켜서 관리가 필요하긴 함.)
- JDK 6부터 Method Area를 Native Memory로 옮길 계획을 세우기 시작함
- Native Memory: JVM이 OS에게 할당받은 영역으로, GC가 아닌 OS 메모리 관리 정책을 따르게 됨.
- JDK 7에 문자열 상수, 정적 변수 등의 정보를 Java Heap으로 옮김.
- JDK 8에 PermGen 개념을 완전히 지우고, 남아 있던 모든 데이터들(주로 타입 정보)을 Metaspace로 옮김
📌 Metaphor
💡 위 내용을 처음 이해하는 게 너무 어려워서, 비유적으로 설명해놓은 파트입니다.
필요 없다면 지나쳐도 무방하며, 틀릴 수 있습니다.
여기서 주체는 다섯 개다.
- 아파트 단지(Heap)
- 관리사무소(Method Area)
- 관리사무소에서 관리하는 정보(Metadata)
- 아파트 주민(Instance)
- 아파트 관리인(Garbage Collector): 아파트 단지 주민들의 입주와 퇴거를 관찰하고 관리한다.
관리사무소(Method Area)에서는 다음과 같은 정보를 관리한다고 가정해보자.
- 각 세대의 평면도 (클래스 구조 정보)
- 입주민 명단과 차량 등록 정보 (메서드와 필드 정보)
- 관리비 납부 내용 (상수 풀)
- 시설물 사용 규칙 (JIT 컴파일러 최적화 정보)
- 공용 시설 관리 메뉴얼 (클래스 로더 정보)
핫스팟 JDK의 논리에 따르면, 아파트 관리인(GC)이 관리사무소까지 관리하도록 만든 셈이다.
그런데 잘 생각해보면, '이게 정말 효율적인가?'에 대한 의문이 든다.
- 아파트 단지(Heap)는 고정 크기인데, 관리사무소(Method Area)가 단지 내로 들어오면서, 주민(Instance)들의 거주 공간(memory)이 줄어듦.
- 아파트 관리인(GC)이 거의 변경될 일이 없는 정보들(평면도, 시설물 사용 규칙 등)을 추가로 관리해야 함.
- 그 와중에 관리비 납부 내용은 지속적으로 누적
- 아파트 주민(Instance)들은 자주 이사(생성/소멸)를 오고 갈 수 있지만, 관리사무소 정보는 그렇지 않음. (관리인이 여길 추가로 관리해봐야, 큰 이득이 없다.)
그래서 관리사무소(Method Area)를 지자체(OS)가 운영하도록 외부로 옮겨버린 것이다.
비유가 이상해보이겠지만, 모든 것을 JVM이 관리하는 것을 포기한 것이나 다름없으니 완전히 틀린 말도 아니다. (사실 내가 이해한 게 그렇고, 틀렸을 수 있다.)
7. Run-time Constant Pool
📌 def
- 메서드 영역의 일부
- 클래스를 로드할 때 상수 풀 테이블 정보를 런타임 상수 풀에 저장
- 상수 풀 테이블에는 클래스 버전, 필드, 메서드, 인터페이스, 컴파일 타임에 생성된 다양한 리터럴과 심벌 참조 등이 저장되어 있다.
- 클래스 파일은 상수 풀을 포함해 각 영역별로 엄격한 규칙이 정의되어 있다.
- 런타임 상수 풀은 상세한 요구사항이 없어서, VM 개발자 입맛에 맞게 개발 가능
- 일반적으론 런타임 상수 풀 또한 심벌 참조와 심벌 참조로부터 번역된 직접 참조 역시 저장한다.
- 동적으로 값이 할당 (런타임에도 메서드 영역의 런타임 상수 풀에 새로운 상수가 추가될 수 있다.)
- 메서드 영역을 넘어서 확장될 수 없기에, 공간이 부족하면 OOM 에러를 던진다.
8. Direct Memory
📌 def
JVM 명세에도 없고, 런타임에도 속하지 않지만 책에 있길래 소개.
책에는 이렇게 적혀있다.
- "JDK 1.4에서 도입된 NIO(힙이 아닌 메모리를 직접 할당할 수 있는 네이티브 라이브러리를 이용)의 메모리에 저장되어 있는 DirectByteBuffer 객체를 이용하면, Java Heap과 Native Heap 사이의 데이터를 복사해 주고받지 않아도 된다."
- 그러나 하부 기기의 총 메모리 용량과 프로세서가 다룰 수 있는 주소 공간을 넘을 수 없으므로, 메모리 관리할 때 주의해야하며, OOM 에러의 원인이 될 수도 있다.
뭔 소린가 싶긴 한데, 조금만 생각해보면 그렇게 어려운 내용이 아니다.
일단 이걸 의식 중에 사용하는 경우는 잘 없겠지만, 라이브러리나 프레임워크를 통해서 무의식 중에 사용하게 될 수도 있다.
Spring의 대용량 파일 업로드, Redis 클라이언트, Netty 기반 라이브러리 등등
(사실 이것들이 정말 이렇게 동작하는 지는 확실하지 않다. 찾는 데 시간이 너무 오래 걸려서 거의 절반은 짐작이다.)
문제는 이런 I/O를 Heap 메모리를 사용한다고 생각해보자.
- Disk에서 Data를 가져온다.
- JVM Heap에서 Data를 임시 보관한다.
- 다시 다른 Disk로 Data를 옮긴다.
여기서 Data는 두 번 이동하며, JVM Heap까지 경유해간다.
NIO의 DirectBuffer는 힙이 아닌 메모리를 직접 할당(JVM이 아니라, OS로부터 직접 메모리를 받아 옴)할 수 있으므로, 이 단계를 줄일 수 있다.
- Disk 바로 옆에 Direct Memory를 만들어서 Data 저장
- Data를 Direct Memory에서 다른 Disk로 이동
이렇게 하면 중간 단계가 생략될 수 있으므로, 일부 시나리에서 성능이 크게 향상될 수 있다는 것이다.
Physical Memory를 직접 할당하니까 Java Heap과 관련은 없지만, 어쨌든 메모리라서 애플리케이션이 실행 중인 기기의 메모리 용량을 넘어서면 안 된다는 것.
그런데 종종 서버 관리자들이 -Xmx 등으로 Heap 영역 크기 설정할 때, Direct Memory를 무시하는 바람에 OOE가 발생하는 경우가 있다는 내용.