Java 락 확장 (lock coarsening)

이세민·2025년 7월 28일
post-thumbnail

Java에서의 락

Java는 멀티스레드를 지원하고, 그에 따른 동시성 문제를 해결하기 위해 synchronized 문법을 제공한다. synchronized는 여러개의 스레드가 동시성 문제가 생길 수 있는 동작에 대해서, 한번에 한 스레드씩 실행되도록 돕는다.

이러한 synchronized는 Lock을 사용하여 구현되어있는데

이렇게 어떠한 객체에 Monitor Lock이란것이 존재하고, 스레드별로 이것을 사용하고 반납하도록하여 구현되어있다. synchronized를 다룬 다른 포스트

락을 여러번 사용하면?

synchronized(obj){
  doSomething();
}
synchronized(obj){
  doSomeThing2();
}

위 코드를 보면, 같은 key(monitor lock)을 사용하는 synchronized 구문이 두번 연속되어있다. 만약 이것을 실행한다면 다음과 같이 실행될 것이다.

- obj 락 경쟁/점유
- doSomething()
- obj 락 해제
- obj 락 경쟁/점유
- doSomething2()
- obj 락 해제

실행 과정에서 락을 해제하자마자 다시 점유하는 상황이 발생한다.
이 경우에는 락을 해제하고 다시 다른 스레드와 경쟁하여 점유하는데에 있어서 오버헤드가 발생하게 된다.

락 확장

JVM에서는 위 문제를 해결하기 위해 JIT 컴파일러를 통하여 락 확장을 실행한다.
락 확장이란 락이 연속적으로 걸리는 경우 불필요한 해제, 점유 과정을 방지하기 위해 한개의 락으로 합치는것이다.

synchronized(obj){
  doSomething();
}
synchronized(obj){
  doSomeThing2();
}

이렇게 락이 연속적으로 걸리는 경우엔 불필요한 락 과정을 방지하기위해

synchronized(obj){
  doSomething();
  doSomething2();
}

이렇게 락 확장이 이루어진다.
락 확장이 적용된 이후에는 락을 해제하고, 경쟁/점유하는 과정이 줄어들어 성능적으로 이득을 볼 수 있다.

반복문에 대한 락 확장

for (int i = 0; i < 3; i++) {
  synchronized (obj) {
    doSomething(i);
  }
}

위 코드를 보면 아까와 비슷하게 락이 연속적으로 걸리는 경우이지만 반복문을 통해 락이 연속적으로 발생한다.
이 경우에도 락 확장이 일어난다.

JVM에는 Loop Unrolling이라는 또 다른 최적화 기법이 있는데,
Loop Unrolling은 미리 내부 코드를 복제하여 반복을 줄인다.
예를 들어,

for (int i = 0; i < 4; i++) {
    sum += arr[i];
}

이러한 코드를

sum += arr[0];
sum += arr[1];
sum += arr[2];
sum += arr[3];

이렇게 미리 바꾸어 둔다. 이러한 동작은 반복으로 인한 분기 및 인덱스 계산에 대한 오버헤드를 줄일 수 있다.

다시 락 확장으로 돌아와서 Loop Unrolling을 적용시켜보면,

for (int i = 0; i < 3; i++) {
  synchronized (obj) {
    doSomething(i);
  }
}

해당 코드는

synchronized (obj) {
    doSomething(0);
}
synchronized (obj) {
    doSomething(1);
}
synchronized (obj) {
    doSomething(2);
}

이렇게 바뀌게 된다. 이럼, 처음의 예시와 같이 락 확장이 작동하여

synchronized (obj) {
    doSomething(0);
    doSomething(1);
    doSomething(2);
}

이렇게 바뀌게 된다.

꼭 좋은건 아님

락 확장은 그냥 보기엔 단점이 없어보이지만, 오히려 성능상의 문제를 야기할 수 있다.

//Thread 1
synchronized(obj){
  doSomething1();
}
synchronized(obj){
  doSomeThing2();
}

원래 이러한 코드가 있었고, 다른 스레드에서

//Thread 2
synchronized(obj){
  doSomething3();
}

이렇게 obj락에 대해서 경쟁하는 상황이라고 하면

락 확장이 없는 경우엔 doSomething1()이 끝나고 나서 doSomething3()이 실행 될 수도 있지만, 락 확장이 있다면 doSomething1()doSomething2()이 끝나야 doSomething3()이 실행될 수 있다.

만약 doSomething2()가 꽤 오래 걸리는 작업이라고 한다면 doSomething3()의 실행이 지나치게 늦어질 수도 있다.
이처럼 많은 작업들이 락 확장의 대상이 될수록, 다른 스레드들의 작업을 지나치게 늦추는 문제가 발생할 수 있다.

마무리 총총

JVM의 JIT 최적화는 파도파도 새로운것이 넘쳐나고,
그 최적화 방법들을 볼수록 영감을 얻고, 감탄이 나온다.

profile
gsm 8기 고등학생

0개의 댓글