Java Concurrency in Practice, 객체 공유 - 스레드 한정

3.3 스레드 한정

스레드 안정성을 확보하는 방법

  1. 변경 가능한 객체를 공유해 사용하는 경우, 항상 동기화 한다.
  2. 동기화를 사용하지 않는다면, 기본적으로 객체를 공유해 사용하지 않아야 한다.
  3. 특정 객체를 단일 스레드에서만 활용한다고 확신할수 있다면 동기화가 필요없다.

이처럼 스레드를 한정 confine하는 방법으로 스레드 안전성을 확보할 수 있다.

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

스레드 한정 기법을 사용할 것인지를 결정하는 일은 GUI 모듈과 같은 특정 시스템을 단일 스레드로 동작하도록 만들 것이냐에 달려 있다. 특정 모듈의 기능을 단일 스레드로 동작하도록 구현한다면, 언어적인 지원 없이 직접 구현한 스레드 한정 기법에서 나타날 수 있는 오류의 가능성을 최소화할 수 있다.

임시방편적인 스레드 한정 기법은 안전성을 완벽하게 보장할 수 있는 방법은 아니기 떄문에 꼭 필요한 곳에만 제한적으로 사용하는 게 좋다. 가능하다면 좀더 안전한 스레드 한정 기법을 사용하자. ( 비추천 )

3.3.2 스택 한정

스택 한정 기법은 특정 객체를 로컬 변수를 통해서만 사용할 수 있는 특별한 경우의 스레드 한정기법이라고 할 수 있다. 변수를 클래스 내부에 숨겨두면 변경 상태를 관리하기가 쉬운데, 클래스 내부에 숨겨둔 변수는 특정 스레드에 쉽게 한정시킬 수도 있다. 스택 한정 기법은 앞서 살펴본 스레드 한정 기법보다 안전하다.

public int loadTheArk(Collection<Animal> candidates) {
        SortedSet<Animal> animals;
        int numPairs = 0;
        Animal candidate = null;

        // animals confined to method, don't let them escape!
        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와 같이 기본 변수형을 사용하는 로컬 변수는 일부러 하려고 해도 스택 한정 상태를 깰 수 없다. 기본 변수형은 객체와 같이 참조되는 값이 아니기 때문인데, 이처럼 기본 변수형을 사용하는 로컬 변수는 언어적으로 스택 한정 상태가 보장된다.
  • 기본 변수형의 로컬 변수와 객체형의 로컬 변수에 대한 스택 한정

3.3.3 ThreadLocal

스레드 내부의 값과 값을 갖고 있는 객체를 연결해 스레드 한정 기법을 적용할 수 있도록 도와주는 좀더 형식적인 방법으로 ThreadLocal이 있다. ThreadLocal 클래스에는 get과 set 메소드가 있는데 호출하는 스레드마다 다른 값을 사용할 수 있도록 관리해준다. 다시 말해 threadLocal 클래스의 get 메소드를 호출하면 현재 실행중인 스레드에서 최근에 set 메소드를 호출해 저장했던 값을 가져올 수 있다.

  • 스레드 로컬 변수는 변경 가능한 싱글톤이나 전역 변수 등을 기반으로 설계되어있는 구조에서 변수가 임의로 공유되는 상황을 막기위해 사용하는 경우가 많다.
private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>() {
  public Connection initialValue() {
    return DriverManager.getConnection (DB_URL);
  }
};

public static Connection getConnection() {
  return connectionHolder.get();
}
  • ThreadLocal을 사용해 스레드 한정 상태를 유지
  • connectionHolder와 같이 JDBC 연결을 보관할때 ThreadLocal을 사용하면 스레드는 저마다 각자의 연결 객체를 갖게 된다.
특정 스레드가 ThreadLocal.get 메소드를 처음 호출한다면 initialValue 메소드에서 값을 만들어 해당 스레드에게 초기 값으로 넘겨준다. 개념적으로 본다면 ThreadLocal<T> 클래스는 Map<Thread, T>라는 자료구조로 구성되어 있고, Map<Thread, T>에 스레드별 값을 보관한다고 생각할 수 있겠다. 물론 ThreadLocal이 Map<Thread, T>를 사용해 구현되어 있다는 말은 아니다. 스레드별 값은 실제로 Thread 객체 자체에 저장되어 있으며, 스레드가 종료되면 스레드별 값으로 할당되어 있던 부분도 가비지 컬렉터가 처리한다.

3.4 불변성 (immutable)

직접적으로 객체를 동기화하지 않고도 안전하게 사용할 수 있는 방법 가운데 마지막은 불변 immutable객체 이다. 이 모든 문제들은 여러 스레드가 예측할 수 없는 방향으로 변경 가능한 값을 동시에 사용하려 하기 때문에 발생한다.

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

다음 조건을 만족하면 해당 객체는 불변 객체다.
1. 생성되고 난 이후에는 객체의 상태를 변경할 수 없다.
2. 내부의 모든 변수는 final로 설정돼야 한다.
3. 적절한 방법으로 생성돼야 한다(예를 들어 this 변수에 대한 참조가 외부로 유출되지 않아야 한다.)
@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);
    }
}
  • 일반 객체를 사용해 불변 객체를 구성한 모습

실행 중인 프로그램은 그 상태가 계속해서 바뀌고 또 바뀌어야 하기 때문에 불변 객체가 그다지 쓸모 있을지에 대해 의문이 생길 수도 있지만, 불변 객체는 쓰임새가 참 다양하다.

[객체]가 불변이라는 것과 [참조]가 불변이라는 것은 반드시 구분해서 생각해야한다. 예를 들어 프로그램이 사용하는 데이터가 불변 객체에 들어있다 해도, ㅎ당 객체를 가리키고 있는 참조 변수에 또 다른 불변 객체를 바꿔치기하면 프로그램의 데이터가 언제든지 바뀌는 셈이다.

3.4.1 final 변수

final 키워드는 불변 객체를 생성할 떄도 도움을 준다. final을 지정한 변수의 값은 변경할수 없는데 (물론 변수가 가리키는 객체가 불변 객체가 아니라면 해당 객체에 들어 있는 값은 변경할 수 있다.) 자바 메모리 모델을 놓고 보면 약간 특별한 의미를 찾을 수 있다. final 키워드를 적절하게 사용하면 초기화 안전성 initialization safety을 보장하기 때문에 별다른 동기화 작업 없이도 불변 객체를 자유롭게 사용하고 공유할 수 있다.

  • 완전한 불변 객체는 아니지만 상태 값이 하나 또는 두개 정도로 바뀔 수 있는 거의 불변인 객체 역시 일반 객체보다는 훨씬 고려해야 할 범위를 줄여준다. 그리고 변수를 final로 선언해두면 후임자가 코드를 읽을 떄에도 해당 변수에 지정된 값이 변하지 않는다는 점을 정확하게 이해할 수 있다.
외부에서 반드시 사용할 일이 없는 변수는 private으로 선언하는 게 괜찮은 방법인 만큼, 나중에 변경할 일이 없다고 판단되는 변수는 final로 선언해두는 것도 좋은 방법이다.

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

@Immutable
public 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);
    }
}
  • 입력 값과 인수 분해된 결과를 묶는 불변 객체
@ThreadSafe
public class VolatileCachedFactorizer extends GenericServlet 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);
    }
}
  • VolatileCachedFactorizer 클래스는 OneValueCache 클래스를 사용해 입력 값과 결과를 캐시한다. 스레드 하나가 volatile로 선언된 cache 변수에 새로 생성한 OneValueCache 인스턴스를 설정하면, 다른 스레드에서도 cache 변수에 설정된 새로운 값을 즉시 사용할 수 있다.
  • 최신 값을 불변 객체에 넣어 volatile 변수에 보관
  • OneValueCache 클래스가 불변인데다 cache 변수를 사용하는 코드에서는 cache 변수를 정확하게 한번씩만 사용하기 때문에 캐시와 관련된 연산은 전혀 혼동되거나 섞이지 않는다. VolatileCachedFactorizer 클래스는 변경할 수 없는 상태 값을 여러 개 갖고 있는 불변 객체인데다 volatile 키워드를 적용해 시간적으로 가시성을 확보하기 때문에 따로 락을 사용하지 않았다 해도 스레드에 안전하다.

3.5 안전 공개

  • 지금까지는 객체를 특정 스레드에 한정하거나 다른 객체 내부에 넣을 때, 객체를 공개하지 않고 확실하게 숨기는 방법에 대해 살펴봤다.
  • 여러 스레드에서 공유하도록 공개해야 할 상황이 있을 수도 있을떄에는 반드시 안전한 방법을 사용해야 한다.
// 안전하지 않은 객체 공개
// AntiPattern
public Holder holder;

public void initialize() {
  holder = new Holder(42);
}
  • 별 문제 없어 보이는 코드 이지만, 이렇게 별 문제 없어 보이는 코드가 얼마나 큰 문제를 일으킬 수 있는지를 이해하게 되면 정말 놀랍다는 생각이 든다.
  • 가시성 문제 때문에 Holder 클래스가 안정적이지 않은 상태에서 외부 스레드에게 노출될 수 있으며, 심지어 생성 메소드에서 내부의 고정된 값을 정상적으로 설정한 이후에도 문제가 된다.
  • 이렇게 단순한 방법으로 객체를 외부에 공개하면 생성 메소드가 채 끝나기도 전에 공개된 객체를 다른 스레드가 사용할 수 있다.

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

만약 객체의 생성 메소드가 제대로 완료되지 않은 상태라면 과연 그 객체를 제대로 사용할 수 있을까?

  • 생성 메소드가 실행되고 있는 상태의 인스턴스를 다른 스레드가 사용하려 할때, 비정상 상태임에도 그대로 사용한다.
  • 나중에 생성 메소드가 제대로 끝나고 보니 공개한 이후에 값이 바뀐 적이 없음에도 불구하고 처음 사용할 떄와는 값이 다른 경우도 생긴다.
public class Holder {
  private int n;

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

  public void assertSanity() {
    if (n != n)
      throw new AssertionError("This Statement Is False.");
  }
}
  • 올바르게 공개하지 않으면 문제가 생길 수 있는 객체
올바르게 공개하지 않으면 두 가지 문제가 발생할 수 있다.
1. holder 변수에 스테일 상태가 발생할 수 있는데, holder 변수에 값을 지정한 이후에도 null 값이 지정되어 있거나 예전에 사용하던 참조가 들어가 있을 수도 있다.
2. 다른 스레드는 모두 holder 변숭서 정상적인 참조 값을 가져갈 수 있지만 Holder 클래스의 입장에서는 스테일 상태에 빠질 수 있다.

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

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

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

final로 선언된 모든 변수는 별다른 동기화 작업 없이도 안전하게 사용할 수 있다. 하지만 final로 선언된 변수에 변경 가능한 객체가 지정되어 있다면 해당 변수에 들어있는 객체 값을 사용하려고 하는 부분을 모두 동기화 시켜야한다.

3.5.3 안전한 공개 방법의 특성

  • 불변 객체가 아닌 객체는 모두 올바른 방법으로 안전하게 공개해야 한다.
  • 대부분 공개하는 스레드와 불러다 사용하는 스레드 양쪽 모두에 동기화 방법을 적용해야 한다.
객체를 안전하게 공개하려면 해당 객체에 대한 참조와 객체 내부의 상태를 외부의 스레드에게 동시에 볼 수 있어야 한다. 올바르게 생성 메소드가 실행되고 난 객체는 다음과 같은 방법으로 안전하게 공개할 수 있다.
1. 객체에 대한 참조를 static 메소드에서 초기화시킨다.
2. 객체에 대한 참조를 volatile 변수 또는 AtomicReference 클래스에 보관한다.
3. 객체에 대한 참조를 올바르게 생성된 클래스 내부의 final 변수에 보관한다.
4. 락을 사용해 올바르게 막혀 있는 변수에 객체에 대한 참조를 보관한다.
  • Hashtable, ConcurrentMap, synchronizedMap을 사용해 만든 Map 객체를 사용하면 그 안에 보관하고 있는 키와 값 모두를 어느 스레드에서라도 항상 안전하게 사용할 수 있다.
  • 객체를 Vector, CopyOnWriteArrayList, CopyOnWriteArraySet이나 synchronizedList 또는 synchronizedSet 메소드로 만든 컬렉션은 그 안에 보관하고 있는 객체를 어느 스레드에서라도 항상 안전하게 사용할 수 있다.
  • BlockingQueue나 ConcurrentLinkedQueue 컬렉션에 들어 있는 객체는 어느 스레드라도 항상 안전하게 사용할 수 있다.

자바 라이브러리에서 제공하는 몇 가지 간단한 방법 (Future 클래스나 Exchanger 클래스)를 적절하게 활용해도 객체를 안전하게 공개할 수 있다. 다음과 같이 static 변수를 선언할 때 직접 new 연산자로 생성 메소드를 실행해 객체를 생성할 수 있다면 가장 쉬우면서도 안전한 객체 공개 방법이다.

public static Holder holder = new Holder(42);

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

3.5.4 결과적으로 불변인 객체

안전하게 공개한 결과적인 불변 객체는 별다른 동기화 작업 없이도 여러 스레드에서 안전하게 호출해 사용할 수 있다.

예를 들어 Date 클래스는 불변 객체가 아니라서 여러 스레드에서 공유해 사용하려면 항상 락을 걸어야만 했다. 하지만 앞에서 설명한 것처럼 불변 객체인 것처럼 사용하면 동기화 작업을 하지 않아도 된다. 아래와 같이 사용자별로 최근 로그인한 시각을 Map에 저장해 두는 코드를 생각해보자.

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

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

3.5.5 가변 객체

객체의 생성 메소드를 실행한 이후에 그 내용이 변경될 수 있다면, 안전하게 공개했다 하더라도 그저 공개한 상태를 다른 스레드가 볼 수 있다는 정도만 보장할 수 있다.

  • 가변 객체 mutable object를 사용할 때에는 공개하는 부분과 가변 객체를 사용하는 모든 부분에서 동기화 코드를 작성해야만 한다. 그래야 객체 내용이 바뀌는 상황을 정확하게 인식하고 사용할 수 있다.
  • 가변 객체를 안전하게 사용하려면 안전하게 공개해야만 하고, 또한 동기화와 락을 사용해 스레드 안전성을 확보해야만 한다.
가변성에 따라 객체를 공개할 때 필요한 점을 살펴보면 다음과 같다.
1. 불변 객체는 어떤 방법으로 공개해도 아무 문제가 없다.
2. 결과적으로 불변인 객체는 안전하게 공개해야 한다.
3. 가변 객체는 안전하게 공개해야 하고, 스레드에 안전하게 만들거나 락으로 동기화시켜야 한다.

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

언제든 객체에 대한 참조를 가져다 사용하는 부분이 있다면, 그 객체로 어느 정도의 일을 할 수 있는지를 정확하게 알고 있어야 한다.

  1. 객체를 사용하기 전에 동기화 코드를 적용해 락을 확보해야 하는지?
  2. 객체 내부의 값을 바꿔도 괜찮은지, 아니면 값을 읽기만 해야 하는 것인지?
  3. 대부분의 동기화 오류는 이와같이 몇가지 수칙을 이해하지 못하고 프로그램을 작성하는데서 싹트기 시작한다.
  4. 또한, 반대로 객체를 외부에서 사용할 수 잇도록 공개할 때에는 해당 객체를 어떤 방법으로 사용할 수 있고, 사용해야 하는지에 대해서 정확하게 설명해야 한다.
여러 스레드를 동시에 사용하는 병렬 프로그램에서 객체를 공유해 사용하고자 할 때 가장 많이 사용되는 몇 가지 원칙을 살펴보면 다음과 같다.

[스레드 한정] : 스레드에 한정된 객체는 완전하게 해당 스레드 내부에 존재하면서 그 스레드에서만 호출해 사용할 수 있다.
[읽기 전용 객체를 공유] : 읽기 전용 객체를 공유해 사용한다면 동기화 작업을 하지 않더라도 여러 스레드에서 언제든지 마음껏 값을 읽어 사용할 수 있다. 물론 읽기 전용 객체에 해당한다고 볼 수 있다.
[스레드에 안전한 객체를 공유] : 스레드에 안전한 객체는 객체 내부적으로 필수적인 동기화 기능이 만들어져 있기 때문에 외부에서 동기화를 신경 쓸 필요가 없고, 여러 스레드에서 마음껏 호출해 사용할 수 있다.
[동기화 방법 적용] : 특정 객체에 동기화 방법을 적용해두면 지정한 락을 획득하기 전에는 해당 객체를 사용할 수 없다. 스레드에 안전한 객체 내부에서 사용하는 객체나 공개된 객체 가운데 특정 락을 확보해야 사용할 수 있도록 막혀 있는 객체 등에 동기화 방법이 적용되어 있다고 볼 수 있다.

출처 :브라이언 괴츠, 더그 리, 팀 피얼스, 조셉 보우비어, 데이빗 홈즈, 조슈아 블로쉬 공저, 『 멀티코어를 100% 활용하는 자바 병렬 프로그래밍』, 에이콘(2008.7.30), 3장 인용.