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이 실제로 적용하는 끌어올리기라는 최적화 기법이다.
아래와 같이 바꾸면 기대한대로 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();
}
물론 가장 좋은 방법은 애초에 가변 데이터를 공유하지 않는 것이다. 가변 데이터는 단일 스레드에서만 쓰도록 하자.