자바병렬프로그래밍 3. 객체 공유 Sharing Objects

Eugene Lim
17 min readApr 3, 2022

--

이전 챕터에서는 상태가 바뀔 수 있는 객체를 어떻게 공유할 것인가를 살펴봤다면, 이번 챕터는 안전하게 객체를 공유하고 공개하는 방법에 대해 알려준다.

synchronized 키워드 뿐만아니라 다른 방식으로도 객체 공유를 할 수 있다는 내용을 이 챕터 전반에 걸쳐 설명한다.

3.1 가시성 Visibility

// 예제 3.1
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;
}
}

Thread.yield()는 현재 스레드를 대기 상태로 돌리고 프로세스 내의 다른 작업할 스레드에게 실행할 기회를 준다. 그리고 Thread 관리에 의해 다시 깨어나는 시점에는 ready를 체크하고 true가 되면 while을 벗어나서 number를 프린트 한다.

Effective Java Item.72 는 yield에 대해 설명하고 있다.
만약 예제 3.1에서 yield를 호출하지 않으면, ready 가 될 때까지 한 스레드가 CPU 점유율이 높아져서 다른 작업을 하기 힘들 것이다.

일부 스레드가 다른 스레드에 비해 상대적으로 충분한 CPU 시간을 얻지 못 해서 프로그램이 거의 일을 안 하는 상황에 직면할 때, Thread.yield()를 호출하여 “문제를 해결”하려고 하면 안된다. 특정 JVM에서는 성능을 향상시키지만, 다른 종류의 JVM에서는 오히려 성능을 떨어뜨리거나 아무 영향이 없을 수 있기 때문이다.(Effective Java Item 72)

즉, JVM이 제공하는 Thread Scheduler에 디팬던시가 있는 코드를 짜면 안된다는 이야기다.

하지만 여기서는 적절한 동기화를 하지 않았기 때문에

  • while문을 벗어나지 못할 수도 있고
  • number = 0이라는 값을 프린트 할 수도 있다.

이것의 의미는 결국 다른 스레드에서 조작한 값은 제대로 안 보일 수 있다는 것이다. 책에서 이런 결과가 나올 수 있는 것의 원인은

  • 코드 재배치 code reordering

때문이라고 말하고 있다. 이 부분은 사실 16장에서 자세하게 설명하므로 그렇다라고만 알고 넘어가자.

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

3.1.1 스테일 데이터 stale data

결국 예제 3.1은 스테일 데이터의 한 종류이다. 만약에 책에 나온 예제 3.3 에서 get() 함수의 synchronized 키워드를 빼면, 곧바로 스테일 데이터가 될 확률이 높아진다.

// 예제 3.3
@ThreadSafe
public class SynchronizedInteger {
@GuardedBy("this") private int value;
public int get() { return value; }
public synchronized void set(int value) { this.value = value; }
}

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

64비트를 사용하는 숫자형(double이나 long 등)에 volatile 키워드를 사용하지 않는 경우에는 난데없는 값마저 생길 가능성이 있다.

64비트 값에서 메모리를 쓰거나 읽을 때 두 번의 32비트 연산을 사용할 수 있도록 허용하므로, 결국 읽기 쓰기조차도 단일연산이 아닐 수 있다는 의미이다. 하지만 요즘 컴퓨터들은 64비트 프로세서이므로, 이런 현상이 있었다는 것만 기억해도 좋을 것 같다.

3.1.3 락과 가시성

그림 3.1

위 그림은, A 스레드가 M객체로 락을 갖고 있고, x 변수를 조작했고, 락을 풀었다. 그리고 스레드 B가 동일한 락으로 x 값을 읽고 y값을 읽었다면, y도 새로 적용된 값을 볼 수 있다는 뜻이다.

스레드마다 캐시메모리를 갖고 있다

프로세서에서 각 스레드마다 캐시메모리를 두고 있다면(chap.16 에서 자세하게 설명), M락을 잡을 Thread1이 현재 자신의 캐시메모리값을 main memory에 반영하고 동기화하는 것이다. 그래야 Thread2가 M락을 잡았을 때, 제대로 된 x, y값을 볼 수 있다.

3.1.4 volatile 변수

volatile로 지정된 변수는 프로세서의 레지스터에 캐시되지도 않고, 프로세서 외부의 캐시에도 들어가지 않기 때문에 volatile 변수의 값을 읽으면 항상 다른 스레드가 쓴 최신의 값을 읽어갈 수 있다.

결국 volatile 키워드를 쓰면 캐시하지 않고 메인메모리에서 직접 읽고 쓴다고 보면 좋을 것 같다.

volatile도 synchronized 키워드와 마찬가지로 volatile 변수에 값을 쓰고 읽으면 다른 스레드들도 같은 메모리 가시성을 갖는다. 책에서는 간편하게 쓸 수 있는 변수에 쓰는게 좋다고 되어 있다. 하지만 락과의 차이점은 여러 변수를 단일 연산을 보장할 수 없다는 점이다.

volatile boolean asleep; 
...
while (!asleep)
countSomeSheep();

3.2 공개와 유출 Publication and Escape

특정 객체를 현재 코드의 스코프 범위 밖에서 사용할 수 있도록 만들면 공개 published 되었다고 한다.

특정 객체가 함수내에서 사용되지 않고, singleton 처럼 여러 부분에서 쓰는 객체라면 공개된 객체라고 보면 된다. 아래 코드에서 knownSecrets는 완전히 공개되었다. 그리고 HashSet에 들어가는 Secret 객체 또한 완전히 공개된 객체들이다.

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

의도적으로 공개시키지 않았지만 외부에서 사용할 수 있게 공개된 경우를 유출 상태 escaped라고 한다.

class UnsafeStates {
private String[] states = new String[] {"AK", "AL" ... };
public String[] getStates() { return states; }
}

states 배열 자체는 private이지만, getStates() 함수로 완전히 공개되었다. 즉, states 변수는 유출 escape 되었다고 말할 수 있다.

가장 흔히 발생하는 공개는 다음 코드와 같다. 이 코드에 대해서는 다음장에서 설명하고 있다.

// 예제 3.7
public class ThisEscape {
public ThisEscape(EventSource source) {
source.registerListner(
new EventListener() {
public void onEvent(Event e) {
doSomething(e); // -> 여기서 this@ThisEscape 객체가 유출됨
}
}
);
}

private void doSomething(Event e) {
// do something
}
}

3.2.1 생성 메소드 안정성

예제 3.7에서 6번째 라인에서 ThisEscape 객체도 함께 다른 클래스 EventSource로 공개되어진다. 여기서 생성자가 완전히 종료되고 난 이후에 EventListener가 호출되면 아무 문제 없지만, 생성자가 끝나기도 전에 onEvent가 실행된다면 doSomething에서 완벽하지 못한 this 클래스에 어떤 일이 일어날지 사실 예상할 수 없다.

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

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;
}
}

생성자에서는 variable를 적절히 생성해주고, this가 나갈 수 있는 코드는 따로 함수를 호출해서 작업해야한다. 여기서는 명시적으로 팩토리메서드 newInstance() 를 만들어서 호출해주었다.

3.3 스레드한정 Thread Confinement

2장에서도 읽었듯이 특정 코드가 single thread 상황이라면 동기화를 신경쓰지 않아도 된다. 예를 들어, UI-Thread만 접근하는 뷰를 그리는 작업들에서는 특별히 동기화 작업을 해줄 필요가 없다. 이런 기법을 스레드 한정이라고 한다.

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

이 부분에서는 GUI를 예로 들고 있는데,

실제로 GUI 애플리케이션에서 사용하는 화면 컴포넌트나 데이터 모델과 같은 객체가 public field로 정의되어 있는 경우를 많이 볼 수 있다. 스레드 한정 기법을 사용할 것인지를 결정하는 일은 GUI 모듈과 같은 특정 시스템을 단일 스레드로 동작하도록 만들 것이냐에 달려있다.

안드로이드도 OS에서 코드에서 main-thread 이외 다른 스레드에서 UI를 수정하려고 할 때 exception을 방출한다. 즉, main-thread만 접근할 수 있도록 라이브러리에서 스레드 한정을 해주는 것이다.

volatile 변수를 쓸 때도 주의점을 적어놓았는데, 읽기와 쓰기가 모두 가능한 volatile 변수를 공유할 때는 쓰기 작업은 단일 스레드에서만 하도록 이야기하고 있다.

3.3.2 스택 한정

Chp2 에서 설명했듯이, 함수내에서 선언된 변수를 local variable이라고 하고 이 변수들은 함수 실행기간 동안만 스택에서 살게 된다.

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;
}

하지만 local variable도 주의해서 사용해야 한다. numPairs를 int 기본 변수 primitive type 이다. 여기서 numPairs가 리턴되지만, 기본변수 primitive type은 복사되서 나가기 때문에, 스택 한정이 유지된다. 그러나

return animals 라고 하면 주의해야한다. 이런 객체 변수는 animals를 가리키는 포인터만 스택에 생기고, 객체는 Heap memory 영역에 생기므로,

Set animals = loadTheArk(...)

이렇게 animals가 함수밖으로 나온 후, 외부 코드에 의해 충분히 변경될 수 있다. 책에서는 이 부분에 대해서 3.5 객체 공개에서 자세하게 설명한다.

3.3.3 ThreadLocal

ThreadLocal은 잘 써본적이 없는 클래스지만, 소개하는 것도 좋을 것 같다. 스레드마다 저장할 수 있는 공간을 제공해준다고 생각하면 되는데,

private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>() {
public Connection initialValue() {
return DriverManager.getConnection(DB_URL);
}
};
public static Connection getConnection() {
return connectionHolder.get();
}

ThreadLocal은 Map<Thread, T> 라고 보면 될 것 같다(실제로 이렇게 구현되어 있진 않다고 한다)

하지만 이렇게 구현하면 좋지 않은 점은, 이 클래스 구조 상 애플리케이션 라이프사이클을 가질 수 밖에 없다. 그러면서 자연스럽게 이 클래스에 모든 스레드의 전역변수들을 남발할 수도 있게 된다. 그리고 전역변수가 되므로 객체간의 dependency 가 커질 수 밖에 없다.

3.4. 불변성 Immutability

지금까지 저자가 계속 강조하는 점이다.

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

불편객체는 다음 조건을 만족해야한다.

  1. 생성되고 난 후에는 객체의 상태를 변경할 수 없다. (instance variable이 변경되면 안된다)
  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);
}
}

여기서 stooges 변수에 final을 설정하고 생성자에서 데이터를 잘 채워두었다. setter 가 없으므로 변수가 변경될 가능성도 없다. 하지만 클래스에 final 이 꼭 필요할까?? 불변 객체는 상속도 막을 수 밖에 없다.

public class ChildClass extends ThreeStooges {
public Set<String> getStooges() {
return stooges;
}
}

이렇게 상속을 하고, protected instance variable이 외부로 유출될 수 있다. 결국 ThreeStooges는 불변성을 유지할 수 없다. 그래서 코틀린의 data class는 상속이 안되는 이유이다.

3.4.1 final 변수

자바에서는 final keyword의 동작방식이 2가지 인데,

  1. 변수에서 사용하면 값이 변할 수 없다는 것이고
  2. 클래스에서 사용하면 클래스 상속을 막는 것이다.(위의 예제에서 봤듯이)

final로 변수를 지정하면, 동시성 프로그래밍을 신경쓰지 않아도 될 뿐만 아니라 디버깅에도 편하게 해준다.

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

Chp2 에서 작성했던 인수분해를 고쳐보자. lastNumber와 lastFactors 를 final 클래스로 묶었다.

@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);
}
}
@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);
}
}

여기서는 cache를 volatile로 지정하고, 값을 읽은 후 factors 가 null 이면, 인수분해를 계산해서 cache에 저장한다. volatile은 thread 간의 변수 가시성을 확보해준다고 하였으니, Thread A와 Thread B가 동시에 10에 대한 factors를 요청한다고 하면, 하나는 값이 보일 수도 있다.

만약에 안 보인다면, factor를 계산중일 수도 있다. 그러나 OneValueCache(10, factors)가 2개 생긴다하더라도 immutable 객체이므로 사실 크게 문제되지는 않을것이다.

3.5 안전 공개 Safe Publication

위에서 설명한 스레드 한정, 스택 한정 혹은 final, volatile, 불변 객체 만으로는 프로그래밍을 할 수 없다. 객체를 함수밖으로 내보내고, 여러 스레드에서 결국 객체를 공유해야만 하는데, 어떻게 해야 잘 공개할 수 있는지에 대해서 이야기 해본다.

3.5.1 적절하지 않은 공개 방법

// 다른 클래스
public Holder holder;
public void initialize() {
holder = new Holder(42);
}

// Holder 클래스
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.");
}
}

이렇게 여러 스레드에서 holder를 참조할 때, public으로 공개된 것이 문제가 된다고 설명하고 있다. (제일 공감이 안 가는 예제이긴 하지만)

생성자도 thread-safe 를 생각해야한다고 이 장에서 설명하고 있다. 생성자가 실행되고 있는 상태의 인스턴스를 다른 스레드가 사용하려고 한다면, 비정상적인 상태임에도 불구하고 그래도 사용하게 될 가능성이 있다는 것이다. Holder 클래스에서 n이 아직 세팅되지 않았을 때(이보다 복잡한 작업을 생성자에서 한다고 상상해야한다), 다른 스레드에서 assertSanity()를 호출하면 Exception이 발생할 수 있다.

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

불변객체는 이전 예제에서 겪었던 초기화시 공개되어 전혀 다른 동작을 하는 것을 막을 수 있다.

3.5.3 안전한 공개 방법의 특성

여기서 말하는 안전한 공개 방법이 몇가지 있는데 대부분의 내용은 이후 챕터에서 충분히 설명하고 있다.

  • 락을 사용해 올바르게 막혀 있는 변수에 객체에 대한 참조를 보관한다.
    우리가 Chp2 에서 살펴본 synchronized로 안전하게 동기화된 변수를 공개하는 방법이다.
  • 객체에 대한 참조를 static 키워드로 초기화 시킨다.
    public static Holder holder = new Holder(42);
    static 초기화 방법은 JVM에서 클래스를 초기화하는 시점에 작업이 모두 진행된다. singleton 도 static method 로 getInstance()를 하는 이유와 같다.
  • final 변수
  • volatice 변수 혹은 AtomicReference 클래스 사용

3.5.4 결과적으로 불변인 객체

만약 가변 객체도 클래스에서 캡슐화하여 불변인 속성으로 만들면 충분히 불변 객체로 프로그래밍 할 수 있다.

--

--