안녕하세요, 지난 글에서 스레드의 기본 개념에 대해서 공부하며, 하나의 프로그램 안에서 여러 작업이 동시에 실행될 수 있다는 점을 배웠습니다. 오늘은 멀티스레드 환경에서 스레드가 어떤 상태를 거치며 실행이 되는지, 그리고 여러 스레드가 동시에 공유 자원에 접근하면 발생할 수 있는 문제 등에 대해서 공부하려고 합니다.
멀티스레드 환경에서 중요한 스레드 간의 충돌을 방지하고, 프로그램의 안정성을 보장하기 위해 반드시 필요한 개념인 동기화(synchronization)가 왜 중요한지도 함께 알아볼 예정입니다. 따라서 이번 내용을 통해 멀티스레드의 유용함과, 위험성에 대해서 알아보고 한 단계 더 깊이 이해하는 것이 목표입니다.
스레드 상태
스레드에는 총 6가지의 상태로 나누어지며, 각 상태는 스레드가 실행되고 종료되기까지의 과정을 나타냅니다.

| 스레드 상태 | |
| New(새로운 상태) | new Thread(); 스레드가 생성 했으나, 아직 시작하지 않음. |
| Runnable(실행 가능 상태) | thread.start(); - 스레드가 실행 중이거나 실행될 준비가 된 상태. - 해당 상태에 있는 모든 스레드가 동시에 실행 하지는 않고, 운영체제의 스케줄러가 각 스레드 에 CPU 시간을 할당하여 실행. 즉 해당 상태에 있는 스레드는 스케줄러 큐에 포함돼 있다가 차례대로 CPU에서 실행됨. 이 때 CPU에서 실행중인 스레드와 스케줄러의 실행 대기열의 스레드드들의 스위칭 속도는 매우 빠르기에 구별이 힘듬. - 일시 중지 상태들은 Runnable 상태가 아님. |
| Terminated (종료) | - 정상적으로 종료(run() 종료) 되거나 예외가 발생하여 종료된 경우의 상태. - 스레드는 한 번 종료되면 다시 시작할 수 없음 |
| Blocked(차단 상태) | - 스레드가 다른 스레드에 의해 동기화 락을 기다리는 상태 (코드 실행x) - synchronized 블록에 진입하기 위해 락을 얻어야 하는 경우나, 다른 스레드가 이미 락을 갖고 있는 경우 해당 상태가 됨. - 락이 해제되면 자동으로 깨어나며, 락을 얻을때까지 강제 대기 ex) 화장실 문 앞에서 줄 서서 기다리는 상태(문이 열리면 바로 들어감) |
| Waitng(대기 상태) | - 스레드가 다른 스레드의 작업을 무기한 기다리는 상태(코드 실행x) - wait(), join() 메서드가 호출될때의 상태 - notify, notifyAll, 대상 종료 등으로 깨어나며, 누군가 깨워주기전까지 대기 ex) 친구가 부를 때까지 방에서 가만히 기다리는 상태(누군가 불러주어야 움직임) |
| Time Wating(시간 제한 대기 상태) | - 스레드가 특정시간 동안 다른 스레드의 작업을 기다리는 상태(코드 실행x) - sleep(long millis). wait(long timeout), join(long millis) 메서드가 호출될때의 상태 - 주어진 시간이 경과하거나, 다른 스레드가 해당 스레드를 깨우면 해제 |
그러면 스레드의 작업은 어떻게 중단시킬 수 있을까요? 특정 스레드의 작업을 중단시키는 가장 일반적인 방법은 공유 변수(flag) 또는 interrupt()를 사용하는 것입니다.
yield()
Thread.yield()는 현재 실행 중인 스레드가 "CPU를 다른 스레드에게 양보할 의사가 있다"라는 힌트를 운영체제에 전달합니다. 여기서 "힌트"라고 하는 이유는 해당 메서드를 호출한다고 해서 CPU의 스레드가 바로 전환이 될지는 모르며 오로지 스케쥴러가 결정하기 때문입니다. 만약 yield()를 호출해 CPU 실행 스레드가 바뀐다면 현재 스레드는 CPU를 잠시 내려놓고 RUNNABLE 상태를 유지한 채 다시 스케줄링 큐에 들어가, 다시 실행될 수 있습니다.
정리하자면, RUNNING상태는 스레드가 CPU에서 실행 중임을 뜻하며, RUNNABLE 상태는 실행 대기 중인 상태로, 스케줄링 큐에서 CPU 실행을 위해 대기 중입니다. 하지만 서로 전환되는 시간은 매우 짧기에 자바에서는 이 두 상태를 구분할 수는 없습니다.
sleep()
sleep()은 특정 시간 동안 스레드를 일시 정지 상태(TIMED_WAITING)로 만듭니다. 즉 해당 메서드를 호출하면, 현재 스레드는 RUNNABLE -> TIMED_WAITING 상태로 이동하며, 지정된 시간 동안 CPU 자원을 전혀 사용하지 않습니다. 이후 지정된 시간이 지나면 다시 RUNNABLE 상태로 복귀합니다. 이렇게 되면 해당 스레드는 강제로 실행 스케줄링에서 제외되고, 다른 스레드가 CPU를 사용할 수 있게 됩니다.
yield()와 다르게 무조건 다른 스레드의 실행 기회를 보장하지만, 이는 양보할 스레드가 없는데도 양보를 해 CPU가 쉴수도 있다는 단점도 초래합니다.
interrupt()
thread.interrupt()를 호출하면 해당 스레드의 interrupt의 상태는 true로 설정 됩니다. 그리고 스레드가 대기 메서드(sleep(), wait(), join() 등 ) 상태라면 즉시 깨어나면서 InterruptedException이 발생합니다.
아래의 예시 코드처럼 구현하면, InterruptedException이 발생하게 되면 while문을 나오게 됩니다.
while (!Thread.currentThread().isInterrupted()) {
// 작업 수행
}
여기서 중요한 점은 interrupt()는 스레드를 강제로 종료하는 것이 아니라, 단지 중단 요청 신호를 보내는 것뿐이며, 해당 신호를 받았다고 해서 바로 예외에 걸리는 게 아니라 대기 메서드를 만나야 InterruptedException이 발생하고, 자바는 자동으로 해당 스레드의 interrupt 상태를 false로 초기화한다는 점입니다.
여기서 false로 자동 초기화 하는 이유는, 계속 interrupt 상태가 true라면 sleep() 같은 대기 메서드에 들어갈대마다 계속 예외가 발생하는 문제가 발생합니다. 그래서 interrupt 목적(while 탈출 등)을 달성한 후 더 이상 interrupt가 계속 발생하지 않도록 위한 설계입니다.
스레드의 상태를 확인하는 2가지의 메서드를 간단하게 보겠습니다
thread.isInterrupted()
- 특정 스레드의 interrupt 상태를 확인
- 상태를 변경하지 않음
Thread.interrupted()
- 현재 스레드의 interrupt 상태 확인 후 interrupt 상태면 true , 아니면 false 반환
- 현재 상태가 true일시 false로 초기화
- while문 등 탈출과 동시에 interrupt 상태도 정상화
위 내용에 대한 전체적인 예시 코드를 보겠습니다.
class Worker extends Thread {
@Override
public void run() {
while (!Thread.interrupted()) { // 상태 체크 + 초기화
try {
System.out.println("작업 중");
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("interrupt 발생");
break;
}
}
System.out.println("스레드 정상 종료");
}
}
public class Gpt_Example_Jan_0123 {
public static void main(String[] args) throws InterruptedException {
Worker w = new Worker();
w.start();
Thread.sleep(3000);
w.interrupt();
}
}
작업 중
작업 중
작업 중
interrupt 발생
스레드 정상 종료
직관적인 이해를 위해 새로 생성된 스레드를 w라고 칭하겠습니다. 초기 상태에서 w의 interrupt 상태는 false입니다. 따라서 while문 조건을 통과하여 "작업 중"을 출력한 뒤 Thread.sleep(1000)을 호출해 1초간 대기 상태(Time-Waiting)에 빠집니다.
메인 스레드는 3초 동안 대기 한 후 w.interrupt()를 호출합니다. 이 시점에서 w스레드는 sleep()으로 인해 대기 상태(Time Waiting)에 있으므로 interrupt가 발생하면 바로 다음과 같이 행동합니다.
1. sleep()강제 종료
2. InterruptedException 발생 (catch 구문으로 바로 이동)
3. JVM이 자동으로 w의 interrupt 상태를 정상(fasle)으로 초기화
즉 while문 안에서 sleep()에 의해 RUNNABLE <-> TIM WAITING 상태를 계속 반복하고 있었고, interrupt를 통해 즉시 예외가 발생하여 반복된 루프를 탈출하고 스레드가 종료되어 TERMINATED 상태가 됐습니다.
동시성 컬렉션(concurrency collection)
멀티스레드 환경에서 여러 스레드가 동시에 하나의 컬렉션에 접근하면, 일반적인 컬렉션 프레임워크(ArrayList, LinkedList, HashMap 등)들은 안전하지 않습니다. 이 경우 동시성 문제(데이터 손상, 예외 발생 등)가 발생할 수 있기 때문에, 반드시 동시성을 지원하는 컬렉션을 사용해야 합니다. 대표적인 예시가 ConcurrentLinkedQueue 입니다. 해당 클래스는 내부적으로 동기화 처리가 되어있어 동시에 접근해도 안전하게 사용할 수 있습니다.
*여러 스레드가 함께 사용하는 자원을 공유 자원(shared resource) 이라고 하며, 대표적인 공유 자원은 인스턴스 필드(멤버 변수), static 변수, 컬렉션 객체 등이 있습니다.
* ConcurrentLinkedQueue 등의 동시성 컬렉션 프레임워크에 대해서는 다음 글에서 정리하려고 합니다.
일반적인 컬렉션 프레임워크는 왜 안전하지 않을까요? 왜냐하면 애초에 설계 자체가 내부 연산에 동기화가 전혀 없어 스레드에 안전하게 설계되지 않았으며, "한 번에 한 스레드만 접근한다"라는 전제로 만들어진 구조입니다.
간단한 예시 코드를 보겠습니다.
import java.util.*;
public class Main {
public static void main(String[] args) throws Exception {
List<Integer> list = new ArrayList<>();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
list.add(i);
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("리스트 크기: " + list.size());
}
}
저희는 2개의 스레드가 사이즈를 각각 1,000씩 더해주기를 바랬지만, 결과는 실행할 때마다 다르지만, 2000은 나오지 않고 1005, 1300 등 다양한 결과가 나옵니다. 이는 두 스레드가 동시에 size 값을 읽고, 같은 위치에 값을 저장한 뒤 서로의 size 증가 연산이 서로 덮어써지면서 일부 증가가 사라지기 때문입니다. 즉 똑같은 실행을 두 개의 스레드가 중복적으로 한다고 생각 하시면 됩니다.
이처럼 여러 스레드가 하나의 공유 자원에 동시에 접근하는 상황에서는, 한 스레드의 작업이 다른 스레드의 작업에 의해 방해받지 않도록 보장해야 합니다. 이를 위해 필요한 것이 바로 동기화(synchronized)입니다.
동기화는 특정 코드 영역을 한 번에 하나의 스레드만 실행하도록 제한함으로써, 데이터의 일관성과 안전성을 보장해 줍니다.
객체 락(Monitor Lock)
자바의 모든 객체는 자기 자신만의 내부 락(lock)을 한 개씩 가지고 있습니다. 이 락을 모니터 락(Monitor Lock) 또는 내장 락(Intrinsic Lock)이라고 부릅니다. 이 락은 객체 내부에 존재하며, 개발자가 직접 볼 수 없고 synchronized 키워드로만 제어할 수 있습니다.
임계 영역(critical Section)
임계 영역이란 여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작을 일으킬 수 있는 위험하고, 중요한 코드 영역을 의미합니다. 즉 공유 자원을 읽거나, 공유 자원을 수정하는 코드 중에서 동시에 실행하면 안 되는 부분이 임계 영역입니다.
동기화(synchronized)
자바에서는 임계 영역을 보호하기 위해 synchronized 키워드를 제공합니다. 해당 키워드는 순서를 맞춘다는 의미로 한 번에 오직 하나의 스레드만 이 코드를 실행할 수 있다는 뜻입니다. 즉 한 스레드가 동기화된 영역에 들어가면 다른 스레드는 해당 영역이 끝날 때까지 대기해야 합니다.
이는 가장 큰 장점이자 단점이 됩니다. CPU 실행 양보를 보장하는 대신, 여러 스레드가 동시에 실행되지 못하기 때문에 전체적으로는 성능이 떨어질 수밖에 없습니다. 따라서 해당 키워드는 꼭 필요한 영역을 한정해서 설정해야 하며, 임계영역설정 ()에 락으로 들어가는 값은 락을 획득할 인스턴스의 참조입니다.
그러면 아래의 예시 코드를 보겠습니다. synchronized 키워드를 이용한 withdraw()를 호출한 스레드는 해당 BankAccount 인스턴스의 락(lock)을 획득해야만 메서드 안으로 들어갈 수 있습니다.
class BankAccount {
private int balance = 1000;
public synchronized void withdraw(int amount) {
balance -= amount;
}
}
그러면 여러 스레드가 같은 인스턴스를 사용하면 어떻게 될까요?
BankAccount account = new BankAccount();
Thread t1 = new Thread(() -> account.withdraw(100));
Thread t2 = new Thread(() -> account.withdraw(200));
이 경우 t1이 먼저 account 객체의 락을 획득하면, t1은 메서드 호출이 끝나면 락을 반납하고, t2는 락이 풀릴 때까지 무한정 대기 상태(BLOCKED)가 되어 락을 다시 획득하기 전까지 스케줄링 큐에도 들어가지 않고 계속 대기하게 됩니다. 즉 두 스레드가 동시에 balance 값을 수정하지 못하므로, 오류 없이 안전한 데이터 처리가 가능합니다.
위의 코드처럼 synchronized를 사용하면 한 번에 하나의 스레드만 실행하는 안전한 임계 영역 구간을 편리하게 만들 수 있지만, 락을 획득하는 순서는 보장하지 않습니다.
그러면 다른 스레드에게 간섭받지 않는 다른 예시는 뭐가 있을까요?
지역변수(local value)
스택 영역은 각각의 스레드가 가지는 별도의 메모리 공간으로, 이 메모리 공간은 다른 스레드와 공유하지 않습니다.
지역 변수는 스레드의 개별 저장 공간인 스택 영역에 생성되어 절대로 다른 스레드와 공유하지 않기에, 지역 변수는 동기화에 대한 걱정을 하지 않아도 됩니다. 다만 지역 변수를 제외한 인스턴스의 멤버 변수(필드), 클래스 변수 등은 공유될 수 있습니다.

간단한 예시 코드를 보겠습니다.
package self_test;
public class Test {
public static void main(String[] args) {
Runnable task = () -> {
int localValue = 0; //지역 변수(스택에 생성)
for (int i = 0; i < 1000; i++) {
localValue++;
}
System.out.println(Thread.currentThread().getName()
+ " : " + localValue);
};
new Thread(task).start();
new Thread(task).start();
}
}
Thread-1 : 1000
Thread-0 : 1000
위의 지역변수는 각 스레드의 스택 영역에 생성되어, 서로 간섭 받지 않은 것을 확인할 수 있습니다.
그러면 상수 값인 final 필드는 여러 스레드가 접근하면 어떨까요?
final field
fineld field는 우선 여러 스레드가 접근해도 문제가 되지 않습니다. 왜냐하면 여러 스레드가 공유 자원을 읽는 것 자체는 문제가 아니지만, 누군가 그 값을 변경할 때 문제가 발생하는 것이기 때문입니다. 그렇기에 한 번 초기화되면 값 변경이 불가능한 final 필드는 조건부로 매우 안전합니다.
java.util.concurrent
그러면 다시 위의 글을 생각해 보겠습니다. 동기화(synchronized)는 기본으로 제공되는 문법이며, 자동으로 락이 해제되기에 아주 편리한 기능이었습니다. 하지만 그만큼 단점도 있었는데, 어떤 단점이 있었을까요?
- 무한 대기 문제 : synchronized에서 락을 얻지 못한 스레드는 BLOCKED 상태로 들어가, 락이 풀릴 때까지 무한 대기를 하게 됩니다. 이때 타임아웃 설정이 불가능해서 언제 깨어날지 알 수 없었고, 극단적으로는 영원히 실행되지 못할 수도 있었습니다.
- 공정성 문제 : 먼저 기다린 스레드가 먼저 실행됨을 보장하는 것이 아니라, 어떤 스레드가 먼저 락을 얻을지 알 수 없어 특정 스레드가 계속 밀려나는 기아 상태(Starvation) 발생 가능성이 있었습니다.
즉 락 획득 시도, 대기 시간, 중단 처리, 공정성 설정 등을 개발자가 직접 제어할 수 없었기에 단순한 동기화에는 좋지만, 복잡한 동기화에는 한계가 있는 키워드였습니다.
그래서 이러한 문제점들을 해결하기 위해 자바는 java.util.concurrent라는 패키지를 제공합니다. 대표적으로는 ReentrantLock, LockSupport 등이 있고, 해당 패키지들은 더욱 유연하고 세밀한 동기화가 제어 가능하지만 구현이 복잡하고 학습 난이도가 높습니다. 그래서 간단한 경우에는 synchronized를 사용하고, 복잡한 제어가 필요할 경우에는 해당 패키지를 이용합니다.
ReentrantLock
synchronized의 가장 큰 문제 중 하나는 BLOCKED 상태에서 무한 대기하며, CPU 스케줄링에 계속 포함된다는 점이었습니다. 이러한 단점을 보완하기 위해 자바는 ReentrantLock이라는 고급 동기화 도구를 제공하였으며, 이는 Lock 인터페이스의 대표적인 구현체입니다. 내부적으로는 LockSupport(park / unpark)를 사용하여 스레드의 대기와 깨움을 보다 유연하게 제어합니다.
간단한 예시 코드를 보겠습니다.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class BankAccount {
private int balance = 1000;
private final Lock lock = new ReentrantLock();
public void withdraw(int amount) {
lock.lock();
try {
if (balance >= amount) {
System.out.println(Thread.currentThread().getName()
+ "출금 시도 : " + amount);
balance -= amount;
System.out.println(Thread.currentThread().getName()
+ "출금 완료, 잔액 : " + balance);
}
} finally {
lock.unlock(); //반드시 해제
}
}
}
public class ReentrantLockMain {
public static void main(String[] args) {
BankAccount account = new BankAccount();
Thread t1 = new Thread(() -> account.withdraw(600), "Thread-1");
Thread t2 = new Thread(() -> account.withdraw(600), "Thread-2");
t1.start();
t2.start();
}
}
Thread-1출금 시도 : 600
Thread-1출금 완료, 잔액 : 400
우선 withdraw() 메서드 안에 lock.lock()을 호출하여 락이 있으면 WAITING 상태로 대기하게 하였습니다. 이는 내부적으로 LockSupport.park()을 사용한 것입니다. 그리고 예외가 발생해도 락은 반드시 해제하게 하였으며, 이때 unlock()을 누락한다면 스레드가 영구 대기(deadlock) 하게 됩니다.
즉 두 스레드가 동시에 접근해도 한 번에 하나의 스레드만 balance를 수정하여 데이터 불일치 문제가 발생하지 않았습니다.
< ReentrantLock의 특징 정리 >
- 객체 내부의 모니터 락(monitor lock) 을 사용하지 않음
- synchronized에서 사용하는 BLOCKED 상태를 사용하지 않음
- 내부적으로 LockSupport 기반의 WAITING / TIMED_WAITING 상태를 사용
그러면 ReentrantLock에서 내부적으로 사용하는 LockSupport에 대해서 간단히 보겠습니다.
LockSupport
ReentrantLock은 LockSupport를 기반으로 구현된 고수준 동기화 도구입니다.
LockSupport.park()를 호출하면, 스레드는 WAITING 상태로 들어가 CPU 스케줄링 대상에서 완전히 제외되고, 누군가 unpark() 해줄 때까지 대기합니다. 즉 불필요한 CPU 경쟁을 줄이고 효율적인 대기가 가능합니다.
간단한 예시 코드를 보겠습니다.
import java.util.concurrent.locks.LockSupport;
public class LockSupportExample {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(()-> {
System.out.println("작업 시작");
// 현재 스레드를 WAITING 상태로 만듦
LockSupport.park();
System.out.println("다시 실행");
});
worker.start();
//잠시 대기 후 worker 스레드 깨우기
Thread.sleep(1000);
System.out.println("worekr 깨우기");
LockSupport.unpark(worker);
}
}
작업 시작
worekr 깨우기
다시 실행
출력 결과를 보면 알 수 있듯, LockSupport.park()를 호출하는 순간, WAITING 상태가 되어 CPU에서 제외되어 메인 스레드의 'worker 깨우기'가 먼저 출력된 것을 알 수 있습니다.
즉, LockSupport.park() 메서드를 통해 실행을 멈추었으며(WAITING), LockSupport.unpark(thread)를 통해 특정 스레드를 깨웠으며, 무한 BLOCKED 문제를 해결했습니다.
LockSupport의 기능을 ReentrantLock 내부에서 이미 사용하기 때문에 굳이 LockSupport를 따로 쓸 필요는 없지만, ReentrantLock를 더 원활히 이해하기 위해 사용을 했습니다.
그렇다면 여기서 LockSupport.unpark(thread)는 왜 매개 변수를 넣어야 할까요? 왜냐하면 이미 대기상태에 빠진 스레드는 자신의 코드를 실행할 수 없기에 외부 스레드의 도움을 받아야 깨어날 수 있기 때문입니다.
unpark() vs interrupt()
그러면 unpark()와 interrupt()의 차이에 대해서 알아보겠습니다.
| unpark(thread) | interrupt() |
| - 특정 스레드만 정확히 깨울 수 있음 | - WAITING / TIMED_WAITING 상태(park(), sleep(), wait()) 의 스레드를 중간에 깨울 수 있음 |
| - 예외 발생x | - InterruptedException 발생 가능 |
| - 락과 무관하게 동작 | - 인터럽트 상태가 사용 목적에 맞게 초기 |
메모리 가시성
멀티스레드 환경에서 한 스레드가 변경한 값이 다른 스레드에서 언제, 그리고 올바르게 보이는가에 대한 개념을 메모리 가시성(Memory Visibility)이라고 합니다. 각 스레드의 캐시 메모리와 메모리 구조 때문에 한 스레드가 값을 변경했더라도 그 값이 즉시 다른 스레드에게 보이지 않을 수 있습니다. 해당 내용에 대한 설명은 아래에서 이어서 하겠습니다.
Java Memory Model(JMM)
JMM은 자바 프로그램이 메모리에 접근하고 수정하는 방법과, 멀티스레드 환경에서 스레드 간 메모리 상호작용 규칙을 정의한 명세이며, JMM의 핵심 목적은 다음 두 가지를 보장하는 입니다.
- 스레드 간 메모리 가시성(Visibility)
- 스레드 간 작업 순서(Ordering)
이 두 가지를 보장하기 위해 happens-before 관계라는 개념을 정의합니다.
happens-before
happens-before 관계는 자바 메모리 모델에서 스레드 간의 작업 순서와 메모리 가시성을 보장하는 규칙입니다. 예를 들어 만약 A가 작업 B보다 happens-before 관계에 있다면, 작업 A에서 발생한 모든 메모리 변경 사항은 작업 B에서 반드시 볼 수 있습니다.
즉, "A의 결과는 B가 실행되기 전에 반드시 메모리에 반영 된다"를 보장합니다.
그래서 이 관계가 성립이 된다면, 한 스레드에서 변경한 값이 다른 스레드에서 항상 최신 값으로 보이며, 명령어 재정렬로 인한 오류를 방지합니다.
Volatile
위의 설명처럼 멀티스레드 환경에서는 메모리 가시성 문제가 발생할 수 있습니다. 이러한 문제를 방지하기 위해 자바에서는 volatile 키워드를 제공합니다. volatile로 선언된 변수는 항상 메인 메모리에서 직접 읽고 쓰도록 보장되어, 이를 통해 여러 스레드가 항상 최신 값을 공유할 수 있습니다.
다만 원자성(atomicity)을 보장하지는 않아 증가 연산(count++) 같은 복합 연산에는 synchronized나 Atomic 계열이 필요합니다. 단순한 플래그 변수(종료 여부, 상태 체크 등)에 주로 사용됩니다.
동일한 객체를 참조하는데, 서로 다른 값을 본다는 게 무슨 의미일까요? 아래 코드를 보겠습니다.
class Worker extends Thread {
boolean running = true; // volatile 아님
@Override
public void run() {
System.out.println("작업 시작");
while (running) { // 계속 true로 보일 수도 있음
// 작업 수행
}
System.out.println("작업 종료");
}
}
public class Test {
public static void main(String[] args) throws Exception {
Worker w = new Worker();
w.start();
Thread.sleep(2000);
w.running = false; // 종료 신호
System.out.println("메인: 종료 신호 보냄");
}
위 코드를 보면 main 스레드가 running = false로 바꿨으니, Worker 스레드가 while을 빠져나와 종료해야 하지만 즉각적으로 반영이 되지 않으며, 극단적인 경우 Worker 스레드가 영원히 종료되지 않을 수 있습니다.
두 개의 스레드가 공통된 객체를 참조해도 왜 서로 다른 값을 보는 문제가 발생할까요? 그 이유는 main 스레드와 Worker 스레드는 running의 값을 각자의 CPU 캐시에 복사하고, 그 값만 보고 while문을 실행하기 때문입니다.

메인 메모리는 램(RAM)을 뜻하며 모든 스레드가 공유하는 실제 메인 메모리입니다. 메인 메모리는 CPU 입장에서 거리도 멀고, 속도도 상대적으로 느립니다. 하지만 CPU 연산은 매우 빠르기에 CPU에 가까이 있는 매우 빠른 메모리가 필요한데, 이것이 캐시 메모리입니다.
그림처럼 각 스레드가 메인 메모리의 running값을 복사해서 가져오고, 각각의 캐시 메모리에 보관합니다. 결과적으로 main 스레드에서 running을 false로 한 것은 본인 캐시 메모리에 있는 running의 값을 false로 바꾼 것이고, Worker스레드와 메인 메모리에는 값이 바뀌지 않았습니다.
그렇다면 main 스레드의 캐시메모리에 있는 running의 값이 언제 메인 메모리에 반영될까요?
이것은 CPU의 설계방식과 종류에 따라 다르기에 알 수 없어, 즉시 반영 될 수도 있고 몇 초 후에 반영될 수도 있습니다. 주로 스레드가 Time-Waiting 상태이거나, 콘솔에 내용을 출력할 때 스레드가 쉬는데 이럴 때 컨텍스트 스위칭이 되면서 캐시메모리도 함께 갱신이 됩니다. 하지만 이 또한 개신을 보장하지 않습니다.
성능도 중요하지만, 여러 스레드를 사용할 때 같은 시점에 정확히 같은 데이터를 보는 것이 더 중요한 경우가 많기에 성능을 약간 포기하는 대신 값을 읽거나 쓸 때 모두 메인 메모리에 직접 접근을 할 수 있도록 자바에서는 volatile이라는 키워드 제공합니다.
그러면 volatie를 이용한 코드를 보겠습니다. 이렇게 하면 Worker 스레드는 바로 while문을 탈출할 수 있을까요?
class Worker extends Thread {
volatile boolean running = true;
@Override
public void run() {
System.out.println("작업 시작");
while (running) {
System.out.println("작업중");
}
}
}
public class Test {
public static void main(String[] args) throws InterruptedException {
Worker w = new Worker();
w.start();
Thread.sleep(2000);
w.running = false;
System.out.println("메인 : 종료 신호 보냄");
}
}
정답은 While문은 바로 멈추지 않습니다. ruuning에 volatie를 사용했는데 왜 바로 멈추지 않을까요?
volatie의 역할부터 다시 살펴보겠습니다.
volatie는 main 스레드가 running = false로 바꾸면, Worker 스레드가 언젠가는 반드시 그 변경값을 보게 해 줄 뿐(가시성 보장), 즉시 멈추게 하거나 CPU를 빼앗기진 않습니다. 즉 volatie는 신호 전달용이지, 스레드를 깨우거나 중단시키는 기능은 없습니다.
또한 위의 코드는 이미 CPU에 눌러앉아 무한 루프의 While문을 계속 돌고 있으며, println은 매우 느린 I/O 작업이기에 시간이 더 지연되며, 변경된 메인 메모리의 false값을 이미 봤지만, 루프를 빠져나오기까지 시간이 오래 걸리는 것입니다.
즉 volatile의 값을 false로 바꾼 것을 인지하고 있지만, while 안에서 CPU를 계속 점유하고 있어 반응이 늦은 것뿐입니다.
그러면 바로 반응하게 만들고 싶으면 어떻게 하면 될까요? 아래 코드처럼 약간의 대기 시간을 주어 Time-Waiting상태로 만들어 while문의 반복적인 실행에 제약을 두면 됩니다.
while (running) {
System.out.println("작업중");
try {
Thread.sleep(100); // CPU 잠깐 내려놓기
} catch (InterruptedException e) {}
}
'Java' 카테고리의 다른 글
| [Java] 컬렉션 프레임워크 - Set(HashSet, TreeSet)의 이해 (1) | 2026.03.03 |
|---|---|
| [Java] 컬렉션 프레임워크 - List(ArrayList, LinkedList)의 이해 (0) | 2026.02.23 |
| [Java] 시간복잡도와 빅 오(Big-O) 표기의 이해 (1) | 2026.02.18 |
| [Java] I/O스트림의 이해와 활용 (1) | 2026.02.09 |
| [Java] 멀티스레드의 기초 이해 (0) | 2026.01.22 |
