운영체제를 공부하면서 프로세스와 스레드의 개념은 익히 들어 알고 있었지만, JVM 쪽으로의 스레드에 대해서 어떻게 다루는지 코드를 통한 학습은 해보지 않았다. 이번 글에서는 JVM을 기반으로 스레드에 대해서 알아보고, 동시성 문제가 왜 생기는지, 스레드 풀을 왜 사용하는지 어떻게 설정하는지까지 정리한다.
✅ 스레드(Thread)
▶ 프로세스 vs 스레드
구분
프로세스(Process)
스레드(Thread)
정의
실행 중인 프로그램
프로세스 내에서 실행되는 단위
메모리
독립된 메모리 공간(Code, Data, Heap, Stack)
Code, Data, Heap은 공유 / Stack은 독립
통신
IPC(비교적 비용 높음)
같은 프로세스 내이므로 빠름
생성 비용
높음 (OS 수준 생성)
낮음 (프로세스 내부 생성)
▶ JVM 스레드 구조
JVM은 OS로부터 스레드를 생성하고, 이를 내부적으로 Java Thread 객체와 매핑한다. JVM 메모리는 공유 영역과 스레드별 영역으로 나뉜다.
영역
공유 여부
설명
Method Area
모든 스레드가 공유
메서드, static 변수, 상수 등을 보관
Heap
모든 스레드가 공유
new로 만든 객체와 배열을 저장 (GC 대상)
JVM Stack
스레드마다 따로 존재
지역 변수, 임시 계산 값, 호출 기록 등을 저장
PC Register
스레드마다 따로 존재
현재 실행 중인 명령어의 위치를 기억
🔽 참고 - JVM 구조
▶ 스레드 생성 방법
스레드를 생성하는 방법은 크게 Thread 클래스를 상속하는 방법과 Runnable 인터페이스를 구현하는 방법 두 가지로 나뉜다.
🔽 참고 - Thread 객체 - run()에 작성한 코드가 스레드가 실행할 작업이다. - start()를 호출해야 새로운 스레드가 생성되어 run()이 비동기로 실행된다. - run()을 직접 호출하면 단순히 현재 스레드에서 실행된다.
🔽 Thread 상속
public class ExtendedThread extends Thread {
private String message;
public ExtendedThread(final String message) {
this.message = message;
}
@Override
public void run() {
log.info(message);
}
}
@Test
void testExtendedThread() throws InterruptedException {
// ExtendedThread 클래스를 Thread 클래스로 상속하고 스레드 객체를 생성한다.
Thread thread = new ExtendedThread("hello thread");
// 생성한 thread 객체를 시작한다.
thread.start();
// thread의 작업이 완료될 때까지 기다린다.
thread.join();
}
🔽 Runnable 구현
public class RunnableThread implements Runnable {
private String message;
public RunnableThread(final String message) {
this.message = message;
}
@Override
public void run() {
log.info(message);
}
}
@Test
void testRunnableThread() throws InterruptedException {
// RunnableThread 클래스를 Runnable 인터페이스의 구현체로 만들고 Thread 클래스를 활용하여 스레드 객체를 생성한다.
Thread thread = new Thread(new RunnableThread("hello thread"));
// 생성한 thread 객체를 시작한다.
thread.start();
// thread의 작업이 완료될 때까지 기다린다.
thread.join();
}
Runnable 구현 방식은 Thread 상속 방식과 다르게 다중 상속이 가능하고, ExecutorService 같은 스레드 풀과도 자연스럽게 연동된다.
✅ 스레드 풀(Thread Pool)
스레드 풀은 다수의 작업을 효율적으로 처리하기 위해 미리 생성된 스레드들을 재사용하는 메커니즘이다. java.util.concurrent 패키지의 Executor 프레임워크를 통해 제공된다.
매번 새로운 스레드를 생성하면 안 되는 걸까? 매번 스레드를 생성하면 다음과 같은 문제가 있다.
스레드 생성/소멸 비용이 높다.
너무 많은 스레드가 생기면 Context Switching 부담이 커져 오히려 성능이 저하된다.
스레드 관리(수명 주기, 예외 처리 등)가 어렵다.
이를 해결하기 위해 스레드 풀(Thread Pool)을 사용한다. 미리 일정 개수의 스레드를 만들어 두고, 요청된 작업을 대기열(Queue)에 저장해 순차적으로 스레드에게 할당한다.
▶ 주요 구현체
스레드 풀은 ExecutorService 인터페이스를 통해 지원되며, Java에서는 Executors 유틸리티 클래스를 사용해 ExecutorService 구현체 기반의 스레드 풀을 간단히 생성할 수 있다.
메서드
설명
Executors.newFixedThreadPool(n)
고정된 개수의 스레드 유지
Executors.newCachedThreadPool()
필요 시 새 스레드 생성, 일정 시간 후 유휴 스레드 제거
Executors.newSingleThreadExecutor()
단일 스레드로 순차 실행
Executors.newScheduledThreadPool(n)
일정 지연이나 주기적으로 작업 실행 가능
▶ 내부 동작 원리
🔽 예시 코드
public class ThreadPoolExample {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(3);
for (int i = 1; i <= 10; i++) {
int taskId = i;
executorService.submit(() -> {
System.out.println(Thread.currentThread().getName() + " 실행 중 - Task " + taskId);
});
}
executorService.shutdown();
}
}
submit() 또는 execute() 호출 시 작업이 제출된다.
submit()은 Future 반환, execute()는 void
제출된 작업은 내부 BlockingQueue(작업 큐)에 저장된다.
대기 중인 스레드가 큐에서 작업을 꺼내 실행한다.
작업을 끝낸 스레드는 다시 풀로 반환되어 다음 작업을 대기한다.
shutdown() 호출 시 새로운 작업은 받지 않고, 큐에 남은 작업만 처리한 뒤 종료된다.
🔽 Executors.newFixedThreadPool 내부 구현
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
🔽 ThreadPoolExecutor 생성자 구조
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), defaultHandler);
}
🔽 참고 Spring MVC, 내장 Tomcat 요청 처리 스레드 풀은 ThreadPoolExecutor 기반이지만, Executors.newFixedThreadPool()이 아니라 StandardThreadExecutor 구성을 사용한다.
✅ 동시성 문제
JVM 메모리 구조에서 살펴본 것처럼, Java 프로그램은 여러 스레드가 동시에 실행된다. 이때 스레드들이 Method Area나 Heap 영역과 같은 공유 자원에 동시에 접근하면 문제가 발생할 수 있다.
예를 들어, 두 스레드가 같은 객체의 값을 동시에 수정하면 값이 덮어써지거나, 여러 락을 순서 없이 획득하려다 데드락(Deadlock)이 발생할 수 있다. 이러한 문제를 방지하기 위해 동시성(Concurrency) 제어를 해야한다.
▶ 공유 자원 접근 제어 방법
🔽 Synchronized 블록
synchronized (lock) {
// 임계 구역
}
synchronized 키워드는 객체 단위로 모니터 락(Monitor Lock)을 건다.
한 스레드가 lock 객체의 모니터 락을 점유하는 동안, 다른 스레드는 같은 객체를 사용하는 synchronized 블록에 진입할 수 없다.
즉, 한 시점에 오직 하나의 스레드만 임계 구역을 실행하게 된다.
🔽 ReentrantLock
Lock lock = new ReentrantLock();
try {
lock.lock();
// 임계 구역
} finally {
lock.unlock();
}
ReentrantLock은 명시적으로 lock(), unlock()을 호출해야 하는 수동 락 제어 클래스다.
내부적으로 재진입(reentrant)을 허용한다. 즉, 같은 스레드가 여러 번 같은 락을 획득해도 데드락이 발생하지 않는다.
tryLock()이나 lockInterruptibly() 등을 활용하면 대기 시간, 인터럽트, 타임아웃을 세밀하게 제어할 수 있다.
🔽 Atomic 클래스
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();
java.util.concurrent.atomic 패키지의 클래스들은 CAS(Compare-And-Swap) 알고리즘을 사용한다.
CPU의 하드웨어 수준 원자성 연산을 이용해 락 없이도 동시성 안전성을 보장한다.
즉, 값을 읽고, 비교하고, 갱신하는 세 단계를 단일 원자적 연산으로 처리한다.
🔽 Thread-safe 컬렉션
Map<String, Integer> map = new ConcurrentHashMap<>();
List<String> list = new CopyOnWriteArrayList<>();
BlockingQueue<Task> queue = new LinkedBlockingQueue<>();