Web/Java

[Java] 멀티쓰레드 프로그래밍

학습할 것

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

 

Thread 클래스와 Runnable 인터페이스

 

쓰레드를 생성하는 방식에는 두가지가 있습니다.

 

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

 

Thread 클래스 상속

class MyThread extends Thread {
    public void run() {}
}

 

Runnable 인터페이스 구현

class MyThread implements Runnable {
    public void run() {}
}

 

 

차이점

다음의 코드를 통해서 두 방식의 차이점을 알아보겠습니다.

package javastudy.ch10;

public class ThreadEx1 {
    public static void main(String[] args) {
        
        // Thread 상속방식
        ThreadThread t1 = new ThreadThread();

        // Runnable 구현방식
        Runnable r = new ThreadRunnable();
        Thread t2 = new Thread(r);

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

class ThreadThread extends Thread {
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(getName());
        }
    }
}

class ThreadRunnable implements Runnable {
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName());
        }
    }
}

 

 

Thread 상속을 사용할땐 클래스 인스턴스를 생성하는 일반방식이랑 같습니다.

Thread를 상속받은 클래스의 타입으로 인스턴스를 생성해 바로 사용하면 됩니다.

 

하지만 Runnable 인터페이스를 구현하는 방식을 사용하는 방식은 약간 다릅니다.

Runnable 타입으로 인스턴스를 생성 후 Thread 생성자에 주입해주는 방식으로 사용합니다.

 

실제 Thread.java 에는 생성자로 Runnable 타입의 참조변수를 받고 run()에서 이 참조변수의 run()을 호출하기 때문에 오버라이딩이 따로 필요하지 않습니다.

 

 

 

 

현재 실행중인 쓰레드의 참조 반환하기

 

위의 두 방식은 현재 실행중인 쓰레드의 참조를 반환하는 것도 다릅니다.

 

 

Thead 상속 방식

 

Thread를 상속받았기 때문에 getName()을 호출하면 됩니다.

 

class ThreadThread extends Thread {
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(getName());
        }
    }
}

 

Runnable 구현방식

 

Runnable을 구현한 방식에는 멤버가 run() 밖에 없기때문에 Thread 상속방식 처럼 getName()만으로 현재 실행중인 쓰레드의 참조를 반환할 수는 없습니다.

 

그래서 Thread의 static 메서드를 이용해 현재 실행중인 Thread를 반환받는 방법을 사용합니다.

class ThreadRunnable implements Runnable {
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName());
        }
    }
}

 

 

 

그럼 어떤 것을 선택해야 하나?

 

어느 쪽을 선택해도 별 차이는 없지만 Thread클래스를 상속받으면 다른 클래스를 상속받을 수 없기 때문에, Runnable 인터페이스를 구현하는 방법이 일반적입니다.

 

Runnable 인터페이스를 구현하는 방법은 재사용성이 높고 코드의 일관성을 유지할 수 있기 때문에 보다 객체지향적인 방법이라 할 수 있겠습니다.

 

 

 

 

 

 

start() vs run()

아래 코드를 잘 보면 run()을 사용하지 않고 start()를 이용해 쓰레드를 생성하는 것을 볼 수 있습니다.

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

        // Thread 상속방식
        ThreadThread t1 = new ThreadThread();

        // Runnable 구현방식
        Runnable r = new ThreadRunnable();
        Thread t2 = new Thread(r);

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

 

 

start()와 run()은 차이가 있습니다.

 

  • start()는 새로운 호출스택 생성 후 메서드 호출
  • run()은 현재 호출스택에서 메서드 호출

 

사실 Java에서는 main메서드가 실행될때도 호출스택에서 실행이 됩니다.

 

 

쓰레드를 start()를 통해 실행시키면 새로운 호출스택을 생성하고 그 스택에 run을 실행하게 됩니다.

 

그럼 이제 호출스택이 2개이므로 스케줄러가 정한 순서에 의해서 번갈아가면서 실행됩니다.

 

 

 

start

start()를 실행했을 때 printStackTrace()를 통해 호출스택을 출력해보면 호출스택의 첫번째 메서드가 main이 아니라 run 메서드입니다.

package javastudy.ch10;

public class ThreadEx3 {
    public static void main(String[] args) {
        ThreadEx3_1 t1 = new ThreadEx3_1();
        t1.start();
    }
}

class ThreadEx3_1 extends Thread {
    public void run() {
        throwException();
    }

    public void throwException() {
        try {
            throw new Exception();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

 

 

 

run

하지만 run을 실행했을때의 호출스택의 첫번째 메서드는 main인 것을 확인할 수 있습니다.

package javastudy.ch10;

public class ThreadEx3 {
    public static void main(String[] args) {
        ThreadEx3_1 t1 = new ThreadEx3_1();
//        t1.start();
        t1.run();
    }
}

class ThreadEx3_1 extends Thread {
    public void run() {
        throwException();
    }

    public void throwException() {
        try {
            throw new Exception();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

 

 

 

 

Main 쓰레드

 

모든 쓰레드는 독립적인 작업을 수행하기위해 자신만의 호출스택을 필요로 합니다.

그래서 새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스택이 생성되고 쓰레드가 종료되면 작업에 사용된 호출스택은 소멸됩니다.

 

main()메서드의 작업을 수행하는 것도 쓰레드이며, 이를 main쓰레드라고 합니다.

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

 

 

 

 

 

 

 

 

쓰레드 스케줄링 메서드

 

메서드 설명
static void sleep(long millis)
static void sleep(long millis, int nanos)
현재 실행중인 쓰레드를 지정된 시간동안 일시정지 시킨다.
지정한 시간이 지나고 나면, 자동적으로 다시 실행대기상태가 된다.
void join()
void join(long millis)
void join(long millis, int nanos)
지정된 시간동안 쓰레드가 실행되도록 한다.
지정된 시간이 지나거나 작업이 종료되면 join()을 호출한 쓰레드로 다시 돌아와 실행을 계속한다.
void interrupt() sleep()이나 join()에 의해 일시정지상태인 쓰레드를 깨워서 실행대기상태로 만든다. 해당 쓰레드에서는 InterruptException이 발생함으로써 일시정지상태를 벗어나게 된다.
void stop() 쓰레드를 즉시 종료시킨다.
void suspend() 쓰레드를 일시정지시킨다. resume()을 호출하면 다시 실행대기상태가된다.
void resume() suspend()에 의해 일시정지상태에 있는 쓰레드를 실행대기상태로 만든다.
static void yield() 현재 실행중인 쓰레드에게 주어진 실행시간을 다른 쓰레드에게 양보하고 자신은 실행대기상태가 된다.

 

 

 

 

쓰레드의 상태

상태 설명
NEW 쓰레드가 생성되고 아직 start()가 호출되지 않은 상태
RUNNABLE 실행 중 또는 실행 가능한 상태
BLOCKED 동기화블럭에 의해서 일시정지된 상태(lock이 풀릴 때까지 기다리는 상태)
WAITING, TIMED_WAITING 쓰레드의 작업이 종료되지는 않았지만 실행가능하지 않은(unrunnable) 일시정지상태. TIMED_WATING은 일시정지시간이 지정된 경우를 의미한다.
TERMINATED 쓰레드의 작업이 종료된 상태

 

 

⓵ 쓰레드를 생성하고 start()를 호출하면 바로 실행되는 것이 아니라 실행대기열에 저장되어 자신의 차례가 될 때까지 기다려야한다. 실행대기열은 큐(queue)와 같은 구조로 먼저 실행대기열에 들어온 쓰레드가 먼저 실행된다.

 

⓶ 실행대기상태에 있다가 자신의 차례가 되면 실행상태가 된다.

 

⓷ 주어진 실행시간이 다되거나 yield()를 만나면 다시 실행대기상태가 되고 다음 차례의 쓰레드가 실행상태가 된다.

 

⓸ 실행 중에 suspend(), sleep(), wait(), join(), I/O block에 의해 일시정지상태가 될 수 있다.

I/O block은 입출력작업에서 발생하는 지연상태를 말한다. 예를 들어 사용자의 입력을 기다리는 경우에는 일시정지 상태에 있다가 사용자가 입력을 마치면 다시 실행대기상태가 된다.

 

⓹ 지정된 일시정지시간이 다되거나(time-out), notifty(), resume(), interrupt()가 호출되면 일시정지상태를 벗어나 다시 실행대기열에 저장되어 자신의 차례를 기다리게 된다.

 

⓺ 실행을 모두 마치거나 stop()이 호출되면 쓰레드는 소멸된다.

 

 

 

 

 

쓰레드의 우선순위

쓰레드는 우선순위 (priority)라는 멤버변수를 가지고 있는데, 이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라집니다.

 

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

main메서드를 수행하는 쓰레드는 우선순위가 5이므로 main메서드내에서 생성하는 쓰레드의 우선순위는 자동적으로 5가 됩니다.

 

우선순위 지정하기

void setPriority(int newPriority); // 쓰레드의 우선순위를 지정한 값으로 변경한다.
int getPriority(); // 쓰레드의 우선순위를 반환한다.

public static final int MAX_PRIORITY = 10; // 최대 우선순위
public static final int MIN_PRIORITY = 1; // 최소 우선순위
public static final int NORM_PRIORITY = 5; // 보통 우선순위

 

 

 

ThreadEx8_1 은 "-" 를 출력하는 쓰레드,

ThreadEx8_2 는 "|"를 출력하는 쓰레드입니다.

 

여기서 ThreadEx8_2 의 우선순위를 7로 주겠습니다.

 

그러면, ThreadEx8_1, ThreadEx8_2 둘 다 main 쓰레드에서 생성되었기때문에 우선순위 5를 상속받지만 ThreadEx8_2 는 직접 7로 바꾸어주었기 때문에 둘의 우선순위가 다르게 됩니다.

 

우선순위

  • ThreadEx8_1 : 5
  • ThreadEx8_2 : 7
package javastudy.ch10;

public class ThreadEx2 {
    public static void main(String[] args) {
        ThreadEx8_1 th1 = new ThreadEx8_1();
        ThreadEx8_2 th2 = new ThreadEx8_2();

        th2.setPriority(7);

        System.out.println("Priority of th1 (-) : " + th1.getPriority());
        System.out.println("Priority of th2 (|) : " + th2.getPriority());

        th1.start();
        th2.start();
    }
}

class ThreadEx8_1 extends Thread {
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print("-");
            for (int j = 0; j < 10000000; j++);
        }
    }
}

class ThreadEx8_2 extends Thread {
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print("|");
            for (int j = 0; j < 10000000; j++);
        }
    }
}

 

실행결과

 

우선순위에 따라 "|"를 출력하는 쓰레드가 더 먼저 수행되는 것을 볼 수 있었습니다.

Priority of th1 (-) : 5
Priority of th2 (|) : 7
-||-|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||-------------|||||||||||||||||||||||||||||||||||||||||||--------------------------------------------------||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

 

 

그런데, 단순한 호기심으로 여러번 똑같이 프로그램을 돌려보았습니다.

 

2차시도

Priority of th1 (-) : 5
Priority of th2 (|) : 7
-|-|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||---------------------------------------||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

 

3차시도

Priority of th1 (-) : 5
Priority of th2 (|) : 7
-|------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||

 

4차시도

Priority of th1 (-) : 5
Priority of th2 (|) : 7
-|-|---------------------------------------------------------------------------------------------------------||||-----|||||||||||||||||||||||||||||||||||||||||||||||||---------------------||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||-----------|||||||||||||||||---------||||||||||||||--|||||-----------------------------------------||||||----------------------------------------------||----------------||||||||||||||||||------------------------------------------|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||

 

 

 

ThreadEx8_2의 우선순위가 더 높다고해도 무조건 더 많은 실행시간과 실행기회를 갖게 될 것이라고 기대할 수 없었습니다.

 

왜냐하면, 멀티코어라고 해도 OS의 스케줄링 정책과 JVM의 쓰레드 우선순위와 관련된 구현이 다를 수 있기 때문입니다.

 

 

해결방법

만약 실제로 항상 먼저 수행되어야하는 작업이 있다면 우선순위를 부여하는 대신 우선순위에 따라 PriorityQueue에 저장해 놓고, 우선순위가 더 높은 작업이 처리되도록 하는 것이 더 나을 수 있습니다.

 

 

 

 

 

동기화 (Synchronization)

 

멀티쓰레드 환경의 문제점

멀티쓰레드프로그래밍 환경에서는 공유데이터를 여러 쓰레드에서 수정할때 의도했던 것과 다른 결과를 얻을 수 있습니다.

 

대표적인 예로 은행계좌와 잔고 문제가 있습니다.

 

은행계좌에서 잔고가 뽑을 돈 보다 많은 경우에만 돈을 뽑을 수 있도록 프로그래밍을 해놓았습니다.

하지만 if문으로 잔고가 뽑을 돈 보다 많은 경우를 통과했을때 다른 쓰레드에서 동시에 잔고에 접근해 돈을 뽑을 경우 잔고가 마이너스(-)가 될 경우를 생각해볼 수 있습니다.

 

이해하기 쉽게 코드로 보겠습니다.

package javastudy.ch10;

public class ThreadEx4 {
    public static void main(String[] args) {
        Runnable r = new RunnableEx4();
        new Thread(r).start();
        new Thread(r).start();
    }
}

class Account {
    private int balance = 1000;

    public int getBalance() {
        return balance;
    }

    public void withdraw(int money) {
        if (balance >= money) { // 잔고가 뽑을 돈보다 많을 경우에만 인출 가능
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {}
            balance -= money;
        }
    }
}

class RunnableEx4 implements Runnable {
    Account acc = new Account();
    
    public void run() {
        while (acc.getBalance() > 0) {
            // 100, 200, 300 중의 한 값을 임의로 출금
            int money = (int) (Math.random() * 3 + 1) * 100;
            acc.withdraw(money);
            System.out.println("balance : " + acc.getBalance());
        }
    }
}

 

실행결과

 

 

 

한 쓰레드에서 잔고가 200이 남아서 조건식 (balance >= money)가 true가 되어 출금을 수행하려는 순간 다른 쓰레드로 제어권이 넘어가 300을 출금하여 잔고가 결국 -100이 된 상황으로 볼 수 있습니다.

 

 

 

 

 

synchronization 적용

위와같은 문제 때문에 도입된 개념이 바로 '임계 영역(critical section)'과 '잠금(락, lock)'입니다.

 

공유데이터를 사용하는 코드 영역을 임계 영역으로 지정해놓고, 공유 데이터(객체)가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 합니다. 그리고 해당 쓰레드가 임계 영역 내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만 다른 쓰레드가 반납된 lock을 획득하여 임계 영역의 코드를 수행할 수 있게 됩니다.

 

이처럼 한 쓰레드가 진행중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 '쓰레드의 동기화(synchronization)'라고 합니다.

 

 

 

synchronized 키워드

가장 간단한 동기화 방법인 synchronized 키워드를 이용한 방법이 있습니다.

 

아래와 같이 두 가지 방식이 있습니다.

 

1. 메서드 전체를 임계영역으로 지정

public synchronized void calcSum() {
    ...
}



2. 특정한 영역을 임계 영역으로 지정

synchronized(객체의 참조변수) {
   ...
}

 

모든 객체는 lock을 하나씩 가지고 있으며, 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있습니다.

 

임계 영역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 가능하면 메서드 전체에 락을 거는 것보다 synchronized 블럭으로 임계 영역을 최소화해서 보다 효율적인 프로그램이 되도록 노력해야 합니다.

 

 

 

적용

 

그러면 synchronized 를 이용해 은행계좌와 잔고문제를 해결해보겠습니다.

잔고를 확인하고 출금하는 부분을 synchronized로 묶었습니다.

package javastudy.ch10;

public class ThreadEx5 {
    public static void main(String[] args) {
        Runnable r = new RunnableEx5();
        new Thread(r).start();
        new Thread(r).start();
    }
}

class Account2 {
    private int balance = 1000;

    public int getBalance() {
        return balance;
    }

    public void withdraw(int money) {
        synchronized (this) {
            if (balance >= money) { // 잔고가 뽑을 돈보다 많을 경우에만 인출 가능
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {}
                balance -= money;
            }
        }
    }
}

class RunnableEx5 implements Runnable {
    Account2 acc = new Account2();

    public void run() {
        while (acc.getBalance() > 0) {
            // 100, 200, 300 중의 한 값을 임의로 출금
            int money = (int) (Math.random() * 3 + 1) * 100;
            acc.withdraw(money);
            System.out.println("balance : " + acc.getBalance());
        }
    }
}

 

 

balance가 음수가 나오지 않는 것을 확인할 수 있습니다.

 

 

여기서 또 중요한 것은 balance의 접근제어자가 private이라는 점입니다.

synchronization의 목적은 공유데이터를 다른 곳에서 접근하지 못하게 하는 것인데 만약 private이 아니라면 외부에서 직접 접근이 가능하기 때문에 아무리 동기화를 잘 해놓아도 이 값의 변경을 막을 길이 없습니다.

 

동기화를 할때는 공유데이터의 접근제어자를 private으로 꼭 해놓아야합니다.

 

 

 

 

데드락 (Deadlock)

데드락이란, 둘 이상의 쓰레드가 자신의 lock을 잡고있는 상태에서 서로 다른 lock을 기다리며 block상태에 놓이는 것을 말합니다.

 

이해하기 쉽게 예를들면,

 

  • Thread-1이 A의 lock을 가지고있으면서 B의 lock을 획득하기 위해 기다립니다.
  • Thread-2가 B의 lock을 가진 상태에서 A의 lock을 기다립니다.

 

Thread-1, Thread-2 둘 다 서로의 lock을 획득하기 전까진 자신의 lock을 놓지 않기 때문에 각 쓰레드들은 서로 영원히 차단된 상태로 유지됩니다.

 

 

예제 코드

package javastudy.ch10;

public class DeadlockEx {
    public static final Object LOCK_1 = new Object();
    public static final Object LOCK_2 = new Object();

    public static void main(String[] args) {
        ThreadSample1 thread1 = new ThreadSample1();
        ThreadSample2 thread2 = new ThreadSample2();

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

    private static class ThreadSample1 extends Thread {
        public void run() {
            synchronized (LOCK_1) {
                System.out.println("Thread 1: Holding lock1...");

                try {
                    Thread.sleep(10);
                } catch (InterruptedException e){}

                System.out.println("Thread 1: Waiting for lock2...");
                synchronized (LOCK_2) {
                    System.out.println("Thread 1: Holding lock 1 & 2...");

                }
            }
        }
    }

    private static class ThreadSample2 extends Thread {
        public void run() {
            synchronized (LOCK_2) {
                System.out.println("Thread 2: Holding lock2...");

                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {}

                System.out.println("Thread 2: Wating for lock1...");

                synchronized (LOCK_1) {
                    System.out.println("Thread 2: Holding lock 1 & 2...");

                }
            }
        }
    }


}

 

 

서로 자신의 lock을 놓지 않아 데드락에 빠진 것을 볼 수 있습니다.

 

 

 

 

 

해결방법

확실히 동기화가 필요한 부분에서만 synchronized를 사용하고 중첩 synchronized는 없애는 것이 좋습니다.

package javastudy.ch10;

public class DeadlockEx {
    public static final Object LOCK_1 = new Object();
    public static final Object LOCK_2 = new Object();

    public static void main(String[] args) {
        ThreadSample1 thread1 = new ThreadSample1();
        ThreadSample2 thread2 = new ThreadSample2();

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

    private static class ThreadSample1 extends Thread {
        public void run() {
            synchronized (LOCK_1) {
                System.out.println("Thread 1: Holding lock1...");

                try {
                    Thread.sleep(10);
                } catch (InterruptedException e){}

                System.out.println("Thread 1: Waiting for lock2...");

                System.out.println("Thread 1: Holding lock 1 & 2...");
//                synchronized (LOCK_2) {
//                    System.out.println("Thread 1: Holding lock 1 & 2...");
//                }
            }
        }
    }

    private static class ThreadSample2 extends Thread {
        public void run() {
            synchronized (LOCK_2) {
                System.out.println("Thread 2: Holding lock2...");

                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {}

                System.out.println("Thread 2: Waiting for lock1...");

                System.out.println("Thread 2: Holding lock 1 & 2...");
//                synchronized (LOCK_1) {
//                    System.out.println("Thread 2: Holding lock 1 & 2...");
//                }
            }
        }
    }


}

 

 

 

 

 

 

'Web > Java' 카테고리의 다른 글

[Java] Enum  (2) 2021.08.11
[Java] 애노테이션  (1) 2021.08.11
[Java] 예외  (2) 2021.08.02
[Java] 인터페이스  (2) 2021.07.26
[Java] brew로 자바 특정버전 설치하고 적용하기  (0) 2021.07.19