[JVM 밑바닥까지 파헤치기] 12장 자바 메모리 모델과 스레드
멀티태스킹이 중요해진 이유는, 컴퓨터의 연산 성능과 저장/통신 성능간 격차가 커졌기 때문이다. 프로세서가 요청한 자원을 기다리며 놀고 있는 시간이 많아졌기 때문이다. 이런 문제를 해결하기 위해 멀티태스킹이 등장했다.
계산량이 동일한 작업을 수행한다면, 프로그램이 스레드를 동시에 더 많이 돌릴수록 효율이 높아질 것이다. 반대로, 동일 데이터를 두고 경합이 많이 일어나면 효율이 떨어진다.
자바와 JVM은 동시성 프로그래밍의 도구를 상당 수 제공하며, 미들웨어와 프레임워크를 통해 더 나은 동시성 프로그래밍을 지원한다. 이번 장에서는 가상 머신이 멀티스레드를 구현하는 방법과 스레드 간 데이터 공유 혹은 경합하며 발생하는 문제를 해결하는 방법을 살펴본다.
하드웨어에서의 효율과 일관성
멀티태스킹을 하는데 있어 프로세서 성능을 최대화 하는 것은 생각보다 쉽지 않다. 이 이유는 컴퓨팅 작업이 단순 프로세서 컴퓨팅 작업이 아니기 때문이다.
공유 메모리 멀티프로세서 시스템
프로세서는 메모리에서 데이터를 가져오고, 연산을 수행하고, 결과를 메모리에 저장한다. 이 과정에서 데이터가 메모리에 저장되는 시간이 존재한다. 이 시간을 줄이는 것이 프로세서 성능을 높이는 것이다.
공유 메모리 멀티프로세서 시스템
프로세서 성능을 높이기 위해 위해 캐시를 사용할 수 있으며, 이와 같은 시스템을 공유 메모리 멀티프로세서 시스템이라 한다.
캐시를 사용하면 프로세서-메모리 간 데이터 전송 시간이 줄어들어 성능이 높아진다. 하지만 캐시 일관성 문제가 발생할 수 있다.
캐시 일관성 문제는 멀티프로세서 시스템에서 발생하는 문제이다. 멀티프로세서 시스템에서는 각 프로세서가 자신의 캐시를 가지고 있기 때문에, 한 프로세서가 캐시에 있는 데이터를 변경하면 다른 프로세서에서는 이 데이터를 볼 수 없다. 이를 해결하기 위해 캐시 일관성 프로토콜이 사용된다.
캐시 일관성 프로토콜은 메모리 모델을 정의하는데, 이는 프로세서가 메모리와 캐시에 접근하는 방식을 결정한다. 대표적인 캐시 일관성 프로토콜은 다음과 같다.
- MSI, MESI, MOSI, 시냅스, 파이어플라이, 드래곤 프로토콜 등
비순차 실행 최적화
캐시 방식 외에도 비순차 실행 최적화(Out-of-Order Execution) 기법을 사용할 수 있다. 이 기법은 프로세서가 명령어를 순서대로 실행하는 것이 아니라, 순서를 바꿔 실행할 수 있도록 한다. 단, 이렇게 실행된 결과는 순차적으로 실행된 결과와 동일해야 한다.
이와 유사하게, JVM의 JIT 컴파일러는 명령어 재정렬이라는 기법을 수행한다.
자바 메모리 모델
JVM은 다양한 하드웨어 및 운영체제의 메모리 모델로부터 자바 프로그램을 보호하기 위해 메모리 모델을 정의한다. 이 모델은 자바 프로그램이 메모리에 접근하는 방식을 정의한다. 이 덕분에 자바 프로그램은 플랫폼에 독립적으로 메모리를 일관된 방식으로 사용할 수 있다.
메인 메모리와 작업 메모리
자바 메모리 모델은 모든 변수가 메인 메모리에 저장된다고 규정하며, 여기에서의 메인 메모리는 물리적 메인 메모리가 아니라 가상 머신 내의 메모리를 의미한다.
그리고 각 스레드는 자신의 작업 메모리를 가지고 있으며, 이 작업 메모리는 메인 메모리에 있는 데이터의 사본이다. 이 작업 메모리는 스레드가 사용하는 데이터를 저장하는 곳이며, 이 작업 메모리는 스레드가 사용하는 데이터를 저장하는 곳이다. 스레드는 메인 메모리에 직접 접근할 수 없으며, 작업 메모리를 통해서만 접근할 수 있다.
메모리 간 상호작용
자바 가상 머신은 메인 메모리와 작업 메모리간 동기화하는 각 단계의 연산이 원자적으로 이루어지도록 보장해야 한다.
- 잠금(lock): 메인 메모리에 존재하는 변수를 특정 스레드만 사용할 수 있는 상태로 만든다.
- 잠금 해제(unlock): 잠겨 있는 변수를 잠금 해제한다. 잠금이 해제된 변수는 다른 스레드에 의해 잠길 수 있다.
- 읽기(read): 뒤이어 수행되는 적재 연산을 위해 메인 메모리의 변숫값을 특정 스레드의 작업 메모리로 전송한다.
- 적재(load): 읽기 연산으로 메인 메모리에서 얻어온 값을 작업 메모리의 변수에 복사해 넣는다.
- 사용(use): 작업 메모리의 변숫값을 실행 엔진으로 전달한다. 가상 머신이 변숫값을 사용하는 바이트코드 명령어를 만날 때마다 실행된다.
- 할당(assign): 실행 엔진에서 받은 값을 작업 메모리의 변수에 할당한다. 가상 머신이 변수에 값을 할당하는 바이트코드 명령어를 만날 때마다 실행된다.
- 저장(store): 뒤이어 수행되는 쓰기 연산을 위해 작업 메모리의 변숫값을 메인 메모리로 전송한다.
- 쓰기(write): 저장 연산으로 작업 메모리에서 얻어온 값을 메인 메모리의 변수에 기록한다.
메인에서 작업 메모리로 변수를 복사하려면 읽기와 적재 연산이 순차적으로 필요하다. 작업 메모리에서 메인 메모리로 변수를 복사하려면 저장과 쓰기 연산이 순차적으로 필요하다. 단, 순차적으로 수행되어야 할 뿐 바로 이어서 수행되는 것은 아니다.
위 연산을 수행할 때 지켜야 하는 조건은 다음과 같다.
- 읽기와 적재, 저장과 쓰기는 단독 수행이 불가능하다.
- 스레드는 최근 할당 연산을 버릴 수 없다.
- 스레드는 작업 메모리 데이터를 할당 없이 메인 메모리에 저장할 수 없다.
- 변수는 메인 메모리에서만 새로 생성할 수 있으며, 작업 메모리의 변수를 곧바로 사용할 수 없다.
- 변수는 한번에 한 스레드만 잠글 수 있다. 동일 스레드라면 여러번 잠글 수 있다.
- 변수를 잠그면 작업 메모리의 변수값은 지워진다. 실행 엔진이 변수를 사용하려면 적재 및 할장을 다시 수행해야 한다.
- 잠금을 해제하려면 변수를 메인 메모리에 동기화해야 한다.
위와 같은 메모리 접근 연산 및 규칙과 volatile
키워드를 사용하면 자바 프로그램이 동시성 작업에 안전하다는 것을 보장할 수 있다.
자바에서는 자바 메모리 모델의 연산을 네가지 유형으로 단순화 해 정의한다. (읽기, 쓰기, 잠금, 잠금 해제)
volatile
volatile
키워드는 자바 가상 머신의 가장 가벼운 동기화 기능이다.
변수가 volatile
로 선언되면 두 가지 특성을 가진다.
- 모든 스레드에서 이 변수를 투명하게 볼 수 있다.
- 이는 한 스레드가 변수를 변경하면 다른 스레드가 변경된 값을 즉시 볼 수 있음을 의미한다.
volatile
변수는 가시성만 보장하기 때문에 다음 두 규칙을 충족하지 못하는 경우 락을 사용해야 한다.- 연산 결과가 변수의 현재 값과 무관하거나 변수 값을 수정하는 스레드가 하나임을 보장해야 한다.
- 다른 상태 변수와 관련한 불변성 제약 조건에 관여하지 않는다.
- 명령어 재정렬을 방지한다.
- 이는 변수 할당 작업의 실행 순서가 코드 순서와 동일함을 보장한다. 즉, 변수 할당 작업이 코드 순서대로 실행된다는 것이다.
volatile
지정 이후에는 메모리 장벽이 추가되어 명령어 재정렬을 방지한다.
volatile
변수는 다른 동기화 도구들보다 코드를 더 빨리 실행할 수 있다.
volatile
변수용 특별 규칙
volatile
변수는 다음 규칙을 충족해야 한다.
- 스레드 T가 변수 V를 사용하려면 T가 V에 수행한 이전 연산이 적재여야 한다.
- 스레드 T가 변수 V를 저장하려면 T가 V에 수행한 이전 연산이 할당이어야 한다.
- 액션 A, F, P, B, G, Q를 다음과 같이 정의하자.
액션 A: 스레드 T가 변수 V에 수행한 사용 또는 할당
액션 F: 액션 A와 관련한 적재 또는 저장
액션 P: 액션 F에 해당하는 변수 V에 대한 읽기 또는 쓰기
액션 B: 스레드 T가 변수 W를 사용 또는 할당
액션 G: 액션 B와 관련한 적재 또는 저장
액션 Q: 액션 G에 해당하는 변수 W에 대한 읽기 또는 쓰기 이때 A가 B보다 앞서면 P가 Q보다 앞선다.volatile
변수가 명령어 재정렬에 의해 최적화되지 않도록 하여 코드 실행 순서가 프로그램에서 정의한 순서가 같도록 보장한다는 뜻이다.
long
과 double
변수용 특별 규칙
long
과 double
변수는 좀 더 느슨한 동기화 규칙을 적용받는다. 이는 이 타입의 변수를 읽거나 쓰는 연산이 두 개의 쓰기 연산으로 나눠질 수 있기 때문이다.
현재 주류 플랫폼의 상용 64비트 가상 머신에서는 비원자적 접근이 일어나지 않았지만, 32비트 플랫폼에서는 long
타입 데이터에 대해 비원자적 접근이 일어날 수 있다.
double
타입의 경우, 요즘 프로세서들은 대체로 부동 소수점 데이터 전용 유닛을 탑재하고 있어서 32비트 가상 머신이라 해도 비원자적 접근 문제는 일어나지 않는다.
원자성, 가시성, 실행 순서
자바 메모리 모델은 원자성, 가시성, 실행 순서를 정의한다.
원자성
자바 메모리 모델이 직접 보장하는 원자적 변수 연산은 다음과 같다.
- 읽기, 적재, 할당, 사용, 저장, 쓰기
가시성
가시성이란 공유 변수의 값이 한 스레드에서 변경되면 다른 스레드가 변경된 값을 즉시 볼 수 있도록 보장하는 것을 의미한다. 앞서 설명한 volatile
키워드에서 자세히 설명하였다.
자바에는 volatile
변수 외에도 가시성을 보장하는 다른 방법이 있다.
final
final
필드는 생성자에서 초기화되고, 생성이 완벽하게 끝나지 않은 객체의 참조(this
)를 다른 스레드에 전달하지 않는다.- 따라서
final
필드는 가시성을 보장한다.
synchronized
- 변수의 잠금을 해제하기 전에 변수를 메인 메모리에 동기화하는 규칙을 통해 가시성을 보장한다.
실행 순서
자바에서 실행 순서를 보장하기 위해 volatile
과 synchronized
를 사용할 수 있다.
volatile
: 명령어 재정렬을 방지하여 실행 순서를 보장한다.synchronized
: 락을 통해 실행 순서를 보장한다.
선 발생 원칙
실행 순서를 volatile
과 synchronized
로만 보장하려 하면 많은 연산이 장황해진다.
선 발생 원칙이란 자바 메모리 모델에서 정의된 두 작업의 수행 순서 관계를 의미한다.
// 선 발생 원칙
i = 1; // 스레드 A에서 수행
j = i; // 스레드 B에서 수행
i = 2; // 스레드 C에서 수행
위 코드에서 스레드 A, B, C는 각각 다른 스레드이다. 이 코드에서 스레드 A는 B보다 선행되어야 한다.
- 선 발생 원칙에 따라 i = 1의 결과를 관찰할 수 있다.
- 스레드 C는 아직 등장하지 않았으며 스레드 A의 작업이 완료된 후 다른 스레드가 변수 i의 값을 수정하지 않았다.
다음은 자연스러운 선 발생 관계이며, 동기화 장치 지원 없이 이루어지며 직접 코딩에 활용이 가능하다.
- 프로그램 순서 규칙: 한 스레드 안에서는 제어 흐름 순서에 따라 앞의 연산이 뒤따르는 연산보다 선 발생한다. 분기와 순환문 같은 구조까지 고려되기 때문에 프로그램 코드 순서가 아니라 제어 흐름 순서가 기준이다.
- 모니터 락 규칙: 잠금 해제 연산은 같은 락에 대한 잠금 연산보다 선 발생한다. 여기서는 같은 락이 중요하며 순서는 시간 순서다.
- 휘발성 변수 규칙:
volatile
변수의 쓰기 연산은 같은 변수에 대한 읽기 연산보다 선 발생한다. 여기서 순서는 시간 순서다. - 스레드 시작 규칙:
Thread
객체의start()
메서드는 해당 스레드의 어떤 작업보다도 선 발생한다. - 스레드 종료 규칙: 스레드의 모든 작업은 해당 스레드의 종료 감지보다 선 발생한다. 스레드가 종료되었는지 여부는
Thread::join()
메서드나Thread::is Alive()
메서드의 반환값으로 감지할 수 있다. - 스레드 인터럽트 규칙:
Thread
의interrupt()
메서드 호출은 인터럽트되는 스레드가 인터럽트 이벤트 발생 감지보다 선 발생한다. 인터럽트 여부는Thread::interrupted()
메서드로 감지할 수 있다. - 종료자 규칙: 객체 초기화(생성자 수행 완료)는
finalize()
메서드 시작보다 선 발생한다. - 전이성: 연산 A가 연산 B보다 선 발생하고, 연산 B가 연산 C보다 선 발생한다면, A가 C보다 선 발생한다고 결론지을 수 있다.
선 발생 관계가 시간 순서와 같다는 것은 아니다. 시간 순서는 물리적 시간 순서를 의미하며, 선 발생 관계는 프로그램 순서를 의미한다.
자바와 스레드
스레드 구현
자바에서는 스레드가 프로세서 자원 스케줄링의 최소 단위이다. (-JDK 20) 이하의 내용에서 설명하는 스레드는 전통적인 자바 스레드를 의미한다.
Thread
클래스는 대부분의 다른 자바 클래스 라이브러리 api와 이질적이고, 네이티브 코드로 구현되어 있다.
스레드 구현 방법은 크게 3가지가 있다.
- 커널 스레드 구현
- 사용자 스레드 구현
- 하이브리드 구현
커널 스레드 구현
커널 스레드는 운영체제가 직접 지원하는 스레드이며, 이 각각은 커널의 복제본으로 생각할 수 있다.
멀티스레딩 지원 커널을 멀티스레드 커널이라 한다.
프로그램은 커널 스레드를 직접 사용하지 않고, 경량 프로세스를 이용한다. 이 경량 프로세스가 우리가 알고 있는 스레드이다. 경량 스레드 각각이 커널 스레드에 도움을 받기 때문에 커널 스레드가 먼저 지원되어야 경량 프로세스가 생성될 수 있다.
경량 프로세스는 두가지 한계를 가진다.
- 커널 스레드 기반이므로 모든 스레드 연산이 시스템 콜로 이루어지므로 생성 비용이 높다.
- 경량 프로세스 하나가 커널 스레드 하나에 대응되므로 경량 프로세스는 커널 자원을 소모하게 된다. 따라서 시스템이 지원할 수 있는 스레드 수가 제한된다.
사용자 스레드 구현
넓은 의미에서 커널 스레드가 아닌 이상 모든 스레드는 일종의 사용자 스레드로 볼 수 있으므로 경량 프로세스 역시 사용자 스레드에 속한다. 그러나 효율이 좋지 않기에 일반적인 사용자 스레드의 이점은 없다.
사용자 스레드는 커널 스레드 기반이 아니므로 스레드 연산이 시스템 콜로 이루어지지 않는다. 따라서 생성 비용이 적다. 또한 스레드 수가 많아져도 커널 자원을 소모하지 않는다. 때문에 일부 고성능 데이터베이스는 멀티스레딩을 사용자 스레드로 구현하여 높은 성능을 낼 수 있도록 한다.
사용자 스레드의 장점은 시스템 커널이 스레드 생성 및 스케줄링을 관리하지 않아도 된다는 점이다. 단점 또한 시스템 커널의 지원이 없기 때문에 커널 스레드 기반의 스레드보다 제한적이라는 점이다. 블로킹과 멀티프로세서 지원 없이 작업을 처리해야 하므로 문제 해결이 어려워 질 수 있다.
하이브리드 구현
커널 스레드 기반의 스레드와 사용자 스레드 기반의 스레드를 결합한 하이브리드 구현이 있다.
운영 체제가 제공하는 경량 프로세스가 사용자 스레드와 커널 스레드 사이의 매개체 역할을 한다.
자바 스레드 구현
자바 가상 머신 명세에서 구현 방식을 별도로 지정하고 있지 않다. 따라서 자바 가상 머신 구현체는 자신의 특성에 따라 자바 스레드를 구현할 수 있다.
현재 주류 자바 가상 머신은 운영체제의 기본 스레드 모델(1:1 스레딩 모델)을 사용한다.
핫스팟 가상 머신의 경우, 자바 스레드 각각이 운영체제 스레드에 대응된다. 따라서 가상 머신은 스레드 스케줄링에 관여하지 않고 운영체제가 스레드 스케줄링을 결정한다.
자바 ME CLDC (CLDC-HI)의 경우, 두 가지 스레딩 모델을 동시에 지원한다. 기본적으로 사용자 스레딩 모델을 사용하나, 하이브리드 모델을 사용할 수도 있다.
자바 스레드 스케줄링
협력적 스케줄링과 선점형 스케줄링이 있다.
- 협력적 스케줄링: 협력적 스케줄링은 스레드가 자발적으로 스케줄링을 양보하는 방식이다.
- 선점형 스케줄링: 선점형 스케줄링은 스케줄러가 스레드를 강제로 중단시키고 다른 스레드를 실행하는 방식이다.
자바는 선점형 스케줄링을 사용한다.
상태 전이
자바에서 스레드의 상태는 총 6가지가 있다.
- 신규: 스레드 생성 후 아직 시작되기 전 상태를 말한다.
- 실행 중: 운영 체제 스레드의 상태 중에서 실행 중(running)과 준비(ready)에 해당한다. 스레드가 실행 중이거나 운영 체제가 실행 시간을 할당하기를 기다리는 중이다.
- 무기한 대기: 프로세서 실행 시간이 할당되지 않았고, 다른 스레드가 명시적으로 깨워 주기를 기다리는 중이다. 다음 메서드들이 스레드를 무기한 대기 상태로 만든다.
타임아웃 매개 변수를 설정하지 않은
Object::wait()
타임아웃 매개 변수를 설정하지 않은Thread::join()
LockSupport::park()
- 시간 제한 대기: 프로세서 실행 시간이 할당되지 않았으나 (다른 스레드가 명시적으로 깨워 주기를 기다릴 필요 없이) 일정 시간이 지나면 시스템에 의해 자동으로 깨어난다. 다음 메서드들이 스레드를 시간 제한 대기 상태로 만든다.
Thread::sleep()
타임아웃 매개 변수를 설정한Object::wait()
타임아웃 매개 변수를 설정한Thread::join()
LockSupport::parkNanos()
LockSupport::parkUntil()
- 블록: 스레드가 블록되었다. 배타적 락을 얻기를 기다린다는 점에서 대기와 차이가 있다. 배타적 락은 다른 스레드가 해당 락을 해제할 때 얻을 수 있다. 반면 대기는 일정 시간 동안 또는 다른 스레드가 깨워 주기를 기다리는 것이다. 프로그램이 동기화된 영역에 진입하기를 기다리는 동안 이 상태가 된다.
- 종료: 스레드가 실행을 마쳤다.
가상 스레드
자바는 다양한 운영 체제별 스레드 모델의 차이를 숨기는 통합된 스레드 인터페이스를 제공해, 멀티스레드 어플리케이션과 프레임워크를 보다 쉽게 작성할 수 있도록 한다.
자바에서 기존 동시성 프로그래밍 매커니즘은 1:1 커널 스레드 모델을 기반으로 하므로, 마이크로서비스 아키텍쳐와 같은 대규모 분산 시스템과는 잘 어울리지 않는다.
이러한 문제를 해결하기 위해 자바는 가상 스레드를 도입하였다.
코루틴
프로세서가 스레드 A의 프로그램 코드를 실행하려면 프로그램의 동작을 의미하는 코드와 함께 동작시 필요한 문맥 정보를 제공해야 한다.
여기서 문맥이란, 프로세서가 프로그램의 동작을 이해하기 위해 필요한 모든 정보를 의미한다. 개발자 관점에서는 메서드 호출 과정에 쓰이는 지역 변수와 자원일 것이고, 스레드 관점에서는 메서드 호출 스택에 저장된 모든 정보를 의미한다. 운영체제 및 하드웨어 관점에서는 메모리, 캐시, 레지스터 등에 저장된 값을 의미한다.
코루틴은 콜 스택을 저장하고 복구하는 메커니즘을 제공한다. 스택풀 코루틴이라고도 한다.
코루틴의 가장 큰 장점은 가볍다는 점이다.
협력적 스케줄링 방식으로 작업을 처리해야 한다.
가상 스레드
JDK 21이후로, 자바 가상 머신은 스택풀 코루틴을 사용해 가상 스레드를 구현한다.
내부에서 사용자 스레드를 사용하며, 커널 스레딩을 대체하지는 않는다. 대신 기존 스레드 모델과 공존하며 프로그램에서 혼용할 수 있는 형태로 제공한다.
가상 스레드는 플랫폼 스레드와 N:1 관계를 맺는다. 가상 스레드가 블럭되면 플랫폼 스레드가 다른 가상 스레드를 실행한다. 이러한 방식으로 문맥 전환 없이 쉬지 않고 어플리케이션 코드를 실행할 수 있다.
가상 스레드를 사용한 JDBC 커넥터는 R2DBC보다도 성능이 좋다.
후속문과 스케줄러에서 가상 스레드를 이용해 더 나은 성능을 얻을 수 있다.
Leave a comment