-
[23.12.31] Java Volatile vs Synchronized블로그 번역 2023. 12. 31. 14:41반응형
이번 주제는 자바 동시성 관련하여 Volatile과 Synchronized 키워드에 관한 주제입니다.
멀티 스레드의 문제점
자바에서는 동시성 관련 문제가 중요하다.
왜냐하면 적절하게 스레드를 다루지 못하면 큰 사고로 이어질 수 있기 때문이다.
Race Condition 문제
두 개 이상의 스레드가 자원을 동시에 공유하는 경우 타이밍에 따라서 결과가 달라질 수 있는 문제.
Data Corruption 문제
Synchronized 키워드를 사용하지 않는 자원이 수정 될 경우 데이터 일관성이 깨지는 문제.
Deadlock 문제
두 개 이상의 스레드가 Synchronized 키워드나 다른 상황에 의해서 서로 원하는 리소스를 점유하고 있어 다음 리소스를 얻지 못하는 문제.
위 문제들이 발생하면서 "불안정한 스레드 구조"가 되어서 해결이 어려운 문제가 된다.
공유 자원
스레드 공유 자원은 각 스레드가 실행 될 때 CPU Cache라는 곳으로 복사하여 사용한다.
그래서 그 사이 다른 스레드가 수정하는 경우 값이 변경되지 않는다.
위 상황을 구현해보자.
Switcher 라는 클래스 내부 필드를 다른 스레드들이 조작하여 변경할 수 있다고 가정하자.
Thread1, Thread2를 동시에 실행시키면 결과는 어떻게 될까? 계속 동일할까?
아니다. 결과는 계속 달라질 것 이다.
public class Switcher { private boolean isEnabled = false; public void enable() { isEnabled = true; } public boolean isEnabled() { return isEnabled; } }
public class Runner { public static void main(String[] args) { Switcher switcher = new Switcher(); // Thread 1 - Updates the configuration Thread thread1 = new Thread(() -> { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } switcher.enable(); System.out.println("Thread 1: Switch enabled"); }); // Thread 2 - Checks the configuration Thread thread2 = new Thread(() -> { try { Thread.sleep(11); } catch (InterruptedException e) { e.printStackTrace(); } if (switcher.isEnabled()) { System.out.println("Thread 2: Switch is enabled"); } else { System.out.println("Thread 2: Switch is not enabled"); } }); thread1.start(); thread2.start(); } }
특이한 결과가 아래와 같이 발생할 것이다.
분명 스레드 1이 먼저 시작되었고 enable 전 sleep 시간도 더 짧은데 스레드 2에서 읽히지 않는 현상이다.
한 10번 중 1번 꼴로 아래 상황이 발생했다.
Thread 1: Switch enabled Thread 2: Switch is not enabled
이것은 위 CPU Cache 값을 읽어오기 때문에 발생하는 문제이다.
순서가 변경되는 Race Condition 문제도 발생하지만 그 문제는 추후 얘기하겠다.
이것을 해결하기 위해서 isEnable 변수에 volatile 키워드를 붙이고 테스트해보자.
이 키워드는 JVM이 각 스레드 로컬 메모리에 Cache하지 않는다.
대신 JVM은 항상 메인 메모리에 읽고 쓰기를 한다.
private volatile boolean isEnabled = false;
Volatile 키워드는 일반적인 사용예시를 보자.
1. 상태 또는 플래그 변수
private volatile boolean shouldStop = false; public void stop() { shouldStop = true; } public void run() { while (!shouldStop) { // Perform some work } }
2. 싱글톤 패턴
private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; }
3. 설정 쓰고 읽는 곳
private volatile int maxConnections = 10; public void setMaxConnections(int value) { maxConnections = value; } public int getMaxConnections() { return maxConnections; }
4. 공유 자원 모니터링
private volatile ServerState serverState = ServerState.STARTED; public void stopServer() { serverState = ServerState.STOPPED; } public ServerState getServerState() { return serverState; }
Race Condition 문제
Race Condition 문제 적절한 Synchronized 키워드가 없을 때 발생한다.
Counter 예시를 보자.
만약 스레드 1과 스레드 2가 동시에 공유 자원에 접근하면 2가 증가되어야하지만 1이 증가되는 경우가 발생한다.
그림으로 보면 아래와 같다.
Bank 송금을 예시로 보자.
User1, User2가 동시에 현금을 인출하는 과정을 보자.
public class BankSystem { private int balance; public BankSystem(int balance) { this.balance = balance; } public void sendMoney(int money) { if (balance >= money) { // execute main logic try { Thread.sleep(50); } catch (InterruptedException ignored) { } balance -= money; System.out.println("Send Money : " + money); } else { System.out.println("No Money"); } } public int getMoney() { return this.balance; } }
public class BankRunner { public static void main(String[] args) { BankSystem bankSystem = new BankSystem(1000); Runnable user1Task = () -> { bankSystem.sendMoney(100); System.out.println("User1: Send money - " + bankSystem.getMoney()); }; Runnable user2Task = () -> { bankSystem.sendMoney(200); System.out.println("User2: Send money - " + bankSystem.getMoney()); }; Thread user1 = new Thread(user1Task, "User1"); Thread user2 = new Thread(user2Task, "User2"); user1.start(); user2.start(); } }
80% 이상은 정상적으로 300원이 감소한 결과를 볼 수 있다.
그러나 일부 결과는 아래와 같이 차감되지 않는 경우를 볼 수 있다.
Send Money : 100 Send Money : 200 User1: Send money - 800 User2: Send money - 800
이런 문제는 은행권에선 심각한 문제이다. Race Condition 문제로 인해 발생한 현실 문제들을 보자.
* 2013 Race Condition으로 인한 나스닥 시장 정지
* 2016 Race Condition으로 인한 Heartbleed bug
* 2018 이더리움 블록체인 암호화 탈취
이러한 문제를 해결하기 위해서 Synchronized 키워드를 사용해보자.
public synchronized void sendMoney(int money) { .... }
<기존 동작>
Send Money : 100 Send Money : 200 User1: Send money - 700 User2: Send money - 700
<Synchronized 동작>
Send Money : 100 User1: Send money - 900 Send Money : 200 User2: Send money - 700
순서대로 실행되면서 이전 Race Condition 문제는 없어졌다.
그러나 Blocking 시간이 오래 되어질 수 있기 때문에 좁은 범위에서 Synchronized를 사용해주는게 좋다.
또 하나의 방법은 Atomic 클래스 사용하는 것이다.
import java.util.concurrent.atomic.AtomicInteger; public class BankSystem { private AtomicInteger balance; public BankSystem(int balance) { this.balance = new AtomicInteger(balance); } public void sendMoney(int money) { if (balance.get() >= money) { // execute main logic try { Thread.sleep(100); } catch (InterruptedException ignored) { } balance.addAndGet(-money); System.out.println("Send Money : " + money); } else { System.out.println("No Money"); } } public int getMoney() { return this.balance.get(); } }
결과는 Syncronized랑은 다르지만 객체에 원자성을 부여해 읽기/수정에 대해서 Race Condition문제를 예방한다.
<결과>
Send Money : 100 Send Money : 200 User1: Send money - 700 User2: Send money - 700
그 외 해결방법은 불변성 객체 이용, ConcurrnetMap 등 이용이 있다.
Volatile vs Synchronized 차이
두 키워드는 자바의 멀티 스레드 환경을 다루기 위한 방법이지만 목적이 다르다.
Volatile 키워드는 스레드 간 공유 자원에 대한 가시성을 확보하기 위해서 사용된다.
Synchronized 키워드는 가시성 확보 뿐만 아니라 자원의 Race Condition에 대한 처리를 하기 위해서 사용된다.
Volatile 키워드는 자원에 대한 원자성을 부여하지 않는다. 즉 Race Condition문제는 발생한다는 것이다.
Synchronized 키워드는 원자성을 제공하여 가시성 + Race Condition문제를 해결한다.
두 키워드를 상황에 맞게 적절하게 활용해야한다. Synchronized가 은총알은 아니다.
반응형'블로그 번역' 카테고리의 다른 글
[24.03.01] 마이크로서비스 안티 패턴 (0) 2024.03.01 [23.12.29] Java 21 - Sequenced Collections (0) 2023.12.29 [23.12.28] 2024년 자바 개발자를 위한 면접 질문 - 1 (0) 2023.12.28 [23.12.27] @Retryable 사용법 (1) 2023.12.27 [23.12.26] Outbox Transaction Pattern (0) 2023.12.26