https://github.com/whiteship/live-study
백기선님 자바 기초 스터디 10주차
목표
자바의 멀티쓰레드 프로그래밍에 대해 학습하세요.
학습할 것 (필수)
- Thread 클래스와 Runnable 인터페이스
- 쓰레드의 상태
- 쓰레드의 우선순위
- Main 쓰레드
- 동기화
- 데드락
Thread 클래스와 Runnable 인터페이스
프로세스와 쓰레드
프로세스(process)란 실행 중인 프로그램이다.
프로세스는 프로그램을 수행하는 데 필요한 데이터와 메모리 등의 자원 그리고 쓰레드로 구성되어 있으며, 프로세스의 자원을 이용해서 실제로 작업을 수행하는 것이 바로 쓰레드이다.
그래서 모든 프로세스에는 최소한 하나 이상의 쓰레드가 존재하며, 둘 이상의 쓰레드를 가진 프로세스를 멀티쓰레드 프로세스라고 한다.
Java에서 쓰레드를 구현하는 방법은 두 가지가 있다.
- Thread클래스를 상속받는 방법
- Runnable인터페이스를 구현하는 방법
별 차이는 없지만 Thread클래스를 상속받으면 다른 클래스를 상속받을 수 없기 때문에, Runnable인터페이스를 구현하는 방법이 일반적이다.
<Thread클래스 상속>
class MyClass extends Thread {
//Thread 클래스의 run()을 오버라이딩
public void run() { /* 작업내용 */}
}
<Runnable인터페이스 구현>
class MyClass implements Runnable {
//Runnable인터페이스의 run()을 구현
public void run() { /* 작업내용 */}
}
쓰레드를 구현한다는 것은, 위의 두 방법 중 어떤 것을 선택하든지, 그저 쓰레드를 통해 작업하고자 하는 내용으로 run()의 몸통{ }을 채우는 것일 뿐이다.
만약 Thread의 메서드 중 run()을 제외하고 오버라이딩을 해야 한다면 Thread를 상속 받고 그렇지 않다면 Runnable을 구현하는 것이 좋다.
+) Runnable은 run() 메서드 하나만 존재하는 인터페이스이다.
다음 예제를 통해서 쓰레드를 구현하는 2가지 방법의 차이를 확인하자.
public class ThreadEx1 {
public static void main(String[] args) {
ThreadEx1_1 t1 = new ThreadEx1_1();
Runnable r = new ThreadEx1_2();
Thread t2 = new Thread(r); // 생성자 Thread(Runnable target)
t1.start();
t2.start();
}
}
class ThreadEx1_1 extends Thread {
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(getName()); // 조상인 Thread의 getName()dmf ghcnf
}
}
}
class ThreadEx1_2 implements Runnable {
public void run() {
for (int i = 0; i < 5; i++) {
//Thread.currentThread() - 현재 실행중인 Thread를 반환한다.
System.out.println(Thread.currentThread().getName());
}
}
}
<결과>
Thread-0
Thread-0
Thread-0
Thread-0
Thread-0
Thread-1
Thread-1
Thread-1
Thread-1
Thread-1
코드를 상세히 분석해보자.
인스턴스 생성
Thread클래스를 상속받은 경우와 Runnable인터페이스를 구현한 경우의 인스턴스 생성 방법이 다르다.
<Thread클래스 상속>
ThreadEx1_1 t1 = new ThreadEx1_1(); //Thread 자손 클래스의 인스턴스를 생성
<Runnable인터페이스 구현>
Runnable r = new ThreadEx1_2(); //Runnable을 구현한 클래스의 인스턴스를 생성
Thread t2 = new Thread(r); // 생성자 Thread(Runnable target) //생성자 Thread(Runnable target)
Runnable인터페이스를 구현한 경우, Runnable인터페이스를 구현한 클래스의 인스턴스를 생성한 다음, 이 인스턴스를 Thread클래스의 생성자의 매개변수로 제공해야 한다.
메서드 호출
<Thread클래스 상속>
System.out.println(getName()); // 조상인 Thread의 getName()을 호출
<Runnable인터페이스 구현>
//Thread.currentThread() - 현재 실행중인 Thread를 반환한다.
System.out.println(Thread.currentThread().getName());
- Thread클래스를 상속받으면, 자손 클래스에서 조상인 Thread클래스의 메서드를 직접 호출할 수 있지만,
- Runnable을 구현하면 Thread클래스의 static메서드인 currentThread()를 호출하여 쓰레드에 대한 참조를 얻어 와야만 호출이 가능하다.
getName()은 쓰레드의 이름을 반환하는 메서드이다.
쓰레드의 실행
쓰레드를 생성했다고 해서 자동으로 실행되는 것은 아니다.
start()를 호출해야만 쓰레드가 실행된다.
t1.start(); //쓰레드 t1을 실행시킨다.
t2.start(); //쓰레드 t2를 실행시킨다.
사실은 start()가 호출되었다고 해서 바로 실행되는 것이 아니라, 일단 실행대기 상태에 있다가 자신의 차례가 되어야 실행된다.
참고로 한 번 실행이 종료된 쓰레드는 다시 실행할 수 없다.
즉, 하나의 쓰레드에 대해 start()가 한 번만 호출될 수 있다.
ThreadEx1_1 t1 = new ThreadEx1_1();
t1.start();
t1.start(); //IllegalThreadStateException 발생
그래서 만일 쓰레드의 작업을 한 번 더 수행해야 한다면 아래 코드처럼 새로운 쓰레드를 다시 생성해야 한다.
ThreadEx1_1 t1 = new ThreadEx1_1();
t1.start();
t1 = new ThreadEx1_1(); //다시 생성
t1.start(); //OK
호출스택
그렇다면 쓰레드를 실행시킬 때 왜 run()이 아닌 start()를 호출하는 걸까?
main메서드에서 run()을 호출한다면 쓰레드를 실행시키는 것이 아닌 단순히 클래스에 선언된 메서드를 호출하는 것일뿐이다.
반면에 start()는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택을 생성한 다음에 run()을 호출한다.
모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택을 필요로한다.
그래서 이처럼 새로운 쓰레드를 실행할 때마다 새로운 호출스택이 생성되고 쓰레드가 종료되면 해당 쓰레드의 호출스택은 소멸된다.
스케줄러
호출스택에서는 가장 위에 있는 메서드가 현재 실행중인 메서드이고, 나머지 메서드들은 대기상태에 있다.
그러나 위의 그림에서와 같이 쓰레드가 둘 이상일 때는 호출스택의 최상위에 있는 메서드일지라도 대기상태에 있을 수 있다.
스케줄러는 실행대기중인 쓰레드들의 우선순위를 고려하여 실행순서와 실행시간을 결정하고, 각 쓰레드들은 작성된 스케줄에 따라 자신의 순서가 되면 지정된 시간동안 작업을 수행한다.
이 때 주어진 시간동안 작업을 마치지 못한 쓰레드는 다시 자신의 차례가 돌아올 때까지 대기상태로 있게되며,
작업을 마친 쓰레드, 즉 run()의 수행이 종료된 쓰레드는 호출스택이 모두 비워지면서 해당 쓰레드의 호출스택이 소멸된다.
멀티쓰레드 프로그래밍
두 개의 작업을 하나의 쓰레드로 처리하는 경우와 두 개의 쓰레드로 처리하는 경우를 가정해보자.
- 하나의 쓰레드로 두 작업을 처리하는 경우는 한 작업을 마친 후에 다른 작업을 시작하지만,
- 두 개의 쓰레드로 작업하는 경우에는 짧은 시간동안 2개의 쓰레드가 번갈아 가면서 작업을 수행해서 동시에 두 작업이 처리되는 것과 같이 느끼게 한다.(스케줄링)
다음은 싱글쓰레드 프로세스와 멀티쓰레드 프로세스를 비교한 그림이다.
예제를 통해 더 자세히 알아보자.
public class ThreadEx4 {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
System.out.printf("%s", new String("-"));
}
System.out.print("소요시간1:" + (System.currentTimeMillis() - startTime));
for (int i = 0; i < 300; i++) {
System.out.printf("%s", new String("|"));
}
System.out.print("소요시간2:" + (System.currentTimeMillis() - startTime));
}
}
<결과>
----------------------------------------------------------------------------------------
----------------------------------------------------------------------------------------
----------------------------------------------------------------------------------------
------------------------------------소요시간1:46||||||||||||||||||||||||||||||||||||||||||
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||소요시간2:87
'-'를 출력하는 작업과 '|'를 출력하는 작업을 하나의 쓰레드(main메서드)가 연속적으로 처리하는 시간을 측정하는 예제이다.
이제 새로운 쓰레드를 하나 생성해서 두 개의 쓰레드가 작업을 하나씩 나누어서 수행한 후 실행결과를 비교해보자.
public class ThreadEx5 {
static long startTime = 0;
public static void main(String[] args) {
ThreadEx5_1 th1 = new ThreadEx5_1();
th1.start();
startTime = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
System.out.printf("%s", new String("-"));
}
System.out.print("소요시간1:" + (System.currentTimeMillis() - ThreadEx5.startTime));
}
}
class ThreadEx5_1 extends Thread {
public void run() {
for (int i = 0; i < 300; i++) {
System.out.printf("%s", new String("|"));
}
System.out.print("소요시간2:" + (System.currentTimeMillis() - ThreadEx5.startTime));
}
}
<결과 1 - 싱글코어>
<결과 2 - 멀티코어>
|||||||||||||||||||||||||||||-||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||---------------------------
------------------------------------------------------------------------------------||||
|||||-----------------------------------------------------------------------------------
-------------------------------------------------|||||||||||||||||||||||||||||||||||||||
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
||||||||||||||||--------------------------------------------------------소요시간2:59소요시간1:72
이전 예제와는 달리 두 작업이 아주 짧은 시간동안 번걸아가면서 실행되었으며 거의 동시에 작업이 완료되었다.
실행결과를 싱글 코어일때와 멀티 코어일 때를 비교하면,
- 싱글 코어는 멀티쓰레드라도 하나의 코어가 번갈아가면서 작업을 수행하는 것이므로 두 작업이 절대 겹치지 않는다.
- 멀티 코어에서는 멀티쓰레드로 두 작업을 수행하면, 동시에 두 쓰레드가 수행될 수 있으므로 두 작업이 겹치게 된다. 그래서 화면(console)이라는 자원을 놓고 두 쓰레드가 경쟁하게 되는 것이다.
<싱글쓰레드 vs 멀티쓰레드>
지금은 같은 자원(console)을 사용해서 싱글스레드와 멀티스레드의 시간차이가 별로 없고 오히려 쓰레드간의 작업 전환(context switching)으로 인해 싱글쓰레드가 유리할 때가 있다.
(예제에서는 내 mac의 작업 전환이 빠른지 멀티스레드가 더 빠르게 측정되었다)
그러나 만약 두 쓰레드가 서로 다른 자원을 사용하는 작업의 경우에는 싱글쓰레드보다 멀티쓰레드가 더 효율적이다.
예를 들면, 사용자로부터 데이터를 입력받는 작업과, 프린터로 파일을 출력하는 작업과 같이 외부기기와의 입출력을 필요로 하는 경우가 이에 해당한다.
싱글쓰레드: 만일 사용자로부터 입력받는 작업(A)과 화면에 출력하는 작업(B)을 하나의 쓰레드로 처리한다면 사용자가 입력을 마칠 때까지 아무 일도 하지 못하고 기다리기만 해야한다.
멀티쓰레드: 그러나 두 개의 쓰레드로 처리한다면 사용자의 입력을 기다리는 동안 다른 쓰레드가 작업을 처리할 수 있기 때문에 보다 효율적인 CPU사용이 가능하다.
쓰레드의 상태
쓰레드 프로그래밍이 어려운 이유는 동기화와 스케줄링 때문이다.
동기화는 뒤에서 알아볼 것이고, 쓰레드의 스케줄링을 잘하기 위해서는 쓰레드의 상태에 대해 잘 알아야 한다.
상태 | 설명 |
NEW | 쓰레드가 생성되고 아직 start()가 호출되지 않은 상태 |
RUNNABLE | 실행 중 또는 실행 가능한 상태 |
BLOCKED | 동기화블럭에 의해서 일시정지된 상태 (lock이 풀릴 때까지 기다리는 상태) |
WAITING, TIMED_WAITING | 쓰레드의 작업이 종료되지는 않았지만 실행가능하지 않은 (unrunnable) 일시정지 상태. TIMED_WAITING은 일시정지기간이 지정된 경우를 의미한다. |
TERMINATED | 쓰레드의 작업이 종료된 상태 |
다음 그림은 쓰레드의 생성부터 소멸까지의 모든 과정을 그린 것이다.
1. 쓰레드를 생성(NEW)하고 start()를 호출하면 바로 실행되는 것이 아니라 실행대기열(queue)에 저장되어 기다린다.
2. 실행대기상태(RUNNABLE)에 있다가 자신의 차례가 되면 실행된다.
3. 주어진 실행시간이 다되면 다시 실행대기상태가 된다.
4. 실행 중에 입출력작업을 하는 I/O block 등에 의해서 일시정지상태(WAITING, BLOCKED)가 될 수 있다.
5. 지정된 일시정지시간이 다되거나(time-out) interrupt() 등의 일시정지상태를 깨우는 메서드가 호출되면 다시 실행대기상태가 된다.
6. 실행을 모두 마치면 쓰레드는 소멸(TERMINATED)된다.
이 밖에 쓰레드의 스케줄링을 위한 여러 메서드들이 있는데 간단히 알아보자.
sleep
sleep()은 지정된 시간동안 쓰레드를 멈추게 한다.
static void sleep(long mills);
static void sleep(long mills, int nanos);
밀리세컨드와 나노세컨드의시간단위로 세밀하게 값을 지정할 수 있다.
sleep()에 의해 일시정지 상태가 된 쓰레드는 지정된 시간이 다 되거나 interrupt()가 호출되면, 잠에서 깨어나 실행대기 상태가 된다.
sleep()을 호출할 때는 try-catch문으로 예외를 처리해줘야 한다. 코드가 번거로워질 것 같으면 lombok의 @Sneakythrows를 사용해도 된다.
interrupt
진행 중인 쓰레드의 작업이 끝나기 전에 취소시켜야할 때가 있다. 예를 들어 큰 파일을 다운로드 받을 때 시간이 너무 오래걸리면 중간에 취소할 수 있어야 한다.
interrupt()는 쓰레드에게 작업을 멈추라고 요청한다.(종료는 아니다)
그리고 interrupted()는 쓰레드에 대해 interrupt()가 호출되었는지 알려준다. interrupt()가 호출되지 않았다면 false를, 호출되었다면 true를 반환한다.
void interrupt() //쓰레드의 interrupted상태를 false에서 true로 변경
boolean isInterrupted() //쓰레드의 interrupted상태를 반환.
static boolean interrupted() //현재 쓰레드의 interrupted상태를 반환 후, false로 변경
yield
yield는 쓰레드 자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보한다.
static void yield();
join
쓰레드 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때 join()을 사용한다.
void join()
void join(long mills)
void join(long mills, int nanos)
시간을 지정하지 않으면, 다른 쓰레드가 작업을 모두 마칠 때까지 기다리게 된다.
쓰레드의 우선순위
쓰레드는 우선순위(prioirty)라는 속성(멤버변수)을 가지고 있는데, 이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다.
즉, 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 서로 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있다.
쓰레드의 우선순위와 관련된 메서드와 상수는 다음과 같다.
void setPriority(int newPrioirity) //쓰레드의 우선순위를 지정한 값으로 변경한다.
int getPrioirty() //쓰레드의 우선순위를 반환한다.
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 ThreadEx8 {
public static void main(String[] args) {
ThreadEx8_1 t1 = new ThreadEx8_1();
ThreadEx8_2 t2 = new ThreadEx8_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 ThreadEx8_1 extends Thread {
public void run() {
for (int i = 0; i < 300; i++) {
System.out.print("-");
for (int x = 0; x < 10000000; x++);
}
}
}
class ThreadEx8_2 extends Thread {
public void run() {
for (int i = 0; i < 300; i++) {
System.out.print("|");
for (int x = 0; x < 10000000; x++);
}
}
}
t1, t2 모두 main메서드에서 생성하였기 때문에 main메서드를 실행하는 쓰레드의 우선순위인 5를 상속받았다.
다만 t2.setPrioirty(7)로 t2의 우선순위를 7로 변경한다.
싱글코어, 멀티코어 상황에서의 결과를 살펴보자.
<결과 1 - 싱글코어>
Priority of t1(-) : 5
Priority of t2(|) : 7
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|||||||||||||||||||||||---||||||||||||||||||||||||||||||||||||||||||||||||
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
||||||||||||||----||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|||||||||||---------------------------------------------------------------
--------------------------------------------------------------------------
--------------------------------------------------------------------------
--------------------------------------------------------------------------
-----
<결과 2 - 멀티코어>
Priority of t1(-) : 5
Priority of t2(|) : 7
-|-|----------------------------------------------------------------------
|----------|--------------------------------------------------------------
--------------||||||||----------------------------------------------------
----|--|||||||||||---|||||||||||||||||||||||||||||||||||||||||||||||||||||
||||||||||||||||||||||||||||----------------------------------------||----
||||||||||||||--------------------------||-|||||||||--------||||||||||||||
||--||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
||||||||
싱글코어의 경우 한 번에 하나의 작업만 가능하기 때문에 우선순위가 높은 t2 쓰레드에게 더 많은 양의 실행시간이 주어지고 결과적으로 t2 쓰레드가 더 빨리 완료된다.
멀티코어의 경우 쓰레드의 우선순위에 따른 차이가 거의 아니 전혀 없다.
결국 우선순위에 차등을 두어 쓰레드를 실행시키는 것은 별 효과가 없고 높은 우선순위에 대해 더 많은 실행시간을 갖게 될 것이라고 기대할 수 없다.
요즘은 보통 멀티코어 컴퓨터를 사용하기 때문에, 차라리 쓰레드에 우선순위를 부여하는 대신 작업에 우선순위를 두어 PriorityQueue에 저장해 놓고, 우선순위가 높은 작업이 먼저 처리되도록 하는 것이 나을 수 있다.
Main 쓰레드
main메서드의 작업을 수행하는 것도 쓰레드이다. 그리고 이를 main쓰레드라고 한다.
지금까지는 main메서드가 수행을 마치면 프로그램이 종료되었으나, main메서드가 수행을 마쳤다하더라고 다른 쓰레드가 아직 작업을 마치지 않을 상태라면 프로그램이 종료되지 않는다.
즉, 실행 중인 사용자 쓰레드가 하나도 없을 때 프로그램은 종료된다.
<데몬 쓰레드>
참고로 쓰레드는 '사용자 쓰레드(user thread)'와 '데몬 쓰레드(daemon thread)', 두 종류가 있는데,
- 사용자 쓰레드는 일반적인 쓰레드를 이야기 하고,
- 데몬 스레드는 일반 쓰레드의 작업을 돕는 보조적인 역할을 하기 때문에(가비지 컬렉터 등) 일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제 종료된다.
boolean isDaemon() //쓰레드가 데몬쓰레드이닞 확인한다.
void setDaemon(boolean on) //쓰레드를 데몬 쓰레드(true)로 또는 사용자 쓰레드(false)로 변경한다.
<Main 쓰레드와 호출스택>
다음 두 예제를 통해 main쓰레드와 호출스택에 대해 이해해보자.
<start()>
public class ThreadEx2 {
public static void main(String[] args) {
ThreadEx2_1 t1 = new ThreadEx2_1();
t1.start();
}
}
class ThreadEx2_1 extends Thread {
public void run() {
throwException();
}
public void throwException() {
try {
throw new Exception();
} catch (Exception e) {
e.printStackTrace();
}
}
}
<결과>
java.lang.Exception
at week10.ThreadEx2_1.throwException(ThreadEx2.java:17)
at week10.ThreadEx2_1.run(ThreadEx2.java:12)
새로 생성한 쓰레드에서 고의로 예외를 발생시키고 printStackTrace()를 이용해서 예외가 발생한 당시의 호출스택을 출력하는 예제이다.
쓰레드를 start()로 실행했기 때문에 t1 쓰레드를 위한 새로운 호출스택이 생성되었고, main쓰레드가 아닌 t1 쓰레드의 호출스택이 출력된 모습이다.
이처럼 한 쓰레드가 예외가 발생해서 종료되어도 다른 쓰레드의 실행에는 영향을 미치지 않는다. 여기서 main쓰레드는 이미 종료되었다.
<run()>
public class ThreadEx3 {
public static void main(String[] args) {
ThreadEx3_1 t1 = new ThreadEx3_1();
t1.run();
}
}
class ThreadEx3_1 extends Thread {
public void run() {
throwException();
}
public void throwException() {
try {
throw new Exception();
} catch (Exception e) {
e.printStackTrace();
}
}
}
<결과>
java.lang.Exception
at week10.ThreadEx3_1.throwException(ThreadEx3.java:17)
at week10.ThreadEx3_1.run(ThreadEx3.java:12)
at week10.ThreadEx3.main(ThreadEx3.java:6)
이전 예제와 달리 쓰레드가 새로 생성되지 않았다. 그저 ThreadEx3_1클래스의 run()이 호출되었을 뿐이다.
결국 출력된 호출스택은 main쓰레드의 호출스택이다.
동기화
멀티쓰레드 프로세스의 경우 여러 쓰레드가 같은 프로세스내의 자원을 공유해서 작업하기 때문에, 서로의 작업에 영향을 주게 된다.
이러한 일이 발생하는 것을 방지하기 위해서 한 쓰레드가 특정 작업을 끝마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 하는 것이 필요하다. 그래서 도입된 개념이 바로 '임계 영역(critical section)'과 '잠금(lock)'이다.
공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정해놓고, 공유 데이터에 대한 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 한다.
그리고 해당 쓰레드가 임계 영역 내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만 다른 쓰레드가 반납된 lock을 획득하여 임계 영역의 코드를 수행할 수 있게 된다.
이처럼 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 '쓰레드의 동기화(synchronization)'라고 한다.
synchronized
먼저 가장 간단한 동기화 방법인 synchronized 키워드를 이용한 동기화에 대해서 알아보자.
1. 메서드 전체를 임계 영역으로 지정
public synchronized void calcSum() {
//...
}
2. 특정한 영역을 임계 영역으로 지정
synchronized(객체의 참조변수) {
//...
}
lock은 객체 단위로 얻을 수 있다. 즉, 해당 객체의 lock을 얻은 쓰레드만이 객체 내부의 임계 영역인 메서드 혹은 특정 영역의 코드를 수행할 수 있게 된다.
synchronized 키워드를 사용해 임계 영역만 지정해주면 자동적으로 lock의 획득과 반납이 이루어진다.
+) synchronized는 2번의 방식처럼 최대한 좁은 영역으로 설정해 주는 것이 좋다.
(lock을 거는 영역을 좁게 해야 다른 쓰레드들이 lock이 불필요한 코드에 접근할 수 있다.)
다음 예제를 보자.
public class ThreadEx21 {
public static void main(String[] args) {
RunnableEx21 r = new RunnableEx21();
new Thread(r).start();
new Thread(r).start();
}
}
class Account {
private int balance = 1000;
public int getBalance() {
return balance;
}
public void withdraw(int money) {
if (balance >= money) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
balance -= money;
}
}
}
class RunnableEx21 implements Runnable {
Account acc = new Account();
public void run() {
while (acc.getBalance() > 0) {
//100, 200, 300중의 한 값을 임의로 선택해서 출금(withdraw)
int money = (int) (Math.random() * 3 + 1) * 100;
acc.withdraw(money);
System.out.println("balance:" + acc.getBalance());
}
}
}
<결과>
balance:700
balance:500
balance:200
balance:0
balance:-200
은행계좌(account)에서 잔고(balance)를 확인하고 임의의 금액을 출금(withdraw)하는 예제이다.
withdraw()를 보면 잔고(balance)가 출금하려는 금액(money) 이상인 경우에만 출금하도록 되어 있다.
그러나 실행결과를 보면 잔고(balance)가 음수인 것을 볼 수 있다.
그 이유는 한 쓰레드가 withdraw()의 조건식을 통과하고 출금하기 바로 직전에 다른 쓰레드가 끼어들어서 출금을 먼저 했기 때문이다.
예를 들어, 쓰레드A가 잔고(balance)가 200이고 출금하려는 금액(money)이 200이어서 if문을 통과했는데, 다른 쓰레드B가 끼어들어 먼저 200을 출금해버리면 잔고가 0인 상태에서 쓰레드A가 200을 출금하므로 남은 잔고는 결국 -200이 된다.
그래서 잔고를 확인하는 if문과 출금하는 문장은 하나의 임계 영역으로 묶여져야 한다.
<코드 수정>
public synchronized void withdraw(int money) {
if (balance >= money) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
balance -= money;
}
}
<결과>
balance:700
balance:500
balance:400
balance:100
balance:0
balance:0
wait()과 notify()
synchronized로 동기화하는 방법은 임계 영역을 보호하는 차원에서 좋다.
그러나 특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것도 중요하다. 그렇지 않으면 한 쓰레드의 락을 기다리느라 다른 쓰레드들의 작업들도 원활히 진행되지 않을 것이다.
이러한 상황을 개선하기 위해 고안된 것이 바로 wait()과 notify()이다.
- 쓰레드 A가 동기화된 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, 일단 wait()을 호출하여 락을 반납하고 대기한다.
- 그러면 다른 쓰레드 B가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 된다.
- 나중에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서, 다시 쓰레드A가 락을 얻어 작업을 진행한다.
wait(), notify(), notifyAll()
- 특정 객체에 대한 것이므로 Object에 정의되어 있다.
- 동기화 블록(synchronized블록) 내에서만 사용할 수 있다.
- 보다 효율적인 동기화를 가능하게 한다.
wait()이 호출되면 실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 notify()를 기다린다.
notify()가 호출되면, 해당 객체의 대기실에 있던 모든 쓰레드 중에서 임의의 쓰레드 하나만 통지를 받는다.
그래서 notifyAll()이라는 것이 있는데, 이것도 결국 모든 쓰레드에게 통보를 하지만, 락을 걸 수 있는 쓰레드는 한 뿐이므로 나머지 쓰레드는 락을 얻지 못해 다시 기다리는 신세가 된다.
또한, notifyAll()은 객체 단위이기 때문에 모든 객체의 waiting pool의 쓰레드에게 통지하는 것이 아닌 notifyAll()이 호출된 쓰레드만 해당된다.
다음 식당 예제를 통해 wait(), notify()의 필요성을 알아보자.
예제는 식당에서 음식(Dish)을 만들어서 테이블(Table)에 추가(add)하는 요리사(Cook)와 음식을 소비(remove)하는 손님(Customer)을 쓰레드로 구현했다.
public class ThreadWaitEx1 {
public static void main(String[] args) throws Exception {
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(100); //0.1초 후에 강제 종료시킨다.
System.exit(0); //프로그램 전체를 종료.(모든 쓰레드가 종료됨)
}
}
class Customer implements Runnable {
private Table table;
private String food;
Customer(Table table, String food) {
this.table = table;
this.food = food;
}
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. :(");
}
}
}
boolean eatFood() {
return table.remove(food);
}
}
class Cook implements Runnable {
private Table table;
Cook(Table table) {
this.table = table;
}
public void run() {
while (true) {
//임의의 요리를 하나 선택해서 table에 추가한다.
int idx = (int) (Math.random() * table.dishNum());
table.add(table.dishNames[idx]);
try {
Thread.sleep(1);
} catch (InterruptedException e) {
}
}
}
}
class Table {
String[] dishNames = {"donut", "donut", "burger"}; //donut이 더 자주 나온다.
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 < dishes.size(); i++) {
if (dishName.equals(dishes.get(i))) {
dishes.remove(i);
return true;
}
}
return false;
}
public int dishNum() {
return dishNames.length;
}
}
<결과>
CUST1 failed to eat. :(
Dishes:[burger]
Dishes:[donut]
Dishes:[donut, donut]
Dishes:[donut, donut, burger]
Dishes:[donut, donut, burger, donut]
Dishes:[donut, donut, burger, donut, burger]
Dishes:[donut, donut, burger, donut, burger, burger]
Dishes:[donut, burger, donut, burger, burger, burger]
CUST2 ate a burger
CUST1 ate a donut
CUST2 ate a burger
CUST1 ate a donut
Dishes:[burger, donut, burger, burger, burger, donut]
...
CUST1 ate a donut
Dishes:[burger, burger, donut, donut, burger, burger]
CUST2 ate a burger
CUST1 ate a donut
CUST2 ate a burger
CUST1 ate a donut
Exception in thread "COOK1" java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1043)
at java.base/java.util.ArrayList$Itr.next(ArrayList.java:997)
at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:472)
at week10.Table.add(ThreadWaitEx1.java:83)
at week10.Cook.run(ThreadWaitEx1.java:60)
at java.base/java.lang.Thread.run(Thread.java:834)
...
코드가 길어보이지만 클래스 하나하나 보면 단순한 코드다.
위 결과에서는 Exception이 발생했는데, 이는 요리사(Cook) 쓰레드가 테이브렝 음식을 놓는 도중에, 손님(Customer) 쓰레드가 음식을 가져가려했기 때문에 발생하는 예외이다.
이런 예외가 발생하는 이유는 여러 쓰레드가 테이블(자원)을 공유하는데 동기화를 하지 않았기 때문이다.
또한 스레드마다 화면에 출력되는 순서도 뒤죽박죽이다. 이렇게 자원을 공유하는 경우 반드시 동기화가 필요하다.
다음 예제에 동기화를 추가해 예외가 발생하지 않도록 해보자.
<Table 코드 수정>
class Table {
String[] dishNames = {"donut", "donut", "burger"}; //donut이 더 자주 나온다.
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 < dishes.size(); i++) {
if (dishName.equals(dishes.get(i))) {
dishes.remove(i);
return true;
}
}
return false;
}
public int dishNum() {
return dishNames.length;
}
}
<결과>
Dishes:[burger]
CUST1 is waiting. <- 음식이 없어서 테이블에 lock을 건채로 계속 기다린다.
CUST2 ate a burger
CUST1 is waiting.
CUST1 is waiting.
CUST1 is waiting.
CUST1 is waiting.
...
여러 쓰레드가 공유하는 객체인 테이블(Table)의 add()와 remove()를 동기화하였다. 그러나 이 역시 문제가 있다.
손님 쓰레드가 원하는 음식이 테이블에 없으면 0.5초마다 음식이 추가되었는지 기다리도록 작성되어 있는데,
손님 쓰레드가 Table객체의 lock을 쥐고 있기 때문에 요리사 쓰레드가 새로 음식을 추가할 수가 없게 된다.
이제 wait¬ify를 사용해 lock을 제어해보자.
class Customer implements Runnable {
private Table table;
private String food;
Customer(Table table, String food) {
this.table = table;
this.food = food;
}
public void run() {
while (true) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
String name = Thread.currentThread().getName();
table.remove(food);
System.out.println(name + " ate a " + food);
}
}
}
class Cook implements Runnable {
private Table table;
Cook(Table table) {
this.table = table;
}
public void run() {
while (true) {
//임의의 요리를 하나 선택해서 table에 추가한다.
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"}; //donut이 더 자주 나온다.
final int MAX_FOOD = 6; //테이블에 놓을 수 있는 최대 음식의 개수
private ArrayList<String> dishes = new ArrayList<>();
public synchronized void add(String dish) {
while (dishes.size() >= MAX_FOOD) {
String name = Thread.currentThread().getName();
System.out.println(name + " is waiting.");
try {
wait(); //COOK쓰레드를 기다리게 한다.
Thread.sleep(500);
} catch (InterruptedException e) {
}
}
dishes.add(dish);
notify(); //기다리고 있는 CUST를 깨우기 위함.
System.out.println("Dishes:" + dishes.toString());
}
public void remove(String dishName) {
synchronized (this) {
String name = Thread.currentThread().getName();
while (dishes.size() == 0) {
System.out.println(name + " is waiting.");
try {
wait(); //CUST쓰레드를 기다리게 한다.
Thread.sleep(500);
} catch (InterruptedException e) {
}
}
while (true) {
//지정된 요리와 일치하는 요리를 테이블에서 제거한다.
for (int i = 0; i < dishes.size(); i++) {
if (dishName.equals(dishes.get(i))) {
dishes.remove(i);
notify(); //잠자고 있는 COOK을 꺠우기 위함.
return;
}
}
try {
System.out.println(name + " is waiting.");
wait(); //원하는 음식이 없는 CUST쓰레드를 기다리게 한다.
Thread.sleep(500);
} catch (InterruptedException e) {
}
}
}
}
public int dishNum() {
return dishNames.length;
}
}
class ThreadWaitEx3 {
public static void main(String[] args) throws Exception {
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); //2초 후에 강제 종료시킨다.
System.exit(0); //프로그램 전체를 종료.(모든 쓰레드가 종료됨)
}
}
<결과>
Dishes:[burger]
Dishes:[burger, burger]
Dishes:[burger, burger, donut]
Dishes:[burger, burger, donut, donut]
Dishes:[burger, burger, donut, donut, donut]
Dishes:[burger, donut, donut, burger]
Dishes:[burger, donut, donut, burger, burger]
Dishes:[burger, donut, donut, burger, burger, donut]
COOK1 is waiting.
CUST2 ate a burger
CUST1 ate a donut
CUST2 ate a burger
Dishes:[donut, donut, burger, burger, donut, burger]
CUST2 ate a burger
CUST1 ate a donut
Dishes:[donut, burger, donut, burger, donut]
Dishes:[donut, burger, donut, burger, donut, burger]
COOK1 is waiting.
CUST2 ate a burger
Dishes:[donut, donut, burger, donut, burger, burger]
CUST2 ate a burger
CUST1 ate a donut
Dishes:[donut, donut, burger, burger, donut]
Dishes:[donut, donut, burger, burger, donut, donut]
COOK1 is waiting.
CUST2 ate a burger
CUST1 ate a donut
전체적으로 코드를 변경했다.
손님 쓰레드가 lock을 쥐고 기다리는게 아니라, wait()으로 lock을 풀고 기다리다가 음식이 추가되면 notify()로 통보를 받고 다시 lock을 얻어서 나머지 작업을 진행한다.
그리고 테이블에 음식이 없을 때 뿐만 아니라, 원하는 음식이 없을 때도 손님이 기다리도록 바꾸었다.
실행 결과를 보니 뭔가 잘 돌아가는 듯 하다.
그러나 여기에도 한 가지 문제가 있다.
테이블 객체의 waiting pool에 요리사 쓰레드와 손님 쓰레드가 같이 기다린다는 것이다.
그래서 notify()를 호출했을 때, 요리사, 손님 쓰레드 중 누가 통지를 받을지 알 수가 없다.
기아 현상과 경쟁 상태
운이 나쁘면 요리사 쓰레드는 계속 통지를 받지 못하고 오랫동안 기다리게 되는데, 이것을 기아(starvation) 현상이라고 한다.
이를 방지하기 위해, notifyAll()을 사용하면 언젠가 요리사 쓰레드는 lock을 얻어서 작업을 진행할 수 있다.
그러나 notifyAll()로 기아 현상은 막았지만, 손님 쓰레드까지 통지를 받아서 불필요하게 요리사, 손님 간에 lock을 얻기 위한 경쟁을 한다. 이처럼 여러 쓰레드가 lock을 얻기 위해 서로 경쟁하는 것을 경쟁 상태(race condition)라고 하는데,
이를 방지하기 위해, 요리사, 손님 쓰레드를 구별해서 통지하는 것이 필요하다. -> Lock과 Condition을 통해 해결할 수 있다.
Lock과 Condition을 이용한 동기화
synchronized블럭 외에도 lock클래스를 사용해 동기화할 수 있다.
가장 많이 쓰는 클래스는 ReetrantLock이다. 다른 lock들은 Java API 문서를 확인해보고 이번 스터디에서는 생략한다.
<생성자>
ReetrantLock은 다음과 같이 두 개의 생성자를 가지고 있다.
ReentrantLock()
ReentrantLock(boolean fair)
생성자의 매개변수를 true로 주면, lock이 풀렷을 때 가장 오래 기다린 쓰레드가 lock을 획득할 수 있게 공정(fair) 처리한다.
그러나 성능은 떨어진다. 대부분의 경우 굳이 공정하게 처리하지 않아도 문제가 되지 않으므로 공정함보다 성능을 선택한다.
<메서드>
void lock() //lock을 잠근다.
void unlock() //lock을 해지한다.
boolean isLocked() //lock이 잠겼는지 확인한다.
자동적으로 lock의 잠금과 해제를 관리하는 synchronized블럭과 달리, ReetrantLock은 수동으로 lock을 잠그고 해제해야 한다.
임계 영역 내에서 예외가 발생하면 lock이 풀리지 않을 수 있기 때문에 unlock은 try-finally문으로 감싸는 것이 일반적이다.
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
//임계 영역
} finally {
lock.unlock();
}
<Condition>
앞선 예제에서 요리사 쓰레드와 손님 쓰레드를 구분해서 통지하지 못한다는 단점이 있었다. Condition은 이 문제점을 해결하기 위한 것이다.
요리사 쓰레드를 위한 Condition과 손님 쓰레드를 위한 Condition을 만들어서 각각의 waiting pool에 따로 기다리도록 하면 된다.
코드를 보자.
ReentrantLock lock = new ReentrantLock();
private Condition forCook = lock.newCondition(); //요리사를 위한 Condition
private Condition forCust = lock.newCondition(); //손님을 위한 Condition
이렇게 Condition을 생성하고 wait() & notify() 대신 await() & signal()을 사용하면 그걸로 끝이다.
class Customer implements Runnable {
private Table table;
private String food;
Customer(Table table, String food) {
this.table = table;
this.food = food;
}
public void run() {
while (true) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
String name = Thread.currentThread().getName();
table.remove(food);
System.out.println(name + " ate a " + food);
}
}
}
class Cook implements Runnable {
private Table table;
Cook(Table table) {
this.table = table;
}
public void run() {
while (true) {
//임의의 요리를 하나 선택해서 table에 추가한다.
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"}; //donut이 더 자주 나온다.
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(); //COOK쓰레드를 기다리게 한다.
Thread.sleep(500);
} catch (InterruptedException e) {
}
}
dishes.add(dish);
forCust.signal(); //기다리고 있는 CUST를 깨우기 위함.
System.out.println("Dishes:" + dishes.toString());
} finally {
lock.unlock();
}
}
public void remove(String dishName) {
lock.lock();
String name = Thread.currentThread().getName();
try {
while (dishes.size() == 0) {
System.out.println(name + " is waiting.");
try {
forCust.await(); //CUST쓰레드를 기다리게 한다.
Thread.sleep(500);
} catch (InterruptedException e) {
}
}
while (true) {
//지정된 요리와 일치하는 요리를 테이블에서 제거한다.
for (int i = 0; i < dishes.size(); i++) {
if (dishName.equals(dishes.get(i))) {
dishes.remove(i);
forCook.signal(); //잠자고 있는 COOK을 꺠우기 위함.
return;
}
}
try {
System.out.println(name + " is waiting.");
forCust.await(); //원하는 음식이 없는 CUST쓰레드를 기다리게 한다.
Thread.sleep(500);
} catch (InterruptedException e) {
}
}
} finally {
lock.unlock();
}
}
public int dishNum() {
return dishNames.length;
}
}
public class ThreadWaitEx4 {
public static void main(String[] args) throws Exception {
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); //2초 후에 강제 종료시킨다.
System.exit(0); //프로그램 전체를 종료.(모든 쓰레드가 종료됨)
}
}
<결과>
Dishes:[donut]
Dishes:[donut, donut]
Dishes:[donut, donut, donut]
Dishes:[donut, donut, donut, burger]
Dishes:[donut, donut, donut, burger, donut]
Dishes:[donut, donut, donut, donut]
Dishes:[donut, donut, donut, donut, donut]
Dishes:[donut, donut, donut, donut, donut, burger]
COOK1 is waiting.
CUST1 ate a donut
CUST2 ate a burger
Dishes:[donut, donut, donut, donut, burger]
CUST2 ate a burger
CUST1 ate a donut
Dishes:[donut, donut, donut, donut]
Dishes:[donut, donut, donut, donut, donut]
Dishes:[donut, donut, donut, donut, donut, donut]
COOK1 is waiting.
CUST2 is waiting.
CUST1 ate a donut
Dishes:[donut, donut, donut, donut, donut, burger]
CUST1 ate a donut
CUST2 ate a burger
Dishes:[donut, donut, donut, donut, donut]
CUST1 ate a donut
Dishes:[donut, donut, donut, donut, burger]
Dishes:[donut, donut, donut, donut, burger, burger]
COOK1 is waiting.
이제 요리사 쓰레드가 통지를 받아야 하는 상황에서 손님 쓰레드가 통지를 받는 경우가 없어졌다.
기아 현상과 경쟁 상태가 확실히 개선된 것이다.
그러나 이 역시 쓰레드의 종류에 따라 구분할 뿐, 특정 쓰레드를 선택할 수는 없기 때문에 여전히 위험성이 남아있다.
이는 손님이 원하는 음식의 종류로 Condition을 더 세분화하여 해결할 수 있다.
데드락
지금까지 멀티쓰레드 프로그래밍에서 동기화를 통해 lock을 획득하여 동일환 자원을 여러 곳에서 함부로 사용하지 못하도록 하였다.
하지만 두 개의 쓰레드에서 서로가 가지고 있는 lock이 해제되기를 기다리는 상태가 생길 수 있으며 이러한 상태를 교착상태, 즉 데드락이라고 한다.
데드락 발생 조건
- 상호 배제: 한 자원에 대해 여러 쓰레드 동시 접근 불가
- 점유와 대기: 자원을 가지고 있는 상태에서 다른 쓰레드가 사용하고 있는 자원 반납을 기다리는 것
- 비선점: 다른 쓰레드의 자원을 실행 중간에 강제로 가져올 수 있음
- 환형대기: 각 쓰레드가 순환적으로 다음 쓰레드가 요구하는 자원을 가지고 있는 것
위의 4가지 조건을 모두 충족할 경우 데드락이 발생하게 된다.
반대로 말하면 위 4가지 중 하나라도 충족하지 않을 경우 데드락을 해결할 수 있다.
다음 예제를 보자.
public class Deadlock {
public static Object object1 = new Object();
public static Object object2 = new Object();
public static void main(String[] args) {
FirstThread thread1 = new FirstThread();
SecondThread thread2 = new SecondThread();
thread1.start();
thread2.start();
}
private static class FirstThread extends Thread {
public void run() {
synchronized (object1) {
System.out.println("First Thread has object1's lock");
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("First Thread want to have object2's lock. so wait");
synchronized (object2) {
System.out.println("First Thread has object2's lock too");
}
}
}
}
private static class SecondThread extends Thread {
public void run() {
synchronized (object2) {
System.out.println("Second Thread has object2's lock");
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Second Thread want to have object1's lock. so wait");
synchronized (object1) {
System.out.println("Second Thread has object1's lock too");
}
}
}
}
}
- 상호 배제: object1과 object2가 객체를 두 쓰레드가 동시에 사용할 수 없다.
- 점유와 대기: FirstThread에서는 object1의 락을 가지고 있으면서 object2의 락을 원하고 SecondThread도 반대로 마찬가지이다.
- 비선점: 쓰레드의 우선순위의 기본값은 NORM_PRIORITY로 동일하다.
- 환형대기: FirstThread는 SecondThread의 object2 락을 대기하고 SecondThread도 반대로 마찬가지이다.
<결과>
First Thread has object1's lock
Second Thread has object2's lock
First Thread want to have object2's lock. so wait
Second Thread want to have object1's lock. so wait
실행결과 데드락이 발생하여 아무런 실행하지 못한채 무한정 대기하고 있다.
<SecondThread 수정>
private static class SecondThread extends Thread {
public void run() {
synchronized (object1) {
System.out.println("Second Thread has object1's lock");
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Second Thread want to have object2's lock. so wait");
synchronized (object2) {
System.out.println("Second Thread has object2's lock too");
}
}
}
}
SecondThread도 FirstThread처럼 object1의 lock먼저 접근하도록 수정했다.
<결과>
First Thread has object1's lock
First Thread want to have object2's lock. so wait
First Thread has object2's lock too
Second Thread has object1's lock
Second Thread want to have object2's lock. so wait
Second Thread has object2's lock too
점유와 대기, 환형대기 조건을 만족하지 않기 때문에 데드락이 발생하지 않는 것을 볼 수 있다.
+) 사실 지금까지 공부한 내용은 현업에서 사용할 일은 거의 없다. 즉, 현업에서 Thread를 상속받거나 Runnable을 구현할 일은 거의 없다는 뜻이다.
그럼 언제 사용할까?
서버를 직접 만드는 경우 동시성, 병렬 처리를 위해 사용한다.
우리가 스프링 등의 프레임워크를 사용할 때, 멀티쓰레드 프로그래밍을 안하는 이유는 우리 대신에 서버 컨테이너들(tomcat 등)이 알아서 멀티쓰레딩 작업을 해주기 때문에 실질적으로 사용할 일은 많지 않은 것이다.
다만, 멀티쓰레딩을 사용하지 않는 개발은 거의 없기 때문에 상세히는 아니더라도 기본적으로 알아둬야 하는 내용들이고, 멀티쓰레딩은 면접 때 물어볼만한 부분이기도 하다.
'java > java' 카테고리의 다른 글
[Java] 다중 조건 정렬 (0) | 2021.08.29 |
---|---|
[Java] Enum (1) | 2021.08.27 |
[Java] 예외 처리 (1) | 2021.08.12 |
[Java] 인터페이스 (2) | 2021.07.27 |
[Java] 패키지 (2) | 2021.07.21 |