개발 일기

[이해하기] JVM에서의 Lock

Pleasant Pain 2025. 3. 14. 10:58

개요

 

JVM 환경에서의 스레드 v1 [스레드에 대한 이해]

개요S/W 개발자로서 성능 최적화의 핵심 요소인 스레드에 대해 정리한다 목적효율적으로 성능 최적화하기 위해, 멀티 스레드 환경에서 무엇을 고려해야하는지 명확히 이해하기 위함즉, 멀티 스

pablo7.tistory.com

 

 

목적

  • Lock에 대한 깊이있는 이해를 통해 상황에 따라 동시성 문제를 어떻게 해결해야하는지 판단하는 힘을 기른다

 

😵  동시성 문제 (동기화 문제)

  • 앞 글에서 동시성 문제를 이미 언급했으나,
    이 글의 주된 포커싱이 Lock이며, Lock은 '동시성 문제 해결'과 연관이 깊은 만큼, 다시 한번 동시성에 대해 설명한다
"도로에서 교차로를 지나가는 차들"
신호등 없이 여러 차량이 동시에 교차로를 지나가려 하면 충돌 사고가 날 수 있다

스레드들도 마찬가지로 공유 자원(변수, 객체)을 동시에 수정하면 충돌이 발생한다
해결책? 신호등(락, 동기화 기법)을 사용해서 한 번에 한 차량(스레드)만 지나가게 한다

 

class Counter {
    private int count = 0;

    public void increment() {
        for (int i = 0; i < 10000; i++) { // 10000번 ++ 연산
            count++;
        }
    }

    public int getCount() {
        return count;
    }
}

public class SynchronizedExample {
    public static void main(String[] args) {
        Counter counter = new Counter();

        // 여러 개의 스레드가 동시에 count 값을 증가시킴
        Thread t1 = new Thread(counter::increment);
        Thread t2 = new Thread(counter::increment);

        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("최종 카운트: " + counter.getCount());
    }
}
최종 카운트: 17555
  • 위 예시 코드와 같이 공유하는 `Count` 객체에 대해 두 스레드가 동시에 연산을 하게되면 
    1만번(반복문) * 2(스레드 개수) = 20,000 이라는 `count` 값을 기대할 수 있으나,
    실제로 결과는 17,555가 나왔다(매번 다름)
    • 위와 같은 코드에서 각 스레드가 단순히 초기화된 count 값을 읽기만 했다면,
      공유하는 자원에 대한 값의 변화가 없음으로 두 스레드가 동시에 접근했다고 하더라도 아무런 문제가 없다

    • 즉, 어떠한 부분에서 동시성 문제가 발생되는지 명확히 구분하는 것이 가장 우선 사항이다
  • 이렇게 공유하는 데이터를 동시에 연산하려고 할 때, 동시성 문제를 해결하기 위해서 JVM 환경에서는 Lock을 활용하게 된다

 

🔐  Lock - 동시성 문제 해결의 핵심 키워드

  • Lock은 말 그대로 특정 스레드가 [어떤 자원]을 본인이 사용 중이니, 사용이 끝날 때까지 자물쇠로 막아두겠다는 뜻과 같다
  • 따라서 공유 자원에 Lock을 걸어두면 아무리 많은 스레드가 동시에 접근하더라도 ‘질서 있는 접근’을 보장한다
  • 하지만 이 세상에 Trade off가 없는 기술이 있겠는가? 항상 Lock 활용에 따른 성능 저하를 감안해야한다
  • 그리고 이러한 성능 저하를 최소화하기 위해 JVM에서는 다양한 Lock을 지원해주고 있다

 

1️⃣  synchronized (가장 기본적인 Lock) 

  • JVM 환경에서 가장 간단하게 Lock을 걸 수 있는 방법이 바로 synchronized 키워드 활용이다
  • 동시성 문제가 발생된 코드를 그대로 활용해서 예시 코드를 만들면 아래와 같다
class Counter {
    private int count = 0;

    public synchronized void increment() { // 배타적 락
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    public int getCount() {
        return count;
    }
}

public class SynchronizedExample {
    public static void main(String[] args) {
        Counter counter = new Counter();

        // 여러 개의 스레드가 동시에 count 값을 증가시킴
        Thread t1 = new Thread(counter::increment);
        Thread t2 = new Thread(counter::increment);

        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("최종 카운트: " + counter.getCount());
    }
}
최종 카운트: 20000
  • 위와 같이 `increment()` 메소드에 synchronized 키워드를 활용해 간단히 해결했다
  • 여기서 명확히 이해할 사항 2가지가 있다
  1. Lock이 걸린 주체
  2. Lock을 거는 주체
  • 위 코드에서 누가 Lock이 걸렸고, 누가 Lock을 걸었는가? 이걸 명확히 설명할 수 있어야한다

 

🔐 Lock이 걸린 주체 (잠금을 당하는 대상)

  • Lock이 걸린 주체는 바로 Counter 클래스의 인스턴스인 count
    • WHY?
      increment() 메서드는 인스턴스 메서드이므로,
       해당 메서드가 실행되는 객체(Counter의 인스턴스)가 Lock이 걸린 주체가 된다
  • 만약 Counter 클래스의 다른 인스턴스 count2가 추가로 있다면,
    당연하게도 count2 인스턴스의 increment() 메서드는 t1, t2 스레드에 영향을 받지 않는다.
    즉, count2는 또 다른 별개의 Lock이 존재하는 것이다

  •  따라서 위와 같은 Counter Class 코드에서는 인스턴스 단위로 Lock이 걸린다

 

🔑 Lock을 거는 주체 (잠금을 실행하는 주체)

  • 위 코드에서 생성된 t1과 t2 스레드는 공유된 counter 객체의 increment() 메서드를 실행한다
  • increment() 메서드는 synchronized가 걸려 있으므로 한 스레드가 실행 중이면 다른 스레드는 대기해야 한다
  • 즉, t1 또는 t2가 increment()를 호출하면 스레드가 Lock을 거는 역할을 수행한다
    • 어디에? 바로 인스턴스인 count에

 

🤔 그럼 Lock이 걸리는 주체는 인스턴스 단위로만 가능한 걸까?

  • 아니, Lock이 걸리는 것은 클래스 레벨에서도 가능하다
  • 클래스 레벨로 Lock이 걸리는 가장 쉬운 방법은 메서드를 인스턴스 메서드가 아닌 클래스 메서드로 만들면 된다
    => HOW? static 정적으로 붙박이 메서드로 만들면 되는 것이다
class Counter {
    private int count = 0;

    public static synchronized void increment() { // 배타적 락
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    public int getCount() {
        return count;
    }
}
  • 위와 같이 `increment()` 메서드에 static 키워드를 활용해, 클래스 메서드로 구현하면,
    인스턴스를 생성해서 호출하든, 인스턴스를 생성하지 않고 호출하든, 클래스 레벨에서 Lock이 걸리게 된다 

  • 또 다른 방법으로 동기화 블록에서 Class 자체를 선언하는 방법도 있다
class Counter {
    private int count = 0;

    public void increment() { 
        synchronized (Counter.class) { // 배타적 락
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        }
    }

    public int getCount() {
        return count;
    }
}
  • 위와 같이 구현 시 `increment()` 메서드는 인스턴스 메서드이나,
    동기화 블록에서 클래스 레벨로 Lock이 걸리기 때문에, 
    어떠한 쓰레드에서도, 어떠한 Counter 클래스의 인스턴스에서도 동일한 Lock이 걸리게 된다

 

😰  아니.. 인스턴스 또는 클래스 단위의 Lock이 하나로 관리되는 건가?

  • 필자는 Lock이 걸리는 주체가 인스턴스 또는 클래스 단위인 것을 이해하게 된 후 아래와 같은 궁금증이 생겼다
  • 만약 synchronized 키워드가 붙은 메서드가 여러개라면?
    • Lock이 인스턴스 또는 클래스 단위로 1개의 Lock으로 관리된다면,
      특정 메서드 a()가 잡아먹는 시간 때문에, 다른 별개의 메서드 b()를 실행 못하고 대기해야하나?
  • 예를 들면 아래와 같다
class Counter {
    private int count = 0;

    public synchronized void increment() { // 🔒 인스턴스(객체) 단위 Lock
        count++;
    }

    public synchronized int getCount() { // 🔒 인스턴스(객체) 단위 Lock
        return count;
    }
}
  • 위와 같이 Lock이 걸리는 메서드가 2개가 존재한다면?
    • 인스턴스를 생성하여 특정 스레드가 `increment()` 메서드를 호출하든, `getCount()` 메서드를 호출하든
      인스턴스 내부적으로 하나의 Lock을 공유하므로,
      `inrement()` 메서드와 `getCount()` 메서드 둘다 서로 영향을 받게 된다

    • 즉, 같은 Lock을 공유한다면, 서로 분리되어 있는 로직이라 할지라도 블로킹 당하는 것이다
      (사실 따지고 보면 count 변수의 값을 일관되게 처리하려면 둘다 블로킹 당하는게 맞긴하지만..)
  • 위 문제를 해결하는데 필자가 가장 먼저 떠오른 해결법은 메서드별로 클래스를 분리하는 것이었다
  • 그런데 찾아보니 더 좋은 해결법이 있었다
class Counter {
    private int count = 0;
    private final Object lock = new Object(); // 🚀 별도의 Lock 객체 생성

    public synchronized void increment() {  
        synchronized (lock) { // 🔒 특정 객체(lock)에 대해 동기화
            count++;
        }
    }
    
    public synchronized int getCount() { // 🔒 인스턴스(객체) 단위 Lock
        return count;
    }
}
  • 위와 같이 동기화 블록을 Counter의 인스턴스(this)가 아닌 별도의 객체를 둠으로서 Lock을 분리시키는 것이다
  • 여기서 필자가 기억해야할 중요한 점은 Lock은 인스턴스 또는 클래스 단위로 내부적으로 공유한다는 점이다
  • 그렇기에 다른 인스턴스 또는 다른 클래스의 Lock이라면 서로 별도의 Lock이라는 점도 잘 이해하자

 

🦮 Lock에 대한 접근 방식

  • 위에서는 JVM 환경에서 제공하는 가장 간단한 Lock인 synchronized 활용을 알아보았다
  • Lock은 동시성 문제를 해결할 수 있는 아주 주요한 개념이지만, 이를 위해 성능 저하라는 꼬릿말이 항상 따라 올 수 밖에 없다
  • 따라서 필자는 개발자로서 성능 저하를 얼마나 최소화하면서 Lock을 활용할 수 있는가가 매우 중요한 관점이라고 생각한다 
    • 즉, Lock이 정말 필요한 부분과 Lock 불필요한 부분을 가려내기 위해 깊이 고민해야한다
    • 또한, [데이터 안정성 <-> 성능 저하]라는 Trade off를 처한 환경에 따라 얼마나 깊이있게 고려할 수 있느냐겠다
  • 이로 인해 아래와 같은 관점으로 Lock 거는 것에 대한 2가지 접근 방식이 나온게 아닌가 싶다 

 

😞  '비관적' 락 (Pessimistic Lock)

  • 공유 자원에 접근할 때, 문제가 발생할 가능성이 있다고 가정하고, 무조건 락을 먼저 거는 방식
    • 즉, "일단 락을 걸고 보자!"라는 접근 방식, "경합을 방지하기 위해 선제적으로 보호"하는 개념
  • 위에 언급된 synchronized를 활용한 락이 비관적 락의 대표적인 예

📌 예제 (synchronized를 이용한 비관적 락)

class SharedResource { 
    private int value = 0; 
    public synchronized void updateValue() { // 비관적 락 
    	System.out.println(Thread.currentThread().getName() + "가 값을 변경 중..."); 
        value++; 
    } 
 }
  • 여러 스레드가 접근하더라도 락을 선제적으로 걸기 때문에 충돌을 피할 수 있다

 

😌  '낙관적' 락 (Optimistic Lock)

  • 비관적 락과 반대로 "경합이 발생하지 않을 거라고 가정"하고, 락을 걸지 않는 방식
    • 락을 걸지 않고 데이터를 수정하려고 시도
    • 하지만 다른 스레드가 먼저 수정했다면, 변경을 무효화하고 다시 시도
    • 즉, 낙관적 락은 "필요할 때만 다시 시도"하는 방식이다
  • 결국 Lock을 사용하지 않는데 이름을 Lock으로 지어서 좀 당황스러웠다..

  • 보통 CAS(Compare-And-Swap)같은 알고리즘을 사용해서 구현함
    • 아직 Atomic Class에 대해 설명하지 않았지만, 낙관적 락으로 Atomic Class를 활용한 아래와 같은 예시 코드가 있다 
class OptimisticLockExample { 
    private AtomicInteger value = new AtomicInteger(0); 
    public void increment() { 
        int oldValue, newValue; 
        do { 
            oldValue = value.get(); 
            newValue = oldValue + 1; 
        } while (!value.compareAndSet(oldValue, newValue)); // CAS 연산
    } 
}
  • 위 AtomicInteger 클래스의 value 값은 CAS 연산 덕분에 동시성 문제가 해결된다
  • CAS 연산의 구현 방식을 간단히 정리하면 아래와 같다
    • 데이터를 수정하기 전에 현재 데이터의 상태를 읽어두고 (예를 들어, 버전 번호나 타임스탬프 같은 정보),
    • 작업이 끝난 후, 데이터가 그동안 변경되지 않았다면 그대로 적용.
    • 만약 다른 스레드가 데이터를 수정한 후라면 충돌이 발생한 것으로 보고, 롤백하거나 다시 시도하는 방식.
  • 하지만 수정이 완료되기 전에 데이터가 변경되는 충돌이 자주 발생한다면?
    • 오히려 반복해서 재시도를 해야하므로 성능 저하가 발생하고, 경우에 따라 비관적 락이 더 나을 수 있다
    • 또한, 금융과 같이 매우 민감한 도메인이라면 데이터 정합성을 위해 성능을 포기하더라도 비관적락을 선택할 것 같다

 

🔐  JVM 환경에서 제공하는 다양한 Lock

  • 위 Lock에 대해 언급한 것과 같이, Java에서는 성능 저하를 최소화하기 위해 다양한 Lock을 지원해주고 있다

1️⃣ synchronized (가장 기본적인 Lock)

  • 위에서 정리한 것과 같이, 가장 기본적인 Lock으로 인스턴스 단위 또는 클래스 단위로 Lock을 걸 수 있다

 그런데 synchronized 키워드로 인한 Lock을 누가? 어떻게? 관리해주는 걸까?

  • 누구긴 역시 JVM이다, 근데 어떻게? JVM이 Lock 관리를 어떻게 해주는데?
  • JVM 내부적으로 Lock을 관리해주는 매커니즘을 이해하는 것은 생각보다 간단하지 않았다
    • 정신 똑바로 차리고 여기서 확실하게 이해하고 넘어가자

🖥️ synchronized로 인한 Lock을 JVM이 어떻게 관리할까?

  • 바로 JVM 내부에서 객체의 헤더(Object Header)에 있는 mark word를 활용하여 락을 관리한다

✔ 객체의 헤더 구조 (Object Header)

  • 객체의 헤더는 다음과 같이 구성된다
Mark Word (마크 워드) Class Pointer 기타
락 정보, 해시값, GC 정보 클래스 메타데이터 포인터 정렬 패딩

=> Mark Word(마크 워드) 에 락 관련 정보가 포함되며, JVM이 이 값을 변경하면서 락을 관리한다

 

🔹 synchronized가 걸리면 JVM 내부에서 벌어지는 일을 순서대로 살펴보자

1️⃣ 스레드가 synchronized 블록에 진입

  • JVM이 해당 객체의 모니터 락을 획득하려고 시도
  • 객체의 헤더(Mark Word)를 확인하여 락이 걸려 있는지 확인한다

2️⃣ 락이 없다면?

  • 현재 스레드가 Mark Word를 수정하여 락을 획득
  • 이후 다른 스레드는 해당 객체에 접근할 수 없다

3️⃣ 이미 다른 스레드가 락을 가지고 있다면?

  • 현재 스레드는 JVM의 모니터 큐(Monitor Wait Queue)에 대기한다
  • 락이 해제되면 JVM이 대기 중인 스레드를 깨워서 락을 획득하게 한다

4️⃣ synchronized 블록이 끝나면?

  • JVM이 객체의 Mark Word를 원래 상태로 되돌리고, 대기 중인 스레드가 있다면 순차적으로 락을 넘겨줌

즉, JVM 내부에서 객체 헤더의 Mark Word에 락 정보를 관리하여 동시성을 제어했던 것이다
=> 자, 여기서 객체 헤더에 대해 알게 되었고, 이를 JVM이 관리한다는 것도 알게 되었다


하지만 여기서 또 다른 워딩이 존재 한다. 바로 '모니터 락'

대충 느낌은 락 상태를 모니터링 해서 붙인 이름이 아닐까 싶은데..

확실히 인지하고 넘어가는게 좋을 것 같았다

 

 모니터락이 뭔데?

  • 알아보니 synchronized로 인해 JVM 내부에서 관리하는 Lock은 3가지의 상태가 존재했다..!
    (GPT 형님 덕)
  • 먼저 3가지 Lock을 아래와 같이 정리할 수 있었다

🔹 JVM이 관리하는 락 종류

 

Lock 상태 설명 특징
Bias Lock (편향 락) 락 경쟁이 거의 없는 경우 락을 재사용하여 성능 최적화
Lightweight Lock (경량 락) 락 경쟁이 적은 경우 CAS(Compare-And-Swap) 사용
Heavyweight Lock (중량 락, 모니터 락) 락 경쟁이 심한 경우 OS 수준의 락, OS 스레드 블로킹 발생

📌 락 상태 변화

  • 기본적으로 Bias Lock(편향 락)을 사용하다가 경쟁이 발생하면 Lightweight Lock(경량 락)으로 전환하고,
  • 경량 락에서 경쟁이 심해지면 Heavyweight Lock(모니터 락)으로 변경한다

위와 같이 락 상태를 3가지로 두고,
Lock 경쟁이 심화될 수록 Bias Lock -> Lightweight Lock -> Heavyweight Lock으로 변경하는 것이다
(물론 똑똑한 JVM이 알아서 바꿔준다)

 

이유가 무엇일까? 역시 성능 최적화를 위해서 Lock 상태를 3개나 설계한 것 아닐까?

그럼 대체 어떻게 성능이 최적화된다는 것인지 이해하기 위해 더 자세히 알아보자

 

락의 상태 변화 과정 (Lock Escalation & De-escalation)

✅ 기본 원칙

  • 처음부터 Heavyweight Lock(모니터 락)이 아닌, 가능하면 가벼운 락(Bias Lock, Lightweight Lock)으로 유지하려고 함
  • 경쟁이 심해질수록 더 강한 락으로 승격(Escalation)
  • 경쟁이 줄어들면 다시 가벼운 락으로 강등(De-escalation)

 

전체적으로 락 상태 변화 흐름을 살펴보자

1️⃣ Bias Lock (편향 락) [기본 상태]

🟢 락 경쟁 없음 → 락을 획득한 스레드가 계속 사용하면 락 체크 자체를 생략

  • synchronized가 있어도 처음부터 락을 걸지는 않는다
  • 특정 스레드가 한 번 락을 획득하면 이후엔 락 체크 없이 바로 실행한다
  • 스레드 경쟁이 발생하면 해제되고 Lightweight Lock으로 이동한다

🟢 Bias Lock 동작 방식

public class BiasLockExample { 

    private static Object lock = new Object(); 

    public static void main(String[] args) {
        synchronized (lock) { // ① 처음에는 Bias Lock이 걸림 (Lock 획득, 카운트 1)
            synchronized (lock) { // ② 같은 Lock이므로 블로킹 없이 바로 실행! (Lock 획득, 카운트 2)
                System.out.println("Bias Lock 상태에서 실행 중");
            } // ③ 내부 synchronized 블록 탈출 → Lock 카운트 1 감소 (카운트 1)
        } // ④ 외부 synchronized 블록 탈출 → Lock 카운트 1 감소 (카운트 0, 완전 해제)
    }
}
  • 중첩된 블록에서 같은 Lock 이기 때문에 락 체크는 발생하지 않으나 내부적으로는 Lock 획득이 카운팅 된다
    • 즉, 위 코드에서는 `lock` 객체의 대한 Lock 카운팅이 2번 일어나게 된다

 

 

2️⃣ Lightweight Lock (경량 락) [스레드 경쟁 발생]

🟡 경쟁이 생기지만 심하지 않다면 CAS(Compare-And-Swap) 연산을 사용해 빠르게 락을 획득

  • Bias Lock 상태에서 다른 스레드가 락을 획득하려 하면, Bias Lock을 해제하고 Lightweight Lock으로 변환한다
    • Lock 상태 변경은 비용이 크므로 잦은 상태 변경은 성능 저하를 초래할 수 있다
  • CAS(비교 후 교체) 방식으로 OS 수준의 스레드 블로킹 없이 락을 관리한다
    • 심한 경쟁이 없으면 이 상태를 유지한다
  • 하지만, 다수의 스레드가 경합하면 Heavyweight Lock으로 변경된다

🟡 Lightweight Lock 동작 방식

public class LightweightLockExample { 

    private static Object lock = new Object(); 
    
    public static void main(String[] args) { 
        new Thread(() -> { synchronized (lock) { 
            System.out.println("스레드 1이 Lightweight Lock 획득"); 
         }}).start(); 
         
         new Thread(() -> { synchronized (lock) { 
             System.out.println("스레드 2가 Lightweight Lock 획득"); } 
         }).start(); 
     } 
 }

 

3️⃣ Heavyweight Lock (중량 락, 모니터 락) [스레드 경쟁 심화]

🔴 다수의 스레드가 경쟁하면 결국 OS 수준의 락을 걸고, 스레드를 블로킹하는 방식으로 동작

  • Lightweight Lock에서 CAS 실패율이 높아지면, JVM이 OS 커널 수준의 락을 사용한다
  • OS에서 스레드를 직접 블로킹하고, 락이 해제될 때까지 대기한다
  • 가장 무거운 락이며, 성능 저하가 가장 심하다

🔴 Heavyweight Lock 동작 방식

public class HeavyweightLockExample { 
    private static Object lock = new Object(); 
    
    public static void main(String[] args) { 
        for (int i = 0; i < 10; i++) { // 여러 개의 스레드가 동시에 경쟁하면 Heavyweight Lock으로 변환 
            new Thread(() -> { synchronized (lock) { 
                System.out.println(Thread.currentThread().getName() + "이(가) Heavyweight Lock 획득"); 
            }}).start(); 
        } 
    } 
}

 

거꾸로 락 상태의 강등(De-escalation)도 가능하다

  • 스레드 경쟁이 줄어들면 JVM이 다시 Heavyweight → Lightweight → Bias Lock 순으로 되돌릴 수 있다
  • 단, Bias Lock은 한 번 해제되면 다시 활성화되지 않음

🙆🏻‍♂️ JVM에서 관리하는 Lock에 대한 결론

✅ JVM에서 관리하는 락은 처음부터 모니터 락(Heavyweight Lock)이 아니다
✅ JVM은 가벼운 락(Bias Lock, Lightweight Lock)부터 시작하여 락 경쟁이 심해질수록 Heavyweight Lock으로 승격한다
✅ 반대로 경쟁이 줄어들면 다시 락을 가볍게 유지하려고 한다
✅ 경쟁이 적다면 synchronized를 써도 큰 성능 저하는 발생하지 않는다

 

 

자... 여기까지 JVM내부에서 관리하는 Lock 상태를 알아보았다

성능이 가장 안 좋은 것은 Lock 획득 경쟁이 심화 될 때 OS 스레드 수준의 Lock이 발생되는 것이라 하며,
이것이 모니터 락(Heavyweight Lock)이라 한다


OS 스레드의 Lock은 뮤텍스라고 하며, 
(뮤텍스(Mutex)는 상호 배제(Mutual Exclusion)의 약자로 락(Lock)이라고도 한다)

OS 스레드에서 Lock이 성능이 안 좋은 이유는, 필연적으로 컨텍스트 스위칭이 발생하기 때문이었다

컨텍스트 스위칭(Context Switching)이란?

CPU가 실행 중인 스레드를 바꿀 때 발생하는 과정이다

CPU Core는 한 번에 하나의 스레드만 실행할 수 있다.

멀티스레드 환경에서는 CPU Core가 여러 스레드를 빠르게 바꿔 실행하면서 마치 동시에 실행되는 것처럼 보이는 게 된다.

그런데 스레드를 바꿀 때는 이전 스레드의 실행 상태(레지스터 값, 프로그램 카운터, 스택 정보 등)를 저장하고
새로운 스레드의 실행 상태를 복원해야 한다

이 과정이 바로 컨텍스트 스위칭이다
컨텍스트 스위칭이 많아지면 CPU가 실제로 연산을 수행하는 시간이 줄어들고, 스위칭 오버헤드가 증가하게 된다



즉, OS 스레드 수준의 Lock을 활용하지 않기 위해 => 즉, JVM에서 Lock을 관리하기 위해

Bias Lock, Lightweight Lock 단계를 둔 것으로 판단된다

 

그런데 필자는 여기서 가장 의문이 들었다

분명 애초에 JVM 스레드는 OS 스레드와 1:1 매치 구조라고 학습했는데..?

즉, JVM 스레드는 OS의 스레드 없이는 애초에 동작할 수 없는 관계라고 이해하고 있었다

 

그런데 여기서 갑자기 JVM의 모니터 락은 OS 스레드 수준의 Lock으로 성능 저하가 가장 심하다 한다..

그렇다면 다른 Lock은 OS 스레드 수준의 Lock이 아닌 것인가?

즉, JVM 스레드는 OS 스레드와 1:1 매치 구조이나, Lock 관리는 별개로 보는 것인가?
그리고 왜 JVM 스레드의 Lock보다 OS 스레드 수준의 Lock이 성능 저하가 심각하다는 말인가?

이 부분이 명확히 이해가 될 때까지 정리를 해본다

 

기존에 알고 있던 것들부터 다시 정리해보자

  • 현대의 JVM에서 JVM의 스레드와 OS의 스레드는 1:1 매치 구조가 맞다
  • 하지만 Bias Lock, Lightweight Lock 같이 JVM 내부적으로 Lock을 관리하는 기법이 존재한다
    • 그렇다면 이때, JVM 스레드는 Lock이 걸린 상태이나, OS 스레드는 Lock이 걸리지 않는 상태가 된다
    • 즉, 자바의 스레드가 WAITING, BLOCKED 상태 ≠ OS에서의 스레드 블로킹
  • 여기서 CPU 입장에서 생각해보자
    • 결국 OS 스레드를 통해, CPU Core가 연산을 해준다

    • 아무리 성능이 좋더라도 (클럭) CPU Core 수 만큼만 동시에 연산을 처리할 수 있다

    • 그러므로 기본적으로 진정한 멀티 태스킹은 CPU 코어수 만큼의 OS 스레드 개수다
      따라서 CPU 코어 수 보다 OS 스레드가 더 많이 생성됐다면,
      CPU 코어들은 단 하나의 스레드만 맡아서 연산 해줄 순 없다

    • 그런데 모든 OS 스레드가 항상 바쁘게 돌아가는 것이 아니기 때문에,
      즉, CPU 연산이 계속 필요한 것이 아닐 때도 있기에,
      CPU 코어들이 연산이 필요한 시점에 적절히 OS 스레드들을 왔다갔다하며 연산해준다.
      사람이 느끼기엔 이렇게 CPU가 OS 스레드들을 왔다갔다 연산해주는게 너무 빨라서 동시에 처리되는 것으로 착각할 수 있다

    • 또한, CPU 코어들이 OS 스레드들을 왔다갔다하는데는 컨텍스트 스위칭 비용이 발생한다
      결국 컨텍스트 스위칭이 많이 발생하지 않는 환경이어야 CPU가 더 안정적으로 성능을 발휘할 수 있겠다

    • 하지만 OS 스레드의 Lock(뮤텍스)이 발생하면 OS 스레드가 블로킹 상태가 되면서,
      블로킹 할 때의 컨텍스트 스위칭 비용과 다시 다른 스레드로 왔다갔다하는데 드는 컨텍스트 스위치 비용이 더블이 된다

    • 그러므로 컨텍스트 스위칭 비용을 최소화하는데는 OS 스레드의 Lock(뮤텍스)이 사용되지 않는게 더 유리하겠다

    • 물론 JVM 레벨에서(유저 모드) 관리되는 스레드 Lock은 CAS 같은 연산 때문에,
      CPU Core의 연산 작업이 들 수 있겠지만,
      CAS 연산은 CPU 어셈블리 레벨에서 하나의 명령어 수준으로 매우 간단하고 빠르므로,
      CPU 입장에서 그리 부담되는 작업이 아니다

    • 또한 CPU 4코어에 8개의 OS 스레드가 작동 중이라 할지라도,
      연산 작업이 별로 없는 스레드들은 CPU가 그만큼 왔다갔다 하지 않아서 컨텍스트 스위치 비용도 많이 들지 않는다

  • 단편적으로 봤을 때, 위와 같이 모니터 락이 성능 저하가 가장 심하다는 말이 틀린 말은 아닌 것 같다
    하지만.. 과연 항상 그런한가? 모니터 락이 오히려 성능적으로 우수한 상황도 있지 않을까?
    • OS 스레드 Lock이 발생했다고 치자.
      그로인해 다른 스레드들이 블로킹 되면서 컨텍스트 스위칭이 발생되고,
      Lock 획득에 따른 다시 블로킹을 해제하면서 추가적인 컨텍스트 스위칭이 발생한다고 치잔 말이다

    • OS 스레드 Lock이 발생되면서 Lock 획득을 시도하는 여러 스레드가
      2번 씩은 컨텍스트 스위칭이 발생하는 것은 분명 성능 저하의 원인이 될 수 있다

    • 그런데 CPU가 2 Core이고 총 10개의 OS 스레드가 JVM 스레드 10개와 매칭되어 동작한다 가정해보자
      또한, JVM 스레드 수준에서 Lock을 관리할 것이고 10개의 스레드가 동시에 Lock 획득을 요청할 것이라 가정한다
      추가로 Lock을 획득하여 작동되는 로직은 최소 10초가 걸린다고 가정해보자
      • 자.. 10초가 걸리는 작업을 최소 10개의 스레드가 1개씩 작업을 처리할테니,
        총 작업 시간은 최소 100초의 시간은 소요되겠다

      • 이 긴 시간 동안, 2개의 CPU Core들은,
        처음 10초 동안은 9개의 스레드들의 내부적인 Lock 획득을 위해 컨텍스트 스위칭이 발생할 것이며,
        다음 10초 동안은 8개의 스레드들의 내부적인 Lock 획득을 위해 또 컨텍스트 스위칭이 발생할 수 있고, 
        그 다음 10초 동안은 7개의 스레드들의 내부적인 Lock 획들을 위해  또 컨텍스트 스위칭이 발생될 수 있다..
        적어도 한번에 실행할 스레드가 2개밖에 남지 않은 상황이 되어서야 컨텍스트 스위칭 발생이 사라질 것 아닌가?
        필자가 단순 계산해 봤을 때 최소 [5번 + 4번 + 4번 + 3번 + 3번 + 2번..]..
        최소 20번 이상은 컨텍스트 스위칭이 발생할 것 같다
         
        WHY? 처음 10초 동안 9개의 스레드들이 Lock 획득을 하기 위해 CAS 연산을 요청할 것 아닌가?
        10초간 몇번의 컨텍스트 스위칭이 발생될지 가늠하기 어렵지만, 적어도 최소 1번씩은 발생할 것이라 예상했다

      • 반대로 처음부터 OS 스레드 수준의 Lock이 걸렸다면,
        처음 9개 스레드이 블로킹 되면서 총 18번의 컨텍스트 스위칭이 발생 될 수 있겠지만,
        다중 JVM 스레드에서 지속된 CAS 연산 요청으로 인해 발생되는 컨텍스트 스위칭보다 훨씬 합리적인 횟 수 일수 있다
        (Read Lock을 학습하며 이 지식도 잘못됐음을 알게 된다)
        (OS 스레드 수준에서 처음 9개의 Lock이 걸렸다면 총 18번의 컨텍스트 스위칭이 아닌,
          9+8+7+6+5... 번의 컨텍스트 스위칭으로 굉장히 많은 컨텍스트 스위칭이 발생된다)
        하지만 CAS 연산이 계속 반복해서 이뤄진다면 컨텍스트 스위칭이 많이 발생되긴 할 것 같다

      • 따라서 OS 스레드 수준의 Lock이 성능적으로 가장 나쁘다는 것은 결국 상황에 따라 다를 수 있다고 봐야한다

  • 위와 같은 고찰을 통해, JVM 내부적으로 관리하는 Lock의 상태를 처음에는 가벼운 락으로 시작하지만,
    Lock 획득 경쟁이 심해질 수록 무거운 락(OS 스레드 수준의 락)으로 상태를 변이하는게 매우 타당한 방안이었다는 생각이 들었다

  • 이에 대해 명확히 인지하기 위해 성능 측정 실습을 수행하려한다
    • [JVM 스레드 수준에서의 락 VS OS 스레드 수준에서의 락] 성능 측정 비교는 다음 챕터에 정리하겠다

여기서부터는 Java 라이브 러리에서 제공되는 Lock이다
즉, JVM에서 처리해주는 Lock이 아닌, 자바 코드로 관리되는 Lock으로 이해할 것이다

 

2️⃣  ReetrantLock (재진입이 가능한 Lock)

  • 네이버 영문 사전에서는 찾지 못 했으나, Reetrant은 "재진입성"이라는 뜻을 갖고 있다고 한다
    • 즉, synchronized 블록에서 같은 객체 또는 같은 클래스 Lock이라면 중복해서 블록 진입이 가능 했듯이,
      ReetrantLock 또한 동일한 스레드에서 여러번 Lock 획득이 가능하며,
      내부적으로 카운팅하여 모든 Lock이 unlock 되어야 다음 스레드에게 Lock 획득의 기회가 주어진다

🤔 그럼 자바 코드로 어떻게 Lock을 관리하며, 어떻게 스레드들에게 Lock 획득을 부여할까?

      ReentrantLock에서 Lock 획득 실패 시 동작 과정에 대해 정리해보자

  • ReentrantLock은 낙관적(lock-free) 방식을 사용하기 때문에,
    스레드는 처음부터 바로 대기 상태로 들어가지 않고, 먼저 적극적으로 CAS 연산을 반복 시도한다

1. 먼저 CAS 연산을 통해 Lock 획득을 시도

  • state == 0이면, compareAndSetState(0, 1) 을 수행하여 Lock 획득을 시도한다
  • 만약 성공하면 바로 Lock을 획득하고 이후 로직을 실행한다
  • 실패하면 조금 더 CAS 연산을 재시도 한다

2. 일정 횟수 동안 CAS 연산을 반복 시도 (낙관적 접근)

  • 짧은 시간 안에 Lock이 해제될 수도 있기 때문에 Lock을 획득하려는 스레드는 일정 횟수 동안 tryAcquire()를 반복 수행한다
  • 여기서 일정 횟수라는 것은 CPU 연산 비용을 고려한 AQS 내부 정책에 따라 결정된다
  • 경쟁이 심하지 않다면, 자바 스레드가 여기서 Lock을 획득하고 실행될 수도 있다

3. 일정 횟수 동안 CAS 연산이 실패하면, 대기 큐에 들어감

  • 계속해서 CAS 연산에 실패하면 대기 큐에 Node를 만들어 등록한다
  • 이때 LockSupport.park()을 호출하여 자바 스레드는 WAITING 상태로 전환되며 블로킹된다

📌 즉, 스레드는 처음부터 WAITING 상태로 들어가지 않고,
     먼저 여러 번 CAS 연산을 수행한 뒤에야 대기 큐에 들어가게 된다!

    이렇게 하면 짧은 시간 동안 Lock이 해제될 가능성이 높은 경우,
     대기 큐로 가지 않고 바로 Lock을 획득할 수도 있기 때문이다

 

🎯 이런 방식의 장점: 불필요한 컨텍스트 스위칭을 줄인다

  • 만약, 즉시 WAITING으로 들어간다면 OS 컨텍스트 스위칭 비용이 발생할 확률이 매우 높아진다
    • 해당 자바의 스레드 WAITING 상태가 된다면, 더이상 연산이 발생되지 않을 것이고
      이에 따라 매핑된 OS 스레드가 CPU에 연산 요청을 하지 않으니,
      CPU는 다른 연산이 필요한 OS 스레드의 요청으로 이동하면서 컨텍스트 스위칭이 발생한다는 것이다
  • 하지만, Lock이 빨리 해제될 수도 있으므로, 먼저 CAS를 몇 번 더 시도한 후 블로킹되도록 설계한 것이다

쉽게 이해가 되나 싶었는데, ReetrantLock을 정리하는 과정에서 처음보는 AQS라는 단어가 나왔다..
AQS에 대해 찾아보니 AbstractQueuedSynchronizer를 축약한 단어였고,
Abstract 키워드가 붙은 것을 보아, 추상 클래스로 설계된 것으로 추측이 되었다

 

여기서 AQS에 조금이나마 파악하고 넘어가야 할 것 같다

 

🔹 AQS(AbstractQueuedSynchronizer)의 동작 방식

  • AQS는 Java에서 제공하는 여러 동기화 도구의 기반이 되는 핵심 구조라고 한다
    • 즉, ReentrantLock 뿐만 아니라 ReadWriteLock, CountDownLatch, Semaphore 등
      자바 라이브러리에서 제공하는 여러 Lock들이 AQS를 기반으로 구현되어있다

🔹AQS의 핵심 개념

  • AQS는 FIFO(First-In-First-Out) 대기 큐를 이용해 스레드를 관리한다
  • State 값을 사용해 현재 Lock의 상태를 나타내고, 대기 큐(Linked List)를 사용해 기다리는 스레드를 관리한다
  • AQS의 주요 개념은 다음과 같다
    • state: 공유 자원의 상태를 나타내는 변수 (ex. Lock을 획득했는지 여부)
    • CAS 연산: state 값을 원자적으로 변경하여 Lock을 관리
    • Node: AQS의 대기 큐에 들어가는 개별 스레드 정보를 담은 객체
    • CLH 큐 (FIFO Queue): 대기하는 스레드들이 연결된 FIFO 대기 큐
      • Craig, Landin, Hagersten 사람 이름 앞글자만 따서 CLH라고 부르는 것 같음
      • CLH 큐 덕분에 먼저 Lock 획득을 요청한 스레드가 순서에 맞게 Lock을 획득할 수 있게 된다

AQS의 작동 흐름

🔵 Lock을 획득하는 과정
1️⃣ tryAcquire() 호출

  • state 값을 CAS 연산으로 변경하여 Lock을 시도한다
  • 성공하면 바로 Lock을 획득하고 종료한다
  • 실패하면 다음 단계로 진행한다

2️⃣ 대기 큐에 등록

  • Lock 획득 실패한 스레드는 Node를 생성하여 AQS의 CLH 큐에 등록한다
  • prev → current → next 형태로 연결된 FIFO 큐를 유지한다

3️⃣ 대기 상태로 전환 (park())

  • LockSupport.park(this); 를 호출하여 해당 자바 스레드는 WAITING 상태로 전환된다
  • 자바 스레드가 WAITING 상태가 되므로, 매핑된 OS 스레드도 더 이상 CPU를 점유하지 않게 된다
  • 이때, OS 스케줄러는 컨텍스트 스위칭을 통해 다른 OS 스레드에게 CPU를 할당할 수 있다

4️⃣ Lock이 해제되면, 다음 스레드에게 신호

  • Lock을 해제하는 스레드가 unpark()를 호출하여 대기 중인 자바 스레드가 다시 RUNNABLE 상태로 전환된다
  • 깨어난 스레드는 다시 tryAcquire()를 시도한다

🔴 Lock을 해제하는 과정

1️⃣ state 값을 감소시키면서 Lock 해제

  • Lock이 해제되면 unpark()을 호출하여 WAITING 상태였던 head 다음 스레드를 RUNNABLE 상태로 만든다
  • 해당 스레드는 CAS 연산을 통해 다시 Lock을 획득한다

2️⃣ 대기 큐에서 제거

  • Lock을 획득한 스레드는 FIFO 큐에서 제거되며, Lock 획득 이후 로직들이 실행된다

🟢 AQS의 장점

✔ 스레드가 너무 많은 CAS 연산을 수행하지 않도록 블로킹으로 전환
✔ FIFO 대기 큐를 사용해 공정한 Lock 획득이 가능
✔ 다양한 동기화 도구 (ReentrantLock, Semaphore 등)에 활용 가능

 

🟢 AQS와 synchronized의 차이

비교 항목 synchronized AQS 기반 Lock (ReentrantLock 등)
구현 방식 JVM 모니터 락 (OS 지원) Java 코드에서 직접 구현
대기 방식 OS 레벨 블로킹 AQS 대기 큐 기반 블로킹
성능 경쟁이 심할 경우 성능 저하 공정성 옵션, tryLock() 등으로 조정 가능
공정성 공정하지 않음 (JVM 정책에 따름) 공정성 설정 가능 (FIFO)
  • 필자는 여기서 성능에 대한 의문을 갖게 되었다
    • 확실히 AQS 기반 Lock이 syncrhonized에 의한 모니터 락 보다 성능적으로 좋은게 맞나?
  • 의문을 품은 이유는 다음과 같다
    • CAS 연산으로 빠르게 Lock 획득이 안되는 경우엔 자바의 스레드가 WAITING 상태가 된다
    • 그렇다면 OS 스레드가 CPU 점유를 더이상 못 하게 된다
    • CPU 점유를 못 한다는 건 원래 연산하던 CPU가 다른 스레드의 연산을 한다는 것과 같다
    • 결국 컨텍스트 스위칭 비용이 필연적으로 발생한다
    • 즉, 필자가 생각했을 때, 컨텍스트 스위칭 발생되는 비율은 크게 차이가 나지 않을 것 같다
  • 그런데 확실하게 컨텍스트 스위칭에서 synchronized로 인한 OS 스레드의 Lock보다 확실히 이점이 생기는 부분이 있었다
  • 어떻게보면 AQS 기반의 Lock이 더 좋다기 보단, OS 스레드 Lock이 더 안 좋은 점이라 할 수 있겠다
    • 먼저 OS 스레드 수준의 Lock은 그냥 이뤄지는게 아니라, JVM 모니터 락이 시스템 콜을 호출해 처리되게 된다

    • 시스템 콜을 호출하는 비용 또한 만만치 않기에 그냥 CPU가 컨텍스트 스위칭하는 것보다,
      시스템 콜 + 컨텍스트 스위칭이 더 비용이 많이 들 수 밖에 없다

    • 이제 여기가 핵심이다,
      OS 스레드 수준의 Lock이 해제될 때, 블로킹 중이던 스레드들이 모두 한번에 다시 경합하게 된다
      따라서 synchronized는 "N개의 스레드가 깨어나서 다시 경쟁"하므로,
      재경쟁이 발생할 때마다 최대 (N-1)번의 불필요한 컨텍스트 스위칭이 추가로 발생할 수 있음.
      • 만약 10개의 OS 스레드가 Lock이 해제될 때까지 대기 중 이었다면?
        Lock이 해제됨으로서 10개의 OS 스레드가 대기 상태에서 풀려나 Lock 획득 시도를 하게 된다
        즉, OS 스레드 수준의 Lock은 Lock이 획득되고 해제될 때 마다,
        다수의 스레드에서 컨텍스트 스위칭이 발생될 수 밖에 없는 구조다

      • 그런데 AQS 기반에서는 대기 큐를 활용하여 Lock이 해제될 때,
        1개 스레드씩 상태를 바꿔 CAS 연산을 재시도하는 구조이므로,
        여러 스레드가 다시 병합할 필요가 없어,
        AQS로 인한 스레드 10개가 대기 중이라 할지라도,
        상대적으로 OS 스레드 수준의 Lock보다 컨텍스트 스위칭이 덜 발생 된다
        (대부분의 경우에서 말이다)
  • 위 근거에 따라,
    [AQS 기반의 자바 스레드 Lock 성능] VS [JVM 모니터 락에 의한 OS 스레드 수준의 Lock 성능] 을 비교한다면,
    대다수 경우에서 AQS 기반의 자바 Lock이 시스템 성능을 높일 수 있겠다고 판단되었다

자.. 여기까지는 자바 라이브러리의 Lock이 OS 스레드 수준의 Lock보다
성능적인 이점이 있다는 것을 정리한 수준에 불과하다

 

드디어 본격적으로 ReetrantLock의 특징을 알아보겠다,
먼저 ReetrantLock을 활용한 간단한 예시 코드는 다음과 같다

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {
        Runnable task = () -> {
            lock.lock();  // 🔒 Lock 획득
            try {
                System.out.println(Thread.currentThread().getName() + " 가 Lock을 획득했습니다.");
                Thread.sleep(1000); // 임의의 작업 수행
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();  // 🔓 Lock 해제
                System.out.println(Thread.currentThread().getName() + " 가 Lock을 해제했습니다.");
            }
        };

        Thread thread1 = new Thread(task, "Thread-1");
        Thread thread2 = new Thread(task, "Thread-2");

        thread1.start();
        thread2.start();
    }
}
Thread-1 가 Lock을 획득했습니다.
Thread-1 가 Lock을 해제했습니다.
Thread-2 가 Lock을 획득했습니다.
Thread-2 가 Lock을 해제했습니다.

[ ReetrantLock의 특징 ]

  • JVM이 알아서 Lock 해제를 해줬던 synchronized와 다르게, 개발자가 꼭 unlock을 해줘야한다
    따라서 try ~ finally에서 finally 블록에 필히 unlock(); 을 호출하도록 설계해야한다
    (이건 모든 자바 라이브러리 Lock이 동일함)

  • AQS 관련해 언급한 것과 같이 OS 스레드 수준의 Lock이 아닌, 자바 코드로 관리되는 Lock으로,
    CAS 연산과 대기 큐를 활용해 Lock을 관리한다

  • `ReetrantLock(true)` 생성자를 통해 공정성 기능 추가가 가능하다
    • 여기서 공정성이란 먼저 Lock 획득을 시도한 스레드 순서에 맞춰 Lock을 제공해주는 것을 뜻한다
    • 공정성이 없는 경우보다 성능이 조금 더 떨어질 순 있다

  • tryLock(), tryLock(시간) 기능을 제공한다
    • 락 획득을 시도하고 실패하면 재시도하거나 대기 상태로 빠지는게 아닌, false를 리턴한다
    • 또는 시간을 설정해서 설정된 시간 만큼만 Lock 획득을 재시도 한다
    • 경우에 따라 이 메서드를 활용하면 스레드 낭비를 줄일 수 있겠다 판단된다

  • lockInterruptibly()는 인터럽트가 걸리면 대기 중이던 스레드를 깨울 수 있다
    • 긴급 종료가 필요한 스레드라면 lockInterruptibly()을 사용할 수 있겠다

 

3️⃣ ReadWriteLock (데이터 읽기와 쓰기가 분리된 Lock)

✅ [ Read - Write ] 말 그대로 읽기/쓰기 Lock을 뜻한다

     ReadWriteLock의 핵심적인 개념은 아래와 같다

  • Read Lock: 여러 스레드가 동시에 수행 가능하다
  • Write Lock: 한 번에 한 스레드만 수행 가능하다
  • Write Lock이 활성화되면 Read Lock도 차단된다
    • 즉, Write Lock이 발생하면 Read Lock도 같이 대기해야 한다
  • 따라서, ReadWriteLock을 사용할 때는 Read Lock이 Write Lock보다 더 자주 발생하는 경우에 효과를 제대로 볼 수 있다 
    즉, 읽기 작업이 많은 경우 성능 최적화에 유리하다
import java.util.concurrent.locks.ReentrantReadWriteLock;

class SharedResource {
    private int data = 0;
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    public void write(int value) {
        lock.writeLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + " - Writing: " + value);
            data = value;
            Thread.sleep(1000); // 실제 작업 시뮬레이션
            System.out.println(Thread.currentThread().getName() + " - Write Complete");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.writeLock().unlock();
        }
    }

    public int read() {
        lock.readLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + " - Reading: " + data);
            Thread.sleep(500); // 실제 작업 시뮬레이션
            return data;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return -1;
        } finally {
            lock.readLock().unlock();
        }
    }
}

public class ReadWriteLockExample {
    public static void main(String[] args) {
        SharedResource resource = new SharedResource();

        // 여러 개의 읽기 스레드
        Runnable readTask = () -> {
            for (int i = 0; i < 5; i++) {
                resource.read();
            }
        };

        // 하나의 쓰기 스레드
        Runnable writeTask = () -> {
            for (int i = 0; i < 3; i++) {
                resource.write(i);
            }
        };

        Thread writer = new Thread(writeTask, "Writer");
        Thread reader1 = new Thread(readTask, "Reader1");
        Thread reader2 = new Thread(readTask, "Reader2");

        reader1.start();
        reader2.start();
        writer.start();
    }
}

 

✅ 코드 설명

  1. SharedResource 클래스:
    • ReentrantReadWriteLock을 사용하여 읽기/쓰기 락을 관리한다
    • write(int value): writeLock을 획득한 후 데이터를 변경한다
    • read(): readLock을 획득한 후 데이터를 읽는다
  2. ReadWriteLockExample 클래스:
    • readTask: 데이터를 읽는 여러 개의 Reader 스레드 생성한다
    • writeTask: 데이터를 변경하는 Writer 스레드 생성한다
    • Reader1, Reader2, Writer 스레드를 실행하여 동시 접근을 시뮬레이션

✅ 실행 흐름

  1. Writer 스레드는 writeLock을 획득하고 데이터를 갱신함
  2. Reader1Reader2 스레드는 readLock을 획득하고 데이터를 읽음
  3. 여러 개의 Reader 스레드는 동시에 실행될 수 있지만, Writer가 실행되면 Reader들은 대기해야 한다

여기서 주의할 점이 있다..

Read Lock이 발생될 때, Read Lock이 해제되기 전까지 Write Lock도 획득이 불가하다는 점이다!

즉, Read Lock과 Write Lock은 동시에 획득될 수 없다

  • 따라서 Read Lock이 지속적으로 너무 많이 요청되거나,
    한 번의 Read Lock 획득에서 굉장히 많은 읽기 연산이 존재하여 많은 시간을 잡아 먹는 다면,
    그 만큼 오래도록 Write Lock이 대기해야하는 사태가 벌어질 수도 있다

  • 그렇기 때문에 `new ReentrantReadWriteLock(true);`처럼 공정한 락을 설정하여,
    Write Lock이 기다리다가도 적절한 시점에 실행될 수 있도록 보장하는 것을 고려해야한다

  • 또한, Read Lock이라고 해서 Lock Free 만큼 성능이 뛰어난 것은 아니다
    • WHy?
      Read Lock을 사용할 때도 여러 스레드의 데이터 일관성을 유지해야하기 때문이다 
  • 모든 스레드가 데이터 일관성을 유지한다.. 어딘가 익숙한 문장이다했는데
    JVM 환경에서의 싱글톤 패턴에 대해 정리하면서 학습했던 volatile 키워드가 떠올랐다

  • volatile 키워드를 학습할 때 다음과 같은 문구가 있다
    '변수의 쓰기와 읽기가 모두 메인 메모리에 직접 이루어지기 때문메모리 가시성 문제가 해결된다'
    • 사실 필자는 이 문구를 보고 JVM 메모리 구조에서 힙 메타 스페이스 영역과 힙 영역을 떠올렸다
      하지만 이번에 CPU 연산과 OS 스레드(커널 스레드), JVM 스레드(유저 스레드) 관계를 되짚어 보면서
      그동안 잘못 이해하고 있었다는 것을 깨닫게 되었다

    • 위에서 얘기는 메인 메모리는 RAM으로,
      JVM 수준의 논리적으로는 메타 스페이스, 힙, 스택 영역 등 나눠지겠지만,
      OS 수준에서는 그저 같은 메인 메모리 레벨인 것이다

    • 따라서 각 스레드가 데이터를 읽을 때 캐시 메모리 영역부터 읽어 드리는다는 속 뜻에는
      JVM. 즉, 유저 스레드 영역에서 논할게 아니라, OS 스레드 영역에서 바라봐야할 관점이었으며,
      CPU가 OS 스레드의 연산 요청을 받을 때,
      기본적으로 메인 메모리(RAM)가 아니라 CPU의 캐시 메모리(L1, L2, L3)부터 확인하는 것이었다
      이 관점을 정말 명확히 이해해야 한다
  • 따라서 이번에 다시금 정리한다
    OS 스레드 수준에서 CPU Core에게 연산을 요청할 때,
    각 CPU Core는 기본적으로 본인의 캐시 메모리 영역을 가지고 있으며,
    연산 요청한 데이터가 CPU Core 본인의 캐시 메모리 영역에 존재하는지부터 먼저 검사한다

  • 하지만 volatile 키워드가 붙었던 변수의 데이터들은,
    그냥 캐시 메모리를 쓰는 것이 아니다
    '메모리 배리어'를 통해 volatile 키워드가 붙은 변수의 데이터를 최신화한다!

    즉, volatile 변수의 데이터가 각 CPU Core의 캐시 메모리 영역에 저장되었던 상태에서
    값이 변경되면 해당 값을 캐시에서 무효화(invalidate)하고, 최신 데이터를 메인 메모리에서 다시 읽도록 강제한다
    따라서 CPU Core의 캐시 메모리 영역에서도 항상 최신화된 데이터를 유지할 수 있었던 것이다

  • 필자는 여기서 이러한 생각도 들게 되었다,
    만약 OS 스레드가 다수가 있다 하더라도, 물리적인 CPU Core가 단 1개로만 존재한다면?
    그렇다면 애초에 캐시 메모리의 일관성을 지킬 필요가 없어지기 때문에,
    특정 변수를 메인 메모리(RAM)에서만 관리도록 하는게 의미가 있을까?
    즉, 물리적으로 CPU Core가 단 1개 밖에 없다면 굳이 메모리 배리어가 필요할까 라는 의문이 들었다

    • 하지만 (삽질을 통해) 좀 더 알아보니, 필자가 캐시 메모리에 대한 이해도가 부족했던 것이었다
    • 캐시 메모리에 대해 알게 된 것을 좀 더 정리해보자
  • 각 CPU Core들은 본인만의 캐시 메모리 영역이 존재한다
    • 이것도 알아보니 L1, L2는 각 Core의 고유의 영역이나 L3는 Core들끼리 공유하는 영역일 수 있다고 한다
  • 하지만 각 CPU Core들 안에서 캐시 메모리가 스레드 별로 구분되어서 운영되는건 아니다

  • 그렇기에 캐시 메모리는 스레드 구분 없이 유지되며, 스레드가 다르더라도
    만약 같은 메모리 주소를 참조할 경우 캐시 히트도 기대해 볼 수 있다

  • 하지만 CPU Core가 여러 스레드의 연산을 왔다갔다 바쁘게 처리해주면서,
    다른 스레드에 의해 캐시 메모리에 저장되어있던 데이터가 변경될 수 있다

  • 또는 다른 스레드의 작업으로 인해 기존 캐시에 저장되어있던 데이터가 밀려나,
    다시금 메인 메모리에서 읽어야하는 상황도 발생될 수 있다

  • 즉, 단일 Core라 할지라도 캐시 메모리에 같은 메모리 주소를 가진 데이터는 1개로 유지된다는 것이다
    • 이것도 알아보니 같은 메모리 주소를 참조하는 데이터는 단 하나만 유지되려고 하지만, 
      캐시 정책에 따라 잠깐 중복될 수도 있고 한다
  • 따라서 CPU Core가 1개일 뿐이라도, 스레드 간에 공유하는 데이터.
    즉, JVM 수준에서는 힙 영역에 존재하는 데이터가 캐시 메모리에 올라가게 되면
    멀티 스레드라 할지라도 해당 데이터는 캐시 메모리 영역에서 분리되어 존재하는게 아닌,
    같은 메모리 주소를 가진 데이터로 하나만 존재한다
    • 이 또한, 캐시 정책에 따라 잠깐 중복될 수도 있고 한다

하지만 여기까지 정리가 됐음에도 불구하고,
CPU Core가 단 1개 뿐이라면, 캐시 일관성 문제가 발생되지 않는게 아닌가하는 의문이 사라지지 않았다

따라서 GPT 형님께 아래와 같은 질문을 하게 된다

나는 각 CPU Core 당 고유의 캐시 메모리 영역이 있고, L3 캐시는 각 CPU Core들 끼리도 공유할 수 있다는 것도 이해는 갔어

그리고 RAM 메모리 주소가 같은 데이터라면,
CPU Core의 캐시 메모리 영역에서 1개로 유지하는 메커니즘이 존재한다는 것도 이해가 가

그런데 말이지...

CPU Core가 단 1개인 상황이야? 그럼 캐시 메모리 영역도 1개만 존재할꺼고,
또한 여러 스레드에서 공유하는 데이터라 할지라도 캐시 메모리 영역에서 최대한 1개의 데이터로 유지할꺼 아니야?

그럼 결국 여러 스레드가 공유하는 데이터를 수정한다고 할지라도,
결국 데이터를 수정한 후 캐시 메모리에 값을 수정하는 것 아닌가?

그렇다면 CPU 단일 Core 환경에서는,
캐시 메모리의 데이터와 메인 메모리의 데이터가 계속 같은 값으로 유지 되는거 아닌가? 생각하게 됐어

왜냐하면 CPU 단일 Core 입장에서는 여러 스레드에서 공유하는 데이터가 수정된다 하더라도,
메인 메모리인 RAM에서 해당 데이터를 찾아 값을 수정한 다음 캐시 메모리까지 업데이트 하고 난 뒤,
다른 스레드의 연산을 하러 갈거 아니야?

메인 메모리에 값만 수정하고 캐시 메모리는 업데이트 안하고 컨텍스트 스위칭이 발생하는건 순서가 좀 이상한데..?
내 생각이 잘못 된거야?

 

이에 대한 GPT 형님의 도움은 이렇다 (이해한 내용만 수정했다)

  1. 오히려 쓰기가 지연될 수 있음(Write Delay)
    CPU가 메모리를 수정할 때, 반드시 즉시 캐시에 반영하지 않을 수도 있다!
    이유는 쓰기 버퍼(Write Buffer) 또는 Store Buffer 때문이다,
    CPU는 연산 속도를 높이기 위해, "메모리 접근을 최소화" 하려고 한다,
    그래서 데이터를 캐시에 올려두고 즉시 RAM에 반영하지 않는 경우가 있다

  2. 명령어 재정렬(Instruction Reordering)
    CPU는 연산 최적화를 위해 명령어 실행 순서를 변경할 수도 있다!
    즉, A라는 변수를 먼저 수정한 다음 B를 수정해야 한다고 해도, CPU는 내부적으로 더 빠른 실행을 위해 순서를 바꿔 실행할 수도 있다
    그럼 스레드 A가 보기엔 "업데이트가 아직 안 된 상태" 로 보일 수도 있다

  3.  메모리 가시성 문제(Visibility Issue)
    단일 CPU 코어라 할지라도 여러 스레드가 번갈아 실행되면, 캐시가 아닌 레지스터(Register)에서 값을 읽는 경우도 있다!
    즉, 스레드 A가 sharedValue = 10으로 변경했는데, 스레드 B가 같은 값을 sharedValue = 5로 바꿀 때,
    각 스레드가 레지스터 값을 보고 다르게 동작할 수도 있다

✅ 결론: 단일 코어 환경에서도 메모리 불일치는 발생할 수 있다! 

  • 💡 단일 코어 환경에서도 공유 변수는 반드시 최신 값이 보장되지 않는다
    이 때문에 Java에서는 volatile, synchronized 같은 키워드를 제공한다

  • 💡 그래서 어떻게 해결할까?
    volatile: 메모리 가시성 문제 해결 → 항상 메인 메모리에서 읽도록 강제
    synchronized: 캐시 & 메인 메모리 동기화 보장 + 상호 배제(Mutual Exclusion)
    Lock: CPU 레벨에서 Lock을 걸어, 명령어 실행 순서를 보장함. 

여기까지가 GPT 형님의 도움으로 얻은 정보를 정리한 것이나,
필자의 생각으로는 단일 Core 환경이라면 1, 2번은 크게 문제가 되진 않을 것 같다

  • WHY?
    1. 쓰기 버퍼(Store Buffer) 때문에 메인 메모리(RAM) 업데이트가 늦어질 수 있다 치자,
    → 하지만 어쨌든 단일 Core 입장에서는 자기 캐시의 값이 최신 값이긴 하다

    2. 명령어 재정렬(Instruction Reordering) 때문에, 변수 값이 뒤늦게 업데이트 된다고 치자,
    -> 하지만 단일 Core에서는 결국 같은 캐시를 쓰니까, 일정 시간이 지나면 다른 스레드도 최신 값을 읽게 된다.

주된 문제는 3번이라고 생각된다

  • CPU갸 성능 최적화를 위해 캐시도 안 보고, 바로 레지스터(Register)에서 값을 읽는 경우!

  • 그러면 위 예시와 같이,
    스레드 B가 바꾼 sharedValue = 5가 반영되지 않고,
    스레드 A는 예전 값 10을 계속 읽어버릴 수도 있다

중간에 핵심적인 개념들을 파악하고 있지 못해서, 많이 돌아왔다..

그러나 지금의 삽질을 통해
CPU Core의 캐시 메모리, 메인 메모리(RAM), OS 스레드, JVM 스레드 등의 파편화된 지식들이
확실히 연결되고 있음을 느낀다

또한 점점 관점이 바뀌고 있다
멀티 스레드간 데이터 일관성을 보장하기 위해,
성능 저하가 발생될 수 있는 부분이 존재한다는 관점이 아니라,

오히려 성능을 최적화하기 위해,
CPU 레지스터와 캐시 메모리, 메인 메모리(RAM)등 여러 깊이있는 기술적 연구가 있었겠다는 생각이 들었다

 

4️⃣  StampedLock (낙관적 락을 지원)

  • 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)을 동시에 지원하는 락이다

1. StampedLock의 특징

낙관적 락(Optimistic Lock) 지원 → tryOptimisticRead()
비관적 읽기 락(Read Lock) 지원 → readLock()
비관적 쓰기 락(Write Lock) 지원 → writeLock()
Lock 재진입 불가 (Reentrant 불가)

  • 즉, 기존의 ReadWriteLock보다 유연한 방식으로 동기화를 지원하는 락이다
    특히, 낙관적 락(Optimistic Lock)을 사용할 수 있어서 읽기 성능이 더 뛰어날 수 있다

2. StampedLock의 주요 메서드

(1) 낙관적 읽기 락 (tryOptimisticRead())

  • 락을 거는 대신 낙관적으로 읽기를 수행한다
  • 실행 도중 쓰기 작업이 발생하면 읽기 결과가 신뢰할 수 없으므로 검증(Validation)이 필요하다
  • 사용 방법:
    1. long stamp = lock.tryOptimisticRead(); → 낙관적 읽기 시작
    2. 읽기 작업 수행
    3. if (!lock.validate(stamp)) { 다시 읽기 } → 중간에 쓰기 작업이 있었는지 확인
class OptimisticReadExample {
    private int count = 0;
    private final StampedLock lock = new StampedLock();

    public int getCount() {
        long stamp = lock.tryOptimisticRead(); // 낙관적 락 획득
        int currentCount = count; // 읽기 수행
        if (!lock.validate(stamp)) { // 락이 유효한지 검증
            // 유효하지 않다면 다시 읽기 락을 획득
            stamp = lock.readLock();
            try {
                currentCount = count;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return currentCount;
    }
}

📝 낙관적 락은 쓰기 작업이 없을 경우 매우 빠르다
👉 다만, 쓰기 작업이 발생하면 데이터를 다시 읽어야 한다

 

(2) 비관적 읽기 락 (tryOptimisticRead()), 비관적 쓰기 락

  • ReadWriteLock의 readLock(), writeLock()과 유사
  • 비관적 읽기 락은 마찬가지로 여러 스레드가 동시에 읽을 수 있으나, 쓰기 락 걸리면 대기
  • 비관적 쓰기 락은 마찬가지로 읽기/쓰기 작업을 모두 차단하고 단일 스레드만 접근 가능하다
  • 반드시 unlockRead(stamp)로 해제해야 한다

3. StampedLock을 언제 사용해야 할까?

  • 낙관적 읽기가 가능한 상황 (데이터 변경이 자주 발생하지 않음)
  • 읽기 작업이 매우 빈번하지만 쓰기 작업이 드물 때
  • ReentrantLock이나 ReadWriteLock보다 성능이 더 필요한 경우

4. StampedLock vs ReadWriteLock

비교 항목 StampedLock ReadWriteLock
락 종류 낙관적 락, 읽기 락, 쓰기 락 읽기 락, 쓰기 락
낙관적 읽기 ✅ 지원 (tryOptimisticRead()) ❌ 미지원
성능 읽기 성능 우수 (낙관적 락 덕분) 상대적으로 낮음
재진입 가능 여부 ❌ 불가능 (Reentrant 지원 X) ✅ 가능
사용 예제 데이터 변경이 적은 시스템 (ex. 캐시, 통계) 전통적인 읽기-쓰기 동기화

💡 정리하면?

1️⃣ 읽기 작업이 압도적으로 많다면? → StampedLock (낙관적 락 활용)
2️⃣ 읽기/쓰기 비율이 비슷하면? → ReadWriteLock
3️⃣ 락 재진입이 필요하면? → ReentrantLock

 

5️⃣  Semaphore (접근 가능한 스레드 수 제한)

  • 동시에 접근할 수 있는 스레드의 개수를 제한하는 동기화 기법이다
    • 쉽게 말하면 "N개의 스레드가 동시에 Lock을 획득할 수 있는 Lock" 
  • 뮤텍스(Mutex)는 1개 스레드만 접근 가능 → ReentrantLock, synchronized
  • 세마포어(Semaphore)는 N개 스레드가 접근 가능 → Semaphore
import java.util.concurrent.Semaphore;

class SharedResource {
    private final Semaphore semaphore = new Semaphore(3); // 최대 3개 스레드 동시 접근

    public void access() {
        try {
            semaphore.acquire(); // 락 획득 (남은 자원 없으면 대기)
            System.out.println(Thread.currentThread().getName() + " 접근 중...");
            Thread.sleep(1000); // 작업 수행
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            semaphore.release(); // 락 해제
        }
    }
}

public class SemaphoreExample {
    public static void main(String[] args) {
        SharedResource resource = new SharedResource();

        // 5개 스레드가 동시에 접근 시도
        for (int i = 0; i < 5; i++) {
            new Thread(resource::access).start();
        }
    }
}
실행 결과
Thread-0 접근 중...
Thread-1 접근 중...
Thread-2 접근 중...
(1초 후)
Thread-3 접근 중...
Thread-4 접근 중...
 

✅ Semaphore의 주요 메서드

메서드 설명
acquire() 락 획득 (가능할 때까지 대기)
acquire(int n) n개의 락을 동시에 획득
tryAcquire() 락을 비동기적으로 시도 (즉시 반환)
release() 락 해제
release(int n) n개의 락을 해제

✅ Semaphore는 언제 사용할까?

📌 리소스가 제한된 환경 (ex. DB Connection Pool, Rate Limiting, Thread Pool)
📌 동시에 실행할 스레드 개수를 제어해야 할 때
📌 특정 개수의 동시 접근만 허용하고 싶을 때


정리하자면?

1️⃣ Semaphore는 동시에 N개 스레드가 접근 가능한 락이다
2️⃣ 읽기/쓰기 락 구분 없이, 단순히 허용된 개수만큼만 실행한다
3️⃣ acquire()와 release()를 사용하여 동작한다

💡 핵심: "락의 개수를 조절하는 동기화 도구" 

 

6️⃣  CountDownLatch (모든 스레드 작업 완료 대기)

  • CountDownLatch는 여러 스레드의 작업 완료를 기다리는 데 사용하는 동기화 도구이다
    • 즉, 특정 개수의 작업(스레드)이 끝날 때까지 main 스레드를 대기시키는 용도로 많이 쓰인다

✅ CountDownLatch의 특징

  1. 초기 카운트(Count)를 설정
    • new CountDownLatch(N)로 생성
    • N은 기다려야 할 작업(스레드) 개수
  2. 각 스레드가 작업을 완료하면 countDown()을 호출하여 카운트 감소
    • 모든 작업이 완료되면 N → 0이 되어 await() 상태가 해제됨
  3. await()를 호출한 스레드는 모든 작업이 끝날 때까지 블로킹
    • 즉, N개의 작업이 완료될 때까지 대기

 

✅ 사용 예시: 병렬 연산 후 최종 결과 처리

import java.util.concurrent.*;

public class CountDownLatchExample {
    private static final int THREAD_COUNT = 10;
    private static final CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
    private static int sum = 0; // 최종 결과 저장
    private static final Object lock = new Object(); // 동기화를 위한 락

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
        
        for (int i = 0; i < THREAD_COUNT; i++) {
            int start = i * 100000 + 1;
            int end = (i + 1) * 100000;
            executor.execute(() -> {
                int partialSum = 0;
                for (int j = start; j <= end; j++) {
                    partialSum += j;
                }

                // 결과를 합산 (멀티스레드 환경이라 동기화 필요)
                synchronized (lock) {
                    sum += partialSum;
                }

                latch.countDown(); // 작업 완료 시 카운트 감소
            });
        }

        latch.await(); // 모든 스레드가 작업을 끝낼 때까지 대기
        executor.shutdown();

        System.out.println("최종 합산 결과: " + sum);
    }
}​

📌 실행 흐름

  1. 10개의 스레드가 병렬로 실행되어 각자 10만 개의 숫자를 합산
  2. 각 스레드는 계산이 끝나면 latch.countDown()을 호출하여 카운트 감소
  3. 메인 스레드는 latch.await()에서 모든 작업이 끝날 때까지 대기
  4. 10개의 스레드가 모두 끝나면 await()가 풀리고 최종 합산 결과를 출력

✅ CountDownLatch의 핵심 개념

단방향(One-time-use)

  • 한 번 countDown()이 0이 되면 다시 재사용할 수 없다
  • 다시 사용하려면 새로운 CountDownLatch를 만들어야 한다

여러 스레드를 동시에 대기시키는 용도로 사용 가능

  • 위 예제처럼 병렬 연산을 제어할 수도 있고,
    또는 여러 스레드를 같은 시점에 동시에 실행하도록 동기화하는 용도로도 활용 가능하다

 

7️⃣  CyclicBarrier (모든 스레드 작업 완료 대기 + 반복)

  • CyclicBarrier는 반복적인 동기화가 가능한 CountDownLatch
    • 모든 스레드가 도착점(Barrier)에 도달하면 다시 초기 상태로 돌아가 반복 수행할 수 있는 동기화 도구이다

✅ CyclicBarrier vs CountDownLatch 차이점

  CountDownLatch CyclicBarrier
카운트 초기화 한 번 사용 후 재사용 불가 사용 후 자동 초기화(반복 가능)
동작 방식 특정 개수의 작업이 완료될 때까지 대기 N개의 스레드가 도착할 때까지 대기한 후, 다시 시작
스레드 대기 await()를 호출한 스레드만 대기 참여한 모든 스레드가 함께 대기
콜백 기능 없음 모든 스레드가 Barrier에 도달했을 때 실행할 코드 지정 가능

✅ CyclicBarrier의 동작 방식

  • 모든 스레드가 await()를 호출해야 다음 단계로 넘어간다
  • 모든 스레드가 모이면 Barrier를 초기화하고 다시 반복 가능하다
  • 특정 코드(Barrier Action)를 실행한 후 다음 라운드 시작 가능하다

✅ 사용 예제: CyclicBarrier로 라운드별 동기화

import java.util.concurrent.*;

public class CyclicBarrierExample {
    private static final int THREAD_COUNT = 3;
    private static final CyclicBarrier barrier = new CyclicBarrier(THREAD_COUNT, () -> {
        System.out.println(">> 모든 스레드가 도착! 다음 라운드 시작!");
    });

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);

        for (int i = 0; i < THREAD_COUNT; i++) {
            executor.execute(() -> {
                try {
                    for (int round = 1; round <= 3; round++) {
                        System.out.println(Thread.currentThread().getName() + " - 라운드 " + round + " 진행 중...");
                        Thread.sleep((long) (Math.random() * 3000)); // 랜덤한 시간 동안 작업
                        
                        System.out.println(Thread.currentThread().getName() + " - 라운드 " + round + " 완료, 대기 중...");
                        barrier.await(); // 모든 스레드가 여기 도착할 때까지 대기
                    }
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
            });
        }

        executor.shutdown();
    }
}
 

✅ 실행 흐름

  1. 각 스레드는 라운드 1 작업 수행 → barrier.await()에서 대기
  2. 3개의 스레드가 모두 barrier.await()에 도달하면 Barrier Action 실행
  3. Barrier가 초기화되고 라운드 2 시작
  4. 같은 방식으로 라운드 3까지 진행

✅ CyclicBarrier의 핵심 개념

모든 스레드가 await()를 호출해야 다음 단계로 진행
Barrier 도달 후 초기화되므로 반복 사용 가능
Barrier Action을 활용하여 특정 코드 실행 가능

 

✅ 언제 CountDownLatch vs CyclicBarrier를 써야 할까?

사용 사례 CountDownLatch CyclicBarrier
한 번만 기다리면 되는 경우
반복적으로 동기화가 필요한 경우
특정 개수의 스레드가 끝날 때까지 대기
모든 스레드가 동시에 시작해야 하는 경우

 

8️⃣  SpinLock (짧은 락에 좋은)

  • Lock을 획득할 때, 스레드를 “블로킹” 상태로 만들지 않고,
    CPU를 계속 돌리면서(바쁘게 대기, Busy-Waiting) 락이 해제되길 기다리는 방식이다
    • 즉, "락을 얻을 때까지 루프를 돌면서(CAS) 계속 재시도" 한다
 
public class SimpleSpinLock {
    private final AtomicBoolean lockFlag = new AtomicBoolean(false);

    public void lock() {
        // lockFlag가 false → true로 전환될 때까지 계속 시도
        while (!lockFlag.compareAndSet(false, true)) {
            // 아무것도 안 하고 계속 반복(스핀)
        }
    }

    public void unlock() {
        lockFlag.set(false);
    }
}

특징

  1. Busy-Waiting(바쁘게 대기)
    • 스레드가 “락을 얻을 때까지” while문으로 돌면서 CPU를 계속 사용한다
    • OS 스레드 블로킹(컨텍스트 스위칭)을 안 하므로, 락이 금방 풀릴 거라면 성능이 좋을 수 있다
  2. 컨텍스트 스위칭 비용 감소
    • OS 차원의 블록 → 깨움 과정(시스템 콜)이 없어, 락이 짧게 유지되는 상황에서는 좋다
  3. 경합이 심하면 오히려 CPU 낭비
    • 락을 오랫동안 못 잡으면 계속해서 CAS 연산을 시도해야 하므로 CPU가 낭비될 수 있다
  4. 자바 표준 라이브러리에 SpinLock은 공식적으로 없음
    • 하지만 내부적으로 “짧은 스핀”을 사용한 최적화가 이뤄지기도 함(예: JDK synchronized의 경량 락, etc.).

언제 쓸까?

  • 락이 아주 짧게 유지되는 경우
    • 예) 한 스레드가 거의 금방 끝낼 락이라면, 스레드를 블록시키는 것보다 스핀하는 게 낫다
  • 멀티 코어 환경
    • 한 코어가 스핀하는 동안, 다른 코어가 락을 해제하면 재빨리 감지 가능하다
    • 하지만 단일 코어에선 스핀락이 비효율적일 수 있음 (컨텍스트 스위칭으로 인해 캐시 오염 발생)

정리

  • 블로킹 대신 바쁘게 대기(Spin)하는 Lock이다
  • 락 보유 시간이 매우 짧을 때 → 컨텍스트 스위칭 없이 빠르게 락을 획득한다
  • 락 보유 시간이 길면 → CPU가 낭비된다

 

🔹 Lock 종류에 따른 특징 정리

Lock 종류 특징
synchronized JVM 내장 락, 제일 간단하지만 성능 저하 가능
ReentrantLock synchronized보다 유연한 락
ReadWriteLock 읽기/쓰기 분리하여 동시성 개선
StampedLock ReadWriteLock보다 더 빠름 (낙관적 락 지원)
Semaphore 접근 가능한 스레드 수를 제한
CountDownLatch  (Lock보다는 기법) 모든 스레드 작업 완료 대기
CyclicBarrier  (Lock보다는 기법) 특정 지점에서 모든 스레드가 모여야 진행
SpinLock CAS(Compare-And-Swap) 기반, 짧은 락에 유리

 

🔐 Lock에 대해 알아보면서...

휴.. Lock에 대해 학습하면서 거의 3~4일 시간을 투자했다..
학습하면 학습할 수록 그동안 중요한 개념을 제대로 인지하지 않은 채,
너무 많은 시간을 낭비했다고 생각하게 되었다..

 

여기까지 생각보다 긴 호흡이었지만,
글 작성을 시작했던 처음으로 돌아가 다시 생각을 정리해본다

 

생각 정리

1. 우선, [JVM 환경에서의 스레드]에 대해 이번에 제대로 학습해야겠다고 마음먹은 주된 이유는
   앞으로 개발하는 어플리케이션의 성능 최적화를 위해서 멀티 스레드를 잘 활용할 줄 알아야 한다는 생각 때문이었다

 

2. 하지만 멀티 스레드 환경에서는 동시성 문제가 실과 바늘처럼 떼려야 뗄 수 없는 관계였다

    따라서 동시성 문제 해결에 대해 깊이있게 학습해야겠다는 생각이 들었다

3. 이러한 동시성 문제를 해결하는데 주요한 개념은 Lock 이었고,
    가능한 Lock의 근본 원리부터 이해하고 있어야 여러 상황에 따라 적절한 선택을 할 수 있을 것이라 생각했다

 

4. 여러 번 실습해 본 자바 언어에서 제공해주는 가장 간단한 Lock인 synchronized 키워드부터 원리를 파악하고자 했고,
    Lock이 걸린 주체와 Lock을 거는 주체를 명확히 규정하는 것부터가 시작이었다

5. synchronized에 의한 Lock은 역시 JVM 내부에서 관리해 주는 Lock이었고,
    JVM이 어떻게 Lock을 관리하는 것인지 알아보다 보니, 운 좋게 객체 헤더 구조까지 알게 되었다

 

6. 하지만 객체 헤더의 Mark Word를 통해 관리되는 Lock 정보는 Lock 상태를 관리하기 위한 도구이지,
    성능을 위한 내부적인 메커니즘을 파악하기 위해선 모니터락이라는 또 다른 개념 이해가 필요했다

 

7. JVM이 관리하는 Lock의 상태가 Lock 경쟁 수준에 따라 맨 처음 Bias Lock에서 Lightweight Lock,
     또 Lightweight Lock에서 Heavyweight Lock(모니터 락)으로 3가지 상태가 존재한다는 것을 알게 된다

8. 처음에는 굳이 Lock 상태를 3개나 두어 관리하는 게, 그렇게까지 이점이 있을까 싶은 의문이 있었다

    왜냐하면 최종적인 Heavyweight Lock(모니터 락)이 OS 스레드 수준의 Lock(뮤텍스)가 된다 하더라도,
    OS 스레드가 대기 상태로 블락되고, 다시 대기가 풀릴 때 드는 2번의 CPU 컨텍스트 스위칭 비용이
    과연 다른 2가지 상태들의 락들보다 컨텍스트 스위칭이 적게 발생되는지가 의문이었다
   
     즉, OS 스레드가 대기 상태로 블락되지 않는 상황에서,
     여러 JVM 스레드가 계속 번갈아 가면서 Lock 획득에 대한 연산을 요청할 것이고,
     이에 따른 CPU 컨텍스트 스위칭 비용이 지속적으로 들 것이라 생각했다
     
     그러나 CAS 연산이 어셈블리어 수준에서 1번의 명령으로 처리되는 매우 간단한 연산이라는 점과,
     JVM이 Lock 경쟁이 심해지면 알아서 Lock 상태를 변화시켜서,
     결국엔 OS 스레드 수준의 Lock을 걸었던 점을 다시 인지하면서,
     3가지 상태로 관리되는 게 Lock 경쟁이 심하지 않을 경우, 성능적 이점이 많을 수 있겠다는 판단을 하게 된다
     (이 과정에서 JVM에서의 스레드와 OS의 스레드의 종속 관계, CPU 컨
텍스트 스위칭에 대해 다시금 학습하게 된다)

9. 이렇게 점점 Lock 상태 관리가 결국 성능을 최적화하기 위한 JVM의 방법 론이었다는 것을 조금씩 깨닫게 된다

 

10. 하지만 지난 2회 차 멘토링에서 멘토님께서 언급해 주셨던 것과 같이,
     synchronized 키워드를 제외한 Lock이 존재한다는 점을 인식하고 있었고,
     본격적으로 멘토님께서 언급해주셨던 Lock에 대해 알아보기 시작했다

 

11. 먼저 자바 라이브러리에서 제공되는 Lock의 주된 차이점부터 조금씩 인식하게 된다
     자바 라이브러리에서 제공되는 Lock은 JVM에서 관리되는 Lock이 아닌,
     자바 코드로 관리되는 Lock이라고 볼 수 있었다

 

12. 여기서 자바 라이브러리 Lock 관리의 핵심이라고 볼 수 있는,
      AQS(AbstractQueuedSynchronizer)에 대해 학습
하게 된다

      AQS의 주된 개념은 [CAS 연산 + 대기 큐] 조합으로 Lock 경쟁으로 인한 성능 저하를 최소화하는 것이었다

 

13. 처음에는 [CAS 연산 + 대기 큐] 조합이 모니터락으로 인한 스레드 Lock보다
       성능적으로 정말 유리한가? 의문이 들었다
     
      왜냐하면 JVM 스레드가 대기 큐로 들어가는 것 또한 OS 스레드가 더 이상 CPU를 점유하지 못한다는 뜻과 같고,
      CPU가 계속 놀고 있을 순 없기에 특정 스레드가 대기 큐로 가면,
      다른 스레드의 연산을 도와주러 컨텍스트 스위칭이 발생활 거고,
      대기 큐에서 대기하다가 락 획득을 하는 과정에서도 컨텍스트 스위칭이 발생할게 아닌가?
     

      애초에 [CAS 연산 + 대기 큐] 조합에서도 OS 스레드에서의 Lock(뮤텍스) 같이 대기 상태로 빠지는 스레드가 생길 때,
      컨텍스트 스위칭이 발생하는 것과 별반 다를 게 없다고 느꼈다

 

14. 하지만 여기서 제대로 파악하고 있지 못했던 2가지 사실을 깨닫게 된다

      첫 번째는 컨텍스트 스위칭이 발생되는 것은 같을지라도,

      JVM의 모니터락으로 인해 OS 스레드 수준의 Lock이 걸리기 위해선 시스템 콜이 이뤄난다는 점이다
      시스템 콜 또한 무시할 수 없는 비용이므로 JVM 스레드 수준에서 Lock이 걸리는 것보다 성능적 단점일 수밖에 없다

 

      두 번째는 경합하는 스레드가 많을수록 컨텍스트 스위칭 발생 비용이 기하급수 적으로 차이가 날 수 있다는 점이다

      CAS 연산 + 대기 큐 조합은 다음 Lock 획득 시, 대기 큐에서 하나씩 스레드를 깨우면 그만이다

      그러나 OS 스레드 수준의 Lock은 기존에 Lock이 해제되어 Lock 경쟁이 다시 발생되면,
      Lock 획득을 위해 대기했던 모든 스레드가 깨어나 Lock 경쟁을 시도하여,
      컨텍스트 스위치 비용이 대기하던 스레드 수만큼 또 일어나게 된다

      이 점을 인지한 뒤에야 의문이 해소되며,
      그로인해 대부분의 경우에서 AQS를 활용한 Lock 관리가 성능이 뛰어날 것 같다는 납득을 할 수 있었다


15. 위에까지 인지를 한 이후에는 ReetrantLock에 대해 좀 더 쉽게? 긍정적으로 개념을 받아들이게 된 것 같다
     그만큼 AQS를 활용한 Lock과 스레드 수준에서 발생한 Lock의 차이를 이해한 것이 주요했다고 생각된다

 

16. 하지만 ReadWriteLock에 대해 학습하면서 다시 한번 의문이 해소되지 않는 위기가 찾아왔다

       발단은 Read Lock도 데이터 일관성을 유지하기 위해서 메모리 베리어가 발생될 수 있고,
       이로 인해 성능 저하가 생길 수 있다는 점
이었다

      데이터 일관성을 유지하기 위한 수단으로 멘토링 첫 회에 학습하게 됐던 volatile 키워드가 떠올랐고,
      데이터 일관성을 지키기 위해 캐시 메모리 사용을 포기해야 한다는 식으로 정리했던 지난주가 떠올랐다
      

17. 지난주 학습했던 내용을 다시 점검하면서 volatile 키워드에 대해 잘못 정리했다는 점을 인지하게 된다

      심각한 문제였던 부분은 캐시 메모리와 메인 메모리의 영역을 올바른 시스템 Layer에서 바라보지 않았다는 점이다

      캐시 메모리와 메인 메모리에 대한 영역은 JVM에서의 스레드 관점에서 바라보기보단,
      CPU Core와 OS 스레드 관점에서 바라봐야 하는 문제였다

      왜냐하면 내가 쓴 명령어를 읽어드려 작동하는 JVM 스레드 입장에서는,

      내 명령어를 수행하기 위해 필요한 작업을 OS 스레드에게 요청하는 입장이며,
      OS 스레드가 요청받은 명령을 수행해 주기 위해 물리적 자원인 CPU와 통신하는 것을
      JVM 스레드 입장에서는 알 수 없는 구조이기 때문이다

      따라서 JVM 스레드의 요청을 OS 스레드가 잘 처리해 주기 위해, CPU와의 통신을 하게 된 것이고
      CPU 입장에서도 본인이 연산을 효율적으로 처리하기 위해, 메인 메모리(RAM) 영역까지 가기 전에,
      본인의 CPU단에서 빠르게 데이터를 확인할 수 있는 캐시 메모리 영역부터 확인했던 것이다

 

- 2025년 3월 21일 내용 추가
(CPU 모드에 대한 학습을 통해 아래와 같은 내용을 추가한다)

JVM 스레드는 실제로 OS 스레드에 의해 실행되며, CPU Core에서 동작하게 되는 게 맞다
따라서 volatile 키워드에 의해 발생하는 메모리 배리어는 JVM 내부 개념처럼 보이지만,
실제로는 하드웨어 메모리 모델(예: x86, ARM 등)에 의존하며 CPU 캐시 → 메인 메모리 간 동기화를 강제한다

하지만 이러한 하위 Layer의 동작은 시스템 콜처럼 CPU 커널 모드를 필요로 하는 건 아니며,
대부분 사용자 모드 내에서 수행된다

 

18. 유저 스레드 입장. 즉, JVM 스레드에서 모든 걸 통제한다는 사고에서 벗어나,
      OS 스레드, 그리고 그보다 더 아래 Layer라고 볼 수 있는 CPU Core의 입장에서 바라보니,
      조금씩 흐릿했던 시야가 또렷해지고 있음을 느꼈다

      각 Layer 마다 본인들이 처한 입장(책임)이 있고, 이를 효율적으로 처리하기 위해 여러 노력을 하는 것이었다
      이러한 각 Layer 구조에 따른 이해가 필요한 것은 자원을 잘 활용하는 데 있어 처음엔 어려움을 느끼겠지만,
      이는 개발자인 내게 자원 활용의 어려움을 주려고 했다기보다는,
      오히려 여러 많은 도움을 주기 위해 이렇게 고안되었다고 보는 게 맞는 것 같다

 

19. 위처럼 기존에 잘못된 사고를 했던 것을 조금씩 바로 잡아가는 과정에서,

      각 CPU Core 마다 고유의 캐시 메모리가 있다는 점과, (물론 L3는 코어끼리도 공유가 가능할 수 있지만)

      메인 메모리(RAM)는 OS 수준에서는 그저 같은 메모리 영역일 뿐이지,
      이를 스택, 힙, 메타 스페이스 등으로 논리적으로 나눠 구분 짓던 것은 JVM에서의 영역의 일인 점을 깨닫게 된다

20. 여기까지 정리가 된 상태에서 CPU가 단일 Core로 이루어져 있다면,
      멀티 스레드 사이에 공유 자원이 있다고 하더라도, CPU Core의 캐시 메모리는 일관성이 지켜지지 않을까?
      라는 의문을 강하게 품게 되었고,

      CPU가 단일 Core라 할지라도 CPU가 성능 최적화를 위해서 캐시 메모리까지 가기도 전에,
      레지스터 수준에서 값을 읽어드릴 수도 있다는 점을 알게 되며,

      이러한 문제까지 통제할 수 있는 volatile 키워드 활용이,
      멀티 스레드 환경에서는 CPU가 단일 Core라 할지라도 충분히 필요하겠다고 판단할 수 있게 되었다

21. 이후 자바 라이브러리에서 제공하는 Lock에 대한 개념이 점점 자연스럽게 이해가 가기 시작했고,
      아직 Lock을 깊이 있게 활용해 보진 못 했으나 학습 과정에서 의문점들이 해소가 되면서,
      어느 시점에 어떤 Lock을 활용하는 게 적절한지, CPU의 입장까지 고려할 수 있게 된 것 같다

22. 그러나 학습할수록, 지금 알게 된 것들을 포함하여 내가 아는 것은 정말 작디작은 부분이겠다는 생각이 들게 된다

      이제야 겨우 유저 스레드, 커널 스레드, CPU Core, 레지스터, 캐시 메모리, 메인 메모리 등의 개념들이
      조금씩 서로 연결되고 있는 상태에 불과하다

      하지만 조금의 개념이라도 더 구체화하기 위해, 간단한 성능 테스트를시도해보려 한다
      멀티 스레드 환경에서 Lock에 대한 성능 테스트는 다음 챕터에 정리하겠다

다음 챕터: JVM 환경에서의 스레드 v3 [성능 테스트]

 

JVM 환경에서의 스레드 v3 [Lock 성능 테스트]

개요S/W 개발자로서 성능 최적화의 핵심 요소인 스레드에 대해 정리한다JVM 환경에서의 스레드 v2 [Lock]의 후속 글로, 이 문서에서는 주로 Lock 성능 테스트에 대해 다룬다  JVM 환경에서의 스레드

pablo7.tistory.com