자바스터디 - 10주차

megaseunghan·2022년 5월 11일
0

자바스터디

목록 보기
1/15
post-thumbnail

목표

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

학습할 것 (필수)

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

Thread 클래스와 Runnable 인터페이스

프로세스란

  • 프로그램은 하나의 프로세스이다.

  • 프로그램을 실행하면 운영체제로부터 자원을 할당받아 프로세스가 된다.

  • 프로세스는 프로그램을 실행하는데 필요한 자원스레드로 구성되어 있다.

쓰레드란

  • 프로세스의 자원을 이용해서 실제 작업을 수행하는 것
  • 모든 프로세스에는 하나 이상의 스레드가 존재한다. 둘 이상의 쓰레드를 가진 프로세스를 멀티쓰레드 프로세스라고 한다.
  • 가장 작은 실행 단위이다.

현재 우리가 사용하고 있는 대부분의 OS는 멀티 태스킹을 지원하기 떄문에 여럭 개의 프로세스가 동시에 실행될 수 있다. 마찬가지로 멀티쓰레딩은 하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행하는 것이다.

멀티쓰레딩의 장점은 다음과 같다.

멀티 스레딩의 장점
CPU의 사용률을 향상시킨다.
자원을 보다 효율적으로 사용할 수 있다.
사용자에 대한 응답성이 향상된다.
작업이 분리되어 코드가 더 간결해진다.

Thread 클래스와 Runnable 인터페이스

쓰레드를 구현하는 방법은 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법 모두 두 가지가 있다.

Thread 클래스를 상속 받으면 다른 클래스를 상속 받을 수 없기 때문에 Runnable 인터페이스를 구현하는 방법이 일반적이다.

Runnable 인터페이스를 구현하는 방법은 재사용성이 높고, 코드의 일관성을 유지할 수 있기 때문에 보다 객체지향적인 방법이다.

Thread 클래스를 상속받은 경우와 Runnable 인터페이스를 구현한 경우의 인스턴스 생성 방법이 다르다.

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

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

Thraed t2 = new Thread(new ThreadExample());		// 위의 두 줄을 한 줄로 간단히
  • Runnable Example 예제

    package week_10;
    
    public class RunnableExample implements Runnable {
        @Override
        public void run() {
            System.out.println("This is Runnable Example run()");
        }
    }
  • Thread Example 예제

    package week_10;
    
    public class ThreadExample extends Thread {
        @Override
        public void run() {
            System.out.println("This is Thread Example run()");
        }
    }
  • 실행 - start()

쓰레드를 생성헀다고 해서 자동으로 실행되는 것은 아니다. start() 메서드릃 호출해야만 쓰레드가 실행된다.

t1.start();	// 쓰레드 t1 실행
t2.start(); // 쓰레드 t2 실행

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

또한 한 번 실행이 종료된 쓰레드는 다시 실행할 수 없다. start()가 한 번만 호출될 수 있다는 뜻이다. 두 번 이상 호출하면 IllegalThreadStateException이 발생한다.

쓰레드의 실행순서는 OS의 스케줄러가 작성한 스케줄에 의해 결정된다.

  • start()와 run()

run() 아닌 start()를 호출하는 이유는 무엇일까? main 메서드에서 run()을 호출하는 것은 단순히 클래스에 선언된 메서드를 실행하는 것일 뿐이다.

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

모든 쓰레드는 독립적인 작업을 수행하기 위해서 자신만의 호출스택을 필요로 하기 때문에 새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스택이 생성되고 쓰레드가 종료되면 작업에 사용된 호출스택은 소멸된다.

  • 선언 및 실행 예제

    package week_10;
    
    public class ExecuteExample {
        public static void main(String[] args) {
    
            // Runnable을 사용하여 Thread 생성
            RunnableExample runnable = new RunnableExample();
            new Thread(runnable).start();
            
            // Runnable은 다음과 같이 생성이 가능하다
            Thread thread = new Thread(new Runnable() {
             	@Override
                public void run() {
                    System.out.println("This is Runnable Example run()");
                }
            });
    
            // Runnable은 Lambda를 통해서도 생성이 가능하다.
            Thread thread = new Thread( () -> {
               System.out.println("This is Runnable Example run()")
            });
                  
            // Thread를 사용하여 Thread 생성
            ThreadExample thread = new ThreadExample();
            thread.start();
    
        }
    }
  • 실행 결과

This is Runnable Example run()
This is Thread Example run()

Process finished with exit code 0

  • ThreadExameple 수정
package week_10;

public class ThreadExample extends Thread {
    public ThreadExample(String valueOf) {
    }

    @Override
    public void run() {
        System.out.println("This is Thread Example run() \t And My Name is \t" + this.getName());
    }
}
  • Thread 배열을 선언하고 순서대로 실행되는지 확인하는 예제
package week_10;

public class ThreadOrderExample {
    public static void main(String[] args) {
        Thread[] tArr = new ThreadExample[50];
        
        for (int i = 0; i < 50; i++) {
            Thread t = new ThreadExample(String.valueOf(i + 1));
            tArr[i] = t;
        }

        for (Thread thread : tArr) {
            thread.start();
        }
    }
}
  • 출력결과
This is Thread Example run() 	 And My Name is 	Thread-1
This is Thread Example run() 	 And My Name is 	Thread-6
This is Thread Example run() 	 And My Name is 	Thread-13
This is Thread Example run() 	 And My Name is 	Thread-8
This is Thread Example run() 	 And My Name is 	Thread-21
.
.

This is Thread Example run() 	 And My Name is 	Thread-38
This is Thread Example run() 	 And My Name is 	Thread-44
This is Thread Example run() 	 And My Name is 	Thread-42

순서대로 실행되지 않는다는 것을 알 수 있다. Thread는 실행할 때 먼저 대기 상태로 진입하며 OS의 스케쥴링에 따라 실행되고, 컴퓨터의 성능에 따라 달리지기 때문이다.

쓰레드의 상태

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

스레드 객체를 생성하고 start() 메서드를 호출하면 곧바로 스레드가 실행되는 것처럼 보이지만 실행대기 상태가 된다.

실행대기란 ?

  • 아직 스케줄링이 되지 않아서 실행을 기다리는 상태이다.
  • 실행대기 상태에 있는 스레드 중에서 스케줄링으로 선택된 스레드가 비로소 CPU를 점유하고 run() 메소드를 실행한다. 이때를 실행상태라고 한다.
  • 실행 상태 스레드는 메서드를 모두 실행하기 전에 다시 실행 대기 상태로 돌아갈 수 있다. 그리고 실행 대기 상태에 있는 다른 실행 대기 스레드가 선택되어 실행 상태가 된다. 이런식으로 번갈아가면서 자신의 run() 메서드를 실행하고 모든 run()메서드가 종료되면 종료 상태가 되는 것이다.

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

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

밀리세컨드와 나노세컨드의 시간단위로 세밀하게 값을 지정할 수 있지만 어느 정도의 오차가 발생할 수 있다는 것은 염두에 둬야한다.

sleep()에 의해 일시정지 상태(TIMED_WAITING)가 된 쓰레드는 지정된 시간이 다 되거나 interrupt()가 호출되면 잠에서 깨어나 실행대기 상태(RUNNABLE)가 된다. 그래서 sleep()을 호출할 때는 항상 try - catch로 감싸 예외처리를 해야 한다.

sleep()은 현재 실행 중인 쓰레드에 대해 작동한다. 따라서 Thread.sleep()을 사용한다.

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

th1.sleep()을 호출한다고 하더라도 현재의 쓰레드인 main 쓰레드가 sleep 상태에 들어간다.

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

진행 중인 쓰레드의 작업이 끝나기 전에 취소시켜야할 때가 있다.

  • interrupt() - 단지 멈추라고 요청하는 것일뿐, 쓰레드를 강제로 종료시키지는 못한다.
    • 쓰레드의 interrupted상태를 false에서 true로 변경
  • interrupted() - 쓰레드에 대해 interrupt()가 호출되었는지 알려준다.
    • boolean isInterrupted() - 쓰레드의 interrupted 상태를 반환
    • static boolean isInterrupted() - 현재 쓰레드의 interrupted 상태를 반환 후, false로 변경
Thread th = new Thread();
th.start();
...
    
th.interrupt();	// 쓰레드  th에 interrupt()를 호출한다.

class MyThread extends Thread {
    public void run() {
        while(!interrupted()) {	// interrupted() 결과가 false인 동안 반복
            ...
        }
    }
}

쓰레드가 sleep(), wait(), join()에 의해 일시정지 상태에 있을 때, 해당 쓰레드에 대해 interrupt()를 호출하면 sleep(), wait(), join()에서 InterruptedException이 발생하고 쓰레드는 실행대기 상태(RUNNABLE)로 바뀐다.

suspend(), resume(), stop() - deprecated

suspend() - 쓰레드를 멈추게 한다. suspend()에 의해 정지된 쓰레드는 resume()을 호출해야 다시 실행대기 상태가 된다. stop()은 호출되는 즉시 쓰레드가 종료된다. 위 3개의 메서드는 쓰레드의 실행을 제어하는 가장 손쉬운 방법이지만 suspend()와 stop()이 교축상태를 일으키기 쉽게 작성되어 있으므로 권장하지는 않는다.

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

쓰레드 자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보한다.

예를 들어 스케쥴러에 의해 1초의 실행시간을 할당받은 쓰레드가 0.5초의 시간동안 작업한 상태에서 yield()가 호출되면 나머지 0.5초는 포기하고 다시 실행대기상태가 된다.

yield(), interrupt()를 적절히 사용하면 프로그램의 응답성을 높이고 보다 효율적인 실행이 가능하게 할 수 있다.

package week_10.status;

public class ThreadYieldExample implements Runnable {
    boolean suspended = false;
    boolean stopped = false;
    Thread th;

    public ThreadYieldExample(String name) {
        th = new Thread(this, name); // Thread(Runnable r, String name)
    }

    @Override
    public void run() {
        String name = th.getName();

        while (!stopped) {
            if (!suspended) {
                System.out.println(name);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    System.out.println(name + " - interrupted");
                }
            } else {
                Thread.yield();
            }
        }
        System.out.println(name + "- stopped");
    }

    public void suspend() {
        suspended = true;
        th.interrupt();
        System.out.println(th.getName() + " - interrupted() by suspend()");
    }

    public void resume() {
        suspended = false;
    }

    public void stop() {
        stopped = true;
        th.interrupt();
        System.out.println(th.getName() + " - interrupted by stop()");
    }

    public void start() {
        th.start();
    }


    public static void main(String[] args) {
        ThreadYieldExample th1 = new ThreadYieldExample("*");
        ThreadYieldExample th2 = new ThreadYieldExample("**");
        ThreadYieldExample th3 = new ThreadYieldExample("***");
        th1.start();
        th2.start();
        th3.start();

        try {
            Thread.sleep(2000);
            th1.suspend();
            Thread.sleep(2000);
            th2.suspend();
            Thread.sleep(3000);
            th1.resume();
            Thread.sleep(3000);
            th1.stop();
            th2.stop();
            Thread.sleep(2000);
            th3.stop();
        } catch (InterruptedException e) {
        }
    }
}

else문을 보면 yield()를 호출해서 남은 실행시간을 while문에서 낭비하지 않고 다른 쓰레드에게 양보하므로 더 효율적이다.

interrupt()를 호출하면 sleep()에서 InterruptedException이 발생하여 즉시 일시정지 상태에서 벗어나게 되므로 응답성이 좋아진다.

만일 stop()이 호출되었을 때 Thread.sleep(1000)에 의해 쓰레드가 일시정지 상태에 머물러 있는 상황이라면 stopped의 값이 true로 바뀌었어도 쓰레드가 정지될 때까지 1초의 시간지연이 생겼을 것이다. 하지만 같은 상황에서 interrupt()를 호출하면 sleep()에서 InterruptedException이 발생하여 즉시 일시정지 상태에서 벗어나게 되므로 응답성이 좋아진다.

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

쓰레드 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간 동안 작업을 수행하도록 할 때 join()을 사용한다.

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

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

try {
    th1.join();	// 현재 실행중인 쓰레드가 쓰레드 th1의 작업이 끝날 때까지 기다린다.   
} catch(InterruptedExcepion e) {
	    
}

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

  • join() 예제
package week_10.status;

import java.sql.SQLOutput;

public class joinExample {
    static long startTime = 0;

    public static void main(String[] args) {
        joinExample_1 th1 = new joinExample_1();
        joinExample_2 th2 = new joinExample_2();
        th1.start();
        th2.start();
        startTime = System.currentTimeMillis();

        try {
            th1.join(); // main 쓰레드가 th1의 작업이 끝날 떄까지 기다린다.
            th2.join(); // main 쓰레드가 th2의 작업이 끝날 떄까지 기다린다.
        } catch (InterruptedException e) {
        }
        System.out.print("소요시간 : " + (System.currentTimeMillis() - joinExample.startTime));
    }   // main
}

class joinExample_1 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print(new String("-"));
        }
    } // run
}

class joinExample_2 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print(new String("|"));
        }
    } // run
}
  • 실행결과
|||||||||||||||||||---------------------------------------------------|||----------------------------------|||------||--------|||||----||-||||||--------------------|||||||||||||||||||||||||||||||||||||----||||||||||||||||||--------------------------------------------|||--||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||-----------------------------------------------------------------||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||-|||||-------|||||||||||||||||||||||||-----------------------------------------------------소요시간 : 11

join()을 사용하여 main 쓰레드가 th1, th2의 작업이 끝날때까지 기다리도록 했다.

  • 더욱 실질적인 예제
package week_10.status;

public class JoinDeeperExample {
    public static void main(String[] args) {
        JoinDeeperExample_1 gc = new JoinDeeperExample_1();
        gc.setDaemon(true);
        gc.start();

        int requiredMemory = 0;

        for (int i = 0; i < 20; i++) {
            requiredMemory = (int) (Math.random() * 10) * 20;

            // 필요한 메모리가 사용할 수 있느 양보다 크거나 전체 메모리의 60% 이상을 사용했을 경우 gc를 깨운다.
            if (gc.freeMemory() < requiredMemory
                    || gc.freeMemory() < gc.totalMemory() * 0.4) {
                gc.interrupt();
            }
            gc.usedMemory += requiredMemory;
            System.out.println("userMemory : " + gc.usedMemory);
        }
    }
}

class JoinDeeperExample_1 extends Thread {
    final static int MAX_MEMORY = 1000;
    int usedMemory = 0;

    public void run() {
        while (true) {
            try {
                Thread.sleep(10 * 1000);
            } catch (InterruptedException e) {
                System.out.println("Awaken by interrupt()");
            }
            gc();
            System.out.println("Garbage Collected. Free Memory : " + freeMemory());
        }
    }

    public void gc() {
        usedMemory -= 300;
        if (usedMemory < 0) {
            usedMemory = 0;
        }
    }

    public int totalMemory() {
        return MAX_MEMORY;
    }

    public int freeMemory() {
        return MAX_MEMORY - usedMemory;
    }
}
  • 실행결과
userMemory : 20
userMemory : 140
userMemory : 320
userMemory : 340
userMemory : 380
userMemory : 520
userMemory : 680
userMemory : 680
userMemory : 680
Awaken by interrupt()
userMemory : 700
userMemory : 540
userMemory : 700
userMemory : 820
userMemory : 840
userMemory : 1000
userMemory : 1160
userMemory : 1340
Garbage Collected. Free Memory : -340
userMemory : 1400
Awaken by interrupt()
Garbage Collected. Free Memory : -100
userMemory : 1240
userMemory : 1260
Awaken by interrupt()
Garbage Collected. Free Memory : 40
Awaken by interrupt()
Garbage Collected. Free Memory : 340

쓰레드의 우선순위

쓰레드는 우선순위라는 속성을 가지고 있는데 이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다. 쓰레드가 수행하는 작업의 주요도에 따라 쓰레드의 우선순위를 서로 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있다.

쓰레드의 우선순위 지정하기

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

void setPriority(int newPriority) // 쓰레드의 우선순위를 지정한 값으로 변경한다.
int getPriority()				  // 쓰레드의 우선순위를 반환한다.

public static final int MAX_PRIORITY = 10 	// 최대 우선 순위
public static final int MIN_PRIORITY = 1	// 최소 우선 순위
public static final int NORM_PRIORITY = 5	// 보통 우선 순위

쓰레드가 가질 수 있는 우선순위의 범위는 1 ~ 10 사이이며 숫자가 높을수록 우선순위가 높다. 쓰레드의 우선 순위는 생성한 쓰레드로부터 상속 받는다. main 메서드를 수행하는 쓰레드는 우선순위가 5이므로 main 메서드 내에서 생성하는 쓰레드의 우선순위는 자동적으로 5가 된다.

public class ThreadPriorityExample {
    public static void main(String[] args) {
        ThreadPriorityExample_1 t1 = new ThreadPriorityExample_1();
        ThreadPriorityExample_2 t2 = new ThreadPriorityExample_2();

        t2.setPriority(7);

        System.out.println("Priority of t1 (-) : " + t1.getPriority());
        System.out.println("Priority of t2 (|) : " + t2.getPriority());
        t1.start();
        t2.start();
    }
}

class ThreadPriorityExample_1 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print("-");
            for (int x = 0; x < 10_000_000; x++) ;
        }
    }
}

class ThreadPriorityExample_2 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print("|");
            for (int x = 0; x < 10_000_000; x++) ;
        }
    }
}

t1과 t2 모두 main 메서드에서 생성하였기 때문에 우선순위 5를 상속 받는다. 그 다음에 t2의 우선순위를 7로 지정하여 각 start()를 호출하여 쓰레드를 실행시켰다.

우선순위가 같은 경우 각 쓰레드에게 거의 같은 양의 실행시간이 주어지지만 우선순위가 다르다면 우선수위가 높은 쓰레드에게 상대적으로 더 많은 양의 실행시간이 주어지고 결과적으로 더 빨리 작업을 완료할 수 있다. 하지만 멀티코어에서는 쓰레드의 우선순위에 따른 차이가 전혀 없었다.

그저 쓰레드에 높은 우선순위를 주면 더 많은 실행시간과 실행기회를 갖게 될 것이라고 기대할 수 없는 것이다.

멀티코어라 해도 OS마다 다른 방식으로 스케줄링하기 때문에 어떤 OS에서 실행하느냐에 따라 다른 결과를 얻을 수 있다. 굳이 우선순위에 차등을 두어 쓰레드를 실행하려면 특정 OS의 스케줄링 정책과 JVM의 구현을 직접 확인해봐야 한다. 자바는 쓰레드가 우선순위에 따라 어떻게 다르게 처리되어야 하는지 강제하지 않으므로 쓰레드의 우선순위와 관련된 구현이 JVM마다 다를 수 있기 때문이다.

만일 확인하나 하더라도 OS의 스케줄러에 종속적이라서 어느 정도 예측만 가능한 정도일 뿐 정확히 알 수는 없다.

쓰레드 그룹 (Thread Group)

스레드 그룹은 서로 관련된 쓰레드를 그룹으로 다루기 위한 것으로, 폴더를 생성해서 관련된 파일들을 함께 넣어서 관리하는 것처럼 쓰레드 그룹을 생성해서 쓰레드를 그룹으로 묶어서 관리할 수 있다.

사실 쓰레드 그룹은 보안상의 이유로 도입된 개념으로 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경할 수 있지만 다른 쓰레드 그룹의 쓰레드는 변경할 수는 없다.

ThreadGroup 을 사용해서 생성할 수 있으며 주요 생성자와 메서드는 다음과 같다.

생성자 / 메서드설명
ThreadGroup(String name)지정된 이름의 새로운 쓰레드 그룹을 생성
ThreadGroup(ThreadGroup parent, String name)지정된 쓰레드 그룹에 포함되는 새로운 쓰레드 그룹을 생성
int activeCount()쓰레드 그룹에 포함된 활성상태에 있는 쓰레드의 수를 반환
int activeGroupCount()쓰레드 그룹에 포함된 활성상태에 있는 쓰레드 그룹의 수를 반환
void checkAccess()현재 실행중인 쓰레드가 쓰레드 그룹을 변경할 권하니 있는지 체크. 만일 권한이 없다면 SecurityException을 발생시킨다.
void destroy()쓰레드 그룹과 하위 쓰레드 그룹까지 모두 삭제한다. 단, 쓰레드 그룹이나 하위 쓰레드 그룹이 비어져 있어야한다.
int enumerate(Thread[] list)
int enumerate(Thread[] list, boolean recurse)
int enumerate(ThreadGroup[] list)
int enumerate(ThreadGroup[] list, boolean recurse)
쓰레드 그룹에 속한 쓰레드 또는 하위 쓰레드 그룹의 목록을 지정된 배열에 담고 그 개수를 반환.
두 번째 매개변수인 recurse의 값을 true로 하면 쓰레드 그룹에 속한 하위 쓰레드 그룹에 쓰레드 또는 쓰레드 그룹까지 배열에 담는다.
int getMaxPriority()쓰레드 그룹의 최대우선순위를 반환
String getName()쓰레드 그룹의 이름을 반환
ThreadGroup getParent()쓰레드 그룹의 상위 쓰레드그룹을 반환
void interrupt()쓰레드 그룹에 속한 모든 쓰레드를 interrupt()
boolean isDaemon()쓰레드 그룹이 데몬 쓰레드그룹인지 확인
boolean isDestroyed()쓰레드 그룹이 삭제 되었는지 확인
void list()쓰레드 그룹에 속한 쓰레드와 하위 쓰레드그룹에 대한 정보를 출력
boolean parentOf(ThreadGroup g)지정된 쓰레드 그룹의 상위 쓰레드그룹인지 확인
void setDaemon(boolean daemon)쓰레드 그룹을 데몬 쓰레드 그룹으로 설정 / 해제
void setMaxPriority(int pri)쓰레드 그룹의 최대 우선 순위를 설정

모든 쓰레드는 반드시 쓰레드 그룹에 포함되어야 하기 때문에 쓰레드 그룹을 지정하지 않은 쓰레드는 기본적으로 자신을 생성한 쓰레드와 같은 쓰레드 그룹에 속하게 된다.

  • 예제 - 쓰레드 그룹과 쓰레드를 생성하고 main.list()를 호출해서 main 쓰레드 그룹의 정보를 출력
package week_10.threadGroup;

public class ThreadGroupExample {
    public static void main(String[] args) {
        ThreadGroup main = Thread.currentThread().getThreadGroup();
        ThreadGroup grp1 = new ThreadGroup("Group1");
        ThreadGroup grp2 = new ThreadGroup("Group2");

        // ThreadGroup(ThreadGroup parent, String name)
        ThreadGroup subGrp1 = new ThreadGroup(grp1, "SubGroup1");

        grp1.setMaxPriority(3);     // 쓰레드 그룹 grp1의 최대 우선순위를 3으로 지정

        Runnable r = new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                }
            }
        };

        // Thread(ThreadGroup tg, Runnable r, String name)
        new Thread(grp1, r, "th1").start();
        new Thread(subGrp1, r, "th2").start();
        new Thread(grp2, r, "th3").start();

        System.out.println(">> List of ThreadGroup : " + main.getName() + ", Active ThreadGroup : " + main.activeGroupCount() + ", Active Thread : " + main.activeCount());
        main.list();
    }
}
> 실행 결과

>> List of ThreadGroup : main, Active ThreadGroup : 3, Active Thread : 5
java.lang.ThreadGroup[name=main,maxpri=10]
    Thread[main,5,main]
    Thread[Monitor Ctrl-Break,5,main]
    java.lang.ThreadGroup[name=Group1,maxpri=3]
        Thread[th1,3,Group1]
        java.lang.ThreadGroup[name=SubGroup1,maxpri=3]
            Thread[th2,3,SubGroup1]
    java.lang.ThreadGroup[name=Group2,maxpri=10]
        Thread[th3,5,Group2]

Main 쓰레드

프로그램이 실행되기 위해서는 작업을 수행하는 쓰레드가 최소한 하나가 필요하다. 그래서 JVM은 실행시 쓰레드를 생성하는데 이 쓰레드가 main()을 호출하면 작업이 수행되도록 하는 것이다.

package week_10.main;

class ThreadEx2 {
    public static void main(String[] args) throws Exception {
        ThreadEx2_1 t1 = new ThreadEx2_1();
        t1.start();
    }
}

class ThreadEx2_1 extends Thread {
    @Override
    public void run() {
        throwException();
    }

    public void throwException() {
        try {
            throw new Exception();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
>> 실행결과

java.lang.Exception
	at week_10.main.ThreadEx2_1.throwException(ThreadEx2.java:18)
	at week_10.main.ThreadEx2_1.run(ThreadEx2.java:13)

Process finished with exit code 0

호출스택의 첫 번째 메서드가 main 메서드가 아니라 run 메서드이다. main 쓰레드의 호출스택이 없는 것은 main쓰레드가 이미 종료되었기 때문이다.

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

package week_10.main;

class ThreadEx3 {
    public static void main(String[] args) throws Exception{
        ThreadEx3_1 t1 = new ThreadEx3_1();
        t1.run();
    }
}

class ThreadEx3_1 extends Thread {
    @Override
    public void run() {
        throwException();
    }

    public void throwException() {
        try {
            throw new Exception();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
>> 실행결과

java.lang.Exception
	at week_10.main.ThreadEx3_1.throwException(ThreadEx3.java:18)
	at week_10.main.ThreadEx3_1.run(ThreadEx3.java:13)
	at week_10.main.ThreadEx3.main(ThreadEx3.java:6)

Process finished with exit code 0

단순히 run()이 호출되었기 때문에 쓰레드가 생성되지 않았다.

데몬 쓰레드

데몬 쓰레드는 다른 일반 쓰레드의 작업을 보조하는 역할을 가진 쓰레드이다. 일반 쓰레드가 모두 종료된다면 데몬 쓰레드는 강제적으로 작업을 종료한다. 일반 쓰레드가 종료되는 순간 데몬 쓰레드 또한 쓸모없어지기 때문에 종료시킨다.

  • 데몬 쓰레드 생성

    먼저 쓰레드를 생성한 뒤, 해당 쓰레드를 실행하기 전에 setDaemon(true)를 호출하여 데몬 쓰레드로 세팅한다.

boolean isDaemon();		// 쓰레드가 데몬 쓰레드인지 확인한다.
void setDaemon(boolean on); 	// 쓰레드를 데몬 쓰레드 또는 일반 쓰레드로 변경, true면 데몬 쓰레드
  • 활용예시

자바 실행 시 JVM은 GC, 이벤트 처리, 그래픽 처리와 같은 프로그램이 실행되는데, 필요한 보조 작업을 수행하는 쓰레드들은 데몬 쓰레드이며 자동적으로 생성되어서 실행되는 구조이다.

동기화

한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 쓰레드의 동기화라고 한다.

멀티쓰레드 프로세스의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 된다. 이러한 일이 발생하는 것을 막기 위해서 한 쓰레드가 특정 작업을 마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 하는 것이 필요한데, 이를 위해 도입된 개념이 임계 영역(critical section)과 잠금(lock)이다.

synchronized 를 이용한 동기화

  • 이 키워드(synchronized)는 임게 영역을 설정하는데 필요하다. 방법은 두가지 방식이 있다.
// 1. 메서드 전체를 임계 영역으로 설정
public synchronized void calcSum() {		// 임계영역
    // ...
}

// 2. 특정한 영역을 임계 영역으로 지정 
synchronized(객체의 참조변수) {				// 임계 영역
    // ...
}
  1. 은 메서드 앞에 synchronized를 붙이는 것인데, synchronized를 붙이면 메서드 전체가 임계 영역으로 설정된다. 쓰레드는 synchronized 메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환한다.
  2. 는 메서드 내의 코드 일부를 블럭 {}으로 감싸고 블럭 앞에 synchronized(참조변수)를 붙이는 것인데, 이때 참조변수는 락을 걸고자하는 객체를 참조하는 것이어야 한다. 이 블럭을 synchronized 블럭이라고 부르며, 이 블럭의 영역 안으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게 되고 이 블럭을 벗어나면 lock을 반납한다.

모든 객체는 lock을 하나씩 가지고 있으며 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있다. 그리고 다른 쓰레드들은 lock을 얻을 때까지 기다리게 된다. 임계 영역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 전체보단 임계영역을 최소화해서 효율적인 프로그램이 되도록 노력해야 한다.

또한 synchronized가 붙은 임계영역 내에서 사용되는 인스턴스 변수의 접근 제어자는 private이 아니면 안된다. 외부에서 접근이 가능하기 때문에 동기화로 막아놓는다고 해도 값이 변하기 때문이다.

wait()과 notify()

synchronized를 사용해서 공유 데이터를 보호하는 것 까지는 좋은데 특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것도 중요하다.

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

나중에 작업을 진행할 수 있는 상황이 되면 notify()를 호출하여 작업을 중단했던 쓰레드가 다시 락을 얻어 작업을 진행할 수 있게 된다.

다만 작업을 오래 기다린 쓰레드가 락을 얻는다는 보장이 없다는 것이다.

wait()은 notify() || notifyAll()이 호출될 때까지 기다리지만, 매개변수가 있는 wait()은 지정된 시간 동안만 기다린다. notifyAll()은 호출되면 waiting pool에 모든 객체가 깨어나는 것이 아니라 waiting pool에 대기중인 쓰레드만 깨워진다는 것을 기억하자.

  • wait(), notify(), notifyAll()
    1. Object에 정의되어 있다.
    2. 동기화 블록내에서만 사용할 수 있다.
    3. 보다 효율적인 동기화를 가능하게 한다.
  • 음식점 예제 - 소비자
package week_10.waitAndNotify;

public class Customer implements Runnable {
    private Table table;
    private String food;

    public Customer(Table table, String food) {
        this.table = table;
        this.food = food;
    }
    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
            }
            String name = Thread.currentThread().getName();

            if(eatFood()) {
                System.out.println(name + " : ate a " + food);
            } else {
                System.out.println(name + " failed to eat. :(");
            }
        }
    }

    private boolean eatFood() {
        return table.remove(food);
    }
}
  • 음식점 예제 - 요리사
package week_10.waitAndNotify;

public class Cook implements Runnable {
    private Table table;

    public Cook(Table table) {
        this.table = table;
    }

    @Override
    public void run() {
        while (true) {
            int idx = (int) (Math.random() * table.dishNum());
            table.add(table.dishNames[idx]);

            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
            }
        }
    }
}
  • 음식점 예제 - 테이블
package week_10.waitAndNotify;

import java.util.ArrayList;

public class Table {
    String[] dishNames = {"donut", "donut", "burger"};
    final int MAX_FOOD = 6;

    private ArrayList<String> dishes = new ArrayList<>();

    public void add(String dish) {
        if (dishes.size() >= MAX_FOOD) {
            return;
        }
        dishes.add(dish);
        System.out.println("Dishes : " + dishes.toString());

    }

    public boolean remove(String dishName) {
        for (int i = 0; i < dishNames.length; i++) {
            if (dishName.equals(dishes.get(i))) {
                dishes.remove(i);
                return true;
            }
        }
        return false;
    }
    public int dishNum() {
        return dishNames.length;
    }
}
  • 음식점 예제 - 실행 (예외 발생)
package week_10.waitAndNotify;

public class ThreadWaitExample_1 {
    public static void main(String[] args) {
        Table table = new Table();

        new Thread(new Cook(table), "COOK1");
        new Thread(new Customer(table, "donut"), "CUST1").start();
        new Thread(new Customer(table, "burger"), "CUST2").start();

        try {
            Thread.sleep(100);
            System.exit(0);
        } catch (Exception e) {
        }
    }
}
  • 실행결과
Exception in thread "CUST1" Exception in thread "CUST2" java.lang.IndexOutOfBoundsException: Index: 0, Size: 0
	at java.util.ArrayList.rangeCheck(ArrayList.java:659)
	at java.util.ArrayList.get(ArrayList.java:435)
	at week_10.waitAndNotify.Table.remove(Table.java:22)
	at week_10.waitAndNotify.Customer.eatFood(Customer.java:29)
	at week_10.waitAndNotify.Customer.run(Customer.java:20)
	at java.lang.Thread.run(Thread.java:750)
java.lang.IndexOutOfBoundsException: Index: 0, Size: 0
	at java.util.ArrayList.rangeCheck(ArrayList.java:659)
	at java.util.ArrayList.get(ArrayList.java:435)
	at week_10.waitAndNotify.Table.remove(Table.java:22)
	at week_10.waitAndNotify.Customer.eatFood(Customer.java:29)
	at week_10.waitAndNotify.Customer.run(Customer.java:20)
	at java.lang.Thread.run(Thread.java:750)

테이블의 마지막 음식을 가져가는 도중에 다른 손님이 낚아채갔기 때문에 IndexOutOfBoundsException이 발생한다.

또한 위 실행결과에는 없지만, 요리사가 음식을 만들기도 전에 음식을 가져가려고 하기 때문에 ConcurrentModificationException이 발생한다.

위 문제를 해결하기 위해서 synchronized 를 통해 동기화를 추가한다.

  • 음식점 예제 - 테이블(동기화 추가)
package week_10.waitAndNotify;

import java.util.ArrayList;

public class Table {
    String[] dishNames = {"donut", "donut", "burger"};
    final int MAX_FOOD = 6;

    private ArrayList<String> dishes = new ArrayList<>();

    public synchronized void add(String dish) {
        if (dishes.size() >= MAX_FOOD) {
            return;
        }
        dishes.add(dish);
        System.out.println("Dishes : " + dishes.toString());

    }

    public boolean remove(String dishName) {
        synchronized (this) {
            while (dishes.size() == 0) {
                String name = Thread.currentThread().getName();
                System.out.println(name + " is waiting");
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {

                }
            }
            for (int i = 0; i < dishNames.length; i++) {
                if (dishName.equals(dishes.get(i))) {
                    dishes.remove(i);
                    return true;
                }
            }
            return false;
        }

    }

    public int dishNum() {
        return dishNames.length;
    }
}
  • 실행결과
CUST2 is waiting
CUST2 is waiting
CUST2 is waiting
CUST2 is waiting
CUST2 is waiting
CUST2 is waiting
CUST2 is waiting
CUST2 is waiting
CUST2 is waiting
CUST2 is waiting

예외는 발생하지 않지만, 결과가 원활하지 않다.

요리사 쓰레드가 음식을 추가하지 않고 손님 쓰레드를 기다리는 이유는 손님 쓰레드가 테이블 객체의 lock을 쥐고 있기 때문이다. 이럴때 사용하는 것이 wait() & notify() 이다.

  • 음식점 예제 - 테이블(wait(), notify()) 추가
package week_10.waitAndNotify;

import java.util.ArrayList;

public class Table {
    String[] dishNames = {"donut", "donut", "burger"};
    final int MAX_FOOD = 6;

    private ArrayList<String> dishes = new ArrayList<>();

    public synchronized void add(String dish) {
        String name = Thread.currentThread().getName();
        System.out.println(name + " is waiting");

        try {
            wait();
            Thread.sleep(500);
        } catch (InterruptedException e) {
        }
        dishes.add(dish);
        notify();
        System.out.println("Dishes : " + dishes.toString());

    }

    public boolean remove(String dishName) {
        synchronized (this) {
            String name = Thread.currentThread().getName();
            while (dishes.size() == 0) {
                System.out.println(name + " is waiting");
                try {
                    wait();
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                }
            }
            while (true) {
                for (int i = 0; i < dishNames.length; i++) {
                    if (dishName.equals(dishes.get(i))) {
                        dishes.remove(i);
                        notify();
                    }
                }
                try {
                    System.out.println(name + " is waiting");
                    wait();
                    Thread.sleep(500);

                } catch (InterruptedException e) {

                }
            }
        }
    }

    public int dishNum() {
        return dishNames.length;
    }
}

이전 예제에 wait(), notify()를 추가하였다.

하지만 아직 한가지 문제가 있다. 요리사와 손님 쓰레드가 같이 기다다는 것이다. 그래서 notify()가 호출 되었을 때, 요리사 쓰레드와 손님 쓰레드 중에서 누가 통지 받을지 알 수 없다.

기아 현상과 경쟁 상태

  • 기아 현상

    : 쓰레드가 계속 통지를 받지 못하고 오랫동안 기다리게 되는 것을 말한다. 이럴 때는 모든 쓰레드에게 통지를 할 수 있게끔 notify대신 notifyAll()을 사용한다.

  • 경쟁 상태

    : lock을 얻기 위해 서로 경쟁하는 것.

이럴 때, 선별적인 통지를 가능하게끔 lock과 Condition을 사용한다.

Lock과 Condition을 이용한 동기화

JDK1.5에 와서 java.util.concurrent.locks패키지가 제공하는 lock클래스들을 사용할 수 있다.

synchronized블럭으로 동기화를 하면 자ㅓ동적으로 lock이 잠기고 풀리기 때문에 편리하다. 심지어 synchronized 블럭 내에서 예외가 발생해도 lock은 자동으로 풀린다. 그러나 떄로는 같은 메서드 내에서만 lock을 걸 수 있다는 제약이 불편하기도 하다. 그럴 때 이 lock 클래스를 사용한다.

Reentrant - 재진입할 수 있는

  • ReentrantLock - 재진입이 가능한 lock, 가장 일반적인 배타 lock

    reentrant라는 단어가 붙은 이유는 특정 조건에서 lock을 풀고 나중에 다시 lock을 얻고 임계영역으로 들어와서 이후의 작업을 수행할 수 있기 떄문이다.

  • ReentrantReadWriteLock - 읽기에는 공유적이고, 쓰기에는 배타적인 lock

    읽기를 위한 lock과 쓰기를 위한 lock을 제공한다. 읽기 lock이 걸려있으면 다른 쓰레드가 읽기 lock을 중복해서 걸고 읽기를 수행할 수 있다. 읽기는 내용을 변경하지 않으므로 동시에 여러 쓰레드가 읽어도 문제가 되지 않는다. 그러나 읽기 lock이 걸린 상태에서 쓰기 lock을 거는 것은 허용되지 않는다. 반대의 경우 또한 동일하다.

  • StampedLock - ReentrantReadWriteLock에 낙관적인 lock의 기능을 추가

    lock을 걸거나 해지할 때 stamp(long 타입의 정수값)를 사용하며, 읽기와 쓰기를 위한 lock외에 낙관적 읽기(optimistic reading lock)이 추가된 것이다. 읽기 lock이 걸려 있으면 쓰기 lock을 얻기 위해서는 읽기 lock이 풀릴 때까지 기다려야 하는데 비해 낙관적 읽기 lock은 쓰기 lock에 의해 바로 풀린다.

    낙관적 읽기에 실패하면 다시 읽기 lock을 얻어서 다시 읽어 와야 한다. 무조건 읽기 lock을 걸지 않고 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 거는 것이다.

  • 예제 - StampedLock

int getBalance() {
	long stamp = lock.tryOptimisticRead(); // 낙관적 읽기 lock을 건다. 
    
    int curBalance = this.balance; // 공유 데이터인 balance를 읽어온다.
    
    if(!lock.validate(stamp)) {	// 쓰기 lock에 의해 낙관적 읽기 lock이 풀렸는지 확인
        stamp = lock.readLock();	// lock이 풀렸으면, 읽기 lock을 얻으려고 기다린다.
        
        try {
            curBalance = this.balance; 	// 공유 데이터를 다시 읽어온다.
        } finally {
            lock.unlockRead(stamp);	// 읽기 lock을 푼다.
        }
    }
    return curBalance;	// 낙관적 읽기 lock이 풀리지 않았으면 곧바로 읽어온 값을 반환
}
  • ReentrantLock의 생성자
ReentrantLock()
ReentrantLock(boolean fair)

생성자의 매개변수를 true로 주면, lock이 풀렸을 때 가장 오래 기다린 쓰레드가 lock을 획득할 수 있게, 즉 공정하게 처리한다. 어떤 쓰레드가 가장 오래기다렸는지 확인해야하므로 성능은 떨어진다.

void lock();
void unlock();
boolean isLocked();

ReentrantLock과 같은 lock 클래스들은 수동으로 lock을 잠그고 해제해야 한다. 방법은 간단하다. 메서드를 호출하면 된다. 임계 영역 내에서 예외가 발생하거나 return문으로 빠져나가게 되면 lock이 풀리지 않을 수 있으므로 try-finally문으로 감싸는게 일반적이다.

이외에도 tryLock()이라는 메서드가 있는데, 이 메서드는 lock()과 달리, 다른 쓰레드에 의해 lock이 걸려있으면 lock을 얻으려고 기다리지 않는다. 또는 지정된 시간만큼만 기다린다. lock을 얻으면 true, 아니면 false를 리턴한다.

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

응답성이 중요한 경우 tryLock()을 사용하여 지정된 시간 동안 lock을 얻지 못하면 다시 작업을 시도할지, 포기할지를 사용자가 결정하게 하는 것이 좋다.

  • ReentrantLock과 Condition

wait() & notify() 의 단점을 해결하기 위한 것.

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

private ReentrantLock lock = new ReentrantLock();	// lock을 생성
private Condition forCook = new lock.Condition();
private Condition forCust = new lock.Condition();
  • wait() & notify()와 await() & signal()의 비교
ObjectCondition
void wait()void await()
void awaitUninterruptibly()
void wait(long timeout)boolean await(long time, TimeUnit unit)
long awaitNanos(long nanosTimeout)
void notify()void signal()
void notifyAll()void signalAll()
  • 예제
package week_10.status;

import java.util.ArrayList;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class AwaitConditionExample {
}

class Customer implements Runnable {

    private Table table;
    private String food;

    Customer(Table table, String food) {
        this.table = table;
        this.food = food;
    }

    @Override
    public void run() {
        while (true) {
            int idx = (int) (Math.random() * table.dishNum());
            table.add(table.dishNames[idx]);
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
            }
        }
    }
}

class Table {
    String[] dishNames = {"donut", "donut", "burger"};
    final int MAX_FOOD = 6;
    private ArrayList<String> dishes = new ArrayList<>();

    private ReentrantLock lock = new ReentrantLock();
    private Condition forCook = lock.newCondition();
    private Condition forCust = lock.newCondition();

    public void add(String dish) {
        lock.lock();
        try {
            while (dishes.size() >= MAX_FOOD) {
                String name = Thread.currentThread().getName();
                System.out.println(name + " is waiting.");
                try {
                    forCook.await();
                    Thread.sleep(500);

                } catch (InterruptedException e) {
                }
            }
            dishes.add(dish);
            forCust.signal();
            System.out.println("Dishes:" + dishes.toString());
        } finally {
            lock.unlock();
        }
    }

    public void remove(String dishName) {
        lock.lock();    // synchronized(this) {
        String name = Thread.currentThread().getName();

        try {
            while (dishes.size() == 0) {
                System.out.println(name + "is waiting.");
                try {
                    forCust.await();    // wait(); CUST 쓰레드를 기다리게 한다.
                } catch (InterruptedException e) {
                }
            }
            while (true) {
                for (int i = 0; i < dishes.size(); i++) {
                    if (dishName.equals(dishes.get(i))) {
                        dishes.remove(i);
                        forCook.signal();       // notify(); 잠자고 있는 COOK을 깨움
                        return;
                    }
                }

                try {
                    System.out.println(name + "is waiting.");
                    forCust.await();
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                }
            }
        } finally {
            lock.unlock();
        }
    }

    public int dishNum() {
        return dishNames.length;
    }
}

class Cook implements Runnable {
    private Table table;

    public Cook(Table table) {
        this.table = table;
    }

    @Override
    public void run() {
        while (true) {
            int idx = (int) (Math.random() * table.dishNum());
            table.add(table.dishNames[idx]);

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

class TreadWaitEx4 {
    public static void main(String[] args) throws InterruptedException {
        Table table = new Table();
        new Thread(new Cook(table), "COOK1").start();
        new Thread(new Customer(table, "donut"), "CUST1").start();
        new Thread(new Customer(table, "burger"), "CUST2").start();

        Thread.sleep(2000);
        System.exit(0);
    }
}

volatile

Java변수를 Main Memory에 저장하겠다라는 것을 명시하는 것이다. 매번 변수의 값을 읽어올 때 Cache Memory가 아닌 Main Memory에서 읽는다. 또한 변수의 값을 Write할 때 Main Memory에 까지 작성한다.

코어는 메모리에서 읽어온 값을 캐시에 저장하고 캐시에서 값을 읽어서 작업한다. 다시 같은 값을 읽어올 때는 먼저 캐시에 있는지 확인하고 없을 때만 메모리에서 읽어온다. 그러다보니 도중에 메모리에 저장된 변수의 값이 변경되었는데도 캐시에 저장된 값이 갱신되지 않아서 메모리에 저장된 값이 다른 경우가 발생한다. 그래서 변수 stopped의 값이 바뀌었는데도 쓰레드가 멈추지 않고 계속 실행되는 것이다.

boolean suspend = false;
boolean stopped = false;
// ->
volatile boolean suspend = false;
volatile boolean stopped = false;

volatile을 붙히면 코어의 변수가 값을 읽어올 때 캐시가 아닌 메모리에서 읽어온다. 따라서 캐시와 메모리간의 값의 불일치가 해결된다.

실제로 캐시와 메모리에서 값을 읽어오는 과정은 더 복잡하다.

synchronized블럭을 사용하는 것도 같은 효과를 낼 수 있다. 쓰레드가 synchronized 블록으로 들어갈 때와 나올 때, 캐시와 메모리간의 동기화가 이뤄지기 때문이다.

  • volatile로 long과 double을 원자화

volatile은 해당 변수에 대한 읽거나 쓰기가 원자화 된다.

원자화란 작업을 더 이상 나눌 수 없다는 뜻이다, volatile로 처리하는 원자화는 synchronized처럼 동기화하는 것은 아니다.

JVM이 데이터를 처리하는 단위는 4Byte이기 때문에 int형과 그보다 작은 타입들은 한 번에 읽거나 쓰는 것이 가능하다. 반면에 크기가 8Byte인 long과 double 타입의 변수는 하나의 명령어로 값을 읽거나 쓸 수 없기 때문에 변수의 값을 읽는 과정에 다른 쓰레드가 끼어들 여지가 있다. 다른 쓰레드가 끼어들지 못하게 하려고 변수를 읽고 쓰는 모든 과정을 volatile로 해결할 수 있다.

volatile long sharedVal;	// long타입의 변수(8byte)를 원자화
volatile double sharedVal;	// double타입의 변수(8byte)를 원자화

// 예제

volatile long balance; 	// 인스턴스 변수 balance를 원자화한다.

synchronized long getBalance() {	// balance의 값을 반환한다.
    return balance;
}

synchronized void withDraw(int money) {
    if(balance > money) {
        balance -= money;
	}
}

balance를 volatile로 원자화 했으니까 getBalance를 동기화 할 필요가 없다고 생각할 수 있다. 그러나 getBalance를 동기화 하지 않으면 withDraw()가 호출되어 객체에 lock을 걸어도 getBalance()가 호출되는 것이 가능해진다. 따라서 getBalance()는 synchronized로 동기화를 해야한다.

fork & join 프레임워크

시대가 빠르게 변하면서 멀티 코어를 잘 활용할 수 있는 멀티 쓰레드 프로그래밍이 점점 더 중요해지고 있다. 하지만 그리 쉽지는 않다.

JDK1.7부터는 fork&join 프레임워크가 추가 되었고 이 프로그램은 하나의 작업은 작게 나누어 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어준다.

  • 수행할 작업에 따라 두 클래스 중에서 하나를 상속받아 구현해야 한다.
RecusiveAction // 반환값이 없는 작업을 구현할 때 사용
RecursiveTask // 반환값이 있는 작업을 구현할 때 사용

두 클래스 모두 compute()라는 추상 메서드를 가지고 있는데, 우리는 상속을 통해 이 추상 메서드를 구현하기만 하면 된다.

public abstract class RecusiveAction extends ForkJoinTask<Void> {
    ...
    protected abstract void compute(); // 상속을 통해 이 메서드를 구현해야 한다.
    ...
}

public abstract class RecursiveTask<V> extends ForkJoinTask<V> {
    ...
    V result;
    protected abstract V compute();	// 상속을 통해 이 메서드를 구현해야 한다.
}

1부터 n까지의 합을 계산한 결과를 돌려주는 작업의 구현은 다음과 같이 한다.

public class SumTask extends RecursiveTask<Long> {  // RecursiveTask 를 상속 받는다.
    long from, to;

    SumTask(long from, long to) {
        this.from = from;
        this.to = to;
    }

    @Override
    protected Long compute() {
        long result = 0;
        // 처리할 작업을 수행하기 위한 문장을 넣는다.
        for (from = 1; from < to; from++) {
            result += from;
        }
        return result;
    }
}

그 다음에는 쓰레드풀과 수행할 작업을 생성하고 invoke()로 작업을 시작한다. 쓰레드를 시작할 때 run()이 아니라 start()를 호출하는 것처럼 fork&join프레임워크로 수행할 작업도 compute()가 아닌 invoke()로 시작한다.

ForkJoinPool pool = new ForkJoinPool();
SumTask task = new SumTask(from, to);
long result = pool.invoke(task);
  • ForkJoinPool 장점

    1. 프레임워크에서 제공하는 쓰레드 풀로 지정된 수의 쓰레드를 미리 만들어 놓고 재사용할 수 있게 해준다.
    2. 쓰레드를 반복해서 생성하지 않아도 된다
    3. 너무 많은 쓰레드가 생성되어 성능이 저하되는 것을 막아준다
  • 쓰레드 풀은 쓰레드가 수행해야하는 작업이 담긴 큐를 제공하며, 각 쓰레드는 자신의 작업 큐에 담긴 작업을 순서대로 처리한다.

  • compute() 구현

compute()를 구현할 때는 수행할 작업 외에도, 작업을 어떻게 나눌 것인가에 대해서도 알려줘야 한다.

public Long compute() {
    long size = to - from + 1; // from <= 1 <= to
    
    if(size <= 5) {	// 더할 숫자가 5 이하면
        return sum(); // 숫자의 합을 반환. sum()은 from 부터 to 까지의 수를 더해서 반환        
    }
    // 범위를 반으로 나눠서 두 개의 작업을 생성
    long half = (from+to) / 2;
    
    SumTask leftSum = new SumTask(from, half);
    SumTask rightSum = new SumTask(half + 1, to);
    
    leftSum.fork(); // 작업을 큐에 넣는다.
    
    return rightSum.compute() + leftSum.join();
}

실제 수행한 작업은 sum() 뿐이고, 나머지는 수행할 작업의 범위를 반으로 나눠서 새로운 작업을 생성해서 실행시키기 위한 것이다. 이 과정은 작업이 더 이상 나눠질 수 없을 때까지 size의 값이 5보다 작거나 같을 때까지 반복된다.

이 그림에서는 작업의 size가 2가 될 때까지 나눈다. compute()가 처음 호출되면 더할 숫자의 범위를 반으로 나눠서 한 쪽에는 fork()를 호출해서 작업 큐에 저장한다. 하나의 쓰레드는 compute()를 재귀호출하면서 작업을 계속해서 반으로 나누고 다른 쓰레드는 fork()에 의해 작업 큐에 추가된 작업을 수행한다.

  • 다른 쓰레드의 작업 훔쳐오기

fork()가 호출되어 작업 큐에 추가된 작업 역시 compute()에 의해 더 이상 나눌 수 없을 때까지 반복해서 나뉘고, 자신의 작업 큐가 비어있는 쓰레드는 다른 쓰레드의 작업 큐에서 작업을 가져와서 수행한다. 이것을 작업 훔쳐오기라고 한다. 이 과정은 모두 쓰레드풀에 의해 자동적으로 이루어진다.

작업 큐가 비어있는 쓰레드가 다른 쓰레드의 작업을 가져와서 수행하는 것을 그런 것이다. 이런 과정을 통해 한 쓰레드에 작업이 몰리지 않고 여러 쓰레드가 골고루 작업을 나누어 처리하게 된다.

  • fork() 와 join()

fork()는 작업을 쓰레드의 작업 큐에 넣는 것이고, 작업 큐에 들어간 작업은 더 이상 나눌 수 없을 때까지 나뉜다. 즉 compute()로 나누고 fork()로 작업 큐에 넣는 작업이 계속해서 반복된다. 그리고 나눠진 작업은 각 쓰레드가 골고루 나눠서 처리하고 작업의 결과는 join()을 호출해서 얻을 수 있다.

fork()와 join()의 차이점은 동기 / 비동기이다.

  • fork() - 해당 작업을 쓰레드 풀의 작업 큐에 넣는다. 비동기메서드
  • join() - 해당 작업의 수행이 끝날 때까지 기다렸다가, 수행이 끝나면 그 결과를 반환한다. 동기 메서드

비동기 메서드는 일반적인 메서드와 달리 메서드를 호출만 할 뿐, 그 결과를 기다리지 않는다. 그래서 fork()를 호출하면 결과를 기다리지 않고 return문으로 넘어간다. return문에서 compute()가 재귀호출될 때, join()은 호출되지 않는다. 그러다가 작업을 더 이상 나눌 수 없게 됐을 때, compute()의 재귀호출은 끝나고 join()의 결과를 기다렸다가 더해서 결과를 리턴한다. 재귀호출된 compute()가 모두 종료될 때 최종 결과를 얻는다.

public Long compute() {
    ...
    SumTask leftSum = new SumTask(from, half); 
    SumTask rightSum = new SumTask(half + 1, to);
    leftSum.fork();		// 비동기 메서드, 호출 후 결과를 기다리지 않는다.
    
    return rightSum.compute() + leftSum.join(); // 동기 메서드. 호출 결과를 기다린다.
}

데드락

2개 이상의 쓰레드가 서로를 기다리며 영원히 blocked된 상태

데드락(교착상태) 은 둘 이상의 쓰레드가 Lock을 획득하기 위해 대기하는데, 이 Lock을 잡고 있는 자원에 서로 다른 쓰레드들이 Lock을 동시에 획득하려고 할 때 발생할 수 있다.

데드락 발생 조건

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

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

0개의 댓글