핵심 요약
- 멀리 스레드 환경에서 가변 데이터를 공유할 때는 데이터의 일관성과 안정성을 위해 동기화를 사용하라.
- 데이터를 동기화하지 않으면 에측할 수 없는 결과나 안전 실패(safe failure)가 발생할 수 있다.
- 최적의 성능과 안정성을 위해 가변 데이터는 가능한 한 단일 스레드 내에서만 사용하거나, 필요한 부분만 동기화하라.
동기화의 기능
- 배타적 실행 (Exclusive Execution)
- synchronized 키워드는 해당 메소드나 블록을 한 번에 하나의 스레드만 실행할 수 있도록 보장한다.
- 동기화된 메소드를 사용하면, 한 스레드가 객체의 상태를 변경하는 도중에 다른 스레드가 해당 객체의 상태를 읽거나 변경하는 것을 방지할 수 있다.
- 즉, 동기화를 통해 객체의 상태가 항상 일관된 상태를 유지하도록 보장한다.
- 가시성 (Visibility)
- 메모리 가시성: 한 스레드에서 변경한 데이터의 값을 다른 스레드에서 정확히 볼 수 있는 특성
- 자바에서는 스레드가 변수를 캐시에 저장하고 사용할 수 있기 때문에, 동기화를 하지 않으면 한 스레드에서 변경한 값이 다른 스레드에게 즉시 보이지 않을 수 있다.
- 그러나 동기화를 사용하면 한 스레드가 변경한 값이 메인 메모리에 즉시 반영되고, 이 변경사항은 동기화된 다른 스레드에게도 보장된다.
- 즉, 스레드 간의 데이터 일관성과 가시성이 보장된다.
✍ 동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다.
- 예시
- 자바는 long과 double을 제외한 모든 변수의 읽기와 쓰기는 원자적이다. (즉, 배타적 실행이 보장된다.)
- 원자성이 변수의 읽기와 쓰기 연산의 안전성을 보장한다 하더라도, 다중 스레드 환경에서 한 스레드가 변수에 저장한 값을 다른 스레드가 언제 보게 될지는 보장되지 않는다.
- 동기화를 사용하면 메모리에 쓰여진 모든 변경 사항이 다른 스레드에게도 보이게 된다.
- 결론
- 즉, 변수에 대한 접근이 원자적이라고 해서 동기화의 필요성이 사라지는 것은 아니다.
- 원자성은 일부 연산의 안전성만 보장하는 반면, 동기화를 메모리 가시성과 배타적 실행 모두를 보장한다.
❔ 기본형 타입이 원자적인 이유
더보기
JVM은 데이터를 4바이트 단위로 처리한다. 따라서 4바이트 이하의 데이터는 CPU에서 하나의 연산으로 처리될 수 있다.
i) 4바이트 이하의 기본형 타입 : 하나의 명령어로 처리되기 때문에, 한 스레드로만 처리된다.
ii) 8바이트의 기본형 타입 : 여러 스레드가 개입될 여지가 생겨 원자적이라 할 수 없다.
❔ 원자성
더보기
작업이 중단되거나 중간에 방해받지 않고 한 번에 완료되는 것을 말한다.
멀티 스레딩 환경에서 발생할 수 있는 문제와 해결 방법
✍ Thread.stop 메소드는 사용하지 말자.
- Thread.stop 메소드는 스레드를 강제로 멈추게 한다.
- 스레드가 실행 중인 작업을 안전하게 완료하지 않고 중단될 수 있기 때문에, 데이터의 무결성을 손상시킬 위험이 있다.
- 따라서, 이 메소드는 deprecated API로 지정되었다.
flag polling
- 첫 번째 스레드는 boolean 필드(flag)의 값을 주기적으로 확인(polling)하며, 해당 필드의 값이 true일 경우 스레드의 작업을 멈춘다.
- 다른 스레드가 첫 번째 스레드를 멈추고자 할 때, boolean 필드의 값을 true로 변경하여 첫 번째 스레드에게 멈추라고 신호를 보낸다.
- boolean 필드에 대한 접근이 원자적이더라도 동기화나 volatile을 사용하지 않으면 메모리 가시성 문제가 존재한다.
예시 1 - 동기화 X
public class StopThread {
private static boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread th = new Thread(() -> {
int i = 0;
while (!stopRequested) {
i++;
System.out.println(i);
}
});
th.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
- 예시 1은 두 가지 관점에서 예상과 달리 1초 후에 종료되지 않는다.
- 메모리 가시성
- stopRequested를 true로 변경했을 때, 그 변경사항이 백그라운드 스레드에게 즉시 보이지 않을 수 있다.
- JVM 내에서 각 스레드는 종종 로컬 메모리 캐시를 사용하기 때문에, 메인 스레드에서 변경한 값이 백그라운드 스레드의 캐시에 즉시 반영되지 않을 수 있다.
- stopRequested를 true로 변경했을 때, 그 변경사항이 백그라운드 스레드에게 즉시 보이지 않을 수 있다.
- JVM의 최적화: hoisting 최적화 기법
- JVM은 코드를 실행할 때 성능 최적화를 위해 여러 변환을 수행한다.
- 그 중 하나로, 빈번한 체크하는 조건을 최적화하여 반복문을 더 효율적으로 만들려고 할 수 있다.
- while(!stopRequested) 구문에서 stopRequested가 변하지 않는다고 판단된다면, 이를 반복문 밖으로 빼내어 성능을 최적화하려고 할 수 있다.
- 이로 인해 응답 불가(liveness failure) 상태가 되게 된다.
//원래 코드
while(!stopRequested) {
i++;
}
// 최적화한 코드
if(!stopRequested) {
while(true) {
i++;
}
}
예시 2 - 동기화 O (synchronized)
✍ 읽기와 쓰기를 모두 동기화하여 데이터에 대한 안전한 접근을 보장하자.
- 동기화를 통해 예시 1의 문제를 해결할 수 있다.
public class StopThread {
private static boolean stopRequested;
private static synchronized void requestStop() { // true로 설정하는 쓰기 연산을 수행
stopRequested = true;
}
private static synchronized boolean stopRequested() { // flag의 현재 값을 반환하는 읽기 연산을 수행
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);
stopRequested();
}
}
- 예시 2의 synchronized는 스레드 간 통신을 위해 사용한 것이다.
volitile
- Java에서 변수의 선언 앞에 사용하는 한정자이다.
- 캐시가 아닌 메인 메모리에 읽고 쓰는 연산을 수행한다.
- 해당 변수가 여러 스레드에 의해 접근될 수 있으며, 항상 변수의 최신값을 읽거나 쓸 수 있도록 보장한다.
- volitile이 선언된 변수가 있는 코드는 최적화되지 않는다.
- synchronized와 달리 배타적 수행을 하지 않아 성능에 미치는 부담이 적다.
- cf. long, double을 제외한 기본타입은 volitile 키워드를 사용하면 동기화를 생략해도 된다.
예시 3 - volitile
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 주의해야 할 점
ex. ++ 연산자
앞서 다뤘듯, volatile 키워드는 배타적 수행을 보장하지 않는다.
따라서 동시성 문제가 발생할 수 있는데, 아래 예시를 살펴보자.
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}
- 멀티 스레드 환경에서 변수의 증가 연산자(++)와 같은 연산자는 안전하지 않다.
- ++ 연산은 두 단계로 구성된다.
- nextSerialNumber 값을 읽음
- nextSerialNumber 값을 1 증가시킴
- 즉, ++ 연산자는 여러 단계로 구성되어 있어 원자적이지 않다.
- ex. 멀티 스레드 환경에서, 한 스레드가 값을 읽은 뒤 증가시키기 전에, 다른 스레드가 그 값을 읽을 수 있다.
- 해결 방법
- generateNumber 메소드를 synchronized 키워드를 사용하여 동기화해야 한다.
- synchronized 키워드를 사용하므로 volatile을 지운다.
- cf. 일련변호같은 경우 더 큰 범위를 제공하는 long 타입을 사용하는 것이 좋다.
- cf. 최댓값 예외 처리를 포함시키는 것이 좋다.
java.util.concurrent.atomic 패키지 : lock-free 동기화
- lock 없이 스레드 안전한 프로그래밍을 할 수 있도록 설계된 클래스들로 구성되어 있다.
- 기존의 synchronized 키워드를 사용한 동기화 방식은 상대적으로 비용이 크기 때문에, 이를 대체하고자 lock-free 프로그래밍 기법을 지원하는 클래스들이 이 패키지에 포함되어 있다.
- 즉, synchronized 키워드와 달리 오버헤드 없이 원자적 연산을 제공하여 멀티 스레드 환경에서 높은 성능과 안정성을 보장한다.
더보기
❔ 어떻게 lock-free하게 구현 ??
CAS(compare-and-swap)같은 저수준 연산을 활용하여, 전통적인 락 기반의 동기화 없이 스레드 안전을 보장한다고 한다.
- AtomicLong 클래스
- long 값에 대한 원자적 연산을 지원하는 클래스이다.
- 주요 메소드
- get(): 현재 값을 반환한다.
- set(): 새로운 값을 설정한다.
- getAndIncrement(): 현재 값을 반환하고, 그 후에 값을 1 증가시킨다.
private static final AtomicLong nextNum = new AtomicLong();
public static long generateNumber() {
return nextNum.getAndIncrement();
}
기타 조언
- 가변 데이터는 단일 스레드에서만 사용하라. 그리고 정책을 문서에 남겨라.
- 애초에 가변 데이터를 공유하지 말아라.
- 불변 데이터만 공유하거나 아무 것도 공유하지 말아라.
- 프레임워크와 라이브러리를 깊이 이해하자.
- 객체를 안전 발행하는 방법
- 정적 필드
- volatile 필드
- 락을 통한 필드 접근(ex. synchronized)
- 동시성 컬렉션(ex. ConcurrentHashMap)
❔ 사실상 불변(Effectively Immutable) 객체
더보기
- 한 스레드에서 데이터가 완전히 생성 또는 수정된 후 다른 스레드와 공유될 때, 그 데이터는 더 이상 수정되지 않으면 사실상 불변이라고 할 수 있다.
- 이런 객체는 더 이상 변경되지 않기 때문에 여러 스레드에서 동기화 없이 안전하게 읽을 수 있다.
❔ 안전 발행(Safe Publication)
더보기
- 다른 스레드로 사실상 분변 객체를 건네는 행위를 말한다.
- 즉, 안전 발행을 통해 다른 스레드는 항상 완전히 생성 또는 수정된 최신의 데이터를 보게 된다.
'Language > Java' 카테고리의 다른 글
[effective java] 아이템 80. 스레드보다는 실행자, 태스크, 스트림을 애용하라. (0) | 2023.08.13 |
---|---|
[effective java] 아이템 79. 과도한 동기화는 피하라. (0) | 2023.08.13 |
[effective java] 아이템 77. 예외를 무시하지 말라. (0) | 2023.08.12 |
[effective java] 아이템 76. 가능한 한 실패 원자적으로 만들라. (0) | 2023.08.12 |
[effective java] 아이템 75. 예외의 상세 메시지에 실패 관련 정보를 담으라. (0) | 2023.08.12 |