자바 초기부터 멀티 쓰레드 기반의 동시성 프로그래밍을 위해 만들어졌던 Thread와 Runnable를 살펴보도록 하겠습니다.
1. Thread와 Runnable에 대한 이해 및 사용법
[ 쓰레드와 자바의 멀티 쓰레드]
쓰레드란 프로그램 실행의 가장 작은 단위입니다. 일반적으로 자바 애플리케이션을 만들어 실행하면 1개의 메인(main) 쓰레드에 의해 프로그램이 실행됩니다. 하지만 1개의 쓰레드 만으로는 동시에 여러 작업을 할 수 없습니다. 동시에 여러 작업을 처리하고 싶다면, 별도의 쓰레드를 만들어 실행시켜줘야 하는데, 자바는 멀티 쓰레드 기반으로 동시성 프로그래밍을 지원하기 위한 방법들을 계속해서 발전시켜 왔습니다.
그 중에서 Thread와 Runnable은 자바 초기부터 멀티 쓰레드를 위해 제공되었던 기술인데, 이 두가지에 대해 먼저 알아보도록 하겠습니다.
Java5 이전 | Runnable과 Thread |
Java5 | Callable과 Future 및 Executor, ExecutorService, Executors |
Java7 | Fork/Join 및 RecursiveTask |
Java8 | CompletableFuture |
Java9 | Flow |
[ Thread 클래스]
Thread는 쓰레드 생성을 위해 Java에 미리 구현해둔 클래스 입니다. Thread는 기본적으로 다음과 같은 메소드들을 제공합니다.
- sleep
- 현재 쓰레드 멈추기
- 자원을 놓아주지는 않고, 제어권을 넘겨줌으로 데드락이 발생할 수 있음
- interupt
- 다른 쓰레드를 깨워서 interruptedException을 발생시킴
- Interupt가 발생한 쓰레드는 예외를 catch하여 다른 작업을 할 수 있음
- join
- 다른 쓰레드의 작업이 끝날 때 까지 기다리게 함
- 쓰레드의 순서를 제어할 때 사용할 수 있음
Thread 클래스로 쓰레드를 구현하려면 이를 상속받는 클래스를 만들고, 내부에서 run 메소드를 구현해야 합니다. 그리고 Thread의 start 메소드를 호출하면 run 메소드가 실행됩니다. 실행 결과를 보면 main 쓰레드가 아닌 별도의 쓰레드에서 실행됨을 확인할 수 있습니다.
void threadStart() {
Thread thread = new Thread();
thread.start();
System.out.println("Hello: " + Thread.currentThread.getName());
}
static class MyThread extends Thread {
@Override
public void run(){
System.out.println("Thread: " + Thread.currentThread.getName());
}
}
// 출력결과
// Hello: main
// Thread: Thread-2
여기서 run을 직접 호출하는 것이 아니라 start를 호출하는 것에 주의해야 합니다. 우리는 해당 메소드의 실행을 별도의 쓰레드로 하고 싶은 것인데, run을 직접 호출하는 것은 메인 쓰레드에서 객체의 메소드를 호출하는 것에 불과합니다. 이를 별도의 쓰레드로 실행시키려면 JVM의 도움이 필요합니다. 그래서 start를 호출하는 것인데, start메소드를 자세히 살펴보도록 합시다.
public synchronized void start() {
if (threadStatus != 0)
throw new IllegalThreadStateException();
group.add(this);
boolean started = false;
try {
start(0);
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
}
}
}
위의 코드를 보면 알 수 있듯이 start는 크게 다음과 같은 과정으로 진행됩니다.
- 쓰레드가실행 가능한지 검사함
- 쓰레드를 쓰레드 그룹에 추가함
- 쓰레드를 JVM이 실행시킴
1. 쓰레드가 실행 가능한지 검사함
쓰레드는 New, Runnable, Waiting, Timed Waiting, Terminated 총 5가지 상태가 있습니다. start 가장 처음에는 해당 쓰레드가 실행 가능한 상태인지(0인지) 확인합니다. 그리고 만약 쓰레드가 New(0) 상태가 아니라면 IllegalThreadStateException 예외를 발생시킵니다.
2. 쓰레드를 쓰레드 그룹에 추가함
그 다음 쓰레드 그룹에 해당 쓰레드를 추가시킵니다. 여기서 쓰레드 그룹이란 서로 관련있는 쓰레드를 하나의 그룹으로 묶어 다루기 위한 장치인데, 자바에서는 ThreadGroup 클래스를 제공합니다. 쓰레드 그룹에 해당 쓰레드를 추가하면 쓰레드 그룹에 실행 준비된 쓰레드가 있음을 알려주고, 관련 작업들이 내부적으로 진행됩니다.
3. 쓰레드를 JVM이 실행시킴
그리고 start() 메소드를 호출하는데, 이것은 native 메소드로 선언되어 있습니다. 이것은 JVM에 의해 호출되는데, 이것이 내부적으로 run을 호출하는 것입니다. 그리고 쓰레드의 상태 역시 Runnable로 바뀌게 됩니다. 그래서 start는 여러 번 호출하는 것이 불가능하고 1번만 가능합니다.
private native void start(0);
만약 다음과 같이 run을 직접 호출하면 새롭게 쓰레드가 만들어지지 않고, 메인 쓰레드에 의해 해당 메소드가 실행됨을 확인할 수 있습니다. 또한 여러 번 실행해도 아무런 문제가 없습니다. 그리고 출력 결과를 보면 main 메소드에 의해 실행됨을 실제로 확인할 수 있습니다.
void threadRun() {
Thread thread = new MyThread();
thread.run();
thread.run();
thread.run();
System.out.println("Hello: " + Thread.currentThread().getName());
}
// 출력 결과
// Thread: main
// Thread: main
// Thread: main
// Hello: main
[ Runnable 인터페이스 ]
Runnable 인터페이스는 1개의 메소드 만을 갖는 함수형 인터페이스 입니다. 그렇기 떄문에 람다로도 사용이 가능합니다.
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
이것은 쓰레드를 구현하기 위한 템플릿에 해당하는데, 해당 인터페이스의 구현체를 만들고 Thread 객체 생성 시에 넘겨주면 실행 가능합니다. 앞서 살펴본 Thread 클래스는 반드시 run 메소드를 구현해야 했는데, Thread 클래스가 Runnable를 구현하고 있기 때문입니다.
public class Thread implements Runnable {
...
}
기존에 Thread로 작성되었던 코드를 Runnable로 변경하면다음과 같습니다. 마찬가지로 별도의 쓰레드에서 실행됨을 확인할 수 있습니다.
@Test
void runnable() {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Thread: " + Thread.currentThread().getName());
}
};
Thread thread = new Thread(runnable);
thread.start();
System.out.println("Hello: " + Thread.currentThread().getName());
}
// 출력 결과
// Hello: main
// Thread: Thread-1
2. Thread와 Runnable 비교
[ Thread와 Runnable 비교]
Runnable은 익명 객체 및 람다로 사용할 수 있지만, Thread는 별도의 클래스를 만들어야 한다는 점에서 번거롭습니다. 또한 Java에서는 다중 상속이 불가능하므로 Thread클래스를 상속받으면 다른 클래스를 상속받을 수 없어서 좋지 않습니다. 또한 Thread 클래스를 상속받으면 Thread 클래스에 구현된 코드들에 의해 더 많은 자원(메모리와 시간 등)을 필요로 하므로 Runnable이 주로 사용됩니다.
물론 Thread 관련 기능의 확장이 필요한 경우에는 Thread 클래스를 상속받아 구현해야 할 때도 있습니다. 하지만 거의 대부분의 경우 Runnable 인터페이스를 사용하면 해결 가능합니다.
Runnable | Thread | |
람다 가능 | O | X |
상속 필요 | X | O |
자원 사용량 | 적음 | 많음 |
[ Thread와 Runnable의 단점 및 한계]
하지만 위에 코드를 통해 보았듯이 Thread와 Runnable을 직접 사용하는 방식은 다음과 같은 한계점이 있습니다.
- 지나치게 저수준의 API(쓰레드의 생성)에 의존함
- 값의 반환이 불가능
- 매번 쓰레드 생성과 종료하는 오버헤드가 발생
- 쓰레드들의 관리가 어려움
먼저 Thread와 Runnable은 쓰레드를 생성하는데 너무 저수준의 API들을 필요로 합니다. 쓰레드를 어떻게 만드는지는 애플리케이션을 만드는 개발자의 관심사와는 거리가 멉니다. 그리고 쓰레드의 작업이 끝난 후의 결과 값을 반환 받는 것도 불가능합니다. 또한 쓰레드를 사용하려면 항상 새롭게 쓰레드를 생성하고 종료해야 하는데, 이는 비용이 많이 드는 작업들이며 직접 쓰레드를 만드는 만큼 쓰레드의 관리 역시 어렵습니다.
그래서 Java는 쓰레드를 사용하는 방법들을 꾸준히 발전시켜오고 있는데, 다음에는 Java5에 등장한 Excutor,ExecutorService, ScheduledExecutionService와 Callable, Future에 대해 알아보도록 합시다.
'Java' 카테고리의 다른 글
[Java] Java NIO의 Tomcat에서의 동작 (0) | 2023.12.28 |
---|---|
[Java] 동기/비동기 & NonBlocking Blocking과 Spring의 관계 (1) | 2023.12.28 |
[Java] Callable, Future 및 Executors, Executor, ExecutorService, ScheduledExecutorService에 대한 이해 및 사용법 (0) | 2023.12.13 |
[Java] CompletableFuture에 대한 이해 및 사용법 (0) | 2023.12.13 |
[Java] try-with-resources란? (1) | 2023.12.07 |