동기화
동기화란 멀티 쓰레드 환경에서 하나의 메서드 혹은 블록을 한 번에 하나의 쓰레드만 수행하도록 보장하는 것을 의미합니다.
- 싱글 쓰레드 환경에서는 동기화 걱정 안 해도 됨
- 멀티 쓰레드 환경에서는 여러 개의 쓰레드가 하나의 객체를 공유해서 사용하는 경우가 있으므로 동기화 처리 필요
1. 동기화 과정
- 한 객체가 일관된 상태를 가지고 생성되었을 때 해당 객체에 접근하는 메서드는 다른 쓰레드가 메서드를 실행할 수 없도록 락을 검
- 락을 건 메서드는 객체의 상태를 확인하거나 필요하면 수정
- 정리하자면 일관된 하나의 상태에서 다른 일관된 상태로 변화시킴
- 메서드 실행이 끝나면 락을 해제
2. 동기화 특징
- 동기화를 제대로 사용할 경우 어떤 메서드도 해당 객체의 상태가 일과되지 않은 순간을 목격할 수 없음
- 동기화가 없을 경우 여러 쓰레드가 접근해 시점에 따라 일관된 상태가 아닐 수 있기 때문에 동기화 없이는 한 쓰레드가 많은 변화를 다른 쓰레드에서 확인할 수 없음
- 언어 명세상 long과 double 외의 변수를 읽고 쓰는 동작은 원자적
- 다수의 쓰레드가 하나의 변수에 동기화 없이 접근하더라도 항상 정상적으로 저장한 값을 온전히 읽어옴을 보장
- `원자적 데이터를 읽고 쓸 때는 동기화하지 않아도 되겠다`라고 생각하는 것은 위험한 발상
- 필드를 읽을 때 항상 수정이 완전히 반영된 값을 얻지만
- `한 쓰레드가 저장한 값이 다른 쓰레드에도 보이는가?` 즉, 가시성은 보장하지 않기 때문
- 가시성은 한 쓰레드에서 수정된 변수의 변경이 다른 쓰레드에서 즉시 반영되도록 하는 특성을 의미
공유 중인 가변 데이터를 동기화 처리하지 않은 코드
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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초 뒤에 `stopRequested` 필드가 false로 변경되어 쓰레드 내 while 문이 종료될 것으로 예상
실제 결과
- `stopRequested` 필드가 동기화되지 않아 메인 쓰레드에서 수정한 `stopRequested` 필드 값을 백그라운드 쓰레드가 언제 즘에나 보게 될지 보증할 수 없음
- 따라서 무한 루프에 빠지게 됨
- 설상가상으로 동기화 처리를 하지 않을 경우 JVM이 다음과 같은 최적화를 수행할 수도 있음
- 자바에서는 JIT(Just-In-Time) 컴파일러를 통해 코드를 최적화하며 최적화 과정에서는 다양한 최적화 기법이 사용될 수 있으며, 이 중에는 "hoisting"과 유사한 최적화 기법이 존재
- 호이스팅(Hoisting)은 일반적으로 변수 및 함수 선언을 해당 스코프의 최상위로 옮기는 것을 가리키는데, 이는 주로 JavaScript에서 발생하는 현상
- 이 결과 프로그램은 응답 불가 상태가 되어 더 이상 진전이 없음
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//원래 코드 | |
while (!stopRequested) { | |
i++; | |
} | |
// hoisting 처리된 코드 | |
if (!stopRequested) { | |
while(true) { | |
i++; | |
} | |
} |
공유 중인 가변 데이터를 동기화 처리한 코드
1. 메서드에 synchornized 키워드 부여
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} |
코드 부연 설명
- 자바에서 제공하는 synchronized 키워드를 통해 동기화 처리
- 읽기/쓰기 메서드 모두 synchronized 키워드를 통해 동기화 처리
- 쓰기 메서드만 동기화 처리를 하고 읽기 메서드에는 동기화 처리를 하지 않을 경우 정상적인 동작을 보장할 수 없음
- 둘 다 동기화 처리를 했기 때문에 예상대로 1초만에 프로그램 종료
2. 가변 변수에 volatile 키워드 부여
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 변수를 읽어 들일 때 CPU 캐시가 아니라 메인 메모리로부터 값을 읽어 들임
- 즉, 읽을 때도 CPU 캐시가 아닌 메인 메모리에서 읽어오고, 쓸 때도 CPU 캐시가 아닌 메인 메모리에 쓰기를 수행
- long, double을 제외한 primitive type 자료형은 volatile 키워드 부여 시 syncrhonized 키워드를 통한 동기화를 생략해도 예상한 대로 동작
2.1 volatile 키워드 사용 시 주의할 점
- volatile 키워드는 mutex 즉, 단일 메서드 혹은 블록을 단일 쓰레드만 수행하도록 하는 기법과는 무관
- mutex를 원한다면 volatile 키워드와 함께 syncrhonized 키워드도 사용해야 함
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class Example { | |
private static volatile int nextSerialNumber = 0; | |
public static int generateSerialNumber() { | |
return nextSerialNumber++; | |
} | |
public static void main(String[] args) { | |
Map<Integer, Integer> num2cnt = new ConcurrentHashMap<>(); | |
Thread thread1 = new Thread(() -> { | |
for (int i = 0; i < 1e6; i++) { | |
int num = generateSerialNumber(); | |
if (num2cnt.get(num) == null) { | |
num2cnt.put(num, 1); | |
} else { | |
num2cnt.put(num, num2cnt.get(num) + 1); | |
} | |
} | |
}); | |
Thread thread2 = new Thread(() -> { | |
for (int i = 0; i < 1e6; i++) { | |
int num = generateSerialNumber(); | |
if (num2cnt.get(num) == null) { | |
num2cnt.put(num, 1); | |
} else { | |
num2cnt.put(num, num2cnt.get(num) + 1); | |
} | |
} | |
}); | |
thread1.start(); | |
thread2.start(); | |
try { | |
thread1.join(); | |
thread2.join(); | |
} catch (InterruptedException e) { | |
throw new RuntimeException(e); | |
} | |
for (Map.Entry<Integer, Integer> entry : num2cnt.entrySet()) { | |
if (entry.getValue() >= 2) { | |
System.out.println(String.format("%d가 %d번 반환됨.\n", entry.getKey(), entry.getValue())); | |
} | |
} | |
} | |
} |

예상 결과
- generateSerialNumber() 메서드를 호출할 때마다 1씩 증가한 고유한 nextSerialNumber을 반환할 것으로 예상
- nextSerialNumber가 long이나 double이 아닌 int이고 volatile 키워드가 부여되었기 때문에 최신 값을 읽을 수 있을 것이라고 예상
실제 결과
- `nextSerialNumber++;`를 풀어서 작성하면 `nextSerialNumber = nextSerialNumber + 1;`과 같은 형태
- 결국 nextSerialNumber 값을 한 번 읽어와 + 1 한 다음 다시 nextSerialNumber 변수에 저장하는 형태
- volatile 키워드만 부여되었으므로 nextSerialNumber + 1 연산이 이루어지는 시점에 다른 쓰레드가 비집고 들어오면 특정 숫자가 두 번 리턴되는 상황
- 이런 오류를 안전 실패(safety failure)라고 지칭
해결 방법
- mutex를 보장하기 위해 generateSerialNumber() 메서드에 synchronized 키워드를 부여하면 됨
- synchronized 메서드 또는 블록을 사용하는 경우 변수에 대한 가시성을 보장하기 위해 volatile을 사용할 필요가 없음
- synchronized 블록을 통해 동기화가 이미 이루어지고 있기 때문에 메모리에 쓰인 값이 다른 쓰레드에게 정확하게 보장되기 때문
- 따라서, generateSerialNumber() 메서드에 synchronized 키워드 부여 시 nextSerialNumber 변수의 volatile 키워드는 제거해도 됨

long, double을 사용할 때는 주의하자
- long, double 타입 자료형에 동기화 처리가 필요시 java.util.concurrent.atomic 패키지에서 제공하는 AtomicLong, AtomicDouble 사용하는 것을 권장
- 위 패키지는 락 없이도 thread-safe 한 프로그래밍을 지원하는 클래스들이 담겨 있음
- CAS 기법(Compare And Swap)을 사용하기 때문에 락 없이도 thread-safe 함을 보장
- java.util.concurrent.atomic 패키지는 가시성과 함께 mutex까지 지원
참고하면 좋은 카테고리
https://jaimemin.tistory.com/category/JAVA/RxJava
'JAVA/RxJava' 카테고리의 글 목록
메일: jaimemin@naver.com
jaimemin.tistory.com
참고
이펙티브 자바
반응형
'JAVA > Effective Java' 카테고리의 다른 글
[아이템 80] 쓰레드보다는 실행자, 태스크, 스트림을 애용하라 (0) | 2024.04.07 |
---|---|
[아이템 79] 과도한 동기화는 피하라 (0) | 2024.04.07 |
[아이템 77] 예외를 무시하지 말라 (2) | 2024.03.29 |
[아이템 76] 가능한 한 실패 원자적으로 만들라 (0) | 2024.03.29 |
[아이템 75] 예외의 상세 메시지에 실패 관련 정보를 담으라 (0) | 2024.03.29 |