쓰레드의 개념과 이해
자바는 쓰레드를 다룰 수 있는 다양한 클래스를 제공한다. 처음 자바로 쓰레드를 다룰 때는 수많은 클래스들의 특징과 차이점을 잘 모르니 어떤 클래스를 써야하는지 엄청 헷갈린다. 검색해서 나오는 예제마다 구현 방법도 다르다. 한 번에 “빡” 공부해두지 않으면 계속 헷갈리기 쉽상이다.
커널쓰레드와 자바쓰레드
이론부터 공부해보고자 한다. 프로세스와 쓰레드의 차이 같은 기본적인 특징은 생략했다.
커널 레벨 쓰레드와 유저 레벨 쓰레드
커널 쓰레드는 스케줄링 주체가 커널이다. 커널이 쓰레드를 관리한다. 유저 어플리케이션이 만들어낸 쓰레드를 말한다. 커널 입장에서는 그냥 하나의 프로세스로만 본다. 학부 OS 강의 때 처음 커널 쓰레드와 유저 쓰레드를 들었을 때는 이게 무슨 말인지 감이 안왔었다. 지금은 커널이 만들어내는 쓰레드냐, 어플리케이션이 만들어내는 쓰레드냐의 차이로 이해하고 있다. 그리고 유저 쓰레드가 실행되기 위해서는 커널 쓰레드에 매핑되어야 한다는 점.
매핑 방식
매핑에는 1:1, 1:N, N:M이 있다.
그러면 JVM은 자바 쓰레드를 어떻게 취급할까
과거 - Green Thread 방식
과거에는 N:1로 매핑되었다. 커널 쓰레드 한개에 유저 쓰레드 여러 개가 붙는 방식이다. 비효율적인 방식처럼 보이지만, 당시에는 나름 이유가 있었다. 과거에는 어차피 싱글 코어 컴퓨터가 대부분이였다. 그 당시 커널 쓰레드를 여러 개 쓰기에는 너무 무거웠었기 때문에, 이 방식을 채택한 것으로 보인다.
현재 - Native Thread 방식
현재는 N:M으로 매핑된다. 유저 레벨 쓰레드와 커널 레벨 쓰레드의 매핑을 조율해주는 친구가 있다.
LWP
LWP(Light Weight Process)라고 하는 친구가 있다. LWP는 자바 쓰레드를 커널 쓰레드에 매핑한다. 자바 쓰레드는 커널 쓰레드에 바인딩되어 있지 않은 상태(unbound)가 디폴트다.
LWP는 Solaris Thread Library에 C++로 구현되어 있다.
자바 소스 코드를 실행하면, JNI(Java Native Interface)를 통해 LWP가 실행된다.
Native Thread는 Solaris Threads Library Level의 C++ Thread 객체가 OS Level Kernel Thread를 소유하게 된 상태를 의미한다. Thread 인스턴스는 Kernel Level Thread (OS Thread)를 멤버변수로 가지고 있다.
Java Thread를 사용할 때, start() / run() / join() 등의 메소드들은 OS에 종속적이다. OS에 SystemCall을 보내서 OS에 해당 기능을 요청하는 형태이기 때문이다. Java Thread가 start()라는 자바 코드를 실행하면 JNI를 실행하게 되고, JNI가 JVM과 C++ 라이브러리를 통해 OS에 시스템 콜을 요청하는 형태로 동작한다.
동작 요약
- Thread.start()를 호출
- Solaris Library Thread (C++의 Java Thread 클래스 인스턴스) 생성
- Kernel Thread (리눅스 pthread 호출) 생성
- Solaris Library Thread(C++)와 Java Application Level Thread를 연결해서 Native Thread 완성
- Native Thread와 Java Application Level Thread를 Binding
- 완성된 자바 쓰레드를 쓰레드 큐에 추가
- Java Application Level Thread가 원하는 코드를 Native Thread에게 실행시킬 수 있음 (Thread.run() Java Code)
자바쓰레드의 실제 구현
자바 쓰레드의 공부하기 위한 “기초”를 클래스 7개로 요약했고, 7개의 클래스를 3개의 분류로 나누었다.
- Thread / Runnable
- Callable / Future
- Executor / ExecutorService / Executors
Thread & Runnable
태초에 Thread와 Runnable이 있었다.
- Thread는 클래스다.
- Thread를 만들고 새로운 쓰레드로 실행하려면 start() 메소드를 사용해야 한다. run() 메소드는 구현한 코드를 실행하는 메소드일 뿐이라서, run()을 호출하면 메인 쓰레드에서 run() 메소드를 실행할 뿐이다.
class ThreadExample extends Thread {
public void run() {
// 쓰레드에서 동작시킬 코드들
}
}
...
ThreadExample threadExample = new ThreadExample();
threadExample.start()
- Runnable은 인터페이스다.
- Thread도 Runnable의 구현체다.
- Runnable과 Thread가 별도로 존재하는 이유 -> 자바는 다중 상속이 안된다. 그래서 Thread를 사용하면 다른 클래스를 상속받을 수 없다. 따라서 확장성을 고려한다면 Runnable 인터페이스를 구현하여 Thread를 주입받아 사용하는 것이 좋다.
class RunnableExample implements Runnable {
public void run() {
// 쓰레드에서 동작시킬 코드들
}
}
...
RunnableExample runnableExample = new RunnableExample();
Thread t = new Thread(runnableExample);
t.start();
Thread와 Runnable의 한계점
- 저수준 API(쓰레드 생성)에 의존한다는 문제점
- 쓰레드가 종료될 때 값을 반환받을 수 없다는 점 -> 쓰레드의 작업 결과물을 받아오기 까다로움
- 매번 쓰레드를 생성하고 종료해야함 -> 매번 커널쓰레드를 생성하고 종료하는 오버헤드의 존재
- 쓰레드들의 관리가 어려움
위와 같은 치명적인 한계점을 극복하고자, Java 5에서 Future, Callable, ExecutorService, Executor가 등장했다.
Callable
- 기존 Runnable 인터페이스가 결과를 반환할 수 없다는 한계를 극복하기 위해 등장
- Callable은 Runnable을 발전시킨 형태 -> Generic을 통해 결과를 돌려받을 수 있음
- Callable 인터페이스를 구현하여 Task를 만드는 방식
package java.util.concurrent;
@FunctionalInterface
public interface Callable<V> { // Generic V로 리턴값의 타입을 설정
V call() throws Exception;
}
Future
- Task는 가용 가능한 쓰레드가 없는 경우, 실행되지 않고 대기하거나, 실행하는데 시간이 오래걸릴 수 있음
- Task가 완료되고 얻게 될 실행 결과를 받아오기 위해 사용되는 것이 Future
- Future는 비동기 작업을 통해 미래에 실행 결과를 얻을 수 있도록 만들어 줌
- Future는 비동기로 이루어지는 작업들의 “현재 상태를 확인하고”, “기다리고”, “결과를 얻는 방법”을 제공
package java.util.concurrent;
public interface Future<V> {
boolean cancel(boolean var1); // 작업을 취소시키고, 취소 여부를 booelan으로 반환. cancel 이후에 isDone()은 항상 true를 반환
boolean isCancelled(); // 작업의 취소 여부 리턴
boolean isDone(); // 작업 완료 여부를 리턴
V get() throws InterruptedException, ExecutionException;
// 블로킹 방식으로 결과를 가져옴
V get(long var1, TimeUnit var3) throws InterruptedException, ExecutionException, TimeoutException;
}
Future와 Callable이 헷갈릴 수 있는데, 요약하자면 Callable은 “실행”될 코드고, Future는 Callable이 실행된 이후 반환될 “정보”다.
Future와 Callable 예제
Future와 Callable의 존재가 헷갈릴 수 있으니 예제를 통해 알아보자.
public class Main {
public static void main(String[] args) throws Exception {
ExecutorService executorService = Executors.newSingleThreadExecutor();
Callable<Integer> hello = new RandomNumberReduce();
Future<Integer> helloFuture = executorService.submit(hello);
// Callable을 실행해서 결과를 Future에 저장하는 것
System.out.println(helloFuture.isDone()); // false가 나옴
helloFuture.get(); // 여기서 블락킹이 된다!
System.out.println(helloFuture.isDone()); // true가 나옴
}
}
class RandonNumberReduce implements Callable<Integer> {
@Override
public Integer call() throws Exception {
System.out.println("Callable Start");
Thread.sleep(2);
System.out.println("Callable End");
return new Random.nextInt(100);
}
}
// 결과
// false
// Callable Start
// Callable End
// true
Java5 쓰레드 기술의 한계
Future의 한계점
- 외부에서 완료시킬 수 없음, Future.get()의 타임아웃 설정으로만 완료시킬 수 있음
- 블로킹 코드인 Future.get()을 통해서만 이후의 결과를 처리할 수 있음
- 여러 Future를 조합할 수 없음
- 여러 작업을 조합하거나 예외 처리할 수 없음
내 생각으로는 Future를 언급할 때 계속 Blocking 된다는 이야기가 나오는게 불편했다.
Future.get()을 쓰면 Blocking한 뒤, 데이터를 받아온다고 하니…
JS 같은 언어에서는 비동기로 callback 함수를 넘겨주면 해결되는 문제였으니 말이다.
그래서 생각보다 구린데?라고 생각하고 있었는데 이걸 해결한게 또 있었다.
CompletableFuture라는 친구가 있었다.
Executor 인터페이스
- 쓰레드 풀의 구현을 위해 등장한 존재
- 등록된 작업(Runnable)을 실행하기 위한 인터페이스
- 작업의 등록은 담당하지 않고, 작업의 실행만을 담당함
Executor는 단순 인터페이스이므로, executor를 통해 새로운 쓰레드를 실행하고 싶다면, 오른쪽 코드와 같이 execute 메소드 안에서 Thread 객체를 생성해 start를 호출해주어야 한다.
public interface Executor {
void execute(Runnable command);
}
void executorRun() {
final Runnable runnable = () ->
System.out.println("Thread: " + Thread.currentThread.getName());
Executor executor = new StartExecutor();
executor.execute(runnable);
}
...
static class StartExecutor implements Executor {
@Override
public void execute(final Runnable runnable) {
new Thread(command).start();
}
}
ExecutorService 인터페이스
- ExecutorService는 Task(Runnable, Callable) 등록을 위한 인터페이스
- ExecutorService는 Executor를 상속받아서 작업 등록 + 작업 실행이라는 두 가지 역할을 모두 담당함
- ThreadPool은 ExecutorService의 구현체 (예: ThreadPoolExecutor)
- ThreadPoolExecutor는 내부에 있는 BlockingQueue에 Task를 등록해놓고, Thread에 작업을 분배해 실행시킴
- ThreadPool에 있는 Thread 수보다 Task가 많으면, 미실행된 Task는 Queue에 저장되고, 실행을 마친 Thread로 할당되어 순차적으로 수행됨
public interface ExecutorService extends Executor {
void shutdown();
List<Runnable> shutdownNow();
boolean isShutdown();
boolean isTerminated();
boolean awaitTermination(long var1, TimeUnit var3) throws InterruptedException;
<T> Future<T> submit(Callable<T> var1);
<T> Future<T> submit(Runnable var1, T var2);
Future<?> submit(Runnable var1);
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> var1) throws InterruptedException;
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> var1, long var2, TimeUnit var4) throws InterruptedException;
<T> T invokeAny(Collection<? extends Callable<T>> var1) throws InterruptedException, ExecutionException;
<T> T invokeAny(Collection<? extends Callable<T>> var1, long var2, TimeUnit var4) throws InterruptedException, ExecutionException, TimeoutException;
}
ExecutorService 인터페이스가 없다면?
- 각기 다른 Thread를 매번 수동으로 생성해서 작업을 처리하고, 처리가 완료되면 해당 Thread를 제거하는 작업을 손수 진행해야 함
- 쉽게 이야기하면, ExecutorService에 Task만 넣어주면, 알아서 ThreadPool을 이용해서 Task를 실행하고 관리한다는 뜻
ExecutorService 비동기 작업의 주요 메소드
- submit
- 실행할 작업들을 추가하고, 작업의 상태와 결과를 포함하는 Future를 반환
- Future의 get을 호출하면, 성공적으로 작업이 완료된 이후 결과를 얻을 수 있음
- invokeAll
- 모든 결과가 나올 때까지 대기하는 블로킹 방식의 요청
- 동시에 주어진 작업들을 모두 실행하고, 전부 끝나면 각각의 상태와 결과를 갖는 List
를 반환
- invokeAny
- 가장 빨리 실행된 결과가 나올 때까지 대기하는 블로킹 방식의 요청
- 동시에 주어진 작업들을 모두 실행하고, 가장 빨리 완료된 하나의 결과를 Future로 반환받음
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(10);
Instant start = Instant.now();
Callable<String> hello = () -> {
Thread.sleep(1000L);
final String result = "Hello";
System.out.println("result = " + result);
return result;
}
Callable<String> world = () -> {
Thread.sleep(2000L);
final String result = "World";
System.out.println("result = " + result);
return result;
}
String result = executorService.invokeAny(Arrays.asList(hello, world));
System.out.println("result = " + result + " time = " + Duration.between(
start, Instant.now()).getSeconds()
);
List<Future<String>> futures = executorService.invokeAll(Arrays.asList(hello, world));
for(Future<String> f : futures) {
System.out.println(f.get());
}
executorService.shutdown();
}
Executors
앞에서 Executor, ExecutorService는 쓰레드 풀을 위한 인터페이스라고 했다. 인터페이스는 구현체가 필요하다. 직접 쓰레드를 다루는 구현체를 만들기는 번거롭다. 그래서 구현체를 만들어둔 팩토리 클래스가 등장했는데, 이게 바로 Executors다. Executors를 활용하면 Executor, ExecutorService 등의 인터페이스를 구현한 쓰레드 풀을 손쉽게 만들 수 있다.
- newFixedThreadPool
- 고정된 쓰레드 개수를 갖는 쓰레드 풀을 생성
- ExecutorService를 구현한 ThreadPoolExecutor 인스턴스 생성
- newCachedThreadPool
- 필요할 때 필요한 만큼의 쓰레드 풀을 생성
- 이미 생성된 쓰레드가 있다면 재활용
- newScheduledThreadPool
- 일정 시간 뒤 혹은 주기적으로 실행되는 작업을 위한 쓰레드 풀을 생성
- ScheduledExecutorService 인터페이스를 구현한 ScheduledThreadPoolExecutor 인스턴스를 생성
- newSingleThreadExecutor, newSingleThreadScheduledExecutor
- 1개의 쓰레드만을 갖는 쓰레드 풀을 생성
- 각각 newFixedThreadPool, newScheduledThreadPool에 1개의 쓰레드만을 생성
public class Executors {
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue());
}
public static ExecutorService newWorkStealingPool(int parallelism) {
return new ForkJoinPool(parallelism, ForkJoinPool.defaultForkJoinWorkerThreadFactory, (Thread.UncaughtExceptionHandler)null, true);
}
public static ExecutorService newWorkStealingPool() {
return new ForkJoinPool(Runtime.getRuntime().availableProcessors(), ForkJoinPool.defaultForkJoinWorkerThreadFactory, (Thread.UncaughtExceptionHandler)null, true);
}
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue(), threadFactory);
}
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue()));
}
...
출처
- 쓰레드의 매핑 이미지 출처
- 스레드란?
- Java Thread의 변화
- JDK 1.1 for Solaris Developer’s Guide
- Java Thread에 대해 깊게 이해해보자
- How Java thread maps to OS thread?
- Java : 가상 스레드
- thread에 대한 질문 (인프런)
- Thread와 Runnable에 대한 이해 및 사용법
- Callable, Future 및 Executors, Executor, ExecutorService, ScheduledExecutorService에 - 대한 이해 및 사용법
- Java ExecutorService란
- 자바 스레드 동기화 개념 및 활용