본문 바로가기

플밍 is 뭔들/JAVA

[자바] 쓰레드(thread)

※ 프로세스와 쓰레드 
  • 프로세스(process) - 실행 중인 프로그램 (프로세스는 데이터, 메모리 등의 자원과 쓰레드로 구성되어있음)
  • 쓰레드(thread) - 프로세스의 실제 작업을 수행하는 것

프로세스가 가질 수 있는 쓰레드의 양은 정해져 있지 않으나 쓰레드가 작업을 수행하는데 개별적인 메모리공간(호출스택)을 필요로 하기 때문에 프로세스의 메모리의 한계에 따라 생성할 수 있는 쓰레드가 결정된다.


※ 멀티쓰레드
멀티쓰레딩은 한개의 프로세스 안에서 여러개의 쓰레드가 동시에 작업을 수행하는 것이 가능하다. 실제로 CPU는 한가지 작업밖에 하지 못하지만 아주 짧은 시간 동안에 여러 작업을 번갈아 수행하가며 동시에 여러 작업이 수행되는 것처럼 보이게 하는 것이다.
그렇기 때문에 쓰레드가 많다고 프로세스가 빨라지는 것은 아니다.
한번에 한가지 작업만 할 수 있는 OS와 윈도우 처럼 멀티 태스킹이 가능한 OS의 차이는 이미 알고 있을것 이다. 싱글쓰레드와 멀티쓰레드의 차이도 이와 같다고 생각하면 된다.


※ 멀티쓰레딩의 장점
 - CPU 사용률 향상
 - 자원을 효율적으로 사용
 - 사용자에 대한 응답성 향상
 - 작업이 분리되어 코드가 간결해짐 


※ 싱글쓰레드와 멀티쓰레딩의 주의할 점
새로운 쓰레드를 생성하는 것 보다 새로운 프로세스를 생성하는 것이 더 많은 시간과 메모리공간이 필요하다. 그래서 다수의 요청이 있을 수 있는 경우 예를들어 서버프로그래밍을 할 때 싱글쓰레드로 프로그래밍을 하면 사용자의 요청마다 새로운 프로세스를 생성해야 한다. 즉 비효율 적이다. 
그렇기 때문에 프로그램의 특성에 맞게 멀티쓰레딩과 싱글쓰레드를 선택해서 프로그래밍 해야한다.
그리고 멀티쓰레딩의 경우 여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업을 하기 때문에 동기화(sysnchronization), 교착상태(deadlock)와 같은 문제들을 고려해서 신중히 프로그래밍 해야 한다.

[참고] 동기화 - 멀티쓰레딩을 사용하면 다수의 쓰레딩이 공공자원에 접근 할 수 있다. 이런 공공자원을 사용할 때 한 쓰레드가 이 공공자원을 
                         변경시켰을 때 그 후 접근하는 다른 쓰레드는 이 공공자원의 변경상태를 적용시킨 상태에서 사용 되어야한다.
[참고] 교착상태 - 두 쓰레드가 자원을 점유한 상태에서 서로 상대편이 점유한 자원을 사용하려고 기다리느라 진행이 멈춰있는 상태


※ 쓰레드의 구현과 실행

  1. Thread클래스 상속받아 사용하기
public class MyThread1 extends Thread{
      
      public void run(){
            //기능구현
      }
}


  1. Runnable 인터페이스 구현하기
public class MyThread2 implements Runnable{
      @Override
      public void run() {
            // 기능구현
      }
}

Runable 인터페이스는 run()메서드만 정의되어 있는 간단한 인터페이스이다. 우리는 run()메서드의 몸통만 구현해주면 된다.
또한 Thread클래스를 상속받아 사용하는 것도 run메서드만 구현해주면 된다. 즉 어떤방법을 쓰던 run()메서드의 몸통만 구현해주면 된다.


※ Thread 실행

public static void main(String[] args) {
            
    //쓰레드클래스 상속을 받은 자식클래스로 객체 만들기
    MyThread1 t1 = new MyThread1();
            
    //인터페이스를 이용한 쓰레드 객체 만들기
    Runnable r = new MyThread2();
    Thread t2 = new Thread(r);
    
    //쓰레드 실행        
    t1.start();
    t2.start();
            
}

참고로 쓰레드의 재사용은 안된다.
MyThread1 t1 = new MyThread1();
t1.start();
t1.start(); // 예외발생

그렇기 때문에 만약 똑같은 기능을 두번 사용할 일이 생긴다면 다음과 같이하자.

MyThread1 t1 = new MyThread1();
t1.start();
t1 = new MyThread1();
t1.start();


※ start()와 run()
우리는 run()메서드에 쓰레드의 기능들을 구현하지만 막상 쓰레드를 생성할 때는 start()메서드를 호출한다.
start()메서드는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택을 생성한 다음 run()을 호출해서, 생성된 호출스택에 run()이 첫번째로 저장되게 한다.
새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스택이 생성되고 쓰레드가 종료되면 작업에 사용된 호출스택은 소멸된다.

우리가 3개의 쓰레드객체를 만들어 main()메서드에서 각각의 쓰레드 객체에 start()메서드를 호출하면 다음 그림과 같아진다.
물론 main() 메서드가 있는 메인쓰레드에는 start()스택이 쌓였다가 새로운 호출스택을 만들고 run()메서드를 생성된 스택에 저장되게 한다음 사라지는 행위를 쓰레드의 개수인 3번을 반복했을 것이다.

그리고 새로 생성된 쓰레드의 스택 모두 쓰레드가 종료되면 소멸될 것이다.


[참고] start()가 호출된 쓰레드는 바로 실행되는 것이 아니다. 스케줄러가 정한 순서에 따라 실행되고 자신의 순서가 되면 지정된 시간동안 작업을 한다. 그리고 실행 중인 쓰레드가 하나도 없을 때 프로그램은 종료된다.(메인 메서드도 쓰레드임을 알아두자)


※ 싱글쓰레드와 멀티쓰레드 비교
두가지 작업을 싱글쓰레드로 처리할 경우 한가지 작업을 완료한 다음 다음 작업을 처리한다.
하지만 멀티 쓰레드로 처리할 경우 첫번째 작업을 하다가 중간에 두번째 작업을 하다가 중간에 다시 첫번째 작업으로 돌아가는 식으로 번갈아 가면서 처리한다.
즉 멀티쓰레드는 동시에 여러개를 처리하는게 아니라 조금씩 번갈아가며 여러개를 처리하는 것이다.
그렇기 때문에 CPU만을 사용하는 계산작업에서는 싱글쓰레드가 더 효율적이고 빠르다. 
왜냐하면 멀티쓰레드는 번갈아 작업할 때 마다 다음 작업에 대한 정보를 저장하고 읽어오는 시간이 걸리기 때문이다.

하지만 CPU이외의 자원을 사용하는 작업의 경우는 멀티쓰레드가 더 효율적이다.
예를들어 데이터를 입력받거나, 네트워크 파일을 주고받는 작업, 프린터로 파일을 출력하는 작업과 같이 외부기기와의 입출력을 필요로 하는 경우 싱글 쓰레드로 프로그래밍을 하면 사용자가 데이터를 입력하지 않으면 다음 작업으로 넘어갈 수 없다.
그리고 네트워크 파일을 주고받을때도 파일전송이 왼료되지 않으면 사용자는 아무런 작업을 할 수 없고 프린터 출력이 완벽히 끝날때 까지 사용자는 아무것도 할 수 없기 때문이다.


※ 쓰레드의 우선순위
쓰레드는 우선순위(priority)라는 속성(멤버변수)를 갖고 있다. 이 우선순위의 값에 따라 쓰레드가 얻는 시간이 달라진다. 우선순위가 높을수록 쓰레드가 얻는 시간이 많아진다. 예를들어 채팅하면서 파일을 전송할 때 채팅기능의 쓰레드가 우선순위가 높아야 파일전송시간은 조금 길어저도 채팅기능을 사용하는데 불편함이 없을 것 이다.

 - 우선순위 관련 멤버
void setPriority(int priority); //해당 쓰레드의 우선순위 설정
ex) t1.setPriority(1);

int getPriority();  //해당 쓰레드의 우선순위를 얻음
ex) System.out.println(t1.getPriority());

public static final int MAX_PRIORITY = 10; //최대우선순위
public static final int MIN_PRIORITY = 1;  //최소우선순위
public static final int NORM_PRIORITY = 5; //보통우선순위
ex)System.out.println(t1.MAX_PRIORITY);
   System.out.println(t1.MIN_PRIORITY);
   System.out.println(t1.NORM_PRIORITY);
   System.out.println(Thread.MAX_PRIORITY);
   System.out.println(Thread.MIN_PRIORITY);
   System.out.println(Thread.NORM_PRIORITY);


※ 쓰레드 그룹 
서로 관련된 쓰레드를 그룹으로 다루기 위한 기능으로 쓰레드그룹을 생성해 쓰레드를 그룹으로 묶어 관리할 수 있다. 보안상의 이유로 도입된 개념으로, 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경시킬 수 있지만 다른 쓰레드 그룹의 쓰레드를 변경 할 수 없다.



※ 쓰레드그룹 생성자

Thread(ThreadGroup group , String name)
Thread(ThreadGroup group , Runable target)
Thread(ThreadGroup group , Runable target, String name)
Thread(ThreadGroup group , Runable target, String name, long stackSize)

모든 쓰레드는 그룹에 속해있어야 한다. 그래서 쓰레드 그룹을 지정하는 생성자를 사용하지 않은 쓰레드는 기본적으로 자신을 생성한 쓰레드와 같은 쓰레드 그룹에 속하게 된다. (만약 main메서드 즉 main쓰레드에서 쓰레드를 생성을 하고 쓰레드그룹을 생성하지 않으면 메인 쓰레드 그룹에서 속하게 된다.) 
자바 어플리케이션이 실행되면, JVM은 main과 system이라는 쓰레드 그룹을 만들고 JVM운영에 필요한 쓰레드들을 생성해서 이 쓰레드 그룹에 포함시킨다. main메서드는 main 쓰레드 그룹에 속하게 되고 가비지컬렉션을 수행하는 Finalizer쓰레드는 system쓰레드 그룹에 속하게 된다.


※ 쓰레드그룹 생성자 및 메서드 사용 예제

public static void main(String[] args) {
            
    //메인쓰레드의 쓰레드그룹을 구함
    ThreadGroup main = Thread.currentThread().getThreadGroup();
            
    //쓰레드그룹 생성
    ThreadGroup tg1 = new ThreadGroup("Group1");
    ThreadGroup tg2 = new ThreadGroup("Group2");
           
    //쓰레드그룹1에 최고 우선순위 지정
    tg1.setMaxPriority(3);
            
    //쓰레드그룹을 생성한 후 쓰레드그룹1에 Sub그룹으로 넣음
    ThreadGroup subTg1 = new ThreadGroup(tg1, "SubGroup1");
            
    //다양한 방법으로 쓰레드 생성
    Thread t1 = new MyThread1(tg1, "Thread1");
            
    Runnable r = new MyThread2();
    Thread t2 = new Thread(subTg1, r, "Thread2...SubThreadGroup1 Thread");
            
    Thread t3 = new Thread(tg2, "Thread3");
            
    //쓰레드 실행
    t1.start();
    t2.start();
    t3.start();
            
    //메인쓰레드 그룹의 이름을 출력
    System.out.println("List of ThreadGroup : " + main.getName());
    //현재 활성화중인 쓰레드그룹의 수를 출력
    System.out.println("Active ThreadGroup : " + main.activeGroupCount());
    //현재 활성화중인 쓰레드의 수를 출력
    System.out.println("Active Thread : " + main.activeCount());
            
    //메인쓰레드그룹에 대한 정보와 하위쓰레드 그룹에 대한 정보를  출력
    main.list();
            
}

public class MyThread1 extends Thread{
      
      public MyThread1(ThreadGroup tg , String name) {
            super();
      }
      
      public void run(){
          /*아무작업도 안함*/
      }
}

public class MyThread2 implements Runnable{
      @Override
      public void run() {
            while(true){ /*무한정 쓰레드가 실행되도록 하기 위해 작성*/}
      }
}


결과 
List of ThreadGroup : main
Active ThreadGroup : 3
Active Thread : 2
java.lang.ThreadGroup[name=main,maxpri=10]
    Thread[main,5,main]
    java.lang.ThreadGroup[name=Group1,maxpri=3]
        java.lang.ThreadGroup[name=SubGroup1,maxpri=3]
            Thread[Thread2...SubThreadGroup1 Thread,3,SubGroup1]
    java.lang.ThreadGroup[name=Group2,maxpri=10]


결과를 보면 Active ThreadGroup에는 tg1, tg2, subTg1 총 3개가 활성화가 되어 있기 때문에 3이라고 출력 되었고 Active Thread 부분에는 main쓰레드와 MyThread2로 생성한 t2, 총 2개의 쓰레드가 활성화 상태이기 때문에 2가 출력된다.
그리고 마지막으로 main쓰레드그룹에 속한 쓰레드들의 정보를 출력하면서 끝난다. 하위 그룹은 들여쓰기가 되어 출력되고 있다.


※ 데몬 쓰레드란?
데몬 쓰레드란 다른 데몬쓰레드가 아닌 일반 쓰레드의 작업을 돕는 보조적인 역할을 수행하는 쓰레드이다.
일반 쓰레드가 모두 종료되면 데몬 쓰레드도 종료된다. 데몬쓰레드의 예로는 가비지 컬렉터, 워드프로세서의 자동저장, 화면자동갱신 등이 있다.


※ 데몬 쓰레드의 원리 
데몬 쓰레드는 무한루프와 조건문을 이용해서 실행 후 대기하고 있다가 특정 조건이 만족되면 작업을 수행하고 다시 대기하도록 작성한다. 데몬 쓰레드는 일반 쓰레드와 작성방법과 실행방법이 같다. 다만 쓰레드를 생성한 다음 실행전에 setDaemon(true)를 호출하기만 하면 된다. 그리고 데몬 쓰레드가 생성한 쓰레드는 자동으로 데몬 쓰레드가 된다.

boolean isDaemon() - 쓰레드가 데몬 쓰레드인지 확인, 데몬 쓰레드이면 true 반환
void setDaemon(boolean on) - 쓰레드를 데몬 쓰레드로 또는 사용자 쓰레드로 변경 
                             (매개변수 on이 true이면 데몬 쓰레드)


※데몬 쓰레드 작성 예제
  
public class MyDaemonThread extends Thread{
      @Override
      public void run() {
            
            System.out.println("자동 저장 기능을 실행합니다.");
            
            while(true){
                  try {
                        Thread.sleep(3*1000); //쓰레드 3초 중지
                  } catch (InterruptedException e) {}
                  
                  if(Main.bAutoSave){
                        autoSave();
                  }
                  
            }
      }
      
      public void autoSave(){
            System.out.println("파일을 저장합니다.");
      }
}

public class Main {
      
      public static boolean bAutoSave = false;
      public static void main(String[] args) {
            
            Thread dTh1 = new MyDaemonThread();
            dTh1.setDaemon(true);
            dTh1.start();
            
            for(int i = 1 ; i <= 20 ; i++){
                  
                  try {
                        Thread.sleep(1*1000);
                  } catch (InterruptedException e) {}
                  
                  System.out.println(i);
                  
                  if(i==5){
                        bAutoSave = true;
                  }
                  
            }
            
            System.out.println("시스템을 종료합니다.");
            
      }
}


결과

자동 저장 기능을 실행합니다.
1
2
3
4
5
파일을 저장합니다.
6
7
8
파일을 저장합니다.
9
10
11
파일을 저장합니다.
12
13
14
파일을 저장합니다.
15
16
17
파일을 저장합니다.
18
19
20
시스템을 종료합니다.

위의 예제는 파일 자동저장 예제이다. MyDaemonThread를 생성하여 start()하면 이 데몬 쓰레드는 뒤에서 무한루프를 돌며 bAutoSave
값이 ture일 때 3초마다 자동으로 저장이 되도록 한다. 그러다가 메인쓰레드가 종료되면 메인쓰레드를 보조하는 데몬 쓰레드도 종료된다. 만약 dTh1.setDaemon(true)를 선언하지 않으면 메인 쓰레드가 종료되도 데몬 쓰레드는 종료되지 않아 계속 파일을 저장한다. 그렇기 때문에 dTh1.setDaemon(true)를 선언해 줘야한다. 또한 dTh1.setDaemon(true)는 start()를 호출하기 전에 실행되어야 한다. 그렇지 않으면 예외가 발생한다.


※ 쓰레드의 실행제어
효율적인 멀티쓰레드 프로그래밍을 하려면 정교한 스케줄링을 통해 프로세스에게 주어진 자원과 시간을 여러 쓰레드가 낭비없이 잘 사용하도록 프로그래밍 해야한다. 그러기 위해선 쓰레드의 상태와 관련 메서드 및 상태를 잘 알아야 한다.



※ 쓰레드의 생성부터 소멸까지의 과정

1.쓰레드를 생성하고 start()를 호출하면 바로 실행이 되는것이 아니라 실행대기열에 저장이된다.
  그리고 차례를 기다린다. 실행대기열은 큐(Queue)와 같은 구조로 먼저 실행대기열에 들어온 쓰레드가 먼저 실행된다.

2.실행대기열에 있다 자신의 차례가 오면 실행된다.

3.주어진 실행시간이 다되거나 yield()를 만나면 다시 실행대기 상태가 되어 다음차례의 쓰레드가 실행된다.

4.실행 중에 suspend(), wait(), join(), I/O block에 의해 일시정지상태가 될 수 있다. 
  I/O block은 입출력작업에서 발생하는 지연상태를 말한다. 사용자의 입력을 기다리는 경우를 예로 들 수 있다
  이런 경우 일시정지 상태에 있다가 사용자가 입력을 마치면 다시 실행대기상태가 된다.

5.일시정지시간이 다되거나(time-out), notify(), resume(), interrupt()가 호출되면 일시정지 상태를 벗어나 
  다시 실행대기열에 저장되어 자신의 차례를 기다린다.

6.실행을 모두 마치거나 stop()이 호출되면 쓰레드는 소멸된다.


※ 쓰레드를 효율적으로 사용하는 예시

  1. 쓸대없는 기능이 쓰레드를 통해 동작중이라면 다른 쓰레드에게 yield(양보)를 하자.

public void run(){

    ...

    while(stopped){
        if(suspended){
            ...
            try{
                Thread.sleep(1000);
            }catch(InterruptedException e){}
        }else{
            Thread.yield();
        }    
    }

}

 - 코드설명 
만약 이 코드에 빨간색으로 표시된 else{}문이 없다면 if의 조건이 만족하지 않으면 쓰레드가 할당받은 시간만큼 의미없이 while문을 돌고있을 것이다. 그럴때는 else문을 하나 만들어줘 Thread.yield()를 통해 다음 실행대기 상태열에 있는 쓰레드에게 순서를 양보해주자.


  1. sleep(), join(), wait()에 의한 일시정지 상태를 interrupt()로 잠자고 있는 쓰레드를 깨우자.

public class MyThread1 extends Thread{
      
      public void run(){
            while(true){
                  try {
                        System.out.println("쓰레드의 동작을 잠시 중단시킵니다.");
                        Thread.sleep(10*1000);
                  } catch (InterruptedException e) {
                        System.out.println("InterruptedException이 발생해 "
                                    + "sleep, join, wait에 의한 일시정지된 쓰레드를 "
                                    + "실행 대기상태로 만듭니다.");
                  }
            }
      }
}


public class Main {
      
      public static void main(String[] args) {
            
            Thread th1 = new MyThread1();
            th1.start();
            th1.interrupt();
            
      }
}   


결과 
쓰레드의 동작을 잠시 중단시킵니다.

<-- interrupt()를 호출하여 InterruptedException 발생 -->
InterruptedException이 발생해 sleep, join, wait에 의한 일시정지된 쓰레드를 실행 대기상태로 만듭니다.

<-- 일시정지중이던 쓰레드가 깨어나 바로 실행된다 -->
쓰레드의 동작을 잠시 중단시킵니다.

<--이 아래부터는 sleep때문에 10초 간격으로 실행된다. -->
쓰레드의 동작을 잠시 중단시킵니다.
쓰레드의 동작을 잠시 중단시킵니다.
...

- 코드설명 
위의 예시는 interrupt()를 호출하면 InterruptException이 발생하여 catch의문장을 실행되고 바로 쓰레드가 다시 실행되는 것을 보여준다. 만약 쓰레드의 일시정지를 하였을 때 바로 깨워야 할 상황이 발생한다면 interrupt()를 통해 바로 깨워주어야 하며 catch내의 문장이 실행되는걸 이용하여 쓰레드의 응답성을 높힐 수 있다.


  1. 필요하다면 join을 이용해 쓰레드가 작업할 시간을 어느 정도 주자.

if(gc.freeMemory() < requiredMemory){
    gc.interrupt();
    try{
        gc.join(100);
    }catch(InterruptException e){}
}

- 코드설명
위의 예시는 가상으로 가비지컬렉터를 만들어 여유공간(freeMemory())가 부족하면 가비지컬렉터를 interrupt()를 이용해 일시정지 상태에서 깨어나 작동하도록 하는 코드다. 하지만 interrupt()를 호출한다고 바로 실행되는 것이 아니라 일시정지 상태에서 대기열로 옮겨져 자신의 순서를 기다리는 것이므로 가비지 컬렉터보다 다른 작업이 먼저 수행되어 메모리가 부족할 수 도 있다.
이러한 문제를 해결하기 위해 join()을 이용해 가비지 컬렉터가 작업할 시간을 어느정도 주는것이 좋다.
이처럼 쓰레드를 깨우고 즉시 작업을 하게 해야 하는 상황이라면 join()을 함께 사용하여 만들어주자.


※ 쓰레드 동기화
멀티 쓰레드의 경우 같은 프로세스 내에 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 된다. 그렇기 때문에 해당 작업에 관련된 공유데이터에 lock을 걸어 먼저 작업 중이던 쓰레드가 작업을 완전히 마칠 때까지는 다른 쓰레드에게 제어권이 넘어가더라도 데이터가 변경되지 않도록 보호해야 한다.
동기화(synchronized)는 아래 두가지 방법으로 사용할 수 있다.

1.특정한 객체에 lock을 걸고자 할 때
    synchronized(객체의 참조변수){
        ...
    }

2.메서드에 lock을 걸고자 할 때
    public synchronized void calcSum(){
        ...
    }

synchronized로 지정된 객체, 메서드는 lock이 걸렸다가 작업이 끝나면 lock이 풀린다. 이 작업을 수행하는 도중에는 객체, 메서드에 lock이 걸려 다른 쓰레드가 이 객체에 접근할 수 없다.
단 synchronized를 이용하여 lock을 걸 때에는 교착상태(dead-lock)에 빠지지 않도록 조심해야한다. 또한 작업중 변경되었던 공유데이터를 작업 이전의 상태로 돌려놓는 것까지 고려해야한다. 


※ wait() 과 notify()
만약 한 쓰레드가 객체에 lock을 걸어 놓고 어떤 조건이 만족될 때까지 기다려야하는 경우, 이 쓰레드를 그대로 놔두면 이 객체를 사용하려는 다른 쓰레드들은 lock이 풀릴때까지 기다려야 한다.
이러한 비효율을 개선하기 위해 wait()과 notify()를 사용한다. 한 쓰레드가 객체에 lock을 걸고 오래 기다리는 대신 wait()을 호출하여 다른 쓰레드에게 제어권을 넘겨주고 대기상태로 기다리다가 다른 쓰레드에 의해서 notify()가 호출되면 다시 실행상태가 되도록 하는 것.
wait()과 notify()는 Thread클래스가 아닌 Object클래스에 정의된 메서드이므로 모든 객체에서 호출이 가능하다.
그리고 동기화 블럭에서만 사용이 가능하다. wait()이 호출되면 lock을 모두 풀고 객체의 대기실인 waiting pool에 들어가게 된다. 그러다 notify()가 호출되면 waiting pool에서 벗어나 실행대기 열에서 자신이 실행될 차례를 기다린다.
notify()가 호출되면 객체의 waiting pool에 있는 쓰레드 중의 하나만 깨우고 notifyAll()을 호출하면 모든 쓰레드를 깨운다. 어짜피 한 번에 하나의 쓰레드만 객체를 사용할 수 있기 때문에 notify()를 사용하나 notifyAll()을 사용하나 별반 차이가 없지만 notify()는 어떤 쓰레드가 깨워질지 알 수 없어서 notifyAll()을 호출해주는게 좋다.