Java Category/Java

[Java] 데몬 스레드와 스레드풀

ReBugs 2023. 8. 2.

이 게시글은 이것이 자바다(저자 : 신용권, 임경균)의 책과 동영상 강의를 참고하여 개인적으로 정리하는 글임을 알립니다. 


데몬 스레드(Daemon Thread)

  • 데몬 스레드는 주 스레드의 작업을 돕는 보조적인 역할을 수행하는 스레드이다.
  • 주 스레드가 종료되면 데몬 스레드도 따라서 자동으로 종료된다.
  • 스레드를 데몬으로 만들기 위해서는 주 스레드가 데몬이 될 스레드의 setDaemon(true)를 호출하면 된다.
주 스레드와 메인 스레드는 같을 수도 있고 다를 수도 있다.
메인 스레드에서, 다른 스레드를 데몬 스레드로 설정했다면 메인 스레드는 주 스레드가 된다.
하지만 메인 스레드가 아닌 A 스레드에서, B 스레드를 데몬 스레드로 설정했다면 B 스레드 관점에서는 A 스레드가 주 스레드가 된다.

 

아래의 코드에서는 메인 스레드에서 또 다른 스레드를 데몬 스레드로 설정했기 때문에 메인 스레드가 주 스레드가 된다.

public class Main {
	public static void main(String[] args) {
		XXXThread thread = new XXXThread();
		thread.setDaemon(true);
		thread.start();
	}
}

따라서 메인 스레드가 종료되면 데몬 스레드도 종료된다.

 

아래의 예제는 메인 스레드에서 자동 저장 스레드를 데몬 스레드로 설정한다.

메인 스레드가 종료되면 자동 저장 스레드도 종료되는 것을 보여준다.

 

AutoSaveThread.java

public class AutoSaveThread extends Thread {
	public void save() {
		System.out.println("작업 내용을 저장함.");
	}

	@Override
	public void run() {
		while(true) {
			try {
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				break;
			}
			save();
		}
	}
}

 

DaemonExample.java

public class DaemonExample {
	public static void main(String[] args) {
		AutoSaveThread autoSaveThread = new AutoSaveThread();
		autoSaveThread.setDaemon(true);
		autoSaveThread.start();

		try {
			Thread.sleep(3000);
		} catch (InterruptedException e) {
		}

		System.out.println("메인 스레드 종료");
	}
}
/*
작업 내용을 저장함.
작업 내용을 저장함.
메인 스레드 종료
*/

 


 

스레드풀(ThreadPool)

무분별하게 스레드를 늘리면 CPU 사용량이 증가하고 메모리 사용량이 늘어난다.

이에 따라 애플리케이션의 성능 또한 급격히 저하된다.

이렇게 병렬 작업 증가로 인한 스레드의 폭증을 막으려면 스레드풀을 사용하는 것이 좋다.

 

  • 스레드 풀은 작업 처리에 사용되는 스레드를 제한된 개수만큼 정해놓는다.
  • 이후 작업들은 작업 큐(Queue)에 들어가게 된다.
  • 작업 큐에 있는 작업들은 스레드가 하나씩 맡아서 처리한다.
예를 들어
작업이 20개 있고 최대 스레드 수가 5개라고 하면, 작업 1, 2, 3, 4, 5가 각각 스레드 1, 2, 3, 4, 5에 배정되고 스레드 1에 배정된 작업 1이 끝나면 스레드 1에 작업 6이 배정된다. 스레드 2에 배정된 작업 2가 끝나면 스레드 2에 작업 7이 배정된다.
이렇게 최대 스레드 수는 5개로 제한하고 먼저 작업이 끝나는 스레드에 작업 큐에 있는 다음 작업을 처리하도록 맡기는 것이다.

이렇게 하면 작업량이 증가해도 스레드의 개수가 늘어나지 않아 성능 저하가 발생하지 않는다.

출처 : 이것이 자바다 유튜브 동영상 강의

 


 

스레드풀 생성

자바는 스레드풀을 생성하고 사용할 수 있도록 java.util.concurrent 패키지에서 ExecutorService 인터페이스와 Executors 클래스를 제공하고 있다.

Executors의 아래의 두 정적 메소드를 사용하면 간단하게 스레드풀인 ExecutorsService 구현 객체를 만들 수 있다.

메소드명(매개변수) 초기 수  코어 수 최대 수
newCachedThreadPool() 0 0 int 범위 최대 값(이론상)
newFixedThreadPool(int n) 0 생성된 수 n
  • 초기 수 : 스레드풀이 생성될 때 기본적으로 생성되는 스레드 수
  • 코어 수 : 스레드가 증가된 후 사용되지 않는 스레드를 제거할 때 최소한 풀에서 유지하는 스레드 수
  • 최대 수 : 증가되는 스레드의 한도 수

 

newCachedThreadPool()

  • newCachedThreadPool()로 생성된 스레드풀의 초기 수와 코어 수는 0개이고, 작업 개수가 많아지면 새 스레드를 생성시켜 작업을 한다.
  • 이 메소드는 스레드 최대 수를 정하지 않는다. 따라서 이론상 int 범위 최대 값인 약 21억 개의 스레드가 생성될 수 있다.
  • 60초 동안 스레드가 아무 작업을 하지 않으면 스레드를 풀에서 제거한다.
ExecutorService executorService = Executors.newCachedThreadPool()

 

 

newFixedThreadPool(int n)

  • newFixedThreadPool(int n)로 생성된 스레드 풀의 초기 수는 0개이고, 작업 개수가 많아지면 최대 n개까지 스레드를 생성시켜 작업을 처리한다.
  • 이 스레드 풀의 특징은 생성된 스레드를 제거하지 않는다는 것이다. 
    즉, 최대가 n이고 n보다 작은 m개가 생성되었다면 코어수는 m이 된다.
ExecutorService executorService = Executors.newFixedThreadPool(5);
스레드풀 종료
newCachedThreadPool()와 달리 newFixedThreadPool(int n)은 스레드가 아무 작업을 하지 않아도 스레드는 제거가 되지 않는다

 

 

사용자 정의 스레드풀

  • 위 두 메소드를 사용하지 않고 직접 ThreadPoolExecutor로 스레드풀을 생성할 수도 있다.
  • 아래의 예시는 초기 수 0개, 코어 수 3개, 최대 수 100개인 스레드풀을 생성하는 코드이다.
  • 스레드가 120초 동안 놀고 있을 경우 해당 스레드를 풀에서 제거한다.
ExecutorService threadPool = new ThreadPoolExecutor(
    3, // 코어 스레드 개수
    100, // 최대 스레드 개수
    120L, // 최대 놀 수 있는 시간 (이 시간 넘으면 스레드 풀에서 쫓겨 남.)
    TimeUnit.SECONDS, // 놀 수 있는 시간 단위
    new SynchronousQueue<Runnable>() // 작업 큐
);

 


 

스레드풀 종료

스레드풀의 스레드는 기본적으로 데몬 스레드가 아니기 때문에 main 스레드가 종료되더라도 작업을 처리하기 위해 계속 실행 상태로 남아 있다.

스레드풀의 모든 스레드를 종료하려면 ExecutorService의 다음 두 메소드 중 하나를 실행해야 한다.

리턴 타입 메소드명(매개변수) 설명
void shutdown() 현재 처리 중인 작업뿐만 아니라 작업 큐에 대기하고 있는 모든 작업을 처리한 뒤에 스레드풀을 종료시킨다.
List(Runnable) shutdownNow() 현재 작업 처리 중인 스레드를 interrupt해서 작업을 중지시키고 스레드풀을 종료시킨다. 또한 작업 큐에 있는 미처리된 작업을 리턴한다.
  • shutdown() : 남아있는 작업을 마무리하고 스레드풀을 종료할 때
  • shutdownNow() : 남아있는 작업과 상관없이 강제 종료를 할 때

 

아래의 예제는 최대 5개의 스레드로 운영되는 스레드풀을 생성하고 종료한다.

ExecutorServiceExample.java

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
	
public class ExecutorServiceExample {
	public static void main(String[] args) {
		//스레드풀 생성
		ExecutorService executorService = Executors.newFixedThreadPool(5);
		//작업 생성과 처리 요청
		//스레드풀 종료
		executorService.shutdownNow();
	}
}

 


 

작업 생성과 처리 요청

작업 생성

하나의 작업은 Runnable 또는 Callable 구현 객체로 표현한다.

  • Runnable : 작업 처리 완료 후 리턴값 없음
  • Callable : 작업 처리 완료 후 리턴값 있음
  • 각각 run() 메소드와 call() 메소드를 호출시켜서 작업을 생성한다.

 

Runnable 익명 구현 객체

new Runnable(){
	@Override
	public void run(){
    //스레드가 처리할 작업 내용
    }
}

 

 

Callable 익명 구현 객체

new Callable<T>(){
	@Override
	public T call() throws Exception {
    //스레드가 처리할 작업 내용
    return T;
    }
}
call() 의 리턴 타입은 Callable <T>에서 지정한 T 타입 파라미터와 동일한 타입이어야 한다.(제네릭 개념)

 

 

작업 처리 요청

작업 처리 요청이란 ExcutorService의 작업 큐에 Runnable 또는 Callable 객체를 넣는 행위를 말한다.

작업 처리 요청을 위해 ExecutorService는 아래의 두 가지 메소드를 제공한다.

리턴 타입 메소드명(매개변수) 설명
void execute(Runnable command) -Runnable을 작업 큐에 저장
-작업 처리 결과를 리턴하지 않음
Future<T> submit(Callable<T> task) -Callable을 작업 큐에 저장
-작업 처리 결과를 얻을 수 있도록 Future를 리턴
  1. Runnable 또는 Callable 객체가 ExecutorService의 작업 큐에 들어간다.
  2. ExecutorService는 들어온 객체를 처리할 스레드가 있는지 보고, 없다면 스레드를 새로 생성시킨다.
  3. 스레드는 작업 큐에서 Runnable 또는 Callable 객체를 꺼내와 run() 또는 call() 메소드를 실행하면서 작업을 처리한다.
    즉, 작업 처리 요청을 한 뒤, 작업을 생성하고 작업을 처리한다.

 

아래의 예제에 대한 설명

  • 이메일을 보내는 작업으로, 1000개의 Runnable을 생성한 다음 execute() 메소드로 작업 큐에 넣는다.
  • ExcutorService는 최대 5개 스레드로 작업 큐에서 Runnable을 하나씩 꺼낸다.
  • 꺼낸 Runnable 객체의 run() 메소드를 실행하면서 작업을 처리한다.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class RunnableExecuteExample {

	public static void main(String[] args) {
		//1000개의 메일 생성
		String[][] mails = new String[1000][3];
		for(int i=0; i<mails.length; i++) {
			mails[i][0] = "admin@my.com";
			mails[i][1] = "member"+i+"@my.com";
			mails[i][2] = "신상품 입고";
		}
		
		//ExecutorService 생성
		ExecutorService executorService = Executors.newFixedThreadPool(5);
		
		//이메일을 보내는 작업 생성 및 처리 요청
		for(int i=0; i<1000; i++) {
			final int idx = i;
			executorService.execute(new Runnable() {
				@Override
				public void run() {
					Thread thread = Thread.currentThread();
					String from = mails[idx][0];
					String to = mails[idx][1];
					String content = mails[idx][2];
					System.out.println("[" + thread.getName() + "] " + from + " ==> " + to + ": " + content);
				}
			});
		}
			
		//ExecutorService 종료
		executorService.shutdown();
	}
}
/*
....
....
....
[pool-1-thread-4] admin@my.com ==> member987@my.com: 신상품 입고
[pool-1-thread-4] admin@my.com ==> member988@my.com: 신상품 입고
[pool-1-thread-4] admin@my.com ==> member989@my.com: 신상품 입고
[pool-1-thread-5] admin@my.com ==> member974@my.com: 신상품 입고
[pool-1-thread-5] admin@my.com ==> member991@my.com: 신상품 입고
[pool-1-thread-4] admin@my.com ==> member990@my.com: 신상품 입고
[pool-1-thread-4] admin@my.com ==> member993@my.com: 신상품 입고
[pool-1-thread-3] admin@my.com ==> member984@my.com: 신상품 입고
[pool-1-thread-3] admin@my.com ==> member995@my.com: 신상품 입고
[pool-1-thread-3] admin@my.com ==> member996@my.com: 신상품 입고
[pool-1-thread-2] admin@my.com ==> member983@my.com: 신상품 입고
[pool-1-thread-2] admin@my.com ==> member998@my.com: 신상품 입고
[pool-1-thread-1] admin@my.com ==> member981@my.com: 신상품 입고
[pool-1-thread-2] admin@my.com ==> member999@my.com: 신상품 입고
[pool-1-thread-3] admin@my.com ==> member997@my.com: 신상품 입고
[pool-1-thread-4] admin@my.com ==> member994@my.com: 신상품 입고
[pool-1-thread-5] admin@my.com ==> member992@my.com: 신상품 입고
*/
//이메일을 보내는 작업 생성 및 처리 요청에서의 final int
executorService.execute에 Runnable 인터페이스의 구현 객체를 대입해야 한다.
위 예제에서는 익명 구현 객체를 대입한다.
따라서 로컬 변수를 로컬 클래스에서 사용할 때 제한이 있다.

 

아래의 예제에 대한 설명

  1. 자연수 덧셈을 하는 작업으로 100개의 Callable을 생성하고 submit() 메소드로 작업 큐에 넣는다.
  2. ExecutorService는 최대 5개 스레드로 작업 큐에서 Callable 객체를 하나씩 꺼낸다.
  3. 꺼낸 객체의 call() 메소드를 실행하면서 작업을 처리한다.
  4. Future의 get() 메소드는 작업이 끝날 때까지 기다렸다가 call() 메소드가 리턴한 값을 리턴한다.
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
	
public class CallableSubmitExample {
	public static void main(String[] args) {
		//ExecutorService 생성
		ExecutorService executorService = Executors.newFixedThreadPool(5);

		//계산 작업 생성 및 처리 요청
		for(int i=1; i<=100; i++) {
			final int idx = i;
			Future<Integer> future = executorService.submit(new Callable<Integer>() {
				@Override
				public Integer call() throws Exception {
					int sum = 0;
					for(int i=1; i<=idx; i++) {
						sum += i;
					}
					Thread thread = Thread.currentThread();
					System.out.println("[" + thread.getName() + "] 1~" + idx + " 합 계산");
					return sum;
				}
			});

			try {
				int result = future.get();
				System.out.println("\t리턴값: " + result);
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
		
		//ExecutorService 종료
		executorService.shutdown();
	}
}
/*
...
...
...
[pool-1-thread-2] 1~92 합 계산
	리턴값: 4278
[pool-1-thread-3] 1~93 합 계산
	리턴값: 4371
[pool-1-thread-4] 1~94 합 계산
	리턴값: 4465
[pool-1-thread-5] 1~95 합 계산
	리턴값: 4560
[pool-1-thread-1] 1~96 합 계산
	리턴값: 4656
[pool-1-thread-2] 1~97 합 계산
	리턴값: 4753
[pool-1-thread-3] 1~98 합 계산
	리턴값: 4851
[pool-1-thread-4] 1~99 합 계산
	리턴값: 4950
[pool-1-thread-5] 1~100 합 계산
	리턴값: 5050
*/

댓글