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 val in 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.