BOOK 5 - 자바 병렬 프로그래밍(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;
}
}
- main 스레드와 읽기 스레드가
ready와number라는 변수를 공유하고 있다. - 읽기 스레드는
ready값이true가 될 때까지 반복문에서 기다리다가number변수의 값을 출력한다.- 일반적으로 읽기 스레드가 42 라는 값을 출력할 것으로 기대하겠지만,
- 0 이라는 값을 출력할 수도 있고,
- 영원히 값을 출력하지 못하고
ready변수의 값이true로 바뀌기를 계속해서 기다릴 수도 있다.
- 즉, 메인스레드에서 지정한
ready변수와number변수의 값을 읽기 스레드에서 사용할 수 없는 상황이 발생할 수 있다.
- 일반적으로 읽기 스레드가 42 라는 값을 출력할 것으로 기대하겠지만,
-
두 개의 스레드에서 변수를 공유해 사용함에도 불구하고 동기화 기법을 사용하지 않았기 때문이다.
- 재배치 현상으로 인해 읽기 스레드가 메인 스레드에서
number변수에 지정한 값보다ready변수에 지정한 값을 먼저 읽어가는 상황이 가능하다.- 재배치(reordering) 현상은 특정 메소드의 소스 코드가 100% 코딩된 순서로 동작한다는 점을 보장할 수 없다는 점에서 기인하는 문제이다.
- 메인 스레드는
number변수에 먼저 값을 저장하고ready변수에 값을 저장하지만, 동기화되지 않은 상태이기 때문에 읽기 스레드 입장에서는 마치ready변수에 먼저 값이 쓰여진 이후에number변수에 값이 저장되는 것처럼 순서가 바뀌어 보일수도 있고 아예 변경된 값을 읽지 못할수도 있다.
- 동기화되지 않은 상황에서 메모리상의 변수를 대상으로 작성해둔 코드가 ‘반드시 이런 순서로 동작할 것이다’ 라고 단정지을 수 없다.
- 따라서 여러 스레드에서 공동으로 사용하는 변수에는 항상 적절한 동기화 기법을 적용해야 한다.
스테일 데이터
- 위
NoVisibility예제에서처럼 특정 스레드가 어떤 변수를 사용할 때 정상적인 최신 값을 사용할 수도 있고, 유효하지 않은 값을 사용할 수도 있다.- 이러한 스테일 현상이 발생하면 예외 상황이 발생하기도 하고, 데이터를 관리하는 자료 구조가 망가질수도 있고, 계산된 결과값이 올바르지 않을 수도 있고, 무한 반복에 빠져들 수도 있다.
- 다음 예제 역시 스테일 현상이 발생할 수 있는 소지가 있다.
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메소드에서 제대로 읽어오지 못할 수 있다.
- 특정 스레드가
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메소드를 동기화시켜 위MutableInteger클래스의 문제점을 제거했다.
단일하지 않은 64비트 연산
- 64비트를 사용하는 숫자형(
double이나long등)에volatile키워드를 사용하지 않는 경우에 전혀 다른 값이 생길 가능성이 있다. - 자바 메모리 모델이
volatile로 지정되지 않은 64비트 값에 대해서는 메모리에 쓰거나 읽을 때 두번의 32비트 연산을 사용할 수 있도록 허용하고 있다.- 따라서
volatile로 지정하지 않은long형 변수의 값을 쓰는 기능과 읽는 기능이 서로 다른 스레드에서 동작한다면, 이전 값과 최신 값에서 각각 32비트를 읽어올 가능성이 생긴다.
- 따라서
락과 가시성
- 내장된 락을 적절히 활용하면 특정 스레드가 특정 변수를 사용하려 할 때, 다른 스레드가 이전에 그 변수를 사용하고 난 결과 값을 정확하게 가져갈 수 있는 상태에서 사용할 수 있다.
- 예를 들어, 스레드 A가
synchronized블록을 실행하고 그 뒤에 스레드 B가 같은 락을 사용하는synchronized블록을 실행하면, 스레드 B가 락을 획득해 변수를 사용하려 할 때 스레드 A가 락을 풀기 전 사용했던 변수의 값을 정확하게 가져갈 수 있다.
- 예를 들어, 스레드 A가
- 만약 동기화하지 않으면 변수의 값을 제대로 읽어간다고 보장할 수 없다.
- 여러 스레드에서 사용하는 변수를 적당한 lock으로 막아주지 않는다면, 스테일 상태에 빠지기 쉽다.
- lock은 상호배제(mutual exclusion) 뿐만 아니라 정상적인 메모리 가시성을 확보하기 위해서도 사용한다.
- 변경 가능하면서 공유되는 변수를 각 스레드에서 각자 최신의 정상적인 값으로 활용하려면 동일한 락을 사용해 모두 동기화시켜야 한다.
volatile 변수
volatile로 선언된 변수는 값을 바꿨을 때 다른 스레드에서 항상 최신 값을 읽어갈 수 있도록 한다.- 특정 변수를
volatile로 선언하면 컴파일러와 런타임 모두 ‘이 변수는 공유해 사용하기 때문에 실행 순서를 재배치해서는 안된다’ 라고 이해한다.- 프로세서의 레지스터에 캐시되지도 않고, 프로세서 외부의 캐시에도 들어가지 않는다.
- 따라서
volatile변수의 값을 읽으면 항상 다른 스레드가 보관해둔 최신의 값을 읽어갈 수 있다.
volatile변수를 사용할 때에는 아무런 락이나 동기화 기능이 동작하지 않기 때문에synchronized를 사용한 동기화보다는 아무래도 강도가 약할 수 밖에 없다.volatile변수가 갖는 가시성 효과는 변수 자체의 값에 대한 범위보다 약간 확장되어 있다.- 즉, 스레드 A가
volatile변수에 값을 써넣고 스레드 B가 해당 변수의 값을 사용하기 위해volatile변수의 값을 읽고 나면 스레드 A가 변수에 값을 쓰기 전에 볼 수 있었던 모든 변수의 값을 스레드 B도 볼 수 있다. - 메모리 가시성에 효과가 있기는 하지만
synchronized로 직접 동기화한 코드보다 훨씬 읽기가 어렵고 오류가 발생할 가능성도 높기 때문에volatile변수에 너무 의존하지 않는 것이 좋다.
- 즉, 스레드 A가
volatile변수를 사용하기 적절한 경우는 변수에 보관된 클래스의 상태에 대한 가시성을 확보하거나, 중요한 이벤트(초기화, 종료 등)가 발생했다는 등의 정보를 정확하게 전달하고자 하는 경우가 해당된다.volatile키워드를 사용하는 일반적인 예제를 확인해보자.
volatile boolean asleep;
...
while(!asleep) {
countSomeSheep();
}
- 특정 변수의 값을 확인해 반복문을 빠져나갈 상황인지 확인하고 있다.
-
asleep변수를volatile로 선언하지 않으면 다른 스레드가 변수 값을 바꿨을 때 변경된 상태를 확인하지 못할 수도 있기 때문에volatile로 선언해주어야 한다. - 일반적으로 위 예제와 같이 작업을 완료했다거나, interrupt가 걸리거나, 기타 상태를 보관하는 flag 변수에
volatile키워드를 지정한다. - lock을 사용하면 가시성과 연산의 단일성을 모두 보장받을 수 있다. 하지만 volatile 변수는 연산의 단일성은 보장하지 못하고 가시성만 보장한다.
- 따라서
volatile변수는 다음과 같은 상황에서만 사용하는 것이 좋다.- 변수에 값을 저장하는 작업이 현재 변수의 값과 관련이 없거나, 해당 변수의 값을 변경하는 스레드가 하나만 존재하는 경우
- 해당 변수가 객체의 불변조건에 관련되어 있지 않은 경우
- 해당 변수를 사용하는 동안에는 어떤 경우라도 락을 걸어둘 필요가 없는 경우
- 따라서
공개와 유출
- 특정 객체를 현재 코드의 scope 밖에서 사용할 수 있도록 만들면 공개(published) 되었다고 한다.
- 스코프 밖의 코드에서 볼 수 있는 변수에 스코프 내부의 객체에 대한 참조를 저장하거나
private이 아닌 메소드에서 호출한 메소드가 스코프 내부에서 생성한 객체를 리턴하거나- 다른 클래스의 메소드로 객체를 넘겨주는 경우 등
- 특정 객체를 공개해서 여러 부분에서 공유해 사용할 수 있도록 만드는 경우에는 반드시 해당 객체를 동기화시켜야 한다.
- 객체가 안정적이지 않은 상태에서 공개하면 스레드 안정성에 문제가 생길 수 있다.
- 이처럼 의도하지 않았지만 외부에 공개된 경우를 유출 상태(escaped) 라고 한다.
- 객체 공개 예제를 보자.
public static Set<Secret> knownSecrets;
public void initialize() {
knownSecrets = new HashSet<Secret>();
}
public static변수에 객체를 설정하면 가장 직접적인 방법으로 해당 객체를 모든 클래스와 모든 스레드에서 사용할 수 있도록 공개하는 셈이다.knownSecrets에 저장된HashSet객체는 스코프에 관계없이 완전히 공개된다.
- 만약
knownSecrets변수에Secret객체 인스턴스를 하나 추가한다면, 추가한 해당Secret인스턴스도 함께 공개된다. - 다음과 같이
private이 아닌 메소드를 호출해 변수를 받아오는 과정으로도 객체가 공개된다.
class UnsafeStates {
private String[] states = new String[] {
"AK", "AL", ...
};
public String[] getStates() {
return states;
}
}
private키워드로 숨겨져 있는states변수를 위와 같이 공개하면getStates()를 호출하는 쪽에서 숨겨진states변수의 값을 직접 변경할 수 있게 된다.- 즉,
states변수는 유출 상태에 놓여있다고 할 수 있다.
- 즉,
-
객체를 공개했을 때, 그 객체 내부의 private이 아닌 변수나 메소드를 통해 불러올 수 있는 모든 객체는 함께 공개된다는 점을 명시하자.
- 클래스에 정의는 되어있지만 그 기능이 만들어져 있지 않은 메소드를 에일리언 메소드 라고 한다.
- 예를 들어, 해당 클래스를 상속받으면서 오버라이드할 수 있는 메소드가 에일리언 메소드에 해당한다.
- 즉,
final로 지정되지 않고,private으로 지정되지도 않아야 한다.
- 어떤 객체를 에일리언 메소드에 인자로 넘겨주는 것도 넘겨준 객체를 공개하는 것과 같다.
- 에일리언 메소드를 누가 어떻게 구현할지 정확하게 알 수 없기 때문에, 넘겨 받은 객체를 다른 스레드에서 사용할 수 있도록 공개하지 않으리라는 보장이 없다.
- 다음 예제는 내부 클래스의 인스턴스를 외부에 공개하는 경우이다.
public class ThisEscape {
public ThisEscape(EventSource source) {
source.registerListener(
new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
}
);
}
}
- 내부 클래스는 항상 부모 클래스에 대한 참조를 갖고 있기 때문에
ThisEscape클래스가EventListener객체를 외부에 공개하면,EventListener를 포함하고 있는ThisEscape클래스도 외부에 공개된다.
생성 메소드 안정성
-
위 예제 코드는 생성 메소드를 실행하는 과정에
this변수가 외부에 유출된 경우이다. - 생성 메소드가 실행되는 도중에 해당 객체를 외부에 공개한다면 정상적이지 않은 상태의 객체를 외부에서 불러 사용할 가능성이 있다.
- 생성 메소드가 완전히 종료하고 난 이후가 되어야 객체의 상태가 개발자가 예상한 상태로 초기화되기 때문이다.
- 따라서 생성메소드를 실행하는 도중에는 this 변수를 외부에 유출되지 않게 해야한다.
- 생성 메소드에서
this변수를 유출시키는 가장 흔한 오류는 생성 메소드에서 새로운 스레드를 만들어 시작시키는 일이다.- 생성 메소드에서 만든 스레드의 클래스가 원래 클래스의 내부 클래스라면 자동으로 원래 클래스의
this변수를 공유하는 상태가 된다. - 생성 메소드에서 스레드를 ‘생성’하는 것은 별 문제가 없지만, 스레드를 생성과 동시에 ‘시작’시키는 건 문제의 소지가 많다.
- 스레드를 생성하면서 바로 시작시키기 보다는 스레드를 시작시키는 기능을
start나initialize등의 메소드로 만들어 사용하는 편이 좋다.
- 스레드를 생성하면서 바로 시작시키기 보다는 스레드를 시작시키는 기능을
- 생성 메소드에서 만든 스레드의 클래스가 원래 클래스의 내부 클래스라면 자동으로 원래 클래스의
- 생성 메소드에서 에일리언 메소드를 호출하는 경우에도
this변수가 외부에 유출될 가능성이 있다. - 클래스의 생성 메소드에서 EventListener를 등록하거나 새로운 스레드를 시작시키려면 다음과 같이 하는 것이 좋다.
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;
}
}
- 생성 메소드를
private으로 만들고,public으로 지정된 팩토리 메소드를 만들어 사용한다.
스레드 한정(Thread confinement)
- 변경 가능한 객체를 공유해 사용하는 경우에는 항상 동기화시켜야 한다.
- 특정 객체를 단일 스레드에서만 활용한다고 확신할 수 있다면 해당 객체는 따로 동기화할 필요가 없다.
- 이처럼 객체를 사용하는 스레드를 한정하는 방법으로 스레드 안정성을 확보할 수 있다.
- 스레드 한정 기법을 사용하는 사례에는 JDBC의
Connection객체를 pulling하여 사용하는 경우이다.- pool에서 DB 연결을 확보하고, 확보한 DB 연결로 요청 하나를 처리한 다음 사용한 연결을 다시 반환하는 과정을 거친다.
- 서블릿 요청은 대부분 단일 스레드에서 처리하고, DB pool은 한쪽에서 DB 연결을 사용하는 동안에는 해당 연결을 다른 스레드가 사용하지 못하게 막기 때문에 특정
Connection을 한번에 하나 이상의 스레드가 사용하지 못하도록 한정할 수 있다.
스택 한정
- 스택 한정 기법은 특정 객체를 로컬 변수를 통해서만 사용할 수 있는 특별한 경우를 의미한다.
- 클래스 내부에 숨겨둔 변수는 특정 스레드에 쉽게 한정시킬 수 있다.
- 로컬 변수는 모두 암묵적으로 현재 실행중인 스레드에 한정되어 있다고 볼 수 있기 때문이다.
- 스택 한정 기법 예제를 살펴보자.
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;
}
numPairs와 같은 기본 변수형을 사용하는 로컬 변수는 객체처럼 참조되는 값이 아니기 때문에 언어적으로 스택 한정 상태가 보장된다.- 객체형 변수가 스택 한정 상태를 유지할 수 있게 하려면 해당 객체에 대한 참조가 외부로 유출되지 않도록 해야 한다.
- 만약 메소드 내에서 생성한
TreeSet인스턴스에 대한 참조를 외부에 공개한다면 스택 한정 상태가 깨질 수 밖에 없다.
- 만약 메소드 내에서 생성한
- Thread-safe 하지 않은 객체라고 해도 특정 스레드 내부에서만 사용한다면 동기화 문제가 없기 때문에 안전하다.
ThreadLocal
- ThreadLocal은 스레드 내부의 값과 값을 갖고 있는 객체를 연결해 스레드 한정 기법을 적용할 수 있도록 도와주는 방법이다.
- ThreadLocal 클래스의
get과set메소드는 호출하는 스레드마다 다른 값을 사용할 수 있도록 관리해준다.- 즉, ThreadLocal 클래스의
get메소드를 호출하면 현재 실행 중인 스레드에서 최근에set으로 저장했던 값을 가져올 수 있다.
- 즉, ThreadLocal 클래스의
- 변경 가능한 singleton이나 전역 변수 등을 기반으로 설계되어 있는 구조에서 변수가 임의로 공유되는 상황을 막기 위해 사용하는 경우가 많다.
- 예를 들어, JDBC 연결은 스레드에 안전하지 않기 때문에 멀티스레드 어플리케이션에서 적절한 동기화 없이 연결 객체를 전역 변수로 만들어 사용하면 어플리케이션 역시 스레드 안전하지 않다.
- 아래 예제와 같이 JDBC 연결을 보관할 때 ThreadLocal을 사용하면 스레드는 저마다 각자의 연결 객체를 갖게 된다.
private static ThreadLocal<Connection> connectionHolder =
new ThreadLocal<Connection>() {
public Connection initialValue() {
return DriverManager.getConnection(DB_URL);
}
};
public static Connection getConnection() {
return connectionHolder.get();
}
- 이 같은 방법은 자주 호출되는 메소드에서 임시 버퍼와 같은 객체를 만들어 사용해야 하는데, 객체를 매번 새로 생성하는 대신 이미 만들어진 객체를 재활용하고자 할 때 많이 사용한다.
- 특정 스레드가
get메소드를 처음 호출한다면initialValue()메소드에서 초기 값을 넘겨준다. -
스레드 별 값은
Thread객체 자체에 저장되어 있고, 스레드가 종료되면 가비지 컬렉터가 처리한다. - ThreadLocal 클래스는 어플리케이션 프레임워크를 구현할 때 많이 사용된다.
- 스레드 단위로 트랜잭션 컨텍스트를 관리하고자 할 때는
static으로 선언된 ThreadLocal 변수에 트랜잭션 컨텍스트를 넣어두면 편리하다. - 즉, 트랜잭션 컨텍스트가 보관되어 있는 ThreadLocal 클래스에서 현재 진행 중인 트랜잭션을 찾을 수 있다.
- 스레드 단위로 트랜잭션 컨텍스트를 관리하고자 할 때는
불변성
- 지금까지 봤던 연산의 단일성이나 가시성에 대한 거의 모든 문제는 여러 개의 스레드가 예측할 수 없는 방향으로 변경 가능한 값을 동시에 사용하려 하기 때문에 발생한다.
- 그런데 만약 객체의 상태가 변하지 않는다고 가정한다면, 지금까지 발생했던 문제가 사라진다.
- 불변 객체는 맨 처음 생성되는 시점을 제외하고는 그 값이 전혀 바뀌지 않는 객체를 말한다.
- 따라서 불변 객체는 언제나 스레드 안전한 상태이다.
- 불변 객체는 객체의 상태가 변경되는 경우에 따로 대비할 필요 없이 어디에든 공개하고 공유해 사용할 수 있다.
- 다음 조건을 만족하면 해당 객체는 불변 객체이다.
- 생성되고 난 이후에는 객체의 상태를 변경할 수 없다.
- 내부의 모든 변수는
final로 설정되어야 한다. - 적절한 방법으로 생성되어야 한다.
- 다음 예제로 불변 객체 예시를 보자.
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변수의 값을 변경할 수 없도록 되어있다.
- 생성 메소드에서
this변수에 대한 참조가 외부로 유출될만한 일을 전혀 하고 있지 않기 때문에ThreeStooges는 불변 객체라고 볼 수 있다. - ‘객체’가 불변이라는 것과 ‘참조’가 불변이라는 것은 구분해서 생각해야 한다.
- 어떤 참조 변수에 불변 객체가 들어있다 해도, 해당 참조 변수에 또 다른 불변 객체를 바꿔치기하면 데이터가 바뀌는 셈이다.
final 변수
final로 지정한 변수의 값은 변경할 수 없다.final키워드를 적절하게 사용하면 초기화 안정성을 보장하기 때문에 별다른 동기화 작업 없이도 불변 객체를 자유롭게 사용하고 공유할 수 있다.
- 참조하는 객체가 불변 객체가 아니더라도 변수를
final로 지정하면 해당 변수에 어떤 값이 들어갈 수 있는지에 대해 고려해야 할 범위가 줄어든다.- 나중에 변경할 일이 없다고 판단되는 변수는
final로 선언해두는 것이 좋다.
- 나중에 변경할 일이 없다고 판단되는 변수는
예제 : 불변 객체를 공개할 때 volatile 키워드 사용
- 앞의 인수분해 서블릿 예제에서
AtomicReference로 최근 입력 값과 최근 결과값을 저장했었다.- 이는 두 값을 단일 연산으로 읽거나 쓸 수 없기 때문에 Thread-safe 하지 않았다.
- 불변 객체를 활용하면 어느정도까지는 연산의 단일성을 보장할 수 있다.
- 인수분해 서블릿에서는 단일 연산으로 처리해야 하는 작업이 두가지 있다.
- 캐시 값을 보관하는 작업과
- 캐시된 값이 요청한 값에 해당하는 경우, 보관되어 있던 캐시 값을 읽어오는 작업이다.
- 여러 개의 값이 단일하게 한꺼번에 행동해야 한다면 여러 개의 값을 한데 묶는 불변 클래스를 만들어 사용하는 것이 좋다.
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;
}
return Arrays.copyOf(lastFactors, lastFactors.length);
}
}
- 서로 관련되어 있는 여러 개의 변수 값을 서로 읽거나 쓰는 과정에 race condition이 발생할 수 있다.
- 불변 객체에 해당하는 변수들을 모두 모아두면 race condition을 방지할 수 있다.
- 불변 객체가 아닌 일반 객체를 사용하면 lock을 사용해야 연산의 단일성을 보장할 수 있다.
- 만약 불변 객체 내부에 있는 변수 값을 변경하면, 새로운 불변 객체가 만들어지기 때문에 기존에 변수 값이 변경되기 전의 불변 객체를 사용하는 스레드는 아무 이상 없이 동작할 수 있다.
- 아래 예제는
OneValueCache클래스를 사용해 입력 값과 결과 값을 캐시한다.
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);
}
}
- 한 스레드가
volatile로 선언된cache변수에 새로 생성한OneValueCache인스턴스를 설정하면, 다른 스레드에서도cache변수에 설정된 새로운 값을 즉시 사용할 수 있다. - 위 클래스는 변경할 수 없는 상태 값을 갖고 있는 불변 객체인데다
volatile키워드를 사용해 시간적으로 가시성을 확보했기 때문에 따로 락을 사용하지 않아도 Thread-safe 하다.
안전 공개
- 상황에 따라 객체를 여러 스레드에서 공유하도록 공개해야 할 상황일 수 있는데, 이럴 때는 반드시 안전한 방법을 사용해야 한다.
- 객체를 공개하는 안전하지 않은 단순한 방법을 살펴보자.
public Holder holder;
public void initialize() {
holder = new Holder(42);
}
- 객체에 대한 참조를
public변수에 넣어 공개하는 것은 그다지 안전한 방법이 아니다. - 가시성 문제때문에
Holder클래스가 안정적이지 않은 상태에서 외부 스레드에게 노출될 수 있다.- 또한 생성 메소드가 채 끝나기도 전에 공개된 객체를 다른 스레드가 사용할 수 있다.
적절하지 않은 공개 방법 : 정상적인 객체도 문제를 일으킨다
- 생성 메소드가 실행되고 있는 상태의 인스턴스를 다른 스레드가 사용하려 한다면, 비정상적인 상태 그대로 사용될 가능성이 있다.
- 위 예제에서 안전하지 않은 방법으로 공개한
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");
}
}
}
- 객체를 공개하는 스레드가 아닌 다른 스레드에서
assertSanity메소드를 호출하면AssertionError가 발생할 수 있다. - 객체를 올바르지 않게 공개하면 두 가지 문제가 발생할 수 있다.
holder변수에 스테일 상태가 발생할 수 있다.- 다른 스레드는 모두
holder변수에서 정상적인 참조 값을 가져갈 수 있지만Holder클래스의 입장에서 스테일 상태에 빠질 수 있다.
불변 객체와 초기화 안정성
- Java Memory Model에는 불변 객체를 공유하고자 할 때 초기화 작업을 안전하게 처리할 수 있는 방법이 만들어져 있다.
- 안전하게 초기화 과정을 진행하려면 앞에서 봤던 불변 객체 요구 조건을 만족 시켜야 한다.
- 불변 객체는 별다른 동기화 방법을 적용하지 않더라도 항상 안전하게 올바른 참조 값을 사용할 수 있다.
- 즉, 올바른 방법으로 생성된 불변 객체는 별다른 동기화 작업 없이도 안전하게 사용할 수 있다.
안전한 공개 방법의 특성
- 불변 객체가 아닌 객체는 모두 올바른 방법으로 안전하게 공개해야 한다.
- 객체를 안전하게 공개하려면 해당 객체에 대한 참조와 객체 내부의 상태를 외부 스레드에게 동시에 보일 수 있어야 한다.
- 생성 메소드가 올바르게 실행되고 난 객체는 다음과 같은 방법으로 안전하게 공개할 수 있다.
- 객체에 대한 참조를
static메소드에서 초기화시킨다. - 객체에 대한 참조를
volatile변수 또는AtomicReference클래스에 보관한다. - 객체에 대한 참조를 올바르게 생성된 클래스 내부의
final변수에 보관한다. - lock을 사용해 올바르게 공유된 변수에 객체에 대한 참조를 보관한다.
- 예를 들어,
Vector객체나synchronizedList메소드를 이용해 동기화된 thread-safe한 컬렉션을 만들어 객체를 보관할 수 있다.
- 예를 들어,
- 객체에 대한 참조를
- 생성 메소드가 올바르게 실행되고 난 객체는 다음과 같은 방법으로 안전하게 공개할 수 있다.
- 스레드A가 객체 X를 thread-safe한 컬렉션에 보관하고, 스레드 B가 객체 X를 읽어가려는 상황을 생각해보자.
- 스레드 A와 B에 별도의 동기화 코드를 작성하지 않아도 스레드 B는 스레드 A가 저장한 객체 X를 정확하게 읽어갈 수 있다.
- 자바에서 기본으로 제공하는 thread-safe한 collection은 다음과 같이 스레드 동기화 기능을 갖고 있다.
Hashtable,ConcurrentMap,synchronizedMap을 사용해 만든 map 객체- 컬렉션에 보관하고 있는 key, value 모두를 어느 스레드에서라도 항상 안전하게 사용할 수 있다.
Vector,CopyOnWriteArrayList,CopyOnWriteArraySet,synchronizedList또는synchronizedSet을 사용해 만든 컬렉션- 컬렉션에 보관하고 있는 객체를 어느 스레드에서라도 항상 안전하게 사용할 수 있다.
BlockingQueue,ConcurrentLinkedQueue
- 다음과 같이
static변수 선언시에 직접new연산자로 생성 메소드를 실행해 객체를 생성하는 것이 가장 쉬우면서도 안전한 객체 공개 방법이다.
public static Holder holder = new Holder(42);
static초기화는 JVM에서 클래스를 초기화하는 시점에 진행되는데, JVM 내부에서 동기화가 맞춰져 있기 때문이다.
결과적으로 불변인 객체
- 특정 객체를 안전한 방법으로 공개했을 경우, 해당 객체 내부의 값이 바뀌지 않는 한 여러 스레드에서 동시에 값을 가져다 사용해도 동기화 문제가 발생하지 않는다.
- 예를 들어,
Date클래스는 불변 객체가 아니지만, 불변 객체인 것 처럼 사용하면 동기화 작업을 하지 않아도 된다.
public Map<String, Date> lastLogin = Collections.synchronizedMap(new HashMap<String, Date>());
- 위 코드는 사용자별로 최근 로그인한 시각을
Map에 저장해두는 코드이다. Map에 한번 들어간Date인스턴스의 값이 더이상 바뀌지 않는다면synchronizedMap을 사용하는 것만으로 동기화 작업이 충분하다.
가변 객체
- 가변 객체를 사용할 때에는 공개하는 부분과 가변 객체를 사용하는 모든 부분에서 동기화 코드를 작성해야 한다.
- 즉, 가변성에 따라 객체를 공개할 때 주의할 점은 다음과 같다.
- 불변 객체는 어떤 방법으로 공개해도 아무 문제 없다.
- 결과적으로 불변인 객체는 안전하게 공개해야 한다.
- 가변 객체는 안전하게 공개해야 하고, 스레드에 안전하게 만들거나 락으로 동기화시켜야 한다.
객체를 안전하게 공유하기
- 병렬 프로그램에서 객체에 대한 참조를 가져다 사용하는 부분이 있다면, 그 객체로 어느 정도의 일을 할 수 있는지를 정확하게 알고 있어야 한다.
- 병렬 프로그램에서 객체를 공유해 사용하고자 할 때 가장 많이 사용되는 몇가지 원칙은 다음과 같다.
- 스레드 한정 : 스레드에 한정된 객체는 완전하게 해당 스레드 내부에 존재하고, 해당 스레드에서만 사용 가능하다.
- 읽기 전용 객체를 공유
- 불변 객체와 결과적으로 불변인 객체가 읽기 전용 객체에 해당한다.
- 동기화 작업을 하지 않더라도 여러 스레드에서 마음껏 값을 읽어 사용할 수 있지만, 값을 변경할 수는 없다.
- 스레드에 안전한 객체를 공유 : 객체 내부적으로 필수적인 동기화 기능이 만들어져 있기 때문에 외부에서 동기화를 신경 쓸 필요가 없이 여러 스레드에서 마음껏 사용할 수 있다.
- 동기화 방법 적용 : 지정한 lock을 획득하기 전까지는 해당 객체를 사용할 수 없다.