자바는 1.3이후부터는 커널 스레드와 유저 스레드가 1대1로 매핑하여 Native Thread를 사용한다.
1.2 버전 이전 까지는 ManyToOne 모델인 Green Thread을 사용했으나 Green Thread는 멀티 코어의 장점을 살리기 어려웠기 때문이다.
JDK21부터는 ManyToMany 모델인 Virtual Thread도 지원한다.
하지만 아직까지 기본적으로는 OneToOne 모델인 Native Thread를 사용한다.
JVM은 JNI을 통해 유저 스레드와 커널 스레드를 1대1로 매핑함으로써 Native Thread를 사용한다.
JNI, Java Native Interface
Java Native Interface (JNI) is a standard programming interface for writing Java native methods and embedding the Java virtual machine into native applications. The primary goal is binary compatibility of native method libraries across all Java virtual machine implementations on a given platform.
- Oracle Java Document
출처 : 우아한기술블로그
자바는 임계 영역 문제(Critical Section Problem)을 해결하기 위한 다양한 동기화 툴을 제공한다.
synchronized 키워드는 임계 영역을 설정하는데 사용된다.
synchronized 키워드로 지정된 코드 영역을 수행하기 위해선 객체에 대한 락을 획득해야 한다.
지정된 영역을 모두 수행한 뒤에는 자동으로 락을 반납한다.
다음과 같이 2가지 방법으로 임계 영역을 지정할 수 있다.
public synchronized void method1(){
// 메서드 단위로 임계영역을 설정한다.
// 메서드를 소유한 객체에 대한 락을 획득한다.
// 메서드가 끝나면 자동으로 락을 반납한다.
}
private T sharedObj; //락을 걸고자 하는 객체
public void method2(){
synchronized(sharedObj){
// 메서드의 특정 영역을 sharedObj에대한 임계 영역으로 설정한다.
}
synchronized(this){
// 임계 영역은 프로그램 성능을 좌우하기 때문에 최소화 하는 것이 좋다.
// 메서드 전체 보다는 공유 메모리에 접근하는 최소한의 영역만을 임계영역으로 지정하는 것이 좋다.
}
}
public void diningPhilosophersProblemPossible(){
synchronized(this){
synchronized(sharedObj){
// sharedObj가 만약 this객체에 대한 락을 획득하는 메서드를 가지고 있다면 데드락 가능성이 존재한다.
// 서로가 자신의 락을 획득한 상태로 상대방의 락을 요구하는 등...
// synchronized 뿐만 아니라 서로 다른 객체에 대한 락을 중첩해서 획득하는 행위는 위험하다.
}
}
}
주의할 점
락을 획득해 Synchronized 블럭 내부에 진입한 스레드가 특정 조건을 만족할 때까지 대기한다면, 작업 진행이 가능한 상태인 다른 스레드들이 락을 획득하지 못하고 대기해야 한다.
이러한 문제를 해결하기 위해 synchronized 블럭 내부에서 wiat()과 notify() 메서드를 사용함으로써 좀 더 효율적인 동기화가 가능하게 한다.
해당 메서드들은 Object 클래스에 정의되어 있다.
wait() : 해당 스레드는 락을 반납하고 객체의 waiting pool에서 대기하게 된다.
notify() : 대기중이던 스레드 중 임의의 스레드가 깨어나 락을 획득할 수 있게 된다.
notifyAll() : 객체의 waiting pool에서 대기중이던 스레드를 모두 깨운다.
synchronized키워드와 wait & notify를 사용하여 Producer - Consumer Problem을 해결해보자.
public class Cook{ //Producer
private Table table;
public void cook(String dish){
table.produce(dish);
}
}
public class Customer{ //Consumer
private Table table;
public void eat(String dish){
table.consume(dish);
}
}
public class Table{
private List<String> dishes = new ArrayList(); //테이블에 올라와 있는 음식 리스트
private int size; //테이블에 올라갈 수 있는 음식의 갯수
public synchronized void produce(String dish){
while(dishes.size >= size){ // 테이블이 가득 차 음식을 추가할 수 없는 경우
wait(); // produce를 호출한 Cook 스레드 대기
}
dishes.add(dish); //테이블에 음식 추가
notify(); //음식을 추가하였으므로 손님을 깨우기 위함. but 요리사만 깨울 수도 있다.(Stravation 발생 가능)
}
public boolean contains(String dish){
return dishes.contains(dish); //음식이 테이블에 존재하는지 검사
}
public void consume(String dish){
synchronized(this){
int waitCnt = 0;
while(!dishes.contains(dish){ // 원하는 음식이 테이블에 없는 경우
if(waitCnt++ > 10) return; //10번 기다려도 음식이 안나오면 가게를 떠난다.
wait(1000); //consume을 호출한 Customer 스레드가 1초간 대기
}
/*테이블에 올라온 음식 리스트에서 dish 하나를 찾아 제거*/
// 테이블에 빈 공간이 생겼으므로 대기중인 요리사 스레드가 있다면 깨우기 위함
// 모든 스레드를 깨움으로써 stravation을 방지한다.
// 모든 스레드를 깨우면 요리사와 손님들이 서로 불필요하게 락을 획득하기 위해 경쟁한다.
notifyAll();
} // synchronized
}
}
public class Main{
public static void main(String[] args){
int nThreads = 2;
ExecutorService executors = Executors.newFixedThreadPool(nThreads);
Cook producer = new Cook();
Customer consumer = new Customer();
for (int i = 0; i < 100; i++) {
executorService.execute(() -> producer.cook());
executorService.execute(() -> consumer.eat());
}
}
}
JDK1.5 이후 java.util.concurrent.locks가 제공하는 Lock클래스들을 사용할 수 있다.
Lock 클래스들은 조건 변수(Condition)을 제공함으로써 Monitor와 같이 동작한다.
Lock Class는 Condition을 제공해 줌으로써 유연하게 각 스레드를 상황에 따라 구분하여 대기시키고 깨울 수 있다.
Lock을 사용한 동기화 모델은 모니터를 사용한다.
작업 진행 도중 락을 반납하고 모니터 외부가 아닌 내부의 대기 장소에서 잠시 대기할 수 있다.
이후 다시 락을 획득하고 임계영역으로 재진입 할 수 있으므로 Lock 클래스들을 재진입 가능하다고 한다.(Reentrant)
synchronized 키워드의 wait과 notify도 하나의 조건 변수만 사용하는 모니터라고 생각할 수도 있다.

Lock 클래스들을 사용하면 조건 변수를 사용함으로써 유연하게 스레드들을 제어할 수 있지만, 명시적으로 직접 락을 획득하고 해제해야 한다.
3가지 종류의 Lock 클래스가 있다.
다음과 같은 계좌 입출금 예제를 통해 차이를 비교해보자.
public class NaiveAccount implements Account {
protected Long id;
protected long balance;
public NaiveAccount(Long id, long balance) {
this.id = id;
this.balance = balance;
}
@Override
public void deposit(long amount) {
balance += amount;
}
@Override
public void withdraw(long amount) {
if (balance < amount) {
throw new IllegalStateException("잔액 부족");
}
balance -= amount;
}
@Override
public long getBalance() {
return balance;
}
}
가장 기본적인 락이다.
public class LockAccount extends NaiveAccount {
private Lock lock = new ReentrantLock(); //읽기 쓰기 구분이 없는 배타 락
private Condition readCondition = lock.newCondition(); //락에 대한 조건 변수 생성
private Condition timeCondition = lock.newCondition(); //조건 변수 여러개도 가능
public LockAccount(Long id, long balance) {
super(id, balance);
}
@Override
public void deposit(long amount) {
lock.lock(); //락을 획득한다.
try { //임계영역 진입
LocalTime time = LocalTime.now();
if (time.isAfter(LocalTime.of(5, 0)) && time.isBefore(LocalTime.of(6, 0))) {
int sec = LocalTime.of(6, 0).toSecondOfDay() - time.toSecondOfDay();
timeCondition.await(sec, TimeUnit.SECONDS); //5시에서 6시 사이에 입금 시도할 경우 timeCondition에서 대기한다.
}
balance += amount;
} catch (Exception e) {
throw new RuntimeException();
} finally {
readCondition.signal(); // 해당 조건 변수에서 대기중인 임의의 스레드를 깨운다.
lock.unlock(); //락을 반납한다.
}
}
@Override
public void withdraw(long amount) {
lock.lock(); //락 획득
try { //임계영역
if (balance < amount) {
throw new IllegalStateException("잔액 부족");
}
balance -= amount;
} finally {
readCondition.signalAll(); // 해당 조건 변수에서 대기중인 모든 스레드 깨운다.
lock.unlock(); //락 반납
}
}
@Override
public long getBalance() {
int cnt = 0;
while (cnt++ < 3) { //3회 시도
if (lock.tryLock()) { //tryLock() : 락 획득을 시도하고 성공 여부를 반환한다. 락 획득 실패 시 대기하지 않는다.
try { //성공한 경우 락을 획득하고 임계영역 진입
return balance;
} finally {
readCondition.signal(); // 대기중인 임의의 읽기 작업 스레드를 깨운다.
lock.unlock(); //락 반환
}
} else { //락 획득 실패한 경우
try {
readCondition.await(1000); // 쓰기 작업 혹은 다른 읽기 작업이 수행중이므로 1초간 대기한다.
} catch (InterruptedException e) { //인터럽트 처리
throw new RuntimeException(e);
}
}
}
throw new RuntimeException("나중에 다시 시도해주세요");
}
}
synchronized와 달리 명시적으로 락을 획득하고 반환해야 한다.
락 획득 이후 try-finally 구문을 통해 예외가 발생하더라도 락을 반납할 수 있도록 한다.
조건 변수의 사용법이 synchronized의 wait(), notify() 메서드와 유사함을 알 수 있다.
wait 대신 await, notify 대신 signal을 사용하는 것 외에는 거의 유사하다.
여러 스레드가 읽기 작업을 동시에 수행할 수 있다.
읽기 작업이 많은 경우 동시성이 향상되는 것을 기대할 수 있다.
Read Lock
Write Lock
public class ReadWriteLockAccount extends NaiveAccount {
private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
/*Condition 사용 방법은 ReentrantLock 클래스와 동일함*/
...
@Override
public void deposit(long amount) {
rwLock.writeLock().lock(); // 쓰기 전용 락 : 배타 락(exclusive lock)
try {
balance += amount;
} finally {
rwLock.writeLock().unlock(); //쓰기와 읽기를 구분하여 락을 획득 및 반환한다.
}
}
...
@Override
public long getBalance() {
rwLock.readLock().lock(); // 읽기 전용 공유 락(shared lock)
try {
return balance;
} finally {
rwLock.readLock().unlock();
}
}
}
ReentrantReadWriteLock은 진행 중인 읽기 락이 모두 끝나기 전까지 쓰기 락이 대기해야 했다.
StampedLock은 ReetrantReadWriteLock에 더하여 쓰기 락에 의해 해제되는 낙관적 읽기 락(Optimistic Read Lock)을 사용한다.
public class StampedLockAccount extends NaiveAccount {
private StampedLock lock = new StampedLock();
/*Read, Write Lcok은 stamp를 통해 락을 반환하는 것을 제외하면 ReentrantReadWriteLock과 유사하다.*/
...
@Override
public long getBalance() {
long stamp = lock.tryOptimisticRead(); // 낙관적 읽기 락
long curBalance = this.balance; // 공유 데이터를 읽어온다.
if(!lock.validate(stamp)){ // 쓰기 락에 의해 낙관적 읽기 락이 풀렸는지(or유효한지) 확인한다.
stamp = lock.readLock(); // 낙관적 읽기 락이 유효하지 않다면 읽기 락(shared lock)을 획득한다.
try {
curBalance = this.balance; // 공유 데이터를 다시 읽어온다. 읽기 락은 쓰기 락에 의해 해제되지 않는다.
} finally {
lock.unlockRead(stamp); // 읽기 락을 해제한다.
}
}
return curBalance;
}
가시성이란 스레드 간에 통신을 통해 한 스레드가 만든 변화를 다른 스레드에서 확인하는 것이다.
JVM은 기본적으로 4byte 단위로 연산을 처리한다.
따라서 primitive type 중 8byte인 long과 double을 제외한 int, boolean과 같은 4byte 변수에 대한 읽거나 쓰는 행위는 원자적으로 이루어진다.
즉, 더 이상 나눌 수 없는 최소 작업 단위이므로 작업 중간에 다른 스레드가 끼어들 틈이 없다.
따라서 수정한 값을 온전히 저장하고, 온전히 수정이 완료된 값을 읽을 수 있음을 보장한다.
그렇다면 boolean이나 int 타입 변수에 대한 읽거나 쓰는 작업은 동기화가 필요 없을까? 필요하다
동기화가 하는 역할은 단순히 배타적 실행을 보장하는 것만이 아니다.
동기화는 배타적인 실행 뿐만 아니라 스레드 사이의 안정적인 통신(가시성)을 보장한다.
동기화 없이는 한 스레드가 만든 변화를 다른 스레드에서 확인하지 못할 수도 있다.
CPU는 코어마다 별도의 캐시를 가지고 있다.
코어는 메모리에서 읽어온 값을 캐시에 저장하고 캐시에서 값을 읽어서 작업한다.
같은 값을 읽어올 때는 먼저 캐시에 있는지 확인하고 없을 때만 메모리에서 읽어온다.
그러다 보니 메모리에 저장된 변수의 값이 변경되었는데도 캐시에 저장된 값이 갱신되지 않아서 메모리와 캐시에 저장된 값이 다른 경우가 발생한다.
멀티 코어 환경에서 한 스레드가 정상적으로 저장한 값이 다른 스레드에게 '보이지' 않으면 작업이 올바르게 동작하지 않을 수 있다.
출처 : https://jenkov.com/tutorials/java-concurrency/volatile.html
volatile 한정자를 사용하면 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다.
코어가 변수의 값을 읽어올 때 캐시가 아닌 메모리에서 읽어오기 때문에 가시성이 보장된다.
synchronized 키워드 등을 사용하여 동기화를 진행하여도 배타적인 실행과 가시성을 모두 보장할 수 있다.
배타적 수행이 필요 없는 경우 volatile 한정자를 사용하여 좀 더 빠른 속도를 얻을 수 있다.
또한 volatile 키워드를 사용하면 8byte인 long이나 dobule 타입의 변수에 대한 읽기나 쓰기를 원자화 시킬 수 있다.
public Class StopThread{
private static volatile boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
while (!stopRequested){} //main thread에서 boolean 값을 true 바꿔주기 전까지 반복한다.
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
// volatile 한정자가 없다면 main thread에서 값을 바꿨더라도,
// background thread에서 인지하지 못하고 반복문을 탈출하지 못할 수 있다.
stopRequested = true;
}
}
주의할 점
volatile 한정자를 적용하면 키워드가 적용된 변수에 대한 읽기, 쓰기만 원자적임을 주의해야 한다.
여러 동작이 합쳐진 경우에는 따로 동기화를 해야한다.
특히 ++, --같은 증감 연산자는 하나의 동작으로 오해하기 쉽지만, 값을 읽어오고(1) 1증감한 값을 쓰는(2) 과정에서 2번 변수에 접근한다. 따라서 동기화 해야 한다.
private static volatile int num = 0;
public static synchronized void increment(){
num++; //원자적 연산이 아니다. 동기화 해야 한다.
}
여러 스레드가 동시에 같은 값을 수정하는 경우는 생각보다 많지 않다.
따라서 많은 스레드가 경쟁하는 상황이라도 락을 거는 쪽보다 문제를 감지하는 쪽이 대부분 더 효율적이다.
java.util.concurrent.atomic 패키지에는 락 없이도(lock-free) 스레드 안전한 프로그래밍을 지원하는 클래스들이 담겨 있다.
Atomic 변수들은 내부적으로 volatile 키워드를 사용함으로써 변수에 대한 읽기 쓰기 원자성과 가시성을 보장한다.
이후 CAS 연산을 바탕으로 스레드를 차단하지 않고(non blocking) 안정적으로 값을 갱신한다.
AtomicLong 생성자와 volatile 한정자가 적용된 value 변수
private volatile long value;
/**
* Creates a new AtomicLong with the given initial value.
*
* @param initialValue the initial value
*/
public AtomicLong(long initialValue) {
value = initialValue;
}
CAS(Compare And Swap) 연산은 프로세서가 제공해주는 원자적 연산으로, 특정 데이터 값을 비교하여 같으면 새로운 값으로 갱신하는 연산이다.
다음은 CAS 연산에 대한 의사코드이다.
function cas(p: pointer to int, old: int, new: int) is
if *p ≠ old
return false
*p ← new
return true
메서드가 공유 변수를 갱신하려 든다면 CAS연산은 현재 변수 값이 최종으로 알려진 값인지 확인한다.
맞다면 변수를 갱신하고, 아니라면 다른 스레드가 끼어들었다는 뜻이므로 값을 갱신하지 않는다.
Atomic변수들에 대한 간단한 연산은 getAndAdd, incrementAndGet과 같은 메서드를 통해 수행할 수 있으며, compareAndSet 연산을 통해 복잡한 연산에 대한 데이터 정합성을 유지할 수 있다.
Atomic 변수들의 동작은 모두 유사하므로 AtomicLong에 대해서만 간단히 살펴보자
다음은 AtomicLong 변수를 활용한 계좌 입출금 예제이다.
public class AtomicAccount implements Account {
private Long id;
private AtomicLong balance;
public AtomicAccount(Long id, long balance) {
this.id = id;
this.balance = new AtomicLong(balance);
}
@Override
public void deposit(long amount) {
balance.addAndGet(amount); // 내부적으로 CAS연산을 사용해 값을 더하고, 결과를 반환한다.
}
@Override
public void withdraw(long amount) {
while (true) {
long temp = balance.get(); //원자적으로 값을 읽어온다.
if (temp < amount) {
throw new IllegalStateException();
}
// AtomicLong은 내부적으로 volatile 키워드가 적용된 value값을 가지고 있다.
// 따라서 다른 스레드에 의해 value값이 변경되었다면 value != temp이므로 CAS연산이 false를 반환한다.
// value == temp라면 value를 temp-amount로 갱신하고 true를 반환한다.
if(balance.compareAndSet(temp, temp - amount)){
return;
}
//갱신하지 못했다면 갱신할 때까지 반복; 데이터베이스의 optimisitic locking과 유사하다.
}
}
}
멀티 스레드 환경에서는 Thread Safe Collection을 사용해야 한다.
Vector, HashTable과 같은 클래스들을 쓸 수도 있지만 이 클래스들은 무조건 synchronized 키워드를 사용하기에 성능이 좋지 않다.
java.util.concurrent 패키지는 ConcurrentHashMap, CopyOnWriteArrayList와 같은 스레드 안전하면서 성능도 더 좋은 Collection들을 제공해준다.
남궁성, 『자바의 정석 3rd Edition』, 도우출판(2016)
Joshua Bloch, 『이펙티브 자바』, 도서출판인사이트(2018),
로버트 C. 마틴, 『클린 코드』, 도서출판인사이트(2013),
wikipedia, Compare-And-Swap
JENKOV.COM, Java Volatile Keyword