자바를 사용하고 공부하다보면 Thread와 관련된 부분이 많습니다. 이번 글에서는 자바의 멀티스레딩에 대해서 이야기해보겠습니다.

Thread 클래스와 Runnable 인터페이스

프로세스와 스레드

   프로세스(process)란 간단히 말해서 ‘실행 중인 프로그램(program)’입니다. 프로그램을 실행하면 OS로부터 실행에 필요한 자원(메모리)를 할당받아 프로세스가 됩니다.

프로세스는 프로그램을 수행하는 데 필요한 데이터와 메모리 등의 자원 그리고 스레드로 구성되어 있으며 프로세스의 자원을 이용해서 실제로 작업을 수행하는 것이 바로 스레드입니다.

모든 프로세스에는 최소한 하나 이상의 스레드가 존재하며, 둘 이상의 스레드를 가진 프로세스를 멀티스레드 프로세스(multi-threaded process)라고 합니다.

공장(프로세스)에서 일하는 일꾼(스레드)라고 생각하면 됩니다.


하나의 프로세스가 가질 수 있는 스레드의 개수는 제한되어 있지 않습니다. 하지만 스레드가 작업을 수행하는데 개별적인 메모리 공간(호출스택)을 필요로 하기 때문에 프로세스의 메모리 한계에 따라 생성할 수 있는 스레드의 수가 결정됩니다.

멀티태스킹과 멀티스레딩

   우리가 사용하는 대부분의 OS는 멀티태스킹(multi-tasking, 다중작업)을 지원하기 때문에 여러 개의 프로세스가 동시에 실행될 수 있습니다.

멀티스레딩은 하나의 프로세스 내에서 여러 스레드가 동시에 작업을 수행하는 것으로, CPU의 코어(core)가 한 번에 단 하나의 작업만 수행할 수 있으므로, 실제로 동시에 처리되는 작업의 개수는 코어의 개수와 일치합니다. 그러나 처리해야 하는 스레드의 수는 항상 코어의 수보다 많기 때문에 각 코어가 짧은 시간 동안 여러 작업을 번갈아 가며 수행함으로써 여러 작업들이 모두 동시에 수행되는 것처럼 보이게 합니다.

프로세스의 성능이 단순히 스레드의 개수에 비례하는 것은 아니며, 하나의 스레드를 가진 프로세스 보다 두 개의 스레드를 가진 프로세스가 오히려 더 낮은 성능을 보일 수도 있습니다.

스레드의 구현과 실행

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

Thread 클래스를 상속

class MyThread extends Thread {

    @Override
    public void run() { ... } // Thread 클래스의 run()을 오버라이딩
}

Runnable 인터페이스를 구현

class MyThread implements Runnable {
    public void run() { ... } // Runnable 인터페이스의 run()을 구현
}

Runnable 인터페이스는 오로지 run()만 정의되어 있는 간단한 인터페이스입니다. Runnable 인터페이스를 구현하기 위해서 해야 할 일은 추상메서드인 run()의 몸통{ }을 만들어 주는 것 뿐입니다.

    public interface Runnable {
        public abstract void run();
    }

스레드를 구현한다는 것은, 위의 두 방법 중 어떤 것을 선택하든, 스레드를 통해 작업하고자 하는 내용으로 run()의 몸통{ }을 채우는 것일 뿐.

class App {
    public static void main(String[] args) {
        ThreadOne t1 = new ThreadOne();

        Runnable r = new ThreadTwo();
        Thread t2 = new Thread(r);      // 생성자 Thread(Runnable Target)

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

class ThreadOne extends Thread {

    @Override
    public void run() {
        for(int i = 0; i < 3; i++) {
            System.out.println(getName()); // 조상인 Thread의 getName()을 호출
        }
    }
}

class ThreadTwo implements Runnable {
    public void run() {
        for(int i = 0; i < 3; i++) {
            // Thread.currentThread() : 현재 실행 중인 Thread를 반환
            System.out.println(Thread.currentThread().getName());
        }
    }
}


상속받을 때와 구현할 때의 인스턴스 생성 방법이 다릅니다.

  ThreadOne t1 = new ThreadOne();   // Thread의 자손 클래스의 인스턴스를 생성

  Runnable r = new ThreadTwo();     // Runnable을 구현한 클래스의 인스턴스를 생성
  Thread t2 = new Thread(r);        // 생성자 Thread(Runnable Target)

Runnable 인터페이스를 구현한 경우, Runnable 인터페이스를 구현한 클래스의 인스턴스를 생성한 다음, 이 인스턴스를 Thread 클래스의 생성자의 매개변수로 제공해야 합니다.

Thread 클래스를 상속받으면, 자손 클래스에서 조상인 Thread 클래스의 메서드를 직접 호출할 수 있지만, Runnable을 구현하면 Thread 클래스의 static 메서드인 currentThread()를 호출하여 스레드에 대한 참조를 얻어 와야만 호출이 가능합니다.

  • static Thread currentThread()
    • 현재 실행중인 스레드의 참조를 반환한다.
  • String getName()
    • 스레드의 이름을 반환한다.

스레드의 이름

스레드의 이름은 다음과 같은 생성자나 메서드를 통해서 지정 또는 변경할 수 있습니다.

    Thread(Runnable target, String name)
    Thread(String name)
    void setName(String name)

스레드의 이름을 지정하지 않으면 ‘Thread-번호’의 형식으로 이름이 정해집니다.

    System.out.println(Thread.currentThread().getName());

위 코드는 아래 코드를 한 줄로 쓴 것이라고 생각하면 됩니다.

    Thread t = Thread.currentThread();
    String name = t.getName();
    System.out.println(name);

스레드의 실행 - start()

   스레드를 생성했다고 해서 자동으로 실행되는 것은 아닙니다. start()를 호출해야만 스레드가 실행됩니다.

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

사실 start()가 호출되었다고 바로 실행되는 것은 아니고 실행대기 상태에 있다가 자신의 차례가 되어야 실행됩니다. 물론 실행대기 중인 스레드가 하나도 없으면 바로 실행상태가 됩니다.

한 번 실행이 종료된 스레드는 다시 실행할 수 없습니다. 즉, 하나의 스레드에 대해 start()가 한 번만 호출될 수 있다는 뜻입니다.
따라서 스레드의 작업을 한 번 더 수행해야 한다면 새로운 스레드를 생성한 다음 start()를 호출해야 합니다. 만일 하나의 스레드에 대해 start()를 두 번 이상 호출하면 실행 시에 IllegalThreadStateException이 발생합니다.

잘못된 호출

class App {
    public static void main(String[] args) {
        ThreadOne t1 = new ThreadOne();

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

class ThreadOne extends Thread {

    @Override
    public void run() {
        for (int i = 0; i < 3; i++) {
            System.out.println(getName());
        }
    }
}


올바른 호출

class App {
    public static void main(String[] args) {
        ThreadOne t1 = new ThreadOne();

        t1.start();
        t1 = new ThreadOne(); // 다시 생성
        t1.start();
    }
}

class ThreadOne extends Thread {

    @Override
    public void run() {
        for (int i = 0; i < 3; i++) {
            System.out.println(getName());
        }
    }
}


Thread와 Runnable은 언제 상속받고 언제 구현해야할까?

Runnable 인터페이스를 익명 내부 클래스로 사용하는 방법

▶ Thread 상속 예제

public class ThreadCreation extends Thread {

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());
        ThreadCreation threadcreation = new ThreadCreation();
        threadcreation.start();
    }
}

▶ Runnable 구현 예제

public class ThreadCreation {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName());
            }
        }).start();
    }
}

그래서 언제 쓰는지 어떻게 구분하는데?

extends Thread, 즉 Thread를 상속받아 사용할 때 run() 외에도 다른 것들을 Override를 해야할 필요가 있다면 Thread를 상속해서 만듭니다.
run()만 사용해도 되는 경우에는 Runnable을 사용하면 됩니다. 또는 Thread를 상속받을 클래스가 다른 클래스도 상속받아야 된다면 Runnable을 사용합니다.

스레드의 상태

   스레드 프로그래밍이 어려운 이유는 동기화(synchronization)와 스케쥴링(scheduling)때문입니다. 효율적인 멀티스레드 프로그램을 만들기 위해서는 보다 정교한 스케쥴링을 통해 프로세스에게 주어진 자원과 시간을 여러 스레드가 낭비없이 잘 사용하도록 프로그래밍 해야 합니다.

스레드의 상태

스레드의 상태는 다음과 같습니다.

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

이러한 스레드의 상태는 JDK1.5부터 추가된 Thread의 getState() 메서드를 호출해서 확인할 수 있습니다.

스레드의 생성부터 소멸까지


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

단, 무조건 번호 순서대로 스레드가 수행되는 것은 아닙니다.

스레드의 스케쥴링과 관련된 메서드

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

   sleep()은 지정된 시간동안 스레드를 멈추게 합니다.

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

세밀하게 값을 지정할 수 있지만 어느 정도의 오차가 발생할 수도 있습니다.

sleep()에 의해 일시정지 상태가 된 스레드는 지정된 시간이 다 되거나 interrupt()가 호출되면(InterruptedException 발생), 실행대기 상태가 됩니다.
따라서 sleep()을 호출할 때는 항상 try-catch문으로 예외를 처리해줘야 합니다. 매번 try-catch문을 사용하기 번거롭기 때문에, 새로운 메서드로 만들어 사용하기도 합니다.

    void delay(long millis) {
        try {
            Thread.sleep(millis)
        } catch (InterruptedException e) {}
    }
class App {
    public static void main(String[] args) {
        ThreadOne th1 = new ThreadOne();
        ThreadTwo th2 = new ThreadTwo();
        th1.start();
        th2.start();

        try {
            th1.sleep(2000);
        } catch(InterruptedException e) { }

        System.out.println("<< main 종료 >>");
    }
}

class ThreadOne extends Thread {

    @Override
    public void run() {
        for(int i = 0; i < 50; i++) {
            System.out.print("-");
        }
        System.out.println("<< th1 종료 >>");
    }
}

class ThreadTwo extends Thread {

    @Override
    public void run() {
        for(int i = 0; i < 50; i++) {
            System.out.print("|");
        }
        System.out.println("<< th2 종료 >>");
    }
}


예제의 결과를 보면 th1, th2, main 순으로 종료되었는데, 아래 코드를 생각하면 조금 의외입니다.

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

    try {
        th1.sleep(2000);
    } catch (InterruptedException e) { }

    System.out.println("<< main 종료 >>");

start()를 호출하고 th1.sleep(2000)를 호출하여 th1을 2초동안 일시정지상태로 만들었는데 th1이 가장 먼저 종료되었습니다.

그 이유는 sleep()이 항상 현재 실행 중인 스레드에 대해 작동하기 때문에 th1.sleep(2000)으로 호출하여도 실제로 영향을 받는 것은 main 메서드를 실행하는 main 스레드입니다.

그래서 sleep()은 static으로 선언되어 있으며 참조변수를 이용해서 호출하기 보다는 Thread.sleep(2000)과 같이 해야합니다.

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

   진행 중인 스레드의 작업이 끝나기 전에 취소시켜야할 때가 있습니다. interrupt()를 사용하면 스레드에게 작업을 멈추라고 요청합니다. 단지 멈추라고 요청만 하는 것이고 스레드를 강제로 종료시키지는 못합니다. interrupt()는 그저 스레드의 interrupted상태(인스턴스 변수)를 바꾸는 것일 뿐입니다.

그리고 interrupted()는 스레드에 대해 interrupt()가 호출되었는지 알려줍니다. interrupt()가 호출되지 않았다면 false를, 호출되었다면 true를 반환합니다.

    Thread th = new Thread();
    th.start();
      ...
    th.interrupt();

    class MyThread extends Thread {
        public void run() {
            while(!interrupted()) {
                ...
            }
        }
     }

   isInterrupted()도 스레드의 interrupt()가 호출되었는지 확인하는데 사용할 수 있지만, interrupted()와 달리 isInterrupted()는 스레드의 interrupt상태를 false로 초기화하지 않습니다.

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

스레드가 sleep(), wait(), join()에 의해 일시정지 상태(WAITING)에 있을 때, 해당 스레드에 대해 interrupt()를 호출하면, sleep(), wait(), join()에서 InterruptedException이 발생하고 스레드는 실행대기 상태(RUNNABLE)로 바뀝니다. 즉, 멈춰있던 스레드를 깨워서 실행가능한 상태로 만드는 것입니다.

suspend(), resume(), stop()

   suspend()는 sleep()처럼 스레드를 멈추게 합니다. suspend()에 의해 정지된 스레드는 resume()을 호출해야 다시 실행대기 상태가 됩니다. stop()은 호출되는 즉시 스레드가 종료됩니다.

suspend(), resume(), stop()은 스레드의 실행을 제어하는 가장 손쉬운 방법이지만, suspend()와 stop()이 교착상태(deadlock)을 일으키기 쉽게 작성되어있으므로 사용이 권장되지 않습니다. 그래서 이 메서드들은 모두 deprecated되었다. Java API문서 stop()을 찾아보면 아래와 같이 Deprecated.라고 적혀있습니다.

void stop(Throwable obj)
  Deprecated.
This method was originally designed to force a thread to stop and throw a given Throwable as an exception. It was inherently unsafe (see stop() for details), and furthermore could be used to generate exceptions that the target thread was not prepared to handle.

deprecated의 의미는 ‘전에는 사용되었지만, 앞으로는 사용하지 않을 것을 권장한다’는 의미입니다. deprecated된 메서드는 하위 호환성을 위해서 삭제하지 않는 것일 뿐이므로 사용해서는 안 됩니다.

yield() - 다른 스레드에게 양보한다.

   yield()는 스레드 자신에게 주어진 실행시간을 다음 차례의 스레드에게 양보(yield)합니다. 예를 들어 스케쥴러에 의해 1초의 실행시간을 할당받은 스레드가 0.5초의 시간동안 작업한 상태에서 yield()가 호출되면, 나머지 0.5초는 포기하고 다시 실행대기상태가 됩니다.

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

   join()은 자신의 작업 중간에 다른 스레드의 작업을 참여(join)시킨다는 의미로 이름 지어진 것입니다. 스레드 자신이 하던 작업을 잠시 멈추고 다른 스레드가 지정된 시간동안 작업을 수행하도록 할 때 사용합니다.

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

시간을 지정하지 않으면, 해당 스레드가 작업을 모두 마칠 때까지 기다리게 됩니다. 작업 중에 다른 스레드의 작업이 먼저 수행되어야할 필요가 있을 때 join()을 사용합니다.

    try {
        th1.join()
    } catch (InterruptedException e) { }

join()도 sleep()처럼 interrupt()에 의해 대기상태에서 벗어날 수 있으며, join()이 호출되는 부분을 try-catch문으로 감싸야 합니다. join()은 여러모로 sleep()과 유사한 점이 많은데, sleep()과 다른 점은 join()은 현재 스레드가 아닌 특정 스레드에 대해 동작하므로 static메서드가 아니라는 것입니다.

쓰레드는 언제 쓰면 좋을까?

서버의 리소스를 극한으로 활용할 때 사용하면 좋습니다. 근데 왜 안 쓸까요? 컨테이너가 대신 해주기 때문입니다.

예를 들어 자바개발자들은 대부분 스프링 MVC로 코딩을 합니다.

package spring;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@ReastController
public class HelloController {

    @GetMapping("/hello")
    publicc String hello() {
        return "Hello Spring";
    }
}

위 코드에는 별개의 멀티 쓰레드 코딩을 하지 않았지만, 매 요청이 들어올 때마다 알아서 멀티 쓰레드가 활용되어서 코드가 실행됩니다.

스레드의 우선순위

   스레드는 우선순위(priority)라는 속성(멤버변수)를 가지고 있는데, 이 우선순위의 값에 따라 스레드가 얻는 실행시간이 달라집니다. 스레드가 수행하는 작업의 중요도에 따라 스레드의 우선순위를 서로 다르게 지정하여 특정 스레드가 더 많은 작업시간을 갖도록 할 수 있습니다.

우선순위 지정하기

   스레드의 우선순위와 관련된 메서드와 상수는 다음과 같습니다.

    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    // 보통 우선순위

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

   스레드의 우선순위는 스레드를 생성한 스레드로부터 상속받습니다. main메서드를 수행하는 스레드는 우선순위가 5이므로 main메서드 내에서 생성하는 스레드의 우선순위는 자동적으로 5가 됩니다.

class App {
    public static void main(String[] args) {
        ThreadOne th1 = new ThreadOne();
        ThreadTwo th2 = new ThreadTwo();

        th2.setPriority(10);

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

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

class ThreadOne extends Thread {

    @Override
    public void run() {
        for(int i = 0; i < 300; i++) {
            System.out.print("-");
            for(int x = 0; x < 10000000; x++); // 작업을 지연시키기위한 for문
        }
    }
}

class ThreadTwo extends Thread {

    @Override
    public void run() {
        for(int i = 0; i < 300; i++) {
            System.out.print("|");
            for(int x = 0; x < 10000000; x++);
        }
    }
}


한 가지 의문은 예제에서 th2의 우선순위를 높게했음에도 불구하고 th1이 먼저 완료되는 결과가 나왔습니다.


우선순위가 10에서 7로 낮아졌는데 이번에는 th2가 먼저 완료되었습니다. 몇 번 더 실행해본 결과 th2가 먼저 완료되는 경우가 많기는 했지만 th1이 먼저 완료되는 경우도 출력이 되었습니다. 이러한 결과에서 알 수 있듯이 우선순위는 절대적으로 지켜지는 것이 아닙니다. 다만 우선순위가 높은 스레드에게 상대적으로 많은 양의 실행시간이 주어지는 것일 뿐입니다.

Main 스레드

   실제로 스레드를 실행시킬 때는 run()이 아니라 start()를 사용합니다.

main메서드에서 run()을 호출하는 것은 생성된 스레드를 실행시키는 것이 아니라 단순히 클래스에 선언된 메서드를 호출하는 것일 뿐입니다.

반면에 start()는 새로운 스레드가 작업을 실행하는데 필요한 호출스택(call stack)을 생성한 다음에 run()을 호출해서, 생성된 호출스택에 run()이 첫번째로 올라가게 합니다.

모든 스레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택이 필요하고, 새로운 스레드를 생성하고 실행시킬 때마다 새로운 호출스택이 생성되고 소멸되는 것을 반복합니다.

호출스택은 이름처럼 스택(stack)과 같이 동작하며 가장 위에 있는 메서드가 현재 실행중인 메서드이고 나머지 메서드들은 대기상태입니다. 그러나 스레드가 둘 이상인 경우에는 호출스택의 최상위에 있는 메서드일지라도 대기상태에 있을 수 있습니다.

main스레드

   main메서드의 작업을 수행하는 것도 스레드이며, 이를 main 스레드라고 합니다. 프로그램을 실행하면 기본적으로 하나의 스레드를 생성하고, 그 스레드가 main메서드를 호출해서 작업이 수행되는 것입니다.

보통 main메서드가 수행을 마치면 프로그램이 종료되지만, main메서드는 수행을 마쳤는데 다른 스레드가 아직 작업을 마치지 않은 상태라면 프로그램이 종료되지 않습니다.

class App {
    public static void main(String[] args) {
        ThreadOne th1 = new ThreadOne();
        th1.start();
    }
}

class ThreadOne extends Thread {

    @Override
    public void run() {
        throwException();
    }

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


결과를 보면 알 수 있듯이 호출스택의 첫 번째 메서드가 main메서드가 아니라 run메서드입니다.

한 스레드가 예외를 발생해서 종료되어도 다른 스레드의 실행에는 영향을 미치지 않습니다.

class App {
    public static void main(String[] args) {
        ThreadOne th1 = new ThreadOne();
        th1.run();
    }
}

class ThreadOne extends Thread {

    @Override
    public void run() {
        throwException();
    }

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


이전 예제와 달리 run()을 사용하여 새로운 스레드가 생성되지 않았습니다.

동기화

   멀티스레드 프로세스의 경우 여러 스레드가 같은 프로세스 내의 자원을 공유하기 때문에 서로의 작업에 영향을 줄 수 있습니다.

이러한 일을 방지하기 위해서 한 스레드가 특정 작업을 끝마치기 전까지 다른 스레드에 의해 방해받지 않도록 하는 것이 필요합니다. 그래서 도입된 개념이 바로 ‘임계 영역(critical section)’과 ‘잠금(락, lock)’입니다.

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

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

자바에서는 synchronized블럭을 이용해서 스레드의 동기화를 지원했지만, JDK1.5부터 java.util.concurrent.locksjava.util.concurrent.atomic패키지를 통해서 다양한 방식으로 동기화를 구현할 수 있도록 지원하고 있습니다.

synchronized를 이용한 동기화

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

  1. 메서드 전체를 임계 영역으로 지정
    public synchronized void calcSum() {
    // …
    }

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

첫 번째 방법은 메서드 앞에 synchronized를 붙이는 것인데, synchronized를 붙이면 메서드 전체가 임계 영역으로 설정됩니다. 스레드는 synchronized메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환합니다.

두 번째 방법은 메서드 내의 코드 일부를 블럭으로 감싸고 블럭 앞에 synchronized(참조변수)를 붙이는 것인데, 이 때 참조변수는 락을 걸고자 하는 객체를 참조하는 것이어야 합니다. 이 블럭을 synchronized블럭이라고 부르며, 이 블럭의 영역 안으로 들어가면서부터 스레드는 지정된 객체의 lock을 얻게 되고, 이 블럭을 벗어나면 lock을 반납합니다.

   두 방법 모두 lock의 획득과 반납이 모두 자동적으로 이루어지므로 우리가 해야 할 일은 그저 임계 영역만 설정해주는 것뿐입니다.

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

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


은행계좌에서 잔고를 확인하고 임의의 금액을 출금하는 예제입니다. 코드 중 withdraw부분을 살펴보면 잔고가 출금하려는 금액보다 큰 경우에만 출금하도록 되어 있는 것을 확인할 수 있습니다.

    public void withdraw(int money) {
        if (balance >= money) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) { }
            balance -= money;
        }
    }

그러나 실행결과를 보면 잔고가 음수 값으로 되어있는데, 그 이유는 한 스레드가 if문의 조건식을 통과하고 출금하기 바로 직전에 다른 스레드가 끼어들어서 출금을 먼저 했기 때문입니다.

이러한 상황을 막기 위해서 synchronized를 사용하는 것입니다.

아래 코드는 synchronized를 사용하여 수정한 코드입니다.

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

class Account {
    private int balance = 1000;

    public int getBalance() {
        return balance;
    }

    public synchronized void withdraw(int money) {
        if (balance >= money) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {

            }
            balance -= money;
        }
    }
}

class RunnableEx implements Runnable {
    Account acc = new Account();

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


결과 값에 음수가 사라진 것을 확인할 수 있습니다. 여기서 한 가지 주의할 점은 Account클래스의 인스턴스 변수인 balance의 접근 제어자가 private이라는 것입니다. 만일 private이 아니면, 외부에서 직접 접근할 수 있기 때문에 동기화가 무의미해 집니다. synchronized를 이용한 동기화는 지정된 영역의 코드를 한 번에 하나의 스레드가 수행하는 것을 보장하는 것이기 때문입니다.

wait()와 notify()

   동기화를 이용해서 공유 데이터를 보호하는 것은 좋은데, 특정 스레드가 락을 보유한 채로 상황이 해결될 때까지 오랜 시간을 보내게 된다면, 다른 작업들이 원활히 진행되지 않을 것입니다.

   이러한 상황을 개선하기 위해 고안된 것이 바로 wait()notify()입니다. 동기화된 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, 일단 wait()를 호출하여 스레드가 락을 반납하고 기다리게 합니다. 그러면 다른 스레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 됩니다. 나중에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서, 작업을 중단했던 스레드가 다시 락을 얻어 작업을 진행할 수 있게 합니다.

lock이 넘어간다고 해서 무조건 오래 기다리던 스레드가 받게 된다는 보장은 없습니다. wait()을 호출하면 작업을 하던 스레드는 해당 객체의 대기실(waiting pool)로 이동하여 연락을 기다립니다. notify()가 호출되면, 해당 객체의 대기실에 있던 모든 스레드 중 임의의 스레드만 연락을 받게되고, notifyAll()이 호출되면 연락은 모든 스레드가 받지만 랜덤하게 선택된 하나의 스레드가 lock을 받게 됩니다.

   wait()와 notify()는 특정 객체에 대한 것이므로 Object클래스에 정의 되어있습니다.

    void wait()
    void wait(long timeout)
    void wait(long timeout, int nanos)
    void notify()
    void notifyAll()

wait()는 notify() 또는 notifyAll()이 호출될 때까지 기다리지만, 매개변수가 있는 wait()는 지정된 시간동안만 기다립니다.

waiting pool은 객체마다 존재하는 것이므로 notifyAll()이 호출된다고 해서 모든 객체의 waiting pool에 있는 스레드가 깨워지는 것은 아닙니다. notifyAll()이 호출된 객체의 waiting pool에 대기 중인 스레드만 해당됩니다.

  • wait(), notify(), notifyAll()
    • Object에 정의되어 있다.
    • 동기화 블록(synchronized블록)내에서만 사용할 수 있다.
    • 보다 효율적인 동기화를 가능하게 한다.

기아 현상과 경쟁 상태

정말 지독하게 운이 나빠서 스레드가 연락을 받지 못하고 오랫동안 기다리게 되는데, 이것은 기아(starvation) 현상이라고 합니다. 이 현상을 막으려면, notify() 대신 notifyAll()을 사용해야 합니다.

notifyAll()로 원하는 스레드의 기아현상은 막았지만, 다른 스레드까지 연락을 받아서 불필요하게 lock을 얻기 위해 경쟁하게 됩니다. 이처럼 여러 스레드가 lock을 얻기 위해 서로 경쟁하는 것을 경쟁 상태(race condition)라고 하는데, 이것을 개선하기 위해서는 구별해서 연락하는 것이 필요합니다.

Lock과 Condition을 이용한 동기화

   동기화할 수 있는 방법은 synchronized블럭 외에도 java.util.concurrent.locks패키지가 제공하는 lock클래스들을 이용하는 방법이 있습니다. 이 패키지는 JDK1.5에 와서야 추가된 것으로 그 전에는 동기화 방법이 synchronized블럭 뿐이었습니다.

   synchronized블럭으로 동기화를 하면 자동적으로 lock이 잠기고 풀리기 때문에 편리합니다. 하지만 같은 메서드 내에서만 lock을 걸 수 있다는 제약이 불편하기도 합니다. 그럴 때 이 lock클래스를 사용합니다. lock클래스의 종류는 다음과 같이 3가지가 있습니다.

  • ReentrantLock
    • 재진입이 가능한 lock. 가장 일반적인 배타 lock
  • ReentrantReadWriteLock
    • 읽기에는 공유적이고, 쓰기에는 배타적인 lock
  • StampedLock
    • ReentrantReadWriteLock에 낙관적인 lock을 추가

▶ ReentrantLock

   ReentrantLock은 가장 일반적인 lock입니다. ‘reentrant(재진입할 수 있는)’이라는 단어가 앞에 붙은 이유는 wait() & notify()처럼, 특정 조건에서 lock을 풀고 나중에 다시 lock을 얻어 이후의 작업을 수행할 수 있기 때문입니다.

▶ ReentrantReadWriteLock

   ReentrantReadWriteLock은 읽기를 위한 lock과 쓰기를 위한 lock을 제공합니다. ReentrantLock은 배타적인 lock이라서 무조건 lock이 있어야만 임계 영역의 코드를 수행할 수 있지만, ReentrantReadWriteLock은 읽기 lock이 걸려있으면, 다른 스레드가 읽기 lock을 중복해서 걸고 읽기를 수행할 수 있습니다. 읽기는 내용을 변경하지 않으므로 동시에 여러 스레드가 읽어도 문제가 되지 않습니다. 그러나 읽기 lock이 걸린 상태에서 쓰기 lock을 거는 것은 허용되지 않습니다. 반대의 경우도 마찬가지입니다. 읽기를 할 때는 읽기 lock을 걸고, 쓰기 할 때는 쓰기 lock을 거는 것일 뿐 lock을 거는 방법은 같습니다.

▶ StampedLock

   StampedLock은 lock을 걸거나 해지할 때 ‘스탬프(long타입의 정수값)’를 사용하며, 읽기와 쓰기를 위한 lock외에 ‘낙관적 읽기 lock(optimistic reading lock)’이 추가된 것입니다. 읽기 lock이 걸려있으면, 쓰기 lock을 얻기 위해서는 읽기 lock이 풀릴 때까지 기다려야하는데 비해 ‘낙관적 읽기 lock’은 쓰기 lock에 의해 바로 풀립니다. 따라서 낙관적 읽기에 실패하면, 읽기 lock을 얻어서 다시 읽어 와야 합니다. 무조건 읽기 lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 거는 것입니다.

int getBalance() {
    long stamp = lock.tryOptimisticRead();  // 낙관적 읽기 lock을 건다.

    int curBalance = this.balance;    // 공유 데이터인 balance를 읽어온다.

    if(!lock.validate(stamp)) {   // 쓰기 lock에 의해 낙관적 읽기 lock이 풀렸는지 확인
        stamp = lock.readLock(); // lock이 풀렸으면, 읽기 lock을 얻으려고 기다린다.

        try {
            curBalance = this.balance;    // 공유 데이터를 다시 읽어온다.
        } finally {
            lock.unlockRead(stamp);     // 읽기 lock을 푼다.
        }
    }

    return curBalance;    // 낙관적 읽기 lock이 풀리지 않았으면 곧바로 읽어온 값을 반환
}

ReentrantLock의 생성자

    ReentrantLock()
    ReentrantLock(boolean fair)

생성자의 매개변수를 true로 주면, lock이 풀렸을 때 가장 오래 기다린 스레드가 lock을 획득할 수 있게, 즉 공정(fair)하게 처리합니다. 그러나 공정하게 처리하면 가장 오래된 스레드를 찾는 과정이 추가되어 성능이 떨어집니다.

   대부분의 경우 공정하게 처리하지 않아도 문제가 되지 않으므로 공정함보다 성능을 선택합니다.

    void lock()         // lock을 잠근다.
    void unlock()       // lock을 해제한다.
    boolean isLocked()  // lock이 잠겼는지 확인한다.

자동적으로 lock의 잠금과 해제가 관리되는 synchronized블럭과 달리, ReentrantLock과 같은 lock클래스들은 수동으로 lock을 잠그고 해제해야 합니다. 하지만 메서드를 호출하기만 하면되기 때문에 간단합니다. lock을 걸고 나서 푸는 것을 잊지않도록 주의해야 합니다.

    lock.lock();
    // 임계 영역
    lock.unlock();

임계 영역 내에서 예외가 발생하거나 return문으로 빠져 나가게 되면 lock이 풀리지 않을 수 있으므로 unlock()은 try-finally문으로 감싸는 것이 일반적입니다. 참조변수 lock은 ReentrantLock객체를 참조한다고 가정했습니다.

    lock.lock();
    try {
        // 임계 영역
    } finally {
        lock.unlock();
    }

이렇게 하면, try블럭 내에서 어떤 일이 발생해도 finally블럭에 있는 unlock()이 수행되어 lock이 풀리지 않는 일은 발생하지 않습니다. 대부분의 경우 synchronized블럭을 사용할 수 있어서 그냥 synchronized블럭을 사용하는 것이 나을 수 있습니다.

▶ tryLock()

   tryLock()은 lock()과 달리, 다른 스레드에 의해 lock이 걸려 있으면 lock을 얻으려고 기다리지 않습니다. 또는 지정된 시간만큼만 기다립니다. lock을 얻으면 true, 얻지 못하면 false를 반환합니다.

    boolean tryLock()
    boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException

lock()은 lock을 얻을 때까지 스레드를 블락(block)시키므로 스레드의 응답성이 나빠질 수 있습니다. 응답성이 중요한 경우, tryLock()을 이용해서 지정된 시간동안 lock을 얻지 못하면 다시 작업을 시도할 것인지 포기할 것인지를 사용자가 결정할 수 있게 하는 것이 좋습니다.

이 메서드는 InterruptedException을 발생시킬 수 있는데, 이것은 지정된 시간동안 lock을 얻을려고 기다리는 중에 interrupt()에 의해 작업이 취소될 수 있도록 코드를 작성할 수 있다는 뜻입니다.

ReentrantLock과 Condition

Condition은 wait() & notify()에서 스레드를 구분해서 연락하지 못한다는 단점을 해결하기 위한 것입니다.

   wait() & notify()로 스레드의 종류를 구분하지 않고, 공유 객체의 waiting pool에 같이 몰아넣은 대신, 각각의 스레드의 Condition을 만들어서 각각의 waiting pool에서 기다리도록 하면 문제는 해결됩니다.

Condition은 이미 생성된 lock으로부터 new Condition()을 호출해서 생성합니다.

    private ReentrantLock lock = new ReentrantLock(); // lock을 생성

    // lock으로 condition을 생성
    private Condition forOne = lock.newCondition();
    private Condition forTwo = lock.newCondition();

그리고 wait() & notify() 대신 await() & signal()을 사용하면 됩니다.

  • void await()
    • void await()
    • void awaitUninterruptibly()
  • void await(long timeout)
    • boolean await(long time, TimeUnit unit)
    • long awaitNanos(long nanosTimeout)
    • boolean awaitUntil(Date deadline)
  • void notify()
    • void signal()
  • void notifyAll()
    • void signalAll()

데드락

   교착상태(데드락, deadlock)은 두 개 이상의 작업이 서로 상대방의 작업이 끝나기를 기다리고 있어서 아무것도 완료되지 못하는 상태를 말합니다.

교착상태의 조건

  1. 상호배제(Mutual exclusion) : 프로세스들이 필요로 하는 자원에 대해 배타적인 통제권을 요구한다.
  2. 점유대기(Hold and wait) : 프로세스가 할당된 자원을 가진 상태에서 다른 자원을 기다린다.
  3. 비선점(No preemption) : 프로세스가 어떤 자원의 사용을 끝낼 때까지 그 자원을 뺏을 수 없다.
  4. 순환대기(Circular wait) : 각 프로세스는 순환적으로 다음 프로세스가 요구하는 자원을 가지고 있다.

위 조건 중에서 한 가지라도 만족하지 않으면 교착 상태는 발생하지 않습니다. 이중 순환대기 조건은 점유대기 조건과 비선점 조건을 만족해야 성립하는 조건이므로, 위 4가지 조건은 서로 완전히 독립적인 것은 아닙니다.

교착상태는 예방, 회피, 무시 세 가지 방법으로 관리할 수 있습니다.

예방

  • 상호배제 조건의 제거
    • 교착 상태는 두 개 이상의 프로세스가 공유가능한 자원을 사용할 때 발생하는 것이므로 공유 불가능한, 즉 상호 배제 조건을 제거하면 교착 상태를 해결할 수 있다.
  • 점유와 대기 조건의 제거
    • 한 프로세스에 수행되기 전에 모든 자원을 할당시키고 나서 점유하지 않을 때에는 다른 프로세스가 자원을 요구하도록 하는 방법이다. 자원 과다 사용으로 인한 효율성, 프로세스가 요구하는 자원을 파악하는 데에 대한 비용, 자원에 대한 내용을 저장 및 복원하기 위한 비용, 기아 상태, 무한대기 등의 문제점이 있다.
  • 비선점 조건의 제거
    • 비선점 프로세스에 대해 선점 가능한 프로토콜을 만들어 준다.
  • 환형 대기 조건의 제거
    • 자원 유형에 따라 순서를 매긴다.

이 해결 방법들은 자원 사용의 효율성이 떨어지고 비용이 많이 드는 문제점이 있습니다.

회피

자원이 어떻게 요청될지에 대한 추가정보를 제공하도록 요구하는 것으로 시스템에 circular wait가 발생하지 않도록 자원 할당 상태를 검사합니다.

교착 상태 회피 알고리즘은 크게 두가지가 있습니다.

  1. 자원 할당 그래프 알고리즘 (Resource Allocation Graph Algorithm)
  2. 은행원 알고리즘 (Banker’s algorithm)

무시

예방과 회피방법을 활용하면 성능 상 이슈가 발생하는데, 데드락 발생에 대한 상황을 고려하는 것에 대한 비용이 낮다면 별다른 조치를 하지 않을 수도 있다고 합니다.

다음 코드는 오라클에서 제공하는 데드락의 예제입니다.

public class Deadlock {
    static class Friend {
        private final String name;
        public Friend(String name) {
            this.name = name;
        }
        public String getName() {
            return this.name;
        }
        public synchronized void bow(Friend bower) {
            System.out.format("%s: %s"
                + "  has bowed to me!%n",
                this.name, bower.getName());
            bower.bowBack(this);
        }
        public synchronized void bowBack(Friend bower) {
            System.out.format("%s: %s"
                + " has bowed back to me!%n",
                this.name, bower.getName());
        }
    }

    public static void main(String[] args) {
        final Friend alphonse =
            new Friend("Alphonse");
        final Friend gaston =
            new Friend("Gaston");
        new Thread(new Runnable() {
            public void run() { alphonse.bow(gaston); }
        }).start();
        new Thread(new Runnable() {
            public void run() { gaston.bow(alphonse); }
        }).start();
    }
}