- 프로세스와 쓰레드
- Process는 간단히 말해서 '실행중인 프로그램' => 프로그램을 실행하면 OS로부터 실행에 필요한 메모리(자원)을 할당 받아 프로세스가 된다.
- 프로세스는 프로그램을 수행하는데 필요한 데이터, 메모리 등의 자원 그리고 쓰레드로 구성
=> 프로세스의 자원을 이용하여 작업을 수행하는 것이 쓰레드 - 하나의 프로세스가 가질 수 있는 쓰레드의 개수는 제한되어 있지 않음
- 쓰레드가 작업을 수행하는데 개별적인 메모리(호출스텍)을 필요로 하기에 프로세스 메모리의 한계에 따라 생성 할 수 있는 쓰레드 개수 정해져 있다.
- 멀티태스킹과 멀티 쓰레딩
- 현재 대부분의 OS는 멀티 태스킹을 지원하기 때문에 여러개의 프로세스가 동시에 실행 될 수 있음
- CPU의 Core 가 한 번에 단 하나의 작업만 수행할 수 있으므로 실제로 동시에 처리되는 작업의 개수는 Core의 수와 일치
- Thread : 각 Core가 아주 짧은 시간 동안 여러작업을 번갈아 수행
- 멀티쓰레딩의 장단점
- 장점
- CPU의 사용률을 향상
- 자원을 보다 효율적으로 사용
- 사용자에 대한 응답성 향상
- 작업이 분리되어 코드가 간결해짐
- ※ 쓰레드를 가벼운 프로세스, 즉 경량 프로세스(LWP, light-weight process)라 부르기도 함
- 단점
- 동기화(Synchronization), 교착상태(Deadlock) 등 발생 할 수 있음
- ※ Deadlock : 두 Thread가 자원을 점유한 상태에서 서로 상대편이 점유한 자원을 사용하려고 기다리느라 진행이 멈춘 상태
- 장점
- 쓰레드의 구현과 실행
- 쓰레드 구현 방법
- Thread Class를 상속
- Runnable Interface 구현 : 오로지 run()만 정의되어 있는 간단한 인터페이스, 추상메서드 run()만 구현해주면 됨
=> 두개 중 어느 쪽을 선택해도 차이는 없지만 Thread Class 상속 시 타 Class 상속이 불가, Runnable Interface 구현하는 것이 일반적
=> 어떠한 방법이든 작업하고자 하는 내용으로 run()의 내용을 채우는 것
// Thread Class 상속 class MyThread extends Thread{ public void run() {/* 작업 내용 */} //Thread Class의 run()을 오버라이딩 } // Runnable Interface 구현 class MyThread implements Runnable { public void run() {/* 작업 내용 */} //Runnable Interface의 run()을 구현 }
- Thread 클래스 상속과 Runnable 인터페이스를 구현한 경우의 인스턴스 생성 방법은 상이
ThreadEX1 t1 = new ThreadEX1(); // Thread의 자손 클래스의 인스턴스를 생성 Runnable r = new ThreadEX2(); // Runnable을 구현한 클래스의 인스턴스를 생성 Thread t2 = new Thread(r) // 생성자 Thread(Runnable target) Thread t2 = new Thread(new ThreadEX2()); // 위 두줄을 합친 방식 t1.start(); t2.start();
- Thread 클래스를 상속 받으면, 자손 클래스에서 조상인 Thread 클래스의 메서드를 직접 호출할 수 있지만, Runnable을 구현하면 Thread 클래스의 static 메서드인 currentThread()를 호출하여 쓰레드에 대한 참조를 얻어 와야만 호출이 가능하다
static Thread currentThread() // 현재 실행중인 쓰레드의 참조를 반환 String getName() // 쓰레드의 이름을 반환 // Thread 상속 getName(); // Runnable 구현 Thread.currentThread().getName();
- Thread의 이름은 다음과 같은 생성자나 메서드를 통해서 지정 또는 변경 가능
Thread(Runnable target, String name) Thread(String name) void setName(String name)
- Thread의 실행 : start()
- start를 호출 해야만 쓰레드 실행 (하나의 Thread에 대해 start()가 한 번만 호출)
- start 호출 시 바로 실행하지 않음, [ic] 실행대기 상태에 있다가 자신의 차례가 되어야 실행 [/ic]
- Thread의 실행 순서는 OS의 스케줄러가 작성한 스케줄에 의해 결정
- [ic] 한번 실행이 종료된 쓰레드는 다시 실행할 수 없다[/ic] 두번 이상 start 호출 시 [ic] illegalThreadStateException [/ic] 발생
- 쓰레드 구현 방법
- start()와 run()
- run() : main 메서드에서 호출하는 것은 단순히 클래스에 선언된 Method 호출
- start() : 새로운 Thread가 작업을 하는데 필요한 call stack(호출스텍)을 생성한 다음 run()을 호출, 생성된 호출스텍에 run()이 첫 번째로 올라가게 함
=> 모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출스텍을 필요로 함.
=> 새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스텍이 생성되고 쓰레드가 종료되면 작업에 사용된 호출 스텍은 소멸start()와 run() - 새로운 쓰레드를 생성하고 start()를 호출한 후 호출스텍의 변화
1. main메서드에서 쓰레드의 start()를 호출
2. start()는 새로운 쓰레드를 생성, 쓰레드가 작업하는데 사용될 호출 스택을 생성
3. 새로 생성된 호출스텍에 run()이 호출되어, 쓰레드가 독립된 공간에서 작업을 수행
4. 이제는 호출스텍이 2개이므로 스케줄러가 정한 순서에 의해서 번갈아 가면서 실행
=> 호출 스택에서는 가장 위에 있는 메서드가 현재 실행준인 메서드 - main Thread
- [ic]실행중인 사용자 쓰레드가 하나도 없을 때 프로그램은 종료[/ic]
- 쓰레드는 사용자 쓰레드(user Thread, non-daemon thread)와 데몬 쓰레드(daemon thread)로 나뉨
- 싱글쓰레드와 멀티 쓰레드
- 싱글 코어 기준 싱글 쓰레드, 멀티쓰레드 작업 수행 결과 수행 시간은 거의 같다
- 작업 전환 (context switching) : 프로세스 또는 쓰레드 간의 작업 전환
- 작업 전환 시 다음에 실행해야할 위치(PC, 프로그램 카운터) 등의 정보를 저장하고 읽어 오는 시간 발생
- 쓰레드 스위칭 보다 프로세스 스위칭이 더 많은 정보를 저장해야하므로 더 많은 시간이 소요
- 예제 13-4,5 : 두개의 쓰레드로 작업하는데도 더 많은 시간이 걸린 이유
- 쓰레드 간의 context switching 시간 소요
- 한 쓰레드가 화면에 출력하고 있는 동안 다른 쓰레드는 출력이 끝나기를 기다려야함, 이 때 대기시간 발생
- 여러 쓰레드가 여러 작업을 동시에 진행하는 것을 병행(concurrent)라고 하고, 하나의 작업을 여러 쓰레드가 나눠서 처리하는 것을 병렬(parallel)이라고 함
- 책의 예제들은 실행 시 마다 다른 결과를 얻을 수 있음 => 실행 중인 예제 프로그램(프로세스)이 OS의 프로세스 스케줄러의 영향을 받음
- JVM의 쓰레드 스케줄러 => 쓰레드 실행 관리, 프로세스 스케줄러 => 프로세스 실행 관리
- JAVA가 OS의 종속적인 부분 중 하나
- JVM의 종류에 따라 쓰레드 스케줄러 구현 방법이 다를 수 있음, 멀티 쓰레드로 작성된 프로그램을 다른 종류의 OS에서도 충분히 테스트 할 필요가 있음
- 쓰레드의 우선 순위
- 쓰레드는 우선순위(priority)라는 멤버 변수(속성)을 가지고 있음
// 상수 및 메서드 void setPriority(int newPriority) int getPriority() public static final int MAX_PRIOIRY = 10 public static final int MIN_PRIOIRY = 1 public static final int NORM_PRIOIRY = 5
- 쓰레드가 가질 수 있는 우선 순위의 범위는 1~10이며 숫자가 높을 수록 운선 순위가 높음
- 쓰레드의 우선 순위는 쓰레드를 생성한 쓰레드로부터 상속 받음
- 쓰레드 실행 전에만 우선 순위 변경 가능
- JAVA는 쓰레드가 우선 순위에 따라 어떻게 다르게 처리되어야 하는지에 대해 강제하지 않음, 쓰레드의 우선 순위 관련된 구현이 JVM 마다 다를 수 있음 // 만일 확인 한다 하더라도 OS 스케줄러에 종속적이라 어느 정도 예측만 가능
=> 쓰레드 우선 순위 부여보다 작업에 우선순위를 두어 PriorityQueue에 저장하고, 우선 순위가 높은 작업이 먼저 처리 되게 하는 것이 좋을 수 있음
- 쓰레드는 우선순위(priority)라는 멤버 변수(속성)을 가지고 있음
- Thread Group
- 쓰레드를 그룹으로 묶어서 관리, 보안상의 이유로 도입된 개념
=> 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경 가능하지만, 다른 쓰레드 그룹의 다른 쓰레드를 변경할 수는 없음 - 모든 쓰레드는 반드시 쓰레드 그룹에 포함
- 쓰레드 그룹 생성 안 할 시 기본적으로 자신을 생성한 쓰레드와 같은 그룹
- 자바 어플리케이션 실행 시, JVM은 main과 system 이라는 쓰레드 그룹 생성 (가비지 컬렉션을 수행하는 Finalizer 쓰레드는 system 쓰레드에 속함
- 사용자가 생성한 모든 쓰레드 그룹은 main 쓰레드 그룹의 하위 쓰레드 그룹
// 쓰레드 자신이 속한 쓰레드 그룹을 반환 ThreadGroup getThreadGroup() // 쓰레드 그룹의 쓰레드가 처리되지 않은 예외에 의해 실행이 종료 되었을 때, // JVM에 의해 이 메서드가 자동적으로 호출 void uncaughtException (Thread t, Throwable e)
- 쓰레드를 그룹으로 묶어서 관리, 보안상의 이유로 도입된 개념
- 데몬 쓰레드(deamon thread)
- 다른 쓰레드의 작업을 돕는 보조적인 역할을 수행하는 쓰레드
- 일반 쓰레드가 모두 종료되면 자동 종료
- ex : 가비지 컬렉터, 워드프로세서의 자동 저장, 화면 자동 갱신
- 데몬 쓰레드는 무한루프와 조건문을 이용해서 실행 후 대기하고 있다가 특정 조건이 만족 되면 작업을 수행하고 다시 대기하도록 작성
- 데몬쓰레드는 일반 쓰레드와 작성,실행 방법이 같음
- 데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드
- 쓰레드의 실행 제어
- 쓰레드, 스케줄링 관련 메서드 중 resume(), stop(), suspend()는 쓰레드 dead-lock을 만들기 쉽기 때문에 사용 x
- Thread 상태는 getState()을 이용하여 확인 가능(JDK 1.5부터)
- sleep()
- sleep 호출 시 항상 try-catch 사용 (번거롭기에 method를 만들어서 사용하기도)
- sleep(), yield() static으로 선언되어 있으며 참조변수를 이용해 호출 보다 Thread.sleep() 이렇게 이용
=> 현재 실행 중인 Thread에 반영되기에
- interrupt()
- 쓰레드를 강제로 종료 시키는 것은 아님
- 쓰레디의 interrupted상태(인스턴스 변수)를 바꾸는 것
- join()
- 다른 쓰레드의 작업을 기다린다
- join 호출 부 try-catch 이용, sleep 과 유사하나 join은 static이 아니며 특정 쓰레드에 대해 동작
try{ th1.join(); // 현재 실행중인 쓰레드가 쓰레드 th1의 작업이 끝날 때까지 기다림. }catch(InterruptedException e){}
- Thread의 동기화
: 임계 영역 (critical section), 잠금(lock) 개념 도입
: lock => 공유 데이터(객체)를 한 곳에서만 사용
- synchronized를 이용한 동기화
- public synchronized void calcsum()
- 메서드 전체가 임계 영역으로 설정, 해당 메서드가 포함된 객체의 lock을 얻어 수행 후 종료되면 반납
- synchronized(객체의 참조 변수){}
- 참조변수는 lock을 걸고자하는 객체를 참조, 블록 영역 들어가면서 lock을 획득
- 항상 자동 반납, 임계 영역은 멀티 쓰레드 성능을 좌우함 => 싱크로즈드 블록 사용을 권장
- public synchronized void calcsum()
- wait()과 notify()
: 특정 쓰레드가 객체의 락을 오래 가지고 있는 것을 방지
- wait()을 호출 하면 쓰레드가 락을 반납하고 기다린다
- notify()를 호출 하여, 작업을 중단했던 쓰레드가 다시 락을 얻어 작업 진행
=> 오래 기다린 쓰레드가 락을 얻는다는 보장이 없음 - wait() 호출, 실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다림.
notify() 가 호출 되면 해당 객체의 대기실에 있던 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받음 - notifyAll()은 기다리고 있는 모든 쓰레드에 통보하지만 그래도 lock을 얻는 것은 하나
- 매개변수가 있는 wait은 지정된 시간 동안만 대기 (지정된 시간 후 자동으로 noify를 호출 하는 것과 같음)
- waiting pool은 객체 마다 존재 notifyAll()이 호출된다고 해서 모든 객체의 wiaiting pool에 있는 쓰레드가 깨워지는 것은 아님, notifyAll()이 호출된 객체의 waiting pool에 대기 중인 쓰레드만 해당됨
-
wait(), notify(), notifyAll() - object에 정의 됨 - 동기화 블록 내에서만 사용 가능 - 효율적 동기화 가능하게 함
- 기아상태(starvation), 경쟁 상태 (race condition)
- 기아 상태 : 통지를 받지 못 하고 오래 기다리는 상태
=> notifyAll() 을 이용하면 결국 lock을 얻어 진행할 수 있음 - 경쟁 상태 : 여러 쓰레드가 lock을 얻기 위해 경쟁
- 기아 상태 : 통지를 받지 못 하고 오래 기다리는 상태
- lock과 condition을 이용한 동기화
- Lock 클래스
ReentrantLock 재진입이 가능한 lock, 가장 일반적인 배타 lock ReentrantReadWriteLock 읽기에는 공유적이고, 쓰기에는 배타적인 lock StampedLock ReentrantReadWriteLock에 낙관적인 lock의 기능을 추가
- stampedlock은 jdk1.8부터 추가 lock 인터페이스 구현하지 않음
- ReentrantLock(boolean fair)
- 매개변수를 이용하면 가장 오래 기다린 쓰레드가 lock을 얻을 수 있음 (단, 성능은 떨어질 수 있음)
- ReentrantLock은 동기화 블럭과 달리 수동으로 lock 관리
- condition을 이용해서 통지 할 대상 선택
-
private ReentrantLock lock = new ReentrantLock(); //lock 생성 //lock으로 condition 생성 private Condition forCook = lock.newCondtion(); private Condition forCust = lock.newCondtion(); forCook.await() // wait() forCust.signal() // notify()
-
- Lock 클래스
- volatile : 변수의 읽거나 쓰기를 원자화 할 뿐, 동기화 하는 것은 아님
- 멀티 코어 프로세서에서는 코어마다 별도의 캐시를 가지고 있음
- 코어는 메모리에서 읽어온 값을 캐시에 저장하고 캐시에서 값을 읽어서 작업. 다시 같은 값을 읽어올 때는 먼저 캐시에 있는지 확인하고 없을 때만 메모리에서 읽어옴
- 메모리에서 읽기에 캐시와 메모리 간 불일치 발생1
- 변수에 volatile 붙이는 대신 동기화 블럭을 사용해도 같은 효과를 얻을 수 있음
- 쓰레드가 동기화 블럭으로 들어갈 때와 나올 때 캐시와 메모리간의 동기화가 이루어짐
- JVM은 데이터를 4byte(=32bit) 단위로 처리 함
- int등 4byte이하는 한 번에 읽고 쓰는 것이 가능
- 8byte인 long과 double타입은 변수를 읽는 과정에서 다른 쓰레드가 끼어들 수 있음
=> volatile을 이용하여 막음 (싱크로나이즈 블록을 사용 해도 됨) - 상수에는 volatile을 붙일 수 없다. 즉, final과 사용 불가 (변하지 않는 값 임으로 굳이 필요하지 않다)
- volatile은 변수의 읽기 쓰기를 원자화할 뿐 동기화하는 것이 아님을 기억하자
- fork & join 프레임웍
- 등장 배경
- 10년 전까지만 해도 CPU의 속도는 매년 2배씩 향상. 하지만 지금은 한계에 도달하여 코어의 개수를 늘려 CPU 성능을 향상
- 멀티코어를 잘 활용할 수 있는 멀티 쓰레드 프로그래밍이 중요해지고 있다. - JDK 1.7부터 추가
- 하나의 작업을 작은 단위로 나눠 여러 쓰레드가 동시에 처리하는 것을 쉽게 해주고 있음
-
// 아래 두 클래스 중 하나를 상속 받아 구현해야함 RecursiveAction // 반환값이 없는 작업을 구현할 때 사용 RecursiveTask // 반환값이 있는 작업을 구현할 때 사용 // 두 클래스 모두 compute()라는 추상 메서드를 포함, 상속을 통해 구현해야함 ForkJoinPool pool = new ForkJoinPool(); // 쓰레드 풀을 생성 SumTask task = new SumTask(from, to); // 수행할 작업을 생성 Long result = pool.invoke(task); // invoke를 호출하여 작업 시작
- 쓰레드를 시작할 때 run()이 아니라 start()를 호출하는 것 처럼, compute()가 아닌 invoke()를 호출
- Thread Pool
- fork&join프레임웍에서 제공
- 지정된 수의 쓰레드를 미리 만들고 반복해서 재사용할 수 있음
- 쓰레드를 반복해서 생성하지 않아도 됨
- 너무 많은 쓰레드가 생성되어 성능이 저하되는 것을 막음
- [ic] 쓰레드가 수행해야하는 작업이 담긴 큐 제공, 각 쓰레드는 자신의 작업 큐에 담긴 작업을 순서대로 처리 [/ic]
- ※ 쓰레드 풀은 기본적으로 코어의 개수와 동일한 개수의 쓰레드를 생성
- ※ comput()의 구조는 일반적인 재귀 호출 메서드와 동일
- fork()
- 호출을 통해 작업 큐에 작업 저장
- compute()를 이용해 더 이상 나눌 수 없을 때 까지 나눈 후 자신의 작업 큐가 비어있는 쓰레드는 다른 쓰레드의 작업 큐에서 작업을 가지고 와 수행
=> work stealing(작업 훔치기) 모든 쓰레드풀에 의해 자동으로 진행 - ※ 작업 크기를 충분히 작게 해야 골고루 작업이 나눠짐
- join() : 호출 하여 작업의 결과를 가지고 옴
- [ic]fork() - 해당 작업을 쓰레드 풀의 작업 큐에 넣음. 비동기 메서드 [/ic]
- [ic] join() - 해당 작업의 수행이 끝날 때 까지 기다렸다가, 수행이 끝나면 그 결과를 반환. 동기 메서드 [/ic]
- 예제 13-27
- 실행 결과를 보면 fork&join을 이용한 것이 for문의 결과 보다 느림
- 작업을 나누고 다시 합치는 데 시간 소요가 생김
- 멀티 쓰레드가 항상 빠르다고 생각하지 말자. 상황에 맞게 사용해야 함
- 등장 배경
- synchronized를 이용한 동기화
'JAVA' 카테고리의 다른 글
Java의 정석 Chapter 05 배열 (0) | 2022.07.24 |
---|---|
Java의 정석 Chapter 03 연산자 + Chapter 04 조건문과 반복문 (0) | 2022.07.24 |
Java의 정석 Chapter 02 변수 (0) | 2022.07.24 |
Java의 정석 Chapter 01 자바를 시작하기 전에 (0) | 2022.07.24 |