JVM概述

本文为作者原创,转载请注明出处。

前言

首先纠正一些对JVM的误区:JVM是一种java虚拟机的规范,基于这个规范下比较主流的两个实现是Sun公司的HotSpot虚拟机google的dalvik虚拟机,前者是基于栈的虚拟机,后者是基于寄存器的虚拟机。Hotspot直接执行的是java字节码(class文件),dalvik则需要将class文件通过dx转变成可以直接执行的dex文件。(从这也能看出dalvik相对于Hotspot来说不那么符合JVM标准)。
从Android L开始,Android虚拟机从dalvik替换成了ART,ART主要改进在于:

  • AOT(预编译,通过dex2oat编译dex,提高了启动速度)
  • improved GC
    • 采用一个而非两个 GC 暂停
    • 在 GC 保持暂停状态期间并行处理
    • 采用总 GC 时间更短的回收器清理最近分配的短时对象这种特殊情况
    • 更加及时地进行并行垃圾回收,这使得 GC_FOR_ALLOC 事件在典型用例中极为罕见,压缩 GC 以减少后台内存使用和碎片
  • 开发和调试优化
  • 支持采样分析器
  • 优化崩溃报告

关于dalvik和art具体的实现机制不是本文重点,暂且点到为止。有兴趣的可以去看官方文档,后面我也会专门再写一篇关于dalvik的博客。

自动内存管理机制

运行时数据区

如图示,运行时数据区主要包括:方法区,虚拟机栈,本地方法栈,堆,程序计数器(PC)。

由于JVM同一时刻一个处理器只会执行一个线程,所以程序计数器是线程私有的。如果线程执行的是java方法,那PC记录的是正在执行的虚拟机字节码指令的地址;如果执行的native方法,那么PC值为undefined。程序计数器是唯一一个在JVM中没有规定任何OOM情况的区域。

同PC一样,虚拟机栈也是线程私有,每个方法执行时创建一个栈帧(stack frame),存储局部变量表,操作数栈,动态链接,方法出口。每一个方法从调用到结束,对应着一个栈帧在虚拟机栈里入栈到出栈的过程。局部变量表存放了各种基本数据类型(boolean,byte,char,short,int,float,long,double)以及对象引用(reference类型,可能是指针或者句柄)和returnAddress类型(指向一条字节码指令的地址)。这个区域规定了两种异常情况:如果线程请求的栈深度超过虚拟机栈的最大深度,就会跑出stackoverflow异常,部分虚拟机栈可以动态扩展,但是如果扩展后无法申请到足够的内存,则会抛出OOM异常。

本地方法栈和虚拟机栈类似,也会有SOF和OOM,区别在于本地方法栈执行的是native方法。

java堆是线程共享的,几乎所有的对象实例和数组都在堆上分配(JIT另说)。此处是垃圾回收的主要区域,按照分代收集算法可以分:新生代(包括Eden,from survivor和to survivor)和老年代。线程共享的堆上也可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

方法区也是多个线程共享,存放着已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。对Hotspot而言,他的GC扩展到了永久代(在java8开始就叫元空间),而方法区在永久代范围内,所以也有内存限制。这个区域的回收主要是对常量池的回收和类的卸载。运行时常量池是方法区的一部分,一旦内存不足会OOM。

Hotspot对象内存布局

Hotspot中对象在内存中存储的布局可分三部分:Header,Instance Data,PaddingHeader包括Mark WordKlass Pointer,前者存储对象的HashCode,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等,见下表,这些在32位和64位的虚拟机里分别占32bit和64bit。

存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 重量级锁定
空,无需记录信息 11 GC标记
偏向线程ID,偏向时间戳,对象分代年龄 01 可偏向

Klass Pointer是对象指向他的类元数据的指针,虚拟机通过这个指针确定这个对象是哪个类的实例。

Padding并不是必须的,Hotspot规定对象起始地址必须是8字节整数倍,所以意味着对象大小也要是8字节整数倍。对象头正好是8字节整数倍,所以当实例大小不是8字节整数倍是就需要这部分对齐。

Hotspot通过指针访问对象,好处是减少了一次指针定位,速度快。假如采用句柄访问的话好处在于对象被移动只需要修改句柄里的实例数据指针,reference本身不需要修改。

GC

哪些内存需要回收(不只是堆,还有永久代)

引用计数不能解决循环引用下的问题。java采用GC Roots tracing算法,对象到GC Roots不可达,则可回收。可作为GC ROOTs的对象有:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中的类静态属性引用的对象。
  • 方法区中的常量引用的对象。
  • 本地方法栈中 JNI(Native 方法)的引用对象。

引用的四种情况:

  • 强引用:宁愿OOM也不回收。
  • 软引用:抛出OOM之前会回收。
  • 弱引用:下一次GC就会被回收。
  • 虚引用:对生命周期不影响,只是让对象被GC之前收到一个通知。

GC Roots不可达不一定会被回收。实际上,在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行根搜索后发现没有与 GC Roots 相连接的引用链,那它会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。当对象没有覆盖 finalize()方法,或 finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。如果该对象被判定为有必要执行 finalize()方法,那么这个对象将会被放置在一个名为 F-Queue 队列中,并在稍后由一条由虚拟机自动建立的、低优先级的 Finalizer 线程去执行 finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会(因为一个对象的 finalize()方法最多只会被系统自动调用一次),稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果要在 finalize()方法中成功拯救自己,只要在 finalize()方法中让该对象重引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。

方法区的回收主要是废弃常量和无用的类。废弃常量意为不被任何地方引用,而无用的类必须满足以下三条件:

  • 该类实例均已被回收
  • 加载该类的classloader已被回收
  • 该类的class对象没有被任何地方引用,无法在任何地方通过反射获取该类的方法等。

如何回收

新生代主要是复制算法,eden:survivor=8:1,GC后eden+from survivor -> to survivor,这里还有分配担保。老年代主要是标记清除或者标记整理。

如何找到所有GC Roots?首先全栈搜索是不可行的,仅仅方法区栈大小就可能有百兆,其次确定GC Roots要求引用关系不变,即stop the world,这会对程序运行造成太大影响。所以Hotspot采用了OopMap,一开始OopMap的出现时为了实现准确性GC,当然也额外带来了扫描GC Roots速度的提升。这里区分下准确性GC和保守式GC,半保守式GC。

  • 保守式GC:在栈上不记录类型信息,栈上扫描的时候每次判断扫描到的是不是一个指向GC堆的指针。缺点是不准,并且扫描的时候对象不能移动。
  • 半保守式GC: 在栈上不记录类型信息,而在对象上记录类型信息。dalvik早期也使用这种。
  • 准确性GC: 对象的类型信息里有记录自己的OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据。所以从对象开始向外的扫描可以是准确的;这些数据是在类加载过程中计算得到的。

话又说回来,采用OopMap记录类引用数据,如果对每条指令都更新OopMap,那代价太大。所以必须选择某个刚刚合适的时刻来更新OopMap。这个时刻称为SafePoint。只有到达SafePoint才能暂停下来进行GC。安全点主要以“让程序长时间执行”为特征选取,主要有:

  • 1、循环的末尾
  • 2、方法临返回前 / 调用方法的call指令后
  • 3、可能抛异常的位置

然而即使有SafePoint,万一程序没有被分配CPU时间,那SafePoint将不会到达。也就无法开始GC。所以Safe Region出来了,当线程运行到一段引用关系不变的代码片段中时,就进入了Safe Region ,这样GC时就不用管进入到Safe Region的线程了。

GC一般有minor GC,major GC,full GC。major GC含义不明,有些说是针对老年代,有些说就是full GC。暂且不讨论。

  • 新生代 GC(Minor GC):发生在新生代的垃圾收集动作,因为 Java 对象大多都具有朝生夕灭的特性,因此Minor GC 非常频繁,一般回收速度也比较快。Eden区满就触发。
  • Full GC:发生在老年代&新生代&元空间的 GC,触发条件:
    • system.gc()建议触发
    • 新生代中对象晋升到老年代,可是老年代内存不够。(一般新生代到了15岁会晋升)
    • Minor GC时候存货对象过多,导致survivor区不够,需要老年代分配担保,但是老年代内存也不够担保。
    • 分配大对象老年代内存不够
    • 元空间内存不够。

垃圾收集器

  • Serial:client模式下默认的新生代收集器,简单高效,单线程,适合单CPU。
  • ParNew:serial的多线程版本,server模式下默认的新生代收集器。
  • Parallel Scavenge:新生代,精确控制吞吐量。
  • Serial Old:serial的老年代版本,标记-整理,client。
  • Parallel Old:Parallel Scavenge的老年代版本,标记整理。
  • CMS:concurrent mark sweep。优点是低停顿,缺点是
    • CPU敏感,(cpu+3)/4=线程数,CPU核数越少性能代价越高
    • 无法处理浮动垃圾(并发清除阶段产生的垃圾)
    • 基于标记清除,产生大量碎片,易触发full gc。
      CMS工作过程分四步:
    • 初始标记:标记直连GC ROOTS的对象,会stop the world
    • 并发标记:GC ROOTS Tracing
    • 重新标记:修正上一步过程中发生的引用关系变化。同样会stop the world
    • 并发清除
  • G1:JDK7开始商用的服务端 垃圾回收器,通过-XX:+UseG1GC参数来启用。全称是Garbage First。G1中虽然保留了分代,但是各代的逻辑地址是不连续的。堆分为大小相等的region,面向单独的region收集。相对CMS来说,其优点在于空间整合(从整体看是标记整理,region之间看是复制)和可预测的停顿。user可以指定在M毫秒内消耗在GC的时间不超过N毫秒。每个region都有个RSet:用于记录”谁引用了我”。虚拟机发现程序对Reference类型数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之间,如果是便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set中。当内存回收时,在GC根节点的枚举范围加入Remembered Set即可保证不对全局堆扫描也不会有遗漏。还有CSet也是辅助GC的空间结构,记录哪些region需要GC。

内存分配策略

  • 对象优先在 Eden 分配。
  • 大对象直接进入老年代。
  • 长期存活的对象将进入老年代。(一般到15岁,可通过-XX:MaxTenuringThreshold设置,另外如果survivor中相同年龄的对象大小超过survivor空间一半,那么这些对象都可以直接进入老年代)

GC日志

产生gc日志:-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:<your-gc-log-file-path>

[Full GC [PSYoungGen: 116544K->12164K(233024K)] [PSOldGen: 684832K->699071K(699072K)] 801376K->711236K(932096K) [PSPermGen: 2379K->2379K(21248K)], 3.4230220 secs] [Times: user=3.40 sys=0.02, real=3.42 secs]

可知是full GC,新生代,老年代,永久代后面的三个数字分别是:before gc -> after gc (totoal allocated memory size),再后面的三个时间分别是CPU花在用户模式,kernel的时间以及真实的合计时间。

性能监控和故障处理工具

只列出部分,具体详见官方文档

虚拟机执行子系统

类文件结构

class文件是一组以8字节为基础单位的二进制流,遇到大于8字节的数据会按照大端法(即最高位字节在地址最低位,最低位字节在地址最高位)存储。U1,U2,U4,U8分别代表占1,2,4,8个字节的无符号数。

class文件格式如下:

类型 名称 数量
u4 magic 1
u2 minor_version 1
u2 major_version 1
u2 constant_pool_count 1
cp_info constant_pool constant_pool_coun-1
u2 access_flags 1
u2 this_class 1
u2 super_class 1
u2 interfaces_count 1
u2 interfaces interfaces_count
u2 fields_count 1
field_info fields fields_count
u2 methods_count 1
method_info methods methods_count
u2 attributes_count 1
attribute_info attributes attributes_count

下面以一段简单的java程序来分析字节码:

1
2
3
4
5
6
7
8
9
10
11
12

public class Main {
private int m;

public int inc() {
return m + 1;
}

public static void main(String[] args) {
System.out.println("hello world");
}
}

通过javac得到class文件,通过sublime打开:

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
cafe babe 0000 0035 0023 0a00 0700 1409
0006 0015 0900 1600 1708 0018 0a00 1900
1a07 001b 0700 1c01 0001 6d01 0001 4901
0006 3c69 6e69 743e 0100 0328 2956 0100
0443 6f64 6501 000f 4c69 6e65 4e75 6d62
6572 5461 626c 6501 0003 696e 6301 0003
2829 4901 0004 6d61 696e 0100 1628 5b4c
6a61 7661 2f6c 616e 672f 5374 7269 6e67
3b29 5601 000a 536f 7572 6365 4669 6c65
0100 094d 6169 6e2e 6a61 7661 0c00 0a00
0b0c 0008 0009 0700 1d0c 001e 001f 0100
0b68 656c 6c6f 2077 6f72 6c64 0700 200c
0021 0022 0100 044d 6169 6e01 0010 6a61
7661 2f6c 616e 672f 4f62 6a65 6374 0100
106a 6176 612f 6c61 6e67 2f53 7973 7465
6d01 0003 6f75 7401 0015 4c6a 6176 612f
696f 2f50 7269 6e74 5374 7265 616d 3b01
0013 6a61 7661 2f69 6f2f 5072 696e 7453
7472 6561 6d01 0007 7072 696e 746c 6e01
0015 284c 6a61 7661 2f6c 616e 672f 5374
7269 6e67 3b29 5600 2100 0600 0700 0000
0100 0200 0800 0900 0000 0300 0100 0a00
0b00 0100 0c00 0000 1d00 0100 0100 0000
052a b700 01b1 0000 0001 000d 0000 0006
0001 0000 0002 0001 000e 000f 0001 000c
0000 001f 0002 0001 0000 0007 2ab4 0002
0460 ac00 0000 0100 0d00 0000 0600 0100
0000 0600 0900 1000 1100 0100 0c00 0000
2500 0200 0100 0000 09b2 0003 1204 b600
05b1 0000 0001 000d 0000 000a 0002 0000
000a 0008 000b 0001 0012 0000 0002 0013

首先可以看出魔数为0xCAFEBABE,所有java class魔数都是这个,否则无法通过校验。其次minor_version为0x0000,major_version为0x0035,即53,这是因为我的jdk版本为1.9。接着看常量池大小为0x0023,即35,说明有34个常量(要减1)。后面开始就是34个常量表了。常量池里一般放两大类常量:字面量和符号引用。字面量即文本字符串,final常量等。而符号引用包括了以下三类常量:

  • 类和接口的全限定名
  • 字段的name和descriptor
  • 方法的name和descriptor

首先介绍下常量池里的项目类型:

以及每种类型常量详细的结构:

接着来分析class文件,下一个字节是0x0a,从上表可知这是个方法引用,接着看后两个字节为0x0007,说明声明方法的类描述符在第7项,接着后俩字节为0x0014,说明NameAndType描述符在第20项。这样第一个常量占用的内存就结束了。下面通过javap辅助分析:

可知该方法为Object类的初始化方法。(Main类继承自Object)

后面的常量类似分析即可。

由javap看出access_flags为0x0021,为ACC_PUBLICACC_SUPER按位取或的结果。所有flags见下表:

Flag Name Value Interpretation
ACC_PUBLIC 0x0001 Declared public; may be accessed from outside its package.
ACC_PRIVATE 0x0002 Declared private; usable only within the defining class.
ACC_PROTECTED 0x0004 Declared protected; may be accessed within subclasses.
ACC_STATIC 0x0008 Declared static.
ACC_FINAL 0x0010 Declared final; no further assignment after initialization.
ACC_SUPER 0x0020 Treat superclass methods specially when invoked by the invokespecial instruction.(always true after jdk1.0.2)
ACC_VOLATILE 0x0040 Declared volatile; cannot be cached.
ACC_TRANSIENT 0x0080 Declared transient; not written or read by a persistent object manager.
ACC_NATIVE 0x0100 Declared native; implemented in a language other than Java.
ACC_INTERFACE 0x0200 Is an interface, not a class.
ACC_ABSTRACT 0x0400 Declared abstract; may not be instantiated.
ACC_STRICT 0x0800 Declared strictfp; floating-point mode is FP-strict

在sublime中搜索找到其位置,以便往后继续分析:

access_flags后两个字节为:0x0006,再后两个字节为0x0007,说明this_classsuper_class分别指向第6和第7个常量,由javap也可以验证,正好第六个常量是Main,第七个常量是Object。接着看下一个字节是0x0000,说明interfaces_count为0。所以没有interfaces的字节码,接下来的0x0001就代表了fields_count,即后面fields部分有一个字段表。那么字段表是什么结构呢?如下:

类型 名称 数量
u2 access_flags 1
u2 name_index 1
u2 descriptor_index 1
u2 attributes_count 1
attribute_info attributes attributes_count

接着来看字段表的字节码:

可知access_flags为0x0002,即ACC_PRIVATEname_index为0x0008,还是回顾之前的javap截图,第8个常量为m,descriptor_index为0x0009,对应第9个常量为I,意为int。attributes_count为0x0000,即没有多余属性。综合起来正好吻合private int m。关于描述符标识字符的含义可见下表:

标识字符 含义
B byte
C char
D double
F float
I int
J long
S short
Z boolean
V void
L 对象类型,如Ljava/lang/Object

对于数组类型,每一维度使用前置的[表示,如String[][]表示为[[Ljava/lang/String;。使用描述符描述方法时按照先参数后返回值的顺序描述,而且参数按顺序放在()内,如void test() -> ()V,又比如int fun(char[] source,int sourceOffset,Object o,char[] target,int targetOffset,int fromIndex) -> ([CILjava/lang/Object;CII)I。其中对象类型后面要紧接着;,基本类型不加。

再往后看methods_count,即0x0003,有三个方法。方法表的结构和字段表的结构一样:

类型 名称 数量
u2 access_flags 1
u2 name_index 1
u2 descriptor_index 1
u2 attributes_count 1
attribute_info attributes attributes_count

这里先介绍下attribute_info大体应该符合的结构:

类型 名称 数量
u2 attribue_name_index 1
u4 attribue_length 1
u1 info attribute_length

下面来看第一个方法的字节码:

同样方法分析:access_flags为0x0001,即ACC_PUBLICname_index为0x000a,找到第10个常量为<init>descriptor_index为0x000b,第11个常量为()V,即没有参数,返回值为void;attributes_count为0x0001,即有一个属性表。接下来的就是属性表里的内容:attribue_name_index为0x000c,找出来是Code,这个属性待会儿再介绍。attribue_length为0x0000001d,即29,所以后面有29个字节都是具体的属性信息。

Code属性表的结构:

类型 名称 数量
u2 attribue_name_index 1
u4 attribue_length 1
u2 max_stack 1
u2 max_locals 1
u4 code_length 1
u1 code code_length
u2 exception_table_length 1
exception_info exception_table exception_table_length
u2 attributes_count 1
attribute_info attributes attributes_count

其中attribue_name_index指向CONSTANT_UTF8_info类型的常量,常量值固定为Codeattribue_length指示了属性值的长度,固定为整个属性表长度减去6字节(attribue_name_index+attribue_length)。

接着来看attribute_length后面的29个字节:

可知max_stackmax_locals都为0x0001,即1,而code_length为0x00000005,即后面的5个字节都是字节码指令。那我们试着来翻译这5个字节(2ab70001b1):

  • 0x2a对应指令为aload_0,可以查官方文档指令表得知。该指令含义为将一个引用类型本地变量推到操作数栈顶。
  • 0xb7对应指令为invokespecial,意为调用栈顶数据指向的对象的实例构造器,private方法或其父类构造方法。
  • 接下来是invokespecial的参数,根据0x0001查常量池第1个常量得到#1 = Methodref #7.#20 // java/lang/Object."<init>":()V即父类的构造方法。
  • oxb1对应指令为return,即返回方法,而且返回void。执行完后方法结束。

通过javap来验证下我们的翻译是否正确:

可见我们的翻译是正确的。其中还有个LineNumberTable属性,用于描述java源码行号与字节码行号之间的对应关系。没有该属性会导致抛出异常时堆栈中不显示出错的行号。这里还应该注意下,图上inc()方法里locals为1,Args_size也为1,这是因为每个方法的局部变量表里有一个看不见的局部变量指向类实例,方法参数里也隐式添加上了this。

还有其他属性暂且不去深究。

剩下两个方法也同样分析。这里跳过。类文件就只剩最后的attribute_countattribute_info了。

容易知道attribute_count为1,attribute_info含义为SourceFile Main.java。到这里我们就把这个简单的类文件分析完了。

了解java字节码对我们来说有什么意义呢?数据统计,APM之类的SDK都可以通过字节码插桩来实现无痕AOP。例如eleme的lancet和Jake wharton的hugo都是类似原理,一个使用的是ASM,一个是ASPECTJ。

字节码指令简介

java字节码指令很多格式相同,只是操作数类型不同。例如:baload,iaload,aaload都是Taload格式的指令,都是表示加载。不同的是b表示byte,i表示int,a表示reference。类似的,s代表short,l代表long,f代表float,d代表double,c代表char。对于Taload型指令言有8个具体的指令,涵盖了刚才那8种类型,但是不是每类指令都有8个,例如Tadd类型的就不支持byte和char,事实上,大部分类型的指令都不是完整的有8条,即不能支持所有类型的操作数。

加载和存储指令

  • 将一个局部变量加载到操作数栈:iload,iload_<n>,lload,lload_<n>,fload,fload_<n>,dload,dload_<n>,aload,aload_<n>
  • 将一个数从操作数栈存储到局部变量表:istore,istore_<n>,lstore,lstore_<n>,fstore,fstore_<n>,dstore,dstore_<n>,astore,astore_<n>
  • 将一个常量加载到操作数栈:bipush,sipush,ldc,ldc_w,ldc2_w,aconst_null,iconst_m1,iconst_<i>,lconst_<l>,fconst_<f>,dconst_<d>

运算指令

  • 加:iadd,ladd,fadd,dadd
  • 减:isub,lsub,fsub,dsub
  • 乘:imul,lmul,fmul,dmul
  • 除:idiv,ldiv,fdiv,ddiv
  • 求余:irem,lrem,frem,drem
  • 取反:ineg,lneg,fneg,dneg
  • 位移:ishl,ishr,iushr,lshl,lshr,lushr
  • 按位或:ior,lor
  • 按位与:iand,land
  • 按位异或:ixor,lxor
  • 局部变量自增:iinc
  • 比较:dcmpg,pcmpl,fcmpg,fcmpl,lcmp

类型转换指令

  • 宽范围向窄范围:隐式转换
  • 窄范围向宽范围:i2b,i2c,i2s,l2i,f2i,f2l,d2i,d2l,d2f

更多指令

因为指令实在太多,不一一介绍(其实是因为我很多指令都还不知道啥意思)

可以前往gityuan的这篇博客java7官方文档查看。

类加载机制

类加载的流程如下:

其中加载,验证,准备,初始化,卸载这五个阶段的顺序是确定的,而解析则不一定,可以再初始化之后再开始,这是为了支持java的动态绑定(运行时)。JVM严格规定了有且只有以下五种情况必须立即对类进行初始化:

  • 遇到new,putstatic,getstatic,invokestatic这几条指令时如果类未初始化则必须先初始化。
  • 反射调用某类的时候如果该类未初始化则必须先初始化。
  • 初始化一个类时候如果父类未初始化则先初始化父类。
  • 虚拟机启动时候包含main()哪个类要先初始化。(程序入口)
  • 使用jdk1.7的动态语言支持时,如果一个MethodHandle实例最后的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄和这个句柄对应的类都没有初始化,那先触发其初始化。

以上的情况都属于主动引用,再来说说被动引用的几种情况。

  • 通过子类引用父类中定义的静态字段,只会触发父类的初始化。
  • 数组创建不会触发类的初始化,如T[] t=new T[1000]不会触发T的初始化,但是会触发[T的初始化,这是由jvm生成的继承自Object的子类,创建动作由指令newarray触发。
  • A类使用了B类的静态常量,不会触发B类的初始化。因为编译期该常量存入了A类的常量池中。

加载

虚拟机主要完成:

  • 通过一个类的全限定名获取定义该类的二进制字节流
  • 将该字节流所代表的静态存储结构转换为方法区的运行时数据结构
  • 在内存中生成一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

验证

1.文件格式验证

  • 是否以魔数(0xcafebabe)开头
  • 主次版本号是否在虚拟机处理范围内
  • 常量池中常量是否有不被支持的类型(检查tag标志)
  • 指向常量的各种索引值是否有指向不存在的常量或者不符合类型的常量
  • CONSTANT_Utf8_info类型的常量是否有不符合utf8编码的数据
  • class文件各部分以及文件本身是否有增删
  • ……

2.元数据验证

  • 该类是否有父类
  • 是否继承了final类
  • 如果该类不是抽象类,是否实现了抽象父类或接口中的所有抽象方法
  • 类中字段和方法是否与父类矛盾(例如覆盖了父类的final字段,或者错误的方法重载)
  • ……

3.字节码验证

  • 保证操作数类型和指令支持的类型一致
  • 保证跳转指令不会跳转到方法提以外的字节码指令上
  • ……

4.符号引用验证

  • 符号引用中通过字符串引用描述的全限定名是否能找到对应的类
  • 指定类中是否存在相应的方法和字段
  • 类,方法,字段的访问性是否遵循

准备

正式为类变量(static)在方法区分配内存并设置初始值的阶段。例如static int value = 123在该阶段过后value为0,因为赋值为123的过程发生在类构造器<clinit>方法中,在初始化阶段才会执行。但是如果是static final int value = 123就不一样了,value的属性表中有ConstantValue属性,将直接赋值为该属性指向的值,也就是123

解析

将常量池中的符号引用替换为直接引用的过程。符号引用通常是涉及字符串的——用文本形式来表示引用关系。而直接引用是JVM(或其它运行时环境)所能直接使用的形式,例如指针,地址偏移,句柄。一个和虚拟机内存布局不相关,一个相关。

更详细的区别可以看R大的回答

初始化

类加载的最后一步。主要是执行类构造器,<clinit>()。该方法是由编译期收集类的变量赋值和static 块的语句合并产生的,收集的顺序是源文件中出现的顺序。<clinit>()与实例构造器<init>()不同,他不需要先调用父类的类构造器,虚拟机会保证父类的类构造器先执行完。因为虚拟机中最先执行的的类构造器一定是Object类的类构造器。同时这也意味着父类的static块在子类static块和类变量赋值之前执行。如果一个类没有类变量赋值和static语句块,那么编译期可以不生成类构造器。类构造器执行在多线程环境下虚拟机会正确地加锁,保证原子性。

双亲委托模型

先介绍下类加载器

双亲委托模型的实现:

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
34
35
36
37
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

先检查类是否已被加载,未加载则调用父类的loadClass(),父类加载失败抛出ClassNotFoundException后再调用自己的findClass()方法加载。整个过程是同步的。父类为空则使用BootstrapClassLoader作为父类。

打破双亲委托模型:自定义classloader重写loadClass()。注意:不能去加载一个以java.*这种的为包名的类,会收到虚拟机的SecurityException.

运行时栈帧结构

stack frame是用于支持虚拟机方法调用和方法执行的数据结构,存在于虚拟机栈中(线程私有),存储了方法的局部变量表,操作数栈,动态链接和方法返回地址等信息。每个方法从调用到执行完成的过程都对应一个栈帧在虚拟机中从入栈到出栈的过程。局部变量表用于存放方法的参数或者局部变量,在方法的code属性的max_locals项中确定了该方法所要分配的局部变量表的最大容量。局部变量表的容量以变量槽(variable slot简称Slot)为最小单位。每个Slot应该能存放一个boolean,byte,char,short,int,float,reference,returnAddress类型的数据。局部变量表线程私有。操作数栈的最大深度在max_stacks数据项中定义了。

并发

java内存模型(JMM)

JMM的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中读取数据这样的底层细节。JMM规定了所有的变量都存储在主内存中,但每个线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,工作内存是线程之间独立的,线程之间变量值的传递均需要通过主内存来完成。

内存间交互操作有:lock,unlock,read,load,use,assign,store,write。要将一个变量从主内存复制到工作内存,就要顺序执行read和load;反之,从工作内存同步回主内存要顺序执行store和write。

JMM的规则:

  • 不允许read和load、store和write这俩成对操作之一单独出现。
  • 不允许一个线程丢弃assign操作,即变量在工作内存中改变了必须同步回主内存。
  • 不允许一个线程无原因的(没有assign操作)将数据从工作内存同步回主内存。
  • 一个新的变量只能在主内存中产生,不允许在工作内存直接使用未load或assign的变量。
  • 同一时刻一个变量只能被一个线程lock,但是同一个线程可以对同一个变量多次lock。只有执行相同次数的unlock,变量才会被解锁。
  • 对一个变量lock后会清空工作内存中该变量的值,重新使用该变量前必须load或者assign。
  • lock和unlock成对使用,不允许线程A去unlock线程B lock的变量。
  • unlock之前必须将该变量同步回主内存,即store和write。

对于volatile变量,其具备两个特性:可见性和禁止指令重排序优化。可见性指一个线程修改了这个变量的值,新值对于其他线程来说是立即可知的。由于volatile只能保证可见性,所以在不符合以下两条规则的场景中,依然要通过加锁来保证原子性。

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

对于第一条规则例如i++,我们知道其翻译成指令依次是:getstatic,iconst_1,iadd,putstatic,不是原子操作。第二条规则例如:

1
2
3
4
5
6
7
8
9
10
11
valatile boolean shutdownRequested = false;

public void shutdown() {
shutdownRequested = true;
}

public void work(){
while(!shutdownRequested){
// do stuff
}
}

再来说说指令重排序。来看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
volatile boolean initialized = false;

// in thread A
// initialize some configs , may cost time ...

initialized = true;

// in thread B, 等待initialized为true,代表A准备好了。
while(!initialized){
sleep();
}

doSomeThingAfterInitialized()

假如没有用volatile修饰,由于指令重排序可能在A线程还没初始化完成的时候,initialized就成了true,导致B运行出错。volatile会在赋值操作后插入一个lock操作,作为memory barrier,使得本CPU的cache写入内存,同时使得其他CPU invalidate 其cache。指令重排序无法越过memory barrier,即barrier之后的指令不会再barrier之前执行。所以volatile变量读和其他的变量一样,写的话开销稍微大一点。总的来说比锁低很多。

除了volatile之外,synchronized和final也能实现可见性。synchronized的可见性是由unlock之前必须将该 变量同步回主内存这条规则得到的。

happens-before原则:

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
  • 传递性

乐观锁和悲观锁:乐观锁总认为发生冲突概率小,所以不加锁尝试运行,如果产生了冲突则不断重试,在竞争不激烈的时候效率高。悲观锁是总假设最坏的情况,即每次都会加锁。synchronize就是一种悲观锁。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”

java对CAS的支持:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class AtomicInteger extends Number implements java.io.Serializable {  

private volatile int value;

public final int get() {
return value;
}

public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}

public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
}

getAndIncrement相当于++i,其通过CAS实现了非阻塞的同步,简单高效。但是CAS也有问题。比如ABA问题,假如一个过程中A->B->A,CAS会认为A的值没变。可实际上却变化了,JDK1.5提供了AtomicStampedReference来解决这类问题。CAS如果执行时间长的话开销较大,并且只能保证一个共享变量的原子操作。

可重入锁和不可重入锁:支持嵌套加锁的属于可重入锁,比如synchronizedReentrantLock,不支持的属于不可重入锁(自旋锁)。一个简单的实现二者的思路是:自旋锁加锁的时候设置locked为true,再次加锁的话如果locked为true就waiting;可重入锁的话再多一个lockedBy标记是哪个thread加的锁,一个线程加锁后其他线程如果试图加锁就waiting,如果是已获得锁的线程再次加锁的话无需等待。


参考: