쓰레드

goose_bumps·2024년 10월 20일

Java

목록 보기
19/21

쓰레드는 프로그램 실행과 제어에 있어서 중요한 개념이기 때문에 설명해야 할 분량이 많다. 종종 작업관리자를 이용해서 프로세스 강제 종료라는 기능을 많이 사용한 적이 있는데 이것 또한 쓰레드와 연관되어 있다.
그래서 쓰레드란 무엇인가?

1. 쓰레드란?

쓰레드를 알기 전에 프로세스에 대해 알아야 한다. 우리가 프로그램을 실행하면 프로세스로 나타나는데 이 실행중인 프로그램이 곧 프로세스이다.
OS로부터 메모리를 할당받아 프로세스가 되고 이 프로세스 내에서 작업을 수행하는 것이 쓰레드이다.

생산시설을 예로 들어보자. 반도체를 만드는 거대한 기업이 특정 반도체를 생산하기 위해 A라는 공장을 짓고 생산에 필요한 장비와 시설 즉, 자원을 할당해준다.
이때, 공장 A가 프로세스가 되는 것이다.
그럼 공장에서 일할 근로자가 필요할텐데 지금까지 고용된 근로자가 단 1명뿐이다. 이 근로자는 반도체 생산 장비를 이용하여 작업을 시작하게 되는데, 이 장비와 근로자를 쓰레드라고 할 수 있다.
요청 생산량이 늘어나서 더 많은 근로자를 고용하여 n명의 근로자가 반도체 장비를 이용하여 생산을 하게 되었다. 그럼 이제 멀티 쓰레드가 되는 것이다.

이 예시처럼 하나의 프로세스는 최소 한 개 이상의 쓰레드를 가진다. 쓰레드의 개수가 1개일 경우를 싱글 쓰레드, 2개 이상일때는 멀티 쓰레드라고 부른다.

CPU에는 코어라는 것이 있는데 코어의 개수에 따라 동시에 처리되는 작업이 결정된다. 듀얼코어일 경우 동시에 2개의 작업을 처리할 수 있는 것이다. 그렇다면 싱글코어인데 쓰레드가 5개일 경우는 어떻게 될까?
우리가 눈으로 볼때에는 작업이 동시에 진행되는 것처럼 보여도 실제로는 5개의 쓰레드를 번갈아 가면서 작업을 수행하게 된다.
쓰레드 수는 코어의 개수보다 항상 많기 때문에 대부분의 작업이 이렇게 진행된다.

2. 쓰레드 구현

자, 그러면 쓰레드는 도대체 어떻게 구현할 수 있을까?
방법은 크게 2가지가 있다

  • Thread 클래스를 상속한다
  • Runnable 인터페이스를 구현 후 Thread에 참조를 준다

1번째 방법을 사용하면 다른 클래스를 상속받을 수 없어 재사용성이 떨어진다. 이러한 이유로 2번째 방법이 주로 선호된다.
그런데, Thread에 참조를 준다는 것은 무슨 말일까? 우선, 예제를 보면서 이해해보자.

1) Thread 클래스 상속

public class Main {
    public static void main(String[] args) {
        Thread1 thread1 = new Thread1();
        thread1.start();
    }
}

class Thread1 extends Thread{
    @Override
    public void run(){
        System.out.println("Thread 클래스를 상속받아 구현");
    }
}

Thread1 클래스는 Thread 클래스를 상속받고 run() 메서드를 오버라이딩 한다. 그 후 해당 클래스의 인스턴스를 생성하여 참조변수에 할당하면 된다.
간단한 방법이지만 앞에서도 말했듯이 Thread 클래스를 상속받았기 때문에 다른 클래스를 상속받을 수 없어 재사용성이 떨어진다.

2) Runnable 인터페이스 구현

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

class Thread1 implements Runnable{
    public void run(){
        System.out.println("Runnable을 구현하여 구현");
    }
}

이 방법은 가장 먼저 Runnable 인터페이스를 구현하는 Thread1 클래스를 만들고 run() 메서드를 구현한다. 복습차원에서 얘기하자면 인터페이스를 구현한 클래스는 그 인터페이스의 추상 메서드를 반드시 구현해야 한다.

Runnable 타입의 참조변수 r에 인스턴스를 생성하여 할당한 후, 새로 생성한 Thread의 인스턴스에 r을 입력매개변수로 인스턴스를 참조할 수 있게 한다.

이게 무슨 말이냐면 Thread 인스턴스에 Runnable 타입의 참조변수를 제공하여 이 Thread 인스턴스는 별도로 run()을 오버라이딩하지 않아도 외부에서 run()을 제공받을 수 있다는 것이다.

실제로 Thread 클래스의 생성자를 보면 Runnable 타입의 변수를 입력매개변수로 받는 생성자가 있다.

public class Thread{
	private Runnable r;
    
    public Thread(Runnable r){
    	this.r = r;
    }
    
    public void run(){
    	if(r != null)
        	r.run();
    }
}

run() 메서드를 보면 Runnable 타입의 변수가 있을 경우 해당 변수에서 run()을 호출하도록 되어 있다. 만약, 별도의 입력매개변수 없이 Thread 인스턴스를 생성했다면 run()을 호출해도 단순히 public void run(){} 만 실행될 것이다.

Thread thread = new Thread();
thread.run(); // r = null 이므로 r.run()이 실행되지 않음

3. run()과 start()

예시를 들때마다 start()를 사용하여 쓰레드를 실행시키는 것을 볼 수 있다. 이 때 "왜 run()으로 호출을 안하지?"라는 의문이 들 것이다.

사실 run()으로 실행시켜도 상관은 없다. 실행은 되기 때문이다. 하지만 어느 쓰레드에서 실행이 되는지가 다르다.

우선, start()든 run()이든 main메서드에서 호출을 하게 된다.
main메서드가 실행되면 Call Stack에서 작업을 수행하게 된다. 이때 run()을 바로 사용할 경우 결국 main 쓰레드와 같은 Call Stack에서 작업하게 되는 것이다.
즉, 생성된 쓰레드를 실행하는 것이 아니라 단순히 클래스 내에 있는 메서드를 호출하는 것이다.

하지만, start()를 사용할 경우 새로운 Call Stack을 생성하여 여기서 run()을 실행시킨다. 독립적인 공간에서 작업을 수행하게 되는 것이다.

이 사각형이 Call Stack이라고 하면 main메서드가 실행되고 같은 공간에서 thread1.run()이 실행되는데 이는 단순히 메서드 호출에 불과하다.

start()를 사용할 경우 새로운 Call Stack(우측 사각형)을 생성하고 작업을 수행하게 된다.

결국 run()메서드가 호출되는 것은 둘 다 마찬가지이지만 이 두 방식의 가장 큰 차이점은 작업순서다.

  • run()을 바로 호출 : main메서드의 순서대로 작업 수행
  • start()를 실행 : 스케쥴러에 의해 번갈아가면서 작업 수행
public class Main {
    public static void main(String[] args) {
        System.out.println("main 작업 시작");
        Thread1 thread1 = new Thread1("thread1");
        thread1.start();
        for(long i = 0; i < 1000000000; i++){
        }
        System.out.println("main 작업 종료");
    }
}

class Thread1 extends Thread{
    public void run(){
        System.out.println(Thread.currentThread().getName() + "작업 시작");
        for(long i = 0; i < 2000000000; i++){
        }
        System.out.println(Thread.currentThread().getName() + "작업 종료");
    }
    Thread1(String name){
        super(name);
    }
}

main 작업 시작
thread1작업 시작
main 작업 종료
thread1작업 종료

반복문은 단순히 시간지연용으로 추가한 것이다. 여기서는 새로운 Call Stack이 생성되어 main과 thread1이 번갈아가면서 작업이 수행된다. 그래서 main이 종료되어도 아직 thread1이 종료되지 않았기 때문에 프로그램이 실행중이게 된다.

하지만, 같은 Call Stack에서 작업을 할 경우 다음과 같은 결과가 나오게 된다.

public class Main {
    public static void main(String[] args) {
        System.out.println("main 작업 시작");
        Thread1 thread1 = new Thread1("thread1");
        thread1.run();
        for(long i = 0; i < 1000000000; i++){
        }
        System.out.println("main 작업 종료");
    }
}

class Thread1 extends Thread{
    public void run(){
        System.out.println(Thread.currentThread().getName() + "작업 시작");
        for(long i = 0; i < 2000000000; i++){
        }
        System.out.println(Thread.currentThread().getName() + "작업 종료");
    }
    Thread1(String name){
        super(name);
    }
}

main 작업 시작
main작업 시작
main작업 종료
main 작업 종료

전부 main이라고 출력되는 이유는 run()을 오버라이딩 할 때 현재 작업중인 쓰레드의 이름을 출력하도록 했기 때문이다. 근데 main으로 출력되었다는 것은 현재 작업중인 쓰레드가 main 쓰레드, 즉 같은 Call Stack이라는 것이다.
그래서 결과를 보면 "main 실행 -> run() 실행 -> run() 종료 -> main 종료" 순으로 진행되었다는 것을 알 수 있다.

이러한 이유 때문에 start()를 사용하여 별도의 Call Stack을 생성하여 작업을 하는 것이다. 추가로, 쓰레드가 작업을 한 번 더 수행해야 할 경우 새로운 쓰레드를 다시 생성하여 start()를 호출해야 한다.

Thread1 t = new Thread1();
t.start();
// t.start(); //컴파일 에러 발생
t = new Thread1(); //새로운 쓰레드 생성
t.start();

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

여러 개의 작업을 1개의 쓰레드에서 처리할지(싱글쓰레드) 여러 개의 쓰레드에서 처리할지(멀티쓰레드) 어떻게 결정할까?

작업을 처리하는 일꾼이 많은 멀티쓰레드가 무조건적으로 좋을까? 정답은 어떤 작업을 처리하냐에 따라 다르다.

2개의 작업을 하나의 쓰레드(th1)로 처리하는 것과 두 개의 쓰레드(th1,th2)로 처리하는 경우를 가정해보자.

하나의 쓰레드로 처리할 경우 1개의 작업이 끝난 후 바로 나머지 작업을 끝낸다.
두 개의 쓰레드를 이용할 경우 쓰레드 간의 작업이 전환되면서 동시에 진행되는데 이를 context switching이라고 한다.

결과적으로는 두 방법의 처리시간이 크게 차이가 나지 않는다. 두 개의 쓰레드를 사용하는 것이 더 빠를 것 같지만 context switching 간 시간이 소요되기 때문에 실제로는 그렇지 않은 것이다.

public class Main {
    public static void main(String[] args) {
        long start_time = System.currentTimeMillis();

        //작업1
        for(int i = 0; i < 300; i++){
            System.out.print("-");
        }
        System.out.print("작업1 소요시간 : " + (System.currentTimeMillis() - start_time));

        //작업2
        for(int i = 0; i < 300; i++){
            System.out.print("+");
        }
        System.out.print("작업2 소요시간 : " + (System.currentTimeMillis() - start_time));
    }
}

이 예제는 main이라는 1개의 쓰레드에서 2개의 작업을 처리하는 것을 구현한 것이다.

이번에는 Call Stack을 새로 호출하여 2개의 쓰레드에서 작업을 처리해보자.

public class Main {
    static long start_time = System.currentTimeMillis();

    public static void main(String[] args) {
        subThread st = new subThread();
        st.start();

        //작업1
        for(int i = 0; i < 300; i++){
            System.out.print("-");
        }
        System.out.print("작업1 소요시간 : " + (System.currentTimeMillis() - start_time));
    }
}

class subThread extends Thread{
    @Override
    public void run(){
        //작업2
        for(int i = 0; i < 300; i++){
            System.out.print("+");
        }
        System.out.print("작업2 소요시간 : " + (System.currentTimeMillis() - Main.start_time));
    }
}

예시로 든 것은 단순 출력이라는 비슷한 작업을 하기 때문에 같은 자원을 놓고 두 개의 쓰레드가 경쟁을 하기 때문에 작업 전환 시간에서 시간이 소요될 수 밖에 없다.
물론, 이건 멀티 코어에 해당하는 것이고 대부분의 컴퓨터들이 멀티 코어이기 때문에 보편적인 현상일 것이다.

실행결과 또한 실행마다 다른데 OS의 스케쥴러에 의해 실행순서와 실행시간이 결정되기 때문에 자바가 OS 독립적이지만 이런 부분에서는 OS 종속적이다.

자, 그렇다면 서두에서 말한 어떤 작업을 하냐에 따라 다른 이유는 무엇일까?
같은 자원을 사용해야 하는 여러 작업의 경우에는 작업의 전환을 계속해야 하기 때문에 context switching에서 시간이 소요된다.

하지만 다른 자원을 사용하는 작업, 예를 들어 하나는 입력을 받는 작업, 나머지 하나는 출력만 하는 작업일 경우에는 멀티 쓰레드 프로세스가 훨씬 효율적이다.
왜냐하면 context switching이 필요없기 때문이다.

사용자로부터 키워드를 입력받고 시간을 출력하는 작업을 구현해보면서 이해해보자.

public class Main {
    public static void main(String[] args) {
        String input = JOptionPane.showInputDialog("Input Any word");
        System.out.println(input);
        for(int i = 10; i > 0; i--){
            System.out.println("작업 종료 "+ i + "초 전");
            try{
                Thread.sleep(1000);
            }
            catch (InterruptedException e){}
        }
    }
}

Hello
작업 종료 10초 전
작업 종료 9초 전
작업 종료 8초 전
작업 종료 7초 전
작업 종료 6초 전
작업 종료 5초 전
작업 종료 4초 전
작업 종료 3초 전
작업 종료 2초 전
작업 종료 1초 전

JOptionPane.showInputDialog()를 사용하면 사용자로부터 입력을 받을 수 있는 창이 표시가 되는데 이 예시의 경우 main 쓰레드라는 1개의 쓰레드에서 둘 다 처리해야 하기 때문에 입력을 하기 전까지 출력 작업은 진행되지 않는다.

하지만, 입력와 출력을 각각 2개의 쓰레드로 나눠서 작업하면 어떻게 될까?
입력을 받는 시간동안 출력이 진행되므로 작업이 훨씬 효율적일 것이다.

public class Main {
    public static void main(String[] args) {
        Runnable r = new output();
        Thread t = new Thread(r);
        t.start();

        String input = JOptionPane.showInputDialog("Input Any word");
        System.out.println(input);
    }
}

class output implements Runnable{
    public void run(){
        for(int i = 10; i > 0; i--){
            System.out.println("작업 종료 "+ i + "초 전");
            try{
                Thread.sleep(1000);
            }
            catch (InterruptedException e){}
        }
    }
}

작업 종료 10초 전
작업 종료 9초 전
작업 종료 8초 전
작업 종료 7초 전
Hello
작업 종료 6초 전
작업 종료 5초 전
작업 종료 4초 전
작업 종료 3초 전
작업 종료 2초 전
작업 종료 1초 전

5. 데몬 쓰레드

데몬 쓰레드는 쓰레드의 보조 역할을 하는 쓰레드로 다른 쓰레드가 종료되면 데몬 쓰레드 또한 강제 종료된다.
Garbage Collector나 자동저장 등이 이에 해당한다.

Thread1 t1 = new Thread1();
t1.setDaemon(true);

setDaemon(boolean on)을 true로 설정하면 데몬 쓰레드로 설정이 가능하다. 데몬 쓰레드로 설정된 쓰레드를 isDaemon()으로 확인하면 boolean 값으로 결과값이 나온다.

public class Main {
    static boolean autosave = false;
    public static void main(String[] args) {
        Daemon dt = new Daemon();
        dt.setDaemon(true);
        dt.start();

        for(int i = 10; i > 0; i--){
            System.out.println(i);
            try{
                Thread.sleep(2000);
            }
            catch (InterruptedException e){}
            if(i == 8){
                autosave = true;
            }
        }
        System.out.println("프로그램을 종료합니다.");
    }
}

class Daemon extends Thread{
    @Override
    public void run(){
        while(true){
            try{
                Thread.sleep(3000);
            }
            catch (InterruptedException e){}
            if(Main.autosave == true){
                System.out.println("자동저장되었습니다.");
            }
        }
    }
}

10
9
8
7
6
자동저장되었습니다.
5
자동저장되었습니다.
4
3
자동저장되었습니다.
2
자동저장되었습니다.
1
프로그램을 종료합니다.

main 쓰레드는 10초부터 카운트를 하고 8초부터 자동저장이 실행되는 구조다. 만약, 데몬 쓰레드로 설정하지 않았다면 Daemon 클래스는 루프에 빠져 영원히 종료되지 않을 것이다.
main이 종료되면서 Daemon도 루프에서 벗어나 강제 종료가 된다.

6. 실행제어

앞에서 나온 메서드 중에 Thread.sleep()을 자주 봤을 것이다. 쓰레드의 실행제어를 하는 메서드에 해당하는데 이외에도 join(), interrupt(), yield() 등이 있다.
suspend(),resume(),stop()은 교착상태를 일으키기 쉽게 작성되어 있어 Deprecated로 되었다.
(Deprecated는 하위 호환성을 위해서 삭제를 하지 않는 것일뿐이므로 사용해서는 안 된다)

1) 쓰레드의 상태

쓰레드의 실행제어를 하기 위해서는 우선 쓰레드의 상태에는 어떤 것들이 있는지 알아야 한다.
가장 먼저 쓰레드를 제어하기 위해서는 생성을 해야한다. new 연산자를 사용하여 쓰레드를 생성한 상태를 생성(NEW)라고 한다.

생성된 쓰레드를 start()하면 실행대기(RUNNABLE) 상태에 들어가는데 별도의 실행제어가 없다면 자기 차례가 됬을 때 실행(RUNNABLE) 상태로 들어간다.

실행을 모두 마치거나 stop()을 호출할 경우 쓰레드는 소멸(TERMINATED) 상태로 들어간다.

실행 상태에서 sleep(), suspend(), join(), I/O BLOCK 등이 호출될 경우 쓰레드는 일시정지(TIMED_WAITING,BLOCKED,WAITING) 상태로 변하는데, 다시 RUNNABLE로 돌아오려면 resume(), interrupt() 등이 호출되거나 time-out 되어야한다.

I/O BLOCK은 사용자의 입력을 기다리는 것을 의미하는데, 사용자가 입력을 마칠때까지 일시정지 상태에 있다가 입력을 마치면 다시 실행대기 상태로 돌아가게 된다.

실행 중인 쓰레드가 실행 대기 상태로 돌아가는 경우도 있는데 yield()를 사용하면 자신의 작업 시간을 다른 쓰레드에게 할당하게 되어 실행 대기 상태로 들어가게 된다.

쓰레드의 상태를 그림으로 나타낸 것인데 일시정지 상태를 보면 3가지가 있는 것을 알 수 있다.

TIMED_WAITING은 sleep(1000), join(10) 처럼 특정 시간이 지나면 time-out되어 자동으로 RUUNABLE로 돌아올 수 있는 상태를 말한다.

WAITING은 시간정보가 없는 join()이나 wait()가 호출되면 전환되며 특정 시간이 지난다고 다시 돌아갈 수 있는게 아니다.
join()으로 된 경우에는 해당 작업이 끝나거나 interrupt()를 호출하면 RUNNABLE로 돌아갈 수 있고 wait()의 경우에는 notify()나 notifyAll()을 호출해야 한다.

BLOCKED는 동기화와 관련된 내용으로 뒤에서 다루겠다.

쓰레드의 상태는 참조변수.getState()를 사용하여 알 수 있다.

2) 일정시간 쓰레드 정지 - sleep(long mills)

가장 많이 사용되며 매개변수로 받은 시간동안 쓰레드를 일시정지 상태로 만든다.
실행 중인 쓰레드에 sleep()이 호출되면 일시정지 상태로 들어가게 되는데, 일시정지된 쓰레드는 interrupt()가 호출되거나 지정한 시간이 지나면(time-out) 다시 실행대기 상태로 들어가고 자기 차례가 되면 다시 실행된다.

뒤에서 설명할거지만 InterruptedException이 발생할 수 있기 때문에 sleep()은 항상 try-catch문으로 구현해야 한다.

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

    }
}

class Thread_ex extends Thread{
    @Override
    public void run(){
        for(int i = 5; i > 0; i--){
            System.out.println(i);
            try{
                System.out.println("일시정지 상태로 들어갑니다.");
                Thread.sleep(3000);
                System.out.println("time-out! 실행대기 상태로 들어갑니다.");
            }catch(InterruptedException e){
                System.out.println("InterruptedException 발생! 실행대기 상태로 들어갑니다.");
            }
        }
    }
}

sleep()을 이용한 간단한 예제인데 여기서는 일시정지된 th1이 time-out되서 다시 RUNNABLE 상태로 들어가기 때문에 InterruptedException은 발생하지 않는다.

하지만, time-out 되기 전에 깨우기 위해 고의로 interrupt()를 호출하여 InterruptedException이 발생한다면 다른 문구가 출력될 것이다.

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

        try{
            Thread.sleep(4000);
        }
        catch (InterruptedException e){}

        th1.interrupt();

    }
}

class Thread_ex extends Thread{
    @Override
    public void run(){
        for(int i = 5; i > 0; i--){
            System.out.println(i);
            try{
                System.out.println("일시정지 상태로 들어갑니다.");
                Thread.sleep(3000);
                System.out.println("time-out! 실행대기 상태로 들어갑니다.");
            }catch(InterruptedException e){
                System.out.println("InterruptedException 발생! 실행대기 상태로 들어갑니다.");
            }
        }
    }
}

5
일시정지 상태로 들어갑니다.
time-out! 실행대기 상태로 들어갑니다.
4
일시정지 상태로 들어갑니다.
InterruptedException 발생! 실행대기 상태로 들어갑니다.
3
일시정지 상태로 들어갑니다.
time-out! 실행대기 상태로 들어갑니다.
2
일시정지 상태로 들어갑니다.
time-out! 실행대기 상태로 들어갑니다.
1
일시정지 상태로 들어갑니다.
time-out! 실행대기 상태로 들어갑니다.

th1이 시작된 후 main 쓰레드를 4초 정지시킨 후 고의적으로 th1에 InterruptedException을 발생시켰더니 time-out이 아닌 InterruptedException 문구가 출력되었다.

sleep() 사용시 주의해야 할 점이 있는데 sleep()은 static으로 선언되어 있기 때문에 참조변수로 호출하면 호출한 쓰레드가 영향을 받는다.

이게 무슨말인지 잘 이해가 가지 않을 것이다. 예제를 한 번 보자.

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

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

        System.out.println("main 쓰레드 종료");
    }
}

class Thread_ex extends Thread{
    @Override
    public void run(){
        for(int i = 5; i > 0; i--){
            System.out.println(i);
        }
        System.out.println(Thread.currentThread().getName() + " 종료");
    }
}

th1 실행시키고 main에서 th1을 4초 일시정지 시키고 main을 먼저 종료시키고 그 다음 th1을 실행시켜야겠다고 구상하고 코드를 구성했다.

하지만 결과는 완전히 다르게 나온다.

5
4
3
2
1
Thread-0 종료
main 쓰레드 종료

오히려 main 쓰레드가 멈추고 th1은 정상적으로 작동한다.

왜 이런 결과가 나오게 된 것일까?
방금 sleep()은 static 메서드라고 언급했었는데 Thread_ex 클래스는 Thread를 상속받기 때문에 Thread_ex 타입의 변수도 sleep()을 사용가능하다.

하지만, static 메서드이기 때문에 인스턴스에 상관없이 사용가능해야 하고 해당 클래스의 인스턴스 메서드로서 호출되지 않는다.

그래서 결국 Thread.sleep()과 th1.sleep()이 다른게 아닌 같은 기능을 하는 것이고 이를 호출한 main이 정지된 것이다.

이러한 특성을 알고 사용 시 주의하도록 하자.

3) interrupted 상태를 바꾼다 - interrupt()

상태를 바꾼다고 설명한 이유는 말그대로 상태를 바꾸기만 하기 때문이다.
interrupt()를 호출한다고 실행 중인 쓰레드를 강제 종료시키는 것도 아니고 단지 interrupted상태 변수를 바꾸는 것이다.

  • void interrupt() : 쓰레드의 interrupted 상태를 false->true로 변경
  • boolean isInterrupted() : 쓰레드의 interrupted 상태를 반환
  • boolean interrupted() : 현재 쓰레드의 interrupted 상태를 반환 후, false로 변경

관련된 메서드는 interrupted 상태를 변경하거나 반환할 뿐 그 외에 기능은 하지 않는다.
그렇다고 중요하지 않은 것은 아니다. 일시 정지 상태에 있는 쓰레드를 다시 RUNNABLE 상태로 변경하려면 time-out이 되거나 interrupt()를 호출해야 하기 때문이다.

interrupt()를 호출하면 쓰레드가 RUNNABLE로 변경되는 원리는 간단하다.
실행 -> 일시정지로 상태를 바꾸는 메서드들이 try-catch문으로 구성되어 있는데 InterruptedException이 발생하면 catch문이 실행되기 때문이다.

sleep()을 예로 들어보자.

try{
	Thread.sleep()
    }catch(InterruptedException e){}

sleep()으로 일시정지되어 있는 쓰레드도 interrupted 상태가 true로 변경되면 InterruptedException이 발생하기 때문에 다시 RUNNABLE로 돌아간다.

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

        try{
            Thread.sleep(1000);
        }catch (InterruptedException e){}

        th1.interrupt();
        System.out.println("main 쓰레드 종료");
    }
}

class Thread_ex extends Thread{
    @Override
    public void run(){
        for(int i = 10; i > 0; i--){
            System.out.println(i);
            if(i == 10){
                try{
                    Thread.sleep(3000);
                }catch (InterruptedException e){
                    System.out.println("Awakened By InterruptedException");
                    System.out.println(Thread.currentThread().getName() + " interrupted : " + Thread.interrupted());
                }
            }
        }
        System.out.println(Thread.currentThread().getName() + " 종료");
    }
}

이 예시는 th1이 시작하자마자 3초 일시정지상태에 들어가고, main은 1초 정지 후 th1의 interrupted를 true로 바꿔 다시 RUNNABLE로 변경하는 것인데 결과가 예상과 다르게 나온다.

10
main 쓰레드 종료
Awakened By InterruptedException
Thread-0 interrupted : false
9
8
7
6
5
4
3
2
1
Thread-0 종료

interrupted 상태변수가 false로 출력이 된다. 분명 interrupt()를 사용하면 true 바뀌어야 할텐데 왜 그럴까?

실제로는 interrupt()를 호출하면 InterruptedException이 발생하는 동시에 일시적으로 interrupted 상태변수가 true로 변경된다. 이후 interrupted는 false로 자동 초기화 된다.

그래서 코드를 다음과 같이 변경하면 interrupted가 true로 출력된다.

try{
                    Thread.sleep(3000);
                }catch (InterruptedException e){
                    System.out.println("Awakened By InterruptedException");
                    System.out.println(Thread.currentThread().getName() + " interrupted : " + Thread.interrupted());
                    interrupt();
                    System.out.println(Thread.currentThread().getName() + " interrupted : " + Thread.interrupted());
                }

정리

  • interrupt()는 interrupted 상태변수를 false->true로 변경해준다
  • 단, InterruptedException이 발생하거나 Thread.interrupted()를 호출할 경우에는 다시 false로 초기화된다
  • 일시정지되어 있는 쓰레드는 interrupt() 호출 시 interrupted 상태변수가 true로 먼저 바뀌고 그 후에 InterruptedException 발생하고 다시 false로 초기화

4) 다른 쓰레드에게 차례를 양보 - yield()

이름 그대로 양보해주는 기능을 한다. 스케쥴러에게 1초라는 실행시간을 할당받은 쓰레드가 0.5초정도 작업을 한 상태에서 yield()를 호출하면 나머지 0.5초가 다른 쓰레드의 작업시간으로 넘겨지고 원래 쓰레드는 실행대기 상태가 된다.

즉, 실행 중인 쓰레드가 다시 실행대기 상태로 돌아가는 것이다. 하지만, 무한히 CPU 사용을 양보하는 것이 아니라 한 차례만 양보하는 것이다.

쓰레드를 가장 먼저 설명할 때 대부분의 쓰레드의 개수가 코어의 개수보다 많아서 작업 간에 쓰레드를 계속 전환하면서 작업한다고 하지 않았던가?

만약, A와 B라는 쓰레드가 있으면 A->B->A->B->...이런식으로 작업을 전환하면서 진행하다가 A에 yield()가 호출되면 A->B->B->A->...이렇게 한 차례 자신의 턴을 양보한다고 생각하면 된다.

public class Main {
    static boolean loop = true;

    public static void main(String[] args) {
        MyThread th1 = new MyThread();
        th1.setName("Thread1");
        th1.setDaemon(true);
        th1.yieldFlag = false;

        MyThread th2 = new MyThread();
        th2.setName("Thread2");
        th2.setDaemon(true);
        th2.yieldFlag = true;

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

        for(int i = 0; i < 6; i ++){
            try{
                Thread.sleep(1000);
            }catch (InterruptedException e){}
            th1.yieldFlag = !th1.yieldFlag;
            th2.yieldFlag = !th2.yieldFlag;
        }
        //루프 탈출
        loop = false;
    }
}

class MyThread extends Thread{
    boolean yieldFlag;
    @Override
    public void run(){
        while(Main.loop){
            if(yieldFlag == true){
                Thread.yield();
            }
            else{
                System.out.println(Thread.currentThread().getName() + "실행");
                for(long i = 0; i < 1000000000L; i++){}
            }
        }
    }
}

yieldFlag라는 인스턴스 변수를 1초 단위로 반복해서 변경하여 쓰레드가 서로의 차례를 양보하는 것인데 결과를 보면 무한히 양보하는 것이 아님을 알 수 있다.

Thread1실행
Thread1실행
Thread1실행
Thread2실행
Thread2실행
Thread1실행
Thread1실행
Thread2실행
Thread2실행
Thread1실행
Thread1실행
Thread2실행
Thread2실행
Thread1실행

5) 다른 쓰레드의 작업을 기다린다 - join()

join()은 매개변수를 넣거나 넣지 않음으로써 기능이 다르다.

  • join() : 지정한 쓰레드의 작업이 끝날때까지 기다린다
  • join(long mills) : 지정한 쓰레드가 지정한 시간동안 작업을 수행하도록 한다
  • join(long mills, int nanos) : 위와 동일하며 세밀한 시간 설정을 한다

try-catch 구문으로 작성을 해야하며 interrupt() 호출 시 InterruptedException이 발생하여 일시정지 상태에서 벗어날 수 있다.

개념적으로 구분하면 join()을 사용할 경우 지정된 쓰레드의 작업이 끝나야 일시정지에서 벗어나기 때문에 WAITING, join(long mills)는 지정된 시간이 지나면 돌아오기 때문에 TIMED_WAITING이 된다.

public class Main {
    public static void main(String[] args) {
        MyThread th = new MyThread();
        th.setName("Thread_ex 1");
        th.start();
        try{
            System.out.println("Thread_ex1이 작업을 종료할 때까지 main 일시정지");
            th.join();
        }catch (InterruptedException e){}

        for(int i = 0; i < 5; i++){
            System.out.println(Thread.currentThread().getName() + " " + i);
        }
        System.out.println("Main 종료");
    }
}

class MyThread extends Thread{
    @Override
    public void run(){
        for(int i = 0; i < 5; i++){
            System.out.println(Thread.currentThread().getName() + " " + i);
            for(long x = 0; x < 10000000000L; x++){}
        }
        System.out.println(Thread.currentThread().getName() + " 종료");
    }
}

Thread_ex1이 작업을 종료할 때까지 main 일시정지
Thread_ex 1 0
Thread_ex 1 1
Thread_ex 1 2
Thread_ex 1 3
Thread_ex 1 4
Thread_ex 1 종료
main 0
main 1
main 2
main 3
main 4
Main 종료

7. 동기화

복습 차원에서 다시 설명하자면, 대부분의 쓰레드는 코어보다 많기 때문에 결국 멀티 쓰레딩을 할 수 밖에 없다.
멀티 쓰레딩의 단점이 무엇이 있었는가? 같은 자원을 사용하는 여러 개의 쓰레드가 있을 경우 context switching으로 인해 시간 소요가 더 크다.
한 가지 문제가 더 있다. 바로 "간섭"이다.

데이터를 추가하고 추출하는 기능을 예로 들어보자.
2개의 쓰레드로 추가 -> 추출 -> 추가 -> 추출... 순으로 작업을 하고 있는데 만약, 어떤 쓰레드가 추출해버려서 데이터를 저장하고 있던 배열에 아무것도 없는데 그 시점에 다른 쓰레드가 추출을 해버리면 IndexOutOfBoundsException이 발생할 수 있다.

이러한 문제는 한 쓰레드가 작업을 마치기도 전에 다른 쓰레드가 간섭을 해서 발생한 것이다. 이를 방지하려면 쓰레드의 동기화(synchronization)이 필요하다.

쓰레드의 실행제어에서 동기화 부분에서 BLOCKED 상태를 다룬다고 했었는데, 만약 동기화를 하여 한 쓰레드가 작업 중일 때 다른 쓰레드는 BLOCKED 상태에 들어간다.
그리고 작업이 마치면 BLOCKED 상태에서 RUNNABLE 상태로 전환되어 작업을 진행하게 된다.

이렇게만 들어서는 무슨 말인지 모르겠으니 차근차근 동기화에 대해 알아보자.

우선, 동기화를 어떻게 하는지 알아보자.

1) synchronized를 이용한 동기화

동기화를 하는 방법은 2가지이다. synchronized 블럭을 사용하거나 Lock,Condition을 사용하는 방법이다.
Lock,Condition을 사용하는 방법은 JDK 1.5 이상부터 지원하는 기능으로, 그 전까지는 synchronized 블럭을 사용하는 방법으로만 동기화가 가능했다.

synchronized 키워드를 사용하여 임계영역을 지정할 수 있는데 2가지 방식이 있다.

  • 메서드 전체를 임계영역으로 지정
  • 특정한 영역을 지정
public synchronized void....

메서드 선언부에 리턴 타입 앞에 추가하여 해당 메서드 전체를 임계영역으로 지정할 수 있다.

public void method(){
	synchronized(ReferenceVariable){
    
    }
}

메서드 내에 임계영역으로 지정할 영역을 중괄호로 감싸서 지정할 수 있다. 이때 소괄호 안에는 lock을 걸고자 하는 객체를 참조하는 변수를 추가한다.

이게 무슨 말인지 잘 이해가 가지 않을 것이다.

이해하려면 왜 동기화를 해야하는지 다시 생각해 볼 필요가 있다. 동기화는 같은 자원을 서로 다른 쓰레드가 사용하여 발생하는 간섭을 방지하기 위해 사용하는 기능이다.

즉, lock을 걸어야 하는 객체는 공유할 자원이라고 생각하면 된다.

알기 쉽게 예시를 하나 설명하자면, 입력하는 기능과 출력하는 기능을 생각해보자.
입력을 하면 특정 변수 값이 변경되고, 출력을 하면 가장 최근의 변수 값이 출력된다.
쓰레드는 2개가 있으며, 각각 입력과 출력을 담당한다.

코드를 보면서 구체적으로 이해해보자.

public class Main {
    public static void main(String[] args) {
      Data data = new Data();
      Thread t1 = new Thread(){
          public void run(){
              for(int i = 0; i < 5; i++){
                  data.input(i);
              }
          }
      };
      Thread t2 = new Thread(){
          public void run(){
              for(int i = 0; i < 5; i++){
                  data.output();
              }
          }
      };
      t1.start();
      t2.start();
    }
}

class Data{
    int Data;

    //입력하는 기능
    public void input(int Data){
        this.Data = Data;
        System.out.println("Input Data : " + Data);
    }
    //출력하는 기능
    public void output(){
        System.out.println("Output Data : " + Data);
    }
}

t1,t2는 익명 쓰레드의 참조변수로 각각 입력과 출력 기능을 실행시키고, 두 쓰레드는 모두 data라는 같은 인스턴스를 공유한다.

결과값을 0~4까지 순서대로 입력->출력->입력->출력... 순으로 출력하고자 한다.
하지만, 여기서 문제가 생긴다.

Input Data : 0
Input Data : 1
Input Data : 2
Input Data : 3
Input Data : 4
Output Data : 0
Output Data : 4
Output Data : 4
Output Data : 4
Output Data : 4

두 쓰레드가 자원을 차지하기 위해 경쟁을 하게 되어 결과값이 뒤죽박죽으로 나온다.

이를 해결하려면 동기화를 시켜야 하는데 메서드 전체를 동기화 하는 것은 간단하니 특정 영역을 동기화시키는 방법으로 해보겠다.

class Data{
    int Data;

    //입력하는 기능
    public void input(int Data){
        synchronized (this){
            this.Data = Data;
            System.out.println("Input Data : " + Data);
        }
    }
    //출력하는 기능
    public void output(){
        synchronized (this){
            System.out.println("Output Data : " + Data);
        }
    }
}

우선, synchronized가 추가된 영역은 실행하기 위해서 lock을 얻어야 한다. t1,t2가 자원 경쟁을 시작했을때 t1이 먼저 lock을 얻으면, t2는 output()을 호출하려해도 lock이 없기 때문에 불가능하고 BLOCKED 상태에 들어간다.

t1의 작업이 끝나면 자동으로 lock을 반납한다.(자동으로 가능한 것은 synchronized의 기능이다.)

여기서 소괄호 안에 this를 넣은 것은 공유할 자원이 this, 여기서는 Data 클래스의 객체를 가리킨다.

사실 여기에 들어가는 참조는 공유할 자원이면 무엇이든 가능하다. 인스턴스 변수인 Data도 공유되기 때문에 소괄호에 들어가도 상관없다. 어차피 Data 클래스의 객체가 공유자원이고 그 내부에 있으면 동일하게 공유자원이기 때문이다.

메서드 구현부 전체가 임계영역이어서 메서드 전체를 지정하는 것과 동일하기 때문에 코드를 메서드 전체 지정으로 다시 변경해서 실행시켜보겠다.

Input Data : 0
Input Data : 1
Input Data : 2
Input Data : 3
Input Data : 4
Output Data : 4
Output Data : 4
Output Data : 4
Output Data : 4
Output Data : 4

이번에는 간섭없이 진행이 되었지만 원하는 순서대로 결과 출력이 되지 않았다. 이러한 결과가 나온 것은 이번에는 자원 경쟁이 아닌 lock을 얻기 위한 경쟁이 발생한 것이고, t1이 lock을 계속 얻어 t2는 t1이 작업을 모두 마친 후 진행된 것이다.

어떻게 하면 입력->순서->입력->순서 순으로 결과값을 출력할 수 있을까?

여기서 wait()과 notify()라는 메서드를 사용하면 구현할 수 있다.

2) wait(), notify()

쓰레드 실행제어를 설명하는 도입부에서 Waiting 상태에 대해 언급한 부분 기억나는가?
Runnable 상태에서 wait()을 호출하여 Waiting 상태에 들어가고 notify()나 notifyAll()을 호출하여 다시 Runnable 상태로 바꿀 수 있다고 했었다.

wait(), notify(), notifyAll() 은 동기화 블럭 내에서만 사용할 수 있는 메서드이고 Thread 클래스가 아닌 Object 클래스에 정의되어 있다.

wait()은 매개변수를 입력할 수 있게 오버로딩되어 있어, 매개변수를 입력하여 사용할 경우 Waiting이 아닌 Timed-Waiting이 되어 일정 시간이 지나면 notify()를 호출하는 것과 동일하게 waiting pool에서 벗어난다.

wait()은 InterruptedException을 발생시킬 수 있기 때문에 반드시 try-catch문을 구성해야 한다.
wait()을 호출하면 해당 쓰레드는 해당 객체(공유하고 있는 자원)의 대기실인 waiting pool에 들어가고 notify()가 호출되면 waiting pool에서 벗어난다.

notifyAll()은 이 waiting pool에서 대기중인 모든 쓰레드를 깨우는 기능이다. 여기서 주의해야 할 점은 공유하고 있는 객체에 해당하는 waiting pool에 있는 쓰레드에 한정해서이다.

쉽게 말해, A라는 자원을 공유하는 쓰레드가 notifyAll()을 호출할 경우 A의 waiting pool에 있는 쓰레드들 전부가 벗어나는 것이지, B라는 자원을 공유하는 다른 쓰레드들에 대해서는 해당되지 않는다.

이제 이 메서드들을 사용하여 코드를 다시 재구성해보자.

class Data{
    int Data;
    boolean isEmpty = true;
    //입력하는 기능
    public synchronized void input(int Data){
        if(!isEmpty){
            try{
                wait();
            }catch (InterruptedException e){}
        }
        this.Data = Data;
        isEmpty = false;
        notify();
        System.out.println("Input Data : " + Data);
    }
    //출력하는 기능
    public synchronized void output(){
        if(isEmpty){
            try{
                wait();
            }catch (InterruptedException e){}
        }
        isEmpty = true;
        notify();
        System.out.println("Output Data : " + Data);
    }
}

isEmpty라는 인스턴스 변수의 불리언 값에 따라 if문을 실행하게 구성하였다.
데이터가 비어있으면 출력이 안되고, 비어있지 않은데 입력하는 것은 맞지 않기 때문에 그에 맞게 if문을 구성하였다.
동기화를 시켜도 lock을 먼저 얻은 쓰레드가 실행될 수 있기 때문에 if문을 통해 자신의 실행 차례가 아닐 경우 waiting pool에서 대기시키고, 작업이 끝나갈 때쯤 notify()를 호출하여 RUNNABLE 상태로 바꾸었다.

Input Data : 0
Output Data : 0
Input Data : 1
Output Data : 1
Input Data : 2
Output Data : 2
Input Data : 3
Output Data : 3
Input Data : 4
Output Data : 4

원했던 방향으로 결과값이 출력되었다.

3) Lock을 이용한 동기화

JDK 1.5 이후부터 제네릭과 함께 추가된 기능으로, 그 전까지는 synchronized를 이용하여 동기화를 해왔다.
synchronized를 이용하여 동기화를 하면 자동으로 lock이 잠기고 풀리기 때문에 편리하지만 같은 메서드 내에서만 lock을 걸 수 있다는 제약이 있다.

java.util.concurrent.locks 패키지의 lock 클래스를 사용하면 수동으로 lock을 걸고 풀 수 있다.

lock 클래스에는 ReentrantLock, ReentrantReadWriteLock, StampedLock 3가지가 있는데 가장 일반적인 ReentrantLock에 대해 알아보자.

ReentrantLock()
ReentrantLock(boolean fair)

생성자는 기본 생성자와 오버로딩된 매개변수가 있는 생성자로 나뉜다.
매개변수에는 불리언 값을 입력할 수 있는데, true를 입력할 경우 lock이 풀렸을 때 가장 오래 기다린 쓰레드가 lock을 획득할 수 있게 처리한다.
다시말해, 공정하게 처리한다는 것인데 문제는 어떤 쓰레드가 가장 오래 기다렸는지 확인하는 과정을 거쳐야해서 성능을 떨어진다.

공정성보다는 성능을 중요시 생각한다면 기본 생성자를 사용하는 것이 효과적이다.

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

ReentrantLock 클래스의 메서드로 lock을 얻고, 해제하고, 상태를 확인하는 기능을 한다. lock이 걸린 임계 영역 내에서 예외가 발생하거나 return문으로 빠져 나갈 경우 lock이 풀리지 않을 수 있기 때문에, unlock()은 일반적으로 try-finally 구문으로 감싼다.

앞에서 synchronized를 사용한 예제를 ReentrantLock 클래스를 이용하여 바꿔보자.

그 전에 Condition 인터페이스에 대해 알아야 한다.

4) Condition 인터페이스

Condition 인터페이스는 동기화 메커니즘을 보다 세밀하게 제어할 수 있도록 도입된 기능이다.
기본적으로는 Object 클래스의 wait(), notify(), notifyAll()과 동일한 역할을 수행하지만, Lock과 함께 사용되어 유연하게 여러 개의 대기 집합을 구성할 수 있는 장점이 있다.

Condition과 Lock을 통해 하나의 객체에서 여러 개의 대기 집합을 관리할 수 있어, 복잡한 동기화 로직 구성 시 유용하다.

쉽게 말해, waiting pool을 여러 개 구성이 가능하여 각각의 pool을 따로 제어가 가능하며 기존의 메서드( wait(), notify(), notifyAll() )의 기능을 그대로 수행할 수 있다.

그렇다면 갑자기 왜 Condition 인터페이스를 언급하냐면 synchronized 블럭이 아니면 wait(), notify(), notifyAll()를 사용할 수 없다....

기본적인 Condition 인스턴스 생성법을 알아보자.

Lock lock = new ReentrantLock();
Condition conditionA = lock.newCondition();
Condition conditionB = lock.newCondition();

기존의 lock 객체에서 Condition 인스턴스를 생성하며 예시와 같이 각각의 인스턴스를 통해 서로 다른 waiting pool을 관리할 수 있다.

void await() // wait()
void signal() // notify()
void signalAll() // notifyAll()

다음은 Condition의 메서드들로 앞에서 언급했듯이 기능은 동일하다.
주의해야 할 사항은 waiting pool마다 따로 사용해야 한다는 것이다. 예를 들어, conditionA에 대기중인 쓰레드에 conditionB.singnal() 을 사용해봤자 아무런 일도 일어나지 않는다.

여러 개의 대기 집합을 만들 수 있기 때문에 이점을 유의하여 사용해야 한다.

await()의 경우 2가지 더 알아야 할 점이 있다.

첫 번째로, InterruptedException을 throws 하기 때문에 호출 시 try-catch문을 사용해야 한다.
두 번째로, lock을 얻은 상태로 await()가 호출될 경우 자동으로 lock을 해제,반납 후 waiting pool로 들어간다.

더 자세한 내용은 자바 API 공식문서를 참고하면 알 수 있다.
https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/locks/Condition.html#signalAll--


자, 이제 기존에 synchronized로 구성된 코드를 Lock,Condition을 이용하여 수동으로 lock을 걸고 해제하는 코드로 변경해보자.

class Data{
    private int Data;
    private boolean isEmpty = true;
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();

    //입력하는 기능
    public void input(int Data){
        lock.lock();
        try{
            while(!isEmpty){
                condition.await();
            }
        }catch (InterruptedException e){}
        finally {
            this.Data = Data;
            System.out.println("Input Data : " + Data);
            isEmpty = false;
            condition.signal();
            lock.unlock();
        }
    }
    //출력하는 기능
    public void output(){
        lock.lock();
        try{
            while(isEmpty){
                condition.await();
            }
        }catch (InterruptedException e){}
        finally {
            System.out.println("Output Data : " + Data);
            isEmpty = true;
            condition.signal();
            lock.unlock();
        }
    }
}

두 메서드 동일하게 lock 참조변수에서 락을 얻고 조건에 부합하지 않을 경우 루프를, 부합할 경우 나머지 코드를 실행한다.
isEmpty값까지 변경하였으면 waiting pool에 있는 쓰레드를 깨우고 unlock()으로 lock을 반납한다.

5) volatile

공유자원을 사용하는 멀티 쓰레딩으로 인해 쓰레드 간의 간섭이라는 문제가 발생할 수 있었지만, 동기화를 통해 이 문제를 해결할 수 있다.
이게 가능한 이유는 동기화가 가진 가시성원자화 덕분이다.
가시성과 원자성에 대해 설명하기 전에 우선 멀티 코어 프로세서의 캐시와 메모리 간의 통신을 알아야 한다.

대부분의 컴퓨터가 멀티 코어 프로세서를 사용하는데 각 코어마다 캐시가 존재한다. 각 코어의 캐시는 메모리에서 값을 읽어와 캐시에 저장하고, 코어는 캐시에 저장된 값을 읽어오는 구조로 이루어져있다.

하지만, 여기서 문제가 발생한다. 메모리의 값은 변경이 되었는데 캐시에서 갱신이 되지 않아 코어가 읽은 값이 메모리에 저장된 값과 다른 것이다!

그림에 Core1처럼 캐시에서 갱신이 되지 않아 잘못된 값을 읽게 되는 것이다.
이때, 동기화를 해주면 캐시와 메모리가 동기화 되어 쓰레드에서 변경된 최신 값을 바로 볼 수 있는데 이를 "가시성"이라고 한다.

그렇다면 원자화는 무엇일까?
JVM에서는 데이터를 4Byte 단위로 처리를 하는데 이 단위는 더 이상 쪼갤 수 없는 최소의 작업 단위이고 하나의 명령어로 읽기나 쓰기가 가능하다. 원시타입인 int나 그보다 작은 타입이 이에 해당한다.
그렇다면 long이나 double 같은 타입은 어떨까? 둘 다 8Byte이기 때문에 하나의 명령어로 처리가 불가능하기 때문에 읽거나 쓰는 과정에서 다른 쓰레드가 간섭할 수 있다.
이때 synchronized 블럭으로 감쌀 경우 해당 블럭이 하나의 작업 단위가 되어 다른 쓰레드의 간섭을 받지 않을 수 있는데, 이를 원자화라고 한다.
즉, 원자화라는 것은 작업을 더 이상 나눌 수 없게 하는 것이다.

  • 가시성 : 변수의 변경사항이 즉시 메인 메모리에 반영되도록 하여, 쓰레드가 최신 값을 볼 수 있도록 보장
  • 원자화 : 연산이 더 작은 단계로 나누어지지 않고 한번에 이루어지는 것

이처럼 synchronized를 사용하면 가시성과 원자화를 보장받을 수 있는 장점이 있다.
그렇다면 갑자기 이걸 왜 설명한거냐?

가시성만 보장받을 수 있는 방법이 하나 더 있기 때문이다. 바로 변수 앞에 volatile을 선언하는 것이다.

자, 예시를 한 번 보자.

public class Main {
    static boolean running = true;

    public static void main(String[] args) {
        Thread thread = new Thread(){
            public void run(){
                System.out.println("thread started");
                while(running){

                }
                System.out.println("thread stopped");
            }
        };

        thread.start();

        try{
            Thread.sleep(1000);
        }catch (InterruptedException e){
            Thread.currentThread().interrupt();
        }

        System.out.println("Main Thread setting running to false");
        running = false;
    }
}

running는 클래스 변수이기 때문에 공유자원으로 사용된다. main과 thread가 running을 공유하는데, running이 true일 경우 thread는 루프에서 탈출하지 못하고 false로 바뀌어야만 탈출이 가능하다.'
그래서 코드 마지막에 main에서 running을 false로 변경하여 출력문이 나오도록 구성했다.

하지만, 실제로 실행을 시켜보니 출력문은 나오지 않고 계속 루프에 빠져있었다.
왜 그럴까?

그림과 같이 thread는 캐시에서 변경된 running 값이 갱신이 되지 않아 계속 true로 인식하고 있는 것이다.
이렇게 되면 갱신될 때까지 루프에 빠지거나, 영원히 갱신되지 않을 수 있다.

이 때 volatile을 사용하면 해결이 가능하다.

static volatile boolean running = true;

공유자원인 running에 volatile을 선언하게 되면 가시성이 보장되어, 쓰레드에서 running 값을 읽어올 때 캐시가 아닌 메모리에서 읽어오기 때문에 최신 상태가 반영된다.
그래서 thread는 루프에서 탈출할 수 있게 된다.

public class Main {
    static volatile boolean running = true;

    public static void main(String[] args) {
        Thread thread = new Thread(){
            public void run(){
                System.out.println("thread started");
                while(running){

                }
                System.out.println("thread stopped");
            }
        };

        thread.start();

        try{
            Thread.sleep(1000);
        }catch (InterruptedException e){
            Thread.currentThread().interrupt();
        }

        System.out.println("Main Thread setting running to false");
        running = false;
    }
}

thread started
Main Thread setting running to false
thread stopped

0개의 댓글