스레드는 어떻게 동작할까?
프로세스와 스레드
--
프로세스는
실행 중인 프로그램을 의미하고,
이때 프로세스는 "자원(메모리, CPU 등)"과 "스레드"로 구성되어 있다.
스레드는
프로세스 내에서 "자원"을 가지고 실제 작업을 수행하는 무언가로
모든 프로세스는 무조건 하나 이상의 스레드를 가지고 있다.
비유
- 프로세스 = 공장
- 스레드 = 직원(일꾼)
--
싱글 스레드 & 멀티 스레드
--
싱글 스레드
하나의 프로세스가 하나의 스레드만 사용하는 방식이다.
특징
- 작업을 하는 스레드가 하나뿐이라서 모든 작업이 순차적으로 처리된다.
(A작업을 하고 있었다면 해당 작업을 모두 끝내야 다음 작업을 할 수 있다.) - 구현이 단순하고 디버깅이 쉽다.
- 다만 모든 작업이 순차적으로 처리되기 때문에 작업 시간이 긴 작업이 있으면
다른 작업들은 오래 대기해야 하므로 성능이 낮아질 수 있다.
적합한 경우
- 프로그램이 단순하고, 작업 시간이 짧거나 작업 수가 적은 경우
- 스레드 간의 동기화 문제가 중요하지 않은 경우
멀티 스레드
하나의 프로세스가 둘 이상의 스레드를 사용하는 방식이다.
특징
- 여러 작업을 동시에 처리할 수 있으므로 효율적이다.
- CPU를 최대한 활용할 수 있어 응답성이 좋다.
- 다만 구현이 복잡하고, 동기화 문제 및 교착상태(dead-lock)가 발생할 수 있다.
적합한 경우
- CPU를 최대한 활용하고 여러 작업을 동시에 처리해야 하는 경우
- 응답 속도가 중요한 프로그램인 경우
--
스레드 구현 방법
--
스레드를 구현하는 방법 2가지
- Thread 클래스 상속
- Runnable 인터페이스 구현
[ Thread 클래스 상속 ]
class TestThread extends Thread {
// Thread 클래스의 run() 메서드 오버라이딩
@Override
public void run() {
// 작업 로직
}
}
TestThread t1 = new TestThread(); // 스레드 생성
t1.start(); // 스레드 실행
[ Runnable 인터페이스 구현 ]
class TestThread implements Runnable {
// Runnable 인터페이스의 추상 메서드인 run()을 구현
@Override
public void run() {
// 작업 로직
}
}
Runnable r = new TestThread(); // run() 메서드 정의
Thread t1 = new Thread(r); // 스레드 생성 [생성자 Thread(Runnable r)]
t1.start(); // 스레드 실행
Runnable 인터페이스는 추상 메서드인 run() 하나만 존재한다.
즉, Runnable 인터페이스는 스레드에서 동작할 run() 메서드를 구현만 하는 역할로
이를 Thread 생성자에 넣어 객체를 생성하고 사용해야 한다.
위에 두 방법 중에서 어느 방법을 사용해도 결국 run() 메서드를 작성하여 사용하는 것은 똑같기 때문에 상관없다.
다만 Thread클래스 상속 보다는 Runnable 인터페이스 구현하는 쪽이 더 괜찮을 수 있다.
자바는 단일 상속만 가능하기 때문에 Thread 클래스를 상속받으면 다른 클래스를 상속받을 수 없기 때문이다.
싱글 스레드 예제
public static void main(String args[]) {
for(int i = 0; i < 5; i++) {
System.out.print(0);
}
for(int i = 0; i < 5; i++) {
System.out.print(1);
}
}
// 결과 : 0000011111
main메서드의 작업을 수행하는 것 또한 스레드다.
즉, 이를 main스레드라고 부르며, 스레드를 몰랐을 때에도 이미 나도 모르게 main스레드를 사용하고 있던 것이다.
(프로그램을 실행하면 기본적으로 하나의 스레드를 생성하고 이를 실행하는데 이것이 바로 main 스레드다.)
멀티 스레드 예제
// Thread클래스를 상속받아 쓰레드 구현
class ThreadEx1 extends Thread {
public void run() { //쓰레드가 수행할 작업
for(int i = 0; i < 5; i++) {
System.out.print(0);
}
}
}
// Runnable인터페이스를 구현하여 쓰레드 구현
class ThreadEx2 implements Runnable {
public void run() { //쓰레드가 수행할 작업
for(int i = 0; i < 5; i++) {
System.out.print(1);
}
}
}
public static void main(String args[]) {
ThreadEx1 t1 = new ThreadEx1(); // Thread상속받은 쓰레드 생성
Runnable r = new ThreadEx2();
Thread t2 = new Thread(r); // Runnable구현한 쓰레드 생성
t1.start();
t2.start();
}
// 결과 : 0010011101
싱글 스레드 동작 : A시작 => A 종료 => B시작 => B종료 => 프로그램 종료
멀티 스레드 동작 : A시작 => A 종료 => B시작 => B종료 => (반복) => 프로그램 종료
멀티 스레드 동작 그림에서
A와 B가 서로 일정한 시간만큼 서로 번갈아 가는 것으로 표현되어 있지만
실제로는 각 스레드의 수행시간을 "OS스케줄러"가 결정하기 때문에 정확히 알 수 없다.
(OS에서 수많은 프로세스들이 수행되기 때문에 상황에 따라 계속 수행 시간이 변경된다.)
위에 멀티 스레드 코드 결과처럼 (0010011101) 두 스레드의 수행 시간이 일정하지 않다.
싱글 스레드와 멀티 스레드의 총 소요 시간을 비교하면 멀티 스레드가 수행시간이 더 걸리는 것을 알 수 있다.
A와 B를 넘어가는 과정에서 시간이 요소되기 때문이다.
(이를 "context switching"이라고 부른다.)
시간이 좀 걸리더라도 여러 작업을 동시에 동작한다는 장점이 있기 때문에 자주 사용한다.
그리고 위와 같은 상황만 보면 멀티 스레드가 더 오래 걸리는 것으로 보이지만
여러 상황들을 고려한다면 멀티 스레드가 더 빨리 끝나는 경우도 존재한다.
예시로 "I/O 블락킹"이 있는데
I/O 블락킹은 입출력 동작을 할 경우에 해당 입출력에 대한 결과를 받을 동안 잠시 작업이 일시 중지되는 것을 의미하는데
이때 멀티 스레드를 사용한다면 일시 중지되는 동안 다른 스레드를 동작하여 효율적으로 작업할 수 있도록 도와준다.
--
스레드 동작 흐름
--
각 스레드는 모두 자신만의 "호출 스택(Call Stack)"을 가진다.
호출 스택 (Call Stack)은
스레드가 실행 중인 메서드 호출 정보를 저장하는 구조로
메서드 호출, 지역 변수, 리턴 주소 등이 저장된다.
예시 코드
class Test {
public static void main(String args[]) {
TestThread t1 = new TestThread();
t1.start();
}
}
- 프로그램 시작 => JVM 시작 => main 스레드 생성 => main 호출 스택 생성
- main() 메서드 실행 => main 호출 스택에 main() 메서드 추가
- t1스레드를 start() 메서드로 호출
- t1 호출 스택 생성
- t1 스레드의 run() 메서드 실행 => t1 호출 스택에 run() 메서드 추가
- t1.start() 메서드의 역할은 끝났으므로 main 호출 스택에서 start() 메서드 제거 (아주 짧은 시간 동안에만 존재)
- main() 메서드 동작을 모두 수행했으므로 main 호출 스택에서 제거
- main 스레드의 동작을 모두 수행(main 스레드 종료) => main 호출 스택 제거 (스레드 종료 시 즉시 제거)
- t1 스레드의 run() 메서드 동작 완료 => t1 호출 스택에서 run() 메서드 제거
- t1 스레드의 동작을 모두 수행(t1 스레드 종료) => t1 호출 스택 제거(스레드 종료 시 즉시 제거)
- 프로그램 종료
정리
- 스레드는 프로그램 내에서 독립적으로 실행되는 흐름이다.
- 각 스레드는 자체적인 "호출 스택"을 가지고 있으며, 이를 통해 메서드 호출과 실행 흐름을 관리한다.
- 호출 스택은 해당 스레드가 실행되는 동안에만 존재하며, 스레드가 종료되면 함께 제거된다.
- 호출 스택은 GC와는 관계없이 JVM이 직접 관리한다.
- 한 번 실행이 종료된 스레드는 다시 실행이 불가능하다.
- 실행 중인 스레드가 하나도 없을 경우에 프로그램이 종료된다.
데몬 스레드 (Daemon Thread)
스레드는 2가지가 존재한다.
- 일반 스레드 : 데몬 스레드가 아닌 모든 스레드
- 데몬 스레드 : 일반 스레드의 작업을 돕기 위해 보조 역할을 수행하는 스레드
일반적으로 데몬 스레드는
GC, 자동 저장, 화면 자동 갱신 등
주기적으로 계속 보조를 해주는 역할이 필요할 때 사용된다.
그래서 무한 루프 또는 조건문을 통해 실행 후 대기하다가
특정 조건이 만족될 때마다 작업을 수행하고 다시 대기하는 것을 반복하도록 작성한다.
데몬 스레드는 일반 스레드와 달리
무한 루프를 사용한다고 영원히 종료가 안 되는 것이 아니라
일반 스레드가 모두 종료되면 자동으로 데몬 스레드도 종료된다.
(정확히는 애플리케이션의 종료 시점에 종료된다.)
데몬 스레드 사용 방법
class TestDaemonThread implements Runnable {
@Override
public void run() {
while(true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
system.out.println("!!!");
}
}
}
}
run() 메서드를 구현하는 것은 일반 스레드와 다른 점이 없다.
무한 루프와 sleep() 메서드를 통해 일정 간격마다 작업을 수행하도록 하고
특정 조건이 맞으면 그 때 작업을 하도록 구성하면 된다.
public static void main(String[] args) {
TestDaemonThread dt1 = new TestDaemonThread();
dt1.setDaemon(true); // 데몬 스레드로 설정
dt1.start(); // 데몬 스레드 시작
System.out.println("일반 스레드 종료");
}
해당 스레드를 바로 start()로 사용하면 일반 스레드로 취급하지만
start()를 하기 전에 setDaemon(true) 메서드를 통해 해당 스레드를 데몬 스레드로 설정한 뒤에 start()를 호출해야 한다.
(반대로 setDaemon(false)는 일반 스레드로 설정한다.)
isDaemon() 메서드를 사용하면 해당 스레드가 데몬 스레드인지 일반 스레드인지 확인할 수 있다.
(ture = 데몬 스레드, false = 일반 스레드)
--
'Language > Java' 카테고리의 다른 글
스레드의 동기화 (0) | 2025.01.09 |
---|---|
스레드의 상태 종류 & 제어 (0) | 2025.01.08 |
제네릭 (Generics) (0) | 2024.12.28 |
Arrays 클래스에서 제공하는 메서드 (0) | 2024.12.25 |
Iterator & ListIterator (0) | 2024.12.21 |