Ch.13 쓰레드(Thread)
- 프로세스와 쓰레드
- 프로세스(process): 실행 중인 프로그램
- 프로그램이 실행하면 OS로 부터 실행에 필요한 자원(메모리)를 할당받음
- 구성: 자원 (데이터, 메모리 등) + 쓰레드
- 프로세스에는 최소한 하나 이상의 쓰레드 존재
- 프로세스의 메모리 한계에 다라 생성할 수 있는 쓰레드 수가 결정
- 쓰레드(thread): 실제로 작업을 수행하는 것
- 멀티쓰레드 프로세스: 둘 이상의 쓰레드를 가짐
- 멀티태스킹과 멀티쓰레딩
- 멀티태스킹: 여러 개의 프로세스를 동시에 실행 (다중 작업)
- 멀티쓰레딩: 하나의 프로세스 안에서 여러 개의 쓰레드가 동시에 실행
- 멀티쓰레딩 장단점
- 장점
- CPU의 사용률을 향상
- 자원을 보다 효율적으로 사용
- 사용자에 대한 응답성 향상
- 작업이 분리되어 코드 간결
- 단점
- 동기화(synchronization) 문제: 여러 쓰레드가 한 자원을 공유하여 사용
- 교착 상태(deadlock): 두 쓰레드가 자원을 점유한 상태에서 서로 기다리는 상태에 들어가 진행이 멈추는 것
- 장점
- 쓰레드의 구현과 실행
- 구현 방법 두 가지
- Thread를 상속
- @Override public void run() { //오버라이딩 }
- Runnable 인터페이스를 구현
- public void run() { //바디 구현 }
- 상속 보다 인터페이스로 구현하면 재사용성이 높아지고 코드의 일관성이 유지됨
- Thread를 상속하는 경우에는 다른 클래스를 상속하지 못하기 때문에 인터페이스를 구현하는 것이 좋음
- Thread를 상속
- 사용 방법
- Thread를 상속한 경우
- Thread의 자손 클래스의 인스턴스 생성
- Runnable을 구현한 경우
- Runnable을 구현한 클래스의 인스턴스 생성
- 생성자 Thread의 매개변수에 인스턴스 대입
- 공통 다음 단계 - 실행, start()
- Thread를 상속한 경우
- Thread 클래스의 메서드
- static Thread currentThread() //현재 실행중인 쓰레드의 참조를 변환
- String getName() //쓰레드의 이름을 반환
- Thread 상속: 자손 클래스에서 메서드를 직접 호출 가능
- Runnable 구현:
- Thread 클래스의 static 메서드인 currentThread()를 호출
- 쓰레드에 대한 참조를 얻어와야 호출 가능
- 한번 실행이 종료된 쓰레드는 다시 사용할 수 없음 → 새로운 인스턴스를 생성해서 사용
- 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)에서 양보하게 되어 효율적
- void sleep() //지정된 시간동안 쓰레드를 일시정지시킴, 시간이 지나면 자동적으로 일어남
- 쓰레드의 상태
- NEW: 쓰레드가 생성되고 아직 start()가 호출되지 않은 상태
- RUNNABLE: 실행 중 또는 실행 가능한 상태
- BLOCKED: 동기화 블럭에 의해서 일시정지된 상태(lock이 풀릴때 까지 기다림)
- WAITING, TIMED_WAITING: 쓰레드의 작업이 종료되지는 않지만, 실행가능하지 않은(unrunnable) 일시정지 상태, TIMED_WAITING은 일시정지시간이 지정된 상태를 의미
- TERMINATED: 쓰레드의 작업이 종료된 상태
- 쓰레드의 상태 변화
- 쓰레드 생성 (NEW): start()
- 실행중 또는 실행 대기 상태 (RUNNABLE): yield()
- 일시 정지 상태 (WAITING, TIME_WAITING): suspend(), sleep(), wait(), join(), I/O block
- 실행 또는 실행 대기 상태 (RUNNABLE): time-out, resume(), notify(), interrupt()
- 쓰레드 종료 (TERMINATED): stop()
- 쓰레드의 동기화
- 멀티쓰레드 프로세스인 경우, 한 자원을 여러 쓰레드가 공유함으로써 서로의 작업에 영향을 미침
- 임계 영역(critical section), 잠금(lock): 한 쓰레드가 특정 작업을 마칠때 까지 다른 쓰레드에 의해 방해받지 않도록 추가된 개념
- 쓰레드의 동기화(synchronized): 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 의미
- Synchronized를 이용한 동기화
- 동기화 방법 2가지
- 메서드 전체를 임계 영역으로 지정: public sunchronized 메서드이름() { // }
- 특정한 영역을 임계 영역으로 지정: synchronized(객체의 참조변수) { // }
- 참조변수: 락을 걸고자 하는 객체를 참조하는 것이여야함
- 두 방법 모두 lock의 획득과 반납이 자동으로 이뤄짐
- 모든 객체는 lock을 하나씩 가지고 있음
- 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 실행할 수 있음
- 동기화 방법 2가지
- 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을 거는 것
- ReentrantLock: 재진입이 가능한 lock, 가장 일반적인 배타 lock
- condition:
- wait() & notify()로 쓰레드의 종류를 구분하지 않고 공육개체의 waiting pool에 넣는 것이 아닌 각각의 comdition을 만들어서 각각의 waiting pool에서 대기 상태로 기다리는 것을 의미
- Conditoin의 newCondition()을 이용해서 만듬
- synchronized 블럭과 메서드 이외의 lock을 사용하는 방법: java.util.concurrent.locks 패키지
- volatile
- 사용 이유: 코어가 변수의 값을 읽어올 때 캐시가 아닌 메모리에서 읽어오기 때문에 캐시와 메모리간의 불일치 해결
- 같은 방법: synchronized 이용
- fork & join 프레임웍
- 하나의 작업을 작은 단위로 나눠서 여러 쓰레드가 동시에 처리하는 것을 쉽게 해줌
- 클래스를 상속받아 구현: abstract compute()
- RecursiveAction: 반환값이 없는 작업을 구현할 때 사용
- RecursiveTask: 반환값이 있는 작업을 구현할 때 사용
- fork(): 해당 작업을 쓰레드 풀의 작업 큐에 넣음(비동기 메서드)
- join(): 해당 작업의 수행이 끝날때까지 기다렸다가 수행이 끝나면 그 결과를 반환(동기 메서드)
'JAVA 기초' 카테고리의 다른 글
[JAVA 기초] JAVA의 정석 - Ch.14 람다와 스트림 (정리) (1) | 2025.01.24 |
---|---|
[JAVA 기초] JAVA의 정석 - Ch.12 지네릭스, 열거형, 애너테이션 (정리) (3) | 2025.01.22 |
[JAVA 기초] JAVA의 정석 - Ch.11 컬렉션 프레임웍 (정리) (1) | 2025.01.21 |
[JAVA 기초] JAVA의 정석 - Ch.10 날짜와 시간 & 형식화 (정리) (4) | 2025.01.17 |
[JAVA 기초] JAVA의 정석 - Ch.09 Java.lang 패키지와 유용한 클래스 (정리) (2) | 2025.01.16 |