일단 해보고, 아니면 뭐

실패했어도 배웠으면 실패가 아냐.

무한한 개발세계 여행기

Programing/Java

Java ThreadLocal 파헤치기

개발자 김은혜 2022. 7. 16. 02:33

요즘에 나는 인프런 강의를 즐겨 듣고 있다.

오늘은 스프링 핵심 원리 - 고급편 강의를 들었는데 강의 내용이

'ThreadLocal'를 이용하여 동시성 이슈를 해결하는 거였다.

 

ThreadLocal은 자바를 공부하던 시절부터 자주 들어왔어서

기능 자체가 낯설지는 않았다.

쓰레드 별로 본인의 특별한 저장공간? 을 만들어서 사용하는 기능인데,

어떻게 쓰고, 무슨 일을 하는지는 알지만

 

'ThreadLocal이 어떻게 이런 기술을 구현했는지'는 따로 알아본 적이 없는 거 같다!

 

그래서 나는 이번 기회에 ThreadLocal 클래스를 분석해서

어떻게 쓰레드간 동시성 이슈를 해결했는지 정리해보고자 한다!

 

(동시성 이슈는 서버 개발을 하다 보면 무조건 겪게 되는 거 같다.

이번 기회에 원리와 구현방식을 공부하면 많은 도움이 될 거라 생각한다!)

 

 

 

 


먼저 ThreadLocal 클래스는 public 접근제한자로 외부에 공개된 함수는

set / get / remove 로 총 3가지이다.

 

함수 이름에서도 바로 짐작 가능하듯

- set : 데이터 저장

- get : 데이터 조회

- remove : 데이터 제거

라고 생각하면 될 것 같다.

 

 

 

가장 중요하다고 판단하는(?) set에 대해서 분석해보자.

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        map.set(this, value);
    } else {
        createMap(t, value);
    }
}

먼저 Thread.currentThread()를 통해 현재 실행 중인 Thread 정보를 받아온다.

그 후 getMap(t) 를 통해 ThreadLocalMap을 조회한다.

(ThreadLocalMap은 Thread의 필드로 전역변수에 할당되어 있다.)

 

만약 조회해온 ThreadLocalMap이 있다면 (null 이 아니라면)

저장할 데이터를 ThreadLocalMap에 저장 (set) 해주고

존재하지 않는다면 (null이라면)

새로 만들어 (createMap) 저장해준다.

 

 

흐름을 잠깐 봤지만 사실 중요한 부분은

LocalThread 가 아닌

실제 데이터를 저장하는 클래스인 LocalThreadMap이다.

LocalThread는 Thread 별 데이터 공간인 LocalThreadMap에 데이터를 저장해주고

조회 및 삭제를 해주는 기능만 구현되어 있을 뿐이다.

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

private static final int INITIAL_CAPACITY = 16;

private Entry[] table;

private int size = 0;

private int threshold; // Default to 0

LocalThreadMap은 value 필드를 가지고 있는 Entry 내부 클래스 하나와

네 가지의 전역 필드들을 가지고 있다.

 

 


그렇다면 이제

LocalThreadMap의 set을 살펴보자!

(현재 진행 중인 Thread의 LocalThreadMap이 null이 아닐 때 호출)

private void set(ThreadLocal<?> key, Object value) {

    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);

    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        if (e.refersTo(key)) {
            e.value = value;
            return;
        }

        if (e.refersTo(null)) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

필요한 변수들을 할당하고

(무언가의 수식에 의한) 인덱스 증가에 따른 반복문을 돌며 Entry 객체를 꺼낸다.

 

Entry 객체가 파라미터로 넘어온 ThreadLocal을 참고하고 있다면

(refersTo() 함수는 native 함수로 되어있어 구현 코드를 볼 수 없다. 검색하면 나오겠지만..)

Entry의 value 필드를 파라미터로 넘어온 value 필드로 변경한 후 return 한다.

 


다시 정리해 보자면

Thread 별로 ThreadLocalMap 이 존재하고,

ThreadLocalMap에는 Entry라는 value를 담는 내부 클래스가 있고

종류별로 한 개씩 여러 Entry를 보관하기 위해 Entry[] 를 전역 변수로 사용 중이다.

 

ThreadLocal<T> 은 제네릭 하기 때문에

ThreadLocalMap 에는 T 유형의 Entry를 하나씩 가지고 있을 수 있고 (정책상 여러 개이면 안되니까!)

T 유형의 존재  판단 여부를 refersTo() 함수로 처리했다고 볼 수 있다!

 

그래서 참고하는 데이터가 있으면 새로운 값으로 덮어쓰는 거다!


 

그 후 만약에 ThreadLocal을 참고하는 것이 아닌 null을 참고하고 있다면

(= Entry 객체가 존재는 하나 그 어느 ThreadLocal 도 참고하고 있지 않다면)

replaceStaleEntry() 함수를 호출한다.

private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                               int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    int slotToExpunge = staleSlot;
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.refersTo(null))
            slotToExpunge = i;

    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        if (e.refersTo(key)) {
            e.value = value;

            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        if (e.refersTo(null) && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

함수는 이렇게 생겼는데 (알고리즘 레벨이기 때문에)

대충 확인만 해보자면 배열 특성상 새로운 Entry 객체의 삽입 및 삭제가 어렵기 때문

index 기준으로 이전 공간 이후 공간을 훑어가며

사용되지 않는 공간 index를 추출해 해당 공간에 새로운 Entry를 생성하여 추가하는 내용이다!

 

 

만약 전체 Entry[] 배열을 돌았음에도 조건문에 걸리지 않는다면

ThreadLocal 을 참조하는 데이터도 존재하지 않고, 비어있는 공간도 없는것으로 판단해

새로운 Entry 객체를 만들어 추가해준다!

 

 

 


그 후 다시 ThreadLoca set() 함수로 돌아가

조회해온 ThreadLocalMap이 없다면 (null이라면)

ThreadLocalMap 생성자를 통해 새로 만들어준다!

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    table = new Entry[INITIAL_CAPACITY];
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    table[i] = new Entry(firstKey, firstValue);
    size = 1;
    setThreshold(INITIAL_CAPACITY);
}

Entry 들을 담을 Entry[] 을 생성해주고

그 안에 새롭게 추가된 값을 저장해준다!

 

 

 

여기까지가 ThreadLcoal set() 함수의 흐름이다.

분석하면서 느낀 건데

사실 이 부분이 동시성 이슈를 해결했다? 는 부분은 아닌 거 같다.

 

단지 Thread 클래스에는 전역 변수로 LocalThreadMap 이 있기에

(즉, 한 Thread당 한 LocalThreadMap 이 있다.)

Thread Safe 하게 데이터를 관리할 수 있는 것뿐이었다.

다만 추가로 든 생각은

우리는 웹 서비스를 개발할 때 WAS에서는 Thread 생성 수거의 비용을 줄이기 위해

Thread Pool에 Thread를 미리 만들어 담아놓은 후

요청이 있을 때마다 가져다가 쓰게 된다.

 

그 말은 즉, 서버가 기동 되면 한 번에 일정 개수에 Thread Class 가 생성되게 되고

각 Thread 마다 저장 공간인 ThreadLocalMap 이 있다는 거다.

 

하지만 Thread는 한번 쓰이고 제거되는 것이 아니라 재 사용되기 때문에

(그렇기 때문에 요청에서는 최초의 set 이더라도 LocalThreadMap은 null이 아닐 수도 있다)

ThreadLocalMap에 저장된 Entry 데이터들을 제거해 주지 않으면

다른 요청에 같은 Thread 사용 시 데이터 동시성 이슈가 다시 일어날 수 있다는 점이다!

(중요 중요!!)

 

이때 데이터 제거는 remove() 함수를 사용한다.

 

 

 

 

 

 

물론 오늘 분석한 내용으로

동시성 이슈를 어떻게 해결했는지에 '아주 근본적인 해결 방안'은 알지 못했지만

'대략적인 방안' 은 알게 된 거 같고, 추가 이슈 가능성도 깨닫게 되어 좋은 시간이었다!

 

(대략적인 방안? Thread 별로 전역 변수에 데이터 저장 공간인 LocalEntryMap을 할당했다)

 

그럼 안녕!