요약
동기화는 배타적 실행 뿐만 아니라 스레드 간 안정적인 통신을 보장하기 때문에 공유 중인 가변 데이터는 동기화해 사용해야 한다.
그렇지 않으면 응답 불가 상태에 빠지거나 안전 실패(프로그램이 잘못된 결과를 계산해내는 오류)로 이어질 수 있다.
동기화의 두 가지 기능
- 배타적 실행
배타적 실행은 한 스레드가 객체의 상태를 변경하고 있을 때, 상태가 일관되지 않은 순간의 객체를 다른 스레드가 보지 못하게 막는 것을 의미한다. 따라서, 동기화를 사용하면 항상 일관적인 객체 상태를 보장할 수 있다.
- 스레드 사이의 안정적 통신 보장
동기화를 사용하면 하나의 스레드에서 수행한 작업의 결과를 다른 스레드가 접근해 상태를 확인할 수 있다.
원자적 데이터
long, double 외의 type 데이터를 읽고 쓰는 동작은 원자적
→ 여러 스레드가 동기화 없이 수정하는 중에도 다른 스레드가 저장한 값을 읽어올 수 있다.
→ BUT, 원자적 데이터로 만들고 동기화를 하지 말아야겠다는 생각 NO
→ 자바에서는 스레드가 필드를 읽을 때 항상 수정이 완전하게 반영된 값을 얻지만, 이는 다른 이 값을 다른 스레드가 볼 수 있는지 보장하지는 못한다(자바 메모리 모델이 원인)
실패 예시
- 로직
- stopRequested = false로 초기화
- 첫번째 스레드가 stopRequested를 폴링하다가 stopRequested == true 일 때 loop 멈춤
- 다른 스레드에서 스레드를 멈추려고 할 때 stopRequest = true
- 예상 : 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) { // background 스레드
i++;
}
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true; // 메인 스레드
}
}
원인
- 동기화를 하지 않았기 때문 → 안정적 통신을 보장한다
- 동기화를 하지 않아 main 스레드가 수정한 값을 background 스레드가 언제쯤에 보게 될지 보증할 수 없다
- 동기화가 빠지면 VM이 다음과 같이 최적화를 수행할 수도 있다
- 이는 OpenJDK 서버 VM이 실제로 적용하는 끌어올리기(hosting)라는 최적화 기법으로, 그 결과 프로그램은 응답 불가(liveness failure) 상태가 되어 더이상 진전이 없다
- // 원래 코드 while (!stopRequested){ i++; } // 최적화한 코드 if (!stopRequested) { while(true) { i++; } }
해결
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
위 코드의 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++;
}
- 의도
- 2^32을 넘지 않을 경우, 매번 고유한 값 반환하는 것
- 문제
- nextSerialNumber가 int 타입으로 원자적으로 접근할 수 있어서 동기화하지 않아도 불변식을 보호할 수 있어 보이지만, 실제로는 올바르게 동작하지 않는다
- 원인 - 증가 연산자(++)
- 이 연산자는 nextSerialNumber에 두 번 접근하기 때문에 문제가 발생한다
- 만약 두번째 스레드가 첫 번째와 두 번째 사이에 접근한다면, +1된 값이 아닌 첫번째 스레드와 똑같은 값을 돌려받게 되는 문제가 발생한다
- 해결 방법
- generateSerialNumber()에 synchronized 설정하기
- 여러 스레드가 동시에 호출해도 서로 간섭하지 않으며, 이전 호출이 변경한 값을 읽을 수 있게됨
- 이때 nextSerialNumber 앞에 volatile은 제거해야 한다
- ➕ int 타입 → long 타입 || nextSerialNumber가 최댓값 도달 시 예외 던지기
- ➕ java.util.concurrent.atomic 패키지 클래스(AtomicLong) 사용하기
- AtomicLong을 활용한 최적화 코드
- public class StopThreadAtomic { private static final AtomicLong nextSerialNum = new AtomicLong(); public static long generateSerialNumber() { return nextSerialNum.getAndIncrement(); } }
- generateSerialNumber()에 synchronized 설정하기
애초부터 동기화 문제를 피하는 방법
- 가변 데이터는 단일 스레드에서만 쓰도록 하자
- 사용하려는 프레임워크와 라이브러리를 깊게 이해하자
- 필요한 부분만 부분 동기화 하자
- 한 스레드가 데이터를 다 수정한 후, 다른 스레드에 공유할 때 해당 객체에서 공유하는 부분만 동기화하는 것
- 그 객체를 다시 수정할 일이 생기기 전까지 다른 스레드를 동기화 없이 자유롭게 값을 읽어갈 수 있다
- 이를 사실상 불변(effectively immutable)이라고 한다
- 이런 사실상 불변 객체를 다른 스레드에게 전달하는 것을 안전 발행(safe publication)이라 한다
참고
'Dev Language > EffectiveJava' 카테고리의 다른 글
[EffectiveJava] 스레드 안전성 수준을 문서화하라 (1) | 2025.05.18 |
---|---|
[EffectiveJava] 스레드보다는 실행자, 태스크, 스트림을 애용하라 (0) | 2025.05.11 |
[Effective Java] 예외의 상세 메시지에 실패 관련 정보를 담으라 (0) | 2025.04.27 |
[EffectiveJava] 표준 예외를 사용하라 (0) | 2025.04.20 |
[EffectiveJava] 복구할 수 있는 상황에는 검사 예외를, 프로그래밍 오류에는 런타임 예외를 사용하라 (0) | 2025.04.13 |