Java에서 다중 스레드 환경에서 공유 데이터에 대한 안전한 접근을 하는 방법, 즉 스레드 간 동기화 방식들에 대해 알아보고자 한다.
이번엔 대표적 예시인 synchronized 키워드에 대해 공부해보자.
Java에서의 스레드 개념은 아래 포스팅을 참고하도록 하자.
synchronized 키워드
synchronized 키워드를 사용해 메서드 또는 코드 블록을 임계 영역(critical section)으로 지정할 수 있다.
이렇게 지정된 영역은 스레드 간 공유 자원에 대한 접근을 동기화한다.
스레드는 해당 영역에 접근하기 위해 모니터 락(monitor lock)을 획득해야 한다. 모니터 락은 고유 락 또는 모니터라고도 불리며, 뮤텍스(mutex)로 동작한다.
다른 스레드가 이미 모니터 락을 보유 중인 경우, 해당 스레드는 락이 해제될 때까지 대기해야 한다.
락은 메서드 단위로 설정할 수도 있고, 특정 코드 블록에만 설정할 수도 있다.
메서드 단위로 락을 설정하는 경우, 해당 메서드 전체가 임계영역으로 지정된다.
이는 해당 메서드를 호출하는 객체의 인스턴스나 클래스에 대한 동기화를 제공한다.
public synchronized void synchronizedMethod() {
/***/
}
특정 코드 블럭에만 락을 설정하여 임계 영역을 줄이면 스레드 안전성을 유지하면서 동시성을 향상시킬 수 있다.
공유 자원에 대한 접근이 필요한 부분만 동기화되어 다른 작업들과 병행 실행이 가능해진다.
Object lockObject = new Object();
synchronized (lockObject) {
/***/
}
복잡한 계산 작업, 네트워크 작업, 사용자 입출력 작업 등 시간이 오래 걸리는 작업은 가능한 임계 영역에서 제외하는 것이 좋다.
단, synchronized 블록 범위를 너무 작게 줄여 단일 연산으로 처리해야하는 작업을 분리하지 않도록 주의해야한다.
또한, 락을 얻고 놓는 작업만으로도 어느 정도의 오버헤드가 발생하므로, 단일 연산 구조에 문제가 생기지 않더라도 너무 잘게 쪼개는 것은 피하는 것이 좋다.
인스턴스 변수 vs 스태틱 변수
인스턴스 변수와 스태틱 변수에 락을 설정하는 경우 서로 다르게 동작한다.
인스턴스 변수는 해당 인스턴스 단위로 락이 걸리고, 스태틱 변수는 클래스 단위로 락이 걸린다.
private final Object instanceLock = new Object();
private static final Object staticLock = new Object();
public void instanceMethod() {
synchronized (instanceLock) {
/***/
}
}
public void staticMethod() {
synchronized (staticLock) {
/***/
}
}
인스턴스 메서드 vs 스태틱 메서드
인스턴스 메서드와 스태틱 메서드 역시 다르게 동작한다.
인스턴스 메서드
인스턴스 메서드는 해당 인스턴스에 락을 설정한다.
이는 this 객체를 기반으로 한다고 볼 수 있다. (아래 두 메서드는 동일한 역할을 한다.)
public synchronized void foo1() {
/***/
}
public synchronized void foo2() {
synchronized(this) {
/***/
}
}
스태틱 메서드
스태틱 메서드는 해당 클래스에 락을 설정한다.
아래 두 메서드는 동일한 역할을 한다.
public static synchronized void foo1() {
/***/
}
public static void foo2() {
synchronized(TargetClass.class) {
/***/
}
}
인스턴스 메서드와 스태틱 메서드는 서로 다른 락을 설정하기 때문에 충돌하지 않는다.
wait(), notify()
wait()과 notify()는 synchronized 블럭 또는 메서드 내에서만 호출할 수 있는 특별한 메서드이다.
wait()을 호출하는 스레드는 해당 객체의 모니터 락을 해제하고 WAIT 상태로 전환된다.
해당 스레드는 notify()나 notifyAll()이 호출될 때까지 WAIT 상태로 대기한다.
notify()를 호출하면, 해당 객체의 대기열에서 하나의 스레드를 임의로 선택하여 깨운다.
notifyAll()을 호출하면, 대기 중인 모든 스레드들을 깨운다.
깨어난 스레드들은 해당 객체의 모니터 락을 획득하기 위해 경쟁한다. 락을 성공적으로 획득하면 작업을 계속 진행하고, 그렇지 못하면 BLOCKED 상태로 유지된다.
notify()와 기아 현상
notify()는 대기 중인 스레드 중 하나만 깨운다. notify()를 반복적으로 사용할 경우, JVM에 의해 항상 동일한 스레드가 선택될 가능성이 있어 기아 현상(starvation)이 발생할 수 있다.
그에 비해, notifyAll()은 모든 대기 중인 스레드가 깨우므로 모든 스레드에게 균등한 기회를 제공한다.
notifyAll()이 기아 현상을 완전히 해결하는 것은 아니지만, notify()에 비해 기아 현상이 발생할 확률을 줄일 수 있다.
특징
재진입 가능성(reentrancy)
synchronized 영역 내에서 자신이 이미 보유한 락을 다시 획득할 수 있는 기능인 재진입을 지원한다. 즉, 자신이 가진 잠금을 다시 잠그려 할 때 허용한다.
이로 인해 같은 스레드 내에서 재귀적인 호출이나 상속 관계에서도 안전하게 동작한다.
구조적인 락(structed lock)
락이 스택처럼 쌓인다. A획득 → B획득 → B해제 → A해제 이런 식으로 동작한다.
가시성(visibility)
스레드들이 순서대로 실행되기 때문에, 이전의 스레드가 쓴 값을 읽을 수 있다.
데드락의 위험성
synchronized 키워드를 무분별하게 사용하다간, 데드락이 발생하기 쉽다.
따라서 synchronized block 안에 다른 synchronized block을 둘 때는 신중해지자.
데드락의 네 가지 필요조건
- 상호 배제: 한 번에 하나의 스레드만 락을 소유할 수 있다.
- 점유 및 대기: 스레드는 이미 할당된 리소스를 보유하면서 추가 리소스를 대기한다
- 비선점: 리소스를 한 스레드가 소유하고 있으면, 다른 스레드가 그 리소스를 강제로 가져갈 수 없다.
- 순환 대기: 두 개 이상의 스레드가 서로를 기다리는 사이클이 형성된다.
데드락 예시
아래는 synchronized 블럭에서 데드락이 발생하는 예시 코드이다.
public class DeadlockExample {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: Lock 1 획득");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 1: Lock 2 기다림");
synchronized (lock2) {
System.out.println("Thread 1: Lock 2 획득");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2: Lock 2 획득");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 2: Lock 1 기다림");
synchronized (lock1) {
System.out.println("Thread 2: Lock 1 획득");
}
}
});
thread1.start();
thread2.start();
}
}
참고 자료
자바 병렬 프로그래밍
자바의 정석 (3rd edition)
'프로그래밍' 카테고리의 다른 글
[Spring] TaskScheduler를 활용해 런타임에 동적으로 작업 예약하기 (7) | 2023.10.08 |
---|---|
Spring Event란? (0) | 2023.09.17 |
[Java] Thread란? (0) | 2023.09.13 |
Facade 객체를 활용해 트랜잭션에서 외부 API 통신 분리하기 (4) | 2023.09.11 |
[오픈소스] 톰캣에 컨트리뷰트하기 🐱 (13) | 2023.09.05 |