Through hardship to the stars
My name is Denis, I am the team leader of the R&D team at Naumen Service Management Platform.
Since our product is written mainly in Java, we were looking forward to the next LTS release last year, anticipating the power of virtual threads and the coolness of the improved pattern matching.
Here's the same code for comparison: one written in Java 17, the second in Java 21.
Code written in Java 17:
public Double convert(Object value)
{
if (null == value)
{
return null;
}
if (value instanceof Double)
{
return (Double)value;
}
else if (value instanceof String)
{
if (((String)value).trim().isEmpty())
{
return null;
}
return Double.parseDouble((String)value);
}
else if (value instanceof Number)
{
return ((Number)value).doubleValue();
}
throw new AdvImportException("Can't convert value " + value + " to double");
}
Code written in Java 21:
public Double convert(@Nullable Object value)
{
return switch (value)
{
case null -> null;
case Double doubleValue -> doubleValue;
case String stringValue when stringValue.isBlank() -> null;
case String stringValue -> Double.parseDouble(stringValue);
case Number numberValue -> numberValue.doubleValue();
default -> throw new IllegalArgumentException("Can't convert value" + value + " to double");
};
}
To understand the first code and understand what is happening here, you need to wade through all the ifs, instanceofs and delve into the internal logic. The one written in Java 21 looks much better: it is both more pleasant to read and faster to understand.
In general, the advantages of Java 21 are obvious. But was the path to migrating systems to Java 21 as easy and enjoyable? In short: no.
In this article, I’ll tell you what obstacles our team encountered, what we got after the update, and whether it’s worth upgrading at all.
The article will be useful to developers and technical leads who are thinking about or are already planning to migrate their systems to Java 21.
Update on Java 20: challenge one – changing final static fields does not work through reflection
So, August 2023: Java 20 has long been released, and I got the idea to try to update our product, which by that time had been on Java 17 for several years. I wanted to make sure that there were no problems, and in a couple of months, when Java 21 would be released , the update will go smoothly.
Upgrading to Java 20 had to be done in six fairly simple steps:
sdk install java 20.xx
sdk d java 20.xx
change pom.xml
mvn clean install
tomcat -> cargo:run
profit
The plan was this: I install Java 20 on my local machine, make it default, and make changes to the pom.xml file. Then I assemble a project in Java 20, launch it and be glad that the update was successful.
Everything went according to plan until I started assembling the project in Java 20. At this stage, the project assembly was painted over. I realized that it was time to remember the programmer’s main assistants, logs. Actually, this is where I came in. So I found the culprit – changing final static fields through reflection.
Let me briefly explain why we need it. We, like many, use the popular Hibernate ORM solution, but we hack it a little. Namely: we add our own BytecodeProvider implementation to the internal class Environment. This is necessary for a reason. Our application can generate a huge number of short queries, for example, getUUID, toString, hashCode and others. For them, we do not need to make calls to the database, try to deprox the object, and perform other potentially difficult work. It is enough to construct a response from the data that is already in the request arguments. This makes it possible to cope with very heavy loads in such places.
Therefore, we wrote our own implementation of BytecodeProvider and added it to this field in the classical way through reflection. To do this, we temporarily excluded the final modifier and wrote the value we needed into a variable. All this worked fine on Java 8, 11 and 17. On Java 20 it suddenly stopped working.
Is it possible to do this not through reflection, for example, inside Hibernate itself, to carry out a similar replacement, to find an easier way? No, alas, there are no such methods on Hibernate 4 and 5.
Below is the implementation of getting a BytecodeProvider in Hibernate 4. Even if you give it your own providerName, Hibernate will ignore it and return its own BytecodeProvider implementation. In Hibernate 5 and initial versions 6 the situation is similar. Only instead of javassist, byte-buddy is now used. By the way, in the latest versions 6 of Hibernate this problem was fixed – again it became possible to add your own BytecodeProvider implementation. Here here and so here You can find out more on GitHub.
public static BytecodeProvider buildBytecodeProvider(Properties properties) {
String provider = ConfigurationHelper.getString(BYTECODE_PROVIDER, properties, defaultValue: "javassist");
LOG.bytecodeProvider(provider);
return buildBytecodeProvider(provider);
}
private static BytecodeProvider buildBytecodeProvider(String providerName) {
if ("javassist".equals(providerName)) {
return new org.hibernate.bytecode.internal.javassist.BytecodeProviderImpl();
}
LOG.unknownBytecodeProvider(providerName);
return new org.hibernate.bytecode.internal.javassist.BytecodeProviderImpl();
}
So, I went online to look for the reason why our workaround for Java 20 stopped working. The culprit turned out to be JEP 416: Reimplement Core Reflection with Method Handles. His committer Mandy Chang, in a discussion on her pull request, stated that the ability to edit final static fields through reflection was a hack that they never got around to fixing.
Moreover, this JEP died back in Java 18. So, starting with Java 18, accessing final static fields through reflection is no longer possible. As a result, we decided not to look for even more sophisticated ways, but simply forked Hibernate, throwing out the very final modifier from this field. We barely changed our code, but the problem was solved. Having rebuilt Hibernate and made some small changes to the pom file, I continued to move according to plan.
Java 20 Update: Challenge Two – JEP 418 Broke the System
I tried to assemble the project again, again I got a “blue screen” and again went into the logs. This time I came across traces of JEP 418: Internet-Address Resolution SPI. This JEP was also rolled out in Java 18. The address resolution mechanism was greatly optimized here. This was needed for virtual threads, in particular for the Loom project, because before this JEP, working with native calls was not entirely correct. During this optimization, the guys broke our ancient hack. We had this code:
private void initAddressResolver() {
try {
Class<?> clazz = Class.forName("java.net.InetAddressImplFactory");
Method create = clazz.getDeclaredMethod("create");
create.setAccessible(true);
addressResolver = create.invoke(null);
Class<?> inetAddressClass = addressResolver.getClass();
getHostMethod = inetAddressClass.getMethod("getHostByAddr", byte[].class);
getHostMethod.setAccessible(true);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
We took the internal InetAddressImplFactory class, called its create method to get the current addressResolver – we called its native getHostByAddr method:
final class Inet4AddressImpl implements InetAddressImpl {
public native String getLocalHostName() throws UnknownHostException;
public InetAddress[] lookupAllHostAddr(String hostname, LookupPolicy lookupPolicy) throws UnknownHostException {
if ((lookupPolicy.characteristics() & IPV4) == 0) {
throw new UnknownHostException(hostname);
}
return lookupAllHostAddr(hostname);
}
private native InetAddress[] lookupAllHostAddr(String hostname) throws UnknownHostException;
public native String getHostByAddr(byte[] addr) throws UnknownHostException;
private native boolean isReachable0(byte[] addr, int timeout, byte[] ifaddr, int ttl) throws IOException;
}
Why did we do this? It’s simple – this code was written almost at the dawn of the project, in the year 2010, probably still in Java 5. But at that time, it was apparently not possible to make such logic in any other way. Therefore, this old code quietly migrated from version to version of Java and worked perfectly for more than 10 years.
But a new JEP came and ruined everything for us. Fortunately, the fix turned out to be simple. We not only updated Java, but also got rid of legacy code.
Along with JEP 418, it became possible to pull the getHostName statistical method, inside of which lookupByAddress will be called – the getHostByAddr we need is called in it. So all I had to do was throw out a couple of dozen lines of our old code and replace it with one line:
InetAddress inetAddress = ...
inetAddress.getHostName();
Update on Java 20: call three – Degrade Thread.stop() crashed the code
Finally, after these two fixes, the Java 20 project came together. But a couple of seconds after starting, I was again greeted with a “blue screen”.
The reason turned out to be an improvement that was rolled out in Java 20 – Degrade Thread.stop(). In this fix, the guys from JDK did what they promised for a long time. Now all calls that made it possible to manage threads from a Java application have become forRemoval and throw UnsupportedOperationException, which makes working with them absolutely useless.
There were several places in our code where we used similar methods. I had to spend time rewriting all these places to work with threads correctly.
After fixing this problem, the Java 20 project started. All that remained was to roll out the fixes that I had made into the product and wait for the release of Java 21.
Java 21 Update: Challenge One – Ignite 2.15 Doesn't Support New Version
On September 19, Java 21 was released, but on this day, of course, we did not update. We decided to wait until Java 21 is released on all platforms, Docker images appear and version Java 21.0.1 is released. During this time, we prepared our test system and infrastructure. In general, we spent about 2-3 more weeks waiting.
When everything was ready, I began the product update of our product to Java 21. I was guided by the checklist that I used when updating to Java 20. I thought that this time there would be no problems. So I installed Java 21, made it default, made changes to the pom file, assembled the project, started running it, and… the blue screen again.
All roads again led to logs. I found the culprit – Ignite thin client version 2.15. It turned out that it does not yet support Java 21.
They promised to fix the problem in Ignite 2.16. By this time, there was already an issue on GitHub, created back in May 2023. Someone in the pre-release builds of Java 21 discovered a problem and created an issue that was open in October, and no one did anything about it.
So we had no idea how long we would have to wait for a version that would support Java 21. We had a choice: either postpone the update or look for a solution. We chose the second – we refused to use Ignite in the application, because none of our clients used it. In addition, while it was in our code base, we encountered some bugs that had to be fixed and spent time on it.
Ignite 2.16, by the way, was released in December 2023.
As a result, I removed five lines from the pom file and launched the project without any problems. It's time to test it. I sent the thread to the testing system, expecting that after some time I would receive the coveted green checkmark, but no, the “blue screen” again.
Update on Java 21: call two – java.util.SequencedCollection#getFirst failed GWT
I looked into the logs again and discovered this “charm” – StackOverflowException from the depths of the GWT code.
GWT or Google Web Toolkit, if anyone is not aware, is a framework that allows you to write the front-end part of an application in pure Java. We write logic in Java that should be processed in the browser; at the time of compilation, the Java code is transformed into JavaScript, and it is already processed in the browser. And since our entire front was written in GWT at that moment, it was unpleasant to receive such an error from its depths. After all, let me remind you that everything worked perfectly in Java 20.
I started googling to see if anyone had already encountered something similar; after all, GWT remains a fairly popular product. And, indeed, almost immediately I found published on GitHubwhere our problem is stated right in the title.
Java 21, as you know, introduced the new SequencedCollection interface, which brings with it several convenient and useful methods. One of them, getFirst(), turned out to be the culprit that painted over GWT. Along with the issue, a bug fix was already ready, and it literally consisted of several lines.
Alas, we could not wait for a fresh GWT release where this bug fix will be included, since their release cycle is very long. A new version of GWT is usually released once a year, in January or February. Therefore, we did something simpler – we copied this commit into our already existing fork.
After which I rebuilt GWT, made further changes to the pom file and continued testing.
Java 21 update: challenge three – PMD failed
This time the result was much better. Not yet success, but already significant progress towards it. Instead of a green checkmark from the test system, I received an “orange” one, which means most of the tests were passed, but there are still problems. And this problem turned out to be PMD – the coolest static code analyzer. The main feature is the possibility of its expansion. You can write custom rules yourself that will cover the features of your code. We actively use PMD: about 20-30 custom rules check the features we need.
So, I started to figure out why PMD failed. I found out that Java 21 is not yet supported by the latest version 6.55 at that time. But, very fortunately, a release candidate for version 7 has already been announced, which includes support for Java 21.
However, we use maven-pmd-plugin to run PMD. And its latest version at that time, 3.21.2, unfortunately, had not yet been adapted to work with version 7 of PMD. That is, there seems to be support for Java 21, because a PMD release candidate appeared, which promised that everything would work, but it is impossible to run it normally in a project using current means.
Here loomed the unpleasant prospect of making a difficult fork of this plugin, sharpening it for our product and for the new PMD, then testing for a long time and figuring out what doesn’t work. But I didn’t have to do practically any of this. A developer from Munich did everything for me. Andreas Dangel, the main committer of PMD and Maven PMD Plugin, made an experimental branch of Maven PMD Plugin with support for 7 PMDs back in the summer of 2023. So all I had to do was take this branch and adapt it to our conditions.
As a result, PMD itself worked: it launched its built-in rules and tried to check our code. But absolutely all of our custom rules refused to work. In the end, having studied the volumetric Migration Guide from 6 to 7 PMD and having dealt with all the changes in AST, I managed to cope with this problem.
But, I must say, the fight against PMD turned out to be the most serious challenge on the way to updating our platform from Java 17 to 21. When, finally, all the tests were passed, our project was completely adapted for Java 21.
Update to Java 20 and Java 21: summary
Here's what we did when upgrading our platform from Java 17 to Java 21:
forked Hibernate to hack BytecodeProvider;
got rid of legacy with InetAddress Resolution;
rewrote work with threads;
dropped Ignite;
updated several libraries: byte-buddy, spotbugs, etc.;
forked Maven PMD Plugin, updated PMD and rewrote all our rules.
But the latter has now changed again. In the spring of 2024, the official version of 7 PMD and the official Maven PMD Plugin were released, which supports the operation of 7 PMD. So in the current version we are already using the native implementation of the plugin instead of our own fork.
Well, the time has come for the main question: is it worth upgrading? Or can you live quietly on Java 17, 11 or even 8 and do nothing?
Our answer: definitely worth it. And this is not only because with each version you get a large package of ready-made new language features, but also because of the continuous improvement in performance and language security that you get simply by updating.
At the spring Naumen Java Meetup #3, I spoke in more detail about the migration path to Java 21.
If you want even more actual evidence of a performance boost simply by updating from Java 17 to 21, then I recommend taking a look report Pera Minborg, who, I hope, will convince you that I am right.