1. 쓰레드 우선순위와 쓰레드 그룹
2. 쓰레드 상태와 제어
1. 쓰레드 우선순위와 쓰레드 그룹
1. 쓰레드 우선순위
쓰레드 작업의 중요도에 따라서 쓰레드의 우선순위를 부여할 수 있다.
- 작업의 중요도가 높을 때 우선순위를 높게 지정하면 더 많은 작업시간을 부여받아 빠르게 처리될 수 있다.
- 쓰레드는 생성될때 우선순위가 정해진다.
- 이 우선순위는 우리가 직접 지정하거나 JVM에 의해 지정될 수 있다.
- 우선순위는 아래와 같이 3가지 (최대/최소/보통) 우선순위로 나뉜다.
- 최대 우선순위 (MAX_PRIORITY) = 10
- 최소 우선순위 (MIN_PRIORITY) = 1
- 보통 우선순위 (NROM_PRIORITY) = 5
- 기본 값이 보통 우선순위이다.
- 더 자세하게 나눈다면 1~10 사이의 숫자로 지정 가능하다.
- 이 우선순위의 범위는 OS가 아니라 JVM에서 설정한 우선순위이다.
- 스레드 우선순위는 setPriority() 메서드로 설정할 수 있다.
- 우선순위가 높다고 반드시 쓰레드가 먼저 종료되는 것은 아니다. 단지 먼저종료될 확률이 높은것
public class Main {
public static void main(String[] args) {
Runnable task1 = () -> {
for (int i = 0; i < 100; i++) {
System.out.print("$");
}
};
Runnable task2 = () -> {
for (int i = 0; i < 100; i++) {
System.out.print("*");
}
};
Thread thread1 = new Thread(task1);
thread1.setPriority(8); // setPriority 메서드를 통해 우선순위를 설정
int threadPriority = thread1.getPriority(); // getPriority를 통해 우선순위 확인
System.out.println("threadPriority = " + threadPriority);
Thread thread2 = new Thread(task2);
thread2.setPriority(2);
thread1.start();
thread2.start();
}
}
2. 쓰레드 그룹
서로 관련이 있는 쓰레드들을 그룹으로 묶어서 다룰 수 있다.
- 쓰레드들은 기본적으로 그룹에 포함되어 있다.
- JVM 이 시작되면 system 그룹이 생성되고 쓰레드들은 기본적으로 system 그룹에 포함된다.
- 메인 쓰레드는 system 그룹 하위에 있는 main 그룹에 포함된다.
- 모든 쓰레드들은 반드시 하나의 그룹에 포함되어 있어야 한다.
- 쓰레드 그룹을 지정받지 못한 쓰레드는 자신을 생성한 부모 쓰레드의 그룹과 우선순위를 상속받게 되는데 우리가 생성하는 쓰레드들은 main 쓰레드 하위에 포함된다.
- 따라서 쓰레드 그룹을 지정하지 않으면 해당 쓰레드는 자동으로 main 그룹에 포함된다.
- 쓰레드 그룹 생성
- ThreadGroup 클래스로 객체를 만들어서 Thread 객체 생성시 첫번째 매개변수로 넣어주면 된다.
- 쓰레드 그룹으로 묶어서 쓰레드 관리
- ThreadGroup 객체의 interrupt() 메서드를 실행시키면 해당 그룹 쓰레드들이 실행대기 상태로 변경된다.
public class Main {
public static void main(String[] args) {
Runnable task = () -> {
while (!Thread.currentThread().isInterrupted()) {
try {
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName());
} catch (InterruptedException e) {
break;
}
}
System.out.println(Thread.currentThread().getName() + " Interrupted");
};
// ThreadGroup 클래스로 객체를 만든다.
ThreadGroup group1 = new ThreadGroup("Group1");
// Thread 객체 생성시 첫번째 매개변수로 넣어준다.
// Thread(ThreadGroup group, Runnable target, String name)
Thread thread1 = new Thread(group1, task, "Thread 1");
Thread thread2 = new Thread(group1, task, "Thread 2");
// Thread에 ThreadGroup 이 할당된것을 확인할 수 있다.
System.out.println("Group of thread1 : " + thread1.getThreadGroup().getName());
System.out.println("Group of thread2 : " + thread2.getThreadGroup().getName());
thread1.start();
thread2.start();
try {
// 현재 쓰레드를 지정된 시간동안 멈추게 한다.
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// interrupt()는 일시정지 상태인 쓰레드를 실행대기 상태로 만듭니다.
group1.interrupt();
}
}
6. 쓰레드 상태와 제어
1. 쓰레드 상태
- 이처럼 쓰레드는 실행과 대기를 반복하며 run() 메서드를 수행한다.
- run() 메서드가 종료되면 실행이 멈추게 된다.
- 음악을 듣다 일시정지를 하는 것과 마찬가지로 쓰레드도 일시정지 상태를 만들 수 있다. (2)
- 일시정지 상태에서는 쓰레드가 실행을 할 수 없는 상태가 된다.
- 쓰레드가 다시 실행 상태로 넘어가기 위해서는 우선 일시정지 상태에서 실행대기 상태로 넘어가야 한다. (3)
쓰레드의 상태 정리
상태
|
Enum
|
설명
|
객체생성
|
NEW
|
쓰레드 객체 생성, 아직 start() 메서드 호출 전의 상태
|
실행대기
|
RUNNABLE
|
실행 상태로 언제든지 갈 수 있는 상태
|
일시정지
|
WAITING
|
다른 쓰레드가 통지(notify) 할 때까지 기다리는 상태
|
일시정지
|
TIMED_WAITING
|
주어진 시간 동안 기다리는 상태
|
일시정지
|
BLOCKED
|
사용하고자 하는 객체의 Lock이 풀릴 때까지 기다리는 상태
|
종료
|
TERMINATED
|
쓰레드의 작업이 종료된 상태
|
2. 쓰레드 제어
3. sleep()
현재 쓰레드를 지정된 시간동안 멈추게 한다.
- sleep()은 쓰레드 자기자신에 대해서만 멈추게 할 수 있다.
- Thread.sleep(ms); ms(밀리초) 단위로 설정된다.
- 예외처리를 해야한다.
- sleep 상태에 있는 동안 interrupt() 를 만나면 다시 실행되기 때문에 InterruptedException이 발생할 수 있다.
- 특정 쓰레드를 지목해서 멈추게 하는 것은 불가능하다.
public class Main {
public static void main(String[] args) {
Runnable task = () -> {
try {
Thread.sleep(2000); // 2초
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("task : " + Thread.currentThread().getName());
};
Thread thread = new Thread(task, "Thread");
thread.start();
try {
thread.sleep(1000); // 1초
System.out.println("sleep(1000) : " + Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
4. interrupt()
일시정지 상태인 쓰레드를 실행대기 상태로 만든다.
- Thread 클래스 내부에 interrupted 되었는지를 체크하는 boolean 변수가 존재
- 쓰레드가 start() 된 후 동작하다 interrupt()를 만나 실행하면 interrupted 상태가 true가 된다.
- isInterrupted() 메서드를 사용하여 상태값을 확인할 수 있다.
public class Main {
public static void main(String[] args) {
Runnable task = () -> {
try {
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName());
} catch (InterruptedException e) { // sleep 실행중 interrupt 발생시 예외 발생
e.printStackTrace();
}
System.out.println("task : " + Thread.currentThread().getName());
};
Thread thread = new Thread(task, "Thread");
thread.start();
thread.interrupt();
System.out.println("thread.isInterrupted() = " + thread.isInterrupted());
}
}
!Thread.currentThread().isInterrupted() 로 interrupted 상태를 체크해서 처리하면 오류를 방지할 수 있다.
public class Main {
public static void main(String[] args) {
Runnable task = () -> {
while (!Thread.currentThread().isInterrupted()) {
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());
}
}
5. join(), yield()
정해진 시간동안 지정한 쓰레드가 작업하는 것을 기다린다.
- 시간을 지정하지 않았을 때는 지정한 쓰레드의 작업이 끝날 때까지 기다린다.
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));
}
}
6. yield()
남은 시간을 다음 쓰레드에게 양보하고 쓰레드 자신은 실행대기 상태가 된다.
thread1과 thread2가 같이 1초에 한번씩 출력되다가 5초뒤에 thread1에서 InterruptedException이 발생하면서 Thread.yield();가 실행되어 thread1은 실행대기 상태로 변경되면서 남은 시간은 thread2에게 리소스가 양보된다.
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();
}
}
7. synchronized
멀티 쓰레드의 경우 여러 쓰레드가 한 프로세스의 자원을 공유해서 작업하기 때문에 서로에게 영향을 줄 수 있다. 이로인해서 장애나 버그가 발생할 수 있다.
- 이러한 일을 방지하기 위해 한 쓰레드가 진행중인 작업을 다른 쓰레드가 침범하지 못하도록 막는 것을 '쓰레드 동기화(Synchronization)'라고 한다.
- 동기화를 하려면 다른 쓰레드의 침범을 막아야하는 코드들을 ‘임계영역’으로 설정하면 된다.
- 임계영역에는 Lock을 가진 단 하나의 쓰레드만 출입이 가능하다.
- 즉, 임계영역은 한번에 한 쓰레드만 사용이 가능하다.
- synchronized 를 사용한 동기화
- 실행할 메서드 또는 실행할 코드 묶음 앞에 synchronized 를 붙여서 임계영역을 지정하여 다른 쓰레드의 침범을 막을 수 있다. (침범을 막다. = Lock을 걸다.)
- 임계영역 지정
1. 메서드 전체를 임계영역으로 지정
public synchronized void asyncSum() {
...침범을 막아야하는 코드...
}
2. 특정 영역을 임계영역으로 지정합니다.
synchronized(해당 객체의 참조변수) {
...침범을 막아야하는 코드...
}
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;
}
}
}
}
사과를 순서대로 잘 먹는 것을 확인할 수 있습니다.
만약 synchronized가 없다면 남은 사과의 수가 뒤죽박죽 출력될뿐만 아니라 없는 사과를 먹는 경우도 발생.
7. wait(), notify()
침범을 막은 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, wait() 을 호출하여 쓰레드가 Lock을 반납하고 기다리게 할 수 있다.
- 그럼 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행 할 수 있게 되고,
- 추후에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서,
- 작업을 중단했던 쓰레드가 다시 Lock을 얻어 진행할 수 있게 된다.
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();
}
}
}
}
8. Lock, Condition
synchronized 블럭으로 동기화하면 자동적으로 Lock이 걸리고 풀리지만, 같은 메서드 내에서만 Lock을 걸 수 있다는 제약이 있다.
이런 제약을 해결하기 위해 Lock 클래스를 사용한다.
- ReentrantLock
- 재진입 가능한 Lock, 가장 일반적인 배타 Lock
- 특정 조건에서 Lock을 풀고, 나중에 다시 Lock을 얻어 임계영역으로 진입이 가능하다.
public class MyClass {
private Object lock1 = new Object();
private Object lock2 = new Object();
public void methodA() {
synchronized (lock1) {
methodB();
}
}
public void methodB() {
synchronized (lock2) {
// do something
methodA();
}
}
}
- methodA는 lock1을 가지고, methodB는 lock2를 가진다.
- methodB에서 methodA를 호출하고 있으므로, methodB에서 lock2를 가진 상태에서 methodA를 호출하면 lock1을 가지려고 할 것이다.
- 그러나 이때, methodA에서 이미 lock1을 가지고 있으므로 lock2를 기다리는 상태가 되어 데드락이 발생할 가능성이 있다.
- 하지만 ReentrantLock을 사용하면, 같은 스레드가 이미 락을 가지고 있더라도 락을 유지하며 계속 실행할 수 있기 때문에 데드락이 발생하지 않는다.
- 즉, ReentrantLock을 사용하면 코드의 유연성을 높일 수 있다.
- ReentrantReadWriteLock
- 읽기를 위한 Lock과 쓰기를 위한 Lock을 따로 제공한다.
- 읽기에는 공유적이고, 쓰기에는 베타적인 Lock이다.
- 읽기 Lock이 걸려있으면 다른 쓰레드들도 읽기 Lock을 중복으로 걸고 읽기를 수행할 수 있다. (read-only)
- 읽기 Lock이 걸려있는 상태에서 쓰기 Lock을 거는 것은 허용되지 않는다. (데이터 변경 방지)
- StampedLock
- ReentrantReadWriteLock에 낙관적인 Lock의 기능을 추가했다.
- 낙관적인 Lock : 데이터를 변경하기 전에 락을 걸지 않는 것을 말합니다. 낙관적인 락은 데이터 변경을 할 때 충돌이 일어날 가능성이 적은 상황에서 사용한다.
- 낙관적인 락을 사용하면 읽기와 쓰기 작업 모두가 빠르게 처리된다. 쓰기 작업이 발생했을 때 데이터가 이미 변경된 경우 다시 읽기 작업을 수행하여 새로운 값을 읽어들이고, 변경 작업을 다시 수행한다. 이러한 방식으로 쓰기 작업이 빈번하지 않은 경우에는 낙관적인 락을 사용하여 더 빠른 처리가 가능하다.
- 낙관적인 읽기 Lock은 쓰기 Lock에 의해 바로 해제 가능하다.
- 무조건 읽기 Lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기 후 읽기 Lock을 겁니다.
- ReentrantReadWriteLock에 낙관적인 Lock의 기능을 추가했다.
9. Condition
wait() & notify()의 문제점인 waiting pool 내 쓰레드를 구분하지 못한다는 것을 해결한 것이 Condition 이다.
[추가 설명]
wait()과 notify()는 객체에 대한 모니터링 락(lock)을 이용하여 스레드를 대기시키고 깨운다. 그러나 wait()과 notify()는 waiting pool 내에 대기중인 스레드를 구분하지 못하므로, 특정 조건을 만족하는 스레드만 깨우기가 어렵다.
이러한 문제를 해결하기 위해 JDK 5에서는 java.util.concurrent.locks 패키지에서 Condition 인터페이스를 제공한다. Condition은 waiting pool 내의 스레드를 분리하여 특정 조건이 만족될 때만 깨우도록 할 수 있으며, ReentrantLock 클래스와 함께 사용됩니다. 따라서 Condition을 사용하면 wait()과 notify()의 문제점을 보완할 수 있다.
- wait() & notify() 대신 Condition의 await() & signal() 을 사용한다.
- 아래 코드와 같이 Condition 을 만들어서 대기줄(waiting pool)을 사용 할 수 있다.
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(); // 임계영역 끝
}
}
쓰레드까지 정리하면서 솔직하게 전부 이해하진 못한 부분이 많고 쓰레드 자체가 워낙 난도가 있는 내용이기도 하다. 사용을 한다면 그때 다시 꺼내보며 하나하나 다시 익혀가는 과정이 필요할 것 같다.
'배운내용 정리' 카테고리의 다른 글
IntelliJ 한글깨짐 현상 수정 (0) | 2023.11.04 |
---|---|
Spring port error (1) | 2023.10.31 |
Java Thread(2) (0) | 2023.10.27 |
Java Thread(1) (0) | 2023.10.24 |
Java Generic (1) | 2023.10.23 |