Notice
Recent Posts
Recent Comments
Link
«   2025/12   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

기록하자..

멀티스레드 프로그래밍 | 백기선님 LIVE-STUDY 본문

자바

멀티스레드 프로그래밍 | 백기선님 LIVE-STUDY

P23Yong 2022. 5. 31. 15:49

멀티 쓰레드 프로그래밍

학습할 것

  • Thread 클래스와 Runnable 인터페이스
  • 쓰레드의 상태
  • 쓰레드의 우선순위
  • Main 쓰레드
  • 동기화
  • 데드락

Process와 Thread

  • 프로세스

    • 프로세스란 간단히 말해 실행 중인 프로그램을 말한다. 좀 더 자세히 말해보면, 사용자가 작성한 프로그램이 운영체제로부터 메모리를 할당받아 실행 중인 것을 말한다.

    • 프로세스는 프로그램을 수행하는데 필요한 데이터와 메모리등의 자원, 쓰레드로 구성되어 있다.

  • 쓰레드

    • 프로세스의 자원을 이용해서 프로세스 내에서 실제로 작업을 수행하는 주체이다.
    • 모든 프로세스에는 1개 이상의 쓰레드가 존재하여 작업을 수행할 수 있다.

Thread 클래스와 Runnable 인터페이스

쓰레드를 생성할 수 있는 방법은 크게 두 가지가 있다.

  1. Thread 클래스를 상속받는 방법
  2. Runnable 인터페이스를 구현하는 방법

Thread 클래스도 기본적으로 Runnable인터페이스를 구현하고 있기 때문에 어느 것을 사용해도 좋다.

먼저 Thread 클래스를 상속받는 법을 알아보자.

public class ThreadCreation extends Thread {

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());

        ThreadCreation threadCreation = new ThreadCreation();
        threadCreation.start();
    }
}
main
Thread-0

다음은 Runnable 인터페이스를 람다를 이용해 구현한 경우이다.

public class ThreadCreation {

    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());

        Thread newThread = new Thread(() ->
                System.out.println(Thread.currentThread().getName()));
        newThread.start();
    }
}
main
Thread-0

언제 Thread 클래스를 상속받고 언제 Runnable 인터페이스를 구현해야할까?

Thread 클래스를 상속받는 경우는 run() 말고도 다른 것들을 오버라이딩 해야하는 경우가 있을 때 사용할 수 있다.

Runnable 인터페이스를 구현하는 경우는 run() 메서드만 사용하는 경우 혹은 Thread 클래스 말고도 다른 클래스를 상속받아야 할 때 사용할 수 있다.

start()와 run()

쓰레드를 실행하기 위해서는 start()메서드를 통해 해당 쓰레드를 호출해야 한다. start()가 호출되면 실행대기 상태로 가서 자신의 차례가 오기를 기다린다. start()메서드는 쓰레드가 작업을 실행할 호출 스택을 만들고 그 안에 run() 메서드를 올려주는 역할을 한다.

한 번 실행이 종료된 쓰레드는 다시 실행할 수 없다. 즉, 하나의 쓰레드에 대해 start()가 한 번만 호출될 수 있음을 의미한다. 그렇기 때문에 작업을 다시 수행하기 위해서는 새로운 쓰레드를 생성하고 다시 start()를 호출해 주어야 한다. 만약 같은 쓰레드에 대해 start()를 두 번 호출하게 되면 실행 시에 IllegalThreadStateException이 발생한다.

그러면 start()run()의 차이는 무엇일까?

모든 쓰레드는 독립적인 작업을 수행하기 위해서 자신만의 호출 스택을 필요로 한다.
start()run()의 차이는 바로 호출 스택을 만드는지의 여부에 차이가 있다.

run()메서드는 단순히 쓰레드가 실행할 때 수행할 동작에 대한 코드를 작성해 놓은 것이다. 별도의 실행 흐름이 만들어 지는 것이 아니기 때문에 별도의 호출 스택도 만들어지지 않는다.

쓰레드의 상태

쓰레드는 다음 상태 중 한 가지를 가진다.

  • NEW
    • 쓰레드는 생성되었지만 아직 시작되지 않은 상태
  • RUNNABLE
    • 쓰레드가 JVM에서 실행 중 혹은 실행 대기 중인 상태
  • BLOCKED
    • 쓰레드가 실행 중지 상태이며, 모니터 락이 풀리기를 기다리는 상태
  • WAITING
    • 다른 쓰레드가 특정 작업을 수행할 때까지 무한정 대기 중인 상태
  • TIMED_WAITING
    • 다른 쓰레드가 특정 작업을 수행할 때까지 특정 시간만큼 대기 중인 상태
  • TERMINATED
    • 쓰레드가 종료된 상태

Thread 클래스에서 enum으로 정의되어 있다.

  • suspend()
    • 쓰레드를 일시정지 시킨다. resume()을 호출하면 다시 실행 대기 상태가 된다.
  • sleep(long millis) / sleep(long millis, int nanos)
    • 지정된 시간동안 쓰레드를 일시정지 시킨다. 지정한 시간이 지나면 자동으로 실행 대기 상태가 된다.
  • join() / join(long millis) / join(long millis, int nanos)
    • 지정된 시간동안 쓰레드가 실행되도록 한다. join()을 호출한 쓰레드는 그동안 일시정지 상태가 된다.
      지정된 시간이 지나거나 작업이 종료되면 다시 돌아와 작업을 수행한다.
  • wait()
    • 객체의 락을 풀고 쓰레드를 해당 객체의 waiting pool에 넣는다.
  • resume()
    • suspend()에 의해 일시정지 상태에 있는 쓰레드를 실행대기 상태로 만든다.
  • notify()
    • waiting pool에서 대기 중인 쓰레드를 하나 깨운다.
  • interrupt()
    • sleep()이나 join()에 의해 일시정지 상태인 쓰레드를 깨워서 실행대기 상태로 만든다. 해당 쓰레드에서는 InterruptedException이 발생함으로써 일시정지 상태를 벗어나게 된다.
  • yield()
    • 실행 중에 자신에게 주어진 실행시간을 다른 쓰레드에게 양보하고 자신은 실행대기 상태가 된다.

resume(), stop(), suspend()는 쓰레드를 교착상태로 만들기 쉽기 때문에 deprecated 되었다.

예제를 보자. 다음 예제는 해당 블로그에서 가져온 것이다. (정리가 굉장히 잘 되어있다.)

public class ThreadState extends Thread {

    private Object monitor;

    public ThreadState(Object monitor) {
        this.monitor = monitor;
    }

    @Override
    public void run() {
        try {
            for (int i = 0; i < 100000; i++) {
                String a = "Something";
            }
            synchronized (monitor) {
                monitor.wait();
            }

            System.out.println(getName() + " is notified");
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    // ....
}
  • 쓰레드를 실행 중인 상태로 만들기 위해 String 객체를 생성한다.
  • synchronized 블럭안에서 monitor 객체의 wait() 메서드를 호출한다.
  • wait()이 끝나면 1초 후 이 쓰레드는 종료 된다.
public static void main(String[] args) {
        Object monitor = new Object();
        ThreadState thread = new ThreadState(monitor);
        ThreadState thread2 = new ThreadState(monitor);

        try {
            System.out.println("Thread State = " + thread.getState());
            thread.start();
            thread2.start();
            System.out.println("Thread State(after start) = " + thread.getState());

            Thread.sleep(100);
            System.out.println("Thread State(after 0.1 sec) = " + thread.getState());

            synchronized (monitor) {
                monitor.notifyAll();
            }

            Thread.sleep(100);
            System.out.println("Thread State(after notify) = " + thread.getState());

            thread.join();
            System.out.println("Thread State(after join) = " + thread.getState());
            thread2.join();
            System.out.println("Thread State(after join) = " + thread2.getState());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
Thread State = NEW
Thread State(after start) = RUNNABLE
Thread State(after 0.1 sec) = WAITING
Thread-1 is notified
Thread-0 is notified
Thread State(after notify) = TIMED_WAITING
Thread State(after join) = TERMINATED
Thread State(after join) = TERMINATED

thread, thread2가 생성되고 start()를 호출해 runnable 상태로 만든다. 해당 쓰레드들은 wait()에 의해 WATING 상태가 되고, main 메서드의 synchronized블럭에 들어가 notify된다. 이후 쓰레드가 종료될 떄까지 기다렸다가 상태를 출력해준다.

쓰레드의 우선순위

자바에서 쓰레드는 우선순위에 관한 자신만의 필드를 가지고 있다. java.lang.Thread를 보면 public 한 우선순위에 대한 필드가 있다.

  • public static final int MIN_PRIORITY = 1;
  • public static final int NORM_PRIORITY = 5;
  • public static final int MAX_PRIORITY = 10;

쓰레드의 우선순위는 setPriority(int newPriority) 메서드를 통해 설정할 수 있고 getPriority() 메서드를 통해 가져올 수 있다.

쓰레드가 가질 수 있는 우선순위의 범위는 1~10이며 숫자가 높을수록 우선순위가 높다.

쓰레드의 우선순의는 쓰레드를 생성한 쓰레드로부터 상속받게 된다. main 메서드를 수행하는 쓰레드의 우선순위가 5이기 때문에 main 메서드 내에서 생성되는 쓰레드의 우선순위는 자동적으로 5가 된다.

Main 쓰레드

우리가 자바 프로그램을 시작하게 되면 하나의 쓰레드가 바로 실행되는데, 이를 main thread라고 부른다.

  • 이 쓰레드는 추가적인 쓰레드를 만들 수 있다.
  • 프로그램이 종료되기 전 마지막으로 종료되는 쓰레드여야 한다.

각 자바 프로그램이 실행될 떄 JVM에 의해 main 쓰레드가 생성된다.

Daemon 쓰레드

  • Main 쓰레드의 작업을 돕는 보조적인 역할을 하는 쓰레드이다.
  • Main 쓰레드가 종료되면 데몬 쓰레드는 강제적으로 종료가 된다.
    • Main 쓰레드의 보조 역할을 수행하기 때문에 Main 쓰레드가 종료되면 종료된다.

동기화

멀티 쓰레드 프로그램의 경우 쓰레드들이 같은 프로세스 내의 자원을 공유하기 때문에 문제가 발생할 수 있다.

문제가 발생하지 않도록 방지하기 위해 쓰레드는 작업을 수행하는 동안 방해를 받지 않도록 보장받는 것이 필요하다.
임계 영역과 잠금을 이용해 방해받지 않도록 보장하며 방해 받지 않도록 하는 작업을 동기화라고 한다.

자바에서 동기화를 진행하는 방법은 3가지가 있다.

  • synchronized 키워드
  • Atomic 클래스
  • volatile 키워드

먼저 synchronized에 대해 알아보자.

synchronized

동기화를 하기 위해 쓰레드가 작업을 하는 동안 다른 쓰레드가 작업을 방해하지 않도록 하는 구간을 임계 영역으로 설정해주어야 한다. synchronized 키워드를 이용해 이를 설정한다.

synchronized 키워드는 다음과 같이 사용할 수 있다.

  • 메서드 자체를 synchronized로 선언하는 방법
  • 메서드 내의 특정 구간만 synchronized로 감싸는 방법
// 메서드 자체를 synchronized로 선언
public synchronized void method() {
    // ...
}

// 메서드 내의 특정 구간만 synchronized로 선언
public void method2() {
    // ...
    sychronized (객체의 참조 변수) {
        // ...
    }
}

메서드 자체를 synchronized로 선언하면 해당 메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 락(lock)을 얻어 작업을 수행하고 메서드가 종료되면 락을 반환한다.

메서드 내의 특정 구간만 synchronized로 감싸는 경우, 객체의 참조 변수는 락(lock)을 걸고자 하는 객체를 참조해야 한다. 이 참조 영역으로 들어가면서 쓰레드는 락을 얻게 되고 해당 영역을 벗어나면 락을 돌려주게 된다.

Atomic

Atomic은 CAS 방식에 기반해 동기화 문제를 해결한다.
CAS란 Compare And Swap의 약자로 변수의 값을 변경하기 전에 기존에 가지고 있던 값이 내가 예상하는 경우와 같은 경우에만 새로운 값을 할당하는 방법이다.

Java에서 제공하는 Atomic Type은 Wrapping 클래스의 일종으로 참조 타입과 원시 타입 두 종류의 변수에 모두 적용이 가능하다. 사용시 내부적으로 CAS 알고리즘을 하드웨의 도움을 받아 락 없이 단 하나의 쓰레드만 변수의 값을 변경할 수 있도록 제공하고 있다.

Atomic의 CAS는 compareAndSet() 메서드가 역할을 수행하고 있다.
두 개 이상의 쓰레드가 접근하려 할 때 쓰레드를 blocking하는 것이 아니다.

@HotSpotIntrinsicCandidate
public final int getAndSetInt(Object o, long offset, int newValue) {
    int v;
    do {
        v = getIntVolatile(o, offset);
    } while (!weakCompareAndSetInt(o, offset, v, newValue));
    return v;
}

volatile

volatile 키워드는 Java 변수를 메인 메모리에 저장하겠다는 것이다. volatile 변수는 캐시가 아닌 메인 메모리에서 읽어오게 된다. 또한 변수를 작성할 때마다 메인 메모리까지 작성하겠다는 것이다.

volatile를 사용하지 않는 멀티 쓰레드 프로그램에서 작업을 수행하는 동안 성능 향상을 위해서 메인 메모리에서 읽은 변수를 캐시에 저장한다. 만약 CPU가 두 개 이상이라면 각 쓰레드는 다른 CPU에서 돌아가게 될 것이다. 이는 각 쓰레드가 다른 CPU의 CPU캐시에 변수를 복사할 수 있다는 것이다. 밑의 그림을 보자.

non-volatile 변수는 JVM이 메인 메모리에서 캐시로 데이터를 읽거나 캐시에서 메인 메모리로 데이터를 쓰는 경우를 보장할 수 없다.
이로 인해 몇 가지 문제가 발생할 수 있다.

다음과 같이 공유 객체가 있고 counter라는 변수가 있다고 하자.

public class SharedObject {

    public int counter = 0;
}


그리고 위의 그림과 같이 Thread1만 counter의 값을 증가시키지만 Thread1과 Thread2 모두 counter를 읽을 수 있다고 생각하자. counter 변수가 volatile로 선언되지 않았기 때문에 counter 값이 메인 메모리까지 써지는 것을 보장하지 않는다. 이는 CPU 캐시 안에 있는 counter 변수의 값이 메인 메모리에 있는 값과 일치하지 않는다는 것을 의미할 수도 있다.

이는 volatile 키워드를 추가해 해결할 수 있다.

public class SharedObject {

    public volatile int counter = 0;
}

volatile 변수는 읽기와 쓰기를 메인 메모리에서 진행하게 된다. 그런데 CPU 캐시보다 메인 메모리에서 작업하는 비용이 더 크기 때문에 변수 값 일치를 보장해야 하는 경우 사용하는 것이 좋다.

출처: Jenkov-volatile

락(lock)

락은 일종의 자물쇠같은 개념이다. 한 객체의 락은 하나씩 가지고 있기 때문에 쓰레드 하나가 락을 얻었다면 다른 쓰레드는 락을 얻을 수 없고 해당 락이 반환될 때까지 기다리는 구조이다.

임계 영역은 프로그램의 성능에 영향을 주기 떄문에 메서드 전체에 락을 거는 것보다 특정 객체에 락을 거는 것이 더 좋다.

wait()과 notify()

특정 쓰레드가 락을 보유한 채로 작업이 완료될 때까지 오랜 시간을 보내게 되면, 다른 쓰레드의 작업에 영향을 줄 것이다.

이런 상황을 개선하기 위한 것이 wait()notify()이다. 동기화된 임계 영역의 작업을 수행하다가 작업을 더 이상 진행할 상황이 아니면 일단 wait()을 호출해 쓰레드가 락을 반납하게 하고 기다린다.
그렇게 반납한 락을 이용해 다른 쓰레드가 작업을 수행할 수 있게 된다. 나중에 작업을 수행할 수 있는 상황이 온다면 notify()를 호출해 락을 얻어 작업을 진행할 수 있게 된다.

데드락

데드락은 쓰레드가 객체의 락을 기다리고 있을 떄 발생할 수 있다. 쓰레드가 두 개 있다고 가정할 때, 첫 번째 쓰레드는 두 번째 쓰레드에 의해 획득되고 두 번째 쓰레드는 첫 번째 쓰레드에 의해 획득된 객체 락을 기다리고 있다. 두 쓰레드는 이 상태를 모르며, 두 쓰레드는 서로의 락이 release되기를 기다리고 있으며 이상태를 데드락이라고 한다.

public class ThreadDeadLock {
    public static void main(String[] args) {
        final String resource1 = "resource1";
        final String resource2 = "resource2";

        // t1 쓰레드가 resource1에 대한 락을 가진 상태로 resource2 락을 요청하고 있다.
        Thread t1 = new Thread() {
            public void run() {
                synchronized (resource1) {
                    System.out.println("Thread 1: locked resource 1");

                    try { Thread.sleep(100);} catch (Exception e) {}

                    synchronized (resource2) {
                        System.out.println("Thread 1: locked resource 2");
                    }
                }
            }
        };

        // t2 쓰레드가 resource2에 대한 락을 가진 상태로 resource1 락을 요청하고 있다.
        Thread t2 = new Thread() {
            public void run() {
                synchronized (resource2) {
                    System.out.println("Thread 2: locked resource 2");

                    try { Thread.sleep(100);} catch (Exception e) {}

                    synchronized (resource1) {
                        System.out.println("Thread 2: locked resource 1");
                    }
                }
            }
        };


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

출처 : javapoint DeadLock

데드락의 조건

데드락의 발생 조건은 다음 4가지이다.

  1. Exclusive use of resources
    • 한 순간에 한 프로세스만 사용 가능한 자원
  2. Non-preemptible resources
    • 선점 당하면 이후 진행에 문제가 생기는 자원
  3. Hold and wait
    • 자원을 하나 hold한 상태로 다른 자원을 요청하는 상태
  4. Circular wait
    • 각 프로세스가 순환적으로 다음 프로세스가 요구하는 자원을 가지고 있는 상태

위 조건 중에서 하나라도 만족하지 않으면 데드락을 발생하지 않는다.

데드락 해결 방법

데드락을 해결하기 위해서 세 가지 방법을 사용한다.

  1. 교착상태 예방
  2. 교착상태 회피
  3. 교착상태 탐지 및 복구

교착상태 예방

교착상태를 예방하는 방법은 위의 네 가지 데드락 발생 조건 중 하나를 제거하는 것이다.

  • Exclusive use of resources
    • 모든 자원을 공유 허용하는 것인데, 현실적으로 불가능하다.
  • Non-preemptible resources
    • 모든 자원에 대해 선점 허용하는 것인데, 현실적으로 불가능하다.
  • Hold and wait
    • 필요 자원을 한 번에 모두 할당하는 것이다.
    • 자원 낭비가 발생할 수 있다.
  • Circular wait
    • 자원들에게 순서를 부여하는 방법이다. 프로세스는 순서가 증가하는 방향으로만 자원이 요청 가능하다.
    • 자원 낭비가 발생할 수 있다.

교착상태 회피

시스템의 상태를 계속 감시하는 것이다. 시스템이 데드락 상태가 될 가능성이 있는 자원의 할당을 보류한다.

교착상태 회피 알고리즘은 두 가지가 있다.

  1. Banker's algorithm
  2. Habermann’s algorithm

교착상태 탐지 및 복구

일단 데드락이 발생하도록 내버려 두고 데드락이 발생하면 이를 탐지하고 복구하는 방법이다.

주기적으로 데드락의 발생을 확인하고 Resource Allocation Graph(RAG)를 사용한다.

'자바' 카테고리의 다른 글

Annotation | 백기선님 LIVE-STUDY  (0) 2022.08.06
Enum | 백기선님 LIVE-STUDY  (0) 2022.08.06
예외처리 | 백기선님 LIVE-STUDY  (0) 2022.05.31
인터페이스 | 백기선님 LIVE-STUDY  (0) 2022.05.31
패키지 | 백기선님 LIVE-STUDY  (0) 2022.05.31