14장 Thread

1. 스레드?

프로그램 vs 프로세스 vs 쓰레드

  • 프로그램 : 프로세스를 실행할 수 있는 파일로, cpu를 할당 받기 전의 상태를 의미한다.

  • 프로세스 : 실행 중인 프로그램으로 작업을 위한 cpu 자원을 할당 받은 상태. 각 프로세스는 독립된 메모리 공간을 가짐.

  • 스레드: 프로세스 내에서 실제로 작업을 수행하는 실행 단위. 여러 쓰레드가 하나의 메모리 공간(Heap)을 공유해서 사용 한다. 중심 쓰레드를 main스레드라 부르며 , 그 외의 쓰레드를 서브스레드라고 부른다.

  • 모든 프로세스는 1개 이상의 쓰레드를 가지고 있다.

멀티쓰레드

  • 자바는 한번에 여러 개의 스레드를 실행할 수 있는 멀티쓰레드 환경을 지원한다.

  • 멀티스레드는 하나의 쓰레드가 해야할 작업을 여러개의 쓰레드가 분담하여 작업할 수 있으므로 작업 효율이 향상 된다.

  • 멀티쓰레드로 인해 사용자는 빠른 응답을 받을 수 있다.

  • 멀티쓰레드 환경에서는 자원 공유에 따른 동기화 문제가 발생하며, DeadLock상태가 발생할 수 있으므로 주의해야 한다.

쓰레드의 특징

  • 동시성 (Concurrency)

    • 여러 작업을 교대로 빠르게 처리하여 동시에 여러 작업이 실행되는 것 처럼 보이는 특징.

    • 실제로는 스케쥴러에 의해 실행할 쓰레드를 순간적으로 전환하며 실행하기 때문에 "동시"에 실행되지는 않는다.(싱글 코어 기준)

  • 병렬성 (Parallelism)

    • CPU코어가 여러개인 경우 CPU코어별로 작업내용이 할당 되므로, 코어의 개수만큼 실제로 여러 작업을 동시에 실행한다.

    • 즉, CPU코어가 여러 개라면 쓰레드가 실제로 동시에 여러 작업을 수행할 수 있게 되는 것.

  • 독립성(Isolation)

    • 하나의 스레드에서 발생한 에러는 다른 스레드에 영향을 끼치지 않는다.

    • 개별적인 실행을 보장하기 위해 스레드는 고유한 stack영역을 할당 받는다.


2. 쓰레드 생성 방법

1. Thread 클래스 상속

class MyThread extends Thread {
    public void run() {
        System.out.println("Thread 실행 중...");
    }
}

MyThread t = new MyThread();
t.start();  // 반드시 start()로 실행해야 run()이 별도 쓰레드에서 실행됨
// start() 실행시 쓰레드 생성. 쓰레드가 생성되면 내부의 run()메서드가 실행.

2. Runnable 인터페이스 구현

class MyRunnable implements Runnable {
    public void run() {
        System.out.println("Runnable 실행 중...");
    }
}

Thread t = new Thread(new MyRunnable());
t.start();

3. 람다 표현식 사용

Thread t = new Thread(() -> {
    System.out.println("람다 쓰레드 실행");
});
t.start();

3. 스레드 스케줄링

스레드는 OS에 의해 스케줄링되며, 작업 순서를 개발자가 완전히 제어할 수는 없지만, 우선순위를 설정하거나 데몬 여부(종속설정)를 조절하여 실행 흐름에 관여할 수 있습니다.

1. 우선순위 기반 스케줄링

  • 자바에서 Thread.setPriority(int) 메서드를 통해 우선순위를 설정할 수 있습니다.

  • 우선순위는 1 (MIN_PRIORITY) ~ 10 (MAX_PRIORITY) 사이의 값이며 기본값은 5입니다.

단, 우선순위가 높다고 먼저 실행된다는 보장은 없습니다. JVM은 스케줄링을 운영체제에 위임하기 때문에 결과는 OS마다 다르게 나올 수 있습니다.

2. Round-Robin 방식 (순환 할당)

  • 운영체제가 각 스레드에 동일한 시간만큼 CPU를 할당하는 기본 스케쥴링 방식입니다.

  • 자바의 JVM은 운영체제의 스레드 스케줄러에 위임하므로 Round-Robin은 운영체제의 스케쥴링 방식이라고 볼 수 있습니다.

  • 자바 코드로는 제어할 수 없으며, 같은 우선순위 스레드들 사이에 적용됩니다.

  • 우선순위에서 같은 값을 가진 스레드 사이에서는 Round-Robin이 적용됩니다.

3. 데몬 스레드

  • setDaemon(true) 설정 시 메인 스레드가 종료되면 자동으로 같이 종료됩니다.

  • 백그라운드 작업(로그 저장, 자동 저장 등)에 자주 사용됩니다.


4. 동기화(Synchronization)

멀티스레드 환경에서는 여러 스레드가 동시에 하나의 공유 자원(Heap영역의 객체 등)에 접근할 수 있습니다. 이 상태를 경쟁상태(race condition) 라고 부릅니다. 경쟁상태에서는 데이터 충돌이나 예기치 못한 결과가 발생할 수 있습니다. 이 문제를 방지하기 위해서는 경쟁상태의 자원을 통제하기 위한 통제영역(임계영역)을 지정하기 위해 사용하는 것이 "동기화(synchronization)"입니다.

synchronized

  • synchronized 키워드를 사용하여 특정 메서드나 블록을 한 번에 하나의 스레드만 접근할 수 있도록 제한합니다.

  • synchronized예약어로 여러 스레드가 동시에 접근해서는 안 되는 메서드를 임계 영역 (Critical Section)을 지정한다.

  • 예시코드:

public class Account {
    private int balance = 1000;
    
    // 출금 메서드는 한번에 하나의 스레드만 사용 가능.
    public synchronized void withdraw(int money) {
        if (balance >= money) {
            balance -= money;
            System.out.println(Thread.currentThread().getName() + " 출금 후 잔액: " + balance);
        } else {
            System.out.println(Thread.currentThread().getName() + " 출금 실패 - 잔액 부족");
        }
    }
}
  • 위 예제에서 withdraw 메서드는 임계 영역이므로 synchronized를 사용하여 한 번에 하나의 스레드만 접근하게 합니다.

동기화 비유

  • 여러분들이 화장실을 이용한다고 가정해봅시다. 화장실은 칸당 1명만 들어갈 수 있는 구조로, 누군가 화장실에 들어가면 문을 잠그고(Lock) 사용이 끝나면 문을 열고(UnLock)나옵니다. 이때 화장실 밖에는 화장실칸에 들어가려는 사람들이 순서에 맞춰 대기중일 겁니다.

  • 코딩적 관점에서 보면 화장실 칸은 공유자원으로, 이를 이용하는 사람은 스레드입니다. 화장실을 이용할 때는 한번에 한명의 사람이 이용하듯, 동기화 처리가 완료된 공유자원은 한번에 한개의 스레드만 접근할 수 있습니다. 이때 공유자원을 사용 중 인 스레드는 Lock을 얻고, 사용 완료한 스레드는 UnLock후 다음 스레드가 Lock을 획득합니다.

  • 이 과정을 락(Lock) 메커니즘이라고 부릅니다. 자바에서는 동기화(synchronized )를 통해 락 메커니즘을 구현할 수 있습니다.

동기화의 단점

  • 모든 메서드에 synchronized를 걸면 병목 현상이 발생할 수 있습니다.

  • 병목현상 : 하나의 공유자원에 너무 많은 스레드가 몰려 , 전체 시스템 성능이 하향되는 현상

    • 8차선 도로로 주행하던 차들이 1차선 도로로 주행해야 하는 경우를 생각하시면 됩니다.

  • 스레드 수가 많아질수록 전체 시스템성능이 하향될 수 있으므로 , 반드시 필요한 부분에만 synchronized를 걸어야 합니다.

데드락

  • 두개 이상의 스레드가 서로 락을 얻기 위해 무한으로 대기하는 현상

  • 데드락 예시 코드

public class DeadLockRun {
    // 공유 자원 1
    private static final Buffer buffer1 = new Buffer();
    // 공유 자원 2
    private static final Buffer buffer2 = new Buffer();

    public static void main(String[] args) {
        // 첫 번째 스레드: buffer1 먼저 획득하고 buffer2를 기다림
        Thread thread1 = new Thread(() -> {
            synchronized (buffer1) {
                System.out.println("Thread1 : buffer1에 대한 key 획득");
                synchronized (buffer2) {
                    System.out.println("Thread 1: buffer2에 대한 key 획득");
                }
            }
        });

        // 두 번째 스레드: buffer2를 먼저 획득하고 buffer1d을 기다림
        Thread thread2 = new Thread(() -> {
            synchronized (buffer2) {
                System.out.println("Thread 2: buffer2에 대한 key 획득");
                synchronized (buffer1) {
                    System.out.println("Thread 2: buffer1에 대한 key 획득");
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

5. 쓰레드의 생명 주기 (Life Cycle)와 제어메서드

생명주기

  1. NEW: new Thread()생성만 된 상태

  2. RUNNABLE: start() 호출 후 운영체제에 의해 실행 가능 상태

  3. RUNNING: 실제로 CPU를 점유하고 실행 중인 상태

  4. BLOCKED / WAITING / TIMED_WAITING: 일시 정지된 상태 (sleep, wait, join 등)

    1. BLOCKED ⇒ 동기화 블럭에서 스레드가 Lock을 얻기 위해 대기하는 상태.

  5. TERMINATED: 실행이 종료된 상태

스레드 상태 제어 메서드

  • sleep(ms): 지정 시간 동안 일시 정지 (예: Thread.sleep(1000))

    • 스레드는 RUNNING -> TIME_WAITING->RUNNABLE 상태로 변경

  • join(): 특정 스레드가 끝날 때까지 대기(일시정지)

    • RUNNING -> (WAITING/TIME_WAITING)

  • interrupt(): 일시정지 상태의 스레드를 깨움 (InterruptedException 발생)

    • RUNNING -> TIME_WAITING -> RUNNABLE

  • wait() : 동기화 블록(synchronized)에서 사용되는 스레드 간 통신으로 스레드를 대기상태로 변경

    • RUNNING -> WAITING

  • notify() / notifyAll(): 동기화 블록에서 사용되는 스레드 간 통신으로 WAITING상태의 쓰레드를 깨움

    • WAITING -> RUNNABLE


6. 스레드 풀

  • 스레드를 필요할때마다 생성하는 것이 아닌, 미리 여러개의 스레드를 생성하여 보관해두고, 재사용하는 스레드 저장공간을 스레드 풀이라고 부른다.

  • 스레드가 생성/소멸할때 발생하는 자원의 낭비를 줄일 수 있으며, 사용자의 스레드 요청시 생성 없이 만들어둔 스레드를 즉시 반환할 수 있으므로 빠른 응답이 가능해진다.

    • 치킨가게에서 메뉴주문시 조리된 치킨을 즉시 주는 것과, 주문이 들어간 후 치킨을 만들어서 주는 것의 차이.

  • 사용자의 요청이 들어올때 마다 스레드를 계속 생성한다면, 과도하게 많은 스레드가 생성될 수 있으나, 미리 생성할 총 스레드의 갯수를 정해두고 미리 생성한다면 이러한 현상을 방지할 수 있음.

Last updated