[자바의정석] 쓰레드

etlaou·2021년 8월 13일
1

개념은 스터디 때 넘기고 코드 위주로 확인한다.

프로세스 & 쓰레드

  • 프로세스: 실행중인 프로그램 (자원[데이터 + 메모리] + 쓰레드)
  • 쓰레드: 프로세스의 자원을 이용해 실제로 작업을 수행하는 단위

멀티태스킹 & 멀티쓰레딩

  • 멀티태스킹: 여러 프로세스가 동시에 실행되는 것
  • 📌 멀티쓰레딩: 하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행하는 것

싱글쓰레드와 멀티쓰레드 차이

  1. CPU 사용률 향상
  2. 효율적인 자원 사용
  3. 사용자에 대한 응답성 향상
  4. 작업이 분리되기 때문에 코드가 간결해진다.

위와 같은 장점이 있다면, 단점으로 하나의 프로세스에서 여러 쓰레드가 자원을 공유하면서 작업을 수행하기 때문에 동기화, 교착상태등의 문제를 고려해야 한다.

쓰레드 구현 및 실행

💡[쓰레드 구현 방법]

  1. Thread Class 상속
class MyThread extends Thread{
	public void run() {}
}

Thread 클래스를 상속받으면 다른 클래스를 상속받을 수 없기 때문에 Runnuable 인터페이스로 구현하는 것이 일반적
Thread 상속하면 Thread 클래스의 메서드를 직접 호출 가능

  1. Runnable Interface 구현
class MyThread implements Runnable{
	public void run() {} //쓰레드를 통해 수행하고자 하는 작업 내용을 채워준다.
}

제사용성이 높고 코드의 일관성을 유지할 수 있기 때문에 보다 객체지향적인 방법
Runnable로 구현할 경우, Thread 클래스의 static메서드인 currentThread를 호출하여 쓰레드에 대한 참조를 얻어 와야만 호출 가능

class ThreadEx1 {
	public static void main(String args[]) {
		ThreadEx1_1 t1 = new ThreadEx1_1();

		Runnable r  = new ThreadEx1_2();
		Thread   t2 = new Thread(r);	  // 생성자 Thread(Runnable target)
		// Thread t2 = new Thread(new ThreadEx1_2())
		t1.start(); //쓰레드 실행
		t2.start(); //쓰레드 실행
	}
}

//Thread 클래스를 상속할 경우
class ThreadEx1_1 extends Thread {
	public void run() {
		for(int i=0; i < 5; i++) {
        		//getName를 직접 호출 가능
			System.out.println(getName()); // 조상인 Thread의 getName()을 호출
		}
	}
}

class ThreadEx1_2 implements Runnable {
	public void run() {
		for(int i=0; i < 5; i++) {
		   // Thread.currentThread() - 현재 실행중인 Thread를 반환한다.
		    System.out.println(Thread.currentThread().getName());
		}
	}
}

위 코드를 보면 Thread 클래스를 상속했냐, Runnable 인터페이스로 구현했냐에 따라 getName을 호출하는 방법이 다르다.

t1.start(); //쓰레드 실행
t2.start(); //쓰레드 실행

start()를 호출한다해서 쓰레드가 시작되는 것이 아니라 실행 대기 상태에 있다가 차례가 되면 실행
한 번 start하면 재할당을 하지 않으면 재호출 불가능

Thread t1 = new Thread();
t1.start();
//t1 = new Thread(); 재할당
t1.start(); //에러

start()와 run() 차이

  • main에서 run() 호출
    : 단순히 클래스에 선언된 메서드를 호출하는 것

  • start()
    : 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택을 생성한 후 run()을 호출해 호출스택에 run 실행

 class ThreadEx2 {
	public static void main(String args[]) throws Exception {
		ThreadEx2_1 t1 = new ThreadEx2_1();
		t1.start();
	}
}

class ThreadEx2_1 extends Thread {
	public void run() {
		throwException();
	}

	public void throwException() {
		try {
			throw new Exception();		
		} catch(Exception e) {
			e.printStackTrace();	
		}
	}
}

Page 730 쪽에 Call Stack을 보면 Main 쓰레드가 이미 종료되어 존재하지 않는다고 하는데
이 부분이 이해가 안감

Main 호출스택이 비워졌으면, run이랑 throwException도 없어야되는거 아닌가?

싱글 쓰레드와 멀티 쓰레드 성능 분석

[하나의 쓰레드 VS 두개의 쓰레드]

  • 싱글쓰레드가 작업속도가 더 빠른 이유?

    작업 전환: 다음에 실행해야할 위치 등의 정보를 저장하고 읽어 오는 시간이 소요되므로

📌 싱글코어에서 단순히 CPU만을 사용하는 계산작업은 싱글쓰레드가 더 효율적

멀티코어일 경우 작업이 겹치는 부분이 생기는데, 이 경우는 한 쓰레드가 화면에 출력하고 있는 동안 다른 쓰레드는 대기상태

  • 두 쓰레드가 서로 다른 자원을 사용할 경우에는?

    멀티쓰레드가 효율적이다. 다른 쓰레드가 입력을 기다릴때 또 다른 쓰레드가 일을 처리하면 되기 때문에.

쓰레드 우선순위

class ThreadEx8 {
	public static void main(String args[]) {
		ThreadEx8_1 th1 = new ThreadEx8_1();
		ThreadEx8_2 th2 = new ThreadEx8_2();

		th2.setPriority(7); //쓰레드 보통우선순위를 7로 변경
        //따로 우선 순위를 지정하지 않을 경우 main의 기본 우선순위인 5을 할당
		//setPriority() : 쓰레드 우선순위 지정
		//getPriority() : 쓰레드 우선순위 반환
		System.out.println("Priority of th1(-) : " + th1.getPriority() ); // 5
		System.out.println("Priority of th2(|) : " + th2.getPriority() ); //7
		
        //또한, Thread를 실행하기 전에만 우선순위를 정할 수 있다.
        th1.start();
		th2.start();
	}
}

class ThreadEx8_1 extends Thread {
	public void run() {
		for(int i=0; i < 300; i++) {
			System.out.print("-");
			for(int x=0; x < 10000000; x++);
		}
	}
}

class ThreadEx8_2 extends Thread {
	public void run() {
		for(int i=0; i < 300; i++) {
			System.out.print("|");
			for(int x=0; x < 10000000; x++);
		}
	}
}

싱글코어의 경우, 우선순위가 7인 th2가 th1보다 높으므로 th2를 우선적으로 해결한 후 th1 수행
멀티코어의 경우, 우선순위가 뭐가 됐건, 우선순위에 따른 차이가 거의 없다.

데몬쓰레드

다른 일반쓰레드의 작업을 돋는 보조 역할을 수행하는 쓰레드
→ 일반 쓰레드가 종료되면, 모든 데몬 쓰레드는 종료
Ex) 가비지 컬렉터, 자동저장 등

무한 루프와 조건문을 사용해 대기 상태에 있다가 특정 조건에 작업을 수행하고 대기

[데몬 쓰레드 작성법]

class ThreadEx10 implements Runnable  {
	static boolean autoSave = false;

	public static void main(String[] args) {
		Thread t = new Thread(new ThreadEx10()); //일반 쓰레드 생성
        //데몬 쓰레드는 반드시 쓰레드 생성 후 setDaemon()을 호출해야 한다.
		t.setDaemon(true);		// 이 부분이 없으면 종료되지 않는다.
		t.start(); // 쓰레드로 run을 호출을 하겠죠?

		for(int i=1; i <= 10; i++) {
			try{
				Thread.sleep(1000); //1초마다
			} catch(InterruptedException e) {}
			System.out.println(i); //i를 출력하고
			
			if(i==5) //i가 5가 되었을 때 (5초)
				autoSave = true; //autoSave True
		}

		System.out.println("프로그램을 종료합니다.");
	}

	public void run() {
		while(true) {
			try { 
				Thread.sleep(3 * 1000);	// 3초마다
			} catch(InterruptedException e) {}	

			// autoSave의 값이 true이면 autoSave()를 호출한다.
			if(autoSave) { 
				autoSave();
			}
		}
	}

	public void autoSave() {
		System.out.println("작업파일이 자동저장되었습니다.");
	}
}

지금은 setDemon을 했기 때문에 일반 쓰레드가 종료되면 자동으로 데몬 쓰레드가 종료되기 때문에 프로그램이 종료되나 그러지 않을 경우 종료되지 않는다.

쓰레드의 실행제어

  • NEW : 쓰레드가 생성되고 start되지 않은 상태
  • RUNNABLE: 쓰레드가 실행중인 상태
  • BLOCKED: 동기화 블럭에 의해 일시정지된 상태
  • WAITIN: 쓰레드가 종료되지는 않았지만, 일시정지 상태
  • TERMINATED: 쓰레드의 작업이 종료된 상태

sleep()

쓰레드를 지정한 시간동안 멈춤

class ThreadEx12 {
	public static void main(String args[]) {
		ThreadEx12_1 th1 = new ThreadEx12_1();
		ThreadEx12_2 th2 = new ThreadEx12_2();

		th1.start();
		th2.start();

		try {
			th1.sleep(2000);	
		} catch(InterruptedException e) {}

		System.out.print("<<main Á¾·á>>");
	} // main
}

class ThreadEx12_1 extends Thread {
	public void run() {
		for(int i=0; i < 300; i++) {
			System.out.print("-");
		}
		System.out.print("<<th1 Á¾·á>>");
	} // run()
}

class ThreadEx12_2 extends Thread {
	public void run() {
		for(int i=0; i < 300; i++) {
			System.out.print("|");
		}
		System.out.print("<<th2 Á¾·á>>");
	} // run()
}

실행결과를 보면 th1 → th2 → main 순으로 종료된다.
하지만 코드를 보면 우린 지금 th1을 2초동안 정지를 시켰음에도 불구하고 th1이 제일 먼저 종료되었다.
신기하다.

그 이유는 sleep()은 항상 현재 실행중인 쓰레드에 대해 작동하기 때문에, th1.sleep()이라고 호출하더라도 실제로 영향 받는 것은 main 쓰레드이다.
💡그렇다면 th1을 정지시키는 방법은?? 단순히 Thread.sleep()이라 하면 될까??

interrupt()

쓰레드가 작업을 끝내기 전에 취소하는 것

import javax.swing.JOptionPane;

class ThreadEx14_1 {
	public static void main(String[] args) throws Exception 	{
		ThreadEx14_2 th1 = new ThreadEx14_2();
		th1.start();

		String input = JOptionPane.showInputDialog("아무 값이나 입력하세요."); 
		System.out.println("입력하신 값은 " + input + "입니다.");
		th1.interrupt();   // interrupt()를 호출하면, interrupted상태가 true가 된다.
		System.out.println("isInterrupted():"+ th1.isInterrupted());
	}
}

//10초부터 카운트다운하는 쓰레드
class ThreadEx14_2 extends Thread {
	public void run() {
		int i = 10;
		// interrupt는 원래 False
		while(i!=0 && !isInterrupted()) { //i가 0이 아니고, Interrupt가 False일 때만
			System.out.println(i--);

			try {
				Thread.sleep(1000);  // 1초 지연
			} catch(InterruptedException e) {}
            		//해결
			//try {
			//	Thread.sleep(1000);  // 1초 지연
			//} catch(InterruptedException e) {
            		//	interrupt();
            		//}
		}

		System.out.println("카운트가 종료되었습니다.");
	} // main
}

원래 같은 경우에는 10초에서 카운트다운되고 사용자에게 입력을 받으면 th1.interrupt로 인해 False에서 True로 변경되어 카운트다운이 종료가 되어야한다. 하지만 입력을 받았음에도 카운트다운은 멈추지 않는다.

그 이유는 Thread.sleep()에 있다. 쓰레드가 sleep상태일때 interrupt가 호출되면 InterruptException 오류가 발생하고 interrupt가 false로 초기화 되므로 계속 카운트 된 것이다.

suspend(), resume(), stop()

  • suspend(): 쓰레드 정지
  • resume(): suspend로 정지된 쓰레드를 재실행
  • stop(): 호출 즉시 쓰레드 종료

이 세가지 메서드는 실행을 제어하는 가장 쉬운 방법이지만, 교착상태를 야기하기 때문에 suspend(), stop은 사용을 권장하지 않는다. 그래서 이 메서드들을 모두 @deprecated 되어 있다.

📌 @deprecated에 대해서는 이전 게시물 Annotation에 정리해뒀다.

yield()

다음 차례에 쓰레드에게 실행시간을 양보할 때 사용
yeild()와 interrupt()를 적절히 사용해 응답성이 높은 프로그램을 구현할 수 있다.

public void run() {
		String name =th.getName();

		while(!stopped) {
			if(!suspended) {
				System.out.println(name);
				try {
					Thread.sleep(1000);
				} catch(InterruptedException e) {
					System.out.println(name + " - interrupted");
				}			
			} else {
				Thread.yield();
			}
		}
		System.out.println(name + " - stopped");
	}

만약 else부분이 없는 코드라고 가정하자. 그러면 suspended가 True일 경우(실행을 멈춘 상태)에는 while문을 의미 없이 도는 것과 같다 (= 바쁜 대기상태). 하지만 else에서 Thread.yield()로 실행시간을 양보함으로써 while문에서 시간을 낭비하지 않고 양보하게 함으로써 효율적인 프로그램이 될 수 있다.

	public void suspend() {
		suspended = true;
		th.interrupt();
		System.out.println(th.getName() + " - interrupt() by suspend()");
	}

	public void stop() {
		stopped = true;
		th.interrupt();
		System.out.println(th.getName() + " - interrupt() by stop()");
	}

이렇게 가정하자. 만약 Thread.sleep(1000)으로 1초동안 정지상태일 때, stop이 호출된다면, stopped가 True로 변경되도 쓰레드가 정지될때까지 지연이 생긴다.
하지만 Interrupt를 호출하면 sleep()에서 예외가 발생해 즉시 정지상태에서 벗어날 수 있으므로 지연시간을 없앨 수 있다.

join()

자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때 사용
시간을 지정하지 않으면 해당 쓰레드가 작업을 모두 마칠 때까지 기다리게 되는데, 작업 중에 다른 쓰레드의 작업이 먼저 수행되어야 할 때 사용

class ThreadEx19 {
	static long startTime = 0;

	public static void main(String args[]) {
		ThreadEx19_1 th1 = new ThreadEx19_1();
		ThreadEx19_2 th2 = new ThreadEx19_2();

		th1.start();
		th2.start();
		startTime = System.currentTimeMillis();

		try {
			th1.join();	// main쓰레드가 th1의 작업이 끝날 때까지 기다린다.
			th2.join();	// main쓰레드가 th2의 작업이 끝날 때까지 기다린다.
		} catch(InterruptedException e) {}

		System.out.print("소요시간:" + (System.currentTimeMillis() - ThreadEx19.startTime));
	} // main
}

class ThreadEx19_1 extends Thread {
	public void run() {
		for(int i=0; i < 300; i++) {
			System.out.print(new String("-"));
		}
	} // run()
}

class ThreadEx19_2 extends Thread {
	public void run() {
		for(int i=0; i < 300; i++) {
			System.out.print(new String("|"));
		}
	} // run()
}

join을 사용하지 않을 경우 th1,th2가 안끝났어도 main이 종료되면 프로그램이 종료된다.
하지만 join을 사용하면, th1,th2 작업을 기다린 후 Main이 종료된다.

💡쓰레드의 동기화

멀티쓰레드 프로세스의 경우 여러 쓰레드가 동일한 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 된다.

이로 인한 사이드 이펙트를 생각해 봤을 때
쓰레드A가 작업 도중 다른 쓰레드B로 제어권이 넘어갔을 때, 쓰레드B가 A가 작업중이던 데이터를 수정할 경우, 다시 쓰레드A가 작업을 할 때 원하던 결과를 얻지 못하는 상황이 있을 수 있다.

📌 따라서 한 쓰레드가 작업을 끝내기 전까지 다른 쓰레드에 의해 영향을 받지 않는 것이 필요하다.
→ "임계영역", "LOCK"
→ 한 쓰레드가 진행중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을
쓰레드의 동기화

synchronized 동기화

임계영역을 지정

  • 두 가지 방법이 있다.
public synchronized void calcSum(){}

메서드 전체가 임계영역으로 설정. 메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock 반환

synchronized (객체 참조변수)

메서드 전체가 아닌, 메서드 내 일부의 코드를 임계영역으로 지정하는 것
이 블럭 내에서만 lock이 존재하고 벗어나면 lock을 반환

💡 임계영역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 가능하면 2번째 방법을 쓰는 습관을 들여야한다.

class ThreadEx21 {
	public static void main(String args[]) {
		Runnable r = new RunnableEx21();
		new Thread(r).start(); // ThreadGroup에 의해 참조되므로 gc대상이 아니다.
		new Thread(r).start(); // ThreadGroup에 의해 참조되므로 gc대상이 아니다.
	}
}

class Account {
	private int balance = 1000;

	public  int getBalance() {
		return balance;
	}

	public void withdraw(int money){
		if(balance >= money) {
			try { Thread.sleep(1000);} catch(InterruptedException e) {}
			balance -= money;
		}
	} // withdraw
}

class RunnableEx21 implements Runnable {
	Account acc = new Account();

	public void run() {
		while(acc.getBalance() > 0) {
			// 100, 200, 300중의 한 값을 임으로 선택해서 출금(withdraw)
			int money = (int)(Math.random() * 3 + 1) * 100;
			acc.withdraw(money);
			System.out.println("balance:"+acc.getBalance());
		}
	} // run()
}

잔고에서 임의 금액을 출금하는 프로그램인데, 잔고가 없으면 출금이 불가능해야 하지만, 위 코드의 실행결과는 잔고가 음수일 때도 정상적으로 동작한다.

이는 withdraw함수가 호출되고 if문에서 balance(잔고) >= money(출금금액)의 결과 true가 반환되고 try를 진입하는 순간 sleep으로 인해 다른 쓰레드로 제어권이 넘어간다.
따라서 두 개의 쓰레드가 하나의 데이터 공간을 사용하기 때문에 다른 쓰레드에서 얼마를 출금한 후 다시 원래 쓰레드로 돌아갔을 때 이전의 출금하려는 금액이 차감되기 때문에 우리가 기대했던 양수의 결과가 아닌 음수의 결과를 얻는 것이다.
이렇듯 임계영역을 특정지어줘야 쓰레드간에 서로 방해하지 않을 수 있다.

public synchronized void withdraw(int money){
		if(balance >= money) {
			try { Thread.sleep(1000);} catch(InterruptedException e) {}
			balance -= money;
		}
	} // withdraw

wait() & notify()

방금까지 동기화로 공유데이터를 보호하는 것을 봤다면, 이번에는 하나의 쓰레드가 lock을 오랫동안 갖지 않도록 하는 것을 보겠다.

  • wait()

    lock을 가진 상태에서 더 이상할 작업이 없을 경우 lock을 반납하고 기다림

  • noitfy()

    lock을 반납한 객체가 다시 할 작업이 생길 경우 notify를 호출해 다시 락을 얻는다.
    notify를 호출할 경우 대기열에 있는 쓰레드 중 순서대로 Lock를 제공하는 것이 아니라 임의의 쓰레드로 제공한다.

🔍 기아현상 & 경쟁상태

  • 기아현상

    notify를 계속 호출했으나 lock을 받지 못해 계속 기다리는 현상
    → notify()가 아닌 notifyAll()을 사용

  • 경쟁상태

    불필요한 쓰레드까지 waiting pool에 들어와 불필요한 lock 경쟁을 하는 것

🐳 Lock & Condition을 이용한 동기화

같은 매서드 내에서만 lock을 거는 제약을 깨트리기 위해 사용하는 동기화 방법 Lock

  1. ReentrantLock
  2. ReentrantReadWriteLock
  3. StampedLock
  1. ReentrantLock
    : 특정 조건에서 lock을 풀고 나중에 다시 lock을 얻어 다시 작업을 수행할 수 있다.

  2. ReentrantReadWriteLock
    : 읽기를 위한 lock과 쓰기를 위한 lock을 제공
    무조건 lock이 있어야만 임계영역 코드를 수행하는 것이 아닌 읽기 또는 쓰기의 lock이 걸려있으면 중복해서 lock을 걸 수 있다.

  3. 무조건 일기 lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 거는 것

ReentrantLock()

lock이 풀렸을 때 가장 오래 기다린 쓰레가 lock을 얻는다. 공정하게 처리
하지만 오래 기다린 쓰레드를 찾기 때문에 성능이 떨어짐

ReentrantLock은 synchronized와 달리 lock을 풀고 잠그는 것을 수동으로 해줘야한다.
lock() unlock()

volatile

멀티코어 프로세서는 코어마다 별로의 캐시를 지니고 있음
코어는 메모리에서 읽어온 값을 캐시에서 저장하고 캐시에서 값을 읽어 작업을 하고 다시 같은 값을 읽을 때는 먼저 캐시를 확인하고 없을 때 메모리에서 읽는다.
이럴 경우 메모리에 저장된 변수의 값이 변경되었지만 캐시의 값은 변경이 되지 않아 서로 다른 경우가 발생할 수 있는데,
→ 이를 해결하기 위해 volatile 키워드를 사용한다.
→ 이로 인해 코어가 변수의 값을 메모리에서 읽어올때 캐시가 아닌 메모리에서 읽어오게함으로써 캐시와 메모리간의 데이터 불일치 해결

fork & join

fork & join 프레임워크는 하나의 작업을 작은 단위로 나눠 여러 쓰레드가 동시에 처리하는 것을 쉽게 해준다.

RecursiveAction: 반환값이 없는 작업을 구현
RecursiveTask: 반환값이 있는 작업을 구현
두 클래스는 모드 compute() 라는 추상 메서드를 가지고 있음

쓰레드 시작: start() 또는 run()
fork/join 작업: invoke()

ForkJoinPool pool = new ForkJoinPool(); //ForkJoinPool : 쓰레드 풀
SumTask task = new SumTask(from, to);
Long Result = pool.invoke(task); //invoke로 작업 시작

ForkJoinPool
: 쓰레드 풀(Queue)로, 지정된 수의 쓰레드를 생성해서 미리 만들어 놓고 반복해서 재사용
쓰레드를 반복 생성할 필요가 없고 많은 수의 쓰레드를 생성하지 않아 성능에 좋음

Compute() 추상메서드

  • 수행할 작업과 작업을 어떻게 나눌지 결정
  • fork로 나눈 작업을 큐(쓰레드 풀)에 넣고, compute를 재귀호출
public Long compute(){
    long size = to - from + 1;
    //실제 수행할 작업
    if (size <= 5) return sum();
    
    // 수행할 작업을 나눔
    long half = (from + to) / 2;
    
    SumTask leftSum = new SumTask(from, half);
    SumTask rightSum = new SumTask(half+1, to);
    
    leftSum.fork(); // 쓰레드 풀에 작업을 추가하고
    return rightSum.compute() + leftSum.join();
    //compute가 재귀호출됨, 하지만 join은 호출되지 않음
    //join은 언제 호출하나? compute가 반복이 끝났을 때
}

Work Stealing

이해가 잘 안감..

fork & join

  1. compute(): 작업을 나눔
  2. fork() : 작업을 큐에 추가 (비동기 메서드)
  3. join() : 작업의 결과를 합침 (동기 메서드)

비동기메서드
: 메서드를 호출만 할 뿐 기다리지 않는다. → 다른 쓰레드에게 작업을 지시만!

profile
To be Cloud DevOps Engineer

0개의 댓글