프로세스와 스레드
독립 메모리의 프로세스와 공유 힙의 스레드를 비교하고 시작 순서와 완료 순서가 다를 수 있음을 실행합니다.
운영체제는 실행 중인 프로그램을 프로세스로 관리하고, 프로세스 안의 실제 실행 흐름을 스레드로 스케줄링합니다.
멀티태스킹은 짧은 시간 조각을 여러 작업에 번갈아 배정해 동시에 진행되는 것처럼 보이게 합니다.
여러 코어에서는 일부 스레드가 실제 병렬로 실행될 수 있지만 실행 순서는 여전히 스케줄러와 작업 상태에 달려 있습니다.
멀티프로세싱은 둘 이상의 프로세스 또는 처리 자원이 일을 나누는 관점이며, 각 프로세스의 격리 비용과 통신 구분을 함께 고려합니다.
시작 순서와 종료 순서의 차이
start는 새 스레드를 실행 가능 상태로 등록할 뿐 완료 순서를 예약하지 않습니다.
slow는 80ms, fast는 5ms 동안 잠들도록 요청하지만 두 작업의 실제 완료 순서는 스케줄러에 따라 달라질 수 있습니다.
public final class ThreadCompletionOrderAssumption {
public static void main(String[] args) throws InterruptedException {
Thread slow = new Thread(() -> work("slow", 80), "slow-worker");
Thread fast = new Thread(() -> work("fast", 5), "fast-worker");
slow.start();
fast.start();
slow.join();
fast.join();
System.out.println("assumed=slow-fast, observed=see-above");
}
private static void work(String name, long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("done=" + name);
}
}fast가 먼저 출력될 수 있지만 어느 작업이 먼저 끝나는지는 보장하지 않습니다.
sleep 시간은 교육용으로 차이를 크게 만든 것이며 일반 코드에서 시간 지연으로 순서를 보장해서는 안 됩니다.
순서 의존성이 있다면 join, Queue, Future 같은 명시적 조정 수단을 사용합니다.
프로세스 구분과 스레드 공유 범위
프로세스마다 가상 주소 공간과 자원이 격리됩니다.
한 프로세스의 일반 객체 참조를 다른 프로세스가 그대로 읽을 수 없고 IPC가 필요합니다.
격리는 장애 전파를 줄이지만 통신과 생성 비용이 큽니다.
같은 JVM의 스레드는 힙 객체와 static 상태를 공유할 수 있고, 각자 Java 스택과 프로그램 카운터를 갖습니다. 네이티브 메서드 스택의 제공과 구현은 JVM에 달려 있습니다.
공유 힙은 빠른 협업을 가능하게 하지만 동시에 바꾸면 경합 조건이 생깁니다.
19장에서 가시성과 synchronized로 이 위험을 다룹니다.
서로 다른 프로세스의 주소 공간, 같은 JVM의 공유 힙, 스레드별 Java 실행 문맥과 지역 참조가 가리키는 객체의 범위를 비교합니다.
| 대상 | 공유·분리 범위 | 확인할 점 |
|---|---|---|
| 다른 프로세스 | 각자의 가상 주소 공간 | 일반 객체 참조를 그대로 교환하지 않음 · IPC 필요 |
| 같은 JVM의 스레드 | 힙 객체와 static 상태를 공유할 수 있음 | 공유하는 가변 객체에는 접근 규칙이 필요 |
| 스레드별 Java 실행 문맥 | Java 스택 프레임과 pc 분리 | 각 호출의 지역 변수·계산 상태를 유지 |
| 지역 변수의 객체 참조 | 참조 값은 각 프레임에 있어도 대상 객체는 같을 수 있음 | 참조가 지역 변수라는 이유로 객체가 독점되지는 않음 |
- 다른 프로세스
- 공유·분리 범위: 각자의 가상 주소 공간확인할 점: 일반 객체 참조를 그대로 교환하지 않음 · IPC 필요
- 같은 JVM의 스레드
- 공유·분리 범위: 힙 객체와
static상태를 공유할 수 있음확인할 점: 공유하는 가변 객체에는 접근 규칙이 필요 - 스레드별 Java 실행 문맥
- 공유·분리 범위: Java 스택 프레임과
pc분리확인할 점: 각 호출의 지역 변수·계산 상태를 유지 - 지역 변수의 객체 참조
- 공유·분리 범위: 참조 값은 각 프레임에 있어도 대상 객체는 같을 수 있음확인할 점: 참조가 지역 변수라는 이유로 객체가 독점되지는 않음
논리적 공유 범위를 비교한 표입니다. 이어지는 ProcessAndThreadIdentity는 같은 pid와 서로 다른 스레드 식별자를 출력하지만, 메모리 배치나 공유 힙을 직접 측정하지는 않습니다.
public final class ProcessAndThreadIdentity {
public static void main(String[] args) throws InterruptedException {
long processId = ProcessHandle.current().pid();
Runnable report =
() ->
System.out.println(
"pid="
+ processId
+ ", thread="
+ Thread.currentThread().getName()
+ ", id="
+ Thread.currentThread().threadId());
Thread left = new Thread(report, "board-left"), right = new Thread(report, "board-right");
report.run();
left.start();
right.start();
left.join();
right.join();
}
}세 출력의 pid는 같고 threadId와 이름은 다릅니다.
main도 JVM이 만든 하나의 스레드입니다.
report.run()은 main에서 일반 메서드처럼 실행되고, start()만 새 실행 흐름을 만듭니다.
CPU-bound와 I/O-bound의 대기 모양
CPU 중심 작업은 계산 시간 대부분을 RUNNABLE 상태로 쓰므로 코어 수보다 지나치게 많은 스레드는 문맥 전환만 늘릴 수 있습니다.
I/O 중심 작업은 네트워크·디스크를 기다리며 CPU를 놓는 시간이 많아 더 많은 동시 작업이 유리할 수 있습니다.
스레드 수 공식 하나를 모든 프로그램에 적용하지 않습니다.
계산/대기 비율, 코어 수, 응답 시간, 메모리, 외부 시스템 제한을 측정합니다.
Java 21+ 가상 스레드는 대기 작업의 스레드 비용을 줄이지만 CPU 코어를 늘리지 않고 공유 상태 안전성도 자동 해결하지 않습니다.
동시성과 병렬성도 구별해야 합니다.
한 코어에서 두 작업이 번갈아 진척되면 동시성하지만 같은 순간에 명령을 실행하는 병렬 상태는 아닙니다.
여러 코어가 있어도 락 하나를 놓고 기다리면 실제 병렬 구간은 작아집니다.
반대로 I/O 요청은 CPU를 동시에 쓰지 않아도 여러 응답을 겹쳐 기다리면서 전체 처리량을 높일 수 있습니다.
따라서 “스레드가 여러 개 보인다”는 관찰만으로 병렬 성능을 주장하지 않습니다.
public final class ParallelCpuWork {
public static void main(String[] args) throws InterruptedException {
Result left = new Result(), right = new Result();
Thread a = new Thread(() -> left.value = sum(1, 500_000), "range-a");
Thread b = new Thread(() -> right.value = sum(500_001, 1_000_000), "range-b");
a.start();
b.start();
a.join();
b.join();
System.out.println("sum=" + (left.value + right.value));
}
private static long sum(int start, int end) {
long result = 0;
for (int i = start; i <= end; i++) {
result += i;
}
return result;
}
private static final class Result {
long value;
}
}range-a와 range-b가 겹치지 않는 정수 범위를 각각 left.value와 right.value에 기록하고 main이 두 join 뒤 최종 합계를 읽는 구조를 비교합니다.
| 실행 흐름 | 기록 대상·계산 범위 | 완료 뒤 값 |
|---|---|---|
range-a | left.value1..500000 | 125000250000 |
range-b | right.value500001..1000000 | 375000250000 |
main | a.join(); b.join();두 결과를 읽어 합산 | sum=500000500000 |
range-a- 기록 대상·계산 범위:
left.value1..500000완료 뒤 값:125000250000 range-b- 기록 대상·계산 범위:
right.value500001..1000000완료 뒤 값:375000250000 main- 기록 대상·계산 범위:
a.join(); b.join();두 결과를 읽어 합산완료 뒤 값:sum=500000500000
두 부분합은 원문의 범위와 덧셈에서 계산한 값이며 실제 출력은 마지막 sum=500000500000 한 줄입니다. 정상 반환한 두 join 뒤에 결과를 읽습니다. 실행 시간, 코어 사용이나 속도 향상은 측정하지 않습니다.
두 join이 정상 반환한 뒤 결과를 읽으면 작업자의 계산 완료와 결과 쓰기의 가시성이 보장됩니다.
join 없이 즉시 합산하면 기본값 0을 읽을 수 있습니다.
이 코드에서 두 Result 객체를 각 스레드가 하나씩 쓰게 해 쓰기 충돌도 피했습니다.
스케줄러 상태를 결과 순서와 혼동하지 않는 이유
NEW 스레드는 아직 start되지 않았고, RUNNABLE은 실제 CPU 실행 중과 실행 대기를 함께 포함합니다.
BLOCKED는 모니터 락, WAITING·TIMED_WAITING은 다른 사건이나 시간 경과를 기다립니다.
TERMINATED는 run이 끝난 상태입니다.
우선순위는 힌트이며 정확한 실행 순서 규칙이 아닙니다.
OS와 JVM 구현에 따라 영향이 다릅니다.
사용자 기능 순서를 우선순위나 sleep에 맡기지 않습니다.
시작 순서 반례를 확인할 때도 단일 실행 로그 한 번으로 일반화하지 않습니다.
앞 예제의 서로 다른 지연은 역순 완료를 관찰하기 위한 설정이며, 특정 출력 순서를 보장하지는 않습니다.
실제 기능에서 결과 순서를 요구하면 스케줄러 우연 대신 단계별 래치, 큐의 FIFO 규칙, 이전 스레드의 join처럼 원인과 결과가 연결된 수단을 둡니다.
반대로 서로 독립인 계산은 출력 순서를 단언에서 제거하고 결과 집합과 최종 합계만 검사합니다.
운영에서 RUNNABLE 비율이 높다고 항상 건강한 것도 아닙니다.
코어 수보다 훨씬 많은 계산 스레드가 실행 가능 큐에 쌓이면 각 작업은 조금씩만 진행되고 꼬리 지연 시간이 커질 수 있습니다.
WAITING이 많아도 의도한 큐 대기인지 교착 상태 전조인지 소유자와 대기 원인을 함께 확인합니다.
Java 스레드 상태는 JVM이 제공하는 관찰 정보이며 업무 성공을 나타내는 지표는 아닙니다.
NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED의 API 의미와 관찰 한계를 비교합니다. 상태들을 정해진 순서로 모두 거친다는 뜻은 아닙니다.
| JVM 상태 | 의미 | 관찰의 한계 |
|---|---|---|
NEW | 아직 시작하지 않음 | 객체 생성만으로 작업이 실행되지는 않음 |
RUNNABLE | JVM에서 실행 가능하거나 실행 중 | 실제 CPU 사용 중이라는 보장은 아님 |
BLOCKED | 모니터 락 획득 대기 | 모든 대기를 뜻하지 않음 |
WAITING | 다른 스레드의 동작을 기한 없이 기다림 | 대기 원인과 대상도 함께 확인 |
TIMED_WAITING | 지정한 시간 한도가 있는 대기 | sleep이나 시간 제한 대기 |
TERMINATED | 스레드 실행 종료 | 업무 성공 여부는 결과·예외로 확인 |
NEW- 의미: 아직 시작하지 않음관찰의 한계: 객체 생성만으로 작업이 실행되지는 않음
RUNNABLE- 의미: JVM에서 실행 가능하거나 실행 중관찰의 한계: 실제 CPU 사용 중이라는 보장은 아님
BLOCKED- 의미: 모니터 락 획득 대기관찰의 한계: 모든 대기를 뜻하지 않음
WAITING- 의미: 다른 스레드의 동작을 기한 없이 기다림관찰의 한계: 대기 원인과 대상도 함께 확인
TIMED_WAITING- 의미: 지정한 시간 한도가 있는 대기관찰의 한계:
sleep이나 시간 제한 대기 TERMINATED- 의미: 스레드 실행 종료관찰의 한계: 업무 성공 여부는 결과·예외로 확인
Thread.State API의 분류이며 운영체제 스레드 상태와 같은 목록이 아닙니다. 행 순서는 필수 전이 경로가 아닙니다. 이 장의 예제가 여섯 상태를 모두 관찰했다는 뜻도 아닙니다.
import java.util.List;
public final class ConcurrentBoardTasks {
public static void main(String[] args) throws InterruptedException {
List<Task> tasks =
List.of(
new Task("publish", 101),
new Task("index", 102),
new Task("notify", 103));
Thread[] workers = new Thread[tasks.size()];
for (int i = 0; i < tasks.size(); i++) {
Task task = tasks.get(i);
workers[i] = new Thread(() -> run(task), "board-" + task.action());
workers[i].start();
}
for (Thread worker : workers) {
worker.join();
}
System.out.println("all-complete=" + workers.length);
}
private static void run(Task task) {
System.out.println(
Thread.currentThread().getName()
+ ":"
+ task.action()
+ "#"
+ task.postId());
}
private record Task(String action, long postId) {}
}개별 출력 순서는 고정하지 않고 마지막 all-complete만 모든 작업자 종료 뒤 나온다는 규칙으로 둡니다.
로그에는 스레드 이름을 넣어 섞인 출력의 출처를 구분합니다.
연습 문제
1부터 900까지를 겹치지 않는 세 범위로 나눠 계산하고 join 뒤 합치세요.
누락·중복을 막기 위해 각 범위 시작과 끝도 출력합니다.
정답과 해설
각 작업자는 sums의 자기 인덱스에만 쓰며 main은 모두 join한 뒤 읽습니다.
범위는 1300, 301600, 601~900입니다.
public final class ThreeRangeSumSolution {
public static void main(String[] args) throws InterruptedException {
Range[] ranges = {new Range(1, 300), new Range(301, 600), new Range(601, 900)};
long[] sums = new long[3];
Thread[] workers = new Thread[3];
for (int i = 0; i < 3; i++) {
int index = i;
workers[i] =
new Thread(
() -> {
Range r = ranges[index];
long sum = 0;
for (int v = r.start(); v <= r.end(); v++) {
sum += v;
}
sums[index] = sum;
System.out.println(r);
});
workers[i].start();
}
for (Thread worker : workers) {
worker.join();
}
System.out.println("total=" + (sums[0] + sums[1] + sums[2]));
}
private record Range(int start, int end) {}
}총합은 405450입니다.
범위 출력 순서는 달라도 최종 합과 범위 집합은 일정합니다.
프로세스와 스레드를 구분했다면 공유 힙과 개별 스택, start()와 완료 순서, CPU 중심 작업과 I/O 중심 작업의 스레드 수 기준을 각각 설명할 수 있어야 합니다.