[Java] 쓰레드 - volatile
싱글 코어 프로세서가 장착된 컴퓨터에서는 다음 예제가 아무런 문제없이 실행될 것이다.
class ThreadEx1 {
public static void main(String[] args) {
RunImplEx1 r1 = new RunImplEx1();
RunImplEx1 r2 = new RunImplEx1();
RunImplEx1 r3 = new RunImplEx1();
Thread th1 = new Thread(r1. "*");
Thread th2 = new Thread(r1. "**");
Thread th3 = new Thread(r1. "***");
th1.start();
th2.start();
th3.start();
try {
Thread.sleep(2000);
r1.suspend(); // th1.suspend() 가 아님에 주의
Thread.sleep(2000);
r2.suspend();
Thread.sleep(3000);
r1.resume();
Thread.sleep(3000);
r1.stop();
r2.stop();
Thread.sleep(2000);
r3.stop();
} catch(InterruptedException e) { }
}
}
class RunImplEx1 implements Runnable {
boolean suspended = false;
boolean stopped = false;
public void run() {
while(!stoped) {
if(!suspended) {
System.out.println(Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch(InterruptedException e) {}
}
}
System.out.println(Thread.currentThread().getName() + " - stopped");
}
public void suspend() { suspended = true; }
public void resume() { suspended = false; }
public void stop() { stopped = true; }
}
그러나 요즘엔 대부분 멀티 코어 프로세서가 장착된 컴퓨터를 사용하기 때문에 이 예제에서 문제가 발생할 가능성이 있다. 그 이유는 멀티 코어 프로세서에서는 코어마다 별도의 캐시를 가지고 있기 때문이다.
코어는 메모리에서 읽어온 값을 캐시에 저장하고 캐시에서 값을 읽어서 작업한다. 다시 같은 값을 읽어올 때는 먼저 캐시에 있는지 확인하고 없을 때만 메모리에서 읽어온다.
갱신되지 않아서 메모리에 저장된 값이 다른 경우가 발생한다. 그래서 변수 stopped의 값이 바뀌었는데도 쓰레드가 멈추지 않고 계속 실행되는 것이다.
그러나 다음과 같이 변수 앞에 volatile을 붙이면, 코어가 변수의 값을 일겅올 때 캐시가 아닌 메모리에서 읽어오기 때문에 캐시와 메모리간의 값의 불일치가 해결된다.
// 변경 전
boolean suspended = false;
boolean stopped = false;
// 변경후
volatile boolean suspended = false;
volatile boolean stopped = false;
변수에 volatile을 붙이는 대신에 wynchronized 블럭을 사용해도 같은 효과를 얻을 수 있다. 쓰레드가 synchronized 블럭으로 들어갈 때와 나올 때, 캐시와 메모리간의 동기화가 이루어지기 때문에 값의 불일치가 해소되기 때문이다.
// 변경 전
public void stop() {
stopped = true;
}
// 변경 후
public synchronized void stop() {
stopped = true;
}
volatile로 long과 double을 원자화
JVM은 데이터를 4byte 단위로 처리하기 때문에, int와 int보다 작은 타입들은 한 번에 읽거나 쓰는 것이 가능하다. 즉, 단 하나의 명령어로 읽거나 쓰기가 가능하다는 뜻이다. 하나의 명령어는 더 이상 나눌 수 없는 최소의 작없단위이므로, 작업의 중간에 다른 쓰레드가 끼어들 틈이 없다.
그러나 크기가 8byte인 long과 double타입의 변수는 하나의 명령어로 값을 읽거나 쓸 수 없기 때문에, 변수의 값을 읽는 과정에 다른 쓰레드가 끼어들 여지가 있다. 다른 쓰레드가 끼어들지 못하게 하려고 변수를 읽고 쓰는 모든 문장을 synchronized 블럭으로 감쌀수도 있지만, 더 간단한 방법이 있다. 변수를 선언할 때 volatile을 붙이는 것이다.
|참고| 상수에는 volatile을 붙일 수 없다. 즉, 변수에 final과 volatile을 같이 붙일 수 없다. 사실 상수는 변하지 않는 값이므로 멀티쓰레드에 안전하다. 그래서 volatile을 붙일 필요가 없다.
volatile long sharedVal; // long 타입의 변수를 원자화
volatile double sharedVal; // double 타입의 변수를 원자화
volatile은 해당 변수에 대한 읽거나 쓰기가 원자화된다. 원자화라는 것은 작업을 더 이상 나눌 수 없게 한다는 의미인데, synchronized 블럭도 일종의 원자화라고 할 수 있다. 즉, synchronized 블럭은 여러 문장을 원자화함으로써 쓰레드의 동기화를 구현한 것이라고 보면 된다.
volatile은 변수의 읽거나 쓰기를 원자화 할 뿐, 동기화하는 것은 아니라는 점에 주의하자. 동기화가 필요할 때 synchronized 블럭 대신 volatile을 쓸 수 없다.
예를 들어 아래와 같은 코드가 있을 때,
volatile long balance; // 인스턴스 변수 balance를 원자화 한다.
synchronized int getBalance() {
return balance;
}
synchronized void withdraw(int money) {
if(balance >= money) {
balance -= money;
}
}
인스턴스 변수 balance를 volatile로 원자화 했으니까, 이 값을 읽어서 반환하는 메서드 getBalance()를 동기화할 필요가 없다고 생각할 수 있다. 그러나 getBalance()를 synchronized로 동기화하지 않으면, withdraw()가 호출되어 객체에 lock을 걸고 작업을 수행하는 중인데도 getBalance()가 호출되는 것이 가능해진다. 출금이 진행중일 때는 기다렸다가 출금이 끝난 후에 잔고를 조회할 수 있도록 하려면 getBalance()에 synchronized를 붙여서 동기화를 해야 한다.