
쓰레드는 객체를 생성한 뒤에 쓰레드.start()를 통해 실행대기 상태 Runnable이 된다.
그리고 위 그림처럼 쓰레드는 실행과 대기를 반복하며 run() 메서드를 수행한다.
run()메서드가 종료되면 실행이 멈추게 된다.
이 외에도 일시정지 상태도 될 수 있는데 다음 그림과 같다.

음악을 듣다 멈췄다가 다시 재생하는 것 같이
start()를 통해 실행대기 상태로왔다가 실행하는 중에 제어가 걸려 일시정지가 되었다가 다시 그 제어가 풀려 runnable상태로 오는 이러한 과정을 통해 쓰레드 상태가 변하게 된다.
| 상태 | Enum | 설명 |
|---|---|---|
| 객체생성 | NEW | 쓰레드 객체 생성, 아직 start() 메서드 호출 전의 상태 |
| 실행대기 | RUNNABLE | 실행 상태로 언제든지 갈 수 있는 상태 |
| 일시정지 | WAITING | 다른 쓰레드가 통지(notify) 할 때까지 기다리는 상태 |
| 일시정지 | TIMED_WAITING | 주어진 시간 동안 기다리는 상태 |
| 일시정지 | BLOCKED | 사용하고자 하는 객체의 Lock이 풀릴 때까지 기다리는 상태 |
| 종료 | TERMINATED | 쓰레드의 작업이 종료된 상태 |
위는 쓰레드 상태 정리 표이다.

(원형은 쓰레드의 상태, 다각형은 제어라고 보면 좋다.. + yield())
sleep은 말 그대로 잠드는 것! 지정된 시간동안 멈추게 하는 것이다.
이는 예외처리를 해야하는데
자는 중간에 interrupt가 오면 깨어나야하는 부분에서 충돌이 나서 InterruptedException 발생할 수 있기 때문이다.
또한, 특정 쓰레드를 지목해서 멈추는 것은 불가능하다.
(밑에 예시를 보면 알 수 있듯이 클래스명.메소드() = static메소드이기 때문에 인스턴스화 할 수 없어 특정을 못한다고 보면된다.)
try {
Thread.sleep(2000); // 2초 // ms(밀리초) 단위 사용
} catch (InterruptedException e) {
e.printStackTrace();
}
위에 말한대로 깨우는 제어를 말한다.
(일시정지 상태인 쓰레드를 실행대기 상태로 만든다.)
또한, 인터럽트가 되었는지를 체크하는 boolean변수 interrupted도 존재한다.
isInterrupted()라는 메서드를 통해 인터럽트가 되면 true를 아니면 false를 interrupted에 저장하여 반환한다.
위에서 말한것처럼 sleep 중간에 interrupt가 들어오게되면 예외가 발생하는데 이부분은 interrupted의 상태를 체크하여 예외처리를 할 수 있다.
public class Main {
public static void main(String[] args) {
Runnable task = () -> {
while (!Thread.currentThread().isInterrupted()) {
//isInterrupted를 통해 인터럽트 되었는지 보고 현재 쓰레드가 인터럽트 된 상태가
//아니라면 아래 sleep을 실행하는 조건을 준다.
try {
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName());
} catch (InterruptedException e) {
break;
}
}
System.out.println("task : " + Thread.currentThread().getName());
};
Thread thread = new Thread(task, "Thread");
thread.start();
thread.interrupt();
System.out.println("thread.isInterrupted() = " + thread.isInterrupted());
}
}
sleep()처럼 일시정지로 상태를 바꾸는 메서드이며,
정해진 시간동안 지정한 쓰레드가 작업하는 것을 기다린다.
시간을 정하지않으면 지정한 쓰레드의 작업이 끝날 때까지 기다린다.
join도 sleep처럼 예외처리를 해주어야한다. (interrupt)
public class Main {
public static void main(String[] args) {
Runnable task = () -> {
try {
Thread.sleep(5000); // 5초
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread thread = new Thread(task, "thread");
thread.start();
long start = System.currentTimeMillis();
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// thread 의 소요시간인 5000ms 동안 main 쓰레드가 기다렸기 때문에 5000이상이 출력됩니다.
System.out.println("소요시간 = " + (System.currentTimeMillis() - start));
}
}
양보하다라는 뜻으로 남은 시간을 다음 쓰레드에게 양보하고 쓰레드 자신은 실행대기 상태가 된다.
public class Main {
public static void main(String[] args) {
Runnable task = () -> {
try {
for (int i = 0; i < 10; i++) {
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName());
}
} catch (InterruptedException e) {
Thread.yield();
}
};
Thread thread1 = new Thread(task, "thread1");
Thread thread2 = new Thread(task, "thread2");
thread1.start();
thread2.start();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
thread1.interrupt(); //이 부분이 위에 catch문으로 가니까 thread1은 대기상태
//thread2가 1의 리소스를 받고 실행한다~
}
}
위에서 나왔던 싱크를 맞춘다라는 말이 여기서 나온 얘기이다.
병렬적으로 코드가 실행되다보니 같은 자원을 공유받아 사용할 때 서로에게 영향을 줄수 있는데 이로인해 장애나 버그가 발생할 수 있다.
그럴때 싱크를 맞춰 동기화 하면 다른 쓰레드가 침범하지 못하게 할 수 있다는 뜻이다.
침범하지 못하게 막아야하는 코드들을 임계영역으로 설정하면 그 영역에는 Lock을 가진 단 하나의 쓰레드만이 출입이 가능한 원리이다.
임계영역을 지정하는데는 두가지 방법, 메서드 전체와 특정영역이 있다.
//메서드 전체
public synchronized void asyncSum() {
...침범을 막아야하는 코드...
}
//특정영역
synchronized(해당 객체의 참조변수) {
...침범을 막아야하는 코드...
}
만약 사과를 먹는 쓰레드가 있다고 가정하자.
총갯수가 10개인데, 3명이 먹을 때 1~3번째까지는 각 3개씩 잘 먹는다.
그런데 이후에 1개만 남았을 때 3명다 1개가 있다는 것을 확인하고 가지고 먹으면 -2개가 되어버리는 장애가 발생하게된다.
이럴때 우리는 싱크를 맞추는 것이다.
public class Main {
public static void main(String[] args) {
AppleStore appleStore = new AppleStore();
Runnable task = () -> {
while (appleStore.getStoredApple() > 0) {
appleStore.eatApple();
System.out.println("남은 사과의 수 = " + appleStore.getStoredApple());
}
};
for (int i = 0; i < 3; i++) {
new Thread(task).start();
}
}
}
class AppleStore {
private int storedApple = 10;
public int getStoredApple() {
return storedApple;
}
public void eatApple() {
synchronized (this) {
if(storedApple > 0) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
storedApple -= 1;
}
}
}
}
이와 같은 방법으로 해당 장애를 예방할 수 있다.
침범을 막은 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, wait() 을 호출하여 쓰레드가 Lock을 반납하고 기다리게 할 수 있다.
그렇게 되면 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 되고,
추후에 작업을 다시 재개할 상황이 된다면 notify()로 알려 작업을 중단했던 쓰레드가 다시 Lock을 얻어 진행할 수 있게 된다.
정확한 의미는 아래와 같다.
1. wait()
실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다립니다.
notify()
해당 객체의 대기실(waiting pool)에 있는 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받습니다.
두 메소드는 세트로 쓰므로 같이 설명해보도록 하겠다.
public class Main {
public static String[] itemList = {
"MacBook", "IPhone", "AirPods", "iMac", "Mac mini"
};
public static AppleStore appleStore = new AppleStore();
public static final int MAX_ITEM = 5;
public static void main(String[] args) {
// 가게 점원
Runnable StoreClerk = () -> {
while (true) {
int randomItem = (int) (Math.random() * MAX_ITEM);
appleStore.restock(itemList[randomItem]);
try {
Thread.sleep(50);
} catch (InterruptedException ignored) {
}
}
};
// 고객
Runnable Customer = () -> {
while (true) {
try {
Thread.sleep(77);
} catch (InterruptedException ignored) {
}
int randomItem = (int) (Math.random() * MAX_ITEM);
appleStore.sale(itemList[randomItem]);
System.out.println(Thread.currentThread().getName() + " Purchase Item " + itemList[randomItem]);
}
};
new Thread(StoreClerk, "StoreClerk").start();
new Thread(Customer, "Customer1").start();
new Thread(Customer, "Customer2").start();
}
}
class AppleStore {
private List<String> inventory = new ArrayList<>();
public void restock(String item) {
synchronized (this) {
while (inventory.size() >= Main.MAX_ITEM) {
System.out.println(Thread.currentThread().getName() + " Waiting!");
try {
wait(); // 재고가 꽉 차있어서 재입고하지 않고 기다리는 중!
Thread.sleep(333);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 재입고
inventory.add(item);
notify(); // 재입고 되었음을 고객에게 알려주기
System.out.println("Inventory 현황: " + inventory.toString());
}
}
public synchronized void sale(String itemName) {
while (inventory.size() == 0) {
System.out.println(Thread.currentThread().getName() + " Waiting!");
try {
wait(); // 재고가 없기 때문에 고객 대기중
Thread.sleep(333);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
while (true) {
// 고객이 주문한 제품이 있는지 확인
for (int i = 0; i < inventory.size(); i++) {
if (itemName.equals(inventory.get(i))) {
inventory.remove(itemName);
notify(); // 제품 하나 팔렸으니 재입고 하라고 알려주기
return; // 메서드 종료
}
}
// 고객이 찾는 제품이 없을 경우
try {
System.out.println(Thread.currentThread().getName() + " Waiting!");
wait();
Thread.sleep(333);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
이와같이 점원 클래스는 재고를 쌓고 고객은 제품을 원하게 된다.
재고가 없거나 원하는 물품이 없으면 wait하고 재입고가 되면 notify하는 등의 코드를 확인할 수 있으나, 한가지 에러가 있다.
점원이 물건을 가져오는 것이 랜덤이고, 손님 또한 랜덤이다.
maxlegth의 끝까지 물건을 다 쌓아놨는데 손님이 원하는 물건이 없다면 둘다 wait상태로 병목현상이 일어나 끝나지않은 클래스인데도 불구하고 아무런 작업을 하지않는 상태가 될 수 있다는 뜻이다.
동기화를 하면 자동적으로 Lock이 걸리고 풀리지만 같은 메소드에서만 가능하므로 Lock클래스를 사용하여 이러한 제약을 해결할 수 있다.
ReentrantLock
- 재진입 가능한 Lock, 가장 일반적인 배타 Lock
- 특정 조건에서 Lock을 풀고, 나중에 다시 Lock을 얻어 임계 영역으로 진입이 가능
public class MyClass {
private Object lock1 = new Object();
private Object lock2 = new Object();
public void methodA() { //메소드 A는 lock1
synchronized (lock1) {
methodB();
}
}
public void methodB() { //메소드 B는 lock2
synchronized (lock2) {
// do something
methodA();
}
}
}
위코드를 설명해보겠다.
B에서는 A를 호출하여 2를 가진상태에서 A를 호출하면 lock1을 가지려고 할 것입니다.
그런데 이미 A는 1을 가지고 있으므로 2를 기다리는 상태가 되어 데드락이 발생할 가능성이 있다.
이럴 때 - ReentrantLock을 사용하면, 같은 스레드가 이미 락을 가지고 있더라도 락을 유지하며 계속 실행할 수 있기 때문에 데드락이 발생하지 않는다.
즉, ReentrantLock을 사용하면 코드의 유연성을 높일 수 있다.
ReentrantReadWriteLock
StampedLock
여기서 잠깐!
낙관적인 Lock이란?데이터를 변경하기 전에 락을 걸지 않는 것을 의미한다.
데이터 변경을 할 때 충돌이 일어날 가능성이 적은 상황에서 사용하며, 읽기와 쓰기 작업 모두가 빠르게 처리된다.
쓰기 작업이 발생했을 때 데이터가 이미 변경된 경우 다시 읽기 작업을 수행하여 새로운 값을 읽어들이고, 변경 작업을 다시 수행하기 때문에 쓰기 작업이 빈번하지 않는 경우에는 낙관적인 락을 사용하여 더 빠른 처리가 가능하다.낙관적인 읽기 Lock은 쓰기 Lock에 의해 바로 해제가 가능하다.
무조건 읽기 Lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기 후 읽기 Lock을 겁니다.
wait() & notify()의 문제점인 waiting pool 내 쓰레드를 구분하지 못한다는 것을 해결한 것이 Condition이다.
위에서 보았던 병목현상이 바로 그것을 말한다.
그렇기 때문에 wait와 notify가 아닌 wait와 signal을 사용한다.
public class Main {
public static final int MAX_TASK = 5;
private ReentrantLock lock = new ReentrantLock();
// lock으로 condition 생성
private Condition condition1 = lock.newCondition();
private Condition condition2 = lock.newCondition();
private ArrayList<String> tasks = new ArrayList<>();
// 작업 메서드
public void addMethod(String task) {
lock.lock(); // 임계영역 시작
try {
while(tasks.size() >= MAX_TASK) {
String name = Thread.currentThread().getName();
System.out.println(name+" is waiting.");
try {
condition1.await(); // wait(); condition1 쓰레드를 기다리게 합니다.
Thread.sleep(500);
} catch(InterruptedException e) {}
}
tasks.add(task);
condition2.signal(); // notify(); 기다리고 있는 condition2를 깨워줍니다.
System.out.println("Tasks:" + tasks.toString());
} finally {
lock.unlock(); // 임계영역 끝
}
}
}