A Look at the Synchronized Keyword in Scala

Explore concurrency in Scala: Master the synchronized keyword and unlock advanced JVM locking mechanisms for efficient, safe multi-threading.

A Look at the Synchronized Keyword in Scala
A Food Sincronizada with the title "A Look at the Synchronized Keyword in Scala"

Overview

Scala embraces immutability, often guiding us toward a paradigm where data is unchanging. Immutable data shines in multi-threaded environments, negating the dangers of concurrent modifications and enabling us to use parallelism without fear.

Yet, not all applications can be written with immutable data alone. There are scenarios where mutability is not just a necessity but a pragmatic choice.

Enter Scala's synchronized keyword, a beacon of order in the mutable fog. While Scala advocates for immutability, it is not dogmatic. It acknowledges the need for mutable data and provides the means to manage it safely.

The synchronized keyword is our sentinel in these mutable endeavors, guarding against the concurrency hazards that threaten the consistency of our state.

Mapping closely to Java's synchronization mechanism, synchronized in Scala, is seamlessly integrated into the language's fabric. It sits within the AnyRef class, Scala's equivalent to Java's Object class, providing every object with the intrinsic capability to lock and synchronize a code block.

The synthesized signature is akin to def synchronized[T](block: => T): T. It is not explicitly declared. The compiler automatically converts its use to the JVM's concurrency methods.

With synchronized, We have a tool that is as robust as it is familiar, allowing us to manage mutable data easily with the backing of the well-established concurrency constructs of the Java platform.


Understanding synchronized by Desugaring an Example

Let's try to understand synchronized in Scala by asking the compiler to show us the intermediate code. This is often referred to as desugared code since the initial phases of the compiler could be thought of as removing syntactic sugar.

To illustrate this, consider a simple Counter class that encapsulates an integer count and provides an increment method to increase the count in a multi-threaded context safely.

Scala 3

class Counter {
  private var count = 0

  def increment(): Unit = synchronized {
    val current = count
    // Simulate some work
    Thread.sleep(10)
    count = current + 1
  }

  def getCount: Int = count
}

@main def runSynchronizedExample(): Unit = {
  val counter = new Counter()

  val threads = List(
    new Thread(() => counter.increment()),
    new Thread(() => counter.increment())
  )

  threads.foreach(_.start())
  threads.foreach(_.join())

  println(s"The final count is ${counter.getCount}")
}            

When invoked the increment method, the synchronized block ensures that only one thread can execute the block at a time, effectively preventing race conditions.

We turn to the Scala compiler's desugaring process to truly understand what happens under the hood. When the above code is compiled, the synchronized method call is translated into a more lengthy form that explicitly includes the current instance of Counter.

Here's what the desugared method looks:

Scala 3

class Counter() extends Object() {
  private[this] var count: Int = 0
  def increment(): Unit =
    this.synchronized[Unit](
      {
        val current: Int = this.count
        Thread.sleep(10L)
        this.count = current.+(1)
      }
    )
  def getCount: Int = this.count
}          

In the highlighted line (5), the synchronized call is translated to a method call on the current object's this reference. We are now sure the Scala compiler transpiles synchronized to Java's intrinsic lock mechanism.

In our Counter example, the increment method, when compiled, uses this as the lock for synchronizing the block of code, ensuring that the increment operation is atomic and thread-safe. This is an essential detail because it implies that the lock is specific to the instance of Counter. Two different Counter instances would have separate locks and would not block each other's increment operations.

Understanding the desugared form of synchronized helps clarify its behavior: it ensures that when one thread executes the synchronized block, any other thread attempting to do the same must wait, thus maintaining the integrity of the mutable state within the Counter class.


Best Practices and Alternatives

While synchronized is valuable in Scala's concurrency toolkit, employing it effectively requires adherence to certain best practices. We should also consider alternatives that can lead to better performance and cleaner code in some cases.

Best Practices for Using synchronized

  • Minimize Lock Duration: We should keep the synchronized blocks as short as possible. The longer a thread holds a lock, the longer other threads may be blocked, leading to reduced parallelism and potential performance bottlenecks.
  • Avoid Nested Locks: Nesting synchronized blocks can lead to complex interdependencies that are difficult to reason about and can cause deadlocks. We should design your code to avoid requiring multiple locks at once.
  • Consistent Locking Order: If we must acquire multiple locks, always do so in the same order to prevent deadlocks.
  • Lock on Private Objects: Instead of using this to synchronize, we could consider locking on a private object within your class. This can prevent external synchronization on the same object, which might lead to unexpected blocking or deadlocks.

JVM Locking Alternatives

Scala's seamless integration with the JVM brings a functional paradigm to the table and grants access to the robust concurrency mechanisms that Java provides. When employing synchronized isn't fitting, Scala developers can use these JVM-level locks for more sophisticated control.

  • Reentrant Locks: The java.util.concurrent.locks.ReentrantLock gives you additional features over the standard synchronized synchronization, such as timed lock waits, interruptible lock waits, and fairness policies.
  • Read/Write Locks: For read-heavy operations, java.util.concurrent.locks.ReentrantReadWriteLock allows multiple readers to access the resource concurrently, promoting throughput and reducing contention.
  • Stamped Locks: java.util.concurrent.locks.StampedLock enables the code optimistic read scenarios because StampedLock allows upgrading and downgrading it. We can improve system throughput and reduce contention by using it.

By effectively leveraging these locking mechanisms from the JVM, Scala applications can achieve the desired balance between safety and efficiency in managing concurrency.


Conclusion

synchronized provides a simple and familiar way to manage concurrent access to shared resources. It is by no means the only option available. Scala's position on the JVM allows developers to employ a variety of sophisticated locking mechanisms that Java offers.

In the end, effective concurrency control in Scala is about understanding the different tools at your disposal and knowing when and how to apply them. By embracing both Scala's functional features and the JVM's concurrency primitives, developers are well-equipped to tackle the challenges of modern, multi-threaded application design.


Addendum: A Special Note for Our Readers

I decided to delay the introduction of subscriptions, you can read the full story here.

In the meantime, I decided to accept donations.

If you can afford it, please consider donating:

Every donation helps me offset the running costs of the site and an unexpected tax bill. Any amount is greatly appreciated.

Also, if you are looking to buy some Swag, please visit I invite you to visit the TuringTacoTales Store on Redbubble.

Take a look, maybe you can find something you like: