금일은 Java 의 원자적 연산에 대해 알아보겠습니다.
원자적 연산은 해당 연산이 더 이상 나눌 수 없는 단위로 수행된다는 개념이며 다른 연산과 간섭 없이 완전히 실행되거나 전혀 실행되지 않는 성질을 가지고 있다.
즉 익히 알고 있는 원자성의 특징이며 멀티 스레드 상황에서 다른 스레드의 간섭 없이 안전하게 처리되는 연산이라는 말이다.
i = 1; (원자적 연산)
i = i + 1; (원자적 연산 x)
원자적 연산은 멀티스레드 상황에서 아무런 문제가 발생하지 않는다. 하지만 원자적 연산이 아닌 경우에는
synchronized 블럭이나 Lock 등을 사용해서 안전한 임계 영역을 만들어야 한다
public interface IncrementInteger {
void increment();
int get();
}
public class BasicInteger implements IncrementInteger {
private int value;
@Override
public void increment() {
value++;
}
@Override
public int get() {
return value;
}
}
public class IncrementThreadMain {
public static final int THREAD_COUNT = 1000;
public static void main(String[] args) throws InterruptedException {
test(new BasicInteger());
test(new VolatileInteger());
test(new SyncInteger());
test(new MyAtomicInteger());
}
private static void test(IncrementInteger incrementInteger) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
sleep(10);
incrementInteger.increment();
}
};
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < THREAD_COUNT; i++) {
Thread thread = new Thread(runnable);
threads.add(thread);
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
int result = incrementInteger.get();
System.out.println(incrementInteger.getClass().getSimpleName() + " result: " + result);
}
}
해당 BasicIntger() 함수를 Main method 에서 실행해보면 기대값인 1000이 당연히 나오지 않는다 그 이유로는 여러 스레드가 동시에 원자적이지 않은 value++ 를 실행했기 때문에 발생한다.
해당 문제의 해결법으로 volatile, syncronized 를 적용해 볼수 있다.
volatile private int value;
하지만 여전히 문제가 해결되지 않는다. volatile 를 사용하면 cpu 의 캐시 메모리를 무시하고, 메인 메모리를 직접 사용하도록 하게 되는데 지금 이 문제와는 상관이 없다.
연산 자체가 나누어져 있기 때문에 발생하게 되는것이다. volatile은 연산 자체를 원자적으로 묶어주는 기능이 아니기 때문이다.
다음 해결 방법으로는 해당 increment(), get() 함수에 syncronized 키워드를 붙여주면 안전한 임계 영역을 만들 수 있긷 때문에 해결 가능하다.
syncronized 키워드 외에 Lock 을 통해 임계영역을 생성 가능하다. Lock 의 예시는 아래와 같다.
private final Lock lock = new ReentrantLock();
@Override
public void increment() {
try {
if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) {
return false;
}
value++;
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
자바는 멀티스레드 상황에서 안전하게 증가 연산을 수행할 수 있는 AtomicInteger 라는 클래스를 제공한다.
별도의 키워드 없이도 선언 만으로 해당 문제들을 해결 가능 합니다.
그밖에도 AtomicLong, Bollean, Xxx 클래스가 존재합니다.
public class MyAtomicInteger implements IncrementInteger {
AtomicInteger atomicInteger = new AtomicInteger(0);
@Override
public void increment() {
atomicInteger.incrementAndGet();
}
@Override
public int get() {
return atomicInteger.get();
}
}
AtomicInteger 의 성능 테스트를 진행해 보겠습니다.
syncronized 와 AtomicInteger 의 비교군을 활용해서 진행하였습니다.
public class IncrementPerformanceMain {
public static final long COUNT = 100_000_000;
public static void main(String[] args) {
test(new SyncInteger());
test(new MyAtomicInteger());
}
private static void test(IncrementInteger incrementInteger) {
long startMs = System.currentTimeMillis();
for (long i = 0; i < COUNT; i++) {
incrementInteger.increment();
}
long endMs = System.currentTimeMillis();
System.out.println(incrementInteger.getClass().getSimpleName() + ": ms=" + (endMs - startMs));
}
}

실행 결과가 보여주듯이 AtomicInteger 의 성능은 syncronized, Lock(ReentrantLock) 을 사용하는 경우보다 1.5 ~ 2 배정도 빠릅니다.
i++ 의 연산은 원자적인 연산이 아니다 AtomicInteger 가 제공하는 incrementAndGet() 메서드는 락을 사용하지 않고 원자적 연산을 만들어 낸다.
대부분 복잡한 동시성 라이브러리들이 CAS 연산을 사용한다. 때문에 우리들을 연산을 사용하는 해당 라이브러리들을 잘 사용하면 충분하다.
기존의 락 기반 방식의 문제점으로는 락을 획득하고 해제하는 데 시간이 소요 된다는 점이다.
10000번의 연산이 있다면 10000번의 연산 모두 같은 과정을 반복하고 때문에 해당 문제를 해결하기 위해 CAS 연산을 활용한다. 락을 사용하지 않기 때문에 락 프리(lock -free) 기법이라고하며 작은 단위의 일부영역에 적용 가능하다.
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(0);
System.out.println("start value = " + atomicInteger.get());
boolean result1 = atomicInteger.compareAndSet(0, 1);
System.out.println("result1 = " + result1 + ", value = " + atomicInteger.get()); // 1
boolean result2 = atomicInteger.compareAndSet(0, 1);
System.out.println("result2 = " + result2 + ", value = " + atomicInteger.get());
}
new AtomicInteger(0) : 내부에 있는 기본 숫자 값을 0으로 설정
compareAndSet(0, 1) : 메서드를 통해 CAS 연산을 지원한다. 현재 값이 0이면 이 값을 1로 변경하라는 매우 단순한 메서드이다.
여기서 중요한점은 해당 예제의 연산은 원자적 연산이 아니다 하지만 cpu 가 원자적 연산으로 만든다.
CAS 연산은 두 개의 연산을 cpu 하드웨어 차원에서 특별하게 하나의 원자적인 연산으로 묶어서 제공하는 기능으로 대부분의 현대 cpu 들은 cas 연산을 위한 명령어를 제공합니다.
cpu 는 두 과정을 하나의 원자적은 명령으로 만들기 위해 1번과 2번사이에 다른 스레드가 x001 의 값을 변경하지 못하게 막는다.
cpu 의 연산은 1초에 수십억번의 연산이 이루어지며 두개의 연산을 묶는 역할은 성능에 큰 영향을 미치치는 않는다.
AtomicInteger 의 내부 함수의 getAndIncrement() 의 함수를 보게 되면 락없이도 값을 증가시키는 것을 볼 수 있으며 현재값을 확인하며 compareAndSet() 함수를 실행하는 것을 볼 수 있다.
두개의 연산을 묶어서 실행하는 동안 다른 스레드가 참여한다면 do ~ while() 구문에서 false를 반환하고 돌아가서 해당 스레드가 다시 성공할 때 까지 다시 실행하는 것을 확인 할 수 있다.
충돌이 빈번하게 발생하는 환경에서는 성능에 문제가 될 수 있다. 재시도로 인한 cpu 의 자원 소모가 있기 때문이다.
간단한 cpu 연산에는 락 보다는 CAS 를 사용하는 것이 효과적이다.
sleep(1) : 다음과 같이 오래 걸리는 로직에서는 스핀 락을 사용하면 안된다.Lock vs CAS 비교
private volatile boolean lock = false;
public void lock() {
log("락 획득 시도");
while(true) {
if (!lock) { // 1. 락 사용 여부 확인
sleep(100); // 문제 상황 확인용, 스레드 대기
lock = true; // 2. 락의 값 변경
break;
} else {
// 락을 획득할 때 까지 스핀 대기(바쁜 대기) 한다.
log("락 획득 실패 - 스핀 대기");
}
}
log("락 획득 완료");
}
public void unlock() {
lock = false;
log("락 반납 완료");
}
해당 코드의 문제점은 lock() method 에서 두개의 스레드가 동시에 락획득이 가능하다는 점이다.
1. 락 사용 여부 확인
2. 락의 값 변경
이 두 부분은 한번에 하나의 스레드만 실행해야 되면 즉 synchronized 또는 Lock 을 사용해서 두 코드를 동기화하여 안전한 임계 영역을 만들어야 한다.
다른 방안으로는 CAS 연산을 사용하여 원자적인 연산으로 처리 가능하다. 아래의 코드를 확인해보자
public static void main(String[] args) {
//SpinLockBad spinLock = new SpinLockBad();
SpinLock spinLock = new SpinLock();
Runnable task = new Runnable() {
@Override
public void run() {
spinLock.lock();
try {
// critical section
log("비즈니스 로직 실행");
sleep(1); // 오래 걸리는 로직에서 스핀 락 사용X
} finally {
spinLock.unlock();
}
}
};
Thread t1 = new Thread(task, "Thread-1");
Thread t2 = new Thread(task, "Thread-2");
t1.start();
t2.start();
}
private final AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
log("락 획득 시도");
while (!lock.compareAndSet(false, true)) {
// 락을 획득할 때 까지 스핀 대기(바쁜 대기) 한다.
log("락 획득 실패 - 스핀 대기");
}
log("락 획득 완료");
}
public void unlock() {
lock.set(false);
log("락 반납 완료");
}
다음 코드에서는 원자적인 연산으로 "락을 사용하지 않는다면 락의 값을 변경" 다음과 같은 문장으로 해석된다.
보통은 동기화 락을 사용하는 경우 스레드가 락을 획득하지 못하면 BLOCKED, WAITING 등으로 상태가 변하지만 cas 연산은 RUNNABLE 상태를 유지하면서 가볍고 빠르게 작동한다.