[이펙티브 자바] 아이템 78. 공유 중인 가변 데이터는 동기화해 사용하라

June·2022년 3월 10일
0

[이펙티브자바]

목록 보기
71/72

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

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

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

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

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

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이 실제로 적용하는 끌어올리기라는 최적화 기법이다.

아래와 같이 바꾸면 기대한대로 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();
}

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

0개의 댓글