전체적인 Flow
1. 프로세스와 쓰레드
2. 쓰레드의 구현과 실행
3. start()와 run()
4. 싱글쓰레드와 멀티쓰레드
5. 쓰레드의 우선순위
6. 쓰레드 그룹
7. 데몬 쓰레드
8. 쓰레드의 실행제어
9. 쓰레읃의 동기화
-9.1 synchronized를 이용한 동기화
-9.2 wait()와 notify()
-9.3 Lock과 Condition을 이용한 동기화
-9.4 volatile
-9.5 fork & join 프레임웤
이 부분에 대한 개념을 정확하게 이해하기 위해서 여러 블로그의 개념들을 찾아보았고 이해하는데 도움이 되었다.
프로세스
=실행 중인 프로그램
=eclips라는 프로그램이 실행중일 때는 프로세스가 된다.
= 여러 줄의 코드로 구성됨
= 매모리 + CPU 등의 자원 + 쓰레드(하나의 코드 실행 흐름) + 쓰레드...
= 각각의 프로세스는 독립된 메모리 공간을 할당받고 데이터를 저장
프로세스에서 쓰레드라는 개념이 어떻게 등장하게 되었는지 자세히 살펴보자
-메모리 : 프로세스가 CPU를 할당받기 위해 대기하는 곳
-CPU : 연산장치
-IO(input/output) : 데이터를 주고 받는 일
① 처음에는 단일 프로세스 시스템만 존재했다.
이는 CPU가 하나의 프로세스(카카오)를 시작하고 종료될 때까지 다음 프로세스(eclips)는 계속 대기해야하는 것이다.
이는 CPU 사용률에 문제를 일으킨다. 중간 중간 데이터를 주고 받는 일을 할 때는 CPU를 이용하지 않는데 그 때도 다음 프로세스는 대기해야 하는 것이다.
② 이래서 등장하는 것이 멀티프로그래밍
내가 카카오에서 데이터를 주고 받는 IO작업을 할 때 eclips가 실행되게 하는 것이다
이로써 CPU의 사용률을 조금 올렸다.
그렇지만 만약에 카카오를 할 때 IO작업을 아주 적게 하고 CPU의 이용이 길어진다면 eclips를 이용하기 위해서 계속 대기해야만 한다.
③ 이 문제를 해결해주는 것이 멀티태스킹 또는 시분할.
이는 약속이다. 프로세스마다 CPU를 짧게 사용하고 다음 프로세스를 실행할 수 있도록 하는 것이죠. 번갈아 가며 수행하지만 사용자는 이 시간이 매우 짧아 동시에 실행하는 것처럼 느끼게 된다. 유튜브로 음악 들으며 카카오 메신저 보내기
④ 근데 하나의 프로세스로 여러개의 작업을 하고 싶다. 카카오톡으로 메신저도 보내면서 카카오 내부 사진기능을 이용하고 싶다. 그렇다면 우리는 두 개의 프로세스를 만들어서 실행해야 한다. 앞 서 말했듯이 프로세스는 독립된 메모리 공간을 이용하기 때문에 이렇게 2개의 프로세스를 이용하는 경우 프로세스를 바꾸는데 드는 비용이 발생하고 서로 메모리를 공유하기 위해서 드는 비용이 또 발생한다.
그래서 등장한 것이 쓰레드와 코어라는 것이다.
코어는 듀얼코어 등 CPU 하나를 마치 2개의 CPU가 돌아가는 것처럼
물리적으로 분리해서 정말로 번갈아가 아닌 동시에 실행될 수 있도록 하는 것이다.
즉 주방에 요리사가 2(n)명이 있는 것이다.
한명은 야채를 썰고 한명은 불에 요리를 볶는 것처럼.
프로세스에는 적어도 하나 이상의 갈래가 존재한다.
카카오에는 사진보내기 갈래, 메신저보내기 갈래 등이 존재한다
이 하나하나의 갈래를 우리는 쓰레드라는 단위로 부르기로 했다.
즉 하나의 프로세스에는 적어도 하나 이상의 쓰레드가 존재하도록
이 쓰레드는 프로세스가 같기 때문에 메모리를 독립적으로 할당하지 않고 stack에 존재하여 data, heap, code를 공유한다.
자, 앞서서 우리는 CPU를 배웠고 이 CPU를 물리적으로 나눈 코어를 배웠다
이 코어에서는 멀티태스킹과 멀티쓰레딩이 이루어진다.
여기서 알 수 있듯이 이제 작업의 단위는 쓰레드이다.
멀티태스킹으로 쓰레드를 번갈아 가며 수행하는 것 이를 이제 멀티쓰레딩이라고 부를 것이다
"쓰레드"
= 저 프로세스를 직접 수행하기 위해서 일하는 사람
= 한 가닥의 실
= 하나의 코드 실행 흐름
= 두 개 이상의 쓰레드로 구성된 것을 멀티쓰레드라고 한다.
CPU(뇌)-코어(여러개 뇌 : 요리사 n명)-쓰레드(팔다리 : 후라이펜 m개)
멀티쓰레딩의 장점
- CPU 사용률을 향상시킨다.
- 자원을 보다 효율적으로 사용할 수 있다.
- 사용자에 대한 응답성이 향상된다.
- 작업이 분리되어 코드가 간결해진다.
멀티쓰레드의 단점- 같은 프로세스 내에서 자원을 공유하면서 작업을 하기 때문에 발생하는 동기화
- 교착상태와 같은 문제점 발생
쓰레드를 구현하는 방법
① Thread class 상속받기 ( 다른 클래스를 상속받을 수 없음)
② Runnable interface 구현하기 ( 재사용성이 높고 코드의 일관성을 유지할 수 있기 때문에 보다 객체지향적인 방법)
public interface Runnable {
public abstract void run();
}
Runnable 인터페이스에는 오로지 run()만 정의되어 있는 간단한 인터페이스이다.
class ThreadEx1 {
public static void main(String[] args) {
ThreadEx1_1 t1=new ThreadEx1_1();//①Thread의 자손 클래스의 인스턴스
Runnable r = new ThreadEx1_2(); //② - ① Runnable을 구현한 클래스의 인스턴스
Thread t2 = new Thread(r); ///② - ② 생성자 Thread(Runnable target)
//Thread t2 = new Tread(new ThreadEx1_2())
t1.start();
t2.start();
}
}
class ThreadEx1_1 extends Thread {//① Thread class 구현
public void run() {
for(int i=0;i<5;i++)
System.out.println(getName());
}
}
class ThreadEx1_2 implements Runnable {//② 인터페이스 Runnable 구현
public void run() {
for (int i=0;i<5;i++)
System.out.println(Thread.currentThread().getName());
}
}
👆 앞 서 말했지만 총 두가지 방법이 있으며
✔️ 인스턴스 생성하는 방법
① Thread class 상속받는 경우
ThreadEx1_1 t1=new ThreadEx1_1();
② Runnable interface 구현하는 경우
Runnable r = new ThreadEx1_2();
Thread t2 = new Thread(r);
이렇게 구현한다.
✔️ 쓰레드 구현->run()의 몸통 채우기
① Thread class 상속받는 경우
getName()을 호출
② Runnable interface 구현하는 경우
Thread 클래스의 getName()을 호출하려면 Thread.currentThread().getName()으로 해야한다.
쓰레드 생성(인스턴스 생성)->start()를 호출하여 쓰레드 실행
한 번 실행이 종류된 쓰레드는 다시 실행할 수 없다. 따라서 새로운 쓰레드를 생성한 다음에 start()를 호출해야한다.
ThreadEx1_1 t1 = new ThreadEx1_1();
t1.start();
t1=new ThreadEx1_1();
t1.start();
2장을 배우면서 나는 왜 run()을 호출하지 않았는데 run에 의해서 thread의 name이 나오는지 이해하지 못했는데 알고보니 start()라는 메서드가 run()메서드를 호출하는 것이없다.
무슨 말인지 자세히 알아보자.
✔️main메서드에서 run()의 호출 = 생성된 쓰레드를 실행시키는 것이 아니라 단순히 클래스에 선언된 run() 메서드를 호출하는 것
✔️main메서드에서 start() 호출
새로운 쓰레드가 작업을 실행하는 필요한 호출스택을 생성->run()메서드 호출->생성된 호출 스텍에 run()매서드 올리기
모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출 스택을 필요로 한다.
즉 새로운 쓰레드를 생성하여 실행시킨다 = 새로운 호출 스택이 생겨난다.
쓰레드가 종료된다 = 호출 스텍이 소멸된다.
main메서드(public static void main(String[] args))를 수행하는 쓰레드가 존재하지 않겠는가? 이를 우리는 main쓰레드라고 부를것이다.
프로그램을 실행하기 위해서는 최소한 하나의 작업 흐름인 쓰레드가 적어도 하나 존재한다. 그래서 프로그램을 실행하면 기본적으로 하나의 쓰레드를 생성하고 그 쓰레드가 main메서드를 호출해서 작업을 수행한다.
프로그램의 종료 = 실행 중이 사용자 쓰레드가 하나도 없을 때
쓰레드는 '사용자 쓰레드'와 '데몬 쓰레드' 두 종류가 있다.
class ThreadEx1 {
public static void main(String[] args) {
ThreadEx1_1 t1=new ThreadEx1_1();
t1.start();
}
}
class ThreadEx1_1 extends Thread {
public void run() {
throwException();
}
public void throwException() {
try {
throw new Exception();
}catch(Exception e) {
e.printStackTrace();
}
}
}
class ThreadEx1 {
public static void main(String[] args) {
ThreadEx1_1 t1=new ThreadEx1_1();
t1.run();
}
}
class ThreadEx1_1 extends Thread {
public void run() {
throwException();
}
public void throwException() {
try {
throw new Exception();
}catch(Exception e) {
e.printStackTrace();
}
}
}
사실 이 부분은 1장에 잘 설명되어져 있으므로 넘어간다.
쓰레드는 우선순위라는 속성(멤버변수)를 가지고 있다.
우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다.
더 빠르게 반응해야 하는 작업을 하는 쓰레드에게 더 높은 우선순위를 줄 수 있다.
void setPriority(int newPriority) 쓰레드의 우선순위를 지정한 값으로 변경
int getPriority() 쓰레드의 우선순위를 반환한다.
public static final int MAX_PRIORITY = 10//최대 우선순위
public static final int MIN_PRIORITY = 1//최소 우선순위
public static final int NORM_PRIORITY = 5 //보통 우선순위
우선순위는 1~10까지이며 가장 높은 우선순위는 10이다. 숫자가 클 수록 우선순위가 높다.
✔️ 쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속받는다.
main메서드를 수행하는 쓰레드는 우선순위가 5이므로 main메서드 내에서 생성하는 쓰레드의 우선순위는 자동적으로 5이다.
class ThreadEx8 {
public static void main(String[] args) {
ThreadEx8_1 th1= new ThreadEx8_1();
ThreadEx8_2 th2= new ThreadEx8_2();
th2.setPriority(7);
System.out.println("Priority of th1(-):"+th1.getPriority());
System.out.println("Priority of th2(|):"+th2.getPriority());
th1.start();
th2.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++);//우선순위가 높아지면 한번에 작업이 끝나버릴 수 있기 때문에 지연을 위한 for문
}
}
}
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++);//우선순위가 높아지면 한번에 작업이 끝나버릴 수 있기 때문에 지연을 위한 for문
}
}
}
👆내 노트북의 경우 싱글코어로 돌린 결과가 나온다. th1과 th2 둘다 main메서드에서 쓰레드는 실행했기 때문에 우선순위가 5다. 하지만 중간에 내가 th2의 우선순위를 7로 설정하였기 때문에 우선순위가 바뀌었다. 우선순위는 쓰레드 실행 전에만 변경할 수 있다는 것을 기억하자.
싱글코어 결과를 보면 우선순위가 높은 th2의 |작업이 더 많은 실행시간을 할당받아 먼저 작업이 끝나는 것을 볼 수 있다.
하지만 싱글코어가 아닌 멀티코어의 경우에는 여러개의 코어가 동시에 돌아가므로
우선순위에 영향이 없다. OS마다 다른 방식으로 스케쥴링하기 때문에 어떤 OS에서 실행하느냐에 따라 다른 결과를 얻는다. 이 부분은 개인적으로 찾아서 확인해야 한다.
쓰레드 그룹 : 서로 관련된 쓰레드를 그룹으로 다루기 위한 것
쓰레드 그룹 안에 하위 그룹을 둘 수 있음
쓰레드 그룹은 보안상의 이유로 도입된 개념
따라서 자신이 속한 쓰레드 그룹이나 하위 그룹을 변경할 수 있고 그 외 다른 쓰레드 그룹은 변경할 수 없다.
class ThreadEx9 {
public static void main(String[] args) {
ThreadGroup main = Thread.currentThread().getThreadGroup();
System.out.println(Thread.currentThread().getThreadGroup());
ThreadGroup grp1 = new ThreadGroup("Group1");//그룹 만들기 ThreadGroup(String groupname)
ThreadGroup grp2 = new ThreadGroup("Group2");
ThreadGroup subGrp1 = new ThreadGroup(grp1,"SubGroup1");
//하위 그룹을 만들 때
//ThradGroup(ThreadGroup parent, String subgroupName)
grp1.setMaxPriority(3);
//grp1의 최대 우선순위는 3
Runnable r = new Runnable() {
public void run() {
try {
Thread.sleep(1000);
}catch (InterruptedException e) {}
}
};
//이제 그룹 안에 쓰레드를 넣어보자
new Thread(grp1,r,"th1").start();
new Thread(grp2,r,"th2").start();
new Thread(subGrp1,r,"th3").start();
System.out.println(">>List of ThreadGroup :"+main.getName()+
", Active ThreadGroup: "+ main.activeGroupCount()+", Active Thread: "+main.activeCount());
main.list();
}
}
👆
✔️ 그룹 설정하기
ThreadGroup grp1 = new ThreadGroup("Group1");//그룹 만들기
그룹 이름이 Group1이다.
✔️ ThreadGroup getThreadGroup() : 쓰레드 자신이 속한 쓰레드 그룹을 반환한다.
✔️ grp1.setMaxPriority(3);
쓰레드가 grp1에 포함되기 전에 우선순위를 설정한다.
✔️ 하위 그룹 만들기
ThreadGroup subGrp1 = new ThreadGroup(grp1,"SubGroup1");
✔️ 쓰레드를 그룹에 포함시키기
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);
new Thread(grp1,r,"th1").start();
넣고 쓰레드 이름 지정한 후에 star()를 통해 실행
모든 쓰레드는 반드시 쓰레드 그룹에 포함되어 있어야 하기 때문에, 위와 같이 쓰레드 그룹을 지정하는 생성자를 사용하지 않는 쓰레드는 기본적으로 자신이 생성한 쓰레드와 같은 쓰레드 그룹에 속하게 된다.
자바 애플리케이션이 실행되면 JVM이 main과 system이라는 쓰레드 그룹을 만든다.
main메서드를 수행하는 main이라는 이름의 쓰레드는 main쓰레드 그룹에 속한다
Finalizer쓰레드는 system쓰레드 그룹에 속한다.
class ThreadEx8 {
public static void main(String[] args) {
ThreadEx8_1 th1= new ThreadEx8_1();
ThreadEx8_2 th2= new ThreadEx8_2();
th2.setPriority(7);
System.out.println("Priority of th1(-):"+th1.getPriority());
System.out.println("Priority of th2(|):"+th2.getPriority());
th1.start();
th2.start();
Thread.currentThread().getThreadGroup().list();
}
}
//생략
우리가 생성하는 모든 쓰레드 그룹은 main쓰레드 그룹의 하위 쓰레드 그룹이 된다.
쓰레드 그룹을 지정하지 않고 생성한 쓰레드는 자동적으로 main 쓰레드 그룹에 속하게 된다.
✔️ 다른 일반 쓰레드의 작업을 돕는 보조적인 역할
✔️ 일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 자동 종료
✔️ 예시 ) 가비지 컬렉터, 워드프로세서의 자동저장, 회면자동갱신 등
✔️ 무한루프와 조건문을 이용해서 실행 후 대기하고 있다가 특정 조건이 만족되면 작업을 수행하고 다시 대기하도록 작성
✔️ 데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 된다.
class ThreadEx10 implements Runnable { //run()
static boolean autoSave = false;
public static void main(String[] args) {
Thread t = new Thread(new ThreadEx10());
//t.setDaemon(true); // 이 부분이 없으면 종료되지 않는다.
t.start();
for(int i=1; i<=10;i++) {
try {
Thread.sleep(1000);//1초마다
}catch(InterruptedException e) {}
System.out.println(i);
if(i==5)
autoSave=true;
}
System.out.println("프로그램을 종료합니다.");
}
public void run() {
while(true) {
try {
Thread.sleep(3*1000);//3초마다
}catch(InterruptedException e) {}
//autoSave가 true인 경우 호출 //5초 이후
if(autoSave) {
autoSave();
}
}
}
public void autoSave() {
System.out.println("작업파일이 자동저장되었습니다.");
}
}
👆 이 경우 t가 일반쓰레드이기 때문에
main메서드가 종료되어도 t가 종료되지 않았기 때문에 계속 run메서드가 실행되는 것을 볼 수 있다.
다음 예시는 저 t를 데몬메서드로 만들어서 main메서드인 일반쓰레드가 종료되면 강제 종료되도록 설정하여 멈추도록 하겠다.
class ThreadEx10 implements Runnable { //run()
static boolean autoSave = false;
public static void main(String[] args) {
Thread t = new Thread(new ThreadEx10());
t.setDaemon(true); // 이 부분이 없으면 종료되지 않는다.
t.start();
for(int i=1; i<=10;i++) {
try {
Thread.sleep(1000);//1초마다
}catch(InterruptedException e) {}
System.out.println(i);
if(i==5)
autoSave=true;
}
System.out.println("프로그램을 종료합니다.");
}
public void run() {
while(true) {
try {
Thread.sleep(3*1000);//3초마다
}catch(InterruptedException e) {}
//autoSave가 true인 경우 호출 //5초 이후
if(autoSave) {
autoSave();
}
}
}
public void autoSave() {
System.out.println("작업파일이 자동저장되었습니다.");
}
}
👆 여기서 짚고 넘어갈 부분은
반드시 start()를 호출하기 전에 t를 데몬 쓰레드로 만드는 작업을 실행해야 한다.
① t.setDaemon(true); // 이 부분이 없으면 종료되지 않는다.
② t.start();
import java.util.*;
class ThreadEx11 {
public static void main(String[] args) {
ThreadEx11_1 t1= new ThreadEx11_1("Thread1");
ThreadEx11_2 t2= new ThreadEx11_2("Thread2");
t1.start();
t2.start();
}
}
class ThreadEx11_1 extends Thread {
ThreadEx11_1(String name){
super(name);
}
public void run() {
for(int i=0;i<5;i++) {//5초 동안 기다린다.
try {
sleep(1000);
}catch(InterruptedException e) {}
System.out.println(i);
}
}
}
class ThreadEx11_2 extends Thread {
ThreadEx11_2(String name){
super(name);
}
public void run() {
Map map = getAllStackTraces();
System.out.println("map: "+map);
System.out.println();
// 실행 중 또는 대기 상테 즉 작업이 완료되지 않은 모든 쓰레드의 호출스텍을 출력
Iterator it = map.keySet().iterator();
System.out.println("map.keySet() : "+map.keySet());
System.out.println();
int x=0;
while(it.hasNext()) {
Object obj = it.next();
Thread t =(Thread)obj;
StackTraceElement[] ste = (StackTraceElement[])(map.get(obj));
System.out.println("["+ ++x +"] name : "+t.getName()
+", group : "+t.getThreadGroup().getName()
+". demon : "+t.isDaemon());
for(int i=0;i<ste.length;i++) {
System.out.println(ste[i]);
}
System.out.println();
}
}
}
👆 getAllStackTraces()를 이용하면 실행 중 또는 대기 상테 즉 작업이 완료됮 않은 모든 쓰레드의 호출스텍을 출력
✔️ 프로그램을 실행하면 JVM은 가비지컬렉션, 이벤트처리, 그래픽처리와 같이 프로그램이 실행되는데 필요한 보족적인 데몬쓰레드를 자동으로 생성해서 실행시키며 이는 system쓰레드 그룹이나 main쓰레드 그룹에 속하게 된다.
✔️ Thread1과 Thread2를 제외한 6개 쓰레드는 데몬쓰레드이며 JVM이 자동적으로 생성한 것을 유추할 수 있다.
쓰레드의 스케줄링과 관련된 메서드
메서드 | 설명 |
---|---|
static void sleep(long millis) | 지정된 시간 동안 쓰레드 일시정지 |
static void sleep(long millis, int nanos) | 지정된 시간이 지나고 나면 자동으로 다시 실행대기상태 |
void join() | 지정된 시간 동안 쓰레드 실행 |
void join(long millis) | 지정된 시간이 지나거나 종료되면 join()을 호출한 쓰레드로 |
void join(long millis, int nanos) | 다시 돌아와 실행을 계속 |
void interrupt() | sleep()이나 join()에 의해 일시정지상태인 쓰레드를 깨워서 실행대기 상태로 만든다. 해당 쓰레드에서 InterruptedException이 발생함으로써 일시정지상태에서 벗어난다. |
void stop() | 쓰레드 즉시 종료 |
void suspend() | 쓰레드를 일시정지시킨다. resume()을 호출하면 다시 실행대기상태로 |
void resume() | suspend()에 의해 일시정지 상태의 쓰레드를 다시 실행대기상태로 |
static void yield() | 실행 중에 자신에게 주어진 실행대기시간을 다른 쓰레드에게 양보 |
쓰레드의 상태
상태 | 설명 |
---|---|
NEW | 쓰레드가 생성되고 start()를 호출하기 전의 상태 |
RUNNABLE | 실행 중 또는 실행 가능한 상태 |
BLOCKED | 동기화 블럭에 의해서 일시정지된 상태 |
WAITING,TIMED_WAITING | 쓰레드의 작업이 종료되지는 않았지만 실행가능하지 않은 일시정지 상태. TIMED_WAITING은 일시정지시간이 지정된 경우 |
TERMINATED | 쓰레드의 작업이 종료된 상태 |
class ThreadEx12 {
public static void main(String[] args) {
ThreadEx12_1 th1= new ThreadEx12_1();
ThreadEx12_2 th2 = new ThreadEx12_2();
th1.start();
th2.start();
try {
//Thread.sleep(2000);
th1.sleep(2000);
}catch(InterruptedException e) {}
System.out.print("<<main 종료>>");
}
}
class ThreadEx12_1 extends Thread {
public void run() {
for(int i=0;i<300;i++)
System.out.print("-");
System.out.println("<<th1 종료>>");
}
}
class ThreadEx12_2 extends Thread {
public void run() {
for(int i=0;i<300;i++)
System.out.print("|");
System.out.println("<<th2 종료>>");
}
}
👆 여기서 중요한 것은 th1.sleep(1000);이 th1에 아무런 영향을 주지 않는다는 것이다. 일단 sleep메서드는 static 메서드이기 때문에 참조변수랑 상관이 없다. 앞서 배웠지만 static메서드는 인스턴스 생성없이 사용할 수 있는 메서드이기 때문이다. 따라서 Thread.sleep(1000);으로 표현하는게 더 알맞은 표현이다. 또한 sleep()메서드는 실행중인 메서드에 대해서 작동하기 때문에 여기서는 th1이 아닌 실행중인 main 쓰레드에 대해서 1초의 일시정지 상태가 된다. 따라서 main쓰레드가 가장 마지막에 종료된다!
✔️ 진행중인 쓰레드의 작업이 끝나기 전에 취소시켜야할 때
예를 들어 큰 파일 다운로드 중 시간이 오래 걸려 중간에 다운로든 포기하는 경우
강제 종료가 아닌 멈추라는 요청
void interrupt () 쓰레드의 interrupted상태를 false->true
boolean isInterrupted() 쓰레드의 interrupted상태를 반환
static boolean interrupted() 쓰레드의 interrupted상태를 반환 후, false로 변경
✔️ 쓰레드가 sleep(), wait(), join()에 의해 일시정지 상태에 있을 때
-> 해당 쓰레드에 interrupt()를 호출하면
-> 실행대기 상태로 바뀐다.
suspend() : sleep()처럼 쓰레드를 멈추게 하지만 정지된 thread는 resume()호출로 다시 실행대기 상태가 된다.
stop() : 호출되는 즉시 쓰레드 종료
그러나 이 둘의 메서드는 교착상태를 일으키기 쉬워서 사용이 권장되지 않는다.
그래서 모두 'deprecated'되었다.
이는 전에는 사용되었지만 앞으로 사용하지 않을 것을 권장한다는 의미이다.
class ExceptionEx9 {
public static void main(String[] args) {
RunImpEx15 r= new RunImpEx15();
Thread th1 = new Thread(r,"*");
Thread th2 = new Thread(r,"**");
Thread th3 = new Thread(r,"***");
th1.start();
th2.start();
th3.start();
try {
Thread.sleep(2000);//main쓰레드가 1초간 멈춤
th1.suspend();
Thread.sleep(2000);
th2.suspend();
Thread.sleep(3000);
th1.resume();
Thread.sleep(3000);
th1.stop();
th2.stop();
Thread.sleep(2000);
th3.stop();
}catch(InterruptedException e) {}
}
}
class RunImpEx15 implements Runnable {
public void run() {
while(true) {
System.out.println(Thread.currentThread().getName());
try {
Thread.sleep(1000);
}catch(InterruptedException e) {}
}
}
}
class ThreadEx16 {
public static void main(String[] args) {
RunImplEx16 r1 = new RunImplEx16();
RunImplEx16 r2 = new RunImplEx16();
RunImplEx16 r3 = new RunImplEx16();
//각 쓰레드가 다른 실행상태를 가질 수 있어야 하므로
//이전 예제와 다른 부분인데
//각자 정의된 함수를 따로 사용하기 위함이라고 이해하겠다.
Thread th1 = new Thread(r1,"*");
Thread th2 = new Thread(r2, "**");
Thread th3 = new Thread(r3,"***");
th1.start();
th2.start();
th3.start();
try {
Thread.sleep(2000);
r1.suspend();//th1.suspend()가 아님
//r1.suspend()로 한 이유는 내가 RunImpleEx16의 suspend()함수를 이용하기 위함이다.
Thread.sleep(2000);
r2.suspend();
Thread.sleep(3000);
r1.resume();
Thread.sleep(3000);
r1.stop();
r2.stop();
Thread.sleep(2000);
r3.stop();
}catch (InterruptedException e) {}
}
}
class RunImplEx16 implements Runnable {
volatile boolean suspended = false ;
volatile boolean stopped = false;
public void run() {
while(!stopped) { // 즉 stopped가 false인 경우에 실행
if(! suspended) {
System.out.println(Thread.currentThread().getName());
try {
Thread.sleep(1000);
}catch(InterruptedException e) {}
}
}
System.out.println(Thread.currentThread().getName() + "-stopped");
}
public void suspend() {suspended = true;}
public void resume() {suspended = false;}
public void stop() {stopped = true;}
}
class ThreadEx16 {
public static void main(String[] args) {
//RunImplEx16 r1 = new RunImplEx16();
//RunImplEx16 r2 = new RunImplEx16();
//RunImplEx16 r3 = new RunImplEx16();
//각 쓰레드가 다른 실행상태를 가질 수 있어야 하므로
//이전 예제와 다른 부분인데
//각자 정의된 함수를 따로 사용하기 위함이라고 이해하겠다.
ThreadEx17_1 th1 = new ThreadEx17_1("*");
ThreadEx17_1 th2 = new ThreadEx17_1("**");
ThreadEx17_1 th3 = new ThreadEx17_1("***");
th1.start();
th2.start();
th3.start();
try {
Thread.sleep(2000);
//r1.suspend();//th1.suspend()가 아님
//r1.suspend()로 한 이유는 내가 RunImpleEx16의 suspend()함수를 이용하기 위함이다.
th1.suspend();
Thread.sleep(2000);
//r2.suspend();
th2.suspend();
Thread.sleep(3000);
//r1.resume();
th1.resume();
Thread.sleep(3000);
//r1.stop();
//r2.stop();
th1.stop();
th2.stop();
Thread.sleep(2000);
//r3.stop();
th3.stop();
}catch (InterruptedException e) {}
}
}
class ThreadEx17_1 implements Runnable {
volatile boolean suspended = false ;
volatile boolean stopped = false;
Thread th;
ThreadEx17_1(String name){
th = new Thread(this,name);//this인 ThreadEx17_1은 Runnable을 구현했기 때문에
//Thread (Runnable r, String name);
}
public void run() {
while(!stopped) { // 즉 stopped가 false인 경우에 실행
if(! suspended) {
System.out.println(Thread.currentThread().getName());
try {
Thread.sleep(1000);
}catch(InterruptedException e) {}
}
}
System.out.println(Thread.currentThread().getName() + "-stopped");
}
public void suspend() {suspended = true;}
public void resume() {suspended = false;}
public void stop() {stopped = true;}
public void start() {th.start();}
}
👆 이 예시는 앞서 있었던 예시보다 좀 더 객체지향적인 코드이다.
자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보한다.
public class ThreadEx18 {
public static void main(String[] args) {
ThreadEx18_1 th1 = new ThreadEx18_1("*");
ThreadEx18_1 th2 = new ThreadEx18_1("**");
ThreadEx18_1 th3 = new ThreadEx18_1("***");
th1.start();
th2.start();
th3.start();
try {
System.out.println(Thread.currentThread().getName());
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName());
th1.suspend();
System.out.println(Thread.currentThread().getName());
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName());
th2.suspend();
System.out.println(Thread.currentThread().getName());
Thread.sleep(3000);
System.out.println(Thread.currentThread().getName());
th1.resume();
System.out.println(Thread.currentThread().getName());
Thread.sleep(3000);
System.out.println(Thread.currentThread().getName());
th1.stop();
System.out.println(Thread.currentThread().getName());
th2.stop();
System.out.println(Thread.currentThread().getName());
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName());
th3.stop();
} catch(InterruptedException e) {}
}
}
class ThreadEx18_1 implements Runnable {
boolean suspended = false;
boolean stopped = false;
Thread th;
ThreadEx18_1(String name) {
th=new Thread(this,name);
}
public void run() {
String name = th.getName();
while(!stopped) {
if(!suspended) {
System.out.println(name);
try {
System.out.println("오"+Thread.currentThread().getName());
Thread.sleep(1000);
}catch(InterruptedException e) {
System.out.println(name+"- interrupted");
}
} else {
Thread.yield();
}
}
System.out.println(name+"-stopped");
}
public void suspend() {
suspended = true;
th.interrupt();
System.out.println(th.getName()+"- interrupt() by suspend()");
}
public void stop() {
stopped = true;
th.interrupt();
System.out.println(th.getName()+"- interrupt() by stop()");
}
public void resume() {suspended =false;}
public void start() {th.start();}
}
👆 코드가 많이 더러워졌지만
중간에 어떻게 돌아가는지 정확히 알기 위해서
System.out.println(Thread.currentThread().getName());
을 통해서 언제 main쓰레드가 실행되는지
System.out.println("오"+Thread.currentThread().getName());
를 통해서 언제 run()이 실행되는지 확인했다.
생각보다 멀티쓰레드의 전환이 많았다.
엄청 짧은 시간동안 번갈아 수행해서 우리는 동시에 수행하는 것처럼 보이는 이유를 간접적으로나마 느낄 수 있었다.
또한 내가 이해하지 못했던 말이 있었는데
"stop()이 호출되었을 때 Thread.sleep(1000)에 의해 쓰레드가 일시정지 상태에 머물러 있는 상황이라면, stopped값이 true로 바뀌었어도 쓰레드가 정지(stop)될 때까지 최대 1초의 시간지연이 생길것이다."
이 부분이었다.
하지만 저렇게 쪼개서 확인해보니 얼마나 빠르게 번갈아 가며 진행하게 저 말이 가능한지 알게되었다.
또한
else{ //suspended가 true인 경우
Thread.yield();
}
이 부분인데 while문은 계속 실행되기 때문에 이 상황을 바쁜 대기상태라고 한다고 한다.
그러나 yield()를 호출해서 남은 실행시간을 while문에서 낭비하지 않고 다른 쓰레드에게 양보하게 되므로 더 효율적이다.
쓰레드가 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때 join()을 사용한다.
void join()
void join(long miils)
void join(long miilis, int name)
시간을 지정하지 않으면 해당 쓰레드가 작업을 마칠때까지 기다리게 된다.
작업 중에 다른 쓰레드의 작업이 먼저 수행되어야할 필요가 있을 때 join()을 사용
try{
th1.join() // 현재 실행중이 쓰레드가 쓰레드 th1 작업이 끝날 때까지 기다린다.
}catch(InterruptedException e) {}
join()은 sleep() 마찬가지로 interrupt()에 의해서 대기 상테에서 벗어난다.
따라서 try-catch문으로 감싸여져 있어야 한다. (interrupt()에 의해서 InterruptedException이 발생)
하지만 join() 메서드는 static메서드가 아니기 때문에 현재 쓰레드가 아닌 특정 쓰레드에 대해 동작한다.
class ThreadEx19 {
static long startTime =0;
public static void main(String[] args) {
ThreadEx19_1 th1 = new ThreadEx19_1();
ThreadEx19_2 th2 = new ThreadEx19_2();
th1.start();
th2.start();
startTime = System.currentTimeMillis();
//try {
// th1.join();
// th2.join();
//}catch(InterruptedException e) {}
System.out.println("소요시간 : "+(System.currentTimeMillis()
-ThreadEx19.startTime));
}
}
class ThreadEx19_1 extends Thread {
public void run() {
System.out.println("start th1");
for (int i=0;i<400;i++) {
System.out.print(new String("-"));
}System.out.println("end th1");
}
}
class ThreadEx19_2 extends Thread{
public void run() {
System.out.println("start th2");
for(int i=0;i<400;i++) {
System.out.print(new String("|"));
}System.out.println("end th2");
}
}
👆위 예시는 내가 join()함수는 주석처리하였을 때 결과이다.
알수 있듯이 main쓰레드는 먼저 종료된다.
대략 그림으로 보면 마지막에
이 모습일것이다.(나의 예측,,정확하지 않습니다 틀리면 말씀해주세요)
main쓰레드는 이미 종료되어 사라진 상태
class ThreadEx19 {
static long startTime =0;
public static void main(String[] args) {
ThreadEx19_1 th1 = new ThreadEx19_1();
ThreadEx19_2 th2 = new ThreadEx19_2();
th1.start();
th2.start();
startTime = System.currentTimeMillis();
try {
th1.join();//현재 쓰레드인 main쓰레드가 기다린다. th1이 종료될 때까지
th2.join();//현재 쓰레드인 main쓰레드가 기다린다. th2가 종료될 때까지
}catch(InterruptedException e) {}
System.out.println("소요시간 : "+(System.currentTimeMillis()
-ThreadEx19.startTime));
}
}
class ThreadEx19_1 extends Thread {
public void run() {
System.out.println("start th1");
for (int i=0;i<400;i++) {
System.out.print(new String("-"));
}System.out.println("end th1");
}
}
class ThreadEx19_2 extends Thread{
public void run() {
System.out.println("start th2");
for(int i=0;i<400;i++) {
System.out.print(new String("|"));
}System.out.println("end th2");
}
}
👆 나는 try-catch문의 주석을 제거하고 해보았다.
파란 형광펜에서 알 수 있듯이 가장 마지막에 main 쓰레드가 사라진다.
대략 이런 순서로 존재하게 될 것이다.(나의 예상..순간포착이다 중간에 어떤 쓰레드에 메서드가 있는 상황이 존재할 수 있음)
class ThreadEx20 {
public static void main(String[] args) {
ThreadEx20_1 gc = new ThreadEx20_1();
gc.setDaemon(true);//main쓰레드 종료되면 강제 종료
gc.start();
int requireMemory=0;
for(int i=0;i<20;i++) {
requireMemory = (int)(Math.random()*10)*20;
System.out.println("requireMemory :" +requireMemory);
if(gc.freeMemory()<requireMemory ||
gc.freeMemory()<gc.totalMemory()*0.4) {
if(gc.freeMemory()<gc.totalMemory()*0.4)
{System.out.println("메모리를 60%이상 사용한 상태");
} else if (gc.freeMemory()<requireMemory) {
System.out.println("요구되는 메모리가 남은 메모리보다 클 때");}
gc.interrupt();
}
gc.usedMemory+=requireMemory;
System.out.println("usedMemory: "+gc.usedMemory);
}
}
}
class ThreadEx20_1 extends Thread{
final static int MAX_MEMORY =1000;
int usedMemory = 0;
public void run() {
while(true) {
try {
Thread.sleep(10*1000);
}catch(InterruptedException e) {
System.out.println("Awaken by interrupt().");
}
gc();
System.out.println("Garbage Collected. Free Memory :"+freeMemory());
}
}
public void gc() {
usedMemory-=300;
if(usedMemory <0 ) usedMemory =0;
}
public int totalMemory() {return MAX_MEMORY;}
public int freeMemory() {return MAX_MEMORY - usedMemory ;}
}
👆일부로 단계별로 보려고
if(gc.freeMemory()<requireMemory ||
gc.freeMemory()<gc.totalMemory()*0.4) {
if(gc.freeMemory()<gc.totalMemory()*0.4)
{System.out.println("메모리를 60%이상 사용한 상태");
} else if (gc.freeMemory()<requireMemory) {
System.out.println("요구되는 메모리가 남은 메모리보다 클 때");}
지저분한 코드를 추가했다.
근데 왜.... 1000 이 넘는 상황이 나오지 않는지 모르겠다.
20번은 계속 돌렸는데 저 상황이 나오지 않아서
join()을 사용할 필요성을 느끼지 못했다.
하지만 책에서 왜 join()을 사용하는지 이해했다.
일단 번갈아가면서 main과 gc를 수행하기 때문에 main에서 interrupt()가 main쓰레드에서 발생함. 그래서 gc run()의 catch문까지 도달했으나 다시 main쓰레드의 requireMemory를 렌덤하게 뽑는 부분으로 이동하게 되면서 1000이 넘는 상황이 발생.. 따라서 gc쓰레드에게 시간을 줘야한다. 이 과정에서 join()을 이용한다.
if(gc.freeMemory()<requireMemory ||
gc.freeMemory()<gc.totalMemory()*0.4) {
gc.interrupt();
try {//추가 시작
gc.join(100);}
catch(InterruptedException e){}//추가 끝
}
동기화의 정의는 위와 같다.
왜 일정한 간격을 두고 일어나도록 시간을 조정하는 걸까?
그 이유는 멀티쓰레드 프로세스 때문이다.
A 작업을 하다가 B의 차례가 와서 제어권이 넘어갔다.
같은 프로세스의 멀티쓰레드의 경우 동일한 메모리를 사용하기 때문에 공유데이터가 넘어가게 되어있다.
따라서 A 작업을 하다가 공유데이터가 B 작업으로 넘어가고 다시 그 데이터가 update 되었다. 이제 다시 A작업으로 넘어갔지만 이어서 작업할 수가 없다. 공유데이터가 변경되어서 그 이전의 상태에서 작업할 수 없기 때문이다.
따라서 우리는 이 때 한 쓰레드가 특정 작업을 끝마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 하는 것이 필요하다. 즉 어떤 문을 열 수 있는 하나의 열쇠를 특정 쓰레드가 가지고 있다가 작업이 끝나면 다음을 이어받는 쓰레드에게 넘겨주는 것이다.
이 때 도입된 개념은 임계영역과 잠금이다.
공유데이터를 사용하는 코드 영역을 임계영역으로 지정해놓고
공유데이터가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있도록 하는 것이다. 그리고 끝나면 그 lock을 다음을 이어받는 쓰레드에게 넘겨준다.
한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는것을 쓰레드의 동기화라고 한다.
가장 간단한 방법인 synchronized 키워드를 이용한 동기화
두 가지 방법이 존재한다.
① 메서드 전체를 임계 영역으로 지정
public synchronized void calcSum() {
//...
}
② 특정한 영역을 임계영역으로 지정
synchronized {
//...
}
임계영역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 가능하면 메서드 전체보다는 synchronized블럭으로 임계영역을 최소화
class ThreadEx21 {
public static void main(String[] args) {
Runnable r = new RunnableEx21();
new Thread(r).start();
new Thread(r).start();
}
}
class Account {
private int balance = 1000;//privat이므로 메서드를 통해서 접근가능함.
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) {
int money = (int)(Math.random()*3+1)*100;//100,200,300 중 나온다.
acc.withdraw(money);
System.out.println("balance:"+acc.getBalance());
}
}
}
👆 결과를 보니 -100이 나왔다. 어떻게 나올 수 있었을까?
여기서 나는 2개의 새로운 쓰레드를 만들었는데
if문을 통과하고 try에서 1초 대기하는 순간에 (즉 아직 balance-=money까지 가지 못함) 다른 쓰레드 차례가 되어 if문을 통과하면서 마이너스의 값이 발생하게 되는 것이다. 이것을 의도하기 위해서 1초 대기하는 Thread.sleep(1000)을 넣은 것이다.
이렇게 하나의 쓰레드가 이미 들어와 있는 코드에 다른 쓰레드 차례가 되면서 문제가 충돌하게 되었다. 따라서 이를 개선하는 예시를 살펴보자
class ThreadEx21 {
public static void main(String[] args) {
Runnable r = new RunnableEx21();
new Thread(r).start();
new Thread(r).start();
}
}
class Account {
private int balance = 1000;//privat이므로 메서드를 통해서 접근가능함.
public int getBalance() {
return balance;
}
public synchronized 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) {
int money = (int)(Math.random()*3+1)*100;//100,200,300 중 나온다.
acc.withdraw(money);
System.out.println("balance:"+acc.getBalance());
}
}
}
근데 만약에 문을 열 수 있는 열쇠를 한 사람이 오랫동안 가지고 있으면 다음 사람은 그 문을 열지 못하고 계속 기다려야 한다. 따라서 더 이상 그 문을 이용하지 않으면 다음 사람에게 작업을 끝마치지 않더라고 넘겨줄 수 있는 장치가 필요하다.
이것이 바로 wait()와 notify()이다
wait() 넘겨주고 다시 돌려받은 notify()
하지만 돌려받을 때 가장 오래 기다린 쓰레드가 받는 것은 아니고
내부적으로 우선순위를 계산해서 우선순위가 높은 쓰레드가 그 열쇠를 받는다.
종류는 아래와 같다.
wait()의 경우는 매개변수가 존재할 수 있으며 매개변수는 지정된 시간동안만 넘겨주다가 그 시간이 경과하면 notify()가 호출되는 것과 같다.
void wait();
void wait(long timeout);
void wait(long timeout, int nanos);
void notify();
void notifyAll();
wait()와 notify()는 특정 객체에 대한 것이므로 Object클래스에 정의되어져 있다.
wait(), notity(), notifyAll()
- Object에 정의되어 있다.
- 동기화 블록 내에서만 사용할 수 있다.
- 보다 효율적인 동기화를 가능하게 한다.
다음 예시를 보자
여기에는 3개의 클래스가 존재한다
Table, Customer, Cook
import java.util.ArrayList;
public class ThreadWaitEx1 {
public static void main(String[] args) throws Exception {
Table table = new Table();//여러 쓰레드가 공유하는 객체
//이제 딱 보면 안다. 이렇게 되어있는 것 Runnable 인터페이스를 구현하여 쓰레드를 생성하고 구현한 것!
new Thread(new Cook(table),"COOK1").start();
new Thread(new Customer(table,"donut"),"CUST1").start();
new Thread(new Customer(table,"burger"),"CUST2").start();
//자 COOK1,CUST1,CUST2 쓰레드 3개가 구현되었음.
//3개의 stack이 생겼다.
Thread.sleep(100);
System.exit(0);
}
}
class Customer implements Runnable {
private Table table;
private String food;
Customer(Table table, String food){
this.table=table;
this.food=food;
}
//Customer은 음식을 소비하기 때문에 run()은 Table의 remove()메서드를 이용
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);} //음식 있으면 제거하고 true, 없으면 제거 못하고 false
}
class Cook implements Runnable {
private Table table;
Cook(Table table) {this.table=table;}
//Cook은 음식을 만들기 때문에 run()은 Table의 add()메서드를 이용
public void run() {
//Table의 dishNum String array에 있는 요소를 랜덤으로 뽑아서 음식으로 추가
while(true) {
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"};
final int MAX_FOOD =6;
private ArrayList<String> dishes = new ArrayList<>();//String을 요소로 하는 ArrayList
public void add(String dish) {
//table에 음식을 추가하되, 사이즈 6을 넘으면 아무것도 return하지 않음
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;}
}
👆 자, 여기서 우리가 눈여겨 봐야할 것은 저기 등장하는
ConcurrentModificationException
이것은 요리사 쓰레드가 음식을 놓은 도중에서 작업 중단되고 손님 쓰레드가 차례가 되어 음식을 가져가려 했기 때문에 등장하는 에러이다.
그리고 하나 더 있는데
IndexOutOfBoundsException
이것은 마지막 음식을 가져가는 도중에 다른 손님 쓰레드가 먼저 음식을 낚아채서 가져가버려 있지도 않은 음식을 테이블에서 제거하려했기에 발생하는 에러이다.
이를 해결하기 위해서 공유하는 테이블을 동기화해보자
import java.util.ArrayList;
public class ThreadWaitEx1 {
public static void main(String[] args) throws Exception {
Table table = new Table();//여러 쓰레드가 공유하는 객체
//이제 딱 보면 안다. 이렇게 되어있는 것 Runnable 인터페이스를 구현하여 쓰레드를 생성하고 구현한 것!
new Thread(new Cook(table),"COOK1").start();
new Thread(new Customer(table,"donut"),"CUST1").start();
new Thread(new Customer(table,"burger"),"CUST2").start();
//자 COOK1,CUST1,CUST2 쓰레드 3개가 구현되었음.
//3개의 stack이 생겼다.
Thread.sleep(5000);
System.exit(0);
}
}
class Customer implements Runnable {
private Table table;
private String food;
Customer(Table table, String food){
this.table=table;
this.food=food;
}
//Customer은 음식을 소비하기 때문에 run()은 Table의 remove()메서드를 이용
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);} //음식 있으면 제거하고 true, 없으면 제거 못하고 false
}
class Cook implements Runnable {
private Table table;
Cook(Table table) {this.table=table;}
//Cook은 음식을 만들기 때문에 run()은 Table의 add()메서드를 이용
public void run() {
//Table의 dishNum String array에 있는 요소를 랜덤으로 뽑아서 음식으로 추가
while(true) {
int idx = (int)(Math.random()*table.dishNum());
table.add(table.dishNames[idx]);
try { Thread.sleep(100);} catch(InterruptedException e) {}
}
}
}
class Table{
String[] dishNames = {"donut","donut","burger"};
final int MAX_FOOD =6;
private ArrayList<String> dishes = new ArrayList<>();//String을 요소로 하는 ArrayList
public synchronized void add(String dish) {
//table에 음식을 추가하되, 사이즈 6을 넘으면 아무것도 return하지 않음
if(dishes.size() >=MAX_FOOD)
return;
dishes.add(dish);
System.out.println("Dishes:"+dishes.toString());
}//음식을 다 놓고 난 다음에 음식을 가져가도록 synchronized
public boolean remove(String dishName) {
//일치하는 음식이 있으면 제거
synchronized (this) {
while(dishes.size()==0) {
//남은 음식이 0일 때 지금의 thread는 가져갈게 없으니 대기하고 있다고 푯;
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;}
}
👆 손님이 계속 lock을 쥐고 있어서 요리사가 음식을 추가하지 못한채
손님은 Table의 remove의 while을 계속 돌고 있다.
이럴 때 사용하는 것이 wait()와 notify()메서드이다.
나는 이제 손님은 기다리고 wait()를 통해서 요리사가 음식을 만들게 한다음에
음식이 추가되면 notify()를 통해서 통보를 받고 음식을 손님이 소비할 수 있도록 고쳐보겠다. <근데 앞에서도 말했지만 기다린다고 그 사람 순서에 가는 것은 아니다. 컴퓨터 내부의 우선순위 규칙에 따라 쓰레드에게 기회가 가는것이기 때문이다.>
import java.util.ArrayList;
public class ThreadWaitEx1 {
public static void main(String[] args) throws Exception {
Table table = new Table();//여러 쓰레드가 공유하는 객체
//이제 딱 보면 안다. 이렇게 되어있는 것 Runnable 인터페이스를 구현하여 쓰레드를 생성하고 구현한 것!
new Thread(new Cook(table),"COOK1").start();
new Thread(new Customer(table,"donut"),"CUST1").start();
new Thread(new Customer(table,"burger"),"CUST2").start();
//자 COOK1,CUST1,CUST2 쓰레드 3개가 구현되었음.
//3개의 stack이 생겼다.
Thread.sleep(5000);
System.exit(0);
}
}
class Customer implements Runnable {
private Table table;
private String food;
Customer(Table table, String food){
this.table=table;
this.food=food;
}
//Customer은 음식을 소비하기 때문에 run()은 Table의 remove()메서드를 이용
public void run() {
while(true) {
try { Thread.sleep(10);}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;}
//Cook은 음식을 만들기 때문에 run()은 Table의 add()메서드를 이용
public void run() {
//Table의 dishNum String array에 있는 요소를 랜덤으로 뽑아서 음식으로 추가
while(true) {
int idx = (int)(Math.random()*table.dishNum());
table.add(table.dishNames[idx]);
try { Thread.sleep(100);} catch(InterruptedException e) {}
}
}
}
class Table{
String[] dishNames = {"donut","donut","burger"};
final int MAX_FOOD =6;
private ArrayList<String> dishes = new ArrayList<>();//String을 요소로 하는 ArrayList
public synchronized void add(String dish) {
//table에 음식을 추가하되, 사이즈 6을 넘으면 계속 while문
while(dishes.size() >=MAX_FOOD) {//if(dishes.size() >=MAX_FOOD) return;
String name = Thread.currentThread().getName();
System.out.println("add_Thread: "+name);
System.out.println(name+"is waiting. ");
try {
System.out.println("waiting.-start ");
wait(); //요리사 열쇠를 넘기고 waiting pool로 가기
Thread.sleep(500); //waiting pool에 있다가 언제가 호출
System.out.println("waiting.-end ");
} catch(InterruptedException e) {}
}//while 끝.
dishes.add(dish);
notify(); //Customer 깨우기
//하지만 요리사나 손님 중 누구한테 갈지는 모름
System.out.println("Dishes:"+dishes.toString());
}//음식을 다 놓고 난 다음에 음식을 가져가도록 synchronized
public void remove(String dishName) {
//일치하는 음식이 있으면 제거
synchronized (this) {
String name = Thread.currentThread().getName();
System.out.println("remove-현재 Thread : "+name);
System.out.println("remove시작");
while(dishes.size()==0) {
//남은 음식이 0일 때 계속 돌도록
System.out.println("size 0 : "+name+" is waiting");//기다리는 CUST Thread이름
try {
System.out.println("waiting.-start ");
wait(); //손님이 lock을 넘겨주고 waiting pool로
System.out.println(name+"의 waiting.-end ");
//waiting pool에 있다가 언젠가 호출
Thread.sleep(500);
System.out.println("remove size 0 0.5초 후");
} catch(InterruptedException e) {}
}
while(true) {
for(int i=0; i<dishes.size();i++) {
if(dishName.equals(dishes.get(i))) {
dishes.remove(i);
notify();
return;
}
}
System.out.println("원하는 음식이 없는 손님은 try-catch까지");
try {
System.out.println(name+ " is waiting");
wait(); //원하는 음식이 없는 Cust 기다리기
Thread.sleep(500);
System.out.println("원하는 음식이 없어 끝");
} catch(InterruptedException e) {}
}//while
}//synchronized
}//remove()
public int dishNum() {return dishNames.length;}
public int dishesNum() {return dishes.size();}
}//class
👆 사실 코드와 결과가 좀 더럽다.
내가 어떻게 쓰레드가 번갈아 진행되고 wait()와 notify()가 역할을 하는지 궁금해서 중간중간에 println으로 문장을 추가했기 때문이다.
사실 이 wait()와 notify()는 동기화된 블럭에 다른 쓰레드가 접근할 수 있도록 하는 것이다. wait()를 통해서 waiting pool로 가지만 그렇다고 원하는 조건이 되었을 때 등장하거나 notify()를 통해서 해당 쓰레드가 등장하는 것은 아니다. 그냥 내부 우선순위에 따라 언젠가 다시 등장한다. 하지만 등장하기까지 다른 쓰레드가 접근할 수 있도록 열쇠를 다른 쓰레드에게 넘겨주는 것 뿐이다.
또한 notify()도 마찬가지다. waiting pool에 있는 우선순위가 먼저인 쓰레드를 깨울 뿐이지 맞는 조건에 맞는 쓰레드를 깨우는 것도 아니다.
따라서 이 wait()와 notify()는 동기화 블럭에 다른 쓰레드에게 접근 기회와 waiting pool에 있는 쓰레드를 멀티쓰레딩에 있는 번갈아 깨우는 것이 아니라 코드를 통해서 깨우는 정도의 일을 할 뿐이지 어떤 조건에 맞는 상황일 때 등장하는 것은 아니라는 것을 깨달았다.
나는 앞서 말했지만 waiting pool에 있는 우선순위에 따라 호출된다고 했다. 따라서 계속 호출을 받지 못하는 쓰레드가 있을 수 있으며 운이 나쁘다면 그 쓰레드가 요리사 쓰레드가 되어서 계속 음식을 만들지 못할 수도 있는 것이다.
이것을 '기아현상(starvation)'이라고 한다.
👀 이 현상을 막으려면 notify() 대신 notifyAll()을 사용하자
말 그대로 모든 쓰레드에게 통지를 하는 것
하지만 결국 모든 쓰레드 손님이나 요리사 구별없이 모두에게 통지하기 때문에
이 쓰레드들은 경쟁을 하게된다. 우선순위가 없는 경쟁상태에서
우리는 구별을 해서 통지를 해야하는 필요성을 느끼게 된다.
👀 이것은 Lock과 Condition을 이용하여 해결해보자
동기화할 수 있는 방법
① synchronized 블럭
② java.util.concurrent.locks 패키지가 제공하는 lock클래스들을 이용
lock클래스의 종류
- ReentrantLock : 재진입이 가능한 lock, 가장 일반적인 배타 lock
- ReentrantReadWriteLock : 읽기에는 공유적, 쓰기에는 배타적 lock
- StampedLock : ReentrantReadWriteLock + 낙관적인 lock의 기능
ReentrantLock
특정 조건에서 lock을 풀고 다시 lock을 얻고 임계영역으로 들어와서 이후의 작업을 수행
ReentrantReadWriteLock
읽기를 위한 lock, 쓰기의 위한 lock을 제공
ReentrantLock은 배타적인 lock이라서 무조건 lock이 있어야만 임계영역의 코드를 수행할 수 있지만
ReentrantReadWriteLock은 읽기 lock이 걸려있으면, 다른 쓰레드가 읽기 lock을 중복해서 걸고 읽기를 수행
읽기를 할 때는 읽기 lock을 걸고, 쓰기 할 때는 쓰기 lock을 건다.
StampedLock
lock을 걸거나 해지할 때 스탬프(long타입의 정수값)를 사용
읽기와 쓰기를 위한 lock + 낙관적인 읽기 lock
읽기 lock 걸려있으면 쓰기 lock을 얻기 위해서는 읽기 lock이 풀릴 때까지 기다려야 하지만 낙관적인 읽기는 읽기 lock이 쓰기 lock에 의해서 바로 풀림
ReentrantLock 두 종류 생성자
ReentrantLock()
ReentrantLock(boolean fair)
생성자의 매개변수 true
lock이 풀렸을 때 가장 오래 기다린 쓰레드가 lock을 획득 = 공정하게 처리
그러나 오래 기다린 쓰레드를 계산하는 과정이 추가되므로 성능 떨어짐
대부분 공정함<<성능
void lock() //lock을 잠근다.
void unlock() // lock을 해지한다.
boolean isLocked() //lock이 잠겼는지 확인한다.
lock을 걸고 lock을 푸는 것을 잊어버리지 않게 조심
임계영역 내에서 예외 발생 or return문으로 빠져나가는 상황 발생
-> lock이 풀리지 않을 수 있으므로
-> unlock은 try-finally문으로 감싸는 것이 일반적
lock.lock();
try{
//임계영역
}finally {
lock.unlock();
}
try블럭 내에서 어떤 일이 발생해도 finally블럭에 있는 unlock을 수행
tryLock()
다른 쓰레드에 의해 lock이 걸려있으면 lock을 얻으려고 기다리기 ❌
지정된 시간만큼만 기다린다.
lock을 얻으면 true
lock을 얻지 못하면 false
boolean tryLock()
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException
응답성이 중요한 경우, tryLock()을 이용해서 지정된 시간동안 lock을 얻지 못하면 다시 작업을 시도할 것인지 포기할 것인지를 사용자가 결정할 수 있게 하는 것
이 메서드는 InterruptedException을 발생시킬 수 있다
-> 지정된 시간동안 lock을 얻으려고 기다리는 동안에 interrupt()에 의해 작업이 취소될 수 있으므로
앞서 언급한 wait()와 notify()의 문제점인 waiting pool애서 쓰레드의 종류를 구별하여 통지하지 못하는 것을 해결해주는 것이 Condition
✔️ Condition은 미리 생성된 ReentrantLock의 객체로부터 newCondition()메서드를 호출해서 생성
private ReentrantLock lock = new ReentrantLock();
private Condition forCook = lock.newCondition();
private Condition forCust = lock.newCondition();
✔️ wait() -> await() & notify() -> signal()
Object | Condition |
---|---|
void wait() | void await() void awaitUninterruptibly() |
void wait(long timeout) | boolean await(long time, TimeUnit unit) long awaitNanos(long nanosTimeout) boolean awaitUntil(Date deadline) |
void notify() | void signal() |
void notifyAll() | void signalAll() |
✔️ 위 메서드를 어떻게 활용하는지 보자
import java.util.ArrayList;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;
public class ThreadWaitEx1 {
public static void main(String[] args) throws Exception {
Table table = new Table();//여러 쓰레드가 공유하는 객체
//이제 딱 보면 안다. 이렇게 되어있는 것 Runnable 인터페이스를 구현하여 쓰레드를 생성하고 구현한 것!
new Thread(new Cook(table),"COOK1").start();
new Thread(new Customer(table,"donut"),"CUST1").start();
new Thread(new Customer(table,"burger"),"CUST2").start();
//자 COOK1,CUST1,CUST2 쓰레드 3개가 구현되었음.
//3개의 stack이 생겼다.
Thread.sleep(5000);
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();
table.remove(food); //Customer은 음식을 소비한다.
System.out.println(name+ " ate a "+food);
}
}
}
class Cook implements Runnable {
private Table table;
Cook(Table table) {this.table=table;}
public void run() {
//Table의 dishNum String array에 있는 요소를 랜덤으로 뽑아서 음식으로 추가
while(true) {
int idx = (int)(Math.random()*table.dishNum());
table.add(table.dishNames[idx]); //Cook은 음식을 만든다.
try { Thread.sleep(100);} catch(InterruptedException e) {}
}
}
}
class Table{
String[] dishNames = {"donut","donut","burger"};
final int MAX_FOOD =6;
private ArrayList<String> dishes = new ArrayList<>();
private ReentrantLock lock = new ReentrantLock(); //① 생성자
private Condition forCust = lock.newCondition(); //② 조건
private Condition forCook = lock.newCondition();
public void add(String dish) { //Cook이 음식을 추가하기 위한 메서드
lock.lock(); //synchronized 대신 locks 클래스를 이용한 동기화
try { //try-finally를 통해서 unlock을 무조건 하게끔 만들기 위해 추가
while(dishes.size() >=MAX_FOOD) { //table에 음식의 수가 6과 같거나 큰 경우 계속 돌아~ Customer이 음식을 소비할 때까지
String name = Thread.currentThread().getName();
System.out.println(name+"is waiting. ");
try {
forCook.await();//wait(); //Cook 열쇠를 넘기고 waiting pool로 가기
Thread.sleep(500);
} catch(InterruptedException e) {}
}//while 끝.
// 이 시점은 이제 음식이 소비되어서 table에 음식이 6보다 적은 상황
dishes.add(dish);
forCust.signal();//notify();
//Customer 깨우기하지만 요리사나 손님 중 누구한테 갈지는 모르다가 이제는 Customer만 깨움.
//만들었으니 소비하도록
System.out.println("Dishes:"+dishes.toString());
} finally {
lock.unlock(); //동기화 풀어주기 꼭 해야한다.
}
}
public void remove(String dishName) { //Customer의 음식 소비룰 위한 메서드
lock.lock(); //synchronized (this) {
String name = Thread.currentThread().getName();
try {
while(dishes.size()==0) { //table에 음식이 없는 경우 계속 돈다.
System.out.println("size 0 : "+name+" is waiting");//기다리는 CUST Thread이름
try {
forCust.await();//wait(); //손님이 lock을 넘겨주고 waiting pool로
Thread.sleep(500);
} catch(InterruptedException e) {}
}
// 이 시점은 table에 음식이 추가된 시점
while(true) { //일치하는 음식이 있을 때까지 계속 돈다.
for(int i=0; i<dishes.size();i++) {
if(dishName.equals(dishes.get(i))) {
dishes.remove(i);
forCook.signal();//notify();//음식 소비했으니 음식을 만드는 Cook 쓰레드를 깨우자
return;
}
}
//이 시점은 원하는 음식이 없는 시점, return 되지 못했다.
try {
System.out.println(name+ " is waiting");
forCust.await();//wait(); //원하는 음식이 없는 Cust 기다리기
Thread.sleep(500);
} catch(InterruptedException e) {}
}//while
//}//synchronized
}finally {
lock.unlock();
}
}//remove()
public int dishNum() {return dishNames.length;}
}//class
✔️ 멀티 코어 프로세서
코어마다 별도의 캐시를 가지고 있다.
코어는 메모에서 읽어온 값을 캐시에 저장하고 캐시에서 값을 읽어서 작업
다시 같은 값을 읽어올 때는 캐시에 있는지 확인하고 없을 때만 메모에서 읽어온다.
도중에 메모리에 저장된 변수 ≠ 캐시에 저장된 값
캐시에 저장된 값이 갱시되지 않았기 때문에
그렇지만 쓰레드는 멈추지 않고 계속 실행된다.
따라서 캐시가 아니 바로 메모리에서 값을 읽어오는 것을 해결해주는 "volatile"
synchronized 블럭의 효과 = volatile
쓰레드가 synchronized 블럭으로 들어갈 때와 나갈 때, 캐시와 메모리 간의 동기화가 이루어지기 때문에 값의 불일치가 해소
JVM 데이터를 4byte단위로 처리
따라서 int(4byte)와 int보다 작은 타입들은 한 번에 읽거나 쓰는 것이 가능하다
= 다른 쓰레드가 끼어들 틈이 없다.
= 단 하나의 명령어로 읽거나 쓰기가 가능
= 하나의 명령어란 더 이상 나눌 수 없는 최소의 작업 단위
그러나 크기가 8byte인 long과 double타입의 변수는 하나의 명령어로 값을 쓰거나 읽을 수 없다.
= 다른 쓰레드가 변수를 읽는 과정에 끼어들 수 있음.
= 단 하나의 작업이 아닌 두개 이상의 작업으로 쪼개지기 때문이다.
👀 해결책 -> 원자화
① synchronized블럭으로 감싼다.
② 변수를 선언할 때 volatile를 붙인다.
여기서 volatile은 해당 변수에 대한 읽거나 쓰기가 원자화되도록 한다.
원자화란 작업을 더 이상 나눌 수 없게 한다는 것
synchronized도 원자화의 일종
다른 쓰레드가 접근하지 못하도록 하기 때문에
하지만 volatile이 동기화를 한다는 것은 아니다.
JDK1.7부터 fork&join 프레임웍이 추가
하나의 작업을 작은 단위로 쪼개서 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어 준다.
✔️ 먼저 수행할 작업에 따라 RecursiveAction과 RecursiveTask, 두 클래스 중에서 하나를 상속받아 구현
RecursiveAction 반환값이 없는 작업을 구현할 때 사용
RecursiveTask 반환값이 있는 작업을 구현할 때 사용
✔️ 이 두 클래스를 상속받으면 compute() 추상 메서드를 구현해야한다.
✔️ 쓰레드를 시작할 때 run()이 아니라 start()를 호출하는 것처럼
compute()구현 -> 쓰레드풀과 수행할 적업을 생성 -> invoke()로 작업 시작
ForkJoinPool pool = new ForkJoinPool ();
SunTask task = new SumTask(from,to);
Long result = pool.invoke(task);
✔️ 위 코드에서 등장하는 ForkJoinPool은 fork&join프레임웍에서 제공하는 쓰레드 풀
쓰레드 풀이란,
지정된 수의 쓰레드를 생성해서 미리 만들어 놓고 반복해서 재사용
쓰레드를 반복해서 생성하지 않아도 된다는 장점
너무 많은 쓰레드가 생성되어 성능이 저하되는 것을 막아준다는 장점
톰캣은 최대 200개 기본 설정
쓰레드가 수행해야하는 작업이 담긴 큐를 제공
각 쓰레드는 자신의 작업 큐에 담긴 작업을 순서대로 처리
"수행할 작업 + 작업을 어떻게 나눌 것인가"
위에 코드를 보자
실제 수행할 작업은 sum + 그 아래는 작업 범위를 반으로 나누는 과정
으로 짜여진 compute()메서드를 볼 수 있다.
정리를 하면
더하는 범위가 5보다 작거나 같은 경우에는 실제 작업을 수행하지만
5보다 큰 경우에는 범위를 반으로 나눠서 다시 compute()를 수행하는
재귀호출의 코드인 것이다.
위 예시는
앞서 있었던 compute()의 sum() 사이즈를 2이하로 줄인것이다.
fork()의 작업은 비동기적이기 때문에 큐에 저렇게 작업이 쌓일 수 있으며
이 추가된 작업 역시 compute()메서드에 의해서 사이즈 2이하가 될 때까지 나눠지고 더해진다.
자신의 작업 큐가 비어있는 쓰레드는 다른 쓰레드의 작업 큐에서 작업을 가져와서 수행한다.
이것을 우리는 작업 훔쳐오기(work stealing)라고 하며, 이 과정은 모두 쓰레드풀에 의해 자동적으로 이루어진다.
작업 큐가 비어있는 쓰레드가 다른 쓰레드의 작업을 수행하는 것을 표현,
이를 통해서 여러 쓰레드가 골고루 작업을 나누어 처리한다.
-> 작업의 크기를 작게 설정해야 각 쓰레드가 골고루 작업을 나눠가질 수 있다.
forK()
✔️ 비동기 메서드
✔️ 작업을 쓰레드의 작업 큐에 넣고 작업 큐에 들어가 작업은 더 이상 나눌 수 없으 때까지 나뉜다.
✔️ 즉, compute()로 나누고 fork()로 작업 큐에 넣는 작업이 계속 반복
✔️ 나눠진 작업은 각 쓰레드에 골고루 나눠서 처리
join()
✔️ 동기 메서드
✔️ fork() 의 작업의 결과가 끝날 때까지 기다렸다가 수행이 끝나면 그 결과를 반환
비동기 VS. 동기
비동기 메서드는 일반적인 메서드와 달리 메서드를 호출할 뿐 그 결과를 기다리지 않음
따라서 위 코드에서 fork()메서드 호출 후 결과를 기다리지 않고 바로 return으로 넘어감-> 이로 인해 큐에 작업들이 쌓이는 것이 가능함.
return문에서 compute()가 재귀호출될 때 join()은 호출되지 않음
그러다가 fork()로 인해서 더 이상 작업을 나눌 수 없을 때 (작업 큐가 비어있는 쓰레드가 더 이상 작업을 훔쳐올 수 없을 때) compute()의 재귀호출이 끝나고 join()의 결과를 기다렸다가 더해서 결과를 반환
즉, compute()모두 종료될 때 최종결과를 얻는다.
import java.util.concurrent.*;
class ForkJoinEx1 {
static final ForkJoinPool pool = new ForkJoinPool(); //쓰레드 풀 생성
public static void main(String[] args) {
long from =1L,to=100_000_000L;
SumTask task = new SumTask(from, to);
long start = System.currentTimeMillis(); //시작 시간
Long result = pool.invoke(task); //쓰레드 풀과 수행할 작업을 invoke()를 통해 compute()를 수행
System.out.println("Elapsed time(4 Core):"+(System.currentTimeMillis()-start));
System.out.printf("sum of %d ~ %d=%d%n",from,to,result);
System.out.println();
result = 0L;
start = System.currentTimeMillis();
for(long i =from;i<=to;i++)
result+=i;
System.out.println("Elapsed time(1 Core) :"+(System.currentTimeMillis()-start));
System.out.printf("sum of %d ~ %d=%d%n",from,to,result);
}
}
class SumTask extends RecursiveTask<Long> {
long from, to ;
SumTask(long from , long to){
this.from=from;
this.to = to;
}
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();
}
long sum() {
long tmp = 0L;
for(long i=from;i<=to;i++)
tmp+=i;
return tmp;
}
}
👆 for문을 이용하는 join()과 fork()를 이용하는 것보다 시간이 덜 걸린다.
그 이유는 작업을 나누고 다시 합치는 과정에서 시간이 걸리기 때문에
따라서 멀티쓰레드가 항상 처리시간이 빠른 것은 아니므로
꼭 두개를 비교해보고 효과적인 것을 사용하자.
👆 처음에 돌렸을 때 Error가 발생했다.
그 이유는 %를 하나 더 썼기 때문이다.
"알 수 없는 변환이 주어지면" 이 부분
java API를 읽는 습관이 필요하다.
네이버나 구글에 검색해서 찾는 것보다
문서를 정독해서 이해하는 습관을 기르자!!