전체 목록
JavaHard#061

Java의 synchronized, volatile, Lock의 차이점과 스레드 안전성 확보 방법을 설명해주세요.

#Java#멀티스레딩#동기화#동시성

답변 포인트

가시성(visibility)과 원자성(atomicity)의 차이를 생각해보세요.

정답 및 해설

빠른 요약

volatile: 변수 쓰기/읽기를 메인 메모리에서 직접 수행 → 가시성 보장. 원자성은 보장하지 않음 (i++같은 복합 연산은 위험).

멀티스레드 환경에서 공유 자원에 대한 동시 접근은 데이터 불일치, 경쟁 조건(race condition) 등의 문제를 일으킵니다. Java는 이를 해결하기 위해 volatile, synchronized, Lock 세 가지 메커니즘을 제공합니다. 각각 보장하는 범위와 적용 시나리오가 다르므로 올바른 선택이 중요합니다.

스레드 안전성의 두 가지 핵심 개념

가시성(Visibility)

한 스레드가 공유 변수를 수정했을 때, 다른 스레드가 그 변경 사항을 즉시 볼 수 있는 성질입니다. CPU 캐시와 메인 메모리 간의 불일치로 발생합니다.

Java
// 가시성 문제 예시
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)

하나의 연산이 완전히 실행되거나 전혀 실행되지 않는 성질입니다. 중간 상태가 다른 스레드에 노출되지 않습니다.

Java
// 원자성 문제 예시 (i++는 실제로 3단계 연산)
// 1. 메모리에서 i 읽기
// 2. i + 1 계산
// 3. 결과를 메모리에 쓰기
// → 두 스레드가 동시에 실행하면 하나의 증가가 유실될 수 있음
int counter = 0;
counter++; // NOT atomic!

volatile

동작 원리

volatile 키워드는 변수를 CPU 캐시가 아닌 메인 메모리에서 직접 읽고 쓰도록 강제합니다. 이로써 가시성은 보장되지만 원자성은 보장하지 않습니다.

Java
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이 적합한 경우

Java
// 패턴 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의 한계

Java
// 안전하지 않은 코드: volatile은 복합 연산을 보호하지 못함
private volatile int count = 0;

// 두 스레드가 동시에 호출하면 최종값이 기대와 다를 수 있음
public void increment() {
    count++; // 여전히 race condition 발생
}

synchronized

동작 원리

synchronized는 **내재적 락(intrinsic lock / monitor lock)**을 사용하여 한 번에 하나의 스레드만 블록 또는 메서드에 접근하도록 보장합니다. 가시성과 원자성을 모두 보장합니다.

synchronized 메서드

Java
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 블록

Java
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는 같은 스레드가 이미 보유한 락을 다시 획득할 수 있습니다.

Java
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

Java
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 - 타임아웃 및 비차단 락

Java
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 - 읽기/쓰기 분리

Java
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) 설정

Java
// true: 가장 오래 기다린 스레드가 먼저 락 획득 (기아 방지, 성능 저하)
// false (기본값): 성능 우선, 기아 발생 가능
Lock fairLock = new ReentrantLock(true);

java.util.concurrent.atomic 패키지

volatile보다 강력하고 synchronized보다 가벼운 원자적 연산을 제공합니다. CAS(Compare-And-Swap) 하드웨어 명령을 활용합니다.

Java
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);
    }
}

세 메커니즘 비교

구분volatilesynchronizedLock
가시성 보장OOO
원자성 보장XOO
성능가장 빠름중간중간~느림
유연성낮음중간높음
tryLock/타임아웃불가불가가능
공정성 설정불가불가가능
조건 변수불가wait/notifyCondition
명시적 unlock불필요불필요필수
적합한 상황단순 플래그, 상태 확인일반적인 동기화복잡한 동기화 요구사항

데드락(Deadlock) 방지

Java
// 데드락 발생 패턴
// 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) (공정 락)

관련 질문

같은 카테고리/태그 기준