DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Memory Optimization and Utilization in Java 25 LTS: Practical Best Practices
  • Unleash Peak Performance in Java Applications: Overview of Profile-Guided Optimization (PGO)
  • Legacy Code Refactoring: Tips, Steps, and Best Practices
  • Beginner's Guide to Compilation in Java

Trending

  • A Practical Guide to Using Java Virtual Threads With JMS Listeners
  • How Docker Is Becoming an AI Development Platform
  • Java Enterprise Is Already Ready for the AI Era
  • You Don’t Need To Be a Manager To Lead: Why Leadership Matters for Software Engineers
  1. DZone
  2. Coding
  3. Java
  4. Pragmatic Premature Optimization

Pragmatic Premature Optimization

Learn simple Java performance tips for strings, collections, enums, and initialization that make code faster without sacrificing readability.

By 
Alexander Radzin user avatar
Alexander Radzin
·
Aug. 28, 26 · Opinion
Likes (0)
Comment
Save
Tweet
Share
85 Views

Join the DZone community and get the full member experience.

Join For Free

“...premature optimization is the root of all evil…”

Donald Ervin Knuth 

Introduction

"Premature optimization is the root of all evil." Most software engineers know this, attributed to Donald Knuth, author of The Art of Computer Programming and one of the most influential figures in computer science. Many have also picked up the practical conclusion that followed: "let's make it work first, fix performance later." After all, it's easier to add another EC2 instance than to find the root cause.

But here is what Knuth actually wrote: "We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%."

A little different, isn't it? The second sentence is almost never quoted — and that is convenient, because it turns a careful statement into a simple excuse. Sometimes for laziness. Sometimes because people assume that optimization means sacrificing readability: cryptic bit manipulation, obscure tricks, code that only the author understands at 2 am. I believe Knuth was indeed warning against that kind of optimization. But that assumption is wrong more often than people think. Good, clean code is frequently efficient code too — not by accident, but because choosing the right tool for the job tends to be both clearer and faster. The examples in this article are proof of that.

Scope

This article focuses on simple, cheap, and foolproof tips that can be applied universally — regardless of your architecture, framework, or domain. In my experience, they carry virtually no risk of making things worse.

Architecture, design, networking, database connectivity, threading — these are deliberately out of scope. Not because they are unimportant, but because they are context-dependent. The right answer depends on your specific system, and each of these topics deserves its own article.

Examples

String Operations

We are all familiar with built-in JDK string utilities like: equals(), startsWith(), endsWith(), contains():

Java
 
s1.equals(s2);
s1.startsWith(s2);
s1.endsWith(s2);
s1.contains(s2);


Unfortunately, JDK provides only one function for case-insensitive comparison:

Java
 
s1.equalsIgnoreCase(s2)


There are no functions for case-insensitive startsWith(), endsWith(), contains().

So, often we combine toLowerCase() or toUppserCase() with startsWith(), endsWith(), contains():

Java
 
s1.toLowerCase().startsWith(s2.toLowerCase());
s1.toLowerCase().endsWith(s2.toLowerCase());
s1.toLowerCase().contains(s2.toLowerCase());


A little verbose and null-prone, but just fine if not on the critical path. However, this technique might cause some performance problems. Do not forget that String is an immutable class, so instead of just a char-to-char comparison between two strings, we create two additional strings that then must be garbage-collected. Considering that String is a wrapper over a char array, the memory allocation may become expensive. 

The solution is to use case-insensitive utilities provided by different libraries, e.g., Apache Lang3:

Java
 
startsWithIgnoreCase(s1, s2);
endsWithIgnoreCase(s1, s2);
containsIgnoreCase(s1, s2);


Or, starting from version 3.18.0:

Java
 
Strings.CI.startsWith(s1, s2);
Strings.CS.startsWith(s1, s2);


Where CI exposes case-insensitive and CS — case-sensitive utilities. 

Many people like regular expressions and use java.util.Pattern class sometimes, not where it is really necessary. For example:

Java
 
Pattern.compile("^prefix.+suffix$").matcher(s).find()


Instead of:

Java
 
s.startsWith("prefix") && s.endsWith("suffix")


Or even:

Java
 
Pattern.compile("^prefix").matcher(s).find() instead of s.startsWith("prefix")
Pattern.compile("suffix$").matcher(s).find() instead of s.endsWith("suffix")


Pattern matching is significantly slower than trivial substring matching. 

The following table shows evaluation time for 1 million operations:

Operation * 1 million times

Time, ms

s.equals("hello")

7

s.startsWith("hello")

6

s.endsWith("hello")

11

s.contains("hello")

24

s.toUpperCase().startsWith("HELLO")

65

s.equalsIgnoreCase("hello")

5

Pattern.compile("hello").matcher(s).find()

238

pattern.matcher(s).find()

31


What can we see from this table? 

  1. Performance of equals() and startsWith() is similar
  2. endsWith() is 2 times more expensive
  3. contains() is 4 times more expensive than equals
  4. Changing case followed by startsWith() is 10 times (!) more expensive
  5. Case-insensitive comparison functions do not have any performance penalties
  6. Searching for a substring using a precompiled pattern is about 20% more expensive than using a plain contains() method. 
  7. Compiling the pattern and using it is almost 10 times more expensive than the plain contains() method. 

So next time you reach for Pattern.compile(), it is worth pausing for a second: is regex actually needed here, or is a plain string method both simpler and faster? If you really need a pattern, at least compile it in advance — better yet, declare it as a private static final class member. 

Collections

Let’s assume that we want to know whether a given list contains the specific element:

Java
 
list.contains("red");


In fact, this call invokes code like this:

Java
 
int n = list.size();
for (int i = 0; i < n; i++) {
   if ("red".equals(list.get(i))) {
       return true;
   }
}


Starting from Java 8, we have a streaming API that just hides from us the same gory details:

Java
 
list.stream().anyMatch("red"::equals);


This is perfectly fine when the list is short, changes frequently, or is searched only occasionally. But if the list is large, stable, and searched repeatedly, a HashSet is the right tool — offering average O(1) lookup instead of O(n). If you cannot change the original data structure, converting it once at initialization time and searching the Set from that point forward is almost always worth it.

If both the guaranteed element order and the fast lookup are needed, we can either hold duplicated data structures — a list for ordering and a set for search or just use LinkedHashSet, which solves both problems. 

Another common case is case-insensitive search. We already saw above that the combination of toLowerCase() or toUpperCase() with comparison significantly reduces the performance. This can be solved by using TreeSet with custom comparator, e.g. String.CASE_INSENSITIVE_ORDER:

Java
 
Set<String> set = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);


This gives you a sorted, case-insensitive set with no extra allocations - and the same approach works for TreeMap when your data is key-value pairs. 

Enum Lookups

Everyone knows that an enum entry can be found by its name using a built-in method valueOf(s). However, what to do if the given string is lowercase while enum entries following the naming convention are called using capital letters? Some people use a combination of toUpperCase() and valueOf() that work just fine but have the penalty we discussed above.  

However, very often people prefer to create a special field representing a “custom” name, so the simple enum like:

Java
 
enum Color { RED, GREEN, BLUE }


Turns into:

Java
 
enum Color {
   RED("red"),
   GREEN("green"),
   BLUE("blue"),
   …
}


Let’s mention that this design has at least two disadvantages:

  1. Duplicate data: The custom name is the same as a built-in but in a different case, which can be solved much more easily. 
  2. This allows using really custom names that, according to my experience, in most cases are not needed and just create so-called “edge cases” that, in turn, in most cases are just a signal of bad design and might cause a lot of “stupid” bugs.

However, let’s continue. How do people often use this custom name?

Java
 
public static Color ofColor(String color) {
   return Arrays.stream(values())
           .filter(c -> c.color.equals(color))
           .findFirst()
           .orElseThrow(() -> new IllegalArgumentException("No enum constant %s.%s".formatted(Color.class.getName(), color)));
}


The implementation looks pretty nice, but this approach means that each call of ofColor() iterates over the list. Yes, in most cases enums are not huge, so the list is short, but anyway, why do this if we can just create a map from the custom name to the enum entry once during initialization and then use it with O(1) complexity? The following example solves both problems at once: it uses a case-insensitive map where the key is the standard name() of the enum entry during initialization:

Java
 
private static final Map<String, Color> colors = Arrays.stream(values()).collect(toMap(Enum::name, e -> e,
       (existing, replacement) -> replacement,
       () -> new TreeMap<>(CASE_INSENSITIVE_ORDER)));


So, now the method ofColor() becomes trivial:

Java
 
public static Color ofColor(String color) {
   return Optional.ofNullable(colors.get(color))
       .orElseThrow(() -> new IllegalArgumentException("No enum constant for " + color));
}


One can argue that a map-based implementation is not always possible because sometimes the lookup criteria are too complex to be reduced to a simple key. Although I agree in general, I can say in turn that in many (if not in most) cases this is still possible. 

So far, the lookup key was a simple string. But what if the search criteria is a range rather than an exact value? Consider a more physically accurate model of colors as ranges of electromagnetic waves.

Java
 
public enum Color { BLUE(450, 495), GREEN(495, 570), RED(620, 750); …}


How to implement the method ofWaveLength(int waveLength)? 

The straight-forward way is to iterate over the values of the enum and compare the given wave length with the range for each entry, i.e. implement O(n) search. But we can do better using NavigableMap,  which is designed exactly for this kind of range query:

Java
 
private static final NavigableMap<Integer, Color> wavelengthMap = Arrays.stream(values())
       .collect(Collectors.toMap(
               color -> color.minNm,
               color -> color,
               (existing, replacement) -> existing,
               TreeMap::new
       ));


Unfortunately, the search method is not as trivial as in the previous example, but still very simple and fast:

Java
 
public static Color ofWaveLength(int nm) {
   return Optional.ofNullable(wavelengthMap.floorEntry(nm))
           .map(Entry::getValue)
           .filter(value -> nm <= value.maxNm)
           .orElseThrow(() -> new IllegalArgumentException("No enum constant for wavelength: " + nm + " nm"));
}


Now, let’s compare the performance.

Operation * 1 million times

Time, ms

valueOf(s)

34

valueOf(toUpperCase(s))

78

Iteration with equals()

40

Color.ofColor() iteration

166

Color.ofColor() map

20

Color.ofWaveLength() map

32


The table shows that:

  1. As expected, toUpperCase() reduces performance twice
  2. Iteration with call of equals is a little bit more expensive than valueOf() although the enum has only three members and will grow linearly as the enum grows. The more members enum has, the more time iteration takes. 
  3. Map-based implementation is even faster than one based on the built-in valueOf(). 
  4. Stream-based iteration (ofColor() iteration) is surprisingly slow. Stream setup overhead (boxing, lambda dispatch, spliterator initialization) is non-trivial for tiny collections

Pre-Intitialization

The principle here is: do not do something several times if you can do it once. 

The most trivial example is string or numeric constants:

Java
 
private static final String FILE_NAME = "config.json";
private static final int MAX_VALUE = 10_000;


However, the same principle applies to heavier objects — and that is where it really matters.

Let’s take a look at logging. Most people are used to writing the following “magic” line at the beginning of each class (unless we use Lombok’s @Slf4j annotation):

Java
 
private static final Logger logger = LoggerFactory.getLogger(MyClass.class);


Are all these modifiers (private static final) really needed? Some people try to save typing time:

Java
 
private final Logger logger = LoggerFactory.getLogger(MyClass.class);


Moreover, if the logger is not static, we can do even more:

Java
 
private final Logger logger = LoggerFactory.getLogger(getClass());


This line looks better because it is error-proof: the class here is not hard-coded, so this line can be copied as-is from one class to another or inherited from the base class. So, what’s the problem?

The problem is that retrieving the correct logger is potentially expensive due to synchronized registry lookups. Doing this on every instantiation adds up. A friend of mine told me that once in the company where he worked, this change in some critical path improved performance so much that they managed to reduce the AWS cluster by about one hundred large EC2 machines.

The same rule applies to pattern compilation. As the benchmark table showed, compiling a pattern on every method call is nearly ten times slower than reusing a precompiled one. The result of Pattern.compile() should always be stored in a static final field. The only exception is the case when the regular expression is generated dynamically, but we should do our best to avoid such a design. 

Very often we have to format or parse dates. Traditionally I used SimpleDateFormat. What can be more obvious than this:

Java
 
private static final String FORMAT = "yyyy-MM-dd HH:mm:ss";
private static final DateFormat format = new SimpleDateFormat(FORMAT);


Frankly speaking, I did this many times following the principle I stated above: there is no reason to create the instance every time we need it if we can create it only once. The problem is that SimpleDateFormat is not thread-safe, so sharing the same instance among different threads can cause the problem. Even worse: we can live with this bug for years without knowing about it, since it only happens under high load and in some cases can just produce slightly wrong results that can be lost in an ocean of valid data. So, should we create instances of SimpleDateFormat every time we need it and cause CPU and GC to work hard? Fortunately, starting from Java 8, we can use DateTimeFormatter instead:

Java
 
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_FORMAT);


This class is thread-safe, so we can share its instance among different threads and get consistent results. 

Conclusion

We started with a quote that is almost always cited incomplete. Knuth never said ignore performance — he said don't sacrifice clarity for speculative gains, while reminding us not to pass up opportunities in that critical 3%. The examples in this article live in that 3%.

None of the performance issues described here should ever appear in production code. They are not hard to avoid — they require no profiler, no benchmarking framework, no architectural discussion. Just the habit of reaching for the right tool.

And that habit pays off. Choosing equalsIgnoreCase() over toLowerCase().equals() is cleaner and faster. A static final logger is simpler and cheaper. A pre-built enum map is more readable and O(1). Good code and efficient code are not in conflict here — they are the same code.

The only thing required is the habit of pausing for a second and asking: am I doing this n times when once would do?

All code examples from this article are available on Gist. 

Java (programming language) optimization

Opinions expressed by DZone contributors are their own.

Related

  • Memory Optimization and Utilization in Java 25 LTS: Practical Best Practices
  • Unleash Peak Performance in Java Applications: Overview of Profile-Guided Optimization (PGO)
  • Legacy Code Refactoring: Tips, Steps, and Best Practices
  • Beginner's Guide to Compilation in Java

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook