본문 바로가기
JAVA

자바 쓰레드(Thread)

by mjjang 2021. 1. 25.

목표

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

학습할 것 (필수)

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

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

Thread 클래스를 알아보기 전에 프로세스와 쓰레드에 대해 알아보겠습니다.

프로세스란 메모리에 올라가 있는 프로그램이란 뜻입니다. 기본적으로 프로세스는 최소 1개의 쓰레드를 가지고 있습니다.
쓰레드란 프로세스의 논리적인 작업 단위를 뜻합니다. 하나의 프로세스를 구성하는 쓰레드들은 프로세스에 할당된 메모리, 자원 등을 공유해서 사용할 수 있습니다. 각 쓰레드는 자신만의 스택과 레지스터를 가지고 있습니다.

이제 자바에서 쓰레드를 어떻게 생성하고 사용하는지 알아보겠습니다.

Treahd 클래스 확장하기

public class ThreadExample extends Thread {

    public void run() {
        String name = Thread.currentThread().getName();
        System.out.println(name + " has been started");

        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(name + " has been ended");
    }

    public static void main(String[] args) {
        Thread thread0 = new ThreadExample();
        Thread thread1 = new ThreadExample();

        thread0.start();
        thread1.start();
    }
}
실행 결과

Thread-1 has been started
Thread-0 has been started
Thread-0 has been ended
Thread-1 has been ended

Thread 클래스를 확장한 후 run() 메소드를 오버라이딩하면 쓰레드를 구현할 수 있습니다. main() 메소드에서 2개의 쓰레드 인스턴스를 생성하고 start() 메소드를 호출했습니다. 실행 결과를 보면 알 수 있듯이 쓰레드를 실행하면 동시에 실행이 되기 때문에 먼저 실행된 쓰레드가 먼저 끝나는 것을 보장할 수 없습니다.

Runnable 인터페이스 구현하기

public class RunnableExample implements Runnable {
    public void run() {
        String name = Thread.currentThread().getName();
        System.out.println(name + " has been started");

        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(name + " has been ended");
    }

    public static void main(String[] args) {
        RunnableExample runnable = new RunnableExample();

        Thread thread0 = new Thread(runnable);
        Thread thread1 = new Thread(runnable);

        thread0.start();
        thread1.start();
    }
}

쓰레드를 구현하는 또 다른 방법으로는 Runnable 인터페이스를 구현하는 것입니다. Runnable 인터페이스는 run() 추상 메소드를 하나만 가지고 있는 인터페이스 입니다. Runnable 인터페이스를 구현한 방법으로 쓰레드를 실행시키려면 Thread 클래스의 생성자에 Runnable 타입의 객체를 넘겨줘야 합니다.

Thread 확장 vs Runnable 구현

일반적으로 Thread 클래스를 확장하는 것보다 Runnable 인터페이스를 구현하는 방식이 많이 쓰이는데 그 이유는 쓰레드를 구현하는 클래스가 다른 클래스의 상속이 필요할 때 자바에서는 다중 상속이 지원되지 않기 때문에 Thread 클래스를 확장할 수 없게 됩니다. 그래서 많은 개발자들이 상속의 제한이 없는 Runnable 인터페이스를 구현하는 방식을 더 선호합니다.

start() vs run()

쓰레드를 실행시킬 때 run() 메소드가 아닌 start() 메소드를 호출하는 이유는 run() 메소드는 쓰레드의 기능을 수행하는 메소드입니다. main() 메소드도 하나의 쓰레드인데 main() 메소드에서 run() 메소드를 실행시키면 main() 쓰레드의 스택에서 run() 메소드를 호출시키는 것입니다. 즉 독립적인 쓰레드가 생성이 되지 않습니다. 반면에 start() 메소드를 호출하면 새로운 쓰레드를 생성하고 그 쓰레드의 스택에 run() 메소드를 호출해서 쓰레드를 동작시킵니다.

2. 쓰레드의 상태

자바에서 쓰레드는 6가지의 상태를 가질 수 있습니다. 하나하나 알아보겠습니다.

  • NEW : 쓰레드가 생성된 상태 start() 메소드 호출 전
  • RUNNABLE : 쓰레드가 실행 중이거나 실행이 준비된 상태
  • WAITING : 다른 쓰레드가 notify()나 notifyAll()을 호출하는 것을 기다리는 상태
  • TIMED_WAITING : 쓰레드가 sleep() 호출로 인해 일정 시간 동안 대기하는 상태 일정 시간 후 다시 깨어남
  • BLOCK : 쓰레드가 I/O 작업을 요청하면 BLOCK 상태로 만든 후 대기하다가 I/O 요청이 완료되면 다시 깨어남
  • TERMINATED : 쓰레드가 종료된 상태

3. 쓰레드의 우선순위

멀티 쓰레드의 순서를 정하는 것을 쓰레드 스케줄링이라고 합니다. 쓰레드 스케줄링 방식에는 우선순위(priority) 방식과 순환 할당(round-robin)방식이 있습니다.

  • 우선순위 방식 : 우선순위가 높은 쓰레드에게 먼저 CPU를 할당하는 방식입니다.
  • 순환 할당 방식 : 시간 할당량을 정해서 하나의 쓰레드가 정해진 시간만큼 실행하고 정해진 시간이 끝난 후에 다른 쓰레드가 실행되는 방식입니다.

자바에서 순환 할당 방식은 JVM에 의해 결정되기 때문에 개발자가 임의로 수정할 수 없습니다. 그러나 쓰레드의 우선순위는 개발자가 설정할 수 있습니다. 우선순위는 1에서 10까지 부여할 수 있고 숫자가 높을수록 우선순위가 높습니다.

public class ThreadExample extends Thread {

    public void run() {
        System.out.println(Thread.currentThread().getName() + " start !!");
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 1; i <= 5; i++) {
            Thread thread = new ThreadExample();
            if (i == 5) thread.setPriority(10);
            thread.start();
        }

    }
}
실행 결과

Thread-4 start !!
Thread-1 start !!
Thread-2 start !!
Thread-0 start !!
Thread-3 start !!

쓰레드의 우선순위는 setPriority() 메소드로 설정할 수 있습니다. 맨 마지막 쓰레드에 가장 높은 우선순위인 10을 설정했습니다. 실행 결과를 보시면 마지막 쓰레드가 가장 먼저 실행됨을 알 수 있습니다.

4. Main 쓰레드

자바에서 main() 메소드 역시 하나의 쓰레드인데 메인 쓰레드에서 추가적인 쓰레드를 만들 수 있습니다. 멀티 쓰레드 환경에서는 메인 쓰레드가 종료되더라도 다른 쓰레드가 작업을 마칠 때까지 프로그램이 종료되지 않습니다.

public class ThreadExample extends Thread {

    public void run() {
        System.out.println(Thread.currentThread().getName() + " 종료");
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new ThreadExample();
        thread.start();
        System.out.println("Main Thread 종료");
    }
}
실행 결과

Main Thread 종료
Thread-0 종료

메인 쓰레드에서 새로운 쓰레드를 만들어 실행한 예제입니다. 실행 결과를 보시면 메인 쓰레드가 먼저 종료되고 Thread-0이 종료됩니다.

5. 동기화

싱글 쓰레드 프로세스의 경우 하나의 쓰레드만 사용하기 때문에 자원을 사용하는데 문제가 없지만 멀티 쓰레드의 경우 하나의 자원을 가지고 여러 쓰레드가 공유해서 사용하기 때문에 공유하는 자원에 대한 동기화 작업이 필요합니다. 예를 들어 하나의 공유 문서를 여러 쓰레드가 접근할 수 있을 때 A쓰레드가 공유 문서를 수정을 하고 있는데 B쓰레드도 수정을 하게되면 A쓰레드는 작업을 마쳤을 때 자신이 의도했던 것과는 다른 결과를 얻을 수 있습니다. 그래서 A쓰레드가 공유 문서를 수정할 때에는 B쓰레드는 공유 문서를 수정할 수 없게 동기화 처리를 해줘야 합니다.

자바에서는 synchronized를 이용해 동기화를 할 수 있습니다.

public class Cinema {
    int remainingSeat = 1;

    public void book() {
        if (remainingSeat <= 0) {
            System.out.println("남아 있는 자리가 없습니다.");
            return;
        }
        System.out.println("예매를 완료했습니다.");
        remainingSeat--;
    }
}

Cinema 클래스는 영화관 예매 기능을 담당하고 있습니다.

public class SynchronizedExample implements Runnable {

    Cinema cinema = new Cinema();

    public void run() {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        cinema.book();
    }

    public static void main(String[] args) {
        Runnable r = new SynchronizedExample();
        Thread thread = new Thread(r);
        Thread thread1 = new Thread(r);
        Thread thread2 = new Thread(r);

        thread.start();
        thread1.start();
        thread2.start();
    }
}
실행 결과

예매를 완료했습니다.
예매를 완료했습니다.
예매를 완료했습니다.

만약 3개의 쓰레드가 동시에 예매를 시도하면 어떻게 될까요?? 현재 남은 좌석 수가 1개이기 때문에 1개의 예약만 완료되어야 하는데 3개의 쓰레드 모두 예매를 성공합니다. 문제가 발생한 이유는 3개의 쓰레드가 동시에 book() 메소드에 접근했기 때문입니다. 예약 완료 후 좌석 수를 -1 해주기 전에 모두 자리가 남아 있는지 확인 하는 if문을 통과했기 때문에 3개의 쓰레드 모두 예약을 한것입니다. 자바에서는 이 문제를 syncronized를 사용해서 해결할 수 있습니다.

public synchronized void book() {
        if (remainingSeat <= 0) {
            System.out.println("남아 있는 자리가 없습니다.");
            return;
        }
        System.out.println("예매를 완료했습니다.");
        remainingSeat--;
    }

이렇게 동시성 제어가 필요한 메소드에 synchronized를 쓰면 하나의 쓰레드만 메소드에 접근할 수 있습니다. 한 쓰레드에 의해 book() 메소드가 호출되면 종료될 때까지 나머지 쓰레드가 book() 메소드를 호출해도 대기하게 됩니다. 즉 동시에 한 쓰레드만이 접근할 수 있게 돼서 동시성이 보장됩니다.

6. 데드락

데드락이란 프로세스가 자원을 얻지 못해 다음 처리를 못하는 상태로 더 이상 진행이 안 되는 상태입니다. 예를 들어 공동으로 사용하는 주방에서 A프로세스와 B프로세스가 라면을 끓이려고 하고 있습니다. 주방에는 라면 1개, 냄비 1개밖에 없었는데 A는 라면을 챙겼고 B는 냄비를 챙겼습니다. 라면을 끓이려면 라면과 냄비가 둘 다 있어야 하는데 A는 B가 가져간 냄비를 기다리고 있고 B는 A가 가져간 라면을 기다리고 있습니다. 결국 A, B 둘 다 서로가 필요한 자원을 양보하지 않고 원하기만 해서 라면을 끓이지 못하고 무한정 기다리기만 하게 됩니다.

데드락의 발생 조건

데드락은 네 가지의 조건이 동시에 성립할 때 발생합니다. 이 중 하나라도 성립하지 않는다면 데드락을 해결할 수 있습니다.

  1. 상호 배제 : 자원은 한 번에 한 프로세스만이 사용할 수 있어야 한다.
  2. 점유 대기 : 최소한 하나의 자원을 점유하고 있으면서 다른 프로세스가 가지고 있는 자원을 얻기 위해 대기해야 하는 프로세스가 있어야 한다.
  3. 비선점 : 다른 프로세스에 할당된 자원은 사용이 끝날 때까지 강제로 뺏을 수 없다.
  4. 순환 대기 : 프로세스의 집합 {P0, P1, ... Pn}이 있을 때 P0은 P1이 점유한 자원을 대기하고 있고 P1은 Pn이 점유한 자원을 대기하고 Pn은 P0이 점유한 자원을 대기하고 있는 상태여야 한다.

데드락의 처리

  1. 예방 기법 : 데드락이 일어나지 않도록 예방하는 방법입니다. 데드락은 위에 4가지 조건이 동시에 일어나야 발생하는데 이 중 적어도 하나가 성립하지 않게 만드는 방법입니다.
  2. 회피 기법 : 데드락이 발생되지 않게 알고리즘을 이용해 회피하는 기법입니다. 순환 대기가 발생하는지 확인하면서 자원을 할당해도 문제가 없을 경우에만 자원을 할당하는 방식입니다.
  3. 탐지와 회복 기법 : 데드락이 발생하도록 허용을 하고 데드락을 해결하는 방식입니다. 발생했으면 데드락을 일으킨 프로세스를 종료하거나 프로세스의 할당된 자원을 선점해 데드락을 제거하는 기법입니다.
  4. 무시 기법 : 데드락을 무시하고 특별한 조치를 취하지 않는 기법입니다. 교착상태가 발생하면 프로세스를 종료하는데 UNIX, Windows 등 대부분의 운영체제에서 이 방법을 사용합니다.

참조

https://goodgid.github.io/What-is-Thread/
https://www.daleseo.com/java-thread-runnable/
https://raccoonjy.tistory.com/15
https://coding-factory.tistory.com/569

'JAVA' 카테고리의 다른 글

JAVA 예외 처리  (0) 2021.01.15
Garbage Collection  (0) 2021.01.08
자바 인터페이스  (0) 2021.01.07
자바 패키지에 관하여  (0) 2020.12.31
JVM 구조와 JAVA 메모리 구조  (0) 2020.12.28

댓글