This is the 16th day of my participation in Gwen Challenge

This article is the third in jakewharton’s series on D8 and R8.

  • Avoiding vendor-and version-specific VM Bugs
  • Originally written by jakewharton
  • Translator: Antway

In the previous two articles, I introduced D8’s use of deicing for compatibility with new Java language features. Deicing is an interesting feature, but it’s a secondary feature of the D8. D8’s primary responsibility is to convert stack-based Java bytecode to register-based Dalvik bytecode so that it can run on Android VMS.

During Android’s implementation, we thought this conversion (called Dexing) was a solvable problem. However, during the building and rollout of D8, bugs were found in specific vendors or virtual machines on specific versions, which this article explores.

1. Not A Not

D8’s process of compiling Java bytecode to Dalvik bytecode can be seen in a simple example:

class Not {
  static void print(int value) { System.out.println(~value); }}Copy the code

Let’s look through javac compilation.

$ javac *.java

$ javap -c *.class
class Not {
  static void print(int);
    Code:
       0: getstatic     #2 // Field java/lang/System.out:Ljava/io/PrintStream;
       3: iload_0
       4: iconst_m1
       5: ixor
       6: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
       9: return
}
Copy the code

In the bytecode above, subscript 3 pushes the parameter value, subscript 4 pushes the constant -1, and subscript 5 pushes the xOR operation on the top two elements of the stack. In binary, -1 is made up of a string of 1’s. The xor operation rules are that each bit of binary is a 1 if it is different, and 0 if it is the same.

00010100  (value)
 xor
11111111  (-1)
 =
11101011
Copy the code

As can be seen from the above results, after xOR operation of a number, many bits of binary become 1. After binary operation, a number has been greatly changed from the original.

If we use D8 to execute the.class file above, we will find that it is not much different from Dalvik bytecode.

$ java -jar d8.jar \
    --lib $ANDROID_HOME/platforms/android-28/android.jar \
    --release \
    --output . \
    *.class

$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex [000134] Not. Print :(I)V 0000: sget-object v0, Ljava/lang/System; .out:Ljava/io/PrintStream; 0002: xor-int/lit8 v1, v1,#int -10004: invoke-virtual {v0, v1}, Ljava/io/PrintStream; .println:(I)V 0007: return-voidCopy the code

Do the same xOR operation on our input parameters v1 and -1 at position 0002, and store the result in V1. This is a very simple Java operation, and if you don’t know better, you don’t have to think about it. But this article should tell you there’s more to it.

All Dalvik bytecodes are available on the Android development guide website. If you look closely, you can see that the unary operation contains a not-in bytecode. This is a more efficient way to replace the parameter and -1 bit operation.

The answer lies in the old version of the DX tool, which did not use the not-in directive.

$ $ANDROID_HOME/build-tools/28.0.3/dx \ --dex \ --output= class.dex \ *. Class [000130] Not. Print :(I)V 0000: sget-object v0, Ljava/lang/System; .out:Ljava/io/PrintStream; 0002: xor-int/lit8 v1, v2,#int -10004: invoke-virtual {v0, v1}, Ljava/io/PrintStream; .println:(I)V 0007: return-voidCopy the code

The older version of dx is in dalvik/dx/, and if we grep its code, we can find which constants use the not int directive.

$ grep -r -C 1 'not-int' src/com/android/dx/io
OpcodeInfo.java-522-    public static final Info NOT_INT =
OpcodeInfo.java:523:        new Info(Opcodes.NOT_INT, "not-int",
OpcodeInfo.java-524-            InstructionCodec.FORMAT_12X, IndexType.NONE);
Copy the code

So there is a not-in directive in the DX tool, which we filter out in our code, but it’s not there when it’s compiled into a class file. For comparison, I also include the if-eq directive when filtering.

$ grep -r -C 1 'NOT_INT' src/com/android/dx/cf

$ grep -r -C 1 'IF_EQ' src/com/android/dx/cf
code/RopperMachine.java-885-            case ByteOps.IFNULL: {
code/RopperMachine.java:886:                return RegOps.IF_EQ;
code/RopperMachine.java-887-            }
Copy the code

By comparison, no matter what Java bytecode is used, the DX tool does not use the not-in instruction. It’s unfortunate, but at the end of the day it’s no big deal.

The problem stems from the fact that because bytecode is never used by standard Dexing tools, some vendors don’t bother to support it in their DALvik-VM JIts! Jit-compiled applications running on those specific phones crash as soon as D8 comes along and starts using the full set of bytecode. Therefore, in this case, the NOT INT directive cannot be used to prevent crashes, even if D8 wishes to do so.

With the release of API 21 version of the ART VM environment, all handsets now support the NOT-IN directive, so adding — min-API 21 with D8 will make bytecode use the not-in directive.

$ java -jar d8.jar \
    --lib $ANDROID_HOME/platforms/android-28/android.jar \
    --release \
    --min-api 21 \
    --output . \
    *.class

$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex [000134] Not. Print :(I)V 0000: sget-object v0, Ljava/lang/System; .out:Ljava/io/PrintStream; 0002: not-int v1, v1 0003: invoke-virtual {v0, v1}, Ljava/io/PrintStream; .println:(I)V 0006: return-voidCopy the code

We see our expected not-in instruction at 0002.

Similar to Android’s compatibility with other language features, D8 can change the format of individual bytecodes to ensure compatibility. As the ecosystem and minimum API levels increase, D8 will automatically use more efficient bytecode.

2. Long Compare

Even if all bytecode instructions in use are supported, a particular vendor’s JIT can contain errors just like any other type of software. This happens in the code in OKHTTP and OKIO.

Both libraries have operations to move and count bytes. Their methods often start by checking for negative counts (which are invalid), followed by zero counts (there is no work to do).

class LongCompare {
  static void somethingWithBytes(long byteCount) {
    if (byteCount < 0) throw new IllegalArgumentException("byteCount < 0");
    if (byteCount == 0) return; // Nothing to do!
    / / Do something...}}Copy the code

We look at the compiled bytecode and see that 0 is loaded onto the stack, and we compare it twice.

$ javac *.java

$ java -jar d8.jar \
    --lib $ANDROID_HOME/platforms/android-28/android.jar \
    --release \
    --output . \
    *.class

$ $ANDROID_HOME/ build - the tools / 28.0.3 / dexdump - d classes. Dex [000138] LongCompare. SomethingWithBytes: (J) V 0000: const wide / 16 where v0,#int 00002: Cmp-long v2, V3, V0 0004: IF-LTZ v2, 000B 0006: Cmp-long v2, V3, V0 0008: IF-NEz v2, 000A...Copy the code

Combined with the bytecode above, cmp-long produces a number less than, equal to, or greater than 0. After each comparison, a less than zero check and a non-zero check are performed. But if a single CMP-long produces comparison results, why would index 0006 perform it again?

This is because some vendor-specific JIts will break if a non-zero check is performed immediately after a less-than zero check. This will cause the program to see impossible exceptions, such as NullPointerExceptions, when it only handles longs.

Using the above example, the introduction of ART virtual machines in API 21 solves this problem. Generate bytecode that performs only a single CMP-long operation by specifying — min API 21.

$ java -jar d8.jar \
    --lib $ANDROID_HOME/platforms/android-28/android.jar \
    --release \
    --min-api 21 \
    --output . \
    *.class

$ $ANDROID_HOME/ build - the tools / 28.0.3 / dexdump - d classes. Dex [000138] LongCompare. SomethingWithBytes: (J) V 0000: const wide / 16 where v0,#int 00002: Cmp-long V2, V2, V0 0004: IF-LTZ v2, 0009 0006: IF-NEZ v2, 0008...Copy the code

Usually D8 modifies the format of the optimized bytecode for compatibility. So bytecode becomes more efficient when your application no longer supports versions of Android implemented by flawed vendors. But while ART brings canonicalization to virtual machines across the ecosystem, eliminating (or at least reducing) these vendor-specific defects, it does not eliminate the defects themselves.

3. Recursion

Vendor-supplied ART has bugs that affect specific Android versions, and with the popularity of D8, some ART bugs are suddenly exposed.

The bug example shown below is no doubt well designed, but the code was extracted from a real application and distilled into a stand-alone example.

import java.util.List;

class Recursion {
 private void f(int x, double y, double u, double v, List<String> w) {
   f(x, y, u, v, w);
   f(x, y, u, v, w);
   f(x, y, u, v, w);
   f(x, y, u, v, w);
   f(x, y, u, v, w);
   f(x, y, u, v, w);
   f(x, y, u, v, w);
   f(x, y, u, v, w);
   f(x, y, u, v, w);
   w.add(g(y, u, v));
 }

 private String g(double y, double u, double v) {
   return null; }}Copy the code

Call analysis was added to ART’s AOT compiler on Android 6.0 (API 23) to perform inline methods. The above function f contains a large number of recursive method calls, so the Dex2OAT compiler consumes all memory on the device when it compiles, causing a crash. Fortunately, recursive calls to this situation were fixed in Android 7.0 (API 24).

On versions lower than API 24, D8 changes the dex file to cause this crash. So before we look at solutions, let’s recreate this crash.

$ javac *.java

$ java -jar d8.jar \
    --lib $ANDROID_HOME/platforms/android-28/android.jar \
    --release \
    --min-api 24 \
    --output . \
    *.class
Copy the code

We give D8 –min-api 24 to compile a dex file, and put the compiled dex file on a device with API 23, it will look at dex2OAT and refuse to compile the dex file.

$ adb shell push classes.dex /sdcard $ adb shell dex2oat --dex-file=/sdcard/classes.dex --oat-file=/sdcard/classes.oat $ The adb logcat... 11-29 13:57:08.303 4508 4508 I dex2Oat: Dex --oat-file=/sdcard/classes. Oat 11-29 13:57:08.306 4508 4508 W Failed to open .dex from file'/sdcard/classes.dex': Failed to open dex file '/sdcard/classes.dex' from memory: Unrecognized version number in/sdcard/classes. Dex :0 3 7 11-29 13:57:08.306 4508 4508 E dex2OAT: Failed to open some dex files: 1 11-29 13:57:08.309 4508 I Dex2OAT: Dex2Oat took 7.440ms (Threads: 4)Copy the code

In the DEX file format specification, the first 8 bytes of a dex file should be dex characters, followed by the version number on the next line, followed by a null byte. Since we specified –min-api 24, the version number of dex file is 037. Let’s check now.

$ xxd classes.dex | head -1 00000000: 6465 780a 3033 3700 e595 2d8c 49b5 d6b6 dex.037... -.I...Copy the code

In order to install on this older device, we had to specify the version number as 035. This is easy and can be changed through any hexadecimal editor. I used XXD for the conversion.

$ xxd -p classes.dex > classes.hex

$ nano classes.hex  # Change 303337 to 303335

$ xxd -p -r classes.hex > classes.dex
Copy the code

By changing the version number, the dex file can be compiled on Android 6.0 devices.

$ adb shell push classes.dex /sdcard

$ adb shell dex2oat --dex-file=/sdcard/classes.dex --oat-file=/sdcard/classes.oat
Segmentation fault
Copy the code

As shown above, we recreated the crash of ART. As we expected, the crash would not occur if we ran the dex file on an Android 7.0 device.

Let’s change the dex file name and remove –min-api 24 to specify recompile.

$ mv classes.dex classes_api24.dex

$ java -jar d8.jar \
    --lib $ANDROID_HOME/platforms/android-28/android.jar \
    --release \
    --output . \
    *.class
Copy the code

Look at the DEX bytecode to see the difference.

$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes_api24.dex [000190] Recursion :(IDDDLjava/util/List;) V 0000: invoke-direct/range {v7, v8, v9, v10, v11, v12, v13, v14, v15}, LRecursion; .f:(IDDDLjava/util/List;) V... 0018: invoke-direct/range {v7, v8, v9, v10, v11, v12, v13, v14, v15}, LRecursion; .f:(IDDDLjava/util/List;) V 001b: move-object v0, v7 001c: move-wide v1, v9 001d: move-wide v3, v11 001e: move-wide v5, v13 001f: invoke-direct/range {v0, v1, v2, v3, v4, v5, v6}, LRecursion; .g:(DDD)Ljava/lang/String; 0022: move-result-object v8 0023: invoke-interface {v15, v8}, Ljava/util/List; .add:(Ljava/lang/Object;) Z 0026: return-void catches : (none) $$ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex [000198] Recursion. F :(IDDDLjava/util/List) V 0000: invoke-direct/range {v7, v8, v9, v10, v11, v12, v13, v14, v15}, LRecursion; .f:(IDDDLjava/util/List;) V... 0018: invoke-direct/range {v7, v8, v9, v10, v11, v12, v13, v14, v15}, LRecursion; .f:(IDDDLjava/util/List;) V 001b: move-object v0, v7 001c: move-wide v1, v9 001d: move-wide v3, v11 001e: move-wide v5, v13 001f: invoke-direct/range {v0, v1, v2, v3, v4, v5, v6}, LRecursion; .g:(DDD)Ljava/lang/String; 0022: move-result-object v8 0023: invoke-interface {v15, v8}, Ljava/util/List; .add:(Ljava/lang/Object;) Z 0026: return-void 0027: move-exception v8 0028: throw v8 catches : 1 0x0018 - 0x001b Ljava/lang/Throwable; -> 0x0027Copy the code

By comparing the two compiled dex files, the dex file in question contains the additional bytecode move-exception and throw, as well as all the tode entries. The AOT compiler disables inline call analysis of methods by inserting the try-catch block, which ranges from 0x0018 to 0x001B. If we remove a recursive call to f from the source code, we will not cause this AOT compilation error because the volume is not large enough.

The same code would not crash on Android 6.0 if we compiled with the old DX compiler because the old DX compiler was inefficient and used registers to disable inline analysis.

4. To summarize

The three examples above are some vendor-specific errors in the Android virtual machine. As with the language feature elimination described in the previous article, D8 applies compatible solutions for these bugs only when necessary, based on your lowest API level. VM bugs occur not only in older versions, but also in newer versions. It’s important to remember that none of these problems are caused by D8. Compared to dx, D8 uses registers more efficiently and sorts bytecodes more efficiently. To further optimize dex, we must turn to D8’s optimization sibling, R8, which we will begin to explore in the next article.