synchronized: 해당 메소드나 블록을 한번에 한 스레드씩 수행하도록 보장하는 키워드
- synchronized 키워드를 선언한 메소드는 메소드가 선언된 객체에 락을 걸고 메소드가 종료되면 락을 해제한다.
- synchronized 키워드를 선언한 블록은, 파라미터로 넘어간 객체에 락을 걸고 블록이 끝나면 락을 해제한다.
배타적 수행 기능 제공
객체를 하나의 일관된 상태에서 다른 일관된 상태로 변화시킨다. 즉, 한 스레드가 객체의 상태를 변경하는 중, 상태가 일관되지 않은 순간의 객체를 다른 스레드가 보지 못하게 락을 걸기 때문에 어떤 메소드도 객체의 일관되지 않은 상태를 볼 수 없다.
스레드 간 통신 기능 제공
동기화된 메소드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다. 즉, 동기화 없이는 한 스레드가 만든 변화를 다른 스레드에서 확인하지 못할 수 있다.
double과 long의 비원자적(Non-atomic) 처리
자바 프로그래밍 언어 메모리 모델의 특성상 long, double 외의 변수를 읽고 쓰는 동작은 원자적(atomic)이다. 64비트인 long과 double은 각각 32비트로 나뉘어서 두 번에 나눠서 처리하기 때문이다. 그래서 가능한 volatile 키워드를 붙혀 원자적으로 만들거나, 64비트 값이 분할되지 않도록 처리를 할 필요가 있다. 참조에 대한 쓰기, 읽기는 32비트 또는 64비트 구현 여부에 관계없이 항상 원자적이다. [참고]
자바의 메모리 모델
double과 long을 제외하곤 원자적이이라고해서 동기화를 하지 않아선 안된다. 한 스레드가 저장한 값이 다른 스레드에게 보이는지 보장하지 않기 때문이다. 스레드의 안정적 통신을 위해서 동기화를 해야하는 이유는 자바의 메모리 모델 규정때문이다.
좌측은 JVM의 메모리 구조고, 우측은 하드웨어(컴퓨터) 아키텍처 구조다. 이 둘은 근본적으로 다르다. 하드웨어에서 스레드 스택과 힙 영역은 모두 메인 메모리에 위치하긴하지만, 이 중 일부는 CPU Registers나 CPU Cache Memory에 존재할 수 있다. 각각의 스레드 스택에서 어떤 변수를 어떻게 가지는지는 이 사이트를 참조하자.
공유객체가 처음 메인 메모리에 저장된 후, 스레드에서 사용하려는 시점에서 CPU Cache Memory에서 읽는다. 그리고 공유객체를 변경했다고 가정하면 CPU Cache Memory에서는 업데이트 되지만 Main Memory에 바로 Flush하지 않는다. 그러면 다른 스레드에서 이 공유객체를 읽을 때 변경되지 않은 값을 읽게될 것이다. 만약 다른 스레드에서도 공유객체를 변경한다면 프로그램이 잘못된 결과를 계산해내는 안전 실패(safety failure)를 일으킨다. 즉, 공유 중인 가변 데이터를 원자적으로 읽고 쓸 수 있더라도 동기화에 실패하면 처참한 결과로 이어진다는 것이다.
예시로 다른 스레드를 멈추는 작업을 생각해보자.
*Thread.stop() 메소드는 안전하지 않아 deprecated API가 됐다. 본질적으로 안전하지 않기 때문이다. 간단하게 요약하면 stop 메소드를 통해 갑자기 종료할 경우 스레드가 사용중인 자원이 불안전한 상태로 남겨지는 것 때문이다. deprecated된 좀 더 자세한 이유는 document를 참고하자
non-synchronized
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;
}
}
stopRequested는 정적 boolean 변수다. 초기값이 없음으로 false가 되고, 이 상태에서 backgroundThread는 stopRequested 변수가 true가 될때까지 루프를 돈다. 이 스레드를 시작하고, 1초뒤에 메인스레드에서는 stopRequested 변수의 상태를 true로 바꾼다. 하지만 backgroundThread의 루프는 멈추지 않는다. 메인 스레드에서 stopRequested 변수에 true를 넣는 것(혹은 읽어오는 작업) 원자적이더라도 CPU Cache Memory에서 업데이트 한거지, Main Memory에 Flush하지 않았기 때문이다. 따라서 backgroundThread는 자신의 CPU Cache Memory에 존재하는 stopRequested는 false이기 때문에 무한루프를 돈다.
그리고 동기화가 빠질 경우 가상머신이 코드를 최적화할 수도 있다.
// 원래 코드
while(!stopRequested)
i++;
// 책에서 설명한 JVM이 최적화 한다는 코드
if(!stopRequested)
while(true)
i++;
// 실제 컴파일된 결과
for(int var0 = 0; !stopRequested; ++var0) {
}
OpenJDK 서버 VM이 실제로 적용하는 끌어올리기(hoisting)라는 최적화 기법이고, 이 결과가 프로그램을 응답 불가(liveness failure) 상태가 되어 더 이상 진전이 없어지는 것이다. 그래서 동기화가 필요한 것이다.
synchronized
동기화를 적용하면 문제가 해결되고 코드는 다음과 같다.
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
반복문에서 매번 동기화 비용이 크진 않지만 속도가 더 빠른 방법도 있다. 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: 변수를 메인 메모리에 저장할 것을 명시하기 위한 키워드. 이 키워드를 사용한 변수의 모든 읽기는 CPU Cache Memory가 아닌 Main Memory에서 읽고, 모든 쓰기는 CPU Cache Memory뿐만 아니라 Main Memory에 기록한다.
자바 메모리 모델을 설명할 때, 동기화를 하지 않으면 CPU Cache Memory에만 업데이트 되고 Main Memory에 flush 되지 않아 다른 스레드가 언제 이를 알게될 지 모르는 문제가 발생한다고 했다. volatile은 항상 Main Memory에 기록하고 읽기 때문에 이러한 문제가 발생하지 않는다.
volatile은 이 특징 뿐만 아니라 더 많은 특징을 가진다. 만약 스레드 A가 객체 B의 volatile 변수를 read/write할 때, 객체 B에 존재하는 non-volatile 변수들도 main memory에서 re-loading한다. write할 땐 현재 스레드에 존재하는 변수들도 main memory를 전부 flush한다. 이를 활용하면 한 객체안에 volatile 변수 하나로 non-volatile 변수들이 의존해서 값을 업데이트할 수 있다.
volatile을 write하면 스레드에 보이는 모든 변수를 메인 메모리로 플러시한다고 했는데, 실제로는 cpu 레지스터에서 메인메모리로 플러시한다. 단지 cpu 캐시가 cpu 레지스터랑 메인메모리 사이에 존재할 뿐이라서 밀어내는건 같다.
딱 이렇게 기억하자.
volatile 변수를 read하면 스레드에 표시되는 모든 변수가 메인 메모리에서 새로 고쳐지고, volatile 변수를 write하면 스레드에 보이는 모든 변수는 메인 메모리에 동기화된다. volatile 변수는 그냥 메인 메모리에 읽고 쓰고할 뿐이다.
Happens Before Guarantee
JVM이 코드를 최적화 하면서 volatile을 write한 후에 non-volatile을 write할 수도 있고, volatile을 read하기 전에 non-volatile을 읽도록 최적화 해버릴 수도 있다. 같은 코드지만 이렇게 최적화가 되면 non-volatile의 값을 최신화할 수 없다. 따라서 volatile 키워드가 있는 부분은 "before-happen"를 보장한다. volatile 변수에 대한 읽기/쓰기 명령은 JVM 에 의해 재정리되지 않는다는 것이다.[The Java volatile Happens-Before Guarantee]
volatile은 만능인가?
만능은 아니다. volatile은 스레드간에 변경된 값을 감지할 수 있지만, 배타적 실행을 보장하진 않는다. 증감식을 예로 들어보면 int로 선언된 volatile 변수 a=0이 있다고 가정해보자. a 값을 1 증가시키는 a++ 증가 연산자는 코드상으로는 하나지만 값을 읽고, 그런 다음 1증가 한 값을 새로 저장하는 것이다. 만약 스레드 A가 a의 값을 읽고 1증가한 값을 저장하기 전에, 스레드 B가 a의 값을 읽으면 증간된 값 1이 아닌 0을 읽게된다. 따라서 두 스레드에서 a에 대한 증감식 결과는 1이된다.
스레드 A가 volatile 변수를 변경하는 과정이 원자적이지 않다면, 스레드 B가 변경되기 전 값을 읽고 값을 변경해버린다. 그러면 앞서 설명했던 프로그램이 잘못된 결과를 계산해내는 안전 실패 오류를 발생시킨다.
다음 코드를 보자.
public class Volatile {
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}
public static void main(String[] args) {
Thread thread = new Thread(Volatile::generateSerialNumber);
}
}
매번 고유한 값을 반환할 의도로 만들어졌다. 원자적으로 접근할 수 있고 어떤 값이든 허용해서 동기화 없어도 불변식을 보호할 수 있는 것처럼 보이지만 위에 말한 증가 연산자는 원자적이지 않다.
그렇다면 언제 사용해야하는가?
하나의 스레드만 읽기/쓰기를 하고 나머지 스레드는 읽기만 할 때 사용해야한다. 그렇다면 최신의 값을 언제나 보장할 수 있다. 여러 스레드가 쓰기를 할 경우 동기화를 사용해야한다.
volatile의 성능상 단점
변수가 메인 메모리에 읽히거나 쓰이게 되니 CPU cache memory에 액세스하는 것보다 더 높은 비용을 가진다. 그리고 volatile변수에 접근하면 JVM의 일반적인 성능 향상 기술 최적화를 사용할 수 없다.(코드 재배열을 할 수 없으니까)
Volatile 클래스 최적화
두가지 방법으로 최적화할 수 있다.
synchronized 한정자를 붙히기
이렇게 되면 동시 호출해도 서로 간섭않고 이전 호출이 변경한 값을 읽게된다.이 경우 volatile 키워드를 제거해야한다. 동기화를 보장하는데 굳이 메인메모리에서 값을 읽고 쓸 필요가 없기 때문이다.
java.util.concurrent.atomic 패키지의 AtomicLong 사용
위 패키지엔 락 없이도 스레드 안전한 프로그래밍을 지원한다. 이 패키지는 원자성(배타적 실행)과 스레드간 통신을 모두 지원할 수 있다. 성능도 동기화 버전보다 우수하다. AtomicLong으로 변경한 코드는 다음과 같다.
public class Volatile {
private static final AtomicLong nextSerialNumberAtomic = new AtomicLong();
public static long generateSerialNumberAtomic() {
return nextSerialNumberAtomic.getAndIncrement();
}
public static void main(String[] args) {
Thread thread = new Thread(Volatile::generateSerialNumberAtomic);
}
}
다른 예시를 통해서 확인해보자.
public class AtomicLongPractice {
private AtomicLong number = new AtomicLong();
public void up() {
String name = Thread.currentThread().getName();
for(int i=0;i<5;i++) {
long num = number.incrementAndGet();
System.out.printf("값: %d, 쓰레드: %s \n", num, name);
}
}
public static void main(String[] args) {
AtomicLongPractice atomicLongPractice = new AtomicLongPractice();
Thread t1 = new Thread(atomicLongPractice::up);
Thread t2 = new Thread(atomicLongPractice::up);
t1.start();
t2.start();
}
}
AtomicLong의 실제 값은 volatile long으로 선언되어있다. 따라서 스레드간 통신을 보장하는 것이고 원자성을 가지는 것은 내부적으로 네이티브 메소드를 통해서 해결한다. 그래서 동시에 여러 스레드가 값을 증가시켜도, 원자성을 가지고 적절한 값 증가를 수행해낼 수 있다.
synchronized 를 이해하며 발생했던 issue와 회고
1. synchronized의 commit과 refresh
synchronized 메소드나 블록이 종료될 때, 스레드에 존재하는 모든 변수의 변경사항을 commit한다. 나는 각 스레드가 가지는 변수의 값 변경을 추적하기 위해서 System.out.println()을 이용해 값을 파라미터로 던져서 출력을했다. 하지만 매번 println을 쓰기 전에는 값의 변경을 감지하지 못하다가 println만 쓰면 변경된 값을 인지했다. 코드로 보면 다음과 같다.
// non-volatile boolean a의 값은 다른 스레드에서 변경한다.
// a는 변경된 값을 감지하고 루프가 종료될 수 있다.
while(a) {
System.out.println();
}
// a는 변경된 값을 감지하지 못하고 무한루프에 빠진다.
while(a) {
}
무한루프에 빠질 상황에서 System.out.println을 사용할 때 값의 변경을 감지한 이유는 println이 내부적으로 동기화하기 때문이다.
뿐만아니라, print() 메소드도 내부적으로 synchronized하는 건 같다. 따라서 PrintStream의 메소드를 이용해서 출력할 경우 동기화가 무조건 진행된다는 것이다. 나는 처음에 동기화 메소드를 사용하는 객체나, 동기화 블록에서 사용되는 객체에 대해서만 동기화를 진행하고 값을 업데이트 하는 줄 알았다.
스레드가 동기화된 블록이나 메소드에 들어가면 스레드에 표시되는 모든 변수의 값을 새로 고친다. 즉, 위에서 설명한 스레드간 통신 기능 제공을 수행하게된다. 스레드가 동기화된 블록을 벗어날 때 스레드에 보이는 모든 변수 변경 사항은 Main Memory에 commit됨은 물론이고 변경된 값을 인지한다는 것이다. 이는 volatile과 유사하다. non-volatile boolean 변수 a가 외부스레드에서 변경되고 현재 실행중인 스레드가 동기화를 진행한다면 변경된 값을 가져온다는 것이다.
따라서 println을 실행하는 스레드는 PrintStream에 접근할 때 lock을 걸기 위해서고, 현재 스레드에서 외부 변수의 값들은 전부 새로고쳐지기 때문에 변경된 값을 가져올 수 있다. 하지만 synchronized를 사용하는 객체를 의존하면 안된다고 생각하기에, 단순히 내가 결과값을 출력으로 확인하려는 우연이 아닌 객체에 필요하다면 synchronized를 붙혀야한다.
다시말하자면, 하나만 기억하자.
스레드가 동기화된 블록에 들어가면 스레드에 표시되는 모든 변수가 메인 메모리에서 새로 고쳐지고, 스레드가 동기화된 블록을 벗어날 때 스레드에 보이는 모든 변수는 메인 메모리에 다시 기록한다.
2. 중첩 synchronized
PrintStream 클래스를 예시로 들면 println(Object o), 즉 파라미터를 보내는 모든 println 메소드는 synchronized 블록을 사용한다. 그리고 개행을 위해서 부른 newLine() 메소드도 synchronized 블록을 사용한다. 처음에는 synchronized를 중첩으로 사용하면, 이미 락이 걸려있는 객체에 다시 접근하려 하는 상황이니 deadlock이 걸릴것이라고 생각했다. newLine 메소드와 println(Object o) 메소드 둘다 자신을 lock을 하려고하기 때문에 자원을 사용하기 위해서 무한히 대기할 것이라고 말이다.
하지만 synchronized 메소드나 블록에서 lock을 거는 건, 같은 스레드에서는 상관이 없다. 즉, 내부적으로 중첩해서 synchronized를 호출해도 사용해도 상관 없기 때문이다. 이건 내가 멍청해서 실수한 것 같다.
정리
- 가능한 불변 데이터만 공유하거나 아무것도 공유하지 말자.
- 가능한 불변 데이터만 공유하거나 아무것도 하지 말고, 가변데이터는 가능한 단일 스레드에서만 쓰자. 그러기로 했다면 문서로 남겨 유지보수 과정에도 정책이 지켜지도록 하자.
- 사용하려는 프레임워크와 라이브러리를 깊이있게 이해하자. 외부 코드가 인지하지 못한 스레드를 수행하는 복병이될 수 있다.
- 한 스레드가 데이터를 다 수정 후 다른 스레드에 공유할 땐 해당 객체에서 공유하는 부분만 동기화해도 된다.(동기화 블록) 그러면 그 객체를 다시 수정할 일이 생기기 전까지 다른 스레드들은 동기화 없이 자유롭게 값을 읽을 수 있다.
- 다른 스레드에 이런 객체를 건네는 행위를 안전 발행(safe publication)이라고 한다.
- 객체를 안전 발행 하는 방법은 클래스 초기화 과정에서 객체를 정적 필드, volatile 필드, final 필드, 혹은 보통의 락을 통해 접근하는 필드에 저장해도 된다.
여러 스레드가 가변 데이터를 공유한다면 그 데이터를 읽고 쓰는 동작은 반드시 동기화하자.
한 스레드가 수행한 변경을 다른 스레드가 못보거나, 가변 데이터 동기화 실패시 응답 불가 상태나 안전 실패로 이어지는데 이는 간헐적이거나 특정한 타이밍에 발생할 수 있고, VM에 따라 현상이 달라지기에 디버깅 난이도가 가장 높다.
배타적 실행은 필요 없고 스레드끼리 통신만 필요하면 volatile 한정자 만으로 동기화할 수 있지만 올바르게 사용하긴 힘들다.
참고:
http://tutorials.jenkov.com/java-concurrency/volatile.html
http://tutorials.jenkov.com/java-concurrency/java-memory-model.html
http://tutorials.jenkov.com/java-concurrency/synchronized.html
http://tutorials.jenkov.com/java-concurrency/java-happens-before-guarantee.html
* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.