티스토리 뷰

Java & Kotlin

[JVM] JIT 컴파일러(Just-In Time Compiler)의 다양한 최적화 기술들 알아보기(Optionazation techniques)

망나니개발자 2024. 3. 19. 10:00
반응형

 

 

 

 

1. JIT 컴파일러(Just-In Time Compiler)란?


[ JIT 컴파일러(Just-In Time Compiler)란? ]

자바 컴파일러(javac)는 자바 파일을 읽고 클래스 파일로 컴파일한다. 바이트코드는 모든 연산 코드가 단일 바이트로 표시되며 플랫폼에 종속되지 않는(portable) 명령어 집합으로 구성된다. 이들은 자바 가상 머신(JVM)에서 실행되며 실행 시점에 기계어로 번역한다. 덕분에 특정 아키텍처에 종속적되지 않지만 성능은 훨씬 떨어진다.

JVM 초기에는 실행 시점에 모든 바이트코드를 직접 기계어로 해석했기 때문에 성능 문제가 있었다. 이를 위해 JDK 1.3에 HotSpot VM이 도입되어 런타임에 바이트코드를 느리게 해석하는 대신 최적화된 코드를 실행할 수 있는 JIT(Just In Time) 컴파일을 제공하게 되었다.

 

 

이를 위해 JVM은 실행되는 코드를 주의 깊게 관찰한다. 코드의 자주 실행되는 부분을 분석하여 최적화할 부분을 결정하고, 코드 실행 방식까지 분석하여 어떤 최적화를 적용할 수 있는지 대상 아키텍처에 맞게 결정한다. 이때 컴파일은 백그라운드 스레드에서 이루어지므로 프로그램 실행을 방해하지는 않는다.

일부 최적화는 더 자세한 프로필(profile)이 필요하고 일부는 필요하지 않는다. 따라서 일부 최적화는 다른 최적화보다 일찍 적용될 수 있으며 일부 최적화는 애플리케이션이 장시간 실행될 경우에만 최적화 된다. 이러한 차이로 인해 JVM에는 클라이언트(c1) 컴파일러와 서버(c2) 컴파일러 두 가지를 갖고 있다.

 

 

 

  • c1 클라이언트 컴파일러(Client Compiler)
    • 코드 최적화는 덜하지만 즉시 시작되는 속도는 빠름
    • 즉시 실행되는 데스크톱 애플리케이션 등에 적합함
  • c2 서버 컴파일러(Server Compiler)
    • 즉시 시작되는 속도는 느리지만 최적화는 많이 되어 warm-up 후에는 빠름
    • 장기 실행되는 서버 애플리케이션 등에 적합함

 

 

Java 6에서는 c1 컴파일러와 c2 컴파일러 중 하나를 선택해야 했지만, Java 7부터는 계층형 컴파일을 사용할 수 있는 옵션이 추가되었고, Java 8부터는 이것이 JVM의 기본 동작이 되었다. 이 접근 방식은 c1 컴파일러와 c2 컴파일러를 모두 사용한다.

 

 

Hotspot VM은 초기에 인터프리터를 사용해서 최적화 없이 코드를 실행하지만, Hotspot VM은 각 메소드의 호출 여부를 계속해서 주시한다. 각 메서드의 호출 횟수를 추적하고, 호출 횟수가 C1 컴파일러의 임계값을 초과하면 해당 메소드를 C1 컴파일러 대기열에 넣고 재컴파일하여 최적화한다. 이후에도 계속 각 메서드의 호출 횟수를 추적하여 동일하게 최적화를 진행하는데, C1 이후에는 C2 컴파일러를 사용한다. 이렇듯 컴파일러를 순차적으로 적용하는 방식을 계층형 컴파일(Tiered Compliation)이라고 한다.

 

 

이때 컴파일되어 최적화된 코드는 코드 캐시라고 하는 특수 힙에 저장된다. 관련 클래스가 언로드될 때까지 또는 코드가 최적화되지 않을 때까지 코드 캐시에 저장된다. 참고로 코드 캐시는 크기가 한정되어 있으므로 공간이 부족하면 더 이상 코드를 컴파일할 수 없어 성능 문제가 발생할 수 있다.

Java HotSpot(TM) 64-Bit Server VM warning: CodeCache is full. Compiler has been disabled.
Java HotSpot(TM) 64-Bit Server VM warning: Try increasing the code cache size using -XX:ReservedCodeCacheSize=
CodeCache: size=3072Kb used=2528Kb max_used=2536Kb free=544Kb
 bounds [0x00007f12efae5000, 0x00007f12efde5000, 0x00007f12efde5000]
 total_blobs=989 nmethods=629 adapters=272
 compilation: disabled (not enough contiguous free space left)

 

 

즉, JVM은 대상 아키텍처와 코드의 동적인 동작 방식에 대한 사용 통계 정보를 얻을 때까지 컴파일을 연기한다. 미리 네이티브코드로 컴파일하는 AOT(Ahead-Of-Time) 컴파일러와 다르게 동적으로 적시에 지연 컴파일(Deferred Compilation)한다. JIT 컴파일러와 AOT 컴파일러에 대한 자세한 내용은 여기서 참고하도록 하자.

  • 런타임 정보 및 통계를 고려해 최적화를 함
  • 애플리케이션의 시작 시간이 느려지지만 나중에 최고 성능에 도달하게 됨
  • 특정 플랫폼에 종속적이게 컴파일할 필요 없이 가상머신이 이를 해결해줌

 

 

 

2. JIT 컴파일러(Just-In Time Compiler)의 다양한 최적화 기술들
(Optionazation techniques)


JIT(Just In Time) 컴파일과 다양한 최적화 기법 덕분에 JVM 최적화와 코드의 성능 향상에 대한 많은 방법들이 나오고 있다. 그래서 이번에는 해당 최적화가 실제로 어떻게 적용되는지 직접 확인하고 측정하고자 한다. 참고로 측정이 수행되는 JVM 구현과 아키텍처마다 다를 수 있으므로, 정확한 측정값의 관점이 아니라는 점을 알아두도록 하자.

 

 

[ Inlining(인라인) ] 

Inlining은 메서드의 본문에 코드를 삽입하여 메서드 호출 비용을 제거하는 방식이다. 관련 코드를 함께 이동하면 하위 표현식 제거 또는 힙 할당 제거와 같이 개별 메서드에서는 불가능한 추가 최적화가 가능하다. 물론 모든 것을 인라인 처리하는 것은 비현실적이므로 기본적으로 컴파일된 크기가 35바이트 미만인 메서드 또는 325 바이트보다 작은 핫 메서드에 대해 작동한다. 이때 크기 제한은 assertion 문이 비활성화되어 있더라도 고려되며, 임계값은 JVM 옵션(-XX:MaxInlineSize, -XX:FreqInlineSize)을 통해 변경할 수 있다.

 

예를 들어 다음의 add 메서드는 최종적으로 인라인될 것이다.

public void inliningExample() {
	int sum = add(100, 2000);
	// Do something with sum.
}

public static int add(int a, int b) {
	return a + b;
}

 

 

다음은 add 메서드를 점점 크게 만들었을 때 inline 되는 효과를 측정한 것이다. 메서드가 특정 크기를 넘어서면 성능이 명확하게 저하되는 것을 보여준다.

 

 

inlining은 다형성과 연관된다. 메서드 호출을 보낼때(dispatch) 해당 메서드의 구현이 얼마나 다양한지 확인한다. 구현체의 수에 따라 inline 최적화 적용이 달라지는데, 구현체가 2개보다 많을 경우는 inline이 불가능하다.

  • 구현체가 하나일 경우(monomorphic dispatch)
  • 구현체가 두개일 경우(bimorphic dispatch)
  • 구현체가 두개보다 많을 경우(megamporphic dispatch)

 

 

다음과 같은 코드로 실험한 결과 구현체가 하나일 경우가 3개 이상일 경우보다 2배 더 빨랐다.

public abstract interface Animal { public String say();}
public class Dog implements Animal {public String say() {return "bark";}}
public class Cat implements Animal {public String say() {return "meow";}}
public class Octopus implements Animal {public String say() {return "...";}}
// More animals if needed...

public void inlining() {
	Animal a;
	if (i == 0) { // i is a random number from 0 to 2.
		a = new Dog();
	} 
	else if (i == 1) {
		a = new Cat();
	}
	else if (i == 2) {
		a = new Octopus();
	} 
	else {
		throw new NullPointerException();
	}
	
	String result = a.say();
	// Do something with the result.
}

 

 

 

 

[ Escape Analysis, Allocation Elimination, Lock Elision
(이스케이프 분석, 할당 제거, 잠금 제거) ]

객체가 현재 메서드 또는 스레드의 범위를 벗어날 수 있는지 확인하기 위해 JVM이 수행하는 범위 분석(scope analysis)을 escape analysis라고 한다. 객체가 단일 스레드에서만 실행된다면 잠금을 제거할 수 있으므로 불필요한 동기화(synchronization)로 인해 성능 저하가 발생하지 않는다. 또한 객체가 한 메서드의 컨텍스트에만 머무르면 힙 할당도 피할 수 있으므로, 이는 앞의 inline의 도움을 받아 적용될 수도 있다.

예를 들어 다음 코드의 동기화 섹션(synchronization section)은 현재 스레드에서만 접근할 수 있으므로 효과가 없다. 따라서 JVM은 이를 인식하고 실행된 코드에서 잠금 관련 부분을 제거하여 동기화 블록이 전혀 없는 것과 같은 성능을 낸다.

public void lockElision() {
	A a = new A();
	synchronized (a) {
		// Do something meaningful.
	}
}

 

 

이 기법은 더욱 복잡한 상황에서도 작동한다. 예를 들어 StringBuilder 대신 StringBuffer를 사용하는 코드에 대해 잠금을 없애 최적화해준다.

public void testMethod() {
	StringBuffer sb = new StringBuffer();
	for (int i = 0; i < 1000; i++) {
		String s = "adding stuff";
		sb.append(s);
	}
	String result = sb.toString();
	// Do something with the result.
	
}

 

 

실험 결과 StringBuilder를 사용한 경우와 거의 동일한 성능을 달성했다. StringBuffer가 해당 메서드를 벗어나 사용되지만 않는다면 StringBuilder 만큼 성능이 좋은 것이다. 그러나 StringBuffer의 참조가 벗어난다면 최적화가 되지 않아서 성능이 떨어진다.

 

 

 

 

 

 

[ Lock Coarsening(잠금 조정) ]

Lock Elision(잠금 제거)가 적용되지 않는 경우 Lock Coarsening(잠금 조정)을 통해 성능을 개선할 수 있다. 이 최적화는 동일한 잠금 객체가 사용되는 인접한 동기화 블록을 병합하여 동기화 오버헤드를 줄인다. 다음과 같은 코드에서 첫 번째 메서드는 앞선 2개의 동기화 블록이 동일한 객체에 잠긴다. 따라서 JVM은 이를 병합하여 실행시키는데, 그 결과로 두 번째 메서드와 성능 측면에서 동일해진다. 반면에 세 번째 메서드는 최적화가 불가능하므로 개별 블록을 유지해야 한다.

public void canBeMerged() {
	synchronized (A.class) {
		// Do something 1.
	}
	
	synchronized (A.class) {
		// Do something 2.
	}

	synchronized (B.class) {
		// Do something 3.
	}
}

public void manuallyMerged() {
	synchronized (A.class) {
		// Do something 1.
		// Do something 2.
	}

	synchronized (B.class) {
		// Do something 3.
	}
}

public void canNotBeMerged() {
	synchronized (A.class) {
		// Do something 1.
	}
	
	synchronized (B.class) {
		// Do something 2.
	}

	synchronized (A.class) {
		// Do something 3.
	}
}

 

 

실행 결과를 보면 마지막 메서드는 최적화가 되지 않았음을 확인할 수 있다.

 

 

[ Dead Code Elimination(데드 코드 제거) ]

데드 코드(Dead code)는 프로그램 결과에 아무런 영향을 미치지 않는 코드이다. 대부분의 경우 이러한 코드는 성능을 제외하고 눈에 띄는 동작 변화 없이 감지 및 제거할 수 있다.

예를 들어 아래의 코드에서 계산된 결과로 아무 작업도 하지 않는다면 초기화 및 덧셈 연산을 할 필요가 없다. 그러면 배열도 아무 용도도 없으므로 createValues 메서드 호출도 건너뛸 수 있고, size도 불필요하다.

public void deadCodeElim() {
	int size = 20000;
	int[] values = createValues(size);
	
  // 계산한 result는 사용되지 않음
	int result = 0;
	for (int value : values) {
		result += value;
	}
}

public int[] createValues(int size) {
	int[] values = new int[size];
	for (int i = 0; i < values.length; i++) {
		values[i] = i;
	}
	return values;
}

 

 

JVM은 이러한 최적화의 일환으로 불필요한 코드를 모두 제거하여 구현 속도가 훨씬 빨라진다.

 

 

[ Code motion(코드 이동) ]

Code motion(코드 이동)은 핫 패스에서 코드의 일부를 재배치하여 실행 횟수를 줄이는 기법이다. Expression hoisting은 이러한 최적화 형태 중 하나로, 코드의 의미를 변경하지 않고 루프 외부로 본문을 이동하여 실행 그래프에서 표현식을 끌어올리는 것이다.

아래의 코드에서 result 변수와 관련된 표현식은 모든 루프 반복에 대해 동일한 결과가 보장되므로 루프 불변성이다.

public void hoisting(int x) {
	for (int i = 0; i < 1000; i = i + 1) {
		// Loop invariant computation
		int y = 654;
		int result = x * y;
		
		// Meaningful work with the result
	}
}

 

 

따라서 JVM은 이를 인지하고 계산을 루프 밖으로 빼내서 최적화한다.

public void manuallyOptimized(int x) {
	int y = 654;
	int result = x * y;
	for (int i = 0; i < 1000; i = i + 1) {
		// Meaningful work with the result
	}
}

 

 

만약 다음과 같이 반복분을 변경하여 루프 불변성을 깨뜨려 최적화가 되지 않도록 해보자.

public void noHoisting(int x) {
  for (int i = 0; i < 1000; i = i + 1) {
		// Loop invariant computation - not anymore
		int y = i;
		int result = x * y;

		// Meaningful work with the result
	}
}

 

 

실험 결과 최적화가 되지 않은 경우에는 초당 가능한 연산의 수가 확연히 줄었음을 확인할 수 있다.

 

 

Hoisting과 유사하게 영향을 받는 코드를 실행 그래프 아래로 이동시키는 최적화 전략이 있는데, 이를 Expression sinking이라고 한다. 이는 미리 계산된 값이 이후의 모든 분기에서 사용되지 않을 때 유용하다.

예를 들어 다음과 같은 코드가 있고, else 문에서는 result가 사용되지 않는다고 하자.

public void sinking(int i) {
	// Expensive operation
	int result = 543 * i;

	if (i % 2 == 0) {
		// Do some work with the calculated result
	} else {
		// Do some work, but ignore the result.
	}
}

 

 

JVM은 이러한 패턴을 인식하고 다음과 같이 코드를 최적화한다.

public void manuallyOptimized(int i) {
	if (i % 2 == 0) {
		// Expensive operation
		int result = 543 * i;

		// Do some work with the calculated result
	} else {
		// Do some work, but ignore the result.
	}
}

 

 

이에 대한 실행 결과를 측정해보면 다음과 같다.

 

 

[ Redundant store elimination(중복 저장 제거) ]

JVM은 저장된 값에 액세스하기 전에 그 결과를 덮어쓰는 경우가 있는지 확인한다.

다음의 코드에서 result의 초기 할당은 프로그램에 아무런 영향을 미치지 않는다. 이러한 경우 JVM은 불필요한 계산을 제거한다.

int x = i;
int y = 654;
int result = x * y;
result = 5;

 

 

따라서 기술적으로는 의미적으로나 성능 측면에서나 단순한 상수 할당과 동일하다.

int result = 5;

 

 

 

실행 결과를 보면 JVM이 실제로 이 코드를 최적화할 수 있음을 볼 수 있다.

 

 

[ Loop unswitching(루프 분리) ]

Loop unswitching(루프 분리)은 컴파일러가 조건문을 복제하여 루프 밖으로 이동시키는 Code Motion(코드 이동)의 특수한 경우로 간주할 수 있다.

예를 들어 아래의 코드에서 myCondition은 루프 불변이므로 반복문 내부에서 어떤 분기를 취할지 계산할 필요가 없다.

public void unswitching(int[] array, boolean myCondition) {
	for (int i = 0; i < array.length; i++) {
		if (myCondition) {
			// Some work
		} else {
			// Other work
		}
	}
}

 

 

컴파일러는 코드의 의미를 변경하지 않고도 다음과 같이 코드를 변환할 수 있다.

public void unswitching(int[] array, boolean myCondition) {
	if (myCondition) {
		for (int i = 0; i < array.length; i++) {
			// Some work
		}
	} else {
		for (int i = 0; i < array.length; i++) {
			// Other work
		}
	}
}

 

 

 

 

[ Loop peeling(루프 빼기) ]

배열을 반복하는 동안 처리해야 하는 특별한 경우가 있다면 대개 첫 번째 요소에 관한 것이다.

Loop peeling(루프 빼기)는 이러한 경우를 최적화하기 위한 전략으로, 첫 번째 또는 마지막 몇 번의 반복을 루프 본문 밖으로 이동하고 루프의 반복 범위를 변경한다.

다음과 같은 코드가 있다고 할 때 i가 0인지에 대한 연산을 반복하게 된다.

int[] array = new int[1000];

for (int i = 0; i < array.length; i++) {
	if (i == 0) {
		// Handling special case
		array[i] = 42;
	} else {
		// Regular operation
		array[i] = i * 2;
	}
}

 

 

이러한 코드는 최적화(그리고 이후 일부 데드 코드 제거)를 통해 더 나은 성능으로 대체될 수 있다.

int[] array = new int[1000];

// Handling special case
array[0] = 42;

for (int i = 1; i < array.length; i++) {
	// Regular operation
	array[i] = i * 2;
}

 

 

만약 조건을 다음과 같이 특수하게 변경하면 성능 이득이 사라진다.

int[] array = new int[1000];

for (int i = 0; i < array.length; i++) {
	if (i == 500) {
		// Handling special case - in the middle
		array[i] = 42;
	} else {
		// Regular operation
		array[i] = i * 2;
	}
}

 

 

최적화 결과를 보면 다음과 같다.

 

 

 

 

다음 포스팅에서는 JIT 컴파일러의 최적화들이 어떻게 수행되는지 살펴보도록 하자. 위의 내용은 해외의 포스팅을 번역 및 요약 그리고 재정리한 내용이다. 원문은 참고자료를 참고하도록 하자.

 

 

 

관련 포스팅

 

 

참고 자료

 

 

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함