Friday, August 15, 2014

Autoboxing, Unboxing, and NoSuchMethodError

J2SE 5 introduced numerous features to the Java programming language. One of these features is autoboxing and unboxing, a feature that I use almost daily without even thinking about it. It is often convenient (especially when used with collections), but every once in a while it leads to some nasty surprises, "weirdness," and "madness." In this blog post, I look at a rare (but interesting to me) case of NoSuchMethodError resulting from mixing classes compiled with Java versions before autoboxing/unboxing with classes compiled with Java versions that include autoboxing/unboxing.

The next code listing shows a simple Sum class that could have been written before J2SE 5. It has overloaded "add" methods that accept different primitive numeric data types and each instance of Sum> simply adds all types of numbers provided to it via any of its overloaded "add" methods.

Sum.java (pre-J2SE 5 Version)
import java.util.ArrayList;

public class Sum
{
   private double sum = 0;

   public void add(short newShort)
   {
      sum += newShort;
   }

   public void add(int newInteger)
   {
      sum += newInteger;
   }

   public void add(long newLong)
   {
      sum += newLong;
   }

   public void add(float newFloat)
   {
      sum += newFloat;
   }

   public void add(double newDouble)
   {
      sum += newDouble;
   }

   public String toString()
   {
      return String.valueOf(sum);
   }
}

Before unboxing was available, any clients of the above Sum class would need to provide primitives to these "add" methods or, if they had reference equivalents of the primitives, would need to convert the references to their primitive counterparts before calling one of the "add" methods. The onus was on the client code to do this conversion from reference type to corresponding primitive type before calling these methods. Examples of how this might be accomplished are shown in the next code listing.

No Unboxing: Client Converting References to Primitives
private static String sumReferences(
   final Long longValue, final Integer intValue, final Short shortValue)
{
   final Sum sum = new Sum();
   if (longValue != null)
   {
      sum.add(longValue.longValue());
   }
   if (intValue != null)
   {
      sum.add(intValue.intValue());
   }
   if (shortValue != null)
   {
      sum.add(shortValue.shortValue());
   }
   return sum.toString();
}

J2SE 5's autoboxing and unboxing feature was intended to address this extraneous effort required in a case like this. With unboxing, client code could call the above "add" methods with references types corresponding to the expected primitive types and the references would be automatically "unboxed" to the primitive form so that the appropriate "add" methods could be invoked. Section 5.1.8 ("Unboxing Conversion") of The Java Language Specification explains which primitives the supplied numeric reference types are converted to in unboxing and Section 5.1.7 ("Boxing Conversion") of that same specification lists the references types that are autoboxed from each primitive in autoboxing.

In this example, unboxing reduced effort on the client's part in terms of converting reference types to their corresponding primitive counterparts before calling Sum's "add" methods, but it did not completely free the client from needing to process the number values before providing them. Because reference types can be null, it is possible for a client to provide a null reference to one of Sum's "add" methods and, when Java attempts to automatically unbox that null to its corresponding primitive, a NullPointerException is thrown. The next code listing adapts that from above to indicate how the conversion of reference to primitive is no longer necessary on the client side but checking for null is still necessary to avoid the NullPointerException.

Unboxing Automatically Coverts Reference to Primitive: Still Must Check for Null
private static String sumReferences(
   final Long longValue, final Integer intValue, final Short shortValue)
{
   final Sum sum = new Sum();
   if (longValue != null)
   {
      sum.add(longValue);
   }
   if (intValue != null)
   {
      sum.add(intValue);
   }
   if (shortValue != null)
   {
      sum.add(shortValue);
   }
   return sum.toString();
}

Requiring client code to check their references for null before calling the "add" methods on Sum may be something we want to avoid when designing our API. One way to remove that need is to change the "add" methods to explicitly accept the reference types rather than the primitive types. Then, the Sum class could check for null before explicitly or implicitly (unboxing) dereferencing it. The revised Sum class with this changed and more client-friendly API is shown next.

Sum Class with "add" Methods Expecting References Rather than Primitives
import java.util.ArrayList;

public class Sum
{
   private double sum = 0;

   public void add(Short newShort)
   {
      if (newShort != null)
      {
         sum += newShort;
      }
   }

   public void add(Integer newInteger)
   {
      if (newInteger != null)
      {
         sum += newInteger;
      }
   }

   public void add(Long newLong)
   {
      if (newLong != null)
      {
         sum += newLong;
      }
   }

   public void add(Float newFloat)
   {
      if (newFloat != null)
      {
         sum += newFloat;
      }
   }

   public void add(Double newDouble)
   {
      if (newDouble != null)
      {
         sum += newDouble;
      }
   }

   public String toString()
   {
      return String.valueOf(sum);
   }
}

The revised Sum class is more client-friendly because it allows the client to pass a reference to any of its "add" methods without concern for whether the passed-in reference is null or not. However, the change of the Sum class's API like this can lead to NoSuchMethodErrors if either class involved (the client class or one of the versions of the Sum class) is compiled with different versions of Java. In particular, if the client code uses primitives and is compiled with JDK 1.4 or earlier and the Sum class is the latest version shown (expecting references instead of primitives) and is compiled with J2SE 5 or later, a NoSuchMethodError like the following will be encountered (the "S" indicates it was the "add" method expecting a primitive short and the "V" indicates that method returned void).

Exception in thread "main" java.lang.NoSuchMethodError: Sum.add(S)V
 at Main.main(Main.java:9)

On the other hand, if the client is compiled with J2SE 5 or later and with primitive values being supplied to Sum as in the first example (pre-unboxing) and the Sum class is compiled in JDK 1.4 or earlier with "add" methods expecting primitives, a different version of the NoSuchMethodError is encountered. Note that the Short reference is cited here.

Exception in thread "main" java.lang.NoSuchMethodError: Sum.add(Ljava/lang/Short;)V
 at Main.main(Main.java:9)

There are several observations and reminders to Java developers that come from this.

  • Classpaths are important:
    • Java .class files compiled with the same version of Java (same -source and -target) would have avoided the particular problem in this post.
    • Classpaths should be as lean as possible to reduce/avoid possibility of getting stray "old" class definitions.
    • Build "clean" targets and other build operations should be sure to clean past artifacts thoroughly and builds should rebuild all necessary application classes.
  • Autoboxing and Unboxing are well-intentioned and often highly convenient, but can lead to surprising issues if not kept in mind to some degree. In this post, the need to still check for null (or know that the object is non-null) is necessary remains in situations when implicit dereferencing will take place as a result of unboxing.
  • It's a matter of API style taste whether to allow clients to pass nulls and have the serving class check for null on their behalf. In an industrial application, I would have stated whether null was allowed or not for each "add" method parameter with @param in each method's Javadoc comment. In other situations, one might want to leave it the responsibility of the caller to ensure any passed-in reference is non-null and would be content throwing a NullPointerException if the caller did not obey that contract (which should also be specified in the method's Javadoc).
  • Although we typically see NoSuchMethodError when a method is completely removed or when we access an old class before that method was available or when a method's API has changed in terms of types or number of types. In a day when Java autoboxing and unboxing are largely taken for granted, it can be easy to think that changing a method from taking a primitive to taking the corresponding reference type won't affect anything, but even that change can lead to an exception if not all classes involved are built on a version of Java supporting autoboxing and unboxing.
  • One way to determine the version of Java against which a particular .class file was compiled is to use javap -verbose and to look in the javap output for the "major version:". In the classes I used in my examples in this post (compiled against JDK 1.4 and Java SE 8), the "major version" entries were 48 and 52 respectively (the General Layout section of the Wikipedia entry on Java class file lists the major versions).

Fortunately, the issue demonstrated with examples and text in this post is not that common thanks to builds typically cleaning all artifacts and rebuilding code on a relatively continuous basis. However, there are cases where this could occur and one of the most likely such situations is when using an old JAR file accidentally because it lies in wait on the runtime classpath.

3 comments:

@DustinMarx said...

The graphic of boxing conversions and unboxing conversions and the bullet regarding using javap -version to see the "major version" of a compiled class have been added since this post's original publication.

@DustinMarx said...

The "Introduction" portion of the article What Java Developers Know About Compatibility, And Why This Matters by Jens Dietrich, Kamil Jezek, and Premek Brada discusses issues of Java binary compatibility including those related to autoboxing and unboxing.

@DustinMarx said...

The r/java subreddit thread Friendly reminder about Integer, int, nulls and autoboxing/unboxing contains several examples of autoboxing and auto-unboxing surprises.