상세 컨텐츠

본문 제목

Spring 프로젝트 컨트리뷰터 된 후기 (ThreadLocal.set(null) vs ThreadLocal.remove() )

공부/Spring

by seungpang 2024. 5. 24. 23:32

본문

반응형

스프링 공식문서를 보다가 ThreadLocal에 주의사항에 대한 글을 봤다.

 

ThreadLocal instances come with serious issues (potentially resulting in memory leaks) when incorrectly using them in multi-threaded and multi-classloader environments. You should always consider wrapping a ThreadLocal in some other class and never directly use the ThreadLocal itself (except in the wrapper class).
Also, you should always remember to correctly set and unset (where the latter simply involves a call to ThreadLocal.set(null)) the resource local to the thread. Unsetting should be done in any case, since not unsetting it might result in problematic behavior. Spring’s ThreadLocal support does this for you and should always be considered in favor of using ThreadLocal instances without other proper handling code.

 

간단히 요약하자면 ThreadLocal 인스턴스는 멀티 스레드 및 멀티 클래스 로더 환경에서 부적절하게 사용할 경우 심각한 문제(메모리 누수 가능성)를 일으킬 수 있고 스레드에 로컬 리소스를 설정하고 해제하는 것을 항상 올바르게 기억해야 한다.

해제는 ThreadLocal.set(null) 호출만으로 간단히 할 수 있다. 해제를 하지 않으면 문제를 일으킬 수 있으므로, 해제는 어떤 경우든지 해야 한다.

그래서 스프링 프로젝트 내부에서도 ThreadLocal 리소스 해제를 ThreadLocal.set(null)로 하는지 궁금해서 찾아봤다.

찾아봤더니 다 ThreadLocal.remove()로 해제를 하고 있다. 그 이유는 뭘까?

이미 과거에 해당 문제에 대한 이슈가 있었다.

결국에는 ThreadLocal 이는 값만 null로 설정하고 실제로 스레드 로컬 맵에서 항목을 제거하지 않아 메모리 누수를 일으킨다.

더 자세한 내용은 밑에서 추가적으로 설명하겠다.

그래서 이와 같은 문제를 발견하고 PR을 제출했고 바로 반영이 되었다.

그래서 다른 Spring 프로젝트들도 동일한 문제가 있을까? 하고 찾아보다가 Spring batch에서도 .set(null)을 사용하는 부분이 있어서

추가적으로 PR을 제출했다.

Spring Batch에는 내 이름까지 남길 수 있었다.

그렇다면 ThreadLocal.set(null)이 무잇이 문제인지 정확하게 살펴보도록 하자


ThreadLocal의 내부 구현

ThreadLocal 클래스는 내부적으로 ThreadLocalMap이라는 클래스를 사용하여 각 스레드별로 데이터를 저장한다.

ThreadLocalMap은 각 스레드의 인스턴스에 의해 관리되며, 이 맵의 키는 ThreadLocal 객체이고 값은 실제 저장되는 데이터다.

public class ThreadLocal<T> {
    static class ThreadLocalMap {
        static class Entry extends WeakReference<ThreadLocal<?>> {
            Object value;
            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
        //...
    }
    //...
}

ThreadLocalMap의 Entry 클래스는 키로 ThreadLocal 객체를 가지고, 값으로 실제 데이터를 갖는다.

여기서 중요한 점은 키가 약한 참조(WeakReference)로 되어 있다는 점이다.


WeakReference와 메모리 누수

약한 참조(WeakReference)는 가비지 컬렉터가 해당 객체를 수거할 수 있도록 하는 참조 타입이다.

강한 참조(Strong Reference)와 달리, 약한 참조는 가비지 컬렉션의 대상이 될 수 있다.

그러나 ThreadLocal.set(null)을 사용하여 값만 null로 설정하면, 여전히 참조가 맵에 남아있게 되어 가비지 컬렉터가 이를 수거하지 못하고 메모리 누수가 발생할 수 있다.

약한 참조가 사용되더라도, ThreadLocal 객체가 더 이상 사용되지 않으면 가비지 컬렉션이 발생할 때 수거될 수 있다.

그러나 이 경우에도 ThreadLocalMap의 엔트리 자체는 남아있어 메모리 누수가 발생할 수 있다.


ThreadLocal.remove()의 필요성

ThreadLocal.remove()는 현재 스레드의 ThreadLocal 변수에서 항목을 완전히 제거하여 메모리 누수를 방지한다.

remove() 메서드는 ThreadLocalMap에서 해당 항목을 완전히 제거하므로, 가비지 컬렉터가 이를 수거할 수 있게 한다.

다음 예제 코드를 통해 ThreadLocal.set(null)ThreadLocal.remove()의 차이를 비교할 수 있다.

public class Main {
    private static ExecutorService executorService = Executors.newFixedThreadPool(2);

    private static void simulateMemoryLeak() {
        ThreadLocal<byte[]> threadLocal = new ThreadLocal<>();

        // 10MB 할당
        threadLocal.set(new byte[1024 * 1024 * 10]);

        // set null
        //threadLocal.set(null);

        // remove
        threadLocal.remove();
    }

    public static void main(String[] args) {
        Runtime runtime = Runtime.getRuntime();
        List<GarbageCollectorMXBean> gcBeans = ManagementFactory.getGarbageCollectorMXBeans();

        for (int i = 0; i < 100; i++) {
            executorService.submit(() -> {
                simulateMemoryLeak();
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        executorService.shutdown();

        try {
            if (!executorService.awaitTermination(1, java.util.concurrent.TimeUnit.MINUTES)) {
                executorService.shutdownNow();
            }
        } catch (InterruptedException e) {
            executorService.shutdownNow();
        }
        System.out.println(getGcCount(gcBeans));
        // 최종 메모리 사용량 측정
        long finalMemoryUsage = runtime.totalMemory() - runtime.freeMemory();
        System.out.println("Final memory usage: " + finalMemoryUsage);
    }

    private static long getGcCount(List<GarbageCollectorMXBean> gcBeans) {
        return gcBeans.stream().mapToLong(GarbageCollectorMXBean::getCollectionCount).sum();
    }
}

위에 코드는 ThreadLocal.set(null)ThreadLocal.remove()를 비교하기 위한 예제 코드이다.

ThreadLocal을 사용하여 10MB의 메모리를 할당하고, 이를 제거하는 과정을 포함한다.

주석 처리된 threadLocal.set(null) 부분은 값만 null로 설정하는 경우를 나타내며, 이는 메모리 누수를 유발할 수 있다.

threadLocal.remove()를 사용하여 ThreadLocal 객체를 명시적으로 제거하면 메모리 누수를 방지할 수 있다.

프로그램은 100개의 작업을 스레드 풀에 제출하고, 각 작업에서 메모리 누수를 시뮬레이션한다. 실행 후, 가비지 컬렉션 횟수와 최종 메모리 사용량을 출력한다.

각각 실행한 결과이다.

threadLocal.set(null) 인 경우

10
Final memory usage: 121869984
  • 메모리 사용량이 매우 높은 것을 알 수 있습니다. 이는 값만 null로 설정하고, ThreadLocalMap에서 항목을 제거하지 않았기 때문에 메모리 누수가 발생했음을 의미한다.

threadLocal.remove() 인 경우

11
Final memory usage: 45322944
  • 메모리 사용량이 훨씬 적다. 이는 ThreadLocal 객체를 완전히 제거하여 가비지 컬렉터가 이를 수거할 수 있게 했기 때문이다.

결론

ThreadLocal.set(null)을 사용하여 값을 null로 설정하는 것만으로는 충분하지 않다.

값이 null로 설정되더라도, ThreadLocalMap의 엔트리 자체는 여전히 존재하게 되어 메모리 누수가 발생할 수 있다.

따라서, ThreadLocal.remove()를 사용하여 현재 스레드의 ThreadLocal 변수에서 항목을 완전히 제거하는 것이 바람직합니다.

스프링 프레임워크에서도 이와 같은 이유로 ThreadLocal 리소스를 해제할 때 ThreadLocal.set(null)이 아닌 ThreadLocal.remove()를 사용하고 있다.

이는 메모리 누수를 방지하고, 멀티 스레드 및 멀티 클래스 로더 환경에서도 안전하게 동작할 수 있도록 하기 위함이다.

이와 같은 문제를 발견하고 PR을 제출하여 반영되었다는 것이 만족스럽다.

Spring Batch PR

Spring framework PR

관련글 더보기