본문 바로가기
Language/Java

[JVM] CPU 캐시와 메인 메모리 사이, @Volatile 어노테이션에 대해 알아보자!

by 시니성 2024. 12. 15.
728x90

들어가며

멀티코어 프로세서가 당연해진 현대의 애플리케이션 개발에서, 동시성 제어는 필수적인 고려사항이 되었습니다.
그 중에서도 @Volatile 키워드는 자바의 동시성 제어에서 가장 기본적인 영역입니다.
그럼에도 저는 사실 부트캠프를 통해 속성으로 프로덕션 코드를 생산하는 방법 위주로 학습했었기에, 잘 몰랐던 부분입니다.
그래서 이번 글을 통해 @Volatile의 동작 원리를 CPU 캐시와 메인 메모리의 관계부터 차근차근 살펴보고, 실제 발생할 수 있는 문제 상황과 해결 방법을 자세히 알아보겠습니다.

1. CPU 캐시와 메모리 가시성 문제

1.1 CPU 캐시는 왜 필요한가?

현대의 CPU는 메인 메모리에 비해 훨씬 빠른 속도로 동작합니다.
만약 CPU가 모든 데이터를 메인 메모리에서 직접 읽고 써야 한다면, CPU는 대부분의 시간을 메모리 접근을 기다리는 데 소비하게 됩니다.

CPU 처리 속도: ~3GHz (0.3 나노초)
메인 메모리 접근 시간: ~100 나노초
L1 캐시 접근 시간: ~1 나노초
L2 캐시 접근 시간: ~3-10 나노초

이러한 속도 차이를 줄이기 위해 CPU는 자주 사용하는 데이터를 캐시에 보관합니다.

1.2 가시성 문제의 발생

문제는 멀티코어 환경에서 발생합니다.
각 CPU 코어는 자신만의 캐시를 가지고 있어, 한 코어가 변수를 수정해도 다른 코어는 이를 즉시 알 수 없습니다.

다음은 이러한 가시성 문제를 보여주는 예제입니다:

public class VisibilityProblem {
    private boolean flag = false;

    public void writer() {
        flag = true; // CPU1의 캐시에만 기록됨
    }

    public void reader() {
        while (!flag) { // CPU2는 자신의 캐시에서 계속 false를 읽음
            // 무한 루프에 빠질 수 있음
        }
    }
}

2. @Volatile의 동작 원리

2.1 메모리 배리어(Memory Barrier)

@Volatile은 JVM에게 해당 변수에 대해 특별한 처리를 하도록 지시합니다. 구체적으로:

  1. 읽기 전 메모리 배리어: 캐시된 값을 무시하고 메인 메모리에서 직접 읽음
  2. 쓰기 후 메모리 배리어: 변경된 값을 즉시 메인 메모리에 기록
public class VisibilitySolution {
    private @Volatile boolean flag = false;

    public void writer() {
        flag = true; // 즉시 메인 메모리에 기록됨
    }

    public void reader() {
        while (!flag) { // 매번 메인 메모리에서 읽음
            // 정상적으로 종료됨
        }
    }
}

2.2 하드웨어 레벨에서의 동작

실제 CPU 레벨에서는 다음과 같은 일이 발생합니다:

  1. MFENCE (Memory Fence) 명령어 발행
  2. 캐시 라인 무효화 (Cache Line Invalidation)
  3. 캐시 일관성 프로토콜 작동

3. 실제 시나리오로 보는 차이점

3.1 주문 처리 시스템 예제

온라인 상점의 주문 처리 시스템을 예로 들어보겠습니다:

public class OrderProcessor {
    private boolean orderComplete = false;  // non-volatile
    private int processedCount = 0;         // non-volatile

    public void processOrder() {
        // 주문 처리 로직
        processedCount++;
        orderComplete = true;
    }

    public void monitor() {
        while (!orderComplete) {
            System.out.println("Waiting... Processed: " + processedCount);
        }
        System.out.println("Order complete! Total processed: " + processedCount);
    }
}

이 코드의 문제점:

  1. orderComplete 값의 변경이 다른 스레드에 보이지 않을 수 있음
  2. processedCount의 증가가 누락될 수 있음

3.2 @Volatile을 적용한 해결책

public class OrderProcessor {
    private @Volatile boolean orderComplete = false;
    private @Volatile int processedCount = 0;

    public void processOrder() {
        // 주문 처리 로직
        processedCount++;
        orderComplete = true;
    }

    public void monitor() {
        while (!orderComplete) {
            System.out.println("Waiting... Processed: " + processedCount);
        }
        System.out.println("Order complete! Total processed: " + processedCount);
    }
}

이제:

  1. orderComplete 값의 변경이 즉시 모든 스레드에 보임
  2. processedCount의 모든 변경이 정확히 반영됨

3.3 성능 고려사항

@Volatile의 사용은 성능에 영향을 미칠 수 있습니다:

// 벤치마크 결과 예시
Non-volatile 읽기: ~1-2ns
Volatile 읽기: ~3-4ns
Non-volatile 쓰기: ~1-2ns
Volatile 쓰기: ~8-10ns

4. @Volatile의 적절한 사용 시나리오

4.1 적합한 경우

  • 상태 플래그 (시작/중지 신호)
  • 단일 쓰레드가 쓰고 다중 쓰레드가 읽는 설정 값
  • Double-Checked Locking 패턴의 구현
public class Singleton {
    private static @Volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

4.2 부적합한 경우

  • 복합 연산이 필요한 카운터
  • 여러 변수의 원자적 업데이트가 필요한 경우
  • 동시성 제어가 필요한 복잡한 데이터 구조

마치며

@Volatile은 멀티코어 환경에서 변수의 가시성을 보장하는 강력한 도구입니다. 하지만 그 사용에는 신중한 고려가 필요합니다. 특히:

  1. 가시성 문제만 해결하며, 원자성은 보장하지 않음
  2. 과도한 사용은 성능 저하를 초래할 수 있음
  3. 적절한 사용 사례를 이해하고 적용하는 것이 중요

올바른 동시성 제어를 위해서는 @Volatile의 특성을 정확히 이해하고, 각 상황에 맞는 적절한 도구를 선택하는 것이 중요합니다.

728x90

'Language > Java' 카테고리의 다른 글

Java의 Enum 클래스와 그 장점(feat. 예시 코드)  (0) 2023.09.12