[Java] 쓰레드

thingzoo·2023년 6월 17일
0

Java

목록 보기
14/20
post-thumbnail

프로세스 vs 쓰레드

📌 프로세스(공장) vs 쓰레드(일꾼)

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

프로세스(Process)

실행 중인 프로그램, 자원과 쓰레드로 구성

프로세스 구조

OS가 프로그램 실행을 위한 프로세스를 할당해줄때, 프로세스안에 프로그램 Code와 Data, 메모리 영역을 함께 할당해줌

  1. Code: Java main 메소드와 같은 코드
  2. Data: 프로그램이 실행중 저장 할 수 있는 저장공간
    • 전역변수, 정적변수(static), 배열등 초기화된 데이터를 저장하는 공간
  3. Memory (메모리 영역)
    • Stack : 지역변수, 매개변수 리턴 변수를 저장하는 공간
    • Heap : 프로그램이 동적으로 필요한 변수를 저장하는 공간 (new(), mallock())

쓰레드(Thread)

프로세스 내에서 실제 작업을 수행

쓰레드 생성

프로세스가 작업중인 프로그램에서 실행요청이 들어오면 쓰레드를 만들어 명령을 처리하도록 함

쓰레드 자원

  • 프로세스 안에는 여러 쓰레드들이 있을 수 있고, 쓰레드들은 실행을 위한 프로세스 내 주소공간이나 메모리공간(Heap)을 공유받음
  • 추가로, 쓰레드들은 각각 명령처리를 위한 자신만의 메모리공간(Stack)도 할당받음

Java 쓰레드

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

  • Java 프로그램을 실행하면 앞서 배운 JVM 프로세스 위에서 실행됨
  • Java 프로그램 쓰레드는 Java Main 쓰레드부터 실행되며 JVM에 의해 실행됨

멀티쓰레드

📌 Java는 메인 쓰레드가 main() 메서드를 실행시키면서 시작

  • 메인 쓰레드는 필요에 따라서 작업 쓰레드들을 생성해서 병렬로 코드를 실행 시킬 수 있음
  • 즉, Java는 멀티 쓰레드 지원

단일 쓰레드(Sigle Thread)

프로세스 안에서 하나의 쓰레드만 실행되는 것

  • Java 프로그램의 경우, main() 메서드만 실행시키면 싱글 쓰레드
  • Java 프로그램 main() 메서드의 쓰레드를 ‘메인 쓰레드’라고 함
  • JVM 의 메인 쓰레드가 종료되면, JVM도 같이 종료

멀티 쓰레드(Multi Thread)

프로세스 안에서 여러개의 쓰레드가 실행되는 것

  • 하나의 프로세스는 여러개의 실행단위(쓰레드)를 가질 수 있으며 이 쓰레드들은 프로세스의 자원을 공유함

장점

  • 여러개의 쓰레드(실행 흐름)을 통해 여러개의 작업을 동시에 할 수 있어서 성능이 좋아짐
  • 스택을 제외한 모든 영역에서 메모리를 공유하기 때문에 자원을 보다 효율적으로 사용
  • 응답 쓰레드와 작업 쓰레드를 분리하여 빠른 응답 가능 (비동기)

단점

  • 동기화 문제(Synchronization Problem)가 발생할 수 있음
    • 프로세스의 자원을 공유 하면서 작업을 처리하기 때문에 자원을 서로 사용하려고 하는 충돌이 발생하는 경우
  • 교착 상태(Deadlock)가 발생할 수 있음
    • 둘 이상의 쓰레드가 서로의 자원을 원하는 상태가 되었을 때 서로 작업이 종료되기만을 기다리며 작업을 더 이상 진행하지 못하게 되는 상태
    • 일반적으로 동기화 및 리소스 할당의 부적절한 처리로 인해 발생
  • 병목현상(Bottleneck)이 발생할 수 있음
    • 어떤 시스템 내 데이터의 처리 속도가 지연됨에 의해서 다음에 오는 데이터 처리가 지연되는 현상
    • 비효율적인 알고리즘, 느린 I/O 작업, 과도한 Lock 또는 리소스 제한과 같은 다양한 원인이 있음

쓰레드 구현방법

1. Thread 클래스

  1. Thread 클래스를 상속 받아 쓰레드 구현
    • run()를 오버라이딩해서 수행할 작업 작성
  2. 1에서 만든 쓰레드 객체 생성 후
    • start()로 쓰레드 실행
public class TestThread extends Thread { // 1. Thread 상속
	@Override
	public void run() {
		// 쓰레드 수행작업
	}
}

public class Main {
    public static void main(String[] args) {
        TestThread thread = new TestThread(); // 2. 쓰레드 생성
        thread.start() // 쓰레드 실행
    }
}

2. Runnable 인터페이스 ✨

  1. Runnable 인터페이스 구현
    • run()를 오버라이딩해서 수행할 작업 작성
  2. 1에서 만든 Runnable 구현 객체 생성
  3. 2에서 만든 Runnable 구현 객체를 인자로 넣어 쓰레드 객체 생성 후
    • start()로 쓰레드 실행
class TestRunnable implements Runnable { // 1. Runnable 구현
    @Override
    public void run() {
		// 쓰레드 수행작업
    }
}

public class Main {
    public static void main(String[] args) {
        Runnable run = new TestRunnable(); // 2. Runnable 생성
        Thread thread = new Thread(run); // 3. 쓰레드 생성
        thread.start(); // 쓰레드 실행
    }
}

🤷🏻‍♀️ 왜 굳이 Runnable 인터페이스 사용하나요?

  • Thread는 클래스이므로 다중 상속되지 않아 확장성이 매우 떨어짐
  • 반대로 Runnable은 인터페이스이기 때문에 다른 필요한 클래스를 상속받을 수 있어 확정성에 매우 유리!

lambda식 이용 👍🏻

  • run() 메서드에 작성했던 쓰레드가 수행할 작업을 실행 블록 { } 안에 작성하는게 람다식
  • setName() 메서드: 쓰레드에 이름 부여
  • Thread.currentThread().getName(): 현재 실행 중인 쓰레드의 이름 반환
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();
    }
}

쓰레드 우선순위

쓰레드 작업의 중요도에 따라 쓰레드의 우선순위 부여 가능

  • 작업의 중요도가 높을 때 우선순위를 높게 지정하면 더 많은 작업시간을 부여받아 빠르게 처리될 수 있음
  • 단, 확률이 높은거지 반드시 먼저 종료가 되는 것은 아님!!
  • 쓰레드는 생성될때 우선순위가 정해짐
    • 직접 지정하거나 JVM에 의해 지정됨
  • 우선순위는 구체적으로 1~10 사이의 숫자로 지정 가능
    - OS가 아니라 JVM에서 설정한 우선순위
    • 최대 우선순위 (MAX_PRIORITY) = 10
    • 최소 우선순위 (MIN_PRIORITY) = 1
    • 보통 우선순위 (NROM_PRIORITY) = 5(기본값)
  • setPriority(n) 메서드로 설정
  • getPriority() 로 우선순위를 반환

Daemon Thread

보이지 않는곳(background) 에서 실행되는 낮은 우선순위를 가진 쓰레드

  • 일반 쓰레드의 작업을 돕는 보조적인 역할
  • 예시: 가비지 컬렉터(GC), 자동저장, 화면자동갱신 등
  • 무한루프와 조건문을 이용해 실행 후 대기하다가, 특정 조건이 만족되면 작업을 수행하고 다시 대기하도록 작성
  • void setDaemon(boolean on): 쓰레드를 데몬쓰레드 혹은 사용자쓰레드로 변경
    • 반드시 start()전에 실행해야함
public class Main {
    public static void main(String[] args) {
        Runnable demon = () -> {
            for (int i = 0; i < 1000000; i++) {
                System.out.println("demon");
            }
        };

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

        thread.start();

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

User Thread(Non-Daemon Thread)

보이는 곳(foregorund) 에서 실행되는 높은 우선순위를 가진 쓰레드

  • 데몬스레드 설정을 하지 않은 모든 쓰레드
  • 예시: 메인 쓰레드 등

🚨 JVM 은 사용자 쓰레드의 작업이 끝나면 데몬 쓰레드도 자동으로 종료시킴

Thread Group

서로 관련이 있는 쓰레드들을 그룹으로 묶어서 다루기 위한 것(보안상)

  • 모든 쓰레드들은 반드시 하나의 그룹에 포함
    • JVM이 시작되면 system 그룹이 생성되고 쓰레드들은 기본적으로 system 그룹에 포함
  • 메인 쓰레드는 system 그룹 하위에 있는 main 그룹에 포함됨
  • 자신을 생성한 쓰레드(부모쓰레드)의 그룹과 우선순위 상속받음
  • 그룹 미지정시, main 그룹에 포함됨
// ThreadGroup 클래스로 객체를 만듭니다.
ThreadGroup group1 = new ThreadGroup("Group1");

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

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

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

쓰레드 상태 및 제어

쓰레드 상태

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

쓰레드 제어

sleep()

지정된 시간동안 현재 쓰레드를 일시정지시킴
지정한 시간 후 자동적으로 다시 실행대기상태가 됨

try {
    Thread.sleep(2000); // 2초
} catch (InterruptedException e) {
    e.printStackTrace();
}
  • Thread.sleep(ms);: static메소드, ms(밀리초) 단위로 설정
  • 예외처리 필요
    • 일시정지 상태에 있는 동안 interrupt() 를 만나면 다시 실행되기 때문에 InterruptedException 발생
  • 특정 쓰레드를 지목해서 멈추게 하는 것은 불가능

interrupt()

일시정지 상태인 쓰레드를 깨워서 실행대기상태로 만듦
InterruptedException 발생함으로서 일시정지상태를 벗어나게됨

  • Thread 클래스 내부에 interrupted 되었는지를 체크하는 boolean 변수가 존재
  • 쓰레드가 start() 된 후 동작하다 interrupt()를 만나 실행하면 interrupted 상태가 true가 됨
  • isInterrupted() 메서드를 사용하여 상태값을 확인
  • !Thread.currentThread().isInterrupted() 로 interrupted 상태를 체크해서 예외 방지
public class Main {
    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()

정해진 시간동안 지정한 쓰레드가 작업하는 것을 기다림(일시정지 상태)

  • 지정된 시간이 없으면 지정한 쓰레드의 작업이 끝날때까지 기다림
Thread thread = new Thread(task, "thread");

thread.start();

try {
    thread.join();
} catch (InterruptedException e) {
    e.printStackTrace();
}
  • thread.join(ms);: ms(밀리초) 단위로 설정(선택)
  • 예외처리 필요
    • 일시정지 상태에 있는 동안 interrupt() 를 만나면 다시 실행되기 때문에 InterruptedException 발생

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();

    }
}

thread1과 thread2가 같이 1초에 한번씩 출력되다가 5초뒤에 thread1에서 InterruptedException이 발생하면서 Thread.yield(); 이 실행되어 thread1은 실행대기 상태로 변경되면서 남은 시간은 thread2에게 리소스가 양보된다.

동기화(synchronization)

한 번에 하나의 쓰레드만 객체에 접근할 수 있도록 객체에 Lock을 걸어서 데이터의 일관성을 유지하는 것

  • 멀티 쓰레드의 경우 여러 쓰레드가 한 프로세스의 자원을 공유해서 작업하기 때문에 서로에게 영향을 줄 수 있다. 이로인해서 장애나 버그가 발생할 수 있다.
  • 이러한 일을 방지하기 위해 '쓰레드 동기화(Synchronization)'가 필요
  • 동기화를 하려면 다른 쓰레드의 침범을 막아야하는 코드들을 ‘임계영역’으로 설정하면 된다.
  • 임계영역에는 Lock을 가진 단 하나의 쓰레드만 출입 가능
    • 즉, 임계영역은 한번에 한 쓰레드만 사용 가능

synchronized를 사용한 동기화

  • 실행할 메서드 또는 실행할 코드 묶음 앞에 synchronized 를 붙여서 임계영역을 지정하여 다른 쓰레드의 침범을 막을 수 있다. (침범을 막다. = Lock을 걸다.)
  • 임계영역 지정
    1. 메서드 전체를 임계영역으로 지정합니다.
      public synchronized void asyncSum() {
      	...침범을 막아야하는 코드...
      }
    2. 특정 영역을 임계영역으로 지정합니다.
      synchronized(해당 객체의 참조변수) {
              ...침범을 막아야하는 코드...
      }

wait() & notify()

침범을 막은 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, wait() 을 호출하여 쓰레드가 Lock을 반납하고 기다리게 할 수 있다.

  • 그럼 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행 할 수 있게 되고,
  • 추후에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서,
  • 작업을 중단했던 쓰레드가 다시 Lock을 얻어 진행할 수 있게 된다.

wait()

실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다림(일시정시 상태)

notify()

해당 객체의 대기실(waiting pool)에 있는 모든 쓰레드 중에서 하나의 쓰레드만 깨움(실행대기 상태로)

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

    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();
            }
        }

    }
}

이 코드에서는 고객은 원하는 제품이 없어서 기다리고, 점원은 재고가 다차서 기다려서 결국 둘다 무한정 기다리게되면서 병목현상이 발생할 수 있음
이 때문에 notify()와 wait()를 쓸때는 주의해야함

Lock, Condition

Lock

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을 사용하면 코드의 유연성을 높일 수 있음

ReentrantReadWriteLock

  • 읽기를 위한 Lock과 쓰기를 위한 Lock을 따로 제공
  • 읽기에는 공유적이고, 쓰기에는 베타적인 Lock
  • 읽기 Lock이 걸려있으면 다른 쓰레드들도 읽기 Lock을 중복으로 걸고 읽기를 수행할 수 있음 (read-only)
  • 읽기 Lock이 걸려있는 상태에서 쓰기 Lock을 거는 것은 비허용 (데이터 변경 방지)

StampedLock

  • ReentrantReadWriteLock에 낙관적인 Lock의 기능 추가
    • 낙관적인 Lock : 데이터를 변경하기 전에 락을 걸지 않는 것. 데이터 변경을 할 때 충돌이 일어날 가능성이 적은 상황에서 사용
    • 낙관적인 락을 사용하면 읽기와 쓰기 작업 모두가 빠르게 처리
      • 쓰기 작업이 발생했을 때 데이터가 이미 변경된 경우 다시 읽기 작업을 수행하여 새로운 값을 읽어들이고, 변경 작업을 다시 수행. 이러한 방식으로 쓰기 작업이 빈번하지 않은 경우에는 낙관적인 락을 사용하여 더 빠른 처리 가능
  • 낙관적인 읽기 Lock은 쓰기 Lock에 의해 바로 해제 가능
    • 무조건 읽기 Lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기 후 읽기 Lock

Condition

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

  • wait() & notify() 대신 Condition의 await() & signal() 사용
  • 아래 코드와 같이 Condition을 만들어서 대기줄(waiting pool)을 사용 가능
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 addMethod(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(); // 임계영역 끝
	}
}

Reference

🔗 스파르타코딩클럽 Java 문법 종합반
🔗 자바의 정석
🔗 https://gmlwjd9405.github.io/2018/09/14/process-vs-thread.html
🔗 https://math-coding.tistory.com/173
🔗 https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=qbxlvnf11&logNo=220921178603

profile
공부한 내용은 바로바로 기록하자!

0개의 댓글