Performance Optimization

Making Java Applications Fast and Efficient

← Back to Index

Performance Principles

Premature Optimization

"Premature optimization is the root of all evil." - Donald Knuth

Always measure first. Optimize only after profiling identifies actual bottlenecks.

Optimization Process

  1. Write correct, clean code first
  2. Measure performance with realistic data
  3. Identify bottlenecks using profilers
  4. Optimize the critical path
  5. Measure again to verify improvement

String Operations

StringBuilder for Concatenation

// BAD: String concatenation in loop - O(n²)
String result = "";
for (String item : items) {
    result += item + ", ";  // Creates new String each iteration!
}

// GOOD: StringBuilder - O(n)
StringBuilder sb = new StringBuilder();
for (String item : items) {
    sb.append(item).append(", ");
}
String result = sb.toString();

// BETTER: String.join() for simple cases
String result = String.join(", ", items);

// BEST: Streams with Collectors.joining()
String result = items.stream()
    .collect(Collectors.joining(", "));

// Note: Single concatenation is fine - compiler optimizes it
String msg = "Hello, " + name + "!";  // OK

String Interning

// String literals are automatically interned
String s1 = "hello";
String s2 = "hello";
s1 == s2;  // true - same object in string pool

// new String() creates new object
String s3 = new String("hello");
s1 == s3;  // false - different objects

// Manual interning (use sparingly)
String s4 = s3.intern();
s1 == s4;  // true - s4 points to pool

Collection Performance

Choosing the Right Collection

// ArrayList vs LinkedList
// ArrayList: Fast random access O(1), slow insert/delete O(n)
List<String> arrayList = new ArrayList<>();

// LinkedList: Slow random access O(n), fast insert/delete O(1)
// In practice, ArrayList is almost always faster due to CPU cache

// HashMap vs TreeMap
// HashMap: O(1) average for get/put
Map<String, User> hashMap = new HashMap<>();

// TreeMap: O(log n) for get/put, but maintains sorted order
Map<String, User> treeMap = new TreeMap<>();

// HashSet vs TreeSet - same tradeoffs
Set<String> hashSet = new HashSet<>();   // O(1)
Set<String> treeSet = new TreeSet<>();   // O(log n), sorted

Initial Capacity

// BAD: Default capacity causes resizing
List<String> list = new ArrayList<>();  // Initial capacity 10
for (int i = 0; i < 10000; i++) {
    list.add(item);  // Multiple resizes!
}

// GOOD: Pre-size when you know the size
List<String> list = new ArrayList<>(10000);

// HashMap: Account for load factor (default 0.75)
int expectedSize = 1000;
Map<String, Object> map = new HashMap<>(
    (int) (expectedSize / 0.75) + 1
);

Avoid Boxed Primitives in Collections

// BAD: Boxing overhead
List<Integer> numbers = new ArrayList<>();
int sum = 0;
for (Integer n : numbers) {
    sum += n;  // Unboxing on each iteration
}

// GOOD: Use primitive arrays when possible
int[] numbers = new int[1000];
int sum = 0;
for (int n : numbers) {
    sum += n;  // No boxing
}

// ALTERNATIVE: Use specialized collections (Eclipse Collections, etc.)
// IntArrayList, LongHashSet, etc.

Stream Performance

// Streams have overhead - avoid for simple operations

// Simple iteration - loop is faster
for (User user : users) {
    user.setActive(true);
}

// Complex transformations - streams shine
Map<String, List<Order>> ordersByCustomer = orders.stream()
    .filter(Order::isCompleted)
    .collect(Collectors.groupingBy(Order::getCustomerId));

// Parallel streams - only for CPU-intensive operations on large datasets
long sum = numbers.parallelStream()
    .filter(n -> expensiveCheck(n))
    .mapToLong(Long::valueOf)
    .sum();

// DON'T parallelize:
// - Small collections (< 10,000 elements)
// - I/O-bound operations
// - Operations with side effects

// Avoid repeated stream creation
// BAD
users.stream().filter(u -> u.isActive()).count();
users.stream().filter(u -> u.isActive()).map(...);  // Second traversal!

// GOOD: Collect intermediate results if needed multiple times
List<User> activeUsers = users.stream()
    .filter(User::isActive)
    .toList();
long count = activeUsers.size();
List<String> names = activeUsers.stream().map(User::getName).toList();

Object Creation

// Reuse immutable objects
// BAD
Boolean flag = new Boolean(true);  // Deprecated, creates new object

// GOOD
Boolean flag = Boolean.TRUE;  // Reuses cached instance
Integer num = Integer.valueOf(42);  // Caches -128 to 127

// Object pooling for expensive objects
private static final DateTimeFormatter FORMATTER =
    DateTimeFormatter.ofPattern("yyyy-MM-dd");  // Create once

public String format(LocalDate date) {
    return date.format(FORMATTER);  // Reuse
}

// Lazy initialization for expensive objects
private volatile ExpensiveObject instance;

public ExpensiveObject getInstance() {
    if (instance == null) {
        synchronized (this) {
            if (instance == null) {
                instance = new ExpensiveObject();
            }
        }
    }
    return instance;
}

Database Performance

// Use connection pooling
@Bean
public HikariDataSource dataSource() {
    HikariConfig config = new HikariConfig();
    config.setMaximumPoolSize(10);
    config.setMinimumIdle(5);
    config.setConnectionTimeout(30000);
    return new HikariDataSource(config);
}

// Batch operations
// BAD: Individual inserts
for (User user : users) {
    userRepository.save(user);  // N database calls
}

// GOOD: Batch insert
userRepository.saveAll(users);  // Single batched call

// Fetch only what you need
// BAD: Fetch entire entity
List<User> users = userRepository.findAll();
List<String> emails = users.stream()
    .map(User::getEmail)
    .toList();

// GOOD: Projection
@Query("SELECT u.email FROM User u")
List<String> findAllEmails();

// Pagination for large datasets
Page<User> page = userRepository.findAll(PageRequest.of(0, 20));

Caching

// Spring Cache abstraction
@Service
public class ProductService {

    @Cacheable("products")
    public Product findById(Long id) {
        return productRepository.findById(id).orElseThrow();
    }

    @CacheEvict(value = "products", key = "#product.id")
    public Product update(Product product) {
        return productRepository.save(product);
    }

    @CacheEvict(value = "products", allEntries = true)
    public void clearCache() { }
}

// In-memory memoization
private final Map<String, Result> cache = new ConcurrentHashMap<>();

public Result compute(String key) {
    return cache.computeIfAbsent(key, this::expensiveComputation);
}

Profiling Tools

Essential Profiling Tools
  • JProfiler / YourKit - Commercial profilers
  • VisualVM - Free, bundled with JDK
  • async-profiler - Low-overhead sampling profiler
  • JMH - Microbenchmarking harness
  • JFR (Java Flight Recorder) - Production profiling

JMH Benchmark Example

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class StringConcatBenchmark {

    private List<String> items;

    @Setup
    public void setup() {
        items = IntStream.range(0, 100)
            .mapToObj(String::valueOf)
            .toList();
    }

    @Benchmark
    public String stringConcat() {
        String result = "";
        for (String item : items) {
            result += item;
        }
        return result;
    }

    @Benchmark
    public String stringBuilder() {
        StringBuilder sb = new StringBuilder();
        for (String item : items) {
            sb.append(item);
        }
        return sb.toString();
    }
}