본문 바로가기

프로그래밍/JAVA

[JAVA 이론] 쓰레드

쓰레드

프로세스란?

간단히 말해서 '실행중인 프로그램'이다. 프로그램을 실행하면 OS로부터 실행에 필요한 자원(메모리)을 할당받아 프로세스가 된다.

 

프로세스의 구성

프로그램을 수행하는 데 필요한 데이터와 메모리 등의 자원 그리고 쓰레드로 구성

 

쓰레드

프로세스의 자원을 이용해서 실제로 작업을 수행하는 것이 쓰레드이다.모든 프로세스에는 하나 이상의 쓰레드가 존재하며, 둘 이상의 쓰레드를 가진 프로세스를 '멀티 쓰레드 프로세스'라고 한다.하나의 프로세스가 가질 수 있는 쓰레드의 개수는 제한되어 있지 않으나 쓰레드가 작업을 수행하는데 개별적인 메모리 공간(호출스택)을 필요로 하기 때문에 프로세스의 메모리 한계에 따라 생성할 수 있는 쓰레드의 수가 결정된다.

 

멀티태스킹과 멀티쓰레딩

멀티테스킹: 여러 개의 프로세스가 동시에 실행 되는 것 

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

 

멀티쓰레딩의 장단점

  • 장점
    1. CPU의 사용률을 향상시킨다.
    2. 자원을 보다 효율적으로 사용할 수 있다.
    3. 사용자에 대한 응답성이 향상된다.
    4. 작업이 분리되어 코드가 간결해진다.
  • 단점
    프로세스는 여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업을 하기 때문에 발생할 수 있는 동기화교착상태와 같은 문제들이 발생 가능

쓰레드의 구현과 실행

쓰레드를 구현하는 방법은 Thread클래스를 상속받는 방법과 Runnable인터페이스를 구현하는 방법이 존재한다.

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

//Thread클래스를 상속
class ThreadTest1 extends Thread{
    public void run(){ /* 작업내용 */} //Thread클래스의 run()을 오버라이딩
}

ThreadTest1 t1 = new ThreadTest1(); //Thread의 자손 클래스의 인스턴스 생성

//Runnable인터페이스를 구현
class ThreadTest2 implements Runnable{
    public void run(){ /* 작업내용 */} //Runnable인터페이스의 run()을 구현
}

Runnable r = new ThreadTest2(); //Runnable을 구현한 클래스의 인스턴스 생성
Thread t2 = new Thread(r);      //생성자 Thread(Runnable target)
//Thread t2 = new Thread(new ThreadTest2());

 

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

static Thread currentThread() 현재 실행중인 스레드의 참조를 반환한다.
String getName()              쓰레드의 이름을 반환한다.

 

쓰레드의 실행 - start()

쓰레드를 생성했다고 해서 자동으로 실행되는 것이 아니다. start()를 호출해야만 쓰레드가 실행된다.

또한. 한 번 실행이 종료된 쓰레드는 다시 실행 할 수 없고, 만일 쓰레드의 작업을 한 번 더 수행해야 한다면 새로운 쓰레드를 생성한 다음에 start()를 호출해야 한다.

ThreadTest1 t1 = new ThreadTest();
t1.start();
t1.start(); //예외 발생

ThreadTest1 t1 = new ThreadTest();
t1.start();
t1 = new ThreadTest(); //다시생성
t1.start();

 

start()와 run()

    • start()
      새로운 쓰레드가 작업을 실행하는데 필요한 호출스택을 생성한 다음에 run()을 호출해서, 생성된 호출스택에 run()이 첫 번째로 올라가게 한다.
    • run()
      run()을 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라 단순히 클래스에 선언된 메서드를 호출하는 것
    • main쓰레드
      main메서드의 작업을 수행하는 쓰레드이다. 쓰레드는 실행 중인 사용자 쓰레드가 하나도 없을 때 프로그램은 종료된다.

      모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택을 필요로 하기 때문에, 새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스택이 생성되고 쓰레드가 종료되면 작업에 사용된 호출스택은 소멸된다.
      1. main메서드에서 쓰레드의 start()를 호출한다.
      2. start()는 새로운 쓰레드를 생성하고, 쓰레드가 작업하는데 사용될 호출스택을 생성한다.
      3. 새로 생성된 호출스택에 run()이 호출되어, 쓰레드가 독립된 공간에서 작업을 수핸한다.
      4. 이제는 호출스택이 2개이므로 스케줄러가 정한 순서에 의해서 번갈아 가면서 실행된다.

싱글쓰레드와 멀티쓰레드

1. 쓰레드간의 작업전환(context switching)으로 인해 멀티쓰레드가 더 시간이 오래 걸릴 수도 있다.

2. 두 쓰레드가 서로 다른 자원을 사용하는 작업의 경우에는 싱글쓰레드 프로세스보다 멀티 쓰레드 프로세스가 더

   효율적이다.

 

쓰레드의 우선순위

쓰레드는 우선순위라는 속성을 가지고 있는데, 이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다. 그러나 멀티코어나 OS의 스케쥴링 방식에 따라 결과가 달라질 수도 있다.

void setPriority(int newPriority) //쓰레드의 우선순위를 지정한 값으로 변경한다.
int  getPriority()                 //쓰레드의 우선순위를 반환한다.

public staic final int MAX_PRIORITY = 10   //최대 우선순위
public staic final int MIN_PRIORITY = 1    //최소 우선순위
public staic final int NORM_PRIORITY = 10  //보통 우선순위

 

쓰레드 그룹

쓰레드 그룹은 서로 관련된 쓰레드를 그룹으로 다루기 위한 것으로, 폴더를 생성해서 관련된 파일들을 함께 넣어서 관리하는 것처럼 쓰레드 그룹을 생성해서 쓰레드를 그룹으로 묶어서 관리할 수 있다.

또한 폴더 안에 폴더를 생성할 수 있듯이 쓰레드 그룹에 다른 쓰레드 그룹을 포함 시킬 수 있다.

생성자/메서드 설명
ThreadGroup(String name) 지정된 이름의 새로운 쓰레드 그룹을 생성
ThreadGroup(ThreadGroup parent, String name) 지정된 쓰레드 그룹에 포함되는 새로운 쓰레드 그룹을 생성
int activeCount() 쓰레드 그룹에 포함된 활성상태에 있는 쓰레드의 수를 반환
int activeGroupCount() 쓰레드 그룹에 포함된 활성상태에 있는 쓰레드 그룹의 수를 반환
void checkAccess() 현재 실행중인 쓰레드가 쓰레드 그룹을 변경할 권한이 있는지 체크, 만일 권한이 없다면 SecurityException을 발생시킨다.
void destroy() 쓰레드 그룹과 하위 쓰레드 그룹까지 모두 삭제한다. 단, 쓰레드 그룹이나 하위 쓰레드 그룹이 비어있어야 한다.
int enumerate(Thread[] list)
int enumerate(Thread[] list, boolean resource)
int enumerate(ThreadGroup[] list)
int enumerate(ThreadGroup[] list, 
boolean resource)
쓰레드 그룹에 속한 쓰레드 또는 하위 쓰레드 그룹의 목록을 지정된 배열에 담고 그 개수를 반환. 
두 번째 매개변수인 resourse의 값을 true로 하면 쓰레드 그룹에 속한 하위 쓰레드 그룹에 쓰레드 또는 쓰레드 그룹까지 배열에 담는다.
int getMaxPriority() 쓰레드 그룹의 최대 우선순위를 반환
String getName() 쓰레드 그룹의 이름을 반환
ThreadGroup getParent() 쓰레드 그룹의 부모 쓰레드를 반환
boolean isDaemon() 쓰레드 그룹이 데몬 쓰레드그룹인지 확인
boolean isDestroyed() 쓰레드 그룹이 삭제되었는지 확인
void list() 쓰레드 그룹에 속한 쓰레드와 하위 쓰레드그룹에 대한 정보를 출력
boolean parentOf(ThreadGroup g) 지정된 쓰레드 그룹의 상위 쓰레드그룹인지 확인
void setDaemon(boolean daemon) 쓰레드 그룹을 데몬 쓰레드그룹으로 설정/해제
void setMaxPriority(int pri) 쓰레드 그룹의 최대우선순위를 설정

 

데몬 쓰레드

데몬 쓰레드는 다른 일반 쓰레드의 작업을 돕는 보조적인 역할을 수행하는 쓰레드이다. 일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 자동 종료된다. 데몬쓰레드의 예로는 가비지 컬렉터, 워드프로세서의 자동저장, 화면자동갱신 등이 있다.

boolean isDaemon() //쓰레드가 데몬 쓰레드인지 확인하다.
                   //데몬 쓰레드이면 true를 반환한다.
void setDaemon(boolean on) //쓰레드를 데몬 쓰레드로 또는 사용자 쓰레드로 변경한다.
                           //매개변수 on의 값을 true로 지정하면 데몬 쓰레드가 된다.

 

쓰레드의 실행제어

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

 

● 스케줄링과 관련된 메서드

메서드 설명
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() 실행 중에 자신에게 주어진 실행시간을 다른 쓰레들에게 양보(yield)하고 자신은 실행대기상태가 된다.

 

● 쓰레드의 상태

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

 

● 쓰레드의 상태 변화

1. 쓰레드를 생성하고 start()를 호출하면 실행대기열에 저장되어 자신의 차례가 될 때까지 기다린다.

2. 실행대기상태에 있다가 자신의 차례가 되면 실행상태가 된다.

3. 주어진 실행시간이 다되거나 yield()를 만나면 다시 실행대기상태가 된다.

4. 실행중 suspend(), sleep(), wair(), join(), I/O block에 의해 일시정지상태가 될 수 있다.

5. 지정된 일시정지시간이 다되거나(time-out), notify(). resume(), interrupt()가 호출되면 일시정지상태를 벗어나 실행대기상태가 된다.

6. 실행을 모두 마치거나 stop()이 호출되면 쓰레드는 소멸된다.

 

● sleep(long mills) - 일정시간동안 쓰레드를 멈추게 한다.

static void sleep(long millis)
static void sleep(long millis, int nanos)

try{
    Thread.sleep(1, 500000); //쓰레드를 0.0015초 동안 멈추게 한다.
} catch(InterrupedExcption e){}

 

● interrupt()와 interrupted() - 쓰레드의 작업을 취소한다.

메서드 설명
void interrupt() * 쓰레드에게 작업을 멈추라고 요청한다. 단지 멈추라고 요청만 하는 것일 뿐 쓰레드를 강제로 종료시키지는 못한다.
* 대기상태(WAITING)인 쓰레드를 실행대기 상태(RUNNABLE)로 만듦
* 쓰레드를 중단하거나, 중단시킨 쓰레드를 재개하고 싶을 때 사용
* interrupted상태를 false -> true
boolean isInterrupted() 쓰레드의 interrupted상태를 반환
static boolean interrupted() 쓰레드에 대해 interrupt()가 호출되었는지 알려준다. interrupt()가 호출되지 않았다면 false를 interrupt()가 호출되었다면 true를 반환한다

쓰레드가 sleep(), wait(), join()에 의해 '일시정지 상태(WATING)'에 있을 때, 해당 쓰레드에 대해 interrupt()를 호출하면, sleep(), wait(), join()에서 InterruptedException이 발생하고 쓰레드는 '실행대기 상태'로 바뀐다.

 

● suspend(), resume(), stop()

suspend()는 sleep()처럼 쓰레드를 멈추게 한다. suspend()에 의해 정지된 쓰레드는 resume()을 호출해야 다시 실행대기 상태가 된다. stop()은 호출되는 즉시 쓰레드가 종료된다. 

suspend(), resume(), stop()은 쓰레드의 실행을 제어하는 가장 손쉬운 방법이지만. suspend(), stop()이 교착상태를 일으키기 쉽게 작성되어있므으로 권장하지 않는다.

 

● yield() - 다른 쓰레드에게 양보한다.

yield()는 쓰레드 자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보(yield)한다. yield()와 interrupt()를 적절히 사용하면 프로그램의 응답성을 높이고 보다 효율적인 실행이 가능하게 할 수 있다.

 

● join() - 다른 쓰레드의 작업을 기다린다.쓰레드 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때 join()을 사용한다.

void join()
void join(long millis)
void join(long millis, int nanos)

try{
    th1.join() //현재 실행중인 쓰레드가 쓰레드 th1의 작업이 끝날때까지 기다린다.
} catch(InterruptedException e){}

1. 시간을 지정하지 않으면, 해당 쓰레드가 작업을 모두 마칠 때까지 기다리게 된다.

2. 작업 중에 다른 쓰레드의 작업이 먼저 수행되어야할 필요가 있을 때 join()을 사용한다.

3. join()도 sleep()처럼 interrupt()에 의해 대기상태에서 벗어 날 수 있다.

4. join()이 호출되는 부분을 try-catch문으로 감싸야 한다.

5. sleep()과 다른 점은 join()은 현재 쓰레드가 아닌 특정 쓰레드에 대해 동작하므로 static메서드가 아니다.

 

쓰레드의 동기화

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

- 동기화하려면 간섭받지 않아야 하는 문장들을 '임계 영역'으로 설정

- 임계영역은 락(lock)을 얻은 단 하나의 쓰레드만 출입가능(객체 1개에 락 1개)

예) 공유 데이터를 사용하는 코드 영역을 임계영역으로 지정해놓고, 공유 데이터(객체)가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 한다. 그리고 해당 쓰레드가 임계영역 내의 모든 코드를 수행하고 벗어나서  lock을 반납해야만 다른 쓰레드가 반납된 lock을 획득하여 임계 영역의 코드를 수행할 수 있게 된다.

 

● synchronized를 이용한 동기화

//1.메서드 전체를 임계 영역으로 지정
public synchronized void calSum(){
    ...
}

//2.특정한 영역을 임계 영역으로 지정
synchronized(객체의 참조변수){
    ...
}

synchronized를 이용한 동기화는 지정된 영역의 코드를 한 번에 하나의 쓰레드가 수행하는 것을 보장하는 것일 뿐 외부에서 직접 접근할 수 있는 경우 값의 변경을 막을 길이 없다.

 

wait()과 notify()

- 동기화의 효율을 높이기 위해 wait(), notify()를 사용

- Object클래스에 정의되어 있으며, 동기화 블록 내에서만 사용할 수 있다.

상태 설명
wait 객체의 lock을 풀고 쓰레드를 해당 객체의 waiting  pool에 넣는다
notify() waiting pool에 대기중인 쓰레드 중의 하나를 꺠운다.
notifyAll() waiting pool에 대기중인 모든 쓰레드를 꺠운다.

 

Lock과 Condition을 이용한 동기화

synchronized블럭으로 동기화 하면 같은 메서드 내에서만 lock을 걸 수 있다는 제약이 불편하기 때문에, lock클래스 이용

클래스 설명
ReentrantLock 재진입이 가능한 lock, 가장 일반적인 베타 lock
ReentrantReadWriteLock 읽기에는 공유적이고, 쓰기에는 베타적인 lock
StampedLock ReentrantReadWriteLock에 낙관적인 lcok의 기능을 추가

● ReentrantLock 생성자

ReentrarntLock()
ReentrarntLock(boolean fair)

매개변수를 true로 주면 가장 오래 기다린 쓰레드부터 처리한다. 그러나 성능은 떨어진다.

void lock()     //lock을 잠근다.
void unlock()   //lock을 해지한다다.
void isLocked() //lock이 잠겼는지 확인한다.

lock을 잠궜다면 푸는 건 필수이다.

boolean tryLock()
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException

지정된 시간동안 lock을 얻지 못하면 다시 작업을 시도할 것인지 포기할 것인지를 사용자가 결정할 수 있음.

 

● Condition

공유 객체의 waiting pool에 같이 몰아 넣는 대신, Condtion을 만들어서 각각의 waiting pool에서 따로 기다리도록 만듬

Object Condition
void wait() void await()
void awaitUninterruptibly()
void wait(long timeout) boolean await(long time, TimeUnit unit)
long         awaitNanos(long nanosTimeout)
boolean awaitUntill(Date deadline)
void notify() void signal()
void notifyAll() void signalAll()

 

volatile

- 메모리의 값이 변경 되었는데도 캐시에 저장 된 값이 갱신 되지 않을 때 캐시의 값이 아닌 메모리의 값을 참조하도록 해준다.

- volatile대신에 synchronized를 쓸 수 있지만 반대는 불가능 하다.

 

fork & join 프레임웍

하나의 작업을 작은 단위로 나누서 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어준다.

RecursionAction 반환값이 없는 작업을 구현할 때 사용
RecursiveTask   반환값이 있는 작업을 구현할 때 사용

두 클래스 모두 compute()라는 추상 메서드를 가지고 있는데, 상속을 통해 이 추상 메서드를 구현하기만 하면 된다.

 

- compute()

   작업을 어떻게 나눌 것인가에 대해서 구현

 

- fork & join

  fork()는 작업을 쓰레드의 작업 큐에 넣는 것이고, 작업 큐에 들어간 작업은 더 이상 나눌 수 없을 때까지 나뉜다.

  즉, compute()로 나누고 fork로 작업 큐에 넣는 작업을 반복

fork() //해당 작업을 쓰레드 풀의 작업 큐에 넣는다. 비동기 메서드
join() //해당 작업의 수행이 끝날 때까지 기다렸다가, 수행이 끝나면 그 결과를 반환한다. 동기 메서드