[JAVA] 멀티 스레드의 기능과 보조 메서드들

Re_Go·2024년 6월 7일
0

JAVA

목록 보기
28/37
post-thumbnail

1. 멀티 프로세스와 멀티 스레드

여기 PC가 한 대 있습니다. 이 PC는 프로그램을 하나만 돌릴 수 있습니다. 그리고 이 프로그램은 단 하나의 작업만을 수행할 수 있죠. 이를 싱글 프로세스(single process)싱글 스레드(single thread)라고 합니다. 프로세스를 프로그램, 스레드를 작업에 비유
한 것이고요.

그렇다면 멀티 프로세스란(multi process)? 네. 한 대의 PC에서 여러 프로그램을 돌릴 수 있는 것
과 같습니다. 그리고 멀티 스레드(multi thread)하나의 프로그램에서 다수의 작업이 가능한 것을 의미
하는 셈이죠. 사진으로 보면 아래와 같이 쉽게 설명이 가능한데요.

(자료 출처 : https://thebook.io/080367/0024/)

(자료 출처 : https://wooody92.github.io/os/%EB%A9%80%ED%8B%B0-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4%EC%99%80-%EB%A9%80%ED%8B%B0-%EC%8A%A4%EB%A0%88%EB%93%9C/)

사진에서처럼 멀티 프로세스라 하더라도 어떤 프로세스는 싱글 스레드를, 또 어떤 프로세스는 멀티 스레드를 운영할 수 있는 셈인거죠.

그럼 자바와 자바스크립트로 살펴보겠습니다. 이 둘 다 컴퓨터 프로그램인데요. 자바는 멀티 스레드를 지원
하는 반면, 자바스크립트는 싱글 스레드를 지원합니다. 물론 비동기 처리로 싱글 스레드의 한계를 극복하긴 하지만요.

정리하자면, 자바는 멀티 스레드를 지원하여 동기/비동기 처리를 원활하게 수행할 수 있고, 자바스크립트는 싱글 스레드를 지원하므로 처리 속도에 어느 정도의 한계점이 존재한다고 정리할 수 있습니다.

2. 자바에서의 스레드

위에서 언급했던 자바에서의 스레드는 프로그램 안에서 실행되는 코드의 특정 진행 상황을 의미하는데요.
하는데요.

기본적인 틀은 프로그램이 실행될 때 진입점이 되는 main 메서드를 실행하는 main thread에서 시작
됩니다.

그 이후의 업무 처리는 다음 이미지를 보시는게 이해하시기 편하실 텐데요.

(자료 출처 : https://guiyomi.tistory.com/33)

앞서 소개한 자바스크립트의 실행 컨텍스트 처럼 자바 또한 특정 코드에서 새로운 작업 스레드의 시작점이 호출될 때 해당 스레드의 코드문이 시작되는데요.

여기서 자바스크립트와 다른점이라고 한다면, 해당 스택의 작업이 완전히 끝나야 다시 메인 작업으로 돌아오는 것과 달리, 자바에서는 여러 스레드에서 동시에 일을 처리(멀티 스레드의 장점) 하므로 동기 작업을 효율적으로 진행 할 수 있게 됩니다.

3. Thread 클래스 생성

이러한 스레드를 생성할 때의 이득은, 하나의 스레드에서 작업하는 것보다 훨씬 효율적으로 코드를 운용할 수 있다는 점에서 스레드 개념은 자바에서 없어서는 안될 중요한 개념인데요.

이러한 Thread를 사용자가 직접 생성하기 위해서는 우선 Runnable을 상속 받는 클래스의 메서드를 작성해 준 후 그 클래스의 객체를 생성한 후, 이 객체를 다시 매개변수로 전달하여 Thread 객체를 하나 생성해야 하는데요.

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("MyRunnable is running in a separate thread.");
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println("Main thread is starting.");

        // 새로운 스레드 생성
        Thread thread = new Thread(new MyRunnable());
        thread.start(); // 새로운 스레드에서 run 메서드 실행

        System.out.println("Main thread is running.");
    }
}

하지만 이 방법 보다는 스레드 객체를 하나 생성할 때 새로운 러너블 객체를 전달하고, 그 부분에서 바로 스레드가 실행할 코드 부분을 작성
하는 방법을 이용하는 경우가 많습니다.

public class Main {
    public static void main(String[] args) {
        // 스레드를 생성하고 실행합니다.
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                // 스레드 내부에서 작업을 실행합니다.
                for (int i = 1; i <= 5; i++) {
                    System.out.println("Thread: " + i);
                    try {
                        Thread.sleep(1000); // 1초간 대기
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        // 스레드 시작을 위한 메서드
        thread.start(); 

        // 스레드 시작 이후의 메인 스레드에서 코드를 실행합니다.
        for (int i = 1; i <= 5; i++) {
            System.out.println("Main thread: " + i);
        }
    }
}

/*결과
Main thread: 1
Thread: 1
Main thread: 2
Thread: 2
Main thread: 3
Thread: 3
Main thread: 4
Thread: 4
Main thread: 5
Thread: 5  
*/

4. Thread를 직접 상속 받는 자식 클래스를 생성

또 하나의 스레드 생성 방법은, Thread 클래스를 직접 상속 받는 자식 클래스를 생성
하는건데요.

하지만 이 경우 다른 클래스를 상속 받고 있는 자식 클래스가 Thread 클래스를 직접 상속 받을 수 없으므로 상속을 전혀 받지 않는 클래스가 Thread 클래스를 상속 받아 스레드를 생성하고자 할 때
사용되는 방법입니다.

하지만 대부분의 경우는 이미 상속 받고 있는 상태라, 기존의 Runnable 인터페이스를 구현하여 해당 객체를 생성 후 스레드를 실행하는 것이 훨씬 유연한 방법입니다.

public class MyThread extends Thread {
    @Override
    public void run() {
        // 스레드에서 실행할 작업을 여기에 작성합니다.
        System.out.println("MyThread is running.");
    }
}

public class Main {
    public static void main(String[] args) {
        // Thread를 직접 상속 받고 있는 MyThread 클래스의 인스턴스를 생성합니다.
        MyThread myThread = new MyThread();

        // 스레드를 시작합니다.
        myThread.start();
    }
}

5. Thread 메서드 종류

앞서 소개한 Thread는 당연한 메서드들을 가지고 있는데요. 이름 설정부터 상태 제어를 담당하는 메서드까지, 이번 시간에는 해당 메서드들을 알아보도록 하겠습니다.

- sleep

현재 스레드를 지정된 시간 (밀리초 단위) 동안 일시정지 시킵니다.

public class SleepExample {
    public static void main(String[] args) {
        System.out.println("Start of the program");

        try {
            // 현재 main 스레드를 2초 동안 일시 정지
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    
        System.out.println("End of the program");
    }
}

- join

해당 메서드를 호출한 스레드는 일시정지 된 후, 호출된 스레드의 작업이 종료된 뒤 다시 작업을 재개하는 메서드 입니다. 단어를 직역하여 '현재 스레드에서 특정 스레드를 호출해 자신의 우선적으로 참여(join)' 시킨다는 의미로 연결할 수 있습니다.

public class JoinExample {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            try {
                Thread.sleep(2000);
                System.out.println("Child thread finished");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        thread.start();

        try {
            thread.join(); // join 메서드를 호출한 main thread를 일시정지 하고 thread의 작업을 끝낸 뒤 다시 작업 시작
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Main thread finished");
    }
}

- wait

synchronized 블록 내에서 스레드가 특정 조건을 충족할 때까지 기다립니다.

- notify

대기 중인 스레드 중 하나를 깨웁니다.

- notifyAll

대기 중인 모든 스레드를 깨웁니다.

public class WaitNotifyExample {
    public static void main(String[] args) {
        final Object lock = new Object();
       
        Thread thread1 = new Thread(() -> {
  //임시 객체를 하나 만들어 동기화 임계점으로 지정한 뒤, 해당 영역 내에 실행할 스레드 코드문을 작성합니다.
            synchronized(lock) {
                try {
                    System.out.println("Thread 1 is waiting...");
                    lock.wait(); // 다른 스레드에서 notify() 호출할 때까지 일시정지 합니다.
                    System.out.println("Thread 1 is notified");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
		Thread thread2 = new Thread(() -> {
            synchronized(lock) {
                try {
                    System.out.println("Thread 1 is waiting...");
                    lock.wait(); // 다른 스레드에서 notify()나 notifyAll()을 호출할 때까지 일시정지 합니다.
                    System.out.println("Thread 1 is notified");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
    
        Thread thread3 = new Thread(() -> {
            synchronized(lock) {
                try {
                    Thread.sleep(2000);
                    System.out.println("Thread 2 is notifying...");
                    lock.notify(); // 무작위로 정지( wait)해 있는 스레드를 깨웁니다.
  					lock.notifyAll(); // 정지해 있는 모든 스레드를 깨웁니다.
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        thread1.start();
  		thread2.start();
        thread3.start();
    }
}

참고로 synchronized 메서드로 특정 메서드나 코드 블록 구간을 생성할 경우 여러 스레드의 개입으로 인한 부수 효과를 방지하고 보다 사용자의 의도에 맞는 연산 수행을 설계할 수 있습니다.

- interrupt

실행 중인 스레드에 interruptException 예외를 발생시켜 일시 정지 상태로 만들거나, 일시 정지상태(sleep, join, wait)에 진입한 상태의 스레드에 마찬가지로 interruptException 예외를 발생시켜 다시 실행 대기 상태로 진입시키는 메서드 입니다.

public class InterruptExample {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            try {
                // 일시 정지된 상태에서 interrupt() 메서드가 호출되면 InterruptedException이 발생합니다.
                while (!Thread.currentThread().isInterrupted()) { // 현재 실행되고 있는 호출 스레드의 상태가 실행 대기상태가 아니라면 다음 문구를 출력
                    System.out.println("Thread is running...");
                    Thread.sleep(1000); // 문구 출력 후 1초마다 메서드를 호출한 스레드를 슬립시킵니다. 
                }
            } catch (InterruptedException e) {
                // InterruptedException이 발생하면 스레드가 종료됩니다.
                System.out.println("Thread is interrupted.");
            }
        });

        thread.start(); // 스레드 시작

        // 3초 후에 thread의 interrupt를 호출하여 해당 스레드가 실행 중인 상태면 일시 정지 시키고, 일시 정지 중이라면 실행 대기 상태로 진입 시킵니다.
        try {
            Thread.sleep(3000);
            thread.interrupt(); // 스레드를 중단합니다.
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

이전에는 stop 메서드로 스레드를 종료했으나, 안전 상의 이유로 지금은 interrupt 메서드로 스레드를 종료시키는게 보편화 되었다고 합니다.

- yield

다른 스레드에게 실행을 양보하고 실행 대기 상태로 진입합니다. 일시 정지 상태를 건너띄기 때문에 다시 호출되면 언제든 시작될 수 있습니다.

// 두 스레드는 실행될 때마다 yield를 호출하고 있으므로 실행 대기에서 실행, 다시 실행에서 실행대기로 자연스럽게 번갈아가며 진행됩니다. (스레드가 3개 이상일 경우 양보 받는 스레드는 무작위)
public class YieldExample {
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println("Thread 1: " + i);
                Thread.yield(); // 다른 스레드에게 실행 기회 양보
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println("Thread 2: " + i);
                Thread.yield(); // 다른 스레드에게 실행 기회 양보
            }
        });

        thread1.start();
        thread2.start();
    }
}

이러한 실행 상태를 제어하는 메서드들은 다음 그림처럼 스레드를 특정 상태에 진입시키는 역할을 합니다.

(자료 출처 : https://widevery.tistory.com/28)

6. 스레드풀

스레드 풀은 스레드의 생성 및 제거등의 작업으로 인해 스레드가 무분별하게 증가되는 것을 방지 하기 위해 미리 적당량의 스레드를 생성하여 보관 한 후 사용자의 요청이 있을 때마다 작업을 큐에서 가져와 처리 하여 그 결과값을 반환한 뒤 사용 종료된 스레드를 풀에 보관
하는, 일종의 보관함 역할을 하는데요.

(자료 출처 : https://palpit.tistory.com/entry/Java-%EB%A9%80%ED%8B%B0-%EC%8A%A4%EB%A0%88%EB%93%9C-%EC%8A%A4%EB%A0%88%EB%93%9C%ED%92%80ThreadPool)

이러한 스레드풀을 생성하기 위해서는 ExecutorService 인터페이스Excutors 클래스의 두 메서드인 newCachedThreadPollnewFixedThreadPool(int nThreads)를 이용하여 쉽게 생성할 수 있다고 하는데요.

다음은 두 메서드로 스레드풀을 생성하는 방법입니다.

// newFixedThreadPool 메서드로 5만큼의 고정 길이를 가진 스레드풀 생성 (생성된 스레드는 제거하지 않음)
ExecutorService executor1 = Executors.newFixedThreadPool(5);

// newCachedThreadPool은 생성할 수 있는 스레드의 수가 Integer.MAX_VALUE 만큼 생성할 수 있기 때문에 사실상 제한이 없으나, 사용되지 않고 60초가 경과된 스레드는 삭제 처리를 합니다.
ExecutorService executor2 = newCachedThreadPool();

이처럼 스레드풀로 생성된 스레드는 특성상 메인 메서드가 종료되어도 스레드풀은 계속 실행중
이기 때문에 사용자가 해당 스레드들을 종료 메서드를 호출하여 종료해 주어야
합니다.

이때 사용되는 메서드는 shutdown(대기 중인 스레드는 종료하고 실행 중인 스레드는 실행이 끝난 뒤 종료)과 shutdownAll(실행 여부에 상관 없이 전부 종료)

7. Runnable과 Callable의 차이

기본적으로 스레드를 실행
하기 위해서는 Thread 클래스나 Thread 자식 클래스를 생성하는 방법이 있는데, 대부분은 Runnable과 ```Callable 인터페이스를 사용
하여 스레드를 생성합니다.

이 둘은 스레드를 생성할 때 사용된다는 함수형 인터페이스라는 점에서는 동일
하나 몇몇의 차이점을 보이는데요. 그 차이점들은 다음과 같습니다.

- Runnable

작업의 결과를 반환하지 않고 작업 실행과 즉시 종료에 목적을 둔 함수형 인터페이스로서 스레드 풀에서 실행되거나 Thread 클래스에서 사용하여 직접 실행될 수 있습니다.

생성된 Runnable 타입의 task를 executor에 제출하여 스레드 풀의 스레드들을 실행하게끔 합니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 스레드 풀 생성
        ExecutorService executor = Executors.newFixedThreadPool(5);

        // Runnable 작업 생성
        Runnable task = () -> {
            String threadName = Thread.currentThread().getName();
            System.out.println("Hello from " + threadName);
        };

        // Runnable 작업을 스레드 풀에 제출하여 for문 만큼 실행
        for (int i = 0; i < 10; i++) {
            executor.submit(task);
        }

        // 스레드 풀 종료
        executor.shutdown();
    }
}

Runnable 인터페이스를 상속 받은 MyRunnable 클래스 객체를 myRunnable 변수에 할당한 뒤 해당 변수로 스레드를 직접 생성하고 start 함수를 호출합니다.

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 스레드에서 실행될 작업을 정의합니다.
        System.out.println("MyRunnable is running...");
    }
}

public class Main {
    public static void main(String[] args) {
        Runnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start();
    }
}

- Callable

작업의 결과를 반환하는 함수형 인터페이스로 run 메서드가 아닌 call 메서드로 스레드 작업을 작성할 수 있습니다.

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        // 작업을 수행하고 결과를 반환하는 코드
        return "Callable task is running...";
    }
}

public class Main {
    public static void main(String[] args) throws Exception {
  		// MyCallable 객체를 생성하고 executor 변수에 단일 스레드풀 객체를 생성해 할당합니다. 
        Callable<String> task = new MyCallable();
        ExecutorService executor = Executors.newSingleThreadExecutor();
  		// 그리고 생성된 단일 스레드풀에 스레드 작업을 수행하는 객체의 상태를 future에서 추적하게 되고, 
        Future<String> future = executor.submit(task);

        // 작업이 완료 될 때 그 결과를 획득(get) 하여 result에 반환합니다.
        String result = future.get();
        System.out.println(result);

        // 사용이 끝난 단일 스레드 풀을 종료합니다.
        executor.shutdown();
    }
}

밑의 사진은 Runnable과 Callable의 차이를 단적으로 보여준 예시 입니다.

(자료 출처 : https://www.scaler.com/topics/callable-interface-in-java/)

또한 밑의 사진은 Runnable과 Callable 스레드를 실행하는 메서드에 따라 반환 유무의 여부가 달라지는 프로세스를 잘 보여주는 예시인데요.

그러나 기본적으로 Runnable 스레드는 실제 결과를 반환하지 않기 때문에 submit에 의해 반환값이 생겨도 실제 반환은 null을 반환하기에, 결국 execute 메서드를 사용하는 것이 더 적절하므로 밑의 사진은 참고만 해두시기 바랍니다.


(자료 출처 : https://www.javatpoint.com/difference-between-executorservice-execute-and-submit-method-in-java)

profile
인생은 본인의 삶을 곱씹어보는 R과 타인의 삶을 배워 나아가는 L의 연속이다.

0개의 댓글