Effective Java : 아이템 78. 공유 중인 가변 데이터는 동기화해 사용하라.

    아이템 78. 공유 중인 가변 데이터는 동기화해 사용하라.

    • 동기화를 처리해야할 경우, 라이브러리를 사용해라. 동기화도 되고 성능도 빠르다.
    • 동기화를 하면 다음이 좋음.
      • 원자적으로 실행함.
      • 한 쓰레드의 변경사항을 다른 쓰레드가 볼 수 있도록 보장해 줌.
      • 컴파일러의 자바 최적화 기법에 의한 코드 변경에서 안전할 수 있음.
    • Synchronized는 다음을 보장함.
      • 원자적 실행 보장.
      • 한 쓰레드의 변경사항을 다른 쓰레드가 볼 수 있도록 보장해 줌.
      • 컴파일러의 자바 최적화 기법에 의한 코드 변경에서 안전할 수 있음.
    • volatile은 다음을 보장함.
      • 한 쓰레드의 변경사항을 다른 쓰레드가 볼 수 있도록 보장해 줌.
      • 컴파일러의 자바 최적화 기법에 의한 코드 변경에서 안전할 수 있음.
    • Synchronized의 특성
      • 블록 / 메서드에 접근하기 위해서 쓰레드는 특정 인스턴스가 유일하게 가지는 모니터 락을 얻어야만 접근 가능함. 
      • 특정 스레드가 모니터락을 얻고 synchornized 블록을 수행하고 있을 때, 다른 스레드가 일반 메서드를 호출하면 동시에 접근할 수 있음. (모니터락은 synchronized 블록을 접근할 때만 고려됨)
    • 동기화가 필요하면 읽기 / 쓰기를 모두 동기화 해야함.
    • 자바에서 long, double(8바이트)을 제외하면 원자적으로 읽기/쓰기가 가능함. 그러나 가시성은 보장하지 않음. 
    • 동기화 문제는 가변 데이터를 쓰레드끼리 공유하기 때문에 발생함. 가급적이면 불변 객체만 쓰레드끼리 공유해라.
    • 가변 데이터를 공유해야한다면, 데이터가 변하는 부분은 동기화(synchronized)를 해라. 이것을 통해 사실상 불변을 달성할 수 있음.
    • 쓰레드 간 가시성 문제가 발생하는 이유
      • 각 쓰레드의 변경사항은 속도 문제로 인해 각 코어의 캐시에 반영 후, 메모리에 반영됨. 
      • 쓰레드 1이 1번 코어, 쓰레드 2가 2번 코어에 저장되는 경우 바로 보는 코어 캐시가 달라서 쓰레드 1의 변경 사항이 쓰레드2에게 보이는 시점을 명확히 알 수 없음. 
      • volatile 키워드를 이용하면 캐시 → 메모리 반영, 메모리에서 항상 읽어오기가 보장됨. (상대적으로 느릴 수는 있음) 

     


    자바 동기화의 역할 (Synchronized 키워드)

    자바에서는 동기화를 할 때 주로 synchornized키워드를 사용한다. synchornized키워드를 사용하는 목적은 다음 두 가지다. 

    • synchronized 메서드에 접근할 때, 이 메서드를 가진 인스턴스에게 Lock을 검(모니터 Lock). 모니터락을 획득해서 접근해야하는 메서드에는 오직 단 하나의 스레드만으로 제한됨. 
    • 한 스레드가 반영한 변경점을 다른 스레드에서 볼 수 있도록 보장해 줌. 

    각 인스턴스는 Monitor Lock을 1개씩 가지고 있다. 만약 어떤 쓰레드가 synchornized메서드나 synchornized블락을 수행하려면 먼저 모니터 락을 얻어야 한다. 만약 이미 다른 스레드가 모니터락을 사용하고 있다면, 얻을 때까지 대기해야한다. 이런 이유 때문에 synchornized키워드 내에서 실행된 행위는 원자적으로 반영된다. 

    public void notAtomicCase() {
        a++;
        b = b + a;
    }
    
    public synchronized void atomicCase() {
        a++;
        b = b + a;
    }

    예를 들어 notAtomicCase()에서는 여러 스레드가 동시에 메서드를 호출했을 때 동기적으로 실행되지 않을 부분이 두 가지가 있다. a++과 (a++) / (b = b+a) 사이에서 다른 스레드가 끼어들 여지가 있다. 이 경우 각 연산은 원자적으로 실행이 되지 않는데, 이 때 synchronized 키워드를 이용하면 원자적으로 실행되는 것을 보장한다

    동기화 (Synchronized)의 또 다른 목적은 한 쓰레드의 변경 사항이 다른 쓰레드에서도 항상 관찰할 수 있도록 보장해준다. 적절한 예시는 다음과 같다.

    1. 쓰레드 A(1번 코어가 실행)가 a라는 값을 0 → 1로 수정함. → 변경 사항은 1번 코어 캐시에 저장됨.  (메모리까지 반영 되지 않음)
    2. 쓰레드 B(2번 코어가 실행)가 a라는 값을 읽음. → 0이 읽힘. 쓰레드 B는 2번 코어 캐시에서 값을 읽어오는데, 2번 코어 캐시에 a는 0으로 저장되어 있기 때문임. 

    이런 식으로 쓰레드 - 쓰레드의 통신에 있어서도 동기화는 필수불가결한 요소다. 


    언어 명세상 long, double 외의 변수를 읽고 쓰는 동작은 Atomic임. 

    자바는 4바이트 단위로 데이터를 읽고 쓴다. 즉, 4바이트 단위의 데이터를 읽고 쓰는 것은 원자적으로 처리된다는 이야기다. 반면 long, double은 8바이트로 구성된다. long, double을 읽고 쓰려면 총 2번의 커맨드가 실행되어야한다. 2번의 커맨드가 실행되는 동안에 다른 스레드가 치고 들어올 수 있기 때문에 long, double 관련 연산은 '원자적이지 않다'라고 표현한다. 

    long, double을 제외한 데이터 타입을 읽고 쓰는게 원자적으로 처리가 되지만 동기화 작업은 반드시 필요하다. 자바 메모리 모델로 인해 '한 스레드의 변화가 다른 스레드에게 언제 보일지'에 대한 가시성 개념은 앞에서 이야기 한 '캐시 - 메모리 간의 동기화' 문제가 여전히 존재하기 때문이다. 

     


    동기화 구문은 예상치 못한 컴파일러의 최적화 코드를 방지함. 

    JVM마다 서로 다른 형태의 최적화가 들어가기도 한다. 이 말은 우리가 작성한 코드가 class 파일에서 Java 파일로 컴파일 되었을 때, 최적화가 반영되어 실행되는 코드가 달라질 수도 있음을 의미한다. 

    public class StopThread {
        private static boolean stopRequested;
        public static void main(String[] args) throws InterruptedException {
    
            Thread backgroundThread = new Thread(() -> {
                int i = 0;
                while (!stopRequested)
                    i++;
            });
    
            backgroundThread.start();
    
            TimeUnit.SECONDS.sleep(1);
            stopRequested = true;
        }
    }

    위의 코드를 예시로 살펴보자. 간략히 설명하면 다음과 같다.

    1. backgroundThread가 실행한다. stopRequested = True가 되면 실행 종료되어야 함. 
    2. 메인 쓰레드는 stopRequested를 true로 설정함. 

    그런데 위 코드를 실행하면 실행이 종료되지 않는다. 이것은 JVM이 코드 최적화를 했고, 그 코드 최적화로 인해 코드 실행 순서가 바뀌었기 때문에 발생한다. 아래처럼 코드가 수정되는데 이것은 OpenJDK VM이 사용하는 Hoisting이라는 기법으로 최적화 되었다. 

    // 원래 코드
    while (!stopRequested)
      i++;
    
    // 최적화한 코드
    if (!stopRequested)
      while(true)
        i++;

    위의 문제를 해결하기 위해서는 stopRequested에 접근하는 부분을 synchronized 키워드를 이용해 동기화 해주면 된다. 

    public class StopThreadSynchronized {
        private static boolean stopRequested;
        public static synchronized boolean getStopRequested() { return stopRequested; }
        public static synchronized void setStopRequested(boolean b) { stopRequested = b; }
        public static void main(String[] args) throws InterruptedException {
            Thread backgroundThread = new Thread(() -> {
                int i = 0;
                while (!getStopRequested())
                    i++;
            });
    
            backgroundThread.start();
    
            TimeUnit.SECONDS.sleep(1);
            setStopRequested(true);
        }
    }

    위 코드처럼 stopRequested 변수를 가져올 때 synchronized 메서드를 이용해서 가져올 수 있도록 리팩토링 한다. synchronized를 이용했을 때의 기대효과는 다음과 같다. 

    1. 인스턴스의 유일한 모니터락을 얻어서 접근함.
    2. synchronized를 했을 때, 이 구문은 최적화로 인한 코드 변경 영향을 받지 않음. 
    3. 다른 스레드가 stopRequested 변수를 수정한 것을 또 다른 스레드가 바로 읽을 수 있도록 함. 

    이처럼 동작하기 때문에 프로그램이 실행 후, Background Thread가 곧 종료하게 된다.


    동기화가 필요하면 쓰기 / 읽기 모두 동기화 해야 함. 

    아래 코드를 보면 get / set 메서드를 이용해서 쓰기 / 읽기를 할 때 모두 동기화 했다는 것을 알 수 있다. 읽기/쓰기 중 하나의 메서드만 동기화 하는 것은 충분하지 않다. 

    public static synchronized boolean getStopRequested() { return stopRequested; }
    public static synchronized void setStopRequested(boolean b) { stopRequested = b; }

    '쓰기 메서드는 동기화 / 읽기 메서드는 동기화 없음'이란 상황을 고려해보자. 그러면 이 때는 어떻게 동작할까? 

    동기화 된 쓰기 메서드가 변수를 업데이트 하고 있는 사이에 다른 쓰레드는 읽기 메서드에 자유롭게 접근할 수 있음. 

    즉, 특정 변수의 값을 동기화 해서 읽어야 하는 경우라면 적절하게 동작하지 않는다는 것을 알 수 있다.


    volatile을 이용한 동기화

    synchronized 키워드는 실행도 동기화 시켜주고, 메모리 - 캐시 간의 데이터 불일치도 해결해준다. volatile 키워드를 변수에 사용하면 다음과 같이 동작하도록 보장해준다.

    • 쓰기 : 캐시에 먼저 쓰고, 메모리에 반영까지 보장. 
    • 읽기 : 메모리에서 읽고, 캐시에 저장을 보장. 

    원자적으로 연산되는 것을 보장하지는 않지만 멀티 쓰레드 환경에서 변경점을 다른 스레드에서 바로 볼 수 있도록 보장해준다. 이전에 Synchronized 키워드를 이용해서 동기화해서 문제를 해결했던 부분은 volatile을 이용하면 더 손쉽게 해결할 수도 있다. 

    public class StopThreadWithVolatile {
        private static volatile boolean stopRequested;
        public static void main(String[] args) throws InterruptedException {
    
            Thread backgroundThread = new Thread(() -> {
                int i = 0;
                while (!stopRequested)
                    i++;
            });
    
            backgroundThread.start();
    
            TimeUnit.SECONDS.sleep(1);
            stopRequested = true;
        }
    }

    volatile을 이용할 때 주의해야 할 부분

    volatile은 변수의 가시성(쓰레드가 변경한 것을 다른 쓰레드가 볼 수 있도록)은 보장하지만, 변수의 원자적인 연산을 보장하지는 않는다. 따라서 아래 같은 코드를 사용할 때는 동시성 문제가 발생할 수 있다.

    public class CautionVolatile {
        private static volatile int a = 0;
    
        // a++ 원자적으로 연산 안됨. 
        public static void increment() {
            a++;
        }
    }

    a++ 코드는 a = a + 1이라는 연산이 되는 것이다. 그런데 이 연산은 총 두 단계로 이루어진다

    1. a 값을 읽어옴.
    2. 읽어온 a 값에 1을 더해서 a에 저장함. 

    만약 다른 스레드가 1~2번 단계 사이에 들어오게 되면, 이 연산은 원자적으로 동작하지 않은 결과를 보여줄 수 있다. 이 부분을 해결하기 위해서는 Concurrent 라이브러리를 사용하는 것이 좋다. Concurrent 라이브러리는 동기화 문제도 해결해주며 성능도 동기화 버전보다 우수하다. 

    public class CautionVolatileWithConcurrent {
    
        private static AtomicInteger a = new AtomicInteger(0); 
        // a++ 원자적으로 연산 안됨.
        public static void increment() {
            a.getAndIncrement();
        }
    }

     


    동기화 문제를 고려해야 하는 이유 → 가변 인스턴스 공유하지 마라.

    동기화 문제를 고려해야 하는 이유는 가변 인스턴스를 쓰레드끼리 공유하기 때문이다. 동기화 문제를 고려하지 않는 가장 쉬운 방법은 가변 데이터는 단일 쓰레드에서만 사용하도록 하는 것이다. 만약 가변 데이터를 여러 쓰레들 사이에서 공유를 해야한다면, 'Effective Immutable 객체'를 사용하는 게 좋겠다. 책에서는 이런 구절이 나온다.

    한 스레드가 데이터를 다 수정한 후 다른 스레드에 공유할 때는 해당 객체에서 공유하는 부분만 동기화해도 된다.
    그러면 그 객체를 다시 수정할 일이 생기기 전까지 다른 스레드들은 동기화 없이 자유롭게 값을 읽어갈 수 있다.
    이런 객체를 사실상 불변이라고 하고, 다른 스레드에 사실상 불변 객체를 건네는 행위를 안전 발행이라 한다. 

    위의 내용을 코드로 이해해보면 다음과 같다.

    1. 처음 객체가 생성될 때, 특정한 값을 가지고 생성된다. 
    2. 서로 다른 쓰레드가 업데이트 할 수 있는 필드는 age라고 가정하자. 이 때, age에 접근할 수 있는 모든 메서드는 동기화 구문을 사용한다.
    3. age를 다른 쓰레드 간에 공유하기 전까지는 기존 인스턴스에는 어떠한 동기화 작업도 필요 없음. (sychronzied 메서드가 실행되지 않음)

    이렇게 동작하기 때문에 이런 인스턴스를 '사실상 불변'이라고 하고, '사실상 불변' 객체를 다른 스레드에게 전달하는 행위를 '안전발행'이라고 한다. 

    public class EffectiveImmutableInstance {
        private final String name;
        private int age;
    
        public EffectiveImmutableInstance(String name) {
            this.name = name;
            this.age = 0;
        }
        
        public synchronized void updateAge(int age) {
            this.age = age;
        }
        
        public synchronized int getAge() {
            return this.age;
        }
    }

    안전발행을 하는 방법

    안전발행을 하는 방법은 다음이 존재한다.

    • final 키워드로 필드 선언하기
    • volatile 키워드로 필드 선언하기
    • 락을 통해서 필드 선언하기

    댓글

    Designed by JB FACTORY