자바의정석 - 쓰레드

쓰레드

쓰레드란 프로세스에서 일하는 일꾼이다. 하나의 프로세스에는 최소 하나의 쓰레드가 필요하며 둘 이상의 쓰레드를 갖은 프로세스는 멀티쓰레드 프로세스라고 한다. 또한 프로세스는 독립되 리소스를 부여받아 사용하지만 쓰레드의 경우 프로세스의 자원을 서로 공유할 수 있다. 프로세스에 쓰레드의 개수 제한은 없으나 일반적으로 쓰레드가 작업을 수행하는데 개별적인 메모리 공간(Call Stack)을 필요로 하기 때문에 프로세스의 메모리 한계에 의해 쓰레드의 개수가 정해진다.

멀티태스킹과 멀티쓰레딩

우리가 사용하는 대부분의 OS는 멀티태스킹을 지원한다 멀티태스킹은 프로세스가 동시에 여러개를 실행할 수 있는 환경이다. 마찬가지로 멀티쓰레딩은 하나의 프로세스에 여러 쓰레드가 동시에 작업을 수행하는 것이다. CPU의 코어가 하나로 가정하였을때 동시에 여러 쓰레드가 처리되는 것은 아니며 번갈아가며 짧은시간에 수행이 된다.(수행의 순서는 OS의 스케줄러에 의해 결정이 된다.) 동시에 처리되는 작업의 개수는 CPU의 코어수와 일치한다. 이러한 멀티쓰레딩의 장점으로는 CPU의 사용률 향상, 효율적인 자원사용, Client 응답률 향상으로 볼 수 있다. 멀티쓰레딩으로 우리가 자주 듣는 채팅기능은 채팅을 하며 서로간 파일을 다운로드 받거나 하는 기능이다.

하지만 장점만 있는 것은 아니다. 멀티쓰레딩 환경에서 쓰레드의 경우 프로세스의 자원을 공유하기때문에 동기화, 교착상태(쓰레드 락) 같은 몬제들이 따른다.

쓰레드의 구현과 실행

쓰레드의 구현은 두가지 방법이 있다. Thread클래스 상속, Runable인터페이스 구현이며 두가지 방법이 차이는 없지만 전자인 상속으로 할 경우 추가적인 다른 클래스를 상속받지 못 하기 때문에 인터페이스 구현으로 진행하는 것이 일반적이다. 두가지 방법으로 정의 후 공통적인 run()메서드를 통해 Thread의 작업내용을 구현하면 된다.

// 1. Thread클래스 상속
class MyThread extends Thread {
    public void run() {
        // 작업내용
    }
}

// 2. Runable인터페이스 구현
class MyTread implements Runable {
    public void run() {
        // 작업내용
    }
}

위의 두가지 방법으로 구현 시 인스턴스 생성방법이 다르다. extends 방법은 해당 클래스를 바로 인스턴스화 하면되지만 implements 방법은 Runable을 통해 생성자 Thread를 통해 인스턴스화 해야한다. Runable로 인스턴스화 후 Thread클래스에 생성자의 매개변수로 넣어주어야 한다. 또 하나의 차이는 현재 실행중인 Thread가 무엇인지 확인하는 getName()메서드는 Thread를 상속받은 방법으로는 바로사용이 가능하지만 Runable로 구현된 방법에는 메서드가 구현이 되어있지 않기에 현재 실행중인 Thread를 반환하는 Thread.currentTread()을 통해 .getName()으로 확인해야 한다.

package com.java.mystudy.thread;

public class ThreadMain {

    public static void main(String args[]) {

        Thread1 t1 = new Thread1();

        Runnable r = new Thread2();
        Thread t2 = new Thread(r);
        // 위의 방법을 한줄로 처리하려면
        // Thread t22 = new Thread(new Thread2());

        t1.start();
        t2.start();
    }

}

class Thread1 extends Thread {
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(getName());
        }
    }
}

class Thread2 implements Runnable {

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName());
        }
    }
}

/** 
    출력결과

    > Task :ThreadMain.main()
    Thread-0
    Thread-0
    Thread-0
    Thread-0
    Thread-0
    Thread-1
    Thread-1
    Thread-1
    Thread-1
    Thread-1
*/

TIP)
쓰레드의 이름은 정의하지 않으면 알아서 넘버링이 된다.
쓰레드의 이름을 부여하고 싶을때는 아래의 방법으로 가능하다.

Thread(Runable target, String name)
Thread(String name)
void setName(String name)

쓰레드의 실행 - run(), start()

쓰레드를 생성했다고 해서 자동으로 실행되지 않는다. start()를 호출해야만 사용가능한데 start() 메서드는 실행시키는 메서드가 아닌 실행대기 상태로 만들어주며 자신 차례가 왔을때 실행이 되는 것이다. 또한 중요한 것은 한번 실행이 종료된 Thread는 다시 실행이 되지 않는다. 하나의 쓰레드에 대해 start()가 한 번만 호출 될 수 있다는 뜻이다.

main메서드에서 run()을 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라 단순히 클래스에 선언된 메서드를 호출하는 것일 뿐이다. 반면에 start()는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택(call stack)을 생성한 다음에 run()을 호출하는 것이다. 아래 이미지를 보면 이해가 될 것이다.

좌측의 main메서드의 에서 start()를 실행하면 call stack에 start()가 생성하며 우측에 call stack을 생성하게 된다. 그 후 우측 call stack에 run()을 호출 하게 된다.

repository

  1. main메서드에서 쓰레드의 start()를 호출한다. (좌측 이미지)
  2. start()는 새로운 쓰레드 생성 (좌측 이미지 중 우측의 call stack 공간 생성)
  3. 새로 생성된 call stack에 run()을 호출 (우측 이미지)
  4. call stack이 2개이므로 스케줄러가 정한 순서에 의해 번걸아 수행

싱글쓰레드와 멀티쓰레드

말 그대로 쓰레드가 하나 혹은 여러개인 상태이다. 두개의 작업을 하나의 쓰레드로 작업을 한다면 싱글쓰레드 두개의 작업을 두개의 쓰레드로 작업을 한다면 멀티쓰레드 인 것이다. 멀티 쓰레드의 경우 스케줄러에 등록된 순서대로 짧은 시간에 번갈아 가면서 작업을 수행하게 된다. 멀티쓰레드라고 해서 작업속도가 빠른 것은 아니다. 하나의 쓰레드 작업을 하다 다른 쓰레드 작업을 하게될때 작업전환(context switchin)을 하게되는데 이때는 쓰레드의 상태 정보를 메모리에 올리는 작업을 하기에 작업전환에 소요시간이 걸린다. 이로 인해 두개의 작업을 싱글쓰레드로 하는것이 더 빠른 효율이 발생할 수 있다는 의미이다. 참고로 멀티태스킹 환경의 여러 프로세스에서도 context switching이 발생하는데 쓰레드에 걸리는 시간보다 더 오래걸린다.

멀티쓰레드의 간단한 소스를 봐보자

package com.java.mystudy.thread;

public class ThreadMain {

    public static void main(String args[]) {

        Thread t = new Thread(new Thread1());
        t.start();

        for (int i = 0; i < 100 ; i++) {
            System.out.printf("%s", new String("|"));
            if(i == 49){
                System.out.println();
            }}
    }

}

class Thread1 implements Runnable {

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.printf("%s", new String("-"));
            if(i == 49){
                System.out.println();
            }
        }
    }
}
/**
출력결과
||||||||||||||||||||||||||||||||||||||||||||||||||
||||||||||||||||||------||||||||||||||||||||||||||||||||--------------------------------------------
--------------------------------------------------
*/

Thread1 클래스를 하나 생성하고 main에서 start()를 시켜보면 출력결과가 위와같이 나온다.(실행마다 결과값은 다름) 그리고 System.out.print("-")으로 해보니 쓰레드 환경으로 보기 어려운 출력결과가 나와 new String으로 수행시간을 약간 늦추었다. 위에는 멀티쓰레드 환경의 가장 간단한 소스이다.

멀티쓰레드 환경에서도 싱글코어멀티코어에서는 수행되는 것이 차이가 있다. 싱글코어의 멀티쓰레드인 경우에라도 하나의 코어에 여러 쓰레드가 번갈아 가면서 수행이 되는 환경이므로 쓰레드간 작업이 겹치지가 않지만 멀티코어의 멀티쓰레드인 경우에는 동시에 두 쓰레드가 수행될 수 있으르모 겹치는 부분이 발생한다. 이때 두 쓰레드가 화면출력 이라든지 다른 자원을 경쟁하게 되며 쓰레드 락이 발생할 위험이 있다.

쓰레드의 우선순위

쓰레드는 우선순위 속성을 가지고 있다. 작업의 중요도에 따라 우선순위를 정하면 실행시간이 달라지게 되며, 낮은 우선순위는 1 높은 우선순위는 10으로 1~10까지 설정이 가능하며 보통 우선순위는 5로 지정한다. 우선순위의 경우 쓰레드를 생성한 쓰레드로부터 상속받는 것이다. main메서드를 수행하는 쓰레드는 우선순위가 5이며 main메서드 내에서 생성하는 쓰레드의 우선순위는 자동적으로 5로 된다.

쓰레드 우선순위 설정방법은 아래와 같다.

void setPriority(int newPriority)   // 쓰레드의 우선순위를 지정한다.
int getPriority()                   // 쓰레드의 우선순위를 반환한다.

Thread t = new Thread();            // 쓰레드 인스턴스.
t.setPriority(10);                  // t의 우선순위를 가장 높게 설정.
System.out.println(t.getPriority()) // t의 우선순위 값 반환.

쓰레드 그룹

쓰레드 그룹은 서로 관련된 쓰레드를 그룹으로 다루기 위한 것이다. 다른 쓰레드 그룹에 또 다른 그룹을 포함시켜서 사용할 수 도 있다. 그룹은 보안상의 이유로 도입된 개념이며, 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경할 수 있지만 다른 쓰레드 그룹의 쓰레드를 변경할 수는 없다. 쓰레드를 쓰레드 그룹에 포함시키려면 Thread의 생성자를 이용해야 한다.

Thread(ThreadGroup group, String name)
Thread(ThreadGroup group, Runable target)
Thread(ThreadGroup group, Runable target, String name)
Thread(ThreadGroup group, Runable target, String name, long stackSize)

쓰레드는 반드시 쓰레드 그룹에 포함되어야 하며, 위와같이 쓰레드 그룹에 지정하는 생성자를 사용하지 않은 쓰레드는 기본적으로 자신을 생성한 쓰레드와 같은 그룹에 속하게 된다. JVM은 main과 system이라는 쓰레드 그룹을 만든다. main메서드를 수행하는 main이라는 이름의 쓰레드는 main쓰레드 그룹에 속하고 가비지컬렉션을 수행하는 Finalizer쓰레드는 system쓰레드 그룹에 속한다.

데몬 쓰레드

데몬 쓰레드는 다른 일반 쓰레드의 작업을 돕는 보조적인 역할을 수행한다. 우리가 사용하는 일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 종료가 된다. 데몬 쓰레드의 경우 무한루프와 조건문을 이용해서 실행 후 대기상태로 있다 특정 조건이 만족하게되면 작업을 수행하고 다시 대기하도록 작성을 한다. 일반 쓰레드 생성과 같으며 setDaemon(true)를 호출하기만 하면 된다. 추가적으로 데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 된다는 점도 알아두자.

boolean isDaemon()          // 데몬 쓰레드인지 확인
void setDaemon(boolean on)  // 쓰레드를 데몬 쓰레드 or 일반 쓰레드로 변경
package com.java.mystudy.thread;

public class ThreadMain {

    private static boolean flag = false;

    public static void main(String args[]) {

        // 일반 쓰레드 생성하듯이 생성
        Thread t = new Thread(new ThreadDaemon());
        // 데몬 쓰레드로 지정
        t.setDaemon(true);
        // 쓰레드 실행대기로 전환
        t.start();

        for (int i = 1; i < 50 ; i++) {
            try {
                Thread.sleep(1000); // 지연시간을 주기위한 값
            } catch (InterruptedException e) {

            }
            System.out.print(i+" ");

            // 데몬 쓰레드의 트리거를 위한 flag값
            if(i%5 == 0){
                flag = true;
            }
        }

        System.out.println("Main Thread 좋료");
    }

    static class ThreadDaemon implements Runnable {

        @Override
        public void run() {
            while(true) {

                try {
                    Thread.sleep(1000); // 데몬 쓰레드를 항시 체크하지 않고 주기를 주기위한 용도
                } catch (InterruptedException e) {

                }

                // flag의 상태를 위의 주기를 통하여 체크
                if(flag) {
                    System.out.println("Daemon Check");
                    flag = false;
                }
            }
        }
    }
}
/**
출력결과
1 2 3 4 5 Daemon Check
6 7 8 9 10 Daemon Check
11 12 13 14 15 Daemon Check
16 17 18 19 20 Daemon Check
21 22 23 24 25 Daemon Check
26 27 28 29 30 Daemon Check
31 32 33 34 35 Daemon Check
36 37 38 39 40 Daemon Check
41 42 43 44 45 Daemon Check
46 47 48 49 Main Thread 좋료
*/

밑의 쓰레드의 종료 조건은 없다. 무한 루프에서 단지 조건문을 통해 데몬 쓰레드의 동작을 유도시킨 것 뿐이다. 하지만 main쓰레드의 종료와 함꼐 데몬 쓰레드가 종료가되어 프로그램이 끝나는 것을 볼 수 있다. 위에서 t.setDaemon(true)를 해주지 않았다면 이 프로그램은 무한반복 동작을 하게 된다. 반드시 데몬 쓰레드의 경우엔 start()를 하기전에 선언을 해야되는 것을 기억해두자.

쓰레드의 실행동제어

쓰레드가 어려운 부분은 바로 동기화스케줄링 때문이다. 효율적인 멀티쓰레드 환경을 구현하기 위해서는 프로세스에 주어진 자원관 시간을 여러 쓰레드가 효율적으로 사용하게 구현해야 한다. 아래는 쓰레드 스케줄링과 관련된 메서드이다.

메서드 설명
static void sleep(long millis) 지정된 시간동안 쓰레드를 일시정지시킨다.
void join(), void join(long millis) 지정된 시간동안 쓰레드가 실행되도록 한다. 자신 쓰레드에서 선언하여 다른 쓰레드의 실행을 유도하는 것이다. (sleep()과는 자원사용부분이 다름)
void interrupt() sleep()이나 join()에 의해 일시정지상태인 쓰레드를 꺠워서 실행대기상태로 만든다.
void stop() 쓰레드를 즉시 종료한다.
void suspend() 쓰레드를 일시정지시킨다. resume()을 사용하여 다시 실행대기상태로 만들수 있다.
void resume() suspend()에 의해 일시정지상태에 있는 쓰레드를 실행대기상태로 만든다.
static void yield() 실행 중에 자신에게 주어진 실행시간을 다른 쓰레드에게 양보하고 자신은 실행대기상태가 된다.

sleep()

sleep()에 의해 일시정지 상태가 된 쓰레드는 지정된 시간이 지나거나 interrupt()가 호출되면 InterruptedException이 발생되어 실행대기상태가 된다. Thread.sleep()을 사용할떄는 항상 try-catch가 붙는 이유이다.

try {
    Thread.sleep(1000);
} catch(InterruptedException e) {

}

interrupt()와 interrupted()

작업중인 쓰레드를 멈출때 사용하며 쓰레드를 종료하는 것은 아니다. interrupt()를 호출하게 되면 interrupted의 상태값을 바꾸는 것일 뿐이다. 역으로 보면 interrupted()를 호출하게 되면 현재 쓰레드의 상태를 알 수 있다. 여기서 알아둘 점은 isInterrupted()도 있다는 것이다. 두개의 차이점은 현재의 상태값을 반환하고 false로 초기화를 하냐 상태값을 유지하느냐의 차이다.

void interrupt()                // 쓰레드의 interrupted의 상태를 true로 변경
boolean isInterrupted()         // 쓰레드의 interrupted의 상태를 반환
static boolean interrupted()    // 쓰레드의 interrupted의 상태를 반환 한뒤 false로 초기화
package com.java.mystudy.thread;

public class ThreadMain implements Runnable {

    static boolean flag = false;

    public static void main(String args[]){

        // Daemon Thread
        Thread td = new Thread(new ThreadMain());
        td.setDaemon(true);
        td.start();

        // Thread
        Thread1 t = new Thread1();
        t.start();

        // Thread1이 수행될 시간을 부여하기 위해
        try {
            Thread.sleep(5*1000);
        } catch (InterruptedException e) {

        }

        // Thread1 Interrupt 발생
        t.interrupt();
        // Daemon Thread 수행을 위한 flag 값
        flag = true;
    }

    @Override
    public void run() {
        while(true) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {

            }
            if(flag) {
                System.out.println("Thread 1 Interrupt");
            }
        }
    }
}

class Thread1 extends Thread {

    public void run() {
        int cnt = 0;

        while(!isInterrupted()) {
            cnt++;
            System.out.println("[ " + cnt + "] isInterrupted : " + isInterrupted());

            // !!!!!!!!! Check point !!!!!!!!!!
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                interrupt();
            }
        }
    }
}
/**
출력결과
[ 1] isInterrupted : false
[ 2] isInterrupted : false
[ 3] isInterrupted : false
[ 4] isInterrupted : false
[ 5] isInterrupted : false
[ 6] isInterrupted : false
[ 7] isInterrupted : false
Thread 1 Interrupt
*/

위의 소스 중 main클래스에서 t.interrupt()를 통해 Thread1클래스 의 반복문을 빠져나오려는 의도의다. 하지만 isInterrupted()를 호출해 보면 false로 나온다. 그 이유는 Thread1 클래스의 Check point의 소스 때문이다. Thread.sleep()을 주게 되면 쓰레드가 잠시 멈춰져있는 상태인데 이떄 interrupt()를 호출하면 InterruptedException이 발생되고 쓰레드의 interrupted()의 상태는 자동으로 false로 초기화가 된다. 이렇게 사용할 때는 catch문에 interrupt()를 한번더 호출해 주어 상태값을 변경해주어야 한다. 꼭 기억해두고 넘어가자.

suspend(), resume(), stop()

suspend()는 sleep()과 같이 쓰레드의 동작을 멈추게하며, resume()을 통해 다시 실행대기상태로 전환할 수 있다. stop()은 말 그대로 쓰레드를 종료시키는 용도이다. 알아두어야 할 점은 suspend()와 stop()은 쓰레드 락을 유발하기 쉬워 현재는 Deprecated로 Java에서 사용을 권장하지 않는다.

yield()

yield()는 스케줄러에 의해 주어진 실행시간을 다음 차례의 쓰레드에게 양보하는 방법이다. 자신이 10초를 할당받아 작업을 한다고 하였을때 로직상 5초 수행하다 yield()가 호출이 되면 나머지 5초를 다음 차례의 쓰레드에게 양보하는 개념이다. yield()와 interrup()를 적절하게 사용하면 프로그램의 응답성을 높이고 효율적인 실행이 가능하다.

join()

자신이 수행할 작업을 잠시 멈춘 뒤 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할때 사용한다. 만일 시간을 지정하지 않을 경우 다른 쓰레드가 작업을 종료할떄까지 기다리게 된다. join()의 경우 try-catch로 감싸주어야 하며 interrupt()에 의해 join상태를 벗어 날 수 있다.

join()
join(long millis)
join(long millis, int nanos)

아래의 예제는 suspend(), resume(), stop(), join()을 사용한 예제이다. main을 제외한 2개의 쓰레드를 생성하여 수행하며 중간에 하나의 쓰레드를 일시중지하고 하나의 쓰레드만 처리되게 하며, 다른 쓰레드를 종료시킨 후 일시중지한 쓰레드를 재개하는 예제이다.

package com.java.mystudy.thread;

public class ThreadMain implements Runnable {

    static String flag = "";

    public static void main(String args[]){

        // Daemon Thread
        Thread td = new Thread(new ThreadMain());
        td.setDaemon(true);
        td.start();

        // Thread 1
        Thread1 t = new Thread1();
        t.start();

        // ThreadJoin
        Thread tj = new Thread(new ThreadJoin("Join"));
        tj.start();

        // ThreadJoin을 join 상태로 만들기 -> main 클래스에서 ThreadJoin의 작업이 종료될때까지 수행된다.
        try {
            tj.join(3*1000);
            flag = "join";
        } catch (InterruptedException e) {

        }

        // Thread 지연을 줌
        try {
            Thread.sleep(3*1000);
        } catch (InterruptedException e) {

        }

        // ThreadJoin쓰레드를 일시중지 함.
        // Thread 1만 수행되게 하기 위해
        tj.suspend();
        flag = "suspend";

        // Thread 지연을 줌
        try {
            Thread.sleep(3*1000);
        } catch (InterruptedException e) {

        }

        // Thread 1 중지시키고 ThreadJoin의 작업을 재개함.
        t.stop();
        System.out.println(t.getName() + " [ Stop ]");
        tj.resume();
    }

    @Override
    public void run() {
        while(true) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {

            }
            if(flag.equals("join")) {
                System.out.println("!!~ ThreadJoin Class status -> [ Join ]");
                flag = "";
            } else if (flag.equals("suspend")) {
                System.out.println("!!~ ThreadJoin Class status -> [ Suspend ]");
                flag = "";
            }
        }
    }
}

class Thread1 extends Thread {

    public void run() {

        System.out.println(getName() + " Start ~!");
        int cnt = 0;

        while(!isInterrupted()) {
            cnt++;
            System.out.println(getName() + "[ " + cnt + " ]");

            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                interrupt();
            }
        }
    }
}

class ThreadJoin implements Runnable {

    // thread name 셋팅을 위한 변수
    Thread thread;

    // thread name 셋팅을 위한 생성자
    ThreadJoin(String name) {
        thread = new Thread(this, name);
    }

    @Override
    public void run() {
        System.out.println(thread.getName()+ " Start ~!");

        int cnt = 0;
        while(true) {
            cnt++;
            System.out.println(thread.getName() + " [ " + cnt + " ] ");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {

            }

            if(cnt >= 10) {
                System.out.println(thread.getName() + " [ Finished ]");
                break;
            }
        }
    }
}
/**
출력결과
Thread-1 Start ~!
Thread-1[ 1 ]
Join Start ~!
Join [ 1 ] 
Thread-1[ 2 ]
Join [ 2 ] 
Thread-1[ 3 ]
Join [ 3 ] 
!!~ ThreadJoin Class status -> [ Join ]
Thread-1[ 4 ]
Join [ 4 ] 
Thread-1[ 5 ]
Join [ 5 ] 
Thread-1[ 6 ]
Join [ 6 ] 
!!~ ThreadJoin Class status -> [ Suspend ]
Thread-1[ 7 ]
Thread-1[ 8 ]
Thread-1[ 9 ]
Thread-1 [ Stop ]
Join [ 7 ] 
Join [ 8 ] 
Join [ 9 ] 
Join [ 10 ] 
Join [ Finished ]
*/

위의 모든 정리내용은 자바의 정석을 공부하며 복습차 정리한 내용입니다.

Leave a comment