Chapter 30

Optimizing Java Code

by Michael Morrison


CONTENTS


Even with all its power and wonder, few people can argue the primary drawback to Java as a development platform: execution speed. It's certainly true that the execution speed of Java programs is improving dramatically with the release of just-in-time (JIT) compilers, but there is still much to be desired. Rather than wait around for faster JIT compilers or enhanced Java virtual machines, you can empower yourself by taking control of your Java code and the speed at which it executes.

This chapter looks at Java code optimization and how you can improve the performance of your Java programs. In this chapter, you learn various optimization techniques that can help you become a more efficient Java programmer. To really understand how these optimization techniques help, you have to go under the hood of Java a little. However, I think you'll find that Java code optimization isn't all that difficult. So roll up your sleeves and prepare to get a little dirty.

What Is Code Optimization?

Code optimization is the process of modifying working code to a more optimal state based on a particular goal. The fact that optimization takes place on working code is an important point; always perform optimizations on code after you get the code working. The type of optimization performed depends on the desired goal; code optimization can be divided into three distinct types, which are based on the needs of the developer:

Based on this list, you probably realize now that code optimization isn't just about improving performance; speed optimization is simply one type of optimization. Nevertheless, it is usually the most important type of optimization to consider when dealing with Java code.

Maintainability Optimization

Maintainability optimization is performed to help make code more manageable in the future. This type of optimization is usually geared toward the structure and organization of code rather than modifications to the algorithms used in the code. In general, maintainability optimization involves a programmer studying the code at large and making changes to help other programmers understand and modify the code in the future.

Fortunately, the rigid structure of the Java language goes a long way toward keeping things optimized for maintainability. In fact, if you adhere to basic object-oriented design principles, you really don't need to do anything else to keep your code optimized for maintainability.

Size Optimization

Another popular optimization is size optimization, which involves making changes to code that result in a smaller executable class file. The cornerstone of size optimization is code reuse, which comes in the form of inheritance for Java classes. Fortunately, good object-oriented design strategies naturally favor size optimization, so you rarely have to go out of your way to perform this type of optimization. For example, it's simply good design practice to put into a method code that is reused more than once. In this way, most size optimizations naturally take place during the initial code development.

Although it's not typically a huge issue, size optimization can't be completely ignored in Java programming. This is because the size of your compiled Java classes directly impacts the amount of time it takes your program to load and initially execute. If you leverage as much of the standard Java API code as possible and reuse code by deriving from other classes, you're probably doing enough for the cause of reducing class size.

Speed Optimization

Speed optimization is without a doubt the most important type of optimization when it comes to Java programming. Speed optimization includes all the techniques and tricks used to speed up the execution of code. Considering the performance problems inherent in Java, speed optimization takes on an even more important role in Java than it does in other languages such as C and C++. Because the Java compiler has the last word on how code is generated, most speed optimizations are performed with the compiler in mind.

The rest of this chapter focuses on issues of speed optimization and how to get the best performance out of your Java code. At times, you will sacrifice the other types of optimization for the sake of speed. In most cases, this sacrifice is entirely acceptable-even expected-because the organization of the code and size of the executable classes won't matter much if your programs are too slow to be useable.

Optimizing with the JDK Compiler

All optimizations begin and end with the Java compiler. If you don't understand the compiler, you're largely guessing at which optimizations will have a positive effect on your code. So let's take a look at the JDK compiler and see what role it plays in turning out speedy Java bytecodes. Bytecodes comprise the intermediate processor-independent code generated by the Java compiler. Bytecode executables (classes) are interpreted by the Java runtime system.

Note
Third-party Java compilers are being released that outclass the JDK compiler in regard to speed optimization. Nevertheless, the JDK compiler is the standard Java compiler and is currently the most reliable.

The JDK compiler (javac) includes a switch for generating optimized Java bytecode executables: -O. In release 1.02 of the JDK, this switch results in only two optimizations taking place: inline methods and exclusion of line numbers. The first of these optimizations is the only one that affects the speed of the executable bytecode; final, static, and private methods are inlined by the compiler, resulting in less method call overhead. Method inlining is the process of replacing each call to a method with the actual method code. Inlining can often increase the size of the resulting class file, but it can help improve performance.

The second optimization performed by the JDK compiler results in the exclusion of line number information from the executable class file. This is a size optimization and does nothing in terms of helping speed up the code. However, it does have the side benefit of making the executable class files a little smaller, which improves the download time of Java applets.

The truth is that the JDK compiler does little for you in regard to optimization. This means that you have to plan on doing a lot of optimization by hand. Hopefully, the compiler that ships with Java 1.1 will improve this situation, but you probably can't afford to stand around waiting for miracles.

Costs of Common Operations

Now that you understand what the JDK compiler does (or doesn't do) for you in regard to optimization, it's time to focus on the Java runtime system. By examining the runtime system, you can get an idea of how fast certain types of code run and make smarter decisions about the way you write Java code. What do I mean by examining the runtime system? Well, I mean running different types of code and timing each type to see how the speeds match up. This operation gives you a very realistic look at how code differs in terms of execution speed, and consequently gives you a place to start making appropriate code optimizations.

The speed of an operation is often referred to as the cost of the operation. Code optimization can almost be likened to accounting, in which you try to keep from blowing a performance budget with your code costs. Jonathan Hardwick performed a very neat analysis on the cost of common Java operations on various systems, the results of which I've included in Tables 30.1 through 30.3. These tables contain approximate times in microseconds for common Java operations. Incidentally, the systems used to perform the cost analysis were a Sun Sparcstation 5 running Solaris, an AMD 486 DX4-120 running Windows 95, and an AMD 486 DX4-120 running Linux 1.2.13.

Table 30.1. The costs of Java variable accesses (speeds measured in microseconds).

DescriptionOperation
Solaris
486 Win95
486 Linux
Method variable assignmenti = 1;
0.40.
3
0.5
Instance variable assignmentthis.i = 1;
2.4
0.7
0.9
Array element assignmenta[0] = 1;
1.1
1.0
1.3

Table 30.2. The costs of increment with Java data types.

DescriptionOperation
Solaris
486 Win95
486 Linux
Byte variable incrementbyte b++;
1.2
1.2
1.3
Short variable incrementshort s++;
1.4
1.2
1.3
Integer variable incrementint I++;
0.3
0.1
0.3
Long variable incrementlong l++;
1.1
1.1
1.3
Float variable incrementfloat f++;
0.9
1.1
1.2
Double variable incrementdouble d++;
1.0
1.3
1.5

Table 30.3. The costs of miscellaneous Java operations.

DescriptionOperation
Solaris
486 Win95
486 Linux
Object creationnew Object();
10.7
13.8
12.8
Method invocationnull_func();
3.1
2.1
2.4
Synchronized methodsync_func();
16.3
20.1
15.9
Math functionMath.abs(x);
5.6
4.8
5.6
Equivalent math code(x < 0) ? -x : x;
0.6
0.4
0.6

These tables point out lots of interesting information regarding the performance of Java code. From Table 30.1, it's readily apparent that method variables are more efficient to use than instance variables. Furthermore, you can see that array element assignment is slower than method variable assignment because Java performs bounds-checking operations whenever an array element is accessed. Keep in mind that this table isn't meant as an argument to get rid of all your class member data. Rather, think of it as providing insight into making decisions where the design could go either way.

Table 30.2 shows timing data related to the use of the standard Java data types. As you may have expected, the two 32-bit data types (int and float) showed the best performance because the tests were performed on 32-bit systems. It is interesting to note that the performance differences between using an int over a byte, short, or long is much more significant than for using a float over a double.

Even though the floating-point types show comparable performance to the integer types, don't be misled about using integer math over floating-point math. This timing table reflects only an increment operation, which is much different than more complex operations performed in the context of a practical Java program. Integer math is much more efficient than floating-point math. So use the table as a measure of the relative speeds among integer types, and then try to use integer math throughout your code.

Table 30.3 focuses on a few miscellaneous operations that are worth thinking about. First off, it shows the high cost of creating an object. This should serve as an incentive to eliminate the creation of temporary objects within a loop where the creation occurs over and over. Rather, you can place the temporary object above the loop and reinitialize its members as needed inside the loop.

Table 30.3 also shows the dramatic performance costs of using a normal method versus a synchronized method. Even though synchronization is very important in multithreaded programming, Table 30.3 should be some encouragement to minimize the usage of synchronized methods in situations where you're in a performance squeeze.

Finally, Table 30.3 shows you how using the standard Java math methods can sometimes be a burden. Even something as simple as taking the absolute value of a number imposes much greater performance costs when you call the Math.abs() method, as opposed to inlining the equivalent code yourself.

Isolating Sluggish Code

The biggest mistake you can make in regard to optimizing your Java code is trying to optimize all the code. Being smart about what code you attack is crucial in not spending years trying to improve the performance of your code. More important, it's a well-established fact that a relatively small portion of code is usually responsible for the bulk of the performance drain. It's your job to isolate this code and then focus your optimization efforts accordingly.

Fortunately, isolating problem code isn't all that difficult if you use the proper tools. The most useful tool in finding bottlenecks in your code is a profiler. A profiler's job is to report the amount of time spent in each section of code as a program is executing. The Java runtime interpreter has an undocumented built-in profiler that is easy to use and works pretty well. To use the runtime interpreter profiler, simply specify the -prof option when using the interpreter, like this:

java -prof Classname

Classname is the name of the class you want to profile. Of course, this technique doesn't work too well for applets, because they must be run within the context of the applet viewer tool or a Web browser. Fortunately, you can use the profiler with applets by altering the arguments to the interpreter a little, like this:

java -prof sun.applet.AppletViewer Filename

In this case, Filename is the name of the HTML file containing a link to your applet. When you finish running the applet, the interpreter writes a file named java.prof to the current directory. This file contains profile information for the applet you just ran. The information consists of a list of methods sorted according to how many times each method is called over the course of the program. The methods are listed in order of decreasing method calls, meaning that the first method in the list is called more than any other method.

You can easily use this information as a guide to determine the code on which to focus your optimization efforts. The methods appearing at the top of the java.prof file should receive more of your attention because they are being called much more than methods farther down in the list. Making small performance gains in a method that is called 20,000 times has a much greater impact than speeding up a method that is called only a couple hundred times. The cool thing is that you can try different optimizations and then run the profiler again to see whether the relative times have changed. This is a very practical (if somewhat time-consuming) way to make great strides in speeding up your code.

Optimization Strategies

Now that you've isolated the code that is making your program crawl, it's time to look into exactly what optimizations you can perform to speed things up. You won't always be able to optimize every piece of problem code; the goal is to make big dents in the areas that can be optimized.

Rethink Algorithms

Many C/C++ programmers have traditionally resorted to assembly language when the issue of performance is raised. As a Java programmer, you don't have this option. This is actually a good thing because it forces you to take a closer look at your design approach instead of relying on heavier processor dependence to solve your problems. What the assembly heads don't realize is that much more significant gains can be made by entirely rethinking an algorithm than by porting it to assembly. And trust me, the amount of time spent hand-coding tedious assembly can easily result in a leaner, more efficient algorithm.

This same ideology applies to Java programming. Before you run off writing native methods and expanding loops to get every little ounce of performance (which you learn about in the next sections), take a step back and see whether the algorithm itself has any weaknesses. To put this all into perspective, imagine if programmers had always resorted to optimizing the traditional bubble sort algorithm and had never thought twice about the algorithm itself. The quick sort algorithm, which is orders of magnitude faster than the bubble sort without any optimization, would never have come about.

Use Native Methods

I hate to recommend them, but the truth is that native methods (methods written in C or C++ that can be called from Java code) are typically much faster than Java methods. The reason I'm reluctant to promote their use is that they blow the platform-independence benefit of using Java, therefore tying your program to a particular platform. If platform independence isn't high on your list, however, by all means look into rewriting problem methods in C. You learn how to connect Java to C code in Chapter 33, "Integrating Native Code."

Use Inline Methods

Inline methods, whose bodies appear in place of each method call, are a fairly effective means of improving performance. Because the Java compiler already inlines final, static, and private methods when you have the optimization switch turned on, your best bet is to try to make as many methods as possible final, static, or private. If this isn't possible and you still want the benefits of inlined code, you can always inline methods by hand: Just paste the body of the method at each place where it is called. This is one of those cases in which you are sacrificing both maintainability and size for speed. (The things we do for speed!)

Replace Slow Java API Classes and Methods

There may be times when you are using a standard Java API class for a few of its features, but the extra baggage imposed by the generic design of the class is slowing you down. In situations like this, you may be better off writing your own class that performs the exact functionality you need and no more. This streamlined approach can pay off big, even though it comes at the cost of rewriting code that already works.

Another similar situation occurs when you are using a Java API class and you isolate a particular method in it that is dragging down performance. In this situation, instead of rewriting the entire class, just derive from it and override the troublesome method. This is a good middle-of-the-road solution because you leverage code reuse against performance in a reasonable manner.

Use Look-Up Tables

An established trick up the sleeve of every programmer who has wrestled with floating-point performance problems is the look-up table. Look-up tables are tables of constant integer values that are used in place of time-consuming calculations. For example, a very popular type of look-up table is one containing values for trigonometric functions, such as sine. The use of trigonometric functions is sometimes unavoidable in certain types of programs. If you haven't noticed, Java's trigonometric functions are all floating-point in nature, which is a bad thing in terms of performance. The solution is to write an integer version of the desired function using a look-up table of values. This relatively simple change is sometimes a necessity considering the performance hit you take by using floating-point math.

Eliminate Unnecessary Evaluations

Moving along into more detailed optimizations, you can often find unnecessary evaluations in your code that serve only to eat up extra processor time. Following is an example of some code that unnecessarily performs an evaluation that acts effectively as a constant:

for (int i = 0; i < size(); i++)
  a = (b + c) / i;

The addition of b + c, although itself a pretty efficient piece of code, is being calculated each time through the loop. It is better off being calculated before the loop, like this:

int tmp = b + c;
for (int i = 0; i < size(); I++)
  a = tmp / i;

This simple change can have fairly dramatic effects, depending on how many times the loop is iterated. Speaking of the loop, there's another optimization you might have missed. Notice that size() is a method call, which should bring to mind the costs involved in calling a method (you learned about this performance drag earlier in the chapter). You may not realize it, but size() is called every time through the loop as part of the conditional loop expression. The same technique used to eliminate the unnecessary addition operation can be used to fix this problem. Check out the resulting code:

int s = size;
int tmp = b + c;
for (int i = 0; i < s; i++)
  a = tmp / i;

Eliminate Common Subexpressions

Sometimes, you may be reusing a costly subexpression without even realizing it. In the heat of programming, it's easy to reuse common subexpressions instead of storing them in a temporary variable, like this:

b = Math.abs(a) * c;
d = e / (Math.abs(a) + b);

The multiple calls to Math.abs() are costly compared to calling it once and using a temporary variable, like this:

int tmp = Meth.abs(a);
b = tmp * c;
d = e / (tmp + b);

Expand Loops

One optimization that is popular among C/C++ programmers is loop expansion, or loop unrolling, which is the process of expanding a loop to get rid of the overhead involved in maintaining the loop. You may be wondering exactly what overhead I'm talking about. Well, even a simple counting loop has the overhead of performing a comparison and an increment each time through. This may not seem like much, but when you consider that some code is called thousands of times in a program, it's easy to see how small changes can sometimes yield big results.

Loop expansion basically involves replacing a loop with the brute-force equivalent. To better understand this process, consider the following piece of code:

for (int i = 0; i < 1000; i++)
  a[i] = 25;

That probably looks like some pretty efficient code and, in fact, it is. But if you want to go the extra distance and perform a loop expansion on it, here's one approach:

int i = 0;
for (int j = 0; j < 100; j++) {
  a[i++] = 25;
  a[i++] = 25;
  a[i++] = 25;
  a[i++] = 25;
  a[i++] = 25;
  a[i++] = 25;
  a[i++] = 25;
  a[i++] = 25;
  a[i++] = 25;
  a[i++] = 25;
}

In this code, you've reduced the loop overhead by an order of magnitude, but you've introduced some new overhead by having to increment the new index variable inside the loop. Overall, this code does outperform the original code, but don't expect any miracles. Loop expansion can be effective at times, but I don't recommend placing it too high on your list of optimization tricks.

Summary

This chapter covered a somewhat murky area of Java programming: code optimization. You began by learning about the fundamental types of optimization, including the most popular type of Java optimization: speed optimization. You then learned about the optimizations (or lack thereof ) provided by the JDK compiler. From there, you got a little dose of realism by looking into the timing costs of common Java operations. You finished the chapter by taking an in-depth look at some practical code optimizations you can apply to your own Java programs.

This chapter rounds out Part VI of this book, "Programming Strategies." Armed with these strategies, you're no doubt ready to press on into Part VII, "Advanced Java." In Part VII, you learn about all kinds of advanced Java programming issues including database connectivity, persistence, and security, among other things.