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 최적화는 파도파도 새로운것이 넘쳐나고,
그 최적화 방법들을 볼수록 영감을 얻고, 감탄이 나온다.