Pragmatic Premature Optimization
Learn simple Java performance tips for strings, collections, enums, and initialization that make code faster without sacrificing readability.
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():
s1.equals(s2);
s1.startsWith(s2);
s1.endsWith(s2);
s1.contains(s2);
Unfortunately, JDK provides only one function for case-insensitive comparison:
s1.equalsIgnoreCase(s2)
There are no functions for case-insensitive startsWith(), endsWith(), contains().
So, often we combine toLowerCase() or toUppserCase() with startsWith(), endsWith(), contains():
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:
startsWithIgnoreCase(s1, s2);
endsWithIgnoreCase(s1, s2);
containsIgnoreCase(s1, s2);
Or, starting from version 3.18.0:
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:
Pattern.compile("^prefix.+suffix$").matcher(s).find()
Instead of:
s.startsWith("prefix") && s.endsWith("suffix")
Or even:
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?
- Performance of
equals()andstartsWith()is similar endsWith()is 2 times more expensivecontains()is 4 times more expensive than equals- Changing case followed by
startsWith()is 10 times (!) more expensive - Case-insensitive comparison functions do not have any performance penalties
- Searching for a substring using a precompiled pattern is about 20% more expensive than using a plain
contains()method. - 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:
list.contains("red");
In fact, this call invokes code like this:
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:
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:
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:
enum Color { RED, GREEN, BLUE }
Turns into:
enum Color {
RED("red"),
GREEN("green"),
BLUE("blue"),
…
}
Let’s mention that this design has at least two disadvantages:
- Duplicate data: The custom name is the same as a built-in but in a different case, which can be solved much more easily.
- 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?
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:
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:
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.
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:
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:
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:
- As expected,
toUpperCase()reduces performance twice - 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. - Map-based implementation is even faster than one based on the built-in
valueOf(). - 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:
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):
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:
private final Logger logger = LoggerFactory.getLogger(MyClass.class);
Moreover, if the logger is not static, we can do even more:
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:
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:
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.
Opinions expressed by DZone contributors are their own.
Comments