프로그래밍을 하다 보면 고려 사항 중 하나가 동시성이다. 동시성이 왜 발생하는지에 관한 고민의 흐름을 정리하고자 글로 작성하고자 한다.
1. 자바 코드는 어떻게 실행될까?
자바 코드는 어떻게 실행될까? 자바는 기본적으로 컴파일 언어이다. 컴파일 언어란 코드가 실행되기 이전에 컴파일러(compiler) 를 거쳐 소스 코드를 기계어로 번역하는 과정을 거치는 언어를 말한다. 자바는 자바 컴파일러를 통해서 소스 파일을 자바 바이트 코드로 변환한다. 변환된 바이트 코드는 런타임 환경에서 JVM(Java Virtual Machine) 을 통해 OS 가 읽을 수 있는 기계어(binary code) 로 변환하고, 변환된 기계어는 OS 가 읽어 명령을 수행하게 된다.
2. 동시성이 발생 지점인 공유 자원은 어느 영역일까?
동시성가 발생하는 상황을 정의하면 여러 스레드 혹은 프로세스가 공유 자원에 동시에 접근했을 때 데이터 정합성 깨지는 현상을 말한다. 그렇다면 어떤 영역이 공유 영역일까?
OS의 프로세스 메모리 구조를 살펴보면 기본적으로 code, data, heap, stack 영역으로 구분한다. code, data, heap 영역을 공유하지만, stack 영역을 각 스레드가 독립적으로 관리하는 공간이다. 즉, 프로세스는 code, data, heap 가 공유 자원 영역이므로 동시성이 발생할 수 있는 원인 지점이 될 수 있다.
(참고로 data 영역는 전역 변수 및 정적 데이터를 관리, heap 영역은 동적 데이터 관리, code 는 프로그램 소스 코드를 관리한다.)
JVM 에서의 메모리 관리 영역은 Runtime Data Areas 이다. 컴파일된 자바 바이트 코드는 실행되면서 Runtime Data Areas 에서 Method Area 와 Heap 영역에서 메모리를 관리한다. 앞서 OS 프로세스 메모리 구조와 매칭시켜보면 Method Area 은 Data 영역, Heap 은 말그대로 Heap 과 매칭된다. 그러므로 자바 개발자 입장에서는 공유 자원 검토 영역을 우선적으로 Method Area 와 Heap 에서 관리하는 데이터를 고려해야 한다.
3. 자바는 기본적으로 동시성을 보장하지 않는다.
자바는 기본적으로 동시성을 보장하지 않는다. 구체적으로 이야기하자면 여러 스레드가 공유 자원에 동시에 쓰기 작업을 하는 경우, 순서를 제어하지 않으면 예기치 않는 결과를 반환한다. 자바에서 제공하는 자료 구조인 JCF(Java Collections Framework) 또한 기본적으로 동시성을 보장하지 않는다. 실제 java docs 에서 HashMap class 를 살펴보면 해당 클래스는 동시성 제어를 하지 않는다는 문구를 확인할 수 있다. (Note that this implementation is not synchronized!!)
4. 동시성이 발생하는 근본적인 이유를 알아보자.
아래 코드는 동시성 문제 중 하나인 경합 조건(race condition) 이 발생하는 코드이다.
단순히 Heap 영역에 있는 공유 자원을 동시에 접근하기 때문에 동시성 문제가 발생하는 것일까??
class Main {
public static void main(String[] args) {
Audio audio = new Audio("보스 오디오", 100_000);
final Runnable runnable = () -> {
for (int i = 0; i < 50_000; i++) {
audio.decrease(1);
}
};
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
System.out.println("audio.amount = " + audio.amount);
}
private static class Audio {
private String name;
private int amount;
public Audio(final String name, final int amount) {
this.name = name;
this.amount = amount;
}
public Audio(final String name) {
this.name = name;
}
public void decrease(int amount) {
this.amount -= amount;
}
}
}
(1) CPU 기계어 명령은 기본적으로 원자적 연산을 지원하지 않는다.
OS 에서 CPU core 가 명령을 수행하기 위해서는 기계어를 읽어 명령을 수행한다. 하지만, CPU 기계어 명령은 기본적으로 원자적 연산을 지원하지 않는다. 자바 코드에서 +1 을 더하는 연산은 코드 한줄로 보이지만 실제로는 메모리 로드, 더하기 연산, 메모리 저장와 같은 여러 스텝의 기계어 명령으로 구성된다. 원자적 연산을 보장하지 않는다는 의미는 +1 더하기 연산의 여러 스텝으로 구성된 기계어 명령을 모두 정상적으로 수행하거나, 하나도 실행되지 않고 인터럽트 된다는 의미이며, 경쟁 상태에서 두 기계어 이상의 연산으로 구성된 코드가 완료하지 않은 상태로 인터럽트 될 경우 데이터 정합성이 깨지는 현상이 발생할 수 있음을 말한다.
자바는 이와 같은 기계어 연산의 원자성을 보장하기 위해
java.util.concurrent.atomic 패키지 하위에 원자적 연산과 관련된 클래스를 제공한다.
(2) CPU 처리 데이터는 실시간으로 메인 메모리에 데이터가 저장되지 않는다.
멀티 스레드에서 가시성(visibility) 은 공유 변수의 변경 내용이 한 스레드에서 다른 스레드에게 어떻게 보이는지를 나타내는 개념이다. 일반적으로 변경 내용이 메모리에 즉시 반영되기를 기대하지만 실제로는 그렇지 않다.
이유은 CPU Cache 이다. CPU 는 메모리의 데이터 로딩으로 인한 처리 속도 저하를 방지하기 위해 중간 계층으로 CPU Cache 를 도입했다. 이로 인해 CPU 가 데이터 연산 결과를 CPU 캐시에 반영 하더라도 실시간으로 메인 메모리에 반영하지 않아 CPU Cache 간의 데이터 정합성 문제가 발생할 수 있다.
이와 같은 가시성 문제를 해결하기 위해서 자바에서는 volatile 키워드를 제공한다. 공유 변수에 volatile 키워드를 사용하면 CPU 가 명령을 수행할 때 메인 메모리에서 공유 변수를 직접 읽어 수정된 결과를 메모리에서 즉시 반영하기 때문에 가시성 문제를 해결한다.
하지만, 멀티 스레드 환경에서 공유 자원에 대해 N개의 쓰레드가 쓰기를 시도하는 경우 메인 메모리가 공유 자원에 관한 동기화를 지원하지 않기 때문에 동시성 문제가 발생할 수 있어 주의해야 한다.
4. 동시성을 발생하지 않으려면 Thread Safe 구조로 만들어야 한다.
동시성을 여러 스레드가 공유 자원에 동시에 접근하는 상황에서 발생한다. 이를 경쟁조건(race condition) 이라 부른다. 동시성 문제를 해결하는 컨셉은 간단하다. 스레드 세이프(Thread Safe) 구조가 되도록 코드를 작성한다. 스레드 세이프는 다중 스레드가 동시에 같은 코드 영역에 접근하거나 데이터를 공유할 때, 올바른 실행 결과를 보장하는 코드 속성을 말한다. 공유 자원에 다중 스레드가 접근을 제어하거나 단일 스레드만 접근 가능한 환경을 조성하면 동시성 제어를 할 수 있다.
스레드 세이프를 보장하기 위한 전략으로는 상호 배제(mutual exclusion), 불변성(immutabliity), 스레드 로컬(thead-local), 원자적 연산(atomic operation) 등이 있으며 목적과 방법은 아래와 같다.
상호 배제 (Mutual Exclusion)
- 목적 : 여러 개의 스레드가 공유 자원(예: 변수, 데이터 구조, 파일 등)에 동시에 접근하는 것을 방지하여 데이터의 일관성을 유지함.
- 방법 : 잠금 메커니즘을 기반한 공유 데이터에 관한 접근 제어.
- 예시 : synchronized 키워드 또는 java.util.concurrent.locks 패키지의 명시적 락 클래스 사용, 지역 변수 사용
불변성 (Immutability)
- 목적 : 객체가 생성된 이후 상태가 변하지 않도록 하여 동시성 문제를 근본적으로 해결.
- 방법 : 객체를 불변으로 만들어, 생성 시점에만 데이터를 할당하고 이후에는 변경할 수 없게 함.
- 예시 : final keyword 를 사용
스레드 로컬 (Thread-Local)
- 목적 : 각 스레드마다 독립적인 데이터를 유지하여 다른 스레드의 접근을 차단하여 동시성 문제를 해결.
- 방법 : 각 스레드에서 고유한 인스턴스를 생성하고 사용.
- 예시 : ThreadLocal 사용, Copy-on-Write 방식 적용
원자적 연산 (Atomic Operations)
- 목적: 데이터에 대한 연산을 분할 불가능한 단일 단위로 만들어, 동시성 문제 없이 실행될 수 있도록 지원.
- 방법: AtomicInteger 같은 원자적 클래스 사용 또는 CompareAndSwap(CAS) 같은 원자적 연산 활용.
- 예시: 자바의 java.util.concurrent.atomic 패키지 내 클래스들.
5. 회고
이번 글에서는 자바에서 왜 동시성이 발생하며, 단일 프로세스에서 동시성을 해결 가능한 방법에 대해 살펴보았다. 해당 내용에서는 경합 조건(race condition) 을 기반하여 이야기했지만, 동시성 문제에는 경합 조건 외에 데드 락(dead lock), 기아 상태(starvation) 과 같은 다른 동시성 문제가 존재하므로 다양한 동시성 문제를 고려한 설계가 필요하다.
또한 서비스의 성장에 따라 다중 서버가 결합하여 분산 환경을 기반으로 운영하므로 분산 환경에서는 별도의 동시성 제어 방법이 필요하므로 단일 프로세스의 경합 조건(race condition) 문제에 대해 검토한 내용임을 다시 한번 상기시키자.
'java > summary' 카테고리의 다른 글
Java Local Cache 비교하기 (0) | 2025.03.09 |
---|---|
SerialVersionUID를 선언해야 하는 이유 (0) | 2024.11.28 |
Virtual Thread 무엇일까? summary (0) | 2024.11.28 |
자바 비동기로 카페 콘솔 예제 만들기 (1) | 2024.11.26 |
어댑터 패턴(adapter pattern) (1) | 2024.07.09 |