12. 자바 메모리 모델과 스레드
하드웨어에서의 효율과 일관성
flowchart TD
subgraph Processor_Cache_1 [Processor 1 & Cache 1]
P1[Processor 1]
C1[Cache 1]
P1 --> C1
end
subgraph Processor_Cache_2 [Processor 2 & Cache 2]
P2[Processor 2]
C2[Cache 2]
P2 --> C2
end
subgraph Processor_Cache_N [Processor N & Cache N]
Pn[Processor N]
Cn[Cache N]
Pn --> Cn
end
C1 --> B[Cache Coherence Bus]
C2 --> B
Cn --> B
B --> M[Main Memory]
B -- Invalidate / Update --> C1
B -- Invalidate / Update --> C2
B -- Invalidate / Update --> Cn
M -. Memory Read/Write .-> B
- 공유 메모리 멀티프로세서 시스템 : 프로세서 각각이 자신만의 캐시를 갖춘 채 똑같은 메인 메모리를 공유한다.
- 각 프로세서의 캐시 <-> 메인메모리 간의 일관성 문제를 해결하려면 프로세서가 캐시를 이용할때 정해진 프로토콜을 따라야한다.
12.3 자바 메모리 모델
- 자바 프로그램은 플랫폼에 상관없이 메모리를 일관된 방식으로 이용할수있다. (인터페이스)
12.3.1 메인 메모리와 작업 메모리
자바 메모리 모델에서
- 메인 메모리 : 가상머신이 관리하는 메모리
- 작업 메모리 : 각 스레드가 관리하는 자체 메모리
- 해당 스레드가 사용하는 변수가 저장된 메인 메모리의 복사본이 담겨있다.
- 스레드가 변수를 읽고쓰는 모든 연산은 작업 메모리에서 수행되며, 메인 메모리의 데이터는 직접 읽고 쓸 수 없다.
- 위 공유메모리 멀티프로세서 시스템과 비교했을때, 메인메모리는 하드웨어 메모리에 대응하며, 작업 메모리는 레지스터와 캐시에 대응한다.
12.3.2 메모리간 상호작용
- 각 단계의 연산이 원자적으로 이루어지도록 보장되어야한다.
- 각 연산은 순서대로 실행되어야할뿐, 바로 이어서 실행될필요는 없다.
- 연산
- lock
- unlock
- read : 적재연산을 위해 메인메모리의 변수값을 특정 스레드의 작업메모리로 전송
- load : read로 얻어온 값을 작업메모리의 변수에 복사
- use : 작업메모리의 변수값을 실행엔진으로 전달, 변수값사용 바이트코드마다 실행된다.
- assign : 실행엔진에서 받은 값을 작업 메모리의 변수에 할당.
- store : 작업메모리의 변수값을 메인메모리로 전송
- write : 메인메모리의 변수에 기록
- 변수는 메인메모리에서만 새로 생겨날수있다. (=변수를 사용하거나 저장하기전에 할당과 적재가 이루어져야한다.)
12.3.3 volatile 변수용 특별 규칙
- volatile은 다른 동기화 도구들보다 코드를 더 빠르게 실행하여 다른 lock 방식보다 성능이 좋다.
-
volatile 변수의 특성1 가시성 : 모든 스레드에서 이 변수를 투명하게 볼 수 있다.
- 한 스레드가 값을 수정하면, 다른 스레드도 새로운 값을 즉시 알게된다.
volatile 변수가 동시성환경에서 안전하지만은 않다.
static volatile int race = 0;
static void increase(){race++}
// 20개의 스레드에서
new Thread(() -> for(0...1000){increase()})
race ?? // 기대결과 20*1000 = 20,000
- increase 메서드의 바이트코드
- 실제로는 바이트코드만으로 동시성문제를 분석하기어려움 : 바이느코드를 해석한 인터프린터가 여러 기계어를 실행하기도하기때문.
getstatic // 최신의 race값을 가져옴. iconst_1 // 이 아래부터는 race값이 변경되는것을 인지하지못함. iadd putstatic return
- 실제로는 바이트코드만으로 동시성문제를 분석하기어려움 : 바이느코드를 해석한 인터프린터가 여러 기계어를 실행하기도하기때문.
volatile 변수의 적절한 사용법
public volatile boolean isShutDownRequested;
public void doWork(){
while(!isShutDownRequested) {...}
}
// A thread
// doSomething...
isShutDownRequeste=true
/// B thread
doWork() // isShutDownRequested 변수 변경에의해 중단됨.
volatile 변수의 특성2 명령어 재정렬 최적화를 막아준다.
- 위 코드에서 volatile이 아니면 A thread에서 doSomething이 끝나기전에 IsShutDownRequested 의 실행이 당겨져서 B thread가 의도치않게 종료될수있다.
- volatile 변수 저장시 바이트코드레벨에서
lock addl $0x0
이 추가되는데 메모리 장벽이라고 불린다.- 프로세서의 캐시를 메인메모리에 쓰고, 다른 프로세서의 캐시를 무효화한다.
- 이 명령어가 수정사항을 메모리에 동기화할때는 이전의 모든 작업이 수행되었음을 뜻하므로, 명령어 재정렬이 메모리장벽을 넘을 수 없는 효과를 가져온다.
12.3.4 long과 double 변수용 특별 규칙
- 가상머신은 volatile 로 지정되지 않은 64bit 데이터의 읽기와 쓰기는 32bit 연산 두개로 나누어 처리할 수 있다.
- 주류 플랫폼의 상용 64bit 가상머신에서는 비원자적 접근이 재현되지않았다.
- long, double 이라고 해서 volatile 로 선언할 필요는 없다.
12.3.5 원자성,가시성,실행순서
원자성
- 기본데이터타입으로의 읽기쓰기는 원자적이다.
- 더 넓은 범위로 원자성을 보장하기위해 잠금과 잠금 해제연산을 제공한다.
가시성
- 공유변수의 값을 한 스레드가 수정하면 수정 결과를 다른 스레드가 즉시 알 수 있다는 뜻.
- volatile, synchronized, final
실행 순서
- 명령어 재정렬 방지 -> volatile
- 같은 락을 공유할때 순서대로 수행되는것 -> synchronized , volatile
12.3.6 선발생 원칙
- 자연스러운 선발생 관계 p.606
- ‘시간상 먼저 발생하는 작업’ 이 ‘선 발생’ 하는 작업을 의미하지않는다.
class TestClass { int value = 0 void setValue(int vall) int getValue() } // thread A setValue(1) // thread B int b = getValue()
b != 1
일 수 있다. 멀티스레드 환경에서 안전하지않다.
- 작업이 선발생하면 시간상 먼저 발생하는것도 아니다. 명령어 재정렬대문.
12.4 자바와 스레드
12.4.1 스레드 구현
- 스레드 각각은 프로세스 자원을 공유할 수 있으며 독립적으로 스케줄링된다.
커널스레드 구현
- 커널 스레드는 운영체제 커널에서 직접 지원하는 스레드이며, 스레드의 작업을 각 프로세서에 매핑하는 역할을 한다.
- 프로그램은 일반적으로 커널스레드를 직접사용하기보다 경량스레드(우리가 일반적으로 부르는 스레드) 를 사용한다.
1:1 스레딩 모델
flowchart TD subgraph "CPU" CPU1[CPU] CPU2[CPU] end %% 커널 스레드 (Kernel-Level Threads) CPU1 --> KLT1[KLT1<br/> :Kernel-Level Thread] CPU1 --> KLT2[KLT2] CPU2 --> KLT3[KLT3] CPU2 --> KLT4[KLT4] CPU2 --> KLT5[KLT5] %% 스레드 스케줄러가 KLT를 스케줄링 subgraph "스레드 스케줄러 (Thread Scheduler)" KLT1 --> LWP1 KLT2 --> LWP2 KLT3 --> LWP3 KLT4 --> LWP4 KLT5 --> LWP5 end %% 사용자 프로세스 1 subgraph P1["P1 (프로세스 1)"] LWP1[LWP1<br/> :Light Weight Process] LWP2[LWP2] LWP3[LWP3] LWP1 --- T1["T1<br/>사용자 스레드"] LWP2 --- T2["T2"] LWP3 --- T3["T3"] end %% 사용자 프로세스 2 subgraph P2["P2 (프로세스 2)"] LWP4[LWP4] LWP5[LWP5] LWP4 --- T4["T4<br/>사용자 스레드"] LWP5 --- T5["T5"] end
- 경량프로세스의 한계
- 커널스레드를 기반으로 구현되므로, 생성,소멸,동기화 등 스레드연산이 시스템 호출로 이루어진다. 실행비용이 상대적으로 높음.
- 시스템이 지원할 수 있는 경량 프로세스 개수는 제한이 있다.
사용자 스레드 구현
- 넓은 의미에서 커널스레드가 아닌이상 모든 스레드는 일종의 사용자 스레드. (경량 프로세스도.)
- 사용자스레드는 커널의 도움 없이 사용자공간에서 스레드연산이 처리된다.
- 사용자 스레드로 구현된 프로그램은 일반적으로 복잡하다.
- 대표적으로 go, erlang
하이브리드 구현 (M:N)
flowchart TD
subgraph CPU ["CPU"]
CPU1[CPU]
CPU2[CPU]
end
%% KLT와 CPU 연결
CPU1 --> KLT1[KLT1<br/> :Kernel Thread]
CPU1 --> KLT2[KLT2]
CPU2 --> KLT3[KLT3]
CPU2 --> KLT4[KLT4]
CPU2 --> KLT5[KLT5]
%% 프로세스 1
subgraph P1["P1 (프로세스 1)"]
LWP1[LWP1<br/> :Light Weight Process]
LWP2[LWP2]
UT1["UT1<br/>(User Thread)"]
UT2["UT2"]
UT3["UT3"]
UT4["UT4"]
%% 사용자 스레드들이 여러 LWP와 연결 (M:N 구조)
UT1 --- LWP1
UT2 --- LWP1
UT3 --- LWP2
UT4 --- LWP2
end
%% 프로세스 2
subgraph P2["P2 (프로세스 2)"]
LWP3[LWP3]
LWP4[LWP4]
UT5["UT5"]
UT6["UT6"]
UT5 --- LWP3
UT6 --- LWP4
end
%% KLT와 LWP 연결
KLT1 --> LWP1
KLT2 --> LWP2
KLT3 --> LWP3
KLT4 --> LWP4
KLT5 --> LWP2
항목 | 1:1 모델 (Kernel-level threads) |
1:N 모델 (User-level threads) |
M:N 모델 (Hybrid threads) |
---|---|---|---|
매핑 구조 | 1 사용자 스레드 ↔ 1 커널 스레드 | N 사용자 스레드 ↔ 1 커널 스레드 | M 사용자 스레드 ↔ N 커널 스레드 |
스케줄링 위치 | 커널 | 사용자 영역 | 사용자 + 커널 협력 |
병렬성 (CPU 병렬 실행) | ✅ 가능 (스레드별 KLT 존재) | ❌ 불가능 (단일 KLT로 직렬 실행) | ✅ 가능 (여러 KLT 활용 가능) |
문맥 전환 비용 | 높음 (커널 간 전환) | 낮음 (사용자 공간 전환) | 중간 |
블로킹 I/O 영향 | 개별 스레드만 블로킹 | 전체 프로세스 블로킹 위험 | LWP 수준으로 블로킹 회피 가능 |
구현 복잡도 | 낮음 (커널에 의존) | 낮음 (라이브러리 수준) | ✅ 높음 (스케줄러 협조 필요) |
유연성 | 보통 | 높음 (커스터마이징 용이) | 매우 높음 |
대표 OS/환경 | Linux pthread, Windows | GNU Portable Threads (과거) | Solaris, FreeBSD (쓰레드 풀 모델) |
자바 스레드 구현
- 핫스팟은 스레드 스케줄링에 관여하지않고, 온전히 밑단 운영체제가 관리한다.
- 운영체제가 어떤 스레딩 모델을 제공하느냐가 자바 가상머신의 스레드가 매핑되는 방식에 지대한 영향을 준다.
12.4.2 자바 스레드 스케줄링
- 협력적 스케줄링 : 스레드 실행시간을 스레드가 스스로 제어한다. 일을 마친 스레드는 다른 스레드로 전환되도록 시스템에 알려야한다.
- lua언어의 코루틴이 대표적.
- 스레드 실행시간을 제어할 수 없어 프로그램 자체가 멈춰버릴수 있음.
- 선점형 스케줄링 : 스레드 실행시간을 시스템이 할당한다.
- java
- 스레드 우선순위 기능도 지원되나, 결국 운영체제가 결정하므로 믿을만한 우선순위 조율방법은 아니다.
- java
12.4.3 상태 전이
12.5 자바와 가상 스레드
- 자바의 기존 동시성 프로그래밍은(1:1 커널 스레드 모델) MSA와 어울리지 않는다.
- 요청당 실행시간이 매우 짧고 수가 많아지므로, 스레드 전환비용이 클수록 불리.
12.5.2 코루틴의 귀환
- 커널스레드의 스케줄링 비용은 주로 사용자 모드와 커널모드 사이의 전환 비용.
- 커널스레드의 전환비용은 문맥저장과 복원비용. 스레드 전환시 메모리와 레지스터에 스레드의 일시정지된 상태를 저장.
- 사용자스레드도 마찬가지임.
- 코루틴의 가장 큰 장점은 가볍다는것.
- JVM 스레드풀 용량이 200개지만, 코루틴 애플리케이션에서는 수십만개 공존할수있다.
- 협력적 스케줄링방식으로 작업을 처리해야한다.
12.5.3 가상스레드 : 자바의 해법
- 커널스레드 1 : 플랫폼 스레드 1 : 가상스레드 N
- 가상스레드 하나가 블록되면 플랫폼 스레드에서 다른 가상 스레드 작업을 이어서 진행한다.
- 가상스레드 전환시 커널스레드는 문맥전환이 발생하지않는다.
- 가상스레드의 이점은 I/O 작업이 많아서 스레드 전환이 자주 일어나는 상황에서 극적으로 볼수있다.