동시성 프로그래밍에서는 CPU와 RAM의 중간에 위치하는 CPU Cache Memory와 병렬성이라는 특징때문에 다수의 스레드가 공유 자원에 접근할 때 두가지 문제가 발생할 수 있다.
- 가시성 문제
여러 개의 스레드가 사용됨에 따라, CPU Cache Memory와 RAM의 데이터가 서로 일치하지 않아 생기는 문제
해당 문제를 해결하기 위해, 가시성이 보장되어야 하는 변수를 CPU Cache Memory가 아닌 RAM에서 바로 읽도록 보장해야 한다.
💡 자바에서 volatile 키워드를 붙임으로써 가시성을 보장할 수 있다.
하지만, 가시성만 보장된다고 동시성이 보장되진 않는다.
가시성을 보장한다는 것은 RAM으로 부터 읽을 수 있게 하는 것이 전부고, 다른 스레드에 의해 값은 언제든지 바뀔 수 있다.
- 원자성(동시 접근 문제)
자바 멀티 스레드 환경에서는 스레드끼리 static 영역과 heap 영역을 공유하므로 공유 자원에 대한 동기화 문제가 발생한다.
멀티 스레드 환경에서 한 스레드가 공유 변수에 대해 작업을 수행하는 동안 다른 스레드가 개입하여 공유 변수에 접근하여 결과값을 꼬이게 한다.
💡 자바에서 synchronized 또는 atomic을 통해 원자성 문제를 해결할 수 있다.
synchronized는 블럭 단위를 들어가기 전에 CPU Cache Memory와 Main Memory를 동기화하며,
atomic은 CAS 알고리즘을 통해 원자성 문제와 CPU Cache Memory에 잘못된 값을 참조하는 문제를 동시에 해결해준다.
멀티 스레드 환경에서 동시성 제어를 위해 공유 객체를 동기화하는 키워드.
⇒ synchronized 키워드가 선언된 블록 안에서 관리되는 자원들은 원자성을 보장할 수 있다.
synchronized는 lock을 이용해 동기화를 수행하며, 4가지의 방법이 존재한다.
- synchronized method
- 인스턴스 단위로 lock을 공유한다.
- static synchronized method
- 클래스 단위로 lock을 공유한다.
- static synchronized method와 synchronized method의 lock은 공유하지 않는다.
- synchronized block
- static synchronized block
- 클래스 단위로 lock 공유
synchronize 경우, blocking 방식을 통해 동시성 문제는 해결해주지만 성능 이슈가 크다.
특정 스레드가 해당 블럭 전체에 lock을 걸면, 해당 lock에 접근하는 스레드들은 블로킹 상태에 들어가기 때문에 아무 작업도 하지 못한 채 자원을 낭비한다.
문제
https://glistening-drifter-920.notion.site/synchronized-84ba6f2683ec48f9974e173317018dab?pvs=4
변수에 volatile 키워드를 통해 CPU Cache Memory가 아닌 RAM에서 바로 읽도록 보장할 수 있다. ⇒ 가시성 문제를 해결할 수 있다.
public class Volatile {
private static boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested) {
i++;
}
});
backgroundThread.start();
Thread.sleep(1000);
stopRequested = true;
}
}
메인 스레드가 1초 후에 stopRequest = true로 설정하기 때문에, 반복문을 빠져나올 수 있는 것처럼 보이지만 그러지 못한다.
⇒ 가시성 문제
해결방안
public class Volatile {
private static volatile boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested) {
i++;
}
});
backgroundThread.start();
Thread.sleep(1000);
stopRequested = true;
}
}
Blcoking이 아닌 CAS(Compared and Swap) 알고리즘으로 원자성을 보장한다.
synchronized 키워드는 blocking 방식으로 동시성을 해결하기 때문에 성능상 문제가 많다. 따라서 non-blocking 방식으로 동시성을 해결하는 방법인 Atomic 클래스가 등장!
CAS 알고리즘
- 이론
- CPU Cache Memory와 RAM을 비교하여 일치한다면 CPU Cache Memory와 RAM에 적용하고
- 일치하지 않는다면, 재시도함으로써 어떠한 스레드에서 공유 자원에 읽기/쓰기 작업을 하더라도 원자성을 보장한다.
- 동작원리
- 인자로 기존 값(Compared Value)과 변경할 값(Exchanged Value)을 전달한다.
- 기존 값(Compared Value)이 현재 메모리가 가지고 있는 값(Destination)과 같다면 변경할 값(Exchanged Value)을 반영하며 true를 반환한다.
- 반대로 기존 값(Compared Value)이 현재 메모리가 가지고 있는 값(Destination)과 다르다면 값을 반영하지 않고 false를 반환한다.
public class AtomicIntegerTest {
private static int count;
public static void main(String[] args) throws InterruptedException {
AtomicInteger atomicCount = new AtomicInteger(0);
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
count++;
atomicCount.incrementAndGet();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
count++;
atomicCount.incrementAndGet();
}
});
thread1.start();
thread2.start();
Thread.sleep(5000);
System.out.println("atomic 결과 : " + atomicCount.get());
System.out.println("int 결과 : " + count);
}
}
// 결과
atomic 결과 : 200000
int 결과 : 152298
**AtomicInteger 클래스 내부 구현 (**incrementAndGet 메서드)
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
private static final Unsafe U = **Unsafe.getUnsafe();**
private static final long VALUE
= U.objectFieldOffset(AtomicInteger.class, "value");
private **volatile** int value;
...
}
public final class Unsafe {
@IntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!**weakCompareAndSetInt**(o, offset, v, v + delta));
return v;
}
}
@IntrinsicCandidate
public final boolean weakCompareAndSetInt(Object o, long offset,
int expected,
int x) {
return **compareAndSetInt**(o, offset, expected, x);
}
@IntrinsicCandidate
public final **native** boolean compareAndSetInt(Object o, long offset,
int expected,
int x);
- volatile 키워드가 value에 붙어 있다.
weakCompareAndSetInt()
메소드를 호출하여 메모리에 저장된 값과 현재 값을 비교하여 동일하다면, 메모리에 변경한 값을 저장하고 true를 반환하여 while문을 빠져 나온다.
참고
- @IntrinsicCandidate
- JDK의 내부 메서드인 intrinsic 메서드에 대한 힌트를 제공하는 어노테이션
- Intrinsic 메서드는 하드웨어 레벨에서 최적화된 기능을 제공하기 위해 JVM에 의해 직접 처리
- 메서드 호출 대신 해당 메서드의 본문을 호출 지점에 직접 삽입하는 것으로 최적화
- native
- 자바에서 네이티브 메서드(native method)를 선언할 때 사용됩니다. 네이티브 메서드는 자바 언어 자체로 구현되지 않고, 네이티브 코드(C, C++, 어셈블리 등)로 작성된 메서드를 의미
- 네이티브 메서드는 주로 하드웨어와 직접 상호작용해야 하는 경우에 사용되며, 특정 플랫폼의 기능에 접근하는 데 사용됩니다.