[Java Concurrency] 자바 메모리 모델 |
-
자바 메모리 모델(JMM, Java Memory Model) 의 내부 구조가 어떻게 동작하는지를 이해하고 있다면
상위 개념을 훨씬 효율적으로 쉽게 사용할 수 있을 것이다.
16.1. 자바 메모리 모델은 무엇이며, 왜 사용해야 하는가?
-
특정 스레드에서 aVariable 이라는 변수에 값을 할당한다고 해보자.
aVariable = 3;
자바 메모리 모델은 "스레드가 aVariable에 할당된 3이란 값을 사용할 수 있으려면 어떤 조건이 돼야 하는가?" 에 대한 답을 알고 있다.
동기화 기법을 사용하지 않는 상태라면 특정 스레드가 값이 할당되는 즉시, 심지어는 영원히 3이라는 값을 읽어가지 못하게 하는 여러 가지 상황이 발생할 수 있다.
-
JMM 은 변수에 저장된 값이 어느 시점부터 다른 스레드의 가시권에 들어가는지에 대해 JVM 이 해야만 하는 최소한의 보장만 할 뿐이다.
JMM 은 예측성에 대한 필요와 함께 높은 성능의 JVM 을 다양한 종류의 프로세서 구조에서 동작하도록 해야 한다는
실제적인 요구 사항을 쉽게 구현할 수 있어야 한다는 점의 균형을 맞출 목적으로 설계됐다.
특히 JMM 의 일부는 JVM 에서 실행되는 프로그램의 성능을 최대한 끌어낼 수 있도록
최신 프로세서와 컴파일러에서 사용하는 여러 기법을 사용하고 있다.
* 16. 1. 1. 플랫폼 메모리 모델
-
메모리를 공유하는 멀티프로세서 시스템은 보통 각자의 프로세서 안에 캐시 메모리를 갖고 있으며,
캐시 메모리의 내용은 주기적으로 메인 메모리와 동기화된다.
하드웨어 프로세서 아키텍쳐는 저마다 다른 캐시 일관성(cache coherence)을 지원한다.
-
일부 시스템에서는 어느 시점이건 간에 동일한 순간에 같은 메모리 위치에서 각 프로세서가 서로 다른 값을 읽어가는 경우를 허용하기도 한다.
운영체제와 컴파일러와 자바 런타임, 때로는 프로그램가지도 서로 다른 하드웨어에서 제공하는 기능과 스레드 안전성에 대한 차이점을 메울 수 있어야 한다.
-
멀티프로세서 시스템에서 각 프로세서가 서로 다른 프로세서가 하는 일을 모두 알 수 있도록 하려면
굉장한 부하를 안고 가야 한다.
대부분의 경우 다른 프로세서가 어떤 일을 하고 있는지에 대한 정보는 별로 필요도 없기 때문에
프로세서는 대부분 성능을 높이고자 캐시 메모리의 일관성을 약간씩 희생하곤 한다.
-
시스템에서 말하는 메모리 모델은 프로그램이 메모리 구조에서 어느 정도의 기능을 사용할 수 있을지에 대한 정보를 제공하고,
메모리의 내용을 서로 공유하고자 할 때 프로세서간의 작업을 조율하기 위한 특별한 명령어(메모리 베리어(memory barrier) 또는 팬스(fence))로는 어떤 것들이 있으며 어떻게 사용해야 하는지에 대한 정보도 제공한다.
-
자바 개발자가 서로 다른 하드웨어가 갖고 있는 각자의 메모리 모델을 직접 신경 쓰지 않도록 자바는 스스로의 메모리 모델인 JMM 을 구성하고 있으며, JMM 과 그 기반이 되는 하드웨어 메모리 모델의 차이점은 메모리 배리어를 적절히 활용하는 방법 등으로 JVM 에서 담당해 처리한다.
-
프로그램이 실행되는 내용을 예상하기에 가장 간편한 방법은
하드웨어 프로세서에 상관 없이 프로그램 내부에 작성된 코드가 실행되는 단 한 가지의 방법이 존재하며,
프로그램이 실행되는 과정에서 변수에 마지막으로 설정한 값을 어떤 프로세서건 간에 정확하게 읽어낼 수 있다고 가정하는 방법이다.
비현실적이긴하지만 이처럼 꿈같이 간편한 상태를 순차적 일관성(sequential consistency) 라고 부른다.
소프트웨어 개발자는 무의식적으로 순차적 일관성이 존재한다고 가정해버리는 경우가 많은데,
현재 사용중인 어떤 프로세서도 순차적 일관성을 지원하지 않으며 JMM 역시 지원하지 않는다.
-
메모리를 공유해 사용하는 멀티프로세서 시스템(컴파일러도..)에서는 여러 스레드에서 데이터를 공유하는 상황에서
메모리 배리어를 사용하지 않도록 일부러 지정한다면 놀랄만한 문제점이 쏟아질 것이다.
다행히도 자바로 프로그램을 작성하는 과정에서 메모리 배리어를 어디에 어떻게 배치해야 하는지를 고민할 필요는 없다.
단지 프로그램 내부에서 동기화 기법을 적절히 활용해 어느 시점에서 공유된 정보를 사용하는지만 알려주면 된다.
* 16.1.2. 재배치
-
JMM 은 서로 다른 스레드가 각자의 상황에 맞는 순서로 명령어를 실행할 수 있도록 허용하고 있다.
특정 작업이 지연되거나 다른 순서로 실행되는 것처럼 보이는 문제는 "재배치(reordering)" 이라는 용어로 통일해서 표현한다.
-
동기화가 제대로 되지 않은 상태에서 재배치될 가능성을 예측하는 일은 너무나 어려우며,
반대로 동기화 방법을 적절하게 사용해 재배치 가능성을 없애는 편이 더 쉽다.
동기화가 잘 된 상태에서는 컴파일러, 런타임, 하드웨어 모두 JMM 이 보장하는 가시성 수준을 위반하는 쪽으로 메모리 관련 작업을 재배치하지 못하게 한다.
* 16.1.3. 자바 메모리 모델을 간략하게 설명한다면
-
변수를 읽거나 쓰는 작업, 모니터를 잠그거나 해제하는 작업, 스레드를 시작하거나 끝나기를 기다리는 작업과 같이 여러 가지 작업에 대해 자바 메모리 모델(JMM)을 정의한다.
JMM 에서는 프로그램 내부의 모든 작업을 대상으로 미리 발생(happens-before)라는 부분 재배치(partial reordering) 연산을 정의하고 있다.
-
작업 A 가 실행된 결과를 작업 B에서 볼 수 있다는 점을 보장하기 위해
작업 A와 B사이에는 미리 발생관계가 갖춰져야 한다.
두 개 작업 간에 미리 발생 관계가 갖춰져 있지 않다면
JVM 은 원하는 대로 해당 작업을 재배치할 수 있게 된다.
-
하나의 변수를 두 개 이상의 스레드에서 읽어가려고 하면서 최소한 하나 이상의 스레드에서 쓰기 작업을 하지만,
쓰기 작업과 읽기 작업 간에 미리 발생 관계가 갖춰져 있지 않은 경우에 데이터 경쟁(data race) 현상이 발생한다.
이와 같은 데이터 경쟁 현상이 발생하지 않는 프로그램을 "올바르게 동기화된 프로그램(correctly synchronized program) 이라고 말한다.
올바르게 동기화된 프로그램은 순차적 일관성을 갖고 있으며, 프로그램 내부의 모든 작업이 고정된 전역 순서(global order)에 따라 실행된다는 것을 의미한다.
-
미리 발생 현상에 대한 규칙은 다음과 같다.
프로그램 순서 규칙
특정 스레드를 놓고 봤을 때 프로그램된 순서에서 앞서있는 작업은 동일 스레드에서 뒤에 실행되도록 프로그램된 작업보다 미리 발생한다.
모니터 잠금 규칙
특정 모니터 잠금 작업이 뒤이어 오는 모든 모니터 잠금 작업보다 미리 발생한다.
volatile 변수 규칙
volatile 변수에 대한 쓰기 작업은 이후에 따라오는 해당 변수에 대한 모든 읽기 작업보다 미리 발생한다.
스레드 시작 규칙
특정 스레드에 대한 Thread.start 작업은 시작된 스레드가 갖고 있는 모든 작업보다 미리 발생한다.
스레드 완료 규칙
스레드 내부의 모든 작업은 다른 스레드에서 해당 스레드가 완료됐다는 점을 파악하는 시점보다 미리 발생한다.
특정 스레드가 완료됐는지를 판단하는 것은 Thread.join 메소드가 리턴되거나, Thread.isAlive 메소드가 false 를 리턴하는지 확인하는 방법을 말한다.
인터럽트 규칙
다른 스레드를 대상으로 interrupt 메소드를 호출하는 작업은
인터럽트 당한 스레드에서 인터럽트를 당했다는 사실을 파악하는 일보다 미리 발생한다
인터럽트를 당했다는 사실을 파악하려면 InterruptedException 을 받거나 isInterrupted 메소드 또는 interrupted 메소드를 호출하는 방법을 사용할 수 있다.
완료 메소드(finalizer) 규칙
특정 객체에 대한 생성 메소드가 완료되는 시점은 완료 메소드가 시작하는 시점보다 미리 발생한다.
전이성(transitivity)
A가 B보다 미리 발생하고, B가 C보다 미리 발생한다면,
A는 C보다 미리 발생한다.
* 16.1.4. 동기화 피기백
-
코드의 실행 순서를 정하는 면에서 미리 발생 규칙이 갖고 있는 능력의 수준 때문에
현재 사용 중인 동기화 기법의 가시성(visibility)에 얹혀가는 방법, 즉 피기백(piggyback)하는 방법도 있다.
다시 말해 락으로 보호돼 있지 않은 변수에 접근해 사용하는 순서를 정의할 때,
모니터 락이나 volatile 변수 규칙과 같은 여러 가지 순서 규칙에 미리 발생 규칙을 함께 적용해 순서를 정의하는 방법을 말한다.
이런 기법은 명령이 나열된 순서에 굉장히 민감하며 따라서 오류가 발생하기 쉽다.
이런 방법은 ReentrantLock 과 같이 성능에 중요한 영향을 미치는 클래스에서 성능을 떨어뜨릴 수 있는 아주 작은 요인까지 완벽하게 제거해야 하는 상황이 오기 전까지는 사용하지 않는 편이 좋다.
-
JDK 라이브러리에 들어 있는 클래스 가운데 미리 발생 관계를 보장하고 있는 클래스로는 다음과 같은 것들이 있다.
* 스레드 안전한 컬렉션 클래스에 값을 넣는 일은 해당 컬렉션 클래스에서 값을 뽑아내는 일보다 반드시 미리 발생한다.
* CountDownLatch 클래스에서 카운트를 빼는 작업은 await 에서 대기하던 메소드가 리턴되는 작업보다 반드시 미리 발생한다.
* Semaphore 에서 퍼밋을 해제하는 작업은 동일한 Semaphore 에서 퍼밋을 확보하는 작업보다 반드시 미리 발생한다.
* Future 인스턴스에서 실행하는 작업은 해당하는 Future 인스턴스의 get 메소드가 리턴되기 전에 반드시 미리 발생한다.
* Executor 인스턴스에 Runnable 이나 Callable 을 등록하는 작업은 해당 Runnable 이나 Callable 의 작업이 시작하기 전에 미리 발생한다.
* CyclicBarrier 나 Exchanger 클래스에 스레드가 도착하는 일은 동일한 배리어나 교환 포인트에서 다른 스레드가 풀려나는 일보다 미리 발생한다.
CyclicBarrier 에서 배리어 동작을 사용하고 있었다면, 배리어에 도착하는 일이 배리어 동작보다 반드시 미리 발생하고,
배리어 동작은 또한 해당 배리어에서 다른 스레드가 풀려나기 전에 반드시 미리 발생한다.
16.2. 안전한 공개
* 16.2.1. 안전하지 못한 공개
-
미리 발생 관계를 제대로 고려하지 못한 상태에서 재배치 작업이 일어날 수 있다는 가능성을 놓고 보면,
적절한 동기화 구조를 갖추지 못하고 공개된 객체를 두고 다른 스레드에서 부분 구성된 객체(partially constructed object)를 볼 수밖에 없는 원인이 쉽게 설명된다.
-
프로그램상에서 공유된 참조를 공개하는 일이 다른 스레드에서 해당 참조를 읽어가는 일보다 미리 발생하도록 확실하게 해두지 않으면 새로운 객체에 대한 참조에 값을 쓰는 작업과 객체 내부의 변수에 값을 쓰는 과정에서 재배치가 일어날 수 있다.
이와 같이 재배치가 일어나면 다른 스레드에서 객체 참조는 올바른 최신 참조 값을 사용하지만, 객체 내부의 변수 전체 또는 일부에 대해서는 아직 쓰기 작업이 끝나지 않은 상태의 예전 값을 사용할 가능성이 있다.
바로 부분 구성된 객체라는 현상이 발생하는 것이다.
-
불변 객체가 아닌 이상, 특정 객체를 공개하는 일이 그 객체를 사용하려는 작업보다 미리 발생하도록 구성돼 있지 않다면
다른 스레드에서 생성한 객체를 사용하는 작업은 안전하지 않다.
* 16.2.2. 안전한 공개
-
안전한 공개(safe publication)라는 용어는 객체를 공개하는 작업이 다른 스레드에서 해당 객체에 대한 참조를 가져다 사용하는 작업보다 미리 발생하도록 만들어져 있기 때문에 공개된 객체가 다른 스레드에게 올바른 상태로 보인다는 것을 뜻한다.
-
일반적으로 프로그램을 작성할 때는 개별적으로 메모리에 쓰기 작업이 일어난 이후의 가시성을 놓고 안전성을 논하기보다는
객체의 소유권을 넘겨주고 공개하는 작업이 훨씬 적합하다.
미리 발생 규칙은 개별적인 메모리 작업의 수준에서 일어나는 순서의 문제를 다룬다.
반대로 안전한 공개 기법은 일반적인 코드를 작성할 때와 비슷한 수준에서 동작하는 동기화 기법이다.
* 16.2.3. 안전한 초기화를 위한 구문
-
늦은 초기화(lazy initialization) 기법을 잘못 사용하면 문제가 발생한다.
-
static 으로 선언된 초기화 문장은 JVM 에서 해당 클래스를 읽어들이고 실제 해당 클래스를 사용하기 전에 실행된다.
이런 초기화 과정에서 JVM 이 락을 확보하며 각 스레드에서 해당 클래스가 읽혀져 있는지를 확인하기 위해 락을 다시 확보하게 돼 있다.
따라서 JVM 이 락을 확보한 상태에서 메모리에 쓰여진 내용은 모든 스레드가 볼 수 있다.
결국 static 구문에서 초기화하는 객체는 생성될 때나 참조될 때 언제든지 따로 동기화를 맞출 필요가 없다.
* 16.2.4. 더블 체크 락
-
악명 높은 피해야 할 패턴인 더블 체크 락(DCL, double-checked locking) 패턴이 있다.
굉장히 초기에 사용하던 JVM 은 경쟁이 별로 없는 상태라고 해도 동기화를 맞추려면 성능에 엄청난 영향을 주었다.
그 결과 동기화 기법이 주는 영향을 최소화하고자 하는 여러 가지 기발한 방법이 나타나기 시작했다.
DCL 은 정말 문제 많은 방법에 속하는 놈 중 하나이다.
-
DCL 은 먼저 동기화 구문이 없는 상태로 초기화 작업이 필요한지를 확인하고,
초기화가 되어 있다면 참조된 객체를 사용한다.
만약 초기화 작업이 필요하다면 동기화 구문을 사용해 락을 걸고 객체를 초기화해야 하는지 다시 한번 확인하는데,
이렇게 하면 초기화하는 작업은 한 번에 하나의 스레드만 가능하긴 하다.
여기에서 가장 자주 사용하는 부분인, 이미 만들어진 객체에 대한 참조를 가져오는 부분은 동기화돼 있지 않았다는 이 부분이 문제이다.
이럴 경우 부분 구성된 인스턴스를 사용하게 될 가능성이 높다.
-
자바 5.0 이후의 JMM 내용을 보면 공유변수를 volatile 로 선언했을 때는 DCL 이 정상적으로 동작한다고 한다.
-
어쨌거나 DCL 이 해결하고자 했던 바는 이미 시대가 지나면서 대부분 사라졌으며,
더 이상 최적화의 의미를 찾기가 어려워졌다.
하지만 늦은 초기화 홀더 클래스 구문은 DCL 보다 훨씬 이해하기도 쉬우면서 동일한 기능을 제공한다.
16.3. 초기화 안전성
-
초기화 안전성이 확보돼 있다면 안전하게 구성된 객체를 대상으로 해당 객체가 어떻게 공개됐던 간에 생성 메소드가 지정하는 모든 final 변수의 값을 어떤 스레드건 간에 올바르게 읽어갈 수 있다는 점을 보장한다.
또한 완전하게 구성된 객체 내부에 final 로 선언된 객체를 거쳐 사용할 수 있는 모든 변수(예를 들어 final 로 선언된 배열의 항목 또는 final 로 선언된 HashMap 내부에 들어 있는 값 등) 역시 다른 스레드에서 안전하게 볼 수 있다는 점도 보장된다.
-
초기화 안전성은 생성 메소드가 완료되는 시점에 final 로 선언된 변수와 해당 변수를 거쳐 접근할 수 있는 값에 대해서만 가시성을 보장한다.
final 로 선언되지 않은 변수나 생성 메소드가 종료된 이후에 변경되는 값에 대해서는 별도의 동기화 구문을 적용해야 가시성을 확보할 수 있다.
Summary
-
자바 메모리 모델은 특정 스레드에서 메모리를 대상으로 취하는 작업이 다른 스레드에게 어떻게 보이는지의 여부를 명시하고 있다.
가시성을 보장해주는 연산은 미리 발생이라는 규칙을 통해 부분적으로 실행 순서가 정렬된 상태를 유지하며,
미리 발생 규칙은 개별적인 메모리 작업이나 동기화 작업의 수준에서 정의하는 규칙이다.
충분히 동기화되지 않은 상태에서는 공유된 데이터를 여러 스레드에서 사용할 때는 굉장히 이상한 현상이 발생할 수 있다.
어쨌거나 2장과 3장에서 소개했던 @GuardedBy 나 안전한 공개 등 고수준의 방법을 적용하면 미리 발생 규칙과 같은 저수준의 세밀한 부분까지 신경 쓰지 않는다 해도 스레드 안전성을 보장할 수 있다.
'프로그래밍 놀이터 > 안드로이드, Java' 카테고리의 다른 글
[Java] Condition 은 어떻게 쓰는걸까? 예를 통해 함 보자. (0) | 2017.05.12 |
---|---|
[Java Concurrency] 목차 정리 (0) | 2017.05.11 |
[Java Concurrency] 단일 연산 변수와 넌블로킹 동기화 (0) | 2017.05.09 |
[Java Concurrency] 동기화 클래스 구현 (0) | 2017.05.08 |
[Java Concurrency] 명시적인 락 (0) | 2017.05.05 |
댓글