백기선님이 과거에 진행했던 Java 스터디 10주차 스터디 입니다.
Thread 클래스와 Runnable 인터페이스
자바에서 쓰레드를 구현하는 방법은 2가지가 있다.
Thread클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법이다.
일반적으로 Runnalbe 인터페이스를 구현해 쓰레드를 구현한다.
class MyThread extends Thread{
public void run(){
//Thread클래스의 run메서드를 오버라이딩해 코드를 작성
}
}
class MyThread implements Runnable{
public void run(){
//Runnable인터페이스의 run메서드를 오버라이딩해 코드를 작성
}
}
쓰레드를 구현한다는 것은, 위의 두 방법 중 어떤 것을 선택하든지,
그저 쓰레드를 통해 작업하고자 하는 내용으로 run()의 몸통{}을 채우는 것이다.
Thread클래스를 상속받은 경우와 Runnable 인터페이스를 구현한 경우의 인스턴스 생성 방법이 다르다.
class ThreadEx1 extends Thread{
public void run(){
//쓰레드에서 실행할 코드 작성
}
}
class ThreadEx2 implements Runnable{
public void run(){
//쓰레드에서 실행할 코드 작성
}
}
ThreadEx1 t1 = new ThreadEx1(); // Thread 자손 클래스의 인스턴스를 생성
Runnable r = new ThreadEx2(); //Runnable를 구현한 클래스의 인스턴스를 생성
Thread t2 = new Thread(r); //생성자 Thread(Runnable target)
Thread t2 = new Thread(new ThreadEx2()); //생성자 Tread(Runnable target)
Runnable 인터페이스를 구현한 경우, Runnable인터페이스를 구현한 클래스의 인스턴스를 생성한 다음,
이 인스턴스를 Thread클래스의 생성자 매개변수로 제공해야 한다.
start()와 run()
쓰레드를 생성했다고 해서 자동으로 실행되는 것은 아니다. start()를 호출해야만 쓰레드가 실행된다.
t1.start();
t2.start();
사실 start()가 호출되었다고 해서 바로 실행되는 것이 아니라, 일단 실행대기 상태에 있다가 자신의 차례가 되어야 실행된다.
물론 실행대기중인 쓰레드가 하나도 없으면 곧바로 실행된다.
추가적으로 한 번 실행이 종료된 쓰레드는 다시는 실행할 수 없다. 즉, 하나의 쓰레드에 대해 start()가 한 번만 호출될 수 있다는 것이다.
이제 run메서드와 start메서드에 대해 더 알아보자.
main메서드에서 run()을 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라 단순히 클래스에 선언된 메서드를 호출하는 것일 뿐이다.
반면에 start()는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택(스택 영역)을 생성한 다음에 run()을 호출해서, 생성된 호출스택에 run()이 첫 번째로 올라가도록 한다. 모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택(스택 영역)을 필요로 한다.
새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스택이 생성되고 쓰레드가 종료되면 작업에 사용된 호출스택은 소멸된다.
그림이 매우 엉성하지만...아래 이미지를 살펴보자.
- main메서드에서 새로운 쓰레드의 start()를 호출한다.
- start()는 새로운 쓰레드를 생성하고, 쓰레드가 작업하는데 사용될 호출스택을 생성한다.
- 새로 생성된 호출스택에 run()이 호출되어, 쓰레드가 독립된 공간에서 작업을 수행한다.
- 이제는 호출스택이 2개이므로 스케줄러가 정한 순서에 의해서 번갈아 가면서 실행된다.
호출스택에서는 가장 위에 있는 메서드가 현재 실행중인 메서드이고 나머지 메서드들은 대기상태다.
그러나 위의 그림에서와 같이 쓰레드가 둘 이상일 때는 호출스택의 최상위에 있는 메서드일지라도 대기상태에 있을 수 있다.
스케줄러는 실행대기중인 쓰레드들의 우선순위를 고려하여 실행순서와 실행시간을 결정하고, 각 쓰레드들은 작성된 스케줄에 따라 자신의 순서가 되면 지정된 시간동안 작업을 수행한다.
스레드의 상태
- NEW : 쓰레드가 생성되고 아직 start()가 호출되지 않은 상태
- RUNNABLE : 실행 중 또는 실행 가능한상태
- BLOCKED : 동기화블럭에 의해서 일시정지된 상태(lock이 풀릴 때까지 기다리는 상태)
- WAITING, TIMED_WAITINT : 쓰레드의 작업이 종료되지는 않았지만 실행가능하지 않은 (unrunnable) 일시정지 상태. TIMED_WAITING은 일시정지시간이 지정된 경우를 의미한다.
- TERMINATED: 쓰레드의 작업이 종료된 상태
다음 그림은 메서드들에 의해서 쓰레드의 상태가 어떻게 변화되는지를 잘 보여준다.
- 쓰레드를 생성하고 start()를 호출하면 바로 실행되는 것이 아니라 실행대기열에 저장되어 자신의 차례가 될 때까지 기다려야 한다. 실행대기열은 큐와 같은 구조로 먼저 실행대기열에 들어온 쓰레드가 먼저 실행된다.
- 실행대기상태에 있다가 자신의 차례가 되면 실행상태가 된다.
- 주어진 실행시간이 다되거나 yield()를 만나면 다시 실행대기상태가 되고 다음 차례의 쓰레드가 실행상태가 된다.
- 실행중에 suspend(), sleep(), wait(), join(), I/O block에 의해 일시정지상태가 될 수 있다. I/O block은 입출력작업에서 발생하는 지연상태를 말한다. 사용자의 입력을 기다리는 경우를 예로 들 수 있는데, 이런 경우 일시정지 상태에 있다가 사용자가 입력을 마치면 다시 실행대기 상태가 된다.
- 지정된 일시정지시간이 다되거나(time-out), notify(), resume(), interrupt()가 호출되면 일시정지상태를 벗어나 다시 실행대기열에 저장되어 자신의 차례를 기다리게 된다.
- 실행을 마치거나 stop()이 호출되면 쓰레드는 모두 소멸된다.
설명을 위해 1부터 6까지 번호를 붙였지만, 번호의 순서대로 쓰레드가 수행되는 것은 아니라고 한다.
이제 그러면 위에서 나온 쓰레드의 스케줄링과 같은 메서드에 대해 살펴보겠다.
sleep(long millis) - 일정시간동안 쓰레드를 멈추게 한다.
sleep()은 지정된 시간동안 쓰레드를 멈추게 한다.
static void sleep(long millis)
static void sleep(long millis, int nanos)
밀리세컨드와 나노세컨드의 시간단위로 세밀하게 값을 지정하지만, 어느 정도의 오차가 발생할 수 있다.
sleep()에 의해 일시정지 상태가 된 쓰레드는 지정된 시간이 다 되거나 interrupt()가 호출되면 (InterruptedException 발생),
잠에서 깨어나 실행대기 상태가 된다.
그래서 sleep를 호출할 때는 항상 try - catch문으로 예외를 처리해줘야 한다.
정말 중요한 부분이 있다.
sleep()은 항상 현재 실행 중인 쓰레드에 대해 작동하기 때문에 참조 변수를 통해 호출하더라도 실제로 영향을 받는 것은 현재 실행중인 쓰레드다. 따라서 예를 들어 Thread 참조변수 t1이 존재한다고 가정하자. 그러면 t1.sleep(2000) 이렇게 참조변수를 호출하기 보다는 Thread.sleep(2000)과 같이 해야 한다.
interrupt()와 interrupted() - 쓰레드의 작업을 취소한다.
진행 중인 쓰레드의 작업이 끝나기 전에 취소시켜야할 때가 있다.
예를 들어 큰 파일을 다운로드받을 때 시간이 너무 오래 걸리면 중간에 다운로드를 포기하고 취소할 수 있어야 한다.
interrupt()는 쓰레드에게 작업을 멈추라고 요청한다. 단지 멈추라고 요청만 하는 것일 뿐 쓰레드를 강제로 종료시키지는 못한다.
interrupt()는 그저 쓰레드의 interrupted상태(인스턴스 변수)를 바꾸는 것일 뿐이다.
그리고 interrupted()는 쓰레드에 대해 interrupt()가 호출되었는지 알려준다. interrupt()가 호출되지 않았다면 false를 , interrupt()가 호출되었다면 true를 반환하다.
Thread th = new Thread();
th.start();
// ..코드들 생략
th.interrupt(); //쓰레드 th에 interrupt()를 호출한다.
class MyThread extends Thread{
public void run(){
while(!interrupted()){ //interrupted()의 결과가 false인 동안 반복
//..코드를 생략
}
}
}
interrupt()가 호출되면, interrupted()의 결과가 false에서 true로 바뀌어 while문을 벗어나게 된다.
쓰레드가 sleep(), wait(), join()에 의해 일시정지 상태에 있을 때, 해당 쓰레드에 대해 interrupt()를 호출하면,
sleep(), wait(), join()에서 Interrupted Exception이 발생하고 쓰레드는 실행대기 상태로 바뀐다.
즉, 멈춰있던 쓰레드를 깨워서 실행가능한 상태로 만드는 것이다.
suspend(), resume(), stop()
suspend()는 sleep()처럼 쓰레드를 멈추게 한다.suspend()에 의해 정지된 쓰레드는 resume()을 호출해야 다시 실행대기 상태가 된다.
stop()은 호출되는 즉시 쓰레드가 종료된다
suspend(), sresume(), stop()는 쓰레드의 실행을 제어하는 가장 손쉬운 방법이지만, suspend()와 stop()이 데드락을 일으키기 쉽게 작성되어있으므로 사용이 권장되지 않는다. 즉 deprecated 되었다.
yield() - 다른 쓰레드에게 양보한다.
yield()는 쓰레드 자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보한다.
예를 들어 스케쥴러에 의해 1초의 실행시간을 할당받은 쓰레드가 0.5초의 시간동안 작업한 상태에서 yield()가 호출되면, 나머지 0.5초는 포기하고 다시 실행대기상태가 된다. yield메서드와 interrupt메서드를 적절히 사용하면, 프로그램의 응답성을 높이고 보다 효율적인 실행이 가능하게 할 수 있다.
join() - 다른 쓰레드의 작업을 기다린다.
쓰레드는 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때 join()을 사용한다.
시간을 지정하지 않으면, 해당 쓰레드가 작업을 모두 마칠 때까지 기다리게 된다.
작업 중에 다른 쓰레드의 작업이 먼저 수행되어야할 필요가 있을 때 join()을 사용한다.
join()도 sleep()처럼 interrupt()에 의해 대기상태에서 벗어날 수 있으며, join()이 호출되는 부분을 try - catch문으로 감싸야 한다.
join()과 sleep()은 유사한점이 많은데, 둘의 차이는 join()은 현재 쓰레드가 아닌 특정 쓰레드에 의해 동작하므로 static 메서드가 아니다.
스레드의 우선 순위
쓰레드는 우선순위(priority)라는 속성(멤버변수)을 가지고 있다.
이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다.
쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 서로 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있다.
쓰레드의 우선 순위와 관련된 메서드와 상수는 다음과 같다.
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이며 숫자가 높을수록 우선순위가 높다.
쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속받는다. main메서드를 수행하는 쓰레드는 우선순위가 5이므로,
main메서드내에서 생성하는 쓰레드의 우선순위는 자동적으로 5가 된다.
Main 스레드
main 메서드의 작업을 수행하는 것도 쓰레드이며, 이를 main 쓰레드라고 한다.
main 메서드가 수행을 마쳤다하더라도 다른 쓰레드가 아직 작업을 마치지 않은 상태라면 프로그램이 종료되지 않는다.
동기화
한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 쓰레드의 동기화라고 한다.
자바에서 동기화를 지원하는 방식에 대해 알아보자.
synchronized를 이용한 동기화
먼저 가장 간단한 동기화 방법인 synchronized 키워드를 이용한 동기화에 대해서 알아보자.
이 키워드는 임계 영역을 설정하는데 사용된다.
1. 메서드 전체를 임계 영역으로 지정
public synchronized void calcSum(){ //임계 영역
//...
}
2. 특정한 영역을 임계 영역으로 지정
synchronized (객체의 참조변수){ //임계 영역
//...
}
첫 번째 방법은 메서드 앞에 synchronized를 붙이는 것인데, synchronized를 붙이면 메서드 전체가 임계 영역으로 설정된다.
쓰레드는 synchronized메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가
메서드가 종료되면 lock을 반환한다.
두 번째 방법은 매서드 내의 코드 일부를 블럭{}으로 감싸고 블럭 앞에 synchronized(참조변수)를 붙이는 것인데,
이때 참조변수는 락을 걸고자하는 객체를 참조하는 것이어야 한다.
이 블럭을 synchronized블럭이라고 부르며, 이 블럭의 영역 안으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게 되고, 이 블럭을 벗어나면 lock을 반납한다.
모든 객체는 lock을 하나씩 가지고 있으며, 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있다.
그리고 다른 쓰레드들은 lock을 얻을 때가지 기다리게 된다.
public synchronized void withdraw(int money){
if(balance >= money){
try {Thread.sleep(1000);} catch(Exception e){}
balance -= money;
}
}
public void withdraw(int money){
synchronized(this){
if(balance >= money){
try {Thread.sleep(1000);} catch(Exception e){}
balance -= money;
}
}
}
한 쓰레드에 의해서 먼저 withdraw()가 호출되면,
이 메서드가 종료되어 lock이 반납될때까지 다른 쓰레드는 withdraw()를 호출하더라도 대기상태에 머물게 된다.
메서드 앞에 synchronized를 붙이는 방식과, synchronized블럭을 사용하는 방식을 모두 위에서 소개했다.
보통 synchronized 메소드를 사용하라고 한다.
wait()과 notify()
synchronized로 동기화해서 공유 데이터를 보호하는 것 까지는 좋은데,
특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것도 중요하다.
만일 계좌에 출금할 돈이 부족해서 한 쓰레드가 락을 보유한 채로 돈이 입금될 때까지 오랜 시간을 보낸다면, 다른 쓰레드들은 모두 해당 객체의 락을 기다리느라 다른 작업들도 원할히 진행되지 않을 것이다.
이러한 상황을 개선하기 위해 고안된 것이 바로 wait()과 notify()다. 동기화된 임계영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, 일단 wait()을 호출하여 쓰레드가 락을 반납하고 기다리게 한다.
그러면 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 된다. 나중에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서, 작업을 중단했던 쓰레드가 락을 얻어 작업을 진행할 수 있게 한다.
이는 마치 빵을 사려고 빵집 앞에 줄을 서있는 것과 유사한데,
자신의 차례가 되었는데도 자신이 원하는 빵이 나오지 않았으면,
다음 사람에게 순서를 양보하고 기다리다가 자신이 원하는 빵이 나오면 통보를 받고 빵을 사가는 것이다.
차이가 있다면, 오래 기다린 쓰레드가 락을 얻는다는 보장이 없다는 것이다.
wait()이 호출되면, 실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다린다.
notify()가 호출되면, 해당 객체의 대기실에 있던 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받는다.
notifyAll()은 모든 쓰레드에게 통보를 하지만, 그래도 lock을 얻을 수 있는 것은 하나의 쓰레드일 뿐이고
나머지는 쓰레드는 통보를 받긴 했지만, lock을 얻지 못하면 다시 lock을 기다리는 신세가 된다.
wait()과 notify()는 특정 객체에 대한 것이므로 Object클래스에 정의되어 있다.
- wait(), notify(), notifyAll()
- Object에 정의되어 있다.
- 동기화 블록(synchronized블록)내에서만 사용할 수 있다.
- 보다 효율적인 동기화를 가능하게 한다.
그리고 waiting pool은 객체마다 존재하는 것이므로,
notifyAll()이 호출된다고 해서 모든 객체의 waiting pool에 있는 쓰레드가 깨워지는 것은 아니다.
nofityAll()이 호출된 객체의 waiting pool에 대기 중인 쓰레드만 해당된다는 것을 기억하자.
기아 현상과 경쟁 상태
지독히 운이 나쁘면 어떤 쓰레드는 계속 통지를 받지 못하고 오랫동안 기다리게 되는데, 이것을 기아 현상이라고 한다.
이 현상을 막으려면, notify()는 대신 notifyAll()을 사용해야 한다.
일단 모든 쓰레드에게 통지를 하면, 다른 쓰레드는 다시 waiting pool에 들어가더라도 방금 언급한 어떤 쓰레드는 결국 lock을 얻어서 작업을 진행할 수 있다. notifyAll()로 어떤 쓰레드의 기아현상은 막았지만, 다른 쓰레드까지 통지를 받아서 불필요하게 어떤 쓰레드와 lock을 얻기 위해 경쟁하게 된다. 이처럼 여러 쓰레드가 lock을 얻기 위해 서로 경쟁하는 것을 '경쟁 상태'라고 한다.
이 경쟁상태를 개선하기 위해서는 어떤 쓰레드와 다른 쓰레드를 구별해서 통지하는 것이 필요하다.
Lock과 Condition을 이용한 동기화
lock 클래스의 종류는 다음과 같이 3가지가 있다.
- ReentrantLock: 재진입이 가능한 lock, 가장 일반적인 배타 lock
- ReentrantReadWriteLock: 일기에는 공유적이고, 쓰기에는 배타적인 lock
- StampedLock: ReentrantReadWriteLock에 낙관적인 lock의 기능을 추가했다.
ReentrantLock는 쉽게 이해가 가능하다. 우리가 계속 사용해 온 락이다.
ReentrantReadWriteLock도 이해가 쉽다. 읽기는 내용을 변경하지 않으므로 여러 쓰레드가 공유해도 문제가 되지 않는다.
그럼 StampedLock에서 등장하는 낙관적인 lock이란 무엇일까?
StampedLock은 lock을 걸거나 해지할 때 스탬프(long타입의 정수형)를 사용하며,
읽기와 쓰기를 위한 lock 외에 '낙관적 읽기 lock'이 추가된 것이다.
읽기 lock이 걸려있으면, 쓰기 lock을 얻기 위해서는 읽기 lock이 풀릴 때까지 기다려야하는데
낙관적 읽기 lock는 쓰기 lock에 의해 바로 풀린다. 그래서 낙관적 읽기에 실패하면, 읽기 lock을 얻어서 다시 읽어 와야 한다.
무조건 읽기 lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 거는 것이다.
int getBalance() {
long stamp = lock.tryOptimisticRead(); // 낙관적 읽기 lock을 건다.
int curBalance = this.balance; // 공유 데이터인 balance를 읽어온다.
if(!lock.validate(stamp)) { // 쓰기 lock에 의해 낙관적 읽기 lock이 풀렸는지 확인
stamp = lock.readLock(); // lock이 풀렸으면, 읽기 lock을 얻으려고 기다린다.
try {
curBalance = this.balance; // 공유 데이터를 다시 읽어온다.
} finally {
lock.unlockRead(stamp); // 읽기 lock을 푼다.
}
}
return curBalance; // 낙관적 읽기 lock이 풀리지 않았으면 곧바로 읽어온 값을 반환
}
데드락
- 상호 배제 - 한 프로세스가 사용하는 자원은 다른 프로세스와 공유할 수 없는 배타적인 자원이어야 한다. 배타적인 자원은 임계 구역으로부터 보호되기 때문에 다른 프로세스가 동시에 사용할 수 없다. 따라서 배타적인 자원을 사용하면 교착 상태, 즉 데드락이 발생한다.
- 비선점 - 한 프로세스가 사용 중인 자원은 중간에 다른 프로세스가 빼앗을 수 없는 비선점 자원이어야 한다. 어떤 자원을 빼앗을 수 있다면 시간 간격을 두고 자원을 공유할 수 있다. 하지만 빼앗을 수 없다면 공유할 수도 없으므로 데드락이 발생한다.
- 점유와 대기 - 프로세스가 어떤 자원을 할당받은 상태에서 자원을 기다리는 상태여야 한다. 다른 프로세스의 작업 진행을 방해하는 교착 상태가 발생하려면 다른 프로세스가 필요로 하는 자원을 점유하고 있으면서 또 다른 자원을 기다리는 상태가 되어야 한다.
- 원형 대기 - 점유와 대기를 하는 프로세스 간의 관계가 원을 이루어야 한다. 프로세스가 특정 자원에 대해 점유와 대기를 한다고 모두 교착 상태에 빠지는 것은 아니다. 점유와 대기를 하는 프로세스들이 서로 방해하는 방향이 원을 이루면 프로세스들이 서로 양보하지 않기 때문에 교착상태에 빠진다.
이 중 단 하나라도 충족하지 않으면 데드락(교착 상태)가 발생하지 않는다.
데드락에 대한 자세한 내용은 아래 포스팅에서 확인할 수 있다.https://devdebin.tistory.com/24?category=972158
DeadLock 교착 상태
두 개 이상의 프로세스가 필요한 자원을 기다리면서 무한정 중지된 상태가 교착 상태, DeadLock이다. 제한된 자원의 이용률을 높이고 시스템의 효율성을 높이고자 했을 때 발생하는 부작용이 교착
devdebin.tistory.com
이상으로 포스팅을 마칩니다. 감사합니다!
참고자료
자바의정석(저자:남궁성, 스레드)
댓글