3. 스레드의 제어
스레드의 우선권
- 스레드의 우선권(Priority)을 어떻게 주느냐에 따라서 스레드의 작업 순서가 달라집니다.
- 작업 순서가 달라진다는 것은 Runnable 상태에서 얼마나 자주 Run 상태가 될 수 있느냐이다
- 우선권이 높다면 Run 상태가 될 확률이 높다. 우선권이 높다면 다른 스레드들보다 작업을 빨리 끝낼 수 있다. 스레드에 할당할 수 있는 스레드의 우선권 상수는 다음과 같습니다.
Thread 클래스의 스태틱 우선권 상수
public static final int MIN_PRIORITY = 1;
public static final int NORM_PRIORITY = 5;
public static final int MAX_PRIORITY = 10;
- 스레드의 우선권에 대한 값은 Thread 클래스의 public static final 멤버로 정의되어 있습니다. 스레드의 우선권을 셋팅하기 위해서 1부터 10까지의 수를 사용해도 되며, Thread의 스태틱 우선권 상수변수를 사용해도 됩니다. 우선권을 설정하기 위해서는 다음과 같이 Thread의 setPriority() 메서드를 사용하면 됩니다.
스레드의 상태 설정하기
PriorityThread t = new PriorityThread();
t.setPriority(1);
//t.setPriority(Thread.MIN_PRIORITY);
//우선권이 가장 낮은 상태
t.setPriority(5);
//t.setPriority(Thread.NORM_PRIORITY);
//일반적인 스레드가 갖는 우선권
t.setPriority(10);
//t.setPriority(Thread.MAX_PRIORITY);
//우선권이 가장 높은 상태
- 현재 실행중인 스레드의 우선권을 얻고자 한다면 다음과 같이 getPriority()를 사용하면 됩니다.
스레드에 설정된 우선권 얻어내기
int p = t.getPriority();
- 스레드의 우선권을 설정하기 위해서 setPriority() 메서드를 사용하고, 현재 설정되어 있는 우선권을 얻어내기 위해서 getPriority()를 사용한다.
자! 그럼 이 두 메서드를 이용해서 스레드의 우선권을 변경해 보도록 하겠습니다.
/**
스레드의 우선권을 테스트하는 예제
**/
class PriorityThread extends Thread {
public void run() {
int i = 0;
System.out.print(this.getName()); //스레드의 이름 출력
System.out.println("[우선권:" + this.getPriority() + "] 시작\t");
while(i < 10000) {
i = i + 1;
try{
this.sleep(1);
}catch(Exception e){System.out.println(e);}
}
System.out.print(this.getName()); //스레드의 이름 출력
System.out.println("[우선권:" + this.getPriority() + "] 종료\t");
}
} //end of PriorityThread class
public class PriorityThreadMain {
public static void main(String[] args) {
System.out.println("Main메서드 시작");
for(int i=1; i<=10; i++){
//for(int i=Thread.MIN_PRIORITY; i<=Thread.MAX_PRIORITY; i++){
PriorityThread s = new PriorityThread();
s.setPriority(i);
s.start();
}
System.out.println("Main메서드종료");
}//end of main
} //end of PriorityThreadMain class
/***
C:\javasrc\chap08>javac PriorityThreadMain.java
C:\javasrc\chap08>java PriorityThreadMain
Main메서드 시작
Thread-6[우선권:6] 시작
Thread-7[우선권:7] 시작
Thread-8[우선권:8] 시작
Thread-9[우선권:9] 시작
Thread-5[우선권:5] 시작
Thread-10[우선권:10] 시작
Main메서드종료
Thread-3[우선권:3] 시작
Thread-4[우선권:4] 시작
Thread-1[우선권:1] 시작
Thread-2[우선권:2] 시작
Thread-10[우선권:10] 종료
Thread-8[우선권:8] 종료
Thread-9[우선권:9] 종료
Thread-6[우선권:6] 종료
Thread-7[우선권:7] 종료
Thread-5[우선권:5] 종료
Thread-3[우선권:3] 종료
Thread-4[우선권:4] 종료
Thread-1[우선권:1] 종료
Thread-2[우선권:2] 종료
***/
───────────────────────────────────────ⓑ
1부터 10까지 우선권 부여
for(int i=1; i<=10; i++){
//for(int i=Thread.MIN_PRIORITY; i<=Thread.MAX_PRIORITY; i++){
PriorityThread s = new PriorityThread();
s.setPriority(i);
s.start();
}
- MIN_PRIORITY와 MAX_PRIORITY까지 모든 우선권을 전부 사용해서 스레드를 생성하고 있습니다.
- 결과는 MAX_PRIORITY를 가진 Thread-9번이 제일 먼저 작업을 끝내고, Thread-0번이 마지막에 작업을 끝내는 것을 볼 수 있습니다.
NotRunnable 상태 만들기
- 스레드가 NotRunnable 상태가 될 수 있는 방법은 다음과 같이 두 가지가 있습니다.
스레드를 NotRunnable 상태로 만드는 방법
- sleep()을 이용해서 일정시간 동안만 대기시키는 방법(자동)
- wait()와 notify()를 이용해서 대기와 복귀를 제어하는 방법(수동)
- sleep()과 wait()는 스레드를 NotRunnable 상태로 만듭니다. sleep()의 경우는 주어진 시간 만큼만 NotRunnable 상태로 보내며, 시간이 완료되면 자동으로 Runnable 상태로 복귀하게 됩니다.
- wait()의 경우에는 사용자가 직접 wait()를 호출해서 NotRunnable 상태로 만들며, Runnable 상태로 되돌아오기 위해서는 수동으로 notify()를 호출해 주어야 합니다.
- sleep()은 자동이며 wait()와 notify()는 수동으로 처리되는 것입니다. sleep()의 경우에는 다음과 같이 대기시킬 시간만 주고 호출하면 됩니다.
sleep()의 사용
try{
Thread.sleep(1000); //시간의 단위는 1/1000초
}catch(InterruptedException e){e.printStackTrace();}
- 1초만큼 NotRunnable 상태가 되며 1초가 지난 뒤에는 자동으로 Runnable 상태로 되돌아와서 작업을 재개하게 됩니다.
- sleep()은 Thread의 스태틱 메서드이기 때문에 프로그램 어디서나 사용할 수 있으며 일시적으로 작업을 중단(대기)시키는 역할을 합니다. 다음은 main()에서 작업을 5초동안 멈추게 하는 예입니다.
ⓙ───────────────────────────────────────
/**
sleep()을 이용한 작업의 일시 중단 - main()에서의 Thread.sleep()
**/
public class NotRunnableMain{
public static void main(String[] args){
long current = System.currentTimeMillis();
System.out.println("프로그램 시작");
try{
Thread.sleep(5000);
}catch(InterruptedException e){e.printStackTrace();}
System.out.println("프로그램 종료");
System.out.println("시간: " + (System.currentTimeMillis()-current));
} //end of main
} //end of NotRunnableMain
//㉶--------------------------------------------㉳
/***
C:\javasrc\chap08>javac NotRunnableMain.java
C:\javasrc\chap08>java NotRunnableMain
프로그램 시작
프로그램 종료
시간: 5000
***/
───────────────────────────────────────ⓑ
- main()에도 sleep()을 사용할 수 있으며, 스레드의 run()에서도 사용할 수 있습니다. 그리고 sleep()을 사용할 때 의무적으로 InterruptedException 처리를 해주어야 합니다. 다음은 스레드에서 sleep()을 사용한 예입니다.
ⓙ───────────────────────────────────────
/**
스레드에서 sleep()의 사용
**/
import java.util.*;
class NotRunnableThread extends Thread {
public void run() {
int i = 0;
while(i < 10) {
System.out.println(i + "회:" + System.currentTimeMillis() + "\t");
i = i + 1;
try{
this.sleep(1000);
}catch(Exception e){System.out.println(e);}
}
}
} //end of NotRunnableThread
public class NotRunnableThreadMain {
public static void main(String args[] ) {
NotRunnableThread s = new NotRunnableThread();
s.start();
} //end of main
} //end of NotRunnableThread class
//㉶--------------------------------------------㉳
/***
C:\javasrc\chap08>javac NotRunnableThreadMain.java
C:\javasrc\chap08>java NotRunnableThreadMain
0회:1087996213815
1회:1087996214815
2회:1087996215815
3회:1087996216815
4회:1087996217815
5회:1087996218815
6회:1087996219815
7회:1087996220815
8회:1087996221815
9회:1087996222815
***/
───────────────────────────────────────ⓑ
- 일반적으로 스레드는 지속적인 작업을 목적으로 하기 때문에 일정 시간동한 작업을 멈추게 하는 것은 반드시 필요합니다. 하나의 스레드가 CPU를 독점하는 것을 막기 위해서 sleep()은 간단하면서도 효과적인 방법입니다.
- Thread를 상속받은 상태에서 sleep()을 사용할 때에는 Thread.sleep()처럼 사용하지 않아도 됩니다. Thread의 멤버이기 때문에 위에서는 다음과 같이 사용하고 있습니다.
Thread를 상속한 경우 sleep()의 사용
this.sleep(1000);
- 위의 예제에서 run() 메서드 내의 sleep()은 해당 스레드를 일정 시간동안 NotRunnable 상태로 만들어 버립니다.
- NotRunnable 상태가 되었을 때 다른 스레드가 Runnable 상태에서 CPU의 제어권을 가지게 될 확률이 높아집니다.
- 보통의 경우에는 지금처럼 하나의 스레드를 제어하는 것이 아니라 여러 개의 스레드를 동시에 제어하면서, sleep()을 이용해서 작업의 로드 밸런싱(Load Balancing)을 하게 됩니다.
public static void sleep(long millis) throws InterruptedException
millis 시간만큼 작업을 멈추게 되며, 시간이 경과했을 때 다시 작업을 재개하게 된다.
millis는 1/1000초 단위를 사용한다.
스레드 죽이기
- 스레드를 계속 살아서 움직이게 하기 위해서 보통 run() 메서드 내에서 while문을 사용합니다. 조건이 만족하는 한 지속되고 반복된다는 특징 때문에 run() 메서드에서 while문이 자주 등장합니다. 스레드의 종료는 run() 메서드의 종료를 의미하기 때문에 일반적으로 while문의 종료가 곧 스레드의 종료가 되는 경우가 많습니다.
- 사실 스레드에 대해서 조금 아시는 분들은 스레드를 종료하기 위해서 stop()을 사용하지 않느냐라고 말할 것입니다. 물론 자바의 스레드를 종료하기 위해서 다음과 같은 메서드를 제공하고 있지만 이 메서드는 Deprecated 되었습니다.
Deprecated된 Thread의 stop() 메서드
public final void stop() Deprecated
public final void stop(Throwable obj) Deprecated
- 보통의 경우 어떠한 방식으로든 run()을 종료함으로써 스레드를 멈추게 하면 됩니다. 일반적으로 다음과 같이 while문의 조건을 제어함으로써 run()을 빠져 나오게 하는 방법을 주로 사용합니다.
run() 메서드의 일반적인 모델(while문의 조건에 따라 run()의 종료를 제어)
public void run(){
while(조건){
//작업
}
}
- while문의 조건을 이용한 스레드의 종료를 테스트하는 예제는 다음과 같습니다.
ⓙ───────────────────────────────────────
/**
while문의 조건을 이용한 스레드의 종료를 테스트하는 예
**/
class TerminateThread extends Thread {
//스레드의 종료를 제어하는 플래그
private boolean flag = false;
public void run() {
int count = 0;
System.out.println(this.getName() +"시작");
while(!flag) {
try {
//작업
this.sleep(100);
} catch(InterruptedException e) { }
}
System.out.println(this.getName() +"종료");
}
public void setFlag(boolean flag){
this.flag = flag;
}
} //end of TerminateThread class
public class TerminateThreadMain {
public static void main(String args[])throws Exception{
System.out.println("작업시작");
TerminateThread a = new TerminateThread();
TerminateThread b = new TerminateThread();
TerminateThread c = new TerminateThread();
a.start();
b.start();
c.start();
int i;
System.out.print("종료할 스레드를 입력하시오! A, B, C, M?\n");
while(true){
i = System.in.read();
if(i == 'A'){
a.setFlag(true);
}else if(i == 'B'){
b.setFlag(true);
}else if(i == 'C'){
c.setFlag(true);
}else if(i == 'M'){
a.setFlag(true);
b.setFlag(true);
c.setFlag(true);
System.out.println("main종료");
break;
}
}
} //end of main
} //end of TerminateThreadMain class
//㉶--------------------------------------------㉳
/***
C:\javasrc\chap08>javac TerminateThreadMain.java
C:\javasrc\chap08>java TerminateThreadMain
작업시작
종료할 스레드를 입력하시오! A, B, C, M?
Thread-1시작
Thread-2시작
Thread-3시작
A
Thread-1종료
B
Thread-2종료
C
Thread-3종료
M
main종료
C:\javasrc\chap08>java TerminateThreadMain
작업시작
종료할 스레드를 입력하시오! A, B, C, M?
Thread-1시작
Thread-2시작
Thread-3시작
M
main종료
Thread-1종료
Thread-2종료
Thread-3종료
***/
───────────────────────────────────────ⓑ
- 외부에서 스레드를 제어하기 위해서 스레드에 다음과 같이 boolean형 멤버 변수와 이 멤버 변수를 셋팅하는 멤버 메서드를 두고 있습니다.
스레드 제어를 위한 flag 설정하기
private boolean flag = false;
//flag가 true로 설정되면 while문이 끝난다.
public void setFlag(boolean flag){
this.flag = flag;
}
- 그리고 스레드를 제어하기 위해서 while문의 조건에 flag를 이용하고 있습니다.
flag를 이용한 run() 내의 while문 제어
while(!flag) {
//작업
}
- 콘솔에서 스레드를 제어하기 위한 문자를 입력받기 위해서 다음과 같이 System.in을 이용하고 있습니다.
i = System.in.read();
- 문자는 숫자형식으로 입력받게 되며, 'A', 'B', 'C', 'M'의 입력문자에 따라 각각의 스레드를 제어하고 있습니다. flag의 설정이 true가 되면 해당 스레드는 종료하게 됩니다.
- 'A'를 입력받으면 스레드 a를, 'B'를 입력받으면 스레드 b를, 'C'를 입력받으면 스레드 c를 종료하게 되며, 'M'을 입력받으면 전체 스레드를 하나씩 멈추게 되며 main()의 while문까지 빠져 나오게 됩니다.
- 위의 예는 각각의 스레드를 제어하기 위한 수단으로 boolean형 멤버 변수를 이용하게 됩니다. 이러한 제어는 스레드의 종료를 제어하는 일반적인 방법입니다.
- 하지만 각각의 스레드를 제어해야 한다는 단점이 있습니다.
- 만약 한번에 모든 스레드를 제어하고자 한다면 스태틱 멤버 변수를 이용해서 종료를 제어하면 됩니다.
- 다음은 각각의 스레드 제어와 통합적인 스레드 제어를 위해서 일반 멤버 변수와 스태틱 멤버 변수를 이용하는 예입니다.
ⓙ───────────────────────────────────────
/**
두개의 조건을 이용한 스레드의 종료
**/
class ControlThread extends Thread {
//모든 스레드의 종료를 제어하는 플래그
public static boolean all_exit = false;
//스레드의 종료를 제어하는 플래그
private boolean flag = false;
public void run() {
int count = 0;
System.out.println(this.getName() +"시작");
//flag나 all_exit 둘 중 하나만 true이면 while문이 끝난다.
while(!flag && !all_exit) {
try {
//작업
this.sleep(100);
} catch(InterruptedException e) { }
}
System.out.println(this.getName() +"종료");
}
public void setFlag(boolean flag){
this.flag = flag;
}
} //end of ControlThread class
public class ControlThreadMain {
public static void main(String args[])throws Exception{
System.out.println("작업시작");
ControlThread a = new ControlThread();
ControlThread b = new ControlThread();
ControlThread c = new ControlThread();
a.start();
b.start();
c.start();
Thread.sleep(100);
int i;
System.out.print("종료할 스레드를 입력하시오! A, B, C, M?\n");
while(true){
i = System.in.read();
if(i == 'A'){
a.setFlag(true);
}else if(i == 'B'){
b.setFlag(true);
}else if(i == 'C'){
c.setFlag(true);
}else if(i == 'M'){
//모든 스레드를 종료시킨다.
ControlThread.all_exit = true;
System.out.println("main종료");
break;
}
}
} //end of main
} //end of ControlThreadMain class
//㉶--------------------------------------------㉳
/***
C:\javasrc\chap08>javac ControlThreadMain.java
C:\javasrc\chap08>java ControlThreadMain
작업시작
종료할 스레드를 입력하시오! A, B, C, M?
Thread-1시작
Thread-2시작
Thread-3시작
A
Thread-1종료
B
Thread-2종료
C
Thread-3종료
M
main종료
C:\javasrc\chap08>java ControlThreadMain
작업시작
종료할 스레드를 입력하시오! A, B, C, M?
Thread-1시작
Thread-2시작
Thread-3시작
M
main종료
Thread-1종료
Thread-2종료
Thread-3종료
***/
───────────────────────────────────────ⓑ
- 이 예제에서는 조건을 두 개 두고 있습니다. flag는 스레드 각각을 제어하기 위한 것이며, all_exit는 모든 스레드를 제어하기 위한 것입니다.
public boolean all_exit = false;
private boolean flag = false;
- 스태틱은 모든 객체에서 단 하나의 메모리만 생성되는 특징이 있기 때문에 all_exit의 설정을 변경하면 생성된 모든 스레드가 반응하게 됩니다. 위의 예에서 'M'이라는 문자를 입력받았을 때 모든 스레드는 다음의 구문에 반응하게 됩니다.
ControlThread.all_exit = true;
while문의 조건에서 생성된 모든 스레드가 스태틱 멤버 변수 all_exit에 반응하기 때문에 각각의 스레드를 따로 제어하는 것이 아니라 한꺼번에 모든 스레드를 제어할 수 있는 것입니다.
스레드의 Resume, Suspend
- 스레드를 약간이라도 다루어 보신 분이라면 스레드의 작업을 잠깐 멈추게 하거나 멈춘 작업을 재개하는 기능을 생각할 것입니다. 구버전의 자바에서는 이러한 기능을 위해서 suspend()와 resume()이라는 메서드를 지원하고 있었습니다.
- 하지만 자바의 새로운 버전에서는 suspend()와 resume()은 다음과 같이 Deprecated 되었습니다.
public final void suspend() Deprecated
스레드의 작업을 대기시킨다.
public final void resume() Deprecated
스레드의 대기 상태를 해제하고 작업을 재개한다.
- 그렇다면 스레드를 수동으로 멈추게 하거나 작업을 재개하는 메카니즘이 자바의 스레드에는 없는 것일까요?
- 자바에서는 suspend()와 resume()을 사용하지 않고, wait()와 notify()를 이용해서 스레드의 대기 상태를 제어하게 됩니다.
결론
- 스레드의 제어를 위한 도구로 setPriority(), sleep(), wait(), notify() 등을 사용할 수 있습니다.
- setPriority()는 스레드가 Run 상태에 들어갈 수 있는 우선권을 결정하게 되며, sleep()은 일정 시간동안 작업을 멈추게 하는 기능이 있습니다.
- wait()는 스레드를 대기상태(NotRunnable)로 보내게 되며,
- notify()는 대기상태에 있는 스레드를 Runnable 상태로 복귀시켜서 작업을 재개하게 합니다.
- 예전의 자바에서는 wait()와 notify() 대신에 suspend()와 resume()을 사용했지만, 현재는 suspend()와 resume()은 더 이상 사용하지 않습니다.
4. 동기화
멀티쓰레드의 문제점
- 스레드의 정의를 내릴 때 '스레드란 메서드가 동시에 실행되는 것'이라고 했습니다. 이 말은 동시에 메서드의 작업이 진행된다는 의미를 담고 있습니다.
- 여러 개의 스레드가 동시에 작업을 진행할 때 가장 큰 문제로 제기되는 것은 바로 공유자원(Shared Resource)의 문제입니다.
- 스레드가 생성되더라도 메서드 내부에 존재하는 자료만을 사용한다면 별다른 문제가 없습니다. 즉 공유자원 자체가 없는 것이죠.
- 예를 들어 A, B, C라는 세사람이 화장실을 사용하려고 합니다. 각자의 방에 화장실이 하나씩 있다면 별다른 문제 없이 일을 볼 것입니다. 문제는 화장실이 하나밖에 없을 때 발생합니다. 공용 화장실, 그것도 변기가 하나밖에 없는 화장실이라면 사용상의 문제가 발생합니다. A가 일을 보고 있을 때는 B와 C는 대기해야 합니다. 만약 이것을 어기면 상상하기 힘든 문제가 발생합니다. 이러한 문제를 해결하기 위해서 화장실의 문을 잠그는(Lock) 것입니다.
- 순서대로 공유자원을 사용하게끔 하는 기법을 동기화(Synchronization)라고 합니다. 즉 동기화를 순수한 우리말로 바꾸면 '줄서기'라고 말할 수 있습니다.
- 이러한 공유자원의 줄서기를 위해서 자바에서 제공하는 기법은 다음과 같습니다.
◈ synchronized 블록(자원을 사용할 때 자원에 락(Lock)을 거는 방식)
◈ wait()와 notify()
이 장에서는 synchronized 기법만을 알아본 뒤 10장에서 wait()와 notify()를 학습하도록 하겠습니다. 먼저 공유자원이 만들어지는 경우와 공유자원을 사용할 때 문제가 발생하는 경우의 예제부터 알아본 뒤 synchronized 기법으로 동기화를 해결해 보도록 하겠습니다.
공유자원의 접근
- 여러 개의 스레드가 생성되었을 때 하나의 자원을 이용하는 상황은 자주 발생합니다. 공유자원을 이용할 때 문제가 발생하게 되는 근본적인 이유는 동시에 작업을 진행하는 스레드의 특성 때문입니다.
- 공유자원이 되는 경우는 다음과 같습니다.
다음은 Bank라는 클래스의 객체를 NotSynMain에서 스태틱으로 생성함으로써 myBank 객체는 프로그램에서 하나의 메모리만 생성되게 하였습니다.
Bank 클래스
class Bank{
//...
}
NotSynMain 클래스의 스태틱 멤버
class NotSynMain{
public static Bank myBank = new Bank();
//...작업
}
- 그리고 다음과 같이 두 개의 스레드에서 NotSynMain의 myBank 객체를 이용한다면 myBank는 공유자원이 됩니다.
Park 스레드 클래스
class Park extends Thread{
public void run(){
//NotSynMain.myBank 사용
}
}
ParkWife 스레드 클래스
class ParkWife extends Thread{
public void run(){
//NotSynMain.myBank 사용
}
}
- 두 개의 스레드가 다음과 같이 동시에 실행된다면 공유자원의 문제가 발생합니다.
스레드의 생성 및 실행
Park p = new Park(); //Park 스레드 생성
ParkWife w = new ParkWife(); //ParkWife 스레드 생성
p.start(); //스레드 시작
w.start(); //스레드 시작
- Park과 ParkWife의 클래스가 스레드로 동작할 때 각각의 run()이 호출되고 둘 다 동시에 실행될 것입니다.
- 이 때 NotSynMain의 스태틱 멤버 변수인 NotSynMain.myBank는 공유자원이 되며, 동기화의 문제가 제기될 수 있습니다.
- 다음은 동기화의 문제가 발생하는 상황을 고의로 만든 경우입니다.
/**
동기화의 문제 발생
**/
class Bank{
private int money = 10000; //예금 잔액
public int getMoney(){
return this.money;
}
public void setMoney(int money){
this.money = money;
}
public void saveMoney(int save){
int m = this.getMoney();
try{
Thread.sleep(3000);
}catch(InterruptedException e){e.printStackTrace();}
this.setMoney(m + save);
}
public void minusMoney(int minus){
int m = this.money;
try{
Thread.sleep(200);
}catch(InterruptedException e){e.printStackTrace();}
this.setMoney(m - minus);
}
} //end of Bank class
class Park extends Thread{
public void run(){
NotSyncMain.myBank.saveMoney(3000);
System.out.println("saveMoney(3000):" + NotSyncMain.myBank.getMoney());
}
} //end of Park class
class ParkWife extends Thread{
public void run(){
NotSyncMain.myBank.minusMoney(1000);
System.out.println("minusMoney(1000):" + NotSyncMain.myBank.getMoney());
}
} //end of ParkWife class
public class NotSyncMain{
public static Bank myBank = new Bank();
public static void main(String[] args) throws Exception{
Park p = new Park();
ParkWife w = new ParkWife();
p.start();
try{
Thread.sleep(200);
}catch(InterruptedException e){e.printStackTrace();}
w.start();
} //end of main
} //end of NotSyncMain class
//㉶--------------------------------------------㉳
/***
C:\javasrc\chap08>javac NotSyncMain.java
C:\javasrc\chap08>java NotSyncMain
minusMoney(1000):9000
saveMoney(3000):13000
***/
───────────────────────────────────────ⓑ
- 위의 예에서 Park과 ParkWife 클래스의 run()에서 NotSyncMain의 myBank를 다음과 같이 이용하고 있습니다.
공유자원 Bank myBank의 이용
NotSyncMain.myBank.saveMoney(3000); //3000원 입금
NotSyncMain.myBank.minusMoney(1000); //1000원 출금
- Bank myBank 객체는 통장과 같은 역할을 하며, myBank는 기본적으로 10000원이 예금된 상태입니다. 그리고 Park의 스레드에서 3000원을 입금하려고 하며, ParkWife 스레드에서 1000원을 출금하려 합니다. 이 때 입금을 위해서는 3초의 시간이 걸리며, 출금을 위해서는 0.2초의 시간이 소요됩니다. 일반적인 계산으로는 다음과 같이 계산되어야 합니다.
동기화가 보장된 상태에서의 입출금 금액
12000(결과금액) = 10000(원금) + 3000(입금) - 1000(출금)
- 하지만 위의 예제의 실행결과는 13000원이 출력됩니다. 그 이유는 Park의 run()에서 예금된 금액을 가져온 후 3초의 시간이 지연된 후 입금이 처리되기 때문입니다.
public void saveMoney(int save){
int m = this.getMoney(); //예금되어 있는 금액 확인
try{
Thread.sleep(3000);
}catch(InterruptedException e){e.printStackTrace();}
this.setMoney(m + save); //입금처리
}
- 3초의 시간이 지연되는 동안 다른 곳에서 작업이 이루어진다면 문제가 발생할 것입니다. 즉 출금할 때 minusMoney()에서 0.2초의 시간으로 출금작업이 중간에 처리되어 버리기 때문에 계산은 다음의 절차대로 됩니다.
동기화가 유지되지 않은 경우 입출금의 계산 절차
Park이 10000을 읽어감
Park는 3초 대기
Park이 대기하는 동안 ParkWife 또한 10000을 읽어감
Park이 대기하는 동안 ParkWife 0.2초 대기
Park이 대기하는 동안 ParkWife는 1000원을 출금
Park이 대기하는 동안 ParkWife는 작업 완료(남은 돈은 9000원)
Park은 3초 대기한 후 읽어온 10000으로 3000원 입금
결과는 13000원
- Thread.sleep()과 데이터를 읽어온 후 작업을 하는 방법을 이용해서 고의로 동기화의 에러가 발생하도록 만든 예제입니다. 위의 예제에서 Park p라는 스레드가 작업을 끝내기 전까지는 ParkWife w는 대기하도록 만들어야 합니다.
동기화의 유지
Park이 Bank myBank를 사용할 때 ParkWife는 Bank myBank를 사용하기 위해서 대기해야 한다.
- 즉 Park p가 Bank myBank를 사용하고 있을 때 다른 곳에서 myBank를 사용하려 한다면 다른 스레드는 대기하도록 만들어야 합니다. 위의 예에서 실제 공유자원은 Bank의 money입니다. 즉 Bank myBank의 멤버 변수인 money의 메모리를 락(Lock)으로 봉쇄한다면 다른 스레드는 이를 이용하지 못할 것입니다. 공유자원에 락(Lock)을 걸기 위해서 synchronized 키워드를 이용하면 됩니다.
synchronized
- synchronized는 메서드와 블록 형태로 사용할 수 있으며, synchronized 메서드로 사용될 경우 해당 메서드 내에서 사용되는 모든 멤버 변수들은 락(Lock)이 걸리게 됩니다.
synchronized 메서드의 예
public synchronized void saveMoney(int save){
//....공유자원 - 멤버의 사용
}
- 만약 블록 형태로 사용된다면 블록에서 명시한 객체의 멤버들은 모두 락(Lock)에 걸리게 됩니다.
synchronized 블록의 예
public void saveMoney(int save){
synchronized(this){
//....공유자원 - 멤버의 사용
}
}
//synchronized 메서드나 블록 내에서 사용되는 공유자원은 무조건 동기화가 보장된다.
- 앞에서 배운 예제에서 saveMoney()와 minusMoney() 메서드 부분에 synchronized만 붙인다면 동기화의 문제는 해결될 것입니다. 다음은 동기화 처리를 한 실제 예를 보여주고 있습니다.
ⓙ───────────────────────────────────────
/**
synchronized를 이용한 동기화의 보장
**/
class Bank{
private int money = 10000; //예금 잔액
public int getMoney(){
return this.money;
}
public void setMoney(int money){
this.money = money;
}
public synchronized void saveMoney(int save){
int m = this.getMoney();
try{
Thread.sleep(3000);
}catch(InterruptedException e){e.printStackTrace();}
this.setMoney(m + save);
}
public void minusMoney(int minus){
synchronized(this){
int m = this.money;
try{
Thread.sleep(200);
}catch(InterruptedException e){e.printStackTrace();}
this.setMoney(m - minus);
}
}
} //end of Bank class
class Park extends Thread{
public void run(){
SyncMain.myBank.saveMoney(3000);
System.out.println("saveMoney(3000):" + SyncMain.myBank.getMoney());
}
} //end of Park class
class ParkWife extends Thread{
public void run(){
SyncMain.myBank.minusMoney(1000);
System.out.println("minusMoney(3000):" + SyncMain.myBank.getMoney());
}
} //end of ParkWife class
public class SyncMain{
public static Bank myBank = new Bank();
public static void main(String[] args) throws Exception{
Park p = new Park();
ParkWife w = new ParkWife();
p.start();
try{
Thread.sleep(200);
}catch(InterruptedException e){e.printStackTrace();}
w.start();
} //end of main
} //end of SyncMain class
//㉶--------------------------------------------㉳
/***
C:\javasrc\chap08>javac SyncMain.java
C:\javasrc\chap08>java SyncMain
saveMoney(3000):13000
minusMoney(3000):12000
***/
───────────────────────────────────────ⓑ
- 위의 예제는 앞에서 테스트한 NotSyncMain의 예제에서 saveMoney()와 minusMoney() 메서드에 syncrhonzied 단 한단어만 추가되었습니다.
synchronized 메서드
public synchronized void saveMoney(int save){
//메서드 내에 사용된 멤버 변수들의 동기화가 보장된다.
}
public synchronized void minusMoney(int minus){
//메서드 내에 사용된 멤버 변수들의 동기화가 보장된다.
}
- synchronized는 synchronized 메서드가 위치한 클래스의 멤버 변수를 사용할 때, 해당 멤버 변수의 메모리를 잠그는(Lock) 방법으로 다른 스레드가 해당 자원에 접근하면 자동으로 대기시키게 만듭니다.
- synchronized 키워드를 메서드 앞에 붙여주면 해당 메서드 내에 존재하는 모든 멤버 변수를 모두 동기화시키게 됩니다. 하지만 동기화할 필요가 없는 자원도 있을 수 있습니다. 이를 위해서 메서드 내에 특정 구간만 동기화시키고자 할 때는 다음과 같이 synchronized 블록을 이용하면 됩니다.
synchronized 블록을 사용한 saveMoney() 메서드
public void saveMoney(int save){
synchronized(this){
//블록 내에 사용된 this 즉 현재 클래스의 멤버 변수들에 대한 동기화가 보장된다.
}
}
synchronized 블록을 사용한 minusMoney() 메서드
public void minusMoney(int minus){
synchronized(this){
//블록 내에 사용된 this 즉 현재 클래스의 멤버 변수들에 대한 동기화가 보장된다.
}
}
- 이 때 주의하셔야 하는 것은 synchronized의 괄호에 들어가는 this입니다. syncrhonized 블록을 사용할 때 괄호안에 사용된 객체의 의미는 공유자원이 어디에 존재하는가의 문제입니다.
- 공유자원이 현재의 클래스에 존재하기 때문에 synchronized 괄호안에 this가 사용된 것입니다. 현재 클래스의 멤버가 공유자원이면 현재 클래스를 의미하는 this를 사용하시면 됩니다.
- 위의 예는 synchronized 메서드와 synchronized 블록을 둘 다 보여주고 있습니다. Park p의 run() 내에 존재하는 synchronized된 부분이 끝나기 전까지는 ParkWife w의 run()은 대기하게 될 것입니다.
- synchronized 메서드와 sychronized 블록 내에서 사용되는 공유자원은 무조건 동기화가 보장됩니다. Bank myBank의 메모리를 공유하지만 synchronized를 사용했기 때문에 순서대로 Bank myBank의 메모리를 사용하게 되는 것입니다.
synchronized의 활용
- synchronized 블록을 이용하실 때 괄호 안에 들어가는 객체의 멤버 변수에 대해서 동기화가 보장된다고 했습니다. 그렇다면 다음과 같이 saveMoney()와 minusMoney()의 메서드에서 synchronized 블록을 사용해도 됩니다.
public void run(){
syncrhonized(SyncMain2.myBank){
SyncMain2.myBank.saveMoney(3000);
}
}
public void run(){
syncrhonized(SyncMain2.myBank){
SyncMain2.myBank.minusMoney(1000);
}
}
- 이것은 synchronized가 객체의 멤버를 상대하기 때문에 해당 객체만 명시해 주면 어디서든 사용해도 됩니다. 이것을 예제로 만들어 보면 다음과 같습니다.
ⓙ───────────────────────────────────────
/**
synchronized 블럭을 동기화 예제(또다른 방법)
**/
class Bank{
private int money = 10000; //예금 잔액
public int getMoney(){
return this.money;
}
public void setMoney(int money){
this.money = money;
}
public void saveMoney(int save){
int m = this.getMoney();
try{
Thread.sleep(3000);
}catch(InterruptedException e){e.printStackTrace();}
this.setMoney(m + save);
}
public void minusMoney(int minus){
int m = this.money;
try{
Thread.sleep(200);
}catch(InterruptedException e){e.printStackTrace();}
this.setMoney(m - minus);
}
} //end of Bank class
class Park extends Thread{
public void run(){
synchronized(SyncMain2.myBank){
SyncMain2.myBank.saveMoney(3000);
}
System.out.println("saveMoney(3000):" + SyncMain2.myBank.getMoney());
}
} //end of Park class
class ParkWife extends Thread{
public void run(){
synchronized(SyncMain2.myBank){
SyncMain2.myBank.minusMoney(1000);
}
System.out.println("minusMoney(3000):" + SyncMain2.myBank.getMoney());
}
} //end of ParkWife class
public class SyncMain2{
public static Bank myBank = new Bank();
public static void main(String[] args) throws Exception{
Park p = new Park();
ParkWife w = new ParkWife();
p.start();
try{
Thread.sleep(200);
}catch(InterruptedException e){e.printStackTrace();}
w.start();
} //end of main
} //end of SyncMain2 class
//㉶--------------------------------------------㉳
/***
C:\javasrc\chap08>javac SyncMain2.java
C:\javasrc\chap08>java SyncMain2
saveMoney(3000):13000
minusMoney(3000):12000
***/
───────────────────────────────────────ⓑ
- 위의 예에서 사용된 synchronized 블록의 객체는 SyncMain2.myBank입니다. 이것의 의미는 SyncMain2.myBank의 멤버 변수에 동기화를 걸겠다는 의미입니다.
synchronized 블록을 이용한 동기화
synchronized(SyncMain2.myBank){
//SyncMain2.myBank 멤버의 동기화 보장
}
- syncrhonized를 사용하는 곳은 어디라도 상관없습니다. 보통은 공유자원이 있는 클래스에서 사용하는 경우가 일반적입니다. 하지만 그렇지 않은 경우도 많기 때문에 두 가지 기법을 모두 알아두시기 바랍니다. 동기화가 보장되는 것과 보장되지 않은 것은 전혀 다른 의미를 지니고 있습니다. 앞의 예제에서 사용한 동기화는 사소한 것 같지만 동기화가 보장되지 않는 통장을 상상해 보신다면 그 중요성을 짐작할 수 있을 것입니다.
synchronized의 한계
- synchronized는 자동 동기화입니다. 즉 공유자원을 사용하면 synchronized 블록에서는 자동으로 동기화가 보장되는 것입니다. 아주 편리하지만 미세한 동기화를 제어하기에는 약간의 한계가 있습니다.
- 비디오 가게를 생각해 보죠. 비디오 가게에 비디오 테이프가 5개 있고 그리고 손님들이 비디오 테이프를 빌려간다고 가정하죠. 이 때 동기화가 보장되어야 하는 것은 당연한 것입니다. 두 사람이 동시에 하나의 테이프를 빌려가면 안되니까요. 물론 synchronized를 사용하면 동기화는 보장할 수 있습니다. 하지만 비디오를 빌린 후 비디오 테이프를 보는 동안에 동기화를 걸면, 한사람이 비디오를 보는(시청하는) 동안 어떠한 사람도 비디오 테이프를 빌릴 수 없습니다. 다음은 잘못된 동기화의 처리로 인해 비디오 가게에서는 단 하나의 비디오 테이프만을 빌려주는 예를 보여주고 있습니다.
ⓙ───────────────────────────────────────
/**
잘못된 동기화의 예
**/
import java.util.*;
class VideoShop{
private Vector buffer = new Vector();
public VideoShop(){
buffer.addElement("은하철도999-0");
buffer.addElement("은하철도999-1");
buffer.addElement("은하철도999-2");
buffer.addElement("은하철도999-3");
}
public String lendVideo(){
String v = (String)this.buffer.remove(buffer.size()-1);
return v;
}
public void returnVideo(String video){
this.buffer.addElement(video);
}
} //end of VideoShop class
class Person extends Thread{
public void run(){
synchronized(VideoShopMain.vShop){
//5초동안 VideoShopMain.vShop은 락(Lock)에 걸리게 된다.
try{
String v = VideoShopMain.vShop.lendVideo();
System.out.println(this.getName() + ":" + v + " 대여");
System.out.println(this.getName() + ":" + v + " 보는중");
this.sleep(5000);
System.out.println(this.getName() + ":" + v + " 반납");
VideoShopMain.vShop.returnVideo(v);
}catch(InterruptedException e){e.printStackTrace();}
}
}
} //end of Person class
class VideoShopMain{
public static VideoShop vShop = new VideoShop();
public static void main(String[] args){
System.out.println("프로그램 시작");
Person p1 = new Person();
Person p2 = new Person();
Person p3 = new Person();
Person p4 = new Person();
p1.start();
p2.start();
p3.start();
p4.start();
System.out.println("프로그램 종료");
}
} //end of VideoShopMain class
//㉶--------------------------------------------㉳
/***
C:\javasrc\chap08>javac VideoShopMain.java
C:\javasrc\chap08>java VideoShopMain
프로그램 시작
프로그램 종료
Thread-1:은하철도999-3 대여
Thread-1:은하철도999-3 보는중
Thread-1:은하철도999-3 반납
Thread-2:은하철도999-3 대여
Thread-2:은하철도999-3 보는중
Thread-2:은하철도999-3 반납
Thread-3:은하철도999-3 대여
Thread-3:은하철도999-3 보는중
Thread-3:은하철도999-3 반납
Thread-4:은하철도999-3 대여
Thread-4:은하철도999-3 보는중
Thread-4:은하철도999-3 반납
***/
───────────────────────────────────────ⓑ
- 위의 예에서 4개의 Person 스레드를 실행시키고 있습니다.
Person p1 = new Person();
Person p2 = new Person();
Person p3 = new Person();
Person p4 = new Person();
p1.start(); p2.start(); p3.start(); p4.start();
- 일반적인 경우라면 4개의 스레드가 동시에 작업을 진행할 것이라고 생각합니다. 하지만 다음과 같이 비디오를 빌린 후 반납할 때까지 동기화를 걸어두었다면, 비디오 테이프를 빌린 첫번째 사람이 비디오를 보기 전까지는 어떠한 스레드도 VideoShop에 접근할 수 없습니다.
synchronized 블럭
synchronized(VideoShopMain.vShop){
String v = VideoShopMain.vShop.lendVideo(); //비디오를 빌린다.
try{
this.sleep(5000); //비디오를 보는 시간 5초
}catch(InterruptedException e){e.printStackTrace();}
VideoShopMain.vShop.returnVideo(v); //비디오를 반납한다.
- 비디오를 보는 5초동안 VideoShop vShop은 synchronized로 인해 락(Lock)이 걸린 상태이기 때문에 어떠한 스레드도 접근할 수 없습니다. 이렇게 될 경우 비디오 가게에서는 단 하나의 테이프만을 운영하는 것이 됩니다. 결과를 보시면 '은하철도999-4' 비디오 테이프만을 대여하고 있는 것을 확인할 수 있습니다. 이것은 일반적인 메서드 호출이나 별반 다른 것이 없습니다. 즉 비디오 가게에서는 4개의 테이프를 동시에 빌려 주어야만 장사를 잘하는 것이 될 것입니다.
- synchronized를 사용할 때 다음과 같이 비디오 테이프를 빌려주는 곳과 반환하는 곳에 synchronized를 거는 것이 더 정확한 사용 방법입니다.
synchronized를 걸어야 하는 곳
public synchronized String lendVideo(){...}
public synchronized void returnVideo(String video){...}
데이터를 집어넣고 추출할 때에만 동기화를 보장하는 것이죠. 다음은 이러한 방법으로 프로그램한 예는 다음과 같습니다.
『chap08\VideoShopMain2.java』
ⓙ───────────────────────────────────────
/**
데이터를 집어넣고 추출할 때에만 동기화를 보장
**/
import java.util.*;
class VideoShop{
private Vector buffer = new Vector();
public VideoShop(){
buffer.addElement("은하철도999-0");
buffer.addElement("은하철도999-1");
buffer.addElement("은하철도999-2");
buffer.addElement("은하철도999-3");
}
public synchronized String lendVideo(){
String v = (String)this.buffer.remove(buffer.size()-1);
return v;
}
public synchronized void returnVideo(String video){
this.buffer.addElement(video);
}
} //end of VideoShop class
class Person extends Thread{
public void run(){
try{
String v = VideoShopMain2.vShop.lendVideo();
System.out.println(this.getName() + ":" + v + " 대여");
System.out.println(this.getName() + ":" + v + " 보는중");
this.sleep(5000);
System.out.println(this.getName() + ":" + v + " 반납");
VideoShopMain2.vShop.returnVideo(v);
}catch(InterruptedException e){e.printStackTrace();}
}
} //end of Person class
class VideoShopMain2{
public static VideoShop vShop = new VideoShop();
public static void main(String[] args){
System.out.println("프로그램 시작");
Person p1 = new Person();
Person p2 = new Person();
Person p3 = new Person();
Person p4 = new Person();
p1.start();
p2.start();
p3.start();
p4.start();
System.out.println("프로그램 종료");
} //end of main
} //end of VideoShopMain2 class
//㉶--------------------------------------------㉳
/***
C:\javasrc\chap08>javac VideoShopMain2.java
C:\javasrc\chap08>java VideoShopMain2
프로그램 시작
프로그램 종료
Thread-1:은하철도999-3 대여
Thread-2:은하철도999-2 대여
Thread-3:은하철도999-1 대여
Thread-4:은하철도999-0 대여
Thread-1:은하철도999-3 보는중
Thread-2:은하철도999-2 보는중
Thread-3:은하철도999-1 보는중
Thread-4:은하철도999-0 보는중
Thread-1:은하철도999-3 반납
Thread-2:은하철도999-2 반납
Thread-3:은하철도999-1 반납
Thread-4:은하철도999-0 반납
***/
───────────────────────────────────────ⓑ
- 위의 프로그램은 synchronized를 lendVideo()와 returnVideo()에 걸어 두었습니다. 이렇게 되면 4개의 비디오를 동시에 대여할 수 있습니다. 비디오를 보는 동안 synchronized 처리하는 것보다는 훨씬 유연하게 동작하는 것을 확인할 수 있습니다.
- 하지만 이 경우에는 또 다른 문제가 제기됩니다. 즉 비디오 테이프를 보는 곳에 동기화를 걸지 않았기 때문에 5번째 비디오 테이프를 빌리면 심각한 문제가 발생할 것입니다. 위의 예에서 다음과 같이 5번째 비디오를 빌리는 구문을 삽입하면 에러가 발생합니다.
문제발생
Person p5 = new Person();
p5.start();
- 비디오를 보는 곳에 동기화를 걸 수 없으며, 테이프는 4개밖에 없기 때문에 에러가 발생할 것입니다. 물론 비디오가 없으면 빌려주지 않으면 됩니다. 즉 Person p5가 구동되었을 때 비디오 테이프가 없으면 바로 p5를 종료하면 될 것입니다. 즉 여유분이 있을 때만 비디오 테이프를 빌려주면 됩니다. 이를 프로그램으로 작성하면 다음과 같습니다.
『chap08\VideoShopMain3.java』
ⓙ───────────────────────────────────────
/**
여유분이 있을 때만 비디오 테이프를 빌려주기
**/
import java.util.*;
class VideoShop{
private Vector buffer = new Vector();
public VideoShop(){
buffer.addElement("은하철도999-0");
buffer.addElement("은하철도999-1");
buffer.addElement("은하철도999-2");
buffer.addElement("은하철도999-3");
}
public synchronized String lendVideo(){
if(buffer.size()>0){
String v = (String)this.buffer.remove(buffer.size()-1);
return v;
}else{
return null;
}
}
public synchronized void returnVideo(String video){
this.buffer.addElement(video);
}
} //end of VideoShop class
class Person extends Thread{
public void run(){
String v = VideoShopMain3.vShop.lendVideo();
if( v == null){
System.out.println(this.getName() + "비디오가 없군요. 안봅니다.");
return;
}
try{
System.out.println(this.getName() + ":" + v + " 대여");
System.out.println(this.getName() + ":" + v + " 보는중\n");
this.sleep(5000);
System.out.println(this.getName() + ":" + v + " 반납");
VideoShopMain3.vShop.returnVideo(v);
}catch(InterruptedException e){e.printStackTrace();}
}
} //end of Person class
public class VideoShopMain3{
public static VideoShop vShop = new VideoShop();
public static void main(String[] args){
System.out.println("프로그램 시작");
Person p1 = new Person();
Person p2 = new Person();
Person p3 = new Person();
Person p4 = new Person();
Person p5 = new Person();
p1.start();
p2.start();
p3.start();
p4.start();
p5.start();
System.out.println("프로그램 종료");
} //end of main
} //end of VideoShopMain3 class
//㉶--------------------------------------------㉳
/***
C:\javasrc\chap08>javac VideoShopMain3.java
C:\javasrc\chap08>java VideoShopMain3
프로그램 시작
프로그램 종료
Thread-1:은하철도999-3 대여
Thread-1:은하철도999-3 보는중
Thread-2:은하철도999-2 대여
Thread-2:은하철도999-2 보는중
Thread-3:은하철도999-1 대여
Thread-3:은하철도999-1 보는중
Thread-4:은하철도999-0 대여
Thread-4:은하철도999-0 보는중
Thread-5비디오가 없군요. 안봅니다.
Thread-1:은하철도999-3 반납
Thread-2:은하철도999-2 반납
Thread-3:은하철도999-1 반납
Thread-4:은하철도999-0 반납
***/
───────────────────────────────────────ⓑ
- 위의 예에서는 비디오 테이프가 없을 때 비디오 테이프를 빌리고자 한다면 null을 리턴하게 되어 있습니다.
비디오 테이프가 없을 때 null을 리턴
if(buffer.size()>0){
String v = (String)this.buffer.remove(buffer.size()-1);
return v;
}else{
return null;
}
- 그리고 Person에서는 비디오 테이프가 없다면 다음과 같이 바로 스레드를 종료하게 됩니다.
if( v == null){
System.out.println(this.getName() + "비디오가 없군요. 안봅니다.");
return null; //비디오 테이프를 빌리는 것 자체를 포기한다.
}
- 이렇게 한다면 에러 발생은 막을 수 있습니다. 하지만 자원을 요청한 사람은 대기하는 것이 아니라 아예 포기하는 것이 됩니다. 즉 이 비디오 가게는 장사를 잘 못하는 것입니다. 만약 제대로 장사를 한다면 잠깐 기다리라고 한 뒤 비디오 테이프가 들어오면 바로 빌려주면 될 것입니다.
- 지금까지 배운 동기화 기법으로 이것을 구현하는 것은 거의 불가능합니다. 물론 구현할 수 없는 것은 아닙니다만 약간 까다롭습니다. 보다 효율적인 스레드 제어의 기법을 구현하기 위해서는 즉 비디오 가게에서 손님의 동기화를 보장하기 위해서는 wait()와 notify()를 사용하면 됩니다. 비디오 테이프가 없다면 wait() 시켰다가 비디오 테이프가 반납되면 notify()해서 빌려가게 한다면 보다 효율적으로 비디오 가게를 운영할 수 있을 것입니다.
- wait()와 notify() 예제는 10장에서 다루게 될 것입니다. 이 절의 문제 해결책으로 제시된 10장의 예제를 반드시 확인하시기 바랍니다. 10장에 있는 이유는 10장을 어느 정도 학습한 뒤 wait()와 notify()를 보면 더 효율적이기 때문입니다.