본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Computer Science

Java 동시성: 메모리 모델, 동기화, 스레드 풀, 가상 스레드

작성 2026. 10. 30.추가
목차10개 항목
출처와 참고

jbee의 「Interview Question for Beginner」, 4z7l의 「tech_interview.zip」, gyoogle의 「tech-interview-for-developer」 Java 문서와 fru1tworld/cs-basic 모의 면접 질문 목록을 바탕으로, Java 언어 명세(17장)와 java.util.concurrent 문서, OpenJDK JEP로 확인해 재구성한 노트다.

동시성·병렬·비동기의 개념 구분과 런타임별 스케줄링은 동시성·병렬·비동기·논블로킹과 동시성 모델에, 락·세마포어·모니터의 일반 원리는 프로세스 동기화에, CompletableFuture는 Java 실무 관용구에 있다.

Java 메모리 모델

Java 메모리 모델(JMM)은 한 스레드의 쓰기가 다른 스레드의 읽기에 언제 보이는지를 정한 규칙이다. CPU 캐시, 저장 버퍼, 컴파일러·JIT의 명령 재배치 때문에 한 스레드가 쓴 값이 다른 스레드에 늦게 보이거나 순서가 바뀌어 보일 수 있다. JMM은 그런 최적화를 허용하되, 프로그래머가 happens-before 관계를 만들면 순서와 가시성을 보장한다.

happens-before 규칙뜻
프로그램 순서한 스레드 안에서는 앞의 동작이 뒤의 동작보다 먼저다
모니터 락어떤 락의 해제(unlock)는 같은 락의 이후 획득(lock)보다 먼저다
volatilevolatile 변수 쓰기는 같은 변수의 이후 읽기보다 먼저다
스레드 시작·종료t.start() 이전 동작은 t 안의 동작보다, t 안의 동작은 t.join() 반환보다 먼저다
전이성A가 B보다, B가 C보다 먼저면 A가 C보다 먼저다
final 필드생성자가 끝나기 전에 this가 새지 않았다면, 객체 참조를 본 스레드는 생성자에서 쓴 final 필드 값을 본다
  • 두 스레드가 같은 변수에 접근하고 하나 이상이 쓰기인데 그 사이에 happens-before가 없으면 데이터 경쟁(data race) 이다. 데이터 경쟁이 없는 프로그램은 순차적으로 일관된 것처럼 동작한다(DRF-SC 보장).
  • 안전한 발행(safe publication): 다른 스레드에 객체를 넘길 때 volatile 필드, final 필드, 락, 동시성 컬렉션, static 초기화를 거치면 생성이 끝난 상태로 보인다. 그냥 일반 필드에 대입하면 반쯤 만든 객체가 보일 수 있다. DCL 싱글턴에 volatile이 필요한 이유다.

synchronized와 고유 락

여러 스레드가 함께 쓰는 객체(싱글턴, Spring 빈)의 필드에 요청별 상태를 두면 경쟁 상태가 생긴다. 상태는 지역 변수·매개변수로 넘기는 것이 기본이다(싱글톤 빈과 스레드 안전성).

꼭 공유해야 하면 synchronized로 상호 배제한다. Java의 모든 객체는 고유 락(intrinsic lock, 모니터 락) 을 하나씩 갖고, synchronized는 그 락을 잡는다. 이미 그 락을 가진 스레드가 다시 들어가도 막히지 않는다(재진입, reentrancy). count++는 읽기·고치기·쓰기 세 단계라 원자적이지 않으므로 이런 보호가 필요하다. 메서드에 붙이면 그 인스턴스(this, static이면 클래스 객체) 전체를 잠그고, 블록으로 쓰면 지정한 객체로 지정한 구간만 잠근다. 잠그는 범위는 좁을수록 좋다.

고유 락에는 조건 대기 장치도 붙어 있다. synchronized 블록 안에서 wait()는 락을 놓고 잠들고, notify()는 그 객체에서 기다리는 스레드 하나를, notifyAll()은 모두를 깨운다. 깨어난 스레드는 락을 다시 얻은 뒤 이어서 실행하므로, 조건은 if가 아니라 while로 다시 검사한다(가짜 깨어남 대비). 생산자-소비자 같은 대기는 BlockingQueue나 Lock·Condition으로 쓰는 편이 안전하다.

동기화 도구 비교

도구보장맞는 곳
synchronized상호 배제 + 가시성. 블록을 나가면 자동으로 풀린다여러 필드를 함께 바꾸는 임계 구역
ReentrantLock, ReadWriteLock, StampedLock상호 배제 + 가시성. tryLock·시간 제한·인터럽트 가능한 대기·공정성 옵션·여러 Condition대기를 포기해야 하거나 읽기가 압도적으로 많을 때. finally에서 unlock()한다
volatile쓰기가 다른 스레드에 바로 보인다(가시성)와 앞뒤 재배치 금지. 원자성은 보장하지 않는다한 스레드가 쓰고 여럿이 읽는 상태 플래그(running = false), 안전한 발행
AtomicInteger, AtomicReference, LongAdder잠금 없이 CAS(compare-and-swap)로 단일 변수의 원자적 갱신카운터, 상태 전환. 경합이 심한 카운터는 셀을 나눠 더하는 LongAdder
Semaphore, CountDownLatch, CyclicBarrier, Phaser허용 개수 제한, 여러 스레드의 완료·도착 기다리기동시 접근 수 제한, 작업 묶음 동기화

volatile int count에 count++를 해도 깨진다. 읽기와 쓰기 각각은 보이지만, 그 사이에 다른 스레드가 끼어들 수 있다. 원자성이 필요하면 AtomicInteger.incrementAndGet()이나 락을 쓴다.

스레드 안전 컬렉션

기본 구현체(ArrayList, HashMap)는 여러 스레드가 함께 고치면 깨진다(HashMap은 내부 구조가 손상되거나 갱신이 사라진다). 선택지는 세 단계다.

방식예특징
옛 동기화 클래스Vector, Hashtable, Stack모든 메서드가 synchronized라 느리다. 새 코드에서는 쓰지 않는다
동기화 래퍼Collections.synchronizedList/Map/Set객체 전체에 잠금 하나. 순회할 때는 직접 synchronized 블록으로 감싸야 한다
동시성 컬렉션(java.util.concurrent)ConcurrentHashMap, CopyOnWriteArrayList, ConcurrentLinkedQueue, BlockingQueue잠금을 잘게 나누거나 CAS를 써서 여러 스레드가 동시에 진행한다

ConcurrentHashMap과 HashMap의 차이:

비교HashMapConcurrentHashMap
스레드 안전아니다그렇다. 읽기는 잠그지 않는다
쓰기 방식-빈 버킷에는 CAS로 넣고, 이미 노드가 있으면 그 버킷의 첫 노드만 잠근다(Java 8부터). Java 7까지는 테이블을 세그먼트 16개로 나눠 세그먼트 단위로 잠갔다
null 키·값허용허용하지 않는다. get이 null이면 「없음」으로만 해석되게 하려는 것이다
순회순회 중 다른 수정이 있으면 ConcurrentModificationException(fail-fast)예외 없이 순회 시점 전후의 상태 일부를 반영한다(weakly consistent)
size()정확하다동시 수정 중에는 근삿값이다

ConcurrentHashMap이라도 「읽고 → 판단하고 → 쓰기」를 따로 호출하면 그 사이에 끼어들 수 있다. putIfAbsent, computeIfAbsent, merge 같은 원자적 복합 연산을 쓴다. 해시 버킷과 트리화 같은 HashMap 내부는 해시 테이블에 있다.

ThreadLocal과 ScopedValue

ThreadLocal은 스레드마다 따로 보이는 변수다. 같은 ThreadLocal 객체라도 get()은 현재 스레드가 set()한 값만 돌려준다. 각 Thread 객체가 ThreadLocalMap을 갖고 그 안에 ThreadLocal을 키로 값을 저장하는 구조다. 요청 단위 사용자 정보, 트랜잭션 컨텍스트(Spring의 TransactionSynchronizationManager, SecurityContextHolder)처럼 매개변수로 계속 넘기기 번거로운 값에 쓴다.

private static final ThreadLocal<User> CURRENT = new ThreadLocal<>();
 
try {
    CURRENT.set(user);
    handle();                // 깊은 곳에서 CURRENT.get()
} finally {
    CURRENT.remove();        // 스레드 풀에서는 반드시 지운다
}
  • 스레드 풀의 스레드는 재사용되므로 remove()를 빼먹으면 다음 요청이 이전 사용자의 값을 본다. 값이 남아 메모리 누수도 된다(맵의 키는 약한 참조지만 값은 강한 참조다).
  • 비동기로 다른 스레드에 작업을 넘기면 값이 따라가지 않는다. InheritableThreadLocal은 자식 스레드 생성 시점에만 복사한다.
  • 가상 스레드는 수백만 개가 생길 수 있어 스레드마다 큰 값을 두는 ThreadLocal이 부담이 된다. Java 25에서 정식이 된 ScopedValue(JEP 506)는 ScopedValue.where(KEY, v).run(...)의 범위 안에서만 보이는 불변 값을 넘긴다. 범위가 끝나면 사라지므로 지울 필요가 없다.

스레드 풀과 Executor 프레임워크

스레드를 작업마다 만들고 버리면 생성 비용이 크고 개수가 무한히 늘 수 있다. Executor 프레임워크는 작업 제출(Runnable, Callable)과 실행 방식(스레드 풀)을 분리한다.

인터페이스역할
Executorexecute(Runnable) 하나
ExecutorServicesubmit으로 Future를 받고, invokeAll·invokeAny, shutdown·awaitTermination으로 생명주기를 관리한다. Java 19부터 AutoCloseable이라 try-with-resources로 종료를 기다린다
ScheduledExecutorService지연·주기 실행

Runnable은 반환값이 없고 checked 예외를 던질 수 없다. Callable<V>는 값을 돌려주고 예외를 던질 수 있으며, submit은 Future<V>를 돌려준다. Future.get()은 블로킹이고 작업 중 예외는 ExecutionException으로 감싸져 나온다.

ThreadPoolExecutor의 동작

flowchart TB
  sub["작업 제출"] --> q1{"실행 중 스레드 < corePoolSize"}
  q1 -->|"예"| a1["새 스레드를 만들어 실행"]
  q1 -->|"아니면"| q2{"큐에 넣을 수 있다"}
  q2 -->|"예"| a2["작업 큐에 대기"]
  q2 -->|"큐가 가득 참"| q3{"스레드 < maximumPoolSize"}
  q3 -->|"예"| a3["스레드를 더 만든다"]
  q3 -->|"그것도 안 된다"| a4["거부 정책(RejectedExecutionHandler)"]

corePoolSize를 넘는 스레드는 keepAliveTime 동안 놀면 종료된다.

거부 정책동작
AbortPolicy(기본)RejectedExecutionException을 던진다
CallerRunsPolicy제출한 스레드가 직접 실행한다. 제출 속도가 자연히 느려진다(배압)
DiscardPolicy / DiscardOldestPolicy새 작업 / 가장 오래 기다린 작업을 버린다
  • Executors.newFixedThreadPool(n)은 크기 제한이 없는 큐를 써서, 처리보다 제출이 빠르면 큐가 무한히 쌓여 메모리가 터진다. newCachedThreadPool()은 스레드 수 제한이 없다. 운영 코드는 큐 크기와 거부 정책을 직접 정한 ThreadPoolExecutor를 쓴다.
  • 크기는 작업 성격으로 정한다. CPU 바운드는 코어 수 근처, I/O 바운드는 대기 비율만큼 더 크게 잡는다. I/O 바운드가 대부분이면 가상 스레드가 더 단순하다.
  • 풀 안의 작업이 같은 풀의 다른 작업 결과를 기다리면 스레드가 모두 대기에 묶이는 교착(스레드 기아)이 생긴다.

Fork/Join 프레임워크

Fork/Join은 큰 작업을 재귀적으로 쪼개(fork) 병렬로 처리하고 결과를 합치는(join) 분할 정복용 풀이다. RecursiveTask<V>(결과 있음)·RecursiveAction(없음)의 compute()에서 작업이 충분히 작으면 직접 계산하고, 크면 둘로 나눠 한쪽을 fork()하고 다른 쪽을 직접 계산한 뒤 join()한다.

class Sum extends RecursiveTask<Long> {
    protected Long compute() {
        if (hi - lo <= 10_000) return sequentialSum();
        Sum left = new Sum(arr, lo, mid), right = new Sum(arr, mid, hi);
        left.fork();                          // 왼쪽은 다른 워커가 가져갈 수 있다
        return right.compute() + left.join(); // 오른쪽은 직접
    }
}
  • 작업 훔치기(work stealing): 워커 스레드마다 양방향 덱(deque)을 갖는다. 자기 덱의 꼬리에서 작업을 꺼내 쓰고(LIFO, 캐시 지역성), 일이 없으면 다른 워커 덱의 머리에서 훔친다(FIFO, 큰 작업을 가져간다). 쪼갠 작업이 불균형해도 놀고 있는 스레드가 생기지 않는다.
  • join()에서 기다리는 워커는 놀지 않고 다른 작업을 실행한다. 그래서 블로킹 I/O를 넣으면 이 설계가 무너진다.
  • ForkJoinPool.commonPool()(병렬도는 코어 수 - 1)은 병렬 스트림과 CompletableFuture의 기본 실행기로 JVM 전체가 함께 쓴다. 가상 스레드의 캐리어 스케줄러도 별도의 ForkJoinPool이다.

가상 스레드

가상 스레드(virtual thread)는 JVM이 관리하는 가벼운 Thread다(Java 21, JEP 444). 플랫폼 스레드는 OS 스레드를 1:1로 감싼 것이고, 가상 스레드는 적은 수의 플랫폼 스레드(캐리어) 위에 M:N으로 올라간다. 블로킹 I/O·sleep·락 대기를 만나면 JVM이 가상 스레드의 스택을 힙에 내려놓고(unmount) 캐리어에 다른 가상 스레드를 올린다.

비교플랫폼 스레드가상 스레드
구현OS 스레드 1:1JVM이 캐리어 위에 M:N 스케줄링
스택생성 시 큰 고정 스택 예약(보통 1MB 안팎)힙에 필요한 만큼. 수백 바이트~수 KB
개수수천 개가 한계수백만 개
생성 비용비싸서 풀로 재사용한다싸서 작업마다 새로 만든다. 풀에 넣지 않는다
블로킹OS 스레드가 그대로 묶인다캐리어를 놓아주므로 다른 작업이 진행된다
맞는 작업CPU 바운드대기가 많은 I/O 바운드(요청당 스레드 서버)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var req : requests) executor.submit(() -> handle(req));   // 블로킹 코드 그대로
}
Thread.ofVirtual().start(task);

동시성 프로그래밍에서 달라지는 것:

  • 블로킹 코드로 다시 확장한다. 콜백·CompletableFuture 체인·리액티브 스트림으로 스레드를 아끼던 코드를 순차적인 블로킹 코드로 써도 동시 연결 수가 늘어난다. 스택 트레이스와 디버거도 평소처럼 동작한다.
  • 동시성 제한은 풀 크기가 아니라 Semaphore로 한다. 스레드 수가 더는 자원 상한 역할을 하지 않으므로, DB 커넥션·외부 API 호출 수를 세마포어로 직접 제한한다.
  • CPU 바운드 작업은 빨라지지 않는다. 캐리어 수(기본은 코어 수)만큼만 실제로 돈다.
  • 고정(pinning): JNI 같은 네이티브 프레임 안에서 블로킹하면 가상 스레드가 캐리어에 묶인다. Java 21~23에서는 synchronized 안의 블로킹도 묶였지만 Java 24(JEP 491)부터 해결됐다. -Djdk.tracePinnedThreads나 JFR의 jdk.VirtualThreadPinned 이벤트로 찾는다.
  • ThreadLocal에 큰 객체를 캐시하던 관용(예: 스레드마다 파서 하나)은 가상 스레드 수만큼 복제되므로 ScopedValue나 공유 객체로 바꾼다.
  • 여러 하위 작업을 하나의 단위로 시작·취소·대기하는 구조적 동시성(StructuredTaskScope)은 Java 25 기준 아직 프리뷰다(구조적 동시성).

이벤트 루프 서버와의 선택은 비동기 서버의 이벤트 루프에, Spring에서 켜는 방법은 싱글톤 빈과 스레드 안전성에 있다.

정리

  • JMM은 happens-before로 가시성과 순서를 정한다. 락 해제→획득, volatile 쓰기→읽기, start·join이 그 관계를 만든다.
  • synchronized·Lock은 상호 배제, volatile은 가시성만, Atomic은 단일 변수의 CAS 원자성이다.
  • 공유 컬렉션은 java.util.concurrent 구현체와 원자적 복합 연산을 쓴다. ConcurrentHashMap은 버킷 단위로 잠그고 null을 받지 않는다.
  • ThreadLocal은 finally에서 지우고, 가상 스레드에서는 ScopedValue를 고려한다.
  • 스레드 풀은 큐 크기와 거부 정책을 정해 만든다. Fork/Join은 작업 훔치기로 분할 정복을 병렬화한다.
  • 가상 스레드는 작업마다 만들고, 동시성 제한은 세마포어로 하며, I/O 바운드에만 이득이 있다.

관련 노트: Java 언어 기본, Java 실무 관용구, JVM 내부 구조, 동시성 모델, 프로세스 동기화, 비동기 서버의 이벤트 루프, 구조적 동시성, 싱글톤 빈과 스레드 안전성

원본 기록10

댓글

GitHub 계정으로 댓글을 남길 수 있어요.