Introduction
Kotlin's lateinit var allows delayed initialization of non-nullable properties without the overhead of nullable types. However, lateinit is not thread-safe — if multiple threads access a lateinit var before it is initialized, or if one thread checks ::property.isInitialized while another thread is assigning it, race conditions occur. The UninitializedPropertyAccessException is thrown when code accesses the property before assignment, and in concurrent scenarios this can happen intermittently, making the bug difficult to reproduce.
Symptoms
UninitializedPropertyAccessExceptionthrown intermittently in production- Property appears initialized in one thread but not another
isInitializedcheck passes but property is null on access- Race condition between background thread initialization and UI thread access
- Tests pass consistently but production crashes randomly
Error output:
``
kotlin.UninitializedPropertyAccessException: lateinit property userManager has not been initialized
at com.example.app.AppContainer.getUserManager(AppContainer.kt:15)
at com.example.app.MainActivity.onCreate(MainActivity.kt:32)
Common Causes
lateinit varaccessed before initialization completes on background threadisInitializedcheck not synchronized with assignment- Multiple threads racing to initialize the same
lateinit var lateinitin singleton or shared component accessed before app fully starts- Unit test accessing
lateinitproperty before test setup completes
Step-by-Step Fix
- 1.Replace lateinit with by lazy for thread-safe lazy initialization:
- 2.```kotlin
- 3.// WRONG - lateinit is not thread-safe
- 4.class UserRepository {
- 5.lateinit var database: RoomDatabase
fun init(context: Context) { database = Room.databaseBuilder( context, AppDatabase::class.java, "app-db" ).build() }
fun getUsers(): List<User> { return database.userDao().getAll() // May throw if not initialized } }
// CORRECT - by lazy is thread-safe by default (synchronized) class UserRepository { val database: RoomDatabase by lazy { Room.databaseBuilder( applicationContext, AppDatabase::class.java, "app-db" ).build() }
fun getUsers(): List<User> { return database.userDao().getAll() // Safe - initialized on first access } }
// For lazy initialization with different thread safety modes class ServiceManager { // SYNCHRONIZED (default) - only one thread initializes, all others wait val serviceA: ServiceA by lazy { ServiceA() }
// PUBLICATION - multiple threads may initialize, first value wins val serviceB: ServiceB by lazy(LazyThreadSafetyMode.PUBLICATION) { ServiceB() }
// NONE - no thread safety, fastest, only for single-threaded access val serviceC: ServiceC by lazy(LazyThreadSafetyMode.NONE) { ServiceC() } } ```
- 1.Use @Volatile with setter for mutable late-initialized properties:
- 2.```kotlin
- 3.// When you MUST mutate after initialization
- 4.class AppConfig {
- 5.@Volatile
- 6.private var _config: Config? = null
val config: Config get() = _config ?: throw IllegalStateException("Config not initialized")
fun initialize(config: Config) { if (_config != null) { throw IllegalStateException("Config already initialized") } _config = config }
val isInitialized: Boolean get() = _config != null }
// Singleton pattern with double-checked locking object DatabaseProvider { @Volatile private var instance: AppDatabase? = null
fun getInstance(context: Context): AppDatabase { return instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "app-db" ).build().also { instance = it } } } } ```
- 1.Use nullable type with require for safety:
- 2.```kotlin
- 3.// When lateinit is unavoidable (e.g., Android lifecycle)
- 4.class MyActivity : AppCompatActivity() {
- 5.private var adapter: UserAdapter? = null
private val safeAdapter: UserAdapter get() = adapter ?: throw IllegalStateException( "Adapter not initialized. Ensure setupAdapter() is called in onCreate()" )
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setupAdapter() }
private fun setupAdapter() { adapter = UserAdapter(userDiffCallback) recyclerView.adapter = adapter }
fun updateUsers(users: List<User>) { safeAdapter.submitList(users) // Throws with clear message if not set up } }
// Check lateinit before access (not thread-safe, use with caution) class TestSetup { lateinit var service: MockService
fun testSomething() { if (!::service.isInitialized) { service = MockService() } // Still not thread-safe - another thread could change state between check and use } } ```
Prevention
- Prefer
by lazyoverlateinit varwhenever possible - Use constructor injection instead of late initialization when feasible
- If
lateinitis required (Android lifecycle), add null checks orisInitializedguards - Document which thread is responsible for initialization
- Use
@Volatilefor mutable shared state accessed from multiple threads - Write tests that access properties from multiple threads to catch race conditions
- Consider using dependency injection (Hilt, Koin) to manage lifecycle instead of manual lateinit