상황에 따라 객체 생성에 굉장히 큰 비용이 들어갈 수도 있다. 따라서 불필요한 객체 생성을 피하는 것이 최적화의 관점에서 좋다.
JVM에서는 하나의 가상 머신에서 동일한 문자열을 처리하는 코드가 여러개 있다면, 기존의 문자열을 재사용한다.
val str1 = "Lorem"
val str2 = "Lorem"
print(str1 == str2) // true
print(str1 === str2) // true
Integer와 Long처럼 박싱된 기본 자료형도 작은 경우 재사용 된다.
- Integer는 -128 ~ 127 범위를 캐싱한다.
val i1: Int? = 1
val i2: Int? = 1
println(i1 == i2) // true
println(i1 === i2) // true
val i1: Int? = 1024
val i2: Int? = 1024
println(i1 == i2) // true
println(i1 === i2) // false
nullable 타입은 int 자료형 대신 Integer 자료형을 사용하게 강제된다.
Int를 사용하면 일반적으로 기본 자료형 int로 컴파일된다. 하지만 nullable이나 타입 아규먼트로 사용할 경우 Integer로 컴파일된다.
이는 기본 자료형은 null일 수 없고, 타입 아규먼트로도 사용할 수 없기 때문이다. 이러한 메커니즘은 객체 생성 비용에 큰 영향을 준다.
객체 생성 비용은 항상 클까?
어떤 객체를 랩(wrap)하면 크게 세가지 비용이 발생한다.
- 객체는 더 많은 용량을 차지한다. 객체 뿐만 아니라 객체에 대한 레퍼런스 또한 공간을 차지한다.
- 요소가 캡슐화되어 있다면 접근에 추가적인 함수 호출이 필요하다. 이 비용은 굉장히 적지만 수 많은 객체를 처리하면 이 비용도 굉장히 커진다.
- 객체는 생성되어야 한다. 메모리 영역 할당, 이에 대한 레퍼런스를 만드는 등의 작업이 필요하며 이는 적은 비용이지만 모이면 큰 비용이 된다.
class A
private val a = A()
// 2.698 ns/op
fun accessA(blackhole: Blackhole) {
blackhole.consume(a)
}
// 3.814 ns/op
fun accessA(blackhole: Blackhole) {
blackhole.consume(A())
}
// 3828.540 ns/op
fun accessA(blackhole: Blackhole) {
blackhole.consume(List(1000) { a })
}
// 5322.857 ns/op
fun accessA(blackhole: Blackhole) {
blackhole.consume(List(1000) { A() })
}
위에서 재사용, 객체 생성을 비교한 부분을 보면 미미한 차이이지만 모이면 꽤 큰 차이가 발생하는것을 알 수 있다.
객체를 제거하면 위에서 말한 세가지 비용 모두를 피할 수 있으며 객체를 재사용하면 첫번째와 세번째에 대한 비용을 제거할 수 있다.
객체 선언
객체를 재사용하는 간단한 방법은 객체 선언을 사용하는 것이다.(싱글톤)
sealed class LinkedList<T>
class Node<T>(
val head: T,
val tail: LinkedList<T>
): LinkedList<T>()
class Empty<T>: LinkedList<T>()
val list1: LinkedList<Int> = Node(1, Node(2, Node(3, Empty())))
val list2: LinkedList<String> = Node("A", Node("B", Empty()))
위 구현에서는 List를 만들 때 마다 Empty 인스턴스를 만들어야 한다는 문제점이 있다.
Empty를 하나만 만들고 모든 리스트에서 활용할 수 있게 한다면 좋겠지만 제네릭 타입이 일치하지 않아 문제가 될 수 있다.
이를 해결하기 위해 LinkedList<Nothing>
을 활용할 수 있다. 여기서 리스트는 immutable이고 이 타입은 out 위치에만 사용되므로 현재 상황에서 타입 아규먼트를 covariant로 만드는 것은 의미있는 일이다.
Empty 인스턴스에 대한 문제는 아래와 같이 Nothing을 활용해 개선할 수 있다.
sealed class LinkedList<out T>
class Node<T>(
val head: T,
val tail: LinkedList<T>
): LinkedList<T>()
object Empty: LinkedList<Nothing>()
val list1: LinkedList<Int> = Node(1, Node(2, Node(3, Empty)))
val list2: LinkedList<String> = Node("A", Node("B", Empty))
이러한 트릭은 immutable sealed 클래스를 정의할 때 자주 사용된다.
mutable 객체에 사용하면 공유 상태 관리와 관련된 버그를 검출하기 어려울 수 있으므로 mutable에 위와 같은 트릭을 사용하는 것은 좋지 않다. mutable 객체는 캐시하지 않는다는 규칙을 지키자.
캐시를 활용하는 팩토리 함수
일반적으로 객체는 생성자를 사용해서 만든다. 하지만 팩토리 메소드를 사용해서 만드는 경우도 있다.
팩토리 함수는 캐시를 가질 수 있으며 팩토리 함수에서는 항상 같은 객체를 리턴하게 만들 수 있다.
// stdlib의 emptyList
fun <T> List<T> emptyList() {
return EMPTY_LIST;
}
모든 순수 함수는 캐싱을 활용할 수 있으며 이를 메모이제이션(memoization)이라고 부른다.
private val connections =
mutableMapOf<String, Connection>()
fun getConnection(host: String) =
connections.getOrPut(host) { createConnection(host) }
private val FIB_CACHE = mutableMapOf<Int, BigInteger>()
fun ib(n: Int): BigInteger = FIB_CACHE.getOrPut(n) {
if (n <= 1) BigInteger.ONE else fib(n - 1) + fib(n - 2)
}
위와 같이 메모제이션을 활용할 수 있지만 캐시를 위한 Map을 저장해야 하므로 더 많은 메모리를 사용하는 단점이 있다.
메모리가 필요할 때 GC가 자동으로 메모리를 해제해 주는 SoftReference를 사용하면 더 좋다.
-
WeakReference
- 가비지 컬렉터가 값을 정리하는 것을 막지 않는다. 따라서 다른 레퍼런스가 이를 사용하지 않으면 곧바로 제거된다.
-
SoftReference
- 메모리가 부족해 추가로 필요한 경우만 정리한다. 따라서 캐시를 만들때는 SoftReference를 사용하는것이 좋다.
캐시는 언제나 메모리와 성능의 트레이드 오프가 발생하므로 여러 상황을 잘 고려해 사용하자.
무거운 객체를 외부 스코프로 보내기
무거운 객체를 외부 스코프로 보내는 방법이 있다. 컬렉션 처리에서 이루어지는 무거운 연산은 컬렉션 처리 함수 내부에서 외부로 빼는 것이 좋다.
-
최대값의 수를 세는 확장함수 최적화
// AS-IS fun <T: Comparable<T>> Iterable<T>.countMax(): Int { return count { it == this.max() } }
max를 찾은 뒤 활용하므로 코드의 성능이 좋아진다.
// TO-BE fun <T: Comparable<T>> Iterable<T>.countMax(): Int { val max = this.max() return count { it == max } }
-
정규식 톱 레벨로 보내 최적화하기
// AS-IS fun String.isValidIpAddress(): Boolean { return this.matches("\\A(?:(?:25[0-5]2[0-4]....\\z".toRegex()) }
Regex 객체를 매번 새로 만드는 것은 성능적으로 문제를 일으킨다.
// TO-BE private val IS_VALID_EMAIL_REGEX = "\\A(?:(?:25[0-5]2[0-4]....\\z".toRegex() fun String.isValidIpAddress(): Boolean { return this.matches(IS_VALID_EMAIL_REGEX) }
그리고 이 함수가 한 파일에 다른 함수와 함께 있을 때, 함수를 사용하지 않으면 정규식이 만들어지는 것 자체가 낭비이므로 아래와 같이 지연 초기화를 사용할 수 있다.
// TO-BE + lazy private val IS_VALID_EMAIL_REGEX by lazy { "\\A(?:(?:25[0-5]2[0-4]....\\z".toRegex() } fun String.isValidIpAddress(): Boolean { return this.matches(IS_VALID_EMAIL_REGEX) }
지연 초기화
무거운 클래스를 만들 때 지연되게 만드는 것이 좋을 때가 있다.
class A {
val b = B()
val c = D()
val d = D()
// ..
}
내부의 인스턴스를 지연 초기화하면 A 생성 과정을 가볍게 만들 수 있다.
class A {
val b by lazy { B() }
val c by lazy { D() }
val d by lazy { D() }
// ..
}
이는 아래와 같은 단점도 가지고 있기 때문에 상황에 맞게 사용해야 한다.
- 처음 호출 때 무거운 객체의 초기화가 필요해 첫번째 호출 때 응답 시간이 오래 걸린다.
- 성능 테스트가 복잡해지는 문제가 있다.
기본 자료형 사용하기
JVM은 숫자와 문자 등의 기본 요소를 나타내기 위한 기본 자료형(primitives)을 가지고 있다.
코틀린 / JVM 컴파일러는 내부적으로 이러한 기본 자료형을 사용하지만 다음과 같은 상황에서는 기본 자료형을 랩(wrap)한 자료형이 사용된다.
- nullable 타입을 연산할 때(기본 자료형은 null일 수 없으므로)
- 타입을 제네릭으로 사용할 때
코틀린과 자바 간의 자료형을 간단히 비교하면 아래와 같다.
- Kotlin Int → Java int
- Kotlin Int? → Integer
- Kotlin List
→ List
위 내용을 잘 알면 기본 자료형을 사용하게 코드를 최적화할 수 있다. 이 작업은 굉장히 큰 컬렉션을 처리할 때 차이를 확인할 수 있다.
코틀린으로 컬렉션 내부의 최댓값을 리턴하는 함수를 만들어보자.
fun Iterable<Int>.maxOrNull(): Int? {
var max: Int? = null
for (i in this) {
max = if(i > (max ?: Int.MIN_VALUE)) i else max
}
return max
}
이 구현에는 두가지 심각한 단점이 있다.
- 각각의 단계에서 Elvis operator를 사용해야 한다.
- nullable을 사용해 JVM 내부에서 int가 아니라 Integer로 연산이 일어난다.
두가지 문제를 해결하려면 아래와 같이 while을 사용해 반복을 구현할 수 있다.
fun Iterable<Int>.maxOrNull(): Int? {
val iterator = iterator()
if (!iterator.hasNext()) return null
var max: Int = iterator.next()
while (iterator.hasNext()) {
val e = iterator.next()
if (max < e) max = e
}
return max
}
책에서는 컬렉션에 100~1000만개 요소를 넣었을 경우 518ms, 289ms로 대략 두배정도의 차이가 존재한다고 한다. 하지만 이 정도의 최적화는 성능이 그렇게까지 중요하지 않은 코드에서는 큰 의미가 없는 최적화이다.
라이브러리를 구현한다면 성능이 중요할 수 있다. 위와 같은 최적화는 성능이 아주 중요한 경우에 활용하자.
정리
객체 생성 시 문제를 피하는 아래 방법들을 살펴보았다.
- 캐시를 활용하는 팩토리 함수
- 무거운 객체를 외부 스코프로 보내기
- 지연 초기화
- 기본 자료형 사용하기
이러한 최적화에 큰 변경이 필요하거나, 다른 코드에 문제를 일으킬 수 있다면 최적화를 미루는 것도 방법이다.
Reference
- 이펙티브 코틀린 - 프로그래밍 인사이트, 마르친 모스칼라 지음, 윤인성 옮김
개인적인 기록을 위해 작성된 글이라 잘못된 내용이 있을 수 있습니다.
오류가 있다면 댓글을 남겨주세요.