Effective Java : 아이템32. 완벽공략45-46

    들어가기 전

    이 글은 인프런 백기선님의 강의를 복습하며 작성한 글입니다.


    이 글의 요약

     

     


    완벽공략 45. ThreadLocal (쓰레드 지역 변수)

    • 모든 멤버 변수는 기본적으로 여러 쓰레드에서 공유해서 쓰일 수 있다. 이 때 쓰레드 안전성과 관련된 여러 문제가 발생할 수 있다.
      • 경합 (Race-Condition)
      • 교착상태(Dead Lock)
      • LiveLock : 쓰레드끼리 Lock만 계속 교체하는 상태다.
    • 쓰레드 지역 변수를 사용하면 동기화를 하지 않아도 한 쓰레드에서만 접근 가능한 값이기 때문에 안전하게 사용할 수 있다. 
    • 한 쓰레드 내에서 공유하는 데이터로, 메서드 매개변수에 매번 전달하지 않고 전역변수처럼 사용할 수 있다. 

    ThreadLocal (쓰레드 로컬)이란?

    쓰레드 로컬은 쓰레드 범위의 변수다. 쓰레드 로컬은 Thread-Safe한 환경을 손쉽게 제공해준다. 따라서 ThreadLocal을 사용한다면 Thread-safe 한 코드를 작성하기 위해 고민하지 않아도 된다는 장점이 있다. 만약 쓰레드 로컬 없이 객체가 가지고 있는 멤버 필드를 여러 쓰레드에서 사용한다면 Thread-Safe를 신경써서 코드를 작성해야한다. 이 부분을 ThreadLocal이 한방에 해결해주는 것이다. 


    쓰레드 안전하지 않은 코드 

    아래 코드는 쓰레드 안전하지 않은 코드다. 쓰레드 안전하지 않은 이유는 멤버 필드인 formatter에 여러 쓰레드가 동시에 접근해서 값을 바꾸기 때문이다. 여러 쓰레드가 동시에 formatter 필드에 접근한다면, 변경점이 다른 쓰레드에 전파되므로 쓰레드 안전하지 않게 된다. 

    public class ThreadLocalExampleUnSafe implements Runnable{
    
        // not thread-safe.
        private SimpleDateFormat formatter = new SimpleDateFormat("yyyyMMdd HHmm");
    
        public static void main(String[] args) throws InterruptedException {
            ThreadLocalExampleUnSafe obj = new ThreadLocalExampleUnSafe();
            for (int i = 0; i < 10; i++) {
                Thread t = new Thread(obj, "" + i);
                Thread.sleep(new Random().nextInt(1000));
                t.start();
            }
        }
    
        @Override
        public void run() {
            System.out.println("Thread Name = " + Thread.currentThread().getName() + " default Formatter = " + formatter.toPattern());
            try {
                Thread.sleep(new Random().nextInt(1000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
            // not thread-safe
            formatter = new SimpleDateFormat();
            System.out.println("Thread Name = " + Thread.currentThread().getName() + " Formatter = " + formatter.toPattern());
        }
    }

    위 코드를 실행한 결과를 보면 무슨 의미인지 좀 더 명확히 알 수 있다. 위와 같이 멤버 필드를 여러 쓰레드가 동시에 접근하면 다른 쓰레드에도 영향을 미칠 수 있다. 

    • 처음에는 default Formatter를 출력하는 위치에서 yyyyMMdd HHmm을 출력해준다.
    • 그런데 어떤 쓰레드가 formatter를 SimpleFormater로 초기화 한 시점부터 default Formatter에서도 yyyyMMdd HHmm이 출력된다. 
    Thread Name = 0 default Formatter = yyyyMMdd HHmm
    Thread Name = 1 default Formatter = yyyyMMdd HHmm
    Thread Name = 1 Formatter = yy. M. d. a h:mm
    Thread Name = 2 default Formatter = yy. M. d. a h:mm
    Thread Name = 0 Formatter = yy. M. d. a h:mm
    Thread Name = 3 default Formatter = yy. M. d. a h:mm
    Thread Name = 3 Formatter = yy. M. d. a h:mm
    Thread Name = 2 Formatter = yy. M. d. a h:mm
    Thread Name = 4 default Formatter = yy. M. d. a h:mm
    Thread Name = 4 Formatter = yy. M. d. a h:mm
    Thread Name = 5 default Formatter = yy. M. d. a h:mm
    Thread Name = 6 default Formatter = yy. M. d. a h:mm
    Thread Name = 5 Formatter = yy. M. d. a h:mm
    Thread Name = 7 default Formatter = yy. M. d. a h:mm
    Thread Name = 6 Formatter = yy. M. d. a h:mm
    Thread Name = 8 default Formatter = yy. M. d. a h:mm
    Thread Name = 7 Formatter = yy. M. d. a h:mm
    Thread Name = 8 Formatter = yy. M. d. a h:mm
    Thread Name = 9 default Formatter = yy. M. d. a h:mm
    Thread Name = 9 Formatter = yy. M. d. a h:mm

     


    ThreadLocal을 사용한 Thread Safe 확보

    위에서 발생한 문제는 formatter를 멤버 필드가 아닌 각 쓰레드 로컬에 생성해주는 것으로 해결할 수 있다. ThreadLocal은 각 쓰레드가 자신의 범위에서만 참조 가능한 객치이기 때문에 Thread끼리는 공유할 수 없게 된다. 따라서 Thread Safe를 고려하지 않은 코딩을 해도 괜찮다. 

    아래 코드는 ThreadLocal을 반영한 코드다.

    public class ThreadLocalExampleSafe implements Runnable{
    
        // SimpleDateFormat is not thread-safe, so give one to each thread.
        private static final ThreadLocal<SimpleDateFormat> formatter =
                ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm"));
    
        public static void main(String[] args) throws InterruptedException {
            ThreadLocalExampleSafe obj = new ThreadLocalExampleSafe();
            for (int i = 0; i < 10; i++) {
                Thread t = new Thread(obj, "" + i);
                Thread.sleep(new Random().nextInt(1000));
                t.start();
            }
        }
    
        @Override
        public void run() {
            System.out.println("Thread Name = " + Thread.currentThread().getName() + " default Formatter = " + formatter.get().toPattern());
            try {
                Thread.sleep(new Random().nextInt(1000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
            // formatter pattern is changed here by thread, but it won't reflect to other thread with threadLocal.
             formatter.set(new SimpleDateFormat());
            System.out.println("Thread Name = " + Thread.currentThread().getName() + " Formatter = " + formatter.get().toPattern());
        }
    }

    필드 멤버로 선언하긴 하지만 ThreadLocal 타입으로 선언했기 때문에 각 쓰레드가 쓰레드 범위 내에서만 참조할 수 있는 값이 된다. 따라서 Thread Safe하고 main() 메서드의 결과도 Thread Safe한 것을 볼 수 있다. 

    • 아래 실행결과에서 default Formatter는 항상 yyyyMMdd HHmm을 출력한다. 
    • Formatter는 항상 yy. M. d. a h:mm을 출력한다. 
    Thread Name = 0 default Formatter = yyyyMMdd HHmm
    Thread Name = 1 default Formatter = yyyyMMdd HHmm
    Thread Name = 1 Formatter = yy. M. d. a h:mm
    Thread Name = 0 Formatter = yy. M. d. a h:mm
    Thread Name = 2 default Formatter = yyyyMMdd HHmm
    Thread Name = 2 Formatter = yy. M. d. a h:mm
    Thread Name = 3 default Formatter = yyyyMMdd HHmm
    Thread Name = 4 default Formatter = yyyyMMdd HHmm
    Thread Name = 4 Formatter = yy. M. d. a h:mm
    Thread Name = 3 Formatter = yy. M. d. a h:mm
    Thread Name = 5 default Formatter = yyyyMMdd HHmm
    Thread Name = 5 Formatter = yy. M. d. a h:mm
    Thread Name = 6 default Formatter = yyyyMMdd HHmm
    Thread Name = 6 Formatter = yy. M. d. a h:mm
    Thread Name = 7 default Formatter = yyyyMMdd HHmm
    Thread Name = 7 Formatter = yy. M. d. a h:mm
    Thread Name = 8 default Formatter = yyyyMMdd HHmm
    Thread Name = 8 Formatter = yy. M. d. a h:mm
    Thread Name = 9 default Formatter = yyyyMMdd HHmm
    Thread Name = 9 Formatter = yy. M. d. a h:mm

    완벽공략 46. ThreadLocalRandom (쓰레드 지역 랜덤값 생성기)

    • java.util.Random은 멀티 쓰레드 환경에서 CAS(CompareAndSet)로 인해 성능이 좋지 않을 수 있다.
      • Random은 낙관적 락으로 구성되어있고, 멀티 쓰레드 환경에서 실패하면 성공할 때까지 재시도한다. 따라서 실패할 때 발생하는 Overhang으로 인해 성능이 저하되기도 함. 
    • ThreadLocalRandom은 Thread 범위에서만 사용하는 Random이기 때문에 낙관적 락에 의한 성능 감소가 발생하지 않는다. 

     


    Random은 낙관적 락으로 구현됨. 

    아래 코드에서 random.nextInt()로 새로운 값을 호출하는 것을 볼 수 있다. 

    public static void main(String[] args) {
        Random random = new Random();
        System.out.println(random.nextInt(10));
    }

    nextInt()의 메서드는 next()라는 메서드를 호출한다. next() 메서드에서는 두 가지를 알 수 있다.

    • seed라는 값은 AtomicLong이다.
    • while문을 돌면서 compareAndSet()을 성공할 때 까지 호출한다.
    protected int next(int bits) {
        long oldseed, nextseed;
        AtomicLong seed = this.seed;
        do {
            oldseed = seed.get();
            nextseed = (oldseed * multiplier + addend) & mask;
        } while (!seed.compareAndSet(oldseed, nextseed));
        return (int)(nextseed >>> (48 - bits));
    }

     

    AtomicLong?

    AtomicLong은 Concurrent 패키지에 들어있는 녀석이다. Atomic으로 시작되는 클래스는 멀티 쓰레드 환경에서 ThreadSafe한 환경을 제공하는데 사용된다. 그러면 ThreadSafe한 환경은 어떻게 제공해줄 수 있을까?

    • 비관적 락 : 경합이 발생할 것이라 생각한다. 따라서 반드시 락을 접근해서 처리할 수 있다. 락을 얻을 때까지 기다린다.
      • 화장실의 문이 잠겼으면, 문이 열릴 때까지 들어가지 않고 기다린다
      • 화장실의 문이 열렸으면, 화장실에 들어가서 볼 일을 본다.
    • 낙관적 락 : 경합이 발생하지 않을 것이라 생각한다. 일단 접근하고, 기대하고 있던 상태와 다르면 실패한다. 기대하고 있던 상태라면 자신이 할 일을 한다. 
      • 화장실에 일단 들어간다. 만약 사람이 있으면 실패하고, 나중에 다시 시도하거나 한다
      • 화장실에 일단 들어간다. 만약 사람이 없으면 내가 하고자 했던 일을 한다. 

    여기서 Atomic 키워드는 낙관적 락이다. 비관적 락은 락을 획득할 때 까지 기다리기 때문에 성능이 상대적으로 떨어진다. 반면 낙관적 락은 락을 얻을 때까지 기다리지 않기 때문에 비관적락보다 좀 더 좋은 성능을 보여준다. 

     

    AtomicLong은 낙관적 락을 사용한다.

    AtomicLong은 Optimistic Lock을 쓴다. 내부적으로는 compareAndSet()이라고 하지만, 여기서 사용하는 로직의 좀 더 일반적인 이름은 Compare And Swap에 가깝다. 실제 자바 코드에서는 네이티브 코드로 이루어져 있어서 살펴볼 수는 없지만, 비슷한 형태로 구현된 CompareAndSwap() 코드를 아래에서 살펴볼 수 있다. 

    // Synchronized 키워드는 비관적 락이다.
    public synchronized int compareAndSwap(int expectedValue, int newValue) {
        int readValue = value;
        if (readValue == expectedValue)
            value = newValue;
        return readValue;
    }

    위의 코드를 살펴보면 다음같이 동작하는 것을 볼 수 있다. 

    1. 내가 원래 가지고 있어야 할 값(expectedValue)을 그대로 가지고 있다면(readValue), 내가 원하는 값(newValue)으로 수정을 한다. (화장실을 열었을 때, 이전 화장실의 상태와 똑같다면 내가 볼 일을 본다.)
    2. 만약 다른 쓰레드가 값을 바꾸어 readValue와 expectedValue가 다르다면, 내가 원하는 상태가 아니기 때문에 실패하고 다시 시도한다. 

    지금 위 코드에서는 실패 했을 때 아무것도 하지 않는다. 그런데 실패했을 때 재시도/예외를 던지는 형태로도 처리가 가능하다. 아무튼 compare And Swap의 의미는 '내가 기대한 값일 때만 값을 변경'하는데, 그런 의미로 원자적(Atomic)이라고 한다. 

     

    Random은 왜 성능이 떨어질까?

    random은 낙관적 락을 사용하고 있고, 낙관적 락을 통해 값을 수정하는 것이 성공할 때까지 반복문을 실행한다. 아래 while 문 내에서 compareAndSet()을 계속 호출하는 부분에서 알 수 있다. 

    위에서 이야기 했듯이 낙관적 락은 동시에 여러 스레드가 접근할 수 있다. 동시에 2개의 쓰레드가 접근한다면 하나는 반드시 실패한다. 그러면 하나는 반드시 재시도를 하게 된다. 실패하고 재시도하는 과정에서 overhang이 발생할 수 있는데, 이런 이유 때문에 큰 멀티 쓰레드 환경에서 Random을 사용하면 성능 이슈가 있을 수 있다는 것이다. 

    protected int next(int bits) {
        long oldseed, nextseed;
        AtomicLong seed = this.seed;
        do {
            oldseed = seed.get();
            nextseed = (oldseed * multiplier + addend) & mask;
        } while (!seed.compareAndSet(oldseed, nextseed));
        return (int)(nextseed >>> (48 - bits));
    }

    ThreadLocal Random 사용으로 해결

    일반 Random을 사용하면 여러 쓰레드가 동시에 접근했을 때, 실패가 발생하고 재시도 하는 과정에서 성능 이슈가 생길 수 있다. ThreadLocal은 Thread 별로 가지는 Random이기 때문에 멀티 쓰레딩 환경에서 실패하지 않는다. 따라서 성능적인 이슈는 보다 가져갈 수 있게 된다. 

    public class RandomExample {
    
        public static void main(String[] args) {
            // Random random = new Random();
            // System.out.println(random.nextInt(10)); // 성능 이슈가 있을 수 있음.
            
            ThreadLocalRandom threadLocalRandom = ThreadLocalRandom.current();
            System.out.println(threadLocalRandom.nextInt()); // 멀티 쓰레드 환경에서 thread safe 하기 때문에 실패하지 않음.
        }
    
        private int value;
    
    
        // Synchronized 키워드는 비관적 락이다.
        // 아래는 멀티 쓰레드 환경에서 Atomic이 대충 이런 식으로 돌아간다는 느낌으로 보면 된다. 
        public synchronized int compareAndSwap(int expectedValue, int newValue) {
            int readValue = value;
            if (readValue == expectedValue)
                value = newValue;
            return readValue;
        }
    }

     

     

     

    따라서 실패하는 것을 없애서 성능을 개선하기 위해서, 한 쓰레드에서만 사용이 되는 랜덤을 사용하자. 따라서 ThreadLocalRandom.current()를 이용해서 호출해서 사용하면 된다. 만약 정말 짧은 시간 안에 여러 쓰레드에서 막 호출되는 것이 아니라면 그냥 Random을 써도 문제는 없다. 

     

    댓글

    Designed by JB FACTORY