멀티 스레드
운영체제는 실행 중인 프로그램을 프로세스 단위로 관리한다.
운영체제는 여러 개의 프로세스를 동시에 처리하는 멀티 태스킹을 지원한다.
하지만 이 멀티 태스킹은 프로세스에만 국한된 이야기는 아니다.
하나의 프로세스가 여러 개의 작업을 동시에 처리할 수 있다는데 이 단위를 스레드(Thread)라고 한다.
따라서 멀티 스레드란 하나의 프로세스에 동시에 처리하는 스레드들을 의미한다.
멀티 프로세스들은 서로 독립적이기 때문에 하나의 프로세스에서 오류가 발생해도 다른 프로세스에게 영향을 미치지 않지만 멀티 스레드는 프로세스 내부에서 생성되기 때문에 하나의 스레드에 오류가 발생하면 프로세스가 종료돼 다른 스레드에게도 영향을 미친다.
자바의 메인 스레드와 작업 스레드
모든 자바 프로그램은 main() 메서드를 실행하며 시작된다. 이 main() 메서드가 바로 자바의 메인 스레드이다.
public static void main(String[] args) {
// 로직 시작
// ...
// 로직 종료
}
main() 메서드가 시작돼 모든 로직이 종료돼면 프로세스가 종료된다.
이 메인 스레드는 멀티 스레딩을 위해 여러 개의 작업 스레드를 만들 수 있다.
자바 작업 스레드 생성과 실행
자바에서 작업 스레드를 생성하는 방법은 크게 두 가지가 있다.
우선 우리가 만들고자하는 것은 MyThread이다.
여기서 두 가지 선택을 할 수가 있다.
첫 번째, Runnable 인터페이스를 구현.
두 번째, Thread 클래스를 상속.
지금부터 이 두가지 방법에 대해 알아보자.
1. Runnable 인터페이스를 구현
작업 스레드 선언
class MyThread implements Runnable {
@Override
public void run() {
// 스레드가 할 작업
}
}
- Runnable 인터페이스를 구현하려면 run() 메서드를 구현해야 한다. 이 안에는 작업 스레드가 할 작업이 들어간다.
메인 스레드에서 실행
public class Main {
public static void main(String[] args) {
Runnable myThread = new MyThread();
Thread t = new Thread(myThread);
t.start();
}
}
- 메인 스레드에서 작업 스레드를 시작하려면 start() 메서드를 호출하면 된다. start()를 호출하면 내부적으로 run() 메서드를 실행한다.
익명 클래스 기능 활용하기
public class Main {
public static void main(String[] args) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
// 스레드가 할 작업
}
});
}
}
- 실제로 이 방법이 더 많이 사용된다고 한다.
2. Thread 객체를 상속
작업 스레드 선언
class MyThread extends Thread {
@Override
public void run() {
// 스레드가 할 작업
}
}
- Thread는 Runnable 인터페이스를 구현하고 있는 클래스이다. run() 메서드를 똑같이 같고 있으므로 오버라이드한다.
메인 스레드에서 실행
public class Main {
public static void main(String[] args) {
Thread myThread = new MyThread();
Thread t = new Thread(myThread);
t.start();
}
}
- 앞선 방법과 큰 차이가 없다.
익명 클래스 기능 활용
public class Main {
public static void main(String[] args) {
Thread myThread = new Thread() {
@Override
public void run() {
super.run();
}
};
}
}
- 실제로 이 방법이 더 많이 사용된다고 한다.
스레드의 상태
작업 스레드 객체를 생성(new)하고 start() 메서드를 호출한다고 해서 바로 해당 스레드가 실행되는 것이 아니다.
해당 스레드는 실행 대기(Runnable) 상태로 가는데 대기하다가 스케쥴링에 의해 본인 차례가 되면 run() 메서드를 실행(Run)한다.
그렇게 실행되던 스레드는 다양한 상태로 변경 될 수 있는데 별 문제없이 실행되면 해당 스레드는 종료(Terminated)된다.
하지만 실행 중인 스레드는 일시정지 상태로 가기도 하는데 이때는 스레드가 실행될 수 없다. 이 일시정지된 스레드들이 다시 실행되려면 실행대기 상태로 가야한다.
자바에서는 스레드의 상태를 전환할 수 있는 다양한 메서드를 제공한다. 지금부터 그것들에 대해 알아보자.
1. sleep()
실행 중인 스레드를 일시정지 상태로 만들 수 있는 가장 간단한 방법은 sleep() 메서드를 호출하는 것이다.
sleep() 메서드를 호출하면 지정한 시간동안 해당 스레드를 멈추게 할 수 있다. 이때 인자는 밀리초 단위로 받는다.
sleep()은 정적 메서드이기 때문에 Thread.sleep()과 같은 형식으로 작성해야 한다.
예시 코드
public class SleepExample {
public static void main(String[] args) {
System.out.println("Thread will sleep for 3 seconds.");
try {
// 현재 스레드를 3초(3000 밀리초) 동안 일시 정지
Thread.sleep(3000);
} catch (InterruptedException e) {
// InterruptedException이 발생할 경우 처리할 코드
System.err.println("Thread was interrupted!");
}
System.out.println("Thread woke up after 3 seconds.");
}
}
- 일시정지 상태에서는 InterruptException이 발생할 수 있다.
출력
Thread will sleep for 3 seconds.
(3초 대기)
Thread woke up after 3 seconds.
2. join()
스레드를 다른 스레드가 종료할 때까지 기다렸다가 실행해야하는 경우가 있다. 이때 join()을 사용한다. join()은 필드 메서드로 스레드 인스턴스를 통해 호출하게 되는데 해당 메서드를 호출한 스레드는 그 스레드 인스턴스가 종료될 때까지 일시정지 상태가 된다.
예를 들어, 메인스레드에서 작업스레드 t1을 start()를 통해 실행하고 t1.join()을 하면 메인스레드는 t1이 종료될 때까지 일시정지 상태가 된다.
예시 코드
class WorkerThread extends Thread {
private String threadName;
WorkerThread(String name) {
threadName = name;
}
public void run() {
System.out.println(threadName + " is starting.");
try {
for (int i = 1; i <= 5; i++) {
System.out.println(threadName + " is working: " + i);
Thread.sleep(1000); // 1초 동안 일시 정지
}
} catch (InterruptedException e) {
System.out.println(threadName + " was interrupted.");
}
System.out.println(threadName + " has finished.");
}
}
public class JoinExample {
public static void main(String[] args) {
WorkerThread thread1 = new WorkerThread("Thread-1");
WorkerThread thread2 = new WorkerThread("Thread-2");
thread1.start();
thread2.start();
try {
// thread1이 종료될 때까지 현재 스레드를 일시 정지
thread1.join();
System.out.println("Thread-1 has joined. Now main thread will wait for Thread-2.");
// thread2가 종료될 때까지 현재 스레드를 일시 정지
thread2.join();
System.out.println("Thread-2 has joined. Now main thread will continue.");
} catch (InterruptedException e) {
System.out.println("Main thread was interrupted.");
}
System.out.println("Main thread has finished.");
}
}
- 메인스레드에서 두 개의 WorkerThread를 start() 해줬고 순서대로 join()을 했다.
출력 결과
Thread-1 is starting.
Thread-2 is starting.
Thread-1 is working: 1
Thread-2 is working: 1
Thread-1 is working: 2
Thread-2 is working: 2
Thread-1 is working: 3
Thread-2 is working: 3
Thread-1 is working: 4
Thread-2 is working: 4
Thread-1 is working: 5
Thread-2 is working: 5
Thread-1 has finished.
Thread-1 has joined. Now main thread will wait for Thread-2.
Thread-2 has finished.
Thread-2 has joined. Now main thread will continue.
Main thread has finished.
- 이때 주의해야할 점이 thread1.join()이 호출되면 thread2까지 일시정지 상태가 된다고 오해할 수도 있는데 join()을 호출한 스레드는 메인스레드라서 메인스레드만 일시정지 상태가 되고 thread2는 정상적으로 본인의 작업을 수행한다.
3. yield()
스레드가 처리하는 작업은 반복적인 실행을 위해 for문이나 while문을 포함하는 경우가 많은데 가끔 반복문이 무의미한 반복을 하는 경우가 있다. 이때 다른 스레드에게 실행을 양보하고 본인은 실행대기 상태로 가도록 해주는 것이 yield()이다.
예시코드
// 코드 출처: [이것이 자바다] - p610
public class WorkThread extends Thread {
public boolean work = true;
public WorkThread(String name) {
setName(name);
}
@Override
public void run() {
while (true) {
if (work) {
System.out.println(getName() + ": 작업처리");
} else {
Thread.yield(); // 다른 스레드에게 실행을 양보
}
}
}
}
class YieldExample {
public static void main(String[] args) {
WorkThread threadA = new WorkThread("A");
WorkThread threadB = new WorkThread("B");
threadA.start();
threadB.start();
try {
Thread.sleep(5000); // 5초 동안 대기
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
threadA.work = false; // threadA가 실행을 양보하도록 설정
try {
Thread.sleep(10000); // 10초 동안 대기
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
threadA.work = true; // threadA가 다시 작업을 수행하도록 설정
}
}
- 처음 5초는 threadA와 threadB가 번걸아가며 실행되게 하다가 그 이후부터는 threadA가 yield()를 호출하게 해 실행을 양보한다.
- 다음 10초 이후부터는 다시 theadA도 작업을 수행한다.
4. wait() / notify(), notifyAll()
스레드 동기화
해당 메서드들을 이해하기 전에 먼저 스레드 동기화를 이해해야 한다.
스레드 동기화란 하나의 객체에 대해 여러 스레드가 동시에 접근하려고 할 때 발생할 수 있는 데이터 불일치 문제를 해결하는 것을 말한다.
특정 영역을 스레드 동기화하면 해당 영역을 한번에 하나의 스레드만 접근할 수 있게 해준다.
동기화 처리할 수 있는 방법은 크게 두 가지가 있다.
1. 메서드 동기화
메서드 전체를 동기화하는 것이다.
public synchronized void method() {
// 동기화 될 로직
}
2. 동기화 블록
메서드 내에 특정 영역에만 동기화가 적용되게 할 수 있다.
public void method() {
// 동기화 처리되지 않을 로직
synchronized (this) {
// 동기화 될 로직
}
}
예시 코드
class BankAccount {
private int balance = 1000;
// 동기화된 메소드
public synchronized void deposit(int amount) {
balance += amount;
System.out.println(Thread.currentThread().getName() + " deposited " + amount + ", Balance: " + balance);
}
// 동기화된 메소드
public synchronized void withdraw(int amount) {
if (balance >= amount) {
balance -= amount;
System.out.println(Thread.currentThread().getName() + " withdrew " + amount + ", Balance: " + balance);
} else {
System.out.println(Thread.currentThread().getName() + " tried to withdraw " + amount + " but insufficient balance, Balance: " + balance);
}
}
public int getBalance() {
return balance;
}
}
class AccountThread extends Thread {
private BankAccount account;
private int depositAmount;
private int withdrawAmount;
public AccountThread(BankAccount account, String name, int depositAmount, int withdrawAmount) {
super(name);
this.account = account;
this.depositAmount = depositAmount;
this.withdrawAmount = withdrawAmount;
}
@Override
public void run() {
account.deposit(depositAmount);
account.withdraw(withdrawAmount);
}
}
public class SyncExample {
public static void main(String[] args) {
BankAccount account = new BankAccount();
AccountThread thread1 = new AccountThread(account, "Thread-1", 100, 50);
AccountThread thread2 = new AccountThread(account, "Thread-2", 200, 100);
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Final balance: " + account.getBalance());
}
}
- 두 개의 스레드가 하나의 BankAccount 객체에 동시에 접근하려고 한다.
- 이때 deposit()(입금)과 withdraw()(출금) 메서드를 동기화 처리했다.
출력 결과
Thread-1 deposited 100, Balance: 1100
Thread-2 deposited 200, Balance: 1300
Thread-1 withdrew 50, Balance: 1250
Thread-2 withdrew 100, Balance: 1150
Final balance: 1150
wait() / notify()(notifyAll())
이 메서드들을 설명하기 전에 동기화를 설명했던 이유는 해당 메서드들은 동기화된 메서드나 동기화 블록에서만 사용이 가능하기 때문이다.
해당 메서드들은 같이 사용되는 경우가 많은데 특히 두 개의 스레드를 교대로 번갈아가며 실행되고 싶게 할 때 사용된다.
각 메서드의 기능은 다음과 같다.
- wait(): 해당 스레드를 일시정지 상태로 만든다.
- notify(): wait()로 인해 일시정지 상태인 스레드 하나를 실행 대기 상태로 만든다.
- notifyAll(): wait()로 인해 일시정지 상태인 스레드들 전부를 실행 대기 상태로 만든다.
해당 메서드들은 Thread 클래스의 메서드가 아니라 모든 클래스의 부모클래스인 Object클래스의 메서드라는 것을 명심하자.
예시 코드
class Data {
private String packet;
// 수신 중 상태를 확인하기 위한 플래그
private boolean transfer = true;
// 데이터를 읽는 메소드
public synchronized String receive() {
while (transfer) {
try {
wait(); // 데이터가 준비될 때까지 대기
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("Thread interrupted");
}
}
transfer = true;
notifyAll(); // 대기 중인 스레드들에게 알림
return packet;
}
// 데이터를 쓰는 메소드
public synchronized void send(String packet) {
while (!transfer) {
try {
wait(); // 데이터가 소비될 때까지 대기
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("Thread interrupted");
}
}
transfer = false;
this.packet = packet;
notifyAll(); // 대기 중인 스레드들에게 알림
}
}
class Producer extends Thread {
private Data data;
public Producer(Data data) {
this.data = data;
}
@Override
public void run() {
String[] packets = {
"First packet",
"Second packet",
"Third packet",
"Fourth packet",
"End"
};
for (String packet : packets) {
data.send(packet);
System.out.println("Sent: " + packet);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("Thread interrupted");
}
}
}
}
class Consumer extends Thread {
private Data data;
public Consumer(Data data) {
this.data = data;
}
@Override
public void run() {
for (String receivedMessage = data.receive(); !"End".equals(receivedMessage); receivedMessage = data.receive()) {
System.out.println("Received: " + receivedMessage);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("Thread interrupted");
}
}
}
}
public class WaitNotifyExample {
public static void main(String[] args) {
Data data = new Data();
Producer producer = new Producer(data);
Consumer consumer = new Consumer(data);
producer.start();
consumer.start();
}
}
1. Data 클래스
- 공유 객체
- transfer 플래그: 데이터 전송 상태를 나타냄
- receive(): 데이터가 준비될 때까지 wait()하다가 데이터가 준비되면 다른 스레드들을 notifyAll()로 깨운다.
- send(): 데이터가 소비될 때까지 wait()하다가 데이터가 전송되면 다른 스레드들을 notifyAll()로 깨운다.
2. Producer 클래스
- 데이터를 생성하여 Data 객체를 전송
3. Consumer 클래스
- Producer가 보낸 데이터를 수신
두 스레드가 실행되면 Producer 스레드가 메시지를 send()하고 메시지를 기다리고 있던 Consumer는 데이터를 receive()한다.
출력결과
Sent: First packet
Received: First packet
Sent: Second packet
Received: Second packet
Sent: Third packet
Received: Third packet
Sent: Fourth packet
Received: Fourth packet
Sent: End
Received: End
5. interrupt()
interrupt()는 일시정지 상태에 있는 스레드를 안전하게 종료시키고 싶을 때 사용된다.
정확히는 실행대기 상태로 가는 것이긴 하지만 interrupt()는 실행시 스레드가 일시정지 상태에 있으면 InterruptException 예외를 발생시키기 때문에 종료시키는 용도로 많이 사용된다.
interrupt() 메서드는 사용시 내부 동작을 확실히 이해해야하는데 이는 다음과 같다.
interrupt()메서드 호출시점에 해당 스레드가 일시정지 상태가 아니라고 해서 '아 아니구나' 하고 넘어가는 것이 아니다.
interrupt() 메서드가 호출되게 되면 내부에서 인터럽트 플래그를 설정한다.
그 이후 스레드가 실행되다가 일시정지 상태가 되면 인터럽트 플래그를 보고 그때 InterruptException이 발생하는 것이다.
isInterrupted()/interrupted()
때때로 예외처리 외의 상황에서 인터럽트를 대응하고 싶어질 수가 있고 인터럽트가 된 시점을 알고 싶어질 때가 있다.
그 때 사용하는 것이 isInterrupt(), interrupted()이다.
class MyThread extends Thread {
@Override
public void run() {
while (!isInterrupted()) {
System.out.println("Thread is running...");
}
System.out.println("Thread is exiting.");
}
}
public class InterruptExample {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
try {
Thread.sleep(5000); // 메인 스레드가 5초 동안 대기
} catch (InterruptedException e) {
e.printStackTrace();
}
thread.interrupt(); // 스레드를 인터럽트하여 종료 요청
}
}
isInterrupted(), interrupted()의 차이는 다음과 같다.
- isInterrupted(): Thread 클래스의 필드 메서드로 호출시 인터럽트 플래그를 초기화하지 않는다.
- interrupted(): Thread 클래스의 정적 메서드로 호출시 인터럽트 플래그를 초기화한다.
데몬 스레드
데몬 스레드란, 주인 스레드를 보조하는 스레드를 말한다.
데몬 스레드는 주인 스레드에 의존하기 때문에 주인 스레드가 종료되면 데몬 스레드도 같이 종료된다.
자바에서 데몬 스레드를 설정하려면 setDaemon(true)를 호출하면 된다.
daemonThread.setDaemon(true);
- 이 메서드를 호출한 스레드는 daemonThread를 데몬 스레드로 가진다.
데몬 스레드의 대표적인 예시는 워드 프로세서의 자동 저장, 미디어 플레이어의 동영상 및 음악 재생, 가비지 컬렉터 등이 있다.
스레드 풀
병렬작업처리가 많아져 스레드 갯수가 폭증하게 되면 CPU가 바빠지고 메모리가 부족해진다. 이는 곧 애플리케이션의 성능저하로 이어진다. 그렇기 때문에 스레드 갯수를 제한할 필요가 있다.
이때 사용하는 것이 스레드 풀이다.
스레드 풀은 작업 처리 요청을 받은 스레드를 작업 큐에 넣어 관리한다. 그리고 스레드를 꺼내서 작업을 병렬적으로 처리하는데 이때 병렬적으로 처리하는 스레드의 수를 제한한다.
스레드 풀의 초기수, 코어수, 최대수 개념
초기수: 스레드 풀이 생성될 때 기본적으로 생성되는 스레드 수
코어수: 스레드가 증가된 후 사용되지 않는 스레드를 제거할 때 최소한 풀에서 유지하는 스레드 수
최대수: 스레드 풀이 동시에 실행할 수 있는 최대 스레드 수(작업 큐의 최대 용량 X)
만약 초기수가 0, 코어수가 5, 최대수가 10이라면
스레드가 처음 생성될 때는 0개의 스레드를 가지고 있다가 스레드가 8개 처리요청이 들어오면 스레드를 새로 생성해 처리한다.
이후 해당 스레드가 종료되면 코어수에 해당하는 5개는 종료하지 않고 그대로 유지하고 3개는 일정시간 후에 제거한다. 만약 12개의 처리 요청이 들어오면 10개는 병렬적으로 처리하고 나머지 2개는 작업 큐에 넣는다.
스레드풀 생성
자바에서 스레드풀을 생성하고 사용할 수 있도록 ExcutorService 인터페이스와 Executors 클래스를 제공한다.
스레드 풀을 생성하는 방법은 크게 3가지이다.
1. Executors.newCachedThreadPool()
ExecutorService excutorService = Executors.newCachedThreadPool();
- 해당 방법으로 생성된 스레드 풀의 초기 수는 0개이고 이후 새로운 작업 요청이 올 때마다 스레드를 생성한다.
- 코어 수가 0이기 때문에 작업이 종료된후 60초가 지나면 스레드를 풀에서 제거한다.
- 최대 수는 Integer.MAX_VALUE(2,147,483,648)이다.
2. Executors.newFixedThreadPoll(int)
ExecutorService excutorService = Executors.newFixedThread(5);
- 해당 방법으로 생성된 스레드 풀의 초기 수는 0개이고 이후 새로운 작업 요청이 올 때마다 스레드를 생성한다.
- 코어 수는 생성된 스레드의 수 이기 때문에 작업이 종료되어도 스레드 풀에서 스레드를 버리지 않고 유지한다.
- 최대 수는 매개변수이며 해당 예제에선 5개로 설정했다.
3. new ThreadPoolExecutor() (직접 생성)
ExecutorService threadPool = new ThreadPoolExecutor (
3, // 코어 스레드 수
100, // 최대 스레드 수
120L, // 놀고 있는 시간
TimeUniy.SECONDS // 놀고 있는 시간 단위
new SynchronousQueue<Runnable> // 작업 큐
);
스레드 풀 종료
스레드 풀의 스레드는 기본적으로 데몬 스레드가 아니기 때문에 main 스레드가 종료되더라도 작업을 처리하기 위해 계속 실행 상태로 남아 있다. 스레드 풀의 모든 스레드를 종료하려면 다음의 두 메서드 중 하나를 실행 해야한다.
1. void shutdown()
현재 처리 중인 작업뿐만 아니라 작업 큐에 대기하고 있는 모든 작업을 처리한 뒤에 스레드 풀을 종료시킨다.
2. List<Runnable> shutdownNow()
현재 작업 처리 중인 스레드를 interrupt해서 작업을 중지시키고 스레드 풀을 종료시킨다.
리턴 값은 작업 큐에 있는 미처리된 작업(Runnable)의 목록이다.
작업 생성 및 처리 요청
스레드를 생성해서 스레드 풀에 넣어 처리를 요청하려면 ExecutorService의 execute()와 submit()을 호출하면 된다.
이 둘의 차이는 매개변수의 차이와 리턴타입에 있다.
1. execute()
- execute()는 Runnable을 인자로 받는다.
- 작업 처리 결과를 리턴하지 않는다.
예시 코드
ExecutorService executorService = Executors.newFixedThreadPool(5); // 스레드 풀 생성
executorService.execute(new Runnable() {
@Override
public void run() {
// 작업 내용
}
}
);
2. submit()
- submit()은 Callable<T>를 인자로 받는다.
- 작업 처리 결과를 Future<T>로 리턴한다.
예시 코드
ExecutorService executorService = Executors.newFixedThreadPool(5); // 스레드 풀 생성
Future<Object> submit = executorService.submit(new Callable<>() {
@Override
public Object call() throws Exception {
return null;
}
});
Object result = submit.get();
참고: [이것이 자바다] - Ch14. 멀티스레드
'Java' 카테고리의 다른 글
[Java] TCP UDP 프로그래밍 (0) | 2024.07.28 |
---|---|
[Java] 해시기반 컬렉션의 동등성 비교(hashCode() 메서드의 필요성) (0) | 2024.07.25 |
[Java] Stream API (0) | 2024.07.25 |
[Java] 람다식의 이해 (0) | 2024.07.25 |
[Java] HashMap - value값을 기준으로 정렬 (0) | 2024.07.23 |