Java Memory Model
Java Memory Model(JMM)은 JVM 내에서 메모리에 데이터를 읽고(read) 쓸 때(write)의 규칙을 말한다.
JMM는 메모리를 두 가지의 공간 개념으로 다룬다.
- 하나는 스레드 간 공유되는 메모리로,
- 다른 하나는 스레드 내에서만 사용되는 메모리로 다룬다.
JVM에서 스레드 간 공유되는 영역은 Heap(Method Area도 포함)이다.
스레드 내에서만 사용되는 영역은 JVM Stack이다. (JVM Stack 내의 Stack Frame들을 아우르는 스택 영역이라 생각하자.)
메모리 모델의 이해를 위해 쉬운 예시를 들어보자
가정
Heap에는 `Object 1` 객체와 `Object 2` 객체가 들어있다.
메서드 내용은 primitive 타입인 int 변수를 생성하고, `Object 2` 객체의 참조(reference)를 읽는 것이다.
public void something() {
int val = 2;
Object2 obj2 = MyObject.getSecondObject();
}
이 메서드의 호출을 두 스레드에서 실행하게 했다. (Thread-1(`스레드 1`), Thread-2(`스레드 2`))
작업 실행
두 스레드는 동일한 메서드에 의해 각각 작업을 실행한다.
`스레드 1`과 `스레드 2`는 내부에 primitive 타입인 int 지역 변수를 생성한다. 그다음 Heap에 있는 `Object 2`의 참조를 지역 변수(local variable)로 복사해 자신의 스택 영역에 저장한다.
결과
두 스레드가 갖고 있는 스택은 스레드 자신만 접근 가능한 작업 공간이다.
primitive 타입의 int는 각 스택에서 같은 값(`2`)을 가질지라도, 서로 독립된 공간에 있기 때문에 각각의 공간(스택 영역)에 저장된다. 마찬가지로 두 스레드가 각각 저장한 지역 변수 `Object 2`의 참조(reference)는 각각의 스택 영역에 저장된다.
하지만, 두 스레드에 생성된 `Object 2`에 대한 참조(reference)가 가리키는 것은 Heap에 있는 단 하나의 동일한 `Object 2`를 가리킨다. `Object 2` 객체가 두 스레드에 공유되고 있는 것이다.
다시 말해, 두 스레드가 동시에 `Object 2`를 접근할 수 있으며, `Object 2` 내부의 필드에 읽기(read)와 쓰기(write) 작업을 수행할 수 있다는 뜻이다.
명령어 재정렬 문제
JMM에서 발생하는 동시성 문제 중 하나다. 재정렬은 무엇인가?
재정렬(reorder)
CPU 또는 컴파일러에 의해 명령어의 순서를 바꾸는 것을 재정렬이라고 한다.
최적화를 이유로 명령어가 재정렬되면, 직관과 일치하지 않는 실행 흐름 때문에 많은 혼란을 일으켰다.
재정렬이 일으키는 혼란이 무엇인지 JSR 133(Java Memory Model) FAQ에서 가져온 예시를 통해 알아보자.
public class Reordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}
public void reader() {
int r1 = y;
int r2 = x;
}
}
위의 코드가 두 스레드에서 동시에 실행됐다고 가정한다.
`writer()`를 호출해 `x`와 `y`에 쓰기(write)를 수행한 다음,
`reader()`를 호출해 `r1`과 `r2`를 읽기(read)를 수행한다고 가정하자.
직관적으로는 `x`를 읽으면 반드시 `1`이 나와야 하고, `y`를 읽으면 `2`가 나와야 할 것이다.
하지만 재정렬에 의해 명령어의 순서가 재정렬되어 쓰기(write)보다 읽기(read)가 먼저 수행될 수도 있다.
예를 들어, 재정렬에 의해 `writer()`에서 `y = 2`라는 쓰기(write)가 먼저 발생하고, `reader()`에서 `y`와 `x`에 대한 읽기(read)가 발생한다. 그다음에서야 `x = 1` 쓰기(write)가 발생해서 결과적으로 `r1 = 2`로 읽지만 `r2 = 0`으로 읽는 잘못된 결과를 초래한다.
Happens-Before 관계
JMM은 Happens-Before 관계를 읽기(read)와 쓰기(write) 같은 작업의 순서를 보장하는 규칙으로 정의한다.
두 작업 사이에 Happens-Before 관계가 성립하면 첫 번째 작업의 결과가 두 번째 작업에 반드시 반영되고, 이를 통해 가시성(visibility) 문제와 재정렬(reorder) 문제를 해결할 수 있다는 것이다.
예시를 들어보자.
두 작업 A와 B가 있다.
A가 B의 Happens-Before 선행 작업이라고 가정하자.
Happens-Before 관계는 다음과 같이 정의한다.
- A는 반드시 B보다 먼저 발생해야 한다.
- A에서의 모든 변경 사항은 B에서 반드시 관찰 가능해야 한다.
만약 위와 같은 Happens-Before 관계가 정의되지 않았다면, JVM은 가시성(visibility) 문제와 재정렬(reorder) 문제가 발생하지 않음을 보장하지 않는다.
가시성 문제를 더 잘 이해하기 위해 먼저 하드웨어가 메모리를 어떻게 다루는지 알아본 다음, JMM에서 발생하는 동시성 문제들과 해결책에 대해 살펴보겠다.
하드웨어의 메모리 구조
대부분의 시스템에서 각 CPU는 한 개 이상의 캐시 계층을 갖고 있다.
JMM을 이해할 때 몇 개의 계층을 갖고 있냐는 중요하지 않기 때문에 L1, L2, L3 캐시를 하나의 계층으로 표현했다.
CPU는 연산을 위해 메인 메모리에 접근해야 할 때 메인 메모리의 일부를 캐시 계층으로 읽어온다.
연산 결과는 레지스터에 저장되고, 레지스터의 값은 다시 캐시 계층으로 보내진다.
그러고 나서 어느 시점이 되면 저장되어 있는 캐시 계층의 값이 메인 메모리로 플러시(flush)된다.
하드웨어의 메모리 구조와 Java Memory Model
하드웨어의 메모리 구조와 Java Memory Model은 메모리를 다루는 방식이 다르다.
하드웨어는 JVM을 모른다.
다시 말해, 하드웨어는 JVM 내에 논리적으로 존재하는 Heap 영역과 스레드 내부의 Stack을 구별하지 않는다.
Heap과 Stack 내에 있는 데이터는 CPU 레지스터에 있을 수도 있고, 캐시 계층에 있을 수도 있고, 메인 메모리에 있을 수도 있다.
이렇게 하드웨어와 JMM의 메모리를 다루는 방식의 차이가 아래의 동시성 문제를 일으킨다.
- 스레드 간 공유 변수에 대한 가시성(visibility)
- 스레드 간 공유 변수에 대한 경쟁 상태(race condition)
동시성 문제와 해결책
이제 지금까지 제시된 3가지 문제들과 Java Memory Model이 제공하는 해결책을 알아보자.
- 가시성 (visibility)
- 경쟁 상태 (race condition)
- 명령어 재정렬 (instruction reorder)
가시성(visibility)
앞서 살펴봤듯이 하드웨어와 JMM의 메모리를 다루는 방식의 차이에서 발생하는 문제다.
왼쪽의 CPU는 `Object 2`를 L1 캐시에서 읽고, 해당 객체 내부의 값을 `17`로 변경했다고 하자.
이 변경된 값은 아직 플러시(flush)되지 않았기 때문에 아직 메인 메모리에 갱신되지 않았다.
하지만 오른쪽의 CPU는 메인 메모리에서 `Object 2`를 읽었기 때문에 내부의 값은 여전히 `5`를 가리킨다.
왼쪽의 CPU와 오른쪽의 CPU가 읽고 있는 `Object 2`가 서로 다른 값을 갖고 있는 문제를 가시성(visibility) 문제라고 부른다.
JMM의 해결책
JMM은 가시성(visibility) 문제를 해결하기 위해 `volatile` 키워드를 제공한다.
volatile boolean value = 5;
`volatile` 키워드가 적용된 필드는 항상 메인 메모리에서 직접 읽히고(read), 직접 쓰인다(write).
하지만 주의할 점이 있다.
`volatile` 키워드는 가시성(visibility) 문제를 해결해 주지만 원자성(atomic) 문제는 해결하지 않는다.
원자성 문제는 아래 경쟁 상태(race condition)에서 다룬다.
경쟁 상태(race condition)
class Counter {
volatile int count = 0;
public void increment() {
count++;
}
}
`volatile int count`는 가시성을 보장한다. 하지만 원자성을 보장하지 않는다고 했다. 원자성 문제를 경쟁 상태 문제와 함께 설명을 하겠다.
예시를 들겠다.
두 스레드 A, B에서 동시에 `increment()`를 한 번 호출했다고 가정하자. (합해서 2번 호출)
아래 같은 상황이 발생할 수 있다.
- 스레드 A: count 값을 읽어서 0 임을 확인
- 스레드 B: count 값을 읽어서 0 임을 확인
- 스레드 A: 계산된 1을 count에 저장
- 스레드 B: 계산된 1을 count에 저장
결과적으로 두 스레드가 각각 1씩 더했음에도 `Counter.count`의 값은 `2`가 아니라 `1`이 될 수도 있는 것이다. 이를 원자성(atomic) 문제라 한다.
JMM의 해결책
synchronized
JMM은 경쟁 상태(race condition) 문제 해결을 위해 `synchronized` 키워드를 제공한다.
`synchronized`는 모니터 락으로 단 하나의 스레드만 임계 영역(critical section)에 들어갈 수 있도록 보장한다.
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
}
Lock(ex. ReentrantLock)
`synchronized`보다 유연한 동기화를 제공한다. (타임아웃, 공정성 등) 다만, 명시적인 획득(lock)과 해제(unlock)가 필요하다.
private final Lock lock = new ReentrantLock();
private int counter = 0;
public void increment() {
lock.lock();
try {
counter++;
} finally {
lock.unlock();
}
}
public int getCounter() {
lock.lock();
try {
return counter;
} finally {
lock.unlock();
}
}
Concurrent Collection
`java.util.concurrent` 패키지에서 가시성과 원자성을 해결하는 동시성 컬렉션 중 하나인 `Atomic` 클래스를 제공한다.
class Counter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
}
명령어 재정렬 (instruction reorder)
위에서 JMM에 대해 설명할 때 명령어 재정렬 문제에 대해 알아봤다.
public class Reordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}
public void reader() {
int r1 = y;
int r2 = x;
}
}
간략하게 다시 설명하면, CPU나 컴파일러에 의해 명령어의 재정렬(reorder)이 발생한다.
`writer()`로 쓰기 작업을 수행한 후, `reader()`로 읽기를 수행했을 때 `x = 1`보다 `y = 2`가 먼저 수행돼서 `r1`을 읽을 때는 기대한 값인 `1`가 아닌 `0` 출력되는 문제였다.
JMM의 해결책
JMM은 재정렬을 방지하기 위해 Happens-Before 관계를 보장해 주는 동기화 도구를 제공한다.
눈치챘겠지만 앞서 살펴봤던 도구들(`volatile`, `synchronized`, `Lock`, `AtomicInteger` 등)은 모두 재정렬을 방지해 준다. (Happens-Before 관계를 보장해 준다.)
JMM이 제공하는 동기화 도구 정리
도구 | 가시성 보장 | 원자성 보장 | 재정렬 방지 |
`volatile` | O | X | O |
`synchronized` | O | O | O |
`Lock` | O | O | O |
`Concurrent Collection` | O | O | O |
Happens-Before 관계를 보장하는 몇 가지 규칙
이쯤 왔으니 Happens-Before 관계를 보장하는 규칙들을 보면 쉽게 이해할 수 있다.
JMM은 아래의 규칙들을 통해 Happens-Before 관계를 정의한다.
1. 프로그램 순서 규칙
단일 스레드 내에서는 소스 코드의 순서대로 Happens-Before 관계가 성립한다.
즉, 한 스레드 안에서 이전에 실행된 모든 작업은 나중에 실행된 작업에 선행한다.
x = 1; // A
synchronized (lock) {
y = x; // B
}
int z = y; // C
위 코드에서 A → B 순으로 Happens-Before 관계가 성립한다.
2. 모니터 락 규칙 (synchronized 블록)
한 스레드에서 모니터 락을 해제한 시점은, 동일한 락을 다른 스레드가 획득하는 시점보다 Happened-Before 관계가 성립한다.
synchronized (lock) {
sharedVar = 1; // A
}
synchronized (lock) {
int localVar = sharedVar; // B
}
위 코드에서 A → B 관계가 성립하여 sharedVar 값이 올바르게 전달된다.
3. volatile 변수 규칙
volatile 변수에 대한 쓰기는 그 이후에 발생하는 모든 읽기보다 Happens-Before 관계를 가진다.
volatile boolean flag = false;
// Thread 1
flag = true; // A
// Thread 2
if (flag) { // B
// 수행
}
위 코드에서 A → B 관계가 성립하므로, Thread 2는 flag = true를 항상 볼 수 있다.
4. 스레드 시작 규칙
`Thread.start()` 호출은 새로 생성된 스레드의 작업보다 Happens-Before 관계를 가진다.
Thread t = new Thread(() -> {
// B
});
t.start(); // A
위 코드에서 A → B 관계가 성립하여, Thread t의 작업이 A 이후에 실행됨을 보장한다.
5. 스레드 종료 규칙
한 스레드의 종료는 `Thread.join()`을 호출한 작업보다 Happens-Before 관계를 가집니다.
Thread t = new Thread(() -> {
// A
});
t.start();
t.join(); // B
위 코드에서 A → B 관계가 성립한다.
6. 전파(transitivity) 규칙
만약 A→B (Happens-Before)이고, B→C 관계가 성립한다면, 관계도 성립한다.
x = 1; // A
synchronized (lock) {
y = x; // B
}
int z = y; // C
위 코드에서 A → B → C 관계가 성립한다.
마치며
메모리 모델을 정리하며 생소했던 Happens-Before 관계와 가시성, 재정렬 문제에 대해 자세히 알 수 있게 되었다.
프로젝트에서 겪었던 데드락이나 성능 저하 문제들이 메모리 모델의 특성과 밀접한 관련이 있음을 깨달았다. 다음에 동시성 문제가 발생했을 때 정리해놓은 글을 읽으면 도움이 될 것 같다.
마지막으로 동시성 문제는 항상 까다롭고 어려웠는데, 이번 정리로 인해 JVM 개발자로서 좀 더 견고한 멀티 스레딩 프로그램을 작성할 수 있지 않을까하는 기대가 생겼다.
참고
- https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
- https://jenkov.com/tutorials/java-concurrency/java-memory-model.html
- https://docs.oracle.com/javase/specs/jls/se17/html/jls-17.html#jls-17.4
- https://www.geeksforgeeks.org/happens-before-relationship-in-java/
- https://velog.io/@hellomatia/자바-동시성의-규칙-Java-Memory-Model-JMM
- https://www.youtube.com/watch?v=LCSqZyjBwWA&list=PLL8woMHwr36EDxjUoCzboZjedsnhLP1j4&index=5
'Java' 카테고리의 다른 글
JVM 아키텍처 정리 (0) | 2025.01.22 |
---|---|
[Java] opencsv 로 CSV 읽고 저장하기 (2) | 2024.07.09 |
[Java] Optional에서 map과 flatMap의 차이점 쉽고 빠르게 이해하기 (0) | 2024.02.28 |