목표

자바의 멀티쓰레드 프로그래밍에 대해 학습하세요.

학습할 것 (필수)

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

공부한 내용

1. Thread 클래스와 Runnble 인터페이스

  • Thread와 Process의 차이

    자바 Thread 클래스를 바로 보기 전에 Thread가 뭔지, 프로세스가 어떤 것인지 정리를 하고 넘어가야 할 필요성을 느껴 정리하고 넘어가고자 한다.

    Process(이하 프로세스): 자바 프로그램은 메소드, 스택, 힙 이렇게 메모리 영역을 기반으로 프로그램이 수행되는데 이렇게 할당된 메모리를 기반으로 실행중인 프로그램을 프로세스라고 한다.

    음... 조금 어려울 수 있는데 쉽게 말해서 프로세스가 일종의 실행중인 프로그램 (켜놓은 크롬 브라우저 라던지와 같은)을 의미한다고 보면 된다.

    Thread(이하 스레드): 스레드는 프로세스 안에서 둘 이상의 프로그램 흐름을 생성할 수 있는 것을 말한다.(즉, 크롬 브라우저가 프로세스라면 크롬에서 다양한 작업이 동시에 일어날 수 있는데 이것을 일종의 스레드로 봐도 될  것 같다)

    다시 정리하면 프로세스가 좀 더 큰 단위이고 스레드가 작은 단위이다.

  • 프로세스와 스레드를 이용하면 다중 또는 병렬 프로그래밍 구현이 가능하다.

    그런데 왜? 멀티 프로세스 보다 멀티 스레드를 선호하는 걸까?

  • Multi Process vs Multi Thread

    우선 멀티라는 것을 이해해야 하는데 지금은 컴퓨터 코어가 여러개 여서 넉넉한 연산이 가능하지만 코어가 하나라고 가정하고 얘기를 한다면 컴퓨터는 멀티 프로세싱 및 스레딩을 구현하기 위해서 Context Switching 이라는 것을 한다.

    그러면 Context Switching 이란? 프로세스 A 및 프로세스 B가 있다고 할 때 서로 CPU를 시분할로 점유해서 사용할 수 있는데 이럴때 기존에 하던 작업 상태를 기억해야 한다. 프로세스인 경우는 PCB에 저장한다. (기존작업 PCB 저장 -> 다른 프로세스 호출 사용)

    다시 돌아가서...그럼 왜 멀티 스레딩이 멀티 프로세싱보다 많이 사용하게 되느냐? 멀티 스레딩이 사용하는 TCP가 저장하는 정보가 더 작기 때문에 CPU에 가하는 부담을 더 줄일 수 있다.

출처: https://slideplayer.com/slide/12698810/

 

위 슬라이드에서도 보여 주듯이 TCB는 24개의 필드정보만 가지고 있는데 반해 PCB는 106개의 필드를 가지고 정보를 저장한다.
잦은 Context Switching이 일어난다면 CPU에 가해지는 부담도 커지기 때문에 스레드를 더 선호할 수 있다고 생각해 볼 수 있다. (물론 이건 개인 의견일 수 있기 때문에 각자의 생각에 따라 달라질 수 있음)

참고로 자바는 User Level Thread 란 것을 이용해 OS의 Kernel Level Thread와 1:1 매핑하여 사용한다고 한다.

 

  • 자바의 Thread 클래스

    자바의 Thread 클래스는 아래와 같이 선언할 수 있다.

class ShowThread extends Thread {
    String threadName;

    public ShowThread(String name) {
        threadName = name;
    }

    public void run() {
        for (int i=0; i<10; i++){
            System.out.println("Hello this is "+threadName);

        }
    }
}

class ThreadBasic {
    public static void main(String[] args) {
        ShowThread st1 = new ShowThread("하얀 스레드");
        ShowThread st2 = new ShowThread("검은 스레드");

        st1.start();
        st2.start();

    }
}

위 예제는 2개의 스레드를 선언하고 각자 시작했을 때 어떻게 출력되는지 보여주는 단순 예제이다.

class ShowThread extends Thread : Thread라는 이름의 클래스를 상속해서 스레드를 구현한다.

ShowThread(String name) : 생성자를 통해서 스레드 이름을 입력받도록 했다.

run() : 스레드는 스레드만의 별도 프로그램 흐름을 구성한다. 여기서 쓰이는 일종의 main 메소드 처럼 쓰이는게 run 이다.

st1.start(): 스레드 인스턴스를 만들고 시작할 수 있도록 하는 메소드가 start이다.
                 나는 기본서를 보다가 여기서 궁금증이 생겼는데 'run 메소드를 바로 호출하면 되는거 아닌가?' 였다. 책에서 확인해 보니
                 그렇게 된다면 '단순 메소드 호출이 되고 스레드가 생성되지 않는다' 라는 글을 보았기 때문에 단순히 지금은 start 메소드를
                 호출하면 스레드를 만들고 시작할 수 있구나 정도로 이해하고 넘어갔다.

결과는 아래와 같이 나온다.

 

  • Runnable 인터페이스

    지금까지 내가 배운 방법은 자바의 스레드를 사용하기 위해서는 Thread 클래스를 상속하는 방법 밖에 없었다. 자바는 다중상속 지원이 안되기 때문에 만약 다중 상속과 같은 부분이 필요하다면 어떻게 해야 할까? 그 부분을 해결하는 것이 인터페이스다.

    마찬가지로 스레드도 인터페이스 방식으로 사용할 수 있는데 그것이 Runnable 인터페이스 이다.

class Sum {
    int num;
    public Sum() { num = 0; }
    public void addNum(int n) { num += n; }
    public int getNum() { return num; }
}

class AddThread extends Sum implements Runnable {
    int start, end;

    public AddThread(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    public void run() {
        for (int i=start; i<=end; i++){
            addNum(i);
        }
    }
}

class RunnableThread {
    public static void main(String[] args) {
        AddThread at1 = new AddThread(1, 50);
        AddThread at2 = new AddThread(51, 100);
        Thread tr1 = new Thread(at1);
        Thread tr2 = new Thread(at2);
        tr1.start();
        tr2.start();

        try {
            tr1.join();
            tr2.join();

        } catch (InterruptedException ie) {
            ie.printStackTrace();
        }

        System.out.println("1 에서 100까지의 합 : "+(at1.getNum() + at2.getNum()));
    }
}

위 소스는 두개의 스레드를 각각 1~50까지 51~100까지 더하고 두 값을 다시 더한다.
말이 어려울 수 있는데 쉽게 말하면 1~100까지의 합을 출력하는데 두개의 쓰레드로 나눠서 일을 시킨 것이라고 보면된다.

class AddThread extends Sum implements Runnable : Sum 클래스를 상속하고, Runnable 인터페이스를 구현하고 있다.
                                                                                            만약 기존에 배웠던 방식으로 사용하려면 상속을 2번 해야 하는데 자바는
                                                                                            이것이 불가능하다.
                                                                                            Sum 클래스의 필드와 메소드, Runnable 인터페이스의 Run 메소드를
                                                                                            오버라이딩 한다.

AddThread at1 = new AddThread(1, 50)
... 이하 중략
Thread tr2 = new Thread(at2):
Runnable 인터페이스를 통해서 스레드를 생성하는 법을 보여 준다.
                                                    각각 1~50까지 더하는 AddThread at1과 51~100까지 더하는 AddThread at2를 생성한 후 이를 바로                                                        start() 메소드를 호출하는게 아니라 Thread 인스턴스를 생성해 매개변수로 넘겨주고 있다. 
                                                    조금 복잡하지만 결론적으로는 Thread 인스턴스를 통해서 생성(무엇을? 스레드를) 한다라고
                                                    이해하면 될 듯 하다.

tr1.start(): start() 메소드로 스레드를 생성하고 호출한다.

tr1.join(): 스레드는 메인 메소드의 수행순서와는 다르게 수행되는데 스레드를 먼저 끝내고 메인 메소드를 끝내고 싶을 때 사용할 수 있다.

결과는 다음과 같다.

2. Thread의 상태

스레드는 다음과 같은 상태를 가진다.

상태 열거 상수 설명
객체 생성 NEW 스레드 객체가 생성, 아직 start() 메소드 미호출
실행 대기 RUNNABLE 실행 상태로 언제든지 갈 수 있음
일시 정지 WAITING 다른 스레드가 알려줄 때까지 대기
TIMED_WAITING 주어진 시간동안만 대기
BLOCKED 사용하고자 하는 객체의 Lock이 걸려 대기하는 상태
종료 TERMINATED 실행이 끝남

 

그럼 실제로 이렇게 스레드의 상태가 변하는 지 확인해 볼 수 있을것 같다.

스레드의 상태 변화는 getState()라는 메소드를 통해서 확인해 볼 수 있다.

 

3. Thread의 우선순위

스레드는 두개 이상 수행 될 수 있기 때문에 JVM은 스레드의 스케쥴링을 해야 한다.

스케쥴링의 기본 원칙은 우선순위가 높은 순위로 수행되고 우선순위가 같다면 동등한 CPU 사용 시간을 할당해서 사용 하도록 한다.

우선순위는 1 단계(최하) 부터 10 단계(최고) 까지 부여 되며 임의로 부여할 수도 있다.

코드를 통해서 확인해보자.

import static java.lang.Thread.sleep;

class MessagingSendThread extends Thread {
    String message;

    public MessagingSendThread(String message, int priority) {
        this.message = message;
        super.setPriority(priority);
    }

    public void run() {
        for (int i=0; i<5; i++){
            System.out.println(message +"("+super.getPriority()+")");

            try {
                sleep(1);
            } catch (InterruptedException ie) {
                ie.printStackTrace();
            }
        }
    }
}

public class PriorityTest {
    public static void main(String[] args) {
        MessagingSendThread tr1 = new MessagingSendThread("first", Thread.MAX_PRIORITY);
        MessagingSendThread tr2 = new MessagingSendThread("second", Thread.NORM_PRIORITY);
        MessagingSendThread tr3 = new MessagingSendThread("third", Thread.MIN_PRIORITY);

        tr1.start();
        tr2.start();
        tr3.start();
    }
}

간단한 메시지를 출력하는 MessagingSendThread 를 생성하고 메인 메소드에서 호출할 때 Thread.MAX_PRIORITY 등과 같이 우선 순위를 제공하고 있다.
여기까지 나의 생각으로는 first 5개 출력 => second 5개 출력 => third 5개 출력 이렇게 될거라고 예상했으나 결과는 그렇지 않았다.

예상치 못했던 결과

아니! 이게 어떻게 된일일까...라고 여기 저기 구글링을 해보았으나 우선순위를 직접 준다곤 해도 OS의 상황또는 물리적 코어에 따라서 충분히 여유가 있을때는 변경 될 수 있다고 나온다.

음 이 부분은 한번 더 찾아봐야 할 것 같다.

4. Main 스레드

일반적으로 스레드를 임의로 생성하지 않았을 때 자바 프로그램이 main 함수로 부터 코드의 시작과 끝을 가져가는데 이것을 메인 스레드가 수행한다고 보면 된다. 

모든 스레드의 흐름은 메인에서 시작해서 구성되고 스레드가 모두 종료될 때까지 프로그램은 종료되지 않는다.

5. 동기화

두개의 쓰레드가 서로 하나의 변수에 값을 더하는 상황이라고 생각을 해보자. (하나의 변수에 서로 다른 쓰레드가 값을 참고 할 수 있다는 말은 공유 변수를 가질 수 있다는 의미)

 

위 상황과 같이 동시에 같은 변수에 데이터를 저장한다 라고 하면 그 순간, 변수의 데이터는 101이 될까? 102가 될까? 정답은 장담할 수 없다이다. 이처럼 예상을 벗어난 동작을 방지하기 위해서는 일종의 Lock을 걸어야 하는데 그것이 동기화 이다.

해결할 수 있는 방법은 Syncronized 구문을 이용해 동기화 시켜 주는 것이다.

public class SyncronizedTest {
    
    //TODO: 첫번째 방법
    public synchronized  syncTest() {
        System.out.println("Do something");
    }
    
    public static void main(String[] args) {
        //TODO: 두번째 방법
        synchronized (this) {
            //doSomething
        }
    }
}

첫번째 방법은 메소드에서 syncronized 구문을 사용하는 것이고 두번째 방법은 syncronized 블록을 만들어 사용하는 방법이다.

어떤 것이 낫다라고 명확히 얘기할 순 없으나 개인적인 경험으로는 syncronized 구문은 성능에 영향을 미치므로 최소화 해서 사용해야 한다.

따라서 개인적으로는 syncronized 구문을 써야 한다면 블록 단위로 사용하는 것이 좋을 듯 하다.

또다른 동기화 방법으로 자바에서 제공해 주는 방법은 ThreadLocal이 있다. (개인적으로는 ThreadLocal 말만 들어봤지 이번에 제대로 공부를 했다. 반성중)

import java.util.Date;

class Context {
    public static ThreadLocal<Date> local = new ThreadLocal<Date>();
}

class A {
    public void a() {
        Context.local.set(new Date());

        System.out.println(Context.local.get());
        B b = new B();
        b.b();

        Context.local.remove();
    }
}

class B {
    public void b() {
        Date date = Context.local.get();

        System.out.println(date);

        C c = new C();
        c.c();
    }
}

class C {
    public void c() {
        Date date = Context.local.get();

        System.out.println(date);
    }
}

class ThreadLocalTest {
    public static void main(String[] args) {
        A a = new A();
        a.a();
    }
}

구조를 보면 Context라는 클래스에 ThreadLocal 변수를 선언해 놓고 활용하는 구조다.

A, B, C 클래스가 있는데 A 클래스에서 값을 넣고 B, C 클래스에서 이를 읽어와서 사용한다.

ThreadLocal은 Thread 클래스와 관련이 있다. Thread.currentThread() 메소드로 현재 실행중인 스레드를 확인할 수 있고 실제로 Thread 클래스는 ThreadLocal.ThreadLocalMap threadLocals = null; 이라는 형태로 threadLocals 라는 변수가 있다.

실제로 ThreadLocal의 변수는 여기에 저장이 되고 보다시피 ThreadLocalMap은 16개의 엔트리를 가지는 배열로 선언되며 변수양에 따라서 더 늘어난다.

ThreadLocal를 사용할 때 주의할 점이 있는데 시간복잡도가 O(n)이므로 변수가 많아질수록 성능에 영향을 미치는 부분이 크니 이 부분을 고려해서 프로그래밍을 해야 한다!

6. 데드락

프로세스가 서로 붙잡고 있어서 아무것도 하지 못하는 상태를 데드락 상태라고 한다. (흔히 먹통)

어떤 상황에서 이런 문제가 발생할까?

 

서로 자원을 점유하고 있어 이러지도 저러지도 못하는 상황이다.

데드락이 발생하는 조건은 아래와 같다.

상호 배제 자원은 한번에 한 프로세스만 사용할 수 있어야 함
점유 대기 최소한 하나의 자원을 점유하고 있으면서 다른 프로세스에 할당된 자원을 추가 점유 위해 대기
비선점 다른 프로세스에 할당된 자원은 뺏을 수 없음
순환 대기 프로세스의 집합에서 (P0~ PN) 이전 프로세스가 다음 프로세스가 점유한 자원을 대기하는 상태를 연달아서 가짐

처리 방법은 교착 상태를 예방 및 회피하기 위해 일종의 Watchdog과 같은 감시 프로그램을 데몬처럼 띄워서 감시, 확인 후 릴리즈 하는 방법이 있다.

또는 은행원 알고리즘이 있는데 은행원이 대출 심사자를 꼼꼼하게 보고 대출을 제공하는 것처럼 자원을 할당하는 메인 프로세스는 자원을 요구하는 프로세스의 상태 및 가용 가능한 자원을 꼼꼼하게 확인 후 데드락 상태에 빠지지 않을 상황일 때만 자원을 제공한다.

 

7. 참고

+ Recent posts