쓰레드
프로세스(process)란 간단히 말해서 '실행 중인 프로그램'입니다. 프로그램을 실행하면 OS로부터 실행에 필요한 자원(메모리)을 할당받아 프로세스가 됩니다.
프로세스는 프로그램을 수행하는데 필요한 데이터와 메모리 등의 자원 그리고 쓰레드로 구성되어 있으며 프로세스의 자원을 이용해서 실제로 작업을 수행하는 것이 바로 쓰레드입니다. 그래서 모든 프로세스에는 최소한 하나 이상의 쓰레드가 존재하며, 둘 이상의 쓰레드를 가진 프로세스를 '멀티쓰레드 프로세스(mutil-threaded process)'라고 합니다.
하나의 프로세스가 가질 수 있는 쓰레드의 개수는 제한되어 있지 않으나 쓰레드가 작업을 수행하는데 개별적인 호출 스택을 필요로 하기 때문에 프로세스의 메모리 한계에 따라 생성할 수 있는 쓰레드의 수가 결정됩니다.
JVM 실행 옵션 -Xss로 호출 스택의 크기를 줄이면 더 많은 쓰레드를 생성할 수 있습니다.
멀티 태스킹과 멀티 쓰레딩
현재 주로 사용되는 윈도우나 리눅스를 포함한 대부분의 OS는 멀티 태스킹(multi-tasking, 다중 작업)을 지원하므로 여러 개의 프로세스가 동시에 실행될 수 있습니다. 이와 마찬가지로 멀티 쓰레딩은 하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행하는 것입니다. CPU의 코어(core)가 한 번에 단 하나의 작업만 수행할 수 있으므로, 실제로 동시에 처리되는 작업의 개수는 코어의 개수와 일치합니다.
그러나 애플리케이션에서 사용하는 쓰레드의 수는 언제나 코어의 개수보다 많기 때문에 각 코어가 아주 짧은 시간 동안 여러 작업을 번갈아 가며 수행함으로써 여러 작업들이 모두 동시에 수행되는 것처럼 보이게 합니다.
그래서 프로세스의 성능이 단순히 쓰레드의 개수에 비례하는 것은 아니며, 하나의 쓰레드를 가진 프로세스보다 두 개의 쓰레드를 가진 프로세스가 오히려 더 낮은 성능을 보일 수도 있습니다.
멀티 쓰레딩의 장단점
도스(DOS)와 같이 한 번에 한 가지 작업만 할 수 있는 OS와 윈도우와 같은 멀티 태스킹이 가능한 OS의 차이는 이미 잘 알고 있습니다. 싱글 쓰레드 프로그램과 멀티 쓰레드 프로그램의 차이도 이와 같으며, 멀티 쓰레딩의 장점은 다음과 같습니다.
- CPU의 사용률을 향상시킵니다.
- 자원을 보다 효율적으로 사용할 수 있습니다.
- 사용자에 대한 응답성이 향상됩니다.
- 작업이 분리되어 코드가 간결해집니다.
메신저로 채팅하면서 파일을 다운로드 받거나 음성대화를 나눌 수 있는 것이 가능한 이유가 바로 멀티 쓰레드로 작성되어 있기 때문입니다. 만일 싱글 쓰레드로 서버 프로그램을 작성한다면 사용자의 요청마다 새로운 프로세스를 생성해야 하는데 프로세스를 생성하는 것은 쓰레드를 생성하는 것에 비해 더 많은 시간과 메모리 공간이 필요하기 때문에 많은 수의 사용자 요청을 서비스하기 어렵습니다.
쓰레드를 가벼운 프로세스, 즉 경량 프로세스(LWP, light-weight process)라고 부르기도 합니다.
그러나 멀티쓰레딩에 장점만 있는 것은 아닙니다. 멀티 쓰레드 프로세스는 여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업을 하기 때문에 발생할 수 있는 동기화(synchronization), 교착상태(deadlock)와 같은 문제들을 고려해서 신중히 프로그래밍해야 합니다.
쓰레드의 구현과 실행
쓰레드를 구현하는 방법은 Thread클래스를 상속받는 방법과 Runnable인터페이스를 구현하는 방법, 모두 두 가지가 있습니다. 어느 쪽을 선택해도 별 차이는 없지만 Thread클래스를 상속받으면 다른 클래스를 상속받을 수 없기 때문에, Runnable인터페이스를 구현하는 방법이 일반적입니다. Runnable인터페이스를 구현하는 방법은 재사용성이 높고 코드의 일관성을 유지할 수 있기 때문에 보다 객체지향적인 방법이라고 할 수 있습니다.
class MyThread extends Thread {
public void run() {
// 작업
}
}
public static void main(String[] args) {
MyThread t = new MyThread();
t.start();
}
class MyThread implements Runnable {
public void run() {
// 작업
}
}
public static void main(String[] args) {
Runnable r = new MyThread();
Thread t = new Thread(r);
t.start();
}
Thread클래스를 상속받은 경우와 Runnable인터페이스를 구현한 경우의 인스턴스 생성 방법이 다릅니다.
Runnable인터페이스를 구현한 경우, Runnable인터페이스를 구현한 클래스의 인스턴스를 생성한 다음, 이 인스턴스를 Thread클래스의 생성자의 매개변수로 제공해야 합니다.
다음은 Thread의 소스 코드의 일부를 간추린 것입니다.
public class Thread implements Runnable {
private Runnable target;
public Thread(Runnable target) {
this.target = target;
}
@Override
public void run() {
if (target != null) {
target.run();
}
}
public synchronized void start() {
start0();
}
private native void start0();
}
위 코드에서 알 수 있듯이 run()은 target(Runnable)이 있을 경우에만 run()을 수행합니다. 따라서 생성자의 인자로 run()을 구현한 Runnable을 제공하던지, Thread클래스의 run()을 직접 오버라이딩해야 하는 것입니다.
Thread의 start()는 JVM의 native 메서드를 호출하고, 이 메서드가 OS 수준에서 새로운 쓰레드의 새로운 호출 스택위에서 오버라이딩한 run() 메서드를 실행시켜줍니다.
Thread클래스를 상속받으면, 자손 클래스에서 조상인 Thread클래스의 메서드를 직접 호출할 수 있지만, Runnable을 구현하면 Thread클래스의 static메서드인 currentThread()를 호출하여 쓰레드에 대한 참조를 얻어 와야만 호출이 가능합니다.
class MyThread_1 extends Thread {
public void run() {
System.out.println(getName());
}
}
class MyThread_2 implements Runnable {
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
참고로 쓰레드의 이름은 Thread클래스의 생성자나 메서드를 통해서 지정 또는 변경가능합니다.
그리고 쓰레드의 이름을 지정하지 않으면 "Thread-번호"의 형식으로 이름이 정해집니다.
쓰레드의 실행 - start()
쓰레드를 생성하면 자동으로 실행되는 것이 아니라, start()를 호출해야만 쓰레드가 실행됩니다. 사실은 start()가 호출되었다고 해서 바로 실행되는 것이 아니라, 일단 실행대기 상태에 있다가 자신의 차례가 되어야 실행됩니다. 물론 실행대기 중인 쓰레드가 하나도 없으면 곧바로 실행됩니다.
한 가지 더 알아 두어야 하는 것은 한 번 실행이 종료된 쓰레드는 다시 실행할 수 없다는 것입니다. 즉, 하나의 쓰레드에 대해 start()가 한 번만 호출될 수 있다는 뜻입니다. 그래서 만일 쓰레드의 작업을 한 번 더 수행해야 한다면 새로운 쓰레드를 생성한 다음에 start()를 호출해야 합니다. 동일한 쓰레드에 대해 start()를 두 번 이상 호출하면 실행시에 IllegalThreadStateException이 발생합니다.
start()와 run()
쓰레드를 실행시킬 때 run()이 아닌 start()를 호출하는데, 두 메서드의 차이와 쓰레드가 실행되는 과정에 대해서 자세히 살펴보겠습니다. main메서드에서 run()을 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라 단순히 클래스에 선언된 메서드를 호출하는 것일 뿐입니다.

반면에 start()는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택(call stack)을 생성한 다음에 run()을 호출해서, 생성된 호출스택에 run()이 첫 번째로 올라가게 합니다.
모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택을 필요로 하기 때문에, 새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스택이 생성되고 쓰레드가 종료되면 작업에 사용된 호출스택은 소멸합니다.

호출스택에서는 가장 위에 있는 메서드가 현재 실행중인 메서드이고 나머지 메서드들은 대기상태에 있습니다. 그러나 위 그림과 같이 쓰레드가 둘 이상일 때는 호출스택의 최상위에 있는 메서드일지라도 대기상태에 있을 수 있습니다.
스케줄러는 실행대기중인 쓰레드들의 우선순위를 고려하여 실행순서와 실행시간을 결정하고, 각 쓰레드들은 작성된 스케줄에 따라 자신의 순서가 되면 지정된 시간동안 작업을 수행합니다.
이때 주어진 시간동안 작업을 마치지 못한 쓰레드는 다시 자신의 차례가 돌아올 때까지 대기상태로 있게 되며, 작업을 마친 쓰레드, 즉 run()의 수행이 종료된 쓰레드는 호출스택이 모두 비워지면서 이쓰레드가 사용하던 호출스택은 사라집니다.
main 쓰레드
main메서드의 작업을 수행하는 것도 쓰레드이며, 이를 main쓰레드라고 합니다. 프로그램을 실행하면 기본적으로 하나의 쓰레드를 생성하고, 그 쓰레드가 main메서드를 호출해서 작업이 수행되도록 하는 것입니다. 보통 main메서드가 수행을 마치면 프로그램이 종료되었으나, 다른 쓰레드가 아직 작업을 마치지 않은 상태라면 프로그램이 종료되지 않습니다.
쓰레드는 '사용자 쓰레드(user thread)'와 '데몬 쓰레드(daemon thread)', 두 종류가 있습니다.
class Test {
public static void main(String[] args) {
ThreadEx t = new ThreadEx();
t.start();
}
}
class ThreadEx extends Thread {
public void run() {
throw new RuntimeException();
}
}
위 코드를 실행해보면 RuntimeException이 발생하고 처리되지 않은 예외를 JVM이 printStackTrace()를 자동 호출해서 콘솔에 다음과 같이 출력해줍니다.

여기서 두 가지를 확인할 수 있습니다.
- main 쓰레드가 아닌 "Thread-0" 쓰레드에서 예외 발생
- 호출 스택의 첫 메서드가 run()
즉, main 쓰레드가 아닌 새로 생성한 쓰레드의 새로운 호출 스택에서 run()을 실행하다가 예외가 발생했다는 뜻입니다.
싱글쓰레드와 멀티쓰레드
두 개의 작업을 하나의 쓰레드(th1)로 처리하는 경우와 두 개의 쓰레드(th1, th2)로 처리하는 경우를 가정해보겠습니다. 하나의 쓰레드로 두 작업을 처리하는 경우는 한 작업을 마친 후에 다른 작업을 시작하지만, 두 개의 쓰레드로 작업을 하는 경우에는 짧은 시간동안 2개의 쓰레드(th1, th2)가 번갈아 가면서 작업을 수행해서 동시에 두 작업이 처리되는 것과 같이 느끼게 합니다.
다음은 println()을 수행할 때, "싱글 코어"에서의 싱글 쓰레드와 멀티 쓰레드 비교 그래프입니다.

위 그래프에서 알 수 있듯이 하나의 쓰레드로 두 개의 작업을 모두 끝낸 시간과 두 개의 쓰레드로 두 개의 작업을 모두 끝낸 시간은 비슷합니다. 오히려 두 개의 쓰레드로 작업한 시간이 싱글 쓰레드로 작업한 시간보다 더 걸리게 되는데 그 이유는 쓰레드간의 작업 전환(context switching)에 시간이 걸리기 때문입니다.
작업 전환(context switching)을 할 때는 현재 진행 중인 작업의 상태, 예를 들면 다음에 실행해야할 위치 등의 정보를 저장하고 읽어 오는 시간이 소요됩니다. 참고로 쓰레드의 스위칭에 비해 프로세스의 스위칭이 더 많은 정보를 저장해야하므로 더 많은 시간이 소요됩니다.
그래서 "싱글 코어"에서 단순히 CPU만을 사용하는 계산작업이라면 오히려 멀티 쓰레드보다 싱글 쓰레드로 프로그래밍하는 것이 더 효율적입니다.
다음은 println()을 수행할 때, "싱글 코어"와 "멀티 코어"에서의 멀티 쓰레드 비교 그래프입니다.

싱글 코어인 경우에는 멀티 쓰레드라도 하나의 코어가 번갈아가면서 작업을 수행하는 것이므로 두 작업이 절대 겹치지 않습니다. 그러나, 멀티 코어에서는 멀티 쓰레드로 두 작업을 수행하면, 동시에 두 쓰레드가 수행될 수 있으므로 오른쪽과 같이 두 작업 A, B가 겹치는 부부분이 발생합니다.
하지만 시간이 정확히 절반으로 줄어들지 않은 것은 System.out이 단 하나의 PrintStream 객체이기 때문에 완전한 병행(concurrent)으로 사용할 수 없기 때문입니다.
여러 쓰레드가 여러 작업을 동시에 진행하는 것을 병행(concurrent)이라고 하고,
하나의 작업을 여러 쓰레드가 나눠서 처리하는 것을 병렬(parallel)이라고 합니다.
위 결과는 실행할 때마다 다른 결과를 얻을 수 있는데 그 이유는 실행 중인 프로그램이 OS의 프로세스 스케줄러의 영향을 받기 때문입니다.
자바 프로그램에서 쓰레드는 JVM에 의해 생성되고 관리되지만, 실제 쓰레드는 운영체제(OS)가 관리하는 네이티브 쓰레드(native thread)이기 때문에 실제로 어떤 쓰레드가 얼마동안 실행될지는 운영체제의 스케줄러에 의해 결정됩니다.
따라서 매 순간 상황에 따라 프로세스에 할당되는 실행 시간이 일정하지 않고, 각 스레드에 할당되는 CPU 시간 역시 일정하지 않습니다. 이러한 특성으로 인해, 스레드의 실행 순서나 실행 시간은 예측할 수 없으며, 쓰레드가 이러한 불확실성을 가지고 있다는 것을 염두에 두어야 합니다.
두 쓰레드가 서로 다른 자원을 사용하는 작업의 경우에는 싱글 쓰레드 프로세스보다 멀티 쓰레드 프로세스가 더 효율적입니다. 예를 들면 사용자로부터 데이터를 입력받는 작업, 네트워크로 파일을 주고 받는 작업, 프린터로 파일을 출력하는 작업과 같이 외부기기와의 입출력을 필요로 하는 경우가 이에 해당합니다.

만일 사용자로 부터 입력을 받는 작업(A)과 화면에 출력하는 작업(B)을 하나의 쓰레드로 처리한다면 첫 번째 그래프처럼 사용자가 입력을 마칠 때까지 아무 일도 하지 못하고 기다리기만 해야합니다.
그러나 두 개의 쓰레드로 처리한다면 사용자의 입력을 기다리는 동안 다른 쓰레드가 작업을 처리할 수 있기 때문에 보다 효율적인 CPU의 사용이 가능합니다. 작업이 모두 완료되는 시간을 비교하면 멀티 쓰레드 프로세스가 더 빨리 마치는 것을 알 수 있습니다.
쓰레드의 우선순위
쓰레드는 우선순위(priority)라는 속성(멤버변수)을 갖고 있는데, 이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라집니다. 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 서로 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있습니다.
예를 들어 파일 전송기능이 있는 메신저의 경우, 파일 다운로드를 처리하는 쓰레드보다 채팅내용을 전송하는 쓰레드의 우선순위가 더 높아야 사용자가 채팅하는데 불편함이 없을 것입니다. 대신 파일 다운로드 작업에 걸리는 시간은 더 길어질 것입니다.
이처럼 시각적인 부분이나 사용자에게 빠르게 반응해야하는 작업을 하는 쓰레드의 우선 순위는 다른 작업을 수행하는 쓰레드에 비해 높아야 합니다.
쓰레드의 우선순위 정하기
쓰레드의 우선순위와 관련된 상수와 메서드는 다음과 같습니다.
public static final int MIN_PRIORITY = 1;
public static final int NORM_PRIORITY = 5;
public static final int MAX_PRIORITY = 10;
public final void setPriority(int newPriority) {}
public final int getPriority() {}
쓰레드가 가질 수 있는 우선순위의 범위는 1~10이며 숫자가 높을수록 우선순위가 높습니다.
한 가지 더 알아두어야 할 것은 쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속받는다는 것입니다. main메서드를 수행하는 쓰레드는 우선순위가 5이므로 main메서드 내에서 생성하는 쓰레드의 우선순위는 자동적으로 5가 됩니다. 쓰레드의 우선 순위는 실행하기 전에만 변경할 수 있습니다.
쓰레드의 우선 순위에 따른 차이는 싱글 코어일 때와 멀티 코어일 때에 다른 결과를 보여줍니다.
“싱글 코어” 환경에서는 CPU가 한 번에 하나의 스레드만 실행할 수 있기 때문에, 우선순위가 높은 스레드가 스케줄러에 의해 더 자주, 더 오래 CPU를 점유하게 됩니다. 결과적으로 높은 우선순위 스레드가 상대적으로 더 빠르게 작업을 완료하게 됩니다.
반면 “멀티 코어” 환경에서는 여러 스레드가 서로 다른 코어에서 동시에 실행되므로, 각 스레드가 별도의 CPU를 점유할 수 있어 우선순위에 따른 실행 시간 차이가 거의 없습니다. 단, 같은 코어를 공유하는 스레드끼리는 여전히 우선순위의 영향을 받습니다.
자바의 쓰레드 우선순위는 운영체제(OS)의 스케줄러 힌트 역할만 하기 때문에, 우선순위를 보장하지 않습니다. 굳이 우선순위에 차등을 두어 쓰레드를 실행하려면 특정 OS의 스케줄링 정책과 JVM의 구현을 직접 확인해봐야 합니다.
따라서 쓰레드의 우선순위 할당보다는, 작업에 우선순위를 두어 PriorityQueue에 저장해 놓고, 우선순위가 높은 작업이 먼저 처리되도록 하는 것이 나을 수 있습니다.
쓰레드 그룹 (thread group)
쓰레드 그룹은 서로 관련된 쓰레드를 그룹으로 다루기 위한 것으로, 폴더를 생성해서 관련된 파일들을 함께 넣어서 관리하는 것처럼 쓰레드 그룹을 생성해서 쓰레드를 그룹으로 묶어서 관리할 수 있습니다.
사실 쓰레드 그룹은 보안상의 이유로 도입된 개념으로, 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경할 수 있지만, 다른 쓰레드 그룹의 쓰레드를 변경할 수는 없습니다. ThreadGroup을 사용해서 생성할 수 있으며, 생성자는 다음과 같습니다.
public ThreadGroup(String name) {}
public ThreadGroup(ThreadGroup parent, String name) {}
그리고 쓰레드를 쓰레드 그룹에 포함시키려면 Thread의 생성자를 이용해야 합니다.
public Thread(ThreadGroup group, String name) {}
public Thread(ThreadGroup group, Runnable target) {}
public Thread(ThreadGroup group, Runnable target, String name) {}
public Thread(ThreadGroup group, Runnable target, String name,long stackSize) {}
public Thread(ThreadGroup group, Runnable target, String name,long stackSize, boolean inheritThreadLocals) {}
모든 쓰레드는 반드시 쓰레드 그룹에 포함되어 있어야 하기 때문에, 위와 같이 쓰레드 그룹을 지정하는 생성자를 사용하지 않은 쓰레드는 기본적으로 자신을 생성한 쓰레드와 같은 쓰레드 그룹에 속하게 됩니다.
자바 어플리케이션이 실행되면, JVM은 main과 system이라는 쓰레드 그룹을 만들고 JVM운영에 필요한 쓰레드들을 생성해서 이 쓰레드 그룹에 포함시킵니다. 예를 들어 main메서드를 수행하는 main쓰레드는 main쓰레드 그룹에 속하고, gc를 수행하는 Finalizer쓰레드는 system쓰레드 그룹에 속합니다.
우리가 생성하는 모든 쓰레드 그룹은 main쓰레드 그룹의 하위 쓰레드 그룹이 되며, 쓰레드 그룹을 지정하지 않고 생성한 쓰레드는 자동으로 main쓰레드 그룹에 속하게 됩니다.
public static void main(String[] args) {
ThreadGroup main = Thread.currentThread().getThreadGroup();
ThreadGroup grp1 = new ThreadGroup("Group1");
ThreadGroup grp2 = new ThreadGroup("Group2");
ThreadGroup subGrp1 = new ThreadGroup(grp1, "SubGroup1");
grp1.setMaxPriority(3);
Runnable r = new Runnable() {
@Override
public void run() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
};
new Thread(grp1, r, "th1").start();
new Thread(grp2, r, "th2").start();
new Thread(subGrp1, r, "th3").start();
System.out.println("ThreadGroup name: " + main.getName());
System.out.println("Active ThreadGroup: " + main.activeGroupCount());
System.out.println("Active Thread: " + main.activeCount());
main.list();
}
위 코드를 실행시키면 다음과 같이 출력됩니다.

결과를 보면 쓰레드 그룹에 포함된 하위 쓰레드 그룹이나 쓰레드는 들여쓰기를 이용해서 구별되도록 하였음을 알 수 있습니다.
새로 생성한 모든 쓰레드 그룹은 main쓰레드 그룹의 하위 쓰레드 그룹으로 포함된다는 것과, MaxPriority는 쓰레드가 쓰레드 그룹에 추가되기 전에 호출되어야 하며, Group1은 최대우선순위를 3으로 했기 때문에, 후에 여기에 속하게 된 쓰레드 그룹과 쓰레드가 영향을 받았음을 확인할 수 있습니다.
그리고 참조 변수 없이 쓰레드를 생성해서 바로 실행시켰지만, 이 쓰레드의 참조가 ThreadGroup에 저장되어 있기 때문에 gc 대상이 되지 않습니다.
데몬 쓰레드 (deamon thread)
데몬 쓰레드는 다른 일반 쓰레드의 작업을 돕는 보조적인 역할을 수행하는 쓰레드입니다. 일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 자동 종료되는데, 기 이유는 데몬 쓰레드는 일반 쓰레드의 보조 역할을 수행하므로 일반 쓰레드가 모두 종료되고 나면 데몬 쓰레드의 존재의 의미가 없기 때문입니다. 이점을 제외하고는 데몬 쓰레드와 일반 쓰레드는 다르지 않으며, 대표적인 데몬 쓰레드로는 가비지 컬렉터가 있습니다.
데몬 쓰레드는 무한루프와 조건문을 이용해서 실행 후 대기하고 있다가 특정 조건이 만족되면 작업을 수행하고 다시 대기하도록 작성합니다. 데몬 쓰레드는 쓰레드를 생성하고 `setDaemon(true);`를 호출하기만 하면 되며, 데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 됩니다.
public static void main(String[] args) {
Map<Thread, StackTraceElement[]> map = Thread.getAllStackTraces();
Iterator<Thread> it = map.keySet().iterator();
while (it.hasNext()) {
Thread th = it.next();
System.out.println("ThreadGroup name: " + th.getThreadGroup().getName()
+ ", Thread name: " + th.getName() + ", Daemon: " + th.isDaemon());
}
}
getAllStackTraces()를 사용하여 현재 JVM에서 실행 중인 모든 쓰레드의 stack trace 정보를 가져온 뒤 출력하면 다음과 같습니다.

총 7개의 쓰레드가 실행 중 또는 대기 상태에 있다는 것을 알 수 있습니다.
프로그램을 실행하면, JVM은 가비지 컬렉션, 객체의 참조 관리, 리소스 관리와 같이 프로그램이 실행되는 데 필요한 보조 작업을 수행하는 데몬 쓰레드들을 자동적으로 생성해서 실행시킵니다. 그리고 이들 대부분은 'system쓰레드 그룹'에 속합니다.
쓰레드의 실행제어
쓰레드 프로그래밍이 어려운 이유는 동기화(synchronization)와 스케줄링(scheduling)때문입니다. 효율적인 멀티 쓰레드 프로그램을 만들려면 보다 정교한 제어를 통해 프로세스에게 주어진 자원과 시간을 여러 쓰레드가 낭비없이 잘 사용하도록 프로그래밍해야 합니다.
쓰레드의 스케줄링을 잘하기 위해서는 쓰레드의 상태와 관련 메서드를 잘 알아야 합니다.
| 메서드 | 설 명 |
| static void sleep(long millis) static void sleep(long millis, int nanos) static void sleep(Duration duration) |
지정된 시간동안 쓰레드를 정지시킵니다. 지정된 시간이 지나고 나면, 자동적으로 다시 실행 대기 상태가 됩니다. - sleep(duration)은 JDK 19부터 추가 |
| void join() void join(long millis) void join(long millis, int nanos) boolean join(Duration duration) |
지정된 시간동안 쓰레드가 실행되도록 합니다. 지정된 시간이 지나거나 작업이 종료되면 join()을 호출한 쓰레드로 다시 돌아와 실행을 계속합니다. - join(duration)은 JDK 19부터 추가 |
| void interrupt() | sleep()이나 join()에 의해 일시 정지 상태인 쓰레드를 깨워서 실행 대기 상태로 만듭니다. 해당 쓰레드에서는 InterruptedException이 발생함으로써 일시 정지 상태를 벗어나게 됩니다. |
| void stop() - deprecated | 쓰레드를 즉시 종료시킵니다. |
| void suspend() - deprecated | 쓰레드를 일시 정지시킵니다. resume()을 호출하면 다시 실행 대기 상태가 됩니다. |
| void resume() - deprecated | suspend()에 의해 일시 정지 상태에 있는 쓰레드를 실행 대기 상태로 만듭니다. |
| static void yield() | 실행 중에 자신에게 주어진 실행시간을 다른 쓰레드에게 양보하고 자신은 실행 대기 상태가 됩니다. |
위 메서드들 중 stop(), suspend(), resume()은 쓰레드를 교착 상태(dead-lock)상태로 만들기 쉽기 때문에 deprecated 되었습니다.
| 상태 | 설 명 |
| NEW | 쓰레드가 생성되고 아직 start()가 호출되지 않은 상태 |
| RUNNABLE | 실행 중 또는 실행 가능한 상태 |
| BLOCKED | 동기화 블럭에 의해서 일시정지된 상태(lock이 풀릴 때까지 기다리는 상태) |
| WATING, TIMED_WATING |
쓰레드의 작업이 종료되지는 않았지만 실행가능하지 않은 일시정지 상태. (TIME_WATING은 일시정지 시간이 지정된 경우를 의미합니다) |
| TERMINATED | 쓰레드의 작업이 종료된 상태 |
다음은 쓰레드의 생성부터 소멸까지의 모든 과정을 그린 것인데, 앞서 소개한 메서드들에 의해서 쓰레드의 상태가 어떻게 변화되는지를 잘 보여줍니다.

- 쓰레드를 생성하고 start()를 호출하면 바로 실행되는 것이 아니라 실행 대기열에 저장되어 자신의 차례가 될 때까지 대기합니다.
(실행 대기열은 큐(queue)와 같은 구조로 먼저 실행 대기열에 들어온 쓰레드가 먼저 실행됩니다.) - 실행 대기 상태에 있다가 자신의 차례가 되면 실행상태가 됩니다.
- 주어신 실행 시간이 다되거나 yield()를 만나면 다시 실행 대기 상태가 되고 다음 차례의 쓰레드가 실행상태가 됩니다.
- 실행 중에 suspend(), sleep(), wait(), join(), I/O block에 의해 일시 정지 상태가 될 수 있습니다.
(I/O block은 입출력 작업에서 발생하는 지연 상태를 말합니다.) - 지정된 일시 정지 시간이 다되거나(time-out), notify(), resume(), interrupt()가 호출되면 일시 정지 상태를 벗어나 다시 실행 대기열에 저장되어 자신의 차례를 기다립니다.
- 실행을 모두 마치거나 stop()이 호출되면 쓰레드는 소멸됩니다.
자바의 쓰레드 상태는 실행 중일 때와 실행 대기중 모두 'RUNNABLE'로 표현합니다.
sleep(long millis)
sleep()은 지정된 시간동안 쓰레드를 멈추게 합니다.
밀리초와 나노초의 시간 단위로 세밀하게 값을 지정할 수 있지만 어느정도의 오차가 발생할 수 있다는 것은 염두에 둬야 합니다.
try {
Thread.sleep(1, 500000);
} catch (InterruptedException e) { }
위 코드는 쓰레드를 0.0015초동안 일시정지하는 코드로, sleep()에 의해 일시정지된 쓰레드는 지정된 시간이 다 되거나 interrupt()가 호출되면(InterruptedException), 잠에서 깨어나 실행대기 상태가 됩니다.
그래서 sleep()을 호출할 때는 항상 try-catch문으로 예외 처리를 해줘야 합니다. 매번 예외 처리를 해주는 것이 번거롭기 때문에, 아래와 같이 try-catch문까지 포함하는 새로운 메서드를 만들어서 사용하기도 합니다.
void delay(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {}
}
중요한 점은 sleep()은 항상 현재 실행 중인 쓰레드에 대해 작동하며, 이 때문에 static메서드로 선언되어 있습니다.
interrupt()
진행 중인 쓰레드의 작업이 끝나기 전에 취소해야 할 때가 있습니다.
예을 들어 큰 파일을 다운로드받을 때 시간이 너무 오래 걸리면 중간에 다운로드를 포기하고 취소할 수 있어야 합니다. interrupt()는 쓰레드에게 작업을 멈추라고 요청합니다. 단지 멈추라고 요청만 하는 것일 뿐 쓰레드를 강제로 종료시키지는 못합니다. interrupt()는 그저 쓰레드 내부의 boolean 플래그(interrupted)를 변경하는 것일 뿐입니다.
/* Interrupt state of the thread - read/written directly by JVM */
private volatile boolean interrupted;
그리고 쓰레드의 interrupted 상태를 반환하는 메서드도 있습니다.
boolean isInterrupted()
static boolean interrupted() // 상태 초기화
위 두 메서드는 모두 쓰레드의 interrupted 상태를 반환합니다. 차이점은 interrupted()는 상태를 반환 후 플래그를 false로 초기화한다는 것입니다.
앞서 말했듯이 interrupt()는 그저 플래그만 변경하는 메서드지만, 쓰레드의 상태에 따라 다르게 동작합니다.
쓰레드가 sleep(), wait(), join()에 의해 '일시정지 상태(WATING)'에 있을 때, 해당 쓰레드에 대해 interrupt()를 호출하면, sleep(), wait(), join()에서 InterruptedException이 발생하고 쓰레드는 '실행대기 상태(RUNNABLE)'로 바뀝니다. 그리고 InterruptedException이 발생하면 interrupted 상태는 false로 자동 초기화됩니다.
sleep(), wait(), join() 이 세 메서드는 모두 내부에서 인터럽트 플래그(interrupted)가 true이면 InterruptedException을 던집니다.
그외에 쓰레드 상태가 RUNNABLE이거나 BLOCK이면 동작의 변화 없이 그저 interrupted 플래그만 변경됩니다.
따라서 실행 중 프로그램을 중지하고 싶다면 다음과 같이 작성해야 합니다.
class MyThread implements Runnable {
public void run() {
while(!isInterrupted()){
// 작업
}
}
}
suspend(), resume(), stop()
suspend()는 sleep()처럼 쓰레드를 멈추게 합니다. suspend()에 의해 정지된 쓰레드는 resume()을 호출해야 다시 실행대기 상태가 됩니다. stop()은 호출되는 즉시 쓰레드가 종료됩니다.
suspend(), resume(), stop()은 쓰레드의 실행을 제어하는 가장 손쉬운 방법이지만, suspend()와 stop()이 교착상태(deadlock)을 일으키기 쉽게 작성되어있으므로 사용이 권장되지 않습니다. 그래서 이 메서들은 모두 'deprecated' 되었습니다.
따라서 위 기능들을 사용하고 싶다면 다음과 같이 stopped와 suspended라는 boolean타입의 변수를 선언하고, 이를 사용하여 조건식을 작성하는 것이 좋습니다.
class ThreadEx implements Runnable {
volatile boolean suspended = false;
volatile boolean stopped = false;
Thread th;
ThreadEx(String name) {
th = new Thread(this, name);
}
public void run() {
while(!stopped) {
if (suspended) continue;
// 작업
try {
Thread.sleep(1000);
} catch (InterruptedException e) {}
}
}
public void suspend() { suspended = true; }
public void resume() { suspended = false; }
public void stop() { stopped = true; }
public void start() { th.start(); }
}
한 가지 주의할 점은 boolean 변수 앞에 'volatile'을 붙여야 합니다. 이 키워드에 대해서는 뒤에서 살펴보겠습니다.
yield()
yield()는 쓰레드 자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보(yield)합니다.
예를 들어 스케줄러에 의해 1초의 실행시간을 할당받은 쓰레드가 0.5초의 시간동안 작업한 상태에서 yield()가 호출되면, 나머지 0.5초는 포기하고 다시 실행대기상태가 됩니다.
yield()와 interrupt()를 적절히 사용하면, 프로그램의 응답성을 높이고 보다 효율적인 실행이 가능하게 할 수 있습니다. 이전 예시를 수정해 응답성을 높여보겠습니다.
class ThreadEx implements Runnable {
volatile boolean suspended = false;
volatile boolean stopped = false;
Thread th;
ThreadEx(String name) {
th = new Thread(this, name);
}
public void run() {
while(!stopped) {
if (suspended) {
Thread.yield();
continue;
}
// 작업
try {
Thread.sleep(1000);
} catch (InterruptedException e) {}
}
}
public void suspend() {
suspended = true;
th.interrupt();
}
public void resume() { suspended = false; }
public void stop() {
stopped = true;
th.interrupt();
}
public void start() { th.start(); }
}
위 코드를 보면 if문에서 suspended 상태이면 yield()를 호출하는 코드가 추가되었습니다. 이전에는 suspended 상태일 때 쓰레드가 주어진 실행시간을 그전 while문을 의미없이 돌면서 낭비하는 '바쁜 대기상태(busy-waiting)'였지만, yield()를 호출함으로써 남은 실행 시간을 다른 쓰레드에게 양보하여 더 효율적입니다.
또 한 가지 달라진 점은 suspend()와 stop()에 interrupt()를 호출하는 코드를 추가하였습니다. 이전에는 만일 stop()이 호출되었을 때 Thread.sleep(1000)에 의해서 쓰레드가 일시정지 상태에 머물러 있는 상황이라면, stopped 상태로 변경되었더라도 sleep()의 일시정지 시간이 모두 끝날때까지 기다렸어야 했습니다.
그러나 같은 상황에서 interrupt()를 호출하면, sleep()에서 InterruptedException이 발생하여 즉시 일시정지 상태에서 벗어나게 되므로 응답성이 좋아집니다.
join()
쓰레드 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때 join()을 사용합니다. 시간을 지정하지 않으면, 해당 쓰레드가 작업을 모두 마칠 때까지 기다리게 됩니다. 작업중에 다른 쓰레드의 작업이 먼저 수행되어야할 필요가 있을 때 join()을 사용합니다.
join()은 자신의 작업 중간에 다른 쓰레드의 작업을 참여(join)시킨다는 의미로 이름 지어진 것입니다.
join()도 sleep()처럼 interrupt()에 의해 대기상태에서 벗어날 수 있으며, join()이 호출되는 부분을 try-catch문으로 감싸야 합니다.
try {
th1.join(); // 현재 실행중인 쓰레드가 th1의 작업이 끝날 때까지 기다립니다.
} catch(InterruptedException e) { }
join()은 sleep()과 유사한 점이 많지만, 차이점은 join()은 현재 쓰레드가 아닌, 특정 쓰레드에 대해 동작하므로 static메서드가 아닙니다.
쓰레드의 동기화
싱글 쓰레드 프로세스의 경우 프로세스 내에서 단 하나의 쓰레드만 작업하기 때문에 프로세스의 자원을 가지고 작업하는데 별 문제가 없지만, 멀티 쓰레드 프로세스의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 됩니다. 만일 쓰레드A가 작업하던 도중 다른 쓰레드B에게 제어권이 넘어갔을 때, 쓰레드A가 작업하던 공유데이터를 쓰레드B가 임의로 변경하였다면, 다시 쓰레드A가 제어권을 받아서 나머지 작업을 마쳤을 때 원래 의도했던 것과는 다른 결과를 얻을 수 있습니다.
이러한 일이 발생하는 것을 방지하기 위해서 한 쓰레드가 특정 작업을 끝마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 하는 것이 필요합니다. 그래서 도입된 개념이 바로 '임계 영역(critical section)' 과 '잠금(lock)'입니다.
공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정해놓고, 공유 데이터(객체)가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 모든 코드를 수행하고 영역을 벗어나서 lock을 반납해야만 다른 쓰레드가 반납된 lock을 획득하여 임계 영역의 코드를 수행할 수 있게 됩니다.
이처럼 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 '쓰레드의 동기화(synchronization)'라고 합니다. 자바에서는 synchronized블럭을 이용해서 쓰레드의 동기화를 지원했지만, JDK 5부터는 'java.util.concurrent.locks'와 'java.util.concurrent.atomic'패키지를 통해서 다양한 방식으로 동기화를 구현할 수 있도록 지원하고 있습니다.
synchronized를 이용한 동기화
먼저 가장 간단한 동기화 방법인 synchronized 키워드를 이용한 동기화에 대해서 알아보겠습니다. 이 키워드는 임계 영역을 설정하는데 사용됩니다. 이래와 같이 두 가지 방식이 있습니다.

첫 번째 방법은 메서드 앞에 synchronized를 붙이는 것인데, synchronized를 붙이면 메서드 전체가 임계 영역으로 설정됩니다. 쓰레드는 synchronized메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환합니다.
두 번째 방법은 메서드 내의 코드 일부를 블럭{ }으로 감싸고 블럭 앞에 'synchronized(참조 변수)'를 붙이는 것인데, 이때 참조변수는 락을 걸고자하는 객체를 참조하는 것이어야 합니다. 이 블럭은 synchronized블럭이라고 부르며, 이 블럭의 영역 안으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게 되고, 이 블럭을 벗어나면 lock을 반납합니다.
이러한 임계 영역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 가능하면 메서드 전체에 락을 거는 것보다 synchronized블럭으로 임계 영역을 최소화해서 보다 효율적인 프로그램이 되도록 해야합니다.
Monitor
자바에서 사용하는 동기화 단위를 '모니터(Monitor)'라고 합니다. 자바 언어 명세서(JLS)에서는 이를 다음과 같이 설명합니다.
Every object in Java is associated with a monitor, which a thread can lock or unlock.
즉, 자바의 모든 객체는 모니터 락을 가진다는 의미입니다.
객체는 new 키워드를 통해 힙 메모리에 생성될 때 객체의 메타데이터를 저장하는 객체 헤더(Object Header)가 만들어집니다. 객체 헤더에서는 객체의 Lock 상태를 관리하는데, 락 경쟁의 정도에 따라 다음과 같은 락 상태 단계를 거칩니다.

락 경쟁이 심해질수록 가벼운 락에서 무거운 락으로 승격되며, 그 중 마지막 단계인 Heavyweight Lock은 모니터(Monitor) 에 의해 관리됩니다
이 모니터는 JVM 내부의 ObjectMonitor(C++ 구조체) 를 기반으로 구현되어 있으며, OS 수준의 뮤텍스(mutex) 를 사용해 스레드 간 동기화를 수행합니다. ObjectMonitor 구조체는 내부적으로 락을 기다리는 쓰레드 목록(EntryList), wait() 호출로 대기 중인 쓰레드 목록(WaitSet)들을 관리하며, synchronized, wait(), notify() 는 내부적으로 모두 이 모니터를 통해 락 획득, 대기, 해제 등의 동작을 관리합니다.
wait()과 notify()
synchronized로 동기화해서 공유 데이터를 보호하는 것도 중요하지만, 특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것도 중요합니다. 만일 계좌에 출금할 돈이 부족해서 한 쓰레드가 락을 보유한 채로 돈이 입금될 때까지 오랜 시간을 보낸다면, 다른 쓰레드들은 모두 해당 객체의 락을 기다리느라 다른 작업들도 원활히 진행되지 않을 것입니다.
이것을 개선하기 위한 것이 바로 wait()과 notify()입니다. 동기화된 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니며, 일단 'wait()'을 호출하여 쓰레드가 락을 반납하고 기다리게 합니다. 그러면 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게됩니다. 나중에 작업을 진행할 수 있는 상황이 되면 'notify()'를 호출해서, 작업을 중단했던 쓰레드가 다시 락을 얻어 작업을 진행할 수 있도록 합니다.
wait(), notify(), notifyAll()은 모두 모니터를 조작하기 때문에 synchronized블럭(메서드) 안에서만 호출해야합니다.
다음은 실제 메서드 호출 시 수행하는 동작들입니다.
- wait() → 현재 스레드를 모니터의 WaitSet(대기 중인 쓰레드 목록) 으로 이동
- notify() → WaitSet 중 하나를 EntryList(락을 기다리는 쓰레드 목록)로 옮김 (깨움)
- notifyAll() → WaitSet 전부를 EntryList로 이동
wait()과 notify()는 특정 객체에 대한 것이므로 Thread가 아닌 Object클래스에 정의되어 있습니다.
void wait()
void wait(long timeout)
void wait(long timeout, int nanos)
void notify()
void notifyAll()
기아 현상과 경쟁 상태
wait()을 호출하면 현재 쓰레드가 WaitSet으로 이동하고, 그 다음 준비가 되면 notify()를 호출해서 쓰레드를 EntryList로 옮깁니다. 그러나 이때 Set은 순서를 보장하지 않기 때문에 정말 운이 나쁘면 계속 EntryList로 이동되지 못하고 오랫동안 기다리게 됩니다. 이것을 '기아(starvation) 현상' 이라고 합니다.
기아 현상을 막으려면 모든 쓰레드에게 통지를 하는 notifyAll()을 사용해야 합니다. 그러면 일단 원하는 쓰레드도 EntryList로 이동할 수 있기 때문에 기다리면 결국 lock을 얻어 작업을 진행할 수 있습니다.
notifyAll()을 사용하면 기아 현상은 막을 수 있지만, 불필요한 다른 쓰레드들까지 모두 EntryList로 이동되어 원하는 쓰레드와 lock을 얻기 위해 경쟁하게 됩니다. 이처럼 여러 쓰레드가 lock을 얻기 위해 서로 경쟁하는 것을 '경쟁 상태(race condition)'이라고 합니다.
경쟁 상태를 개선하기 위해서는 쓰레드를 구별해서 통지하는 것이 필요하는데, 이는 Lock과 Condition을 이용하면 가능합니다.
Lock과 Condition을 이용한 동기화
동기화할 수 있는 방법은 synchronized블럭 외에도 'java.util.concurrent.locks'패키지가 제공하는 lock클래스들을 이용하는 방법이 있습니다. 이 패키지는 JDK 5에 와서야 추가된 것으로 그 전에는 동기화 방법이 synchronized블럭뿐이었습니다.
synchronized블럭으로 동기화를 하면 자동적으로 lock이 잠기고 풀리기 때문에 편리합니다. 심지어 synchronized블럭 내에서 예외가 발생해도 lock은 자동적으로 풀립니다. 그러나 때로는 같은 메서드 내에서만 lock을 걸 수 있다는 제약이 불편하기도 합니다. 그럴 때 이 lock클래스를 사용합니다. lock클래스는 다음과 같이 3가지 종류가 있습니다.
- ReentrantLock : 재진입이 가능한 lock으로 가장 일반적인 배타 lock
- ReentrantReadWriteLock : 읽기에는 공유적이고, 쓰기에는 배타적인 lock
- StampedLock: ReentrantReadWriteLock에 낙관적인 lock의 기능을 추가
StampedLock은 JDK 8부터 추가되었으며, 다른 lock과 달리 Lock 인터페이스를 구현하지 않았습니다.
ReentrantLock은 가장 일반적인 lock입니다. 'reentrant(재진입할 수 있는)'이라는 단어가 앞에 붙은 이유는 앞서 wait() & notify()에서 배운 것처럼, 특정 조건에서 lock을 풀고 나중에 다시 lock을 얻어서 임계영역으로 들어와 이후의 작업을 수행할 수 있기 때문입니다. 앞에서 불러왔던 lock과 일치합니다.
ReentrantReadWriteLock은 이름에서 알 수 있듯이, 읽기를 위한 lock과 쓰기를 위한 lock을 제공합니다. ReentrantLock은 배타적인 lock이라서 무조건 lock이 있어야만 임계영역의 코드를 수행할 수 있었지만, ReentrantReadWriteLock은 읽기 lock이 걸려있으면, 다른 쓰레드가 읽기 lock을 중복해서 걸고 읽기를 수행할 수 있습니다. 읽기는 내용을 변경하지 않으므로 동시에 여러 쓰레드가 읽어도 문제가 되지 않습니다. 그러나 읽기 lock이 걸린 상태에서 쓰기 lock을 거는 것은 허용되지 않습니다. 반대의 경우도 마찬가지입니다.
StampedLock은 lock을 걸거나 해지할 때 '스탬프(long타입의 정수값)'를 사용하며, 읽기와 쓰기를 위한 lock외에 '낙관적 읽기 lock(optimistic reading lock)'이 추가된 것입니다. 읽기 lock이 걸려있으면, 쓰기 lock을 얻기 위해서는 읽기 lock이 풀릴 때까지 기다려야하는데 비해 '낙관적 읽기 lock'은 쓰기 lock에 바로 풀립니다. 그래서 낙관적 읽기에 실패하면, 읽기 lock을 얻어서 다시 읽어 와야 합니다. 무조건 읽기 lock을 거는 것이 아닌, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 lock을 거는 것입니다.
ReenteranceLock의 생성자
ReentrantLock은 다음과 같이 두 개의 생성자를 가지고 있습니다.
ReentrantLock()
ReentrantLock(boolean fair)
생성자의 매개변수를 true로 주면, lock이 풀렸을 때 가장 오래 기다린 쓰레드가 lock을 획득할 수 있게, 즉 공정(fair)하게 처리합니다. 그러나 공정하게 처리하려면 어떤 쓰레드가 가장 오래 기다렸는지 확인하는 과정을 거칠 수 밖에 없기 때문에 성능은 떨어집니다. 따라서 대부분의 경우 공정함보다는 성능을 선택합니다.
void lock()
void unlock()
boolean isLocked()
자동적으로 lock의 잠금과 해제가 관리되는 synchronized블럭과 달리 ReentrantLock과 같은 lock클래스들은 수동으로 lock을 잠그고 해제해야 합니다. 따라서 lock을 걸고 나서 푸는 것을 잊어버리지 않도록 주의해야 합니다.
임계영역 내에서 예외가 발생하거나 return문으로 빠져나가게 되면 lock이 풀리지 않을 수도 있으므로 다음과 같이 try-finally문을 사용하는게 일반적입니다.
lock.lock();
try {
// 임계 영역
} finally {
lock.unlock();
}
이외에도 tryLock()이라는 메서드가 있는데, 이 메서드는 lock()과 달리, 다른 쓰레드에 의해 lock이 걸려있으면 lock을 얻으려고 기다리지 않습니다. 또는 지정된 시간만큼만 기다립니다. 그 후 lock을 얻으면 true, 얻지 못하면 false를 반환합니다.
boolean tryLock()
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException
lock()은 lock을 얻을 때까지 쓰레드를 블로킹(blocking)시키므로 쓰레드의 응답성이 나빠질 수 있습니다. 응답성이 중요한 경우, tryLock()을 이용해서 지정된 시간동안 lock을 얻지 못하면 다시 작업을 시도할 것인지 포기할 것인지를 사용자가 결정할 수 있게 하는것이 좋습니다.
그리고 이 메서드는 InterruptedException을 발생시킬 수 있는데, 이는 지정된 시간동안 lock을 얻으려고 기다리는 중에 interrupt()에 의해서 작업을 취소되면 InterruptedException을 전파하도록 구현되어있기 때문입니다.
ReentrantLock과 Condition
앞서 wait() & notify()에 대해 살펴볼 때 원하는 쓰레드를 깨우지 못한다는 단점이 있었습니다. Condition은 이런 문제점을 해결하기 위한 것입니다. Condition은 다음과 같이 이미 생성된 lock으로부터 newCondition()을 호출해서 생성합니다.
private ReentrantLock lock = new ReentrantLock();
private Condition forCustomer = lock.newCondition();
private Condition forSeller = lock.newCondition();
Condition에는 wait() & notify()와 대응되는 await() & signal()이 있습니다.
| Object | Condition |
| void wait() | void await() void awaitUninterruptibly() |
| void wait(long timeout) | boolean await(long time, TimeUnit unit) long awaitNanos(long nanosTimeout) boolean awaitUntil(Date deadline) |
| void notify() | void signal() |
| void notifyAll() | void signalAll() |
Condition은 내부적으로 ReentrantLock과는 별개의 대기 큐를 갖고 있기 때문에 같이 대상을 지정해서 멈추고 깨울 수 있습니다.
forSeller.await();
...
forCustomer.signal();
Condition을 더 세분화하여 사용하면, 원하는 쓰레드가 통지를 못받는 기아 상태나, 불필요한 쓰레드들과 경쟁하는 경쟁 상태를 더 확실히 개선할 수 있습니다.
volatile
이전 글인 `[Java 21] Thread 1` 에서 작성한 예제입니다.
class ThreadEx implements Runnable {
boolean suspended = false;
boolean stopped = false;
Thread th;
ThreadEx(String name) {
th = new Thread(this, name);
}
public void run() {
while(!stopped) {
if (suspended) continue;
// 작업
try {
Thread.sleep(1000);
} catch (InterruptedException e) {}
}
}
public void suspend() { suspended = true; }
public void resume() { suspended = false; }
public void stop() { stopped = true; }
public void start() { th.start(); }
}

멀티코어 프로세서에서는 코어마다 별도의 캐시를 갖고 있고, 코어는 메모리에서 읽어온 값을 캐시에 저장하고 캐시에서 값을 읽어서 작업합니다. 다시 같은 값을 읽어올 때는 먼저 캐시에 있는지 확인하고 없을 때만 메모리에서 읽어옵니다.
그러다보니 도중에 메모리에 저장된 변수의 값이 변경되었는데도 캐시에 저장된 값이 갱신되지 않아서 메모리에 저장된 값이 다른 경우가 발생합니다. 그래서 위 예제에서 변수 stopped의 값이 바뀌었는데도 쓰레드가 멈추지 않고 계속 실행되는 것입니다.
이 문제는 멀티 쓰레드 환경에서의 문제가 아닌 멀티 코어 환경에서의 문제로, 싱글 코어 멀티 쓰레드 환경에서는 발생하지 않습니다.
그러나 volatile을 붙여주면, 코어가 변수의 값을 읽어올 때 캐시가 아닌 메모리에서 읽어오기 때문에 캐시와 메모리간의 값의 불일치가 해결됩니다.
volatile boolean suspended = false;
volatile boolean stopped = false;
위 예제는 변수에 volatile을 붙이는 대신에 synchronized블럭을 사용해도 같은 효과를 얻을 수 있습니다. 쓰레드가 synchronized블럭으로 들어갈 때와 나올 때, 캐시와 메모리간의 동기화가 이루어지기 때문에 값이 불일치가 해소됩니다.
public synchronized void stop() {
stopped = true;
}
volatile로 long과 double을 원자화
자바 언어 명세서(JLS)에는 다음과 같이 명시되어 있습니다.
or the purposes of the Java programming language memory model,
a single write to a non-volatile long or double value is treated as two separate writes
이는 long 또는 double 값에 대한 단일 쓰기 연산은 각각 32비트 두 개의 연산으로 다뤄질 수 있다는 뜻입니다.
JVM은 데이터를 4byte(32bit)단위로 처리하기 때문에, int와 int보다 작은 타입들은 한 번에 읽거나 쓰는 것이 가능합니다. 즉, 단 하나의 명령어로 읽거나 쓰기가 가능하다는 뜻입니다. 하나의 명령어는 더 이상 나눌 수 없는 최소의 작업단위이므로, 작업의 중간에 다른 쓰레드가 끼어들 틈이 없습니다.
그러나 크기가 8byte인 long과 double타입의 변수는 하나의 명령어로 값을 읽거나 쓸 수 없기 때문에, 변수의 값을 읽는 과정에서 다른 쓰레드가 끼어들 여지가 있습니다. 다른 쓰레드가 끼어들지 못하게 하려고 변수를 읽고 쓰는 모든 문장을 synchronized블럭을 감쌀 수도 있지만, 더 간단하게 변수를 선언할 때 volatile을 선언하면 됩니다.
상수는 변하지 않는 값이기 때문에 thread-safe하여 volatile을 붙일 수 없습니다.
fork & join 프레임워크
2000년대 초반 이전까지 CPU의 속도는 매년 거의 2배씩 빠르게 향상되어왔습니다. 그러나 이제 그 한계에 도달하여 속도 보다는 코어의 개수를 늘려서 CPU의 성능을 향상시키는 방향으로 발전해 가고 있습니다. 이러한 하드우어의 변화에 발맞춰 프로그래밍도 멀티 코어를 잘 활용할 수 있는 멀티쓰레드 프로그래밍이 점점 더 중요해지고 있습니다.
그래서 JDK 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> {
...
protected abstract V compute();
...
}
메서드를 구현하고 나면 쓰레드 풀과 수행할 작업을 생성하고, invoke()로 작업을 시작합니다.
class SumTask extends RecursiveTask<Long> {
long from, to;
SumTask(long from, long to) {
this.from = from;
this.to = to;
}
public Long compute() {
// 구현
}
}
public static void main(String[] args) {
ForkJoinPool pool = new ForkJoinPool();
SumTask task = new SumTask(from, to);
Long result = pool.invoke(task);
}
ForkJoinPool
ForkJoinPool은 fork & join 프레임워크에서 제공하는 쓰레드 풀(thread pool)로, 병렬 처리용 스레드 풀입니다. 핵심 아이디어는 "작업을 쪼개고(fork), 끝나면 합친다(join)"는 겁니다. 쓰레드 풀은 쓰레드가 수행해야하는 작업이 담긴 큐를 제공하며, 각 쓰레드는 자신의 작업 큐에 담긴 작업을 순서대로 처리합니다.
쓰레드 풀은 기본적으로 코어의 개수와 동일한 개수의 쓰레드를 생성합니다.
work stealing
fork()가 호출되어 작업 큐에 추가된 작업 역시, compute()에 의해 더 이상 나눌 수 없을 때까지 반복해서 나뉘고, 자신의 작업 큐가 비어있는 쓰레드는 다른 쓰레드의 작업 큐에서 작업을 가져와서 수행합니다. 이것을 '작업 훔쳐오기(work stealing)'라고 하며, 이 과정은 모드 쓰레드풀에 의해 자동적으로 이루어집니다. 이런 과정을 통해 한 쓰레드에 작업이 몰리지 않고, 여러 쓰레드가 골고루 작업을 나누어 처리하게 됩니다.
fork()와 join()
fork()와 join()은 RecursiveAction과 RecursiveTask의 조상 인터페이스인 ForkJoinTask의 메서드로, 요약하면 다음과 같습니다.
public final ForkJoinTask<V> fork() {
workQueue.push(this, w.pool);
return this;
}
public final V join() {
int s = awaitDone(null, false, false, false, 0L);
return getRawResult();
}
fork()는 작업을 쓰레드의 작업 큐에 넣는 것이고, 작업 큐에 들어간 작업음 더 이상 나눌 수 없을 때까지 나뉩니다. 즉, compute()로 나누고 fork()로 작업 큐에 넣는 작업이 계속 반복됩니다. 그리고 나눠진 작업은 골고루 나눠서 처리하고, 작업의 결과는 join()을 호출해서 얻을 수 있습니다.
fork()와 join()의 중요한 차이점이 하나 있는데, 그것은 바로 fork()는 비동기 메서드이고, join()은 동기 메서드라는 것입니다. 비동기 메서드인 fork()는 일반적인 메서드와 달리 메서드를 호출만 할 뿐, 그 결과를 기다리지 않습니다. 반면 join()은 동기 메서드로, 작업이 다 끝날 때까지 기다렸다가 결과를 반환합니다. 이때 결과를 기다리는 동안 쓰레드는 블로킹됩니다.
compute()
compute()를 구현할 때는 수행할 작업 외에도 작업을 어떻게 나눌 것인가에 대해서도 알려줘야 합니다.
public Long compute() {
long size = to - from + 1;
if(size <= 5)
return sum();
long half = (from + to) / 2;
SumTask leftSum = new SumTask(from, half);
SumTask rightSum = new SumTask(half + 1, to);
leftSum.fork();
return rightSum.compute() + leftSum.join();
}
실제 수행할 작업은 sum()뿐이고 나머지는 수행할 작업의 범위를 반으로 나눠서 새로운 작업을 생성해서 실행시키기 위한 것입니다. 좀 복잡해 보이지만, 성능·작업 수·스틸(steal) 확률을 최적화하기 위해 하나는 fork하여 다른 쓰레드에게 맡기고, 다른 하나는 현재 쓰레드에서 compute 후에 join하는 것이 전형적인 패턴입니다.
여기서는 지정된 범위를 절반으로 나누어서 나눠진 범위의 합을 계산하기 위한 새로운 SumTask를 생성하는데, 이 과정은 작업이 더 이상 나눠질 수 없을 때까지, size의 값이 5보다 작거나 같을 때까지 반복됩니다. 이는 일번적인 재귀호출 메서드의 구조와 동일합니다.
다음은 이해를 돕기 위해 1부터 8까지의 숫자를 더하는 과정을 그림으로 그린 것입니다.

'Lang > Java' 카테고리의 다른 글
| [Java 21] (15) - Lambda & stream (0) | 2025.11.17 |
|---|---|
| [Java 21] (14) - thread 2 (0) | 2025.11.13 |
| [Java 21] (12) - modern Java features (0) | 2025.11.05 |
| [Java 21] (11) - collections framework (0) | 2025.11.03 |
| [Java 21] (10) - date, time and formatting (0) | 2025.10.31 |