"객체 할당은 비싸다"는 말은 자바를 하면 한 번쯤 듣는다. 그런데 실제로 마이크로벤치마크를 돌려보면 new가 수 나노초, 명령 몇 개로 끝나는 경우가 대부분이다. 이 괴리가 계속 걸렸다.
그러다 GC가 압축(compaction)으로 eden 영역을 항상 "연속된 빈 공간 + 경계 포인터" 상태로 유지한다는 걸 알게 됐다. 그러면 할당은 그냥 포인터를 밀면 되는데, 문제는 여러 스레드가 그 하나의 경계 포인터를 어떻게 경합 없이 나눠 쓰느냐였다. 그 빈칸을 메우는 게 TLAB(Thread-Local Allocation Buffer)였다. HotSpot 소스와 Oracle GC 튜닝 가이드를 따라가며 정리했다.
TLAB은 각 스레드가 eden에서 미리 잘라 받은 사유 구획이다. 그 안에서의 할당은 CAS 없이
top += size한 번(bump-the-pointer)으로 끝나고, 구획이 다 차면 그때만 공유 eden에 원자적 연산을 걸어 새 구획을 떼어 온다.
복사형·압축형 컬렉터의 eden은 항상 "연속 free 영역 + top 포인터" 꼴이다. 할당의 본질은 top을 객체 크기만큼 밀고 이전 값을 객체 주소로 돌려주는 것뿐이다. free list를 뒤져 빈 칸을 찾는 malloc과는 접근 자체가 다르다.
문제는 공유다. N개 스레드가 같은 eden top을 동시에 밀면 매 할당이 CAS 경합이 된다. HotSpot은 이걸 두 단계로 쪼갠다.
eden: [ ###사용### | T0 TLAB | T1 TLAB | ... | free ]
^_start ^_top ^_end
(스레드 T1 사유)
공유 경합 : eden free에서 TLAB 한 덩이 떼기 = CAS 1회 (드묾)
무경합 : TLAB 내부 top 밀기 = 그냥 store (거의 항상)
if (top + size <= end) { obj = top; top += size; }. 스레드 사유 메모리라 원자성이 필요 없다. JIT가 인라인해서 명령 몇 개로 컴파일한다.핵심은 경합이 "할당마다"가 아니라 "TLAB 하나를 다 쓸 때마다"로 줄어든다는 점이다. TLAB이 수천 개의 객체를 담을 만큼 크다면, 공유 경합의 빈도도 그만큼 낮아진다.
TLAB이 꽉 찼다고 무조건 새로 받으면, 지금 TLAB에 남은 꼬리 공간이 통째로 버려진다(internal fragmentation). 그래서 HotSpot은 남은 공간과 임계값을 비교해 결정한다(소스 threadLocalAllocBuffer.cpp의 allocate_from_tlab_slow).
refill_waste_limit보다 크면 → 지금 TLAB은 그대로 두고(버리기 아까우니), 이 객체 하나만 공유 eden에 직접 할당한다.여기엔 굶주림 방지 장치가 있다. 매번 "밖에서 할당"만 택하면 영원히 refill을 안 해 fast path를 못 타게 된다. 그래서 밖에서 할당할 때마다 refill_waste_limit을 조금씩 올린다. 임계값이 결국 남은 공간을 넘어서면 refill이 강제된다.
한편 fresh TLAB에도 안 들어갈 만큼 큰 객체는 처음부터 TLAB 대상이 아니라 곧장 공유 힙/eden으로 간다.
GC와 힙 워킹은 eden을 선형으로 훑는다. "여기부터 객체 → 그 klass 포인터로 크기 계산 → 다음 객체"를 반복하는 식이다. 그런데 retire되는 TLAB의 남은 꼬리는 아무 객체도 없는 구멍이라, 그대로 두면 워커가 그 바이트를 klass 포인터로 오해한다.
그래서 HotSpot은 retire할 때 남은 공간을 filler 객체(보통 int[], 아주 작으면 filler 인스턴스)로 채운다(CollectedHeap::fill_with_object). 버려지는 공간에도 "여기는 그냥 죽은 배열"이라는 파스 가능한 헤더가 박히는 것이다. TLAB 낭비가 CPU가 아니라 메모리로 나타나는 이유가 여기 있다.
retire는 TLAB이 꽉 차 교체될 때만이 아니라 GC 직전에도 일어난다. 컬렉터가 eden을 훑기 전에 모든 스레드의 현재 TLAB을 filler로 마감(make_parsable)해야 힙 전체가 선형 워킹 가능해지기 때문이다. 그래서 낭비 카운터가 세 갈래로 집계된다.
| 낭비 종류 | 언제 발생 | 의미 |
|---|---|---|
| fast waste | refill로 retire할 때 남는 꼬리 | 정상 운영 중 버려진 꼬리 |
| slow waste | TLAB 밖 할당(§3) | refill을 미룬 대가 |
| gc waste | GC 직전 make_parsable | 아직 안 찬 TLAB의 미사용분 |
TLAB이 너무 크면 스레드가 eden을 선점해 GC가 잦아지고, 너무 작으면 refill(=공유 경합)이 잦아진다. HotSpot은 이 둘을 저울질하려고 스레드별 할당 이력의 지수이동평균(EMA)으로 다음 epoch(대개 GC 사이)의 목표 크기를 다시 계산한다. 대략의 목표는 이렇다.
desired_tlab_size ≈ eden_free / (n_threads × target_refills_per_epoch)
-XX:TLABWasteTargetPercent(기본값은 1로 알려져 있음)는 낭비 목표 비율이다. refill_waste_limit은 tlab_size / TLABRefillWasteFraction(기본 64)에서 출발하며, 이 두 축이 §3의 임계값을 조율한다.-XX:-UseTLAB로 끄면 모든 할당이 공유 경합 경로로 떨어져 멀티스레드에서 급격히 느려진다.또 하나, bump 할당은 순차적이라 다음 할당 주소가 예측된다. HotSpot은 -XX:AllocatePrefetchStyle/Distance/Lines로 top 앞쪽 캐시 라인을 미리 prefetch해, 새 객체를 zero-init하고 채울 때의 캐시 미스를 줄인다.
동작을 확인하는 가장 작은 사고 실험은 이렇다.
// 32B짜리 객체(헤더 + byte[16])를 대량 할당
for (int i = 0; i < 100_000_000; i++) {
new byte[16]; // 거의 모든 반복이 fast path: top += 32
}
top += 32)라, 스레드가 늘어도 성능이 선형으로 확장된다.-XX:+UseTLAB(기본)와 -XX:-UseTLAB를 비교하면 후자는 코어 수가 늘수록 CAS 경합으로 급락한다.-Xlog:gc+tlab=trace(또는 구버전의 -XX:+PrintTLAB)를 켜면 스레드별 refills, slow allocs, waste(gc/slow/fast) 카운터를 볼 수 있다. refill이 드물고 waste가 TLABWasteTargetPercent 근처로 수렴하는 걸 확인할 수 있다.정리하다 보니, 그동안 막연히 믿던 것들이 여럿 어긋났다.
int[] filler로 채워진다.한 줄로 요약하면: 자바 객체 할당이 대부분 거의 공짜인 건 우연이 아니라, "경합을 할당마다가 아니라 구획마다로 미루는" TLAB 설계 덕분이다.
더 파고든다면 Eden→Survivor 복사에서 GC 스레드가 쓰는 PLAB(TLAB의 GC 버전), 그리고 G1의 region 기반 할당에서 TLAB이 region 경계·humongous 객체와 만나는 지점을 볼 만하다.
src/hotspot/share/gc/shared/threadLocalAllocBuffer.{hpp,cpp}, collectedHeap.inline.hpp, runtime/globals.hpp