6.5 Performance and Best Practices

Understanding performance characteristics and following best practices ensures your collections and streams code is both elegant and efficient.

Collection Performance Characteristics

ArrayList vs LinkedList:

// ArrayList: Fast random access, slow insertions/deletions in middle
List<String> arrayList = new ArrayList<>();
arrayList.get(100); // O(1) - fast
arrayList.add(0, "first"); // O(n) - slow, shifts all elements

// LinkedList: Slow random access, fast insertions at ends
List<String> linkedList = new LinkedList<>();
linkedList.get(100); // O(n) - slow, must traverse
linkedList.addFirst("first"); // O(1) - fast

// Recommendation: Use ArrayList unless you need frequent head/tail insertions

HashSet vs TreeSet:

// HashSet: O(1) add/contains, no order
Set<String> hashSet = new HashSet<>();
hashSet.add("item"); // O(1)
hashSet.contains("item"); // O(1)

// TreeSet: O(log n) add/contains, sorted order
Set<String> treeSet = new TreeSet<>();
treeSet.add("item"); // O(log n)
treeSet.contains("item"); // O(log n)
// Iterates in sorted order

// Recommendation: Use HashSet unless you need sorting

HashMap vs TreeMap:

// HashMap: O(1) put/get, no order
Map<String, Integer> hashMap = new HashMap<>();
hashMap.put("key", 1); // O(1)
hashMap.get("key"); // O(1)

// TreeMap: O(log n) put/get, sorted by keys
Map<String, Integer> treeMap = new TreeMap<>();
treeMap.put("key", 1); // O(log n)
treeMap.firstKey(); // O(log n) - min key
treeMap.lastKey(); // O(log n) - max key

// Recommendation: Use HashMap unless you need sorted keys

Initial Capacity

Pre-sizing collections avoids expensive resizing operations:

// Bad: Will resize multiple times (default capacity is 16)
List<String> items = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
    items.add("item" + i);
}

// Good: Pre-sized, no resizing needed
List<String> items = new ArrayList<>(10000);
for (int i = 0; i < 10000; i++) {
    items.add("item" + i);
}

// HashMap: Set initial capacity and load factor
Map<String, Value> cache = new HashMap<>(1000, 0.75f);

// Rule of thumb: capacity = expected size / load factor
// For 1000 elements: capacity = 1000 / 0.75 = 1334
Map<String, Value> optimized = new HashMap<>(1334);

Stream Performance

When to use streams:

  • Data transformation pipelines
  • Filtering and mapping operations
  • Aggregations and reductions
  • Readable declarative code

When to avoid streams:

  • Simple iterations (traditional loops are faster)
  • Small collections (< 100 elements)
  • Performance-critical hot paths
  • Operations with many side effects
List<Integer> numbers = IntStream.rangeClosed(1, 1000).boxed().toList();

// Simple iteration: traditional loop is faster
// Stream
long sum = numbers.stream().mapToLong(Integer::longValue).sum();

// Loop (faster for small datasets)
long sum = 0;
for (int n : numbers) {
    sum += n;
}

Primitive Streams:

Avoid boxing overhead by using primitive streams:

// Bad: Boxing overhead
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
int sum = numbers.stream()
    .mapToInt(Integer::intValue)  // unboxing
    .sum();

// Good: No boxing
IntStream.range(1, 6).sum();

// Primitive streams
IntStream intStream = IntStream.of(1, 2, 3);
LongStream longStream = LongStream.range(0, 1000);
DoubleStream doubleStream = DoubleStream.generate(Math::random);

// Converting between primitive streams
IntStream ints = IntStream.range(0, 10);
LongStream longs = ints.asLongStream();
DoubleStream doubles = ints.asDoubleStream();

// Boxing when necessary
Stream<Integer> boxed = ints.boxed();

Short-Circuit Operations:

Place cheap filters before expensive operations:

// Bad: Expensive operation first
List<String> result = users.stream()
    .map(user -> expensiveTransform(user))  // Called for all users
    .filter(User::isActive)                  // Filter after transform
    .toList();

// Good: Filter first
List<String> result = users.stream()
    .filter(User::isActive)                  // Cheap filter first
    .map(user -> expensiveTransform(user))   // Only for active users
    .toList();

// Short-circuit terminal operations
boolean hasActive = users.stream()
    .anyMatch(User::isActive);  // Stops at first match

// vs non-short-circuit
long count = users.stream()
    .filter(User::isActive)
    .count();  // Processes entire stream

Lazy Evaluation:

Understand that intermediate operations are lazy:

// Nothing is executed here
Stream<String> stream = list.stream()
    .filter(s -> {
        System.out.println("Filtering: " + s);
        return s.length() > 5;
    })
    .map(s -> {
        System.out.println("Mapping: " + s);
        return s.toUpperCase();
    });

// Execution happens when terminal operation is called
List<String> result = stream.toList();

// Optimize by combining operations
// Bad: Multiple passes
list.stream().filter(...).toList().stream().map(...).toList();

// Good: Single pass
list.stream().filter(...).map(...).toList();

Parallel Streams

Parallel streams can improve performance for CPU-intensive operations on large datasets:

List<Integer> numbers = IntStream.rangeClosed(1, 10_000_000)
    .boxed()
    .toList();

// Sequential
long start = System.currentTimeMillis();
long sum = numbers.stream()
    .mapToLong(Integer::longValue)
    .sum();
long sequential = System.currentTimeMillis() - start;

// Parallel
start = System.currentTimeMillis();
sum = numbers.parallelStream()
    .mapToLong(Integer::longValue)
    .sum();
long parallel = System.currentTimeMillis() - start;

System.out.println("Sequential: " + sequential + "ms");
System.out.println("Parallel: " + parallel + "ms");

When parallel streams help:

  • Large datasets (10,000+ elements)
  • CPU-intensive operations (complex computations)
  • Independent operations (no shared state)
  • Multi-core systems available

When to avoid parallel streams:

// 1. I/O operations - parallelism doesn't help
users.parallelStream()
    .forEach(user -> database.save(user)); // Sequential I/O anyway

// 2. Small datasets - overhead exceeds benefit
List.of(1, 2, 3).parallelStream().sum(); // Not worth it

// 3. Ordered operations - defeats parallelism
numbers.parallelStream()
    .sorted()          // Requires coordination
    .forEachOrdered(System.out::println); // Sequential output

// 4. Shared mutable state - race conditions
List<Integer> result = new ArrayList<>(); // NOT thread-safe
numbers.parallelStream()
    .forEach(n -> result.add(n * 2)); // Race condition!

// Fix: Use concurrent collection or collect()
List<Integer> result = numbers.parallelStream()
    .map(n -> n * 2)
    .toList(); // Thread-safe

Custom ForkJoinPool:

import java.util.concurrent.*;

// Use custom thread pool instead of common pool
ForkJoinPool customPool = new ForkJoinPool(4); // 4 threads

try {
    List<Integer> result = customPool.submit(() ->
        numbers.parallelStream()
            .map(this::expensiveOperation)
            .toList()
    ).get();
} catch (InterruptedException | ExecutionException e) {
    // handle exception
}

Memory Efficiency

Immutable Collections:

Use immutable collections to save memory:

// Regular ArrayList: overhead for capacity management
List<String> mutable = new ArrayList<>(List.of("a", "b", "c"));

// Immutable list: more compact representation
List<String> immutable = List.of("a", "b", "c");

// Memory comparison for 1000 elements
List<Integer> mutableList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
    mutableList.add(i);
}
// Capacity: 1024 (next power of 2), wasted: 24 slots

List<Integer> immutableList = IntStream.range(0, 1000)
    .boxed()
    .toList();
// Exact size: 1000, no waste

Avoid Unnecessary Boxing:

// Bad: Boxing creates objects
List<Integer> boxed = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
    boxed.add(i); // Creates Integer object
}

// Better: Primitive array when possible
int[] primitives = new int[1000];
for (int i = 0; i < 1000; i++) {
    primitives[i] = i; // No object creation
}

// Or use primitive streams
IntStream.range(0, 1000).toArray();

Stream vs Collection:

// Don't collect if you don't need to
// Bad: Collects to list just to iterate
users.stream()
    .filter(User::isActive)
    .toList()
    .forEach(this::process);

// Good: Iterate directly
users.stream()
    .filter(User::isActive)
    .forEach(this::process);

Best Practices Summary

Collection Selection:

// Fast lookup, unique elements → HashSet
Set<String> uniqueIds = new HashSet<>();

// Sorted elements → TreeSet
Set<Integer> sortedScores = new TreeSet<>();

// Insertion order matters → LinkedHashSet
Set<String> orderedTags = new LinkedHashSet<>();

// Key-value lookup → HashMap
Map<String, User> userCache = new HashMap<>();

// Sorted keys → TreeMap
Map<LocalDate, BigDecimal> chronologicalSales = new TreeMap<>();

// List with frequent access → ArrayList
List<String> names = new ArrayList<>();

// Queue/Deque operations → ArrayDeque
Deque<Task> taskQueue = new ArrayDeque<>();

Immutability:

// Return immutable collections from public APIs
public List<User> getUsers() {
    return List.copyOf(internalUsers);
}

// Use immutable factories for constants
private static final Set<String> ADMIN_ROLES = Set.of("admin", "superadmin");
private static final Map<String, Integer> LIMITS = Map.of(
    "free", 10,
    "premium", 100
);

Stream Operations:

// 1. Filter early
stream.filter(...).map(...) // not map(...).filter(...)

// 2. Use method references
stream.map(User::getName) // not .map(u -> u.getName())

// 3. Collect once
stream.filter(...).map(...).toList() // not .filter().toList().stream().map().toList()

// 4. Use specialized collectors
stream.collect(Collectors.summingInt(...)) // not .mapToInt(...).sum()

// 5. Short-circuit when possible
stream.anyMatch(...) // not .filter(...).count() > 0

Avoid Common Pitfalls:

// 1. Don't modify collection while iterating
// Bad
for (String item : list) {
    if (condition) list.remove(item); // ConcurrentModificationException
}
// Good
list.removeIf(item -> condition);

// 2. Don't compare with == for objects
// Bad
list.contains(new String("hello")); // Use equals
// Good
String str = "hello";
list.contains(str);

// 3. Don't ignore capacity for known sizes
// Bad
Map<String, Value> map = new HashMap<>(); // Will resize
// Good
Map<String, Value> map = new HashMap<>(expectedSize);

// 4. Don't use stream for simple operations
// Bad
list.stream().forEach(System.out::println);
// Good
list.forEach(System.out::println);

// 5. Don't abuse Optional
// Bad
public Optional<List<User>> getUsers() { }
// Good
public List<User> getUsers() { return List.of(); }

Real-World Performance Example

import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;

record Transaction(String id, String userId, BigDecimal amount, LocalDate date) {}

class PerformanceOptimizedAnalytics {
    private final List<Transaction> transactions;

    public PerformanceOptimizedAnalytics(List<Transaction> transactions) {
        // Defensive copy as immutable list
        this.transactions = List.copyOf(transactions);
    }

    // Optimized: Single pass with pre-sized collections
    public Map<String, BigDecimal> getUserTotals() {
        int estimatedUsers = transactions.size() / 10; // Estimate
        Map<String, BigDecimal> totals = new HashMap<>(estimatedUsers);

        for (Transaction tx : transactions) {
            totals.merge(tx.userId(), tx.amount(), BigDecimal::add);
        }

        return Map.copyOf(totals); // Return immutable
    }

    // Optimized: Primitive stream, no boxing
    public double getAverageAmount() {
        if (transactions.isEmpty()) return 0.0;

        double sum = 0;
        for (Transaction tx : transactions) {
            sum += tx.amount().doubleValue();
        }
        return sum / transactions.size();
    }

    // Optimized: Parallel processing for large dataset
    public Map<String, Long> getTransactionCountsByUser() {
        // Use parallel only if dataset is large enough
        Stream<Transaction> stream = transactions.size() > 10000
            ? transactions.parallelStream()
            : transactions.stream();

        return stream.collect(Collectors.groupingBy(
            Transaction::userId,
            Collectors.counting()
        ));
    }

    // Optimized: Early filtering, primitive operations
    public OptionalDouble getAverageHighValueTransaction(BigDecimal threshold) {
        return transactions.stream()
            .filter(tx -> tx.amount().compareTo(threshold) >= 0) // Filter first
            .mapToDouble(tx -> tx.amount().doubleValue())        // Primitive stream
            .average();
    }

    // Optimized: Short-circuit operation
    public boolean hasHighValueTransaction(BigDecimal threshold) {
        // Stops at first match
        return transactions.stream()
            .anyMatch(tx -> tx.amount().compareTo(threshold) >= 0);
    }

    // Optimized: Batch processing with pre-allocated collection
    public List<Transaction> getRecentTransactions(int days) {
        LocalDate cutoff = LocalDate.now().minusDays(days);
        List<Transaction> recent = new ArrayList<>(transactions.size() / 10);

        for (Transaction tx : transactions) {
            if (!tx.date().isBefore(cutoff)) {
                recent.add(tx);
            }
        }

        return List.copyOf(recent);
    }
}

// Performance comparison
void demonstratePerformance() {
    // Generate large dataset
    List<Transaction> transactions = IntStream.range(0, 1_000_000)
        .mapToObj(i -> new Transaction(
            "TX" + i,
            "USER" + (i % 10000),
            BigDecimal.valueOf(Math.random() * 1000),
            LocalDate.now().minusDays(i % 365)
        ))
        .toList();

    var analytics = new PerformanceOptimizedAnalytics(transactions);

    // Measure performance
    long start = System.nanoTime();
    Map<String, BigDecimal> totals = analytics.getUserTotals();
    long elapsed = System.nanoTime() - start;

    System.out.printf("Processed %d transactions in %.2f ms%n",
        transactions.size(), elapsed / 1_000_000.0);
}

Benchmarking Tips

Use JMH (Java Microbenchmark Harness) for accurate measurements:

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
public class CollectionBenchmark {
    private List<Integer> data;

    @Setup
    public void setup() {
        data = IntStream.range(0, 10000).boxed().toList();
    }

    @Benchmark
    public long streamSum() {
        return data.stream().mapToLong(Integer::longValue).sum();
    }

    @Benchmark
    public long loopSum() {
        long sum = 0;
        for (int n : data) {
            sum += n;
        }
        return sum;
    }
}

Key Takeaways

  1. Choose the right collection based on access patterns
  2. Pre-size collections when you know the approximate size
  3. Use primitive streams to avoid boxing overhead
  4. Filter early in stream pipelines
  5. Use parallel streams judiciously - not always faster
  6. Prefer immutable collections for thread safety and memory efficiency
  7. Profile and measure - don't optimize prematurely
  8. Readability matters - don't sacrifice clarity for micro-optimizations