Search
Duplicate
📒

[Java Study] 13-4. 원자적 연산(CAS), 동시성 컬렉션

상태
완료
수업
Java Study
주제
연관 노트
3 more properties
참고

원자적 연산

NOTE
원자적 연산(atomic operation)의 의미는 해당 연산이 더 이상 나눌 수 없는 단위로 수행된다는 것을 의미합니다. 즉 여러 스레드가 동시에 접근해도 중간 상태가 없어, 연산이 완료된 후의 상태만이 보장됩니다.
원자적 연산은 중단 없이 완전히 실행되거나 전혀 실행되지 않는 특성을 지닙니다. 이는 다른 연산과의 간섭 없이 수행됨을 의미합니다. 쉽게 말해, 멀티스레드 환경에서 다른 스레드의 개입 없이 안전하게 처리되는 연산을 뜻합니다.
// 원자적 연산 i = 1 // 1. 1의 값을 i에 대입한다. // 원자적 연산이 아님 (순서가 나누어져있다.) i = i + 1; // 1. 오른쪽에 있는 1의 값을 읽는다. // 2. 읽은 값에 1을 더한다. // 3. 더한 값을 i에 대입한다.
Java
복사
원자적 연산 예시
처음에 i = 0이라고 가정하겠다. 스레드1: i = i + 1 연산 수행 스레드1: i의 값을 읽는다. i는 0이다. 스레드1: 읽은 01을 더해서 1을 만든다. 스레드1: 더한 1을 왼쪽의 i변수에 대입한다. 결과: i의 값은 1이다. 스레드2: i = i + 1 연산 수행 스레드2: i의 값을 읽는다. i는 1이다. 스레드2: 읽은 11을 더해서 2을 만든다. 스레드2: 더한 2을 왼쪽의 i변수에 대입한다. 결과: i의 값은 2이다.
Java
복사
순서대로 실행
처음에 i = 0이라고 가정하겠다. 스레드1: i = i + 1 연산 수행 스레드2: i = i + 1 연산 수행 스레드1: i의 값을 읽는다. i는 0이다. 스레드2: i의 값을 읽는다. i는 0이다. 스레드1: 읽은 01을 더해서 1을 만든다. 스레드2: 읽은 01을 더해서 1을 만든다. 스레드1: 더한 1을 왼쪽의 i변수에 대입한다. 스레드2: 더한 1을 왼쪽의 i변수에 대입한다. 결과: i의 값은 1이다.
Java
복사
동시에 실행

원자적 연산 예시

NOTE
원자적이지 않은 연산을 멀티 스레드에서 발생하는 문제를 알아봅시다.
COUNT값 까지 1에서 순차적으로 더하는 작업을 4가지 시나리오로 구분해서 진행합니다.
public class IncrementPerformanceMain { public static final long COUNT = 100_000_000; public static void main(String[] args) { test(new BaseInteger()); // 실패 test(new VolatileInteger()); // 실패 test(new SyncInteger()); // 성공 test(new MyAtomicInteger()); // 성공 } private static void test(IncrementInteger incrementInteger) { long startMs = System.currentTimeMillis(); for (int i = 0; i < COUNT; i++) { incrementInteger.increment(); } long endMs = System.currentTimeMillis(); System.out.println(incrementInteger.getClass().getSimpleName() + ": ms=" + (endMs - startMs)); } }
Java
복사
BaseInteger: ms=13 VolatileInteger: ms=236 SyncInteger: ms=415 MyAtomicInteger: ms=224

1. 기본 연산(문제해결 x)

value++ 연산은 원자적이지 않기 때문에 멀티 스레드 환경에서 쓰이지 못합니다. 하지만 CPU 캐시를 사용하는 만큼 모든 시나리오에서 가장 빠릅니다.
public class BaseInteger implements IncrementInteger { // 기본 연산 private int value; @Override public void increment() { value++; } @Override public int get() { return value; } }
Java
복사

2. volatile 연산(문제해결 x)

volatile 키워드를 사용해 CPU 캐시와 메인 메모리간의 동기화 문제를 해결할 수 있지만, 원자적 연산을 보장하지 못해 멀티 스레드에서 사용하지 못합니다.
public class VolatileInteger implements IncrementInteger { private volatile int value; @Override public void increment() { value++; } @Override public int get() { return value; } }
Java
복사

3. 동기화 연산(문제해결 o)

synchronized 키워드를 사용하여 value++ 연산을 임계영역으로 보호합니다. 이 방식은 멀티 스레딩 환경에서 안전하게 사용할 수 있지만 성능면에서 떨어집니다.
public class SyncInteger implements IncrementInteger { private volatile int value; @Override public synchronized void increment() { value++; } @Override public synchronized int get() { return value; } }
Java
복사

4. 원자적 연산((문제해결 o)

자바가 제공하는 원자적 연산을 지원하는 클래스를 사용해서 멀티 스레딩 환경에서 안전하게 사용할 수 있으며 synchronized보다 1.5~2배정도 빠릅니다.
public class MyAtomicInteger implements IncrementInteger { AtomicInteger atomicInteger = new AtomicInteger(0); @Override public void increment() { atomicInteger.incrementAndGet(); } @Override public int get() { return atomicInteger.get(); } }
Java
복사

CAS(Compare And Swap, Compare And Set)

NOTE
CAS연산은 락을 걸지 않고 원자적인 연산을 수행하는 방법이며, 락을 완전히 대체하는 것은 아니고, 작은 단위의 일부 영역에서 적용하는 방식입니다.
Lock 기반 방식의 문제점은 특정 자원을 보호하기 위해 스레드가 해당 자원에 접근하는 것을 제한하며 추가적인 연산이 필요하게 되어 상대적으로 무거운 방식입니다.
1.
락이 있는지 확인한다.
2.
락을 획득하고 임계 영역에 들어간다.
3.
작업을 수행한다.
4.
락을 반납한다.
CAS는 락을 걸지 않고 원자적인 연산을 수행하며, 메모리에 있는 값이 기대한 값과 일치하는 경우에만 값을 변경하는 과정이 원자적으로 이루어집니다.
자바에서는 java.util.concurrent.atomic 패키지에 원자적 연산을 지원하는 클래스들을 제공합니다.
public class CasMainV1 { public static void main(String[] args) { AtomicInteger atomicInteger = new AtomicInteger(0); System.out.println("start value = " + atomicInteger.get()); // 0 => 1(CAS 연산) boolean result1 = atomicInteger.compareAndSet(0, 1); System.out.println("result1 = " + result1 + ", value = " + atomicInteger.get()); // 1 => 1(CAS 연산) boolean result2 = atomicInteger.compareAndSet(0, 1); System.out.println("result2 = " + result2 + ", value = " + atomicInteger.get()); } }
Java
복사
CAS 연산은 현재 메모리에서 값을 읽어오고 기대한 값과 현재 값이 일치한다면 새로운 값으로 변경합니다.
CAS 연산은 이렇게 원자적이지 않은 연산을 CPU 하드웨어 차원에서 하나의 원자적인 연산으로 묶어서 제공하는 기능이다.
CAS를 사용하는 방식은 드물게 충돌이 발생하는 환경에서 락을 사용하지 않아 높은 성능을 보장할 수 있지만 충돌이 빈번하게 발생하는 경우 그 만큼 재시도를 해 오버헤드가 늘어납니다.
실제로 충돌은 매우 드물게 발생한다. 따라서 간단한 CPU 연산에서는 CAS 사용이 효과적이다.

CAS 락 구현

NOTE
CAS는 단순한 연산 뿐만 아니라, Lock을 구현하는데 사용할 수 있습니다.
public class SpinLock { // CAS 연산을 통해 Lock 획득에 대해 동기화문제 해결 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("락 반납 완료"); } }
Java
복사

CAS와 락 방식의 비교

원자적 연산은 스레드 관점에서 분할할 수 없는 단일 연산이므로, 여러 스레드가 동시에 실행해도 안전합니다.
LOCK 방식: 비관적 접근법으로, 데이터 접근 시 항상 락을 획득해야 합니다. 스레드가 락을 획득하지 못하면 BLOCKEDWAITING 상태로 변경됩니다.
CAS 방식: 낙관적 접근법으로, 락 없이 데이터에 직접 접근합니다. 충돌 발생 시 재시도하며, RUNNABLE 상태를 유지합니다.
CAS는 스레드를 RUNNABLE 상태로 유지하는 것이 락 획득을 반복 확인하는 것보다 효율적인 경우에 사용해야 합니다. 나노초 단위의 매우 짧은 연산에만 CAS를 사용하고, 그 외에는 락을 사용하는 것이 바람직합니다.

동시성 컬렉션

NOTE
java.util의 대부분 컬렉션 프레임워크는 대부분 스레드 세이프하지 못하다. Java에서는 그렇다면 어떻게 동시성 문제를 해결한 컬렉션을 제공해주는가?
자바의 자료구조가 동기화를 제공하는 synchronized를 붙이지 않는 이유는 동기화를 하지 않는 경우가 가장 빠르며, 단일 스레드를 사용하는 경우가 가장 많기 때문입니다.
실제로 과거 자바에서 Vector 클래스에서 동기화 기능을 synchronized를 붙여 제공했지만, 단일 스레드 환경에서 불필요한 동기화로 인해 성능이 떨어져 아무도 사용하지 않게 되었습니다.
그렇다면 동시성을 지원하는 컬렉션을 만들려면 어떻게 해야하는가?

synchronizd 프록시 사용

NOTE
컬렉션들의 동시성 문제를 해결하기 위해 synchronized를 모든 메서드에 적용할 수 있지만, 이는 두 가지 구현체를 만들어 관리를 복잡하게 만듭니다. 자바는 이 문제를 간단히 해결하기 위해 프록시 개념을 도입했해 synchronized가 적용된 컬렉션을 쉽게 생성할 수 있습니다.
public class SynchronizedMain { public static void main(String[] args) { // 동기화 적용! // synchronized를 추가한 프록시 역할 List<String> list = Collections.synchronizedList(new ArrayList<>()); list.add("data1"); list.add("data2"); list.add("data3"); System.out.println(list.getClass()); System.out.println("list = " + list); } }
Java
복사

자바 동시성 컬렉션

NOTE
synchronized 프록시 방식은 편리하지만 결국 동기화 오버헤드가 발생하게 되고 정교한 동기화가 불가능합니다. 이러한 이유로 Java에서는 1.5부터 java.util.concurrent패키지에 동시성 컬렉션을 제공합니다.
동시성 컬렉션들은 아래와 같은 기술을 활용해서 멀티스레드 환경에서의 성능과 안전성을 최적화했습니다.
synchronized, Lock: 전통적인 방법으로 특정 코드 블록을 잠금 처리해 동기화합니다.
CAS: 하드웨어 수준에서 원자적 연산을 보장하는 기법으로 Lock 없이도 안전하게 데이터를 수정할 수 있습니다.
분할 잠금 기술: 큰 데이터를 여러개의 세그먼트로 나누어, 각 세그먼트에 대한 접근을 병렬로 처리할 수 있게 했습니다.
public class ListMain { public static void main(String[] args) { List<Integer> list = new CopyOnWriteArrayList<>(); list.add(1); list.add(2); list.add(3); System.out.println("list = " + list); Set<Integer> list = new CopyOnWriteArraySet<>(); list.add(1); list.add(2); list.add(3); System.out.println("list = " + list); // 순서 유지 Set<Integer> skipListSet = new ConcurrentSkipListSet<>(); skipListSet.add(3); skipListSet.add(2); skipListSet.add(1); System.out.println("skipListSet = " + skipListSet); } }
Java
복사