If you tune for allocation patterns that are in the code, then you are cementing those as continuing in perpetuity. Better to cut the fat first, so that you can tune for the necessary complexity instead of the accidental. That will be self-correcting because any new misuses will be taxed with higher performance regressions.
People started dismissing allocation discipline as a thing from the past because "that thing was solved a lot ago and the compiler now is smart enough".
Well, for string, yes, but not for arbitrary objects.
There's a version of Java, I can't recall which but I want to say 4? Maybe 3? Where someone rewrote parts of the Swing backend as native code to speed it up. Then Hotspot got good enough in the next version that the generated code was faster than the native code + FFI overhead. FFI was pretty high at the time. So they reverted the native code migration and went back to the old code.
The JVM does heroics to try and avoid it as much as possible, but when you end up with some primitive boxing in a hotspot the amount of GC pressure that creates can be unreal.
Map<Integer, Integer> intCache = new HashMap<>();
while (loading) {
Integer feild1 = intCache.computeIfAbsent(getField1(), (i)->i);
}
This is a terrible thing that shouldn't be as useful as it is to us... but it is really useful. We have a bunch of objects that can optionally have Integer values (hence a null is valid) but those int values are frequently the same.This saves a bunch of memory and ultimately GC pressure as a result.
Valhalla can't come soon enough for us.
Right now, if I do this
LocalDate a = LocalDate.of(2020, 1, 1);
LocalDate b = LocalDate.of(2020, 1, 1);
A and B reference 2 different object allocations on the heap even though they are the same date. a != b.In Java, that can be pretty expensive even for an object as light as a LocalDate. By running the cache and doing
var cache = new HashMap<LocalDate, LocalDate>();
LocalDate a = cache.computeIfAbsent(LocalDate.of(2020, 1, 1), (i)->i);
LocalDate b = cache.computeIfAbsent(LocalDate.of(2020, 1, 1), (i)->i);
Now you have the situation where `a == b` and you immediately end up dropping the object allocation for b on the next GC.The technique works best when you have a lot of repeated objects which are immutable. It is also only really needed because Valhalla isn't here. Once "value types" become a thing, then the representation for `LocalDate` inside the JVM can become just the fields and not a reference. The JVM is also free to do the sort of de-duplication optimization all on it's own for larger objects.
> Valhalla can't come soon enough for us.
This will be interesting to see for sure, I think it will raise the bar for competitors as well; .NET GC has lingered in some ways for a while in progressing [0][1], there is a long standing github discussion around a lower latency GC where there is a potential alternative [2] but nothing really signaling that it could be integrated in future as an option. Valhalla might finally put enough pressure on microsoft to do something about the lag in this space.
[0] - The inferred stackalloc stuff on the JIT level is awesome but I don't count that as improvement to actual GC
[1] - Pinned allocs took a long time and we still can't get aligned pinned allocs (have to manually pad instead)
Since there wasn’t a link to the source code in that post, can you help me understand this - for the SLF4J baseline is your logger impl a console appender, a file appender, or a network service like an OTel collector? Does any of that matter for GC context?
These are typically short-lived objects and therefore cheap. Nevertheless, continually creating many such objects increases GC pressure, in particular if the logging happens in code that doesn't otherwise create many objects.
Consider, for example, if you have a log message like this
logger.info("Hello {}", myOldObject);
if "myOldObject" is large enough or contains references to large things or has just been around for a while, it may be a part of OldGen at this point. And if that's the case, the LogEvent objects will end up automatically promoted to OldGen. Meaning the only time those can be be claimed is in an expensive major collection. The end result is that these things will ultimately fill up old gen and trigger more of the expensive old gen collections.That's why it can be faster in some circumstances to write the more wordy
if (logger.isInfoEnabled()) {
logger.info("Hello {}", myOldObject.toString());
}
Nothing saves you, however, if your string being logged is too long. It can be autopromoted to old gen if you are trying to log a 10mb string.Why do you think this would happen? There's no mechanism that makes young gen objects that reference old gen objects (or are referenced by old gen objects) get promoted faster. You have to survive a certain number of collections.
I was thinking of "GC Nepotism" [1]. That's the case where an object in old gen pointing to an object in new gen will automatically promote that new gen object into old gen. This can be particularly problematic with graph structures.
[1] https://psy-lob-saw.blogspot.com/2016/03/gc-nepotism-and-lin...
Which then gets discarded because that was a Log.verbose and your minimum log level in production is WARN.
Which is why many libraries have moved towards making your log message returned by a lambda. One constant lambda allocation (so, not a lot, an invokedynamic is absolutely fuck all.) that allows you to straight up skip allocating a full string that most likely is interpolating things and attempting to reach for context present on other threads is strictly better in 99.9% of the cases. The GC pressure is kept minimal and most importantly, constant.
This isn't true for the LogEvent or equivalent object, which only gets created after the log level is tested to be applicable by the logger implementation.
For call-site object allocation, you can wrap the logging call into an if statement that checks for the corresponding log level. The lambda allocation isn't constant if it captures anything from the surrounding scope, which will generally be the case for logging calls. (Unless by "constant" you mean that it's a single allocation per execution.)
To quote Bjarne Stroustrup:
> I don't like garbage. I don't like littering. My ideal is to eliminate the need for a garbage collector by not producing any garbage. That is now possible.
Time has moved on.
More importantly, a typical 1990s C++ dev was likely someone who learned assembly, then C or C++. Meaning they already knew how to control hardware / memory allocation, and C++ was just a new set of abstraction tools. It was a step forward for them.
To modern devs, C++ is a step backwards. And a tough one at that.
Anyway, your view of the 1990s devs is incorrect. Almost no programmers who took up C++ learned assembly first (and few ever learned assembly). I believe most of them learned Pascal, or C, or scripting languages like Perl or Tcl or Unix shell scripts. Some may have learned Lisp or some ML variant as their first language, or Fortran 90. They didn't take a step back, they switched to a different set of language design goals and tradeoffs, and found it, well, serviceable.
It is indeed a bit peculiar that the language has had this much staying power. For C, it's much more understandable - because C is such a small and simple language (and one which, as you suggested, often feels like a bunch of syntactic sugar over PDP-7 assembly). But C++ is big, and has its baggage and warts and flaws. I think it's probably because it's been able to adapt and stretch just enough under the influence of trends in programming languages, for people not to ditch it for something new. Maybe Rust will change that; but - C++ might very well "eat its lunch".