Chapter 13. 쓰레드
03 Feb 2022 | 자바의 정석 JAVA1. 프로세스와 쓰레드
- 프로세스(process): 실행 중인 프로그램
- 프로그램을 실행하면 OS로부터 실행에 필요한 자원(메모리)을 할당받아 프로세스가 됨
- 프로세스는 프로그램을 수행하는 데 필요한 데이터와 메모리 등의 자원과 쓰레드로 구성
- 쓰레드: 프로세스의 자원을 이용해서 실제로 작업을 수행하는 것
- 모든 프로세스에는 최소한 하나 이상의 쓰레드가 존재
- 멀티쓰레드 프로세스(multi-htreaded process): 둘 이상의 쓰레드를 가진 프로세스
- 하나의 프로세스가 가질 수 있는 쓰레드의 개수는 제한되어 있지 않으나 쓰레드가 작업을 수행하는데 개별적인 메모리 공간을 필요로 하기 때문에 프로세스의 메모리 한계에 따라 생성할 수 있는 쓰레드의 수가 결정됨
멀티태스킹과 멀티쓰레딩
- 대부분의 OS는 멀티태스킹(multi-tasking, 다중 작업)을 지원하기 때문에 여러 개의 프로세스가 동시에 실행될 수 있음
- 멀티쓰레딩: 하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행하는 것
- CPU의 코어가 한 번에 단 하나의 작업만 수행할 수 있으므로, 실제로 동시에 처리되는 작업의 개수는 코어의 개수와 일치
- 처리해야하는 쓰레드의 수는 코어의 개수보다 훨씬 많기 때문에 각 코어가 아주 짧은 시간 동안 여러 작업을 번갈아 가며 수행함으로써 여러 작업들이 모두 동시에 수행되는 것처럼 보이게 함
멀티쓰레딩의 장점
- CPU 사용률 향상
- 자원을 효율적으로 사용
- 사용자에 대한 응답성 향상
- 작업이 분리되어 코드가 간결해짐
멀티쓰레딩 단점
- 여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업을 하기 때문에 발생할 수 있는 동기화(synchronization), 교착상태(deadlock)와 같은 문제 발생 가능
- 교착상태: 두 쓰레드가 자원을 점유한 상태에서 서로 상대편이 점유한 자원을 사용하려고 기다리느라 진행이 멈춰있는 상태
2. 쓰레드의 구현과 실행
-
구현 방법 1. Thread 클래스를 상속받는 방법
class MyThread extends Thread { public void run() {/* 작업내용 */} // Thread 클래스의 run()을 오버라이딩 }
-
구현 방법 2. Runnable 인터페이스를 구현하는 방법
class MyThread implements Runnable { public void run() {/* 작업내용 */} // Runnable 인터페이스의 run()을 구현 }
-
Thread 클래스를 상속받으면 다른 클래스를 상속받을 수 없기 때문에 Runnable 인터페이스를 구현하는 방법이 일반적
-
재사용성(reusablility)가 높고 코드의 일관성(consistency)을 유지할 수 있기 때문에 주로 사용
-
Runnable 인터페이스를 구현한 경우, Runnable 인터페이스를 구현한 클래스의 인스턴스를 생성한 다음, 이 인스턴스를 Thread 클래스의 생성자의 매개변수로 제공해야 함
Runnable r = new ThreadEx1_2(); Thread t2 = new Thread(r);
-
-
Runnable 인터페이스
public interface Runnable { public abstract void run(); }
run()
만 정의되어 있는 간단한 인터페이스
-
Thread 클래스를 상속받으면, 자손 클래스에서 조상인 Thread 클래스의 메서드를 직접 호출할 수 있지만, Runnable을 구현하면 Thread 클래스의 static 메서드인
currentThread()
를 호출하여 쓰레드에 대한 참조를 얻어 와야만 호출이 가능static currentThread() 현재 실행 중인 쓰레드의 참조를 반환
String getName() 쓰레드의 이름을 반환
쓰레드의 실행 - start()
start()
를 호출해야만 쓰레드가 실행됨start()
를 호출하면 실행대기 상태에 있다가 자신의 차례가 되면 실행 됨. 실행대기 중인 쓰레드가 하나도 없으면 곧바로 실행상태가 됨- 한 번 실행이 종료된 쓰레드는 다시 실행할 수 없음
- 쓰레드의 작업을 한 번 더 수행해야 한다면 새로운 쓰레드를 생성한 다음
start()
를 호출해야 함 - 하나의 쓰레드에 대해
start()
를 두 번 이상 호출하면 실행시에IllegalThreadStateException
이 발생
- 쓰레드의 작업을 한 번 더 수행해야 한다면 새로운 쓰레드를 생성한 다음
3. start()와 run()
- main 메서드에서 run()을 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라 클래스에 선언된 메서드를 호출하는 것일 뿐
- start()는 새로운 쓰레드가 작업을 싱행하는데 필요한 호출스택(call stack)을 생성한 다음 run()을 호출해서, 생성된 호출스택에 run()이 첫 번째로 올라가게 함
- 모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택을 필요로 하므로, 새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스택이 생성되고 쓰레드가 종료되면 작업에 상용된 호출스택은 소멸됨
새로운 쓰레드를 생성하고 start()를 호출한 후 호출스택의 변화
- 출처: https://watrv41.gitbook.io/devbook/java/java-live-study/10_week
- main 메서드에서 쓰레드의 start()를 호출
- start()는 새로운 쓰레드를 생성하고, 쓰레드가 작업하는데 사용될 호출스택을 생성
- 새로 생성된 호출스택에 run()이 호출되어, 쓰레드가 독립된 공간에서 작업을 수행
- 호출스택이 2개이므로 스케줄러가 정한 순서에 의해 번갈아 가면서 실행됨
- 스케줄러는 실행대기 중인 쓰레드들의 우선순위를 고려해 실행순서와 실행시간을 결정하고, 각 쓰레드들은 작성된 스케줄에 따라 자신의 순서가 되면 지정된 시간동안 작업을 수행
- 주어진 시간동안 작업을 마치지 못한 쓰레드는 자신의 차례가 돌아올 때까지 대기상태로 있게 되며, run()의 수행이 종료된 쓰레드는 호출스택이 모두 비워지면서 이 쓰레드가 사용하던 호출스택은 사라짐
main 쓰레드
- main 메서드의 작업을 수행하는 쓰레드
- 프로그램을 실행하면 기본적으로 하나의 쓰레드를 생성하고, 그 쓰레드가 main 메서드를 호출해 작업이 수행되도록 함
- main 메서드가 수행을 마쳤다하더라도 다른 쓰레드가 아직 작업을 마치지 않은 상태라면 프로그램이 종료되지 않음
- 실행 중인 사용자 쓰레드가 하나도 없을 때 프로그램은 종료됨
4. 싱글쓰레드와 멀티쓰레드
- 두 개의 작업을 하나의 쓰레드로 처리하는 경우와 두 개의 쓰레드로 처리하는 경우를 가정해보면
- 하나의 쓰레드로 두 작업을 처리하는 경우는 한 작업을 마친 후에 다른 작업을 시작
- 두 개의 쓰레드로 작업하는 경우에는 짧은 시간동안 2개의 쓰레드가 번갈아 가면서 작업을 수행해 동시에 두 작업이 처리되는 것과 같이 느껴짐
- 두 개의 쓰레드로 작업한 시간이 싱글쓰레드로 작업한 시간보다 더 걸림
- 이유: 쓰레드간의 작업 전환(context switching)에 시간이 걸리기 때문
- 작업 전환시, 현재 진행 중인 작업 상태 등의 정보를 저장하고 읽어 오는 시간이 소요됨
- 싱글 코어에서 단순히 CPU만을 사용하는 계산작업이라면 멀티쓰레드보다 싱글쓰레드로 프로그래밍하는 것이 더 효율적
- 두 쓰레드가 서로 다른 자원을 사용하는 작업의 경우에는 싱글쓰레드 프로세스보다 멀티쓰레드 프로세스가 더 효율적
- ex) 사용자로부터 데이터를 입력받는 작업, 네트워크로 파일을 주고받는 작업, 프린터로 파일을 출력하는 작업과 같이 외부기기와의 입출력을 필요호 하는 경우
5. 쓰레드의 우선순위
- 쓰레드는 우선순위(priority)라는 속성(멤버변수)을 가지고 있는데, 이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라짐
- 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 서로 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있음
- 시각적인 부분이나 사용자에게 빠르게 반응해야하는 작업을 하는 쓰레드의 우선순위는 다른 작업을 수행하는 쓰레드에 비해 높아야 함
쓰레드의 우선순위 지정하기
-
쓰레드의 우선순위와 관련된 메서드와 상수
void setPriority(int new Priority) // 쓰레드의 우선순위를 지정한 값으로 변경 int getPriority() // 쓰레드의 우선순위를 반환 public static final int MAX_PRIORITY = 10 // 최대우선순위 public static final int MIN_PRIORITY = 10 // 최소우선순위 public static final int NORM_PRIORITY = 10 // 보통우선순위
- 숫자가 높을수록 우선순위가 높음
-
쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속받음
- main 메서드를 수행하는 쓰레드는 우선순위가 5이므로 main 메서드내에서 생성하는 쓰레드의 우선순위는 자동적으로 5가 됨
-
쓰레드를 실행하기 전에만 우선순위 변경이 가능
-
멀티코어에서는 쓰레드의 우선순위에 따른 차이가 전혀 없음
- 우선순위에 차등을 두어 쓰레드를 실행하는 것보다 작업에 우선순위를 두어 PriorityQueue에 저장해 놓고, 우선순위가 높은 작업이 먼저 처리되도록 하는 것이 나을 수 있음
6. 쓰레드 그룹(thread group)
- 서로 관련된 쓰레드를 그룹으로 다루기 위한 것
- 쓰레드 그룹에 다른 쓰레드 그룹을 포함시킬 수 있음
- 쓰레드 그룹은 보안상의 이유로 도입된 개념으로, 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경 가능하지만, 다른 쓰레드 그룹의 쓰레드를 변경할 수는 없음
ThreadGroup
을 사용해 생성 가능함-
쓰레드를 쓰레드 그룹에 포함시키려면 Thread 의 생성자를 이용해야 함
Thread(ThreadGroup group, String name) Thread(ThreadGroup group, Runnable target) Thread(ThreadGroup group, Runnable target, String name) Thread(ThreadGroup group, Runnable target, String name, long stackSize)
-
모든 쓰레드는 반드시 쓰레드 그룹에 포함되어 있어야 함
- 쓰레드 그룹을 지정하는 생성자를 사용하지 않은 쓰레드는 기본적으로 자신을 생성한 쓰레드와 같은 쓰레드 그룹에 속하게 됨
-
자바 어플리케이션이 실행되면, JVM은 main과 system이라는 쓰레드 그룹을 만들고 JVM 운영에 필요한 쓰레드들을 생성해 이 쓰레드 그룹에 포함시킴
-
우리가 생성한 모든 쓰레드 그룹은 main 쓰레드 그룹의 하위 쓰레드 그룹이 되고, 쓰레드 그룹을 지정하지 않고 생성한 쓰레드는 자동적으로 main 쓰레드 그룹에 속하게 됨
-
Thread의 쓰레드 그룹과 관련된 메서드
ThreadGroup getThreadGroup() // 자신이 속한 쓰레드 그룹을 반환 void uncaughtException(Thread t, Throwable e) // 쓰레드 그룹의 쓰레드가 처리되지 않은 예외에 의해 실행이 종료되었을 때, JVM에 의해 이 메서드가 자동적으로 호출됨
7. 데몬 쓰레드(daemon thread)
- 다른 일반 쓰레드(데몬 쓰레드가 아닌 쓰레드)의 작업을 돕는 보조적인 역할을 수행하는 쓰레드
- 일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 자동 종료됨
- 이유: 데몬 쓰레드는 일반 쓰레드의 보조역할을 수행하므로 일반 쓰레드가 모두 종료되고 나면 데몬 쓰레드의 존재의 의미가 없기 때문
-
데몬 쓰레드의 예: 가비지 컬렉터, 워드프로세서의 자동저장, 화면자동갱신
-
데몬 쓰레드는 무한루프와 조건문을 이용해 실행 후 대기하고 있다가 특정 조건이 만족되면 작업을 수행하고 다시 대기하도록 작성
-
데몬 쓰레드는 일반 쓰레드의 작성방법과 실행방법이 같으며, 쓰레드를 생성한 다음 실행하기 전
setDaemon(true)
를 호출하기만 하면 됨boolean isDaemon() // 쓰레드가 데몬 쓰레드인지 확인 (데몬 쓰레드이면 true 반환) void setDaemon(boolean on) // 쓰레드를 데몬 쓰레드로 또는 사용자 쓰레드로 변경 // 매개변수 on의 값을 true로 지정하면 데몬쓰레드가 됨
- 데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 됨
- setDaemon 메서드는 반드시
start()
를 호출하기 전에 실행되어야 함. 그렇지 않으면 예외 발생
8. 쓰레드의 실행제어
-
쓰레드 스케줄링 관련 메서드
- suspend(), resume(), stop()은 deprecated 됨
- 이유: suspend(), stop()은 교착상태를 일으키기 쉽게 작성되었기 때문
- deprecated는 전에는 사용되었지만, 앞으로 사용하지 않을 것을 권장한다는 의미. 하위 호환성을 위해 삭제하지 않은 것일 뿐 사용해서는 안됨
- suspend(), resume(), stop()은 deprecated 됨
-
쓰레드 상태
상태 설명 NEW 쓰레드가 생성되고 아직 start()가 호출되지 않은 상태 RUNNABLE 실행 중 또는 실행 간으한 상태 BLOCKED 동기화블럭에 의해 일시정지된 상태(lock)이 풀릴 때까지 기다리는 상태 WAITING,
TIMED_WAITING쓰레드의 작업이 종료되지는 않았지만 실행가능하지 않은 일시정지 상태. TIMED_WAITING은 일시정지시간이 지정된 경우를 의미 TERMINATED 쓰레드의 작업이 종료된 상태
쓰레드의 상태
- 쓰레드를 생성하고
start()
를 호출하면 바로 실행되는 것이 아니라 실행대기열에 저장되어 자신의 차례가 될 때까지 기다려야 함
- 실행대기열은 큐와 같은 구조로 먼저 실행디기열에 들어온 쓰레드가 먼저 실행됨
- 실행대기상태에 있다가 자신의 차례가 되면 실행상태가 됨
- 주어진 실행시간이 다되거나
yield()
를 만나면 다시 실행대기상태가 되고 다음 차례의 쓰레드가 실행상태가 됨- 실행 중에
suspend()
,sleep()
,wait()
,join()
, I/O block에 의해 일시정지 상태가 될 수 있음
- I/O block은 입출력작업에서 발생하는 지연상태를 의미
- 지정된 일시정지시간이 다되거나(time-out),
notify()
,resume()
,interrupt()
가 호출되면 일시정시상태를 벗어나 다시 실행대기열에 저장되어 자신의 차례를 기다리게 됨- 실행을 모두 마치거나
stop()
이 호출되면 쓰레드는 소멸됨
sleep(long millis) - 일정시간동안 쓰레드를 멈추게 함
static void sleep(long millis)
static void sleep(long millis, int nanos)
- 밀리세컨드와 나노세컨드의 시간단위로 세밀하게 값을 지정할 수 있지만 어느 정도 오차가 발생할 수 있음
- sleep 메서드에 의해 일시정지 상태가 된 쓰레드는 지정된 시간이 다 디거나 interrupt()가 호출되면, 잠에서 깨어나 실행대기 상태가 됨
- sleep 메서드를 호출할 때는 항상 try-catch문으로 예외를 처리해줘야 함
- sleep 메서드는 항상 현재 실행 중인 쓰레드에 대해 작동하기 때문에
th1.sleep(2000)
과 같이 호출했더라도 실제로 영향을 맏는 것은 main 메서드를 실행하는 main 쓰레드- 따라서 sleep()은 static으로 선언되어 있으며 참조변수를 이용해 호출하기 보다는
Thread.sleep(2000)
과 같이 해야 함
- 따라서 sleep()은 static으로 선언되어 있으며 참조변수를 이용해 호출하기 보다는
**interrupt()와 interrupted() - 쓰레드의 작업을 취소함 **
void interrupt() // 쓰레드의 interrupted 상태를 false에서 true로 변경
boolean isInterrupted() // 쓰레드의 interrupted 상태를 반환
static boolean interrupted() // 현재 쓰레드의 interrpted 상태를 반환 후, false로 변경
- interrupt()는 쓰레드에게 작업을 멈추라고 요청함
- 멈추라고 요청만 하는 것이고 쓰레드를 강제로 종료시키지는 못함
- 쓰레드의 interrupted 상태(인스턴스 변수)를 바꾸는 것
- interrupted()는 쓰레드에 대해 interrupt()가 호출되었는지 알려줌
- interrupt()가 호출되지 않았다면 false를, interrupt()가 호출되었다면 true를 반환
- sleep(), wait(), join()에 의해 WAITING 상태에 있을 때, 해당 쓰레드에 대해 interupt()를 호출하면 Interrupted Exception이 발생하고 쓰레드는 RUNNABLE 상태로 바뀜
- 멈춰있던 쓰레드를 깨워 실행가능한 상태로 만드는 것
yield() - 다른 쓰레드에게 양보함
- 쓰레드 자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보
- yield()와 interrupt()를 적절히 사용하면, 프로그램의 응답성을 높이고 효율적인 실행이 가능하게 할 수 있음
join() - 다른 쓰레드의 작업을 기다림
void join()
void join(long millis)
void join(long millis, int nanos)
- 쓰레드 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때 사용
- 작업 중에 다른 쓰레드의 작업이 먼저 수행되어야할 필요가 있을 때 사용
- 시간을 지정하지 않으면, 해당 쓰레드가 작업을 모두 마칠 때까지 기다림
- interrupt()에 의해 대기상태에서 벗어날 수 있으며, join()이 호출되는 부분을 try-catch문으로 감싸야 함
9. 쓰레드의 동기화
- 멀티쓰레드 프로세스인 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게됨
- 쓰레드 A가 작업하던 도중 다른 쓰레드 B에게 제어권이 넘어갔을 때, 쓰레드 A가 작업하던 공유 데이터를 쓰레드 B가 임의로 변경했다면, 다시 쓰레드 A가 제어권을 받아 나머지 작업을 마쳤을 때 원래 의도했던 것과는 다른 결과를 얻을 수 있음
- 이것을 방지하기 위해 한 쓰레드가 특정 작업을 마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 하는 것이 필요
- 이로 인해 도입된 개념이 임계영역(critical section)과 잠금(락, lock)
- 공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정해놓고, 공유 데이터가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 함
- 해당 쓰레드가 임계 영역 내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만 다른 쓰레드가 반납된 lock을 획득하여 임계 영역의 코드를 수행할 수 있게 됨
- 쓰레드 동기화(synchronization): 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것
- JDK1.5부터
java.util.concurrent.locks
와java.util.concurrent.atomic
패키지를 통해 다양한 방식으로 동기화를 구현할 수 있도록 지원하고 있음
- JDK1.5부터
9.1 synchronized를 이용한 동기화
-
synchronized 키워드는 임계 영역을 설정하는데 사용됨
-
방식 1. 메서드 전체를 임계 영역으로 지정
public synchronized void calcSum(){ // ... }
- 메서드 앞에
synchronized
를 붙이는 방식. 메서드 전체가 임계 영역으로 설정됨 - 쓰레드는 synchronized 메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다 메서드가 종료되면 lock을 반환
- 메서드 앞에
-
방식 2. 특정한 영역을 임계 영역으로 지정
synchronized(객체의 참조변수){ // ... }
- 메서드 내의 코드 일부를 {}으로 감싸고 블럭 앞에
synchronized(참조변수)
를 붙이는 방식. 이 블럭을 synchronized 블럭이라고 함 - 참조변수는 락을 걸고자하는 객체를 참조하는 것이어야 함
- 이 블럭의 영역 안으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게 되고, 이 블럭을 벗어나면 lock을 반납
- 메서드 내의 코드 일부를 {}으로 감싸고 블럭 앞에
- 두 방식 모두 lock의 획득과 반납이 자동적으로 이루어지므로 임계 영역만 설정해주면 됨
- 모든 객체는 lock을 하나씩 가지고 있으며, 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있음. 다른 쓰레드들은 lock을 얻을 때까지 기다리게 됨
- 임계 영역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 메서드 전체에 락을 거는 것보다 synchroized 블럭으로 임계 영역을 최소화해 효율적인 프로그램이 되도록 노력해야 함
9.2 wait()와 notify()
-
특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록하기 위해 고안된 것이
wait()
와notify()
-
synchronized 블록 내에서만 사용 가능
-
동기화된 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, 일단
wait()
을 호출해 쓰레드가 락을 반납하고 기다리게 함. 그러면 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 됨. 나중에 작업을 진행할 수 있는 상황이 되면notify()
를 호출해 작업을 중단했던 쓰레드가 다시 락을 얻어 작업을 진행할 수 있게 함 -
단, 오래 기다린 쓰레드가 락을 얻는다는 보장이 없음
wait()
가 호출되면, 실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다림notify()
가 호출되면 해당 객체의 대기실에 있던 모든 쓰레드 중 임의의 쓰레드만 통지를 받음nofifyAll()
은 기다리고 있는 모든 쓰레드에게 통보를 하지만, lock을 얻을 수 있는 것은 하나의 쓰레드이고 나머지 쓰레드는 통보를 받긴 했지만, lock을 얻지 못하면 다시 lock을 기다리게 됨
-
wait()
과notify()
는 특정 객체에 대한 것이므로 Object 클래스에 정의되어 있음void wait() void wait(long timeout) void wait(long timeout, int nanos) void notify() void notifyAll()
wait()
는notify()
또는notifyAll()
이 호출될 때까지 기다리지만, 매개변수가 있는wait()
는 지정된 시간동안만 기다림. 지정된 시간이 지난 후에 자동적으로notify()
가 호출되는 것과 같음
-
waiting pool은 객체마다 존재하는 것이므로
notifyAll()
이 호출된다고 해서 모든 객체의 waiting pool에 있는 쓰레드가 깨워지는 것은 아님notifyAll()
이 호출된 객체의 waiting pool에 대기 중인 쓰레드만 해당됨
기아 현상과 경쟁 상태
- 기아 (starvation) 현상: 어떤 하나의 쓰레드가 계속 통지를 받지 못하고 오랫동안 기다리게 되는 현상
notifyAll()
을 사용해 해결
- 경쟁 상태(race condition): 여러 쓰레드가 lock을 얻기 위해 서로 경쟁하는 것
- Lock과 Condition을 이용해 해결
9.3 Lock과 Condition을 이용한 동기화
-
synchronized 블럭으로 동기화를 하면 자동적으로 lock이 잠기고 풀리기 때문에 편리
- synchronized 블럭 내에서 예외가 발생해도 lock은 자동적으로 풀림
- 하지만 같은 메서드 내에서만 lock을 걸 수 있다는 제약이 있어 불편하기도 함. 이럴 때 lock 클래스를 사용
ReentrantLock 재진입이 가능한 lock. 가장 일반적인 배타 lock
ReentrantReadWriteLock 읽기에는 공유적이고, 쓰기에는 배타적인 lock
StampedLock ReentrantReadWriteLock에 낙관적인 lock의 기능을 추가
- ReentrantLock은 가장 일반적인 lock
- 특정 조건에서 lock을 풀고 나중에 다시 lock을 얻고 임계영역으로 들어와 이후의 작업을 수행할 수 있음
- ReentrantLock은 배타적인 lock이라서 무조건 lock이 읽어야만 임계 영역의 코드를 수행할 수 있지만, ReentrantReadWriteLock은 읽기 lock이 걸려있으면, 다른 쓰레드가 읽기 lock을 중복해서 걸고 읽기를 수행할 수 있음
- 읽기를 위한 lock과 쓰기를 위한 lock을 제공
- 읽기는 내용을 변경하지 않으므로 동시에 여러 쓰레드가 읽어도 문제가 되지 않음
- 읽기 lock이 걸린 상태에서 쓰기 lock을 거는 것은 허용되지 않음
- StampedLock은 lock을 걸거나 해지할 때 ‘‘스탬프(long타입의 정수값)’를 사용하며, 읽기와 쓰기를 위한 lock외에 ‘낙관적 읽기 lock(optimistic reading lock)’이 추가된 것
- 읽기 lock이 걸려있으면, 쓰기 lock을 얻기 위해서는 읽기 lock이 풀릴 때까지 기다려야하는데 비해 낙관적 읽기 lock은 쓰기 lock에 의해 발로 풀림
- 낙관적 읽기에 실패하면, 읽기 lock을 얻어 다시 읽어 와야 함
- 무조건 읽기 lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 거는 것
ReentrantLock의 생성자
ReentrantLock ()
ReentrantLock (boolean fair)
- 생성자의 매개변수를 true로 주면, lock이 풀렸을 때 가장 오래 기다린 쓰레드가 lock을 획득할 수 있게 공정하게 처리
- 공정하게 처리할 경우 어떤 쓰레드가 가장 오래 기다렸는지 확인하는 과정을 거쳐야하므로 성능은 떨어짐
void lock() // lock을 잠금
void unlock() // lock을 해지
boolean isLocked() // lock이 잠겼는지 확인
boolean tryLock()
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException
- synchronized 블럭은 lock의 잠금과 해제가 자동적으로 관리되지만, ReentrantLock과 같은 lock 클래스들은 수동으로 lock을 잠그고 해제해야 함
- 임계 영역 내에서 예외가 발생하거나 return문으로 빠져 나가게 되면 lock이 풀리지 않을 수 있으므로
unlock()
은 try-finally문으로 감싸는 것이 일반적 tryLock()
은 다른 쓰레드에 의해 lock이 걸려 있으면 lock을 얻으려고 기다리지 않거나 지정된 시간만큼 기다림- lock을 얻으면 true를 반환하고, 얻지 못하면 false를 반환
- 응답성이 중요한 경우, 지정된 시간동안 lock을 얻지 못하면 다시 작업을 시도할 것이지 포가힐 것인지를 사용자가 결정할 수 있게 하는 것이 좋음
- 예외을 발생시킬 수 있는데, 지정된 시간동안 lock을 얻으려고 기다리는 중에 interrupt()에 의해 작업을 취소될 수 있도록 코드를 작성할 수 있다는 의미
9.4 volatile
- 코어는 메모리에서 읽어온 값을 캐시에 저장하고 캐시에서 값을 읽어서 작업. 다시 같은 값을 읽어올 때는 먼저 캐시에 있는지 확인하고 없을 때문 메모리에서 읽어옴. 그러다보니 도중에 메모리에 저장된 변수의 값이 변경되었는데도 캐시에 저장된 값이 갱신되지 않아 메모리에 저장된 값이 다른 경우가 발생
- 변수 앞에
volatile
을 붙이면 코어가 변수의 값을 읽어올 때 캐시가 아닌 메모리에서 읽어오기 때문에 캐시와 메모리간의 값의 불일치가 해결됨 - 변수에
volatile
을 붙이는 대신 synchronized 블럭을 사용해도 같은 효과를 얻을 수 있음- 쓰레드가 synchronized 블럭으로 들어갈 때와 나올 때, 캐시와 메모리 간의 동기화가 이루어지기 때문에 값의 불일치가 해소되기 때문
volatile로 long과 double을 원자화
- JVM은 데이터를 4byte 단위로 처리하기 때문에, int와 int보다 작은 타입들은 한 번에 읽거나 쓰는 것이 가능
- 단 하나의 명령어로 읽거나 쓰기가 가능하다는 의미
- 하나의 명령어는 더 이상 나눌 수 없는 최소의 작업단위이므로, 작업의 중간에 다른 쓰레드가 끼어들 수 없음
- 크기가 8byte인 long과 double 타입의 변수는 하나의 명령어로 값을 읽거나 쓸 수 없기 때문에, 변수의 값을 읽는 과정에 다른 쓰레드가 끼어들 수 있음
- 다른 쓰레드가 끼어들지 못하게 변수를 읽고 쓰는 모든 문장을 synchronized 블럭으로 감쌀 수도 있지만, 변수를 선언할 때
volatile
을 붙이는 방법으로도 해결 가능
- 다른 쓰레드가 끼어들지 못하게 변수를 읽고 쓰는 모든 문장을 synchronized 블럭으로 감쌀 수도 있지만, 변수를 선언할 때
volatile
는 해당 변수에 대한 읽거나 쓰기가 원자화됨- 원자화: 작업을 더 이상 나눌 수 없게 함
- synchronized 블럭도 일종의 원자화. synchronized 블럭은 여러 문장을 원자화함으로써 쓰레드의 동기화를 구현한 것
volatile
은 해당 변수에 대한 읽거나 쓰기를 원자화 할 뿐, 동기화하는 것은 아님
9.5 fork & join 프레임웍
-
하나의 작업을 작은 단위로 나눠서 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어줌
-
수행할 작업에 따라 RecursiveAction과 RecursiveTask, 두 클래스 중 하나를 상속받아 구현해야 함
RecursiveAction 반환값이 없는 작업을 구현할 때 사용
RecursiveTask 반환값이 있는 작업을 구현할 때 사용
- 두 클래스 모두
compute()
라는 추상 메서드를 가짐 - 상속을 통해
compute()
를 구현한 후, 쓰레드풀과 수행할 작업을 생성하고,invoke()
로 작업을 시작
- 두 클래스 모두
-
ForkJoinPool
: fork&join 프레임웍에서 제공하는 쓰레드 풀- 지정된 수의 쓰레드를 생성해서 미리 만들어 놓고 반복해서 재사용할 수 있게 함
- 쓰레드 풀은 기본적으로 코어의 개수와 동일한 개수의 쓰레드를 생성
- 쓰레드를 반복해서 생성하지 않아도 되고, 너무 많은 쓰레드가 생성되어 성능이 저하되는 것을 막아준다는 장점이 있음
- 쓰레드 풀은 쓰레드가 수행해야하는 작업이 담긴 큐를 제공하며, 각 쓰레드는 자신의 작업 큐에 담긴 작업을 순서대로 처리
- 지정된 수의 쓰레드를 생성해서 미리 만들어 놓고 반복해서 재사용할 수 있게 함
compute()
의 구현
- 수행할 작업 외에도 작업을 어떻게 나눌 것인가에 대해서도 알려줘야 함
다른 쓰레드의 작업 훔쳐오기
- 작업 훔쳐오기(work stealing):
fork()
가 호출되어 작업 큐에 추가된 작업도compute()
에 의해 더 이상 나눌 수 없을 때까지 반복해서 나뉘고, 자신의 작업 큐가 비어있는 쓰레드는 다른 쓰레드의 작업 큐에서 작업을 가져와 수행- 쓰레드풀에 의해 자동적으로 이루어짐
fork()와 join()
fork()
는 작업을 쓰레드의 작업 큐에 넣는 것이고, 작업 큐에 들어간 작업은 더 이상 나눌 수 없을 때까지 나뉨compute()
로 나누고fork()
로 작업 큐에 넣는 작업이 계속해서 반복됨- 나눠진 작업은 각 쓰레드가 골고루 나눠서 처리하고, 작업의 결ㄱ허눈
join()
을 호출해서 얻을 수 있음
fork()
와join()
의 차이점:fork()
는 비동기 메서드(asynchronous method)이고,join()
은 동기 메서드(synchronous method)- 비동기 메서드는 메서드를 호출만 할 뿐, 결과를 기다리지 않음. 내부적으로는 다른 쓰레드에게 작업을 수행하도록 지시만 하고 결과를 기다리지 않고 돌아오는 것