아이템 78. 공유 중인 가변 데이터는 동기화해서 사용하라. - ksw6169/effective-java GitHub Wiki
동기화의 중요한 기능
1. 배타적 실행
synchronized
키워드는 해당 메소드나 블록을 한 번에 한 스레드씩 수행하도록 보장한다.
- 즉, 한 스레드가 변경하는 중일 때 일관되지 않은 순간의 객체를 다른 스레드가 보지 못하게 막는 용도로 사용한다.
2. 스레드 간의 통신
- 동기화 없이는 한 스레드가 만든 변화를 다른 스레드에서 확인하지 못할 수 있다.
- 동기화는 일관성이 깨진 상태를 볼 수 없게 하는 것은 물론, 동기화된 메소드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다.
원자적 데이터를 읽고 쓸 때도 동기화가 필요하다.
- 언어 명세상 long과 double 외의 변수를 읽고 쓰는 동작은 원자적(atomic)이다. 즉, 여러 스레드가 같은 변수를 동기화 없이 수정하는 중이라도 항상 어떤 스레드가 정상적으로 저장한 값을 온전히 읽어옴을 보장한다는 뜻이다.
- 여기서 성능을 높이려면 원자적 데이터를 읽고 쓸 때는 동기화하지 말아도 된다고 생각할 수 있지만, 이는 위험한 발상이다. 자바 언어 명세는 스레드가 필드를 읽을 때 항상 수정이 완전히 반영된 값을 얻는다고 보장하지만, 한 스레드가 저장한 값이 다른 스레드에게 보이는가는 보장하지 않기 때문이다. 따라서 동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다.
다른 스레드를 멈추는 올바른 방법
- 다른 스레드를 멈출 때는 Thread.stop을 사용하지 말라.
- Thread.stop 메소드는 안전하지 않아 이미 오래 전에 deprecated API로 지정되었다. (이 메소드를 사용하면 데이터가 훼손될 수 있다.) 대신에 다른 스레드를 멈추는 올바른 방법이 있다.
- 첫 번째 스레드는 boolean 필드를 폴링하면서 그 값이 true가 되면 멈춘다. 이 필드를 false로 초기화해놓고, 다른 스레드에서 이 스레드를 멈추고자 할 때 true로 변경하는 식이다.
- boolean 필드를 읽고 쓰는 작업이 원자적이라 어떤 프로그래머는 이런 필드에 접근할 때 동기화를 제거하기도 하는데 이는 올바르지 못한 생각이다.
// 잘못된 코드 - 이 코드는 1초 후에 종료되지 않고 무한 루프를 수행한다.
public class StopThread {
private static boolean 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);
stopRequested = true;
}
}
backgroundThread
가 중지되지 않는 원인은 동기화에 있다. 동기화하지 않으면 메인 스레드가 수정한 값을 백그라운드 스레드가 언제쯤에나 보게 될지 보증할 수 없다. 동기화가 빠지면 가상머신(JVM)이 다음과 같은 최적화를 수행할 수도 있는 것이다.
// 원래 코드
while (!stopRequested)
i++;
// 최적화한 코드
if (!stopRequested)
while (true)
i++;
- 이 문제는
stopRequested
필드를 동기화해 접근하면 이 문제를 해결할 수 있다.
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();
}
}
- 여기서 주의할 점은 쓰기와 읽기 모두가 동기화되지 않으면 동작을 보장하지 않는다는 점이다.
동기화보다 속도가 더 빠른 대안 - volatile
- volatile 한정자는 배타적 수행과는 상관없지만 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다. (즉, 동기화의 두 가지 기능 중 하나인 배타적 수행 의도로는 동기화를 대체할 수 없다.)
- 앞에서는
stopRequested
필드를 스레드 간 통신이라는 용도로만 사용했으므로 stopRequested
필드를 volatile로 선언하면 동기화를 생략해도 된다.
public class StopThread {
private static volatile boolean 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);
stopRequested = true;
}
}
volatile은 주의해서 사용해야 한다.
- 예를 들어 다음은 일련번호를 생성할 의도로 작성한 메소드다.
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}
- 이 메소드는 매번 고유한 값을 반환할 의도로 만들어졌다.
- 이 메소드의 상태는 단 하나의 필드로 결정되는데, 원자적으로 접근할 수 있고 어떤 값이든 허용한다. 따라서 굳이 동기화하지 않더라도 불변식을 보호할 수 있어 보인다. 하지만 이 역시 동기화 없이는 올바로 동작하지 않는다.
- 문제는 증가 연산자(++)로 이 연산자는 코드상으로는 하나지만 실제로는 필드에 두 번 접근한다. 먼저 값을 읽고, 그런 다음 1 증가한 새로운 값을 저장한다. 만약 두 번째 스레드가 이 두 접근 사이를 비집고 들어와 값을 읽어가면 첫 번째 스레드와 똑같은 값을 돌려받게 된다. 프로그램이 잘못된 결과를 계산해내는 이런 오류를 안전 실패(safety failure)라 한다.
- 이 문제는
generateSerialNumber
메소드에 synchronized
한정자를 붙이면 해결된다. 메소드에 synchronized
를 붙였다면 nextSerialNumber
필드에서는 volatile을 제거해야 한다.
java.util.concurrent.atomic 패키지
- 이 패키지에는 락 없이도(lock-free) 스레드 안전한 프로그래밍을 지원하는 클래스들이 담겨 있다.
- volatile은 동기화의 두 효과 중 통신 쪽만 지원하지만 이 패키지는 원자성(배타적 실행)까지 지원한다.
- 더구나 성능도 동기화 버전보다 우수하다.
// java.util.concurrent.atomic을 이용한 락-프리 동기화
private static final AmomicLong nextSerialNum = new AtomicLong();
public static long generateSerialNumber() {
return nextSerialNum.getAndIncrement();
}
가변 데이터는 단일 스레드에서만 써라.
- 앞에서 언급한 문제를 피하는 가장 좋은 방법은 애초에 가변 데이터를 공유하지 않는 것이다.
- 불변 데이터만 공유하거나 아무것도 공유하지 말자.
- 다시 말해 가변 데이터는 단일 스레드에서만 쓰도록 하자.
- 다른 스레드에 데이터를 공유할 때는 해당 객체에서 공유하는 부분만 동기화해도 된다.
안전 발행(safe publication)
- 한 스레드가 데이터를 다 수정한 후 다른 스레드에 공유할 때는해당 객체에서 공유하는 부분만 동기화해도 된다. 그러면 그 객체를 다시 수정할 일이 생기기 전까지 다른 스레드들은 동기화 없이 자유롭게 값을 읽어갈 수 있다.
- 이런 객체를 사실상 불변(effectively immutable)이라 하고 다른 스레드에 이런 객체를 건네는 행위를 안전 발행이라고 한다.
- 객체를 안전하게 발행하는 방법은 이 밖에도 많다. 클래스 초기화 과정에서 객체를 정적 필드, volatile 필드, final 필드, 혹은 보통의 락을 통해 접근하는 필드에 저장해도 된다. 그리도 동시성 컬렉션에 저장하는 방법도 있다.
핵심 정리
- 여러 스레드가 가변 데이터를 공유한다면 그 데이터를 읽고 쓰는 동작은 반드시 동기화해야 한다.
- 동기화하지 않으면 한 스레드가 수행한 변경을 다른 스레드가 보지 못할 수도 있다.
- 공유되는 가변 데이터를 동기화하는 데 실패하면 응답 불가 상태에 빠지거나 안전 실패로 이어질 수 있다. 이는 디버깅 난이도가 가장 높은 문제에 속한다. 간헐적이거나 특정 타이밍에만 발생할 수도 있고, VM에 따라 현상이 달라지기도 한다.
- 배타적 실행은 필요 없고 스레드끼리의 통신만 필요하다면 volatile 한정자만으로 동기화할 수도 있다. 다만 올바로 사용하기가 까다롭다.
참고 자료