언어/Java
Java - 가시성 문제? "volatile, synchronized, Atomic 정확히 언제 써야 할까?
믿을수없는맛
2025. 6. 29. 19:46
반응형
📍 자바 멀티스레딩에서의 함정: 가시성(Visibility)
자바에서 멀티스레드를 사용할 때 흔히 하는 실수 중 하나는 다음과 같은 코드다.
public class VisibilityProblemExample {
static boolean flag = false;
public static void main(String[] args) {
// Thread A: flag가 true가 될 때까지 기다림
Thread readerThread = new Thread(() -> {
System.out.println("Reader: 대기 중...");
while (!flag) {
// 무한 대기
}
System.out.println("Reader: flag가 true가 되었습니다!");
});
// Thread B: 1초 뒤에 flag를 true로 변경
Thread writerThread = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {}
flag = true;
System.out.println("Writer: flag를 true로 변경했습니다.");
});
readerThread.start();
writerThread.start();
}
}
이 코드에서 flag
가 true
로 바뀌었는데도 reader()
는 무한 루프를 탈출하지 못하는 경우가 발생할 수 있다.
왜일까? 바로 가시성(visibility) 문제 때문이다.
📍 가시성이란?
멀티스레드 환경에서 각각의 스레드가 공유된 자원에 대해서 모두 같은 값을 바라보고있을 것을 의미한다.
쉽게 설명 하면 한 스레드에서 변경한 값이 다른 스레드에서 언제 볼 수 있는가를 의미한다.
자바에서는 성능 최적화를 위해 아래 사진과 같이 각 스레드가 변수를 CPU 캐시에 보관할 수 있다. 즉, 어떤 스레드가 값을 바꿔도, 다른 스레드는 여전히 옛날 값을 볼 수 있다.

✅ 해결 방법: volatile
이때 해결 방법은 간단하다. flag
를 volatile
로 선언하면 된다.
volatile boolean flag = false;
volatile
을 쓰면 다음이 보장된다:
- 스레드는 항상 메인 메모리에서 변수 값을 읽고 씀
- 다른 스레드가 변경한 값을 즉시 감지할 수 있음
- 컴파일러와 CPU의 명령 재배열 방지도 보장
volatile
키워드 사용시 메인 메모리에 직접 접근하기 때문에 성능저하가 있을수도 있다.
📍 volatile은 원자성(atomicity)을 보장하지 않는다
그러면 volatile
하나만 사용하면 만능일까? 아니다.
🙅다음 코드는 안전하지 않다.
volatile int count = 0;
void increase() {
count++; // 이게 안전해 보일까?
}
왜냐하면 count++
는 아래처럼 세 단계로 쪼개진 연산이기 때문:
count
읽기+1
계산count
에 다시 쓰기
→ 이 중간에 다른 스레드가 끼어들면, 증가값이 덮여쓰여진다 (lost update)
✅ 해결 방법 1: synchronized
synchronized void increase() {
count++;
}
- 정확함 (원자성, 가시성 보장)
- 하지만 느림 (락 오버헤드 있음)
✅ 해결 방법 2: AtomicInteger
AtomicInteger count = new AtomicInteger(0);
void increase() {
count.incrementAndGet(); // 💥 이건 원자적으로 동작함!
}
- 빠르고 정확하게 작동
- 내부적으로 CAS(Compare-And-Swap) 사용해서 락 없이 연산
- 가시성과 원자성을 모두 보장
AtomicInteger
외에도 AtomicBoolean
, AtomicReference
등 사용가능.
CAS(Compare-And-Swap)란?
"값이 예상한 값과 같을 때만 새로운 값으로 바꾸는 연산" 이다.
이 연산은 원자적으로(atomic) 수행된다.
Atomic*
클래스에서 제공되는 메서드들은 내부적으로 아래와 같은 형태로 동작하여 원자적인 연산을 가능하게 한다.
compareAndSwap()
의사코드
function compareAndSwap(expectedValue, newValue):
currentValue ← readFromMemory() // 현재 값 읽기
if currentValue == expectedValue:
writeToMemory(newValue) // 예상한 값과 같으면 새 값으로 교체
return true // 교체 성공
else:
return false // 예상과 다르면 교체 실패
incrementAndGet()
의사코드
function incrementAndGet():
loop forever:
current ← get() // ① 현재 값 읽기
next ← current + 1 // ② 새 값 계산
if compareAndSwap(current, next) // ③ CAS 성공 시
return next // 계산된 값을 반환하며 종료
// CAS 실패하면 loop 처음으로 돌아가서 재시도
🆚 차이점
항목 | AtomicReference |
synchronized |
---|---|---|
동기화 방식 | 비차단(non-blocking), CAS 기반 | 차단(blocking), 락 기반 |
성능 | 경합이 적을 땐 빠름 (락 오버헤드 없음) | 경합이 많아질수록 느림 (락 오버헤드 큼) |
사용 대상 | 변수(참조 1개)에 대한 원자적 연산 | 블록/메서드 내 모든 코드에 대한 동기화 |
교착 상태 | 없음 (Deadlock 없음) | 있음 (락 잘못 사용 시 Deadlock 발생 가능) |
복잡도 | 간단한 연산에 적합 | 복잡한 로직 동기화에 적합 |
결론
정리하면 즉시 다른 쓰레드도 알아야되는 값이면 volatile
를 사용하고,
값이 계속 누적되고 하나라도 누락되면 안되는 경우는 synchronized
, Atomic*
를 사용하면 된다.
✅ 요약 정리
상황 | 예시 | 적절한 방식 | 설명 |
---|---|---|---|
즉시 다른 스레드가 변경을 알아야 하는 단순 플래그 | boolean running , boolean enabled |
volatile |
가시성(visibility)만 보장하면 되는 경우 |
하나라도 빠지면 안 되는 숫자 증가, 데이터 누적 등 | count++ , 리스트에 요소 추가 |
synchronized , AtomicInteger , ConcurrentList 등 |
원자성(atomicity)과 정합성(consistency)이 중요하므로 더 강한 동기화 필요 |
값이 바뀌더라도 다른 스레드가 몰라도 되는 경우 | 스레드 로컬 캐시, 로깅용 통계 등 | 상황에 따라 다름 | 동기화가 필요 없는 경우도 있음 |
반응형