ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Java] 멀티스레드의 위험과 동시성 제어
    Java 2025. 12. 1. 15:38

    1. 멀티스레딩의 문제점: 힙(Heap) 

    스레드를 여러 개 쓰면 작업 속도는 빨라지지만, JVM 메모리 중 모든 스레드가 공유하는 힙(Heap) 영역의 객체에 동시에 접근하는 순간 문제가 발생합니다.

    참고: 메서드 안에서 선언된 지역 변수(Local Variable)는 스택(Stack) 영역에 생기므로 동시성 문제가 절대 발생하지 않습니다. 문제는 항상 객체의 필드(Member Variable) 처럼 힙 영역에 있는 데이터에서 발생합니다.

    1) 경쟁 상태 (Race Condition)

    경쟁 상태란, 두 개 이상의 스레드가 공유 자원(Shared Resource)에 동시에 접근하여, 자원의 상태를 변경(Write)하려 할 때 발생하는 문제입니다.

    이때 "누가 더 먼저 실행되는가" 혹은 "언제 컨텍스트 스위칭이 일어나는가" 하는 타이밍(Timing)에 따라 결과값이 달라지거나 예측 불가능해지는 상태를 말합니다.

     

    가장 유명한 예제인 count++를 통해 알아봅시다. 개발자 눈에는 코드 한 줄이지만, CPU 입장에서는 3단계의 명령으로 쪼개집니다. 이때 count는 힙 영역에 있는 객체의 멤버 변수라고 가정합니다.

    1. READ: JVM 힙 메모리에 있는 count 값을 읽어와서 CPU 레지스터(임시 저장소)로 가져온다.
    2. MODIFY: CPU 연산 장치에서 값을 1 증가시킨다.
    3. WRITE: 증가된 값을 다시 JVM 힙 메모리의 원래 주소에 저장한다.

    예시 시나리오

    • 상황: 힙 영역에 있는 Counter 객체의 count 변수 값이 10입니다.
    1. 스레드 A가 힙 메모리에서 10을 읽었습니다. (CPU로 가져옴, 아직 힙은 10)
    2. 이때, 갑자기 스레드 B가 동작하여(Context Switch) 힙 메모리에서 10을 읽습니다.
    3. 스레드 A가 자신의 CPU 캐시에서 11로 증가시키고, 이를 다시 힙 메모리에 저장(WRITE)합니다. (힙: 11)
    4. 스레드 B도 (아까 읽어둔 10을 기준으로) 11로 증가시키고, 이를 힙 메모리에 덮어씁니다.
    5. 결과: 두 스레드가 한 번씩 더했으므로 12가 되어야 하는데, 최종 결과는 11이 되었습니다. (업데이트 분실)

    2) 임계 영역 (Critical Section)

    이렇게 여러 스레드가 공유 메모리(Heap)의 데이터를 동시에 접근하면 데이터 무결성이 깨질 수있는 코드 영역을 임계 영역이라고 합니다. 이 영역은 한 번에 하나의 스레드만 진입하여 메모리를 수정하도록 제어(Lock)해야 합니다.

    2. 해결책 1: synchronized (원자성 보장)

    synchronized는 자바에서 상호 배제(Mutual Exclusion)를 보장하기 위해 제공되는 가장 기본적인 키워드입니다.

    멀티스레드 환경에서 여러 스레드가 동시에 접근하면 데이터 무결성이 깨질 수 있는 코드 영역, 즉 임계 영역(Critical Section)에 대해 "한 번에 하나의 스레드만 접근할 수 있도록(One Thread-at-a-time)" 잠금(Lock)을 거는 역할을 합니다.

    동작 원리: 모니터(Monitor)와 객체 헤더

    synchronized 키워드 사용 시, JVM은 객체의 헤더(Header)와 운영체제 레벨의 모니터(Monitor)를 상호작용시켜 동시성을 제어합니다.

    ① 객체 헤더(Object Header)

    Java Heap에 생성된 객체의 헤더에는 Mark Word라는 32bit(혹은 64bit) 메타데이터 공간이 존재합니다.

    • 역할: 객체의 HashCode, GC 세대 정보, 락(Lock) 정보를 저장합니다.
    • Heavyweight Lock 상태: 스레드 경합이 발생하여 락이 고도화(Inflation)되면, Mark Word는 ObjectMonitor 객체를 가리키는 포인터(Address)로 변경됩니다. 즉, 해당 객체의 락 관리를 실제 모니터 시스템에 위임합니다.

    ② ObjectMonitor의 내부 구조

    JVM 내부(C++ 레벨)에서 모니터는 ObjectMonitor라는 구조체로 구현되어 있습니다. 핵심 필드는 다음과 같습니다.

    1. owner (Active Thread)
      • 현재 락(Monitor Lock)을 획득하여 임계 영역에서 실행 중인 스레드의 주소를 저장합니다.
      • _owner가 null이면 락이 해제된 상태이며, 값이 있다면 해당 스레드가 락을 점유 중인 상태입니다.
    2. EntryList (Blocking Queue)
      • 락을 획득하기 위해 진입했으나, _owner가 이미 존재하여 락 획득에 실패한 스레드들이 대기하는 리스트입니다.
      • 이곳에 있는 스레드들은 BLOCKED 상태로 전환되며, OS 스케줄러의 대기 큐에서 관리됩니다.
      • _owner가 락을 반납(monitorexit)하면, JVM은 _EntryList에 있는 스레드 중 하나를 선택하여 실행 권한을 넘깁니다. (일반적으로 FIFO를 보장하지 않음)
    3. WaitSet (Waiting Queue)
      • 락을 획득한(_owner인) 스레드가 실행 도중 wait() 메서드를 호출했을 때 이동하는 공간입니다.
      • wait() 호출 시, 해당 스레드는 _owner 필드를 비우고(락 반납), _WaitSet에 추가되며 WAITING 상태로 전환됩니다.
      • 다른 스레드가 notify() 또는 notifyAll()을 호출하면, _WaitSet에 있던 스레드는 다시 _EntryLis로 이동하여 락 획득을 기다리게 됩니다.

    사용법 1: Synchronized Method (메서드 전체 잠금)

    가장 간편한 방법입니다. 메서드 선언부에 키워드를 붙이면 됩니다.

    public class Counter {
        private int count = 0;
    
        // 1. 인스턴스 메서드 동기화
        // - 잠금 대상: 이 메서드를 호출하는 인스턴스 객체 (this)
        public synchronized void increment() {
            count++; 
        }
    }
    
    • 특징: 메서드 전체가 임계 영역(Critical Section)이 됩니다.
    • 단점: 메서드 내에 동기화가 필요 없는 코드가 길게 포함되어 있다면, 불필요하게 락을 오래 쥐고 있게 되어 성능이 떨어집니다.

    사용법 2: Synchronized Block (필요한 구간만 잠금)

    실무에서 권장하는 방식입니다. 메서드 전체를 막는 게 아니라, 문제가 되는 최소한의 구간(Critical Section)만 감싸서 성능 저하를 줄입니다.

    public class Counter {
        private int count = 0;
        private final Object lock = new Object(); // 락을 위한 별도 객체
    
        public void increment() {
            // ... 동기화가 필요 없는 로직 (예: 로그 출력, 파라미터 검증) ...
            System.out.println("진입"); 
    
            // 필요한 구간만 콕 집어서 잠금
            synchronized (this) { // 또는 synchronized(lock)
                count++; 
            }
    
            // ... 나머지 로직 ...
        }
    }
    

    4) 주의사항: Static Method 동기화

    면접에서 자주 나오는 함정입니다. static 메서드에 synchronized를 붙이면 무엇이 잠길까요?

    public static synchronized void staticIncrement() {
        staticCount++;
    }
    
    • 잠금 대상: 인스턴스(this)가 없습니다. 대신 클래스 객체 (Counter.class) 자체를 잠급니다.
    • 영향: 이 클래스로 만든 모든 객체가 이 메서드를 호출할 때 락을 획득해야 사용할 수 있습니다. 주의해서 사용해야 합니다.

    5) 한계점과 부작용

    synchronized는 강력하지만 만능은 아닙니다.

    1. 성능 저하 (Context Switching):
      • 락을 얻지 못한 스레드는 BLOCKED 상태로 전환되며 CPU 사용을 멈춥니다.
      • 나중에 락이 풀려 다시 실행될 때, 스레드 정보를 복구하는 컨텍스트 스위칭 비용이 발생합니다.
    2. 교착 상태 (Deadlock):
      • 스레드 A는 자원 1을 잡고 자원 2를 기다리고, 스레드 B는 자원 2를 잡고 자원 1을 기다리면 영원히 멈춥니다.
    3. 공정성(Fairness) 없음:
      • 기다리는 스레드들에게 순서를 보장하지 않습니다. 운 나쁜 스레드는 계속해서 락을 얻지 못하는 기아 현상을 겪을 수 있습니다.

    3. 해결책 2: ThreadLocal (스레드 격리)

    동기화(Synchronization)는 근본적으로 공유 자원에 대한 접근을 제어(Blocking)하는 방식이므로 성능 저하가 발생합니다. 반면, ThreadLocal은 자원의 공유 자체를 하지 않고 스레드마다 별도의 저장 공간을 제공합니다.

    1) 개념 및 동작 원리

    ThreadLocal은 변수를 스레드별로 격리하여 저장하는 클래스입니다. 하지만 실제 데이터가 ThreadLocal 객체 내부에 저장되는 것은 아닙니다.

    ① Thread 클래스와 ThreadLocalMap

    Java의 Thread 클래스 내부를 살펴보면 다음과 같은 필드가 존재합니다.

    public class Thread implements Runnable {
        // ...
        // 스레드 자신만의 저장소 (Map)
        ThreadLocal.ThreadLocalMap threadLocals = null;
        // ...
    }
    
    • 저장 위치: 데이터는 ThreadLocal 객체가 아니라, 현재 실행 중인 Thread 객체 내부의 threadLocals 필드에 저장됩니다.
    • ThreadLocal의 역할: ThreadLocal 객체는 단지 현재 스레드의 threadLocals 맵(Map)에 접근하기 위한 액세스 키(Key) 역할을 수행합니다.

    ② ThreadLocalMap의 구조

    ThreadLocalMap은 ThreadLocal 클래스 내부의 정적 내부 클래스(Static Inner Class)로 구현된 커스텀 해시 맵입니다.

    • Key: ThreadLocal 객체 그 자체 (this)
    • Value: 사용자가 저장하려는 변수

    즉, threadLocal.set("A")를 호출하면, JVM은 현재 스레드(Current Thread)를 찾아내고, 그 스레드의 맵에 { key: threadLocal, value: "A" } 엔트리를 저장합니다.

    2) 활용 사례 (Spring Framework)

    Spring 프레임워크는 요청(Request) 단위의 로직 처리가 많기 때문에, 매개변수로 데이터를 계속 전달하는 대신 ThreadLocal을 적극적으로 활용합니다.

    ① Spring Security: SecurityContextHolder

    • 인증된 사용자 정보(Authentication)를 저장하는 SecurityContext는 기본 전략으로 ThreadLocal을 사용합니다.
    • 필터 체인이나 컨트롤러 어디서든 SecurityContextHolder.getContext()를 호출하면, 현재 스레드에 바인딩된 사용자 정보를 즉시 조회할 수 있습니다.

    ② Transaction Synchronization (트랜잭션 관리)

    • @Transactional이 선언된 서비스 메서드에서 리포지토리 메서드를 호출할 때, 동일한 데이터베이스 커넥션을 사용해야 트랜잭션이 유지됩니다.
    • Spring의 TransactionSynchronizationManager는 ThreadLocal을 사용하여 현재 트랜잭션에 할당된 Connection 객체를 보관합니다. 덕분에 개발자가 커넥션을 파라미터로 넘기지 않아도 됩니다.

    3) 주의사항: 메모리 누수 (Memory Leak)와 데이터 오염

    ThreadLocal 사용 시 가장 주의해야 할 점은 Thread Pool 환경에서의 동작 방식입니다.

    ① 스레드 재사용으로 인한 데이터 오염

    WAS(Tomcat 등)는 오버헤드를 줄이기 위해 스레드를 생성해두고 재사용(Thread Pool)합니다.

    • 현상: HTTP 요청 A를 처리한 스레드가 ThreadLocal 값을 비우지 않고 반환됩니다. 이후 HTTP 요청 B를 처리하기 위해 같은 스레드가 할당되면, 요청 B에서 요청 A의 잔여 데이터를 읽게 됩니다.
    • 위험: 타인의 개인정보 노출 등 심각한 보안 사고로 이어질 수 있습니다.

    ② WeakReference와 메모리 누수 (Memory Leak)

    ThreadLocalMap의 Entry는 WeakReference(약한 참조)를 상속받아 구현되어 있습니다.

    static class Entry extends WeakReference<ThreadLocal<?>> {
        Object value; // Value는 강한 참조(Strong Reference)
        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }
    
    • Key(ThreadLocal)는 약한 참조: ThreadLocal 객체 자체에 대한 참조가 사라지면 GC(Garbage Collector)에 의해 Key는 수거됩니다. (Map에서 Key가 null이 됨)
    • Value는 강한 참조: 하지만 Value는 여전히 Entry 객체( 데이터를 저장하기 위해 사용하는 핵심 내부 클래스)에 의해 강하게 참조되고 있습니다.
    • 누수 발생 메커니즘: 스레드 풀의 스레드는 애플리케이션이 종료될 때까지 살아있습니다. 따라서 스레드 내부의 threadLocals 맵도 살아있고, Value 객체도 GC되지 않고 힙 메모리에 계속 남게 됩니다. 이것이 누적되면 OutOfMemoryError를 유발합니다.

    ③ 해결책: remove() 필수 호출

    반드시 작업이 끝나는 시점(주로 Servlet Filter나 Spring Interceptor의 afterCompletion, 혹은 try-finally 블록)에 ThreadLocal.remove()를 호출해야 합니다. 이 메서드는 현재 스레드의 맵에서 해당 엔트리를 완전히 삭제하여 데이터 오염과 메모리 누수를 동시에 방지합니다.

     

    4. 해결책 3: volatile

    synchronized가 락(Lock)을 통해 원자성과 가시성을 모두 보장하는 무거운 동기화 방식이라면, volatile은 가시성(Visibility)만을 보장하는 가벼운 동기화 키워드입니다.

    1) 가시성 문제 (Visibility Problem)

    멀티 코어 프로세서 환경에서 각 코어(CPU)는 성능 향상을 위해 메인 메모리(RAM)에서 읽어온 데이터를 자신만의 CPU 캐시(L1, L2 Cache)에 저장해두고 사용합니다.

    • 문제 상황:
      • Thread A(CPU 1)가 임의의 boolean 타입 변수 flag를 true로 변경했습니다. 이 값은 CPU 1의 캐시에는 반영되었지만, 메인 메모리에는 아직 반영되지 않았습니다(Write-Back 지연).
      • 이때 Thread B(CPU 2)가 flag 값을 읽으려 합니다. 메인 메모리의 값은 여전히 false이므로, Thread B는 변경된 사실을 모른 채 false로 로직을 수행합니다.
    • 정의: 하나의 스레드가 공유 변수를 수정했을 때, 다른 스레드가 그 수정된 값을 즉시 볼 수 없는 현상을 가시성 문제라고 합니다.

    2) volatile의 역할: 메인 메모리 직접 접근

    변수에 volatile 키워드를 선언하면, 자바 메모리 모델(Java Memory Model)은 해당 변수에 대해 다음을 보장합니다.

    • WRITE: 변수의 값을 수정할 때, CPU 캐시가 아닌 메인 메모리에 즉시 기록(Write-Through)합니다.
    • READ: 변수의 값을 읽을 때, CPU 캐시가 아닌 메인 메모리에서 직접 읽어(Direct Read)옵니다.

    즉, volatile은 스레드 간의 데이터 불일치를 해결하여, 한 스레드가 쓴 값을 다른 스레드가 즉시 볼 수 있게 합니다.

    3) 메모리 배리어(Memory Barrier)와 재정렬 방지

    volatile은 단순히 메모리 읽기/쓰기만 제어하는 것이 아니라, 컴파일러와 CPU의 성능 최적화 기법인 명령어 재정렬(Instruction Reordering)을 제한하는 역할도 수행합니다.

    • 명령어 재정렬: 컴파일러나 CPU는 프로그램의 실행 속도를 높이기 위해, 결과에 영향을 주지 않는 선에서 코드의 실행 순서를 임의로 바꿀 수 있습니다.
    • 메모리 배리어: volatile 변수를 읽거나 쓰는 코드 위치에는 메모리 배리어(Memory Barrier)라는 CPU 명령어가 삽입됩니다.
      • Happens-Before 보장: volatile 변수 쓰기 이전의 명령들은 volatile 쓰기 이후로 순서가 바뀌지 않으며, 그 반대도 마찬가지입니다. 이를 통해 순서가 중요한 초기화 로직 등에서 안전성을 보장합니다.

    4) 한계점: 원자성(Atomicity) 보장 불가

    가장 중요한 주의사항입니다. volatile은 가시성은 해결하지만, 원자성은 해결하지 못합니다. 앞서 살펴본 count++ 예제를 다시 보겠습니다.

    1. READ: 메인 메모리에서 count 값(10)을 읽어옴.
    2. MODIFY: CPU에서 1 증가(11).
    3. WRITE: 메인 메모리에 11 저장.

    volatile을 붙이면 1번(읽기)과 3번(쓰기)이 메인 메모리에서 직접 이루어지는 것은 보장되지만, 1번과 3번 사이에 다른 스레드가 끼어드는(Context Switching) 것은 막을 수 없습니다.

    • 결론:
      • 하나의 스레드만 쓰기(Write)를 하고, 나머지 스레드는 읽기(Read)만 하는 상황에서는 volatile이 적합합니다.
      • 여러 스레드가 동시에 값을 쓰고 읽는 상황(예: 카운터, 누적 합)에서는 volatile만으로 부족하며, synchronized나 Atomic 클래스를 사용해야 합니다.

    5) Spring Boot 활용 예시: 백그라운드 작업 제어

    volatile은 '하나의 스레드만 쓰기(Write)를 하고, 나머지 스레드는 읽기(Read)만 하는 경우'에 가장 효율적입니다. 대표적인 예로 무한 루프를 도는 백그라운드 데몬 스레드를 종료(Graceful Shutdown)시키는 패턴이 있습니다.

    다음은 센서 데이터를 수집하는 모니터링 서비스를 구현한 코드입니다.

     

    import org.springframework.stereotype.Service;
    import org.springframework.scheduling.annotation.Async;
    
    @Service
    public class MonitoringService {
    
        // volatile 키워드 사용:
        // 모든 스레드가 항상 Main Memory에서 최신 값을 읽어오도록 보장함.
        private volatile boolean running = true;
    
        /**
         * 센서 데이터를 수집하는 백그라운드 작업 (Worker Thread)
         * @Async를 통해 별도의 스레드(TaskExecutor)에서 실행된다고 가정
         */
        @Async
        public void startMonitoring() {
            System.out.println("모니터링 시작... (Thread: " + Thread.currentThread().getName() + ")");
            
            // running 변수의 값이 메인 메모리에서 변경되었는지 매 반복마다 확인
            while (running) {
                try {
                    // 실제 센서 데이터 수집 로직 (생략)
                    System.out.println("데이터 수집 중...");
                    Thread.sleep(1000); 
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
            
            System.out.println("모니터링 종료.");
        }
    
        /**
         * 작업을 종료시키는 메서드 (Main Thread 또는 HTTP Request Thread)
         * running 상태를 false로 변경하여 while 루프를 빠져나오게 함
         */
        public void stopMonitoring() {
            System.out.println("종료 신호 수신! (Thread: " + Thread.currentThread().getName() + ")");
            
            // 쓰기 작업: 즉시 Main Memory에 false 값을 기록
            this.running = false; 
        }
    }

     

    코드 분석 및 volatile의 필요성:

    1. 상황 설정:
      • startMonitoring()은 @Async에 의해 별도의 스레드(Worker Thread)에서 실행되며 while(running) 루프를 돕니다.
      • stopMonitoring()은 운영자의 요청(Controller 등)에 의해 또 다른 스레드에서 실행됩니다.
    2. 만약 volatile이 없다면? (가시성 문제):
      • Worker Thread는 성능 최적화를 위해 running 변수의 값(true)을 자신의 CPU 캐시(L1/L2)에 로드해놓고 참조할 수 있습니다.
      • 다른 스레드가 stopMonitoring()을 호출하여 running을 false로 바꿔도, Worker Thread는 자신의 캐시에 있는 true만 계속 바라보며 무한 루프에서 빠져나오지 못하는 버그가 발생할 수 있습니다.
    3. 왜 synchronized를 안 썼나?:
      • 이 로직에서는 running 변수를 수정하는 주체(Writer)가 stopMonitoring() 메서드 하나뿐입니다.
      • 여러 스레드가 동시에 값을 변경하는 '경쟁 상태(Race Condition)'가 없으므로, 무거운 락(synchronized)을 걸 필요 없이 가시성만 보장하는 volatile이 성능상 훨씬 유리합니다.

     

    5. 해결책 4: Atomic 클래스와 CAS 알고리즘

    volatile만으로는 count++와 같은 연산의 원자성을 보장할 수 없습니다. 그렇다고 매번 무거운 synchronized를 쓰자니 성능 저하가 걱정됩니다. 이때 사용하는 것이 Atomic 클래스입니다.

    1) Atomic 클래스란?

    java.util.concurrent.atomic 패키지에서 제공하는 클래스들(AtomicInteger, AtomicLong, AtomicBoolean 등)은 synchronized와 같은 락(Lock)을 걸지 않고도, 원자성(Atomicity)과 가시성(Visibility)을 모두 보장합니다.

    • 특징: Non-blocking 동기화 방식을 사용하여, 스레드가 작업을 멈추고 대기하는(Context Switching) 비용을 제거했습니다.
    • 핵심 기술: 이 기술은 하드웨어(CPU) 레벨의 기술인 CAS (Compare-And-Swap) 알고리즘을 통해 이루어집니다.

    2) CAS (Compare-And-Swap) 알고리즘의 동작 원리

    CAS는 소프트웨어 레벨의 처리가 아니라, CPU가 제공하는 원자적 명령어(Atomic Instruction)를 활용하는 기술입니다. (x86 아키텍처의 경우 cmpxchg 명령어)

    CAS 연산은 다음 3가지 인자를 가지고 동작합니다.

    1. V (Value): 메모리 상의 실제 값 (현재 주소에 있는 값)
    2. A (Expected): 내가 기대하는 값 (변경 전 읽어온 기존 값)
    3. B (New): 새로 업데이트하려는 값

     

    동작 로직:

    1. CPU에게 명령합니다. "메모리 위치 V에 있는 값이 A와 똑같니?"
    2. 일치하면(V == A): 아무도 건드리지 않았구나! 안전하게 B로 변경(Swap)하고 true를 반환해.
    3. 불일치하면(V != A): 앗, 그 사이에 다른 스레드가 값을 바꿨구나! 변경하지 말고 실패(false)를 반환해.

    이 과정 자체가 하나의 CPU 명령어이므로 중간에 다른 스레드가 끼어들 수 없습니다(원자성 보장).

    3) 내부 구현: Busy Waiting (Spin Lock)

    Java의 AtomicInteger는 CAS가 실패했을 때 락을 걸고 대기하는 것이 아니라, 성공할 때까지 계속 재시도하는 무한 루프(Loop) 구조를 취합니다. 이를 Busy Waiting 혹은 Spin Lock이라고 합니다.

    실제 JDK 내부 코드(Java 8 기준 Unsafe 클래스 활용 예시)를 논리적으로 재구성하면 다음과 같습니다.

     

    public class AtomicInteger extends Number {
        
        // CAS 연산을 수행하는 하위 레벨의 Unsafe 클래스 (Native Method)
        private static final Unsafe unsafe = Unsafe.getUnsafe();
        private volatile int value; // 가시성 보장을 위해 volatile 사용
    
        public final int incrementAndGet() {
            int expected; // A (기대하는 값)
            int next;     // B (새로운 값)
            
            do {
                // 1. 현재 메모리에 있는 값을 읽어옴
                expected = this.value; 
                
                // 2. 새 값을 계산 (expected + 1)
                next = expected + 1;
                
                // 3. CAS 실행 (하드웨어 명령어 호출)
                // "현재 메모리(value) 값이 expected와 같다면, next로 바꿔라."
                // 성공하면 true 반환 -> 루프 탈출
                // 실패하면 false 반환 -> 루프 재진입 (다시 1번부터 수행)
            } while (!unsafe.compareAndSwapInt(this, valueOffset, expected, next));
            
            return next;
        }
    }

     

    • 성공 시나리오:
      1. 스레드 A가 expected=10을 읽음.
      2. next=11 계산.
      3. CAS(V=10, A=10, B=11) 시도 -> 성공 -> 메모리는 11이 됨.
    • 실패 시나리오 (충돌 발생):
      1. 스레드 A가 expected=10을 읽음.
      2. 그 순간 스레드 B가 끼어들어 값을 11로 바꿈.
      3. 스레드 A가 CAS(V=11, A=10, B=11) 시도 -> V(11)와 A(10)가 다름 -> 실패.
      4. 스레드 A는 do-while 루프에 의해 다시 1번으로 돌아가서 expected=11을 읽고 재시도.

    4) Synchronized vs CAS (비교)

     

    구분 Synchronized (Blocking)  Atomic / CAS (Non-Blocking)
    제어 방식 비관적 락 (Pessimistic Lock) 낙관적 락 (Optimistic Lock)
      충돌이 발생할 것이라 가정하고 아예 막아버림. 충돌이 없을 것이라 가정하고 일단 시도. 실패하면 재시도.
    스레드 상태 락을 못 얻으면 BLOCKED 상태 전환 (Context Switching 발생) 락을 대기하지 않고 RUNNABLE 상태 유지 (Loop 회전)
    CPU 사용 대기 중에는 CPU를 사용하지 않음. 성공할 때까지 루프를 돌므로 CPU를 계속 사용함.
    적합한 상황 임계 영역이 길거나, 충돌이 매우 잦은 경우. 임계 영역이 짧고, 충돌이 적은 경우.

    5) 한계점: ABA 문제

    CAS 알고리즘의 유명한 부작용입니다.

    • 스레드 A가 값을 10(A) 읽음.
    • 스레드 B가 값을 10 -> 50 -> 10으로 변경함.
    • 스레드 A가 다시 와서 보니 값이 여전히 10임. "아무도 안 건드렸네?"라고 판단하고 CAS 성공.
    • 값 자체는 같지만, 데이터의 상태나 버전이 변경된 사실을 감지하지 못할 수 있습니다. (Java에서는 AtomicStampedReference로 해결 가능)

     

Designed by MSJ.