Search

멀티 스레드와 동시성 (3) 동기화: synchronized와 ReentrantLock

Tags
Study
Java
Last edited time
2025/09/29 00:32
2 more properties
Search
멀티 스레드와 동시성 (5) 자바 동기화 메커니즘: 스레드 락 / 대기 집합 / 임계영역
Study
Java
멀티 스레드와 동시성 (5) 자바 동기화 메커니즘: 스레드 락 / 대기 집합 / 임계영역
Study
Java

1. 모니터락과 Synchronized

synchronized는 자바에서 멀티스레드 환경의 동시성 문제를 해결하기 위한 핵심 키워드
주로 공유 자원에 여러 스레드가 동시에 접근할 때 발생할 수 있는 데이터 불일치나 예상치 못한 동작을 방지하기 위해 사용
예시 코드
초기 잔액: 1000원
스레드1이 800원 출금 시도
스레드2가 800원 출금 시도
둘 다 검증을 통과하여 잔액이 -600원이 되는 문제 발생
package thread.sync; import static util.MyLogger.log; import static util.ThreadUtils.sleep; public class BankMain { public static void main(String[] args) throws InterruptedException { BankAccount account = new BankAccountV1(1000); Thread t1 = new Thread(new WithdrawTask(account, 800), "t1"); Thread t2 = new Thread(new WithdrawTask(account, 800), "t2"); t1.start(); t2.start(); sleep(500); // 검증 완료까지 잠시 대기 log("t1 state : " + t1.getState()); log("t2 state : " + t2.getState()); t1.join(); t2.join(); log("최종 잔액: " + account.getBalance()); } }
Java
복사
public class BankAccountV1 implements BankAccount { private int balance; public BankAccountV1(int initialBalance) { this.balance = initialBalance; } @Override public boolean withdraw(final int amount) { log("거래 시작: " + getClass().getSimpleName()); // 잔고가 출금액보다 적으면, 진행하면 안됨 log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance); if (balance < amount) { log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance); return false; } // 잔고가 출금액 보다 많으면, 진행 log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance); sleep(1000); // 출금에 걸리는 시간으로 가정 balance = balance - amount; log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance); log("거래 종료"); return true; } @Override public int getBalance() { return balance; } }
Java
복사

1.1. 모니터락의 정의

자바의 모든 객체는 고유한 모니터 락을 가지고 있음
이 락은 멀티스레드 환경에서 공유 자원에 여러 스레드가 동시에 접근하여 발생하는 동시성 문제를 방지하기 위한 핵심 도구
특히, 여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 코드 부분인 임계 영역을 보호하는 데 사용
모니터 락은 임계 영역에 한 번에 하나의 스레드만 접근하도록 보장하여 데이터의 일관성과 안전성을 유지

1.2. synchronized의 주요 특징

모니터 락은 주로 synchronized 키워드와 함께 사용됨
스레드가 synchronized 메서드나 블록에 진입하려고 할 때, 해당 객체의 모니터 락을 획득해야 함
락을 성공적으로 획득하면 스레드는 임계 영역 코드를 실행할 수 있음
만약 다른 스레드가 이미 락을 가지고 있다면, 락을 획득하려는 스레드는 BLOCKED 상태가 되어 락이 해제될 때까지 대기
synchronized 메서드나 블록이 완료되거나 예외로 종료되면, 스레드는 자동으로 모니터 락을 해제

1.3. synchronized 사용 패턴

메서드 레벨 동기화
@Override public synchronized boolean withdraw(int amount) { log("거래 시작: " + getClass().getSimpleName()); log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance); if(balance < amount) { log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance); return false; } log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance); sleep(1000); balance = balance - amount; log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance); log("거래 종료"); return true; }
Java
복사
블록 레벨 동기화
@Override public boolean withdraw(int amount) { log("거래 시작: " + getClass().getSimpleName()); // 특정 코드 블록만 동기화 synchronized (this) { log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance); if(balance < amount) { log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance); return false; } log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance); sleep(1000); balance = balance - amount; log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance); } log("거래 종료"); return true; }
Java
복사

1.4. 메모리 가시성 보장

모니터 락의 획득과 해제는 Java Memory Model(JMM)에서 정의하는 happens-before 관계를 형성
이는 한 스레드가 synchronized 블록 내에서 공유 변수를 변경하면, 이 락을 획득한 다른 스레드가 해당 변경 사항을 즉시 볼 수 있게(메모리 가시성) 보장

1.5. synchronized 의 한계점

무한 대기(Infinite Waiting)
synchronized를 통해 락을 획득하지 못해 BLOCKED 상태가 된 스레드는 락이 풀릴 때까지 무기한 대기
특정 시간만 대기하는 타임아웃 기능이나 대기 중 인터럽트 처리 기능이 없음
공정성(Fairness) 부재
락이 해제되었을 때, 락 대기 집합에 있는 여러 스레드 중 어떤 스레드가 다음 락을 획득할지는 보장되지 않음
이로 인해 특정 스레드가 장기간 락을 획득하지 못하는 기아 현상이 발생할 수 있음
단일 대기 집합
Object.wait()notify()는 하나의 스레드 대기 집합을 사용하므로, 생산자-소비자 문제와 같은 상황에서 생산자가 소비자를 깨워야 할 때 임의의 스레드(예: 다른 생산자)를 깨우는 비효율성이 발생할 수 있음
이러한 synchronized의 모니터 락의 한계점을 극복하기 위해 자바 1.5부터는 java.util.concurrent.locks.Lock 인터페이스와 ReentrantLock 구현체가 도입됨.
ReentrantLock은 모니터 락과 달리 객체 내부에 내재된 락이 아니며, 유연하게 락을 제어하고, 타임아웃, 인터럽트 가능한 대기, 그리고 여러 개의 Condition 객체를 통한 분리된 대기 집합을 제공

2. LockSupport 클래스

2.1. LockSupport 개념

LockSupport는 스레드를 블로킹하고 언블로킹하는 저수준 원시 기능을 제공하는 유틸리티 클래스
synchronized와 달리 더 유연한 스레드 제어가 가능합니다.

2.2. LockSupport 주요 메서드

park()
스레드를 WAITING 상태로 변경
unpark()
WATING 상태의 스레드를 RUNNABLE 상태로 변경
public class LockSupportExample { public void demonstrateParkUnpark() { Thread worker = new Thread(() -> { System.out.println("작업 시작"); // 현재 스레드를 블로킹 LockSupport.park(); // 여기서 대기 System.out.println("작업 재개"); }); worker.start(); try { Thread.sleep(2000); // 2초 대기 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 워커 스레드 언블로킹 LockSupport.unpark(worker); } }
Java
복사

3. ReentrantLock 클래스

3.1. ReentrantLock 개념

ReentrantLock은 자바 1.5부터 java.util.concurrent 패키지에 추가된 Lock 인터페이스의 대표적인 구현체
기존의 synchronized 키워드가 가진 여러 한계점을 극복하기 위해 도입되었음
synchronized보다 더 유연한 락킹 메커니즘을 제공하는 명시적 락
재진입이 가능하며, 공정성 모드와 타임아웃 등 고급 기능을 지원

3.2. synchronized의 단점 해결

무한 대기 문제 해결
tryLock() 메서드를 통해 특정 시간 동안만 락 획득을 시도
lockInterruptibly() 메서드를 통해 대기 중에 인터럽트를 받고 락 획득을 포기할 수 있도록 하여 이 문제를 해결가능
공정성(Fairness) 문제 해결
ReentrantLock은 생성자 매개변수를 통해 공정 모드(fair mode)를 설정 가능
공정 모드에서는 락을 요청한 순서(FIFO)대로 스레드가 락을 획득하게 하여 공정성을 보장하고 기아 현상을 방지
비공정 모드(기본값)는 성능을 우선시함

3.3. ReentrantLock 기본 사용법

lock()
락을 획득. 만약 다른 스레드가 이미 락을 획득했다면, 락이 풀릴 때까지 현재 스레드는 WAITING 상태로 대기. 이 메서드는 인터럽트에 응답하지 않음
import java.util.concurrent.locks.ReentrantLock; public class LockExample { private final ReentrantLock lock = new ReentrantLock(); private int count = 0; public void increment() { lock.lock(); // 대기 후 반드시 락 획득 try { count++; Thread.sleep(100); // 임계 구역 } catch (InterruptedException ignored) {} finally { lock.unlock(); // 반드시 해제 } } public int getCount() { return count; } public static void main(String[] args) throws InterruptedException { LockExample ex = new LockExample(); Runnable worker = () -> { for (int i = 0; i < 3; i++) ex.increment(); System.out.println(Thread.currentThread().getName() + " done"); }; Thread t1 = new Thread(worker, "T1"); Thread t2 = new Thread(worker, "T2"); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("Final count = " + ex.getCount()); } }
Java
복사
tryLock()
락 획득을 시도하고, 즉시 성공 여부(true/false)를 반환합니다. 락을 획득할 수 없다면 대기하지 않고 즉시 false를 반환
import java.util.concurrent.locks.ReentrantLock; public class TryLockExample { private final ReentrantLock lock = new ReentrantLock(); private int count = 0; public boolean tryIncrement() { if (lock.tryLock()) { // 즉시 시도 try { count++; Thread.sleep(200); return true; } catch (InterruptedException ignored) {} finally { lock.unlock(); } } return false; // 락 못 얻음 } public static void main(String[] args) throws InterruptedException { TryLockExample ex = new TryLockExample(); // 락을 오래 잡고 있게 함 Thread holder = new Thread(() -> { ex.lock.lock(); try { System.out.println("[HOLDER] holding lock..."); Thread.sleep(1000); } catch (InterruptedException ignored) {} finally { ex.lock.unlock(); } }); holder.start(); Thread.sleep(100); // holder가 먼저 락을 잡도록 Runnable task = () -> { boolean ok = ex.tryIncrement(); System.out.println(Thread.currentThread().getName() + " result=" + ok); }; new Thread(task, "T1").start(); new Thread(task, "T2").start(); } } // [HOLDER] holding lock for 1s... // [T1] tryIncrement result=false, count=0 // [T2] tryIncrement result=false, count=0 // [HOLDER] released lock // [T3] tryIncrement result=true, count=1 // Final count = 1
Java
복사
tryLock(long time, TimeUnit unit)
주어진 시간 동안 락 획득을 시도
시간 내에 락을 획득하면 true, 시간 초과 시 false를 반환하며, 대기 중 인터럽트 발생 시 InterruptedException을 던짐
import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; public class TryLockTimeoutExample { private final ReentrantLock lock = new ReentrantLock(); private int count = 0; public boolean tryIncrementWithTimeout(long timeout, TimeUnit unit) { try { if (lock.tryLock(timeout, unit)) { try { count++; Thread.sleep(200); return true; } finally { lock.unlock(); } } return false; // 타임아웃 } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } public static void main(String[] args) throws InterruptedException { TryLockTimeoutExample ex = new TryLockTimeoutExample(); Thread holder = new Thread(() -> { ex.lock.lock(); try { System.out.println("[HOLDER] holding lock..."); Thread.sleep(1500); } catch (InterruptedException ignored) {} finally { ex.lock.unlock(); } }); holder.start(); Thread.sleep(100); Runnable task = () -> { boolean ok = ex.tryIncrementWithTimeout(1, TimeUnit.SECONDS); System.out.println(Thread.currentThread().getName() + " result=" + ok); }; new Thread(task, "W1").start(); } } // [HOLDER] holding lock for 1500ms... // [W1] tryLock(timeout) result=false, count=0 // [HOLDER] released lock // [W2] tryLock(timeout) result=true, count=1 // Final count = 1
Java
복사
lockInterruptibly()
락 획득을 시도하되, 대기 중에 인터럽트가 발생하면 InterruptedException을 던지고 락 획득을 포기
import java.util.concurrent.locks.ReentrantLock; public class LockInterruptiblyExample { private final ReentrantLock lock = new ReentrantLock(); private int count = 0; public void interruptibleIncrement() throws InterruptedException { lock.lockInterruptibly(); // 대기 중 인터럽트 가능 try { count++; Thread.sleep(2000); } finally { lock.unlock(); } } public static void main(String[] args) throws InterruptedException { LockInterruptiblyExample ex = new LockInterruptiblyExample(); Thread holder = new Thread(() -> { ex.lock.lock(); try { System.out.println("[HOLDER] holding lock..."); Thread.sleep(2000); } catch (InterruptedException ignored) {} finally { ex.lock.unlock(); } }); holder.start(); Thread.sleep(100); Thread waiter = new Thread(() -> { try { System.out.println("[WAITER] trying lockInterruptibly..."); ex.interruptibleIncrement(); System.out.println("[WAITER] done"); } catch (InterruptedException e) { System.out.println("[WAITER] interrupted while waiting"); Thread.currentThread().interrupt(); } }); waiter.start(); Thread.sleep(300); System.out.println("[MAIN] interrupting waiter"); waiter.interrupt(); } } // [HOLDER] holding lock for 2s... // [WAITER] trying lockInterruptibly... // [MAIN] interrupting waiter // [WAITER] interrupted while waiting for lock // [HOLDER] released lock // Final count = 0
Java
복사
unlock()
락을 해제. 락을 획득한 스레드가 반드시 호출해야 함
일반적으로 try-finally 블록 내에서 호출하여 락이 항상 해제되도록 보장
락을 해제하면 대기 중인 스레드 중 하나가 락을 획득할 수 있게 됨

3.4. ReentrantLock 공정성 모드

public class FairnessComparisonExample { // 공정하지 않은 락 (기본값) - 성능 우선 private final ReentrantLock unfairLock = new ReentrantLock(false); // 공정한 락 - FIFO 순서 보장, 기아 방지 private final ReentrantLock fairLock = new ReentrantLock(true); public void demonstrateUnfairLock() { unfairLock.lock(); try { // 새로 도착한 스레드가 대기 중인 스레드보다 먼저 락을 획득할 수 있음 // 더 높은 처리량, 하지만 일부 스레드의 긴 대기 시간 가능 performWork(); } finally { unfairLock.unlock(); } } public void demonstrateFairLock() { fairLock.lock(); try { // FIFO 순서로 락 획득 보장 // 기아 현상 방지, 하지만 처리량 감소 가능 performWork(); } finally { fairLock.unlock(); } } private void performWork() { // 작업 수행 System.out.println(Thread.currentThread().getName() + " 작업 중"); } }
Java
복사

3.5. ReentrantLock 고급 기능

public class AdvancedReentrantLockExample { private final ReentrantLock lock = new ReentrantLock(); private final Condition notEmpty = lock.newCondition(); private final Condition notFull = lock.newCondition(); private final Queue<String> queue = new LinkedList<>(); private final int maxSize = 10; // Condition을 이용한 생산자-소비자 패턴 public void put(String item) throws InterruptedException { lock.lock(); try { while (queue.size() == maxSize) { notFull.await(); // 큐가 가득 찰 때까지 대기 } queue.offer(item); System.out.println("생산: " + item); notEmpty.signal(); // 소비자에게 신호 } finally { lock.unlock(); } } public String take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); // 큐가 빌 때까지 대기 } String item = queue.poll(); System.out.println("소비: " + item); notFull.signal(); // 생산자에게 신호 return item; } finally { lock.unlock(); } } // 락 상태 모니터링 public void printLockInfo() { System.out.println("보유 중인 스레드: " + (lock.isLocked() ? lock.getOwner().getName() : "없음")); System.out.println("대기 중인 스레드 수: " + lock.getQueueLength()); System.out.println("현재 스레드의 보유 횟수: " + lock.getHoldCount()); } }
Java
복사