멀티스레드에서의 핵심 위험: 동시성 문제
int balance)에 동시에 접근하면 예상치 못한 오류 발생합니다.출금 예제로 보는 동시성 문제
💡 코드 요약
if (balance < amount) {
return false;
}
balance = balance - amount;
t1, t2)가 접근하면 둘 다 잔액이 충분하다고 판단해 출금을 진행문제 상황 요약
| 시나리오 | 설명 |
|---|---|
t1, t2가 동시에 출금 시도 | 둘 다 balance = 1000이라고 판단 |
t1 출금 후 balance = 200 | t2도 출금해 balance = -600 or 200으로 덮어씀 |
| 결과 | 잔액 불일치 또는 마이너스 잔액 발생 (은행 입장에서 재앙) |
핵심 원인: 임계 영역(critical section)
balance를 읽고 → 검증하고 → 갱신하는 일련의 작업은 쪼갤 수 없는 단위여야 합니다.// 임계 영역 예시
if (balance < amount) {
return false;
}
balance = balance - amount;
정리
synchronized 키워드를 제공합니다.synchronized로 임계 영역 보호하기문제 해결: BankAccountV2 - synchronized 메서드 도입
public synchronized boolean withdraw(int amount) {
// 검증 & 출금
}
synchronized 메서드는 해당 메서드 전체를 임계 영역으로 만듭니다.동작 방식
synchronized 메서드는 해당 객체의 락을 획득한 스레드만 진입 가능합니다.실행 흐름 예시 (t1, t2 순서로 실행)
| 시점 | 설명 |
|---|---|
t1 실행 | 락을 획득하고 withdraw() 진입 |
t2 실행 | 락이 없어서 BLOCKED 상태 |
t1 완료 | 락을 해제함 |
t2 | 락을 획득하고 withdraw() 진입 |
| 결과 | 잔액 정확하게 계산됨 (잔액 = 200원) |
synchronized는 자동으로 락을 얻고 반납하므로 직접 unlock할 필요가 없습니다.
락 획득 순서 보장 여부
참고: volatile과의 차이
volatile은 가시성 문제(변경된 값을 즉시 반영)만 해결합니다.balance를 읽고 쓰는 동안 중간에 다른 스레드가 끼어들 수 있습니다.따라서 계산 과정 전체를 보호하려면 반드시 synchronized가 필요합니다.
getBalance()도 보호 필요
public synchronized int getBalance() {
return balance;
}
synchronized 메서드로 충분합니다.synchronized 코드 블럭 – 최소 범위로 임계 영역 지정하기문제 인식
public synchronized boolean withdraw(int amount) {
log("거래 시작");
// ... (임계 영역 시작)
sleep(1000);
balance -= amount;
// ... (임계 영역 끝)
log("거래 종료");
}
synchronized로 잠겨 있으므로, 공유 자원과 관계없는 로그 출력까지도 락 점유에 포함됩니다.해결 방법: 코드 블럭에만 synchronized 적용
public boolean withdraw(int amount) {
log("거래 시작");
synchronized (this) {
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
if (balance < amount) {
log("[검증 실패]");
return false;
}
log("[검증 완료]");
sleep(1000);
balance -= amount;
log("[출금 완료]");
}
log("거래 종료");
return true;
}
synchronized (this)는 해당 인스턴스의 락을 명시적으로 지정합니다.어떤 부분을 보호해야 하나?
성능 측면에서 유리한 이유
| 방식 | 장점 | 단점 |
|---|---|---|
synchronized 메서드 전체 | 구현이 간단함 | 보호 필요 없는 부분도 락 걸림 |
synchronized 블럭 | 필요한 부분만 보호 가능 | 코드가 다소 복잡해짐 |
고성능이 중요하다면 → 블럭 단위로 최소화된 임계 영역 사용 권장합니다.
문제 1: 공유 자원 없이 동기화 X
public void increment() {
count = count + 1;
}
count는 공유 자원입니다.count++는 사실상 3단계 연산:해결: synchronized 키워드 적용
public synchronized void increment() {
count = count + 1;
}
increment()에 진입 가능하려면 synchronized를 추가합니다. → 동시성 문제 해결문제 2: 지역 변수는 공유 대상인가?
public void count() {
int localValue = 0;
for (int i = 0; i < 1000; i++) {
localValue++;
}
log("결과: " + localValue);
}
답: 공유되지 않습니다. → 동기화 불필요
문제 3: final 필드는 안전한가?
private final int value;
답: 안전합니다.
final 필드는 초기화 이후 절대 변경 불가능합니다.전체 정리: synchronized 요약
| 항목 | 설명 |
|---|---|
| 목적 | 공유 자원에 한 번에 하나의 스레드만 접근하게 함 |
| 키워드 | synchronized (메서드, 블럭 단위 모두 가능) |
| 락(lock) | 객체 단위로 존재, 모니터 락이라 부름 |
| 상태 | 락을 못 얻으면 BLOCKED 상태 진입 |
| 가시성 | synchronized는 메모리 가시성 보장도 포함 |
| 성능 | 과도한 동기화는 성능 저하 → 최소 임계 영역 설정 필요 |
synchronized 단점 (그리고 자바 1.5 이후 해결책)
| 단점 | 설명 |
|---|---|
| 무한 대기 | 락 못 얻으면 BLOCKED 상태에서 끝없이 기다림 |
| 공정성 부족 | 어떤 스레드가 락을 얻을지 순서 보장 없음 |
| 인터럽트 불가 | BLOCKED 상태는 interrupted()로도 깰 수 없음 |
자바 1.5부터 등장한 java.util.concurrent 패키지는 위의 단점을 보완한 고급 동시성 제어 도구들을 제공합니다 (ReentrantLock, Semaphore, 등)