Effective Java : 아이템17. 완벽공략

    완벽 공략

    • p105, 새로 생성된 불변 인스턴스를 동기화 없이 다른 스레드로 건네도 문제없이 동작 (JLS 17.5)
    • p106, readObject 메서드 (아이템 88)에서 방어적 복사를 수행하라.
    • p112, 불변 클래스의 내부에 가변 객체를 참조하는 필드가 있다면... (아이템 88)
    • p113, java.util.concurrent 패키지의 CountDownLatch 클래스 

    새로 생성된 불변 인스턴스를 동기화 없이 다른 스레드로 건네도 문제없이 동작 (JLS 17.5)

    불변 인스턴스는 다른 쓰레드에서도 별도의 동기화 (Syncronization) 없이 다른 쓰레드에서 공유해서 써도 안전하다는 것이다. 내부가 모두 final 키워드로 선언되어 있기 때문에 A 쓰레드가 인스턴스를 생성하는 도중에 B 쓰레드가 인스턴스를 참조하더라도, 필드가 완전히 초기화 된 이후에 참조할 수 있도록 보장해주기 때문이다. 아래에 더욱 자세한 내용이 나온다. (완벽 공략 32 참조)


    readObject 메서드 (아이템 88)에서 방어적 복사를 수행하라.

    불변 객체가 가변 객체를 참조(필드로 가지고 있다면)한다면 다음 메서드에서 방어적 복사를 해야한다.

    • 생성자
    • 접근자
    • readObject() 메서드

    가변 객체는 바뀔 수 있기 때문에 불변 객체의 멱등성을 보장하지 못한다. 멱등성을 보장하기 위해서 가변 객체를 이용할 때 마다 새로운 오브젝트를 하나 만들고 값을 복사해서 사용하고 반환하도록 한다. 

    ReadObject() 메서드는 Serialization과 관련되어있다. 

     


    완벽 공략 32. final과 자바 메모리 모델 (JMM)

    final을 사용하면 인스턴스를 안전하게 초기화 할 수 있다. 

    • JMM과 final을 완벽히 이해하려면 JLS 17.4와 JLS 17.5를 참고.
    • JMM (Java Memory Model) 
      • 자바 메모리 모델은 JVM의 메모리 구조가 아니다.
      • JMM은 적법한 (legal) 프로그램 실행 규칙을 의미함.
      • 메모리 모델이 허용하는 범위 내에서 프로그램을 어떤 순서로 어떻게 실행하는 것은 실행하든 JVM 구현체마다 다르다. (이 과정에서 실행 순서가 바뀔 수도 있다)
    • 어떤 인스턴스의 final 변수를 초기화 하기 전까지 해당 인스턴스를 참조하는 모든 쓰레드는 기다려야 한다. (freeze)
      • 따라서 인스턴스가 안전하게 초기화 된다고 이야기 할 수 있다. 

    final을 사용하면 인스턴스를 안전하게 초기화 할 수 있다는 말이 중요하다.  final을 안 쓰면 안전하지 않게 초기화 되는 것일까? 이 내용을 이해하기 위해서 JMM(Java Memeory Model)과 JMM에서 final의 동작 정의를 이해해야한다. 

    Java Memory Model은 주어진 프로그램이 있을 때, 프로그램을 실행하는 과정이 적합한 지를 정의해주는 것이다. Java Memeory Model은 프로그램을 어떻게 실행해야 적합한 순서인지에 대한 큰 틀을 제공해준다. 그리고 각 JVM은 실행 순서를 JMM 메모리 모델을 벗어나지 않는 선에서 자유롭게 구현해서 사용한다. 이 말은 JVM마다 하나의 코드를 실행하는 순서가 다를 수 있다는 것을 의미한다. 

    자바의 메모리 모델은 주어진 프로그램과 프로그램을 실행하는 트레이스가 있을 때, 프로그램을 실행하는 과정이 적합한지를 알려주는 것이다. (어떻게 프로그램을 실행해야 적합한 프로그램 실행인지를 알려준다). 예를 들어 생성자에서 코드를 만든다. x와 y를 1,2로 각각 할당한다. 이 할당하는 실행 순서가 자유롭다. 실행 순서를 어떻게 구현할지는 메모리 모델이 허용하는 범위 내에서 JVM 구현체가 자유롭게 구현한다. (실행 순서가 바뀜) 그로 인해서 우리가 생각하는 순서와는 다른 순서로 실행되는 경우도 있을 수 있다. 

    아래 코드를 살펴보면서 이해해보자. main() 메서드는 WhiteShip 인스턴스를 하나 만드는 코드가 작성되어있다. 이 코드는 주석으로도 표시되어있지만, 두 가지 실행 방법이 존재할 수 있다. 이것은 Java Memory Model의 큰 실행 순서 틀 내에서 JVM이 허용치 내에서 자유롭게 바꿀 수 있음을 의미한다. 

    public class WhiteShip {
    
        private int x, y;
    
        public WhiteShip() {
            this.x = 1;
            this.y = 2;
        }
    
        public static void main(String[] args) {
            // 두 가지 실행 방법이 존재 (그 이상 존재할 수도)
            // 1. Object w = new Whiteship()
            // 2. whiteShip = w
            // 3. w.x = 1
            // 4. w.y = 2
    
            // 1. Object w = new Whiteship()
            // 2. w.x = 1
            // 3. w.y = 2
            // 4. whiteShip = w
            WhiteShip whiteShip = new WhiteShip();
        }
    }

    그런데 위 코드 실행 순서에서 아래 순서라면 멀티 쓰레드 환경에서 문제가 될 수도 있다.

    A 쓰레드가 생성자를 통해서 생성하는 과정인데, 이 때 B 쓰레드가 생성하는 인스턴스를 2번 단계에서 참조했다고 가정해보자. 그러면 B 쓰레드가 whiteship 변수를 참조한 순간에는 x, y의 값은 초기화 되지 않은 상태가 된다. 이것은 객체가 안전하게 초기화 되지 않을 수도 있다는 것을 의미한다. 

    이런 문제가 있음에도 JVM이 이런 실행 계획을 가지는 것은 JMM의 적법성은 '싱글 쓰레드'를 관점으로만 작성되기 때문이다. 멀티 쓰레드 환경을 고려하지 않는다. 멀티 쓰레드 환경에서는 '인스턴스의 불완전한 초기화' 상태에서 객체 참조가 가능한 것을 의미한다. 

    // 1. Object w = new Whiteship()
    // 2. whiteShip = w
    // 3. w.x = 1
    // 4. w.y = 2

    이런 문제를 해결하기 위해 JLS 17.5 명세가 등장한다. final 키워드가 있는 필드를 사용하면, 언어 스펙상(JLS) 인스턴스에 final로 선언된 필드가 초기화 된 이후에만 해당하는 인스턴스를 다른 쓰레드가 참조해서 사용할 수 있게 된다. 그 때 까지는 freeze 된다. 

    The usage model for final fields is a simple one: Set the final fields for an object in that object's constructor; and do not write a reference to the object being constructed in a place where another thread can see it before the object's constructor is finished. If this is followed, then when the object is seen by another thread, that thread will always see the correctly constructed version of that object's final fields. It will also see versions of any object or array referenced by those final fields that are at least as up-to-date as the final fields are.

    final 필드의 사용 모델은 간단합니다: 해당 객체의 생성자에서 객체의 final 필드를 설정하고, 객체의 생성자가 완료되기 전에 다른 스레드가 볼 수 있는 위치에 생성 중인 객체에 대한 참조를 작성하지 않습니다. 이 원칙을 준수하면 다른 스레드에서 객체를 볼 때 해당 스레드는 항상 해당 객체의 final 필드에 대해 올바르게 구성된 버전을 볼 수 있습니다. 또한 해당 final 필드에서 참조하는 모든 객체 또는 배열의 버전이 적어도 final 필드만큼 최신 버전으로 표시됩니다.
    (https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html)

    예를 들어 아래 코드를 살펴보자.

    1. writer() 메서드를 사용하기 위해서는 FinalFieldExample 인스턴스가 생성되어야 한다.
    2. 인스턴스가 생성되려면 x, y에 각각 3,4가 할당되어야 한다. 이 때, x는 final 키워드를 가진다. 
    3. 따라서 JLS 17.5에 따라서 x의 값이 초기화 될 때까지 FinalFieldExample 인스턴스를 다른 인스턴스에게 참조시키지 않는다. 따라서 다른 쓰레드는 freeze()가 된다. 

    이런 이유 때문에 x의 값을 조회할 때는 반드시 3이 된다. 반면 y는 초기화 되지 않은 시점에 참조될 수 있기 때문에 int 타입의 기본값인 0이 반환될 수도 있다는 것이다. 

    public class FinalFieldExample {
        final int x;
        int y;
        static FinalFieldExample f;
    
        public FinalFieldExample() {
            this.x = 3;
            this.y = 4;
        }
        
        static void write() {
            f = new FinalFieldExample();
        }
        
        static void reader() {
            if (f != null) {
                int i = f.x; // 3을 보는 것을 보장
                int j = f.y; // 0을 볼 수도 있음. 
            }
        }
    }

     

    정리

    JDK마다 실행 순서가 다를 수 있다. 어떤 JDK는 인스턴스가 완전히 생성된 후에 변수에 인스턴스를 바인딩 할 수 있다. 반대로 변수에 인스턴스를 바인딩하고 필드를 할당하는 작업을 할 수 있다. 이처럼 각 JDK 구현체마다 실행 계획이 다르기 때문에 우리는 JLS (랭귀지 스펙)에만 의존해서 코드를 작성하면 된다.

    JLS 17.5를 따라서 '반드시 초기화가 된 이후에 쓰여져야 하는 값들은 final 키워드를 붙이면 된다. 그런 의미에서 final을 사용하면 완전하게 초기화를 할 수 있다. 

     

     


    java.util.concurrent 패키지의 CountDownLatch 클래스 

    concurrent 패키지는 병행 프로그래밍에 유용하게 사용할 수 있는 유틸리티 묶음. 

    • 병행과 병렬의 차이
      • 병행은 여러 작업을 번갈아가며 실행해 마치 동시에 여러 작업을 동시에 처리하듯 보이지만, 실제로는 한번에 오직 한 작업만 실행한다. CPU가 한 개여도 가능하다. (한 순간에는 하나의 작업만 진행됨) 
      • 병렬여러 작업을 동시에 처리한다. CPU가 여러개 있어야 가능하다.
    • 자바의 Concurrent 패키지는 병행 어플리케이션에 유용한 다양한 툴을 제공한다. 
      • BlockingQueue, Callable, ConcurrentMap, Executor, ExecutorService, Future, ...

    개발자 입장에서 코드를 작성할 때 병렬 / 병행은 중요하지 않다. 우리는 쓰레드로 Task를 나눠서 실행을 할 것이고, 그랬을 때 발생할 수 있는 문제를 고려하기만 하면 된다. 그렇다면 어떤 문제를 고려해야할까? 

    • 여러 쓰레드가 Heap 메모리에 있는 멤버 변수를 동시 참조했을 때 발생할 수 있는 문제
    • 쓰레드 간의 경쟁(A, B 쓰레드 중 누가 먼저 실행되느냐에 따라 결과가 달라지는 경우)

    이런 경우를 고려하면 될 것이고, concurrent 패키지는 이런 경우들을 제어할 수 있는 도구들을 제공해준다. 

     

    CountDownLatch

    • CountdownLatch는 주로 다음 경우에 사용한다.
      • 다른 여러 스레드로 실행되고 있는 동작이 마칠 때까지 기다렸다가 다음 작업을 하고 싶을 때 사용.
      • 어떤 조건이 되었을 때, 여러 쓰레드를 동시에 실행하고 싶을 때 사용. 
    • CountdownLatch의 기능
      • CountdownLatch 생성 시, 숫자를 입력하고 await() 메서드를 사용해 숫자가 0이 될 때까지 Blocking 됨.
      • 숫자를 셀 때는 countDown() 메서드를 사용한다.
      • 재사용할 수 있는 인스턴스가 아니다. 숫자를 리셋해서 재사용하려면 CyclicBarrier를 사용해야 한다.
      • 시작 또는 종료 신호로 사용할 수 있다.

    코드를 통해서 내용을 살펴본다.

    • 아래 코드에는 CountDownLatch 인스턴스가 startSignal, doneSignal 2개가 존재함. 
    • Thread를 생성하면서 start()를 호출해서, 쓰레드는 시작되었다. 하지만 쓰레드 내부에 있는 startSignal.await() 블록에서 모든 쓰레드는 Blocking 되고 있다. 
    • startSignal.countDown()을 하면 startSignal의 값이 0이 되면서, startSignal.await()에서 블록킹 된 쓰레드들은 작업을 한다. 
    • 각 작업이 끝나면 쓰레드들은 doneSignal.countDown()을 호출한다. 메인 쓰레드는 doneSignal.await()에서 블록킹 되어있다.
    • 모든 쓰레드의 작업이 끝나서 doneSignal의 값이 0이 되면, 메인 쓰레드는 비로소 doneSignal.await()에서 끝나서 다음 작업을 진행한다. 
    public class ConcurrentExample {
        public static void main(String[] args) throws InterruptedException {
            int N = 10;
            CountDownLatch startSignal = new CountDownLatch(1);
            CountDownLatch doneSignal = new CountDownLatch(N);
    
            for (int i = 0; i < N; i++) {
                // Thread는 시작되지만, startSignal.await()에서 블록킹 됨.
                new Thread(new Worker(startSignal, doneSignal)).start();
            }
    
            ready();
            startSignal.countDown(); // countDown을 해서 Latch를 0으로 만들어줌 → 쓰레드 작업 시작
            doneSignal.await(); // 모든 쓰레드 작업이 종료될 때까지 기다림.
            done(); 
        }
    
        ...
    
        public static class Worker implements Runnable {
    
            private final CountDownLatch startSignal;
            private final CountDownLatch doneSignal;
    
            ...
    
            @Override
            public void run() {
                try {
                    startSignal.await(); // Blocking 
                    doWork();
                    doneSignal.countDown(); // latch 숫자 감소
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
    
            private void doWork() {
                System.out.println(Thread.currentThread().getName());
            }
       }
    }

     

    댓글

    Designed by JB FACTORY