메모리 가시성
실제 메모리의 접근 방식
CPU는 처리 성능을 개선하기 위해 중간에 캐시 메모리라는 것을 사용한다.
- 각 스레드가
runFlag
의 값을 사용하면 CPU는 이 값을 효율적으로 처리하기 위해 먼저runFlag
를 캐시 메모리에 불러온다. - 그리고 이후에는 캐시 메모리에 있는
runFlag
를 사용하게 된다. - 즉, 각 스레드는 자신만의 고유한 캐싱된 데이터를 사용하고 있기 때문에, main 스레드에서
runFlag
값을 변경해도, work 스레드의 캐시 메모리는 값이 변경되지 않아서 데이터 정합성이 깨진다. - 핵심은 캐시 메모리의
runFlag
값만 변하고, 메인 메모리에 이 값이 즉시 반영되지 않는다는 것이다.- 캐시 메모리에 있는
runFlag
의 값이 메인 메모리에 언제 반영될지는 알 수 없다. - CPU 설계 방식과 종류에 따라 다르다.
- 하지만, 메인 메모리에 반영을 한다고 해도 문제가 끝이 아니다. 메인 메모리에 반영된
runFlag
값을 work 스레드가 사용하는 캐시 메모리에 다시 반영까지 해주어야 한다. - 그리고 그 반영이 언제될지 역시 알 수 없다..
- 캐시 메모리에 있는
주로 컨텍스트 스위칭이 될 때, 캐시 메모리도 함께 갱신되는데, 이 부분도 환경에 따라 달라질 수 있다. 예를 들어 Thread.sleep()이나 콘솔에 내용을 출력할 때 스레드가 잠시 쉬는데, 이때 컨텍스트 스위칭이 되면서 주로 갱신된다. 하지만 이것이 갱신을 보장하는 것은 아니다
(점선 위쪽은 스레드의 흐름을 나타내고, 점선 아래쪽은 하드웨어를 나타낸다)
메모리 가시성(Memory Visibility)
: 멀티스레드 환경에서 한 스레드가 변경한 값이 다른 스레드에서 언제 보이는지에 대한 문제
: 이름 그대로 메모리에 변경한 값이 보이는가, 보이지 않는가의 문제
그렇다면 한 스레드에서 변경한 값이 다른 스레드에서 즉시 보이게 하려면 어떻게 해야할까?
값을 읽고 쓸 때, 모두 캐시 메모리가 아닌 메인 메모리에 직접 접근하면 된다! 즉, 성능을 포기하고 정확성을 취하는 것이다.
→ 자바에서는 volatile
이라는 키워드로 이런 기능을 제공한다.
public class VolatileFlagMain {
public static void main(String[] args) {
MyTask task = new MyTask();
Thread t = new Thread(task, "work");
log("runFlag = " + task.runFlag);
t.start();
sleep(1000);
log("runFlag를 false로 변경 시도");
task.runFlag = false;
log("runFlag = " + task.runFlag);
log("main 종료");
}
static class MyTask implements Runnable {
// boolean runFlag = true;
volatile boolean runFlag = true;
@Override
public void run() {
log("task 시작");
while (runFlag) {
// runFlag가 false로 변하면 탈출
}
log("task 종료");
}
}
}
여러 스레드에서 같은 값을 읽고 써야 한다면, volatile
키워드를 사용하면 된다.
단, 캐시 메모리를 사용할 때보다 성능이 느려지는 단점이 있기 때문에 꼭 필요한 곳에만 사용하는 것이 좋다!
자바 메모리 모델(Java Memory Model)
Java Memory Model(JMM)
은 자바 프로그램이 어떻게 메모리에 접근하고 수정할 수 있는지를 규정하며, 특히 멀티 스레드 프로그래밍에서 스레드 간의 상호작용을 정의한다. 여러 내용이 있지만, 핵심은 여러 스레드들의 작업 순서를 보장하는 happens-before
관계에 대한 정의다.
happens-before
: 자바 메모리 모델에서 스레드 간의 작업 순서를 정의하는 개념
만약 A 작업이 B 작업보다 happens-before
관계에 있다면, A 작업에서의 모든 메모리 변경 사항은 B 작업에서 볼 수 있다. 즉, A 작업에서 변경된 내용은 B 작업이 시작되기 전에 모두 메모리에 반영된다.
happens-before
관게는 이름 그대로, 한 동작이 다른 동작보다 먼저 발생함을 보장한다.A happens-before B
는 A가 B보다 먼저 발생함을 보장
happens-before
관계는 스레드 간의 메모리 가시성을 보장하는 규칙이다.happens-before
관계가 성립하면, 한 스레드의 작업을 다른 스레드에서 볼 수 있게 된다.- 즉, 한 스레드에서 수행한 작업을 다른 스레드가 참조할 때 최신 상태가 보장되는 것이다.
→ 이 규칙을 따르면, 프로그래머가 멀티스레드 프로그램을 작성할 때 예상치 못한 동작을 피할 수 있다.
happens-before 관계가 발생하는 경우
프로그램 순서 규칙
- 단일 스레드 내에서, 프로그램의 순서대로 작성된 모든 명령문은 happens-before 순서로 실행된다.
volatile 변수 규칙
- 한 스레드에서
volatile
변수에 대한 쓰기 작업은 해당 변수를 읽는 모든 스레드에 보이도록 한다. 즉,volatile
변수에 대한 쓰기 작업은 그 변수를 읽는 작업보다happens-before
관계를 형성한다.
- 한 스레드에서
스레드 시작 규칙
- 여기에서
start()
호출 전에 수행된 모든 작업은 새로운 스레드가 시작된 후의 작업보다happens-before
관계를 가진다. - 한 스레드에서
Thread.start()
를 호출하면, 해당 스레드 내의 모든 작업은start()
를 호출 이후에 실행된 작업보다happens-before
관계가 성립한다.
- 여기에서
스레드 종료 규칙
- 한 스레드에서
Thread.join()
을 호출하면, join 대상 스레드의 모든 작업은join()
이 반환된 후의 작업보다happens-before
관계를 가진다.
- 한 스레드에서
인터럽트 규칙
- 한 스레드에서
Thread.interrupt()
를 호출하는 작업이, 인터럽트된 스레드가 인터럽트를 감지하는 시점의 작업 보다happens-before
관계가 성립한다. 즉,interrupt()
호출 후, 해당 스레드의 인터럽트 상태를 확인하는 작업이happens-before
관계에 있다. 만약 이런 규칙이 없다면 인터럽트를 걸어도, 한참 나중에 인터럽트가 발생할 수 있다.
- 한 스레드에서
객체 생성 규칙
- 객체의 생성자는 객체가 완전히 생성된 후에만 다른 스레드에 의해 참조될 수 있도록 보장한다. 즉, 객체의 생성자에서 초기화된 필드는 생성자가 완료된 후 다른 스레드에서 참조될 때
happens-before
관계가 성립한다.
- 객체의 생성자는 객체가 완전히 생성된 후에만 다른 스레드에 의해 참조될 수 있도록 보장한다. 즉, 객체의 생성자에서 초기화된 필드는 생성자가 완료된 후 다른 스레드에서 참조될 때
모니터 락 규칙
- 한 스레드에서
synchronized
블록을 종료한 후, 그 모니터 락을 얻는 모든 스레드는 해당 블록 내의 모든 작업을 볼 수 있다. 예를 들어,synchronized(lock) { ... }
블록 내에서의 작업은 블록을 나가는 시점에happens-before
관계가 형성된다. 뿐만 아니라ReentrantLock
과 같은 락을 사용하는 경우에도happens-before
관계가 성립한다.
- 한 스레드에서
전이 규칙(Transitivity Rule)
- 만약 A가 B보다
happens-before
관계에 있고, B가 C보다happens-before
관계에 있다면, A는 C보다happens-before
관계에 있다.
- 만약 A가 B보다
쉽게 한 줄로 이야기하면 다음과 같다.
volatile
또는 스레드 동기화 기법(synchronized
, ReentrantLock
)을 사용하면 메모리 가시성 문제가 발생하지 않는다.
동시성 문제 (with. 출금 예제)
멀티스레드를 사용할 때 가장 주의해야 할 점은, 같은 자원(리소스)에 여러 스레드가 동시에 접근할 때 발생하는 동시성 문제이다. 참고로 여러 스레드가 접근하는 자원을 공유 자원이라 한다. 대표적인 공유 자원은 인스턴스의 필드(멤버 변수)이다.
멀티스레드를 사용할 때는 이런 공유 자원에 대한 접근을 적절하게 동기화(synchronization)해서 동시성 문제가 발생 하지 않게 방지하는 것이 중요하다.
코드
public class BankAccountV1 implements BankAccount {
private int balance;
// volatile private int balance;
public BankAccountV1(int initialBalance) {
this.balance = initialBalance;
}
@Override
public 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;
}
@Override
public int getBalance() {
return balance;
}
}
withdraw(amount)
: 검증과 출금 2가지 단계로 나누어진다.- 검증 단계: 출금액과 잔액을 비교한다. 만약 출금액이 잔액보다 많으면 문제가 있으므로 검증에 실패하고,
false
를 반환한다. - 출금 단계: 검증에 통과하면 잔액이 출금액보다 많으므로 출금할 수 있다. 잔액에서 출금액을 빼고 출금을 완료하면, 성공이라는 의미의
true
를 반환한다.
- 검증 단계: 출금액과 잔액을 비교한다. 만약 출금액이 잔액보다 많으면 문제가 있으므로 검증에 실패하고,
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());
}
static class WithdrawTask implements Runnable {
private BankAccount account;
private int amount;
public WithdrawTask(BankAccount account, int amount) {
this.account = account;
this.amount = amount;
}
@Override
public void run() {
account.withdraw(amount);
}
}
}
동시성 문제
- t1 스레드의
run()
에서withdraw()
를 실행한다. - 거의 동시에 t2 스레드의
run()
에서withdraw()
를 실행한다. - t1 스레드와 t2 스레드는 같은
BankAccount(x001)
인스턴스의withdraw()
메서드를 호출한다 - 따라서 두 스레드는 같은
BankAccount(x001)
인스턴스에 접근하고 또 x001 인스턴스에 있는 잔액(balance
) 필드도 함께 사용한다. - 두 스레드가 거의 동시에
withdraw()
를 실행하다보니 검증 단계에서 동일하게 1000원을 조회하게 되고, 출금 단계에서 동일하게 800원씩 출금하다보니1000 - 800 * 2 = -600
원이 된다.- 둘이 동시에 접근한다면 t1이 아직 잔액을 줄이지 못했기 때문에 t2는 검증 로직에서 현재 잔액을 1000원으로 확인한다.
- t1, t2가 약간의 차이로 순서대로 실행됐을 경우(시분할 기법에 의한 매우 약간의 차이로) → 순차적으로 800원씩 출금되기에 최종 잔액은
-600원
- t1, t2가 서로 다른 CPU 코어에서 동시에 실행됐을 경우 → 동시에 값을 조회 및 변경하기에 하나의 연산이 씹혀 최종 잔액은
200원
balance = balance - amount
라는 연산은 한줄의 코드이지만 내부적으로 값을 확인하고, 계산하고, 이를 다시 변수에 저장하는 3가지 동작으로 이루어진다. 이들은 하나의 원자적 연산으로 묶여있는 것이 아니기 때문에 두 스레드가 동시에 이 코드를 실행한다면 동시성 문제가 발생한다.
balance 값에 volatile을 도입해도 문제는 똑같이 발생한다. 왜냐하면, volatile은 한 스레드가 값을 변경했을 때, 다른 스레드에서 변경된 값을 즉시 볼 수 있게 하는 메모리의 가시성 문제를 해결할 뿐이다. 위 문제에서는 변경하기 전에 값을 확인하고, 이후 변경을 하는 2가지의 단계로 나뉘어져 있기 때문에 문제가되는 것이다.
임계 영역
이런 문제가 발생한 근본 원인은 여러 스레드가 함께 사용하는 공유 자원을 여러 단계로 나누어 사용하기 때문이다.
- 검증 단계: 잔액(
balance
)이 출금액(amount
)보다 많은지 확인한다. - 출금 단계: 잔액(
balance
)을 출금액(amount
)만큼 줄인다.
출금() {
1. 검증 단계: 잔액(balance) 확인
2. 출금 단계: 잔액(balance) 감소
}
이 로직에는 하나의 큰 가정이 있다.
스레드 하나의 관점에서 출금()
을 보면 1. 검증 단계에서 확인한 잔액(balance
) 1000원은 2. 출금 단계에서 계산을 끝마칠 때 까지 같은 1000원으로 유지되어야 한다. 그래야 검증 단계에서 확인한 금액으로, 출금 단계에서 정확한 잔액을 계산할 수 있다.
즉, 내가 사용하는 값이 검증 ~ 출금 단계 중간에 변경되지 않을 것이라는 가정이 있다.
그런데 만약 중간에 다른 스레드가 잔액의 값을 변경한다면, 큰 혼란이 발생한다.
공유 자원
잔액은 여러 스레드에서 함께 사용하는 공유 자원이다. 따라서 출금 로직을 수행하는 중간에 다른 스레드에서 얼마든지 값을 변경할 수 있다.
그렇다면 출금()
이라는 메서드를 한 번에 하나의 스레드만 실행할 수 있게 제한한다면 어떻게 될까?
→ 검증과 계산 이 두 단계는 한 번에 하나의 스레드만 실행하게 될 것이고, 잔액이 중간에 변하지 않고 안전하게 계산을 수행할 수 있게 된다.
임계 영역(critical section)
- 여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 중요한 코드 부분
- 여러 스레드가 동시에 접근해서는 안 되는 공유 자원을 접근하거나 수정하는 부분
→ 출금()
로직이 바로 임계 영역이다.
이런 임계 영역은 한 번에 하나의 스레드만 접근할 수 있도록 안전하게 보호해야 한다.
→ 자바에서는 이를 위한 synchronized
키워드를 제공하여 아주 간단하게 임계 영역을 보호할 수 있다.
synchronized
synchronized
메서드
public class BankAccountV2 implements BankAccount {
private int balance;
public BankAccountV2(int initialBalance) {
this.balance = initialBalance;
}
@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;
}
@Override
public synchronized int getBalance() { // 한 번에 하나의 스레드만 실행
return balance;
}
}
synchronized
분석
- 모든 객체(인스턴스)는 내부에 자신만의 락(lock)을 가지고 있다.
모니터 락(monitor lock)
이라고도 부른다.- 객체 내부에 있고 우리가 확인하기는 어렵다.
- 스레드가
synchronized
키워드가 있는 메서드에 진입하려면 반드시 해당 인스턴스의 락이 있어야 한다- 여기서는
BankAccount(x001)
인스턴스의synchronized withdraw()
메서드를 호출하므로 이 인스턴스의 락이 필요하다.
- 여기서는
- t1 스레드가
withdraw()
를 먼저 실행했다고 가정하면, t1은BankAccount(x001)
인스턴스에 있는 락을 획득하게 되고, 이후 t2가withdraw()
메서드 호출 시도 시, 락 획득을 시도하고, 해당 인스턴스의 락이 없어서 접근할 수 없다. → t2는BLOCKED
상태로 대기한다.- t2 스레드는
RUNNABLE → BLOCKED
상태로 변하고, 락을 획득할 때까지 무한정 대기한다.
- t2 스레드는
- 이후 t1이 모든 로직을 수행하면 락을 반납하고, 락이 반납되면 락 획득을 대기하는 스레드는 자동으로 락을 획득한다. t2가 로직을 수행한다.
락을 획득하는 순서는 보장되지 않는다. 락을 기다리는 수 많은 스레드 중에 하나의 스레드만 락을 획득하고, 락을 획득한 스레드만 BLOCKED → RUNNABLE 상태가 된다. 어떤 순서로 락을 획득하는지는 자바 표준에 정의되어 있지 않다.
volatile를 사용하지 않아도 synchronized 안에서 접근하는 변수의 메모리 가시성 문제는 해결된다. ← JMM의 happens-before 참고
synchronized
코드 블럭
synchronized
를 사용하면 한 번에 하나의 스레드만 실행할 수 있기 때문에, 동시에 실행하지 못해 성능이 저하될 수 있다는 단점이 있다. 따라서 synchronized
키워드는 꼭! 필요한 곳으로 한정해서 설정해야 한다. → 최대한 병목이 일어나는 구간을 짧게 가져가기 위해서!
→ 진짜 임계 영역. 즉, 진짜로 병목이 일어나는 부분(코드 블럭)에만 synchronized
을 걸자
public class BankAccountV3 implements BankAccount {
private int balance;
public BankAccountV3(int initialBalance) {
this.balance = initialBalance;
}
@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;
}
@Override
public synchronized int getBalance() {
return balance;
}
}
synchronized (this) {}
: 안전한 임계 영역을 코드 블럭으로 지정한다.- 덕분에 아주 약간이지만 여러 스레드가 동시에 수행되는 부분을 더 늘려서 전체적으로 성능을 더 향상 시킬 수 있었다.
synchronized
의 장단점
- 장점
- 프로그래밍 언어에 문법으로 제공
- 아주 편리한 사용
- 자동 잠금 해제:
synchronized
메서드나 블록이 완료되면 자동으로 락을 대기중인 다른 스레드의 잠금이 해제된다. 개발자가 직접 특정 스레드를 깨우도록 관리해야 한다면, 매우 어렵고 번거로울 것이다.
- 단점
- 무한 대기:
BLOCKED
상태의 스레드는 락이 풀릴 때 까지 무한 대기한다.- 특정 시간까지만 대기하는 타임아웃 X
- 중간에 인터럽트 X
- 공정성: 락이 돌아왔을 때
BLOCKED
상태의 여러 스레드 중에 어떤 스레드가 락을 획득할 지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜기간 락을 획득하지 못할 수 있다. → 스레드 기아 문제 발생 가능
- 무한 대기:
→ 웹 애플리케이션의 경우 고객이 어떤 요청을 했는데, 화면에 계속 요청 중만 뜨고, 응답을 못 받는 것
→ 이러한 synchronized
의 치명적 단점을 해결하기 위해 자바 1.5부터 java.util.concurrent
라는 동시성 문제 해결을 위한 패키지가 추가된다.
이 라이브러리에는 수 많은 클래스가 있지만, 가장 기본이 되는 LockSupport
에 대해 먼저 알아보자.
이 클래스를 통해 synchronized
의 가장 큰 단점인 무한 대기 문제를 해결할 수 있다.
LockSupport
LockSupport 기능
LockSupport
는 스레드를 WAITING
(≠ BLOCKED
) 상태로 변경한다. (누가 꺠워주기 전까지는 계속 대기 & CPU 실행 스케줄링에 들어가지 않는다.)
- 대표적인 기능
park()
: 스레드를WAITING
상태로 변경parkNanos(nanos)
: 스레드를 나노초 동안TIMED_WAITING
상태로 변경unpark(thread)
:WAITING
상태의 대상 스레드를RUNNABLE
상태로 변경
인터럽트 사용
WAITING
상태의 스레드에 인터럽트가 발생하면 WAITING
상태에서 RUNNABLE
상태로 변하면서 깨어난다.
⇒ Lock.unpark(thread)
또는 thread.interrupt()
를 통해서 WAITING
상태의 스레드를 깨우고 실행을 제어할 수 있다.
BLOCKED vs WAITING
WAITING
상태에 특정 시간까지만 대기하는 기능이 포함된 것이 TIMED_WAITING
이다. 여기서는 둘을 묶어서 WAITING
상태라 표현하겠다.
- 인터럽트
BLOCKED
상태는 인터럽트가 걸려도 대기 상태를 빠져나오지 못한다. (여전히BLOCKED
)WAITING
,TIMED_WAITING
상태는 인터럽트가 걸리면 대기 상태를 빠져나온다. 그래서RUNNABLE
상태로 변한다.
- 용도
BLOCKED
상태는 자바의synchronized
에서 락을 획득하기 위해 대기할 때 사용된다.WAITING
,TIMED_WAITING
상태는 스레드가 특정 조건이나 시간 동안 대기할 때 발생하는 상태이다.WAITING
상태는 다양한 상황에서 사용된다. 예를 들어,Thread.join()
,LockSupport.park()
,Object.wait()
와 같은 메서드 호출 시WAITING
상태가 된다.TIMED_WAITING
상태는Thread.sleep(ms)
,Object.wait(long timeout)
,Thread.join(log millis)
,LockSupport.parkNanos(ns)
등과 같은 시간 제한이 있는 대기 메서드를 호출할 때 발생한다.
대기(WAITING) 상태와 시간 대기 상태(TIMED_WAITING)는 서로 짝이 있다.
Thread.join()
,Thread.join(long millis)
Thread.park()
,Thread.parkNanos(long millis)
Object.wait()
,Object.wait(long timeout)
BLOCKED
, WAITING
, TIMED_WAITING
상태 모두 스레드가 대기하며, 실행 스케줄링에 들어가지 않기 때문에, CPU 입장에서 보면 실행하지 않는 비슷한 상태이다.
BLOCKED
상태는synchronized
에서만 사용하는 특별한 대기 상태라고 이해하면 된다.WAITING
,TIMED_WAITING
상태는 범용적으로 활용할 수 있는 대기 상태라고 이해하면 된다.
LockSupport 정리
LockSupport
를 사용하면 스레드를 WAITING
, TIMED_WAITING
상태로 변경할 수 있고, 또 인터럽트를 받아서 스레드를 깨울 수도 있다. 이런 기능들을 잘 활용하면 synchronized
의 단점인 무한 대기 문제를 해결할 수 있다
- synchronized 단점
- 무한 대기:
BLOCKED
상태의 스레드는 락이 풀릴 때 까지 무한 대기한다.- 특정 시간까지만 대기하는 타임아웃X →
parkNanos()
를 사용하면 특정 시간까지만 대기할 수 있음 - 중간에 인터럽트X →
park()
,parkNanos()
는 인터럽트를 걸 수 있음
- 특정 시간까지만 대기하는 타임아웃X →
- 무한 대기:
하지만, 이런 기능들을 직접 개발해서 사용하기에는 너무 어렵다.. synchronized
키워드로 다 해결할 수 있었던 것에 비해 너무 저수준 레벨이기 때문이다.
→ Lock
인터페이스와 ReentrantLock
이라는 구현체로 이런 기능들을 이미 다 구현해두었다!
ReentrantLock
Lock 인터페이스
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
void lock()
- 락을 획득한다. 다른 스레드가 이미 획득했다면, 락이 풀릴 때까지 현재 스레드는
WAITING
상태가 된다. - 이 메서드는 인터럽트에 응답하지 않는다
*WAITING
상태로 되는데 어떻게 인터럽트에 응하지 않는것일까?*lock()
을 호출해서 대기 중인 스레드에 인터럽트가 발생하면 순간 대기 상태를 빠져나오는 것은 맞다. 그래서 아주 짧지만WAITING → RUNNABLE
이 된다.- 그런데
lock()
메서드 안에서 해당 스레드를 다시WAITING
상태로 강제로 변경해버린다. 이를 통해 인터럽트를 무시하는 것이다.
- 락을 획득한다. 다른 스레드가 이미 획득했다면, 락이 풀릴 때까지 현재 스레드는
여기서 사용하는 락은 객체 내부의 모니터 락이 아니다! Lock 인터페이스와 ReentrantLock이 제공하는 기능이다!
모니터 락과BLOCKED
상태는synchronized
에서만 사용된다!
void lockInterruptibly()
- 락 획득을 시도하되, 다른 스레드가 인터럽트할 수 있도록 한다. 만약 다른 스레드가 이미 락을 획득했다면, 현재 스레드는 락을 획득할 때까지
WAITING
상태가 된다. - 대기 중에 인터럽트가 발생하면
InterruptedException
이 발생하며 락 획득을 포기한다.
- 락 획득을 시도하되, 다른 스레드가 인터럽트할 수 있도록 한다. 만약 다른 스레드가 이미 락을 획득했다면, 현재 스레드는 락을 획득할 때까지
boolean tryLock()
- 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 다른 스레드가 이미 락을 획득했다면
false
를 반환하고, 그렇지 않으면 락을 획득하고true
를 반환한다.
- 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 다른 스레드가 이미 락을 획득했다면
boolean tryLock(long time, TimeUnit unit)
- 주어진 시간동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면
true
를 반환한다. - 이 메서드는 대기 중 인터럽트가 발생하면
InterruptedException
이 발생하며 락 획득을 포기한다.
- 주어진 시간동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면
void unlock()
- 락을 해제한다. 락을 해제하면 락 획득을 대기 중인 스레드 중 하나가 락을 획득할 수 있게 된다.
- 락을 획득한 스레드가 호출해야 하며, 그렇지 않으면
IllegalMonitorStateException
이 발생할 수 있다.
Condition newCondition()
Condition
객체를 생성하여 반환한다.Condition
객체는 락과 결합되어 사용되며, 스레드가 특정 조건을 기다리거나 신호를 받을 수 있도록 한다. 이는Object
클래스의wait
,notify
,notifyAll
메서드와 유사한 역할을 한다.
공정성
Lock
인터페이스의 대표적인 구현체로 ReentrantLock
이 있는데, 이 클래스는 스레드가 공정하게 락을 얻을 수 있는 모드를 제공한다. ReentrantLock
은 공정성(fairness
) 모드와 비공정(non-fair
)모드로 설정할 수 있으며, 이 두 모드는 락을 획득하는 방식에 차이가 있다.
- 비공정 모드(Non-fair mode)
Lock nonFairLock = new ReentrantLock();
- 락을 먼저 요청한 스레드가 락을 먼저 획득한다는 보장이 없다.
- 락을 풀었을 때, 대기 중인 스레드 중 아무나 락을 획득할 수 있다.
- 특징
- 성능 우선: 락을 획득하는 속도가 빠르다
- 선점 가능: 새로운 스레드가 기존 대기 스레드보다 먼저 락을 획득할 수 있다
- 기아 현상 가능성: 특정 스레드가 계속해서 락을 획득하지 못할 수 있다
- 공정 모드(Fair mode)
Lock fairLock = new ReentrantLock(true);
- 락을 요청한 순서대로 스레드가 락을 획득할 수 있게 한다.
- 이는 먼저 대기한 스레드가 먼저 락을 획득하게 되어 스레드 간의 공정성을 보장한다. 하지만, 이로 인해 성능이 저하될 수 있다.
- 특징
- 공정성 보장: 대기 큐에서 먼저 대기한 스레드가 락을 먼저 획득한다.
- 기아 현상 방지: 모든 스레드가 언젠가 락을 획득할 수 있게 보장한다.
- 성능 저하: 락을 획득하는 속도가 느려질 수 있다.
⇒ Lock
인터페이스와 ReentrantLock
구현체를 사용하면 synchronized
단점인 무한 대기와 공정성 문제를 모두 해결할 수 있다.
ReentrantLock 활용
public class BankAccountV4 implements BankAccount {
private int balance;
private final Lock lock = new ReentrantLock();
public BankAccountV4(int initialBalance) {
this.balance = initialBalance;
}
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
lock.lock(); // ReentrantLock 이용하여 lock 걸기 (lock이 없는 스레드는 이 코드 만나면 WAITING 상태)
try { // lock을 걸고 나면 무슨 일이 있든 lock을 해제해주어야 하므로 try..finally 사용
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
if (balance < amount) {
log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
return false;
}
log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
sleep(1000); // 출금에 걸리는 시간으로 가정
balance = balance - amount;
log("[출금 완료] 출금액: " + amount + ", 변경 잔액: " + balance);
} finally {
lock.unlock();
}
log("거래 종료");
return true;
}
@Override
public int getBalance() {
lock.lock();
try {
return balance;
} finally {
lock.unlock();
}
}
}
synchronized(this)
대신에lock.lock()
을 사용해서 락을 건다.- 임계 영역이 끝나면 반드시! 락을 반납해야 한다. 그렇지 않으면 대기하는 스레드가 락을 얻지 못한다.
- 따라서
lock.unlock()
은 반드시finally
블럭에 작성해야한다. 이렇게 하면 검증에 실패해서 중간 에return
을 호출해도 또는 중간에 예상치 못한 예외가 발생해도lock.unlock()
이 반드시 호출된다.
- 따라서
ReentrantLock 대기 중단
ReentrantLock
을 사용하면 락을 무한 대기하지 않고, 중간에 빠져나오는 것이 가능하다. 심지어 락을 얻을 수 없다면 기다리지 않고 즉시 빠져나오는 것도 가능하다
boolean tryLock()
- 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 만약 다른 스레드가 이미 락을 획득했다면
false
를 반환하고, 그렇지 않으면 락을 획득하고true
를 반환한다.
- 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 만약 다른 스레드가 이미 락을 획득했다면
boolean tryLock(long time, TimeUnit unit)
- 주어진 시간 동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면
true
를 반환한다. 주어진 시간이 지나도 락을 획득하지 못한 경우false
를 반환한다. 이 메서드는 대기 중 인터럽트가 발생하면InterruptedException
이 발생하며 락 획득을 포기한다
- 주어진 시간 동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면
tryLock 예시
public class BankAccountV5 implements BankAccount {
private int balance;
private final Lock lock = new ReentrantLock();
public BankAccountV5(int initialBalance) {
this.balance = initialBalance;
}
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
if (!lock.tryLock()) { // 락을 획득하지 못한 상태라면
log("[진입 실패] 이미 처리 중인 작업이 있습니다.");
return false; // 기다리지 않고 바로 종료. 임계 영역 자체를 접근하지 않음.
}
try { // lock을 걸고 나면 무슨 일이 있든 lock을 해제해주어야 하므로 try..finally 사용
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
if (balance < amount) {
log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
return false;
}
log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
sleep(1000); // 출금에 걸리는 시간으로 가정
balance = balance - amount;
log("[출금 완료] 출금액: " + amount + ", 변경 잔액: " + balance);
} finally {
lock.unlock();
}
log("거래 종료");
return true;
}
@Override
public int getBalance() {
lock.lock();
try {
return balance;
} finally {
lock.unlock();
}
}
}
lock.tryLock()
을 통해 락을 획득할 수 없으면 바로 포기하고 대기하지 않는다.
tryLock(시간) 예시
public class BankAccountV6 implements BankAccount {
private int balance;
private final Lock lock = new ReentrantLock();
public BankAccountV6(int initialBalance) {
this.balance = initialBalance;
}
@Override
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
try {
if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) { // 락을 획득하지 못한 상태라면
log("[진입 실패] 이미 처리 중인 작업이 있습니다.");
return false; // 기다리지 않고 바로 종료. 임계 영역 자체를 접근하지 않음.
}
} catch (InterruptedException e) { // tryLock(time)은 인터럽트 예외를 받을 수 있다
throw new RuntimeException(e);
}
try { // lock을 걸고 나면 무슨 일이 있든 lock을 해제해주어야 하므로 try..finally 사용
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
if (balance < amount) {
log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
return false;
}
log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
sleep(1000); // 출금에 걸리는 시간으로 가정
balance = balance - amount;
log("[출금 완료] 출금액: " + amount + ", 변경 잔액: " + balance);
} finally {
lock.unlock();
}
log("거래 종료");
return true;
}
@Override
public int getBalance() {
lock.lock();
try {
return balance;
} finally {
lock.unlock();
}
}
}
- 스레드의 상태는 대기하는 동안
TIMED_WAITING
이 되고, 대기 상태를 빠져나오면RUNNABLE
이 된다. - 인터럽트를 받을 수 있다.
'Java' 카테고리의 다른 글
[Java] CAS - 동기화와 원자적 연산 (1) | 2025.01.06 |
---|---|
[Java] 스레드 - 생산자 소비자 문제 (0) | 2025.01.06 |
[Java] 스레드의 생성 / 실행 / 제어 / 생명 주기 (0) | 2025.01.06 |
[Java] Optional (0) | 2025.01.05 |
[Java] Stream API (0) | 2025.01.05 |