dsc-sookmyung / 2023-01-Effective-Java-Study

이펙티브 자바 공부하는 스터디입니다
2 stars 3 forks source link

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

Open yejin9858 opened 1 year ago

yejin9858 commented 1 year ago

Synchronized

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

Syncronized(동기화)의 기능

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

언어 명세상 long과 double 외의 변수를 읽고 쓰는 동작은 원자적이다. → long과 double은 64bits를 차지하기에 하나의 CPU 명령어로 수행되지 않기 때문

여러 스레드가 같은 변수를 동기화 없이 수정하는 중이라도, 항상 어떤 스레드가 정상적으로 저장한 값을 온전히 읽어옴을 보장하기 때문이다.

엥? 그럼 성능을 높이려면 원자적 데이터를 읽고 쓸 때는 동기화하지 말아야겠다

이것은 아주 위험한 발상이다.

자바 언어 명세는 스레드가 필드를 읽을 때 항상 수정이 완전히 반영된 값을 얻는다고 보장하지만,

한 스레드가 저장한 값이 다른 스레드에게 보이는가는 보장하지 않기 때문

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

이는 한 스레드가 만든 변화가 다른 스레드에게 언제 어떻게 보이는지를 규정하는 자바의 메모리 모델 때문이다.

공유 중인 가변 데이터를 비록 원자적으로 읽고 쓸 수 있을지라도 동기화에 실패하면 처참한 결과로 이어질 수 있다.

→ 이로 발생한 응답 불가 상태/ 안전 실패 상태가 발생하면, 디버깅 난이도가 가장 높은 문제에 속한다. 간헐적이거나 특정 타이밍에만 발생할 수도 있고 VM에 따라 현상이 달라지기도 한다.

ex) 다른 스레드를 멈추는 작업

Thread.stop 메서드는 안전하지 않아 이미 오래 전에 deprecated 되었다 Thread.stop은 사용하지 말자

→ 새로운 방법

한 스레드는 자신의 boolean필드를 false로 초기화해놓고, 필드를 확인하면서 그 값이 true가 되면 멈춘다.

다른 스레드는 위 스레드를 멈추조자 할 때 그 스레드의 boolean필드를 true로 변경한다.

public class StopThread {
    private static boolean stopRequested;

    public static void main(String[] args) throws InterrupedException {
        Tread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested) i++;
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

위 코드를 보면 1초 뒤에 backgroundThread가 종료될 것 같다. 하지만 그렇지 않음 → 영원히 실행됨(응답 불가 상태)

동기화하지 않으면 메인 스레드가 수정한 값을 백그라운드 스레드가 언제쯤이나 보게 될지 보증할 수 없기 때문이다.

1→2로 최적화 해버릴 수도 있다.

//1
while (!stopRequested) i++;

//2
if(!stopRequested) while(true) i++;

위는 OpenJDK 서버 VM이 실제로 적용하는 hosting이라는 최적화 기법이다.

public class StopThread {
    private static boolean stopRequested;

    private static synchroized void requestStop() {
        stopRequested = true;
    }
    private static synchroized boolean stopRequested() {
        return stopRequested;
    } 

    public static void main(String[] args) throws InterrupedException {
        Tread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested()) i++;
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
        requestedStop();
    }
}

위처럼 수정하면 제대로 작동한다.

쓰기 메서드(requestStop)와 읽기 메서드(stopRequested) 모두 동기화했다. (사실 위 두 메서드는 간단해서 동기화 없이도 원자적으로 동작한다.)

쓰기 메서드만 동기화 하는 것은 충분하지 않고 쓰기와 읽기 모두가 동기화되어야 함에 주목하자.

위 코드에서는 배타적 수행과 스레드 간 통신 中 스레드 간 통신 목적으로만 사용되었다.

volatile

앞 코드도 나쁘진 않지만 속도가 더 빠른 대안을 소개하겠다.

public class StopThread {
    private static volatile boolean stopRequested;

    public static void main(String[] args) throws InterrupedException {
        Tread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested) i++;
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

volatile을 사용하면 동기화를 생략해도 된다.

volatile 한정자는 배타적 수행과상관없지만 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다.

하지만 주의할 점이 있다.

private static volatile int nextSerialNumber = 0;

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

이 메서드는 매번 고유한 값을 반환할 의도로 만들어졌다. (int 의 범위를 넘어가지만 않는다면)

이 메서드의 상태는 nextSerialNumber라는 단 하나의 필드로 결정되는데, 원자적으로 접근할 수 있고 어떤 값이 든 허용한다.

동기화하지 않더라도 불변식을 보호할 수 있어 보이나, 동기화가 필요하다.

문제는 증가연산자(++)다.

이 연산자는 nextSerialNumber 필드에 두 번 접근한다.

  1. 값을 읽고
  2. 1 증가한 새로운 값을 저장한다.

만약 두 번째 스레드가 이 두 접근 사이를 비집고 들어와 값을 읽어가면 첫 번째 스레드와 똑같은 값을 돌려받게 된다. 프로그램이 잘못된 결과를 계산해내는 이런 오류를 ‘안전실패’라고 한다.

private static int nextSerialNumber = 0;

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

synchroized 한정자를 붙이면 이 문제를 해결할 수 있다.

동시에 호출해도 서로 간섭하지 않으며, 이전 호출이 변경한 값을 읽게 된다.

이 경우 volatile 은 제거해야 하며, 이 메서드를 더 견고하게 하려면 int 대신 long을 사용하거나, nextSerialNumber가 최댓값에 도달하면 예외를 던지게 하자.

AtomicLong

‘아이템 59 라이브러리를 익히고 사용하라’에서 잠깐 언급되었던 java.util.concurrent.atomic 패키지에 소속되어있음

이 패키지에는 락 없이도 스레드 안전한 프로그래밍을 지원하는 클래스들이 담겨있다. (lock-free)

volatile은 동기화의 두 효과 중 통신 쪽만 지원하지만, 이 패키지는 원자성(배타적 실행)까지 지원한다.

성능도 동기화보다 우수하다.

private static final AtomicLong nextSerialNumber = new AtomicLong();

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

사실 이런 문제를 피하는 가장 좋은 방법은 애초에 가변 데이터를 공유하지 않는 것이다.

불변 데이터(아이템 17)만 공유하거나 그냥 공유를 하지 말자.

가변 데이터는 단일 스레드에서만 쓰도록 하자.

이 정책을 받아드렸다면 이 사실을 문서에 남겨 유지보수 과정에서도 정책이 지켜지도록 하는 것이 중요하다.

한 스레드가 데이터를 다 수정한 후 다른 스레드에 공유할 때는 해당 객체에서 공유하는 부분만 동기화해도 된다.

그러면 그 객체를 다시 수정할 일이 생기기 전까지 다른 스레드들은 동기화 없이 자유롭게 값을 읽어갈 수 있다.

이런 객체를 사실상 불변이라고 하고 다른 스레드에 이런 객체를 건네는 행위를 안전발행이라고 한다.

→ 객체를 안전하게 발행하는 방법

정적 필드, volatile 필드, final 필드, 보통의 락을 통해 접근하는 필드, 동시성 컬렉션(AutomicLong 같은)