자바 안에서 말하는 쓰레드란?

자바 파일이 컴파일러에 의해 JVM에 적재되고 나면, 하나의 프로세스가 실행된다. 이 프로세스 안에서 실행하는 일련의 흐름 단위를 쓰레드라고 한다.

쓰레드를 생성하는 방법

1. Runnable 인터페이스 사용

Runnable 인터페이스는 java.lang패키지에 있다. 그리고 이 인터페이스는 run()이라는 메소드만 선언되어있다.

In most cases, the Runnable interface should be used if you are only planning to override the run() method and no other Thread methods. This is important because classes should not be subclassed unless the programmer intends on modifying or enhancing the fundamental behavior of the class.
보통, Thread클래스에서 다른 메소드를 사용하지 않고 , run()메소드만 사용하고 싶을 경우 Runnable인터페이스를 통해 쓰레드를 생성한다고 한다.

Runnable 인터페이스를 이용하여 스레드를 생성하는 코드예제

package e.thread;

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

package e.thread;

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

    private void runBasic() {

        RunnableSample runnableSample = new RunnableSample();
        new Thread(runnableSample).start();
        System.out.println("RunThreads.runBasic() method is ended");
    }


}

Runnable구현 클래스 자체는 스레드가 아니다. 스레드가 실행하는 코드만 포함하고 있는 클래스이다. 따라서, Runnable 구현 클래스로 생성한 객체를 Thread 클래스 생성자의 인수로 넘겨 호출해야만 작업 스레드를 실행할 수 있다.

2. Thread 클래스 사용

Thread 클래스는 Runnable 인터페이스를 구현한 클래스이다. 상속받아서 사용해야 하므로 다른 클래스를 상속받을 수 없다.

Thread 클래스를 상속받아 스레드를 생성하는 코드예제

package e.thread;

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

package e.thread;

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

    private void runBasic() {
        ThreadSample threadSample = new ThreadSample();
        threadSample.start();
        System.out.println("RunThreads.runBasic() method is ended");
    }

}

여기서 우리가 염두해야 할 것은,

  • 쓰레드가 수행될 때는 run()이 실행된다.
  • 쓰레드를 시작하는 메소드는 start()이다.
    • 해당 메소드 실행 시, 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택을 생성한 다음, run()을 호출해 그 안에 run()이 저장된다. 즉, 쓰레드를 사용하기 위해 start()를 실행시키는 순간, 쓰레드만의 독립적인 작업 공간인 호출스택이 만들어지는 것이다.
  • 한 번 사용한(start()가 이미 호출된) 쓰레드는 재사용 할 수 없다.

그래서, Runnable 인터페이스를 구현하거나, Thread 클래스를 확장할 때는 run()메소드를 시작점으로 작성해야 한다.

그렇다면 왜 이 두가지 방법을 제공하는가?

  • 쓰레드 클래스가 다른 클래스를 확장할 필요가 있을 경우에는 Runnable 인터페이스를 구현한다.
  • 그렇지 않을 경우(다른 클래스 확장 x)에는 쓰레드 클래스를 사용하는 것이 편하다.

Thread 클래스의 주요 메소드

  • run() : 쓰레드 실행시 구현되어야 하는 부분
  • getId() : 쓰레드의 고유 ID 리턴
  • getName(), setName(String name) : 쓰레드의 이름을 리턴, 설정
  • getPriority(), setPriority(int newPriority) : 쓰레드의 우선 순위 확인, 지정
    • MAX_PRIORITY : 가장 높은 우선순위, 값은 10이다.
    • NORM_PRIORITY : 일반 쓰레드의 우선순위, 값은 5이다.
    • MIN_PRIORITY : 가장 낮은 우선순위, 값은 1이다.
  • isDaemon(), setDaemon(boolean on): 쓰레드 데몬여부 확인, 데몬으로 설정
  • getStackTrace() : 쓰레드의 스택정보 확인
  • getState() : 쓰레드의 상태 확인
  • getThreadGroup() : 쓰레드의 그룹 확인
package e.thread;

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

    private void checkThreadProperty() {
        ThreadSample thread1 = new ThreadSample();
        ThreadSample thread2 = new ThreadSample();
        ThreadSample daemonThread = new ThreadSample();

        System.out.println("thread1 id =" + thread1.getId());
        System.out.println("thread2 id =" + thread2.getId());

        System.out.println("thread1 name =" + thread1.getName());
        System.out.println("thread2 name =" + thread2.getName());

        System.out.println("thread1 priority =" + thread1.getPriority());

        daemonThread.setDaemon(true);
        System.out.println("thread1 isDaemon=" + thread1.isDaemon());
        System.out.println("daemonThread isDaemon="+daemonThread.isDaemon());

    }
}

예제 코드 결과:

thread1 id =13
thread2 id =14
thread1 name =Thread-0
thread2 name =Thread-1
thread1 priority =5
thread1 isDaemon=false
daemonThread isDaemon=true

쓰레드의 종류

  • 사용자 쓰레드(비데몬 쓰레드) : 사용자가 생성하여 제어하는 쓰레드, JVM이 해당 쓰레드가 끝날 때까지 기다린다.
  • 데몬 쓰레드 : 수행 완료여부에 상관없이 JVM이 마칠 수 있다. 일반적으로 start()전에 데몬 쓰레드로 셋팅하면 사용자 쓰레드를 데몬 쓰레드로 변경 할 수 있다.
    • 사용자가 직접 생성하지 않아도 기본 적으로 데몬 쓰레드를 여러개 생성한다.
    • 가비지 컬렉터, 모니터링 자동저장, 화면 갱신등에 주로 데몬 쓰레드를 이용한다.
package e.thread;

public class DaemonThread extends Thread{
    public void run(){
        try{
            Thread.sleep(Long.MAX_VALUE);
        } catch (Exception e){
            e.printStackTrace();
        }
    }
}
package e.thread;

public class RunDaemonThreads {
    public static void main(String[] args) {
        DaemonThread thread = new DaemonThread();
       //thread.setDaemon(true);
        //thread.start();
    }

주석 해제를 하지 않고 그대로 실행하면, 함수가 종료되지 않고 계속 수행되는 것을 볼 수 있다.
여기서 주석 해제를 하고 (=> 쓰레드를 데몬 쓰레드로 셋팅하고) 실행하면, 프로세스가 바로 종료된다.


synchronized

앞서 여러 곳에서 thread-safe라는 단어를 여러 차례 썼다. 이 말은 즉슨, 여러 쓰레드에서 어떤 한 객체를 접근하더라도 도이에 연산을 수행하여 값이 꼬이는 경우가 없다는 말이다. 이렇게 데이터 충돌 현상 없이 수행하기 위해서는 synchronized를 사용하는데, 아래 두 가지 방법으로 사용할 수 있다고 한다.

  • 메소드 자체를 synchronized로 선언하는 방법(synchronized methods)
  • 메소드 내 특정 문장만 synchronzied로 감싸는 방법(synchronized statements)

메소드를 synchronized로 선언하는 방법

백문이 불여일견, 코딩을 해보며 확인해본다.

package e.thread.sync;

public class CommonCalculate {
    private int amount;
    public CommonCalculate(){
        amount = 0;
    }
    public void plus(int value){
        amount += value;
    }
    public void minus(int value){
        amount -= value;
    }
    public int getAmount(){
        return amount;
    }
}
package e.thread.sync;

public class ModifyAmountThread extends Thread{
    private  CommonCalculate calc;
    private boolean addFlag;

    public ModifyAmountThread(CommonCalculate calc, boolean addFlag){
        this.calc = calc;
        this.addFlag = addFlag;
    }

    public void run(){
        for(int i = 0; i < 10000; i++){
            if(addFlag) calc.plus(1);
            else calc.minus(1);
        }
    }
}
package e.thread.sync;

public class RunSync {

    public static void main(String[] args) {
        RunSync runSync = new RunSync();
        runSync.runCommonCalculate();
    }

    private void runCommonCalculate() {
        CommonCalculate calc = new CommonCalculate();

        ModifyAmountThread thread1 = new ModifyAmountThread(calc,true);
        ModifyAmountThread thread2 = new ModifyAmountThread(calc, true);

        thread1.start();
        thread2.start();
        try {
            thread1.join();
            thread2.join();
            System.out.println("Final value is " + calc.getAmount());
        } catch (InterruptedException e){
            e.printStackTrace();
        }
    }
}

각각 계산을 연산을 수행하는 클래스, 더하기/빼기를 멀티쓰레드로 수행하는 클래스, 상위 2개의 객체를 실질적으로 실행시키는 메인메소드로 구분지을 수 있다.
여기서 코드를 실행해보면, 20000이 아닌 훨씬 더 작은 숫자가 나온다. CommonCalculate객체 내 amount인스턴스 변수 때문이다.

 public void plus(int value){
        amount += value;
    }

위 코드 실행 구간에서, 기존 amount에서 value 값을 더해 amount 값에 대입하기 직전, 다른 쓰레드가 들어와서 다시 더하기 연산을 수행하게 된다. 결국 좌측 항에 치환이 2번 되어야 하는데 1번만 되는 이런 현상이 발생하여 결과적으로는 20000보다 더 작은 숫자가 출력되는 것이다.

그래서 우리는 해당 코드를 아래와 같이 수정한다.

 public synchronized void plus(int value){
        amount += value;
    }

이렇게 예약어 synchronized를 추가함으로써 동일한 객체를 참조하는 다른 쓰레드에서 이 메소드를 변경하려고 하면, 먼저 들어온 쓰레드가 종료될 때 까지 기다렸다가 수행을 하게 된다. (다시 실행해보면 20000이 출력된다!)

하지만 synchronized는 성능상 문제점이 있는데 ?

 public synchronized void plus(int value){
        amount += value;
        methodA();
        methodB();
        methodC();
        methodD();
        methodE();
    }

위와 같은 상황이 발생하였을 때, amount 인스턴스 변수 충돌 문제를 위해서 후속단의 메소드A~E를 처리 할 때 필요 없는 대기시간이 발생한다.(메소드 A~E에서는 동기화가 필요 없을 때를 가정한다.
이러한 경우, 메소드 전체를 감싸기 보다는 동기화가 필요한 변수만 synchronized 블록처리를 해주면 좋다.

Object lock = new Object();
 public synchronized void plus(int value){
 	synchronized(lock){
 		amount += value;
 	}
        methodA();
        methodB();
        methodC();
        methodD();
        methodE();
    }

이렇게 변경하면, amount변수를 처리하는 부분만 여러 쓰레드에서 처리를 하지 않도록 설정할 수 있다. 추가적으로, 클래스 내 여러 인스턴스 변수가 선언되어 각 변수별로 synchroznied 처리를 해주어야 할 경우, 여러 lock 객체를 생성하여 각각 Thread-safe하게 처리 할 수 있다.

Object amountLock = new Object();
Object hitLock = new Object();
 public synchronized void plus(int value){
 	synchronized(amountLock){
 		amount += value;
 	}
 	synchronized(hitLock){
 		hit += 1;
 	}
        methodA();
        methodB();
        methodC();
        methodD();
        methodE();
    }

쓰레드의 상태와 제어

쓰레드의 상태는 java.lang.Thread 클래스 내부에 State라는 이름을 가진 열거형 클래스로 관리되고 있다.

  • NEW : 쓰레드가 생성되었지만 아직 실행되지 않은 상태
  • RUNNABLE: 현재 CPU를 점유하고 작업을 수행 중인 상태. 운영체제의 자원분배(컨텍스트 스위칭 등)으로 인해 WAITING, BLOCKED 등이 될 수 있다.
  • BLOCKED : Monitor를 획득하기 위해 다른 스레드가 락을 해제하기를 기다리는 상태
  • WAITING : wait(), join(), park() 메소드 등을 이용해 대기하고 있는 상태
  • TIME_WAITING : sleep(), wait(), join() 등을 이용해 대기하고 있는 상태 ( WAITING 상태와의 차이점은 메서드의 인수로 최대 대기 시간을 명시할 수 있어, 외부적 변화 뿐 아니라 시간에 의해서도 대기 상태가 해제 될 수 있다는 점)
  • TERMINATED : 쓰레드가 종료된 상태, 사실상 소멸된 것으로 이 상태로 존재하지는 않는다.

쓰레드 객체의 상태를 제어하는 Thread 클래스의 메소드

  • sleep() : 주어진 시간동안 쓰레드를 WAITING상태로 만든다.
    • 대기 시간동안 interrupt()메소드가 호출되면 InterruptedException이 발생하기 때문에 예외처리가 필요하다.
  • interrupt() : 일시정지 상태의 쓰레드에서 InterruptedException예외를 발생시켜, 예외 처리 코드(catch)에서 실행 대기 상태로 가거나, 종료 상태로 갈 수 있도록 한다.
    • 쓰레드 시작 전, 혹은 종료된 상태에서 호출 시 예외나 에러 없이 다음 문장으로 넘어간다.
  • join() : 다른 쓰레드가 실행을 종료할 때 까지 대기한다.
    • 대기 시간동안 interrupt()메소드가 호출되면 InterruptedException이 발생하기 때문에 예외처리가 필요하다.
  • isAlive() : 쓰레드가 살아있는지 확인한다.
  • yield() : 현재 실행중인 쓰레드는 양보를 하고 다음 우선순위를 가진 쓰레드가 실행할 수 있도록 하는 메소드이다.

쓰레드 객체의 상태를 제어하는 Object 클래스의 메소드

  • wait() ,wait(long timeout),wait(long timeout, int nanos): 동기화 블록 내에서 쓰레드를 일시정지 상태로 만든다. 매개변수로 시간을 입력하지 않을 경우, notify(), notifyAll()등 메소드에 의해 실행 대기상태로 갈 수 있다.
  • notify() , notifyAll() : 동기화 블록 내에서 wait()메소드에 의해 일시 정지 상태에 있는 쓰레드를 실행 대기 상태로 만든다.

쓰레드 그룹

쓰레드 그룹은 관련된 쓰레드를 묶어 관리할 목적으로 이용된다. JVM이 실행될 때, system 쓰레드 그룹을 만들고, JVM은 운영에 필요한 쓰레드들을 생성한다. 여기서 생성한 쓰레드들을 system 쓰레드 그룹에 포함시킨다.


참고 자료 :

  1. 자바의 신 제 2권, 25장 - 쓰레드는 개발자라면 알아두는 것이 좋아요

oksusutea's blog

꾸준히 기록하려고 만든 블로그