프로그램의 실행이라는 것은 프로그램을 구성하는 코드를 순서대로 CPU에서 하나씩 연산,실행하는 일입니다.
하나의 CPU 코어로 여러 프로그램을 동시에 실행하는 멀티태스킹이라는 기술이 등장.
프로그램 A, B가 짧은 순간에 번갈아가면서 실행을 하게 되어 이것을 반복 동작하여 각 프로그램의 실행 시간을 분할해서 마치 동시에 실행되는 것처럼 하는 기법을 시분할 기법이라고 합니다.
이렇게 하나의 컴퓨터 시스템이 동시에 여러 작업을 수행하는 능력을 멀티태스킹이라 합니다.
운영체제가 스케쥴링을 수행하고 CPU를 최대한 많이 사용하면서 그 안에서 또 작업이 골고루 수행될 수 있게 최적화한다 라고 이해하면 됩니다.
처리하는 것이 여러 개 인 것인데 만약 CPU 코어가 둘 이상이고 프로그램이 A,B,C로 3가지라면 물리적으로 동시에 프로그램을 처리할 수 있습니다.
CPU 코어들이 프로그램 A,B를 실행하다가 잠시 멈추고, 프로그램 C,A를 수행
즉 멀티프로세싱이란 둘 이상의 CPU 코어를 사용해서 여러 작업을 동시에 처리하는 기술입니다 그래서 하나인 CPU 코어를 사용하는 것보다 더 많은 작업을 처리할 수 있습니다.
프로세스 내에서 실행되는 작업의 단위입니다. 한 프로세스 내에서 여러 스레드가 존재할 수 있으며, 이들은 프로세스가 제공하는 동일한 메모리 공간을 공유합니다.
코드 한줄한줄을 실행할 스레드가 필요하기 때문에 반드시 한 프로세스 내에 하나의 스레드가 존재해야 합니다.
동일한 메모리 공간을 공유한다는 의미
하나의 프로세스 안에 있는 코드, 힙, 기타 메모리들을 다 접근해서 갖다 쓸 수 있다는 의미입니다.
그림에서 초록색은 공유, 노란색은 개인
멀티스레드가 필요한 이유
하나의 프로그램도 그 안에서 동시에 여러 작업이 필요합니다.
여러 스레드로 나눠서 각각의 작업들을 병렬로 나눠서 처리할 수가 있습니다.
스케줄링 : CPU에 어떤 프로그램이 얼마만큼 실행될지는 운영체제가 결정하는데 이것을 스케줄링이라 합니다. 시간으로만 작업을 분할하지 않고 CPU를 최대한 활용할 수 있는 다양한 우선순위와 최적화 기법을 사용합니다.
운영체제는 내부에 스케줄링 큐를 가지고 있습니다. 어떤 순서대로 내가 이 스레드들을 번갈아서 실행해야 될지를 관리하는 것입니다.
각각의 스레드는 스케줄링 큐에서 대기하고 있다가 스레드 A를 큐에서 꺼내고 CPU를 통해 연산 수행합니다. 잠시 멈추고 다시 큐에 넣은 다음 스레드 B1을 수행하는 방식으로 번갈아가면서 진행합니다.
CPU가 하나일 경우였지만 이것은 2개 이상으로 진행하는 것입니다.
프로그램 A를 수행하다가 급박하게 다른 프로그램 B를 수행한 뒤 다시 A로 돌아오면 A를 어디까지 개발하고 있었는지 코드의 위치를 찾아야 합니다. 또한 프로그램 수행 중 많은 변수들의 값에 어떤 값이 들어가 있는지 기억이 필요합니다.
A의 개발을 끝내고 B를 수정한다면 전체 시간으로 봤을 때 더 효츌적으로 개발할 수 있습니다.
컴퓨터의 멀티태스킹을 생각해보면 스레드 A와 B가 있다고 가정할 경우 A를 실행 중 잠시 멈추고 B를 실행한 후 그냥 바로 A로 다시 돌아갈 수가 없습니다.
CPU에서 쓰레드를 실행해야 하는데 A의 코드가 어디까지 수행됐는지 위치를 찾아야 하고 계산하던 변수들의 값을 CPU에 다시 불러들여야 합니다. 이것이 컨텍스트 스위칭입니다.
쓰레드 A를 실행하다 B를 실행하면 문맥이 바뀌어 버리는데 이 문맥이 전환된다고 해서 컨텍스트 스위칭입니다.
이 컨텍스트 스위칭 과정에서 이전에 실행 중인 값을 잠깐 메모리 어딘가에 저장을 해놔야 합니다. (CPU 내부에서 쓰는 값들) 이후에 다시 실행하는 시점에 저장하는 값을 CPU에 불러와야 합니다.
멀티스레드는 대부분 효율적이지만 컨텍스트 스위칭 과정이 필요하기 때문에 가끔 항상 효율적이라고는 할 수 없습니다.
CPU 코어가 1개 있는데 스레드를 2개로 만들어서 연산하면 중간중간 컨텍스트 스위칭 비용이 발생합니다. 결과적으로 연산 시간 + 컨텍스트 스위칭 시간이 들기 때문에 스레드 하나로 하는 것이 효율적일 수도 있습니다.
CPU를 100% 활용할 수 있고, 컨텍스트 스위칭 비용도 자주 발생하지 않기 때문에 최적의 상태가 됩니다. 이성적으로는 CPU 코어 수 + 1개 정도로 쓰레드를 맞추면 특정 쓰레드가 잠시 대기할 때 남은 쓰레드를 활용할 수 있습니다.
하지만 개수를 맞추는 경우 큰일이 발생할 수 있습니다.
각각의 쓰레드가 하는 작업은 크게 2가지로 CPU 바운드 작업과 I/O 바운드 작업이 있습니다.
CPU 바운드 작업은 CPU를 최대한 많이 쓰는 것으로 주로 계산을 많이 하거나 데이터를 처리하거나 알고리즘을 실행하거나 등 CPU의 처리 속도가 작업 완료 시간을 결정하는 경우입니다.
I/O는 디스크, 네트워크, 파일 시스템 같은 입출력 작업을 많이 요구하는 작업을 의미합니다. I/O 작업이 완료될 때까지 대기 시간이 많습니다. 파일을 복사, 네트워크 파일 전송 등은 CPU는 거의 사용하지 않고 I/O 작업이 완료될 때까지 대기합니다.
그래서 쓰레드를 5개 이상 만들지 않고 컨텍스트 스위칭 비용이 들지 않게 만들겠다고 할 수 있습니다.
하지만 정작 CPU를 사용하지 않고 들어와서 네트워크로, 데이터베이스로 보내고 결과 올 때까지 기다리고 별로 하는 것 없이 그냥 놀고만 있는 것입니다. 하나의 작업 처리하는데(쓰레드 하나에) CPU 1%를 사용한다면 쓰레드 4개가 있다고 했을 때 CPU 4%만 사용하고 나머지 96%의 CPU를 쓰지 않는 것입니다.
실무에서는 성능 테스트를 통해서 최적의 쓰레드 숫자를 찾는 게 이상적입니다. 쓰레드 숫자만 늘리면 되는데 서버 장비에 문제가 있는 거로 판단하고 더 좋은 장비를 찾는 문제가 발생할 수 있습니다. 사용자 응답이 느려져 우리 서버가 문제가 있는 거라 판단하는 것입니다. (사용자는 여전히 동시에 4명만 받음.)
쓰레드 숫자는 CPU 바운드 작업이 많은가, 아니면 I/O 바운드 작업이 많은가에 따라 다르게 설정이 필요합니다.
CPU 바운드 작업이 많다면 CPU 코어 수 + 1개가 적당하고 (CPU를 거의 100% 사용하는 작업이므로 쓰레드를 CPU 숫자에 최적화) I/O 바운드 작업은 쓰레드가 중간중간 계속 실행되는 게 CPU를 계속 쓰는 것이 아닌 쓰레드가 많이 쉬는 것이기 때문에 CPU 코어 수보다 많은 쓰레드를 기본적으로 생성해야 하고 CPU를 최대한 활용할 수 있는 숫자까지 쓰레드를 생성해주는 것이 좋습니다. (성능 테스트 필요)
쓰레드가 100개라고 해도 100개가 다 동시에 CPU를 사용하는 것은 아님. 요청하고 결과 기다리면서 CPU 안 쓰고 대기하고 쉬는 경우가 많습니다. 그래서 쓰레드를 많이 만들어야 합니다.
깃허브를 통해 작성
스레드 간의 실행 순서는 얼마든지 달라질 수 있다.
CPU 코어가 2개여서 물리적으로 정말 동시에 실행될 수도 있고 하나의 CPU 코어에 시간을 나누어 실행될 수도 있습니다. 그리고 한 스레드가 얼마나 오랜기간 실행되는지도 보장하지 않습니다.
한 스레드가 먼저 다 수행된 다음에 다른 스레드가 수행될 수도 있고, 둘이 완전히 번갈아 가면서 수행되는 경우도 있습니다.
스레드는 순서와 실행 기간을 모두 보장 X -> 이것이 멀티스레드
start()
가 아닌 run()
을 직접 호출한다면 메인 스레드가 실행 하던 중 run()
을 Thread-0이 아닌 main 스레드가 실행하게 됩니다. main 스레드가 run()
메서드를 실행했기 때문에 main 스레드가 사용하는 스택 위에 run()
스택 프레임이 올라갑니다.
그래서 main 스레드가 아닌 별도의 스레드에서 재정의한 run 메서드를 실행하려면 반드시 start()
로 호출해야 합니다.
지금까지 프로그램의 주요 작업을 수행하는 사용자 스레드 를 사용했고 작업이 완료될 때까지 실행됩니다. 데몬 스레드는 백그라운드에서 보조적인 작업을 수행하고 모든 사용자 스레드가 종료되면 데몬 쓰레드는 자동으로 종료됩니다.
스레드 만들 때 옵션을 줄 수 있습니다. JVM은 데몬 스레드의 실행 완료를 기다리지 않고 종료됩니다. 데몬 스레드가 아닌 모든 스레드가 종료되면 자바 프로그램도 종료됩니다.
즉 사용자에게 직접적으로 보이지 않으면서 시스템의 백그라운드에서 작업을 수행하는 것을 데몬스레드, 프로세스 라고 합니다.
ex) 사용하지 않는 파일이나 메모리 정리하는 작업
자바는 사용자 스레드가 작업을 다 완료하면 종료됩니다. (메인 메서드를 다 수행해야 종료되는 것이 아님)
데몬 스레드 여부는 스타트 실행 전에 결정해야 합니다. setDaemon(true or false)
run 메서드
메서드에서 throws 해서 예외를 던지는 것이 불가능합니다. 무조건 try로 잡아야 합니다.
public interface Runnable{
void run();
}
run이라는 메서드 딱 하나만 있고 우리는 이것을 구현해야 합니다.
기존과 동일하지만 스레드와 스레드가 실행할 작업이 서로 분리되어 있습니다.
스레드 객체를 생성할 때 실행할 작업을 생성자로 전달하면 됩니다.
스레드를 사용할 때는 스레드를 상속받는 방법보다 Runnable
인터페이스를 구현하는 방식을 사용하는 것이 효과적
Thread 클래스 상속 방식
장점 : 간단한 구현 - Thread
클래스를 상속받아 run() 메서드만 재정의하면 끝.
단점
Runnable 인터페이스 구현 방식
장점
단점 : 코드가 약간 복잡해짐. (Runnable 객체 생성 + Thread에 전달하는 과정)
코드를 누가 실행하는지를 빠르게 판단이 가능해야 개발 시 편해집니다. 출력했을 때 이름을 확인하는 게 되게 중요합니다. Thread.currentThread().getName()
으로 하기에는 너무 길고 어떤 게 몇 초 동안 실행될지 실행 시간도 중요하여 이런 정보들을 출력하는 로그가 필요합니다.
log("hello thread")
log(123)
/*
15:39:02.000 [ main] hello thread
15:39:02.002 [ main] 123
*/
이렇게만 해주면 main 스레드가 출력했고 언제 실행 했는지도 알려줍니다. 실무에서는 로그라는 라이브러리를 사용합니다.
System.out.println() 대신에 스레드 이름과 실행 시간을 알려주는 MyLogger
를 사용.
public class ManyThreadMainV1 {
public static void main(String[] args) {
log("main() start");
HelloRunnable runnable = new HelloRunnable();
Thread thread1 = new Thread(runnable);
thread1.start();
Thread thread2 = new Thread(runnable);
thread2.start();
Thread thread3 = new Thread(runnable);
thread3.start();
log("main() end");
}
}
스레드에 같은 인스턴스로 작업을 던져주는데 start() 하면 Thread-0, Thread-1, Thread-2가 생성되고 HelloRunnable 인스턴스에 있는 run()
에서드를 실행합니다. (3개의 스레드 다)
3개의 스레드가 아닌 for문으로 100개의 스레드를 돌린다면 스레드의 숫자를 유동적으로 변경하며넛 실행할 수 있습니다.
즉 작업을 내가 병렬로 하는데 스레드를 경우에 따라 개수를 정해서 실행할 수 있습니다.
스레드 실행 순서는 운영체제 맘대로이므로 보장되지 않음.
중첩 클래스를 사용하면 Runnable
을 더 편리하게 만들 수 있습니다
클래스 하나에 Runnable을 만드는 것이 아닌
즉 다른 클래스로 만들어도 되는데 여러 군데서 사용하지 않고 하나의 클래스에서만 사용할 것 같다면 중첩 클래스를 사용하면 됩니다
특정 클래스 안에서만 사용되는 경우에는 정적 중첩 클래스로 가지고 오면 됩니다.
public class InnerRunnableMainV1 {
public static void main(String[] args) {
log("main() start");
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
log("main() end");
}
// 중첩 클래스 - 정적 중첩 클래스
static class MyRunnable implements Runnable {
@Override
public void run() {
log(Thread.currentThread().getName() + ": run()");
log("run()");
}
}
}
/**
* 중첩 클래스 사용
* 익명 클래스
*/
public class InnerRunnableMainV2 {
public static void main(String[] args) {
log("main() start");
// Runnable 인터페이스를 바로 구현해버리기
Runnable runnable = new Runnable() {
@Override
public void run() {
log("run() start");
}
};
Thread thread = new Thread(runnable);
thread.start();
log("main() end");
}
}
특정 메서드 안에서만 간단히 정의하고 사용하고 싶다면 익명 클래스 사용.
더 간단하게 하고 싶다면 안에 넣어버리기 -> 단축키 Option + Command + N
// 더 간단하게 하고 싶다면 안에 넣어버리기
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
log("run() start");
}
});
thread.start();
또는 람다로 더 간단히 가능
Thread thread = new Thread(() -> log("run()"));
CounterThread라는 스레드 클래스 생성 후 1~5초 간격으로 출력.
log()
사용하고 main()
메서드에서 CounterThread 스레드 클래스 만들고 실행.
public class Test1Main {
public static void main(String[] args) {
CounterThread counterThread = new CounterThread();
counterThread.start();
}
static class CounterThread extends Thread {
@Override
public void run() {
for(int i = 1; i <= 5; i++) {
log("value: " + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
}
실행 결과
11:24:22.111 [ Thread-0] value: 0
11:24:23.118 [ Thread-0] value: 1
11:24:24.124 [ Thread-0] value: 2
11:24:25.126 [ Thread-0] value: 3
11:24:26.132 [ Thread-0] value: 4
public class Test2Main {
public static void main(String[] args) {
Thread thread = new Thread(new CounterRunnable(), "counter");
thread.start();
}
static class CounterRunnable implements Runnable {
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
log("value: " + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
}
결과는 같음
public class Test3Main {
public static void main(String[] args) {
Runnable runnable = new Runnable() {
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
log("value: " + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
};
Thread thread = new Thread(runnable, "counter");
thread.start();
}
}
public class Test4Main {
public static void main(String[] args) {
PrintWorker a = new PrintWorker("A", 1000);
PrintWorker b = new PrintWorker("B", 2000);
Thread threadA = new Thread(a, "Thread-A");
Thread threadB = new Thread(b, "Thread-B");
threadA.start();
threadB.start();
}
/**
* 하나의 Runnable에서 2개의 스레드가 다르게 수행 가능
* 객체를 생성할 때 private 으로 어떤 내용을 출력하고 얼마나 쉴 것인지 처럼 넣어줘서 하나의 클래스로 해결 가능하다
*/
static class PrintWorker implements Runnable {
private String content;
private int sleepMs;
public PrintWorker(String content, int sleepMs) {
this.content = content;
this.sleepMs = sleepMs;
}
@Override
public void run() {
while (true) {
log(content);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
}
스레드를 생성할 때는 실행할 Runnable 인터페이스의 구현체와, 스레드의 이름을 전달할 수 있다.
Thread myThread = new Thread(new HelloRunnable(), "myThread");
threadId()
: 스레드의 고유 식별자를 반환하는 메서드
getName()
: 스레드의 이름을 반환하는 메서드
getPriority()
: 스레드의 우선순위를 반환하는 메서드. 1~10까지 있고 기본값은 5. 높을수록 조금 더 많이 실행됨. (OS, JVM에 따라 달라질 수 있음) 우선순위는 스레드 스케줄러가 어떤 스레드를 우선 실행할지 결정하는 데 사용됨.
getThreadGroup()
: 스레드가 속한 스레드 그룹을 반환하는 메서드. 스레드를 그룹화하여 관리할 수 있는 기능을 제공하고 기본적으로 모든 스레드느 부모 스레드와 동일한 스레드 그룹에 속함.
부모 스레드 : 새로운 스레드를 생성하는 스레드를 의미. 메인 스레드를 제외하고는 다른 스레드들은 다 어떤 스레드에 의해 생성됩니다.
getState()
: 스레드의 현재 상태를 반환.
NEW
: 스레드가 아직 시작 XRUNNABLE
: 스레드가 실행 중이거나 실행될 준비가 된 상태.BLOCKED
: 스레드가 동기화 락을 기다리는 상태. (스레드가 대기 상태)WAITING
: 스레드가 다른 스레드의 특정 작업이 완료되기를 기다리는 상태.TIMED_WAITING
: 일정 시간 동안 기다리는 상태.TERMINATED
: 스레드가 실행을 마친 상태.
스레드가 생성되고 실행하다가 바로 종료될 수도 있고 차단,대기,시간 대기 상태로 왔다갔다 할 수 있습니다.
자바에는 일시 중지 상태들 이라는 용어가 없습니다. Blocked, Waiting, Timed Waiting 들은 그냥 하나의 일시 중지 상태들(Suspended States)로 용어가 따로 존재하지는 않습니다.
Runnable은 실행 가능한 상태인데 모든 스레드가 동시에 실행되는 것은 아닙니다. Runnable 상태가 되면 OS의 스케줄러에 들어가게 됩니다. OS의 스케줄러가 각 스레드의 CPU 시간을 할당해서 실행하기 때문에 Runnable 상태에 있는 스레드는 스케줄러의 실행 대기열에 포함되어 있다가 차례대로 CPU에서 실행됩니다.
우리가 봤을 때는 스레드 둘 다 실행 중인 것처럼 보이는데 CPU에서 보면 하나기 때문에 멀티태스킹을 해야 해서 물리적으로 본다면 그 중에 하나가 실행되고 있는 것입니다. 그래서 실행 가능한 상태와 스케줄러에서 기다리는 상태가 있는 것입니다. 이 둘다 RUNNABLE 상태라고 합니다. (구분 무의미 -> 너무 빠르게 교체됨.)
예를 들면 CPU 하나에서 2개의 스레드를 실행 중일 때 A 스레드를 잠깐 실행하다가 B 스레드 실행하는 식으로 번갈아가면서 수행을 해야 합니다. 스케줄러에 들어있는 게 2개가 있으면 하나가 나와서 CPU에서 실행되고 다시 스케줄러에 들어가고 다른 하나가 CPU에서 실행되고 다시 스케줄러에 들어가는 식으로 번갈아가면서 되는 것입니다.
일시 중지 상태는 CPU의 실행 스케줄러에 들어가지 않습니다.
wait()
, join()
sleep(long millis)
, wait(long millis)
, join(long millis)
그림에 맞게 코드 작성
자바에서 메서드를 재정의할 때, 재정의 메서드가 지켜야할 예외와 관련된 규칙이 있습니다.
void run()
만 있습니다. check 예외를 던지지 않기 때문에 자식도 check 예외를 던지지 못합니다.sleep()
이 아닌 join()
을 통해 WAITING 수행.
스레드가 다른 스레드의 특정 작업이 완료되기를 무기한 기다리는 상태입니다.
sleep()
을 사용했을 때 main 스레드는 스레드1,2를 실행하고 바로 자신의 다음 코드를 실행합니다. 핵심은 main 스레드가 스레드1,2가 끝날 때까지 기다리지 않는다는 점입니다. main 스레드는 단지 start() 를 호출해서 다른 스레드를 실행만 하고 바로 자신의 다음 코드를 실행합니다
스레드1,2가 다 종료된 다음에 main 스레드를 가장 마지막에 종료하여 두 스레드의 결과를 받아서 처리한 후 main 스레드를 종료하는 방법입니다.
예시로 1~100까지 더하는 작업을 하나로 하는 것이 아닌 2개의 스레드로 나눠서 1~50까지, 51~100까지 더하는 작업을 진행하여 이 두 작업 결과를 메인 스레드가 받을 수 있습니다.
main 스레드는 2개의 스레드에 작업을 지시했는데 스레드에서 계산을 끝내기도 전에 계산 결과를 조회해서 로그에서 값이 0이 나옵니다. (스레드 2개의 계산 완료하는데 2초 정도의 시간이 걸리는데 그냥 바로 main 메서드를 실행했기 때문에 값이 나오지 않음.)
14:04:18.093 [ main] main Start
14:04:18.095 [ thread-1] 스레드 작업 시작
14:04:18.095 [ thread-2] 스레드 작업 시작
14:04:18.097 [ main] task1.result = 0
14:04:18.097 [ main] task2.result = 0
14:04:18.098 [ main] sumAll = 0
14:04:18.098 [ main] main End
14:04:20.102 [ thread-2] 스레드 작업 완료 = 3775
14:04:20.102 [ thread-1] 스레드 작업 완료 = 1275
main 스레드는 이미 자신의 코드를 모두 실행하고 종료가 된 상태이기 때문에 main 스레드가 스레드1,2의 계산이 끝날 때까지 기다려야 합니다.
sleep()
사용
// 정확한 타이밍을 맞춰서 기다리기는 어려움
log("main 스레드 sleep()");
sleep(3000);
log("main이 3초 뒤에 깨어남"); // 다른 스레드 2개의 계산이 2초이므로 3초 지나면 계산이 완료됨.
14:13:59.725 [ main] main Start
14:13:59.727 [ main] main 스레드 sleep()
14:13:59.727 [ thread-1] 스레드 작업 시작
14:13:59.727 [ thread-2] 스레드 작업 시작
14:14:01.750 [ thread-2] 스레드 작업 완료 = 3775
14:14:01.750 [ thread-1] 스레드 작업 완료 = 1275
14:14:02.730 [ main] main이 3초 뒤에 깨어남
14:14:02.732 [ main] task1.result = 1275
14:14:02.732 [ main] task2.result = 3775
14:14:02.733 [ main] sumAll = 5050
14:14:02.733 [ main] main End
두 스레드의 계산이 2초 뒤에 깨어나는 것을 알기 때문에 main 스레드는 3초로 지정하고 main 스레드의 계산 결과를 조회하도록 했습니다.
하지만 sleep()
을 사용하면 무작정 기다리는 식으로 대기 시간에 손해가 있고 두 스레드의 수행 시간이 달라지는 경우에는 정확한 타이밍을 맞추기가 어렵습니다. 그래서 두 스레드의 계산이 끝나고 종료될 때까지 main 스레드가 기다려야 합니다.
while문으로 두 스레드의 상태가 TERMINATED 될 때까지 기다리는 방법이 있지만 이 방법도 번거롭고 또 계속되는 반복문은 CPU 연산을 사용하여 좋지 않습니다.
그래서 Join을 사용하면 깔끔하게 해결이 가능합니다.
thread1.start();
thread2.start();
// 두 스레드가 종료될 때까지 대기
thread1.join(); // 이 스레드가 종료될 때까지 main 스레드는 대기
thread2.join(); // 이 코드 라인이 다음으로 넘어가지 않는다
실행 결과
14:26:07.023 [ thread-1] 스레드 작업 시작
14:26:07.023 [ thread-2] 스레드 작업 시작
14:26:07.023 [ main] join() - main 스레드가 thread1,2 종료까지 대기
14:26:09.037 [ thread-1] 스레드 작업 완료 = 1275
14:26:09.037 [ thread-2] 스레드 작업 완료 = 3775
14:26:09.038 [ main] join() - main 스레드가 thread1,2 종료까지 대기 완료
14:26:09.039 [ main] task1.result = 1275
14:26:09.039 [ main] task2.result = 3775
14:26:09.040 [ main] sumAll = 5050
14:26:09.040 [ main] main End
thread1이 종료되지 않았다면 join으로 대기. 종료되면 main 스레드는 RUNNABLE 상태가 되고 다음 코드로 이동.
thread2이 종료되면 main 스레드는 RUNNABLE 상태가 되고 다음 코드로 이동.
현재 코드는 스레드 두 개가 거의 동시에 종료되기 때문에 thread2의 join()은 대기하지 않고 바로 빠져나옵니다.
이게 WAITING 상태입니다.
단점
다른 스레드가 완료될 때까지 무기한 기다려야 합니다. 만약 다른 스레드의 작업을 일정 시간 동안만 기다리게 하고 싶다면 join(ms)을 써야 합니다.
join(ms)
특정 시간 만큼만 대기. 지정한 시간이 지나면 다시 RUNNABLE이 되고 다음 코드 진행.
대기라는 것은 스레드가 종료되는 동안 현재 스레드인 main을 대기 상태로 만드는 것입니다. 즉 main 스레드가 thread1의 종료를 기다리는 것입니다.
14:37:23.010 [ main] main Start
14:37:23.012 [ main] join(1000) - main 스레드가 thread1 종료까지 1초 대기
14:37:23.012 [ thread-1] 스레드 작업 시작
14:37:24.016 [ main] main 스레드 대기 완료
14:37:24.025 [ main] task1.result = 0
14:37:24.025 [ main] main End
14:37:25.019 [ thread-1] 스레드 작업 완료 = 1275
스레드 작업을 중간에 중단하는 것입니다. 변수 volatile boolean runFlag
를 사용해서
static class MyTask implements Runnable {
volatile boolean flag = true;
@Override
public void run() {
while (flag) {
log("작업 중");
sleep(3000);
}
log("자원 정리");
log("자원 종료");
}
}
해주고 sleep(4000)
주고 하면 작업이 종료가 됩니다. 하지만 4초 후에 종료하는 것이 첫번째 작업 3초 후 1초 뒤에 작업이 종료되는데 작업 중이던 남은 2초(3초 + 3초 - 2초)때문에 중단 지시가 즉각 반응하지 않습니다.
인터럽트를 사용하면 대기 상태에 있는 스레드를 직접 깨워서, 작동하는 RUNNABLE 상태로 만들 수 있습니다.
public static void main(String[] args) {
MyTask task = new MyTask();
Thread thread = new Thread(task, "work");
thread.start();
sleep(4000);
log("작업 중단 지시 thread.interrupt");
thread.interrupt();
log("work 스레드 인터럽트 상태1 = " + thread.isInterrupted());
}
static class MyTask implements Runnable {
/**
* 스레드에 인터럽트가 걸리면 예외가 터집니다
* 대기하다가 갑자기 깨어나서 exception으로 넘어갑니다
* 인터럽트 주는 방법은 thread.interrupt()
*/
@Override
public void run() {
try{
while (true) {
log("작업 중");
Thread.sleep(3000);
}
}catch (InterruptedException e){
// 인터럽트가 걸렸는지 확인
log("work 스레드 인터럽트 상태2 = " + Thread.currentThread().isInterrupted());
log("interrup message = " + e.getMessage());
log("state = " + Thread.currentThread().getState());
}
log("자원 정리");
log("자원 종료");
}
}
실행 결과
14:24:49.116 [ work] 작업 중
14:24:52.122 [ work] 작업 중
14:24:53.106 [ main] 작업 중단 지시 thread.interrupt
14:24:53.118 [ main] work 스레드 인터럽트 상태1 = true
14:24:53.118 [ work] work 스레드 인터럽트 상태2 = false
14:24:53.119 [ work] interrup message = sleep interrupted
14:24:53.119 [ work] state = RUNNABLE
14:24:53.120 [ work] 자원 정리
14:24:53.120 [ work] 자원 종료
인터럽트 상태가 거의 동시에 false -> true가 됩니다. 예외가 터지면 Interrupt 상태가 풀리고 스레드가 깨어나서 인터럽트 상태는 false가 됩니다. (다시 작동되는 상태)
이렇게 하면 훨씬 반응성이 좋아집니다.
while문에서 인터럽트 체크가 되지 않고 Thread.sleep()
에서만 인터럽트가 발생하여 체크하기 때문에 좀 더 빨리 반응을 하도록 하기 위해 while()
에서 인터럽트가 발생하여 체크하도록 설정할 수 있습니다.
인터럽트의 상태를 직접 확인하면, sleep()
과 같은 코드가 없어도 인터럽트 상태를 직접 확인하기 때문에 while문을 빠져나갈 수 있습니다.
먼저 심각한 문제점 하나를 보면
public static void main(String[] args) {
MyTask task = new MyTask();
Thread thread = new Thread(task, "work");
thread.start();
sleep(100); // 0.1초 뒤 바로 작업 중단 지시하면 인터럽트 걸림
log("작업 중단 지시 thread.interrupt");
thread.interrupt();
log("work 스레드 인터럽트 상태1 = " + thread.isInterrupted());
}
static class MyTask implements Runnable {
/**
* 예외가 터져서 작동하게 됐을 때 왜 인터럽트 상태를 자바가 내부적으로 true인 것을 다시 false로 돌리는 이유
* -> true 상태로 있다가 인터럽트 예외가 걸리는 메서드를 만나는 순간 인터럽트 터져서 catch 코드로 갑니다.
* 기대하는 건 자원 정리 중에는 인터럽트가 발생하지 않기를 기대했습니다.
*/
@Override
public void run() {
while(!Thread.currentThread().isInterrupted()) { // 인터럽트 상태만 확인할 뿐 변경을 하지는 않음
log("MyTask 작업 중");
}
log("work 스레드 인터럽트 상태2 = " + Thread.currentThread().isInterrupted());
try{
log("자원 정리");
Thread.sleep(1000); // 인터럽트 예외 발생
log("자원 종료");
} catch (InterruptedException e) {
log("자원 정리 실패 - 자원 정 중 인터럽트 발생");
log("work 스레드 인터럽트 상태 = " + Thread.currentThread().isInterrupted());
}
log("작업 종료");
}
}
실행 결과
15:17:01.377 [ work] MyTask 작업 중
15:17:01.377 [ work] MyTask 작업 중
15:17:01.377 [ main] 작업 중단 지시 thread.interrupt
15:17:01.377 [ work] MyTask 작업 중
15:17:01.380 [ work] work 스레드 인터럽트 상태2 = true
15:17:01.380 [ work] 자원 정리
15:17:01.380 [ work] 자원 정리 실패 - 자원 정리 중 인터럽트 발생
15:17:01.380 [ work] work 스레드 인터럽트 상태 = false
15:17:01.380 [ work] 작업 종료
15:17:01.380 [ main] work 스레드 인터럽트 상태1 = true
work 스레드의 인터럽트 상태가 true를 계속 유지되는 점입니다. 스레드가 특정 작업을 수행하고 있을 때, 그 작업을 중단하고 종료하기 위해 인터럽트를 한 번만 사용하겠다는 것입니다.
sleep()
에서 인터럽트를 받을 경우 인터럽트 예외가 발생하고 스레드의 인터럽트 상태를 자동으로 false로 설정됩니다. 스레드가 정상적으로 종료되었다고 판단할 수 있지만 인터럽트가 발생했음에도 불구하고 자원 정리를 제대로 하지 못하고 종료될 수 있습니다.
우리가 기대하는 것은 while()
문을 탈출하기 위해 딱 한 번만 인터럽트를 사용하는 것이지, 다른 곳에서도 계속해서 인터럽트가 발생하는 것이 아닙니다.
만약 스레드가 여러 번 인터럽트를 받게 되면 스레드가 중단 상태에 여전히 있고 두 번째 인터럽트를 처리하기 위해 추가적인 정리 작업을 하거나, 자원 상태를 다시 확인해야 할 수 있습니다. 자원 정리 중 인터럽트가 발생하면 이미 진행 중인 자원 정리 작업이 중단될 수 있습니다. (자원 정리 X, 누수 발생)
자바에서 인터럽트 예외가 한번 발생하면 스레드의 인터럽트 상태를 다시 정상(false) 으로 돌리는 이유가 이것입니다.
스레드의 인터럽트 상태를 정상으로 돌리지 않으면 이후에도 계속 인터럽트가 발생하게 됩니다.
그래서 while()
같은 곳에서 인터럽트의 상태를 확인한 다음에 만약 인터럽트 상태가 true라면 다시 정상인 false로 돌려두어야 합니다.
스레드의 인터럽트 상태를 단순히 확인하는 것이 아닌 직접 체크해서 사용할 수 있습니다.
스레드가 인터럽트 상태라면 true 반환, 해당 스레드의 인터럽트 상태를 false로 변경. 스레드가 인터럽트 상태가 아니라면 false 반환, 해당 스레드의 인터럽트 상태를 변경 X
work 스레드의 인터럽트 상태를 정상(false) 로 변경됩니다.
while(!Thread.interrupted()) { // 인터럽트 상태만 확인할 뿐 변경 O
log("MyTask 작업 중");
}
work 스레드는 자원을 정리하는 코드를 실행하는데 인터럽트의 상태를 false이므로 인터럽트가 발생하는 sleep()
과 같은 코드를 수행해도 인터럽트가 발생하지 않습니다. 그래서 자원이 정상적으로 잘 정리가 됩니다.
너무 긴급한 상황에서는 자원 정리 하지도 않고 최대한 빨리 스레드를 종료하여 해당 스레드를 다시 인터럽트 상태로 변경할 수도 있습니다.
사용자 입력을 프린터에 출력하기. -> MyPrinterV1.class
연달아서 b,c,d를 입력하면 차례대로 출력됨.
11:23:57.091 [ printer] 출력 시작 : b, 대기 문 : [c, d]
11:24:00.092 [ printer] 출력 완료
11:24:00.093 [ printer] 출력 시작 : c, 대기 문 : [d]
11:24:03.097 [ printer] 출력 완료
11:24:03.098 [ printer] 출력 시작 : d, 대기 문 : []
11:24:06.100 [ printer] 출력 완료
여러 스레드가 동시에 접근하는 변수에는 volatile
키워드를 붙여주어야 안전합니다.
main 스레드와 printer 스레드 둘다 flag 변수에 동시에 접근할 수 있는 상태입니다.
여러 스레드가 동시에 접근하는 경우 동시성을 지원하는 동시성 컬렉션을 사용해야 합니다. 큐의 경우 ConcurrentLinkedQueue
를 사용하면 됩니다.
메인 스레드는 사용자의 입력을 받아서 프린터 인스턴스의 큐에 담는 것까지 진행.
큐가 비어있다면 continue를 사용해서 다시 while문 반복.
프린터 종료는 main 스레드에서 사용자가 q를 입력하면 flag가 false가 되고 while문 빠져나가 main 스레드 종료.
프린터 스레드는 while문에서 flag 값이 false인 것을 확인하고 프린터 종료.
여기서 문제점은 연달아 입력하고 나서 q를 눌렀을 때 바로 금방 종료가 되지 않습니다. 잘못된 문서의 경우 빨리 꺼야해서 q를 눌렸는데 다 출력되고 나서 종료가 되는 것입니다.
프린터 스레드가 sleep()
을 통해 대기 상태에 빠져서 작동하지 않기 때문입니다. (큐에 작업이 있는 경우)
MyPrinterV2
// 인터럽트 걸기
if(s.equals("q")){
printer.flag = false; -> 제거 가능(더 깔끔한 수정)
thread.interrupt();
break;
}
인터럽트를 줘서 빠르게 q를 입력하면 바로 반응하여 작업이 종료되도록 수정했습니다.
flag 변수 지워서 Thread.interrupted()
메서드를 사용하면 해당 스레드가 인터럽트 상태인지 아닌지 확인할 수 있습니다.
interrupted()
와isInterrupted()
차이
둘다 현재 실행 중인 스레드의 인터럽트 상태를 확인합니다. 하지만interrupted()
의 경우 그 상태를 리셋해버립니다. 리셋은 호출 후 스레드의 인터럽트 상태가 false로 리셋이 된 다는 것입니다. 즉 상태가 변경됩니다.
isInterrupted()
의 경우 호출 후에도 인터럽트 상태는 그대로 유지됩니다.
어떤 스레드를 얼마나 실행할지는 OS가 스케줄링을 통해 결정하는데 특정 스레드가 크게 바쁘지 않은 상황이어서 다른 스레드에 CPU 실행 기회를 양보하고 싶을 수 있습니다. 이렇게 양보하면 스케줄링 큐에 대기중인 다른 스레드가 CPU 실행 기회를 더 빨리 얻을 수 있습니다.
다른 스레드에 실행을 양보
yield는 스레드의 상태가 바뀌지 않고 RUNNABLE로 유지하는데 CPU에서 실행 하는 거를 빠져서 스케줄링 큐에 다시 들어갑니다
yield라고 하면 내가 CPU에서 지금 실행해야 하는데 그냥 다른 스레드를 실행하고 스케줄링 대기 큐로 들어갑니다. 그래서 스레드의 상태는 바뀌지 않고 RUNNABLE 상태로 있습니다.
1. Empty() 의 경우
2. sleep() 의 경우
3. yield()
sleep()은 스레드의 상태도 변합니다. RUNNABLE에서 WAITING 되면 스케줄링 큐에서도 빠지고 나중에 다시 들어오고 하는 과정이 복잡합니다. 하지만 yield()는 CPU 내가 쓰고 있다가 다음 대기 사람이 있으면 쓰게 두고 나는 스케줄링 큐에 다시 뒤로 들어가는 방식입니다. (다음에 언젠가 내가 실행됨.)
운영체제가 최적화를 해주는데 운영체제 입장에서 yield로 양보를 한다고 했는데 지금 양보할 사람도 없고 너가 실행되는 게 나을 것 같다고 한다면 운영체제가 최적화해서 본인이 계속 실행되도록 해줍니다.
CPU는 처리 성능을 개선하기 위해 중간에 캐시 메모리라는 것을 사용합니다.
CPU 연산은 매우 빠르기 때문에 CPU 연산의 빠른 성능을 따라가려면 CPU와 가까이에서 매우 빠른 메모리가 필요합니다. 이것이 캐시 메모리입니다. (가격 쌈)
현대의 대부분의 CPU는 코어 단위로 캐시 메모리를 각각 보유하고 있습니다.
메모리 가시성 - 멀티스레드 환경에서 한 스레드가 변경한 값이 다른 스레드에서 언제 보이는지에 대한 문제입니다. (메모리에 변경한 값이 보이는지 보이지 않는지)
멀티스레드 환경에서 실제로 main 스레드와 work 스레드에는 CPU 코어각 각각 들어가있고 캐시 메모리도 있습니다. CPU는 runFlag 값을 효율적으로 처리하기 위해 먼저 runFlag를 캐시 메모리에 불러옵니다. (runFlag가 각각의 캐시 메모리에 보관)
그래서 각 CPU 코어에 붙어 있는 자신의 캐시 메모리에 runFlag가 복제되어 올라갑니다.
메인 메모리에 있는 값이 아닌 캐시 메모리에 있는 runFlag를 사용하고 (엄청 빨리 읽기 가능) runFlag를 false로 바꾼다면 메인 메모리에 바로 반영하는 것이 아닌 자기가 사용하는 캐시 메모리에 있는 값을 먼저 바꿉니다. 캐시 메모리의 runFlag만 false로 변경.
그래서 work 스레드에 있는 캐시 메모리는 true인 상태로 남아있고 while()
을 빠져나가지 못하고 계속 반복하게 됩니다.
캐시 메모리를 사용하면 CPU 처리 성능을 향상시킬 수 있지만 여러 스레드에서 같은 시점에 정확히 같은 데이터를 보는 것이 중요할 수 있습니다.
단순하게 성능을 약간 포기하고 값을 읽을 때, 쓸 때 모두 메인 메모리에 직접 접근하면 됩니다.
변수에 volatile
을 넣어주면 캐시 메모리르 무시하고 메인 메모리에 직접 접근하게 됩니다. 그래서 여러 쓰레드에서 같은 값을 읽고 써야 한다면 volatile
을 사용하면 됩니다. (성능 이슈 때문에 꼭 필요한 곳에만 사용)
public static void main(String[] args) {
MyTask task = new MyTask();
Thread t = new Thread(task, "work");
t.start();
sleep(1000);
task.flag = false;
log("flag = " + task.flag + ", count = " + task.count + " in main");
}
static class MyTask implements Runnable {
boolean flag = true;
long count;
// volatile boolean flag = true;
// volatile long count;
@Override
public void run() {
while (flag) {
count++;
if (count % 100_000_000 == 0) {
log("flag = " + flag + ", count = " + count + " in while()");
}
}
log("flag = " + flag + ", count = " + count + " in while()");
}
}
15:23:11.485 [ work] flag = true, count = 1200000000 in while()
15:23:11.549 [ work] flag = true, count = 1300000000 in while()
15:23:11.614 [ work] flag = true, count = 1400000000 in while()
15:23:11.662 [ main] flag = false, count = 1474686888 in main
15:23:11.678 [ work] flag = true, count = 1500000000 in while()
15:23:11.678 [ work] flag = false, count = 1500000000 in while()
volatile
없이 반복으로 flag와 count를 출력했을 때 main 스레드와 work 스레드의 flag 상태 시점이 다릅니다. work 스레드가 true이다가 false가 되는 카운트가 1466506794인데 main 스레드는 1500000000에서 false가 되고 종료됩니다. (서로 false를 확인한 시점이 다름)
결과적으로 main 스레드가 flag 값을 false로 변경하고 한참이 지나서야 work 스레드는 flag 값이 false로 변경된 것을 확인한 것입니다. 캐시 메모리에 있는 값을 읽어서 반영하는 사이의 시간이 필요합니다.
콘솔에 결과가 출력되면, 출력하는 동안 스레드가 잠시 대기하며 쉬는데, 이때 컨텍스트 스위칭이 발생하면서 캐시 메모리 값이 갱신됩니다. (항상 보장 X. 환경에 따라 결과 달라짐)
volatile
사용15:23:48.790 [ work] flag = true, count = 400000000 in while()
15:23:48.981 [ work] flag = true, count = 500000000 in while()
15:23:48.994 [ work] flag = false, count = 506698635 in while()
15:23:48.994 [ main] flag = false, count = 506698635 in main
성능 차이로 volatile 없을 때는 대략 15억까지 플러스가 됐지만 volatile이 있으니 약 5억까지 증가.
자바 프로그램이 어떻게 메모리에 접근하고 수정할 수 있는지를 규정. 특히 멀티스레드 프로그래밍에서 스레드 간의 상호작용을 정의. 한 동작이 다른 동작보다 먼저 발생.
핵심은 여러 스레드들의 작업 순서를 보장하는 happens-before 관계에 대한 정의.
happens-before란 자바 메모리 모델에서 스레드 간의 작업 순서를 정의하는 개념으로 A 작업이 B 작업보다 happens-before 관계에 있다면 A 작업에서의 모든 메모리 변경 사항은 B 작업에서 볼 수 있습니다. 즉 A 작업에서 변경된 내용은 B 작업이 시작되기 전에 모두 메모리에 반영된다는 의미입니다.
그냥 반영되는 것은 아니고 volatile
같은 키워드를 넣는 방식 같은 몇 가지 룰을 적용해야 A에서 작업한 메모리 변경을 B에서 읽었을 때 바로 볼 수가 있습니다.
start()
로 호출하면 해당 스레드 내의 모든 작업은 start()
호출 이후에 실행된 작업보다 happens-before 관계가 성립.join()
호출하면 join 대상 스레드의 모든 작업은 join()
이 반환된 후의 작업보다 happens-before 관계를 가짐. 캐시 메모리에 있는 것이여서 값을 읽지 못하면 안됨. 메모리 가시성 문제를 해결하기 위한 방법입니다. 메모리 가시성이란 멀티스레드 환경에서 여러 스레드가 동시에 실행하고 있는 경우 A 스레의 변경이 B에게 즉시 반영되지 않아 스레드 B가 여전히 이전의 값을 읽게 되는 것이었습니다.
그래서 동기화 기법(synchronized, ReentrantLock)을 사용합니다.
멀티스레드를 사용할 때 가장 주의해야 하는 점은 같은 자원에 여러 스레드가 동시에 접근할 때 발생하는 동시성 문제입니다. 여러 스레드가 특정 인스턴스의 같은 멤버 변수에 접근하여 동시에 값을 변경해버릴 수 있습니다.
아이패드를 여러 아이들이 동시에 컨트롤하면 문제가 발생하니 시간을 정해놓고 한다고 생각하면 기억하기 쉬움.
좋은 예시로 출금 로직을 만들었습니다.
스레드 2개를 생성하여 같은 출금 로직을 처리하도록 했습니다.
출력 결과
19:26:20.669 [ t1] 거래 시작: BankAccountV1
19:26:20.669 [ t2] 거래 시작: BankAccountV1
19:26:20.676 [ t1] [검증 시작] 출금액: 800, 잔액: 1000
19:26:20.676 [ t1] [검증 완료] 출금액: 800, 잔액: 1000
19:26:20.676 [ t2] [검증 시작] 출금액: 800, 잔액: 1000
19:26:20.676 [ t2] [검증 완료] 출금액: 800, 잔액: 1000
19:26:21.158 [ main] t1 state: TIMED_WAITING
19:26:21.159 [ main] t2 state: TIMED_WAITING
19:26:21.682 [ t2] [출력 완료] 출금액: 800, 잔액: -600
19:26:21.682 [ t1] [출력 완료] 출금액: 800, 잔액: 200
19:26:21.683 [ t1] 거래 종료
19:26:21.683 [ t2] 거래 종료
19:26:21.694 [ main] 최종 잔액: -600
검증은 두 스레드 모두 통과했는데 거의 동시에 실행되지만 t1이 먼저 실행한 경우 둘 다 검증을 하고 둘 다 검증을 통과해버립니다. 결국 두 스레드 모두 출금 완료를 해버립니다.
출금액이 잔고보다 많이 되지 않게 하기 위해서 검증하는 로직을 작성했는데 음수 금액이 나옵니다. (두 스레드 모두 들어와서 로직을 뚫어버림)
잔고에서 뺐을 때 200원이 남은 상태인데 검증을 통과해버렸으니 200 - 800해서 -600이 출력된 것입니다.
이것이 바로 동시성 문제입니다. 동시에 실행이 되면서 발생하는 문제입니다.
volatile을 넣어준다고 해결되지 않습니다. 캐시 문제가 아니기 때문입니다.(메모리 가시성) -> 동시성 문제 해결이 안됨.
t1은 출금하기 위해 로직을 수행하는 중이고 실제 balance값을 200원으로 업데이트 하지 않은 상태입니다. 검증 로직만 통과한 상태이고 실제로는 아직 출금이 완료된 상태가 아닙니다. (출금 완료가 돼야 잔고가 200원이 됨.)
만약 두 스레드가 동시에 실행됐다면 둘 다 동시에 balance를 1000원 이라고 읽습니다. t1 스레드의 계산 결과도 200원이 되고 t2스레드의 계산 잔액 결과도 200원이 됩니다.
더 큰 문제는 1000원에서 1600원이 빠져나가게 된 것.
코드는 같지만 OS에 따라 최종 결과가 -600원이 될 수도 있고 200원이 될 수도 있습니다.
한 번에 하나의 스레드만 실행하여 출금 기능을 하나의 스레드만 실행할 수 있게 제한한다면 t1,t2 스레드가 함께 출금 요청을 했을 때 t1 스레드가 먼저 처음부터 끝까지 출금 기능을 완료하고 그 다음에 t2 스레드가 출금 기능을 완료하면 됩니다.
결론 - 스레드가 각각 차례대로 기능을 수행하면 됨.
그러면 계산 중간에 다른 스레드가 balance 값을 변경하는 부분을 걱정하지 않아도 됩니다.
임계 영역이란 여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 중요한 코드 부분을 의미합니다. 여러 스레드가 동시에 접근해서는 안 되는 공유 자원을 접근하거나 수정하는 부분을 의미합니다.
출금을 진행할 때 잔액을 검증하는 단계부터 잔액 계산을 완료하기까지가 임계 영역이었습니다. 이런 임계 영역을 한 번에 하나의 스레드만 접근할 수 있도록 안전하게 보호해야 합니다.
BankAccountV2
에서 withdraw 에서드에 synchronized
를 붙여주기만 하면 됩니다.
@Override
public synchronized boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
// 잔고가 출금액보다 적으면, 진행하면 안됨
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
"""
}
그러면 이 메서드는 한 번에 하나의 Thread만 실행할 수 있게 되고 다른 스레드는 실행하지 못하고 앞에서 대기합니다. (동시 실행 X)
14:06:18.211 [ t1] 거래 시작: BankAccountV2
14:06:18.217 [ t1] [검증 시작] 출금액: 800, 잔액: 1000
14:06:18.217 [ t1] [검증 완료] 출금액: 800, 잔액: 1000
14:06:18.698 [ main] t1 state: TIMED_WAITING
14:06:18.699 [ main] t2 state: BLOCKED
14:06:19.236 [ t1] [출력 완료] 출금액: 800, 잔액: 200
14:06:19.239 [ t1] 거래 종료
14:06:19.240 [ t2] 거래 시작: BankAccountV2
14:06:19.240 [ t2] [검증 시작] 출금액: 800, 잔액: 200
14:06:19.243 [ t2] [검증 실패] 출금액: 800, 잔액: 200
14:06:19.270 [ main] 최종 잔액: 200
synchronized withdraw()
메서드를 호출하므로 이 인스턴스의 락이 필요합니다.BankAccount 객체의 락을 t1 스레드가 가지고 있어 withdraw()
메서드를 실행하지만 t2 스레드에는 락이 존재하지 않아 진행할 수 없는 것입니다. 그래서 BLOCKED 상태로 대기하고 락을 흭득할 때까지 무한정 기다립니다.
t2가 락을 흭득하고 출금 메서드를 수행할 때 검증 로직을 통과하지 못하므로 락을 반납하고 return 합니다.
락을 흭득하는 순서는 보장되지 않습니다.
수많은 스레드가 동시 호출한다면 1개의 스레드만 락을 흭득하고 나머지는 모두 BLOCKED 상태가 되고 락을 흭득한 스레드만 RUNNABLE 상태가 됩니다. 환경에 따라서 어떤 순서로 락을 흭득하는지는 달라질 수 있습니다.
그리고 volatile를 사용하지 않아도 synchronized 안에서 접근하는 변수의 메모리 가시성 문제는 해결됩니다. (Lock 같은 동기화 블록 쓰면 자동으로 메머리 가시성 해결)
스레드가 동시에 실행하지 못하기 때문에 성능이 떨어져서 synchronized
를 통해 동시에 여러 스레드를 실행할 수 없는 코드 구간은 반드시 필요한 곳으로 한정해서 설정해야 합니다.
(5차선의 고속도로를 달리던 차들이 1차선으로 달리게 되어 속도가 매우 느려지는 상황을 생각하면 이해 빠름)
이 구간은 최대한 최소화해서 짧게 만드는 것이 중요
최소화 해야 하는데 현재 출금 기능에는 임계 영역에 들어가지 않아도 되는 코드들이 섞여있습니다. 그래서 synchronized
로 하나의 메서드를 묶어버리는 것이 아닌 자바에서는 특정 ㅋ코드 블록에 최적화해서 적용할 수 있는 기능을 제공합니다.
synchronized (this) {
// ==임계 영역 시작==
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
if (balance < amount) {
log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
return false;
}
// 출금 가능하므로 진행
log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
sleep(1000); // 출금에 걸리는 시간 -> 이것은 t1이 너무 빨리 계산해서 확인하기 위한 것으로 동시성 문제 해결은 안됨.
balance -= amount; // 잔고 - 출금 금액
log("[출력 완료] 출금액: " + amount + ", 잔액: " + balance);
// ==임계 영역 종료==
}
synchronized(this) {}
로 동기화 코드 블록을 만들어줍니다. 이렇게 하면 조금이라도 여러 스레드가 동시에 수행되는 부분을 늘려서 전체적인 성능을 향상시킬 수 있습니다.
동기화 장점
- 경합 조건 : 2개 이상의 스레드가 경쟁적으로 동일한 자원(공유 자원)을 수정할 때 발생하는 문제
- 데이터 일관성 : 여러 스레드가 동시에 읽고 쓰는 데이터의 일관성을 유지.
공유 자원 접근
여러 스레드가 공유 자원에 접근하는 것이 문제가 되는 것이 아닌 공유 자원을 사용하는 중간에 다른 스레드가 공유 자원의 값을 변경해버리는 것이 문제입니다.
그래서 공유 자원이라도 그 값을 아무도 변경할 수 없다면, 모든 스레드가 항상 같은 값을 읽는다면 문제가 되지 않습니다.
**final
이 붙는다면 어떤 스레드도 값을 변경할 수 없기 때문에 안전한 공유 자원이 될 수 있습니다.
synchronized
로 동기화를 했을 때 BLOCKED 상태인 스레드가 락이 풀릴 때까지 무한 대기해야 하고 중간에 인터럽트도 불가능합니다. 락이 돌아와도 어떤 스레드가 락을 흭득할 지 알 수가 없습니다.
그래서 자바 1.5부터 java.util.concurrent
라는 동시성 문제 해결을 위한 라이브러리 패키지가 추가됐습니다.
LockSupport 기능(좋은 방법 아님)
WAITING 상태의 스레드에 인터럽트가 발생하면 WAITING 상태에서 RUNNABLE 상태로 변하면서 깨어납니다. (Blocking 상태는 걸어도 못 깨어남)
static class ParkTest implements Runnable {
@Override
public void run() {
log("Park 시작");
// LockSupport.park();
LockSupport.parkNanos(2_000_000_000); // 2초 뒤에 깨어남
log("park 종료, state : " + Thread.currentThread().getState());
log("인터럽트 상태: " + Thread.currentThread().isInterrupted());
}
}
2초 -> 2,000,000,000(20억) 나노초
BLOCKED 상태는 인터럽트가 걸려도 대기 상태를 빠져나오지 못합니다. 여전히 BLOCKED 상태이고 WAITING 상태는 인터럽트가 걸리면 대기 상태를 빠져나옵니다. 그래서 RUNNABLE이 됩니다.
BLOCKED는 자바의 synchronized에서 락을 흭득하기 위해 대기할 때 사용합니다.
Thread.join()
, LockSupprot.park()
, Object.wait()
와 같은 메서드 호출 시 WAITING 상태가 됩니다. TIMED_WAITING이라면 Thread.sleep(ms)
, Object.wait(long timeout)
, Thread.join(long millis)
, LockSupport.parkNanos(ns)
둘다 스레드가 지금 CPU에서 실행하지 않고 대기 상태에 있는 상태인데 synchronized 에서만 사용하고 interrupt 안 먹고 대기하는 상태 다른 WAITING 들은 범용적으로 활용할 수 있는 대기 상태.
하지만 LockSupport 로는 너무 저수준으로 할 수 있는 것이 많지가 않아 synchronized 처럼 더 고수준의 기능이 필요합니다. 그래서 Lock 인터페이스와 ReentrantLock이라는 구현체로 이런 기능들이 이미 구현되어 있어 사용하면 됩니다.
void lock()
은 락을 흭득합니다.void lockInterruptibly()
은 락 흭득을 시도하되 다른 스레드가 인터럽트할 수 있도록 합니다boolean tryLock()
은 락 흭득을 시도하고 즉시 성공 여부를 반환합니다.boolean tryLock(long time, TimeUnit unit)
은 주어진 시간 동안 락 흭득을 시도합니다.void unlock()
은 락을 해제합니다. 락 흭득을 대기 중인 스레드 중 하나가 락을 흭득합니다Lock 인터페이스는 synchronized 블록보다도 더 많은 유연성을 제공하고 락을 특정 시간 만큼만 시도하거나, 인터럽트 가능한 락을 구현할 때 유용합니다. 무한 대기의 단점도 해결할 수 있습니다.
interrupt 무시하고 계속 lock 흭득, interrupt를 받거나 기다리지 않고 lock 포기 등 다양하게 사용하여 유용하게 사용 가능합니다.
lock()
메서드의 인터럽트
인터럽트에 응하지 않는 것으로 인터럽트가 발생해도 무시하고 락을 기다리는 메서드인데 WAITING 상태인데도 인터럽트에 응하지 않는다고 되어있습니다.
lock()
호출해서 락을 얻기 위해 대기 중인 스레드에 인터럽트가 발생하면 대기 상태를 빠져나오는 것은 맞지만(WAITING -> RUNNABLE)lock()
메서드 안에서 해당 스레드를 다시 WAITING 상태로 강제 변경해버립니다.
그렇게 인터럽트를 무시하기 때문에 인터럽트 필요 시lockInterruptibly()
를 사용하면 됩니다.
어떤 스레드가 락을 흭득할 지 알 수 없는 단점도 존재 했는데 이점은 ReentrantLock을 통해 스레드가 공정하게 락을 얻을 수 있는 모드를 제공할 수 있습니다.
private final Lock fairLock = new ReentrantLock(true)
라고 하면 공정 모드 락이 돼서 오래 기다린 쓰레드가 먼저 실행되는 게 보장됩니다.
비공정 모드
공정 모드
모니터 락과
ReentrantLock
void lock
을 하면 락을 흭득합니다. 여기서 사용하는 락은 객체 내부에 있는 모니터 락이 아닙니다. Lock 인터페이스와 ReentrantLock이 제공하는 기능입니다.
모니터 락과 BLOCKED 상태는 synchronized 에서만 사용됩니다.
출금 로직에서 synchronized
를 사용했던 로직을 lock() 과 unlock()으로 해주면 끝입니다. 리턴되거나 예외가 터지면 호출되지 않기 때문에 항상 락을 걸고 나면 try-finally로 unlock을 걸어줘야 합니다.
lock.lock(); // ReentrantLock 이용하여 lock을 걸기
try{
// ==임계 영역 시작==
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
if (balance < amount) {
log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
return false;
}
// 출금 가능하므로 진행
log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
sleep(1000); // 출금에 걸리는 시간 -> 이것은 t1이 너무 빨리 계산해서 확인하기 위한 것으로 동시성 문제 해결은 안됨.
balance -= amount; // 잔고 - 출금 금액
log("[출력 완료] 출금액: " + amount + ", 잔액: " + balance);
// ==임계 영역 종료==
} finally {
lock.unlock();
}
lock()
부터 unlock()
까지는 안전한 임계영역이 됩니다.
임계영역 끝나면 반드시!!!! 락을 반납해야 한다는 것과 unlock()
을 finally 블럭에 넣어서 검증 실패하면 return을 호출 또는 예외가 발생해도 unlock()
이 반드시 호출되도록 하는 것 잊지 말기.
락을 흭득하면 RUNNABLE 상태가 유지되고, 임계 영역의 코드를 실행할 수 있습니다. 하지만 락이 없다면 WAITING 상태가 되고 대기 큐에서 관리됩니다. t1이 임계 영역을 수행하고 나면 unlock()
을 호출합니다. 락을 반납하면 대기 큐의 스레드 하나를 깨웁니다.
t2는 RUNNABLE이 되고 락 흭득을 시도하고 흭득하면 lock()
빠져나오면서 대기 큐에서도 제거. 락을 흭득하지 못하면 다시 대기 상태.
t2가 검증 로직을 통과하지 못하므로 false 반환하고 unlock()
하면 대기 큐의 스레드를 하나 깨우려고 시도하는데 대기 큐에 스레드가 없으면 깨우지 않습니다.
volatile 사용하지 않아도 Lock을 사용하면 메모리 가시성 문제 해결됩니다.
ReentrantLock을 사용하면 락을 무한 대기하지 않고 중간에 빠져나오는 것이 가능합니다.
락 흭득을 시도하고 즉시 성공 여부를 반환합니다. 만약 다른 스레드가 이미 락을 흭득했다면 false를 반환하고 그렇지 않으면 락을 흭득하고 true를 반환합니다.
락이 없을 때 락을 대기할 시간도 지정이 가능합니다. 해당 시간이 지나도 락을 얻지 못하면 false를 반환합니다.
// 0.5초간 기다리고 만약 기다려도 결과가 없으면 진입 실패. - false 반환
// 그 사이에 interrupt가 들어오면 catch로 빠져나감
try{
if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) {
log("[진입 실패] 이미 처리 중인 작업이 있습니다");
return false;
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
t2는 대기 시간인 0.5초 동안 락을 흭득하지 못하고 즉시 빠져나와 false를 반환합니다. 스레드는 TIMED_WAITING -> RUNNABLE이 됩니다.
그리고 [진입 실패]가 되고 false를 반환하면서 메서드가 종료됩니다. t1은 임계 영역의 수행을 마치고 거래를 종료합니다. (락 반납까지)