728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

스레드를 시작하기 앞서, 스레드를 제대로 이해하려면 자바 메모리 구조를 확실히 이해하고 있어야 한다.

자바 메모리 구조

  • 메서드 영역: 메서드 영역은 프로그램을 실행하는데 필요한 공통 데이터를 관리한다. 이 영역은 프로그램의 모든 영역에서 공유한다.
    • 클래스 정보: 클래스의 실행 코드(바이트 코드), 필드, 메서드와 생성자 코드등 모든 실행 코드가 존재한다.
    • static 영역: static 변수들을 보관한다.
    • 런타임 상수 : 프로그램을 실행하는데 필요한 공통 리터럴 상수를 보관한다.
  • 스택 영역: 자바 실행 시, 하나의 실행 스택이 생성된다. 각 스택 프레임은 지역 변수, 중간 연산 결과, 메서드 호출 정보 등을 포함한다.
  • 힙 영역: 객체(인스턴스)와 배열이 생성되는 영역이다. 가비지 컬렉션(GC)이 이루어지는 주요 영역이며, 더 이상 참조되지 않는 객체는 GC에 의해 제거된다.

스택 영역은 더 정확히는 각 스레드별로 하나의 실행 스택이 생성된다. 따라서 스레드 수 만큼 스택이 생성된다. 지금은 스레드 1개만 사용하므로 스택도 하나이다. 이후 스레드를 추가할 것인데 그러면 스택도 스레드 수만큼 증가한다.

 

스레드 생성

스레드를 직접 만들어보자. 그래서 해당 스레드에서 별도의 로직을 수행해보자.

스레드를 만들때는 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법이 있다.

먼저 Thread 클래스를 상속 받아서 스레드를 생성해보자.

 

스레드 생성 - Thread 상속

자바는 많은 것을 객체로 다룬다. 자바가 예외도 객체로 다루듯 스레드도 객체로 다룬다.

스레드가 필요하면 스레드 객체를 생성해서 사용하면 된다.

 

HelloThread

package thread.start;

public class HelloThread extends Thread {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + ": run()");
    }
}
  • Thread 클래스를 상속하고, 스레드가 실행할 코드를 run() 메서드에 재정의한다.
  • Thread.currentThread()를 호출하면 해당 코드를 실행하는 스레드 객체를 조회할 수 있다.
  • Thread.currentThread().getName(): 실행중인 스레드의 이름을 조회한다.

HelloThreadMain

package thread.start;

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");
    }
}
  • 앞서 만든 HelloThread 객체를 생성하고 start() 메서드를 호출한다.
  • start() 메서드는 스레드를 실행하는 아주 특별한 메서드이다.
  • start()를 호출하면 HelloThread 스레드가 run() 메서드를 실행한다.
주의! run() 메서드가 아니라 반드시 start() 메서드를 호출해야 한다. 그래야 별도의 스레드에서 run() 코드가 실행된다. run()을 직접 호출하면 현재 진행중인 쓰레드가 그냥 run() 메서드를 실행하는 것이다.

 

실행결과

main: main() start
main: start() 호출 전
main: start() 호출 후
main: main() end
Thread-0: run()

실행 결과는 스레드의 실행 순서에 따라 약간 다를 수 있다. 가령 이런 경우도 있고.

main: main() start
main: start() 호출 전
main: start() 호출 후
Thread-0: run()
main: main() end

 

실행 결과 설명 - 1단계

실행 결과를 보면 main() 메서드는 main 이라는 이름의 스레드가 실행하는 것을 확인할 수 있다. 프로세스가 작동하려면 스레드가 최소한 하나는 있어야 한다. 그래야 코드를 실행할 수 있다. 자바는 실행 시점에 main 이라는 이름의 스레드를 만들고 프로그램의 시작점인 main() 메서드를 실행한다.

 

실행 결과 설명 - 2단계

  • HelloThread 스레드 객체를 생성한 다음에 start() 메서드를 호출하면 자바는 HelloThread 스레드를 위한 별도의 스택 공간을 할당한다.
  • 스레드 객체를 생성하고 반드시 start()를 호출해야 스택 공간을 할당받고 스레드가 작동한다.
  • 스레드에 이름을 주지 않으면 자바는 스레드에 Thread-0, Thread-1 과 같은 임의의 이름을 부여한다.
  • 새로운 Thread-0 스레드가 사용할 전용 스택 공간이 마련되었다.
  • Thread-0 스레드는 run() 메서드의 스택 프레임을 스택에 올리면서 run() 메서드를 시작한다.

 

 

메서드를 실행하면 스택 위에 스택 프레임이 쌓인다.

  • main 스레드는 main() 메서드 스택 프레임을 스택에 올리면서 시작한다.
  • 직접 만드는 스레드는 run() 메서드 스택 프레임을 스택에 올리면서 시작한다.

실행 결과를 보면 Thread-0 스레드가 run() 메서드를 실행한 것을 확인할 수 있다.

  • main 스레드가 HelloThread 인스턴스를 생성한다. 이때 스레드에 이름을 부여하지 않으면 자바가 Thread-0, Thread-1 과 같은 임의의 이름을 부여한다.
  • main 스레드가 start() 메서드를 호출하면, Thread-0 스레드가 시작되면서 Thread-0 스레드가 run() 메서드를 호출한다.
  • 여기서 핵심은 main 스레드가 run() 메서드를 실행하는 게 아니라, Thread-0 스레드가 run() 메서드를 실행한다는 점이다.
  • main 스레드는 단지 start() 메서드를 통해 Thread-0 스레드에게 실행을 지시할 뿐이다. 다시 강조하지만 main 스레드가 run()을 호출하는 게 아니다! main 스레드는 다른 스레드에게 일을 시작하라고 지시만 하고 바로 start() 메서드를 빠져나온다.
  • 이제 main 스레드와 Thread-0 스레드는 동시에 실행된다.
  • main 스레드 입장에서 보면 그림 1, 2, 3번 코드를 멈추지 않고 계속 수행한다. 그리고 run() 메서드는 main이 아닌 별도의 스레드에서 실행된다.

스레드 간 실행 순서는 보장하지 않는다.

스레드는 동시에 수행되기 때문에 스레드 간에 실행 순서는 얼마든지 달라질 수 있다. 따라서 위에서 보여준 실행결과와 같이 다양한 실행 결과가 나올 수 있다. CPU 코어가 2개여서 물리적으로 정말 동시에 실행될 수도 있고, 하나의 CPU 코어에 시간을 나누어 실행될 수도 있다.

그리고 한 스레드가 얼마나 오랜기간 실행되는지도 보장하지 않는다. 한 스레드가 먼저 다 수행된 다음에 다른 스레드가 수행될 수도 있고, 둘이 완전히 번갈아 가면서 수행되는 경우도 있다. 

 

스레드는 순서와 실행 기간을 모두 보장하지 않는다. 이것이 바로 멀티 스레드다!

 

start() vs run()

스레드의 start() 대신에 재정의한 run()을 직접 호출하면 어떻게 될까?

package thread.start;

public class BadThreadMain {
    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.run();
        System.out.println(Thread.currentThread().getName() + ": start() 호출 후");

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

실행결과

main: main() start
main: start() 호출 전
main: run()
main: start() 호출 후
main: main() end

  • 실행 결과를 잘 보면 별도의 스레드가 run()을 실행하는 것이 아니라, main 스레드가 run() 메서드를 호출한 것을 알 수 있다.
  • 자바를 처음 실행하면 main 스레드가 main() 메서드를 호출하면서 시작한다.
  • main 스레드는 HelloThread 인스턴스에 있는 run() 이라는 메서드를 호출한다.
  • main 스레드가 run() 메서드를 실행했기 때문에 main 스레드가 사용하는 스택위에 run() 스택 프레임이 올라간다.

결과적으로 main 스레드에서 모든 것을 처리한 것이 된다.

 

스레드의 start() 메서드는 스레드에 스택 공간을 할당하면서 스레드를 시작하는 아주 특별한 메서드이다. 그리고 해당 스레드에서 run() 메서드를 실행한다. 따라서 main 스레드가 아닌 별도의 스레드에서 재정의한 run() 메서드를 실행하려면, 반드시 start() 메서드를 호출해야 한다.

 

데몬 스레드

스레드는 사용자 스레드데몬 스레드 2가지 종류로 구분할 수 있다.

 

사용자 스레드 (non-daemon 스레드)

  • 프로그램의 주요 작업을 수행한다.
  • 작업이 완료될 때까지 실행된다.
  • 모든 user 스레드가 종료되면 JVM도 종료된다.

데몬 스레드

  • 백그라운드에서 보조적인 작업을 수행한다.
  • 모든 user 스레드가 종료되면 데몬 스레드는 자동으로 종료된다.

JVM은 데몬 스레드의 실행 완료를 기다리지 않고 종료된다. 데몬 스레드가 아닌 모든 스레드가 종료되면, 자바 프로그램도 종료된다.

참고로, 데몬 스레드라는 용어는 사용자에게 직접적으로 보이지 않으면서 시스템의 백그라운드에서 작업을 수행하는 것을 데몬 스레드, 데몬 프로세스라고 한다. 

 

DaemonThreadMain

package thread.start;

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

        DaemonThread dt = new DaemonThread();
        dt.setDaemon(true); // 데몬 스레드 여부
        dt.start();

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

    static class DaemonThread extends Thread {
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + ": run() start");

            try {
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            System.out.println(Thread.currentThread().getName() + ": run() end");
        }
    }
}
  • 데몬 스레드를 설정하려면 setDaemon(true); 를 호출하면 된다.
  • 데몬 스레드 여부는 start() 실행 전에 결정해야 한다. 이후에는 변경되지 않는다.
  • 기본값은 false

실행결과

main: main start
main: main end
Thread-0: run() start

 

분명 Thread-0은 10초가 지나야 끝나는데 그냥 끝나버렸다. 그 이유는 Thread-0은 데몬 스레드이고, 메인 스레드(사용자 스레드)가 다 끝났기 때문에 자바 프로그램도 종료된 것이다. 만약 데몬 스레드가 아니라면 예상한대로 아래처럼 실행된다.

 

실행결과 (setDaemon(false))

main: main start
main: main end
Thread-0: run() start
Thread-0: run() end

 

스레드 생성 - Runnable

스레드를 만들 때는 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법이 있다고 했다.

이제 Runnable 인터페이스를 구현하는 방식으로 스레드를 생성해보자.

 

Runnable 인터페이스

package java.lang;

@FunctionalInterface
public interface Runnable {
    void run();
}

 

HelloRunnable

package thread.start;

public class HelloRunnable implements Runnable {

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

 

HelloRunnableMain

package thread.start;

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

        HelloRunnable helloRunnable = new HelloRunnable();
        new Thread(helloRunnable).start();

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

실행결과

main: main() start
main: main() end
Thread-0: run() start
Thread-0: run() end

 

실행 결과는 기존과 같다. 차이가 있다면 스레드와 해당 스레드가 실행할 작업이 서로 분리되어 있다는 점이다.

스레드 객체를 생성할 때, 실행할 작업을 생성자로 전달하면 된다. 위 코드처럼.

 

Thread 상속 vs Runnable 구현

스레드를 사용할 땐 Thread를 상속받는 것보다 Runnable 인터페이스를 구현하는 방식을 사용하자.

두 방식이 서로 장단점이 있지만, 스레드를 생성할 때는 Thread 클래스를 상속받는 방식보다 Runnable 인터페이스를 구현하는 방식이 더 나은 선택이다. 

 

Thread 상속

장점

  • 간단한 구현: Thread 클래스를 상속받아 run() 메서드만 재정의하면 된다.

단점

  • 상속의 제한: 자바는 단일 상속만을 허용하므로 이미 다른 클래스를 상속받고 있는 경우 Thread 클래스를 상속받을 수 없다.
  • 유연성 부족: 인터페이스를 사용하는 방법에 비해 유연성이 떨어진다.

Runnable 구현

장점

  • 상속의 자유로움: Runnable 인터페이스 방식은 다른 클래스를 상속받아도 문제없이 구현할 수 있다.
  • 코드의 분리: 스레드와 실행할 작업을 분리하여 코드의 가독성을 높일 수 있다.
  • 여러 스레드가 동일한 Runnable 객체를 공유할 수 있어 자원 관리를 효율적으로 할 수 있다.

단점

  • 코드가 약간 복잡해질 수 있다. Runnable 객체를 생성하고 이를 Thread에 전달하는 과정이 추가된다.

정리하자면, Runnable 인터페이스를 구현하는 방식을 사용하자. 스레드와 실행할 작업을 명확히 분리하고, 인터페이스를 사용하므로 Thread 클래스를 직접 상속받는 방식보다 더 유연하고 유지보수 하기 쉬운 코드를 만들 수 있다.

 

로거 만들기

현재 어떤 스레드가 코드를 실행하는지 출력하기 위해 다음과 같이 긴 코드를 작성하는게 이젠 귀찮다.

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

 

이런식으로 실행하면 현재 시간, 스레드 이름, 출력 내용등이 한번에 나올 수 있으면 좋을것같다.

log("Hello World");
log(1234);

실행결과

2024-07-18 09:54:08.567 [     main] Hello World
2024-07-18 09:54:08.570 [     main] 1234

 

MyLogger

package util;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public abstract class MyLogger {

    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");

    public static void log(Object obj) {
        String localDateTime = LocalDateTime.now().format(formatter);
        System.out.printf("%s [%9s] %s\n", localDateTime, Thread.currentThread().getName(), obj);
    }
}
  • 다른곳에서 new로 생성하지 못하도록 abstract 클래스를 만들었다.
  • 현재 시간을 원하는 포맷으로 출력하기 위해 DateTimeFormatter를 사용한다.
  • printf에서 %s는 문자열을 뜻한다. 인자를 순서대로 사용한다.
  • 마지막 출력할 객체는 문자열이 아니라 Object 타입인데, %s를 사용하면 toString()을 사용해서 문자열로 변환 후 출력한다. 이렇게 Object 타입을 사용하면 문자열 뿐만 아니라 객체도 출력할 수 있어서 이렇게 했다.
  • %9s는 다음과 같이 문자를 출력할 때 9칸을 확보한다는 뜻이다. 9칸이 차지 않으면 왼쪽에 그 만큼 비워둔다. 이 기능은 단순히 출력시 정렬을 깔끔히 하기위해 사용한다.
  • [     main]: 앞에 5칸 공백
  • [ Thread-0]: 앞에 1칸 공백

 

MyLoggerMain

package util;

import static util.MyLogger.*;

public class MyLoggerMain {
    public static void main(String[] args) {
        log("Hello World");
        log(1234);
    }
}

실행결과

2024-07-18 09:54:08.567 [     main] Hello World
2024-07-18 09:54:08.570 [     main] 1234

 

이제 이 로거를 사용해서 마음껏 스레드 공부를 해보자!

 

여러 스레드 만들기

많은 스레드를 만들어보고 실행해보자.

ManyThreadMainV1

package thread.start;

import static util.MyLogger.log;

public class ManyThreadMainV1 {
    public static void main(String[] args) {

        log("main() start");

        HelloRunnable helloRunnable = new HelloRunnable();

        Thread thread1 = new Thread(helloRunnable);
        thread1.start();

        Thread thread2 = new Thread(helloRunnable);
        thread2.start();

        Thread thread3 = new Thread(helloRunnable);
        thread3.start();

        log("main() end");
    }
}

실행결과

2024-07-18 10:07:46.166 [     main] main() start
2024-07-18 10:07:46.171 [     main] main() end
2024-07-18 10:07:46.171 [ Thread-1] run() start
2024-07-18 10:07:46.171 [ Thread-2] run() start
2024-07-18 10:07:46.171 [ Thread-0] run() start
2024-07-18 10:07:46.171 [ Thread-1] run() end
2024-07-18 10:07:46.171 [ Thread-2] run() end
2024-07-18 10:07:46.172 [ Thread-0] run() end

 

이 예시는 단순 3개의 스레드를 만들고 실행한 결과이다.

  • 스레드 3개를 생성할 때 모두 같은 HelloRunnable 인스턴스를 스레드의 실행 작업으로 전달했다.
  • Thread-0, Thread-1, Thread-2 모두 HelloRunnable 인스턴스에 있는 run() 메서드를 실행한다.

 

스레드 100개도 생성해보자.

ManyThreadMainV2

package thread.start;

import static util.MyLogger.log;

public class ManyThreadMainV2 {
    public static void main(String[] args) {

        log("main() start");

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

        log("main() end");
    }
}

실행결과

2024-07-18 10:10:01.192 [     main] main() start
2024-07-18 10:10:01.195 [ Thread-2] run() start
2024-07-18 10:10:01.195 [ Thread-5] run() start
2024-07-18 10:10:01.196 [ Thread-5] run() end
2024-07-18 10:10:01.196 [Thread-20] run() start
2024-07-18 10:10:01.195 [ Thread-4] run() start
2024-07-18 10:10:01.197 [Thread-29] run() start
2024-07-18 10:10:01.195 [ Thread-8] run() start
...
2024-07-18 10:10:01.218 [Thread-98] run() end

 

실행 결과는 그때마다 다르고 스레드의 실행 순서는 보장되지 않는다!

 

Runnable을 만드는 다양한 방법

중첩 클래스 사용하면 Runnable을 더 편리하게 만들 수 있다.

InnerRunnableMainV1

package thread.start;

import static util.MyLogger.log;

public class InnerRunnableMainV1 {
    public static void main(String[] args) {
        log("main() start");
        MyRunnable myRunnable = new MyRunnable();
        Thread t1 = new Thread(myRunnable);
        t1.start();
        log("main() end");
    }

    static class MyRunnable implements Runnable {

        @Override
        public void run() {
            log("run() start");
            log("run() end");
        }
    }
}

실행결과 (실행 결과는 모두 같다)

2024-07-18 10:47:29.014 [     main] main() start
2024-07-18 10:47:29.017 [     main] main() end
2024-07-18 10:47:29.017 [ Thread-0] run() start
2024-07-18 10:47:29.018 [ Thread-0] run() end

 

익명 내부 클래스를 사용하면 더 편리하게 만들수 있다.

InnerRunnableMainV2

package thread.start;

import static util.MyLogger.log;

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

        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                log("run() start");
                log("run() end");
            }
        });
        t1.start();
        log("main() end");
    }
}

 

람다를 사용하면 더더 편리하게 만들수도 있다.

InnerRunnableMainV3

package thread.start;

import static util.MyLogger.log;

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

        Thread t1 = new Thread(() -> {
            log("run() start");
            log("run() end");
        });
        t1.start();
        log("main() end");
    }
}

 

 

정리

이렇게 여러 방법으로 Runnable을 만드는 방법을 알아보았다. 스레드를 어떻게 만들고 뭐로 만드는게 더 효율적인지를 배워본것이다.

다음 포스팅부턴 더 깊은 내용을 알아볼 예정이다.

728x90
반응형
LIST

+ Recent posts