3. 스레드 생성과 실행

임대일·2025년 5월 11일

Thread

목록 보기
3/13
post-thumbnail

자바 메모리 구조

스레드를 제대로 이해하려면 자바 메모리 구조를 확실히 이해하고 있어야 합니다.
스레드를 시작하기 전에 잠깐 자바 메모리 구조를 복습합니다.

메서드 영역(Method Area)

프로그램을 실행하는데 필요한 공통 데이터를 관리합니다. 따라서 프로그램의 모든 영역에서 공유합니다.

  1. 클래스 정보: 클래스의 실행 코드(바이트 코드), 필드, 메서드, 생성자 등 클래스의 모든 실행 코드를 의미합니다.
  2. static 영역: static 변수들을 보관합니다.
  3. 상수 풀: 공통 리터럴 상수를 보관합니다.

메서드 영역의 데이터들은

  • 클래스 단위로 한 번만 로딩되고 모든 스레드가 공유하고,
  • 런타임 중에 클래스가 로딩될 때 채워집니다.

힙 영역(Heap Area)

new 키워드로 생성된 객체 인스턴스가 저장되는 공간입니다. 따라서 프로그램의 모든 영역에서 공유합니다.

  1. 객체(인스턴스)
  2. 배열
  3. 람다, 내부 클래스 객체 등

힙 영역의 데이터들은

  • 가장 큰 메모리 영역에 저장되고,
  • GC가 자동으로 관리하고,
  • 객체가 참조되지 않으면 GC 대상이 됩니다.

스택 영역(Stack Area)

스레드마다 독립적으로 존재하며, 메서드 호출 시 스택 프레임이 생성됩니다. 스택 프레임의 정보는 다음과 같습니다.

  1. 지역 변수(매개변수 포함)
  2. 참조 주소(객체는 힙. 참조는 스택)
  3. 리턴 주소
  4. 중간 연산 결과

메서드가 종료되면, 해당 프레임은 LIFO 구조로 스택에서 pop됩니다. 스택 영역의 데이터들은

  • 스레드마다 독립된 스택을 갖고,
  • 메서드 호출 시 스택 프레임을 생성하고 메서드 종료 시 소멸되고,
  • 메모리 접근이 빠르고 GC 대상이 아닙니다.

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

요약

메모리 영역저장 내용생명 주기접근 속도GC 대상공유 여부
메서드 영역클래스 정보, static 변수, 메서드, 상수 등클래스 언로드 시중간✅ 모든 스레드 공유
힙 영역new로 생성된 객체, 배열참조 끊기 전까지느림✅ 공유됨
스택 영역지역 변수, 메서드 호출 정보, 참조 변수 등메서드 종료 시빠름❌ 스레드마다 독립

스레드 생성

이제 스레드를 직접 생성합니다. 스레드를 생성할 때는 두 가지로 Thread 클래스를 상속 받는 방법과 Runnable 인터페이스를 구현하는 방법이 있습니다.

  1. Thread 클래스를 상속하는 방법
  2. Runnable 인터페이스를 구현하는 방법

먼저 Thread를 상속 받아서 스레드를 생성하겠습니다.

Thread 상속

자바는 많은 것을 객체로 다루고 있습니다. 자바가 예외를 객체로 다루듯이, 스레드도 객체로 다룹니다.
즉, 스레드가 필요하면 스레드 객체를 직접 생성하고, 실행하면 됩니다.

package thread.start;

public class HelloThread extends Thread {

  @Override
  public void run() {
    System.out.println(Thread.currentThread().getName() + ": run()");
  }
}
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() 코드가 실행됩니다.

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

참고로 실행 결과는 스레드의 실행 순서에 따라 약간 다를 수 있습니다. 이 부분은 바로 뒤에서 설명하고 지금은 작성한 코드부터 살펴보겠습니다.

스레드 생성 전

실행 결과를 살펴보면 main() 메서드는 main이라는 이름의 스레드가 실행된 것을 확인할 수 있습니다. 프로세스가 작동하려면 스레드는 최소한 한 개는 있어야 합니다. 따라서 실행 시점에 main이라는 이름의 스레드를 생성하고 프로그램의 시작점인 main() 메서드를 실행합니다.

스레드 생성 후

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 과 같은 임의의 이름을 부여합니다.
  • 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 코어에 시간을 나누어 실행될 수도 있습니다. 그리고 한 스레드가 얼마나 오랜기간 실행되는지도 보장하지 않습니다. 한 스레드가 먼저 다 수행된 다음에 다른 스레드가 수행될 수도 있고, 둘이 완전히 번갈아 가면서 수행되는 경우도 있습니다. 스레드는 순서와 실행 기간을 모두 보장하지 않습니다. 이것이 바로 멀티스레드입니다!

스레드 시작2

start() vs run()

스레드의 start() 대신에 재정의한 run() 메서드를 직접 호출하면 스레드는 생성되지 않습니다. 결과적으로 main 스레드에서 모든 메서드를 수행하게 됩니다.
스레드의 start()는 스레드에 스택 공간을 할당하면서 스레드를 시작하는 특별한 메서드입니다. 그리고 해당 스레드에서 run() 메서드를 내부적으로 실행합니다. 따라서 main 스레드가 아닌 별도의 스레드에서 재정의한 run() 메서드를 실행하려면, 반드시 start() 메서드를 호출해야 합니다.

데몬 스레드

스레드는 사용자(user) 스레드와 데몬(daemon) 스레드 2가지 종류로 구분할 수 있습니다.

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

  • 프로그램의 주요 작업을 수행합니다.
  • 작업이 완료될 때까지 실행됩니다.
  • 모든 사용자 스레드가 종료되면 JVM도 종료됩니다.

데몬 스레드

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

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

용어 - 데몬 : 그리스 신화에서 데몬은 신과 인간 사이의 중간적 존재로, 보이지 않게 활동하며 일상적인 일들을 도왔습니다. 이런 의미로 컴퓨터 과학에서는 사용자에게 직접적으로 보이지 않으면서 시스템의 백그라운드에서 작업을 수행하는 것을 데몬 스레드, 데몬 프로세스라 합니다. 예를 들어서 사용하지 않는 파일이나 메모리를 정리하는 작업들이 있습니다.

package thread.start;

public class DaemonThreadMain {
  public static void main(String[] args) {
    System.out.println(Thread.currentThread().getName() + ": main() start");
    DaemonThread daemonThread = new DaemonThread();
    daemonThread.setDaemon(true); // 데몬 스레드 여부
    daemonThread.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(1000);
      } catch (InterruptedException e) {
        throw new RuntimeException(e);
      }
      System.out.println(Thread.currentThread().getName() +": run() end");
    }
  }
}
main: main() start
main: main() end
Thread-0: run start()
  • setDaemon(true)로 설정
  • Thread-0는 데몬 스레드로 설정됨
  • 유일한 사용자 스레드인 main 스레드가 종료되면서 자바 프로그램도 종료됨
  • 따라서 run() end가 출력되기 전에 프로그램이 종료됨

실행 결과 - setDaemon(false)

main: main() start
main: main() end
Thread-0: run() start
Thread-0: run() end
  • setDaemon(false)로 설정
  • Thread-0는 사용자 스레드로 설정됨
  • main 스레드가 종료되어도, 사용자 스레드인 Thread-0가 종료될 때까지 자바 프로그램은 종료되지 않음
  • 따라서 Thread-0: run end가 출력됨
  • 사용자 스레드인 main 스레드와 Thread-0 스레드가 모두 종료되면서 자바 프로그램도 종료됨

스레드 생성 - Runnable

스레드를 만들 때는 Thread 클래스를 상속 받는 방법과 Runnable 인터페이스를 구현하는 방법이 있습니다.
앞서 Thread 클래스를 상속 받아서 스레드를 생성했습니다. 이번에는 Runnable 인터페이스를 구현하는 방식으로 스레드를 생성합니다.

Runnable 인터페이스

  • 자바가 제공하는 스레드 실행용 인터페이스
 package java.lang;

public interface Runnable {
  void run();
}
public class HelloRunnable implements Runnable {   

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

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

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

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

실행 결과는 Thread 객체만으로 스레드 생성한 결과와 동일합니다. 차이가 있다면 스레드와 해당 스레드가 실행할 작업이 서로 분리되어 있다는 점입니다. 스레드 객체를 생성할 때, 실행할 작업을 생성자로 전달하면 됩니다.

Thread 상속 vs Runnable 구현

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

Thread 클래스 상속 방식

장점

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

단점

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

Runnable 인터페이스 구현 방식

장점

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

단점

  • 코드가 약간 복잡해질 수 있습니다.
  • Runnable 객체를 생성하고 이를 Thread 에 전달하는 과정이 추가됩니다.
profile
🤔오늘의 복습은❓

0개의 댓글