프로그램을 실행한다는 것은 해당 프로그램의 코드를 순차적으로 처리하여 CPU에서 연산을 수행하는 것이다. 그러나 현대의 컴퓨터에서는 여러 프로그램을 동시에 실행하는 것처럼 보이는데, 이를 멀티태스킹(Multitasking)이라고 한다.
멀티태스킹은 시분할(Time Sharing) 기법을 활용하여 구현된다. 이는 CPU가 매우 짧은 시간 동안 여러 프로그램을 번갈아 가며 실행하는 방식이다. 마치 애니메이션이 초당 30~60장의 정지된 이미지를 빠르게 전환하여 연속적인 영상처럼 보이게 하는 원리와 유사하다. 현대의 CPU는 초당 수십억 번의 연산을 수행할 수 있기 때문에, 여러 프로그램을 빠르게 교차 실행함으로써 동시에 실행되는 것처럼 느끼게 한다.
CPU에 어떤 프로그램이 얼마만큼 실행될지는 최적화 기법에 따라 운영체제가 결정하는데 이것을 스케줄링(Scheduling)이라 한다.
컴퓨터 시스템에서 둘 이상의 CPU 코어(프로세서)를 사용하여 여러 작업을 동시에 처리하는 기술을 의미한다.
멀티프로세싱 vs. 멀티태스킹
멀티프로세싱은 하드웨어 장비의 관점이고, 멀티태스킹은 운영체제 소프트웨어의 관점이다.
멀티프로세싱:여러 CPU 코어를 사용하여 동시에 여러 작업을 수행하는 것을 의미한다.
멀티태스킹:단일 CPU 코어가 여러 작업을 동시에 수행하는 것처럼 보이게 하는 것을 의미한다.
(소프트웨어를 기반으로 CPU 시간을 분할하여 각 작업에 할당한다.)
위 사진은 둘 이상의 CPU 코어를 사용하기 때문에 멀티프로세싱이고, 하나의 CPU에서 다양한 프로그램을 동작시키므로 멀티태스킹이다.
프로그램은 실제 실행하기 전까지는 단순한 파일에 불과하고 프로그램을 실행하면 프로그램이 인스턴스화(프로세스) 되고 실행된다. 각 프로세스는 독립적인 메모리 공간을 갖고 있으며 서로 간섭하지 않는다. 특정 프로세스에 문제가 발생해도 다른 프로세스에 영향을 주지 않는다.
프로세스 메모리 구성
프로세스는 하나 이상의 스레드를 반드시 포함한다. 스레드는 프로세스 내에서 실행되는 작업의 단위이고 한 프로세스 내에서 여러 스레드가 존재할 수 있으며, 이들은 프로세스가 제공하는 동일한 메모리 공간을 공유한다.
실제 프로그램이 실행된다는 것은 스레드(Thread)가 코드를 한 줄씩 순차적으로 처리하는 것이다.
멀티 스레드가 필요한 이유
하나의 프로그램안에서도 동시에 여러 작업이 필요하다.
CPU가 하나의 코어에서 여러 프로그램을 실행하려면, 특정 스레드의 실행을 잠시 중단하고 다른 스레드로 전환해야 한다. 이 과정에서 현재 실행 중인 스레드(A)의 실행 위치와 변수 값 등을 메모리에 저장하고, 나중에 다시 불러와야 한다.
이렇게 스레드의 실행 상태를 저장하고 복원하여 다른 스레드로 전환하는 과정을 컨텍스트 스위칭(Context Switching)이라고 한다.
컨텍스트 스위칭을 통해 CPU는 여러 프로그램을 빠르게 번갈아 실행할 수 있으며, 이는 멀티태스킹을 가능하게 하는 핵심 기술이다.
따라서 CPU-바운드 작업이 중요한 업무는 스레드를 필요 이상으로 많이 잡으면 컨텍스트 스위칭 (문맥 전환)비용이 많이 증가하고 캐시 미스 발생률이 증가하여 효율이 떨어진다. 또한 I/O-바운드 작업은 네트워크 요청(DB 조회)와 같이 응답을 기다리는 시간이 많이 때문에 대부분 스레드는 유후(idle)상태가 많다. 따라서 스레드를 많이 생성하는것이 좋다.
CPU 연산을 처리하는 시간이 긴 작업이 많아 스레드가 지속적으로 CPU를 점유하려 함
스레드를 필요 이상으로 많이 생성하면 컨텍스트 스위칭(Context Switching: CPU 레지스터를 저장하고 복원) 비용 증가 및 캐시 미스 발생률 상승으로 성능이 저하됨
CPU 코어 개수에 맞춰 적절한 스레드 개수를 유지하는 것이 효율적
대부분의 스레드가 I/O 응답을 기다리는 유휴(idle) 상태이므로 CPU 리소스를 많이 사용하지 않음
CPU를 점유하는 시간이 짧아 컨텍스트 스위칭 부담이 적고, 많은 스레드를 생성해도 성능 저하가 크지 않음
스레드를 많이 생성하여 병렬 처리를 극대화하는 것이 효과적
대부분 웹 어플리케이션에 해당
메서드 영역(Method Area): 메서드 영역은 프로그램을 실행하는데 필요한 공통 데이터를 관리한다. 이 영역은 프로그램의 모든 영역에서 공유한다.
static 변수들을 보관한다스택 영역(Stack Area): 자바 실행 시, 하나의 실행 스택이 생성된다. 각 스택 프레임은 지역 변수, 중간 연산 결과, 메서드 호출 정보 등을 포함한다.
힙 영역(Heap Area): 객체(인스턴스)와 배열이 생성되는 영역이다. 가비지 컬렉션(GC)이 이루어지는 주요 영역이며, 더 이상 참조되지 않는 객체는 GC에 의해 제거된다.
Main함수가 시작되면 Main Thread가 생성, 스택 프레임에 메인 메서드가 올라가서 실행되며 그안에서 Thread를 상속받은 클래스의 인스턴스 생성 및 start()시 메인 메서드와 해당 클래스의 run()이 동시에 실행되게 된다.(만약 start()하지 않고 run()을 직접 호출하면 main thread에 스택 프레임이 할당된다.)
스레드는 사용자(user) 스레드와 데몬(daemon) 스레드 2가지 종류로 구분할 수 있다.
사용자 스레드(non-daemon 스레드)
데몬 스레드
DaemonThread daemonThread = new DaemonThread();
daemonThread.setDaemon(true); // 데몬 스레드 여부
setDaemon(true)를 통해 데몬스레드로 설정이 가능하다. 사용자 스레드가 종료되면 데몬 스레드의 실행과 관계없이 프로세스는 무조건 종료된다.
실무에선 Thread를 상속 받는 방법보다 Runnable 인터페이스를 구현하는 방식을 선호한다. 왜냐하면 다른 클래스를 상속받아도 문제없이 구현이 가능하고 여러 스레드가 동일한 Runnable 객체를 공유할수 있어 자원 관리를 효율적으로 할수 있다.
HelloRunnable runnable = new HelloRunnable();
Thread thread = new Thread(runnable);
이렇게 Thread에 로직을 구현한 Runnable을 위임함으로써 여러 스레드에서 로직 공유가 가능하다.
1.단일 상속 제약 회피 : 자바에선 클래스가 단 하나의 부모 클래스만 상속 가능한데, Runnable을 구현하면 자유롭게 다른 클래스를 상속받으면서 동시에 멀티스레드 작업을 정의할수 있다.
2.작업(로직)과 스레드의 분리 : Thread를 상속하면 스레드 실행 로직과 스레드 제어 로직이 뒤섞이지만, Runnable로 분리해두면 “실행할 로직”과 “스레드를 운용하는 방식”을 명확히 구분할 수 있어 구조가 단순해지고 재사용성이 높아진다.
Thread 상속
public class HelloThread extends Thread {
// 여기에 제어 로직
@Override
public void run() {
//실행 로직
System.out.println(Thread.currentThread().getName() + ": run()");
}
}
3.스레드 풀, Executor 프레임워크 활용: 일반적으로 스레드 풀(ExecutorService, Executor)을 사용하게 되는데 이때 Runnable,Callable 형태로 작업을 제출하므로 호환성이 뛰어나다.
여러 스레드가 코드를 실행하는지 출력하기 위해 System으로 매번 찍는것은 번거롭다. 아래와 같이 클래스를 만들고 static 메서드를 통해 어떤 스레드가 실행됬는지 알수있는 기능을 만들자.
public abstract class MyLogger {
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
public static void log(Object obj) {
String time = LocalTime.now().format(formatter);
System.out.printf("%s [%9s] %s\n", time, Thread.currentThread().getName(), obj);
}
}
각각의 스레드에서 log를 호출하면 thread의 이름과 내용을 출력할수 있다.
1.스레드 생성
스레드를 생성할 때는 실행할 Runnable 인터페이스의 구현체와, 스레드의 이름을 전달할 수 있다.
Thread myThread = new Thread(new HelloRunnable(), "myThread");
2.스레드 객체 정보
myThread 객체를 문자열로 변환하여 출력한다.toString() 메서드는 스레드ID,스레드 이름,우선순위,스레드 그룹을 차례로 출력한다.
log("myThread = " + myThread);
->Thread[#21,myThread,5,main]
3.스레드 ID
스레드의 고유 식별자를 반환한다.(JVM 내에서 유일,직접 지정 X)
log("myThread.threadId() = " + myThread.threadId());
4.스레드 이름
지정된 name값이 반환된다.(중복 가능)
log("myThread.getName() = " + myThread.getName());
5.스레드 우선순위
1~10까지 우선순위를 설정할수 있고 기본값은 5이다. setPriority()를 통해 변경할수 있다.
log("myThread.getPriority() = " + myThread.getPriority());
6.스레드 그룹
스레드가 속한 그룹을 출력한다. 기본적으로 스레드의 그룹은 부모 스레드와 동일한 그룹이다. 스레드 그룹은 여러 스레드를 하나의 그룹으로 묶어서 특정 작업을 수행할수 있다.
log("myThread.getThreadGroup() = " + myThread.getThreadGroup());
7.스레드 상태
Thread.State 열거형에 정의된 상수중 하나를 반환한다.
log("myThread.getState() = " + myThread.getState());
NEW: 스레드가 시작되지 않은 상태
RUNNABLE: 스레드가 실행중이거나 실행될 준비가 된 상태
스레드는 동시 실행되는것이지만 실제로는 CPU 코어 1개당 물리적으로 하나의 스레드를 실행하고 CPU 시간을 여러 스레드에 할당하고 Contexct-Switching 하기 때문에 스케줄러의 대기열에 들어가있는 스레드도 RUNNABLE에 포함된다.
BLOCKED: 스레드가 동기화 락을 기다리는 상태
WAITING: 스레드가 다른 스레드의 특정 작업이 완료되기를 기다리는 상태
TIMED_WAITING: 일정 시간동안 기다리는 상태
Thread.sleep()을 통해 특정 시간을 기다릴수 있다.(1000=1초) sleep은 InterruptedException이라는 체크 예외를 던진다. -> run 메서드안에서는 반드시 잡아야 한다.
TERMINATED: 스레드가 실행을 마친 상태
부모 클래스에서 예외를 처리하지 않거나 자식 클래스보다 하위 타입의 예외를 처리하면 자식 클래스에서 재정의한 메서드에선 예외를 던질수 없다.(try-catch로 잡아야한다.)
Runnable 인터페이스는 예외를 처리하지 않기 때문에(멀티 스레드 환경에서 잡고 가자는 취지) 상속받은 run() 메서드에서 try-catch로 잡아야 한다. 또는 static 메서드로 정의하여 재사용 가능하다.
public abstract class ThreadUtils {
public static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
log("인터럽트 발생, " + e.getMessage());
throw new RuntimeException(e);
}
}
}
Thread.sleep() 메서드를 통해 TIMED_WAITING 상태를 알아보았다. 이번에는 Join() 메서드를 통해 WAITING 상태가 무엇이고 왜 필요한지 알아보자.
여기서 Join()을 호출하는 스레드는(Main) 대상 스레드가 TERMINATED될때까지 대기한다. 대상 스레드가 종료되면 호출 스레드는 다시 RUNNABLE상태가 되어 다음 코드를 수행한다. -> join(1000)를 통해 시간을 지정할수 있다.(TIMED_WAITING)
Join이 필요한 상황: 대상 스레드의 필드값을 받아 호출한 스레드에서 로직을 수행할때
This
각 스레드는 독립적인 스택을 가지며, 동일한 run() 메서드를 실행하더라도 서로 다른 스택에서 실행된다. this 키워드는 현재 실행 중인 스레드 인스턴스를 가리키며, 스레드가 자신의 객체를 구별하는 데 사용된다.
JVM은 this를 통해 각 스레드가 실행하는 run() 메서드가 어느 인스턴스에서 호출되었는지 식별할 수 있다.(지역변수에는 this가 생략되어 있다.) 따라서, Thread.currentThread()를 호출하면 현재 실행 중인 스레드를 식별할 수 있다. (스택 프레임에 this값이 저장되어 있다.)