[Java] Lock의 종류와 동시성 제어

슈퍼대디·2024년 12월 23일

CS면접대비

목록 보기
3/13

Lock의 종류와 동시성 제어

목차

  1. 동시성 제어의 필요성
  2. synchronized 키워드
  3. Lock 인터페이스
  4. 고급 동시성 제어
  5. 실무 적용 가이드
  6. 면접 예상 질문

1. 동시성 제어의 필요성

동시성 문제 예시

public class Counter {
    private int count = 0;
    
    // 동시성 제어가 없는 경우
    public void increment() {
        count++; // 문제 발생 가능 지점
    }
    
    public int getCount() {
        return count;
    }
}

위 코드는 다음과 같은 문제를 발생시킬 수 있습니다:
1. Race Condition
2. 가시성(Visibility) 문제
3. 원자성(Atomicity) 위배

2. synchronized 키워드

메서드 레벨 동기화

public class SafeCounter {
    private int count = 0;
    
    // 메서드 전체를 동기화
    public synchronized void increment() {
        count++;
    }
    
    public synchronized int getCount() {
        return count;
    }
}

블록 레벨 동기화

public class BlockSyncExample {
    private final Object lockObject = new Object();
    private int count = 0;
    
    public void increment() {
        synchronized(lockObject) {
            count++;
        }
    }
    
    public void complexOperation() {
        // 동기화가 필요없는 작업
        heavyComputation();
        
        // 동기화가 필요한 부분만 블록으로 처리
        synchronized(lockObject) {
            count++;
        }
    }
}

synchronized의 특징

  1. 자동 잠금 해제
  2. 재진입 가능 (Reentrant)
  3. 블록 구조적 잠금
  4. 묵시적 모니터 제공

3. Lock 인터페이스와 세부 기능 설명

Lock 인터페이스 개요

Lock 인터페이스는 synchronized보다 더 유연하고 세밀한 동시성 제어를 제공합니다. 주요 메서드들은 다음과 같습니다:

  • lock(): 락을 획득할 때까지 대기
  • unlock(): 락 해제
  • tryLock(): 락 획득을 시도하고 즉시 결과 반환
  • tryLock(long time, TimeUnit unit): 지정된 시간 동안만 락 획득 시도
  • newCondition(): 현재 락과 연관된 Condition 객체 생성

ReentrantLock

public class ReentrantLockCounter {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;
    
    public void increment() {
        lock.lock();  // 명시적 잠금
        try {
            count++;
        } finally {
            lock.unlock();  // 명시적 해제
        }
    }
    
    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

ReentrantLock 상세 설명

ReentrantLock은 가장 일반적으로 사용되는 Lock 구현체입니다. "Reentrant"는 재진입이 가능하다는 의미로, 같은 스레드가 이미 획득한 락을 다시 획득할 수 있습니다.

주요 특징과 기능

  1. 공정성 (Fairness)

    // 공정한 락 생성
    private final ReentrantLock fairLock = new ReentrantLock(true);
    // 비공정한 락 생성 (기본값)
    private final ReentrantLock unfairLock = new ReentrantLock(false);
    • 공정한 락: 가장 오래 기다린 스레드가 우선적으로 락 획득
    • 비공정한 락: 락 획득 순서가 무작위 (성능상 이점)
  2. 락 획득 시도 (tryLock)

    public boolean transferMoney(Account from, Account to, double amount) {
        boolean fromLocked = from.getLock().tryLock();
        try {
            if (!fromLocked) {
                return false; // 락 획득 실패
            }
            
            boolean toLocked = to.getLock().tryLock();
            try {
                if (!toLocked) {
                    return false; // 락 획득 실패
                }
                
                // 실제 송금 처리
                if (from.getBalance() < amount) {
                    throw new InsufficientFundsException();
                }
                from.debit(amount);
                to.credit(amount);
                return true;
            } finally {
                if (toLocked) {
                    to.getLock().unlock();
                }
            }
        } finally {
            if (fromLocked) {
                from.getLock().unlock();
            }
        }
    }
  3. 인터럽트 가능한 락 획득

    public void lockInterruptibly() throws InterruptedException {
        lock.lockInterruptibly();
        try {
            // 임계 영역
        } finally {
            lock.unlock();
        }
    }
  4. 락 상태 확인

    ReentrantLock lock = new ReentrantLock();
    
    // 현재 락이 획득된 상태인지 확인
    boolean isLocked = lock.isLocked();
    
    // 현재 스레드가 락을 보유하고 있는지 확인
    boolean isHeldByCurrentThread = lock.isHeldByCurrentThread();
    
    // 대기 중인 스레드 수 확인
    int queueLength = lock.getQueueLength();
public class AdvancedLockExample {
    private final ReentrantLock lock = new ReentrantLock(true); // 공정성 설정
    
    public void tryLockExample() {
        boolean acquired = lock.tryLock(); // 비차단 잠금 시도
        if (acquired) {
            try {
                // 임계 영역
            } finally {
                lock.unlock();
            }
        }
    }
    
    public void timeoutLockExample() {
        try {
            boolean acquired = lock.tryLock(1, TimeUnit.SECONDS); // 타임아웃 설정
            if (acquired) {
                try {
                    // 임계 영역
                } finally {
                    lock.unlock();
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

ReadWriteLock 상세 설명

ReadWriteLock은 읽기와 쓰기 작업을 분리하여 처리하는 락 인터페이스입니다. 읽기 작업은 동시에 여러 스레드가 수행할 수 있지만, 쓰기 작업은 독점적으로 수행되어야 할 때 사용합니다.

작동 원리

  1. 읽기 락(Read Lock)

    • 여러 스레드가 동시에 읽기 락을 획득 가능
    • 쓰기 락이 획득된 상태에서는 읽기 락 획득 불가
    • 데이터 일관성을 해치지 않으면서 동시성 향상
  2. 쓰기 락(Write Lock)

    • 한 번에 하나의 스레드만 쓰기 락 획득 가능
    • 읽기 락이 획득된 상태에서는 쓰기 락 획득 불가
    • 배타적 접근 보장

구현 예시: 캐시 시스템

public class EnhancedCache {
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();
    
    private final Map<String, String> cache = new HashMap<>();
    private final Map<String, Long> expiryMap = new HashMap<>();
    private final long defaultExpiryMs = 60000; // 1분
    
    public String get(String key) {
        readLock.lock();
        try {
            // 만료 체크
            Long expiryTime = expiryMap.get(key);
            if (expiryTime != null && System.currentTimeMillis() > expiryTime) {
                readLock.unlock(); // 읽기 락 해제
                
                // 만료된 데이터 제거를 위해 쓰기 락 획득
                writeLock.lock();
                try {
                    cache.remove(key);
                    expiryMap.remove(key);
                    return null;
                } finally {
                    writeLock.unlock();
                }
            }
            return cache.get(key);
        } finally {
            if (readLock.tryLock()) { // 위에서 이미 unlock한 경우 체크
                readLock.unlock();
            }
        }
    }
    
    public void put(String key, String value) {
        writeLock.lock();
        try {
            cache.put(key, value);
            expiryMap.put(key, System.currentTimeMillis() + defaultExpiryMs);
        } finally {
            writeLock.unlock();
        }
    }
    
    public int size() {
        readLock.lock();
        try {
            return cache.size();
        } finally {
            readLock.unlock();
        }
    }
    
    // 캐시 청소
    public void cleanup() {
        writeLock.lock();
        try {
            long now = System.currentTimeMillis();
            Iterator<Map.Entry<String, Long>> it = expiryMap.entrySet().iterator();
            while (it.hasNext()) {
                Map.Entry<String, Long> entry = it.next();
                if (now > entry.getValue()) {
                    cache.remove(entry.getKey());
                    it.remove();
                }
            }
        } finally {
            writeLock.unlock();
        }
    }
}
public class CacheWithReadWriteLock {
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();
    private final 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();
        }
    }
}

4. 고급 동시성 제어

StampedLock 상세 설명

StampedLock은 Java 8에서 도입된 새로운 락 메커니즘으로, ReadWriteLock의 성능을 개선한 버전입니다. 가장 큰 특징은 낙관적 읽기(Optimistic Reading) 모드를 제공한다는 점입니다.

StampedLock의 세 가지 모드

  1. 쓰기 모드 (Write Mode)

    long stamp = lock.writeLock(); // 배타적 락 획득
    try {
        // 쓰기 작업 수행
    } finally {
        lock.unlockWrite(stamp);
    }
  2. 읽기 모드 (Read Mode)

    long stamp = lock.readLock(); // 공유 락 획득
    try {
        // 읽기 작업 수행
    } finally {
        lock.unlockRead(stamp);
    }
  3. 낙관적 읽기 모드 (Optimistic Read Mode)

    long stamp = lock.tryOptimisticRead(); // 락 획득 없이 낙관적으로 읽기
    // 데이터 읽기
    if (!lock.validate(stamp)) { // 도중에 데이터가 변경되었는지 확인
        // 일반 읽기 모드로 전환
        stamp = lock.readLock();
        try {
            // 데이터 다시 읽기
        } finally {
            lock.unlockRead(stamp);
        }
    }

실제 활용 예시: 좌표 시스템

public class Point {
    private final StampedLock sl = new StampedLock();
    private double x, y;
    
    // 좌표 이동 (쓰기 모드)
    public void move(double deltaX, double deltaY) {
        long stamp = sl.writeLock();
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            sl.unlockWrite(stamp);
        }
    }
    
    // 현재 좌표 읽기 (읽기 모드)
    public Point getPoint() {
        long stamp = sl.readLock();
        try {
            return new Point(x, y);
        } finally {
            sl.unlockRead(stamp);
        }
    }
    
    // 원점으로부터의 거리 계산 (낙관적 읽기 모드)
    public double distanceFromOrigin() {
        // 낙관적 읽기 시도
        long stamp = sl.tryOptimisticRead();
        double currentX = x, currentY = y;
        
        // 데이터가 변경되었는지 확인
        if (!sl.validate(stamp)) {
            // 데이터가 변경되었다면 일반 읽기 락으로 전환
            stamp = sl.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
    
    // 특정 범위 내에 있는지 확인 (락 변환 예시)
    public boolean moveIfInRange(double newX, double newY, double radius) {
        // 낙관적 읽기로 시작
        long stamp = sl.tryOptimisticRead();
        try {
            double currentX = x;
            double currentY = y;
            
            if (!sl.validate(stamp)) {
                // 낙관적 읽기 실패시 읽기 락으로 전환
                stamp = sl.readLock();
                try {
                    currentX = x;
                    currentY = y;
                } finally {
                    sl.unlockRead(stamp);
                }
            }
            
            // 범위 체크
            if (Math.sqrt((newX - currentX) * (newX - currentX) +
                         (newY - currentY) * (newY - currentY)) > radius) {
                return false;
            }
            
            // 쓰기 락으로 전환
            long ws = sl.writeLock();
            try {
                x = newX;
                y = newY;
                return true;
            } finally {
                sl.unlockWrite(ws);
            }
        } finally {
            // 읽기 락이 남아있다면 해제
            if (StampedLock.isReadLockStamp(stamp)) {
                sl.unlockRead(stamp);
            }
        }
    }
}

StampedLock 사용 시 주의사항

  1. 낙관적 읽기는 CAS(Compare-And-Swap) 연산을 사용하므로 CPU 사용량이 증가할 수 있음
  2. ReentrantLock과 달리 재진입이 불가능
  3. 락 해제 시 반드시 획득할 때 받은 stamp를 사용해야 함
  4. InterruptedException을 지원하지 않음
    Java 8에서 도입된 새로운 락 메커니즘입니다.
public class StampedLockExample {
    private final StampedLock sl = new StampedLock();
    private double x, y;
    
    public void move(double deltaX, double deltaY) {
        long stamp = sl.writeLock();
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            sl.unlockWrite(stamp);
        }
    }
    
    public double distanceFromOrigin() {
        // 낙관적 읽기
        long stamp = sl.tryOptimisticRead();
        double currentX = x, currentY = y;
        if (!sl.validate(stamp)) {
            // 낙관적 읽기 실패시 일반 읽기 락으로 전환
            stamp = sl.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
}

Condition

public class BoundedBuffer<T> {
    private final Lock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();
    private final T[] items;
    private int putIndex, takeIndex, count;
    
    public BoundedBuffer(int capacity) {
        items = (T[]) new Object[capacity];
    }
    
    public void put(T item) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length) {
                notFull.await();
            }
            items[putIndex] = item;
            putIndex = (putIndex + 1) % items.length;
            count++;
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }
    
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) {
                notEmpty.await();
            }
            T item = items[takeIndex];
            takeIndex = (takeIndex + 1) % items.length;
            count--;
            notFull.signal();
            return item;
        } finally {
            lock.unlock();
        }
    }
}

5. 실무 적용 가이드

Lock 선택 가이드라인

  1. synchronized 사용이 적절한 경우

    • 단순한 동기화가 필요한 경우
    • 락의 범위가 명확한 경우
    • 중첩된 락이 필요없는 경우
  2. ReentrantLock 사용이 적절한 경우

    • 타임아웃이 필요한 경우
    • 비차단 잠금이 필요한 경우
    • 공정성 보장이 필요한 경우
    • 락 중첩이 필요한 경우
  3. ReadWriteLock 사용이 적절한 경우

    • 읽기 작업이 쓰기 작업보다 많은 경우
    • 읽기 작업 병렬화가 필요한 경우
  4. StampedLock 사용이 적절한 경우

    • 낙관적 읽기가 유용한 경우
    • 읽기/쓰기 성능이 매우 중요한 경우

성능 고려사항

public class PerformanceExample {
    // 세밀한 락 분할
    private final Lock lock1 = new ReentrantLock();
    private final Lock lock2 = new ReentrantLock();
    
    public void operation1() {
        lock1.lock();
        try {
            // 작업 1
        } finally {
            lock1.unlock();
        }
    }
    
    public void operation2() {
        lock2.lock();
        try {
            // 작업 2
        } finally {
            lock2.unlock();
        }
    }
}

6. 면접 예상 질문

Q: synchronized와 ReentrantLock의 차이점을 설명해주세요.
A: 주요 차이점:

  1. 기능적 측면

    • synchronized: 자동 잠금/해제
    • ReentrantLock: 명시적 잠금/해제, 타임아웃, 공정성 지원
  2. 유연성

    • synchronized: 블록 구조적 락킹만 가능
    • ReentrantLock: 더 유연한 락킹 방식 제공
  3. 성능

    • synchronized: Java 6 이후 많은 최적화
    • ReentrantLock: 처음부터 성능 최적화 설계

Q: Java의 동시성 문제를 해결하기 위한 방법들을 설명해주세요.
A: 동시성 문제 해결 방법:

  1. 락 기반 해결책

    • synchronized 키워드
    • Lock 인터페이스 구현체
    • volatile 키워드
  2. 격리 기반 해결책

    • ThreadLocal 사용
    • 불변 객체 활용
    • Concurrent 컬렉션 사용
  3. 비차단 알고리즘

    • CAS(Compare-And-Swap)
    • Atomic 클래스 사용

Q: ReadWriteLock을 사용할 때의 장단점은 무엇인가요?
A: ReadWriteLock 특징:

  1. 장점

    • 읽기 작업의 병렬처리 가능
    • 읽기/쓰기 작업 분리로 인한 성능 향상
    • 데드락 위험 감소
  2. 단점

    • 구현 복잡도 증가
    • 메모리 사용량 증가
    • 오버헤드 발생 가능성

참고 자료

profile
성장하고싶은 Backend 개발자

0개의 댓글