I never used a profiler before last night. I profiled one of my apps with Visual VM, and this is what I learned:

In terms of object counts and memory use, the two standouts were HashMap$KeyIterator (53%) and Rectangle (48%), with tens of thousands of instances each.
In terms of time spent in method calls, the big standout was javax.swing.RepaintManager$ProcessingRunnable.run (53%).

I made the following changes to my program:

Instead of iterating over a HashSet of listeners, I changed it to iterate over an array. The array is updated when a listener is added or removed from the HashSet. This eliminated almost all the instances of HashMap$KeyIterator, and improved the run time of my test program from about 12 seconds to 9 seconds.

The listeners were receiving instances of Rectangle, and each one was getting its own copy because Rectangle is mutable. I changed the listener API to receive four ints instead of a Rectangle. This eliminated most of the Rectangles, but did not further improve my test program's run time. However, I noticed a significant change in the graphs of heap use and GC activity. Whereas before it was very spiky and chaotic, now it's a fairly regular sawtooth shape, slowly building to a peak and then dropping when the GC runs.

Finally, I made some changes to combine repaint() calls to take the load off the Swing repaint manager. (I realize that combining these calls is part of the repaint manager's job, but the suggestion that it does so using Runnables led me to think I could do it more efficiently.) These changes improved the run time of my test program tremendously, from 9 seconds down to about 2.5 seconds!

I had already been thinking about getting rid of the Rectangles, but I would not have suspected the other areas as bottlenecks.

I guess I'm doing this right, because I'm achieving results. But what else can I learn? What should I be looking for in a profiler?