[Java] 🚦 자바 메모리 가시성(Java Memory Visibility)
[Java] 🚦 자바 메모리 가시성(Java Memory Visibility)
Java와 같은 멀티스레드 환경에서 각 스레드는 성능 향상을 위해 메인 메모리(Heap)에 있는 변수의 복사본을 자신의 캐시(CPU 캐시 등)에 저장하여 사용합니다. 스레드가 변수 값을 변경하면, 이 변경 내용은 즉시 메인 메모리에 반영되지 않고 자신의 캐시에만 반영될 수 있습니다.
이때 다른 스레드가 해당 변수 값을 읽으려 하면, 메인 메모리에 아직 반영되지 않은 오래된 캐시 값을 읽거나 전혀 다른 값을 읽을 수 있습니다. 이처럼 한 스레드의 변수 변경 결과를 다른 스레드가 즉시(또는 적절한 시점에) 볼 수 없는 현상을 메모리 가시성 문제라고 합니다.
Spring 애플리케이션에서 여러 웹 요청이 같은 서비스 빈의 상태를 공유하거나, 백그라운드 스케줄러 스레드가 특정 상태 값을 업데이트하고 다른 스레드가 이 값을 읽는 시나리오 등에서 이 문제가 발생할 수 있습니다.
예시 코드
public class MemoryVisibilityIssue {
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
// 작업 스레드 생성
Thread worker = new Thread(() -> {
while (!flag) {
// flag가 true가 될 때까지 대기
}
System.out.println("작업 스레드: flag가 true로 변경되어 루프를 종료합니다.");
});
worker.start(); // 작업 스레드 시작
System.out.println("메인 스레드: 2초 대기 후 flag를 true로 변경합니다.");
Thread.sleep(100); // 2초 대기
flag = true; // flag 값 변경
System.out.println("메인 스레드: flag를 true로 변경했습니다.");
// 작업 스레드가 종료될 때까지 대기
System.out.println("메인 스레드 종료");
}
}
이 코드를 실행하면, 예상대로라면 메인 스레드가 flag를 true로 변경한 후 작업 스레드의 루프가 종료되고 "작업 스레드: flag가 true로 변경되어 루프를 종료합니다."라는 메시지가 출력되어야 합니다. 하지만 실제 실행해 보면 작업 스레드가 종료되지 않고 계속 실행되는 현상이 발생할 수 있습니다.
// 예상 결과
메인 스레드: 2초 대기 후 flag를 true로 변경합니다.
메인 스레드: flag를 true로 변경했습니다.
메인 스레드 종료
작업 스레드: flag가 true로 변경되어 루프를 종료합니다.
// 실제 결과
메인 스레드: 2초 대기 후 flag를 true로 변경합니다.
메인 스레드: flag를 true로 변경했습니다.
메인 스레드 종료 // ❗️ 작업 스레드가 종료되지 않고, 계속 실행 됨
이와 같은 이유는 작업 스레드가 while (!flag) 조건문을 반복하면서 flag 값을 자신의 캐시에서 읽어옵니다. 메인 스레드가 flag = true; 코드를 실행하여 값을 변경하더라도, 이 변경 내용이 작업 스레드의 캐시로 즉시 전파되지 않기 때문에 작업 스레드는 계속해서 오래된 false 값을 읽고 무한 루프에 빠지는 것입니다. JVM의 최적화(예: 상수 폴딩) 때문에 flag 변수가 루프 내에서 변경되지 않는다고 판단하여 캐시된 값을 계속 사용할 수도 있습니다.
💡 해결 방법 (Java 5 이전)
1) volatile 키워드 사용
volatile
키워드는 변수에 붙여서 사용하며, 해당 변수의 읽고 쓰는 작업을 메인 메모리에서 직접 수행하도록 강제합니다. 따라서 volatile
변수를 쓰면 작업 스레드에서 변경(쓰기)하게 되면 캐시에 있는 값이 메인 메모리에 즉시 반영되고, 읽기 또한 마찬가지로 항상 메인 메모리에서 최신 값을 가져옵니다. 이를 통해 다른 스레드에서는 서로의 변경 내용을 즉시 확인할 수 있습니다.
성능 고려 사항: volatile
읽기는 일반 변수 읽기보다 약간의 오버헤드가 발생할 수 있으며 (메인 메모리 접근), 쓰기는 가시성 보장을 위해 추가적인 작업(메모리 배리어 등)을 수행하므로 일반 변수 쓰기보다 오버헤드가 있습니다. 하지만 synchronized
에 비해서는 훨씬 가볍습니다. 따라서 단일 변수의 가시성만 필요한 경우에는 volatile이
좋은 선택입니다.
❗️volatile
은 단일 변수의 가시성만 보장하며, 복합 연산(i++
와 같이 읽기, 수정, 쓰기 세 단계로 이루어진 복합 연산)의 원자성(Atomicity)은 보장하지 않습니다.
예시 코드
public class VolatileMemoryVisibilityExample {
private static volatile boolean flag = false; // volatile 키워드 추가
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (!flag) {
// volatile 변수를 읽으므로 최신 값을 가져옴
}
System.out.println("작업 스레드: flag가 true로 변경되어 루프를 종료합니다.");
});
worker.start();
System.out.println("메인 스레드: 2초 대기 후 flag를 true로 변경합니다.");
Thread.sleep(2000);
flag = true; // volatile 변수에 값을 쓰므로 메인 메모리에 즉시 반영
System.out.println("메인 스레드: flag를 true로 변경했습니다.");
// 작업 스레드가 종료될 때까지 대기하여 결과를 확인
worker.join();
System.out.println("메인 스레드 종료");
}
}
// ✨ 결과
메인 스레드: 2초 대기 후 flag를 true로 변경합니다.
메인 스레드: flag를 true로 변경했습니다.
메인 스레드 종료
작업 스레드: flag가 true로 변경되어 루프를 종료합니다.
2) synchronized 키워드
synchronized
키워드는 메서드 또는 블록에 사용하여 해당 코드 영역에 대해 한 번에 하나의 스레드만 접근할 수 있도록 합니다 (상호 배제). synchronized
는 객체 잠금(monitor lock)을 사용하며, 이는 volatile
보다 더 강력하고 복잡한 메커니즘으로 가시성과 원자성을 동시에 보장합니다.
- 가시성 보장:
synchronized
블록/메서드를 나갈 때 (unlock) 해당 스레드의 캐시 변경 내용이 메인 메모리에 모두 반영됩니다.synchronized
블록/메서드에 진입할 때 (lock) 다른 스레드에 의해 변경된 최신 값을 메인 메모리로부터 가져와 캐시를 갱신합니다. - 원자성 보장:
synchronized
블록/메서드 내의 코드는 하나의 단위로 실행되며, 다른 스레드가 중간에 끼어들 수 없습니다.
성능 고려 사항: synchronized
는 스레드 간의 잠금 획득 및 해제 과정에서 문맥 전환(Context Switching)이 발생할 수 있어 volatile
보다 상대적으로 오버헤드가 큽니다. 경쟁(Contention)이 심할수록 성능 저하가 두드러질 수 있습니다. 따라서 원자성까지 필요한 복합적인 작업이거나 여러 변수에 대한 일관된 접근이 필요할 때 사용하는 것이 적합합니다. 단순히 단일 변수의 가시성만 필요하다면 volatile
이 더 효율적입니다.
public class SynchronizedMemoryVisibility {
private boolean flag = false; // 공유 변수 (volatile 아님)
public synchronized void setFlag(boolean value) {
this.flag = value; // 변경
// synchronized 블록을 나갈 때 캐시 내용이 메인 메모리에 반영됨
}
public synchronized boolean isFlag() {
// synchronized 블록에 진입할 때 메인 메모리에서 최신 값을 가져옴
return this.flag;
}
public static void main(String[] args) throws InterruptedException {
SynchronizedMemoryVisibility example = new SynchronizedMemoryVisibility();
Thread worker = new Thread(() -> {
// flag 값을 읽기 위해 synchronized 메서드 호출
while (!example.isFlag()) {
// 대기
}
System.out.println("작업 스레드: flag가 true로 변경되어 루프를 종료합니다.");
});
worker.start();
System.out.println("메인 스레드: 2초 대기 후 flag를 true로 변경합니다.");
Thread.sleep(2000);
// flag 값을 변경하기 위해 synchronized 메서드 호출
example.setFlag(true);
System.out.println("메인 스레드: flag를 true로 변경했습니다.");
worker.join();
System.out.println("메인 스레드 종료");
}
}
// ✨ 실행 결과
메인 스레드: 2초 대기 후 flag를 true로 변경합니다.
메인 스레드: flag를 true로 변경했습니다.
작업 스레드: flag가 true로 변경되어 루프를 종료합니다.
메인 스레드 종료
💡 해결 방법(Java 5 이후)
Java 5부터 도입된 java.util.concurrent
패키지는 더 정교하고 성능 친화적인 동시성 제어 도구들을 제공합니다.
1) Atomic 변수 (java.util.concurrent.atomic)
- 무엇인가?
AtomicInteger
,AtomicLong
,AtomicBoolean
,AtomicReference
등과 같이 단일 변수에 대해 원자적인 연산(읽기, 쓰기, 증가, 비교 후 교환 - CAS: Compare-And-Swap)을 제공하는 클래스들입니다. - 왜 더 나은가? Atomic 변수는 대부분의 경우 내부적으로 락(Lock) 없이 CAS 연산을 사용합니다. CAS는 CPU 레벨에서 지원되는 매우 빠르고 원자적인 연산으로, 스레드 간의 문맥 전환(Context Switching) 오버헤드 없이 변수 값을 안전하게 변경할 수 있습니다. 이는
synchronized
가 락을 사용함으로써 발생하는 성능 저하를 회피하게 해 줍니다. 또한Atomic
변수는volatile
의 가시성 특성도 포함하고 있습니다. - 가시성 및 원자성:
Atomic
변수에 대한 모든 연산은 원자적이며,volatile
처럼 항상 최신 값을 읽고 변경 내용을 즉시 가시화합니다. JMM 관점에서는Atomic
변수에 대한 모든 작업이 Happens-before 관계를 따릅니다. - 주요 활용 사례 (Spring/Backend): 고유 ID 생성 (예: 요청 카운터), 상태 플래그 관리, 공유 참조 변수의 원자적 업데이트.
예시 코드
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounterExample {
// 여러 스레드가 동시에 접근해도 안전한 카운터
private AtomicInteger counter = new AtomicInteger(0);
public int increment() {
// 원자적으로 값을 1 증가시키고 현재 값을 반환
return counter.incrementAndGet();
}
public int getCounter() {
return counter.get(); // 현재 값을 가져옴 (가시성 보장)
}
public static void main(String[] args) throws InterruptedException {
AtomicCounterExample example = new AtomicCounterExample();
Runnable incrementTask = () -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
};
Thread t1 = new Thread(incrementTask);
Thread t2 = new Thread(incrementTask);
t1.start();
t2.start();
t1.join();
t2.join();
// 예상 결과: 2000 (두 스레드가 각각 1000번씩 증가)
System.out.println("최종 카운터 값: " + example.getCounter());
}
}
// ✨ 실행 결과
최종 카운터 값: 2000
AtomicInteger
의 incrementAndGet() 메서드는 스레드 안전하게 카운터 값을 1 증가시키고 그 값을 반환합니다. 여러 스레드가 동시에 이 메서드를 호출해도 경쟁 상태(Race Condition) 없이 정확하게 값이 증가하며, 변경된 값은 다른 스레드에게 즉시 가시적입니다. synchronized
블록 없이도 원자성과 가시성이 보장됩니다.
2) 동시성 컬렉션 (java.util.concurrent)
- 무엇인가?
ConcurrentHashMap
,CopyOnWriteArrayList
,ConcurrentLinkedQueue
등 다중 스레드가 동시에 안전하게 접근하고 수정할 수 있도록 설계된 컬렉션 구현체들입니다. - 왜 더 나은가? 기존의
Collections.synchronizedMap()
이나Vector
와 같은 전체 락(Intrinsic Lock) 방식의 동기화 컬렉션과 달리, Concurrent 컬렉션은 내부적으로 더 세밀한 락킹 (예: ConcurrentHashMap의 버킷 단위 락킹) 또는 락-프리(Lock-free) 알고리즘을 사용하여 동시성을 처리합니다. 이를 통해 여러 스레드가 컬렉션의 다른 부분에 동시에 접근할 때 병렬성을 높여 성능을 향상합니다. - 가시성 및 원자성: 각 컬렉션의 구현체는 내부적으로 가시성과 원자성을 보장합니다. 예를 들어
ConcurrentHashMap
의 put 작업 결과는 다른 스레드의 get 작업에서 즉시 가시적입니다. - 주요 활용 사례 (Spring/Backend): 스레드 안전한 캐시 구현, Producer-Consumer 패턴에서 스레드 간 데이터 전달, 변경이 드물고 읽기가 잦은 리스트.
예시 코드
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentMap {
// 여러 스레드가 동시에 접근해도 안전한 Map
private Map<String, Integer> sharedMap = new ConcurrentHashMap<>();
public void addOrUpdate(String key, Integer value) {
sharedMap.put(key, value); // 스레드 안전한 쓰기 (가시성 및 원자성 보장)
}
public Integer getValue(String key) {
return sharedMap.get(key); // 스레드 안전한 읽기 (가시성 보장)
}
public static void main(String[] args) throws InterruptedException {
ConcurrentMap example = new ConcurrentMap();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
example.addOrUpdate("key" + i % 10, i);
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Map 크기: " + example.sharedMap.size());
System.out.println("마지막 key9 값: " + example.getValue("key9")); // 마지막으로 put된 값 중 하나
}
}
ConcurrentHashMap은 여러 스레드가 동시에 put, get 등의 연산을 수행해도 내부적으로 동기화 처리가 되어 있어 안전합니다. 별도의 synchronized 블록 없이도 스레드 간 데이터 충돌 없이 값을 읽고 쓸 수 있으며, 변경 내용은 다른 스레드에게 즉시 가시적입니다. 일반 HashMap을 여러 스레드가 공유하면서 사용하면 ConcurrentModificationException이 발생하거나 데이터가 손상될 수 있습니다.
3) 불변 객체 (Immutable Objects)
- 무엇인가? 객체 생성 후에 그 상태를 변경할 수 없는 객체입니다. 모든 필드가 final이거나, 필드가 참조하는 객체가 불변이거나, 객체의 상태를 변경하는 메서드를 제공하지 않습니다. (예: String, Integer, BigDecimal)
- 왜 더 나은가? 객체의 상태가 절대 변하지 않기 때문에, 여러 스레드가 동시에 불변 객체를 읽더라도 어떠한 동시성 문제나 가시성 문제도 발생하지 않습니다. 본질적으로 스레드 안전합니다. 따라서 별도의 동기화 메커니즘이 전혀 필요 없습니다.
- 가시성 및 원자성: 객체 생성 시점의 상태가 모든 스레드에게 영구적으로 가시적이며, 변경이 불가능하므로 원자성을 걱정할 필요가 없습니다. (단, 불변 객체를 참조하는 변수의 가시성은 여전히 보장되어야 합니다. 이럴 때 final 키워드가 참조 자체의 가시성을 부분적으로 보장합니다.)
- 주요 활용 사례 (Spring/Backend): 설정 정보 객체 로딩, 읽기 전용 캐시 데이터, 여러 계층(Layer) 간 데이터 전달을 위한 DTO (Data Transfer Object), 복잡한 계산의 결과.
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
final class ImmutableConfig {
private final String serverUrl;
private final int timeout;
@Override
public String toString() {
return "AppConfig{serverUrl='" + serverUrl + "', timeout=" + timeout + '}';
}
}
import java.util.concurrent.atomic.AtomicReference;
public class ImmutableObject {
// 불변 객체에 대한 스레드 안전한 참조
private AtomicReference<ImmutableConfig> currentConfig = new AtomicReference<>(
new ImmutableConfig("initial.url", 1000)
);
public ImmutableConfig getImmutableConfig() {
return currentConfig.get(); // AtomicReference로 참조 읽기 (가시성 보장)
}
public void updateConfig(String serverUrl, int timeout) {
ImmutableConfig newConfig = new ImmutableConfig(serverUrl, timeout); // 새로운 불변 객체 생성
currentConfig.set(newConfig); // AtomicReference로 참조 업데이트 (원자성 및 가시성 보장)
}
public static void main(String[] args) throws InterruptedException {
ImmutableObject example = new ImmutableObject();
// 설정을 읽는 스레드
Thread reader = new Thread(() -> {
for (int i = 0; i < 5; i++) {
ImmutableConfig config = example.getImmutableConfig();
System.out.println("Reader: " + config);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
// 설정을 업데이트하는 스레드 (예: 관리 기능)
Thread updater = new Thread(() -> {
try {
Thread.sleep(1200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
example.updateConfig("updated.url", 5000);
System.out.println("Updater: 설정 업데이트 완료");
});
reader.start();
updater.start();
reader.join();
updater.join();
}
}
ImmutableConfig
클래스는 생성 후에는 상태를 변경할 수 없는 불변 객체입니다. ImmutableObject
클래스는 이 불변 ImmutableConfig
객체에 대한 참조를 AtomicReference로
관리합니다. 여러 스레드가 동시에 getImmutableConfig()를 호출하여 ImmutableConfig
객체를 읽어도 안전합니다. updateConfig() 시에는 새로운 ImmutableConfig
객체를 생성하고 AtomicReference
의 set() 메서드를 통해 참조를 원자적으로 업데이트합니다. AtomicReference
덕분에 새로운 설정 객체는 즉시 다른 스레드에게 가시적이 됩니다.
4) Lock 인터페이스 (java.util.concurrent.locks)
- 무엇인가?
ReentrantLock
,ReadWriteLock
등 명시적인 락(Lock) 메커니즘을 제공하는 인터페이스와 그 구현체들입니다.synchronized
블록/메서드의 대안입니다. - 왜 더 나은가?
synchronized
는 락 획득/해제가 자동화되어 편리하지만,Lock
은 수동으로 lock(), unlock() 메서드를 호출해야 합니다. 이 수동 제어는 다음과 같은 유연성을 제공합니다.- 타임아웃 설정: tryLock(long time, TimeUnit unit)을 사용하여 일정 시간 동안만 락 획득을 시도할 수 있습니다.
- 인터럽트 가능한 대기: lockInterruptibly()를 사용하여 락 대기 중에 스레드를 인터럽트 할 수 있습니다.
- 공정성 옵션: 락 대기 중인 스레드들에게 락을 공정하게 분배할지 여부를 설정할 수 있습니다 (공정 모드는 성능 저하 가능성).
- 분리된 락: 읽기 락(ReadLock)과 쓰기 락(WriteLock)을 분리하여 읽기 작업 간의 병렬성을 높일 수 있습니다 (ReadWriteLock).
- 가시성 및 원자성:
synchronized
와 마찬가지로,Lock
의 unlock()은 이후의 lock()보다 Happens-before 관계를 가지므로 가시성과 원자성을 모두 보장합니다. - 주요 활용 사례 (Spring/Backend): 복잡한 동기화 로직 또는 스레드 풀 관리, 읽기와 쓰기 작업의 빈도가 다른 경우 (ReadWriteLock), 락 획득 실패 시 다른 작업을 수행하거나 타임아웃이 필요한 경우.
예시 코드
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockEx {
private int count = 0;
// 명시적인 락 객체 생성
private final Lock lock = new ReentrantLock();
// 공정 모드 락
// private final Lock lock = new ReentrantLock(trye);
public void increment() {
lock.lock(); // 락 획득 (synchronized 블록 진입과 유사)
try {
// 이 블록 내의 코드는 한 번에 하나의 스레드만 실행 가능
// 가시성 및 원자성 보장
count++;
System.out.println(Thread.currentThread().getName() + ": count = " + count);
} finally {
lock.unlock(); // 락 해제 (synchronized 블록 탈출과 유사) - 필수적으로 finally에서 호출
}
}
public int getCount() {
lock.lock(); // 읽기 시에도 락 획득하여 가시성 보장
try {
return count; // 최신 값 읽기
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
ReentrantLockEx example = new ReentrantLockEx();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
};
Thread t1 = new Thread(task, "Thread-1");
Thread t2 = new Thread(task, "Thread-2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("최종 카운트 값: " + example.getCount());
}
}
// ✨ 실행 결과
Thread-1: count = 1
Thread-1: count = 2
Thread-1: count = 3
...
Thread-2: count = 1998
Thread-2: count = 1999
Thread-2: count = 2000
최종 카운트 값: 2000
📔 올바른 선택
- 단일 변수의 단순 가시성: volatile 또는 AtomicBoolean/Integer/Long
- 단일 변수에 대한 원자적 복합 연산 (읽-수-쓰): Atomic 클래스
- 스레드 안전한 컬렉션: java.util.concurrent 패키지의 컬렉션
- 변경되지 않는 데이터: 불변 객체
- 복잡한 동기화, 유연한 락킹, 읽기/쓰기 분리: Lock 인터페이스 구현체
- 스레드별 독립적인 데이터: ThreadLocal
- 여러 변수에 대한 일관된 접근, 복잡한 원자 연산: synchronized 또는 Lock (원자성과 가시성 모두 필요)