Backend Development 11 min read

Lazy Initialization and Lazy Loading in Java: Using Supplier, Streams, and Virtual Proxy Patterns

This article explains the concepts of eager versus lazy evaluation in Java, presents common scenarios for lazy initialization, demonstrates how to implement lazy loading with Supplier, virtual proxy, and delegate patterns, and shows practical Stream examples including infinite prime number generation.

Top Architect
Top Architect
Top Architect
Lazy Initialization and Lazy Loading in Java: Using Supplier, Streams, and Virtual Proxy Patterns

Many operations in code are eager, evaluating parameters immediately during method calls; while eager evaluation simplifies coding, lazy evaluation often yields better performance.

Lazy Initialization is useful in scenarios such as resource‑heavy objects to save resources and speed up creation, and when certain data cannot be obtained at startup and must be fetched later.

Java 8 introduced lambda expressions, making lazy operations convenient through Supplier and Stream . The get() method of a Supplier defers object creation until it is actually needed, similar to Spring's @Lazy annotation.

public class Holder {
    // Default first call to heavy.get() triggers synchronized method
    private Supplier
heavy = () -> createAndCacheHeavy();
    public Holder() {
        System.out.println("Holder created");
    }
    public Heavy getHeavy() {
        // After first call, heavy points to a new instance, so no more synchronization
        return heavy.get();
    }
    private synchronized Heavy createAndCacheHeavy() {
        // Define class inside method, different from nested class loading
        class HeavyFactory implements Supplier
{
            private final Heavy heavyInstance = new Heavy();
            public Heavy get() {
                // Always return the same instance
                return heavyInstance;
            }
        }
        // First call redirects heavy to a new Supplier instance
        if (!HeavyFactory.class.isInstance(heavy)) {
            heavy = new HeavyFactory();
        }
        return heavy.get();
    }
}

The above code implements a lightweight virtual proxy pattern, ensuring correct lazy loading across different environments.

An alternative delegate‑based implementation is shown below:

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Supplier;

public class MemoizeSupplier
implements Supplier
{
    final Supplier
delegate;
    ConcurrentMap
, T> map = new ConcurrentHashMap<>(1);
    public MemoizeSupplier(Supplier
delegate) {
        this.delegate = delegate;
    }
    @Override
    public T get() {
        // computeIfAbsent guarantees the supplier is called only once per key
        return this.map.computeIfAbsent(MemoizeSupplier.class,
            k -> this.delegate.get());
    }
    public static
Supplier
of(Supplier
provider) {
        return new MemoizeSupplier<>(provider);
    }
}

Another more complex CloseableSupplier example demonstrates thread‑safe lazy initialization with double‑checked locking, reset after close, and additional utility methods.

public static class CloseableSupplier
implements Supplier
, Serializable {
    private static final long serialVersionUID = 0L;
    private final Supplier
delegate;
    private final boolean resetAfterClose;
    private volatile transient boolean initialized;
    private transient T value;
    private CloseableSupplier(Supplier
delegate, boolean resetAfterClose) {
        this.delegate = delegate;
        this.resetAfterClose = resetAfterClose;
    }
    public T get() {
        if (!this.initialized) {
            synchronized (this) {
                if (!this.initialized) {
                    T t = this.delegate.get();
                    this.value = t;
                    this.initialized = true;
                    return t;
                }
            }
        }
        return this.value;
    }
    // Additional methods omitted for brevity
}

Stream operations are lazy: intermediate methods like limit() , filter() , map() build a pipeline, and only a terminal operation such as collect() or findFirst() triggers execution. Elements are processed one‑by‑one through the pipeline.

For infinite collections, Stream.iterate() can generate an unbounded stream. Example: generating a list of prime numbers using an infinite stream and a terminating limit() operation.

public class Primes {
    public static boolean isPrime(final int number) {
        return number > 1 &&
            IntStream.rangeClosed(2, (int) Math.sqrt(number))
                .noneMatch(divisor -> number % divisor == 0);
    }
    private static int primeAfter(final int number) {
        if (isPrime(number + 1))
            return number + 1;
        else
            return primeAfter(number + 1);
    }
    public static List
primes(final int fromNumber, final int count) {
        return Stream.iterate(primeAfter(fromNumber - 1), Primes::primeAfter)
            .limit(count)
            .collect(Collectors.
toList());
    }
}

This approach avoids the need to know the numeric bounds in advance and handles large ranges elegantly, showcasing the power of lazy evaluation in Java streams.

Design PatternsJavaConcurrencyStreamLazy InitializationSupplier
Top Architect
Written by

Top Architect

Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn together.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.