Java의 synchronized, volatile, Lock의 차이점과 스레드 안전성 확보 방법을 설명해주세요.
답변 포인트
가시성(visibility)과 원자성(atomicity)의 차이를 생각해보세요.
정답 및 해설
빠른 요약
volatile: 변수 쓰기/읽기를 메인 메모리에서 직접 수행 → 가시성 보장. 원자성은 보장하지 않음 (i++같은 복합 연산은 위험).
멀티스레드 환경에서 공유 자원에 대한 동시 접근은 데이터 불일치, 경쟁 조건(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) (공정 락) |