ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 45장 - 불필요한 객체 생성을 피하라
    Kotlin/Effective Kotlin 요약 2023. 4. 8. 00:01

    객체 생성의 비용

    객체 생성은 언제나 비용이 들어갑니다.

    JVM에서는 하나의 가상 머신에서 동일한 문자열을 처리하는 코드가 여러 개 있다면, 기존의 문자열을 재사용합니다.

    또한 Int는 -128 ~ 127 범위를 캐시해 둡니다.

     

    객체 생성 비용은 항상 클까?

    현재 64비트 JDK에서 객체는 8바이트의 배수만큼 공간을 차지합니다.

    앞부분 12바이트는 헤더로서 반드시 있어야 하므로, 최소 크기는 16바이트입니다.

    기본 자료형 int는 4바이트입니다.

    하지만 오늘날 널리 사용되고 있는 64비트 JDK에 랩 되어 있는 Integer는 16바이트입니다.

    추가로 이에 대한 레퍼런스로 인해 8바이트가 더 필요해 5배 이상의 공간을 차지한다고 볼 수 있습니다.

     

    객체는 생성되어야 하며, 메모리 영역에 할당되고, 이에 대한 레퍼런스를 만드는 등의 작업이 필요합니다.

    적은 비용이지만, 모이면 굉장히 큰 비용이 됩니다.

     

    객체 재사용

    객체를 재사용하는 간단한 방법은 싱글톤을 사용하는 것입니다.

    매 순간 객체를 생성하지 않으며 기존에 존재하는 객체를 사용합니다.

    sealed class  LinkedList<T>
    
    class Node1<T>(
        val head: T,
        val tail: LinkedList<T>
    ): LinkedList<T>()
    
    class Empty<T>: LinkedList<T>()
    
    fun main() {
        val list = Node1(1, Node1(2, Node1(3, Empty())))
        val list2 = Node1(1, Node1(2, Node1(3, Empty())))
    }

    Empty 인스턴스를 계속 만들어내야 합니다.

     

    sealed class  LinkedList<out T>
    
    class Node1<T>(
        val head: T,
        val tail: LinkedList<T>
    ): LinkedList<T>()
    
    object Empty : LinkedList<Nothing>()
    
    fun main() {
        val list = Node1(1, Node1(2, Node1(3, Empty)))
        val list2 = Node1(1, Node1(2, Node1(3, Empty)))
    }

    이제 Empty를 재사용하게 됩니다.

    보통 immutable sealed 클래스를 정의할 때 자주 사용됩니다.

    mutable 객체에 사용하면 공유 상태 관리와 관련된 버그를 검출하기 어려울 수 있으므로 좋지 않습니다.

     

    캐시를 활용하는 팩토리 함수

    일반적으로 객체는 생성자를 사용해서 만듭니다.

    하지만 팩토리 메서드를 사용해서 만드는 경우도 있습니다.

    팩토리 함수는 캐시를 가지게 할 수 있으며, 같은 객체를 리턴하게 만들 수도 있습니다.

     

    실제로 stdlib의 emptyList는 이를 활용해서 구현되어 있습니다.

    fun <T> List<T> emptyList(){
    	return EMPTY_LIST;
    }

     

    다만 캐시를 활용하는 경우 메모리를 더 많이 사용할 수 있습니다.

    이때 GC가 자동으로 메모리를 해제해 주는 SoftReference를 사용하면 더 좋습니다.

     

    SoftReference vs WeakReference

    • WeakReference는 GC가 값을 정리하는 것을 막지 않습니다.
    • SoftReference는 GC가 값을 정리할 수도 있고, 정리하지 않을 수도 있습니다. (메모리가 부족한 경우에만 정리)

    무거운 객체를 외부 스코프로 보내기

    최댓값의 수를 세는 확장 함수를 만드는 경우를 생각해 보겠습니다.

    컬렉션 처리에서 이루어지는 무거운 연산은 내부에서 외부로 빼는 것이 좋습니다.

    fun <T: Comparable<T>> Iterable<T>.countMax(): Int =
    	count {it == this.max()}

    위의 코드를 조금 수정하면 다음과 같이 만들 수 있습니다.

     

    fun <T: Comparable<T>> Iterable<T>.countMax(): Int {
    	val max = this.max()
    	return count {it == max}
    }

    max를 호출하는 수가 줄어들어 성능이 향상되고 가독성도 향상됩니다.

     

    당연하게 보이지만 자주하는 실수입니다.

     

    비슷한 예시로 문자열이 IP주소 표현식을 갖는지 확인하는 함수가 있습니다.

    fun String.isValidIpAddress(): Boolean {
        return this.matches("\\A(?:(?:25[0-5]2[0-4]....\\z".toRegex())
    }

    이 함수를 사용할 때 마다 Regex 객체를 반복적으로 생성해야 합니다.

    정규 표현식 패턴을 컴파일하는 과정을 꽤 복잡한 연산이라 성능적으로 문제를 일으킵니다.

     

    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)
    }

    정규 표현식을 톱레벨로 보내 문제를 해결할 수 있습니다.

    하지만 함수를 사용하지 않는다면 정규 표현식이 만들어지는 것 자체가 낭비입니다.

    지연 초기화까지 도입합니다.

     

    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)
    }

    이제 프로퍼티를 지연되게 만들어 무거운 클래스를 용이하게 사용할 수 있습니다.

     

    lazy의 단점

    빠른 응답이 필요한 백엔드 애플리케이션에서 무거운 객체가 지연되게 만들어졌다고 가정하겠습니다.

    응답이 들어오게 되면 객체의 생성이 이루어지는데 첫 번째 호출 때 응답 시간이 굉장히 길어질 수 있습니다.

    따라서 지연 초기화는 상황에 맞게 사용해야 합니다.

     

    기본 자료형 사용하기

    JVM은 숫자와 문자 등의 기본적인 요소를 나타내기 위한 기본 자료형(primitives)를 가지고 있습니다.

    JVM 컴파일러는 내부적으로 기본 자료형을 최대한 사용합니다.

     

    다만 다음과 같은 상황에서는 기본 자료형을 wrap한 자료형을 사용합니다.

    • nullable 타입을 연산(기본 자료형은 null 일 수 없음)
    • 타입을 제네릭으로 사용할 때

     

    코틀린과 자바의 자료형 비교

    • Kotlin Int  = Java int
    • Kotlin Int? = Java Integer
    • Kotlin List<Int> = Java List <Integer>

     

    굉장히 큰 컬렉션을 처리할 때 차이를 사용할 수 있습니다.

    결과적으로 코드와 라이브러리의 성능이 굉장히 중요한 부분에서 이를 적용할 수 있습니다.

     

    간단한 예로 코틀린으로 컬렉션 내부의 최댓값을 리턴하는 함수를 만들 수 있습니다.

    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
    }

    다음과 같은 심각한 단점들이 존재합니다.

    • 각 단계에서 엘비스 연산자를 사용해야 합니다.
    • nullable 값을 사용하기 때문에 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만 개의 요소를 넣고 함수를 실행하면 성능에 2배 정도의 차이가 존재합니다.

     

    댓글

Designed by Tistory.