Java 멀티스레드 심층 분석: 생성부터 고급 동기화까지

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

프로그램(Program)은 명령어와 데이터의 정적인 집합체로, 실행 중이 아닌 상태를 의미합니다.
프로세스(Process)는 프로그램이 실행 중인 동적인 상태로, 시스템 자원 할당의 기본 단위입니다.
스레드(Thread)는 프로세스 내에서 실행되는 더 작은 단위이며, 모든 프로세스는 최소 하나의 스레드를 가집니다. CPU 스케줄링의 실제 대상입니다.

진정한 멀티스레딩은 멀티코어 CPU 환경에서 각 코어가 동시에 여러 코드를 실행하는 것을 말합니다. 반면, 단일 코어에서 시뮬레이션된 멀티스레딩은 CPU가 매우 빠른 속도로 스레드를 전환하여 동시에 실행되는 것처럼 보이게 합니다.

  • 스레드는 독립적인 실행 경로입니다.
  • 프로그램 실행 중에는 개발자가 명시적으로 생성하지 않아도 메인 스레드, GC 스레드 등 여러 스레드가 존재합니다.
  • main() 메서드는 주 스레드(Main Thread)로, 프로그램의 진입점 역할을 하며 전체 실행 흐름을 시작합니다.
  • 멀티스레드 환경에서 스레드 실행 순서는 운영체제의 스케줄러에 의해 결정되므로, 개발자가 임의로 제어할 수 없습니다.
  • 여러 스레드가 동일한 자원에 접근하면 경쟁 조건(Race Condition)이 발생할 수 있어 동시성 제어가 필요합니다.
  • 스레드는 CPU 스케줄링 오버헤드, 동시성 제어 오버헤드 등 추가적인 비용을 발생시킵니다.
  • 각 스레드는 자신만의 작업 메모리(Working Memory)를 가지며, 메모리 관리가 잘못되면 데이터 불일치 문제가 발생할 수 있습니다.

2. 스레드 생성 방법

자바에서 스레드를 생성하는 세 가지 주요 방법이 있습니다.

방법 1: Thread 클래스 상속

  • Thread 클래스를 상속받는 사용자 정의 스레드 클래스를 생성합니다.
  • run() 메서드를 오버라이드하여 스레드가 수행할 작업을 정의합니다.
  • 스레드 객체를 생성하고 start() 메서드를 호출하여 스레드를 시작합니다.
public class CustomThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println("서브 스레드: " + i);
        }
    }
    
    public static void main(String[] args) {
        CustomThread t = new CustomThread();
        t.start();
        
        for (int i = 0; i < 500; i++) {
            System.out.println("메인 스레드: " + i);
        }
    }
}

스레드가 즉시 실행되지 않고 CPU 스케줄러의 결정에 따라 실행됩니다.

방법 2: Runnable 인터페이스 구현

  • Runnable 인터페이스를 구현하는 클래스를 생성합니다.
  • run() 메서드를 구현하여 스레드가 수행할 작업을 정의합니다.
  • Thread 객체를 생성할 때 Runnable 구현체를 전달하고 start()를 호출합니다.
public class MyTask implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println("작업 스레드: " + i);
        }
    }
    
    public static void main(String[] args) {
        MyTask task = new MyTask();
        new Thread(task).start();
        
        for (int i = 0; i < 500; i++) {
            System.out.println("메인 스레드: " + i);
        }
    }
}

자바는 단일 상속만 지원하기 때문에 Runnable 인터페이스를 구현하는 방식이 더 유연하며 권장됩니다.

방법 3: Callable 인터페이스 구현

  • Callable<V> 인터페이스를 구현하고, call() 메서드에서 반환값을 정의합니다.
  • ExecutorService를 사용하여 스레드 풀을 생성하고 작업을 제출합니다.
  • Future<V> 객체를 통해 작업 결과를 비동기적으로 가져옵니다.
import java.util.concurrent.*;

public class DataProcessor implements Callable<Boolean> {
    @Override
    public Boolean call() throws Exception {
        for (int i = 0; i < 100; i++) {
            System.out.println(Thread.currentThread().getName() + ": " + i);
        }
        return true;
    }
    
    public static void main(String[] args) {
        DataProcessor d1 = new DataProcessor();
        DataProcessor d2 = new DataProcessor();
        DataProcessor d3 = new DataProcessor();
        
        ExecutorService executor = Executors.newFixedThreadPool(3);
        Future<Boolean> f1 = executor.submit(d1);
        Future<Boolean> f2 = executor.submit(d2);
        Future<Boolean> f3 = executor.submit(d3);
        
        try {
            boolean r1 = f1.get();
            boolean r2 = f2.get();
            boolean r3 = f3.get();
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
        
        executor.shutdownNow();
    }
}

3. Lambda 표현식 (Java 8+)

Lambda 표현식은 함수형 프로그래밍 개념을 도입하여 익명 내부 클래스의 과도한 사용을 줄입니다.

함수형 인터페이스(Functional Interface): 단 하나의 추상 메서드를 가진 인터페이스를 의미합니다. Lambda 표현식을 사용하여 이러한 인터페이스의 객체를 간결하게 생성할 수 있습니다.

@FunctionalInterface
interface MyFunction {
    void execute();
}

public class LambdaDemo {
    static class InnerImpl implements MyFunction {
        @Override
        public void execute() {
            System.out.println("내부 정적 클래스");
        }
    }
    
    public static void main(String[] args) {
        // 익명 내부 클래스
        MyFunction f1 = new MyFunction() {
            @Override
            public void execute() {
                System.out.println("익명 클래스");
            }
        };
        f1.execute();
        
        // Lambda 표현식 (기본)
        MyFunction f2 = () -> {
            System.out.println("Lambda 기본");
        };
        f2.execute();
        
        // Lambda 표현식 (한 줄 간소화)
        MyFunction f3 = () -> System.out.println("Lambda 간소화");
        f3.execute();
    }
}

4. 스레드 생명주기

스레드는 Thread.State 열거형으로 정의된 여러 상태를 거칩니다.

5. 스레드 정지

JDK의 stop(), destroy() 메서드는 사용을 권장하지 않습니다. 대신 플래그 변수를 사용하여 스레드를 안전하게 종료하는 것이 좋습니다.

public class StoppableTask implements Runnable {
    private volatile boolean running = true;
    
    public void shutdown() {
        this.running = false;
    }
    
    @Override
    public void run() {
        int count = 0;
        while (running) {
            System.out.println("실행 중: " + count++);
        }
    }
    
    public static void main(String[] args) {
        StoppableTask task = new StoppableTask();
        new Thread(task).start();
        
        for (int i = 0; i < 500; i++) {
            if (i == 200) {
                task.shutdown();
                System.out.println("스레드 종료 요청");
            }
            System.out.println("메인: " + i);
        }
    }
}

6. 스레드 수면 sleep()

  • sleep(long millis)는 현재 실행 중인 스레드를 지정된 밀리초 동안 일시 중단합니다.
  • InterruptedException 예외를 던질 수 있습니다.
  • 수면 시간이 끝나면 스레드는 RUNNABLE 상태로 돌아갑니다.
  • 네트워크 지연 시뮬레이션, 카운트다운 등에 유용합니다.
  • sleep()은 락을 해제하지 않습니다.

7. 스레드 양보 yield()

  • 현재 실행 중인 스레드를 RUNNABLE 상태로 되돌리고, CPU 스케줄러가 다른 스레드를 선택하도록 힌트를 줍니다.
  • 스레드를 BLOCKED 상태로 만들지 않습니다.
  • 양보가 항상 성공하는 것은 아니며, CPU 스케줄러의 결정에 따라 달라집니다.

8. 스레드 강제 실행 join()

join() 메서드는 호출한 스레드가 종료될 때까지 현재 스레드를 일시 중단합니다. 즉, 스레드가 실행 순서를 강제로 끼어드는 것과 같습니다.

9. 스레드 상태 모니터링

Thread.State state = thread.getState();를 통해 스레드의 현재 상태를 확인할 수 있습니다.

  • NEW: 아직 시작되지 않은 스레드
  • RUNNABLE: JVM에서 실행 중인 스레드
  • BLOCKED: 모니터 락을 기다리며 차단된 스레드
  • WAITING: 다른 스레드의 특정 작업을 무기한 기다리는 스레드
  • TIMED_WAITING: 지정된 대기 시간까지 다른 스레드의 작업을 기다리는 스레드
  • TERMINATED: 실행을 마치고 종료된 스레드

10. 스레드 우선순위

자바 스레드 스케줄러는 우선순위(1~10)에 따라 스레드를 스케줄링합니다. 높은 우선순위가 항상 먼저 실행되는 것은 아니며, CPU 스케줄링 정책에 따라 달라집니다.

  • Thread.MIN_PRIORITY = 1
  • Thread.MAX_PRIORITY = 10
  • Thread.NORM_PRIORITY = 5

11. 데몬 스레드 setDaemon()

  • 사용자 스레드(User Thread): JVM이 종료될 때까지 완료될 때까지 기다립니다.
  • 데몬 스레드(Daemon Thread): JVM이 종료될 때 완료 여부와 관계없이 종료됩니다.
  • 백그라운드 작업(로깅, 메모리 모니터링, GC)에 사용됩니다.

12. 스레드 동기화

동시성(Concurrency): 여러 스레드가 동시에 동일한 객체에 접근하는 현상입니다.

스레드 동기화: 여러 스레드가 공유 자원에 접근할 때 데이터 일관성을 보장하기 위한 메커니즘입니다. synchronized 키워드를 사용하여 락을 걸고, 하나의 스레드만 자원에 접근하도록 제어합니다.

13. 동기화 메서드

  • synchronized 메서드는 객체 수준의 락을 사용합니다. 메서드가 호출되면 객체의 락을 획득하고, 메서드가 종료될 때까지 락을 유지합니다.
  • 큰 메서드에 synchronized를 사용하면 성능 저하가 발생할 수 있습니다.
public class TicketSystem implements Runnable {
    private int tickets = 10;
    private boolean active = true;
    
    @Override
    public void run() {
        while (active) {
            try {
                purchase();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
    
    private synchronized void purchase() throws InterruptedException {
        if (tickets <= 0) {
            active = false;
            return;
        }
        Thread.sleep(50);
        System.out.println(Thread.currentThread().getName() + " 구매 완료, 남은 티켓: " + tickets--);
    }
    
    public static void main(String[] args) {
        TicketSystem system = new TicketSystem();
        new Thread(system, "User1").start();
        new Thread(system, "User2").start();
        new Thread(system, "User3").start();
    }
}

14. 동기화 블록

  • synchronized (obj) { ... } 형태로 사용하며, obj는 동기화 대상 객체입니다.
  • 동기화 메서드의 this와 달리, 동기화 블록은 어떤 객체든 락의 대상으로 지정할 수 있습니다.
  • 공유 자원 자체를 동기화 객체로 사용하는 것이 일반적입니다.
public class SafeListExample {
    public static void main(String[] args) {
        java.util.List<String> sharedList = new java.util.ArrayList<>();
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                synchronized (sharedList) {
                    sharedList.add(Thread.currentThread().getName());
                }
            }).start();
        }
        try { Thread.sleep(300); } catch (InterruptedException e) { throw new RuntimeException(e); }
        System.out.println("리스트 크기: " + sharedList.size());
    }
}

java.util.concurrent.CopyOnWriteArrayList는 JUC 패키지의 스레드 안전 컬렉션입니다.

15. 교착 상태(Deadlock)

두 개 이상의 스레드가 서로의 자원을 기다리며 무한히 대기하는 상태입니다. 교착 상태를 방지하려면 다음 네 가지 조건 중 하나를 깨야 합니다:

  1. 상호 배제(Mutual Exclusion): 자원은 한 번에 하나의 스레드만 사용할 수 있습니다.
  2. 점유와 대기(Hold and Wait): 스레드가 자원을 보유한 상태에서 다른 자원을 기다립니다.
  3. 비선점(No Preemption): 스레드가 자원을 강제로 빼앗길 수 없습니다.
  4. 순환 대기(Circular Wait): 두 개 이상의 스레드가 순환적으로 자원을 기다립니다.

16. Lock 인터페이스

Java 5부터 java.util.concurrent.locks.Lock 인터페이스가 도입되어 명시적인 동기화를 제공합니다. ReentrantLock이 가장 일반적인 구현체입니다.

import java.util.concurrent.locks.ReentrantLock;

class SafeCounter {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;
    
    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
    
    public int getCount() { return count; }
}

17. Lock vs synchronized

특징synchronizedLock
명시성암시적 (블록/메서드 종료 시 자동 해제)명시적 (직접 lock/unlock 호출)
적용 범위메서드, 코드 블록코드 블록만 가능
성능상대적으로 더 많은 오버헤드더 나은 성능과 확장성
권장 사용 순서3순위 (동기화 메서드)1순위 (Lock)

18. 스레드 협력 (생산자-소비자 패턴)

생산자와 소비자가 동일한 자원을 공유하며 서로의 상태에 의존합니다. wait(), notify(), notifyAll() 메서드를 사용하여 스레드 간 통신을 구현합니다.

방식 1: 모니터(Monitor) 방식

버퍼(공유 큐)를 통해 생산자와 소비자를 중개합니다.

import java.util.LinkedList;
import java.util.Queue;

class SharedBuffer {
    private Queue<Integer> buffer = new LinkedList<>();
    private int capacity = 10;
    
    public synchronized void produce(int value) throws InterruptedException {
        while (buffer.size() == capacity) {
            wait();
        }
        buffer.add(value);
        System.out.println("생산: " + value + ", 버퍼 크기: " + buffer.size());
        notifyAll();
    }
    
    public synchronized int consume() throws InterruptedException {
        while (buffer.isEmpty()) {
            wait();
        }
        int value = buffer.poll();
        System.out.println("소비: " + value + ", 버퍼 크기: " + buffer.size());
        notifyAll();
        return value;
    }
}

방식 2: 신호등(Semaphore) 방식

플래그 변수를 사용하여 생산자와 소비자의 상태를 제어합니다.

19. 스레드 풀 (Thread Pool)

스레드를 빈번하게 생성/파괴하는 대신, 미리 생성된 스레드 풀에서 스레드를 재사용하여 성능을 향상시킵니다.

  • corePoolSize: 기본 스레드 수
  • maximumPoolSize: 최대 스레드 수
  • keepAliveTime: 유휴 스레드가 종료되기까지 대기 시간
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolDemo {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(5);
        
        for (int i = 0; i < 10; i++) {
            executor.execute(() -> {
                System.out.println(Thread.currentThread().getName() + " 작업 실행");
            });
        }
        
        executor.shutdown();
    }
}

태그: java Multi-threading concurrency Runnable Callable

6월 7일 18:19에 게시됨