코틀린이 자바로 컴파일될 때, 그 컴파일 방식이 성능에 영향을 미칩니다. 특히 람다를 파라미터로 전달하는 경우에 대해서 자세히 살펴보겠습니다.
코틀린은 람다를 무명 클래스로 컴파일한다
// 람다 - 무명 객체를 메서드를 호출할 때마다 반복 사용
postponeComputation(1000) {println(42)}
// 람다를 컴파일한다면? 무명 인스턴스가 생성됨
postponeComputation(1000, object: Runnable {
override fun run() { println("hello") }
}
)
코틀린은 기본적으로 람다를 무명클래스로 컴파일하지만 항상 새로운 무명 클래스를 생성하는 것은 아닙니다. 위의 경우는 항상 인스턴스가 생성되지만 사실은 아래와 같이 클래스를 한 번만 생성하고 계속 같은 변수를 이용하여 사용됩니다.
// 객체를 쓰면서 람다와 동일한 케이스
val runnable = Runnable {println(42)}
postponeComputation(1000, runnable) // 람다를 위한 무명 객체 인스턴스 단 한번만 생성
하지만 인스턴스가 항상 생성되는 경우가 존재하는데 그것은 바로 람다가 변수 캡쳐링를 하는 경우입니다. 항상 다른 케이스가 필요하기 때문에 새로운 인스턴스를 항상 생성하는 것은 당연한 일 입니다.
// 람다가 변수를 포획 - 람다가 생성되는 시점마다 새로운 무명 클래스 객체 생성
fun handleComputation(id: String) {
postponeComputation(1000) {println(id)}
}
💡 람다에 대한 진실 정리
1. 람다 식을 사용할 때 마다 새로운 인스턴스가 만들어지지는 않는다. 일반적인 경우 하나의 인스턴스를 생성하고 계속 사용되는 형태이다.
2. 람다가 변수를 포획한다면 (즉 외부변수를 사용한다면) 람다가 생성되는 시점마다 새로운 무명 클래스 객체가 생긴다
인라인 함수
람다가 컴파일될 때 무명클래스 인스턴스를 생성하는 부가 비용을 막기 위해 등장합니다.
인라인 아닌 함수를 컴파일하는 경우
fun add(x: Int, y: Int, action: (Int, Int) -> Int): Int {
return action(x, y)
}
fun main() {
add(1, 3){x, y->x + y}
}
// 컴파일 후
public final class ExampleKt {
public static final int add(int x, int y, @NotNull Function2 action) {
Intrinsics.checkNotNullParameter(action, "action");
return ((Number)action.invoke(x, y)).intValue();
}
public static final void main() {
add(1, 3, (Function2)null.INSTANCE);
}
public static void main(String[] var0) {
main();
}
}
컴파일 후 Function2 타입의 인스턴스를 생성하여 람다를 넘겨주고 있습니다. 이것을 인라인 처리를 한다면 인스턴스를 생성하지 않습니다. 대신 전달할 람다를 미리 바이트 코드로 넣어주는 형태입니다.
inline fun add(x: Int, y: Int, action: (Int, Int) -> Int): Int {
return action(x, y)
}
fun main() {
add(1, 3) { x, y -> x + y }
}
// 컴파일 후
public final class ExampleKt {
public static final int add(int x, int y, @NotNull Function2 action) {
int $i$f$add = 0;
Intrinsics.checkNotNullParameter(action, "action");
return ((Number)action.invoke(x, y)).intValue();
}
public static final void main() {
byte x$iv = 1;
int y$iv = 3;
int $i$f$add = false;
int var5 = false;
int var10000 = x$iv + y$iv;
}
public static void main(String[] var0) {
main();
}
}
인라인 함수 한계
~~inline~~ fun add(x: Int, y: Int, action: (Int, Int) -> Int): Int {
transform(action)
return action(x, y)
}// Illegal usage of inline-parameter
fun main() {
add(1, 3){x, y->x + y}
}
fun transform(func: (Int, Int)-> Int) {
println("func input")
}
- 전달받은 람다를 다른 변수에 저장하고 나중에 그 변수를 사용해야 한다면 람다를 표현하는 객체가 어디가는 존재해야합니다.
- 위의 경우에는 transform 메소드의 인수로 저장을 해야하는 상황입니다. 이러한 상황에서 바이트 코드로 치환함은 불가능하기 때문에 컴파일러는 inline 함수로 선언을 못하도록 막습니다.
시퀀스와 컬렉션 - 시퀀스를 쓰면 안 되는 이유
컬렉션 | 시퀀스 | |
장점 | - 인라인 함수이기 때문에 람다를 위한 무명 객체가 생성되진 않음 | - 중간 연산 결과를 저장하지 않음 |
단점 | - 중간 연산 결과를 저장함. 메모리 사용률이 높음 | - 람다를 저장해야하기 때문에 람다를 인라인 하지 않음. - 크기가 작은 컬렉션은 오히려 인스턴스 생성 비용이 더 크기 때문에 시퀀스를 사용하지 않는게 좋음 |
시퀀스의 지연 연산 기능을 제공하는데도 불구하고, 왜 많은 사람들이 이를 활용하지 않는지에 대한 궁금증이 생길 수 있습니다. 실제로 자바의 스트림에 비해 코틀린 시퀀스의 사용률이 현저히 낮다는 점이 그 이유 중 하나입니다. 이 현상의 결론적인 이유는 시퀀스가 인라이닝을 지원하지 않아 무명 클래스 인스턴스 생성 비용이 높기 때문에, 특히 시퀀스의 크기가 작은 경우에는 효율적이지 못하기 때문입니다.
JVM JIT 인터프리터
JVM 아키텍처는 이미 강력한 인라이닝을 제공하고 있습니다. 코드를 분석하여 이익이 되는 경우에는 해당 부분의 바이트 코드를 실제 기계어 코드로 번역하여 저장합니다. 이후, 미리 저장해둔 기계어 코드를 호출하여 실행시키는 최적화를 수행합니다. 비록 인터프리터가 해석하는 비용이 크지만 해당 부분이 반복적으로 사용된다면 이로 인한 이득을 얻을 수 있습니다.
그러나 코틀린의 인라인 함수는 함수 호출 지점을 함수 본문으로 대치함으로써 코드 중복이 발생합니다. 또한, 함수를 직접 호출하는 것이 스택 트레이스가 깔끔하여 디버깅이 편리하지만, 코틀린의 인라인 함수는 이러한 이점을 제공하지 못합니다. 더불어 대치되는 바이트 코드가 너무 큰 경우에는 코드가 전체적으로 비대해질 수 있는 단점도 있습니다.
그럼에도 불구하고 코틀린의 인라인 함수를 사용하는 이유는 결국 람다를 전달해야 하는 경우 무명 클래스 인스턴스 생성 비용을 최소화할 수 있기 때문입니다. 이를 통해 성능 개선을 이끌어내고자 하는 것이 주된 동기입니다.
'🔍Kotlin' 카테고리의 다른 글
[코틀린인액션] 9. 제네릭스 (2) | 2024.03.31 |
---|---|
[코틀린 인 액션] backing 필드, backing 프로퍼티 그리고 위임 프로퍼티 (0) | 2024.03.03 |