Scala - Lazy Evaluation (lazy val)
Lazy evaluation postpones computation until absolutely necessary. In Scala, `lazy val` creates a value that's computed on first access and cached for subsequent uses. This differs from regular `val`...
Key Insights
- Lazy evaluation defers computation until the value is actually accessed, reducing unnecessary work and enabling infinite data structures that would otherwise cause stack overflows or infinite loops
lazy valin Scala is thread-safe by default and evaluates exactly once, making it ideal for expensive initializations, circular dependencies, and resource management scenarios- Understanding the performance trade-offs is critical: lazy evaluation adds synchronization overhead on first access but can dramatically improve startup time and memory usage when values aren’t always needed
Understanding Lazy Evaluation Mechanics
Lazy evaluation postpones computation until absolutely necessary. In Scala, lazy val creates a value that’s computed on first access and cached for subsequent uses. This differs from regular val (evaluated immediately) and def (evaluated on every access).
object EvaluationComparison {
println("Object initialized")
val eagerVal = {
println("eagerVal computed")
expensive()
}
lazy val lazyValue = {
println("lazyValue computed")
expensive()
}
def methodDef = {
println("methodDef computed")
expensive()
}
private def expensive(): Int = {
Thread.sleep(1000)
42
}
}
// Usage
EvaluationComparison // Prints: "Object initialized" and "eagerVal computed"
EvaluationComparison.lazyValue // First access: prints "lazyValue computed"
EvaluationComparison.lazyValue // Second access: prints nothing, returns cached value
EvaluationComparison.methodDef // Prints "methodDef computed" every time
The bytecode for lazy val includes a volatile bitmap field for thread-safe initialization and a synchronized block ensuring single evaluation even under concurrent access.
Breaking Circular Dependencies
Lazy evaluation elegantly solves initialization order problems that plague eager evaluation. This is particularly useful in dependency injection scenarios and complex object graphs.
class DatabaseConnection(config: Configuration) {
lazy val connection: java.sql.Connection = {
println(s"Connecting to ${config.url}")
java.sql.DriverManager.getConnection(config.url, config.user, config.password)
}
lazy val metadata: java.sql.DatabaseMetaData = {
println("Fetching metadata")
connection.getMetaData
}
}
class Configuration(db: DatabaseConnection) {
val url = "jdbc:postgresql://localhost/mydb"
val user = "admin"
val password = "secret"
lazy val tableNames: List[String] = {
println("Loading table names")
val rs = db.metadata.getTables(null, null, "%", Array("TABLE"))
val builder = List.newBuilder[String]
while (rs.next()) builder += rs.getString("TABLE_NAME")
builder.result()
}
}
// Circular dependency resolved through lazy evaluation
lazy val config: Configuration = new Configuration(database)
lazy val database: DatabaseConnection = new DatabaseConnection(config)
// Access triggers cascading initialization only when needed
database.connection
Without lazy val, this code would fail with initialization errors. The lazy approach allows mutual references while deferring actual computation.
Performance Optimization Patterns
Lazy evaluation shines when dealing with expensive computations that may not always be needed. Consider a report generation system:
case class Report(userId: String) {
// Always needed
val basicInfo: UserInfo = fetchUserInfo(userId)
// Expensive, only needed for detailed reports
lazy val purchaseHistory: List[Purchase] = {
println(s"Fetching purchase history for $userId")
// Simulating expensive database query
Thread.sleep(2000)
fetchPurchases(userId)
}
// Very expensive, rarely needed
lazy val analyticsData: AnalyticsReport = {
println(s"Computing analytics for $userId")
// Simulating ML model inference
Thread.sleep(5000)
computeAnalytics(userId, purchaseHistory)
}
private def fetchUserInfo(id: String): UserInfo =
UserInfo(id, "John Doe", "john@example.com")
private def fetchPurchases(id: String): List[Purchase] =
List(Purchase("item1", 100), Purchase("item2", 200))
private def computeAnalytics(id: String, purchases: List[Purchase]): AnalyticsReport =
AnalyticsReport(purchases.size, purchases.map(_.amount).sum)
}
case class UserInfo(id: String, name: String, email: String)
case class Purchase(item: String, amount: Double)
case class AnalyticsReport(totalPurchases: Int, totalSpent: Double)
// Basic report: instant
val report = Report("user123")
println(report.basicInfo) // No delay
// Detailed report: 2 second delay only if accessed
println(report.purchaseHistory) // First access: 2s delay
// Full analytics: 5 second delay only if accessed
println(report.analyticsData) // First access: 5s delay
This pattern reduces average response time dramatically when most requests only need basic information.
Infinite Data Structures
Lazy evaluation enables working with conceptually infinite structures without materializing the entire sequence:
class LazyList[+A](headThunk: => A, tailThunk: => LazyList[A]) {
lazy val head: A = headThunk
lazy val tail: LazyList[A] = tailThunk
def take(n: Int): List[A] = {
if (n <= 0) Nil
else head :: tail.take(n - 1)
}
def map[B](f: A => B): LazyList[B] =
new LazyList(f(head), tail.map(f))
def filter(p: A => Boolean): LazyList[A] =
if (p(head)) new LazyList(head, tail.filter(p))
else tail.filter(p)
}
object LazyList {
def from(n: Int): LazyList[Int] =
new LazyList(n, from(n + 1))
}
// Infinite sequence of natural numbers
val naturals = LazyList.from(1)
// Only computes what's needed
val firstTenEvens = naturals
.filter(_ % 2 == 0)
.take(10)
println(firstTenEvens) // List(2, 4, 6, 8, 10, 12, 14, 16, 18, 20)
Scala’s standard library provides LazyList (formerly Stream) implementing this pattern with additional optimizations.
Resource Management and Initialization
Lazy evaluation is crucial for managing resources that shouldn’t be initialized unless actually used:
trait ResourceManager {
lazy val httpClient: sttp.client3.SttpBackend[Identity, Any] = {
println("Initializing HTTP client")
sttp.client3.HttpURLConnectionBackend()
}
lazy val cacheConnection: redis.clients.jedis.Jedis = {
println("Connecting to Redis")
new redis.clients.jedis.Jedis("localhost", 6379)
}
lazy val messageQueue: org.apache.kafka.clients.producer.KafkaProducer[String, String] = {
println("Initializing Kafka producer")
val props = new java.util.Properties()
props.put("bootstrap.servers", "localhost:9092")
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer")
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer")
new org.apache.kafka.clients.producer.KafkaProducer[String, String](props)
}
}
class LightweightService extends ResourceManager {
def processLocal(data: String): String = {
// No external resources initialized
data.toUpperCase
}
}
class HeavyService extends ResourceManager {
def processWithCache(key: String): Option[String] = {
// Only Redis initialized, not HTTP or Kafka
Option(cacheConnection.get(key))
}
}
This approach dramatically improves startup time for applications with many optional dependencies.
Thread Safety Considerations
While lazy val provides thread-safe initialization, understanding the implementation details helps avoid performance pitfalls:
class ThreadSafetyDemo {
// Thread-safe but has synchronization overhead
lazy val sharedResource: ExpensiveObject = {
println(s"Initializing on thread: ${Thread.currentThread().getName}")
Thread.sleep(100)
new ExpensiveObject()
}
// For frequently accessed values after initialization, consider:
@volatile private var cached: ExpensiveObject = _
def getOptimized: ExpensiveObject = {
if (cached == null) {
synchronized {
if (cached == null) {
cached = new ExpensiveObject()
}
}
}
cached
}
}
class ExpensiveObject
// Concurrent access test
val demo = new ThreadSafetyDemo()
val threads = (1 to 10).map { i =>
new Thread(() => {
println(s"Thread $i accessing: ${demo.sharedResource}")
})
}
threads.foreach(_.start())
threads.foreach(_.join())
// Prints initialization message exactly once
The double-checked locking pattern in getOptimized eliminates synchronization overhead after initialization, useful for hot paths.
Common Pitfalls and Best Practices
Avoid capturing mutable state in lazy initializers:
class ProblematicLazy {
var counter = 0
// BAD: captures mutable state
lazy val snapshot = {
counter // Value depends on when first accessed
}
}
val p = new ProblematicLazy()
p.counter = 5
println(p.snapshot) // 5
p.counter = 10
println(p.snapshot) // Still 5 (cached)
// GOOD: explicit about timing
class CorrectLazy {
var counter = 0
def currentSnapshot: Int = counter // Always current
lazy val initialSnapshot: Int = counter // Clearly named
}
Use lazy val judiciously—not every value benefits from lazy evaluation. Reserve it for genuinely expensive operations, circular dependencies, or optional resources. The synchronization overhead makes it counterproductive for cheap computations accessed frequently.