当一个类加载到内存后,JVM运行时数据区域都需要存储什么东西呢?

1)类的格式信息、主次版本号、 常量池、字段、方法、属性信息需要存储,这部分数据需要每个线程共享;
当类加载器将静态的二进制字节码从类文件加载到内存后,对内容进行验证、解析转换为符合JVM存储的数据格式。

2)生成的Class类对象和代码运行时动态创建的对象和数组需要存储;
通过loading、linking、initializing后生成的Class对象,做为程序访问这个类的入口,
以及程序运行时产生的对象和数组,都有可能会被不同的线程引用和访问,因此这部分区域的数据也需要线程共享。
3)每个线程的线程数据需要存储和每个线程下方法的方法数据需要存储;
当线程创建的时候需要有一个线程栈来存放线程信息,以及PC计数器记录当前线程正在执行的代码行号。
还有JVM本地线程执行时需要的本地线程栈存放本地线程的执行情况。

《Java虚拟机规范》规定了如下内存区域来存储数据

  • 共享区域
  • 堆区
  • 方法区
    • 运行时常量池
    • 线程私有区域
  • JVM线程栈
  • 本地方法栈
  • PC计数器

Runtime DataArea

堆区

几乎所有的对象以及数组都存放在堆区。

方法区

用于存储已经被虚拟机加载的类型信息、常量、静态变量、及时编译器变异后的代码缓存等数据。

运行时常量池

运行时常量池是方法区的一部分,用于存放编译期或者运行期生成的各种字面量和符号引用。

Java虚拟机栈

每个线程会生成一个线程栈,每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口信息。

局部变量表

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、long 、float、double)、引用类型、returnAddress类型。
这些数据类型在局部变量表中的存储空间以局部变量槽来表示(64位的long和double占用2个变量槽,其余数据类型占用1个变量槽)。局部变量表所需的内存空间在编译期完成分配,因此当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是确定的。在运行期间是不会改变局部变量表的大小(槽的多少)。

本地方法栈

与虚拟机栈作用类似,为虚拟机使用到的本地方法服务。

PC计数器

当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

对于JDK1.8以后HotSpot虚拟机改用与Jrokit、J9一样与本地内存中实现的元空间(Meta-space)来实现方法区。无论是原空间还是永久代,他们只是方法区的实现,字符串常量池和静态变量的存储逻辑上仍然属于方法区。元空间使用的是物理内存、永久代使用的是JVM内存。因此元空间的大小只受限于物理内存。

HotSpot 虚拟机实现

HotSpot 虚拟机对象的创建与引用

对象的创建

1) 在new一个对象的时候,先需要通过类加载检查,然后虚拟机为新生对象分配内存。

对象所需要的内存大小在类加载完成后便可以完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。

  • 指针碰撞
    假设内存规整,将使用过的在一边,空闲的在一边,中间放一个指针作为分界点的指示器,所分配内存就是把指针向空闲空间方向挪动一段与对象相等的距离。
    使用Serial、ParNew带压缩整理过程的GC收集器时,采用指针碰撞,简单高效;
  • 空闲列表
    假设内存不规整,是一块块不连续的,那么就需要维护一个列表记录哪块内存可用,哪块内存不可用,在分配的时候在列表中找一块足够大的空间划分给对象实例,并更新列表上的记录。
    使用CMS基于清除算法的收集器时,理论上只能采用复杂的空闲列表来分配内存。
Thread Local Allocation Buffer

在堆中创建对象是特别频繁的事情,并且涉及到多线程的访问。如何保证高效且安全的申请空间?

  • 同步或者CAS+失败重试的方式保证更新操作的原子性;
  • 为每个线程在堆中预先分配一小块内存,通过-XX:+/-UseTLAB 设定;

2)分配完内存后,为内存空间初始化零值以及必要的设置。
初始化零值可以保证对象实例字段在Java对象中可以不赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。
之后设置这个对象的类型、关联类的元数据信息、GC分代年龄信息等。

3)执行 <init>() 方法,将对象进行初始化。

对象的内存布局

在Hotspot虚拟机中,对象在内存中的存储布局划分为三个部分

  • 对象头(Header)
    • 一部分是对象自身运行时数据,HashCode, GC分代年龄,锁状态标志,线程持有锁,偏向线程ID,偏向时间戳(数组长度)。
    • 一部分是类型指针,指向它的类型元数据的指针,用来确定是哪个类的实例。
  • 实例数据(Instance Data)
    字段的存储顺序受到虚拟机分配策略(-XX:FieldsAllocationStyle)和字段在源码中的顺序影响。
    默认相同宽度的字段被分配到一起存放,父类中定义的变量会出现在子类之前。
    使用+XX:CompactFields=true字类中较窄的变量也会插入到父类变量的空隙之中。
  • 填充(Padding)
    为了满足硬件的性能需求和提高垃圾回收时指针扫描的方便性。HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8个字节的整数倍(任何对象的大小必须是8个字节的整数倍)。

对象的访问定位

对象在堆中创建好了,Java程序会通过栈上局部变量表中的reference字段来引用堆上的对象。

  • 句柄访问
    Java堆中划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自具体的地址信息。
    好处:reference中存储的事稳定的句柄地址,对象被移动时只会改变句柄中的实例数据指针。
    句柄访问
  • 直接指针
    reference中存储的是对象的地址。好处是速度快,节约了指针定位的时间开销。
    直接指针

计算机为了提升效率,增加了多内核,并且为每个内核增加了自己的高速缓存,而他们又共享同一个主内存。

计算机处理器、高速缓存、主内存之间的交互关系

除了增加高速缓存外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,但保证结果与顺序执行的结果一致。

与处理器的乱序执行优化类似,Java虚拟机的即时编译器也有指令重排序优化。

Java内存模型

《Java虚拟机规范》中曾试图定义一种Java内存模型来屏蔽各种硬件和操作系统的内存访问差异。

主内存与工作内存

Java内存模型的主要目的是定义程序中各种变量的访问规则(关注虚拟机中把变量存储到内存和从内存中取出变量的底层细节。)
Java内存模型规定了所有的变量都存储在主内存(除线程私有的以外)。
线程的工作内存保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存的数据。不同的线程之间也无法直接访问对方工作内存的变量,线程之间变量值的传递均需要通过主内存来完成。

内存间交互操作

关于主内存与工作内存之间的交互协议,Java内存模型中定义了8种的操作。

Java虚拟机实现时必须保证每种操作都是原子的,不可再分的(对于double和long来讲,load、store、、read、write允许有例外)。

  • lock : 锁定,作用于主内存的变量,把一个变量标识为一条线程独占的状态。

  • unlock: 解锁,作用于主内存的变量,把一个处于锁定状体的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read: 读取,作用于主内存的变量,把一个变量的值从主内存传输到工作内存,以便随后的load动作使用。

  • write: 写入,作用于主内存的变量,把store操作从工作内存中得到的变量值存放到主内存变量中。

  • load: 载入,作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use:使用,作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

  • assign: 赋值,作用于工作变量,把一个从执行引擎接收的值赋给工作内存变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store: 存储,把工作内存中一个变量的值传送到内存中,以便随后的write操作使用。

Java内存模型规定必须满足如下规则:

1) 不允许read 和 load、store和write操作单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写单主内存不接受
2)不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
3)不允许一个线程在没有发生assign操作就把数据从线程的工作内存同步回主内存中。
4) 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load\assign)的变量,即对一个变量进行use之前必须使用load, 使用store之前必须使用assign。。
5)一个变量在同一个时刻内只允许一个线程对其进行lock操作, 但lock操作可以被同一个线程重复执行多次, 多次执行lock后, 只有执行相同次数的unlock操作,变量才会被解锁。
6)对一个变量执行unlock之前, 必须先把此变量同步回主内存中(执行store、write操作)

volatile型变量的特殊规则

volatile是Java虚拟机最轻量级的同步机制。它有两个特性:
1)保证此变量对于所有线程的可见性(一个线程修改了这个变量的值,其他线程立即可见,也就是每个线程在使用被volatile修饰的变量时,都会强制从主内存中同步该变量的值到工作内存中)
在不符合以下两条规则的场景中,仍然需要通过加锁来保证原子性(synchronized、java.util.concurrent中的锁或者原子类):

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值;
  • 变量不需要与其他状态变量共同参与不变约束;

2) 禁止指令重排序优化

volatile变量读操作的性能与普通变量几乎没有差别,但是写操作会慢些, 因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

Singleton DoubleCheck

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Singleton {
private Singleton() {}

private volatile static Singleton instance = null;

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); //warning
}
}
}
return instance;
}
}

在上面的代码中,instance = new Singleton()最终会被编译成多条汇编指令。
(1)为Singleton的实例分配内存。
(2)调用Singleton的构造函数,初始化成员变量
(3)将instance对象指向分配的内存空间。

或者可能被重排序为(1)、(3)、(2), 也就是当分配内存后,如果没有对instance 加volatile,将instance对象指向分配的内存空间后,另外一个线程可以读到未被初始化的对象。

针对long和double类型变量的特殊规则

Java内存模型要求lock、unlock、read、write、 load、assign、use、store这8种操作都具有原子性,但是对于64位数据类型的long 和double, 在模型中定义了一条宽松的规定, 允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为2次32为的操作来执行,也就是虚拟机自己选择是否要保证64位数据类型的load、store、read、write这四个操作的原子性。

经过实际的测试,在目前的主流平台下商用的64位Java虚拟机中并不会出现非原子性访问行为,但是对于32位的Java虚拟机(如32位的X86Hotspot)存在非原子性访问的风险。

因此,在实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写代码的时候一般不需要因为这个原因刻意把用到的long和double变量专门声明位volatile。

原子性、可见性与有序性

原子性(Atomicity)

有Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store、write。因此基本数据类型的访问都是具备原子性的。

如果应用场景需要一个更大范围的保证,需要使用锁、或者synchronized关键字来保证原子性。
(lock、unlock并没有直接开放给用户使用)。

可见性(visibility)

可见性就是当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

volatile

volatile的特殊规则保证了新值立即能同步到主内存,以及每次使用前都立即从主内存刷新。而普通变量则不能保证“立即”。

synchronized

同步块的可见性时由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”这条规则获得。

final

被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this的引用传递出去,那么在其他线程中就能看见final字段的值。

this引用逃逸,其他线程有可能通过这个引用访问到“初始化了一般”的对象。

有序性

如果在本线程中观察,所有的操作都是有序的(线程内似表现为串行的语义);
如果在一个线程中观察另一个线程,所有的操作都是无序的(指令重排序和工作内存与主内存同步延迟现象);

Java语言提供了volatile和sychronized两个关键字来保证线程之间的有序性。
volatile:本身包含了禁用指令重排序的语义;
synchronized:一个变量在同一时刻只允许一个线程对其进行lock操作;

先行发生原则

先行发生原则:判断数据是否存在竞争,线程是否安全的重要手段。

程序次序规则:在一个线程内,按照控制流顺序(分支、循环等),书写在前面的操作先行发生在书写在后面的操作;

管程锁定规则:一个unlock的操作先行发生在后面对同一个锁的操作。

volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作;

线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作;

线程的中止规则:线程中所有的操作都先行发生于此线程的终止检测,可以通过thread.join()方法是否结束、thread.isAlive()返回值来检测线程是否已经停止;

线程的终端规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过thread.interrupted()检测是否有中断发生;

对象终结规则:一个对象初始化完成(构造函数执行结束)先行发生于他的finalize()方法的开始;

传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出,操作A先行发生于操作C。

Java代码要在JVM中正常运行,首先要先编译成.class文件,由类加载器将其加载到内存中。之后由类编译器来编译执行。
在加载的过程中需要注意什么呢?
1)什么时候需要加载一个类呢?
2)随便任何一个文件都是否可以被加载呢,它的加载过程是什么样的?
3)类加载器需要对内存中的内容做什么工作才可以被JVM直接使用?

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Class类型,这就是虚拟机的类加载机制。—《深入理解Java虚拟机》

1
2
类型的加载、链接和初始化过程都是在程序运行期间完成的。
Java可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

类的加载时机

Java虚拟机规范并没有规定什么时候加载类,但是规定了什么时候初始化一个类。

虚拟机规范严格规定了有且只有5中情况必须立即对类进行“初始化”。
1)遇到new、getstatic、putstatic、invokestatic;
2)初始化类的时候,对父类进行初始化;
3)JVM执行的时候,对主类进行初始化;
4)使用java.lang.reflect包的方法对类进行反射调用时;
5)使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandler实例最后解析结果是REF_getstatic, REF_putstatic, REF_invokestatic时,对该类进行初始化;
6) jdk1.8中子类实现的接口中定义了default方法时;

类的加载过程

类的加载过程总共分为:Loading(加载)、Linking(连接)、Initializing(初始化)、Using(使用)、Unloading(卸载)。
其中 Linking阶段 分为 Verification(验证)、Preparation(准备)、Resolution(解析)。

ClassLoader

加载

加载阶段的成果是生成Class类对象,并将该对象存储在堆中。Class对象作为访问该对象数据的访问入口。
加载的步骤:
1) 根据类的全限定名读取类的二进制字节流;
2) 对二进制字节流进行解析转换,并将类信息存储在方法区中;
3) 堆中生成Class类对象,作为程序访问方法区中的类型数据的外部接口;

注意:
数组类不通过类加载器创建,由java虚拟机直接在内存中动态构造出来的。
数组的元素类型(去掉所有维度)要靠类加载器加载完成。
数组的组件类型(去掉一个维度)是引用类型,数组将被标识在加载该组建类型的类加载器的类名称空间上。
如果数组的组件类型不是引用类型,Java虚拟机会把数组标记为与引导类加载器关联。

验证

验证加载的类信息是否符合Java虚拟机规范,类的实现和继承、方法的执行、对象和方法的可访问性等等,主要分为四个阶段:

1)文件格式的验证
主要对Class文件中内容进行验证,是否符合Java虚拟机规范。
比如魔数、常量池的类型、指向常量的索引值是否存在或者类型是否正确等等;

这个阶段的验证是基于二进制字节流进行的,只有通过了验证才会被允许进入Java虚拟机的内存方法区存储。

2)元数据验证
对字节码描述信息进行语义分析,保证描述信息符合《Java语言规范》比如:

  • 这个类是否有父类、父类是否允许继承(被final修饰);
  • 这个类是否实现了父类所要求实现的所有方法;
  • 类中的字段、方法是否与父类产生矛盾;

3)字节码验证
对类的方法体进行验证,保证任何跳转指令都不会跳转到方法体以外的字节码指令上;
保证类型转化都是有效的.

4)符号引用验证

虚拟机在解析阶段将符号引用转化为直接引用的时候,对符号引用进行验证;

  • 符号引用中通过字符串描述的全限定名能否找到对应的类;
  • 在指定类中是否存在符合方法的字段描述以及简单名称所描述的方法和字段;
  • 符号引用中的类、字段、方法的可访问性是否可以被当前类访问;

符号引用阶段抛出的异常有:IllegalAccessError、NoSuchFieldError、NoSuchMethodError;

准备

准备阶段是正式为类中定义的变量分配内存并设置类变量初始值的阶段;

对于类变量(静态属性),设置的是“零值”,真正赋值是在初始化阶段;
对于常量(final static),会被设置为ConstantValue属性所指定的初始值。

对于常量,Javac时会为value生成ConstantValue属性。

解析

解析阶段是Java虚拟机将常量池的符号引用替换为直接引用的过程。《Java虚拟机规范》并没有规定何时进行解析。

  • 符号引用:可以无歧义的定位到目标即可;与虚拟机的内存布局无关;引用的目标不一定已经加载到虚拟机中;必须遵循《Java虚拟机规范》;
  • 直接引用:直接可以指向目标的指针、偏移量,或者可以间接定位到目标的句柄;与虚拟机的内存布局直接相关;直接引用的目标一定已经加载到虚拟机内存中;

解析动作主要针对类或者接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符号引用进行。

类、接口解析:

虚拟机的完整解析过程:
1)如果不是一个数组类型,虚拟机将会把全限定名传给当前类的加载器去加载,在加载的过程中,由于元数据验证、字节码验证的需要,又可能出发其他相关类的加载动作。
2)如果是一个数组类型,并且数组的元素类型为对象,那么会按照第一点的规则加载数组元素类型。如果是基础数据类型包装类,那么由虚拟机生成一个代表该数组唯独和元素的数组对象;
3)前面两部解析OK后进行符号引用验证,确定当前类是否有加载类的访问权限。

如果一个类拥有另外一个类的访问权限,那么至少有一条是成立的:
1)被访问类是public的,并且与访问类处于同一个模块;
2)被访问类是public的,不与访问类处于同一个模块,但是被访问类的模块允许访问类访问;
3)被访问类不是public的,但是与访问类处于同一个包中

字段解析

对字段表内class_index项中索引的CONSTANT_Class_Info符号引用进行解析(即字段所属类)

1)先查找当前类是否包含了简单名称与描述符与目标相匹配
2)从下往上的顺序查找实现的接口类
3)从下往上的顺序查找父类
4)都无法查找到,抛出java.lang.NoSuchFieldError.

如果查找成果返回了引用,将会对这个字段进行权限验证,对于不具备权限的访问会抛出IllegalAccessError;

方法解析

解析出方法表class_index项中索引的方法所属的类或者接口的符号引用。
具体的解析过程与字段解析类似。

接口方法解析

与方法解析类似。

初始化

这个阶段Java虚拟机才真正开始执行类中编写的程序代码。程序在这个阶段初始化类变量和其他资源。

初始化阶段也是执行<clinit>()的过程。
<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的;
编译器收集顺序是由语句在源文件中出现的顺序决定的;
静态语句块中智能访问到定义在静态语句块之前的变量;
定义在静态语句块后的变量,静态语句块可以赋值,但是不能访问;
Java虚拟机保证父类的<clinit>() 总是优先于子类的<clinit>()执行;

如果一个类中没有静态语句块,也没有对变量的赋值操作,编译器可以不为这个类生成clinit()方法。

类加载器

类加载器主要通过一个类的全限定名来获取描述该类的二进制字节流,它处于Java虚拟机的外部,方便让应用程序自己决定如何获取所需的类。

对于任意一个类,必须由加载它的类加载器与类本身来确定在Java虚拟机中的唯一性。
每个类加载器都拥有一个独立的类名称空间。

类加载器的双亲委派模型

双亲委派模型

双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应有自己的父“类加载器”。
当类加载器需要加载一个类的时候,首先看该类是否已经被加载,如果没有被加载则尝试着让父“类加载器”去加载,如果没有父“类加载器”则让bootstrapClassLoader作为父“类加载器”,如果父“类加载器”加载失败,则使用自己的find_class方法去加载。

  • 启动类加载器(Bootstrap Class Loader)
    启动类加载器负责加载<JAVA_HOME>\lib 目录下的jar包(按名字识别,名字不符合的类库存放在lib目录下也不会被加载),也可以指定 -Xbootclasspath参数指定加载目录。启动类加载器无法被java程序直接引用。
  • 扩展类加载器(Extension Class Loader)
    扩展类加载器负责加载<JAVA_HOME>\lib\ext目录下的jar包,或者被java.ext.dirs指定的路径这种的所有类库。这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码实现的。
  • 应用程序类加载器(Application Class Loader)
    负责加载用户类路径上的所有类库。这个类加载器是在sun.misc.Launcher$AppClassLoader实现。
定制自己的类加载器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class CustomClassLoader extends ClassLoader {

public CustomClassLoader(ClassLoader parent) {
super(parent);
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {

String path = "/classes/";
try {
InputStream ins = new FileInputStream(path + name.replace(".", File.separator) + ".class");
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead = 0;
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
byte[] classData = baos.toByteArray();
return defineClass(name, classData, 0, classData.length);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

public static void main(String[] args) throws Exception {
CustomClassLoader classLoader = new CustomClassLoader(null);
Class clazz = classLoader.loadClass("com.*.ClassLoader");
System.out.println(clazz.getClassLoader());
System.out.println(clazz.getClass());
}

Java虚拟机规范规定Class文件格式统一采用一种类似于C语言结构体的伪结构体来存储数据,
这种伪结构体存储两种数据类型:无符号数和表。

无符号数:属于基本的数据类型,以u1、u2、u4、u8分别代表1个字节、2个字节、4个字节、8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码结构构成的字符串值。

表:由多个无符号数或者其他表作为数据项构成的符合数据类型,所以表都习惯性地以_info结尾。
表用于描述有层次关系的符合结构的数据,整个Class文件就是一张表,由下表中数据项构成。

类型 名称 个数
u4 Magic 1
u2 minor_version 1
u2 major_version 1
u2 constant_pool_count 1
cp_info constant_pool constant_pool_count - 1
u2 access_flag 1
u2 this_class 1
u2 super_class 1
u2 interface_count 1
u2 interfaces interface_count
u2 fields_count 1
field_info fields fields_count
u2 method_count 1
method_info methods methods_count
u2 attributes_count 1
attribute_info attributes attributes_count

魔数

每个Class文件的头四个字节称为魔数,其值的16进制表示为0xCAFEBABE(换算为二进制为1100 1010 1111 1110 1011 1010 1011 1110),虚拟机在加载类时分析该文件是否为Class文件。

版本号

第5、6个字节表示次版本号。第7、8个字节表示主版本号。
高版本的JDK能够向下兼容低版本的Class文件,虚拟机会拒绝执行超过其版本的Class文件。

常量池

常量池中存放两大类常量:

  • 字面量常量:
    比较接近Java语言层面的常量概念,比如字符串和被声明为final的常量值。对于范围在-127~128之间的包装类型也是常量。
  • 符号引用常量:属于编译原理方面的概念,包含
    1. 类和接口的全限定名
    2. 字段的名称和描述符
    3. 方法的名称和描述符

常量池的大小不固定,用两个字节表示,第零项空出来,为了满足某些指向常量池的索引值的数据“不引用任何一个常量池项目”。

常量池的每一项常量都是一个表,这个表的第一位都是一个u1类型的标识位。

下面是常量池中的14种项目类型:
常量池中的14种项目类型

下面是常量池中的14种项目类型的结构表:

常量池中的表类型

常量池中的表类型

常量池中数据项类型 类型标志 类型描述
CONSTANT_Utf8 1 UTF-8编码的Unicode字符串
CONSTANT_Integer 3 int类型字面值
CONSTANT_Float 4 float类型字面值
CONSTANT_Long 5 long类型字面值
CONSTANT_Double 6 double类型字面值
CONSTANT_Class 7 对一个类或接口的符号引用
CONSTANT_String 8 String类型字面值
CONSTANT_Fieldref 9 对一个字段的符号引用
CONSTANT_Methodref 10 对一个类中声明的方法的符号引用
CONSTANT_InterfaceMethodref 11 对一个接口中声明的方法的符号引用
CONSTANT_NameAndType 12 对一个字段或方法的部分符号引用

访问标志

访问标志标示了当前类的修饰符,比如 public / final / super / interface / enum / abstract / annotation / synthetic

标志名称 标志值 含义
ACC_PUBLIC 0x00 01 是否为Public类型
ACC_FINAL 0x00 10 是否被声明为final,只有类可以设置
ACC_SUPER 0x00 20 是否允许使用invokespecial字节码指令的新语义.
ACC_INTERFACE 0x02 00 标志这是一个接口
ACC_ABSTRACT 0x04 00 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假
ACC_SYNTHETIC 0x10 00 标志这个类并非由用户代码产生
ACC_ANNOTATION 0x20 00 标志这是一个注解
ACC_ENUM 0x40 00 标志这是一个枚举

类索引、父类索引、接口索引集合

  • 类索引:this_class, 用于确定这个类的全限定名
  • 父类索引:super_class, 父类索引用于确定这个类的父类的全限定名
  • 接口索引集合:interfaces, 用于描述这个类实现了哪些接口

字段表集合

字段表集合(field_info)用于描述接口或者类中声明的变量,包含类变量和实例变量,但不包含方法内部声明的局部变量。

|类型|名称|数量|
|—-|—-|—-|—-|
|u2 |access_flag|1|访问修饰符|
|u2 |name_index | 1| 字段的简单名称,如String str = “123”, str就是简单名称|
|u2 |descritpor_index|1| 字段或者方法的描述符|
|u2 |attributes_count |1 | |
|attribute_info | attributes | attributes_count| |

访问修饰符

标志名称 标志值 含义
ACC_PUBLIC 0x0001 字段是否为public
ACC_PRIVATE 0x0002 字段是否为private
ACC_PROTECTED 0x0004 字段是否为protected
ACC_STATIC 0x0008 字段是否为static
ACC_FINAL 0x0010 字段是否为final
ACC_VOLATILE 0x0040 字段是否为volatile
ACC_TRANSTENT 0x0080 字段是否为transient
ACC_SYNCHETIC 0x1000 字段是否为由编译器自动产生
ACC_ENUM 0x4000 字段是否为enum

描述符:
描述符的作用是用来描述字段的数据类型、方法的参数列表(数量、类型、顺序)和返回值。
根据描述符的规则:基本数据类型以及代表无返回值的void类型都用一个大写的字符来表示;
而对象类型则用字符L加对象的全限定名来描述;

标志符号 含义
B byte
C char
D double
F float
I int
J long
S short
Z boolean
V void 按照先参数列表后返回值的顺序描述,参数列表按照顺序放在”()”内部,String toString()描述为V()java.lang.String
L 对象类型
[ 数组类型 [I: 代表int[], [[I:代表int[][]

方法表集合

Class 文件中对方法的描述和对字段的描述完全是一致的, 方法表中的结构和字段表中的接口一样。

因为volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有ACC_VOLATILE和ACC_TRANSIENT。 但是增加了synchronized,native,abstract,strictfp(strict float point, 精确的浮点数)关键字的修饰符。
对于方法里的代码,经过编译器变异常字节码指令后,存放在方法属性表中一个名为code的属性里。

标志名称 标志值 含义
ACC_PUBLIC 0x00 01 方法是否为public
ACC_PRIVATE 0x00 02 方法是否为private
ACC_PROTECTED 0x00 04 方法是否为protected
ACC_STATIC 0x00 08 方法是否为static
ACC_FINAL 0x00 10 方法是否为final
ACC_SYHCHRONRIZED 0x00 20 方法是否为synchronized
ACC_BRIDGE 0x00 40 方法是否是有编译器产生的方法
ACC_VARARGS 0x00 80 方法是否接受参数
ACC_NATIVE 0x01 00 方法是否为native
ACC_ABSTRACT 0x04 00 方法是否为abstract
ACC_STRICTFP 0x08 00 方法是否为strictfp (strict-float-point,精准的浮点数)
ACC_SYNTHETIC 0x10 00 方法是否是有编译器自动产生的

属性表集合

在Class文件、字段表、方法表中都可以携带自己的属性表集合(attribute_info),用于描述某些场景的专有信息。

不强制要求各属性表的顺序,只要不与已有属性表重名即可。
任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机在运行时会忽略不认识的属性。

属性名称 使用位置 含义
Code 方法表 Java代码编译成的字节码指令
ConstantValue 字段表 final关键字定义的常量池
Deprecated 类,方法,字段表 被声明为deprecated的方法和字段
Exceptions 方法表 方法抛出的异常
EnclosingMethod 类文件 仅当一个类为局部类或者匿名类是才能拥有这个属性,这个属性用于标识这个类所在的外围方法
InnerClass 类文件 内部类列表
LineNumberTable Code属性 Java源码的行号与字节码指令的对应关系
LocalVariableTable Code属性 方法的局部变量描述
StackMapTable Code属性 JDK1.6中新增的属性,供新的类型检查检验器检查和处理目标方法的局部变量和操作数有所需要的类是否匹配
Signature 类,方法表,字段表 用于支持泛型情况下的方法签名
SourceFile 类文件 记录源文件名称
SourceDebugExtension 类文件 用于存储额外的调试信息
Synthetic 类,方法表,字段表 标志方法或字段为编译器自动生成的
LocalVariableTypeTable 使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加
RuntimeVisibleAnnotations 类,方法表,字段表 为动态注解提供支持
RuntimeInvisibleAnnotations 表,方法表,字段表 用于指明哪些注解是运行时不可见的
RuntimeVisibleParameterAnnotation 方法表 作用与RuntimeVisibleAnnotations属性类似,只不过作用对象为方法
RuntimeInvisibleParameterAnnotation 方法表 作用与RuntimeInvisibleAnnotations属性类似,作用对象哪个为方法参数
AnnotationDefault 方法表 用于记录注解类元素的默认值
BootstrapMethods 类文件 用于保存invokeddynamic指令引用的引导方式限定符

对于看了文章还有点懵的同学,看这里的视频讲解,忽略广告。《全网最牛JVM字节码结构分析、Class类文件核心结构》。

HotSpot 虚拟机常用配置

配置 说明
-Xms1g 最小堆内存
-Xmx4g 最大堆内存
-XX:+UseG1GC 使用G1垃圾回收器
-Xss256k 每个线程栈大小256k
-XX:MetaspaceSize=256m 元数据区大小256M
-XX:MaxMetaspaceSize=256m 元数据区最大256M
-XX:MaxGCPauseMillis=500 GC最大停顿毫秒数
-XX:+DisableExplicitGC 禁用代码中显式调用GC,System.gc()将会失效
-XX:+UnlockExperimentalVMOptions 解锁实验参数
-XX:+UseStringDeduplication GC的同时做重复字符串消除,只用于G1
-XX:InitiatingHeapOccupancyPercent=60 启动并发GC周期时的堆内存占用百分比. G1之类的垃圾收集器用它来触发并发GC周期,基于整个堆的使用率,而不只是某一代内存的使用比. 值为 0 则表示”一直执行GC循环”. 默认值为 45.
-XX:ParallelGCThreads=8 设置垃圾收集器在并行阶段使用的线程数,默认值随JVM运行的平台不同而不同.
-XX:G1MixedGCCountTarget=4 设置在标记周期完成之后混合收集的数量,以维持old region(也就是老年代)中,最多有G1MixedGCLiveThresholdPercent的存活对象。默认值为8,混合收集的数量将维持在这个值之内。(JVM build > 23)
-XX:MaxTenuringThreshold=7 年老代的最大临界值(tenuring threshold). 默认值为 15.
-XX:+UseGCLogFileRotation 滚动记录GC日志文件
-XX:NumberOfGCLogFiles=5 GC日志文件数量为5个
-XX:GCLogFileSize=64M 单个GC日志文件大小64M
-Xloggc:/app/data/logs/gc-date +%Y%m%d-%H-%M.log GC日志文件存储目录和名称
-XX:+PrintGCDetails 打印GC详情
-XX:+PrintGCDateStamps 打印GC时时间戳
-XX:+PrintHeapAtGC GC时打印堆信息
-XX:+PrintAdaptiveSizePolicy 自适应大小策略,每次 GC 后会重新计算 Eden、From 和 To 区的大小,计算依据是 GC 过程中统计的 GC 时间、吞吐量、内存占用量。
-XX:+UseFastAccessorMethods
-XX:SoftRefLRUPolicyMSPerMB=0 softly reachable objects will remain alive for some amount of time after the last time they were referenced. The default value is one second of lifetime per free megabyte in the heap

命令行工具

jps

列出正在运行的虚拟机进程,并显示虚拟机执行主类名称以及这些进程的本地虚拟唯一ID(与操作系统进程ID一致)。

jps [option] [hostid]

选项 作用
-q 只输出lvmid,省略主类的名称
-m 输出虚拟机启动时传递给主类main()的参数
-l 输出主类的全名,如果是jar包,则输出jar路径
-v 输出虚拟机启动时的jvm参数

jstat

用于监视虚拟机各种运行状态信息的命令行工具。
显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据。

jstat [option vimid [interval[s|ms] [count]]]
[protocal:][//]lvmid[@hostname[:port]/servername]

选项 作用
-class 监视类加载、卸载数量、总空间以及类装载耗费的时间
-gc 监视Java堆状况,包括Eden、Survivor、老年代、永久代的容量,已用空间,垃圾收集时间
-gccapacity 监视内容与-gc基本相同,但输出主要关注java堆各个区域使用到最大最小空间
-gcutil 监视内容与-gc基本相同,但输出主要关注已使用空间占总空间的百分比
-gccause 与-gcutil功能一样,会额外输出导致上一次垃圾收集产生的原因
-gcnew 监视新生代垃圾收集情况
-gcnewcapacity 监视内容与-gcnew基本相同,输出主要关注使用到的最大、最小空间
-gcold 监视老年代垃圾收集状况
-gcoldcapacity 与-gcold相同,输出主要关注使用到的最大、最小空间
-gcpermcapacity 输出永久代使用到的最大、最小空间
-compiler 输出即时编译器编译过的方法、耗时等信息
-printcompilation 输出已经被即时编译的方法

每隔1s查询一次2105的gc情况,查询10次。
jstat -gc 2105 1000 10

jstat -gcutil 2105

S0 S1 E O P YGC YGCT FGC FGCT GCT
0.00 0.00 6.20 41.42 47.20 16 0.105 3 0.472 0.577

jinfo

实时查看和调整虚拟机各项参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Usage:
jinfo [option] <pid>
(to connect to running process)
jinfo [option] <executable <core>
(to connect to a core file)
jinfo [option] [server_id@]<remote server IP or hostname>
(to connect to remote debug server)

where <option> is one of:
-flag <name> to print the value of the named VM flag
-flag [+|-]<name> to enable or disable the named VM flag
-flag <name>=<value> to set the named VM flag to the given value
-flags to print VM flags
-sysprops to print Java system properties
<no option> to print both of the above
-h | -help to print this help message

jmap

用于生成堆转储快照,查询finalize执行队列、Java堆和方法区的详细信息(空间使用率、当前使用收集器类型)

生成堆转储文件的方式:

1
2
3
4
-XX:+HeapDumpOnOutOfMemoryError 在虚拟机内存溢出异常出现后自动生成堆转储快照文件。
-XX:HeapDumpOnCtrlBreak 使用ctrl+Break 生成堆转储快照文件
kill -3 pid 生成堆转储快照文件
jmap -dump:format=b,file=heap.dump 3120
选项 作用
-dump 生成java堆转储快照,格式为-dump:[live,]format=b,file=,live子参数说明是否只dump出存活对象
-finalizerinfo 显示再F-Queue中等待Finalizer线程执行finalize方法的对象。只有在Linux平台有效
-heap 显示Java堆详细信息,使用回收器类型、参数配置、分代状况,只在Linux平台下有效
-histo 显示堆中对象统计信息,包括类、实例数量、合计容量
-permstat 在以ClassLoader为统计口径显示永久代内存状态。只在Linux下有效
-F 当虚拟机进程对-dump选项没有响应时,可以使用-F强制生成dump快照,只在Linux下有效

jhat

用于分析jmap生成的堆转储快照。

jhat heap.dump

jhat功能相对简陋,分析工作耗时且耗费硬件资源,一般不在本机使用。
替代方案:VisualVM、Eclipse Memory Analyzer、IBM HeapAnalyzer。

jstack

生成虚拟机当前时刻的线程快照。目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Usage:
jstack [-l] <pid>
(to connect to running process)
jstack -F [-m] [-l] <pid>
(to connect to a hung process)
jstack [-m] [-l] <executable> <core>
(to connect to a core file)
jstack [-m] [-l] [server_id@]<remote server IP or hostname>
(to connect to a remote debug server)

Options:
-F to force a thread dump. Use when jstack <pid> does not respond (process is hung)
-m to print both java and native frames (mixed mode)
-l long listing. Prints additional information about locks
-h or -help to print this help message

高级工具

JConsole

基于JMX的可视化监视、管理工具。

JHSDB

基于服务性代理实现的进程外调试工具。服务性代理是HotSpot虚拟机中一组用于映射Java虚拟机运行信息的、主要基于Java语言实现的API集合。
JCMD 和JHSDB工具对比

VisualVM

多合一故障处理工具,不需要被监视的程序基于特殊Agent执行,通用性强,对应用程序的实际性能影响较小,可以直接在生产环境使用。

Java Mission Control

可持续在线监控工具。

arthas

阿里巴巴提供的性能分析工具
https://alibaba.github.io/arthas/

编译

  • 前端编译器: JDK的Javac 、EclipseJDT中的增量式编译器(ECJ)
  • 即时编译器:HotSpot虚拟机的C1\c2编译器、Graal编译器
  • 提前编译器:JDK的Jaotc、GNU Compiler for Java, Excelsisor JET.

前端编译器

编译过程分为1个准备过程和3个处理过程:
1)初始化插入式注解处理器
2)解析与填充符号表

  • 语法、词法分析
    词法分析是将源代码的字符流转变为标记集合的过程,单个字符是程序编写的最小元素,但标记才是编译时的最小元素。关键字、变量名、字面量、运算符都可以作为标记。

    语法分析时根据标记序列构造抽象语法树的过程,抽象语法树是用来描述程序代码语法结构的树形表示方式,抽象语法树的每一个节点都代表程序代码中的一个语法结构。例如包、类型、修饰符、运算符、接口、返回值、代码注释等。

    经过词法和语法分析生成语法树以后,编译器后续的操作都建立在抽象语法树之上。

  • 填充符号表
    符号表是一组符号地址和符号信息构成的数据结构,符号表中所登记的信息在编译的不同阶段都要被用到。如,在语义分析过程中,符号表所登记的内容将用于语义检查和产生中间代码,在目标代码生成阶段,对符号名进行地址分配时,符号表是地址解析分配的直接依据。

3)注解处理器
插入式注解处理器可以提前至编译期对代码中的特定注解进行处理,从而影响到前端编译器(javac)的工作过程。

如果在处理注解期间对语法树进行过修改,编译器将回到解析以及填充符号表的过程重新处理,知道所有插入式注解处理器都没有再对语法树进行修改位置,每次循环称为一个轮次。

4)语义分析与字节码生成

语义分析过程分为标注检查和数据以及控制流分析两个步骤。

在经过语法分析后,编译器获得了程序代码的抽象语法树表示,抽象语法树能够表示一个结构正确的源程序,但无法保证源程序的语义是符合逻辑的。而语义分析的主要任务则是对结构上正确的源程序进行上下文相关性质的检查,譬如进行类型检查、控制流检查、数据流检查等等。

  • 标注检查
    检查的内容包括变量是用钱是否已经被声明、变量与赋值之间的数据类型是否能够匹配等等。
    常量折叠:int a = 1 + 1 ; ==> int a = 2;

  • 数据流以及控制流分析
    数据流分析以及控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受检查异常都被正确处理了等问题。
    编译器的数据以及控制流分析与类加载时的数据以及控制流分析的目的基本哈桑一致,但是校验范围有所区别。

  • 解语法糖
    Java语言的语法糖包含泛型、变长参数、自动装箱拆箱等,Java虚拟机在运行时并不知节支持这些语法,它们在编译阶段被还原回原始的基础语法结构,这个过程称为解语法糖。

  • 字节码生成

    字节码生成阶段需要把前面哥哥步骤生成的信息转化成字节码指令写到磁盘中外,还要进行少量的代码添加和转换工作。
    如实例构造器<init>()方法和<cinit>()方法的添加。
    如果用户代码中没有提供任何构造函数,那么编译器将会添加一个没有参数的、可访问性与当前类型一致的默认构造函数,这个工作在填充符号表阶段已经完成。

    编译器会把语句块、变量变量初始化、调用父类的实例构造器方等操作收敛到<init>()和<cinit>()方法中,并且保证无论源码中出现的顺序如何,都一定是按先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序执行,

语法糖

泛型、自动装箱、拆箱、遍历循环、变长参数和条件编译、内部类、枚举类、断言、数值直面量、枚举和字符串的switch、try with resource, lambda等。

泛型

类型擦除

自动装箱、拆箱和循环遍历

1)大于127的值不会自动拆箱
2)没有遇到运算符的情况下不会自动拆箱
3)equals方法不处理数据转型

变长参数

条件编译

Java编译器将所有编译但愿的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间可以互相提供符号信息。

根据条件中布尔值常量值的真假,编译器将把分支中不成立的代码块消除掉。

后端编译

当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。
当程序运行环境中内存资源限制较大,可以使用解释执行节约内存。
当程序启动以后,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,减少解释器的中间损耗,获得更高的效率。

即时编译器

即时编译器将运行比较频繁的代码块或者方法编译成本地机器吗,并且对这段代码进行优化。

在Hotspot中内置了2个即时编译器 Client Compiler、 Server Compiler。 虚拟机会根据自身版本与宿主机的硬件性能自动选择运行模式,也可以使用-client 或者 -Server 来强制指定虚拟机运行在客户端模式或者服务端模式。

解释模式(Interperted Mode):-Xint
编译模式(Compiled Mode): -Xcomp
混合模式(Mixed Mode): -Xmixed (默认)

编译器编译本地代码需要占用程序运行时间,位了打到响应速度与运行效率之间打到最佳平衡,HotSpot在编译子系统中加入了分层编译的功能。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次:
第0层,程序春解释执行,并且解释器不开启性能监控。
第一层:使用客户端编译器将字节码编译为本地代码,进行简单可靠的稳定优化,不开启性能监控功能。
第二层:使用客户端编译器执行,仅开启方法以及回边次数统计等优先的性能监控功能。
第三层:使用客户端编译器执行,开启全部性能监控,除了第2层的痛就之外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
第四层:使用服务端编译器将字节码编译为本地代码,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

实施分层编译后, 解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被多次编译,用客户端编译器获得更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无需额外承担手机性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时, 客户端编译器可先采用简单优化来为它争取更多的编译时间。

编译对象与触发条件

被编译的热点代码有:被多次调用的方法和被多次执行的循环体。这两种情况编译的目标对象都是整个方法体,而不是单独的循环体。

基于采样的热点探测

周期性的检查线程的调用栈顶,如果发现某个方法经常出现在栈顶,那么这个方法就是热点方法。
优点:简单高效,容易获取方法的调用关系
缺点:不能精确确认方法的忍睹,容易受到线程阻塞或者外界因素影响扰乱热点探测。

基于计数器的热点探测

为每个方法建立计数器,统计方法的执行次数,如果超过一定的阈值就认为它是热点方法。
优点:结果精确严谨
缺点:每个方法建立并维护计数器,不能直接获取到方法的调用关系。
HotSpot为每个方法准备了两类计数器:方法调用计数器和回边计数器(在循环边界往回跳),当调用次数超过设定的阈值,就会触发即时编译。
-XX:CompileThreshold 来设定触发即时编译的阈值。
默认设置下,方法调用计数器统计的是相对执行瓶绿,当一个时间段内调用次数不足以提交即时编时,这个方法的调用计数器就会被减半(衰减),可以使用-XX:-UseCounterDecay关闭热度衰减。可以设置-XX:CounterHalfLifeTime设置半衰周期的时间,单位是秒。

当方法被调用时,首先检查该方法是否被即使编译器编译过,是则优先使用编译后的版本执行,否则将方法调用的计数器加一,然后判断方法调用计数器与回边计数器值之和是否超过阈值,超过则像即时编译器提交该方法的代码编译请求。
如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址会被系统自动改写成新值,下次调用该方法时就会使用已编译版本。

方法调用计数器 触发即时编译

回边计数器:统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”,回边计数器的目的是为了触发栈上的替换编译。回边计数器没有热度衰减过程。

编译过程

一般情况下,方法调用产生的标准编译请求和栈上替换编译请求,在虚拟机编译器未完成编译之前,都将按照解释方式执行代码,编译动作在后台的编译线程完成。
设置-XX:-BackgroundCompilation禁止后台编译,禁止后触发即时编译条件时,执行线程向虚拟机提交编译请求后一致阻塞等待,直到编译过程完成后再开始执行编译器输出的本地代码。

服务端编译器效率较低,但是编译速度远超传统的静态优化编译器,相对于客户端编译器编译输出的代码质量有很大的提交,可以大幅降低本地代码的执行时间,从而抵消掉额外的编译水间开销。

即时编译的缺点

1)占用程序运行时间
2)占用程序运算资源

提前编译器

提前编译器的分支:
与传统C、C++编译器类似,在程序运行之前把程序代码编译成机器码的静态翻译工作;
把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码时直接加载进来使用。

编译器优化技术

方法内联

最重要的优化技术之一。

逃逸分析

最前沿的优化技术之一。

  • 栈上分配
    支持方法逃逸,但不支持线程逃逸

  • 标量替换
    如果一个数据已经无法在分解为更小的数据表示时(原始数据类型),这些数据就可以称为标量。
    标量替换就是将用到的成员变量恢复为原始类型来访问的过程。
    加入逃逸分析可以证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候可能不去创建这个对象,而改为直接创建它的若干个被这个的方法使用的成员变量来替代,成员变量存储在栈上。

    同步消除:如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定不会有竞争,对这个变量的同步措施也可以安全地消除掉。

公共子表达式消除

语言无关的经典优化技术。如果一个表达式E之前已经被计算过,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。

数组边界检查消除

语言相关的经典优化技术。

Java技术体系的自动内存管理最根本目标是自动化地给对象分配内存以及自动回收分配给对象的内存。
《Java虚拟机规范》并未规定新对象的创建和存储细节,这取决于虚拟机当前使用的是哪种垃圾收集器,以及虚拟机中与内存相关的参数设定。

HotSpot虚拟机中对象的分配原则

对象优先在Eden分配

在大多数情况下,对象在新生代Eden区中分配。 当Eden区没有足够空间进行分配时,虚拟机将发起一次minorGC。
当survivor区的空间无法存放Eden区的对象时,就会通过担保机制提前转移到老年代去。

-Xms:20M 堆内存初始值20M
-Xmx:20M 堆内存最大20M
-Xmn10M 新生代占用10M(剩下的10M留给老年代)
-XX:SurvivorRatio=8 决定了新生代Eden区与一个Survivor区的空间比例是8:1:1

大对象直接进入老年代

大对象是需要大量连续内存空间的Java对象,如很长的字符串或者元素数量很多的数组。

在分配空间时,如果对象比较大,就会提前触发垃圾收集,以获取足够的连续空间才能存放该对象, 当复制对象时大对象意味着高额的内存复制开销。

-XX:PretenureSizeThreshold=3145728 (大于3M的对象会直接分配到老年代中)
该参数只对Serial和ParNew两款收集器有效。

长期存活的对象将进入老年代

内存回收时必须决策那些存活对象应该存放在新生代,哪些存活对象存放在老年代。
虚拟机为每个对象定义了一个对象年龄(Age)存储在对象头重。

对象通常在Eden区诞生,如果经过一次MinorGC后仍然存活,并且能被Survivor容纳的话,会被移动到Survivor空间,并且将对象的年龄设置为1岁。对象在Survivor中每经过一次MinorGC,Age增加1, 当年龄增加到一定程度的时候,该对象会被移动到老年代中。
可以通过-XX:MaxTenuringThreshold设置(默认为15次)。

动态对象年龄判定

如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就可以直接进入老年代,无需等到-XX:MaxTenuringThreshold中要求的年龄。

也就是当前Survivor空间大小为1M,有两个Age=1且大小为256k的对象时,这两个对象就会直接进入老年代。

空间分配担保

在发生MinorGC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。
如果条件成立,那么这次MinorGC时安全的;;
如果不成立,虚拟机会先查看-XX:HandlerPromotionFailure参数的设置值是否允许担保失败
如果允许担保失败,继续检查老年代最大可用的连续空间是否大于历次今生到老年代对象的平均大小
大于,尝试进行一次MinorGC;
小于,或者-XX:HandlePromotionFailure不允许冒险,则变更为FullGC。

在JDK6 Update24之后的规则变为只要老年代的连续空间大于新生代总大小或者历次晋升的平均大小,就会进行MinorGC,否则就进行FullGc。-XX:HandlePromotionFailure参数不在生效。

垃圾的回收

  • 哪些内存需要回收
  • 什么时候回收
  • 如何回收

如何判断对象已死

  • 引用计数器
  • 可达性分析算法(固定可作为GCRoot的对象有)
    • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如每个线程背调用的方法堆栈中使用的参数、局部变量、临时变量
    • 在方法区中类静态属性引用的对象,如Java类的引用类型静态变量
    • 在方法区中常量引用的对象,如字符串常量池中的引用
    • 在本地方法栈中JNI引用的对象
    • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象,系统类加载器
    • 所有背同步锁持有的对象
    • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

对象的引用

  • 强引用,如Object obj = new Object(); 无论任何情况下,只要强引用关系存在,垃圾收集器就永远不会回收掉背引用的对象。
  • 软引用,描述有用但非必须的对象,被软引用关联的对象在系统将要发生内存溢出前会把这些对象例如回收范围内进行二次回收。(SoftReference)
  • 弱引用,描述非必须对象,被弱引用的对象只能生存到下一次垃圾收集发生位置。无论内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用,一个对象是否有虚引用存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的时为了能在这个对象被收集器回收时受到一个系统通知(NIO用的RdirectMemory)

垃圾收集器算法

分代收集理论

建立在两个分代假说上:
1)弱分代假说,绝大数对象都是朝生夕灭的。
2)强分代假说,经过越多次收集过程的对象月难消亡。
3)(推理假说)跨代引用假说,跨代引用相对于同代来说仅占极少数。

分代收集区域
  • 部分收集(Partial GC)
    • 新生代收集(Minor GC/ Yong GC)
    • 老年代收集(Major GC / Old GC)(只有CMS会单独回收老年代)
    • 混合收集(Mixed GC),目前只有GC存在混合回收行为。
  • 整堆收集(Full GC)

标记清除算法

标记需要被回收的对象后,回收掉所有被标记的对象;
或者标记不需要被回收的对象,同一回收未被标记的对象。

缺点:
不稳定,标记清除的性能随着对象的增多而降低;
空间碎片化问题,肯能会导致在分配较大对象时无法找到足够的连续空间内存不得不提前触发另一次垃圾收集动作。

mark-sweep

标记复制算法

为了解决标记清除算法面对大量可回收对象时执行效率低的问题。

优点:只需要复制少数存活对象,简单高效
缺点:浪费内存空间。

mark-copy

根据弱分代假说,以及实际情况,大部分的对象在第一次垃圾回收时就会被回收,因此并不需要按照1:1的比例来划分新生代的内存空间。
内存被分为较大的Eden区和较小的两个Survivor区域,HotSpot默认比例是8:1:1。
打给你Survivor空间不足容纳一次MinorGC后存活的对戏那个时,就需要依赖其他内存区域进行分配担保。

标记整理算法

根据老年代对象的存亡特征以及强分代假说提出的算法。
将所有存活的对象向内存空间的一端移动,然后直接清理掉边界以外的内存。

mark-compact

吞吐量:赋值器和收集器的效率总和。
如果在清理时移动,必然会影响到对象回收的效率。
如果清理时不移动,必然会影响到对象分配的效率。

常见的垃圾收集器

Serial / SerialOld

Serial收集器是最古老的收集器,使用标记拷贝算法,在进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
优点:在内存较小的桌面应用或者Client模式下,额外内存消耗最小,简单高效。

SerialOld同样是单线程收集起,使用标记整理算法。当CMS收集起发生失败时会转为SerialOld工作。

Serial GC

PS / PO

Paralle Scavenge/ Paralle Old 的目标是达到一个可控的吞吐量。

ParalleScavenge 同样是基于标记-复制算法,支持多线程并发收集,
吞吐量 = (运行用户代码时间)/(运行用户代码时间+运行垃圾收集时间)
-XX:MaxGCPauseMillis 控制最大垃圾收集停顿时间
-XX:GCTimeRatio 控制垃圾收集时间占总时间的比率
-XX:+UseAdaptiveSizePolicy 就不需要人工指定新生代的大小,Eden于Survivor的比例,以及晋升老年代的大小,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

ParalleOld 收集老年代,基于标记整理算法。

Paralle Scavenge / Paralle Old

ParNew / CMS

ParNew是Serial收集器的多线程并行版本,除了使用多条线程进行垃圾收集之外,其余行为与Serial收集起完全一致。

ParNew / SerialOld

CMD是一种以获取最短回收停顿时间为目的的收集器。符合希望系统停顿时间尽可能短,以给用户带来良好交互体验的需求。他是一种标记-清除算法实现的。
包含四个步骤:
1)初始标记
2)并发标记
3)重新标记
4)并发清除

其中初始标记和重新标记这两个步骤仍需要STW。
初始标记仅仅只是标记一下GC Roots能直接关联到的对象、速度很快;
并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时长,但不需要停顿用户线程,可以与垃圾收集线程一起并发运行;
重新标记为了修正并发标记期间因为用户程序继续运行导致标记产生变动的部分,这个阶段比初始标记耗时稍长;
并发清除,清理删除掉标记阶段判断已经死亡的对象,不需要移动哦存活对象,因此这个阶段也是与用户线程同时并发。

CMS

CMS缺点:
1) 耗CPU资源
2) 无法处理浮动垃圾,由于预留的内存无法分配新对象时,有可能出现回收失败而导致退化到Serial Old的模式收集。
3)分配占用大空间的对象时,会出现老年代还有很多剩余空间,但是无法找到足够大的连续空间分配对象,不得不提前触发FullGC。
提供了两个参数优化,但是JDK1.9之后已经废弃:
-XX:UseCMS-CompactAtFullCollection 不得不进行FullGC时开启内存碎片的合并整理。
-XX:CMSFullGCsBefore-Compaction,要求CMS收集器在执行过若干次不整理空间的FullGC之后,下一次进入FullGC前进行碎片整理。

Garbage First

G1时一款主要面向服务端应用的垃圾收集器。

在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。

G1面向堆内存任何部分来组成回收集(CollectionSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的MixedGC模式。

G1把连续的Java堆内存划分为多个大小相等的独立区域(Region),每个Region都可以根据需要扮演 新生代的Eden空间Survivor空间,或者老年代空间。收集器能够对不同角色的Region采用不同的=策略取处理,这样无论是新创建的对象还是存活了一段时间、经过多次收集的旧对象都能获得很好的收集再熬过。

Region中有一类Humongous区域专门存放大对象。 G1认为只要大小超过了一个Region容量的一般的对象就可以判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围1MB~32MB,且应为2的N次幂。那些超过了整个Region容量的超级大对象,会被存放在N个连续的HumongousRegion之中,G1的大多数行为都把HumongousRegion作为老年代的一部分进行看待。

G因为将Region作为单词回收的最小单元,所以可以建立可预测的停顿时间模型。
每次根据用户设置的停顿时间-XX:MaxGCPauseMillis, 优先处理回收价值收益最大的那些Region。
这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在悠闲的时间内获取尽可能高的收集效率。

G1 Region

需要解决的问题

1)跨Region引用的对象: 每个Region都维护自己的记忆集避免全堆作为GCRoots扫描, 这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。
G1的记忆集是一种Hash表,Key时别的Region的起始地址,Value时一个集合,里面存储的元素时卡表的索引号。这种双向的卡表结构记录了我指向谁和谁指向了我,因此G1只少需要耗费大约10%~20%的额外内存来维持收集工作。

2)在并发阶段如何保证收集线程与用户线程互不干扰地运行?
用户线程在改变对象引用关系的时候,必须保证不能打破原本的对象图结构,导致比阿吉结果出现错误,
CMS使用增量更新算法,G1使用原始快照算法来实现。

垃圾收集对用户线程的影响还体现在回收过程中心窗卷对象的内存分配上,程序要继续运行就肯定会持续有新对象呗创建,G1位每个Region设计了两个名为TAMS(TOp at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时心分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象时被隐式标记过的,不纳入回收范围。如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致FullGC而产生长时间的STW。

3)如何建立可靠的停顿模型
G1的停顿预测模型时以衰减均值为理论基础实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可以测量的步骤话费的成本,并分析得出平均值,标准偏差、可行度等统计信息,衰减平均值时比普通的平均值更容易受到新数据的影响,平均值更容易受到新数据的影响,平均值代表整体平均状态,衰减均值代表最近的平均状态。 Region的统计状态越新越能决定器回收的价值。通过这些信息预测现在开始回收的话,哪些region组成回收集采可以在不超过期望停顿时间的约束下获得最高的收益。

G1的回收步骤

初始标记

只标记一下GCRoots能直接关联到的对象,并且修改TAMS指针的值,让下一个阶段的用户线程并发运行时,能正确滴在可用的Region中分配新对象。这个阶段需要停顿线程,但是耗时短,而且是借用进行MinorGC的时候同步完成,所以G1收集器在这个阶段实际并没有额外的停顿。

并发标记

从GCRoots开始对对子红对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这个阶段耗时较长,但是可以和用户程序并发执行,当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。

最终标记

对用户线程做另外一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。

筛选回收

负责更新Region的统计数据,对哥哥Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧的Region的全部空间,对存活对象移动时必须暂停用户线程,但是由多条线程完成的。

G1

G1从整体上来看是基于标记整理算法,但从局部来看时基于标记复制算法。无论如何,这两种算法意味着G1再与性期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发限一次收集。

G1位了垃圾收集产生的内存占用以及额外的执行副在都比CMS搞。
内存占用是由于双向卡表占用空间大。CMs只记录了老年代到新生代的卡表记录。

负载:CMS用写后屏障维护更新卡表;
G1除了使用写后屏障维护卡表外,位了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发前的指针变化。 原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点。

CMS的写屏障是同步操作,G1使用的是消息队列,把写前屏障和写后屏障中要做的事情都放在队列里,再异步处理。

目前线程是java中进行处理器资源调度的最基本单位。(Loom项目正在尝试改变线程这一重量级的实现)

实现线程的方式主要有三种:

  1. 使用内核线程1:1实现;
    内核系统就是由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。

内核线程

  1. 使用用户线程1:n实现;

用户线程

  1. 使用用户线程价轻量级进程混合实现(n:M实现)

混合实现

Java线程的实现

《Java虚拟机规范》中没有限定使用哪种线程模型来实现,主流平台上的主流商用Java虚拟机线程模型普遍都给予操作系统原生线程模型来实现(1:1的线程模型)

Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,调度方式分为两种:

  • 协同式线程调度
    线程的执行时间由线程本身来控制,线程把自己的工作执行完之后,同志系统切换到另外一个线程。
  • 抢占式线程调度
    每个线程由系统来分配执行时间,线程的切换不由线程本身来决定。
    Java可以使用thread.yield()方法主动让出执行时间,但是并不能主动获取执行时间。

线程的优先级

我们并不能在程序中通过优先级来完全准确判断一组状态都为Ready的线程先执行哪一个?

状态转换

Java语言定义了6中线程状态,在任意一个时间点中, 一个线程只能有且只有一种状态,并且可以通过特定的方法在不同状态之间转换。

  • 新建:new,创建后尚未启动的线程处于新建状态;
  • 运行:runnable,包括操作系统线程状态中的Running和Ready,
  • 等待:Waiting,处于这种状态的线程不会被分配处理器执行时间,它们需要被其他线程显示唤醒。
    以下方法可以让线程无限期等待:
    • 没有设置timeout参数的Object.wait()方法;
    • 没有设置timeout参数的Thread.join()方法;
    • LockSupport.part()方法;
  • 限期等待:处于这种状态的线程也不会被分配处理器执行时间,在一定时间后会有系统自动唤醒。
    • Thread.sleep()
    • 设置了Timeout的Object.wait()
    • 设置了Timeout的Thread.join()
    • LockSupport.parkNanos();
    • LockSupport.parkUntil();
  • 阻塞:Blocked,在程序等待进入同步区域的时候,线程将进入阻塞状态。
  • 结束: Terminated,已终止线程的线程状态,线程已经结束执行。

线程状态图

线程安全

当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要执行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

也就是代码本身封装了所有必要的正确性保障手段,令调用者无序关心多线程下的调用问题,更无需自己实现任何措施来保证多线程环境下的正确调用。

不可变

在Java语言中,不可变对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。

只要一个不可变的对象被正确的构建出来,那其外部的可见状态永远都不会改变,永远都不会看到他在多个线程之中处于不一致的状态。

绝对线程安全

绝对的线程安全除了对象本身外, 还需要在调用端做相应的同步措施。

相对线程安全

保证这个对象在单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。在Java中,相对线程安全的类型有Vector、HashTable、Collections.synchronizedCollection();

线程兼容

对象本身并不是线程安全的, 但是可以通过在调用端正确的使用同步手段来保证对象在并发环境中可以安全使用。
Vector、HashTable、ArrayList、HashMap等都是线程兼容的。

线程对立

如果两个线程同时持有一个线程对象,一个尝试去中断线程、一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在思索的风险。(Thread类的suspend和resume方法),常见的对立操作还有System.in() System.out()和System.runFinalizersOnExit();

线程安全的实现方法

互斥同步

用互斥来实现同步。
同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条线程使用。
互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。
因此它面临的问题是进行线程阻塞和唤醒所带来的性能开销,因此也叫阻塞同步。
属于一种悲观的并发策略。

synchronized(非公平锁)
java.util.concurrent.locks.Lock
ReentrantLock(非公平锁)

可重入锁比synchronized增加了一些高级功能:

  • 等待可中断
    当持有锁的线程长期不释放所得时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 公平锁
    多个线程在等待同一个锁的时候,必须按照申请锁的时间顺序依次获得锁。
  • 锁绑定多个条件
    一个ReentrantLock对象可以同时绑定多个Condition对象, 多次调用newCondition()即可。

非阻塞同步

基于硬件冲突检测的乐观并发策略,也就是说先进性操作,如果没有其他线程争用共享数据,操作成功;
如果共享数据被争用,产生冲突,那么再进行其他的补偿措施,最常用的补偿措施就是不断重试,直到成功。
这种方式不需要阻塞线程,因此也叫无锁编程。
常用的具有原子性的硬件操作和冲突检测指令有:

  • 测试并设置(Test-And-Set)
  • 获取并增加(Fetch-And-Increment)
  • 交换(Swap)
  • 比较并交换(Compare And Swap)
  • 加载链接/条件存储(Load-Linked/ Store-Conditional)

Java中暴露出来的是CAS指令,当CAS指令执行时,当且仅当内存地址V的值符合预期值A时,处理器才会用新的值B更新内存地址V的值,该过程是一个原子操作,执行期间不会被其他线程中断。

CAS存在的ABA问题,大部分情况下不会影响程序并发的正确性,如果需要解决该问题,传统的互斥同步可能会比原子类更为高效。

无同步方案

如果可以让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的。

  • 可重入代码
    如果一个方法的返回结果是可预测的,只要输入了相同的数据,就就能返回相同的结果,那么它就满足可重入性的要求,当然他就是线程安全的。
    可重入代码不依赖于全局变量、存储在队上的数据和公用的系统资源,用到的状态量都由参数重传入,比调用非可重入的方法等。
  • 线程本地存储
    如果一段代码中使用的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。这样就可以把共享数据的可见范围限制在一个线程之内。
    常见的有生产者消费者模式(Web交互模型)。
    ThreadLocal的使用。

锁优化

自旋锁和自适应自旋

互斥同步的时候需要对线程进行挂起和恢复,但是挂起和恢复需要陷入内核态来完成。
因此当一个线程获取锁的时候,如果暂时没有获取到,那么让他稍微等一会儿,但不放弃处理器的执行时间,看看持有锁的线程是否很快会释放锁。

如果锁被占用很长的时间,那么自旋的线程只会白白消耗处理器资源。因此自旋等待时间必须有一定的限度。
如果自旋超过了限定的次数,仍然没有成功获得锁,就应当使用传统方式去挂起线程。

自旋锁的实现

几种自旋锁的java实现
认真的讲一讲:自旋锁到底是什么
看完你就明白的锁系列之自旋锁

锁消除

虚拟机即使编译器在运行时,判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把他们当作栈上的数据对待,认为他们是线程私有的,消除同步锁的过程。

锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的, 可以把加锁同步的范围扩展到整个操作序列的外部。

轻量级锁

在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

Hotspot对象头分为两部分:
1)用于存储对象自身运行的数据,如哈希吗,GC分代年龄。这部分数据的长度在32位和64位的Java虚拟机中分别占用32个或者64比特。这部分是实现轻量级锁和偏向锁的关键。
2)用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分存储数组长度。

由于对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到Java虚拟机的空间使用效率,MarkWord被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。
它会根据对象的状态服用自己的存储空间。
在32位的Hotspot虚拟机中,对象未被锁定的状态下,MarkWord的32个比特空间有25个比特用于存储对象的哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,还有1个比特股定位0(表示未进入偏向模式)。对象除了违背锁定的正常状态外,还有轻量级锁定、重量级锁定、GC标记、可偏向等几种状态。

如果代码即将进入同步块的时候,如果此同步对象没有被锁定(标志位位01),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(LockRecord)的空间,用于存储锁对象目前的MarkWord的拷贝。

然后虚拟机将使用CAS操作尝试把对象的markword更新为指向LockRecord的指针。如果这个更新成功,那么代表该线程拥有了这个对象的锁,并且对像MarkWord的锁标志位将转变为00,表示此对象处于轻量级锁定状态。

如果更新操作失败,意味着至少存在一个线程与当前线程竞争获取该对象的锁。虚拟机首先检查对象的MarkWord是否只想当前线程的栈帧,如果是,说明线程已经拥有了这个对象的锁,那么直接进入同步块继续执行,否则说明这个对象已经被其他线程抢占了。
如果出现两条以上的线程争用同一个锁的情况,那么轻量级锁不再有效,必须要膨胀为重量级锁,锁标志的状态变为10, 此时Markword中存储的就是指向重量级锁的指针,后面等待锁的线程也必须进入阻塞状态。

它的解锁过程也是通过CAS来进行的,如果对象的MarkWord仍然指向线程的锁记录,那就用CAS操作把对象当前的MarkWord和线程中复制的DisplacedMarkWord替换回来,如果替换成功,说明解锁成功。
替换失败,说明有其他线程尝试过获取该锁,需要在释放锁的同时唤醒被挂起的线程。

如果没有竞争,轻量级锁能通过CAS操作成功避免使用互斥量的开销;
如果存在竞争,除了互斥量本身开销外,还额外发生了CAS操作的开销,因此性能更差。

偏向锁

用于消除数据在无竞争情况下的同步,连CAS都去掉了,进一步提高程序的运行性能。
偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一致没有被其他的线程获取,则持有偏向锁的线程永远不需要再同步。

如果当前虚拟机启用了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为01,把偏向模式设置为1,表示进入偏向模式。
同时使用CAS操作把获取到这个锁的线程ID记录在对象的MarkWord中。
如果CAS成功,持有偏向锁的线程以后每次进入这个锁相关的同步块是,虚拟机都可以不再进行任何同步操作。

一旦出现另外一个线程尝试获取这个锁的情况,偏向模式马上宣告结束。
根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(把偏向模式设置为0),撤销后标志位恢复到为锁定或者轻量级锁定的轧辊台,后续的同步操作按照轻量级锁执行。

偏向锁

当进入偏向状态是,MarkWord大部分的空间都用于存储持有锁的线程ID,这部分空间占用了原有存储对象Hash码的位置。当一个对象已经计算过一次一致性哈希码后,它就再也无法进入偏向锁状态。
当一个对象正在处于偏向锁状态时,受到计算hash码的请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。

在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类中有字段可以记录非加锁状态下的MarkWork,其中自然可以存储原来的哈希码。

偏向锁可以提高带有同步但无竞争的程序性能,但如果程序中大多数的锁都总是被多个不同的线程访问,那偏向锁就是多余的。

当代码被编译成二进制字节码,由Classloader加载到内存,经过加载、验证、准备、解释后将数据转化为JVM可以接受的格式存储到方法区,并在堆内存中创建一个Class对象,作为外部访问的入口。
接下来就由字节码执行引擎来解释执行代码。

《Java》虚拟机规范中规定了Java虚拟机字节码执行引擎的概念模型,输入字节码二进制流,输出执行结果。

在不同的虚拟机实现中,执行引擎在执行字节码的时候会有两种:

  • 解释执行(Sun Classic 虚拟机)
  • 编译执行(Jrockit)
  • 两种兼备 (HotSpot)

运行时栈帧结构

栈帧是用于支持虚拟机方法进行调用和执行的数据结构,每个方法从调用开始到执行结束的过程,都对应一个栈帧从虚拟机栈中入栈到出栈的过程。

栈帧存储了方法的局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。

栈帧

栈帧中需要多大的局部变量表、多深的操作数栈在编译Java的时候就已经计算出来存放在方法表的Code属性中。

局部变量表

用于存放方法参数和方法内部定义的局部变量。

在代码被编译成Class文件时,在方法的Code属性中max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。局部变量表的容量以变量槽(Variable Slot)为最小单位,每个变量槽应该可以存放一个boolean、byte、char、short、int、float、reference、returnAddress这8种不超过32位数据类型(对于超过32位的long和double,则使用两个32位的变量槽)。

引用类型:

  • 通过引用可以找到这个对象在堆中的数据存放的起始地址或者索引;
  • 找到对象所属类型在方法区中的存储类型;

returnAddress是为字节码指令jsr、jsr_w 和ret服务,指向了一条字节码指令的地址,一些jvm使用这几条指令来实现异常处理时的跳转。
现在全部改为采用异常表来代替。

当一个方法被调用的时候,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,如果是实例方法局部变量表第0位索引的变量槽存放的是this。

以恰当的变量作用域来控制变量的回收时间才是最优雅的解决方法,而不是使用完后就将其引用赋予null, 因为这仅仅是基于虚拟机的概念模型层面作出的优化,虚拟机的具体实现不同,解释执行与编译执行结果都不同。

操作数栈

一个后入先出的栈,同局部变量表一样,操作数栈的最大深度也在编译的时候就被写入到Code属性的max_stacks数据项中。32位数据类型所占用的栈容量是1, 64位数据类型所占的栈容量是2.

通过将操作数入栈出栈来进行算术运算;
通过操作数栈将方法的参数进行传递;

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用时为了支持方法调用过程中的动态连接。

Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令以常量池指向方法的符号引用作为参数。

这些符号引用一部分在类加载阶段或者第一次使用会被转化为直接引用;
另一部分在每次运行期间都转化为直接引用,这部分称为动态连接;

方法返回地址

当一个方法退出以后,必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,来帮助恢复主调方法的执行状态。

一个方法的退出有两种方式:
第一种是执行引擎遇到方法返回的字节码指令;主调方法的PC计数器的值就可以作为返回地址。
另一种是遇到了异常,并且在本方法的异常表中没有搜索到匹配的异常处理器导致方法退出;

方法调用

方法调用阶段的任务是确认被调用方法的版本。
Class文件的编译过程不包含传统程序语言的连接步骤,一切方法调用在Class文件里面存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址。因此一些调用需要在加载期间,甚至到运行期间才能确定目标方法的直接引用。

解析

在类加载的解析阶段,将静态方法和私有方法对应的符号引用转化为直接引用。因为静态方法与类关联,私有方法不可能被继承或者重写,在编译阶段即可确定。

字节码指令集设计了不同的指令调用不同类型的方法:

  • invokestatic, 调用静态方法
  • invokespecial, 调用实例构造器方法,私有方法,父类中的方法。
  • invokevirtual, 用于调用所有的虚方法(被final修饰的方法)
  • invokeinterface,用于调用接口方法,会在运行时再确定一个实现接口的对象
  • invokedynamic, 在运行时动态解析出调用点限定符引用的方法。

前四条指令,分派逻辑都固化在Java虚拟机内部,invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。

只要能被invokestatic 和 invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本。Java语言中符合这个条件的方法有静态方法、构造方法、私有方法、父类方法和被final修饰的方法,这五种方法被称为非虚方法,其他的被称为虚方法。

解析调用是一个静态的过程, 在类加载的解析阶段就把涉及的符号引用转换为明确的直接引用,不必延迟到运行期再去完成。

分派

  • 静态分派
    所有依赖静态类型来决定方法执行版本的分派叫做静态分派。静态分派的典型应用表现是方法重载。
    方法重载发生在同一个类中,方法名称相同,参数列表不同。

  • 动态分派与实现
    在运行期根据时机类型确定方法执行版本的分派过程叫动态分派,它就是Java语言中方法重写的本质。多态的根源在于虚方法调用指令invokevirtual的执行逻辑,因此多态只对方法有效,对字段无效(字段永远不参与多态)。

    动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接受着类型方法元数据中搜索合适的目标方法,

  • 单分派和多分派
    方法的接收者(要被执行的方法的所有者叫接收者)与参数统称为方法的宗量。
    单分派是根据一个宗量对目标方法进行选择;
    多分派是根据多个宗量对目标方法进行选择;

Java中的解释执行与编译执行

编译过程

在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树。

对于C/C++来说,词法、语法分析以后到后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整有意义的编译器去实现,这类就是编译执行。

对于Java来说,javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再便利语法树生成线性的字节码指令流的过程。

基于栈的指令集与给予寄存器的指令集

  • 基于栈的指令集
    零地址指令,依赖操作数栈进行工作,
    优点是可移植性高,不与具体的硬件绑定。编译器实现简单。
    缺点是速度慢。
  • 给予寄存器的指令集
    而地址指令集,物理硬件直接支持的指令集架构,依赖寄存器工作。

虚拟机最终会对执行过程作出一系列优化来提高性能, 实际的情况会和盖面模型差距非常大,差距产生的根本原因是虚拟机中解析起和即时编译起都会对输入的字节码进行优化,即使解释其中也不是按照字节码指令去逐条执行的。