동시성(Concurrency)과 병렬성(Parallelism)은 멀티스레드의 핵심 개념입니다.
동시성(=Concurrent하다는 것)은 여러 작업을 동시에 처리하는 것처럼 보이는 것이고, 병렬성(=Parallel하다는 것)은 여러 작업을 실제로 동시에 처리하는 것을 의미합니다.
동시성은 하나의 코어에서 여러 task들을 빠르게 번갈아가면서 수행하는 기술입니다. "동시에" 실행되는 것처럼 보이는 것은 스케쥴러가 아주 빠르게 스레드(혹은 프로세스) 간 컨텍스트 스위치를 하기 때문입니다. (어떤 task를 CPU에 할당할 지 결정)
특히, I/O wait이 많은 웹 서버나 DB 서버에서 많이 사용됩니다.
병렬성은 여러 task들을 실제로 동시에 실행하는 기술입니다. 따라서, core가 하나라면 parallel한 방법은 불가능합니다. 대규모 데이터 연산/분석, 이미지 처리 등에서 단순 계산 작업들을 빠르게 처리할 수 있습니다.
동기화 문제는 concurrent하게 동작하는 여러 task가 동시에 공유된 자원(resource)에 변경하려 할 때 발생합니다.
예를 들어, 10개의 스레드가 동일한 변수 counter
를 1씩 증가시키는 코드가 있다고 가정하겠습니다.
public class Counter {
private int counter = 0;
public void increment() {
counter++;
}
public int getCounter() {
return counter;
}
}
10개의 스레드(t1, t2, ..., t10)가 각각 increment()
메서드를 동시에 호출하면, 다음과 같은 과정을 겪습니다.
counter
(ex: 3)를 읽고, counter <- 4
하려고 합니다.counter
(ex: 3)를 읽고, counter <- 4
하려고 합니다.counter
가 두 번 호출되더라도 실제 counter
는 1만 증가됩니다.이를 Race Condition(경쟁 상태)이 발생했다고 하고, thread-unsafe 한 코드에서 발생합니다.
thread-safe란, 멀티스레드 환경에서 여러 스레드가 동시에 공유 자원을 write 하더라도 자원의 상태를 일관되게 유지할 수 있도록 보장된 상태를 의미합니다.
그러면 counter
라는 공유 자원을 스레드 간에 race condition이 발생하지 않도록 thread-safe하게 처리하는 방법들에 대해 알아보겠습니다.
synchronized
키워드 사용Lock
인터페이스 사용 (java.util.concurrent.locks.ReentrantLock
)AtomicInteger
사용LongAdder
사용@ExtendWith(SpringExtension.class)
class CounterTest {
@Autowired
private Counter counter;
@Autowired
private CounterWithSync counterWithSync;
@Autowired
private CounterWithLock counterWithLock;
@Autowired
private CounterWithAtomic counterWithAtomic;
@Autowired
private CounterWithAdder counterWithAdder;
// 스레드 풀 생성
private final ExecutorService executorService = Executors.newFixedThreadPool(10);
// task 수
private final int TASK_COUNT = 1_000_000;
@Test
void testCounterWithMultipleThreads() throws InterruptedException {
for (int i = 0; i < TASK_COUNT; i++) {
executorService.submit(() -> counter.increment());
}
executorService.shutdown();
executorService.awaitTermination(5, TimeUnit.SECONDS);
assertThat(counter.getCounter()).isLessThan(TASK_COUNT); // thread safe 하지 않음
}
@Test
void testCounterWithSyncWithMultipleThreads() throws InterruptedException {
for (int i = 0; i < TASK_COUNT; i++) {
executorService.submit(() -> counterWithSync.increment());
}
executorService.shutdown();
executorService.awaitTermination(5, TimeUnit.SECONDS);
assertThat(counterWithSync.getCounter()).isEqualTo(TASK_COUNT);
}
@Test
void testCounterWithLockWithMultipleThreads() throws InterruptedException {
for (int i = 0; i < TASK_COUNT; i++) {
executorService.submit(() -> counterWithLock.increment());
}
executorService.shutdown();
executorService.awaitTermination(5, TimeUnit.SECONDS);
assertThat(counterWithLock.getCounter()).isEqualTo(TASK_COUNT);
}
@Test
void testCounterWithAtomicWithMultipleThreads() throws InterruptedException {
for (int i = 0; i < TASK_COUNT; i++) {
executorService.submit(() -> counterWithAtomic.increment());
}
executorService.shutdown();
executorService.awaitTermination(5, TimeUnit.SECONDS);
assertThat(counterWithAtomic.getCounter()).isEqualTo(TASK_COUNT);
}
@Test
void testCounterWithAdderWithMultipleThreads() throws InterruptedException {
for (int i = 0; i < TASK_COUNT; i++) {
executorService.submit(() -> counterWithAdder.increment());
}
executorService.shutdown();
executorService.awaitTermination(5, TimeUnit.SECONDS);
assertThat(counterWithAdder.getCounter()).isEqualTo(TASK_COUNT);
}
@Configuration
public static class TestConfig {
@Bean
public Counter counter() {
return new Counter();
}
@Bean
public CounterWithSync counterWithSync() {
return new CounterWithSync();
}
@Bean
public CounterWithLock counterWithLock() {
return new CounterWithLock();
}
@Bean
public CounterWithAtomic counterWithAtomic() {
return new CounterWithAtomic();
}
@Bean
public CounterWithAdder counterWithAdder() {
return new CounterWithAdder();
}
}
public static class Counter {
private int counter = 0;
public void increment() {
counter++;
}
public int getCounter() {
return counter;
}
}
public static class CounterWithSync {
private int counter = 0;
public synchronized void increment() {
counter++;
}
public int getCounter() {
return counter;
}
}
public static class CounterWithLock {
private int counter = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
counter++;
} finally {
lock.unlock();
}
}
public int getCounter() {
return counter;
}
}
public static class CounterWithAtomic {
private final AtomicInteger counter = new AtomicInteger(0);
public void increment() {
counter.incrementAndGet();
}
public int getCounter() {
return counter.get();
}
}
public static class CounterWithAdder {
private final LongAdder counter = new LongAdder();
public void increment() {
counter.increment();
}
public long getCounter() {
return counter.sum();
}
}
public static class CounterWithParam {
public int increment(int counter) {
return counter + 1;
}
}
}
synchronized는 블록, 메소드, 클래스 단위로 동기화를 적용할 수 있습니다. 특정 코드 블록 -> 인스턴스 메소드 -> 정적 메소드 수준으로 점차 범위가 증가합니다.
// 1. 코드 블록 동기화
public void increment() {
// logic 1...
synchronized (this) {
counter++;
}
// logic 2...
}
// 2. 메서드 동기화
private int counter = 0;
public synchronized void increment() {
counter++;
}
// 3. 메서드 동기화
privte static int counter = 0;
public static synchronized void increment() {
counter++;
}
synchronized는 블록 전체에 락을 걸고 다른 스레드가 접근하면 blocking되는 방식이므로 스레드가 많을수록 성능 저하가 크게 발생합니다.
1) 성능 저하
2) 데드락
3) 과도한 블록 범위
또한, JVM 수준에서 monitorenter
, monitorexit
으로 변환되어 바이트코드 수준에서도 동기화를 보장합니다.
가시성 문제: 스레드 A가 값을 바꿔도, 스레드 B가 바꾼 사실을 못 본다.
원자성 문제: 스레드 A와 B가 동시에 값을 바꾸려 할 때, 중간 과정의 데이터를 read하는 문제.
한 스레드에서 변경한 값을 다른 스레드가 즉시 관찰할 수 없는 현상을 의미합니다. CPU Cache Memory와 RAM의 데이터가 일치하지 않아 생기는 문제입니다.
class SampleTest {
private static boolean running = true;
@Test
void test2() throws InterruptedException {
Thread thread = new Thread(() -> {
while (running) {
// running 값을 다른 스레드에서 변경하기 때문에 이를 감지하지 못함
// 단, 여기에 System.out.println()을 넣으면 내부적으로 synchronized가 실행되어 동기화가 이루어진다.
}
System.out.println("Thread stopped.");
});
thread.start();
Thread.sleep(1000);
running = false; // 1초 후에 running 값을 false로 변경해도, thread는 이를 알지 못한다. (가시성 문제)
}
}
즉, 한 스레드가 공유 자원(공유 변수)을 변경해도, 다른 스레드는 이를 인식하지 못하고 캐싱된 값을 사용합니다. 이를 가시성 문제라고 합니다.
volatile
키워드volatile로 선언하면 항상 RAM에서 데이터를 읽고 쓰게 강제하여 가시성 문제를 해결할 수 있습니다.
하나의 연산 과정이 여러 단계로 나뉘면서, 과정 중에 다른 스레드가 중간 상태의 데이터를 관찰하여 일관되지 않은 값을 일으키는 문제입니다.
count++;
더하기 연산을 하게 되면,class SampleTest {
private static int count = 0;
@Test
void atomicTest() throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 10_000; i++) {
count++; // 읽기 -> 연산 -> 쓰기 (3단계)
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("count = " + count); // 20_000이 나와야 하는데 그렇지 않음
assertThat(count).isLessThan(20_000);
}
}
결과를 보면 20000에 한참 미치지 못하는 것을 볼 수 있다.
AtomicInteger
등 Atomic
클래스 사용java.util.concurrent.atomic
패키지 클래스들은 CAS 알고리즘을 통해 원자성을 보장합니다.
private static AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();
// CAS 간단 예시 (실행되는 코드는 아님)
int expected = x; // 예상 값
int newValue = x + 1; // 새로운 값
// 1. Compare: 공유변수 x가 현재 값(expected)과 같은지 비교
// 2. Swap: 같다면, 변수 x를 변경 값(newValue)으로 교체하고 true 반환
// 3. 다르다면, false를 반환하고 다시 시도
boolean success = compareAndSwap(x, expected, newValue);
if(success) {
System.out.println("성공적으로 값을 업데이트 했습니다.");
} else {
System.out.println("다른 스레드가 값을 변경했으므로 다시 시도합니다.");
}
Lock 동기화처럼 Blocking이 아닌 스레드가 계속 연산을 시도할 수 있는 Non-Blocking 방식이기 때문에 성능이 향상됩니다.