Java 멀티스레딩 소개

스레드는 프로세스 내에서 독립적으로 실행되는 하위 작업입니다.

멀티스레딩 기술 사용 시나리오

  • 블로킹(Blocking): 시스템에 블로킹 현상이 발생하면 멀티스레딩 기술을 사용하여 실행 효율을 높일 수 있습니다.
  • 종속성(Dependency): 비즈니스 로직이 두 개의 실행 과정 A와 B로 나뉘는데, A 과정이 블로킹될 때 B 과정은 A 과정의 실행 결과를 필요로 하지 않는다면, B 과정에 멀티스레딩 기술을 적용하여 실행 효율을 향상시킬 수 있습니다.

멀티스레딩은 비동기적으로 동작합니다. CPU는 시간 할당(Time Slice)을 여러 스레드에 분배하며, 스레드는 할당된 시간 동안 작업을 수행합니다. CPU가 스레드 간 전환 시 오버헤드가 발생하므로, 스레드 수가 많다고 반드시 성능이 향상되는 것은 아니며, 오히려 과도한 스레드 생성은 성능 저하를 유발할 수 있습니다.

멀티스레딩 구현 방법

Java에서 멀티스레딩을 구현하는 두 가지 주요 방법이 있습니다:

  1. Thread 클래스 상속
  2. Runnable 인터페이스 구현

Thread 클래스는 이미 Runnable 인터페이스를 구현하고 있습니다. Thread 클래스를 상속하여 스레드를 생성하는 경우, Java의 다중 상속 제약으로 인해 다른 클래스를 상속받는 데 제약이 따릅니다.

1. Thread 클래스 상속을 통한 스레드 생성

MyThread.java

package thread;

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

MyThreadRun.java

package thread;

public class MyThreadRun {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start(); // 스레드 시작 (시간 소요)
        System.out.println("메인 스레드 종료"); // 메인 스레드 작업 (시간 적게 소요)
    }
}

2. Runnable 인터페이스 구현을 통한 스레드 생성

MyRunnable.java

package thread;

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

MyRunnableRun.java

package thread;

public class MyRunnableRun {
    public static void main(String[] args) {
        Runnable runnable = new MyRunnable();
        Thread thread = new Thread(runnable);
        thread.start();
        System.out.println("메인 스레드 종료");
    }
}

데이터 공유: 동시성 제어

여러 스레드가 동일한 변수에 접근하여 데이터를 공유할 수 있습니다. 예를 들어, 티켓 판매 시스템에서는 여러 스레드가 동시에 티켓 수를 감소시킬 수 있습니다.

synchronized 키워드를 사용하여 여러 스레드가 run() 메소드를 실행할 때 순차적으로 처리되도록 동기화를 적용할 수 있습니다. synchronized는 객체 또는 메소드에 잠금을 설정하며, 잠금이 적용된 코드를 "상호 배제 구역(Critical Section)"이라고 합니다.

synchronized는 메서드 호출 시 `ACC_SYNCHRONIZED` 플래그를 확인하여 동기화를 수행합니다. 플래그가 설정되어 있으면, 해당 스레드는 동기화 잠금을 획득하고 메서드를 실행한 후, 메서드 완료 시 잠금을 해제합니다.

데이터 공유 예제 (synchronized 사용)

MyThreadSharedData.java

package thread;

public class MyThreadSharedData extends Thread {
    private int ticketCount = 5; // 총 티켓 수

    @Override
    // synchronized 키워드를 사용하여 해당 메소드를 동기화
    public synchronized void run() {
        super.run();
        if (ticketCount > 0) {
            ticketCount--;
            System.out.println(Thread.currentThread().getName() + " 티켓 판매. 남은 티켓: " + ticketCount);
        }
    }
}

MyThreadSharedDataRun.java

package thread;

public class MyThreadSharedDataRun {
    public static void main(String[] args) {
        MyThreadSharedData sellTicketThread = new MyThreadSharedData();

        // 여러 스레드 생성 및 시작
        Thread thread1 = new Thread(sellTicketThread, "Thread-1");
        Thread thread2 = new Thread(sellTicketThread, "Thread-2");
        Thread thread3 = new Thread(sellTicketThread, "Thread-3");
        Thread thread4 = new Thread(sellTicketThread, "Thread-4");
        Thread thread5 = new Thread(sellTicketThread, "Thread-5");

        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
        thread5.start();
    }
}

실행 결과 (예상)

출력 순서는 스레드 스케줄링에 따라 달라질 수 있으며, start() 호출 순서가 run() 실행 순서를 보장하지 않습니다.

비동기 문제(Non-thread-safe): 여러 스레드가 동일한 객체의 인스턴스 변수에 동시에 접근하여 수정할 때, 값의 불일치나 예기치 않은 변경이 발생하여 프로그램 흐름에 영향을 줄 수 있습니다.

run(): 현재 스레드에서 즉시 run() 메소드를 실행합니다. 새로운 스레드를 생성하지 않습니다.

start(): 새로운 스레드를 생성하고, 해당 스레드에서 run() 메소드를 실행합니다. 실행 시점은 스케줄링에 따라 달라집니다.

스레드 중지: 예외 처리 활용

스레드를 중지시키는 한 가지 방법은 스레드 내에서 `InterruptedException`을 발생시키는 것입니다.

MyInterruptThread.java

package thread;

public class MyInterruptThread extends Thread {
    @Override
    public void run() {
        super.run();
        try {
            for (int i = 0; i < 500000; i++) {
                // 스레드가 중단 요청을 받았는지 확인
                if (this.isInterrupted()) {
                    System.out.println("중단 상태 감지, 스레드 종료.");
                    throw new InterruptedException(); // InterruptedException 발생시켜 catch 블록으로 이동
                }
                System.out.println("i = " + i);
            }
            System.out.println("for 루프 종료 후"); // 정상적으로 루프를 마쳤을 경우
        } catch (InterruptedException e) {
            System.out.println("MyInterruptThread.run() 메소드의 catch 블록 진입");
            e.printStackTrace(); // 예외 스택 트레이스 출력
        }
    }
}

MyInterruptThreadRun.java

package thread;

public class MyInterruptThreadRun {
    public static void main(String[] args) {
        try {
            MyInterruptThread thread = new MyInterruptThread();
            thread.start();
            Thread.sleep(2000); // 메인 스레드를 2초간 일시 중지
            thread.interrupt(); // 작업 스레드에 중단 요청
        } catch (InterruptedException e) {
            System.out.println("main 메소드의 catch 블록 진입");
            e.printStackTrace();
        }
        System.out.println("메인 스레드 종료");
    }
}

스레드 종류: 일반 스레드와 데몬 스레드

Java에는 두 종류의 스레드가 있습니다: 일반 스레드(사용자 스레드)와 데몬 스레드(Daemon Thread).

데몬 스레드는 일반 스레드가 모두 종료되면 자동으로 소멸되는 보조적인 스레드입니다. 대표적인 예로 가비지 컬렉션 스레드가 있습니다.

synchronized 키워드 상세

SynchronizedMethodTest.java

package thread;

public class SynchronizedMethodTest {
    // static 메소드에 synchronized 키워드 적용
    synchronized public static void testMethod() {
        System.out.println("testMethod() 호출됨");
    }

    public static void main(String[] args) throws InterruptedException {
        testMethod();
    }
}

javap -c -v SynchronizedMethodTest.class 명령어를 사용하여 해당 클래스의 바이트코드를 분석하면, `ACC_SYNCHRONIZED` 플래그가 메소드에 적용된 것을 확인할 수 있습니다.

동기화 블록 (synchronized block)

동기화 블록을 사용하면 특정 코드 영역에 대해 동기화를 적용할 수 있습니다. 이 경우, JVM은 `monitorenter`와 `monitorexit` 명령어를 사용하여 동기화를 처리합니다.

SynchronizedBlockTest.java

package thread;

public class SynchronizedBlockTest {
    public void testMethod() {
        // 'this' 객체를 잠금 대상으로 하는 동기화 블록
        synchronized (this) {
            int age = 100; // 예시 변수
            System.out.println("testMethod() 내부 동기화 블록 실행");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SynchronizedBlockTest test = new SynchronizedBlockTest();
        test.testMethod();
    }
}

javap -c -v SynchronizedBlockTest.class 명령어를 사용하여 바이트코드를 분석하면, `monitorenter`와 `monitorexit` 명령어가 사용된 것을 확인할 수 있습니다.

주의사항: 스레드에서 예외가 발생하면 해당 스레드가 보유하고 있던 모든 잠금이 해제됩니다. 단, `Thread.suspend()` 및 `Thread.sleep(mills)` 메소드는 잠금을 해제하지 않습니다.

volatile 키워드

volatile 키워드는 다음 세 가지 특성을 보장합니다:

  1. 가시성(Visibility): 한 스레드에서 변경한 값이 다른 스레드에게 즉시 반영됩니다.
  2. 원자성(Atomicity): (주의: volatile 자체만으로는 복잡한 연산의 원자성을 보장하지 않습니다. 단일 변수 읽기/쓰기에 대한 원자성을 제공합니다.)
  3. 재정렬 방지(No reordering): 컴파일러나 CPU가 명령어 실행 순서를 임의로 재정렬하는 것을 방지합니다.

synchronized 키워드와 volatile 키워드 비교

synchronized 키워드의 특징:

  1. 가시성: synchronized는 잠금 획득 시점에 이전 스레드가 변경한 값을 읽어오고, 잠금 해제 시점에 변경된 값을 저장하므로 가시성을 제공합니다.
  2. 원자성: 동기화된 코드는 한 번에 하나의 스레드만 실행할 수 있으므로 원자성을 보장합니다.
  3. 재정렬 방지: 동기화 블록 내의 코드에 대한 재정렬을 방지합니다.

synchronizedvolatile 사용 시나리오 요약

  • volatile: 공유 변수의 값이 변경되었을 때 다른 스레드들이 항상 최신 값을 읽도록 보장해야 할 때 사용합니다.
  • synchronized: 여러 스레드가 동일한 객체의 인스턴스 변수에 접근하여 수정할 때, 데이터의 일관성을 유지하고 스레드 안전성 문제를 방지하기 위해 사용합니다.

태그: java 멀티스레딩 동시성 스레드 Runnable

7월 24일 00:36에 게시됨