JAVA/Effective Java

[Item 78] 공유 중인 가변 데이터는 동기화해 사용하라

IT-사과 2022. 9. 26. 01:20

synchronized(동기화)

해당 메서드나 블록을 한 번에 한 스레드씩 수행하도록 보장한다.

한 객체가 일관된 상태를 가지고 생성되고, 이 객체에 접근하는 스레드가 락을 건다. 상태를 확인하고 필요하면 수정한다. 일관된 상태에서 다른 일관된 상태로 변화시키는 것이다. 그래서 동기화를 제대로 사용하면 항상 일관된 상태를 볼 수 있다.

 

위의 기능도 중요하지만 동기화의 중요한 기능이 하나 더 있다. 동기화 없이는 한 스레드가 만든 변화를 다른 스레드에서 확인하지 못할 수도 있다. 동기화는 일관성이 깨진 상태를 볼 수 없게 하는 것은 물론, 동기화된 메서드나 블록에 들어간 메서드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해 준다.

 

long과 double 외의 변수를 읽고 쓰는 것은 원자적이다.
하지만 원자적 데이터를 쓸 때도 동기화는 해야 한다. 자바 언어는 스레드가 필드를 읽을 때 항상 '수정이 완전히 반영된' 값을 얻는다고 보장하지만, 한 스레드가 저장한 값이 다른 스레드에게 '보이는가'는 보장하지 않는다.

 

동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다.

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

boolean 필드를 읽고 쓰는 작업이 원자적이라 어떤 프로그래머는 이런 필드에 접근할 때 동기화를 제거하기도 한다. 이 프로그램이 1초 후에 종료되지 않았고 계속 실행이 되었다. 이유는 동기화인데, 동기화하지 않으면 메인 스레드가 수정한 값을 백그라운드가 언제쯤에 볼지 보장할 수 없다.

 

동기화가 빠져서 가상 머신이 아래와 같은 최적화를 할 수도 있다.

// 원래 코드
while (!stopRequested)
    i++;

// 최적화된 코드
if (!stopRequested)
    while (ture)
        i++;

OpenJDK 서버 VM이 실제로 적용하는 끌어올리기(hoisting)라는 최적화 기법이다.

 

아래와 같이 바꾸면 기대한 대로 1초 후에 종료된다.

public class StopThread {
    private static boolean stopRequested;
    
    private static synchronized void requestStop() {
        stopRequested = true;
    }
    
    private static synchronized boolean stopRequested() {
        return 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);
        requestStop();
    }
}

쓰기 메서드와 읽기 메서드 모두 동기화한 것이 포인트다. 쓰기와 읽기 모두가 동기화되지 않으면 동작을 보장하지 않는다. 처음 말했듯이 동기화는 배타적 수행 스레드 간 통신이라는 두 가지 기능을 수행하는데, 이 코드에서는 통신 목적으로만 사용된 것이다.

 

반복문에서 동기화하는 비용이 크지 않지만 조금 더 최적화하고 싶으면 stopRequested 필드를 volatile로 선언하면 동기화를 생략해도 된다. volatile 한정자는 배타적 수행과는 상관없지만 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다.

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

volatile은 주의해서 사용해야 한다.

private static volatile int nextSerialNumber = 0;

public static int generateSerialNumber() {
    return nextSerialNumber++;
}

위 코드도 동기화 없이는 제대로 동작하지 않는다. 이유는 ++ 연산자 때문인데, 이 연산자는 코드상으로는 하나지만 실제로는 nextSerialNumber에 두 번 접근한다. 먼저 값을 읽고, 그다음 1 증가한 새로운 값을 저장한다. 그래서 synchronized 한정자를 붙여야 한다. 그리고 volatile을 제거해야 한다.

 

또 아이템 59의 조언에 따라 java.util.concurrent.atomic 패키지의 AtomicLong을 사용해보자. 이 패키지에는 락 없이도 스레드 안전한 프로그래밍을 지원하는 클래스들이 담겨있다. 성능도 동기화 버전보다 우수하다.

private static final AtomicLong nextSerialNumber = new AtomicLong();

public static long generateSerialNumber() {
    return nextSerialNumber.getAndIncrement();
}

물론 가장 좋은 방법은 애초에 가변 데이터를 공유하지 않는 것이다. 가변 데이터는 단일 스레드에서만 쓰도록 하자.

 

핵심정리

  • 여러 스레드가 가변 데이터를 공유한다면 그 데이터를 읽고 쓰는 동작은 반드시 동기화해야 한다.
  • 동기화하지 않으면 한 스레드가 수행한 변경을 다른 스레드가 보지 못할 수도 있다.
  • 공유되는 가변 데이터를 동기화하는데 실패하면 응답 불가 상태에 빠지거나 안전 실패로 이어질 수 있다.
  • 되도록이면 가변 데이터는 단일 스레드에서만 쓰도록 하자.

참고자료

www.kyobobook.co.kr/product/detailViewKor.laf?ejkGb=KOR&mallGb=KOR&barcode=9788966262281&orderClick=LEa&Kc=

 

이펙티브 자바 3/E - 교보문고

프로그래밍인사이트 | 자바 6 출시 직후 출간된 『이펙티브 자바 2판』 이후로 자바는 커다란 변화를 겪었다. 그래서 졸트상에 빛나는 이 책도 자바 언어와 라이브러리의 최신 기능을 십분 활용

www.kyobobook.co.kr