자바 멀티스레딩: 락 최적화 전략, CAS 원리 및 synchronized의 진화 과정

1. 주요 락(Lock) 동작 전략 개요

자바의 동시성 제어에서 다양한 상황에 맞춰 효율적인 락 메커니즘이 사용됩니다. 상황별 특징과 적용 시점은 다음과 같습니다.

전략 (Strategy) 핵심 메커니즘 (Core Mechanism) 적합한 사용 환경 (Use Case)
비관적 락 (Pessimistic Lock) 작업 수행 전 반드시 락을 선점하여 충돌을 원천 봉쇄 데이터 쓰기 작업이 빈번하여 충돌 가능성이 높은 경우
낙관적 락 (Optimistic Lock) 락 없이 작업을 수행하고 커밋 시점에 충돌 여부만 검사 데이터 읽기가 많고 쓰기가 드문 상황
스핀 락 (Spin Lock) 락 획득 실패 시 블로킹되지 않고 반복문(Loop)을 통해 대기 락 보유 시간이 매우 짧아 컨텍스트 스위칭 비용이 아까운 경우
중량 락 (Heavyweight Lock) OS 커널 차원의 모니터(Monitor)를 사용하여 스레드를 Blocking 경쟁이 치열하여 빠른 해결이 어려운 상황
경량 락 (Lightweight Lock) CAS 연산을 통해 락을 시도하며, 실패 시 단순 대기 스레드 간 경쟁이 그리 치열하지 않을 때
편향 락 (Biased Lock) 특정 스레드가 락을 독점한다고 가정하여 추가 동작 생략 실질적으로 단일 스레드만 객체에 접근하는 환경

2. 스핀 락(Spin Lock)의 동작 원리

스핀 락은 락 획득에 실패했을 때 스레드를 바로 잠들게(block) 하는 대신, CPU 자원을 사용하며 계속해서 락이 해제되었는지 확인하는 바쁜 대기(Busy Waiting) 방식입니다.

이 방식의 장점은 스레드의 상태 변화(Running ↔ Waiting)에 따른 커텍스트 스위칭 비용을 절약할 수 있다는 점입니다. 락을 잡고 있는 작업이 아주 짧게 끝난다면, 잠깐 CPU를 써서 기다리는 것이 훨씬 효율적입니다. 반면, 락 보유 시간이 길어지면 CPU 사이클을 낭비하게 되므로, JVM은 이를 방지하기 위해 적응형 스핀(Adaptive Spinning) 기능을 제공하여 상황에 따라 스핀 횟수를 동적으로 조절합니다.

3. 편향 락(Biased Lock)의 특성과 변화

편향 락은 특정 객체가 항상 동일한 스레드에 의해서만 접근된다는 통계적 사실에 기반합니다. 처음 락을 획득한 스레드의 ID를 객체 헤더(Object Header)에 기록해두고, 이후 해당 스레드가 접근할 때는 동기화 검사 과정 없이 바로 작업을 수행하도록 합니다.

하지만 다른 스레드가 경쟁을 시도하면 편향 상태를 해제(Revoke)하고 락을升级해야 하는데, 이 과정의 비용이 생각보다 큽니다. 최근의 하드웨어 및 애플리케이션 환경은 멀티스레드 경쟁이 빈번하므로, JDK 15부터는 편향 락이 기본적으로 비활성화화되어 있습니다.

4. CAS(Compare-And-Swap)와 그 한계점

CAS는 락을 사용하지 않고 원자성을 보장하는 하드웨어 수준의 명령어입니다. 현재 메모리 값이 내가 예상하는 값(기대값)과 같을 때만 새로운 값으로 교체(Swap)하고, 그렇지 않으면 실패를 반환합니다.

자바에서는 `Unsafe` 클래스나 `java.util.concurrent.atomic` 패키지를 통해 이를 구현합니다. 다음은 CAS 연산을 활용한 간단한 카운터 증가 로직의 예시입니다.

import java.util.concurrent.atomic.AtomicInteger;

public class CasExample {
    private final AtomicInteger counter = new AtomicInteger(0);

    public void safeIncrement() {
        int prev, next;
        do {
            prev = counter.get();       // 현재 값 읽기
            next = prev + 1;            // 수행할 연산
            // CAS: prev가 메모리에 여전히 존재할 때만 next로 변경
        } while (!counter.compareAndSet(prev, next));
    }
}

CAS의 주요 문제점 및 해결책:

  • ABA 문제: 값이 A에서 B로 변경되었다가 다시 A로 돌아오면, CAS는 변경이 없었다고 착각합니다. 이를 해결하기 위해 버전 정보나 타임스탬프를 함께 저장하는 AtomicStampedReference를 사용합니다.
  • CPU 오버헤드: 경쟁이 심해 CAS가 계속 실패하면 무한 루프에 빠질 수 있습니다. 앞서 언급한 적응형 스핀 등으로 이를 완화합니다.
  • 복수 변수 원자성: CAS는 단일 변수만 원자적으로 보장할 수 있습니다. 여러 변수를 묶서 업데이트해야 한다면 synchronized나 Lock을 사용해야 합니다.

5. synchronized의 락 업그레이드(Lock Escalation)

JDK 1.6 이전의 synchronized는 OS에 의존하는 무거운 모니터 락이었습니다. 하지만 이후 버전에서는 경쟁 정도에 따라 락의 단계를 동적으로 변경하는 최적화가 도입되었습니다. 락의 상태는 보통 다음과 같이 넘어갑니다.

락 없음 (No Lock) → 편향 락 (Biased) → 경량 락 (Lightweight) → 중량 락 (Heavyweight)

상세 흐름:

  1. 특정 스레드가 처음으로 락을 획득하면 편향 락 상태가 되며, 이 스레드의 ID가 마킹됩니다.
  2. 다른 스레드가 락 획득을 시도하면 편향 락은 해제되고, 경쟁을 피하기 위해 경량 락으로 변경됩니다. 이때 스레드들은 CAS 연산을 통해 락을 획득하려 시도합니다.
  3. 경쟁이 격해져 CAS 시도가 일정 횟수 이상 실패하면, 결국 OS의 자원을 사용하는 중량 락으로 확장(Inflate)되고 스레드들은 블로킹됩니다.

이 과정은 주로 단방향으로 진행되며, 중량 락에서 다시 경량 락으로 낮아지는 경우는 드뭅니다.

6. synchronized와 ReentrantLock의 비교

자바에서 상호배제(Mutual Exclusion)를 구현하는 두 가지 주요 방식의 차이를 분석합니다.

특성 (Feature) synchronized ReentrantLock
구현 방식 JVM 바이트코드 수준의 명령어(monitorenter/exit) 자바 API 기반의 AQS(AbstractQueuedSynchronizer) 구현체
락 해제 코드 블록 종료 시 자동 해제 lock() 호출 후 반드시 unlock()을 명시적으로 호출 필요 (주로 finally 블록에서)
인터럽트 처리 락 획득 대기 중 인터럽트 불가 lockInterruptibly()를 통해 대기 중 인터럽트 가능
타임아웃(Time-out) 지원하지 않음 tryLock(long time, TimeUnit unit)으로 대기 시간 설정 가능
공정성(Fairness) 비공정(Non-fair) 방식만 지원 생성자 옵션을 통해 공정/비공정 선택 가능
조건 변수(Conditions) wait()/notify()만 제공 (단일 조건) newCondition()을 통해 여러 개의 조건 변수 생성 및 정밀한 제어 가능

ReentrantLock의 고급 활용 예시:

import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;

public class AdvancedLockUsage {
    private final ReentrantLock lock = new ReentrantLock(true); // 공정 락 설정
    private final Condition resourceAvailable = lock.newCondition();
    private boolean isReady = false;

    public void performTask() throws InterruptedException {
        // 1. 타임아웃과 함께 락 획득 시도
        if (lock.tryLock(2, TimeUnit.SECONDS)) {
            try {
                // 자원이 준비될 때까지 대기
                while (!isReady) {
                    resourceAvailable.await();
                }
                // 작업 수행
                process();
            } finally {
                lock.unlock(); // 반드시 해제
            }
        } else {
            System.out.println("락 획득 실패: 타임아웃 도래");
        }
    }

    private void process() {
        // 로직 처리
    }
}

7. volatile 키워드와 synchronized의 차이

둘 다 스레드 간의 가시성(Visibility)을 보장하지만, 원자성과 사용 목적에서 명확한 차이가 있습니다.

구분 (Category) volatile synchronized
가시성 (Visibility) 보장 (변수 값의 변경을 메인 메모리에 즉시 반영) 보장 (락 해제 시 메모리 스토어Barrier 작동)
원자성 (Atomicity) 미보장 (단일 읽기/쓰기만 보장, i++ 등 연산은 불안전) 보장 (코드 블록 전체를 원자 단위로 실행)
순서성 (Ordering) 보장 (명령어 재배치 방지) 보장 (모니터 진입/진출 규칙에 의한 순서 보장)
블로킹 (Blocking) 스레드 블로킹 없음 (Non-blocking) 락 획득 실패 시 스레드 블로킹 발생

태그: java concurrency Synchronized CAS Multithreading

10월 8일 09:57에 게시됨