5부: 효율적인 동시성
동시성은 현대 컴퓨터의 계산 능력을 최대한 활용하기 위한 강력한 도구입니다. 여러 작업을 동시에 수행함으로써 응답 시간을 줄이고, 시스템의 효율성과 성능을 향상시킬 수 있습니다.
12장: 자바 메모리 모델과 스레드
12.1 들어가며
오늘날의 컴퓨팅 환경에서는 여러 작업을 동시에 처리해야 하는 요구가 증가하고 있습니다. 특히 서버나 대규모 애플리케이션에서는 동시에 많은 요청을 처리해야 하며, 이는 효율적인 동시성 프로그래밍을 필요로 합니다.
하지만 동시성 프로그래밍은 여러 스레드 간의 데이터 공유와 동기화 문제로 인해 복잡하고 어려운 기술입니다. 자바는 이러한 복잡성을 완화하기 위해 자바 메모리 모델(JMM)과 스레드 관련 기능을 제공합니다.
12.2 하드웨어에서의 효율과 일관성
프로세서와 메모리의 속도 차이
프로세서는 매우 빠른 속도로 연산을 수행하지만, 메인 메모리의 접근 속도는 상대적으로 느립니다. 이로 인해 프로세서의 연산 능력을 충분히 활용하지 못하는 병목 현상이 발생합니다.
캐시의 등장과 캐시 일관성 문제
이러한 속도 차이를 해소하기 위해 캐시(Cache)가 도입되었습니다. 캐시는 프로세서와 메인 메모리 사이에 위치하여 자주 사용하는 데이터를 빠르게 접근할 수 있도록 합니다.
그러나 멀티프로세서 시스템에서는 각 프로세서가 자체 캐시를 가지고 있어 캐시 일관성(Cache Coherence) 문제가 발생합니다. 한 프로세서가 메모리를 변경해도 다른 프로세서의 캐시에 그 변경 사항이 즉시 반영되지 않을 수 있습니다.
메모리 모델과 명령어 재정렬
캐시 일관성 문제를 해결하기 위해 하드웨어 수준에서 메모리 모델과 캐시 일관성 프로토콜이 사용됩니다. 또한 프로세서의 성능 향상을 위해 명령어 재정렬(Instruction Reordering)과 비순차 실행(Out-of-Order Execution)이 활용되는데, 이는 프로그램의 실행 순서가 코드 작성 순서와 다를 수 있음을 의미합니다.
이러한 최적화는 성능 면에서 이점이 있지만, 동시성 프로그래밍에서는 예상치 못한 결과를 초래할 수 있습니다.
12.3 자바 메모리 모델
자바 메모리 모델의 필요성
자바는 다양한 플랫폼에서 동일한 동작을 보장하기 위해 자바 메모리 모델(JMM)을 정의합니다. JMM은 스레드가 메모리에 접근하고 상호 작용하는 방식을 규정하여 일관된 프로그램 동작을 보장합니다.
메인 메모리와 작업 메모리
JMM에서는 모든 변수가 메인 메모리에 저장되며, 각 스레드는 자신의 작업 메모리(Working Memory)를 가지고 있습니다. 스레드는 변수를 사용할 때 메인 메모리에서 작업 메모리로 변수를 가져와 사용하고, 변경된 값은 다시 메인 메모리에 저장합니다.
이러한 구조로 인해 스레드 간에 변수의 변경 사항이 즉시 반영되지 않을 수 있으며, 이를 메모리 가시성(Memory Visibility) 문제라고 합니다.
volatile 키워드
변수의 변경 사항이 즉시 다른 스레드에 반영되도록 하기 위해 volatile 키워드를 사용할 수 있습니다. volatile로 선언된 변수는 다음과 같은 특징을 가집니다.
- 가시성 보장: 한 스레드가 변수의 값을 변경하면 즉시 메인 메모리에 저장되며, 다른 스레드는 메인 메모리에서 그 값을 읽어옵니다.
- 명령어 재정렬 방지: 컴파일러와 프로세서의 최적화에 의해 명령어 순서가 변경되는 것을 방지합니다.
public class VolatileExample {
private volatile boolean flag = false;
public void updateFlag() {
flag = true;
}
public void checkFlag() {
if (flag) {
// flag가 변경되었음을 감지
}
}
}
하지만 volatile은 복합 연산의 원자성을 보장하지 않으므로 주의가 필요합니다.
원자성(Atomicity)과 동기화
복합 연산(예: i++)은 읽기와 쓰기가 결합된 연산으로, 여러 스레드가 동시에 접근하면 예상치 못한 결과를 가져올 수 있습니다. 이를 해결하기 위해 synchronized 키워드를 사용하여 원자성을 보장할 수 있습니다.
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
}
synchronized를 사용하면 한 번에 하나의 스레드만 해당 메서드나 블록을 실행할 수 있어 데이터의 일관성을 유지할 수 있습니다.
12.4 자바와 스레드
스레드의 개념과 운영체제의 스레드 모델
스레드는 프로세스 내에서 실행 흐름을 분리하여 동시에 여러 작업을 수행할 수 있게 합니다. 운영체제는 스레드를 관리하는 방식에 따라 커널 스레드, 사용자 스레드, 하이브리드 스레드 모델을 제공합니다.
- 커널 스레드: 운영체제가 관리하며, 각 스레드는 운영체제의 스케줄러에 의해 관리됩니다.
- 사용자 스레드: 애플리케이션 레벨에서 관리되며, 운영체제는 단일 스레드로 인식합니다.
- 하이브리드 스레드: 사용자 스레드와 커널 스레드가 매핑되어 운영됩니다.
자바 스레드와 JNI
자바에서 스레드를 생성하면 실제로는 운영체제의 커널 스레드가 생성됩니다. 이는 JNI(Java Native Interface)를 통해 자바가 운영체제의 기능을 활용하기 때문입니다.
Thread 클래스의 start() 메서드를 보면 내부적으로 네이티브 메서드 start0()를 호출하여 스레드를 시작합니다.
public class Thread {
// ...
public synchronized void start() {
// ...
start0();
}
private native void start0();
}
기존 스레드 모델의 한계
기존의 자바 스레드는 운영체제의 스레드와 1:1 매핑되므로 많은 수의 스레드를 생성하면 메모리 소비와 스케줄링 오버헤드가 증가합니다.
- 메모리 소비: 각 스레드는 독립적인 스택 메모리를 가지며, 많은 스레드를 생성하면 메모리 사용량이 크게 증가합니다.
- 스케줄링 오버헤드: 운영체제의 스케줄러가 많은 스레드를 관리해야 하므로 컨텍스트 스위칭 비용이 증가합니다.
가상 스레드(Virtual Threads)의 도입
이러한 문제를 해결하기 위해 JDK 21에서는 가상 스레드(Virtual Threads)가 도입되었습니다. 가상 스레드는 경량화된 스레드로, 운영체제의 스레드와 1:1 매핑되지 않고 자바 레벨에서 관리됩니다.
- 경량성: 스레드 생성 비용이 낮으며, 수많은 스레드를 생성할 수 있습니다.
- 높은 동시성: 블로킹 I/O 작업에서도 스레드가 블로킹되지 않고 효율적으로 처리됩니다.
- 코드의 간결성: 비동기 프로그래밍의 복잡성을 줄이고, 동기식 코드 스타일을 유지하면서도 높은 성능을 제공합니다.
public class VirtualThreadExample {
public static void main(String[] args) {
Thread.startVirtualThread(() -> {
// 가상 스레드에서 실행할 코드
System.out.println("가상 스레드 실행 중");
});
}
}
12.5 자바와 가상 스레드
가상 스레드가 왜 필요하고, 왜 사용해야할까요?
기존 자바 스레드 모델
자바에서는 멀티 스레드를 지원하는 언어라는 것은 다들 알고 계실거에요. 우리가 사용 중인 스프링 프레임워크에서도 멀티 스레드 모델을 사용하고 있죠. 1개의 요청을 1개의 스레드가 처리하는 thread-per-request 방식으로 동작하고 있는데, 동시 요청이 많아진다면 스레드의 수가 증가해야 이를 대응할 수 있습니다.
기존 자바 스레드 모델은 Native Thread 인데요. Java의 유저 스레드를 생성하면 JNI를 통해서 커널 영역을 호출하고 OS가 커널 스레드를 생성해 매핑한 뒤 작업을 수행하는 형태입니다. 또한, 이렇게 생성된 Thread는 1:1로 구현됩니다. 이는 각 Java의 유저 스레드가 OS의 커널 스레드에 1:1로 대응된다는 것을 의미합니다. 이는 아래와 같은 문제점을 가지고 있는데요.
- 컨텍스트 스위칭 비용: 커널 스레드 간 전환에는 높은 비용이 발생합니다.
- 메모리 소모: 각 스레드마다 큰 스택 공간(보통 1MB)이 필요합니다.
- 리소스 제한: OS 커널 스레드의 수 제한으로 인해 많은 동시 작업 처리에 부적합합니다.
Java 유저 스레드가 늘어나면 OS의 커널 스레드도 똑같이 증가해야겠죠? 그럼 당연하게도 컨텍스트 스위칭에 대한 오버헤드가 증가하고, 리소스에 대해서도 문제가 계속 생겨나게 됩니다.
가상 스레드 등장
기존의 자바 스레드 모델 문제점을 해결하기 위해 가상 스레드가 등장하게 되었습니다.
가상 스레드란?
가상 스레드는 경량 스레드로, 하나의 커널 스레드 위에서 여러 개의 가상 스레드가 실행될 수 있습니다. 가상 스레드는 JVM 내에서 유저 스레드로 구현되고, 커널 스레드와 독립적으로 스케줄링될 수 있기 때문이죠. 그래서 이를 "플랫폼 스레드"와 "가상 스레드" 구분할 수 있는데, 플랫폼 스레드 위에서 가상 스레드가 번갈아 가며 실행되는 형태로 동작합니다.
더 많은 요청을 처리하고, 스레드 간 컨텍스트 스위칭 비용을 줄이기 위해 가상 스레드는 어떤 방식으로 동작을 할까요?
가상 스레드의 특징
가장 큰 특징은 기존 스레드와는 달리 가볍기 때문에, 컨텍스트 스위칭 비용이 상당히 낮아진다는 것입니다. 또한, JVM에 의해 관리되기 때문에 실제 커널 영역의 호출이 적어지는 특징도 존재하죠.
- 저비용 생성: 가상 스레드는 1~2KB의 메모리만 필요로 합니다.
- 블로킹 작업 처리: I/O 블로킹 시, 작업을 중단(yield)하고 캐리어 스레드를 반환하여 리소스 낭비를 줄입니다.
- JVM 기반 스케줄링: 운영체제 대신 JVM이 가상 스레드의 스케줄링을 담당합니다.
가상 스레드와 플랫폼 스레드 비교
특징 | 기존 스레드 | 가상 스레드 |
---|---|---|
스레드 관리 | OS 스케줄러 | JVM 내부 스케줄링 |
메모리 사용량 | 최대 2MB | 최대 10KB |
컨텍스트 스위칭 비용 | 높음 | 낮음 |
스레드 생성 비용 | 상대적으로 높음 | 매우 낮음 |
마무리
자바는 초기부터 멀티스레드 지원을 통해 동시성 프로그래밍을 가능하게 했지만, 기존의 플랫폼 스레드 기반 모델은 메모리 소모와 컨텍스트 스위칭 비용 등 여러 한계가 존재했습니다. 이러한 문제를 해결하기 위해 JDK 21에서 가상 스레드가 도입되었으며, 이는 자바에서의 동시성 프로그래밍 패러다임에 중요한 변화를 가져왔습니다.
가상 스레드는 더 많은 동시 요청을 효율적으로 처리할 수 있도록 설계되어 I/O 중심의 애플리케이션에서 특히 강력한 이점을 제공합니다. 또한, 동기식 코드 스타일을 유지하면서도 비동기 프로그래밍의 성능과 확장성을 누릴 수 있게 해주죠.
또한 가상 스레드는 컨텍스트 스위칭 비용이 낮기 때문에, I/O 작업 시 Blocking이 잦아 System Call 이 자주 발생하는 서비스라면 가상 스레드를 사용하는 것이 효율적이지만, 그렇지 않은 경우에는 굳이 도입할 이유도 없어보입니다.