rueMi 2024. 4. 15. 20:39

JAVA의 Thread

자바에서는 여러 스레드를 생성하여 여러 작업들을 병렬적으로 수행할 수 있다. 오늘은 이에 대해 알아보자!


Lang.Thread의 주요 메소드

우선 간단하게 스레드와 관련된 메서드를 먼저 보자. 각각은 스레드의 상태를 변화시키는 메서드이다.

start() NEW에서 RUNNABLE로 보낸다.
run() 스레드 상태가 RUNNING되면, run 메소드에 오버라이딩된 로직을 수행한다
yield() 우선권이 동일한 스레드에게 실행 기회를 양보한다. 해당 스레드는 RUNNING에서 RUNNABLE 상태로 바뀐다.
sleep() 현재 실행중인 스레드를 주어진 시간동안 TIMED_WAITING 상태로 빠트린다.
join() 다른 스레드와 협동 작업을 할 때 주로 쓴다. 호출되면 BLOCKED 상태가 되었다가, 기다리는 스레드의 작업이 끝나면 다시 RUNNABLE로 간다.

Thread 생성하기

실제로 java 코드로 스레드를 생성해보자. 자바 class 파일을 하나 만들면 기본적으로 main thread가 생성된다. 다음 두 가지 방법을 통해 추가적인 스레드를 만들어 병렬적으로 작업을 수행할 수 있다.

Thread Class

  1. 새로운 Thread Class를 생성한다
  2. extends 키워드를 통해 Thread Class를 상속 받는다.
  3. run 메소드를 오버라이딩한다.

[Main Thread]

package thread;

public class MainThread {
    public static void main(String[] args) {
        System.out.println("NEW : main thread");
        System.out.println("RUNNING 1 : " + SubThread.currentThread().getName());

        SubThread subThread = new SubThread();
        subThread.start();

        System.out.println("RUNNING 3 : " + SubThread.currentThread().getName());
        System.out.println("TERMINATED : main thread");
    }
}

[Sub Thread]

package thread;

public class SubThread extends Thread{

    @Override
    public void run() {
        super.run();
        System.out.println("NEW : sub thread");
        System.out.println("RUNNING 2 : " + SubThread.currentThread().getName());
        System.out.println("TERMINATED : sub thread");
    }
}

실행 결과

 

코드의 순서 대로라면 RUNNING 1 → 2 → 3으로 실행되어야 한다. 하지만 실행 결과를 보면 메인 스레드가 종료된 이후에 sub thread가 생성되고, 실행되고, 종료됨을 볼 수 있다. 즉, 코드 흐름에 따라 실행되는 것이 아니라, 각 스레드가 병렬적으로 실행됨을 알 수 있다.

 

또한 여기서 서브 스레드에 대해 start()를 해주지 않으면, 스레드가 생성 되기만 할 뿐 RUNNABLE 상태로 변하지 않기 때문에 실행되지 않는다.


Runnable interface

  1. 새로운 Thread Class를 생성한다.
  2. implements 키워드를 통해 Runnable Interface를 구현한다.
  3. run 메소드를 구현한다.

Runnable Interface

package java.lang;

@FunctionalInterface //인터페이스 내부에 메소드 1개가 있다는 의미
public interface Runnable {
    public abstract void run();
}

 

주석을 제외하면 위와 같이 매우 간단한 인터페이스이다. 해당 인터페이스는 abstract 키워드를 통해 run 메소드를 무조건 구현하라고 강제하고 있다.

 

Main Thread

package thread.runnable_interface;

public class MainThread {
    public static void main(String[] args) {
        System.out.println("NEW : main thread");
        System.out.println("RUNNING 1 : Main Thread");

        Thread thread = new Thread(new SubThread());
        thread.start();

        System.out.println("RUNNING 3 : Main Thread");
        System.out.println("TERMINATED : main thread");
    }
}

Sub Thread

package thread.runnable_interface;

public class SubThread implements Runnable{

    @Override
    public void run() {
        System.out.println("NEW : sub thread");
        System.out.println("RUNNING 2 : SubThread");
        System.out.println("TERMINATED : sub thread");
    }
}

실행 결과

첫 번째 실행 결과이다

두 번째 실행 결과이다.

 

위와 같이 실행 순서가 코드의 흐름이 아닌 병렬적으로 실행됨을 알 수 있다. 그렇기 때문에 스레드가 끝나는 시점도 실행할 때마다 다르다.


start()와 run()의 차이점

사실 위 예제에서는 start()를 호출함으로써 스레드를 실행했다. 하지만 맨 처음 표에서도 볼 수 있듯이 start()와 run()은 별개의 메서드이다.

start() : NEW → RUNNABLE : 스레드가 실행 가능하게 만들어준다

run() : RUNNABLE → RUNNING : 준비된 스레드의 run 메서드 실행한다.

 

그럼 위 두 메서드의 차이는 무엇일까? start()를 해도 run()을 해도 스레드가 실행된다. 아래 Runnable 인터페이스로 구현한 스레드의 예시를 자세히 다시 보자.

Main Thread

이번에는 코드를 조금 수정해서 10개의 서브 스레드를 생성하였다.

package thread.runnable_interface;

public class MainThread {
    public static void main(String[] args) {
        System.out.println("NEW : main thread");
        System.out.println("RUNNING 1 : Main Thread");

        for (int i = 0; i < 10; i++){
            Thread thread = new Thread(new SubThread(i));
            thread.start();
        }

        System.out.println("RUNNING 3 : Main Thread");
        System.out.println("TERMINATED : main thread");
    }
}

Sub Thread

package thread.runnable_interface;

public class SubThread implements Runnable{
    int num;
    public SubThread(){
        this.num = 0;
    }
    public SubThread(int num){
        this.num = num;
    }
    @Override
    public void run() {
        System.out.println("NEW : sub thread " + this.num);
        try{
            System.out.println("RUNNING : SubThread " + this.num);
            Thread.sleep(100);
        }catch(InterruptedException e){
            e.printStackTrace();
        }
        System.out.println("TERMINATED : sub thread " + this.num);
    }
}

start()로 실행한 결과 + run()으로 실행한 결과

 

다음은 start()로 실행한 결과이다.

다음은 run()으로 실행한 결과이다.

결론부터 말하자면 스레드는 원래 각 스레드마다 병렬적, 동시적으로 수행되기 때문에 알고 있는 개념에 의해서는 위의 결과처럼 랜덤하게 실행 및 종료되는 것이 정상이다. 이와 달리 run()으로 실행하면 스레드들이 순차적으로 실행됨을 알 수 있다.

 

여기서 좀 헷갈릴 수 있다. 정리해보자.

  1. 그래서 start()와 run()이 뭐가 다른가? 뭐가 맞는 것인가?
  2. start()만 실행했는데, 즉 스레드를 RUNNABLE로만 바꿨는데 왜 자동으로 실행되는것인가?

이에 대해서 차근차근 알아보자.

 

프로세스의 메모리 구조

먼저 프로세스의 메모리 구조에 대해 알아야 한다.

 

왼쪽은 단일 스레드 프로세스, 오른쪽은 다중 스레드 프로세스이다.

다중 스레드는 Heap, Code, Data 영역은 공유하지만 Stack 영역은 각 스레드별로 독립적으로 존재한다.

 

여기서 run()과 start()의 차이점이 나온다.

 

Thread 클래스의 run() 메소드를 호출하여 실행하는 것은, 단순히 오버라이딩 한 메소드를 호출하는 것에 불과하다. 즉, 메인 스레드에서의 Stack 영역을 차지하여 run()을 호출한 순서대로, 순차적으로 실행될 수밖에 없다.

 

하지만 start() 메서드를 실행하는 것은 JVM이 스레드를 위한 stack 영역을 새로 만들기 때문에 run 메소드 호출과는 다르게 독립적으로 동작하는 것이다.

 

start() 메소드

그렇다면 왜 start()만 했는데 스레드가 실행되기 까지 하는가?

그것은 start 메소드의 내부를 보면 알 수 있다.

 

아래는 본인의 컴퓨터의 인텔리제이에서 start 메서드를 본 상태이다

원래 start0()라는 네이티브 함수가 보여야 하는데, 아쉽게도 startImpl()에 가려진건지 여기서는 보이지 않았다.

public synchronized void start() {
	boolean success = false;

	if (started) {
		// K0341 = Thread is already started
		throw new IllegalThreadStateException(com.ibm.oti.util.Msg.getString("K0341")); //$NON-NLS-1$
	}
	group.add(this);

	try {
		synchronized (lock) {
			startImpl();
			success = true;
		}
	} finally {
		if (!success) {
			group.remove(this);
		}
	}
}

 

아래는 다른 블로그에서 가져온 start() 메소드 내부이다.

public synchronized void start() {
        /**
         * This method is not invoked for the main method thread or "system"
         * group threads created/set up by the VM. Any new functionality added
         * to this method in the future may have to also be added to the VM.
         *
         * A zero status value corresponds to state "NEW".
         */
        if (threadStatus != 0)
            throw new IllegalThreadStateException();

        /* Notify the group that this thread is about to be started
         * so that it can be added to the group's list of threads
         * and the group's unstarted count can be decremented. */
        group.add(this);

        boolean started = false;
        try {
            start0();
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
                /* do nothing. If start0 threw a Throwable then
                  it will be passed up the call stack */
            }
        }
    }

 

내부 함수에 start0()이 있는 것을 확인할 수 있다. 이 메소드는 네이티브 함수로 내부적으로 run 메소드를 호출하도록 구현되어 있다.

 

아마도 필자의 코드에서는 startImpl이 이 역할을 할 것으로 예상된다.

 

이와 같은 이유로 start를 실행했을 때 run이 실행됨을 알 수 있다. 또한, run은 단순히 함수를 호출하는 것이므로, 스레드를 실행할 때에는 start()를 호출해야 함을 잊지 말자.


Thread Class와 Runnable Interface의 차이점

Thread Class를 상속하여 스레드를 생성하든, Runnable Interface를 구현하여 스레드를 생성하든 run 메서드 내부의 코드의 실행과 성능은 동일하다. 다만 구현 과정에서 차이가 난다.

 

객체 지향 프로그래밍에서 ‘상속’은 부모의 기능을 물려받아 재사용 또는 재정의 하고 새로운 기능들은 추가하여 확장하는 것을 의미한다. 하지만 자바에서는 다중 구현은 가능하지만 다중 상속이 불가능하기 때문에 Thread 클래스의 run 메소드 하나 때문에 상속 기능을 사용하는 것은 비효율적이다.

 

이를 해결하기 위해 스레드 생성 시 반드시 구현해야 하는 run 메소드를 Thread 클래스와 분리하고, 구현을 강제하는 인터페이스를 사용하는 것이다. 이것이 바로 Runnable 인터페이스이다. 만약 Thread 클래스의 또 다른 기능을 확장하거나 재정의 해야 할 경우라면, Runnable 인터페이스 대신 Thread 클래스를 상속하는 것이 더 효과적일 수 있다.

 

정리하자면, Thread 클래스 기능의 확장 여부에 따라 Thread 클래스를 상속 받을 것인지 Runnable 인터페이스를 구현할 것인지 선택할 수 있다.

 

  Runnable 인터페이스 구현  Thread 클래스 상속
코드 implements Runnable extends Thread
범위 단순히 run() 메소드만 구현하는 경우 Thread 클래스의 기능 확장이 필요한 경우
설계 논리적으로 분리된 테스크 설계에 장점 테스크의 세부적인 기능 수정 및 추가에 장점
상속 Runnable 인터페이스에 대한 구현이 간결 Thread 클래스 상속에 따른 오버헤드

Synchronized 키워드

뮤텍스와 세마포어 같은 개념으로, 1개의 공유 데이터를 여러 개의 스레드가 쓰려고 할 때 데이터 오염을 막아준다.

 

해당 키워드를 메소드에 달아주거나, 코드의 특정 부분을 synchronized 블록으로 처리해주면, 어떤 스레드가 어떤 코드를 차지하는 동안 다른 스레드의 접근을 막아낸다. 즉, 다른 스레드가 객체의 synchronized 메소드를 호출한 경우, 메소드를 호출하지 못하고 모니터링 락이 반환되어 객체의 잠금 상태가 풀리 때까지 대기해야 한다. 이런 상태를 synchronized blocking이라고 한다.

 

바로 위에서 본 start 메서드 내부에도 해당 키워드가 존재한다.


참고자료

 

[Java] 자바 쓰레드 생성(Thread, Runnable)

이 글은 "자바 온라인 스터디" 공부한 내용을 정리하여 쓴 글입니다. Process vs Thread 자바의 쓰레드를 설명하기 이전에 프로세스와 쓰레드의 차이에 대해 간략하게나마 알 필요가 있습니다. Process

math-coding.tistory.com

 

 

Thread의 모든 것! (스레드 생성, 생명주기, 정보, 상태, 스케줄링, 주요 메소드, synchronized)

참조문서 : https://docs.oracle.com/javase/8/docs/api/java/lang/Thread.html1. 쓰레드란?자바의 메인메소드 역시 하나의 실행흐름으로서, 메인 쓰레드에 해당한다. 이것은 main() 메소드에서 Thread.currentThread().getNam

sjh836.tistory.com