Java Performance: Remove Repeated Searches and Unnecessary Work

Reduce repeated searches, unnecessary sorting, eager fallback calls, and excess memory retention, then verify changes with correctness tests, JFR, and JMH.

A Java service does not need a complicated algorithm to waste CPU or memory. Sometimes the extra work comes from a membership check inside a loop, a default value that is evaluated even when it is unnecessary, or a sorting operation that repeatedly parses the same field.

These problems are easy to overlook because the code is usually readable and produces the correct result. With a small input, the difference may be irrelevant. As the input grows or the operation becomes more frequent, the accumulated cost becomes harder to ignore.

The useful question is not whether a particular Java feature is “slow.” It is what work an expression performs, how often it performs that work, and whether the business actually needs the result.

The examples below use student imports and order processing as illustrative workloads. They are not production benchmark results. Each change needs to preserve business behavior before its performance is evaluated.

Repeated Searches Can Turn a Simple Loop into a Large Workload

Suppose an import process receives a list of students and a separate list of selected student IDs:

for (Student student : students) {
    if (selectedIds.contains(student.getId())) {
        process(student);
    }
}

If selectedIds is an ArrayList, each membership check may scan the list. With N students and M selected IDs, the worst-case work is O(N × M), assuming individual ID comparisons have constant cost.

The loop itself is not the problem. The repeated search is.

When the same IDs will be queried many times, building a lookup set can avoid those scans:

Set<Long> selectedIdSet = new HashSet<>(selectedIds);

for (Student student : students) {
    if (selectedIdSet.contains(student.getId())) {
        process(student);
    }
}

With well-distributed hashes, set construction and all membership checks together have expected O(M + N) time. This is an expected bound, not a guarantee that every hash-based operation always takes constant time. The HashSet documentation explicitly makes its performance assumption dependent on hash distribution.

The conversion also consumes memory and takes time. For a tiny list or a single lookup, building a set may provide no useful benefit. If the keys are custom objects, correct and stable equals() and hashCode() implementations are essential.

This particular rewrite preserves student traversal order because the outer loop still iterates over students. It also preserves membership semantics: duplicate selected IDs do not cause repeated processing in either version.

A meaningful comparison should vary both list sizes and the proportion of successful lookups. Failed lookups scan the entire original list; successful lookups depend on where the match occurs. Measure set construction inside the operation if production constructs it for every request. Measuring only lookups against a prebuilt set would answer a different question.

The same reasoning applies to repeatedly scanning orders to calculate hourly counts. Replacing an inner stream with an inner for loop leaves the repeated search intact. Building an index or aggregate once changes the amount of work.

For hourly grouping, the key also matters. An integer from 0 to 23 merges different dates. Decide whether the business means an elapsed hourly interval or a local calendar hour, including how daylight-saving transitions should be handled, before choosing the grouping key.

Collection Access Patterns Matter as Much as Collection Names

Consider this traversal:

for (int i = 0; i < items.size(); i++) {
    process(items.get(i));
}

For an ArrayList, indexed access is constant time. For a LinkedList, finding an element requires traversing links from the nearer end. Repeating indexed access across the whole list can therefore produce O(N²) traversal work.

If the operation simply visits each element, sequential iteration expresses the requirement directly:

for (Item item : items) {
    process(item);
}

The LinkedList documentation describes this traversal behavior. The improvement comes from matching the access pattern to the data structure.

A related mistake appears when an ArrayList is used as a queue:

while (!tasks.isEmpty()) {
    Task task = tasks.remove(0);
    process(task);
}

Removing the first element shifts the remaining elements. Draining a large list this way can require O(N²) element-reference movements.

For a single-threaded queue of non-null tasks:

Deque<Task> queue = new ArrayDeque<>(tasks);

while (!queue.isEmpty()) {
    process(queue.removeFirst());
}

ArrayDeque supports efficient operations at the ends, but it rejects null elements and is not thread-safe. Those constraints must fit the workload. A shared producer-consumer queue requires an appropriate concurrent implementation. See the ArrayDeque API.

If removal is unnecessary, simply iterating over the original collection is often clearer than creating another container.

When benchmarking a destructive operation such as draining a queue, ensure every measured operation receives equivalent input. Otherwise, later invocations may time an already empty queue. Also decide whether copying or building the container belongs in the measurement.

Sorting Can Perform Much More Work Than the Result Requires

An order report may need only the most recent order:

Order latest = orders.stream()
        .sorted(
                Comparator.comparing(Order::getCreatedAt)
                        .reversed()
        )
        .findFirst()
        .orElse(null);

For a general unsorted input, sorting the entire stream typically requires O(N log N) work and buffering. Selecting a maximum requires a linear scan.

However, replacing sorting with max() deserves a correctness check. An ordered stream’s stable sort preserves encounter order for equal sort keys. Code that relies on the first order among tied timestamps should make that rule explicit.

For non-null orders with non-null timestamps, this loop selects the first encountered order with the latest timestamp:

Order latest = null;

for (Order order : orders) {
    if (latest == null
            || order.getCreatedAt().isAfter(latest.getCreatedAt())) {
        latest = order;
    }
}

If ties should instead be resolved by order ID, use that explicit secondary rule. The Stream API documents stable sorting for ordered streams and the maximum-selection operation.

Another sorting cost hides inside the comparator:

records.sort(
        Comparator.comparing(
                record -> LocalDate.parse(record.getDateText())
        )
);

The key extractor can run repeatedly during comparisons. A single record’s date text may be parsed multiple times.

When sorting is a measured hotspot, parse once and retain the key for the duration of the operation:

record DatedRecord(ImportRecord source, LocalDate date) {}

List<DatedRecord> datedRecords = records.stream()
        .map(record -> new DatedRecord(
                record,
                LocalDate.parse(record.getDateText())
        ))
        .collect(Collectors.toCollection(ArrayList::new));

datedRecords.sort(
        Comparator.comparing(DatedRecord::date)
);

This example uses Java records, available as a standard feature from Java 16. Earlier versions can use a small holder class.

The change trades extra storage for fewer parses. It also sorts a separate list rather than mutating records, and it parses every record before sorting. These differences matter if callers depend on mutation or validation behavior.

Verify empty inputs, equal dates, malformed dates, and output order first. Then compare total parsing-plus-sorting time and allocation. A separate diagnostic test can count parses, but remove such instrumentation from the timing benchmark.

Default Arguments Can Execute Even When They Are Not Needed

This expression looks like it loads a product only on a cache miss:

Product product =
        cache.getOrDefault(id, loadFromDatabase(id));

It does not. Java evaluates method arguments before invoking the method, so loadFromDatabase(id) executes even when the map already contains the key.

If the requirement is only to avoid unnecessary loading, an explicit branch is often enough:

Product product = cache.get(id);

if (product == null) {
    product = loadFromDatabase(id);
}

This assumes a null result means absence. A map that permits stored null values may require a containsKey() check to distinguish “missing” from “present with null.”

If the requirement also includes storing the loaded value, another option is:

Product product =
        cache.computeIfAbsent(id, this::loadFromDatabase);

That is a semantic change: getOrDefault() does not populate the map. A loader returning null also does not establish a useful cached value through computeIfAbsent(), so repeated missing-product requests may keep loading.

Concurrency adds another boundary. For ConcurrentHashMap, the computation is atomic for the mapping operation, but some updates may block while it runs. Its documentation recommends short, simple computations. A slow database call inside the mapping function is therefore not a complete cache-loading strategy. Timeouts, load concurrency, expiry, and failure handling still need design. See ConcurrentHashMap.computeIfAbsent.

The same eager-evaluation issue appears with Optional:

User user = existingUser.orElse(loadDefaultUser());

When creating the fallback involves work, defer it:

User user = existingUser.orElseGet(this::loadDefaultUser);

orElseGet() invokes its supplier only when the optional is empty. A constant or existing object does not need this treatment. The distinction is documented in the Optional API.

These changes can be verified without a benchmark. Use a test loader that counts invocations: a hit should perform no fallback call, and a miss should follow the intended loading behavior. Test exceptions and null results as well. Reducing database calls is often more significant than shaving nanoseconds from the expression itself.

Formatting and Logging Can Hide Repeated Work

Parameterized logging avoids constructing a concatenated message when the logging framework does not need it:

log.debug("Imported student: {}", studentId);

But placeholders do not make arbitrary argument expressions lazy:

log.debug("Import payload: {}", serialize(largeImport));

Here, serialization happens before debug() is called. If producing the argument is expensive, guard that work:

if (log.isDebugEnabled()) {
    log.debug("Import payload: {}", serialize(largeImport));
}

The SLF4J FAQ explains the benefit of parameterized logging. That benefit should not be confused with deferring computations performed by the caller.

A simple counter-based test can verify that serialization does not execute when debug logging is disabled. For enabled logging, measure separately: serialization and the logging destination may still dominate the cost.

Fixed regular expressions offer another opportunity to avoid repeated setup:

private static final Pattern STUDENT_CODE =
        Pattern.compile("[A-Z]{2}[0-9]{8}");

boolean valid = STUDENT_CODE.matcher(code).matches();

For repeated validation, reusing a compiled pattern avoids compiling the expression for every call. The pattern can be shared across threads; a mutable Matcher should not be shared concurrently. Reusing a pattern also does not fix an expression with expensive backtracking. Test representative invalid inputs as well as valid ones. These distinctions are covered by the Pattern documentation.

Text generation needs the same attention to scope. Repeatedly appending to an immutable string copies an increasingly large accumulated result:

StringBuilder report = new StringBuilder();

for (String line : lines) {
    report.append(line).append('\n');
}

String result = report.toString();

A builder is appropriate when the complete result must be materialized. If a large report can be written incrementally, a buffered writer may avoid retaining the entire output string.

Neither observation means every + expression should become a builder. A small, one-off expression and a growing string inside a loop are different workloads. Likewise, replacing String.format() must preserve any required precision, padding, and locale behavior.

Batch Pipelines Can Retain More Data Than Expected

A batch import may pass through several complete representations:

List<Row> rows = parseAll(file);
List<Student> students = convertAll(rows);
List<Score> scores = calculateAll(students);
List<Result> results = evaluateAll(scores);

saveAll(results);

The concern is whether several object graphs remain reachable at the same time. Merely declaring local variables does not prove they stay live until method exit; JVM liveness analysis can make references collectible earlier. Retention depends on actual use and references from collections, tasks, caches, and other objects.

For large imports, a bounded pipeline can make the working set easier to control. The parser emits a batch, the application validates and calculates that batch, persistence completes, and references are released before more work is admitted.

But the whole path must be bounded.

Parsing the entire workbook before dividing it into smaller lists still retains the original input. Submitting every batch to an unbounded executor queue can retain all those batches. An asynchronous save may continue holding objects after the producing method returns.

Smaller transactions can also change failure behavior. A job that previously rolled back as one unit may now leave completed batches behind. Resumability, duplicate handling, and job status must be designed alongside the memory change.

To verify the result, observe peak heap use, allocation rate, GC behavior, and queued work under concurrent imports. Throughput alone will not show whether the application has made its memory demand predictable.

Verify the Removed Work, Then Measure the Business Result

I would start with correctness tests and counts of meaningful operations. Confirm that selected students are unchanged, tie-breaking is preserved, fallback loaders are called only when required, and batches do not exceed the intended concurrency.

Next, profile a representative workload. A CPU profile helps identify active computation. Allocation evidence helps locate object creation. Lock, parking, and I/O events help explain waiting. A CPU flame graph alone cannot account for all elapsed time.

For a bounded recording on a representative test instance, one starting point is:

jcmd <pid> JFR.start name=java-work \
  settings=profile duration=60s \
  filename=/path/to/java-work.jfr

The destination must be writable by the target JVM. Recording overhead depends on the enabled events and workload; choose settings accordingly. The command options are documented in the jcmd reference.

For isolated alternatives, use a dedicated JMH benchmark project. Include warmup and multiple forks, return results or consume them through a Blackhole, and keep setup costs inside or outside the measurement according to the real operation being compared.

Before publishing numbers, record:

  • The JDK build, JVM settings, hardware, and relevant library versions.
  • Input sizes, hit rates, data distribution, and concurrency.
  • Whether construction, copying, parsing, and I/O are included.
  • Timing variability and allocation per operation, not just the best run.

Finally, repeat the application-level workload. A faster lookup may have little effect on an endpoint dominated by database waits. Removing an unnecessary database call may produce a much larger improvement than changing the collection used afterward.

The most useful optimization is one whose mechanism is clear: fewer scans, fewer parses, fewer unwanted calls, or fewer objects kept alive together. Once that work is identified, the code change becomes easier to explain—and the performance claim becomes something another developer can verify.

Related reading: Java heap OOM and retained data with Arthas | OpenFeign timeouts and safe retries

Leave a Reply

Your email address will not be published. Required fields are marked *