가상 스레드 시대의 동기화 정리 synchronized volatile LockSupport까지 한 번에

JV-CO-05calendar_today2026-02-03 23:50#Java #Level2

가상 스레드가 바꾸려는 문제

  • 스레드는 실행의 단위입니다.
  • 스레드는 동시에 실행되는 것처럼 보입니다.
  • 멀티스레드는 항상 동기화가 필요합니다.

기존 커널 스레드의 비용

  • 커널 스레드는 OS가 관리합니다.
  • 커널 스레드는 생성과 종료가 상대적으로 비쌉니다.
  • 커널 스레드는 블로킹이 늘면 운영 비용이 커집니다.
    • 컨텍스트 스위칭이 늘 수 있습니다.
    • 커널 스케줄링 비용이 늘 수 있습니다.

가상 스레드의 핵심 아이디어

  • 가상 스레드는 “스레드를 더 싸게 많이 쓰자”에 가깝습니다.
  • 가상 스레드는 커널 스레드에 1대1로 고정되지 않습니다.
  • 가상 스레드는 소수의 커널 스레드 위에서 스케줄링될 수 있습니다.
  • 그래서 동시 작업 개수를 늘리기 쉬워집니다.

오해하기 쉬운 포인트

  • 가상 스레드는 동기화를 없애지 않습니다.
  • 가상 스레드는 경쟁 조건을 자동으로 해결하지 않습니다.
  • 가상 스레드는 “컨텍스트 스위칭을 0으로 만든다”는 표현은 과합니다. (커널 스레드 스위칭 압박을 줄이는 효과가 핵심입니다)

멀티스레드에서 제일 중요한 것

  • 멀티스레드의 핵심은 동기화 전략입니다.
  • 동기화는 아래 2가지를 보장하려고 씁니다.
    • 상호 배제
    • 가시성 및 순서

OS 동기화 객체와 자바 동기화의 연결

  • 대부분의 OS는 동기화 프리미티브를 제공합니다.
  • Windows 관점에서는 아래가 대표적입니다.
    • Event
    • Mutex
    • Semaphore
  • 커널 객체 기반 락은 무겁게 느껴질 수 있습니다.
    • 커널 개입이 늘면 스케줄링 비용이 늘 수 있습니다.
  • 하지만 Java의 동기화는 “항상 커널 객체를 직접 쓴다”로 단순화하면 위험합니다. (경쟁 정도에 따라 유저 영역 최적화가 먼저 동작할 수 있습니다)

synchronized 메서드가 의미하는 것

synchronized void testFunc()

  • synchronized임계 영역을 만듭니다.
  • 메서드 전체를 임계 영역으로 지정할 수 있습니다.
  • 코드 블록만 임계 영역으로 지정할 수도 있습니다.

synchronized가 보장하는 것

  • 동시에 여러 스레드가 들어오지 못하게 합니다.
  • 락 경계를 기준으로 가시성 및 순서 규칙이 생깁니다.
  • 그래서 공유 상태를 “한 덩어리로” 다루기 쉬워집니다.

“메서드에 원자성을 부여한다” 문장 보정

  • synchronized는 임계 영역 내부를 상호 배제로 보호합니다.
  • 그래서 임계 영역 내부 관점에서는 원자적으로 보일 수 있습니다.
  • 하지만 “메서드가 항상 원자 연산이다”로 일반화하면 오해가 생깁니다. (메서드 밖의 상태나 외부 호출로 원자성이 깨질 수 있습니다)

모니터 락 Monitor Lock 동작 흐름

  • synchronized모니터 락을 사용합니다.
  • 일반 흐름은 아래처럼 이해하면 됩니다.
  • 1 호출 스레드가 동기화 구간에 진입하려고 합니다.
  • 2 락이 비어 있으면 락을 잡고 진입합니다.
  • 3 락이 이미 점유 중이면 진입을 못 합니다.
    • 해당 스레드는 대기 상태로 밀릴 수 있습니다.
  • 4 락이 풀리면 대기 중 하나가 깨어나 진입합니다.

공정성 우선순위에 대한 보정

  • “어느 스레드가 먼저 락을 얻는가”는 개발자가 완전히 통제하기 어렵습니다.
  • JVM만이 아니라 OS 스케줄링 영향도 큽니다.
  • Java의 스레드 우선순위도 힌트로만 동작할 수 있습니다. (플랫폼별로 기대가 다를 수 있습니다)

synchronized 블록 예제

  • 범위를 줄이면 성능과 안전성이 함께 좋아질 수 있습니다.
  • 아래처럼 “정말 필요한 코드만” 감싸는 습관이 중요합니다.
java
class Counter {
    private int value = 0;
    void inc() {
        synchronized (this) {
            value++;
        }
    }
    int get() {
        synchronized (this) {
            return value;
        }
    }
}

synchronized가 적용되는 대상

  • 인스턴스 메서드에 synchronized를 붙이면 락 대상은 this입니다.
  • static 메서드에 synchronized를 붙이면 락 대상은 Class 객체입니다.
  • 그래서 인스턴스 락과 static 락은 완전히 다릅니다.

인스턴스 synchronized가 “안 먹는” 대표 케이스

  • 스레드마다 객체를 따로 만들면 락도 따로 생깁니다.
  • 그러면 서로를 막지 못합니다.
  • 상황
    • new MyWorker()를 스레드마다 각각 생성합니다.
    • 각 인스턴스의 락이 다릅니다.
  • 결과
    • “synchronized인데도 동시 실행”처럼 보일 수 있습니다.

static synchronized가 필요한 케이스

  • “모든 인스턴스에서 하나의 공유 자원”을 보호해야 할 때가 있습니다.
  • 예를 들어 static 필드가 그 대상입니다.
  • 이때는 클래스 수준 락이 필요합니다.
java
class GlobalCounter {
    private static int value = 0;
    static synchronized void inc() {
        value++;
    }
}

동기화는 누가 책임져야 하나

  • 접근자가 동기화하는 방식이 있습니다.
  • 객체가 스스로 동기화하는 방식도 있습니다.
  • 실무에서는 아래 기준이 유용합니다.
  • 객체 스스로 동기화가 유리한 경우
    • 불변식을 지켜야 합니다.
    • 여러 필드를 함께 보호해야 합니다.
    • 사용자가 실수하기 쉬운 API입니다.
  • 외부에서 동기화가 유리한 경우
    • 더 큰 단위의 트랜잭션을 묶어야 합니다.
    • 여러 객체를 한 번에 묶어야 합니다.

Non blocking 동기화와 Spin 개념

  • 멀티스레드의 큰 비용은 상태 전환 비용입니다.
  • 작은 연산을 보호하려고 매번 블로킹하면 오히려 손해일 수 있습니다.
  • 그래서 “짧게 기다리며 재시도”하는 방식이 나옵니다.

Spin Lock의 정확한 의미

  • 스핀은 보통 아래 형태입니다.
    • 조건이 만족될 때까지 반복합니다.
    • CPU를 계속 사용합니다.
  • 그래서 스핀은 아래에서만 유리할 수 있습니다.
    • 임계 영역이 매우 짧습니다.
    • 경쟁이 곧 끝날 가능성이 큽니다.
  • 임계 영역이 길면 스핀은 최악이 될 수 있습니다.
    • CPU를 태우면서도 진전이 없습니다.

AtomicInteger는 왜 빠른가

  • AtomicInteger는 락 대신 CAS 기반 원자 연산을 제공합니다. (스핀 락 “하나”로 단정하기는 어렵습니다)
  • 실패하면 재시도 루프가 돌 수 있습니다.
  • 그래서 매우 작은 공유 상태에는 좋은 선택입니다.
java
import java.util.concurrent.atomic.AtomicInteger;
class Stats {
    private final AtomicInteger count = new AtomicInteger(0);
    void inc() {
        count.incrementAndGet();
    }
}

synchronized vs Atomic 선택 기준

  • synchronized가 유리한 경우
    • 여러 필드를 묶어야 합니다.
    • 불변식을 지켜야 합니다.
    • 임계 영역이 짧고 명확합니다.
  • Atomic*가 유리한 경우
    • 단일 값 업데이트입니다.
    • 경쟁이 심해도 빠르게 처리하고 싶습니다.
    • 락으로 인한 블로킹을 줄이고 싶습니다.

ReentrantLock은 언제 쓰나

  • ReentrantLock은 명시적으로 락을 다루는 방식입니다.
  • synchronized보다 표현력이 좋을 때가 있습니다.
  • 대신 실수하면 바로 장애로 이어질 수 있습니다.

ReentrantLock의 장점

  • 락 획득을 시도만 하는 tryLock()이 있습니다.
  • 공정성 옵션을 줄 수 있습니다.
  • 조건 대기 Condition을 붙일 수 있습니다.

기본 패턴은 try finally 입니다

java
import java.util.concurrent.locks.ReentrantLock;
class BankAccount {
    private final ReentrantLock lock = new ReentrantLock();
    private int balance = 0;
    void deposit(int amount) {
        lock.lock();
        try {
            balance += amount;
        } finally {
            lock.unlock();
        }
    }
}

tryLock 패턴 예시

java
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
class Cache {
    private final ReentrantLock lock = new ReentrantLock();
    private int value = 0;
    boolean updateIfPossible(int next) throws InterruptedException {
        if (lock.tryLock(10, TimeUnit.MILLISECONDS)) {
            try {
                value = next;
                return true;
            } finally {
                lock.unlock();
            }
        }
        return false;
    }
}

대기와 알림 wait notify

  • wait notify는 스레드 간 신호 교환에 씁니다.
  • 반드시 synchronized 안에서 호출해야 합니다.
  • 사용법이 꼬이면 데드락이 쉽게 나옵니다.

대표 위험

  • wait()만 하고 notify()가 오지 않으면 멈춥니다.
  • 조건 없이 notify()를 쓰면 깨어나도 다시 잠들 수 있습니다.
  • 그래서 아래 습관이 중요합니다.
  • 조건은 반드시 while로 검사합니다.
  • 타임아웃을 고려합니다.
  • 신호 설계를 문서화합니다.

LockSupport는 왜 유용한가

  • LockSupport는 스레드 제어 유틸리티입니다.
  • synchronized 없이도 대기와 깨우기가 가능합니다.
  • unpark()park()보다 먼저 와도 효과가 유지될 수 있습니다.
    • 일종의 “permit”을 한 장 쥐여 주는 모델입니다.
    • 그래서 “신호 손실이 줄어든다”는 설명이 가능합니다.

LockSupport 기본 예시

java
import java.util.concurrent.locks.LockSupport;
class Signal {
    private volatile boolean ready = false;
    void await() {
        while (!ready) {
            LockSupport.park();
        }
    }
    void signal(Thread t) {
        ready = true;
        LockSupport.unpark(t);
    }
}
  • ready는 반드시 조건 변수로 확인합니다.
  • park()는 이유 없이 깨어날 수도 있다고 가정해야 안전합니다. (그래서 while이 필요합니다)

데드락은 최악의 논리 오류

  • 데드락은 “서로가 서로를 기다려서 영원히 진행이 없는 상태”입니다.
  • 대표 패턴은 아래입니다.
  • 패턴 1 wait만 있고 notify가 없는 설계
    • 타임아웃이 없으면 영원히 멈출 수 있습니다.
  • 패턴 2 락 순서가 뒤집히는 설계
    • A는 락1 후 락2를 원합니다.
    • B는 락2 후 락1을 원합니다.
    • 서로 잡은 채 서로를 기다립니다.

락 순서 데드락 예시

java
class Deadlock {
    private final Object lockA = new Object();
    private final Object lockB = new Object();
    void aThenB() {
        synchronized (lockA) {
            synchronized (lockB) {
                // work
            }
        }
    }
    void bThenA() {
        synchronized (lockB) {
            synchronized (lockA) {
                // work
            }
        }
    }
}
  • 해결은 보통 아래 중 하나입니다.
    • 락 순서를 전역 규칙으로 고정합니다.
    • 락을 합칩니다.
    • tryLock으로 회피 로직을 넣습니다.

가상 스레드와 동기화 실전 팁

  • 가상 스레드는 “블로킹을 싸게” 만드는 방향입니다.
  • 그래서 아래 조합이 특히 중요합니다.
  • 추천 조합
    • I O 대기 중심 작업은 가상 스레드에 잘 맞습니다.
    • 공유 상태는 가능한 줄이고 메시지 패싱을 늘립니다.
  • 주의 조합
    • 긴 시간의 synchronized 임계 영역은 피하는 게 좋습니다.
    • 락 안에서 오래 걸리는 작업을 하면 병목이 커집니다.
    • 일부 상황에서는 모니터 기반 동기화가 커널 스레드를 붙잡는 것처럼 보일 수 있습니다. (상황과 구현에 따라 달라질 수 있으니 “락 안에서 블로킹 금지”를 원칙으로 두는 게 안전합니다)

최종 정리

  • 가상 스레드는 동기화를 없애는 기술이 아닙니다.
  • synchronized는 가장 단순하고 강력한 기본기입니다.
  • 인스턴스 락과 클래스 락의 대상을 혼동하면 동기화가 깨집니다.
  • 작은 원자 업데이트는 Atomic*가 좋은 선택입니다.
  • 락 제어가 더 필요하면 ReentrantLock을 씁니다.
  • 신호 기반 제어는 wait notify 또는 LockSupport를 씁니다.
  • 데드락은 락 순서와 신호 설계를 표준화하면 대부분 예방됩니다.