본문 바로가기
언어/JAVA

[멀티 스레드2]

by 코딩맛집 2023. 2. 27.

다른 스레드에게 실행 양보

 

 스레드가 처리하는 작업은 반복적인 실행을 위해 for문이나 while문을 포함하는 경우가 많다. 다음 코드에서  work의 값이 false라면 while문은 어떠한 실행문도 실행하지 않고 무의미한 반복을 한다. 이때는 다른 스레드에게 실행을 양보하고 자신은 실행 대기 상태로 가는 것이 프로그램 성능에 도움이 된다. 

public void run() {
    while(true){
        if(work){
            System.out.println("ThreadA 작업 내용");
        }
    }
}

 

  • yield()

yield()를 호출한 스레드는 실행 대기 상태로 돌아가고, 다른 스레드가 실행 상태가 된다.

public void run() {
    while(true){
        if(work){
            System.out.println("ThreadA 작업 내용");
        }else{
        	Thread.yield()
           }
    }
}

 

14.6 스레드 동기화

멀티 스레드는 하나의 객체를 공유해서 작업할 수도 있다. 이 경우, 다른 스레드에 의해 객체 내부 데이터가 쉽게 변경될 수 있기 때문에 의도했던 것과는 다른 결과가 나올 수 있다. 스레드가 사용 중인 객체를 다른 스레드가 변경할 수 없도록 하려면 스레드 작업이 끝날 때까지 객체에 잠금을 걸면 된다. 이를 위해 자바는 동기화 메소드와 블록을 제공한다.

객체 내부에 동기화 메소드와 동기화 블록이 여러 개가 있다면 스레드가 이들 중 하나를 실행할 때 다른 스레드는 동기화 메소드 및 블록을 실행할 수 없다. 하지만 일반 메소드는 실행이 가능하다.

 

동기화 메소드 및 블록 선언

동기화 메소드를 선언하는 방법은 다음과 같이 synchronized 키워드를 붙이면 된다. synchronized 키워드는 인스턴스와 정적 메소드 어디든 붙일 수 있다.

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

스레드가 동기화 메소드를 실행하는 즉시 객체는 잠금이 일어나고, 메소드 실행이 끝나면 잠금이 풀린다. 메소드 전체가 아닌 일부 영역을 실행할 때만 객체 잠금을 걸고 싶다면 다음과 같이 동기화 블록을 만들면 된다.

public void method(){
    //여러 스레드가 실행할 수 있는 영역
    
    synchronized(공유객체){
    //단 하나의 스레드만 실행하는 영역
    }
    //여러 스레드가 실행할 수 있는 영역    
}

 

wait()과 notify()를 이용한 스레드 제어

경우에 따라서는 두 개의 스레드를 교대로 번갈아 가며 실행할 때도 있다. 정확한 교대 작업이 필요할 경우, 자신의 작업이 끝나면 상대방 스레드를 일시 정지 상태에서 풀어주고 자신은 일시 정지 상태로 만들면 된다. 이 방법의 핵심은 공유 객체에 있다. 공유 객체는 두 스레드가 작업할 내용을 각각 동기화 메소드로 정해 놓는다. 한 스레드가 작업을 완료하면 notify() 메소드를 호출해서 일시 정지 상태에 있는 다른 스레드를 실행 대기 상태로 만들고, 자신은 두 번 작업을 하지 않도록 wait() 메소드를 호출하여 일시 정지 상태로 만든다.

 

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

※주의할 점

두 메소드는 동기화 메소드 또는 동기화 블록 내에서만 사용할 수 있다.

 

14.7 스레드 안전 종료

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

스레드를 강제 종료시키기 위해 Thread는 stop() 메소드를 제공하고 있으나 이 메소드는 deprecated (더 이상 사용하지 않음)되었다. 그 이유는 스레드를 갑자기 종료하게 되면 사용 중이던 리소스들이 불안전한 상태로 남겨지기 때문이다. 여기에서 리소스란 파일, 네트워크 연결 등을 말한다.

스레드를 안전하게 종료하는 방법은 사용하던 리소스들을 정리하고 run()  메소드를 빨리 종료하는 것이다. 주로 조건 이용 방법과 interrup() 메소드 이용 방법을 사용한다.

 

조건 이용

스레드가 while 문으로 반복 실행할 경우, 조건을 이용해서 run() 메소드의 종료를 유도할 수 있다. 다음 코드는 stop 필드 조건에 따라서 run() 메소드의 종료를 유도한다.

public class XXXThread extends Thread{
    private boolean stop;
    
    public void run(){
        while(!stop){
            //스레드가 반복 실행할 코드;
        }
        //스레드가 사용한 리소스 정리
    }//스레드 종료
}

 

interrupt() 메소드 이용

interrupt() 메소드는 스레드가 일시 정지 상태에 있을 때 InterruptedException 예외를 발생시키는 역할을 한다. 이것을 이용하면 예외 처리를 통해 run() 메소드를 정상 종료시킬 수 있다.

XThread를 생성해서 start() 메소드를 실행한 후에 XThread의 interrupt()메소드를 실행하면 XThread가 일시 정지 상태가 될 때 InterruptedException이 발생하여 예외 처리 블록으로 이동한다. 이것은 결국 while문을 빠져나와 자원을 정리하고 스레드가 종료되는 효과를 가져온다.

 

스레드가 실행 대기/실행 상태일 때에는 interrupt()메소드가 호출되어도 InterruptedException이 발생하지 않는다. 그러나 스레드가 어떤 이유로 일시 정지 상태가 되면, InterruptedException 예외가 발생한다. 그래서 짧은 시간이나마 일시 정지를 위해 Thread.sleep(1)을 사용한 것이다.

일시 정지를 만들지 않고도 interrupt() 메소드 호출 여부를 알 수 있는 방법이 있다. Thread의 Interrupted()와 isInterrupted() 메소드는 interrupt() 메소드 호출 여부를 리턴한다. Interrupted()는 정적 메소드이고, isInterrupted()는 인스턴스 메소드이다.

boolean status = Thread.interrupted();
boolean status = objThread.isInterrupted();

 

14.8 데몬 스레드

데몬 스레드란?

주 스레드의 작업을 돕는 보조적인 역할을 수행하는 스레드이다. 주 스레드가 종료되면 데몬 스레드도 따라서 자동으로 종료된다.

데몬 스레드를 적용한 예로는 워드프로세서의 자동 저장, 미디어플레이어의 도영상 및 음악 재생, 가비지 컬렉터 등이 있는데, 여기에서 주 스레드(워드프로세스, 미디어플레이어, JVM)가 종료되면 데몬 스레드도 같이 종료된다.

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

public static void main(String[] args){
    AutoSaveThread thread = new AutoSaveThread;
    thread.setDaemon(true);
    thread.start();
    ...
}

메인 스레드는 주 스레드, AutoSaveThread는 데몬 스레드가 된다.

 

14.9 스레드풀

병렬 작업 처리가 많아지면 스레드의 개수가 폭증하여 CPU가 바빠지고 메모리 사용량이 늘어난다. 이에 따라 애플리케이션의 성능 또한 급격히 저하된다. 이렇게 병렬 작업 증가로 인한 스레드의 폭증을 막으려면 스레드풀을 사용하는 것이 좋다.

스레드풀은 작업 처리에 사용되는 스레드를 제한된 개수만큼 정해 놓고 작업 큐에 들어오는 작업들을 스레드가 하나씩 맡아 처리하는 방식이다. 작업 처리가 끝난 스레드는 다시 작업 큐에서 새로운 작업을 가져와 처리한다. 이렇게 하면 작업량이 증가해도 스레드의 개수가 늘어나지 않아 애플리케이션의 성능이 급격히 저하되지 않는다.

 

스레드풀 생성

자바는 스레드풀을 생성하고 사용할 수 있도록 java.util.concurrent 패키지에서 ExecutorService 인터페이스와 Executors 클래스를 제공하고 있다. Executors의 다음 두 정적 메소드를 이용하면 간단하게 스레드풀인 ExecutorService 구현 객체를 만들 수 있다.

메소드명(매개변수) 초기 수 코어 수 최대 수
newCachedThreadPool() 0 0 Integer.MAX_VALUE
newFixedThreadPool(int nThreads) 0 생성된 수 nThreads  

초기 수 : 스레드풀이 생성될 때 기본적으로 생성되는 스레드 수

코어 수 : 스레드가 증가된 후 사용되지 않는 스레드를 제거할 때 최소한 풀에서 유지하는 스레드 수

최대 수 : 증가되는 스레드의 한도 수

 

newCachedThreadPool() 메소드로 생성된 스레드풀의 작업 개수가 많아지면 새 스레드를 생성시켜 작업을 처리한다. 60초 동안 스레드가 아무 작업을 하지 않으면 스레드를 풀에서 제거한다.

ExecutorService executorService = Executors.newCachedThreadPool();

newFixedThreadPool()로 생성된 스레드풀의 작업 개수가 많아지면 최대 5개까지 스레드를 생성시켜 작업을 처리한다. 이 스레드풀의 특징은 생성된 스레드를 제거하지 않는다는 것이다.

ExecutorService executorService = Executors.newFixedThreadPool(5);

위 두 메소드를 사용하지 않고 직접 ThreadPoolExecutor로 스레드풀을 생성할 수도 있다. 다음 예시는 초기 수 0개, 코어 수 3개, 최대 수 100개인 스레드풀을 생성하는 코드이다. 그리고 추가된 스레드가 120초 동안 놀고 있을 경우 해당 스레드를 풀에서 제거한다.

ExecutorService threadPool = new ThreadPoolExecutor(
        3,                                //코어 스레드 개수
        100,                              //최대 스레드 개수
        120L,                             //놀고 있는 시간
        TimeUnite.SECONDS,                //놀고 있는 시간 단위
        new SynchronousQueue<Runnable>()  //작업 큐
);

 

스레드풀 종료

스레드풀의 스레드는 기본적으로 데몬 스레드가 아니기 때문에 main 스레드가 종료되더라도 작업을 처리하기 위해 계속 실행 상태로 남아 있다. 스레드풀의 모든 스레드를 종료하려면 ExecutorService의 다음 두 메소드 중 하나를 실행해야 한다.

리턴 타입 메소드명(매개변수) 설명      
void shutdown() 현재 처리 중인 작업뿐만 아니라 작업 큐에 대기하고 있는 모든 작업을 처리한 뒤에 스레드풀을 종료시킨다.
List<Runnable> shutdownNow() 현재 작업 처리 중인 스레드를 interrup해서 작업을 중지시키고 스레드풀을 종료시킨다. 리턴값은 작업 큐에 있는 미처리된 작업의 목록이다.

 

작업 생성과 처리 요청

하나의 작업은 Runnable 또는 Callable 구현 클래스로 표현한다.

  • 차이점 : 작업 처리 완료 후 리턴값이 있느냐 없느냐

다음은 Runnable과 Callable 구현 클래스 작성 방법이다.

Runnable 익명 구현 클래스 Callable 익명 구현 클래스
new Runnable() {
    @Override
    public void run(){
        //스레드가 처리할 작업 내용
    }
}
  new Callable<T> {
    @Override
    public T call() throws Exception {
        //스레드가 처리할 작업 내용
       return T;
    }
}

 

Runnable의 run() 메소드는 리턴값이 없고, Callable의 call() 메소드는 리턴값이 있다. call()의 리턴 타입은 Callable<T>에서 저장한 T 타입 파라미터와 동일한 타입이어야 한다.

작업 처리 요청이란 ExecutorService의 작업 큐에 Runnable 또는 Callable 객체를 넣는 행위를 말한다. 작업 처리 요청을 위해 ExecutorService는 다음 두 가지 메소드를 제공한다.

리턴 타입 메소드명(매개변수) 설명    
void execute(Runnable command) - Runnable을 작업 큐에 저장
- 작업 처리 결과를 리턴하지 않음
Future<T> submit(Callable<T> task) - Callable을 작업 큐에 저장
- 작업 처리 결과를 얻을 수 있도록 Future을 리턴

Runnable 또는 Callable 객체가 ExecutorService의 작업 큐에 들어가면 ExecutorService는 처리할 스레드가 있는지 보고, 없다며 ㄴ스레드를 새로 생성시킨다. 스레드는 작업 큐에서 Runnable 또는 Callable 객체를 꺼내와 run() 또는 call() 메소드를 실행하면서 작업을 처리한다.

 

'언어 > JAVA' 카테고리의 다른 글

StringTokenizer  (0) 2023.04.02
[chapter 13] 확인 문제  (0) 2023.03.02
[chapter 9] 확인 문제  (0) 2023.02.06
[중첩 인터페이스] 익명 객체(익명 자식 객체, 익명 구현 객체)  (0) 2023.02.05
[중첩 클래스]  (0) 2023.02.05