JAVA/비동기 프로그래밍

[Java] Java 쓰레드 활용

꾸준함. 2024. 2. 21. 19:11

1. UncaughtExceptionHandler

  • 자바 쓰레드의 run() 메서드는 예외를 던질 수 없기 때문에 예외가 발생할 경우 run() 메서드 내에서만 예외를 처리해야 함
    • RuntimeException이 발생하더라도 쓰레드 밖에서 예외를 잡고 처리하는 방법 없음

 

  • 자바에서는 쓰레드가 비정상적으로 종료되었거나 특정한 예외를 쓰레드 외부에서 처리할 수 있도록 UncaughtExceptionHandler 인터페이스 제공
    • 해당 인터페이스를 통해 어떤 원인으로 인해 쓰레드가 종료되었는지 대상 쓰레드와 예외를 파악 가능


public class ExceptionHandlerExample {
private static final Logger LOGGER = Logger.getLogger(ExceptionHandlerExample.class.getName());
public static void main(String[] args) {
// 모든 쓰레드의 예외에 대한 기본 핸들러 설정
Thread.setDefaultUncaughtExceptionHandler(
(t, e) -> System.out.println(String.format("%s에서 예외 발생: %s", t.getName(), e)));
// 예외를 발생시키는 여러 쓰레드
Thread thread1 = new Thread(() -> {
throw new RuntimeException("쓰레드 1 예외!");
});
Thread thread2 = new Thread(() -> {
throw new RuntimeException("쓰레드 2 예외!");
});
thread2.setUncaughtExceptionHandler((t, e) -> {
LOGGER.log(Level.SEVERE, String.format("%s에서 예외가 발생했습니다.", t.getName()), e);
sendNotificationToSlack(e);
});
thread1.start();
thread2.start();
}
private static void sendNotificationToSlack(Throwable e) {
System.out.println(String.format("Slack Notification: %s", e.getMessage()));
}
}
view raw .java hosted with ❤ by GitHub

 

코드 부연 설명

  • 정적 메서드인 setDefaultUncaughtExceptionHandler() 메서드를 통해 모든 쓰레드에서 발생하는 uncaughtException에 대해 디폴트로 처리하는 인터페이스 설정 가능
  •  setUncaughtExceptionHandler() 메서드를 통해 특정 쓰레드에서 발생하는 uncaughtException을 처리하는 인터페이스 설정 가능
  • setUncaughtExceptionHandler() 메서드가 setDefaultUncaughtExceptionHandler()보다 우선순위가 높음
    • 쓰레드 2에서 예외가 발생헀을 때 sendNotificationToSlack() 메서드가 호출된 이유

 

2. 쓰레드를 중지하는 방법

  • 자바 1 버전까지는 실행 중인 쓰레드를 임의로 중지하거나 종료할 수 있는 suspend(), stop() API 제공
    • 하지만 해당 API들을 호출할 경우 쓰레드 종료 후 처리해야 하는 후처리 작업을 수행할 수 없기 때문에 두 API 모두 자바 1.2부터 Deprecated 처리

 

 

  • suspend(), stop() API를 호출하는 대신 flag 변수 혹은 interrupt() 메서드를 활용해 쓰레드를 중지할 수 있음

 

2.1 flag 변수를 통해 쓰레드를 중지하는 방법

 

  • flag 변수의 값이 어떤 조건에 만족할 경우 쓰레드의 실행을 중지하는 방식
  • 동시성 문제를 고려하여 flag 변수를 atomic 변수 혹은 volatile 키워드를 사용하는 것을 권장

 

2.1.1 정상적으로 중지하는 코드

 

public class FlagThreadStopExample {
// volatile 키워드 추가
volatile boolean running = true;
public void flagVariableThreadStopTest() {
new Thread(() -> {
int count = 0;
while (running) { // volatile 키워드 없으면 메모리 값에 올라온 running 참고
count++;
}
System.out.println("Thread 1 종료. Count: " + count);
}).start();
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 2 종료 중..");
running = false; // volatile 키워드 없을 경우 false 값은 캐시 메모리에만 올라감
}).start();
}
public static void main(String[] args) {
new FlagThreadStopExample().flagVariableThreadStopTest();
}
}
view raw .java hosted with ❤ by GitHub

 

코드 부연 설명

  • Thread2가 flag 변수인 running 상태 값을 false로 설정하고 해당 변수는 volatile 키워드로 선언했으므로 메모리에 상태가 업데이트됨
  • Thread 1 또한 메모리 값에 올라온 running을 참고하기 때문에 while문을 탈출하며 쓰레드 종료

 

2.1.2 동시성 이슈가 발생하는 코드


public class FlagThreadStopExample {
boolean running = true;
public void flagVariableThreadStopTest() {
new Thread(() -> {
int count = 0;
while (running) { // volatile 키워드 없으면 메모리 값에 올라온 running 참고
count++;
}
System.out.println(String.format("Thread 1 종료. Count: %d", count));
}).start();
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 2 종료 중..");
running = false; // volatile 키워드 없을 경우 false 값은 캐시 메모리에만 올라감
}).start();
}
public static void main(String[] args) {
new FlagThreadStopExample().flagVariableThreadStopTest();
}
}
view raw .java hosted with ❤ by GitHub

 

코드 부연 설명

  • Thread2가 flag 변수인 running 상태 값을 false로 설정했지만 해당 값은 캐시 메모리에만 업데이트됨
  • 반면, Thread 1은 메모리 값에 올라온 running을 참고하기 때문에 while문을 탈출할 수 없어 자원이 고갈될 때까지 쓰레드 종료 안됨

 

2.2 interrupt() 메서드를 활용해 쓰레드를 중지하는 방법

 

Java 쓰레드 기본 API인 interrupt()를 복습하면 다음과 같습니다.

  • interrupt() 메서드를 통해 특정 쓰레드에게 interrupt 신호를 보낼 수 있으며 해당 쓰레드의 실행 중단, 작업 취소, 강제 종료 등을 목적으로 사용
  • 쓰레드는 인터럽트 발생 여부를 확인할 수 있는 상태 값인 interrupted를 가지고 있으며 디폴트 값은 false
  • interrupted()와 isInterrupted() 메서드 모두 interrupted 상태값을 확인하는 용도
    • interrupted()는 상태 값을 확인한 후 interrupted 상태를 false로 초기화시킴
    • isInterrupted()는 상태 값을 확인한 후 별도 처리 안 함 (상태값 변경 X)

 

  • sleep(), join() 등으로 인해 실행 중인 쓰레드가 Waiting 상태로 전환되었을 때 interrupt 신호를 받으면 InterruptedException이 발생하며 이때 interrupted 값이 false로 초기화됨

 

본론으로 돌아와 자바는 아래 코드처럼 interrupt() 메서드를 통해 쓰레드를 중지할 수 있습니다.

 

public class InterruptThreadStopExample {
public static void main(String[] args) {
Thread worker = new Thread(() -> {
try {
while (!Thread.currentThread().isInterrupted()) {
// 쓰레드의 작업을 수행합니다.
System.out.println("작업 쓰레드가 실행 중입니다.");
System.out.println(String.format("인트럽트 상태 1 : %s", Thread.currentThread().isInterrupted()));
Thread.sleep(500);
}
} catch (InterruptedException e) {
/**
* sleep 메서드가 인터럽트되면 InterruptedException을 던지며
* 이 때 interrupt 상태는 초기화 된다.
* 그렇기 때문에 다시 interrupt 를 호출해 줘야 한다.
*/
System.out.println(String.format("인트럽트 상태 2 : %s", Thread.currentThread().isInterrupted()));
Thread.currentThread().interrupt();
}
System.out.println("작업 쓰레드가 중단되었습니다.");
System.out.println(String.format("인트럽트 상태 3 : %s", Thread.currentThread().isInterrupted()));
});
Thread stopper = new Thread(() -> {
try {
// 1초 후에 쓰레드를 중지합니다.
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
worker.interrupt();
System.out.println("중단 쓰레드가 작업 쓰레드를 중단시켰습니다.");
});
worker.start();
stopper.start();
}
}
view raw .java hosted with ❤ by GitHub

 

코드 부연 설명

  • stopper 쓰레드가 worker 쓰레드에 interrupt 신호를 보냄
  • 아래에 열거할 두 가지 케이스가 발생할 수 있으며 두 케이스 모두 쓰레드를 중지시킴
    • worker 쓰레드가 Thread.sleep(500); 코드에 의해 대기 상태로 전환되었고 이 때 interrupt 신호를 받았다면 InterruptedException이 발생하여 쓰레드 중지
    • worker 쓰레드가 sleep() 메서드로 인해 전환된 Waiting 상태에서 Runnable 상태로 전환되는 찰나에 interrupt 신호를 받을 경우 `!Thread.currentThread().isInterrupted()` 값이 false가 되어 while문 탈출하고 쓰레드 중지 

 

3. 사용자 쓰레드 vs 데몬 쓰레드

자바 쓰레드는 다음과 같이 크게 두 가지 유형의 쓰레드로 구분 가능합니다.

  • 사용자 쓰레드
  • 데몬 쓰레드

 

자바 어플리케이션이 실행되면 JVM은 사용자 쓰레드인 메인 쓰레드와 나머지 데몬 쓰레드를 동시에 생성하고 시작합니다.

 

3.1 메인 쓰레드

 

  • 어플리케이션에서 가장 중요한 부분으로서 어플리케이션을 실행할 때마다 메인 쓰레드가 생성되어 실행
  • 메인 쓰레드는 어플리케이션을 실행하는 최초의 쓰레드이자 어플리케이션 실행을 완료하는 마지막 쓰레드 역할
  • 메인 쓰레드에서 여러 하위 쓰레드를 추가로 시작할 수 있고 하위 쓰레드는 또 여러 하위 쓰레드를 시작할 수 있음
    • 자식 쓰레드는 부모 쓰레드의 상태를 상속받음
    • 메인 쓰레드가 사용자 쓰레드이기 때문에 하위 쓰레드는 모두 사용자 쓰레드

 

3.2 사용자 쓰레드

 

  • 메인 쓰레드에서 직접 생성한 쓰레드
  • 메인 쓰레드를 포함한 모든 사용자 쓰레드가 종료하게 되면 어플리케이션이 종료
  • foreground에서 실행되며 높은 우선순위를 가짐
  • JVM은 사용자 쓰레드가 스스로 종료될 때까지 어플리케이션을 강제로 종료하지 않고 대기
  • 자바가 제공하는 쓰레드 풀인 ThreadPoolExecutor 통해 사용자 쓰레드 생성

 

3.3 데몬 쓰레드

 

  • JVM에서 생성한 쓰레드이거나 직접 데몬 쓰레드로 생성한 경우
  • 모든 사용자 쓰레드가 작업을 완료하면 데몬 쓰레드의 실행 여부에 관계없이 JVM이 데몬 쓰레드를 강제 종료하고 어플리케이션 종료
  • background에서 실행되며 낮은 우선순위를 가짐
    • 데몬 쓰레드는 사용자 쓰레드를 지원하는 성격을 가진 쓰레드로서 보통 사용자 작업을 방해하지 않으면서 백그라운드에서 자동으로 작동

 

  • 자바가 제공하는 쓰레드 풀인 ForkJoinPool 통해 데몬 쓰레드 생성

 

3.3.1 데몬 쓰레드 생성 방법

 

  • setDaemon() 메서드를 통해 사용자 정의 쓰레드를 데몬 쓰레드로 설정 가능
    • 디폴트 값은 false 즉, 사용자 쓰레드
    • 단, 쓰레드가 실행 중인 동안 setDaemon()을 호출할 경우 IllegalThreadStateException 발생

 

public class DaemonThreadLifeCycleExample {
public static void main(String[] args) throws InterruptedException {
Thread userThread = new Thread(() -> {
try {
Thread.sleep(3000);
System.out.println("사용자 쓰레드 실행 중..");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
Thread daemonThread = new Thread(() -> {
while (true) {
try {
Thread.sleep(500);
System.out.println("데몬 쓰레드 실행 중..");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
daemonThread.setDaemon(true);
userThread.start();
daemonThread.start();
userThread.join();
System.out.println("메인 쓰레드 종료");
}
}
view raw .java hosted with ❤ by GitHub

 

코드 부연 설명

  • 데몬 쓰레드는 종료된 적이 없고 계속 실행 중이지만
  • 사용자 쓰레드가 종료되자 JVM은 데몬 쓰레드를 강제 종료하고 어플리케이션 종료

 

4. ThreadGroup

  • 자바는 ThreadGroup을 통해 여러 쓰레드를 그룹화하는 기능 제공
  • ThreadGroup에는 다른 ThreadGroup을 포함시킬 수 있고 그룹 내의 모든 쓰레드를 한 번에 종료 및 중단 가능
  • 쓰레드는 반드시 하나의 쓰레드 그룹에 포함되어야 함
    • 명시적으로 쓰레드 그룹에 포함시키지 않을 경우 자신을 생성한 쓰레드가 속해 있는 쓰레드 그룹에 포함됨
    • 일반적으로 사용자가 main 쓰레드에서 생성하는 모든 쓰레드는 기본적으로 main 쓰레드 그룹에 속함

 

4.1 JVM의 쓰레드 그룹 생성 과정

 

  • JVM이 실행되면 최상위 쓰레드 그룹인 시스템 쓰레드 그룹 생성
  • JVM 운영에 필요한 데몬 쓰레드들을 생성해서 시스템 쓰레드 그룹에 포함시킴
  • 시스템 쓰레드 그룹의 하위 쓰레드 그룹인 메인 쓰레드 그룹을 만들고 메인 쓰레드를 그룹에 포함시킴
    • 시스템 에러가 발생하면 메인 쓰레드도 같이 종료되는 이유

 

public class ThreadGroupInterruptExample {
public static void main(String[] args) throws InterruptedException {
ThreadGroup topGroup = new ThreadGroup("상위그룹");
ThreadGroup subGroup = new ThreadGroup(topGroup, "하위그룹");
Thread topGroupThread = new Thread(topGroup, () -> {
while (true) {
System.out.println("상위 그룹 스레드 실행중...");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
return;
}
}
}, "상위그룹스레드");
Thread subGroupThread = new Thread(subGroup, () -> {
while (true) {
System.out.println("하위 그룹 스레드 실행중...");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
return;
}
}
}, "하위그룹스레드");
Thread subGroupThread2 = new Thread(subGroup, () -> {
while (true) {
System.out.println("하위 그룹 스레드 2 실행중...");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
return;
}
}
}, "하위그룹스레드2");
topGroupThread.start();
subGroupThread.start();
subGroupThread2.start();
Thread.sleep(3000); // 스레드들이 실행되게 잠시 대기
System.out.println("그룹 스레드들 중지...");
subGroup.interrupt();
Thread.sleep(3000); // 3초 동안 상위 그룹 쓰레드만 실행
topGroup.interrupt();
}
}
view raw .java hosted with ❤ by GitHub

 

코드 부연 설명

  • 상위, 하위 그룹 모두 3초간 쓰레드 실행
  • 하위 그룹 쓰레드 중지 (subGroupThread, subGroupThread2 모두 종료)
  • 상위 그룹만 Running 상태
  • 상위 그룹 쓰레드 중지

 

5. ThreadLocal

  • 쓰레드는 오직 자신만이 접근해서 읽고 쓸 수 있는 로컬 변수 저장소 ThreadLocal을 제공
  • 각 쓰레드는 고유한 ThreadLocal 객체를 속성으로 가지며 ThreadLocal은 쓰레드 간 격리되어 있음
  • 모든 쓰레드가 공통적으로 처리해야 하는 기능이나 객체를 제어해야 하는 상황에서 쓰레드마다 다른 값을 적용해야 하는 경우 사용
    • ex) 인증 주체 보관, 트랜잭션 전파, 로그 추적기 등

 

https://www.cnblogs.com/iiiDragon/p/9815951.html

 

 

이미지 부연 설명

  • 쓰레드는 ThreadLocal에 있는 ThreadLocalMap 객체를 자신의 threadLocals 속성에 저장
  • 쓰레드 생성 시 theadLocals 기본값은 null이며 ThreadLocal에 값을 저장할 때 ThreadLocalMap이 생성되고 threadLocals에 연결
  • 쓰레드가 전역적으로 값을 참조할 수 있는 원리는 쓰레드가 ThreadLocal의 ThreadLocalMap에 접근해서 저장된 값을 바로 접근할 수 있기 때문
  • ThreadLocalMap은 항상 새롭게 생성되어 쓰레드 스택에 저장되기 때문에 근본적으로 쓰레드 간 데이터 공유가 될 수 없고 이에 따라 동시성 문제 발생 X

 

5.1 ThreadLocal 사용 시 주의사항

 

  • ThreadPool을 사용하여 쓰레드를 운용할 경우 반드시 ThreadLocal에 저장된 값을 삭제해줘야 함
    • 쓰레드 풀은 쓰레드를 재사용하기 때문에 현재 쓰레드가 이전의 쓰레드를 재사용한 것이라면 이전의 쓰레드에서 삭제하지 않았던 데이터를 참조할 수 있기 때문에 문제될 수 있음

 

public class ThreadPoolThreadLocalExample {
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(2); // 2개의 스레드를 가진 스레드 풀 생성
// 첫 번째 작업: ThreadLocal 값을 설정
executor.submit(() -> {
threadLocal.set("Thread 1의 값");
System.out.println(String.format("%s: %s", Thread.currentThread().getName(), threadLocal.get()));
});
// 여러 번의 두 번째 작업: ThreadLocal 값을 설정하지 않고 바로 값을 가져와 출력
for (int i = 0; i < 10; i++) {
executor.submit(() -> {
System.out.println(String.format("%s: %s", Thread.currentThread().getName(), threadLocal.get())); // 예상치 못한 값 출력 가능
});
}
executor.shutdown();
}
}
view raw .java hosted with ❤ by GitHub

 

 

코드 부연 설명

  • ThreadPool에서 운용하는 쓰레드 개수가 두 개뿐이고 첫 번째 작업에서 threadLocal에 저장된 값을 제거하지 않은 상태
  • 두 번째 작업에서는 별도로 ThreadLocal을 설정하지 않았기 때문에 get() 메서드를 통해 값을 가져올 경우 null 값이 출력될 것이라고 예상했지만 첫 번째 작업에서 설정한 값이 출력되는 것을 확인할 수 있음

 

5.2 ThreadLocal 작동원리

 

  • ThreadLocal은 쓰레드와 ThreadLocalMap을 연결하여 쓰레드 전용 저장소를 구현하고 있는데 이 것이 가능한 이유는 Thread.currentThread()를 참조할 수 있기 때문
    • Thread.currentThread()는 현재 실행 중인 쓰레드의 객체를 참조하는 것으로 CPU가 오직 하나의 쓰레드만 할당받아 처리하기 때문에 ThreadLocal에서 Thread.currentThread()를 참조하면 현재 실행 중인 쓰레드의 로컬 변수를 저장하거나 참조할 수 있음

 

5.3 InheritableThreadLocal

 

  • ThreadLocal의 확장 버전으로서 부모 쓰레드로부터 자식 쓰레드로 값을 전달하고 싶을 경우 사용
  • 부모 쓰레드가 InheritableThreadLocal 변수에 값을 설정할 경우 해당 부모 쓰레드로부터 생성된 자식 쓰레드들은 부모의 값을 상속
  • 반면, 자식 쓰레드가 상속받은 값을 변경하더라도 부모 쓰레드의 값에는 영향 X

 

public class InheritableThreadLocalExample {
public static InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();
public static void main(String[] args) {
inheritableThreadLocal.set("부모 쓰레드의 값");
Thread childThread = new Thread(() -> {
// 부모 쓰레드로부터 값 상속
System.out.println("자식 쓰레드에서 상속받은 값: " + inheritableThreadLocal.get());
// 자식 쓰레드에서 값을 변경
inheritableThreadLocal.set("자식 쓰레드의 새로운 값");
System.out.println("자식 쓰레드에서 설정한 후의 값: " + inheritableThreadLocal.get());
});
childThread.start();
// 자식 쓰레드가 종료될 때까지 대기
try {
childThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 부모 쓰레드의 값 확인
System.out.println("부모 쓰레드의 값: " + inheritableThreadLocal.get());
}
}
view raw .java hosted with ❤ by GitHub

 

 

비고

과거에 제가 ThreadLocal 관련하여 작성한 글 참고하시면 좋을 것 같습니다.

https://jaimemin.tistory.com/2007

 

[SpringBoot] ThreadLocal 간단 정리

개요 스프링 프레임워크 내 Bean들은 스프링 컨테이너에 의해 싱글톤으로 관리됩니다. 정리하자면 인스턴스가 단 하나만 존재한다는 것인데 여러 쓰레드가 동시에 해당 인스턴스를 접근할 경우

jaimemin.tistory.com

 

참고

자바 동시성 프로그래밍 [리액티브 프로그래밍 Part.1] - 정수원 강사님

반응형

'JAVA > 비동기 프로그래밍' 카테고리의 다른 글

[Java] 동기화 기법  (1) 2024.03.04
[Java] 동기화 개념  (0) 2024.02.24
[Java] Java 쓰레드 기본 API  (0) 2024.02.05
[Java] 쓰레드 생성 및 실행 구조  (0) 2024.01.31
[OS] 운영체제 기초  (0) 2024.01.19