Java 멀티스레딩 기초와 동시성 제어 핵심 요약

멀티스레드의 이해와 구현 방법

멀티스레딩은 하나의 프로세스 내에서 여러 실행 흐름을 동시에 처리하는 기술입니다. Java에서 스레드를 생성하고 실행하는 방법은 크게 세 가지로 나뉩니다.

1. Thread 클래스 상속

가장 기본적인 방법으로, Thread 클래스를 상속받아 run() 메서드를 오버라이드합니다.

public class WorkerThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 50; i++) {
            System.out.println(getName() + " - 작업 중: " + i);
        }
    }
}

public class ThreadMain {
    public static void main(String[] args) {
        WorkerThread t1 = new WorkerThread();
        WorkerThread t2 = new WorkerThread();

        t1.setName("프로세서-A");
        t2.setName("프로세서-B");

        t1.start();
        t2.start();
    }
}

2. Runnable 인터페이스 구현

다중 상속이 불가능한 Java의 특성상 가장 권장되는 방식입니다. 작업 내용과 스레드 실행 객체를 분리할 수 있습니다.

public class TaskRunner implements Runnable {
    @Override
    public void run() {
        String currentName = Thread.currentThread().getName();
        for (int i = 0; i < 50; i++) {
            System.out.println(currentName + " 실행 단계: " + i);
        }
    }
}

public class RunnableMain {
    public static void main(String[] args) {
        TaskRunner task = new TaskRunner();
        
        Thread thread1 = new Thread(task, "스레드-1");
        Thread thread2 = new Thread(task, "스레드-2");

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

3. Callable 및 Future 활용

스레드 실행 결과(리턴값)를 받아야 하거나 예외 처리가 필요할 때 사용합니다.

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class SumTask implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        int total = 0;
        for (int i = 1; i <= 100; i++) {
            total += i;
        }
        return total;
    }
}

public class CallableMain {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        SumTask task = new SumTask();
        FutureTask<Integer> futureTask = new FutureTask<>(task);
        
        Thread t = new Thread(futureTask);
        t.start();

        // 결과 대기 및 출력
        Integer result = futureTask.get();
        System.out.println("계산 결과: " + result);
    }
}

주요 스레드 제어 메서드

  • setName(String name): 스레드에 식별 가능한 이름을 부여합니다.
  • currentThread(): 현재 실행 중인 스레드 객체를 참조합니다.
  • sleep(long ms): 지정된 밀리초 동안 현재 스레드를 일시 정지시킵니다.
  • setDaemon(boolean on): 스레드를 데몬 스레드로 설정합니다. 주 스레드가 종료되면 데몬 스레드도 자동 종료됩니다.
  • join(): 특정 스레드가 종료될 때까지 현재 스레드를 대기시킵니다.
  • yield(): 다른 스레드에게 CPU 실행 권한을 양보합니다.

스레드 안전(Thread Safety)과 동기화

여러 스레드가 공유 자원에 동시에 접근할 때 데이터 정합성 문제가 발생할 수 있습니다. 이를 해결하기 위해 동기화 메커니즘을 사용합니다.

1. synchronized 코드 블록 및 메서드

public class InventoryManager implements Runnable {
    private int stock = 100;

    @Override
    public void run() {
        while (true) {
            synchronized (InventoryManager.class) {
                if (stock > 0) {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    stock--;
                    System.out.println(Thread.currentThread().getName() + " 출고 완료. 남은 재고: " + stock);
                } else {
                    break;
                }
            }
        }
    }
}

2. Lock 인터페이스 (ReentrantLock)

보다 세밀한 제어가 가능한 명시적 잠금 방식입니다. 반드시 finally 블록에서 unlock()을 호출해야 합니다.

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class SecureCounter extends Thread {
    private static int count = 0;
    private static final Lock lock = new ReentrantLock();

    @Override
    public void run() {
        while (true) {
            lock.lock();
            try {
                if (count < 100) {
                    Thread.sleep(10);
                    count++;
                    System.out.println(getName() + " 카운트: " + count);
                } else {
                    break;
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }
    }
}

대기 및 호출 메커니즘 (Producer-Consumer)

스레드 간 효율적인 통신을 위해 wait()notifyAll()을 사용하거나, BlockingQueue를 활용할 수 있습니다.

BlockingQueue를 활용한 구현

ArrayBlockingQueue를 사용하면 명시적인 동기화 코드 없이도 안전한 데이터 전달이 가능합니다.

import java.util.concurrent.ArrayBlockingQueue;

public class Producer extends Thread {
    private ArrayBlockingQueue<String> queue;

    public Producer(ArrayBlockingQueue<String> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        while (true) {
            try {
                queue.put("데이터");
                System.out.println("생성자가 데이터를 생성하여 큐에 넣었습니다.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class Consumer extends Thread {
    private ArrayBlockingQueue<String> queue;

    public Consumer(ArrayBlockingQueue<String> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        while (true) {
            try {
                String data = queue.take();
                System.out.println("소비자가 '" + data + "'를 처리했습니다.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

스레드 풀과 성능 최적화

스레드 생성과 소멸에 따르는 오버헤드를 줄이기 위해 스레드 풀을 사용합니다. 시스템의 자원을 효율적으로 관리하기 위해 최대 병렬 처리 가능 수를 확인하는 것이 중요합니다.

// 현재 시스템의 사용 가능한 프로세서 수 확인
int coreCount = Runtime.getRuntime().availableProcessors();
System.out.println("활성 코어 수: " + coreCount);

스레드 풀의 적정 크기는 CPU 집약적인 작업인지, I/O 집약적인 작업인지에 따라 달라집니다. CPU 집약적 작업은 코어 수 + 1 내외, I/O 집약적 작업은 코어 수의 수 배까지 설정하는 것이 일반적입니다.

태그: java Multithreading JUC concurrency ThreadSafety

7월 23일 09:30에 게시됨