-
자바쓰레드 와 병렬 vs 동시 (java) 그리고 JMM이란?카테고리 없음 2025. 11. 28. 11:53728x90반응형
자바쓰레드
현대 JVM(HotSpot)에서는 java.lang.Thread = OS 스레드
Windows, Linux, macOS에서 HotSpot JVM의 Java 스레드는 “네이티브(커널) 스레드”에 1:1로 매핑됩니다.
- Java에서 new Thread(...).start()
→ JVM이 OS의 스레드 생성 API(pthread, Win32 thread 등)를 호출
→ 커널 스케줄러가 그 스레드를 스케줄링
왜 java 스레드는 os쓰레드를 사용할까?
1. 진짜 병렬 실행(멀티코어 활용) => 이전에 JVM이 내부에서만 스레드를 돌림 (1개만 사용)
2. 블로킹 I/O 문제 해결
- A 스레드가 I/O에서 블록돼도
- B,C 스레드는 다른 OS 스레드에서 계속 실행
- 시스템 전반이 훨씬 안정적/예측 가능.
3. 커널 스케줄러의 성숙함 활용
- 우선순위/공정성/기아 방지
- NUMA/캐시 locality 고려
- 실시간 스케줄링
- 각종 최적화
4. 디버깅/모니터링/도구 호환성
- top, htop, ps -L
- 프로파일러, 디버거
- 스레드 덤프/코어 덤프
5. 네이티브 라이브러리/FFI와의 자연스러운 결합
네이티브 코드는 기본적으로
- “OS 스레드 = 실행 단위”를 가정해요.
6. 그럼 왜 Virtual Thread가 다시 나왔냐?
OS 스레드는 장점이 큰 대신 비싸요:
- 스택 메모리 큼(기본 수백 KB~MB)
- 생성/컨텍스트 스위칭 비용 큼
- 수만 개 이상 만들면 OS가 버거움
그래서 Java 21의 Virtual Thread는
- “스케줄링은 JVM이 하되”
- “필요할 때만 OS 스레드 위에 얹어서 돌리는”
하이브리드 모델로 장점만 취하려는 시도예요.
즉,
- 병렬/호환성/안정성 = OS 스레드
- 대규모 I/O 동시성(수십만 연결) = Virtual Thread
병렬 vs 동시성: 자바에서의 의미
동시성(Concurrency): 여러 작업이 겹쳐서 진행되는 구조. 싱글코어에서도 가능(시간 분할).
병렬성(Parallelism): 여러 작업이 진짜 동시에 실행됨. 멀티코어에서 가능.1.쓰레드 기본: 생성/실행/종료
// 1) Thread 상속 new Thread(() -> { System.out.println("run"); }).start(); // 2) Runnable Runnable r = () -> {}; new Thread(r).start(); // 3) Callable + Future (리턴/예외 가능) Callable<Integer> c = () -> 42;쓰레드 생성 후 상태가 존재한다.
NEW → RUNNABLE → (BLOCKED / WAITING / TIMED_WAITING) → TERMINATED
1.NEW: new Thread()만 해둔 상태
2.RUNNABLE: OS 스케줄러에 의해 “돌 수 있는 상태”
- 실제 CPU에서 돌고 있을 수도 있고
- 큐에서 자기 차례 기다리는 중일 수도 있음
- 중간 3개는 RUNNABLE에서 잠깐 빠져나와 멈추는 상태
3.TERMINATED: run() 끝나서 완전 종료
4.BLOCKED — “모니터 락을 못 얻어서 막힘”
synchronized (lock) { // ... }흐름
- Thread A가 lock의 락을 잡고 synchronized(lock) 내부 실행 중
- Thread B가 같은 lock으로 들어가려 함
- B는 락을 얻을 때까지 BLOCKED
- A가 synchronized를 빠져나오면 락 반납
- B가 락 획득 → RUNNABLE로 복귀
5. WAITING — “누가 깨워줄 때까지 무기한 대기”
synchronized (lock) { lock.wait(); // WAITING }흐름
- 스레드가 lock 락을 잡고 있음
- wait() 호출
- 락 반납 + WAITING
- 다른 스레드가 lock.notify() / notifyAll() 호출
- 깨어나서 락을 다시 얻으려고 시도
공유 자원 문제: 왜 락이 필요하나
여러 쓰레드가 같은 데이터를 건드릴 때 생기는 대표 문제:
- Race Condition
실행 순서에 따라 결과가 달라짐. - Visibility 문제
한 쓰레드의 변경이 다른 쓰레드에 안 보일 수 있음(캐시/재정렬 때문). - Atomicity 깨짐
count++ 같은 연산은 사실 3단계(읽기→증가→쓰기)라 중간에 끼어들 수 있음.
자바에서 락 관리하는 방식들
1.synchronized: 자바의 기본 락
1-1. 메서드/블록 동기화
class Counter { private int count = 0; public synchronized void inc() { count++; } public void inc2() { synchronized(this) { count++; } } }- 모니터 락(monitor lock) 사용
모니터 락(monitor lock)은 여러 스레드가 공유 자원(객체/임계 구역)에 동시에 접근하지 못하게 하는 상호배제(mutual exclusion) 잠금 을 말해요. 보통 “모니터”라는 동기화 구조가 한 번에 하나의 스레드만 그 안에 들어오도록 보장하는데, 그때 쓰이는 잠금이 모니터 락입니다.
- 같은 객체(this)에 대해 한 번에 하나의 쓰레드만 진입
- 락 획득 = 진입, 해제 = 블록/메서드 종료
1-2. 장점/한계
- 장점: 문법이 간단, 예외 나도 자동 해제
- 한계:
- 공정성/타임아웃/인터럽트 제어 불가
- 조건 대기(Condition)를 세련되게 쓰기 힘듦
2. ReentrantLock
Lock lock = new ReentrantLock(); lock.lock(); try { // critical section } finally { lock.unlock(); }synchronized보다 좋은 점
- tryLock() : 못 얻으면 바로 포기
- tryLock(timeout) : 일정 시간만 기다림
- lockInterruptibly() : 락 기다리다 인터럽트 가능
- new ReentrantLock(true) : 공정 락(대기 순서 보장)
3. volatile
volatile boolean running = true;- 가시성 보장 + 재정렬 방지
- 하지만 count++ 같은 복합 연산의 원자성은 보장 못함
- “volatile read는 최신값을 보장하는 읽기”
“volatile write는 즉시 공개되는 쓰기”
4. Atomic*
AtomicInteger ai = new AtomicInteger(0); ai.incrementAndGet();- CAS(Compare-And-Swap) 기반
- 락보다 가벼운 경우 많음
CAS(Compare-And-Swap) 기반이라는 말은
“락 없이도 원자적(atomic) 업데이트를 하게 해주는 하드웨어 명령 + 그걸 쓰는 알고리즘”
이라는 뜻이야.장점
- 락이 없으니 컨텍스트 스위칭 비용 없음
- 경합이 낮으면 엄청 빠름
- 데드락 없음
단점
- 경합이 심하면 계속 실패→재시도라 CPU를 태움(스핀)
- 공정성 없음(누가 계속 이겨서 업데이트할 수도)
5. 동시성 컬렉션 & 큐
- ConcurrentHashMap
- CopyOnWriteArrayList (읽기 많고 쓰기 적을 때)
- BlockingQueue (생산자-소비자)
- ArrayBlockingQueue, LinkedBlockingQueue
6. 스레드 풀과 Executor (실전의 기본)
ExecutorService es = Executors.newFixedThreadPool(4); Future<Integer> f = es.submit(() -> 1 + 2); Integer result = f.get(); es.shutdown();- submit(Callable/Runnable) → Future로 결과/예외 처리
- shutdown() 안전 종료
7. ForkJoinPool & parallel streams
10-1. ForkJoinPool
작업을 쪼개고(work-stealing) 합치는 구조 → CPU 바운드에 강함
10-2. parallelStream
list.parallelStream() .map(x -> heavy(x)) .collect(toList());
8. CompletableFuture ( 현업 1티어 도구 )
1) CompletableFuture가 왜 나오게 됐나?
(1) Future의 한계
Future.get()은 결과가 올 때까지 블로킹함.
Future<Integer> f = es.submit(task); Integer r = f.get(); // 여기서 멈춤(블로킹)→ “비동기 결과를 받아서 다음 작업을 이어 붙이는” 흐름이 불편.
(2) 콜백(Callback) 방식의 지옥
비동기 후처리를 콜백으로 하면
- 중첩이 깊어지고
- 예외처리가 지저분해지고
- 조합(두 작업 합치기/경쟁시키기)이 어려움
→ 이걸 해결하려고 **“비동기 파이프라인 DSL”**로 나온 게 CompletableFuture.
2) CompletableFuture의 핵심 아이디어 (한 줄)
“미래에 완료될 값(또는 예외)을 담는 상자 + 그 이후에 실행할 작업들을 체인으로 붙이는 구조”
즉,
- 결과가 아직 없어도 일단 Future를 만들고
- 완료되면 다음 단계들이 자동으로 이어서 실행돼.
3) 원리(내부 동작) — HotSpot 기준 큰 그림
3-1. 상태 머신
CompletableFuture는 내부적으로
- 완료 전(incomplete) 상태였다가
- 값으로 완료(completed normally)
또는 - 예외로 완료(completed exceptionally)
상태 중 하나로 딱 1번 전환돼.
이 완료 전환을 **CAS(Compare-And-Swap)**로 원자적으로 해.그래서 여러 스레드가 동시에 완료시키려 해도 딱 하나만 성공.
3-2. “의존 단계(continuation)” 목록을 들고 있음
thenApply, thenCompose 같은 걸 호출하면
- “다음에 할 일”을 스택/리스트(Completion chain) 에 등록해둬.
- 아직 안 끝났으면 “대기 목록에 걸어두는 것”이고
- 이미 끝났으면 “바로 실행 큐로 보내는 것”.
CF0 (source) ├─ Completion A: thenApply(...) ├─ Completion B: thenCompose(...) └─ Completion C: thenAccept(...)3-3. 완료되면 체인이 “폭포처럼” 실행됨
CF0가 완료되는 순간:
- 대기 중이던 Completion들을 꺼내고
- 각 Completion이 자기 일을 수행해서
- 다음 CompletableFuture(CF1, CF2…)를 완료시킴
- 그 CF가 또 자기 체인을 실행
→ 이런 식으로 끝까지 전파.
3-4. 어떤 스레드가 다음 단계를 실행하나?
두 종류가 있어:
- thenApply 같은 비-Async 단계
→ “완료시킨 스레드”가 이어서 실행하는 게 기본. - thenApplyAsync 같은 Async 단계
→ 지정한 Executor(없으면 ForkJoinPool.commonPool)로 넘김.
이게 성능/데드락과 직결되는 포인트라서 실전에서 엄청 중요해.
4) 핵심 API를 “역할별로” 정리
4-1. 시작: 비동기 작업 만들기
CompletableFuture<Integer> cf = CompletableFuture.supplyAsync(() -> heavy());- runAsync : 리턴 없는 작업
- supplyAsync : 값 리턴 작업
4-2. 변환(1→1): thenApply
cf.thenApply(x -> x * 2);4-3. 평탄화(1→Future): thenCompose
“비동기 안에서 또 비동기”를 납작하게 연결.
cf.thenCompose(x -> CompletableFuture.supplyAsync(() -> f(x)));- thenApply를 쓰면 CompletableFuture<CompletableFuture<T>>가 되기 쉬움
- thenCompose가 그걸 자동으로 펼쳐줘.
4-4. 결합(Future+Future): thenCombine
cf1.thenCombine(cf2, (a, b) -> a + b);4-5. 가장 빠른 놈 채택: anyOf / applyToEither
CompletableFuture.anyOf(cf1, cf2); cf1.applyToEither(cf2, x -> x);4-6. 전부 끝나면: allOf
CompletableFuture<Void> all = CompletableFuture.allOf(cf1, cf2);필요하면
all.thenApply(v -> List.of(cf1.join(), cf2.join()));4-7. 예외 처리
cf.exceptionally(e -> fallback); cf.handle((val, ex) -> ex == null ? val : fallback); cf.whenComplete((val, ex) -> log(...));4-8. 완료 강제/취소
cf.complete(value); // 외부에서 정상 완료 cf.completeExceptionally(ex); // 외부에서 예외 완료 cf.cancel(true); // 취소 시도
5) CompletableFuture가 락/메모리 모델 측면에서 안전한 이유
- 완료 전환이 CAS 기반 원자 연산
- 완료된 값/예외를 읽을 때 happens-before 관계가 성립하도록 구현되어 있음
→ 한 스레드가 complete()한 결과를 다른 스레드가 thenApply에서 정상적으로 보게 됨(가시성 보장)
즉, volatile + CAS로 안전하게 상태 전파를 하는 구조라고 보면 돼.
728x90반응형 - Java에서 new Thread(...).start()