본문 바로가기
자바/스터디

[자바 스터디] 10주차 과제 - 쓰레드

by jeonghaemin 2021. 1. 23.
728x90

본 게시글은 백기선 님의 live-study 과제를 수행하면서 작성한 글입니다.

목표

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

학습할 것 (필수)

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

쓰레드란?

  • 프로세스 : 실행 중인 프로그램, OS로부터 실행에 필요한 자원을 할당받아 프로세스가 된다.
  • 쓰레드: 프로세스의 자원을 이용해서 실제로 작업을 수행

쓰레드를 프로세스라는 공장에서 작업을 처리하는 일꾼으로 비유할 수 있다.

멀티쓰레딩

하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행하는 것이다. CPU 코어는 한 번에 하나의 작업만 수행할 수 있으므로 실제로 동시에 처리되는 작업의 개수는 코어의 개소와 일치하지만, 아주 짧은 시간 동안 여러 작업을 번갈아 가면서 수행함으로써 여러 작업이 동시에 수행되는 것처럼 보이게 한다.
메신저로 채팅하면서 파일을 다운로드 받거나 음성 대화를 나눌 수 있는 것이 멀티쓰레딩때문이다.

Thread 클래스와 Runnable 인터페이스

쓰레드를 구현하는 방법은 Thread 클래스를 상속받는 방법과 Runnable인터페이스를 구현하는 방법, 두 가지가 있다.
두 방법 모두 다 run() 메서드에 쓰레드에서 수행해야 할 코드를 작성해주면 된다.

//Thread 상속
class MyThread extends Thread {
    @Override
    public void run() {
        //코드 작성
    }
}

//Runnable 구현
class MyRunnableImpl implements Runnable {
    @Override
    public void run() {
        //코드 작성
    }
}

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

        Thread t1 = new MyThread();
        t1.start();

        Runnable r = new MyRunnableImpl();
        Thread t2 = new Thread(r;
        t2.start();
    }
}

두 방법의 인스턴스 생성 방법이 다르다. Runnable인터페이스를 구현한 경우, 구현 클래스의 인스턴스를 생성하여 Thread 클래스의 생성자의 매개변수로 넘겨줘야 한다.

start() 메서드를 호출하여 생성한 쓰레드를 실행할 수 있으며, 그 과정은 다음과 같다.

  1. main메서드에서 쓰레드의 start() 호출한다.
  2. start()는 새로운 쓰레드를 생성하고, 작업에 사용할 호출 스택을 생성한다.
  3. 새로 생성된 호출 스택에서 run()이 호출되어, 쓰레드가 독립된 공간에서 작업을 수행한다.
  4. 메인 메서드가 있는 메인 쓰레드와 새로 생성된 2개의 호출 스택이 스케줄러가 정한 순서에 의해 번갈아가면서 실행된다.
  5. run()의 수행이 종료된 쓰레드는 호출 스택이 비워지면서 해당 호출 스택은 사라진다.

한번 실행이 종료된 쓰레드는 다시 실행(start())할 수 없다. start() 메서드를 또 다시 호출하면 IllegalThreadStateException이 발생한다.
그래서 쓰레드 작업을 한번 더 수행해야한다면, 새로운 쓰레드 인스턴스를 생성해서 사용해야 한다.

main 쓰레드

지금껏 우리가 main메서드를 사용할 수 있었던 것은 프로그램이 실행되면 기본적으로 하나의 쓰레드가 생성되고, 그 쓰레드가 main메서드를 호출해주었기 때문이다.

쓰레드 우선순위

쓰레드는 우선순위를 지정하여, 각 쓰레드의 작업의 중요도에 따라 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있다.

//쓰레드의 우선순위를 변경
void setPrioriry(int newPriority)

//쓰레드의 우선순위 반환
int getPriority()

public static final int MAX_PRIORITY = 10 //최대 우선순위
public static final int NORM_PRIORITY = 5 //보통 우선순위
public static final int MIN_PRIORITY = 1 //최소 우선순위
  • 우선순위의 범위는 1~10이며 숫자가 클수록 우선순위가 높다.
  • 참고: main쓰레드의 우선순위는 5이기 때문에 main메서드에서 생성하는 쓰레드으 우선순위는 자동적으로 5가 된다.
public class ThreadSample {
    public static void main(String[] args) {
        Thread t1 = new MyThread();
        t1.run();

        Runnable r = new MyRunnableImpl();
        Thread t2 = new Thread(r);

        int mainPriority = Thread.currentThread().getPriority();
        int t1Priority = t1.getPriority();
        int t2Priority = t2.getPriority();

        System.out.println("mainPriority = " + mainPriority);
        System.out.println("t1Priority = " + t1Priority);
        System.out.println("t2Priority = " + t2Priority);
    }
}

결과

mainPriority = 5
t1Priority = 5
t2Priority = 5

 

class MyThread1 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.print("-");
        }
    }
}

class MyThread2 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.print("|");
        }
    }
}

public class ThreadSample {
    public static void main(String[] args) {
        Thread t1 = new MyThread1(); 

        Thread t2 = new MyThread2();
        t2.setPriority(7);

        int t1Priority = t1.getPriority();
        int t2Priority = t2.getPriority();

        System.out.println("t1Priority = " + t1Priority);
        System.out.println("t2Priority = " + t2Priority);

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

위와 같이 쓰레드를 두 개 생성하여 t2 쓰레드의 우선순위를 더 높게 설정하고 실행을 해보았는데 실행시마다 결과가 다르게 나왔고, 우선순위를 높게 설정한다고 무조건 우선권을 가지지 않는 것을 볼 수 있었다.
실행1

------||||||||||||||||||||||||||||||-------|||||||||||||||||||-----------------|||||----|||------------------------------------------------------------------|||||||||||||||||||||||||||||||||||||||||||

실행2

-----------------------------------||||-----------------------------------------------------------------||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||

OS마다 다른 방식으로 스케줄링을 하고, 자바는 쓰레드가 우선순위에 따라 어떻게 처리되야하는지에 대해 강제하지 않으므로 쓰레드의 우선순위와 관련된 구현이 JVM마다 다를 수 있다.
그래서 작업의 우선순위를 두어야 한다면 쓰레드에 우선순위를 부여하는 대신 PriorityQueue를 사용하여 처리하는 것이 더 나을 수도 있다.

데몬 쓰레드

데몬 쓰레드는 쓰레드의 작업을 돕는 보조적인 역할을 수행하는 쓰레드로, 쓰레드가 모두 종료되면 데몬쓰레드도 강제적으로 종료가 된다.
데몬 쓰레드의 예로는 가비지 컬렉터, 자동 저장, 화면 자동갱신 등이 있다.
데몬 쓰레드는 무한루프와 조건문을 이용해서 특정 조건이 만족되면 작업을 수행하고 다시 대기하도록 작성한다.

public class DaemonThreadSample implements Runnable{
    private static boolean autosave = false;

    @Override
    public void run() {
        while (true) { 
                //자동저장이 활성화되면 3초마다 자동저장
            sleep(3);
            if (autosave) {
                System.out.println("자동저장되었습니다.");
            }
        }
    }

    public static void main(String[] args) {
        Thread t = new Thread(new DaemonThreadSample() );
        t.setDaemon(true); //데몬 쓰레드로 설정
        t.start();

        for (int i = 1; i <= 10; i++) {
            sleep(1);
            System.out.println(i);

            if (i == 5) { 
                autosave = true; //자동저장을 활성화
            }
        }

        System.out.println("프로그램을 종료합니다.");
    }

    private static void sleep(long second) {
        try {
            Thread.sleep(second*1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

결과

1
2
3
4
5
자동저장되었습니다.
6
7
8
자동저장되었습니다.
9
10
프로그램을 종료합니다.

쓰레드의 상태

쓰레드의 상태는 다음과 같으며 JDK1.5부터 Thread.getState()메서드를 호출하여 확인할 수 있다.

  • NEW : 쓰레드가 생성되고 아직 start가 호출되지 않은 상태
  • RUNNABLE : 실행 중 또는 실행 가능 상태.
  • BLOCKED : 동기화 블럭에 의해서 일시 정지된 상태.
  • WATING : 쓰레드의 작업이 종료되지는 않았지만 실행 가능하지 않은 일시정지 상태.
  • TIMED_WATING : WATING과 같이 일시정지 상태지만, 일시정지 시간이 지정된 경우.
  • TERMINATED : 쓰레드의 작업이 종료된 상태

쓰레드 스케줄링 관련 메서드

suspend(), resume(), stop() 메서드는 deprecated되어 사용하면 안된다.

지정된 시간 동안 쓰레드 일시정지시키기

static void sleep(long millis[, int  nanos])
  • 지정한 시간이 지나거나, interrupt()가 호출되면 실행 대기(RUNNABLE) 상태가 된다.
  • 항상 현재 실행 중인 쓰레드에 대해서 작동하기 때문에 참조변수를 통해 호출하기 보다는 Thread.sleep()으로 호출해야 한다.

쓰레드 작업 취소하기

//쓰레드의 interrupted 상태를 false->true로 변경
void interrupt(); 

//쓰레드의 interrupted상태를 반환
boolean isInterrupted();

//쓰레드의 interrupted상태를 반환하고, false로 변경
static boolean interrupted();
  • interrupt()가 호출되면 isInterrupted()결과가 true로 바뀌게 되고 이를 이용하여 코드를 작성하면 작업을 중단시킬 수 있다.
class MyThread extends Thread {
    @Override
    public void run() {
        while (true) {
            //...
               //interrupt()가 호출되면 아래 조건이 true가 됨.
            if (isInterrupted()) { 
                break;
            }
            //...
        }
    }
}
  • 쓰레드가 sleep(), wait(), join() 메서드에 의해 일시정지 상태일 때 해당 쓰레드에서 interrupt()를 호출하면 , leep(), wait(), join() 메서드에서 InterruptedException이 발생하여 쓰레드를 멈춰있는 상태에서 깨워 실행 대기 상태로 바뀐다.

다른 쓰레드에게 양보하기

public static void yield()
  • 자신에게 주어진 실행시간을 다음 쓰레드에게 양보한다.
  • 예를 들어 1초의 실행시간을 할당받은 쓰레드가 0.5초를 실행한 상태에서 yield()를 호출하면, 나머지 0.5초는 포기하고 실행 대기 상태가 된다.

다른 쓰레드의 작업을 기다리기

void join()
void join(long millis)
void join(long millis, int nanos)
  • 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 한다.
  • 시간을 지정하지 않으면, 해당 쓰레드가 작업을 모두 마칠 때까지 기다리게 된다.
  • 작업 중에 먼저 수행되어야 되는 다른 쓰레드 작업이 있을 때 사용한다.
  • sleep()과 유사하지만 join()은 현재 쓰레드가 아닌 특정 쓰레드를 대상으로 동작한다.

쓰레드의 동기화

멀티쓰레드 프로세스의 경우 여러 쓰레드가 같은 자원을 공유해서 사용하기 때문에 서로의 작업에 안 좋은 영향을 줄 수 있다. 예를 들어 쓰레드1이 작업을 하는 도중에 쓰레드2에게 제어권이 넘어가 쓰레드1이 작업하던 자원의 내용을 쓰레드2가 변경해버린다면, 쓰레드1에게 제어권이 다시 넘어왔을 때를 상상해보자.
이를 방지하기 위해 ‘임계 영역(critical section)’과 ‘잠금(lock)’이라는 개념이 생겼다.
공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정하고, 락을 가지고 있는 하나의 쓰레드만이 임계 영역의 코드를 수행할 수 있게한다. 코드 수행이 완료되면 락을 반납하여 다른 쓰레드가 임계영역의 코드를 수행할 수 있도록 한다.

synchronized 키워드

  • 임계 영역을 설정하는 데 사용한다.
  • 임계 영역의 범위를 메서드 또는 특정한 영역으로 지정할 수 있다.
  • 임계 영역은 멀티쓰레드 프로그램의 성능에 중요하기 때문에 메서드 전체를 영역으로 지정하는 것보다 synchronized 블럭으로 영역을 최소화하는 것이 좋다.
  • lock의 획득과 반납은 자동으로 이루어지기 때문에 임계 영역만 설정해주면 된다.
//메서드를 임계 영역으로 설정
public synchronized void method() {
    //임계 영역
}

//특정 영역을 임계 영역으로 설정
synchronized(락을 걸고자하는 객체의 참조변수) {
    //임계 영역
}

간단한 예를 들어보자.

public class SynchronizedSample {
    public static void main(String[] args) {
        Runnable r = new RunnableSample();
        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 RunnableSample implements Runnable {
    Account acc = new Account();

    @Override
    public void run() {
        while (acc.getBalance() > 0) {
            int money = (int)(Math.random()*3+1)*100;
            acc.withdraw(money);
            System.out.println("balance = " + acc.getBalance());
        }
    }
}

위 코드를 실행해보면 withdraw메서드에서 분명히 잔고가 출금액보다 클 경우에만 출금이 되도록 조건을 걸었음에도 불구하고 잔고가 음수가 되는 경우를 볼 수 있을 것이다.

실행 결과
balance: 600
balance: 600
balance: 400
balance: 300
balance: -100
balance: 100

그 이유는 한 쓰레드가 withdraw 메서드에서 잔고가 출금액보다 많다는 것을 확인하고 출금을 하려는 순간 다른 쓰레드가 출금을 이미 해버렸고, 그 결과 출금 후 잔액이 음수가 되는 것이다.
synchronized키워드를 사용하여 임계 영역을 설정 후 다시 실행해보면 원하던 결과를 얻을 수 있다.

public  void withdraw(int money) {
        if (balance >= money) { //잔고가 출금액보다 크다면
            try { Thread.sleep(1000); } catch (InterruptedException e) { }
            balance -= money; //출금
        }
    }
실행 결과
balance = 700
balance = 400
balance = 300
balance = 0
balance = 0

synchronized키워드를 사용하는 방법 외에도 JDK1.5부터 java.util.concurrent.locksjava.util.concurrent.atomic패키지를 통해 다양한 방식으로 동기화를 구현할 수 있도록 하였다.

데드락

쓰레드 스케줄링과 관련된 메서드 중 resume(), stop(), suspend() 메서드는 데드락을 만들기 쉽기 때문에 deprecated되었다.
여기서 말하는 데드락이란 두 개 이상의 쓰레드가 서로 상대방의 락을 얻으려고 무한정 대기하고 있는 상태를 말한다.
예를 들어 쓰레드1은 락A를 가지고 있는 상태에서 락B가 풀리기를 기다리고 있는데, 쓰레드2는 락B를 가지고 있는 상태에서 락A가 풀리기를 기다리고 있는 상황이다.

public class DeadLockSample {
    static Object a = new Object();
    static Object b = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread1();
        Thread t2 = new Thread2();
        t1.start();
        t2.start();
    }

    static class Thread1 extends Thread {
        @Override
        public void run() {
            synchronized (a) {
                System.out.println("Thread1는 락a를 가지고있다.");
                synchronized (b) {
                    System.out.println("Thread1는 락a와 b를  가지고있다.");
                }
            }
        }
    }

    static class Thread2 extends Thread {
        @Override
        public void run() {
            synchronized (b) {
                System.out.println("Thread2는 락b를 가지고있다.");
                synchronized (a) {
                    System.out.println("Thread2는 락a와 b를  가지고있다.");
                }
            }
        }
    }
}
실행 결과
Thread1는 락a를 가지고있다.
Thread2는 락b를 가지고있다.

다음과 같이 출력되며 프로그램은 종료되지 못하고 무한 대기 상태에 빠지게 된다.

참고

댓글