J-C-05 가상 스레드 시대의 동기화 정리 synchronized volatile LockSupport까지 한 번에
2026-02-03 |
글 정보
- 카테고리
- Programming/Java/Core
- 태그
- JavaLevel2
가상 스레드가 바꾸려는 문제
- 스레드는 실행의 단위입니다.
- 스레드는 동시에 실행되는 것처럼 보입니다.
- 멀티스레드는 항상 동기화가 필요합니다.
기존 커널 스레드의 비용
- 커널 스레드는 OS가 관리합니다.
- 커널 스레드는 생성과 종료가 상대적으로 비쌉니다.
- 커널 스레드는 블로킹이 늘면 운영 비용이 커집니다.
- 컨텍스트 스위칭이 늘 수 있습니다.
- 커널 스케줄링 비용이 늘 수 있습니다.
가상 스레드의 핵심 아이디어
- 가상 스레드는 “스레드를 더 싸게 많이 쓰자”에 가깝습니다.
- 가상 스레드는 커널 스레드에 1대1로 고정되지 않습니다.
- 가상 스레드는 소수의 커널 스레드 위에서 스케줄링될 수 있습니다.
- 그래서 동시 작업 개수를 늘리기 쉬워집니다.
오해하기 쉬운 포인트
- 가상 스레드는 동기화를 없애지 않습니다.
- 가상 스레드는 경쟁 조건을 자동으로 해결하지 않습니다.
- 가상 스레드는 “컨텍스트 스위칭을 0으로 만든다”는 표현은 과합니다. (커널 스레드 스위칭 압박을 줄이는 효과가 핵심입니다)
멀티스레드에서 제일 중요한 것
- 멀티스레드의 핵심은 동기화 전략입니다.
- 동기화는 아래 2가지를 보장하려고 씁니다.
OS 동기화 객체와 자바 동기화의 연결
- 대부분의 OS는 동기화 프리미티브를 제공합니다.
- Windows 관점에서는 아래가 대표적입니다.
- 커널 객체 기반 락은 무겁게 느껴질 수 있습니다.
- 커널 개입이 늘면 스케줄링 비용이 늘 수 있습니다.
- 하지만 Java의 동기화는 “항상 커널 객체를 직접 쓴다”로 단순화하면 위험합니다. (경쟁 정도에 따라 유저 영역 최적화가 먼저 동작할 수 있습니다)
synchronized 메서드가 의미하는 것
synchronized void testFunc()
synchronized는 임계 영역을 만듭니다.
- 메서드 전체를 임계 영역으로 지정할 수 있습니다.
- 코드 블록만 임계 영역으로 지정할 수도 있습니다.
synchronized가 보장하는 것
- 동시에 여러 스레드가 들어오지 못하게 합니다.
- 락 경계를 기준으로 가시성 및 순서 규칙이 생깁니다.
- 그래서 공유 상태를 “한 덩어리로” 다루기 쉬워집니다.
“메서드에 원자성을 부여한다” 문장 보정
synchronized는 임계 영역 내부를 상호 배제로 보호합니다.
- 그래서 임계 영역 내부 관점에서는 원자적으로 보일 수 있습니다.
- 하지만 “메서드가 항상 원자 연산이다”로 일반화하면 오해가 생깁니다. (메서드 밖의 상태나 외부 호출로 원자성이 깨질 수 있습니다)
모니터 락 Monitor Lock 동작 흐름
synchronized는 모니터 락을 사용합니다.
- 일반 흐름은 아래처럼 이해하면 됩니다.
- 1 호출 스레드가 동기화 구간에 진입하려고 합니다.
- 2 락이 비어 있으면 락을 잡고 진입합니다.
- 3 락이 이미 점유 중이면 진입을 못 합니다.
- 해당 스레드는 대기 상태로 밀릴 수 있습니다.
- 4 락이 풀리면 대기 중 하나가 깨어나 진입합니다.
공정성 우선순위에 대한 보정
- “어느 스레드가 먼저 락을 얻는가”는 개발자가 완전히 통제하기 어렵습니다.
- JVM만이 아니라 OS 스케줄링 영향도 큽니다.
- Java의 스레드 우선순위도 힌트로만 동작할 수 있습니다. (플랫폼별로 기대가 다를 수 있습니다)
synchronized 블록 예제
- 범위를 줄이면 성능과 안전성이 함께 좋아질 수 있습니다.
- 아래처럼 “정말 필요한 코드만” 감싸는 습관이 중요합니다.
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 필드가 그 대상입니다.
- 이때는 클래스 수준 락이 필요합니다.
class GlobalCounter {
private static int value = 0;
static synchronized void inc() {
value++;
}
}
동기화는 누가 책임져야 하나
- 접근자가 동기화하는 방식이 있습니다.
- 객체가 스스로 동기화하는 방식도 있습니다.
- 실무에서는 아래 기준이 유용합니다.
- 객체 스스로 동기화가 유리한 경우
- 불변식을 지켜야 합니다.
- 여러 필드를 함께 보호해야 합니다.
- 사용자가 실수하기 쉬운 API입니다.
- 외부에서 동기화가 유리한 경우
- 더 큰 단위의 트랜잭션을 묶어야 합니다.
- 여러 객체를 한 번에 묶어야 합니다.
Non blocking 동기화와 Spin 개념
- 멀티스레드의 큰 비용은 상태 전환 비용입니다.
- 작은 연산을 보호하려고 매번 블로킹하면 오히려 손해일 수 있습니다.
- 그래서 “짧게 기다리며 재시도”하는 방식이 나옵니다.
Spin Lock의 정확한 의미
- 스핀은 보통 아래 형태입니다.
- 조건이 만족될 때까지 반복합니다.
- CPU를 계속 사용합니다.
- 그래서 스핀은 아래에서만 유리할 수 있습니다.
- 임계 영역이 매우 짧습니다.
- 경쟁이 곧 끝날 가능성이 큽니다.
- 임계 영역이 길면 스핀은 최악이 될 수 있습니다.
AtomicInteger는 왜 빠른가
AtomicInteger는 락 대신 CAS 기반 원자 연산을 제공합니다. (스핀 락 “하나”로 단정하기는 어렵습니다)
- 실패하면 재시도 루프가 돌 수 있습니다.
- 그래서 매우 작은 공유 상태에는 좋은 선택입니다.
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 입니다
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 패턴 예시
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 기본 예시
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을 원합니다.
- 서로 잡은 채 서로를 기다립니다.
락 순서 데드락 예시
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를 씁니다.
- 데드락은 락 순서와 신호 설계를 표준화하면 대부분 예방됩니다.