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.
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.
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:
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 standardsynchronized
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: