Mastering Lazy Initialization and Stream Laziness in Java 8
This article explains the difference between eager and lazy evaluation, shows when and how to use lazy initialization in Java, demonstrates Supplier‑based lazy loading, virtual proxy patterns, and stream laziness with infinite streams and prime number generation, all with concrete code examples.
Lazy Initialization
Many operations in code are eager, meaning parameters are evaluated immediately when a method is called. While eager evaluation simplifies coding, lazy evaluation usually yields better performance.
Typical scenarios for lazy initialization include:
Resource‑intensive objects: delaying creation saves resources and speeds up overall object creation.
Data unavailable at startup: some context information may only become available later, so delaying initialization guarantees valid data when actually needed.
Java 8 introduced lambda expressions, which make implementing lazy operations (e.g., via Stream or Supplier) very convenient. Below are several examples.
Lambda
Supplier
Calling the get() method on a Supplier triggers the actual computation and returns the object, achieving lazy initialization. When using it, concurrency concerns must be considered to avoid multiple instantiations, similar to Spring’s @Lazy annotation.
public class Holder {
// Default synchronized method triggered on first heavy.get() call
private Supplier<Heavy> 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 a class inside the method (different from nested class loading)
class HeavyFactory implements Supplier<Heavy> {
// Eager initialization
private final Heavy heavyInstance = new Heavy();
public Heavy get() {
// Always return the same instance
return heavyInstance;
}
}
// On first call, redirect heavy to a new Supplier instance
if (!HeavyFactory.class.isInstance(heavy)) {
heavy = new HeavyFactory();
}
return heavy.get();
}
}When a Holder instance is created, the Heavy instance is not yet created. Assume three threads call getHeavy(); the first two invoke it concurrently while the third calls later.
Both of the first two threads enter the synchronized createAndCacheHeavy method. The first thread sees that heavy is still a Supplier instance, replaces it with a HeavyFactory instance, and creates the real Heavy. When the second thread proceeds, heavy is already a HeavyFactory, so it immediately returns the cached instance. The third thread also receives the already‑initialized instance without invoking the synchronized method.
This implementation is essentially a lightweight virtual proxy pattern that guarantees correct lazy loading in multithreaded environments.
A delegate‑based implementation that is easier to understand:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Supplier;
public class MemoizeSupplier<T> implements Supplier<T> {
final Supplier<T> delegate;
ConcurrentMap<Class<?>, T> map = new ConcurrentHashMap<>(1);
public MemoizeSupplier(Supplier<T> delegate) {
this.delegate = delegate;
}
@Override
public T get() {
// computeIfAbsent ensures the supplier is called only once per key
return this.map.computeIfAbsent(MemoizeSupplier.class, k -> this.delegate.get());
}
public static <T> Supplier<T> of(Supplier<T> provider) {
return new MemoizeSupplier<>(provider);
}
}A more feature‑rich CloseableSupplier that supports explicit closing and optional reset:
public static class CloseableSupplier<T> implements Supplier<T>, Serializable {
private static final long serialVersionUID = 0L;
private final Supplier<T> delegate;
private final boolean resetAfterClose;
private volatile transient boolean initialized;
private transient T value;
private CloseableSupplier(Supplier<T> delegate, boolean resetAfterClose) {
this.delegate = delegate;
this.resetAfterClose = resetAfterClose;
}
@Override
public T get() {
if (!initialized) {
synchronized (this) {
if (!initialized) {
T t = delegate.get();
this.value = t;
this.initialized = true;
return t;
}
}
}
return this.value;
}
public boolean isInitialized() {
return initialized;
}
public <X extends Throwable> void ifPresent(ThrowableConsumer<T, X> consumer) throws X {
synchronized (this) {
if (initialized && this.value != null) {
consumer.accept(this.value);
}
}
}
public <U> Optional<U> map(Function<? super T, ? extends U> mapper) {
Objects.requireNonNull(mapper);
synchronized (this) {
if (initialized && this.value != null) {
return Optional.ofNullable(mapper.apply(value));
} else {
return Optional.empty();
}
}
}
public void tryClose() {
tryClose(i -> {});
}
public <X extends Throwable> void tryClose(ThrowableConsumer<T, X> close) throws X {
synchronized (this) {
if (initialized) {
close.accept(value);
if (resetAfterClose) {
this.value = null;
initialized = false;
}
}
}
}
@Override
public String toString() {
if (initialized) {
return "MoreSuppliers.lazy(" + get() + ")";
} else {
return "MoreSuppliers.lazy(" + this.delegate + ")";
}
}
}Stream Laziness
Stream operations are divided into intermediate methods (e.g., limit(), iterate(), filter(), map()) and terminal methods (e.g., collect(), findFirst(), count()). Intermediate methods are lazy; they are executed only when a terminal method triggers the pipeline.
Example:
List<String> names = Arrays.asList("Brad", "Kate", "Kim", "Jack", "Joe", "Mike");
final String firstNameWith3Letters = names.stream()
.filter(name -> length(name) == 3)
.map(name -> toUpper(name))
.findFirst()
.get();
System.out.println(firstNameWith3Letters);When findFirst() is called, the stream processes each element sequentially: the first two names fail the filter, the third name passes, then map() is applied and the result is returned. Thus filter() runs three times and map() runs once.
Streams can be infinite, unlike regular collections. The Stream.iterate() method creates an infinite stream from a seed value and a UnaryOperator that generates the next element.
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<Integer> primes(final int fromNumber, final int count) {
return Stream.iterate(primeAfter(fromNumber - 1), Primes::primeAfter)
.limit(count)
.collect(Collectors.<Integer>toList());
}
}Using a stream for this task avoids the need to know the numeric bounds in advance and eliminates the complexity of managing large collections manually. The stream’s lazy evaluation handles infinite iteration, termination, and result collection elegantly.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Programmer DD
A tinkering programmer and author of "Spring Cloud Microservices in Action"
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
