1

I recently joined a new project that is written in java. The project has been worked on for a few years by different developers, so there are many different code styles across different classes, and I'd like to consolidate this project to a singular design, hopefully to provide a little bit of optimization and/or readability. I'm going through the code base, and seeing the following examples:

Class A:

public void doLogic(ClassB cb) {
    ArrayList<Object> myList = runShell(/*some shell script*/)
    for(item in myList) {
         cb.doSomethingA(item);
         cb.doSomethingB(item);
    }
}

All well and good. However, a chance I see for optimization is in ClassB:

ClassB:

public Class ClassB{
    //init some fields
    public ClassB() {}

    public ClassB ClassBFactory() {
       ClassB cb = new ClassB();
       //set the fields
       return cb
    }

    public Object doSomethingA(Object item) {
       return new ClassBHelper().processA(item);
    }

    public Object doSomethingB(Object item) {
       return new ClassBHelper().processB(item);
    }
}

Ignore "Why are your classes defined this way", as this is very, VERY shortened pseudo-code, but it gets the point across. I look at this and I see unecessary object creation. The two methods are creating their own object of the helper class, then are immediately destroyed when it goes out of scope. What I want to do is have this object created in the ClassB factory method like:

public Class ClassB{
    //init some fields
    ClassBHelper cbHelper;
    public ClassB() {}

    public ClassB ClassBFactory() {
       ClassB cb = new ClassB();
       this.cbHelper = new ClassBHelper()
       //set the fields
       return cb
    }

    public Object doSomethingA(Object item) {
       return this.cbHelper.processA(item);
    }

    public Object doSomethingB(Object item) {
       return this.cbHelper.processB(item);
    }
}

Yeah the Helper class will persist longer in mem untill that ClassB object goes out of scope, but mem isn't REALLY a concern in my environment, and I feel like creating and instantly destroying an object over and over again is bad design, but I don't know if it's worth it to rewrite classes like this. Any advice would be appreciated

3
  • 5
    Allocating an object in java that doesn't have long initialization code is literally just incrementing a pointer. There's almost never anything to gain from avoiding it. Dec 16, 2021 at 11:34
  • 3
    In fact, allocating and collecting short-lived temporary objects is as fast as stack allocation in C, and faster than malloc / free. Dec 16, 2021 at 11:58
  • 2
    Maybe a new helper is created with each call, because code cannot be run concurrently on a single helper instance. In that case you may introduce bugs that are hard to track down, when instantiating the helper as long lived members ... Dec 16, 2021 at 21:22

2 Answers 2

6

tl;dr: don't do it!

Without knowing the reasoning of the original author of the code it's hard to say how it would best be cleaned up. "Helper" classes are always somewhat suspicious, and you should really understand the code deeply before applying changes that may give very marginal performance improvements while potentially breaking stuff.

In some situations, short-lived objects may be just the right solution for embodying some complex algorithm, so it might be possible that this "helper" class is justified, but making them long-lived just because you despise object creation and deletion does not make sense.

If your general top-level code is representative of the actual code (running a shell script, then processing its results) it is extremely unlikely that there will be a measurable performance difference, as any possible improvement in the processing loop will be completely lost in the noise, so if your aversion against creation and deletion of these objects comes from fear of bad performance, that is totally unjustified.

3
  • Thanks for the input! Yeah, I've been reading around that this thought is outdated. The reason I'm going for this code refactor is that this code controls a CICD pipeline. Not all classes were coded with OOP in mind, and sometimes I need to pass the build object of the script pipeline for some env stuff. So I'm trying to nail down bad OOP practices, and this one popped out to me, but I wasn't sure if it's bad design or not :) Dec 16, 2021 at 10:53
  • 1
    Not only is this a god answer from a 'save yourself from yourself' perspective, short-lived objects don't impact performance much in contemporary JVMs. Decades have gone into optimizing JVMs for short-lived objects. What you need to worry about in Java are medium-term objects. That is, objects that live long enough to be promoted to the tenured space and then die there. That's the killer for GC performance.
    – JimmyJames
    Dec 16, 2021 at 21:47
  • 2
    @JimmyJames: Just for completeness' sake: your statement applies equally to all modern GCs, not just the JVM ones. In fact, many of the important techniques were invented in Smalltalk, Lisp, and other languages before Java. It's just that "Java money" provided the necessary engineering resources to bring them to the mainstream. (I know you know this and only mentioned Java because that's what the question is about, but I though it is worth pointing out.) Dec 17, 2021 at 1:52
4

tl;dr summary: There may very well be reasons to refactor this code, but object allocation is not it.

Correctness

As mentioned in the comments, it may be the case that these helpers actually have internal state that is specific to each run. If that is the case, then reusing the helper may lead to bugs.

Garbage Collection

When you analyze the lifetimes of objects in typical programs, you will notice two things:

  • Almost all objects die young.
  • Almost all of the survivors live forever.

And when you look at references, you will notice:

  • Old objects almost never reference young objects.

This has been empirically measured across many different codebases in many different languages in many different domains.

The assumption that these three observations hold for every program is called the Generational Hypothesis. Practically all modern high-performance garbage collectors are optimized to take advantage of this. They do this by splitting the objects into two or more generations and using different strategies to collect these generations.

A typical way of doing this is with two generations. In the young generation, you simply keep a pointer to the start of the free space, and allocating an object is just incrementing that pointer by the size of the object. That's an extremely fast constant time operation, equivalent to allocating an object on the stack and faster than e.g. dynamic memory allocation with malloc in C.

For collecting, you copy all the live objects into the old generation which is O(#live objects). If our Generational Hypothesis is correct, there should only be a small number of live objects, so this process should also be very fast. Note that copying the objects requires fixing up all existing references to those objects … but here is where the second aspect of our Generational Hypothesis comes in: we assume that there are almost no references from the old objects to the young objects, and as for the young objects, we need to touch those anyway to copy them, so we can fix up the references at the same time. And freeing the dead objects is literally just resetting the pointer.

For the old generation, in turn, we can then use a different algorithm which optimizes for the assumption that it won't actually have to collect many objects because most objects that have survived into the old generation will live forever. We can also collect the old generation much less often because again, we don't actually expect to be able to collect much anyway.

Cliff Click of Azul Systems gave a great example a couple of years ago, around 2008–2009: Cliff Click was running the simple Ant Colony Simulation that Rich Hickey used in his early Clojure talks on one of Azul's Azul Vega 3 Series 7300 Model 7380D Java Compute Accelerators with 864 cores and 768 GiBytes of RAM. (Those specs sounded a lot more impressive in 2008 when the machine came out.) The Ant Colony Simulation is designed to showcase Clojure's concurrency features and thus runs each Ant in a separate Agent. When running the simulation with many thousands of Ants and 700 threads in the thread pool, 700 CPU cores were fully loaded and the heap grew to 150 GiByte.

The simulation is written in a functional style and thus generates tons of temporary objects: it was generating more than GiByte/s of garbage! Yet the GC never even broke a sweat. The combined usage of the GC and the JIT compiler never even exceeded 50 CPU cores, and there were still well over 100 CPU cores left for other work.

If a GC in 2009 could handle 20 gigs of garbage per second, surely, a GC in 2021 can handle a couple of temporary helper objects!

As mentioned in the comments, the biggest killer for GC performance are objects that die middle-aged. Objects that live long enough to get copied out of the young generation (which is expensive) and then die in the old generation (which is again expensive).

The refactoring you are proposing would artificially extend the lifetime of those helper objects and thus break the assumption that "almost all objects die young".

Escape Analysis / Detection

Many modern high-performance language implementations also perform Escape Analysis, Escape Detection, or both. "Escape" here means "leaving the local scope", "Analysis" essentially means "statically", and "Detection" is the same but done dynamically at runtime. If Escape Analysis or Escape Detection can determine that no references to your object escape the local scope, it can actually allocate the object on the stack, and not even allocate it on the heap at all.

In the original version, it is very likely that the implementation will be able to determine that the anonymous instance of ClassBHelper does not escape the scope of processA or processB. Whereas in your refactored code, cbHelper is most definitely not local.

Garbage Collection, part 2

Another assumption that modern high-performance garbage collectors make is:

  • Objects are only rarely mutated after they are initialized.

Mutation is one of the worst killers of GC performance. In fact, in the garbage collection community, they actually refer to the user program simply as the mutator because that is the most important aspect of it. Mutation is especially bad for concurrent collectors (collectors that run concurrently with the user program instead of stopping it) and also for parallel collectors (collectors that run multiple of their phases in an overlapping manner, or make use of parallelism within their phases, or both).

While this does not apply to your proposed refactoring, it is something that happens when you try to save object allocations by reusing objects but need to "reset" them in between before you use them.

By the way: if you think about it, you will realize that not having mutation also trivially implies the reference aspect of the Generational Hypothesis: if objects are not mutated after they are initialized, then they can't refer to newer objects that didn't exist yet when they were initialized!

An aside: functional programming

You will notice that functional programming actually satisfies the assumptions pretty well: there is obviously no mutation, thus also no references from old to young objects. And generally, functional programs are often designed as pipelines where each stage of the pipeline creates a slightly modified version of the object (note: "creates", not "changes it into"), thus creating a lot of short-lived objects but no long-lived ones.

In particular, generating all those short-lived temporary objects is often cited as a reason not to use FP for performance, but as we have seen, quite the opposite is true: this kind of usage pattern is exactly what modern high-performance GCs are optimized for!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.