Skip to main content

Command Palette

Search for a command to run...

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Updated
14 min read

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요.

자바를 쓰다 보면 객체를 정말 아무렇지 않게 만들어요. 반복문 안에서 new ArrayList<>()를 수천 번 호출해도 별 고민이 없죠. C나 C++에서 malloc을 그렇게 마구 부르면 성능 걱정부터 했을 텐데요.

그런데 신기하지 않나요? 멀티스레드 환경에서 여러 스레드가 동시에 같은 힙에 객체를 만든다면, 분명 어딘가에서 경쟁이 일어나야 정상이에요. 그런데도 자바의 객체 할당은 대부분 락 하나 잡지 않고 끝나요. 이게 어떻게 가능할까요?

답은 TLAB(Thread-Local Allocation Buffer) 에 있어요. 이 글에서는 객체가 태어나는 자리부터 시작해서, TLAB이 왜 필요했고 어떻게 동작하는지, 그리고 그게 꽉 찼을 때 JVM이 어떤 판단을 내리는지까지 차근차근 풀어볼게요.


객체는 어디에서 태어날까

먼저 무대를 정리해 볼게요. HotSpot JVM의 힙은 세대(generation)로 나뉘어요. 새로 만들어진 객체는 거의 대부분 Young Generation 안의 Eden 영역에서 태어나요.

+------------------- Heap -------------------+
|  Young Generation        | Old Generation  |
|  +--------+----+----+    |                 |
|  |  Eden  | S0 | S1 |    |   (tenured)     |
|  +--------+----+----+    |                 |
+--------------------------------------------+

Eden은 "아직 한 번도 GC를 거치지 않은 갓 태어난 객체들의 구역"이에요. 대부분의 객체는 금방 죽기 때문에(이걸 약한 세대 가설, weak generational hypothesis라고 해요) 짧은 수명의 객체를 한곳에 모아두면 GC가 효율적으로 청소할 수 있거든요.

그럼 Eden 안에서 객체 하나를 만든다는 건 구체적으로 무슨 동작일까요? 가장 단순하게 생각하면 이래요.

"Eden의 현재 빈 자리를 가리키는 포인터가 있다. 객체 크기만큼 그 포인터를 앞으로 밀고(bump), 원래 자리를 객체 주소로 돌려준다."

이걸 bump-the-pointer 방식이라고 해요. 자유 리스트(free list)를 뒤지며 맞는 빈칸을 찾는 malloc 방식과 비교하면 말도 안 되게 빨라요. 그냥 포인터에 덧셈 한 번이니까요.

bump-the-pointer는 왜 가능할까

여기서 한 가지 짚고 갈 게 있어요. malloc이 자유 리스트를 뒤지는 건 게을러서가 아니에요. C의 힙은 객체들이 죽으면서 여기저기 빈 구멍이 생겨요(외부 단편화, external fragmentation). 그래서 새 객체를 넣으려면 "이 구멍에 들어갈까?" 하고 맞는 빈칸을 찾아다녀야 해요.

자바가 그냥 포인터만 밀어도 되는 건 GC가 살아있는 객체들을 한쪽으로 모아주기(compaction) 때문이에요. Young GC가 일어나면 Eden의 살아남은 객체들은 Survivor 영역으로 통째로 복사되고, Eden은 다시 "처음부터 끝까지 전부 빈" 깨끗한 연속 공간이 돼요. 빈 구멍이 중간에 없으니, 그냥 한쪽 끝부터 포인터를 밀기만 하면 돼요.

즉 자바의 빠른 할당은 GC 설계와 한 몸이에요. "치우는 쪽(GC)이 공간을 정돈해주니까, 만드는 쪽(할당)이 단순해질 수 있다"는 거예요. 이 관계는 글 뒤에서 다시 만나게 돼요.


그런데 멀티스레드가 문제예요

bump-the-pointer가 빠른 건 좋은데, 한 가지 큰 문제가 있어요. Eden은 모든 스레드가 공유하는 공간이라는 거예요.

스레드 A와 스레드 B가 동시에 객체를 만들려고 한다고 해볼게요. 둘 다 "현재 빈 자리" 포인터를 읽고, 둘 다 거기에 객체를 쓰려고 해요.

Thread A: read top -> 0x1000, write object at 0x1000
Thread B: read top -> 0x1000, write object at 0x1000   (collision!)

같은 주소에 두 객체가 겹쳐 써지는 참사가 벌어져요. 이걸 막으려면 포인터를 미는 동작을 원자적(atomic)으로 만들어야 해요. CAS(Compare-And-Swap) 루프를 돌리거나 락을 잡아야 한다는 뜻이에요.

문제는 모든 객체 할당마다 이 동기화 비용을 내야 한다는 거예요. 객체를 초당 수백만 개씩 만드는 자바 애플리케이션에서 할당할 때마다 CAS 경쟁이 붙으면, 코어를 아무리 늘려도 할당 지점에서 병목이 생겨요. 멀티스레드 확장성이 여기서 무너지는 거죠.


TLAB — 스레드마다 자기만의 분양 구역

HotSpot이 택한 해법은 발상의 전환이었어요. 공유 공간에서 매번 경쟁하지 말고, 각 스레드에게 Eden의 작은 조각을 통째로 떼어주자는 거예요. 이 떼어준 조각이 바로 TLAB이에요.

OpenJDK 소스의 ThreadLocalAllocBuffer 클래스 주석은 이걸 이렇게 설명해요.

"ThreadLocalAllocBuffer is a descriptor for thread-local storage used by mutator threads for local/private allocation. As a TLAB is thread-private, there is no concurrent/parallel access to its memory or its members."

핵심은 thread-private 라는 단어예요. 어떤 스레드의 TLAB에는 그 스레드만 객체를 할당해요. 다른 스레드는 쳐다보지도 않아요. 그러니 그 안에서는 락도, CAS도 필요 없어요. 혼자 쓰는 공간이니 그냥 포인터를 밀면 끝이에요.

Eden:
+----------------+----------------+----------------+----------------+
|  TLAB of T1    |  TLAB of T2    |  TLAB of T3    |   free ...     |
+----------------+----------------+----------------+----------------+
   ^ only T1         ^ only T2        ^ only T3
   allocates here    allocates here   allocates here

각 스레드는 Eden에서 한 덩어리를 "분양"받아요. 그리고 그 안에서는 아무 동기화 없이 bump-the-pointer로 마음껏 객체를 찍어내요. Eden 전체에 대한 경쟁은 TLAB을 새로 받을 때만 일어나고, 그건 아주 가끔 있는 일이에요.


Fast Path 해부 — 포인터 하나만 밀면 된다

TLAB 하나는 내부적으로 몇 개의 포인터로 표현돼요. OpenJDK의 threadLocalAllocBuffer.hpp를 보면 이런 필드들이 있어요.

필드 역할
_start TLAB의 시작 주소
_top 현재 할당 위치 (다음 객체가 들어갈 자리)
_end 할당 한계선 (힙 샘플링을 위해 조정될 수 있음)
_allocation_end 정렬 예약분을 제외한 실제 끝
_desired_size 이 TLAB이 가지려는 목표 크기
_refill_waste_limit TLAB을 버릴지 유지할지 가르는 임계값

여기서 할당의 본질은 _top_end 두 개로 끝나요. 객체 하나를 만든다는 건 이런 의사 코드예요.

// fast path (개념적 표현)
HeapWord* obj = _top;
HeapWord* new_top = _top + object_size;
if (new_top <= _end) {
    _top = new_top;     // bump the pointer
    return obj;          // 할당 성공
}
// 여기 도달하면 TLAB이 꽉 찬 것 -> slow path

_top에 객체 크기를 더해서 _end를 넘지 않으면, _top을 갱신하고 원래 주소를 돌려주면 끝이에요. 비교 한 번, 덧셈 한 번, 대입 한 번이에요.

더 놀라운 건 이 코드가 런타임 함수 호출조차 아니라는 점이에요. JIT 컴파일러(C1, C2)가 이 fast path를 애플리케이션 코드 안에 직접 인라인해 버려요. HotSpot Glossary는 이렇게 표현해요.

"Compiled code has a 'fast path' of a few instructions which tries to bump a high-water mark in the current thread's TLAB, successfully allocating an object if the bumped mark falls before a TLAB-specific limit address."

new가 컴파일되면 메서드 호출이 아니라 몇 개의 기계어 명령으로 변해요. 포인터를 밀고, 한계와 비교하고, 분기하는 게 전부예요. 자바의 객체 할당이 "거의 공짜"라고 불리는 이유가 여기 있어요.

이 흐름을 그림으로 정리하면 이래요.

flowchart TD
    A["new Object()"] --> B{"top + size <= end ?"}
    B -- "yes (fast path)" --> C["bump top, return old top"]
    B -- "no (TLAB exhausted)" --> D{"tlab.free() > refill_waste_limit ?"}
    D -- "yes" --> E["keep TLAB, allocate object directly in Eden"]
    D -- "no" --> F["retire current TLAB, get a new one, allocate inside it"]
    E --> G["object allocated"]
    F --> G
    C --> G

참고로 -XX:-ZeroTLAB가 기본값이라, TLAB을 통째로 0으로 미리 채워두지는 않아요. 대신 객체가 할당될 때 그 객체의 필드 영역만 0으로 초기화해요. 자바 명세가 모든 필드의 기본값을 0/null/false로 보장하는 게 바로 이 단계예요.


TLAB이 꽉 차면 — slow path의 두 갈래

fast path에서 _top + size_end를 넘으면 어떻게 될까요? 이때 JVM은 흥미로운 판단을 해요. 무작정 새 TLAB을 받는 게 아니에요.

OpenJDK의 memAllocator.cpp를 보면 slow path의 핵심 분기가 이래요.

"Retain tlab and allocate object in shared space if the amount free in the tlab is too large to discard."

if (tlab.free() > tlab.refill_waste_limit()) {
    // 경우 1: TLAB을 그대로 두고, 이번 객체만 Eden에 직접 할당
} else {
    // 경우 2: 이 TLAB을 은퇴(retire)시키고 새 TLAB을 받아 거기에 할당
}

왜 이렇게 둘로 나눌까요? 생각해보면 합리적이에요.

경우 2처럼 TLAB을 버리면, 남아 있던 빈 공간은 그대로 낭비돼요. 만약 TLAB에 아직 30%나 남아 있는데 큰 객체 하나 안 들어간다고 통째로 버린다면, 그 30%가 전부 쓰레기가 돼요. 이걸 TLAB waste 라고 해요.

그래서 JVM은 따져요. "지금 남은 공간(tlab.free())이 버리기 아까울 만큼 크다면, TLAB은 그대로 두고 이번에 안 들어가는 이 객체 하나만 공유 Eden에 직접 할당하자." 이게 경우 1이에요. 큰 객체 하나 때문에 멀쩡한 TLAB을 버리는 손해를 막는 거예요.

반대로 남은 공간이 충분히 작으면(버려도 아깝지 않으면), 미련 없이 TLAB을 은퇴시키고 새 걸 받아요. 그게 경우 2고요.

이 "버리기 아까운가"의 기준선이 바로 refill_waste_limit 이에요.


refill_waste_limit — 얼마나 버려야 아까운가

refill_waste_limit은 TLAB을 새로 채울 때마다 다시 계산돼요. 공식은 단순해요.

refill_waste_limit = desired_size / TLABRefillWasteFraction

TLABRefillWasteFraction의 기본값은 64예요. 즉 TLAB 크기의 약 1/64 정도가 "이 정도 남은 건 버려도 된다"의 기준이에요. 남은 공간이 이보다 크면 TLAB을 살려두고 객체만 밖에 할당하고, 작으면 TLAB을 갈아요.

여기에 더해, 객체 하나 때문에 계속 밖에다 할당하는 일이 반복되지 않도록 매번 이 한계값을 조금씩 올려주는 장치도 있어요. 같은 TLAB에서 "밖에 할당"이 자꾸 일어나면 점점 더 쉽게 새 TLAB으로 교체되도록 유도하는 거예요.

숫자로 따라가 보기

말로만 보면 헷갈리니, 가상의 숫자로 한 번 따라가 볼게요. 어떤 스레드의 TLAB 크기(desired_size)가 256KB라고 해볼게요. 그러면 기준선은 이렇게 잡혀요.

refill_waste_limit = 256KB / 64 = 4KB

이 스레드가 작은 객체들을 계속 만들다가, TLAB에 3KB만 남은 상태에서 5KB짜리 객체를 만나면 어떻게 될까요? 남은 3KB는 기준선 4KB보다 작아요. "이 정도 남은 건 버려도 아깝지 않다"는 판단이에요. 그래서 이 TLAB은 은퇴하고, 새 TLAB을 받아서 거기에 5KB 객체를 넣어요. 버려진 3KB가 그대로 waste가 되고요.

반대로 TLAB에 100KB나 남았는데 갑자기 200KB짜리 큰 객체를 만났다면요? 남은 100KB는 기준선 4KB보다 훨씬 커요. 멀쩡한 100KB를 버리는 건 너무 아까워요. 그래서 TLAB은 그대로 살려두고, 이 200KB 객체 하나만 공유 Eden에 직접 할당해요. 그 다음 작은 객체들은 다시 살아남은 TLAB의 100KB 안에서 빠르게 만들어지고요.

이렇게 "남은 게 아까우면 살리고, 아깝지 않으면 버린다"는 한 줄짜리 규칙이 TLAB 낭비를 적정선에서 막아줘요.


TLAB 크기는 누가 정할까 — 적응형 리사이징

그럼 TLAB 하나는 얼마나 클까요? 너무 작으면 새 TLAB을 너무 자주 받느라(refill) Eden 경쟁이 잦아지고, 너무 크면 스레드마다 큰 덩어리를 쥐고 있다가 GC 때 많이 낭비해요. 적당한 크기를 찾는 게 핵심이에요.

-XX:TLABSize로 직접 정할 수도 있지만, 기본값은 0이에요. 0이면 JVM이 할당 패턴을 보고 알아서 크기를 조정해요. 이걸 켜고 끄는 게 -XX:+ResizeTLAB이고, 기본으로 켜져 있어요.

처음 크기는 Eden 용량을 스레드 수로 나눠서 정해요. threadLocalAllocBuffer.cppinitial_desired_size()는 이런 식이에요.

init_sz = (Universe::heap()->tlab_capacity() / HeapWordSize)
          / (num_threads * target_num_refills());

Eden 용량을 "스레드 수 x GC 한 사이클당 목표 refill 횟수"로 나눠요. 그래야 모든 스레드가 Eden을 공평하게 나눠 쓰면서, GC가 오기 전까지 적당한 횟수만 TLAB을 교체하게 돼요.

여기서 target_num_refills()가 재미있어요.

_target_num_refills = 100 / (2 * TLABWasteTargetPercent);

TLABWasteTargetPercent의 기본값은 1이에요. 그러면 목표 refill 횟수는 100 / (2 * 1) = 50이 돼요. "GC 한 사이클 동안 각 스레드는 TLAB을 50번쯤 교체하는 게 이상적"이라고 보는 거예요. 2로 나누는 건 GC 시점에 평균적으로 각 스레드의 TLAB이 절반쯤 차 있다고 가정하기 때문이에요(절반은 평균적으로 낭비된다는 가정).

실행 중에는 resize()가 직전까지의 할당량을 보고 크기를 다시 맞춰요.

size_t alloc = _allocation_fraction.average()
               * (Universe::heap()->tlab_capacity() / HeapWordSize);
size_t new_size = alloc / _target_num_refills;
// new_size를 [min_size, max_size] 범위로 clamp

_allocation_fraction은 지수 가중 이동 평균(EMA)이에요. 최근에 많이 할당한 스레드는 다음 TLAB을 더 크게, 거의 할당 안 하는 스레드는 더 작게 받아요. 스레드마다 할당 성향이 다르니, 한 값으로 고정하지 않고 각자에게 맞춰주는 거예요. 최소 크기는 -XX:MinTLABSize(기본 2048바이트)로 막혀 있어서 너무 작아지지는 않아요.

정리하면 이래요.

flowchart TD
    A["GC happens / TLAB retired"] --> B["update allocation_fraction (EMA)"]
    B --> C["alloc = avg_fraction * eden_capacity"]
    C --> D["new_size = alloc / target_num_refills"]
    D --> E["clamp to [MinTLABSize, max]"]
    E --> F["next TLAB uses new_size"]

TLAB waste와 힙 파싱 — 버려진 자리를 채우는 가짜 객체

은퇴한 TLAB에 남은 빈 공간은 어떻게 될까요? 그냥 비워두면 안 되는 이유가 있어요.

GC는 힙을 선형으로 훑으며(linear walk) 객체를 하나하나 건너뛰며 순회해요. "이 객체는 크기가 N이니, 다음 객체는 N바이트 뒤에 있겠지" 하는 식이에요. 그런데 TLAB 중간에 정체불명의 빈 공간이 있으면, GC가 그 자리를 만났을 때 "여기 객체 크기가 뭐지?"하고 길을 잃어요.

그래서 HotSpot은 TLAB을 은퇴시킬 때 남은 빈 공간을 가짜 객체로 채워요. threadLocalAllocBuffer.cppmake_parsable()retire()가 이 일을 해요. 보통 int[] 같은 더미 배열 객체 하나를 그 자리에 만들어 넣어요. 살아있는 데이터는 없지만, 헤더와 길이 정보가 있으니 GC가 "아 이건 크기 M짜리 배열이구나, M만큼 건너뛰면 되겠다"하고 무사히 지나갈 수 있어요.

OpenJDK 주석은 이 동작을 "fill_with_dummy_object makes the unused TLAB region traversable so the heap can be parsed correctly"라고 설명해요. 이 더미 객체가 차지하는 공간이 곧 우리가 앞에서 본 TLAB waste예요. 태어날 때부터 쓰레기인 객체인 셈이죠. 그래서 TLAB 크기를 잘 맞추는 게 중요해요. 너무 크면 이 낭비가 커지거든요.

_end_allocation_end가 따로 있는 것도 이것과 관련 있어요. TLAB은 끝부분에 정렬 예약분(alignment reserve)을 조금 남겨둬요. 빈자리를 채우는 더미 객체의 헤더가 항상 들어갈 자리는 확보해두려는 거예요. 그래서 실제로 객체를 할당할 수 있는 한계(_end)는 물리적 끝(_allocation_end)보다 약간 앞이에요.


TLAB과 Young GC는 한 몸이에요

앞에서 "bump-the-pointer는 GC가 공간을 정돈해주니까 가능하다"고 했죠. 그 관계가 여기서 다시 나와요.

TLAB은 Eden에서 잘라낸 조각이에요. 스레드들이 계속 객체를 만들다 보면 Eden이 가득 차고, 그러면 Young GC(Minor GC) 가 일어나요. 이때 모든 스레드의 TLAB은 일단 은퇴 처리돼요. GC가 Eden 전체를 훑어야 하니, 각 TLAB의 남은 빈자리도 전부 더미 객체로 채워서 파싱 가능하게 만들어야 하거든요.

Young GC가 끝나면 Eden은 다시 텅 빈 연속 공간이 되고, 스레드들은 새 TLAB을 받아서 처음부터 다시 시작해요. 그래서 TLAB의 한살이는 Young GC 주기와 맞물려 돌아가요.

flowchart LR
    A["threads allocate via TLAB"] --> B["Eden fills up"]
    B --> C["Young GC: retire all TLABs, fill waste"]
    C --> D["live objects copied to Survivor"]
    D --> E["Eden empty again"]
    E --> F["threads get fresh TLABs"]
    F --> A

이 관계를 알면 적응형 리사이징이 왜 "GC 한 사이클당 목표 refill 횟수"를 기준으로 크기를 잡는지도 자연스럽게 이해돼요. TLAB은 결국 "다음 Young GC가 오기 전까지 이 스레드가 얼마나 할당할까"를 예측해서 크기를 정하는 거예요. 너무 크면 GC 때 버리는 waste가 커지고, 너무 작으면 refill하느라 Eden 경쟁이 잦아져요. 그 사이의 균형점을 스레드마다 따로 잡는 거예요.


큰 객체는 TLAB을 거치지 않아요

객체가 TLAB 하나보다도 크면 어떻게 될까요? 예를 들어 수 MB짜리 배열을요. 이건 TLAB에 넣을 수가 없어요. 이런 객체는 fast path를 건너뛰고 처음부터 Eden(또는 GC 종류에 따라 별도 영역)에 직접 할당돼요.

이때는 어쩔 수 없이 공유 공간에 대한 동기화가 필요해요. 하지만 큰 객체는 자주 만들어지지 않으니 전체 성능에 큰 영향은 없어요. TLAB은 어디까지나 "자잘한 객체를 폭발적으로 많이 만드는" 흔한 경우를 빠르게 만드는 장치예요.


직접 눈으로 확인하기

이론은 그렇고, 실제로 내 애플리케이션의 TLAB이 어떻게 동작하는지 보고 싶을 수 있어요. 몇 가지 방법이 있어요.

1. 통합 로깅으로 TLAB 동작 보기

JDK 9부터는 통합 로깅(Unified Logging)으로 TLAB 통계를 볼 수 있어요.

java -Xlog:gc+tlab=trace -jar app.jar

각 스레드가 TLAB을 몇 번 채웠는지(refill), 얼마나 낭비했는지(waste), 크기가 어떻게 조정됐는지가 찍혀요. 예전 -XX:+PrintTLAB 플래그를 대체한 방식이에요.

2. JFR로 할당 추적하기

JDK Flight Recorder에는 할당 관련 이벤트가 두 개 있어요.

  • jdk.ObjectAllocationInNewTLAB — 새 TLAB을 받으면서 할당된 경우
  • jdk.ObjectAllocationOutsideTLAB — TLAB 밖에 직접 할당된 경우(큰 객체나 retain 경로)
java -XX:+FlightRecorder \
     -XX:StartFlightRecording=duration=60s,filename=alloc.jfr \
     -jar app.jar

alloc.jfr을 JDK Mission Control이나 jfr print로 열면 "어떤 코드 경로가 할당을 많이 일으키는지"를 스택 트레이스와 함께 볼 수 있어요. 흔히 쓰는 async-profiler의 할당 프로파일링도 이 두 이벤트를 기반으로 동작해요.


튜닝 플래그 정리

직접 조정할 일은 많지 않지만, 동작을 이해하는 차원에서 주요 플래그를 정리해 둘게요. 기본값은 OpenJDK 소스 기준이에요.

플래그 기본값 설명
-XX:+UseTLAB 켜짐 TLAB 사용 여부. 끄면 모든 할당이 공유 공간 경쟁이 돼요
-XX:+ResizeTLAB 켜짐 할당 패턴에 따라 TLAB 크기를 적응적으로 조정
-XX:TLABSize 0 TLAB 초기 크기 고정값. 0이면 자동 계산
-XX:MinTLABSize 2048 TLAB 최소 크기(바이트)
-XX:TLABWasteTargetPercent 1 목표 낭비 비율. refill 목표 횟수 계산에 사용
-XX:TLABRefillWasteFraction 64 refill_waste_limit = desired_size / 64
-XX:-ZeroTLAB 꺼짐 TLAB 전체를 미리 0으로 채우지 않음(객체별 초기화)

대부분의 경우 손대지 않는 게 정답이에요. -XX:-UseTLAB로 꺼보는 건 학습용으로만 권해요. Aleksey Shipilëv의 측정에 따르면 TLAB을 끄면 할당 처리량이 최소 5배 떨어지고 실행 시간이 10배까지 늘어났어요. TLAB이 자바 성능에 얼마나 큰 역할을 하는지 보여주는 수치예요.


마무리

자바에서 new 한 줄이 어떻게 "거의 공짜"가 되는지 따라가 봤어요. 핵심을 정리하면 이래요.

  • 객체는 Eden에서 태어나고, 본질적인 할당은 포인터 하나를 미는 bump-the-pointer예요.
  • 공유 Eden에서 매번 경쟁하면 느려지니까, 스레드마다 자기만의 TLAB을 떼어줘서 동기화 없이 할당하게 해요.
  • fast path는 JIT가 인라인한 몇 개의 명령어예요. 포인터 더하고, 한계와 비교하고, 분기하면 끝이에요.
  • TLAB이 꽉 차면 JVM은 "버리기 아까운 만큼 남았나"를 따져서, 객체만 밖에 두거나(retain) TLAB을 갈아요(refill).
  • TLAB 크기는 고정이 아니라, 스레드별 할당 패턴의 이동 평균으로 계속 조정돼요.
  • 버려진 빈자리는 가짜 객체로 채워서 GC가 힙을 무사히 순회하게 해요.

평소엔 의식할 일 없는 동작이지만, 알고 나면 "왜 자바는 객체를 막 만들어도 괜찮은가"라는 질문에 자신 있게 답할 수 있게 돼요. 그리고 할당 프로파일링 결과에서 OutsideTLAB 이벤트가 많이 보인다면, 그게 무슨 의미인지도 이제 읽어낼 수 있을 거예요.


참고 자료

More from this blog

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

Git merge 내부 동작 — 3-way merge, merge base, 그리고 recursive에서 ort로

git merge를 매일 쓰지만, 그 한 줄이 안에서 무슨 일을 하는지 들여다본 적은 드물어요. 이 글은 merge가 두 갈래의 변경을 어떻게 합치는지, merge base가 왜 필요한지, 그리고 Git이 기본 전략을 recursive에서 ort로 갈아치운 이유를 따라가요. Git을 쓰는 백엔드 개발자를 대상으로 해요. 브랜치 두 개를 합치는 일은 겉보기

May 15, 202612 min read

Java NIO ByteBuffer 내부 구조 — Direct vs Heap, Cleaner, 그리고 off-heap 메모리가 GC를 우회하는 방법

Netty가 빠른 이유, Kafka 클라이언트가 직렬화에 신경 쓰는 이유, MappedByteBuffer로 수 GB짜리 파일을 다루는 이유. 그 한가운데에는 ByteBuffer가 있어요. 이번 글에서는 ByteBuffer의 두 얼굴 — heap과 direct — 가 어떻게 다른지, off-heap 메모리는 어떻게 잡고 어떻게 풀리는지, JVM과 운영체제 사

May 15, 202612 min read

Java Flight Recorder 내부 구조 — Thread-Local Buffer부터 Disk Repository까지

JFR을 켜면 1% 미만 오버헤드로 JVM 내부가 그대로 기록돼요. 어떻게 이렇게 가벼울 수 있는지, 그리고 그 데이터가 어떤 경로를 거쳐 디스크에 쌓이는지 한 번 따라가 봐요. 이 글은 JFR을 "그냥 잘 쓰는 도구"에서 "내부 동작을 아는 도구"로 끌어올리고 싶은 분을 위한 글이에요. 운영 중인 서버에서 갑자기 응답 시간이 튀어요. 메트릭 그래프는 분

May 15, 202614 min read

끄적끄적 테크 블로그

162 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ