缘起
最近一直在家办公。在家办公显然是比办公室办公要累很多的。几乎没有扯淡、溜达、扯皮,上个卫生间,打水所耗费的时间。我推测大部分人肯定是不太适应这种节奏。我个人还好,因为写书的时候,一天可以干16个小时。唯一区别是写书情况下,这种状态也就周末最多两天。现在家里办公每天都要搞成这样的话,谁也受不了。
这一个月,我都在系统的学习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代码有一定了解。源码中经常看到这样下面这样的代码:
其中,StackHandleScope,Handle是什么?如果看代码的话,会烦死。ART封装太多了。所以,我们就看它到底包含了什么数据(不考虑行为)
以上就是HandleScope家族的数据结构。
BaseHandleScope和HandleScope包含两个成员。link_和umber_of_reference_
FixedSizeHandleScope:包含storage_数组。这个数组的元素指向一个StackReference。它到底是什么?我们后面会讲
StackHandleScope:多了一个self_,指向当前的线程对象。
ART代码中大量出现获取数据结构中某个成员的操作。尤其是汇编代码里,经常要从数据结构里拿某个数据。它就是通过该成员对应的偏移量(offset)来获取的。比如,上图右边的两个Offset,就是来取number_of_references_以及stroage_的。
解决HandleScope后,再来看Handle和StackReference。
这个图里:
右上角的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指针。
现在,我们可以解释代码的执行结果了。
上图中左上角的代码执行后得到这样的结果:
heap->AllocNonMovableXX得到一个Alloced对象,我们叫它原始对象。
ART不能直接操作这个原始对象(下文会解释)。所以设计了一个Handle对象来操作。
这个Handle对象也颇有来头,它代表HandleScope对象(图中,该对象变量名为hs)里storage_数组里的某个元素。另外,通过Handle,我们可以操作这个原始对象。注意,不能直接操作这个原始对象,只能通过Handle和它的家族。
为毛搞这么复杂呢?明明在第一步已经拿到了原始对象,要对它做什么直接处理就好了,为何要借助Handle,还搞一个HandleScope?这是因为JVM在遍历所谓的Root对象时需要。看下图:
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):
来解释这图,先看左上角。调用约定包含两个部分。一个是调用者规则,一个是被调用者的规则。而被调用者的规则又分为入口规则(Prologue)和出口规则(Epilogue)。所谓的约定,规则,其实就是套路。大家商量好的事情,有时候没有什么道理可讲。
Caller Rule:调用者规则,说明x86上,函数调用一定是这么写的(或者说,编译器一定会生成这样的代码)。
首先是保存调用者保管的寄存器(EAX/ECX/EDX,Caller Saved Register)。注意,如果调用者用了这节寄存器才需要保存。
然后是将参数push到栈上。最后一个参数先push,第一个参数最后push。
接着,通过call调用目标函数。目标函数的返回值一定是存储在EAX里。调用完后,
调用者pop对应的寄存器。右边的绿色箭头指向的是栈的样子。
Callee Rule:被调用者规则。首先是入口规则,它包括:
通过栈来保存EBP,并将ESP的值保存到EBP。
分配局部变量所需的栈空间。这说明一个函数所需栈空间在编译期就是能确定的。
保存callee saved寄存器,比如EBX/EDI/ESI。
目标函数返回前,将执行出口规则,它包括:
pop EBX/ED/ESI
释放局部变量所需空间
还原EBP
ret
Callee Rule对应的栈结构就在最右边的栈图里。x86这种调用约定的设计下,我们会看到如下场景:
第一个参数一定在[EBP+8]中,其他类推
第一个局部变量一定在[EBP-4]中,其他类推
所以,这就是套路。现在,大家可以去看code_generator_x86.cc的GenerateFrameEntry/GenerateFrameExit的代码,看看是不是上面的Callee规则的思想在发挥作用?
接着,看下x86_64的调用约定:
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程序员应该如何理解寄存器。我也是受了开篇第一本书的影响才慢慢体会到的。
首先,可以把寄存器看做是程序里全局变量。有些寄存器有特殊用途,有些是通用用途,你可以随便存储什么东西。
我们如果要保存寄存器的旧值的话,应该把这个旧值存储到栈上。用完后,如果要还原的话,就从栈里把数据pop出来。
简单来说,就好像我们只有两种存储,一个是寄存器,一个是栈。数据就在这两者倒腾。注意,JVM的概念设计里没有寄存器,而全部是栈。而Android dex字节码对应的概念设计里又全部是寄存器,没有栈。但是,最终这两者对应到机器码就变成了栈和寄存器混合使用。
文章最后加了几本书的京东商品链接。好像都有电子版...
后续的安排
我想重点树立起和JVM密切有关的知识体系。有了ART源码打底子,我相信这条路走得通。对JVM的掌握是非常有必要的,我感觉国家层面在底层基础核心技术上会加大投入,JVM是一个非常合适的突破口。
最后的最后
我期望的结果不是朋友们从我的书、文章、博客后学会了什么知识,干成了什么,而应该是说,神农,我可是踩在你的肩膀上的喔。
关于学习方面的问题,我已经讨论完了。后面这个公众号将对一些基础的技术,新技术做一些学习和分享。也欢迎你的投稿。不过,正如我在公众号“联系方式”里说的那样——郑渊洁在童话大王《智齿》里有一句话令我印象深刻,大意是“我有权保持沉默,但你说的每一句话都可能成为我灵感的源泉”。所以,影响不是单向的,很可能我从你那学到的东西更多。
神农和朋友们的杂文集
长按识别二维码关注我们