요구사항
- 수백만 개의 AI 대화 임베딩 데이터(실수형 벡터)를 자바 Double[], double[] 배열에 적재 해 각각의 힙 메모리 점유율 및 소켓 전송 시 CPU 캐시 미스 비용 비교 분석
문제 정의
- double Primitive Type, Double Wrapper Type 을 각각 물리적 메모리를 얼마나 사용하는지, 하드웨어 레벨에서 어떤 성능 차이를 나타내는지 검증하고, 기록 해보자
공통 구성
- VECTOR_COUNT (메모리에 적재할 대화 문장의 개수) = 100_000
- DIMENSION (대화 문장 1개의 실수형 숫자 개수) = 768
PrimitiveEmbeddingMemoryTest
public class PrimitiveEmbeddingMemoryTest {
private static final int VECTOR_COUNT = 100_000;
private static final int DIMENSION = 768;
private static double[][] embeddings;
public static void main(String[] args) throws IOException {
System.out.println("double[][] primitive 임베딩 메모리 테스트");
System.out.println("VECTOR_COUNT = " + VECTOR_COUNT);
System.out.println("DIMENSION = " + DIMENSION);
waitForVisualVm("Before allocation");
embeddings = createPrimitiveEmbeddings();
waitForVisualVm("After allocation");
System.out.println("Done. embeddings length = " + embeddings.length);
}
private static double[][] createPrimitiveEmbeddings() {
Random random = new Random(42);
double[][] result = new double[VECTOR_COUNT][];
for (int i = 0; i < VECTOR_COUNT; i++) {
double[] vector = new double[DIMENSION];
for (int j = 0; j < DIMENSION; j++) {
vector[j] = random.nextDouble();
}
result[i] = vector;
}
return result;
}
private static void waitForVisualVm(String phase) throws IOException {
System.out.println();
System.out.println("[" + phase + "]");
System.out.println("VisualVM에서 힙 상태를 확인한 뒤 Enter를 누르세요.");
System.in.read();
}
}


위 사진을 보면 위 메모리 증가의 이유를 알 수있다, 테스트 코드에서 생성한 double[][] 2차원 배열이 생성 되었다
double[][]
- Count : 1
- Size : 400,016B
이렇게 가지고 있는 이유는 주소는 4B 를 가지고, VECTOR_COUNT가 100,000개 여서 단순 곱셈으로 400,000이 나와야 하지만 16B를 초과하고 있는 모습이다. 이 부분은 자바에서 new 연산자로 생성되는 모든것은 객체이기 때문에 JVM은 이 double[][]이라는 최상위 배열 객체를 관리하기 위해, 포인터 데이터 10만 개를 배치하기 전에 맨 앞 공간에 딱 16바이트만큼의 관리용 메타데이터를 붙인다고 한다.
이때 배열 아닌경우 배열 길이 정보는 제외 될 수 있다.
- 마크 워드 (Mark Word) (8 바이트)
- 객체의 아이덴티티(해시코드), 가비지 컬렉션(GC) 나이, 락(Lock) 상태 등 자바 시스템이 객체를 제어하기 위한 핵심 메타데이터가 저장됩니다.
- 클래스 포인터 (Klass Pointer) (4 바이트)
- 이 객체가 자바의 어떤 클래스 정보(double[][])를 가리키고 있는지 알려주는 거울 역할을 합니다.
- 배열 길이 (Array Length) (4 바이트)
- 일반 객체 헤더(12바이트)와 달리, 배열 객체에만 특별히 붙는 필드입니다. 배열의 방이 총 몇 개인지(length = 100,000)를 기억하는 공간입니다. 이 값이 4바이트(32비트)이기 때문에 자바 배열의 최대 크기가 약 21억 개로 제한되는 것입니다.
double[]
- Count : 100,007
- Size : 616,000,680B
Count는 1차원 배열을 VECTOR_COUNT 만큼 생성했기 때문이다.
Size는 double[] 배열 1개의 크기는 double (8B) * 768 = 6,160 B 순수 데이터 (6,144B + 배열 헤더 16B) 로 단순히 VECTOR_COUNT * 6,160 B 를 한 값이다.
여기서 왜 7개가 더 있지라고 생각을 해서 찾아본 결과

이 사진에서 볼 수 있듯이 items가 37 부터 시작하는 값이 나타는걸 알 수 있다, 요거는 실행되는 코드에서 double 배열을 내부적으로 생성해서 사용하고 있는것으로 내 생각은 100,000 개만 있어야 한다고 생각 했지만 사소한 오차 인것 같다.
여기서 items가 0인 경우에 size는 16B 인것을 알 수 있어서 관리용 메타데이터의 사이즈인것으로 생각 했다.
Primitive 타입의 배열은 총 객체는 100,001개 생성하는것으로 GC로 인한 프리징 현상을 방지해 시스템의 부하를 최소화 할 수 있다.
워크플로우
1. new double[100,000][] (렌덤 메모리 위치에 생성) 미리 100,000개의 방을 미리 잡아둔다
2. 그 후 루프를 돌면서 인덱스에 new double[768](렌덤 메모리 위치에 생성) 미리 768개의 방을 잡아둔다
3. 2차원 배열에는 렌덤 메모리 위치에 생성된 1차원 배열 주소를 저장한다.
4. 1차원 배열에는 double 값을 별도의 new 없이 값을 삽입 할 수 있다.
1차원 배열의 내부 값은 실제 값
WrapperEmbeddingMemoryTest
public class WrapperEmbeddingMemoryTest {
private static final int VECTOR_COUNT = 100_000;
private static final int DIMENSION = 768;
private static Double[][] embeddings;
public static void main(String[] args) throws IOException {
System.out.println("Double[][] wrapper 임베딩 메모리 테스트");
System.out.println("VECTOR_COUNT = " + VECTOR_COUNT);
System.out.println("DIMENSION = " + DIMENSION);
waitForVisualVm("Before allocation");
embeddings = createWrapperEmbeddings();
waitForVisualVm("After allocation");
System.out.println("Done. embeddings length = " + embeddings.length);
}
private static Double[][] createWrapperEmbeddings() {
Random random = new Random(42);
Double[][] result = new Double[VECTOR_COUNT][];
for (int i = 0; i < VECTOR_COUNT; i++) {
Double[] vector = new Double[DIMENSION];
for (int j = 0; j < DIMENSION; j++) {
vector[j] = random.nextDouble();
}
result[i] = vector;
}
return result;
}
private static void waitForVisualVm(String phase) throws IOException {
System.out.println();
System.out.println("[" + phase + "]");
System.out.println("VisualVM에서 힙 상태를 확인한 뒤 Enter를 누르세요.");
System.in.read();
}
}


Double[][]
- Count : 1
- Size : 400,016B
이 부분은 Primitive 타입과 같이 100,000개의 주소값 + 메타 데이터의 크기를 가지고 있다.
Double[]
- Count : 100,000
- Size : 308,800,000B
Primitive 타입과 다르게 Size의 값이 절반으로 준걸 알 수 있다, 여기서도 주소값을 담기 때문에 4B로 계산 되기 때문이다
Double
- Count : 76,800,001
- Size : 1,843,200,024B
Primitive 타입과 다르게 1차원 배열에 바로 값이 들어가는것이 아닌 new Double로 생성한 주소값이 들어가는걸 알 수 있다. 단순히 100,000 * 768을 하면 알 수 있다.
Double 의 크기는 24B로 메타데이터 (배열 길이 정보 제외, 12B) + double (8B) + 8바이트 정렬 패딩 (4B) 총 24B가 된다.

new 으로 생성되기 때문에 객체의 내부 정보를 담는 메타데이터 12B와 추가 8바이트 정렬 패딩으로 4B가 추가 되었기 때문에 Primitive 타입과 다르게 16B의 오버헤드가 발생하게 된다.
워크플로우
1. new Double[100,000][] (렌덤 메모리 위치에 생성) 미리 100,000개의 방을 미리 잡아둔다
2. 그 후 루프를 돌면서 인덱스에 new Double[768](렌덤 메모리 위치에 생성) 미리 768개의 방을 잡아둔다
3. 2차원 배열에는 렌덤 메모리 위치에 생성된 1차원 배열 주소를 저장한다.
4. 1차원 배열에는 new Double (렌덤 메모리 위치에 생성) 로 생성한 주소의 값을 저장한다.
1차원 배열의 내부 값은 실제 값의 위치를 가리키는 주소값
소켓 전송 시 CPU 캐시 미스 비용 비교 분석
1. CPU 캐시 미스 측정 방법은 인텔리제이의 프로파일러 event=cpu,interval=1000000 옵션으로 간접적으로 측정
- event=cpu 옵션은 단순히 시간이 얼마나 걸렸는지 재지 말고, CPU 코어가 명령어(Instructions)를 처리하고 사이클을 소모한 '물리적 작업량'을 기준으로 코드를 쪼개서 보여달라"고 엔진을 바꾸는 옵션
- interval=1000000 옵션은 100만) 사이클이 소모될 때마다 딱 한 번씩만 스냅샷을 찍어서 나한테 보고해라"라고 CPU에게 지시하는 샘플링 주기(Sampling Interval) 옵션
이 라인의 값을 비교
sendWrapperData(out, wrapperData, buffer);
SocketServerPrimitiveBenchmark (Primitive 타입 탐색)

버퍼에 값을 추가 하는 라인 부분은 배열의 인덱스 기준 바로 값이 들어있기 때문에 탐색이 빠르다.
buffer.putDouble(data[i][j])
SocketServerWrapperBenchmark (Wrapper 타입 탐색)

Wrapper 타입인 경우
buffer.putDouble(data[i][j])
버퍼에 값을 추가할 때 배열에 있는 참조를 따라가기 때문에 시간이 오래 걸린다는것을 알 수 있다.
2. Apple Instruments 사용해 내 컴퓨터에서 일어나는 CPU 캐시 미스 비교 분석
- Store Micro-operations (Speculative) :
CPU가 연산한 결과 데이터를 레지스터에서 메모리(캐시/RAM)로 저장(Store)하라고 명령한 총 횟수 - Load Micro-operations (Speculative) :
CPU가 연산을 처리하기 위해 캐시나 RAM에서 데이터를 레지스터로 읽어오려고(Load) 시도한 총 횟수 - L1D Store Misses (Speculative) :
데이터를 저장하려고 L1 데이터 캐시를 조회 했지만, 해당 주소가 캐시에 없어서(Miss) 하위 레이어로 밀려난 횟수 - L1D Load Misses (Speculative) :
CPU가 데이터를 읽으려고 L1 데이터 캐시를 뒤졌으나 실패한 횟수 - L1D Cache Writebacks (Speculative) :
새로운 데이터를 캐시에 올리기 위해, 기존 캐시방에 있던 '수정된 데이터(Dirty Data)'를 지우지 않고 하위 레이어인 L2 캐시로 안전하게 내려보낸 횟수
SocketServerPrimitiveBenchmark (Primitive 타입 탐색)


SocketServerWrapperBenchmark (Wrapper 타입 탐색)


두 방식의 L1D Load Misses (Speculative) 값을 보면 Primitive 타입 보다 Wrapper 타입이 2.5배 정도 높은걸 알 수 있다. CPU가 데이터를 읽기 위해서 L1 캐시를 뒤졌지만 실패한 횟수가 많아지면 그 다음 위치인 L2 ~ RAM 까지 내려가 데이터를 찾게되기 때문에 병목지점이 된다.
이런 문제는 위에서 설명했듯이 new Double() 로 생성하게 되어 렌덤하게 메모리에 적제되고 이것이 배열의 값으로 들어가게 될때 본 데이터가 있는 주소 값을 넣기 때문에 탐색을 할때 배열에 들어가 있는 주소는 연속적이지만, 실제 값을 찾기 위해 한번더 메모리 점프를 하는 문제가 있기 때문에 시간과, 메모리를 많이 차지한다.
'java' 카테고리의 다른 글
| Platform Thread vs Virtual Thread의 AI I/O 블로킹 효율 비교 (0) | 2026.07.04 |
|---|---|
| RAG(검색증강생성)를 위한 코사인 유사도(Cosine Similarity) 고속 연산 (0) | 2026.07.03 |
| Thread-Per-Connection 소켓 폭발 및 락 병목 스레드 덤프 분석 (0) | 2026.06.26 |