1. 동시성 제어 왜 필요한 것일까?
한정된 자원을 가장 효과적으로 사용해 낭비를 줄이는 경제적 효율성의 원리는 어김없이 서버에도 적용된다. 수 백, 수 천만에 이르는 사용자들의 요청을 1:1로 순차적으로 대응하는 것은 극히 비효율적이고 생산성이 떨어지기 때문에, 요청에 유연하게 대응하기 위해서는 '동시성'은 필연적으로 수반되어야 한다. 요청에 최적의 응답시간을 보장하기 위해 클라이언트 요청마다 스레드를 할당하거나 비동기 / 논블로킹 IO를 사용할 수 있는데, 이 때 공유자원(shared resource)에 대하여 서로 다른 두 스레드가 동시에 조회, 수정하는 일이 발생할 수 있다. 만약 공유 자원에 대한 접근을 제대로 제어하지 않는다면 API의 멱등성이 깨지고, 원치 않는 결과와 장애를 만들어낼 수 있기 때문에 동시성은 신중히 다루어야 한다.

2. 프로세스 수준에서 동시성을 제어하는 방법들
2.1 Lock을 이용한 제어
A lock is tool for controlling access to a shared resource by multiple threads.
- java.util.concurrent.locks.Lock
락은 공유자원에 접근하는 스레드들의 접근을 제어하는 도구다. 락은 공유 자원에 접근하는 스레드를 한 번에 하나로 제한한다(ReadWriteLock과 같은 일부 락은 동시접근을 허용하기도 한다). Mutual Exclusion의 약어로 뮤텍스(mutex)라고도 하며, Java에는 Lock 인터페이스를 구현한 ReentrantLock이 대표적이다.
Lock lock = new ReentrantLock();
lock.lock();
try {
// 임계 구역 - 공유 자원에 접근하는 코드 영역
} finally {
lock.unlock();
}
2.2 세마포어(Semaphore)
ReentrantLock을 비롯한 기본적인 락은 공유자원에 대해 하나의 프로세스(스레드)만 허용하는 반면, 세마포어는 동시에 실행할 수 있는 스레드 수를 제한한다. P(wait) / V(signal) 연산을 통해 퍼밋(permit)을 구하고 반환한다.
Semaphore semaphore = new Semaphore(3); // 최대 3개 허용
semaphore.acquire();
try {
// 자원 사용
} finally {
semaphore.release();
}
2.3 원자적 타입(Atomic Type)
잠금을 사용하면 동시성 문제를 간단하게 해결할 수 잇지만, 여러 스레드가 동시에 실행하고자 했을 때 대기를 하게 됨에 따라 CPU 효율이 떨어지게 된다. AtomicInteger, AtomicLong, AtomicBoolean과 같은 원자적 타입을 사용하면 내부적으로 CAS(Compare And Swap, 락 프리(Lock-Free) / 비차단(Non-blocking) 알고리즘) 연산으로 안전하게 값을 변경할 수 있다. 세부 연산에 대해서는 다음 포스트를 통해 알아본다.
2.4 동시성 지원 컬렉션
HashMap이나 HashSet과 같은 컬렉션을 여러 스레드가 공유하면 동시성 문제가 발생할 수 있다. ConcurrentHashMap 혹은 Collections에서 지원하는 synchronizedMap과 같은 동기화된 컬렉션을 사용하면 동시성 문제를 방지할 수 있다. 세부적인 구현은 다음 포스트를 통해 알아본다.
3. Java가 제공하는 Lock
Java의 lock은 `java.util.concurrent.locks` 패키지에 위치하고 있으며 앞서 언급한 바와 같이 공유 자원에 단일 스레드만 접근할 수 있도록 제어하는 역할을 한다. 사실 Java는 JDK 1.0부터 모니터 락(monitor lock)을 이용한 스레드 동기화 기능인 synchronized 키워드를 제공하고 있었는데, synchronized의 한계를 극복하고 보다 세밀한 동시성 제어를 위해 JDK 1.5부터 Lock이 제공되기 시작했다.
3.1 모니터 락(monitor lock)이란?
[JVM 공식문서] Synchronization is built around an internal entity known as the intrinsic lock or monitor lock.
모니터는 공유 자원과 그 공유 자원을 다루는 함수로 구성된 동기화 도구로, 상호 배제(Mutual Exclusion)과 조건 변수(Condition variable)이 핵심이다. 다시 말해, 상호 배제를 위한 동기화뿐만 아니라 특정 조건 하에서 실행/중단이 가능하도록 실행 순서를 제어할 수 있는 동기화도 지원하는 개념이다.
모니터는 뮤텍스로써 하나의 스레드만 프로세스를 실행할 수 있다. 단, 특정한 조건이 만족되지 않았을 경우, 스레드는 모니터 내부의 대기 큐(wait-set)에 들어가 기다릴 수 있고 다른 스레드가 조건을 만족시켰을 때 signal() 또는 notify()를 통해 대기 중인 스레드를 깨우게 된다.
/**
* @throws IllegalMonitorStateException – if the current thread is not the owner of the object's monitor
*/
public final void wait() throws InterruptedException {
wait(0L);
}
Java 객체의 모니터를 획득해야만 호출가능한 조건 변수 제어 기능들
3.2 동기화 블럭 Synchronized
앞서 언급한 바와 같이 Java는 모니터 기반의 동기화 메커니즘을 synchronized 키워드를 통해 제공하고 있다. 하지만 보다 엄밀하게 말하자면 synchronized는 항시 모니터 락으로 동작하는 것은 아니라는 점이다. Java의 동기화 매커니즘은 최적화 계층에 따라 동작하는데, 이는 OS 수준의 뮤텍스 기반 모니터 락이 비용이 너무 크기 때문이다.
🤔 왜 모니터 락이 비용이 클까?
OS 수준의 동기화 도구를 사용한다는 것은 결국 커널이 개입한다는 것을 의미한다.
락 충돌이 있을 때마다 "커널 전환 + 컨텍스트 스위칭"이 일어난다면 아주 값비싼 연산을 계속해서 사용하는 꼴이 되버림
또한 실제로 synchronized 블록이 대부분 락 경쟁이 거의 없는 상태인 경우가 많음에 따라, 항시 커널 락을 쓸 필요가 없었고 경량 락(lightweight-lock)과 편향 락(biased-lock)이 나타나게 되었다. 다만, 편향 락의 성능과 복잡도에 따라 JDK 15부터는 비활성화 되었다. 결과적으로 synchronized 블록을 호출하게 되면 "경량 락 > 중량 락"의 순서로 단계적 동기화 로직이 동작한다.
이 때 락에 대한 정보를 표기하기 위해서는 Java 객체의 힙 메모리 공간을 알 필요가 있다. Java의 모든 객체는 인스턴스 데이터 외에도 헤더와 정렬 패딩을 위한 추가 메모리 공간이 존재한다. 여기서 헤더는 크게 Mark Word와 Klass Pointer로 나눌 수 있는데 객체의 락 정보는 여기서 Mark Word에 담기게 된다. 그리고 마크 워드는 락 수준에 따라 동적으로 변경되게 된다.
- Mark Word: 락(Lock) 정보, GC 정보, 해시코드(Hashcode) 등 (8바이트, 64bit JVM 기준)
- Klass Pointer: 클래스 메타데이터를 가리키는 포인터
# Object Header
┌─────────────────┬──────────────────┬──────────┐
│ Object Header │ Instance Data │ Padding │
└─────────────────┴──────────────────┴──────────┘
# Lock에 따른 마크워드 변경
┌─────────────────────────────────────────────────────────┐
│ 무잠금(Unlocked) │
│ [해시코드 25bit | GC정보 4bit | 나이 4bit | 플래그 01] │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 경량 락(Lightweight) │
│ [Lock Record 포인터 62bit | 플래그 00] │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 중량 락(Heavyweight) │
│ [ObjectMonitor 포인터 62bit | 플래그 10] │
└─────────────────────────────────────────────────────────┘
경량 락(Lightweight-lock, Thin-lock)
여러 스레드가 접근하긴 하지만 실제 경합이 거의 없는 경우에 사용
- 스레드의 스택에 Lock Record 공간 할당
- 객체의 Mark Word를 Lock Record에 복사(Displaced Mark Word)
- CAS 연산으로 Mark Word를 Lock Record의 포인터로 변경
- 실패하면 짧은 스핀락으로 재시도
- 계속 실패하면 중량 락으로 승급
중량 락(Heavyweight-lock, Inflate-lock)
실제 경합이 발생하면 OS 수준의 뮤텍스를 사용하는 중량 락으로 승급
- Mark Word가 ObjectMonitor를 가리키도록 변경
- 락을 얻지 못한 스레드는 EntryList에 추가되어 블로킹
- 락 소유자가 해제하면 EntryList의 스레드 중 하나를 깨움
- wait()가 호출되면 WaitSet으로 이동
- notify()가 호출되면 WaitSet에서 EntryList로 이동
class ObjectMonitor {
void* _owner; // 락을 소유한 스레드
int _recursions; // 재진입 횟수 (Reentrant)
ObjectWaiter* _EntryList; // 락 대기 큐 (락 요청자)
ObjectWaiter* _WaitSet; // wait() 호출한 스레드 큐
...
os::PlatformMonitor _lock; // OS 수준 락 (예: pthread_mutex)
};
3.3 왜 별도의 Lock이 필요해졌을까
자체로 강력한 synchronized 였지만 보다 유연한 락의 관리를 위해 그리고 높은 비용을 관리하기 위해 JDK 1.5부터 추가되었다. 근원적인 한계는 아래와 같았고 이를 넘어서기 위해 출시되었다. 이러한 한계들의 synchronized와 Lock에서 어떻게 다르게 적용되었는지는 다음 포스트를 통해 알아본다.
- 블록 단위 모니터 락으로 인해, 획득과 해제 번위가 정적으로 고정
- 잠깐 풀었다가 다시 잡을 수 없음
- 예외 발생 시 제어 불가능
- 예외가 발생하면 lock이 해제되지만, Lock은 try-finally로 처리 가능
- 대기·타임아웃·인터럽트 처리 불가능
- 단일 조건 큐만을 제공함에 따라 다중 조건을 관리할 수 없음
3.4 주요한 Lock 구현체 및 관련 클래스
java.util.concurrent.locks 패키지에 포함된 주요 클래스와 인터페이스들에 대해서 간단히 살펴본다.
| Class | Description |
| AbstractOwnableSynchronizer | Lock을 소유하고 있는 스레드를 추적하기 위한 추상 클래스로, 소유자(exclusiveOwnerThread)의 기록/조회 기능만을 제공한다. |
| AbstractQueuedSynchronizer | Lock과 동기화의 핵심 구현 기반 클래스로, Java의 주요한 동시성 제어를 위한 클래스(ReentrantLock, CountDownLatch, Semaphore, ReentrantReadWriteLock)들이 AQS 기반으로 구현되어 있다. 이름 그대로 대기열을 통해 대기하는 구조로 내부적으로 state를 이용해 락의 상태를 표현한다. |
| Condition | 정교한 스레드 제어를 가능하게 한 말 그대로 '조건'이며, synchronized의 객체 모니터 메서드를 대체하는 조건 대기 매커니즘이다. await(), signal(), signalAll()과 같은 주요 메서드를 제공한다. |
| Lock | synchronized 동기화 블럭을 대체하는 Lock 인터페이스로 보다 세밀하게 락을 제어할 수 있는 인터페이스. |
| ReadWriteLock | 읽기/쓰기가 분리된 Lock 인터페이스로, 여러 스레드가 동시에 읽을 수 있지만 쓰기는 단독으로만 가능하게 하는 락 구조를 정의해두었다. |
| ReentrantLock | 가장 대표적인 Lock 구현체로, 동일 스레드가 여러 번 락을 획득할 수 있는 재진입 가능(reentrant)한 구조로 fair 모드와 non-fair 모드를 지원한다. |
| ReentrantReadWriteLock | ReadWriteLock의 구현체로, 읽기 Lock은 공유 모드로, 쓰기 Lock은 배타 모드로 작동하며, 같은 스레드가 읽기/쓰기 Lock에 재진입할 수 있다. |
| StampedLock | Optimistic Lock을 지원하는 고성능 Lock으로, 읽기 작업이 많고 쓰기가 적은 상황에서 낙관적 읽기(Optimistic read)를 통해 락 경합을 최소화한다. 단, 재진입이 불가능하며 Condition을 지원하지 않는다. |
잘못되거나 보완해야할 내용이 있다면 피드백은 언제나 환영입니다🙏