본문 바로가기

JAVA 스터디

[JAVA] 멀티쓰레드 프로그래밍(Multi Thread Programming)

프로세스(process)

프로세스란 단순히 실행 중인 프로그램(program)을 말합니다.

즉, 사용자가 작성한 프로그램이 운영체제에 의해 메모리 공간을 할당받아 동작하는 것을 말합니다.

프로세스는 프로그램에 사용되는 데이터와 메모리 등의 자원 그리고 쓰레드로 구성됩니다.

 

쓰레드(thread)

쓰레드란 프로세스 내에서 실질적으로 작업을 수행하는 주체를 말합니다.

모든 프로세스에는 한 개 이상의 쓰레드가 존재합니다.

이때 두 개 이상의 쓰레드를 가지는 프로세스를 멀티 쓰레드 프로세스(multi-htreaded process)라고 합니다.

 

예를 들어 메신저 프로세스 같은 경우 채팅 기능을 제공하면서 동시에 파일 업로드 기능을 수행할 수 있습니다.

이처럼 한 프로세스에서 멀티 태스킹이 가능한 이유는 멀티 쓰레드 ( Multi Thread ) 덕분입니다.

멀티 프로세스는 프로세스마다 운영체제로부터 할당받은 고유의 메모리를 서로 침범할 수 없지만

멀티 쓰레드는 /java/java-jvm/ 포트스에서 확인할 수 있는 것처럼

프로세스 내부에서의 멀티 쓰레드는 공유되는 자원이 있어 하나의 쓰레드에서 예외가 발생한다면

프로세스 자체가 종료될 수 있습니다.

 

Thread 클래스와 Runnable 인터페이스

Thread를 만드는 방법은 다음과 같이 두 가지 방법이 있습니다.

 

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

두 방법 모두 스레드를 통해 작업하고 싶은 내용을 run() 메소드에 작성하면 됩니다.

 

빠른 이해를 위해서 예제를 살펴보겠습니다.

 

1. Thread 클래스를 상속받는 방법

 

public class Example extends Thread {
    public void run() {
        System.out.println("thread run.");
    }
    
    public static void main(String[] args) {
        Example test = new Example();
        test.start();
    }
}

// 실행결과
thread run.

 

Example 클래스가 Thread 클래스를 상속했습니다.

Thread 클래스의 run 메소드를 구현하면 위 예제와 같이 test.start() 실행 시 Test 객체의 run() 메소드가 수행됩니다.

 

Thread 클래스는 start() 메소드 실행 시, run() 메소드가 수행되도록 내부적으로 코딩되어 있습니다.

따라서 Thread 클래스를 상속했기 때문에 main()에서 start()를 실행했을 때, run() 메소드가 실행되는 것입니다.

 

쓰레드를 좀 더 이해하기 위해서는 2개 이상의 쓰레드를 동작시켜볼 필요가 있습니다.

 

public class Example extends Thread {
    int seq;
    public Example(int seq) {
        this.seq = seq;
    }
    public void run() {
        System.out.println(this.seq + " thread start.");
        try {
            Thread.sleep(1000);		// 1초간 일시정지
        } catch(Exception e) {
            ...
        }
        System.out.println(this.seq + " thread end.");
    }
    
    public static void main(String[] args) {
        for(int i = 0; i < 10; i++) {
            Thread t = new Example(i);
            t.start();
        }
        System.out.println("main end.");
    }
}

 

위의 예제는 총 10개의 쓰레드를 실행시키고 있습니다.

각 쓰레드를 구별하기 위해서 식별번호를 부여했습니다.

그리고 run() 메소드 수행 시, 시작과 종료를 출력하게 했고

시작과 종료 사이에 1초의 간격이 생기도록 작성했습니다.

 

그리고 main()이 종료되면 "main end."를 출력하도록 했습니다.

아래는 그 결과입니다.

 

0 thread start.
4 thread start.
6 thread start.
2 thread start.
main end.
3 thread start.
7 thread start.
8 thread start.
1 thread start.
9 thread start.
5 thread start.
0 thread end.
4 thread end.
2 thread end.
6 thread end.
7 thread end.
3 thread end.
8 thread end.
9 thread end.
1 thread end.
5 thread end.

 

결과를 보면 0번 부터 9번까지 순서대로 실행되지 않고 임의로 동시에 실행됨을 알 수 있습니다.

또한 모든 쓰레드가 실행되기도 전에 main()이 종료되었습니다.

 

각 쓰레드가 운영체제로 부터 메모리 공간을 할당받는 시기가 모두 다르기 때문에 발생하는 문제입니다.

그리고 10개의 쓰레드가 모두 종료된 다음에 main()이 종료되길 의도하였지만 뜻대로 되지 않았습니다.

 

이처럼 쓰레드 프로그래밍 시 가장 많이 실수하는 부분이

쓰레드가 종료되지 않았는데 그다음 코드를 수행하게 만드는 것입니다.

 

해당 문제를 방지하기 위해 필요한 것이 바로 join 메소드입니다.

join 메소드는 다른 쓰레드가 종료될 때까지 기다리게 하는 메서드입니다.

 

public static void main(String[] args) {
    ArrayList<Thread> threads = new ArrayList<Thread>();
    for(int i=0; i<10; i++) {
        Thread t = new Example(i);
        t.start();
        threads.add(t);
    }

    for(int i=0; i<threads.size(); i++) {
        Thread t = threads.get(i);
        try {
            t.join();
        }catch(Exception e) {
        }
    }
    System.out.println("main end.");
}

 

생성되는 쓰레드를 담기 위해서 ArrayList 객체인 threads를 만든 다음

쓰레드 생성 시 생성된 객체를 threads에 저장합니다.

 

main()이 종료되기 전에 threads에 담긴 각각의 thread에 join() 메소드를 호출하여

쓰레드가 종료될 때까지 대기하도록 변경했습니다.

 

아래는 그 결과입니다.

 

0 thread start.
5 thread start.
2 thread start.
6 thread start.
9 thread start.
1 thread start.
7 thread start.
3 thread start.
8 thread start.
4 thread start.
0 thread end.
5 thread end.
2 thread end.
9 thread end.
6 thread end.
1 thread end.
7 thread end.
4 thread end.
8 thread end.
3 thread end.
main end.

 

"main end."가 제일 마지막에 출력된 것을 볼 수 있습니다.

 

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

Runnable 인터페이스는 몸체가 없는 메소드인 run() 메소드 단 하나만을 가지는 인터페이스입니다.

 

일반적으로 쓰레드 객체를 만들 때는 Runnable 인터페이스를 구현하는 방법을 많이 사용합니다.

Thread 클래스를 상속받으면 다른 클래스를 상속받지 못하는 문제가 있기 때문입니다.

 

public class Example implements Runnable {
    int seq;
    public Example(int seq) {
        this.seq = seq;
    }
    public void run() {
        System.out.println(this.seq+" thread start.");
        try {
            Thread.sleep(1000);
        }catch(Exception e) {
        }
        System.out.println(this.seq+" thread end.");
    }

    public static void main(String[] args) {
        ArrayList<Thread> threads = new ArrayList<Thread>();
        for(int i=0; i<10; i++) {
            Thread t = new Thread(new Example(i));
            t.start();
            threads.add(t);
        }

        for(int i=0; i<threads.size(); i++) {
            Thread t = threads.get(i);
            try {
                t.join();
            }catch(Exception e) {
            }
        }
        System.out.println("main end.");
    }
}

 

Thread를 상속하던 것에서 Runnable을 구현하도록 변경했습니다.

그리고 Thread를 생성하는 부분을 다음과 같이 변경했습니다.

 

Thread t = new Thread(new Example(i));

 

Thread의 생성자로 Runnable 인터페이스를 구현한 객체를 넘길 수 있는데 이 방법을 사용한 것입니다.

이렇게 변경된 코드는 이전에 만들었던 예제와 완전히 동일하게 동작합니다.

 

쓰레드의 상태

쓰레드를 활용하기 위해서는 쓰레드의 상태에 대해 자세히 알아볼 필요가 있습니다.

쓰레드의 상태를 그림으로 나타내면 다음과 같습니다.

 

스레드 상태

실행 대기 상태

아직 스케줄링이 되지 않아서 실행을 기다리고 있는 상태입니다.

 

실행 상태

실행 대기 상태에 있는 쓰레드 중에서 쓰레드 스케줄링으로 선택된 쓰레드가

CPU를 점유하고 run() 메소드를 실행하는 상태입니다.

 

쓰레드 객체를 생성하고, start() 메소드를 호출하면

곧바로 쓰레드가 실행되는 것처럼 보이지만 실은 실행 대기 상태가 됩니다.

실행 대기 상태에 있는 스레드 중에서 쓰레드 스케줄링으로 선택된 스레드만 실행 상태가 됩니다.

실행 상태의 쓰레드는 run() 메소드를 모두 실행하기 전에

쓰레드 스케줄링에 의해 다시 실행 대기 상태로 돌아갈 수 있습니다.

 

그리고 실행 대기 상태에 있는 다른 쓰레드가 선택되어 실행 상태가 됩니다.

 

종료 상태

run() 메소드가 종료되어 더 이상 실행할 코드가 없어서 쓰레드의 실행을 멈춥니다.

 

쓰레드는 실행 대기 상태와 실행 상태를 번갈아가면서 자신의 run() 메소드를 조금씩 실행합니다.

실행 상태에서 run() 메소드가 종료되면, 더 이상 실행할 코드가 없기 때문에

쓰레드의 실행이 멈추는 종료 상태가 됩니다.

 

일시 정지 상태

쓰레드가 실행할 수 없는 상태로 WAITING, TIMED_WAITING, BLOCKED의 3가지 상태가 존재합니다.

 

경우에 따라서 쓰레드는 실행 상태에서 실행 대기 상태로 가지 않고,

일시 정지 상태로 가기도 합니다.

 

상태 열거 상수 설명
객체 생성 NEW 쓰레드 객체가 생성
아직 start() 메소드가 호출되지 않은 상태
실행 대기 RUNNABLE 실행 상태로 언제든지 갈 수 있는 상태
일시 정지 WAITING 다른 쓰레드가 통지할 때까지 기다리는 상태
TIMED_WAITING 주어진 시간 동안 기다리는 상태
BLOCKED 사용하고자 하는 객체의 락이 풀릴 때까지 기다리는 상태
종료 TERMINATED 실행을 마친 상태

 

getState() 메소드를 통해서 위의 표처럼 쓰레드의 상태에 따라 Thread.State 열거 상수를 반환받을 수 있습니다.

 

WAITING과 TIMED_WAITING은 wait(), join(), sleep() 메소드 등을 통해서 쓰레드가 대기하고 있는 상태입니다.

둘의 차이점은 최대 대기 시간을 지정할 수 있냐 없냐입니다.

 

TIMED_WAITING은 메소드의 인수로 최대 대기 시간을 명시할 수 있어 외부적인 변화뿐만 아니라

시간에 의해서도 WAITING 상태가 해제될 수 있습니다.

 

BLOCKED는 Monitor를 획득하기 위해 다른 쓰레드가 락을 해제하기를 기다리는 상태입니다.

Monitor란 쓰레드 동기화와 관련된 개념으로서 한 스레드가 동기화 코드 영역에 들어갔을 때

해당 쓰레드가 작업을 완료할 때까지 다른 쓰레드는 작동을 멈추게 됩니다.

이런 상태를 BLOCKED라고 합니다.

 

쓰레드의 우선순위

동시성과 병렬성

멀티 쓰레드는 동시성(Concurrency) 또는 병렬성(Parallelism)으로 실행됩니다.

 

동시성이란 멀티 작업을 위해 1개의 코어로 쓰레드마다 돌아가면서 조금씩 실행하지만,

너무 빨라 사람의 눈으로 보기에는 독립적으로 작업이 이루어지는 것처럼 보입니다.

 

병렬성이란 쓰레드마다 각각의 독립적인 Core가 할당되어 작업이 이루어지는 것을 말합니다.

코어의 수보다 쓰레드의 수가 작으면 각각의 코어로 병렬성이 보장되지만

쓰레드의 개수가 코어보다 많은 경우, 쓰레드를 어떤 순서로 동시에 실행할 것인가를 결정해야 합니다.

 

이것을 쓰레드 스케줄링이라고 합니다.

이런 스케줄링 방식은 우선순위(Priority) 방식과 순환 할당(Round-Robin) 방식이 있습니다.

 

우선순위(Priority) 방식

우선순위는 쓰레드를 만드는 동안 JVM에 의해 제공되거나, 프로그래머가 명시적으로 지정할 수 있습니다.

 

우선순위는 1에서 10 사이의 값만 허용합니다.

Thread 클래스에는 3개의 static 변수가 우선순위로 정의되어 있습니다.

 

  1. MIN_PRIORITY : 값은 1로, 쓰레드가 가질 수 있는 우선순위의 최소값입니다. 
  2. NORM_PRIORITY : 우선순위를 명시적으로 지정하지 않으면 기본적으로 할당되는 우선순위입니다. 값은 5입니다.
  3. MAX_PRIORITY : 쓰레드가 가질 수 있는 최대치의 우선순위입니다. 값은 10입니다.

쓰레드의 우선순위를 가져오거나 설정하는 방법은 다음과 같습니다.

 

  • public final int getPriority() : java.lang.Thread.getPriority() 메서드는 해당 쓰레드의 우선 순위를 반환합니다.
  • public final void setPriority(int newPriority) : java.lang.Tread.setPriority() 메서드는 새로운 우선순위 값으로 쓰레드의 우선순위를 설정합니다. 이 메서드는 새로운 우선순위 값이 1 ~ 10 사이의 값이 아니면, IllegalArgumentException을 던질 수 있습니다.

순환 할당(Round-Robin) 방식

제한시간을 정해서 하나의 쓰레드를 정해진 시간만큼만 실행하는 방법입니다.

해당 방식은 JVM 안에서 이루어지기 때문에 개발자가 제어할 수 없습니다.

 

아래는 우선순위 방식의 예제 코드입니다.

 

public class PriorityThread extends Thread {
    public static void main(String[] args) {
        PriorityThread t1 = new PriorityThread();
        PriorityThread t2 = new PriorityThread();
        PriorityThread t3 = new PriorityThread();

        t1.setName("1번 스레드");
        t2.setName("2번 스레드");
        t3.setName("3번 스레드");

        printPriority(t1, t2, t3);

        t1.setPriority(2);
        t2.setPriority(5);
        t3.setPriority(8);

        printPriority(t1, t2, t3);

        System.out.println("현재 실행중인 스레드: " + Thread.currentThread().getName());
        System.out.println("Main 스레드의 우선순위: " + Thread.currentThread().getPriority());

        Thread.currentThread().setPriority(Thread.MAX_PRIORITY);
        System.out.println("Main 스레드의 우선순위: " + Thread.currentThread().getPriority());
        startAll(t1, t2, t3);
    }

    private static void printPriority(PriorityThread... threads) {
        for (PriorityThread thread : threads) {
            System.out.println("[" + thread.getName() + "] 우선순위: " + thread.getPriority());
        }
    }

    private static void startAll(PriorityThread... threads) {
        for (PriorityThread thread : threads) {
            thread.start();
        }
    }

    @Override
    public void run() {
        System.out.println("[" + Thread.currentThread().getName() + "] run 메서드 내부");
        for (int i = 0; i < 5; i++) {
            try {
                Thread.sleep(1000);
                System.out.println("[" + Thread.currentThread().getName() + "] " + (i + 1) + "초");
            } catch (InterruptedException e) {
                return;
            }
        }
    }
}
// 실행결과
[1번 스레드] 우선순위: 5
[2번 스레드] 우선순위: 5
[3번 스레드] 우선순위: 5
[1번 스레드] 우선순위: 2
[2번 스레드] 우선순위: 5
[3번 스레드] 우선순위: 8
현재 실행중인 스레드: main
Main 스레드의 우선순위: 5
Main 스레드의 우선순위: 10
[2번 스레드] run 메서드 내부
[3번 스레드] run 메서드 내부
[1번 스레드] run 메서드 내부
[1번 스레드] 1초
[3번 스레드] 1초
[2번 스레드] 1초
[2번 스레드] 2초
[1번 스레드] 2초
[3번 스레드] 2초
[3번 스레드] 3초
[1번 스레드] 3초
[2번 스레드] 3초
[3번 스레드] 4초
[2번 스레드] 4초
[1번 스레드] 4초
[2번 스레드] 5초
[1번 스레드] 5초
[3번 스레드] 5초

 

Main 쓰레드

Java 프로그램이 시작되면 하나의 쓰레드가 즉시 실행됩니다.

이것은 프로그램이 시작될 때 실행되는 쓰레드이기 때문에 일반적으로 프로그램의 Main 쓰레드라고 합니다.

Main 쓰레드는 추가적인 쓰레드를 만들 수 있습니다.

 

메인 쓰레드

 

Main 쓰레드는 프로그램이 시작될 때 자동으로 생성됩니다.

이것을 제어하기 위해선 Main 쓰레드에 대한 참조를 얻어야 합니다. Thread 클래스에 있는 currentThread() 메소드를 호출해서 참조를 얻을 수 있습니다.

 

쓰레드의 기본 우선 순위는 5이며, 나머지 모든 사용자 쓰레드의 우선 순위는 부모의 것을 상속합니다.

 

public class Example extends Thread {
    public static void main(String[] args) {
        // Main 쓰레드 reference 얻기
        Thread t = Thread.currentThread();
          
        // Main 쓰레드의 이름 얻기
        System.out.println("Current thread: " + t.getName());
          
        // Main 쓰레드의 이름 변경
        t.setName("Geeks");
        System.out.println("After name change: " + t.getName());
          
        // Main 쓰레드의 현재 우선순위 값
        System.out.println("Main thread priority: "+ t.getPriority());
          
        // Main 쓰레드의 우선순위값 변경
        t.setPriority(MAX_PRIORITY);
          
        System.out.println("Main thread new priority: "+ t.getPriority());
          
          
        for (int i = 0; i < 5; i++) {
            System.out.println("Main thread");
        }
          
        // Main 쓰레드의 child 쓰레드 생성
        ChildThread ct = new ChildThread();
          
        // child 쓰레드의 현재 우선순위 값
        // Main 쓰레드의 우선순위를 상속한 것을 알 수 있음
        System.out.println("Child thread priority: "+ ct.getPriority());
          
        // child 쓰레드의 우선순위 값 변경
        ct.setPriority(MIN_PRIORITY);
          
        System.out.println("Child thread new priority: "+ ct.getPriority());
          
        // child 쓰레드 실행
        ct.start();
    }
}
  
// child 쓰레드 클래스
class ChildThread extends Thread {
    @Override
    public void run()  {
        for (int i = 0; i < 5; i++) {
            System.out.println("Child thread");
        }
    }
}
// 실행결과
Current thread: main
After name change: Geeks
Main thread priority: 5
Main thread new priority: 10
Main thread
Main thread
Main thread
Main thread
Main thread
Child thread priority: 10
Child thread new priority: 1
Child thread
Child thread
Child thread
Child thread
Child thread

 

싱글 쓰레드 같은 경우 메인 쓰레드가 종료되면 프로세스도 종료되지만,

멀티 쓰레드는 메인 쓰레드가 종료되더라도 실행 중인 쓰레드가 하나라도 있다면 프로세스는 종료되지 않습니다.

 

아래의 코드는 싱글 쓰레드 환경이지만, join() 메소드 때문에 종료되지 않는 상태가 됩니다.

 

public class DeadlockMainThread {

  public static void main(String[] args) {
    try {
      System.out.println("Deadlock 만들기");

      Thread.currentThread().join();

      System.out.println("이 문장은 절대 실행되지 않을겁니다.");
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

// 실행결과
Deadlock 만들기

 

"Deadlock 만들기"를 출력한 이후엔 종료되지 않습니다.

 

이유는 Thread.currentThread().join()은 Main 쓰레드가 자기 자신이 종료되기 전까지 기다립니다.

따라서 위와 같은 상태가 발생하고, 이를 교착상태(Deadlock)라고 합니다.

동기화

멀티  쓰레드 환경에서 쓰레드들이 서로 커뮤니케이션을 할 때, 쓰레드들이 객체를 공유하며 작업하는 경우가 생깁니다.

이러한 형태의 통신은 작업 속도가 빨라지는 매우 효율적인 방식입니다.

그러나 공유하는 객체가 무분별하게 수정되어 서로의 작업에 영향을 미치는 경우도 발생합니다.

 

흔히 작성하는 클래스를 예제로 살펴보겠습니다.

 

class Counter {
    private int c = 0;

    public void increment() {
        c++;
    }

    public void decrement() {
        c--;
    }

    public int value() {
        return c;
    }
}

 

Counter는 겉보기에 아무 문제가 없는 클래스이지만 멀티 쓰레드 환경에서 보면 그렇지 않습니다.

 

Thread_A와 Thread_B가 있다고 가정하고 다음의 단계를 거쳐보겠습니다.

이때 c의 초기값은 0으로 가정합니다.

 

  1. Thread_A : c 조회
  2. Thread_B : c 조회
  3. Thread_A : 조회된 값을 1 증가시킴
  4. Thread_B : 조회된 값을 1 감소시킴
  5. Thread_A : 증가된 값을 c에 저장 (예상 값 : 1)
  6. Thread_B : 감소된 값을 c에 저장 (예상 값 : -1)

위의 단계를 거치면 5번과 6번에서 명시했듯이 Thread_A와 Thread_B는 각각 1과 -1의 값을 반환하게 됩니다.

그러나 실행결과는 값 0을 반환합니다.

 

동일한 변수에 대해 데이터를 수정했기 때문입니다. (전역 변수를 떠올리면 이해하기 쉬울 수도 있습니다.)

 

위와 같은 문제를 사전에 방지하기 위해서 동기화를 이용합니다.

 

동기화는 특정 시점에 하나의 쓰레드만 객체(리소스)에 접근할 수 있도록 제한시키는 것을 말합니다.

 

A 쓰레드가 리소스에 접근하면(access / lock) B, C, D, ... 등의 다른 모든 쓰레드는 리소스에 접근하지 못합니다.

A 쓰레드가 작업을 끝내고 자신의 작업이 끝났음을 모두에게 알려야(notify) B, C, D 등이 리소스에 접근할 수 있습니다.

 

멀티 스레드 프로그램에서 단 하나의 스레드만 실행할 수 있는 코드 영역을 임계 영역(critical section)이라고 한다.

 

동기화 방법에는 동기화 메소드와 동기화 블록 방법이 있습니다.

 

public synchronized void method(){ 
    // 임계 영역; // (하나의 스레드만 실행) 
} 

// 메소드안에서 일부 내용만 임계 영역으로 만들고 싶다면 동기화(synchronized) 블록을 생성
public void method() { 
    // 여러 스레드 실행 가능 영역 
    synchronized(공유객체){ // 공유객체가 자기자신이면 this 삽입 
        //임계 영역; 
    } 
    
    // 여러 스레드 실행 가능 영역 
}

 

첫 번째가 동기화 메소드이고, 다음이 동기화 블록입니다.

 

다음은 동기화된 멀티 쓰레드의 예입니다.

 

import java.io.*;
import java.util.*;
 
// 메세지를 보내는 데 사용하는 클래스
class Sender {
    public void send(String msg) {
        System.out.println("Sending\t"  + msg );
        try {
            Thread.sleep(1000);
        } catch (Exception e) {
            System.out.println("Thread  interrupted.");
        }
        System.out.println("\n" + msg + "Sent");    }
}
 
// 쓰레드를 이용해서 메세지를 보내는 클래스
class ThreadedSend extends Thread {
    private String msg;
    Sender  sender;
 
    // 메세지 객체와 문자열을 수신
    // 보낼 메시지
    ThreadedSend(String m,  Sender obj) {
        msg = m;
        sender = obj;
    }
 
    public void run() {
        // 오직 하나의 쓰레드만 메시지를 보낼 수 있음
        synchronized(sender) {
            // snd 객체 동기화
            sender.send(msg);
        }
    }
}

// 예제 코드
class SyncDemo {
    public static void main(String args[]) {
        Sender snd = new Sender();
        ThreadedSend S1 = new ThreadedSend( " Hi " , snd );
        ThreadedSend S2 = new ThreadedSend( " Bye " , snd );
 
        // ThreadedSend 타입으로 2개의 쓰레드 시작
        S1.start();
        S2.start();
 
        // 쓰레드가 끝나길 기다림
        try {
            S1.join();
            S2.join();
        } catch(Exception e) {
            System.out.println("Interrupted");
        }
    }
}

 

// 실행결과
Sending     Hi 
Hi Sent

Sending     Bye 
Bye Sent

 

실행결과는 우리가 프로그램을 실행할 때마다 동일합니다.

 

위의 예제에서 ThreadedSend 클래스의 run() 메소드 내에서 Sender 객체를 동기화하도록 코딩을 했습니다.

 

또는 Sender 클래스의 send() 메소드를 동기화 블록으로 정의하는 것도 하나의 방법입니다.

그러면 ThreadedSend 클래스의 run() 메소드 내에서 Message 객체를 동기화할 필요가 없습니다.

 

아래는 Sender.send() 메소드를 동기화 메소드로 코딩한 것이고

 

class Sender {
    public synchronized void send(String msg) {
        System.out.println("Sending\t" + msg );
        try {
            Thread.sleep(1000);
        } catch (Exception e){
            System.out.println("Thread interrupted.");
        }
        System.out.println("\n" + msg + "Sent");
    }
}

 

아래는 해당 메소드를 동기화 블록으로 코딩한 것입니다.

 

class Sender {
    public void send(String msg) {
        synchronized(this) {
            System.out.println("Sending\t" + msg );
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                System.out.println("Thread interrupted.");
            }
            System.out.println("\n" + msg + "Sent");
        }
    }
}

 

두 예제 모두 결과값은 동일합니다.

 

데드락(Deadlock)

데드락은 우리말로 교착상태라고 합니다.

두 개 이상의 작업이 서로 상대방의 작업이 끝나길 기다리고 있기 때문에

결과적으로 아무것도 완료되지 못하는 상태를 가리킵니다.

 

예를 들어, 하나의 외나무다리가 있고 두 명의 사람이 양끝에 서있습니다.

이때 두 사람이 동시에 외나무다리를 건너게 된다면 중간 지점에서 만나게 될 것이고

서로 비켜주기를 기다리는 상황이 발생합니다.

결과적으로 아무도 외나무 다리를 건너지 못하게 됩니다.

 

데드락 발생 조건

  • 상호 배제 (Mutual Exclusion) : 한 자원에 대해 여러 쓰레드 동시 접근 불가
  • 점유와 대기 (Hold and Wait) : 자원을 가지고 있는 상태에서 다른 쓰레드가 사용하고 있는 자원을 반납하길 기다림
  • 비선점 (Non Preemptive) : 다른 쓰레드의 자원을 실행 중간에 강제로 가져올 수 없음
  • 환형 대기 (Circle Wait) : 각 쓰레드가 순환적으로 다음 쓰레드가 요구하는 자원을 가지고 있는 것

위의 4가지 조건을 모두 충족할 경우 데드락이 발생하게 됩니다.

다르게 말하면, 위 4가지 중 단 하나라도 충족하지 않을 경우 데드락을 해결할 수 있습니다.

 

public class Main {
    public static Object object1 = new Object();
    public static Object object2 = new Object();

    public static void main(String[] args) {
        FirstThread thread1 = new FirstThread();
        SecondThread thread2 = new SecondThread();

        thread1.start();
        thread2.start();
    }

    private static class FirstThread extends Thread{
        @Override
        public void run() {
            synchronized (object1){
                System.out.println("First Thread has object1's lock");

                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("First Thread want to have object2's lock. so wait");

                synchronized (object2){
                    System.out.println("First Thread has object2's lock too");
                }
            }
        }
    }

    private static class SecondThread extends Thread{
        @Override
        public void run() {
            synchronized (object2){
                System.out.println("Second Thread has object2's lock");

                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Second Thread want to have object1's lock, so wait");

                synchronized (object1){
                    System.out.println("Second Thread has object1's lock too");
                }
            }
        }
    }
}

 

object1과 object2 객체에 대해서 동시에 쓰레드가 사용할 수 없도록 하였습니다. ( 상호 배제 )

 

FirstThread에서는 object1의 lock을 가지고 있으면서 object2에 대한 lock을 원하고,

SecondThread는 object2에 대한 lock을 가지고 있으면서 object1의 lock을 얻길 원합니다. ( 점유와 대기 )

 

쓰레드의 우선순위의 기본값은 NORM_PRIORITY로 동일하게 설정되어 있습니다. ( 비선점 )

 

FirstThread는 SecondThread의 object2 객체의 lock을 대기하고 SecondThread는 FirstThread의 object1 객체의 lock을 대기하고 있습니다. ( 환형 대기 )

 

// 실행결과
First Thread has object1's lock
Second Thread has object1's lock
First Thread want to have object2's lock. so wait
Second Thread want to have object2's lock. so wait

 

위의 실행결과처럼 데드락이 발생하여 아무런 실행도 하지 못한 채 무한정 대기하고 있습니다.

 

참고

https://docs.oracle.com/javase/tutorial/essential/concurrency/threads.html

 

Thread Objects (The Java™ Tutorials > Essential Classes > Concurrency)

The Java Tutorials have been written for JDK 8. Examples and practices described in this page don't take advantage of improvements introduced in later releases and might use technology no longer available. See Java Language Changes for a summary of updated

docs.oracle.com

https://www.geeksforgeeks.org/multithreading-in-java/?ref=lbp 

 

Multithreading in Java - GeeksforGeeks

A Computer Science portal for geeks. It contains well written, well thought and well explained computer science and programming articles, quizzes and practice/competitive programming/company interview Questions.

www.geeksforgeeks.org