기본 개념
멀티스레드 기본 개념
왜 쓰는지
단일 스레드 프로그램은: - I/O 대기 중 전체 프로그램이 멈춤 (UI 응답 불가) - 멀티코어 CPU를 활용하지 못함 - 동시에 여러 작업을 할 수 없음
핵심: 멀티스레드는 하나의 프로세스 내에서 여러 작업을 동시에 수행하는 방식입니다.
어떻게 쓰는지
단일 스레드 vs 멀티스레드
// 1️⃣ 단일 스레드: 순차 실행
System.out.println("작업 1 시작");
Thread.sleep(2000); // 2초 대기
System.out.println("작업 1 완료");
System.out.println("작업 2 시작");
Thread.sleep(2000); // 2초 대기
System.out.println("작업 2 완료");
// 전체 실행 시간: ~4초
// 2️⃣ 멀티스레드: 동시 실행
new Thread(() -> {
System.out.println("작업 1 시작");
Thread.sleep(2000);
System.out.println("작업 1 완료");
}).start();
new Thread(() -> {
System.out.println("작업 2 시작");
Thread.sleep(2000);
System.out.println("작업 2 완료");
}).start();
// 전체 실행 시간: ~2초 (동시 진행)
프로세스 vs 스레드 메모리 구조
┌─────────────────────────────────────────┐
│ 프로세스 1 │
├─────────────────────────────────────────┤
│ ┌─ 공유 메모리 ──────────────────────┐ │
│ │ Code Section (메서드 코드) │ │
│ │ Data Section (정적 변수) │ │
│ │ Heap (객체, 동적 할당) │ │
│ └────────────────────────────────────┘ │
│ │
│ ┌─ 스레드 1 스택 ┐ ┌─ 스레드 2 스택┐ │
│ │ 지역 변수 │ │ 지역 변수 │ │
│ │ 반환 주소 │ │ 반환 주소 │ │
│ └────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────┘
프로세스 2는 메모리 독립
언제 쓰는지
| 상황 | 선택 | 이유 |
|---|---|---|
| I/O 대기 많음 | ✅ 멀티스레드 | 대기 중 다른 작업 진행 |
| UI 응답성 | ✅ 멀티스레드 | 메인 스레드 블로킹 방지 |
| 멀티코어 활용 | ✅ 멀티스레드 | 병렬 처리 |
| 네트워크 서버 | ✅ 멀티스레드 | 여러 클라이언트 동시 처리 |
| 간단한 순차 작업 | ❌ 단일 스레드 | 오버헤드 제거 |
| CPU 집약적 | ⚠️ 멀티스레드 | 오버헤드로 인해 느릴 수 있음 |
장점
| 장점 | 설명 |
|---|---|
| 응답성 | I/O 대기 중 다른 작업 수행 |
| 리소스 활용 | 멀티코어 CPU 활용 |
| 공유 | 프로세스 내 스레드들이 메모리 공유 |
| 간편함 | 프로세스 간 통신(IPC)보다 간단 |
단점
| 단점 | 설명 |
|---|---|
| 복잡성 | 동시성 제어 어려움 |
| 동기화 필요 | Race condition 방지 |
| 디버깅 어려움 | 재현 불가능한 버그 발생 가능 |
| 메모리 오버헤드 | 각 스레드마다 스택 필요 |
| 컨텍스트 스위칭 | CPU 전환 비용 발생 |
특징
1. 멀티태스킹 vs 멀티프로세싱
┌──────────────────────────┐
│ 멀티태스킹 │
├──────────────────────────┤
│ • 소프트웨어 관점 │
│ • 단일 CPU 코어가 │
│ 여러 작업을 시간대별로 │
│ 번갈아 실행 │
│ • Java: 스레드 기반 │
└──────────────────────────┘
┌──────────────────────────┐
│ 멀티프로세싱 │
├──────────────────────────┤
│ • 하드웨어 관점 │
│ • 여러 CPU 코어가 │
│ 진짜 동시에 실행 │
│ • Java: parallelStream │
└──────────────────────────┘
2. 프로세스 (Process)
public class ProcessExample {
public static void main(String[] args) throws Exception {
// 각 프로세스는 독립적
// 프로세스 1: 현재 Java 프로세스
System.out.println("프로세스 1 ID: " + ProcessHandle.current().pid());
// 프로세스 2: 별도 프로그램 실행
Process process = new ProcessBuilder("cmd", "/c", "dir").start();
process.waitFor();
// 두 프로세스는 메모리 독립, 한쪽 오류가 다른쪽 영향 없음
}
}
특징: - 각각 독립적인 메모리 (Code, Data, Heap, Stack) - 한 프로세스 오류가 다른 프로세스 영향 없음 - 프로세스 간 통신(IPC) 복잡함 - 생성/제거 비용 큼
3. 스레드 (Thread) 메모리 구조
public class ThreadMemoryExample {
// 공유 메모리 (모든 스레드가 접근 가능)
private static int sharedValue = 0;
public static void main(String[] args) {
// 스레드 1
new Thread(() -> {
// 지역 변수 (스레드 1 스택에만 존재)
int localValue = 10;
System.out.println("스레드 1: " + localValue);
sharedValue++; // 공유 메모리 접근
}).start();
// 스레드 2
new Thread(() -> {
// 지역 변수 (스레드 2 스택에만 존재)
int localValue = 20;
System.out.println("스레드 2: " + localValue);
sharedValue++; // 공유 메모리 접근
}).start();
// 스레드 1의 localValue는 스레드 2에서 접근 불가
// 하지만 sharedValue는 둘 다 접근 가능 (위험!)
}
}
메모리 구성: - 공유: Code, Data, Heap - 개별: Stack (각 스레드마다 별도 스택)
4. 스케줄링 (Scheduling)
CPU 시간축:
[Thread 1 실행 2ms] → [Thread 2 실행 2ms] → [Thread 3 실행 2ms] → [Thread 1 재개 2ms] ...
OS 스케줄러가 결정:
- 어느 스레드를 실행할지
- 얼마나 오래 실행할지 (Time Slice)
- 우선순위 (Priority)
Java에서의 우선순위:
Thread t1 = new Thread(() -> System.out.println("High Priority"));
t1.setPriority(Thread.MAX_PRIORITY); // 10 (높음)
Thread t2 = new Thread(() -> System.out.println("Normal Priority"));
t2.setPriority(Thread.NORM_PRIORITY); // 5 (보통)
Thread t3 = new Thread(() -> System.out.println("Low Priority"));
t3.setPriority(Thread.MIN_PRIORITY); // 1 (낮음)
// 하지만 우선순위는 보증되지 않음 (OS에 따라 다름)
5. 컨텍스트 스위칭 (Context Switching)
스레드 A 상태 저장:
- CPU 레지스터 값
- Program Counter (다음 명령어 위치)
- 메모리 맵 정보
↓
[스레드 A 중단]
↓
[스레드 B 시작]
↓
스레드 B 상태 복원:
- CPU 레지스터 값 로드
- Program Counter 로드
↓
스레드 B 재개
이 과정에서 CPU 사이클 낭비 (오버헤드)
public class ContextSwitchingCost {
public static void main(String[] args) throws Exception {
// 단일 스레드: 빠름
long start = System.nanoTime();
for (int i = 0; i < 100_000_000; i++) {
// 계산
}
long single = System.nanoTime() - start;
// 멀티 스레드: 컨텍스트 스위칭 오버헤드
start = System.nanoTime();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50_000_000; i++) {}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50_000_000; i++) {}
});
t1.start();
t2.start();
t1.join();
t2.join();
long multi = System.nanoTime() - start;
// CPU 집약적 작업에서는 multi가 더 느릴 수 있음
System.out.println("단일: " + single);
System.out.println("멀티: " + multi);
}
}
주의할 점
⚠️ 동기화 문제: Race Condition
private static int counter = 0;
// ❌ 위험: 여러 스레드가 동시에 접근
for (int i = 0; i < 100; i++) {
new Thread(() -> counter++).start();
}
Thread.sleep(1000);
System.out.println(counter); // 100이 아닐 수 있음!
// 이유: counter++는 실제로는 3가지 단계
// 1. 메모리에서 값 읽기 (읽음: 5)
// 2. 1 더하기 (계산: 6)
// 3. 메모리에 쓰기 (쓰기: 6)
// 문제: 스레드 A가 읽는 중에 스레드 B가 읽으면?
// 결과: 같은 값에서 둘 다 1만 증가
❌ 데몬 스레드 주의
// ❌ 위험: 메인 스레드 종료 시 데몬도 즉시 종료
Thread daemon = new Thread(() -> {
try {
Thread.sleep(5000);
System.out.println("작업 완료"); // 출력 안될 수 있음
} catch (InterruptedException e) {
System.out.println("중단됨");
}
});
daemon.setDaemon(true);
daemon.start();
System.out.println("메인 끝");
// 메인 끝이 출력되고 즉시 프로그램 종료
// ✅ 올바른 방식: 데몬이 아닌 일반 스레드 사용하거나 join() 호출
Thread normal = new Thread(() -> {
try {
Thread.sleep(5000);
System.out.println("작업 완료");
} catch (InterruptedException e) {}
});
normal.start();
normal.join(); // 스레드 끝날 때까지 대기
System.out.println("메인 끝");
⚠️ 너무 많은 스레드 생성
// ❌ 위험: 스레드 메모리 폭발
for (int i = 0; i < 1_000_000; i++) {
new Thread(() -> {
Thread.sleep(10000);
}).start();
}
// OutOfMemoryError 발생 (각 스레드 스택: ~1MB)
// ✅ 올바른 방식: 스레드 풀 사용
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
// 작업
});
}
executor.shutdown();
정리
| 항목 | 설명 |
|---|---|
| 프로세스 | OS 관리, 독립 메모리, 무겁고 느림 |
| 스레드 | 프로세스 내, 메모리 공유, 가볍고 빠름 |
| 공유 | Code, Data, Heap (위험!) |
| 개별 | Stack (안전) |
| 스케줄링 | OS가 CPU 할당 결정 |
| 컨텍스트 스위칭 | 상태 저장/복원 (오버헤드) |
다음 단계: - Thread1.md — 스레드 생성 방법 - Thread2.md — 스레드 상태 관리 - synchronized.md — 동기화