[Java] 멀티 스레드

박세진·2021년 1월 25일
7
post-thumbnail

개발자 면접을 준비하면서, 기술 질문에서 빼놓을 수 없었던 문제중 하나가 바로 멀티스레드와 관련한 문제이다.
필자는 학원에서 자바 수업을 들으면서 멀티 스레드에 대해 깊게 공부하지 않았다는 생각이 든다. synchronized 라는 키워드에 대해서도 잘 기억하고 있지 못했다.
그래서 이 부분을 다시 공부하면서 개념을 정리하고자 한다.


프로세스와 스레드

  • 프로세스 : OS에서 실행중인 하나의 애플리케이션
  • 사용자가 애플리케이션을 실행하면 OS로부터 실행에 필요한 메모리를 할당받아 애플리케이션의 코드를 실행하는데, 이것을 프로세스라고 부른다.
  • 하나의 애플리케이션은 다중 프로세스를 만들기도 한다.
    ex) Chrome 브라우저를 2개 실행 : 두개의 Chrome 프로세스가 생성된 것.

멀티 태스킹 : 2가지 이상의 작업을 동시에 처리하는 것.

OS는 멀티 태스킹을 할 수 있도록 CPU 및 메모리 자원을 프로세스마다 적절히 할당해주고 병렬로 실행시킨다.
ex) 워드로 문서 작업 + 윈도우 미디어 플레이어로 음악 듣기

주의 !

멀티 태스킹이 꼭 멀티 프로세스를 뜻하는 것은 아니다.
한 프로세스 내에서도 멀티 태스킹을 할 수 있도록 만들어진 애플리케이션도 있음
(미디어 플레이어, 메신저 등)
ex) 메신저 : 채팅 기능을 제공하면서 동시에 파일 전송 기능을 수행.
how? 🤔 => 멀티 스레드를 이용!

스레드 : 하나의 코드 실행 흐름을 말함.
한 프로세스 내에 스레드가 2개라면, 2개의 코드 실행 흐름이 생긴다는 의미.

  • 멀티 프로세스는 애플리케이션 단위의 멀티 태스킹
  • 멀티 스레드는 애플리케이션 내부에서의 멀티 태스킹

멀티 프로세스들은 OS에서 할당받은 자신의 메모리를 가지고 실행하기 때문에 서로 독립적이다. 따라서 하나의 프로세스에서 오류가 발생해도 다른 프로세스에게 영향을 미치지 않는다.
하지만 멀티스레드는 하나의 프로세스 내부에 생성되기 때문에 하나의 스레드가 예외를 발생시키면 프로세스 자체가 종료될 수 있어 다른 스레드에게 영향을 미치게 된다.

  • ex) 멀티 프로세스인 워드와 액셀을 동시에 사용하던 중, 워드에 오류가 생겨서 먹통이 되더라도 엑셀은 여전히 사용 가능하다.
  • ex2) 멀티 스레드로 동작하는 메신저의 경우, 파일을 전송하는 스레드에서 예외가 발생하면 메신저 프로세스 자체가 종료되므로 채팅 스레드도 같이 종료된다
    ( 예외처리 중요 !! )

메인 스레드

모든 자바 애플리케이션은 메인 스레드가 main() 메소드를 실행하면서 시작된다.
메인 스레드는 main()메소드의 첫 코드부터 아래로 순차적으로 실행하고, main()메소드의 마지막 코드 실행 or return문을 만나면 실행이 종료된다.

메인 스레드는 필요에 따라 작업 스레드들을 만들어서 병렬로 코드를 실행할 수 있다.
(즉, 멀티 스레드를 생성해서 멀티 태스킹을 수행)

설명을 위해 [이것이 자바다] 책을 참고했다


작업 스레드

멀티 스레드로 실행하는 애플리케이션을 개발하려면

  • 몇개의 작업을 병렬로 실행할지 결정하고
  • 각 작업별로 스레드를 생성

해야 한다.
어떤 자바 애플리케이션이든 메인 스레드는 반드시 존재하므로, 메인 작업 외의 추가적인 작업의 수 만큼 스레드를 생성하면 된다.
또한, 자바에서는 작업 스레드도 객체로 생성되기 때문에 클래스가 필요하다
생성하는 방법은

  1. java.lang.Thread 클래스를 직접 객체화해서 생성
  2. Thread 클래스를 상속해서 하위 클래스를 만들어 생성

이렇게 2가지 방법이 있다.

방법1) Thread 클래스로부터 직접 생성하기

이때는 Runnable 인터페이스 타입의 매개값을 갖는 생성자를 호출해야 한다

Thread thread = new Thread(Runnable target);

[참고]

Runnable : 작업 스레드가 실행할 수 있는 코드를 가지고 있는 객체 라고 해서 붙여진 이름. 인터페이스 타입이기 때문에 구현 객체를 만들어서 대입해야 한다.
Runnable 에는 run() 메소드 하나가 정의되어 있음.
구현 클래스는 run()을 재정의 해서 작업 스레드가 실행할 코드를 작성해야 한다.

public class Task implements Runnable{

	@Override
	public void run() {
	// 스레드가 실행할 코드 
	}
}

Runnable은 작업 내용을 가지고 있는 객체이지 실제 스레드는 아니다.
Runnable 구현 객체를 생성한 후 , 이것을 매개값으로 해서 Thread 생성자를 호출해야 비로소 작업 스레드가 생성된다.

public class MultiThread {
	Runnable task = new Task();
	Thread thread = new Thread(task); // 구현 객체를 매개값으로 해서 Thread 생성자를 호출 -> 작업 스레드 생성.
}

위의 방법 보다는 Thread 생성자를 호출할 때 Runnable 익명 객체를 매개값으로 사용하는 방법을 더 많이 사용한다. (코드 절약 ok !)

public class RunnableAnony {
	Thread thread = new Thread(new Runnable() {
		
		@Override
		public void run() {
			// 스레드가 실행할 코드 
		}
	});
}

이렇게 해서 작업 스레드를 생성했다. 하지만 작업 스레드는 생성되는 즉시 실행되는 것이 아니라, start() 메소드를 호출해야만 비로소 실행된다.

thread.start();

start()메소드가 호출되면, 작업 스레드는 매개값으로 받은 Runnable의 run() 메소드를 실행하면서 자신의 작업을 처리한다.

지금까지 내용을 그림으로 정리해 보았다. 아래 그림을 참고하자.

예제

0.5초 간격으로 비프음을 발생시키면서 동시에 프린팅하는 작업이 있다고 가정.
두가지는 서로 다른 작업이므로, 두 작업 중 하나를 메인 스레드가 아닌 다른 스레드에서 실행시켜야 한다.
프린팅은 메인스레드가 담당, 비프음을 들려주는 것은 작업스레드가 담당하도록 하자.

  • 작업을 정의하는 Runnable 구현 클래스를 작성
public class BeepTask implements Runnable {

	@Override
	public void run() {
		// 스레드 실행 내용 !
		Toolkit toolkit = Toolkit.getDefaultToolkit();
		for (int i=0; i<5; i++) {
			toolkit.beep();
			try {
				Thread.sleep(500);
			} catch (Exception e) {}
		}
	}
}
  • 메인 스레드와 작업 스레드를 동시에 실행
public class BeepPrintExample1 {
	public static void main(String[] args) {
		Runnable beepTask = new BeepTask(); // BeepTask 클래스 이용해서 Runnable 구현 객체를 생성.
		Thread thread = new Thread(beepTask); // Thread 생성자 호출시 BeepTask 객체를 매개값으로 이용. 작업 스레드를 생성.
		thread.start(); // 작업 스레드에 의해 BeepTask 객체의 run() 메소드 실행되어 비프음이 발생.
		
		// 메인 스레드가 for 문을 실행해서 0.5초 간격으로 "띵!"을 프린트함.
		for(int i=0; i<5; i++) {
			System.out.println("띵!");
			try {
				Thread.sleep(500);
			} catch (Exception e) {}
		}
	}
}

위 코드의 3~4 라인을 대체하여 작업 스레드를 만들 수 있는 또 다른 방법이 있다.
다음의 코드를 보자.

public class BeepPrintExample2 {
	public static void main(String[] args) {
		// 위와의 차이점 : BeepTask 라는 클래스를 따로 만든 뒤 이를 이용해서 구현 객체를 만드는게 아님 !
		// 익명 구현 객체를 이용해서 바로 생성한다.
		Thread thread = new Thread(new Runnable() {

			@Override
			public void run() {
				// 작업 스레드의 실행내용을 여기에 쓰는거 !
				Toolkit toolkit = Toolkit.getDefaultToolkit();
				for (int i=0; i<5; i++) {
					toolkit.beep();
					try {
						Thread.sleep(500);
					} catch (Exception e) {}
				}
			} // run ()
		});
	}
}

이 다음은 Thread.start() 부터 해서 첫번째 방법과 똑같이 해주면 된다.

참고 !

Thread.sleep() 시 try~catch 문이나 throws로 예외처리를 해주는 이유? 🤔

  • Thread.sleep(일정 시간) 메소드를 호출하면, 주어진 시간동안 스레드는 일시 정지 상태가 되고 다 끝나면 다시 실행 대기 상태로 돌아간다.
  • 일시정지 상태에서 주어진 시간이 되기 전 interrupt 메소드가 호출되면 InterruptedException이 발생하기 때문에 예외처리를 해주는 것 !
    • 인터럽트 (Interrupt) : 자바에서 스레드에게 하던 일 멈춰!라는 신호를 보내기 위해서 사용

방법 2) Thread 하위 클래스로부터 생성하기

작업 스레드가 실행할 작업을 Runnable 로 만들지 않고, Thread 의 하위 클래스로 작업 스레드를 정의하면서 작업 내용을 포함시킬 수 있다.

How? 😶

Thread 클래스를 상속한 후 , run() 메소드를 재정의해서 스레드가 실행할 코드를 작성.

방법 1 과의 차이점?

방법 1은 Runnable 인터페이스의 구현 객체를 만들 때, 우선 클래스를 작성하고 implements Runnable 해준 다음 Runnable 에 있는 run() 메소드를 클래스에서 재정의 했음.
방법 2는 extends Thread 를 한 다음 run() 메소드를 재정의 했음.

public class WorkerThread extends Thread{
	@Override
    	public void run() {
        // 스레드가 실행할 코드
        }
}

Thread thread = new WorkerThread();

방법 1 처럼 익명 구현 객체를 이용해서 코드를 절약할 수 있다.

Thread thread = new Thread(){
	public void run(){
    	// 스레드가 실행할 코드.
    }
}

이렇게 생성된 작업 스레드 객체에서 start() 메소드를 호출하면, 작업 스레드는 자신의 run() 메소드를 실행하게 된다

방법 1 에서는 작업 스레드가 매개값으로 받은 Runnable의 run() 메소드를 실행하면서 자신의 작업을 처리한다.

위에서 했던 예제를 방법2로 해보자.

이번에는 익명 구현 객체를 이용한 방법으로만 풀었다.

public class BeepPrintExample3 {
	public static void main(String[] args) {
		Thread thread = new Thread() {
			@Override
			public void run() {
				// 작업 스레드의 실행내용을 여기에 쓰는거 !
				Toolkit toolkit = Toolkit.getDefaultToolkit();
				for (int i=0; i<5; i++) {
					toolkit.beep();
					try {
						Thread.sleep(500);
					} catch (Exception e) {}
				}
			} // run ()
		};
		thread.start();
		
		for(int i=0; i<5; i++) {
			System.out.println("띵!");
			try {
				Thread.sleep(500);
			} catch (Exception e) {}
		}
	}
}

스레드 우선순위

멀티스레드는 동시성 or 병렬성으로 실행된다.

  • 동시성 : 멀티 작업을 위해 하나의 코어 에서 멀티 스레드가 번갈아가면서 실행 하는 성질
  • 병렬성 : 멀티 작업을 위해 멀티 코어 에서 개별 스레드를 동시에 실행 하는 성질
  • 싱글 코어 CPU를 이용한 멀티 스레드 작업은 병렬적으로 실행되는 것처럼 보이지만, 사실은 번갈아가면서 실행하는 동시성 작업이다. 워낙 빠르게 번갈아서 실행되다 보니 병렬성으로 보이는 것 뿐이다.

스레드의 개수가 코어의 수보다 많을 경우, 스레드를 어떤 순서에 의해 동시성으로 실행할 것인가를 결정해야 한다. 이것을 스레드 스케줄링이라고 한다.
스레드 스케줄링에 의해 스레드들은 아주 짧은 시간에 번갈아가면서 그들의 run() 메소드를 조금씩 실행한다.

아래의 그림을 참고하자.

자바의 스레드 스케줄링은 우선순위(Priority) 방식과 순환 할당(Round-Robin) 방식을 사용한다.

  • 우선순위 방식 : 우선순위가 높은 스레드가 실행 상태를 더 많이 가지도록 스케줄링. 스레드 객체에 우선순위 번호를 부여할 수 있으므로 개발자가 코드로 제어 가능.
  • 순환 할당 방식 : 시간 할당량(Time Slice)을 정해서 하나의 스레드를 정해진 시간만큼 실행하고, 다시 다른 스레드를 실행하는 방식. JVM에 의해 정해지므로 개발자가 코드로 제어 불가능.

동기화 메소드 & 동기화 블록

싱글 스레드 프로그램 : 한 개의 스레드가 객체를 독차지해서 사용하면 된다.

멀티 스레드 프로그램 : 스레드들이 객체를 공유 해서 작업해야 하는 경우가 있다.
이 경우, 스레드 A를 사용하던 객체가 스레드 B에 의해 상태가 변경될 수 있기 때문에 A가 의도한 것과 다른 결과를 산출할 수 있다.

스레드가 사용 중인 객체를 다른 스레드가 변경할 수 없도록 하려면, 스레드 작업이 끝날 때까지 객체에 잠금을 걸어서 다른 스레드가 사용할 수 없도록 해야 한다.
멀티 스레드 프로그램에서 단 하나의 스레드만 실행할 수 있는 코드영역을 임계영역 이라고 한다. 자바는 임계 영역을 지정하기 위해 동기화(synchronized) 메소드와 동기화 블록을 제공한다.

스레드가 객체 내부의 동기화 메소드 or 동기화 블록에 들어가면 즉시 객체에 잠금을 걸어 다른 스레드가 임계 영역 코드를 실행하지 못하도록 한다.

Q. 동기화 메소드를 만들려면? 🤔

A. 메소드 선언에 synchronized 키워드를 붙이면 된다.

public synchronized void method(){
// 임계영역 : 단 하나의 스레드만 실행!
}

동기화 메소드는 메소드 전체 내용이 임계 영역 이므로,
스레드가 동기화 메소드를 실행하는 즉시 ! 객체에 잠금이 일어나고,
스레드가 동기화 메소드를 종료하면 잠금이 풀린다.
메소드 전체 내용이 아니라 일부 내용만 임계 영역으로 만들고 싶다면 동기화 블록을 만들면 된다.

public void method(){
	// 여러 스레드가 실행 가능한 영역
    
    	synchronized(공유객체){
        // 임계 영역 (단 하나의 스레드만 실행 !)
        }
        // 여러 스레드가 실행 가능한 영역
}

참고
동기화 블록과 동기화 메소드가 여러 개 있을 때 : 스레드가 이들 중 하나를 실행할 때, 다른 스레드는 해당 메소드는 물론이고 다른 동기화 메소드 / 블록 실행 불가능하다.
(일반 메소드는 실행 가능하다.)


스레드 상태

스레드 객체를 생성하고 start() 메소드를 호출하면?

곧바로 스레드가 실행된다 -> (X)
실행 대기 상태에 있다 (O)

실행 대기 상태 : 아직 스케줄링이 되지 않아서 실행을 기다리고 있는 상태.

실행 대기 상태에 있는 스레드 중에서 스레드 스케줄링으로 선택된 스레드가 비로소 CPU를 점유하고 run()메소드를 실행한다. 이때가 실행 상태 !!!
실행 상태의 스레드는 run()메소드를 모두 실행하기 전 스레드 스케줄링에 의해 다시 실행 대기 상태로 돌아갈 수 있다. 그리고 실행 대기 상태에 있는 다른 스레드가 선택되어 실행 상태가 된다.

즉, 스레드는 실행 대기 상태와 실행 상태를 번갈아가면서 자신의 run()메소드를 조금씩 실행한다.

실행 상태에서 run() 메소드가 종료되면 더 이상 실행할 코드가 없기 때문에 스레드의 실행은 멈추게 된다. 이 상태를 종료 상태 라고 한다.

cf)
경우에 따라서 실행 상태에서 실행 대기 상태로 가지 않을 수도 있다.
실행 상태에서 일시정지 상태로 가는 경우도 있다.
일시 정지 상태 : 스레드가 실행할 수 없는 상태. 스레드가 다시 실행상태로 가기 위해서는 일시 정지 상태에서 실행 대기 상태로 가야한다.


스레드 상태 제어

스레드 상태 제어 : 실행 중인 스레드의 상태를 변경하는 것.

ex) 미디어 플레이어를 사용하고있는 사용자 : 동영상을 보다가 일시 정지시킬 수도 있고, 종료시킬 수도 있다.
정지는 조금 뒤 다시 동영상을 보겠다는 의미이므로 동영상 스레드를 일시 정지 상태로 만들어야 한다.
종료는 더 이상 동영상을 보지 않겠다는 의미이므로 미디어 플레이어는 스레드를 종료 상태로 만들어야 한다.

스레드 상태를 제대로 제어하기 위해서는 스레드의 상태 변화를 가져오는 메소드를 파악하고 있어야 한다. 다음을 보자.

메소드설명
interrupt()일시 정지 상태의 스레드에서 InterruptedException 예외를 발생시켜 예외처리 코드(catch)에서 실행 대기 상태 or 종료 상태로 갈 수 있도록 한다.
notify() notifyAll()동기화 블록 내에서 wait() 메소드에 의해 일시 정지 상태에 있는 스레드를 실행 대기 상태로 만든다.
sleep(long millis)주어진 시간 동안 스레드를 일시 정지 상태로 만든다. 주어진 시간이 지나면 자동적으로 실행 대기 상태가 된다.
join(), join(long millis), join(long millis, int nanos)join() 메소드를 호출한 스레드는 일시 정지 상태가 된다. 실행 대기 상태로 가려면 join() 메소드를 멤버로 가지는 스레드가 종료되거나 or 매개값으로 주어진 시간이 지나야 한다.ex) 다른 스레드가 종료될 때까지 기다렸다가 실행해야 하는 경우. (계산 작업을 하는 스레드가 모든 계산 작업을 마치고 계산 결과값을 받아 이용하는 경우 !)
wait(), wait(long millis), wait(long millis, int nanos)동기화 블록 내에서 스레드를 일시 정지 상태로 만든다. 매개값으로 주어진 시간이 지나면 자동적으로 실행 대기 상태가 된다. 시간이 주어지지 않으면 notify(), notifyAll()메소드에 의해 실행 대기 상태로 갈 수 있다.
yield()실행 중에 우선순위가 동일한 다른 스레드에게 실행을 양보하고 실행 대기 상태가 된다. 동일 우선순위 or 높은 우선순위를 갖는 다른 스레드가 실행 기회를 가질 수 있다.

위의 메소드들에 대해서 하나씩 파헤쳐보자.

1. 주어진 시간동안 일시 정지 (sleep())

실행 중인 스레드를 일정 시간 멈추게 하고 싶을 때 sleep() 메소드를 사용하면 된다.
Thread.sleep() 메소드를 호출한 스레드는 주어진 시간동안 일시 정지 상태가 되고, 시간이 지나면 다시 실행 대기 상태로 돌아간다.

2. 다른 스레드에게 실행 양보 (yield())

스레드가 처리하는 작업은 반복적인 실행을 위해 for or while 문을 포함하는 경우가 많다.
가끔은 이 반복문들이 무의미한 반복을 하는 경우가 있다. 이것 보다는 다른 스레드에게 실행을 양보하고 자신은 실행 대기 상태로 가는 것이 전체 프로그램 성능에 도움이 된다. 이런 기능을 위해 스레드는 yeild() 메소드를 제공한다.
yeild() 메소드를 호출한 스레드는 실행 대기 상태로 돌아가고, 동일 우선순위 or 높은 우선순위를 갖는 다른 스레드가 실행 기회를 가질 수 있도록 해준다.

3. 다른 스레드의 종료를 기다림 (join())

스레드는 다른 스레드와 독립적으로 실행하는 것이 기본이지만, 다른 스레드가 종료될 때 까지 기다렸다가 실행해야 하는 경우가 발생할 수도 있다.
ex) 계산 작업을 하는 스레드가 모든 계산 작업을 마친 후 결과값을 받아 이용하는 경우.

만약 메인 스레드에서 sumThread.join() 을 호출하면, 메인 스레드는 sumThread 가 종료할 때까지 일시정지 된다.

4. 스레드 간 협업 (wait(), notify(), notifyAll())

경우에 따라서는 두개의 스레드를 교대로 번갈아가며 실행해야 할 경우가 있다.
자신의 작업이 끝나면 상대방 스레드를 일시 정지 상태에서 풀어주고, 자신은 일시 정지 상태로 만드는 것이다. 이 방법의 핵심은 공유 객체 에 있다.

공유 객체는 두 스레드가 작업할 내용을 동기화 메소드로 각각 구분해 놓는다.
한 스레드가 작업을 완료하면 notify() 메소드를 호출하여 일시 정지 상태에 있는 다른 스레드를 실행 대기 상태로 만들고, 자신은 두번 작업을 하지 않도록 wait() 메소드를 호출하여 일시 정지 상태로 만든다.

notify() : wait()에 의해 일시 정지된 스레드 중 한개를 실행 대기 상태로 만든다.
notifyAll() : wait()에 의해 일시 정지된 모든 스레드를 실행 대기 상태로 만든다.

5. 스레드의 안전한 종료 (interrupt())

스레드는 자신의 run() 메소드가 모두 실행되면 자동적으로 종료된다.
하지만 경우에 따라서 실행 중인 스레드를 즉시 종료할 필요가 있다.
ex) 동영상을 끝까지 보지 않고 사용자가 멈춤을 요구하는 경우

interrupt(): 스레드가 일시 정지 상태에 있을 때 InterruptedException 을 발생시키는 역할. -> run() 메소드를 정상 종료시킬수 있다.


글을 마치면서

정리하다 보니 내용이 꽤 많아서 한 글에 담는 것 보다는 나눠서 글을 쓰는게 낫겠다는 생각이 들었다.
데몬 스레드, 스레드 그룹, 스레드 풀 등 다른 주제에 대해서는 다음 글에서 다룰 예정이다.

수업시간에 배우지 못했던 부분도 혼자서 공부하다 보니 꽤 오래 걸린것 같다.
까먹지 않도록 자주 복습해야겠다 !

profile
계속해서 기록하는 개발자. 조금씩 성장하기!

0개의 댓글