How Java 8 is supported on Android

Hello, Habr! I bring to your attention a translation of a wonderful article from a series of articles by the notorious Jake Worton about how Android 8 is supported by Java.



The original article is here

I worked from home for several years, and I often heard my colleagues complain about Android supporting different versions of Java.

This is a rather complicated topic. First you need to decide what we mean by “Java support in Android”, because in one version of the language there can be a lot of things: features (lambdas, for example), bytecode, tools, APIs, JVM and so on.

When they talk about Java 8 support in Android, they usually mean support for language features. So, let's start with them.

Lambdas


One of the main innovations of Java 8 was lambdas.
The code has become more concise and simple, lambdas have saved us from writing cumbersome anonymous classes using an interface with a single method inside.

class Java8 { interface Logger { void log(String s); } public static void main(String... args) { sayHi(s -> System.out.println(s)); } private static void sayHi(Logger logger) { logger.log("Hello!"); } } 

After compiling this using javac and legacy dx tool , we get the following error:

 $ javac *.java $ ls Java8.java Java8.class Java8$Logger.class $ $ANDROID_HOME/build-tools/28.0.2/dx --dex --output . *.class Uncaught translation error: com.android.dx.cf.code.SimException: ERROR in Java8.main:([Ljava/lang/String;)V: invalid opcode ba - invokedynamic requires --min-sdk-version >= 26 (currently 13) 1 error; aborting 

This error occurs due to the fact that lambdas use the new instruction in the bytecode - invokedynamic , which was added in Java 7. From the error text, you can see that Android only supports it starting with the 26 API (Android 8).

It doesn’t sound very good, because hardly anyone will release an application with 26 minApi. To get around this, the so-called desugaring process is used , which makes lambda support possible on all versions of the API.

History of Desaccharization


She's pretty colorful in the Android world. The goal of desaccharization is always the same - to allow new language features to work on all devices.

Initially, for example, to support lambdas in Android, developers connected the Retrolambda plugin. He used the same built-in mechanism as the JVM, converting lambdas to classes, but he did it in runtime, and not at compile time. The generated classes were very expensive in terms of the number of methods, but over time, after refinements and improvements, this indicator dropped to something more or less reasonable.

Then, the Android team announced a new compiler that supported all Java 8 features and was more productive. It was built on top of the Eclipse Java compiler, but instead of generating a Java bytecode, it generated a Dalvik bytecode. However, its performance is still poor.

When the new compiler (fortunately) was abandoned, the Java bytecode transformer in the Java bytecode, which did the juggling, was integrated into the Android Gradle Plugin from Bazel , Google’s build system. And its performance was still low, so in parallel the search continued for a better solution.

And now we were dexer - D8 , which was supposed to replace the dx tool . Desaccharization was now performed during the conversion of compiled JAR files to .dex (dexing). The D8 is much better in performance compared to dx , and since Android Gradle Plugin 3.1 it has become the default dexer.

D8


Now, using D8, we can compile the code above.

 $ java -jar d8.jar \ --lib $ANDROID_HOME/platforms/android-28/android.jar \ --release \ --output . \ *.class $ ls Java8.java Java8.class Java8$Logger.class classes.dex 

To see how the D8 converted lambda, you can use the dexdump tool , which is included in the Android SDK. It will display quite a lot of everything, but we will focus only on this:

 $ $ANDROID_HOME/build-tools/28.0.2/dexdump -d classes.dex [0002d8] Java8.main:([Ljava/lang/String;)V 0000: sget-object v0, LJava8$1;.INSTANCE:LJava8$1; 0002: invoke-static {v0}, LJava8;.sayHi:(LJava8$Logger;)V 0005: return-void [0002a8] Java8.sayHi:(LJava8$Logger;)V 0000: const-string v0, "Hello" 0002: invoke-interface {v1, v0}, LJava8$Logger;.log:(Ljava/lang/String;)V 0005: return-void … 

If you haven’t read the bytecode yet, don’t worry: a lot of what is written here can be understood intuitively.

In the first block, our main method with index 0000 gets a reference from the INSTANCE field to the INSTANCE class Java8$1 . This class was generated during . The main method bytecode also does not mention the body of our lambda anywhere, so most likely it is associated with the Java8$1 class. Index 0002 then calls the sayHi static method using the link to INSTANCE . The sayHi requires Java8$Logger , so it seems Java8$1 implements this interface. We can verify this here:

 Class #2 - Class descriptor : 'LJava8$1;' Access flags : 0x1011 (PUBLIC FINAL SYNTHETIC) Superclass : 'Ljava/lang/Object;' Interfaces - #0 : 'LJava8$Logger;' 

The SYNTHETIC flag means that the Java8$1 class Java8$1 been generated and the list of interfaces that it includes contains the Java8$Logger .
This class represents our lambda. If you look at the implementation of the log method, you will not see the body of the lambda.

 … [00026c] Java8$1.log:(Ljava/lang/String;)V 0000: invoke-static {v1}, LJava8;.lambda$main$0:(Ljava/lang/String;)V 0003: return-void … 

Instead, the static method of the Java8 class is Java8 - lambda$main$0 . Again, this method is presented only in bytecode.

 … #1 : (in LJava8;) name : 'lambda$main$0' type : '(Ljava/lang/String;)V' access : 0x1008 (STATIC SYNTHETIC) [0002a0] Java8.lambda$main$0:(Ljava/lang/String;)V 0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream; 0002: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V 0005: return-void 

The SYNTHETIC flag again tells us that this method was generated, and its bytecode just contains the lambda body: a call to System.out.println . The reason the lambda body is inside Java8.class is simple - it may need to access private members of the class, which the generated class will not have access to.

Everything you need to understand how desaccharization works is described above. However, looking at it in the Dalvik bytecode, you can see that everything is much more complicated and frightening there.

Source Transformation


To better understand how desaccharization occurs, let's try step by step to convert our class into something that will work on all versions of the API.

Take the same class with lambda as a basis:

 class Java8 { interface Logger { void log(String s); } public static void main(String... args) { sayHi(s -> System.out.println(s)); } private static void sayHi(Logger logger) { logger.log("Hello!"); } } 

First, the lambda body is moved to the package private method.

  public static void main(String... args) { - sayHi(s -> System.out.println(s)); + sayHi(s -> lambda$main$0(s)); } + + static void lambda$main$0(String s) { + System.out.println(s); + } 

Then a class is implemented that implements the Logger interface, inside which a block of code from the lambda body is executed.

  public static void main(String... args) { - sayHi(s -> lambda$main$0(s)); + sayHi(new Java8$1()); } @@ } + +class Java8$1 implements Java8.Logger { + @Override public void log(String s) { + Java8.lambda$main$0(s); + } +} 

Next, a singleton instance of Java8$1 , which is stored in the static variable INSTANCE .

  public static void main(String... args) { - sayHi(new Java8$1()); + sayHi(Java8$1.INSTANCE); } @@ class Java8$1 implements Java8.Logger { + static final Java8$1 INSTANCE = new Java8$1(); + @Override public void log(String s) { 

Here is the final dubbed class that can be used on all versions of the API:

 class Java8 { interface Logger { void log(String s); } public static void main(String... args) { sayHi(Java8$1.INSTANCE); } static void lambda$main$0(String s) { System.out.println(s); } private static void sayHi(Logger logger) { logger.log("Hello!"); } } class Java8$1 implements Java8.Logger { static final Java8$1 INSTANCE = new Java8$1(); @Override public void log(String s) { Java8.lambda$main$0(s); } } 

If you look at the generated class in the Dalvik bytecode, you will not find names like Java8 $ 1 - there will be something like -$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY . The reason why such naming is generated for the class, and what are its advantages, draws to a separate article.

Native lambda support


When we used the dx tool to compile a class containing lambdas, an error message said that this would only work with 26 APIs.

 $ $ANDROID_HOME/build-tools/28.0.2/dx --dex --output . *.class Uncaught translation error: com.android.dx.cf.code.SimException: ERROR in Java8.main:([Ljava/lang/String;)V: invalid opcode ba - invokedynamic requires --min-sdk-version >= 26 (currently 13) 1 error; aborting 

Therefore, it seems logical that if we try to compile this with the —min-api 26 flag, then desaccharization will not occur.

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

However, if we dump the .dex file, then it can still be found in it -$$Lambda$Java8$QkyWJ8jlAksLjYziID4cZLvHwoY . Why is that? Is this a D8 bug?

To answer this question, and also why desaccharization always occurs , we need to look inside the Java bytecode of the Java8 class.

 $ javap -v Java8.class class Java8 { public static void main(java.lang.String...); Code: 0: invokedynamic #2, 0 // InvokeDynamic #0:log:()LJava8$Logger; 5: invokestatic #3 // Method sayHi:(LJava8$Logger;)V 8: return } … 

Inside the main method, we again see invokedynamic at index 0 . The second argument in the call is 0 - the index of the bootstrap method associated with it.

Here is a list of bootstrap methods:

 … BootstrapMethods: 0: #27 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:( Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String; Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType; Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;) Ljava/lang/invoke/CallSite; Method arguments: #28 (Ljava/lang/String;)V #29 invokestatic Java8.lambda$main$0:(Ljava/lang/String;)V #28 (Ljava/lang/String;)V 

Here the bootstrap method is called metafactory in the java.lang.invoke.LambdaMetafactory class. He lives in the JDK and creates anonymous on-the-fly classes in runtime for lambdas, just like D8 generates them in compute time.

If you look at the Android java.lang.invoke
or to the AOSP java.lang.invoke , we see that this class is not in the runtime. That's why de-juggling always happens at compile time, no matter what minApi you have. The VM supports bytecode instructions similar to invokedynamic , but the invokedynamic built-in to the JDK LambdaMetafactory not available for use.

Method references


Along with lambdas, Java 8 added method references - this is an effective way to create a lambda whose body references an existing method.

Our Logger interface is just such an example. The lambda body referred to System.out.println . Let's turn the lambda into a reference method:

  public static void main(String... args) { - sayHi(s -> System.out.println(s)); + sayHi(System.out::println); } 

When we compile it and take a look at the bytecode, we will see one difference with the previous version:

 [000268] -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM.log:(Ljava/lang/String;)V 0000: iget-object v0, v1, L-$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM;.f$0:Ljava/io/PrintStream; 0002: invoke-virtual {v0, v2}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V 0005: return-void 

Instead of calling the generated Java8.lambda$main$0 , which contains a call to System.out.println , now System.out.println is called directly.

A class with a lambda is no longer a static singleton, but by the 0000 index in the bytecode, we see that we get a link to PrintStream - System.out , which is then used to call println on it.

As a result, our class turned into this:

  public static void main(String... args) { - sayHi(System.out::println); + sayHi(new -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM(System.out)); } @@ } + +class -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM implements Java8.Logger { + private final PrintStream ps; + + -$$Lambda$1Osqr2Z9OSwjseX_0FMQJcCG_uM(PrintStream ps) { + this.ps = ps; + } + + @Override public void log(String s) { + ps.println(s); + } +} 

Default and static methods in interfaces


Another important and major change that Java 8 brought was the ability to declare default and static methods in interfaces.

 interface Logger { void log(String s); default void log(String tag, String s) { log(tag + ": " + s); } static Logger systemOut() { return System.out::println; } } 

All this is also supported by D8. Using the same tools as before, it is easy to see a logged-in version of Logger with default and static methods. One of the differences with lambdas and method references is that the default and static methods are implemented in the Android VM and, starting with the 24 API, D8 will not decouple them.

Maybe just use Kotlin?


Reading the article, most of you probably thought about Kotlin. Yes, it supports all Java 8 features, but they are implemented by kotlinc in the same way as D8, with the exception of some details.

Therefore, Android support for new versions of Java is still very important, even if your project is 100% written in Kotlin.

It is possible that in the future Kotlin will cease to support Java 6 and Java 7 bytecode. IntelliJ IDEA , Gradle 5.0 switched to Java 8. The number of platforms running on older JVMs is decreasing.

Desugaring APIs


All this time I talked about Java 8 features, but did not say anything about the new APIs - streams, CompletableFuture , date / time and so on.

Returning to the Logger example, we can use the new date / time API to find out when messages were sent.

 import java.time.*; class Java8 { interface Logger { void log(LocalDateTime time, String s); } public static void main(String... args) { sayHi((time, s) -> System.out.println(time + " " + s)); } private static void sayHi(Logger logger) { logger.log(LocalDateTime.now(), "Hello!"); } } 

Compile it again with javac and convert it to the Dalvik bytecode with D8, which decouples it for support on all versions of the API.

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

You can even run this on your device to make sure that it works.

 $ adb push classes.dex /sdcard classes.dex: 1 file pushed. 0.5 MB/s (1620 bytes in 0.003s) $ adb shell dalvikvm -cp /sdcard/classes.dex Java8 2018-11-19T21:38:23.761 Hello 

If API 26 and above is on this device, the Hello message will appear. If not, we will see the following:

 java.lang.NoClassDefFoundError: Failed resolution of: Ljava/time/LocalDateTime; at Java8.sayHi(Java8.java:13) at Java8.main(Java8.java:9) 

D8 dealt with lambdas, the reference method, but did nothing to work with LocalDateTime , and this is very sad.

Developers have to use their own implementations or wrappers on date / time api, or use libraries like ThreeTenBP to work with time, but why can't you do D8 with your hands?

Epilogue


Lack of support for all the new Java 8 APIs remains a big problem in the Android ecosystem. Indeed, it is unlikely that each of us can allow us to specify the 26 min API in our project. Libraries supporting both Android and JVM cannot afford to use the API introduced to us 5 years ago!

And even though Java 8 support is now part of D8, every developer should still explicitly specify source and target compatibility in Java 8. If you write your own libraries, you can strengthen this trend by laying out libraries that use Java 8 bytecode (even if you are not using new language features).

A lot of work is being done on D8, so it seems that everything will be ok in the future with support for language features. Even if you write only on Kotlin, it is very important to force the Android development team to support all new versions of Java, improve bytecode and new APIs.

This post is a written version of my talk Digging into D8 and R8 .

Source: https://habr.com/ru/post/478692/


All Articles