[이해하기] JVM에서의 스레드에 대한 이해

개요

  • S/W 개발자로서 성능 최적화의 핵심 요소인 스레드에 대해 정리한다

 

목적

  • 효율적으로 성능 최적화하기 위해, 멀티 스레드 환경에서 무엇을 고려해야 하는지 명확히 이해하기 위함
    • 즉, 멀티 스레드 환경에서 발생할 수 있는 문제를 명확히 이해하고,
      이를 해결하기 위한 가장 적절한 방안을 제시하는 주관을 갖기 위함이다
  • 따라서 스레드의 개념부터 시작해 JVM 환경에서 스레드 활용 시 고려해야 할 사항, 성능 최적화 기법까지 정리한다

 

스레드란?

🤔 스레드를 비유하자면?

"스레드는 프로세스라는 커다란 식당에서 일하는 요리사다"

쉽게 표현하기 위해 스레드를 식당의 요리사로 비유할 수 있다

컴퓨터에서 실행되는 프로그램을 하나의 큰 식당으로 생각해 보자
(프로세스 = 큰 식당) (식당은 손님들에게 요리를 제공하기 위해 오픈(실행)되었다)

식당에는 손님(요청)이 계속해서 들어오고, 요리사(스레드)들이 각자 맡은 일을 처리한다
(요리사들 = 스레드들)

싱글 스레드는 요리사 한 명이 모든 요리를 순서대로 만드는 방식이다
(주문이 많아지면 요리사가 감당하지 못해 대기 시간이 길어진다)

멀티 스레드는 여러 명의 요리사가 각자 맡은 요리를 동시에 조리하는 방식이다
(여러 요리를 한 번에 처리할 수 있어 속도가 빨라진다)

하지만 요리사들이 같은 재료(공유 자원)를 동시에 사용하려고 하면 문제(경합)가 발생할 수 있다

이를 방지하기 위해 요리사들끼리 서로 협력하는 규칙(동기화, 락)이 필요하다
  • 위와 같이 스레드는 하나의 프로세스 안에서 여러 작업을 동시에 수행할 수 있도록 도와주는 실행 단위다
  • 프로그램이 실행되면 운영체제는 프로세스를 생성하고, 프로세스 내부에서 특정 작업을 수행하기 위해 스레드를 만든다
  • 하나의 프로세스는 최소 하나의 스레드를 가지며(메인 스레드), 여러 개의 스레드를 생성하여 병렬 처리를 할 수도 있다
  • 스레드는 같은 프로세스 내의 메모리 공간(Code, Data, Heap)을 공유하지만, 스택(Stack)은 각각 독립적으로 갖는다
  • 멀티 스레딩을 활용하면 여러 작업을 동시에 수행할 수 있지만,
    동기화 문제, 데드락, 컨텍스트 스위칭 비용과 같은 고려해야 할 요소도 존재한다
    • 즉, 스레드는 프로세스 내에서 실행되는 독립적인 작업 흐름이며, 멀티태스킹을 가능하게 하는 중요한 개념이다

 

프로세스  VS  스레드

  • 위 비유와 같이 프로세스는 독립적인 식당이며, 스레드는 그 식당에서 일하는 직원(요리사)과 같다
    즉, 프로세스는 OS가 관리하는 독립적인 실행 단위이고, 스레드는 그 내부에서 실행되는 실행 흐름이다
각 식당들은 서로 독립적이다 => 각 프로세스는 서로 독립적이며, 고유한 자원(메모리, 파일 등)을 가진다

프로세스 = 하나의 식당
→ 각 식당은 자신의 주방, 재료, 주문 시스템을 따로 가지고 있음
→ 다른 식당과는 자원을 공유하지 않음
→ 식당(프로세스) 간에 협업하려면 ‘전화 주문’ 같은 별도의 통신(IPC, Inter-Process Communication)이 필요함

스레드 = 식당의 직원
→ 같은 식당에서 일하는 직원들은 공용 냉장고(메모리), 주방(데이터 영역)을 함께 사용함
→ 하지만 직원(스레드)마다 자기만의 작업 공간(스택)은 따로 있음
→ 직원이 많아지면(멀티 스레드) 일을 빠르게 처리할 수 있지만, 같은 냉장고(공유 자원)를 동시에 사용하면 충돌이 발생할 수 있음
  • 위 비유와 같이 프로세스는 운영체제(OS)로부터 독립적인 메모리 공간(Code, Data, Heap, Stack)을 할당받아 실행된다
  • 스레드는 프로세스 내부에서 실행되는 작업 흐름이며,
    같은 프로세스의 메모리(Code, Data, Heap)를 공유하지만 Stack은 개별적으로 가진다
  • 멀티 프로세스는 여러 개의 프로세스를 실행하여 작업을 처리하는 방식이며, 프로세스 간 통신(IPC)이 필요하므로 오버헤드가 크다
  • 멀티 스레드는 하나의 프로세스 내에서 여러 스레드를 실행하는 방식이며,
    메모리를 공유하여 효율적이지만 동기화 문제가 발생할 수 있다

 

유저 스레드  VS  커널 스레드

"유저 스레드가 무대 위에서 공연하는 배우라면, 커널 스레드는 무대 뒤에서 일하는 스태프다."

우리가 극장에서 연극을 볼 때, 배우들은 무대 위에서 공연을 펼치고 있지만,
무대 뒤에서는 조명팀, 음향팀, 무대 감독 등이 공연이 원활하게 진행되도록 지원하고 있다

유저 스레드(User Thread) = 배우
→ 애플리케이션이 직접 관리하는 스레드
→ 운영체제의 개입 없이 실행되며, 성능적으로 유리하지만 시스템적인 보호가 부족함
→ 배우(유저 스레드)가 공연 중 쓰러지면, 무대 뒤(커널 스레드)의 지원 없이는 정상적으로 공연을 마치기 어려움
즉, 가볍고, 성능적으로 유리하지만, 시스템 콜을 직접 수행할 수 없고, 커널의 지원이 필요

커널 스레드(Kernel Thread) = 무대 뒤 스태프
→ 운영체제가 직접 관리하는 스레드
→ 배우(유저 스레드)의 작업을 지원하며, 스케줄링, 리소스 관리 등의 역할을 수행
→ 무대 뒤에서 동작하므로, 운영체제의 개입이 필요하고 컨텍스트 스위칭 비용이 발생할 수 있음
즉, 유저 스레드의 작업을 처리하며, 시스템 콜을 수행할 수 있음

👉 즉, 유저 스레드는 애플리케이션이 직접 다루는 스레드이고, 커널 스레드는 운영체제가 관리하는 스레드다.
     유저 스레드는 커널 스레드 없이는 실행될 수 없으며, 커널 스레드는 유저 스레드의 동작을 지원한다.

 


✅  스레드의 장점

👍🏻  병렬 처리 (Parallel Processing)

“여러 명이 함께 요리하면 음식이 더 빨리 완성된다”

싱글 스레드는 마치 1명이 요리를 하는 것과 같다
한 사람이 재료 손질 → 조리 → 플레이팅을 순차적으로 해야 해서 주문이 밀렸을 때, 시간이 오래 걸린다

하지만 여러 사람이 역할을 나눠 병렬로 작업하면 요리 속도가 훨씬 빨라지듯,
멀티 스레드를 활용하면 여러 작업을 동시에 처리하여 실행 속도를 향상시킬 수 있다


📌 예시

  • 웹 서버에서 여러 사용자의 요청을 동시에 처리할 때
  • 동영상 편집 소프트웨어에서 여러 필터 효과를 동시에 적용할 때
  • 게임 엔진에서 물리 연산, 렌더링, AI 계산을 병렬로 수행할 때

🟢 [1부터 10만까지 더하는 연산] 싱글 스레드 VS 멀티 스레드

public class MultiThreadSumExample {
    public static void main(String[] args) throws InterruptedException {
        int start = 1;
        int mid = 50_000;
        int end = 100_000;

        // 🟢 1. 싱글 스레드로 실행
        long startTime = System.nanoTime();
        long singleThreadSum = sumRange(start, end);
        long singleThreadTime = System.nanoTime() - startTime;

        System.out.println("Single Thread Sum: " + singleThreadSum);
        System.out.println("Single Thread Time: " + singleThreadTime / 1_000_000.0 + " ms");

        // 🟢 2. 멀티 스레드로 실행
        SumThread thread1 = new SumThread(start, mid);
        SumThread thread2 = new SumThread(mid + 1, end);

        startTime = System.nanoTime();
        thread1.start();
        thread2.start();
        thread1.join(); // Thread-1이 끝날 때까지 대기
        thread2.join(); // Thread-2가 끝날 때까지 대기
        long multiThreadTime = System.nanoTime() - startTime;

        long multiThreadSum = thread1.getResult() + thread2.getResult();
        System.out.println("Multi Thread Sum: " + multiThreadSum);
        System.out.println("Multi Thread Time: " + multiThreadTime / 1_000_000.0 + " ms");
    }

    // 🟢 싱글 스레드 합 계산 함수
    public static long sumRange(int start, int end) {
        long sum = 0;
        for (int i = start; i <= end; i++) {
            sum += i;
        }
        return sum;
    }
}

// 🟢 멀티 스레드용 계산 클래스
class SumThread extends Thread {
    private final int start, end;
    private long result;

    public SumThread(int start, int end) {
        this.start = start;
        this.end = end;
    }

    public void run() {
        result = MultiThreadSumExample.sumRange(start, end);
    }

    public long getResult() {
        return result;
    }
}
Single Thread Sum: 5000050000
Single Thread Time: 3.2 ms

Multi Thread Sum: 5000050000
Multi Thread Time: 2.1 ms
  • 1부터 10만까지 덧셈을 단일 스레드로 연산한 시간: 3.2 ms
  • 1부터 5만까지 덧셈, 5만1부터 10만까지 덧셈을 멀티 스레드로 동시에 연산하여 합산한 시간: 2.1 ms
    • 위 결과와 같이 스레드 생성 비용도 존재하며 정확히 2배의 효율은 아니나, 처리 시간 약 1.5배 가량 차이가 발생했다

 

👍🏻  자원 공유 & 빠른 컨텍스트 스위칭

한 집에서 형제들이 냉장고를 공유하면, 효율적으로 식재료를 공유할 수 있다”

멀티 스레드는 같은 프로세스의 메모리 공간(코드, 힙, 데이터 영역)을 공유하기 때문에,
서로 정보를 주고받거나 자원을 활용할 때 효율적이다

만약 여러 개의 프로세스를 사용했다면,
프로세스 간 통신(IPC, Inter-Process Communication) 비용이 발생하지만,
스레드는 동일한 메모리 공간을 사용하기 때문에 데이터 공유가 쉽고 빠르다

또한, 컨텍스트 스위칭 비용도 프로세스보다 적게 든다
WHY?
프로세스 간 전환할 때는 CPU가 전체 메모리 맵을 변경 해야 하지만
,

스레드 간 전환은 스택과 레지스터 정보만 바꾸면 되므로 오버헤드가 적다

📌 예시

  • 웹 브라우저에서 여러 개의 탭을 띄워 놓아도 빠르게 전환 가능
  • 멀티플레이어 온라인 게임에서 여러 캐릭터가 같은 맵을 공유하며 동시에 활동 가능
public class MultiThread {
    private static final int sharedValue = 100; // 공유 자원 (힙 영역)

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> System.out.println("Thread 1: sharedValue = " + sharedValue));
        Thread thread2 = new Thread(() -> System.out.println("Thread 2: sharedValue = " + sharedValue));

        thread1.start();
        thread2.start();
    }
}
  • 위와 같이 여러 스레드에 공유될 수 있는 자원(sharedValue)을 활용하여 효율적으로 정보를 주고받을 수 있다 

 

❌  스레드의 단점

👎🏻  동기화 문제 (Synchronization Issues)

“여러 명이 한 노트에 동시에 글을 쓰면, 글자가 엉망이 된다.”

스레드는 메모리를 공유하기 때문에 
동시에 같은 데이터를 수정하면 데이터 충돌(Race Condition)이 발생할 수 있다
(당연히 자원 공유에 따른 장점도 크지만)

예를 들어, 두 개의 스레드가 은행 계좌의 잔액을 수정한다고 가정하면,
스레드 A가 잔액을 읽고 100원을 추가하려는 순간, 스레드 B가 같은 잔액을 읽고 200원을 추가한다면?
결과적으로 총 300원이 추가되는게 아닌, 100원이나 200원만 추가되어 예상치 못한 결과(데이터 불일치)가 발생할 수 있다

이를 방지하려면 synchronized, Lock, Atomic 클래스 활용과 같은 동기화 기법을 사용해야 하지만,
동기화를 잘못하면 데이터 불일치 문제가 해결이 안 되거나, 성능 저하가 발생할 수도 있다

 

📌 예시

  • 멀티스레드 환경에서 하나의 파일을 동시에 수정하는 경우
  • 은행 시스템에서 여러 사용자가 같은 계좌의 잔액을 수정하는 경우
public class RaceCondition {
    private static int count = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> count++);
        Thread t2 = new Thread(() -> count++);
        t1.start();
        t2.start();

        System.out.println(count); // 예상과 다를 수 있음 (Race Condition)
    }
}
  • `count++`는 읽기 → 증가 → 쓰기 단계로 나뉘는데, 두 스레드가 동시에 접근하면 값이 꼬일 수 있다
    • 즉, 예상한 값(2)이 나오지 않을 수도 있음

 

👎🏻 데드락 (Deadlock)

“서로 길을 양보하지 않는 두 대의 차가 교차로에서 멈춰버린 상황”
(제발 서로 차 빼..)

위의 동시성 문제를 해결하기 위해, 멀티 스레드는 여러 개의 락(Lock)을 사용해 데이터를 보호한다
(즉, 동시성 문제가 애초에 발생되지 않은 조건이면 Lock을 걸 이유도 없기 때문에 데드락도 존재해선 안 된다)

만약 스레드 A가 자원 X를 점유한 상태에서 자원 Y를 기다리고,
스레드 B가 자원 Y를 점유한 상태에서 자원 X를 기다리면 
서로 대기만 하다가 멈춰버리는 현상(데드락)이 발생할 수 있다

 

📌 예시

  • ATM 시스템에서 한 사용자가 출금을 시도하는 동시에, 다른 사용자가 계좌 이체를 시도할 경우
  • 교착 상태에 빠진 철도 시스템 (서로 반대 방향으로 진행하는 기차가 교차로에서 멈춰 있음)
public class DeadlockExample {
    static final Object A = new Object();
    static final Object B = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> { synchronized (A) { synchronized (B) {} } });
        Thread t2 = new Thread(() -> { synchronized (B) { synchronized (A) {} } });
         t1.start();
         t2.start();
         t1.join(); //t2에서 Object B에 대해 Lock을 걸어둬서 대기 중
         t2.join(); //t1에서 Object A에 대해 Lock을 걸어둬서 대기 중, 사실상 바로 위 t1.join(); 에서 이미 main 메소드 멈춤
    }
}

 

  • 위와 같이 두 스레드가 서로 필요한 동기화 객체에 Lock을 걸어두고 있어서 데드락에 빠짐
    • 위 예시는 굳이 데드락을 만들기 위한 예시이며,
      근본적으로 Lock을 거는 이유는, 공유하는 자원에 대한 동시성 문제를 방지하고자 함이다

💡 해결 방법

  • 락을 요청하는 순서를 정해준다
  • 타임아웃을 설정하여 일정 시간 후 락을 해제한다

 

👎🏻 컨텍스트 스위칭 비용 (Context Switching Overhead)

“한 사람이 여러 개의 일을 번갈아가며 하면 집중력이 떨어진다.”

CPU는 한 번에 하나의 스레드만 실행할 수 있다

멀티 스레드를 사용할 때, CPU는 빠르게 스레드를 번갈아 실행하는데,
이때 각 스레드의 상태(레지스터, 스택 정보 등)를 저장하고 복원하는 작업(컨텍스트 스위칭)이 필요하다

컨텍스트 스위칭이 잦아지면 CPU가 실질적으로 연산하는 시간보다 문맥 전환하는 시간이 많아질 수 있기 때문에,
이 경우 성능이 오히려 저하될 수도 있다
  • 컨텍스트 스위칭 문제에 대해 명확히 이해하기 위해서는, 실질적인 연산을 담당하는 CPU를 이해해야 한다 

📌 예시

  • 너무 많은 스레드를 생성하면 오히려 성능이 떨어지는 경우
  • 웹 서버에서 트래픽이 많아질 때, 스레드가 과부하 상태에 빠지는 현상

💡 해결 방법

  • 스레드 풀(Thread Pool) 사용 → 불필요한 스레드 생성을 줄이고 재사용
  • 최적의 스레드 개수를 유지 → CPU 코어 수에 맞게 적절한 개수의 스레드 운영

 

🤞🏻 스레드 장단점 정리

장점 단점
✅ 병렬 처리를 통해 성능 향상 가능 동기화(동시성) 문제로 인해 데이터 충돌 가능
동일한 메모리 공간을 공유하여 자원 활용 효율적 ❌ 데드락(교착 상태) 발생 가능
✅ 컨텍스트 스위칭 비용이 프로세스보다 적음 ❌ 컨텍스트 스위칭 비용이 누적되면 성능 저하

 

📌 결론:

  • 멀티 스레드는 성능 최적화에 강력한 도구지만, 무분별하게 사용하면 오히려 성능 저하를 초래할 수 있다
  • 따라서 적절한 동기화 기법과 최적의 스레드 개수 조절이 중요하다

 

🏗 JVM에서의 스레드

1️⃣ JVM의 기본 스레드 구조

"공장 속 작업자들"
JVM은 하나의 공장이고, 이 공장에서 여러 작업자(스레드)가 동시에 일을 한다고 생각하면 된다

이 공장에는 기본적으로 운영에 필요한 필수 작업자들(시스템 스레드)
사용자가 직접 고용한 일반 작업자들(애플리케이션 스레드)이 있다
  • 메인 스레드 (Main Thread) 🏗️
    → 프로그램이 실행될 때 자동으로 생성되는 스레드로, 메인 작업자가 업무를 시작하는 역할
  • GC 스레드 (Garbage Collector Thread) 🗑️
    → 불필요한 자원을 청소하는 청소부 역할
  • JIT 컴파일러 스레드 (Just-In-Time Compiler Thread)
    → 성능을 최적화하기 위해 코드를 빠르게 재구성하는 모니터링 엔지니어
  • Finalizer 스레드 ☠️
    → 더 이상 사용되지 않는 객체들의 마지막 유언(?)을 처리하는 스레드

  • 이처럼 JVM은 여러 개의 시스템 스레드를 내부적으로 운영하면서, 사용자 애플리케이션의 멀티 스레드도 관리하게 된다

 

2️⃣ JVM 메모리 모델과 스레드

🔍 JVM 메모리 모델(JMM, Java Memory Model)이란?

JVM은 여러 스레드가 동시에 실행될 때 데이터가 어디에 저장되고, 어떻게 공유되는지를 관리해야 한다
이를 정의한 것이 JMM(Java Memory Model)이고, 스레드 간 일관성(Consistency)을 보장하는 규칙을 제공한다
"공용 창고  VS  개인 사무실"
  • Metaspace (메타스페이스) 📚 → 공장의 운영 매뉴얼 보관소 (클래스 정보 저장)

 

  • Heap (힙 영역) 🏢 → 모든 작업자가 공유하는 창고 (객체 저장)

  • Stack (스택 영역) 🗄️각 작업자가 쓰는 개인 사무실 (지역 변수, 메서드 호출 정보)

💡 중요한 점!

  • 여러 스레드가 동시에 힙(Heap) 영역의 같은 데이터를 수정하려 하면 경합(Race Condition)이 발생할 수 있음
  • 반면 각 스레드의 스택(Stack)은 독립적이기 때문에 다른 스레드와 충돌할 일이 없음

 

3️⃣ 스레드 관련 내부 메커니즘 (GC 스레드, JIT 컴파일러 스레드 등)

  • JVM 내부에는 다양한 백그라운드 스레드가 존재하는데, 그중 중요한 역할을 하는 것들을 살펴본다

🔄 GC (Garbage Collector) 스레드 🗑️

"공장에서 쓰레기를 치우는 청소부"
  • 역할: 사용되지 않는 객체를 자동으로 정리
  • 특징:
    • Stop-The-World(STW)GC가 작동할 때 모든 애플리케이션 스레드가 멈춘다
    • 다양한 GC 알고리즘이 존재 (Serial, Parallel, G1, ZGC 등)

 

JIT (Just-In-Time) 컴파일러 스레드

"공장에서 반복적인 업무를 자동화하는 로봇"
  • 역할: 실행 중인 바이트코드를 프로파일링 하여 네이티브 코드로 변환하여 속도를 최적화
  • 특징:
    • 실행 빈도가 높은 코드만 즉석에서 최적화하여 변환 (HotSpot 방식)
    • C1, C2 컴파일러 → C1은 빠른 컴파일, C2는 최적화된 컴파일

 

🧪 Finalizer 스레드

"퇴사하는 직원이 떠나기 전에 인수인계를 마치는 것"
  • 역할: 객체가 메모리에서 제거될 때 정리 작업 수행
  • 특징:
    • finalize() 메서드를 호출하는 역할 (하지만 거의 사용되지 않음)
    • GC보다 실행 타이밍이 보장되지 않아서 신뢰성이 낮음

 

✅ JVM 환경에서의 스레드 V1 정리

  • 이번 글에서 스레드에 대한 기본적인 이해도를 높이고,
    장단점에 대해 정리하면서 어떠한 문제를 고려해서 스레드를 활용해야 하는지 정리가 되었다
    • 대부분 멀티 스레드의 장점을 제대로 활용하지 못했을 때가 문제였다
  • 또한, JVM이 애플리케이션의 성능을 최적화하고 메모리 관리, 코드 실행 속도 향상 등을 담당할 수 있었던 것은
    여러 개의 백그라운드 스레드를 운영했기 때문이었다는 것을 이해하게 되었다

  • 따라서 이러한 자바 메모리 모델부터(JMM), JVM 내부의 스레드 동작 원리를 이해하고 있어야 한다

 

멀티 스레드를 활용할 때 무엇을 어떻게 고려해야 하는지 정리하는 내용은 다음 글로 정리한다

=> JVM 환경에서의 스레드 v2 [Lock]

 

JVM 환경에서의 스레드 v2 [Lock] (작성 중..)

개요S/W 개발자로서 성능 최적화의 핵심 요소인 스레드에 대해 정리한다JVM 환경에서의 스레드 v1 [스레드에 대한 이해]의 후속 글로, 이 문서에서는 주로 Lock에 대해 다룬다   JVM 환경에서의 스

pablo7.tistory.com