这是我参与「第四届青训营 」笔记创作活动的的第6天
一、ART演进
-
Android 4.x(Interpreter + JIT)
-
- 原理:平时代码走解释器,但热点trace会执行JIT进行即时编译
- 优点:占用内存少
- 缺点:耗电(退出App下次启动还会重复编译),卡顿(JIT编译时)
-
Android 5.0/5.1/6.0(interpreter + AOT)
-
- 原理: 在AOT模式下,App在安装过程时, 就会完成所有编译。
- 优点: 性能好
- 缺点: App安装时间长,占用存储空间多。
-
Android 7.0/7.1的ART引入了全新的Hybrid模式(Interpreter + JIT + AOT)
-
-
原理:
-
- App在安装时不编译, 所以安装速度快。
- 在运行App时, 先走解释器, 然后热点函数会被识别,并被JIT进行编译, 存储在jit code cache, 并产生profile文件(记录热点函数信息)。
- 等手机进入charging和idle状态下, 系统会每隔一段时间扫描App目录下profile文件,并执行AOT编译(Google官方称之为profile-guided compilation)。
- 不论是jit编译的binary code, 还是AOT编译的binary code, 它们之间的性能差别不大, 因为它们使用同一个optimizing compiler进行编译。
-
优点: App安装速度快,占用存储少(只编译热点函数)。
-
缺点: 前几次运行会较慢, 只有用户操作得次数越多,jit 和AOT编译后, 性能才会跟上来。
-
(来自于字节青训营,侵删)
二、ART整体架构
上层:编译器或者解释器
下层:支撑一些基本语法特性
三、课程背景
课程主要从作为一个Android开发人员,我们使用java编写很多代码,运行的时候到底发生了什么? 单身的你给自己new了一个对象,对象从哪里来,会到哪里去? new了几个线程,这几个线程协作的背后,是怎样的运筹调度? 虚拟机的这些特点,如何从面试八股文变成指导我们认识程序执行逻辑,帮助我们设计出高效的程序?
从中我们可以分出两个主线
主线一:我们的对象是怎么分配出来的?
主线二:虚拟机为了保证我们的代码高效顺利执行,需要提供哪些机制?
四、对象篇
1 对象是谁 - 类的管理
1.1 对象的生命旅程
Q:那我们先回答第一个问题,我们分配一个对象的时候,具体是多大空间?
类管理它决定一个对象的大小和行为
主要描述的是一个对象的内存布局和函数信息 内存布局:类成员的大小,类型,和排布 函数信息:主要是虚表的信息:某个函数定义在当前类函数表的第几个位置 因为java是支持继承的,因此类的内存布局和函数虚表需要做继承链全展开以后才能真正确认。(这也是动态性的来源)
为什么这么说呢?我们从两个方面进行分析
第一是Oibect基类
它持有的klass和monitor的两个属性,我们可以看出
klass是指向该对象所属类的类对象
monitor存储了hashCode和锁信息
那么我们想知道一个类的大小和行为,就需要找出他的继承链路
那么他的继承链路是怎么加载出来的呢?
这时候就要提到一个概念类加载器
顾名思义就是一个可以将Java字节码加载为java.lang.Class实例的工具。
这个过程包括,读取字节数组、验证、解析、初始化等。另外,它也可以加载资源,包括图像文件和配置文件。
类加载器的特点:
动态加载,无需在程序一开始运行的时候加载,而是在程序运行的过程中,动态按需加载,字节码的来源也很多,压缩包jar、war中,网络中,本地文件等。类加载器动态加载的特点为热部署,热加载做了有力支持。 全盘负责,当一个类加载器加载一个类时,这个类所依赖的、引用的其他所有类都由这个类加载器加载,除非在程序中显式地指定另外一个类加载器加载。所以破坏双亲委派不能破坏扩展类加载器以上的顺序。
类加载器有哪些?
首先,我们需要知道的是,Java语言系统中支持以下4种类加载器:
- Bootstrap ClassLoader 启动类加载器
- Extention ClassLoader 标准扩展类加载器
- Application ClassLoader 应用类加载器
- User ClassLoader 用户自定义类加载器
这四种类加载器之间,是存在着一种层次关系的,如下图
一般认为上一层加载器是下一层加载器的父加载器,那么,除了BootstrapClassLoader之外,所有的加载器都是有父加载器的。
那么,所谓的双亲委派机制
指的就是:当一个类加载器收到了类加载的请求的时候,他不会直接去加载指定的类,而是把这个请求委托给自己的父加载器去加载。只有父加载器无法加载这个类的时候,才会由当前这个加载器来负责类的加载。
那么,什么情况下父加载器会无法加载某一个类呢?
其实,Java中提供的这四种类型的加载器,是有各自的职责的:
| 类加载器 | 作用 |
|---|---|
| Bootstrap ClassLoader | 主要负责加载Java核心类库,%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等。 |
| Extention ClassLoader | 主要负责加载目录**%JRE_HOME%\lib\ext**目录下的jar包和class文件。 |
| Application ClassLoader | 主要负责加载当前应用的classpath下的所有类 |
| User ClassLoader | 用户自定义的类加载器,可加载指定路径的class文件 |
那么也就是说,一个用户自定义的类,如com.hollis.ClassHollis 是无论如何也不会被Bootstrap和Extention加载器加载的。
为什么需要双亲委派?
如上面我们提到的,因为类加载器之间有严格的层次关系,那么也就使得Java类也随之具备了层次关系。
或者说这种层次关系是优先级。
比如一个定义在java.lang包下的类,因为它被存放在rt.jar之中,所以在被加载过程汇总,会被一直委托到Bootstrap ClassLoader,最终由Bootstrap ClassLoader所加载。
而一个用户自定义的com.hollis.ClassHollis类,他也会被一直委托到Bootstrap ClassLoader,但是因为Bootstrap ClassLoader不负责加载该类,那么会在由Extention ClassLoader尝试加载,而Extention ClassLoader也不负责这个类的加载,最终才会被Application ClassLoader加载。
这种机制有几个好处。
首先,通过委派的方式,可以避免类的重复加载,当父加载器已经加载过某一个类时,子加载器就不会再重新加载这个类。
另外,通过双亲委派的方式,还保证了安全性。因为Bootstrap ClassLoader在加载的时候,只会加载JAVA_HOME中的jar包里面的类,如java.lang.Integer,那么这个类是不会被随意替换的,除非有人跑到你的机器上, 破坏你的JDK。
那么,就可以避免有人自定义一个有破坏功能的java.lang.Integer被加载。这样可以有效的防止核心Java API被篡改。
以上概念均引用其他文章,见文末参考文章
e g :

我们要找确定小猪佩奇的某种基因上限(仅从父辈分析)
小猪佩奇 -> 小猪佩奇的爸爸 -> 小猪佩奇的爷爷 得到此条链路才能分析出小猪佩奇的上限,
对应我们的类关系即 爷爷+爸爸+儿子自己的所有成员的大小 就是儿子类的大小
其中还有一个概念:双亲委派
这时候有一个很像小猪爸爸的叔叔出现了,那么我们的大小又如何确定呢
即使他们么长得再像,也没有血缘关系,所以在JAVA中定义回沿着自己的继承链
bootclassloader -> pathclassloader
那么问题来了,知道类的大小的计算方式,对不搞ART的我们,有什么好处呢?
合理继承的好处就不言而喻了
那么我们的第一个问题对象大小如何确定就得以解决了
2 对象从哪里来 一 内存分配
有一个内存分配器承担的这一部分的责任
APP的java对象内存分配上是托管到VM来处理的,并不会直接向操作系统去由造立际上对OS内存的占用和内存布局,是 VM控制的(预留-扩展)
不同分配器的特点:
一些典型的场景:
前两个的特点是,钱都在自己名下 而large是绕过自己的钱包,直接从”父母"一操作系统去取
但是我们的钱和内存有什么不一样呢?
我们内存是会释放的呀,在内存回收的时候,怎么处理解决碎片的问题呢? 内存的碎片程度不一样,可用性就天差地别
ART内存分配的根本原理,还是给使用者在最优的范围内找到一块大小符合的连续内存
那么如何去解决呢?来到我们的下一点
3 对象到哪里去 一 内存回收
内存回收一般有两种思路
GC:垃圾回收(Garbage Colection),需要定期查找系统内不用的对象,并且释放占用的内存(android) RC:引用计数(Reference Counting),指的是对一个对象引用进行计数,多一个引用者,就+1,少一个就-1,为0就释放 典型的如/OS的swift就使用RC进行内存管理(IOS)
对比我们可以得知,RC的方法好像更及时更高效,那么他会不会存在什么问题呢?
由这张图片我们可以得知在环引用的时候,我们的RC是认不出来的,
因此引入了弱引用和手动标记(增加了开发者的负担)需要在编程的过程中去手动标记
那么对于在我们的GC中有没有呢?
实际上也是有的
难道GC不是只有内存不足的时候才会触发吗?
当然不是,触发GC的条件有两种:
1.我们想要不被预期外的GC导致卡顿,可以考虑适当的预留内存 2.大小有上限可预期的情况,new一个大数组,可能比分配一大堆放到容器里面要好。
我们可以看到图上有一个任性了,想触发了,那么我们就不管他了嘛?
实际是不是的,这取决于每一个厂商ROM的制定
这也是苹果手机内存少的原因之一
那么我们Android的GC又是为什么占用那么高呢?
GC roots的概念:怎么判断哪些内存是有用的那些是没用的?
我们GC在遍历的时候会进行分类,如图所示,比如栈上正在运行的东西,我们肯定不能释放,static 变量也不行,原生层面的也不行
那么什么可以释放呢,这时候遍历到了一个单身狗即对象3,GC一看他没有人爱,就被干掉了。
我们知道了单身狗是怎么被灭掉的,那么GC又是怎么发现(遍历)单身狗的呢?
两种方式:
**tracing(跟踪) GC:**从roots遍历,所有mark的对象是有holder的,释放掉没有holder的object
copying GC:从roots遍历,把有用的的对象拷贝到另一个区域,然后集中释放掉当前区域的內存
ART的做法:
了解了这些基础的概念,我们一个内存友好的代码怎么写呢?
举个例子:学生是一个类,我们怎么在内存里面保存一个班的学生信息? 我们看下八股文里面怎么写: 如果增删改比较多,使用链表,具有可扩展性 其实我们学习过前面的内存模型,就知道在虚拟机里面,离散的分配10次对象和把10个对象放在一起分配,是不经济的,反而有可能加剧内存碎片化的情况。 如果遍历比较多,使用数组,性能好,但是新增删除慢
我们还可以再想想一辆火车和一堆顺丰小三轮?
内存回收完成之后,还有有其他操作嘛?
答案是有的,不能保证内存回收一定是完美的,肯定会有一些擦屁股的工作
如果我们的对象,绑定了一个native变量,那我们怎么让这个native对象跟随这个java的生命周期一起结束呢?
这时候就要提到我们的finalize()
他就好像一个渣男,分手的之后还联系着前女友
"放了,但没有完全放”
它是Java中Object的一个方法,返回值为空,当该对象被垃圾回收器回收时,会调用该方法。
要注意的是一个对象的finalize方法只会执行一次,再次激活之后的对象是不会触发finalize的。
所以我们在使用的时候要特别注意,可以使用clean其他方法
小结:
-
finalize不等价于c++中的析构函数
-
对象可能不被垃圾机回收器回收
-
垃圾回收不等于析构
-
垃圾回收只与内存有关
-
垃圾回收和finalize()都是靠不住的,只要JVM还没有快到耗尽内存的地步,它是不会浪费时间进行垃圾回收的。
-
程序强制终结后,那些失去引用的对象将会被垃圾回收。(System.gc())
finalize()的用途:比如当一个对象代表了打开了一个文件,在对象被回收前,程序应该要关闭该文件,可以通过finalize函数来发现未关闭文件的对象,并对其进行处理。
总结
通过本节课老师幽默风趣的风格,让我们了解到了以下的知识
1.我们怎么决定一个对象的大小,内存的布局
2.我们怎么分配出一个对象
3.我们怎么回收一个对象
过程中我们从基础的代码,到生活中的小例子,最后到我们的实际代码中,
青训营老师为我们逐层拨开ART的迷雾!
参考文章
Java双亲委派模型:为什么要双亲委派?如何打破它?破在哪里?
萌新初学,本文为笔记,大佬若有更好的见解欢迎评论区留言