훌륭한 자바 개발자가 되기 위한 소양 기르기 (3) - 1 멀티 스레드 프로그래밍을 이해하기 전 스레드에 관한 정리

김성혁·2022년 10월 8일
0

스프링에서 Servlet은 한 개의 요청에 한 스레드를 맵핑하여 요청을 처리합니다. 여러 개의 스레드들이 만들어져 병렬적으로 여러 요청을 처리할 수 있는데, 이러한 모델을 멀티 스레드 모델이라고 합니다. 멀티 스레드 모델은 여러 작업을 병렬적으로 멀티 프로세스 모델보다 가볍게 처리하기 위한 목적으로 나왔습니다.

하지만 멀티 스레드 모델은 동일 메모리 영역을 공유하기 때문에 동일한 데이터에 대한 접근을 처리하여 동시성 문제를 해결해야 합니다.

먼저 스레드의 라이프 사이클에 대해 알아보겠습니다.

Life Cycle of Thread

스레드는 5가지 라이프 사이클을 가지고 있습니다.

New

스레드가 새롭게 생성되고, 아직 실행되지 않는 상태

RUNNABLE

JVM에서 실행 가능한 상태. 실행 가능한 상태는 현재 실행 중이거나 OS로부터의 다른 자원을 기다리며 실행할 준비가 완료된 상태를 의미합니다.

BLOCKED

Monitor lock을 획득하기를 기다리는 차단된 스레드의 스레드 상태입니다. 차단된 상태의 스레드는 동기화된 블록 메소드(임계영역을 의미)에 들어가기 위해 Monitor lock을 획득하기를 기다리거나 Object.wait를 호출한 후 동기화된 블록 메소드에 다시 들어가기를 기다리고 있습니다.

  • 모니터락을 얻어서 임계영역에 진입하고 모니터락을 풀어 임계영역에서 나간다.

WAITING

다른 스레드가 작업을 수행할 때까지 대기 중인 스레드의 상태

다음 메서드에 의해서 실행될 수 있음.

  • Object.wait with no timeout
  • Thread.join with no timeout
  • LockSupport.park

예를 들어, 메인 스레드가 Thread.join() 을 (내부적으로 Object.wait() 를 사용함 → wait()는 락을 해체한 상태)) 호출하면 해당 스레드는 자식 스레드의 종료를 기다리고 있는 대기 상태가 됩니다. 그런 다음 자식 스레드의 작업이 끝났을 때 자식 스레드가 Object.notify() 또는 Object.notifyAll()(해당 작업을 통해 다시 락을 획득함)을 사용해 작업이 완료됨을 메인 스레드에게 알리면 메인 스레드는 WAITING 상태에서 다시 RUNNABLE 상태로 이동합니다.

public final synchronized void join(long millis)
    throws InterruptedException {
        long base = System.currentTimeMillis();
        long now = 0;

        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (millis == 0) {
            while (isAlive()) {
                wait(0);
            }
        } else {
            while (isAlive()) {
                long delay = millis - now;
                if (delay <= 0) {
                    break;
                }
                wait(delay);
                now = System.currentTimeMillis() - base;
            }
        }
    }

BLOCKED 상태와 WAITING 상태를 구분짓지 않는 경우도 있는데 엄밀히 따지자면 BLOCKED는 락을 획득하기 위해 차단된 상태이고 WAITING은 다른 스레드의 작업이 끝날 때까지 대기 중인 상태를 말합니다.

(Side) 락 획득과 방출에 대한 개념 코드

class Customer {
    int amount = 10000;

    synchronized void withdraw(int amount) {
        System.out.println("going to withdraw...");

        if (this.amount < amount) {
            System.out.println("Less balance; waiting for deposit...");
            try {
                wait();
            } catch (Exception e) {
            }
        }
        this.amount -= amount;
        System.out.println("withdraw completed...");
    }

    synchronized void deposit(int amount) {
        System.out.println("going to deposit...");
        this.amount += amount;
        System.out.println("deposit completed... ");
        notify();
    }
}


class Test {
    public static void main(String args[]) {
        final Customer c = new Customer();
        new Thread() {
            public void run() {
                c.withdraw(15000);
            }
        }.start();
        new Thread() {
            public void run() {
                c.deposit(10000);
            }
        }.start();

    }
}

// 출력
going to withdraw...
Less balance; waiting for deposit...
going to deposit...
deposit completed... 
withdraw completed...

TIMED_WAITING

시간 제한이 있는 대기 상태

다음 메서드에 의해서 실행될 수 있음.

  • Thread.sleep
  • Object.wait with timeout
  • Thread.join with timeout
  • LockSupport.parkNanos
  • LockSupport.parkUntil

TERMINATED

실행이 완료된 상태


새 실행 스레드를 만드는 방법

하나의 클래스를 Thread의 하위 클래스로 선언하는 방법과 Runnable 인터페이스를 구현하는 클래스를 만들어 스레드를 생성하여 인수로 해당 클래스를 전달하는 방법입니다. 두 가지 방법 모두 run() 메서드의 오버라이딩이 필요합니다.

Thread Scheduler in Java

어떤 스레드를 실행할 것인지 그리고 어떤 스레드를 기다리게 할 것인지는 자바의 스레드 스케줄러가 해당 역할을 수행합니다.

어떤 스레드를 먼저 수행할 것인지를 판단하는데 우선 순위와 도착 시간이라는 두 가지 기준이 존재합니다. 스레드의 우선 순위가 높은 스레드가 먼저 실행되고, 스레드들의 우선 순위가 같다면 실행 중인 스레드 큐에 먼저 도착한 스레드가 우선권을 가집니다.

스케줄링을 할 때 중요하게 고려해야 할 점은 하나의 스레드가 CPU를 지속적으로 선점하여 다른 스레드들이 CPU라는 자원을 할당받지 못하는 상황을 만들지 않는 것 즉, 기아상태에 도달하지 않게 하는 것입니다.

용어 정리 | 데몬 스레드란?

user thread에게 서비스를 제공하는 서비스 제공자 스레드를 데몬 스레드라고 부릅니다. GC, Finalizer 등을 데몬 스레드라고 합니다. 데몬 스레드의 라이프 사이클은 사용자 스레드의 자비에 달려 있습니다. 즉, 모든 사용자 스레드가 죽으면 JVM은 이 스레드를 자동으로 종료합니다.

Thread Pool

작업을 기다리고 여러 번 재사용되는 worker thread들이 모인 그룹을 스레드 풀이라고 합니다.

My Opinion | 이렇게 커넥션 풀, 스레드 풀과 같은 풀을 만들어서 사용하는 이유는 새로운 무언가를 생성하는 시간이 절약되어 성능을 높이기 위함이라고 생각합니다. 다양한 애플리케이션의 특징에 따라 적절한 스레드 풀을 세팅하는 것도 성능을 높이기 위한 관건이라고 생각합니다.

스레드 누수가 발생하지 않는지 애플리케이션 코드를 검사해야 할 필요성이 있고 지속적으로 놀고 있는 스레드가 없는지 검사할 필요성 그리고 스레드들이 데드락과 같은 상황에 놓여 있지는 않은지 검사할 필요성이 있습니다.

class Tasks implements Runnable {
    private String taskName;

    public Tasks(String str) {
        taskName = str;
    }

    public void run() {
        try {
            for (int j = 0; j <= 5; j++) {
                if (j == 0) {
                    Date date = new Date();
                    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("hh : mm : ss");

                    System.out.println("Initialization time for the task name: " + taskName + " = " + simpleDateFormat.format(date));

                } else {
                    Date date = new Date();
                    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("hh : mm : ss");

                    System.out.println("Time of execution for the task name: " + taskName + " = " + simpleDateFormat.format(date));
                }

                Thread.sleep(1000);
            }

            System.out.println(taskName + " is complete.");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class ThreadPoolExample {

    static final int MAX_TH = 3;

    public static void main(String argvs[]) {

        long startTime = System.currentTimeMillis();

        Runnable rb1 = new Tasks("task 1");
        Runnable rb2 = new Tasks("task 2");
        Runnable rb3 = new Tasks("task 3");
        Runnable rb4 = new Tasks("task 4");
        Runnable rb5 = new Tasks("task 5");

        ExecutorService executorService = Executors.newFixedThreadPool(MAX_TH);

        executorService.execute(rb1);
        executorService.execute(rb2);
        executorService.execute(rb3);
        executorService.execute(rb4);
        executorService.execute(rb5);

        executorService.shutdown();
        
        

        if(executorService.isTerminated()) {
            long stopTime = System.currentTimeMillis();
            System.out.println(stopTime - startTime);
        }
    }
}

// 출력

Initialization time for the task name: task 1 = 04 : 31 : 41
Initialization time for the task name: task 2 = 04 : 31 : 41
Initialization time for the task name: task 3 = 04 : 31 : 41
Time of execution for the task name: task 3 = 04 : 31 : 42
Time of execution for the task name: task 2 = 04 : 31 : 42
Time of execution for the task name: task 1 = 04 : 31 : 42
Time of execution for the task name: task 1 = 04 : 31 : 43
Time of execution for the task name: task 2 = 04 : 31 : 43
Time of execution for the task name: task 3 = 04 : 31 : 43
Time of execution for the task name: task 2 = 04 : 31 : 44
Time of execution for the task name: task 3 = 04 : 31 : 44
Time of execution for the task name: task 1 = 04 : 31 : 44
Time of execution for the task name: task 3 = 04 : 31 : 45
Time of execution for the task name: task 2 = 04 : 31 : 45
Time of execution for the task name: task 1 = 04 : 31 : 45
Time of execution for the task name: task 1 = 04 : 31 : 46
Time of execution for the task name: task 2 = 04 : 31 : 46
Time of execution for the task name: task 3 = 04 : 31 : 46
task 1 is complete.
task 3 is complete.
task 2 is complete.
Initialization time for the task name: task 4 = 04 : 31 : 47
Initialization time for the task name: task 5 = 04 : 31 : 47
Time of execution for the task name: task 5 = 04 : 31 : 48
Time of execution for the task name: task 4 = 04 : 31 : 48
Time of execution for the task name: task 4 = 04 : 31 : 49
Time of execution for the task name: task 5 = 04 : 31 : 49
Time of execution for the task name: task 5 = 04 : 31 : 50
Time of execution for the task name: task 4 = 04 : 31 : 50
Time of execution for the task name: task 5 = 04 : 31 : 51
Time of execution for the task name: task 4 = 04 : 31 : 51
Time of execution for the task name: task 4 = 04 : 31 : 52
Time of execution for the task name: task 5 = 04 : 31 : 52
task 4 is complete.
task 5 is complete.

위의 작업을 보면 스레드 풀을 3으로 세팅했습니다. 유휴 스레드를 가질 경우 남은 태스크를 실행하는 것을 확인할 수 있습니다.

자바에서 동시성을 해결하기 위한 다양한 방법이 제공됩니다.

  1. 변수, 메서드, 블록 레벨에 사용할 수 있는 synchronized (상호 배제를 이용)
  2. 변수 레벨에 사용할 수 있는 volatile - 여러 스레드가 하나의 자원에 동시에 읽기/쓰기를 진행할 때 항상 메모리에 접근하지는 않는다. 캐시를 먼저 조회하는데 이 값은 메인 메모리의 값과 다를 수 있다. volatile을 이용해 실제 메인 메모리의 값을 볼 수 있다.(자원의 가시성)
  3. concurrent 패키지
  4. 불변객체의 사용 - 내부적인 상태가 변하지 않으니 동시성 이슈가 발생하지 않는다.

다음 시간에 자바의 동시성을 해결하는 방법 하나 하나를 자세히 다루도록 하겠습니다.

0개의 댓글