Thread Safety란 멀티 쓰레드로 동작하는 프로그램에서 공유되는 자원이 존재할 때 해당 자원이 개발자의 의도대로 동작할 것임을 보장한다는 의미이다.
멀티 쓰레드로 동작하는 방식으로 인해 개발자의 의도대로 공유 자원의 데이터 값이 보장되지 않는 경우를 Thread Unsafety라 한다. 코드를 통해 살펴보도록 한다. 아래의 코드는 CommonCalculate이라는 객체로 내부 인스턴스 변수인 amount를 지니고 plus 메소드를 통해 인스턴스 변수인 amount의 값을 변경할 수 있게 하였다.
public class CommonCalculate {
private int amount;
public CommonCalculate() {
amount = 0;
}
public void plus(int value) {
amount += value;
}
public int getAmount() {
return amount;
}
}
다음은 CommonCalculate 객체의 amount 인스턴스 변수의 값을 변경시키는 Thread를 구현한다. 쓰레드는 Runnable class를 구현(implement)하거나 Thread class를 상속받아 구현할 수 있다. 이 중 후자의 방식을 채택하여 코드를 작성하였다.
public class ModifyAmountThread extends Thread {
private CommonCalculate calc;
private boolean addFlag;
public ModifyAmountThread(CommonCalculate calc, boolean addFlag) {
this.calc = calc;
this.addFlag = addFlag;
}
public void run() {
for(int loop=0; loop<10000; loop++) {
if(addFlag) {
calc.plus(1);
}
}
}
}
다음으로는 ModifyAmountThread를 실행시켜줄 main 함수가 포함된 class를 생성한다. RunSync class는 2개의 ModifyAmountThread를 실행시키고 있다. 이때 마지막 줄의 Final value를 출력하는 문장에서 기대되는 값은 20000이다. addFlag가 true이므로 각 Thread마다 10000번의 plus method가 호출되어 CommonCalculate 객체의 amount 인스턴스 변수가 10000+10000로 변경되어야 하기 때문이다.
public class RunSync {
public static void main(String[] args) {
RunSync runSync = new RunSync();
runSync.runCommonCalculate();
}
public void runCommonCalculate() {
CommonCalculate calc = new CommonCalculate();
ModifyAmountThread thread1 = new ModifyAmountThread(calc, true);
ModifyAmountThread thread2 = new ModifyAmountThread(calc, true);
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
System.out.println("Final value is " + calc.getAmount());
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
}
하지만, 실행 결과 20000이 아닌 13657이란 값이 출력되었다. 그 이유는 바로 class instance 변수를 공유하며 발생한 Thread Unsafety 때문이다.
CommonCalculate class에서 plus 메소드를 살펴보면 amount += value라는 명령어를 찾을 수 있다. 해당 명령어는 amount에 amount+value의 값을 할당한다는 의미이다. 또한 해당 메소드는 각 Thread에서 모두 접근이 가능한 상태이다. 따라서 2개의 ModifyAmountThread를 생성하여 실행시키면 아래와 같은 상황이 발생한다.
thread1에서 amount의 값을 2로 바꾸기 전 thread2에서 amount의 값을 1로 읽어가버리기 때문에 3이 되었어야 할 thread2의 실행 결과가 thread1과 동일하게 2가 되어버린다. 따라서 마지막 줄에서 출력되는 값은 10000 + 10000보다 더 작은 값을 갖게 되는 것이다. 이 결과를 thread-safe하게 항상 20000이라는 값을 보장해주고자 한다면 plus 메소드에 'synchronized'만 추가해주면 된다.
public synchronized void plus(int value) {
amount += value;
}
synchronized의 역할은 무엇인가? synchronized 메소드가 되면 하나의 쓰레드에서 해당 메소드를 호출했을 때 다른 쓰레드에서는 호출할 수 없게 해주는 역할을 한다. 이렇게 되면 각 thread마다 plus 메소드를 수행한 결과가 모두 인스턴스 변수에 반영될 수 있다.
이렇듯 별도의 접근 통제가 발생하지 않는 공유 자원에 대해 멀티 쓰레드가 접근할 경우 개발자가 의도한 값을 얻을 수 있는 경우가 발생하는데 이를 Thread Unsafey라고 한다.
위의 예제에서 parameter로 주어진 지역변수인 value는 인스턴스 변수와는 달리 각 Thread별로 관리되는 것을 알 수 있다. 지역변수가 thread별로 관리된다는 것은 서로 다른 thread에 영향을 받지 않고 각 thread마다 독립적으로 관리된다, 즉 지역 변수가 Thread-safe하다는 것을 의미한다. 지역 변수가 thread-safe한 이유는 무엇일까? 앞서 조사했던 JVM의 구조 중 Runtime Data Area를 살펴보면 그 답을 찾을 수 있다.
Runtime Data Area는 다음과 같이 각 Thread별로 JVM stack을 지닌다. 메소드가 호출되면 해당 메소드 정보와 매개변수 정보는 JVM stack에 저장된다. 이때, JVM stack은 다른 쓰레드와 공유되지 않고 각 쓰레드마다 존재하고 있다. 따라서 멀티 쓰레드 환경에서도 다른 쓰레드에 영향을 받지 않아 thread-safe 할 수 있었던 것이다.
Immutable 객체 역시 thread-safe하다. Immutable 객체는 값이 변하지 않는 객체이므로 멀티 쓰레드가 접근해도 영향을 받지 않는다. 어떤 쓰레드가 접근하더라도 동일한 값을 보장하므로 개발자가 의도한 대로 작동할 수 있기 때문이다.
[참조] https://en.wikipedia.org/wiki/Thread_safety
[참조] 교재, 『자바의 神』