Introduction to the

On Android 11, the Android Runtime (ART) introduced a JVMTI API extension called Structural Class Redefinition (Structural Redefinition of Classes). This article introduces the structural redefinition capabilities of classes and the problems we encountered in implementing them, including our thoughts, trade-offs, and solutions. Structural redefinition of a class is a runtime feature that extends the redefinition of a class method introduced in Android 8. Apply Changes in Android Studio allows you to change the structure of a class itself and add variables or methods to it.

This can be used for many powerful functions, such as extending Apply Changes to support adding new resources to the application. You can check out the documentation to see how Android Studio’s ‘Apply Changes’ feature works and how it can be extended with structural redefinitions of classes in a future blog post. In the future Android Studio will add more comprehensive and powerful tools to accommodate these new features.

JVMTI is a standard API through which development tools can interact and control the runtime environment underneath. This feature is used to implement many familiar development tools, from Network and Memory profilers in Android Studio to simulation frameworks in debuggers such as Dexmaker-mockito-inline, MockK, To the Layout and Database inspector. You can find out more about the implementation of Android JVMTI and how to apply it to your own tools in the Android documentation.

Structural redefinition

The structural redefinition of classes builds on the redefinition classes added in Android Oreo (8.0). In Oreo, only methods that are already in a class can be modified. The object layout and set of fields and methods defined in the class cannot be modified in any way.

Structural redefinition of a class provides greater freedom to modify classes, making it possible to add full fields and methods to existing classes without any restrictions on the types of fields and methods that can be added. The newly added fields have an initial value of 0 or NULL, but the JVMTI agent can initialize them using other methods provided by JVMTI if needed. As with standard class redefinition, the currently executed method will continue with the previous definition, and subsequent calls will use the new definition. To ensure that the structure class redefinition has clear and consistent semantics, the following changes cannot be performed:

  • Fields and methods are deleted or their properties are modified
  • The class name has been modified
  • The class’s inheritance relationship (the parent class and the interface implemented) has been modified

With the support of Android Studio, structural redefinitions of classes can be used to implement the Apply Changes functionality for most editing scenarios. The rest of this article describes how we implemented this functionality and the considerations and trade-offs required to implement this new runtime functionality.

Above all, performance is harmless

The main challenge in implementing structural redefinitions is not to let the application suffer in release mode. For every developer, while their code is running in debug mode and using tools like Apply Changes or the debugger, there may be millions of users on the other side running the application on their phones. Therefore, it is a first rule of thumb that any new developer-specific features added to ART should not affect runtime performance while the application is in non-debug mode. This meant that we could not make significant changes to the runtime internal core functionality. For example, we cannot change the basic layout of objects, memory requisiting, garbage collection mechanism, loading and wiring of classes, and execution of dex bytecode.

All objects, including java.lang.Class objects (static fields that hold their own types in ART), have their size and layout determined after loading. Such features allow programs to run efficiently, as shown in the Parrot class above, where any Parrot object has a piningFor field stored at an offset of 0x8. This means that ART can generate efficient code, but at the same time, we can’t change the layout of the object after it has been created, because adding new fields not only changes the layout of the current class, but also affects all of its subclasses. To achieve this functionality, we need to replace the original objects and instances with the corresponding classes that have been redefined without feeling and maintaining atomicity.

We need to get deep inside the runtime to achieve structural class redefinition without affecting performance. Fundamentally, there are four key steps to structurally redefining a class:

  1. Create java.lang.Class objects for each modified type using the new Class definition;

  2. Recreate all objects of the original type with the newly defined type;

  3. Replace/update all existing objects with their corresponding new objects;

  4. Ensure that all compiled code and runtime state are correct relative to the new type layout.

The pursuit of performance

Like many programs, ART itself is multithreaded, both because of the multithreaded nature of the DEX bytecode it is running on (potentially) and to avoid pauses while the program is running. At any given moment, ART may perform many operations synchronously, such as executing Java language code, performing garbage collection, loading classes, allocating objects, performing finalizers, or other things.

This means that simply performing the redefinition is clearly competitive. For example: what if a new instance is created after we recreate all the old objects? Therefore, we must be very careful with each step to ensure that we do not encounter or create inconsistent states. We need to make sure that each thread is aware of the atomic transformation process shown above, and that all operations are done synchronously.

The immediate solution to this is to stop everything when we start redefining. We then perform the redefinition as described above (creating new classes and objects, and then replacing the old ones). The advantage of this is that we can get the atomicity we need without making any real investment. When an inconsistency is found, all code is paused, so the inconsistent state is not revealed. Unfortunately, there are several problems with this approach.

First, it slows down processing dramatically. You may need to recreate a large number of objects and reload a large number of classes (for example, if you need to edit the java.util.arrayList class, you may have thousands of instances associated with it). A more serious problem is that it is not possible to allocate objects when all threads are stopped, in order to prevent deadlocks, such as waiting for a paused GC line to complete the collection before allocating memory. This limitation runs deep into the design of ART and its GC. Simply removing this restriction to modify it is not feasible, especially for a feature that is only used in debugging. Since the primary operation of a structured redefinition is to reassign all redefined objects, removing the restriction is clearly unacceptable.

So what do we do now? As far as Java code is concerned, we still need to make sure that any changes are made immediately, but we can’t stop everything. Here we can take advantage of features of the Java language where threads cannot directly access the heap and key class loading states, and important GC management threads never allocate or load classes. This means that the only step where we pause the other operations while running is the replacement process. We can allocate all the classes and new objects while the rest of the code is still running, because these threads don’t have any references to new objects, and the code is still the original code, so no inconsistent state is exposed.

If you are interested in the implementation, you can visit the related links. This article explores how Android and AOSP were created.

As we allow the application code to continue running, it is important to note that the overall state will not change as a result of our actions. To do this, we had to close each part of the run time carefully, in sequence, to ensure that we could gather all the information we needed and that it would not become invalid during the run. For our purposes, we need a complete list of all the ¹ redefined classes and their subclasses of java.lang.Class objects, and a list of the corresponding Class objects for the redefined classes. You need a complete list of all instances of the class and a complete list of all redefined objects.

Since new classes are rarely loaded (and we need new Class objects to allocate redefined instances), we can start by collecting the list of redefined classes and creating new Class objects for redefined types. To ensure that the list is complete and valid, we need to stop class loading ² completely before creating the list. To do this, we need to stop loading new classes from the beginning while waiting for the ongoing class definition to complete. Once that’s done, we can safely collect and recreate all the Class objects for the redefined classes.

At this point, we have collected all the required classes, which will be used to recreate the instances that need to be replaced. Similar to processing classes, we need to pause allocating objects and wait for all threads to confirm that our list of objects is up to date. Here, similar to working with classes, we collect all the old instances and create a new version for each.

Now that we have all the new objects, all we have to do is copy the field values from the old object and actually replace them into the new object. Because once we start providing new objects to threads or object references, they are no longer invisible, and threads can change any field at run time, we need to stop all threads before performing these last few steps. As long as all other threads have stopped, we can copy field values from the old object to the new object.

Once that’s done, we can walk through the heap and replace all the old instances with the new ones that have been redefined. All that remains now is miscellaneous work to ensure that related items, such as reflection objects, various runtime parsing caches, and so on, are updated or cleared as needed. We also make sure that we track enough data to allow all running code to continue running when the redefinition begins.

conclusion

With the ability to structure redefinition, many new and more powerful debugging and development tools have emerged. We’ve already looked at the improvements to Apply Changes, and many teams in the Android space are working on developing other powerful tools based on this feature. These are just a few of the many improvements and new features we add with each Android release. Welcome to read our recent article on how we can improve the startup time of an Android 11 application using IO Prefetching.

[1] Before we do this, we perform some checks to make sure that all classes are redefined and that the new definitions are valid, but these checks are tedious.

[2] It is technically safe to continue loading unrelated classes, but because of the way loading classes work, there is no way to distinguish these cases early enough to achieve the desired effect.

[3] Also, the interaction of allocation objects with the cross-thread synchronization mechanism of the ART VIRTUAL machine has many details that prevent us from simply suspending the assignment of redefining class instances.