여러 스레드가 동시에 지나가면 안 되는 구간(임계 구역) 을 한 번에 하나만 통과시키는 장치.
synchronized
synchronized void inc() { count++; } // 인스턴스(this) 를 잠근다
synchronized static void sinc() { s++; } // 클래스 객체를 잠근다
void f() { synchronized (lock) { ... } } // 특정 객체를 잠근다
- 블록을 벗어나면 예외가 나도 자동으로 풀린다
- 재진입 가능 — 같은 스레드가 이미 쥔 락을 다시 잡을 수 있다
- 가시성도 함께 보장한다 (unlock → lock 의 happens-before)
ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
void f() {
lock.lock();
try { ... } finally { lock.unlock(); } // finally 를 빠뜨리면 영원히 안 풀린다
}
// synchronized 로는 못 하는 것들
if (lock.tryLock(1, TimeUnit.SECONDS)) { ... } // 시간 제한을 두고 시도
lock.lockInterruptibly(); // 대기 중 인터럽트 가능
new ReentrantLock(true); // 공정 모드 (먼저 온 순서대로)
Condition cond = lock.newCondition(); // 조건별 대기열을 여러 개
무엇을 잠글 것인가
// ❌ 문자열 리터럴 — 상수 풀에서 공유된다. 무관한 코드와 락이 겹친다
synchronized ("lock") { }
// ❌ 박싱된 값 — 캐시 범위에서 같은 객체다
synchronized (Integer.valueOf(1)) { }
// ❌ 바뀌는 참조 — 잠근 대상이 도중에 달라진다
synchronized (this.field) { this.field = new Object(); }
// ✅ 전용 final 객체
private final Object lock = new Object();
락 범위는 좁게
// ❌ 외부 호출을 락 안에 둔다 — 그동안 아무도 못 들어온다
synchronized void f() { httpClient.call(); count++; }
// ✅ 필요한 부분만
void f() {
var result = httpClient.call();
synchronized (lock) { count++; }
}
데드락과 그 처방
T1: A 잠금 → B 대기
-
T2: B 잠금 → A 대기 — → 순환 대기 = 데드락
-
① 항상 같은 순서로 잠근다 (가장 효과가 크다)
-
② 락을 쥔 채 다른 락을 요구하지 않는다
-
③ tryLock 에 타임아웃을 걸어 포기할 수 있게 한다
jstack <pid> # "Found one Java-level deadlock" 이 찍힌다
Java 24 이후의 변화
예전에는 가상 스레드에서 synchronized 가 캐리어를 고정(pinning)했다
- 그래서 ReentrantLock 으로 바꾸라는 조언이 있었다
Java 24 의 JEP 491 이 이를 제거했다
- 이제 pinning 회피 목적으로 바꿀 필요는 없다
- tryLock · Condition 같은 기능이 필요할 때만 ReentrantLock 을 쓴다
면접 함정
- ❌ "ReentrantLock이 항상 낫다" →
unlock을 빠뜨릴 위험이 있다. 기능이 필요 없으면synchronized가 안전하다. - ❌ "synchronized는 무겁다" → 경합이 없으면 JVM이 최적화한다. 경합이 문제이지 키워드가 문제가 아니다.