[Java] Thread

허경두·2025년 4월 11일

Java

목록 보기
11/12

쓰레드

프로세스(process) : 실행 중인 프로그램
실행에 필요한 데이터와 메모리 등의 자원, 쓰레드로 구성되어 있다
쓰레드(Thread) : 프로세스 내에서 실행되는 흐름의 단위. 경량 프로세스(Light-Weight Process)라고 부르기도 한다
프로세스라는 공장에서 작업을 처리하는 일꾼으로 생각하면 쉽다

프로세스가 가질 수 있는 쓰레드 개수는 제한되어 있지 않지만 쓰레드가 작업을 수행하는데 개별적인 메모리 공간(콜스택)을 필요로 하기 때문에 프로세스의 메모리 한계에 따라 쓰레드의 수가 결정된다

멀티태스킹(Multi Tasking) : 여러 개의 프로세스를 동시에 실행하는 것
멀티쓰레딩(Multi Threading) : 여러 개의 쓰레드가 동시에 작업을 수행하는 것

CPU의 코어는 한 번에 하나의 작업만 수행할 수 있기 때문에 단순히 쓰레드의 수가 많다고 성능이 좋은 프로세스는 아니다

* 실행 중인 사용자 쓰레드가 하나도 없을 때 프로그램은 종료된다

멀티 쓰레딩의 장점

  1. CPU의 사용률을 향상시킨다
  2. 자원을 보다 효율적으로 사용할 수 있다
  3. 사용자에 대한 응답성이 향상된다
  4. 작업이 분리되어 코드가 간결해진다

멀티 쓰레딩의 단점

  1. 자원을 공유하면서 DeadLock 이 발생할 수 있다
  2. 자원을 공유하면서 동기화 문제가 발생할 수 있다
  3. 디버깅과 테스트가 어렵다
  4. 컨텍스트 스위칭 비용이 발생한다
  5. 쓰레드마다 콜스택이 생성되기 때문에 메모리 사용량이 증가한다

쓰레드의 구현과 실행

  1. Thread 클래스 상속
    class MyThread extends Thread {
    	@Override
    	public void run() {...} // Thread의 run()을 오버라이딩
    }
    // main
    MyThread t = new MyThread();
  2. Runnable 인터페이스 구현
    class MyThread implements Runnable {
    	@Override
       public void run() {...}
    }
    // main
    Thread t = new Thread(new MyThread());
    • 재사용성이 높고 코드의 일관성을 유지할 수 있기 때문에 1번보다 객체지향적이다
    • 1번과 달리 인스턴스 생성 시에 Runnable을 구현한 클래스의 인스턴스를 생성한 후 Thread 클래스 생성자의 매개변수로 제공해야 한다
      왜?
      Runnable은 작업 내용만 정의하고 생성이나 실행 기능은 없기 때문이다 (Runnable은 작업 목록, Thread는 작업할 사람)
    • Thread 클래스는 생성자에 Runnable 구현체를 받음으로써 run 메서드를 상속을 통해 구현하지 않고 제공받은 구현체를 통해서 run 메서드를 실행할 수 있다

Thread 클래스 주요 메서드

메서드설명
start()새로운 쓰레드를 시작하고, 내부적으로 run() 메서드를 호출함
run()쓰레드가 실행할 작업을 정의함 (Runnable을 구현하거나 오버라이딩)
sleep(long millis)현재 실행 중인 쓰레드를 지정한 시간(ms) 동안 멈춤
join()다른 쓰레드가 종료될 때까지 현재 쓰레드를 대기시킴
join(long millis)지정한 시간 동안만 기다림
interrupt()쓰레드에 인터럽트 요청을 보냄 (중단 요청)
isInterrupted()쓰레드가 인터럽트 되었는지 여부를 확인함
interrupted()현재 쓰레드의 인터럽트 상태를 확인하고 초기화함 (static 메서드)
setPriority(int newPriority)쓰레드의 우선순위를 설정함 (1~10)
getPriority()현재 쓰레드의 우선순위를 반환함
setName(String name)쓰레드 이름을 설정함
getName()쓰레드의 이름을 반환함
isAlive()쓰레드가 아직 실행 중인지 여부를 반환함
currentThread()현재 실행 중인 쓰레드 객체를 반환함 (static 메서드)
yield()현재 실행 중인 쓰레드가 다른 쓰레드에게 실행 양보 (static 메서드)
setDaemon(boolean on)데몬 쓰레드로 설정 (true: 백그라운드 쓰레드로 동작)
isDaemon()쓰레드가 데몬인지 확인
  • Runnable로 구현했을 경우 currentThread() 메서드를 호출해야만 쓰레드에 대한 참조를 얻을 수 있다
  • start() 메서드는 call stack을 생성한 후에 해당 call stack에 run() 메서드를 실행한다
  • start() 메서드를 호출했다고 해서 쓰레드가 바로 실행되는 것이 아니라 실행대기 상태에 있다가 JVM의 스케쥴러를 거쳐 최종적으로 OS의 스케쥴러에 실행된다
  • 한 번 실행이 종료된 쓰레드는 다시 실행할 수 없다 = 하나의 쓰레드에 대해 start()는 한 번만 호출될 수 있다
    → 다시 실행하고 싶다면 새로운 쓰레드 객체를 생성해서 실행해야 한다

싱글쓰레드 vs. 멀티쓰레드

싱글 코어에서 단순히 CPU만을 사용하는 계산작업은 멀티쓰레드보다 싱글쓰레드가 효율적이다
왜?
멀티쓰레드는 컨텍스트 스위칭으로 인한 오버헤드가 발생하기 때문이다
또한, I/O 작업이 있는 경우 필요한 자원을 공유하게 되어 한 쪽 쓰레드는 대기하는 시간이 발생한다

반대로 I/O 작업이 하나의 쓰레드에만 있는 경우에는 I/O 작업동안 다른 쓰레드가 실행될 수 있으므로 멀티쓰레드가 더 유리하다

쓰레드 우선순위 지정하기

void setPriority(int newPriority) : 쓰레드의 우선순위를 지정한 값으로 변경한다
int getPriority() : 쓰레드의 우선순위를 반환한다

최소 1에서 최대 10이다

상수설명
Thread.MIN_PRIORITY1가장 낮은 우선순위
Thread.NORM_PRIORITY5기본 우선순위
Thread.MAX_PRIORITY10가장 높은 우선순위

우선순위를 지정한다고 해서 반드시 높은 우선순위가 빨리 완료되는 것은 아니다
왜?
OS의 스케쥴링 정책과 JVM의 구현에 따라 쓰레드가 실행되기 때문이다

쓰레드 그룹

서로 관련된 쓰레드를 그룹으로 다루기 위해 사용된다

생성자

생성자설명
ThreadGroup(String name)지정된 이름을 가진 새 ThreadGroup을 생성합니다.
ThreadGroup(ThreadGroup parent, String name)지정된 상위 ThreadGroup과 이름을 가진 새 ThreadGroup을 생성합니다.

주요 메서드

메서드설명
int activeCount()이 ThreadGroup에 속하거나 하위 그룹에 속한 활성 스레드의 수를 반환합니다.
int activeGroupCount()이 ThreadGroup의 하위 그룹 수를 반환합니다.
void checkAccess()현재 스레드가 이 ThreadGroup에 대한 접근 권한을 가지고 있는지 확인합니다.
void destroy()이 ThreadGroup과 하위 그룹을 제거합니다. (더 이상 사용할 수 없음)
int enumerate(Thread[] list)이 ThreadGroup에 속한 활성 스레드를 배열에 복사합니다.
int enumerate(ThreadGroup[] list)이 ThreadGroup의 하위 그룹을 배열에 복사합니다.
String getName()ThreadGroup의 이름을 반환합니다.
ThreadGroup getParent()상위 ThreadGroup을 반환합니다.
int getMaxPriority()이 ThreadGroup의 최대 우선순위를 반환합니다.
boolean isDaemon()이 ThreadGroup이 데몬 그룹인지 여부를 반환합니다.
boolean isDestroyed()이 ThreadGroup이 이미 destroy() 호출로 파괴되었는지 확인합니다.
void setDaemon(boolean daemon)이 ThreadGroup을 데몬 그룹으로 설정합니다.
void setMaxPriority(int pri)이 ThreadGroup의 최대 우선순위를 설정합니다.
String toString()이 ThreadGroup에 대한 문자열 표현을 반환합니다.

Thread 클래스의 생성자를 사용해서 해당 쓰레드를 그룹에 포함시킬 수 있다
모든 쓰레드는 반드시 쓰레드그룹에 포함되어 있어야 한다
→ 생성하는 모든 쓰레드그룹은 main 쓰레드 그룹의 하위 쓰레드 그룹이 되며, 쓰레드 그룹을 지정하지 않으면 자동으로 main 쓰레드 그룹에 속한다

Thread 클래스의 ThreadGroup 관련 생성자

생성자설명
Thread(ThreadGroup group, Runnable target)지정된 ThreadGroup과 Runnable 객체로 새 스레드를 생성합니다.
Thread(ThreadGroup group, Runnable target, String name)지정된 ThreadGroup, Runnable, 스레드 이름으로 새 스레드를 생성합니다.
Thread(ThreadGroup group, Runnable target, String name, long stackSize)지정된 ThreadGroup, Runnable, 이름, 스택 크기로 스레드를 생성합니다.
Thread(ThreadGroup group, String name)지정된 ThreadGroup과 이름으로 새 스레드를 생성합니다.

Thread 클래스의 ThreadGroup 관련 메서드

메서드설명
ThreadGroup getThreadGroup()현재 스레드가 속한 ThreadGroup을 반환합니다.
void uncaughtException(Thread t, Throwable e)처리되지 않은 예외가 발생했을 때 호출되며, 예외가 발생한 스레드와 예외 객체를 인자로 받습니다. (UncaughtExceptionHandler 인터페이스에 정의됨)

데몬 쓰레드

데몬 쓰레드(Daemon Thread) : 일반 쓰레드의 작업을 돕는 보조 쓰레드
무한 루프와 조건문으로 구성되어 대기하고 있다가 특정 조건이 만족되면 작업을 수행한다
e.g. 가비지컬렉터, 워드프로세서 자동저장, 화면자동갱신, ...

void setDaemon(boolean on) 메서드로 데몬 쓰레드로 변경할 수 있다. 이 메서드는 반드시 start() 메서드를 호출하기 전에 실행되어야 한다

데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 된다

쓰레드의 상태

상태설명
NEW스레드 객체가 생성되었지만 아직 start()가 호출되지 않은 상태입니다.
RUNNABLE스레드가 실행 중이거나 실행을 기다리는 상태입니다 (JVM이 실행할 준비 완료).
BLOCKED다른 스레드가 모니터 락을 가지고 있어서, 해당 락을 얻기 위해 대기 중인 상태입니다.
WAITING다른 스레드가 명시적으로 notify() 또는 interrupt() 하기 전까지 무한히 기다리는 상태입니다.
TIMED_WAITING일정 시간 동안만 기다리는 상태 (sleep(), join(timeout), wait(timeout) 등).
TERMINATED스레드의 실행이 종료된 상태입니다. (run() 메서드가 끝난 경우 등)

출처 : velog

쓰레드의 실행제어

일시 정지 및 대기 관련 메서드

메서드설명
static void yield()현재 실행 중인 스레드가 CPU를 다른 스레드에게 양보하도록 요청합니다.
static void sleep(long millis)지정된 시간(ms) 동안 현재 스레드를 일시 정지시킵니다.
static void sleep(long millis, int nanos)밀리초 + 나노초 단위로 일시 정지합니다.
void join()다른 스레드가 종료될 때까지 현재 스레드를 기다립니다.
void join(long millis)최대 millis 시간 동안 기다립니다.
void join(long millis, int nanos)최대 millis + nanos 시간 동안 기다립니다.

sleep(long millis)

지정된 시간이 다 되거나 interrupt() 가 호출되면 실행대기 상태가 된다
interrupt()InterruptedException 을 발생시키므로 sleep() 은 항상 try-catch 문으로 예외 처리를 해줘야 한다 (정확히는 interrupt() 가 호출되면 JVM이 현재 쓰레드를 깨우고 InterruptedException 을 던진다)

항상 현재 실행 중인 쓰레드에 대해 작동한다
→ 참조변수를 이용해서 호출해도 해당 변수와 관계없이 현재 쓰레드에 대해 작동하므로 Thread.sleep(2000) 의 형태로 호출해야 한다

try {
	th1.sleep(2000); // th1 쓰레드를 일시정지하는 게 아니라 현재 쓰레드가 일시정지된다
} catch (InterruptedException e) {}

yield()

busy-waiting(의미없이 실행시간을 소비하는 경우) 상황일 때, 남은 실행시간을 다른 쓰레드에게 양보한다

join() 예시

인터럽트 및 강제 종료 관련 메서드

메서드설명
void interrupt()해당 스레드에 인터럽트를 발생시킵니다. (주로 sleep, wait 중인 스레드를 깰 때 사용)
boolean isInterrupted()해당 스레드에 인터럽트 요청이 들어왔는지 확인합니다.
static boolean interrupted()현재 스레드의 인터럽트 상태를 확인하고, 상태를 초기화합니다.(interrupted 변수를 false로 초기화)

interrupt()

interrupt() 는 작업을 멈추라고 요청할 뿐 강제로 종료시키지는 못한다 (interrupted 변수를 true로 바꾼다)

interrupt()와 yield()를 적절히 사용한 예

public void suspend() {
	suspended = true;
    th.interrupt();
}

public void stop() {
	stopped = true; // stopped 라는 플래그만 변경한다면 확인을 위해 항상 1초는 기다려야 한다
    th.interrupt(); // interrupt 까지 호출하면 기다릴 필요없이 바로 예외가 발생한다
}

while (!stopped) {
	if (!suspended) {
    	try {
        	Thread.sleep(1000);
        } catch (InterruptedException e) {}
    } else { // suspended가 true이고 이 else 문이 없었다면 CPU 시간을 의미없이 소비하게 된다
    	Thread.yield(); // 의미없이 소비하지 않고 다른 쓰레드에게 양보한다
    }
}

사용 중단(Deprecated) 메서드

아래 메서드들은 안정성 문제(dead-lock 가능성이 높음)로 더 이상 사용이 권장되지 않으며 InterruptedException, volatile, Lock, Condition 등을 사용하는 방식으로 대체되어야 한다

메서드설명이유
void stop()스레드를 즉시 종료시킵니다. (Deprecated)스레드를 중단시키되 lock을 유지함 → 다른 스레드가 lock 잡을 수 없음
void suspend()스레드를 일시 정지시킵니다. (Deprecated)suspend()된 스레드를 다시 실행시키지만, 호출 기회를 못 가지면 deadlock
void resume()suspend()된 스레드를 다시 실행시킵니다. (Deprecated)스레드를 즉시 죽이지만, lock을 해제하지 않음 → 공유 자원 사용 불가 상태

쓰레드의 동기화

한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것

임계 영역(critical section) : 둘 이상의 쓰레드가 동시에 접근해서는 안되는 공유 자원에 접근하는 코드

synchronized

임계 영역을 지정할 수 있다

  1. 메서드 전체
public synchronized void method() { ... }
  1. 특정 영역
syncrhonized(락을 걸고자 하는 객체의 참조변수) { ... }

1번 보다는 2번으로 임계 영역을 최소화하여 프로그램을 효율성을 높여야 한다

synchronized 블럭을 벗어나거나 블럭 내에서 에러가 발생하면 락이 풀린다.
또한, synchronized 블럭을 들어갈 때와 나올 때 캐시와 메모리 간의 동기화가 일어난다

wait() & notify()

wait() : 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아닐 때 쓰레드가 락을 반납하고 기다리게 한다
notify() : 작업을 진행할 수 있게 되면 notify() 를 호출해서 기다리던 쓰레드가 다시 락을 얻고 작업을 진행한다
waiting pool : 쓰레드가 wait()로 인해 일시 정지되면 들어가는 장소

  • waiting pool은 객체마다 존재한다
  • notifyAll() 로 대기 중인 모든 쓰레드를 깨우면 하나의 쓰레드만 락을 얻고 작업을 수행한다
    → 나머지 쓰레드는 다시 waiting pool로 들어가는 것이 아닌 BLOCKED 상태가 되어 락이 풀릴 때까지 기다린다

starvation : 쓰레드의 우선순위가 낮거나 단순한 우연으로 순위가 계속 밀려 작업을 수행하지 못하는 상황
race condition : 공유 자원에 대해 락을 얻기 위해 서로 경쟁하는 상태
→ 기능이 다른 쓰레드(Read 쓰레드, Write 쓰레드)를 구별해서 통지하기
→ ReentrantLock과 Condition 클래스 사용

  • 예시

    시점Thread AThread B공유 변수 (counter)
    T1load → 55
    T2load → 55
    T3add → 6
    T4add → 6
    T5store → 66 (A가 저장)
    T6store → 66 (B가 덮어씀)

Lock & Condition

java.lang.concurrent.locks 패키지의 Lock 클래스

항목ReentrantLockReentrantReadWriteLockStampedLock
제공 클래스java.util.concurrent.locks.ReentrantLockjava.util.concurrent.locks.ReentrantReadWriteLockjava.util.concurrent.locks.StampedLock
읽기/쓰기 락 분리❌ 단일 락readLock() / writeLock()readLock() / writeLock() / optimisticRead()
재진입 가능 여부✅ 가능✅ 읽기/쓰기 모두 가능❌ 비재진입 (예외 발생 가능)
공정성 옵션✅ 생성자에서 설정 가능 (true)✅ 읽기/쓰기 모두 가능 (true)❌ 없음
tryLock 지원✅ 있음✅ 있음 (readLock().tryLock(), 등)✅ 있음 (타임아웃 포함)
락 해제 방식lock.unlock()readLock().unlock(), writeLock().unlock()unlockRead(), unlockWrite(), unlock(stamp)
낙관적 읽기 (Optimistic Read)❌ 없음❌ 없음✅ 매우 빠름 (검증 필요)
스레드 간 안전✅ (주의해서 사용 필요)
사용 난이도⭐⭐⭐⭐⭐⭐⭐⭐⭐ (조금 더 복잡함)
대표 용도일반적인 상호 배제 락읽기 많은 환경에서 성능 향상읽기 위주 환경 + 높은 성능 요구 시

* 낙관적 읽기(Optimistic Read) : 데이터를 읽을 때 락을 먼저 걸지 않고 읽은 후에 다른 쓰레드가 쓰기를 했는지 검사해서 문제가 없으면 그대로 사용, 문제가 있으면 재시도 또는 정식으로 락을 걸어서 읽는 방식
DB 트랜잭션 격리 수준의 READ COMMITTED 와 유사

ReentrantLock

생성자

생성자설명
ReentrantLock()기본 생성자. 비공정(FIFO 아님) 락 생성
ReentrantLock(boolean fair)true이면 공정성(fairness) 보장 (FIFO 락)

주요 메서드

메서드설명
lock()락을 획득할 때까지 현재 스레드 대기 (interruption 불가)
lockInterruptibly()락을 기다리는 중 인터럽트가 가능
tryLock()락을 시도하고 즉시 성공/실패 반환
tryLock(long time, TimeUnit unit)일정 시간 동안 락을 시도하고 성공/실패 반환
unlock()락 해제. 락을 소유한 스레드만 호출 가능
isLocked()현재 락이 걸려 있는지 여부 반환
isHeldByCurrentThread()현재 스레드가 이 락을 소유하고 있는지 여부 반환
getHoldCount()현재 스레드가 이 락을 몇 번 재진입했는지 반환
isFair()이 락이 공정 모드로 생성되었는지 반환
hasQueuedThreads()현재 대기 중인 스레드가 있는지 여부 반환
getQueueLength()대기 중인 스레드 수 반환 (추정값)
newCondition()Condition 객체 생성 (wait/notify 기능 지원)
  • 공정(fair = true): 락 요청 순서를 지켜서 먼저 요청한 스레드가 먼저 락을 얻음
  • 비공정(fair = false, 기본값): 락 요청 순서를 무시하고 가능하면 바로 획득 → 성능은 좋지만 starvation 가능성 있음

Condition

생성 방법

생성 방법설명
Condition condition = lock.newCondition();ReentrantLock에서만 생성 가능 (생성자 없음)
  • Condition 객체는 ReentrantLock이 관리하며, newCondition() 메서드로 생성됨
  • Condition고유한 대기열(Waiting Queue) 을 가짐

주요 메서드

메서드설명
await()현재 스레드를 condition queue에 넣고 대기. signal() 호출될 때까지 블로킹
await(long time, TimeUnit unit)일정 시간만 기다림. 시간 초과 시 자동 해제
awaitNanos(long nanosTimeout)대기 시간(나노초)을 반환하면서 대기
awaitUntil(Date deadline)특정 시간까지 대기
signal()대기 중인 스레드 중 하나를 깨움
signalAll()대기 중인 모든 스레드를 깨움

사용 시 주의사항

  • await(), signal(), signalAll() 모두 반드시 락을 소유한 상태에서 호출해야 함
    lock.lock();
    try {
        while (!조건) {
            condition.await();
        }
        // 조건 만족 시 처리
    } finally {
        lock.unlock();
    }
  • signal()signalAll()notify(), notifyAll()과 유사하지만, 락과 완전히 분리되어 작동 가능

Object.wait() vs Condition.await()

항목Object.wait()Condition.await()
동작 대상synchronized 블록ReentrantLock에서만 사용 가능
여러 조건 지원❌ 불가능✅ 여러 개의 Condition 사용 가능
시간 제어제한적 (wait(timeout))✅ 다양한 시간 기반 메서드 제공
인터럽트 처리기본 지원InterruptedException 명확히 처리 가능

volatile

volatile : Java에서 변수의 값을 모든 스레드에서 항상 메인 메모리의 최신 값으로 유지하도록 보장하는 키워드
즉, 스레드 간의 가시성(visibility) 문제를 해결해주는 수단

멀티 코어 프로세서에서는 코어마다 별도의 캐시를 가지고 있다
→ 메모리 접근은 느리고 비싸기 때문에 특정 조건에만 값을 메모리에 반영한다(쓰기 지연)

  • 쓰기 지연(write-back, lazy write)에서 메모리에 반영되는 시점

    1. 명시적으로 메모리에 쓰기
      e.g. synchronized 블럭
    2. 캐시가 꽉 차거나 캐시 라인이 교체될 때
    3. 다른 쓰레드가 동일한 값을 읽을 때 (반영이 될 수도 있고 안될 수도 있음)
    4. volatile 키워드가 있는 변수를 변경할 때
  • 가시성 문제(visibility issue) 예시

    1. 코어 A에서 실행 중인 스레드가 sharedValue = 42로 변경
    2. 이 값은 A 코어의 캐시에만 반영됨
    3. 코어 B에서 실행 중인 다른 스레드는 여전히 예전 값을 볼 수 있음

long & double 의 원자화

JVM은 4byte 단위로 데이터를 처리하는데 long 과 double 은 8byte 이므로 읽는 과정 중에 다른 쓰레드가 끼어들 여지가 있다
→ 읽고 쓰는 모든 문장을 synchronized 블럭으로 감싼다
volatile 키워드를 사용한다

volatile 키워드를 사용했다고 해서 다른 메서드(getter, setter, ...)를 동기화하지 않아도 되는 것은 아니다
volatile 은 가시성만 보장하고 원자성이나 복합적인 연산에 대한 동기화는 보장하지 않는다

fork & join framework

0개의 댓글