본문 바로가기

Dev.BackEnd/JAVA

[JAVA] 15. Thread ( 스레드 )

쓰레드
실행 중인 프로그램 을 프로세스라 한다.
프로세스 내부에 둘 이상의 쓰레드가 존재할 수 있다.
어떤 프로세스든 간에 쓰레드가 하나 이상 수행된다.

아무런 쓰레드를 생성하지 않아도 JVM을 관리하기 위한 여러 쓰레드가 존재한다.

왜 쓰레드라는 것을 만들었을까.
하나의 작업을 동시에 수행하려고 할 때 여러 개의 프로세스를 띄워서 실행하려면 각각 메모리를 할당해 주어야만 한다. 그에 반해, 쓰레드를 하나 추가하면 더 적은 메모리를 점유하게 된다. 어떤 작업을 할 때 단일 쓰레드로 실행하는 것보다는 다중 쓰레드로 실행하는 것이 더 시간이 적게 걸린다.

자바에서는 쓰레드도 하나의 인스턴스로 정의한다.
쓰레드는 쓰레드만의 main메소드를 지닌다.
단 이름은 main이 아니라 run이다.


쓰레드를 생성하는 방법에는 크게 두가지 방법이 있다.
Thread 클래스를 상속받아 사용하는 것이고(확장한다.)
다른 하나는 Runnable 인터페이스를 사용하는 것이다.(구현한다.)
Runnable은 run( ) 이라는 단 하나의 메소드를 제공한다. 이에 반해 Thread 클래스는 많은 메소드를 포함하고 있다. Thread 클래스를 상속받아 사용할 때는, 상위 클래스(Thread 클래스)로부터 run메소드를 오버라이딩하여 사용한다. Thread 클래스 내부에는 sleep 이라는 메소드가 있다, static 메소드로서 실행흐름을 일시적으로 멈추는 역할을 한다.

Runnable 인터페이스로 구현한 클래스를 쓰레드로 바로 시작할 수는 없다. Thread 클래스의 생성자에 해당 객체를 추가하여 시작해주어야만 한다. 하지만 Thread 클래스를 상속하여 만든 클래스는 start()메소드를 바로 호출할 수 있다. 쓰레드를 시작하는 메소드는 start()이며, 쓰레드가 시작하면 수행되는 메소드는 run()이다.


왜 두 가지를 제공하는 것인가
어떤 클래스가 어떤 다른 클래스를 extends 해야 하는 상황인데 쓰레드로도 구현해야 한다. extends할 부모 클래스도 쓰레드를 상속하고 있지 않은 상황이다. 자바에서 다중 상속은 지원하지 않는다. 그러므로 둘 중 하나는 포기해야 한다. 이럴 경우 Runnable 인터페이스를 구현하여 사용한다. 이런 경우가 아닐 때는, Thread 클래스를 사용하는 것이 편하다.

+데몬쓰레드
해당 쓰레드가 종료되지 않아도 다른 실행중인 일반 쓰레드가 없다면, 멈춰버린다.

Q. start 메소드를 통해서 run메소드를 실행하는 방법으로 작동을 한다고 했는데, run메소드를 직접호출하지 못하나?
못하는 것은 아니나 main 메소드를 직접 호출하지 않는 것과 같다.
start 메소드로부터 run 메소드가 호출이 되어야
쓰레드가 자신만의 메모리 공간을 할당 받고 별도의 실행흐름을 형성할 수 있기 때문이다.


run메소드의 실행이 완료되면 해당 쓰레드는 종료되고 소멸된다.
쓰레드는 main 메소드에 추가적으로 형성되는 실행흐름이다.
run 메소드가 실행된다고 하더라도 main 메소드가 먼저 종료될 수는 있지만 쓰레드 때문에 멈춰서는 것은 아니다.
그리고 main 메소드를 실행하는 쓰레드를 가리켜 별도로 main 쓰레드라 부르기도 한다.

쓰레드란 별도의 실행흐름을 형성하기 위해서
자바 가상머신에 의해 만들어지는 모든 리소스와 각종 정보들을 총칭을 말한다.

둘 이상의 쓰레드가 생성될 수 있기 때문에, 자바 가상머신은 쓰레드의 실행을 스케줄링 해야 한다.
(좀 더 정확히 말하자면 자바 가상머신의 일부로 존재하는 쓰레드 스케줄러가 이 일을 수행한다.)
스케줄링 알고리즘의 기본원칙은
1) 우선순위가 높은 쓰레드의 실행을 우선으로 한다.
2) 동일한 우선순위의 쓰레드가 둘 이상 존재할 때는 CPU할당 시간을 분배해서 실행한다.
자바의 쓰레드에는 우선순위라는 것이 할당된다.
언어 차원에서 쓰레드를 지원하고는 있지만 쓰레드 특성상 운영체제에 매우 의존적이다.
따라서 운영체제에 따른 차이를 최소화할 수 있는 방법은 상수로 정의되어 있는 것을 변경하는 것이 옳다.(?)
낮은 우선순위의 쓰레드도 충분히 실행의 기회를 얻을 수 있다.


라이프 사이클
1) NEW 상태
쓰레드 클래스가 키워드 new를 통해서 인스턴스화 된 상태를 말한다.
이 상태는 아직 JVM에 의해서 관리가 되는 쓰레드의 상태는 아니다.
(하지만 자바에서는 이 상태부터를 쓰레드라 한다)

2) Runnable 상태
인스턴스화 된 쓰레드 인스턴스를 대상으로 start 메소드가 호출되면 Runnable 상태가 된다.
즉 스케줄러에 의해서 선택되어 실핼될 수 있기만을 기다리는 상태를 말한다.
스케줄러에 의해 실행의 대상으로 선택이 되어야 비로소 run메소드가 처음 호출이 된다.

3) Blocked 상태
sleep, join 메소드를 호출하거나, CPU할당이 필요치 않는 입출력 연산을 하게 되면,
CPU를 다른 쓰레드에게 양보하고 자기 자신은 Blocked상태가 된다.
이 상태가 되면 스케줄러의 선택을 받을 수 없다.
입출력 작업이 완료되거나 sleep 메소드가 반환되거나 하는 방식으로 다시 Runnable 상태가 되어야 한다.

4) Dead 상태
run메소드의 실행이 완료되어 빠져나오게 되면 Dead상태가 된다.
한 번 Dead상태가 되면 다시 Runnable 상태로 될 수 없다.
쓰레드 실행을 위해 필요한 모든 것이 소멸되기 때문이다.



쓰레드의 메모리 구성
쓰레드의 가장 큰 역할은 별도의 실행흐름 형성이다. 이 별도의 실행흐름은 메소드의 호출을 통해서 형성된다. 이렇게 main 메소드와는 전혀 다른 실행흐름을 형성하기 위해서는 별도의 스택이 쓰레드에 할당되어야 한다. 모든 쓰레드는 자신의 스택을 할당 받는다. 그러나 힙과 메소드 영역은 모든 쓰레드가 공유한다. 즉 모든 쓰레드가 동일한 힙 영역에 접근이 가능하다는 것을 의미한다. 따라서 A쓰레드가 만든 인스턴스의 참조 값만 알면 B쓰레드도 A쓰레드가 만든 인스턴스에 접근이 가능하다. 그러므로 쓰레드간 통신이 필요할 때에는 힙 영역을 활용한다.


동기화
한 쓰레드가 변수에 접근해서 연산을 완료할 때까지, 다른 쓰레드가 같은 변수에 접근하지 못하도록 막는 것을 말한다. 둘 이상의 쓰레드가 동시에 접근을 해도 문제가 발생하지 않을 때, Thread - safe하다.
어떻게 하는가
synchronized를 사용해서 동기화 메소드를 선언하거나 동기화 블록을 지정해주면 된다. 동기화를 해주면 성능이 떨어질 수 밖에 없다. 쓰레드가 동시에 접근을 하지 못하니. 동기화가 필요하지만 필요한 위치에 제한적으로 사용해서 성능에 영향을 주지 않도록 주의해야 한다.

자바의 모든 인스턴스에는 하나의 열쇠가 존재한다.
이 인스턴스를 실행시키기 위해서는 열쇠가 필요하다.
synchronized를 해주는 것을 열쇠를 준다는 비유를 쓴다.
그리고 다 끝나면 열쇠를 반납한다는 비유를 쓴다.
synchronized는 열쇠를 자동으로 반납해준다는 장점이 있다.

1) 동기화 메소드 선언
1
2
3
4
public synchronized int add(int n1, int n2){
     count++;
     return n1+n2;
};
cs
동기화가 필요한 문장은 count++ 한 문장인데, 메소드 전체에 동기화를 걸어버렸다.
그럼에도 불구하고 메소드 전체의 실행이 완료될 때까지 열쇠가 반납되지 않는다.
비효율적이다.

2) 동기화 블록 구성
1
2
3
4
5
6
public int add(int n1, int n2){
     synchronized(this){      
          count++;
     }
     return n1+n2;
};
cs
동기화를 해줘야 하는 상황이 두 종류라면?
그런 경우에도 모두 동기화를 걸어서 총 4개를 동기화 걸어 순서대로 진행할 것인가?

3) key를 이용하기
첫번째 key는 this라고 한다. 그리고 key1, key2, ...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
 
public int add1(int n1, int n2){
     synchronized(this){      
          count++;
     }
     return n1+n2;
};
 
public int add2(int n1, int n2){
     synchronized(this){      
          count++;
     }
     return n1+n2;
};
 
public int add3(int n1, int n2){
     synchronized(key){      
          count++;
     }
     return n1+n2;
};
 
public int add4(int n1, int n2){
     synchronized(key){      
          count++;
     }
     return n1+n2;
};
cs
=> add1, add2 가 동기화되서 동시에 발생하지 않고 add3, add4가 동기화되서 동시에 발생하지 않는다.
즉 add1과 add3, add4는 상관이 없고, add2와 add3, add4는 상관이 없는 것이다.

동기화란 쓰레드의 접근 순서를 컨트롤한다는 의미이다. 동시에 접근하는 것을 막을 수 있는 것 뿐만 아니라 이를 통해 순서를 제어할 수 있다는 것이 동기화인 것이다. 쓰레드의 실행 순서는 소스코드가 나열된 순서와 다를 수 있다. 그만큼 실행순서를 예측하기 어렵다.
순서를 제어해보자.

wait(), notify(), notifyAll() 메소드!
wait : 잠을 잔다.
notify : 하나의 쓰레드를 깨운다.
notifyAll : 모든 쓰레드를 깨운다.

synchronized 키워드의 대체
ReentrantLock 클래스 제공
lock메소드 unlock 메소드
lock메소드가 호출되는 시점부터 unlock메소드가 호출되는 시점까지
둘 이상의 쓰레드에 의해서 동시에 실행되지 않는 영역이 된다.
따라서 둘 이상의 쓰레드가 동시에 실행하면 안 되는 코드를 try 구문에 넣어두고
unlock 메소드의 호출을 finally 구문에 넣어서 코드의 안정성을 높이자.
await == wait
signal == notify
signalAll == notifyAll

join( ) 메소드
현재 수행 중인 쓰레드가 중지할 때까지 대기한다.

interrupt( ) 메소드
현재 수행 중인 쓰레드를 InterruptedException을 발생시키면서 중단시킨다.
stop()메소드도 존재하지만 안전성의 이유로 deprecated되었다.