Java 멀티스레드 동기화
Java 멀티스레드 환경에서 발생하는 데이터 경쟁 조건과 동기화 문제를 해결하기 위한 방법들을 알아봅니다.
synchronized,volatile,Lock인터페이스 등을 사용하여 안전하게 공유 자원에 접근하는 방법을 살펴봅니다.
왜 필요한가?
멀티스레드 프로그래밍은 여러 스레드가 동시에 실행되어 애플리케이션의 성능을 향상시키는 데 유용하지만, 동시에 여러 스레드가 공유 자원에 접근할 때 데이터 불일치, 경쟁 조건, 데드락과 같은 심각한 문제가 발생할 수 있습니다. 이러한 문제를 해결하기 위해 Java는 다양한 동기화 메커니즘을 제공합니다.
① 데이터 경쟁 (Race Condition)
두 개 이상의 스레드가 동시에 동일한 공유 자원에 접근하여 하나 이상의 스레드가 데이터를 변경하려고 할 때 데이터 경쟁이 발생합니다. 이로 인해 예상치 못한 결과가 발생할 수 있습니다. 예를 들어, 은행 계좌 잔액을 업데이트하는 상황을 생각해 봅시다.
java class Account { private int balance = 1000;
public int getBalance() {
return balance;
}
public void withdraw(int amount) {
balance -= amount;
}
}
public class RaceConditionExample { public static void main(String[] args) throws InterruptedException { Account account = new Account();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
int currentBalance = account.getBalance();
account.withdraw(1);
int newBalance = account.getBalance();
if (currentBalance - 1 != newBalance) {
System.out.println("Race condition detected!");
}
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("Final balance: " + account.getBalance());
}
}
위 코드에서 withdraw 메서드가 동기화되지 않았기 때문에 두 스레드가 동시에 잔액을 변경하려고 시도하여 데이터 경쟁이 발생할 수 있습니다.
② 데드락 (Deadlock)
두 개 이상의 스레드가 서로가 점유하고 있는 자원을 기다리면서 무한정 멈춰있는 상태를 데드락이라고 합니다. 데드락은 멀티스레드 환경에서 발생할 수 있는 심각한 문제 중 하나입니다.
java 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: Holding lock1...");
try { Thread.sleep(10); } catch (InterruptedException e) {}
System.out.println("Thread 1: Waiting for lock2...");
synchronized (lock2) {
System.out.println("Thread 1: Acquired lock2.");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2: Holding lock2...");
try { Thread.sleep(10); } catch (InterruptedException e) {}
System.out.println("Thread 2: Waiting for lock1...");
synchronized (lock1) {
System.out.println("Thread 2: Acquired lock1.");
}
}
});
thread1.start();
thread2.start();
}
}
위 코드에서 thread1은 lock1을 획득하고 lock2를 기다리고, thread2는 lock2를 획득하고 lock1을 기다리기 때문에 데드락이 발생할 수 있습니다.
핵심 개념
| 개념 | 설명 | 사용법 |
|---|---|---|
synchronized |
메서드 또는 코드 블록을 동기화하여 한 번에 하나의 스레드만 접근하도록 합니다. | synchronized (object) { // 코드 } |
volatile |
변수의 값을 항상 메인 메모리에서 읽고 쓰도록 강제하여 가시성을 확보합니다. | private volatile int count; |
Lock 인터페이스 |
ReentrantLock 등의 구현체를 통해 명시적인 잠금 및 해제를 제공합니다. |
Lock lock = new ReentrantLock(); lock.lock(); try { // 코드 } finally { lock.unlock(); } |
[Thread 1] ── synchronized ──→ [Shared Resource] ←── synchronized ────┘ [Thread 2] ── Waiting ──────→ [Shared Resource]
주요 동기화 방법
① synchronized 키워드
synchronized 키워드는 Java에서 가장 기본적인 동기화 방법입니다. 메서드 또는 코드 블록에 적용하여 한 번에 하나의 스레드만 해당 영역에 접근할 수 있도록 합니다.
java public class SynchronizedExample { private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
SynchronizedExample example = new SynchronizedExample();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("Count: " + example.getCount());
}
}
increment 메서드에 synchronized 키워드를 사용하여 스레드 안전성을 보장합니다.
② volatile 키워드
volatile 키워드는 변수의 가시성을 보장하는 데 사용됩니다. volatile로 선언된 변수는 항상 메인 메모리에서 읽고 쓰기 때문에 스레드 간에 최신 값을 공유할 수 있습니다.
java public class VolatileExample { private volatile boolean running = true;
public void stop() {
running = false;
}
public void run() {
while (running) {
System.out.println("Running...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
}
System.out.println("Stopped.");
}
public static void main(String[] args) throws InterruptedException {
VolatileExample example = new VolatileExample();
Thread thread = new Thread(example::run);
thread.start();
Thread.sleep(1000);
example.stop();
}
}
running 변수를 volatile로 선언하여 메인 스레드에서 stop() 메서드를 호출했을 때 작업 스레드가 변경된 값을 즉시 확인할 수 있도록 합니다.
③ Lock 인터페이스
Lock 인터페이스는 synchronized 키워드보다 더 유연하고 강력한 동기화 메커니즘을 제공합니다. ReentrantLock은 Lock 인터페이스의 대표적인 구현체입니다.
java import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock;
public class LockExample { private int count = 0; private Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
LockExample example = new LockExample();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("Count: " + example.getCount());
}
}
ReentrantLock을 사용하여 increment 메서드를 동기화하고, try-finally 블록에서 unlock() 메서드를 호출하여 잠금을 해제합니다.
마무리
Java 멀티스레드 환경에서 동기화는 데이터의 일관성과 안정성을 유지하는 데 필수적입니다. synchronized, volatile, Lock 인터페이스 등 다양한 동기화 메커니즘을 이해하고 적절하게 활용하여 안전하고 효율적인 멀티스레드 애플리케이션을 개발할 수 있습니다. 개인적으로 멀티스레드 프로그래밍은 어렵지만, 꼼꼼하게 동기화 처리를 하면 성능 향상에 큰 도움이 되는 것 같아요. 앞으로 더 다양한 동기화 기법을 공부해야겠어요.