Lecture
Binary compatibility (also called binary compatibility; English: binary compatibility) — a form of software compatibility that allows a program to run in different environments without modifying its executable files.
This term is often used to mean «operating system compatibility», and in that case it refers to the ability of an already compiled version of a program built for one operating system to run on a different operating system without recompilation. For example, practically all programs written for Windows 2000 can be run on Windows XP — this means that Windows 2000 and Windows XP are binary (byte-level) compatible.
Binary compatibility encompasses byte-for-byte compatibility of the load fields, complete identity of the function call mechanism, of variable passing and of obtaining computation results, and a full implementation of the programming interface. Technically, the implementation may be entirely different — the key requirement is that all calls are implemented and that they produce the expected result, while the means by which that result is achieved is left to the program's creators.
Breaking binary compatibility means discontinuing support for programs and mandating that a program be recompiled, along with any necessary fixes, in order for it to work. For example, after Apple began using Intel processors in its computers, binary compatibility was broken with all applications written for PowerPC processors. To avoid losing all the software products developed in the past for the Mac OS operating system, Apple uses the lightweight translator Rosetta, which translates Mac OS system calls for PowerPC into Mac OS calls for Intel. This example also demonstrates a possible method for dealing with a break in binary compatibility.
On the Solaris 10 operating system for x86, it is possible to run Linux applications without recompilation using BrandZ. On the SPARC platform, this capability is not available.
Many of you have probably wondered things like «What happens if someone slips the wrong version of a library into my application?». It's a good question, and you'll find the answer to it, and to a few others, in this post. As a warm-up, here's a little puzzle: suppose we have two interfaces and a class implementing one of them:
And also a class that has a method foo, overloaded for A and B. This method is called on an instance of class C:
It's fairly obvious that «A» will be printed. It's no less obvious that if we were to say C implements A, B, we'd get a compilation error (for those to whom the latter is not obvious, I can recommend reading about how method resolution works. For example, in the specification in section 15.12.2, or in places that describe it more simply).
But what happens if we recompile only C.java and then run CompatibilityChecker from an existing class file — that is a more complicated question. Interested? Read on!
Those who know that overloaded methods are resolved at compile time can figure out that, for this reason, the class file will already contain the information about which method to call, and therefore «A» will be printed as a result. Let's verify this assumption:
Indeed, as you can see from the instruction at offset 19, a quite specific method is being called. However, those who have heard about the verifier might object and suggest that it will notice that these are the wrong bees class C has changed, and throw an exception. Fortunately, they are mistaken, since the verifier checks only the structural correctness of classes and interfaces, not the version consistency of class files.
So, let's go ahead and run our code and confirm that the original assumption was correct: «A» will indeed be printed.
In addition, one might assume that the virtual address table could contain some incorrect address, and therefore everything would break at runtime with a NoSuchMethodError. This assumption is also mistaken, since the method foo(A) is called, and there is only one such method in the virtual table. It would be a different matter if there were subclasses overriding it…
Suppose we have the following three classes:
And a class that calls foo in different variations:
Everyone, of course, knows that since methods on overridden types are resolved at runtime, the initial output will be as follows:
Now it's time to play a dirty trick, replacing the class file of A with the compilation result of the following code:
It's fairly easy to understand what will happen in this case. First, all attempts to call methods where foo is called on an instance of class A will definitely fail with NoSuchMethodError. Among these attempts is also the call to super.foo() in class C. Second, as we have already seen earlier, the method B.foo() will be called successfully.
Now let's change tactics: let's once again make A.foo the way it originally was, but now change B and C, removing the override of the method foo from them entirely:
When the code runs, dynamic dispatch will find only a single entry for A.foo, and therefore will call it in all cases, as a result of which we will see in the console only the letter «A» and a complete absence of any exceptions.
Let's continue our investigations, once again overriding the methods in B and C. After running it, as we might expect, dynamic dispatch will find all the entries in the virtual table and produce exactly the same output as the one we would get by recompiling everything from scratch.
Earlier we only tried experimenting with methods. Now let's look at what happens with fields. Suppose we have a class that stores a value of type int and a subclass of this class:
And, as usual, a consumer of class B:
Now let's add to class B its own field with the same name:
Let's look at the bytecode that was generated for CompatibilityChecker:
This listing may be misleading, since at offset 11 the comment seems to say that the field belongs to B. So one might assume that when B is recompiled we'll run into an error. However, it turns out that this is not the case at all. Since the field physically exists only in the base class, the operand of the putfield instruction points precisely to that very field we need, and as a result the code continues to work after the changes.
In the specification, an entire chapter is devoted to binary compatibility, in which the core concept is a «binary compatible» or «safe» change. The specification states that when only safe changes are made, safe execution of the application is guaranteed without recompiling everything else and without linking errors. Oddly enough, in the entire enormous chapter there is no exact definition of a binary compatible operation, but there are plenty of examples:
It is perhaps precisely because of such a weak definition of binary compatible operations that problems arise.
As it turned out, the specification is not strict enough, and it's possible to come up with a case in which safe changes lead to a linking error. Let's consider an interface, a class implementing this interface, and one more class that uses them:
Let's make two safe changes: add a method foo to interface A and change the implementation of the main method of class CompatibilityChecker:
When it runs, as you may have guessed, an error will occur, namely AbstractMethodError: B.foo()V, which in principle should not happen. This problem is known and lies at the very core of Java bytecode handling. There have been proposals to fix the situation, but so far they have led nowhere.
So, the answer to the question that appeared at the very beginning of the article («What happens if someone slips the wrong version of a library into my application?») is this: «Who knows. It depends on how the version that was used during compilation differs from the one used at runtime».
The article does not touch on some obvious things. For example, that with incompatible changes such as removing methods and classes or turning a class into an interface, merciless errors will be thrown, such as NoSuchMethodError, NoClassDefFoundError or IncompatibleClassChangeError.
Comments