자바 언어·플랫폼 학습 노트 목차

동시성 — 여러 일을 동시에, 안전하게

스레드 둘이 같은 데이터를 건드리면 왜 깨질까? 가시성원자성이라는 두 문제에서 출발해, 락·원자 연산·스레드 풀을 거쳐 가상 스레드까지 — "동시에 빠르게"와 "안전하게"를 어떻게 양립시키는지 따라간다.


1. 모든 문제의 뿌리 — 가시성과 원자성

동시성 버그는 거의 다 둘 중 하나다.

① 가시성 — 한 스레드가 바꾼 값이 다른 스레드에 안 보일 수 있다. 07에서 본 대로 각 CPU 코어가 값을 자기 캐시에 들고 있어서다.

boolean stop = false;          // 다른 스레드의 stop=true가 영영 안 보일 수 있다
while (!stop) { }              // → 무한 루프
volatile boolean stop = false; // volatile: 항상 메인 메모리로 읽고 쓰고, 재정렬 차단

② 원자성count++는 사실 읽고-더하고-쓰는 세 동작이라, 둘이 동시에 하면 갱신이 유실된다. volatile가시성만 줄 뿐 이건 못 막는다.

volatile int count;
void inc() { count++; }   // 💥 여전히 경쟁 (두 스레드가 같은 값을 읽고 둘 다 +1)

이 둘을 규율하는 게 **JMM(자바 메모리 모델)**의 happens-before — "이 쓰기는 저 읽기에 보인다"는 약속이다(volatile 쓰기→읽기, unlocklock, Thread.start→실행 등). 07에서 본 것처럼 이건 결국 메모리 배리어로 내려가고, 하드웨어 메모리 모델(x86 TSO / ARM weak) 위의 이식 가능한 추상화다.


2. 막거나 — 락

가장 직접적인 해법은 한 번에 하나만 들어오게 막는 것이다.

synchronized void inc() { count++; }   // 내장 모니터 락: 가시성 + 원자성 + 자동 해제

synchronized는 단순하고 블록을 벗어나면 자동 해제되는 게 장점이다. 더 많은 제어가 필요하면 ReentrantLock을 쓴다 — tryLock(timeout)·인터럽트·공정성·여러 Condition을 주지만, *반드시 finally에서 unlock*해야 한다.

lock.lock();
try { /* 임계 영역 */ } finally { lock.unlock(); }

CS 연결synchronized는 객체 헤더(04에서 본 mark word)의 락 비트를 쓴다. 경합이 없으면 경량 락(CAS로 가볍게), 경합하면 중량 락(OS 뮤텍스로 승격해 블로킹)으로 단계가 오른다.

락이 둘 이상이면 **교착(deadlock)**을 조심한다. 두 스레드가 락을 반대 순서로 잡으면 서로를 영영 기다린다. 막는 법은 단순하다 — 전역 락 순서를 정해 항상 같은 순서로 획득한다.


3. 락 없이 — 원자 연산과 CAS

막는 건 비싸다(블로킹·컨텍스트 스위치). 그래서 가벼운 경합은 락 없이 처리한다.

AtomicInteger c = new AtomicInteger();
c.incrementAndGet();           // 원자적, 락 없음

이게 가능한 건 07에서 본 CAS(compare-and-swap) — LOCK CMPXCHG(x86) 같은 단일 CPU 명령 덕이다. "기대값이면 바꾸고, 아니면 실패→재시도"하는 낙관적 방식. 다만 경합이 아주 심한 카운터는 AtomicLong보다 **LongAdder**가 빠르다 — 셀을 여러 개로 쪼개 거짓 공유(07)를 피하고 마지막에 합산하기 때문이다.


4. 스레드를 직접 만들지 마라 — Executor와 Future

new Thread()를 손으로 만들면 개수 통제가 안 된다. 그래서 스레드 풀(ExecutorService)에 작업을 맡긴다.

ExecutorService pool = Executors.newFixedThreadPool(8);
Future<Integer> f = pool.submit(() -> heavy());
Integer r = f.get(2, TimeUnit.SECONDS);   // 타임아웃과 함께 결과 대기

여러 비동기 작업을 엮을CompletableFuture로 파이프라인을 만든다(thenApply·thenCompose·allOf·exceptionally).

실무에서는 — 풀 크기는 왜 그렇게 잡나. 핵심은 커넥션 풀과 똑같다 — 코어 수가 진짜 병렬의 상한이다. CPU 바운드(계산만 하는) 작업은 스레드를 코어 수보다 늘려 봐야 병렬성은 안 늘고 컨텍스트 스위치·캐시 축출 오버헤드만 는다(사고사례 ⑥ 커넥션 풀과 같은 메커니즘) → 그래서 코어 수 근처. 반면 I/O 바운드(DB·네트워크 대기가 긴) 작업은 대기 중엔 코어를 안 쓰므로, 그 빈 코어를 다른 작업이 채우게 코어보다 크게 잡는다 — 대략 코어 수 × (1 + 대기시간/연산시간)(JCIP). 거부 정책도 필수다: 큐를 무한히 두면 처리 못 한 작업이 쌓여 힙이 차다 OOM → 큐에 상한을 두고 가득 차면 호출자 직접 실행/버리기/예외로 *역압(backpressure)*을 건다.


5. 그리고 가상 스레드 — 블로킹 코드로 대규모 동시성

전통적 스레드는 OS 스레드와 1:1이라 비싸다(스택 ~1MB·커널 컨텍스트 스위치). 그래서 동시성 한계가 낮았고, 복잡한 비동기/리액티브 코드로 우회해야 했다. 가상 스레드(Loom, Java 21)가 이 판을 바꾼다.

다이어그램 로딩 중…

가상 스레드는 블로킹을 만나면 캐리어(진짜 OS 스레드)에서 언마운트되어 OS 스레드를 점유하지 않는다. 그래서 단순한 블로킹 코드 그대로 수백만 동시성을 얻는다.

실무에서는 — pinning은 왜 생기나. 가상 스레드의 핵심은 블로킹을 만나면 캐리어에서 언마운트해 스택을 힙에 보관하는 것이다. 그런데 synchronized 모니터를 쥔 채로는 그 모니터 소유권이 캐리어(OS 스레드)에 묶여 있어 안전하게 떼어낼 수 없다 → 캐리어에 **고정(pin)**되고, 그 캐리어가 블로킹 내내 점유돼 수천 가상 스레드의 이점이 사라진다. 그래서 그 자리를 ReentrantLock(모니터가 아니라 일반 객체 상태라 언마운트 가능)으로 바꾼다(Java 24에서 상당 완화). 그리고 가상 스레드는 풀을 만들지 말고 작업마다 새로 만든다(newVirtualThreadPerTaskExecutor).

여기에 구조적 동시성(부모-자식 작업을 한 블록으로 묶어 함께 취소·대기 → 고아 작업 방지)과 Scoped Values(ThreadLocal의 불변·자동 해제 대체)가 더해져, 대규모 동시성을 안전하게 다루게 해 준다.


6. 자주 데는 곳

실무에서는 — ① ThreadLocal을 풀에서 안 지우면 다음 요청에 값이 새고 누수까지 난다 → try { set } finally { remove() }, 가능하면 Scoped Values. ② 불변 객체는 그 자체로 스레드 안전하다(01의 불변이 여기서 빛난다 — 공유해도 아무도 못 바꾸니까). ③ 공유가 읽기 압도적·쓰기 드묾이면 CopyOnWriteArrayList, 동시 맵은 ConcurrentHashMap(단 computeIfAbsent 안에서 같은 맵을 또 갱신하면 무한 루프 — 사고사례 참고). ④ 생산자-소비자는 직접 wait/notify 말고 **BlockingQueue**가 깔끔하다.


한눈에 정리

  • 두 문제: 가시성(volatile)과 원자성(volatile로는 부족). JMM happens-before가 규율, 결국 메모리 배리어.
  • : synchronized(자동 해제) vs ReentrantLock(tryLock·Condition·수동 해제). 교착은 전역 락 순서로.
  • CAS: 락 없는 원자 연산(CMPXCHG). 고경합 카운터는 LongAdder.
  • Executor: 스레드 직접 생성 금지, 풀 + Future/CompletableFuture. 풀 크기·거부 정책 설계.
  • 가상 스레드(21): 블로킹 코드로 대규모 동시성, synchronized pinning 주의. 구조적 동시성·Scoped Values.
  • 실무: ThreadLocal remove, 불변=스레드안전, 공유는 동시성 컬렉션, 생산자-소비자는 BlockingQueue.
컬렉션·예외·리소스JVM 실행 흐름 — 내 코드가 도는 과정