CPU의 핵심구성 요소
- Core : CPU의 실제 연산을 담당하는 핵심 단위
- ALU (Arithmetic Logic Unit)
- 실제 산술 연산과 논리 연산을 수행하는 장치
- Control Unit (제어 장치)
- 명령어를 해석하고 데이터의 흐름을 지시하는 관리자
- Register (레지스터)
- CPU 내부에 있는 가장 빠르고 작은 메모리
- ALU의 연산을 돕기 위해 존재
- ALU (Arithmetic Logic Unit)
강력한 연산능력을 가진 CPU 하드웨어가 있지만 초기에는 이를 효율적으로 사용하기 어려웠다.
CPU의 연산이 이뤄지는 동안 I/O 작업을 기다리는 시간이 많았기 때문
이를 해결하기 위해 등장한 소프트웨어적 실행 단위가 쓰레드
쓰레드 ( Thread )
쓰레드의 개념은 프로세스 내부에 실질적으로 작업이 실행되는 최소 단위를 말한다.
먼저 쓰레드를 이해하기 위해 스레드의 생성, 스케줄링, 삭제를 관리하는 단위를 이해 해야한다.
- 관리 단위
- 유저 레벨 쓰레드
- app (JVM 등)이 직접 관리함
- 즉 개발자가 작성한 코드들은 유저 레벨의 쓰레드를 생성하게 된다.
- OS는 해당 쓰레드의 존재를 알 수 없음
- 장점
- 커널의 도움을 안 받아 생성/교체(컨텍스트 스위칭)가 빠르다.
- 단점
- 만약 쓰레드 하나가 I/O 작업을 하느라 멈추면(Blocking), 커널은 프로세스 전체가 멈춘 것으로 판단하고 다른 쓰레드들도 다 같이 멈춰버림(프로세스 블로킹)
- app (JVM 등)이 직접 관리함
- 커널 레벨 쓰레드
- OS가 직접 관리함
- 여기서 말하는 쓰레드는 Task_struct를 의미함
- Task_struct는 커널 레벨 스레드의 구현체다.
- 리눅스에서는 프로세스와 쓰레드를 엄격히 구분하지 않고 모두 Task_struct로 관리한다.
- 이를 LWP ( Light Weight Process )라고 부르기도 한다.
- 장점
- 쓰레드가 하나 멈춰도 운영체제가 다른 쓰레드를 사용하여 일을 계속해서 진행한다. 멀티 코어 활용에 필수로 사용된다.
- 단점
- 커널이 일일이 개입하므로 유저 레벨보다는 전환 속도가 느리다.
- OS가 직접 관리함
- 유저 레벨 쓰레드
현재 우리가 쓰는 대부분의 현대적인 환경(Java, Windows, Linux 등)은 운영체제가 쓰레드를 관리하는 커널 레벨 쓰레드 모델을 기반으로 동작한다.
운영체제(OS)
운영체제는 다음과 같은 계층이 존재한다.
- OS (운영체제)
- 커널 (Kernel ) <- OS의 핵심 엔진
- 프로세스/쓰레드 관리
- 메모리 관리
- I/O 관리
- 커널 (Kernel ) <- OS의 핵심 엔진
쓰레드를 만들었으면 이제 누구를 먼저 실행 시킬지 정해야한다. 이때 OS는 다음과 같은 방식으로 지정하게 된다.
- 스케줄러 ( Scheduler )
- 순서를 정해주는 OS의 관리자
- 컨텍스트 스위칭 ( Context Switching )
- 순서가 바뀔 때, 이전 쓰레드의 진행 상황(레지스터 값, PC 등)을 기록하고 새 쓰레드의 정보를 로드하는 작업
- 즉, 유저 레벨 쓰레드가 커널 레벨 쓰레드를 점유하여 CPU에게 작업을 수행되는 것
- 전환 비용이 발생하므로 스위칭이 너무 잦으면 오히려 성능 저하가 발생
쓰레드 매핑 모델
커널에서는 다음과 같이 커널 레벨 스레드와 유저 레벨 스레드로 구분한다.
- One-to-One model
- 유저 레벨 쓰레드와 커널 레벨 쓰레드가 1 : 1로 매핑된 모델
- Java 기본 쓰레드 방식임
- 커널이 직접 관리하므로 블로킹 문제가 발생하지 않음
- 유저 레벨 쓰레드가 많아질수록 커널 레벨 쓰레드도 많아져 비용이 증가한다.
- Many-to-One model
- 유저 레벨 쓰레드 여러개에 커널 레벨 쓰레드 하나가 매핑된 모델
- 유저 레벨 쓰레드가 많아도 커널 레벨 쓰레드는 하나만 사용되기 때문에 컨텍스트 스위칭 비용이 적음
- 유저 레벨 쓰레드 중 하나가 블로킹되면 프로세스 전체가 블로킹되는 문제가 여전히 존재
- Many-to-Many model
- 유저 레벨 쓰레드 여러개와 커널 레벨 쓰레드 여러개가 매핑된 모델
- 1 : 1모델과 N : 1모델의 장점을 합친 방식
- Java 21 Virtual Thread가 이 방식을 채택했다.
쓰레드 스케쥴러
커널 레벨 쓰레드가 여러개이니 스케쥴링을 통해 CPU 코어에 할당되어 실행.
스케쥴러는 실행 대기 중인 쓰레드를 Ready Queue에 줄을 세워두고 알고리즘에 따라 디스패처를 사용해 선택된 커널 레벨 쓰레드를 실제 CPU 레지스터에 올린다.
실제로 CPU 코어를 점유하는 주체는 커널 레벨 쓰레드이며, 유저 레벨 쓰레드는 이 커널 레벨 쓰레드에 매핑되어 실행 권한을 위임 받는 구조다.
Ready Queue
- CPU 할당을 기다리는 쓰레드들이 대기하는 공간
- 스케쥴러가 여기서 다음 실행할 쓰레드를 선택
스케쥴러가 사용하는 알고리즘은 다음과 같다
- FCFS
- 먼저 온 쓰레드 먼저 실행
- Round Robin
- 일정 시간씩 돌아가며 할당 (현대 OS 기본 방식)
- 이때 할당된 시간을 Time Quantum 또는 Time Slice라고 부른다.
- 이 시간이 너무 길면 반응성이 떨어지고, 너무 짧으면 컨텍스트 스위칭 오버헤드가 커진다.
- Priority Scheduling
- 우선순위 높은 쓰레드 먼저 실행
- Multi-Level Feedback Queue ( MLFQ )
- 현대 Windows나 Linux에서 실제로 가장 많이 쓰이는 복합 알고리즘. (우선순위 + 라운드 로빈의 장점을 합친 방식)
쓰레드 동작 이해
최신 리눅스 커널 및 일반적인 배포판의 설정에서는 다음과 같은 범위를 가진다.
- 약 6KB ~ 9KB 사이
- 과거 32-bit 시절에는 1.5KB ~ 2KB 내외였으나, 기능이 추가되면서 매우 비대해졌다.
Task_struct는 커널이 프로세스/쓰레드에 대해 알아야 할 모든 정보를 담고 있기 때문에 크다.
들어 있는 정보를 요약하면 다음과 같다.
- 상태 정보 -> 실행 상태, 우선 순위, PID, TGID
- 스케쥴링 -> 스케쥴링 엔티티, 런큐 정보
- 메모리 관리 -> mm_struct 포인터 ( 가상 메모리 맵 )
- 파일 시스템 -> files_struct (열린 파일 디스크립터 테이블)
- 시그널 처리 -> 시그널 핸들러, 펜딩된 시그널 목록
- 보통 및 통계 -> CPU 사용 기간, I/O 통계, 네임스페이스 정보
- 보안 -> 커널 기능 (Capabilities), 자격 증명(Credentials)
struct task_struct {
/* 1. 태스크 상태 */
unsigned int __state; /* -1: 실행 불가, 0: 실행 가능, >0: 정지 등 */
void *stack; /* 커널 모드 전용 스택. CPU의 SP(Stack Pointer)가 여기를 가리키게 됨 */
/* 2. 스케줄링 */
int prio; /* 동적 우선순위 */
int static_prio; /* Nice 값에 의한 정적 우선순위 */
struct sched_entity se; /* CFS 스케줄러가 사용하는 '대기 명단' 정보 (vruntime 포함) */
unsigned int policy; /* FIFO, RR, Normal 등 어떤 규칙으로 스케줄링할지 결정 */
/* 3. 메모리 관리 */
struct mm_struct *mm; /* 가상 메모리 관리도구. 유저 스레드끼리는 이 포인터 값이 같음(공유) */
struct mm_struct *active_mm; /* 커널 스레드의 경우 임시로 빌려 쓰는 주소 공간 */
/* 4. 식별자 */
pid_t pid; /* 커널 내부의 고유 ID (사실상의 Thread ID) */
pid_t tgid; /* Thread Group ID (유저가 알고 있는 Process ID) */
/* 5. 가족 관계 */
struct task_struct __rcu *parent; /* 부모 프로세스 포인터 */
struct list_head children; /* 내가 만든 자식들의 리스트 */
struct list_head sibling; /* 같은 부모를 둔 형제들 리스트 */
/* 6. 리소스 */
struct files_struct *files; /* 열려 있는 파일들 (File Descriptor Table) */
struct fs_struct *fs; /* 현재 작업 디렉토리, 루트 경로 등 파일 시스템 정보 */
/* 7. 하드웨어 의존적 상태 */
struct thread_struct thread; /* 실제 CPU 레지스터(PC, SP 등) 값들이 복사되는 장소 */
/* 8. 통계 및 기타 */
u64 utime; /* 유저 모드에서 보낸 시간 */
u64 stime; /* 커널 모드에서 보낸 시간 */
u64 nvcsw; /* 자발적 문맥 교환 횟수 (I/O 기다리느라 양보한 횟수) */
u64 nivcsw; /* 비자발적 문맥 교환 횟수 (스케줄러에 의해 강제로 쫓겨난 횟수) */
};
Stack(1번)과 Thread(7번) 필드
- CPU에 적재하는 주체
- Stack은 메모리공간을 가리키고, Thread구조체는 CPU 레지스터의 스냅샷을 보관
- 컨텍스트 스위칭이 일어나면 Thread의 값을 CPU 레지스터로 그대로 복사하게 된다. (작업 진행도를 알리는 역할)
- 컨텍스트 스위칭 시 CPU의 PC(Program Counter)와 SP(Stack Pointer)가 이 스냅샷을 기반으로 복구된다.
- OS가 관리하는 자원들에 접근할 권한은 오직 커널 레벨 쓰레드만 가능하며 CPU코어에 접근하는 모든 쓰레드는 커널 모드 Stack으로 전환되어야 하드웨어 제어가 가능하다.
- 유저 모드에서 커널 모드로 진입(System Call 등)할 때, 안전한 실행을 하기 위해 커널 전용 스택 공간이 필요하다.
mm_struct *mm
- 프로세스와 쓰레드를 구분하는 역할
- 새로운 작업을 만들 때 mm을 새로 할당하면 새로운 프로세스로 보며, 부모의 mm 포인터를 그대로 복사해서 사용한다면 같은 프로세스에 존재하는 쓰레드로 구분한다.
- 리눅스 커널이 엄격하게 구분하지 않는 이유는 해당 포인터 때문.
tgid와 pid
- 리눅스 커널은 내부적으로 모든 것을 쓰레드 단위(pid)로 관리한다.
- tgid라는 필드를 두어 같은 프로세스임을 표시. 즉, 프로세스 그룹을 의미한다고 볼 수 있다.
- 또한 mm을 통해 프로세스 그룹 식별을 하지 않는 이유는 다음과 같다
- 프로세스 하나에 쓰레드는 하나가 아닌 여러개가 될 수 있으므로 mm기준으로 정리한다면 쓰레드 하나하나를 찾아야하지만, tgid를 대상으로 시그널(종료)을 보내면, 해당 그룹에 속한 모든 쓰레드(PID)들을 한꺼번에 제어가능하다.
쓰레드 풀 ( Thread Pool )
쓰레드를 매번 생성/삭제하는 비용이 크기 때문에 미리 만들어둔 쓰레드들을 재사용하는 방식을 말한다.
- 쓰레드 풀 없는 방식
- 요청이 올때마다 쓰레드 생성하고 작업 후 쓰레드 삭제
- 생성/삭제하는 비용이 계속 발생한다.
- 쓰레드 풀 있는 방식
- 대기 -> 할당 -> 수행 -> 반납으로 진행된다
- 대기 (Idle) -> 생성된 직후 Work Queue를 주시하며 대기 (CPU를 거의 점유하지 않음)
- 할당 (Assign) -> 새로운 요청(Task)이 큐에 들어오면, 쓰레드 하나를 작업에 사용
- 수행 (Execute) -> 할당된 작업 처리
- 반납 (Return) -> 작업이 끝나면 쓰레드를 다시 작업 큐를 대기하는 상태로 돌아감
- 재사용하여 비용이 발생하는 폭이 감소된다.
- 장점
- 쓰레드 생성/삭제 비용 절약
- 쓰레드 수를 제한하여 과부하 방지
- 단점
- Pool에 남은 쓰레드가 없으면 요청 대기
- 미리 메모리를 점유한다.
- 대기 -> 할당 -> 수행 -> 반납으로 진행된다
Task_Struct와 그에 딸린 커널 스택을 미리 커널의 가상 메모리에 생성해두는 것으로 플랫폼 스레드와 그에 대응하는 쓰레드(task_struct)를 만들어 놓는 것이다.
쓰레드를 매번 새로 만드는 행위가 비싼 이유는 단순히 객체 생성 때문이 아니라 아래와 같은 커널 작업 때문
- Ring 0 진입
- 시스템 콜을 통해 유저 모드에서 커널 모드로 전환해야한다.
- 커널 메모리 할당
- 커널이 새로운 task_Struct 구조체를 위한 메모리를 할당하고 초기화한다.
- LDT/GDT 및 페이지 테이블 설정
- CPU가 이 쓰레드를 실행할 수 있도록 메모리 지도를 세팅해야함
- 스케쥴러 등록
- 커널 스케쥴러가 관리하는 실행 대기 큐에 이 쓰레드를 등록해야한다.
이러한 비용이 큰 작업을 쓰레드 풀은 단 한번 수행해서 미리 만들어 놓고, 쓰레드를 idle(대기) 상태로 유지하기 때문에 성능 이점이 있게 됨
Spring ( Tomcat )에서의 Thread Pool
properties나 yaml(yml)에서 수정이 가능하며 기본적으로 max = 200이며, 최소 유지 쓰레드는 10으로 존재한다.

쓰레드 스래싱 ( Thread Thrashing )
쓰레드가 너무 많아 실제 작업보다 컨텍스트 스위칭 비용이 더 커지는 현상을 의미함
다음과 같은 이유가 존재한다.
- 커널 가상 메모리 공간을 많이 쓰게 되고 그만큼 커널 스케쥴러가 많은 수의 메모리를 읽고 그만큼 CPU코어가 많은 데이터를 읽고 내리고 하는 행위가 많아져서 실제 연산하는것보다 스위칭에 더 많은 일을 하게 된다.
- 스위칭으로 인해 연산이 진행이 느려짐
- CPU 내부 존재하는 빠른 메모리인 L1, L2 캐시의 캐시오염(Cache Pollution)문제가 생길 수 있다.
- 쓰레드가 바뀌면 CPU는 이전 A가 채워 놓은 캐시를 다 비우고 B의 캐시를 가져와야하는데 쓰레드가 너무 많으면 데이터를 다 쓰기전에 다음 스레드로 바뀌어 RAM에서 다시 가져와야하는 문제가 발생
- 이런 문제를 캐시 적중률(Cache Hit Ratio)하락 이라고 부른다.
- task_struct와 각 쓰레드의 스택 공간이 RAM을 가득 채운다.
- RAM이 부족해지면 운영체제는 부족한 공간을 늘리기 위해 HDD/SSD(가상 메모리)를 뒤지기 시작하고, SWAP이 발생하여 디스크 I/O 작업이 더 생겨 CPU를 효율적으로 사용하기 어렵다. 이렇게 느려지는 것을 I/O Wait 수치가 높아진다고 함.
- 이런 현상을 OOM ( Out Of Memory )이라고 하며 심각한 경우 OS 강제로 프로세스를 종료시킨다.
JVM 쓰레드 관리
Java 1.2부터 가상 쓰레드( Java 19/21)가 나오기 전까지 Java 쓰레드는 OS 커널 쓰레드와 1:1로 매핑되는 네이티브 쓰레드 방식이다.
자바 객체는 커널 쓰레드를 제어하기 위한 껍데기일 뿐, 실질적인 관리는 운영체제가 담당한다.
- Green Thread (Java 1.1)
- 아주 초기에는 OS 도움 없이 JVM이 직접 쓰레드를 관리하는 유저 레벨 쓰레드 모델을 사용했다.
- 하지만 멀티 코어 CPU를 제대로 활용하지 못하는 등 한계가 많아 금방 폐기되었음
- Native Thread (Java 1.2 ~ Java 20)
- 그 이후 수십 년 동안 Java의 java.lang.Thread는 생성될 때마다 JNI(Java Native Interface)를 통해 OS 커널에 쓰레드 생성을 요청 했다.
- 즉, Java 쓰레드 1개 = OS 커널 쓰레드 1개 (1:1 매핑) 모델이였다.
- 이 때문에 쓰레드를 많이 만들게 되면 Thrashing(스레싱) 문제가 발생했던 것
- 그 이후 수십 년 동안 Java의 java.lang.Thread는 생성될 때마다 JNI(Java Native Interface)를 통해 OS 커널에 쓰레드 생성을 요청 했다.
Java 1.2 이후 java.lang.Thread는 플랫폼 쓰레드로 불린다.
흐름
new Thread().start()
- Java 객체 생성 (User Space)
- new Thread() : Java Heap 메모리에 java.lang.Thread 인스턴스 생성됨.
- 하지만 이 당시에는 커널 레벨 쓰레드가 만들어진 상태가 아니고 그냥 데이터만 담긴 자바 객체 형태를 가짐
- start() 호출과 JNI 진입
- thread.start() 호출시, 내부적으로 start0()이라는 native 메서드를 실행한다.
- JNI(Java Native Interface)를 통해 JVM의 C++레이어로 명령이 전달됨
- JVM 내 전용 객체 생성 ( C++ Level )
- JVM은 자바 쓰레드 객체와 실제 OS 쓰레드를 연결해줄 전용 C++ 객체인 JavaThread를 생성한다.
- 해당 객체는 쓰레드 상태(Running, Blocked등)를 관리하고 커널 레벨 쓰레드의 핸들을 보관한다.
- OS 시스템 콜 호출 ( System Level )
- JVM은 호스트 OS에 따라 실제 쓰레드 생성 API를 호출
- Linux : clone() 시스템 콜을 통해 새로운 LWP ( Light Weight Process ) 생성을 요청
- Windows : CreateThread() API를 호출
- JVM은 호스트 OS에 따라 실제 쓰레드 생성 API를 호출
- 커널 자원 할당 및 스케줄링 ( Kernel Space )
- OS 커널은 이 요청을 받아 task_struct 구조체를 할당하고, 커널 모드 스택을 준비
- 커널 스케쥴러의 Ready Queue에 이 쓰레드를 등록
- Entry Point 실행 및 Run() 호출
- 스케쥴러에 의해 CPU가 할당되면, 먼저 JVM 내부의 C++ 함수인 thread_entry가 실행된다.
- 이 함수가 다시 Java 영역으로 올라와서 우리가 오버라이딩한 run() 메서드를 실행한다.
1.2 이후 모델이 무거웠던 이유
- 메모리 비용
- 자바 쓰레드 하나당 보통 1MB 정도의 스택 메모리가 예약된다.
- 관리 비용
- 자바에서 쓰레드를 하나 만들 때마다 커널 모드로 진입해서 task_struct를 만들고 스케쥴러에 등록 해야하는 시스템 콜 오버헤드가 발생
'BackEnd > CS' 카테고리의 다른 글
| [CS] DBMS 개념와 동작 원리 (0) | 2026.03.24 |
|---|---|
| [CS] 자바의 영속성 (JDBC, SQLMapper, ORM) 정리 (0) | 2026.03.23 |
| [CS] Java Virtual Machine (0) | 2026.03.20 |