도도한 개발자

[Java] #25. Thread(쓰레드) 본문

Backend/Java

[Java] #25. Thread(쓰레드)

Kiara Kim 2023. 2. 2. 20:05

쓰레드란 무엇일까?

 

자바 프로그램을 사용하여 뒤에 클래스 이름을 붙이면 적어도 하나의 JVM이 시작된다. 보통 이렇게 JVM.이 시작되면 자바 프로세스(Process) 가 시작하는데, 이 안에 여러 개의 쓰레드라는 것이 수행된다.

 

java 명령어를 사용하여 클래스를 실행시키는 순간, 자바 프로세스가 시작되고, main() 메소드가 수행되면서 하나의 쓰레드가 시작되는 것이다.

 

왜 쓰레드를 만들었을까?

 

JVM은 기본적으로 아무런 옵션 없이 실행하면 적어도 32MB ~ 64MB의 물리 메모리를 점유하는 반면, 쓰레드를 하나 추가하면 1MB 이내의 메모리를 점유한다. 

 

대부분의 작업을 단일 쓰레드로 실행하는 것보단 다중 쓰레드로 실행하는 것이 더 빠른 시간에 결과를 제공해준다. 따라서 쓰레드를 사용하면 보다 빠른 계산을 처리할 수 있게 된다.

 

 

Runnable 인터페이스와 Thread 클래스

 

쓰레드를 생성하는 것은 크게 두 가지 방법이 있다. 하나는 Runnable 인터페이스를 사용하는 것이고, 다른 하나는 Thread 클래스를 사용하는 것이다. Runnable 인터페이스와 Thread 클래스는 모두 java.lang 패키지에 있기 때문에 이들을 사용할 때엔 별도로 import할 필요가 없다. 

 

Runnable 인터페이스에 선언되어 있는 메소드를 단지 하나다.

 

리턴 타입 메소드 이름 및 매개 변수 설명
void run() 쓰레드가 시작되면 수행되는 메소드

 

그에 반해 Thread 클래스는 매우 많은 생성자와 메소드를 제공한다. 우선 Runnable 인터페이스를 구현한 클래스의 예제를 를 살펴보자.

 

public class RunnableSample implements Runnable{
    public void run(){
        System.out.println("This is RunnableSample's run() method");
    }
}

쓰레드가 시작되면 한 줄을 출력하고 그 쓰레드는 끝난다. 

이번엔 Thread 클래스를 확장한 예제를 살펴보자.

 

public class ThreadSample extends Thread{
    public void run(){
        System.out.println("This is ThreadSample's run() method");
    }
}

RunnableSample 클래스와 ThreadSample 클래스는 모두 쓰레드로 실행할 수 있다는 공통점이 있지만 두 쓰레드 클래스를 실행하는 방식에서 차이점을 보인다. 

 

public class RunThreads {
    public static void main(String[] args) {
        RunThreads threads = new RunThreads();
        threads.runBasic();
    }

    public void runBasic(){
        RunnableSample runnable = new RunnableSample();
        new Thread(runnable).start();

        ThreadSample thread = new ThreadSample();
        thread.start();
        System.out.println("RunThreads.runBasic() method is ended");
    }
}

위 코드에서 두 가지에 집중하자.

 

  • 쓰레드가 수행되는 메소드는 run() 메소드로, 이 부분은 우리가 구현해야한다.
  • 쓰레드를 시작하는 메소드는 start()이다.

즉, Runnable 인터페이스를 구현하거나 Thread 클래스를 확장할 때에는 run() 메소드를 시작점으로 작성하야한다. 그러나 쓰레드를 시작하는 메소드는 run()이 아닌 start()메소드이며 우리가 직접 start() 메소드를 만들지 않아도 알아서 자바에서 run() 메소드를 수행하도록 되어있다.

 

왜 이 두가지 방법을 제공할까?

 

자바에선 하나의 클래스만 확장할 수 있다. 만일 클래스 A가 B를 extends한 상태에서 쓰레드로 구현하고자 한다면 어떻게 될까?

자바에서 Thread 클래스를 확장 받아야만 쓰레드로 구현할 수 있고 다중 상속이 불가능하므로 A 클래스를 쓰레드로 만들 수 없다. 그러나 인터페이스는 여러 개의 인터페이스를 구현해도 전혀 문제가 발생하지 않으므로 이러한 경우에는 Runnable 인터페이스를 구현해서 사용하면 된다.

 

정리하자면, 쓰레드 클래스가 다른 클래스를 확장할 필요가 있을 경우 Runnable 인터페이스를 구현하면 되며, 그렇지 않은 경우 쓰레드 클래스를 사용하는 것이 편하다.

 

 

이제 RunThreads 클래스를 실행하면 다음과 같이 나온다.

This is RunnableSample's run() method
This is ThreadSample's run() method
RunThreads.runBasic() method is ended
This is RunnableSample's run() method
RunThreads.runBasic() method is ended
This is ThreadSample's run() method

쓰레드가 끝났다는 출력문이 맨 마지막에 나오지 않는 경우가 있다. 왜 그럴까?

 

runBasic()이라는 쓰레드를 기동시키는 메소드에서 runnable 이라는 객체를 Thread 클래스의 start() 메소드로 시작한다. 이때, 시작한 start() 메소드가 끝날 때까지 기다리지 않고, 그 다음 줄에 있는 thread라는 객체의 start() 메소드를 실행한다. 이 줄도 마찬가지로, 새로운 쓰레드를 시작하므오 run() 메소드가 종료될 때까지 기다리지 않고, 바로 다음 줄로 넘어간다.

 

우리가 쓰레드를 구현할 때 start() 메소드를 호출하면, 쓰레드 클래스에 있는 run() 메소드의 내용이 끝나든, 끝나지 않든 간에 쓰레드를 시작한 메소드에서는 그 다음 줄에 있는 코드를 실행한다.

 

하나의 예제를 더 살펴보자.

 

public class RunMultiThreads {
    public static void main(String[] args) {
        RunMultiThreads sample = new RunMultiThreads();
        sample.runMultiThread();
    }

    public void runMultiThread() {
        RunnableSample[] runnable = new RunnableSample[5];
        ThreadSample[] thread = new ThreadSample[5];
        for (int loop = 0; loop < 5; loop++) {
            runnable[loop] = new RunnableSample();
            thread[loop] = new ThreadSample();

            new Thread(runnable[loop]).start();
            thread[loop].start();
        }
        System.out.println("RunMultiThreads.runMultiThread() method is ended");
    }
}

직접 확인해보면 알겠지만 메소드를 수행할 때마다 결과가 달라지며 메소드의 가장 마지막 줄에 있는 출력문이 가장 마지막에 수행되지 않는 것을 볼 수 있다.

 

그렇다면 새로 생성한 쓰레드는 언제 끝날까? 바로 run() 메소드가 종료되면 끝난다. 정말 끝나지 않는지를 확인하는 방법으론 데몬 쓰레드 여부가 있는데, 우선 앞서 보지 못했던 Thread 클래스의 생성자를 살펴보자.

 

 

Thread 클래스 생성자

 

Thread 클래스는 다음과 같이 8개의 생성자를 갖고 있다.

 

생성자 설명
Thread() 새로운 쓰레드를 생성한다.
Thread(Runnable target) 매개 변수로 받은 target 객체의 run() 메소드를 수행하는 쓰레드를 생성한다.
Thread(Runnable target, String name) 매개 변수로 받은 target 객체의 run() 메소드를 수행하고, name이라는 이름 갖는 쓰레드를 생성한다.
Thread(String name) name이라는 이름 갖는 쓰레드를 생성한다.
Thread(ThreadGroup group, Runnable target) 매개 변수로 받은 group의 쓰레드 그룹에 속하는 target 객체의 run() 메소드를 수행하는 쓰레드를 생성한다.
Thread(ThreadGroup group, Runnable target, String name) 매개 변수로 받은 group의 쓰레드 그룹에 속하는 target 객체의 run() 메소드를 수행하고, name이라는 이름 갖는 쓰레드를 생성한다. 
Thread(ThreadGroup group, Runnable target, String name, long stackSize) 매개 변수로 받은 group의 쓰레드 그룹에 속하는 target 객체의 run() 메소드를 수행하고, name이라는 이름 갖는 쓰레드를 생성한다. 단 해당 쓰레드의 스택의 크기는 stackSize 만큼만 가능하다.
Thread(ThreadGroup group, String name) 매개 변수로 받은 group의 쓰레드 그룹에 속하는 name이라는 이름 갖는 쓰레드를 생성한다.

 

 

자주 사용되는 sleep() 메소드

 

Thread 클래스에는 deprecated(=더 이상 사용하지 않는 것) 된 메소드도 많고, static(=객체를 생성하지 않아도 사용할 수 있는) 메소드도 많이 있다.

 

다시 말해 Thread에 있는 static 메소드는 대부분 해당 쓰레드를 위해서 존재하는 것이 아니라, JVM에 있는 쓰레드를 관리하기 위한 용도로 사용된다.

 

리턴 타입 메소드 이름 및 매개 변수 설명
static void sleep(long millis) 매개 변수로 넘어온 시간(1/1,000초)만큼 대기한다.
static void sleep(long millis, int nanos) 첫 번째 매개 변수로 넘어온 시작(1/1,000초)+두 번째 매개 변수로 넘어온 시간(1/1,000,000,000초)만큼 대기한다.

앞에서 run() 메소드가 끝나지 않으면 애플리케이션은 끝나지 않는다고 했다. 

 

public class EndlessThread extends Thread{
    public void run(){
        while(true){
            try{
                System.out.println(System.currentTimeMillis());
                Thread.sleep(1000);
            } catch (InterruptedException e){
                e.printStackTrace();
            }
        }
    }
}

run() 메소드 안을 보면 while(true)라고 되어있는데, 이렇게 while문 내의 조건이 true이면 이 while문은 break를 호출하거나 예외를 발생시키지 않는 한 멈추지 않는다. 

 

그리고 Tread.sleep() 메소드를 사용할 때에는 항상 try-catch로 묶어 줘야만 하고 적어도 InterruptedException으로 catch 해 주어야한다. sleep() 메소드는 InterruptedException을 던질 수도 있다고 선언되어 있기 때문이다.

 

이제 위 쓰레드를 실행할 클래스를 만들자.

 

public class RunEndlessThreads {
    public static void main(String[] args) {
        RunEndlessThreads sample = new RunEndlessThreads();
        sample.endless();
    }

    public void endless(){
        EndlessThread thread = new EndlessThread();
        thread.start();
    }
}

이렇게 하면 다음과 같이 밀리초가 화면에 지속적으로 출력될 것이다.

 

1675328335097
1675328336099
1675328337106
1675328338121
...

이처럼 main() 메소드의 수행이 끝나더라도, main() 메소드나 다른 메소드에서 시작한 쓰레드가 종료하지 않으면 해당 자바 프로세스는 끝나지 않는다. 다만 데몬 쓰레드는 예외인데 이는 아래서 살펴보자.

 

 

Thread 클래스의 주요 메소드

 

Thread 클래스의 주요 메소드는 크게 쓰레드의 속성을 확인하고 지정하기 위한 메소드쓰레드의 상태를 통제하기 위한 메소드로 나눌 수 있다.

 

먼저 속성을 확인하고 지정하는 메소드를 살펴보자.

 

리턴 타입 메소드 이름 및 매개 변수 설명
void run() 직접 구현해야 하는 메소드
long getId() 쓰레드의 고유 id를 리턴한다. JVM에서 자동으로 생성한다.
String getName() 쓰레드의 이름을 리턴한다.
void setName(String name) 쓰레드의 이름을 지정한다.
int getPriority() 쓰레드의 우선 순위를 확인한다.
void setPriority(int newPriority) 쓰레드의 우선 순위를 지정한다.
boolean isDaemon() 쓰레드가 데몬인지 확인한다.
void setDaemon(boolean on) 쓰레드를 데몬으로 설정할지 아닌지를 설정한다.
StackTraceElement[] getStackTrace() 쓰레드의 스택 정보를 확인한다.
Thread.State getState() 쓰레드의 상태를 확인한다.
ThreadGroup getThreadGroup() 쓰레드의 그룹을 확인한다.

 

여기서 쓰레드의 우선 순위라는 것을 대기하고 있는 상황에서 더 먼저 수행할 수 있는 순위를 말한다. 대부분 이 값을 디폴트로 사용하는 것이 권장되며 혹여 마음대로 우선 순위를 정했다가 잘못하면 장애로 연결될 수 있다.

 

쓰레드 API를 보면 다음과 같이 우선 순위와 관계있는 3개의 상수가 있다.

 

상수 값 및 설명
MAX_PRIORITY 가장 높은 우선 순위이며, 그 값음 10이다.
NORM_PRIORITY 일반 쓰레드의 우선 순위이며, 그 값은 5다.
MIN_PRIORITY 가장 낮은 우선 순위이며, 그 값은 1이다.

 

만약 우선 순위를 정할 일이 있다면, 숫자로 정하는 것보단 위 상수를 이용할 것을 권장한다.(되도록 지정하지 않는것이 베스트지만.)