자바의 정석 Chapter 13 스레드

Eunkyung·2021년 11월 6일
0

Java

목록 보기
14/21

1. 프로세스와 스레드

프로세스는 메모리에 정적인 상태로 존재하는 프로그램이 프로세스 제어블록(PCB)을 할당받아 메모리에 올라온 동적인 상태를 말한다. 한마디로 표현하자면 실행 중인 프로그램을 말한다.
프로세스는 데이터와 메모리 등의 자원과 스레드로 구성되어 있는데 실제로 작업을 수행하는 것을 스레드라고 한다.

모든 프로세스는 최소한 하나 이상의 스레드가 존재하는데, 둘 이상의 스레드를 가진 프로세스를 멀티스레드라고 한다. 멀티스레드는 프로세스 내의 자원을 공유하여 자원을 효율적으로 사용할 수 있는 장점이 있지만 자원 공유에 따른 동기화, 교착상태 등에 대한 문제가 발생할 수 있다.

2. 스레드의 구현과 실행

스레드를 구현하는 방법은 크게 두 가지가 있다.

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

두 방법에는 큰 차이가 없지만 클래스는 단일 상속만 가능하기 때문에 인터페이스를 구현하는 방법이 일반적이다.

스레드를 생성하고 실행하기 위해 start() 메소드를 호출하면 실행대기 상태에 있다가 OS 스케줄러에 의해 순서대로 실행된다. JVM이 OS에 독립적이긴하나, 스레드는 OS에 종속적이다.

public class ThreadEx1 {
    public static void main(String[] args) {
        // Thread 클래스를 상속받은 경우 해당 객체를 생성하고 start() 메소드 직접 호출 가능
        ThreadEx1_1 t1 = new ThreadEx1_1();
        // Runnable 인터페이스를 구현한 경우 Runnable형 인자를 받는 생성자를 통해 Thread 객체 생성 후 start() 메소드 호출 가능
//        Runnable r = new ThreadEx1_2();
//        Thread t2 = new Thread(r);
        Thread t2 = new Thread(new ThreadEx1_2());

        // start()가 호출되면 실행대기 상태에 있다가 OS 스케줄러에 의해 자신의 차례가 되면 실행된다.
        // 한 번 종료된 스레드는 다시 실행할 수 없음 -> 새로운 스레드 생성 후 start() 호출
        // 스레드 객체 생성 후 start() 메소드 호출 전까지 스레드 생성되지 않음
        t1.start(); // t1 스레드 실행
        t2.start(); // t2 스레드 실행
    }
}

// Thread 클래스 상속
class ThreadEx1_1 extends Thread {
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(getName());
        }
    }
}

// 2. Runnable 인터페이스 구현
class ThreadEx1_2 implements Runnable {

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            // Thread.currentThread() : 현재 실행중인 Thread 반환
            System.out.println(Thread.currentThread().getName());
        }
    }
}

3. start()와 run()

이전에 Thread 클래스를 상속받고, Runnable 인터페이스를 구현하여 run() 메소드를 구현했는데 왜 스레드를 실행시킬 때 main 메소드에서 run()이 아닌 start()를 호출하는걸까?
main 메소드에서 run()을 호출하는 것은 생성된 스레드를 실행시키는 것이 아니라 단순히 클래스에 선언된 메소드를 호출하는 것일 뿐이다. 이 부분을 특히 주의하자!

이전에 스레드를 생성하고 실행하기 위해 start() 메소드를 호출해야 한다고 했는데 이 과정에서 어떤 일이 일어날까?
모든 스레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택이 필요한데, start() 메소드를 호출하면 새로운 스레드가 작업하는데 필요한 호출스택을 생성하고 run() 메소드를 호출하게 된다.

새로운 스레드를 생성하고 start()를 호출한 후 호출스택의 변화

  1. main 메소드에서 스레드의 start()를 호출한다.
  2. start()는 새로운 스레드를 생성하고, 스레드가 작업하는데 사용될 호출스택을 생성한다.
  3. 새로 생성된 호출스택에 run()이 호출되어, 스레드가 독립적인 공간에서 작업을 수행한다.
  4. 2개이 호출스택에서 스케줄러가 정한 순서에 의해서 번갈아 가면서 실행된다.

그렇다면 main 메소드를 실행하는 스레드는 어떤 스레드일까? 사실 우리는 스레드를 공부하기 이전부터 이미 스레드를 사용하고 있었다.
모든 프로세스에 최소한 하나 이상의 스레드가 존재하기 때문에 main 메소드에서 작성한 코드는 main 스레드가 작업한 것이었다.

4. 싱글스레드와 멀티스레드

싱글스레드와 멀티스레드의 차이를 좀 더 자세히 알아보자.
1. 두 개의 작업을 하나의 스레드로 처리하는 경우
2. 두 개의 작업을 두 개의 스레드로 처리하는 경우

싱글스레드와 멀티스레드의 비교

1번의 경우에는 한 작업을 마친 후 다른 작업을 시작하지만, 2번의 경우 짧은 시간동안 2개의 스레드가 번갈아 가면서 작업을 수행해서 동시에 두 작업이 처리되는 것과 같이 느끼게 한다. 이 때, 두 개의 스레드로 작업한 시간이 싱글스레드로 작업한 시간보다 더 걸릴 수 있는데 그 이유는 스레드간의 작업전환(context switching)에 시간이 걸리기 때문이다.

/**
 * 싱글스레드
 */

public class ThreadEx4 {
    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < 300; i++) {
            System.out.printf("%s", new String("-"));
        }
        System.out.print("소요시간1 : " + (System.currentTimeMillis() - startTime));
        System.out.println();
        for (int i = 0; i < 300; i++) {
            System.out.printf("%s", new String("|"));
        }
        System.out.print("소요시간2 : " + (System.currentTimeMillis() - startTime));
    }
}
/**
 * 멀티스레드
 */

public class ThreadEx5 {
    static long startTime = 0;

    public static void main(String[] args) {
        ThreadEx5_1 th1 = new ThreadEx5_1();
        th1.start();
        startTime = System.currentTimeMillis();
        for (int i = 0; i < 300; i++) {
            System.out.printf("%s", new String("-"));
        }
        System.out.print("소요시간1 : " + (System.currentTimeMillis() - ThreadEx5.startTime));
    }
}

class ThreadEx5_1 extends Thread {
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.printf("%s", new String("|"));
        }
        System.out.print("소요시간2 : " + (System.currentTimeMillis() - ThreadEx5.startTime));
    }
}

그렇다면 언제 싱글스레드로 구현하고 언제 멀티스레드로 구현하는 것이 좋을까?
단순히 CPU만을 사용하는 계산작업일 경우 싱글스레드가 효율적이고, 사용자의 입출력을 필요로 할 때는 멀티스레드가 효율적이다. 다음 그림을 보자.

싱글스레드 프로세스와 멀티스레드 프로세스의 비교

순차적으로 작업을 수행하는 싱글스레드의 경우 사용자가 입력을 마칠 때까지 반복문이 실행되지 않고 사용자가 입력을 마치면 그 때 반복문이 실행되어 화면에 숫자가 출력된다.

import javax.swing.*;

/**
 * 싱글스레드
 */

public class ThreadEx6 {
    public static void main(String[] args) {
        String input = JOptionPane.showInputDialog("값을 입력하세요");
        System.out.println("입력하신 값은 " + input + "입니다.");

        for (int i = 10; i > 0; i--) {
            System.out.println(i);
            try {
                Thread.sleep(1000); // 1초간 시간 지연
            } catch (Exception e) {
            }
        }
    }
}

한편, 두 작업을 번갈아 가면서 수행하는 멀티스레드의 경우 사용자의 입력을 받는 부분과 화면에 숫자를 출력하는 부분을 나누어 처리했기 때문에 사용자가 입력을 마치지 않더라도 화면에 숫자가 출력된다.

import javax.swing.*;

/**
 * 멀티스레드
 */

public class ThreadEx7 {
    public static void main(String[] args) {
        ThreadEx7_1 th1 = new ThreadEx7_1();
        th1.start();

        String input = JOptionPane.showInputDialog("값을 입력하세요");
        System.out.println("입력하신 값은 " + input + "입니다.");
    }
}

class ThreadEx7_1 extends Thread {
    public void run() {
        for (int i = 10; i > 0; i--) {
            System.out.println(i);
            try {
                Thread.sleep(1000); // 1초간 시간 지연
            } catch (Exception e) {
            }
        }
    }
}

5. 스레드의 우선순위

스레드는 우선순위라는 멤버변수를 가지고 있어서 우선순위가 높을수록 더 많은 시간동안 작업할 수 있다. 하지만 우선순위를 높게 설정해도 OS에 종속되기 때문에 항상 우선순위가 높은 스레드의 작업이 먼저 종료된다고 할 수 없다.
한 가지 알아두어야 할 것은 스레드의 우선순위는 스레드를 생성한 스레드로부터 상속받는데 main 메소드를 수행하는 스레드는 우선순위가 5이므로 main 메소드에서 생성한 스레드의 우선순위는 자동적으로 5가 된다는 것이다. 또한, 스레드를 실행하기 전에만 우선순위를 변경할 수 있다.

6. 스레드 그룹

스레드 그룹은 서로 관련된 스레드를 그룹으로 다루기 위한 것으로, 스레드 그룹에 다른 스레드 그룹을 포함시킬 수 있다.
모든 스레드 그룹은 main 스레드 그룹의 하위 스레드 그룹이 되며, 스레드 그룹을 지정하지 않고 생성한 스레드는 자동적으로 main 스레드 그룹에 속하게 된다.

7. 데몬 스레드

데몬 스레드는 일반 스레드의 작업을 돕는 보조적인 역할을 수행하는 스레드로, 일반 스레드가 종료되면 데몬 스레드는 강제로 자동 종료된다. 데몬 스레드와 일반 스레드의 작성방법과 실행방법은 같지만 스레드를 생성하고 실행하기 전에 반드시 setDaemon(true)를 호출해야 한다. 만약 setDaemon() 메소드를 호출하지 않는다면 프로그램은 종료되지 않는다.

8. 스레드의 실행제어

멀티스레드로 프로그래밍 할 경우 새로운 프로세스를 생성하는 것보다 새로운 스레드를 추가하는 것이 더 적은 비용이 들고 자원을 공유하여 효율적으로 운영이 가능하다는 장점이 있지만 자원을 공유함으로써 문제가 발생할 수 있기 때문에 동기화와 스케줄링에 신경써야 한다.

스레드의 스케줄링과 관련된 메소드

여기서 sleep()과 yield()는 static method이기 때문에 자기 자신에게만 호출가능하다는 것을 기억하자.
또한 교착상태를 일으키기 쉬워서 suspend(), resume(), stop() 메소드는 deprecated되었다는 것을 기억하자.

스레드의 상태

  • 스레드를 생성하고 start()를 호출하면 바로 실행되는 것이 아니라 실행대기열 큐에 저장되어 자신의 차례가 될 때까지 기다려야 한다.
  • 실행대기상태에 있다가 자신의 차례가 되면 실행상태가 된다.
  • 주어진 실행시간이 다되거나 yield()를 만나면 다시 실행대기상태가 되고 다음 차례의 스레드가 실행상태가 된다.
  • 실행 중에 suspend(), sleep(), wait(), join(), I/O block에 의해 일시정지상태가 될 수 있다.
  • time-out, notify(), resume(), interrupt()가 호출되면 일시정지상태를 벗어나 다시 실행대기열에 저장되어 자신의 차례를 기다리게 된다.
  • 실행을 모두 마치거나 stop()이 호출되면 스레드는 소멸된다.

9. 스레드의 동기화

동기화란 한 스레드가 진행 중인 작업을 다른 스레드가 간섭하지 못하도록 막는 것을 말한다. 다시 말해서, 용하는 코드 영역을 임계 영역으로 지정해놓고, lock을 획득한 단 하나의 스레드만 임계 영역 내의 코드를 수행할 수 있게 하는 것이다.
임계 영역을 설정하는 방법은 두 가지가 있다.

  1. 메소드 전체를 임계 영역으로 지정
public synchronized void fun() {}

synchronized 메소드가 호출된 시점부터 해당 메소드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메소드가 종료되면 lock을 반환한다.

  1. 특정한 영역을 임계 영역으로 지정
synchronized(객체의 참조변수) {}

synchronized 블럭 영역 안으로 들어가면서부터 스레드는 지정된 객체의 lock을 얻게 되고, 이 블럭을 벗어나면 lock을 반환한다.

/**
 * 동기화 : 지정된 영역의 코드를 하나의 스레드가 수행하는 것 보장
 * 작업 중간에 다른 스레드가 끼어들어서 작업하지 못함
 */

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

class Account {
    private int balance = 1000; // 접근제어자 private -> 외부에서 접근 못함

    public int getBalance() {
        return balance;
    }

    // 멀티스레드의 경우 동기화 처리를 하지 않으면 잔고가 마이너스가 될 수 있음
    public synchronized void withdraw(int money) {
        if (balance >= money) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
            }
            balance -= money;
        }
    }
}

class RunnableEx21 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());
        }
    }
}

synchronized로 동기화해서 공유 데이터를 보호할 수 있다는 장점이 있지만 특정 스레드가 객체의 lock을 오랜 시간동안 가지고 있다면 다른 스레드의 작업이 원활하게 이루어지지 않을 것이다. 이러한 문제점을 해결하기 위해 wait()과 notify() 메소드를 호출한다.

동기화된 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면 wait()을 호출하여 lock을 반납하고 기다리게 한다. 이 때 다른 스레드가 lock을 얻어 해당 객체에 대한 작업을 수행하게 되고, 나중에 다시 진행할 상황이 되면 notify()를 호출해서 작업을 중단했던 스레드가 다시 lock을 얻어 작업을 진행하게 된다.

출처

  • 자바의 정석 - 남궁성 지음
profile
꾸준히 하자

0개의 댓글