Language/Java

Java - ThreadLocal 동작 원리 이해하기! (+ 주의사항)

JaeHoney 2022. 11. 6. 16:43

지난 번에 샤딩을 앱 서버에서 처리하기 위해 ThreadLocal이라는 공간에 매핑될 DB 서버 정보를 넣어줬다.

그렇다면 ThreadLocal은 도대체 어떻게 동작하는 걸까..?

 

자세히 알아보자.

ThreadLocal

ThreadLocal은 java.lang에서 지원하는 클래스로 Thread-Safety하다고 알려져있다.

 

나는 'ThreadPool각 ThreadThreadLocal이라는 공간을 가진다. ThreadLocal 하나의 인스턴스로 접근할 수 있고, 멀티 쓰레드 환경에서도 안전하게 동작한다.' 정도로 이해했었다.

 

그런데 어떻게 그런 것이 가능한 지 의문이 있었다.

ThreadLocal 내부를 분석해보면 ThreadLocalMap이라는 클래스를 구현하고 있다.

(필드로 가지지는 않는다.)

ThreadLocal의 get() 메서드의 구현 로직은 위와 같다.

ThreadLocal의 set() 메서드는 위와 같다.

 

ThreadLocalget()set() 등의 메서드를 통해 해당 ThreadLocalMap에 접근한다.

 

그렇다면 ThreadLocalMap은 어디에 있을까?!

바로 Thread 인스턴스 필드ThreadLocalMap이 있다.

 

즉, 하나의 Thread는 1개의 ThreadLocalMap을 가질 수 있다.

 

ThreadLocal의 동작을 정리하자면 아래와 같다.

  1. ThreadLocal은 Thread.currentThread()로 Heap에서 해당 Thread를 조회한다.
  2. 해당 Thread 안에 있는 ThreadLocalMap을 불러온다.
  3. 자기 자신(ThreadLocal)을 key로 ThreadLocalMap에서 value를 가져오거나 set한다.

그렇다면 이제 ThreadLocal이 왜 인스턴스를 생성하는 방식으로 동작하는 지 이해가 된다!

ThreadLocal의 내부를 분석해보면 인스턴스 변수는 다음 3개 밖에 없다.

  • final int threadLocalHashCode = nextHashCode();
  • static int nextHashCode = new AtomicInteger();
  • static final int HASH_INCREMENT = 0x61c88647;

ThreadLocalnextHashCodestatic으로 가지고 있다.

 

ThreadLocal다른 ThreadLocal과 구분하기 위한 용도nextHashCode()를 사용해서 threadLocalHashCode를 매번 다르게 할당해서 해시 충돌이 발생하지 않게 한다.

  • nextHashCode는 Thread-Safety를 위해 AtomicInteger로 감싼 것
  • HASH_INCREMENT는 각 ThreadLocal의 해시 코드를 다르게 동작하기 위해서 매번 증가 시킬 때 사용하는 상수

이러한 내부 동작을 보면서 알 수 있는 중요한 사실은 아래와 같다.

  • ThreadLocal사실은 공간의 개념이 아니다.
  • ThreadLocalMap데이터를 꺼낼 때 사용되는 Key이다.
  • 현재 Thread의 ThreadLocalMap을 제어하기 위한 기능을 제공한다.
    • 단, Key는 반드시 자기 자신으로만 사용할 수 있다.

그림으로 요약하면 아래와 같다!

그래서 아래와 같이 ThreadLocal을 Holder에 담아서 사용한다고 가정하자.

public class ContextHolder {
    public static ThreadLocal<T> threadLocal = new ThreadLocal<T>();
}

해당 Holder에서 threadLocal 인스턴스를 사용하면 현재 Thread의 ThreadLocalMap에 접근해서 해당 ThreadLocal의 데이터를 넣거나 가져올 수 있게 된다.

remove

만약 ThreadLocal에 값을 넣었고, 해당 Thread 계속 재사용된다면 해당 ThreadLocalMap과 같은 자원을 그대로 가진 채로 동작하게 된다.

 

그래서 ThreadLocal을 다룰 때는 사용 이전, 혹은 사용 이후에 반드시 ThreadLocal remove()를 통해 초기화하는 처리가 필요하다.

threadLocal.remove();

비동기

개발을 하다보면 특정 작업을 비동기로 다른 쓰레드에게 작업을 위임할 수 있다. 만약 @Async 등을 사용해서 비동기로 별도 스레드에서 처리하는 경우를 생각해보자.

 

이때 작업을 위임받은 Thread기존 쓰레드의 ThreadLocalMap을 가지지 않게 된다. 즉, 비동기 Thread에서 ThreadLocal에 접근해도 아무 것도 없을 것이다. 즉, 원치 않는 결과를 반환할 수 있다.

 

이 경우 TaskExecutor Custom한 TaskDecorator를 설정하면 해결이 가능하다.

@Component
public class TaskDecoratorImpl implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable task) {
        DBInfo dbInfo = ThreadLocalHolder.getDBInfo();
        
        return() -> {
            try {
                ThreadLocalHolder.setDBInfo(dbInfo);
                runnable.run();
            } finally {
                ThreadLocalHolder.clear();
            }
        };
    }
}

Task Decorator작업(Task)을 시작하기 직전에 진행할 작업을 추가할 수 있도록 만든 interface이다.

 

즉, 위 코드는 비동기로 작업을 시작하기 전에 기존 Thread가 사용하던 공간을 복사해서 다른 Thread에 옮겨준다.

@Configuration
@EnableAsync
@RequiredArgsConstructor
public class AsyncConfig {

    private final TaskDecorator taskDecorator;

    @Bean 
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
        taskExecutor.setCorePoolSize(20);
        taskExecutor.setMaxPoolSize(50);
        taskExecutor.setQueueCapacity(200);
        taskExecutor.setTaskDecorator(taskExecutor);
        taskExecutor.setThreadNamePrefix("async-task-");
        return taskExecutor;
    }

}

taskExecutor.setTaskDecorator() 메서드로 직접 정의한 TaskDecorator를 사용하도록 설정한다.

 

이후에는 비동기에서도 안전하게 ThreadPool을 사용할 수 있다.

참고