본문 바로가기

스터디/JAVA

[JAVA] Thread

[스레드의 개념]

운영체제는 실행 중인 프로그램을 프로세스로 관리한다.

멀티 태스킹은 두 가지 이상의 작업을 동시에 처리하는 것을 말하는데, 이때 운영체제는 멀티 프로세스를 생성해서 처리한다.

멀티 프로세스들은 서로 독립적으로 구성되어 있기 때문에 하나의 프로세스에 오류가 발생해도 다른 프로세스에는 영향을 미치지 않는다. 하지만 멀티 태스킹이 꼭 멀티 프로세스를 뜻하지 않는다.

하나의 프로세스 내에서 멀티 태스킹을 할 수 있도록 만들어진 프로그램이 있다.

예시로 카카오톡은 채팅 작업도 하면서 동시에 파일 전송을 같이 하기 때문에 멀티 스레드를 사용하는 프로그램이다.

멀티 스레드 기능을 사용하는 카카오톡 같은 경우에는 메신저 기능의 스레드가 오류가 발생하면 파일 전송 스레드도 프로세스 자체도 종료되기 때문에 구성시 예외처리를 잘해야 한다.

멀티 스레드는 데이터를 분할해서 병렬로 처리하는 곳에서 사용하기도 하고, 안드로이드 앱에서 네트워크 통신을 하기 위해 사용하기도 한다. 또한 다수의 클라이언트 요청을 처리하는 서버를 개발할 때도 사용된다.

 

하나의 프로세스가 2가지 이상의 작업을 처리할 수 있는 이유는 멀티 스레드가 있기 때문이다.
스레드는 코드의 실행 흐름을 말하는데, 프로세스 내에 스레드가 2개라면 2개의 코드의 실행흐름이 생긴다는 것이다.

멀티 프로세스가 프로그램 단위의 멀티 태스킹이라면 멀티 스레드는 프로그램 내부에서의 멀티 태스킹이라고 볼 수 있다.

스레드 = 프로그램이 메모리에 올라갔을 때 프로그램을 실행시키는 주체이다. 프로그램을 실행시키는 흐름 이라고도 볼 수 있다.

스레드는 메인 메서드를 실행시키는 메인 스레드, 각종 클래스나 여러 기능들을 실행시키도록 하는 서브 스레드들이 존재한다.

이때 스레드가 2개 이상이면 멀티 스레드라고 한다.

 

  • 멀티 스레드란 프로그램 내에서 동시에 여러 작업을 처리하는 기능을 제공한다.
  • 멀티 스레드에서는 메인 스레드가 종료되어도 프로세스는 종료되지 않는다.
  • 프로세스 내의 스레드들이 서로 공유하는 메모리 공간에 있기 때문에 자원을 공유하게 되면서 데이터를 보다 효율적으로 분할하고 병렬로 처리할 수 있다.
  • 멀티 스레드 간의 동기화와 예외 처리를 잘 다루어야 하나의 스레드에서 오류가 발생했을 때도 대처할 수 있다.
  • 멀티 스레드를 사용함으로써 프로그램의 성능을 향상하고 효율적으로 작업을 수행할 수 있으나, 그에 따른 동기화와 예외 처리 같은 추가적인 관리는 필수이다.

 


 

[작업 스레드의 생성과 실행]

멀티 스레드로 실행하는 프로그램을 개발하려면 먼저 몇 개의 작업을 병렬로 실행할지 결정하고 각 작업별로 스레드를 생성해야 한다.

자바에는 메인 메서드는 무조건 존재해야 하기 때문에 메인 스레드를 제외하고 추가적인 스레드를 구성해야 한다.

메인 스레드가 모든 작업을 수행할 수 없기 때문이다.

자바는 작업 스레드도 객체로 관리하기 때문에 클래스가 필요하다. 스레드 클래스로 직접 객체를 생성해도 되지만 하위 클래스로 만들어서 생성할 수도 있다.

 

[스레드 생성]

Thread thread = new Thread(new Runnable() {
	@Override
    public void run() {
    }
 });

Runnable은 스레드가 작업을 실행할 때 사용하는 인터페이스다.

Runnable에는 run() 메서드가 존재하는데 구현 클래스에서 재정의하여 스레드가 실행할 코드를 가지고 있어야 한다.

일반적으로는 Runnable에 대한 구현 객체를 매개값으로 Thread의 객체로 넣어서 사용한다.

thread의 익명 객체를 생성을 했다면 thread. start()를 해줘야 작업 스레드가 실행된다.

 

메인 스레드에서는 동시에 2개의 작업을 처리할 수 없으므로 서브 스레드와 기능을 나눠서 사용해야 한다.

아래의 코드의 주석을 확인해 보면 알 수 있듯이 main스레드(주인아저씨)가 장사를 혼자 할 수 없으니 서브 스레드(아르바이트생)를 고용한다고 생각하면 이해하는데 큰 도움이 될 것이다.

 

import java.awt.Toolkit;

public class BeepPrintExample {

	// main메소드는 main스레드가 해야할 일
	public static void main(String[] args) {
		// main스레드(주인 아저씨) 가 work스레드(알바생)을 고용한다.
		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) {}
			}
		};
		// worker스레드(알바생) 일 시작해라.
		thread.start();
		
		for(int i=0; i<5; i++) {
			System.out.println("띵");
			try {Thread.sleep(500); } catch(Exception e) {}
		}
	}

}

 


Thread 생성하는 방법 2가지

  1. Thread 자식 클래스로 생성
  2. 이미 다른 클래스르 상속받은 상태라면 Runnable 인터페이스의 상속받는 클래스 구현

 

[Thread 자식 클래스로 생성]

작업 스레드의 객체를 생성하는 다른 방법은 Thread의 자식 객체로 만드는 것이다. 

Thread 클래스를 상속한 다음 run() 메서드를 재정의하여 익명 객체를 생성하여 서브 스레드가 실행할 코드( run() 메서드 )를 작성하면 된다.

public class ThreadSleep {
	public static void main(String[] args) {
		
		Thread t1 = new Thread(new Runnable() {
			@Override
			public void run() {
				int sum = 0;
				for(int i=0; i<10; i++) {
					sum += i;
					System.out.println("누적 합 : " + sum);
					
					try {
						Thread.sleep(100);
					} catch (InterruptedException e) {
						// TODO Auto-generated catch block
						e.printStackTrace();
					}
					
				}
				System.out.println("총합은 : " + sum);
				Thread workThread = Thread.currentThread();
				System.out.println( workThread.getName() +" 스레드 종료");
				
			}
		});
		t1.start();
		
		try {
			Thread.sleep(5000);
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		Thread mainThread = Thread.currentThread();
		System.out.println( mainThread.getName() +" 스레드 종료");
	}
}

 

해당 코드를 실행해보면 알 수 있듯이  메인 스레드는 메인 메서드를 실행시키고 ThreadSleep이라는 클래스에 새로운 스레드를 생성해 주면서 Runnable클래스가 가지고 있는 기능들을 사용할 수 있게 해 준다.

또한 익명 객체를 통해  run() 메서드를 오버라이딩을 하여 서브 스레드가 수행할 작업을 구성한다.

 

메인 스레드가 새로운 작업을 스레드를 생성해 준다. 이때 t1이라는 객체를 생성해 주고 해당 객체에 Runnable이라는 새로운 익명 객체를 만들어주는데, 익명 객체에 대한 로직을 오버라이딩으로 run() 메서드로 구성해 놓은 것이다.

[스레드 실행 순서]

  1. 메인 스레드가 메인 메서드를 실행시킨다.
  2. 서브 스레드가 작업할 새로운 스레드 생성 및 익명 객체를 통해 구성한다.
  3. 서브 스레드를 동작시킨다.
  4. 메인 스레드는 동작을 종료한다.
  5. 서브 스레드 로직을 수행 후 종료한다.

 

 

[Runnable 인터페이스의 상속받는 클래스 구현]

class ShowOwnNameThread extends OwnName implements Runnable {

	public ShowOwnNameThread(String ownName) {
		this.setOwnName(ownName);
	}

	@Override
	public void run() {
		for (int i = 0; i < 100; i++) {
			System.out.println(i + "안녕하세요 : " + this.getOwnName() + "입니다.");

			try {
				Thread.sleep(50);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		System.out.println(this.getOwnName() + " 스레드 종료");
	}

}

이미 다른 클래스르 상속받은 상태여서 스레드를 상속받지 못한다면 인터페이스의 상속을 받는 클래스로 구현하면 된다.

다른 클래스로 상속을 받은 상태에서 스레드까지 상속을 받지 못하는 이유는 ‘자바는 단일 상속만 지원’ 하기 때문이다.

그래서 이미 다른 클래스의 상속받은 클래스에서 스레드를 동작시키기 위해 위의 코드와 같이 Runnable 인터페이스를 상속받는 방법을 사용한다.


[스레드 이름]

스레드는 자신의 이름을 가지고 있다. 메인 스레드는 ‘main’이라는 이름을 가지고 있고, 작업 스레드는 자동적으로 ‘Thread-n’이라는 이름을 가진다. 

배열과 같이 0번부터 시작되며 만약 스레드의 이름을 지정하고 싶다면 thread.setName(“원하는 이름”); 으로 구성하면 된다.

주로 스레드 이름은 디버깅할 때 어떤 스레드가 작업을 하는지 조사할 목적으로 주로 사용된다.

만약 현재 코드에 어떤 스레드가 실행하고 있는 확인하고 싶다면 정적 메서드인 cuttentThread()로

스레드 객체의 참조값을 얻은 후 getName() 메서드로 이름을 출력하면 된다.

getter, setter를 사용해 주면 된다.

Thread thread = Thread.currentThread();

System.out.println(thread.getName());


[스레드 상태]

스레드 객체를 생성하고 start() 메서드를 호출할 때 곧바로 스레드가 실행되지 않는 실행 대기 상태이다.

CPU에 있는 스케줄링에 따라 run() 메서드를 실행하게 된다. CPU의 상황에 따라 실행 중 잠시 중단하고 다른 스레드가

실행될 수 도 있다.

 

[일시정지]

 sleep(멈추고 싶은 초), : 1/1000 단위로 시간을 주면 해당 시간만큼 멈춘다. ex) Thread.sleep(1000) = 1초 딜레이

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

sleep() 메서드를 통해 5초 동안 스레드를 일시 정지 상태로 만들어주고, 오류 발생 시 어디서 오류가 발생하는지 확인한다.

try {
	// VM내의 스레드 종료 신호를 기다리고 있다.
	// run()이 리턴되면 스레드는 종료 신호를 VM에 보낸다.
	// 종료 신호(Signal)
	t1.join();
} catch (InterruptedException e) {
	e.printStackTrace();
	}

join() : 해당 메서드를 호출하게 되면 join() 메서드를 가진 스레드가 종료된다.

즉, main스레드에서 사용 시 서브 스레드가 종료될 때까지 기다리게 할 수 있다.

    메인 스레드는 더 이상 할 작업은 없지만 서브 스레드는 아직 실행 중이기 때문에 일시 정지 상태가 되었다가

   서브 스레드가 종료된 것을 확인하고 종료된다.

 

[일시정지 및 탈출]

wait() : 동기화 블록 내에서 스레드를 일시 정지시킨다.

interrupted() : 인터럽트를 발생시켜 실행 대기나 종료를 시킨다.

public class WorkObject {
	public synchronized void methodA() {
		Thread thread = Thread.currentThread();
		System.out.println(thread.getName() + ": methodA 작업 실행");
		/*아래 부분을 주석처리하면 ThreadA와 ThreadB가 제각각
		동작하는 것을 볼 수 있다.*/
		// 기다리는 다른 스레드는 wait에서 탈출하고 동작시작
		notify();	 // 상대방을 깨운다. 
		// 내 스레드는 wait상태로 돌아간다.
		try {
			wait();  // 난 잔다.
		} catch (InterruptedException e) {
		}
	}

	public synchronized void methodB() {
		Thread thread = Thread.currentThread();
		System.out.println(thread.getName() + ": methodB 작업 실행");
		// 아래 부분을 주석처리하면 ThreadA와 ThreadB가 제각각
		// 동작하는 것을 볼 수 있다.
		notify();
		try {
			wait();
		} catch (InterruptedException e) {
		}
	}
}

경우에 따라서는 2개의 스레드를 교대로 번갈아 가면서 실행할 때도 있다. 

위의 코드와 같이 notify() 메서드는 wait() 메서드로 일시정지 상태인 스레드를 실행 대기 상태로 바꾼다

wait() 메서드는 자신을 2번 작업하지 않도록 일시 정지 상태로 만든다.

notify()와 wait() 메서드는 정확한 교대 작업이 필요할 경우 사용함.

 

주의사항으로는 두 메서드는 동기화 메서드 또는 동기화 블록 내에서만 사용이 가능하다.

그렇다면 내 스레드를 잠시 멈추고 상대방의 스레드를 시키려면 동기화된 내 스레드에서 wait()으로 내 거를 정지시키고, notify로 다른 스레드의 실행대기 상태로 만들어주면 된다.

 

[실행 대기로 전송]

yield() : 실행 상태에서 다른 스레드에게 실행을 양보하고 실행 대기 상태가 된다.

            서로 다른 스레드를 번갈아가면서 실행되게 할 수 있다. 이것을 Context Switching이라고 한다.

Ex) Thread.yield();

스레드의 제어권한을 넘기는 방법으로는 앞에서 알아본 sleep() 메서드가 있지만

yield() 메서드를 사용하면 해당 스레드를 기다리는 것 없이 즉시 넘기게 된다.


[스레드 동기화]

멀티 스레드는 하나의 객체를 공유해서 작업할 수도 있다. 이 경우, 다른 스레드에 의해 객체 내부 데이터가 쉽게 변경될 수 있기 때문에 의도했던 것과는 다른 결과가 나올 수 있다.

 

동기화를 할 때 num++의 동작과정과 비슷하다.

[num++ 동작 과정]

num++ 은 실제로는 이러한 과정을 거친다.

  1.   RAM에 있는 값을 레지스터로 옮긴다.
  2.   레지스터에서 값이 1 증가되고
  3.   레지스터에서 램으로 값을 옮긴다.

이러한 과정들 중에서 언제든 다른 스레드에 제어권이 넘어갈 수 있다. -> Context Switching

사실은 2번 증가하는데 1번 증가했을 때의 값을 contest에 보관하고 1이 증가했다가 왜곡시킴

num++연산의 안전한 마무리까지 thread 제어권을 유지해야 한다.(동기화)

 

스레드도 마찬가지로 각 객체별 필드의 값을  지정하게 된다면 한 객체의 값은 사라지게 된다.

그래서 다른 스레드 변경할 수 없도록 스레드 작업이 끝날 때까지 객체에 synchronized라는 키워드를 통해 잠금을 걸어준다.

synchronized 키워드는 인스턴스와 정적 메서드 어디든 붙여줄 수 있다.

를 통해 값이 동기화가 되면서 보호되고 메서드 실행이 끝난다면 잠금이 풀리면서 값에 접근할 수 있다.

여기서 this는 해당 클래스의 필드를 가리킨다.

synchronized 키워드를 통해 단 하나의 스레드만 접근하게 되면서 보다 안전한 접근을 위한 동기화를 한다.


 

[interrupted() 메서드]

해당 메서드는 스레드가 일시 정지 상태에 있을 때 InterruptedException 예외를 발생시키는 역할을 한다.

해당 메서드를 이용하여 예외 처리를 통해 run() 메서드를 정상 종료 시킬 수 있다.

여기서 Thread.sleep(1)은 일시 정지를 만들 수 있도록 오류를 발생시키는 것이다.

만약 스레드가 실행 대기/실행 상태일 때는 intterupt() 메서드가 호출되어도 InterruptedException이 발생하지 않는다.

그러나 스레드가 인위적인 이유로 일시 정지 상태가 되면 InterruptedException 예외가 발생하게 된다.

그래서 짧은 시간이라도 일시 정지를 위해 Thread.sleep(1)로 0.1초 동안 스레드를 일시 정지를 걸어준 것이다.

인위적으로 정지가 되어있어야 InterruptedException 오류가 가능하다.


 

[데몬 스레드]

데몬 스레드는 주 스레드의 작업을 돕는 보조적인 역할을 수행하는 스레드이다.

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

데몬 스레드가 적용된 예시로는 워드프로세서의 자동저장, 유튜브에 음악 재생 기능등이 있는데 워드프로세서나 유튜브가 종료되면 워드의 자동 저장, 유튜브 뮤직기능을 수행하는 데몬 스레드도 같이 종료된다.

public class DaemonExample {
	public static void main(String[] args) {
		AutoSaveThread autoSaveThread = new AutoSaveThread();
		
		/* C/C++은 원래 데몬 스레드이다.
		 * C#/Java는 위의 속성을 없애기 위한 별도의 동기화작업이 필요하다.
		 * 그래서 불편하기 때문에 그냥 별개로 동작하도록 만들었고
		 * 아래처럼 setDaemon(true)일 때 종속적이도록 만들었다.
		 * 
		 * 데몬 스레드(종속 스레드)
		 * 부모 스레드가 종료되면 자식도 함께 종료
		 * 
		 * 일반적으로는 자식 스레드는 부모 스레드와 별개로 동작한다.
		 * */
		
		// false로 하면 메인 스레드가 죽어도 계속해서 실행된다.
		autoSaveThread.setDaemon(false);	// true로 하면 부모가 죽을 때 같이 죽는다.
		autoSaveThread.start();

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

		}
		System.out.println("메인 스레드 종료");
	}
}

만약 스레드를 데몬으로 만들기 위해서는 메인 스레드가 데몬이 될 스레드의 setDaemon(true)를 호출하면 된다.

 

다음과 같이 데몬 스레드는 종속 스레드이고 부모 스레드가 종료되면 같이 종료된다.

autoSaveThread.setDaemone(true)를 통해 해당 객체를 데몬 스레드로 만들어준 것이다.


 

[스레드풀]

병렬 작업처리로 진행될 때 스레드의 개수가 많아지면 CPU가 바빠지고 메모리 사용량이 늘어나면서 앱의 성능이 떨어지게 된다.

그리하여 작업 처리에 사용되는 스레드를 제한된 개수만큼 정해 놓고 작업 큐에 들어오는 작업들을 스레드가 전담마크를 하여 하나씩 처리하는 방식을 사용한다.

이것을 스레드풀이라고 한다.

작업 처리가 끝난 스레드는 다시 작업 큐에 새로운 작업을 가져와서 처리하기 때문에 작업량이 증가해도 스레드의 개수가 늘어나지 않아 앱의 성능이 저하되지 않는다.

예시로는 맛집에 자리는 20자리 밖에 없지만 손님은 100명일 때 번호표를 뽑고 기다리는 것과 비슷하다.

 

그래서 스래드풀을 생성할 때는 스레드의 개수를 선언해줘야 하는데

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

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

처럼 executorService라는 객체에 5개의 스레드를 처리할 수 있게 담아준다.

이때 0개의 스레드부터 시작하다가 작업이 시작되면 최대 5개의 스레드로 처리할 수 있다.

또한 스레드풀은 메인 스레드가 종료되면 같이 종료되는 데몬 스레드와 다르기 때문에 메인 스레드가 종료되어도 계속해서

실행되고 있다.

그러므로 만약 리턴타입이 void라면 shutdown(매개변수),

리턴타입이 List <Runnable>라면 shutdownNow(매개변수)로 종료해줘야 한다.


 

[작업 생성과 처리 요청]

Runnable의 run() 메서드는 리턴값이 없고, Callable의 call() 메서드는 리턴값이 있다.

call()의 리턴 타입은 Callable <T>에서 지정한 T타입 파라미터와 동일한 타입 이어야 된다.

 

run() 메서드 : excute(Runnable command)

-> Runnable을 작업 큐에 저장, 작업 처리 결과를 리턴하지 않는다.

 

call() 메서드 : submit(Callable <T> task)

-> Callable을 작업 큐에 저장, 작업 처리 결과를 얻을 수 있는 Fucture를 리턴.

 

 

[에러 유형 및 세부 메서드]

스레드에서 currentThread() 메서드는 현재 스레드를 말한다.

스레드 자체에는 getName이 원래 부여되어 있다.

InterruptedException은 스레드가 일시 정지 상태에서 다른 스레드에 의해 중단되었을 때 발생한다.

Exception은 모든 종류의 예외를 처리, 모든 예외들의 조상

PrintStackTrace 오류가 발생했을 때 어디서 오류가 발생했는지 알 수 있다.


[핵심 요약]

  • 멀티 스레드란 프로그램 내에서 동시에 여러 작업을 처리하는 기능을 제공한다.
  • 멀티 스레드를 사용함으로써 프로그램의 성능을 향상하고 효율적으로 작업을 수행할 수 있으나, 그에 따른 동기화와 예외 처리 같은 추가적인 관리는 필수이다.
  • 스레드를 사용할 때는 익명 객체를 통해 로직을 구성한다. 람다식과 상당히 유사함
  • 각종 메서드를 통해 스레드를 일시 정지 시키거나, 번갈아가면서 사용할 수 있다.

'스터디 > JAVA' 카테고리의 다른 글

[JAVA] 추상 & 메서드  (0) 2024.04.13
[JAVA] 컬렉션  (1) 2024.02.18
[JAVA] 상속  (0) 2024.02.12
[JAVA] 배열  (1) 2024.02.07
[JAVA] 클래스  (1) 2024.02.03