본문 바로가기

개발기술/Java

Java 쓰레드 관리

쓰레드 개념정립 

스레드는 어떻게 생성되는가? 

  자바에서는 모든 것과 마찬가지로 스레드도 객체로 다룬다. 자바는 실행 시점에 `main` 이라는 이름의 스레드를 만들고 프로그램의 시작점인 `main()` 메서드를 실행한다.  스레드를 생성하는 코드는 main 스레드는 main 메서드 내에서 정의된 것처럼, 새로운 스레드 객체를 생성한 후 , 스레드의 start() 메서드를 시작한다. 그러면, JVM이 OS system call을 통해 새로운 스레드를 위한 별도의 스택 공간을 할당한다. 새로운 스레드는 이어서 스레드 인스턴스 내에 정의되어 있는 `run()` 메서드를 실행하여 정의된 작업을 실행한다. 여기까지가 스레드의 start() 동작이다. main 스레드는 새로운 스레드의 start() 메소드를 동작 완료후 새로운 스레드의 생성을 기다리지 않고 병렬적으로 그 이후의 코드작업을 계속해나간다.

 

public class HelloThreadMain {
    public static void main(String[] args) {

        System.out.println(Thread.currentThread().getName()+": main() start");
        HelloThread helloThread = new HelloThread();
        System.out.println(Thread.currentThread().getName()+": start() 호출전");
        helloThread.start();
        System.out.println(Thread.currentThread().getName()+": start() 호출후");

        System.out.println(Thread.currentThread().getName()+": main() end");
    }
}

* 멀티스레드에서 스레드 간 실행 순서는 보장하지 않는다. 스레드는 병렬적으로 실행되기 때문에  스레드 간에 실행 순서는 상황에 따라 얼마든지 달라질 수 있다. 

 

데몬스레드

  스레드는 사용자(user) 스레드와 데몬(daemon) 스레드 2가지 종류로 구분할 수 있다. 백그라운드에서 보조적인 작업을 수행하며 모든 user 스레드가 종료되어야(Main 스레드를 포함한 모든 스레드) JVM이 종료되고 그제서야 데몬스레드가 종료된다. 

데몬스레드는 Instance.setDaemon(true/false) 메소드로 설정해줄 수 있다. (JVM은 데몬 스레드의 실행 완료를 기다리지 않고 종료된다데몬 스레드가 아닌 모든 스레드가 종료되면자바 프로그램도 종료된다.)

 

Thread Class 설명

  • .Thread.class - 스레드 역시 객체로 다루어짐, 주로 부모클래스로서 상속의 대상으로 많이 사용됨

Static method  

  • Thread.currentThread() :  해당 코드를 실행하는 스레드의 객체를 반환하여 이를 조회할 수 있다
  • Thread.sleep() :  해당 코드를 실행하는 스레드를 쉬도록 한다

Instance Method 

  • Instance.start() : 시스템콜을 통해 제3의 스레드를 만들고 생성된 스레드가 run method를 실행시키도록 함
  • Instance.run() : start()로 인해 생성된 스레드가 이어서 본 메서드를 실행함. 실행할 대상은 runnable로 주입받거나 run() override되어 정의됨.
  • Instance.setDaemon(true/false)` : 해당 인스턴스의 스레드를 데몬스레드로 설정한다. 기본값은 사용자 스레드로, false임.
  • Instance.Tostring() : Thread 인스턴스의 스레드 ID, 스레드 이름우선순위스레드 그룹을 포함하는 문자열을 반환한다.
  • Instance.setPriority() : 우선순위는 1 (가장 낮음)에서 10 (가장 높음)까지의 값으로 설정할 수 있음. 하지만 실제 실행순서는 이것에 큰 영향을 받지는 않고 JVM과 운영체제 상황에 따라달라질 수 있다.
  • getThreadGroup()**: 스레드가 속한 스레드 그룹을 반환하는 메서드이다. 스레드 그룹은 여러 스레드를 하나의 그룹으로 묶어서 특정 작업(예: 일괄 종료, 우선순위 설정 등)을 수행할 수 있다. 그룹은 필요시에 별도로 찾아볼것.

 

Constructor 

  • name : 이름을 스레드에 부여한다. 이 이름은 디버깅이나 로깅 목적으로 유용하다.
  • runnable : 쓰레드에게 작업을 하게할 runnable 객체를 부여한다.

 

스레드의 작업대상생성 - Thread Class 상속

  스레드를 만들 때는 스레드가 어떤 작업을 하도록 정의할 것인가가 중요하다. 어떤 작업을 할지 정하는 방법은 `Thread` 클래스를  상속하여 Run()을 Overriding하거나 Run이 실행할 수있는 객체인 `Runnable` 인터페이스를 구현하는 방법이 있다. 그 중 Thread 클래스 상속은 아래와 같다.

스레드 클래스 상속

`Thread클래스를 상속하고스레드 클래스 내부에 존재하는 작업대상 메소드인 `run()` 메서드를 overriding으로 재정의한다.

public class HelloThread extends Thread {

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName()+": run() 시작");

    }
}

 

main Thread : java가 만들어주는 기본적인 스레드

 

스레드 작업대상생성 - Runnable Interface 구현

   Runnable 인터페이스는 함수형 인터페이스로 Run을 추상메소드로 갖는다.  Runnable은 말하자면, 작업대상이며 스레드는 작업주체이다. 상속이 자유로운 점, 작업과 스레드를 코드 분리가능한 점 등의 장점으로 실무에서는 주로 해당 방식을 채택한다.

@FunctionalInterface
public interface Runnable {
    /**
     * Runs this operation.
     */
    void run();
}

 

  Runnable Interface 상속클래스의 작업 인스턴스와 Thread 클래스의 인스턴스를 각각 생성한다. 

public class StartTestMain4 {
    public static void main(String[] args) {
        new Thread(new PrintWork("A", 1000), "Thread-A").start();
        new Thread(new PrintWork("B", 500), "Thread-B").start();
    }

    static class PrintWork implements Runnable {

        private String content;
        private int sleepTime;

        public PrintWork(String content, int sleepTime) {
            this.content = content;
            this.sleepTime = sleepTime;
        }

        @Override
        public void run() {
            while (true) {
                log(content);
                try {
                    Thread.sleep(sleepTime);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
}

 

 

Runnable의 다양한 구현방법

1. InnerClass - 위의 예시처럼 특정 method에서만 사용될 Runnable의 경우 특정 method의 클래스 내부에 InnerClass로써 정의한다

2. 익명클래스 - 함수형인터페이스를 구현하는 경우 익명클래스로써, 클래스가 호출되는 코드에서 바로 함수를 정의할 수 있다.

 

public class InnerRunnableMainV3 {
    public static void main(String[] args) {
        log("main() start");
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                log("run()");
            } });
        thread.start();
        log("main() end");
    } }

 

3. 람다식 - 익명클래스에서 조금더 나아가서 람다식으로 축약시킬 수 있음.

public class InnerRunnableMainV4 {
    public static void main(String[] args) {
        log("main() start");
        Thread thread = new Thread(() -> log("run()"));
        thread.start();
        log("main() end");
    }
}

 

Runnable interface의 run method는 throws를 허용하지 않는다. 부모메소드에서 허용하지 않는 예외처리는 자식메소드에서도 예외처리할 수 없기에 Runnable 구현체도 throws를 할 수 없다. 때문에, checked exception을 catch한 후 제약이 없는 unchecked exception으로 변환하여 throws 처리를한다.

public class CheckedExceptionMain {
    public static void main(String[] args) throws Exception {
        throw new Exception();
    }
    static class CheckedRunnable implements Runnable {
        @Override
        public void run() /*throws Exception*/ { // 주석 풀면 예외 발생
//throw new Exception(); // 주석 풀면 예외 발생 }
        } }

스레드의 생명주기

getState()로 스레드의 현재 상태를 확인할 수 있다. 반환되는 값은 `Thread.State` 열거형에 정의된 상수 중 하나이다. 

  • NEW:  인스턴스가 생성되었지만 아직 start() 메소드가 실행되지 않아 시작되지 않은 상태이다.
  • RUNNABLE : start() 메소드가 실행되어 스레드가 스케쥴러에 들어가있는 상태로 대기열에 있거나, 실행중인 상태이다. 코어에서 매우 빠르게 Context Switching되기 때문에 사용자의 눈에는 실행 중인상태이므로, 실행대기와 실행중의 상태구분의 의미가 없음.
  • BLOCKED : java 1.0부터 있었던 기본적인 대기상태이다. synchronized에서 lock을 얻기위해 대기하는 데에만 사용되는 상태라고 볼 수 있다.  waiting과 달리 interrupt로 깨어나오지 못한다.  스레드가 동기화 락, I/O응답, waiting on condition을 기다리는 상태이다.
  • WAITING : sava 1.5부터 blocked상태가 깨어나지 못함을 개선하기 위해서 도입되었다. 범용적인 대기상태로 interrupt로 깨어날 수 있다. join(), park(), wait()에서 해당상태로 빠진다,
  • TIMED_WAITING : waiting 중 특정시간 후에 깨어나는 waiting 상태이다. waiting상태의 메소드는 timeswaiting에 해당하는 메소드가 존재한다. sleep(), join(time),parknanos(), wait(time)
  • TERMINATED : 스레드의 스택 내에서 더이상 동작할 것이 없는 상태. 

스레드 생명주기 관리 - JOIN

  멀티스레드의 특징은 병렬작업으로, 서로의 작업에 독립적으로 동작하면서 효율성을 증대시킨다. main thread가 thread1, thread2를 start()시킬지라도 thread1, thread2가 끝날때까지 기다리지 않는다.

  그러나 main thread가 thread1,2의 연산결과를 가지고 작업을 해야하는 경우 thread1,2의 작업을 기다려야한다. main thread가 thread1, thread2의 작업종료를 기다렸다가 작업결과를 가지고 후속작업을 진행하도록 할때 쓰이는 것이 Join이다.

 

  • threadInstance.join() : 해당 메소드를 실행하는 thread가 threadinstance의 상태가 'TERMINATED'되는 것을 기다리며 'WAITING'상태로 빠진다. threadinstance가 'TERMINATED'되면 thread 'RUNNABLE'이 되어 후속연산을 실행한다.
  • threadInstance.join(miliseconds) : 해당 메소드를 실행하는 thread가 threadinstance의 상태가 'TERMINATED'되는 것을 miliseconds만큼 기다리며 'TIMED_WAITING'상태로 빠진다. threadinstance가 'TERMINATED'되거나 시간이 다 되면 thread 'RUNNABLE'이 되어 후속연산을 실행한다.

 

쓰레드 제어 - 예외처리
run() 메서드는 체크예외를 밖으로 던질수없다. 체크 예외를 run() 메서드에서 던질 수 없도록 강제함으로써, 개발자는 반드시 체크 예외를 try-catch 블록 내에서 처리하게 된다. 이는 쓰레드의 생명에서 run()이 마지막 단계이기때문에, 예외 발생 시 해당 예외를 개발자가 인지하고 적절히 처리되지 않아서 프로그램이 비정상 종료되는 상황을 방지할 수 있다. 특히 멀티스레딩 환경에서는 예외 처리를 강제함으로써 스레드의 안정성과 일관성을 유지할 수 있다. 하지만, 최근에는 unchecked exception을 사용하는 기조로 해당 강제성이 다소 무의미하게된 상황이다. 

 

쓰레드 제어 - 인터럽트

  스레드의 작업을 중단시키고 싶을때는?

1. 다른 스레드의 작업을 중단시키려면 flag를 활용해서, while 문을 사용하는 것을 고려할 수 있다. 단점은, while 문 내의 logic을 다 수행하고서 조건문으로 돌아가 확인하기 때문에 timing을 맞추기가 어렵다는 점.

2. 이를 보완하기 위해서, 즉각적으로 스레드를 응답시킬 수 있는 thread.interrupt()를 사용한다.

- timed waiting, waiting인 스레드를 깨워서 runnable로 만든다.

- while문 내의 logic을 실행중이더라도 `InterruptedException` `catch` 로 잡아서 제어흐름을 catch구문으로 이동시킨다.

 

   스레드의 인터럽트는 timedwaiting 혹은 waiting 상태에서만 동작시킬 수 있다. thread.interrupt()를 실행하면 스레드를 isinterrupted true인 상태로 만들고 스레드가 timed waiting 혹은 waiting 상태가 되면 인터럽트 false로 변하면서 interrupt exception이 throw된다. sleep() 이나 join()처럼 interruptexcpetion을 try-catch해야되는 작업에서만 인터럽트가 터진다는 것.

 

쓰레드 제어 - yield 양보하기

  특정 스레드가 크게 바쁘지 않은 상황 이어서 다른 스레드에 CPU 실행 기회를 양보하고 싶을 수 있다. 이렇게 양보하면 스케줄링 큐에 대기 중인 다른 스레드 가 CPU 실행 기회를 더 빨리 얻을 수 있다. sleep()의 경우에는 thread가 timed waiting상태로 변경되면서 양보할 스레드가 없는 상황에서도 코어를 놀릴수 있다. yield는 이점을 보완하여 runnable상태를 유지하되, thread가 scheduling queue에서 본인 차례가 되었어도 다른 스레드에 양보하고 다시 scheduling queue로 들어가 양보할 스레드가 있는 상황에서만 코어를 양보한다. thread Priority와 마찬가지로 CPU에 신호를 주는 것이지 강제성 부여는 없다. 때문에 양보하지 않을 수도 있음

 

메모리 가시성 

코드로 본 메모리 가시성문제

아래의 코드를 보면, mainthread가 runflag를 false로 만들면서, work스레드의 task가 종료될 것을 예상한다. 하지만, 실제 실행 결과를 보면 task 종료되지 않고 자바 프로그램도 멈추지 않고 계속 실행된다. 'work' 스레드가 while문에서 빠져나오지 못하고 있는 것이다. 이를 메모리 가시성 문제라고 한다.

public class VolatileFlagMain {
    public static void main(String[] args) {
        Mytask task = new Mytask();
        Thread thread = new Thread(task);
        log("runflag = "+task.runFlag);
        thread.start();

        sleep(1000);
        task.runFlag = false;
        log("runflag = "+task.runFlag);
        log("main 종료");
    }

    private static class Mytask implements Runnable {

        boolean runFlag = true;

        @Override
        public void run() {
            log("task 시작");
            while (runFlag) {
                //run flag가 false가 되면 탈출
                System.out.println("hello");
                sleep(1000);
            }
            log("task 종료");
        }
    }
}

 

 

컴퓨터 구조로 본 메모리 가시성문제

멀티스레드 환경에서 한 스레드가 변경한 값이 다른 스레드에서 언제 보이는지에 대한 것을 메모리 가시성(memory visibility)이라 한다. 이름 그대로 메모리에 변경한 값이 보이는가, 보이지 않는가의 문제이다.

  CPU는 처리 성능을 개선하기 위해 메모리와 CPU 중간에 캐시 메모리라는 것을 사용한다. CPU 연산은 매우 빠르기 때문에 CPU 연산의 빠른 성능을 따라가려면, CPU 가까이에 매우 빠른 메모리가 필요 한데, 이것이 바로 캐시 메모리이다. 캐시 메모리는 CPU와 가까이 붙어있고, 속도도 매우 빠른 메모리이다

  각 스레드가 'runFlag' 의 값을 사용하면 CPU는 이 값을 효율적으로 처리하기 위해 먼저 'runFlag' 를 캐시 메모리에 불러온다. 그리고 main에서 runflag를 false로 변경시켜도, 캐쉬메모리의 값만 변하고 메인메모리의 값은 즉각적으로 변하지않는다. 메인메모리의 값이 변한다고 하여도 work스레드의 캐시메모리의 값이 즉각적으로 변하지 않는다. 메모리와 캐쉬의 값 변경시점은 CPU 설계 방식과 실행 환경에 따라 다를 수 있다.주로 컨텍스트 스위칭이 될 때, 캐시 메모리도 함께 갱신된다. 

  해결방안은 아주 단순하다 성능을 약간 포기하는 대신에, 값을 읽을 때, 값을 쓸 때 모두 메인 메모리에 직접 접근하면 된다. 자바에서는 `volatile` 이라는 키워드로 이런 기능을 제공한다

 

자바 메모리 모델(Java Memory Model)

Java Memory Model(JMM)은 자바 프로그램이 어떻게 메모리에 접근하고 수정할 수 있는지를 규정하며, 특히 멀티 스레드 프로그래밍에서 스레드 간의 상호작용을 정의한다. 한 스레드에서 수행한 작업을 다른 스레드가 참조할 때 최신 상태가 보장되는 것이다.

 

**happens-before**

happens-before 관계는 자바 메모리 모델에서 스레드 간의 작업 순서를 정의하는 개념이다. 만약 A 작업이 B 작업보 다 happens-before 관계에 있다면, A 작업에서의 모든 메모리 변경 사항은 B 작업에서 볼 수 있다.

 

**모니터 락 규칙**
한 스레드에서 `synchronized` 블록을 종료한 후, 그 모니터 락을 얻는 모든 스레드는 해당 블록 내의 모든 작업을 볼 수 있다. 예를 들어,`synchronized(lock) { ... }` 블록 내에서의 작업은 블록을 나가는 시점에 happens- before 관계가 형성된다. 뿐만 아니라`ReentrantLock` 과 같이 락을 사용하는 경우에도 happens-before 관계가 성립한다.

 

**volatile 변수 규칙**
한 스레드에서 `volatile` 변수에 대한 쓰기 작업은 해당 변수를 읽는 모든 스레드에 보이도록 한다. , `volatile` 변수에 대한 쓰기 작업은 그 변수를 읽는 작업보다 happens-before 관계를 형성한다.

 

**정리** : **volatile 또는 스레드 동기화 기법(synchronized, ReentrantLock)을 사용하면 메모리 가시성의 문제가 발생하지 않는다.**

 

 

동기화 - synchronized

멀티스레드를 사용할 때 가장 주의해야 할 점은, 같은 자원(리소스)에 여러 스레드가 동시에 접근할 때 발생하는 동시성 문제이다.

멀티스레드를 사용할 때는 이런 공유 자원에 대한 접근을 적절하게 동기화(synchronization)해서 동시성 문제가 발생 하지 않게 방지하는 것이 중요하다.

 

동시성문제

임계 영역(critical section)은 영어로 크리티컬 섹션이라 한다.  이는 여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 또 중요한 코드 부분을 뜻한다. 여러 스레드가 동시에 접근해서는 안 되는 공유 자원을 접근하거나 수정하는 부분을 의미한다. 이런 임계 영역은 한 번에 하나의 스레드만 접근할 수 있도록 안전하게 보호해야 한다.  

 

 경합 조건(Race condition)**

두 개 이상의 스레드가 경쟁적으로 동일한 자원을 수정할 때 발생하는 문제.이런 문제가 발생한 근본 원인은 여러 스레드가 함께 사용하는 공유 자원을 여러 단계로 나누어 사용하기 때문이다.

**1. 검증 단계**: 잔액( `balance` )이 출금액( `amount` ) 보다 많은지 확인한다.
**2. 출금 단계**: 잔액( `balance` )을 출금액( `amount` ) 만큼 줄인다.

  여기서, 사실 내가 사용하는 값(잔액)이 중간에 변경되지 않을 것이라는 가정이 있다. 그런데 만약 중간에 다른 스레드가 잔액의 값을 변경한다면, 큰 혼란이 발생한다. 해당 두개의 연속적 로직문제 뿐만 아니라 어셈블리어 단계로 내려가면 한줄의 코드라도 여러단계로 쪼개어 실행되며 여러단계에서 공통적으로 사용되는 값이 여러 쓰레드에 의해서 값이 변경되면 큰 혼란이 발생할 수 있다.

 

 

synchronized 메서드

자바는 `synchronized` 키워드를 통해 아주 간단하게 임계 영역을 보호할 수 있다

자바의 `synchronized` 키워드를 사용하면 한 번에 하나의 스레드만 실행할 수 있는 코드 구간을 만들 수 있다.

 

 

public class BankAccountV2 implements BankAccount {

    private int balance;

    public BankAccountV2(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public synchronized boolean withdraw(int amount) {
        log("거래시작 " + getClass().getSimpleName());
        // 잔고가 출금액보다 적으면, 진행불가
        log("[검증 시작] 출금액 :" + amount + ", 잔액 : " + balance);
        if (balance < amount) {
            log("[검증 실패] 출금액 :" + amount + ", 잔액 : " + balance);
            return false;
        }

        // 잔고가 출금액보다 많으면 진행
        log("[검증 성공] 출금액 :" + amount + ", 잔액 : " + balance);
        sleep(1000);
        balance -= amount;
        log("[출금 완료] 출금액 :" + amount + ", 잔액 : " + balance);

        log("거래종료 ");
        return true;
    }

    @Override
    public synchronized int getBalance() {
        return balance;
    }
}

 

synchronized 코드 블럭

여러 스레드가 동시에 실행하지 못하기 때문에, 전체로 보면 성능이 떨어질 수 있다. 따라서 `synchronized` 를 통해 여러 스레드를 동시에 실행할 수 없는 코드 구간은 꼭! 필요한 곳으로 한정해서 설정해야 한다

@Override
public boolean withdraw(int amount) {
    log("거래시작 " + getClass().getSimpleName());
    // 잔고가 출금액보다 적으면, 진행불가

    synchronized (this) {
        log("[검증 시작] 출금액 :" + amount + ", 잔액 : " + balance);
        if (balance < amount) {
            log("[검증 실패] 출금액 :" + amount + ", 잔액 : " + balance);
            return false;
        }

        // 잔고가 출금액보다 많으면 진행
        log("[검증 성공] 출금액 :" + amount + ", 잔액 : " + balance);
        sleep(1000);
        balance -= amount;
        log("[출금 완료] 출금액 :" + amount + ", 잔액 : " + balance);
    }
    log("거래종료 ");
    return true;

 

**모든 객체(인스턴스)는 내부에 자신만의 락( `lock` )을 가지고 있다. 이는, 모니터 락(monitor lock)이라도고 부른다. 객체 내부에 있고 우리가 확인하기는 어렵다.

스레드가 `synchronized` 키워드가 있는 메서드에 진입하려면 반드시 해당 인스턴스의 락이 있어야 한다. 수 많은 스레드가 동시에 호출한다면, 1개의 스레드만 락 을 획득하고 나머지는 모두 `BLOCKED` 상태가 된다. BLOCKED` 상태가 되면 락을 다시 획득하기 전까지는 계속 대기하고, CPU 실행 스케줄링에 들어가지 않는다.

 

**참고: 락을 획득하는 순서는 보장되지 않는다.**

 

고급 동기화 - concurrent.Lock

 

`synchronized` 는 자바 1.0부터 제공되는 매우 편리한 기능이지만, `synchronized` 의 가장 치명적인 단점은 1. 락을 얻기 위해 BLOCKED` 상태가 되면 락을 얻을 때까지 무한 대기한다는 점이다.(인터럽트사용불가) 2. 어떤 스레드에게 lock이 돌아갈지 순서를 보장할 수 없다는 점이다.(공정성, Lock 대기큐 조절) 이를 극복하기 위해서  결국 더 유연하고, 더 세밀한 제어가 가능한 방법들이 필요하게 되었다. 자바 1.5부터 `java.util.concurrent` 라는 동시성 문제 해결을 위한 패키지가 추가된다.

 

LockSupport 클래스

쓰레드 대기상태로 유일하게 존재하였던 Block 외에도 `LockSupport` 는 스레드를 `WAITING` 상태, Timed Waiting' 상태로 변경한다. Waiting을 도입하여 Interrupt라는 동작이 가능하고 무한대기의 문제를 해결할 수 있다. 

 

  • LockSupport.park()` : 스레드를 `WAITING` 상태로 변경한다. 
  • LockSupport.parkNanos(nanos)` : 스레드를 나노초 동안만 `TIMED_WAITING` 상태로 변경한다.
  • thread1.interrupt()  :WAITING` 상태의 스레드는 다른 스레드가 해당 스레드를 인터럽트 코드를 실행시켜서 중간에 깨울 수 있다.
  • LockSupport.unpark(thread)` : `WAITING` 상태의 대상 스레드를 다른 스레드가 깨워주어 대상 스레드를 `RUNNABLE` 상태로 변경한다

`BLOCKED` 상태는 자바의 `synchronized` 에서 락을 획득하기 위해 대기할 때 사용된다. `WAITING` , `TIMED_WAITING` 상태는 스레드가 특정 조건이나 시간 동안 대기할 때 발생하는 상태이다. `WAITING` 상태는 다양한 상황에서 사용된다. 예를 들어, `Thread.join()` , `LockSupport.park()` , `Object.wait()` 와 같은 메서드 호출 시 `WAITING` 상태가 된다.

 

ReentrantLock 

`LockSupport` 는 저수준의 기능을 제공하는 클래스로서, 대기하고 있는 스레드들을 담아서 관리하기위한 자료구조가 필요하며, 그 스레드 들 중 한가지만 lock을 획득할 수 있도록 하는 메서드가 필요하다. 이러한 기능을 개발자가 사용하려면 더욱 고수준으로 추상화하여야한다. park와 unpark기능을 활용하여 구현한 것들이`Lock` 인터페이스와 `ReentrantLock` 이라는 구현체로 이런 기능들을 이미 다 구현해 두었다. 

 

LockSupport 인터페이스

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}
  • `void lockInterruptibly() :락 획득을 시도하되, 다른 스레드가 인터럽트할 수 있도록 한다. 만약 다른 스레드가 이미 락을 획득했다면, 현재 스레드는 락을 획득할 때까지 대기한다. 대기 중에 인터럽트가 발생하면 `InterruptedException` 이 발생하 며 락 획득을 포기한다.
  • void lock() : 락을 획득한다. 만약 다른 스레드가 이미 락을 획득했다면, 락이 풀릴 때까지 현재 스레드는 대기( `WAITING` )한 다. 이 메서드는 인터럽트에 응답하지 않는다. 'Synchronized {'와 유사한 역할을 한다.
    • 엄밀하게 말하면 스레드에 인터럽트가 발생하면 순간 대기 상태를 빠져나오는 것은 맞다. 그래서아주짧지만 `WAITING` `RUNNABLE` 이된다.그런데 `lock()` 메서드안에서 해당 스레드를 다시`WAITING` 상태로 강제로 변경해버린다
    • 참고 : 여기서 사용하는 락은 객체 내부에 있는 모니터 락이 아니다! `Lock` 인터페이스와 `ReentrantLock` 이 제공하는 기 능이다. 모니터 락과 `BLOCKED` 상태는 `synchronized` 에서만 사용된다.

  •  boolean tryLock() : 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 만약 다른 스레드가 이미 락을 획득했다면 `false` 를 반환하고, 그렇지 않으면 락을 획득하고 `true` 를 반환한다
  • boolean tryLock(long time, TimeUnit unit) : 주어진 시간 동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면 `true` 를 반환한다. 주어진 시간이 지나도 락을 획득하지 못한 경우 `false` 를 반환한다. 이 메서드는 대기 중 인터럽트가 발생하면

    `InterruptedException` 이 발생하며 락 획득을 포기한다.

  • void unlock() : 락을 해제한다. 락을 해제하면 락 획득을 대기 중인 스레드 중 하나가 락을 획득할 수 있게 된다. 락을 획득한 스레드는 반드시 호출해야 하며, 그렇지 않으면 `IllegalMonitorStateException` 이 발생할 수 있다. 'Synchronized }'와 유사한 역할을 한다.
    •  Runnable인 스레드가 lock()을 실행후 lock을 양보하는 unlock()은 반드시 실행되어야하기때문에 try-finally 구문의 finally에 위치시켜주어야한다.
  • Condition newCondition() : Condition` 객체를 생성하여 반환한다. `Condition` 객체는 락과 결합되어 사용되며, 스레드가 waiting상태로 빠지거나 waiting에서 깨어나도록 신호를 받을 수 있도록 한다. 이는 `Object` 클래스의 `wait` , `notify` , `notifyAll` 메서드와 유 사한 역할을 한다.
    • condition.await : waiting과 유사하
    • condition.signal

ReentrantLock

 

  Lock` 인터페이스의 대표적인 구현체로 `ReentrantLock` 이 있는데, 해당 클래스는 큐에 스레드를 보관하여 각 스레드가 Lock에 접근하는 권한을 관리한다. ReentrantLockInstance.lock()을 thread가 실행하면 park()처리가 되어 lock을 얻기 위한 waiting queue에 들어가게 된다. ReentrantLockInstance는 이 큐에 들어 있는 쓰레드들에게 lock을 분배하는 역할을 한다. 

public class BankAccountV4 implements BankAccount {

  private int balance;
  private final Lock lock = new ReentrantLock();

  public BankAccountV4(int initialBalance) {
    this.balance = initialBalance;
  }

  @Override
  public boolean withdraw(int amount) {
    log("거래시작 " + getClass().getSimpleName());
    // 잔고가 출금액보다 적으면, 진행불가

    lock.lock(); // lock을 이용하여 lock을 걸기
    try {
      log("[검증 시작] 출금액 :" + amount + ", 잔액 : " + balance);
      if (balance < amount) {
        log("[검증 실패] 출금액 :" + amount + ", 잔액 : " + balance);
        return false;
      }

      // 잔고가 출금액보다 많으면 진행
      log("[검증 성공] 출금액 :" + amount + ", 잔액 : " + balance);
      sleep(1000);
      balance -= amount;
      log("[출금 완료] 출금액 :" + amount + ", 잔액 : " + balance);
    } finally {
      lock.unlock(); // ReentrantLock을 이용하여 unlock
    }
    log("거래종료 ");
    return true;
  }

  @Override
  public synchronized int getBalance() {
    lock.lock(); // ReentrantLock을 이용하여 lock을 걸기
    try {
      return balance;
    } finally {
      lock.unlock();
    }
  }
}

 

그 외에도 trylock()과 trylocked(times)가 있다. trylocked(times)는 timed waiting으로 interrupt가 적용가능하다. 내부적으로는 park와 parknanos가 실행되는 셈이다. 두 메소드 모두 boolean값을 반환하므로 해당 값에 따른 제어흐름을 변경하는 데에 쓰인다. 

 

  Synchonized의 단점으로 어떤 스레드가 lock을 획득할 지 순서를 정할 수 없다는 공정성의 문제가 있었다.이 클래스는 스레드가 공정하게 락을 얻을 수 있는 모드를 제공한다비공정 모드는 `ReentrantLock` 의 기본 모드이다. 이 모드에서는 락을 먼저 요청한 스레드가 락을 먼저 획득한다는 보장이 없다. 락을 풀었을 때, 대기 중인 스레드 중 아무나 락을 획득할 수 있다. 이는 락의 우선순위 분배 로직의 부재로, 락의 분배가 빨리 되는 성능상 장점이 있지만 특정 스레드가 장기간 락을 획득하지 못할 가능성도 있다. (기아 현상 가능성)

 

  • `new ReentrantLock(true)` : 공정 모드로 락을 요청한 순서대로 스레드가 락을 획득할 수 있게 한다. 요청순서대로 공정성을 확실히 보장하기 때문에 이를 위한 성능소모가 있다.

wait, notify 정리

 

Java는 최초 설계시에 멀티스레드를 고려한 언어로,`Object` 클래스는 임계영역 내에서 무한대기 문제를 해결할 수 있는 `wait()` , `notify()` 라는 메서드를 제공한다. 모든 객체에는 synchronize에 의해서 monitorlock(혹은 ReentrantLock에서의 lock)이 존재하고, wait method에 의해서 대기하고 있는 waitset이 존재한다. waitset은 Lock대기열을 진입하기 위한 대기열로 Lock을 보유하지 않은 상태로 대기하게된다.

 

  • Object.wait()`
    • 현재 스레드가 가진 락을 반납하고 대기(`WAITING`)한다 이 메서드는 현재 스레드가 `synchronized` 블록이나 메서드에서 락을 소유하고 있을 때만 호출할 수 있다.이렇게 대기 상태로 전환된 스레드는 다른 스레드가 `notify()` 또는 `notifyAll()` 을 호출할 때까지 대기 상태를 유지한다
  •  `Object.notify()
    • 이 메서드는 `synchronized` 블록이나 메서드에서 호출되어야 한다. 깨운 스레드는 락을 다시 획득할 기회를 얻게 된다. 만약 대기 중인 스레드가 여러 개라면, 그 중 하나만이 깨워지게 된다.
  • `Object.notifyAll()

    • 대기 중인 모든 스레드를 깨운다.

 

생산자 소비자 문제

문제개념

  • 생산자(Producer)**: 데이터를 생성하는 역할을 한다. 예를 들어, 파일에서 데이터를 읽어오거나 네트워크에서 데 이터를 받아오는 스레드가 생산자 역할을 할 수 있다. 앞서 프린터 예제에서 사용자의 입력을 프린터 큐에 전달하는 스레드가 생산자의 역할이다.
  • **소비자(Consumer)**: 생성된 데이터를 사용하는 역할을 한다. 예를 들어, 데이터를 처리하거나 저장하는 스레드 가 소비자 역할을 할 수 있다. 앞서 프린터 예제에서 프린터 큐에 전달된 데이터를 받아서 출력하는 스레드가 소비자 역할이다.
  • **버퍼(Buffer)**: 생산자가 생성한 데이터를 일시적으로 저장하는 공간이다. 이 버퍼는 한정된 크기를 가지며, 생산 자와 소비자가 이 버퍼를 통해 데이터를 주고받는다. 앞서 프린터 예제에서 프린터 큐가 버퍼 역할이다.

문제 상황

  • 생산자가 너무 빠를 때**: 버퍼가 가득 차서 더 이상 데이터를 넣을 수 없을 때까지 생산자가 데이터를 생성한다. 버 퍼가 가득 찬 경우 생산자는 버퍼에 빈 공간이 생길 때까지 기다려야 한다.
  • 소비자가 너무 빠를 때**: 버퍼가 비어서 더 이상 소비할 데이터가 없을 때까지 소비자가 데이터를 처리한다. 버퍼가 비어있을 때 소비자는 버퍼에 새로운 데이터가 들어올 때까지 기다려야 한다.
public class BoundedQueueV1 implements BoundedQueue {

  private final Queue<String> queue = new ArrayDeque<>();
  private final int max;

  public BoundedQueueV1(int max) {
    this.max = max;
  }

  @Override
  public synchronized void put(String data) {
    if (queue.size() == max) {
      log("[put] 큐가 가득참, 버림 " + data);
      return;
    }
    queue.offer(data);
  }

  @Override
  public synchronized String take() {
    if (queue.isEmpty()) {
      return null;
    }
    return queue.poll();
  }

 

  • 생산자 먼저인 `producerFirst` 를 호출하면`producer1` `producer2` `producer3` 이후 `consumer1` `consumer2` `consumer3`   순서로 실행된다. 소비자 먼저인 `consumerFirst` 를 호출하면 `consumer1` `consumer2` `consumer3` 이후 producer1` `producer2` `producer3` 순서로 실행된다.
  • critical section인 큐의 자원넣기 자원빼기는 임계영역으로 설정하여 스레드 1개만 접근가능하다.
 

 

  • 생산자 소비자 문제 - 예제1
    • 적용코드 : if문을 사용하여 데이터가 차거나 비어있음을 검증 후, 데이터 생산과 소비를 포기
    • 생산자는 큐가 가득차면 데이터를 버리고, 소비자는 큐가 비면 데이터 소비하기를 포기한다. 그러므로, 데이터가 가득차거나 데이터가 비어있으면 자원의 생산과 소비가 이루어지지 않고 손실이 발생한다.
  • 생산자 소비자 문제 - 예제2
    • 적용코드 : while문을 사용하여 데이터가 차거나 비어있으면 sleep상태로 빠지고, 데이터가 다시 비거나 찰때까지 확인을 반복한다. 
    • 한 스레드가 데이터가 확인행위를 하고 작업이 불가할 시에 임계영역 안에서 lock을 받납하지 않은 상태로 대기상태에 빠지면, 다른 스레드가 lock을 획득하지 못하여 문제를 해결해줄 수가 없다. 임계영역 내 스레드는 lock을 가지고서 timed waiting, 다른 스레드는 임계영역 밖에서 block으로 대기하는 교착상태가 발생한다
  • 생산자 소비자 문제 - 예제3
    • 적용코드 : while문을 사용하여 데이터가 차거나 비어있으면 this.wait()을 통해서 lock을 반납하고 wait 대기열로 빠진다. 다른 스레드가 필요한 작업을 하고서 this.notify()를 통해 wait 대기열 내 스레드를 깨워 block 대기열로 이동 시킴.
    • 스레드가 작업을 끝내면 notify() 메소드에 의해서 lock을 반납한 채로 wait 대기상태에 있는 스레드를 깨운다. 그러나, 스레드를 깨울때 생산자와 소비자 스레드 중 선택하여 깨울 수 없어 소비자 스레드를 깨워야하는 상황에서 생산자 스레드를 깨우는 경우 비효율이 발생할 수 있다.
  • 생산자 소비자 문제 - 예제4
    • 적용코드 : while문을 사용하여 데이터가 차거나 비어있으면 condition.await()을 통해서 lock을 반납하고 스레드별로 구분된 wait 대기열로 빠진다. 다른 스레드가 필요한 작업을 하고서 condition.signal()를 통해 스레드 종류별로 구분된 대기열을 깨워 block 대기열로 이동시킨다 
    • 본 코드에서는 스레드의 무한대기 문제, 활용의 비효율성도 개선이 된 완전한 코드라고 할 수 있다. 사실 본 코드와 같은 구조는 BlockingQueue라는 java의 구현체와 동일한 코드임.

public class BoundedQueueV5 implements BoundedQueue {

  private final Lock lock = new ReentrantLock();
  private final Condition producerCond = lock.newCondition();
  private final Condition consumerCond = lock.newCondition();
  private final Queue<String> queue = new ArrayDeque<>();
  private final int max;

  public BoundedQueueV5(int max) {
    this.max = max;
  }

  @Override
  public void put(String data) {
    lock.lock();
    try {
      while (queue.size() == max) {
        log("[put] 큐가 가득 참, 생산자 대기");
        try {
          producerCond.await();
          log("[put] 생산자 깨어남");
        } catch (InterruptedException e) {
          throw new RuntimeException(e);
        }
      }
      queue.offer(data);
      log("[put] 생산자 데이터 저장, consumerCond.signal() 호출");
      consumerCond.signal();
    } finally {
      lock.unlock();
    }
  }

  @Override
  public String take() {
    lock.lock();
    try {
      while (queue.isEmpty()) {
        log("[take] 큐에 데이터가 없음, 소비자 대기");
        try {
          consumerCond.await();
          log("[take] 소비자 깨어남");
        } catch (InterruptedException e) {
          throw new RuntimeException(e);
        }
      }
      String data = queue.poll();
      log("[take] 소비자 데이터 획득, producerCond.signal() 호출");
      producerCond.signal();
      return data;
    } finally {
      lock.unlock();
    }
  }

 

BlockingQueue 

  • 실무에서 멀티스레드를 사용할 때는 응답성이 중요하다. 예를 들어서 대기 상태에 있어도, 고객이 중지 요청을 하거나, 또는 너무 오래 대기한 경우 포기하고 빠져나갈 수 있는 방법이 필요하다.고객 입장에서 무작정 무한 대기하고 결과도 알 수 없는 상황이 가장 나쁜 상황일 것이다.

  • 버퍼 내에 자료가 가득차 스레드가 작업을 할 수 없는 경우에 4가지 선택지가 있다.1. 예외를 던진다. 예외를 받아서 처리한다. 2. 대기하지 않는다. 즉시 `false` 를 반환한다. 3. 대기한다. 4.특정 시간 만큼만 대기한다.BLockingQueue는 이 4가지 대응방안을 모두 제공하며 각 메소드는 아래와 같다.

 

  • Throws Exception - 대기시 예외**
    • **add(e)**: 지정된 요소를 큐에 추가하며, 큐가 가득 차면 `IllegalStateException` 예외를 던진다.
    • **remove()**: 큐에서 요소를 제거하며 반환한다. 큐가 비어 있으면 `NoSuchElementException` 예외를 던진 다.
    • **element()**: 큐의 머리 요소를 반환하지만, 요소를 큐에서 제거하지 않는다. 큐가 비어 있으`NoSuchElementException` 예외를 던진다.
  • **Special Value - 대기시 즉시 반환**
    • **offer(e)**: 지정된 요소를 큐에 추가하려고 시도하며, 큐가 가득 차면 `false` 를 반환한다.
    • **poll()**: 큐에서 요소를 제거하고 반환한다. 큐가 비어 있으면 `null` 을 반환한다.
    • **peek()**: 큐의 머리 요소를 반환하지만, 요소를 큐에서 제거하지 않는다. 큐가 비어 있으면 `null` 을 반환한다.
  • **Blocks - 대기**
    • **put(e)**: 지정된 요소를 큐에 추가할 때까지 대기한다. 큐가 가득 차면 공간이 생길 때까지 대기한다.
    • **take()**: 큐에서 요소를 제거하고 반환한다. 큐가 비어 있으면 요소가 준비될 때까지 대기한다.
    • **Examine (관찰)**: 해당 사항 없음.
  • **Times Out - 시간 대기**
    • **offer(e, time, unit)**: 지정된 요소를 큐에 추가하려고 시도하며, 지정된 시간 동안 큐가 비워지기를 기다리다가 시간이 초과되면 `false` 를 반환한다.
    • **poll(time, unit)**: 큐에서 요소를 제거하고 반환한다. 큐에 요소가 없다면 지정된 시간 동안 요소가 준비되기를 기다리다가 시간이 초과되면 `null` 을 반환한다.
    • **Examine (관찰)**: 해당 사항 없음.

BlockingQueue 인터페이스의 구현체는 아래와 같은 종류가 있다.

  • `ArrayBlockingQueue` : 배열 기반으로 구현되어 있고, 버퍼의 크기가 고정되어 있다.
  • `LinkedBlockingQueue` : 링크 기반으로 구현되어 있고, 버퍼의 크기를 고정할 수도, 또는 무한하게 사용할 수 도 있다.

 

스레드의 대기 

스레드의 대기 : synchronized 대기

  • **대기1: 락 획득 대기**
    • `BLOCKED` 상태로 락 획득 대기
    • `synchronized` 를 시작할 때 락이 없으면 대기
    • 다른 스레드가 `synchronized` 를 빠져나갈 때 대기가 풀리며 락 획득 시도
  •  **대기2: wait() 대기**
    • `WAITING` 상대로 대기
    • `wait()` 를 호출 했을 때 스레드 대기 집합에서 대기
    • 다른 스레드가 `notify()` 를 호출 했을 때 빠져나감

스레드의 대기 : synchronized 대기

 

  • **대기1: ReentrantLock 락 획득 대기**
    • `ReentrantLock` 의 대기 큐에서 관리 `WAITING` 상태로 락 획득 대기
    • `lock.lock()` 을 호출 했을 때 락이 없으면 대기
    • 다른 스레드가 `lock.unlock()` 을 호출 했을 때 대기가 풀리며 락 획득 시도, 락을 획득하면 대기 큐를 빠 져나감
  • **대기2: await() 대기**
    • `condition.await()` 를 호출 했을 때, `condition` 객체의 스레드 대기 공간에서 관리
    • `WAITING` 상대로 대기
    • 다른 스레드가 `condition.signal()` 을 호출 했을 때 `condition` 객체의 스레드 대기 공간에서 빠져 나감

 

 

 

비동기화와 멀티스레드

멀티스레드는 말 그대로 스레드를 여러개 쓰는 것이고 비동기화는 쓰레드가 단순히 I/O작업을 기다리면서 block상태를 유지하는 것이 아니라 시스템에 Block대기를 맡기고 쓰레드는 다른 작업을 지속하는 것.

 

실무에서는 해결이 안되는 경우에는 Synchronized를 사용하고 가능한 Concurrent라이브러리를 사용해서 멀티스레드 환경을 유지함