1. 원자적 연산(Atomic Operation)
컴퓨터 과학에서 원자적 연산이란 해당 연산이 더 이상 나눌 수 없는 단위로 수행된다는 것을 의미한다.
-> 다른 연산과 간섭 없이 완전히 실행되거나 전혀 실행되지 않는다.
예시
// 원자적 연산 O
volatile int i = 0;
// 원자적 연산 X
i++;
// 1) 오른쪽의 i의 값을 읽는다.
// 2) 읽은 값에 1을 더한 값을 만든다.
// 3) 더한 값을 왼쪽 i 변수에 대입한다.
원자적 연산 예시
IncrementInteger 인터페이스 : 숫자 값을 하나씩 증가 시키는 기능 제공
package thread.cas.increment;
public interface IncrementInteger {
void increment();
int get();
}
BasicInteger.java
package thread.cas.increment;
public class BasicInteger implements IncrementInteger {
private int value; // 인스턴스 필드
@Override
public void increment() {
value++;
}
@Override
public int get() {
return value;
}
}
IncrementThreadMain.java
package thread.cas.increment;
import java.util.ArrayList;
import java.util.List;
import static util.ThreadUtils.sleep;
public class IncrementThreadMain {
public static final int THREAD_COUNT = 1000;
public static void main(String[] args) throws InterruptedException {
test(new BasicInteger());
}
private static void test(IncrementInteger incrementInteger) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
sleep(10); // 너무 빨리 실행되기에 다른 스레드와 동시 실행을 위해 잠시 sleep
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);
}
}
실행 결과

-> 스레드 1000개를 실행해 increment() 메서드도 1000번 호출했지만 기대한 1000이 출력되지 않음
volatile 적용
VolatileInteger.java

실행 결과

-> 여전히 문제 발생
volatile은 여러 CPU 사이에 발생하는 캐시 메모리와 메인 메모리의 동기화 문제를 해결할 뿐
: CPU의 캐시 메모리를 무시하고 매인 메모리를 직접 사용하게 한다.
: 하지만 현재 문제는 캐시 메모리가 영향을 줄 수는 있지만, 캐시 메모리를 사용하지 않고 메인 메모리를 직접 사용해도 여전히 발생한다.
-> 연산 자체가 나눠져 있기 때문에 발생
synchronized 적용
SyncInteger.java

실행 결과

-> synchronized로 안전한 임계 영역을 만들고 value++ 연산 수행 시 정확히 1000이라는 결과가 나오는 것 확인
원자적 연산 - AtomicInteger
자바는 위에서 만든 SyncInteger와 같이 멀티스레드 환경에서 안전히 증가 연산을 수행할 수 있는 AtomicInteger 클래스를 제공한다.
MyAtomicInteger.java
package thread.cas.increment;
import java.util.concurrent.atomic.AtomicInteger;
public class MyAtomicInteger implements IncrementInteger {
AtomicInteger atomicInteger = new AtomicInteger();
@Override
public void increment() {
atomicInteger.incrementAndGet();
}
@Override
public int get() {
atomicInteger.get();
}
}
-> incrementAndGet() : 값을 하나 증가하고 증가된 결과 반환
-> get() : 현재 값 반환
-> 이외에도 다양한 증가와 감소 연산 제공
실행 결과

원자적 연산 - 성능 테스트
IncrementPerformanceMain.java : 지금까지 만든 클래스의 성능 비교
package thread.cas.increment;
public class IncrementPerformanceMain {
public static final long COUNT = 100_000_000; // 1억번
public static void main(String[] args) {
test(new BasicInteger());
test(new VolatileInteger());
test(new SyncInteger());
test(new MyAtomicInteger());
}
public 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));
}
}
실행 결과

실행 분석
1. BasicInteger
> 가장 빠름
> CPU 캐시를 적극 사용(CPU 캐시의 위력)
> 멀티스레드 상황에는 사용할 수 없지만 단일 스레드 사용 시 효율적
2. VolatileInteger
> volatile로 CPU 캐시가 아닌 메인 메모리 사용
> 멀티스레드 상황에는 사용할 수 없음
> 단일스레드가 사용하기엔 BasicInteger 보다 느림
3. SyncInteger
> synchronized로 안전한 임계 영역이 있기에 멀티스레드 상황에서 사용 가능
> MyAtomicInteger보다는 성능이 느림
4. MyAtomicInteger
> 자바가 제공하는 AtomicInteger 사용으로 멀티스레드 상황에서 사용 가능
> synchronized, Lock(ReentrantLock)보다 성능이 1.5 ~ 2배 정도 빠름
* AtomicInteger의 incrementAndGet() 메서드는 락을 사용하지 않고 원자적 연산을 만들기에 빠르다!
CAS 연산 1, 2
우리가 직접 CAS 연산을 사용하는 경우는 거의 없다.
우리는 AtomicInteger와 같은 CAS 연산을 사용하는 라이브러리를 잘 사용하는 것으로 충분
락 기반 방식의 문제점
synchronized, Lock(ReentrantLock)은 특정 자원 보호를 위해 스레드가 해당 자원에 대한 접근을 제한한다.
1. 락이 있는지 확인
2. 락 획득 후 임계 영역에 들어감
3. 작업 수행
4. 락 반납
-> 여기서 락 획득과 반납 과정이 계속 반복된다. 이는 상대적으로 무거운 방식이다.
CAS(Compare-And-Swap, Compare-And-Set)
: 락을 걸지 않고 원자적 연산 수행
: 락 프리 기법이라고도 함
: 락을 완전히 대체하는 것은 아니고 작은 단위의 일부 영역에 적용할 수 있음
* 기본은 락을 사용하고, 특별한 경우 CAS를 적용할 수 있다고 생각하면 됨
CasMainV1.java
package thread.cas;
import java.util.concurrent.atomic.AtomicInteger;
public class CasMainV1 {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(0);
System.out.println("start value = " + atomicInteger.get());
boolean result1 = atomicInteger.compareAndSet(0, 1); // 0이면 1로 셋팅해
System.out.println("result1 = " + result1 + ", value = " + atomicInteger.get());
boolean result2 = atomicInteger.compareAndSet(0, 1);
System.out.println("result1 = " + result2 + ", value = " + atomicInteger.get());
}
}
-> compareAndSet() : CAS 연산 지원, 이 메서드는 원자적으로 실행된다!
1) atomicInteger의 현재 값이 0이면 1로 변경되고 true 반환
2) atomicInteger의 현재 값이 0이 아니라면 값이 변경되지 않고 false 반환
실행 결과

-> 두번째 실행결과에서 atomicInteger의 값이 0이 아니기 때문에 false 반환
결과 분석

1. compareAndSet(0, 1)에서 왼쪽이 기대하는 값, 오른쪽이 변경하는 값
2. CAS 연산은 메모리에 있는 값이 기대하는 값이라면 원하는 값으로 변경한다.
3. 생각해보면 이 명령어는 2개로 나뉘어져 있어 원자적이지 않은 연산처럼 보인다.
1) 메인 메모리 값 확인
2) 해당 값이 기대하는 값이라면, 원하는 값으로 변경
CPU 하드웨어의 지원
> CAS 연산은 원자적이지 않은 2개의 연산을 CPU 하드웨어 차원에서 특별히 하나의 원자적 연산으로 묶어 제공하는 기능이다.
> 이는 SW가 아닌 HW가 제공하는 기능
CPU는 두 과정을 묶어 하나의 원자적 명령으로 만들어 버린다.
1. x001의 값을 확인한다.
2. 읽은 값이 0이면 1로 변경한다.
-> 1번과 2번 사이에 다른 스레드가 x001의 값을 변경하지 못하게 막는다.
-> CPU 입장에서 1, 2번 사이의 시간은 아주 찰나의 순간(CPU가 1초에 얼마나 많은 연산을 수행하는지 생각해보자)
하지만 어떻게 락을 일부 대체할 수 있다는 것일까?
AutomicInteger가 제공하는 incrementAndGet()이 어떻게 CAS 연산을 활용해 락 없이 만들어졌는지 구현해보자.
CasMainV2.java
package thread.cas;
import java.util.concurrent.atomic.AtomicInteger;
import static util.MyLogger.log;
public class CasMainV2 {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(0);
System.out.println("start value = " + atomicInteger.get());
/*
int result = atomicInteger.incrementAndGet();
System.out.println("result = " + result);
*/
// incrementAndGet() 구현
int resultValue1 = incrementAndGet(atomicInteger);
System.out.println("resultValue1 = " + resultValue1);
int resultValue2 = incrementAndGet(atomicInteger);
System.out.println("resultValue1 = " + resultValue2);
}
// atomicInteger 내부의 value 값을 하나 증가
private static int incrementAndGet(AtomicInteger atomicInteger) {
int getValue;
boolean result;
do {
getValue = atomicInteger.get();
log("getValue: " + getValue);
result = atomicInteger.compareAndSet(getValue, getValue + 1);
log("result: " + result);
} while (!result);
return getValue + 1;
}
}
-> atomicInteger.get()으로 value 값을 읽음
-> compareAndSet(getValue, getValue + 1)로 방금 읽은 value 값이 메모리의 value값과 같다면 값을 증가 = CAS 연산 사용
-> CAS 연산 성공시 do~while문을 빠져나가고 연산 실패 시 다시 do~while문 실행
실행 결과

CAS 연산 3
멀티 스레드를 사용해 중간에 다른 스레드가 먼저 값을 증가시켜버리는 경우를 알아보자.
CasMainV3.java
: 2개의 스레드가 incrementAndGet()을 함께 호출
package thread.cas;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static util.MyLogger.log;
public class CasMainV3 {
private static int THREAD_COUNT = 2;
public static void main(String[] args) throws InterruptedException {
AtomicInteger atomicInteger = new AtomicInteger(0);
System.out.println("start value = " + atomicInteger.get());
Runnable runnable = new Runnable() {
@Override
public void run() {
incrementAndGet(atomicInteger);
}
};
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 = atomicInteger.get();
System.out.println(atomicInteger.getClass().getSimpleName() + " resultValue : " + result);
}
private static int incrementAndGet(AtomicInteger atomicInteger) {
int getValue;
boolean result;
do {
getValue = atomicInteger.get();
log("getValue: " + getValue);
result = atomicInteger.compareAndSet(getValue, getValue + 1);
log("result: " + result);
} while (!result);
return getValue + 1;
}
}
실행 결과

결과 분석
Thread-1
15::02::30.024 [ Thread-1] getValue: 0
15::02::30.041 [ Thread-1] result: true
Thread-2
15::02::30.024 [ Thread-0] getValue: 0
15::02::30.041 [ Thread-0] result: false
15::02::30.041 [ Thread-0] getValue: 1
15::02::30.042 [ Thread-0] result: true
-> compareAndSet(0, 1) 수행 시 Thread-1이 중간에 먼저 실행되며 value값이 1로 변경되었기 때문에 첫번째 CAS 연산 실패
-> 이후 compareAndSet(1, 2)은 성공하고 true 반환
정리
CAS를 사용하면 락을 사용하지 않지만, 대신 다른 스레드가 값을 먼저 증가해 문제가 발생하는 경우 루프를 돌며 재시도 하는 방식을 사용
1. 현재 변수 값을 읽어옴
2. 변수 값을 1 증가시킬 때, 원래 값이 같은지 확인(CAS 연산 사용)
3. 동일하다면 증가된 값을 변수에 저장하고 종료
4. 동일하지 않다면 다른 스레드가 중간에 값을 변경한 것이므로 다시 처음으로 돌아가 위 과정 반복
CAS vs Lock
1. Lock 방식
> 비관적 접근법
> 데이터에 접근하기 전 항상 락을 획득
> 다른 스레드의 접근을 막음
> "다른 스레드가 방해할 것이다" 라고 가정
2. CAS 방식
> 낙관적 접근법
> 락을 사용하지 않고 바로 데이터에 접근
> 충돌이 발생하면, 그때 재시도
> "대부분의 경우 충돌이 없을 것이다" 라고 가정 -> 충돌이 빈번하게 발생하는 환경에서는 반복문을 계속 돌기에 CPU 자원을 많이 소모하게 되고 성능에 문제가 될 수 있음
=> 간단한 CPU 연산에는 락 보다 CAS를 사용하는 것이 효과적이다.
CAS 락 구현
CAS는 단순한 연산 뿐 아니라 락을 구현하는데 사용할 수도 있다.
SpinLock.java
package thread.cas.spinlock;
import java.util.concurrent.atomic.AtomicBoolean;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
public class SpinLock {
private final AtomicBoolean lock = new AtomicBoolean(false); // 초기값 false
public void lock() {
log("락 획득 시도");
while(!lock.compareAndSet(false, true)) {
log("락 획득 실패 - 스핀 대기");
}
log("락 획득 완료");
}
public void unlock() {
lock.set(false);
log("락 반납 완료");
}
}
-> 스레드가 락을 획득하면 lock이 true가 되고 락을 반납하면 false가 됨
-> 락을 획득하면 while문 탈출, 락을 획득하지 못하면 락을 획득할 때 까지 while문을 계속 반복 실행
실행 분석
while (!lock.compareAndSet(false, true)) {
// 락을 획득할 때 까지 스핀 대기(바쁜 대기)한다.
log("락 획득 실패 - 스핀 대기");
}
1. lock의 사용 여부를 확인해 변경을 시도
2. lock이 false면 lock의 값을 true로 변경
2.1 변경에 성공하면 true 반환
2.2 while(!true) -> while문을 빠져나옴
3. lock이 true라면
3.1 값을 변경할 수 없음, 변경에 실패했기에 false 반환
3.2 락 획득에 실패하고, 락을 획득할 때 까지 while문 반복
* 스핀 락
스레드가 락이 해제되길 기다리며 반복문을 통해 계속 확인하는 모습이 제자리에서 회전(spin)하는 것 처럼 보여 이런 방식을 스핀 락이라고 부른다.
이런 방식에서 스레드가 락을 획득 할 때 까지 대기하는 것을 스핀 대기, 또는 CPU 자원을 계속 사용하며 바쁘게 대기한다고 하여 바쁜 대기라고도 한다.
스핀 란 방식은 아주 짧은 CPU 연산을 수행할 때 사용해야 효율적이다. 잘못 사용한다면 오히려 CPU 자원을 더 많이 사용하게 된다.
=> 동기화 락을 사용하는 경우, 스레드가 락을 획득하지 못하면 BLOCKED, WAITING 으로 상태가 변한다. -> 스케줄링 필요
=> 무겁고 복잡한 과정이 추가로 들어가므로 성능이 상대적으로 느릴 수 있다.
=> 반면, CAS를 활용한 락 방식은 사실 락이 없고 단순 while문을 반복할 뿐 = 대기하는 스레드도 RUNNABLE 상태를 유지하므로 가볍고 빠르게 작동할 수 있다.
CAS 단점
하지만 CAS를 사용해 락을 대체하는 방식에도 단점이 있다.
락을 기다리는 스레드가 BLOCKED, WAITINNG 상태로 빠지지는 않지만, RUNNABLE 상태로 락을 획득할 때 까지 while문 반복
-> 락을 기다리는 스레드가 CPU를 계속 사용하며 대기 = CPU 자원을 계속 사용
결론
안전한 임계 영역이 필요하지만, 연산이 길지 않고 매우매우 짧게 끝날 때 CAS 락을 사용해야 한다.
데이터베이스 결과를 대기하거나, 다른 서버 요청을 기다리거나 하는 오래 기다리는 작업에 사용하면 CPU를 계속 사용하며 기다리는 최악의 결과가 나올 수 있음
정리
일반적으로 동기화 락을 사용하고, 특별한 경우 CAS를 사용해 최적화해야 한다.
CAS를 통한 최적화가 더 나은 경우는 RUNNABLE -> BLOCKED, WAITING 상태에서 다시 RUNNABLE로 가는 것 보다는, 스레드를 RUNNABLE로 살려둔 상태에서 계속 락 획득을 반복 체크하는 것이 더 효율적일 때이다.
또한 CAS는 충돌 가능성이 높은 환경에서는 성능 저하가 발생할 수 있다는 것을 유의해야 한다.
CAS 연산은 심화 내용으로 이해가 어렵다면 가볍게 듣고 넘어가도 괜찮다. 직접 CAS 연산을 사용하는 경우는 거의 없다.
우리는 AtomicInteger와 같은 CAS 연산을 사용하는 라이브러리들을 잘 사용하는 정도로 충분하다.
'인프런 > 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성' 카테고리의 다른 글
| [인프런] 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 / 11. 스레드 풀과 Executor 프레임워크 1 (0) | 2025.04.01 |
|---|---|
| [인프런] 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 / 10. 동시성 컬렉션 (0) | 2025.03.31 |
| [인프런] 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 / 8. 생산자 소비자 문제 2 (0) | 2025.03.27 |
| [인프런] 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 / 7. 생산자 소비자 문제 1 (0) | 2025.03.27 |
| [인프런] 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 / 7. 고급 동기화 - concurrent.Lock (0) | 2025.03.20 |