January 05, 2022
프로세스(process)는 실행중인 프로그램(program)이다.
프로세스를 작업공간(공장)이라고 하면 쓰레드는 작업을 처리하는 일꾼(worker)로 생각하면 된다.
멀티쓰래딩 : 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행하는 것
CPU의 코어가 한번의 하나의 작업만 수행 가능하므로 동시의 처리되는 작업의 개수는 코어의 개수와 일치한다.
-> 아주 짧은 시간동안 여러 작업을 번갈아 가며 수행하면서 여러 작업들이 동시해 수행하는 것처럼 보이게 한다.
class MyThread extends Thread{
public void run() {
//작업내용 // Thread클래스의 run()을 오버라이딩 해서 사용한다.
}
}
class myTread implements Runnable{
public void run(){
// 작업내용 // Runnable 인터페이스의 run()을 구현
}
}
두가지 방법이 있지만 Thread클래스를 상속받으면 다른 클래스를 상속받을 수 없기 때문에 Runnable 인터페이스를 구현하는 방법이 일반적이며 보다 객체지향적인 방법이다!
결국에 쓰래드를 구현한다는 것은 쓰래드를 통해 작업하고자하는 내용으로 사용되는 run()함수를 구현하는 것이다.
Thread를 extend하여 구현하면 Thread클래스의 메서드를 직접호출 할 수 있지만 Runnable을 구현하여 사용할 경우 currentTread()를 호출하여 쓰레드에 대한 참조를 받아와야 한다.
static Thread currentThread() //현재 실행중인 쓰레드의 참조를 반환한다.
String getName() // 쓰레드의 이름을 반환한다.
// 따라서 다음과 같이 사용해서 현재 실행중인 쓰래드의 이름을 파악한다.
Thread.currentTread().getName();
아래 예시는 사용에 대한 차이를 보여준다.
class ThreadEx1_1 extends Thread{
public void run(){
for(int i=0; i<5; i++){
System.out.println(getName());
}
}
}
class ThreadEx1_2 implements Runnable{
public void run(){
for(int i=0; i<5; i++){
//Thread.currentThread() - 현재 실행중인 Thread를 반환한다.
System.out.println(Thread.currentTread().getName());
}
}
}
start()는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택(call stack)을 생성한 다음에 run()을 호출해서, 생성된 호출 스택에 run()이 첫번째로 올라가게 한다.
메인 메서드의 작업을 수행하는 것도 결국 쓰레드이며 이를 main쓰레드라고 한다.
실행 중인 사용자 쓰레드가 하나도 없을 떄 프로그램은 종료 된다.
멀티쓰레드로 작업을 하면 쓰레드간의 작업 전환(context switching)이 발생하기 때문에 서로 다른 자원을 사용하는 작업의 경우 멀티쓰레드를 사용하는것이 싱글쓰레드를 사용하는 것보다 더 효율적이다.
쓰레드를 생성하며 사용자의 입력을 받는 예제는 아래와 같다.
import javax.swing.*;
public class ThreadEx2 {
public static void main(String[] args) throws Exception{
ThreadEx22 th1 = new ThreadEx22();
th1.start();
String input = JOptionPane.showInputDialog("아무값이나 입력하세요.");
System.out.println("입력하신 값은: " + input + "입니다.");
}
}
class ThreadEx22 extends Thread{
public void run(){
for(int i=10; i>0; i--){
System.out.println(i);;
try{
sleep(1000);
}
catch(Exception e){}
}
}
}
위의 예시처럼 사용자에게 입력을 받는 input의 기능을 하는 main쓰레드가 있고, 초를 세주는 ThreadEx22가 있다.
쓰레드는 우선순위(priority)라는 속성(멤버변수)를 가지고 있는데 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다.
다음과 같은 방법으로 우선순위를 정할 수 있다.
void setPriority(int newPriority) // 쓰레드의 우선순의를 지정한 값으로 변경한다.
int getPriority() // 쓰레드의 우선순위를 반환한다.
public static final int MAX_PRIORITY = 10;
public static final int MIN_PRIORITY = 1;
public static final int NORM_PRIORITY = 5;
쓰레드 그룹은 서로 관련된 쓰레드를 그룹으로 다루기 위한 것이다. 폴더처럼 쓰레드 그룹을 생성해서 관련있는 쓰레드를 그룹으로 묶어서 관리할 수 있다.
public class ThreadEx3 {
public static void main(String[] args) throws Exception{
ThreadGroup main = Thread.currentThread().getThreadGroup();
ThreadGroup grp1 = new ThreadGroup("Group1");
ThreadGroup grp2 = new ThreadGroup("Group2");
//ThreadGroup(ThreadGroup parent, String name);
ThreadGroup subGrp1 = new ThreadGroup(grp1, "SubGroup1");
grp1.setMaxPriority(3); //쓰레드 그룹 grp1의 최대우선순의를 3으로 변경
Runnable r = new Runnable() {
@Override
public void run() {
try{
Thread.sleep(1000);
} catch (InterruptedException e) {}
}
};
// Thread(ThreadGroup tg, Runnable r, String name);
new Thread(grp1, r, "th1").start();
new Thread(subGrp1, r, "th2").start();
new Thread(grp2, r, "th3").start();
System.out.println(">>List of ThreadGroup : " + main.getName() + "" +
", Active Thread Group: " + main.activeGroupCount() + ", Active Thread: " + main.activeCount());
main.list();
}
}
데몬 쓰레드는 다른 일반 쓰레드(데몬 쓰레드가 아닌 쓰레드)의 작업을 돕는 보조적인 역할을 수행하는 쓰레드이다. 데몬쓰레드는 무한루프와 조건문을 이용해서 실행 후 대기하고 있다가 특정 조건이 만족되면 작업을 수행하고 다시 대기하도록 작성한다.
boolean isDaemon() // 쓰레드가 데몬 쓰레드인지 확인한다. 데몬쓰레드이면 true를 반환한다.
void setDaemon(boolean on)
// 쓰레드를 데몬쓰레드로 또는 사용자 쓰레드로 변경이 가능하다. on을 true로하면 데몬 쓰레드가 된다.
// start()를 호출하기 전에 실행되어야만 한다.
아래 예시는 데몬 쓰레드에 관한 예시이다. 보다시피 데몬쓰레드는 대기하고 있다가 autoSave의 조건이 걸리면 이제 지정해둔 활동을 진행한다.
public class ThreadEx4 implements Runnable{
static boolean autoSave = false;
public static void main(String[] args){
Thread t = new Thread(new ThreadEx4());
t.setDaemon(true);
t.start();
for(int i=1; i<=10; i++){
try{
Thread.sleep(1000);
}catch(InterruptedException e){}
System.out.println(i);
if(i == 5) autoSave = true;
}
}
public void run(){
while(true){
try{
Thread.sleep(3 * 1000); //3초마다
}catch (InterruptedException e ){}
// autoSave가 true이면 해당 함수를 호출
if(autoSave){
autoSave();
}
}
}
public void autoSave(){
System.out.println("작업파일이 자동저장되었습니다.");
}
}
쓰레드 프로그래밍이 어려운 이유는 동기화(synchronization)와 스케줄링(scheduling)때문이다. 이를 잘 하기 위해서 아래와 같은 함수들의 사용법을 잘 익혀야 한다.
관련 함수들을 알아보기 전에 쓰레드의 상태에 대해서 먼저 알아야한다. 쓰레드는 다음 표의 나온 것처럼 5개의 상태로 나뉜다.
또한 쓰레드의 생성부터 소멸까지의 과정을 그린 그림으로 이제 앞으로 사용할 쓰레드의 동기화 및 스케쥴링을 위한 함수들이 실행하면 실제로 쓰레드가 어떤 상태가 되는지를 보여준다.
1.쓰레드를 생성하고 start()를 호출하면 바로 실행되는 것이 아니라 실행 대기열에 저장되어 자신의 차례가
될 때 까지 기다려야한다. 실행대기열은 큐와 같은 구조로 먼저 실행대기 열에 들어온 쓰레드가 먼저 실행된다.
2.실행대기상태에 있다가 자신의 차례가 되면 실행 상태가 된다.
3.주어진 실행시간이 다되거나 yield()를 만나면 다시 실행대기상태가 되고 다음 차례의 쓰레드가 실행상태가 된다.
4.실행중에 suspend(), sleep(), wait(), join(), I/O block에 의해 일시정지상태가 될 수 있다.
I/O block은 입출력작업에서 발생하는 지연상태를 말한다. 사용자의 입력을 기다리는 경우를 예로 들 수 있는데,
이런 경우 일시정지 상태에 있다가 사용자가 입력을 마치면 다시 실행대기 상태가 된다.
5.지정된 일시정지시간이 다되거나 notify(), resume(), interrupt()가 호출되면 일시 정지상태를 벗어나
다시 실행대기열에 저장되어 자신의 차례를 기다리게 된다.
6.실행을 모두 마치거나 stop()이 호출되면 쓰레드는 소멸된다.
sleep()은 일정 시간동안 쓰레드를 멈추게 한다.
static void sleep(long millis)
static void sleep(long millis, int nanos)
단 sleep을 호출할 때는 항상 try-catch문으로 예외처리를 해줘야 하므로 아래와 같이 try-catch까지 포함하는 메서드를 만들어서 사용하기도 한다.
void delay(long millis){
try{
Thread.sleep(millis);
} catch(InterruptedException e){}
}
sleep() 메서드는 항상 현재 실행 중인 쓰레드에 대해 작동하기 때문에 th1.sleep(2000)이런식으로 해도 결국엔 현재 실행중인 쓰레드를 sleep하는 것이 된다. 따라서 sleep메서드는 Thread.sleep(2000); 처럼 써 줘야한다.
진행중인 쓰레드의 작업이 끝나기 전에 취소시켜야 할 때 사용한다.
void interrupt() // 쓰레드의 interrupted 상태를 false에서 true로 변경
void isInterrupted() // 쓰레드의 interrupted 상태를 반환
static boolean interrupted() // 현재 쓰레드의 interrupted상태를 반환 후, false로 변경
interrupt()를 호출하면 sleep(), wait(), join()에서 InterruptedException이 발생하고 해당 쓰레드는 ‘실행대기 상태(RUNNALBE)’ 로 바뀐다.
import javax.swing.*;
public class ThreadEx5 {
public static void main(String[] args) throws Exception{
ThreadEx14_1 th1 = new ThreadEx14_1();
th1.start();
String input = JOptionPane.showInputDialog("아무값이나 입력하세요.");
System.out.println("입력하신 값은 " + input + "입니다.");;
th1.interrupt(); // interrupt()를 호출하면, interrupted상태가 true가 된다.
System.out.println("isInterrupted(): " + th1.isInterrupted());
}
}
class ThreadEx14_1 extends Thread{
public void run(){
int i= 10;
while(i !=0 && !isInterrupted()){
System.out.println(i--);
try{
Thread.sleep(1000); //1초 지연
}catch (InterruptedException e) {
// sleep함수가 돌고있는 tryCatch문에 interrupt가 발생하면
// InterruptedException이 발생하여 쓰레드의
// Interrupt 상태는 false로 자동 초기화 된다. 따라서 이를 막기위해 아래에 interrupt를 추가
interrupt();
// interrupt함수를 발생시켜서 다시 interrupted의 상태를 true로 바꿔주어야한다.
}
}
System.out.println("카운트가 종료되었습니다.");
}
}
다음과 같은 방법으로 interrupt가 발생했을 쓰레드가 sleep(1000)함수에 빠져있는 상태라면 이를 탈출하며 Interrupt의 상태가 다시 false로 자동 초기화 된다. 하지만 이번 예제의 경우 interrupt가 발생하면 종료해야하기 때문에 catch문에 다시 interrupt()를 넣어 thread가 종료되도록 만들었다.
suspend()는 sleep()처럼 쓰레드를 멈추기 한다. suspend()에 의해 정지된 쓰레드는 resume()을 호출해야 다시 실행대기 상태가 된다. stop()은 호출되는 즉시 쓰레드가 종료된다.
하지만 이 메서드들은 모두 ‘deprecated’ 되었다. 따라서 이 메서드들은 다음과 같이 오버라이딩 하여 사용한다.
public class ThreadEx6 {
public static void main(String[] args){
ThreadEx17_1 th1 = new ThreadEx17_1("*");
ThreadEx17_1 th2 = new ThreadEx17_1("**");
ThreadEx17_1 th3 = new ThreadEx17_1("***");
th1.start();
th2.start();
th3.start();
try{
Thread.sleep(2000);
th1.suspend();
Thread.sleep(2000);
th2.suspend();
Thread.sleep(3000);
th1.resume();
Thread.sleep(3000);
th1.stop();
th2.stop();
Thread.sleep(2000);
th3.stop();
}catch (InterruptedException e){}
}
}
class ThreadEx17_1 implements Runnable{
boolean suspended = false;
boolean stopped = false;
Thread th;
ThreadEx17_1(String name){
th = new Thread(this, name); // Thread(Runnable r, String name);
}
public void run(){
while(!stopped){ // stopped 값이 false인 동안 반복한다
if(!suspended){ // suspended의 값이 false일 떄만 작업을 수행한다.
System.out.println(Thread.currentThread().getName());
try{
Thread.sleep(1000);
}catch(InterruptedException e){}
}
}
System.out.println(Thread.currentThread().getName() + " - stopped");
}
// 이렇게 오버라이딩해서 사용하기
public void suspend() {suspended = true; }
public void resume() {suspended = false; }
public void stop() {stopped = true; }
// suspend, stop에 th.interrupt()를 넣어주면 sleep(1000)에 걸려있던 상태에서 바로 탈출할 수 있다.
public void start() {th.start();}
}
yield()는 쓰레드 자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보한다.
yield()와 interrupt()를 적절히 사요하면 프로그램의 응답성을 높이고 보다 효율적인 실행이 가능하게 할 수 있다.
바로 위에 코드 예제에서 다음과 같은 것들을 추가하면 조금 더 효율적으로 바꿀수 있다.
while(!stopped){
if(!suspended){
System.out.println(Thread.currentThread().getName());
try{
Thread.sleep(1000);
}catch(InterruptedException e){}
}
}
// 위의 코드를 yield를 추가하면 의미없이 while문을 도는 낭비(busy-waiting)를 없앨 수 있다.
while(!stopped){
if(!suspended){
System.out.println(Thread.currentThread().getName());
try{
Thread.sleep(1000);
}catch(InterruptedException e){}
}
else{
Thread.yeild(); //해당 쓰레드가 쓰도록 할당된 시간을 양보하기 떄문이다.
}
}
쓰레드 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할때 join()을 사용한다
void join()
void join(long millis)
void join(long millis, int nanos)
join()도 sleep()처럼 interrupt()에 의해 대기상태에서 벗어날 수 있으며 join()이 호출되는 부분을 try-catch로 감싸야 한다. 단 join()은 현재 쓰레드가 아닌 특정 쓰레드에 대해 동작하므로 static 메서드가 아니다.
public class ThreadEx7 {
public static void main(String[] args){
ThreadEx20_1 gc = new ThreadEx20_1();
gc.setDaemon(true);
gc.start();
int requiredMemory = 0;
for(int i=0; i<20; i++){
requiredMemory = (int)(Math.random() * 10) * 20;
// 필요한 메모리가 사용할 수 있는 양보다 크거나 전체메모리의 60%이상을 사용했을 경우 gc를 꺠운다.
if(gc.freeMemory() < requiredMemory || gc.freeMemory() < gc.totalMemory() * 0.4){
gc.interrupt();
// gc 가 작업할 시간을 기다리는 것
try{
gc.join(100);
// join을 이용해서 gc가 작업을 할 시간을 0.1초 정도 주는 것
// 이를 통해 데이터가 꼬이는 것을 방지할 수 있다.
}catch(InterruptedException e){}
}
gc.usedMemory += requiredMemory;
System.out.println("usedMemory: "+ gc.usedMemory);
}
}
}
class ThreadEx20_1 extends Thread{
final static int MAX_MEMORY = 1000;
int usedMemory = 0;
public void run(){
while(true){
try{
Thread.sleep(10 * 1000); //10초를 기다린다.
}catch (InterruptedException e){
System.out.println("Awaken by Interrupt().");
}
gc();//garbage collection을 수행한다.
System.out.println("Garbage Collected. Free Memory :" + freeMemory());
}
}
public void gc(){
usedMemory -= 300;
if(usedMemory < 0) usedMemory = 0;
}
public int totalMemory() { return MAX_MEMORY; }
public int freeMemory() { return MAX_MEMORY - usedMemory; }
}
한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 쓰레드의 동기화(synchronization)라고 한다.
public synchronized void caclSum(){
//...
}
synchronized(객체의 참조변수 (ex: this)){
//...
}
synchronized로 동기화하여 공유데이터를 보호할 수 있지만, 특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것도 중요하다.
wait(), notify*(), notifyAll()은 동기화 블록(synchronized블록)내에서만 사용 가능하다.
wait() : 해당 쓰레드가 락을 반납하고 기다리도록 만든다.
notify() : 작업을 중단했던 쓰레드가 다시 락을 얻어 작업을 진행할 수 있도록 한다.
ReentrantLock : 재진입이 가능한 lock. 가장 일반적인 배타 lock
ReentrantReadWriteLock : 읽기에는 공유적이고, 쓰기에는 배타적인 lock
StampedLock : ReentrantReadWriteLock에 낙관적인 lock의 기능을 추가
가장 일반적인 lock으로 특정 조건에서 lock을 풀고 나중에 다시 lock을 얻고 임계영역으로 들어와서 이후의 작업을 수행할 수 있다.
ReentrantLock() //ReentrantLock의 생성자
ReentrantLock(boolean fair) // lock이 풀렸을 때 가장 오래 기다린 쓰레드가 lock 획득
void lock() // lock을 잠근다
void unlock() // lock을 해지한다
boolean isLocked() // lock이 잠겼는지 확인한다
자동적으로 lock의 잠금과 해제가 되는 synchronized 블럭과 달리, ReentrantLock과 같은 lock 클래스들은 수동으로 lock을 잠그고 해제해야 한다.
아래 예시처럼 lock과 unlock을 하는 것은 결국 synchronized 블럭을 만드는 것과 같은 기능을 하지만, 수동으로 할 수 있다는 기능이 있는 것 뿐이다.
synchronized(lock){
//임계영역
}
/// 위의 코드는 아래와 기능은 같다
lock.lock();
// 임계영역
lock.unlock();
임계 영역 내에서 예외가 발생하거나 return문으로 빠져나가게 되면 lock이 풀리지 않을 수도 있으므로 unlock()은 try-finally 문으로 감싸는게 일반적이다.
lock.lock(); // ReentrantLock lock = new ReentrantLock();
try{
// 임계영역
} finally{
lock.unlock();
}
lock()은 lock을 얻을 때까지 쓰레드를 블락(block)시키므로 쓰레드의 응답성이 나빠질 수 있다. 응답성이 중요한 경우 tryLock()을 이용해서 지정된 시간동안 lock을 얻지 못하면 다시 작업을 시도할 것인지 포기할 것인지를 사용자가 결정할 수 있게 할 수 있다.
boolean tryLock() // 다른 쓰레드에 의해 lock이 걸려있으면 lock을 얻으려고 기다리지 않는다
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException
// lock을 얻기위해 지정된 시간만큼만 기다린다.
또한 wait와 notifye대신 Condition의 await()와 signal()을 사용하면 된다.
public void add(String dish){
lock.lock();
try{
while(dishes.size() >= MAX_FOOD){
String name = Thread.currentThread().getName();
System.out.println(name+ " is waiting.");
try{
forCook.await(); // wait() ; Cook쓰레드를 기다리게 한다.
}catch(InterruptedException e){}
}
dishes.add(dish);
forCust.signal(); // notify(); 기다리고 있는 CUST를 꺠움
System.out.println("Dishes:)" + dishes.toSTring());
} finally{
lock.unlock();
}
}
volatile은 해당 변수에 대한 읽기와 쓰기가 원자화(더이상 작업을 나눌 수 없음) 된다.
jdk 1.7 부터 fork & join 프레임웍이 추가되었고, 이 프레임웍은 하나의 작업을 작은 단위로 나눠서 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어 준다.
RecursiveAction 반환값이 없는 작업을 구현할 때 사용
RecursiveTask 반환값이 있는 작업을 구현할 때 사용
위의 두 클래스는 모두 compute()라는 추상 메서드를 가지고 있는데, 이를 상속을 통해 추상메서드를 구현하기만 하면 사용할 수 있다. compute()를 구현할 때는 수행할 작업 외에도 작업을 어떻게 나눌 것인가에 대해서도 알려줘야한다.
fork&join 프레임웍으로 수행할 작업은 compute()가 아닌 invoke()로 시작한다.
ForkJoinPool pool = new ForkJoinPool(); //쓰레드 풀을 생성
SumTask task = new SumTask(from, to); // 수행할 작업을 생성
Long result = pool.invoke(task); // invoke()를 호출해서 작업을 시작
fork() : 해당 작업을 쓰레드 풀의 작업 큐에 넣는다.
join() : 해당 작업의 수행이 끝날 때까지 기다렸다가, 수행이 끝나면 그 결과를 반환한다.
import java.util.concurrent.*;
public class ForkJoinEx1 {
static final ForkJoinPool pool = new ForkJoinPool();
public static void main(String[] args){
long from = 1L, to = 100_000_000L;
SumTask task = new SumTask(from, to);
Long result = pool.invoke(task);
System.out.printf("sum of %d~%d=%d%n",from, to, result);
System.out.println();
}
}
class SumTask extends RecursiveTask<Long>{
long from, to;
SumTask(long from, long to){
this.from = from;
this.to = to;
}
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();
}
long sum(){
long tmp = 0L;
for(long i =from; i<=to; i++) tmp+=i;
return tmp;
}
}
}