Don't give up!

[JAVA] 자바의 정석 정리 (13장 - 쓰레드) 본문

개발서적/JAVA의 정석

[JAVA] 자바의 정석 정리 (13장 - 쓰레드)

Heang Lee 2021. 6. 15. 20:33
자바의 정석을 읽고 정리한 내용입니다.

Java의 정석 - YES24

 

Java의 정석

최근 7년동안 자바 분야의 베스트 셀러 1위를 지켜온 `자바의 정석`의 최신판. 저자가 카페에서 12년간 직접 독자들에게 답변을 해오면서 초보자가 어려워하는 부분을 잘 파악하고 쓴 책. 뿐만 아

www.yes24.com


1. 쓰레드

(1) 프로그램을 실행하면 OS로부터 실행에 필요한 자원(메모리)을 할당받아 프로세스가 된다.

(2) 프로세스는 프로그램을 수행하는 데 필요한 데이터와 메모리 등의 자원 그리고 쓰레드로 구성되어 있으며 프로세스의 자원을 이용해서 실제로 작업을 수행하는 것은 쓰레드이다.

(2) 멀티쓰레딩은 하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행하는 것.

(3) 멀티쓰레딩은 CPU의 사용률을 향상시키고 자원을 효율적으로 사용할 수 있으며 사용자에 대한 응답성 향상, 작업을 분리하여 코드를 간단하게 한다는 장점이 있다.

(4) 서버 프로그램의 경우 멀티쓰레드로 작성하는 것은 필수적이다. 하나의 서버 프로세스가 여러 개의 쓰레드를 생성해서 쓰레드와 사용자의 요청이 일대일로 처리되도록 프로그래밍해야 한다.

(5) 멀티쓰레드 프로세스는 여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업을 하기 때문에 발생할 수 있는 동기화, 교착상태와 같은 문제들을 고려해서 신중히 프로그래밍해야 한다.

2. 쓰레드의 구현과 실행

(1) 쓰레드를 구현하는 방법은 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법이 있다.

Thread 클래스를 상속받으면 다른 클래스를 상속받을 수 없기 때문에 Runnable 인터페이스를 구현하는 방법이 일반적이다.

(2) Runnable 인터페이스는 run( )만 정의되어 있는 간단한 인터페이스이다. 클래스를 상속받든 인터페이스를 구현하든, 쓰레드를 통해 작업하고자 하는 내용으로 run( )을 구현하는 것일 뿐이다.

(3) Thread 클래스를 상속받으면 자손 클래스에서 조상인 Thread 클래스의 메서드를 직접 호출할 수 있지만, Runnable을 구현하면 Thread 클래스의 static 메서드인 currentThread( )를 호출하여 쓰레드에 대한 참조를 얻어 와야만 호출이 가능하다.

(4) start( )를 호출하면 실행대기 상태에 있다가 자신의 차례가 되면 실행이 이루어진다. 쓰레드를 생성한 후 start( )를 호출해야만 쓰레드가 실행된다.

(5) 한 번 실행이 종료된 쓰레드는 다시 실행할 수 없다. 하나의 쓰레드에 대해 start( )는 한 번만 호출될 수 있다. 두 번 이상 호출하면 실행시에 IllegalThreadStateException이 발생한다.

(7) start( )는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택을 생성한 다음에 run( )을 올라가게 한다. run( )을 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아닌 현재 사용중인 호출스택에 메서드를 호출하는 것일 뿐이다.

//1.Thread 클래스를 상속
class MyThread extends Thread{
    public void run() { /*작업내용*/ }
}
//2.Runnable 인터페이스를 구현
class MyRunnable implements Runnable{
	public void run() { /*작업내용*/ }
}

public static void main(String args[]){
    //1.Thread 클래스를 상속받아 구현된 쓰레드
    MyThread t1 = new MyThread();
    
    //2.Runnable 인터페이스로 구현된 쓰레드
    Runnable r = new MyRunnable();	//Runnable 인터페이스를 구현한 클래스의 인스턴스를 생성
    Thread t2 = new Thread(r); //인스턴스를 Thread 클래스의 생성자의 매개변수로 제공
	
    t1.start();
    t2.start();
    
    //쓰레드의 작업을 한 번 더 수행해야 할 시 새로운 쓰레드를 생성해야한다.
    t1 = new MyThread();
    t1.start();
    t1.start(); //IllegalthreadStateException 발생
}

(8) 모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택을 필요로 한다. 새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스택이 생성되고 쓰레드가 종료되면 작업에 사용된 호출스택은 소멸된다.

(9) 스케줄러는 실행대기중인 쓰레드들의 우선순위를 고려하여 실행순서와 실행시간을 결정하고, 각 쓰레드들은 작성된 스케줄에 따라 자신의 순서가 되면 지정된 시간동안 작업을 수행한다. 실행중인 사용자 쓰레드가 하나도 없을 때 프로그램은 종료된다.

(10) 쓰레드에서 예외가 발생해서 종료되어도 다른 쓰레드의 실행에는 영향을 미치지 않는다.

3. 싱글쓰레드와 멀티쓰레드

(1) 쓰레드간의 작업전환에 시간이 걸리기 때문에 두 개의 쓰레드로 작업한 시간이 싱글쓰레드로 작업한 시간보다 더 걸린다. 따라서 싱글 코어에서 단순히 CPU만을 사용하는 계산작업이라면 오히려 멀티쓰레드보다 싱글쓰레드로 프로그래밍하는 것이 더 효율적이다.

(2) 싱글 코어인 경우 멀티쓰레드라도 하나의 코어가 번아가며 작업을 수행하는 것이므로 두 작업이 겹치지 않는다.

하지만 멀티 코어에서는 멀티쓰레드로 작업을 수행하면 동시에 두 쓰레드가 수행될 수 있다. (두 쓰레드가 서로 다른 자원을 사용하는 작업의 경우 멀티쓰레드 프로세스가 더 효율적이다.)

(3) 각 프로세스의 실행시간과 실행순서는 OS의 프로세스 스케줄러의 영향을 받고 쓰레드는 JVM의 쓰레드 스케줄러에 의해 각 쓰레드의 실행시간과 실행순서가 결정된다. 매 순간 상황에 따라 프로세스와 쓰레드에 할당되는 시간은 불확실성을 가지고 있다.

4. 쓰레드의 우선순위

(1) 쓰레드는 우선순위(priority)라는 속성(멤버변수)을 가지고 있다. 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다.

(2) 쓰레드가 가질 수 잇는 우선순위의 범위는 1~10이며 숫자가 높을 수록 우선순위가 높다.

(3) 쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속받는다. main메서드를 수행하는 쓰레드는 우선순위가 5이다. main메서드 내에서 생성하는 쓰레드의 우선순위는 자동적으로 5가 된다.

(4) 쓰레드를 실행하기 전에만 우선순위를 변경할 수 있다.

(5) 멀티코어에서는 쓰레드의 우선순위에 차등을 두어 쓰레드를 실행시키는 것이 효과가 없다. 쓰레드에 우선순위를 부여하는 대신 작업에 우선순위를 두어 PriorityQueue를 사용하는 방식이 나을 수 있다.

5. 쓰레드 그룹

(1) 쓰레드 그룹을 생성해서 서로 관련된 쓰레드를 그룹으로 묶어서 관리할 수 있다.

(2) 쓰레드 그룹에 다른 쓰레드 그룹을 포함시킬 수 있다. 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경할 수 있지만 다른 쓰레드 그룹의 쓰레드를 변경할 수는 없다.

(3) 모든 쓰레드는 반드시 쓰레드 그룹에 포함되어 있어야 한다. 쓰레드 그룹을 지정하는 생성자를 사용하지 않은 쓰레드는 기본적으로 자신을 생성한 쓰레드와 같은 쓰레드 그룹에 속하게 된다.

(4) JVM은 자바 어플리케이션이 실행되면 main과 system이라는 쓰레드 그룹을 만들고 JVM운영에 필요한 쓰레드들을 생성해서 쓰레드 그룹에 포함시킨다.

링크 : ThreadGroup (Java Platform SE 8 ) (oracle.com)

 

ThreadGroup (Java Platform SE 8 )

Copies into the specified array every active thread in this thread group. If recurse is true, this method recursively enumerates all subgroups of this thread group and references to every active thread in these subgroups are also included. If the array is

docs.oracle.com

6. 데몬 쓰레드

(1) 데몬 쓰레드는 일반 쓰레드의 작업을 돋는 보조 쓰레드이다. 일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 자동 종료된다.

(2) 데몬 쓰레드는 일반 쓰레드의 작성방법과 실행방법이 같으며 쓰레드를 생성한 다음 start( )를 호출하기 전에 setDaemon(true)를 호출하기만 하면 된다.

(3) 데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 된다.

Thread t = new Thread(new MyRunnable());
t.setDaemon(true);
t.start();

7. 쓰레드의 실행 제어

(1) 효율적인 멀티쓰레드 프로그램을 만들기 위해서는 보다 정교한 스케줄링을 통해 프로세스에게 주어진 자원과 시간을 여러 쓰레드가 낭비없이 잘 사용하도록 프로그래밍 해야 한다.

메서드 설명
static void sleep(long millis)
static void sleep(long millis, int nanos)
지정된 시간동안 쓰레드를 일시정지시킨다. 지정한 시간이 지나고나면 자동적으로 실행대기상태가 된다.
void join( )
void join(long millis)
void join(long millis, int nanos)
지정된 시간동안 쓰레드가 실행되도록 한다. 지정된 시간이 지나거나 작업이 종료되면 join( )을 호출한 쓰레드로 다시 돌아와 실행을 계속한다.
void interrupt( ) sleep( )이나 join( )에 의해 일시정지상태인 쓰레드를 깨워서 실행대기상태로 만든다. 해당 쓰레드에서는 InterruptedException이 발생함으로써 일시정지상태를 벗어나게 된다.
void stop( ) 쓰레드를 즉시 종료시킨다.
void suspend( ) 쓰레드를 일시정지시킨다. resume( )을 호출하면 다시 실행대기상태가 된다.
void resume( ) suspend( )에 의해 일시정지상태에 있는 쓰레드를 실행대기상태로 만든다.
static void yield( ) 실행 중에 자신에게 주어진 실행시간을 다른 쓰레드에게 양보하고 자신은 실행대기상태가 된다.

(2) sleep( )은 항상 현재 실행중인 쓰레드에 작동한다. 따라서 static으로 선언되어 있으며 Thread.sleep( )과 같이 호출해야한다.

(3) interrupt( )는쓰레드에게 작업을 멈추라고 요청하는 것일 뿐 강제로 종료시키지는 못한다. 그저 쓰레드의 interrupted상태를 바꾸는 것일 뿐이다. interrupted( )는 쓰레드의 interrupted상태를 반환한다.

(4) 쓰레드가 WAITING상태에 있을 때 interrupt( )를 호출하면 InterruptedException이 발생하고 쓰레드는 RUNNABLE 상태로 바뀐다.

(5) suspend( )에 의해 정지된 쓰레드는 resume( )을 호출해야 다시 실행대기 상태가 된다. stop( )은 호출되는 즉시 쓰레드가 종료된다. suspend( )와 stop( )은 교착상태(deadlock)을 일으키기 쉬워 사용이 권장되지 않는다.

(6) yield( )는 쓰레드에 주어진 남은 실행시간을 포기하고 다시 실행대기상태가 된다. 주어진 실행시간을 의미없이 낭비하는 상황(바쁜 대기상태)에 호출하여 다른 쓰레드에게 실행시간을 양보할 수 있다.

(7) join( )은 쓰레드 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때 사용한다. 작업 중에 다른 쓰레드의 작업이 먼저 수행되어야 할 필요가 있을 때 해당 쓰레드의 join메서드를 호출한다.

(8) join( )도 interrupt( )에 의해 대기상태에서 벗어날 수 있다. join( )이 sleep( )과 다른 점은 현재 쓰레드가 아닌 특정 쓰레드에 대해 동작하므로 static 메서드가 아니라는 점이다.

상태 설명
NEW 쓰레드가 생성되고 아직 start( )가 호출되지 않은 상태
RUNNABLE 실행 중 또는 실행 가능한 상태
BLOCKED 동기화블럭에 의해서 일시정지된 상태(lock이 풀릴 때까지 기다리는 상태)
WAITING, TIMED_WAITING 쓰레드의 작업이 종료되지는 않았지만 실행가능하지 않은 일시정지 상태. TIMED_WATING은 일시정지시간이 지정된 상태
TERMINATED 쓰레드의 작업이 종료된 상태

8. 쓰레드의 동기화

(1) 멀티쓰레드 프로세스의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 된다.

(2) 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 쓰레드의 동기화(synchronization)라고 한다.

(3) 자바에서는 synchronized 블럭, java.util.concurrent.locks 패키지와 java.util.concurrent.atomic 패키지를 통해서 동기화를 구현할 수 있도록 지원하고 있다.

(4) synchronized 키워드는 임계 영역(critical section)을 설정하는데 사용된다.

//1. 메서드 전체를 임계 영역으로 지정 (메서드가 포함된 객체의 lock을 쓰레드가 얻는다.)
public synchronized void myFunc() {
    //메서드 전체가 임계영역
}
//2. 특정한 영역을 임계영역으로 지정 (지정된 객체의 lock을 쓰레드가 얻는다.)
public void myFunc(){
    //...
    synchronized(<객체의 참조변수>) {
	//synchronized로 감싼 블럭이 임계영역
    }
}

(5) 모든 객체는 lock을 하나씩 가지고 있으며 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있다. 다른 쓰레드들은 lock을 얻을 때까지 기다리게 된다.

(6) 임계 영역은 멀티쓰레드 프로그램의 성능을 좌우한다. 가능하면 메서드 전체에 lock을 거는 것보다 synchronized블럭으로 임계 영역을 최소화해야한다.

(7) 특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보낸다면 다른 쓰레드들은 해당 객체의 락을 기다리느라 다른 작업들도 원활히 진행되지 않는다.

(8) wait( )은 쓰레드가 락을 반납하고 notify( ) 또는 notifyAll( )을 기다리게 한다. 작업을 중단했던 쓰레드는 notify( )를 호출하여 락을 얻고 작업을 진행할 수 있다.

(9) wait(long timeout)은 지정된 시간이 지난 후 자동적으로 notify( )가 호출되는 것과 같은 결과를 얻을 수 있다.

(10) notify( )는 그저 waiting pool에서 대기 중인 쓰레드 중 하나를 임의로 선택해서 깨울 뿐 어떤 쓰레드를 깨울지 선택할 수 없다. 특정 쓰레드가 계속해서 선택받지 못하고 오래 기다리게 되는 것을 기아 현상이라고 한다. 이를 막으려면 notifyAll( )을 사용해야 한다.

(11) notifyAll( )이 호출된 객체의 waiting pool에 대기중인 쓰레드만 깨어난다. 모든 객체의 wating pool에 있는 쓰레드가 깨워지는 것이 아니다. notifyAll( )로 여러 쓰레드가 lock을 얻기 위해 서로 경쟁하는 것을 경쟁 상태(race condition)라고 한다. 경쟁 상태를 개선하기 위해서는 쓰레드를 구별해서 통지하는 것이 필요하다.

(12) java.util.concurrent.locks 패키지가 제공하는 lock 클래스는 재진입이 가능한 ReentrantLock, 읽기를 위한 lock과 쓰기를 위한 lock을 제공하는 ReentrantReadWriteLock, 낙관적 읽기 lock의 기능이 추가된 StampedLock이 있다.

(13) ReentrantLock은 생성자의 매게변수를 true로 주면 lock이 풀렸을 때 가장 오래 기다린 쓰레드가 lock을 획득할 수 있게 처리한다. (어떤 쓰레득가 가장 오래 기다렸는지 확인해야하므로 성능은 떨어진다.)

ReentrantLock lock = new ReentrantLock();
//ReentrantLock과 같은 lock 클래스들은 수동으로 lock을 잠그고 해제해야한다.
lock.lock();
try{
    //임계 영역
} finally {
    lock.unlock();
}

(14) tryLock( )은 다른 쓰레드에 의해 lock이 걸려 있으면 지정된 시간만큼만 기다리고 lock을 얻으면 true를, 얻지 못하면 false를 반환한다. 응답성이 중요한 경우 tryLock( )을 이용해서 지정된 시간동안 기다리고 lock을 얻지 못하면 다시 작업을 시도할 것인지 사용자가 결정할 수 있게 하는 것이 좋다.

tryLock( )은 InterruptedException을 발생시킬 수 있다. 지정된 시간동안 lock( )을 얻으려고 기다리는 중에 interrupt( )에 의해 작업을 취소할 수 있도록 작성할 수 있다.

(15) Condition을 사용하면 쓰레드를 구분해서 통지할 수 있다. 생성된 lock으로부터 newCondition( )을 호출해서 Condition을 생성할 수 있다.

ReentrantLock lock = new ReentrantLock();
Condition forA = lock.newCondition();
Condition forB = lock.newCondition();
forA.await(); //A쓰레드를 기다리게 한다.
forB.signal(); //Waiting pool에 있는 B쓰레드를 깨운다.

(16) Condition은 쓰레드의 종류에 따라 구분하여 통지를 할 수 있을 뿐 같은 종류 쓰레드간 기아 현상이나 경쟁 상태를 해결하지 않는다.

9. volatile

(1) 코어는 메모리에서 읽어온 값을 캐시에 저장하고 캐시에서 값을 읽어서 작업한다. 도중에 메모리에 저장된 변수의 값이 변경되었음에도 캐시에 저장된 값이 갱신되지 않아서 메모리에 저장된 값이 다른 경우가 발생할 수 있다.

(2) 변수 앞에 volatile을 붙이면 코어가 변수의 값을 읽어올 때 캐시가 아닌 메모리에서 읽어오기 때문에 캐시와 메모리간 값의 불일치를 해결할 수 있다.

(3) volatile을 붙이는 대신에 synchronized 블럭을 사용해도 같은 효과를 얻을 수 있다. 쓰레드가 synchronized 블럭으로 들어갈 때와 나올 때 캐시와 메모리간 동기화가 이루어지기 때문.

(4) JVM은 데이터를 4byte 단위로 처리하기 때문에 long과 double과 같은 크기가 큰 변수는 값을 읽는 과정에 다른 쓰레드가 끼어들 여지가 있다. volatile로 변수에 대한 읽기/쓰기를 원자화하여 나눌 수 없도록 할 수 있다.

10. fork & join

(1) fork & join 프레임웍은 하나의 작업을 작은 단위로 나눠서 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어 준다.

(2) RecursiveAction은 반환값이 없는 작업을 구현할 때, RecursiveTask는 반환값이 있는 작업을 구현할 때 해당 클래스를 상속받아 구현해야 한다. 두 클래스 모두 compute( )라는 추상 메서드를 구현하기만 하면 된다.

(3) ForkJoinPool은 지정된 수의 쓰레드를 생성해서 미리 만들어 놓고 반복해서 재사용할 수 있게 한다. 쓰레드를 반복해서 생성하지 않아도 되며 너무 많은 쓰레드가 생성되어 성능이 저하되는 것을 막아준다.

(4) fork( )는 작업을 쓰레드의 작업 큐에 넣는다. join( )은 작업의 수행이 끝날 때까지 기다렸다가 수행이 끝나면 그 결과를 반환한다.

(5) fork( )는 메서드를 호출할 뿐 결과를 기다리지 않는 비동기메서드이다. fork( )를 호출하면 결과를 기다리지 않고 다음 문장으로 넘어간다.

(6) fork & join 프레임웍은 작업을 나누고 다시 합치는데 시간이 걸리므로 for문보다 느리다. 반드시 테스트해보고 이득이 있을 때에만 멀티쓰레드로 처리해야 한다.