💡 여러 Thread가 가변 데이터를 공유한다면 그 데이터를 읽고 쓰는 동작은 반드시 동기화하라
📌 synchronized 키워드
- Thread Synchronization : Multi-thread 환경에서 하나의 공유자원에 여러 thread가 동시에 접근하는 것을 막는 것
- Critical Section : 공유 데이터가 사용되어 Synchronization이 필요한 부분
- Java에서는 ciritical section에 synchronized 키워드를 활용하면 된다.
더보기
✒️ Synchronized 블럭
synchronized(락 객체) {
//임계 영역 (Thread 동시접근이 불가능)
}
- 코드의 가독성 측면에선 좋으나, 성능 면에선 별로다.
- 지정된 객체는 critical section의 공유를 지정하는 변수 혹은 타입(클래스)다.
✒️ Synchronized 함수
public synchronized void method() {
// 자원 경합 (race condition)이 일어나는 코드
}
- 함수 단위로 critical section을 구성한다.
- 해당 Class의 모든 sychronized 함수, this를 사용하는 블럭의 lock이 공유된다.
- 하나만 Thread가 점유해도 모두 들어가지 못한다.
이 외에도 명시적으로 lock, unlock하는 방법도 있다.
📌 Synchronization
💡 동기화 없이는 한 Thread가 만든 변화를 다른 Thread에서 확인하지 못할 수도 있다.
- 배타적 실행(Exclusion)
- 한 thread가 객체를 변경하는 중이라, 상태가 일관되지 않은 순간의 객체를 다른 thread가 보지 못하게 막는다.
- 일관된 상태를 갖는 객체에 접근하는 메서드가 해당 객체에 lock을 걸어, 객체의 상태를 확인하고 수정한다.
- synchronized로 설정된 메서드 혹은 코드 블럭에 lock이 생긴다.
- 그 사이에 일관성이 깨지는 순간을 다른 어떤 메서드도 확인할 수 없다.
- Thread간 통신(Communication)
- 동기화된 메서드나 블록에 들어간 Thread가 같은 lock의 보호 하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다.
- 말을 뭐 이렇게 어렵게 해놨나..쉽게 말해서 같은 임계 영역 바라보고 있는 애들끼리는 일관된 값을 보게 한다는 말이다. (아래 Thread 동기화 중단 To-be 코드를 보면 이해가 간다.)
✒️ Synchronization을 배타적 실행뿐 아니라 Thread 사이 안정적 통신에 꼭 필요하다
Java 명세상 long, double 외의 변수를 읽고 쓰는 동작은 원자적(Atomic)이다.
즉, 여러 Thread가 같은 변수를 동기화 없이 수정하는 중이라도, 항상 어떤 Thread가 정상적으로 저장한 값을 온전히 읽어옴을 보장한다는 것이다.
언뜻 보면, 이 정도만으로도 Thread 안정성에 문제가 없어보인다.
그래서 "성능을 높이기 위해 원자적 데이터를 읽고 쓸 때는 synchronization하지 말아야겠다"고 생각할 수 있는데, 굉장히 위험한 발상이다.
왜냐하면, Java는 한 Thread가 저장한 값이 다른 Thread에게 '보이는가'는 보장하질 않는다.
이는 Java의 Memory model의 특성이다.
Thread가 변수를 읽어올 때 CPU Cache에서 읽어오게 되는데, 실제 변수 값이 변화하더라도 해당 Thread는 그 전의 값이 저장된 CPU cache에서 값을 읽어온다.
즉, "동시성 제어"는 해주지만 "가시성"에 대한 부분을 책임져주지 않는다. (메모리 가시성이라 이해하면 편하다.)
✒️ 원자적(Atomic)이란?
원자성은 연산의 원자성이라고 이해하면 쉽다.
한 줄의 프로그램 statement(문장)가 compiler에 의해 기계어로 변경되면서, 이를 기계가 순차적으로 처리하기 위한 machine instruction이 만들어져 실행되기 때문에 발생한다.
여기서 원자단위 연산이란, 다른 Thread의 CPU 개입이 있을 수 없는 최소 단위 연산이다.
✒️ long, double이 원자적(Atomic)이지 않은 이유
JVM은 32-bit memory에 값을 할당하는 연산은 원자적이다.
여기서 64-bit 연산을 수행하려면 값을 분할해야 하기 때문에 원자성이 깨진다고 한다.
만약, JVM 64-bit machine을 사용하면 long, double도 Atomic한 연산을 수행한다.
📌 동기화 : Thread 중단
❌ As-is. Thread.stop
- Java11까지 와서 겨우 없어지긴 했는데, 이전 버전에서도 deprecated 됐고 쓰지 마라
- 해당 메서드를 사용하면 데이터가 훼손될 수 있다.
- Thread.stop()을 호출하면, 해당 Thread를 바라보던 다른 Thread들에 ThreadDeathException가 전파된다.
- 덩달아 모든 lock이 해제되면서 일관성이 손상된다.
❌ As-is. 동기화 없는 필드 변경
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;
}
}
- boolean은 원자적이라 synchronized 해주지 않아도 괜찮다고 생각하면 안 된다.
- Java memory machine은 visibility를 보장하지 않는다
- 따라서 다른 Thread에서 stopRequested를 바꿔도, backgroundThread에서 언제 그 값을 읽을 지 예측할 수 없다.
더보기
✒️ OpenJDK 최적화
// 원래 코드
while (!stopRequested)
i++;
// 가상 머신에 의해 최적화된 코드
if (!stopRequested)
while (true)
i++;
동기화가 빠지면 OpenJDK server VM이 끌어올리기(hoisting) 최적화 기법으로 위 코드처럼 바꿔버릴 수도 있다.
🟢 To-be
💡 쓰기와 읽기 모두 동기화하라
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();
}
}
- 쓰기 메서드, 읽기 메서드 둘 중 하나만 동기화해도 멀쩡해보이지만 안전하지 않다.
- 위 코드는 배타적 수행 기능 없이 통신 기능만 사용한다.
처음에 requestStop() 바꾸는 거 깜빡하고 그냥 돌렸는데 되길래 뭔가 싶었는데, 그 다음에 책 읽자마자 그렇게 하지 말라고 혼났다 ㅋㅋㅋㅋㅋ
📌 반복문에서 동기화 비용 감소 : 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;
}
}
- 반복을 할 때마다 동기화하는 비용(비싸진 않지만)이 아까울 때, 더 빠르게 만들 수 있다.
- Java memory machine이 보장하지 않던 Visibility를 보장하게 된다.
✒️ volatile 원리
volatile로 선언된 변수는 CPU에서 연산을 하면 바로 Memory에 쓴다.
(cache에서 memory로 값이 이동하는 걸 보통 flush라고 말한다.)
책에서 volatile을 쓰면 조금 더 빨라진다고 한 이유는 synchronized의 lock & unlock 과정의 context switching으로 인한 overhead를 줄여주기 때문이다.
다만, 이 volatile 또한 사용의 제약 조건이 있는데 "하나의 Thread만이 연산(modify)을 수행 해야한다."
🚨 volatile 주의 사항
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}
- 증가 연산자(++)은 내부적으로 필드에 두 번 접근한다.
- 그 사이에 다른 Thread에서 접근해 값을 읽어가면 안전 실패(safety failure)가 된다.
- 문제 해결 방법
- 메서드에 synchronized 한정자를 붙이면 해결된다.
- nextSerialNumber의 volatile을 제거한다.
- int 대신 long을 사용하면 견고해진다.
- nextSerialNumber가 최댓값에 도달하면 예외를 던지게 해도 좋다.
📌 Synchronize 지원 라이브러리
private static final AtomicLong nextSerialNum = new AtomicLong();
public static long generateSerialNumber() {
return nextSerialNum.getAndIncrement();
}
- java.util.concurrent.atomic 패키지의 AtomicLong을 사용해보라.
- Lock free한 Thread-safe를 지원하는 클래스들이 많이 담겨있다.
- 통신만 지원하는 volatile과 달리, 원자성(배타적 실행)까지도 지원한다.
- 성능도 synchronized 버전보다 우수하다.
📌 Synchronize 이슈 피하는 방법
💡 가변 데이터는 Single Thread에서만 쓰도록 하자
- 가변 데이터를 공유하지 말고 불변 데이터만 공유하던가, 아무것도 공유하지 마라
- 위 정책을 따르기로 했다면, 그 사실을 문서에 남겨 유지보수 과정에서도 정책을 유지하게 만들어라.
- 사용하려는 Framework와 Library를 깊이 이해하라
📌 다른 Thread에 공유하는 방법
- 한 Thread가 데이터를 다 수정한 후 공유할 때는 공유하는 부분만 Synchronization해도 된다.
- 이런 객체를 사실상 불변(effectively immutable)이라 한다.
- 다른 Thread에 이런 객체를 건네는 행위를 안전 발행(safe publication)이라 한다.
- 객체를 안전하게 발행하는 방법은 다양하다.
- 클래스 초기화 과정에서 객체를 정적 필드, volatile 필드, finale 필드, 혹은 lock 등을 통해 접근하는 필드에 저장해도 된다.
- 동시성 컬렉션(Item 81)에 저장하는 방법도 있다.
🧐 Effectively Immutable?
final class Foo {
final boolean canChange;
private int num;
Foo(boolean canChange) { this.canChange = canChange; }
public void setNum(int newNum) {
if (canChange) {
this.num = newNum;
} else {
throw new IllegalSateException();
}
}
}
- 값 또는 객체 참조는 특정 범위 외부에서 변경되더라도, 특정 범위 내에서 변경되지 않도록 보장하는 방법이다.
- 메서드 세부 사항으로 인해 필드 변경이 불가능한 클래스의 instance는 효과적으로 변경할 수 없다.
- 또 다른 예로 길이가 0인 배열을 들 수 있다. 변경할 수 있는 요소가 없으므로 사실상 변경할 수 없다.