오늘 인턴 면접에서 질문으로 Callable 과 Thread 사이의 차이점을 말해보라고 질문을 받았다. Callable 은 사실 생전 처음 들어보는거라 당연히 전혀 대답을 하지 못했다. 떨어지면 이거때문이겠지.. 여튼 새로운 공부거리를 던져주었으니, 공부해야겠다는 생각에 이렇게 면접 끝나자마자 찾아보고 있다.
Java 1.0 : Thread 와 Runnable
Thread 클래스 사용법
자바에서 새 쓰레드를 생성하려면 Thread 클래스를 사용한다. 이 클래스로 새 쓰레드를 생성하는 방법에는 크게 2가지가 있다.
1.
Runnable 을 사용하는 방법
Thread thread = new Thread(new Runnable(){
@Override
public void run(){
// 쓰레드에서 하고 싶은 작업
}
});
thread.start();
//Runnable 인터페이스는 함수형 인터페이스 이므로 람다식으로 쓸 수 있다.
Thread lamdaThread = new Thread(() -> {});
lamdaThread.start();
Java
복사
2.
Thread 의 하위 클래스를 생성하는 방법
class MyThread extends Thread{
@Override
public void run(){
// 하고싶은 작업
}
}
Thread thread = new MyThread();
thread.start();
Java
복사
이 두가지 방법중 첫번째 방법이 더 선호된다. 더 확장성이 좋기 때문이다.
Runnable 을 사용하는 방법이 확장성이 더 좋은 이유
Thread 클래스 내부 동작 원리
Thread 클래스의 인스턴스를 생성하고, start() 메서드를 실행시키면 내부적으로 다음 순서대로 메서드가 실행되고 실제 쓰레드가 생성된다.
1.
start() 메서드 실행
2.
start0() 네이티브 메서드 실행 > 이 시점에 실제 쓰레드가 생성된다. 내부에서 run() 메서드를 실행시킨다.
3.
run() 메서드 실행
java 11 기준 Thread 클래스의 코드 일부분
Thread 와 Runnable 사이의 이상한 관계
Thread 클래스는 Runnable 인터페이스를 구현하고 있다
이 부분은 객체지향적으로 바라보았을때 이해할 수 없는 부분이다.
Thread 클래스는 Runnable 객체를 생성자를 통한 의존 주입의 방식으로 외부에서 주입받고, 이를 사용하는 구조이다. 상속을 이용하는 경우에도 굳이 Runnable 을 상속할 이유가 없다. 추측하건데, 최초의 잘못된 설계가 하위 호환성을 위해 지금까지 내려 오는 것 같다.
Thread 와 Runnable 의 문제점
1.
Thread 로 쓰레드를 무진장 많이 생성할 수 있다. ⇒ 쓰레드 풀의 도입 필요
쓰레드의 숫자가 일정 개수 이상으로 늘어나면 성능에 심각한 문제가 생긴다.
2.
쓰레드의 실행 결과를 반환 받을 수 없다.
3.
API 가 지나치게 저수준이다.
Java 1.5 : Executors, Callable …
Java 1.5 에서 기존의 멀티 쓰레드를 위한 API 인 Thread 와 Runnable 의 한계를 극복하기 위해 여러 새로운 API 를 추가하였다.
Callable 과 Future
기존의 Runnable 을 이용해 멀티 스레드에서 수행할 작업을 구현하는 경우, 그 작업의 수행 결과를 반환받을 수 없었다. 이를 위해선 전혀 객체지향적이지 못한 방법을 이용해야만 했다. 이를 해결하기 위해 Callable 인터페이스가 추가되었다. 제네릭을 이용해 작업의 결과를 원하는 타입으로 반환할 수 있다.
@FunctionalInterface
public interface Callable<V> {
V call() throws Exception;
}
Java
복사
Runnable 과 마찬가지로, 단순히 위 인터페이스의 구현체의 메서드를 실행시킨다고 멀티쓰레드에서 작업이 실행되는 것이 아니다. Callable 의 구현체를 멀티쓰레드에서 실행시키기 위해서는 ExecutorService 를 이용해야 한다. 비동기 방식으로 구동하므로, 원래 쓰레드도 계속 작업을 수행할 수 있다.
void future() {
ExecutorService executorService = Executors.newSingleThreadExecutor();
Callable<String> callable = new Callable<String>() {
@Override
public String call() throws InterruptedException {
Thread.sleep(3000L);
return "Thread: " + Thread.currentThread().getName();
}
};
Future<String> future = executorService.submit(callable);
//다른 코드를 작성했다면 callable 로 생성한 작업이 끝나지 않았더라도 코드가 실행된다.
String str = future.get(); // 이 시점에서 callable 의 작업이 끝날 때 까지 블로킹한다.
}
Java
복사
Future 인터페이스는 비동기 작업을 처리하기 위한 인터페이스로, 비동기, 블로킹 방식으로 동작한다. 즉, Future.get() 메서드가 호출되기 전에 메인 쓰레드에선 멀티 쓰레드의 작업 완료 여부와 상관 없이 다른 작업들을 수행할 수 있지만, Future.get() 메서드가 호출되는 순간 멀티 쓰레드의 작업이 끝날 때 까지 메인 쓰레드는 블로킹된다. 물론, 무한정 기다리지 않도록 타임아웃을 설정 할 수 있다.
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}
Java
복사
Executors, Executor, ExecutorService : 쓰레드풀의 도입
Java 1.5에서 쓰레드 풀이 도입되었다. 관련된 인터페이스들의 계층도는 다음과 같다.
Executor : 등록된 작업을 실행하는 책임을 맡은 인터페이스
인터페이스 분리 원칙에 따라 등록된 작업을 실행하는 책임 만을 가지는 인터페이스이다. 따라서 전달받은 작업을 실행하는 메서드만 가지고 있다.
public interface Executor {
void execute(Runnable command);
}
Java
복사
ExecutorService : 비동기 작업의 등록과 종료, 실행을 담당하는 인터페이스
ExecutorService은 비동기 작업의 등록과 종료, 실행을 담당하는 인터페이스이다. 즉, 자바에서 쓰레드 풀을 만드려면 이 인터페이스를 적절히 구현하면 된다.
이 인터페이스에서 제공하는 메서드들을 분류하면
1.
비동기 작업의 등록과 실행을 위한 메서드들
2.
ExecutorService 자신의 라이프 사이클 관리를 위한 메서드들. 즉, 쓰레드 풀 자체의 라이프 사이클을 관리하기 위한 메서드들이다.
ScheduledExecutorService : 주기적인 작업 실행을 위한 인터페이스
ExecutorService 를 상속받은 인터페이스로 특정 작업을 주기적으로 실행시킬 필요가 있을 때 사용한다.
Executors : 팩토리 패턴을 이용해 쓰레드 풀을 생성게 해주는 클래스
위에서 말한 인터페이스들을 이용해 쓰레드 풀을 생성할 수 있는데, 이를 프로그래머가 직접 구현한다면 아주 힘든 고역일 것이다. 이를 위해 자바에서 미리 만들어둔 구현체를 생성할 수 있도록 하는 팩토리 클래스이다.