JAVA 기초

[JAVA 기초] JAVA의 정석 - Ch.13 쓰레드(Thread) (정리)

beginner-in-coding 2025. 1. 23. 16:32

Ch.13 쓰레드(Thread)


-      프로세스와 쓰레드

  • 프로세스(process): 실행 중인 프로그램
    • 프로그램이 실행하면 OS로 부터 실행에 필요한 자원(메모리)를 할당받음
    • 구성: 자원 (데이터, 메모리 등) + 쓰레드
    • 프로세스에는 최소한 하나 이상의 쓰레드 존재 
    • 프로세스의 메모리 한계에 다라 생성할 수 있는 쓰레드 수가 결정
  • 쓰레드(thread): 실제로 작업을 수행하는 것
    • 멀티쓰레드 프로세스: 둘 이상의 쓰레드를 가짐
  • 멀티태스킹과 멀티쓰레딩
    • 멀티태스킹: 여러 개의 프로세스를 동시에 실행 (다중 작업)
    • 멀티쓰레딩: 하나의 프로세스 안에서 여러 개의 쓰레드가 동시에 실행
  • 멀티쓰레딩 장단점
    • 장점
      1. CPU의 사용률을 향상
      2. 자원을 보다 효율적으로 사용
      3. 사용자에 대한 응답성 향상
      4. 작업이 분리되어 코드 간결
    • 단점
      1. 동기화(synchronization) 문제: 여러 쓰레드가 한 자원을 공유하여 사용
      2. 교착 상태(deadlock): 두 쓰레드가 자원을 점유한 상태에서 서로 기다리는 상태에 들어가 진행이 멈추는 것 

-      쓰레드의 구현과 실행

  • 구현 방법 두 가지
    1. Thread를 상속
      • @Override public void run() {  //오버라이딩  }
    2. Runnable 인터페이스를 구현
      • public void run() {  //바디 구현  }
      • 상속 보다 인터페이스로 구현하면 재사용성이 높아지고 코드의 일관성이 유지됨
      • Thread를 상속하는 경우에는 다른 클래스를 상속하지 못하기 때문에 인터페이스를 구현하는 것이 좋음
  • 사용 방법
    • Thread를 상속한 경우
      1. Thread의 자손 클래스의 인스턴스 생성
    • Runnable을 구현한 경우
      1. Runnable을 구현한 클래스의 인스턴스 생성
      2. 생성자 Thread의 매개변수에 인스턴스 대입
    • 공통 다음 단계 - 실행, start()
  • Thread 클래스의 메서드
    • static Thread currentThread()  //현재 실행중인 쓰레드의 참조를 변환
    • String getName()  //쓰레드의 이름을 반환
    • Thread 상속: 자손 클래스에서 메서드를 직접 호출 가능
    • Runnable 구현:
      1. Thread 클래스의 static 메서드인 currentThread()를 호출
      2. 쓰레드에 대한 참조를 얻어와야 호출 가능
  • 한번 실행이 종료된 쓰레드는 다시 사용할 수 없음  →  새로운 인스턴스를 생성해서 사용

-      start()와 run()

  • main()에서 run()를 호출하는 것: 단순히 클래스에 선언된 메서드를 호출하는 것을 의미
  • run(): 
    • 새로운 쓰레드가 작업을 실행하는데 필요한 호출 스택(call stack)을 생성
    • run()을 호출해서 run()이 첫 번째로 올라가게 함
    • 모든 쓰레드는 독립적인 실행을 위해 각각의 호출 스택이 필요
    • 호출할 때 마다 새로운 호출 스택이 생성
    • 작업이 종료되면 호출 스택 소멸
  • 스케쥴러가 각 쓰레드들의 우선 순위를 고려햐서 실행 순서와 실행 시간을 지정
  • main 쓰레드
    • 프로그램을 실행하면 기본적인 하나의 쓰레드를 생성
    • 그 쓰레드가 main을 호출해서 작업을 시작함
    • 실행 중인 사용자 쓰레드가 하나도 없어야지만 종료됨

-      싱글 쓰레드와 멀티 쓰레드

  • 싱글 쓰레드와 멀티 쓰레드의 실행 시간은 거의 동일함
    • 오히려 멀티 쓰레드가 쓰레드간의 작업 전환(context switching)에 시간이 더 걸림
  • 싱글 코어일 때와 멀티 코어인 경우에서 멀티 쓰레드
    • 싱글 코어: 하나의 코어가 번갈아가면서 실행  →  두 작업이 겹치지 않음
    • 멀티 코어: 동시에 쓰레드가 실행이 가능  →  겹치는 부분이 발생하여 화면(console)이라는 자원에서 경쟁
    • 병행(concurrent): 여러 쓰레드가 여러 작업을 동시에 진행
    • 병렬(parallel): 하나의 작업을 여러 쓰레드가 나눠서 처리
  • 싱글 쓰레드: 싱글 코어에서 단순히 CPU만을 사용하는 계산 작업에서 유리
  • 멀티 쓰레드: 두 쓰레드가 서로 다른 자원을 사용하는 작업의 경우에서 유리

-      쓰레드의 우선순위

  • 쓰레드: 우선순위(priority)라는 속성(멤버변수)를 가지고 있음
  • 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라짐
  • 사용 이유: 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 서로 다르게 지정해줘서 특정 쓰레드가 더 많은 시간을 쓸 수 있게함
  • 쓰레드의 우선순위 지정하기
    • void setPriority(int newPriority)  //쓰레드의 우선순위를 지정한 값으로 변경
    • int getPriority()  //쓰레드의 우선순위를 반환
    • 최대 우선순위(10) ~ 최소 우선순위(1)
  • 단점: OS의 스케쥴러에 종속적이므로 예측 가능한 정도로만 사용
    • 해결: 작업에 우선순위를 두어 PriorityQueue에 저장하고 우선순위가 높은 작업을 먼저 처리

-      쓰레드 그룹(thread group)

  • 서로 관련된 쓰레드를 그룹으로 다루기 위한 것
  • 쓰레드 그룹에 다른 쓰레드 그룹을 포함시킬 수 있음
  • 추가된 이유: 보안상의 이유로 도입됨
    • 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경이 가능
    • 이외의 다른 쓰레드 그룹의 쓰레드를 변경할 수 없도록 함
  • 생성 방법: ThreadGroup을 사용해서 생성
    • 관련 메서드
      • ThreadGroup getThreadGroup()  //쓰레드 자신이 속한 쓰레드 그룹을 반환
      • void uncaughtException(Thread t, Throwable e)  //쓰레드 그룹의 쓰레드가 처리되지 않은 예외에 의해 실행이 종료되었을 때, JVM에 의해 이 메서드가 자동으로 호출됨
  • 모든 쓰레드는 반드시 쓰레드 그룹에 속해있어야함
    • 사용하지 않으면 기본적으로 자신을 생성한 쓰레드와 같은 그룹에 속하게 됨

-      데몬 쓰레드(demon thread)

  • 다른 일반 쓰레드(데몬 쓰레드가 아닌 쓰레드)의 작업을 돕는 보조적인 역할을 수행하는 쓰레드
  • 일반 쓰레드가 종료되면 데몬 쓰레드는 자동으로 종료됨
  • 사용 예시: 가비지 컬렉터, 워드 프로세서의 자동 저장, 화면자동갱신 등
  • 무한 루프와 조건문을 이용해서 실행 후 대기하고 있다가 특정 조건이 만족되면 작업을 수행하고 다시 대기
  • 일반 쓰레드와 같지만 쓰레드를 생성하기 전 setDamon(true)를 호출해야함
  • 관련 메서드
    • boolean isDamon()  //쓰레드가 데몬 쓰레드인지 확인
    • void setDamon(boolean on)  //쓰레드를 데몬 쓰레드 또는 사용자 쓰레드로 변경

-      쓰레드의 실행 제어

  • 효율적인 멀티쓰레드 프로그램: 정교한 스케쥴링을 통해 프로세스에게 주어진 자원과 시간을 여러 쓰레드가 낭비 없이 잘 사용하도록 프로그래밍해야함
  • 쓰레드의 스케쥴링과 관련된 메서드
    • void sleep()  //지정된 시간동안 쓰레드를 일시정지시킴, 시간이 지나면 자동적으로 일어남
      • try-catch문 작성 필수(InterruptedException)
      • 자신의 쓰레드만 일시정지 시킬 수 있음(static)
    • void join()  //지정된 시간동안 쓰레드가 실행되도록 함, 지정된 시간이 지나 작업이 종료되면 join()을 호출한 쓰레드로 돌아와 실행을 진행함
      • try-catch문 작성 필수(InterruptedException)
      • join()은 현재 쓰레드가 아닌 특정 쓰레드에 대해 동작 (not static)
    • voif interrupt()  //sleep()이나 join()에 의해 일시정지 상태인 쓰레드를 깨워서 실행대기상태로 만듬, 해당 쓰레드에선 InterruptedException 발생함으로써 일시정지 상태에서 벗어남
      • 응답성이 좋아짐
      • boolean isInterrupted()  //쓰레드의 interrupted 상태를 반환
      • static boolean isInterrupted()  //현재 쓰레드의 interrupted 상태를 반환 후, false로 전환
    • void stop()  //쓰레드를 즉시 종료
      • 교착 상태를 일으키기 쉽기 때문에 decrecated
    • void suspend()  //쓰레드를 일시정지시킴, resume()을 호출하면 실행대기상태로 돌아감
      • 교착상태를 일으키기 쉽기 때문에 deprecated
    • void resume()  //suspend()로 일시정지상태에 있는 쓰레드를 실행대기상태로 만듬
    • static void yield()  //실행 중에 자신에게 주어진 실행 시간을 다른 쓰레드에게 양보하고 자신은 실행 대기상태로 변함
      • 바쁜 대기 상태(busy-waiting)에서 양보하게 되어 효율적
  • 쓰레드의 상태
    • NEW: 쓰레드가 생성되고 아직 start()가 호출되지 않은 상태
    • RUNNABLE: 실행 중 또는 실행 가능한 상태
    • BLOCKED: 동기화 블럭에 의해서 일시정지된 상태(lock이 풀릴때 까지 기다림)
    • WAITING, TIMED_WAITING: 쓰레드의 작업이 종료되지는 않지만, 실행가능하지 않은(unrunnable) 일시정지 상태, TIMED_WAITING은 일시정지시간이 지정된 상태를 의미
    • TERMINATED: 쓰레드의 작업이 종료된 상태
  • 쓰레드의 상태 변화
    1. 쓰레드 생성 (NEW): start()
    2. 실행중 또는 실행 대기 상태 (RUNNABLE): yield()
    3. 일시 정지 상태 (WAITING, TIME_WAITING): suspend(), sleep(), wait(), join(), I/O block
    4. 실행 또는 실행 대기 상태 (RUNNABLE): time-out, resume(), notify(), interrupt()
    5. 쓰레드 종료 (TERMINATED): stop()

-      쓰레드의 동기화

  • 멀티쓰레드 프로세스인 경우, 한 자원을 여러 쓰레드가 공유함으로써 서로의 작업에 영향을 미침
  • 임계 영역(critical section), 잠금(lock): 한 쓰레드가 특정 작업을 마칠때 까지 다른 쓰레드에 의해 방해받지 않도록 추가된 개념
  • 쓰레드의 동기화(synchronized): 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 의미
  • Synchronized를 이용한 동기화
    • 동기화 방법 2가지
      1. 메서드 전체를 임계 영역으로 지정: public sunchronized 메서드이름() {  //  }
      2. 특정한 영역을 임계 영역으로 지정: synchronized(객체의 참조변수) {  //  }
        • 참조변수: 락을 걸고자 하는 객체를 참조하는 것이여야함
    • 두 방법 모두 lock의 획득과 반납이 자동으로 이뤄짐
    • 모든 객체는 lock을 하나씩 가지고 있음
    • 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 실행할 수 있음
  • wait() notify()
    • 특정 쓰레드가 객체의 락을 오래 갖고 있지 않게 하는 것이 중요
    • 더 이상 수행할 상황이 아니라면 wait()를 호출하여 쓰레드가 락을 반납하고 기다리고, 다른 쓰레드가 락을 얻어 객체에 대한 작업을 수행하게 함
    • 재진입(reentrance): wait()에 의해 lock을 반납했다가 다시 lock을 얻어서 임계 영역에 들어오는 것
    • notify()와 notifyAll()
      • notify(): 해당 객체의 대기실에 있던 쓰레드들 중에서 임의의 쓰레드를 하나 깨움
      • notifyAll(): 해당 객체의 대기실에 있던 쓰레드를 모두에게 통보하지만, 실제로 lock을 얻는 쓰레드는 한 개임
    • 기아 상태와 경쟁 상태
      • 기아 현상(starvation): 특정 쓰레드가 오랜 기간동안 통지(notify)를 받지 못하고 대기 상태에 들어가있는 상태
      • 경쟁 상태(race condition): 여러 쓰레드가 lock을 얻기 위해 서로 경쟁하는 상태 
  • Lock과 Condition을 이용한 동기화
    • synchronized 블럭과 메서드 이외의 lock을 사용하는 방법: java.util.concurrent.locks 패키지
      • ReentrantLock: 재진입이 가능한 lock, 가장 일반적인 배타 lock
        • 재진입 가능: 특정 조건에서 lock을 풀고 나중에 다시 lock을 얻고 임계영역으로 들어와서 이후의 작업을 수행할 수 있는 것
      • ReentrantReadWriteLock: 읽기에는 공유적, 쓰기에는 배타적
        • 다른 쓰레드가 읽기 lock을 중복해서 걸고 읽기를 수행 가능
        • 읽기 lock이 걸린 상태에서 쓰기 lock을 거는 것은 허용하지 않음
      • StampedLock: ReentrantReadWriteLock에 낙관적인 lock 기능 추가
        • lock을 걸거나 해지할 때 스탬프(long타입의 정수값)을 사용
        • 읽기와 쓰기 lock + 낙관적 읽기(optimistic lock)
        • 낙관적 읽기: 무조건 읽기 lock을 걸지 않고, 쓰기와 읽기 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 거는 것
    • condition:
      • wait() & notify()로 쓰레드의 종류를 구분하지 않고 공육개체의 waiting pool에 넣는 것이 아닌 각각의 comdition을 만들어서 각각의 waiting pool에서 대기 상태로 기다리는 것을 의미
      • Conditoin의 newCondition()을 이용해서 만듬
  • volatile
    • 사용 이유: 코어가 변수의 값을 읽어올 때 캐시가 아닌 메모리에서 읽어오기 때문에 캐시와 메모리간의 불일치 해결
    • 같은 방법: synchronized 이용
  • fork & join 프레임웍
    • 하나의 작업을 작은 단위로 나눠서 여러 쓰레드가 동시에 처리하는 것을 쉽게 해줌
    • 클래스를 상속받아 구현: abstract compute()
      • RecursiveAction: 반환값이 없는 작업을 구현할 때 사용
      • RecursiveTask: 반환값이 있는 작업을 구현할 때 사용
    • fork(): 해당 작업을 쓰레드 풀의 작업 큐에 넣음(비동기 메서드)
    • join(): 해당 작업의 수행이 끝날때까지 기다렸다가 수행이 끝나면 그 결과를 반환(동기 메서드)