본문 바로가기

JVM

자바의 동기화 매커니즘


"JVM 밑바닥까지 파헤지기" 
책을 읽고 개인적으로 공부한 내용을 작성한 글 입니다.


들어가며
이 책은 번역본이라 단순히 읽는 것만으로는 문맥상 이해하기 어려운 부분이 꽤 있었습니다. 그래서 제가 읽고 이해한 내용을 기반으로 구조를 재배치해 정리한 내용을 공유하려 합니다. 5부 12장은 JVM이 멀티 스레드 환경에서 동기화를 처리하는 방식을 다루고 있습니다. 이를 통해 평소 의미만 알고 사용했던 함수들의 동작 원리와, 유사한 역할을 하는 함수들 간의 차이를 이해할 수 있었습니다.
 

자바의 다중 스레드 환경

자바의 다중 스레드 환경을 이해하기 전에, 하드웨어 멀티태스킹 환경을 먼저 떠올려 봅시다. 책에서 다중 스레드 환경과 함께 하드웨어 멀티태스킹 환경을 설명한 이유는 물리적인 해결 방식을 가상 머신에 적용할 때 유용한 경우가 있기 때문입니다. 두 환경의 세부적인 동작 프로세스는 다를 수 있지만, 기본적인 논리는 유사하다고 볼 수 있습니다.
 

1. 하드웨어 멀티태스킹

최근 대부분의 운영 체제(OS)는 멀티태스킹을 지원하고 있습니다. 그렇다면 하드웨어에서 멀티태스킹 방식을 사용하는 이유는 무엇일까요? 여러 이유가 있지만, 대표적으로 다음 두 가지를 들 수 있습니다.

  1. CPU 연산 처리 성능과 통신 성능(메모리I/O)의 격차
    CPU는 연산 속도가 매우 빠른 반면, 통신 속도는 상대적으로 느립니다. 멀티태스킹은 이런 성능 차이를 보완하여 시스템 자원을 더 효율적으로 활용할 수 있게 합니다.
  2. 처리량 증가
    동일한 작업에 여러 스레드를 동시에 운용하면 처리 효율이 높아지고, 결과적으로 처리량이 증가합니다.

멀티태스킹은 처리 효율을 높이지만, 여러 스레드가 동일한 자원을 동시에 요청하면 경합이 발생해 성능 저하로 이어질 수 있습니다. 단순한 단일 작업 프로세스에 비해 스레드 간 경합을 최소화하기 위한 관리가 필요합니다. 이러한 경합 관리는 멀티태스킹 환경의 성능과 안정성을 크게 좌우합니다.
 

2. 자바의 다중 스레드 환경

이제 자바의 다중 스레드 환경에 대해 살펴봅시다. 자바는 자바 메모리 모델(JMM, Java Memory Model)을 통해 다양한 플랫폼의 메모리 모델로부터 프로그램을 보호하며, 플랫폼에 상관없이 일관된 메모리 접근 방식을 제공합니다. 이 방식이 가능한 이유는 JVM(Java Virtual Machine)이 일관된 메모리 방식을 제공하기 때문입니다. JVM은 메인 메모리작업 메모리를 구분하여 이러한 일관성을 유지합니다.

메인 메모리와 작업 메모리
자바 메모리 모델의 주된 목적은 가상 머신 메모리에서 변수 값을 저장하고 가져오는 규칙을 정의하는 것입니다. 여기서 변수가 포함하는 것은 인스턴스 필드, 정적 필드, 배열 객체의 원소로 제한되며, 지역 변수와 메서드 매개변수는 포함되지 않습니다. 이는 지역 변수와 매개변수가 각 스레드에 독립된 고유 공간을 사용하기 때문입니다.
자바 메모리 모델에서 메모리는 다음과 같이 동작합니다:

  1. 모든 변수는 메인 메모리에 저장됩니다.
  2. 각 스레드는 자체 작업 메모리를 가지고 있으며, 이는 프로세서의 캐시와 유사한 역할을 합니다.
  3. 각 스레드는 메인 메모리에서 데이터를 복사하여 작업 메모리에서 사용합니다.
  4. 스레드 간에는 서로의 작업 메모리에 직접 접근할 수 없으며, 반드시 메인 메모리를 통해 데이터가 전달됩니다.

위 내용을 기반으로 살펴보면, 자바 메모리 모델에서도 메인 메모리와 작업 메모리 간의 동기화 과정이 필요합니다. 이는 멀티태스킹 환경에서 스레드 경합 관리와 유사한 구조로 이해할 수 있습니다.
여러 스레드가 동시에 동일한 메모리 자원에 접근하려고 하면 경합이 발생할 수 있습니다. 이를 해결하기 위해 자바는 동기화 메커니즘(예: synchronized 키워드, ReentrantLock)을 제공합니다. 이러한 동기화는 스레드가 자원을 안전하게 사용하도록 보장하지만, 잘못 설계되면 성능 저하로 이어질 수 있습니다. 따라서 동기화의 필요성과 사용 범위를 적절히 고려해야 합니다.
 

3. 비순차 실행 최적화

앞서 멀티태스킹 환경에서 스레드 간 경합 관리가 성능에 중요한 역할을 한다고 언급했습니다. 이를 좀 더 구체적으로 살펴보면 다음과 같습니다.
멀티태스킹 환경의 가장 큰 이슈는 CPU 연산 속도메모리 I/O 처리 속도 간의 격차입니다. 이를 해소하기 위해 각 프로세서는 캐시라는 자신만의 메모리를 사용합니다. 캐시는 메인 메모리에서 자주 사용하는 데이터를 복사해 오는 임시 저장소로, 이를 통해 프로세서는 메모리 입출력 과정을 반복하지 않고 빠르게 작업을 수행할 수 있습니다.
 
캐시와 동기화 문제
캐시는 성능을 향상시키지만, 여러 프로세서가 동일한 데이터를 각자의 캐시에 보관할 경우 동기화 문제가 발생합니다. 메인 메모리와 캐시 간의 데이터 일관성을 유지하려면, 메모리를 업데이트할 때 어떤 프로세서의 데이터를 기준으로 삼아야 할지 결정해야 합니다. 이를 해결하기 위한 대표적인 프로토콜로 MSI, MESI, Firefly 등이 있습니다. 이들 프로토콜은 각 프로세서가 데이터를 일관성 있게 공유하도록 보장합니다.
 
비순차 실행 최적화
또한, 현대 프로세서는 성능을 극대화하기 위해 비순차 실행 최적화(out-of-order execution)를 사용합니다. 이는 프로세서가 프로그램 명령어를 코드에 작성된 순서와 다르게 실행함으로써, 자원을 더 효율적으로 활용하는 방법입니다. 하지만 이런 최적화는 동시성 문제를 유발할 수 있습니다. 예를 들어, 순서에 의존적인 데이터 처리에서 예상치 못한 결과를 초래할 수 있습니다.
 
동시성 문제 해결 방법
동시성 문제를 해결하기 위해 주로 두 가지 기법이 사용됩니다:

  • 메모리 베리어 (Memory Barrier) : 프로세서의 명령어 재정렬을 제한하여, 메모리 접근 순서를 보장하는 기법입니다. 이는 가시성(visibility) 문제를 해결해 스레드가 일관된 메모리 상태를 보도록 합니다.
  • 뮤텍스 (Mutex) : 특정 자원에 한 번에 하나의 스레드 또는 프로세스만 접근하도록 락(lock)을 걸어, 데이터 일관성을 유지하는 방식입니다. 이는 동시성 문제를 방지하며, 원자성(atomicity)을 보장합니다.

4. 선 발생 원칙

다시 자바로 돌아와, 자바에서 발생할 수 있는 동시성 문제와 이를 해결하기 위한 방법을 살펴보겠습니다. 자바의 멀티스레드 환경에서는 동시성 문제를 유발하는 다양한 요인이 존재하며, 그중 하나가 비순차 실행 최적화(out-of-order execution)입니다. 
 
명령어 재정렬
현대 프로세서와 JVM(Java Virtual Machine)은 성능을 최적화하기 위해 프로그램 코드에 작성된 순서와 다르게 명령어를 실행하거나 재정렬할 수 있습니다. 이러한 최적화는 실행 시간을 단축하고 프로세서의 자원을 최대한 활용하려는 의도에서 이루어집니다. 하지만 멀티스레딩 환경에서는 명령어 실행 순서가 변경되면 예상치 못한 결과를 초래할 수 있습니다. 스레드 간 메모리 가시성 문제(visibility) 또는 데이터 경쟁(race condition)이 발생해 데이터 일관성이 깨질 수 있기 때문입니다. 해당 내용에 관해선 글의 마지막에서 다시 다뤄보겠습니다.
 
선 발생 원칙
앞서 멀티태스킹 환경에서 비순차 실행 최적화를 활용할 때 발생하는 동시성 문제를 해결하기 위해 메모리 베리어(가시성)뮤텍스(원자성) 같은 방법을 사용한다고 설명했습니다. 자바에서도 마찬가지로, 동시성 문제가 발생할 때 가시성과 원자성을 보장하여 이를 해결하는 메커니즘을 제공합니다.
선 발생 원칙(Happens-Before Principle)은 자바 메모리 모델(Java Memory Model)에서 정의된 두 작업 간의 수행 순서 관계를 규정하는 원칙입니다. 이 원칙은 멀티스레드 환경에서 데이터의 일관성을 보장하기 위해 고안되었습니다. 자바에서는 이 원칙을 적용하기 위해 주로 synchronized 키워드와 volatile 키워드를 사용합니다.

  1. volatile (가시성) :
    • 역할 : 특정 변수를 모든 스레드에서 항상 최신 상태로 볼 수 있도록 보장합니다.
    • 특징 : 변수의 가시성을 확보하여, 변경된 값이 즉시 다른 스레드에 반영됩니다.
    • 문제점 : 다른 동기화 도구보다 성능이 더 우수하지만, 원자적 연산을 보장하지 않으므로 복잡한 연산에는 적합하지 않습니다.
  2. synchronized (원자성) : 
    • 역할 : 락(Lock)을 사용해 특정 블록이나 메서드를 동기화함으로써 원자적 연산을 보장합니다.
    • 특징 : 한 번에 하나의 스레드만 동기화된 블록에 접근할 수 있어, 데이터의 안전한 사용을 보장합니다. 내부적으로 monitorenter와 monitorexit 명령어를 통해 락을 획득하고 해제합니다.
    • 문제점 : volatile보다 느릴 수 있으나, 가시성과 원자성을 모두 보장합니다.

 
volatile 키워드의 문제점
앞서 메모리 가시성(visibility) 문제에 대해 여러 차례 언급하였습니다. volatile 키워드는 동시성 문제를 해결하는 데 있어 몇 가지 한계를 가지고 있습니다. 다음 예제는 volatile이 가진 주요 문제를 보여줍니다

public class VolatileTest {
      public static volatile int race = 0;
  
      public static void increase() {
          race++;
      }
  
      private static final int THREADS_COUNT = 20;
  
      public static void main(String[] args) {
          Thread[] threads = new Thread[THREADS_COUNT];
          for (int i = 0; i < THREADS_COUNT; i++) {
              threads[i] = new Thread(new Runnable() {
                  @Override
                  public void run() {
                      for (int i = 0; i < 10000; i++) {
                          increase();
                      }
                  }
              });
              threads[i].start();
          }
  
          // 다른 모든 스레드가 종료될 때까지 대기
          while (Thread.activeCount() > 1)
              Thread.yield();
  
          System.out.println(race);
      }
  }
  • race 변수에 volatile 키워드가 사용되었지만, 이는 단순히 가시성(visibility)을 보장할 뿐, 복합 연산(race++)의 원자성(atomicity)을 보장하지 않습니다.
  • 여러 스레드가 동시에 이 연산을 수행하면, 값이 덮어씌워지거나 일관되지 않은 결과가 발생할 수 있습니다.

Java 5 이전의 메모리 모델에서는 volatile이 명령어 재정렬(instruction reordering)을 방지하지 못했습니다. 이로 인해 명령어 재정렬로 인한 동작 오류와 같은 문제가 발생할 수 있습니다. 이외에도 싱글톤 객체를 생성할 때 성능 최적화를 위해 더블 체크 락킹에서도 같은 문제가 발생할 수 있습니다. Java 버전 5 이후의 개선된 메모리 모델에서는 volatile의 키워드가 명령어 재정렬을 방지하여 선 발생 원칙이 성립됩니다. 하지만 여전히 명령어 재정렬을 방지할 뿐 원자성을 보장하지 않기 때문에 syncronized 키워드보다 빠른 수행 속도를 보장합니다.
 

5. 결론

  • 복합 연산이 필요하고 원자성을 보장해야 하는 경우 -> syncronized 키워드 사용
  • 단순한 읽기 / 쓰기 연산처럼 원자성 보장이 필요 없는 경우 -> volatile 사용