본문 바로가기
난 정말 JAVA를 공부한적이 없다구요

23. 쓰레드(Thread)와 동기화

by moca7 2024. 7. 3.

 

ㅁ 쓰레드의 이해와 생성

- 쓰레드 관련 프로그래밍 자체는 그리 어렵지 않다. 

하지만 쓰레드에 대한 이해 없이는 프로그래밍이 불가능하다. 

 

 

ㅁ 쓰레드의 이해와 Thread 클래스의 상속

- 프로그램의 실행주체는 누구인가? 

프로그램의 실행요청은 컴퓨터 사용자에 의해 이뤄지지만, 실질적인 프로그램의 실행은 운영체제에 의해 이뤄진다.

 

앞서 19장에서 프로그램의 실행이 요청되면, 다음의 형태로 메모리 공간이 할당되고, 이 메모리를 기반으로 프로그램이 실행됨을 설명하였다.

 

메소드 영역   |      스택 영역         |    힙 영역

 

이렇듯 할당된 메모리 공간을 기반으로 실행 중에 있는 프로그램을 가리켜 프로세스라 한다.

따라서 프로세스를 간단히 '실행중인 프로그램'으로 설명하기도 한다. 

- 그런데 지금까지 봐온 프로세스들은 프로그램의 흐름을 하나만 형성하고 있었다.

main 메소드의 호출을 통해서 하나의 흐름이 형성되었으며, main 메소드의 실행이 완료되면 흐름도 종료되었다.

- 그러나 하나의 프로세스 내에서 둘 이상의 프로그램 흐름을 형성할 수도 있다.

쓰레드는 프로세스 내에서 프로그램의 흐름을 형성하는 주체인데, 하나의 프로세스 내에 둘 이상의 쓰레드가 존재할 수 있다.

 

- ThreadUnderstand.java

class ShowThread extends Thread {

 

    String threadName;

 

    public ShowThread(String name) { threadName = name; }

 

    public void run() {

         for(int i=0; i<100; i++) {

              System.out.println("안녕하세요. " + threadName +"입니다.");

              try{ sleep(100); }

              catch(InterruptedException e) { e.printStackTrace(); }

         }

     }

}

 

class ThreadUnderstand {

    public static void main(String[] args) {

  

        ShowThread st1 = new ShowThread("멋진 쓰레드");

        ShowThread st2 = new ShowThread("예쁜 쓰레드");

        st1.start();

        st2.start();

    }

}

[실행 결과]

안녕하세요. 멋진 쓰레드입니다.

안녕하세요. 예쁜 쓰레드입니다.

안녕하세요. 예쁜 쓰레드입니다.

안녕하세요. 멋진 쓰레드입니다.

안녕하세요. 예쁜 쓰레드입니다.

안녕하세요. 멋진 쓰레드입니다.

안녕하세요. 예쁜 쓰레드입니다.

안녕하세요. 멋진 쓰레드입니다.

안녕하세요. 예쁜 쓰레드입니다.

 

- 교차로 나오지 않고 연속해서 나오는 이유?

 

쓰레드(Thread)는 병렬적으로 실행되는 프로그램의 흐름입니다. 여러 개의 쓰레드가 동시에 실행될 때, 각 쓰레드는 CPU 시간을 할당받아 실행됩니다. 그렇기 때문에 여러 쓰레드가 번갈아가면서 실행되는 현상이 일어납니다.

여러 쓰레드가 번갈아가면서 실행되기 때문에, 쓰레드의 실행 순서나 시점은 정확히 예측하기 어렵습니다. 따라서 여러 쓰레드가 번갈아가면서 자원을 사용하고 출력을 하게 되면, 출력 순서가 예상과 다를 수 있습니다. 이는 동기화나 스레드 스케줄링 등 여러 요소에 의해 결정됩니다.

여기서 보여주신 예제에서도 "멋진 쓰레드"와 "예쁜 쓰레드"가 번갈아가면서 실행되고 있습니다. 때때로 같은 쓰레드가 연속해서 실행되는 현상도 있을 수 있습니다. 이는 쓰레드 스케줄링에 따라 달라질 수 있는데, 운영체제나 JVM(Java Virtual Machine)의 스케줄러가 어떤 쓰레드에 CPU 시간을 할당할지 결정하기 때문입니다.

결론적으로, 쓰레드는 동시성 프로그래밍을 가능하게 하지만, 실행 순서나 시점에 대해 정확한 예측을 하기 어렵다는 점을 유의해야 합니다.

 

- Thread라는 클래스를 상속하고 있다. 자바에서는 쓰레드도 인스턴스로 표현한다. 

때문에 쓰레드의 표현을 위한 클래스가 정의되어야 하며, 이를 위해서는 Thread라는 이름의 클래스를 상속해야 한다. 

- 쓰레드는 별도의 프로그램 흐름을 구성한다. 

즉 쓰레드는 쓰레드만의 main 메소드를 지닌다(단 이름은 main이 아니고 run이다).

참고로 run()메소드는 Thread 클래스의 run 메소드를 오버라이딩한 것이다.

- sleep은 Thread 클래스의 static 메소드이다. 실행흐름을 일시적으로 멈추는 역할을 한다.

sleep(100)이면 0.1초간 멈춘다.

- Thread 클래스를 상속하는 ShowThread의 인스턴스를 두 개 생성하고 있다.

이로써 두 개의 쓰레드가 생성된 셈이다. 

 

 

ㅁ 쓰레드의 이름 지정하기

- 위의 예제를 보면 쓰레드의 이름을 저장하기 위한 참조변수 threadName을 별도로 선언하고 있다.

- 그러나 Thread 클래스에는 이름의 지정을 위한 생성자가 다음과 같이 정의되어 있다.

public Thread(String name)

 

- 키워드 super를 이용해서 상위 클래스의 생성자를 호출할 수 있음을 알고 있을 것이다. 

따라서 이를 이용한 쓰레드의 이름 지정이 가능하다. 

이렇게 지정된 이름은 Thread 클래스의 getName 메소드를 통해서 언제든지 문자열의 형태로 참조할 수 있다.

-

class ShowThread extends Thread {

 

    public ShowThread(String name) { super(name); }

 

    public void run() {

        for(int i=0; i<100; i++) {

                System.out.println("안녕하세요. " + super.getName() +"입니다.");

                try{ sleep(100); }

                catch(InterruptedException e) { e.printStackTrace(); }

         }

    }

}

 

class ThreadUnderstand {

      public static void main(String[] args) {

 

           ShowThread st1 = new ShowThread("멋진 쓰레드");

           ShowThread st2 = new ShowThread("예쁜 쓰레드");

           st1.start();

           st2.start();

      }

}

 

 

ㅁ 쓰레드 생성 첫 예제의 의문점들

 

(1) 쓰레드 인스턴스를 생성하고 나서 start 메소드를 호출하면 run 메소드가 실행되는데, run 메소드를 직접 호출하면 안되나요?

- run 메소드를 직접 호출하는 것도 불가능한 일은 아니다. 

단 이런 경우 단순히 메소드가 호출되는 것 뿐이고 쓰레드가 생성되지는 않는다.

- 쓰레드는 자신만의 메모리 공간을 할당 받아서 별도의 실행흐름을 형성한다.

따라서 자바가상머신은 start 메소드의 호출을 요구한다.

- 메모리 공간의 할당 등 쓰레드 실행을 위한 기반을 마련한 다음에 run 메소드를 대신 호출해주기 위해서 말이다.

 

(2) CPU는 하나인데 어떻게 둘 이상의 쓰레드가 동시에 실행 가능한가?

- 모든 쓰레드는 CPU를 공유하기 때문이다.

- CPU를 공유하는 방식에 원칙이 존재하는데 나중에 소개한다.

- 참고로 코어(CPU 내에 존재하는 연산장치)가 여러 개 존재하는 CPU에서는 쓰레드 각각에 코어가 하나씩 할당되어 실행되기도 한다. 

 

(3) 쓰레드가 별도의 실행흐름을 구성하는 것은 알겠는데, 정확히 무엇을 가리켜 쓰레드라 하는가?

- Thread를 상속하는 클래스의 인스턴스를 가리켜 쓰레드라 하기도 하지만 이는 엄밀히 말해서는 잘못된 표현이다.

쓰레드는 자바 가상머신이 생성하는 것이기 때문이다.

- start 메소드가 호출되면 자바 가상머신은 별도의 실행흐름을 형성하기 위한 여러 가지 준비에 들어간다.

그 중 대표적인 것이 메모리 공간의 할당이다. 

그리고 이미 생성된 쓰레드들과 CPU를 나눠 쓰기 위한 각종 정보들이 등록된다.

- 이렇듯 별도의 실행흐름을 형성하기 위해서 자바 가상머신에 의해 만들어지는(준비되는) 모든 리소스와 각종 정보들을 총칭해서 쓰레드라 한다.

 

※ 앞으로의 용어 정리

- Thread를 상속하는 클래스를 가리켜 '쓰레드 클래스'라 표현한다.

- 그리고 이 클래스를 기반으로 생성되는 인스턴스를 가리켜 '쓰레드 인스턴스'라 표현한다.

 

 

ㅁ 쓰레드 생성의 두번째 방법

- 쓰레드 클래스의 정의를 위해서는 Thread 클래스를 상속해야 하는데, 쓰레드 클래스가 상속해야 할 또 다른 클래스가 존재하면 문제가 된다.

- 따라서 자바는 쓰레드를 생성하는 또 다른 방법을 제시하고 있다. 바로 인터페이스 구현을 통한 방법이다.

 

- RunnableThread.java

 

class Sum {

    int num;

    public Sum() { num=0; }

    public void addNum(int n) { num += n; }

    public int getNum() { return num; }

}

 

class AdderThread extends Sum implements Runnable {

 

    int start, end;

 

    public AdderThread(int s, int e) { start=s; end=e; }

    public void run() {

          for(int i=start; i<=end; i++) { addNum(i); }

    }

}

 

class RunnableThread {

    public static void main(String[] args) {

 

        AdderThread at1 = new AdderThread(1, 50);

        AdderThread at2 = new AdderThread(51, 100);

        Thread tr1 = new Thread(at1);

        Thread tr2 = new Thread(at2);

        tr1.start();

        tr2.start();

 

        try { tr1.join();

               tr2.join(); }

        catch(InterruptedException e) {

              e.printStackTrace(); 

        }

 

        System.out.println("1~100까지의 합 : " + (at1.getNum() + at2.getNum()) );

    }

}

[실행 결과]

1~100까지의 합 : 5050

 

 

- Runnable 인터페이스를 구현한 AdderThread 클래스의 인스턴스 두 개를 생성했다.

그러나 곧바로 at1.start()를 할 수는 없다. 

start() 메소드는 Thread 클래스의 메소드이기 때문이다 .

(Runnable 인터페이스는 run 메소드 하나로 이뤄져 있다.)

- Thread 클래스에 Runnable 인터페이스를 구현하는 인스턴스의 참조값을 전달받을 수 있는 생성자가 정의되어 있다.

결국은 Thread 클래스의 인스턴스가 생성되었다.

- 쓰레드 인스턴스를 대상으로 join() 메소드를 호출하고 있다. 

이는 해당 쓰레드가 종료될 때까지 실행을 멈출 때 호출하는 메소드이다.

결과적으로 tr1과 tr2가 참조하는 두 쓰레드가 종료되어야 비로소 46행을 실행하게 된다.

(다른 쓰레드의 실행완료를 기다리는 목적으로 호출하는 join 메소드)

 

ㅁ 문제

- RunnableThread.java를 인터페이스가 아닌 상속의 방식으로 변경하기.

    

class AdderThread extends Thread {

 

     int num=0;

     int start, end;

 

     public AdderThread(int s, int e) { start=s; end=e; }

 

     public void addNum(int n) { num += n; }

     public int getNum() { return num; }

 

     public void run() {

          for(int i=start; i<=end; i++) { addNum(i); }

     }

}

 

class Example {

     public static void main(String[] args) {

 

          AdderThread at1 = new AdderThread(1, 50);

          AdderThread at2 = new AdderThread(51, 100);

          at1.start();

          at2.start();

 

          try { at1.join();

                 at2.join(); }

          catch(InterruptedException e) {

             e.printStackTrace();

          }

 

          System.out.println("1~100까지의 합 : " + (at1.getNum() + at2.getNum()) );

     }

}

 

 

ㅁ 쓰레드의 특성

- 쓰레드가 무엇인지, 프로그램상에서 쓰레드를 생성하는 방법도 알아봤다.

- 그러나 쓰레드의 다양한 특성을 알지 못하면, 지금까지 설명한 내용만 가지고는 결코 멀티 쓰레드 기반의 코드를 작성할 수 없다. 

 

 

ㅁ 쓰레드의 스케줄링(Scheduling)과 쓰레드의 우선순위 컨트롤

- 둘 이상의 쓰레드가 생성될 수 있기 때문에 자바 가상머신(자바 가상머신의 일부로 존재하는 쓰레드 스케줄러)는 쓰레드의 실행을 스케줄링(컨트롤) 해야 한다. 스케줄링에 사용되는 알고리즘의 기본 원칙은 다음과 같다.

 

ㅇ 우선순위가 높은 쓰레드의 실행을 우선한다.

ㅇ 동일한 우선순위의 쓰레드가 둘 이상 존재할 때는 CPU의 할당시간을 분배해서 실행한다.

 

- 자바의 쓰레드에는 우선순위가 할당된다. 이는 자바 가상머신에 의해 우선적으로 실행되어야 하는 순위를 의미한다.

가장 낮은 우선순위는 정수 1, 가장 높은 우선순위는 정수 10으로 표현한다. 

- 그리고 이러한 쓰레드의 우선순위는 프로그램 상에서 변경 및 확인이 가능하다. 

 

- PriorityTestOne.java

 

class MessageSendingThread extends Thread {

    

    String message;

 

    public MessageSendingThread(String str) { message = str; }

 

    public void run() {
        for(int i=0; i<1000000; i++) { System.out.println(message + "(" + getPriority() + ")" ); }

    }

}

 

class PriorityTestOne {

    public static void main(String[] args) {

 

        MessageSendingThread tr1 = new MessageSendingThread("First");

        MessageSendingThread tr2 = new MessageSendingThread("Second");

        MessageSendingThread tr3 = new MessageSendingThread("Third");

        tr1.start();

        tr2.start();

        tr3.start();

    }

}

[실행 결과]

First(5)

First(5)

Second(5)

....

Third(5)

First(5)

...

Third(5)

 

- 실행할때마다 결과가 다름. 뒤죽박죽. 

- 실행 결과를 통해서 처음 쓰레드가 생성되었을 때의 우선순위가 어떻게 되는지 확인해 보았다.

그리고 우선순위가 동일한 쓰레드들이 CPU의 할당시간을 나눠가면서 실행됨도 확인하였다.

- 쓰레드 별로 할당되는 시간이나 순서가 여러분의 기대와 다르다는 것을 확인했다.

- 쓰레드의 실행 방식은 시스템의 상황과 환경에 따라서 매우 많은 차이를 보인다.

즉, "동일한 우선순위의 쓰레드들은 CPU의 할당시간을 적절히 나눠서 실행된다"라고만 이야기할 수 있을 뿐, 

엄밀하게 수치적으로 할당시간과 할당순서를 이야기할 수는 없다.

 

 

- 이번에는 우선순위를 달리해서 쓰레드를 생성해본다.

 

PriorityTestTwo.java

 

class MessageSendingThread extends Thread {

    

    String message;

 

    public MessageSendingThread(String str, int prio) { message = str; setPriority(prio); }

 

    public void run() {
        for(int i=0; i<1000000; i++) { System.out.println(message + "(" + getPriority() + ")" ); }

    }

}

 

class PriorityTestOne {

    public static void main(String[] args) {

 

        MessageSendingThread tr1 = new MessageSendingThread("First", Thread.MAX_PRIORITY);

        MessageSendingThread tr2 = new MessageSendingThread("Second" , Thread.NORM_PRIORITY );

        MessageSendingThread tr3 = new MessageSendingThread("Third" , Thread.MIN_PRIORITY );

        tr1.start();

        tr2.start();

        tr3.start();

    }

}

- 책에서의 실행 결과는 First(10) 다 하고 Second(5) 다 하고 Third(1)가 나옴. 그렇게 나오기도 하는데, 

여러번 해보면 실행결과 뒤죽박죽. 

(책: 우선순위가 가장 높은 쓰레드가 종료되어야, 그 다음 우선순위의 쓰레드가 실행된다고 써져있음)

- (gpt)  tr1 스레드가 가장 높은 우선순위를 가지므로, 다른 스레드들보다 더 많은 CPU 자원을 할당받아 먼저 실행될 가능성이 높습니다.

스레드의 우선순위는 JVM과 운영체제에 따라 다르게 동작할 수 있습니다. 일반적으로는 우선순위가 높은 스레드가 CPU 자원을 더 많이 할당받을 가능성이 있지만, 이는 보장되는 것이 아니며 환경에 따라 달라질 수 있습니다.

 

- setPriority 메소드는 Thread 클래스의 인스턴스 메소드로, 쓰레드의 우선순위를 변경한다.

- MAX_PRIORITY는 static 상수로 그값은 10이다. 

MIN_PRIORITY는 1이고, NORM_PRIORITY는 5다. 

이렇게 static 상수를 이용해도 되고, 그냥 정수를 사용해도 된다.

 

 

ㅁ 쓰레드 우선순위가 지니는 의미

- 자바가 언어차원에서 쓰레드를 지원하고는 있지만, 쓰레드는 그 특성상 운영체제에 상당히 의존적이다.

즉 가상머신이 동작하는 운영체제에 따라서 실행 결과가 다르게 나타날 수 있다.

특히 우선순위에 관련된 부분은 더욱 그렇다.

자바에서 총 10단계의 쓰레드 우선순위를 제공해도 운영체제에서 7단계의 쓰레드 레벨을 지원하면 실질적인 쓰레드 레벨은 7단계가 된다. 때문에 자바의 우선순위 7과 8이 해당 운영체제의 우선순위 6으로 표현될 수도 있는 것이다. 

- 이렇듯 우선순위를 기반으로 쓰레드 프로그래밍을 할 때에는 해당 운영체제에 대한 지식이 필요하다.

- 그리고 이러한 문제때문에라도 쓰레드의 우선순위를 변경할 때에는 상수로 정의되어 있는 MAX_PRIORITY, MIN_PRIORITY, NORM_PRIORITY 중 하나를 선택해서 변경하는 것이 운영체제에 따른 차이를 최소화할 수 있는 방법이다. 

 

 

ㅁ 우선순위가 별 의미 없어 보이는데요?

- 대부분의 시스템에서 우선순위가 높은 쓰레드에게만 실행 기회를 부여하다 보니, 우선순위가 낮은 쓰레드는 거의 실행되지 않는다고 생각할 수 있다. 

- 그러나 프로그램의 실행내용을 잘 보면, CPU의 할당이 필요치 않는 데이터의 입출력에 대한 부분이 매우 높은 비율을 차지함을 알 수 있다. 

- 간단하게는 다음 Chapter에서 소개하는 파일의 입출력에서부터 네트워크를 통한 데이터의 송수신 역시 CPU의 할당이 필요치 않는 데이터의 입출력에 해당된다. 

- 때문에 프로그램의 실질적인 흐름을 담당하는 쓰레드 역시 CPU의 할당이 필요치 않는 데이터의 입출력과 관련 있는 연산을 상당부분 처리한다고 볼 수 있다.

- 그리고 이러한 상황에 놓였을 때(CPU의 할당이 필요치 않는 입출력을 처리하는 상황에 놓였을 때),

쓰레드는 무리하게 CPU를 차지하려고 하지 않는다. 오히려 이러한 상황에서는 자신에게 할당된 CPU를 다른 쓰레드들에게 넘긴다. 

쓰레드의 바로 이러한 특성 때문에 우선순위가 낮은 쓰레드 역시 실행 기회를 얻을 수 있는 것이다.

 

- PriorityTestThree.java

 

class MessageSendingThread extends Thread {

    

    String message;

 

    public MessageSendingThread(String str, int prio) { message = str; setPriority(prio); }

 

    public void run() {
        for(int i=0; i<1000000; i++) {

             System.out.println(message + "(" + getPriority() + ")" ); 

 

             try { sleep(1); }

             catch (InterruptedException e) { e.printStackTrace(); }

        }

    }

}

 

class PriorityTestThree {

    public static void main(String[] args) {

 

        MessageSendingThread tr1 = new MessageSendingThread("First", Thread.MAX_PRIORITY);

        MessageSendingThread tr2 = new MessageSendingThread("Second" , Thread.NORM_PRIORITY );

        MessageSendingThread tr3 = new MessageSendingThread("Third" , Thread.MIN_PRIORITY );

        tr1.start();

        tr2.start();

        tr3.start();

    }

}

- 실행 결과 뒤죽박죽.

- 실행 결과를 보면, 높은 우선순위의 쓰레드가 둘(10, 5)이나 존재함에도 불구하고 먼저 실행되는 가장 낮은 우선순위의 쓰레드(1)를 볼 수 있다. (심지어 책 실행 결과도 1짜리가 제일 먼저 출력됨)

- 비록 우선순위가 낮은 쓰레드라 하더라도 높은 우선순위의 쓰레드가 CPU를 양보해서 실행 기회를 얻게 되면 최소 단위의 실행 시간은 보장받는다. 결론적으로 낮은 우선순위의 쓰레드도 충분히 실행 기회를 얻을 수 있다.

 

- sleep 메소드가 호출되면, 쓰레드는 CPU의 할당이 불필요한 상황이 된다.

따라서 다른 쓰레드에게 할당된 CPU를 양보한다.

즉 입출력을 대신해서, 할당된 CPU를 다른 쓰레드에게 양보하는 상황을 연출한 것이다. 

 

 

ㅁ 쓰레드의 라이프 사이클

- 쓰레드가 처리되는 방식을 전체적으로 정리.

 

쓰레드의 라이프 사이클

 

- 스케줄러는 Runnable 상태의 쓰레드 중 하나를 선택해 실행한다. 

 

- 쓰레드가 생성되면 위 그림이 보여주는 네 가지 중 한 가지 상태에 있게 된다.

 

(1) New 상태

- 쓰레드 클래스가 키워드 new를 통해서 인스턴스화된 상태를 가리켜 'New 상태'라 한다.

- 이 상태에서는 자바 가상머신에 의해 관리가 되는 쓰레드 상태는 아니다.

즉 운영체제 입장에서는 쓰레드라 부르기에는 이르다. 그러나 자바는 이 상태부터 쓰레드라 표현한다.

 

(2) Runnable 상태

- 쓰레드 인스턴스를 대상으로 start 메소드가 호출되면, 해당 쓰레드는 비로소 Runnable 상태가 된다.

- 이는 모든 실행의 준비를 마치고, 스케줄러에 의해 선택되어 실행될 수 있기만을 기다리는 상태이다.

- start 메소드가 호출된다고 해서 바로 run 메소드가 호출되는 것이 아니다.

- Runnable 상태에 있다가, 스케줄러에 의해서 실행의 대상으로 선택이 되어야 비로소 run 메소드가 처음 호출된다.

 

(3) Blocked 상태

- 실행 중인 쓰레드가 sleep, 또는 join 메소드를 호출하거나, CPU의 할당이 필요치 않는 입출력 연산을 하게 되면, CPU를 다른 쓰레드에게 양보하고 본인은 'Blocked 상태'가 된다. 

- Blocked 상태에서는 스케줄러의 선택을 받을 수 없다. 

- 다시 스케줄러의 선택을 받아서 실행되려면 Blocked 상태에 놓이게 된 원인이 제거되어서 Runnable 상태로 돌아와야 한다.

- 입출력 작업으로 인해 Blocked 상태가 되었다면, 입출력 작업이 완료되면서 Runnable 상태가 된다.

sleep 메소드의 호출로 인해서 Blocked 상태가 되었다면, sleep 메소드가 반환되면서 다시 Runnable 상태가 된다. 

 

(4) Dead 상태

- run 메소드의 실행이 완료되어서 run 메소드를 빠져 나오게 되면 해당 쓰레드는 'Dead 상태'가 된다.

- 이 상태에서는 쓰레드의 실행을 위해서 할당 받았던 메모리를 비롯해서 각종 쓰레드 관련 정보가 완전히 사라지는 상태이다.

- 한번 Dead 상태가 된 쓰레드는 다시 Runnable 상태가 되지 못한다. 

쓰레드 실행을 위해 필요한 모든 것이 소멸되기 때문이다.

 

 

ㅁ 쓰레드의 메모리 구성

- 쓰레드가 생성되면 가상머신은 쓰레드 실행을 위한 별도의 메모리 공간을 할당한다.

이러한 별도의 메모리 공간은 정확히 무엇을 의미하는걸까?

- 쓰레드의 가장 큰 역할은 별도의 실행흐름 형성이다.

그리고 별도의 실행흐름은 메소드의 호출을 통해서 형성된다.

- 즉 처음에는 run 메소드가 호출되고, run 메소드 내에서는 또 다른 메소드를 호출하면서 main 메소드와는 다른 흐름을 형성한다.

- 이렇듯 main 메소드와는 전혀 다른 실행흐름을 형성하기 위해서는 별도의 스택이 쓰레드에게 할당되어야 한다.

따라서 main 쓰레드 이외에 두 개의 쓰레드가 추가로 생성되면, 가상머신은 다음의 형태로 메모리를 구성한다.

 

쓰레드에 할당되는 메모리

 

 

- 모든 쓰레드는 자신의 스택을 할당받는다. 

그러나 힙과 메소드 영역은 모든 쓰레드가 공유한다. 특히 힙이 공유됨에 주목하자.

- 힙 영역이 공유된다는 것은 모든 쓰레드가 동일한 힙 영역에 접근이 가능함을 의미하는 것이고, 

A 쓰레드가 만든 인스턴스의 참조값(사실상 주소값)만 알면 B 쓰레드도 A 쓰레드가 만든 인스턴스에 접근이 가능하다.

- 그래서 쓰레드 사이에 데이터를 주고받아야 할 때에는(쓰레드간 통신이 필요한 때에는) 힙 영역을 활용한다.

 

 

- 둘 이상의 스레드가 힙에 할당된 특정 메모리 영역에 함께 접근하는 예제

 

ThreadHeapMultiAccess.java

 

class Sum {

    int num;

    public Sum() { num=0; }

    public void addNum(int n) { num += n; }

    public int getNum() { return num; }

}

 

class AdderThread extends Thread {

 

    Sum sumInst;

    int start, end;

 

    public AdderThread(Sum sum, int s, int e) { sumInst = sum; start=s; end=e; }

    public void run() {

          for(int i=start; i<=end; i++) { sumInst.addNum(i); }

    }

}

 

class ThreadHeapMultiAccess {

    public static void main(String[] args) {

 

        Sum s = new Sum();

        AdderThread at1 = new AdderThread(s, 1, 50);

        AdderThread at2 = new AdderThread(s, 51, 100);

        at1.start();

        at2.start();

 

        try { at1.join();

               at2.join(); }

        catch(InterruptedException e) {

              e.printStackTrace(); 

        }

 

        System.out.println("1~100까지의 합 : " + s.getNum() );

    }

}

[실행 결과]

1~100까지의 합 : 5050

 

- Sum s = new Sum();로 생성한 인스턴스의 참조 값을, 그 다음 문장과 다다음 문장에서 생성하는 쓰레드 인스턴스에 생성자를 통해서 전달하고 있다. 

따라서 두 개의 쓰레드는 s에 접근이 가능하다.

- at1.start();과 at2.start();로 인해서 두 개의 쓰레드가 실행되었다.

이로써 두 쓰레드는 run 메소드를 실행하면서  Sum s = new Sum();로 생성한 인스턴스에 접근한다.

(실제 접근은 sumInst.addNum(i); 문장에서 일어나고 있다.)

 

- 만약 쓰레드 별로 스택과 마찬가지로 독립된 힙이 할당되었다면, 위 예제의 실행결과는 어땠겠는가.

생성한 인스턴스의 참조 값을 두 쓰레드에 전달하는 것은 가능하지만, 

각각의 쓰레드가 이 참조값을 이용해서 인스턴스에 접근할 때 문제가 생긴다.

쓰레드가 자신에게 할당된 힙에서 인스턴스를 찾으려 하기 때문이다.

 

- 끝으로 위 예제는 다소 문제가 있다.

대부분의 경우 문제가 발생하진 않겠지만, 위 예제는 잘못된 실행결과를 보일 수 있는 소지가 다분하다.

어떠한 문제가 있는지, 해결책은 무엇인지 '동기화 Synchronization' 주제를 가지고 알아본다.

 

 

 

ㅁ 동기화 Synchronization

- 방금 전의 예제에서 보았듯이 실제 쓰레드 프로그래밍에서는 하나의 인스턴스에 둘 이상의 쓰레드가 접근하는 형태의 구현이 자주 등장한다. 

- 그런데 이러한 경우에 '동기화' 처리라는 것을 해주지 않으면 문제가 발생한다.

 

 

ㅁ 쓰레드의 메모리 접근방식과 그에 따른 문제점

- 한 쓰레드가 변수 num에 접근해서 연산을 완료할 때까지, 다른 쓰레드가 변수 num에 접근하지 못하도록 막아야 한다.

바로 이것이 동기화(Synchronization)이다.

- 동기화는 중요하다. 쓰레드를 생성하는 방법만 알고, 동기화를 제대로 이해하지 못하면 결코 쓰레드 기반의 프로그래밍을 할 수 없다. 한다 해도 문제가 발생하고, 그 문제의 원인조차 찾지 못하게 된다. 

 

- 예를 들어 변수에 저장된 값을 1씩 증가시키는 연산을 하는 두 개의 쓰레드가 진행된다고 가정한다.

 

      변수 num = 0     

thread1           thread2

 

thread1이 변수 num에 저장된 값을 1로 증가시키고, thread2가 변수 num에 저장된 값을 2로 증가시킨다.

 

- 그런데 여기서 주목할 사실은 값의 증가 방식이다.

값의 증가는 CPU를 통한 연산이 필요한 작업이다.

따라서 그냥 변수 num에 저장된 값이, 변수 num에 저장된 상태로 증가하지 않는다.

 

변수에 저장된 값이 thread1에 의해 참조가 되고, thread1이 이 값을 CPU에 전달해서 1을 증가시켜 증가된 값 1을 얻는다.

마지막으로 연산이 완료된 값을 변수 num에 다시 저장한다. 

이를 thread2도 반복한다.

그런데 이는 매우 이상적인 상황이다.

 

thread1이 변수 num에 저장된 값을 완전히 증가시키 전에도 얼마든지 thread2로 CPU의 실행이 넘어갈 수 있기 때문이다.

 

thread1이 변수 num에 저장된 값을 참조해서 값을 1 증가시키는 것까지 완료했지만, 아직 변수 num에는 증가된 값을 저장하지 않았다.

변수 num에 증가된 값을 저장하려는데, 이 작업이 진행되기 전에 thread2로 실행의 순서가 넘어가 버릴 수 있다.

thread2도 증가연산을 완료해서 증가된 값을 변수 num에 저장했다고 가정하자.

 

그 후에 thread1도 변수 num에 다시 증가된 값을 저장하면, 이미 1로 저장된 num에 1을 다시 저장하는 일이 발생한다.

비록 thread1과 thread2가 각각 1씩 증가시켰지만 결과적으로 엉뚱한 값이 저장될 수 있는 것이다.

 

- 이러한 문제를 막기 위해서, 한 쓰레드가 변수 num에 접근해서 연산을 완료할 때까지, 다른 쓰레드가 변수 num에 접근하지 못하도록 막아야 한다. 이것이 바로 동기화 Synchronization이다. 

 

 

ㅁ Thread-safe

- StringBuffer 클래스는 쓰레드에 안전하다. 

StringBuffer 클래스에 이미 동기화 처리가 되어있어서, 둘 이상의 쓰레드가 동시에 접근해도 문제가 발생하지 않는다.

때문에 이런 클래스를 사용할 때에는 동기화를 적용할 필요가 없다.

- ArrayList<E> 클래스와 HashSet<E> 클래스에는? 동기화가 되어있지 않다.

API 문서를 보면 매우 진한 글씨로 동기화 처리가 되어있지 않음을 명시하고 있다.

 

Note that this implementation is not synchronized

 

 

ㅁ 쓰레드 동기화 기법 1 - synchronized 기반의 동기화 메소드

- 동기화를 하지 않아서 문제가 발생하는 상황을 보여주는 예제

 

ThreadSyncError.java

 

class Increment {

    int num = 0;

    public void increment() { num++; }

    public int getNum() { return num; }

}

 

class IncThread extends Thread {

    Increment inc;

 

    public IncThread(Increment inc) { this.inc = inc; }

    

    public void run() {

           for(int i=0; i<10000; i++)

                  for(int j=0; j<10000; j++)

                         inc.increment();

    }

}

 

class ThreadSyncError {

    public static void main(String[] args) {

 

        Increment inc = new Increment();

        IncThread it1 = new IncThread(inc);

        IncThread it2 = new IncThread(inc);

        IncThread it3 = new IncThread(inc);

 

        it1.start();

        it2.start();

        it3.start();

 

        try { 

            it1.join();

            it2.join();

            it3.join();

         }

         catch(InterruptedException e) { e.printStackTrace(); }

 

         System.out.println(inc.getNum());

    }

}

[실행 결과]

200244076

 

- 실행 결과는 300,000,000이 출력되어야 한다. 

- 스레드 간 동기화 부재의 문제다.

- join() 메서드는 호출한 스레드가 대기하도록 만들어 주는 메서드입니다. 예를 들어, it1.join()을 호출하면 main 스레드는 it1 스레드가 종료될 때까지 대기하게 됩니다.

하지만 join() 메서드는 스레드의 실행이 완료될 때까지 대기하는 기능을 제공할 뿐, 스레드 간의 동기화를 보장하지는 않습니다. 즉, join() 메서드로 인해 다른 스레드의 실행이 멈추지는 않습니다.

 

- 이 문제는 쓰레드에게 많은 일을 시켜서 동기화로 인한 문제가 발생할 확률을 높여 놓았다.

이 정도 예제라면 대부분의 컴퓨터에서 잘못된 실행결과를 확인할 수 있을 것이다.

문제된 부분은 4행의 num++;이다.

 

앞서 설명했듯이 둘 이상의 쓰레드가 동시에 이 문장을 실행하면서(하나의 스레드가 이 문장을 완료하지 않은 상태에서 다른 쓰레드도 이 문장을 실행하면서) 문제가 발생한다. 

 

해결책은 위의 문장이 둘 이상의 쓰레드에 의해서 동시에 실행되지 않게 하면 된다.

키워드 synchronized를 사용해서 '동기화 메소드'를 선언하거나, '동기화 블록'을 지정해주면 된다.

 

- 동기화 메소드의 선언 방법

 

public synchronized void increment () { num++; }

 

이렇게 메소드 선언에 synchronized 선언을 하면, 이는 동기화 메소드가 된다.

그리고 동기화 메소드는 한 순간에 하나의 쓰레드만 호출이 가능하다.

thread1이 이 메소드를 호출하여 실행 중인 때에, thread2가 이 메소드를 호출하면, thread2는 thread1이 이 메소드 실행을 완료할 때까지 대기하고 있다가 완료하면 비로소 이 메소드를 실행한다.

 

- 이 예제를 저렇게 동기화 메소드로 선언하면 제대로 된 값 300,000,000이 출력된다. 

그러나 실행시간이 오래 걸린다. 쓰레드의 동기화로 인해 성능이 매우 많이 저하되었다.

 

 

ㅁ synchronized 기반 동기화 메소드의 정확한 이해 

- 사실 위의 예제에서 말한 내용은 동기화 메소드의 정확한 이해가 아니다.

- 실질적인 동기화의 주체는 인스턴스이기 때문에 동기화되는 영역은 인스턴스 전체로 확장된다. 

 

동기화의 원리

 

- 위 그림에서 보듯이 자바의 모든 인스턴스에는 하나의 열쇠 존재한다. 

전문영어로 이 열쇠를 가리켜 lock 또는 monitor라 하는데, 이를 그냥 열쇠로 이해해도 된다.

- synchronized로 선언된 메소드는 위 그림에서 보이듯이 자물쇠가 걸린다.

따라서 synchronized로 선언된 메소드를 호출하려면 먼저 열쇠를 획득해서 그 열쇠로 자물쇠를 열고 들어가야 한다.

- 그런데 그림에서 보이듯이 열쇠는 하나다.

따라서 인스턴스 내에서 synchronized로 선언된 모든 메소드는 동시에 둘 이상이 실행될 수 없다.

- 우리가 열쇠의 획득과 반납을 코드상에 명시할 필요는 없다.

synchronized로 선언된 메소드를 호출하면 열쇠는 자동으로 획득되고, 메소드를 빠져나오면 획득한 열쇠는 자동으로 반납되기 때문이다. (실수로 열쇠를 반납하지 않는 문제가 발생하지 않는 것이 synchronized 선언의 매력이다)

 

- 동기화 대상이 인스턴스이긴 하지만, synchronized로 선언된 메소드가 호출된다고, 호출된 인스턴스의 다른 쓰레드의 접근이 불가능한 것은 아니다.

synchronized로 선언되지 않은 메소드에는 얼마든지 접근이 가능하다.  

 

 

- ThreadSyncMethod.java

 

class Calculator {

    int opCnt = 0;

 

    public int add(int n1, int n2) {

          opCnt++;           //         동기화가 필요한 문장

          return n1 + n2;

    }

 

    public int min(int n1, int n2) {

          opCnt++;           //         동기화가 필요한 문장

          return n1 - n2;

    }

 

    public void showOpCnt() {

          System.out.println("총 연산 횟수 : " + opCnt);

    }

}

 

class AddThread extends Thread {

     Calculator cal;

 

     public AddThread(Calculator cal) { this.cal = cal; }

 

     public void run() {

         System.out.println("1+2=" + cal.add(1, 2) );

         System.out.println("2+4=" + cal.add(2, 4) );

     }

}

 

class MinThread extends Thread {

     Calculator cal;

 

     public MinThread(Calculator cal) { this.cal = cal; }

 

     public void run() {

         System.out.println("2-1=" + cal.min(2, 1) );

         System.out.println("4+2=" + cal.min(4, 2) );

     }

}

 

class ThreadSyncMethod {

    public static void main(String[] args) {

 

         Calculator cal = new Calculator();

         AddThread at = new AddThread(cal);

         MinThread mt = new MinThread(cal);

 

         at.start();

         mt.start();

 

         try {

             at.join();

             mt.join();

         }

         catch(InterruptedException e) { e.printStackTrace(); }

 

         cal.showOpCnt();

    }

}

[실행 결과]

1+2=3

2-1=1

4+2=2

2+4=6

총 연산 횟수 : 4

 

- Calculator의 add와 min 메소드에서는 Calculator의 변수 opCnt에 접근을 하고 있다.

따라서 이 두 메소드는 각각 다른 쓰레드에 의해 동시에 호출되어서는 안된다.

예를 들어, A 쓰레드가 add 메소드를 실행 중일 때, B 쓰레드에게 add 메소드는 물론이거니와 min 메소드의 호출도 허용해서는 안 된다. (min 메소드의 호출도 허용해서는 안된다는 사실에 주목해야 한다)

- Calculator의 showOpCnt() 메서드도 opCnt에 접근하고 있지만, 이 메소드는 main 메소드의 join 메소드 호출로 인해서 add 또는 min 메소드의 호출과 겹치지 않는다고 가정 하에 동기화의 대상에서 제외시켰다.

 

- 다음과 같이 두 메소드를 모두 동기화 처리하면 된다.

    public synchronized int add(int n1, int n2) {

          opCnt++;           //         동기화가 필요한 문장

          return n1 + n2;

    }

 

    public synchronized  int min(int n1, int n2) {

          opCnt++;           //         동기화가 필요한 문장

          return n1 - n2;

    }

 

이 정도 선언만으로도 add나 min 메소드 끼리만 동시에 호출되지 않는 것이 아니라,

add가 호출될 때 min의 호출도 막고, min이 호출될 때 add의 호출도 막을 수 있다.

 

동기화 메소드에 의해서 메소드가 동기화되는 것은 맞지만, 실질적인 동기화의 주체는 인스턴스이기 때문에 동기화되는 영역은 인스턴스 전체로 확장되기 때문이다.

 

 

 

 

ㅁ 쓰레드 동기화 기법2 - synchronized 선언에 의한 동기화 블록의 구성

- 앞선 예제의 실행을 통해서 동기화 처리로 인한 속도 저하가 얼마나 큰지를 확인하였을 것이다. 

 

    public synchronized int add(int n1, int n2) {

          opCnt++;           //         동기화가 필요한 문장

          return n1 + n2;

    }

 

    public synchronized  int min(int n1, int n2) {

          opCnt++;           //         동기화가 필요한 문장

          return n1 - n2;

    }

 

- 그런데 위 두 메소드 모두, 실제로 동기화가 필요한 문장은 한 줄에 지나지 않는다.

저 메소드들에 코드가 100줄이 있고 동기화가 필요한 문장이 한 줄밖에 되지 않는다고 가정하면 성능감소가 심하다.

(메소드 전체의 실행이 완료될 때까지 열쇠는 반납되지 않는다.)

- 그래서 동기화의 대상을 메소드 전부가 아니라 코드 블록 일부로 제한할 필요가 있다.

이러한 목적으로 존재하는 것이 '동기화 블록'이다. 


    public int add(int n1, int n2) {

 

          synchronized(this) {

              opCnt++;           //         동기화가 필요한 문장

          }

          return n1 + n2;

    }

 

    public int min(int n1, int n2) {

       

          synchronized(this) {

              opCnt++;           //         동기화가 필요한 문장

          }

          return n1 - n2;

    }

 

- 위 코드에서 보이듯이 동기화 블록은 동기화가 필요한 코드를 중괄호로 묶어서 표현한다.

따라서 메소드 단위가 아닌, 코드의 일부를 부분적으로 동기화의 대상에 포함시킬 수 있다.

- 그런데 위의 선언을 보면 중괄호 사이에 this가 삽입되어 있다.

어디에 있는 열쇠를 가져다 동기화 하겠는가에 대한 답이다.

- 동기화 메소드는 열쇠를 선택할 수 없었다. 그러나 동기화 블록은 열쇠를 선택할 수 있다.

앞서 "자바의 모든 인스턴스는 하나의 열쇠를 지니고 있다."라고 설명했다.

따라서 어떠한 인스턴스의 참조 값이든 이 위치에 올 수 있다. 

- 이에 대한 명확한 이해를 위해 간단한 예제를 제시한다.

 

- SyncObjectKey.java

 

class TwoNum {

    int num1 = 0;         //        동기화가 필요한 변수 1

    int num2 = 0;         //        동기화가 필요한 변수 2

 

    public void addOneNum1() { num1 += 1; }

    public void addTwoNum1() { num1 += 2; }


    public void addOneNum2() { num2 += 1; }

    public void addTwoNum2() { num2 += 2; }

 

    public void showAllNums() {

        System.out.println("num1 : " + num1);

        System.out.println("num2 : " + num2);

    }

}

 

class AccessThread extends Thread {

     

    TwoNum twoNumInst;

 

    public AccessThread(TwoNum inst) { twoNumInst = inst; }

 

    public void run() { 

        twoNumInst.addOneNum1();

        twoNumInst.addTwoNum1();

        twoNumInst.addOneNum2();

        twoNumInst.addTwoNum2();

    }

}

 

class SyncObjectKey {

    public static void main(String[] args) {

 

         TwoNum t = new TwoNum();

 

         AccessThread at1 = new AccessThread(t);

         AccessThread at2 = new AccessThread(t);

 

         at1.start();

         at2.start();

 

         try{ 

            at1.join();

            at2.join();

         }

         catch(InterruptedException e) { e.printStackTrace(); }

 

         t.showAllNums();

    }

}

[실행 결과]

num1 : 6

num2 : 6

 

- addOneNum1(), addTwoNum1() 메소드는 변수 num1에 접근을 한다.

addOneNum2(), addTwoNum2() 메소드는 변수 num2에 접근을 한다.

따라서 둘 이상의 쓰레드가 동시에 호출되는 상황이 발생할 수 있다면, 서로 동시에 호출이 불가능하도록 동기화 처리를 해야 한다.

- showAllNums() 메소드도 변수 num1과 num2에 접근하지만, 

 main 메소드의 join 메소드 호출로 인해서 addOneNum1(), addTwoNum1(), addOneNum2(), addTwoNum2() 메소드와 겹치지 않는다는 가정 하에 동기화의 대상에서 제외시켰다.

- AccessThread at1 = new AccessThread(t);

AccessThread at2 = new AccessThread(t);

 

두 개의 쓰레드 인스턴스 생성과정에서  TwoNum t = new TwoNum();에서 생성한 인스턴스의 참조값이 전달되었다.

즉 TwoNum의 인스턴스는 두 개의 쓰레드에 의해 접근이 이뤄지기 때문에, TwoNum의 변수 num1과 num2에 접근하는 문장들은 동기화 처리가 되어야 한다. 

 

-

    public synchronized void addOneNum1() { num1 += 1; }

    public synchronized void addTwoNum1() { num1 += 2; }


    public synchronized void addOneNum2() { num2 += 1; }

    public synchronized void addTwoNum2() { num2 += 2; }

 

위와 같이 코드를 구성하면 문제없이 동기화가 된다. 그러나 동기화가 지나치게 되어있어서 문제이다.

 

동기화가 된 네 개의 메소드는 모두 TwoNum의 인스턴스가 지니는 하나의 열쇠를 대상으로 동기화가 되어있다.

따라서 동기화된 네 개의 메소드는 모두 동시에 호출되는 일이 없다.

 

그러나 thread1이 addOneNum1()을 실행하는 중간에, thread2가 addOneNum2()를 호출하거나,

thread2가 addTwoNum2()를 실행하는 중간에, thread1이 addTwoNum1()을 호출하는 상황은

같이 실행되어도 문제가 되지 않는 상황이다. (각각의 변수에 따로 접근하므로)

 

즉, 동기화가 필요없는(정확히는 동기화 하지 말아야 하는) 상황이다.

 

 

- 위의 문제를 해결하기 위한 예제

 

SyncObjectKeyAnswer.java

 

class TwoNum {

    int num1 = 0;       

    int num2 = 0;        

 

    public void addOneNum1() { 

         synchronized(key1) {

                 num1 += 1;

          }

    }

    public void addTwoNum1() {

         synchronized(key1) {

                 num1 += 2;

          }

    }

    public void addOneNum2() {

         synchronized(key2) {

                 num2 += 1;

          }

    }

    public void addTwoNum2() {

         synchronized(key2) {

                 num2 += 2;

          }

    }

 

    public void showAllNums() {

        System.out.println("num1 : " + num1);

        System.out.println("num2 : " + num2);

    }

 

    Object key1 = new Object();
    Object key2 = new Object();

 

}

 

class AccessThread extends Thread {

     

    TwoNum twoNumInst;

 

    public AccessThread(TwoNum inst) { twoNumInst = inst; }

 

    public void run() { 

        twoNumInst.addOneNum1();

        twoNumInst.addTwoNum1();

        twoNumInst.addOneNum2();

        twoNumInst.addTwoNum2();

    }

}

 

class SyncObjectKey {

    public static void main(String[] args) {

 

         TwoNum t = new TwoNum();

 

         AccessThread at1 = new AccessThread(t);

         AccessThread at2 = new AccessThread(t);

 

         at1.start();

         at2.start();

 

         try{ 

            at1.join();

            at2.join();

         }

         catch(InterruptedException e) { e.printStackTrace(); }

 

         t.showAllNums();

    }

}

[실행 결과]

num1 : 6

num2 : 6

 

- Object key1 = new Object();, Object key2 = new Object();는 동기화의 '열쇠'로 사용하기 위해서 생성되었다.

(모든 인스턴스는 열쇠를 지니고 있다.)

- key1과 key2 두개의 열쇠로 동기화 블록을 구성했기 때문에 앞서 말한 과도한 동기화로 인한 성능 저하가 발생하지 않는다.

 

 

- 참고로 위 예제에서는 두 개의 열쇠를 사용하기 위해서 Object 인스턴스를 두 개 생성했는데, 다음과 같이 하나만 생성해도 된다. 나머지 하나는 TwoNum의 인스턴스 열쇠를 사용하면 되기 때문이다.(this를 사용한다는 얘기다)

 

class TwoNum {

    int num1 = 0;       

    int num2 = 0;        

 

    public void addOneNum1() { 

         synchronized(this) {

                 num1 += 1;

          }

    }

    public void addTwoNum1() {

         synchronized(this) {

                 num1 += 2;

          }

    }

    public void addOneNum2() {

         synchronized(key) {

                 num2 += 1;

          }

    }

    public void addTwoNum2() {

         synchronized(key) {

                 num2 += 2;

          }

    }

 

    ......

 

    Object key = new Object();

 

}

- 보통은 위와 같은 형태로 클래스를 정의한다.

필요한 첫번째 열쇠는 this로부터 얻고, 열쇠가 더 필요할 때 Object 인스턴스를 추가한다.

- 참고로 정말 필요한 부분에, 최소한의 형태로 동기화를 하는 개발자가 정말로 동기화를 잘하는 개발자이다.

과도한 동기화를 통해서 성능에 상관없이 원하는 결과만 보이는 것은 누구나 할 수 있는 동기화이다.

 

 

ㅁ 동기화는 쓰레드의 접근 순서(방식)을 컨트롤한다는 의미이다.

- 앞서 보인 동기화는 순서에 상관없이, 쓰레드의 동시 접근만을 막는 동기화였다.

- 그러나 쓰레드의 실행 순서를 조절(결정)하는 것도 동기화의 범주에 포함된다.

 

- 실행 순서를 조절해야 하는 상황 연출 예제

 

NewsPaperStory.java

 

class NewsPaper {

    String todayNews;

 

    public void setTodayNews(String news) { todayNews = news; }

 

    public String getTodayNews() { return todayNews; }

}

 

class NewsWriter extends Thread {

    NewsPaper paper;

 

    public NewsWriter(NewsPaper paper) { this.paper = paper; }

 

    public void run() { paper.setTodayNews("자바의 열기가 뜨겁습니다."); }

}

 

class NewsReader extends Thread {

    NewsPaper paper;

 

    public NewsReader(NewsPaper paper) { this.paper = paper; }

 

    public void run() { System.out.println("오늘의 뉴스 : " + paper.getTodayNews() ); }

}

 

class NewsPaperStory {

    public static void main(String[] args) {

 

        NewsPaper paper = new NewsPaper();

        NewsReader reader = new NewsReader(paper);

        NewsWriter writer = new NewsWriter(paper);

 

        reader.start();

        writer.start();

 

        try {

           reader.join();

           writer.join();

        }

        catch(InterruptedException e) { e.printStackTrace(); }

    }

}

[실행 결과]

오늘의 뉴스 : null           //    근데 계속 실행하면 정상적으로 출력되기도 함. 

 

- 이 문제의 해결책으로 그저 reader.start();와 writer.start();의 순서를 바꾸는 것은, 실행해보면 실제로 제대로 된 결과를 확인할 수도 있으나 대단히 위험한 해결책이다. ( 이렇게 계속 실행했더니 잘 되기도 하지만 null이 나오기도 함.

- 그 이유는 쓰레드의 실행순서는 소스코드가 나열된 순서와 다를 수 있기 때문이다.

쓰레드의 실행순서는 예측이 불가능하다. 

소스코드의 나열 순서를 가지고 쓰레드의 실행순서를 예측하는 것은 매우 어리석은 짓이다.

따라서 소스코드의 나열순서를 정리하는 방법이 아닌, 보다 확실한 방법으로 쓰레드의 실행순서를 컨트롤해야 한다.

 

 

ㅁ 참고( A 쓰레드가 먼저 생성되었지만 늦게 생성된 B 쓰레드가 먼저 실행되는 상황의 예)

- A 쓰레드와 B 쓰레드는 우선순위가 동일하다. 

이러한 상황에서 A 쓰레드가 먼저 생성되었고, 이어서 B 쓰레드가 생성되었다.

그리고 먼저 생성된 A 쓰레드의 run 메소드가 호출되었는데, 그 순간 우선순위가 높은 C 쓰레드가 등장하여, A 쓰레드는 C 쓰레드에게 실행의 기회를 넘기고 말았다. 

결국 A 쓰레드의 run 메소드는 하나도 실행되지 않은 상태가 되었다. 

- 이어서 C 쓰레드는 종료되고, 이번에는 B 쓰레드의 run 메소드가 호출 실행되었다.

- 결과적으로 먼저 생성된 A 쓰레드보다 나중에 생성된 B 쓰레드가 먼저 실행되었다.

 

 

wait, notify, notifyAll에 의한 실행순서의 동기화

- Object 클래스에 아래의 메소드들이 정의되어 있다.

 

ㅇ public final void wait() throws InterruptedException

ㅇ public final void notify()         //        하나의 쓰레드만 깨운다.

ㅇ public final void notifyAll()         //       모든 쓰레드를 깨운다.

 

-

SyncNewsPaper.java

 

class NewsPaper {

    String todayNews;

    boolean isTodayNews=false;

 

    public void setTodayNews(String news) {

        todayNews = news;

        isTodayNews = true;

 

        synchronized(this) { notifyAll(); }          

    }

 

    public String getTodayNews() {

        

        if( isTodayNews == false ) {

             try{

                 synchronized(this) { wait(); }

             }

             catch(InterruptedException e) { e.printStackTrace(); }

        }

 

        return todayNews; 

    }

}

 

class NewsWriter extends Thread {

    NewsPaper paper;

 

    public NewsWriter(NewsPaper paper) { this.paper = paper; }

 

    public void run() { paper.setTodayNews("자바의 열기가 뜨겁습니다."); }

}

 

class NewsReader extends Thread {

    NewsPaper paper;

 

    public NewsReader(NewsPaper paper) { this.paper = paper; }

 

    public void run() { System.out.println("오늘의 뉴스 : " + paper.getTodayNews() ); }

}

 

class SyncNewsPaper {

    public static void main(String[] args) {

 

        NewsPaper paper = new NewsPaper();

        NewsReader reader1 = new NewsReader(paper);

        NewsReader reader2 = new NewsReader(paper);

        NewsWriter writer = new NewsWriter(paper);

 

        try {

            reader1.start();

            reader2.start();

 

            Thread.sleep(1000);

            writer.start();            

 

            reader1.join();

            reader2.join();

            writer.join();

        }

 

        catch(InterruptedException e) { e.printStackTrace(); }

 

    }

}

[실행 결과]

오늘의 뉴스 : 자바의 열기가 뜨겁습니다.

오늘의 뉴스 : 자바의 열기가 뜨겁습니다.

 

- setTodayNews의 synchronized(this) { notifyAll(); } 문장은 쓰레드가 데이터를 가져다 놓고 혹시라도 잠을 자는 쓰레드를 전부 깨운다.

여기서 중요한 점은 1행에 정의된 NewsPaper 클래스의 인스턴스에 걸쳐잠을 자고 있는 쓰레드를 대상으로 잠을 깨운다는 사실이다. (NewsPaper의 notifyAll 메소드를 호출하고 있다!)

 

- getTodayNews의 synchronized(this) { wait(); } 문장은 쓰레드가 데이터를 가져가는데, isTodayNews가 false라면 아직 데이터가 도착하지 않은 상황이니, wait 메소드 호출을 통해 낮잠에 들어간다.

여기서 중요한 점은 1행에 정의된 NewsPaper 클래스의 인스턴스에 걸쳐서 잠을 자게 된다는 사실이다.

 (NewsPaper의 wait 메소드를 호출하고 있다!)

 

- Thread.sleep(1000);은 동기화를 공부하기 위한 목적으로 데이터를 가져다 놓는 쓰레드의 실행을 늦추고 있다. 

(데이터를 가져가는 쓰레드가 wait 메소드를 호출하게 하기 위해서)

 

 

- 추가로, wait과 notifyAll(notify) 메소드는 동기화 처리를 해서, 한 순간에 하나의 쓰레드만 호출이 가능하도록 해야한다.

두 메소드 wait과 nofityAll(notify)가 서로 다른 두 쓰레드에 의해서 동시에 각각 호출되는 것을 허용하지 않아야 한다.

 

위의 두 메소드는 동시에 호출되면 문제가 생길 수 있는 민감한 성격의 메소드이다. 

따라서 동기화 블록 또는 동기화 메소드를 이용해서 메소드 호출문장을 동기화 처리해야 한다.

- wait과 notifyAll은 동시에 호출이 불가능 할 뿐, 연이은 호출은 가능. ( wait - wait -notifyAll )

 

 

 

 

ㅁ 새로운 동기화 방식

- 자바는 java.util.concurrent 패키지를 통해서 보다 다양한 형태의 동기화 방식을 지원하기 시작했다.

 

ㅁ synchronized 키워드의 대체

- 자바 버전 5.0에서는 동기화 블록, 또는 동기화 메소드를 대신해서 사용할 수 있는 ReentrantLock이라는 이름의 클래스를 제공하기 시작했는데, 이를 적용하기 위한 기본적인 구조는 다음과 같다. 

 

class MyClass {

 

    private final ReentrantLock aaa = new ReentrantLock();

    .......

    void myMethod(int arg) {

        aaa.lock();        //         다른 쓰레드가 진입하지 못하게 문을 잠근다. 

        .......

        .......

        aaa.unlock();        //         다른 쓰레드가 진입이 가능하게 문을 연다.

    }

 

}

 

- ReentrantLock의 인스턴스를 이용해서 lock 메소드와 unlock 메소드를 호출하고 있다.

lock 메소드는 한번 호출되면, unlock 메소드가 호출될 때까지 lock 메소드의 재호출이 불가능하기 때문에,

lock 메소드가 호출되는 시점부터 unlock 메소드가 호출되는 시점까지 둘 이상의 쓰레드에 의해서 동시에 실행되지 않는 영역이 된다.

 

 

- 그런데 만약 lock 메소드를 호출한 쓰레드가 unlock 메소드를 호출하지 않으면, 이는 큰 문제가 될 수 있기 때문에 다음과 같이 코드를 작성하는 것이 보다 안정적이다.

 

class MyClass {

 

    private final ReentrantLock aaa = new ReentrantLock();

    .......

    void myMethod(int arg) {

        aaa.lock();        //         다른 쓰레드가 진입하지 못하게 문을 잠근다. 

        try {

            .......

            .......

        }

        finally {

            aaa.unlock();        //         다른 쓰레드가 진입이 가능하게 문을 연다.

        }

 

    }

}

- 이렇게 unlock 메소드의 호출을 finally 구문에 묶어두면, 어느 상황에서건 unlock 메소드의 호출을 보장받을 수 있다.

- 따라서 둘 이상의 쓰레드가 동시에 실행하면 안 되는 코드를 try 구문에 넣어두고, unlock 메소드의 호출을 finally 구문에 넣어서 코드의 안정성을 높이는 것이 좋다. 

 

 

- SyncObjectKeyAnswer.java를 ReentrantLock 클래스를 사용하여 재구현.

 

UseReentrantLock.java

 

import java.util.concurrent.locks.ReentrantLock;

 

class TwoNum {

    int num1 = 0;       

    int num2 = 0;        

 

    public void addOneNum1() { 

         key1.lock();

         try {

                 num1 += 1;

          }

         finally { key1.unlock(); }

    }

    public void addTwoNum1() {

         key1.lock();

         try {

                 num1 += 2;

          }

         finally { key1.unlock(); }

    }

    public void addOneNum2() {

         key2.lock();

         try {

                 num2 += 1;

          }

         finally { key2.unlock(); }

    }

    public void addTwoNum2() {

         key2.lock();

         try {

                 num2 += 2;

          }

         finally { key2.unlock(); }

    }

 

    public void showAllNums() {

        System.out.println("num1 : " + num1);

        System.out.println("num2 : " + num2);

    }

 

    private final ReentrantLock key1 = new ReentrantLock( );
    private final ReentrantLock key2 = new ReentrantLock( );

 

}

 

class AccessThread extends Thread {

     

    TwoNum twoNumInst;

 

    public AccessThread(TwoNum inst) { twoNumInst = inst; }

 

    public void run() { 

        twoNumInst.addOneNum1();

        twoNumInst.addTwoNum1();

        twoNumInst.addOneNum2();

        twoNumInst.addTwoNum2();

    }

}

 

class SyncObjectKey {

    public static void main(String[] args) {

 

         TwoNum t = new TwoNum();

 

         AccessThread at1 = new AccessThread(t);

         AccessThread at2 = new AccessThread(t);

 

         at1.start();

         at2.start();

 

         try{ 

            at1.join();

            at2.join();

         }

         catch(InterruptedException e) { e.printStackTrace(); }

 

         t.showAllNums();

    }

}

[실행 결과]

num1 : 6

num2 : 6

 

 

ㅁ await, signal, signalAll에 의한 실행순서의 동기화

- ReentrantLock 인스턴스를 대상으로 newCondition이라는 이름의 메소드를 호출하면, Condition형 인스턴스가 반환된다. 

(정확히는 Condition 인터페이스를 구현하는 인스턴스의 참조값이 반환된다)

- 그리고 반환된 인스턴스를 대상으로 다음의 메소드를 호출할 수 있다.

 

ㅇ await 낮잠을 취한다 wait 메소드에 대응

ㅇ signal 낮잠 자는 쓰레드 하나를 깨운다 notify 메소드에 대응

ㅇ signalAll 낮잠 자는 모든 쓰레드를 깨운다 notifyAll 메소드에 대응

 

- 이 메소드들 역시 한번에 하나의 메소드만 호출될 수 있도록 동기화 처리가 되어야 한다.

단 반드시 앞서보인 ReentrantLock 인스턴스 기반으로 동기화 처리가 되어야 한다.(synchronized 기반이 아닌)

 

 

ㅁ Condition 인스턴스 기반의 실행순서 동기화 사례 예제

- 두 개의 쓰레드가 생성되는데, 하나는 프로그램 사용자로부터 문자열을 입력받는 쓰레드이고, 다른 하나는 입력받은 문자열을 출력하는 쓰레드이다. (좀 길다)

 

- ConditionSyncStringReadWrite.java

 

import java.util.concurrent.locks.ReentrantLock;

import java.util.concurrent.locks.Condition;

import java.util.Scanner;

 

class StringComm {

    String newString;

    boolean isNewString = false;

 

    private final ReentrantLock entLock = new ReentrantLock();

    private final Condition readCond = entLock.newCondition();

    private final Condition writeCond = entLock.newCondition();

 

    public void setNewString(String news) {

        

        entLock.lock();

        try {

           if( isNewString == true) 

                writeCond.await();

 

           newString = news;

           isNewString = true;

           readCond.signal();

        }

        catch(InterruptedException e) { e.printStackTrace(); }

        finally { entLock.unlock(); }

    }

 

    public String getNewString() {

 

         String retStr = null;

       

        entLock.lock();

        try {

           if( isNewString == false) 

                readCond.await();

 

           retStr = newString;

           isNewString = false;

           writeCond.signal();

        }

        catch(InterruptedException e) { e.printStackTrace(); }

        finally { entLock.unlock(); }

 

        return retStr;

    }

}

 

- entLock을 대상으로 두 개의 Condition 인스턴스를 생성하고 있다. ( readCond와 writeCond )

이는 특정 조건의 만족 여부에 따라서 실행여부를 결정할 사항이 두 가지라는 뜻이다.

- await과 signal 메소드 호출문은 동기화 처리가 된 상태에서 호출되어야 한다.

 

class StringReader extends Thread {

    StringComm comm;

 

    public StringReader(StringComm comm) { this.comm = comm; }

 

    public void run() {

         Scanner sc = new Scanner(System.in);

         String readStr;

 

         for(int i=0; i<5; i++) {

               readStr = sc.nextLine();

               comm.setNewString(readStr);

         }

    }

}

 

class StringWriter extends Thread { 

    StringComm comm;

 

    public StringWriter(StringComm comm) { this.comm = comm; }

 

    public void run() {

         for(int i=0; i<5; i++) {

             System.out.println("read String : " + comm.getNewString() );

         }

    }

}

 

class ConditionSyncStringReadWrite {

    public static void main(String[] args) {

 

        StringComm strComm = new StringComm();

        StringReader sr = new StringReader(strComm);

        StringWriter sw = new StringWriter(strComm);

 

        System.out.println("입출력 쓰레드의 실행...");

        sr.start();

        sw.start();

    }

}

[실행 결과]

입출력 쓰레드의 실행...

/String one/

read string : String one

/String two/

read string : String two

/String three/

read string : String three

/좋은 저녁입니다./

read string : 좋은 저녁입니다.

/마지막 문자열입니다./

read string : 마지막 문자열입니다.

 

 

- StringReader 클래스는 문자열을 총 5회 입력받아서 가져다 놓는다.

- StringWriter 클래스는 문자열을 총 5회 가져간다.