03. 객체 공유
3.1 가시성
가시성은 그다지 직관적으로 이해할 수 있는 문제가 아니기 때문에 흔히 무시하고 넘어가는 경우가 많다. 동기화 기능을 지정하지 않으면 컴파일러나 프로세서, jvm 등이 프로그램 코드가 실행되는 순서를 임의로 바꿔 실행하는 이상한 경우가 발생하기도 한다. 다시 말하자면, 동기화 되지 않은 상황에서 메모리상의 변수를 대상으로 작성해둔 코드가 반드시 이런 순서로 동작할 것이다 라고 단정지을 수 없다.
3.1.1 스테일 데이터
@NotThreadSafe
public class MutableInteger {
private int value;
public int get() { return value; }
public void set(int value) { this.value = value; }
}
동기화되지 않은 상태로 정수 값을 보관하는 클래스
@ThreadSafe
public class SynchronizedInteger {
@GuardedBy("this") private int value;
public synchronized int get() { return value; }
public synchronized void set(int value) { this.value = value; }
}
동기화된 상태로 정수 값을 보관하는 클래스
3.1.2 단일하지 않은 64비트 연산
동기화되지 않은 상태에서 특정 스레드가 변수의 값을 읽으려 한다면 스테일 상태의 값을 읽어갈 가능성이 있긴 하지만, 그래도 전혀 엉뚱한 값을 가져가는 것이 아니라 바로 이전에 다른 스레드에서 설정한 값을 가져가게 된다. 말하자면 ‘전혀 난데 없는 값이 생기지는 않는다’는 정도로 생각할 수 있겠다.
하지만 64비트를 사용하는 숫자형에 volatile 키워드를 사용하지 않는 경우에는 난데없는 값마저 생길 가능성이 있다. 자바 메모리 모델은 메모리에서 값을 가져오고(fetch) 저장(store)하는 연산이 단일해야 한다고 정의하고 있지만, volatile로 지정되지 않은 long이나 double형의 64비트 값에 대해서는 메모리에 쓰거나 읽을 때 두 번의 32비트 연산을 사용할 수 있도록 허용하고 있다. 따라서 volatile을 지정하지 않은 long 변수의 값을 쓰는 기능과 읽는 기능이 서로 다른 스레드에서 동작한다면, 이전 값과 최신 값에서 각각 32비트를 읽어올 가능성이 생긴다.
3.1.3 락과 가시성
락은 상호 배제(mutual exclusion) 뿐만 아니라 정상적인 메모리 가시성을 확보하기 위해서도 사용한다. 변경 가능하면서 여러 스레드가 공유해 사용하는 변수를 각 스레드에서 각자 최신의 정상적인 값으로 활용하려면 동일한 락을 사용해 모두 동기화 시켜야 한다.
3.1.4 volatile 변수
자바 언어에서는 volatile 변수로 약간 다른 형태의 좀더 약한 동기화 기능을 제공하는데 ,다시 말해 volatile로 선언된 변수의 값을 바꿨을 떄 다른 스레드에서 항상 최신 값을 읽어갈 수 있도록 해준다. 특정 변수를 선언할 떄 volatile 키워드를 지정하면, 컴파일러와 런타임 모두 ‘이 변수는 공유해 사용하고, 따라서 실행 순서를 재배치 해서는 안 된다’고 이해한다. volatile로 지정된 변수는 프로세서의 레지스터에 캐시 되지도 않고, 프로세서 외부의 캐시에도 들어가지 않기 때문에 volatile 변수의 값을 읽으면 항상 다른 스레드가 보관해둔 최신의 값을 읽어갈 수 있다.
실제로 volatile
변수가 갖는 가시성 효과는 volatile
로 지정된 변수 자체의 값에 대한 범위보다 약간 확장되어 있다. 스레드 A가 volatile
변수에 값을 써넣고 스레드 B가 해당 변수의 값을 읽어 사용한다고 할 떄, 스레드 B가 volatile
변수의 값을 읽고 나면 스레드 A가 변수에 값을 쓰기전에 볼 수 있었던 모든 변수의 값을 스레드 B도 모두 볼 수 있다는 점이다. 따라서 메모리 가시성의 입장에서 본다면 volatile
변수를 사용하는 것과 synchronized
키워드로 특정 코드를 묶는 게 비슷한 효과를 가져오고, volatile
변수의 값을 읽고나면 synchronized
키워드로 특정 코드를 묶는 게 비슷한 효과를 가져오고, volatile
변수의 값을 읽고 나면 synchronized
블록에 진입하는 것과 비슷한 상태에 해당한다.
동기화하고자 하는 부분을 명확하게 볼 수 있고, 구현하기가 훨씬 간단한 경우에만 volatile 변수를 활용하자. 반대로 작은 부분이라도 가시성을 추론해봐야 하는 경우에는 volatile 변수를 사용하지 않는 것이 좋다. volatile 변수를 사용하는 적절한 경우는, 일반적으로 변수에 보관된 클래스의 상태에 대한 가시성을 확보하거나 중요한 이벤트가 발생했다는 등의 정보를 정확하게 전달하고자 하는 경우 등이 해당된다.
volatile boolean asleep;
...
while (!asleep)
countSomeSheep();
양 마리 수 세기
보다시피 volatile 변수는 굉장히 간편하게 사용할 수 있는 반면 제약 사항도 있다. 락을 사용하면 가시성과 연산의 단일성을 모두 보장받을 수 있다. 하지만 volatile 변수는 연산의 단일성은 보장하지 못하고 가시성만 보장한다.
정리하자면, volatile 변수는 다음과 같은 상황에서만 사용하는 것이 좋다.
- 변수에 값을 저장하는 작업이 해당 변수의 현재 값과 관련이 없거나 해당 변수의 값을 변경하는 스레드가 하나만 존재
- 해당 변수가 객체의 불변조건을 이루는 다른 변수와 달리 불변조건에 관련되어 있지 않다.
- 해당 변수를 사용하는 동안에는 어떤 경우라도 락을 걸어 둘 필요가 없는 경우
3.2 공개와 유출
public static Set<Secret> knownSecrets;
public void initialize() {
knownSecrets = new HashSet<Secret>();
}
객체 공개
public static 변수에 객체를 설정하면 가장 직접적인 방법으로 해당 객체를 모든 클래스와 모든 스레드에서 변수를 사용 할 수 있도록 공개하는 셈이다. initialize 메소드는 HashSet 클래스의 인스턴스를 생성해 public static으로 지정된 knownSecrets 변수에 저장하고, knownSecrets 변수에 저장된 HashSet 객체는 스코프에 관계없이 완전히 공개된다.
3.2.1 생성 메소드 안전성
3.3 스레드 한정
3.3.3 ThreadLocal
스레드 내부의 값과 값을 갖고 있는 객체를 연결해 스레드 한정 기법을 적용할 수 있도록 도와주는 좀더 형식적인 방법으로 ThreadLocal
이 있따. ThreadLocal
클래스에는 get과 set 메소드가 있는데 호출하는 스레드마다 다른 값을 사용할 수 있도록 관리해준다. 다시 말해 ThreadLocal
클래스의 get 메솓를 호출하면 현재 실행 중인 스레드에서 최근 set 메소드를 호출해 저장했던 값을 가져올 수 있다.
JDBC 연결은 스레드에 안전하지 않기 떄문에 멀티스레드 애플리케이션에서 적절한 동기화 없이 연결 객체를 전역 변수로 만들어 사용하면 애플리케이션 역시 스레드에 안전하지 않다.
private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>() {
public Connection initialValue() {
return DriverManager.getConnection(DB_URL);
}
};
public static Connection getConnection() {
return connectionHolder.get();
}
ConnectionHolder
이처럼 JDBC 연결을 보관할 때 ThreadLocal을 사용하면 스레드는 저마다 각자의 연결 객체를 갖게 된다. 특정 스레드가 ThreadLocal.get 메소드를 첨으로 호출하면 initialValue 메소드에서 값을 만들어 해당 스레드에게 초기 값으로 넘겨준다.
만약 원래 단일 스레드에서 동작하던 기능을 멀티스레드 환경으로 구성해야 할 떄, 그 의미에 따라 다르지만 공유된 전역 변수를 ThreadLocal을 활용하도록 변경하면 스레드 안전성을 보장할 수 있다. 단일 스레드 애플리케이션에서 프로그램 전체를 대상으로 사용하던 캐시를 멀티스레드 애플리케이션에서는 여러 개의 스레드별 캐시로 나눠 사용하는 편이 더 효과적일 것이다.
3.4 불변성
직접적으로 객체를 동기화 하지 않고도 안전하게 사용할 수 있는 방법 가운데 마지막으로 알아볼 내용은 바로 불변 객체이다. 만약 객체의 상태가 변하지 않는다고 가정하면 어떨까? 지금까지 발생했던 복잡하고도 다양한 문제가 일순간에 사라진다.
3.5.6 객체를 안전하게 공유하기
언제든 객체에 대한 참조를 가져다 사용하는 부분이 있다면, 그 객체로 어느 정도의 일을 할 수 있는지를 정확하게 알고 있어야 한다. 객체를 사용하기 전에 동기화 코드를 적용해 락을 확보해야 하는지? 객체 내부의 값을 바꿔도 괜찮은지, 아니면 값을 읽기만 해야 하는 것인지? 대부분의 동기화 오류는 이와 같이 일반적인 몇 가지 수칙을 이해하지 못하고 프로그램을 작성하는 데서 싹트기 시작한다. 또한, 반대로 객체를 외부에서 사용할 수 있도록 공개할 떄에는 해당 객체를 어떤 방법으로 사용할 수 있고, 사용해야 하는지에 대해서 정확하게 설명해야 한다.
여러 스레드를 동시에 사용하는 병렬 프로그램에서 객체를 공유해 사용하고자 할 때 가장 많이 사용되는 몇 가지 원칙을 살펴보면 다음과 같다.
스레드 한정
: 스레드에 한정된 객체는 완전하게 해당 스레드 내부에 존재하면서 그 스레드에서만 호출해 사용할 수 있다.
읽기 전용 객체를 공유
: 읽기 전용 객체를 공유해 사용한다면 동기화 작업을 하지 않더라도 여러 스레드에서 언제든지 마음껏 값을 읽어 사용할 수 있다. 물론 읽기 전용이기 때문에 값이 변경될 수는 없다. 불변 객체와 결과적으로 불변인 객체가 읽기 전용 객체에 해당한다고 볼 수 있다.
스레드에 안전한 객체를 공유
: 스레드에 안전한 객체는 객체 내부적으로 필수적인 동기화 기능이 만들어져 있기 때문에 외부에서 동기화를 신경 쓸 필요가 없고, 여러 스레드에서 마음껏 호출해 사용할 수 있다.
동기화 방법을 적용
: 특정 객체에 동기화 방법을 적용해두면 지정한 락을 획득하기 전에는 해당 객체를 사용할 수 없다. 스레드에 안전한 객체 내부에서 사용하는 객체나 공개된 객체 가운데 특정 락을 확보해야 사용할 수 있도록 막혀 있는 객체 등에 동기화 방법이 적용되어 있다고 볼 수 있다.
출처 :브라이언 괴츠, 더그 리, 팀 피얼스, 조셉 보우비어, 데이빗 홈즈, 조슈아 블로쉬 공저, 『 멀티코어를 100% 활용하는 자바 병렬 프로그래밍』, 에이콘(2008.7.30), 3장 인용.