9

了解一下,Android 10中的ART虚拟机(3)

 3 years ago
source link: https://blog.csdn.net/innost/article/details/104744436
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

了解一下,Android 10中的ART虚拟机(3)

阿拉神农 2020-03-08 17:15:57 5713

最近一直在家办公。在家办公显然是比办公室办公要累很多的。几乎没有扯淡、溜达、扯皮,上个卫生间,打水所耗费的时间。我推测大部分人肯定是不太适应这种节奏。我个人还好,因为写书的时候,一天可以干16个小时。唯一区别是写书情况下,这种状态也就周末最多两天。现在家里办公每天都要搞成这样的话,谁也受不了。format,png

这一个月,我都在系统的学习JVM的知识。除了周志明老师的书之外,我看了大概两本书《Programming for the Java Virtual Machine》和《Advanced Design and Implementation of Virtual Machines》。第一本书主要是讲Java字节码执行方面的东西。看完后呢,对字节码以及它的执行应该不再陌生(kongju)。JVM规范里说,JVM是以一种以栈为存储结构的执行器。运算过程中的数据都存在栈上。这本书难度不大,中下。可以作为科普材料看看。

第二本书则难度要大得多,越到后面越难。我到现在也没全部看完,一半多。这本书难度大的原因是它是一个系统性的回顾JVM设计里的各种细节以及相关的理论知识。显然,你要是没看过JVM的实现,是看不懂这个书的。

我之前写ART一书的时候看过这本,但是没有从头到尾的看。现在从头到尾看的话,感受完全不同。总结而言,Android源码的学习有两种途径:

  • 理论为主,辅助以代码分析:比如我14年那本《Wi-Fi、NFC和GPS》,没有理论为支撑,代码是绝对不能看懂的。

  • 直接分析源码。比如深入理解卷1、卷2。因为说实话没有什么理论,直接上源码分析就行了,道理就在源码里。

我在ART学习中采用的是第二种直接分析源码这种比较暴力的方式。但正如上篇公众号里了解一下,Android 10中的ART虚拟机(2)说的那样。实际上ART所代表的JVM是有很强理论知识背景的。所以对待ART,不能像卷1、卷2那样。

另外,第二本书给我的另外一个启示是,我应该跳出具体的代码实现来理解ART的设计。所以,我最近画了一些图,以ART 10的代码来重温ART。这些图主要是一些数据结构的关系。为什么要这么做呢。其实程序说白了就是数据结构+处理逻辑。而ART为了开发的方便,用C++做了太多的封装,看起来特别痛苦。而我画的图,将剥开这些封装,直面数据。今天先讲一部分内容。

HandleScope相关

注意,阅读这部分内容的时候需要对ART代码有一定了解。源码中经常看到这样下面这样的代码:

format,png

其中,StackHandleScope,Handle是什么?如果看代码的话,会烦死。ART封装太多了。所以,我们就看它到底包含了什么数据(不考虑行为)

format,png

以上就是HandleScope家族的数据结构。

  • BaseHandleScope和HandleScope包含两个成员。link_和umber_of_reference_

  • FixedSizeHandleScope:包含storage_数组。这个数组的元素指向一个StackReference。它到底是什么?我们后面会讲

  • StackHandleScope:多了一个self_,指向当前的线程对象。

ART代码中大量出现获取数据结构中某个成员的操作。尤其是汇编代码里,经常要从数据结构里拿某个数据。它就是通过该成员对应的偏移量(offset)来获取的。比如,上图右边的两个Offset,就是来取number_of_references_以及stroage_的。

解决HandleScope后,再来看Handle和StackReference。

format,png

这个图里:

  • 右上角的mirror::Object对象代表一个new出来的Object对象。new出来的是一个指向这个对象的指针。

  • ART中不允许直接保存这指针,所以弄了一个ObjectPtr数据结构,这个数据结构里reference_(uintptr_t类型,可以存下一个指针,不管是64位还是32位)。

  • ART也不直接使用ObjectPtr,而是使用ObjectReference(包含CompressedReference、StackReference)。这三个Reference结构只有一个reference_(uint32_t类型,32位长,特别注意)。

  • ObjectReference中reference_是由ObjectPtr的reference_强制数据类型转换而来。在64位机器上,指针也是64位,而ObjectReference只有32位长,岂不是会丢失信息?确实有这个问题,但是没关系。因为丢失的那32位数据都是0。这里涉及到JVM在64位设备上设计的考虑。64位设备上,分配的对象的地址是64位长,但如果我要保存这些对象地址的话,所需的一个变量也需要64位长。假如我们分配了1万个对象,光存储这1万个对象的地址就需要64万/8个字节。这比32位系统多了1倍的存储空间。所以,JVM在64位设备上做了一些优化。实际上还是使用32位数据来存储这个64位指针。而这个64位指针的高(或低)32位为0(或者为其他什么别的固定值)。另外,为了兼容32和64位,所分配对象的大小必须是8字节的整数倍。所以,基于这种设计的JVM不能支持32GB(8*(2<<32))以上的内存空间。

  • 最下边的是Handle家族。它的reference_指向一个StackReference指针。

现在,我们可以解释代码的执行结果了。

format,png

上图中左上角的代码执行后得到这样的结果:

  1. heap->AllocNonMovableXX得到一个Alloced对象,我们叫它原始对象。

  2. ART不能直接操作这个原始对象(下文会解释)。所以设计了一个Handle对象来操作。

  3. 这个Handle对象也颇有来头,它代表HandleScope对象(图中,该对象变量名为hs)里storage_数组里的某个元素。另外,通过Handle,我们可以操作这个原始对象。注意,不能直接操作这个原始对象,只能通过Handle和它的家族。

为毛搞这么复杂呢?明明在第一步已经拿到了原始对象,要对它做什么直接处理就好了,为何要借助Handle,还搞一个HandleScope?这是因为JVM在遍历所谓的Root对象时需要。看下图:

format,png

ART中,一个线程对象有一个top_handle_scope链表。这个链表的元素就是HandleScope。而刚才说了,原始对象的地址其实是存在HandleScope里storage_数组里 的。这样,我们在VisitRoots的时候就能找到JVM创建的对象了。

所以,总结一下,ART在这块的设计:

  • 原始对象分配出来后,保存在HandleScope里。而这个HandleScope又被链接到线程里的一个链表中。

  • ART不允许直接操作原始对象,所以封装了一个Handle,通过Handle来操作原始对象。

以上相关的代码至少有几千行。而只要了解上面四个只关注数据的图,相关数据结构就非常清楚了。

x86/x86_64调用约定相关
我是在抽象ART调用栈设计的时候顺手对x86/x86_64调用栈按上面的思路进行了整理。先说x86的调用约定(calling convention):

format,png

来解释这图,先看左上角。调用约定包含两个部分。一个是调用者规则,一个是被调用者的规则。而被调用者的规则又分为入口规则(Prologue)和出口规则(Epilogue)。所谓的约定,规则,其实就是套路。大家商量好的事情,有时候没有什么道理可讲。

Caller Rule:调用者规则,说明x86上,函数调用一定是这么写的(或者说,编译器一定会生成这样的代码)。

  1. 首先是保存调用者保管的寄存器(EAX/ECX/EDX,Caller Saved Register)。注意,如果调用者用了这节寄存器才需要保存。

  2. 然后是将参数push到栈上。最后一个参数先push,第一个参数最后push。

  3. 接着,通过call调用目标函数。目标函数的返回值一定是存储在EAX里。调用完后,

  4. 调用者pop对应的寄存器。右边的绿色箭头指向的是栈的样子。

Callee Rule:被调用者规则。首先是入口规则,它包括:

  1. 通过栈来保存EBP,并将ESP的值保存到EBP。

  2. 分配局部变量所需的栈空间。这说明一个函数所需栈空间在编译期就是能确定的。

  3. 保存callee saved寄存器,比如EBX/EDI/ESI。

目标函数返回前,将执行出口规则,它包括:

  1. pop EBX/ED/ESI

  2. 释放局部变量所需空间

  3. 还原EBP

Callee Rule对应的栈结构就在最右边的栈图里。x86这种调用约定的设计下,我们会看到如下场景:

  • 第一个参数一定在[EBP+8]中,其他类推

  • 第一个局部变量一定在[EBP-4]中,其他类推

所以,这就是套路。现在,大家可以去看code_generator_x86.cc的GenerateFrameEntry/GenerateFrameExit的代码,看看是不是上面的Callee规则的思想在发挥作用?

接着,看下x86_64的调用约定:

format,png

x86_64位调用约定有很大不同,对Caller规则来说:

  • Caller保管的寄存器是R10、R11以及下面六个参数传递寄存器

  • 前6个参数通过寄存器传递,第7个参数才用栈传递。而32位上全是栈传递。另外,第一个参数必须使用寄存器RDI,第二个参数必须是RSI,然后是RCX、R8、R9。

  • 返回值在RAX中。

而Callee规定则是:

  • Callee保管的寄存器是RBX/RBP/R12/R13/R14/R15。

  • 参数(第7个及以上的参数,它们存在栈上)或局部变量通过[RSP+偏移量]来获取。当然,也可以使用和32位的EBP那种方式。

这里简单说下Java程序员应该如何理解寄存器。我也是受了开篇第一本书的影响才慢慢体会到的。

  1. 首先,可以把寄存器看做是程序里全局变量。有些寄存器有特殊用途,有些是通用用途,你可以随便存储什么东西。

  2. 我们如果要保存寄存器的旧值的话,应该把这个旧值存储到栈上。用完后,如果要还原的话,就从栈里把数据pop出来。

简单来说,就好像我们只有两种存储,一个是寄存器,一个是栈。数据就在这两者倒腾。注意,JVM的概念设计里没有寄存器,而全部是栈。而Android dex字节码对应的概念设计里又全部是寄存器,没有栈。但是,最终这两者对应到机器码就变成了栈和寄存器混合使用。

文章最后加了几本书的京东商品链接。好像都有电子版...

后续的安排

我想重点树立起和JVM密切有关的知识体系。有了ART源码打底子,我相信这条路走得通。对JVM的掌握是非常有必要的,我感觉国家层面在底层基础核心技术上会加大投入,JVM是一个非常合适的突破口。

最后的最后

  • 我期望的结果不是朋友们从我的书、文章、博客后学会了什么知识,干成了什么,而应该是说,神农,我可是踩在你的肩膀上的喔。

  • 关于学习方面的问题,我已经讨论完了。后面这个公众号将对一些基础的技术,新技术做一些学习和分享。也欢迎你的投稿。不过,正如我在公众号“联系方式”里说的那样——郑渊洁在童话大王《智齿》里有一句话令我印象深刻,大意是“我有权保持沉默,但你说的每一句话都可能成为我灵感的源泉”。所以,影响不是单向的,很可能我从你那学到的东西更多。

format,png

神农和朋友们的杂文集

长按识别二维码关注我们


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK