第三章 android 热修复系列文章 之 认识java虚拟机

目录
热更新学习他的使用很假单,网上可以轻松的找到大量的热更新相关的框架和成熟的解决方案,但是对于一个成熟的android开发工程师来说这些显然是不够的,为了更深入一步学习也为了后面的插件化学习,我们必须要静下心来研究,本章的重点是java虚拟机原理,毫无疑问这是动态更新的核心原理了,所以你必须仔细点哦

对堆栈陌生的可以参考下这篇文章:JAVA中的栈和堆
对jre和jdk不理解的小伙伴可以参考下这篇文章:关于JRE和JDK的区别,终于知道他们的区别了
简单说 Jre 是java runtime environment, 是java程序的运行环境。既然是运行,当然要包含jvm,也就是大家熟悉的虚拟机啦,还有所有java类库的class文件,都在lib目录下打包成了jar。
Jdk 是java development kit,是java的开发工具包,里面包含了各种类库和工具。当然也包括了另外一个Jre. 是针对开发人员配置的,普通用户只需要安装jre运行环境即可(用户单纯安装jre,安装过程程序会自动配置环境变量,因此安装的jre是不需要用户手动配置环境变量的)

JVM 中类加载器的结构

名称 | 作用 | 备注 |
---|---|---|
Bootstrap ClassLoader | 加载jdk runtime class文件 | Bootstrap ClassLoader和Extension ClassLoader主要用于jdk核心class文件的加载 |
Extension ClassLoader | 加载jdk 扩展 class文件 | |
App ClassLoader | 加载app中的 class文件 | |
Custom ClassLoader | 加载自定义的 class文件 |
JVM中类的加载流程

**Loading:**类的信息从文件中获取并载入到JVM的内存里
**Verifying:**检查读入的结构是否符合JVM规范的描述
**Preparing:**分配一个数据结构来存储类的所有信息
**Resolving:**把这个类的常量池中的中的所有的符号引用改变成直接引用
**Initlalinzing:**执行静态初始化程序,把静态变量初始化成指定的值

java虚拟机的内存分为:本地方法区、java堆、java栈、本地方法栈四个部分
每个区的作用如下

定义:一个线程的每个方法在执行的同时,都会创建一个栈帧(Statck Frame),栈帧中存储的有局部变量表、操作站、动态链接、方法出口等,当方法被调用时,栈帧在JVM栈中入栈,当方法执行完成时,栈帧出栈。
虚拟机栈中定义了两种异常,如果线程调用的栈深度大于虚拟机允许的最大深度,则抛出StatckOverFlowError(栈溢出);不过多 数Java虚拟机都允许动态扩展虚拟机栈的大小(有少部分是固定长度的),所以线程可以一直申请栈,知道内存不足,此时,会抛出 OutOfMemoryError(内存溢出)。
作用: 存放java方法执行过程的所有数据
组成: 栈区是由栈帧组成的,一个栈帧代表一个方法的执行
- 3.1.1.1 栈帧
每个方法从调用到执行就对应一个栈帧在虚拟中从入栈到出栈
- 每一个栈帧包含了:局部变量表、栈操作数、动态链接、方法出口
注: 当方法的嵌套深度大于最大长度(比如无限调用的递归)将会抛出StackOverflowError
作用;所有通过new创建对象的内存都在堆中分配
-
特点:
-
是虚拟机中最大的一块内存,是GC要回收的重要部分
-
存储会被分为新生代区和老生代区,新创建的对象存储在新生代,新生代内存不足时虚拟机通过算法将对象逐步移入老生代,虚拟机优先回收老生代内存对象
在虚拟机中堆区被分割为新生代和老生代

当对象刚刚创建时,对象将会被存放在新生代内存区,当新生代内存不足时,对象将会被虚拟机通过算法机制移入到老生代中,老生代中的对象在堆内存不足时优先被回收,如果回收不及时,导致内存不足将会导致OOM也就是传说中的内存溢出哦。
其实新生代和老生代内存大小是可以被程序员定义配置的,这一点在java后台开发中尤为重要。当我们创建对象的频率要求比较高的时候(比如即时通讯,临时消息创建比较多)我们需要将新生代的内存大小调整的大一些,相反我们需要维持对象持久化时,需要将老生代的空间调整的大一些。
作用:存储被虚拟机加载的类的信息、常量、静态变量、及时编译器编译后的数据等
作用:本地方法栈是专门为native方法提供服务的,其功能与普通栈区相似

引用计数器算法算是一种古老的java垃圾回收算法,目前很多版本的java已经废弃掉这种算法了
原理:给每个对象分配一个计算器,当有引用指向这个对象时,计数器加1,当指向该对象的引用失效时,计数器减一。最后如果该对象的计算器为0时,java垃圾回收器会认为该对象是可回收的。
优点:引用计数算法(Reference Counting)的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法
缺点:
-
a. 每次对象被引用时,都需要去更新计数器,有一点时间开销。另外无法解决循环引用问题
-
b、浪费cpu,即使内存够用,仍然在运行时进行计数器的统计
例如
class TestA{
public TestB b;
}
class TestB{
public TestA a;
}
public class Main{
public static void main(String[] args){
A a = new A();
B b = new B();
a.b=b;
b.a=a;
a = null;
b = null;
}
}
虽然a和b都为null,但是由于a和b存在循环引用,这样a和b永远都不会被回收。
至少主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题
这种算法目前定义了几个root,也就是这几个对象是jvm虚拟机不会被回收的对象,所以这些对象引用的对象都是在使用中的对象,这些对象未使用的对象就是即将要被回收的对象。简单就是说:如果对象能够达到root,就不会被回收,如果对象不能够达到root,就会被回收。
如下图:对象D访问不到根对象,所以就会被回收

以下对象会被认为是root对象:
- 被启动类(bootstrap加载器)加载的类和创建的对象
- jvm运行时方法区类静态变量(static)引用的对象
- jvm运行时方法去常量池引用的对象
- jvm当前运行线程中的虚拟机栈变量表引用的对象
- 本地方法栈中(jni)引用的对象
由于这种算法即使存在互相引用的对象,但如果这两个对象无法访问到根对象,还是会被回收。如下图:对象C和对象D互相引用,但是由于无法访问根,所以会被回收。

jvm在确定是否回收的对象的时候采用的是root搜索算法来实现。
在root搜索算法的里面,我们说的引用这里都指定的是强引用关系。所谓强引用关系,就是通过用new 方式创建的对象,并且显示关联的对象
Object obj = new Object();
以上就是代表的是强引用关系,变量obj 强引用了 Object的一个对象。
java里面有四种应用关系,从强到弱分别为:
-
Strong Reference(强引用) –>Weak Reference (弱引用) -> Soft Reference(软引用) – > Phantom Reference(引用)
-
Strong Reference : 只有在引用对象root不可达的情况下才会标识为可回收,垃圾回收才可能进行回收
-
Soft Reference : 无论其引用的对象是否root可达,在响应内存需要时,由垃圾回收判断是否需要回收。
-
Weak Reference :用来描述非必需对象。即使在root算法中 其引用的对象root可达到,只能生存到下一次垃圾回收之前。
-
Phantom Reference :无法通过虚引用获得一个对象的实例,设置虚引用的目的就是能在这个对象被收集器回收时收到一个系统通知。
下面可以看一个测试
public class ReferenceTest {
public static final Map<Integer, Reference> map = new HashMap<Integer, Reference>();
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
map.put(i, new WeakReference(new ReferenceObject(i)));
}
int i = 0;
for (Reference r : map.values()) {
if (r.get() == null) {
i++;
}
}
System.out.println("被回收的对象数:" + i);
}
static class ReferenceObject {
private int i;
private byte[] b;
public ReferenceObject(int i) {
this.i = i;
b = new byte[1024 *10];
}
}
}
这里创建大约1000个 10K的 Weak Reference 对象,最后打印的结果是:被回收的对象数:767,这里ReferenceObject如果设置为1K的话,最后的打印结果是0
这个例子并不严谨,但是却说明了被Weak Reference的对象在一定的时候会被jvm回收,但是强引用就不会出现这种状态。

原理: 虚拟机会从根节点开始,遍历A对象的引用,也可以由A节点遍历到C节点;遍历完成后B节点不可达
优点
- 不需要对存储对象进行移动并且仅对不存活的对象进行处理
- 在存或对象比较多的情况下纪委高效
缺点
- 由于直接回收不存活的对象,会造成内存碎片

将一段内存被引用的部分复制到另一端内存当中
优点
- 当存或的对象比较少的时候极为高效
缺点
- 需要一段内存作为交换内存空间

在标记-清除算法的基础上又进行了指针的移动
优点
- 相对于标记-清除算法避免了内存碎片
缺点
- 资源性能开销比较大

总结:
在java中,三种回收算法各有优势;java中并不是仅使用其中一种的,在内存垃圾比较多时使用复制算法;在垃圾比较少时使用标记整理算法或标记清除算法
-
a. java虚拟机无法在位新创建的对象分配内存空间了
-
b. 手动调用 System.gc() 方法(强烈不推荐,会增加虚拟机的压力同时,该指令只是告诉JVM尽快GC一次,但不会立即执行GC)
-
c. 低优先级的GC线程,被运行时就会执行GC
3.2.4.1 有gc为什么还需要手动释放资源?
-
1)gc只能释放内存资源,而不能释放与内存无关资源。
-
2)gc回收具有不确定性,你根本不知道它什么时候会回收,而对于需要程序员手动回收的资源往往具有这样的特点:资源开销大,不用需要立即释放;或者资源是系统唯一的,不释放会导致别的程序也无法使用该资源。那对于具有这些特点的资源就必须保证不使用后能够立即释放出这部分资源,而不能把这件事情交给一个具有不确定性的gc来完成。
3)有人可能会说java IO流资源虽然不能被gc直接释放,但可以利用finalizer机制来释放非java资源,事实上java也确实在IO流的一些类中这么做了,如下:/** * 该段代码摘自FileInputStream源码,jdk版本1.8 */ protected void finalize() throws IOException { if ((fd != null) && (fd != FileDescriptor.in)) { /* if fd is shared, the references in FileDescriptor * will ensure that finalizer is only called when * safe to do so. All references using the fd have * become unreachable. We can call close() */ close(); } }
但是请注意,这仅仅是api程序员的严谨,防止由于我们这些程序员的大意忘记手动close资源,这依旧不是我们不手动调用close方法释放资源的借口。因为第一finalize的执行时机是在gc前,而gc具有时间不确定性,所以finalize执行时间也不具有确定性,对于需要及时回收的资源finalize无法保证及时。第二,finalize不是析构函数,jvm也根本就不能保证finalize一定会执行,那么就更不能依赖finalizer机制释放资源了。
3.2.4.2 需手动释放的常见资源
- 1)java IO流资源
- 2)jdbc资源(Connection,PrepareStatement,ResultSet)
- 3)android中的bitmap资源
3.2.4.3 资源关闭顺序问题
单个资源关闭往往没什么可说的,直接关闭即可,但在java很多类体系中往往存在依赖关系和资源装饰关系,这个时候就有关闭先后问题,否则还会引发异常
- 1)先开后关原则(栈原则)
整个模型像栈一样,先开的后关(先进后出)。这个原则很像生活中的一件事情,那就是使用燃气灶。使用燃气灶的时候我们都是先开气阀,再打开燃气灶的开关,做完饭我们则先关闭燃气灶开关,最后再关上气阀。(电脑主机和显示器的开关顺序也满足这个原则,开机先开显示器后开主机,关机先关主机后关显示器,别问我为什么,问你们微机老师去)
jdbc资源的开关顺序如下:
先打开Connection资源,再打开PrepareStatement资源,最后打开ResultSet资源。使用完毕后先调用ResultSet的close方法,再调用PrepareStatement的close方法,最后调用Connection的close方法。
java IO流资源的开关顺序如下:
我们一般先打开一个输入流进行读取操作,然后将读取的数据写入输出流中,关闭时按照上面的原则,则应该先关输出流,然后关闭输入流。但是,我们发现在java io流关闭操作中,即使顺序反了也不会出现异常。因为我们关闭流的时机是在读写完成之后,并且输出流和输入流一般用一个中间buff数组做数据传递关联,并不像jdbc中资源之间的强依赖关联,所以即使关闭一个,另一个并不受影响。
- 2)由外到内原则(洋葱原则,%>_<%)
如果资源存在包装嵌套关系,则先关闭外层,后关闭内层的。
java io流中,处理流装饰节点流,我们应该先关闭装饰流,再关闭节点流。原则上是这样,但是我们发现这样反而会出现程序异常,因为java api上已经帮我们做了这样的事情。就是在处理流的close方法中调用了节点流的close方法。因此对java io流资源,如果是处理流,我们只需要调用处理流的close方法即可
-
什么是运行时?
-
简单来说,运行时就是一个供操作系统使用的系统,它负责将你用高级语言(比如 Java)编写的代码转换成 CPU/处理器能够理解的机器码。
运行时由你的程序运行时所执行的指令构成,尽管本质上它们不属于程序代码的任何一部分。
CPU (或者更通用的说法电脑)只能够理解机器语言(二进制代码),所以为了使程序能够在 CPU 上运行,就必须将它们翻译成机器码,这一工作由翻译器完成。
这里按序列出历代翻译器: -
1.汇编器
- 它直接将汇编语言翻译成机器码,所以它的速度非常快。
-
2.编译器
- 它将源码翻译成汇编语言,然后再用汇编器转换成机器码。这种方式编译过程很慢但是执行速度很快。但是使用编译器最大的问题是编译出来的机器码依赖于特定的平台。换句话说,在一台机器上可以运行的代码在另一台不同的机器上可能就无法运行。
-
3.解释器
- 它在执行程序时才翻译代码。由于代码翻译是在执行阶段才发生,所以执行速度很慢。
-
JAVA 代码是怎么执行的?
-
为了使代码和平台无关,JAVA开发了 JVM,即 Java 虚拟机。它为每一个平台开发一个 JVM,也就意味着 JVM 是和平台相关的。Java 编译器将 .java 文件转换成 .class文件,也就是字节码。最终将字节码提供给 JVM,由 JVM 将它转换成机器码。
这比解释器要快但是比 C++ 编译要慢。 -
Android 代码是怎么执行的
-
在 Android 中,Java 类被转换成 DEX 字节码。DEX 字节码通过 ART 或者 Dalvik runtime 转换成机器码。这里 DEX 字节码和设备架构无关。
-
Dalvik 是一个基于 JIT(Just in time)编译的引擎。使用 Dalvik 存在一些缺点,所以从
Android 4.4(Kitkat)
开始引入了 ART 作为运行时,从Android 5.0(Lollipop)
开始 ART 就全面取代了Dalvik。ART 虚拟机采用AOT技术(一种可以在安装apk文件时将dex转换为机器码并存储在本地的技术),这样就可以在应用运行时持续的提高其性能。
重点:Dalvik 使用 JIT(Just in time)编译而 ART 使用 AOT(Ahead of time)编译。
下图描述了 Dalvik 虚拟机和 Java 虚拟机之间的差别。

-
Just In Time (JIT)
-
使用 Dalvik JIT 编译器,每次应用在运行时,它实时的将一部分 Dalvik 字节码翻译成机器码。在程序的执行过程中,更多的代码被被编译并缓存。由于 JIT 只翻译一部分代码,它消耗的更少的内存,占用的更少的物理存储空间。
-
Ahead Of Time(AOT)
-
ART 内置了一个 Ahead-of-Time 编译器。在应用的安装期间,他就将 DEX 字节码翻译成机器码并存储在设备的存储器上。这个过程只在将应用安装到设备上时发生。由于不再需要 JIT 编译,代码的执行速度要快得多。
由于 ART 直接运行的是应用的机器码(native execution),它所占用的 CPU 资源要少于 使用 JIT 编译的 Dalvik。由于占用较少的 CPU 资源也就消耗更少的电池资源。

ART 和 Dalvik 一样使用的是相同的 DEX 字节码。编译好的应用如果使用 ART 在安装时需要额外的时间用于编译,同时还需要更多的空间用于存储编译后的代码。
-
Android 为什么要使用虚拟机?
Android 使用虚拟机作为其运行环境是为了运行 APK 文件构成的 Android 应用。它的优点有: -
应用代码和核心的操作系统分离。所以即使任意一个程序中包含恶意的代码也不会直接影响系统文件。这使得 Android 操作系统更稳定可靠。
它提高了跨平台兼容性或者说平台独立性。这意味着即使某一个应用是在 PC 上编译的,它也可以通过虚拟机在移动平台上执行。 -
ART 的优点
-
应用运行更快,因为 DEX 字节码的翻译在应用安装是就已经完成。
减少应用的启动时间,因为直接执行的是 native 代码。
提高设备的续航能力,因为节约了用于一行一行解释字节码所需要的电池。
改善的垃圾回收器
改善的开发者工具 -
ART 的缺点
-
应用安装需要更长的时间,因为 DEX 字节码需要在安装时就翻译成机器码。
由于在安装时时生成的 native 机器码是存储在内部存储器上,所以需要更多的内部存储空间。
结论
DEX 是专门为 Android 设计的一种字节码格式,主要是为了消耗更少的内存进行优化。ART 是为了在低端设备上运行多个虚拟机而开发的,这一目的通过使用 DEX 字节码实现。它使得应用的 UI 反应更及时。这就是我个人的全部观点。更多关于 ART 和 Dalvik 的细节可以参考Android 官方文档。