C++에서 배우는 자바-쓰레딩
가족여행을 갔다오고 이런저런 일들을 하느라 포스팅이 좀 늦었다. 멀티쓰레딩이 무엇인지, 어떤 역할을 하는지 등에 대해서는 과거 포스팅에서 살펴본 적이 있으니, 이번 포스팅에서는 자바에서 멀티쓰레딩 환경의 프로그래밍을 어떻게 처리하는지에 대해 중점적으로 살펴보도록 하겠다.
쓰레드의 구현
java에서 쓰레드 단위의 작업을 구현하기 위해서는 클래스가 Thread 클래스를 상속받거나, Runnable 인터페이스를 구현하는 방법 두 가지가 있다.
둘 다 내부의 run 메서드를 구현하여 사용하는데, 둘의 인스턴스 생성 방법이 다르다.
ThreadEX1_1 thr=new ThreadEX1_1();//Thread 클래스를 상속받은 클래스의 인스턴스 생성
Runnable r=new ThreadEX1_2();//Runnable 인터페이스를 구현한 클래스 인스턴스의 생성
Thread t2=new Thread(r);//Thread 생성자의 매개변수로 넣어 사용한다.
위와 같이 다른 방법으로 생성하게 되는데, Thread 클래스를 상속받는 방법이 더 편해보이지만 java는 단일 상속만을 지원한다는 것을 잊어서는 안된다.
따라서 Thread 클래스를 상속받으면 다른 클래스에 일절 상속을 받지 못하게 되므로 Runnable 인터페이스를 구현하는 것이 더 좋은 방법같다.
쓰레드의 실행은 run()이 아닌 start()로 한다.
ThreadEX1_1 thr=new ThreadEX1_1();//Thread 클래스를 상속받은 클래스의 인스턴스 생성
thr.run();
thr.start();
run() 메서드를 실행시키면 그저 클래스 내부에 구현한 run 메서드가 실행될 뿐이다. start() 메서드로 실행시켜야 제대로 쓰레드 단위의 작업을 시작한다.
참고로 한번 실행이 종료된 쓰레드는 다시 시작될 수 없다. 이미 start()를 통해 실행시킨 쓰레드를 작업이 끝난 후 start()로 다시 실행시킬 경우 IllegalThreadStateException을 반환할 뿐이다. 다시 실행시키길 원한다면 새로운 쓰레드 인스턴스를 만든 후 실행시켜 주어야 한다.
쓰레드의 실행
이전 포스팅에서 배웠듯이 모든 쓰레드는 각자의 call stack을 갖는다. java에서도 start 메서드를 통해 쓰레드를 작동시키면 해당 쓰레드의 새로운 call stack을 생성한 후 내부에 구현된 run 메서드를 call stack 첫번째에 올려 실행한다. 이렇게 여러 쓰레드가 동시에 실행되면 이전 포스팅에서 배웠던 스케줄링 기법들에 따라 순서대로 실행된다.
모든 작업을 완료하고 call stack이 빈 쓰레드는 작동을 중지하고 call stack을 제거한다. 참고로 우리가 main 메서드에서 프로그램을 실행시키는 것 또한 쓰레드이다. main 쓰레드라고 부르는데 main 쓰레드가 종료되면 프로그램이 종료되듯이 모든 쓰레드가 작동을 중지하면 프로그램은 종료된다.
여러 개의 쓰레드로 나누어 멀티쓰레딩을 한다고 무조건 좋은 것은 아니다. 실행하던 쓰레드를 바꿀 때의, context switching을 할 때의 비용이 있기에 잘못 사용하면 싱글 쓰레딩보다 성능이 저하될 수도 있다.
하지만 멀티 코어 환경일 때, 서로 하나의 자원으로 경쟁하지 않을 때 등에 사용하면 cpu를 매우 효율적으로 사용할 수 있다. 예시 코드를 하나 보자.
import javax.swing.JOptionPane;
class ThreadEX7 {
public static void main(String args[]) throws Exception {
ThreadEX7_1 th1=m=new ThreadEX7_1();
th1.start();
String input=JOptionPane.showInputDialog("아무 값이나 입력하세요");
System.out.println("입력하신 값은 " + input + "입니다.");
}
}
class ThreadEX7_1 extends Thread {
public void run() {
for(int i=10; i>0; i--) {
System.out.println(i);
try {
sleep(1000);
} catch(Exception e) {}
}
}
}
숫자를 10부터 1까지 출력하는 작업을 쓰레드에서, main 쓰레드에서는 입력을 받아 출력하는 작업을 하도록 구현하였다.
싱글 쓰레드 환경이었다면 입력을 받을 때까지 기다렸다 숫자가 출력되겠지만 멀티 쓰레딩을 활용하면 main 쓰레드가 I/O 인터럽트를 받아 waiting 상태에 들어가도 숫자를 출력하는 작업은 상관없이 이루어지도록 할 수 있다.
쓰레드의 우선순위
이전 포스팅에서 배운 CPU의 스케줄링 기법 중 우선순위(Priority)가 있던 것을 기억할 것이다.
java에서는 이 쓰레드의 우선순위를 임의로 프로그래머가 정해줄 수가 있다. 쓰레드의 우선순위를 지정하는 메서드는 다음과 같다.
void setPriority(int newPriority)//해당 쓰레드의 우선순위를 변경한다.
int getPriority()//해당 쓰레드의 우선순위를 반환한다.
쓰레드의 기본 우선순위는 5이고(main 쓰레드의 우선순위가 5이고 보통 main 쓰레드 내부에서 생성되므로), 1부터 10까지 있으며 숫자가 클수록 먼저 처리된다.
쓰레드 그룹
여러 쓰레드를 한번에 관리할 수 있는 쓰레드 그룹(Thread Group)이란 기능도 존재한다. 여러 파일을 관리하는 폴더와 비슷한 느낌이다.
쓰레드 그룹의 존재의의는 여러 쓰레드의 관리 이외에 보안 상의 이유도 있다. 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹을 변경할 수는 있지만 다른 그룹의 쓰레드는 변경하지 못하게 함으로써 권한을 조정하는 것이다.
쓰레드 그룹에 관련된 주요 메서드들은 다음과 같다.
ThreadGroup(String name)//생성자
ThreadGroup(ThreadGroup parent,String name)//해당 쓰레드그룹에 속하는 다른 쓰레드그룹을 생성
void list()//쓰레드 그룹에 속한 쓰레드와 하위 쓰레드그룹의 정보들을 출력
void destroy()//쓰레드 그룹과 하위 쓰레드 그룹들을 제거
Thread(ThreadGroup group, String name)//쓰레드 그룹에 속하는 쓰레드 생성자
Thread(ThreadGroup group, Runnable target)
쓰레드 그룹을 활용하면 여러 쓰레드를 관리하며 쓰레드의 목록과 활성화된 개수 등을 알 수 있다.
데몬 쓰레드
데몬 쓰레드(Daemon Thread)는 다른 쓰레드의 작동을 보조하는 쓰레드이다.
java의 가비지 컬렉터, 워드프로세서의 자동저장 등이 데몬 쓰레드의 대표적인 예이다. 데몬 쓰레드는 루프문과 조건문을 통해 waiting 상태에 들어가있다가 프로그램 실행 중 특정 조건을 만족하면 작동한다.
데몬 쓰레드는 일반 쓰레드가 모두 종료되면 강제적으로 자동 종료된다. 데몬 쓰레드는 일반 쓰레드와 생성과 실행방법이 모두 같으며 대신 생성 후 실행하기 전에 setDaemon(true)를 호출하여 데몬 쓰레드로 만들어주면 된다.
데몬 쓰레드가 생성한 쓰레드는 자동으로 데몬 쓰레드가 된다. 그리고 isDaemon() 메서드를 활용하여 해당 메서드가 데몬 메서드인지 확인할 수 있다.
쓰레드의 실행 제어
멀티쓰레딩의 가장 어려운 점은 임계구역에 대한 동기화와 스케줄링이다. java에서는 프로그래머가 쓰레드의 실행을 제어하여 임의로 스케줄링 해줄 수 있다. 쓰레드의 실행 제어에 쓰이는 주요 메서드들은 다음과 같다.
static void sleep(long millis)//**현재** 쓰레드를 지정 시간동안 일시정지시킨다. 시간이 끝나면 waiting상태가 된다.
void join()//해당 쓰레드를 지정된 시간동안 실행되게 한다. 시간이 끝나면 원래 쓰레드로 다시 돌아와 실행을 계속한다.
void interrupt()//해당 쓰레드에 InterruptedException을 발생시켜 sleep이나 join 등에 의해 일시정지된 쓰레드를 다시 작동하게 한다.
static void yield()//실행 중 자신에게 주어진 작동 시간을 다른 쓰레드에 양보하고 자신은 waiting 상태에 들어간다.
이외에도 stop(), resume(), suspend() 등의 메서드가 있지만 데드락을 발생시킬 가능성이 커 deprecated 되었기에 서술하지 않았다.
java에서는 쓰레드의 상태를 다음과 같이 구분한다.
- NEW : 쓰레드가 생성된 후 start() 되지 않은 상태
- RUNNABLE : 쓰레드가 실행 가능한 상태
- BLOCKED : 동기화 블럭 등에 의해 LOCK을 얻지 못해 대기하는 상태
- WAITING : 쓰레드의 작업이 종료되지 않았지만 I/O인터럽트 등의 이유로 실행가능하지 않은 일시정지 상태
- TERMINATED : 쓰레드의 작업이 종료된 상태
CPU의 스케줄링에서 배운 상태들과 매우 유사하면서도 조금 다르다. 쓰레드의 작동 순서는 다음과 같다.- 쓰레드는 생성된 후 start() 메서드를 통해 실행시키면 실행 대기열에 저장되어 RUNNABLE 상태가 된다.
- 자신의 차례가 오면 실행되다 시간이 다되면, 혹은 yield를 사용하면 정지되어 다시 RUNNABLE 상태로 들어간다.
- 실행 도중 sleep, join 등으로 인해 일시정지가 되면 WAITING 상태가 되고 이를 빠져나오면 다시 RUNNABLE로 들어간다.
- 작업이 종료 시 TERMINATED 상태가 되어 call stack을 제거하고 소멸한다.
이제 쓰레드의 실행 제어에 쓰이는 각 메서드에 대해 알아보자.
sleep
현재 메서드를 일정 시간동안 일시정지시킨다.
항상 현재 메서드에 대해 작동하기 때문에 main 쓰레드에서 다른 쓰레드의 참조변수에 sleep()을 호출한다 해도 영향은 main 쓰레드가 받게 된다.
따라서 보통 Thread.sleep()과 같이 쓰는 것을 권장한다.
또한 interrupt를 받으면 깨어나야하기 때문에 sleep 메서드는 항상 try-catch 문 내부에 작성하여 예외처리를 해주어야 한다.
interrupt
해당 메서드의 기본적인 기능은 메서드의 interrupted 변수를 true로 만드는 것 뿐이다. 메서드를 향해 일종의 신호를 보낸다고 생각하면 된다.
해당 메서드 내에서 interrupted() 메서드를 사용하여 interrupted 변수의 값을 확인하고 만약 true라면 다시 false로 만들어 줄 수 있다.
isInterrupted() 메서드를 사용한다면 interrupted 변수의 값을 반환하는건 같지만 다시 false로 만들어주지 않는다.
만약 해당 메서드가 sleep, join 등에 의해 WAITING 상태에 있을 때 interrupt()를 호출하면, sleep, join 등에서 InterruptedException이 발생하고 RUNNABLE 상태로 바뀐다.
yield
yield 메서드는 자신의 남은 실행시간을 다른 메서드에 양보한다.
예를 들어 스케쥴러로부터 1초를 받은 쓰레드가 0.5초간 작동하고 yield를 호출하면, 남은 0.5초는 포기하고 다시 RUNNABLE 상태로 돌아간다.
예를 들어
while(!stopped) {
if(!suspended) {
...
try {
Thread.sleep(1000);
} catch(InterruptedException e) {}
} else {
Thread.yield();
}
}
위와 같이 코드를 작성하면 suspended 값이 true이고 stopped가 false일 때 의미없이 while문을 계속 돌며 busy-waiting 하는 대신 yield를 통해 다른 메서드에 작동 시간을 양보함으로써 성능을 향상시킬 수 있다.
join
join() 메서드는 자신이 하던 활동을 멈추고 다른 쓰레드를 일정시간 작동시킬 때 사용된다. 다른 쓰레드에서 우선적으로 작업을 수행해주어야 할 경우, 예를 들면 가용 메모리가 별로 없어 가비지 컬렉터를 호출해야할 경우 등에 사용된다.
가비지 컬렉터를 호출하면서 작업을 계속한다면 메모리를 계속해서 사용하므로 가비지 컬렉터가 가용 메모리를 늘리기 전에 가용 메모리가 전혀 남지않는 상황이 발생할 수도 있을 것이다.
시간을 따로 지정하지 않으면 해당 쓰레드가 작동을 완료할 때까지 기다린다.
join도 sleep과 같이 interrupt에 의해 깨어나는 일시정지 상태이기 때문에 try-catch문 내부에서 호출해주어야 한다.
쓰레드의 동기화
쓰레드의 동기화에 대해서는 이전 포스팅에서 배운 적이 있다. java에서는 굉장히 간단하게 임계영역에 대한 상호배제를 구현할 수 있는데 첫 번째 방법으로 syncronized의 사용이 있다.
syncronized를 사용한 동기화
syncronized 키워드로 지정된 부분은 임계영역으로 지정되어 여러 쓰레드가 동시에 접근할 수 없게 된다. syncronized 키워드의 사용 방법은 다음과 같다.
public syncronized void calc() {}
syncronized(참조변수) {}
첫 번째는 메서드 전체를 임계영역으로 지정하는 것이다. 쓰레드에서 syncronized가 선언된 메서드를 호출하면 해당 메서드에 대한 lock을 얻어 작업을 수행하고 작업이 끝나면 lock을 반환한다.
두 번째는 메서드 내에 임의의 syncronized 블럭을 생성하는 것이다. 이 때 참조변수는 lock을 걸고자 하는 객체의 참조변수가 들어가야 한다. 주로 this가 사용된다. 쓰레드는 이 syncronized 블럭에 진입하면서 해당 객체에 대한 lock을 얻고, 작업이 끝나면 lock을 반환한다.
임계영역은 프로그램 전체의 성능을 좌우하는 만큼 syncronized 블럭을 사용하여 임계영역을 최소화하는 것이 좋다.
wait과 notify
만약 syncronized로 lock이 걸린 부분을 쓰레드가 작업을 하다 I/O 인터럽트를 받았다고 가정해보자. 그러면 해당 쓰레드는 lock을 얻은 채로 WAITING 상태에 들어가게 되고 다른 쓰레드들은 하염없이 기다려야 할 것이다.
이러한 상황을 개선하기 위해 나온 것이 wait()과 notify()로 임계영역 내에서 작업을 진행하다 더 이상 진행할 수 없게 된다면 wait()을 호출하여 lock을 반납하고 기다리게 한다. 이후 작업을 진행할 수 있게 되면 notify()를 호출하여 다시 lock을 얻고 작업을 진행할 수 있게 한다. wait이 매개변수 없이 호출되면 notify()가 호출될 때까지 기다리게 되고, 매개변수가 있다면 해당 매개변수만큼 기다리다 자동으로 깨어난다. wait이 호출되면, 해당 쓰레드는 작업하던 객체의 waiting pool에서 대기하게 된다. notify()가 호출되면 waiting pool 내부의 임의의 쓰레드가 깨어나게 되고, notifyAll()을 호출하여 기다리던 모든 쓰레드를 깨운다 해도 결국 lock을 얻는 것은 하나의 쓰레드 뿐이다.
따라서 쓰레드가 아무리 기다려도 lock을 얻지 못하는 기아(Starvation) 현상이 발생할 수도 있다.
Lock 클래스
syncronized 블럭 이외에 ‘java.util.concurrent.loccks’ 패키지가 제공하는 lock 클래스들을 이용하는 방법도 있다. lock 클래스의 종류는 세 가지가 있다.
- ReentrantLock : 재진입이 가능한 lock. 가장 일반적인 배타 lock이다.
- ReentrantReadWriteLock : 읽기에는 공유적이고, 쓰기에는 배타적인 lock
- StampedLock : ReentrantReadWriteLock의 기능에 낙관적인 lock 기능을 추가
ReentrantLock은 가장 일반적인 lock이다. 재진입이 가능하다는 것은 위에서 배운 wait, notify와 같이 특정 조건에서 lock을 풀었다가 추후 다시 lock을 얻고 작업을 재진행할 수 있다는 뜻이다.
ReentrantReadWriteLock은 읽기를 위한 lock과 쓰기를 위한 lock을 제공한다. ReentrantLock은 배타적인 lock이기 때문에 무조건 lock이 있어야 진입을 할 수 있지만, ReentrantReadWriteLock은 읽기 lock이 걸려있으면 다른 쓰레드가 읽기 lock을 중복해서 걸고 진입하여 읽기를 수행할 수 있다. 읽기는 내용을 변경하지 않기 때문에 여러 쓰레드가 동시에 읽어도 문제가 발생하지 않기 때문이다.
하지만 읽기 lock이 걸린 상태에서 쓰기 lock을 거는 것은 허용되지 않는다. 쓰기 lock이 걸린 도중에도 읽기 lock을 걸 수는 없다.
StampedLock은 long 타입의 변수인 Stamp를 사용한다. 또한 읽기와 쓰기를 위한 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;//읽어온 값을 반환
}
ReentrantLock을 이해하면 다른 lock들은 충분히 응용이 가능하므로, ReentrantLock에 대해서 알아보도록 하자.
ReentrantLock의 생성자는 두 가지가 있다.
ReentrantLock()
ReentrantLock(boolean fair)
생성자의 매개변수를 true로 주면, lock이 풀렸을 때 가장 오래 기다린 쓰레드가 lock을 얻을 수 있도록 fair하게 처리한다. 그러나 어느 쓰레드가 가장 오래 기다렸는지 확인하는 과정이 필요하므로 성능은 저하된다.
void lock() //lock을 잠근다
void unlock() //lock을 해제한다
boolean isLocked() //lock이 잠겼는지 확인한다
ReentrantLock의 메서드들이다. 자동으로 lock을 걸고 풀어주는 syncronized 블럭과 달리 lock 클래스는 직접 lock을 걸고 해제해야하는 만큼 주의를 기울여 사용해야한다.
syncronized(this) {
//임계영역
}
||
lock.lock();
//임계영역
lock.unlock();
lock을 풀지 않고 임계영역을 빠져나가는 일을 막기위해 lock과 unlock은 보통 try-finally 문과 함께 사용한다.
lock.lock();
try {
//임계영역
} finally {
lock.unlock();
}
이런 식으로 코드를 짜면 try블럭 내에서 어떤 일이 발생하더라도 finally문에서 unlock을 수행해주기 때문에 안전하다.
이외에도 tryLock()이란 메서드도 있는데, lock을 얻으려고 기다리지 않거나, 일정 시간만 기다린다. lock을 얻으면 true, 얻지 못하면 false를 반환한다. 일반적인 lock은 lock을 얻을 때까지 계속해서 기다리므로, 응답성이 중요한 경우 tryLock을 사용하여 일정 시간 이상 기다려야 한다면 다른 방법을 사용하는 식의 응용이 가능하다.
ReentrantLock과 Condition
위에서 wait과 notify를 설명할 때 notify는 waiting pool의 임의의 쓰레드를 깨우기 때문에 특정 쓰레드가 아주 오래 기다리는 기아 현상이 일어날 수 있다는 것을 설명하였다.
이 때 Condition을 사용하여 각 쓰레드마다 다른 waiting pool에서 기다리도록 하면 문제는 해결된다.
Condition은 이미 생성된 lock에서 newCondition()을 호출하여 생성한다.
private ReentrantLock lock=new ReentrantLock();
private Condition forCook=lock.newCondition();
private Condition forCust=lock.newCondition();
위와 같이 2개의 Condition을 생성한 후, wait과 notify 대신 Condition의 await과 signal을 사용하면 된다. condition.await(); 과 같이 사용하면 현재의 쓰레드가 condition에 저장되어 나중에 signal을 통해 깨울 수 있는 식이다.
volatile
멀티 코어 프로세서에서는 각 코어마다 각자의 cache를 가진다. 이는 데이터의 지역성 때문인데 보통 쓰던 데이터를 많이 쓴다는 이론이다.
따라서 원하는 데이터가 cache에 있으면 cache의 데이터를 사용하고, 없다면 cache miss가 일어나 메모리에서 데이터를 가져와 cache에 입력하는 식으로 작동한다.
그러다보니 메모리에서 데이터가 변경되었는데 cache에는 변경되지 않은 데이터가 있어 원하는 방식대로 작동하지 않는 경우가 발생한다.
volatile boolean suspended=false;
volatile boolean stopped=false;
이 때 위와 같이 변수 앞에 volatile을 붙이면 코어가 변수를 읽어올 때 cache가 아닌 메모리에서 읽어오기 때문에 cache와 메모리 간의 데이터 불일치를 막을 수 있다.
volatile을 붙이는 대신 syncronized 블럭 안에 작성해도 같은 효과가 있는데, 쓰레드가 syncronized 블럭에 들어갈 때와 나올 때 cache와 메모리 간의 동기화가 일어나기 때문이다.
JVM은 데이터를 4byte 단위로 처리한다. 따라서 int, char 등의 자료형은 하나의 명령어로 읽고 쓸 수 있으므로 다른 쓰레드가 끼어들 여지가 없다.
하지만 double, long과 같은 4byte를 넘는 크기의 자료형들은 값을 읽는 도중에 다른 쓰레드가 끼어들어 데이터가 손상될 여지가 있다.
volatile long sharedvalue;
volatile double sharedvalue;
이 때에도 변수 앞에 volatile을 붙여주면 해당 변수에 대한 읽기과 쓰기를 atomic하게 만들 수 있다. 읽기와 쓰기를 atomic하게 할 뿐, 임계영역으로 만들어 동기화시켜주진 않는다는 점을 주의하자.
fork & join
JDK 1.7부터 생긴 기능이다. 하나의 작업을 여러 작은 작업으로 나누어 여러 쓰레드에서 동시에 처리하는 것을 돕는 기능이다.
사용하기 위해선 먼저 두 클래스 중 하나를 상속받아 구현해야 한다.
RecursiveAction //반환값이 없는 작업을 구현할 때 사용
RecursiveTask //반환값이 있는 작업을 구현할 때 사용
두 클래스 모두 compute라는 추상 메서드가 있는데, 상속을 통해 이 메서드를 구현하면 된다. 이후 쓰레드풀과 수행할 작업을 생성하고, 쓰레드가 run이 아닌 start로 시작하듯이 compute가 아닌 invoke로 시작하면 된다.
ForkJoinPool pool=new ForkJoinPool();
SumTask task=new SumTask(from,to);
Long result=pool.invoke(task);
compute의 구현
compute를 구현할 때엔 작업을 하는 것 외에 작업을 어떻게 나눌지에 대해서도 구현해야 한다. 예를 들어
public Long compute() {
long size=to-from+1;
if(size<=5) return sum();
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()이 처리하고 나머지는 모두 작업을 나누는 것 뿐이다.
하나의 쓰레드는 compute를 수행하며 작업을 계속해서 반으로 나누고, 다른 쓰레드는 작업 큐에 들어간 작업들을 처리한다. 자신의 작업큐가 빈 쓰레드는 다른 쓰레드의 작업큐에서 작업을 가져와 수행하며 이 과정은 쓰레드풀에 의해 자동으로 이루어진다.
이러한 과정을 통해 여러 쓰레드가 동시에 효율적으로 작업을 처리하게 된다.
fork는 작업을 작업 큐에 넣는 것 뿐이기때문에 호출만 할 뿐 결과를 기다리거나 하진 않는 비동기 메서드이다. join은 해당 작업이 끝날 때까지 기다렸다가 수행이 끝나면 결과를 반환하는 동기 메서드라는 차이점이 있다.
따라서 위의 코드에서 fork()를 호출하면 바로 바음 return문으로 넘어가고, return문에서는 join()의 결과를 기다렸다가 rightSum의 값에 더해서 반환하게 된다.
지금까지 java의 멀티 쓰레딩에 대해 알아보았다. 아주 중요하게 쓰이면서도 어려운 부분이기 때문에 분량도 많고 힘든 부분이었다.
그래도 멀티 쓰레딩의 개념만 알던 상태에서 실제 코드에서 어떤 식으로 사용되고 관리하는지에 대해 아니깐 눈이 좀 뜨이는 느낌이다.
추후 프로젝트를 한다면 꼭 멀티 쓰레딩을 조금이라도 응용하여 만들어보는게 큰 도움이 될 것 같다.