Programming/JAVA

멀티 쓰레드 프로그래밍이란?

잇나우 2021. 3. 1. 17:09
반응형

멀티쓰레드 프로그래밍

Process란?

  • 실행중인 프로그램을 의미
  • 운영체제로부터 메모리 공간을 할당 받아 실행중인 것을 말한다. 이러한 프로세스는 프로그램에 사용되는 데이터와 메모리 등의 자원, 쓰레드로 구성된다.

Thread란?

  • 프로세스 내에서 작업을 수행하는 일꾼(주체)
  • 모든 프로세스에는 1개 이상의 쓰레드가 존재하여 작업을 수행
  • 1개의 쓰레드를 가지는 프로세스를 싱글 쓰레드 프로세스라고 한다
  • 2개 이상의 쓰레드를 가지는 프로세스를 멀티 쓰레드 프로세스라고 한다.
  • 경량 프로세스라고 불리며 가장 작은 실행단위이다.
  • 프로세스의 자원을 이용해서 작업을 수행한다.

멀티 태스킹(multi-tasking)

  • 여러개의 프로세스가 동시에 실행될 수 있는 것.

멀티 쓰레딩(multi-threading)

  • 하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행하는 것
  • CPU의 코어(Core)가 한 번에 하나의 작업만 수행할 수 있으므로, 실제로 동시에 처리되는 작업의 개수와 일치한다.
  • 코어가 아주 짧은 시간 동안 여러 작업을 번갈아 가며 수행함으로써 여러 작업들이 모두 동시에 수행되는 것처럼 보이게한다.
  • 프로세스의 성능은 쓰레드의 개수와 비례하지 않는다.

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

자바 Thread

JVM을 사용하면 여러 쓰레드를 가질 수 있다. 모든 쓰레드에는 우선순위가 있다. 우선 순위가 높은 쓰레드가 우선 순위가 낮은 쓰레드보다 우선적으로 실행된다. 각 쓰레드는 데몬 쓰레드로 마크가 될수도 있다. 데몬 쓰레드란 사용자 쓰레드를 보조하는 쓰레드이며, 자바에서는 대표적으로 Garbage Collector가 데몬 쓰레드이다.

일부 쓰레드에서 새로운 Thread 객체를 생성할 때 새로운 쓰레드는 자신을 생성한 쓰레드의 우선 순위와 동일한 우선순위를 가지며 데몬 쓰레드일 경우 데몬 쓰레드가 된다.

JVM이 시작될때 일반적으로 하나의 쓰레드가 있는데 다음 중 하나가 발생할 때 까지 쓰레드를 유지한다.

  • Runtime 클래스의 종료 메서드가 호출되었으며 보안관리자(Security manager)가 종료 조작이 발생하도록 허용했을 때 종료된다.
  • 데몬 쓰레드가 아닌 모든 쓰레드는 실행된 후 run() 메서드의 작업이 끝나거나 run 메서드 이외에서 예외를 throw 했을 때 종료된다.

모든 쓰레드는 식별을 목적으로 이름을 가지고 있다. main메서드의 작업을 수행하는 것도 하나의 쓰레드로 이름이 main이다. 둘 이상의 쓰레드가 동일한 이름을 가질수 있고 쓰레드가 생성될 때 이름이 지정되지 않으면 Thread-숫자 형식으로 새 이름이 생성된다. 숫자는 0부터 시작하여 1씩 증가한다. 따로 명시되지 않는 한 쓰레드 생성자 또는 메서드에 null 값을 넣으면 NullPointerException이 throw된다. Thread 클래스의 생성자들 중 init()를 살표보면 매개변수 중 name 값이 null 이면 NullPointerException을 던지는 것을 확인할 수 있다.

Thread 클래스와 Runnable 인터페이스

자바에서 쓰레드를 생성하는 방법은 크게 두가지로 나눌 수 있다

  1. Thread 클래스를 사용
  2. Runnalbe 인터페이스를 사용

1. Thread 클래스를 상속받는 방법

클래스를 Thread의 자식 클래스로 선언하는 것이다. 자식 클래스는 실행 메서드(run 메서드)를 재정의 하여 인스턴스를 할당하고 실행할 수 있다. Thread 클래스는 Runnable 인터페이스를 구현한 클래스이다.

public class ThreadTest {
    public static void main(String[] args) {
        SubThread thread = new SubThread();
        thread.start();
    }
}

class SubThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println("Thread 클래스를 상속받은 클래스.");
        }
    }
}

> Task: ThreadTest.main()
> Thread 클래스를 상속받은 클래스.
> Thread 클래스를 상속받은 클래스.
> Thread 클래스를 상속받은 클래스.
> Thread 클래스를 상속받은 클래스.
> Thread 클래스를 상속받은 클래스.

2. Runnalbe 인터페이스를 구현하는 방법

Runnalbe 인터페이스를 구현하는 클래스를 만들어 사용하는 방법으로 해당 클래스는 run() 메서드를 구현한다. run() 메서드를 구현했다면 클래스의 인스턴스를 할당하고 Thread를 만들 때 인수로 전달하고 시작할 수 있다.

public class ThreadTest {
    public static void main(String[] args) {
        Runnable myThread = new MyThread2();
        Thread thread = new Thread(myThread);
        thread.start();
    }
}

class MyThread implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println("Runnable을 구현해서 만든 쓰레드");
        }
    }
}

> Task: ThreadThest.main()
> "Runnable을 구현해서 만든 쓰레드"
> "Runnable을 구현해서 만든 쓰레드"
> "Runnable을 구현해서 만든 쓰레드"
> "Runnable을 구현해서 만든 쓰레드"
> "Runnable을 구현해서 만든 쓰레드"

Runnable 인터페이스를 구현한 경우, 클래스의 인스턴스를 생성한 다음 인스턴스를 Thread 클래스의 생성자의 매개변수로 제공해야 한다.

public vlass Thread {
    private Runnable r; // Runnable을 구현한 클래스의 인스턴스를 참조하기 위한 변수

    public Thread(Runnable r) {
        this.r = r;
    }

    public void run() {
        if (r != null) {
            r.run(); // Runnable 인터페이스를 구현한 인스턴스의 run()을 호출
        }
    }
}

Thread 클래스를 상속받으면 다른 클래스를 상속 받을 수 없기 때문에, Runnable 인터페이스를 구현하는 방법이 일반적이다. Thread 클래스가 다른 클래스를 확장할 필요가 있을 경우에는 Runnable 인터페이스를 구현하면 된다.
Runnable 인터페이스를 구현하는 방법은 재사용성(reusablity)이 높고 코드의 일관성(consistency)을 유지할 수 있기 때문에 보다 객체지향적인 방법이라 할 수 있다.

Runnable 인터페이스

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

Thread 클래스

Thread 클래스에는 접근지정자가 public인 필드는 3개만 존재한다. 모두 쓰레드의 우선 순위에 대한 상수 필드이다.

  • public final static int MIN_PRIORITY = 1 (우선 순위 최소값)
  • public final static int NORM_PRIORITY = 5 (우선 순위 기본값)
  • public final static int MAX_PRIORITY (우선 순위 최대값)

Thread 클래스의 생성자에서 인자들이 가지는 의미

  • String gname
    이름을 지정하지 않고 쓰레드를 생성할 때 자동으로 생성되는 이름이다. 자동으로 생성되는 이름은 Thread-정수 형식을 가진다.
    • String name
      쓰레드 생성자에 인자로 주는 새로운 쓰레드의 이름을 의미한다.
  • Runnable target
    target은 쓰레드가 시작될 때 run() 메서드가 호출될 객체이다.
  • ThreadGroup group
    group은 생성할 쓰레드를 설정할 쓰레드 그룹이다. group 값이 null 이면서 security manager가 존재한다면 그룹은 SecurityManager.getThreadGroup()에 의해서 결정된다. security manager가 없거나 SecurityManager.getThreadGroup()이 null을 반환한다면 현재 쓰레드의 그룹으로 설정된다.
  • long stackSzie
    새로운 쓰레드의 스택 사이즈를 의미한다. 0이면 이 인자는 없는것과 같다. stackSize는 가상 머신이 쓰레드의 스택에 할당 할 주소 공간의 대략적인 바이트 수를 말한다.

구현과 실행에 관련된 run() 메서드와 start() 메서드
public void run() : 쓰레드가 실행되면 run() 메서드를 호출하여 작업을 한다.
public synchronized void start() : 쓰레드를 실행시키는 메서드이다. start 메서드가 호출되었다고 해서 바로 실행되는 것이 아니라 일단 실행 대기 상태에 있다가 자신의 차례가 되어야 실행된다.

run() 메서드와 start() 메서드의 차이점

쓰레드를 시작할때 start() 메서드를 호출해서 쓰레드를 실행한다. main 메서드에서 run() 메서드를 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라 단순히 메서드를 호출하는 것이다. 반면 start() 메서드를 호출하면 새로운 쓰레드가 작업을 실행하는데 필요한 새로운 호출 스택(call stack)을 생성한 다음 run() 메서드를 호출한다. 새로 생성된 콜 스택에 run() 메서드가 첫 번째로 올라가데 한다. run() 메서드의 수행이 종료된 쓰레드는 콜 스택이 모두 비워지면서 생성된 호출 스택도 소멸된다.

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

// 다음과 같은 경우는 첫번째 스레드를 실행 후 또 다른 쓰레드를 생성하여 실행하기 때문에 정상 실행된다.

MyThread1 thread1 = new MyThread1();
thread1.start();
thread1 = new MyThread1();
thread1.start();

한 쓰레드에서 예외가 발생하여 종료되더라도 다른 쓰레드의 실행에는 영향을 미치지 않는다.

Thread 메서드

메서드 설명
static void sleep(long millis)
static void sleep(long millis, int nanos)
지정한 시간(1/1000초) 동안 쓰레드를 일시정지 시킨다.
지정한 시간이 지나고 나면 다시 실행 대기 상태가 된다.
void join()
void join(long millis)
void join(long millis, int nanos)
지정한 시간동안 쓰레드가 실행되도록 한다.
지정된 시간이 지나거나 작업이 종료되면 join()을 호출한 쓰레드로 다시 돌아와 실행을 계속한다.
void interrupt() 쓰레드에게 작업을 멈추라고 요청한다. 쓰레드의 interrupted 상태를 false에서 true로 변경한다.
static boolean interrupted() sleep()이나 join()에 의해 일시정지 상태인 쓰레드를 깨워서 실행대기상태로 만든다.
해당 쓰레드에서는 interruptedException이 발생함으로써 일시정지 상태를 벗어나게 된다.
@Deprecated void stop() 쓰레드를 즉시 종료시킨다.
@Deprecated void suspend() 쓰레드를 일시정지 시킨다. resume()을 호출하면 다시 실행 대기상태가 된다,
@Deprecated void resume() suspend()에 의해 일시정지 상태에 있는 쓰레드를 실행대기 상태로 만든다.
static void yield() 실행 중에 자신에게 주어진 실행시간을 다른 쓰레드에게 양보(yield)하고 자신은 실행 대기 상태가 된다.
currentThread() 현재 실행중인 thread 객체의 참조를 반환한다.
destroy() clean up 없이 쓰레드를 파괴한다.@Deprecated 된 메서드로 suspend()와 같이 교착상태(deadlock)을 발생시키기 쉽다.
isAlive() 쓰레드가 살아있는지 확인하기 위한 메서드이다. 쓰레드가 시작되고 아직 종료되지 않았다면 살아있는 상태이다.
setPriority(int newPriority) 쓰레드의 우선순위를 새로 설정할 수 있는 메서드이다.
getPriority() 쓰레드의 우선순위를 반환한다.
setName(String name) 쓰레드의 이름을 새로 설정한다.
getName(String name) 쓰레드의 이름을 반환한다.
getThreadGroup() 쓰레드가 속한 쓰레드 그룹을 반환한다. 종료됐거나 정지된 쓰레드라면 null을 반환한다.
activeCount() 현재 쓰레드의 쓰레드 그룹 내의 쓰레드 수를 반환한다.
enumerate(Thread[] tarray) 현재 쓰레드의 쓰레드 그룹내에 있는 모든 활성화된 쓰레드들을 인자로 받은 배열에 넣는다. 그리고 활성화된 쓰레드의 숫자를 int 타입의 정수로 반환한다.
dumpStack() 현재 쓰레드의 stack trace를 반환한다.
setDaemon(boolean on) 이 메서드를 호출한 쓰레드를 데몬 쓰레드 또는 사용자 쓰레드로 설정한다.
JVM은 모든 쓰레드가 데몬 쓰레드만 있다면 죵료된다. 이 메서드는 쓰레드가 시작되기 전에 호출되어야 한다.
isDaemon() 이 쓰레드가 데몬 쓰레드인지 아닌지를 확인하는 메서드이다. 데몬 쓰레드면 true, 아니면 false를 반환한다.
getStackTrace() 호출하는 쓰레드의 스택 덤프를 나타내는 스택 트레이스 요소의 배열을 반환한다.
getAllStackTrace() 활성화된 모든 쓰레드의 스택 트레이스 요소의 배열을 value로 가진 map을 반환한다. key는 thread이다.
getId() 쓰레드의 고유값을 반환한다. 고유값은 long 타입의 정수이다.
getState() 쓰레드의 상태를 반환한다.

쓰레드의 상태

쓰레드의 현재 상태를 나타낸다.

상태 의미
NEW 쓰레드 객체는 생성되었지만 아직 시작되지 않은 상태 (start()가 호출되지 않은 상태)
RUNNABLE 쓰레드가 실행중인 상태 또는 실행 가능한 상태
BLOCKED 쓰레드가 실행 중지 상태이며, 모니터 락(monitor lock)이 풀리기를 기다리는 상태
WAITING 쓰레드가 대기중인 상태, 쓰레드의 작업이 종료되진 않았지만 실행가능하지 않은(unrunnable) 일시 정지 상태
TIMED_WAITING WAITING 상태에서 일시정지시간이 지정된 경우를 의미
TERMINATED 쓰레드의 작업이 종료된 상태

sleep()

  • 밀리세컨드와 나노세컨드의 시간단위로 값을 지정할 수 있지만 어느 정도 오차가 발생할 수 있다.
  • sleep()에 의해 일시정지 상태가 된 쓰레드는 지정된 시간이 다 되거나 interrupt() 가 호출되면 (InterruptedExcetpion 발생시킴) 깨어나 실행 대기 상태가 된다.
  • sleep() 메서드를 호출할 때는 항상 try-catch 문으로 InterruptedExcetpion을 예외처리 해주어야 한다.
  • sleep()는 항상 현재 실행 중인 쓰레드에 대해 작동한다. static으로 선언되어 있으며 참조변수를 이용해서 호출하기 보다는 Thread.sleep(1000)와 같이 호출해야 한다.

interrupt()

  • public void interrupt()
    쓰레드의 interrupted 상태를 false에서 true로 변경, 쓰레드에게 작업을 멈추라고 요청한다. 요청만 할 뿐 쓰레드를 강제로 종료시키는 것은 아니다.
  • public boolean insInterrupted()
    쓰레드의 interrupted 상태를 반환, interrupt()가 호출되었는지 확인하는데 사용할 수 있지만 interrupted()와 달리 interrupted 상태를 false로 초기화하지 않는다.
  • public static boolean interrupted()
    현재 쓰레드의 interrupted 상태를 반환 후 false로 변경, 쓰레드가 sleep(), wait(), join()에 의해 '일시정지 상태 (waiting)'에 있을때 해당 쓰레드에 대해 interrupt()를 호출하면 sleep(), wait(), join()에서 interruptedException이 발생하고 쓰레드는 '실행 대기 상태(Runnalbe);로 바뀐다.

suspend(), resume(), stop()

  • suspend()
    sleep()처럼 쓰레드를 일시정지 한다.
  • resume()
    suspend()에 의해 일시정지 상태에 있는 쓰레드를 실행대기 상태로 만든다.
  • stop()
    호출되는 즉시 쓰레드가 종료된다.

위 세 메서드는 쓰레드의 실행을 제어하는 가장 손쉬운 방법이지만, suspend()와 stop()가 교착 상태(deadlock)을 일으키기 쉽게 작성되어 있어 이 메서드들은 모두 @Deprecated 되었다.

yield()

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

join()

쓰레드 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때 사용한다.
시간을 지정하지 않으면 해당 쓰레드가 작업을 모두 마칠 때까지 기다린다. 작업중에 다른 쓰레드의 작업이 먼저 수행되어야할 필요가 있을때 join()를 사용한다.
join()도 sleep() 처럼 interrupt()에 의해 대기상태에서 벗어날 수 있으며, join()가 호출되는 부분을 try-catch문으로 감싸서 InterruptedException을 catch해야 한다.
sleep()와 다른점은 join()는 현재 쓰레드가 아닌 특정 쓰레드에 대해 동작하므로 static 메서드가 아니라는 점이다.

쓰레드의 우선순위

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

필드 설명
static int MAX_PRIORITY 쓰레드가 가질 수 있는 최대 우선순위를 명시함
static int MIN_PRIORITY 쓰레드가 가질 수 있는 최소 우선순위를 명시함
static int NORM_PRIORITY 쓰레드가 생성될 때 가지는 기본 우선순위를 명시함

getPriority()와 setPriority() 메서드를 통해 쓰레드위 우선순위를 반환하거나 변경할 수 있다. 쓰레드의 우선순위가 가질 수 있는 범위는 1부터 10까지이며, 숫자가 높을 수록 우선순위가 높아진다. 하지만 쓰레드의 우선순위는 상재적인 값일 뿐이다. 우선순위가 10인 쓰레드가 우선순위가 1인 쓰레드보다 10배 더 빨리 수행되는 것이 아니다. 단지 우선순위 10이 1보다 좀 더 많이 실행 큐에 포함되어 좀 더 많은 작업 시간을 할당받을 뿐이다.

Main 쓰레드

Java는 실행 환경인 JVM(Java Virtual Machine)에서 돌아가게 된다.이것이 하나의 프로세스이고 Java를 실행하기 위해 우리가 실행하는 main() 메서드가 메인 쓰레드이다. main()메서드는 메인 쓰레드의 시작점을 선언하는 것이다.

public class MainMethod {
    public static void main(String[] args) {
        // ...
    }
}

따로 쓰레드를 실행하지 않고 main() 메서드만 실행하는 것을 싱글쓰레드 애플리케이션 이라고 한다.
메인 쓰레드는 자바에서 처음으로 실행되는 쓰레드이자 모든 쓰레드는 메인 쓰레드로 부터 생성된다.

Daemon Thread

데몬 쓰레드는 다른 일반 쓰레드(데몬 쓰레드가 아닌 쓰레드)의 작업을 돕는 보조적인 역할을 수행하는 쓰레드이다. 데몬 쓰레드는 일반 쓰레드의 보조 역할을 수행하므로 일반 쓰레드가 모두 종료되고 나면 데몬 쓰레드의 존재 의미가 없어지기 떄문에 일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 자동 종료된다.

  • isDaemon()
    쓰레드가 데몬인지 확인한다. 데몬 쓰레드면 true, 아니면 false
  • setDaemon()
    쓰레드를 데몬 쓰레드로 또는 사용자 쓰레드로 변경한다. true면 데몬 쓰레드가 된다.
public class ThreadExample {
    public static void main(String[] args) {
        Thread main = Thread.currentThread();
        MyThread1 th1 = new MyThread1();

        th1.setDaemon(true);

        System.out.println("main.isDaemon() : " + main.isDaemon());
        System.out.println("th1.isDaemon() : " + th1.isDaemon());
    }
}

class MyThread1 extends Thread {
    @Override
    public void run() {
        super.run();
    }
}


> Task: ThreadExample.main()
> main.isDaemon() : false
> th1.isDaemon() : true
// long의 최대값 만큼 대기
public class DaemonThread extends Thread {
    public void run() {
        try {
            Thread.sleep(Long.MAX_VALUE);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
public void runCommonThread() {
    DaemonThread thread = new DaemonThread();
    thread.start();
}

// 프로그램이 대기하지 않고 그냥 끝나버린다.
// 데몬 쓰레드는 해당 쓰레드가 종료되지 않아도 다른 실행중인 일반 쓰레드가 없다면 멈춰버린다.
public void runDaemonThread() {
    DaemonThread thread = new DaemonThread();
    thread.setDaemon(true);
    thread.start();
}

데몬쓰레드를 만든 이유

예를들어 모니터링하는 쓰레드를 별도로 띄워 모니터링을 하다가, Main 쓰레드가 종료되면 관련된 모니터링 쓰레드가 종료되어야 프로세스가 종료될 수 있다. 모니터링 쓰레드를 데몬 쓰레드로 만들지 않으면 프로세스가 종료할 수 없게 된다. 이렇게 부가적인 작업을 수행하는 쓰레드를 선언할 때 데몬 쓰레드를 만든다.

동기화 (Synchronize)

싱글 쓰레드 프로세스의 경우 프로세스 내에서 단 하나의 쓰레드만 작업하기 때문에 프로세스의 자원을 가지고 작업하는데는 별 문제가 없다. 멀티 쓰레드 프로세스의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 된다. 공유 자원 접근 순서에 따라 실행 결과가 달라지는 프로그램의 영역임계구역(critical section) 이라고 한다.

임계구역 해결 조건

  • 상호 배제 (mutual exclusion)
    한 쓰레드가 임계구역에 들어가면 다른 쓰레드는 임계구역에 들어갈 수 없다. 이것이 지켜지지 않으면 임계구역을 설정한 의미가 없어진다.
  • 한정 대기 (bounded waiting)
    한 쓰레드가 계속 자원을 사용하고 있어 다른 쓰레드가 사용하지 못한 채 계속 기다리면 안된다. 어떤 쓰레드도 무한 대기 (infinite postpone) 하지 않아야 한다. 특정 쓰레드가 임계구역에 진입하지 못하면 안된다.
  • 진행의 융통성(progress flexibility)
    한 쓰레드가 다른 쓰레드의 작업을 방해해서는 안된다.

임계구역과 잠금(lock)의 개념을 활용해서 한 쓰레드가 특정 작업을 마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 할 수 있다.
공유 데이터를 사용하는 코드 영역을 임계구역으로 지정해놓고, 공유 데이터(객체)가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 한다. 해당 쓰레드가 임계 구역내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만 다른 쓰레드가 반납된 lock을 획득하여 임계 구역의 코드를 수행할 수 있게 된다.
이처럼 한 쓰레드가 진행중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 쓰레드의 동기화(synchronization) 이라고 한다.

자바에서 동기화 하는 방법은 3가지로 분류한다.

  • Synchronized 키워드
  • Atomic 클래스
  • Volatile 키워드

Synchronized 키워드

  • Java 예약어 중 하나로 변수명이나, 클래스명으로 사용이 불가능하다

    Synchronized 사용 방법

  • 메소드 자체를 synchronized로 선언하는 방법(synchronized methods)

      public synchronized void calcSum() {
          // ,,,
      }    
  • 다른 하나는 메서드 내의 특정 문장만 synchronized로 감싸는 방법 (synchronized statements)

      synchronized (객체의 참조변수) {
          // ...
      }

    두 가지 방법 모두 lock의 획득과 반납이 자동적으로 이루어지므로 임계구역만 지정해주면 된다.
    임계 구역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 가능하면 매서드 전체에 락을 거는 것보다 syncjronized 블럭으로 임계구역을 최소화해서 보다 효율적인 프로그램이 되도록 노력해야한다.

    public class Calculate {
      private int amount;
      private int interest;
      public static Obejct interestLock = new Object();
      public Calculate() {
          amount = 0;
      }
    
      public void addInterest(int value) {
          synchronized(interestLock) {
              interest += value;
          }
      }
    
      public void plus(int value) {
          synchronized (this) {
              amount += value;
          }
      }
    
      public sychronized void minus(int value) {
          amount -= value;
      }
    
      public int getAmount() {
          return amount;
      }
    }

Atomic

  • Atomicity(원자성)의 개념은 '쪼갤 수 없는 가장 작은 단위'를 뜻한다.
  • 자바의 Atomic Type은 Wrapping 클래스의 일종으로, 참조 타입과 원시 타입 두 종류의 변수에 모두 적용이 가능하다. 사용시 내부적으로 CAS(Compare-And-Swap) 알고리즘을 사용해 lock 없이 동기화 처리를 할 수 있다.
  • Atomic Type의 경우 volatile과 synchronized와 달리 java.util.concurrent.atomic 패키지에 정의된 클래스이다.
  • CAS는 특정 메모리 위치와 주어진 위치의 value를 비교하여 다르면 대체하지 않는다.
  • 변수를 선언할때 타입을 Atomic Type으로 선언해주면 된다. ex) AtomicLong

Compare-And-Swap(CAS) 란?

  • 메모리 위치의 내용을 주어진 값과 비교하고 동일한 경우에만 해당 메모리 위치의 내용을 새로 주어진 값으로 수정한다.
  • 현재 주어진 값(현재 쓰레드에서의 데이터)과 실제 데이터와 저장된 데이터를 비교해서 두 개가 일치할때만 값을 업데이트 한다. 이 역할을 하는 메서드가 compareAndSet()이다. 즉 synchronized 처럼 임계구역에 같은 시점에 두개 이상의 쓰레드가 접근하려 하면 쓰레드 자체를 blocking 시키는 매커니즘이 아니다.

Volatile

  • volatile 키워드는 Java 변수를 Main Memory에 저장하겠다라는 것을 명시하는 것이다.
  • 매번 변수의 값을 Read할 때마다 CPU cache에 저장된 값이 아닌 Main Memory에서 읽는 것이다.
  • 또한 변수의 값을 Write할 때마다 Main Memory에 작성하는 것이다.
  • volatile 변수를 사용하고 있지 않은 MultiThread 애플리케이션은 작업을 수행하는 동안 성능 향상을 위해서 Main Memory에서 읽은 변수를 CPU Cache에 저장하게 된다. 만약 Multi Thread 환경에서 Thread가 변수 값을 읽어올 때 각각의 CPU Cache에 저장된 값이 다르기 때문에 변수 값 불일치 문제가 발생하게 된다.

Lock과 Condition을 이용한 동기화

오래 기다린 쓰레드가 notify()로 인해 락을 얻는다는 보장은 없다. wait()가 호출되면 실행 중이던 쓰레드는 해당 객체의 대기실 (waiting pool)에서 통지를 기다린다. notify()가 호출되면 해당 객체의 대기실에 있는 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받는다. notifyAll()을 해서 모든 쓰레드에게 통보를 해도 lock을 얻는것은 하나의 쓰레드뿐이기 때문에 다른 쓰레드들은 계속해서 lock을 기다려야 한다. 이처럼 lock을 얻지 못하고 오랫동안 기다리게 되는 현상을 기아현상 (starvation)이라고 한다. 여러 쓰레드가 lock을 얻기 위해 경쟁하는 것은 경쟁 상태 (race confition)이라고 한다.

데드락 (교착상태, DeadLock)

2개 이상의 프로세스가 다른 프로세스의 작업이 끝나기만 기다리며 작업을 더 이상 진행하지 못하는 상태를 교착 상태 (dead lock)이라고 한다.

  • 둘 이상의 쓰레드가 lock을 획득하기 위해 대기하는데 이 lock을 잡고 있는 쓰레드들도 똑같이 다른 lock을 기다리면서 서로 block 상태에 놓이는 것을 말한다. Deadlock은 다수의 쓰레드가 같은 lock을 동시에, 다른 명령에 의해 획득하려 할 때 발생할 수 있다.

교착 상태가 발생하는 원인

교착 상태가 발생하기 위해서는 아래의 4가지 조건을 만족해야 한다. 이 4가지 조건을 교착 상태의 필요조건 이라고 한다.

  1. 상호 배제
    자원을 공유하지 못하면 교착 상태가 발생한다. 여기서 자원은 배타적인 자원이어야 한다. 배타적인 자원은 임계구역에서 보호되기 때문에 다른 쓰레드가 동시에 사용할 수 없다.

  2. 비선점
    자원을 빼앗을 수 없으면 자원을 놓을 때까지 기다려야 하므로 교착상태가 발생한다.

  3. 점유와 대기
    자원 하나를 잡은 상태에서 다른 자원을 기다리면 교착 상태가 발생한다.

  4. 원형 대기
    자원을 요구하는 방향을 원을 이루면 양보를 하지 않기 때문에 교착상태가 발생한다.

교착 상태 해결방법

  • 교착 상태 예방
    교착 상태는 상호 배제, 비선점, 점유와 대기, 원형 대기 라는 4가지 조건을 동시에 충족해야 발생하기 때문에 이 중 하나라도 막는다면 교착 상태는 발생하지 않는다.

  • 교착 상태 회피
    자원 할당량을 조절하여 교착 상태를 해결하는 방식이다. 자원을 할당하다가 교착 상태를 유발할 가능성이 있다고 판단하면 자원 할당을 중단하고 지켜보는 것이다.

  • 교착 상태 검출과 회복
    교착 상태 검출은 어떤 제약을 가하지 않고 자원 할당 그래프를 모니터링 하면서 교착 상태가 발생하는지 살펴보는 방식이다. 교착 상태가 발생하면 교착 상태 회복 단계가 진행된다.
    교착상태를 검출한 후 이를 회복시키는 것은 결론적으로 교착 상태를 해결하는 현실적인 접근 방법이다.

참고
https://sujl95.tistory.com/63
https://parkadd.tistory.com/48

반응형

'Programming > JAVA' 카테고리의 다른 글

애너테이션이란?  (0) 2021.03.06
Enum이란?  (0) 2021.03.02
예외란?  (0) 2021.02.13
인터페이스란?  (0) 2021.02.06
패키지란?  (0) 2021.01.21