본문 바로가기

개발(합니다)/Java&Spring

[java-기초-12] 멀티 스레드

반응형

멀티 스레드 개념

프로세스와 스레드

운영체제에서는 실행 중인 하나의 애플리케이션을 프로세스라고 부르며 사용자가 애플리케이션을 실행하면 운영체제로부터 실행에 필요한 메모리를 할당 받아 애플리케이션의 코들르 실행하는데 이것을 프로세스라고 부른다.
예를 들어 Chrome 브라우저를 두 개 실행했다면 두 개의 Chrome 프로세스가 실행 된 것이다.

  • 작업 관리자에서 확인 할 수 잇듯 이 1개의 프로그램의 실행으로 프로세스가 생성되고 그 안에서 스레드가 구동되고 있다.

멀티 태스킹은 두 가지 이상의 작업을 동시에 처리하는 것을 말하고 운영체제는 멀티 태스킹을 할 수 있도록 CPU 및 메모리 자원을 프로세스마다 적절히 할당해주고, 병렬로 실행시킨다.

  • 멀티 프로세스들은 운영체제에서 할당 받은 자신의 메모리를 가지고 실행하기 때문에 서로 독립적이다. 그러므로 하나의 프로세스에서 오류가 발생해도 다른 프로세스에게 영향을 미치지 않는다.
  • 멀티 스레드는 하나의 프로세스 내부에 생성되기 때문에 하나ㅡ이 스레드가 예외를 발생시키면 프로세스 자체가 종료될 수 있어서 다른 스레드에게 영향을 미칠 수 있다.
  • 멀티 스레드에서는 예외 처리에 만전을 기해야 한다.

메인 스레드

모든 자바 애플리케이션은 메인 스레드가 main() 메서드를 실행하면서 시작된다.
메인 스레드는 필요에 따라 작업 스레드들을 만들어서 병렬로 코드를 실행 할 수 있어서 멀티 스레드를 생성해 멀티 태스킹을 수행할 수 있다.

  • 싱글 스레드 애플리케이션에서는 메인 스레득 종료하면 프로세스도 종료되지만 멀티 스레드 애플리케이션에서는 실행 중인 스레드가 하나라도 있다면, 프로세스는 종료되지 않는다.

작업 스레드 생성과 실행

멀티 스레드로 실행하는 애플리케이션을 개발하려면 먼저 몇 개의 작업을 병렬로 실행할지 결정하고 각 작업별로 스레들르 생성해야 한다.

  • 어떤 작업이건 메인 스레드는 반드시 존재하므로 메인 작업 이외에 추가적인 병렬 작업의 수만큼 스레드를 생성하면 된다.

Thread 클래스로부터 직접 생성

    Thread t = new Thread();
  • 직접 생성하는 방법
public class threadTest1 implements Runnable{

    @Override
    public void run() {

    }
}
  • Runnable을 상속하는 방법
Thread t = new Thread(new Runnable() {
    @Override
     void run() {

});

Thread t2 = new Thread( () -> {
    // 실행할 코드

});
  • 가장 많이 사용되는 방법
    public static void main(String[] args) {
        Toolkit toolkit = Toolkit.getDefaultToolkit();
        for (int i = 0; i < 5; i++) {
            toolkit.beep();
            try {
                Thread.sleep(500);
            } catch (Exception e ) {}

        }

        for (int i = 0; i < 5; i++) {
            System.out.println("띵");
            try {
                Thread.sleep(500);

            }catch (Exception e) { }
        }
    }
  • 메인 스레드만을 이용한 소스
  • 메인 스레드만 이용해 비프음과 "띵"은 동시에 실행하지 않고 비프음이 완료되고 "띵"이 표시된다.
public class ThreadTest1 implements Runnable{
    @Override
    public void run() {
        Toolkit toolkit = Toolkit.getDefaultToolkit();
        for (int i = 0; i < 5; i++) {
            toolkit.beep();
            try {
                Thread.sleep(500);
            } catch (Exception e ) {}

        }
    }

    public static void main(String[] args) {
        ThreadTest1 t = new ThreadTest1();
        Thread tt = new Thread(t);
        tt.start();

        for (int i = 0; i < 5; i++) {
            System.out.println("띵");
            try {
                Thread.sleep(500);

            }catch (Exception e) { }
        }
    }
}
  • 원하는 방식으로 동작하는 코드
  • 객체를 생성하고 작업 스레드를 생성한 뒤 start() 메서드를 호출하여 작업 스레드에 의해 객체가 병렬로 수행된다.
  • 람다식과 익명의 객체로 스레드를 사용할 수도 있다.

Thread 하위 클래스로부터 생성

Runnable로 만들지 않고 Thread의 하위 클래스로 작업 스레드를 정의하면 작업 내용을 포함시킬 수도 있다.

public class ThreadTest2 extends Thread{
    @Override
    public void run() {
        // 수행 내용
    }
}
  • 사용 방법은 동일하다.

스레드의 이름

스레드는 자신의 이름을 가지고 디버깅을 할 때 어떤 스레드가 어떤 작업을 하는지 조사할 목적으로 가끔 사용된다.
메인 스레드는 "main"이라는 이름을 가지고 우리가 직접 생성한 스레드는 자동으로 "Thread-n"이라는 이름으로 설정된다.

public class ThreadTest2 {
    public static void main(String[] args) {
        Thread t = Thread.currentThread();
        System.out.println("프로그램 시작 스레드 이름 : " + t.getName());

        ThreadA tA = new ThreadA();
        System.out.println("작업 스레드 이름 : " + tA.getName());
        tA.start();
    }
}

class ThreadA extends Thread {
    public ThreadA() {
        this.setName("ThreadA");
    }
    @Override
    public void run() {
        System.out.println(this.getName());
    }
}
  • 실행한 스레드의 이름을 알 수 있다.

스레드 우선순위

스레드의 구분

  • 동시성 : 멀티 작업을 위해 하나의 코어에서 멀티 스레드가 번갈아가며 실행하는 성질
  • 병렬성 : 멀티 작업을 위해 멀티 코어에서 개별 스레드를 동시에 실행하는 성질
    싱글 코어 CPU를 이용한 멀티 스레드 작업은 병렬적으로 실행되는 것처럼 보이지만, 사실은 번갈아가며 실행하는 동시성 작업이다.

  • 스레드의 개수가 코어의 수보다 많을 경우, 스레드를 어떤 순서에 의해 동시성으로 실행할것인가를 결정해야 하는데, 이것을 스레드 스케줄링이라고 한다.

스케줄링 방식

  • 우선 순위 방식 : 우선순위가 높은 스레드가 실행 상태를 더 많이 가지도록 스케줄링

  • 순환 할당 방식 ; 시간 할당량을 정해서 하나의 스레드가 정해진 시간만큼 실행하고 다시 다른 스레드를 실행하는 방식
    스레드 우선순위 방식은 스레드 객체에 우선 순위 번호를 부여할수 있기 때문에 개발자가 코드로 제어할 수 있지만 순환 할당 방식은 자바 가상 기계에 의해서 정해지기 때문에 코드로 제어할 수 없다.

  • 우선 순위 방식에서 우선 순위는 1~10까지 부여할 수 있고 1이 가장 낮은 우선순위이고, 10이 가장 높은 우선 순위이며 기본적으로 5의 우선순위를 할당한다.

    thread.setPriority(우선순위);
    thread.setPriority(Thread.MAX_PRIORITY);
    thread.setPriority(Thread.MIN_PRIORITY);
    thread.setPriority(Thread.NORM_PRIORITY);
  • 쿼드 코어일 경우에는 4개의 스레드가 병렬성으로 실행될 수 있기 때문에 4개 이하의 스레드를 실행할 경우에는 우선순위 방식이 크게 영향을 미치지 못한다.

동기화 메서드와 동기화 블록

공유 객체를 사용할 때의 주의할 점

싱글 스레드 프로그램은 한 개의 스레드가 객체를 독차지해서 사용해도 되지만, 멀티 스레드는 객체를 공유해야 하는 경우가 있다.
마치 계산기를 두 명의 사람이 나눠쓰는것 같은 상황처럼 한 사람이 자리를 비웠을 때 다른 사람이 값을 입력하면 원했던 결과가 달라지는 상황이 만들어 진다.

동기화 메서드 및 동기화 블록

스레드가 사용 중인 객체를 다른 스레드가 변경할 수 없도록 하려면 스레드 작업이 끝날 때까지 객체에 잠금을 걸어서 다른 스레드가 사용할 수 없도록 하는 방법이 있다.

  • 단 하나의 스레드만 실행할 수 있는 코드 영역을 임계 영역이라고 한다.
  • 자바는 임계 영역을 지정하기 위해 동기화 메서드와 동기화 블록을 제공한다.
  • synchronized 키워드를 붙이면 사용할 수 있고 인스턴스와 정적 메서드 어디든 사용할 수 있다.
public synchronized void method() {

}

스레드 상태

스레드 객체를 생성하고, start() 메서드를 호출하면 곧바로 스레드가 실행되는 것처럼 보이지만 사실은 실행 대기 상태가 된다.
실행되지 않고 기다리고 있다가 스레드 스케줄링으로 선택된 스레드가 CPU를 점유하고 run() 메서드를 실행해여 실행 상태가 된다.
스레드 스케줄링에 의해 다시 대기 상태가 되고 실행 상태가 되고를 반복하면서 조금씩 코드를 수행한다.
더 이상 실행할 코드가 없으면 실행을 멈추고 이 상태를 종료 상태라고 한다.

실행 상태에서 일시 정지 상태가 되기도 하는데 스레드가 실행할 수 없는 상태이다.

  • WAITING, TIMED_WAITING, BLOCKED가 있다.
상태 열거 상수 설명
객체 생성 NEW 스레드 객체가 생성, 아직 start() 메서드가 호출되지 않는 상태
실행 대기 RUNNABLE 실행 상태로 언제든지 갈 수 있는 상태
일시 정지 WAITING 다른 스레드가 통지할 때까지 기다리는 상태
TIMED_WAITING 주어진 시간 동안 기다리는 상태
BLOCKED 사용하고자 하는 객체의 락이 풀릴 때까지 기다리는 상태
종료 TERMINATED 실행을 마친 상태
public class ThreadTest3 extends Thread{
    private Thread targetTHread;
    public ThreadTest3(Thread targetThread) {
        this.targetTHread = targetThread;
    }

    @Override
    public void run() {
        while(true) {
            Thread.State state = this.targetTHread.getState();
            System.out.println("타켓 스레드 상태: " + state);

            if (state == Thread.State.NEW)
                this.targetTHread.start();

            if (state == Thread.State.TERMINATED)
                break;
            try {
                Thread.sleep(500);
            }catch (Exception e ) {

            }
        }
    }

    public static void main(String[] args) {
        ThreadTest3 t = new ThreadTest3(new TargetThread());
        t.start();
    }
}

class TargetThread extends Thread {
    @Override
    public void run() {
        for (long i = 0; i < 1000000000; i++){}

        try {
            Thread.sleep(1500);
        }catch (Exception e ) {

        }

        for (long i = 0; i < 1000000000; i++) {}
    }
}

스레드 상태 제어

실행중인 스레드의 상태를 변경하는 것을 스레드 상태 제어라고 하며 정교한 스레드 상태 제어가 되지 않으면 프로그램은 불안정해져서 먹통이 되거나 다운된다.
멀티 스레드 프로그래밍이 어려운 이유는 스레드를 잘 사용하면 약이 되지마느 잘못 사용하면 치명적인 버그가 된다.

메서드 설명
interrupt() 일시 정지 상태의 스레드에서 interruptedException 예외를 발생시켜, 예외 처리 코드에서 실행 대기 상태로 가거나 종료 상태로 갈 수 있도록 한다.
notify()
notifyAll()
동기화 블록 내에서 waite() 메서드에 의해 일시 정지 상태에 있는 스레들르 실행 대기 상태로 만든다.
resume() suspend() 메서드에 의해 일시 정지 상태에 있는 스레드를 실행 대기 상태로 만든다.
- Deprecated ( 대신 notify(), notifyAll 사용)
sleep(long millis)
sleep(long millis, int nanos)
주어진 시간 동안 호출한 스레드는 일시 정지 상태가 된다. 실행 대기 상태로 가려면, join() 메서드를 멤버로 가지는 스레드가 종료되거나, 매개값으로 주어진 시간이 지나야 한다.
join()
join(long millis)
join(long millis, int nanos)
join() 메서드를 호출한 스레드는 일시 정지 상태가 된다. 실행 대기 상태로 가려면, join() 메서드를 멤버로 가지는 스레드가 종료되거나, 매개값으로 주어진 시간이 지나야 한다.
wait()
wait(long millis)
wait(long millis, int nanos)
동기화(synchronized) 블록 내에서 스레드를 일시 정지 상태로 만든다. 매개값으로 주어진 시간이 지나면 자동적으로 실행 대기 상태가 된다. 시간이 주어지지 않으면 notify(), notifyAll() 메서드에 의해 실행 대기 상태로 갈 수 있다.
suspend() 스레드를 일시 정지 상태로 만든다. resume()메서드를 호출하면 다시 실행 대기 상태가 된다.
- Deprecated(대신 wait() 사용)
yield() 실행 중에 우선순위가 동일한 다른 스레드에게 실행을 양보하고 실행 대기 상태가 된다.
stop() 스레드를 즉시 종료시킨다.
- Deprecated

데몬 스레드

데몬 스레드는 주 스레드의 작업을 돕는 보조적인 역할로 주 스레드가 종료되면 데몬 스레드는 강제적으로 자동 종료된다.

public static void main(String[] args) {
        Thread a = new Thread();
        a.setDaemon(true); // start()가 호출되기 전에 호출해야 한다.
        a.start(); 
    }

스레드 그룹

스레드 그룹은 관련된 스레드를 묶어서 관리할 목적으로 이용되며 JVM이 실행되면 system 스레드 그룹을 만들고, JVM 운영에 필요한 스레드들을 생성해서 system 스레드 그룹에 포함시킨다.
system의 하위 스레드 그룹으로 main을 만들고 메인 스레드를 main 스레드 그룹에 포함시킨다.
스레드는 반드시 하나의 그룹에 포함되며 명시적으로 그룹에 포함시키지 않으면 기본적으로 자신이 생성한 스레드와 같은 스레드 그룹에 속하게 된다.
우리가 생성하는 작업 스레드는 대부분 main 스레드가 생성하므로 기본적으로 main 스레드 그룹에 속하게 된다.

ThreadGroup g = new ThreadGroup(String name);
ThreadGroup g = new ThreadGroup(ThreadGroup group, Runnable target);
ThreadGroup g = new ThreadGroup(ThreadGroup group, Runnable target, String name);
ThreadGroup g = new ThreadGroup(ThreadGroup group, Runnable target, String name, long stackSize);
ThreadGroup g = new ThreadGroup(ThreadGroup group, String name);
  • 스레드 그룹은 5가지 생성자를 가진다.
메서드   설명
int activeCount() 현재 그룹 및 하위 그룹에서 활동 중인 모든 스레드의 수를 리턴한다.
int activeGroupCount() 현재 그룹에서 활동 중인 모든 하위 그룹의 수를 리턴한다.
void checkAccess() 현재 스레드가 스레드 그룹을 변경할 권한이 있는지 체크한다. 만약 권한이 없으면 SecurityException을 발생시킨다.
void destroy() 현재 그룹 및 하위 그룹을 모두 삭제한다. 단, 그룹 내에 포함된 모든 스레드들이 종료 상태가 되어야 한다.
boolean isDestroyed() 현재 그룹이 삭제되었는지 여부를 리턴한다.
int getMaxPriority() 현재 그룹에 포함된 스레드가 가질 수 있는 최대 우선순위를 리턴한다.
void  setMaxPriority(int pri) 현재 그룹에 포함된 스레드가 가질 수 있는 최대 우선순위를 설정한다.
String getName() 현재 그룹의 이름을 리턴한다.
ThreadGroup getParent() 현재 그룹의 부모 그룹을 리턴한다.
boolean isDaemon() 현재 그룹이 데몬 그룹인지 여부를 리턴한다.
void  setDaemon(boolean daemon) 현재 그룹을 데몬 그룹으로 설정한다.
void list() 현재 그룹에 포함된 스레드와 하위 그룹에 대한 정보를 출력한다.
void interrupt() 현재 그룹에 포함된 모든 스레드들을 interrupt한다.

스레드 풀

갑작스런 병렬 작업의 폭증으로 인한 스레드의 폭증을 막으려면 스레드 풀을 사용해야 한다.
스레드 풀은 작업 처리에 사용되는 스레드를 제한된 개수만큼 정해 놓고 작업 큐에 들어오는 작업들을 하나씩 스레드가 맡아 처리한다. 스레드의 전체 개수가 늘어나지 않으므로 애플리케이션의 성능이 급격히 저하되지 않는다.

메서드명(매개변수) 초기 스레드 수 코어 스레드 수  최대 스레드 수
newCachedThreadPool() 0 0 Integer.MAX_VALUE
newFixedThreadPool(int nThreas) 0 nThreads nThreads
  • 초기 스레드 : 기본적으로 생성되는 스레드 수
  • 코어 스레드 : 스레드 수가 증가된 후 사용되지 않는 스레드를 스레드 풀에서 제거할 때 최소한 유지해야 할 스레드 수
  • 최대 스레드 : 스레드 풀에서 관리하는 최대 스레드 수
    ExecutorService es = Executors.newCachedThreadPool();

    ExecutorService es2 = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); // 코어 개수만큼 스레드 풀을 생성한다.

    ExecutorService es3 = new ThreadPoolExecutor(
        3, // 코어 스레드 개수
        100, // 최대 스레드 개수
        120L, // 놀고 있는 시간
        TimeUnit.SECONDS, // 놀고 있는 시간 단위
        new SynchronousQueue<Runnable>() // 작업 큐
    );

 

스레드 풀은 데몬 스레드가 아니기 때문에 main 스레드가 종료되더라도 작업을 처리하기 위해 계속 실행 상태로 남아있으므로 스레드풀을 종료시켜 스레드들이 종료 상태가 되도록 처리해야 한다.

리턴 타입 메서드명(매개변수) 설명
void shutdown() 현재 처리 중인 작업뿐만 아니라 작업 큐에 대기하고 있는 모든 작업을 처리한 뒤에 스레드풀을 종료시킨다.
List<Runnable> shutdownNow() 현재 작업 처리 중인 스레드를 Interrupt해서 작업 중지를 시도하고 스레드풀을 종료시킨다. 리턴값은 작업 큐에 있는 미처리된 작업(Runnable)의 목록이다.
boolean awaitTermination(
long timeout,
TimeUnit unit)
shutdown() 메서드 호출 이후, 모든 작업 처리를 timeout 시간 내에 완료하면 true를 리턴하고, 완료하지 못하면 작업 처리 중인 스레드를 interrupt하고 false를 리턴한다.

 

작업 생성

Runnable의 run() 메서드는 리턴 값이 없고 Callable의 call() 메서드는 리턴 값이 있다.

    public static void main(String[] args) {
        Runnable r = new Runnable() {
            @Override
            public void run() {
                
            }
        };

        Callable<T> c = new Callable<T>() {
            @Override
            public T call() throws Exception {
                return null;
            }
        };
    }

작업 처리 요청

리턴 타입 메서드명(매개 변수) 설명
void execute(Runnable command) - Runnable을 작업 큐에 저장
- 작업 처리 결과를 받지 못함
Future<?>
Future<V>
Future<V>
submit(Runnable task)
submit(Runnable task, V result)
submit(Callable<V> task)
- Runnable 또는 Callable을 작업 큐에 저장
- 리턴된 Future를 통해 작업 처리 결과를 얻을 수 있음

  • execute() : 작업 처리 도중 예외가 발생하면 스레드가 종료되고 해당 스레드는 스레드풀에서 제거되고 다른 작업 처리를 위해 새로운 스레드를 생성한다.
  • submit() : 작업 처리 도중 예외가 발생하더라도 스레드는 종료되지 않고 다음 작업을 위해 재사용되어 가급적이면 스레드의 생성 오버헤더를 줄이기 위해 submit()을 사용하는 것이 좋다.

블로킹 방식의 작업 완료 통보

Future 객체는 작업 결과가 아니라 작업이 완료될때까지 기다렸다가(지연했다가=블로킹되었다가) 최종 결과를 얻는데 사용되어 지연 완료 객체라고 하고 이를 작업 완료 통보 방식이라고 한다.

get() 메서드를 호출하면 스레드가 작업을 완료할 때까지 블로킹되었다가 작업을 완료하면 처리 결과를 리턴한다.

 

주의 할 점은 처리하는 스레드가 작업을 완료하기 전까지는 get() 메서드가 블로킹되므로 다른 코드를 실행할 수 없다.

 

리턴 타입 메서드명(매개변수) 설명
V get() 작업이 완료될 때까지 블로킹되었다가 처리 결과 V를 리턴
V get(long timeout, TimeUnit unit) timeout 시간 전에 작업이 완료되면 결과 V를 리턴하지만, 작업이 완료되지 않으면 TimeoutException을 발생시킴

submit() 메서드별 리턴 값

메서드 작업 처리 완료 후 리턴 타입 작업 처리 도중 예외 발생
submit(Runnable task) future.get() -> null future.get() -> 예외 발생
submit(Runnable task, Integer result) future.get() -> int 타입 값 future.get() -> 예외 발생
submit(Callable<String> task) future.get() -> String 타입 값 future.get() -> 예외 발생

Future객체가 제공하는 메서드

리턴 타입 메서드명(매개변수) 설명
boolean cancel(boolean mayInterruptIfRunning) 작업 처리가 진행 중일 경우 취소시킴
boolean isCancelled() 작업이 취소되었는지 여부
boolean isDone() 작업 처리가 완료되었는지 여부

 

리턴 값이 없는 작업 완료 통보 : submit(Runnable task) 메서드를 이용하면 된다. 

리턴 값이 있는 작업 완료 통보 : 작업 객체를 Callable로 생성하면 된다.

작업 처리 결과를 외부 객체에 저장

스레드가 작업 처리를 완료하고 외부 Result 객체에 작업 결과를 저장하면 애플리케이션이 Result 객체를 사용해서 어떤 작업을 진행할 수 있게 되며 대개 Result 객체는 공유 객체가 되어, 두 개 이상의 스레드 작업을 취합할 목적으로 이용된다.

Result result = ...;
Runnable task = new task(result);
Future<Result> future = executorService.submit(task, result);
result = future.get();

작업 완료 순으로 통보

스레드 풀에서 작업 처리가 완료된 것만 통보받는 방법이 있는데 CompletionService를 이용하는 것이다.

CompletionService는 처리 완료된 작업을 가져오는 poll()과 task()메서드를 제공한다.

리턴 타입 메서드명(매개변수) 설명
Future<V> poll() 완료된 작업의 Future를 가져옴.
완료된 작업이 없다면 즉시 null을 리턴함.
Future<V> poll(long timeout, TimeUnit unit) 완료된 작업의 Future를 가져옴.
완료된 작업이 없다면 timeout까지 블로킹됨.
Future<V> take() 완료된 작업의 Future를 가져옴.
완료된 작업이 없다면 있을 때까지 블로킹됨.
Future<V> submit(Callable<V> task) 스레드 풀에 Callable 작업 처리 요청
Future<V> submit(Runnable task, V result) 스레드 풀에 Runnable 작업 처리 요청

콜백 방식의 작업 완료 통보

애플리케이션이 스레드에게 작업 처리를 요청한 후, 스레드가 작업을 완료하면 특정 메서드를 자동 실행하는 기법을 말하며 자동 실행되는 메서들르 콜백 메서드라고 한다.

ExecutorService는 콜백읠 위한 별도의 기능이 제공되지 않지만 Runnable 구현 클래스를 작성 할 때 콜백 기능을 구현할 수 있다.

반응형