자바의 멀티 쓰레드 프로그래밍에 대해 학습
1. Thread 클래스와 Runnable 인터페이스
2. 쓰레드의 상태
3. 쓰레드의 우선순위
4. Main 쓰레드
5. 동기화
6. 데드락
Thread클래스는 자바에서 스레드를 생성하고 제어할 수 있게 해주는 클래스입니다. 스레드는 프로그램 내에서 독립적으로 실행될 수 있는 작은 단위의 작업을 말합니다.
사용방법
Thread 클래스를 상속받아 새로운 클래스를 정의하고, run 메서드를 오버라이드하여 실행할 코드를 작성합니다.
Thread 객체를 생성한 후 start 메서드를 호출하면, 새로운 스레드가 시작되고 run 메서드 내의 코드가 실행됩니다.
class MyThread extends Thread {
public void run() {
// 스레드에서 실행할 코드
System.out.println("스레드 실행 중...");
}
}
public class Main {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
// 새로운 스레드를 시작
}
}
Runnable인터페이스는 스레드로 실행할 작업을 정의하는 데 사용됩니다.
Runnable인터페이스를 구현한 클래스는run메서드를 오버라이드해야 합니다.Runnable은 스레드를 생성하는Thread클래스와 함께 사용됩니다.
사용방법
Runnable 인터페이스를 구현하는 클래스를 정의하고, run 메서드를 오버라이드하여 실행할 코드를 작성합니다.
Thread 객체를 생성할 때 Runnable 객체를 전달하고, start 메서드를 호출하여 스레드를 시작합니다.
class MyRunnable implements Runnable {
public void run() {
// 스레드에서 실행할 코드
System.out.println("Runnable 실행 중...");
}
}
public class Main {
public static void main(String[] args) {
MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable);
thread.start(); // 새로운 스레드를 시작
}
}
Thread 클래스는 스레드를 생성하고 제어하는 기능을 포함하고 있고, 이 클래스를 상속받으면 다른 클래스를 상속받을 수 없습니다. 자바는 단일 상속만 지원하기 때문입니다.Runnable 인터페이스는 스레드로 실행할 작업만을 정의하며, 클래스를 다른 클래스에서 상속받는 것이 가능합니다. 이렇게 하면 보다 유연하게 코드를 작성할 수 있습니다.보통 Runnable 인터페이스를 구현하여 사용하는 방법이 더 많이 권장됩니다. 이렇게 하면 스레드와 실행 로직을 분리할 수 있고, 더 유연하고 재사용 가능한 코드를 작성할 수 있습니다.
왜 Runnable이 더 나은 선택일까?
단일 상속의 제약 회피
자바는 단일 상속만 지원하므로, Thread 클래스를 상속받으면 다른 클래스를 상속받을 수 없습니다. 반면, Runnable 인터페이스는 다른 클래스를 상속받으면서 동시에 구현할 수 있습니다.
유연한 설계
Runnable 인터페이스를 사용하면 실행 로직을 정의하는 클래스와 스레드 실행을 관리하는 클래스를 분리할 수 있습니다. 이렇게 하면 코드의 모듈성이 높아지고 재사용성이 증가합니다.
결론적으로, Runnable 인터페이스를 사용하면 코드의 유연성과 재사용성이 높아지고, 불필요한 중복을 줄일 수 있습니다.
Thread를 사용하는 경우
Thread 클래스의 메서드를 오버라이드할 필요가 있는 경우
Thread 클래스에는 run 메서드 외에도 여러 가지 메서드가 있습니다. 이 중 특정 메서드를 오버라이드하여 고유한 기능을 추가하고자 할 때 Thread 클래스를 상속받을 수 있습니다.
Thread의 일부 기능을 변경해야 하는 경우
Thread 클래스의 특정 기능을 변경하거나 확장해야 할 때도 상속이 필요할 수 있습니다.
예시는 아래와 같습니다.
class CustomThread extends Thread {
private static int threadCount = 0;
private int threadID;
public CustomThread() {
threadID = ++threadCount;
}
@Override
public void run() {
System.out.println("Thread " + threadID + " 시작");
// 실행할 로직
try {
Thread.sleep(1000); // 스레드 실행 중인 척
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread " + threadID + " 종료");
}
@Override
public void start() {
System.out.println("Thread " + threadID + " 준비 중...");
super.start();
}
}
public class Main {
public static void main(String[] args) {
CustomThread thread1 = new CustomThread();
CustomThread thread2 = new CustomThread();
thread1.start();
thread2.start();
}
}
위 코드처럼 쓰레드가 시작될 때마다 고유한 ID를 부여하고, 종료될 때 로그를 출력하도록 커스텀 할 수 있습니다.
Java의 쓰레드는 여러 가지 상태를 가질 수 있으며, 각 상태는 쓰레드가 실행중 어떤 단계에 있는지를 나타냅니다.
Thread클래스에는 쓰레드 상태를 나타내는State열거형이 정의되어 있습니다.
New
쓰레드가 생성되었지만, start 메서드가 호출되지 않은 상태입니다.
RUNNABLE
쓰레드가 실행 가능한 상태입니다. 자바 가상 머신(JVM)에서는 이 상태를 RUNNING 상태와 구별하지 않습니다.
BLOCKED
쓰레드가 동기화 블록에 들어가기 위해 대기하고 있는 상태입니다.
WAITING
쓰레드가 다른 쓰레드가 특정 작업을 완료할 때까지 대기하고 있는 상태입니다. Object.wait() 메서드나 Thread.join() 메서드, LockSupport.park() 메서드를 호출하여 이 상태로 진입할 수 있습니다.
TIMED_WAITING
쓰레드가 일정 시간 동안 기다리는 상태입니다. Thread.sleep(), Object.wait(long timeout), Thread.join(long millis), LockSupport.parkNanos(long nanos), LockSupport.parkUntil(long deadline) 메서드를 호출하여 이 상태로 진입할 수 있습니다.
TERMINATED
쓰레드가 실행을 마친 상태입니다. run 메서드가 종료되거나 예외로 인해 쓰레드가 종료된 경우 이 상태가 됩니다.
Java에서는
Threada.getState()메서드를 사용하여 쓰레드의 현재 상태를 확인할 수 있습니다.
public class ThreadStateExample {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
System.out.println("스레드 상태: " + thread.getState());
// NEW 상태
thread.start();
System.out.println("스레드 상태: " + thread.getState());
// RUNNABLE 상태
Thread.sleep(500);
System.out.println("스레드 상태: " + thread.getState());
// TIMED_WAITING 상태
thread.join();
System.out.println("스레드 상태: " + thread.getState());
// TERMINATED 상태
}
}
쓰레드에는 우선순위가 있습니다. 이 우선선위는 쓰레드가 실행될 순서를 결정하는데 사용됩니다. 우선순위는 1부터 10까지의 정수 값으로 설정할 수 있으며, 기본 우선순위는 5입니다.
Thread.MIN_PRIORITY (1) : 최소 우선순위Thread.NORM_PRIORITY (5) : 기본 우선순위Thread.MAX_PRIORITY (10) : 최대 우선순위class MyThread extends Thread {
public MyThread(String name) {
super(name);
}
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(getName() + " 실행 중, 우선순위: " + getPriority());
}
}
}
public class Main {
public static void main(String[] args) {
MyThread thread1 = new MyThread("Thread1");
MyThread thread2 = new MyThread("Thread2");
MyThread thread3 = new MyThread("Thread3");
thread1.setPriority(Thread.MIN_PRIORITY); // 우선순위 1
thread2.setPriority(Thread.NORM_PRIORITY); // 우선순위 5
thread3.setPriority(Thread.MAX_PRIORITY); // 우선순위 10
thread1.start();
thread2.start();
thread3.start();
}
}
위 코드에서 Thread1, Thread2, Thread3는 각각 다른 우선순위를 가집니다. 우선순위가 높을수록 쓰레드가 더 자주 실행될 가능성이 있지만, 이는 운영체제의 스케줄러에 따라 다를 수 있습니다.
플랫폼 의존적
Java 쓰레드 스케줄링은 운영체제에 따라 다릅니다. 일부 운영체제에서는 우선순위가 제대로 반영되지 않을 수 있습니다.
비보장성
우선순위는 쓰레드의 실행 순서를 보장하지 않습니다. 쓰레드 스케줄러가 항상 높은 우선순위의 쓰레드를 먼저 실행하는 것은 아닙니다.
복잡성 증가
우선순위 기반 스케줄링은 프로그램의 복잡성을 증가시키며, 잘못 사용하면 예상치 못한 결과를 초래할 수 있습니다.
쓰레드 우선순위는 쓰레드의 실행 순서를 결정하는 데 도움을 줄 수 있지만, 운영체제와 자바 런타임의 스케줄러에 따라 다르게 동작할 수 있습니다. 따라서 우선순위는 보조적인 수단으로 사용하고, 주요 논리 흐름은 다른 방식으로 제어하는것이 좋습니다.
Java 프로그램이 실행될 때, Java 런타임 환경(JRE)은 기본적으로 하나의 쓰레드를 생성하여 프로그램을 시작합니다. 이 쓰레드를
Main Thread라고 합니다.메인 쓰레드는
main메서드를 실행하는 쓰레드로, 모든 자바 프로그램은 메인 쓰레드로 부터 시작됩니다.
프로그램 시작
메인 쓰레드는 프로그램이 시작될 때 main 메서드를 실행합니다.
다른 쓰레드 생성
메인 쓰레드는 필요에 따라 추가로 생성되는 다른 쓰레드의 부모 쓰레드가 됩니다. 새로운 쓰레드가 생성되면 메인 쓰레드가 해당 쓰레드를 시작할 수 있습니다.
프로그램 종료
메인 쓰레드가 종료되면 프로그램이 종료됩니다. 단, 다른 사용자 정의 쓰레드가 실행 중인 경우에는 그 쓰레드들이 모두 종료될때까지 프로그램이 계속 실행됩니다.
메인 쓰레드도 다른 일반 쓰레드처럼 관리할 수 있습니다. 예를 들어, 메인 쓰레드의 우선순위를 변경하거나 메인 쓰레드의 상태를 확인할 수 있습니다.
public class MainThreadExample {
public static void main(String[] args) {
// 현재 실행 중인 쓰레드 얻기 (메인 쓰레드)
Thread mainThread = Thread.currentThread();
// 메인 쓰레드의 이름 출력
System.out.println("메인 쓰레드 이름: " + mainThread.getName());
// 메인 쓰레드의 우선순위 출력
System.out.println("메인 쓰레드 우선순위: " + mainThread.getPriority());
// 메인 쓰레드의 우선순위 변경
mainThread.setPriority(Thread.MAX_PRIORITY);
System.out.println("변경된 메인 쓰레드 우선순위: " + mainThread.getPriority());
// 메인 쓰레드가 종료되기 전에 다른 작업 수행
for (int i = 0; i < 5; i++) {
System.out.println("메인 쓰레드 작업: " + i);
}
// 메인 쓰레드가 종료되면 프로그램이 종료됨
System.out.println("메인 쓰레드 종료");
}
}
메인 쓰레드가 종료되면, JVM은 다른 실행 중인 사용자 정의 쓰레드가 있는지 확인합니다.
만약 다른 모든 사용자 정의 쓰레드가 종료된 상태라면 JVM은 프로그램을 종료합니다.
하지만, 다른 사용자 정의 쓰레드가 실행 중이라면 그 쓰레드들이 모두 종료될 때까지 프로그램은 계속 실행됩니다.
public class MainThreadExample {
public static void main(String[] args) {
// 새로운 쓰레드 생성 및 시작
Thread newThread = new Thread(() -> {
for (int i = 0; i < 5; i++) {
System.out.println("새로운 쓰레드 작업: " + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("새로운 쓰레드 종료");
});
newThread.start();
// 메인 쓰레드 작업
for (int i = 0; i < 3; i++) {
System.out.println("메인 쓰레드 작업: " + i);
}
System.out.println("메인 쓰레드 종료");
}
}
동기화는 여러 쓰레드가 동시에 공유 자원에 접근할 때 발생할 수 있는 문제를 방지하기 위한 방법입니다.
동기화를 통해 하나의 쓰레드만이 공유 자원에 접근하도록 보장할 수 있습니다.
이를 통해 데이터의 일관성과 무결성을 유지할 수 있습니다.
멀티쓰레딩 환경에서는 여러 쓰레드가 동시에 공유 자원(변수, 객체 등)에 접근하고 수정할 수 있습니다. 이로 인해 아래와 같은 문제가 발생할 수 있습니다.
데이터 일관성 문제
여러 쓰레드가 동시에 데이터를 읽고 쓰는 경우, 데이터의 일관성이 깨질 수 있습니다.
데드락(Deadlock)
두 개 이상의 쓰레드가 서로의 자원을 기다리며 무한 대기에 빠지는 현상입니다.
레이스 컨디션(Race Condition)
여러 쓰레드가 동시에 자원에 접근할 때 발생하는 문제로, 쓰레드들이 경쟁적으로 자원에 접근하여 예기치 않은 결과를 초래할 수 있습니다.
Java에서는 synchronized 키워드를 사용하여 동기화를 구현할 수 있습니다. 동기화 방법에는 두 가지 주요 방법이 있습니다. 바로 메서드 동기화와 블록 동기화입니다.
1. 메서드 동기화
메서드에 synchronized 키워드를 붙여서 동기화할 수 있습니다. 이렇게 하면 메서드가 호출될 때 해당 메서드의 객체에 대해 락(Lock)을 획득하게 되어, 다른 쓰레드가 이 메서드를 호출하려면 락을 해제할 때까지 기다려야 합니다.
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("Final count: " + counter.getCount()); // 2000
}
}
2. 블록 동기화
특정 코드 블록만 동기화할 수도 있습니다. 이 방법은 필요하지 않은 경우 전체 메서드를 동기화하는 대신, 특정 코드 블록만 동기화할 수 있어서 효율적입니다.
class Counter {
private int count = 0;
public void increment() {
synchronized (this) {
count++;
}
}
public int getCount() {
return count;
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("Final count: " + counter.getCount()); // 2000
}
}
성능 저하
동기화는 성능을 저하시킬 수 있습니다. 동기화된 메서드나 블록은 동시에 하나의 쓰레드만 접근할 수 있으므로 병렬 처리를 제한할 수 있습니다.
데드락
동기화된 코드에서 여러 락을 사용하다 보면 데드락이 발생할 수 있습니다. 데드락을 피하기 위해서는 락을 획득하는 순서를 일관되게 유지해야 합니다.
대기 시간
동기화된 메서드나 블록에서 오랜 시간 동안 작업을 수행하면 다른 쓰레드가 오랫동안 대기해야 할 수 있습니다. 이를 피하기 위해 가능한 짧은 코드 블록을 동기화하는 것이 좋습니다.
데드락(Deadlock)은 멀티쓰레드나 멀티프로세스 환경에서 발생할 수 있는 심각한 문제입니다.
데드락은 두 개 이상의 쓰레드나 프로세스가 서로 필요로 하는 자원을 무한히 기다리며 진행이 멈춘 상태를 말합니다.
각 쓰레드는 다음과 같은 조건을 만족할때 데드락에 빠집니다.
상호 배제(Mutual Exclusion)
자원은 한 번에 하나의 쓰레드나 프로세스만 사용할 수 있어야 합니다.
점유와 대기(Hold and Wait)
최소한 하나의 자원을 점유하고 있는 상태에서 다른 쓰레드나 프로세스가 점유하고 있는 자원을 기다려야 합니다.
비선점(No Preemption)
다른 쓰레드나 프로세스가 이미 점유한 자원을 강제로 빼앗아 사용할 수 없어야 합니다.
순환 대기(Circular Wait)
쓰레드나 프로세스 사이에 무한 순환 형태로 자원을 요구하는 사이클이 형성되어야 합니다.
class SharedResource {
synchronized void method1(SharedResource resource) {
System.out.println(Thread.currentThread().getName() + " 메서드 1 실행");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 메서드 1에서 method2 호출");
resource.method2(this);
System.out.println(Thread.currentThread().getName() + " 메서드 1 실행 완료");
}
synchronized void method2(SharedResource resource) {
System.out.println(Thread.currentThread().getName() + " 메서드 2 실행");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 메서드 2에서 method1 호출");
resource.method1(this);
System.out.println(Thread.currentThread().getName() + " 메서드 2 실행 완료");
}
}
public class DeadlockExample {
public static void main(String[] args) {
final SharedResource resource1 = new SharedResource();
final SharedResource resource2 = new SharedResource();
Thread thread1 = new Thread(() -> {
resource1.method1(resource2);
});
Thread thread2 = new Thread(() -> {
resource2.method2(resource1);
});
thread1.start();
thread2.start();
}
}
위 예제에서 SharedResource 클래스는 두 개의 메서드 method1과 method2를 가지고 있습니다.
각 메서드는 synchronized 키워드로 동기화 되어 있어, 한 번에 하나의 쓰레드만이 접근할 수 있습니다. 그러나 두 쓰레드가 서로의 자원을 동시에 점유하려고 하고 있습니다. 따라서 이 예제는 데드락에 빠질 가능성이 높습니다.
상호 배제 원칙 사용 최소화
락을 최소한으로 사용하고, 필요한 경우만 사용합니다.
락의 순서
여러 자원을 사용할 때 일관된 순서로 락을 획득하도록 합니다.
시간 제한
자원을 얻기 위한 대기 시간을 제한합니다. 일정 시간 내에 자원을 얻지 못한다면 다시 시도하거나 포기합니다.
락을 해제
락을 오랫동안 유지하지 않고 가능한 빨리 해제합니다.
데드락 탐지 및 복구
시스템이 데드락을 탐지하고, 탐지되면 적절히 복구할 수 있는 방법을 구현합니다.