Skip to main content

Command Palette

Search for a command to run...

Caffeine Cache 내부 구조 — W-TinyLFU와 Segmented LRU

Updated
13 min read

이 글은 Java 진영의 사실상 표준 인메모리 캐시 라이브러리인 Caffeine이 어떻게 LRU 한 가지로 풀리지 않던 캐시 적중률 문제를 풀어내는지를 정리합니다. Window TinyLFU 어드미션, Segmented LRU, Count-Min Sketch 기반 빈도 추정, ring buffer로 분리한 동시성 경로까지 한 번에 풀어 봅니다. Spring Cache의 백엔드가 Caffeine 한 줄로 끝나는 이유를 알고 싶은 분이 대상 독자입니다.

왜 또 다른 캐시인가

ConcurrentHashMap은 동시성은 훌륭하지만 캐시가 아닙니다. eviction이 없으니 키가 누적되면 메모리가 무한히 자랍니다. Guava의 CacheBuilder는 그 빈자리를 채워 size·time·reference 기반 eviction과 비동기 로딩을 묶어 한 시대를 풍미했지만, 16-thread JMH 기준 100% read 워크로드에서 약 43M ops/s에 그쳤습니다. Caffeine은 같은 워크로드에서 약 382M ops/s를 기록합니다. 약 9배 차이입니다 (출처: Caffeine wiki Benchmarks 페이지, 환경별 변동 있음).

Caffeine은 Guava CacheBuilder의 원저자 Ben Manes가 2014년경부터 새로 짠 라이브러리입니다. ConcurrentHashMap을 데코레이트하는 형태는 같지만, eviction 정책을 LRU에서 W-TinyLFU로 바꾸고, 동시성 경로를 read/write buffer 두 단계로 분리해 hot path의 락 경합을 거의 제거했습니다.

flowchart LR
    App[Application] --> CHM[ConcurrentHashMap]
    App -->|on access| RB[Read Buffer striped ring]
    App -->|on mutate| WB[Write Buffer growable]
    RB --> Maint[Maintenance task]
    WB --> Maint
    Maint --> WTLFU[W-TinyLFU policy]
    WTLFU -->|evict decision| CHM

핵심은 "캐시 적중률을 LRU 이상으로 끌어올리되, hot path는 절대 느려지지 않게 한다"는 두 마리 토끼를 동시에 잡는 설계입니다. 이를 어떻게 풀었는지 한 층씩 들여다보겠습니다.


전체 그림 — BoundedLocalCache와 W-TinyLFU

크기 제한이 있는 모든 Caffeine 캐시는 내부적으로 BoundedLocalCache라는 클래스를 거치고, eviction 정책은 W-TinyLFU (Window TinyLFU) 한 종류만 씁니다. 다른 정책 선택지를 제공하지 않는 이유는 이 정책이 다양한 워크로드에서 LRU·LFU·ARC·LIRS와 견주어 평균적으로 가장 좋은 hit rate를 내기 때문입니다.

W-TinyLFU는 캐시 공간을 두 부분으로 나눕니다.

  • Window (eden) — LRU. 전체의 약 1%. 새로 들어온 모든 엔트리는 일단 여기로.
  • Main — Segmented LRU. 전체의 약 99%. 두 부분으로 다시 나뉨.
    • Protected — Main의 약 80%. 두 번 이상 접근된 "안정적인" 엔트리.
    • Probation — Main의 약 20%. Window에서 막 승격된, 아직 검증 안 된 엔트리.
flowchart LR
    NEW[New entry] --> W[Window LRU 1%]
    W -->|evicted from window| ADM{TinyLfu admit?}
    ADM -->|yes| PB[Probation LRU 20% of main]
    ADM -->|no| OUT[Discarded]
    PB -->|hit| PR[Protected LRU 80% of main]
    PR -->|protected full| PB
    PB -->|probation full| OUT

흐름을 글로 풀면 이렇습니다. 새 엔트리는 Window에 들어오고, Window가 가득 차면 LRU 끝의 후보(candidate)가 Main으로 넘어가려 합니다. 이때 TinyLFU 어드미션 필터가 candidate의 과거 빈도와 Probation LRU 끝의 victim 빈도를 비교해 누구를 살릴지 결정합니다. Probation에 들어간 엔트리가 한 번 더 hit되면 Protected로 승격되고, Protected가 가득 차면 LRU 끝이 Probation으로 강등됩니다.

이 구조가 풀어내는 두 문제는 다음과 같습니다.

  1. 풀 스캔(scan) 오염 — 한 번만 쓰일 엔트리가 LRU의 신선한 자리를 모조리 밀어내는 문제. W-TinyLFU에서는 Window의 1%까지만 들어왔다가 어드미션 필터에서 거부되어 사라집니다.
  2. 빈도 망각 — 전통적 LFU는 한때 인기 있었던 옛 데이터가 영원히 살아남는 문제. Caffeine의 Frequency Sketch는 주기적으로 카운터를 절반으로 줄여(aging) 시간이 지난 빈도를 자연스럽게 잊습니다.

Frequency Sketch — 8바이트로 빈도를 기억하기

W-TinyLFU의 핵심은 "빈도를 정확하지 않아도 되니 싸게 기억"하는 자료구조입니다. Caffeine은 이를 4-bit Count-Min Sketch로 구현했습니다. 캐시 엔트리 하나당 약 8바이트의 오버헤드로 0~15 사이의 빈도 추정값을 보관합니다.

4-bit 카운터 packing

내부는 단일 long[] table입니다. 각 long은 64비트이므로 4비트 카운터를 16개 담을 수 있습니다.

// FrequencySketch.java의 핵심 아이디어 (개념 코드)
long[] table;       // 길이 = 2^k (캐시 maxSize에 맞춰 반올림)
int sampleSize;     // 보통 maxSize * 10
int size;           // increment 누적 카운터

int frequency(int hash) {
    int start = (hash & 3) << 2;     // 16개 슬롯 중 시작 위치
    int frequency = Integer.MAX_VALUE;
    for (int i = 0; i < 4; i++) {
        int index = indexOf(hash, i);
        int count = (int) ((table[index] >>> ((start + i) << 2)) & 0xfL);
        frequency = Math.min(frequency, count);
    }
    return frequency;
}

키 해시로부터 4개의 독립 위치를 계산하고, 그 4개의 카운터 중 최솟값을 빈도 추정값으로 씁니다. 이게 Count-Min Sketch의 기본 원리입니다. 해시 충돌로 어느 위치는 부풀려질 수 있지만, 같은 키의 4개 위치가 모두 부풀려질 확률은 곱셈으로 떨어지기 때문에 min을 취하면 false positive가 크게 줄어듭니다. 카운터는 4비트이므로 최댓값은 15에 고정됩니다 (saturating counter).

Aging — 절반으로 줄이는 reset

전통적 LFU의 함정은 "한때 인기였던 키"가 지금 안 쓰여도 영원히 살아남는 것입니다. Caffeine은 sample size에 도달할 때마다 모든 카운터를 절반으로 줄여 이 문제를 풉니다.

// 개념 코드
void increment(int hash) {
    // ... 4개 위치 카운터를 +1 (15 cap)
    if (++size == sampleSize) {
        reset();
    }
}

void reset() {
    for (int i = 0; i < table.length; i++) {
        table[i] = (table[i] >>> 1) & RESET_MASK;
    }
    size = (size - calculateOddCount()) >>> 1;
}

sampleSize는 보통 maxSize * 10입니다. 캐시 한 번 채울 만큼의 접근이 누적되면 한 번 aging이 일어난다고 보면 됩니다. >>> 1로 모든 4비트 카운터를 동시에 우측 시프트하면 시프트 경계에서 1비트가 흘러나오므로 RESET_MASK로 보정합니다. size는 홀수 카운터의 개수만큼 보정해서 sample 누적을 정확히 유지합니다.

이 한 가지 트릭으로 W-TinyLFU는 "어제 핫했지만 오늘은 식은 데이터"를 자연스럽게 잊습니다.

HashDoS 방어용 jitter

빈도 추정만으로 admit/reject를 결정하면, 공격자가 의도적으로 hash collision을 만들어 특정 victim의 추정 빈도를 부풀려 캐시를 점거할 수 있습니다. Caffeine은 어드미션 결정 시 작은 무작위성을 섞어 이 공격을 막습니다.


Window TinyLFU 어드미션 — 누구를 살릴 것인가

Window가 가득 차서 LRU 끝 엔트리 한 개가 밀려날 때, 그 엔트리(candidate)를 Main으로 옮길지 결정하는 단계가 어드미션입니다.

flowchart TD
    A[Window evicts candidate] --> B[Probation LRU tail = victim]
    B --> C{freq candidate vs freq victim}
    C -->|cand greater| ADMIT[Admit to probation, drop victim]
    C -->|cand less| REJECT[Reject candidate]
    C -->|tied| JITTER{random jitter}
    JITTER -->|admit| ADMIT
    JITTER -->|reject| REJECT

단계는 단순합니다.

  1. Probation LRU의 마지막 자리(가장 오래된) 엔트리를 victim 후보로 본다.
  2. Frequency Sketch에서 candidate와 victim의 빈도를 각각 조회한다.
  3. candidate의 빈도가 victim의 빈도보다 크면 candidate를 Probation으로 옮기고 victim을 버린다.
  4. 작으면 candidate를 그냥 버린다 (어드미션 거부).
  5. 같으면 jitter로 무작위 결정한다.

여기서 결정적으로 중요한 점은 "Main 캐시는 항상 빈도가 가장 높은 엔트리들로 구성된다"는 보장입니다. 한 번 들어왔다 거부되어도 그 키가 다시 들어와 Window에 머물면서 빈도를 쌓을 기회는 계속 있으니, hot한 키는 결국 Main에 자리잡습니다. 반면 한 번만 쓰일 키는 Window의 1%에 잠깐 머물다 어드미션에서 막혀 사라지므로 Main의 80%를 차지하는 Protected 영역은 오염되지 않습니다.


Segmented LRU — Main 안의 두 층

Main 공간은 단순 LRU가 아니라 Segmented LRU (SLRU) 입니다. Probation과 Protected 두 LRU 리스트로 구성됩니다.

  • Probation (20%) — Main에 막 들어온 엔트리. 한 번 더 hit하면 Protected로 승격.
  • Protected (80%) — 적어도 두 번 이상 접근된 "검증된" 엔트리.
flowchart LR
    P1[Probation head] --> P2[...] --> P3[Probation tail]
    R1[Protected head] --> R2[...] --> R3[Protected tail]
    P3 -.evict.-> OUT[Discarded]
    P1 -.hit.-> R1
    R3 -.demote.-> P1

승격·강등 규칙은 이렇습니다.

  • Probation 엔트리가 hit되면 Protected의 머리로 이동한다.
  • Protected가 꽉 차 있는데 새 승격이 일어나면, Protected의 LRU 끝(Protected tail)이 Probation의 머리로 강등된다.
  • Probation에서 evict가 필요하면 Probation의 LRU 끝부터 버린다.

이 두 층 구조의 의도는 명확합니다. 어드미션을 통과한 candidate라 해도 곧바로 Protected에 넣으면, 잠깐 빈도가 튀었을 뿐 곧 사라질 키가 Protected의 자리를 차지해 진짜 hot 키를 밀어낼 위험이 있습니다. Probation에 한 단계 대기시켜 "한 번 더 hit되는지" 검증한 후 Protected로 들이는 것입니다. 이는 사실상 cache 내부의 "qualifier round"입니다.


Hill Climbing — Window 크기 적응 조정

Window는 1%, Protected는 80%라는 비율은 기본값일 뿐 고정값이 아닙니다. 워크로드 성격에 따라 어떤 환경에서는 Window를 크게 잡는 편이, 어떤 환경에서는 작게 잡는 편이 hit rate에 유리합니다.

  • Recency-friendly 워크로드 (최근 본 게 곧 다시 쓰임) — Window를 키워야 신선한 엔트리가 오래 머문다.
  • Frequency-friendly 워크로드 (옛 핫 키가 꾸준히 쓰임) — Window를 줄여야 Main에 자리가 더 많이 나서 빈도 높은 키가 살아남는다.

Caffeine은 hill climbing 기법으로 이 비율을 동적으로 조정합니다. 일정 주기마다 현재 hit rate를 샘플링하고, Window를 살짝 키워본 뒤 hit rate가 개선되면 같은 방향으로 더 가고, 나빠지면 반대 방향으로 트랙을 바꿉니다. 산을 오르며 더 높은 봉우리를 찾는 것과 같다고 해서 hill climber입니다. step size도 처음엔 크게 잡았다가 점점 줄여 진동을 안정화합니다.

이 적응의 결과로 Caffeine은 워크로드 패턴이 정확히 어떤지를 사용자가 미리 알지 못해도 합리적인 비율을 스스로 찾아냅니다. 워크로드가 바뀌면 비율도 따라 바뀝니다.


동시성 — Read/Write Buffer로 hot path 분리

지금까지 본 정책은 LRU 리스트와 frequency sketch를 끊임없이 갱신해야 합니다. 만약 모든 get() 호출이 LRU 리스트의 head로 노드를 옮기는 작업을 그 자리에서 처리한다면, 멀티스레드 환경에서는 그 리스트가 거대한 lock contention point가 됩니다. Guava가 64개 segment lock으로 풀어보려 한 문제가 그것이고, Caffeine은 더 멀리 갑니다.

flowchart LR
    G1[Thread 1 get] --> CHM[ConcurrentHashMap]
    G2[Thread 2 get] --> CHM
    G1 --> RB1[Stripe 1 ring buffer]
    G2 --> RB2[Stripe 2 ring buffer]
    PUT[put] --> CHM
    PUT --> WB[Write buffer growable]
    RB1 -.drain.-> M[Maintenance]
    RB2 -.drain.-> M
    WB -.drain.-> M
    M --> LRU[LRU & sketch update]

핵심 아이디어는 "정책 갱신은 비동기로 미룬다"입니다.

Read Buffer — 손실 허용, striped ring buffer

get()은 두 가지 일을 합니다.

  1. ConcurrentHashMap.get()으로 값 자체를 즉시 반환한다 (사용자 응답).
  2. 이 키가 방금 접근되었다는 "이벤트"를 read buffer에 기록한다 (정책 갱신용).

read buffer는 striped ring buffer입니다. 스레드별로 다른 stripe에 기록하므로 CAS contention이 거의 없습니다. ring buffer가 꽉 차면 그 이벤트는 그냥 버립니다 — 손실 허용 구조입니다. 캐시 정책은 통계적으로 정확하면 충분하지, 모든 접근을 빠짐없이 반영할 필요는 없기 때문입니다.

stripe 개수는 CPU 수에 기반해 동적으로 늘어납니다. contention이 감지되면 stripe를 더 만들어 부담을 나누는 식입니다.

Write Buffer — 손실 불가, growable circular array

put()/invalidate()/asMap().remove() 같은 mutation은 정책 입장에서 손실되면 안 됩니다. 새 엔트리를 LRU에 안 넣어두면 evict 시점에 추적이 안 되니까요. 그래서 write buffer는 손실을 허용하지 않는 growable circular array입니다. 가득 차면 배열을 확장합니다.

Maintenance — 배치로 처리

read·write buffer에 쌓인 이벤트는 별도의 maintenance 단계가 배치로 drain합니다. Maintenance가 트리거되는 시점은 대략 다음과 같습니다.

  • read buffer가 threshold(보통 32) 이상 차오를 때 호출 스레드 중 하나가 시도한다.
  • write buffer에 이벤트가 들어올 때마다 시도한다.
  • expireAfter 같은 시간 기반 작업은 Pacer가 주기적으로 스케줄링한다.

여러 스레드가 동시에 maintenance에 진입하려 하면 한 스레드만 실제로 작업하고 나머지는 그냥 돌아갑니다 (try-lock 또는 CAS-based status). 사용자가 별도 executor를 지정했다면 maintenance는 그 쪽으로 위임되어 호출 스레드는 응답에 영향을 받지 않습니다.

이 분리가 Caffeine이 100% read 워크로드에서 Guava 대비 약 9배 처리량을 내는 이유입니다. hot path가 ConcurrentHashMap 한 번 + lock-free ring buffer 한 번이면 끝나기 때문입니다.

Entry state 전이 — Alive, Retired, Dead

putinvalidate, eviction이 동시에 진행되면 같은 엔트리에 대해 정책이 모순된 결정을 내릴 수 있습니다. Caffeine은 엔트리에 상태 머신을 붙여 이를 정리합니다.

  • Alive — 현재 캐시 안에 살아 있음.
  • Retired — 정책상 제거 결정이 났지만 아직 ConcurrentHashMap에서 제거되지 않음.
  • Dead — 모든 곳에서 제거 완료.

maintenance가 LRU 리스트를 정리할 때는 상태가 Alive인지 확인해서 이미 Retired된 엔트리는 건너뜁니다. 이로써 lock 없이도 동시 작업 간 일관성을 유지합니다.


실전 — Spring Boot에서 Caffeine 쓰기

API 측면에서 Caffeine은 Java 표준 캐시 추상(JSR-107 JCache)도 지원하고, Spring CacheManager 어댑터도 제공합니다. Spring Boot에서는 의존성과 짧은 설정 한 줄이면 끝납니다.

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
@EnableCaching
@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager("users", "products");
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(10))
            .recordStats());
        return manager;
    }
}

이렇게 두면 @Cacheable("users")를 붙인 메서드의 결과가 Caffeine에 자동 저장됩니다.

maximumSize vs maximumWeight

Caffeine.newBuilder()
    .maximumWeight(100_000_000) // 100MB
    .weigher((String key, byte[] value) -> value.length)
    .build();

maximumWeight는 엔트리 개수가 아니라 사용자 정의 weight 합으로 제한합니다. 단, weight는 엔트리 생성·업데이트 시점에 한 번 계산되어 정적으로 고정됩니다. 값이 시간에 따라 커지는 객체에는 적합하지 않습니다.

expireAfterAccess vs expireAfterWrite vs refreshAfterWrite

세 옵션은 비슷해 보이지만 의도가 다릅니다.

  • expireAfterWrite — 마지막 쓰기 후 N분 지나면 다음 조회 시 만료 처리.
  • expireAfterAccess — 마지막 읽기/쓰기 후 N분 지나면 만료.
  • refreshAfterWrite — 마지막 쓰기 후 N분 지난 엔트리를 조회하면 일단 stale 값을 반환하고 백그라운드에서 갱신.

가장 헷갈리는 조합이 refreshAfterWrite입니다. 사용자가 별도 executor를 지정하지 않으면 갱신은 기본 ForkJoinPool.commonPool()에서 비동기 수행되지만, commonPool task 압박 상황에서는 갱신이 지연되어 의도와 달리 stale 응답이 길어질 수 있습니다. Caffeine.executor()로 별도 풀을 지정하거나 AsyncLoadingCache를 쓰는 게 안전합니다.

AsyncCache — non-blocking 로딩

AsyncLoadingCache<Key, Value> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(5))
    .buildAsync(key -> loadAsync(key));

CompletableFuture<Value> future = cache.get(key);

buildAsync는 값 자체를 CompletableFuture로 저장하므로, 같은 키에 대한 두 번째 호출은 첫 번째 로딩이 끝나기 전에 같은 future를 반환합니다 — 자연스러운 request collapsing 효과가 생깁니다. 외부 API를 캐시할 때 thundering herd를 막는 핵심 패턴입니다.

Removal Listener — 캐시 밖과의 동기화

Caffeine.newBuilder()
    .maximumSize(1000)
    .evictionListener((key, value, cause) ->
        log.info("evicted {} due to {}", key, cause))
    .removalListener((key, value, cause) ->
        externalStore.invalidate(key))
    .build();

evictionListener는 정책에 의한 자동 제거에 대해, removalListener는 explicit invalidate 포함 모든 제거에 대해 호출됩니다. evictionListener는 정책의 동기 경로에서 실행되므로 가벼워야 하고, removalListener는 별도 executor에서 비동기 실행됩니다.


운영 함정

Caffeine은 "사고 안 나는 캐시"에 가깝지만, 그래도 자주 부딪히는 함정이 있습니다.

  1. maximumSize 미설정 — 빌더에서 maximumSize도 maximumWeight도 expireAfter도 안 걸면 사실상 ConcurrentHashMap입니다. 메모리 누수의 시작.
  2. refreshAfterWrite의 갱신 지연 — 별도 executor 미지정 시 Caffeine.executor()의 기본값인 ForkJoinPool.commonPool()에서 갱신이 수행됩니다. commonPool에 task 압박이 있는 상황에서는 갱신이 지연되어 stale 응답이 길어질 수 있습니다.
  3. weakKeys() 모드weakKeys()는 일관성과 GC 행위에 영향을 줍니다. 특히 equals() 기반 키 비교가 == (identity 비교)로 바뀌므로 Integer.valueOf(1) != new Integer(1)가 캐시 miss를 일으킵니다. softValues()/weakValues()는 값 비교에만 영향이 있고 키 비교는 변경되지 않습니다.
  4. recordStats() 오버헤드 — 통계 활성화는 LongAdder 갱신 비용이 있습니다. 대부분 무시할 만하지만 초고처리량 환경에서는 주의가 필요합니다.
  5. weight 갱신 불가weigher가 반환하는 weight는 entry put 시점에 고정되므로, mutable 객체(List를 add하는 식)의 weight 변화는 추적되지 않습니다. 캐시 비용 추정이 어긋날 수 있습니다.
  6. invalidateAll의 비용invalidateAll()은 모든 키를 순회하면서 evict 이벤트를 발행합니다. 큰 캐시에서 호출하면 maintenance 단계가 길어질 수 있습니다.

다른 정책과의 비교

정책 메모리 오버헤드 스캔성 워크로드 빈도 망각 실용성
LRU 작음 약함 (오염 발생) 없음 (recency만) 단순
LFU (전통) 강함 없음 (옛 데이터 점거) 거의 안 씀
ARC 캐시 크기 2배 강함 부분적 IBM 특허 이슈
LIRS 캐시 크기 3배 강함 부분적 구현 복잡
W-TinyLFU 8 bytes/entry 강함 (어드미션 차단) 강함 (aging) 특허·복잡도 모두 없음

Caffeine wiki의 시뮬레이션에서는 Wikipedia 트래픽, ERP 워크로드, 검색 엔진 트레이스, 데이터베이스 페이지 접근 등 다양한 실데이터에서 W-TinyLFU가 LRU·ARC·LIRS와 견주어 평균적으로 가장 높은 hit rate를 냅니다. 특히 풀 스캔 같은 한 번성 접근이 섞이는 워크로드에서 LRU와의 격차가 크게 벌어집니다.


정리

Caffeine이 풀어낸 문제는 세 갈래입니다.

  • 적중률 — Window LRU + SLRU + TinyLFU 어드미션으로 스캔성 접근과 빈도 망각 양쪽을 잡았다.
  • 메모리 오버헤드 — Frequency Sketch를 4-bit Count-Min Sketch + aging으로 압축해 엔트리당 8바이트로 끝냈다.
  • 동시성 — read/write buffer로 정책 갱신을 hot path 밖으로 미루고, ring buffer + striping + Alive/Retired/Dead 상태 머신으로 lock contention을 거의 없앴다.

Spring Cache의 사실상 기본 백엔드가 Caffeine으로 굳어진 이유는 코드 한두 줄로 이 모든 설계를 다 가져갈 수 있기 때문입니다. 한 번 더 hit해야 Protected로 승격된다는 작은 규칙 하나가 캐시 오염을 어떻게 막는지를 떠올리면, @Cacheable 뒤편에서 일어나는 일이 더 잘 보입니다.


참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

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

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

May 15, 202614 min read

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

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

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

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