본문 바로가기

스터디/자바

자바의 스레드(Thread)

저번 java.concurrent 패키지의 동기화 장치들을 살펴보면서 스레드에 대해 다시 한번 봐야겠다는 생각이 들었다. 스레드에 대해 간단하게 알아보자.

1. 프로세스와 스레드

프로세스란 운영체제에서 실행 중인 하나의 어플리케이션을 의미한다. 프로그램을 실행하면 운영체제로부터 실행에 필요한 메모리를 할당 받아 해당하는 코드를 실행하는 작업의 단위를 프로세스라 한다. 

그렇다면 스레드는 무엇일까? 스레드프로세스 안에서 독립적으로 실행되는 흐름의 단위를 말한다. 그리고 하나의 프로세스가 여러 스레드를 실행하는 것을 멀티 스레드라고 한다.

출처 : https://ko.wikipedia.org/wiki/%EC%8A%A4%EB%A0%88%EB%93%9C_(%EC%BB%B4%ED%93%A8%ED%8C%85)

2. 스레드 생성과 실행

스레드의 생성방법은 크게 2가지가 있다.

1. Thread 클래스를 상속받아 생성

public class ExtendThread extends Thread {

    @Override
    public void run() {
        //실행할 코드
        System.out.println("Thread 클래스 상속");
    }
}

실행방법은 간단하다. Thread를 상속받은 클래스의 인스턴스를 생성해서 start() 메소드만 호출하면 된다.

ExtendThread extendThread = new ExtendThread();
extendThread.start();

실행 결과

2. Runnable 인터페이스를 구현해서 생성

public class ImplementRunnable implements Runnable{
    @Override
    public void run() {
        //실행할 코드
        System.out.println("Runnable 인터페이스 구현");
    }
}

실행방법은 Thread 인스턴스를 생성할때 생성자의 매개값으로 Runnable 구현 객체를 넘겨주고 생성한 Thread 인스턴스의 start() 메소드를 호출하면 된다.

ImplementRunnable implementRunnable = new ImplementRunnable();
Thread thread = new Thread(implementRunnable);
thread.start();

실행 결과

스레드의 이름

스레드는 자신의 이름을 지정할 수 있다. 만약 스레드를 생성할때 특별한 이름을 설정하지 않는다면 "Thread-스레드 번호" 로 자동 설정된다.

public static void main(String[] args) {
        ImplementRunnable implementRunnable = new ImplementRunnable();
        Thread thread = new Thread(implementRunnable);
        thread.start();

        ExtendThread extendThread = new ExtendThread();
        extendThread.start();
}

실행 결과

스레드의 이름은 Thread 클래스의 getName() 메소드를 호출하여 이름을 가져올 수 있다.
또한 현재 스레드를 가져오려면 Thread 클래스의 정적 메소드인 currentThread() 메소드를 호출하면 된다.

스레드의 이름을 지정하는 몇가지 방법을 보자.

1. 생성자 이용

위의 스레드 생성예제를 살짝 수정하여 구현하였다.

public class ExtendThread extends Thread {

    public ExtendThread() {
    }

    public ExtendThread(String name) {
        super(name);
    }

    @Override
    public void run() {
        //실행할 코드
        System.out.printf("[%s] : %s\n",currentThread().getName(), "Thread 클래스 상속");
    }
}

Thread 클래스를 상속한 ExtendThread는 Thread 클래스의 생성자를 호출하여 이름을 지정하는 방법이 있다.

public static void main(String[] args) {
        ImplementRunnable implementRunnable = new ImplementRunnable();
        Thread thread = new Thread(implementRunnable,"Implement");
        thread.start();

        ExtendThread extendThread = new ExtendThread("Extend");
        extendThread.start();
}

Runnable을 구현하여 스레드를 생성하는 경우엔 Thread 인스턴스를 생성하고 Runnable 객체를 매개값으로 넘겨줄때 스레드 이름을 같이 매개값으로 넘겨서 스레드 이름을 지정할 수 있다.

2. setName() 메소드 이용

Thread 인스턴스를 생성하고 setName() 메소드를 호출하여 스레드의 이름을 지정할 수 있다.

public static void main(String[] args) {
        ImplementRunnable implementRunnable = new ImplementRunnable();
        Thread thread = new Thread(implementRunnable);
        thread.setName("Implement");
        thread.start();

        ExtendThread extendThread = new ExtendThread();
        extendThread.setName("Extend");
        extendThread.start();
}

실행 결과

스레드의 우선순위 

멀티 스레드는 동시성 또는 병렬성으로 실행된다. 동시성은 하나의 코어에서 여러개의 스레드가 번갈아가며 실행하는 것이고,
병렬성은 여러개의 코어에서 개별 스레드를 동시에 실행하는 것을 말한다.
즉, 싱글 코어에서 멀티 스레드 작업은 스레드가 번갈아가며 실행되는 동시성 작업이다.

스레드의 개수가 코어의 수보다 많을 경우 스레드를 어떤 순서에 의해 동시성으로 실행할 것인가를 결정해야 하는데 이를 스레드 스케줄링이라고 한다.
자바의 스레드 스케줄링 방식은 우선순위 방식을 사용한다. 우선순위 방식은 우선순위가 높은 스레드를 더 먼저 실행하는 방식으로 각 스레드의 우선순위를 코드 상에서 지정할 수 있다.

스레드의 우선순위는 1 ~ 10의 값으로 부여되는데 1의 우선순위가 가장 낮고, 10이 가장 높다. 
스레드의 우선순위를 따로 지정하지 않는다면 스레드들은 기본적으로 5의 우선순위를 할당받는다. 우선순위를 지정하기 위해선 Thread 클래스의 setPriority() 메소드를 사용하면 된다.

Thread thread = new Thread();
thread.setPriority(Thread.MAX_PRIORITY);
우선순위 매개값은 1 ~ 10의 값을 직접 주어도 되지만 코드의 가독성을 높이기 위해 Thread 클래스의 우선순위 상수값을 사용할 수 도 있다.
MIN_PRIORITY = 1
NORM_PRIORITY = 5
MAX_PRIORITY = 10

 

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

멀티 스레드를 사용하면서 스레드 간에 공유 객체가 있을때 동기화 문제가 발생할 수가 있다. 아래의 출금 예제를 통해 알아보자.

public static class Shared {
        public int cash;

        public Shared(int cash) {
            this.cash = cash;
        }

        public void withdraw(int amount) {
            if (this.cash >= amount) {
                try {
                    Thread.sleep(500);
                    this.cash -= amount;
                    System.out.printf("[%s] - %d 출금, 잔액 %d \n",
                            Thread.currentThread().getName(),amount,this.cash);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static class Task implements Runnable {
        private Shared shared;

        public Task(Shared shared) {
            this.shared = shared;
        }

        @Override
        public void run() {
            while (shared.cash >= 0) {
                int amount = (new Random().nextInt(3) + 1) * 100;
                shared.withdraw(amount);
            }
        }
    }

각 스레드는 Shared라는 공유 객체를 가지고 있고 객체의 잔액이 0이 될때까지 랜덤한 값으로 출금을 한다.

Shared 객체의 withdraw() 메소드는 매개값으로 받은 amount의 값이 멤버 필드로 가지고 있는 cash 보다 작을 경우에만 cash값을 감소시킨다. 

아래와 같이 실행했을때 어떤 결과가 나올까?

public static void main(String[] args) {
        Shared shared = new Shared(1000);

        Thread thread1 = new Thread(new Task(shared));
        Thread thread2 = new Thread(new Task(shared));
        thread1.start();
        thread2.start();
}

실행 결과

실행 결과와 같이 잔액이 음수값을 가지는 경우가 생겼다. 한 스레드가 변경중인 객체를 다른 스레드가 동시에 변경하였기 때문에 이런 상황이 발생하였다. 이러한 동기화 문제는 synchronized 키워드를 사용하면 해결할 수 있다.

멀티 스레드에서 단 하나의 스레드만 실행할 수 있는 코드 영역을 임계 영역이라고 하는데, 이 임계영역을 지정하기 위해 동기화 메소드동기화 블록을 사용하면 된다. 

동기화 메소드는 메소드 선언에 synchronized 키워드를 붙이면 된다. synchronized 키워드는 인스턴스와 정적 메소드 어디든 붙일 수 있다.

public synchronized void withdraw(int amount) {
//...
}

실행 결과

동기화 메소드는 메소드 전체 영역이 임계 영역으로 스레드가 해당 메소드를 실행하는 즉시 객체에는 잠금(lock)이 일어나고 스레드가 동기화 메소드를 실행 종료하면 잠금이 풀린다.

메소드의 일부 영역만 임계 영역으로 만들고 싶다면 동기화 블록을 이용하면 된다. 

public void withdraw(int amount) {
	synchronized (this) {
		if (this.cash >= amount) {
			try {
				Thread.sleep(500);
				this.cash -= amount;
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
	System.out.printf("[%s] - %d 출금, 잔액 %d \n",
		Thread.currentThread().getName(),amount,this.cash);
}

 

3. 스레드의 상태 

스레드를 생성하고 start() 메소드로 실행을 하면 곧바로 스레드가 실행되는 것처럼 보이지만 사실은 실행 대기 상태(RUNNABLE) 가 된다.

스레드가 실행 대기 상태에서 스레드 스케줄링으로 선택되었을때 run() 메소드를 실행하는데 이때를 실행 상태라 한다.

자신의 run() 메소드를 전부 실행하고 run()메소드가 종료되면, 스레드가 종료되는데 이때를 종료 상태(TERMINATED) 라 한다.

때때로 스레드는 실행 상태에서 일시 정지 상태로 가기도 하는데. 일시 정지 상태는 스레드가 실행할 수 없는 상태로 WAITING, TIMED_WAITING, BLOCKED 가 있다.
스레드가 일시 정지 상태에서 실행 상태로 가기 위해선 실행 대기 상태를 거쳐서 가야한다.

Java 5 부터 Thread에 State라는 열거 상수가 추가되었다. 그리고 getState() 메소드를 이용해 스레드의 현재 상태를 가져올 수 있다. 

상태 열거 상수 설명
객체 생성 NEW 스레드가 생성되었지만, 아직 start 메소드가 호출되지 않은 상태
실행 대기 RUNNABLE 실행 가능한 상태, 스레드 스케줄링에 의해 실행을 기다리는 상태
일시 정지 BLOCKED 사용하고자 하는 객체의 락이 풀릴때 까지 기다리는 상태
WAITING wait(), join(), LockSupport의 park() 메소드에 의해 멈춘 상태, 다른 스레드가 통지할 때 까지 기다린다
TIMED_WAITING 특정 시간동안 기다리는 상태
종료 TERMINATED 실행을 마치고 종료된 상태

출처 : https://www.geeksforgeeks.org/lifecycle-and-states-of-a-thread-in-java/

4. 스레드 상태 제어

스레드의 상태를 제어하기 위해선 Thread 클래스 또는 Object 클래스의 메소드를 이용하면 된다.

실행 -> 일시 정지

메소드 설명
sleep() 매개값으로 받은 시간만큼 스레드를 일시 정지 상태로 만든다.
join() 다른 스레드의 join 메소드를 호출한 스레드는 일시 정지 상태가 된다. 다른 스레드가 종료 되거나 매개값으로 받은 시간이 지나면 실행 대기 상태가 된다.
wait() 동기화 블록 내에서 스레드를 일시 정지 상태로 만든다. 매개값으로 주어진 시간이 지나면 실행 대기 상태가 된다. 또는 notify() 나 notifyAll() 메소드에 의해 실행 대기 상태가 될 수 있다.

일시 정지 -> 실행 대기

메소드 설명
interrupt() 일시 정지 상태의 스레드에 InterruptException을 발생시킨다. catch 문 안에서 실행 대기 상태 또는 종료 상태로 갈 수 있게 한다.
notify() 동기화 블록 내에서 wait() 메소드에 의해 일시 정지 상태인 스레드를 실행 대기 상태로 만든다.
notifyAll()

실행 -> 실행 대기

메소드 설명
yield() 우선순위가 동일하거나 높은 스레드에게 실행을 양보하고 실행 대기 상태가 된다.

 

메소드 중 wait(), notify(), notifyAll() 은 Object의 메소드이고 나머지는 Thread의 메소드이다. 먼저 Thread의 각 메소드를 하나씩 살펴보자.

1. sleep()

try {
    Thread.sleep(1000);
} catch(InterruptedException e) {
    //inturrpt() 메소드가 호출되면 실행
}

1초 동안 일시 정지 상태가 된다. 매개값은 밀리세컨드 단위로 시간을 주면 된다. 또한 일시 정지 상태에서 interupt() 메소드가 호출되면 InterrputedException이 발생하기 때문에 예외 처리가 필요하다.

2. join()

public class ThreadExampleMain {
    public static void main(String[] args) {
        SumTask sumTask = new SumTask();
        Thread thread = new Thread(sumTask);
        thread.start();

        try {
            thread.join();
        } catch (InterruptedException e) {
        }

        System.out.println("Main 종료 : " + sumTask.getSum());
    }

    static class SumTask implements Runnable {
        private long sum;

        public long getSum() {
            return sum;
        }
        @Override
        public void run() {
            for (int i = 0 ; i < 2000 ; i++) {
                sum += i;
            }

            System.out.println("Sum 종료");
        }
    }
}

실행 결과

Main 스레드에서 SumTask를 수행하는 스레드의 join() 메소드 호출했기 때문에 해당 스레드가 끝날때까지 기다린다.

3. yield()

class MeaninglessTask implements Runnable {
    boolean stop = false;
    boolean work = true;
    @Override
    public void run() {
        while (!stop) {
            if (work) {
                System.out.println(Thread.currentThread().getName());
            }
        }
    }
}

위 코드는 무의미한 반복 잡업을 하는 코드이다. 만약 work의 값이 false 라면 while문은 어떠한 것도 수행하지않고 무의미하게 반복만 할 뿐이다. 이런 경우 work 값이 false 일때 다른 스레드에게 실행을 양보하고 실행 대기로 상태로 가는 것이 프로그램 성능 향상에 조금 더 도움을 줄 수 있을 것이다.

class MeaninglessTask implements Runnable {
    boolean stop = false;
    boolean work = true;
    @Override
    public void run() {
        while (!stop) {
            if (work) {
                System.out.println(Thread.currentThread().getName());
            } else {
                Thread.yield();
            }
        }
    }
}

4. interrupt()

interrupt() 메소드는 스레드가 일시 정지 상태에 있을 때 InterruptedException을 발생시킨다.

public class ThreadExampleMain {
    public static void main(String[] args) {
        Thread thread = new Thread(new InterruptTask());
        thread.start();

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

        }
        thread.interrupt();
    }

    static class InterruptTask implements Runnable {
        @Override
        public void run() {
            try {
                while (true) {
                    Thread.sleep(500);
                    System.out.println("실행 중");
                }
            } catch (InterruptedException e) {
                System.out.println("Interrupt 발생");
            }

            System.out.println("Thread 종료");
        }
    }
}

생성한 스레드는 무한히 "실행 중" 이라는 문자열을 출력하는 작업을 수행하는데, 메인 스레드에서 해당 스레드의 interrupt 메소드를 호출하여 반복문을 빠져나와 스레드가 종료되도록 유도하였다.

실행 결과

그런데 여기서 중요한 점은 반복 작업을 하는 스레드에서 sleep() 메소드를 호출하여 주기적으로 일시 정지 상태로 만든다는 점이다.
interrupt() 메소드는 InterruptException을 발생시킨다고 하였다. 만약 스레드가 일시 정지 상태가 아니라 실행 상태거나 실행 대기 상태라면 해당 Exception은 발생하지 않고 일시 정지 상태가 되었을때 Exception이 발생한다.

일시 정지 상태가 되지않고도 interrupt() 메소드 호출 여부를 알려면 Thread 클래스의 interrupted() 메소드 또는 isInterruped() 메소드를 사용하면 된다.

interrupted() 메소드는 정적 메소드이고 isInterruped() 는 인스턴스 메소드 이다.
@Override
public void run() {
    while (true) {
        System.out.println("실행 중");
        if (Thread.interrupted()) {
            System.out.println("interrupt 발생");
            break;
        }
    }

    System.out.println("Thread 종료");
}

실행 결과

5. wait(), notify(), notifyAll()

스레드간 공유 객체가 존재하고 각 스레드가 교대로 번갈아가며 실행하는 경우에 위의 메소드들을 사용하여 효율적으로 교대 작업을 할 수 있다.

다음은 wait() 메소드와 notify() 메소드를 이용하여 간단한 생산자 소비자 패턴을 구현해보았다.

public class ProducerThread extends Thread{
    private Data data;
    private boolean stop = false;

    public ProducerThread(Data data,String name) {
        super(name);
        this.data = data;
    }

    public void setStop(boolean stop) {
        this.stop = stop;
    }

    @Override
    public void run() {
        for (int i = 0 ; ; i++) {
            if (stop) {
                break;
            }
            data.setData(String.valueOf(i));
        }

        System.out.println("producer 종료");
    }
}

public class ConsumerThread extends Thread {
    private Data data;
    public boolean stop = false;

    public ConsumerThread(Data data,String name) {
        super(name);
        this.data = data;
    }

    public void setStop(boolean stop) {
        this.stop = stop;
    }

    @Override
    public void run() {
        while (!stop) {
            data.deleteData();
        }

        System.out.println("consumer 종료");
    }
}

 

 

두 스레드는 Data라는 하나의 공유 객체를 가지고있다. Producer 스레드는 Data 객체의 String 타입 value 필드에 data를 생산하고 Consumer 스레드는 해당 필드를 null로 바꿔 소비한다.

 

public class Data {
    private String value;

    public synchronized void setData(String data) {
        while (value != null) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(Thread.currentThread() + " set " + data);
        value = data;
        try {
            Thread.sleep(700);
        } catch (InterruptedException e) {

        }
        notify();
    }

    public synchronized void deleteData() {
        while (value == null) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        String removed = this.value;
        System.out.println(Thread.currentThread() + "delete " + removed);
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {

        }
        this.value = null;
        notify();
    }
}

setData() 메소드에서 value 필드가 null이 아닐때 값이 다시 한번 쓰여지면 안돼기 때문에 wait() 메소드를 이용해 다른 스레드에서 notify() 메소드를 호출하기 전까지 대기한다.

deleteData() 메소드 또한 마찬가지로 value 필드가 null 일때 값을 소비하면 안돼기 때문에 wait() 메소드를 이용해 다른 스레드에서 notify() 메소드를 호출하기 전까지 대기한다.

즉, 생산자 스레드가 값을 쓰면 notify() 메소드를 호출해 소비자 스레드를 깨워 그 값을 소비할 수 있게한다. 그리고 생산자 스레드는 값이 소비될 때까지 wait() 메소드로 일시 정지 상태에 들어간다. 이어서 소비자 스레드는 값을 소비하고 notify() 메소드를 호출해 생산자 스레드에게 값을 생산할 수 있도록 알린다. 

public class ThreadExampleMain {
    public static void main(String[] args) {
        Data data = new Data();
        ProducerThread producer = new ProducerThread(data,"Producer");
        ConsumerThread consumer = new ConsumerThread(data,"Consumer");

        producer.start();
        consumer.start();

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

        producer.setStop(true);
        consumer.setStop(true);
    }
}

실행 결과

 

참고자료
이것이 자바다 - 신용권의 Java 프로그래밍 정보2, 신용권 지음

'스터디 > 자바' 카테고리의 다른 글

자바의 제네릭  (1) 2020.06.14
자바 인액션으로 보는 병렬스트림  (0) 2020.05.31
gradle 자바 프로젝트  (0) 2020.03.22
Java concurrent 패키지의 동기화 장치  (1) 2020.03.15
부딪히며 적용하는 자코코(jacoco)  (2) 2020.03.07