All Categories :
Java
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.
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:
- Maintainability
- Size
- Speed
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.
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.
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).
Description | Operation
| Solaris | 486 Win95
| 486 Linux |
Method variable assignment | i = 1;
| 0.40. | 3
| 0.5 |
Instance variable assignment | this.i = 1;
| 2.4 | 0.7
| 0.9 |
Array element assignment | a[0] = 1;
| 1.1 | 1.0
| 1.3 |
Table 30.2. The costs of increment with Java data types.
Description | Operation
| Solaris | 486 Win95
| 486 Linux |
Byte variable increment | byte b++;
| 1.2 | 1.2
| 1.3 |
Short variable increment | short s++;
| 1.4 | 1.2
| 1.3 |
Integer variable increment | int I++;
| 0.3 | 0.1
| 0.3 |
Long variable increment | long l++;
| 1.1 | 1.1
| 1.3 |
Float variable increment | float f++;
| 0.9 | 1.1
| 1.2 |
Double variable increment | double d++;
| 1.0 | 1.3
| 1.5 |
Table 30.3. The costs of miscellaneous Java operations.
Description | Operation
| Solaris | 486 Win95
| 486 Linux |
Object creation | new Object();
| 10.7 | 13.8
| 12.8 |
Method invocation | null_func();
| 3.1 | 2.1
| 2.4 |
Synchronized method | sync_func();
| 16.3 | 20.1
| 15.9 |
Math function | Math.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.
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.
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.
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.
Contact
reference@developer.com with questions or comments.
Copyright 1998
EarthWeb Inc., All rights reserved.
PLEASE READ THE ACCEPTABLE USAGE STATEMENT.
Copyright 1998 Macmillan Computer Publishing. All rights reserved.