Java - Thread와 상태제어
study[Java] Thread
1. Thread란?
쓰레드란 여러작업을 동시에 수행할 수 있게 하는 것을 의미한다.
자바로 만들어진 프로그램은 JVM(Java Virtual Machine)에 의해 실행되는데, JVM위에서 여러가지 프로그램을 실행하는것이다.
즉 OS입장에서 본다면 JVM이라는 프로세스 하나가 실행된다고 느껴질 수 있지만, JVM에서 쓰레드를 통해 작업을 실행하면, 결국 여러개의 작업을 한번에 실행할 수 있게 되는것이다.
2. Thread사용
Java에서 Thread를 만드는 방법은 두가지로 나뉜다.
1. Thread클래스를 상속받는 방법
public class threadTest extends Thread{
String str;
public threadTest(String str){
this.str = str;
}
public void run(){
for(int i=0;i<10;i++){
System.out.println(str);
try {
Thread.sleep((int)(Math.random()*1000));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
먼저 위 그림과 같이 threadTest라는 클래스를 만들어 주었다. 여기에서는 Thread를 상속받고 있다.
run이라는 함수는 직접 생성한것이 아니라 Thread클래스를 상속받기 때문에 Thread내부에 존재하는 함수이다.
Main을 보면
public class Main {
public static void main(String args[]){
threadTest t1 = new threadTest("Thread1");
threadTest t2 = new threadTest("Thread2");
t1.start();
t2.start();
System.out.println("end?");
}
}
threadTest라는 클래스에서 t1, t2라는 객체를 만들어 해당 객체를 .start()라는 메서드를 사용해 실행하는것을 볼 수 있다.
이 때 .start라는 메서드는 위에서 만들었던 run함수를 실행시켜주는 메서드이다.
이렇게 구성하고 실행시켜보면
end?
Thread1
Thread2
Thread2
Thread1
Thread2
Thread2
Thread1
Thread1
Thread2
Thread2
Thread1
Thread2
Thread1
Thread2
Thread1
Thread2
Thread1
Thread2
Thread1
Thread1
위와 같이 나오는 것을 볼 수 있는데, 우리는 t1객체의 run함수와 t2객체의 run함수가 동시에 실행되고 있는것을 알 수 있다.
또한 추가적으로 end가 제일 먼저 실행되는것을 알 수 있는데, 이것을보고 우리는 Thread객체가 준비되고 실행되는데 까지 시간이 조금 소요된다는 것을 알 수 있다.(바로 실행된다면 Thread1 -> end? 순이다.)
이렇게 코드를 줄단위로 찍어내는 예전의 방식과는 다르게 Thread라는것을 사용해 다중코드를 한번에 처리할 수 있다는 것을 알 수 있다.
2. Runnable interface를 구현하는 방법
public class threadTest implements Runnable {
String str;
public MyThread2(String str){
this.str = str;
}
public void run(){
for(int i = 0; i < 10; i ++){
System.out.print(str);
try {
Thread.sleep((int)(Math.random() * 1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Main {
public static void main(String[] args) {
threadTest2 r1 = new threadTest2("*");
threadTest2 r2 = new threadTest2("-");
Thread t1 = new Thread(r1);
Thread t2 = new Thread(r2);
t1.start();
t2.start();
System.out.print("!!!!!");
}
}
코드를 위와같이 적어 보았다.
threadTest2클래스는 Thread클래스를 상속받아 사용하는것이 아닌, implements Runnable 을 적어 Runaable인터페이스를 구현해 주었다. 이후 Thread를 상속받는 방식처럼 객체를 선언하고, run메서드를 적어주었는데 이때의 run은 Thread클래스의 run과는 다른 Runnable 클래스의 run이다.
이후 Main에서는 위와같은 방식으로 객체를 생성하고, .start()를 통해 실행하려는데, .start()메서드는 Thread에 의해 상속받은 객체에 대해서만 사용가능하다. 우리는 Thread를 상속받은게 아니라 Runnable인터페이스를 구현해주었고, 그래서 객체들은 Thread가 아닌것이다. 그래서 우리는 다시 Thread를 객체로 재정의 해주었다. 이후 .start() 메서드를 사용해 실행해주었다.
결과는 위와 같다.
그럼 우리가 이렇게 Runnable을 implements한 이유는 무엇일까?
Java에서는 우선 다중 상속이 불가능 하다. 즉, 하나의 클래스에서 Thread클래스를 상속받으면 다른 클래스를 상속받을 수 없다.
Runnable 클래스로 가보자,
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
끝이다 이게, 여기서 run()메서드는 Thread의 run()메서드 이며 Runnable인터페이스 구현을 통해 이것을 사용할 수 있게 해준다.
하지만 Runnable을 상속받지 않고 Thread를 상속받으면 Thread 클래스에 구현된 코드들에 의해 더 많은 시간과 메모리가 필요로 될것이다.
물론 우리가 Thread의 확장이 필요하다면 Thread를 사용하는것이 맞을것이다.
3. Thread와 공유객체
공유객체 라는 말에서 느껴지듯이 하나의 객체를 여러개의 Thread가 사용한다는 것을 의미한다.
우선 우리는 Test클래스를 만들어서 3가지 종류의 작업을 할 필요가 있다고 생각하자.
public class Test {
public static void test1() {
for (int i = 0; i < 10; i++) {
System.out.println("1번 작업");
try {
Thread.sleep((int) (Math.random() * 1000));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
public static void test2() {
for (int i = 0; i < 10; i++) {
System.out.println("2번 작업");
try {
Thread.sleep((int) (Math.random() * 1000));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
public static void test3() {
for (int i = 0; i < 10; i++) {
System.out.println("3번 작업");
try {
Thread.sleep((int) (Math.random() * 1000));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
작업은 총 10번 진행되어야 하며 위와같이 반복문으로 해주었다. 작업소요시간까지는 0~1초가 걸린다.
우리는 이러한 작업을 진행하기 위한 Machine이 필요한데
Machine 객체또한 만들어 주었다.
public class Machine extends Thread{
int input;
Test test;
public Machine(int input, Test test){
this.input = input;
this.test = test;
}
public void run(){
switch(input){
case 1:
Test.test1();
break;
case 2:
Test.test2();
break;
case 3:
Test.test3();
break;
}
}
}
Machine 의 경우 3가지의 작업을 다 할 수 있으며, input(case)에 따라 Test클래스의 n번째 작업을 진행하게 된다.
마지막으로 이러한 작업을 기계에 진행할 사람이 필요하므로 Main에서 사람을 고용해 작업을 진행해보았다.
public class Main {
public static void main(String args[]){
Test test = new Test();
Machine cho = new Machine(1, test);
Machine dong = new Machine(2, test);
Machine young = new Machine(3, test);
cho.start();
dong.start();
young.start();
}
}
우리는 Machine객체에 할 일이 test임을 알려주기 위해 Test객체를 생성해주었고
cho, dong, young 이라는 이름을 가진 사람들이 각각 Machine에서 1번, 2번, 3번일을 할 것이다.
2번 작업/3번 작업/1번 작업/2번 작업/1번 작업/1번 작업/2번 작업/3번 작업/3번 작업/1번 작업/1번 작업/2번 작업/3번 작업/2번 작업/3번 작업/1번 작업/2번 작업/1번 작업/3번 작업/2번 작업/1번 작업/3번 작업/1번 작업/1번 작업/3번 작업/2번 작업/2번 작업/2번 작업/3번 작업/ 3번 작업
결과가 이렇게 나오는것에 대해서는 다들 예상하고 있을것이다.
이때의 공유 객체는 Machine일까?
아니다. Test이다.
우리가 Machine에서 작업을 하는 방법이 적혀있는 한장의 설계도를 Test라고 하겠다.
Machine에서 작업을 하기 위해 각각 설계도를 보아야 하는데 이때 Thread로 정의된 cho, dong, young의 사람들이 설계도를 공유한다는 정도로 생각하면 될 것 같다.
4. 동기화 메소드
한사람이 일을 다 마치고, 나머지 사람들이 일을 할 수는 없을까?
이때는 메소드 앞에 synchronized를 붙힌다.
여러개의 Thread들이 공유객체의 메소드를 사용할 때 메소드에 synchronized가 붙어 있을 경우 먼저 호출한 메소드가 객체의 사용권을 얻는다.
이때의 사용권을 Monitoring Lock(모니터링 락) 이라고 한다.
Synchronized의 사용
public class Test {
public synchronized static void test1() {
for (int i = 0; i < 10; i++) {
System.out.println("1번 작업");
try {
Thread.sleep((int) (Math.random() * 1000));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
public synchronized static void test2() {
...
}
public synchronized static void test3() {
...
}
}
이렇게 적어줄 수 있다.
또한 우리는 Synchronized를 블록에 붙혀 아래와 같이 나타낼 수 있다.
synchronized(this){
System.out.println("1번 작업");
}
이렇게 된 경우 먼저 호출된 Thread에 대해 cho -> dong -> young 순서대로 모니터링 락을 얻는다.
cho.start();
dong.start();
young.start();
만약 synchronized를 붙인 메서드와 synchronized를 붙히지 않은 메서드가 존재할 때
synchronized 메서드는 다른 쓰레드들이 synchronized메소드를 실행하면서 모니터링 락을 획득했다 하더라도, 그것과 상관없이 실행된다.
Time(flow) | Task1(Sync) | Task2(Sync) | Task3(not Sync) |
---|---|---|---|
1 | running | running | |
2 | running | running | |
3 | running | running | |
4 | running | running |
상대적인 흐름에서 보면 이런식이다.
Thread의 장단점?
Thread를 사용하는 이유는 아래와 같다.
- CPU 사용률 향상
- 자원을 보다 효율적으로 사용할 수 있다.
우리가 특정 프로그램을 사용할 때(카카오톡) 사진을 보냄과 동시에 상대에게 채팅을 보낼수 있다.
- 작업이 분리되어 코드가 간결해진다.
단점도 존재한다.
일단, 교착상태와 동기화문제가 발생할 수 있다.
-둘 이상의 작업이 서로 상대방 작업이 끝나기 만을 기다리고 있기 때문에 결과적으로 아무 작업도 완료되지 못하는 상태가 발생할 수 있다.
-메모리 접근에 대한 동기화 문제가 발생할 수 있다.
(메모리 내 A라는 값이 아직 스레드1에서 update되지 않았는데 스레드2에서 가져다 쓰는 문제 등)
[Java] Thread와 상태제어
1. Thread의 상태
Thread는 프로그램 실행의 가장 작은 단위이다. 우리가 이전 글에서는 MultiThread를 구현하는것을 보았는데,
그냥 저냥 프로그램을 실행시키면 그냥 main thread에서 실행하게 된다.
이런 Thread도 항상 실행 될 수가 없는것이. Thread도 한개의 프로세스이기 때문에, JVM에서 동시에 처리하는것이 아닌 시간을 잘게 쪼갠 후에 여러개의 쓰레드를 순서를 두고 실행하는것을 알 수 있다.
우리가 CPU내 프로세스의 상태를 나타낼 때
new, ready, running, blocked, suspended등으로 나타낼 수 있는데
JVM의 Thread역시 이렇게 현재 상태에 따라 여러 단계로 나뉠 수 있다.
현재 실행되는 Thread는
NEW: 쓰레드 객체가 생성되었으나, 아직 start()메서드가 호출되지 않은 상태
Runnable: 실행대기 상태
Running: 실행중 상태
Blocked: 봉쇄 상태
1. WAITING: 다른 쓰레드가 통지할 때 까지 기다리는 상태
2. TIMED_WAITING: Timesleep 상태
3. BLOCKED: 락이 풀릴때까지 기다리는 상태
Dead: 쓰레드의 run메서드가 종료된 상태
로 나눌 수 있다.
2. join() 메서드
쓰레드가 멈출 때 까지 기다리게 한다.
코드를 우선 보자
public class joinTest extends Thread{
public void run(){
for(int i = 0; i < 5; i++){
System.out.println("joinTest : "+ i);
try {
Thread.sleep(500); //0.5초씩 휴식
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class JoinExam {
public static void main(String[] args) {
joinTest thread = new joinTest();
thread.start();
System.out.println("Thread가 종료될때까지 기다립니다.");
try {
thread.join(); //join을 통해 joinTest의 running상태가 지속
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread가 종료되었습니다.");
}
}
main에서 joinTest 클래스 객체를 만들고 실행시키는데,
main역시 위에서 또 하나의 thread라고 설명했다. 즉 thread join이 없을 때, 아래와 같은 flow를 가지게 되는데,
시간(JVM에서 쪼갠) | code | main | joinTest |
---|---|---|---|
… | (thread.start) | running | runable |
1 | runable | running | |
2 | running | runable | |
3 | runable | running |
.join()이라는 메서드를 사용한 후에는 joinTest의 running이 dead상태로 가기 전 까지 joinTest(thread객체)를 실행한다.
시간(JVM에서 쪼갠) | code | main | joinTest |
---|---|---|---|
1 | thread.start(); | running | runable |
2 | thread.join(); | running | runable |
3 | … | WAITING(blocked) | running |
4 | .. | WAITING(blocked) | running |
5 | … | WAITING(blocked) | running |
3. wait()과 notify() 메서드
wait과 notify는 Synchronized된 블록 안에서 사용해야 한다.
wait 를 만나게 되면 모니터링 락의 권한을 놓고 대기하게 된다.
notify를 만나게 되면, 모니터링 락의 권한을 다시 내려놓는것을 의미한다.
public class ThreadB extends Thread{
int total;
public void run(){
synchronized(this){ //해당 동기화 블록이 모니터링 락을 획득한다.
for(int i=0; i<5 ; i++){
total += i;
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
notify(); //main thread를 깨운다.
}
}
}
public class ThreadA {
public static void main(String[] args){
ThreadB b = new ThreadB();
b.start();
synchronized(b){
try{
System.out.println("b가 완료될때까지 기다립니다.");
b.wait();//wait을 만났다.
}catch(InterruptedException e){
e.printStackTrace();
}
System.out.println("Total: " + b.total);
}
}
}
표로 설명하면,
time/code | main | ThreadB |
---|---|---|
ThreadB b = new ThreadB(); | running | NEW |
b.start(); | running | runnable |
synchronized(b){.. | running | runnable |
b.wait(); | blocked (WAITING) | running |
for();… //덧셈연산, threadB실행 | blocked (WAITING) | running |
notify(); | running | dead |
System.out.println(“Total: “,b.total); | running | dead |
dead | dead | |
wait 과 notify의 사용은 running 상태와 runnable상태로 쓰레드가 나뉘는것이 아니라, blocked 상태와 runnable, running 상태 간의 변화이다. wait()된 스레드는 반드시 notify()나 notifyAll()메소드를 호출하여 블록상태를 해제해 줄 필요가 있다는 점을 기억하자.
[##_Image | kage@cFSBl2/btrR71zgaAi/QHRLFkWas705hcLcCV79DK/img.png | CDM | 1.3 | {“originWidth”:1690,”originHeight”:1526,”style”:”alignCenter”,”caption”:”출처- 프로그래머스 자바 중급”,”filename”:”스크린샷 2022-11-25 오후 2.20.38.png”}_##] |
4. 데몬 쓰레드
데몬(Daemon)이란 보통 리눅스와 같은 유닉스계열의 운영체제에서 백그라운드로 동작하는 프로그램을 말한다.
쓰레드에 데몬을 설정하여 데몬 쓰레드로 바꾸어 주면 된다.
public class DaemonThread implements Runnable { //Runnable로 구현
public void run() {
while (true) {
System.out.println("데몬 쓰레드가 실행중입니다.");
try {
Thread.sleep(500); //0.5초씩 쉬면서 무한으로 데몬 쓰레드를 실행시킴
} catch (InterruptedException e) {
e.printStackTrace();
break; //Exception발생시 while을 탈출하게 함
}
}
}
public static void main(String[] args) {
Thread th = new Thread(new DaemonThread());
th.setDaemon(true); //th를 demon thread로 설정한다.
th.start(); //th실행
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("메인 쓰레드가 종료됩니다. "); //이때 Demonthread도 함께 종료됨
}
}
데몬쓰레드는 일반쓰레드(main)이 종료되면 강제적으로 종료되는 특징을 가지고 있다.
데몬쓰레드의 예로는 가비지컬랙션, 자동저장, 화면보호기 등이 있다.
실행 후 대기하고 있다가 어떤 조건이 만족되면 작업을 진행하고 작업이 종료되면 다시 대기하고 있는다.