计算机到目前为止,都只能识别0和1,所以我们写的程序都需要被编译器翻译成0和1的二进制格式才能被计算机执行。很多程序语言选择了与操作系统和机器指令集无关的、平台中立的格式作为程序编译后的格式,而不再需要像最初那样把程序编译成二进制本地机器码。Oracle公司发布的一些虚拟机可以载入和执行同一种平台无关的字节码,从而实现了程序的“一次编写,到处运行”。
各种不同平台的Java虚拟机,以及所有平台都统一支持的程序存储格式-字节码(Byte Code)是构成平台无关性的基石,设计者最初考虑过其他语言在Java虚拟机上运行的可能,所以在发布规范文档的时候,刻意把Java的规范拆分成了《Java语言规范》及《Java虚拟机规范》。现目前Kitlin/JRuby、JPython都可以运行在java虚拟机之上。
实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机不与包括Java语言的任何程序语言绑定,它只与“class”这种二进制文件格式所关联,Class文件中包含了Java虚拟机指令集、符号表以及若干其他辅助信息。《Java虚拟机规范》要求在Class文件必须应用许多强制性语法和结构化约束。使用java编译器可以把java代码编译为存储字节码的Class文件,使用JRuby等其他编译器一样可以把它们的源程序代码编译成Class文件。
任何一个Class文件都对应着唯一一个类或者接口的定义信息,但是,类或接口并不一定都得定义在Class文件里(比如类或者接口也可以动态生成,直接送入类加载器中)。Class文件是一组以8个字节为单位的二进制流,各个数据严格按照顺序紧凑的排列在文件之中,这使得整个class文件中存储的内容几乎全是程序运行的必要数据。当遇到需要占用8个字节以上的数据时,高位字节放在地址最低位,最低字节放在最高位来存储。
class文件采用类似C语言结构体的伪结构来存储数据,这种伪结构只有两种数据类型:"无符号数"和表。
无论是无符号数还是表,当需要描述同一类型但是数量不定的多个数据时,经常使用一个前置容器计数器加若干个连续数据项的形式,这个时候称这是某一类型的“集合”
每个class文件的头四个字节被称为魔数,它的唯一作用是确定这个文件是否为一个能被虚拟机接受的class文件。class文件的魔术是0xCAFEBABE(咖啡宝贝),这个也是Java商标的由来。
紧接着魔数的四个字节是存储的Class的版本号,第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version),高版本JDK能向下兼容低版本的Class,但是不能向上。
紧接着主、次版本号之后是常量池入口,常量池也可以比喻为class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据。通常也是占用class文件空间最大的数据项目之一,它也是class文件中第一个出现表类型的数据项目。
由于常量池中的常量数量是不固定的,所以在常量的入口放置一项u2类型的数据,代表常量池容量计数值。常量池容量从1开始计数,比如0x00000008为16进制的0x16,即十进制的22,这代表常量池中有21项常量,索引范围为1~21。常量池的第0项空出来表达为,如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,就可以把索引值设置为0来表示。
常量池主要存放两大类常量:字面量和符号引用。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念:
Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是虚拟机加载Class文件的时候进行动态连接。也就是说,在class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址。当虚拟机在做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析和翻译到具体的内存地址中。
常量池中每一项常量都是一个表,最初常量表中共有11中结构各不相同的表结构数据,截止JDK13,常量表中有17中不同类型的常量。这17类表都有一个共同点,表结构起始的第一位是U1类型的标志位,代表着当前常量属于哪一种类型。
之所以说常量池是最繁琐的数据,因为这17中常量有着完全独立的数据结构。比如说标志位为0x07(偏移地址0x0000000A),它是CONSTANT_CLASS_info类型,这个类型的常量代表一个类或者接口的符号引用。它的结构比较简单:
类型 | 名称 | 数量 |
u1 | tag | 1 |
u2 | name_index | 1 |
tag是标志位,他用于区分常量类型。name_index是常量池的索引值,它指向了常量池中一个CONSTANT_Utf8_info类型常量,此常量代表了这个类(或者接口)的全限定名。比如name_index为0x00002,也就是指向了常量池中的第二个常量。而查看第二项常量它的标志位为0x01,是一个CONSTANT_Utf8_info类型的常量。CONSTANT_Utf8_info类型的结构表如6-5所示:
类型 | 名称 | 数量 |
u1 | tag | 1 |
u2 | length | 1 |
u1 | bytes | length |
length值说明了这个UTF-8编码的字符串长度是多少字节,它后面紧跟着长度为length字节的连续数据是一个使用UTF-8缩略编码表示的字符串。
由于Class文件中的方法、字段都需要引用CONSTANT_Utf8_info常量来描述名称,所以CONSTANT_Uft8_info型常量的最大长度也就是java中方法、字段名的最大长度。而这里的最大长度length的最大值,也就是u2类型能表达的最大值65535,所以java中定义了超过64kb的英文字符的变量或者方法名,就算规则合法,也会无法编译。
在常量池结束之后,紧接着2个字节代表访问标志(access_flags), 比如这个Class的类还是接口,是否定义为public类型,是否定义为abstract类型,如果是类的话,是否被声明为final。
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据集合,class文件中有这三项数据来确定该类型的继承关系。类索引确定了这个类的全限定名,父类索引确定了这个类的父类全限定名称,接口索引集合用来描述这个类实现了哪些接口,这些被实现的接口将按implements关键字(如果这个class文件表示的是一个接口,则应当是extends关键字)后的接口顺序从左到右排列在接口索引集合中。对于接口索引集合,入口第一项是u2类型的数据为接口计数器。
字段表用于描述接口或者类中声明的变量。这个字段包括类级变量和实例变量,但不包括在方法内部声明的局部变量。字段可以包括的修饰符有: 作用域(public,private,protected)、是实例变量还是类变量(static),可变性(final),并发可见性(volatile)、可否被序列化(transient)、字段数据类型(基本类型、对象、数组)、字段名称。 这些修饰符都是bool值,要么有要么没有,使用标志位标识。而字段叫做什么名字、字段的数据类型这些都是无法固定的,只能用常量池中的常量来描述。字段表结构如下:
类型 | 名称 | 数量 |
u2 | access_flags | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
字段表集合,第一个u2类型的数据为容量计数器fields_count,紧接着是access_flags标志。
access_flags,通过标志值来表达是否是 该修饰符,比如ACC_PUBLIC,标志值是0x0001,字段是否是public。
name_index,简单名称索引,指的是没有类型和参数修饰的方法或者字段名称。
descriptor_index,方法和字段的描述符指向常量池的索引,描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。基本数据类型以及代表无返回值的void类型都是用一个大写字符,比如byte用B表示。对象类型则用字符L加对象的全限定名来表示。数组类型 比如 “java.lang.String[][]”,将被记录成“[[Ljava/lang/String;”。 int indexOf(char[] source,int sourceOffset,int sourceCount,char[] target, int targetOffset, int targetCount, int fromIndex)的描述符为“([CII[CIII)I”
attribute_info,属性表集合(后文会介绍),存储属性等,
字段表集合不会列出从父类或者父接口中继承而来的字段。
方法表结构和字段表结构一样。方法表里的java代码,经过javac编译成字节码后,存放在属性表集合中一个名为“Code”的属性表里面。
类型 | 名称 | 数量 |
u2 | access_flags | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
access_flag,方法表的访问标志(public,private,static,synchronized,native,abstract.....等等)
attribute_info,属性表,本质也是一张表(包含多个无符号数,比如attribute_name_index等),属性名称(attribute_name_index)的索引值如果为(0x0009),对应的常量为“Code”,说明此属性是方法的字节码描述
attribute_info 属性表,Class文件、字段表、方法表可以携带自己的属性表集合,来描述某些场景专有的信息。属性表的结构不固定,只要不和已有属性名称重复,任何编译器都可以向属性表中写入自己定义的属性信息。Java虚拟机运行时会忽略掉它不认识的属性,《Java虚拟机规范》现目前已经预定义了很多属性。
类型 | 名称 | 数量 |
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u1 | info | attribute_length |
对于每个属性,它的名称都要从常量池中引用一个CONSTANT_Utf8_info类型常量来表示。而属性值值的结构则是完全没有定义的,只需要通过u4的长度属性去说明属性所占位数即可。
1.code属性
类型 | 名称 | 数量 | 解释 |
u2 | attribute_name_index | 1 | 指向CONSTANT_Utf8_info常量的索引,此常量值固定为“Code” |
u4 | attribute_length | 1 | 属性值的长度,由于属性名称索引与属性长度一共为6个字节,所以属性值的长度固定为整个属性表的长度减去6个字节 |
u2 | max_stack | 1 | 代表了操作数栈深度的最大值,虚拟机在运行时要根据这个值来分配栈帧中的操作栈深度(Java栈的最大深度) |
u2 | max_locals | 1 | 局部变量表所需的最大空间,单位是变量槽,对于byte、char、int、short、boolean和returnAddress等长度不超过32位的数据类型,每个局部变量占用1个变量槽,而double和float则需要两个变量槽来存放。方法参数(包括实例方法的隐藏参数this)、显示异常处理程序的参数(也就是try-catch中catch所定义的异常)、方法体中定义的局部变量都需要依赖局部变量表。并不是局部变量的变量槽数量作为max_locals的值,局部变量表中的变量槽会重用,当代码执行超出了一个局部变量的作用域,则这个局部变量所占变量槽可以被其他局部变量使用。Javac根据同时生存的最大局部变量数和类型计算出max_locals的大小 |
u4 | code_length | 1 | 理论最大值可以是2的32次幂,但是实际只用了u2的长度 |
u1 | code | code_length | 存储Java源程序编译后生成的字节码指令 |
u2 | exception_table_length | 1 | |
exception_info | exception_table | exception_table_length | 异常表,用于处理显示异常,try,catch |
u2 | attributes_count | 1 | |
attribute_ifno | attributes | attributes_count |
2、Exceptions属性,这个处理方法描述时throws关键字后面列举的异常
3、LineNumberTable属性,描述Java源码行号与字节码行号之间的对应关系
4、ConstantValue属性,通知虚拟机自动为静态变量赋值。只有被static修饰的类变量才可以使用这项属性,类似“int x=123,和static=123”这样的,对于非static类型变量(实例变量)的复制是在实例构造器
5、Signature属性,可以出现在类、字段表和方法表结构的属性表中。JDK5出现的泛型,任何类、接口、初始化方法、或成员的泛型前面如果包含了类型变量或者参数化类型,则Signature属性会记录泛型签名信息。之所以要专门使用这样一个属性去记录泛型类型,因为Java泛型采用的擦除法实现的伪泛型,字节码(Code属性)中所有的泛型信息编译之后通通被擦除掉,这样在运行期可以节省一些类型所占的内存空间,但是在运行期做反射无法获得泛型信息,Signature就是弥补这而存在的,Java反射API获得泛型类型,就是通过这个属性得到的。
6、BootstrapMethods属性,JDK7时增加到Class文件规范的,它是一个变长属性,这个属性用于保存invokedynamic指令引用的引导方法限定符,《java虚拟机规范》规定,如果某个类文件结构的常量池中曾经出现过CONSTANT_InvokeDynamic_info类型的常量,那么这个类文件的属性表中必须存在一个明确的BootstrapMethods属性。
类型 | 名称 | 数量 | 解释 |
u2 | attribute_name_index | 1 | |
u4 | attribute_length | 1 | |
u2 | num_bootstrap_methods | 1 | |
bootstrap_method | bootstrap_methods | num_bootstrap_methods |
bootstrap_methods[]数组的每个成员,包含了一个指向常量池CONSTANT_MethodHandle结构的索引值,它代表一个引导方法,还包含了这个引导方法的静态参数列表(可能为空)。
类型 | 名称 | 数量 | 解释 |
u2 | bootstrap_method_ref | 1 | 指向常量池中引导方法的索引值 |
u2 | num_bootstrap_arguments | 1 | 引导方法的静态参数列表,可能为空 |
u2 | bootstrap_arguments | num_bootstrap_arguments | 对常量池的有效索引,而且常量池在该索引处的值必须是一下结构:CONSTANT_String_info、CONSTANT_Class_info、CONSTANT_Integer、CONSTANT_Long_info、CONSTANT_Float_info、CONSTANT_Double_info、 CONSTANT_MethodHandle_info、 CONSTANT_MethodType_info |
7、模块化相关属性
Java虚拟机指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码)以及跟随其后的零至多个代表此操作所需的参数)(称为操作数)构成。Java虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。
字节码指令集可算一种鲜明特点的指令集架构,由于操作码只有一个字节,这意味着指令集的操作码总数不能够超过256条;又由于Class文件格式放弃了编译后代码的操作数长度对齐,意味着要处理超过一个字节的数据时,不得不在运行时从字节中重建出具体数据的结构。比如一个16为长度的无符号整数,使用两个无符号字节存储起来。这种操作会导致解释执行字节码时将损失一些性能,放弃了操作数长度对齐,意味着可以省略掉大量的填充和间隔符号。这种追求尽可能小数据量、高传输效率。
如果不考虑异常出来,Java虚拟机的解释器可以用下面这段伪代码作为最简单的执行模型:
do{
自动计算PC寄存器的值加1;
根据PC寄存器指示的位置,从字节码流中取出操作码;
if(字节码存在操作数) 从字节码流种取出操作数;
执行操作码所定义的操作;
} while(字节码流长度>0);
在Java虚拟机的指令集中,大多数指令都包含了其操作所对应的数据类型信息。比如iload用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。下图是Java虚拟机指令集所支持的数据类型。
大部分指令都没有支持类型byte、char、short 甚至没有任何指令支持boolean类型。编译器会在编译器或者运行期将byte和short类型的数据带符号扩展为相应的int类型数据。将boolean和char类型将数据零位扩展为相应的int类型数据。与之类型,在处理boolean、byte、short和char类型的数组是,也会转换为int对应类型的指令。
加载和存储指令 :加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。存储数据的操作数栈和局部变量表主要由加载和存储指令进行操作。
运算指令:对整形数据进行运算的指令和堆浮点类型数据进行运算的指令。
类型转换指令:可以将两种不同的数值类型相互转换,这些转换操作一般用于实现用户代码中显示类型的转换操作。java虚拟机直接支持(即转换时无须显示的转换指令)以下数值类型的宽化类型转换(小范围类型到大范围类型的安全转换):
int到long、float、double
long类型到double、float
float到double。
处理窄化类型转换,需要显示的指令来转换,并且可能会导致不同正负号,不同数量级的情况,也可能导致精度丢失。
对象创建以及访问指令:
操作数栈管理指令:
控制转移指令:
方法调用和返回指令:
异常处理指令:
同步指令:
java虚拟机支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,更常见的是直接将它称为“锁”)来实现。
方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作中。虚拟机从方法常量池中的方法结构表中的ACC_SYNCHRONIZED的访问标志得知是否需要同步,如果设置了,在方法调用时,执行线程就要求先成功持有管程才能执行方法,最后当方法完成(无论正常完成还是异常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法在获取同一个管程。如果一个同步方法在执行期间抛出了异常,并且方法内部无法处理该异常,那么这个方法持有的管程将在异常抛到同步方法边界之外时自动释放。
同步一段指令集序列,通常是由java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字语义,然后需要javac编译器和java虚拟机两者共同协作支持。方法中调用过的每条monitorenter都必须有其对应的monitorexit指令,无论这个方法是正常还是异常结束。为了保证方法异常完成时,monitorenter和monitorexit可以正确配对执行,编译器会自动产生一个异常处理程序,这个程序可以处理所有异常,它的目的就是来执行monitorexit指令。
java虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称为虚拟机的类加载机制。
在Java中,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略会让提前编译面临困难,也会让类加载时增加性能开销,却为Java应用提供了极高的扩展性和灵活性。Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接实现的。例如 可以等到运行时再指定具体的实现类,用户可以通过Java预置的或自定义的类加载器,让某个本地应用在运行时加载一个二进制流作为程序代码的一部分。这种动态组装从最基础的Applet、jsp到相对复杂的OSGI技术,都依赖着Java运行期类加载才得以诞生。
从一个类型从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期将经历 加载、验证、准备、解析、初始化、使用和卸载七个阶段。验证、准备、解析 这三个部分统称为连接。
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,必须按照这种顺序按部就班的开始(只是开始顺序必须按照这个顺序,并不是按部就班的完成,这些阶段通常会互相交叉的混合着进行,会在一个阶段执行的过程中调用、激活另外一个阶段)。解析阶段不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称动态绑定或者晚期绑定)。
初始化阶段,《Java 虚拟机规范》严格规定,有且只有六种情况,如果类型没有初始化则需要先触发其初始化(而加载、验证、准备自然要在此之前开始),而且是必须立即对类进行“初始化”:
这六类被称为对一个类型进行主动引用,除此之外所有引用类型的方式都不会触发初始化,称为被动引用。
下面看被动引用的三个例子
通过子类引用父类的静态字段,不会导致子类初始化,只会导致父类初始化(构造函数的调用,那是类的实例化)
public class SuperClass1 {
static {
System.out.println("SuperClass init");
}
public static int value = 123;
}
public class SubClass extends SuperClass1 {
static {
System.out.println("SubClass init");
}
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
//SuperClass1[] sca = new SuperClass1[10];
//System.out.println(ConstClass.HELLOWORLD);
}
}
测试结果:
SuperClass init
123
通过数组定义引用类,不会触发此类的初始化,从测试结果可以看到没有“SuperClass init”,说明并没有触发SuperClass初始化
public class SuperClass1 {
static {
System.out.println("SuperClass init");
}
public static int value = 123;
}
public class SubClass extends SuperClass1 {
static {
System.out.println("SubClass init");
}
}
public class NotInitialization {
public static void main(String[] args) {
SuperClass1[] sca = new SuperClass1[10];
}
}
测试结果:
常量在编译阶段会存入调用类的常量池中,本质上没有引用到定义常量的类,因此不会触发定义常量类的初始化(如果HELLOWORLD去掉final修饰符,则会触发)
public class ConstClass {
static {
System.out.println("ConstClass init");
}
public static final String HELLOWORLD = "hello world";
}
public class ConstClassNotFinal {
static {
System.out.println("ConstClassNotFinal init");
}
public static String HELLOWORLD = "hello world";
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);
}
}
测试结果:
hello world
ConstClassNotFinal init
hello world
接口的加载过程与类加载过程稍有不同。接口中不能使用static{} 语句块,但是编译器仍然会为接口生成“
加载是整个类加载过程中的一个阶段,在加载阶段,Java虚拟机需要完成以下三件事:
通过一个类的全限定名来获取二进制字节流,并没有指明二进制字节流必须得从某个Class中获取。可以从以下:
非数组类型的加载阶段(准确的说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的阶段。可以用虚拟机内置的引导类加载器完成,也可以由用户自定义的类加载器完成,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的findClass()或者loadClass()方法),实现根据自己的想法来赋予应用程序获取运行代码的动态性。
对于数组类而言,数组本身不通过类加载器创建,它是由JVM直接在内存中动态构造出来的,创建过程遵循以下规则:
Java语言本身是相对安全的编程语言,使用纯粹的Java代码无法做到访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情。上述Java代码无法做到的事情,在字节码层面都是可以实现的。所以如果Java虚拟机不检查输入的字节流,可能会载入错误或者恶意企图的字节码而导致整个系统出问题,所以验证字节码是Java虚拟机保护自身的必要措施。
验证阶段是非常重要和严谨的,验证过程非常复杂,但是验证阶段大致上会完成四个阶段的检验动作:文件格式验证、元数据验证(java程序的信息分为Code(方法体的代码)和元数据(包括类、字段、方法定义以及其他信息))、字节码验证和符号引用验证。
1、文件格式验证(只有通过这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的)
2、元数据验证(这个阶段主要进行语义校验)
3、字节码验证(是验证过程中最复杂的阶段,主要是通过数据流分析和控制流分析,确定程序语义是否合法复合逻辑,这个阶段还会对类的方法体进行校验分析),由于数据流分析和控制流分析的高度复杂性,Java虚拟机设计团队为 避免执行时间消耗在字节码验证阶段,在JDK6之后的Javac编译器和Java虚拟机里进行了一项联合优化,把尽可能多的校验辅助措施挪到JavaC编译器里面进行,具体做法是在Code属性的属性表中新增加了“StackMapTable”新属性,这项属性描述了方法体所有的基本块(按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态。在字节码验证时期,JVM就不需要根据程序推导这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可,这样就将字节码验证的类型推导转变为类型坚查,从而节省大量时间。
4、符号引用验证,发生在符号引用转化为直接引用的时候,这个转化动作发生在解析阶段。通俗来说,就是该类是否确实或者被禁止访问它依赖的某些外部类、方法、字段等资源
符号引用验证的主要目的是确保解析行为能正常执行,如果程序运行的全部代码都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。从概念上来说这些变量所使用的内存应当在方法区中分配,JDK7之前,HotSpot使用永久代来实现方法区时,是正确的;但是JDK7及之后,类变量则会随着Class对象一起存放在Java堆中。
这时候进行的内存分配仅包括类变量,不包括实例变量,其次这里所说的初始值“通常情况”是数据类型的零值,假设 一个类变量定义为 public static int value=123; 那么这个变量value在准备阶段过后的初始值为0而不是123。这里的通常情况是指,如果类字段的字段属性表中存在ConstantValue属性,那么在准备阶段过后,变量就被初始化为ConstantValue属性所指定的初始值,比如 public static final int value=123,编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123.
解析阶段是java虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、 CONSTANT_Methodref_info等类型的常量出现。
符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。各种虚拟机实现的内存布局各不相同,但是他们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
直接引用:可以是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
如果有了直接引用,那么引用目标必定已经在虚拟机中的内存中存在。对一个符号引用进行多次解析请求是很常见的,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存。对于invokedynamic指令,当碰到某个前面已由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对其他invokedynamic指令同样生效。因为invokedynamic指令的目的本来就是用于动态语言支持(当时做只是为了做动态语言(JRuby,SCala)支持,而到了jdk8,有了lambda表达式和接口的默认方法,它们在底层调用时,就会用到invokedynamic。),必须等到程序实际运行到这条指令时,解析动作才能进行。相对的,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有开始执行代码时就提前进行解析。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,分别对应常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、
CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dynamic_info和CONSTANT_InvokeDynamic_info 8中常量类型。后面四种都和动态语言支持密切相关,现在解释前面四种。
1、类或者接口的解析
假设当前代码所处类为D,如果要把一个从未解析过的符号引用N解析为一个类或者接口C的直接引用,需要包括以下3个步骤:
1) 如果C不是一个数组类型,JVM会把代表N的全限定名传给D的类加载器去加载这个类C,由于元数据的验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或者其他实现的接口
2) 如果C是一个数组类型,并且数组的元素类型为对象,比如N的描述符是“[Ljava/lang/Integer”,那将会按照第一点规则加载数组元素类型,需要加载的元素类型为“java.lang.Integer”,接着由虚拟机生成一个代表该数组维度和元素的数组对象。
3) 如果上面两步没有异常,那么C在虚拟机已经成为一个有效的类或者接口了,但是在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。
针对第3点,在JDK9引入模块化以后,一个public类型也不在意味着程序任何位置都有它的访问权限,我们还必须坚持模块间的访问权限。如果我们说D拥有C的访问权限,那就意味着以下3条规则中至少有其中一条成立:
2、字段解析
要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果解析成功,那么把该字段所属的类或者接口用C表示,按照《Java虚拟机规范》要求如下步骤对C进行后续字段的搜索:
1) 如果C本身就包含了简单名称和字段描述符都与目标匹配的字段,这返回这个字段的直接引用,查找结束。
2)否则,如果在C中实现了接口,则会从下往上递归搜索其父接口,如果父类中包含,则返回直接引用,查找结束
3)否则,如果C不是java.lang.Object的话,会按照集成关系从下往上搜索,如果包含,则返回直接引用,查找结束。
4)否则,查找失败,报错java.lang.NoSuchFieldError异常
如果查找过程中成功返回了引用,则会对这个字段进行权限验证,如果发现不具备对字段的访问权限,则报错java.lang.IllegalAccessError异常。 在实际情况中,JavaC编译器往往更加严格,比如一个同名字段同时出现在某个类的接口和父类当中,或者同时在自己或者父类多个接口中出现,按照解析规则仍是可以唯一确定的,但是JavaC直接拒绝其编译。
3、方法解析
方法解析也是需要先解析出方法表class_index项中索引方法所属的类或接口的符号引用,如果解析成功,将会按照以下步骤进行搜索
1) 由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现class_index索引的c是个接口的话,就直接报错java.lang.IncompatibleClassChangeError异常
2)如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,有则返回直接引用,查找结束
3)否则,在类C父类中递归查找,有则返回,查找结束
4)否则,在类C实现的接口列表及他们的父接口中递归查找,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出java.lang.AbstractError异常
5)否则,宣告查找失败,抛出java.lang.NoSuchMethodError。
最后,查找过程中返回了直接引用,将会进行权限验证。
4、接口方法解析
搜索过程如下:
1) 如果class_index索引的是个类而不是接口,报错
2)否则,在接口C中查找,如果有则返回
3)否则,在接口C的父类中递归查找,知道java.lang.Object类,如果有则返回
4)由于接口允许多重继承,如果C查找父接口中存在多个匹配,将会返回其中一个(具体由具体虚拟机实现)
5)否则查找失败,报错NoSuchMethodError异常
在初始化阶段之前,除了加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都是有JVM来主动。直到初始化阶段,Jvm才真正执行类中编写的java程序代码,将主导权交给应用程序。
进行准备阶段的时候,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。 也可以更直接的表达:初始化阶段就是执行类构造器
public class Test {
static{
i=0;
System.out.println(i);
}
static int i=1;
public static void main(String[] args) {
}
}
编译器会提示下面:Cannot reference a field before it is defined
public class Test{
static int i=1;
static{
i=0;
System.out.println(i);
}
public static void main(String[] args) {
}
}
输出结果:0
public class NotInitialization {
static class SuperClass1 {
public static int A = 1;
static {
A = 2;
}
}
static class SubClass extends SuperClass1 {
static {
A = 3;
}
}
public static void main(String[] args) {
System.out.println(SubClass.A);
}
}
输出结果:2(因为父类的静态代码块优先于子类,并且类变量只初始化一次)
public class NotInitialization {
static class SuperClass1 {
public static int A = 1;
static {
A = 2;
}
}
static class SubClass extends SuperClass1 {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(SubClass.B);
}
}
输出结果:2(因为父类的静态代码块优先于子类的类变量初始化,并且类变量只初始化一次)
虚拟机设计团队有意把类加载阶段的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序决定如何去获取所需的类。实现这个动作的代码称为“类加载器”。类加载器在类层次划分、osgi、程序热部署、代码加密等领域大放异彩。
类加载器虽然只用于实现类的加载动作,但是在程序中起到了非常重要的作用。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确定其在jvm的唯一性,每个类加载器都拥有一个独立的类名称空间。比较两个类是否相等,只有这个两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类源于同一个class文件,被同一个jvm加载,但是它们的类加载器不同,那这两个类就必定不相等。
这里所指的“相等”,包括了CLass对象的equals(),isAssignableFrom()方法、isInstatnce()方法、instanceof关键字做对象的所需关系的判定情况。
package erwan.jvm.loadclass;
import java.io.IOException;
import java.io.InputStream;
public class ClassLoaderTest {
public static void main(String[] args) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
ClassLoader myloader = new ClassLoader() {
@Override
public Class> loadClass(String name) throws ClassNotFoundException {
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
}
try {
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};
Object obj = myloader.loadClass("erwan.jvm.loadclass.ClassLoaderTest").newInstance();
System.out.println(obj.getClass());
System.out.println(obj instanceof erwan.jvm.loadclass.ClassLoaderTest);
}
}
输出结果:
class erwan.jvm.loadclass.ClassLoaderTest
false
结论:从第一行结果来这个对象确实是类erwan.jvm.loadclass.ClassLoaderTest实例化出来的,
但是第二行的输出发现jvm中同时存在了两个ClassLoaderTest类,
一个是有jvm应用程序类加载器加载,一个是有我们自定义的类加载器加载,
虽然他们来自同一个class文件,但是是属于两个互相独立的类。
站在Jvm的角度上来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstarp ClassLoader),这个类加载器使用C++语言实现,是虚拟机的一部分;另一种就是其他所有类加载器,这些类都是由Java虚拟机实现,独立于虚拟机外部,并且全部继承自抽象类java.lang.ClassLoader。
JDK8以及之前,未引入模块化系统的时候,是三层类加载器:
JDK9之前,Java应用都是由这三种类加载器配合完成,用户还可以自定义类加载器来进行拓展,典型的如增加除了磁盘位置以外的Class文件来源,或者通过类加载器实现类的隔离、重载等功能。
双亲委派模型,除了顶层的启动加载器以外,其余的类加载器都应有自己的父类加载器,这里的父子关系一般不是以继承关系来实现,而通常使用组合关系来复用父加载器的代码。双亲委派模型的工作过程:如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把请求委派给父类加载器,因此所以加载请求最终都会传到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
这样的好处有:Java类随着它的类架加载器一起具备了一种带有优先级层次关系,因此可以保证类在程序中的各种类加载器环境中都保证是同一个类(比如Object,String,反之用户如果定义了一个Object,并且没有使用双亲委派,系统就会出现多个Object类)。可以简单归纳为避免重复加载 + 避免核心类篡改
双亲委派模型第一次“被破坏”,发生在JDK1.2之前,由于双亲委派模型在JDK1.2之前被引入,但是类加载器很早就存在。所以在引入双亲委派模型的时候,为了兼容已有的代码,无法再以技术手段避免loadClass()被子类覆盖,只能在JDK1.2之后在ClassLoader中添加一个新的protected方法findClass(),并引导用户编写类加载逻辑的时候尽可能重新findClass()方法,而不是去修改loadClass()的代码。如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样既不影响用户按照自己意愿去加载类,又可以保证新写出来的类加载器符合双亲委派
双亲委派的第二次“被破坏”,是这个模型自身的缺陷导致。双亲委派很好地解决各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器加载),基础类型,比如总是被用户代码继承、调用的API,但是基础类型又要调用回用户代码,那怎么办呢,典型的例子就是JNDI服务,JNDI服务已经是Java的标准服务,它的代码有启动类完成加载(JDK1.3时加入rt.jar),肯定是Java中很基础的类型了。但是JNDI的目的就是对资源进行查找和集中管理,它需要调用其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口(Service Provider Interface,SPI)的代码。那么问题来了,启动类加载器绝不可能认识、加载这些代码的,为了解决这个困境。设计团队,只能引入一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader),这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法设置,如果创建线程时未设置,它将会从父线程继承,如果在应用程序的全局范围内都没有设置的话,那这个类加载器默认就是应用程序类加载器。JNDI服务使用这个线程上下文去加载所需的SPI服务代码,就是通过父加载器去请求子加载器完成类加载,这种就是逆向了。Java中涉及SPI加载器都采用这种方式完成,例如JNDI、JDBC、JCE、JAXB和JBI等。当SPI服务提供者多于一个时,代码只能根据具体提供者的类型来硬编码判断,为了消除这中不优雅的方式,JDK6时,JDK提供了java.util.ServiceLoader类,以META-INF/services的配置信息,辅以责任链模式,这才算给SPI的加载提供了一种相对合理的方案。
双亲委派的第三次“被破坏” 是由于用户需要程序动态性:代码热替换,模块化热部署。说白了,就是希望不用重启机器就能像鼠标那样,可以继续使用。IBM的OSGI是业界“事实上”的Java模块化标准,OSGI通过类加载实现热部署的,关键是它自定义的类加载机制的实现,每个程序模块(OSGI中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码热替换。在OSGI环境下,类加载器不再是双亲委派模型推荐的树状结构,而是复杂网状结构。
jdk9引入的Java模块化系统,为了能够实现模块化的关键目标——可配置的封装隔离机制,Java虚拟机堆类加载架构也做了相应的变动调整。jDK9的模块不仅仅像之前的jar包那样只是简单的充当代码容器,除了代码外,Java的模块定义还包含以下内容:
可配置的封装隔离机制首先要解决JDK9之前基于类路径来查找依赖的可靠性问题,如果类路径中缺失了运行时依赖的类型,那就只能等程序运行时该类型的加载、链接时才会报错。而在JDK9以后,如果启用了模块化封装,模块就可以声明对其他模块的显示依赖,这样jvm在启动时验证应用程序开发阶段设定好的依赖关系是否完备,如果缺失,则启动失败,从而避免很大一部分(并不是说模块化下,就不可能出现ClassNotFoundException了,比如把某个模块中公开的包中包某个类型移除,但是不修改模块导出信息,这样能够正常启动,但是运行时会报错)类型依赖而 引发的运行时异常。
可配置的封装隔离机制还解决了原来类路径上跨Jar包文件的public类型的可访问性问题。jdk9的public不再意味着程序所有地方都可以随意访问他们,模块提供了更精细的可访问性控制,必须明确声明其中哪一些public类型可以被其他模块访问,这种访问控制是在类加载过程中,解析阶段控制的。
为了使可配置的封装隔离机制能够兼容传统的类路径查找机制,JDK9提出了与“类路径”相对应的“模块路径”。简单来说,就是某个类库到底是模块还是传统的jar包,只取决于它存放在那种路径。只要存放在类路径上的jar文件,无论其中是否包含模块化信息(是否包含了module-info.class)文件,它都会被当做jar包来对待。相应的,只要放在模块路径上的jar包,即使没有使用JMOD后缀,甚至不包含module-info.class文件,它任然会被当做一个模块来对待。
模块化系统将按照以下规则来保证使用传统类路径依赖的Java程序可以不经修改直接运行在JDK9版本以后,即使这些版本的JDK已经使用模块来封装了JAVA SE的标志类库,模块化系统的这套规则也仍然保证了传统程序可以访问到所有标准类库模块中导出的包:
以上3条规则,保证了升级到jdk9对应用几乎不会有影响(类加载器上的变动还是会有少量影响)。
除了向后兼容,JDK9更值得关注的是它本身面临的模块间的管理和兼容性问题:如果同一个模块发行了多个不同的版本,那么只能由开发者在编译打包时人工选择。用户可以在编译时使用“ javac --module-version”来指定模块版本,Jvm种种迹象表明,Java模块化系统对版本号的支持不局限在编译期,按道理可以实现运行时部署和替换。模块运行时部署、替换能力没有内置在Java模块化系统和Java虚拟机中,仍然需要通过类加载器去实现,实在不得不说是一个缺憾(OSGI和JigSaw项目,两个厂家直接的利益冲突)。
Java虚拟机内置的JVMIT接口(java.lang.instrument.Instrumenttation)提供了一定程度的运行时修改类的能力,可以在idea和eclipse上使用。
JDK9没有从根本上动摇从JDK1.2运行了几十年的三层类加载架构,但是为了模块化系统顺序实行,模块化下的类加载器仍然发生了一些变动:
执行引擎是Java虚拟机核心的组成部分之一,物理机执行引擎是直接建立在处理、缓存、指令集合操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件限制,可以执行不被硬件直接支持的指令集格式。
在
Java虚拟机以方法作为最基本的执行单元,“栈帧”则是用于支持虚拟机进行方法调用和方法执行背后的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈已经被分析计算出来了,并且写入到方法表的Code属性之中,换言之,一个栈帧需要分配多少内存,并不会受到程序运行期变量的影响,而仅仅取决于程序源码和具体虚拟机实现的内存布局。
一个线程中的方法调用链可能很长,以Java程序的角度来了看,同一时刻,同一线程里面,在调用堆栈的方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有处于栈顶的方法才是在运行的,只有为与栈顶的栈帧才算生效的,它被称为当前栈帧,与这个栈帧相关联的方法称为“当前方法”。
局部变量表是一组变量值的存储空间,用于存放参数和方法内部定义的局部变量。局部变量表的容量以变量槽位最小单位,虚拟机规范中并没有明确指出一个变量槽应占用的内存大小,只是很有导向性的说每个变量槽应该能存放boolean、byte、char、short、int、float、reference或returnAddress类型的数据,一个变量槽可以存放一个32位以内的数据类型。前面6种可以按照Java语言对应数据类型理解,第7种reference类型表示对一个对象实例的引用,Java虚拟机规范没说明它的长度,也没有明确指出这种引用应当有怎么样得结构。但是虚拟机至少能应当通过这个引用做到两件事情,一是从根据引用直接或者间接地查找到对象在Java堆中的数据存放的起始地址或索引,而是根据引用直接或间接地查找到对象说书数据类型在方法区中的存储类型信息。第八种returnAddress已经很少见了,它是为jsr、jsr_w 和ret服务的,某些古老的Java虚拟机使用这些指令实现异常处理的跳转,但是现在全部改为异常表来代替了。
对于64位的数据类型,jvm会通过高位对齐的方式为其分配两个连续的变量槽空间。Java语言中明确的64位数据类型只有long和double两种,因为局部变量表是简历在线程堆栈中的,属于线程私有,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。
Java虚拟机通过索引定位的方式使用局部变量表,索引值从0开始至局部变量表最大的变量槽数量。如果是访问的32位,索引N就代表使用第N个变量槽,如果访问的是64位数据类型的变量,则同时使用第N和N+1两个变量槽。
当一个方法被调用时,Java虚拟机使用局部变量表来完成参数到参数变量列表的传递过程,即实参到形参的传递。如果是执行的实例方法(没有被static修饰的方法),那么局部变量表中的第0位索引的变量槽默认是用于传递方法所属对象实例的引用(this),其余参数按照参数列表顺序排列,参数表分配完成后,再根据方法体内部定义的变量顺序和作用域分配其余变量槽。
为了尽可能的节约栈帧耗用的内存空间,局部变量表中的变量槽可以重用。比如方法体中定义的变量,其作用域也许没有覆盖整个方法体,如果当前PC计数器的值已经超过了某个变量的作用域,那么这个变量对应的变量槽就可以交给其他变量重用。这样的设计可以节约栈帧空间,但是有少量额外的副作用,例如在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为。
/**
* 虚拟机运行参数 -verbose:gc
* @author Administrator
*
*/
(1)public class SlotTest {
public static void main(String[] args) {
byte[] placeholder = new byte[64 * 1024 * 1024];
System.gc();
}
}
输出结果:
[GC (System.gc()) 68075K->66176K(121856K), 0.0008278 secs]
[Full GC (System.gc()) 66176K->66095K(121856K), 0.0043250 secs]
GC中内存placeholder没被回收,是因为,变量placeholder还处于作用域之内
--------------------------------------------------------------------
(2)public class SlotTest {
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
}
输出结果:
[GC (System.gc()) 67440K->66240K(121856K), 0.0010325 secs]
[Full GC (System.gc()) 66240K->66092K(121856K), 0.0047628 secs]
(3)public class SlotTest {
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a=1;
System.gc();
}
}
输出结果:
[GC (System.gc()) 67440K->66208K(121856K), 0.0007991 secs]
[Full GC (System.gc()) 66208K->556K(121856K), 0.0059696 secs]
从代码逻辑上来看,在执行System.gc()的时候,placeholder已经不再
被访问,但是placehoder并没有被回收,在加了一行“int a=0”后,内存
却被回收了。
从前面3个例子,placeholder能否被回收的根本原因:局部变量表中变量
槽是否还存有关于placeholder数组对象的引用。(2)中代码虽然离开了
placeholder的作用域,但是作为GC Roots一部分的局部变量表仍然保持
对placeholder的引用,手动将其设置为null值(用来代替那句int a=1,
把变量对应的局部变量槽清空)便不见得是个无意义的操作。这种操作
可以作为一种极特殊情形(对象占用内存大、此方法的栈帧长时间不能被
回收、方法调用次数达不到即时编译器的编译条件)下的“奇技”来使用。
前面赋null值操作在极端情况下确实有用,但是没必要把它当做一个普遍
的编码规则来推广。原因有两点,从编码角度来讲,以恰当的变量作用域
来控制变量回收时间才是最优雅的解决方法;以执行角度来看,使用赋null
值来优化内存回收是建立在对字节码执行引擎的概念模型的理解之上的,
当虚拟机使用解释执行时,通常与概念模型比较接近,但是经过即时编译
器施加了各种编译优化措施以后,两者的差异就非常大了,在实际情况中,
即时编译才是虚拟机执行代码的主要方式,赋null值的操作在经过即时编译
优化后几乎是一定会被当做无效操作消除掉的,所以赋null值毫无意义。
(2)在经过即时编译后,System.gc()执行时就可以正确回收内存。
--------------------------------------------------------------------
局部变量表不像前面介绍的类变量那样存在“准备阶段”,类变量字段有两次赋值过程,第一次是在准备阶段,赋予系统初始值,第二次是在初始化阶段,赋予程序员定义的初始值。因此在初始化阶段程序员不赋值也没关系,但是一个局部 变量没有赋初始值,完全都是不能被使用的,编译器在编译期间就能检查并且提示。
public class Test {
static int a;
public static void main(String[] args) {
System.out.println(a);
}
}
输出结果:0
如果把a变成局部变量,则编译都不通过
操作数栈通常也被称为操作栈,它是一个后入先出栈,同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性max_stacks数据项中。32位数据占栈容量为1,64位占栈容量为2.
在概念模型中,两个不同的栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的,但是在大多数虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧部分的部分局部变量表重叠在一起,这样做不仅节约空间,更重要的在进行方法调用时,就可以直接共用一部分数据,无须进行额外的参数复制传递。
Java虚拟机的解释执行引擎 被称为“基于栈的执行引擎”,里面的栈就是操作数栈。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接,字节码中的方法调用指令就是以常量池里指向方法的符号引用作为参数,这些符号引用一部分会在类加载阶段,或者第一次使用时就被转化为直接引用,这种转化称为静态解析。另外一部分是在每一次运行期间都转化为直接引用,这部分就称为动态连接。
一个方法开始执行后,只有两种方式退出,第一种是执行引擎遇到任意一个方法返回的字节码指令,第二种是执行过程中遇到了异常。无论采用何种退出方式,方法退出后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能在栈帧中保存一些信息。方法正常退出时,主调方法的PC计数值就可以作为返回地址,栈帧中很有可能保存这个计数值。而方法异常退出时,返回地址要通过异常处理器表来确定,栈帧中就不会保存这部分信息。
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定调用哪一个方法,暂时还未涉及方法内部的具体运行过程。
方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期不可改变(调用目标在程序代码写好,编译器在编译那一刻已经确定下来),这个目标方法在Class文件里面是一个常量池的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。这类方法的调用称为解析。
在“编译期可知,运行期不变”符合这个要求的方法,主要有静态方法和私有方法两大类,它们适合在类加载阶段进行解析。在解析阶段中确定唯一的调用版本,符合这个条件的共有静态方法、私有方法、实例构造器、父类方法,再加上被final修饰的方法,这5种方法调用会在类加载的时候可以把符号引用解析为该方法的直接引用,这些方法统称为“非虚方法”。与之想法都称为“虚方法”。
解析调用一定是静态过程,在编译期间就完全可以确定。而另外一种方法调用形式:分派调用则复杂许多,它可能是静态的也可能是动态的,按照分配依据的宗量数可以分为单分派和多分派。这两类分派两两组合就构成了静态单分配,静态多分派,动态单分派、动态多分派。
分派调用过程将会揭示多态性特征的最基本的体现,如“重载”和“重写”在Java虚拟机中如何实现的
1、静态分派
静态分派和重载(overload)的关系
public class StaticDispatch {
static abstract class Human{
}
static class Man extends Human{
}
static class Woman extends Human{
}
public void sayHello(Human guy){
System.out.println("hello human");
}
public void sayHello(Man man){
System.out.println("hello man");
}
public void sayHello(Woman woman){
System.out.println("hello woman");
}
public static void main(String[] args) {
Human man =new Man();
Human woman=new Woman();
StaticDispatch sr=new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
输出结果:
hello human
hello human
在Human man =new Man()中,Human称为“静态类型”,后面的Man称为“实际类型”。代码中故意定义了两个静态类型相同,而实际类型不同的变量,但虚拟机(准确的说是编译器)在重载时是通过参数的静态类型而不是实际类型来做判定依据的。
所有依赖静态类型来决定方法执行版本的分配动作,都称为静态分派。静态分配最典型的应用就是方法重载,静态分配发生在编译阶段,因此确定静态分配的动作实际不是有虚拟机来执行的,这也是为何一些资料把静态分配归入“解析”而不是“分派”。
2、动态分派
动态分配,它与Java语言的多态性的另外一个重要体现——重写有着很密切的关联。
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man =new Woman();
man.sayHello();
}
}
输出结果:
man say hello
woman say hello
woman say hello
3通过Javap可以输出这段代码的字节码,在invokevirtual指令的运行时解析过程大致为一下几步:
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记成C
2)如果在类型C中,找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回直接引用,查找过程结束
3)否则按照继承关系从下往上一次对各个父类进行第二部的搜索和验证过程
4)否则报错,java.lang.AbstractMethodError异常。
因为invokevirtual指令执行的第一步就是在运行期确定接受者的实际类型,所以invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法的接收者的实际类型来选择方法版本,这就重写的本质。
我们把这种在运行期根据实际类型确定方法执行版本的分配过程称为动态分派。事实上,在Java里面只有虚方法存在,字段永不可能是虚的,字段永远不参与多态,当子类声明了和父类同名字段时,虽然子类的内存中存在了这两个字段,但是子类的字段会遮蔽父类的同名字段
3、单分派与多分派
public class DisPatch {
static class QQ {
}
static class _360 {
}
public static class Father {
public void hardChoice(QQ arg) {
System.out.println("father choose qq");
}
public void hardChoice(_360 arg) {
System.out.println("father choose 360");
}
}
public static class Son extends Father {
public void hardChoice(QQ arg) {
System.out.println("son choose qq");
}
public void hardChoice(_360 arg) {
System.out.println("son choose 360");
}
}
public static void main(String[] args) {
Father father =new Father();
Father son=new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
}
}
输出结果:
father choose 360
son choose qq
我们首先看编译阶段,这个时候选择目标方法的依据有两点:一是静态类型是Father还是son,二是方法参数是QQ还是360。这次选择结果最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father::hardChoice(360)以及Father::hardChoice(QQ)方法的符号引用。因为根据了方法的接收者和方法参数两个宗量来进行选择,所以Java的静态分派属于多分派类型。
再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice(new QQ())”这行代码的时候,更准确的说,执行这行代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不关心传递过来的QQ到底是“腾讯QQ”还是“奇瑞QQ”,因为这时候参数的静态类型、实际类型都对方法的选择不会构成任何影响,只有该方法的接受者的实际类型是Father还是son才会影响,所以Java语言的动态分配属于单分派类型。
从上可以得出,现如今Java语言是一门静态多分派、动态但分派的语言。
4、虚拟机动态分配的实现
动态分派是执行非常频繁的动作,而且动态分派的方法版本选择,需要在运行时在接收者类型的方法元数据搜索合适的目标方法,为了执行性能,真正运行时一般不会如此频繁地去反复搜索类型元数据,面对这种情况,一种基础的优化手段是在方法区中建立一个虚方法表(与此对应的是,在invokeinterface执行时也会用到接口方法表),使用虚方法表索引来代替元数据的查找以提高性能。虚方法表存放着各个方法的实际入口地址,如果某个方法在子类没有被重写,那么子类的虚方法表中的地址入口和父类的相同方法地址入口一致,都指向父类的实现入口。
为了程序是实现方便,具有相同签名的方法,在父类和子类的虚方法表中都应当具有一样的索引序号,这样在类型编号是,进需要变更要查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需要的入口地址。
除了虚方法表,为了进一步提高性能,还会使用类型继承关心分析、守护内联、内联缓存等多种非粉顶的激进优化来争取更大的性能空间。
Java虚拟机的字节码指令集,从问世以来就只增加了一条指令,就是随着JDK7发布而来的invokedynamic指令。这条增加的指令是JDK7的项目目标:实现动态类型语言的支持,也是为JDK8实现lambda表达式做的技术储备。
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足这个特征的比如:APL,Erlang,Python,Tcl等等,相对的C++和Java就是常用的静态类型语言。
1、什么是编译期和运行期
public static void main(String [] args){
int [][][] array=new int[1][0][-1];
}
输出结果:
Exception in thread "main" java.lang.NegativeArraySizeException
这段代码在Java中能够正常编译,但是在运行的时候会报错java.lang.NegativeArraySizeException,这个异常是运行时异常。而C语言,相同语义的代码在编译期就会直接报错,而不是等到运行时才出现异常。
2、什么是类型检查
obj.println("hello world")
如果在Java语言中,并且obj的静态类型为java.io.PrintStream,那么obj的实际类型就必须使PrintStream的子类(实现了PrintStream接口的类)才是合法的。否则,哪怕obj属于一个确定包含println(String)方法相同签名方法的类型,但是只要它和PrintStream接口没有继承关系,代码就不可运行——因为类型检查不合法。
但是相同的代码在JavaScript中情况就不一样,无论obj具体是何种类型,无论其继承关系如何,只要这种类型方法定义确实包含println(String)方法,能够找到相同签名,调用便可成功。
产生这种差别的根本原因是Java语言在编译期间就已将println(String)方法完整的符号引用(本例中为一项CONSTANT_InterfaceMethodref_info)常量生成出来,并作为方法调用指令的参数存储到Class文件中。该符号引用包含了该方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法的返回值等信息,通过这个符号引用,Java虚拟机可以翻译出该方法的直接引用。
而JavaScript等动态类型语言与Java的核心差异就是变量obj本身没有类型,变量obj的值才具有类型,所以编译器在编译时最多能确定方法名称、参数和返回值等信息,而不会确定方法所在的具体类型(即方法的接受者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个核心特征。
Java从第一版就规划了愿景:在未来,会对Java虚拟机进行适当的扩展,以便更换支持其他语言运行在Java虚拟机之上。目前确实有许多动态语言也运行在Java虚拟机之上了。
在JDK7 之前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),方法的符号引用在编译期产生,而动态类型语言只有在运行期才能确定方法的接收者。
这样在Java虚拟机上实现动态语言,不得不使用“曲线救国”的方式(如编译时留个占位符了类型,运行时动态生成字节码实现具体类型到占位符类型的适配),但是这样会让动态类型语言实现的复杂度增加,也会带来额外的性能和内存开销。内存是显而易见的,方法调用产生一大堆动态类。性能瓶颈是在于动态类型方法调用时,由于无法确定调用对象的静态类型而导致方法内联无法有效进行。
也可以想一些办法(比如调用点缓存)尽量缓解支持动态类型语言而导致的性能下降,但是这种缓存也会其他问题:
var arrays={"abc",new Objectx(),123,Dog,Cat,Car..}
for(item in arrays){
item.sayHello();
}
在动态类型语言下,这样的代码是没问题的,但是由于在运行时arrays的元素可以是任意类型,即使他们的类型都有sayHelllo()方法,但是肯定无法在编译优化的时候就确定具体sayHello()的代码在哪里,编译器就只能不停的编译它所遇到的每个sayhello方法,并且缓存起来供运行时选择、调用和内联,但是如果arrays数组中不同类型很多,就势必会对内联缓存产生很大压力,缓存大小总是有限的,类型信息的不确定导致缓存内容的不断被失效和更新。
所以动态类型方法调用的底层问题终归还是应当在Java虚拟机层次上解决,这便是JDK7中invokedynamic指令和java.lang.invoke包出现的技术背景。
java.lang.invoke包目的是在之前单纯依靠符号引用来确定调用的目标方法这条路,提供了一种新的动态确定目标方法的机制,称为“方法句柄”。在拥有方法句柄后,就可以实现类似在运行时动态指定实际类型
package erwan.jvm.loadclass;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
public class MethodHandle {
static class ClassA{
public void println(String s){
System.out.println("我是ClassA的:"+s);
}
}
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, Throwable {
Object obj=System.currentTimeMillis()%2==0?System.out:new ClassA();
getPrintlnMH(obj).invokeExact("我也可以输出内容");
}
private static java.lang.invoke.MethodHandle getPrintlnMH(Object reveiver) throws NoSuchMethodException, IllegalAccessException {
/**
* 代表“方法类型”,第一个参数是方法的返回值,第二个以及以后是方法的具体参数
*/
MethodType mt=MethodType.methodType(void.class,String.class);
/**
* loopup,在指定类中查找符号给定的方法名称,方法类型,并且复合调用权限的方法句柄
* 非static修饰的方法,第一个参数是(this),这个参数以前的放在参数列表中传递,现在提供bindTo来完成
*/
return MethodHandles.lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
}
}
输出结果有两种:
我也可以输出内容 或者我是ClassA的:我也可以输出内容
上面的getPrintMH实际上是模拟了invokevirtual之类的执行过程,只不过它的分派逻辑并非固化在Class文件的字节码上,而是通过用户设计的Java方法来实现。
仅站在Java语言的角度看,MethodHandle在使用方法和效果上和Reflection有众多相似之处,不过它们有以下的区别:
invokedynamic指令与MethodHandle机制的作用一样,都是为了节约原有的"invoke*"指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码中,让用户(包含其他程序语言的设计者)有更高的自由度。
每一处含有invokedynamic指令的位置都被称作“动态调用点”,这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK7新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,该方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。
引导方法有固定的参数,并且返回值规定是java.lang.invoke.CallSite对象,这个对象代表了真正要执行的目标方法调用。虚拟机根据CONSTANT_InvokeDynamic_info常量中提供的信息找到引导方法,从而获取一个CallSite对象,最终调用到要执行的目标方法上。
package erwan.jvm.loadclass;
import java.lang.invoke.CallSite;
import java.lang.invoke.ConstantCallSite;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
public class InvokeDynamicTest {
public static void testMethod(String s) {
System.out.println(s);
}
public static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt)
throws NoSuchMethodException, IllegalAccessException {
return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class, name, mt));
}
private static MethodType MT_BootstrapMethod() {
return MethodType.fromMethodDescriptorString(
"(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;",
null);
}
private static java.lang.invoke.MethodHandle MH_BootstrapMethod()
throws NoSuchMethodException, IllegalAccessException {
return MethodHandles.lookup().findStatic(InvokeDynamicTest.class, "BootstrapMethod", MT_BootstrapMethod());
}
private static java.lang.invoke.MethodHandle INDY_BootstrapMethod() throws NoSuchMethodException,
IllegalAccessException, IllegalArgumentException, TypeNotPresentException, Throwable {
CallSite cs = (CallSite) MH_BootstrapMethod().invokeWithArguments(MethodHandles.lookup(), "testMethod",
MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));
return cs.dynamicInvoker();
}
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, Throwable {
INDY_BootstrapMethod().invokeExact("我可以");
}
}
输出结果:
我可以
调用MethodHandles$Lookup的findStatic()方法,产生testMethod()方法的MethodHandle,然后用MethodHandle创建一个ConstantCallSite对象,最后把这个对象返回个invokedynamic指令,实现对testMethod()方法的调用。
public class Test {
class GrandFather {
void thinking() {
System.out.println("I am grandfather");
}
}
class Father extends GrandFather {
void tinking() {
System.out.println("I am father");
}
}
class Son extends Father {
void thinking() {
// 这里通过传统方式无法实现调用祖父类thingking()方法,最多能通过super调用到父类的thinking方法
try {
MethodType mt = MethodType.methodType(void.class);
java.lang.invoke.MethodHandle mh = MethodHandles.lookup().findSpecial(GrandFather.class, "thinking", mt,
getClass());
mh.invoke(this);
} catch (Throwable e) {
}
}
}
public static void main(String[] args) {
(new Test().new Son()).thinking();
}
}
输出结果:
I am grandfather
JDK7 UPdate9之前,是可以通过上面代码得到I am grandfather的,但是在JDK 7 UPdate 10之后,原因是必须保证findSpecial()查找方法版本时,受到访问约束(访问控制的限制、对参数类型的限制)应该与使用invokespecial指令一样,应该只能访问到直接父类的方法版本,只能得到I am father。
如果需要绕过这个保护措施,在JDK7 update9也是可以得到I am grandfather只需要修改代码为
package erwan.jvm.loadclass;
import java.lang.invoke.CallSite;
import java.lang.invoke.ConstantCallSite;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Field;
public class Test {
class GrandFather {
void thinking() {
System.out.println("I am grandfather");
}
}
class Father extends GrandFather {
void tinking() {
System.out.println("I am father");
}
}
class Son extends Father {
void thinking() {
// 这里通过传统方式无法实现调用祖父类thingking()方法,最多能通过super调用到父类的thinking方法
try {
MethodType mt = MethodType.methodType(void.class);
Field declaredField = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
declaredField.setAccessible(true);
java.lang.invoke.MethodHandle mh = MethodHandles.lookup().findSpecial(GrandFather.class, "thinking", mt,
getClass());
mh.invoke(this);
} catch (Throwable e) {
}
}
}
public static void main(String[] args) {
(new Test().new Son()).thinking();
}
}
输出结果:
I am grandfather
Java许多虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,Class文件中的代码到底是被解释执行还是编译执行,只有虚拟机具体实现自己本身才能准确判断。
无论是解释还是编译,大部分的程序代码转化成物理机的目标代码或者虚拟机能执行的指令集之前,都需要经过下面的各个步骤
对于一门具体语言的实现,词法、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器,这类代表是C/C++。也可以选择把其中一部分步骤(生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言,又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的JavaScript执行引擎。
在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器又在虚拟机内部,所以Java程序的编译就是半独立的实现。
Java的解释执行和编译执行
Javac编译器输出的字节码指令流,基本上(部分字节码指令会带有参数,而纯粹基于栈的指令集架构应当全部都是零地址)是一种基于栈的指令集架构,字节码指令流里面的大多数都是零地址指令,它们依赖操作数栈进行工作。
还有一种指令集架构是基于寄存器的指令集,这些指令集基于寄存器工作。
用这两种指令集去计算“1+1”,
基于栈的指令集是这样的,两条iconst_1指令会对两个常量1压栈,然后相加,最后把结果存放到栈顶,istore_0把栈顶的值放到局部变量表的第0个变量槽中,这种指令流中的指令通常不带参数的,使用操作数栈中的数据作为指令的运算输入:
iconst_1
iconst_1
iadd
istore_0
基于寄存器指令集,mov指令把eax寄存器的值设为1,然后add把这个值再加1,结果就保存到eax寄存器里面:
mov eax,1
add eax,1
基于栈的指令集主要优点是可移植,因为寄存器由硬件提供,程序如果依赖这些硬件寄存器则不可避免的收到硬件的约束。栈架构的指令集,用户程序可以有虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存)放到寄存器中来获取最好的性能。代码相对更加紧凑(字节码每个字节对应一条指令),编译器实现更加简单(不需要考虑空间分配问题,所需空间都在栈上分配)。
栈架构的主要确定是理论上的执行速度稍微慢一点,所有主流的物理机指令集都是寄存器架构也印证了这一点。不过这里的执行速度是局限于解释执行状态下,如果是经过即时编译器输出的物理机上的汇编指令流,那就与虚拟机采用哪种架构无关系了。
在解释执行时,栈架构指令集的代码虽然紧凑,但是完成相同功能的指令数量比寄存器架构来的多,因为出栈和入栈操作本身就产生了大量的指令。栈实现在内存中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是瓶颈。尽管虚拟机采用了栈顶缓存的优化方法,把最常用的操作映射到了寄存器红避免内存访问。由于指令数量和内存访问的原因,导致了栈架构指令集的执行速度会相对慢一点。
public int cal(){
int a=100;
int b=200;
int c=300;
return (a+b)*c;
}
能够通过程序上进行操作的,主要是字节码生成与类加载器这两部分功能,但是仅仅在如何处理这两点上,就已经出现了许多值得欣赏和借鉴的思路。
主流的Java web服务器,如Tomcat、Jetty、WebLogic、WebSphere或其他笔者没有列举的服务器,都实现了自己定义的类加载器。一个功能健全的Web服务器,都要解决如下几个问题:
由于上述的问题,在部署web应用时,单独的Classpath就不能满足需求了,所以各种Web服务器都不约而同地提供了好几个有着不同含义的Classpath路径供用户存放,这种路径一般会以lib或者classes命名,被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常每一个目录都会有一个相应8的自定义的类加载器去加载防止里面的Java类库。
在Tomcat目录中,可以设置三组目录(/common/*,/server/*和/shared/*,但是默认不一定开放,可能只有/lib/*目录存在)用于存放Java类库,另外还应该加上Web应用程序自身的“/WEB-INF”目录,一共4组。
为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现。
Common类加载器、Catalina类加载器(也称为Server类加载器)、Shared类加载器和WebAPP类加载器,分别对应相对目录,其中WebApp类加载器和JSP类加载器通常还会存在多个实例,每个Web应用程序对应一个WebApp类加载器,每个JSP文件对应一个JasperLoader类加载器。
Common类加载器能够加载的类都可以被Catalina类加载器和Shared类加载器使用,而Catalina类加载器和Shared类加载器自己能加载的类则与对方互相隔离。WebApp类 加载器可以使用Shared类加载器加载到的类,但各个WebApp类加载器之间隔离。JasPerLoader的加载范围仅仅是这个JSP文件所编译出来的那个Class,它存在的目的就是为了被丢弃:当服务器检测到JSP文件被修改时,会替换掉目前的JasperLoader实例,并通过再建立一个新的JSP类加载器来实现JSP文件的HotSwap功能。
前面提到的类加载结构在Tomcat6是它默认的类加载结构,在Tomcat6以及之后的版本简化了默认的目录结构,只有指定了tomcat/conf/catalina.properties配置文件的server.loader和share.loader项后才会真正建立Catalina类加载器和Shared类加载器实例,否则用到这两个类加载器的地方都会用Common类加载器的实例代替,所以在Tomcat6之后也顺理成章的把/common,/server/和/shared三个目录默认合并到/lib目录,这是Tomcat开发团队为了简化大多数部署场景所做的一项易用性改进。如果默认设置不能满足,用户可以通过修改配置文件,指定server.loader和share.loader的方式重新启用原来完整的加载器架构。
“学习Java EE规范,推荐去看JBoss源码;学习类加载器的知识,推荐去看OSGI源码”。OSGI时OSGI联盟制定的一个基于Java语言的动态模块化规范,现在已经成为Java世界中“事实上”的动态模块化标准。OSGI现在的重点应用在智慧城市、智慧农业、工业4.0这些地方,而在传统的Java程序员最知名的应用案例就数Eclipse IDE了。
OSGI中的每个模块(称为Bundle)与普通的Java类库区别并不大,两者一般都以JAR格式进行封装,并且内部存储的都是Java的Package和class。但是一个Bundle可以声明它锁依赖的Package(通过Import-Package描述),也可以声明它允许导出发布Package(通过Export-Package描述)。在OSGI里面,Bundle之间的依赖关系,从传统的上层模块依赖底层模块转变为平级模块直接的依赖,而且类库的可见性得到非常精确的控制,一个模块只有被Export过的Package才可以被外界访问。
以上的这些静态模块化特性原本也是OSGI的核心需求之一,不过它和后来出现的Java模块化系统互相重叠了(JDK9引入的JPMS时静态的模块系统),所以OSGI现在着重向动态模块化系统的方向发展。今天,通常引入OSGI的主要理由是基于OSGI架构程序很可能(比如热拔插后的内存管理、上下文状态维护问题)会实现模块级的热拔插功能,当程序升级更新或调试除错时,可以只停用、重新安装其中一部分,比如Eclipse中安装、卸载、更新插件不需要重新启动,就使用了这种特性。
OSGI之所以能满足上述特点,还是因为它灵活的类加载架构,OSGI的bundle类加载器之间没有固定的委派关系,例如某个Bundle声明了一个它依赖的Package,如果有其他Bundle声明发布了这个Package后,那么所有对这个Package的类加载动作都会委派给发布它的Bundle类加载器去完成。不涉及某个具体的Package时,各个Bundle加载器都是平级的关系,只有具体使用到某个Package和Class的时候,才会根据Package导入导出定义来构造Bundle之间的委派和依赖。
另外,一个Bundle类加载器为其他Bundle提供服务时,会根据Export-Package列表严格控制访问范围。如果一个类存在于Bundle的类库中但是没有被Export,Bundle的类加载器虽然能找到这个类,但是不会提供给其他Bundle使用,而且OSGI框架也不会把其他Bundle的类加载请求分配给这个Bundle来处理。通过下面的例子来解释一下规则:
那么3个Bundle之间的类加载器以及父类加载器之间的关系如图
上面只是一种类加载器的概念模型,在OSGI里,加载一个类可能发生的查找行为和委派关系更为复杂:
在OSGI中,加载器直接的关系不再是双亲委派模型的树形结构,而是进一步的复杂、运行时才能确定的网状结构。案例:一个非OSGI的大型系统向OSGI迁移的项目,由于项目规模和历史原因,代码模块之间的依赖关系错综复杂,勉强分离出各个模块的Bundle后,在高并发环境经常出现死锁(BundleA依赖BundleB的packageB,而BundleB又依赖于BundleA的packageA,这两个Bundle进行类加载时就很高概率发生死锁),具体情况是当BundleA加载packageB时,首先需要锁定当前类加载器的实例对象(java.lang.ClassLoader.loadClass()是一个同步方法),然后把请求委派给BundleB的加载器去处理,而如果这时BundleB也正好想加载PackageA的类,它会先锁定自己的加载器再去请求A的加载器处理,这样两个加载器都在等待对方处理自己的请求,互相死锁。可以通过启用osgi.classloader.singleThreadLoads参数来按单线程串行化的方式强制进行类加载动作。JDK7出现的以JDK层面的解决方案,类加载器的架构得到升级,把锁的级别从ClassLoader对象本身,降低为要加载的类名这个级别,来从底层避免以上类死锁出现的可能。
在Java世界里面除了Javac和字节码类库外,使用到字节码生成的例子比比皆是,如Web服务器中的JSP编译器,编译时织入的AOP框架,还有很多常见的动态代理技术,甚至在使用反射的时候虚拟机都有可能会在运行时生成字节码来提高执行速度。
动态代理实例
package erwan.jvm.DynamicProxy;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
public class DynamicProxyTest {
interface IHello {
void sayHello();
}
static class Hello implements IHello {
@Override
public void sayHello() {
System.out.println("hello world");
}
}
static class DynamicProxy implements InvocationHandler {
Object originaLObj;
Object bind(Object originalObj) {
this.originaLObj = originalObj;
return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(),
originalObj.getClass().getInterfaces(), this);
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("welcome");
return method.invoke(originaLObj, args);
}
}
public static void main(String[] args) {
IHello hello = (IHello) new DynamicProxy().bind(new Hello());
System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
hello.sayHello();
}
}
输出结果:
welcome
hello world
System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true"); 这行代码可以在运行时产生一个描述代理类的字节码byte[]数组。在磁盘中名称为“项目路径\jvm\erwan\jvm\DynamicProxy\$Proxy0.class”,如果是多个动态代理类(新建实现了InvokcationHandler的类),就是Proxy1,Proxy2等等,经过反编译可以得到如下内容。
package erwan.jvm.DynamicProxy;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;
final class $Proxy0 extends Proxy implements DynamicProxyTest.IHello {
private static Method m1;
private static Method m3;
private static Method m2;
private static Method m0;
public $Proxy0(InvocationHandler paramInvocationHandler) {
super(paramInvocationHandler);
}
public final boolean equals(Object paramObject) {
try {
return ((Boolean) this.h.invoke(this, m1, new Object[] { paramObject })).booleanValue();
} catch (Error | RuntimeException localError) {
throw localError;
} catch (Throwable localThrowable) {
throw new UndeclaredThrowableException(localThrowable);
}
}
public final void sayHello() {
try {
this.h.invoke(this, m3, null);
return;
} catch (Error | RuntimeException localError) {
throw localError;
} catch (Throwable localThrowable) {
throw new UndeclaredThrowableException(localThrowable);
}
}
public final String toString() {
try {
return (String) this.h.invoke(this, m2, null);
} catch (Error | RuntimeException localError) {
throw localError;
} catch (Throwable localThrowable) {
throw new UndeclaredThrowableException(localThrowable);
}
}
public final int hashCode() {
try {
return ((Integer) this.h.invoke(this, m0, null)).intValue();
} catch (Error | RuntimeException localError) {
throw localError;
} catch (Throwable localThrowable) {
throw new UndeclaredThrowableException(localThrowable);
}
}
static {
try {
m1 = Class.forName("java.lang.Object").getMethod("equals",
new Class[] { Class.forName("java.lang.Object") });
m3 = Class.forName("erwan.jvm.DynamicProxy.DynamicProxyTest$IHello").getMethod("sayHello", new Class[0]);
m2 = Class.forName("java.lang.Object").getMethod("toString", new Class[0]);
m0 = Class.forName("java.lang.Object").getMethod("hashCode", new Class[0]);
return;
} catch (NoSuchMethodException localNoSuchMethodException) {
throw new NoSuchMethodError(localNoSuchMethodException.getMessage());
} catch (ClassNotFoundException localClassNotFoundException) {
throw new NoClassDefFoundError(localClassNotFoundException.getMessage());
}
}
}
代理类$Proxy0继承Proxy类,实现被代理类的接口方法IHello, public final class $Proxy0 extends Proxy implements DynamicProxyTest.IHello,通过反射生成出来四个方法(sayHello,和三个Object自带的方法equals(),hashCode(),toString()),这些方法都会调用父类Proxy中的InvocationHandler变量实例(this.h)的invoke()方法,来实现这些方法的内容,各个方法的区别不过是传入的参数和Method对象有所不同,无论调用动态代理的哪一个方法,实际上都是在执行InvocationHandler::invoke()中的代理逻辑而已。动态代理底层实现
为了把高本版的JDK中编写的代码放到低版本JDK环境中去部署应用,一种名为“Java逆向移植”的工具应运而生,Retrotranslator和Retrolambda是这类工具的杰出代表。
Retrotranslator作用是将JDK5编译出来的Class文件转变为可以在JDK1.4或者1.3上部署,它能很好的支持jdk5的新特性自动装箱、泛型、动态注解、枚举、变长参数、遍历循环、静态导入这些特性,甚至也可以支持JDK5中新增的集合改进、并发包及对泛型、注解等反射操作。
Retrolambda的作用是将JDK8的Lambda表达式和try-resources语法转变为可以在JDK5、jDK6、JDK7中使用的形式,同时也对接口默认方法提供了有限度的支持。
JDK的每次升级新增的功能大致可以分为以下五类:
上述5类,逆向移植工具比较完美的模拟了前面两类,从第3类开始就涉及了虚拟机内部实现的改进了。
在应用程序运行过程中,添加自己想要的代码,在不影响原程序的情况下,达到排错,记录日志的功能,通常有以下解决途径:
最终产品需求:
一种在服务器上编译,使用tools.jar(JAVA_HOME/lib目录下)中的com.sun.tools.Javac.Main类来编译Java,它们其所和直接使用Javac命令来编译是一样的。这里的缺点是程序会绑定特定JDK,并且部署到其他公司的JDK还得包tools.jar带上。 另外一种是直接在客户端编译好,然后把字节码传到服务器。
编译期,可以指前端编译器把*.java文件转变成*.class文件的过程,也可以指Java虚拟机的即时编译器(JIT编译器,Just In Time)运行期把字节码转变成本地机器码的过程,还可以指使用静态的提前编译器(AOT编译器,Ahead Of Time Compiler)直接把程序编译成与目标机器指令集相关的二级制代码的过程。这三类编译过程有比较有代表性的编译产品:
Javac这类前端编译器对代码的运行下来几乎没有任何优化措施可言,因为Java虚拟机设计团队选择把性能的优化全部集中到运行期的即时编译器中,这样可以让那些不是Javac产生的Class文件也同样能享受到编译器优化措施带来的性能红利。可以认为Java中的即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升,而前段编译器在编译期的优化过程,则是支撑程序员编码效率和语言使用者幸福感的提高
在JDK6以前,Javac并不属于标准Java SE API的一部分,它的实现代码放在tools.jar中,要在程序中使用的话必须把这个库放到类路径上。在JDK6发布时,使JavaC编译器的实现代码晋升成为Java标准类库之一,到了JDK9时,整个JDK所有的Java类库都采用模块化进行重构,Javac编译器就被挪到了jdk.compiler模块里面。
从Javac代码的总体结构来看,编译过程大致可以分1个准备过程和3个处理过程。
在3个处理过程里,第3步,执行插入式注解时又可能会产生新的符号,如果有新的符号产生就必须转回到第2步解析、填充符号表的过程中重新处理这些符号。
上述过程对应到代码中,Javac编译动作的入口是com.sum.tools.javac.main.JavaCompiler类,这3个处理过程的代码逻辑集中在这个类的compile()和compile2()方法里面。
解析过程是上图parseFiles()方法来完成,解析过程包括了经典程序编译原理中的词法分析和语法分析两个步骤。
1.词法、语法分析
词法分析是将源代码的字符流转变为标记(Token)集合的过程,单个字符是编写时最小的元素,但标记才是编译时的最小元素。关键字、变量名、字面量、运算符都可以作为标记,“int a=b+2”,这句代码就包含了6个标记,分别是int、a、=、b、+、2,虽然int是由3个字符构成,但是它只是一个独立的标记。在Javac源码中,词法分析过程是有com.sun.tools.javac.parser.Scanner来实现。
语法分析根据标记序列构造抽象语法树的过程,抽象语法树是一种描述程序代码语法结构的树形表示方式,抽象语法树的每一个节点都代表着程序代码中的一个语法结构,例如包、类型、修饰符、运算符、接口、返回值甚至练代码注释都可以是一种特定的语法结构。
经过词法和语法分析生成语法树以后,编译器就不会再对源码字符流进行操作了,后续的操作都是建立在抽象语法树上。
2、填充符号表
上上图的enterTrees()方法要做的事,符号表是有一组符号地址和符号信息构成的数据结构,可以理解成哈希表中的键值对存储形式。符号表中所登记的信息在编译期的不同阶段都要被用到。比如在语义分析过程中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的声明是否一致)和产生中间代码,在目标代码生成阶段,当堆对符号名进行地址分配时,符号表是地址分配的直接依据。
在Javac源代码中,填充符号表的过程由com.sun.tools.javac.comp.Enter类实现,该过程产出物是一个待处理列表,包含每一个编译单元的抽象语法树的顶级节点,以及package-info.java(如果存在)的顶级节点。
jdk5之后,Java语言提供了对注解的支持,注解在设计上原本是与普通Java代码一样,都只会在程序运行期间发挥作用,但是在JDK6中又提出“插入式注解处理器”的标准API,可以提前至编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程。我们可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式主键处理器不再对语法树进行修改为止,每一次循环过程称为一个轮次。
有了编译器处理的标准API后,程序员的代码才有可能干涉编译器的行为。比如Java著名的编码效率工具Lombok,它可以通过注解来实现自动产生getter/setter方法,进行控制检查、生成受查异常表、产出equals()和hashCode()方法等等,帮助开发人员消除Java冗长的代码。
插入式注解处理器的初始化过程是在initProcessAnnotations()方法中完成的,而它的执行过程则是在processAnnotations()方法中完成。这个方法会判断是否还有新的注解处理器需要执行,如果有的话,通过com.sun.tools.javac.processing.JavacProcessingEnvironment类的doProcessing()方法来生成一个新的JavaCompiler对象,对编译的后续步骤进行出来。
抽象语法树能够表示一个结构正确的源程序,但是无法保证源程序的语义是否符合逻辑。语义分析的主要任务则是对结构上正确的源程序进行上下文的相关性质的检查,譬如进行类型检查、控制流检查、数据流检查。
int a =1;boolean b= false; char c=2;
后续的赋值操作:
int d = a+c; int d =b +c ; char d= a+c;
在后续的复制中,如果出现了上面的3种赋值运算,它们都能构成结构正确的抽象语法树,但是只有第一种int d= a+c;在语义上是正确的,能够通过检查和编译。
1、标注检查
语义分析过程中可以分为标准检查和数据及控制流分析两个步骤,分别对应上上图的attribute()和flow()方法。标注检查步骤要检查的内容包括变量使用前是否已被声明、变量与复制之间的数据类型是否能够匹配,在标准检查中,还会顺便进行一个常量折叠的代码优化,这是Javac编译器对源码程序做的极少量的优化措施之一。 比如int a=1+2,在经过常量折叠后,就被折叠成一个字面量“3”。
标注检查步骤在Javac源码中的实现类是com.sun.tools.javac.comp.Attr类和com.sun.tools.javac.comp.Check类
2、数据及控制流分析
数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,它检查局部变量在使用前是否赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确的处理了。一个方法的参数和局部变量如果被final修饰了,在编译后会不存在。通过Class文件结构的讲解,我们已经知道,局部变量与类的字段(实例变量与类变量)存储时有显著差别的,局部变量在常量池中并没有CONSTANT_Fieldref_info的符号引用,自然不可能存储有访问标志(access_flags),自然在Class文件中就不可能知道一个局部变量是不是被声明final。因此可以推断出把局部变量声明为final,对运行期是完全没有影响的,变量的不变性仅仅有javaC在编译器来保障。
数据及控制流分析的入口就是上上图的flow()方法,具体操作由com.sum.tools.javac.comp.Flow类来完成。
3、解语法糖
语法糖也称糖衣语法,使用语法糖能够减少代码量、增加程序的可读性,从而减少程序代码出错的机会。
Java中最常见的语法糖包括了前面提到的泛型、变长参数、自动装箱拆箱等待。Java虚拟机运行时并不直接支持这些语法,它们在编译阶段还原回原始的基础语法结构,这个过程就称解语法糖。
在Javac的源码中,解语法糖的过程有desugar()方法触发,在com.sun.tools.javac.comp.TransTypes类和com.sum.tools.javac.comp.Lower类中完成。
4、字节码生成
字节码生成是Javac编译过程的最后一个阶段,在Javac源码里面由com.sun.tools.javac.jvm.Gen类来完成。这个阶段,把前面生成的信息(语法树、符号表)转化成字节码指令到磁盘中,还进行了少量的代码添加和转化工作。
实例构造器
除了生成构造器外,还有一些代码替换工作,比如把字符串加操作替换成StringBuffer或者StringBuilder(取决于目标代码的版本是否大于或等于JDK5)的append()操作等。
完成了对语法树的遍历和调整后,就会把填充了所有所需信息的符号表交到com.sun.tools.javac.jvm.ClassWriter类手上,有这个类的writeClass()方法输出字节码,生产最后的Class文件
语法糖可以看作时前端编译器实现的一些“小把戏”,这些“小把戏”可以使效率得到大提升
泛型的本质是参数化类型或者参数化多态,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。
1、Java与C#的泛型
Java选择的泛型实现方式叫做“类型擦除式泛型”,而C#选择的是“具现化式泛型”。C#泛型无论在程序源码里面,编译后的中间语言标识,或者运行期的CLR里面都是切实存在的,List
Java的类型擦除式泛型无论在使用效果上还是运行效率上,几乎都落后于C#,它唯一的优势是在于实现这种泛型的影响范围上:擦除式泛型的实现几乎只需要在Javac编译器上做出改进即可,不需要改动字节码、不需要改动Java虚拟机,JDK5之前的代码也可以直接运行在jDK5上。
2、泛型的历史背景
为了保证低版本在新版本中能继续运行,而Java5.0突然要支持泛型,还要让以前的编译的程序在新版本的虚拟机中还能正常运行,设计者就面临着两条路
1) 需要泛型化的类型(主要是容器类型),以前有的就保持不变,然后平行地加一套泛型化版本的新类型。
2) 直接把已有的类型泛型化,即让所有需要泛型化的已有类型都原地泛型化不添加任何平行于已经有类型的泛型
Java 在此时已经快十年了,遗留的代码规模特别大,并且在标准类库中已经有新的集合类,比如Vector(老)和ArrayList(新)、有HashTable(老)和HashMap(新)等两条容器代码并存,所以再平行的加一套新类型,肯定不妥。
3、类型擦除
要让以前直接用ArrayList裸类型的代码在泛型的新版本里必须还能用这同一个类型,就必须让所有泛型化的实例类型自动成为ArrayList的子类型才可以。所以引出裸类型,应该被视为所有该类型泛型化实例的共同父类型。
实现裸类型有两种方式:一种是在运行期由Java虚拟机来自动地、真实地构造出ArrayList
4、 类型擦除带来的缺陷:
擦除法实现泛型直接导致了对原始类型数据的支持又成了新的麻烦,因为不支持int、long与Object之间的强制转化,当时Java给出的解决方案就是:既然没法转化,就不支持原生类型,然后遇到原生类型是把自动装箱、拆箱自动做了。无数构造包装类的装箱、拆箱的开销,也成了Java泛型慢的重要原因
运行期无法获取到泛型的信息,我们写一个泛型版本从List到数组的转化方法,由于不能从List中获取参数法类型T,所以不得不从一个额外参数再传入一个数组的组件类型进去。
public static T[] convert(List list, Class componetType) {
T[] array = (T[]) Array.newInstance(componetType, list.size());
return array;
}
泛型的擦除后,称为相同的裸类型,让重载有有了一定的争议(返回值不参与重载的选择)
public class FXTest {
public static void method(List list) {
System.out.println();
}
public static void method(List list) {
System.out.println();
}
}
这里编译是不成功的会报错
各种场景(虚拟机解析、反射)的方法调用都可能对原有的基础产生影响,如在泛型类中如何获取传入的参数化类型等,在49.0版本后,引入了Signature、LocalVariableTypeTable等新的属性用于解决伴随泛型而来的参数类型识别问题。Signature的作用是存储一个方法在字节码层面的特征签名,这个属性保存的参数类型并不是原生类型,而是包括了参数化类型的信息。
从Signature属性的出现,我们可以得出结论,擦除法所谓的擦除,仅仅是对方法的code属性中的字节码擦除,实际上元数据中还是保留了泛型信息,这也是我们能在编码时通过反射手段取得参数化类型的根本依据
自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,而遍历循环则是把代码还原成了迭代器的是实现,这也是为何遍历循环需要被遍历的类实现了Iterable接口的原因,变长参数,它在调用的时候变长了一个数组类型的参数,在变长参数出现之前,程序员的确是使用数组来完成类似功能的。
包装类的“==”运算在不遇到算术运算的情况下不会自动拆箱,以及它们equals方法不处理数据转型的关系。
public class FXTest {
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
System.out.println(c == d); //1
System.out.println(e == f);//2
System.out.println(c == (a + b));//3
System.out.println(c.equals(a + b)); //4
System.out.println(g == (a + b));//5
System.out.println(g.equals(a + b));//6
}
}
输出结果:
true
false
true
true
true
false
对于 1,2,3,4来说
Integer为对象判断是否相等还是使用equals最靠谱,
int为基本类型,判断是否相等就是可以使用==.
其中的原因:
static final Integer cache[] = new Integer[-(-128) + 127 + 1];
static {
for(int i = 0; i < cache.length; i++)
cache[i] = new Integer(i - 128);
}
}
这是源码中的,也就是说cache中已有-128到127,不在这范围的会新new ,这时可以理解比较是内存地址,
也就是是不是同一对象.
所以说当Integer的值不在-128到127的时候使用==方法判断是否相等就会出错,在这个范围之内的就会没有问题!!!
对于 5,如果注释掉 3,就无缓存,则会返回false
10.3.3 条件编译
java中,if能做条件编译,
public static void main(){
if(true){
syso(2)
}else{
syso(1)
}
}
条件编译后 反编译出来后就变成了
public static void main(){
syso(2)
}
编译器无论在何时、任何状态下把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制码,它都可以视为整个编译器的后端。虽然提前编译(AOT)早已有所应用,但是即时编译才是占绝对主流的编译形式。后端编译器编译性能的好坏、代码优化质量的高低却是衡量一款商业虚拟机优秀与否的关键指标之一。
Java程序最初都是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块运行得特别频繁,就会认定这些代码是“热点代码”,为了执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并尽可能的进行代码优化,在运行期完成这个任务的后端编译器被称为即时编译器。首先我们要思考几个问题:
解释器和编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行内存资源限制较大,可以使用解释执行节约内存(比如部分嵌入式系统和一些javaCard应用就只有解释器存在),反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化的“逃生门角色”,让编译器根据概率选择一些不能保证所有情况都正确,但是大多数都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类以后,类型继承结构出现编号,出现了“罕见陷阱”时可以通过逆优化退回到解释状态继续执行。
Hotspot虚拟机内置了两个(或三个)即时编译器,其中两个编译器存在已久,分别被称为“客户端编译器”和“服务端编译器”,或者简称为C1和C2编译器。第三个是JDK10才出现的、长期目标是代替C2的Graal编译器。
在分层编译的工作模式出现以前,HotSpot虚拟机通常采用解释器与其中一个编译器直接搭配工作,选择哪一个编译器,只取决于虚拟机运行模式,HotSpot会根据自身版本与宿主机器硬件性能自动选择运行模式,用户也可以使用“-client” 或者“-server”参数去强制指定虚拟机运行模式。
解释器与编译器搭配使用的方式(无论是客户端编译器还是服务端编译器),被称为“混合模式(Mixed Mode)”,用户也可以使用参数“-Xint”强制虚拟机运行“解释模式(Interpreted Mode)”,或者“-Xcomp”强制运行“编译模式(Compiled Mode)”,这个时候将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。通过虚拟机的 “-version”命令可以显示这三种模式。
由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花费的时间就便越长;而且想要编译出优化程度更高的代码,解释器可能还要提编译器收集性能监控信息,这对解释器执行阶段的速度也会有所影响。为了在程序启动响应速度与运行速度达到最佳平衡,HotSpot在编译子系统中加入了分层编译的功能,在JDK6时期初步被实现,后来一直在改进阶段,最终在JDK7的服务端模式虚拟机作为默认编译策略被开启。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量,各个层次编译交互,互相转化。
实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码会被多次编译,用客户端编译器获得更高的编译速度,用服务端编译器来获得更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用更高复杂度的优化算法时,客户端编译器可采用简单优化为它争取更多的编译时间。
热点代码主要有两类,被多次调用的方法和被多次执行的循环体。循环体是一个方法只被调用一次或者少量几次,但是方法体内部存在循环次数较多的循环体。对于这两种情况,编译的目标都是整个方法体,而不会是单独的循环体。对于循环体的编译,在编译时会传入执行入口点字节码序号,这种编译方式因为编译发生在方法执行的过程中,因此被称为栈上替换,即方法的栈帧还在栈上,方法就被替换了。
要知道某段代码是否是热点代码,是否需要触发即时编译,这个行为称为“热点探测”,主流的热点探测判断方法有两种:
HotSpot使用的是第二种,为了实现热点计数,HotSpot为每个方法准备了两类计数器:方法调用计数器和回边计数器(回边的意思指在循环边界往回跳转)。方法调用计数器,在客户端模式下是1500次,在服务端模式下时10000次,这个阈值可以通过-XX:CompileThreshold来人为设定。虚拟机会加持该方法是否被即时编译过,如果没有,则调用计数器+1,然后判断方法调用器与回边计数器之和是否超过方法调用计数器的阈值,一旦超过,则提交一个该方法的代码编译请求。
在默认模式下,执行引擎不会同步等待编译请求,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下次调用就会使用已经编译好的版本了。
在默认模式下,方法调用计数器统计并不是方法的调用绝对次数,而是一个相对执行频率,即一段时间内方法被调用的次数。当超过一定时间限度,方法调用次数仍然不足以让它提交给即时编译器编译,那么方法调用计数会被减少一半,这个过程被称为方法调用计数器热度衰减,而这段时间就称为此方法的半衰周期,进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的。可以使用-XX:-UseCounterDecay来关闭热度衰减,这样只要系统运行时间足够长,程序中绝大部分方法会被编译成本地代码。另外还可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。
回边计数器,统计方法中循环体代码执行的次数(是回边次数,不是循环次数,因为并非所有循环都回边,比如空循环不会被统计),在字节码中遇到控制流向后跳转的指令就称为“回边”,建立回边计数器统计的目的是为了触发栈上的替换编译。
我们可以设置-XX:CompileThreshold的参数,我们必须设置另外一个参数:-XX:OnStackReplacePercentage来间接调整回边计数器的阈值,其计算公式有如下两种。
当解释器遇到一条回边指令时,会检查代码片段是否已经编译好,如果有它会优先执行已经编译的代码,否则回边计数器值加1,然后判断方法调用计数器与回边计数器的之和是否超过阈值。当超过阈值的时候,将会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低一点,以便继续在解释器中执行循环,等待编译器输出编译结果。回边计数器没有衰减,当计数器溢出时候,会把方法计数器调整到溢出状态,这样下次再进入该方法的时候就会执行标准的编译过程。
在默认条件下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在未完成编译之前,都将按照解释方式执行代码,而编译动作则在后台的编译线程中进行。用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译,这样执行线程会一直阻塞,直到编译过程完成再开始再开始执行编译器输出的本地代码。
在后台执行编译过程中,编译器在服务端和客户端模式的编译过程是有差别的。对于客户端编译器来说,它是一个相对简单快速的三段式编译器,主要关注点是在于局部性的优化,而放弃许多耗时较长的全局优化手段。
在第一阶段,一个独立于平台的前段将字节码构造成一种高级中间代码(HIR,与目标机器指令集无关的中间表示)。HIR使用静态单分配的形式来代表码值,这使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前,编译器在字节码上完成一部分基础优化,如方法内联、常量传播等优化将在字节码被构造成HIR之前完成
在第二阶段,一个平台相关的后端从HIR产生低级中间代码(LIR,与目标机器指令集相关的中间表示),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR得到更高效的代码
在第三阶段是在平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码。
而服务端编译,它会执行大部分经典的优化动作:无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序、还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除、空值检查消除,不过并非所有空值检查消除都是依赖编译器优化的,有一些是代码运行过程中自动优化了的。还可能更加解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联、分支频率预测等。
服务端采用的寄存器是一个全局图着色分配器,它可以充分利用某些处理器架构上的大寄存器集合,它相对于客户端编译器编译输出的代码质量有很大提高,可以大幅度减少本地代码的执行时间,从而抵消掉额外的编译时间开销。
虚拟机是通过解释来执行代码还是编译,对于用户来说没有什么影响,但是HotSpot虚拟机还是提供了一些参数用来输出即时编译和某些优化措施的运行状况。
Java 的一个核心优势就是平台中立性,其宣传口号是“一次编译,导出运行”,这与平台相关的提前编译在理论上就是直接冲突了。
提前编译产品和对齐的研究有着两条明星的分支,一条分支是做与传统C、C++编译器类似,在程序运行之前把程序代码编译成机器码的静态翻译工作;另外一条分支是把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行这些代码(比如公共库代码在同一台机器其他Java进程使用)时直接把它加载进来使用
即时编译会占用程序运行时间和运算资源,比如,在编译过程中最耗时的优化措施之一是通过“过程间”分析来获得某个程序点上的某个变量的值是否一定为常量、某段代码是否永远不可能被使用,在某个点调用的某个虚方法是否只能有单一版本等分析戒撸。这些操作可以生成高质量的优化代码,但是要精确判断这些信息,必须在全程序范围内做大量极耗时的操作。目前所有Java虚拟机堆过程间的支持都相当有限,要么借助大规模的方法内联来打通方法间的隔阂,以过程内分析来模拟过程间的分析部分效果;要么借助可假设的激进优化,不求得到精确结果,只求按照最可能的状况来优化,有问题再退回来做解释执行。而提前编译,在程序运行前,进行的静态编译,这些耗时的优化就可以放心大胆的进行了。
对于提前编译的第二条路径,本质是给即时编译器做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热后才能达到最高性能的问题。这种提前编译被称为动态提前编译,或者直接叫做即时编译缓存。在目前的Java技术体系里,这条路径的提前编译已经被主流的商业JDK支持。在OpenJdk、OracleJdk9中所带的Jaotc提前编译器,这是基于Graal编译器实现的新工具,目的是让用户可以针对目标机器,为应用程序进行提前编译。HotSpot运行时可以直接加载这些编译结果,实现加快程序启动速度,减少程序达到全速运行状态所需时间的目的。比如java.base等模块,如果将这个类库提前编译好,并且进行比较高指令的优化,显然能够节约不少应用运行时的编译成本。
提前编译的缺点在于,它破坏了平台中立性,字节膨胀等缺点,它不仅要和目标机器相关,甚至还必须与HotSpot虚拟机的运行时参数绑定。比如虚拟机运行时采用了不同垃圾收集器,这原本就需要即时编译子系统配合(典型的如生产内存屏障代码)才能正确工作。
即时编译器输出的代码质量就一定比提前编译质量差吗,虽然提前编译没有执行时间和资源限制压力,能够毫无顾忌的使用重负载的优化手段。即时编译器相对于提前编译器有天然下面优势:
在未来很长一段时间内,即时编译和提前编译都会是Java后端编译技术的共同主角。
第一步原始代码
static class B{
int value;
final int get(){
return value;
}
}
public void foo(){
y=b.get();
//to do
z=b.get();
sum=y+z;
}
第一个进行的优化就是方法内联,主要两个目的:一是出去方法调用成本(如查找方法版本、建立栈帧);二是为其他优化简历良好的基础。方法内联膨胀之后可以便于在更大范围上进行后续的优化手段,因此各中编译器一般把内联优化放在优化序列最靠前的位置。
public void foo(){
y=b.value;
//to do
z=b.value;
sum=y+z;
}
第二步是冗余消除访问,假设代码中介的to do 代表操作不会改变b.value的值,那么可以把z=b.value替换成 z=y
public void foo(){
y=b.value;
//to do
z=y;
sum=y+z;
}
第三步进行复写传播,因为这段程序逻辑中没必要使用一个额外变量Z.
public void foo(){
y=b.value;
//to do
y=y;
sum=y+y;
}
第四步,无用代码消除,无用代码可能是永远不会被执行的代码,或者没有意义的代码
public void foo(){
y=b.value;
//to do
sum=y+y;
}
经过四次优化后,代码所达到的效果是一致的,而且省了很多,在字节码和机器码指令上差距更大.
方法内联是编译器最重要的优化手段,它是优化之母,因为除了消除方法调用成本之外,它更重要的意义是为其他优化手段建立良好的基础。如果不做内联,很多优化都无法进行
public static void foo(Object obj) {
if (obj != null) {
System.out.println();
}
}
public static void testInline(String args) {
Object obj = null;
foo(obj);
}
这个例子,如果不做方法内联,根本无法进行无用代码消除
内联的难点在于,Java方法解析和分派调用的时候:只有使用invokespecial指令调用的私有方法、实例构造器、父类方法和使用invokestatic指令调用的静态方法才会在编译器进行解析。除了上述四种方法(最多再除去被final修饰的方法这种特殊情况,尽管它使用invokevirtual指令调用,但是也是非虚方法),其他Java方法调用都必须在运行时进行方法接受者的多态选择,他们都有可能存在多于一个版本的方法接受者,简而言之,Java语言中默认的实例方法是虚方法,而JAVA间接鼓励了程序员使用大量的虚方法来实现逻辑(面向对象的方式,比如多态)。从根本面上来说,内联与虚方法会产生矛盾。并不是说就应该默认给每个方法使用final关键字去修饰。
为了解决虚方法的内联问题,Java虚拟机引入了类型继承关系分析的技术(CHA),这是整个应用程序范围内的类型分析技术,用于确定在目前已经加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类,某个子类是否覆盖了父类的某个虚方法等信息。这样编译器在进行内联时就会分不同情况:如果是非虚方法,直接进行内联;如果是虚方法,则会向CHA查询当前是否有多个版本的方法可供选择,如果只有一个,那么假设“应用程序的全貌就是现在这个样子”,进行内联,这种内联被称为守护内联。不过由于Java是动态链接的,说不准什么时候改变了CHA的结论,因此这种内联是激进预测优化,必须留好“逃生门”,当假设不成立的时候(加载了导致继承关系发生变化的新类型),就必须抛弃已经变异好的代码,退回到解释执行,或者重新编译。
加入CHA查询出该方法确实有多个版本供选择,那么即时编译器还会进行一次最后努力,使用内联缓存来缩减方法调用的开销,这种状态下方法调用是真正发生了的,但是比起直接查虚方法表还是要快一些。当第一次调用发生后,缓存记录下方法接受者的版本信息,并且每次进行方法调用的时候都进行比较接受者的版本。如果以后每次调用的方法接受者版本都一样,这个时候就单态内联缓存,通过该缓存来调用,比用不内联的非虚方法调用,仅多了一次类型判断开销而已。如果真的出现方法接受者不一致的情况,就说明程序用到了虚方法的多态特性,这个时候退化成超多台内联缓存,其开销相当于查找虚方法表进行方法分派。
逃逸分析与类型继承分析关系一样,不是直接优化代码手段,而是为其他优化措施提供依据的分析技术。
基本原理:分析对象动态作用域,当一个对象在方法里被定义后,它可能被外部方法所引用:例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至可能被外部线程访问到,比如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸、线程逃逸,称为对象由低到高的不同逃逸程度。
如果能证明一个对象不会逃逸到方法或线程之外,或者逃逸程度比较低,则可能为这个对象实例采取不同程度的优化:
原始伪代码
public int test(int x){
int xx=x+2;
Point p=new Point(xx,42);
return p.getX();
}
第一步,将Point构造函数和getX()方法进行内联优化
public int test(int x){
int xx=x+2;
Point p= point_memory_alloc()//堆中分配对象
p.x=xx
p.y=42
return p.x
}
第二步,经过逃逸分析,发现Point对象实例不会发生任何程度的逃逸,这样进行标量替换,避免实例被创建
public int test(int x){
int xx=x+2;
int px=xx;
int py=42;
return px;
}
第三步,通过数据流分析,发现py的值不会对方法造成影响,进行无效代码消除
public int test(int x){
return x+2;
}
在实际的应用程序中,实施逃逸分析可能出现的效果不稳定情况,或者再分析过程耗时的,但是无法判别出非逃逸对象而导致性能下降,所以在JDK6的时候,默认不开启。在JDK7的时候,才成为服务端编译器默认开启的选项。可以通过-XX:DoEscapeAnalysis来手动开启逃逸分析,开启之后可以通过参数-XX:+PrintEscapeAnalysis来查看分析结果,有了逃逸分析支持,可以使用参数-XX:+EliminateAllocations来开启标量替换,使用+XX:+EliminateLocks来开启同步消除,使用-XX:+PrintEliminateAllocations查看标量替换情况。
尽管现在逃逸分析技术仍然在发展之中,为完全成熟,但是在日后肯定会发展更好。
公共子表达式消除是一项非常经典的、普遍应用于各个编译器的优化技术,如果一个表达式E之前被计算过,并且从先前的计算到现在E中的所有变量值没有发生变化,那么E的这次出现就称为公共子表达式。这种优化如果仅限于程序块内,就称为局部公共子表达式消除,如果涵盖了多个基本块,就称为全局公共子表达式消除。
数组边界加持消除是即时编译器中一项语言相关的经典优化技术,在编译期,Java会自动判断是否数组越界,能够避免大多数溢出攻击。但是对于虚拟机的执行子系统来说,每次数组元素的读写都带来一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,必定是一种性能负担。
为了安全,数组边界检查肯定是要做的,但是不是在运行期一次不漏的进行,比如foo[3],只要在编译期根据数据流分析确定了foo.length的值,并且判断3没有越界,执行的时候就无须判断了。更常见的情况是,在循环中,使用循环变量来进行数组访问,如果编译器只要通过数据流分析,可以判断循环变量永远在[0,foo.length]之内,那么在循环中就可以把整个数组的越界判断消除掉。
从更高的视角来看,大量的安全检查使编写Java程序比较简单,但是Java虚拟机会这些检查。为了消除这些隐式开销,除了数组边界检查优化,还有一种是隐式异常处理,Java的空指针检查和算术运算中除0的检查都采用这种方案
if (foo!=null){
return foo.value
}else{
throw new NullPointException();
}
隐式优化后
try{
return foo.value
}catch(segment_fault){
uncommon_trap
}
虚拟机会注册一个Segment Fault信号的异常处理器(伪代码中的uncommon_trap(),指的是进程层面的异常处理器,而不是java中try-cathc的异常处理器),当foo不为空的时候,对value的访问是不会有任何额外的判断非空的开销的。当foo真的为空的,必须转到异常处理器中恢复中断并且抛出NullPointException异常。进入异常处理器涉及到用户态转为核心态的过程,速度远比一次判空的检查慢,当foo极少空的时候,隐式异常是值得的。HotSpot会根据监控信息来来自动选择是否使用隐式异常。
与语言相关的其他消除,比如自动装箱消除,安全点消除,消除反射等。
在许多场景下,让计算机同时去做几件事情,不仅是因为计算机的运算能力强大,还有一个原因是计算机的运算速度与它的存储和通信子系统的速度差距太大了,大量的时间都花费在磁盘I/O,网络通信或者数据访问上。
衡量一个服务器性能的好坏,每秒事务处理数(Transactions Per Second,TPS)是重要的指标之一,它代表一秒内服务端平均能响应的请求总数,而TPS值与程序的并发能力又有非常密切的关系。程序线程并发协调越有条不紊,效率自然就会越高;反之,线程间频繁争用数据,互相阻塞甚至死锁,将会大大降低程序的并发能力。
由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统不得不加入一层或多层读写速度尽可能接近处理器的运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能够快速的进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存速度直接的矛盾,但是也引入了一个新的问题:缓存一致性。在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,这种系统称为共享内存多核系统。当多个处理器的运算任务都涉及同一块主内存区域是,将可能导致各自的缓存数据不一致。为了解决一致性问题,需要各个处理器反问缓存时都遵循一些协议,在读写时根据协议来操作,这类协议有MSI、MESI、MOSI、Synapse等。
除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输出代码进行乱序执行优化,处理器会在计算之后将乱序的执行结果重组,保证该结果与顺序执行结果一致,但是并不保证程序中各个语句计算的先后顺序与输出代码中的顺序一致。Java虚拟机的即时编译器中也有指令重排序优化
Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。这里的变量包括实例字段、静态字段和构成数组对象的元素,到时不包括局部变量与方法参数,因为后者是线程私有的(如果局部变量的一个Reference类型,它引用的对象在Java堆中可被各个线程共享,但是Referece本身在Java栈的局部变量表中是线程私有),不会被共享,自然不会存在竞争问题。为了获得更好的执行效能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存交互,没有限制即时编译器是否要进行调整代码执行顺序这类优化措施。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存(可与前面讲的高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本。线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存的数据(volatile变量依然有工作内存的拷贝,但是由于它特殊的操作顺序性,所以看起来如同直接在主内存读写访问一般)。
Java堆、栈、方法区和上面所得内存两者基本上没有任何关系。如果要勉强对应起来,主内存主要对应Java堆中的对象实例数据部分。而工作内存对应虚拟机栈的部分区域。从更基础的层次上说,主内存直接对应物理硬件的内存。
一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存,Java内存模型定义了8种操作。而且每一种操作是原子的、不可再分的(在最新的JSR-133文档中,已经缩减为四种操作)
lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态
unlock(解锁):作用于主内存变量,它把一个处于锁定的变量释放出来
read:作用域主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
use(使用):作用于工作内存的变量,它把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时会执行这个操作
assign(赋值):作用于工作内存的变量,它把一个执行引擎接收的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存,以便随后的write操作使用
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中
如果要把一个变量从主内存拷贝到工作内存,那就要按照顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按照顺序执行store和write操作。java内存模型只要求上述两个操作必须按顺序执行,但是不要求连续执行。Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但是工作内存不接受,或者工作内存发起了回写但是主内存不接受的情况
不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该编号同步回主内存
不允许一个线程无原因地(没有发生任何assign操作)把数据从线程的工作内存同步回主内存
一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)变量,换句话说对一个变量实施user、store操作之前,必须先执行assign和load操作
一个变量同一时刻只允许一条线程对其进行lock操作,但是lock操作可以被一条线程重复执行多次
如果对一个变量执行lock操作,那么将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或者assign操作以初始化变量的值
如果变量没被lock,那么不允许执行unlock
对一个变量执行unlock操作之前,必须把此变量同步回主内存(执行store、write操作)
Java设计团队觉得这样的8中内存访问操作和上述规定,会导致线程并发安全施行起来比较麻烦,后来将内存模型简化为read、write、lock、unlock四种,但是只是语言描述上的简化,Java内存模型的基础设计并未改变。我们可以通过先行发生原则(happens-before)来确定一个操作在并发环境下是否是安全的。
volatile是Java虚拟机提供的最轻量级的同步机制。一个变量被定义成volatile之后,它将具备两项特性:
第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的指,新值对于其他线程来说可以说立即得知的。volatile在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度来看,各个线程的工作内存的volatile变量可以存在不一致的情况,但是由于每次使用之前都要刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的。比如volatile i++;这种操作,在多线程的运行条件下就会出问题,原理就是当一个线程把i的值读取到操作栈顶时,volatile关键字保证了i此时的值是正确的,但是在执行iconst_1,iadd的指令时候,其他线程已经把i的值改变,而操作栈顶的值就变成了过期数据,此时完成操作后,就把较小的i的值同步回主内存了。
由于volatile变量只能保证可见性,在不符合一下两条规则的运算场景中,我们仍要通过加锁(使用synchronized、java.util.concurrent中的锁或原子类)来保证原子性:
第二项是禁止指令排序优化,普通的变量仅会保证该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量复制操作的顺序与程序代码中的执行顺序一致。这就是Java内存模型中描述的所谓“线程内表现为串行的语义”。
下面代码演示了为何指令重排序会干扰程序的并发执行。
/**
* 线程A,做一些配置的初始化
*/
volatile boolean initialized=false;
doConfig(fileName); //这里做一些初始化动作
initialized=true ;
/**
* 线程B在线程A初始化完成后,再使用线程A初始化好的配置信息
*/
while(!initialized){
sleep();
}
doSomethindWithConfig();
}
如果 变量initialized没有被volatile修饰,可能会由于指令重排序的优化,导致位于线程A中最后一条代码“initialized=true”被提前执行,这样线程B中使用的配置信息代码就可能出现错误,而volatile关键字则可避免此类情况的发生。
volatile修饰的变量,在即时编译产生的汇编代码中,会在赋值后多执行一个“lock add1$0x0,(%esp)”操作,这个操作相当于一个内存屏障,指令重排序时不能把后面的指令重排序到内存屏障之前的位置,只有一个处理器访问内存时,不需要内存屏障;但是如果有两个或者更多处理器访问同一块内存,且其中一个在观测另外一个,就需要内存屏障来保证以一致性了。
这句指令“add1 $0x0,(%esp)”(把esp寄存器加0)显然是空操作,它的作用 是将本处理器的缓存写入内存,然后让其他处理器无效化其缓存,这种操作相当于对缓存变量做了一次前面介绍的Java内核模式所说的“store和write操作”。通过这样一个空操作可以让前面的volatile变量的修改对其他处理器立即可见。
这句指令如何做到的禁止重排序呢?指令重排序是指处理器采用了允许将多条指令不按照程序规定的顺序分开发送给各个电路单元进行处理,但是不是说指令会任意重排,处理器必须能正确处理指令依赖情况保证程序能得出正确的执行结果。所以在同一个处理器中,重排序过的代码看起来依然是有序的。因此“lock add1$0x0,(%esp)” 指令把修改同步到内存时,意味着之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。
volatile的同步机制的性能确实优于锁,但是由于虚拟机对锁实现了许多消除和优化,所以不一定是所有情况下都是这样。volatile变量读操作的性能消耗与普通变量一样,但是写操作可能会慢一些。
Java内存模型定义:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32为操作来进行,即运行虚拟机自行选择是否要保证64位锁具类型的load、store、read和write这四个操作的原子性。
由于现代中央处理器一般都包含专门用于处理浮点数据的浮点运算器,用来专门处理单、双精度额浮点数据,所以哪怕是32位虚拟机中也不会出现非原子性访问的问题。
1.原子性
有Java内存模型来直接保证原子性变量操作包括read、load、assign、use、store和write这六个,基本数据类型的访问,读写都是具备原子性的,如果需要更大范围的原子性保证,Java内存模型提供了lock和unlock来满足,尽管虚拟机没有把lock和unlock操作直接开放给用户,但是提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用者两个操作。这两个字节码指令反映到Java代码中就是同步块-synchronized关键字,因此synchronized块之间的操作也具备原子性
2.可见性
可见性是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此volatile保证了多线程操作时的变量可见性,而普通变量不能
除了volatile之外,Java还有synchronized和final能实现可见性。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得的。而final 的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”引用传递出去,其他线程就能看见final的值。
3. 有序性
Java程序中天然的有序性可以总结为:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另外一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句指“指令重排序”现象和“工作内存与主内存同步延迟”现象。valatile本身就包含了指令重排序语义,而synchronized则是有“一个变量在同一时刻只允许一条线程对其进行lock操作”来获得的。
Happens-Before原则。它是判断数据是否存在竞争,线程是否安全非常有用的手段。先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括内存中共享变量的值、发送了消息,调用了方法等。
// A线程
i=1;
//B线程
j=i;
//C线程
i=2;
假设A操作先行发生于B的操作,那么B操作执行后,变量J一定等于1,
得出这个结论的依据有两个:一是根据先行发生原则,
“i=1”的结果可以被观察到;二是线程C还没登场,
线程A操作结束后,没有其他线程会修改变量i的值。
再来考虑C,我们依然假设A操作先行发生于B,
如果C出现在A和B的操作之间,但是C与B没有先行发生关系,
那么J的值就可以能为1,或者2,因为C对变量i的影响可能会被B观察到,
可能不会,这时候B就存在读取过期数据的风险,不具备多线程安全性
Java内存模型下,有一些天然的先行发生关系,这些关系无须任何同步器协助就存在。
Java语言无须任何同步手段保证就能成立的先行发生规则有且只有上面这些。可以通过这些规则去判断操作间是否具备顺序性。
private int value=0;
public void setValue(int value){
this.value=value
}
public int get value{
return value;
}
假设线程A先(时间上先)调用了setValue(1),
然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值是多少呢。
通过先行发生原则分析:
由于是多个线程,那么程序次序规则不适用
由于没有同步块,自然没有lock和unlokc,管程锁定规则不适用
后面的线程启动、终止、中断规则和对象终结规则也没有关系。
尽管线程A在操作时间上先于线程B,但是无法确定线程B中getValue()方法的返回结果,
换句话说这里的操不是线程安全的
解决办法,定义两个方法都为synchronized方法,套用管程锁定规则
由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景,对变量修饰volatile
可以得出结论,一个操作“时间上的先发生”,不代表这个操作是“先行发生”。时间先后顺序与先行发生原则终结基本没有因果关系
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/0等),又可以独立调度。
Thread类与大部分的Java类库API有显著差别,它的所有关键方法都被声明为Native。一个Native方法往往意味着这个方法没有使用或者无法使用平台无关的手段来实现(当然也可能是为了执行效率而使用Native方法,不过通常最高效率手段也就是平台相关手段)
实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N),使用用户线程加清理级进程实现(N:M)
1.内核线程实现
使用内核线程实现的方式也被称为1:1实现,内核线程(KTL)就是直接由操作系统内核支持的线程,这种线程有内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。
程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口-轻量级进程(LWP),轻量级进程就是我们通常意义上所讲的线程。每一个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量即进程与内核线程之间的1:1关系成为一对一线程模型。
由于内核线程的支持,每个轻量级进程都能成为一个独立的调度单元,即使某一个轻量级进程被阻塞了,也不会影响整个进程继续工作。轻量级进程有它的局限性:由于是基于内核线程实现的,所以各种线程的操作,创建,析构和同步,都需要进行系统调用。而系统调用需要在用户态和内核态来回切换。其次每个轻量级进程都需要一个内核线程支持,因此轻量级进程要消耗内核资源(内核线程的栈空间),一个系统支持的轻量级进程的数量是有限的。
2. 用户线程实现
用户线程实现的方式被称为1:N实现,广义来讲,只要不是内核线程,都可以认为是用户线程。轻量级进程也可以看做是用户线程,但是轻量级进程不具备通常意义上用户线程的优点
狭义上的用户线程是完全建立在用户空间的线程上,系统内核不能感知到用户线程的存在即如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核帮助,这种线程不需要切换到内核代,因此操作是可以非常快且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。
用户线程(UT)的优势在于不需要系统内核支援,劣势也在于没有内核系统只要,所有的线程操作需要用户程序去处理。线程的创建、销毁、切换和调度都需要用户考虑,而且操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”,“多处理系统如何将线程映射到其他处理器上”这些问题处理起来就是比较困难的。近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,比如Golang、Erlang等,使得用户线程的使用率有所回升。
3、混合实现
内核线程与用户线程一起使用的实现方式,称为N:M实现,这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是建立在用户空间中,因此用户线程的创建,切换,析构等操作依然连接,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成。 许多UNIX系列的操作系统,如Solaris、HP-UX等都提供了M:N的线程模拟实现。在这些操作系统上的应用也更容易应用M:N模型
4. Java线程的实现
Java线程在早期的Classic虚拟机上,是基于一种被称为“绿色线程”的用户线程实现的,但是从JDK1.3起,“主流”平台上的“主流”商用Java虚拟机的线程模型普遍为基于操作系统原生线程模型来实现,即采用1:1的线程模型
以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,全权交给地下的操作系统去处理 。
线程调度是指系统为线程分配处理器使用权的过程,主要有两种:协同式线程调度和抢占式线程调度。如果使用协同式调度的多线程系统,线程的执行实现由线程本身来控制,线程把自己的工作执行完了以后,主动通知系统切换到另外一个线程上去。协同式多线程的最大好处是实现简单,由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么 线程同步问题。lua语言中的“协同例程”就是这类实现。它的坏处也很明显:线程执行时间不可控制,甚至如果一个线程代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。
抢占式调度的多线程系统,每个线程将由系统来分配执行时间,线程切换不由线程本身来决定。由Thread::yield()方法可以主动让出执行时间,但是如果想要主动获取执行时间,线程本身是没有办法的。Java使用的线程调度方式就是抢占式调度。
虽然说Java线程调度是系统自动完成的,但是Java语言可以通过设置线程优先级,“建议”操作系统给某些线程多分配一些执行时间。在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。但是线程优先级并不是一项稳定的调节手段,主流虚拟机的Java线程是被映射到系统的原生线程上来实现的,所以线程调度最终还是有操作系统说了算。尽管现代操作系统基本都提供了线程优先级概念,但是并不与Java线程的优先级一一对应。比如在Windows平台的虚拟机设置THREAD_PRIORITY_IDLE为1和2 对应Windows操作系统的优先级效果是一样的。
线程优先级并不是一项稳定的调节手段,这不仅体现在某些操作系统上不同的优先级实际会变得相同一点上,还有其他情况让我们不能过于依赖线程优先级,优先级可能会被系统自行改变。比如windows系统中存在一个叫“优先级推进器”的功能,大致作用是当系统发现一个线程被执行得特别频繁时,可能会越过线程优先级为它分配时间,从而减少线程频繁切换带来的性能消耗。因此我们并不能再程序中通过优先级来完全准确判断一组状态都为Ready线程将会执行哪一个。
Java语言定义了6种线程状态:
Http请求可以直接与Servlet API中的一条处理线程绑定在一起,以“一对一服务”的方式处理由浏览器发来的信息。
B/S系统中一次对外部业务请求的响应,往往需要分布在不同机器上的大量服务共同协作来实现,这种服务细分的架构在减少单个复杂度、增加复用性的同时,也不可能避免地增加了服务数量。这就要求每个服务必须在极短时间内计算,这样组合多个服务的总耗时才不会太长;也要求每个服务提供者能够处理数量更庞大的请求,这样才不会出现请求由于某个服务器被阻塞而出现等待。
java目前的并发编程机制就与上述架构趋势产生了一些矛盾,1:1的内核线程模型,映射到操作系统线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限,在以前的单体应用中,处理一个请求可以允许花费很长时间,具有这种线程切换的成本也是无伤大雅的,但现在在每个请求本身的执行时间变得很短、数量变得很多的前提下,用户线程切换的开销甚至可能会接近用于计算的本身开销,这就会造成严重的浪费。
内核线程调度成本主要来自于用户态与核心态之间的状态转化,而这两种状态转化的开销主要来自响应中断、保护和恢复执行线程的成本。
线程A->系统中断->线程B
从程序员的角度来开,方法调用过程中的各种局部变量与资源,以线程的角度来看,时方法的调用栈中存储的各类信息;而从操作系统来看,则是存储在内存、缓存和寄存器中的一个个具体数值。当中断发生,线程A切换到线程B执行之前,操作系统首先要把线程A的上下文数据妥善保管好,然后把寄存器、内存分页等恢复到线程B挂起时候的状态,这样线程B被重新激活时才能仿佛从来没被挂起过。这种保护和恢复线程B线场的工作,免不了设计一系列数据在各种寄存器、缓存中来回拷贝,当然不可能是一种轻量级的操作。
“如果一个对象可以安全地被多个线程同时使用,那它就是线程安全的”。这样形容是没有任何操作性可言,《Java并发编程实战》的作者给做了一个比较恰当的定义:“当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者再调用方法进行任何其他的协同操作,调用这个对象的行为都可以获取正确结果,那么就称这个对象是线程安全的”。
我们这里讨论的线程安全,将以多个线程之间存在共享数据访问为前提,将Java语言中各种操作共享数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
1、不可变
不可变的对象一定是线程安全的,如果多线程共享数据是一个基本数据类型,那么只要在通过final关键字修饰它就可以保证它不可变。如果共享是一个对象,就需要对象自行保证其行为不会对其状态产生任何影响。在Java类库中符合不可变要求的类型,除了上面提到的String之外,常用的枚举类型及java.lang.Number的部分子类,如Long和Double等数值保证类型、BigInteger和BigDecimal等大数据类型。而同为Number子类型的原子类AtomicInteger和AtomicLong则是可变的
2、绝对线程安全。
java.util.Vector是一个线程安全的容器,但是它不是一个绝对线程安全的,虽然他的add,get(),size()方法都被synchronized修饰的,这样虽然影响效率,但是具备了原子性,可见性和有序性。不过,即时它的所有方法都被修饰成synchronized,也不意味着调用它的时候,就永远不需要同步手段了。
(1)vector的多线程安全,是针对单纯的调用它的某个方法,是有做同步的,比如多个线程调用add的话,是线程安全的
(2)vector的多线程安全,在组合操作下,不是原子的,比如先得到size(),然后获取最后一个元素,在得到size()后,其他线程删除过元素,就会得到报错越界。
3、相对线程安全
它需要保证这个对象的单词操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,Java语言中,大部分声称线程安全的类都属于这种类型,Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。
4、线程兼容
指对象本身并不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境可以安全的使用
5、线程对立
是指不管调用端是否采用同步措施,都无法在多线程环境中并发使用代码。一个线程对立的例子是Thread类的suspend()和resume()方法,如果两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险——假如suspend()中断的线程就是即将要执行resume()那个线程,那么肯定要产生死锁。这是这个原因,suspend()和resume()都被声明为废弃
实现线程安全与代码编写有很大的关系,但是虚拟机提供的同步和锁机制也起到了至关重要的作用。
1.互斥同步
互斥是实现同步的一种手段,互斥是因,同步是果。临界区、互斥量和信号量都是常见的互斥实现方式。
在Java里面,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过Javac编译后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(实例方法或类方法),来决定是取代码所在对象实例还是取类型对应的Class对象来作为线程要持有的锁。
根据《Java虚拟机规范》的要求,在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有那个对象的锁,就把锁的计数器增加1,而在执行monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定对象被持有它的线程释放。
通过monitorenter和monitorexit行为描述,我们可以得出两个直接关于synchroized的直接推论:
1)被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程返回进入同步块也不会把自己锁死
2) 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件阻塞后面其他线程进入。这意味着无法向处理某些数据库的锁那样,强制已获得锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。
从执行成本来看,持有锁是个重量级的操作,Java线程映射到操作系统原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转化中,进行这种转化是需要耗费很多CPU时间。
基于synchronized的局限性,JDK5提供了java.tuil.concurrent.locks.Lock接口便成了Java的另外一种全新互斥同步手段。基于Lock接口,用户能够以非块结构来实现同步互斥,从而摆脱Java语言特性的束缚,改为在类库层面上去实现同步。
重入锁(ReentrantLock)是Lock接口最常见的一种实现,它相比synchronized相比增加了一些高级功能:
2、非阻塞同步
互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的的性能开销,因此这种同步也被称为阻塞同步,互斥同步属于悲观锁,无论共享数据是否真的会出现竞争,它都会进行加锁,这会导致用户态和核心态转化、维护锁计数器和坚持是否有被阻塞的线程需要被唤醒等。
基于冲突检测的乐观并发策略,通俗来说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那么操作直接成功;如果共享的数据的确被争用,产生了冲突,那再进行其他补偿措施,最常用的补偿措施就是不断重试,知道出现没有竞争的共享数据为止。这种乐观并发策略实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步。
使用乐观并发策略需要“硬件指令集的发展”,因为我们要求操作和冲突检测具备原子性,所以我们只能靠硬件来实现这件事。硬件保证某些语义上看起来需要多次操作的行为,可以通过一条处理器指令就能完成,这类指令有很多条,其中就有CAS。
CAS指令需要三个操作数,分别是内存位置(V),旧的预期值(A),和准备设置的新值(B),CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新,但是不管是否更新了V的值,都会返回V的值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。
JDK5之后,Java类库中才开始使用CAS操作,该操作有sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。HotSpot虚拟机内部对这些方法做了特殊处理,即时编译器出来的结果就是一条平台相关的处理器CAS指令,没有方法调用过程,或者可以认为是无条件内联进去了。
尽管CAS看起来很美好,既简单又高效,但是这种操作无法涵盖互斥同步所有的使用场景,它也会有ABA问题,虽然J.U.C的包为了解决问题,提供了带有标记的原子引用类AtomicStampedReference,它可以通过控制版本来保证CAS正确性。不过这个比较鸡肋,大部分的ABA问题都不需要解决,如果需要解决,传统的互斥同步可能比原子类更为高效
3、无同步方案。
要保证线程安全,也并发一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系。如果让一个方法本来就不涉及共享数据,它自然天生就是线程安全的。
大部分使用消费队列的架构模式,都会将消费过程限制在一个线程中消费完,其中最重要的一种应用实例就是经典的Web交互模型中的“一个请求对应一个服务器线程”的处理方式,这种处理方式使得很多Web服务端应用都可以使用线程本地存储来解决线程安全问题。
共享数据在大部分情况下锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,如果物理机有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会儿”,但是不放弃处理器执行时间,看看持有锁的线程是否很快就会释放。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是自旋锁。
自旋锁在1.4.2就已经引入,但是在JDK6才改为默认开启。自旋虽然避免了线程切换的开销,但是它要占用处理器时间,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自选的线程只会白白消耗处理器资源。因此自旋等待的时间必须有一定限度,自旋超过一定次数,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次,可以通过参数-XX:PreBlockSpin来更改。
JDK6对锁优化,引入了自适应的自旋,自适应意味着自旋的时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者状态决定的。对于某个锁,自旋很少成功的获得锁,那么有要获取这个锁的时候,直接省略自旋过程,进行互斥同步避免浪费。
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。变量是否逃逸,对于虚拟机需要使用复杂的过程分析才能确定,而程序员自己应该很清楚,怎么会在明知道不存在数据争用的情况下还要求同步呢,这个问题的答案就是:许多同步措施并不是程序员加入的,同步的代码在Java程序是非常频繁的。
下面的代码是没有任何同步的
public String concatstring(String s1,String s2,String s3){
return s1+s2+s3
}
String是一个不可变的类,对字符串的连接总是通过生成新的String对象来进行的。因此Javac编译器会对String连接做自动优化。在JDK5之前,会转化为StringBuffer对象的append操作,JDK5以及之后,会通过StrignBuilder对象连续append()操作。
StringBuffer.append()方法中都有一个同步块,锁就是sb对象。经过逃逸分析后,发现它的动态作用域被限制在concatString()方法内部,这里虽然有锁,但是可以被安全的消除掉
13.3.2 锁粗化
13.3.4 轻量级锁(轻量级锁原理)
轻量级锁并不是用来代替重量级锁的,它的设计初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
HotSpot虚拟机的对象头分为两个部分,第一部分用于存储对象自身的运行时数据,如哈希吗,GC分代年龄等。这部分数据长度在32位和64位Java虚拟机中分别占用32或64个比特位,官方称它为“Mark Workd”。这部分是实现轻量锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的之争,如果是数组对象,还会额外的部分用于存储数组长度。
Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,对象未锁定的状态下,Mark Word的32个比特空间里的25个比特将用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志,还有一个比特固定为0(这表示为进入偏向模式)。对象除了未被锁定的正常状态外,还有轻量级锁定,重量级锁定、GC标记、可偏向等几种状态。
轻量级锁的工作过程:
1) 在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁定标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(LockRecord)的空间,用于存储锁对象目前的Mark Word的拷贝(Dispaced Mark Word),如下图
2)虚拟机将使用CAS操作尝试把对象Mark Word更新为指向Lock Record的指针。如果更新成功,即代表该线程拥有了这个对象的锁,并且对象的Mark Word的锁标志(Mark Word最后两个比特)将转变为“00”。表示此对象处于轻量级锁定状态
3)如果这个更新操作失败了,就意味着至少存在一条线程和当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就行了。否则说明这个锁对象已经被其他线程抢占了。如果出现争用同一个锁的情况下,那轻量锁就不再有效,必须要膨胀为重量级锁,锁标志位“10”,此时Mark Word中存储的就是指向重量级锁(monitor)的指针,后面等待锁的线程也必须进入阻塞状态。
4)上面提的是加锁过程,它的解锁过程也同样是通过CAS操作来进行的,如果对象的Mark Work仍然指向线程的锁记录,那么就用CAS操作把对象当前的Mark Word 和线程复制的Displaced Mark Word替换回来。加入能够替换成功,那么整个同步过程就顺利完成;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程
轻量级锁提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”。如果没有竞争,轻量级锁便通过CAS操作成功避免使用了互斥量的开销。但是如果确实存在锁竞争,除了互斥量本身的开销外,还额外发生了CAS操作开销。因此在有竞争的情况下,轻量级锁反而比传统重量级锁更慢。
偏向锁是JDk6引入的一项锁优化措施,默认开启(启用参数-XX:+UseBiasedLocking),它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。
偏向锁的“偏”就是偏袒的偏,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他线程获取,则持有偏向锁的线程将永远也不需要同步。当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁以及对MarkWord的更新操作)。
一旦出现了另外的线程去尝试获取这个锁的情况,偏向模式马上就宣告结算。根据锁对象目前是否处于被锁定状态决定是否处于被锁定状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位“00”)的状态,后续的同步操作就是按照轻量级锁那样执行。
进入偏向状态的时候,MarkWord大部分空间(23个比特)都用于存储持有锁的线程ID了,这部分空间占用了原有对象的哈希吗位置,Java对象如果计算过哈希吗,就应该一直保持该值不变(强烈推荐但是不强制,用户可以重载hashCode()方法按照自己一样返回哈希码),否则很多依赖对象哈希码的API都可能存在出错风险。当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象争处于偏向锁状态,又收到需要计算其一致性哈希的请求时,它的偏向状态立即被撤销,并且锁会膨胀为重量级锁。在重量级锁的实现中,对象头指向了重量级锁的位置,代码重量级锁的ObjectMonitor类里有字段可以记录非加锁状态的MarkWord,自然可以存储原来的哈希码。
偏向锁可以提高带有同步但是无竞争的程序性能,但它同样是一个带有效益权衡的优化,如果程序中大多数锁都总是被不同线程访问,那么偏向模式就是多余的 。