동기화
- synchronized 키워드는 해당 메서드나 블록을 한번에 한 스레드씩 수행하도록 보장해준다. 즉, 배타적 실행을 도와준다.
- 동기화는 일관성이 깨진 상태를 볼 수 없게하는 것은 물론, 동기화된 메서드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다.
- 자바 언어 명세는 스레드가 필드를 읽을 때 항상 수정이 완전히 반영된 값을 얻는다고 보장하지만, 한 스레드가 저장한 값이 다른 스레드에게 보이는가는 보장하지 않는다.
스레드 간 통신
공유 중인 가변 데이터를 비록 원자적으로 읽고 쓸 수 있을지라도 동기화에 실패하면 처참한 결과를 얻을 수 있다. 예를 들어 스레드를 멈추는 작업을 생각해보자.
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;
}
}
이 프로그램은 1초 후에 종료될 것 같지만 사실은 그렇지 않다. 원인은 동기화에 있는데, 동기화하지 않으면 메인 스레드가 수정한 값을 백그라운드 스레드가 언제쯤 보게 될지 보증할 수 없다. 동기화가 빠지면 가상 머신이 다음과 같은 최적화를 수행할 수도 있다.
// 원래 코드
while(!stopRequested) i++;
// 최적화한 코드
if(!stopRequestd)
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();
}
}
반복문에서 매번 동기화하는 비용이 크진 않지만 속도가 더 빠른 대안이 있다. stopRequested필드를 volatile으로 선언하면 동기화를 생략해도 된다.
volatile
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++;
}
이 메서드는 제대로 동작하지 않는다. 문제는 증가 연사자(++)에 있다. 이 연산자는 코드상으로는 하나지만 실제로는 nextSerialNumber필드에 두 번 접근한다. GenerateSerialNumber 메서드에 synchronized 한정자를 붙이면 이 문제가 해결된다. 동시에 호출해도 서로 간섭하지 않으며 이전 호출이 변경한 값을 읽게 된다. 메서드에 synchronized를 붙였다면 nextSerialNumber필드에는 volatile을 제거해야 한다.
java.util.concurrent.atomic
이 패키지에는 락 없이도 스레드 안전한 프로그래밍을 지원하는 크래스들이 담겨 있다. volatile은 동기화의 두 효과 중 통신 쪽만 지원하지만 이 패지키는 원자성(배타적 실행)까지 지원한다.
private static final AtomicLong nextSerialNumber = new AtomicLong();
public static int generateSerialNumber() {
return nextSerialNumber.getAndIncrement();
}
이번 챕터에서 언급한 문제를 피하는 가장 좋은 방법은 애초에 가변 데이터를 공유하여 사용하지 않는 것이다. 불변 데이터만 공유하거나 아무것도 공유하지 말자.
사실상 불변과 안전 발행
한 스레드가 데이터를 다 수정한 후 다른 스레드에 공유할 때는 해당 객체에서 공유하는 부분만 동기화해도 된다. 그러면 그 객체를 다시 수정할 일이 생기기 전까지 다른 스레드들을 동기화 없이 자유롭게 값을 읽어갈 수 있다. 이를 사실상 불변이라 하고 다른 스레드에 이런 객체를 건네는 행위를 안전 발행이라 한다.
객체를 안전하게 발행하는 방법은 다음과 같다.
- 클래스 초기화 과정에서 객체를 정적 필드, volatile필드, final필드, 혹은 락을 통해 접근하는 필드에 저장한다
- 동시성 컬렉션에 저장한다.