Java (Multi Thread)

yihyun·2024년 8월 6일

Java

목록 보기
12/12
post-thumbnail

Multi Thread

Thread는 어떤 program 을 실행하게 해주는 원동력이다.

대표적으로 main(String[] args)이 있는데 이 main()은 Thread 를 동작하게 해주는 method이다.
(그래서 main Thread라고 이야기 한다.)

Process 도 program을 움직이지만 Thread와 다른 점은 process는 1개만 존재하지만 하나의 process에는 1개 이상의 Thread가 존재한다.
(간단한 process는 1개의 Thread, 복잡한 process는 n개의 Thread로 구성)

❕ 1개의 program 을 ▶ process 라고 하고, 이 안에 ▶ 여러 Thread 가 있다.

이것을 공학적인 관점에서 바라본다면 process 간에는 메모리를 공유하지 않지만 Thread는 메모리를 공유하고 있다.

예를들어 이클립스에 글 작성 기능과 자동완성 기능은 메모리를 공유하기 때문에 서로가 무슨 일을 하고 있는지 알 수 있지만 이클립스의 자동완성 기능을 메모장에서는 사용할 수 없다.

즉, 정리하자면 process 간에는 memory Share 가 되지 않지만 같은 process 내 Thread 간에는 memory Share가 된다. (process 간에는 서로 뭘 하는지 모른다.)


work Thread

main Thread는 자신을 복사해서 자신을 위해 일할 work Thread를 생성할 수 있는데 이것을 multi Thread라고 부른다.
single Thread : Thread가 1개인 것

multi Thread는 많은 좋은 점이 있지만 내 말을 듣지 않는다는 담점이 있다.
(실행 순서가 다르다거나, 값을 바꾼다거나...💦)

그래서 우리는 이 다른 행동을 어떻게 잡을 수 있을지를 알아보고 공부해야 한다.

정리
1. Thread 는 program 을 움직이는 원동력이다
2. process 는 하나 이상의 Thread 로 되어 있다.
3. main Thread 로 여러 개의 work Thread 를 만들 수 있다.


Thread 생성 방법

Thread 생성 방법은 2가지가 있고 두 방법 모두 익명 객체를 활용할 수 있다.
1. Runnable interface 구현

  • Override을 강제로 해주기 때문에 편리하다.
    2. Thread class 상속
  • 별도의 Thread 객체 생성 없이 사용 가능하다. (다형성)
  • 강제 Override가 되지 않는다.
  • 단일 thread일 때 주로 사용한다.

work Thread는 반드시 할 일을 주어준 뒤 복사해줘야 한다.

Runnable interface 구현 (Runnable 구현 및 run() Override)

public class Job implements Runnable {
	@Override
	public void run() { // Overried 내용을 달라져도 된다.
		for (int i = 1; i <= 5; i++) {
			System.out.println("워크 스레드가 하는 일");
			try {
				Thread.sleep(500);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
}

사용방법 1 : 객체 생성

Job job = new Job();
Thread work = new Thread(job);
work.start();

사용방법 2 : 익명 객체

public class Annony {
	public static void main(String[] args) throws InterruptedException {

		Runnable runn = new Runnable() {
			@Override
			public void run() {
				for (int i = 1; i <= 5; i++) {
					System.out.println("work Thread run");
					try {
						Thread.sleep(500);
					} catch (InterruptedException e) {
						e.printStackTrace();
					} 
				}
			}
		}; 
		Thread work = new Thread(runn);
    }
}

Thread class 상속

public class Job extends Thread {
	@Override
	public void run() {
		for (int i = 0; i < 5; i++) {
			System.out.println("work thread run...");
			try {
				Thread.sleep(500);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
}

사용방법 1 : 객체 생성
상속 받았기 때문에 다형성에 의해 별도의 thread 객체 생성 없이 사용이 가능하다.

Job job = new Job();

job.start();

사용방법 2 : 익명 객체

public static void main(String[] args) throws InterruptedException {
	Runnable runn = new Runnable() {
		@Override
		public void run() {
				System.out.println("워크 스레드가 돌리고 있었다.");
				try {
					Thread.sleep(500);
				} catch (InterruptedException e) {
					e.printStackTrace();
				} 
			}
		}
	}; 
	Thread work = new Thread(runn);
		
	work.start();
}

*쓰레드 익명객체(클래스)는 직접 만들어줘야 한다.

Thread th = new Thread() {}; // run 메소드 오버라이딩 필요

Thread Name

Thread는 구별을 위한 이름이 필요하고, 이 이름은 1. 자동으로 주어지거나 2. 직접 만들어줄 수 있다.

이름을 지정하지 않으면 Thread-n (0부터 시작) 형태의 이름이 자동 지정된다.

다른 이름을 지정하고 싶다면 setName() 을 사용하면 된다.
setName() : 이름 지정하기 Thread 이름은 private
getName() : 이름 가져오기 Thread 이름은 private

Thread 제어 (Round Robin)

Thread 에는 2가지 문제점이 있는데 그 중 하나가 바로 순서가 정해져있지 않다는 것이다.

여러 Thread를 실행하다 보면 순서가 제멋대로 인 것을 볼 수 있는데,
이유는 ThreadRound Robin 방식을 사용하기 때문이다.
※ Round Robin : 이어달리기 처럼 빨리 처리된 것이 다음 일을 받는 방식


Synchronized (동기화)

또 하나의 문제는 Synchronized 가 있다.

Thread는 momory를 공유하기 때문에 객체 간의 데이터 간섭이 일어날 수 있기 때문에 Synchronize(동기화) 를 통해 자신의 작업이 다 끝나기 전에는 아무도 접근하지 못하게 해줘야 한다.
(하나의 객체를 여러 thread가 사용할 경우 *객체가 다르면 상관 없다.)

방식은 1. 동기화 메서드 방식 과 2. 동기화 블록 방식이 있다.

1. 동기화 메서드 방식
메서드 안에 오직 하나의 스레드만 들어갈 수 있도록 하고, 나머지는 메서드 밖에 줄 서 있는다.

public synchronized void setScore(int score) {}

2. 동기화 블록 방식
메서드 까지는 여러 스레드가 들어갈 수 있지만 특정 영역에는 혼자만 들어갈 수 있다.

public synchronized void setScore(int score) {
	
	synchronized (this) { // (mutex)에는 현재 사용중인 객체가 들어가야 한다.
		this.score = score; 
		
		try {
			Thread.sleep(2000); 
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println(Thread.currentThread().getName() + " : " + this.score);
	}
}

정리
1. 스레드를 round robin 방식으로 먼저 시작한 일이 먼저 끝나지 않는다.
2. 그런 특성 때문에 thread 를 제어하기 위한 방법들이 필요하다.
3. 또한 thread 는 memory를 공유하므로 간섭 효과가 생긴다.
4. 이를 막기 위해 synchronized 를 사용한다.


Thread State (Thread 상태값)

Thread는 생성부터 종료까지의 상태값이 있는데 getState()를 통해서 현재의 상태를 알 수 있다.

🔽 상태 종류
NEW : 객체생성 - 시작 안하고 막 생긴 상태
RUNNABLE : 실행 대기 - 현재 실행 중인 상태 (실행 대기)
※ 실행할 때는 아무것도 안찍힌다.
Wating : 다른 스레드가 통지할 때 까지 기다리는 상태
TIMED_WAITING : 시간이 주어진 상황에서 쉬는 상태
Blocked : 사용하려는 객체의 Lock이 풀릴 때 까지 기다리는 상태
TERMINATED : 종료 - Thread가 완전히 종료된 상태

Thread.currentThread : thread 상태 보는거?


Thread Control

Thread 는 참 유용한 기능이지만 예상대로 움직이지 않는다.
그래서 Thread Control을 위한 method 들이 존재한다.

  • sleep() : 주어진 milliseconds 동안 thread를 일시 정지 시킨다. Therad.sleep(1000)

  • yield() : 특정 thread에게 제어권을 양보한다. ( = 할 수 있는 기회를 준다.) Thread.yield()
    ▷ 양보를 받고 싶을 경우 즉시 답변을 해야하고, 한쪽만 독점하는 것을 어느정도 맞춰주는 것이기 때문에 균등하게 나눠지는 것은 아니다.

  • join() : 다른 thread의 종료를 기다린 후에 실행할 때 사용한다. Thread.join()
    (특정한 thread가 도달할 때 까지 기다리고 있다가 도착하면 실행하는 것으로 보통 Blocking이라고 표현한다.)

  • wait() : thread 를 일시 정지 상태로 만든다.

  • notify() : 일시 정지 상태 중인 thread 중 하나를 실행 대기 상태로 바꾼다. (여러개의 thread가 쉬고 있을 경우 무작위로 선택해 하나를 깨워준다.)
    ※ 그래서 2개의 Thread를 번갈아가며 실행시키고 싶을 경우 서로를 지정해서 둘만 움직여야 한다.

  • notifyAll() : 일시 정지된 모든 스레드를 실행 대기 상태로 만든다.

wait(), notify(), notifyAll() 호출
wait() 는 내가 쉬는 것이고, notify()는 누군가를 깨우는 것이다.
1. 내가 자고 있으면 누구를 깨울 수 없기 때문에 notify()wait() 으로 해야한다.
2. 동기화를 위해 synchronized 안에서 해야한다. (방해받으면 안되니까)

정리
1. Thread 제어를 위한 방법은 여러가지가 있다.
2. sleep()은 스스로가 정해진 시간만큼 쉬는 것이다.
3. yeild() 는 상대에게 실행할 기회를 주는 것이다.
4. join() 은 특정 스레드의 종료를 기다리는 것이다.
5. wait() 은 누군가가 notify() 해 줄 때까지 쉬는 것이다.

Stop flag 와 interrupt()

Thread는 run()의 실행 내용이 모두 실행되면 종료되지만 무한 반복문으로 이루어진 경우 강제종료가 필요하다.

이때 사용해 주는 것이 Stop flag와 interrupt()이다.

❕ Stop도 있지만 경고 없이 종료시키는 것으로 스레드가 사용 중이던 자원(변수, 파일, 네트워크 연결 등)들이 불완전한 상태로 남겨지기 때문에 사용중지를 권고하고 있다.

실행을 종료시키는 것에는 2가지가 있다.

  • Stop flag
    스레드는 run() 메소드가 끝나면 자동적으로 종료되므로 run() 메소드가 종료되게 유도하는 방법이다.
    여기서 flag(플래그) 란 boolean 변수를 뜻한다.
public class StopFlag extends Thread {
	private boolean stop = false;
	
//	캡슐화를 통해 외부 사용자가 stop에 대해 수정만 가능하도록 제한
	public void setStop(boolean stop) {
		this.stop = stop;
	}
	@Override
	public void run() {

		while (!stop) {
			System.out.println("stopFlag 실행 중..");
		}
		
//		만약 stop() 를 사용했으면 아래 내용이 실행되지 않고 그냥 바로 종료가 되어버린다.
		System.out.println("자원 정리");
		System.out.println("종료");
	}
}
  • interupt()
    스레드에 강제로 interupt()를 발생시키면 InterruptedException 예외를 발생시켜 catch 문으로 빠져나가게 만들어 준다.
    InterruptedException 은 sleep(), wait() 처럼 스레드가 멈춰있는 상황일 때 발생하기 때문에 해당 메소드를 사용해 예외가 발생하도록 해줘야 한다.

방법 1

public class Inter extends Thread {

	@Override
	public void run() {
		try {
			while(true) {
				System.out.println("Inter 실행 중...");
				Thread.sleep(1); 
			}
			
		} catch (InterruptedException e) {
			System.out.println("자원 정리");
			System.out.println("종료");
		}
    }
}

만약 이렇게 try-catch를 작성해주지 않고 사용하고 싶다면 아래 예제로 사용할 수 있다.
방법 2

public class Inter extends Thread {

	@Override
	public void run() {
    		while(true) {
			System.out.println("Inter 실행 중...");
			if (Thread.interrupted()) { // true 이면 break로 빠져나간다.
				break;
			}
		}
		
		System.out.println("자원 정리");
		System.out.println("종료");
    
    }
}

Demon Thread

work thread는 main thread의 종료여부와 상관없이 자기가 해야할 일을 한다.

하지만 Demon Thread는 내가 실행 중이라도 main thread가 종료되면 같이 종료된다. (= life cycle를 같이 한다.)

Demon Thread 생성 방법은 매우 간단하다.
우리가 일반 work thread를 생성하는 방법으로 thread를 생성한 후 thread 객체에 .setDemon(true) 를 해주면 Demon Thread로 변경된다.
※ false로 바꿔주면 다시 work thread로 돌아간다.

Thread th = new Thread();
th.serDaemon(true); // work thread → domon thread 로 변경
th.start();

Thread Pool

지금까지 살펴본 것은 단일 thread 이다.

만약 우리가 스케이트장에 놀러갔는데 스케이트 대여소가 없고, 제작소만 있다면 스케이트화를 만드는데 시간이 굉장히 오래 걸리고, 한번 사용한 후에도 반납이 안되기 때문에 다시 사용하지 않는다면 버려야 하는 즉, 자원을 낭비하는 상황이 생기게 된다.

하지만 대여소가 있다면 저렴한 가격에 간편하게 대여가 가능하게 되는데 이런 상황을 개발 용어에서는 Pool이라고 한다.

Thread Pool 은 대여소 같은 역할을 수행한다.
Thread를보유하고 있다가 순서에 따라 빌려주고, 사용 후 돌려 받는다.

즉, 기존에는 Thread를 직접 만들어서 사용하고 버렸다면 이제는 스케이트 장을 만든 이후에 스케이트 화를 빌릴 것이다. (신발 사이즈, 종목에 따른 스케이트 화(피켜, 스피드 등..))
그리고 다 사용한 뒤에는 반납할 것이다.

Thread Pool은 ExecutorService 객체를 통해 생성되며, 생성시에는 초기/ 코어 / 최대 스레드 수를 명시해 준다.

  • 초기 스레드 수 : 객체 생성시 기본적으로 생성되는 스레드 수
  • 코어 스레드 수 : 풀에 최소한으로 유지해야 할 스레드 수 (만약 오래된걸 버릴 경우 버리고 남기는 수의 기준을 잡아주는 것)
  • 최대 스레드 수 : 스레드 풀에서 관리하는 최대 스레드 수 (아무리 손님이 많아도 최대 수를 정해놔서 만약 만들어지 수보다 빌리는 사람이 많아져도 기다리라고 한다.)

초기나 코어 스레드 수는 내 컴퓨에 사양에 맞춰서 생성된다.

Thread Pool의 작업은 Runnable 과 Callable interface로 생성하는데 두 방법의 차이는 return의 유무이다.

  • Runnable 은 실행하기만 하고 받는 값이 없을 때 사용 ▶ execute() 로 실행
  • Callable 은 처리한 후에 받아야할 게 있으면 사용 ▶ submit() 로 실행
  • excute() 는 리턴 타입이 void 이다
    작업 처리 도중 예외상황이 생길 시 스레드를 종료하고 풀에서 스레드를 제거한다..

  • submit() 는 리턴 타입이 Future로, 작업 처리 결과를 Future를 통해 반환된다.
    작업 처리 도중 예외 상황에도 스레드를 종료하지 않고 재사용한다.

☆ 하지만 Runnable 에서도 submit() 을 사용할 수 있는데 그 내용을 아래에서 다룰 예정이다.

Thread Pool을 생성하는 방법에는 2가지가 있다.
방법 1 : newCachedThreadPool

  • 처음에는 thread를 가지고 있지 않고, 요청이 올 경우 만들어서 빌려준다.
  • 이후 반납된 thread로 다시 대여를 해주고
  • 60초 동안 사용되지 않은 thread가 있다면 버린다.
ExecutorService pool1 = Executors.newCachedThreadPool();

방법 2 : newFixedThreadPool

  • 처음에 설정된 n개의 thread를 가지고 시작한다.
  • 만들어 둔 thread를 다 사용하면 새롭게 만들어 빌려준다.
  • 60초 동안 사용되지 않은 thread는 버리지만 초기에 설정된 n개의 개수는 유지한다.
    ※ n개는 일반적으로 내 pc의 가용 메인 스레드 수를 기반으로 한다.
int n = Runtime.getRuntime().availableProcessors(); // 내 컴퓨터의 가용 스레드 수 체크
		
ExecutorService pool2 = Executors.newFixedThreadPool(n);

이렇게 thread pool을 만든 이후 스레드를 만들어 일을 시켜보자.

Runnable

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ExecuteMain {
	public static void main(String[] args) throws InterruptedException {

//		1. thread pool 생성 (대여소 만들기)
		int n = Runtime.getRuntime().availableProcessors();
		ExecutorService pool = Executors.newFixedThreadPool(n);
		
//		2. 대여 양식 작성 : 실행 후 반환값이 없을 경우
		Runnable runn = new Runnable() {
			
			@Override
			public void run() {
				System.out.println("Runnable 처리!!");
			}
		};
		
//		3. 양식 제출 (대여)
		pool.execute(runn);
		
//		4. 대여소 종료 
		pool.shutdown(); // 스레드들이 다 나올때 까지 무조건 기다린다.
		boolean end =  pool.awaitTermination(1L, TimeUnit.SECONDS); 
		System.out.println("진상들이 없었나? " + end);
	}
}

Callable

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

public class SubmitMain {
	public static void main(String[] args) throws Exception {

//		1. 스레드 풀 생성
		int n = Runtime.getRuntime().availableProcessors(); // 6~16개가 생김
		ExecutorService pool = Executors.newFixedThreadPool(n);
		
//		2. 대여 신청서 작성 : 실행 후 반환 값이 있을 경우
		Callable<String> call = new Callable<String>() {
			
			@Override
			public String call() throws Exception {
				System.out.println("Callable 처리");
				return "완료";
			}
		};
		
//		3. 양식 제출 (대여)
		Future<String> f = pool.submit(call);
		String result =  f.get(); // 반환받기 위해 사용 (blocking을 안쓰고 싶으면 get을 사용하지 않으면 된다.)
		System.out.println("반환 받은 값 : " + result);
		
//		4. 대여소 종료
		pool.shutdown();
		boolean end = pool.awaitTermination(1L, TimeUnit.SECONDS);
		System.out.println("진상들이 없었나? " + end);
	}
}

종료하는 방법

  • shutdown() : 처리중인 작업 내용을 모두 마무리하고 스레드 풀 종료. (미리 통지하고 모든 손님이 나오면 그 다음에 문을 닫는다.)

  • shutdownNow() : 처리 작업 마무리 여부에 상관 없이 interrupt 시켜 중지시킨다. (진상 손님이 나오라고 했는데 안나오면 그냥 실행중인 스레드를 죽여버린다.) but stop와 동일한 문제가 있다. ※ 사용하지 않는게 좋다! (진상은 죽여도 상관 없지만 이제 빠져나오기 직전인 애들도 다 죽이게 된다)

  • awaitTermination() : shutdown() 메소드 호출 이후 timeout 시간 초과 여부에따라 반환 (시간 내 처리 : true, 시간 내 미처리 : interupt 하고 false )

만드는 방법을 보면 단일 스레드를 직접 만드는게 더 편해보인다.
그래서 이건 규모에 따라 다른 것이다.
만약 스레드를 1~2개 사용한다면 그냥 직접 만들지만 10개 이상을 사용할 경우에는 pool을 만들어주는게 좋다.

Thread Pool Blocking

Thread Pool 에서는 sumit()을 사용하면 runnable 과 callable 관계없이 작업 완료 후 Future 객체를 반환한다.
Future 객체를 가져오는 get() 메서드는 join()과 같은 역할을 수행하는데 이것을 Blocking 라고 한다.

(Runnable만 blocking를 사용 못하면 억울하니까!)

블로킹 사용 여부에 따라 submit을 사용하느냐 안하느냐가 달라진다. (runnable에서)

Future에 get() 까지 써야 블로킹을 해준다.

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class RunMain {
	public static void main(String[] args) throws InterruptedException, ExecutionException {

		int n = Runtime.getRuntime().availableProcessors();
		ExecutorService pool = Executors.newFixedThreadPool(n);
		
		Runnable runn = new Runnable() {
			
			@Override
			public void run() {
					try {
						for (int i = 1; i <= 10; i++) {
							System.out.println("작업 처리 중: " + i);
							Thread.sleep(500);
						}
					} catch (InterruptedException e) {
						System.out.println("예외 발생으로 빠져 나옴");
					}
				}
		};
		
//		Future를 만들어도 반환하는 데이터타입이 없어서 와일드 카드 ? 를 작성한다.
		Future<?> f =  pool.submit(runn); 
		f.get(); // join과 같이 blocking 을 수행해준다.
		
		System.out.println("==========작업 종료==========");
		
		pool.shutdown();
	}
}

정리
1. Runnable 은 반환 값이 없지만 Callable 은 반환 값이 있다.
2. Callable 은 반환 값이 있으므로 submit()만 사용 가능하다.
3. execute() 는 예외를 일으킨 스레드를 죽인다.
4. submit() 을 사용하면 join() 처럼 blocking 이 가능하다.



1. 쓰레드는 프로그램을 움직이는 원동력이다.
2. 프로세스는 교실이며 한 교실에는 1~n명이 있을 수 있고, 같은 교실에 있는 학생은 서로 무얼 배우는지 알 수 있다.
3. main thread가 나를 위해 일을 할 분신을 반드는 것이 work thread 이다.
4. Thread를 사용하는 방법은 ① Runnable interface 구현 ② Thread class 상속 이 있다.
5. Thread는 round robin 방식이기 때문에 순서제어가 안된다.
6. Thread는 같은 메모리를 사용하기 때문에 데이터가 간섭을 받을 수 있기 때문에 synchronized (동기화)를 사용해 내가 완전히 끝나기 전에는 들어오지 못하게 해줘야 한다.
7. 순서 제어를 위해서는 (sleep(), yield(), join())을 사용해줄 수 있다.
profile
개발자가 되어보자

0개의 댓글