DEV Community

Cover image for Effective Ways to Use Locks in Kotlin
Arseni Kavalchuk
Arseni Kavalchuk

Posted on

Effective Ways to Use Locks in Kotlin

In Kotlin, locks play a critical role in ensuring thread safety when multiple threads access shared resources. Here are the common idiomatic ways to use locks in Kotlin:


1. Using ReentrantLock with lock() and unlock()

The most explicit way to use a lock in Kotlin is by calling lock() and unlock() manually. This provides fine-grained control over when the lock is acquired and released.

Example:

import java.util.concurrent.locks.ReentrantLock

class SafeCounter {
    private val lock = ReentrantLock()
    private var count = 0

    fun increment() {
        lock.lock() // Explicitly acquire the lock
        try {
            count++
            println("Incremented to: $count")
        } finally {
            lock.unlock() // Ensure the lock is released even if an exception occurs
        }
    }

    fun getCount(): Int {
        lock.lock()
        return try {
            count
        } finally {
            lock.unlock()
        }
    }
}

fun main() {
    val counter = SafeCounter()
    val threads = List(10) {
        Thread { counter.increment() }
    }
    threads.forEach { it.start() }
    threads.forEach { it.join() }
    println("Final count: ${counter.getCount()}")
}
Enter fullscreen mode Exit fullscreen mode

2. Using withLock Extension Function

The withLock extension function simplifies lock usage in Kotlin by managing the lock() and unlock() calls automatically.

Example:

import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock

class SafeList {
    private val lock = ReentrantLock()
    private val list = mutableListOf<String>()

    fun add(item: String) {
        lock.withLock {
            list.add(item)
        }
    }

    fun getList(): List<String> {
        lock.withLock {
            return list.toList() // Return a copy for safety
        }
    }
}

fun main() {
    val safeList = SafeList()
    val threads = List(5) { index ->
        Thread {
            safeList.add("Item-$index")
        }
    }
    threads.forEach { it.start() }
    threads.forEach { it.join() }
    println("Final list: ${safeList.getList()}")
}
Enter fullscreen mode Exit fullscreen mode

3. Using synchronized

The synchronized keyword offers a simpler alternative for basic synchronization by locking on a specific monitor object.

Example:

class SafeCounter {
    private var count = 0

    fun increment() {
        synchronized(this) { // Synchronize on the instance itself
            count++
            println("Incremented to: $count")
        }
    }

    fun getCount(): Int {
        synchronized(this) {
            return count
        }
    }
}

fun main() {
    val counter = SafeCounter()
    val threads = List(10) {
        Thread { counter.increment() }
    }
    threads.forEach { it.start() }
    threads.forEach { it.join() }
    println("Final count: ${counter.getCount()}")
}
Enter fullscreen mode Exit fullscreen mode

4. Using ReadWriteLock

For scenarios with frequent reads and infrequent writes, ReadWriteLock provides separate locks for reading and writing, allowing multiple threads to read concurrently.

Example:

import java.util.concurrent.locks.ReentrantReadWriteLock

class SafeMap {
    private val lock = ReentrantReadWriteLock()
    private val map = mutableMapOf<String, String>()

    fun put(key: String, value: String) {
        lock.writeLock().lock()
        try {
            map[key] = value
        } finally {
            lock.writeLock().unlock()
        }
    }

    fun get(key: String): String? {
        lock.readLock().lock()
        return try {
            map[key]
        } finally {
            lock.readLock().unlock()
        }
    }
}

fun main() {
    val safeMap = SafeMap()
    val threads = List(5) { index ->
        Thread { safeMap.put("Key-$index", "Value-$index") }
    }
    threads.forEach { it.start() }
    threads.forEach { it.join() }
    println("Final map: $safeMap")
}
Enter fullscreen mode Exit fullscreen mode

5. Using Coroutines and Mutex

For coroutine-based concurrency, Kotlin offers the Mutex class from kotlinx.coroutines.sync. This is a coroutine-friendly lock that avoids blocking threads.

Example:

import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

class SafeCounter {
    private val mutex = Mutex()
    private var count = 0

    suspend fun increment() {
        mutex.withLock {
            count++
            println("Incremented to: $count")
        }
    }

    fun getCount(): Int = count
}

fun main() = runBlocking {
    val counter = SafeCounter()
    val jobs = List(10) {
        launch {
            counter.increment()
        }
    }
    jobs.forEach { it.join() }
    println("Final count: ${counter.getCount()}")
}
Enter fullscreen mode Exit fullscreen mode

6. Using Atomic Variables

For simple scenarios, atomic variables like AtomicInteger and AtomicLong provide a lock-free alternative for thread-safe counters and accumulators.

Example:

import java.util.concurrent.atomic.AtomicInteger

class SafeCounter {
    private val count = AtomicInteger(0)

    fun increment() {
        println("Incremented to: ${count.incrementAndGet()}")
    }

    fun getCount(): Int = count.get()
}

fun main() {
    val counter = SafeCounter()
    val threads = List(10) {
        Thread { counter.increment() }
    }
    threads.forEach { it.start() }
    threads.forEach { it.join() }
    println("Final count: ${counter.getCount()}")
}
Enter fullscreen mode Exit fullscreen mode

When to Use Each?

  • ReentrantLock + lock/unlock: Use for low-level control, especially when advanced features like interruptible locks are required.
  • withLock: The idiomatic Kotlin approach for most use cases.
  • synchronized: Simple and straightforward for basic synchronization.
  • ReadWriteLock: Ideal for frequent reads and infrequent writes.
  • Mutex: Best for coroutine-based concurrency.
  • Atomic Variables: Lightweight and lock-free, perfect for counters and accumulators.

Each approach caters to specific needs based on complexity, performance, and whether you’re working with threads or coroutines. Understanding these options ensures you can write safe and efficient concurrent Kotlin applications.

Top comments (0)