Lazy와 lateinit

Arakene·2025년 7월 7일
post-thumbnail

이 둘의 차이점 중 동시성 처리때문에 사용처가 갈린다는 말을 듣고 한번 뜯어보려고 한다.

둘의 차이점 간단 표 정리

항목lazylateinit
대상val (읽기 전용)var (변경 가능)
초기화 시점처음 접근 시 (Lazy Evaluation)개발자가 명시적으로 초기화
스레드 안전성기본적으로 스레드 안전 (LazyThreadSafetyMode.SYNCHRONIZED)스레드 안전하지 않음
Nullable 가능 여부가능 (val x: String? by lazy { ... })불가 (lateinit var x: String? ❌)
사용 용도계산 비용이 크거나 나중에 필요할 때의존성 주입, 테스트용 객체 등 나중에 값을 지정해야 할 때
초기화 검사자동으로 초기화 여부 체크.isInitialized로 수동 체크 필요

Lazy는 왜 동시성에서 안전할까?

lazy는 총 세가지 모드를 가지고 있으며 기본값이 thread-safe한 LazyThreadSafetyMode.SYNCHRONIZED를 사용하고있다.

public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)
public actual fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T> =
    when (mode) {
        LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer)
        LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer)
        LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer)
    }

LazyThreadSafetyMode.SYNCHRONIZED

lazy의 내부 구현을 확인해보면 아래 이미지처럼 synchronized와 lock을 이용해서 동기화를 하고있는걸 확인할 수 있다.

private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
    private var initializer: (() -> T)? = initializer
    @Volatile private var _value: Any? = UNINITIALIZED_VALUE
    // final field is required to enable safe publication of constructed instance
    private val lock = lock ?: this

    override val value: T
        get() {
            val _v1 = _value
            if (_v1 !== UNINITIALIZED_VALUE) {
                @Suppress("UNCHECKED_CAST")
                return _v1 as T
            }

            return synchronized(lock) {
                val _v2 = _value
                if (_v2 !== UNINITIALIZED_VALUE) {
                    @Suppress("UNCHECKED_CAST") (_v2 as T)
                } else {
                    val typedValue = initializer!!()
                    _value = typedValue
                    initializer = null
                    typedValue
                }
            }
        }

    override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE

    override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."

    private fun writeReplace(): Any = InitializedLazyImpl(value)
}

LazyThreadSafetyMode.PUBLICATION

위에 나온 방식은 sync를 사용해 무조건 락으로 오버헤드가 발생한다. 반면 PUBLICATION 방식은 락은 없지만 동시성을 해결하고싶을 때 사용한다고 한다.

private class SafePublicationLazyImpl<out T>(initializer: () -> T) : Lazy<T>, Serializable {
    @Volatile private var initializer: (() -> T)? = initializer
    @Volatile private var _value: Any? = UNINITIALIZED_VALUE
    // this final field is required to enable safe initialization of the constructed instance
    private val final: Any = UNINITIALIZED_VALUE

    override val value: T
        get() {
            val value = _value
            if (value !== UNINITIALIZED_VALUE) {
                @Suppress("UNCHECKED_CAST")
                return value as T
            }

            val initializerValue = initializer
            // if we see null in initializer here, it means that the value is already set by another thread
            if (initializerValue != null) {
                val newValue = initializerValue()
                if (valueUpdater.compareAndSet(this, UNINITIALIZED_VALUE, newValue)) {
                    initializer = null
                    return newValue
                }
            }
            @Suppress("UNCHECKED_CAST")
            return _value as T
        }

    override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE

    override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."

    private fun writeReplace(): Any = InitializedLazyImpl(value)

    companion object {
        private val valueUpdater = java.util.concurrent.atomic.AtomicReferenceFieldUpdater.newUpdater(
            SafePublicationLazyImpl::class.java,
            Any::class.java,
            "_value"
        )
    }
}

value의 getter를 보면 락이 없고 compareAndSet을 통해 초기화를 시도하고 성공하면 해당 값을, 실패하면 기존에 할당된 값을 사용하게 된다.

락이 없는 만큼 초기화 부분이 여러번 일어날 수 있으며 최초에 할당된 값을 사용하게 된다.

LazyThreadSafetyMode.NONE

아름에서도 알 수 있듯이 thread-safe하지 않는 lazy를 생성하게 된다.
동기화 관련 처리가 필요없으면서 lazy를 사용하게된다면 사용해서 오버헤드를 줄이면 좋을 것 같다.

internal class UnsafeLazyImpl<out T>(initializer: () -> T) : Lazy<T>, Serializable {
    private var initializer: (() -> T)? = initializer
    private var _value: Any? = UNINITIALIZED_VALUE

    override val value: T
        get() {
            if (_value === UNINITIALIZED_VALUE) {
                _value = initializer!!()
                initializer = null
            }
            @Suppress("UNCHECKED_CAST")
            return _value as T
        }

    override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE

    override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."

    private fun writeReplace(): Any = InitializedLazyImpl(value)
}
profile
안녕하세요 삽질하는걸 좋아하는 4년차 안드로이드 개발자입니다.

0개의 댓글