군만두의 IT 공부 일지

[스터디4] 07. 멀티 스레드 본문

프로그래밍/Java

[스터디4] 07. 멀티 스레드

mandus 2025. 3. 11. 09:01

14장. 멀티 스레드

14.6 스레드 동기화

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

위 사진에서 UserThread에 저장된 데이터가 날아간다.

스레드가 사용 중인 객체를 다른 스레드가 변경할 수 없도록 스레드 작업이 끝날 때까지 객체에 잠금을 건다.

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

동기화 메소드 및 블록 선언

동기화 메소드를 선언하려면 synchronized 키워드를 인스턴스나 정적 메소드에 붙인다. 스레드가 동기화 메소드를 실행하는 즉시 객체는 잠금이 일어나고, 메소드 실행이 끝나면 잠금이 풀린다.

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

메소드 전체가 아닌 일부 영역을 실행할 때만 객체 잠금을 걸고 싶다면 동기화 블록을 만든다.

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

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

  • 두 개의 스레드를 교대로 번갈아 가며 실행할 때도 있다.
  • 정확한 교대 작업이 필요한 경우, 자신의 작업이 끝나면 상대방 스레드를 일시 정지 상태에서 풀어주고 자신은 일시 정지 상태로 만든다.
  • 공유 객체는 두 스레드가 작업할 내용을 각각 동기화 메소드로 정해 놓는다.
    • notify() 메소드: wait()에 의해 일시 정지된 스레드 중 한 개를 실행 대기 상태로 만든다.
    • notifyAll() 메소드: wait()에 의해 일시 정지된 모든 스레드를 실행 대기 상태로 만든다.
    • wait() 메소드: 일시 정지 상태로 만든다.

14.7 스레드 안전 종료

  • 스레드는 자신의 run() 메소드가 모두 실행되면 자동적으로 종료되지만, 경우에 따라 실행 중인 스레드를 즉시 종료할 필요가 있다.
  • 예) 동영상을 끝까지 보지 않고 사용자가 멈춤을 요구하는 경우
  • Thread는 stop() 메소드를 제공했으나, 스레드를 갑자기 종료하게 되면 사용 중이던 리소스(파일, 네트워크 연결 등)들이 불안정한 상태로 남겨지기 때문에 deprecated(더 이상 사용하지 않음)되었다.
  • 스레드를 안전하게 종료하는 방법: 사용하던 리소스들을 정리하고 run() 메소드를 빨리 종료하는 것
    • 조건 이용 방법
    • interrupt() 메소드 이용 방법

조건 이용

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

public class XXXThread extends Thread {
    private boolean stop;                        // stop이 필드 선언

    public void run() {
        while (!stop) {                          // stop가 true가 되면 while 문을 빠져나감
            // 스레드가 반복 실행하는 코드;
        }
        // 스레드가 사용한 리소스 정리             // 리소스 정리
    }                                            // 스레드 종료
}

interrupt 메소드 이용

  • interrupt() 메소드: 스레드가 일시 정지 상태에 있을 때 InterruptedException 예외를 발생시킨다.
  • 스레드가 실행 대기/실행 상태일 때는 interrupt() 메소드가 호출되어도 InterruptedException이 발생하지 않는다.

InterruptedException이 발생하여 예외 처리 블록으로 이동하면, 결국 while 문을 빠져나와 자원을 정리하고 스레드가 종료되는 효과를 가져온다.

  • interrupt() 메소드 호출 여부를 알 수 있는 방법
    • Thread.sleep(1)과 같이 스레드가 어떤 이유로 일시 정지 상태가 되면, InterruptedException 예외가 발생한다.
    • Thread의 정적 메소드인 interrupted()와 인스턴스 메소드인 isInterrupted() 메소드는 interrupt() 메소드 호출 여부를 리턴한다.
boolean status = Thread.interrupted();
boolean status = objThread.isInterrupted();

14.8 데몬 스레드

  • 데몬(daemon) 스레드: 주 스레드의 작업을 돕는 보조적인 역할을 수행하는 스레드
  • 주 스레드가 종료되면 데몬 스레드도 자동으로 종료된다.
  • 예) 워드프로세서의 자동 저장, 미디어플레이어의 동영상 및 음악 재생, 가비지 컬렉터 등에서 주 스레드(워드프로세서, 미디어플레이어, JVM)가 종료되면 데몬 스레드도 종료된다.
  • 스레드를 데몬으로 만들기 위해서 주 스레드가 데몬이 될 스레드의 setDaemon(true)를 호출하면 된다.
public static void main(String[] args) {
    AutoSaveThread thread = new AutoSaveThread();
    thread.setDaemon(true);
    thread.start();
    ...
}

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

14.9 스레드풀

병렬 작업 처리가 많아지면 스레드의 개수가 폭증하여 CPU가 바빠지고 메모리 사용량이 늘어나면서 애플리케이션의 성능이 저하되는데, 이것을 막기 위해 스레드풀을 사용한다.

  • 스레드풀(ThreadPool): 작업 처리에 사용되는 스레드를 제한된 개수만큼 정해 놓고 작업 큐(Queue)에 들어오는 작업들을 스레드가 하나씩 맡아 처리하는 방식
  • 작업 처리가 끝난 스레드는 다시 작업 큐에서 새로운 작업을 가져와 처리한다.

스레드풀 생성

  • 자바는 스레드풀을 생성하고 사용할 수 있도록 java.util.concurrent 패키지에서 ExecutorService 인터페이스와 Executors 클래스를 제공한다.
  • Executors의 두 정적 메소드를 이용하면 스레드풀인 ExecutorService 구현 객체를 만들 수 있다.
    • 초기 수: 스레드풀이 생성될 때 기본적으로 생성되는 스레드 수
    • 코어 수: 스레드가 증가된 후 사용되지 않는 스레드를 제거할 때 최소한 풀에서 유지하는 스레드 수
    • 최대 수: 증가되는 스레드의 한도 수
메소드명(매개변수) 초기 수 코어 수 최대 수
newCachedThreadPool() 0 0 Integer.MAX_VALUE
newFixedThreadPool(int nThreads) 0 생성된 수 nThreads
ExecutorService executorService = Executors.newCachedThreadPool();
  • 예1)
    • 초기 수 0개 / 코어 수 0개
    • 작업 개수가 많아지면 새 스레드를 생성시켜 작업을 처리한다.
    • 60초 동안 스레드가 아무 작업을 하지 않으면 스레드를 풀에서 제거한다.
ExecutorService executorService = Executors.newFixedThreadPool(5);
  • 예2)
    • 초기 수 0개 / 최대 수 5개
    • 생성된 스레드를 제거하지 않는다는 특징이 있다.
ExecutorService threadPool = new ThreadPoolExecutor(
    3,                     // 코어 스레드 개수
    100,                   // 최대 스레드 개수
    120L,                  // 놀고 있는 시간
    TimeUnit.SECONDS,      // 놀고 있는 시간 단위
    new SynchronousQueue<Runnable>() // 작업 큐
);
  • 예3)
    • ThreadPoolExecutor로 직접 스레드풀을 생성할 수도 있다.
    • 코드에서 초기 수 0개 / 코어 수 3개  최대 수 100개인 스레드풀을 생성한다.
    • 추가된 스레드가 120초 동안 놀고 있을 경우 해당 스레드를 풀에서 제거한다.

스레드풀 종료

  • 스레드풀의 스레드는 기본적으로 데몬 스레드가 아니기 때문에 main 스레드가 종료되더라도 작업을 처리하기 위해 계속 실행 상태로 남아 있다.
  • 스레드풀의 모든 스레드를 종료하려면 ExecutorService의 두 메소드 중 하나를 실행해야 한다.
리턴 타입 메소드명(매개변수) 설명
void shutdown() 현재 처리 중인 작업뿐만 아니라 작업 큐에 대기하고 있는 모든 작업을 처리한 뒤에 스레드풀을 종료시킨다.
List<Runnable> shutdownNow() 현재 작업 처리 중인 스레드를 interrupt해서 작업을 중지시키고 스레드풀을 종료시킨다. 리턴값은 작업 큐에 있는 미처리된 작업(Runnable)의 목록이다.

작업 생성과 처리 요청

  • 하나의 작업은 Runnable 또는 Callable 구현 객체로 표현한다.
  • Runnable과 Callable의 차이점: 작업 처리 완료 후 리턴값이 있느냐 없느냐
// Runnable 익명 구현 객체
new Runnable() {
    @Override
    public void run() {
        // 스레드가 처리할 작업 내용
    }
}
// Callable 익명 구현 객체
new Callable() {
    @Override
    public T call() throws Exception {
        // 스레드가 처리할 작업 내용
        return T;
    }
}

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

  • 작업 처리 요청: ExecutorService의 작업 큐에 Runnable 또는 Callable 객체를 넣는 행위
  • 작업 처리 요청을 위해 ExecutorService는 두 가지 메소드를 제공한다.
리턴 타입 메소드명(매개변수) 설명
void execute(Runnable command) - Runnable을 작업 큐에 저장
- 작업 처리 결과를 리턴하지 않음
Future<T> submit(Callable<T> task) - Callable을 작업 큐에 저장
- 작업 처리 결과를 얻을 수 있도록 Future를 리턴
  • Runnable 또는 Callable 객체가 ExevutorService의 작업 큐에 들어가면 ExevutorService는 처리할 스레드가 있는지 보고, 없다면 스레드를 새로 생성시킨다.
  • 스레드는 작업 큐에서 Runnable 또는 Callable 객체를 꺼내와 run() 또는 call() 메소드를 실행하면서 작업을 처리한다.

 

이 글은 『이것이 자바다』 책을 학습한 내용을 정리한 것입니다.
Comments