자바 병렬 프로그래밍(3장)

3장 객체 공유

이 장에서는 여러 개의 스레드에서 특정 객체를 동시에 사용하려 할 때, 섞이지 않게 안전하게 동작하도록 객체를 공유하고 공개하는 방법을 살펴본다.

앞에서 synchronized 키워드를 사용해 동기화시킨 블록이 단일 연산인 것처럼 동작하게 할 수 있었다. 소스 코드의 특정 블록을 동기화시키고자 할 때는 항상 메모리 가시성(memory visibility) 문제가 발생하는데 이 때문에 특정 변수의 값을 사용하고 있을 때 다른 스레드가 해당 변수의 값을 사용하지 못하도록 막아야 할 뿐만 아니라, 값을 사용한 다음 동기화 블록을 빠져나가고 나면 다른 스레드가 변경된 값을 즉시 사용할 수 있게 해야 한다는 뜻이다.

3.1 가시성

단일 스레드만 사용하는 환경이라면 특정 변수에 값을 지정하고 다음번에 해당 변수의 값을 다시 읽어보면, 이전에 저장해뒀던 바로 그 값을 가져올 수 있다. 하지만, 여러 스레드에서 앞서거니 뒤서거니 실행된다면 반드시 그렇지 않을 수도 있다. 특정 변수의 값을 가져갈 때 다른 스레드가 작성한 값을 가져갈 수 있다는 보장도 없고, 심지어는 값을 읽지 못할 수도 있다. 이 때문에 메모리상의 공유된 변수를 여러 스레드에서 서로 사용할 수 있게 하려면 반드시 동기화 기능을 구현해야 한다.

아래의 코드는 두 개의 스레드에서 변수를 공유해 사용함에도 적절한 동기화 기법을 사용하지 않았기 때문에 문제가 발생할 수 있는 예제 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class NoVisibility {
private static boolean ready;
private static int number;

private static class ReaderThread extends Thread {
public void run() {
while(!ready)
Thread.yield();
System.out.println(number);
}
}

public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}

}

NoVisibility 클래스의 소스코드를 보면, ready 변수의 값을 읽기 스레드에서 영영 읽지 못할 수도 있기 때문에 무한 반복에 빠질 수 있다. 더 이상하게는 읽기 스레드가 메인 스레드에서 number 변수에 지정한 값보다 ready 변수의 값을 먼저 읽어가는 상황도 가능하다. 흔히 재배치(reordering) 라고 하는 현상이다. 재배치 현상은 특정 메서드의 소스 코드가 100% 코딩된 순서로 동작한다는 점을 보장할 수 없다는 점에 기인하는 문제이다.

동기화 기능을 지정하지 않으면 컴파일러나 프로세서, JVM 등이 프로그램 코드가 실행되는 순서를 임의로 바꿔 실행하는 이상한 경우가 발생하기도 한다. 다시 말하면, 동기화 되지 않은 상황에서 메모리상의 변수를 대상으로 작성해둔 코드가 ‘반드시 이런 순서로 동작할 것이다’ 라고 단정지을 수 없다.

이 때문에 여러 스레드에서 공동으로 사용하는 변수에는 항상 적절한 동기화 기법을 적용해야 한다.

3.1.1 스테일 데이터

변수를 사용하는 모든 경우에 동기화를 시켜두지 않으면 해당 변수에 대한 최신 값이 아닌 다른 값을 사용핟게 되는 경우가 발생할 수 있다.(stale 데이터)
게다가 더 큰 문제는 항상 스테일 데이터를 사용하게 될 때도 있고, 정상적으로 동작하는 경우도 있다는 점이다. 이 때 해당 변수가 프로그램 로직의 안전에 심각한 문제를 유발할 수 있는 변수라면 문제가 커진다.

1
2
3
4
5
6
7
8
9
10
11
@NotThreadSafe
public class MutableInteger {
private int value;

public int get() {
return value;
}
public void set(int value) {
this.value = value;
}
}

예제 코드는 value 라는 변수의 값을 get과 set 메서드에서 동시에 사용함에도 불구하고 동기화가 되어 있지 않기 때문에 여러 스레드에서 동시에 사용하면 문제가 발생할 소지가 많다. 예를들어 특정 스레드가 set 메서드를 호출하고 다른 스레드에서 get 메서드를 호출했을 때 set 메서드에서 지정한 값을 get 메서드에서 제대로 읽어가지 못할 가능성이 있다.

1
2
3
4
5
6
7
8
9
10
11
@ThreadSafe
public class SynchronizedInteger {
@GuardedBy("this") private int value;

public synchronized int get() {
return value;
}
public synchronized void set(int value) {
this.value = value;
}
}

예제 코드는 get과 set 을 동기화 시켜서 그 전 코드의 문제를 제거했다. 만약 set 메서드만 동기화 시켰다면 어떨까? 그러면 get 메서드는 여전히 스테일 상황을 초래할 수 있기 때문에 별다른 효과가 없다.

3.1.2 단일하지 않은 64비트 연산

64비트를 사용하는 숫자형(double 이나 long) 에 volatile 를 사용하지 않은 경우에는 난데없는 값마저 생길 가능성이 있다. 자바 메모리 모델은 메모리에서 값을 가져오고 저장하는 연산이 단일해야 한다고 정의하고 있지만, volatile 로 지정되지 않은 long 이나 double 형의 64비트 값에 대해서는 메모리에 쓰거나 읽을 때 두 번의 32비트 연산을 사용할 수 있도록 허용하고 있다. 따라서 volatile 을 지정하지 않은 long 변수의 값을 쓰는 기능과 읽는 기능이 서로 다른 스레드에서 동작한다면 이전 값과 최신 값에서 각각 32비트를 읽어올 가능성이 생긴다. 따라서 volatile 을 지정하지도 않고 락을 사용해 동기화하지도 않은 상태로 long 이나 double 값을 동시에 여러 스레드에서 사용할 수 있다면 항상 이상한 문제를 만날 가능성이 있다.

3.1.3 락과 가시성

synchronized 로 둘러싸인 코드에서 스레드 A가 사용했던 모든 변수의 값은, 같은 락을 사용하는 synchronized 로 둘러싸인 코드를 스레드 B가 실행할 때 안전하게 사용할 수 있다.

값을 변경할 수 있는 변수를 여러 개의 스레드에서 동시에 사용한다면, 바로 이전 스레드에서 사용했던 변수의 값을 오류 없이 정상적으로 다음 스레드가 사용할 수 있게 하기 위해 동일한 락을 사용하는 synchronized 블록으로 막아줄 필요가 있다.

3.1.4 volatile 변수

자바 언어에서는 volatile 변수로 약간 다른 형태의 좀 더 약한 동기화 기능을 제공하는데, volatile 로 선언된 변수의 값을 바꿨을 때 다른 스레드에서 항상 최신 값을 읽어갈 수 있도록 해준다. 특정 변수를 선언할 때 volatile 키워드를 지정하면, 컴파일러와 런타임 모두 ‘이 변수는 공유해 사용하고, 따라서 실행 순서를 재배치해서는 안 된다’ 고 이해한다. volatile 로 지정된 변수는 프로세서의 레지스터에 캐시 되지도 않고, 프로세서 외부의 캐시에도 들어가지 않기 때문에 volatile 변수의 값을 읽으면 항상 다른 스레드가 보관해둔 최신의 값을 읽어갈 수 있다. 다만, volatile 변수만 사용해 메모리 가시성을 확보하도록 작성된 코드는 synchronized 로 직접 동기화한 코드보다 훨씬 읽기가 어렵고 따라서 오류가 발생할 가능성도 높다.

동기화하고자 하는 부분을 명확하게 볼 수 있고, 구현하기가 훨씬 간단한 경우에만 volatile 변수를 활용하자. volatile 변수를 사용하는 적절한 경우는, 변수에 보관된 클래스의 상태에 대한 가시성을 확보하거나 중요한 이벤트가 발생했다는 등의 정보를 정확하게 전달하고자 하는 경우 등에 해당한다.

1
2
3
4
volatile boolean asleep;
...
while (!asleep)
countSomeSheep();

예제 코드의 asleep 과 같이 작업을 완료했다거나, 인터럽트가 걸리거나, 기타 상태를 보관하는 플래그 변수에 volatile 키워드를 지정한다.

하지만 volatile 연산자의 기본적인 능력으로는 증가 연산자(count++)를 사용한 부분까지 동기화를 맞춰 주지는 않는다.
정리하면, volatile 변수는 다음과 같은 상황에서만 사용하는 것이 좋다.

  • 변수에 값을 저장하는 작업이 해당 변수의 현재 값과 관련이 없거나 해당 변수의 값을 변경하는 스레드 하나만 존재
  • 해당 변수가 객체의 불변조건을 이루는 다른 변수와 달리 불변조건에 관련되어 있지 않을 때
  • 해당 변수를 사용하는 동안에는 어떤 경우라도 락을 걸어 둘 필요가 없는 경우

3.2 공개와 유출

특정 객체를 현재 코드의 스코프 범위 밖에서 사용할 수 있도록 만들면 공개(published)되었다고 한다. 만약 클래스 내부의 상태 변수를 외부에 공개해야 한다면 객체 캡슐화 작업이 물거품이 되거나 내부 데이터의 안정성을 해칠 수 있다. 따라서 객체가 안정적이지 않은 상태에서 공개하면 스레드 안정성에 문제가 생길 수 있다. 이처럼 의도적으로 공개시키지 않았지만 외부에서 사용할 수 있게 공개된 경우를 유출 상태(escaped)라고 한다.

자바로 프로그램을 작성할 때 아래와 같이 public static 변수에 객체를 설정하면 가장 직접적인 방법으로 해당 객체를 모든 클래스와 모든 스레드에서 변수를 사용할 수 있도록 공개하는 셈이다.

1
2
3
4
5
public static Set<Secret> knownSecrets;

public void initialize() {
knownSecrets = new HashSet<Secret>();
}

public static 키워드와 비슷하게 private 이 아닌 메서드를 호출해 그 결과로 받아오는 과정으로도 객체가 공개된다.

1
2
3
4
5
6
7
8
9
class UnsafeStates {
private String[] states = new String[] {
"AK", "AL", ...
};

public String[] getStates() {
return states;
}
}

원래 private 으로 선언되어 있던 states 라는 변수가 private 이 아닌 getStates 메서드를 통해 외부에 공개될 수 있기 때문에, states 변수는 유출 상태에 놓여 있다고 볼 수 있다.

정리해보면 객체를 공개했을 때 그 객체 내부의 private 아닌 변수나 메서드를 통해 불러올 수 있는 모든 객체는 함께 공개된다는 점을 알아두자. 어떤 객체건 일단 유출되고 나면 다른 스레드가 유출된 클래스를 의도적이던 의도적이디 않건 간에 반드시 잘못 사용할 수 있다고 가정해야 한다. 객체가 유출되는 상황에서 어려운 문제점을 겪을 수도 있기 때문에 객체 내부는 캡슐화 해야 한다는 것이다.

객체나 객체 내부의 상태 값이 외부에 공개되는 또 다른 예는 아래 예제와 같이 내부 클래스의 인스턴스를 외부에 공개하는 경우다. 내부 클래스는 항상 부모 클래스에 대한 참조를 갖고 있기 때문에 ThisEscape 클래스가 EventListener 객체를 외부에 공개하면 EventListener 클래스를 포함하고 있는 ThisEscape 클래스도 외부에 함께 공개된다.

1
2
3
4
5
6
7
8
9
10
11
public class ThisEscape {
public ThisEscape(EventSource source) {
source.registerListener {
new EventListener() {
public vlod onEvent(Event e) {
doSomething(e);
}
}
}
}
}

3.2.1 생성 메서드 안정성

일반적으로 생성 메서드가 완전히 종료하고 난 이후가 되어야 객체의 상태가 개발자가 예상한 상태로 초기화되기 때문에 생성 메서드가 실행되는 도중에 해당 객체를 외부에 공개한다면 정상적이지 않은 상태의 객체를 외부에서 불러 사용할 가능성이 있다. 생성 메서드가 실행 도중에 this 변수가 외부에 공개된다면, 이론적으로 해당 객체는 정상적으로 생성되지 않았다고 말할 수 있다.

생성 메서드를 실행하는 도중에는 this 변수가 외부에 유출되지 않게 해야 한다.

생성 메서드에서 this 변수를 유출시키는 가장 흔한 오류는 생성 메서드에서 스레드를 새로 만들어 시작시키는 경우다. 생성 메서드에서 또다른 스레드를 만들어 내면 대부분의 경우에는 생성 메서드에의 클래스와 새로운 스레드가 this 변수를 직접 공유하거나 자동으로 공유되기도 한다.

예를 들어 생성 메서드에서 만든 스레드의 클래스가 원래 클래스의 내부 클래스라면 자동으로 원래 클래스의 this 변수를 공유하는 상태가 된다. 그러면 새로 만들어져 실행된 스레드에서 원래 클래스의 생성 메서드가 끝나기도 전에 원래 클래스에 정의되어 있는 여러 가지 변수를 직접 사용할 수 있게 된다. 스레드를 생성하면서 바로 시작시키기보다는 스레드를 시작시키는 기능을 start 나 initialize 등으 메서드로 만들어 사용하는 편이 좋다.

새로 작성하는 클래스의 생성 메서드에서 이벤트 리스너를 등록하거나 새로운 스레드를 시작시키려면 아래의 예제 코드와 같이 생성 메서드를 private 으로 지정하고 public 으로 지정된 팩토리 메서드를 만들어 사용하는 방법이 좋다. 아래의 코드는 생성 메서드에서 this 변수가 외부로 유출되지 않도록 팩토리 메서드를 사용하는 예제이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class SafeListener {
private final EventListener listener;

private SafeListener() {
listener = new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
};
}

public static SafeListener newInstance(EventSource source) {
SafeListener safe = new SafeListener();
source.registerListener(safe.listener);
return safe;
}

3.3 스레드 한정

특정 객체를 단일 스레드에서만 활용한다고 확신할 수 있다면 해당 객체는 따로 동기화할 필요가 없다. 이처럼 객체를 사용하는 스레드를 한정하는 방법으로 스레드 안정성을 확보할 수 있다.

예를 들어 스윙(swing)에서 스레드 한정 기법을 굉장히 많이 사용하고 있다. 스윙의 화면 컴포넌트와 데이터 모델 객체는 스레드에 안전하지 않지만, 스윙 이벤트 처리 스레드에 컴포넌트와 모델을 한정시켜 스레드 안정성을 확보하고 있다.

스레드 한정 기법을 사용하는 또 다른 사례는 바로 JDBC의 Connection 객체를 풀링해 사용하는 경우다. 일반적인 서버 애플리케이션을 보면 풀에서 DB 연결을 확보하고, 확보한 DB 연결로 요청 하나를 처리한 다음 사용한 연결을 다시 반환하는 과정을 거친다. 서블릿 요청이나 EJB 호출 등의 요청은 대부분 단일 스레드에서 동기적으로 처리하며 DB 풀은 한쪽에서 DB 연결을 하는 동안에는 해당 연결을 다른 스레드가 사용하지 못하게 막기 때문에, 공유하는 Connection 객체를 풀로 관리하면 특정 Connection 을 한 번에 하나 이상의 스레드가 사용하지 못하도록 한정할 수 있다.

언어적인 차원에서 임의의 객체를 특정 스레드에 한정시키는 기능은 제공하지 않기 때문에 스레드 한정 기법은 프로그램을 처음 설계하는 과정부터 함께 다뤄야 하며, 프로그램을 구현하는 과정 내내 한정 기법을 계속해서 적용해야 한다.

3.3.1 스레드 한정 - 주먹구구식

특정 모듈의 기능을 단일 스레드로 동작하도록 구현한다면, 언어적인 지원 없이 직접 구현한 스레드 한정 기법에서 나타날 수 있는 오류의 가능성을 최소화할 수 있다. (특정 모듈을 단일 스레드로 동작하도록 구현하면 데드락을 미연에 방지할 수 있다는 장점이 있다)

읽기와 쓰기가 모두 가능한 volatile 변수를 공유해 사용할 때에는 특정 단일 스레드에서만 쓰기 작업을 하도록 구현해야 안전하다. 이런 경우 경쟁 조건을 막기 위해 ‘변경’ 작업은 특정 스레드 한 곳에서만 할 수 있도록 제한해야 하고, 읽기 작업이 가능한 다른 모든 스레드는 volatile 변수의 특성상 가장 최근에 업데이트된 값을 정확하게 읽어갈 수 있다.

임시방편적인 스레드 한정 기법은 안정성을 완벽하게 보장할 수 있는 방법은 아니기 때문에 제한적으로 사용하고, 가능하다면 스택 한정이나 ThreadLocal 클래스 등의 좀 더 안전한 스레드 한정 기법을 사용하자

3.3.2 스택 한정

스택 한정 기법은 특정 객체를 로컬 변수를 통해서만 사용할 수 있는 특별한 경우의 스레드 한정 기법이라고 할 수 있다. 변수를 클래스 내부에 숨겨두면 변경 상태를 관리하기가 쉬운데, 또한 클래스 내부에 숨겨둔 변수는 특정 스레드에 쉽게 한정시킬 수도 있다. 로컬 변수는 모두 암묵적으로 현재 실행 중인 스레드에 한정되어 있다고 볼 수 있다. 즉, 로컬 변수는 현재 실행 중인 스레드 내부의 스택에만 존재하기 때문이며, 스레드 내부의 스택은 외부 스레드에서 물론 볼 수 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public int loadTheArk(Collection<Animal> candidates) {
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null;

animals = new TreeSet<Animal>(new SpeciesGenderComparator());
animals.addAll(candidates);
for (Animal a : animals) {
if (candidate == null || !candidate.isPotentialMate(a))
candidate = a;
} else {
ark.load(new AnimalPair(candidate, a));
++numPairs;
candidate = null;
}
}
return numPairs;
}

예제 코드의 loadTheArk 메서드 내부의 numPairs 와 같이 기본 변수형을 사용하는 로컬 변수는 일부러 하려고 해도 스택 한정 상태를 깰 수 없다. 기본 변수형은 참조 값이 아니기 때문에 기본 변수형 로컬 변수는 언어적으로 스택 한정 상태가 보장된다.

객체형 변수가 스택 한정 상태를 유지할 수 있게 하려면 해당 객체에 대한 참조가 외부로 유출되지 않도록 개발자가 주의를 기울여야 한다. 예제 코드의 loadTheArk 메서드에서 animal 변수는 TreeSet 클래스의 인스턴스를 참조하고 있다. 그러면 지금까지는 TreeSet 인스턴스에 대한 참조가 정확하게 하나만 존재하며, 또한 로컬 변수에 보관하고 있기 때문에 현재 실행 중인 스레드의 스택에 안전하게 한정되어 있다. 하지만 TreeSet 인스턴스에 대한 참조를 외부에 공개한다면 스택 한정 상태가 깨질수 밖에 없다.

스레드에 안전하지 않은 객체라 해도 특정 스레드 내부에서만 동작한다면 동기화 문제가 없기 때문에 안전하다. 하지만 해당 객체를 현재 스레드에 한정해야 한다는 요구사항과 해당 객체가 스레드에 안전하지 않다는 점은 대부분 코드를 처음 작성했던 개발자만 인식할 뿐, 후임 개발자는 전달받지 못하는 경우가 많다. 따라서 이런 점을 명확하게 정리해 누구든지 알아볼 수 있도록 표시해 두는 것이 좋다.

3.3.3 ThreadLocal

ThreadLocal 클래스에는 get 과 set 메서드가 있는데 호출하는 스레드마다 다른 값을 사용할 수 있도록 관리해준다. 다시 말해 ThreadLocal 클래스의 get 메서드를 호출하면 현재 실행 중인 스레드에서 최근에 set 메서드를 호출해 저장했던 값을 가져올 수 있다. 스레드 로컬 변수는 변경 가능한 싱글턴이나 전역 변수 등을 기반으로 설계되어 있는 구조에서 변수가 임의로 공유되는 상황을 막기 위해 사용되는 경우가 많다. 개념적으로 본다면 ThreadLocal 클래스는 Map<Thread, T> 라는 자료 구조로 구성되어 있고, Map<Thread, T>에 스레드별 값을 보관한다고 생각할 수 있겠다.

만약 원래 단일 스레드에서 동작하던 기능을 멀티스레드 환경으로 구성해야 할 때, 그 의미에 따라 다르지만 공유된 전역 변수를 ThreadLocal 을 활용하도록 변경하면 스레드 안정성을 보장할 수 있다.

ThreadLocal 클래스는 애플리케이션 프레임웍을 구현할 때 상당히 많이 사용되는 편이다. 예를들어 J2EE 컨테이너는 EJB 를 사용하는 동안 해당 스레드와 트랜잭션 컨텍스트를 연결해 관리한다. 이처럼 스레드 단위로 트랜잭션 컨텍스트를 관리하고자 할 때는 static 으로 선언된 ThreadLocal 변수에 트랜잭션 컨텍스트를 넣어두면 편리하다. 만약 프레임웍에서 현재 진행중인 트랜잭션이 어느 것인지 확인하고 싶다면 트랜잭션이 보관되어 있는 ThreadLocal 에서 쉽게 찾아낼 수 있다. 이런 방법을 사용하면 메서드를 호출할 때마다 현재 실행 중인 스레드의 정보를 넘겨줘야 할 필요는 없지만, 이런 방법을 사용하는 코드는 해당 프레임웍에 대한 의존성을 갖게 된다.

이렇게 편리하긴 하지만, 전역 변수가 아니면서도 전역 변수처럼 동작하기 때문에 프로그램 구조상 전역 변수를 남발하는 결과를 가져올 수도 있고, 따라서 메서드에 당연히 인자로 넘겨야할 값을 ThreadLocal 을 통해 뒤로 넘겨주는 방법을 사용하면서 프로그램의 구조가 허약해질 가능성도 높다.

3.4 불변성

만약 객체의 상태가 변하지 않는다고 가정하면 어떨까? 지금까지 발생했던 복잡하고도 다양한 문제가 일순간에 사라진다.

불변 객체는 맨 처음 생성되는 시점을 제외하고는 그 값이 전혀 바뀌지 않는 객체를 말한다. 다시 말해 불변 객체의 변하지 않는 값은 처음 만들어질 때 생성 메서드에서 설정하고, 상태를 바꿀 수 없기 때문에 맨 처음 설정된 값이 나중에도 바뀌지 않는다. 따라서 불변 객체는 태생부터 스레드에 안전한 상태다.

불변 객체는 언제라도 스레드에 안전하다.

불변 객체는 만들기도 쉽고 훨씬 안전하다. 객체 내부의 값을 마음대로 변경할 수 있다면, 객체를 제 3자가 만든 코드에 넘겨주거나 외부의 라이브러리가 볼 수 있는 범위에 공개되어 있다면 굉장히 위험한 상태에 이를 수도 있다. 반대로 불변 객체는 제 3자가 만들어 검증되지 않고 오류가 많거나 심지어는 악의를 갖고 만든 악성 코드가 가져다 사용한다 해도 값을 변경할 수 없다.

다음의 조건을 만족하면 해당 객체는 불변 객체다.

  1. 생성되고 난 이후에는 객체의 상태를 변경할 수 없다.
  2. 내부의 모든 변수는 final 로 설정돼야 한다.
  3. 적절한 방법으로 생성돼야 한다. (예를 들어 this 변수에 대한 참조가 외부로 유출되지 않아야 한다)

아래는 일반 객체를 사용해 불변 객체를 구성한 예제 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Immutable
public final class ThreeStooges {
private final Set<String> stooges = new HashSet<String>();

public ThreeStooges() {
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
}

public boolean isStooge(String name) {
return stooges.contains(name);
}
}

이름을 저장해두는 Set 변수는 변경 가능한 객체이지만, ThreeStooges 클래스의 구조를 보면 생성 메서드를 실행한 이후에는 Set 변수의 값을 변경할 수 없도록 되어 있다. 그리고 stooges 변수는 final 로 선언되어 있기 때문에 객체의 모든 상태는 final 변수를 통해 사용할 수 밖에 없다. 마지막으로 생성 메서드에서 this 변수에 대한 참조가 외부로 유출될 만한, 즉 호출한 클래스나 생성 메서드 이외의 부분에서 참조를 가져갈 수 있는 일을 전혀 하고 있지 않기 때문에 ThreeStooges 클래스는 불변 객체라고 볼 수 있다.

3.4.1 final 변수

final 키워드를 적절하게 사용하면 초기화 안정성을 보장하기 때문에 별다른 동기화 작업 없이도 불변 객체를 자유롭게 사용하고 공유할 수 있다.

외부에서 반드시 사용할 일이 없는 변수는 private 으로 선언하는 게 괜찮은 방법인 만큼 나중에 변경할 일이 없다고 판단되는 변수는 final 로 선언해두는 것도 좋은 방법이다.

3.4.2 예제: 불변 객체를 공개할 때 volatile 키워드 사용

여러 개의 변수 값을 서로 읽거나 쓰는 과정에 경쟁 조건이 발생할 수 있는데, 불변 객체 안에 해당하는 변수를 모두 모아두면 경쟁 조건을 방지할 수 있다. 여러 개의 변수를 묶어 사용하고자 할 때, 불변 객체가 아닌 일반 객체를 만들어 사용하면 락을 사용해야 연산의 단일성을 보장할 수 있다. 하지만 불변 객체에 변수를 묶어두면 특정 스레드가 불변 객체를 사용할 때 다른 스레드가 불변 객체 값을 변경하지 않을까 걱정하지 않아도 된다. 만약 불변 객체 내부에 들어 있는 변수 값을 변경하면 새로운 불변 객체가 만들어지기 때문에, 기존에 변수 값이 변경되기 전의 불변 객체를 사용하는 다른 스레드는 아무런 이상 없이 계속 동작한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Immutable
class OneValueCache {
private final BigInteger lastNumber;
private final BigInteger[] lastFactors;

public OneValueCache(BigInteger i, BigInteger[] factors) {
lastNumber = i;
lastFactors = Arrays.copyOf(factors, factors.length);
}

public BigInteger[] getFactors(BigInteger i) {
if (lastNumber == null || !lastNumber.equals(i))
return null;
else
return Arrays.copyOf(lastFactors, lastFactors.length);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@ThreadSafe
public class VolatileCachedFactorizer implements Servlet {
private volatile OneValueCache cache = new OneValueCache(null, null);

public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = cache.getFactors(i);
if (factors == null) {
factors = factor(i);
cache = new OneValueCache(i, factors);
}
encodeIntoResponse(resp, factors);
}
}

OneValueCache 클래스가 불변인데다 cache 변수를 사용하는 코드에서는 cache 변수를 정확하게 한 번씩만 사용하기 때문에 캐시와 관련된 연산은 전혀 혼동되거나 섞이지 않는다. VolatileCachedFactorizer 클래스는 변경할 수 없는 상태 값을 여러 개 갖고 있는 불변 객체인데다 volatile 키워드를 적용해 시간적으로 가시성을 확보하기 때문에 따로 락을 사용하지 않았다 해도 스레드에 안전하다.

3.5 안전 공개

지금까지는 객체를 특정 스레드에 한정하거나 다른 객체 내부에 넣을 때, 객체를 공개하지 않고 확실하게 숨기는 방법에 대해 살펴봤다. 하지만 상황에 따라 여러 스레드에서 공유하도록 공개해야 할 상황일 수 있는데, 이럴 때는 반드시 안전한 방법을 사용해야 한다. 아래의 예제는 안전하지 않은 공개 방법의 예시이다.

1
2
3
4
5
public Holder holder;

public void initialize() {
holder = new Holder(42);
}

예제 코드는 가시성 문제 때문에 Holder 클래스가 안정적이지 않은 상태에서 외부 스레드에게 노출될 수 있으며, 심지어는 생성 메서드에서 내부의 고정된 값을 정상적으로 설정한 이후에도 문제가 된다. 이렇게 단순한 방법으로 객체를 외부에 공개하면 생성 메서드가 채 끝나기도 전에 공개된 객체를 다른 스레드가 사용할 수 있다.

3.5.1 적절하지 않은 공개 방법: 정상적인 객체도 문제를 일으킨다

객체의 생성 메서드가 제대로 완료되지 않은 상태의 인스턴스를 다른 스레드가 사용하려 한다면 비정상적인 상태임에도 불구하고 그대로 사용하게 될 가능성이 있고, 나중에 생성 메서드가 제대로 끝나고 보니 공개한 이후에 값이 바뀐 적이 없음에도 불구하고 처음 사용할 때와는 값이 다른 경우도 생긴다.

1
2
3
4
5
6
7
8
9
10
11
public class Holder {
private int n;

public Holder(int n) {
this.n = n;
}

public void assertSannity() {
if (n != n)
throw new AssertionError("This statement is false");
}

Holder 객체를 다른 스레드가 사용할 수 있도록 코드를 작성하면서 적절한 동기화 방법을 적용하지 않았으므로 Holder 클래스는 올바르게 공개되지 않았다고 할 수 있다. 이 때 두 가지 문제가 발생할 수 있는데, 첫 번째 문제는 Holder 변수에 스테일 상태가 발생할 수 있는 것이고, 두 번째 문제는 다른 스레드는 모두 Holder 변수에서 정상적인 참조 값을 가져갈 수 있지만 Holder 클래스의 입장에서는 스테일 상태에 빠질 수 있다는 것이다.

다시 한 번 강조하지만, 특정 데이터를 여러 개의 스레드에서 사용하도록 공유할 때 적절한 동기화 방법을 적용하지 않는다면 굉장히 이상한 일이 발생할 가능성이 높다는 점을 알아두자

3.5.2 불변 객체와 초기화 안전성

데이터를 여러 스레드가 공유하는 환경에서는 불변 객체가 굉장히 중요한 위치를 차지하기 때문에, 자바 메모리 모델에는 불변 객체를 공유하고자 할 때 초기화 작업을 안전하게 처리할 수 있는 방법이 만들어져 있다. 특정 객체에 대한 참조를 클래스 외부에서 볼 수 있다 해도 외부 스레드 입장에서 항상 정상적인 참조 값을 사용한다는 보장이 없다. 외부 스레드에서 항상 정상적인 값을 참조하려면 동기화 방법이 필요하다.

반면에 불변 객체를 사용하면 객체의 참조를 외부에 공개할 때 추가적인 동기화 방법을 사용하지 않았다 해도 항상 안전하게 올바른 참조 값을 사용할 수 있다. 이와 같이 안전하게 초기화 과정을 진행하려면 몇 가지 불변 객체의 요구 조건을 만족시켜야 하는데, 요구 조건으로는 1) 상태를 변경할 수 없어야 하고 2) 모든 필드의 값이 final 로 선언돼야 하며, 3) 적절한 방법으로 생성해야 한다.

불변 객체는 별다른 동기화 방법을 적용하지 않았다 해도 어느 스레드에서건 마음껏 안전하게 사용할 수 있다. 불변 객체를 공개하는 부분에 동기화 처리를 하지 않았다 해도 아무런 문제가 없다.

3.5.3 안전한 공개 방법의 특성

불변 객체가 아닌 객체는 모두 올바른 방법으로 안전하게 공개해야 하며, 대부분은 공개하는 스레드와 불러다 사용하는 스레드 양쪽 모두에 동기화 방법을 적용해야 한다.

객체를 안전하게 공개하려면 해당 객체에 대한 참조와 객체 내부의 상태를 외부의 스레드에게 동시에 볼 수 있어야 한다.
올바르게 생성 메서드가 실행되고 난 객체는 다음과 같은 방법으로 안전하게 공개할 수 있다.

  • 객체에 대한 참조를 static 메서드에서 초기화한다
  • 객체에 대한 참조를 volatile 변수 또는 AtomicReference 클래스에 보관한다
  • 객체에 대한 참조를 올바르게 생성된 클래스 내부의 final 변수에 보관한다
  • 락을 사용해 올바르게 막혀 있는 변수에 객체에 대한 참조를 보관한다

자바에서 기본으로 제공하는 스레드 안전한 컬렉션은 다음과 같은 스레드 동기화 기능을 갖고 있다.

  • HashTable, ConcurrentMap, synchronizedMap 을 사용해 만든 Map 객체를 사용하면 그 안에 보관하고 있는 키와 값 모두를 어느 스레드에서라도 항상 안전하게 사용할 수 있다.
  • 객체를 Vector, CopyOnWriteArrayList, CopyOnWriteArraySet 이나 synchronizedList 또는 synchronizedSet 메서드로 만든 컬렉션은 그 안에 보관하고 있는 객체를 어느 스레드에서라도 항상 안전하게 사용할 수 있다.
  • BlockingQueue 나 ConcurrentLinkedQueue 컬렉션에 들어 있는 객체는 어느 스레드라도 항상 안전하게 사용할 수 있다.

다음과 같이 static 변수를 선언할 때 직접 new 연산자로 생성 메서드를 실행해 객체를 생성할 수 있다면 가장 쉬우면서도 안전한 객체 공개 방법이다.

1
public static Holder holder = new Holder(42);

static 초기화 방법은 JVM 에서 클래스를 초기화하는 시점에 작업이 모두 진행된다. 그런데 JVM 내부에서 동기화가 맞춰져 있기 때문에 이런 방법으로 객체를 초기화하면 객체를 안전하게 공개할 수 있다.

3.5.4 결과적으로 불변인 객체

처음 생성한 이후에 그 내용이 바뀌지 않도록 만들어진 클래스에 안전한 공개 방법을 사용하면, 별다른 동기화 방법 없이도 다른 스레드에서 얼마든지 사용해도 아무런 문제가 발생하지 않는다. 다시 설명하면, 특정 객체를 안전한 방법으로 공개했을 경우, 해당 객체에 대한 참조를 갖고 객체를 불러와 사용하는 시점에는 공개하는 시점의 객체 상태를 정확하게 사용할 수 있고, 해당 객체 내부의 값이 바뀌지 않는 한 여러 스레드에서 동시에 값을 가져다 사용해도 동기화 문제가 발생하지 않는다.

1
2
public Map<String, Date> lastLogin 
= Collections.synchronizedMap(new HashMap<String, Date>());

위와 같은 코드에서 Map에 한 번 들어간 Date 인스턴스의 값이 더 이상 바뀌지 않는다면 synchronizedMap 메서드를 사용하는 것만으로 동기화 작업이 충분하며, 그 안의 값을 사용할 때에도 추가적인 동기화 코드를 만들어야 할 필요가 없다.

3.5.5 가변 객체

가변 객체(mutable object)를 사용할 때에는 공개하는 부분과 가변 객체를 사용하는 모든 부분에서 동기화 코드를 작성해야만 한다. 그래야 객체 내용이 바뀌는 상황을 정확하게 인식하고 사용할 수 있다. 가변 객체를 안전하게 사용하려면 안전하게 공개해야만 하고, 또한 동기화와 락을 사용해 스레드 안전성을 확보해야만 한다.

가변성에 따라 객체를 공개할 때 필요한 점을 살펴보면 다음과 같다.

  • 불변 객체는 어떤 방법으로 공개해도 아무런 문제가 없다.
  • 결과적으로 불변인 객체는 안전하게 공개해야 한다.
  • 가변 객체는 안전하게 공개해야 하고, 스레드에 안전하게 만들거나 락으로 동기화 시켜야 한다

3.5.6 객체를 안전하게 공유하기

언제든 객체에 대한 참조를 가져다 사용하는 부분이 있다면, 그 객체로 어느 정도의 일을 할 수 있는지를 정확하게 알고 있어야 한다. 객체를 사용하기 전에 동기화 코드를 적용해 락을 확보해야 하는지? 객체 내부의 값을 바꿔도 괜찮은지, 아니면 값을 알기만 해야 하는지? 대부분의 동기화 오류는 이와 같이 일반적인 몇 가지 수칙을 제대로 이해하지 못하고 프로그램을 작성하는 데서 싹트기 시작한다. 또한, 반대로 객체를 일부에서 사용할 수 있도록 공개할 때에는 해당 객체를 어떤 방법으로 사용할 수 있고, 사용해야 하는지에 대해서 정확하게 설명해야 한다.

여러 스레드를 동시에 사용하는 병렬 프로그래밍에서 객체를 공유해 사용하고자 할 때 가장 많이 사용되는 몇 가지 원칙을 살펴보면 다음과 같다.

  • 스레드 한정: 스레드에 한정된 객체는 완전하게 해당 스레드 내부에 존재하면서도 그 스레드에서만 호출해 사용할 수 있다
  • 읽기 전용 객체를 공유: 읽기 전용 객체를 공유해 사용하면 동기화 작업을 하지 않더라도 여러 스레드에서 언제든지 마음껏 읽어 사용할 수 있다
  • 스레드에 안전한 객체를 공유: 스레드에 안전한 객체는 객체 내부적으로 필수적인 동기화 기능이 만들어져 있기 때문에 외부에서 동기화를 신경 쓸 필요 없이 여러 스레드에서 마음껏 호출해 사용할 수 있다
  • 동기화 방법 적용: 특정 객체에 동기화 방법을 적용해두면 지정한 락을 획득하기 전에는 해당 객체를 사용할 수 없다