Java ‐ wait와 notify는 동시성 유틸리티를 애용하라[Effective Java Item 81] - thought-corner/Backend-PlayGround GitHub Wiki

wait()notify()

  • wait()notify()는 쓰레드의 상태 제어를 위한 메서드이다.
    • wait() : 가지고 있던 고유 락을 해제하고 쓰레드를 잠들게 하는 역할을 하는 메서드
    • notify() : 잠들어 있던 쓰레드 중 임의로 하나를 골라 꺠우는 역할을 하는 메서드
public class ThreadB extends Thread {
    int total = 0; // 누적 값을 저장할 변수 (기존 코드 보완)

    // 해당 쓰레드가 실행되면 자기 자신의 모니터링 락을 획득
    // 5번 반복하면서 0.5초씩 쉬면서 total에 값을 누적
    // 그후에 notify()메소드를 호출하여 wait하고 있는 쓰레드를 깨움
    @Override
    public void run() {
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                System.out.println(i + "를 더합니다.");
                total += i;
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            notify(); // wait하고 있는 쓰레드를 깨움
        }
    }
}
public class ThreadA {
    public static void main(String[] args) {
        // 앞에서 만든 쓰레드 B를 만든 후 start 
        // 해당 쓰레드가 실행되면, 해당 쓰레드는 run메소드 안에서 자신의 모니터링 락을 획득
        ThreadB b = new ThreadB();
        b.start();

        // b에 대하여 동기화 블럭을 설정
        // 만약 main쓰레드가 아래의 블록을 위의 Thread보다 먼저 실행되었다면 wait를 하게 되면서 모니터링 락을 놓고 대기       
        synchronized (b) {
            try {
                // b.wait()메소드를 호출.
                // 메인쓰레드는 정지
                // ThreadB가 5번 값을 더한 후 notify를 호출하게 되면 wait에서 깨어남
                System.out.println("b가 완료될때까지 기다립니다.");
                b.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            // 깨어난 후 결과를 출력
            System.out.println("Total is: " + b.total);
        }
    }
}
  • wait()notify()는 올바르게 사용하기가 위와 같이 아주 까다롭기 때문에 고수준 동시성 유틸리티를 사용하는 것이 좋다.

고수준 동시성 유틸리티 1 - 동시성 컬렉션

  • 표준 컬렉션 + 동시성 = 동시성 컬렉션
  • 동시성 컬렉션의 동시성을 무력화하는 것은 불가능하며, 외부에서 락을 걸면 오히려 속도가 느려진다. 동시성을 무력화하지 못하므로 여러 메서드를 원자적으로 묶어 호출할 수도 없다.
  • 상태 의존적 메서드 - 여러 기본 동작들을 하나로 묶는 상태 의존적 메서드가 추가(ex. putIfAbsent())
  • Collections.synchronized~ - Collections에서 제공해주는 synchronized~()를 사용하여 동기화한 컬렉션을 만드는 것보다 동시성 컬렉션을 사용하는 것이 성능적으로 좋다.

고수준 동시성 유틸리티 2 - 동기화 장치

  • 컬렉션 인터페이스 중 일부는 작업이 성공적으로 완료될 때까지 기다리도록 확장되었다.
public interface BlockingQueue<E> extends Queue<E> {
		/**
     * Retrieves and removes the head of this queue, waiting if necessary
     * until an element becomes available.
     *
     * @return the head of this queue
     * @throws InterruptedException if interrupted while waiting
     */
    E take() throws InterruptedException;
}
  • BlockingQueuetake() 메서드는 큐의 원소를 꺼내는 역할을 하는데, 만약 큐가 비어있다면 새로운 원소가 추가될 때까지 기다린다.

동기화 장치의 종류 1 - CountDownLatch, Semaphore

  • 하나 이상의 쓰레드가 또 다른 하나 이상의 쓰레드 작업이 끝날 때까지 기다리게 한다.
  • 생성할 때 int값을 받는데, 이 값이 countDown()을 몇 번 호출해야 대기 중인 쓰레드를 꺠우는지 결정한다.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CountDownLatchTest {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(5);
        try {
            long result = time(executorService, 3, () -> System.out.println("hello"));
            System.out.println("총 걸린 시간 : " + result);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            executorService.shutdown();
        }
    }

    public static long time(Executor executor, int concurrency, Runnable action) throws InterruptedException {
        CountDownLatch ready = new CountDownLatch(concurrency);
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(concurrency);

        for (int i = 0; i < concurrency; i++) {
            executor.execute(() -> {
                ready.countDown(); // 타이머에게 준비가 됐음을 알린다.
                try {
                    // 모든 작업자 스레드가 준비될 때까지 기다린다.
                    start.await();
                    action.run();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    // 타이머에게 작업을 마쳤음을 알린다.
                    done.countDown();
                }
            });
        }

        ready.await(); // 모든 작업자가 준비될 때까지 기다린다.
        long startNanos = System.nanoTime();
        start.countDown(); // 작업자들을 깨운다.
        done.await(); // 모든 작업자가 일을 끝마치기를 기다린다.
        return System.nanoTime() - startNanos;
    }
}

그럼에도 불구하고 wait()notify() 메서드를 사용해야하는 상황이라면?

  • 반드시 동기화 영역 안에서 사용해야 하며, 항상 반복문 안에서 사용해야 한다.
synchronized (obj) {
    while (조건이 충족되지 않았다) {
        obj.wait(); // 락을 놓고, 깨어나면 다시 잡는다.
    }

    ... // 조건이 충족됐을 때의 동작을 수행한다.
}
  • 반복문 밖에서 절대 호출하면 안 된다. 또한 대기 전에 조건을 검사해 조건을 이미 충족했다면 wait()를 호출하지 않게 해야 하는데, 이는 응답 불가 상태를 예방하기 위해서이다.
  • 한편 대기한 이후 조건을 검사해 조건을 충족하지 않았을 때, 다시 대기하게 하는 경우도 있는데 이는 조건이 만족되지 않아도 쓰레드가 깨어날 수 있는 상황이 몇 가지 있기 때문이다.
  • 일반적으로 notify()보다 notifyAll()이 안전하다.