Java

스레드

yanJuicy 2024. 5. 20. 08:03
반응형

1. 프로세스와 스레드

프로세스 vs 스레드

  • 프로세스: 운영체제로부터 자원을 할당받는 작업의 단위
  • 스레드: 프로세스가 할당받은 자원을 이용하는 실행 단위

프로세스

프로세스는 실행 중인 프로그램을 의미한다

OS 위에서 실행되는 모든 프로그램은 OS가 만들어 준 프로세스에서 실행된다

프로세스 구조

OS가 프로그램 실행을 위한 프로세스를 할당해 줄 때 프로세스 안에서 Code, Data, 메모리 영역(Stack, Heap)을 할당해준다

Code는 Java main 메소드와 같은 코드를 말한다

Data는 프로그램이 실행 중 저장할 수 있는 저장공간을 의미한다

  • 전역변수, 정적 변수 등 초기화 된 데이터를 저장하는 공간

Memory 메모리 영역

  • Stack: 지역변수, 매개변수 리턴 변수를 저장하는 공간
  • Heap: 프로그램이 동적으로 필요한 변수를 저장하는 공간

image

스레드

스레드는 프로세스 내의 코드 실행의 흐름이다

스레드 생성

프로세스에서 실행 요청이 들어오면 스레드를 만들어 명령을 처리하도록 한다

스레드 자원

프로세스 안에는 여러 스레드가 있고, 실행을 위한 프로세스 내 주소 공간이나 메모리 공간(Heap)을 공유 한다

스레드들은 각각 명령 처리를 위한 자신만의 메모리 공간(Stack)도 할당 받는다

image

Java 스레드

일반 스레드와 동일하며 JVM 프로세스 안에서 실행되는 스레드를 말한다

Java 프로그램을 실행하면 JVM 프로세스 위에서 실행된다

Java 프로그램 스레드는 main 스레드부터 실행되며 JVM에 의해 실행된다

image

2. 멀티 스레드

Java 프로세스는 main 스레드가 main() 메소드를 실행시키면서 시작 된다

  • main 스레드는 필요에 따라 작업 스레드들을 생성해 병렬로 코드를 실행할 수 있다
  • Java는 멀티 스레드를 지원한다

싱글 스레드

프로세스 안에서 하나의 스레드만 실행되는 것을 말한다

Java 프로그램에 main() 메소드만 실행시켰을 때 싱글 스레드라고 한다

main() 메소드의 스레드를 메인 스레드라고 부른다

JVM의 메인 스레드가 종료되면, JVM도 같이 종료된다

멀티 스레드

프로세스 안에서 여러 개의 스레드가 실행되는 것을 말한다

프로세스는 여러 스레드를 가질 수 있으며, 이 스레드들은 프로세스의 자원을 공유한다

멀티 스레드의 장점은 다음과 같다

  • 여러 스레드를 통해 여러 작업을 동시에 할 수 있어 성능이 좋아진다
  • 응답 스레드와 작업 스레드를 분리하여 빠르게 응답을 줄 수 있다 (비동기)

단점은 다음과 같다

  • 동기화 문제가 발생할 수 있다
    • 프로세스의 자원을 공유하며 작업을 처리하기 때문에 자원을 서로 사용하려고 충돌이 발생할 수 있다
  • 교착 상태(데드락)가 발생할 수 있다
    • 둘 이상의 스레드가 서로의 자원을 원하는 상태일 때, 서로 작업이 종료되기만을 기다리며 작업을 더 이상 진행하지 못하게 되는 상태다

3. Thread, Runnable

Thread

Thread 클래스를 상속받아 스레드를 구현한다

public class TestThread extends Thread {
    @Override
    public void run() {
        // 쓰레드 수행작업
    }
}

...

TestThread thread = new TestThread(); // 쓰레드 생성
thread.start() // 쓰레드 실행

run() 메서드에 작성된 코드가 스레드가 수행할 작업이다

Runnable

Runnable 인터페이스를 사용해 스레드를 구현한다

public class TestRunnable implements Runnable {
    @Override
    public void run() {
        // 쓰레드 수행작업 
    }
}

...

Runnable run = new TestRunnable();
Thread thread = new Thread(run); // 쓰레드 생성

thread.start(); // 쓰레드 실행

run() 메소드에 작성된 코드가 스레드가 수행할 작업이다

Runnable은 인터페이스이므로 Thread 클래스와 다르게 다른 필요한 클래스를 상속받을 수 있다

따라서 확장성에 유리하다

람다식

Runnable 인터페이스에는 람다식을 사용하여 스레드를 구현할 수 있다

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            int sum = 0;
            for (int i = 0; i < 50; i++) {
                sum += i;
                System.out.println(sum);
            }
            System.out.println(Thread.currentThread().getName() + " 최종 합 : " + sum);
        };

        Thread thread1 = new Thread(task);
        thread1.setName("thread1");  // 스레드에 이름 부여
        Thread thread2 = new Thread(task);
        thread2.setName("thread2");

        thread1.start();
        thread2.start();
    }
}

run() 메서드에 작성했던 스레드 작업을 {} 안에 작성한다

멀티 스레드 실습

public class Main {

    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 10; i++) {
                System.out.print("$");
            }
        };

        Runnable task2 = () -> {
            for (int i = 0; i < 10; i++) {
                System.out.print("*");
            }
        };

        Thread myThread1 = new Thread(task);
        myThread1.setName("My Thread1");

        Thread myThread2 = new Thread(task2);
        myThread1.setName("My Thread2");

        myThread1.start();
        myThread2.start();
    }

}

main 스레드에서 멀티 스레드로 여러 작업을 실행한다

image

여러 번 실행하면 계속 변화하면서 $*이 순서가 일정하지 않게 출력된다

두 스레드의 실행 순서나 걸리는 시간은 OS의 스케줄러가 처리하기 때문에 알 수 없다

4. 데몬 스레드, 사용자 스레드

데몬 스레드

백그라운드에서 실행되는 낮은 우선순위를 가진 스레드다

보조적인 역할을 담당하며 대표적으로 메모리 영역을 정리해주는 GC(가비지 컬렉터)가 있다

데몬 스레드 설정 방법

public class Main {

    public static void main(String[] args) {
        Runnable demon = () -> {
            for (int i = 0; i < 100; i++) {
                System.out.println("demon");
            }
        };

        Thread thread = new Thread(demon);
        thread.setDaemon(true); // true 데몬 스레드로 설정

        thread.start();

        for (int i = 0; i < 10; i++) {
            System.out.println("task");
        }
    }

}

Thread 인스턴스를 만들고 setDaemon() 메소드 인자를 true로 한다

데몬 스레드는 운선순위가 낮고 다른 스레드가 모두 종료되면 강제 종료 된다

코드를 실행하면 main 스레드가 먼저 종료되므로 thread도 같이 종료된다

사용자 스레드

포그라운드에서 실행되는 높은 우선순위를 가진 스레드다

프로그램 기능을 담당하며 대표적으로 main 스레드가 있다

기존에 만들었던 스레드 생성 방법으로 사용자 스레드를 만든다

JVM은 사용자 스레드의 작업이 끝나면 데몬 스레드도 자동으로 종료시킨다

5. 스레드 우선순위, 스레드 그룹

스레드 우선순위

스레드에 우선순위를 부여할 수 있다
작업의 중요도가 높을 때 우선순위를 높게 지정하면 더 많은 작업시간을 부여받아 빠르게 처리될 수 있다

스레드는 생성될 때 우선순위가 정해진다

이는 직접 지정하거나 JVM에 의해 지정될 수 있다

우선순위는 아래와 같이 3가지로 나뉜다

  • 최대 우선순위 (MAX_PRIORITY) = 10
  • 최소 우선순위 (MIN_PRIORITY) = 1
  • 보통 우선순위 (NROM_PRIORITY) = 5 (기본값)
  • 더 자세하게 나눈다면 1~10 사이의 숫자로 지정 가능하다

이 우선순위 범위는 OS가 아니라 JVM에서 설정한다

setPriority() 메소드로 설정할 수 있다

Thread thread1 = new Thread(task1);
thread1.setPriority(8);

getPriority() 메소드로 우선순위를 반환한다

int threadPriority = thread1.getPriority();
System.out.println("threadPriority = " + threadPriority);

우선순위가 높다고 반드시 스레드가 먼저 종료되지 않는다. 다만 확률을 높여준다.

스레드 그룹

서로 관련이 있는 스레드들을 그룹으로 묶어서 다룰 수 있다
JVM이 시작되면 system 그룹이 생성되고 스레드는 기본적으로 system 그룹에 포함된다

main 스레드는 system 그룹 하위에 있는 main 그룹에 포함된다

모든 스레드는 반드시 하나의 그룹에 포함되야 한다

스레드 그룹을 지정받지 못한 스레드는 자신을 생성한 부모 스렏드의 그룹과 우선순위를 상속받는다

우리가 생성하는 스레드는 main 스레드 하위에 포함된다

스레드 그룹 생성

ThreadGroup 클래스로 객체를 만들어 Thread 객체 생성 시 첫 번째 매개변수로 넣어준다

// ThreadGroup 클래스로 객체를 만든다
ThreadGroup group1 = new ThreadGroup("Group1");

// Thread 객체 생성시 첫번째 매개변수로 넣어준다
// Thread(ThreadGroup group, Runnable target, String name)
Thread thread1 = new Thread(group1, task, "Thread 1");

// Thread에 ThreadGroup 이 할당된것을 확인할 수 있다
System.out.println("Group of thread1 : " + thread1.getThreadGroup().getName());

스레드 그룹으로 묶어서 스레드 관리

ThreadGroup 객체의 interrupt() 메소드를 실행시키면 그룹 스레드들이 실행 대기 상태로 변경된다

ThreadGroup group1 = new ThreadGroup("Group1");

Thread thread1 = new Thread(group1, task, "Thread 1");
Thread thread2 = new Thread(group1, task, "Thread 2");

// interrupt()는 일시정지 상태인 쓰레드를 실행대기 상태로 변경
group1.interrupt();

6. 스레드 상태와 제어

스레드 상태

image

스레드는 실행과 대기를 반복하며 run() 메소드를 수행한다

run() 메소드가 종료되면 실행을 멈춘다

image

스레드도 일시정지 상태가 있다

일시정지 상태에서 스레드는 실행할 수 없다

일시정지 상태에서 실행 대기 상태로 넘어가야 스데르를 다시 실행할 수 있다

상태 Enum 설명
객체 생성 NEW 스레드 객체 생성, 아직 start() 메소드 호출 전 상태
실행 대기 RUNNABLE 실행 상태로 언제든지 run 할 수 있는 상태
일시정지 WAITING 다른 스레드가 notify 할 때까지 기다리는 상태
일시정지 TIMED_WAITING 주어진 시간 동안 기다리는 상태
일시정지 BLOCKED 사용하고자 하는 객체의 Lock이 풀릴 때까지 기다리는 상태
종료 TERMINATED 스레드의 작업이 종료된 상태

스레드 제어

image

sleep()

현재 스레드를 지정된 시간 동안 멈추게 한다

sleep() 사용법

try {
    Thread.sleep(2000); // 2초
} catch (InterruptedException e) {
    e.printStackTrace();
}

Thread.sleep(ms) ms(밀리초) 단위로 설정된다

sleep 상태 동안 interrupt()를 만나면 다시 실행되기 때문에 InterruptedException이 발생할 수 있다

특정 스레드를 지목해서 멈추게 하는 것은 불가능하고 자기 자신에 대해서만 멈출 수 있다

interrupt()

일시정지 상태인 스레드를 실행 대기 상태로 만든다

image

Thread 클래스 내부에 interrupted를 체크하는 boolean 변수가 존재한다

스레드가 start()후 동작하다 interrupt()를 만나 실행하면 interrupted = true가 된다

sleep() 실행 중 interrupt()가 실행되면 예외가 발생한다

public static void main(String[] args) {
    Runnable task = () -> {
        while (!Thread.currentThread().isInterrupted()) {
            try {
                Thread.sleep(1000);
                System.out.println(Thread.currentThread().getName());
            } catch (InterruptedException e) {
                break;
            }
        }
        System.out.println("task : " + Thread.currentThread().getName());
    };

    Thread thread = new Thread(task, "Thread");
    thread.start();

    thread.interrupt();

    System.out.println("thread.isInterrupted() = " + thread.isInterrupted());

}

join()

정해진 시간 동안 지정한 스레드가 작업하는 것을 기다린다

시간을 지정하지 않으면 지정한 스레드의 작업이 끝날 때까지 기다린다

join() 사용법

Thread thread = new Thread(task, "thread");

thread.start();

try {
    thread.join();  // 시간 지정 x, thread의 task가 끝날때까지 대기 
} catch (InterruptedException e) {
    e.printStackTrace();
}

thread가 작업을 끝날 때까지 main 스레드는 기다린다

yield()

남은 시간을 다음 스레드에게 양보하고 스레드 자신은 실행 대기 상태가 된다

yield() 사용법

public class Main {

    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                for (int i = 0; i < 10; i++) {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName());
                }
            } catch (InterruptedException e) {
                Thread.yield();
            }
        };

        Thread thread1 = new Thread(task, "thread1");
        Thread thread2 = new Thread(task, "thread2");

        thread1.start();
        thread2.start();

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        thread1.interrupt();
    }
}

thread1thread2가 1초에 한 번씩 출력된다

5초 뒤에 thread1에서 Thread.yield()가 실행되며 실행 대기 상태로 변경된다

synchronized

멀티 스레드에서 여러 스레드가 한 프로세스의 자원을 공유하므로 서로에게 영향을 줄 수 있고, 이로 인해 장애나 버그가 발생할 수 있다

이를 막기 위해 다른 스레드가 침범하지 못하도록 막는 것을 스레드 동기화라고 한다

동기화를 위해 다른 스레드의 침범을 막아야 하는 코드를 임계 영역으로 설정한다

임계 영역에는 Lock을 가진 하나의 스레드만 사용이 가능하다

synchronzed를 사용한 동기화
실행할 메소드 또는 코드 묶음 앞에 synchronized를 붙여서 임계 영역을 지정한다

  • 메서드 전체를 임계 영역으로 지정
    public synchronized void asyncSum() {
        ...침범을 막아야하는 코드...
    }
  • 특정 영역을 임계 영역으로 지정
    synchronized(해당 객체의 참조변수) {
        ...침범을 막아야하는 코드...
    }

synchronzed 없는 예제

public class Main {
    public static void main(String[] args) {
        AppleStore appleStore = new AppleStore();

        Runnable task = () -> {
            while (appleStore.getStoredApple() > 0) {
                appleStore.eatApple();
                System.out.println("남은 사과의 수 = " + appleStore.getStoredApple());
            }

        };

        for (int i = 0; i < 3; i++) {
            new Thread(task).start();
        }
    }
}

class AppleStore {
    private int storedApple = 10;

    public int getStoredApple() {
        return storedApple;
    }

    public void eatApple() {
        if (storedApple > 0) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            storedApple -= 1;
        }
    }
}

실행 결과는 남은 사과의 수가 뒤죽박죽이고 심지어 음수가 나오기도 한다

image

이를 해결하기 위해 AppleStore 클래스의 eatApple() 메소드를 임계 영역으로 지정한다

class AppleStore {
    private int storedApple = 10;

    public int getStoredApple() {
        return storedApple;
    }

    public void eatApple() {
        synchronized (this) {
            if(storedApple > 0) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                storedApple -= 1;
            }
        }
    }
}

image

남은 사과의 수가 순차적으로 감소한다

wait(), notify()

침범을 막은 코드를 수행하다가 더 이상 작업을 진행할 상황이 아니면 wait()을 호출하여 스레드가 Lock을 반납하고 기다리게 할 수 있다
그 후 다른 스레드가 락을 얻어 작업을 수행할 수 있다
추후에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해 작업을 중단했던 스레드가 다시 Lock을 얻을 수 있다

wait()
실행 중이던 스레드는 객체의 waiting pool에서 notify를 기다린다

notify()
객체의 waiting pool에 있는 모든 스레드 중에서 임의의 스레드만 notify를 받는다

wait() notify() 예제

public class Main {

    public static final int MAX_ITEM = 5;
    public static String[] itemList = {
            "MacBook", "IPhone", "AirPods", "iMac", "Mac mini"
    };
    public static AppleStore appleStore = new AppleStore();

    public static void main(String[] args) {

        // 가게 점원
        Runnable StoreClerk = () -> {
            while (true) {
                int randomItem = (int) (Math.random() * MAX_ITEM);
                appleStore.restock(itemList[randomItem]);
                try {
                    Thread.sleep(50);
                } catch (InterruptedException ignored) {
                }
            }
        };

        // 고객
        Runnable Customer = () -> {
            while (true) {
                try {
                    Thread.sleep(77);
                } catch (InterruptedException ignored) {
                }

                int randomItem = (int) (Math.random() * MAX_ITEM);
                appleStore.sale(itemList[randomItem]);
                System.out.println(Thread.currentThread().getName() + " Purchase Item " + itemList[randomItem]);
            }
        };


        new Thread(StoreClerk, "StoreClerk").start();
        new Thread(Customer, "Customer1").start();
        new Thread(Customer, "Customer2").start();

    }
}

class AppleStore {
    private List<String> inventory = new ArrayList<>();

    public void restock(String item) {
        synchronized (this) {
            while (inventory.size() >= Main.MAX_ITEM) {
                System.out.println(Thread.currentThread().getName() + " Waiting!");
                try {
                    wait(); // 재고가 꽉 차있어서 재입고하지 않고 기다리는 중!
                    Thread.sleep(333);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            // 재입고
            inventory.add(item);
            notify(); // 재입고 되었음을 고객에게 알려주기
            System.out.println("Inventory 현황: " + inventory.toString());
        }
    }

    public synchronized void sale(String itemName) {
        while (inventory.size() == 0) {
            System.out.println(Thread.currentThread().getName() + " Waiting!");
            try {
                wait(); // 재고가 없기 때문에 고객 대기중
                Thread.sleep(333);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        while (true) {
            // 고객이 주문한 제품이 있는지 확인
            for (int i = 0; i < inventory.size(); i++) {
                if (itemName.equals(inventory.get(i))) {
                    inventory.remove(itemName);
                    notify(); // 제품 하나 팔렸으니 재입고 하라고 알려주기
                    return; // 메서드 종료
                }
            }

            // 고객이 찾는 제품이 없을 경우
            try {
                System.out.println(Thread.currentThread().getName() + " Waiting!");
                wait();
                Thread.sleep(333);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

    }
}

AppleStore의 sale(), restock()을 통해 wait(), notify() 사용법을 알 수 있다

하지만 다음과 같이 서로 notify를 기다리는 데드락이 발생한다

image

notify()로는 원하는 스레드를 지정할 수 없으므로 이런 문제가 발생한다

Condition

wait(), noitfy()의 문제점인 waiting pool 내 스레드를 구분하지 못한다는 것을 해결한 것이 Condition이다

wait(), notify()는 객체에 대한 모니터링 락을 이용해여 스레드를 대기시키고 깨운다

waint(), notify()는 waiting pool 내에 대기 중인 스레드를 구분하지 못하므로, 특정 조건을 만족하는 스레드만 깨우기 어렵다

Condition은 waiting pool 내 스레드를 분리하여 특정 조건이 만족될 때만 깨우도록 할 수 있고, ReentrantLock 클래스와 함께 사용된다

wait(), notify() 대신 Condition의 await(), signal()을 사용한다

아래와 같이 Condtion을 만들어서 waiting pool을 사용할 수 있다

public class Main {
    public static final int MAX_TASK = 5;

    private ReentrantLock lock = new ReentrantLock();

    // lock으로 condition 생성
    private Condition condition1 = lock.newCondition();
    private Condition condition2 = lock.newCondition();

    private ArrayList<String> tasks = new ArrayList<>();

    // 작업 메서드
    public void restock(String task) {
        lock.lock(); // 임계영역 시작

        try {
            while(tasks.size() >= MAX_TASK) {
                    String name = Thread.currentThread().getName();
                    System.out.println(name+" is waiting.");
                    try {
                        condition1.await(); // wait(); condition1 쓰레드를 기다리게 합니다.
                        Thread.sleep(500);
                    } catch(InterruptedException e) {}    
            }

            tasks.add(task);
            condition2.signal(); // notify();  기다리고 있는 condition2를 깨워줍니다.
            System.out.println("Tasks:" + tasks.toString());
        } finally {
            lock.unlock(); // 임계영역 끝
        }
    }
}

ReentrantLock

synchronized 블럭으로 동기화하면 자동으로 lock이 걸리고 풀리지만, 같은 메소드 내에서만 Lock을 걸 수 있다
이 제약을 해결하기 위해 Lock 클래스를 사용한다

ReentrantLock

재진입 가능한 Lock, 가장 일반적인 배타 Lock이다

특정 조건에서 Lock을 풀고, 나중에 다시 Lock을 얻어 임계 영역으로 진입이 가능하다

public class MyClass {
    private Object lock1 = new Object();
    private Object lock2 = new Object();

    public void methodA() {
        synchronized (lock1) {
            methodB();
        }
    }

    public void methodB() {
        synchronized (lock2) {
            // do something
            methodA();
        }
    }
}
  • methodA는 lock1을 가지고, methodB는 lock2를 가진다
  • methodB에서 methodA를 호출하고 있으므로, methodB에서 lock2를 가진 상태에서 methodA를 호출하면 lock1을 가지려고 할 것이다
  • 그러나 이때, methodA에서 이미 lock1을 가지고 있으므로 lock2를 기다리는 상태가 되어 데드락이 발생할 가능성이 있다
  • 하지만 ReentrantLock을 사용하면, 같은 스레드가 이미 락을 가지고 있더라도 락을 유지하며 계속 실행할 수 있기 때문에 데드락이 발생하지 않는다
  • 즉, ReentrantLock을 사용하면 코드의 유연성을 높일 수 있다
반응형

'Java' 카테고리의 다른 글

Wrapper 클래스  (0) 2024.05.21