김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 : Java Thread, Runnable

jkky98·2024년 7월 24일
0

Java

목록 보기
35/51

Thread - 스택영역

OS를 공부하며 알게 된 사실 중 하나는, 프로세스는 코드, 데이터, 힙, 스택 영역으로 구성된다는 점이다. 이때 하나의 프로세스 안에서 생성된 여러 스레드는 코드, 데이터, 힙 영역을 공유하며, 자신만의 스택 영역만을 별도로 갖는다.

Java 역시 유사한 구조를 가진다. JVM에서 실행되는 각 스레드는 독립된 Java 스택을 가지며, 힙 메모리와 메서드 영역은 공유된다. 이런 구조는 OS에서의 프로세스/스레드 모델과 개념적으로 유사하다고 볼 수 있다.

public class HelloThread extends Thread {

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " : run()");
    }
}


public class HelloThreadMain {

    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName() + ": main() start");

        HelloThread helloThread = new HelloThread();
        System.out.println(Thread.currentThread().getName() + ": start() 호줄 전");
        helloThread.start();
        System.out.println(Thread.currentThread().getName() + ": start() 호줄 후");

        System.out.println(Thread.currentThread().getName() + ": main() end");
    }
}

위의 코드는 실제로 스레드가 어떻게 작동하는지 관찰하기 위함으로 두 개의 스레드를 활용한다(main, helloThread)

새로운 스레드를 활용하기 위해 Thread 클래스를 상속받은 커스텀 스레드 클래스를 만들고 run() 메서드를 오버라이딩 한다.

그리고 실제 호출은 start()로 부모 클래스인 Thread의 start메서드를 호출하여 간접적으로 run()을 호출한다. 이를 통해 start()는 새로운 스레드가 run()을 호출하도록 한다.

왜 간접적으로 run()을 실행하게 설계했을까?

run()은 스레드가 해야 할 작업 내용을 담는 메서드이다.

start()는 JVM이 OS 쓰레드와 연결해서 run()을 실행하도록 준비하는 메서드이다.

즉, start()는 단순히 run()을 실행하는 게 아니라, "스레드라는 실행 단위"를 준비하고 실행하는 역할을 하기 때문에 둘은 구분해서 존재하는 것이다.

결과 콘솔을 나타낸 것으로 실제로 여러번 실행시켜보면 Thread-0 부분과 main() end 부분이 자주 교차되는 것을 볼 수 있다. 즉 main() end를 콘솔에 띄우는 스레드는 main스레드에서 run()을 콘솔에 띄우는 스레드는 Thread-0에서 진행되며 두 스레드의 실행 순서는 보장되지 않고 운영체제의 스케줄링에 따라 항상 다르게 실행결과 시점을 가진다.

  • 스레드는 순서와 실행 기간을 모두 보장하지 않는다.

데몬 스레드

스레드는 사용자(user)스레드와 데몬(daemon)스레드 2가지로 구분이 가능하다. 데몬 스레드는 백그라운드에서 보조적인 작업을 수행한다.

자바의 경우 데몬 스레드의 스택 상황에 상관 없이 모든 user 스레드가 종료(빈 스택)되면 데몬 스레드는 자동으로 종료된다.

  • daemon : 이렇게 보조적인 작업을 수행하며 user스레드와 구분되는 이러한 스레드를 "데몬"이라고 하는 이유는 그리스 신화에서 데몬은 신과 인간 사이의 중간적 존재로 보이지 않게 활동하며 일상적인 일을 돕는 뜻에서 따온 것으로 보인다.
t.setDaemon(true);  // 데몬 스레드로 설정

자바 스레드에 대해 데몬 스레드 설정이 가능하다.

데몬 스레드는 사용자 스레드가 모두 종료되면 자동으로 종료된다.

이 차이로 하여금 데몬 스레드의 사용 목적을 이해해야한다.

데몬 스레드의 작업이 아직 끝나지 않았더라도 사용자 스레드 종료시 JVM은 이를 기다리지 않고 데몬 스레드를 종료시킨다.

다시 정리하자면,
JVM의 종료 조건은 사용자 스레드가 모두 종료되었을 때 이다.
보통 우리가 사용하는 IntelliJ의 서버 종료 버튼은 강제종료로서 사용자 스레드의 작업 존재 여부와 상관 없이 종료한다. 하지만 실 운영환경에서는 종료시 사용자 스레드의 남은 작업을 처리하고 천천히 종료되게 하는 graceful shutdown 방식을 고수하는 것이 일반적이다. 하지만 데몬 스레드는 실 운영환경에서도 로컬 개발 환경에서도 사용자 스레드가 모두 종료되었을 때 남은 작업 여부와 상관 없이 곧바로 종료된다.

데몬 스레드는 결국 강제 종료되더라도 문제 없는 태스크에 대해서만 적용해야한다는 것이다. 아래의 예시에 해당하는 기능들이 그렇다. 하나 두개 정도 누락되더라도 괜찮은 태스크에 대해서다.

  • 로그 기록
  • 임시 파일/캐시 정리
  • 서버 상태 모니터링
  • 백그라운드 알림 처리
  • 스케줄 간격 확인 등 유지보수성 태스크

그냥 모두 사용자 스레드화 하면 되지 않을까?

"로그 기록이 하나 둘 누락되더라도 괜찮다" 라는 말은 하나 둘 다 처리하고 끝내는 것보다 더 좋다고 말할 수 없을 것이다.

그렇다면 데몬 스레드를 사용하지 않고 모두 사용자 스레드로 처리해서 모두 Graceful Shutdown 방식을 채택하는게 좋지 않을까에 대한 의문이 든다.

현실은 이상적이지 않다.

모든 스레드를 사용자 스레드로 구성하고
모든 종료 과정을 기다려주는 shutdown 로직을 작성하는 것은 노력이 많이 들고, 실수할 여지도 많다.

수백 개의 스레드 중 일부가 종료 신호를 무시하고 계속 대기하면?

큐가 비워질 때까지 무한정 기다리는 코드는 결국 시스템을 멈춰 세울 수도 있다

Kubernetes와 같은 환경에서는 30초 이내 종료가 강제되기 때문에 이상적인 종료를 기다릴 수 있는 현실적인 시간이 부족하기도 하다.

그렇기에 확실하게 강제 종료가 되어도 괜찮은 태스크라면 데몬 스레드로 설정하는 것도 좋은 방법이 되는 것이다.

Runnable vs Thread

앞서 우리는 Thread를 상속받아 새로운 스레드에서 실행될 코드를 구성했다.

일반적으로 스레드를 생성하는 방법은 Thread를 상속하거나 Runnable 인터페이스를 구현하는 두 가지가 있다.

  • 결론적으로는 스레드를 사용할 때 Thread를 상속받기 보다는 Runnable 인터페이스를 구현하는 방식을 사용하자.
  • Thread는 이미 클래스를 상속하므로 다중 상속이 불가능
  • Runnable은 인터페이스이므로 다른 클래스를 상속하면서도 사용 가능
  • 실행 로직(run)과 스레드 실행(Thread)을 명확히 분리할 수 있어 재사용성과 유연성이 높음

❓실행 로직과 스레드 실행 분리는 Thread도 동일한 구조를 가지지 않은가?

그렇다.
Thread를 상속받아도 상속받은 클래스는 start()를 통해 멀티스레드로 실행할 수 있고,
run() 메서드에 실행 로직을 정의할 수 있다.
즉, 실행 로직(run)과 실행 트리거(start) 가 구분되어 있기는 하다.
하지만 이 경우 Thread 객체 하나에 실행 주체와 실행 로직이 모두 담기게 된다.
반면 Runnable을 사용할 경우, 실행 로직(Runnable 구현체) 과 실행 주체(Thread 객체) 가 완전히 분리된 객체로 존재하게 된다.

Runnable task = new MyTask();      // 실행 로직
Thread thread = new Thread(task);  // 실행 주체
thread.start();                    // 실행

extends Thread

run() 메서드만 재정의하는 간단한 구현이 가능하지만, 상속의 제한이 존재하며 유연성이 떨어진다.

implements Runnable

코드가 약간 복잡해질 수는 있으나, 인터페이스이기 때문에 다른 클래스를 상속받는 것에 문제가 없으며 스레드와 실행할 작업을 분리한다는 단일책임원칙도 지킬 수 있다.

여러 스레드가 동일한 실행 로직에 해당하는 Runnable 객체를 공유할 수 있다.

Thread static 기능 이용

Runnable runnable = new Runnable() {
            @Override
            public void run() {
                for (int i = 1; i < 6; i++) {
                    log("value : " + i);
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        };

위와 같이 Runnable을 구현하는 익명 클래스 안에 Thread.sleep()과 같은 스태틱 메서드를 사용했다. 이는 현재 이 코드를 실행시키는 스레드를 1000ms간 중지시키는 것으로 이렇게 Thread의 기능을 스태틱 메서드로 바로 이용할 경우 매핑이되는 스레드는 현재 해당 코드를 실행중인 스레드가 대상이 된다.

스레드 100개 만들기

public class ManyThreadMainV2 {

    public static void main(String[] args) {
        log("main() start");

        HelloRunnable runnable = new HelloRunnable();

        for (int i = 0; i < 100; i++) {
            Thread thread = new Thread(runnable);
            thread.start();
        }

    }
}

업로드중.. 위의 결과로 하여금 스레드의 실행 순서가 for문과 맞지 않다는 것을 알 수 있다. for문에서 전달하는 순서적인 이미지때문에 아무리 멀티 스레드로 각각의 스택영역에서 독립적으로 실행한다 하더라도 for문의 순서성이 영향을 미치지 않을 수 있을까?

for문에서 thread.start()는 단순히 호출만 하고 다음 for문으로 넘어간다. 호출 이후 실제로 run()까지 실행되는 과정에 있어서는 이제 다른 스레드의 몫이며 그러므로 for문의 순서성이 거의 무시된다고 볼 수 있다. 호출 자체를 늦게 하는 것이 분명 영향을 주어 100번 스레드가 0번 스레드보다 대부분의 경우에서 늦겠지만

실제로 코드의 실행은 호출 순서가 종료 순서를 보장하지 않는다. 호출이후 진행되는 과정은 같은 스레드가 아니라면 순서가 보장되지 않는다. Thread1을 처리하다가 7로 컨텍스트 스위칭이 일어날 수도 있다는 것이다.

다양한 Runnable 활용

정적 중첩 클래스 활용


public class InnerRunnableMainV1 {

    public static void main(String[] args) {
        log("main() start");

        MyRunnable r = new MyRunnable();
        Thread thread = new Thread(r);
        thread.start();


        log("main() end");
    }

    static class MyRunnable implements Runnable {
        @Override
        public void run() {
            log("run()");
        }
    }
}

익명 클래스 활용


    public static void main(String[] args) {
        log("main() start");


        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                log("run()");
            }
        };

        Thread thread = new Thread(runnable);
        thread.start();


        log("main() end");
    }
  • 생성자가 필요한 경우라면 사용하지 못한다.
  • 익명 클래스에 implements가 붙지 않아도 @Override가 가능한 이유는 익명 클래스는 받는 객체의 타입을 implements 뒤에 들어올 타입으로 매핑해주기 때문이다.

람다 활용

public class InnerRunnableMainV4 {

    public static void main(String[] args) {
        log("main() start");


        Thread thread = new Thread(() -> log("run()"));
        thread.start();


        log("main() end");
    }

}
  • 람다의 경우는 타입 매핑은 익명 클래스와 같지만 오버라이드 메서드 정의 자체가 없다. 오버라이드 메서드까지 매핑될 수 있는 이유는 람다 방식 자체가 해당 부모 타입 클래스의 메서드가 하나만 존재해야하기 때문에, 그 유일한 하나에 매핑된다.
profile
자바집사의 거북이 수련법

0개의 댓글