korean IT student

[Java-Live-Study] 10주차 - 멀티쓰레드 프로그래밍 본문

back-end/JAVA

[Java-Live-Study] 10주차 - 멀티쓰레드 프로그래밍

현창이 2021. 11. 25. 16:51

목표

자바의 멀티쓰레드 프로그래밍에 대해 학습하세요.

학습할 것 (필수)

  • Thread 클래스와 Runnable 인터페이스
  • 쓰레드의 상태
  • 쓰레드의 우선순위
  • Main 쓰레드
  • 동기화
  • 데드락

 

Thread 클래스와 Runnable 인터페이스

Process란

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

Thread란

  • 프로세스 내에서 실행되고 있는 흐름의 단위
  • 동일한 프로세스 내에 존재하며, 프로세스의 메모리 영역을 공유
  • 두 개 이상의 쓰레드를 가지는 프로세스를 멀티 쓰레드 프로세스

Multi Thread란

  • 두 개 이상의 쓰레드를 가지는 프로세스를 멀티 쓰레드
    • ex) 크롬 창을 여러개 실행하면 운영체제로부터 메모리를 할당받아 프로세스가 크롬창을 실행한 만큼 생성

 

자바에서 쓰레드를 만드는 법

public class ThreadMain {
    public static void main(String[] args) {
        ImplThread it = new ImplThread();
        new Thread(it).start();

        ExtendsThread et = new ExtendsThread();
        et.start();
    }
}
// Runnable
class ImplThread implements Runnable{

    @Override
    public void run() {
        System.out.println("Thread가 생성되었습니다.");
    }
}
// Thread
class ExtendsThread extends Thread{
    @Override
    public void run(){
        System.out.println("Thread가 생성되었습니다.");
    }
}
  • java.lang의 Runnable 인터페이스를 구현하는 것(주로 사용함)
  • Runnable을 구현한 Thread 클래스를 상속받는 방법
  • Thread클래스를 상속받으면 다른 클래스를 상속받을 수 없기 때문에 Runnable 인터페이스를 구현하는 방법이 일반적

 

Runnable 인터페이스

  • Runnable 인터페이스는 함수형 인터페이스로 run() 추상메서드 하나만이 존재
  • run()메소드 구현을 통해 쓰레드에게 작업할 내용을 설정할 수 있다.

Thread 상태변화와 메서드

쓰레드의 상태

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

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

1. 쓰레드를 생성하고 start()를 호출하면 바로 실행되는 것이 아니라 실행 대기열에 저장되어 자신의 차례가 될 때까지 기다려야 합니다. (실행 대기열은 큐(queue)와 같은 구조로 먼저 실행 대기열에 들어온 쓰레드가 먼저 실행됩니다.

2. 자기 차례가 되면 실행상태가 됩니다.

3. 할당된 실행시간이 다되거나 yield() 메소드를 만나면 다시 실행 대기상태가 되고 다음 쓰레드가 실행상태가 됩니다.

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

    (I/O block은 입출력 작업에서 발생하는 지연상태를 말합니다. 사용자의 입력을 받는 경우를 예로 들 수 있습니다.)

5. 지정된 일시정지 시간이 다되거나, notify(), resume(), interrupt()가 호출되면 일시정지상태를 벗어나 다시 실행 대기열에 저장되어 자신의 차례를 기다리게 됩니다.

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

-- 출처 [https://parkadd.tistory.com/48]

 

 

public static native void yield();
public static native void sleep(long millis) throws InterruptedException;
public static void sleep(long millis, int nanos) throws InterruptedException;
public static boolean interrupted() {}
public final void join() throws InterruptedException {}

yield()

  • 쓰레드 자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보합니다.
  • 예를 들어, 스케쥴러에 의해 1초의 실행시간을 할당받은 쓰레드가 0.5초의 시간 동안 작업한 상태에서 yield()가 호출되면 나머지 0.5초는 포기하고 다시 실행 대기 상태가 됩니다.

sleep()

  • 말 그대로 실행 중인 쓰레드를 일시정지 상태로 잠시 재우는 것
  • 매개변수로 얼마 동안 재울지 천분의 일초 단위로 지정할 수 있다.

interrupt()

  • 현재 수행 중인 쓰레드를 중단
  • 그냥 중지시키지 않고 interruptedException 예외를 발생시키면서 중단

join()

  • 쓰레드 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때
  • 시간을 지정하지 않으면, 해당 쓰레드가 작업을 모두 마칠 때까지 기다립니다.
public class ThreadMain {
    public static void main(String[] args) {
        ExtendsThread extendsThread = new ExtendsThread();
        extendsThread.start();
        
        try{
            extendsThread.join();
        } catch (InterruptedException e){
            e.printStackTrace();
        }
        System.out.println("main thread");

    }

    static class ExtendsThread extends Thread {
        @Override
        public void run() {
            try {
                Thread.sleep(2000);
                System.out.println("run Thread");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
          
        }
    }
}

join() 실행 시 extendsThread가 끝날 때까지 기다렸다가 main thread가 실행됩니다.

run Thread
main thread

join() 사용하지 않으면 쓰레드가 sleep 하는 동안 main thread가 먼저 종료됩니다.

main thread
run Thread

 

Thread 우선순위

Java에서 각 쓰레드는 우선순위에 관한 자신만의 필드를 가지고 있다. 이러한 우선순위에 따라 특정 쓰레드가 더 많은 시간 동안 작업을 할 수 있도록 설정

 

쓰레드의 우선순위를 지정하는 필드와 메소드

public final static int MIN_PRIORITY = 1   // 쓰레드가 가질 수 있는 최소 우선순위를 명시
public final static int NORM_PRIORITY = 5  // 쓰레드가 생성될 때 가지는 기본 우선순위를 명시
public final static int MAX_PRIORITY = 10  // 쓰레드가 가질 수 있는 최대 우선순의를 명시

setPriority() // 쓰레드의 우선순의를 지정한 값으로 변경
getPriority() // 쓰레드의 우선순위를 반환
  • 쓰레드가 가질 수 있는 우선순위의 범위는 1~10이며 숫자가 높을수록 우선순위가 높다.
  • 우선순위가 10인 쓰레드가 우선순위 1인 쓰레드보다 10배 더 빨리 수행되는 것이 아니라 우선순위가 10인 쓰레드는 우선순위가 1인 쓰레드 보다 좀 더 많이 실행 큐에 포함되어, 좀더 많은 작업시간을 할당받을 뿐이다.

예제

public class ThreadMain {
    public static void main(String[] args) {

        Thread thread1 = new ExtendsThread();
        Thread thread2 = new ExtendsThread();

        thread1.setPriority(5);
        thread2.setPriority(10);
        thread1.start(); // Thread-0 실행
        thread2.start(); // Thread-1 실행


        System.out.println("thread1.getPriority() : " + thread1.getPriority());
        System.out.println("thread2.getPriority() : " + thread2.getPriority());

    }

    static class ExtendsThread extends Thread {
        @Override
        public void run() {

            for(int i=0; i< 5; i++){
                System.out.println(Thread.currentThread().getName());
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }


        }
    }
}

 

Main 쓰레드

  • java 프로그램을 실행하기 위해 Main Thread는 main() 메소드를 실행합니다.
  • JVM에 의해 만들어진 main 스레드의 스레드 코드
    • 하나의 JVM은 한 개의 자바 응용 프로그램만 실행 가능
    • 하나의 응용 프로그램이 여러 개의 스레드를 가질 수 있음 = 하나의 JVM이 여러 개의 자바 스레드 실행 가능
    • 2개 이상의 자바 응용 프로그램을 실행하고 정보를 주고받는 경우 -> 소켓 통신과 같은 통신 방법을 이용
  • main 쓰레드가 종료되더라도 생성된 쓰레드가 실행 중 이라면 모든 쓰레드가 작업을 완료하고 종료될 때 까지 프로그램은 종료되지 않는다. 즉, 실행중인 사용자 쓰레드가 하나도 없다면 프로그램은 종료됩니다.

Daemon 쓰레드

  • Main 쓰레드의 작업을 돕는 보조적인 역할을 하는 쓰레드이다
  • Main 쓰레드가 종료되면 데몬 쓰레드는 강제적으로 자동 종료가 된다.(어디까지나 Main 쓰레드의 보조 역할을 수행하기 때문에 , Main 쓰레드가 없어지면 의미가 없어지기 때문입니다. )
  • Main 쓰레드가 Daemon 이 될 쓰레드의 setDaemon(true)를 호출해주면 Daemon 쓰레드가 된다
public class ThreadMain {
    public static void main(String[] args) {

        Thread thread1 = new ExtendsThread();
        thread1.setDaemon(true);
        thread1.start();
        
        System.out.println(thread1.isDaemon());


    }

    static class ExtendsThread extends Thread {
        @Override
        public void run() {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

데몬 쓰레드를 사용하는 이유?

예를 들어 모니터링하는 스레드를 별도로 띄워 모니터링하다가,

주요 스레드가 종료되면 관련된 모니터링 스레드가 종료돼야 프로세스가 종료될 수 있다.

그런데 모니터링 스레드를 데몬 스레드로 만들지 않으면 프로세스가 종료될 수 없게 된다.

 

동기화(Synchronize)

  • 여러 개의 쓰레드가 한 개의 리소스를 사용하려고 할 때 사용하려는 쓰레드를 제외한 나머지들을 접근하지 못하게 막는 것
  • 자바에서 동기화 방법
    • Synchronized 키워드
    • Atomic 클래스
    • Volatile 키워드

Synchronized 키워드

public class ThreadMain {
    private  String mMessage;

    public static void main(String[] args) {
        ThreadMain threadMain = new ThreadMain();
        new Thread(() -> {
            for (int i = 0; i < 10; ++i) {
                threadMain.printMessage("thread_1");
                threadMain.printMessage2("thread_1");
            }
        }).start();

        new Thread(() -> {
            for (int i = 0; i < 10; ++i) {
                threadMain.printMessage("thread_2");
                threadMain.printMessage2("thread_2");
            }
        }).start();


    }

    // synchronized 메서드 사용 예
    synchronized void printMessage(String message){
        mMessage = message;
        System.out.println(mMessage);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    // synchronized 블록 사용 예
     void printMessage2(String message){
        
         // 동기화가 필요없는 로직을 넣을 수 있다.
        
         synchronized(this){
             mMessage = message;
             System.out.println(mMessage);
             try {
                 Thread.sleep(1000);
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
         }

    }
}
  • Synchronized 메서드를 사용하면 첫 번째 쓰레드가 메서드를 나갈때 까지 두번째 쓰레드가 진입하지 못한다.
  • Synchronized  블록을 사용하면 메서드 안에서 원하는 구역을 지정해서 사용할 수 있다.

Atomic 키워드

  • Atomic변수는 원자성을 보장하는 변수
  • 멀티쓰레드 환경에서 동기화 문제를 synchronized 키워드를 사용하여, 락을 걸곤 하는데 이런 키워드 없이 동기화 문제를 해결하기 위해 고안된 방법
  • synchronized는 특정  Thead가 해당 블럭 전체를 lock을 하기 때문에 다른 Thread는 아무런 작업을 하지 못하고 기다리는 상황이 될 수 있기 때문에 , 낭비가 심합니다. 그래서 NonBlocking하면서 동기화 문제를 해결하기 위한 방법이 Atomic입니다.
  • Atomic의 동작 핵심원리는 바로 CAS알고리즘입니다(Compared and Swap)

Compare-And-Swap(CAS)란?

  • CAS란 변수의 값을 변경하기 전에 기존에 가지고 있던 값이 내가 예상하던 값과 같을 경우에만 새로운 값으로 할당하는 방법
  • 즉, 현재 주어진 값( 현재 쓰레드에서의 데이터)과 실제 데이터와 저장된 데이터를 비교해서 두 개가 일치할 때만 값을 업데이트한다 

아래 예제를 참고해보자.

 

 

AtomicInteger 클래스를 살펴보자

public class AtomicInteger extends Number implements java.io.Serializable { 

private volatile int value;  

        public final int incrementAndGet() {  
            int current;  
            int next; 
            do {  
        current = get();  
                next = current + 1;  
            } while (!compareAndSet(current, next)); 
            return next;  
        }  

        public final boolean compareAndSet(int expect, int update) { 
         return unsafe.compareAndSwapInt(this, valueOffset, expect, update);  
        } 

}
  • incrementAndGet() -> compareAndSet  메소드가 바로 CAS 알고리즘입니다. compareAndSet을 호출하여, 그 결과값이 성공일 때까지 while을 통해 무한루프를 돈다. compareAndSet 내부에서 compareAndSwapInt를 호출해 메모리에 저장된 값과 현재 cpu에 캐시 된 expect 값을 비교해 동일한 경우에만 update를 실행한다.
  • value 값의 선언을 보면 volatile 키워드를 사용하고 있다. -> CAS 알고리즘을 사용해 2중 안전장치 걸어둠

volatile 

  • 해당 키워드가 붙어 있는 객체는 CPU캐시가 아닌 메모리에서 값을 참조한다.
  • 사용하는 이유?
    • volatile변수를 사용하고 있지 않은 멀티쓰레드 어플리케이션은 성능 향상을 위해서 Main Memory에서 읽은 변수를 CPU Cache에 저장한다.
    • 만약 Multi Thread환경에서 Thread가 변수 값을 읽어올 때 각각의 CPU Cache에 저장된 값이 다르기 때문에 변수 값 불일치 문제가 발생하게 된다.

volatile  예제

public class ThreadMain {

    private static volatile int MY_INT = 0;

    public static void main(String[] args) {

        ChangeMaker changeMaker = new ChangeMaker();
        changeMaker.start();

        ChangeListener changeListener = new ChangeListener();
        changeListener.start();

    }

    static class ChangeMaker extends Thread {

        @Override
        public void run() {
            while (MY_INT < 5){
                System.out.println("Incrementing MY_INT "+ ++MY_INT);
                try{
                    Thread.sleep(1000);
                }catch(InterruptedException exception) {
                    exception.printStackTrace();
                }
            }
        }
    }

    static class ChangeListener extends Thread {

        int local_value = MY_INT;


        @Override
        public void run() {

            while ( MY_INT < 5){
              
                if( local_value!= MY_INT){
                    System.out.println("Got Change for MY_INT "+ MY_INT);
                    local_value = MY_INT;
                }
            }
        }
    }

}
  • Multi Thread 환경에서 하나의 Thread만 read & write하고 나머지 Thread 가 read하는 상황에서 가장 최신의 값을 보장한다

데드락(교착상태, Deadlock)

멀티 쓰레드 프로그래밍에서 동기화를 통해 락을 획득하여 동일한 자원을 여러 곳에서 함부로 사용하지 못하도록 하였습니다. 하지만 두 개의 쓰레드에서 서로가 가지고 있는 락이 해제되기를 기다리는 상태가 생길 수 있으며 이러한 상태를 교착상태(deadlock) 이라고 합니다. 교착상태가 되면 어떤 작업도 실행되지 못하고 서로 상대방의 작업이 끝나기만 바라는 무한정 대기 상태입니다.

 

public class ThreadMain {
    public static final Object LOCK_1 = new Object();
    public static final Object LOCK_2 = new Object();

    public static void main(String args[]) {
        Thread1 thread1 = new Thread1();
        Thread2 thread2 = new Thread2();
        thread1.start();
        thread2.start();
    }

    private static class Thread1 extends Thread {
        public void run() {
            synchronized (LOCK_1) {
                System.out.println("Thread 1: Holding lock 1...");
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {

                }
                System.out.println("Thread 1: Waiting for lock 2...");
                synchronized (LOCK_2) {
                    System.out.println("Thread 1: Holding lock 1 & 2...");
                }
            }
        }
    }

    private static class Thread2 extends Thread {
        public void run() {
            synchronized (LOCK_2) {
                System.out.println("Thread 2: Holding lock 2...");
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {

                }
                System.out.println("Thread 2: Waiting for lock 1...");
                synchronized (LOCK_1) {
                    System.out.println("Thread 2: Holding lock 1 & 2...");
                }
            }
        }
    }
}

쓰레드1이 LOCK_1을 점유하고 LOCK_2를 요청하는데 쓰레드 2가 LOCK_2를 점유하고 LOCK_1을 요청한다면 서로의 자원을 요청하지만 해당 자원을 점유해서 놓지 않고 있기 때문에 데드락 생태에 걸린다. 이를 해결하기 위해선 어느 한쪽의 자원을 풀어줘야 한다.

Comments