Java의 synchronized, volatile, Lock의 차이점과 스레드 안전성 확보 방법을 설명해주세요.
힌트
가시성(visibility)과 원자성(atomicity)의 차이를 생각해보세요.
정답 및 해설
Java의 synchronized, volatile, Lock의 차이점과 스레드 안전성 확보 방법을 설명해주세요.
멀티스레드 환경에서 공유 자원에 대한 동시 접근은 데이터 불일치, 경쟁 조건(race condition) 등의 문제를 일으킵니다. Java는 이를 해결하기 위해 volatile, synchronized, Lock 세 가지 메커니즘을 제공합니다. 각각 보장하는 범위와 적용 시나리오가 다르므로 올바른 선택이 중요합니다.
스레드 안전성의 두 가지 핵심 개념
가시성(Visibility)
한 스레드가 공유 변수를 수정했을 때, 다른 스레드가 그 변경 사항을 즉시 볼 수 있는 성질입니다. CPU 캐시와 메인 메모리 간의 불일치로 발생합니다.
// 가시성 문제 예시
class VisibilityProblem {
private boolean flag = false; // CPU 캐시에 복사될 수 있음
public void writer() {
flag = true; // Thread A가 캐시에 쓰지만 메인 메모리 반영 지연 가능
}
public void reader() {
while (!flag) { // Thread B는 캐시의 false를 계속 읽을 수 있음
// 무한 루프 위험
}
System.out.println("flag is true");
}
}
원자성(Atomicity)
하나의 연산이 완전히 실행되거나 전혀 실행되지 않는 성질입니다. 중간 상태가 다른 스레드에 노출되지 않습니다.
// 원자성 문제 예시 (i++는 실제로 3단계 연산)
// 1. 메모리에서 i 읽기
// 2. i + 1 계산
// 3. 결과를 메모리에 쓰기
// → 두 스레드가 동시에 실행하면 하나의 증가가 유실될 수 있음
int counter = 0;
counter++; // NOT atomic!
volatile
동작 원리
volatile 키워드는 변수를 CPU 캐시가 아닌 메인 메모리에서 직접 읽고 쓰도록 강제합니다. 이로써 가시성은 보장되지만 원자성은 보장하지 않습니다.
class VolatileExample {
private volatile boolean flag = false;
private volatile int count = 0;
// Thread A
public void stop() {
flag = true; // 즉시 메인 메모리에 반영
}
// Thread B
public void run() {
while (!flag) { // 항상 메인 메모리에서 읽음 → 가시성 보장
doWork();
}
}
// 주의: count++는 여전히 원자적이지 않음
public void increment() {
count++; // read-modify-write: 3단계 → 경쟁 조건 발생 가능
}
}
volatile이 적합한 경우
// 패턴 1: 단순 플래그 (읽기/쓰기만, 복합 연산 없음)
public class ServiceRunner {
private volatile boolean running = true;
public void shutdown() {
running = false;
}
public void run() {
while (running) {
processNextTask();
}
}
}
// 패턴 2: 싱글톤 double-checked locking (Java 5+)
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 1차 체크 (락 없이)
synchronized (Singleton.class) {
if (instance == null) { // 2차 체크 (락 내부)
instance = new Singleton();
}
}
}
return instance;
}
}
volatile의 한계
// 안전하지 않은 코드: volatile은 복합 연산을 보호하지 못함
private volatile int count = 0;
// 두 스레드가 동시에 호출하면 최종값이 기대와 다를 수 있음
public void increment() {
count++; // 여전히 race condition 발생
}
synchronized
동작 원리
synchronized는 **내재적 락(intrinsic lock / monitor lock)**을 사용하여 한 번에 하나의 스레드만 블록 또는 메서드에 접근하도록 보장합니다. 가시성과 원자성을 모두 보장합니다.
synchronized 메서드
public class Counter {
private int count = 0;
// 인스턴스 메서드: this 객체의 락 사용
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
// 정적 메서드: Counter.class 객체의 락 사용
private static int staticCount = 0;
public static synchronized void incrementStatic() {
staticCount++;
}
}
synchronized 블록
public class BankAccount {
private double balance;
private final Object lock = new Object(); // 전용 락 객체 (권장)
public void deposit(double amount) {
synchronized (lock) { // 더 좁은 범위만 보호 → 성능 향상
balance += amount;
}
// 락 밖에서 로그 출력 등 비보호 작업
System.out.println("Deposited: " + amount);
}
public void withdraw(double amount) {
synchronized (lock) {
if (balance >= amount) {
balance -= amount;
}
}
}
// 안티패턴: 공개된 객체로 락 → 외부에서 해당 객체로 락 획득 가능
public void badDeposit(double amount) {
synchronized (this) { // this를 락으로 사용하면 외부 코드가 동일 락 사용 가능
balance += amount;
}
}
}
재진입 가능(Reentrant)
synchronized는 같은 스레드가 이미 보유한 락을 다시 획득할 수 있습니다.
public class ReentrantExample {
public synchronized void outer() {
System.out.println("outer");
inner(); // 같은 스레드가 inner의 락도 획득 가능 (데드락 없음)
}
public synchronized void inner() {
System.out.println("inner");
}
}
Lock (java.util.concurrent.locks)
Java 5부터 제공되는 명시적 락으로 synchronized보다 유연한 제어가 가능합니다. 단, unlock을 명시적으로 호출해야 합니다.
ReentrantLock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class SafeCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // 락 획득
try {
count++;
} finally {
lock.unlock(); // 반드시 finally에서 해제
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
tryLock - 타임아웃 및 비차단 락
import java.util.concurrent.TimeUnit;
public class ResourceManager {
private final Lock lock = new ReentrantLock();
public boolean processIfAvailable() {
// 락 획득 즉시 시도, 실패 시 기다리지 않고 false 반환
if (lock.tryLock()) {
try {
process();
return true;
} finally {
lock.unlock();
}
}
return false; // 다른 스레드가 락 보유 중
}
public boolean processWithTimeout() throws InterruptedException {
// 최대 500ms 대기 후 실패 시 false 반환
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
process();
return true;
} finally {
lock.unlock();
}
}
return false;
}
private void process() { /* ... */ }
}
ReentrantReadWriteLock - 읽기/쓰기 분리
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadHeavyCache {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
private Map<String, String> cache = new HashMap<>();
// 여러 스레드가 동시에 읽기 가능
public String get(String key) {
readLock.lock();
try {
return cache.get(key);
} finally {
readLock.unlock();
}
}
// 쓰기 시에는 독점 접근
public void put(String key, String value) {
writeLock.lock();
try {
cache.put(key, value);
} finally {
writeLock.unlock();
}
}
}
공정성(Fairness) 설정
// true: 가장 오래 기다린 스레드가 먼저 락 획득 (기아 방지, 성능 저하)
// false (기본값): 성능 우선, 기아 발생 가능
Lock fairLock = new ReentrantLock(true);
java.util.concurrent.atomic 패키지
volatile보다 강력하고 synchronized보다 가벼운 원자적 연산을 제공합니다. CAS(Compare-And-Swap) 하드웨어 명령을 활용합니다.
import java.util.concurrent.atomic.*;
public class AtomicExample {
private AtomicInteger counter = new AtomicInteger(0);
private AtomicReference<String> ref = new AtomicReference<>("initial");
public void increment() {
counter.incrementAndGet(); // 원자적 증가
}
public void conditionalUpdate() {
// CAS: 현재값이 "initial"이면 "updated"로 교체
boolean success = ref.compareAndSet("initial", "updated");
}
public int addAndGet(int delta) {
return counter.addAndGet(delta);
}
}
세 메커니즘 비교
| 구분 | volatile | synchronized | Lock |
|---|---|---|---|
| 가시성 보장 | O | O | O |
| 원자성 보장 | X | O | O |
| 성능 | 가장 빠름 | 중간 | 중간~느림 |
| 유연성 | 낮음 | 중간 | 높음 |
| tryLock/타임아웃 | 불가 | 불가 | 가능 |
| 공정성 설정 | 불가 | 불가 | 가능 |
| 조건 변수 | 불가 | wait/notify | Condition |
| 명시적 unlock | 불필요 | 불필요 | 필수 |
| 적합한 상황 | 단순 플래그, 상태 확인 | 일반적인 동기화 | 복잡한 동기화 요구사항 |
데드락(Deadlock) 방지
// 데드락 발생 패턴
// Thread 1: lock A → lock B
// Thread 2: lock B → lock A
// 해결: 락 획득 순서를 일관되게 유지
public void transfer(Account from, Account to, double amount) {
// 계좌 ID 기준으로 항상 낮은 번호 먼저 락 획득
Account first = from.getId() < to.getId() ? from : to;
Account second = from.getId() < to.getId() ? to : from;
synchronized (first) {
synchronized (second) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
선택 기준 요약
| 상황 | 추천 방법 |
|---|---|
| 단순 boolean 플래그 읽기/쓰기 | volatile |
| 카운터 증감 등 원자 연산 | AtomicInteger 등 |
| 일반적인 공유 자원 보호 | synchronized |
| 타임아웃이 있는 락 획득 | ReentrantLock.tryLock() |
| 읽기 빈번, 쓰기 드문 경우 | ReentrantReadWriteLock |
| 기아 방지가 필요한 경우 | ReentrantLock(true) (공정 락) |