순간을 성실히, 화려함보단 꾸준함을

10주차 과제: 멀티쓰레드 프로그래밍 본문

백기선님과 함께 하는 자바 스터디

10주차 과제: 멀티쓰레드 프로그래밍

폭발토끼 2021. 7. 3. 00:30

목표

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

학습할 것(필수)

  1. Thread 클래스와 Runnable 인터페이스
  2. 쓰레드의 상태
  3. 쓰레드의 우선순위
  4. Main 쓰레드
  5. 동기화
  6. 데드락

Process 와 Thread란?

목차에 들어가기 앞서 필자는 자바가 아닌 운영체제의 시선으로 프로세스와 쓰레드에 관해 이야기를 살짝 하고 싶다.
많이들 어려워 하는 개념이다. 프로세스가 뭔지, 쓰레드가 뭔지, 그렇다면 쓰레드와 프로세스의 차이점은 대체 무엇인지? 뭐가 그렇게 중요하길래 강조를 하는지 한번 천천히 알아보도록 하자.

프로세스(Process)

운영체제에서 프로세스는 하나의 작업 단위를 뜻한다. 간단히 말해서 당신이 메모장프로그램을 마우스로 더블클릭하여 실행하게 되면 더이상 하나의 메모장 프로세스가 실행되는 것이다.

프로그램은 저장장치에 저장되어 있는 정적인 상태이고,
프로세스는 실행을 위해 메모리에 올라와 있는 동적인 상태이다.

프로세스의 상태에는 4가지가 존재한다.

  • 생성 상태(create status) : 프로세스가 메모리에 올라와 실행 준비를 완료한 상태
  • 준비 상태(ready status) : 생성된 프로세스가 CPU를 얻을 때까지 기다리는 상태
  • 실행 상태(running status) : 준비 상태에 있던 프로세스 중 하나가 CPU를 얻어 실제 작업을 수행하는 상태.(execute status 라고도 불린다). 실행 상태에 들어간 프로세스는 일정 시간 CPU를 사용할 권리를 얻는다. 주어진 시간안에 작업을 다 처리하지 못했다면 다시 준비 상태(ready status)로 돌아와 다음 차례를 기다리게 된다.
  • 완료 상태(terminate status) : 실행 상태의 프로세스가 주어진 시간 동안 작업을 마치면 완료 상태로 진입한다.

그러나 오늘날에는 4가지 기본 상태에 1가지 상태 : 대기 상태(blocking status)가 더해지게 되었다. 그 이유는 바로 인터럽트(interrupt) 때문이다.

  • 대기 상태(blocking status) : 실행 상태에 있는 프로세스가 입출력을 요청하면 입출력이 완료될때까지 기다리는 상태이다. 입출력이 완료가 되면 준비상태로 간다.

인터럽트(interrupt)란?

CPU는 입출력 관계자에게 작업 지시를 내리고 다른 일을 하다가 완료 신호를 받으면 하던 일을 중단하고 옮겨진 데이터를 처리하게 된다. 이처럼 하던 일을 중단하고 처리해야 하는 신호라는 의미에서 인터럽트라고 불리게 되었다.

쓰레드(Thread)

프로세스의 코드에 정의된 절차에 따라 CPU 에 작업 요청을 하는 실행 단위를 뜻한다.
이 말이 무엇인지 천천히 설명을 해보겠다.
먼저 프로세스의 작업 과정을 설명해 보면 운영체제는 코드와 데이터를 메모리에서 가져오고, 프로세스 제어 블록을 생성하고, 작업에 필요한 메모리를 확보한 후, 준비된 프로세스를 준비 큐에 삽입한다.
프로세스가 생성이 되면 CPU 스케줄러는 CPU에게 해야 되는 일을 전달하게 된다. 그리고 실제 일은 CPU가 하게 된다.
이때,CPU 스케줄러가 CPU에게 해야할 일을 전달하는 일 하나가 바로 쓰레드(Thread)이다

정리하면 운영체제 입장에서 작업의 단위는 프로세스이지만,
CPU 입장에서 작업의 단위는 쓰레드가 되는 것이다.

프로세스(Process)와 쓰레드(Thread)의 차이점

하나의 예를 들어서 설명하겠다. 우리가 레스토랑에 가서 맛있는 밥을 먹으려고 한다. 메뉴판을 보고 코스요리가 있는 것을 보고 A코스를 주문을 했다. A 코스에는 야채로 된 죽, 미디엄 굽기의 안심스테이크, 초콜릿이 감미된 케익이 포함되어 있다.

이때 죽,안심스테이크,케익을 만들기 위해서 서로 영향을 미치는가???전혀 아니다.
죽을 못만든다고 스테이크를 만들지 못하는 것도 아니고 스테이크가 상했다고 케익에도 문제가 발생하는 것이 전혀아니기 때문이다.

그럼 더 세부적으로 들어가서 안심 스테이크를 만들기 위해선 소소를 만들어야 되고 채소를 구워야 되고 고기를 구워야 한다. 그러나 이 과정중에 단 한개라도 문제가 생겨버리면 안심 스테이크를 완성할 수가 없다.

뭔가 감이 오는가???위에서 말한 죽,스테이크,케익은 각각 프로세스에 해당이 되고, 안심 스테이크를 만들기 위한 작업(task) 는 쓰레드에 해당이 되는 것이다.

결론은 프로세스와 쓰레드의 가장 큰 차이점은 독립성이다. 프로세스는 자신들의 task 가 다른 프로세스에 전혀 영향을 미치지 않지만, 쓰레드는 자신들의 task 가 다른 쓰레드에게 강력한 영향을 미칠 수가 있다.

(중요) CPU 쓰레드 vs 소프트웨어 쓰레드

필자는 이 개념이 정말로 중요하다고 생각된다...왜냐면 그냥 내가 너무 헷갈렸거든..;;;;
흔히 컴퓨터를 맞출 때 우린 코어의 수와 쓰레드의 수를 확인하고 산다.
1코어 2쓰레드, 4코어 4쓰레드, 4코어 8쓰레드 등등...
그런데 프로그래밍을 통해서 쓰레드의 개수를 생성할땐 100,200,400....개씩 만드는데 이게 대체 무엇인가 말인가???

CPU 쓰레드는 CPU 라는 장치 안에 1개의 코어가 동시에 실행할 수 있는 작업의 수를 의미한다.
즉, 1코어 2쓰레드는 CPU 안에 1개의 코어가 존재하고 2개의 쓰레드를 사용하여 한꺼번에 2개의 작업을 동시에 처리할 수 있는 것이고, 4코어 8쓰레드는 CPU 안에 1개의 코어가 2개의 쓰레드를 사용하여 2개의 작업을 동시에 할 수 있는 일꾼이 4명이 있다는 것을 뜻한다.

프로그래밍 쓰레드는 말 그대로 우리가 컴퓨터 언어를 사용하여 쓰레드를 생성하는 것을 뜻한다.
하나의 프로세스를 몇 조각을 쪼개어 실행할 것 인지를 정하는 단위를 말한다.
운영체제(OS)가 스케줄링을 할때 동시에 실행할 수 있는 쓰레드(CPU 쓰레드)의 개수는 정해져 있다.
이들 중 운영체제에 의해서 실행되는 쓰레드들도 있을 것이고, 실행되지 않고 잠에 들어 있는 쓰레드들 또한 존재할 것이다. 이때 CPU 쓰레드의 개수가 허용하는 만큼 쓰레드(프로그래밍)를 할당하여 동시에 실행하는 것이다.

지금까지 언급했던 쓰레드는 CPU 쓰레드에 관한 내용이었고, 앞으로 언급할 쓰레드는 소프트웨어 쓰레드이다. 혼동이 없길 바란다.

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

자바에선 쓰레드를 구현하는 방식은 크게 2가지가 존재한다.

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

다만, Thread 클래스를 상속받으면 다른 클래스를 상속 받을 수 없기 때문에, 보통 Runnable 인터페이스를 구현하는 것이 일반적이다.

1. Thread 클래스를 상속하는 것

public class Main{
    public static void main(String []args) throws IOException{
        Thread t1 = new ThreadEx();
        t1.setName("ThreadEx1");
        t1.start();
    }
}
class ThreadEx extends Thread{
    @Override
    public void run(){
        System.out.println(this.getName());
    }
}
//출력
ThreadEx1

2. Runnable 인터페이스를 구현하는 것

public class Main{
    public static void main(String []args) throws IOException{
        Runnable r = new ThreadEx();
        Thread t1 = new Thread(r);

        t1.setName("ThreadEx1");
        t1.start();
    }
}
class ThreadEx implements Runnable{
    @Override
    public void run(){
        System.out.println(Thread.currentThread().getName());
    }
}
//출력
ThreadEx1

run()으로 구성되어 있는데 왜 start()를 사용해서 Thread를 구동시키는 것 일까?

핵심만 말하자면 run()으로만 호출하여 사용하게 되면 쓰레드를 생성하여 실행시키는 것이 아닌 단순한 run() 메소드를 호출하게 되는 것이고, start()를 사용하면 해당 쓰레드를 new해서 run이 가능한 상태로 만들어 주는 것이다.
반면에 start()는 생성한 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택(call stack)을 생성한 다음에 run()을 실행하게 된다. 즉, 새롭게 생성한 호출스택에 run()을 얹히는 것이다.

이게 도통 무슨 말인지 필자는 이해가 가질 않았다. 한번 천천히 살펴보자.

먼저 쓰레드를 start()하는 것이 아닌 run() 메소드를 호출해 보자

public class Main{
    public static void main(String []args){
        ThreadEx thread = new ThreadEx();
        thread.run();
    }
}
class ThreadEx extends Thread{
    @Override
    public void run(){
        throwException();
    }
    public void throwException(){
        try{
            throw new Exception();
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}
//출력
java.lang.Exception
    at com.example.ThreadEx.throwException(Main.java:22)
    at com.example.ThreadEx.run(Main.java:18)
    at com.example.Main.main(Main.java:12)

출력을 확인해 보면 Main 쓰레드가 가장 처음 스택에 쌓이고 그 이후 run() 그 다음 throwException()이 스택에 쌓인 것을 확인할 수 있다. 즉, 새로운 쓰레드가 생성된 것이 아니다. 그냥 Main 쓰레드가 포함된 스택에서 run() 메소드를 호출 한 것 뿐이다.

그럼 이번에는 start()를 호출해보자.

public class Main{
    public static void main(String []args){
        ThreadEx thread = new ThreadEx();
        thread.start();
        //thread.run();
    }
}
class ThreadEx extends Thread{
    @Override
    public void run(){
        throwException();
    }
    public void throwException(){
        try{
            throw new Exception();
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}
//출력
java.lang.Exception
    at com.example.ThreadEx.throwException(Main.java:23)
    at com.example.ThreadEx.run(Main.java:19)

자 차이점이 보이는가? 아까와 달리 run()메소드가 Main 쓰레드 위에 쌓인 것이 아닌 가장 처음으로 스택에 쌓인 것을 확인 할 수 있다. 이것은 바로 아까 Main 쓰레드가 포함되어 있는 호출스택이 아닌 새롭게 생성된 쓰레드의 호출스택이다.

그림에서 왜 Main 쓰레드의 호출스택이 보이지 않냐고 궁금할 수도 있다. 그 이유는 Main 쓰레드가 이미 종료가 되어버렸기 때문이다.

쓰레드는 자신만을 위한 호출스택이 필요하고 이런 쓰레드를 이용하려면 run() 이 아닌 start() 키워드를 사용해서 새롭게 쓰레드를 생성한 후 사용하도록 하자
(main 쓰레드가 무엇인지는 밑에서 다시 다루도록 하겠다)

이 때문에 start()를 호출하여 쓰레드를 생성하고 실행되었으면, 다시 start()를 호출하지는 못한다. 만약 start()를 다시 사용했다면 IllegalThreadStateException 예외가 발생한다.

public class Main{
    public static void main(String []args){
        ThreadEx thread = new ThreadEx();
        thread.start();
        try{
            thread.start();
        }catch(IllegalThreadStateException e){
            System.out.println("예외 발생");
        }
        //thread.run();
    }
}
class ThreadEx extends Thread{
    @Override
    public void run() {

    }
}
//출력
예외 발생

2. 쓰레드의 상태

쓰레드는 각각의 상태들을 가지고 있다.

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

  1. 쓰레드를 생성하고 start()를 호출하면 바로 실행되는 것이 아니다. 실행대기 큐에 저장되어 자신의 차례가 될 때까지 기다려야한다.
  2. 실행대기 큐에 있다가 자신의 차례가 되면 실행상태가 된다.
  3. 주어진 실행시간이 다 되거나 yield()를 만나면 다시 실행대기상태가 되고 다음 차례의 쓰레드가 실행된다.
  4. 실행 도중에 suspend(),sleep(),wait(),join(),I/O Block 에 의해서 일시정지 상태가 될 수도 있다. I/O Block 은 입출력작업에서 발생하는 지연상태를 뜻한다. 이때는 사용자가 입력을 마지치면 실행대기 상태로 된다.
  5. 일시정지 상태에서 지정된 시간이 다 되거나(time-out), notify(), resume(), interrupt() 가 호출되면 일시 정지상태를 벗어나 다시 실행대기 큐에 저장이 되어 자신의 차례를 기다린다.
  6. 실행을 모두 마치거나 stop()이 호출되면 쓰레드는 소멸한다.

3. 쓰레드의 우선순위

쓰레드는 우선순위라는 걸 각자 가지고 있따. 이 우선순위 값에 따라 쓰레드가 얻는 실행시간이 달라진다.

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

쓰레드가 가질 수 있는 우선순위의 범위는 1~10 이며 숫자가 높을 수록 우선순위가 높아진다.
쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속 받게 된다. 즉, main 함수 내에 선언되어 사용되는 쓰레드는 main 함수의 우선순위 : 5 를 상속받는다.

public class Main{
    public static void main(String []args){
        ThreadEx1 t1 = new ThreadEx1();
        ThreadEx2 t2 = new ThreadEx2();

        t2.setPriority(7);

        System.out.println("Priority of t1 : "+t1.getPriority());
        System.out.println("Priority of t2 : "+t2.getPriority());

        t1.start();
        t2.start();
    }
}
class ThreadEx1 extends Thread{
    @Override
    public void run() {
        for(int i=0;i<300;i++) {
            System.out.print("-");
            for(int j=0;j<100000;j++);
        }
    }
}
class ThreadEx2 extends Thread{
    @Override
    public void run() {
        for(int i=0;i<300;i++){
            System.out.print("|");
            for(int j=0;j<100000;j++);
        }

    }
}
//출력
Priority of t1 : 5
Priority of t2 : 7
||-|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||-|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

t2가 먼저 종료되는 것을 확인 할 수 있다.

4. Main 쓰레드

우리와 가장 익숙한 main 함수도 사실 main 쓰레드에 의해 수행되는 메소드일 뿐이다. 우리도 모르는 사이에 이미 쓰레드를 사용하고 있었던 것이다. 프로그램이 실행되기 위해서 최소한의 쓰레드가 필요할 것인데 이게 바로 main 쓰레드인 것이다. 그래서 프로그램을 실행하면 기본적으로 하나의 쓰레드가 자동적으로 실행되고 이게 main 쓰레드이다.

실행 중인 사용자 쓰레드가 하나도 없을 때 그제야 쓰레드가 종료된다

5. 동기화

싱글쓰레드 프로세스의 경우에는 프로세스 내에 하나의 쓰레드밖에 존재하지 않아 별 문제가 되지 않지만, 멀티 쓰레드 환경에서는 서로 다른 쓰레드가 같은 자원을 공유하며 작업하기 때문에 서로 작업에 영향을 줄 수 있는 문제가 발생한다.
예를 들어 은행에 내 돈(money)이 500만원이 있다고 해보자. 이때 A가 내 통장에 있는 500만원을 확인하고 500만원을 입금을 하려하고,B 또한 500만원이 있는걸 확인하고 200만원을 입금하려한다.

즉, A는 money = money+500 을 수행하려 하고 B는 money = money+200 을 수행하려고 하는 것이다.
그럼 내 통장에는 500+500+200 = 1200 만원이 들어 있는게 정상이지만 멀티 쓰레드 환경에서는 1200만원이 아닌 1000만원이 될 수도 있고 700만원이 될 수도 있는 것이다.

이게 무슨 말이냐면 결국 A와 B가 내 통장에 돈을 입금한다는 뜻은 money 라는 변수에 x 원을 더해준다는 뜻인데 만약 동시에 money변수를 읽은 후 실행하게 되면 결국 money 에 남은 돈은 나중에 연산처리한 돈으로 처리 되는 것이다.

money = money + 500 을 수행하기 전에 money = money + 200을 수행해 버리고 그 후 money = money + 500 을 수행하면 A,B 둘다 money 에는 500만원이 저장되어 있기 때문에 전의 연산을 무시해리고 덮어 씌워버린다. 따라서 이경우에는 1000만원이 찍히게 되는 것이다.

이런 불미스러운 현상을 제어하기 위한 방법을 임계영역(critical section) 과 잠금(lock) 이라고 한다.
그리고 이를 사용하여 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 동기화(synchronization)이라고 한다.

synchronized 키워드

첫번째 방법은 메소드 앞에 synchronized 키워드를 붙여주는 것이다. synchronized 키워드가 붙은 메소드는 전체가 임계 영역으로 설정이 된다. 쓰레드는 synchrozied 메소드가 호출된 시점부터 해당 메소드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메소드가 종료되면 lock을 반환한다.

synchronized{}

두번째 방법은 블록 키워드({}) 앞에 synchronized를 붙이는 것인데, 이때 참조변수는 락을 걸고자하는 객체를 참조하는 것이야 한다. 이 블록의 영역 안으로 들어가면서부터 쓰레드는 lock을 획득하게 되고, 이 블록을 벗어나면 lock을 반납한다.

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

public class Main{
    public static void main(String []args) {
        Account t = new Account();
        Person1 p1 = new Person1(t);
        Person2 p2 = new Person2(t);

        p1.start();
        p2.start();
        try{
            p1.join();
            p2.join();
        }catch (InterruptedException e){

        }
        System.out.println(t.get_money());
    }
}
class Person1 extends Thread{
    private Account t;
    Person1(Account t){
        this.t=t;
    }
    public void run(){
        for(int i=0;i<1000000;i++)
            t.add_money(500);
    }
}
class Person2 extends Thread{
    private Account t;
    Person2(Account t){
        this.t=t;
    }
    public void run(){
        for(int i=0;i<1000000;i++)
            t.withdraw(500);
    }
}
class Account{
    private int money;
    Account(){
        money=0;
    }
    public void add_money(int x){
        this.money+=x;
    }
    public void withdraw(int x){
        this.money-=x;
    }
    public int get_money(){
        return this.money;
    }
}
//출력
-3588000

500을 10만번 더해주고 500을 10만번 빼주었으면 0이 출력이 되야지 정상이다. 그러나 이상한 음수값이 나와버렸다. 바로 이런 문제가 발생할 수도 있다는 것이다.

class Account{
    private int money;
    Account(){
        money=0;
    }
    public synchronized void add_money(int x){
        this.money+=x;
    }
    public synchronized void withdraw(int x){
        this.money-=x;
    }
    public int get_money(){
        return this.money;
    }
}
//출력
0

해결방법은 synchronized 키워들 사용하여 동기화를 시켜주면 된다.

6. 데드락

흔히 교착상태라고도 불리는 데드락은 2개 이상의 프로세스가 다른 프로세스의 작업이 끝나기만 기다리며 작업을 더 이상 진행하지 못하는 상태를 뜻한다.

정말로 유명한 식사하는 철학자 문제가 이에 해당한다.

4명의 철학자는 둥근 원탁에 둘러 앉아있다. 이때 자신의 왼쪽,오른쪽에 젓가락이 하나씩 놓여져 있다. 철학자들은 식사를 하려면 왼쪽,오른쪽 각각 젓가락을 하나씩 들어야지만 식사를 할 수가 있다.
철학자들은 음식을 먹기 위해 왼쪽의 젓가락을 집은채로 오른쪽 젓가락을 집으려고 하지만 오른쪽 젓가락은 이미 옆에 앉아있는 철학자가 집어든 상태이다. 이러면 모든 철학자는 식사를 하지 못해 굶어 죽는다는 것이다.

데드락이 발생하는 상황의 조건은 이와 같다

  1. 철학자들은 서로 젓가락를 공유할 수 없다 (상호 배제)
    -> 자원을 공유하지 못하면 데드락이 발생한다.
  2. 각 철학자는 다른 철학자의 젓가락을 빼앗을 수 없다 (비선점)
    -> 자원을 빼앗을 수 없으면 자원을 놓을 때까지 기다려야 하므로 데드락이 발생한다.
  3. 각 철학자는 왼쪽 젓가락을 잡은 채 오른쪽 젓가락을 기다린다.(점유와 대기)
    -> 자원 하나를 잡은 상태에서 다른 자원을 기다리면 데드락이 발생한다.
  4. 자원 할당 그래프가 원형이다.(원형 대기)
    -> 자원을 요구하는 방향이 원을 이루면 양보를 하지 않기 때문에 데드락이 발생한다.
Object lock1 = new Object();
Object lock2 = new Object();

Thread t1 = new Thread( () -> {
      while(true){
            synchronized (lock1) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("lock1->lock2");
                }
            }
        }
});

Thread t2 = new Thread( () -> {
    while(true){
        synchronized (lock2) {
            synchronized (lock1) {
                System.out.println("lock2->lock1");
            }
      }
    }
});

t1.start();
t2.start();
  1. t1 쓰레드가 lock1을 획득한다.
  2. t1 쓰레드는 lock1을 가진채 잠에 든다.
  3. t2 쓰레드가 lock2를 획득한다.
  4. t2 쓰레드는 다음 문장을 해결하기 위해 lock1을 기다리지만 lock1은 t1이 가지고 있다.
  5. t1 쓰레드는 잠에서 깨어나 다음 문장을 해결하려 하지만 lock2가 필요하다.
  6. 결국 t1 쓰레드는 t2 쓰레드가 가지고 있는 lock2 가 필요하고, t2 쓰레드는 t1 쓰레드가 가지고 있는 lock1 이 필요한 상태가 되버린다.

이렇게 서로가 가지고 있는 자원만을 원하는 상태가 되버릴 수도 있다. 이를 교착상태(데드락) 이라고 칭한다.

쓰레드 상태 제어 메소드

1. sleep(long millis) - 일정시간동안 쓰레드를 멈추게 한다.

static void sleep(long millis)
static void sleep(long millis,int nanos)

밀리세컨드(millis,1/1000 초)와 나노세컨드(nanos, 1/10억 초) 의 시간단위로 세밀하게 값을 지정할 수 있다. 그러나 어느정도 오차가 발생할 수 있다는 점은 명심해야 한다.

sleep()에 의해서 잠든 쓰레드는 지정된 시간이 다 지나거나 interrupt()가 호출되면 잠에서 깨어난다.
이 때문에 sleep()을 호출하려면 항상 try-catch 문을 사용하여 예외처리를 해주어야 한다.

참고로 sleep() 안에 long type의 리터럴이 들어가기 때문에 수끝에 L을 붙여주자

public class Main{
    public static void main(String []args){
        ThreadEx1 t1 = new ThreadEx1();
        ThreadEx2 t2 = new ThreadEx2();

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

        try{
            t1.sleep(1000L);
        }catch(InterruptedException e){ }
        System.out.println("<main 종료>");
    }
}
class ThreadEx1 extends Thread{
    @Override
    public void run() {
        for(int i=0;i<300;i++) System.out.print("-");
        System.out.println("<th1> 종료");
    }
}
class ThreadEx2 extends Thread{
    @Override
    public void run(){
        for(int i=0;i<300;i++) System.out.print("|");
        System.out.println("<th2> 종료");
    }
}
-----------------------------------------------------------||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||------------------------------------------------------------------------------------------------------------------||||||||||||||||||||||||||----------------------------------------------------------------------------------------------||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||---------------------------------|||||||||||||||||||||||||||||||||||||||||||||||||||<th1> 종료
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||<th2> 종료
<main 종료>

쓰레드 2개를 생성해주고 실행을 하였다. 이 중 t1에만 sleep(1000)을 걸어두었다. 그러면 일반적인 생각으로는 t1이 가장 늦게 끝나야 될 것 같지만 main쓰레드가 가장 늦게 끝나버렸다. 이 이유는 sleep()메소는 sleep()메소드를 호출한 쓰레드의 실행을 정지 시키는 것이다. 즉, 소스를 보면 t1.sleep()이라는 소스를 통해 t1의 쓰레드를 1초동안 잠들게 하고 싶었던 것이지만 t1.sleep()이라는 메소드를 호출한 건 main 쓰레드인 것이다.

이해가 잘 안갈 수도 있겠지만, 결국 sleep()메소드를 어떤 쓰레드가 호출했는가?에 초점을 맞추면 된다.

따라서 이렇게 사용하면 원하는 결과값을 얻어 낼 수 없다. 우리가 원하는 결과를 얻기 위해선 sleep() 메소드를 t1 쓰레드가 호출하게끔 하면 된다. 즉, t1의 클래스안에 Thread.sleep()을 선언해주면 된다.

Thread.sleep(1000)이라고 적어줘야지만 t1의 쓰레드를 1초동안 잠에 들게 할 수 있는 것이다.

public class Main{
    public static void main(String []args){
        ThreadEx1 t1 = new ThreadEx1();
        ThreadEx2 t2 = new ThreadEx2();

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

        /*try{
            Thread.sleep(1000L);
        }catch(InterruptedException e){ }*/
        System.out.println("<main 종료>");
    }
}
class ThreadEx1 extends Thread{
    @Override
    public void run() {
        for(int i=0;i<300;i++)
            System.out.print("-");
        try{
            Thread.sleep(1000);
        }catch(InterruptedException e){}
        System.out.println("<th1> 종료");
    }
}
class ThreadEx2 extends Thread{
    @Override
    public void run(){
        for(int i=0;i<300;i++) System.out.print("|");
        System.out.println("<th2> 종료");
    }
}
<main 종료>
||||||||||||||||||||||||||||||||||||||||||----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||------------------------------------------||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||<th2> 종료
----------------------------------------------------------------------<th1> 종료

2. interrupt()와 interrupted() - 쓰레드의 작업을 취소한다.

void interrupt() 쓰레드의 interrupted 상태를 false에서 true로 변경
boolean isInterrupted() 쓰레드의 interrupted 상태를 반환
static boolean interrupted() 현재 쓰레드의 interrupted 상태를 반환 후, false로 변경

쓰레드 작업이 끝나기 전에 작업을 취소시켜야 할때가 발생할 수도 있다. 예를 들어 큰 파일을 다운로드 받을 때 취소해야 될 상황이 올 수도 있는 것이다. 이때 멈추라고 요청을 하는 것 뿐이지만 실제로는 쓰레드를 강제로 종료시키지는 못한다. interrupt()는 그저 쓰레드의 interrupted 상태를 바꾸는 것 뿐이다.

쓰레드가 sleep(),wait(),join() 상태일 때 interrupt()를 호출하게 되면 Interrupted Exception이 발생하게 되고, 쓰레드는 `실행대기 상태(Runnable)로 바뀐다.

public class Main{
    public static void main(String []args){
        ThreadEx1 t1 = new ThreadEx1();
        t1.start();

        String input = JOptionPane.showInputDialog("값 입력해!");
        System.out.println("입력 값 : "+ input);
        t1.interrupt();
        System.out.println("isInterrupted() : "+ t1.isInterrupted());
    }
}
class ThreadEx1 extends Thread{
    @Override
    public void run() {
        int i=10;

        while(i!=0 && !isInterrupted()){
            System.out.println(i--);
            for(long x=0;x<2500000000L;x++);//시간 지연
        }
        System.out.println("카운트가 종료되었다.");
    }
}

카운트다운을 세면서 사용자가 입력이에 의해 카운트다운이 끝나는 소스이다.

10
9
8
7
6
입력 값 : 99
isInterrupted() : true
카운트가 종료되었다.

이 소스에서 for(long x=0;x<2500000000L;x++);sleep()으로 변경해 보자

public class Main{
    public static void main(String []args){
        ThreadEx1 t1 = new ThreadEx1();
        t1.start();

        String input = JOptionPane.showInputDialog("값 입력해!");
        System.out.println("입력 값 : "+ input);
        t1.interrupt();
        System.out.println("isInterrupted() : "+ t1.isInterrupted());
    }
}
class ThreadEx1 extends Thread{
    @Override
    public void run() {
        int i=10;

        while(i!=0 && !isInterrupted()){
            System.out.println(i--);
            try{
                Thread.sleep(1000);
            }catch(InterruptedException e){

            }
        }
        System.out.println("카운트가 종료되었다.");
    }
}
//출력
10
9
8
입력 값 : 12
7
isInterrupted() : false <------true 일 경우도 발생한다.
6
5
4
3
2
1
카운트가 종료되었다.

엥? 똑같이 interrupt가 발생하여 카운트다운이 중단될 거라고 생각했지만 전혀 그렇지가 않다. 왜 이러는 걸까?
바로 쓰레드가 잠들어 있는 상태에서 interrupt()를 호출했기 때문이다.
잠들어 있는 상태에서 interrupt()를 호출하게 되면 InterruptedException이 발생해 버리고 interrrupted 의 상태는 자동으로 false로 초기화가 되버리기 때문에 isinterrupted의 결과값이 false가 나오는 것이다.

// System.out.println("예외 발생"); 를 추가해 주었다.
10
9
8
입력 값 : 2
예외 발생
7
isInterrupted() : false
6
5
4
3
2
1
카운트가 종료되었다.

이를 해결하는 방법은 catch 문에 interrupt()를 추가로 삽입하여 interrupted()상태를 false에서 true로 변경시켜 주는 것이다.

try{
     Thread.sleep(1000);
}catch(InterruptedException e){
     interrupt();
}
10
9
8
입력 값 : 1
카운트가 종료되었다.

3. suspend(), resume(),stop()

suspend()sleep()처럼 쓰레드를 잠에 들게 한다. suspend()에 의해 잠에 든 쓰레드는 resume()를 통해 실행대기 상태로 변환이 된다. stop()은 호출되는 즉시 쓰레드가 종료된다.
이 모든 메소드들은 모두다 Deprecated 되었다. 즉, 사용하지 말라고 JAVA측에서 권고한 것이다.

4. yideld() - 다른 쓰레드에게 양보

yield()는 쓰레드 자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보하는 역할을 수행한다.
yield()interrupt()를 적절히 사용하면, 프로그램의 응답성을 높이고 보다 효율적인 실행이 가능해 진다.

5. join() - 다른 쓰레드의 작업을 기다린다.

void join()
void join(long millis)
void join(lon millis, int nanos)

시간을 따로 지정하지 않으면, 해당 쓰레드가 모두 작업을 마칠 때까지 기다린다.

public class Main{
    public static void main(String []args){
        ThreadEx1 t1 = new ThreadEx1();
        ThreadEx2 t2 = new ThreadEx2();
        t1.start();
        t2.start();

        try{
            t1.join();        //main 쓰레드가 t1쓰레드가 끝날때까지 기다린다.
            t2.join();        //main 쓰레드가 t2쓰레드가 끝날때까지 기다린다.
        }catch(InterruptedException e){ }
        System.out.println("<main end>");
    }
}
class ThreadEx1 extends Thread{
    @Override
    public void run() {
        for(int i=0;i<300;i++)
            System.out.print("-");
        System.out.println("<t1 end>");
    }
}
class ThreadEx2 extends Thread{
    @Override
    public void run() {
        for(int i=0;i<300;i++)
            System.out.print("|");
        System.out.println("<t2 end>");
    }
}
//출력
----|||----------------------------------------------------||||||||||------------------------------------------||||||||||||||||||||-||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||-----------------------------------------------------|||||||||||||||||||--------------------------------------------------------------------------------------------------------------------------------||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||--------------------<t1 end>
<t2 end>
<main end>

join()을 사용하지 않았더라면 main쓰레드가 가장 먼저 끝났을 거지만 join()을 사용하여 t1t2쓰레드가 다 종료될때까지 기다린 후 마지막으로 main 쓰레드가 종료된 걸 확인 할 수 있다.

Reference

'백기선님과 함께 하는 자바 스터디' 카테고리의 다른 글

11주차 과제: Enum  (0) 2021.07.10
[리뷰] 10주차 : Critical Path  (0) 2021.07.03
9주차 과제: 예외 처리  (0) 2021.06.26
8주차 과제: 인터페이스  (0) 2021.06.22
7주차 과제: 패키지  (0) 2021.06.19