자바 가상 머신에서의 멀티스레딩은 CPU 코어를 여러 스레드가 교대로 사용하는 방식으로 구현되기 때문에 특정 시각에 각 코어는 한 스레드의 명령어만 실행하게 된다.
스레드 전환 후 이전에 실행하다 멈춘 지점을 정확하게 복원하려면 스레드 각각에는 고유한 프로그램 카운터가 필요하다.
각 스레드의 카운터는 서로 영향을 주지 않는 독립된 영역에 저장된다. 이 메모리 영역을 스레드 프라이빗 메모리라고 한다.
스레드가 자바 메서드를 실행 중일 때는 실행 중인 바이트코드 명령어의 주소가 프로그램 카운터에 기록된다.
스레드가 네이티브 메서드를 실행 중이일 때 프로그램 카운터 값은 Unidefined다. '정의되지 않음' 이란 뜻이다.
프로그램 카운터 메모리 영역은 자바 가상 머신 명세 에서 OutOfMemoryError 조건이 명시되지 않은 유일한 영역이기도 하다.
쉽게말해 현재 수행중인 JVM 명령의 주소가 저장되는 영역이다.
네이티브 메서드 스택 (Native Method Stack)
자바 코드가 컴파일되어 생성하는 바이트 코드가 아닌 실제 실행할 수 있는 기계어로 작성된 프로그램을 실행시키는 영역 이다. (자바가 아닌 언어에서 제공되는 메소드 C, C++)
스택 (Stack)
스택 에서는 메서드 호출 시 지역 변수, 매개변수, 함수 호출내역 등이 저장되어 있는 영역이다.
각 스레드마다 개별적으로 생성되고, 메서드 호출 시 생성되었다가 메서드가 종료되면 사라진다.
JVM이 새 스택 프레임을 생성할 공간이 없는 상황이 발생하면 StackOverflowError를 던진다.
Stack 용량을 동적으로 확장할 수 있는 자바 가상 머신에서는 Stack을 확장하려는 시점에 여유 메모리가 충분하지 않다면 OutOfMemoryError를 던진다.
스택 프레임 (Stack Frame) - 호출되는 순간 스택에는 그 함수를 위한 영역이 할당된다. 이것을 스택 프레임(stack frame)이라고 한다. - 호출된 메서드의 매개 변수, 로컬 변수 및 메서드의 반환 주소, 즉 호출된 메서드가 반환된 후 메서드 실행이 계속 되어야 하는 지점을 포함한다.
자바 힙 (Heap)
자바 애플리케이션이 사용할 수 있는 가장 큰 메모리다.
자바 힙은 모든 스레드가 공유하며 가상 머신이 구동될 때 만들어진다.
참조형(Reference Type) 데이터 타입을 갖는 객체(인스턴스), 배열 등이 저장되는 공간 이다.
자바 힙은 가비지 컬렉터(GC)가 관리하는 메모리 영역이다.
Stack 과는 다르게 보관되는 메모리가 호출이 끝나더라도 삭제되지 않고 유지 된다.
어떤 참조 변수도 Heap 영역에 있는 인스턴스를 참조하지 않게 된다면, GC에 의해 메모리에서 청소된다.
메모리 할당 관점에서 자바 힙은 모든 스레드가 공유한다. 따라서 객체 할당 효율을 높이고자 스레드 로컬 할당 버퍼 여러개로 나뉜다.
자바 힙은 크기를 고정할 수도, 확장할 수도 있게 구현할 수 있다.
새로운 인스턴스에 할당해 줄 힙 공간이 부족하고 힙을 더는 확장할 수 없다면 OutOfMemoryError를 던진다.
메서드 영역
메서드 영역도 자바 힙처럼 모든 스레드가 공유한다.
JVM이 읽어들인 클래스와 인터페이스에 대한 런타임 상수 풀, 맴버 변수(필드), 클래스 변수(Static 변수), 상수(final), 생성자(constructor)와 메서드(method)등을 저장하는 공간 이다.
자바 힙과 마찬가지로 연속될 필요가 없으며, 크기를 고정할 수도 있고, 확장 가능하게 만들 수도 있다.
프로그램의 시작부터 종료가 될 때 까지 메모리에 남아 있는다.
Static 메모리에 있는 데이터들은 프로그램이 종료될 때 까지 어디서든 사용이 가능하지만 무분별하게 많이 사용할 경우 메모리 부족 현상이 일어날 수 있다.
메서드 영역이 꽉 차서 필요한 만큼 메모리를 할당할 수 없다면 OutOfMemoryError를 던진다.
https://www.baeldung.com/java-stack-heap
class Person {
int id;
String name;
public Person(int id, String name) {
this.id = id;
this.name = name;
}
}
public class PersonBuilder {
private static Person buildPerson(int id, String name) {
return new Person(id, name);
}
public static void main(String[] args) {
int id = 23;
String name = "John";
Person person = null;
person = buildPerson(id, name);
}
}
클래스 버전, 필드, 메서드, 인터페이스 등 클래스 파일에 포함된 설명 정보에 더해 컴파일타임에 생성된 다양한 리터럴과 심벌 참조가 저장된다.
자바 언어에서는 상수가 꼭 컴파일타임에 생성되어야 한다는 규칙은 없다. 즉, 상수 풀의 내용 전부가 클래스 파일에 미리 올라가있는건 아니다. 런타임에도 메서드 영역의 런타임 상수 풀에 새로운 상수가 추가될 수 있다. ( String클래스의 intern() 메서드에 바로 이 특성이 반영되어 있다.)
JVM이 클래스를 로드할 때 이러한 정보를 메서드 영역의 런타임 상수 풀에 저장한다.
런타임 상수 풀은 메서드 영역에 속하므로 메서드 영역을 넘어서까지 확장될 수는 없다. 그래서 상수 풀의 공간이 부족하면 OutOfMemoryError를 던진다.
다이렉트 메모리
JVM프로세스가 사용하는 네이티브 메모리 영역중 하나 이다.
다이렉트 메모리는 가상 머신 런타임에 속하지 않는다.
사용되는 모든 메모리 영역의 합이 물리 메모리 한계를 넘어서면 동적 확장을 시도할 때 OutOfMemoryError가 발생한다.
네이티브 메모리 (Native Memory) JVM 프로세스가 운영체제로부터 할당 받은 메모리 중 힙 메모리를 제외한 영역들을 통칭하며, 가비지 컬렉터의 관리 대상에서 제외된다.
핫스팟 가상 머신에서의 객체 들여다보기
객체 생성
자바에서 객체를 생성하는 과정을 순서대로 나열해보면
클래스 정의
먼저 객체의 틀이 되는 클래스를 정의 한다.
new 키워드 사용
객체를 생성하기 위해 'new' 키워드를 사용한다.
생성자 호출
'new' 키워드 다음에 클래스의 생성자를 호출한다.
기본 생성자가 없다면 명시적으로 정의해야 한다.
메모리 할당
JVM이 힙(Hep) 메모리에 객체를 위한 공간을 할당 한다.
할당된 메모리에는 객체의 인스턴스 변수들이 저장 된다.
메소드 영역에는 클래스의 정보와 static 맴버들이 저장된다.
객체 초기화
생성자가 실행되어 객체의 초기 상태를 설정 한다.
참조 변수에 할당
생성된 객체의 메모리 주소가 참조 변수에 저장 된다.
[클래스 정의]
|
v
[new 키워드 사용]
|
v
[JVM의 메모리 관리]
|
+------------------------+
| |
v v
[메소드 영역] [힙(Heap) 영역]
- 클래스 정보 저장 - 객체 인스턴스 생성
- static 멤버 저장 |
v
[객체 초기화]
1. 명시적 초기화
2. 인스턴스 초기화 블록
3. 생성자 실행
|
v
[참조 변수에 주소 할당]
|
v
[객체 사용 가능]
이 과정에서 주목할 점 - JVM은 가비지 컬렉션을 통해 더 이상 참조되지 않는 객체를 자동으로 메모리에서 제거한다. - 상속 관계에 있는 클래스의 경우, 부모 클래스의 생성자가 먼저 호출된 후 자식 클래스의 생성자가 호출된다. - 스레드 안전성을 고려해야 하는 경우, 동기화 메커니즘을 사용하여 여러 스레드에서의 객체 생성과 접근을 관리 해야 한다.
자바 힙이 완벽히 규칙적이라고 가정하면 사용 중인 메모리는 모두 한쪽에, 여유 메모리는 반대편에 자리하며, 포인터가 두 영역의 결계인 가운데 지점을 가리키게 될 것이다. 이 상태에서 메모리를 할당하면 포인터를 여유 공간 쪽으로, 객체 크기만큼 이동시키게 된다. 이러한 할당 방식을 포인터 밀치기(bump the pointer)라고 한다.
하지만 자바 힙은 규칙적이지 않다. 사용 중인 메모리와 여유 메모리가 뒤 섞여 있어 포인터를 밀쳐 내기가 그리 간단하지 않다.
객체 생성의 문제점
빈번한 발생:
자바 프로그램에서 객체 생성은 매우 자주 일어나는 작업이다.
멀티스레딩 환경의 위험:
여러 스레드가 동시에 메모리를 할당하려 할 때 충돌이 발생할 수 있다.
예: 스레드 A가 메모리 할당 중 스레드 B가 끼어들어 오류 발생 가능성이 있다.
해결 방법
메모리 할당 동기화
비교 및 교환(Compare-And-Swap, CAS) 방식 사용
작동 방식: a. 현재 값 확인 b. 새 값으로 교체 시도 c. 실패 시 재시도
장점: 메모리 할당을 원자적 작업으로 만들어 안전성 확보
스레드 로컬 할당 버퍼(Thread-Local Allocation Buffer, TLAB) 사용
각 스레드에게 힙 메모리의 일부를 전용으로 할당
작동 방식: a. 각 스레드가 자신의 TLAB에서 객체 생성 b. TLAB 공간 부족 시에만 새 버퍼 할당을 위해 동기화
장점
대부분의 객체 생성에서 동기화 불필요
스레드 간 간섭 최소화로 성능 향상
스레드 로컬 할당 버퍼 - TLAB TLAB는 멀티스레드 환경에서 객체 생성의 효율성을 크게 높이는 중요한 JVM 최적화 기술이다. 각 스레드가 자신만의 "작업장"을 가짐으로써, 대부분의 객체 생성 작업에서 다른 스레드와의 동기화 없이 빠르게 메모리를 할당받을 수 있게 된다.
주요 특징: 전용 메모리: 각 스레드는 자신만의 TLAB을 가진다. 빠른 할당: TLAB 내에서의 객체 생성은 매우 빠르다. 동기화 감소: 다른 스레드와의 경쟁 없이 메모리 할당이 가능하다.
작동 방식: 초기 할당: JVM이 각 스레드에 TLAB을 할당한다. 객체 생성: 스레드는 자신의 TLAB 내에서 객체를 생성한다. TLAB 고갈: TLAB 공간이 부족해지면, 새로운 TLAB을 할당받는다. 대형 객체: TLAB보다 큰 객체는 일반 힙 영역에 직접 할당된다.
장점: 성능 향상: 스레드 간 동기화 오버헤드를 크게 줄인다. 캐시 효율성: 지역성(locality) 향상으로 CPU 캐시 사용이 효율적이다. 예측 가능성: 메모리 할당 시간이 더 일정해진다.
단점: 메모리 단편화: 사용되지 않는 TLAB 공간이 생길 수 있다. 메모리 오버헤드: 각 스레드마다 별도의 공간을 할당하므로 전체 메모리 사용량이 증가할 수 있다.
TLAB 크기 조정: JVM은 TLAB 크기를 동적으로 조정할 수 있다. 너무 크면 메모리 낭비, 너무 작으면 빈번한 재할당으로 인한 오버헤드가 발생한다.
한줄요약:TLAB는 Java 힙 메모리 내에서 각 스레드에게 독점적으로 할당되는 작은 메모리 영역이다. 스레드 로컬 할당 버퍼 - TLAB
메모리 할당 후 과정
메모리 초기화
할당된 메모리를 0으로 초기화 (객체 헤더 제외)
TLAB 사용 시: TLAB 할당 시 미리 초기화
결과: 모든 인스턴스 필드가 기본값(0)을 가짐
객체 설정
객체 헤더에 중요 정보 저장:
클래스 정보
메타 정보 접근 방법
해시 코드
GC 관련 정보 (예: 세대 나이)
초기 상태
생성자 (<init>() 메서드) 실행 전 상태
모든 필드는 기본값(0) 상태
생성자 실행
new 명령어 후 <init>() 메서드 실행
개발자가 정의한 초기화 수행
객체 완성
생성자 실행 후 사용 가능한 상태로 완성
주요 포인트:
자바에서 인스턴스 필드를 초기화하지 않고 사용할 수 있는 이유: JVM이 자동으로 0으로 초기화하기 때문
TLAB 사용의 이점: 메모리 초기화를 미리 수행하여 객체 생성 속도 향상
객체 생성의 완성: 메모리 할당 및 초기화 후 생성자 실행까지 완료되어야 함
객체 헤더
객체의 런타임 데이터를 포함한다.
해시코드, GC세대 나이, 상태 플래그, 스레드가 점유하고 있는 락들, 편향된 스레드의 아이디 등.
자바 배열의 경우 배열 길이도 객체 헤더에 저장한다.
인스턴스데이터
객체가 실제로 담고있는 정보다.
프로그램 코드에서 정의한 다양한 타입의 필드 관련 내용, 부모 클래스 유무, 부모 클래스에서 정의한 모든 필드가 이부분에 기록된다.
핫스팟 가상 머신은 기본적으로 long/double, int, short/char, byte/boolean, 일반 객체 포인터 순으로 할당 한다.
정렬 패딩
선택적으로 존재할 수 있다..
특별한 의미 없이 자리만 차지한다.
목적: 객체의 시작 주소를 8바이트의 정수배로 맞추기 위함이다.
정리:JVM은 객체를 효율적으로 관리하고 접근할 수 있다. 객체 헤더는 런타임 정보를, 인스턴스 데이터는 실제 객체 내용을, 정렬 패딩은 메모리 정렬을 위해 사용된다.
객체에 접근하기
대다수 객체는 다른 객체 여러 개를 조합해 만들어진다. 그리고 자바 프로그램은 스택에 있는 참조 데이터를 통해 힙에 들어있는 객체들에 접근해 이를 조작한다.
핸들 방식
객체에 대한 간접 참조 방식이다. 객체 참조가 핸들을 가리키고, 핸들이 실제 객체를 가리킨다.
특징: 스택의 참조가 핸들을 가리키고, 핸들이 실제 객체를 가리킨다.
장점: 객체 이동 시 핸들 내 포인터만 수정하면 된다.
단점: 접근 시 한 단계를 더 거쳐야 한다.
다이렉트 포인터 방식
객체에 대한 직접 참조 방식이다. 참조가 객체의 실제 메모리 주소를 직접 가리킨다.
특징: 스택의 참조가 직접 객체를 가리킨다.
장점: 객체 접근이 더 빠르다.
단점: 객체 이동 시 모든 참조를 수정해야 한다.
정리:핸들 방식은 객체 이동에 유리하지만 접근 속도가 느리다. 다이렉트 포인터 방식은 접근 속도가 빠르지만 객체 이동 시 모든 참조를 수정해야 한다. Java에서는 객체 접근이 빈번하므로, 대부분의 현대 JVM은 다이렉트 포인터 방식을 사용한다.
좀더 비교해보자... 성능: 다이렉트 포인터 방식이 일반적으로 더 빠르다. 자바에서는 객체 접근이 매우 빈번하기 때문에 이 차이가 중요하다. 메모리 관리: 핸들 방식은 객체 이동에 유리하지만, 추가 메모리가 필요하다. 다이렉트 포인터 방식은 메모리를 더 효율적으로 사용하지만, 객체 이동이 복잡하다. 구현 복잡성: 핸들 방식은 구현이 더 단순할 수 있다. 다이렉트 포인터 방식은 가비지 컬렉터 구현이 더 복잡할 수 있다.
자바 힙 오버플로
자바 힙은 객체 인스턴스를 저장하는 공간이다. 객체를 계속 생성하고 그 객체들에 접근할 경로가 살아 있다면 언젠가는 힙의 최대 용량을 넘어설 것이다. 그러면 메모리가 오버플로 된다.
해결 방법
힙 덤프 분석:
메모리 이미지 분석 도구를 사용한다.
힙 덤프 스냅샷을 통해 문제를 파악한다.
메모리 누수 확인:
오버플로를 일으킨 객체의 필요성을 검토한다.
불필요한 객체 존재 시 메모리 누수로 판단한다.
메모리 누수 해결:
GC 루트까지의 참조 사슬을 분석한다.
누수 객체의 타입 정보와 참조 경로를 확인한다.
문제 코드의 정확한 위치를 찾아 수정한다.
진짜 오버플로 해결:
JVM의 힙 매개변수 설정을 검토한다.
가용 메모리를 확인하고 필요시 증가시킨다.
코드 최적화:
객체 수명 주기 검토
불필요한 상태 유지 제거
효율적인 데이터 구조 사용
메모리 사용 최소화:
코드에서 수명 주기가 너무 길거나 상태를 너무 오래 유지하는 객체는 없는지, 공간 낭비가 심한 데이터 구조를 쓰고 있지는 않은지 살펴 프로그램이 런타임에 소비하는 메모리를 최소로 낮춘다.
가상 머신 스택과 네이티브 메서드 스택 오버플로
가상 머신 스택과 네이티브 메서드 스택에서는 두 경우 예외가 발생한다.
스레드가 요구하는 스택 깊이가 가상 머신이 허용하는 최대 깊이보다 크면StackOverflowError를 던진다.
가상 머신이 스택 메모리를 동적으로 확장하는 기능을 지원하나, 가용 메모리가 부족해 스택을 더 확장할 수 없다면OutOfMemoryError를 던진다.
댓글