Search
Duplicate
📒

[Java Study] 13-2. 메모리 가시성, 스레드 동기화

상태
완료
수업
Java Study
주제
연관 노트
3 more properties
참고

메모리 가시성 - volatile

NOTE
메모리 가시성 문제란 Java의 메모리 모델에서 각 스레드가 메모리의 변수를 직접 접근하는 것이 아닌, 로컬 캐시 또는 CPU 캐시에 해당 변수의 값을 저장해 사용하고 있어, 스레드의 변경사항을 다른 스레드가 인식하지 못하는 경우를 말합니다.
메모리 가시성이 발생하는 흐름
메모리 가시성은 결국 특정 스레드에서 변경한 값이 다른 스레드에서 언제 보이는지 알 수 없다는 점이다.
이러한 문제를 해결하기 위해서는 캐시 메모리의 성능을 포기하더라도, 메인 메모리에 직접 접근하면 됩니다. volatile 키워드를 사용해서 이를 구현할 수 있습니다.
static class MyTask implements Runnable { // boolean flag = true; // long count; // 메모리에 직접 접근한다. volatile boolean flag = true; volatile long count; @Override public void run() { while (flag){ // 1억의 연산마다 log 출력 count++; if(count % 100_000_000 == 0){ log("flag = " + flag + ", count = " + count + " in while()"); } } log("flag = " + flag + ", count = " + count + "종료"); } }
Java
복사
public class VolatileCountMain { public static void main(String[] args) { MyTask task = new MyTask(); Thread t = new Thread(task, "work"); t.start(); sleep(1000); // 값 변경 task.flag = false; log("flag = " + task.flag + ", count = " + task.count + "종료"); } }
Java
복사

자바 메모리 모델(JVM)

NOTE
JVM은 자바 프로그램이 메모리에 접근하여 수정하는 방식을 정의하며, 특히 멀티 스레드 환경에서 스레드 간의 상호 작용과 메모리 가시성을 보장합니다. JVM의 핵심은 happen-before관계로, 이는 스레드 간의 작업 순서를 정의하고, 메모리 가시성을 보장하는 규칙입니다.

happens-before

happens-before 관계는 자바 메모리 모델에서 스레드 간의 작업 순서를 정의하는 개념으로 특정 작업이 다른 작업보다 먼저 실행됨을 보장한다.

happens-before 관계가 발생하는 경우

프로그램 순서 규칙
volatile 변수 규칙
스레드 시작 규칙
스레드 종료 규칙
인터럽트 규칙: Thread.interrupt()를 호출하는 작업이, 인터럽트 된 스레드가 인터럽트를 감지하는 시점의 작업보다 happens-before 관계를 가진다.
객체 생성 규칙
모니터 락 규칙: synchronized 블록을 종료한 후, 그 모니터 락을 얻는 모든 스레드는 해당 블록 내의 모든 작업을 볼 수 있다.
전이 규칙
⇒ volaitle 또는 스레드 동기화 기법을 이용하면 메모리 가시성 문제가 발생하지 않는다.

동기화 문제

NOTE
동기화 문제는 멀티 스레드를 사용할 때 여러 스레드가 동시에 동일한 자원(임계영역)에 접근/수정 할 때 발생할 수 있는 문제를 말합니다.
동기화 문제는 은행의 계좌를 출금하는 예제로 쉽게 이해할 수 있다. 2개의 스레드에서 동시에 계좌를 출금하는 코드를 살펴보자.
public class BankAccountV1 implements BankAccount { private int balance; public BankAccountV1(int balance) { this.balance = balance; } @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 -= amount; log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance); log("거래 종료"); return true; } @Override public int getBalance() { return balance; } }
Java
복사
public class BankMain { public static void main(String[] args) throws InterruptedException { BankAccountV1 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(1000); log("t1 state: " + t1.getState()); log("t2 state: " + t2.getState()); t1.join(); t2.join(); // 최종 잔액: -600(비정상!) log("최종 잔액: " + account.getBalance()); } }
Java
복사
balance(잔고)에 동시에 접근하기 때문에 일어나는 문제
잔고에 대해 데이터 경합(Race Condition)이 발생해 데이터 일관성이 깨지게 된다.

synchorinize

NOTE
synchronize 키워드를 사용하여 스레드가 동시에 접근할 수 있는 임계 영역을 설정할 수 있습니다. 이는 특정 코드 블록이나 메서드에 대해 한 번에 하나의 스레드만 접근하도록 보장합니다!
synchronize 키워드는 한 번에 하나의 스레드만 접근할 수 있는 만큼 최대한 짧은 영역으로 설정해야 합니다. 이를 위해서 메서드 단계 뿐 아니라, 블록으로도 동기화 할 수 있는 방법을 제공합니다.
public synchronized void synchronizedMethod() { // 동기화된 코드 }
Java
복사
메서드 동기화
public void method() { synchronized (this) { // 동기화된 코드 } }
Java
복사
블록 동기화

실습 코드

@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 -= amount; log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance); log("거래 종료"); return true; } @Override public synchronized int getBalance() { return balance; }
Java
복사
실습 코드

synchronized의 특징 및 주의사항

synchronized 메서드나 블록에 진입하기 위해서는 해당 객체의 모니터 락을 획득해야 합니다. 한 스레드가 Lock을 획득하면, 다른 스레드는 Lock이 해제될 때까지 BLOCKED로 전환됩니다.
여러 스레드가 Lock을 기다릴 때, 어느 스레드가 Lock을 획득할지 보장하지 못해 예상치 못한 순서로 실행될 수 있습니다.
synchronized 키워드를 사용하면 메모리 가시성 문제도 해결할 수 있습니다.

LockSupport

NOTE
LockSupport는 Thread의 중단과 재개를 제어하는데 사용되는 유틸 클래스입니다. 이 클래스는 java.util.concurrent.locks 패키지에 속하며, pakr(), unpark()와 같은 메서드를 통해 동기화 메커니즘을 제공합니다.
LockSupport의 가장 대표적인 기능은 synchronized에서 발생하던 무한대기 문제를 해결할 수 있습니다. 즉 WAITING ⇒ RUNNABLE으로 상태를 변경할 수 있습니다.
public class LockSupportMainV1 { public static void main(String[] args) { Thread thread1 = new Thread(new ParkTest(), "Thread-1"); thread1.start(); sleep(100); log("Thread-1 state: " + thread1.getState()); log("main -> unpark(Thread-1)"); LockSupport.unpark(thread1); // 2. unpark 사용( } static class ParkTest implements Runnable { @Override public void run() { log("park 시작"); LockSupport.park(); // 1. park 사용 log("park 종료, state: " + Thread.currentThread().getState()); log("인터럽트 상태: " + Thread.currentThread().isInterrupted()); } } }
Java
복사
public class LockSupportMainV2 { public static void main(String[] args) { Thread thread1 = new Thread(new ParkTest(), "Thread-1"); thread1.start(); sleep(100); log("Thread-1 state: " + thread1.getState()); } static class ParkTest implements Runnable { @Override public void run() { log("park 시작"); LockSupport.parkNanos(2000_000_000); // 1. parkNanos 사용(TIMED_WAITING) log("park 종료, state: " + Thread.currentThread().getState()); log("인터럽트 상태: " + Thread.currentThread().isInterrupted()); } } }
Java
복사
park(): 스레드를 WAITING으로 변경
parkNanos(nanos): 스레드를 nano초 동안 TIME_WAITING 상태로 변경한다.
unpark(thread): WATING 상태의 대상 스레드를 RUNNABLE 상태로 변경한다.

ReentrantLock(재진입성 락)

NOTE
ReentrantLockjava.util.concurrent.locks 패키지에 있는 클래스로 synchronized와 유사하게 동기화된 코드 블록을 구현하는데 사용됩니다. 그러나 synchronized보다 더 강력하고 유연한 기능을 제공합니다.
참고로 Reentrant 함수는 Thread-safe 함수와 마찬가지로 여러 Thread에서 동시에 실행 가능하지만, Thread간 공유 자원을 이용하지 않는 함수를 의미한다. (여러번 호출해도 값이 같다)
public interface Lock { void lock(); // 락 획득 시도 void lockInterruptibly() throws INterruptedException; boolean tryLock(); // 락 획득 시도(성공여부 반환) boolean tryLock(long time, TimeUnit unit) throws InterruptedException; void unlock(); Condition newCondition(); }
Java
복사
Lock 인터페이스
public class BankAccountV5 implements BankAccount { private int balance; // Lock 생성 private final Lock lock = new ReentrantLock(); public BankAccountV5(int balance) { this.balance = balance; } @Override public boolean withdraw(int amount) { log("거래 시작: " + getClass().getSimpleName()); // 잔고가 출금액 보다 적으면, 진행하면 안됨 // tryLock(): 스레드 락을 획득할 수 있는지 확인한다. // 만약 얻지 못한다면 바로 반환한다. if (!lock.tryLock()) { log("[진입 실패] 이미 처리중인 작업이 있습니다."); return false; } // ReentratLock 사용 lock.lock(); try{ // === 임계 영역 시작 === log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance); if (balance < amount) { log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance); return false; } // 잔고가 출금액 보다 많으면, 진행 log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance); sleep(1000); // 출금시간 balance -= amount; log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance); // === 임계 영역 종료 === } finally { // Lock 해제 lock.unlock(); } log("거래 종료"); return true; } @Override public synchronized int getBalance() { return balance; } }
Java
복사
ReentrantLock 사용예시
ReentrantLock lock() 메서드는 스레드가 Lock을 얻을 때 까지 WAITING상태로 들어갑니다. 이 과정에서 lock() 메서드가 인터럽트에 응답하지 않는다는 점입니다.
1.
스레드가 lock()을 호출하여 락을 얻기 위해 대기 중일 때 인터럽트가 발생하면, 스레드는 일시적으로 RUNNABLE 상태로 전환됩니다.
2.
lock() 메서드는 인터럽트를 무시하기 때문에, 다시 WAITING 상태로 돌아가 락을 획을 할 때까지 기다립니다.
이렇게 lock() 메서드가 설게된 이유는 Lock을 얻기 전까지 스레드가 계속해서 기다리기 위해서입니다. 그래서 인터럽트로 스레드가 잠시 깨어나도 다시 대기 상태로 돌리게됩니다.

공정성

ReentrantLock에서 공정성은 대기 중인 스레드들이 락을 획들할 기회가 공평하게 주어지는 정책을 말합니다.
// 비공정 모드 락 private final Lock nonFairLock = new ReentrantLock(); // 공정 모드 락 private final Lock fairLock = new ReentrantLock(true);
Java
복사
비공정 모드(false) - 기본: 성능 우선, 선점 가능
공정 모드(true) : 공정성 보장(FIFO 순서에 따라 획득), 기아 현상 방지, 성능 저하