[김영한의 실전 자바 - 고급 1편] 스레드 제어와 생명주기1

Turtle·2024년 8월 27일
0
post-thumbnail

🏷️스레드에 담긴 정보

import static thread.util.Logger.log;

public class ThreadInfo {

	public static void main(String[] args) {

		Thread mainThread = Thread.currentThread();
		log("mainThread : " + mainThread);										// 쓰레드 객체 정보
		log("mainThread.threadId() : " + mainThread.threadId());				// 쓰레드 ID
		log("mainThread.getName() : " + mainThread.getName());					// 쓰레드 이름
		log("mainThread.getPriority() : " + mainThread.getPriority());			// 쓰레드 우선순위
		log("mainThread.getThreadGroup() : " + mainThread.getThreadGroup());	// 쓰레드 쓰레드 그룹
		log("mainThread.getState() : " + mainThread.getState());				// 쓰레드 상태
	}
}

실행 결과

11:50:30.134 [     main] mainThread : Thread[#1,main,5,main]
11:50:30.145 [     main] mainThread.threadId() : 1
11:50:30.146 [     main] mainThread.getName() : main
11:50:30.152 [     main] mainThread.getPriority() : 5
11:50:30.152 [     main] mainThread.getThreadGroup() : java.lang.ThreadGroup[name=main,maxpri=10]
11:50:30.153 [     main] mainThread.getState() : RUNNABLE

쓰레드 정보 설명

  • 쓰레드 ID : JVM 내에서 각 쓰레드에 대해 유일한 값을 가진다. ID는 쓰레드가 생성될 때 할당되며, 직접 지정할 수 없다.
  • 쓰레드 이름 : 쓰레드의 이름을 반환한다. ID는 중복될 수 없으나 이름은 중복될 수 있다.
  • 쓰레드 우선순위 : 쓰레드의 우선순위를 반환한다. 기본값은 5이다.
  • 쓰레드 그룹 : 쓰레드가 속한 쓰레드 그룹을 반환한다.
  • 쓰레드 상태 : 쓰레드의 현재 상태를 반환한다.

🏷️쓰레드의 생명주기

  • New(새로운 상태): 쓰레드가 생성되었으나 아직 시작되지 않은 상태
  • Runnable(실행 가능 상태): 쓰레드가 실행 중이거나 실행될 준비가 된 상태
  • 일시 중지 상태 :
    • Blocked(차단 상태): 쓰레드가 동기화 락을 기다리는 상태
    • Waiting(대기 상태): 쓰레드가 무기한으로 다른 쓰레드의 작업을 기다리는 상태
    • Timed Waiting(시간 제한 대기 상태): 쓰레드가 일정 시간 동안 다른 쓰레드의 작업을 기다리는 상태
  • Terminated(종료 상태): 쓰레드 실행이 완료된 상태
  1. New(새로운 상태)
  • 쓰레드가 생성되고 아직 시작되지 않은 상태
  • 이 상태에서는 Thread 객체가 생성되지만, start() 메서드가 호출되지 않은 상태
  1. Runnable(실행 가능 상태)
  • 쓰레드가 실행될 준비가 된 상태
  • start() 메서드가 호출되면 쓰레드는 이 상태로 들어간다.
  • 모든 Runnable 상태의 쓰레드가 동시에 실행되는 것은 아니다. 운영체제 스케줄러가 각 쓰레드에 CPU 시간을 할당하여 실행하기 때문에 Runnable 상태에 있는 쓰레드는 스케줄러 실행대기 큐에 포함되어 있다가 차례로 CPU에서 실행된다.
  • 운영체제 스케줄러 실행대기 큐에 있든, CPU에서 실제 실행되고 있든 모두 Runnable 상태이기에 자바는 둘을 구분할 수 없다.
  1. Blocked(차단 상태)
  • 쓰레드가 다른 쓰레드에 의해 동기화 락을 얻기 위해 기다리는 상태
  1. Waiting(대기 상태)
  • 쓰레드가 다른 쓰레드의 특정 작업이 완료되기를 무기한 기다리는 상태
  • wait(), join() 메서드가 호출될 때 이 상태가 된다.
  1. Timed Waiting(시간 제한 대기 상태)
  • 쓰레드가 특정 시간 동안 다른 쓰레드의 작업이 완료되기를 기다리는 상태
  • sleep(long millis), wait(long timeout), join(long millis) 메서드가 호출될 때 이 상태가 된다.
  • 주어진 시간이 경과했거나 다른 쓰레드가 해당 쓰레드를 깨우면 이 상태에서 벗어난다.
  1. Terminated(종료 상태)
  • 쓰레드 실행이 완료된 상태
  • 쓰레드가 정상적으로 종료되거나 예외가 발생하여 종료된 경우 이 상태로 들어간다.
  • 쓰레드는 한 번 종료되면 다시 시작이 불가능하다. 동일한 내용의 쓰레드 작업을 다시 수행하려면 새로 쓰레드를 생성해야 한다.
import static thread.util.Logger.log;

public class ThreadState {

	public static void main(String[] args) throws InterruptedException {
		Thread thread = new Thread(new MyRunnable(), "myThread");
		log("myThread.getState1() : " + thread.getState());							
		thread.start();																
		Thread.sleep(1000);													
		log("myThread.getState3() : " + thread.getState()); 						
		Thread.sleep(4000);													
		log("myThread.getState5() : " + thread.getState()); 						
	}

	static class MyRunnable implements Runnable {

		@Override
		public void run() {
			try {
				log("start");
				log("myThread.getState2() : " + Thread.currentThread().getState());	
				log("sleep() start");
				Thread.sleep(3000);
				log("sleep() end");
				log("myThread.getState4() : " + Thread.currentThread().getState());	
				log("end");
			} catch (InterruptedException e) {
				throw new RuntimeException(e);
			}
		}
	}
}

실행 결과

14:07:51.401 [     main] RUNNABLE
14:07:51.416 [     main] thread.getState1() : NEW
14:07:51.417 [     main] RUNNABLE
14:07:51.417 [ myThread] start
14:07:51.418 [ myThread] myThread.getState2() : RUNNABLE
14:07:51.418 [ myThread] sleep() start
14:07:52.429 [     main] myThread.getState3() : TIMED_WAITING
14:07:54.424 [ myThread] sleep() end
14:07:54.425 [ myThread] myThread.getState4() : RUNNABLE
14:07:54.426 [ myThread] end
14:07:56.430 [     main] myThread.getState5() : TERMINATED

🏷️체크 예외 재정의

Runnable 인터페이스의 원형은 아래와 같다.

@FunctionalInterface
public interface Runnable {
   void run();
}

쓰레드를 생성할 때 이 Runnable 인터페이스를 구현하여 만든다.
자바에서 메서드 오버라이딩을 할 때, 재정의 메서드가 지켜야할 예외와 관련된 규칙이 있다.

  • 체크 예외
    • 부모 메서드가 체크 예외를 던지지 않는 경우 재정의된 자식 메서드 역시 체크 예외를 던질 수 없다.
      • 그래서 throw new Exception()이 컴파일되지 않는 것
    • 자식 메서드는 부모 메서드가 던질 수 있는 체크 예외의 하위 타입만 던질 수 있다.
    • main은 체크 예외를 밖으로 던지는 것이 가능하나, run은 체크 예외를 밖으로 던질 수 없다.
      • 원형에서도 볼 수 있다시피 체크 예외를 던지는 부분이 없기 때문에
  • 언체크 예외
    • 예외 처리를 강제하지 않으므로 상관없이 던질 수 있다.

🏷️join

import static thread.util.Logger.log;
import static thread.info.ThreadUtils.sleep;

public class JoinMainV1 {

	public static void main(String[] args) {
		SumTask task1 = new SumTask(1, 50);
		SumTask task2 = new SumTask(51, 100);
		Thread thread1 = new Thread(task1, "thread-1");
		Thread thread2 = new Thread(task2, "thread-2");

		thread1.start();
		thread2.start();

		log(task1.totalValue);
		log(task2.totalValue);
	}
	
	static class SumTask implements Runnable {
		
		int startValue;
		int endValue;
		int totalValue;

		public SumTask(int startValue, int endValue) {
			this.startValue = startValue;
			this.endValue = endValue;
		}

		@Override
		public void run() {
			log("작업 시작");
			sleep(2000);	// 대략 2초 걸린다고 가정
			int sum = 0;
			for (int i = startValue; i <= endValue; i++) {
				sum += i;
			}
			totalValue = sum;
			log("작업 완료");
		}
	}
}

실행 결과

15:17:13.446 [ thread-1] 작업 시작
15:17:13.446 [     main] 0
15:17:13.446 [ thread-2] 작업 시작
15:17:13.450 [     main] 0
15:17:15.478 [ thread-2] 작업 완료
15:17:15.478 [ thread-1] 작업 완료

👉main 쓰레드에서 두 쓰레드(task1, task2)를 생성하고 두 쓰레드에서 연산을 진행하면서 원하는 대로 결과를 출력할 수 있을거라고 생각했으나 결과를 보니 두 쓰레드의 연산 결과가 0이 나오는 문제가 발생한다.

왜냐하면 쓰레드의 연산에는 대략 2초가 걸린다고 가정했기에 연산의 결과가 저장되지 않은 상태에서 main 쓰레드에서 바로 결과를 조회하려고 했기에 결과가 0이 나오게 되는 것이다.

인스턴스 메서드를 호출하면 어떤 인스턴스의 메서드를 호출했는지 기억하기 위해 해당 인스턴스 참조값을 스택 프레임에 저장한다. 이것이 바로 this이다.

특정 메서드 안에서 this를 호출하면 스택 프레임에 저장된 this값을 불러서 사용한다.

정리하면 이 this는 호출된 인스턴스 메서드가 소속된 객체의 참조값을 가리키며 이것이 스택 프레임 내부에 저장되어 있는 것이다.

🏷️문제 해결1 - sleep 사용

간단하다. 현재 연산을 담당하는 쓰레드의 경우 작업 시간은 대략 2초가 걸리므로 main 쓰레드에서 3초 정도만 기다려주면 연산 쓰레드의 결과를 확인할 수 있게 된다.

import static thread.util.Logger.log;
import static thread.info.ThreadUtils.sleep;

public class JoinMainV1 {

	public static void main(String[] args) {
		SumTask task1 = new SumTask(1, 50);
		SumTask task2 = new SumTask(51, 100);
		Thread thread1 = new Thread(task1, "thread-1");
		Thread thread2 = new Thread(task2, "thread-2");

		thread1.start();
		thread2.start();

		sleep(3000);	// main 쓰레드에서 기다리기

		log(task1.totalValue);
		log(task2.totalValue);
	}
	
	static class SumTask implements Runnable {
		
		int startValue;
		int endValue;
		int totalValue;

		public SumTask(int startValue, int endValue) {
			this.startValue = startValue;
			this.endValue = endValue;
		}

		@Override
		public void run() {
			log("작업 시작");
			sleep(2000);	// 대략 2초 걸린다고 가정
			int sum = 0;
			for (int i = startValue; i <= endValue; i++) {
				sum += i;
			}
			totalValue = sum;
			log("작업 완료");
		}
	}
}

실행 결과

15:37:46.315 [ thread-1] 작업 시작
15:37:46.315 [ thread-2] 작업 시작
15:37:48.334 [ thread-2] 작업 완료
15:37:48.334 [ thread-1] 작업 완료
15:37:49.287 [     main] 1275
15:37:49.287 [     main] 3775

👉위와 같이 sleep()을 사용하면 해결이 가능하나 무작정 기다리는 방법은 여러 손해가 발생할 수 있고 연산 담당 쓰레드의 수행 시간이 달라지는 경우 정확한 타이밍을 예측하기 어렵다.

join() 메서드를 사용하면 깔끔하게 문제를 해결할 수 있다.

🏷️문제 해결2 - join 사용

import static thread.info.ThreadUtils.sleep;
import static thread.util.Logger.log;

public class JoinMainV2 {

	public static void main(String[] args) throws InterruptedException {
		SumTask task1 = new SumTask(1, 50);
		SumTask task2 = new SumTask(51, 100);
		Thread thread1 = new Thread(task1, "thread-1");
		Thread thread2 = new Thread(task2, "thread-2");

		thread1.start();
		thread2.start();

		// 쓰레드가 종료될 때까지 대기
		thread1.join();
		thread2.join();

		log(task1.totalValue);
		log(task2.totalValue);
	}
	
	static class SumTask implements Runnable {
		
		int startValue;
		int endValue;
		int totalValue;

		public SumTask(int startValue, int endValue) {
			this.startValue = startValue;
			this.endValue = endValue;
		}

		@Override
		public void run() {
			log("작업 시작");
			sleep(2000);	// 대략 2초 걸린다고 가정
			int sum = 0;
			for (int i = startValue; i <= endValue; i++) {
				sum += i;
			}
			totalValue = sum;
			log("작업 완료");
		}
	}
}

실행 결과

15:43:16.891 [ thread-2] 작업 시작
15:43:16.891 [ thread-1] 작업 시작
15:43:18.901 [ thread-1] 작업 완료
15:43:18.901 [ thread-2] 작업 완료
15:43:18.902 [     main] 1275
15:43:18.902 [     main] 3775

main쓰레드에서 아래 코드를 실행하면 thread1, thread2가 끝날 때까지 기다린다. 이 때, main쓰레드는 WAITING 상태가 된다. thread1, thread2가 끝나면 main쓰레드의 상태는 다시 RUNNABLE이 된다.

WAITING(대기 상태)

  • 쓰레드가 다른 쓰레드의 특정 작업이 완료되기를 무기한 기다리는 상태
  • join()을 호출하는 쓰레드는 대상 쓰레드가 TERMINATED 상태가 될 때까지 대기한다. 대상 쓰레드가 TERMINATED 상태가 되면 join()을 호출하는 쓰레드는 다시 RUNNABLE 상태가 된다.

하지만 join()의 단점은 다른 쓰레드가 완료될 때까지 무기한 기다려야 한다는 것이다. 다른 쓰레드의 작업을 일정 시간 동안만 기다리고 싶다면 join() 내부 파라미터로 특정 시간을 지정해주면 된다.

thread1.join();
thread2.join();

🏷️문제 해결3 - join 특정 시간 만큼만 대기

join()은 두 가지 메서드가 있다.

  • join() : 호출 쓰레드는 대상 쓰레드가 완료될 때까지 무기한 기다린다.
  • join(ms) : 호출 쓰레드는 특정 시간 만큼만 대기한다. 호출 쓰레드는 지정한 시간이 지나면 다시 RUNNABLE 상태가 되면서 다음 라인의 코드를 수행한다.
import static thread.info.ThreadUtils.sleep;
import static thread.util.Logger.log;

public class JoinMainV3 {

	public static void main(String[] args) throws InterruptedException {
		SumTask task1 = new SumTask(1, 50);
		Thread thread1 = new Thread(task1, "thread-1");

		thread1.start();

		// 쓰레드가 종료될 때까지 대기
		log("join(1000) - main 스레드가 thread1 종료까지 1초 대기");
		thread1.join(1000);
		log("main 쓰레드 대기 완료");
		log("task1.totalValue : " + task1.totalValue);
	}
	
	static class SumTask implements Runnable {
		
		int startValue;
		int endValue;
		int totalValue;

		public SumTask(int startValue, int endValue) {
			this.startValue = startValue;
			this.endValue = endValue;
		}

		@Override
		public void run() {
			log("작업 시작");
			sleep(2000);	// 대략 2초 걸린다고 가정
			int sum = 0;
			for (int i = startValue; i <= endValue; i++) {
				sum += i;
			}
			totalValue = sum;
			log("작업 완료 totalValue : " + totalValue);
		}
	}
}

실행 결과

16:00:43.922 [ thread-1] 작업 시작
16:00:43.922 [     main] join(1000) - main 스레드가 thread1 종료까지 1초 대기
16:00:44.934 [     main] main 쓰레드 대기 완료
16:00:44.940 [     main] task1.totalValue : 0
16:00:45.933 [ thread-1] 작업 완료 totalValue : 1275

  1. main쓰레드에서 join(1000)을 사용해서 연산 쓰레드를 1초간 기다린다.
  2. join(1000)을 호출한 후 main쓰레드의 상태는 RUNNABLE → TIMED_WAITING이 된다.
  3. 연산 쓰레드의 작업은 2초가 걸린다.
  4. main쓰레드에서 기다리는 시간은 1초로 아직 연산 쓰레드에서 연산 수행이 완료가 되지 않은 상태이다. 1초가 지나면 main쓰레드의 상태는 TIMED_WAITING → RUNNABLE이 된다.
  5. main쓰레드의 대기가 완료되었다는 부분 이후 log("task1.totalValue : " + task1.totalValue); 코드 라인을 실행하게 되면 아직 연산이 완료되지 않아 0이 터미널에 출력된다.
  6. main쓰레드가 종료된 이후 연산 쓰레드가 종료되므로 연산 쓰레드가 종료되면서 log("작업 완료 totalValue : " + totalValue); 코드 라인이 실행되면서 결괏값이 터미널에 출력된다.

🏷️문제와 풀이

총 실행 시간이 얼마나 소요될지 예측해본다.

import static thread.info.ThreadUtils.sleep;
import static thread.util.Logger.log;

public class JoinTest1Main {

	public static void main(String[] args) throws InterruptedException {
		Thread thread1 = new Thread(new MyTask(), "thread1");
		Thread thread2 = new Thread(new MyTask(), "thread2");
		Thread thread3 = new Thread(new MyTask(), "thread3");

		thread1.start();
		thread1.join();

		thread2.start();
		thread2.join();

		thread3.start();
		thread3.join();
		
		log("모든 쓰레드 실행 완료");
	}

	static class MyTask implements Runnable {

		@Override
		public void run() {
			for (int i = 1; i <= 3; i++) {
				log(i);
				sleep(1000);
			}
		}
	}
}

위의 코드 총 소요시간은 각 쓰레드당 걸리는 시간(3초) x 쓰레드 3개로 총 9초의 시간이 소요된다. 이를 병렬 처리하여 3초로 단축한다.

import static thread.info.ThreadUtils.sleep;
import static thread.util.Logger.log;

public class JoinTest1Main {

	public static void main(String[] args) throws InterruptedException {
		Thread thread1 = new Thread(new MyTask(), "thread1");
		Thread thread2 = new Thread(new MyTask(), "thread2");
		Thread thread3 = new Thread(new MyTask(), "thread3");

		// 쓰레드 3개를 병렬로 처리(9초 → 3초)
		thread1.start();
		thread2.start();
		thread3.start();

		thread1.join();
		thread2.join();
		thread3.join();

		log("모든 쓰레드 실행 완료");
	}

	static class MyTask implements Runnable {

		@Override
		public void run() {
			for (int i = 1; i <= 3; i++) {
				log(i);
				sleep(1000);
			}
		}
	}
}

0개의 댓글