전체 목록
JavaHard#61

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

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

가시성(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);
    }
}

세 메커니즘 비교

구분volatilesynchronizedLock
가시성 보장OOO
원자성 보장XOO
성능가장 빠름중간중간~느림
유연성낮음중간높음
tryLock/타임아웃불가불가가능
공정성 설정불가불가가능
조건 변수불가wait/notifyCondition
명시적 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) (공정 락)