스레드 생성 방식과 내부 동작 원리
자바에서 스레드를 생성하는 대표적인 방법은 세 가지로 나뉜다. 첫 번째는 Thread 클래스를 상속받아 run() 메서드를 오버라이딩하는 방식이다.
new Thread() {
@Override
public void run() {
System.out.println("직접 상속을 통한 스레드 실행");
}
}.start();
두 번째는 Runnable 인터페이스를 구현하는 방법으로, 더 유연한 설계가 가능하다.
Runnable task = () -> System.out.println("Runnable을 통한 작업 수행");
new Thread(task).start();
세 번째는 반환값이 필요한 경우 사용하는 Callable과 FutureTask 조합이다. 이 방식은 비동기 작업의 결과를 추후에 가져올 수 있다.
FutureTask<String> futureTask = new FutureTask<>(new Callable<String>() {
@Override
public String call() throws Exception {
return "작업 완료";
}
});
new Thread(futureTask).start();
// 이후 필요 시 결과를 가져옴
try {
String result = futureTask.get(); // 블로킹 발생 가능
System.out.println(result);
} catch (Exception e) {
e.printStackTrace();
}
핵심 클래스 분석: Runnable, Callable, FutureTask
Runnable은 함수형 인터페이스로, 매개변수 없이 void만 반환할 수 있다. 반면 Callable은 제네릭 타입을 통해 값을 반환할 수 있으며, 예외 처리도 명시적으로 선언해야 한다.
@FunctionalInterface
public interface Callable<V> {
V call() throws Exception;
}
FutureTask는 RunnableFuture 인터페이스를 구현하며, 이는 다시 Runnable과 Future를 동시에 확장한다. 따라서 스레드에서 실행 가능하면서도 결과를 기다릴 수 있는 특성을 가진다.
public class FutureTask<V> implements RunnableFuture<V> { ... }
내부적으로 callable 필드를 통해 실제 작업을 보관하며, run() 메서드가 호출되면 해당 call() 메서드를 실행하고 결과를 저장한다. 이후 get() 메서드를 통해 결과를 안전하게 전달할 수 있다.
스레드 풀과 자원 관리
매번 새로운 스레드를 생성하면 컨텍스트 스위칭과 메모리 소비가 심해지므로, 고성능 애플리케이션에서는 스레드 풀을 사용하는 것이 표준이다. 핵심 인터페이스는 Executor이며, 이를 기반으로 다양한 구현체가 제공된다.
public interface Executor {
void execute(Runnable command);
}
대표적인 구현체인 ThreadPoolExecutor는 다음 파라미터를 기반으로 구성된다:
- corePoolSize: 유지할 최소 스레드 수
- maximumPoolSize: 생성 가능한 최대 스레드 수
- keepAliveTime: 유휴 상태의 초과 스레드가 종료되기까지 대기 시간
- workQueue: 대기 중인 작업을 저장하는 큐
- threadFactory: 스레드 생성 방식 커스터마이징
- handler: 큐가 가득 찼을 때의 거부 정책
주요 스레드 풀 팩토리 메서드
Executors 유틸리티 클래스는 일반적인 사용 사례를 위한 정적 팩토리 메서드를 제공한다.
- newFixedThreadPool(n): 고정된 크기의 스레드 풀 생성. 작업 큐는 무제한
LinkedBlockingQueue사용. - newSingleThreadExecutor(): 단일 스레드로 모든 작업을 순차 처리. 장애 발생 시 자동 복구.
- newCachedThreadPool(): 필요 시 스레드를 생성하고 60초 동안 유휴 상태면 제거. 짧고 많은 작업에 적합하지만 무제한 성장 가능.
- newScheduledThreadPool(n): 지연 또는 주기적 실행이 필요한 작업을 위한 스레드 풀.
동기화 보조 도구들
CountDownLatch – 일방향 대기
주어진 카운트가 0이 될 때까지 한 개 이상의 스레드가 대기하도록 만든다. 초기 값은 대기할 이벤트나 스레드 수로 설정되며, 각 작업 완료 시 countDown()을 호출한다. 대기 중인 스레드는 await()으로 블로킹된다.
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
new Thread(() -> {
try {
Thread.sleep(1000);
latch.countDown();
} catch (InterruptedException e) {}
}).start();
}
latch.await(); // 모든 작업 완료 시까지 대기
System.out.println("모든 작업 종료");
한 번 사용 후 재사용 불가능하다는 점에 주의.
CyclicBarrier – 반복 가능한 동기화 포인트
여러 스레드가 특정 지점에서 서로를 기다리도록 만들며, 모두 도달하면 동시에 진행된다. 생성 시 참여할 스레드 수를 지정하고, 모두 준비되면 선택적으로 리더 역할의 작업을 실행할 수 있다.
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("모든 스레드 준비 완료, 검사 시작");
});
for (int i = 0; i < 3; i++) {
new Thread(() -> {
System.out.println("데이터 준비 중...");
try {
Thread.sleep(2000);
barrier.await(); // 다른 스레드 대기
System.out.println("처리 계속");
} catch (Exception e) {}
}).start();
}
작업 완료 후 reset() 호출 없이도 자동으로 재사용 가능.
Semaphore – 동시 접근 제어
특정 자원에 대한 동시 접근 허용 수를 제한한다. 데이터베이스 연결 풀이나 하드웨어 장치 공유 등에 유용하다.
Semaphore semaphore = new Semaphore(2); // 최대 2개 동시 접근
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
semaphore.acquire(); // 허가 획득
System.out.println(Thread.currentThread().getName() + " 자원 사용 시작");
Thread.sleep(3000);
System.out.println(Thread.currentThread().getName() + " 자원 해제");
} catch (InterruptedException e) {
} finally {
semaphore.release(); // 반드시 해제
}
}).start();
}
공정성 모드 설정을 통해 대기 순서를 보장할 수도 있다.