ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 자바쓰레드 와 병렬 vs 동시 (java) 그리고 JMM이란?
    카테고리 없음 2025. 11. 28. 11:53
    728x90
    반응형

    자바쓰레드

    현대 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) { // ... }

    흐름

    1. Thread A가 lock의 락을 잡고 synchronized(lock) 내부 실행 중
    2. Thread B가 같은 lock으로 들어가려 함
    3. B는 락을 얻을 때까지 BLOCKED
    4. A가 synchronized를 빠져나오면 락 반납
    5. B가 락 획득 → RUNNABLE로 복귀

    5. WAITING — “누가 깨워줄 때까지 무기한 대기”

    synchronized (lock) { lock.wait(); // WAITING }
     

    흐름

    1. 스레드가 lock 락을 잡고 있음
    2. wait() 호출
    3. 락 반납 + WAITING
    4. 다른 스레드가 lock.notify() / notifyAll() 호출
    5. 깨어나서 락을 다시 얻으려고 시도

    공유 자원 문제: 왜 락이 필요하나

    여러 쓰레드가 같은 데이터를 건드릴 때 생기는 대표 문제:

    1. Race Condition
      실행 순서에 따라 결과가 달라짐.
    2. Visibility 문제
      한 쓰레드의 변경이 다른 쓰레드에 안 보일 수 있음(캐시/재정렬 때문).
    3. 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가 완료되는 순간:

    1. 대기 중이던 Completion들을 꺼내고
    2. 각 Completion이 자기 일을 수행해서
    3. 다음 CompletableFuture(CF1, CF2…)를 완료시킴
    4. 그 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
    반응형
Designed by Tistory.