Java에서의 특징:
long, double을 제외한 기본 타입의 읽기/쓰기는 원자적단, 원자적 = 스레드 안전 은 아니다 ❌
long, double은 64비트 → 32비트씩 두 번에 나눠 처리될 수 있음따라서 명세에 의존한 안전한 코드를 작성해야 하며,
volatile또는synchronized사용이 정석이다.
AS-IS: 무한 루프
public class StopThread {
private static boolean stopRequest;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int i = 0;
while (!stopRequest) {
i++;
/**
* print 를 하니까 정상 종료 됨 ??
* System.out.println("i: " + i + "stopRequest: " + stopRequest);
*/
}
});
thread.start();
TimeUnit.SECONDS.sleep(1);
stopRequest = true;
}
}
TO-BE: 1초 후 Stop
public class StopThreadSync {
private static boolean stopRequested;
// write
private static synchronized void requestStop() {
stopRequested = true;
}
// read
private static synchronized boolean stopRequest() {
return stopRequested;
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int i = 0;
while (!stopRequest()) {
i++;
}
});
thread.start();
TimeUnit.SECONDS.sleep(1);
requestStop();
}
}
위 예제 코드를 살펴보면 쓰기/읽기 모두 synchronized 키워드가 붙어 있음을 볼 수 있다.
즉, 쓰기/읽기 모두 동기화되지 않으면 동작을 보장하지 않는다.
public class StopThreadVolatile {
private static volatile boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int i = 0;
while (!stopRequested) {
i++;
}
});
thread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
Volatile 키워드가 붙은 데이터는 메인 메모리에 저장이된다.
Multi-thread 환경
task 를 수행하는 동안 성능 향상을 위해 CPU cache 에 저장하고 활용한다.
즉, 각각의 스레드의 CPU cache 에 저장된 값이 다르기 때문에 동시성 문제가 발생한다.
👉 실무에서는 volatile를 잘 쓰지 않는다?
- volatile은 가시성만 보장하고 원자성은 보장하지 않는다
- 실무에서 동시성 이슈의 핵심은 대부분 쓰기의 원자성 문제
웹 애플리케이션 특성상
→ 최신 값이 잠시 보이지 않아도 치명적이지 않은 경우가 많음- 그래서 현업에서는 volatile보다 Atomic / Lock / Concurrent 컬렉션을 주로 사용한다
- volatile은 종료 플래그 같은 단순 상태 공유에만 제한적으로 사용됨
동기화 안에서 외부 코드(콜백)를 호출하지 마라. → 성능 저하, 예외, 교착상태 위험
과도한 동기화 문제
→ 이걸 제어권을 클라이언트에 넘긴다고 함
synchronized (observers) {
snapshot = new ArrayList<>(observers);
}
for (SetObserver<E> observer : snapshot)
observer.added(this, element);
CopyOnWriteArrayList 사용정리
- 동기화 블록 안에서는 최소한의 일만
- 계산, 콜백, IO → ❌ 밖으로 빼기
Effective Java 초판에서는 작업 큐(work queue)를 직접 구현하는 방법을 소개했지만, 이는 코드가 복잡하고 동시성 오류에 취약했다. 스레드 생성과 관리, 예외 처리까지 모두 개발자가 책임져야 했기 때문이다.
이후 자바에는 java.util.concurrent 패키지, 즉 Executor Framework가 도입되었다. Executor를 사용하면 스레드나 큐를 직접 다룰 필요 없이 작업 실행을 간단히 처리할 수 있다.
ExecutorService exec = Executors.newSingleThreadExecutor();
exec.execute(runnable);
exec.shutdown();
ExecutorService는 작업 실행뿐 아니라 작업 완료 대기, 결과 수집, 주기적 실행 등 다양한 기능을 제공한다. 작은 규모의 프로그램에는 newCachedThreadPool이 적합하며, 기존 스레드를 재사용하고 필요 시 새 스레드를 생성한다. 반면 대규모 애플리케이션에서는 스레드 수가 고정된 newFixedThreadPool을 사용하는 것이 안전하다.
저자가 스레드보다 Executor 사용을 권장하는 이유는, Thread가 작업의 단위와 실행 메커니즘을 함께 가지고 있기 때문이다. Executor Framework에서는 이 둘이 분리된다. 작업의 단위는 태스크이며, Runnable과 Callable이 이에 해당한다. 덕분에 실행 정책을 바꾸더라도 작업 코드는 영향을 받지 않는다.
자바 7부터는 ForkJoinPool을 통해 fork-join 작업을 지원하며, 이를 기반으로 한 parallel stream은 병렬 처리를 더 쉽고 효율적으로 만들어준다.
자바는 오래전부터 wait, notify, notifyAll을 통해 스레드 간 협력을 지원해왔다. 하지만 Java 5 이후 도입된 고수준 동시성 유틸리티 덕분에, 이 저수준 메서드들을 직접 사용할 이유는 점점 줄어들었다.
wait()
synchronized 블록 내부에서 호출해야 함notify()
notifyAll()
👉 세 메서드 모두 Object에 정의되어 있다.
synchronized (obj) {
while (condition) {
obj.wait();
}
}
wait는 조건 검사 반복문(wait loop) 안에서만 사용notify 호출notifyAll로 모든 스레드가 깨어난 경우➡️ 이 모든 상황을 고려해야 하므로 구현 난이도가 매우 높다.
👉 단순히 synchronized 키워드 하나 차이로도 성능이 크게 달라질 수 있다.
Java 5부터 제공되는 java.util.concurrent는
안전성 + 성능 + 가독성을 모두 개선한다.
ExecutorService, ExecutorsConcurrentHashMap, ConcurrentLinkedQueueCollections.synchronizedMap보다 성능 우수CountDownLatch : 특정 조건까지 대기Semaphore : 자원 접근 개수 제한결론
wait와notify는 저수준 도구로 사용이 어렵고 위험하다synchronized기반 설계는 성능과 확장성에 한계가 있다- 특별한 이유가 없다면
👉 동시성 유틸리티를 우선적으로 사용하자
멀티스레드 환경에서 여러 스레드가 동시에 메서드를 호출할 때의 동작 방식은 클래스와 클라이언트 사이의 중요한 계약이다. 하지만 API 문서에 스레드 안전성에 대한 설명이 없다면, 사용자는 스스로 가정을 할 수밖에 없고 이는 심각한 오류로 이어질 수 있다. 동기화가 부족하면 버그가 발생하고, 과도하면 성능이 급격히 저하된다.
따라서 멀티스레드 환경에서도 API를 안전하게 사용하려면, 클래스가 지원하는 스레드 안전성 수준을 반드시 문서로 명확히 밝혀야 한다. 단순히 메서드에 synchronized를 붙였다고 해서 스레드 안전하다고 판단해서는 안 된다.
String, LongAtomicLong, ConcurrentHashMapCollections.synchronizedListArrayList, HashMapnextSerialNumber++ 같은 구현정리
- 모든 클래스는 자신의 스레드 안전성 수준을 문서화해야 한다
- 스레드 안전성은 암묵적인 추측에 맡기면 안 된다
synchronized는 문서화를 대신할 수 없다
private final FieldType field = compute();
private FieldType field;
private synchronized FieldType get() {
if (field == null) field = compute();
return field;
}
private static class Holder {
static final FieldType field = compute();
}
static FieldType get() { return Holder.field; }
private volatile FieldType field;
FieldType get() {
FieldType r = field;
if (r != null) return r;
synchronized(this) {
if (field == null) field = compute();
return field;
}
}
volatileprivate volatile FieldType field;
FieldType get() {
if (field == null)
field = compute();
return field;
}
멀티스레드 환경에서 어떤 스레드를 얼마나 실행할지는 OS 스레드 스케줄러가 결정한다.
스케줄링 정책은 운영체제마다 다르며,
정확성이나 성능이 스케줄러에 의존하는 프로그램은 이식성이 떨어진다.
좋은 멀티스레드 프로그램의 핵심은
실행 가능한 스레드 수를 프로세서 수보다 과도하게 늘리지 않는 것이다.
이를 위해 스레드는
바쁜 대기란
문제점
스레드는 조건이 만족될 때까지 기다려야지, 확인해서는 안 된다.
Thread.yield()
문제점
➡️ 스레드가 제대로 실행되지 않는다고
yield로 고치려는 유혹을 반드시 피해야 한다.
스케줄러에 기대지 말고
스레드는