프로그램 / 프로세스 / 스레드
프로그램(Program)은 컴퓨터에 저장된 실행파일이고, 프로세스는 컴퓨터에서 실행 중인(CPU에 올라간) 프로그램입니다. 이런 프로세스 작업을 스레드 단위로 작업을 할 수 있습니다. 즉, 스레드를 사용함으로써 하나의 프로세스에서 두 가지 이상의 작업을 동시에 실행할 수 있습니다. 이를 멀티 스레드라고 합니다.
멀티 프로세스와 멀티 스레드의 차이는 무엇일까요?
1. 멀티 프로세스
이름 그대로 여러 프로세스를 사용하여 처리하는 방식입니다. 여러 개의 프로세스들은 동시에 실행되는 거처럼 보이지만 사실은 매우 빠르게 돌아가면서 실행되고 있습니다. 하나의 프로세스가 CPU 위에서 돌고 있고, 다른 프로세스가 실행된다면, 기존에 실행되던 프로세스는 준비 상태가 됩니다. 마찬가지로 다른 프로세스가 실행되기 위해서는 기존에 실행되던 프로세스는 준비 상태가 돼야 합니다. 이를 컨텍스트 스위칭(Context Switching)이라 하는데 이러한 작업이 이루어질 때 비용이 많이 발생합니다. 또한 각 프로세스는 메모리를 독립적으로 차지하기 때문에 메모리 사용량이 많아지게 됩니다. 또한 서로 다른 프로세스가 데이를 주고받으려면 소켓 등 통신 방법(IPC: Inter-Process Communication)을 사용해야 합니다.
2. 멀티 스레드는 하나의 프로세스 내에서 여러 개의 스레드를 사용하여 처리하는 방식입니다. 스레드들은 하나의 프로세스 내에서 독립적으로 실행되지만, 메모리 공간(Code, Data, Heap)을 공유하고 Stack만 별도의 저장 공간으로 갖기 때문에 메모리가 효율적입니다. 또한 같은 프로세스 내에서 메모리를 공유하므로, Context Switching 비용도 적고 스레드 간 데이터 전송도 빠릅니다. 대신, 한 스레드가 문제가 발생하면 전체 프로세스 영향을 미칠 수 있습니다.
💁 멀티 코어란?
멀티 코어란 말 그대로 2개 이상의 Core를 갖는 것을 말합니다. 위에서 설명할 때 프로세스가 CPU 위에서 돈다고 하였습니다. 사실은 CPU Core가 이를 처리하는데요. 때문에 스레드를 CPU Core의 실행 단위 Unit Of Excution이라고 하기도 합니다. 싱글 코어의 경우 하나의 코어에 하나의 스레드가 들어와 돌지만, 두 개 이상인 멀티 코어에서는 여러 스레드가 들어와 돌 수 있다 이해하시면 됩니다. :)
스레드
스레드란 무엇인가?
스레드(Thread)는 자바에서 프로그램의 실행 흐름을 나타내는 가장 작은 실행 단위입니다. 모든 자바 프로그램은 기본적으로 하나의 main 스레드에서 실행됩니다. 하지만 멀티쓰레딩을 활용하면, 여러 개의 스레드를 동시에 실행시켜 병렬 처리를 할 수 있습니다. 이를 통해 더 복잡하고 성능이 요구되는 작업을 효율적으로 처리할 수 있습니다.
스레드 생성
자바에서 스레드를 생성하는 두 가지 방법이 있습니다.
1. Thread 클래스 상속받기
Thread 클래스를 상속받아 직접 스레드를 구현하는 방법, run() 메서드를 오버라이드합니다.
class MyThread extends Thread {
@Override
public void run() {
System.out.println("MyThread is running");
}
}
public class Main {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
}
}
2. Runnable 인터페이스 구현
Runnable 인터페이스 구현하여 스레드를 생성하는 방법, run() 메서드를 구현하고 Thread 객체에 전달합니다.
class MyRunnable implements Runnable {
public void run() {
System.out.println("MyRunnable is running");
}
}
public class Main {
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start();
}
}
위 두 방식 모두 스레드를 생성할 수 있지만, 일반적으로 Runnable 인터페이스를 사용하는 것이 더 권장됩니다. 자바에서 다중 상속이 허용되지 않기 때문에, 클래스를 상속받는 경우 유연성이 떨어질 수 있기 때문입니다.
main 스레드
모든 Java 애플리케이션은 항상 main() 메서드에서 실행을 시작합니다. main() 메서드는 프로그램의 진입점으로, JVM이 애플리케이션을 실행할 때 가장 먼저 호출되는 메서드입니다. 이때 생성되는 main 스레드는 Java 애플리케이션의 첫 번째 스레드로 시작과 끝까지 프로그램의 대부분 작업을 처리하는 기본적인 흐름을 제공합니다. main() 메서드 안에 작성된 모든 코드는 기본적으로 main 스레드에서 실행됩니다. 만약 추가적인 스레드를 생성하지 않는다면, Java 프로그램은 main 스레드 하나로만 실행됩니다.
싱글스레드
싱글 스레드로 동작하는 프로그램이란 것은, 하나의 실행 흐름(스레드)만을 사용하는 프로그램을 의미합니다. Java에서는 이 흐름이 바로 main 스레드입니다. main 스레드만(싱글 스레드)을 사용한 작업에서는 모든 작업이 순차적으로 실행되며, 한 작업이 완료된 후에야 다음 작업이 실행됩니다.
public class SingleThreadExample {
public static void main(String[] args) {
System.out.println("start");
waitTask(); // 싱글 스레드로 실행되는 작업
System.out.println("end");
}
private static void waitTask() {
try {
Thread.sleep(2000); // 2초 대기
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
위 코드에서 main 스레드는 waitTask() 메서드를 실행하며, 5초 동안 대기 합니다. 5초 동안 대기한 후 다음 명령인 "end"가 실행됩니다. 즉, 단일 실행 흐름(싱글 스레드)으로 모든 작업이 순차적으로 처리됩니다.
멀티스레드
싱글 스레드 프로그램에선 모든 작업이 동기적으로 순차적으로 실행되기 때문에, 한 작업이 오래 걸리면 응답성이 떨어질 수 있습니다. 예를 들어, 파일을 다운로드하는 등과 같은 오래 걸리는 작업인 경우, main 스레드는 이 작업이 완료될 때까지 아무것도 하지 못한 채 대기해야 합니다. 이로 인해 프로그램의 성능이 저하되고, 응답 속도가 느려집니다.
이런 문제를 해결하기 위해 멀티 스레드를 사용하면 됩니다. 멀티 스레드 환경에서는 여러 개의 실행 흐름(스레드)이 병렬로 작업을 처리할 수 있습니다. 이렇게 하면, 긴 작업을 하나의 스레드에서 처리하는 동안, main 스레드는 다른 작업을 계속 실행할 수 있어 프로그램의 응답성을 유지할 수 있습니다.
public class MultiThreadExample {
public static void main(String[] args) {
System.out.println("Task 1");
// 새로운 스레드 생성
Thread thread = new Thread(() -> waitLongTask());
thread.start(); // 새 쓰레드 실행
// main 스레드는 계속 실행
System.out.println("Task 2");
}
private static void waitLongTask() {
try {
Thread.sleep(60000); // 60초 동안 대기
System.out.println("Task 3");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
위 코드에서 waitLongTask() 메서드가 새로운 스레드에서 실행되어, main 스레드는 대기하지 않고 바로 "Task 2"를 출력합니다. 즉, 멀티 스레드를 활용하여 긴 작업과 다른 작업을 병렬로 처리할 수 있게 됩니다.
싱글 스레드와 멀티스레드 차이점 요약
- 싱글 스레드: main 스레드에서 모든 작업을 처리하며, 작업이 끝날 때까지 다른 작업을 처리하지 못합니다. 즉, 순차적 실행만 가능합니다.
- 멀티 스레드: 여러 스레드를 생성하여 병렬로 작업을 처리할 수 있으며, 긴 작업이 진행되는 동안에도 main 스레드는 다른 작업을 처리할 수 있습니다. 이를 통해 응답성이 향상됩니다.
멀티스레드 동기화 (synchronized)
멀티스레드 환경에서 여러 스레드가 동시에 같은 자원, 즉 공유 자원에 접근할 때 데이터 일관성 문제가 생길 수 있습니다. 예를 들어, 두 스레드가 동시에 하나의 변수에 값을 변경하려고 하면, 예상치 못한 결과가 나올 수 있습니다. 이를 해결하기 위해 동기화(synchronized)가 필요합니다.
동기화 문제
class Counter {
private int count = 0;
public void increment() {
count++; // 공유 자원에 동시에 접근하는 경우 문제 발생 가능
}
public int getCount() {
return count;
}
}
public class SyncProblemExample {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("결과: " + counter.getCount());
}
}
이 예제에서 Count 객체의 count 변수를 두 개의 스레드(t1과 t2)가 동시에 수정하려고 하기 때문에, 공유 자원 접근 문제가 발생합니다. 이 문제는 멀티스레드 환경에서 흔히 발생하는 경쟁 조건(Race Condition) 때문입니다.
counter.increment() 메서드는 count++ 연산을 수행하는데, 이 메서드를 두 스레드에서 동시에 호출하면 예상치 못한 결과가 나올 수 있습니다.
- 스레드 1이 count 값을 읽음 (count = 0)
- 스레드 2도 동시에 count 값을 읽음 (count = 0)
- 스레드 1이 1을 더한 후 값을 저장 (결과: count = 1)
- 스레드 2도 1을 더한 후 값을 저장 (결과: count = 1)
이렇게 되면 두 스레드가 각각 1을 더했음에도 불구하고 최종적으로 count 값은 2가 아니라 1이 되는 문제가 발생합니다. 즉, 두 스레드가 동시에 count 값을 읽고 수정하는 과정에서, 수정한 값이 덮어쓰기 되는 일이 발생합니다.
따라서 위 예제 결과는 t1과 t2가 각각 1000번씩 increment()를 호출했기 때문에, count의 최종 값은 2000이라 기대하지만 실제로는 더 적은 값이 출력됩니다.
해결 방법: synchronized로 동기화
synchronized는 특정 코드 블록 또는 메서드를 동기화하여 한 번에 하나의 스레드만 해당 코드에 접근할 수 있도록 만듭니다. 이를 통해 여러 스레드가 공유 자원을 안전하게 사용할 수 있도록 보호합니다.
class Counter {
private int count = 0;
// 메서드를 synchronized로 동기화
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
public class SyncProblemExample {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("결과: " + counter.getCount()); // 2000
}
}
위 예제에서 increment() 메서드에 synchronized 키워드를 사용해 동기화해 주었습니다. 이를 통해 한 번에 하나의 스레드만 이 메서드에 접근할 수 있기 때문에 예상대로 count 값 2000이 됩니다.
💁 동기화 (Synchronization)
멀티스레딩 환경에서는 여러 스레드가 동시에 같은 자원에 접근할 수 있습니다. 이때, 데이터의 일관성을 보장하기 위해 스레드 간에 동기화가 필요합니다. 동기화는 특정 자원에 대해 여러 스레드가 동시에 접근하지 못하게 막고, 한 번에 하나의 스레드만 그 자원에 접근하도록 제어하는 방법입니다.
💁 임계영역(Critical Section)
동시에 접근해서는 안 되는 공유 자원을 사용하는 코드 영역을 임계 영역이라고 합니다. 여러 스레드가 동일한 자원에 접근할 때, 한 스레드가 임계 영역에 들어가면 다른 스레드는 그 스레드가 임계 영역에서 나올 때까지 기다려야 합니다. 동기화를 통해 이 임계 영역에 대한 보호를 할 수 있습니다.
💁 락 (Lock)
동기화의 핵심은 락(Lock)입니다. 락은 스레드가 자원에 접근할 때 자원을 잠그고, 다른 스레드가 접근하지 못하도록 막는 역할을 합니다. synchronized 키워드를 사용하면 JVM이 자동으로 락을 걸어 임계영역으로 지정하여 해당 영역을 다른 스레드가 그 블록에 들어가지 못하도록 보호할 수 있습니다. 임계 영역이 끝나면 락을 해제해 다른 스레드가 그 자원에 접근할 수 있도록 합니다.
synchronized 키워드는 자바에서 자동으로 락을 관리하지만, 좀 더 세밀하고 유연한 제어가 필요한 경우 Lock 인터페이스를 사용하면 됩니다. Lock을 직접 사용하면 락을 해제하는 시점을 명시적으로 제어할 수 있습니다.
synchronized 사용법
1. synchronized 메서드: 메서드 전체를 임계영역으로 지정
메서드 전체가 동기화되므로, 작은 부분만 동기화가 필요한 경우 비효율적일 수 있습니다.
class Counter {
private int count = 0;
// 전체 메서드를 동기화
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
2. synchronized 블록: 특정 코드 블록만 동기화할 수 있는 방법
필요한 부분만 동기화하기 때문에 더 세밀한 제어가 가능합니다. synchronized 블록은 락(lock)을 획득할 객체를 지정해야 합니다.
아래 예시에서는 increment() 메서드 전체를 동기화하지 않고, 실제로 공유 자원(count)에 접근하는 부분만 동기화합니다. lock 객체는 하나의 스레드만 이 블록을 실행할 수 있도록 임계영역을 보호하는 역할을 합니다.
class Counter {
private int count = 0;
private final Object lock = new Object(); // 동기화를 위한 객체
// 특정 블록만 동기화
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
return count;
}
}
스레드 우선순위
자바에서는 각 스레드에 우선순위를 부여할 수 있습니다. 우선순위는 스레드 스케줄링에서 어떤 스레드를 먼저 실행할지 결정하는 데 사용됩니다. 우선순위는 숫자로 표현되며, Thread.MIN_PRIORITY (1)에서 Thread.MAX_PRIORITY (10) 까지의 값을 가집니다. 기본적으로 모든 스레드는 Thread.NORM_PRIORITY (5)를 가집니다.
Thread thread1 = new Thread(() -> {// 스레드 작업 내용});
Thread thread2 = new Thread(() -> {// 스레드 작업 내용});
thread1.setPriority(Thread.MIN_PRIORITY); // 우선순위를 1로 설정
thread2.setPriority(Thread.MAX_PRIORITY); // 우선순위를 10으로 설정
- 우선순위는 권장 사항입니다. JVM이 스레드를 스케줄링할 때 우선순위를 고려하지만, 이는 반드시 지켜지는 것이 아닙니다. 스레드 우선순위는 운영체제의 스케줄러에 따라 다르게 처리될 수 있습니다.
- 높은 우선순위를 가진 스레드는 낮은 우선순위의 스레드보다 먼저 실행될 가능성이 높습니다. 그러나 이것이 절대적인 게 아니라, 모든 스레드가 CPU 시간을 얻기 때문에 우선순위가 낮다고 해서 절대 실행되지 않는 것은 아닙니다.
- 스레드의 우선순위는 스레드를 생성한 스레드로부터 상속받습니다. 즉, 스레드의 기본 우선순위가 5인 이유는 main() 메서드를 수행하는 스레드의 우선순위가 5이기 때문입니다.
스레드 상태
자바의 스레드는 다양한 상태를 가지며, 이 상태를 이해하는 것은 멀티스레딩 프로그래밍을 할 때 중요합니다. 자바의 스레드는 다음과 같은 6가지 상태를 가집니다.
- NEW: 스레드가 생성되었으나 시작하지 않은 상태.
- RUNNABLE: 스레드가 실행 중이거나 실행 가능 상태.
- BLOCKED: 동기화된 블록에 접근하려고 락을 기다리는 상태.
- WAITING: 다른 스레드의 작업을 기다리며 대기하는 상태.
- TIMED_WAITING: 지정된 시간 동안 대기하는 상태.
- TERMINATED: 스레드의 실행이 종료된 상태.
1. New (새로운 상태)
스레드가 생성되었지만, 아직 start() 메서드가 호출되지 않은 상태입니다. 이 상태에서는 아직 스레드가 실행되지 않습니다.
Thread thread = new Thread(() -> {
System.out.println("Thread");
});
// 아직 start()를 호출하지 않았으므로 NEW 상태
2. RUNNABLE (실행 대기 상태)
start() 메서드가 호출되면 스레드는 RUNNABLE 상태가 됩니다. 이 상태는 실제로 실행 중이거나, CPU 할당을 대기하고 있는 상태를 포함합니다. 스레드는 스케줄러에 의해 선택되어 실행되며, run() 메서드의 코드를 처리합니다.
Thread thread = new Thread(() -> {
System.out.println("Thread");
});
thread.start(); // 스레드는 RUNNABLE 상태가 되어 CPU 할당을 기다리거나 실행됨
3. BLOCKED (차단된 상태)
스레드가 임계 영역에 대한 락(lock)을 얻기 위해 대기할 때 BLOCKED 상태에 놓입니다. 즉, 다른 스레드가 락을 소유하고 있어서, 그 스레드가 락을 해제할 때까지 기다립니다.
public class BlockedExample {
private static final Object lock = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lock) {
System.out.println("Thread 1");
try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); }
}
});
Thread t2 = new Thread(() -> {
synchronized (lock) {
System.out.println("Thread 2");
}
});
t1.start();
t2.start(); // t2는 t1이 락을 해제할 때까지 BLOCKED 상태로 대기함
}
}
t2는 t1이 lock을 해제할 때까지 BLOCKED 상태로 대기합니다.
4. WAITING (대기 상태)
스레드가 명시적으로 다른 스레드의 작업이 끝나기를 기다리는 상태입니다. Object.wait(), Thread.join(), Lock.await() 등의 메서드를 호출하면 이 상태에 진입할 수 있습니다. 이 상태에서는 명시적인 신호를 받지 않으면 계속 대기합니다. 이 상태에 있는 스레드는 다른 스레드가 notify(), notifyAll()을 호출할 때까지 깨어나지 않습니다.
Thread t1 = new Thread(() -> {
try {
System.out.println("Thread 1 waiting for Thread 2");
t2.join(); // t1은 t2가 끝날 때까지 WAITING 상태
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(2000);
System.out.println("Thread 2 completed");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
t2.start();
t1은 t2가 끝날 때까지 WAITING 상태로 대기합니다.
5. TIMED_WAITING (제한된 대기 상태)
스레드가 지정된 시간 동안 대기하는 상태입니다. Thread.sleep(), Object.wait(long timeout), Thread.join(long millis), Lock.tryLock(long timeout) 등의 메서드를 통해 시간제한을 두고 스레드를 대기시키실 수 있습니다. 지정된 시간이 지나면 다시 RUNNABLE 상태로 돌아갑니다.
Thread t1 = new Thread(() -> {
try {
Thread.sleep(2000);
System.out.println("Thread");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
t1은 2초 동안 TIMED_WAITING 상태에 있다가 다시 RUNNABLE 상태로 돌아옵니다.
6. TERMINATED (종료 상태)
스레드가 작업을 완료하거나, 예외가 발생하여 종료된 상태입니다. 한 번 종료된 스레드는 다시 시작할 수 없습니다.
Thread t1 = new Thread(() -> {
System.out.println("Thread");
});
t1.start();
try {
t1.join(); // t1이 종료될 때까지 대기
System.out.println("Thread has terminated");
} catch (InterruptedException e) {
e.printStackTrace();
}
t1이 run() 메서드를 끝내면 TERMINATED 상태로 전환됩니다.
스레드 상태 제어 메서드
1. start(): 스레드를 실행 대기 상태로 전환
스레드는 NEW 상태에서 start() 메서드를 호출해야 RUNNABLE 상태로 진입하여 실행 준비를 합니다. 이때, run() 메서드가 실행됩니다.
class MyThread extends Thread {
public void run() {
System.out.println("Thread");
}
}
public class ThreadExample {
public static void main(String[] args) {
MyThread thread = new MyThread(); // NEW 상태
thread.start(); // 실행 대기(Runnable) 상태가 됨
}
}
2. sleep(long millis): 스레드를 일시적으로 중지
Thread.sleep() 메서드를 사용하면 지정된 시간 동안 스레드를 일시 정지시킬 수 있습니다. 이때 스레드는 TIMED_WAITING 상태로 전환됩니다. 시간이 지나면 다시 RUNNABLE 상태가 됩니다.
class SleepExample extends Thread {
public void run() {
try {
for (int i = 1; i <= 5; i++) {
System.out.println(i);
Thread.sleep(1000); // 1초 동안 스레드를 정지
}
} catch (InterruptedException e) {
System.out.println("Thread interrupted");
}
}
}
public class ThreadExample {
public static void main(String[] args) {
SleepExample t1 = new SleepExample();
t1.start();
}
}
3. join(): 다른 스레드의 종료를 기다림
join() 메서드를 사용하면 현재 실행 중인 스레드는 다른 스레드가 종료될 때까지 기다립니다. 즉, WAITING 상태로 전환됩니다. 즉 스레드 자신이 하던 작업을 잠시 멈추고 다른 스레드가 지정된 시간동안 작업을 수행하도록 할 때 join()을 사용합니다. 이 방법을 사용하면 특정 스레드의 작업이 완료될 때까지 기다릴 수 있습니다.
class JoinExample extends Thread {
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println(i);
try {
Thread.sleep(500); // 각 숫자 출력 후 0.5초 대기
} catch (InterruptedException e) {
System.out.println(e);
}
}
}
}
public class ThreadExample {
public static void main(String[] args) {
JoinExample t1 = new JoinExample();
JoinExample t2 = new JoinExample();
t1.start();
try {
t1.join(); // t1이 끝날 때까지 t2는 실행되지 않습니다.
} catch (InterruptedException e) {
System.out.println(e);
}
t2.start();
}
}
4. yield(): 스레드 실행 양보
현재 실행 중인 스레드가 다른 동등한 우선순위의 스레드에게 CPU 제어권을 넘겨줍니다. RUNNABLE 상태가 유지되지만, 다른 스레드가 실행될 수 있습니다. 다만 스레드 스케줄러가 어떤 스레드를 다시 선택할지는 보장되지 않습니다.
class YieldExample extends Thread {
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println("Thread " + i);
Thread.yield(); // 실행을 양보하고 다시 runnable 상태로 전환
}
}
}
public class ThreadExample {
public static void main(String[] args) {
YieldExample thread1 = new YieldExample();
YieldExample thread2 = new YieldExample();
thread1.start();
thread2.start();
}
}
5. wait()와 notify(): 스레드 동기화
wait()는 스레드를 대기 상태로 만들고, notify()는 대기 중인 스레드를 깨워서 실행하게 합니다. 두 메서드는 주로 동기화 블록(synchronized block) 내에서 사용됩니다. 스레드는 wait()는 모니터 락을 해체 하고 다른 스레드가 락을 얻을 수 있게 만듭니다. notify() 나 notifyAll() 이 호출될 때까지 해당 스레드는 대기하게 됩니다.
예시를 하나 들어보도록 하겠습니다. 전형적인 멀티스레딩 형태로, 생산자 스레드가 데이터를 생성하고, 소비자 스레드가 그 데이터를 소비하는 상황입니다. 생산자는 데이터를 만들 때까지 소비자가 대기하도록 wait()를 사용하고, 데이터가 준비되면 notify()로 소비자를 깨워서 데이터를 사용하게 합니다.
class DataBuffer {
private String data;
private boolean hasData = false;
// 데이터를 생성하는 메서드 (생산자)
public synchronized void produce(String newData) throws InterruptedException {
// 이미 데이터가 있으면 대기
while (hasData) {
wait(); // 소비자가 데이터를 소비할 때까지 대기
}
// 데이터 생성
data = newData;
hasData = true;
System.out.println("Produced: " + data);
// 데이터를 생산했으니 소비자를 깨움
notify();
}
// 데이터를 소비하는 메서드 (소비자)
public synchronized String consume() throws InterruptedException {
// 데이터가 없으면 대기
while (!hasData) {
wait(); // 생산자가 데이터를 생산할 때까지 대기
}
// 데이터 소비
String consumedData = data;
hasData = false;
System.out.println("Consumed: " + consumedData);
// 데이터를 소비했으니 생산자를 깨움
notify();
return consumedData;
}
}
public class ProducerConsumerExample {
public static void main(String[] args) {
DataBuffer buffer = new DataBuffer();
// 생산자 스레드
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 5; i++) {
buffer.produce("Data " + i);
Thread.sleep(500); // 0.5초마다 생산
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
// 소비자 스레드
Thread consumer = new Thread(() -> {
try {
for (int i = 1; i <= 5; i++) {
buffer.consume();
Thread.sleep(1000); // 1초마다 소비
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
// 스레드 시작
producer.start();
consumer.start();
}
}
6. interrupt() 메서드: 스레드에 인터럽트 발생시키기
interrupt()는 스레드의 인터럽트 상태를 설정하여, 특정 조건에서 스레드를 중단시키는 역할을 합니다. 주로 sleep(), wait(), join() 등으로 일시 정지된 상태에서 사용됩니다. 인터럽트가 발생하면 InterruptedException이 발생하여 해당 스레드는 일시 정지 상태에서 벗어나고 예외를 처리합니다.
public class InterruptExample extends Thread {
public void run() {
try {
Thread.sleep(5000);
System.out.println("Thread completed.");
} catch (InterruptedException e) {
System.out.println("Thread interrupted.");
}
}
public static void main(String[] args) {
InterruptExample t1 = new InterruptExample();
t1.start();
try {
Thread.sleep(2000); // 2초 후에 interrupt
t1.interrupt(); // 스레드 중단
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
스레드 그룹
스레드 그룹(Thread Group)은 Java에서 여러 스레드를 논리적으로 그룹화하여 관리할 수 있도록 해주는 기능입니다. ThreadGroup 클래스를 이용해 스레드를 그룹으로 묶어 관리하며, 그룹 내의 스레드를 일괄적으로 제어하거나 상태를 확인할 수 있습니다. 스레드는 반드시 하나의 스레드 그룹에 속해야 하며, 명시적으로 지정하지 않으면 자신을 생성한 스레드의 그룹에 속하게 됩니다. JVM이 실행되면 기본적으로 system 스레드 그룹과 그 하위 그룹인 main 스레드 그룹을 생성하고, 각각의 스레드를 적절한 그룹에 포함시킵니다. 예를 들어, 메인 스레드는 main 스레드 그룹에 속합니다.
ThreadGroup mythreadGroup = new ThreadGroup("myThreadGroup");
스레드 풀(Thread Pool)
스레드(Thread)를 단순히 사용하게 되면 다음과 같은 문제가 발생합니다.
- 스레드(Thread)는 생성비용이 크기 때문에, 스레드를 사용할 때마다 새로운 스레드를 생성하게 되면, 자원 낭비와 성능 저하를 초래합니다.
- 처리 속도보다 요청이 더 많이 들어오게 된다면 새로운 스레드가 계속해서 생성되게 됩니다. 스레드가 많아 질수록 메모리를 차지하고 Context Switching이 자주 발생하여 CPU 오버헤드가 증가하게 됩니다.
- 개발자가 스레드를 직접 관리해야 했기에 개발자가 코드에서 스레드 종료와 스케줄링을 처리해야 했습니다.
이러한 문제를 개선하기 위해 스레드풀(Thread Pool) 기법을 사용합니다. 스레드풀이란 미리 일정 수의 스레드를 생성해 두고 작업 요청이 들어오면 재사용하는 방식입니다. 이를 통해 스레드 생성 비용을 줄이고, 스레드 수를 제어해 시스템 자원을 절약할 수 있습니다.
스레드풀에 작업 요청이 들어오면 작업 큐에 작업 요청이 쌓이게 되고, 스레드풀 안에 스레드들이 작업 큐 안에 작업들을 처리합니다.
즉 스레드풀을 통해 기존에 단순하게 스레드를 사용했을 때의 문제점을 해결할 수 있습니다.
- 미리 만들어 놓은 스레드를 재사용하기 때문에 새로운 스레드를 생성하는 비용을 줄일 수 있습니다.
- 사용할 스레드 개수를 제한할 수 있기 때문에 스레드가 계속해서 생성되는 것을 방지하여, 메모리 문제 및 CPU 오버헤드로부터 안전합니다.
- 스레드풀을 통해 스레드 관리가 보다 간편해졌습니다.
Java에서 ExecutorService를 통해 스레드풀을 구현할 수 있습니다. 이 인터페이스는 Java 5부터 도입된 java.util.concurrent 패키지의 일부로, 스레드를 직접 생성하는 대신 작업을 실행하는 메커니즘을 제공합니다.
Executors 클래스
Executors는 스레드풀을 쉽게 생성할 수 있는 유틸리티 클래스입니다. 이 클래스는 스레드풀 생성에 필요한 복잡한 작업을 간단하게 처리해 주는 여러 가지 정적 메서드를 제공합니다.
- newFixedThreadPool(int nThreads): 고정된 개수의 스레드로 구성된 스레드풀을 생성합니다.
- newCachedThreadPool(): 필요한 만큼 스레드를 생성하고, 유휴 상태가 되면 스레드를 제거하는 캐시형 스레드풀을 생성합니다.
- newSingleThreadExecutor(): 하나의 스레드로만 작업을 처리하는 스레드풀을 생성합니다.
- newScheduledThreadPool(int corePoolSize): 주기적으로 실행할 수 있는 작업을 스케줄링하는 스레드풀을 생성합니다.
// Runnable 인터페이스를 구현한 작업 생성
class MyRunnable implements Runnable {
public void run() {
System.out.println("Runnable 작업 실행 중...");
}
}
ExecutorService threadPool = Executors.newFixedThreadPool(3); // 고정 크기의 스레드풀 생성
threadPool.execute(new MyRunnable()); // 작업 제출
threadPool.shutdown(); // 스레드풀 종료
Executors 자체는 작업을 제출하거나 관리하는 메서드를 가지고 있지 않습니다. Executors클래스의 내부에서는 ThreadPoolExecutor를 사용해서 실제로 스레드풀을 생성합니다. 하지만 사용자는 그 내부 구현을 알 필요 없기 때문에 Executors의 정적 메서드를 호출하면, 구현체인 ThreadPoolExecutor가 아닌 인터페이스인 ExecutorService 객체를 반환합니다.
예를 들어, Executors.newFixedThreadPool(3)을 호출하면, 내부적으로는 ThreadPoolExecutor 객체가 생성되어 반환됩니다. 그러나 반환 타입은 ThreadPoolExecutor가 아닌 ExecutorService 인터페이스를 따르게 됩니다. 즉, 실제 구현체는 ThreadPoolExecutor이지만, 반환되는 타입은 ExecutorService로, 스레드풀의 기본적인 작업 제출 및 관리를 제공합니다.
ThreadPoolExecutor 클래스
Executors는 내부적으로 ThreadPoolExecutor를 사용하여 스레드풀을 생성하고 관리한다 하였습니다. ThreadPoolExecutor는 스레드풀의 세부 사항을 세밀하게 제어할 수 있는 직접적인 클래스입니다. 예를 들어, 핵심 스레드 수, 최대 스레드 수, 작업 큐, 스레드 유지 시간 등을 모두 직접 지정할 수 있습니다.
- corePoolSize: 기본적으로 유지할 스레드의 수
- maximumPoolSize: 최대 스레드 수, 요청이 많이 들어오게 되면 maximumPoolSize까지 스레드의 개수가 늘어납니다.
- keepAliveTime: keepAliveTime 시간 동안 workQueue에 작업이 없으면 corePoolSize만큼 다시 스레드 수가 줄어듭니다.
- workQueue: 대기 중인 작업을 저장하는 큐
- threadFactory: 스레드를 생성하는 방식을 커스터마이징 할 수 있는 팩토리
- handler: 작업이 큐에 꽉 찼을 때 처리하는 방식 (예: RejectedExecutionHandler)
이 클래스는 Executors 내부적으로 사용되기 때문에, 사용자가 직접 세부 사항을 조정할 필요는 없습니다. 예를 들어, Executors.newSingleThreadExecutor()는 corePoolSize와 maxPoolSize가 1인 ThreadPoolExecutor입니다.
하지만 기본 설정 이상의 스레드풀 세부 사항을 커스터마이징해야 하거나, 스레드 풀의 성능 최적화나 특정 조건에서의 동작을 제어할 필요가 있는 경우 ThreadPoolExecutor를 직접 사용하면 됩니다.
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, // corePoolSize
5, // maximumPoolSize
60, // keepAliveTime
TimeUnit.SECONDS, // 시간 단위
new LinkedBlockingQueue<>(100) // 작업 큐
);
executor.execute(new MyRunnable("Task 1"));
executor.shutdown();
Executors와 ThreadPoolExecutor 무엇을 사용할까?
- 간단하고 빠르게 스레드풀을 사용하고 싶다면 Executors를 사용하면 됩니다. 예를 들어, 서버에서 제한된 수의 요청을 처리하거나, 캐시 된 스레드를 사용할 때는 Executors의 newFixedThreadPool() 또는 newCachedThreadPool()과 같은 메서드가 적합합니다. Executors도 내부적으로는 ThreadPoolExecutor로 동작합니다.
- 세밀한 제어가 필요한 스레드풀 생성 시 ThreadPoolExecutor를 직접 사용하면 됩니다. 스레드풀을 정밀한 제어가 필요하거나 성능을 최적화해야 하는 경우에 ThreadPoolExecutor가 더 적합합니다.
ExecutorService 인터페이스
ExecutorService는 스레드풀을 관리하고 작업을 실행할 수 있도록 하는 핵심 인터페이스입니다. 스레드풀을 관리하는 거뿐만 아니라, 작업을 실행하며, 작업이 완료되면 결과를 받을 수 있는 비동기 메커니즘을 제공합니다. 다양한 구현체가 존재하고, 대표적으로 ThreadPoolExecutor가 있습니다.
ExecutorService의 주요 메서드
1. execute(Runnable command)
Runnable 작업을 스레드풀에 제출하여 실행합니다. 반환 값이 없으며, 작업은 비동기적으로 실행됩니다.
ExecutorService executorService = Executors.newFixedThreadPool(2);
executorService.execute(() -> {
System.out.println("Runnable task 실행 중");
});
executorService.shutdown();
2. submit(Runnable task)
Runnable 작업을 스레드풀에 제출하고, 결과를 받을 수 있는 Future<?> 객체를 반환합니다. Runnable은 값을 반환하지 않기 때문에 Future의 결과는 null입니다.
Future<?> future = executorService.submit(() -> {
try {
Thread.sleep(3000);
System.out.println("작업 완료");
} catch (InterruptedException e) {
System.out.println("작업 중단됨");
}
});
// 작업이 완료되었는지 확인
if (future.isDone()) {
System.out.println("작업 완료");
}
3. submit(Callable<T> task)
Callable 작업을 제출하여 결과를 반환받을 수 있습니다. Callable은 Runnable과 달리 값을 반환하며, 이 값을 Future 객체로 비동기적으로 받을 수 있습니다.
Callable<Integer> task = () -> {
Thread.sleep(1000); // 작업 수행 중
return 5; // 결과 반환
};
Future<Integer> future = executorService.submit(task);
try {
System.out.println("결과: " + future.get()); // 작업 완료 후 결과 반환
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
executorService.shutdown();
💁 submit(Runnable)을 사용하는 이유?
submit(Runnable) 메서드를 사용하면 Runnable 작업의 결괏값이 없기 때문에 Future 객체의 반환값이 null이 됩니다. 그럼에도 불구하고 submit(Runnable)을 사용하는 중요한 이유는, 반환된 Future 객체를 통해 작업의 상태를 관리할 수 있기 때문입니다.
● 작업 완료 여부 확인: Future.isDone() 메서드를 통해 해당 작업이 완료되었는지 확인할 수 있습니다.
● 작업 취소: Future.cancel() 메서드를 통해 작업을 중간에 취소할 수 있습니다. 작업이 아직 실행되지 않았거나, 실행 중인 작업을 취소할 수 있는 경우가 있습니다.
● 취소 상태 확인: Future.isCancelled() 메서드를 사용해 해당 작업이 취소되었는지 여부를 확인할 수 있습니다.
이러한 이유로, 결과가 필요 없는 Runnable 작업이라도 submit()을 사용해 작업의 제어와 상태 추적이 가능하므로 유용합니다. execute(Runnable) 메서드는 단순히 작업을 실행만 할 뿐, 작업의 상태나 완료 여부를 추적할 수 있는 추가적인 기능을 제공하지 않습니다. 따라서 작업의 상태를 추적하고 싶을 때는 submit(Runnable)을 사용하는 것이 더 적합합니다.
💁 Callable이 있는데 왜 Runnable을 사용할까?
Callable<V> 인터페이스는 call() 메서드를 통해 결과를 반환할 수 있으며, 체크드 예외(Checked Exception)도 던질 수 있습니다. Callable은 복잡한 작업이나 결과가 필요한 경우에 적합합니다. 예를 들어, 데이터베이스에서 값을 조회하거나 계산이 필요한 작업에서는 Callable을 사용해 작업의 결과를 얻고, 작업 중 발생할 수 있는 예외도 처리할 수 있습니다.
Runnable은 run() 메서드로 작업을 수행하지만 결괏값을 반환하지 않습니다. 또한 체크드 예외를 던질 수 없으므로, 예외 처리도 내부적으로 try-catch로 감싸야합니다. 그럼에도 불구하고 왜 Runnable을 사용할까요?
Callable이 결과 반환과 예외 처리를 지원하기 때문에 메모리와 리소스를 추가로 사용합니다. Runnable은 결과를 반환하지 않기 때문에 더 적은 리소스를 사용해 작업을 수행하므로 단순한 작업에 Runnable이 더 적합합니다. 또한 Callable은 예외를 던질 수 있지만, Runnable은 예외를 직접 던지지 않기 때문에 복잡한 예외 처리가 필요 없는 경우 더 간편합니다.
결과가 필요 없고, 예외를 따로 처리할 필요가 없거나, 간단한 작업을 수행할 때 Runnable을 사용합니다. 이를 통해 메모리와 리소스를 절약하며 복잡성을 줄일 수 있습니다.
4. invokeAll(Collection<? extends Callable<T>> tasks)
여러 Callable 작업을 스레드풀에 제출하고, 모든 작업이 완료될 때까지 대기한 후, 각 작업의 결과를 List<Future<T>>로 반환합니다.
List<Callable<String>> tasks = Arrays.asList(
() -> "Task 1 완료",
() -> "Task 2 완료",
() -> "Task 3 완료"
);
try {
List<Future<String>> results = executorService.invokeAll(tasks);
for (Future<String> result : results) {
System.out.println(result.get()); // 각 작업의 결과 출력
}
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
5. invokeAny(Collection<? extends Callable<T>> task)
여러 Callable 작업을 스레드풀에 제출하고, 가장 먼저 완료된 작업의 결과를 반환합니다. 나머지 작업은 취소됩니다.
List<Callable<String>> tasks = Arrays.asList(
() -> {
Thread.sleep(2000);
return "Task 1 완료";
},
() -> {
Thread.sleep(1000);
return "Task 2 완료";
},
() -> "Task 3 완료"
);
try {
String result = executorService.invokeAny(tasks);
System.out.println("가장 빨리 완료된 작업: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
6. shutdown()
스레드풀의 정상적인 종료를 요청합니다. 현재 실행 중인 작업은 완료되지만, 새로운 작업을 더 이상 받지 않습니다.
executorService.shutdown();
7. shutdownNow()
스레드풀을 즉시 종료하고, 대기 중인 모든 작업을 중단시킵니다.
List<Runnable> unfinishedTasks = executorService.shutdownNow(); // 미완료 작업 목록 반환
8. isShutdown()
스레드풀이 종료 요청(shutdown() 또는 shutdownNow())을 받았는지 확인합니다.
if (executorService.isShutdown()) {
System.out.println("스레드풀이 종료되었습니다.");
}
9. isTerminated()
스레드풀의 모든 작업이 종료되었는지 확인합니다.
if (executorService.isTerminated()) {
System.out.println("모든 작업이 완료되었습니다.");
}
10. awaitTermination(long timeout, TimeUnit unit)
스레드풀이 지정된 시간 내에 종료되기를 대기합니다. 시간이 초과되면 메서드는 false를 반환하고, 스레드풀이 종료되면 true를 반환합니다.
try {
if (executorService.awaitTermination(5, TimeUnit.SECONDS)) {
System.out.println("모든 작업이 완료되었습니다.");
} else {
System.out.println("시간 초과로 작업을 종료합니다.");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
Future 객체
Future는 Java의 java.uti.concurrent 패키지에서 제공하는 비동기 작업의 결과를 나타내는 인터페이스입니다. Future 객체를 통해 여러 작업을 할 수 있습니다.
- 작업의 상태 확인: 작업이 완료되었는지, 진행 중인지 확인할 수 있습니다.
- 결과 가져오기: 작업이 완료되면 결과를 반환받을 수 있습니다. (단 Runnable의 경우 결과가 없기 때문에 null이 반환됩니다.)
- 작업 취소: 아직 실행 중인 작업을 취소할 수 있습니다.
- 비동기 결과 처리: 작업이 완료되기 전까지 결과를 기다리지 않고 다른 작업을 진행하다가, 나중에 결과를 받을 수 있습니다.
1. get()
작업이 완료될 때까지 기다린 후, 작업의 결과를 반환합니다. 만약 작업이 완료되지 않았다면 이 메서드는 블록(blocking) 됩니다.
Future<Integer> future = executorService.submit(() -> 5); // Callable 작업 제출
try {
Integer result = future.get(); // 작업이 완료될 때까지 대기 후 결과 반환
System.out.println("작업 결과: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
2. get(long timeout, TimeUnit unit)
지정한 시간 동안만 기다리고, 그 시간이 지나면 TimeoutException을 발생시킵니다.
try {
Integer result = future.get(1, TimeUnit.SECONDS); // 1초 대기 후 결과 반환
System.out.println("작업 결과: " + result);
} catch (TimeoutException e) {
System.out.println("시간 초과로 작업이 완료되지 않음");
}
3. isDone()
작업이 완료 여부를 확인합니다. 완료되었으면 true를 반환하고, 그렇지 않으면 false를 반환합니다.
if (future.isDone()) {
System.out.println("작업 완료됨");
} else {
System.out.println("작업 아직 진행 중");
}
4. isCancelled()
작업 취소 여부를 확인합니다. 취소되었으면 true, 그렇지 않으면 false를 반환합니다.
if (future.isCancelled()) {
System.out.println("작업이 취소됨");
}
5. cancel(boolean mayInterruptIfRunning)
작업을 취소합니다. mayInterruptIfRunning이 true일 경우, 실행 중인 작업도 취소하려고 시도합니다. 이미 완료된 작업은 취소할 수 없습니다.
boolean cancelled = future.cancel(true); // 실행 중인 작업도 취소 시도
if (cancelled) {
System.out.println("작업이 취소됨");
}
'Java' 카테고리의 다른 글
Java Enum 완벽 이해하기: 상수 관리를 위한 도구 (0) | 2024.10.07 |
---|---|
Java 제네릭(Generic) & 와일드카드 완벽 이해하기 (0) | 2024.10.05 |
Java 멀티스레드 환경에서 Collection 사용: 동기화된 컬렉션과 Concurrent 컬렉션 (1) | 2024.09.26 |
Java 컬렉션 프레임워크 완벽 이해하기 (0) | 2024.09.26 |
Java 날짜 및 시간 포맷 다루기. SimpleDateFormat, DateTimeFormatter, FastDateFormat (1) | 2024.09.24 |