[Java] synchronized, volatile, atomic
멀티 스레드 환경에서 비동기 이슈들을 해결하기 위한 3가지 키워드를 살펴보자.
원인
멀티 스레드 환경에서 비동기 이슈는 아래 자바의 메모리 구조와 아키텍처 때문에 발생해.
- CPU가 RAM으로부터 필요한 데이터를 CPU cache Mem로 가져와 연산을 진행해.
- 근데 CPU가 여러개라면 CPU마다 cache Mem에 있는 데이터의 불균형이 생길 수 밖에 없겠지. 따로따로 계산하고 있으니까.
- 그래서 가시성과 동시성 문제가 발생해.
가시성 문제
- 하나의 스레드에서 수정한 데이터가 다른 스레드에 보이지 않는 문제
코드로 이해해보자
public class Visibility {
private static boolean stopFlag;
public static void main(String[] args) throws InterruptedException {
Thread ThreadB = new Thread(() -> {
for (int i = 0; !stopFlag; i++); // --- (1)
System.out.println("ThreadB 종료.");
});
ThreadB.start(); // --- (2)
TimeUnit.SECONDS.sleep(1);
stopFlag = true; // --- (3)
System.out.println("main 쓰레드가 종료.");
}
}
스레드는 메인 스레드와 ThreadB가 있어.
메인 스레드는 (2)ThreadB를 실행시키고 1초뒤 (1)ThreadB를 종료시키는 (3)stopFlag를 true로 바꾸고 main이 종료되도록 했어.
결과는 어떻게 될까?
메인 스레드만 종료되고 ThreadB는 종료되지 않아. 이 문제가 바로 가시성 문제야.
이유는 stopFlag를 메인스레드와 ThreadB가 각각 cache memory로 들고 있기 때문이야.
메인스레드에서 (3)stopFlag를 변경하더라도 ThreadB입장에서는 자신을 계산해주는 cpu가 바라보는 cache가 false를 들고 있기 때문이지.
아래 코드로 간단하게 가시성 문제를 해결할 수 있어.
public class Visibility {
private static volatile boolean stopFlag; // main memory(RAM)에서 가져옴
public static void main(String[] args) throws InterruptedException {
Thread ThreadB = new Thread(() -> {
for (int i = 0; !stopFlag; i++); // --- (1)
System.out.println("ThreadB 종료.");
});
ThreadB.start(); // --- (2)
TimeUnit.SECONDS.sleep(1);
stopFlag = true; // --- (3)
System.out.println("main 쓰레드가 종료.");
}
}
stopFlag 변수에 volatile을 선언해주면 돼.
이렇게 되면 cache에서만 계산하는 것이 아니라 항상 main memory에서 데이터를 가져오게 돼.
그렇다면 아까 메인 스레드와 ThreadB가 각각의 cache로 stopFlag를 따로 바라보고 있었던 문제를 메인 메모리에서 항상 가져오게 하니까 같은 곳을 바라보게 되는거지.
동시성 문제
- 공유자원에 여러 스레드가 동시에 접근하였을때 마지막으로 연산한 스레드의 값으로 공유자원이 덮어씌워지는 문제
이것도 코드로 이해해보자.
public class Concurrency {
private static int num;
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread(() -> {
for (int j = 0; j < 100; j++)
System.out.println(num++);
}).start();
}
}
}
num이라는 공유자원을 메인스레드와 새로운 스레드로 10000번 +1씩하며 출력하는걸 만들어봤어.
결과는 0부터 9999까지 잘 나올까?
위 결과처럼 숫자를 생략하기도 하고 끝이 9999로 끝나지 않게돼. 이 문제를 동시성 문제라고해.
두개의 스레드가 하나의 데이터를 함께 바라보고 연산을 진행해서 생기는 문제야.
메인 스레드가 1에서 2로 +1했는데 그 사이 다른 스레드가 1에서 2로 +1하면 원래대로라면 1->2->3이 되어야하는데 1->2로 중복되게 계산하게 되는거지.
동시성 문제는 하나의 자원에 여러 스레드를 접근하지 못하게 하는 synchronized를 설정하여 해결할 수 있어.
public class Concurrency {
private static int num;
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
synchronized (new Thread()) {
{
for (int j = 0; j < 1000; j++)
System.out.println(num++);
}
}
}
}
}
하지만 synchronized를 남발하면 속도저하를 발생시킬 수 있어서 단점이 되기도 해.
이를 해결하기 위해 고안한 방식이 atomic 변수야.
atomic 변수를 이해하기 위해서 CAS(compare and swap) 알고리즘을 먼저 알아야해.
cpu, cache, main memory 구조인데 이 구조에서 두 스레드가 방금 전 상황처럼 하나의 공유자원을 사용하고 있다고 가정할게.
하나의 스레드에서 공유자원을 변경했을때 cache에 있는 데이터가 변경되겠지? 그때 변경된 데이터를 메인메모리와 비교를 해.(compare) 비교했을때 같으면 연산내용을 반영하고(swap) 다르면 연산을 실패하고 재시도하게 하는 알고리즘이야.
실제로 AtomicInteger 클래스 내부 로직을 보면 CAS 알고리즘대로 구현되어 있어.
이렇게 가시성 문제를 해결하는 volatile과 동시성 문제를 해결하는 synchronized와 atomic 변수에 대해 알아보았어.
한번 따라 해보면서 직접 실행해보고 이해해보길 바라!