오늘은 어디로 갈까...

volatile 에 대한 단상(斷想) 본문

낙서

volatile 에 대한 단상(斷想)

剛宇 2009. 3. 3. 12:14
 비가 온다.
 무심히 흘러내리는 저 빗방울들을 보면 인생의 덧없음이 느껴진다...
 덧없음 하면 생각나는 단어는 volatile. 오늘은 비도오고 그러니 간단히 volatile 키워드에 알아보자.

volatile 사전적으로는
1. 휘발성의, 2. 변덕스러운, 3. 덧없는, 순간적인
등의 의미를 가지고 있다.
아마 자바에서는 변덕스러운~ 뜻으로 쓰이는게 아닌가 싶다.
이 놈은 변덕쟁이라서 주의깊게 관찰하라~~ 뭐 이런뜻으로...(아님 말구.)
단일 쓰레드 환경에서, 혼자서 푹푹~ 찔러도 변덕을 잘 안부리지만,
멀티 쓰레드 환경에서, 여러명이 동시에 푹푹~ 찌르면 변덕을 부리는 놈이 있어서 만들어진(?) 키워드이다.
한마디로 말하자면 동기화를 해준다는것이다.
동기화 하면 생각나는것이synchronized 키워드일텐데, 이놈과 다른점이 뭘까?
synchronized는 행위 그 자체에 대한 동기화이고, volatile는 그 행위의 목표가되는 대상에 대한 동기화이다.


10년전만해도 자바의 기본형은 모두 쓰레드에 안전한 줄 알고 있었다. 하지만 그렇지 않았다.
long 이나 double형 같은 경우는 예상치 못한 결과를 얻을 수도 있다.
믿기지 않는다면 한번 돌려보자.
아래 코드는 두 쓰레드에서 하나의 long 변수에 동시에 값을 할당하는 코드이다.
package test.thread;

public class VolatileTest1 implements Runnable {

	private static long[] longArray = {0x000000A00000000Dl, 0x0000000B000000C0l};
	
	private long longValue;
	
	
	public void run() {
		int i = 0;
		int length = longArray.length;
		while (true) {
			longValue = longArray[i % length];
			i = (i < length) ? i + 1 : 0;
			
			checkValue(longValue);

		}
	}
	
	private void checkValue(long l) {
		for (int i = 0; i < longArray.length; i++) {
			if (l == longArray[i]) {
				return;
			}
		}
		System.out.println("값이 다릅니다. " + Long.toHexString(l));
		System.exit(-1);
	}
	
	public static void main(String[] args) {
		VolatileTest1 test = new VolatileTest1();
		Thread t1 = new Thread(test);
		Thread t2 = new Thread(test);
		t1.start();
		t2.start();
	}
}


0xA00000000D의 값과 0xB000000C0의 값만들 할당하기 때문에 결코 다른 값이 나오면 안되는것인데,
다행히도(?) 0xA0000000C0 또는 0xB0000000D 라는 엉뚱한 값이 나온다.
그 이유는 long이 변덕쟁이라서 그렇다. --;
그러면 변덕쟁이라는 별명을 달아주자. 즉, volatile 키워드를 long 앞에 붙여주자.
	private volatile long longValue;

아무것도 안나온다. 무한루프라서 강제 종료할때까지 계속 돌것이다. 그러니 사뿐히 죽여주자.

왜 이러한 결과가 나오는것일까?
현재 필자의 개발환경은 32비트 환경이다. 그래서 한번에 처리할 수 있는게 32비트이다.
그런데, long이나 dobule형은 64비트이라서 값을 할당할려면 두 번 작업을 해야하는것이다.
즉, 64비트의 앞부분 32비트에 값을 할당하고, 뒷부분 32비트에 값을 할당하는 작업이 되는것이다.
여기서 문제가 발생한다.
첫번째 쓰레드가 앞부분의 32비트에 값을 할당하고, 두번째 쓰레드가 뒷부분 32비트에 값을 할당해 버리면 전해 새로운 값이 나오게 되는것이다.
0x000000A0 0000000D
0x0000000B 000000C0
우리가 처음에 할당하려고 했던 이 두값이 앞뒤가 섞여서,
0x000000A0 000000C0
0x0000000B 0000000D
이라는 새로운 값이 나오게 되는것이다.
그래서 동기화가 필요한 곳에서는 volatile 키워드를 사용해서 한 쓰레드가 값을 완전히 할당하기전까지는 다른 쓰레드가 값을 할당하지 못하도록 해야하는것이다.

그러면 volatile 키워드만 사용하면 만사OK일까?
아래 코드를 실행해보자.
package test.thread;

public class VolatileTest2 implements Runnable {

	private long longValue;
	
	public void run() {
		for (int i = 0; i < 1000000; i++) {
			add(i);
		}
	}
	
	public void add(int i) {
		longValue += i;
	}
	
	
	public static void main(String[] args) throws Exception {
		long start = System.currentTimeMillis();
		VolatileTest2 test = new VolatileTest2();
		Thread t1 = new Thread(test);
		t1.start();
		t1.join();
		System.out.println(test.longValue);
		System.out.println((System.currentTimeMillis() - start) + "ms");
	}
}

* 출력 결과
499999500000
0ms

그렇다면 쓰레드 2개로 돌리면 어떻게 될까? 한개 돌렸을때 499999500000란 값이 나왔으니 499999500000 * 2 즉 999999000000란 결과가 나오면 될것이다. 쓰레드를 하나 추가해서 실행해보자.
package test.thread;

public class VolatileTest2 implements Runnable {

	private long longValue;
	
	public void run() {
		for (int i = 0; i < 1000000; i++) {
			add(i);
		}
	}
	
	public void add(int i) {
		longValue += i;
	}
	
	
	public static void main(String[] args) throws Exception {
		long start = System.currentTimeMillis();
		VolatileTest2 test = new VolatileTest2();
		Thread t1 = new Thread(test);
		Thread t2 = new Thread(test);
		t1.start();
		t2.start();
		t1.join();
		t2.join();
		System.out.println(test.longValue);
		System.out.println((System.currentTimeMillis() - start) + "ms");
	}
}

아래처럼 결과가 나올것이다. 903215892705
15ms

아마 실행할때 마다 다른 결과 값이 나올것이다. 운이 좋다면 원하던 999999000000 이란 값이 나올수도 있다.
long 앞에 volatile 이란 키워드를 안 붙여서 그런것일까? 그럼 한번 붙여볼까?
	private volatile long longValue;

그래도 결과는 마찬가지일거다. (오히려 숫자가 적어진 느낌이.) 이 처럼 원하는 결과가 안나오는 이유는 아래 메소드 때문이다.
	public void add(int i) {
		longValue += i;
	}

longValue += i; 는 즉, longValue = longValue + i; 이런 의미이다. volatile 키워드를 붙였기때문에, 값을 할당하는데는 동기화가 되어있지만, 값을 더해서 할당하는 행위 그 자체에 대한 동기화는 안된것이다. 예를 들어 longValue가 2이고 i가 1이라고 가정할때, 두 쓰레드에서 동시에 처리가 일어나만 longValue에는 2+1의 값만이 저장된다는 것이다. 즉, 2+1 다음 2+1=1이 일어나는게 아니라 3의 값만이 남아있다는것이다. 이 문제점을 해결하라면 행위자체에 동기화를 걸어주면 된다. 아래처럼 메소드 앞에 synchronized 키워드를 추가해보자.
	public synchronized void add(int i) {
		longValue += i;
	}
* 출력 결과
999999000000
4781ms

출력결과를 보면 원하던 999999000000란 결과를 얻을 수 있다.
위 코드에서 volatile 키워드를 제거하면 어떤 결과가 나올까?
답을 얘기하자면 999999000000란 동일한 결과를 얻을 수 있다.
현재 코드에서 동기화가 필요한 곳이 add(int) 메소드 밖에 없기에 가능한것이다.
이처럼 동기화가 필요한곳에 volatile, synchronized 키워드를 적절히 사용하면 된다.
...
...
...

이렇게 간단하면 얼마나 좋을까.. 예리한분들을 알아차렸겠지만, synchronized 키워드를 사용한후, 처리 속도가 몇배로 늘었다는것을 알 수 있을것이다.
즉, 동기화는 속도 잡아먹는 귀신이다. 멀티 쓰레드 환경을 한번에 많은 것을 처리할려고 구축한것인데, 동기화를 사용해서 속도가 떨어지면... 슬픈일이다. 그래서 여러가지 기술(?)이 발전하게되었는데, 오늘은 살짝 맛만 보자.  다름아닌 atomic 시리즈~. 자바 1.5부터 java.util.concurrent 란 패키지로 추가되었는데 쓰레에 안전하게 처리할 수 있는 여러가지를 제공해주고 있다. 즉, 이것을 사용하면 동기화를하지 않아도 원자성을 보장할 수 있다. 코드를 아래처럼 바꿔보자
package test.thread;

import java.util.concurrent.atomic.AtomicLong;

public class VolatileTest3 implements Runnable {

	private AtomicLong longValue = new AtomicLong(0);
	
	public void run() {
		for (int i = 0; i < 1000000; i++) {
			add(i);
		}
	}
	
	public void add(int i) {
		longValue.addAndGet(i);
	}
	
	public static void main(String[] args) throws Exception {
		long start = System.currentTimeMillis();
		VolatileTest3 test = new VolatileTest3();
		Thread t1 = new Thread(test);
		Thread t2 = new Thread(test);
		t1.start();
		t2.start();
		t1.join();
		t2.join();
		System.out.println(test.longValue);
		System.out.println((System.currentTimeMillis() - start) + "ms");
	}
}
*출력 결과
999999000000
125ms

long 대신 AtomicLong 을 사용했다. 그리고 add(int) 메소드에서 동기화를 제거하였다.  실행해보면, 원하는 999999000000
이라는 값을 출력해줄것이다. 그리고 속도도 동기화 할때보다 훨씬 빨라졌다. 아.. 이 얼마나 행복한가. 속도와 안전성을 동시에 잡다니...