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
- Choose the right collection based on access patterns
- Pre-size collections when you know the approximate size
- Use primitive streams to avoid boxing overhead
- Filter early in stream pipelines
- Use parallel streams judiciously - not always faster
- Prefer immutable collections for thread safety and memory efficiency
- Profile and measure - don't optimize prematurely
- Readability matters - don't sacrifice clarity for micro-optimizations