[Java] Thread

하비·2026년 3월 8일

Java

목록 보기
14/14

Java Thread에 대해 알아보도록 하겠습니다.

1. 프로그램, 프로세스, 그리고 스레드

  • 프로그램: 하드디스크에 저장된 실행 파일
  • 프로세스: 프로그램을 실행하여 메모리에 적재된 상태를 프로세스라고 한다.
  • 스레드: 프로세스라는 작업 공간에서 실질적으로 작업을 수행하는 '일꾼'이다.
  • 멀티 스레딩: 하나의 프로세스 내에 여러 스레드가 존재하여 작업을 동시에 처리하는 구조이다. 이는 자원을 효율적으로 사용하고 사용자 응답성을 높이지만, 자원 공유로 인한 충돌 가능성이 있다.

2. 프로세스와 스레드의 위치

1) 프로세스의 위치: 메인 메모리 (RAM)

  • 프로그램에서 프로세스로: 하드디스크나 SSD와 같은 저장 장치에 파일 형태로 있던 프로그램이 실행되면, 실행에 필요한 데이터와 파일들이 메모리(RAM)로 로딩된다.
  • 실행 중인 상태: 이렇게 메모리에 적재되어 CPU에 의해 처리되고 있는 상태의 프로그램을 프로세스라고 부른다. 즉, 프로세스는 메모리상에서 CPU의 자원을 할당받아 존재하는 작업의 단위이다.

2) 스레드의 위치: 프로세스 내부 및 전용 호출 스택

  • 프로세스 내부 존재: 스레드는 프로세스라는 작업 환경(공장) 내에서 실제로 일을 하는 일꾼으로, 프로세스의 메모리 영역 안에 위치한다.
  • 독립적인 호출 스택(Call Stack): 자바에서 스레드가 start() 메서드를 통해 실행되면, JVM은 메모리 내에 해당 스레드만을 위한 독립적인 호출 스택을 새로 생성한다. 이 스택은 각 스레드가 다른 작업 흐름과 섞이지 않고 자신만의 지역 변수와 메서드 호출 정보를 보관하는 전용 공간이다.
  • 자원 공유 영역 (Heap 등): 스레드들은 프로세스 내에 위치하므로, 해당 프로세스에 할당된 힙(Heap) 메모리와 같은 공용 자원을 공유한다. 인스턴스 변수(IV)와 같은 공유 데이터는 이 공용 영역에 위치하여 여러 스레드가 동시에 접근할 수 있다.

3. 스레드의 생성 및 실행 메커니즘

Thread 클래스를 상속받거나 Runnable 인터페이스를 구현하는 두 가지 방법이 있다.

1) Thread를 상속 받는 방법

Thread 클래스를 상속받아 새로운 클래스를 만들고, run() 메서드를 오버라이딩하여 스레드가 수행할 작업을 정의한다.

// 1. Thread 클래스 상속
class MyThread extends Thread {
    @Override
    public void run() {
        // 스레드가 수행할 작업 정의
        for (int i = 0; i < 5; i++) {
            // 현재 실행 중인 스레드의 이름을 가져옴
            System.out.println(getName() + " 실행 중: " + i);
            try {
                // 0.2초간 일시 정지
                Thread.sleep(200); 
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

// 실행 코드
public class Main {
    public static void main(String[] args) {
        MyThread t1 = new MyThread();
        // start()를 호출해야 새로운 호출 스택이 생성됨
        t1.start(); 
    }
}

2) Runnable 인터페이스를 구현하는 방법

자바는 다중 상속을 지원하지 않기 때문에, 다른 클래스를 상속받아야 하는 경우 이 방법을 주로 사용한다. 인터페이스를 구현한 객체를 Thread 생성자의 매개변수로 전달해야 한다.

// 1. Runnable 인터페이스 구현
class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            // Runnable 구현 시 Thread.currentThread()로 접근
            System.out.println(Thread.currentThread().getName() + " 작업 중");
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {}
        }
    }
}

// 실행 코드
public class Main {
    public static void main(String[] args) {
        // Runnable 객체 생성
        Runnable r = new MyRunnable();
        // Thread 생성자에 전달
        Thread t2 = new Thread(r);
        t2.start();
    }
}

3) 익명 이너 클래스를 활용한 간결한 생성

별도의 클래스 파일을 만들지 않고, 소스 코드 내에서 즉석으로 스레드를 생성할 때 많이 사용되는 방식이다.

Thread t3 = new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("익명 클래스로 생성된 스레드 실행");
    }
});
t3.start();

참고

자바 8, 11, 그리고 최신 버전으로 올수록 단순한 new Thread().start() 방식보다는 쓰레드 풀(ThreadPool)을 관리하는 ExecutorService를 사용하는 것이 권장된다.
Callable과 Runnable에 대한 차이 참고 블로그


4. start() vs run()

스레드를 실행할 때는 반드시 start() 메서드를 호출해야 한다. start()는 내부적으로 새로운 호출 스택(Call Stack)을 생성하여 독립적인 작업 환경을 구축한 뒤 그 위에서 run()을 실행시킨다. run()을 직접 호출하면 새로운 스레드가 만들어지지 않고 기존 스택에서 일반 메서드처럼 동작한다.

1) run() 메서드를 직접 호출할 경우 (일반 메서드 호출)

  • run()을 직접 호출하면 새로운 스레드가 생성되지 않고, 현재 코드를 실행 중인 스레드(주로 메인 스레드)의 호출 스택 위에서 단순히 메서드 내용이 실행된다.
class MyThread extends Thread {
    public void run() {
        // 현재 실행 중인 스레드의 이름을 출력
        System.out.println(Thread.currentThread().getName() + "가 run()을 실행 중");
    }
}

public class Main {
    public static void main(String[] args) {
        MyThread t1 = new MyThread();
        t1.run(); // start()가 아닌 run()을 직접 호출
    }
}
  • 결과: 콘솔에 main가 run()을 실행 중이라고 출력된다.
  • 원인: 새로운 호출 스택이 만들어지지 않았기 때문에, 메인 스레드의 스택 위에서 run() 메서드가 올라가 실행된 것이다. 이는 멀티 스레딩이 아닌 일반적인 객체의 메서드 호출과 동일하다.

2) start() 메서드를 호출할 경우 (멀티 스레딩 시작)

  • start()를 호출하면 JVM은 내부적으로 새로운 호출 스택을 생성하고, 그 독립적인 공간 위에 run() 메서드를 올려 실행시킨다. 하나의 스레드 객체에 대해 start()단 한 번만 호출 가능하다. 다시 실행하려면 새 객체를 만들어야 한다.
public class Main {
    public static void main(String[] args) {
        MyThread t1 = new MyThread();
        t1.start(); // start() 호출
        System.out.println(Thread.currentThread().getName() + "는 자기 할 일 계속함");
    }
}
  • 결과: 콘솔에 Thread-0가 run()을 실행 중main는 자기 할 일 계속함이 동시에(혹은 순차적으로) 출력된다.
  • 원인: start()는 스레드가 독립적으로 작업을 수행할 수 있도록 메모리를 할당받고 분가하는 과정을 거친다. 이 과정에서 새로운 호출 스택이 만들어졌기 때문에 메인 스레드와 별개로 작업이 진행될 수 있는 것이다.

참고

자바에서 start() 메서드가 호출 스택을 만드는 원리
1. 새로운 호출 스택 생성: start() 메서드를 호출하면 JVM은 해당 스레드만을 위한 독립적인 호출 스택(Call Stack)을 새로 생성한다. 이는 스레드가 다른 작업 흐름과 섞이지 않고 자신만의 전용 작업 공간을 갖게 됨을 의미한다.
2. 사전 준비 및 메모리 할당: 스레드가 CPU를 사용하기 위해서는 메모리 할당 등 독립적인 실행을 위한 사전 준비 과정이 필요하다. start() 메서드는 내부적으로 이러한 준비 작업을 수행하여 스레드가 CPU와 직접 통신할 수 있는 환경을 구축한다.
3. run() 메서드 배치 및 실행: 호출 스택이 성공적으로 생성되고 준비가 완료되면, JVM은 새로 만든 스택의 가장 위에 사용자가 정의한 run() 메서드를 올린다. 이때부터 비로소 스레드는 독립적인 일꾼으로서 자신의 로직을 수행하기 시작한다.
4. 독립적 실행 흐름 확보: 이 원리를 통해 새로운 스레드는 메인 스레드나 기존의 실행 흐름에서 벗어나 분가하는 것과 같은 독립성을 얻게 된다. 따라서 각 스레드는 자신만의 호출 스택 내에 있는 지역 변수 등을 사용하여 다른 스레드와 간섭 없이 작업을 진행할 수 있다.


5. 스레드의 주요 속성 5가지

1) 스레드 객체 참조 및 식별

스레드를 관리하기 위해서는 먼저 실행 중인 스레드 객체에 접근할 수 있어야 한다.

  • 현재 스레드 참조 (currentThread()): 정적 메서드인 Thread.currentThread()를 사용하면 현재 코드를 실행 중인 스레드의 참조값을 얻을 수 있다. 이는 개발자가 직접 생성하지 않은 메인 스레드의 정보를 가져오거나, 참조 변수가 없는 익명 스레드의 상태를 확인해야 할 때 필수적이다.
  • 스레드 이름 설정 및 조회 (setName, getName): 모든 스레드는 이름을 가진다. 이름을 직접 지정하지 않으면 컴파일러가 "Thread-0", "Thread-1"과 같이 숫자를 붙여 자동으로 명명한다. 스레드에 이름을 부여하는 것은 멀티 스레드 환경에서 어떤 일꾼이 어떤 작업을 수행하는지 식별하고 디버깅하는 데 매우 유용하다.

2) 실행 중인 스레드 수 확인 (activeCount)

  • 활성 스레드 체크: Thread.activeCount() 메서드는 현재 스레드가 속한 스레드 그룹 내에서 실행 중인 스레드의 개수를 반환한다.
  • 동적 변화: 스레드가 종료(Terminated)되면 카운트에서 제외되므로, 프로그램 실행 도중 작업의 진행 상황에 따라 이 수치는 계속 변하게 된다.

3) 스레드 우선순위 (Priority)

우선순위는 스레드가 CPU 자원을 얼마나 더 많이 할당받을지를 결정하는 척도이다.

  • 범위와 기본값: 우선순위는 1(최저)에서 10(최고) 사이의 값을 가지며, 기본값은 5이다. 자바에서는 MIN_PRIORITY(1), NORM_PRIORITY(5), MAX_PRIORITY(10)와 같은 상수를 제공한다.
  • 실행 원리: 우선순위가 높은 스레드는 낮은 스레드보다 더 많은 타임 슬라이스(Time Slice)를 할당받아 더 빨리 작업을 끝낼 확률이 높다. 다만, 이는 운영체제(OS)의 스케줄링 정책에 따라 달라질 수 있으므로 절대적인 실행 순서를 보장하지는 않는다.

4) 데몬 스레드 (Daemon Thread) 설정

스레드는 성격에 따라 일반 스레드와 데몬 스레드로 나뉜다.

  • 보조적 역할: 데몬 스레드는 주 작업 스레드(일반 스레드)의 작업을 돕는 보조적인 일꾼이다. 자동 저장 기능이나 가비지 컬렉터(GC) 등이 대표적인 예이다.
  • 자동 종료 조건: 데몬 스레드의 가장 큰 특징은 모든 일반 스레드가 종료되면 자신의 작업 완료 여부와 상관없이 JVM에 의해 자동으로 종료된다는 점이다.
  • 설정 방법: setDaemon(true)를 통해 설정하며, 반드시 스레드가 시작(start())되기 전에 지정해야 한다.

5) 자바 스레드 맥락에서의 의미

이러한 속성들은 자바가 복잡한 멀티 태스킹 환경을 제어하는 핵심 수단이다.

  • 효율적 자원 관리: 우선순위와 데몬 설정을 통해 시스템의 자원을 효율적으로 배분하고, 불필요한 스레드가 메모리를 점유하지 않도록 관리한다.
  • 응답성 및 일관성: 스레드 이름을 통한 모니터링과 활성 스레드 수 체크는 프로그램의 응답성을 높이고 안정적인 멀티 스레딩을 구현하는 기반이 된다.

결론적으로 자바 스레드 속성은 개별 스레드의 정체성(이름), 중요도(우선순위), 그리고 생사 여탈권(데몬 설정)을 정의함으로써, 개발자가 복잡한 병렬 처리 시스템을 정교하게 제어할 수 있게 돕는다.


6. 스레드의 상태와 실행 제어

1) 스레드 상태

  • 객체 생성 단계인 NEW
  • 실행(대기) 중인 RUNNABLE
  • 작업 종료인 TERMINATED
  • 일시 정지 상태인 WAITING, TIMED_WAITING, BLOCKED

실행 제어 메서드

  • sleep(): 지정된 시간 동안 현재 스레드를 일시 정지시킨다.
  • join(): 다른 스레드의 작업이 끝날 때까지 기다린다.
  • interrupt(): 일시 정지 중인 스레드를 깨워 실행 대기 상태로 만든다.
  • yield(): 자신의 실행 시간을 다른 스레드에게 양보한다.

7. 스레드 동기화 (Synchronization)

1) 필요성

여러 스레드가 하나의 객체(공유 자원)에 동시에 접근하여 데이터를 수정할 때 발생하는 데이터 오염을 방지하기 위해 필요하다.

2) 임계 영역 (Critical Section)

한 번에 하나의 스레드만 진입할 수 있도록 설정된 코드 영역이다. synchronized 키워드를 메서드나 블록에 사용하여 설정하며, 해당 객체의 락(Lock)을 획득한 스레드만 접근할 수 있다.

3) 효율적 관리 (wait, notify)

스레드가 당장 작업을 진행할 수 없을 때 wait()를 통해 락을 반납하고 대기실로 갔다가, 조건이 충족되면 notify()notifyAll()을 통해 다시 깨어나 작업을 재개함으로써 실행 효율을 높인다.


8. JVM이 자동으로 생성하는 스레드

JVM은 사용자가 직접 코딩하지 않아도 시스템 운영을 위해 필수적인 스레드들을 스스로 생성한다.

  • 메인 스레드: 자바 프로그램이 시작되면 JVM은 가장 먼저 메인 스레드를 생성하여 main() 메서드를 실행한다.
  • 시스템 스레드: 개발자가 직접 생성하지 않아도 JVM(자바 가상 머신)이 프로그램을 실행하고 관리하기 위해 내부적으로 자동 생성하여 운용하는 스레드이다.

참고

시스템 스레드가 활성화되거나 동작하는 시점

1) 프로그램 시작 시 (JVM 구동 시점)
자바 프로그램이 실행되면 JVM은 사용자가 별도의 코드를 작성하지 않아도 가장 먼저 메인 스레드(Main Thread)를 생성하여 main() 메서드를 실행한다. 이와 동시에 JVM은 환경을 유지하기 위해 자체적으로 여러 관리용 스레드들을 함께 돌리기 시작한다. 개발자가 인식하지 못할 뿐, 프로그램 시작과 동시에 이미 여러 시스템 스레드가 배경에서 작동하고 있다.
2) 메모리 관리가 필요한 시점 (가비지 컬렉터)
가장 대표적인 시스템 스레드는 가비지 컬렉터(GC, Garbage Collector)이다. 하지만 모든 GC 방식이 하나의 스레드로만 작동하는 것은 아니다. 최신 JVM에서는 여러 개의 스레드가 동시에 청소하는 Parallel GCG1 GC 등을 사용하므로, "GC 작업을 수행하는 스레드들은 데몬 스레드들이다"라고 이해하면 더 완벽하다.

  • 활성화 조건: 가비지 컬렉터는 메모리가 부족하거나 특정 사용량(예: 60% 이상 사용)에 도달했을 때 JVM에 의해 깨어나 활동을 시작한다.
  • 역할: 더 이상 사용되지 않는 메모리(쓰레기)를 정리하여 시스템이 원활하게 돌아가도록 돕는 '청소부' 역할을 수행한다.

3) 보조적인 작업이 필요한 시점 (데몬 스레드 형태)
많은 시스템 스레드는 데몬 스레드(Daemon Thread)의 속성을 가지고 동작한다.

  • 작동 시점: 주 작업(일반 스레드)이 진행되는 동안 배경에서 자동 저장, 화면 재생산, 가비지 컬렉션 등 보조적인 일을 처리해야 할 때마다 작동한다.
  • 종료 시점: 시스템 스레드는 오직 일반 스레드를 돕기 위해 존재하므로, 모든 일반 스레드가 종료되는 순간 자신의 작업 완료 여부와 상관없이 JVM에 의해 자동으로 함께 종료된다.

4) 운영체제(OS)의 스케줄링에 따름
시스템 스레드가 실제로 CPU를 점유하여 일을 하는 구체적인 타이밍은 OS의 스케줄러가 결정한다. OS는 여러 프로세스와 스레드 사이에서 공평하게 자원을 배분하며, 시스템 운영에 필요한 스레드들을 적절한 시점에 교체하며 실행시킨다.


9. JVM이 가비지 컬렉터(GC)를 실행할 때 스레드에 주는 영향과 관계

1) 보조적인 데몬 스레드로서의 동작

가비지 컬렉터는 자바에서 데몬 스레드(Daemon Thread)로 구현되어 동작한다. 데몬 스레드는 주 작업 스레드(일반 스레드)를 돕는 조수 역할을 하므로, 모든 일반 스레드가 종료되면 가비지 컬렉터 스레드도 자신의 작업 완료 여부와 상관없이 자동으로 함께 종료된다.

2) Stop-the-world (STW)

  • GC가 실행되면 JVM은 GC 스레드를 제외한 모든 애플리케이션 스레드를 일시 정지시킨다.
  • 이는 join()처럼 코드로 기다리는 게 아니라, JVM이 안전한 메모리 정리를 위해 강제로 멈추는 것이다. GC 작업이 끝나면 다시 스레드들을 움직이게 한다.
  • 이때 멈추는 스레드는 사용자가 만든 '애플리케이션 스레드'들이다. JVM 자체의 운영 스레드 중 일부는 멈추지 않을 수도 있다.

3) 성능 및 응답성 영향

가비지 컬렉션은 시스템 자원을 효율적으로 사용하기 위해 필수적이지만, 멀티 스레드 환경에서 적절히 제어되지 않으면 프로그램의 응답성에 영향을 줄 수 있다. 소스에 따르면 가비지 컬렉션과 같은 보조 작업이 너무 빈번하거나 적절한 시간 간격 없이 실행되면 메인 작업 스레드의 흐름이 방해받을 수 있다.

profile
멋진 개발자가 될테야

0개의 댓글