0.前言

这是拉勾教育课程《Android 工程师34讲》的学习笔记一,模块一:JVM与DVM必知必会。

1.JVM的内存分配

Java虚拟机在执行Java程序的过程中,会把所管理的内存划分为不同的数据区域,下图描述类一个HellowWorld.java文件被JVM加载到内存中的过程:
1.HelloWorld.java文件首先需要经过编译器编译,生成HellowWorld.class字节码文件;
2.Java程序中访问HelloWorld这个类时,需要通过ClassLoader(类加载器)将HelloWorld.class加载到JVM的内存中;
3.JVM中的内存可以划分为若干个不同的数据区域,主要分为:程序计数器、虚拟机栈、本地方法栈、堆、方法区。

1.1程序计数器

Java程序是多线程的,CPU可以在多个线程中分配执行时间片段。当某一个线程被CPU挂起时,需要记录代码已经执行到的位置,方便CPU重新执行此线程时,知道从哪行指令开始执行。这就是程序计数器的作用。
关于程序计数器,需要注意几点:
1)在Java虚拟机规范中,对程序计数器这一区域没有规定任何OOM情况;
2)线程私有的,每条线程内部都有一个私有程序计数器,它的生命周期随着线程的创建而创建,线程的结束而死亡;
3)当一个线程正在执行一个Java方法的时候,这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是Native方法,这个计数器值则为空。

1.2虚拟机栈

虚拟机栈也是线程私有的,与线程的生命周期同步。在Java虚拟机规范中,对这个区域规定了两种异常状况:
1)StackOverflowError:当线程请求栈深度超出虚拟机栈所允许的深度时抛出;
2)OutOfMemoryEoor:当Java虚拟机动态扩展到无法申请足够内存时抛出;
在学习Java虚拟机过程中,经常看到的一句话是:
JVM是基于栈的解释器执行的,DVM是基于寄存器解释器执行的。
上面这句话里的“基于栈”指的就是虚拟机栈。虚拟机栈的初衷是用来描述Java方法执行的内存模型,每个方法被执行的时候,JVM都会在虚拟机栈中创建一个栈帧,接下来介绍一下栈帧是什么:

1.2.1栈帧

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,每一个线程在执行某个方法时,都会为这个方法创建一个栈帧。
我们可以这样理解:一个线程包含多个栈帧,而每个栈帧内部包含局部变量表、操作数栈、动态链接、返回地址等,如图:

1.2.2局部变量表

局部变量表是变量值的存储空间,我们调用方法时传递的参数,以及在方法内部创建的局部变量都保存在局部变量表中。在Java编译成class文件的时候,就会在方法的Code属性表中的max_locals数据项中,确定该方法需要分配的最大局部变量表的容量。

1.2.3操作数栈

操作数栈也称为操作栈,它是一个后入先出栈(LIFO)。
同局部变量表一样,操作数栈的最大深度也在编译的时候写入方法的Code属性表中的max_stacks数据项中。栈中的元素可以是任意Java数据类型,包括long和double
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的。在方法执行的过程中,会有各种字节码指令被压入和弹出操作数栈。

1.2.4动态链接

动态链接的主要目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。
在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其所在内存地址中的直接引用,而符号引用存在于方法区中。
Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的就是为了支持方法调用过程中的动态连接。具体过程会在后续的字节码执行中介绍。

1.2.5返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法:
1)正常退出,指方法中的代码正常完成,或者遇到任意一个方法返回的字节码指令(如return)并退出,没有抛出任何异常;
2)异常退出,指方法执行过程中遇到异常,并且这个异常在方法内部没有得到处理,导致方法退出;
无论当前方法采用哪种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行。而虚拟机栈中的“返回地址”就是用来帮助当前方法恢复它的上层方法执行状态。

1.2.6 实例讲解

具体过程

1.3本地方法栈

本地方法栈和上面介绍的虚拟机栈基本相同,只不过是针对本地(native)方法。

1.4堆

Java堆(Heap)是JVM所管理的内存中最大的一块,该区域唯一的目的就是存放对象实例,几乎所有对象的实例都在堆里面分配,因此它是Java垃圾收集管理的主要区域,有时候也叫GC堆。同时它也是所有线程共享的内存区域,因此需要考虑线程安全问题。
具体的垃圾回收过程,后文会详细介绍。

1.5方法区

方法区是、也是JVM规范里规定的一块运行时数据区。方法区主要是存储已经被JVM加载的类信息(版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码和数据。该区域和堆一样是在线程间共享。
注意:关于方法区,很容易将其跟“永久区”混淆。方法区是JVM规范中规定的一块区域,但是并不是实际实现,切忌将规范跟实现混为一谈,不同的JVM厂商可以有不同版本的方法区的实现;HotSpot在JDK1.7以前使用“永久区”来实现方法区,在JDK1.8之后把“永久区”移除来,取而代之的是叫作“元空间(metaspace)”的实现方式。
总结就是:方法区是规范层面的东西,规定来这一区域要存放哪些数据;永久区或者是元空间是对方法区的不同实现,是实现层面的东西。

1.6小结

对于JVM运行内存布局,需要记住一点:上面介绍的这五块内容都是Java虚拟机规范中定义的规则,这些规则只是描述来各个区域负责做什么事情、存储什么样的数据、如何处理异常、是否运行线程间共享等。千万不要将它们理解为虚拟机的具体实现,虚拟机的具体实现有很多,比如Sun公司的HotSpot、JRocked,以及Android Dalvik和ART等,这些具体实现在符合上面五种运行数据区的前提下,又各自有不同的实现方式。
总结来说,JVM的运行时内存结构中一共有两个栈和一个堆,分别是Java虚拟机栈和本地方法栈,以及GC堆,另外还有方法区和程序计数器。其中只是堆和方法区是线程间共享的数据区域,其他区域都是线程私有的。并且程序计数器是唯一一个在Java虚拟机规范中没有任何规定OOM情况的区域。

2GC回收机制与分代回收策略

垃圾回收(GC)是Java语言中一个特性,它是的Java程序员不需要手动释放对象内存,JVM中的垃圾回收器会为我们自动回收。但是一旦这种自动化机制出错,我们就不得不去深入理解GC回收机制,甚至需要对这些自动化的技术实施不要的监控和调节。
上节介绍来Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着入栈和出栈操作,这些区域不需要过多考虑回收问题。
而堆和方法区不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间才能知道会创建哪些对象,这部分内存的分配回收都是动态的,垃圾收集器关注的就是这部分内存。

2.1什么是垃圾

所谓垃圾就是内存中已经没有用的对象。既然是垃圾回收,那就必须知道哪些对象是垃圾。Java虚拟机中使用可达性分析的算法来决定对象是否可以被回收。

2.1.1可达性分析

可达性分析算法是从离散数学中的图论引入的,JVM把内存中所有的对象之间的引用关系看作一张图,通过一组名为“GC Root”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,最后通过判断对象的引用链是否可达来决定对象是否可以被回收。

上图中,对象ABCDE与GC Root之间都存在一条直接或间接的引用链,这表面它们与GC Root之间是可达的,因此它们是不能被GC回收掉的。而对象M和K虽然被J引用到,但是不存在一条引用链连接它们和GC Root,所以进行垃圾回收时,只要遍历到JKM这三个对象,就会将它们回收。
注意:上图中圆形图标虽然标记的是对象,但是实际上代表的是此对象在内存中的引用。包括GC Root也是一组引用而并非对象。

2.1.2GC Root对象

在Java中,以下几种对象可以作为GC Root:
1.虚拟机栈中布局变量表引用的对象;
2.本地方法栈中JNI引用的对象;
3.方法区中静态属性引用的对象;
4.方法区中常量引用的对象;

2.1.3什么时候回收

不同的虚拟机实现有着不同的GC实现机制,但是一般情况下每一种GC实现都会在以下两种情况下触发垃圾回收:
1)Allocation Failure:在堆内存中分配时,如果因为可用剩余空间不足导致对象内存分配失败,这时系统会触发一次GC。
2)System.gc():在应用层,Java开发工程师可以主动调用此API来请求一次GC。

2.2如何回收垃圾

垃圾收集算法的实现涉及大量的程序细节,各家虚拟机厂商的细节实现不同,接下来不会讨论算法和实现,只会介绍几种算法的思想以及优缺点。
1)标记清除算法:从GC Roots集合开始将内存整个遍历一次,保留所有可以被GC Roots直接或间接引用到的对象,而剩下的对象都被当作垃圾回收。分为两步,Mark标记阶段和Sweep清除阶段。
优点:实现简单,不需要移动对象。
缺点:算法需要中断进程内其他组件的执行,并且可能产生内存碎片,提高了GC的频率。
2)复制算法:将现有内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中。之后清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。
3)标记压缩算法
从GC Root开始对所有可达对象做一次标记,之后并不是简单地清理未标记的对象,而是将所有存活对象压缩到内存的一端。最后清理边界外所有的空间。分为两步,Mark标记阶段和Compact压缩阶段。
优点:这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此性价比比较高。
缺点:所谓压缩操作,仍需要进行局部对象移动,所以一定程度上还是降低了效率。
4)分代回收策略
Java 虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代,这就是 JVM 的内存分代策略。
分代回收的中心思想就是:对于新创建的对象会在新生代中分配内存,此区域的对象生命周期一般较短。如果经过多次回收仍然存活下来,则将它们转移到老年代中。

3字节码层面分析class类文件结构

先从一道面试题说起,java中String字符串的长度有限制吗?
要彻底答对这道题,就需要学习class文件。

3.1Class的来龙去脉

Java能够实现“一次编译,到处运行”,calss文件要占大部分功劳。为了让Java语言具有良好的跨平台能力,Java独具匠心的提供类一种可以在所有平台上都能使用的一种中间代码--字节码类文件(.class文件)。有了字节码,无论在哪种平台,只要安装类虚拟机都可以运行字节码。并且,字节码也解除类Java虚拟机和Java语言之间的耦合。
其实Java虚拟机当初被设计出来的目的,就不单单是运行Java这一种语言。目前Java虚拟机已经可以支持很多除Java外的语言,如Groovy、JRuby、Jython、Scala、Kotlin等。之所以可以支持其他语言,是因为这些语言经过编译之后也可以生成能够被JVM解析并执行的字节码文件。而虚拟机并不关心字节码是又哪种语言编译来的。如图:

3.2class文件结构

3.2.1无符号数和表

class文件里面只有两种数据结构:无符号数和表。
无符号数:属于基本的数据类型,以u1、u2、u4、u8来表示1个字节、2个字节、4个字节、8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者字符串(UTF-8编码)。
表:表是由多个无符号数或者其他表作为数据项构成的复合数据类型,class文件中所有的表都以"_info"结尾。其实,整个class文件本质上就是一张表。
两者之间的关系如图:

可以看出,在一张表中可以包含其他无符号数和其他表格。伪代码如下:

// 无符号数
u1 = byte[1];
u2 = byte[2];
u4 = byte[4];
u8 = byte[8];

// 表
class_table {
    // 表中可以引用各种无符号数,
    u1 tag;
    u2 index2;
    ...
    // 表中也可以引用其它表
    method_table mt;
    ...
}

3.2.2class文件结构

在calss文件中只存在无符号数和表这两种数据结构,这些无符号数和表就组成来class中的各个结构。这些结构按照预先规定好的顺序紧密的从前向后排序,相邻的项之间没有任何间隙。如图:

当JVM加载某个class文件时,JVM就是根据上图中的结构去解析calss文件,加载class文件到内存中,并在内存中分配相应的空间。具体某一种结构需要占用多大的空间,参考下图:

1.魔数magic number:如上图所示,在class文件开头的四个字节是class文件的魔数,它是一个固定的值--0xCAFEBABE。魔数是class文件的标识,也就是说它是判断一个文件是不是class格式文件的标准。
2.版本号:紧跟在魔数后面的四个字节代表当前class文件的版本号。
3.常量池:跟在版本号之后的是一个叫做常量池的表(cp_info)。在常量池中保存了类的各种相关信息,比如类的名称、父类的名称、类中的方法名、参数名称、参数类型等,这些信息都是以各种表的形式保存在常量池中的。
在常量池中的每一项都会又一个u1大小的tag值,是表的标识,JVM解析class文件时,通过这个值来判断当前数据结构是哪一种表。以上14种表都有自己的结构,以CONSTANT_Class_info和CONSTANT_Utf8_info两张表举例说明:
首先CONSTANT_Class_info表结构如下所示:

table CONSTANT_Class_info {
    u1  tag = 7;
    u2  name_index;
}

说明:tag占用一个字节大小,比如是7,说明是CONSTANT_Class_info类型表;name_index是一个索引表,可以将它理解为一个指针,指向常量池中索引为name_index的常量。
然后看看CONSTANT_Utf8_info表结构:

table CONSTANT_Utf8_info {
    u1  tag;
    u2  length;
    u1[] bytes;
}

说明:tag值为1,表示是CONSTANT_utf8_info类型表;length表示u1数组的长度,比如length=5,表示接下来的数据是5个连续的u1类型数据;bytes是u1类型数组,长度为length的值。

这里可以回答一开始的问题,我们在java代码中声明的String字符串最终在class文件中的存储格式就是CONSTANT_Utf8_info,因此一个字符串最大长度就是u2所能表示的最大值65536个,但是需要使用两个字节来保存null值,因此一个字符串最大的长度为65535-2=65534。

不难看出,在常量池内部的表中也有互相之间的引用。用一张图来理解CONSTANT_Class_info和CONSTANT_Utf8_info表格之间的关系:

可以借助javap -v Test.class命令帮助查看class常量池中的内容。
4.访问标志:紧跟在常量池之后的就是访问标志,占用两个字节。访问标志代表类或接口的访问信息,比如:该class文件是类还是接口,是否被定义成public,是否是abstract,是否被声明成final等等。

我们定义的Test.java是一个普通的Java类,不是接口、枚举或注解,并且被public修饰但没有声明为final和abstract,因此它所对应的access_flags为0x0021(0x0001和0x0020)。
5.类索引、父类索引与接口索引计数器:访问标志后的2个字节就是类索引,类索引后2个字节是父类索引,父类索引后2个字节是接口索引计数器。根据这些信息可以得知当前类是Test,继承自Object类,并实现类Serializable和Cloneable这两个接口。
6.接口索引集合之后就是字段表,字段表的主要功能是用来描述类或者接口中声明的变量。这里的字段包含类级别变量以及实例变量,但是不包括方法内部声明的局部变量。
7.字段访问标志:对于Java类中的变量,也可以使用public、private、final、static等标识符进行标识。因此解析字段时,需要先判断它的访问标志,如图:

注意:字段表集合中不会列出父类或者父接口中继承来的字段;内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
8.方法表:字段表后跟着的是方法表常量,以一个计数器开始,表示方法的数量,其中默认构造方法也会包含在内。方法表的结构如下所示:

CONSTANT_Methodref_info{
    u2  access_flags;        方法的访问标志
    u2  name_index;          指向方法名的索引
    u2  descriptor_index;    指向方法类型的索引
    u2  attributes_count;    方法属性计数器
    attribute_info attributes;
}

方法有自己的访问标志,具体如下:

9.属性表:在之前解析字段和方法的时候,在它们的具体结构中我们都能看到有一个叫做attributes_info的表,这就是属性表。属性表没有一个固定的结构,各种不同的属性只要满足以下结构即可:

CONSTANT_Attribute_info{
    u2 name_index;
    u2 attribute_length length;
    u1[] info;
}

JVM中预定义类很多属性表,重点讲一下Code属性表,Code属性表中,最主要的就是一些列的字节码。通过javap -v Test.class之后,可以看到方法的字节码指令,如下图:

JVM执行add方法时,就通过这一系列指令来做相应的操作。
小结:这一节我们了解来class文件内容的数据结构,并通过Test.class来模拟显示Java虚拟机解析字节码文件的过程。其中class常量池是重点内容,它相当于是class文件中的资源仓库,其他几种结构或多或少都会最终指向这个资源库中。主要借助javap命令或者其他工具查看class内部结构。

4.编译插桩操纵字节码,实现不可能的任务

gradle编译打包过程中,会生成class文件,动态的对class文件修改。
需要用到的技术点包括自定义gralde插件,asm动态插桩等,细节见文章

5.深入理解ClassLoader的加载机制

Java程序启动的时候,并不会一次性加载所有的class文件,而是在程序的运行过程中,动态地加载相应的类到内存中。通常情况下,class文件会在以下两种情况下被ClassLoader主动加载到内存中:
1.调用类构造器
2.调用类中的静态变量或者静态方法

Java中自带3个类加载器:
1.启动类加载器BootstrapClassLoader;
2.扩展类加载器ExtensionClassLoader;
3.应用类加载器ApplicationClassLoader;

Android中的ClassLoader
本质上,Android和传统的JVM一样,需要通过ClassLoader将目标类加载到内存,加载器之间也符合双亲委派模型,但是加载细节略有差别。Android虚拟机无法直接运行class文件,而是dex文件,Android将加载dex文件的实现封装在BaseDexClassLoader中,我们一般只使用它的两个子类PathClassLoader和DexClassLoader。
PathClassLoader用来加载系统apk和被安装到手机的apk内的dex文件;
DexClassLoader没有PathClassLoader的限制,可以从SD卡上加载包含.dex的jar和apk文件,这也是插件化和热修复的基础,在不需要安装应用的情况下,完成需要使用的dex的加载。

6.Class对象在执行引擎中的初始化过程

一个class文件被加载到内存中需要3个大步:装载、链接、初始化。其中链接又可以细分为:验证、准备、解析3小步。如图:

6.1装载

什么是装载?装载是指Java虚拟机查找class文件并生成字节流,然后根据字节流创建Class对象的过程。
这一过程主要完成以下3件事:
1.ClassLoader通过一个类的全限定名(包名+类名)来查找class文件,并生成二进制字节流;其中class字节码文件的来源不一定是class文件,也可以是jar包,zip包,或者是网络的字节流。
2.把class文件的各个部分分别解析为JVM内部特定的数据结构,并存储在方法区。
3.在内存中创建一个java.lang.Class类型的对象,接下来程序在运行过程中所有对该类的访问都通过这个类对象,也就是这个Class类型的类对象是提供给外界访问该类的接口。

加载时机
当程序运行时,JVM并不会一次性的将所有class文件加载到内存中,JVM规范没有严格规定什么时候记载class文件,一般是以下两种情况:
隐式加载:在程序运行过程中,当碰到通过new等方式生成对象时,系统会隐式调用ClassLoader去装载对应的Class到内存中;
显式加载:在编写源码时,主动调用Class.forName()等方法也会进行class装载操作。

6.2链接

链接分为3步:验证、准备、解析。

6.2.1 验证

验证是链接的第一步,目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危及虚拟机本身的安全,主要包括以下几个方面:
1.文件格式检验,检验字节流是否符合class文件格式的规范,并且能被当前版本的虚拟机处理;
2.元数据检验,对字节码描述的信息进行语义分析,以确保其描述的内容符合Java语言规范;
3.字节码校验,通过数据流和控制流分析,确定程序语义是合法、符合逻辑的;
4.符号引用检验,可以看作是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。

6.2.2 准备

准备是链接的第二步,这一阶段的主要目的是为类中的静态变量分配内存,并为其设置“0值”。
一种情况比较特殊,静态常量会在准备阶段设置初始值。

6.2.3 解析

解析是链接的最后一步,这一阶段的任务是把常量池中的符号引用转换为直接引用,也就是具体的内存地址。在这一阶段,JVM会将常量池中的类、接口名、字段名、方法名等转换为具体的内存地址。

6.3 初始化

这是class加载的最后一步,这一阶段是执行类构造器方法的过程,并真正初始化类变量。

初始化的时机
JVM规范中严格规定类class初始化的时机,主要有以下几种情况会触发:
1.虚拟机启动时,初始化包含main方法的主类;
2.遇到new指令创建对象实例时(如果目标对象类没有被初始化);
3.当遇到访问静态方法或者静态字段的指令时(如果目标对象类没有被初始化);
4.子类的初始化过程中,如果发现父类还没有进行过初始化,则先触发父类的初始化;
5.使用反射API进行反射调用时(如果目标对象类没有被初始化);
6.第一次调用java.lang.invoke.MethodHandle实例时,需要初始化MethodHandle指向方法所在的类。

初始化类变量
在初始化阶段,只会初始化与类相关的静态赋值语句和静态语句,也就是有static关键字修饰的信息,而没有static修饰的语句块在实例化对象的时候才会执行。

被动引用
上述的6种情况都是主动引用,除此之外所有引用类的方式都被称为被动引用,被动引用不会触发class的初始化。
最典型的就是在子类中调用父类的静态变量。对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过子类 Child 来引用父类 Parent 中定义的静态字段,只会触发父类 Parent 的初始化而不会触发子类 Child 的初始化。

class初始化和对象的创建顺序
静态变量/静态代码块 --> 普通代码块 --> 构造函数

  1. 父类静态变量和静态代码块;
  2. 子类静态变量和静态代码块;
  3. 父类普通成员变量和普通代码块;
  4. 父类的构造函数;
  5. 子类普通成员变量和普通代码块;
  6. 子类的构造函数。

6.4 小结

class 文件被加载到内存中所经过的详细过程,主要分 3 大步:装载、链接、初始化。其中链接中又包含验证、准备、解析 3 小步。
装载:指查找字节流,并根据此字节流创建类的过程。装载过程成功的标志就是在方法区中成功创建了类所对应的 Class 对象。
链接:指验证创建的类,并将其解析到 JVM 中使之能够被 JVM 执行。
初始化:则是将标记为 static 的字段进行赋值,并且执行 static 标记的代码语句 。

7Java内存模型与线程

Java内存模型(Java Memory Model),简称JMM,它所描述的是多线程并发、CPU缓存等方面的内容。
在计算机中,CPU的运算速度远远超于内存读写的速度,如果CPU直接操作主内存,那么会因为内存读写速度影响到整个的执行效率,所以会在CPU中添加一个高速缓存(cache)来作为缓冲,执行任务时,CPU会先将运算所需要使用到的数据复制到高速缓存中,让运算能够快速进行,运算完成后,再将缓存中的结果刷回(flush back)到主内存。

7.1可能的问题

7.1.1缓存一致性问题

每个处理器都有自己的高速缓存,同时又共同操作同一块主内存,当多个处理器同时操作主内存时,可能导致数据不一致,这就是缓存一致性问题。
比如下面的代码运行时,每个CPU在某一时刻都能运行一个线程,那么就可能存在多个线程在同一时刻被不同的CPU执行,比如t1线程和t2线程同时拿到的x为0,然后t1执行加1之后,x为1,但是还没有写入到主内存,这时t2执行加1,是在0的基础上加1,所以x还是为1。

    private int x = 0;

    {
        Runnable r = new Runnable() {
            @Override
            public void run() {
                x = x + 1;
                System.out.print("x=" + x);
            }
        };
        Thread t1 = new Thread(r);
        Thread t2 = new Thread(r);
        Thread t3 = new Thread(r);
        t1.start();
        t2.start();
        t3.start();
    }

7.1.2指令重排

除了缓存一致性问题,还存在另外一种硬件问题,指令重排。为了使CPU内部的运算单元能够尽量被充分利用,处理器可能会对输入的字节码指令进行重排序处理,也就是处理器优化。除了CPU外,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器JIT也会做指令重排。
如果任由CPU或者编译器指令重排,那么编写出来的代码最终执行效果可能会极大的出乎意料。为了解决这个问题,让Java代码在不同硬件、不同操作系统中,输出结果一致,Java虚拟机规范提出了一套机制,Java内存模型。

7.2Java内存模型

7.2.1什么是内存模型?

内存模型是一套共享内存系统中多线程读写操作行为的规范,这套规范屏蔽了底层各种硬件和操作系统的内存访问差异,解决了CPU多级缓存、CPU优化、指令重排等导致的内存访问问题,从而保证Java程序(尤其是多线程程序)在各种平台下对内存的访问效果一致。
在Java内存模型中,我们统一用工作内存来当作CPU中寄存器或高速缓存的抽象。线程之间的共享变量存储在主内存中,每个线程都有一个私有工作内存(类似CPU中的寄存器或者高速缓存),本地工作内存中存储来该线程读/写共享变量的副本。

7.2.2 happens-before先行发生原则

happens-before用于描述两个操作的内存可见性,通过保证可见性的机制可以让应用程序免于数据竞争干扰。它的定义为:如果一个操作A happens-before另外一个操作B,那么A的执行结果将对操作B可见。
也可以理解为:如果操作A的结果需要对操作B可见,那么操作A必须happens-before操作B。

7.2.3 happens-before原则规则

1.程序次序规则
在单线程内部,如果一段代码的字节码顺序也隐式符合happens-before原则,那么逻辑顺序靠前的字节码执行结果一定是对后续逻辑字节码可见,只是后续逻辑中不一定用到而已。比如下面的代码:

int a = 10;//1
b = b + 1;//2

当代码执行到2处时,a = 10这个结果已经是公之于众的,至于有没有用到这个结果不一定,上面的代码就没有用到,说明b对这个结果没有依赖,这样就有可能发生指令重排。改为下面的代码后,就不会发生指令重排。

int a = 10;//1
b = b + a;//2

2.锁定规则
无论是在单线程环境还是多线程环境,一个锁如果处于被锁定状态,那么必须先执行unlock操作后才能进行lock操作。

3.变量规则
volatile保证了线程可见性。通俗讲就是如果一个线程先写了一个volatile变量,然后另外一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。

4.线程启动规则
Thread对象的start方法先行发生于此线程的每一个动作。假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程B对线程A启动B之前的操作可见。

5.线程中断规则
对线程interrupt()方法的调用先行发生于被中断线程的代码检测,直到中断事件的发生。

6.线程终结规则
线程中所有的操作都发生在线程的终止检测之前,我们可以通过Thread.join()方法结束、Thread.isAlive()方法的返回值,检测线程是否终止执行。假定线程A执行的过程中,通过调用ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。

7.对象终结规则
一个对象的初始化完成在它的finalize()方法开始前。

8.传递规则
如果操作A先行发生于操作B,操作B先行发生于操作C,则操作A先行发生于操作C。

7.3Java内存模型应用

上面介绍的happens-before先行发生原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,根据这个原则,我们能够解决在并发环境下,操作之间是否可能存在冲突的所有问题。还可以通过Java提供的一系列关键字,将多线程操作“happens-before化”。
比如下面的代码,如果存在多线程操作,那么肯定是不符合先行发生原则的,可以通过上述的规则2或者规则3实现先行发生原则。

private int i = 0;
public void write(int j) {
    i = j;
}
public int read() {
    return i;
}

//锁定规则
private int i = 0;
public void write(int j) {
    synchronized {
        i = j;
    }
    
}
public int read() {
    synchronized {
        return i;
    }
}

//变量规则
private volatile int i = 0;
public void write(int j) {
    i = j;
}
public int read() {
    return i;
}

7.4小结

Java内存模型来源:主要是因为CPU缓存和指令重排等优化会造成多线程程序结果不可控。
Java内存模型是什么:本质上它是一套规则,在这套规范中有一条最重要的happens-before原则(先行发生原则)。
最后是Java内存模型的使用。

8 synchronized和ReentrantLock

8.1 synchronized

synchronized可以用来修饰以下3个层面:
1.修饰实例方法;
2.修饰静态类方法;
3.修饰代码块。

synchronized修饰实例方法:

public class Main {
    private int sum = 0;
    public synchronized void calc() {
        sum ++;
    }
}

这种情况下的锁对象是当前实例对象,因此只有一个实例对象调用此方法才会产生互斥效果;

synchronized修饰静态类方法:

public class Main {
    private int sum = 0;
    public static synchronized void calc() {
        sum ++;
    }
}

这种情况下,锁对象是当前类的Class对象,因此即时在不同线程中调用不同实例对象,也会产生互斥效果。

synchronized修饰代码块:

public class Main {
    private int sum = 0;
    private Object lock = new Object();
    public void calc() {
        synchronized(lock) {
            sum++;
        }
    }
}

synchronized修饰代码块时,锁对象就是跟在后面括号中的对象。

实现细节:
synchronized作用于方法和代码块时,在实现上是有区别的,比如下面代码,使用synchronized作用代码块:

使用javap查看字节码如下,可以看到包含一个monitorenter和两个monitorexit字节码指令。

这是因为虚拟机需要保证当异常发生时,也能释放锁。因此这两个monitorexit一个是代码正常执行结束后释放锁,一个是在代码执行异常时释放锁。

再看synchronized修饰方法:

被synchronized修饰的方法在编译为字节码后,在方法的flags属性中会被标记为ACC_SYNCHRONIZED标志,当虚拟机访问一个被标记为ACC_SYNCHRONIZED的方法时,会自动在方法的开始和结束(正常或异常)位置添加monitorenter和monitorexit指令。

8.2 ReentrantLock

8.2.1基本使用

ReentrantLock的使用同synchronized有所不同,它的加锁和解锁操作都需要手动完成,试例代码如下:

    int sum = 0;
    private ReentrantLock reentrantLock = new ReentrantLock();

    private void calc() {
        reentrantLock.lock();
        try {
            sum++;
        } catch (Exception e) {

        } finally {
            reentrantLock.unlock();
        }
    }

使用ReentrantLock也能实现synchronized相同的效果。可以注意到,代码中的解锁操作放在finally代码块中,这是因为当异常发生时,synchronized会自动释放锁,ReentrantLock不会,因此把解锁操作放在finally代码块中,保证任何时候锁都能够正常释放掉。

8.2.2公平锁

默认情况下,synchronized和ReentrantLock都是非公平锁,但是ReentrantLock可以通过传如true参数来创建一个公平锁。所谓公平锁就是通过同步队列来实现多个线程按照申请顺序获取锁。更多公平锁和非公平锁可以参考这里

8.2.3读写锁(ReentrantReadWriteLock)

开发中,我们需要定义一个线程间共享的用作缓存的数据结构,比如一个较大的Map,缓存中保存来全部城市ID和城市name对应关系,这个Map绝大部分时间提供读服务,而写操作占有的时间很少,通常是在服务启动时初始化,然后可以每隔一定时间再刷新缓存数据,但是写操作开始到结束之间,不能再有其他读操作进来,并且写操作完成之后的更新数据需要对后续的读操作可见。
在没有读写锁支持的时候,如果想要完成上诉工作,就需要使用Java的等待通知机制,就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知后,所有等待的读操作才能继续执行。这样做的目的是使读操作能读取到正确的数据,不会出现脏读。
但是如果使用读写锁(ReentrantReadWriteLock)实现上述功能,就只需要在读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续的读写锁都会被阻塞,写锁释放之后,所有操作继续执行,这种编程方式相对于使用等待通知机制的实现而已,简单明了。

试例代码:

    private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
    private ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();

    private void read() {
        readLock.lock();
        try {
            //do something
        } finally {
            readLock.lock();
        }
    }

    private void write() {
        writeLock.lock();
        try {
            //do something
        } finally {
            writeLock.unlock();
        }
    }

1.创建读写锁对象;
2.通过读写锁对象,分别获取读锁(ReadLock)和写锁(WriteLock);
3.使用读锁同步缓存的读操作,使用写锁同步缓存的写操作。

8.3小结

Java有两个实现同步的方式,synchronized和ReentrantLock,其中synchronized使用简单,加锁和释放锁都是由虚拟机自动完成,而ReentrantLock需要开发者手动完成,但是显然ReentrantLock的使用场景更多,公平锁和读写锁在一些复制场景中发挥重要作用。

9 Java线程优化,偏向锁、轻量级锁、重量级锁

10深入理解AQS和CAS原理

11.线程池刨根问底

创建和销毁线程非常损耗性能,线程池是为了能够复用已经创建好的线程。主要解决两个问题:
1.当执行大量异步任务时,线程池能够提供很好的性能;
2.线程池提供略一种资源限制和管理的手段,比如可以限制线程的个数,动态新增线程等。

创建线程池
JDK提供略一个线程池的工厂类-Executors,它包含了多个静态方法,用于创建不同配置的线程池。常用的有:
1.newSingleThreadExecutor,创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按先进先出的顺序执行。
2.newCachedThreadPool,创建一个可缓存线程池,如果池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
3.newFixedThreadPool,创建一个固定数目的、可重用的线程池。
4.newScheduledThreadPool,创建一个定时线程池,支持定时及周期性任务执行。

线程池参数分析
线程池构造器如下:

参数说明:
corePoolSize:表示核心线程数量。
maximumPoolSize:表示线程池最大能够容纳同时执行的线程数,必须大于等于1。
keepAliveTime:表示线程池中的线程空闲时间,当空闲时间到达此值时,线程会被销毁直到剩下核心线程数。
unit:用来指定keepAliveTime的时间单位。
workQueue:等待队列,BlockingQueue类型。当请求任务数大于corePoolSize时,任务将被缓存在此。
threadFactory:线程工厂,线程池中使用它来创建线程,如果传入的是null,则默认使用工厂类DefaultThreadFactory。
handler:执行拒绝策略的对象。当workQueue满类之后并且活动线程数大于maximumPoolSize的时候,线程池通过该策略处理请求。

执行流程
当我们使用execute或者submit,将一个任务提交给线程池,线程池收到任务请求后,会有以下几种处理情况:
1.当前线程池中运行的线程数量还没有达到核心线程数,直接创建一个新线程执行任务,无论之前创建的线程是否处于空闲状态。
2.当前线程池中运行的线程数量已经达到核心线程数(非0),线程池会把任务加入到等待队列中,直到某一个线程空闲了,线程池会根据设置的等待队列规则,从队列中取出一个新的任务执行。
3.如果线程数大于核心线程数但是小于最大线程数,并且等待队列已满,则线程池会创建新的线程来执行任务。
4.如果提交的任务,无法被核心线程直接执行,又无法加入等待队列,又无法创建新的非核心线程,线程池会根据拒绝处理器定义的策略处理这个任务。比如在ThreadPoolExecutor中,如果没有为线程池设置RejectedExcutionHandler,会抛出RejectExecutionException异常。

12.DVM以及ART是如何对JVM进行优化的

DalVik是Google公司自己设计用于Android平台的Java虚拟机,Android工程师编写的Java或者kotlin代码最终都是在这台虚拟机中被执行的。Android5.0之前叫DVM,之后叫ART(Android Runtime)。
ART和DVM主要有4个区别:
1.DVM中的应用每次运行时,字节码都需要通过JIT编译器编译为机器码,这使得应用程序运行效率低,而在ART中,系统在安装应用程序时会进行一次AOT预编译,将字节码预先编译为机器码并存储在本地,可以大大提升运行效率;
2.DVM是为32位CPU设计的,而ART支持64位并兼容32位CPU;
3.ART对垃圾回收机制进行来改进,比如更频繁地执行并行垃圾收集,将GC暂停由两次减少为1次;
4.ART的运行时堆空间划分与DVM不同。
在整个Android操作系统体系中,ART位于图中红框位置:

DVM大多数实现与传统的JVM相同,但是因为Android最初是被设计用于手机端的,对内存空间要求较高,并且起初Dalvik目标是只运行在ARM架构的CPU上。所以DVM又自己独有的优化措施。

12.1Dex文件

传统Class文件是由一个Java源码文件生成的.class文件,而Android是把所有Class文件进行合并优化,然后生成一个最终的.dex文件,dex文件去除来class文件中的冗余信息(比如重复字符常量),并且结构更加紧凑,因此在dex解析阶段,可以减少IO操作,提高类的查找速度。

12.2架构基于寄存器&基于堆栈结构

JVM的指令集是基于堆栈结构来执行的,而Android却是基于寄存器的,不过不是直接操作硬件的寄存器,而是在内存中模拟一组寄存器。Android字节码和Java字节码完全不同,Android的字节码(smali)更多的是二地址指令和三地址指令,具体Dalvik指令可以参考官网Dalvik字节码
具体看一下Dalvik和JVM字节码的区别,比如如下的add方法:

经过编译为class之后,查看其字节码为下图:

add方法会使用4行指令来完成。而通过dx将其优化为dex文件后,再次查看字节码如图:

add-int指令需要3个寄存器参数,v0、v2,v3,指令会将v2和v3进行相加运算,然后将结果保存在寄存器v0中,return指令将结果返回。
可以看出,Dalvik字节码只需要2行指令。基于寄存器的指令明显会比基于栈的指令少,虽然增加来指令长度,但是缩减来指令数量,总体的执行速度会更快。

12.3内存管理与回收

DVM与JVM另一个比较显著的不同就是内存结构的区别,主要体现在对“堆”内存的管理。Dalvik虚拟机中的堆被划分为来2部分:Active Heap和Zygote Heap。