8 min read

자바 병렬 프로그래밍 - 3장 객체 공유

가시성

특정 변수의 값을 여러 스레드가 가져갈 때, 한 스레드가 작성한 값을 가져간다는 보장을 할 수 없다. 그래서 메모리상의 공유된 변수를 여러 스레드에서 접근할 때는 반드시 동기화 기능이 구현되어야 한다.

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

위 코드를 보면 앞서 설명한 문제가 발생할 수 있다. Reader 스레드가 ready 값을 읽는 시점이 메인 스레드가 ready 값을 write한 시점 이후라는 것을 보장할 수 없다. 심지어 메인 스레드가 write를 했다하더라도 reader 스레드가 쓰여진 최신의 값을 읽어오지 않을 수도 있다. 그렇기에 우리가 바라는 순서대로 흐름이 진행되지 않을 수 있다.

앞선 예제에서 제대로 동기화 되지 않은 값을 읽을 수 있는 상황을 경험했다. 이처럼 최신에 쓰여진 값이 아닌 값들은 stale 데이터라고 한다.

동기화 되지 않은 상태에서 stale 데이터를 가져간다는 것은 바로 이전에 다른 스레드에서 설정한 값을 가져가게 된다. 하지만 64비트(8바이트)를 사용하는 long과 double의 경우 이전 스레드가 설정한 값도 아닌 아예 관계없는 값을 가져오게 될 수도 있다. JVM 메모리 모델은 volatile로 지정되지 않은 long 또는 double 형의 64비트 값에 대해서는 read or write 시 두 번의 32비트 연산을 허용한다. 즉 원자적인 연산임을 보장할 수가 없다. 예를 들어 32비트는 최신값으로 나머지 32비트는 stale한 값으로 읽어올 확률도 존재한다. 결론적으로 완전히 관계없는 값을 가져오는 경우도 생길 수 있다.

이러한 문제는 synchronized를 통해서 간단히 해결 가능하다. 또한 volatile 변수를 통해 좀 더 약한 동기화 기능을 사용할 수 있다. volatile 변수의 경우 값을 특정 스레드가 변경했을 때, 다른 스레드에서 항상 최신의 값을 읽을 수 있도록 해준다. 해당 변수는 프로세서의 레지스터에 캐싱되지도 않고 외부의 캐시에도 들어가지 않아서 항상 메모리에서 최신의 값을 읽어올 수 있게 한다.

반면에 volatile의 경우 특정 연산의 원자성을 보장해주지는 못한다. 즉, 적용된 변수의 가시성만 보장을 해주고 stale 데이터가 아닌 최신 값을 읽을 수 있게 한다. 예를 들면, 특정 스레드에서만 값을 write하고 나머지 여러 스레드에서 해당 값을 read하는 경우에는 volatile을 통해 가시성 문제를 해결하기 좋은 상황이다. 하지만 여러 스레드에서 write를 하는 경우에서는 해당 연산의 원자성을 보장하지 못한다.

공개와 유출

특정 객체를 현재 코드의 스코프 밖에서 사용할 수 있도록 하면 공개되었다고 한다. 예를 들어 스코프 밖의 변수에 해당 변수의 참조값을 저장한다거나 다른 클래스의 메서드로 참조를 넘겨주는 방식 등이 있다. 객체가 공개되는것을 의도하지 않았지만 외부에서 접근 가능하게 된 것을 유출 상태라고 한다.

아래와 같은 코드의 경우 참조가 유출될 수 있는 코드이다.

public static Set<Secret> known;

public void init(){
	known = new HashSet<Secret>();
}

만약 위와 같이 특정 객체를 공개시킬 경우 private이 아닌 모든 변수 속성에 연결되어 있는 모든 객체가 함께 공개된다. 이러한 접근은 결국 의도를 하든 안하든 잘못 사용될 가능이 존재한다.

아래의 코드의 경우에도 EventListener 인스턴스가 source.registerListener 를 통해 외부에 공개된다. 이 경우에는 EventListener뿐만 아니라 이를 포함하는 ThisEscape 클래스까지도 함께 외부에 공개된다.

public class ThisEscape {
    public ThisEscape(EventSource source) {
        source.registerListener(new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        });
    }

    void doSomething(Event e) {
    }


    interface EventSource {
        void registerListener(EventListener e);
    }

    interface EventListener {
        void onEvent(Event e);
    }

    interface Event {
    }
}

위 예제와 같이 생성자가 호출되는 중에 해당 클래스가 공개 될 경우 적절히 초기화되지 않은 상태이기에 정상적이지 않은 상태의 객체를 외부에서 호출할 가능성이 있다. 책에서는 이를 생성자가 실행하는 도중에 this 변수가 외부에 유출되지 않도록 해야한다라고 표현한다. 아래 코드와 같이 리팩토링 가능하다.

이너 클래스를 생성자에서 생성만 한다. 이후 해당 listener 변수를 별도의 public 메서드인 newInstance에서 사용하도록 한다.

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

    void doSomething(Event e) {
    }


    interface EventSource {
        void registerListener(EventListener e);
    }

    interface EventListener {
        void onEvent(Event e);
    }

    interface Event {
    }
}

스레드 한정

스레드는 한정한다는 것은 특정 객체를 사용하는 스레드를 지정하여 동기화 할 필요 없게 하는 것이다. 자연스레 해당 객체는 스레드 안전성을 확보한다. 대표적인 예로 JDBC의 커넥션 풀을 들 수 있다. 커넥션 풀 내의 커넥션은 DB와의 연결을 위해 사용되는 동안 다른 스레드가 사용하지 못하도록 한정되어 있다.

또 다른 스레드 한정 방안 중 하나는 스택을 통한 한정이다. 쉽게 말하면 지역 변수를 활용해서 해당 변수에 하나의 스레드만이 접근하도록 하는것이다. 즉, 로컬 변수는 모두 암묵적으로 현재 실행 중인 스레드에 한정된다. 왜냐하면 로컬 변수는 스레드 내부의 스택에 저장되기 때문이다.

마지막 방안은 ThreadLocal 클래스를 활용하는것이다. 이는 get과 set 메서드를 제공하는데 만약 스레드 A가 set을 한 경우 추후에 get을 할 때 스레드 A 자신이 저장한 값을 읽도록 한다.

불변성

불변 객체의 경우 해당 레퍼런스가 변경되지 않기에, 스레드 안전하다. 즉, 객체의 상태가 단 하나 뿐이라서 언제라도 스레드에 안전해진다.

불변 객체를 구성할 때 내부의 모든 변수를 final로 설정해도 불변객체가 아닐 수도 있다. 왜냐하면 객체 내부의 모든 필드들이 final이더라도 해당 변수에 연결된 참조가 불변이 아니라면 해당 객체도 불변이 아니다. 불변 객체의 조건은 아래와 같다.

  1. 생성되고 난 이후에는 객체의 상태를 변경할 수 없다.
  2. 내부의 모든 변수는 final로 설정되어야한다.
  3. 적절한 방법으로 생성되어야 한다.

책에서는 위와 같이 언급하는데, 3번의 경우가 가장 중요하다. this변수가 외부로 유출되거나 또는 다른 메서드에 의해 불변이 깨지지 않도록 메서드 설계를 하는것이 중요하다. 쉽게 말하면 write 작업을 초기화에 한번 진행하고 이후 호출되는 모든 메서드는 read만 할 경우 불변을 유지할 수 있다.

안전 공개

만약 객체를 공개해야한다면 안전하게 해야한다. 여기서 안전이란 의미는 Thread-Safe하다라고 이해하였다. 대표적인 불안전 공개는 특정 객체의 생성자가 호출되기 전 해당 객체에 스레드가 접근하는것이다. 아래 예시에서 init을 한 스레드가 호출하고 동시에 assertSanity를 호출하면 예외가 던져질 수도 있다. 생성자가 완료되기 전에 메서드 호출이 가능하기 때문이다.

	public class Holder {
		private int n;
		public Holder(int n) {this.n = n;}
	
		public void assertSanity(){
			if (n != n) throw new AssertionsException();	
		}
	}
public Holder holder;

public void init(){
	holder = new Holder(42)
}

책에서 강조하는 내용은 개발자가 어떤 객체든 해당 참조를 가져다 사용해야 한다면 그 객체에 대해 다음과 같은 측면들을 고려해야 한다.

  1. 객체를 사용하기 전, Lock을 사용해야하는가
  2. 객체 내부의 값을 바꿔도 괜찮은가
  3. 값을 읽기만 해야하는가

위와 같은 포인트들을 고려하고 객체를 활용해 로직을 작성하는 습관을 들여보자.