-
Java 쓰레드(Thread)Java 2024. 2. 26. 18:15728x90반응형
1. 프로세스와 쓰레드
실행 중인 프로그램
프로그램을 실행하면 OS로부터 실행에 필요한 자원(메모리)을 할당받아 프로세스가 된다
프로그램을 수행하는 데 필요한 데이터와 메모리 등의 자원 그리고 쓰레드로 구성되어 있으며,
프로세스의 자원을 이용해서 실제로 작업을 수행하는 것이 바로 쓰레드
그래서 모든 프로세스에는 최소한 하나 이상의 쓰레드가 존재하며,
둘 이상의 쓰레드를 가진 프로세스를 멀티쓰레드 프로세스라고 한다.
참고 프로세스라는 작업공간에서 작업을 처리하는 일꾼 쓰레드로 이해하면 쉽다
하나의 프로세스가 가질 수 있는 쓰레드의 개수는 제한이 없지만,
쓰레드가 작업을 수행하는데 개별적인 메모리 공간(호출 스택)을 필요로 하기 때문에,
프로세스의 메모리 한계에 따라 생성할 수 있는 쓰레드의 수가 결정된다.
실제로는 프로세스의 메모리 한계에 다다를 정도로 많은 쓰레드를 생성하는 일은 없을 것이니 걱정하지 않아도 된다.
2. 멀티태스킹과 멀티쓰레딩
대부분의 OS는 멀티태스킹(다중 작업)을 지원하기 때문에 여러 개의 프로세스가 동시에 실행될 수 있다.
CPU의 코어가 한 번에 단 하나의 작업만 수행할 수 있으므로,
실제로 동시에 처리되는 작업의 개수는 코어의 개수와 일치한다.
처리해야 하는 쓰레드의 수는 언제나 코어의 개수보다 훨씬 많기 때문에,
각 코어가 아주 짧은 시간 동안 여러 작업을 번갈아 가며 수행함으로써 여러 작업들이 모두 동시에 수행되는 것처럼 보이게 한다.
그래서 프로세스의 성능이 단순히 쓰레드의 개수에 비례하는 것은 아니며,
하나의 쓰레드를 가진 프로세스보다 두 개의 쓰레드를 가진 프로세스가 오히려 더 낮은 성능을 보일 수 있다.
3. 멀티 쓰레딩의 장단점
장점
1. CPU 사용률을 향상시킨다
2. 자원을 보다 효율적으로 사용할 수 있다.
3. 사용자에 대한 응답성이 향상된다
4. 작업이 분리되어 코드가 간결해진다.
메신저로 채팅하면서 파일을 다운로드 받거나 음성대화를 나눌 수 있는 것이 가능한 이유가 바로 멀티쓰레드로 작성되어 있기 때문이다.
여러 사용자에게 서비스를 해주는 서버 프로그램의 경우 멀티쓰레드로 작성하는 것은 필수적이어서,
하나의 서버 프로세스가 여러 쓰레드를 생성해서 쓰레드와 사용자의 요청이 일대일로 처리되도록 프로그래밍해야 한다.
만일 싱글쓰레드로 서버 프로그램을 작성한다면 사용자의 요청 마다 새로운 프로세스를 생성해야 하는데,
프로세스를 생성하는 것은 쓰레드를 생성하는 것에 비해 더 많은 시간과 메모리 공간이 필요하기 때문에 많은 수의 사용자 요청을 서비스하기 어렵다.
참고 쓰레드를 가벼운 프로세스, 경량 프로레스라고 부르기도 한다.단점
멀티쓰레드 프로세스는 여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업을 하기 때문에,
발생할 수 있는 동기화, 교착상태와 같은 문제들을 고려해서 신중히 프로그래밍해야 한다.
교착상태 : 두 쓰레드가 자원을 점유한 상태에서 서로 상대편이 점유한 자원을 사용하려고 기다리느라 진행이 멈춘 상태를 말한다.
4. 쓰레드의 구현과 실행
쓰레드를 구현하는 방법은 Thread클래스를 상속받은 방법과, Runnable인터페이스를 구현하는 방법 두 가지가 있다.
별 차이는 없지만 클래스를 상속받으면 다른 클래스를 상속받을 수 없기 때문에,
인터페이스를 구현하는 방법이 일반적이다
Thread 상속class MyThread extends Thread{ public void run() { ~~~ } // Thread클래스의 run()을 오버라이딩 }
Runnable 상속class MyThread implements Runnable{ public void run() { ~~~} // Runnable 인터페이스의 run()을 구현 }
Runnable 인터페이스는 오로지 run()만 정의되어 있는 간단한 인터페이스이다.
Runnable 인터페이스를 구현하기 위해 해야 할 일은 추상 메서드인 run()의 몸통을 만들어 주는 것 뿐이다.public interface Runnable{ public abstract void run(); }
쓰레드를 구현한다는 것은 두 방법 중 어떤 것을 선택하든지,
그저 쓰레드를 통해 작업하고자 하는 내용으로 run()의 몸통을 채우는 것일 뿐이다
Thread 클래스를 상속받은 경우와 Runnable 인터페이스를 구현한 경우의 인스턴스 생성 방법이 다르다.ThreadEx1_1 t1 = new ThreadEx1_1(); // Thread의 자손 클래스의 인스턴스 생성 Runnable r = new ThreadEx1_2(); Thread t2 = new Thread(r); Thread t2 = new Thread(new ThreadEx1_2()); // 위 2줄 간단하게 class ThreadEx1_1 extends Thread{ public void run(){ ~~ } } class ThreadEx1_2 implements Runnable{ public void run(){ ~~ } }
Runnable 인터페이스를 구현한 경우
Runnable 인터페이스를 구현한 클래스의 인스턴스를 생성한 다음,
이 인스턴스를 Thread 클래스의 생성자의 매개변수로 제공해야 한다public class Thread{ private Runnable r; // Runnable을 구현한 클래스의 인스턴스를 참조하기 위한 변수 public Thread(Runnable r){ this.r = r; } public void run(){ if(r!=null) r.run(); //Runnable 인터페이스를 구현한 인스턴스의 run()을 호출 } }
인스턴스 변수로 Runnable 타입의 변수 r을 선언해놓고,
생성자를 통해 Runnable 인터페이스를 구현한 인스턴스를 참조하도록 했다.
그리고 run()을 호출하면 참조변수 r을 통해 Runnable 인터페이스를 구현한 인스턴스의 run이 호출된다.
이렇게 함으로써 상속을 통해 run을 오버라이딩하지 않고도 외부로부터 run을 제공받을 수 있게 된다.
Thread클래스를 상속받으면, 자손 클래스에서 조상인 Thread 클래스의 메서드를 직접 호출할 수 있지만,
Runnable을 구현하면 Thread 클래스의 static메서드인 current Thread()를 호출하여 쓰레드에 대한 참조를 얻어 와야만 호출 가능
그래서 Thread를 상속받은 ThreadEx1_1에는 getName()을 호출하면 되지만,
Runnable을 구현한 ThreadEx1_2에는 멤버가 run밖에 없기 때문에,
Thread 클래스의 getName을 호출하려면 Thread.currentThread().getName()으로 해야 한다class ThreadEx1_1 extends Thread{ public void run(){ for(int i = 0; i<5; i++){ //조상인 Thread의 getName을 호출 sysout(getName()); } } } class ThreadEx1_2 implements Runnable{ public void run(){ for(int i = 0; i<5; i++){ // Thread.currentThread() - 현재 실행중인 Thread 반환 sysout(Thread.currentThread().getName()); } } }
Thread의 이름을 지정하지 않으면 'Thread-번호'의 형식으로 정해진다
생성자나 메서드를 통해 지정, 변경 가능Thread(Runnable target, String name) Thread(String name) void setName(String name) Thread t = Thread.currentThread(); String name = t.getName(); sysout(name); 3줄을 한줄로 적으면 sysout(Thread.currentThread().getName());
5. 쓰레드의 실행
쓰레드를 생성했다고 자동으로 실행되는 것이 아니다. start()를 호출해야 한다.
t1. start();
start()가 호출되었다고 해서 바로 실행되는 것은 아니고, 일단 실행대기 상태에 있다가 자신의 차례가 되면 실행된다.
참고 쓰레드의 실행순서는 OS의 스케쥴러가 작성한 스케쥴에 의해 결정된다.
한 번 실행이 종료된 쓰레드는 다시 실행할 수 없다.
> 한 쓰레드에 대해 start는 한번만 호출될 수 있다. 두 번 호출해보면 에러start(), run()
main 메서드에서 run()을 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라,
단순히 클래스에 선언된 메서드를 호출하는 것이다.
start는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택을 생성한 다음에 run을 호출해서,
생성된 호출스택에 run이 첫 번째로 올라가게 한다.
모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택을 필요로 하기 때문에,
새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스택이 생성되고,
쓰레드가 종료되면 작업에 사용된 호출스택은 소멸된다.
쓰레드가 둘 이상일 때는 (한) 호출스택의 최상위에 있는 메서드라도 대기상태에 있을 수 있다.
스케줄러는 실행대기중인 쓰레드들의 우선순위를 고려해 실행 순서와 실행 시간을 결정하고,
각 쓰레드들은 작성된 스케줄에 따라 자신의 순서가 되면 지저된 시간동안 작업 수행.
이 때, 주어진 시간동안 작업을 못마친 쓰레드는 다시 차례가 돌아올 때까지 대기상태,
작업을 마친 쓰레드 = run()의 수행이 종료된 쓰레드는 호출스택이 모두 비워지며 이 쓰레드가 사용하던 호출스택은 사라진다.main쓰레드
main메서드의 작업을 수행하는 것도 쓰레드, main쓰레드라고 함
main메서드가 수행을 마치면 프로그램이 종료되지만,
다른 쓰레드가 작업을 아직 마치치 못한 상태면 종료되지 않는다.
실행 중인 사용자 쓰레드가 하나도 없을 때 프로그램 종료
쓰레드는 사용자 쓰레드와 데몬 쓰레드 두 종류가 있다.
6. 싱글쓰레드와 멀티쓰레드
하나의 쓰레드로 두개의 작업을 수행한 시간보다 두 쓰레드로 두 작업을 수행한 시간이 조금 더 많다.
쓰레드 간의 작업전환에 시간이 걸리기 때문
작업 전환을 할 때, 현재 진행 중인 작업의 상태,
예로, 다음에 실행해야할 위치(PC, 프로그램 카운터) 등의 정보를 저장하고 읽어오는 시간이 소요된다.
참고로 쓰레드의 스위칭에 비해 프로세스의 스위칭이 더 많은 정보를 저장하므로 더 많은 시간이 걸린다
그래서 싱클코어에서 단순히 CPU만을 사용하는 계산작업이라면 오히려 멀티쓰레드보다 싱글쓰레드로 프로그래밍하는 것이 더 효율적이다.
참고 여러 쓰레드가 여러 작업을 동시에 진행하는 것을 병행,
한 작업을 여러 쓰레드가 나눠서 처리하는 것을 병렬이라고 한다
멀티 쓰레드 실행 결과는 실행 때마다 다른 결과를 얻을 수 있는데,
실행중인 프로그램(프로세스)이 OS의 프로세스 스케줄러의 영향을 받기 때문이다.
JVM의 쓰레드 스케줄러에 의해 어떤 쓰레드가 얼마동안 실행될 것인지 결정되는 것과 같이,
프로세스도 프로세스 스케줄러에 의해 실행순서와 실행시간이 결정되기 때문에,
매 순간 상황에 따라 프로세스에게 할당되는 실행 시간이 일정하지 않고,
쓰레드에게 할당되는 시간 역시 일정하지 않게 된다.
자바가 OS 독립적이라고 하지만 실제로는 OS종속적인 부분이 몇 가지 있는데 쓰레드도 그 중 하나이다.
JVM의 종류에 따라 쓰레드 스케줄러의 구현방법이 다를 수 있기 때문에,
멀티쓰레드로 작성된 프로그램을 다른 종류의 OS에서도 충분히 테스트해 볼 필요가 있다.
두 쓰레드가 서로 다른 자원을 사용하는 작업의 경우, 멀티쓰레드가 더 효율적
예를 들면, 사용자로부터 데이터를 입력받는 작업, 네트워크로 파일을 주고받는 작업처럼,
외부 기기와의 입출력을 필요로 하는 경우가 해당한다
| A | 빈칸 | A | B | // 빈칸 = 사용자로부터 입력을 기다리는 구간, 아무 일도 하지 않는다
| A | B | A | B | // 입력을 기다리는 동안 B가 수행된다
7. 쓰레드의 우선순위
쓰레드는 우선순위(priority)라는 속성(멤버변수)을 가지고 있는데, 이 값에 따라 쓰레드가 얻는 실행 시간이 달라진다.
쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 서로 다르게 지정하여 특정 쓰레드가 더 많은 작업 시간을 갖도록 할 수 있다.
예를 들어, 파일전송기능이 있는 메신저의 경우,
파일다운로드를 처리하는 쓰레드보다 채팅내용을 전송하는 쓰레드의 우선순위가 더 높아야 사용자가 채팅하는데 불편함이 없을 것이다.대신 파일다운로드 작업에 걸리는 시간은 더 길어질 것이다.
이처럼 시각적인 부분이나, 사용자에게 빠르게 반응해야하는 작업을 하는 쓰레드의 우선 순위는 다른 작업을 수행하는 쓰레드에 비해 높아야 한다.쓰레드 우선순위 지정
쓰레드의 우선순위와 관련된 메서드와 상수는 다음과 같다.
void setPriority(int newPriority) // 쓰레드의 우선순위를 지정한 값으로 변경 int getPriority() // 쓰레드의 우선순위 반환 public static final int MAX_PRIORITY = 10 // 최대 우선순위 public static final int MIN_PRIORITY = 1 // 최소 우선순위 public static final int NORM_PRIORITY = 5 // 보통 우선순위
쓰레드가 가질 수 있는 우선순위 범위는 1~10, 숫자가 높을수록 우선순위가 높다
쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속받는다
main메서드를 수행하는 쓰레드는 우선순위가 5이므로 main메서드 내에서 생성하는 쓰레드의 우선순위는 자동적으로 5가 된다.
멀티코어에서는 쓰레드의 우선순위에 따른 차이가 전혀 없었다.
>> 그저 쓰레드에 높은 우선순위를 주면 더 많은 실행시간과 기회를 갖게될 것이라 기대할 수 없다는 뜻
멀티코어라 해도 OS마다 다른 방식으로 스케쥴링하기 때문에, 어떤 OS에서 실행하냐에 따라 다른 결과가 나온다.
굳이 우선순위에 차등을 두어 쓰레드를 실행하려면,
특정 OS의 스케쥴링 정책과 JVM의 구현을 직접 확인해봐야 한다.
자바는 쓰레드가 우선순위에 따라 어떻게 다르게 처리되어야 하는지에 대해 강제하지 않으므로,
쓰레드의 우선순위와 관련된 구현이 JVM마다 다를 수 있기 때문이다.
만일 확인한다 하더라도, OS의 스케쥴러에 종속적이라서 어느 정도 예측만 가능한 정도일 뿐, 정확히 알 수는 없다.
차라리 쓰레드에 우선순위를 부여하는 대신 작업에 우선순위를 두어 priorityQueue에 저장해놓고,
우선순위가 높은 작업이 먼저 처리되도록 하는 것이 나을 수 있다.
8. 쓰레드 그룹
서로 관련된 쓰레드를 그룹으로 다루기 위한 것
폴더를 생성해서 관련된 파일들을 함께 넣어서 관리하는 것처럼,쓰레드 그룹을 생성해서 쓰레드를 그룹으로 묶어 관리할 수 있다.
폴더 안에 폴더를 생성하듯이, 쓰레드 그룹 안에 쓰레드 그룹도 가능하다.
보안상의 이유도 있음.
자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경할 수 있지만,
다른 쓰레드 그룹의 쓰레드를 변경할 수는 없다.
모든 쓰레드는 반드시 쓰레드 그룹에 포함되어 있어야 한다.
쓰레드 그룹을 지정하는 생성자를 사용하지 않으면,
기본적으로 자신을 생성한 쓰레드와 같은 쓰레드 그룹에 속하게 된다.
자바 어플리케이션이 실행되면, JVM은 main과 system이라는 쓰레드 그룹을 만들고 JVM운영에 필요한 쓰레드들을 생성해서 이 쓰레드 그룹에 포함시킨다.
예시 main 메서드를 수행하는 main이라는 이름의 쓰레드는 main쓰레드 그룹에 속하고,
가비지 컬렉션을 수행하는 Finalizer쓰레드는 system쓰레드 그룹에 속한다
우리가 생성하는 모든 쓰레드 그룹은 main 쓰레드 그룹의 하위 쓰레드 그룹이 되며,
쓰레드 그룹을 지정하지 않고 생성한 쓰레드는 자동적으로 main쓰레드 그룹에 속하게 된다.
데몬 쓰레드
다른 일반 쓰레드의 작업을 돕는 보조적인 역할 수행
일반 쓰레드의 보조 역할이라 일반 쓰레드가 없인 데몬 쓰레드의 의미가 없기 때문에,
일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 자동 종료된다.
나머지는 일반 쓰레드와 다르지 않다
예시로는 가비지 컬렉터, 워드프로세서의 자동저장, 화면자동갱신 등이 있다.
무한루프와 조건문을 이용해 실행 후 대기하고 있다가,
특정 조건이 만족되면 작업을 수행하고 다시 대기하도록 작성한다.
일반 쓰레드의 작성방법과 실행방법이 같으며,
다만 쓰레드를 생성한 다음 실행하기 전에 setDaemon(true)를 호출하기만 하면 된다void setDaemon(boolean on) // 쓰레드를 데몬 쓰레드로 또는 사용자 쓰레드로 변경한다. // 매개변수 on의 값을 true로 지정하면 데몬 쓰레드가 된다.
데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 된다.
setDaemon()은 반드시 start가 실행되기 전에 실행해야 한다. 그렇지 않으면 IllegalThreadStateException 발생
9. 쓰레드의 실행 제어
쓰레드 프로그래밍이 어려운 이유는 동기화(synchronization)와 스케줄링(scheduling) 때문이다.
메서드 설명
static sleep(long millis) 지정된 시간(천분의 일초 단위)동안 쓰레드 일시 정지. 지정한 시간이 지나고 나면, 자동적으로 다시 실행대기상태가 된다
static sleep(long millis, int nanos)
join() 지정된 시간동안 쓰레드가 실행되도록 한다.지정된 시간이 지나거나 작업이 종료되면 join()을 호출한 쓰레드로 다시 돌아와 실행을 계속한다.
join(long millis)
join(long millis, int nanos)
interrupt() sleep이나 join에 의해 일시정지상태인 쓰레드를 깨워서 실행대기상태로 만든다.
해당 쓰레드에서는 interruptedException이 발생함으로써 일시정지상태를 벗어나게 한다.
stop() 쓰레드 즉시 종료
suspend() 쓰레드 일시정지. resume()호출 시 다시 실행대기상대가 된다.
resume() suspend에 의해 일시정지상태에 있는 쓰레드를 실행대기상태로 만듬
static yield() 실행 중에 자신에게 주어진 실행시간을 다른 쓰레드에게 양보(yield)하고 자신은 실행대기상태가 된다.
참고 resume, stop, suspend는 쓰레드를 교착상태로 만들기 쉽기 때문에 deprecated되었다.
상태 설명
NEW 쓰레드가 생성되고 아직 start()가 호출되지 않은 상태
RUNNABLE 실행 중 또는 실행 가능한 상태
BLOCKED 동기화블럭에 의해 일시정지된 상태(lock이 풀릴 때까지 기다리는 상태)
WAITING, TIMED_WAITING 쓰레드의 작업이 종료되지는 않았지만 실행가능하지 않은 일시정지 상태, TIMED_WAITING은 일시정지시간이 지정된 경우
TERMINATED 쓰레드의 작업이 종료된 상태
sleep에 의해 일시정지 상태가 된 쓰레드는 지정된 시간이 다 되거나 interrupt가 호출되면(interruptedException이) 잠에서 깨어나 실행대기 상태가 된다.
그래서 sleep을 호출할 때는 항상 try-catch문으로 예외를 처리해줘야 한다.
매번 예외처리를 해주는 것이 번거롭기 때문에, try-catch문을 포함하는 새로운 메서드를 만들어서 사용하기도 한다.void delay(long millis){ try{ Thread.sleep(millis); } catch(interruptedException e){} }
예제class ThreadEx12{ public static void main(String args[]){ ThreadEx12_1 th1 = new ThreadEx12_1(); ThreadEx12_2 th2 = new ThreadEx12_2(); th1.start(); th2.start(); try{ th1.sleep(2000); }catch(interruptedException e){} sysout("main 종료"); } } class ThreadEx12_1 extends Thread{ public void run() { sysout("th1 종료") } } class ThreadEx12_2 extends Thread{ public void run() { sysout("th2 종료") } } // 결과 th1 종료 th2 종료 main 종료
try{ th1.sleep(2000); }catch(interruptedException e){}
위 코드를 생각해보면 결과가 뜻밖이다.
th1과 th2에 start를 호출하자마자 th1.sleep을 호출하여 2초동안 멈췄는데 th1이 먼저 종료되었다.
sleep이 항상 현재 실행 중인 쓰레드에 대해 작동하기 때문에 th1.sleep처럼 호출했어도,실제 영향을 받는 것은 main 메서드를 실행하는 main 쓰레드이다.
그래서 sleep은 static으로 선언되어 있으며 참조변수를 이용해 호출하기 보다는,
Thread.sleep(2000);과 같이 해야 한다.
참고 yield()가 static으로 선언되어 있는 것도 sleep과 같은 이유이다.
10. 쓰레드의 동기화
한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭 못하게 막는 것
멀티쓰레드 프로세스의 경우, 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 준다.
만약 쓰레드1이 작업하던 중 쓰레드2에게 제어권이 넘어갔을 때,
쓰레드1이 작업하던 공유데이터를 쓰레드2가 임의로 변경했다면,
다시 쓰레드1이 제어권을 받아서 나머지 작업을 마쳤을 때 원래 의도와 다른 결과를 얻을 수 있다.
이를 방지하기 위해 한 쓰레드가 특정 작업을 마치기 전까지 다른 쓰레드에 방해받지 않게 하는 것이 필요하다.
그래서 도입된 개념이 임계 영역(critical section)과 잠금(락, lock)이다.
공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정하고,
공유 데이터(객체)가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 한다.
그리고 해당 쓰레드가 임계 영역 내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만,
다른 쓰레드가 반납된 lock을 획득하여 임계 영역 코드를 수행할 수 있게 된다.10 - 1, synchronized를 이용한 동기화
1. 메서드 전체를 임계 영역으로 지정
public synchronized void calcSum(){ ~~~ } 예시 public synchronized void withdraw(int money) { if(balance >= money){ balance -= money; } }
2. 특정 영역을 임계 영역으로 지정synchronized(객체의 참조변수){ ~~~ } 예시 public void withdraw(int money) { synchronized(this){ if(balance >= money){ balance -= money; } } }
두 방법 모두 lock의 획득과 반납이 자동으로 됨. 영역 설정만 해주면 됨
임계 영역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 가능하면 메서드 전체에 락을 거는 것보다,
synchronized블럭으로 임계 영역을 최소화해야 한다.
주의점은 synchronized블럭 안의 값을 보호하는 것이 목적이라면, 해당 값을 참조하는 변수를 private로 지정해야 한다.
만약 private가 아니라면, 아무리 동기화를 해도 값의 변경을 막을 방법이 없다.
wait(), notify()
synchronized로 동기화해서 공유데이터를 보호하는 건 좋은데,
특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것도 중요하다.
만일 계좌에 출금할 돈이 부족해서 한 쓰레드가 락을 보유한 채로 돈이 입금될 때까지 오랜 시간을 보낸다면,
다른 쓰레드들은 모두 해당 객체의 락을 기다리느라 다른 작업들도 원활히 진행되지 않을 것이다.
동기화된 임계 영역의 코드를 수행하다 작업을 더 이상 진행할 상황이 아니면,
일단 wait을 호출하여 쓰레드가 락을 반납하고 기다리게 한다.
나중에 작업을 진행할 수 있는 상황이 되면 notify를 호출해서, 작업을 중단했던 쓰레드가 다시 락을 얻게 된다.
단순히 쓰레드를 정지시키고 다시 실행하는 개념과 달리,
락을 반납하고 다시 얻는 개념이라서 suspend와 resume이랑은 다르다
wait이 호출되면, 실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다린다.
notify가 호출되면, 해당 객체의 대기실에 있던 모든 쓰레드 중, 임의의 쓰레드만 통지를 받는다.
notifyAll()은 기다리고 있는 모든 쓰레드에게 통보를 하지만,
그래도 lock을 얻을 수 있는 것은 하나의 쓰레드일 뿐이고,
나머지 쓰레드는 통보를 받긴 했지만,
lock을 얻지 못하면 다시 lock을 기다리는 신세가 된다.
wait과 notify는 특정 객체에 대한 것이므로, Object클래스에 정의되어있다.
wait은 notify 또는 notifyAll이 호출될 때까지 기다리지만, 매개변수가 있는 wait은 지정된 시간동안만 기다린다.
즉, 지정된 시간이 지난 후에 자동적으로 notify가 호출되는 것과 같다
그리고 waiting pool은 객체마다 존재하는 것이므로 notifyAll이 호출된다고 해서,
모든 객체의 waiting pool에 있는 쓰레드가 깨워지는 것은 아니다.
notifyAll이 호출된 객체의 waiting pool에 대기 중인 쓰레드만 해당된다.
예시
식당에서 음식을 만들어 테이블에 추가하는 요리사와, 테이블의 음식을 소비하는 손님을 쓰레드로 구현한다.
요리사, 손님 2명, 테이블이 존재하는 상황이다.
이 상황에서 2가지 예외가 발생한다.
1. 요리사가 테이블에 음식을 놓는 도중에 손님쓰레드가 음식을 가져가려 함.
2. 손님이 마지막 남은 음식을 가져가는 도중에, 다른 손님쓰레드가 먼저 음식을 낚아채버려서 있지도 않은 음식을 테이블에서 제거하려 함.
여러 쓰레드가 테이블을 공유하는데도 동기화를 하지 않았기 때문이다.
예시2
공유하는 객체인 테이블의 add()와 remove()를 동기화했다.
앞의 예외는 없어졌지만 여전히 오류가 난다
실행 결과
CUST1 failed to eat.
CUST2 is waiting.
CUST2 is waiting.
CUST2 is waiting.
~~~
음식이 테이블에 없으면 failed to eat을 출력하고,
테이블이 음식이 하나도 없으면, 0.5초마다 음식이 추가되었는지 확인하면서 기다리도록 작성했다.
왜 오류가 날까?
>> 손님쓰레드가 테이블 객체의 lock을 쥐고 기다리기 때문이다.
요리사쓰레드가 음식을 추가하려고 해도, 테이블 객체의 lock을 얻을 수 없어서 불가능하다.
이럴 때 사용하는 것이 wait, notify다.
손님쓰레드가 lock을 쥐고 기다리는 게 아니라, wait으로 lock을 풀고 기다리다가 음식이 추가되면,
notify로 통보받고 다시 lock을 얻어서 나머지 작업을 진행하게 할 수 있다.
예시3
wait과 notify를 추가하고, 테이블에 음식이 없을 때 뿐 아니라, 원하는 음식이 없을 때도 손님이 기다리게 바꾸었다.
문제 상황
손님1이 원하는 없어서 음식이 기다리고 있는데,
테이블에 음식이 가득차서, 요리사도 테이블에 음식을 더 추가할 수 없어서 기다리고 있다.
손님2가 음식을 먹어서 테이블이 비었을 때 문제가 생긴다.
테이블이 비어서 다음 작업을 위해 notify가 호출되었을 때,
waiting pool에 요리사쓰레드와 손님1쓰레드가 같이 기다린다는 것이다.
이러면 notify가 호출될 때, 요리사와 손님 중 누가 통지받을 지 알 수 없다.
notify는 그저 waiting pool에 대기 중인 임의의 쓰레드에 통지할 뿐,
운 좋게 요리사가 선택받으면 다행인데,
손님이 통지받으면 lock을 얻어도 여전히 원하는 음식이 없어 waiting pool에 들어가게 된다.기아 현상과 경쟁 상태
운이 아주 없으면 요리사 쓰레드는 계속 통지를 못받고 오래 기다리게 되는데 이것이 기아현상(starvation)
이것을 막으려면 notifyAll을 사용해야 한다.
일단 모든 쓰레드에 통지를 하면, 손님이 다시 waiting pool에 들어가더라도,
요리사 쓰레드가 결국 lock을 얻어서 작업을 진행할 수 있기 때문이다.
notifyAll로 요리사의 기아현상은 막았지만,
손님까지 통지를 받아서 불필요하게 요리사와 lock을 얻기 위해 경쟁하게 된다.
>> 경쟁상태
경쟁 상태를 개선하기 위해 요리사와 손님쓰레드를 구별해서 통지할 필요가 있다.
Lock과 Condition을 이용하면 선별적 통지가 가능
10 - 2, Lock과 Condition을 이용한 동기화
synchronized로 동기화하면 자동으로 lock이 잠기고 풀려서 편하지만,
가끔 같은 메서드 내에서만 lock을 걸 수 있다는 제약이 불편하기도 하다.
그럴 때 lock클래스 이용
jdk1.5부터 추가
ReentrantLock 재진입이 가능한 lock, 가장 일반적인 배타 lock,
ReentrantReadWriteLock 읽기에는 공유적이고, 쓰기에는 배타적인 lock,
StampedLock ReentrantReadWriteLock에 낙관적인 lock 기능 추가 << jdk1.8부터 추가, 다른 lock과 달리 Lock인터페이스를 구현하지 않음
ReentrantReadWriteLock
읽기 lock이 걸려 있으면, 다른 쓰레드가 읽기 lock을 중복해서 걸고 읽기를 수행할 수 있다.
그러나 읽기 lock이 걸린 상태에서 쓰기 lock을 거는 것은 허용 X, 반대도 마찬가지
읽기를 할 때는 읽기 lock을 걸고, 쓰기를 할 때는 쓰기 lock을 걸지만,
lock을 거는 방법 자체는 읽기나 쓰기나 똑같다
StampedLock
lock을 걸거나 해지할 때 스탬프(long타입 정수값)를 사용
읽기lock, 쓰기lock, 낙관적 읽기lock(optimistic reading lock)이 추가
읽기lock이 걸려있으면, 쓰기lock을 얻기 위해 읽기lock이 풀릴 때까지 기다려야 하는데,
낙관적 읽기 lock은 쓰기lock에 의해 바로 풀린다.
그래서 낙관적 읽기에 실패하면 읽기 lock을 얻어서 다시 읽어와야 한다.
무조건 읽기 lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 거는 것이다.
자동적으로 lock의 잠금과 해제가 관리되는 synchronized블럭과 달리,
ReentrantLock같은 lock클래스들은 수동으로 잠그고 해제해야 한다
메서드를 이용하면 간단하기 때문에 잠그고 나서 푸는 것을 잊어버리지만 말자void lock() // lock을 잠금 void unlock() // lock을 해지 boolean isLocked() // lock이 잠겼는지 확인
synchronized(lock){ //임계영역 } >> lock.lock(); // 임계 영역 lock.unlock();
임계 영역 내에서 예외가 발생하거나 return문으로 빠져 나가게 되면 lock이 풀리지 않을 수 있으므로,
unlock은 try-finally문으로 감싸는 것이 일반적이다.lock.lock(); // ReentrantLock lock = new ReentrantLock(); 이라고 가정 try { // 임계 영역 } finally { lock.unlock(); }
tryLock()이라는 것도 있다.
lock()과 달리 다른 쓰레드에 의해 lock이 걸려 있으면, lock을 얻으려고 기다리지 않거나, 지정된 시간만큼만 기다린다.
lock을 얻으면 true 반환, 못얻으면 false 반환
응답성이 중요한 경우, tryLock()을 써서 lock을 얻지 못하면,
다시 작업을 시도할 것인지 포기할 것인지를 사용자가 결정할 수 있게 하는 것이 좋아서 사용
ReentrantLock과 Condition
wait, notify 예제에 손님과 요리사 쓰레드를 구분 못한 걸 해결하기 위해 Condition 사용
손님쓰레드를 위한 Condition, 요리사 쓰레드를 위한 Condition을 만들어서,
각각의 waiting pool에 따로 기다리게 하면 해결
wait, notify 대신 Condition의 await(), signal()을 사용하면 끝
wait() 대신 forCook.await(), forCust.await()을 사용함으로써 대기와 통지의 대상이 명확히 구분됨volatile
멀티 코어 프로세서에서는 코어마다 별도의 캐시를 가지고 있다.
코어는 메모리에서 읽어온 값을 캐시에 저장하고 캐시에서 값을 읽어 작업한다.
다시 같은 값을 읽어올 때는, 먼저 캐시에 있는지 확인하고, 없을 때만 메모리에서 읽어온다
그러다보니 도중에 메모리에 저장된 변수의 값이 변경되었는데도,
캐시에 저장된 값이 갱신되지 않아서 메모리에 저장된 값이 다른 경우가 발생
그러나 변수 앞에 volatile을 붙이면, 코어가 변수의 값을 읽어올 때,
캐시가 아닌 메모리에서 읽어오기 때문에 캐시와 메모리간의 값 불일치가 해결된다.boolean stopped = false; >> volatile boolean stopped = false;
변수에 volatile을 붙이는 대신, synchronized블럭을 사용해도 같은 효과가 난다.
쓰레드가 synchronized블럭으로 들어갈 때와 나올 때,
캐시와 메모리간의 동기화가 이루어지기 때문에 값의 불일치가 해소된다.public void stop(){ stopped = true; } >> public synchronized void stop(){ stopped = true; }
volatile로 long과 double을 원자화
JVM은 데이터를 4byte단위로 처리하기 때문에, int, int보다 작은 타입들은 한 번에 읽거나 쓰는 것이 가능하다.
>> 단 하나의 명령어로 읽거나 쓰기가 가능하다
크기가 8byte인 long과 double타입의 변수는 하나의 명령어로 값을 읽거나 쓸 수 없기 때문에,
변수의 값을 읽는 과정에 다른 쓰레드가 끼어들 여지가 있다.
이를 방지하기 위해 synchronized블럭으로 감쌀 수도 있지만,
변수 선언할 때 volatile를 붙이면 간단하다
volatile은 해당 변수에 대한 읽거나 쓰기가 원자화된다. 더 이상 작업을 나눌 수 없게 한다는 의미
synchronized블럭도 일종의 원자화다
여러 문장을 원자화함으로써 쓰레드의 동기화를 구현한 것
volatile은 변수의 읽거나 쓰기를 원자화할 뿐, 동기화하는 것은 아니라는 것에 주의
동기화가 필요할 때 synchronized블럭 대신 volatile을 쓸 수 없다
예시volatile long balance; // 인스턴스 변수 원자화 synchronized int getBalance() { // balance 값 반환 return balance; } synchronized void withdraw(int money) { // balance 값 변경 if(balance >= money){ balance -= money; } }
인스턴스 변수 balance를 원자화했으니까 값을 반환하는 메서드 getBalance()를 동기화할 필요가 없다고 생각할 수 있다.
그러나 동기화하지 않으면 withdraw()가 호출됐을 때,
객체에 lock을 걸고 작업 중인데도 getBalance()가 호출되는 것이 가능해진다.
출금이 진행 중일 때는 기다렸다가, 출금이 끝난 후에 잔고를 조회하도록 하려면 동기화를 해줘야 한다11. fork & join 프레임웍
10년 전까지만 해도 CPU의 속도는 매년 약 2배씩 빠르게 향상되어 왔다.
하지만 이제 그 한계에 도달해서 속도보다는 코어의 개수를 늘려서 CPU의 성능을 향상시키는 방향으로 발전하고 있다.
이러한 하드웨어의 변화에 발맞춰 프로그래밍도 멀티 코어를 잘 활용할 수 있는 멀티 쓰레드 프로그래밍이 중요해졌다.
jdk1.7부터 fork & join 프레임웍이 추가되었고,
하나의 작업을 작은 단위로 나눠 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어준다.
먼저 수행할 작업에 따라 RecursiveAction과 RecursiveTask 두 클래스 중 하나를 상속받아 구현해야 한다
RecursiveAction 반환값이 없는 작업 구현 때 사용
RecursiveTask 반환값이 있는 작업 구현 때 사용
두 클래스 모두 compute()라는 추상 메서드를 가지고 있는데, 우리는 상속을 통해 이 추상메서드를 구현하기만 하면 된다.public abstract class RecursiveAction extends ForkJoinTask<Void>{ ~~~ protected abstract void compute(); // 상속을 통해 이 메서드 구현 ~~~ } public abstract class RecursiveTask<V> extends ForkJoinTask<V>{ ~~~ V result; protected abstract V compute(); // 상속을 통해 이 메서드 구현 ~~~ }
예를 들어 1부터 n까지 합을 계산한 결과를 돌려주는 작업의 구현은 다음과 같다class SumTask extends RecursiveTask<Long>{ long from, to; SumTask(long from, long to){ this.from = from; this.to = to; } public Long compute(){ // 처리할 작업 내용 } }
그 다음 쓰레드풀과 수행할 작업을 생성하고, invoke()로 작업을 시작한다.
쓰레드를 시작할 때 run이 아니라 start를 호출하는 것처럼,
fork&join프레임웍으로 수행할 작업도 compute가 아닌 invoke로 시작한다.ForkJoinPool pool = new ForkJoinPool(); // 쓰레드풀 생성 SumTask task = new SumTask(from, to); // 수행할 작업 생성 Long result = pool.invoke(task); // invoke를 호출해서 작업 시작한다
ForkJoinPool은 fork&join프레임웍에서 제공하는 쓰레드 풀로,
지정된 수의 쓰레드를 생성해서 미리 만들어 놓고 반복해서 재사용할 수 있게 한다.
그리고 쓰레드를 반복해서 생성하지 않아도 된다는 장점과,
너무 많은 쓰레드가 생성되어 성능이 저하되는 것을 막아준다는 장점이 있다.참고 쓰레드 풀은 기본적으로 코어의 개수와 동일한 개수의 쓰레드를 생성한다.
compute() 구현
수행할 작업 외에도 작업을 어떻게 나눌 것인가에 대해서도 알려줘야 한다.
비동기 메서드
일반적으로 메서드는 호출하고 반환값을 받지만,
비동기 메서드는 호출하고 반환될 때까지 안기다린다.
내부적으로는 다른 쓰레드에게 작업을 수행하도록 지시만 하고, 결과를 기다리지 않게 구성되어 있음.
from은 작업통에 나눠 던지고 안기다리는 거고, 비동기 메서드다.
join은 나눠 던진 것들 다 주워담는거라고 함
예시public Long compute(){ long size = to - from + 1; // from <= i <= to if(size <= 5) return sum(); long half = (from + to)/2; // 범위를 반으로 나눠서 두 개의 작업을 생성 SumTask leftSum = new SumTask(from, half); SumTask rightSum = new SumTask(half, right); leftSum.fork(); //작업(leftSum)을 작업 큐에 넣는다 return rightSum.compute() + leftSum.join(); }
참고 보면 알겠지만 compute의 구조는 일반적인 재귀호출 메서드와 동일하다.
그런데 실행 결과를 일반적인 for문과 비교해봤을 때, 훨씬 더 오래걸린다.
작업을 나누고 다시 합치는데 시간이 걸리기 때문이다.
반드시 테스트해보고 이득이 있을 때만, 멀티쓰레드로 처리해야 한다.728x90반응형'Java' 카테고리의 다른 글
Java 스트림(Stream) (1) 2024.02.27 Java 람다, 람다식 (3) 2024.02.26 Java 애너테이션 (0) 2024.02.26 Java 열거형(Enum) (0) 2024.02.26 Java 지네릭스(Generics) (0) 2024.02.26