先做一个小试验: 在apk的activity中放一个Button和一个TextView,点击Button让结果显示在TextView上。

apk的代码如下:

public class MainActivity extends AppCompatActivity {

    Button button;
    TextView textView;    @Override
    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        textView = findViewById(R.id.text);
        button = findViewById(R.id.button);
        button.setOnClickListener(new View.OnClickListener() {            @Override
            public void onClick(View v) {

                Test test = new Test();
                String s = test.getValue();
                textView.setText(s);

            }
        });
    }
}

其中Test类的代码如下:

public class Test {    
    public String getValue() {        
        return "this is method getValued";
    }
}

试着思考下,文本框显示的结果会是什么?

第1次结果:

如果运行正常,结果会如下(本次测试全部在Android AOSP N上执行):

this is method getValued

进一步试验

接下来,再进一步试验。 我们给apk的PathClassLoader的ClassPath最前面注入一个dex,这个dex仅包含一个class,和之前的Test的包名+类名一致,如下:

public class Test {    public String getValue(){        return "this is method getValue from dex";
    }    public String abc(){        return "this is method abc !!!";
    }
}

这是最简单的热修复原理,猜想一下,这次的结果是什么?

第2次结果

这次的结果会是什么呢?

实际上,在debug版本上,我们能够得到正确的结果:

而在release版本上,结果并不是我们想象的这样,结果如下:

深入Android Runtime: 指令优化与Java方法调用-唯嘉利亚云安全

现象解释深入Android Runtime: 指令优化与Java方法调用-唯嘉利亚云安全

为什么会出现这样的现象:明明调用的是getValue方法,为什么返回的是abc方法的结果呢? 要解释这个现象,我们需要对Android虚拟机执行代码的原理有一定的了解。

当我们将Java代码编译成apk时,编译器会用javac将java文件转成class文件,再通过dx将class文件转成dex文件(如果是jack&jill编译器,不会有class生成的过程)。 apk安装时候,PMS会通过installd唤起dex2oat进程对apk进行优化。 当我们启动系统时候,虚拟机先加载BootClassLoader,再加载SystemClassLoader,分别将BOOTCLASSPATH和SYSTEMSERVERCLASSPATH中对应jar包中的class加载起来,。

深入Android Runtime: 指令优化与Java方法调用-唯嘉利亚云安全

apk启动时,将会创建一个PathClassL深入Android Runtime: 指令优化与Java方法调用-唯嘉利亚云安全oader,将apk相关及其依赖的library中的class加载到内存。 如果我们往PathClassLoader的clssapath中最开始注入新的jar/dex,在运行时PathClassLoader就会优先加载前面的jar/dex,从而覆盖apk本身的类实现类的替换。

但是我们通常不会注意到虚拟机的机制。

在安装apk时,如果apk是debug版本,会被强制以解释方式执行,此时执行的是字节码,我们看到的字节码是这样的:

即invoke-virtual+methodID的方式执行。这个methodID是存储在apk自身的dex中的,每个dex中都有一个String表和Method表(当然还有Class表等其他表)。 通过String表,可以查到某个index对应的String是什么;通过method表,可以拿到methodID对应的StringID,然后再到String表中查到方法名称。 虚拟机通过方法名称,再从已加载cache中查找方法,如果方法没找到,就从classpath加载并resolve,最终找到对应的method。深入Android Runtime: 指令优化与Java方法调用-唯嘉利亚云安全

那么正常debug版本解释执行时,这个过程是没有任何问题的,包括使用新的类覆盖了旧的类的时候,仍然可以通过自身编译时就决定的methodID拿到正确的方法名,也就可以获取到正确的method并执行。

但是release版本的时候,dex会被优化的。dex2oat根据系统prop中的配置决定进行何种程度的优化,在AOSP N上,默认配置如下:深入Android Runtime: 指令优化与Java方法调用-唯嘉利亚云安全

interpret-only模式的优化,实际上只是dalvik指令级的优化,并不会生成机器码(其他speed之类的优化模式会产生部分机器码,everything模式是完全编译,将所有字节码均优化成机器码),而是会对invoke-virtual这样的指令进行quicken优化,变成invoke-virtual-quick。 优化的目的,是将methodID的查找变成vtable的查找。methodID是dex全局的查找,相比vtable在class内部的查找,效率要高很多,毕竟一个dex中很可能有几万个method,而一个class中的method通常只有几个到几十个。

interpret-only的优化,是基于一个前提,编译时不仅能获取到class的名称,还能获取到class的定义。 因为我们是动态加载了dex,这个dex只有在classloader加载dex时才会被发现,dex2oat编译时只知道apk自身中的class的存在。

dex2oat进行interpret-only优化时,编译依赖是原先的method,导致生成的vtable索引为原先Test类中的方法索引。但是运行的时候,新的Test类由于加上了一个abc的方法,android中的各种String表、method表、vtable等都是按照字母表顺序进行排序,导致abc方法排在Test方法之前,这样原先的vtable索引查到的method就变成了abc方法。

由于vtable索引的变化,就出现了明明是深入Android Runtime: 指令优化与Java方法调用-唯嘉利亚云安全调用的Test方法,可结果跑的是abc方法的奇特现象。

如果我们进行verify-none模式的编译(不进行quicken优化,或者其他能编译成机器码的模式),让其以解释模式运行,就不会有问题。但是如果apk在Manifest中设置了android:vmSafeMode=”true” ,那么无论是否使用了其他模式进行强制编译,apk会始终以interpret-only方式编译,导致问题一直存在。 比如我们使用speed编译,日志中依然是interpret-only:

深入Android Runtime: 指令优化与Java方法调用-唯嘉利亚云安全深入Android Runtime: 指令优化与Java方法调用-唯嘉利亚云安全

总结

在进行apk热修复、插件化、动态加载的时候,会经常多个jar/dex包含相同的class,如果class结构因为需要升级出现了变化,会隐藏一些很难解释的坑在里面,务必谨慎。