cherish

返朴归真


  • Home

  • Archives

  • Tags

  • Categories

Class文件和类加载机制

Posted on 2019-12-03 | In JAVA , JAVA JVM |
Words count in article: 11.3k | Reading time ≈ 40

1 class文件结构

  • Class文件结构是了解虚拟机的重要基础之一,如果想深入的了解虚拟机,Class文件结构是不能不了解的。

  • Class文件是一组以字节为基础单位的二进制流,各项数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,如果是超过一个字节以上空间的数据项,则会按照高位在前的方式(Big-Endian)分割成若干个字节进行存储。(Big-Endian模式具体可见详解大端模式和小端模式)

  • Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。

  • Class文件格式只有两种数据类型:无符号数和表。

    • 无符号数属于基本的数据类型,以u1,u2,u4,u8来分别代表1个字节,2个字节,4个字节和8个字节的无符号数;可用来描述数字,索引引用,数量值或者按照UTF-8编码构成的字符串值。
    • 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述由层次关系的复合结构的数据。
  • 整个Class文件本质上就是一张表

class文件的内容中没有任何的分隔符号,所以在上表中的数据项,无论是顺序还是数量,都是被严格限定的,哪个字节代表什么含义,长度多少,先后顺序如何,都不允许改变。

1.1 魔数和class文件的版本

  • Class文件的头4个字节称为魔数(Magic Number),它唯一的作用是确定这个文件是否能被一个虚拟机接受,他是一个固定的值: 0XCAFEBABE(咖啡宝贝)。如果开头四个字节不是0XCAFEBABE, 那么就说明它不是class文件, 不能被JVM识别

    • 文件存储标准中都使用魔数来进行身份识别,比如图片格式,gif和JPEG等文件头中都存有魔数。使用魔数而非拓展名来识别身份,主要是基于安全方面的考虑,因为文件拓展名可以随意修改。
  • 紧接着魔数的4个字节是Class文件的版本号:第5,6字节是次版本号(Minor Version),第7,8字节是主版本号(Major Version)。

    • java版本号从45开始,jdk1.1以后每个jdk大版本发布,主版本号向上加1。(jdk1.0~1.1使用了45.0~45.3的版本号)
    • 一般情况下, 高版本的JVM能识别低版本的javac编译器编译的class文件, 而低版本的JVM不能识别高版本的javac编译器编译的class文件。 如果使用低版本的JVM执行高版本的class文件, JVM会抛出java.lang.UnsupportedClassVersionError 。
    • 下图为jdk1.1到jdk1.7,主流jdk编译器输出的默认和可支持的class文件版本号:

1.2 常量池

  • 紧接着主次版本号之后是常量池入口,由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一个常量池容量计数值(constant_pool_count),这个容量计数是从1而不是0开始的,设计者这样设计的目的是为了满足后面某些指向常量池的索引值的数据在特殊情况下需要表达“不引用任何一个常量池项目”的含义。(所以上表中常量的数量为constant_pool_count-1)
  • 常量池中主要存放两大类常量:字面量(Literal)和符号引用。

    • 字面量接近Java语言层面的常量概念,如文本字符串、声明为final的常量值等。
    • 符号引用属于编译原理的概念,包括三类常量:
      1. 类和接口的全限定名;
      2. 字段的名称和描述符;
      3. 方法的名称和描述符。
    • 符号引用 :符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用和虚拟机的布局无关。可以理解为:在编译的时候虚拟机并不知道引用对象的直接地址,多以就用符号引用来代替,而在解析阶段,就是为了把这个符号引用转化成为真正的地址的阶段。

  • 常量池中每一项常量都是一个表,在JDK1.7之后共有14种表结构(有14种不代表每个类的常量池都有全部的14种)。它们有一个共同的特点,就是表开始的第一位是一个u1类型的标志位(tag,取值见下表),代表当前这个常量属于哪种常量类型。

  • 这14种常量类型各自有自己的结构,下面列出每个常量项的结构及含义

继续看下文我们就会知道,常量池就是给别人引用的,甚至常量池内部除了基本数据类型和utf8编码字符串以外,其他项要是需要用字面量来描述,也是利用一个index来指向一个基本数据类型的常量。

1.3 访问标志

  • 紧接着常量池之后的2个字节代表访问标志(access_flags),用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口、是否为public类型、是否为abstract类型、类是否声明为final等。标志位及其含义如下表

  • 假设一个类为普通java类,不是接口,不是枚举或者注解,被public修士但没有被声明为final和abstract,那么它的ACC_PUBLIC标志应该为真(即为1),而ACC_FINAL、ACC_INTERFACE、ACC_ABSTRACT等标志位都应该为假。

  • access_flags中一共有16个标志位可以使用,当前只定义了其中8个,没用使用到的标志位要求一律为0。

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

  • Class文件中由 索引、父类索引与接口索引集合 这三项数据来确定这个类的继承关系。

  • 访问标志之后顺序排列类索引、父类索引、接口索引集合。

  • 类索引两个字节,用于确定这个类的全限定名。

  • 父类索引两个字节,用于确定这个类的父类的全限定名。因为java不允许多继承,所以只有一个父类索引,除了Object类以外,所有的类都有父类索引。Object的父类索引值为0;

  • 类索引和父类索引的值都指向了一个类型为CONSTANT_Class_info的类描述符常量,通过前文我们知道通过CONSTANT_Class_info类型中的index值可以定位到一个CONSTANT_Utf8_info类型的常量,该常量中有全限定名字符串。下图展示了其索引过程:

  • 接口索引集合大小不确定,用来描述这个类实现了哪些接口。接口索引集合入口第一项是u2类型的接口计数器(interfaces_count)表示索引表的容量(即实现了几个接口)。如果该类没用实现任何接口,则计数器值为0,后面的接口索引表不再占用任何字节。否则,接口索引集合的内容也是为指向CONSTANT_Class_info类型的索引值。

1.5 字段表集合

排在接口索引集合后边的是字段计数器:用于标识有多少个字段;

接着就是字段表集合。字段表(field_info)用于描述接口或者类中声明的变量。字段包括类级变量以及实例级变量(不包括方法内声明的局部变量)。可以包括的信息有:

  1. 字段的作用域(public、private、protected修饰符)
  2. 实例变量还是类变量(static修饰符)
  3. 可变性(final)
  4. 并发可见性(volatile)
  5. 可否被序列化(transient)
  6. 字段数据类型(基本类型,对象,数组)
  7. 字段名称

我们来看下字段表集合的结构:

  • access_flags:其中public、private、protected、static、final、volatile、transient这些修饰符都是用access_flags字段来表示的,和上面讲述的类的access_flags类似,即如果一个字段是public的,那么public对应的标志位应该为真(1),以此类推,这些修饰符对应的标志位如下图:

  • name_index和descriptor_index:这两个index都是对常量池的引用,分别代表着字段的“简单名称”和“字段和方法的描述符”;

    • 全限定名:就是类名全称,例如:org/xxx/class/testClass,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”表示全限定名结束。
    • 简单名称:即没有类型和参数修饰的字段或者方法名称,例如方法test()的简单名称就是test,m字段的简单名称就是m。
    • 描述符:描述符的作用是描述字段的数据类型、方法的参数列表(包括数量、类型及顺序)和返回值。根据描述符的规则,基本数据类型以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名表示,见下表
      • 对于数组类型,每一维度将使用一个前置的“[”字符来描述,如“String[][]”,会被记录为”[[Ljava/lang/String”,”int[]”被记录为“[I”。
      • 描述符描述方法时,按照先参数列表,后返回值的顺序描述。参数列表按照参数的严格顺序放置一组小括号“()”内,如
        • “void inc()” 的描述符为“()V”
        • “viod main(String[] args)” 的描述符为“([Ljava/lang/String;)V”
        • “int indexOf(char[] source,int sourceOffset,int sourceCount,char[] target,int targetOffset,int targetCount,int fromIndex)” 的描述符为“([CII[CIII)I”。
  • 字段表集合中不会列出从超类或者父类接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段。

  • 在descriptor_index之后跟随着一个属性表集合用于存储一些额外的信息,字段都可以在属性表中描述零至多项的额外信息。对于本例中的字段m,他的属性表计数器为0,也就是说没有需要额外描述的信息,但是,如果将字段m的声明改为“int m=123”,那就可能会存在一项名称为ConstantValue的属性,其值指向常量123。

1.5.1 字段表集合demo

举例:假设对于一个TestClass.class文件来说,字段表集合从地址0x000000F*开始

  • 第一个u2类型的数据为容量计数器fields_count,如下图所示,其值为0x0001,说明这个类只有一个字段表数据。
  • 接下来紧跟着容量计数器的是access_flags标志,值为0x0002,代表private修饰符的ACC_PRIVATE标志位为真(ACC_PRIVATE标志的值为0x0002),其他修饰符为假。
  • 代表字段名称的name_index的值为0x0005,从常量表中可查的第5项常量是一个CONSTANT_UTF8_info类型的字符串,其值为“m”
  • 代表字符描述符的descriptor_index的值为0x0006,指向常量池的字符串“I”,根据这些信息,我们可以推断出原代码定义的字段为:“private int m;”。
  • 1.6 方法表集合

排在后边的是方法计数器:用于标识有多少个方法;

Class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,方法表的结构如同字段表一样,依次包括了访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表结合(attributes)几项,见下表。这些数据项目的含义也非常类似,仅在访问标志和属性表集合的可选项中有所区别。

  • access_flags

    • 因为volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有了ACC_VOLATILE标志和ACC_TRANSIENT标志。
    • 与之相对的,synchronized、native、strictfp和abstract关键字可以修饰方法,所以方法表的访问标志中增加了ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP和ACC_ABSTRACT标志。
    • 对于方法表,所有标志位及其取值可参考下表。
  • 代码:方法的定义可以通过访问标志、名称索引、描述符索引表达清楚,但方法里面的代码去哪里了?方法里的Java代码,经过编译器编译成字节码指令后,存放在方法属性集合中一个名为“Code”的属性里面,属性表作为Class文件格式中最具扩展性的一种数据项目,我们将在下一内容进行介绍。

  • 与字段表集合相对应的,如果父类方法在子类汇总没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器“<clinit>”方法和实例构造器“<init>”方法。

    1.6.1 方法表集合demo

举例:假设有一个Class文件,对方法表集合进行分析。

  • 如下图所示,方法表集合的入口地址为:0x00000101,第一个u2类型的数据(即是计数器容量)的值为0x0002,代表集合中有两个方法(这两个方法为编译器添加的实例构造器<int>和源码中的方法inc())。
  • 第一个方法的访问标志值为0x001,也就是说只有ACC_PUBLIC标志为真,名称索引值为0x0007,查常量池得方法名为“<init>”,描述符索引值为0x0008,对应常量为“( ) V”。
  • 属性表计数器attributes_count的值为0x0001就表示此方法的属性表集合有一项属性,属性名称索引为0x0009,对应常量为“Code”,说明此属性是方法的字节码描述。

1.7 属性表集合

与Class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松了一些,不再要求各个属性表具有严格顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉他不认识的属性。

为了能正确解析Class文件,《java虚拟机规范》预定义21项虚拟机实现应当能识别的属性,具体内容见下表。下文中将对其中一些属性中的关键常用的部分进行讲解。

对于单个属性来说,他的名称需要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。一个符合规则的属性表应该满足下表所定义的结构。

下面我们来介绍重要的,虚拟机规范预定义的属性——code属性。

1.7.1 code属性

Java程序方法体中的代码经过Javac编译器处理后,最终变为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽象类中的方法就不存在Code属性,如果方法表有Code属性存在,那么Code属性的结构将如下表所示。

  • attribute_name_index是一项指向CONSTANT_Utf8_info型常量的索引,常量值固定为“Code”,他代表了该属性的属性名称,

  • attribute_length指示了属性值的长度,由于属性名称索引与属性长度一共为6个字节,所以属性值的内容长度固定为整个属性表长度减6个字节。

  • max_stack代表了操作数栈(Operand Stacks)深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值分配栈帧(Stack Frame)中的操作帧深度。

  • max_locals代表了局部变量表所需的存储空间。在这里,max_locals的单位是Slot,Slot是虚拟机为局部变量分配内存所使用的最小单位。

    • 对于byte、char、float、int、short、boolean和returnAddress等长度不超过32位的数据类型,每个局部变量占用1个Slot,而double和long这两种64位的数据类型则需要两个Slot来存放。
    • 方法参数(包括实例方法中的隐藏参数“this”)、显式异常处理器的参数(Exception Handler Parameter,就是try-catch语句中catch块所定义的异常)、方法体中定义的局部变量都需要使用局部变量表来存放。
    • 另外,并不是在方法中用到了多少个局部变量,就把这些局部变量所占Slot之和作为max_locals的值,原因是局部变量表中的Slot可以重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的Slot可以被其他局部变量所使用,Javac编译器会根据变量的作用域来分配Slot给各个变量使用,然后计算出max_locals的大小。
  • code_length和code用来存储java源程序编译后生成的字节码指令。

    • code_length代表字节码长度,code是用于存储字节码指令的一系列字节流。
    • 既然叫字节码指令,那么每个指令就是一个u1类型的单字节,当虚拟机读取到code中的一个字节码时,就可以对应找出这个字节码代表的是什么指令,并且可以知道这条指令后面是否需要跟随参数,以及参数应当如何理解。我们知道一个u1数据类型的取值范围为0x00~0xFF,对应十进制的0~255,也就是一共可以表达256条指令,目前,Java虚拟机规范已经定义了其中约200条编码值对应的指令含义。
    • 关于code_length,有一件值得注意的事情,虽然他是一个u4类型的长度值,理论上最大值可以达到2的32次方减1,但是虚拟机规范中明确限制了一个方法不允许超过65535条字节码指令,即他实际只使用了u2的长度,如果超过这个限制,Javac编译器也会拒绝编译。
  • exception_table_length和exception_table表示这个方法的显示异常处理表(下文简称异常表)集合,异常表对于Code属性来说并不是必须存在的

    • 异常表的格式如下表所示
    • 他包含4个字段,这些字段的含义为:如果当字节码在第start_pc行到end_pc行之间(不含第end_pc行)出现了类型为catch_type或者其子类的异常(catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第handler_pc行继续处理。当catch_type的值为0时,代表任意异常情况都需要转向到handler_pc处进行处理。

Code属性是Class文件中最重要的一个属性,如果把一个Java程序中的信息分为代码(Code,方法体里面的Java代码)和元数据(Metadata,包括类、字段、方法定义及其他信息)两部分,那么在整个Class文件中,Code属性用于描述代码,所有的其他数据项目都用于描述元数据。

1.7.2 code属性demo

1
2
3
4
5
6
7
8
public class TestClass {

private int m;

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

以上面代码的TestClass.class文件为例

  • 如下图所示。这时实例构造器“<init>”方法的Code属性。

    • 他的操作数栈的最大深度和本地变量表的容量都为0x0001

    • 字节码区域所占空间的长度为0x0005。

    • 虚拟机读取到字节码区域的长度后,按照顺序依次读入紧随的5个字节,并根据字节码指令翻译出所对应的字节码指令。翻译“2A B7 00 0A B1”的过程中:

      1. 读入2A,查表得0x2A对应的指令为aload_0,这个指令的含义是将第0个Slot中为reference类型的本地变量推送到操作数栈顶。
      2. 读入B7,查表得0xB7对应的指令为invokespecial,这条指令的作用是以栈顶的reference类型的数据所指向的对象作为方法接收者,调用此对象的实例构造器方法、private方法或者他的父类的方法。这个方法有一个u2类型的参数说明具体调用哪一个方法,他指向常量池中的一个CONSTANT_Methodref_info类型常量,即此方法的方法符号引用。
      3. 读入000A,这时invokespecial的参数,查常量吃得0x000A对应的常量为实例构造器“<init>”方法的符号引用。
      4. 读入B1,查表得0xB1对应的指令为return,含义是返回此方法,并且返回值为void。这条指令执行后,当前方法结束。

2 类加载机制

上一节我们已经知道了类文件结构,在class文件中描述的各种信息最终都需要加载到虚拟机中之后才能运行和使用。那么虚拟机是如何加载这些class文件呢?class文件中的信息进入到虚拟机后会发生什么变化?

虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化。最终形成可以被虚拟机最直接使用的java类型的过程就是虚拟机的类加载机制。

另外需要注意的很重要的一点是:java语言中类型的加载、连接以及初始化过程都是在程序运行期间完成的,这种策略虽然会使类加载时稍微增加一些性能开销,但是会为java应用程序提供高度的灵活性。java里天生就可以动态扩展语言特性就是依赖运行期间动态加载和动态连接这个特点实现的。比如,如果编写一个面向接口的程序,可以等到运行时再指定其具体实现类。

2.1 类的生命周期

类从被加载到虚拟机内存到卸出内存为止,它的整个生命周期包括:

加载,样子,准备,初始化和卸载五个阶段的顺序是确定的,而解析阶段则不一定,他在有些情况下可以在初始化阶段后再开始。

2.2 类加载的时机

什么时候需要开始类加载的第一个阶段:加载?

虚拟机规范严格规定了有且只有五种情况必须立即对类进行“初始化”:

  1. 使用new关键字实例化对象的时候、读取或设置一个类的静态字段的时候,以及调用一个类的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类没有被初始化就会先初始化它的父类。
    • 而对于接口,当一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口时(如引用父接口中定义的常量)才会初始化。
  4. 当虚拟机启动的时候,用户需要指定一个要执行的主类(就是包含main()方法的那个类),虚拟机会先初始化这个类;
  5. 使用Jdk1.7动态语言支持的时候的一些情况。如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

这5种场景中的行为称为对一个类进行主动引用。还有就是被动引用:所有引用类的方式都不会触发初始化。

2.2.1 被动引用demo

下面是3个被动引用的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 被动使用类字段演示一:
* 通过子类引用父类的静态字段,不会导致子类初始化
**/
public class SuperClass {
static {
System.out.println("SuperClass init");
}
public static int value = 123;
}

class SubClass extends SuperClass {
static {
System.out.println("SubClass init");
}
}
/**
* 非主动使用类字段演示
*/
class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}

上述代码只会输出”SuperClass init”和”123”。对于静态字段,只有直接定义这个字段的类才会被初始化。因此通过其子类来引用父类定义的静态字段,只会触发父类的初始化。至于是否要触发子类的加载和验证,取决于虚拟机的具体实现。对于Sun HotSpot虚拟机来说,可通过-XX:+TraceClassLoading参数会导致子类的加载。

1
2
3
4
5
6
7
8
/**
* 被动使用类字段演示二:通过数组定义引用类,不会触发此类的初始化
*/
class NotInitialization {
public static void main(String[] args) {
SuperClass[] sc = new SuperClass[10];
}
}

这段代码没有触发初始化,但里面触发了另外一个名为”[LSuperClass”类的初始化阶段,对于用户代码来说,这并不是一个合法的类名称,它是由虚拟机自动生成的、直接继承与java.lang.Object的子类,创建动作由字节码指令newarray触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 被动使用类字段演示三:
* 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
*/
class ConstClass {
static {
System.out.println("ConstClass init");
}
public static final String HELLOWORLD = "hello world";
}
class NotInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);
}
}

上述代码也没有输出”ConstClass init”,因为Java源码中引用了ConstClass类中的常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值”hello world”存储到了NotInitialization类的常量池中。所以在NotInitialization对ConstClass.HELLOWORLD的引用实际上是对自身常量池的引用。

2.3 类加载的过程

2.3.1 加载

“加载” 是 “类加载” 过程的一个阶段,切不可将二者混淆。

加载”是”类加载”过程的一个阶段,虚拟机需要完成3件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。

    • 没有指明从哪里获取、怎样获取,可以说一个非常开放的平台了。
    • 目前可以从zip包获取,即jar,ear,war格式的基础。
    • 从网络获取,即applet实现。
    • 运行时计算生成,典型如动态代理。
    • 由其他文件生成,典型如JSP应用,即为JSP文件生成的class类。
    • 从数据库中读取,这种较少见。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

    • 方法区的数据存储格式由各种虚拟机实现自行定义,并无明确规范。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

    • 并没有明确存放于要在堆中,实际上它虽然是对象,但是HotSpot虚拟机仍将其存放在方法区中。

对于非数组类的加载阶段(准确的说是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器完成,也可以由用户自定义的类加载器完成(即重写一个类加载器的loadClass()方法)。

对于数组,数组类本身不通过类加载器创建,是由Java虚拟机直接创建的。但数组类与类加载器也有密切关系,因为数组类的元素类型(String[]的元素类型即String),最终要靠类加载器创建。

2.3.2 验证

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

虚拟机如果不检查输入的字节流,并对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。这个阶段是否严谨,直接决定了java虚拟机是否能承受恶意代码的攻击。

从整体上看,验证阶段大致上会完成4个阶段的校验工作:文件格式、元数据、字节码、符号引用。

  1. 文件格式验证:验证字节流是否符合Class文件格式的规范。

    • 是否以魔数0xCAFEBABE开头。
    • 主、次版本号是否在当前虚拟机处理范围之内。
    • 常量池的常量是否有不被支持的常量类型(检查常量tag标志)。
    • …
  2. 元数据验证:字节码描述的信息是否符合Java语言规范。

    • 这个类是否有父类。
    • 这个类的父类是否继承了不允许被继承的类。
    • 如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法。
    • …
  3. 字节码验证:通过数据流和控制流分析,确定程序语义是合法、符合逻辑的。

    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现:在操作栈放置了int类型的数据,使用时按long类型加载入本地变量表中。
    • 保证跳转指令不会跳转到方法体以外的字节码指令上。
    • 保证类型转换是有效的。
    • …
  4. 符号引用验证:对类自身以外(常量池中各种符号引用)的信息进行匹配性校验。

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

2.3.3 准备

正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

注意:这时进行内存分配的仅包括类变量(static修饰),不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中;

注意:初始值通常是数据类型的零值:对于:public static int value = 123;,那么变量value在准备阶段过后的初始值为0而不是123,这时候尚未开始执行任何java方法,把value赋值为123的动作将在初始化阶段才会被执行。

注意有final的情况:对于:public static final int value = 123;编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

基本数据类型的零值:

2.3.4 解析

虚拟机将常量池内的符号引用替换为直接引用的过程。(如果不理解这句话的意思,可以参考R大的答案:https://www.zhihu.com/question/30300585)

  • 符号引用:上面我们介绍过符号引用,这里再重申一遍,以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存和布局无关,引用的目标并不一定已经加载到内存中了。

  • 直接引用:直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局有关,引用的目标必定已经在内存中了。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。分别对应常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_InvokeDynamic_info。(先只说前4种)

下面假设我们要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用。

  • 类或接口的解析:假设当前代码所处的类为D,D对C的引用触发的C的类加载,那么有3个步骤:

    • 如果目标类C不是数组类型,虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。

    • 如果目标类C是一个数组类型,并且数组的元素类型为对象,N的描述符会是类似”[Ljava/lang/Integer”的形式,将会按照第1点的规则加载数组元素类型。即需要加载的元素类型是”java.lang.Integer”,接着由虚拟机生成一个代表此数组维度和元素的数组对象。

    • 如果上面没有异常,解析完成前会进行符号引用验证,确认D是否具备C的访问权限(public,private,protected这些)。如果没有权限,将抛出java.lang.IllegalAccessError异常。

  • 字段解析

    • 首先将字段表内class_index中索引的CONSTANT_Class_info符号引用解析(就是字段所属的类或接口的符号引用)。如果解析完成,会有以下步骤:
    • 如果C已经包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个引用。
    • 否则,如果C实现了接口,将会按继承关系从下往上递归搜索各个接口和它的父接口,如果找到则返回。
    • 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果找到则返回。
    • 否则,查找失败,抛出”java.lang.NoSuchFieldError”异常。
    • 如果成功返回了引用,将会对字段进行权限认证。如果发现没有权限抛出”java.lang.IllegalAccessError”异常。
  • 类方法解析

    • 先解析出类方法表的class_index项中索引的方法所属的类或接口的符号引用,解析成功后,有以下几个步骤:

    • 类方法和接口方法符号引用的常量类型定义是分开的,如果方法表中发现class_index中索引的C是个接口,直接抛出”java.lang.IncompatibleClassChangeError”异常。

    • 在类C查找是否有简单名称和描述符与目标匹配的方法,如果有则返回。

    • 否则,在类C的父类中递归查找是否有与目标匹配的方法,如有有则返回。

    • 否则,在类C实现的接口列表及它们的父接口中递归查找是否有与目标匹配的方法,如果有说明C是抽象类,查找结束,抛出”java.lang.AbstractMethodError”异常。

    • 否则,方法查找失败,抛出”java.lang.NoSuchMethodError”异常。

    • 如果查找成功,返回直接引用。会对这个方法进行权限认证,如果没有权限,抛出”java.lang.IllegalAccessError”异常。

  • 接口方法解析

    • 同样,解析接口方法表class_index中索引对方法所属对类或接口的符号引用,如果解析成功,会执行以下步骤:

    • 如果接口方法表中发现class_index中的索引C是个类,直接抛出”java.lang.IncompatibleClassChangeError”异常。

    • 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用。

    • 否则,在接口C的父接口中递归查找,直到java.lang.Object类,查找是否有简单名称和描述符与目标相匹配的方法,如果有则返回。

    • 否则,方法查找失败,抛出”java.lang.NoSuchMehtodError”异常。

    • 接口所有方法默认都是public,没有访问权限问题。

2.3.5 初始化

这个阶段才真正开始执行类中定义的Java程序代码。(字节码)

准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序的主观计划初始化类变量和其他资源。或者说,初始化阶段是执行类构造器<clinit>()方法的过程。

对于类构造器<clinit>(),有如下几个要点:

  • 类构造器<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句快可以赋值,但是不能访问。(否则提示非法向前引用)
  • 类构造器<clinit>()方法与类的构造函数(实例构造函数<init>()方法)不同,它不需要显式调用父类构造,虚拟机会保证在子类<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中的第一个执行的<clinit>()方法的类肯定是java.lang.Object。

  • 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句快要优先于子类的变量赋值操作。

  • <clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句,也没有变量赋值的操作,那么编译器可以不为这个类生成<clinit>()方法。

  • 接口中不能使用静态语句块,但接口与类不太一样的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个进程阻塞。

3 类加载器

虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

对于任何一个类,都需要由加载它的类加载器和这个类来确立其在JVM中的唯一性。也就是说,只有两个类来源于同一个Class文件,并且被同一个类加载器加载,这两个类才相等(这里的相等包括equals方法,isAssignableFrom方法,isInstance方法等判断,包括instanceOf关键字所做出的的对象所属判断)。否则,即便是两个同名的类,甚至是来自一个class文件的类,不同的加载器加载,他们也不会是同一个类。

3.1 类加载器种类

从虚拟机的角度来说,只存在两种不同的类加载器:

  • 一种是启动类加载器(Bootstrap ClassLoader),该类加载器使用C++语言实现,属于虚拟机自身的一部分。
  • 另外一种就是所有其它的类加载器,这些类加载器是由Java语言实现,独立于JVM外部,并且全部继承自抽象类java.lang.ClassLoader。

从Java开发人员的角度来看,类加载器会分的更加细致,大部分Java程序一般会使用到以下三种系统提供的类加载器:

  1. 启动类加载器(Bootstrap ClassLoader):负责加载JAVA_HOME\lib目录中并且能被虚拟机识别的类库到JVM内存中(如rt.jar),如果名称不符合的类库即使放在lib目录中也不会被加载。该类加载器无法被Java程序直接引用。

  2. 扩展类加载器(Extension ClassLoader):该加载器主要是负责加载JAVA_HOME\lib\ext目录中,或者被 java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器;

  3. 应用程序类加载器(Application ClassLoader):这个类加载器由 sun.misc.Launcher$App-ClassLoader 实现。getSystemClassLoader() 方法返回的就是这个类加载器,因此也被称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库。开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

3.2 双亲委派模型

我们的应用程序都是由上述这3种类加载器互相配合进行加载的,在必要时还可以自己定义类加载器。它们的关系如下图所示:

上图中所呈现出的这种层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型要求除了顶层的启动类加载器以外,其余的类加载器都应当有自己的父类加载器。

双亲委派模型的工作过程是这样的:

  • 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。
  • 每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中。
  • 只有当父类加载器反馈自己无法完成这个类加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

这样做的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如 java.lang.Object,它放在 rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型顶端的启动类加载器来加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。
相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基本的行为也就无法保证了。

双亲委派模型对于保证 Java 程序运行的稳定性很重要,但它的实现很简单,实现双亲委派模型的代码都集中在 java.lang.ClassLoader 的 loadClass() 方法中,逻辑很清晰:

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
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
// 首先,检查请求的类是不是已经被加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {//若没有则调用父类加载器的loadClass()方法
c = parent.loadClass(name, false);
} else {//若父加载器为空则默认使用启动类加载器作为父加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类抛出ClassNotFoundException说明父类加载器无法完成加载
}

if (c == null) {
// 如果父类加载器无法加载,则调用自己的findClass方法来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}

JAVA对象的创建和内存分配策略

Posted on 2019-11-25 | In JAVA , JAVA JVM |
Words count in article: 4.7k | Reading time ≈ 16

1 对象的创建

1.1 对象的创建

虚拟机遇到new指令:

  1. 检查指令的参数是否可以在常量池中定位到一个类的符号引用,且检查这个符号引用代表的类是否已被加载。如果没有执行类加载过程。

  2. 为生成的对象分配内存

    • 对象的大小在类加载后已被确定。
    • 目前主流的是两种分配方式:指针碰撞和空闲列表。具体哪种方式由堆采用的GC是否带有压缩整理功能决定。
      • 指针碰撞:已分配空间和未分配空间规整时,中间放置一个指针表示分界点,指针移动即为分配空间。使用serial/parnew等带有压缩compact过程(也就是标记整理算法中的整理过程)的收集器时,系统的分配算法是指针碰撞。
      • 空闲列表:空闲列表:如果内存不规则,已分配和未分配空间犬牙交错,虚拟机必须维护一个列表,记录哪些内存块可用。使用CMS这种标记清除算法的收集器时,系统的分配算法通常用空闲列表。
      • 内存分配会产生并发问题,具体详见 1.2 内存分配并发问题
  3. 将分配到的内存空间都初始化为零值(不代表就为0)(不包括对象头),如果使用TLAB,这一工作也可以提前至TLAB分配时进行。

  4. 填充对象的对象头,具体详见1.3 对象的内存布局

  5. init方法还没执行,所有字段还都为零值,执行init方法,将字段初始化。

1.2 内存分配并发问题

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS: CAS是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
  • TLAB:为每一个线程预先分配一块内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配。设置-XX:UseTLAB参数会开启TLAB。默认是开启的。

1.2.1 TLAB

JVM在内存新生代Eden Space中开辟了一小块线程私有的区域,称作TLAB,全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。

如果设置了虚拟机参数-XX:UseTLAB ,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,也可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

TLAB的本质其实是三个指针管理的区域:start,top和end,每个线程都会从Eden分配一块空间,例如说100KB,作为自己的TLAB,其中start和end是占位用的,标识出eden里被这个TLAB所管理的区域,卡住eden里的一块空间不让其它线程来这里分配。

TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。从这一点看,它被翻译为线程私有分配区更为合理一点

当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB,而在老TLAB里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从TLAB分配出来的,而只关心自己是在eden里分配的。

不过TLAB也有自己的缺点。因为TLAB通常很小,所以放不下大对象:

  1. TLAB空间大小是固定的,但是这时候一个大对象,我TLAB剩余的空间已经容不下它了。(比如100kb的TLAB,来了个110KB的对象)
  2. TLAB空间还剩一点点没有用到,有点舍不得。(比如100kb的TLAB,装了80KB,又来了个30KB的对象)

故而开发人员对于大对象的创建做了优化,最终的分配流程如下:

  1. 编译器通过逃逸分析,确定对象是在栈上分配还是在堆上分配。如果是在堆上分配,则进入选项2.
  2. 如果top + size <= end,说明TLAB还放得下,则在在TLAB上直接分配对象并增加top的值,如果现有的TLAB不足以存放当前对象则进入3.
  3. 重新申请一个TLAB,并再次尝试存放当前对象。如果放不下,则4.
  4. 在Eden区加锁(这个区是多线程共享的),如果eden_top + size <= eden_end,说明Eden区放得下,则将对象存放在Eden区,增加eden_top的值,如果Eden区不足以存放,则5.
  5. 执行一次Young GC(minor collection)。
  6. 经过Young GC之后,如果Eden区仍然不足以存放当前对象,则直接分配到老年代。

1.3 对象的内存布局

hotspot设计了一个OOP-Klass Model,这里的OOP指的是Ordinary Object Pointer(普通对象指针),它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象。而Klass则包含元数据和方法信息,用来描述Java类。

    1. Klass : Klass简单的说是Java类在HotSpot中的c++对等体,用来描述Java类。那Klass是什么时候创建的呢?一般jvm在加载class文件时,会在方法区创建instanceKlass,表示其元数据,包括常量池、字段、方法等。
    1. OOP: Klass是在class文件在加载过程中创建的,OOP则是在Java程序运行过程中new对象时创建的。一个OOP对象包含以下几个部分:

      • 2.1 instanceOopDesc,也叫对象头

        • Mark Word,主要存储对象运行时记录信息,如hashcode, GC分代年龄,锁状态标志,线程ID,时间戳等。这些字段并不是固定的,而是不断变化的,对象在不同的阶段,mark word的值不一样。 在64位的虚拟机上标记字段一般是8个字节,类型指针也是8个字节,总共就是16个字节. 可以使用-XX:UseCompressedOops来开启压缩指针, 以减少对象的内存使用量, 默认是开启的

        • 元数据指针,即指向方法区的instanceKlass实例

        • 如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据。因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中无法确定数组的大小。

      • 2.2 实例数据

        • 实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在 Java 源码中定义顺序的影响。
        • 各字段的分配策略为longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers),相同宽度的字段总是被分配到一起,便于之后取数据。父类定义的变量会出现在子类定义的变量的前面
      • 2.3 对齐填充。仅仅起到占位符的作用,并非必须。

        • 对齐填充是最常见的优化手段,CPU一次寻址一般是2的倍数,所以一般会按照2的倍数来对齐提高CPU效率.这个似乎没什么好讲的。此外,JVM上对齐填充也方便gc, JVM能直接计算出对象的大小, 就能快速定位到对象的起始终止地址.
- ![](https://oscimg.oschina.net/oscnet/e6d2c492581b2287ec2f0720ebd999c3b47.png)

1.4 压缩指针

为了减少对象内存的使用,64位JVM引入了压缩指针的概念(虚拟机选项-XX:+UseCompressedOops,默认开启),将堆中的64位指针压缩成32位,这样以来,对象头占用的内存就从16字节下降到了12字节。

那么压缩指针是什么原理呢?

打个比方,路上停着的全是房车,而且每辆房车恰好占据两个停车位。现在,我们按照顺序给它们编号。也就是说,停在0号和1号停车位上的叫0号车,停在2号和3号停车位上的叫1号车,依次类推。

原本的内存寻址用的是车位号。比如说我有一个值为6的指针,代表第6个车位,那么沿着这个指针可以找到3号车。现在我们规定指针里存的值是车号,比如3指代3号车。当需要查找3号车时,我便可以将该指针的值乘以2,再沿着6号车位找到3号车。

这样一来,32位压缩指针最多可以表示2的32次方辆车,对应着2的33次方个车位。当然,房车也有大小之分。大房车占据的车位可能是三个甚至是更多。不过这并不会影响我们的寻址算法:我们只需跳过部分车号,便可以保持原本车号*2的寻址系统。

上述模型有一个前提,你应该已经想到了,就是每辆车都从偶数号车位停起。这个概念我们称之为内存对齐(对应虚拟机选项 -XX:ObjectAlignmentInBytes,默认值为8)。

1.5 对象大小

JVM的数据类型分为基本数据类型和引用数据类型。基本数据类型有:

1
2
3
4
long/double: 8字节, 长整型和双精度浮点型
int/float: 4字节, 整数和浮点数
char,short: 2字节,字符型和短整型
byte: 1字节, 整数

基本数据类型没啥好说的,这里我们有必要讲一下引用(reference),引用的实现主要有两种:

  1. 句柄访问:

    • 在堆中分配一块句柄池,reference中存的就是句柄地址,而句柄中包括了实例对象和类型对象的地址,如图:
  2. 直接指针:

    • reference中存的直接就是对象地址:

二者之间,我们可以看到,句柄方式,类型数据得到了安置,而直接指针,则需要额外安排类型数据的放置。

HotSpot虚拟机使用的是直接指针,至于对类型数据的安排,前文我们也说过了,类型指针在对象头里。

1.5.1 对象大小的计算

  • 在JDK8, 64位HotSpot上, 引用数据类型都是直接指针, 如果开了压缩指针,就是4字节,没开就是8字节。
  • 对象头在64位的虚拟机上开了压缩指针就是12字节,没开就是16字节。
  • 实例数据的大小依据数据类型的大小来计算, 注意要子类的对象大小要把父类的实例数据大小也计算进去。
  • 对齐填充是按照对象里最宽的数据类型的大小来对齐的, 比如最大的是long 8字节, 那么就是按照8的倍数来对齐。

接下来我们如果有这么一个对象

1
2
3
4
5
6
public class ObjectByteTest {

private double a;
private int b;
private String c;
}

按照理论,开启压缩指针后,对象头占12字节, 实例数据最长的double是8个字节, int是4字节, String是引用类型,占4字节, 按照8字节对齐。

总共是12+8+4+4=28字节,按照8字节对齐是32字节,要4个字节的对齐填充。

故而得到,Instance size=32字节。

1.5.2 字段重排

其实上面的对对象大小的计算,是jvm对对象重排之后的结果,对象重排,目的为了减少填充,节约空间,过程不多说,一张图就足以看懂:

对于对象:

1
2
3
4
5
6
7
8
9

class A {
long l;
int i;
}
class B extends A {
long l;
int i;
}

字段重排列前后如图:

可以看到通过字段重排列,节省了空间。

2 对象的内存的分配

对象的内存分配,就是在堆上分配(如果经过JIT编译器逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配),对象主要分配在eden区,少数情况下也可能直接分配至老年代中,分配的规则视当前使用的垃圾收集器组合和内存参数规则决定。

2.1 对象优先在eden分配

对象在绝大多数情况下,在新生代eden区分配,当eden区没有足够空间进行分配的时候,JVM会发起一次Minor GC。

相关内存参数如下:

  • -Xms:最小堆内存值
  • -Xmx:最大堆内存值
  • -Xmn:新生代内存值
  • -XX:SurvivorRatio:新生代中eden区与一个survivor区的空间比

比如,设置的参数是-Xms20M、-Xmx20M、-Xmn10M、-XX:SurvivorRatio=8,可得知,最小堆和最大堆内存一致,即堆内存固定为20MB,新生代为10MB,而老年代=堆内存-新生代,得知老年代为10MB,eden区与survivor区的比例是8:1,eden区=新生代 * SurvivorRatio / 10,eden区的大小为8MB,survivor区为2MB,s0和s1区都为1MB,那么新生代的总可用空间为9MB(eden区 + 1个survivor区)。

2.2 大对象直接进入老年代

大对象,即需要大量连续内存空间的对象。经常出现大对象就容易导致内存还有不少空间时就提前触发了GC,以便获取更大的连续空间来分配。大对象对虚拟机来说是个坏消息,更坏的消息是那些“朝生夕死”的大对象。

虚拟机提供了一个参数-XX:PretenureSizeThreshold,大于此设置值的对象将直接进入老年代分配内存,这样做的目的是避免在eden区和两个survivor区之间发生大量的内存复制(因为新生代采用复制算法收集)。

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

与大对象相对应,小对象在GC过程中通常不会因为内存空间不够分配而直接进入老年代。为了确定哪些是“稳定”的对象(应该放入老年代),哪些是“朝生夕死”的对象(不应该进入老年代),jvm通过给每个对象定义一个对象年龄计数器的方式定义对象的年龄。对象在eden区出生,经过第一次Minor GC后仍然能存活,并且能被survivor区容纳,将被移动到survivor区中,并且对象的年龄设为1。对象在survivor区每经过一次Minor GC,对象的年龄就加1岁,当它的年龄增加到一定程度时(默认为15岁),就会晋升到老年代中去。

对象晋升老年代的年龄阈值,可通过参数-XX:MaxTenuringThreshold调整。

2.4 动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远的要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,而是有个机智的策略:如果在survivor区中处于某个年龄的对象内存总和大于survivor区内存的一半,那么年龄大于或等于该年龄的对象就可以直接进入老年代,无须达到MaxTenuringThreshold中要求的年龄。

2.5 空间分配担保

  • 在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的空间,
    • 如果条件满足,那么Minor GC就是安全的,
    • 否则继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
      • 如果大于,则“尝试”进行一次Minor GC
      • 如果小于,则要进行Full GC。

为什么是尝试进行Minor GC呢?因为新生代采用复制收集算法,只使用其中一个survivor空间来作为轮换备份,因此出现大量对象在Minor GC后仍然存活的情况下(最极端的就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把survivor区无法容纳的对象直接移至老年代。

老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的空间,然而一共会有多少对象存活下来,在实际完成内存回收的过程中是无法明确知晓的,所以只好取之前每一次回收晋升到老年代的对象容量的平均大小值作为参考值,与老年代的剩余空间比较,来决定是否进行Full GC来让老年代腾出更多空间。

2.6 总结

  1. 对象优先在eden区分配内存,如果eden没有足够的空间,则会触发Minor GC,清理空间
  2. 对象达到了MaxTenuringThreshold设定的年龄,或survivor区中相同年龄的所有对象大小的总和大于survivor区空间的一半时,年龄大于或等于该年龄的对象,就可以直接进入老年代
  3. 新生代对象的总大小或者历次晋升的平均大小大于老年代的连续空间时,就会进行Full GC,反之进行Minor GC

2.7 触发Full GC的方式:

  1. Perm(永久代)空间不足;
  2. CMS GC时出现promotion failed和concurrent mode failure(concurrent mode failure发生的原因一般是CMS正在进行,但是由于老年代空间不足,需要尽快回收老年代里面的不再被使用的对象,这时停止所有的线程,同时终止CMS,直接进行Serial Old GC);
  3. 统计得到的Minor GC晋升到老年代的平均大小大于老年代的剩余空间;
  4. 主动触发Full GC(执行jmap -histo:live [pid])来避免碎片问题。

参考资料

java 对象a内存布局和大小计算

JAVA垃圾回收器

Posted on 2019-11-21 | In JAVA , JAVA JVM |
Words count in article: 16.7k | Reading time ≈ 58

前言

java内存运行时区域的各个部分,其中的程序计数器,虚拟机栈,本地方法栈三个区域是随线程而生、随线程而亡的;

栈中的栈帧是随着方法的进入和退出而执行入栈和出栈的。每个栈帧中分配的内存在类结构确定下来时就是已知的,因此这几个区域的内存分配和回收都是确定的,方法结束和线程结束时,内存自然就回收了。

而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序运行期间才能知道要创建哪些对象,这部分内存分配和回收是动态的,也就是说这部分内存的回收是要干预的。后续我们参与讨论的“内存”分配与回收也仅指这一部分内存

1.判断对象死亡与否

java堆里存放着几乎所有的对象实例,在进行GC前,我们必须要弄清楚那些对象还活着(即不可被回收),哪些对象已经死了(可以被回收了)。

我们有如下的方法来判断对象存活与否;

1.1 引用计数法

给对象添加一个引用计数器,每当有一个地方引用它时,计数值就加1;当引用失效时计数器值就减1;任何时刻当一个对象的计数器值为0时就是不再被使用的,即就是要被回收的。

这种算法实现简单,判定效率也很高,在多数情况下它是一个不错的算法,但是在java语言中没有选取这种方法来管理内存,因为它无法解决对象之间互相循环引用的问题:

  • 比如对象A和对象B都有字段instance
  • 令A.instance = B及B.instance = A,除此之外这两个对象再无其他任何引用。

实际上这两个对象是要被回收的对象,但是他们之间存在着互相引用,导致计数器的值不为0,引用计数算法就不能回收他们(回收条件计数器值为0)。

1.2 可达性分析算法

主流的商用程序语言(JAVA/C#等)的主流实现都是通过可达性分析来判定对象是否存活。

通过“GC Roots”的对象作为起始点,从这个起始点向下搜索,搜索所走过的路径成为引用链,当一个对象没有与任何引用链相连(即从GC Roots不可达),此时说明这个对象是不可用的。

如下图,obj5,obj6,obj7虽然相互有关联,但是他们到GC Root是不可达的,会被判定为可回收的对象。

在java语言中,可作为GC Roots的对象包含以下几种:

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

2. 引用

传统的引用的定义(如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,那它就是引用)无法满足gc的需要。

在实际中我们希望能有这样的对象:当内存空间足够时保存在内存中,当内存紧张时,则可以抛弃这些对象。

故而在JDK1.2之后,java将引用的概念进行了扩充,将引用分为:强引用,软引用,弱引用,虚引用四种,这四种的引用强度依次逐渐减弱。

  • 强引用在代码中普遍存在,如Object obj = new Object() 这样的引用就是强引用,只要这个对象的引用还存在,垃圾回收器就永远不会回收它。(在通常对静态属性赋值时一定要各位注意,它的生命周期会贯穿整个app的生命周期)

  • 软引用用来描述一些还有用,但是并非必须的对象,正常gc时不会回收它,只有在系统即将发生内存溢出之前,会将这些对象进行回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。

  • 弱引用是用来描述非必须对象的,他比软引用更更弱一些,被弱引用关联的对象只能生存到下一次垃圾回收收集之前。当GC时无论当前内存是否足够,都会回收掉只被弱引用关联的对象(注意是只被弱引用关联的对象,如果一个对象即被强引用引用也被弱引用引用,GC时是不会回收的)

  • 虚引用也称为幽灵引用或幻影引用,它是最弱的一种引用关系。一个对象是否有有虚引用,完全不会影响它的生存周期周期,也无法通过一个虚引用获得一个对象。为一个对象设置为虚引用关联的唯一目的就是希望能在这个对象被回收时收到一个系统通知。

3. finalize方法

即使是被可达性分析算法不可达的对象,也不是非死不可,这时候它处于“缓刑”状态,finalize()是它完成自救的最后机会

finalize()是Object中的方法,当垃圾回收器将要回收对象所占内存之前被调用,其过程为:

  • 某个对象被判断为不可达,被第一次标记。判断该对象是否有必要执行finalize()
    • 如果对象没有重写该方法,或者该方法已经被虚拟机调用过(所以finalize()最多只能执行一次),它将第二次被标记,基本上在劫难逃了。
    • 否则,则会将该对象放置在一个叫做F-Queue的队列中,并在稍后由一条虚拟机自动建立的,低优先级的Finalizer线程去执行(调用finalize()方法),但并不承诺会等待它运行结束
      • finalize()方法是对象逃离死亡的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象在finalize()中成功拯救了自己(重新与引用链建立关联),那在第二次标记时它将被移除出即将回收的集合;
      • 否则,第二次标记没躲掉,它基本上也在劫难逃了。

不承诺执行完finalize()的原因是:如果一个对象的finalize()方法执行缓慢或者发生死循环等极端情况,将会导致F-Queue队列永久处于等待状态,甚至导致整个GC系统的崩溃。

在java中不建议使用finalize方法。

4. 方法区的回收

很多人认为方法区(或者HotSpot虚拟机中的永久代/元空间)是没有垃圾回收的,java虚拟机规范确实说过不要求虚拟机在方法区实现垃圾回收,而且在方法区进行垃圾回收的性价比比较低:在堆中,尤其是在新生代中,常规的应用进行一次垃圾回收一般能回收70%~95%的空间,而永久代的垃圾回收效率远低于此。

其实永久代的垃圾回收主要回收两部分内容

  • 废弃常量
    • 回收废弃常量与回收java堆中的对象非常类似,比如常量池字面量‘abc’,如果此时没有一个String对象值为‘abc’,即没有任何String对象引用‘abc’常量,那么发生gc时,其将被清出常量池。常量池中的其他类、接口、方法、字段的符号引用也类似。
  • 无用的类
    • 相比判断废弃常量,判断无用的类条件比较苛刻,需要同时满足以下三个条件。
      • 该对象的所有实例都已经被回收,也就是堆中不存在该类的任何实例。
      • 加载该类的ClassLoader也已经被回收
      • 该类对应的java.lang.Class对象没有在任何地方被引用,也无法在任何地方通过反射来访问该类的方法
    • 虚拟机可以对这样的无用的类进行回收,但也局限于可以,而不是必然。Hotspot虚拟机提供了-Xnoclass参数进行控制。

4. 垃圾回收算法

4.1 标记-清除算法

(Mark-sweep)这是最基础的垃圾回收算法,顾名思义,分为标记和清除两个阶段。它这里的标记就是指介绍finalize方法时提到的第二次标记。

首先标记出所有需要回收的对象,在标记完成后统一回收掉被标记的对象。它主要有两个缺点:一个是效率问题,标记和清理过程效率都不高;另一个问题是空间问题,在清除后会产生大量不连续的内存碎片,当空间碎片太多时会导致,当程序以后运行需要分配较大对象时无法找到足够的连续内存而不得不提前触发下一次GC动作。

4.2 复制算法

为了解决效率问题,复制算法应运而生。

它将内存按容量划分为大小相等的两块,每次使用其中的一块。当这一块用完时就将还存活的对象复制到令一块上,然后将已使用过的这一块内存清理掉。这样分配时就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行效率高。

但是缺点也是显而易见的:内存利用率只有一半。

4.2.1 新生代的回收

现在的商业虚拟机都采用这种算法来收集新生代,IBM的专门研究表明,新生代的对象98%都是朝生夕死的,所以并不需要按1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块SuSurvivor。

回收时,将Eden和刚才刚才用过的Survivor的空间中还活着的对象一次拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才刚才使用过的Survivor的空间。

虚拟机默认Eden区和Survivor区的大小比例是8:1,也就是每次新生代中可用空间为整个新生代容量的90%。

当然我们没办法保证每次回收时,都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他的内存(这里指老年代)进行分配担保。(稍后详解)

4.3 标记-整理算法

如果内存中对象的存活率比较高的话,那么复制算法需要执行较多的复制操作,效率会变低,更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了“标记–整理”算法,标记过程仍与“标记–清除”算法一样,但是后续步骤不是直接对可回收的对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

4.4 分代收集算法

当前商业虚拟机的垃圾回收都是采用的“分代收集”算法,根据对象的存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的垃圾回收算法。在新生代中,每次垃圾每次垃圾回收都发现大批对象死去,只有少量存活,那就使用复制算法。而老年代中因为对象存活率较高,没有额外的空间对它进行分配担保,就必须使用“标记–清理‘’或者‘标记–整理’‘算法来进行回收。

5 HotSpot虚拟机的算法实现

上述的这些对象存活判断算法和垃圾收集算法,在hotspot虚拟机上,会为了达到更高的效率,而做一些优化或者变动。这些优化有:

5.1 枚举根节点

可达性分析算法目前有两个主要的局限:

  • 可达性分析需要从GC root节点开始寻找引用链,而GC root主要在全局性的引用(常量和静态变量)和执行的上下文(栈帧的本地变量)中,这类数据日臻庞大,如果要逐个检查,那么必然消耗很多时间。

  • 可达性分析需要等待GC停顿,即一个整个系统类似被冻结的时间节点(停顿所有执行线程),因为可达性分析无法在引用关系还在不断变化的情况下准确分析。

目前主流的java虚拟机都采用准确式GC,所以当gc停顿后,并不需要一个不漏的检查所有上下文和全局的引用。在HotSpot的实现中,有一组称为OopMap的数据结构,类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知哪些地方存放着对象引用。

准确式内存管理:即虚拟机可以知道内存中某个位置的数据具体是什么类型。譬如内存中有一个32位的整数123456,它到底是一个reference类型指向的123456的内存地址还是一个数值为123456的整数,虚拟机将有能力分辨出来,这样才能在GC的时候准确判断对上的数据是否还可能被使用。

保守式GC,半保守式GC和准确式GC,以及OopMap,推荐可以拓展看该篇文章JVM中的OopMap

5.2 安全点Safe Point

有了OopMap,HotSpot可以快速准确完成GC Roots枚举。但是另一个问题来了,我们要在什么地方创建OopMap?程序运行期间,引用的变化在不断发生,如果每一条指令都生成OopMap,那占用空间就太大了。为了解决这个问题,我们引入了安全点(Safe Point)—— 只在安全点进行GC停顿,只要保证引用变化的记录完成于GC停顿之前就可以。

可以理解为OopMap表示的是一个班级的座位表,上面记录每个同学都在xx行xx列,分别是男是女。假设班上的同学一直在不停的变换位置,如果我们每一次变换都要创建一张新的座位表,那太繁琐,占用空间也多。

为了解决这个问题,我们加入了一个暂停(安全点)的概念,即某个时刻,所有同学的移动停止,我们只会在暂停的时候发生gc,那么也只需要在每次暂停之前生成座位表即可。因为座位表是给gc用的,gc又只会发生在安全点,所以这样是可行的。

安全点选定太少,GC等待时间就太长,选的太多,GC就过于频繁。选定原则是“具有让程序长时间执行的特征”,也就是在这个时刻现有的指令是可以复用的。一般选在方法调用、循环跳转、抛出异常的位置。

现在的问题是在Safe Point让线程们以怎样的机制中断,方案有两种:抢先式中断、主动式中断。

  • 抢先式中断:GC发生时,中断所有线程,如果发现有线程不再安全点上,就恢复线程让它运行到安全点上。现在几乎不用这种方案。
  • 主动式中断:当发生GC时,设置一个标记,所有线程在到达各自的Safe point时判断这个标记,如果中断标记为真就自己中断挂起。HotSpot使用主动式中断。

5.3 安全区域safe region

貌似引入安全点,再加上OopMap,就可以完美解决GC的性能问题了,但实际上,我们还考虑漏了一种情况:即有些程序此时处于无法响应jvm中断请求的状态(比如线程sleep或者block),这样程序不会走到安全点了。

类比的话就是移动过程中,有些同学睡着了,听不到暂停的指令(中断请求),他可能睡醒后回过神来,自顾自的去下一个座位,全然不顾班上其他同学已经暂停了。

为了解决这个问题,HotSpot还引入了安全区域的概念。

安全区域是指在一段代码片段中,引用关系不会发生变化,在该区域的任何地方发生GC都是安全的。当代码执行到安全区域时,首先标识自己已经进入了安全区域,那样如果在这段时间里JVM发起GC,就不用管标示自己在安全区域的那些线程了,在线程离开安全区域时,会检查系统是否正在执行GC,如果是,就等到GC完成后再离开安全区域。

类比为:所有睡着的同学,你们睡着可以,但是要给自己做个标记,这样我暂停排座位表(OopMap)的时候,我就忽略你们了。但是为了防止你们在暂停的时候突然醒来,然后后知后觉的到处乱闯,我只好跟你们约法三章:睡醒的时候,问一下周围现在是不是在暂停中,如果是的话,你们就不要动弹,等暂停结束了再走。

6 垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

以下是HotSpot虚拟机中的7中作用于不同分代的垃圾收集器,连线表示垃圾收集器可以配合使用。

现在来说,目前并不存在一个万能的收集器,具体应用或者具体场景,都有不同的适用的收集器。

关于收集器,我们常会用到并行与并发来做描述,他们的区别是:
并行(parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
并发(concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户线程在继续运行,而垃圾收集程序运行在另一个cpu上。

目前,HotSpot的新生代收集器,都是使用复制算法,老年的收集器,都是采用标记-整理算法(CMS除外)。同代的不同收集器之间的区别,一般在是否并行,并发等回收策略上。带有serial的,顾名思义,顺序执行的意思,是单线程的。带有parallel的,就是多线程并行的。带concurrent的就是并发。

6.1 serial收集器

该收集器是最基本,最悠久的收集器,曾经在jdk1.3.1之前是虚拟机新生代收集的唯一选择。

它是单线程的收集器,不仅意味着只会使用一个线程进行垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停所有其他工作线程,往往造成过长的等待时间。

serial收集器的新生代(serial收集器)采用复制算法,老年代(serial old收集器,后面会讲)采取标记整理算法。

虽然经过长久的发展,为了减少停顿,开发团队设计和实现了许多更优秀更复杂的收集器,但不意味着serial老而无用。它目前仍然是虚拟机运行在client模式下的默认新生代收集器。它的优点是简单高效,对于单个CPU环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。

在Client应用场景中,分配给虚拟机管理的内存一般来说不会很大,该收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。

6.2 ParNew收集器

它是Serial收集器的多线程版本。除了使用多线程进行垃圾收集之外,其余行为包括serial收集器可用的控制参数、收集算法、stop the world、对象分配规则、回收策略等都和serial收集器完全一样。实际上二者也共用了相当多的代码。

虽然并无太多创新之处,但它是许多运行在Server模式下的虚拟机首选的新生代收集器,除了性能原因外,主要是因为除了serial收集器,只有它能与CMS收集器配合工作。

默认开始的gc线程数量与CPU数量相同,可以使用-XX:+ParallelGCThreads参数来设置线程数。

CMS收集器在jdk1.5中横空出世,其并发收集的特性具有划时代意义,但它作为老年代收集器,却只能和parnew和serial配合工作。parnew因为性能原因,是在使用cms时默认的新生代收集器。

6.3 parallel scavenge收集器

新生代收集器,复制算法,并行的多线程收集器。看起来和parnew收集器类似,但它的特点是它的关注点和其他收集器不同。CMS等收集器的关注点是尽可能的缩短垃圾收集时的停顿时间,而parallel scavenge收集器的目的是达到一个可控制的吞吐量。它被称为“吞吐量优先”收集器。这里的吞吐量指CPU用于运行用户代码的时间占总时间的比值。(比如虚拟机运行了100分钟,垃圾回收花掉1分钟,则吞吐量是99%)

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。
而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

parallel scavenge提供了下列参数用于精确控制吞吐量:

  • 控制最大垃圾收集停顿时间 -XX:+MaxGCPauseMillis参数,值为大于0的毫秒数,收集器尽可能保证内存回收花费的时间不超过该值。
  • 直接设置吞吐量大小的 -XX:+GCTimeRatio参数,值为大于0且小于100的整数,它的值是吞吐量的倒数。
  • 开关参数 -XX:+UseAdaptiveSizePolicy。打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden和Survivor区的比例(-XX:+SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为GC自适应的调节策略(GC Ergonomics)。

Parallel scavenge收集器与ParNew收集器的重要区别就是这个GC Ergonomics,垃圾自适应调节策略。

不要以为把-XX:+MaxGCPauseMillis设置得小一些就能使垃圾回收更快,GC停顿时间缩短是牺牲吞吐量和新生代空间换来的。不说别的,调小这个值,一般会导致gc触发更加频繁,吞吐量反而下降。

6.4 Serial Old收集器

老年代收集器,串行的单线程收集器,使用标记整理算法。是serial收集器的老年代版本。

Serial Old是Serial收集器的老年代版本,是个单线程收集器,也是给Client模式下的虚拟机使用。如果用在Server模式下,它有两大用途:

  • 在JDK 1.5 以及之前版本(Parallel Old诞生以前)中与Parallel Scavenge收集器搭配使用。
  • 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

6.5 Parallel Old收集器

老年代收集器,并行的多线程收集器,标记整理算法。是Parallel Scavenge收集器的老年代版本,吞吐量优先的垃圾回收器。

在注重吞吐量以及CPU资源敏感的场合(服务端应用),都可以优先考虑Parallel Scavenge加Parallel Old收集器。

6.6 CMS收集器(详细介绍)

老年代收集器,并行的多线程收集器,使用标记清除算法。是一种以获取最短回收停顿时间为目标的收集器。目前大部分java应用集中在互联网网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短。

CMS牺牲了系统的吞吐量来追求收集速度,适合追求垃圾收集速度的服务器上。

6.6.1 优缺点

特点:

  • 并发收集

  • 低停顿。这也是CMS采用标记清除而不是标记整理的原因,整理要STW
    缺点:

  • 对CPU资源敏感。因为并发阶段虽然用户线程不停顿,但会占用CPU资源导致用户线程变慢,吞吐量降低。CMS默认启动的回收线程数是 (CPU数量 + 3) / 4。

    • 当CPU>4时,并发线程>25%的CPU资源。且随CPU数量增加而下降。
    • 当CPU<4时(假设为2),并发线程>50%的CPU资源,很影响用户体验。
  • 无法处理浮动垃圾。

    • 浮动垃圾:由于并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留到下一次GC时再清理掉,这一部分垃圾就被称为“浮动垃圾”。

    • 也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此它不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。

    • 可以使用-XX:CMSInitiatingOccupancyFraction的值来改变触发收集器工作的内存占用百分比,JDK 1.5默认设置下该值为68,JDK1.6默认设置下该值为92,也就是当老年代使用了68%/92%的空间之后会触发收集器工作。

    • 如果-XX:CMSInitiatingOccupancyFraction 设置的太高,导致浮动垃圾无法保存,那么就会出现Concurrent Mode Failure,此时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集。
  • 标记-清除算法会导致大量空间碎片,给大对象分配带来很大的麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次Full GC。

    • 为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住要进行Full GC时,开启内存碎片的合并整理过程。内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间却不得不变长。
    • 虚拟机设计者还提供了另外一个参数-XX:+CMSFullGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理)。

6.6.2 触发条件

CMS垃圾收集器的触发条件有以下几个:

  1. 如果没有设置-XX:+UseCMSInitiatingOccupancyOnly,虚拟机会根据收集的数据决定是否触发(建议带上这个参数)。
  2. 老年代使用率达到阈值-XX:+CMSInitiatingOccupancyFraction,默认68%,即当老年代的空间使用率达到68%时,会执行一次CMS回收。前提是配置了第一个参数。
  3. 永久代的使用率达到阈值-XX:+CMSInitiatingPermOccupancyFraction,默认92%,前提是开启-XX:+CMSClassUnloadingEnabled并且配置了第一个参数。
  4. 新生代的晋升担保失败。老年代有足够的空间,但是由于碎片化严重,无法容纳新生代中晋升的对象,发生晋升失败。

6.6.3 收集过程

采用“标记-清理”算法对老年代进行回收,过程可以说很简单,标记出存活对象,清理掉垃圾对象,但是为了实现整个过程的低延迟,实际算法远远没这么简单。

注意,CMS是并行的,因为是并发运行的,在运行期间会发生新生代的对象晋升到老年代、或者是直接在老年代分配对象、或者更新老年代对象的引用关系等等。对于这些对象,CMS都是要感应到的。

如何有效率的感应到呢?CMS将老年代的空间分成大小为512bytes的块,并维护一个叫做card table的数组(每个位置存的是一个byte),card table中的每个元素对应着一个块。并发过程中,如果某个对象的引用发生了变化,就标记该对象所在的块为dirty card。

CMS整个过程分为如下几个部分:

  • 初始标记(CMS-initial-mark)
    • 仅仅只是标记一下老年代中的GC Roots,以及被新生代存活对象引用的老年代对象,假设我们称这步标记的对象为objs,速度很快,需要停顿。
  • 并发标记(CMS-concurrent-mark)
    • 与用户线程同时运行,进行GC Roots Tracing的过程,找到与objs对象可达的更多对象进行标记。它在整个回收过程中耗时最长。
  • 预清理(CMS-concurrent-preclean)
    • 与用户线程同时运行;
    • 扫描所有标记为Dirty的Card,然后将之前没有标记到的存活对象也标记上。
  • 可被终止的预清理(CMS-concurrent-abortable-preclean)
    • 与用户线程同时运行;
    • 循环重复做两件事情,期待中间等来一次young gc,循环直到达到退出条件。
      1. 并发重新标记的工作内容
      2. 预清理的工作内容
  • 并发重新标记(CMS-remark)
    • 需要遍历新生代的全部对象,标记这些对象可达的老年代对象,同时,处理dirty card。目的是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  • 并发清除(CMS-concurrent-sweep)
    • 与用户线程同时运行。清除那些没有标记的对象并且回收空间。
  • 并发重置状态等待下次CMS的触发(CMS-concurrent-reset)
    • 与用户线程同时运行;顾名思义,重置一下状态,表示CMS结束

6.6.3.1 初始标记

这是CMS中两次stop-the-world事件中的第一次。该阶段的工作是:标记存活的对象,主要有两种对象:

  1. 标记老年代中所有的GC Roots对象,如下图节点1;
  2. 标记年轻代中活着的对象(GC Roots可达)引用到的老年代的对象(指的是年轻带中还存活的引用类型对象,引用指向老年代中的对象,因为CMS是老年代收集器)如下图节点2、3;

为了加快此阶段处理速度,减少停顿时间,可以开启初始标记并行化,-XX:+CMSParallelInitialMarkEnabled,同时调大并行标记的线程数,线程数不要超过cpu的核数。

6.6.3.2 并发标记

该阶段的工作是:

  • 从”初始标记”阶段标记的对象开始找出所有存活的对象;
  • 有变更的对象作重新标记

如下图所示:

先从”初始标记”阶段标记的对象开始找出所有存活的对象,即我们从节点1、2、3找到了节点4、5。

但在找到4/5节点的过程中,因为我们是并发执行的,所以可能会有一些引用发生了变更,比如节点3引用了另外一个对象,如下图:

这个时候,jvm会将节点3所在的card标记为dirty,但只做标记,不做处理。

6.6.3.3 预清理阶段

这个阶段就是用来处理前一个阶段因为引用关系改变导致没有标记到的存活对象的,它会扫描所有标记为Dirty的Card,然后将之前没有标记到的存活对象也标记上。

如下图,节点3引用的节点6也被标记上了,标记完成,并且将节点3的card的dirty状态清除。

通过参数-XX:+CMSPrecleaningEnabled可以选择关闭该阶段,默认启用

6.6.3.4 可中断的预清理

该阶段发生的前提是,新生代Eden区的内存使用量大于参数-XX:+CMSScheduleRemarkEdenSizeThreshold设置的值,默认是2M,如果新生代的对象太少,就没有必要执行该阶段,直接执行重新标记阶段。

为什么需要这个阶段,存在的价值是什么?

其实这个阶段,是为了后面即将进行的“并发重新标记”环节能少一些工作量而设置的,“并发重新标记”我们还没讲到,这里简单说下:“并发重新标记”会扫描并且标记整个年老代的所有的存活对象,包括被新生代中的对象引用的老年代对象,即使新生代的对象已经不可达了,也将其引用的老年代对象视为存活

因此,如果进入“并发重新标记”时,新生代的对象有很多,那么一个个检查过去他们是否引用老年代对象的过程也必然很长(所以该阶段触发前提是新生代内存大于-XX:+CMSScheduleRemarkEdenSizeThreshold的值)

为了进入“并发重新标记”阶段时新生代对象尽可能少,“可中断的预清理”阶段会做两件工作:

  1. 处理From和To区的对象,标记可达的老年代对象
  2. 和上一个阶段一样,扫描处理Dirty Card中的对象

然而你会发现:

  • 工作1不就是“并发重新标记”的其中一项工作么,现在做和后续做,有什么区别吗?
  • 工作2与其说是“可中断的预清理”的工作,还不如说cms收集器在并发过程中就一直会在标记这些dirty card,并不是该阶段独有的工作。

如此看来,“可中断的预清理”阶段岂不是形同鸡肋?

其实,该阶段的目的在于:期待在该阶段的过程中,能够迎来一次young gc;

我们知道,新生代的对象大部分朝生暮死,每次young gc都会清理大量的新生代对象,如果在进入“并发重新标记”阶段前能够执行一次young gc,那“并发重新标记”阶段的扫描岂不是会轻松很多?

而且本身“可中断的预清理”阶段的触发前提就是新生代内存使用量超过一定阈值,虽然gc是JVM自动调度的,什么时候进行young gc我们控制不了,但既然能够满足“新生代内存使用量超过一定阈值”的前提,并进入“可中断的预清理”阶段,那么理论上离下一次的young gc应该也不远了。

所以,“可中断的预清理”阶段的核心就是:一直重复 “处理From和To区的对象,标记可达的老年代对象” 和 “扫描处理Dirty Card中的对象” 这两项工作,以期待在期间引来一次young gc

注意,是一直重复上述两项工作,直到:

  • 可以设置最多循环的次数-XX:+CMSMaxAbortablePrecleanLoops,默认是0,意思没有循环次数的限制。
  • 如果执行这个逻辑的时间达到了阈值-XX:+CMSMaxAbortablePrecleanTime,默认是5s,会退出循环。
  • 如果新生代Eden区的内存使用率达到了阈值-XX:+CMSScheduleRemarkEdenPenetration,默认50%,会退出循环。(这个条件能够成立的前提是,在进行Precleaning时,Eden区的使用率小于十分之一)

6.6.3.5 并发重新标记

该阶段并发执行,在之前的并行阶段(GC线程和应用线程同时执行,好比你妈在打扫房间,你还在扔纸屑),可能产生新的引用关系如下:

  1. 老年代的新对象被GC Roots引用
  2. 老年代的未标记对象被新生代对象引用
  3. 老年代已标记的对象增加新引用指向老年代其它对象
  4. 新生代对象指向老年代引用被删除
  5. 也许还有其它情况..
    上述对象中可能有一些已经在Precleaning阶段和AbortablePreclean阶段被处理过,但总存在没来得及处理的,所以还要进行如下的处理:
    1. 遍历新生代对象和老年代对象,并重新标记存活的老年代对象,包括前文所说的新生代对象引用的老年代对象,即便新生代对象不可达了。
    2. 根据GC Roots,重新标记
    3. 遍历老年代的Dirty Card,重新标记,这里的Dirty Card大部分已经在clean阶段处理过,这里处理最近新生成的。

在第一步骤中,需要遍历新生代的全部对象,如果新生代的使用率很高,需要遍历处理的对象也很多,这对于这个阶段的总耗时来说,是个灾难(因为可能大量的对象是暂时存活的,而且这些对象也可能引用大量的老年代对象,造成很多应该回收的老年代对象而没有被回收,遍历递归的次数也增加不少),如果在“可中断的预清理”阶段中能够恰好的发生一次young gc,这样就可以避免扫描无效的对象。

如果在AbortablePreclean阶段没来得及执行一次young gc,怎么办?

CMS算法中提供了一个参数:-XX:+CMSScavengeBeforeRemark,默认并没有开启,如果开启该参数,在执行该阶段之前,会强制触发一次YGC,可以减少新生代对象的遍历时间,回收的也更彻底一点。

不过,这种参数有利有弊,利是降低了Remark阶段的停顿时间,弊的是在新生代对象很少的情况下也多了一次YGC,最可怜的是在AbortablePreclean阶段已经发生了一次YGC,然后在该阶段又傻傻的触发一次。

所以利弊需要把握。

6.6.3.6 并发清理

通过以上5个阶段的标记,老年代所有存活的对象已经被标记并且现在要通过Garbage Collector采用清扫的方式回收那些不能用的对象了。

这个阶段主要是清除那些没有标记的对象并且回收空间;

由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。

6.7 G1收集器

G1 GC,全称Garbage-First Garbage Collector,通过-XX:+UseG1GC参数来启用,作为体验版随着JDK 6u14版本面世,在JDK 7u4版本发行时被正式推出,相信熟悉JVM的同学们都不会对它感到陌生。在JDK 9中,G1被提议设置为默认垃圾收集器(JEP 248)。

G1(Garbage First)垃圾收集器也是以关注延迟为目标、服务器端应用的垃圾收集器,被HotSpot团队寄予取代CMS的使命,也是一个非常具有调优潜力的垃圾收集器。

它是专门针对以下应用场景设计的:

  • 像CMS收集器一样,能与应用程序线程并发执行。
  • 整理空闲空间更快。
  • 需要GC停顿时间更好预测。
  • 不希望牺牲大量的吞吐性能。

它的特点有:

  1. 它的设计原则是:“垃圾优先? 不是,是优先处理那些垃圾多的内存块(Garbage First)”。因此,G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候才开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;

  1. G1采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩);

  1. G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(from/to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换;

  1. G1的收集都是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。

  1. G1整体采用标记-整理算法,局部采用复制算法,不会产生很多内存碎片。

6.7.1 G1的内存模型

6.7.1.1 region分区

G1将新生代,老年代的物理空间划分模糊化了。取而代之的是,G1算法将堆划分为若干个大小相等的内存区域(Region)。

  • 每次分配对象空间将逐段地使用内存。因此,在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;

  • 启动时可以通过参数-XX:G1HeapRegionSize=n可指定region大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个region。

  • 它仍然属于分代收集器,仍然会分为新生代(Eden和survivor)和老年代,只不过此时的内存单位是region,即某些region为新生代服务(如下图的E和S),某些region为老年代服务(下图的O),新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间。

  • 每个region也不会确定地只为某个代服务,可以按需在年轻代和老年代之间切换。(但在特定时刻,它要么为新生代服务,要么为老年代服务)。年轻代空间并不是固定不变的,当现有年轻代分区占满时,JVM会分配新的空闲region加入到年轻代空间。

  • 整个年轻代内存会在初始空间-XX:G1NewSizePercent(默认整堆5%)与最大空间-XX:G1MaxNewSizePercent(默认60%)之间动态变化,且由参数目标暂停时间-XX:MaxGCPauseMillis(默认200ms)、需要扩缩容的大小以及分区的已记忆集合(RSet)计算得到。当然,G1依然可以设置固定的年轻代大小(参数-XX:NewRatio、-Xmn),但同时暂停目标将失去意义。

6.7.1.2 Card

在cms收集器的介绍中我们提到过card,这里的card也类似,是比region更小的一个内存单位。

G1启用后,jvm会在每个分区内部分配了若干个大小为512 Byte卡片(Card),标识堆内存最小可用粒度。所有分区的卡片都会记录在卡片表(Card Table)中。

分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时,便可通过记录卡片来查找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。

6.7.1.3 本地分配缓冲(LAB)

本地分配缓冲 Local allocation buffer 简称Lab

我们知道TLAB是在eden区分配的一个线程私有的本地缓冲,当我们启用G1收集器的时候,TLAB的内存单位,也相应的改为了region,即:

  • 每个线程均可以”认领”某个region用于线程本地的内存分配,而不需要顾及region是否连续。

  • TLAB大部分都会落入Eden区域(巨型对象或分配失败除外),因此TLAB的分区属于Eden空间;

  • 而每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间;对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。

6.7.1.4 Humongous区域

在G1中,还有一种特殊的区域,叫Humongous区域。 如果一个对象占用的空间超过了region容量50%以上,G1收集器就认为这是一个巨型对象。

当线程为巨型分配空间时,不能简单在TLAB进行分配,因为巨型对象的移动成本很高,而且有可能一个分区不能容纳巨型对象。

因此这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。

如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

巨型对象会独占一个、或多个连续分区,其中第一个分区被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。由于无法享受Lab带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。

6.7.1.5 Remember Set

在串行和并行收集器中,GC通过整堆扫描,来确定对象是否处于可达路径中(即存活)。然而G1为了避免STW式的整堆扫描,在每个region内部记录了一个已记忆集合(RSet),这个RSet是个point-into思路(谁引用了我的对象)的产物,用来记录“引用了RSet所在region内的对象的卡片索引”。当要回收该分区时,通过扫描分区的RSet,来确定引用本分区内对象的对象是否存活,进而确定本分区内的对象存活情况。

事实上,并非所有的引用都需要记录在RSet中,G1 GC每次都会对年轻代进行整体收集,因此引用目标是年轻代的对象(新生代对象引用新年代对象,或者老年代引用新生代对象),也不需要在RSet中记录(即新生代无需记录哪些老年代对象引用了我)。只需要记录新生代引用老年代对象这种跨代引用。

鉴于RSet是个point-into思路(谁引用了我的对象)的产物,故而最后只有老年代的region可能会有RSet记录(记录哪些新生代对象引用了我),这些分区称为拥有RSet分区(an RSet’s owning region)。

下图表示了RSet、Card和Region的关系

上图中有三个Region,每个Region被分成了多个Card,在不同Region中的Card会相互引用,Region1中的Card中的对象引用了Region2中的Card中的对象,蓝色实线表示的就是points-out的关系,而在Region2的RSet中,记录了Region1的Card,即红色虚线表示的关系,就是points-into。

6.7.1.6 收集集合 (CSet)

收集集合(Collection Set 简称CSet)是每次G1 GC暂停时回收的目标region的集合。在任意一次收集暂停中,CSet内的所有region都会被释放,内部存活的对象都会被转移到分配的空闲region中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代region,而混合收集会通过启发式算法,在老年代候选回收region中,筛选出回收收益最高的region添加到CSet中。

哪些老年代Region会被选入CSet,由一系列参数控制,后续详解。

由上述可知,G1的收集都是根据CSet进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。

6.7.2 G1如何保证在并发标记的正确性

SATB的全称(Snapshot At The Beginning)字面意思是开始GC前存活对象的一个快照。SATB的作用是保证在并发标记阶段的正确性。如何理解这句话?

6.7.2.1 三色标记法

提到并发标记,我们不得不了解并发标记的三色标记算法。它是描述追踪式回收器的一种有用的方法,利用它可以推演回收器的正确性。 首先,我们将对象分成三种类型的。

  • 黑色:根对象,或者该对象与它的子对象都被扫描
  • 灰色:对象本身被扫描,但还没扫描完该对象中的子对象
  • 白色:未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象

当GC开始扫描对象时,按照如下图步骤进行对象的扫描:
根对象被置为黑色,子对象被置为灰色。

继续由灰色遍历,将已扫描了子对象的对象置为黑色。

遍历了所有可达的对象后,所有可达的对象都变成了黑色。不可达的对象即为白色,需要被清理。

这看起来很美好,但是如果在标记过程中,应用程序也在运行,那么对象的指针就有可能改变。这样的话,我们就会遇到一个问题:对象丢失问题

我们看下面一种情况,当垃圾收集器扫描到下面情况时:

这时候应用程序执行了以下操作:

  1. A.c=C

  2. B.c=null

这样,对象的状态图变成如下情形:

这时候垃圾收集器再标记扫描的时候就会下图成这样(因为不会扫描黑色对象的子对象,所以C不会被再标记):

很显然,此时C是白色,被认为是垃圾需要清理掉,显然这是不合理的。那么我们如何保证应用程序在运行的时候,GC标记的对象不丢失呢?有如下2中可行的方式:

  • 在删除的时候记录对象
  • 在插入的时候记录对象

这里,就需要讲到barrier了

6.7.2.2 barrier

我们首先介绍一下栅栏(Barrier)的概念。栅栏是指在原生代码片段中,当某些语句被执行时,栅栏代码也会被执行。栅栏代码分为写前栅栏(Pre-Write Barrrier)和写后栅栏(Post-Write Barrrier)。事实上,写栅栏的指令序列开销非常昂贵,应用吞吐量也会根据栅栏复杂度而降低。

写前栅栏 Pre-Write Barrrier

  • 即将执行一段赋值语句a=b时,原来a所指向的对象假设为A将丢失一个引用。类比G1的场景,即a不再指向A,那么A所在region将因此丧失一个引用。
  • 那么JVM就需要在赋值语句生效之前,记录丧失引用的对象在更新日志缓冲区。JVM并不会立即维护RSet,而是后面找个时机批量处理,在将来对RSet进行更新。

写后栅栏 Post-Write Barrrier

  • 当执行一段赋值语句a=b后,等式右侧对象,即b引用指向的对象B,获取了左侧对象a的引用。类比G1的场景,那么B所在分区的RSet也应该得到更新。同样为了降低开销,写后栅栏发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理(见Concurrence Refinement Threads)。

6.7.2.3 SATB

结合我们之前说的GC标记的对象不丢失的方法

  • 在删除的时候记录对象(写前栅栏 Pre-Write Barrrier)
  • 在插入的时候记录对象(写后栅栏 Post-Write Barrrier)

刚好这对应CMS和G1的2种不同实现方式:

  • 在CMS采用的是增量更新(Incremental update),只要在写屏障(write barrier)里发现要有一个白对象的引用被赋值到一个黑对象 的字段里,那就把这个白对象变成灰色的。即插入的时候记录下来。(写后栅栏)

  • 在G1中,使用的是STAB(snapshot-at-the-beginning)的方式,删除的时候记录所有的对象(写前栅栏),它有如下步骤:

    1. 在开始标记的时候生成一个快照图标记存活对象(通过可达性分析得到)

    2. 在并发标记阶段,当引用关系发生变化的时候,通过pre-write barrier函数会把这种这种变化记录下来,记录方式如下:

      • 找到该引用字段所在的位置(Card),并设置为dirty_card
      • 如果当前是应用线程,每个Java线程有一个dirty card queue,把该card插入队列
      • 除了每个线程自带的dirty card queue,还有一个全局共享的queue
    3. 接下来的RSet更新操作交由多个ConcurrentG1RefineThread()并发完成,每当全局队列集合超过一定阈值后,ConcurrentG1RefineThread会取出若干个队列,遍历每个队列中记录的card,并进行处理,大概实现逻辑如下:

      • 根据card的地址,计算出card所在的Region
      • 如果Region不存在,或者Region是Young区,或者该Region在回收集合中,则不进行处理
      • 否则,更新对应的RSet

并发优化线程(Concurrence Refinement Threads),只专注扫描日志缓冲区记录的卡片来维护更新RSet,线程最大数目可通过-XX:G1ConcRefinementThreads(默认等于-XX:ParellelGCThreads)设置。

并发优化线程永远是活跃的,一旦发现全局列表有记录存在,就开始并发处理。如果记录增长很快或者来不及处理,那么通过阈值-X:G1ConcRefinementGreenZone/-XX:G1ConcRefinementYellowZone/-XX:G1ConcRefinementRedZone,G1会用分层的方式调度,使更多的线程处理全局列表。

如果并发优化线程也不能跟上缓冲区数量,则Mutator线程(Java应用线程)会挂起应用并被加进来帮助处理,直到全部处理完。因此,必须避免此类场景出现。

SATB的方式记录活对象,因为是快照形式,故而也就是那一时刻对象的snapshot,这时会有两类对象需要特殊处理。

  • 在GC过程中变成垃圾的对象,这些叫做浮动垃圾(floating garbage),浮动垃圾只能等到下一次收集回收掉。

  • 在GC过程中新分配的对象,G1的策略是将其都当做是活的,其他不可达的对象就是死的。

如何知道哪些对象是GC开始之后新分配的呢?

原来Region包含了5个指针,分别是bottom、previous TAMS、next TAMS、top和end。其中top是该region的当前分配指针,[bottom, top)是当前该region已用(used)的部分,[top, end)是尚未使用的可分配空间(unused)。

其previous TAMS、next TAMS是前后两次发生并发标记时的位置,全称top-at-mark-start,他们会发生如下变动:

  1. 假设第n轮并发标记开始,将该Region当前的top指针赋值给next TAMS,在并发标记标记期间,分配的对象都在[next TAMS, top]之间,SATB能够确保这部分的对象都会被标记,默认都是存活的

  2. 当并发标记结束时,将next TAMS所在的地址赋值给previous TAMS,SATB给 [bottom, previous TAMS] 之间的对象创建一个快照,所有垃圾对象能通过快照被识别出来

  3. 第n+1轮并发标记开始,过程和第n轮一样

6.7.3 停顿预测模型

G1 GC是一个响应时间优先的GC算法,它与CMS最大的不同是,用户可以设定整个GC过程的期望停顿时间,参数-XX:MaxGCPauseMillis指定一个G1收集过程目标停顿时间,默认值200ms,不过它不是硬性条件,只是期望值。

那么G1怎么满足用户的期望呢?就需要这个停顿预测模型了。G1根据这个模型统计计算出来的历史数据来预测本次收集需要选择的Region数量(即CSet大小),从而尽量满足用户设定的目标停顿时间。

关于停顿时间的设置并不是越短越好。设置的时间越短意味着每次收集的CSet越小,导致垃圾逐步积累变多,最终不得不退化成Serial GC(Full GC);停顿时间设置的过长,那么会导致每次都会产生长时间的停顿,影响了程序对外的响应时间。

6.7.4 G1回收的过程

G1提供了两种GC模式,Young GC和Mixed GC,两种都是完全Stop The World的。

  1. Young GC:选定所有年轻代里的Region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。

  2. Mixed GC:选定所有年轻代里的Region,外加根据global concurrent marking(全局并发标记)统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。

由上面的描述可知,Mixed GC不是full GC,它只能回收部分老年代的Region,如果mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会使用serial old GC(full GC)来收集整个GC heap。所以我们可以知道,G1是不提供full GC的。

6.7.4.1 Young GC

Young GC 回收的是所有年轻代的Region。当E区不能再分配新的对象时就会触发。E区的对象会移动到S区,当S区空间不够的时候,E区的对象会直接晋升到O区,同时S区的数据移动到新的S区,如果S区的部分对象到达一定年龄,会晋升到O区。

Yung GC过程示意图如下:

6.7.4.2 Mixed GC

Mixed GC 翻译过来叫混合回收。之所以叫混合是因为回收所有的年轻代的Region+部分老年代的Region。

Mixed GC的触发也是由-XX:InitiatingHeapOccupancyPercent控制,这个值叫做IHOP阈值,表示老年代占整个堆大小的百分比,默认值是45%,达到该阈值就会触发一次Mixed GC。

Mixed GC分为两个阶段:

  1. 全局并发标记阶段(Global Concurrent marking)
  2. 拷贝存活对象阶段(evacuation)
6.7.4.2.1 全局并发标记阶段
全局并发标记阶段是基于SATB的,与CMS有些类似,但是也有不同的地方,主要的几个阶段如下:
  • 初始标记 Initial Mark

    • 该阶段会STW
    • 负责标记所有能被直接可达的根对象(原生栈对象、全局对象、JNI对象),根是对象图的起点,因此初始标记需要将Mutator线程(Java应用线程)暂停掉,也就是需要一个STW的时间段。
    • 事实上,当达到IHOP阈值时,G1并不会立即进入并发标记阶段,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道(Piggybacking)。在初始标记暂停中,分区的NTAMS都被设置到分区顶部Top,初始标记是并发执行,直到所有的分区处理完。
  • 根分区扫描 Root Region Scanning

    • 在初始标记暂停结束后,年轻代收集也完成将对象复制到Survivor的工作,应用线程开始活跃起来。
    • 此时为了保证标记算法的正确性,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描
    • 同时扫描的Suvivor分区也被称为根分区(Root Region)。根分区扫描必须在下一次年轻代垃圾收集启动前完成(并发标记的过程中,可能会被若干次年轻代垃圾收集打断),因为每次GC会产生新的存活对象集合。
  • 并发标记 Concurrent Marking

    • 和应用线程并发执行,专门司职并发标记的并发标记线程在并发标记阶段启动,可由参数-XX:ConcGCThreads(默认GC线程数的1/4,即-XX:ParallelGCThreads/4)控制并发标记线程启动数量。
    • 每个线程每次只扫描一个region分区,根据RSet收集各个Region的存活对象信息。在这一阶段会处理Previous/Next标记位图,扫描标记对象的引用字段。同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录(即SATB write barrier所记录下的引用),更新对象引用信息。
  • 最终标记 Remark

    • 该阶段会STW
    • 是最后一个标记阶段。在该阶段中,G1需要一个暂停的时间,去处理剩下的SATB日志缓冲区和所有更新,找出所有未被访问的存活对象,同时安全完成存活数据计算。
    • 这个阶段也是并行(注意不是并发,否则也不需要STW)执行的,通过参数-XX:ParallelGCThread可设置GC暂停时可用的GC线程数。
    • 同时,引用处理也是重新标记阶段的一部分,所有重度使用引用对象(弱引用、软引用、虚引用、最终引用)的应用都会在引用处理上产生开销。
  • 清理 Cleanup

    • 该阶段会STW
    • 清点和重置标记状态。这个阶段有点像mark-sweep中的sweep阶段,这个阶段并不会实际上去做垃圾的收集,只是整理堆分区,为混合收集周期识别回收收益高(基于释放空间和暂停目标)的老年代分区集合,去根据停顿模型来预测出CSet,等待evacuation(拷贝存活对象)阶段来回收。
    • preview TAMS/next TAMS 会在清除阶段交换角色
    • 如果发现完全没有活对象的region就会将其整体回收到可分配region列表中。 清除空Region。
6.7.4.2.2 拷贝存活对象

Evacuation阶段是全暂停的。它负责把CSet里面的region里的活对象拷贝到空region里去(并行拷贝),然后回收原本的region的空间(加入空闲分区列表,清除空Region)。

Mixed GC的清理过程示意图如下:

但Evacuation是可能失败的:

转移失败(Evacuation Failure)是指当G1无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的Full GC。Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数-XX:G1ReservePercent(默认10%)可以设置保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。

G1在以下场景中会触发Full GC,同时会在日志中记录to-space-exhausted以及Evacuation Failure:

  1. 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
  2. 从老年代分区转移存活对象时,无法找到可用的空闲分区
  3. 分配巨型对象时在老年代无法找到足够的连续分区
    由于G1的应用场合往往堆内存都比较大,所以Full GC的收集代价非常昂贵,应该避免Full GC的发生。

7 垃圾回收相关的参数

参考资料:

G1垃圾收集器之RSet
详解 JVM Garbage First(G1) 垃圾收集器

JAVA内存结构和内存管理

Posted on 2019-10-23 | In JAVA , JAVA JVM |
Words count in article: 6k | Reading time ≈ 21

前言

按照官方的说法:

“Java 虚拟机具有一个堆,堆是运行时数据区域,所有类实例和数组的内存均从此处分配。堆是在 Java 虚拟机启动时创建的。”
“在JVM中堆之外的内存称为非堆内存(Non-heap memory)”。

可以看出JVM主要管理两种类型的内存:堆和非堆。

简单来说堆就是Java代码可及的内存,是留给开发人员使用的;非堆就是JVM留给自己用的,所以方法区、JVM内部处理或优化所需的内存(如JIT编译后的代码缓存)、每个类结构(如运行时常数池、字段和方法数据)以及方法和构造方法的代码都在非堆内存中。

1. java内存结构简述

上图即为一个标准的java内存结构(也叫运行时数据区)模型图,其中:

  • 方法区——也称”永久代” 、“非堆”, 它用于存储虚拟机加载的类信息、常量、静态变量、是各个线程共享的内存区域。

    • 运行时常量池——是方法区的一部分,其中的主要内容来自于JVM对Class的加载。
  • 虚拟机栈——描述的是java 方法执行的内存模型:每个方法被执行的时候 都会创建一个“栈帧”用于存储局部变量表(包括参数)、操作栈、方法出口等信息。每个方法被调用到执行完的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。声明周期与线程相同,是线程私有的。

  • 本地方法栈——与虚拟机栈基本类似,区别在于虚拟机栈为虚拟机执行的java方法服务,而本地方法栈则是为Native方法服务,是线程私有的。

  • 堆——也叫做java 堆、GC堆是java虚拟机所管理的内存中最大的一块内存区域,也是被各个线程共享的内存区域,在JVM启动时创建。该内存区域存放了对象实例及数组(所有new的对象)。

  • 程序计数器——是最小的一块内存区域,它的作用是当前线程所执行的字节码的行号指示器。线程私有

2. 内存结构分类

2.1 方法区

方法区——也称”永久代” 、“非堆”, 它用于存储虚拟机加载的类信息、常量、静态变量、是各个线程共享的内存区域。默认最小值为16MB,最大值为64MB,可以通过-XX:PermSize 和 -XX:MaxPermSize参数限制方法区的大小。

方法区是jvm的规范,在HotSpot中,它是PermGen space(永久代),即HotSpot的PermGen space就是HotSpot对方法区规范的实现(在JDK的HotSpot虚拟机中,可以认为方法区就是永久代,但是在其他类型的虚拟机中,没有永久代的概念)

至于加载的类信息、常量、静态变量,这太笼统了,具体是什么呢?

如上图,更加详细一点的说法是方法区里存放着被加载过的每一个类的信息(虚拟机加载的类信息(类的版本、字段、方法、接口),常量,静态变量,即时编译器编译后的代码等数据);这些信息由类加载器在加载类的时候,从类的源文件中抽取出来;static变量信息也保存在方法区中。

在 JDK1.2 ~ JDK6 的实现中,HotSpot 使用永久代实现方法区;

由于 GC 分代技术的影响,使之许多优秀的内存调试工具无法在 Oracle HotSpot之上运行,必须单独处理;并且 Oracle 同时收购了 BEA 和 Sun 公司,同时拥有 JRockit 和 HotSpot,在将 JRockit 许多优秀特性移植到 HotSpot 时由于 GC 分代技术遇到了种种困难,所以从 JDK7 开始 Oracle HotSpot 开始移除永久代。
JDK7中符号表被移动到 Native Heap中,字符串常量和类引用被移动到 Java Heap中。

在 JDK8 中,永久代已完全被元空间(Metaspace)所取代。具体可见:Metaspace 之一:Metaspace整体介绍(永久代被替换原因、元空间特点、元空间内存查看分析方法)

2.1.1 class常量池

在Class文件结构中,最头的4个字节用于存储魔数Magic Number,用于确定一个文件是否能被JVM接受,再接着4个字节用于存储版本号,前2个字节存储次版本号,后2个存储主版本号,再接着是用于存放常量的常量池,由于常量的数量是不固定的,所以常量池的入口放置一个U2类型的数据(constant_pool_count)存储常量池容量计数值。

如果说java的运行时数据区,那么是不包括class常量池的(即上图中的类型的常量池),class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。class常量池是非运行时常量池,保存虚拟机加载的class文件,其在编译阶段就已经确定;

  • 字面量就是我们所说的常量概念,如文本字符串、被声明为final的常量值等。

    • 例如String a = “aa”。其中”aa”就是字面量。
  • 符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分一下,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。一般包括下面三类常量:

    1. 类的全限定名

      • 例如对于String这个类,它的全限定名就是java/lang/String。
    2. 字段名和属性

    3. 方法名和属性。

      • 所谓描述符就相当于方法的参数类型+返回值类型。

2.1.2 运行时常量池

运行时常量池——是方法区的一部分,class文件中的class常量池,这部分内容在类加载后进入方法区的运行时常量池存放,符号引用有一部分是会被转变为直接引用(字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。),比如说类的静态方法或私有方法,实例构造方法,父类方法,这是因为这些方法不能被重写其他版本,所以能在加载的时候就可以将符号引用转变为直接引用(这种转化称为静态解析)。而其他的一些方法是在这个方法被第一次调用的时候才会将符号引用转变为直接引用(这部分称为动态连接)。

运行时常量池相对于CLass文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入CLass文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是String类的intern()方法。

2.1.2.1 字符串常量池

字符串常量池是运行时常量池的组成部分。顾名思义,即为存放String字面量的一个常量池。String类的final修饰的,以字面量的形式创建String变量时,jvm会在编译期间就把该字面量(比如“hello”)放到字符串常量池中。这个字符串常量池的特点就是有且只有一份相同的字面量,如果有其它相同的字面量,jvm则返回这个字面量的引用,如果没有相同的字面量,则在字符串常量池创建这个字面量并返回它的引用。

  • 定义一个字符串的方式
1
2
String str1 = "abcd";//优先从常量池中寻找abcd这个字面量,如果有,则返回其引用赋给str1,否则,在常量池中创建该字面量,并返回创建后的引用赋给str1。
String str2 = new String("abcd");//直接在堆中创建新的abcd对象,str2引用指向堆中该对象的地址。只要使用new方法,便需要创建新的对象。

注意,虽说new String(“abcd”);直接在堆中创建新的abcd对象,但如果常量池中没有abcd这个字面量,那么其实也会在常量池中创建abcd,这并非new String()带来的,而是参数”abcd”带来的,在写下”abcd”的时候,其实就是以第一种引号的方式定义了一个String

  • 使用连接符+定义字符串
    • 只有使用引号之间使用“+”连接产生的新对象才会走常量池(即优先从常量池拿,拿不到创建后再放进常量池)
    • 对于所有包含new方式新建对象(包括null)的“+”连接表达式,它所产生的新对象都不会走常量池。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
String s1 = "hello";
String s2 = "hello";
String s3 = "he" + "llo";
String s4 = "hel" + new String("lo");
String s5 = new String("hello");
String s6 = s5.intern();
String s7 = "h";
String s8 = "ello";
String s9 = s7 + s8;
String s10 = new String("hello") + new String("hello");
String s11 = "hellohello";


  System.out.println(s1==s2);//true
  System.out.println(s1==s3);//true //引号之间的拼接走常量池,所以相等
  System.out.println(s1==s4);//false //s4为包含new的拼接,不走常量池,所以地址不相等
  System.out.println(s1==s9);//false //s9为非引号相加得来,不走常量池,它指向堆中对象
  System.out.println(s4==s5);//false //s4和s5是堆中的两个对象,地址肯定不相等
  System.out.println(s1==s6);//true
  System.out.println(s10 == s11);//false

intern()方法能使一个位于堆中的字符串在运行期间动态地加入到字符串常量池中(字符串常量池的内容是程序启动的时候就已经加载好了),如果字符串常量池中有该对象对应的字面量,则返回该字面量在字符串常量池中的引用,否则,创建复制一份该字面量到字符串常量池并返回它的引用。

2.2 虚拟机栈

虚拟机栈(Java Virtual Machine Stacks)是线程隔离的,每创建一个线程时就会对应创建一个Java栈,即每个线程都有自己独立的虚拟机栈。

这个栈中又会对应包含多个栈帧,每调用一个方法时就会往栈中创建并压入一个栈帧,栈帧存储局部变量表、操作栈、动态链接、方法出口等信息,每一个方法从调用到最终返回结果的过程,就对应一个栈帧从入栈到出栈的过程。

虚拟机栈当然是一个后入先出的数据结构,线程运行过程中,只有处于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法,当前活动帧栈始终是虚拟机栈的栈顶元素。

如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。当一个新的方法被调用,一个新的栈帧也会随之而创建,并且随着程序控制权移交到新的方法而成为新的当前栈帧。当方法返回的之际,当前栈帧会传回此方法的执行结果给前一个栈帧,在方法返回之后,当前栈帧就随之被丢弃,前一个栈帧就重新成为当前栈帧了。

栈的大小可以固定也可以动态扩展。

  • 在固定大小的情况下,JVM会为每个线程的虚拟机栈分配一定的内存大小(-Xss参数设置最大栈深度),因此虚拟机栈能够容纳的栈帧数量是有限的,若栈帧不断进栈而不出栈,最终会导致当前线程虚拟机栈的内存空间耗尽,会抛出StackOverflowError异常。
  • 在动态扩展的情况下,如果虚拟机在扩展栈时无法申请到足够的内存空间,就会抛出OutOfMemoryError异常。

2.2.1 栈帧存放的内容

2.2.1.1 局部变量表

  • 数据单元为slot,32位。一个局部变量(Slot)可以保存一个类型为boolean、byte、char、short、float、reference和returnAddress的数据,两个局部变量可以保存一个类型为long和double的数据。
  • 存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(引用指针,并非对象本身),其中64位长度的long和double类型的数据会占用2个局部变量slot的空间,其余数据类型只占1个。
  • 局部变量使用索引来进行定位访问,第一个局部变量的索引值为0,局部变量的索引值是从0至小于局部变量表最大容量的所有整数。
    • long和double类型的数据占用两个连续的局部变量,这两种类型的数据值采用两个局部变量之中较小的索引值来定位。例如我们讲一个double类型的值存储在索引值为n的局部变量中,实际上的意思是索引值为n和n+1的两个局部变量都用来存储这个值。索引值为n+1的局部变量是无法直接读取的,但是可能会被写入,不过如果进行了这种操作,就将会导致局部变量n的内容失效掉。
  • Java虚拟机使用局部变量表来完成方法调用时的参数传递,当一个方法被调用的时候,它的参数将会传递至从0开始的连续的局部变量表位置上。
    • 特别地,当一个实例方法被调用的时候,第0个局部变量一定是用来存储被调用的实例方法所在的对象的引用(即Java语言中的“this”关键字)。后续的其他参数将会传递至从1开始的连续的局部变量表位置上。
  • 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量是完全确定的,在运行期间栈帧不会改变局部变量表的大小空间。
  • 通常我们所说的“栈内存”指的就是局部变量表这一部分。

2.2.1.2 操作栈

操作栈也叫操作数栈(Operand Stack),

主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。只支持出栈入栈操作。

栈帧在刚刚被创建的时候,操作数栈是空的。

Java虚拟机提供一些 字节码指令 来从 局部变量表 或者 对象实例的字段中 复制 常量 或 变量值 到操作数栈中,也提供了一些指令用于从操作数栈取走数据、操作数据和把操作结果重新入栈。

举个例子,iadd字节码指令的作用是将两个int类型的数值相加,它要求在执行的之前操作数栈的栈顶已经存在两个由前面其他指令放入的int型数值。在iadd指令执行时,2个int值从操作栈中出栈,相加求和,然后将求和结果重新入栈。在操作数栈中,一项运算常由多个子运算(Subcomputations)嵌套进行,一个子运算过程的结果可以被其他外围运算所使用。

在操作数栈中的数据必须被正确地操作,这里正确操作是指对操作数栈的操作必须与操作数栈栈顶的数据类型相匹配,例如不可以入栈两个int类型的数据,然后当作long类型去操作他们,或者入栈两个float类型的数据,然后使用iadd指令去对它们进行求和。

有一小部分Java虚拟机指令(例如dup和swap指令)可以不关注操作数的具体数据类型,把所有在运行时数据区中的数据当作裸类型(Raw Type)数据来操作,这些指令不可以用来修改数据,也不可以拆散那些原本不可拆分的数据,这些操作的正确性将会通过Class文件的校验过程来强制保障。

1
2
3
int a = 100;
int b = 98;
int c = a+b;

2.2.1.3 动态链接

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

2.2.1.4 返回地址

  • 正常完成出口

    • 当一个方法开始执行后,只会有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回值的字节指令(return关键字)。这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型间根据遇到何种方法返回值指令来决定,这种退出方法的方式称为正常完成出口。
  • 异常完成出口

    • 另一种退出方式是,载方法执行过程中遇到了异常,并且这个异常没有在方法体内的得到处理,无论是虚拟机内部产生异常还是throw字节码指令产生的异常,只要在本方法异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口。

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器的值。而方法异常退出时,返回地址时通过异常处理器来确定的栈帧中一般不会存储这部分信息。

方法退出的过程实际上就等同于把当前栈帧出栈,因为退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。

2.3 本地方法栈

  • 本地方法栈的功能和特点类似于虚拟机栈,均具有线程隔离的特点以及都能抛出StackOverflowError和OutOfMemoryError异常。
  • 不同的是,本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的java方法。
  • HotSpot虚拟机不区分虚拟机栈和本地方法栈,两者是一块的。

2.4 堆

  • 堆是java虚拟机所管理的内存中最大的一块内存区域,也是被各个线程共享的内存区域,在JVM启动时创建。
  • 堆是垃圾收集器管理的主要区域,因此也被称为“GC堆”。
  • 该内存区域存放了对象实例及数组(所有new的对象)。
  • JAVA堆的分类:
    • 从内存回收的角度上看,可分为新生代(Eden空间,From Survivor空间、To Survivor空间)及老年代(Tenured Gen)。
    • 从内存分配的角度上看,为了解决分配内存时的线程安全性问题,线程共享的JAVA堆中可能划分出多个线程私有的分配缓冲区(TLAB)。
  • JAVA堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。
  • 其大小通过-Xms(最小值)和-Xmx(最大值)参数设置,-Xms为JVM启动时申请的最小内存,默认为操作系统物理内存的1/64但小于1G,-Xmx为JVM可申请的最大内存,默认为物理内存的1/4但小于1G。
    • 默认当空余堆内存小于40%时,JVM会增大Heap到-Xmx指定的大小,可通过-XX:MinHeapFreeRation=来指定这个比列;
    • 当空余堆内存大于70%时,JVM会减小heap的大小到-Xms指定的大小,可通过XX:MaxHeapFreeRation=来指定这个比列

对于运行系统,为避免在运行时频繁调整Heap的大小,通常-Xms与-Xmx的值设成一样。

2.5 程序计数器

程序计数器是最小的一块内存区域,是线程独占的,每一个线程拥有自己的程序计数器。它的作用是当前线程所执行的字节码的行号指示器,在虚拟机的模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、异常处理、线程恢复等基础功能都需要依赖计数器完成。

其生命周期随着线程启动而产生,线程结束而消亡。

因为Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个时刻,一个核心只会执行一条线程的指令,因此为了线程切换以后可以恢复到正确的执行位置,每条线程都需要这个线程私有的程序计数器。

当线程执行java方法时,其记录的是正在执行的虚拟机字节码指令的地址,当执行native方法时,这个计数器的值为空。

3. 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

  • 直接内存的注意事项
    • 本机直接内存的分配不会受到Java 堆大小的限制,但是受到本机总内存大小限制
    • 配置虚拟机参数时,不要忽略直接内存,防止出现OutOfMemoryError异常
  • 直接内存(堆外内存)与堆内存比较
    • 直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显
    • 直接内存IO读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显

其使用demo为

1
2
3
4
5
6
7
8
9
10
11
12
//直接内存分配申请
ByteBuffer buffer = ByteBuffer.allocateDirect(100); //ByteBuffer.allocateDirect(int capacity) 分配新的直接字节缓冲区。
// 往buffer里写入数据
buffer.putChar('a');// putChar(char value) 用来写入 char 值的相对 put 方法
buffer.put(10);


buffer.flip();// 将Buffer从写模式切换到读模式(必须调用这个方法)


buffer.getChar();// 读取buffer里的数据
buffer.get()

JAVA内存模型

Posted on 2019-10-17 | In JAVA , JAVA JVM |
Words count in article: 4.9k | Reading time ≈ 17

前言

Java作为一种面向对象的,跨平台语言,其对象、内存等一直是比较难的知识点。而且很多概念的名称看起来又那么相似,很多人会傻傻分不清楚。比如本文我们要讨论的JVM内存结构、Java内存模型和Java对象模型,这就是三个截然不同的概念,但是很多人容易弄混。

简单来说,他们的概念如下:

  • 内存结构:
    • 虚拟机在执行Java程序的过程中会把所管理的内存划分为若干个不同的数据区域,如堆、栈,寄存器,方法区,本地常量池等。这些区域都有各自的用途。
  • 内存模型:
    • Java内存模型看上去和Java内存结构(JVM内存结构)相似,很多人会误以为两者是一回事儿,但他们不是一个概念;
    • Java内存模型是根据英文Java Memory Model(JMM)翻译过来的。其实JMM并不像JVM内存结构一样是真实存在的。他只是一个抽象的概念。
    • JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。并定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键字。
  • 对象模型:
    • Java是一种面向对象的语言,而Java对象在JVM中的存储也是有一定的结构的。而这个关于Java对象自身的存储模型称之为Java对象模型。
    • HotSpot虚拟机中,设计了一个OOP-Klass模型。OOP(Ordinary Object Pointer)指的是普通对象指针,而Klass用来描述对象实例的具体类型。

JVM内存结构,和Java虚拟机的运行时区域有关。
Java内存模型,和Java的并发编程有关。
Java对象模型,和Java对象在虚拟机中的表现形式有关。


1 计算机的内存模型

前言部分为了区别jvm内存模型,内存结构和对象模型,给出了三者的简单定义,现在我们来好好聊一聊java的内存模型。其实要了解java的内存模型,我们得从计算机的内存模型说起;

1.1 CPU

计算机在执行程序的时候,每条指令都是在CPU中执行的,而执行的时候,又免不了要和数据打交道。而计算机上面的数据,是存放在主存当中的,也就是计算机的物理内存。

刚开始,还相安无事,但是随着CPU技术的发展,CPU的执行速度越来越快。而由于内存的技术并没有太大的变化,所以从内存中读取和写入数据的过程和CPU的执行速度比起来差距就会越来越大,这就导致CPU每次操作内存都要耗费很多等待时间。

为了不让内存成为计算机处理的瓶颈,人们想出来了一个好的办法,就是在CPU和内存之间增加高速缓存。缓存的概念大家都知道,就是保存一份数据拷贝。他的特点是速度快,内存小,并且昂贵。于是程序的执行过程就变成了:

当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。

1.2 多级缓存

而随着CPU能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。

按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存(L1),二级缓存(L2),部分高端CPU还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。

那么,在有了多级缓存之后,程序的执行就变成了:

当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。

单核CPU只含有一套L1,L2,L3缓存;如果CPU含有多个核心,即多核CPU,则每个核心都含有一套L1(甚至和L2)缓存,而共享L3(或者和L2)缓存。

1.3 缓存一致性问题

随着计算机能力不断提升,开始支持多线程。那么问题就来了。我们分别来分析下单线程、多线程在单核CPU、多核CPU中的影响。

  • 单线程
    • cpu核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题。
  • 单核CPU,多线程
    • 进程中的多个线程会同时访问进程中的共享数据,CPU将某块内存加载到缓存后,不同线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。但由于任何时刻只能有一个线程在执行,因此不会出现缓存访问冲突。
  • 多核CPU,多线程
    • 每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。

在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。

1.4 MESI协议

MESI(Modified Exclusive Shared Or Invalid)(也称为伊利诺斯协议,是因为该协议由伊利诺斯州立大学提出)是一种广泛使用的支持写回策略的缓存一致性协议。奔腾处理器有使用它,很多其他的处理器都是使用它的变种。该协议使用了cache line的四种状态的首字母来命名。

1.4.1 缓存行状态

单核Cache中每个Cache line有2个标志:dirty和valid标志,它们很好的描述了Cache和Memory(内存)之间的数据关系(数据是否有效,数据是否被修改),而在多核处理器中,多个核会共享一些数据,MESI协议就包含了描述共享的状态。

多核处理器中,CPU中每个缓存行(caceh line)使用4种状态进行标记(使用两个bit表示):

  • M: 被修改(Modified)

    • 表示这行数据有效,但数据在当前工作内存被修改了,和主内存中的数据不一致,改后的数据目前只存在于该cpu的Cache中。
    • 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回主内存。
    • 图示如下图:Core 0修改了x的值之后,这个Cache line变成了M(Modified)状态,其他Core对应的Cache line变成了I(Invalid)状态。
  • E: 独享的(Exclusive)

    • 表示这行数据有效,数据和主内存中的数据一致,并且数据只存在于该cpu的Cache中,其他cpu没有缓存该数据。
    • 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。
    • 图示如下图:只有Core 0访问变量x,它的Cache line状态为E(Exclusive)。
  • S: 共享的(Shared)

    • 表示这行数据有效,数据和内存中的数据一致,且数据存在于很多cpu的Cache中。
    • 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
    • 图示如下图:多个Core都访问变量x,它们对应的Cache line为S(Shared)状态。
  • I: 无效的(Invalid)

    • 表示这行数据无效
    • 当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。

M(Modified)和E(Exclusive)状态的Cache line,数据是独有的,不同点在于M状态的数据是dirty的(和内存的不一致),E状态的数据是clean的(和内存的一致)。

当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下会性能开销是相对较大的。在写入完成后,修改其缓存状态为M。

上面是不是比较难理解呢?没关系,我们说人话:

  • 假如当前有一个cpu A去主内存拿到一个变量x的值初始为1,放到自己的工作内存中。此时它的状态就是独享状态E

  • 然后此时另外一个cpu B也拿到了这个x的值,放到自己的工作内存中。此时之前那个cpu A会不断地监听内存总线,发现这个x有多个cpu在获取,那么这个时候这两个cpu所获得的x的值的状态就都是共享状态S。

  • 然后第一个cpu A将自己工作内存中x的值带入到自己的ALU计算单元去进行计算,返回来x的值变为2,接着会告诉给内存总线,将此时自己的x的状态置为修改状态M。

  • 而另一个cpu B此时也会去不断的监听内存总线,发现这个x已经有别的cpu将其置为了修改状态,所以自己内部的x的状态会被置为无效状态I,等待第一个cpu A将修改后的值刷回到主内存后,重新去获取新的值。

  • 这个谁先改变x的值可能是同一时刻进行修改的,此时cpu就会通过底层硬件在同一个指令周期内进行裁决,裁决是谁进行修改的,就置为修改状态,而另一个就置为无效状态。

当然,MESI也会有失效的时候,缓存的最小单元是缓存行,如果当前的共享数据的长度超过一个缓存行的长度的时候,就会使MESI协议失败,此时的话就会触发总线加锁的机制,第一个线程cpu拿到这个x的时候,其他的线程都不允许去获取这个x的值。

1.4.2 缓存行状态迁移

在MESI协议中,每个Cache的Cache控制器不仅知道自己的读写操作,而且也监听(snoop)其它Cache的读写操作。每个Cache line所处的状态根据本核和其它核的读写操作在4个状态间进行迁移。

其中:

  • Local Read
    • 表示cpu读自己的Cache中的值
  • Local Write
    • 表示cpu写自己Cache中的值 Remote Read
    • 表示其它cpu读其它Cache中的值
  • Remote Write
    • 表示其它cpu写其它Cache中的值

具体的过程是:

AMD的Opteron处理器使用从MESI中演化出的MOESI协议,O(Owned)是MESI中S和M的一个合体,表示本Cache line被修改,和内存中的数据不一致,不过其它的核可以有这份数据的拷贝,状态为S。

Intel的core i7处理器使用从MESI中演化出的MESIF协议,F(Forward)从Share中演化而来,一个Cache line如果是Forward状态,它可以把数据直接传给其它内核的Cache,而Share则不能。

1.5 处理器优化和指令重排

上面提到在在CPU和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。除了这种情况,还有一种硬件问题也比较重要。那就是为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理。这就是处理器优化。

除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排。

可想而知,如果任由处理器优化和编译器对指令重排的话,就可能导致各种各样的问题。


2 java内存模型

2.1 java内存模型的意义

前面说的和硬件有关的概念你可能听得有点蒙,还不知道他到底和软件有啥关系。但是关于并发编程的问题你应该有所了解,比如原子性问题,可见性问题和有序性问题。

其实,原子性问题,可见性问题和有序性问题。是人们抽象定义出来的。而这个抽象的底层问题就是前面提到的缓存一致性问题、处理器优化问题和指令重排问题等。

  • 原子性

    • 是指在一个操作中,cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。
  • 可见性

    • 是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 有序性

    • 即程序执行的顺序按照代码的先后顺序执行。

我们发现,缓存一致性问题其实就是可见性问题。而处理器优化则可能会导致原子性问题。指令重排即会导致有序性问题。那么,有没有什么机制可以很好的解决上面的这些问题呢?

为了保证并发编程中可以满足原子性、可见性及有序性。所以需引入一个重要的概念,那就是——内存模型。

2.2 Java内存模型的概念

前面介绍过了计算机内存模型,这是解决多线程场景下并发问题的一个重要规范。那么具体的实现是如何的呢,不同的编程语言,在实现上可能有所不同。

我们知道,Java程序是需要运行在Java虚拟机上面的,Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

它规定了:

  1. 所有的变量都存储在主内存中。这是共享数据。
  2. 每条线程还有自己的工作内存,线程的工作内存中保存了该线程中用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。
  3. 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。

在JVM的内存模型中,每个线程有自己的工作内存,实际上JAVA线程借助了底层操作系统线程实现,一个JVM线程对应一个操作系统线程,线程的工作内存其实是cpu寄存器和高速缓存的抽象。


3 Java内存模型的实现

了解Java多线程的朋友都知道,在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final、concurren包等。其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。

在开发多线程的代码的时候,我们可以直接使用synchronized等关键字来控制并发,从来就不需要关心底层的编译器优化、缓存一致性等问题。所以,Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。

3.1 原子性

在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。在synchronized原理和锁优化策略(偏向/轻量级/重量级)中我们介绍过,这两个字节码,在Java中对应的关键字就是synchronized。

3.2 可见性

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

除了volatile,Java中的synchronized和final两个关键字也可以实现可见性。只不过实现方式不同。

3.3 有序性

在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:

volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。

3.4 总结

好了,这里简单的介绍完了Java并发编程中解决原子性、可见性以及有序性可以使用的关键字。读者可能发现了,好像synchronized关键字是万能的,他可以同时满足以上三种特性,这其实也是很多人滥用synchronized的原因。

但是synchronized是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。

参考资料

再有人问你Java内存模型是什么,就把这篇文章发给他。

《大话处理器》Cache一致性协议之MESI

synchronized原理和锁优化策略(偏向/轻量级/重量级)

Posted on 2019-10-15 | In JAVA , JAVA线程与并发控制 |
Words count in article: 6.3k | Reading time ≈ 22

1 前置知识点

要了解锁优化策略中的轻量级锁与偏向锁的原理和运作过程,需要先了解java的锁和Hotspot虚拟机的对象头部分的内存布局。

1.1 Java 2种主要加锁机制

  • synchronized 关键字
    1
    2
    3
    synchronized(lockObject){
    //代码
    }
    或
    1
    2
    3
    public synchornized void test(){
    //代码
    }

    这里需要指出的是,无论是对一个对象进行加锁还是对一个方法进行加锁,实际上,都是对对象进行加锁。对于synchornized方法,实际上虚拟机会根据synchronized修饰的是实例方法还是静态方法,去取对应的实例对象或者Class对象来进行加锁。

  • java.util.concurrent.Lock(Lock是一个接口,ReentrantLock是该接口一个很常用的实现)

这两种机制的底层原理存在一定的差别

  • java.util.concurrent.Lock通过Java代码搭配sun.misc.Unsafe中的native调用实现的,即UNSAFE.park()和UNSAFE.unpark()
  • synchronized关键字通过一对字节码指令 monitorenter/monitorexit实现,这对指令被JVM规范所描述。
    • monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。
    • 任何对象都有一个 monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

1.2 字宽和对象头

  • 字宽(Word): 内存大小的单位概念, 对于32位处理器1 Word = 4 Bytes,64位处理器1 Word = 8 Bytes

  • 对象头信息是与对象自身定义的数据无关的额外存储成本,每一个Java对象都至少占用2个字宽的内存(数组类型占用3个字宽)。

    • 第一个字宽,也被称为对象头Mark Word,用来存储对象自身的运行时数据 如:哈希码(HashCode)、GC分代年龄(Generational GC Age)等。

    • 第二个字宽用于存储指向方法区对象类型数据的指针。

当偏向机制被禁用时,被分配出来的对象初始的MarkWord状态为无锁状态

偏向机制被启用时,分配出来的对象的初始状态是 ThreadId|Epoch|age|1|01, ThreadId 为空时标识对象尚未偏向于任何一个线程, ThreadId 不为空时, 对象既可能处于偏向特定线程的状态, 也有可能处于已经被特定线程占用完毕释放的状态, 需结合 Epoch 和其他信息判断对象是否允许再偏向(rebias)。

1.3 锁记录

锁记录lock Record,在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个栈,存放名为锁记录(Lock Record)的栈帧,锁记录用于存储锁记录目前的Mark Word的拷贝(称为Displaced Mark Word)以及记录锁对象的指针owner。

在代码进入同步块的时候Lock Record就会创建,所以偏向锁时也有Lock Record存在,只不过作用不大。Lock Record主要用于轻量级锁和重量级锁,

其数据结构如下

1
2
3
4
5
6
7
8
9
10
11
// A BasicObjectLock associates a specific Java object with a BasicLock.
// It is currently embedded in an interpreter frame.
class BasicObjectLock VALUE_OBJ_CLASS_SPEC {
private:
BasicLock _lock; // the lock, must be double word aligned
oop _obj; // object holds the lock;
};
class BasicLock VALUE_OBJ_CLASS_SPEC {
private:
volatile markOop _displaced_header;
};

《Eliminating Synchronization-Related Atomic Operations with Biased Locking and Bulk Rebiasing》一文的说法是:当字节码解释器执行monitorenter字节码轻度锁住一个对象时,就会在获取锁的线程的栈上显式或者隐式分配一个lock record。lock record在线程的Interpretered Frame上(解释帧)分配

2 synchronized关键字之锁的升级

synchronized代码块是由一对monitorenter/moniterexit字节码指令实现,monitor是其同步实现的基础,Java SE1.6为了改善性能,减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,

所以在Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。在几乎无竞争的条件下, 会使用偏向锁, 在轻度竞争的条件下, 会由偏向锁升级为轻量级锁, 在重度竞争的情况下, 会升级到重量级锁。

锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。

这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率,下文会详细分析。

偏向锁——>轻量级锁——>重量级锁

下图展现了一个对象在创建(allocate)后,根据偏向锁机制是否打开,锁对象MarkWord状态以不同方式转换的过程

2.1 偏向锁

Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,无竞争且只有一个线程使用锁的情况下,减少使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。

2.1.1 偏向锁配置

偏向锁在Java 6和Java 7里是默认启用的,可以使用下列语句关闭偏向机制

  • -XX:-UseBiasedLocking=false 关闭偏向锁

偏向锁机制默认开启,但是它在应用程序启动几秒钟之后才会激活,如有必要可以使用JVM的BiasedLockingStartupDelay参数来关闭延迟

  • -XX:BiasedLockingStartupDelay = 0 关闭延迟

InstanceKlass

  • HotSpot为所有加载的类型,在class元数据——InstanceKlass中保留了一个MarkWord原型——mark_prototype。这个值的bias位域决定了该类型的对象是否允许被偏向锁定。

  • 与此同时,当前的epoch位(用来标识对象的偏向锁是否还有效,批量重偏向时细说,先知道它表示有效期即可)也被保留在prototype中。这意味着,对应class的新对象可以简单地直接拷贝这个原型值,而不必在后面进行修正。

  • 在批量重偏向(bulk rebias)的操作中,prototype的epoch位将会被更新;

  • 在批量撤销(bulk revoke)的操作中,prototype将会被置成不可偏向的状态——bias位被置0。

2.1.2偏向锁的获取

偏向锁的获取方式是将对象头的MarkWord部分中,标记上线程ID,以表示哪一个线程获得了偏向锁。具体的赋值逻辑如下

  1. 首先读取锁对象的MarkWord, 判断是否处于可偏向的状态

  2. 验证对象所属InstanceKlass的prototype的bias位和epoch位

    • 确认prototype的epoch位是否被设置为1。如果没有设置,则该类所有对象全部不允许被偏向锁定。
    • 确认prototype的bias位是否被设置为1。如果没有设置,则该类所有对象全部不允许被偏向锁定。
  3. 首先检查对象头Mark Word中记录的Thread Id是否是当前线程ID,并且epoch位和InstanceKlass对象的epoch位是否相等(表示偏向锁未过期)

    • 如果是,则表明当前线程已经获得对象锁,以后该线程进入同步块时,不需要CAS进行加锁。

    • 否则,如果偏向模式关闭,则尝试撤销偏向锁

    • 否则,如果epoch位和InstanceKlass对象的epoch位不相等(说明偏向锁已过期),并且jvm参数允许重偏向,那么

      • 重偏向,就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其mark word的Thread Id改成当前线程Id。
      • 如果这里失败,说明存在竞争,进行锁升级。
    • 再else一次,能走到这里,这里说明当前要么偏向别的线程,要么是匿名偏向(即没有偏向任何线程),这时候通过一个CAS(期望值为0)尝试将自己的ThreadID放置到Mark Word中Thread ID。

      • 如果CAS操作成功,则代表当前是匿名偏向,没有锁竞争,锁对象继续保持biasable可偏向状态,ThreadID字段被设置成了偏向锁所有者的ID(即当前线程ID),然后执行同步代码。

      • 如果CAS操作失败,表示在该锁对象上存在竞争并且这个时候有另外一个线程Thread B抢先获取了偏向锁,这种状态说明该对象的竞争比较激烈, 此时需要撤销Thread B获得的偏向锁,将Thread B持有的锁升级为轻量级锁。

        每次进入同步块(即执行monitorenter)的时候都会以从高往低的顺序在栈中找到第一个可用的Lock Record(判断Lock Record是否空闲的依据是其obj字段是否为null),并将其obj设置为偏向线程ID;每次解锁(即执行monitorexit)的时候都会从最低的一个Lock Record移除。先进后出,作为重入的计数器。所以如果能找到对应的Lock Record说明偏向的线程还在执行同步代码块中的代码。

偏向锁使用了一种等到竞争出现才释放锁的机制,即一个线程在执行完同步代码块以后,并不会尝试将MarkWord中的thread ID赋回原值。这样做的好处是:如果该线程需要再次对这个对象加锁,而这个对象之前一直没有被其他线程尝试获取过锁,依旧停留在可偏向的状态下,即可在不修改对象头的情况下,直接认为偏向成功。

2.1.3偏向锁的撤销

偏向锁的撤销(Revoke) 操作并不是将对象恢复到无锁可偏向的状态,而是指在获取偏向锁的过程因为不满足条件导致要将锁对象改为非偏向锁状态;释放是指退出同步块时的过程,即将内存最低的对应的lock Record的obj置为null,需要注意撤销与释放的区别。 偏向锁的撤销,是轻量级锁的前提。

我们之前说过偏向锁使用了一种等到竞争出现才释放锁的机制,所以偏向锁撤销很多时候发生在如下情况:偏向锁偏向的线程是线程B,线程A来争锁,发现有竞争,从而触发了。

  • 如果要撤销的锁偏向的是当前线程,则直接撤销偏向锁,否则会将该操作push到VM Thread中等到safe point的时候再执行。

    在JVM中有个专门的VM Thread,该线程会源源不断的从VMOperationQueue中取出请求,比如GC请求。对于需要safe point的操作(VM_Operationevaluate_at_safepoint返回true),必须要等到所有的Java线程进入到safe point才开始执行。

  • 当到达全局安全点(safe point,在这个时间点上没有字节码正在执行)时,首先将拥有偏向锁的线程挂起

  • 通过Mark Word拿到偏向的线程ID,查看该ID的线程是否存活,如果已经不存活了,则直接撤销偏向锁。

    JVM维护了一个集合存放所有存活的线程,通过遍历该集合判断是否有线程的ID等于偏向线程ID,有的话表示存活。

  • 偏向的线程是否还在同步块中,如果不在了,则撤销偏向锁,升级为轻量级锁,继续往下执行同步代码。

    我们回顾一下偏向锁的加锁流程:每次进入同步块(即执行monitorenter)的时候都会以从高往低的顺序在栈中找到第一个可用的Lock Record,将其obj字段指向锁对象。每次解锁(即执行monitorexit)的时候都会将最低的一个相关Lock Record移除掉。所以可以通过遍历线程栈中的Lock Record来判断线程是否还在同步块中

2.1.4批量重偏向和批量撤销

从上文偏向锁的加锁解锁过程中可以看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。safe point这个词我们在GC中经常会提到,其代表了一个状态,在该状态下所有线程都是暂停的。总之,偏向锁的撤销是有一定成本的,如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此,JVM中增加了一种批量重偏向/撤销的机制。

存在如下两种情况:

  1. 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作。

  2. 存在明显多线程竞争的场景下使用偏向锁是不合适的,例如生产者/消费者队列。

批量重偏向(bulk rebias)机制是为了解决第一种场景。批量撤销(bulk revoke)则是为了解决第二种场景。

其做法是:以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20,jvm参数BiasedLockingBulkRebiasThreshold控制)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。

当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40,jvm参数BiasedLockingBulkRevokeThreshold控制),JVM就认为该class的使用场景存在多线程竞争,执行批量撤销,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。

BiasedLockingDecayTime是开启一次新的批量重偏向距离上次批量重偏向的后的延迟时间,默认25000。也就是开启批量重偏向后,如果经过了一段较长的时间(>=BiasedLockingDecayTime),撤销计数器才超过阈值,那我们会重置计数器。

2.1.4.1批量重偏向

介绍完偏向,我们发现如果锁先偏向了线程B,那么等另外任何一个线程来竞争的时候,都会导致进入偏向锁的撤销流程,在撤销流程里,才会判断线程B是否还活着,如果已经不活动了,则重偏向。

但偏向锁的撤销流程需要等到全局安全点,这是一个极大的消耗,为了能够让许多本应该重偏向的偏向锁无须等到全局安全点时才被重偏向,jvm引入了批量重偏向的逻辑。

该机制的主要工作原理如下:

  • 引入一个概念epoch,其本质是一个时间戳,代表了偏向锁的有效性,epoch存储在可偏向对象的MarkWord中。除了对象中的epoch,对象所属的类class信息中,也会保存一个epoch值
  • 每当遇到一个全局安全点时,比如要对class C 进行批量再偏向,则首先对 class C中保存的epoch进行增加操作,得到一个新的epoch_new
  • 然后扫描所有持有 class C 实例的线程栈,根据线程栈的信息判断出该线程是否锁定了该对象,仅将epoch_new的值赋给被锁定的对象中。(也就是现在偏向锁还在被使用的对象才会被赋值epoch_new)
  • 退出安全点后,当有线程需要尝试获取偏向锁时,直接检查 class C 中存储的 epoch 值是否与目标对象中存储的 epoch 值相等, 如果不相等,则说明该对象的偏向锁已经无效了,可以尝试对此对象重新进行偏向操作。

2.1.4.2批量撤销

  • 将类的偏向标记关闭,之后当该类已存在的实例获得锁时,就会升级为轻量级锁;该类新分配的对象的mark word则是无锁模式。
  • 处理当前正在被使用的锁对象,通过遍历所有存活线程的栈,找到所有正在使用的偏向锁对象,然后撤销偏向锁。

2.2 轻量级锁

JVM的开发者发现在很多情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。

轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;

2.2.2 加锁过程

  1. 当一个线程A进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(称为Displaced Mark Word)以及记录锁对象的指针(即obj(即下图的Object reference)字段指向锁对象)。

    • 下图右边的部分就是一个Lock Record。左边部分是锁对象。
  2. 线程A尝试获取这个锁,虚拟机将使用CAS操作尝试将锁对象的Mark Word更新为指向自己创建的Lock Record的指针。

    • 如果这个更新动作成功了,那么这个线程A就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态
    • 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否已经指向当前线程的栈帧。
      • 如果指向的不是自己线程的栈帧,说明这个锁对象已经被其他线程抢占了。那么
        • 它就会自旋等待锁,一定次数后仍未获得锁对象,说明发生了竞争,需要膨胀为重量级锁。
      • 如果已经指向当前线程的栈帧,说明当前线程已经拥有了这个对象的锁,现在是重入状态,那么
        • 设置Lock Record第一部分(Displaced Mark Word)为null,起到了一个重入计数器的作用。
        • 下图为重入三次时的lock record示意图,左边为锁对象,右边为当前线程的栈帧
        • 然后结束。就可以直接进入同步块继续执行。

为什么JVM选择在线程栈中添加Displaced Mark word为null的Lock Record来表示重入计数呢?首先锁重入次数是一定要记录下来的,因为每次解锁都需要对应一次加锁,解锁次数等于加锁次数时,该锁才真正的被释放,也就是在解锁时需要用到说锁重入次数的。

一个简单的方案是将锁重入次数记录在对象头的mark word中,但mark word的大小是有限的,已经存放不下该信息了。
另一个方案是只创建一个Lock Record并在其中记录重入次数,Hotspot没有这样做的原因我猜是考虑到效率有影响:每次重入获得锁都需要遍历该线程的栈找到对应的Lock Record,然后修改它的值。所以最终Hotspot选择每次获得锁都添加一个Lock Record来表示锁的重入。

2.2.3 解锁过程

  1. 遍历当前线程栈,找到所有obj字段等于当前锁对象的Lock Record。

  2. 如果Lock Record的Displaced Mark Word为null,代表这是一次重入,将obj设置为null后continue(即一次解锁结束)。

  3. 如果Lock Record的Displaced Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为Displaced Mark Word。如果成功,则continue(即一次解锁结束),否则膨胀为重量级锁。

2.3 重量级锁

重量级锁是我们常说的传统意义上的锁,其利用操作系统底层的同步机制去实现Java中的线程同步。

重量级锁的状态下,锁对象的mark word为指向一个堆中monitor对象的指针。

一个monitor对象包括这么几个关键字段:cxq(下图中的ContentionList),EntryList ,WaitSet,owner。

其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。

2.3.1 加锁过程

2.3.1.1 轻量级锁膨胀为重量级锁

  1. 调用omAlloc分配一个ObjectMonitor对象(以下简称monitor),在omAlloc方法中会先从线程私有的monitor集合omFreeList中分配对象,如果omFreeList中已经没有monitor对象,则从JVM全局的gFreeList中分配一批monitor到omFreeList中。

  2. 初始化monitor对象

  3. 将状态设置为膨胀中(INFLATING)状态

  4. 设置monitor的header字段为displaced mark word,owner字段为Lock Record,obj字段为锁对象

  5. 设置锁对象头的mark word为重量级锁状态(锁标志位为10),并指向第一步分配的monitor对象

  6. 进入2.3.1.3 获取锁流程

2.3.1.2 无锁状态膨胀为重量级锁

  1. 调用omAlloc分配一个ObjectMonitor对象(以下简称monitor)

  2. 初始化monitor对象

  3. 设置monitor的header字段为mark word,owner字段为null,obj字段为锁对象

  4. 设置锁对象头的mark word为重量级锁状态,指向第一步分配的monitor对象

  5. 进入2.3.1.3 获取锁流程

2.3.1.3 获取锁流程

  1. 如果当前是无锁状态、或者锁重入、当前线程是之前持有轻量级锁的线程,那么则进行简单操作后返回。

  2. 否则,先自旋尝试获得锁,这样做的目的是为了减少执行操作系统同步操作带来的开销

  3. 当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter对象插入到cxq的队列的队首,然后调用park函数挂起当前线程。在linux系统上,park函数底层调用的是gclib库的pthread_cond_wait,JDK的ReentrantLock底层也是用该方法挂起线程的。

  4. 当线程释放锁时,会从cxq或EntryList中挑选一个线程唤醒,被选中的线程叫做Heir presumptive即假定继承人,就是图中的Ready Thread,假定继承人被唤醒后会尝试获得锁,但synchronized是非公平的,所以假定继承人不一定能获得锁(这也是它叫”假定”继承人的原因)。

  5. 如果线程获得锁后调用Object#wait方法,则会将线程加入到WaitSet中,当被Object#notify唤醒后,会将线程从WaitSet移动到cxq或EntryList中去。需要注意的是,当调用一个锁对象的wait或notify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。

synchronized的monitor锁机制和JDK的ReentrantLock与Condition是很相似的,ReentrantLock也有一个存放等待获取锁线程的链表,Condition也有一个类似WaitSet的集合用来存放调用了await的线程。如果你之前对ReentrantLock有深入了解,那理解起monitor应该是很简单。

2.3.2 释放锁过程

在进行必要的锁重入判断以及自旋优化后,

  1. 设置owner为null,即释放锁,这个时刻其他的线程能获取到锁。这里是一个非公平锁的优化;
  2. 如果当前没有等待的线程则直接返回就好了,因为不需要唤醒其他线程。
  3. 如果EntryList的首元素非空,就取出来调用ExitEpilog方法,该方法会唤醒ObjectWaiter对象的线程,然后立即返回;
  4. 如果EntryList的首元素为空,就将cxq的所有元素放入到EntryList中,然后再从EntryList中取出来队首元素执行ExitEpilog方法,然后立即返回;

参考文章

Evaluating and improving biased locking in the HotSpot virtual machine
锁机制-java面试
Java中的偏向锁,轻量级锁, 重量级锁解析
偏向锁到底是怎么个回事
死磕Synchronized底层实现–偏向锁
死磕Synchronized底层实现–重量级锁

AQS实现之CountDownLatch/Semaphore/CyclicBarrier

Posted on 2019-09-26 | In JAVA , JAVA线程与并发控制 |
Words count in article: 5.2k | Reading time ≈ 20

前言

  • CountDownLatch:一个单调递减的同步计数器,但无法重置计数的数量。
    • 比喻1:CountDownLatch可以看做一个过山车,假设10个座位,那么司机这个线程,就要调用await()方法,等待10个线程到齐。
    • 每个线程坐进来,就调用一下countDown()方法,表示占用个位置,然后做自己事情去了。最后一个线程进来的时候,调用一下countDown()方法,计数为0了,最后一个线程就会唤醒await的线程,也就是司机线程。
  • Semaphore:一个同步的许可证资源池,负责同步给线程提供一个分发许可和归还许可的地方。
    • 做个比喻,老师让同学们上来在黑板上解题,只有两根粉笔,也就是说,同时只能有两个同学在答题。两根粉笔就可以用Semaphore控制,许可是2。
    • 学生们调用acquire()方法争夺粉笔,同时只有两个人争夺到,答完题后,调用release()方法归还粉笔,这时才会有其他人能获取到粉笔。
  • CyclicBarrier:同步的栅栏,拦住规定数量的线程,让他们阻塞,等到线程数量齐了,让他们执行我们指定的逻辑。
    • 做个比喻,CyclicBarrier可以比作一个牌局,需要固定的参与者都到场才行,先到的人阻塞在那。人到齐后,执行一遍我们指定的逻辑,此时其他线程还在阻塞,由最后一个到场的线程完成指定的逻辑,完成后,这局散场,所有人醒来,各自离开。
    • CyclicBarrier可以完成一局后再开启新的一局,即等待新的参与者到来,并达到指定的数量,然后开局,从此往复。

1 CountDownLatch

  • CountDownLatch是一个计数器闭锁,通过它可以完成类似于阻塞当前线程的功能,即:一个线程或多个线程一直等待,直到其他线程执行的操作完成。

  • CountDownLatch用一个给定的计数器来初始化,该计数器的操作是原子操作,即同时只能有一个线程去操作该计数器。调用该类await()方法的线程会一直处于阻塞状态,直到其他线程调用countDown()方法使当前计数器的值逐渐减少,到0为止,每次调用countDown计数器的值减1。

  • 当计数器值减至零时,所有因调用await()方法而处于等待状态的线程就会继续往下执行。这种现象只会出现一次,因为计数器不能被重置,如果业务上需要一个可以重置计数次数的版本,可以考虑使用CycliBarrier。

  • CountDownLatch实现的是AQS的共享锁机制。

  • CountDownLatch出现以前,类似功能我们使用线程的join()方法实现。

1.1 重要方法

1.1.1 构造器

1
2
3
4
5
6
7
8
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
this.sync = new Sync(count);
}

Sync(int count) {
setState(count);
}

其实CountDownLatch的构造器很简单,count入参表示计数器的次数,CountDownLatch将其赋给了state字段,使用AQS的状态值来表示计数器值。

1.1.2 await()

当前线程调用了CountDownLatch对象的await方法后,当前线程会被阻塞,直到下面的情况之一才会返回:

  • 当指定数量的线程都调用了CountDownLatch对象的countDown方法后,也就是说计时器值为0的时候。
  • 其他线程调用了当前线程的interrupt()方法中断了当前线程,当前线程会抛出InterruptedException异常后返回。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public void await() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);//这个1,其实在CountDownLatch里面没有用
    }

    //AQS的获取共享资源时候可被中断的方法
    public final void acquireSharedInterruptibly(int arg)throws InterruptedException {
    //如果线程被中断则抛异常
    if (Thread.interrupted())
    throw new InterruptedException();
    //尝试看当前是否计数值为0,为0则直接返回,否则进入AQS的队列等待
    if (tryAcquireShared(arg) < 0)
    doAcquireSharedInterruptibly(arg);//该方法前文已经论述,详见2.4.2.2 AQS.doAcquireSharedInterruptibly()
    }

    //CountDownLatch.sync类实现的AQS的接口
    protected int tryAcquireShared(int acquires) {
    return (getState() == 0) ? 1 : -1;
    }
    tryAcquireShared方法是CountDownLatch.sync类实现AQS的接口,只判断了getState()是否等于0,这是计数器有别于其他传统共享锁的核心

    1.1.3 await(long timeout, TimeUnit unit)

    当线程调用了CountDownLatch对象的await(long timeout, TimeUnit unit)方法后,当前线程会被阻塞,直到下面的情况之一发生才会返回:
  • 当所有线程都调用了CountDownLatch对象的countDown方法后,也就是计时器值为0的时候,这时候返回true
  • 设置的timeout时间到了,因为超时而返回false;
  • 其它线程调用了当前线程的interrupt()方法中断了当前线程,当前线程会抛出InterruptedException 异常后返回。

也就是相比于await,引入了一个timeout的概念

1
2
3
4
public boolean await(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}

1.1.4 countDown()

当前线程调用了该方法后,会递减计数器的值,递减后如果计数器为0则会唤醒所有调用await方法而被阻塞的线程,否则什么都不做,接下来看一下countDown()方法内部是如何调用AQS的方法的,源码如下:

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
public void countDown() {
sync.releaseShared(1);//委托sync调用AQS的方法
}


//AQS的方法
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();//AQS的释放资源方法,会唤醒head的后继
//后继争锁成功再唤醒后继,使得所有挂起的线程都被唤醒。详见2.4.4.3 AQS.doReleaseShared()
return true;
}
return false;
}

protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
//循环进行cas,直到当前线程成功完成cas使计数值(状态值state)减一并更新到state
for (;;) {
int c = getState();
if (c == 0)//如果当前状态值为0则直接返回返回false,
return false;//返回false就不用调用doReleaseShared()了
//这里的if (c == 0)貌似是多余的,其实不然,之所以添加if (c == 0)是为了防止计数器值为 0 后,其他线程又调用了countDown方法,如果没有这里,状态值就会变成负数。


int nextc = c-1;//否则,state-1
if (compareAndSetState(c, nextc))
return nextc == 0;//这里如果返回true,说明当前线程是最后一个调用countDown()方法的线程
//那么该线程除了让计数器减一外,还需要唤醒调用CountDownLatch的await方法而被阻塞的线程。
//所以它返回true,tryReleaseShared中则会调用doReleaseShared,唤醒其他节点。
}
}

1.2 CountDownLatch的用法

CountDownLatch典型用法:

  1. 某一线程在开始运行前等待n个线程执行完毕。将CountDownLatch的计数器初始化为new CountDownLatch(n),每当一个任务线程执行完毕,就将计数器减1,即调用countDown()。当计数器的值变为0时,在CountDownLatch上await()的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
  1. 实现多个线程开始执行任务的最大并行性。注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的CountDownLatch(1),将其计算器初始化为1,多个线程在开始执行任务前首先countdownlatch.await(),当主线程调用countDown()时,计数器变为0,多个线程同时被唤醒。

1.3 使用demo

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package com.lscherish;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
public class CountDownLatchTest {

private static AtomicInteger id = new AtomicInteger();

// 创建一个CountDownLatch实例,管理计数为ThreadNum
private static volatile CountDownLatch countDownLatch = new CountDownLatch(3);

public static void main(String[] args) throws InterruptedException {

Thread threadOne = new Thread(new Runnable() {

@Override
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

System.out.println("【玩家" + id.getAndIncrement() + "】已入场");
countDownLatch.countDown();
}
});

Thread threadTwo = new Thread(new Runnable() {

@Override
public void run() {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

System.out.println("【玩家" + id.getAndIncrement() + "】已入场");
countDownLatch.countDown();

}
});

Thread threadThree = new Thread(new Runnable() {

@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

System.out.println("【玩家" + id.getAndIncrement() + "】已入场");
countDownLatch.countDown();

}
});

// 启动子线程
threadOne.start();
threadTwo.start();
threadThree.start();
System.out.println("等待斗地主玩家进场");

// 等待子线程执行完毕,返回
countDownLatch.await();

System.out.println("斗地主玩家已经满人,开始发牌.....");

}
}

运行结果

1
2
3
4
5
等待斗地主玩家进场
【玩家0】已入场
【玩家1】已入场
【玩家2】已入场
斗地主玩家已经满人,开始发牌.....

1.4 与join()相比

CountDownLatch与join方法的区别,一个是调用一个子线程的 join()方法后,该线程会一直被阻塞直到该线程运行完毕,而CountDownLatch则使用计数器允许子线程运行完毕或者运行中时候递减计数,也就是CountDownLatch可以在子线程运行任何时候让await方法返回而不一定必须等到线程结束;

另外使用线程池来管理线程时候一般都是直接添加 Runable到线程池这时候就没有办法在调用线程的join方法了,countDownLatch相比Join方法让我们对线程同步有更灵活的控制。

2 Semaphore

Semaphore也叫信号量,在JDK1.5被引入,用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。还可以用来实现某种资源池,或者对容器施加边界。

打个比喻,Semaphore就像一道阀门,可以控制同时进入某一逻辑的线程数量(构造方法中指定),我们使用acquire方法来争取通行票,使用release方法来归还通行票。通行票只是一个比喻,一般我们称之为许可。

Semaphore和countDownLatch挺像,但countDownLatch的许可只能减少,而Semaphore可以获取/归还

  • Semaphore内部维护了一组虚拟的许可,许可的数量可以通过构造函数的参数指定。
    • public Semaphore(int permits) { sync = new NonfairSync(permits); }
  • 访问特定资源前,必须使用acquire方法获得许可,如果许可数量为0,该线程则一直阻塞,直到有可用许可。
  • 访问资源后,使用release释放许可。
  • Semaphore和ReentrantLock类似,获取许可有公平策略和非公平许可策略,默认情况下使用非公平策略。
    • public Semaphore(int permits, boolean fair) { sync = fair ? new FairSync(permits) : new NonfairSync(permits); }
  • 当初始值为1时,可以用作互斥锁,并具备不可重入的加锁语义。
  • Semaphore将AQS的同步状态(status字段)用于保存当前可用许可的数量。

我们调用Semaphore方法时,其实是在间接调用其内部类或AQS方法执行的。

Semaphore类结构与ReetrantLock类相似,内部类Sync继承自AQS,然后其子类FairSync和NoFairSync分别实现公平锁和非公平锁的获取锁方法tryAcquireShared(int arg)。

而释放锁的tryReleaseShared(int arg)方法则有Sync类实现,因为非公平或公平锁的释放过程都是相同的。

2.1 重要方法

Semaphore在JAVA并发之AQS详解2.4节中有过描述,不论是其公平锁实现还是非公平锁实现,故本文不再赘述,欲了解源码可以阅读JAVA并发之AQS详解2.4节

2.2 使用demo

场景:老师需要4个学生到讲台上填写一张表,但是老师只有2支笔,因此同一时刻只能保证2个学生拿到笔进行填写,没有拿到笔的学生只能等前面的学生填写完毕,再去拿笔进行填写。

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
package com.lscherish;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class SemaphoreDemo {
// 2支笔
private static Semaphore semaphore = new Semaphore(2, true);

public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(5);
// 5个学生
for (int i = 1; i <=5; i++) {
service.execute(() -> {
try {
System.out.println("同学"+Thread.currentThread().getId() + "想要拿到笔===");
semaphore.acquire();
System.out.println("同学"+Thread.currentThread().getId() + "拿到笔---");
System.out.println("同学"+Thread.currentThread().getId() + "填写中...");
TimeUnit.SECONDS.sleep(2);
System.out.println("同学"+Thread.currentThread().getId() + "填写完毕,马上归还笔。。。");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
}
});
}
service.shutdown();
}
}

得到结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
同学10想要拿到笔===
同学10拿到笔---
同学10填写中...
同学11想要拿到笔===
同学11拿到笔---
同学11填写中...
同学12想要拿到笔===
同学13想要拿到笔===
同学14想要拿到笔===
同学10填写完毕,马上归还笔。。。
同学11填写完毕,马上归还笔。。。
同学13拿到笔---
同学13填写中...
同学12拿到笔---
同学12填写中...
同学13填写完毕,马上归还笔。。。
同学14拿到笔---
同学14填写中...
同学12填写完毕,马上归还笔。。。
同学14填写完毕,马上归还笔。。。

3 CyclicBarrier

  • 在之前的介绍中,我们知道CountDownLatch可以实现多个线程协调,在所有指定线程完成后,主线程才执行任务。

  • 和CountDownLatch一样,在CyclicBarrier类的内部有一个计数器,每个线程在到达屏障点的时候都会调用await方法将自己阻塞,此时计数器会减1,当计数器减为0的时候所有因调用await方法而被阻塞的线程将被唤醒。

  • 但是,CountDownLatch有个缺陷,这点JDK的文档中也说了:他只能使用一次。在有些场合,似乎有些浪费,需要不停的创建 CountDownLatch实例,JDK在CountDownLatch的文档中向我们介绍了CyclicBarrier——循环栅栏,或者叫循环屏障,同步屏障。

  • CyclicBarrier采用一种屏障的方式来控制线程,让所有线程停在某一点。先到的线程将处于阻塞的状态,直到许可都发出去了才会往下执行。

  • CyclicBarrier并不是严格意义上的基于AQS实现的,他只不过持有一个ReentrantLock和Condition,重用了AQS的部分逻辑来完善自身。

3.1 属性

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
/** 定义一个排他锁 */
private final ReentrantLock lock = new ReentrantLock();

/** 创建一个等待队列 ,利用它来对线程进行阻塞 */
private final Condition trip = lock.newCondition();

/** 等待线程的数量,该值在构造时进行赋值。*/
private final int parties;

/* 当栅栏被释放后执行的线程 */
private final Runnable barrierCommand;

/** 当前的一代线程组 他只有一个成员变量来标识当前的barrier是否已“损坏”
* Generation代表栅栏的当前代,就像玩游戏时代表的本局游戏,利用它可以实现循环等待。*/
private Generation generation = new Generation();

private static class Generation {
boolean broken = false;
}

/**
* 内部计数器,它的初始值和parties相同,以后随着每次await方法的调用而减1,直到减为0就将所有线程唤醒
* count值是在创建新的一代线程时被重置。
*/
private int count;

如果将CyclicBarrier比作一个牌桌,需要xx位选手都到齐后才能够发牌

  • condition就像是为已经到达,在等待开局的玩家准备的休息室或者等待区
  • lock就是进入等待区大门的锁
  • parties是表示一共需要多少位选手才能开局
  • count表示现在还差多少位选手才能开局。
  • generation表示当前的牌局
  • barrierCommand表示人到齐后进行的活动,可自定义,如果是斗地主,那么barrierCommand就是斗地主的逻辑,如果是炸金花,那就是炸金花的逻辑。

3.2 构造器

1
2
3
4
5
6
7
8
9
10
public CyclicBarrier(int parties, Runnable barrierAction) {
if (parties <= 0) throw new IllegalArgumentException();
this.parties = parties;
this.count = parties;//计数器count的初始值被设置为parties
this.barrierCommand = barrierAction;
}

public CyclicBarrier(int parties) {
this(parties, null);
}

3.3 重要方法

CyclicBarrier 的最重要的方法就是await方法,和CountDownLatch的await方法一样,该方法会将当前线程阻塞,就像是树立了一个栅栏,将线程挡住了,只有所有的线程都到了这个栅栏上,栅栏才会打开。

下面的注解,我们都用斗地主的比喻来描述,使得代码逻辑更加便于理解

3.3.1 await()

1
2
3
4
5
6
7
public int await() throws InterruptedException, BrokenBarrierException {
try {
return dowait(false, 0L);
} catch (TimeoutException toe) {
throw new Error(toe); // cannot happen
}
}
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
private int dowait(boolean timed, long nanos)
throws InterruptedException, BrokenBarrierException,
TimeoutException {
final ReentrantLock lock = this.lock;
lock.lock();//上锁,表示每次只能有一个玩家会进入等待区
try {
final Generation g = generation;//获取“当前这局牌局”
if (g.broken)// 如果这局牌局损坏,取消了,抛出异常
throw new BrokenBarrierException();

if (Thread.interrupted()) {// 如果这位玩家临时不玩了(线程中断了),抛出异常
breakBarrier();// 将当前这局牌局(Generation)的损坏状态设置为 true
// 并将count重置为parties的值
// 唤醒其他阻塞在此栅栏上的线程

//相当于这局斗地主还没开就被强行取消了
//要把把这局游戏状态置为取消,然后将还差多少位选手的数量重置为需要的总数量,
//并告诉等待区的玩家:这局取消了,大家出来吧。
throw new InterruptedException();
}

int index = --count;//到这里,说明有一个玩家进入等待区了,表示还差的玩家数量减一
if (index == 0) { // 如果是0 ,离 开局还差的玩家数量是0,表示你刚好是最后一个到齐的人,大家就等你了
boolean ranAction = false;
try {
final Runnable command = barrierCommand;//既然人到齐了,开搞,将设置好的活动取出来
if (command != null)//如果有设置好的活动
command.run();//开搞这个活动,斗地主的斗地主,炸金花的炸金花
ranAction = true;//打个标记,command内容我们玩耍过了
//这里需要注意,我们在哪里搞呢?其实可以理解为在休息室里搞,因为执行command的时候,这局的玩家还困在休息室(阻塞)当中。


nextGeneration();
//开启新局,包括:请现在在等待区的玩家们醒来,你们的牌局结束了,快醒过来离开这里吧
//生成一个新的牌局
//将离新局还差的人数置为需要的总人数
return 0;
} finally {
if (!ranAction)//如果玩耍过程除了问题导致标记没打上
breakBarrier();//就认为有异常,把这局牌局取消,大伙解散
}
}

// loop until tripped, broken, interrupted, or timed out
for (;;) {//如果到这里了,说明上面的if (index == 0) 你没进去,现在不是三缺一在等你,还有其他人要等
try {
if (!timed)//如果没有时间限制,那么久进入休息室等待
trip.await();//进休息室等吧,注意,到时候出来的时候,也是从这里出来
else if (nanos > 0L)//如果开局时间有时限,则等待指定时间
nanos = trip.awaitNanos(nanos);//时间一过,玩家等不到开局,我就走
} catch (InterruptedException ie) {//如果遇到中断异常,也就等同于遇到当前玩家有急事不能玩的情况
if (g == generation && ! g.broken) {//如果这个玩家是当前正在进行的牌局的玩家,并且当前牌局没有被取消
breakBarrier();//那么就手动取消,解散大伙,因为有个玩家退出了,大家玩不成了
throw ie;
} else {
// We're about to finish waiting even if we had not
// been interrupted, so this interrupt is deemed to
// "belong" to subsequent execution.
Thread.currentThread().interrupt();//否则,说明这个玩家不是当前正在进行的牌局的玩家
//标记个中断状态,往上传递,不影响现在正在进行的牌局
}
}

if (g.broken)
throw new BrokenBarrierException();

if (g != generation)//如果牌局过期了,新牌局已经开始了,说明是正常的
//我从休息室(trip.await()方法)出来,出来的时候新牌局开始了,那么返回index
return index;
// 如果有时间限制,且时间小于等于0,取消牌局,并抛出异常
if (timed && nanos <= 0L) {
breakBarrier();
throw new TimeoutException();
}
}
} finally {
lock.unlock();//你进入休息室,不管是一进来就可以开局,立刻打完牌局,还是需要在里面继续等,都要在上锁后解锁。
}
}

3.3.2 reset()

reset方法可以重置CyclicBarrier至初始状态

1
2
3
4
5
6
7
8
9
10
public void reset() {
final ReentrantLock lock = this.lock;
lock.lock();
try {
breakBarrier(); // break the current generation
nextGeneration(); // start a new generation
} finally {
lock.unlock();
}
}
1
2
3
4
5
6
7
8
9
10

/**
* Sets current barrier generation as broken and wakes up everyone.
* Called only while holding lock.
*/
private void breakBarrier() {
generation.broken = true;
count = parties;
trip.signalAll();
}
1
2
3
4
5
6
7
8
9
10
11
/**
* Updates state on barrier trip and wakes up everyone.
* Called only while holding lock.
*/
private void nextGeneration() {
// signal completion of last generation
trip.signalAll();
// set up next generation
count = parties;
generation = new Generation();
}

JAVA中断机制

Posted on 2019-08-29 | In JAVA , JAVA线程与并发控制 |
Words count in article: 3.5k | Reading time ≈ 14

1 中断的含义

有人说中断是让某个线程停止运行的意思,那这如何解释中断可以用来唤醒阻塞中的线程呢?有人说中断是唤醒线程的意思,但很多时候我们确实使用它来停止线程。这到底是怎么回事?

其实中断的意思是:用强制的方式来改变线程的状态,一般使用抛出异常的方式,强行获取到CPU执行权,至于改变状态后,线程是唤醒还是停止,则取决于后续的逻辑:

  • 如果你在catch块捕获异常并什么都不处理,那么等于线程被唤醒了。
  • 如果你一直向上抛出异常,或者在catch块中优雅退出,那么实现的就是将线程停止的目的。

2 中断机制

java并未提供任何直接中断某线程的方法,只提供了中断机制。即线程A向线程B发出“请你改变状态”的请求(线程B也可以自己给自己发送此请求),但线程B并不会立刻停止运行,而是自行选择合适的时机以自己的方式响应中断,当然也可以忽略中断。

也就是说java的中断不能直接控制线程,而是需要被中断的线程自己决定怎么处理,好比是父母叮嘱在外的子女注意身体,但子女是否注意身体,怎么注意身体,则完全取决于他们自己。

3 中断的相关方法

3.1 interrupt()方法和基本概念

interrupt status(中断状态):请记住这个术语,中断机制就是围绕着这个字段来工作的。在Java源码中代表中断状态的字段是Thread类中的:

1
private volatile Interruptible blocker;

对“Interruptible”这个类不需要深入分析,对于“blocker”变量有以下几个操作。

  1.默认blocker=null; ®1

  2.调用方法“interrupt0();”将会导致“该线程的中断状态将被设置(JDK文档中术语)”。®2

  3.再次调用“interrupt0();”将会导致“其中断状态将被清除(同JDK文档中术语)”®3

interrupt()方法并不会中断正在运行的线程,当你的线程正在正常运行时,这个时候调用t.interrupt,除了给该线程置了一个标志位,其他什么反应都没有。当然,如果你在run方法中有判断这个标志位,当其中断为true时有优雅退出的逻辑,或者像很多jdk实现的抛出一个InterruptException异常,利用异常使线程结束,那则另当别论。

“中断“这个词有误区,在语言层面,我们不会真的中断一个运行中的线程,但中断确实可以“中断轻量级阻塞”或者说“唤醒轻量级阻塞”。

“轻量级阻塞”就是调用 Object 类的 wait()、wait(long) 或 wait(long, int) 方法,或者线程类的 join()、join(long)、join(long, int)、sleep(long) 或 sleep(long, int) 方法这几个函数,所造成的线程阻塞。此时线程是处于Waiting状态,会响应t.interrupt,中断阻塞,并直接抛出InterruptException异常。

3.1.1 为什么调用interrupt()并不能中断线程?

我们来看interrupt方法源码

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
/**
* Interrupts this thread.
*
* <p> Unless the current thread is interrupting itself, which is
* always permitted, the {@link #checkAccess() checkAccess} method
* of this thread is invoked, which may cause a {@link
* SecurityException} to be thrown.
*
* <p> If this thread is blocked in an invocation of the {@link
* Object#wait() wait()}, {@link Object#wait(long) wait(long)}, or {@link
* Object#wait(long, int) wait(long, int)} methods of the {@link Object}
* class, or of the {@link #join()}, {@link #join(long)}, {@link
* #join(long, int)}, {@link #sleep(long)}, or {@link #sleep(long, int)},
* methods of this class, then its interrupt status will be cleared and it
* will receive an {@link InterruptedException}.
*
* 如果线程堵塞在object.wait、Thread.join和Thread.sleep,将会抛出InterruptedException,同时清除线程的中断状态;
*
*
* <p> If this thread is blocked in an I/O operation upon an {@link
* java.nio.channels.InterruptibleChannel InterruptibleChannel}
* then the channel will be closed, the thread's interrupt
* status will be set, and the thread will receive a {@link
* java.nio.channels.ClosedByInterruptException}.
*

*如果该线程被阻塞在InterruptibleChannel 的I/O操作上,调用该方法,该channel会被关闭,中断状态会被设置,
*并且线程会收到ClosedByInterruptException异常
*
* <p> If this thread is blocked in a {@link java.nio.channels.Selector}
* then the thread's interrupt status will be set and it will return
* immediately from the selection operation, possibly with a non-zero
* value, just as if the selector's {@link
* java.nio.channels.Selector#wakeup wakeup} method were invoked.
*
*如果该线程被阻塞在nio的Selector上,调用该方法,中断状态会被设置,
*并且线程会携带一个非零值立刻返回(其实就是调用了java.nio.channels.Selector#wakeup方法)
*
*
* <p> If none of the previous conditions hold then this thread's interrupt
* status will be set. </p>
*
* <p> Interrupting a thread that is not alive need not have any effect.
*
* @throws SecurityException
* if the current thread cannot modify this thread
*
* @revised 6.0
* @spec JSR-51
*/
public void interrupt() {
if (this != Thread.currentThread())
checkAccess();

synchronized (blockerLock) {
Interruptible b = blocker;
if (b != null) {
interrupt0(); // Just to set the interrupt flag,只是设置标记
// Interruptible的interrupt方法在AbstractInterruptibleChannel和AbstractSelector类中有各自的实现
//故而如当前方法的注释说的那样(当前方法注释中中文翻译的部分),interrupt对这两种场景有效
//但对于普通线程,其实绝大多数不是上面的中断场景。
//因为thread默认注入的不是AbstractInterruptibleChannel和AbstractSelector类的实现
b.interrupt(this);
return;
}
}
interrupt0();
}

如上是Java源码中的代码,由此我们看出问题的答案。线程的blocker字段(也就是interrupt status)默认是null(®1)。调用interrupt()方法时,只是运行了interrupt0()(设置中断标记),并没有进入if语句,所以没调用真正执行中断的代码b.interrupt().

3.1.2 interrupt()方法使线程提前结束阻塞状态

interrupt()方法使线程提前结束阻塞状态,为什么呢?我们用sleep方法使线程阻塞

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* Causes the currently executing thread to sleep (temporarily cease
* execution) for the specified number of milliseconds, subject to
* the precision and accuracy of system timers and schedulers. The thread
* does not lose ownership of any monitors.
*
* @param millis
* the length of time to sleep in milliseconds
*
* @throws IllegalArgumentException
* if the value of {@code millis} is negative
*
* @throws InterruptedException
* if any thread has interrupted the current thread. The
* <i>interrupted status</i> of the current thread is
* cleared when this exception is thrown.
*/
public static native void sleep(long millis) throws InterruptedException;

该本地方法会将blocker置为®2,因而此时调用interrupt方法,将执行if中的语句:

1
2
3
4
5
if (b != null) {
interrupt0(); // Just to set the interrupt flag
b.interrupt(this);
return;
}

其中,interrupt0();方法将中断标志清除,即置为®3。然后调用核心语句b.interrupt(this),真正的打断线程,并且抛出InterruptedException。

我们来看下b.interrupt(this)为什么能中断线程。

除了上文说到的Interruptible.interrupt在AbstractInterruptibleChannel和AbstractSelector类中有两种实现外,对于正常Thead类,我们注入的Interruptible b并非AbstractInterruptibleChannel和AbstractSelector类中的Interruptible实现。(这两类只在特定场景实现,可见下文:AbstractInterruptibleChannel类中的中断实现)

来看看Thread.blockedOn方法,Thread本身不会初始化它的blocker字段,blocker的值是注入进来的。

1
2
3
4
5
6
7
/* Set the blocker field; invoked via sun.misc.SharedSecrets from java.nio code
*/
void blockedOn(Interruptible b) {
synchronized (blockerLock) {
blocker = b;
}
}

注释说的很明白,该方法被via sun.misc.SharedSecrets调用,注入的b所执行的interrupt(this)方法,其实是native方法,定义在jvm.cpp中:

1
2
3
4
5
6
7
8
9
10
11
12
13
JVM_ENTRY(void, JVM_Interrupt(JNIEnv* env, jobject jthread))
JVMWrapper("JVM_Interrupt");

// Ensure that the C++ Thread and OSThread structures aren't freed before we operate
oop java_thread = JNIHandles::resolve_non_null(jthread);
MutexLockerEx ml(thread->threadObj() == java_thread ? NULL : Threads_lock);
// We need to re-resolve the java_thread, since a GC might have happened during the
// acquire of the lock
JavaThread* thr = java_lang_Thread::thread(JNIHandles::resolve_non_null(jthread));
if (thr != NULL) {
Thread::interrupt(thr);
}
JVM_END

JVM_Interrupt对参数进行了校验,然后直接调用Thread::interrupt:

1
2
3
4
5
void Thread::interrupt(Thread* thread) {
trace("interrupt", thread);
debug_only(check_for_dangling_thread_pointer(thread);)
os::interrupt(thread);
}

Thread::interrupt调用os::interrupt方法实现,os::interrupt方法定义在os_linux.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void os::interrupt(Thread* thread) {
assert(Thread::current() == thread || Threads_lock->owned_by_self(),
"possibility of dangling Thread pointer");

//获取系统native线程对象
OSThread* osthread = thread->osthread();

if (!osthread->interrupted()) {
osthread->set_interrupted(true);
//内存屏障,使osthread的interrupted状态对其它线程立即可见
OrderAccess::fence();
//前文说过,_SleepEvent用于Thread.sleep,线程调用了sleep方法,则通过unpark唤醒
ParkEvent * const slp = thread->_SleepEvent ;
if (slp != NULL) slp->unpark() ;
}

//_parker用于concurrent相关的锁,此处同样通过unpark唤醒
if (thread->is_Java_thread())
((JavaThread*)thread)->parker()->unpark();
//synchronized同步块和Object.wait() 唤醒
ParkEvent * ev = thread->_ParkEvent ;
if (ev != NULL) ev->unpark() ;

}

由此可见,interrupt其实就是通过ParkEvent的unpark方法唤醒对象;

3.2 isInterrupted()方法和interrupted()方法

  • isInterrupted()只是判断自己是否已经被置上中断标志
  • interrupted()方法判断自己是否已经被置上中断标志。并且清除线程的中断状态

接下来说一下”interrupted()”和”isInterrupted()”两个方法的相同点和不同点。在这之前看一下源码中两个方法的代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
public static boolean interrupted() {
return currentThread().isInterrupted(true);
}
public boolean isInterrupted() {
return isInterrupted(false);
}
/**
* Tests if some Thread has been interrupted. The interrupted state
* is reset or not based on the value of ClearInterrupted that is
* passed.
*/
private native boolean isInterrupted(boolean ClearInterrupted);

相同点都是判断线程的interrupt status是否被设置,若被设置返回true,否则返回false.区别有两点:

  • 一:前者是static方法,调用者是current thread,而后者是普通方法,调用者是this current.
  • 二:它们其实都调用了Java中的一个native方法isInterrupted(boolean ClearInterrupted); 不同的是前者传入了参数true,后者传入了false.
  • 意义就是:前者将清除线程的interrupt state(®3),调用后者线程的interrupt state不受影响。

4 AbstractInterruptibleChannel类中的中断实现

如果一个nio通道实现了InterruptibleChannel接口,就可以响应interrupt()中断,其原理就在InterruptibleChannel接口的抽象实现类AbstractInterruptibleChannel的方法begin()中:

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
protected final void begin() {
if (interruptor == null) {
interruptor = new Interruptible() {
public void interrupt(Thread target) {
synchronized (closeLock) {
if (!open)
return;
open = false;
interrupted = target;
try {
AbstractInterruptibleChannel.this.implCloseChannel();
} catch (IOException x) { }
}
}};
}
blockedOn(interruptor);//设置当前线程的blocker为interruptor
Thread me = Thread.currentThread();
if (me.isInterrupted())
interruptor.interrupt(me);
}

protected final void end(boolean completed)
throws AsynchronousCloseException
{
blockedOn(null);//设置当前线程的blocker为null
Thread interrupted = this.interrupted;
//如果发生中断,Thread.interrupt方法会调用Interruptible的interrupt方法,
//设置this.interrupted为当前线程
if (interrupted != null && interrupted == Thread.currentThread()) {
interrupted = null;
throw new ClosedByInterruptException();
}
if (!completed && !open)
throw new AsynchronousCloseException();
}

以上述代码为例,nio通道的ReadableByteChannel每次执行阻塞方法read()前,都会执行begin(),把Interruptible回调接口注册到当前线程上。当线程中断时,Thread.interrupt()触发回调接口Interruptible关闭io通道,导致read方法返回,最后在finally块中执行end()方法检查中断标记,抛出ClosedByInterruptException;

5 AbstractSelector类中的中断实现

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
//java.nio.channels.spi.AbstractSelector
protected final void begin() {
if (interruptor == null) {
interruptor = new Interruptible() {
public void interrupt(Thread ignore) {
AbstractSelector.this.wakeup();
}};
}
AbstractInterruptibleChannel.blockedOn(interruptor);
Thread me = Thread.currentThread();
if (me.isInterrupted())
interruptor.interrupt(me);
}
protected final void end() {
AbstractInterruptibleChannel.blockedOn(null);
}
//sun.nio.ch.class EPollSelectorImpl
protected int doSelect(long timeout) throws IOException {
......
try {
begin();
pollWrapper.poll(timeout);
} finally {
end();
}
......
}

6 对于中断的处理

既然Java中断机制只是设置被中断线程的中断状态,那么被中断线程该做些什么?

6.1 中断状态的管理

  • 一般说来,当可能阻塞的方法声明中有抛出InterruptedException则暗示该方法是可中断的,如BlockingQueue#put、BlockingQueue#take、Object#wait、Thread#sleep等,如果程序捕获到这些可中断的阻塞方法抛出的InterruptedException或检测到中断后,这些中断信息该如何处理?一般有以下两个通用原则:

    • 如果遇到的是可中断的阻塞方法抛出InterruptedException,可以继续向方法调用栈的上层抛出该异常
    • 如果是检测到中断,则可清除中断状态并抛出InterruptedException,使当前方法也成为一个可中断的方法。
  • 若有时候不太方便在方法上抛出InterruptedException,比如要实现的某个接口中的方法签名上没有throws InterruptedException,这时就可以捕获可中断方法的InterruptedException并通过Thread.currentThread.interrupt()来重新设置中断状态。如果是检测并清除了中断状态,亦是如此。

一般的代码中,尤其是作为一个基础类库时,绝不应当吞掉中断(即捕获到InterruptedException后在catch里什么也不做,清除中断状态后又不重设中断状态也不抛出InterruptedException等)。因为吞掉中断状态会导致方法调用栈的上层得不到这些信息。

当然,凡事总有例外的时候,当你完全清楚自己的方法会被谁调用,而调用者也不会因为中断被吞掉了而遇到麻烦,就可以这么做。

  • 总得来说,就是要让方法调用栈的上层获知中断的发生。假设你写了一个类库,类库里有个方法amethod,在amethod中检测并清除了中断状态,而没有抛出InterruptedException,作为amethod的用户来说,他并不知道里面的细节,如果用户在调用amethod后也要使用中断来做些事情,那么在调用amethod之后他将永远也检测不到中断了,因为中断信息已经被amethod清除掉了。

6.2 中断的响应

  • 程序里发现中断后该怎么响应?这就得视实际情况而定了。有些程序可能一检测到中断就立马将线程终止,有些可能是退出当前执行的任务,继续执行下一个任务……作为一种协作机制,这要与中断方协商好,当调用interrupt会发生些什么都是事先知道的,如做一些事务回滚操作,一些清理工作,一些补偿操作等。若不确定调用某个线程的interrupt后该线程会做出什么样的响应,那就不应当中断该线程。

dump文件生成和分析查看

Posted on 2019-08-27 | In JAVA , JAVA监控和调优 |
Words count in article: 242 | Reading time ≈ 1

1,生成dump文件:

1.1手动生成dump文件:

如图:

命令如下:

ps -ef | grep list-app | grep -v grep

jmap -dump:file=test.hprof,format=b 3307

1.2自动生成dump文件:

1. -XX:+HeapDumpOnOutOfMemoryError 
当OutOfMemoryError发生时自动生成 Heap Dump 文件。

    这是一个非常有用的参数,因为当你需要分析Java内存使用情况时,往往是在OOM(OutOfMemoryError)发生时。

2. -XX:+HeapDumpBeforeFullGC 
当 JVM 执行 FullGC 前执行 dump。

3. -XX:+HeapDumpAfterFullGC 
当 JVM 执行 FullGC 后执行 dump。

4. -XX:+HeapDumpOnCtrlBreak 
交互式获取dump。在控制台按下快捷键Ctrl + Break时,JVM就会转存一下堆快照。

5. -XX:HeapDumpPath=d:\test.hprof 
指定 dump 文件存储路径。

注意:JVM 生成 Heap Dump 的时候,虚拟机是暂停一切服务的。如果是线上系统执行 Heap Dump 时需要注意。

2,查看dump文件

推荐使用jdk自带的visualVM,其在JDK_HOME/bin目录下,可搜:jvisualvm。注意:windows系统是jvisualvm.exe文件。然后装入快照即可。

JAVA并发之AQS详解

Posted on 2019-08-23 | In JAVA , JAVA线程与并发控制 |
Words count in article: 16.8k | Reading time ≈ 68

1. 概念

在分析 Java 并发包java.util.concurrent源码的时候,少不了需要了解AbstractQueuedSynchronizer(以下简写AQS)这个抽象类,因为它是Java并发包的基础工具类,是实现ReentrantLock、CountDownLatch、Semaphore、FutureTask等类的基础,许多实现都依赖其所提供的队列式同步器。

AQS的功能可以分为两类:独占锁和共享锁。它的所有子类中,要么实现并使用了它独占锁的API,要么使用了共享锁的API,而不会同时使用两套API,即便是它最有名的子类ReentrantReadWriteLock,也是通过两个内部类:读锁和写锁,分别实现的两套API来实现的,到目前为止,我们只需要明白AQS在功能上有独占锁和共享锁两种功能即可。

1.1 如何使用AQS

AQS管理一个关于状态信息的单一整数,该整数可以表现任何状态。比如, Semaphore用它来表现剩余的许可数,ReentrantLock用它来表现拥有它的线程已经请求了多少次锁;FutureTask用它来表现任务的状态(尚未开始、运行、完成和取消)。

AQS有众多方法,大致可以分为两类:

1.1.1 需要子类实现的方法(模板方法)

  1. tryAcquire(int arg):独占式的获取锁,返回值是boolean类型的,true代表获取锁,false代表获取失败。

  2. tryRelease(int arg):释放独占式同步状态,释放操作会唤醒其后继节点获取同步状态。

  3. tryAcquireShared(int arg):共享式的获取同步状态,返回大于0代表获取成功,否则就是获取失败。

  4. tryReleaseShared(int arg):共享式的释放同步状态。

  5. isHeldExclusively():判断当前的线程是否已经获取到了同步状态。

这些方法是子类实现时可能实现的方法,通过上面的这些方法来判断是否获取了锁,然后再通过AQS本身的方法执行获取锁与未获取锁的过程。

以上方法不需要全部实现,根据获取的锁的种类可以选择实现不同的方法,支持独占(排他)获取锁的同步器应该实现tryAcquire、 tryRelease、isHeldExclusively而支持共享获取的同步器应该实现tryAcquireShared、tryReleaseShared。

1.1.2 AQS本身的实现的方法

  1. acquire(int arg)/acquireInterruptibly(int arg):独占式的获取锁操作,独占式获取同步状态都调用这个方法,通过子类实现的tryAcquire方法判断是否获取了锁。Interruptibly后缀的方法带有中断异常的签名,表示可以响应中断异常,无此后缀的acquire方法则通过重新标记中断状态的方式响应中断。

  2. acquireShared(int arg)/acquireSharedInterruptibly:共享式的获取锁操作,在读写锁中用到,通过tryAcquireShared方法判断是否获取到了同步状态。Interruptibly后缀的方法带有中断异常的签名,表示可以响应中断异常,无此后缀的acquire方法则通过重新标记中断状态的方式响应中断。

  3. release(int arg):独占式的释放同步状态,通过tryRelease方法判断是否释放了独占式同步状态。

  4. releaseShared(int arg):共享式的释放同步状态,通过tryReleaseShared方法判断是否已经释放了共享同步状态。

从这两类方法可以看出,AQS为子类定义了一套获取锁和释放锁以后的操作,而具体的如何判断是否获取锁和释放锁都是交由不同的子类自己去实现其中的逻辑,这也是Java设计模式之一:模板模式的实现。有了AQS我们就可以实现一个属于自己的Lock。

2.源码分析(基于jdk1.8)

2.1 AQS类

首先是类图↓

从图中可以看出来,AbstractQueuedSynchronizer内部维护了一个Node节点类和一个ConditionObject内部类。Node内部类是一个双向的FIFO队列,用来保存阻塞中的线程以及获取同步状态的线程,而ConditionObject对应的是后面要讲的Lock中的等待和通知机制。↓

我们可以看到,AQS类是JUC框架的基石。为什么这么说?我们以ReentrantLock为例,ReentrantLock把所有Lock接口的操作都委派到一个自定义的内部类Sync类上,该类继承自AbstractQueuedSynchronizer。同时该类又有两个子类,NonfairSync 和 FairSync,实现非公平锁和公平锁。↓

1
2
公平锁:线程获取锁的顺序和调用lock的顺序一样,FIFO,先到先得;  
非公平锁:线程获取锁的顺序和调用lock的顺序无关,全凭运气。

同样的,CountDownLatch、Semaphore等其他类,也自定义了自己的Sync类和NonfairSync和FairSync,以达到功能的差异化。

2.2 AQS的属性

2.2.1 状态位state

AQS用的是一个32位的整型来表示同步状态的,它是用volatile修饰的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* The synchronization state.
* 在互斥锁中它表示着线程是否已经获取了锁,0未获取,1已经获取了,大于1表示重入数。
* 同时AQS提供了getState()、setState()、compareAndSetState()方法来获取和修改该值:
*/
private volatile int state;

protected final int getState() {
return state;
}

protected final void setState(int newState) {
state = newState;
}

protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

2.2.2 当前持有独占锁的线程

1
2
3
4
// 代表当前持有独占锁的线程,举个最重要的使用例子,因为锁可以重入
// reentrantLock.lock()可以嵌套调用多次,所以每次用这个来判断当前线程是否已经拥有了锁
// if (currentThread == getExclusiveOwnerThread()) {state++}
private transient Thread exclusiveOwnerThread; //继承自AbstractOwnableSynchronizer

2.2.3 获取锁的阻塞队列——CLH同步队列

2.2.3.1 head和tail属性

AQS内部维护着一个FIFO的CLH队列,用来保存阻塞中的线程以及获取同步状态的线程,每个node都封装着一个独立的线程,head指向的node可以简单理解为当前持有锁的线程,tail指向了等待队列的链尾。正因为head指向当前持有锁的线程,所以,真正的等待队列,不包括head。

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

/**
* Head of the wait queue, lazily initialized. Except for
* initialization, it is modified only via method setHead. Note:
* If head exists, its waitStatus is guaranteed not to be
* CANCELLED.
*/
private transient volatile Node head;

/**
* Tail of the wait queue, lazily initialized. Modified only via
* method enq to add new wait node.
*/
private transient volatile Node tail

因为是CLH队列,所以AQS并不支持基于优先级的同步策略。至于为何要选择CLH队列,主要在于CLH锁相对于MSC锁,他更加容易处理cancel和timeout,同时他进出队列快、检查是否有线程在等待也非常容易(head != tail,头尾指针不同)。当然相对于原始的CLH队列锁,ASQ采用的是一种变种的CLH队列锁:

  1. 原始CLH使用的locked自旋,而AQS的CLH则是在每个node里面使用一个状态字段来控制阻塞,而不是自旋。

  2. 为了可以处理timeout和cancel操作,每个node维护一个指向前驱的指针。如果一个node的前驱被cancel,这个node可以前向移动使用前驱的状态字段。

  3. head结点使用的是傀儡结点。虽然node对象(封装了线程)在获取到锁的时候,逻辑会将这个node置为head,看起来head表示的是当前正在拥有锁的node节点的意思。但看setHead方法就能知道,node赋值为head后,node封装的thread对象被清空,node成为一个空对象。

我们来看看这个队列结点的实现:

2.2.3.2 node实现

我们来看看node的源码 ↓

众多字段,我们一个一个来看:

1
2
3
4
5
6
/**共享模式是允许多个线程可以获取同一个锁,而独占模式则一个锁只能被一个线程持有,其他线程必须要等待。**/

// 标识节点当前在共享模式下
static final Node SHARED = new Node();
// 标识节点当前在独占模式下
static final Node EXCLUSIVE = null;

下面的几个int常量是给waitStatus字段使用的,表示节点现在的状态

1
2
3
4
5
/**代表此线程取消了争抢这个锁
*场景:当该线程等待超时或者被中断,需要从同步队列中取消等待,则该线程被置1,即被取消(这里该线程在取消之前是等待状态)
*被取消状态的结点不应该去竞争锁,只能保持取消状态不变,不能转换为其他状态。处于这种状态的结点会被踢出队列
**/
static final int CANCELLED = 1;
1
2
3
4
5
6
/** waitStatus value to indicate successor's thread needs unparking 
* 场景:后继的节点处于等待状态,当前节点的线程如果释放了同步状态或者被取消(当前节点状态置为-1),
* 将会唤醒后继节点,使后继节点的线程得以运行;
* 当一个节点的状态为SIGNAL时就意味着在等待获取同步状态
**/
static final int SIGNAL = -1;
1
2
3
4
/** waitStatus value to indicate thread is waiting on condition
* 场景:节点处于等待队列中,节点线程等待在Condition上,当其他线程对Condition调用了signal()方法后,CONDITION状态的结点将从Condition等待队列转移到同步队列中,等待获取同步锁。
*/
static final int CONDITION = -2;
1
2
3
4
5
6
/**
* waitStatus value to indicate the next acquireShared should
* unconditionally propagate
* 场景:表示下一次共享式同步状态获取将会被无条件的被传播下去(读写锁中存在的状态,代表后续还有资源,可以多个线程同时拥有同步状态)
*/
static final int PROPAGATE = -3;

然后就是状态字段的主角了,上面的这些常量,都是给该字段赋值用的 ↓

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
/**
* Status field, taking on only the values:
* SIGNAL: The successor of this node is (or will soon be)
* blocked (via park), so the current node must
* unpark its successor when it releases or
* cancels. To avoid races, acquire methods must
* first indicate they need a signal,
* then retry the atomic acquire, and then,
* on failure, block.
* CANCELLED: This node is cancelled due to timeout or interrupt.
* Nodes never leave this state. In particular,
* a thread with cancelled node never again blocks.
* CONDITION: This node is currently on a condition queue.
* It will not be used as a sync queue node
* until transferred, at which time the status
* will be set to 0. (Use of this value here has
* nothing to do with the other uses of the
* field, but simplifies mechanics.)
* PROPAGATE: A releaseShared should be propagated to other
* nodes. This is set (for head node only) in
* doReleaseShared to ensure propagation
* continues, even if other operations have
* since intervened.
* 0: None of the above
*
* The values are arranged numerically to simplify use.
* Non-negative values mean that a node doesn't need to
* signal. So, most code doesn't need to check for particular
* values, just for sign.
*
* The field is initialized to 0 for normal sync nodes, and
* CONDITION for condition nodes. It is modified using CAS
* (or when possible, unconditional volatile writes).
*/
volatile int waitStatus;

前面说过,在AQS中,我们维护了一个链表,故而node节点中,也定义了前后驱。

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
/**
* Link to predecessor node that current node/thread relies on
* for checking waitStatus. Assigned during enqueuing, and nulled
* out (for sake of GC) only upon dequeuing. Also, upon
* cancellation of a predecessor, we short-circuit while
* finding a non-cancelled one, which will always exist
* because the head node is never cancelled: A node becomes
* head only as a result of successful acquire. A
* cancelled thread never succeeds in acquiring, and a thread only
* cancels itself, not any other node.
* 前驱节点,当节点加入同步队列的时候被设置(尾部添加)
*/
volatile Node prev;

/**
* Link to the successor node that the current node/thread
* unparks upon release. Assigned during enqueuing, adjusted
* when bypassing cancelled predecessors, and nulled out (for
* sake of GC) when dequeued. The enq operation does not
* assign next field of a predecessor until after attachment,
* so seeing a null next field does not necessarily mean that
* node is at end of queue. However, if a next field appears
* to be null, we can scan prev's from the tail to
* double-check. The next field of cancelled nodes is set to
* point to the node itself instead of null, to make life
* easier for isOnSyncQueue.
* 后继节点
*/
volatile Node next;

当然,还有node节点的最重要主角:被封装的线程

1
2
3
4
5
6
/**
* The thread that enqueued this node. Initialized on
* construction and nulled out after use.
* 节点封装的线程
*/
volatile Thread thread;

还有一个很特殊的后驱节点,这个后驱,负责维护node节点参与的第二个链表(第一个就是AQS的同步等待链表)——condition等待链表,至于什么是condition,我们最后再来讨论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Link to next node waiting on condition, or the special
* value SHARED. Because condition queues are accessed only
* when holding in exclusive mode, we just need a simple
* linked queue to hold nodes while they are waiting on
* conditions. They are then transferred to the queue to
* re-acquire. And because conditions can only be exclusive,
* we save a field by using special value to indicate shared
* mode.
* 指向condition等待队列中的下一个节点,或特殊值SHARED。
* 因为condition等待队列只有在保持独占模式时才被访问,
* 所以我们只需要一个简单的链表来在节点等待condition时保存节点。
* 然后将它们转移到队列中以重新获取。 并且因为条件只能是独占的,所以我们通过使用特殊值来指示共享模式来保存字段。
*/

// nextWaiter还有一个作用,是区别当前CLH队列是 ‘独占锁’队列 还是 ‘共享锁’队列 的标记
// 若nextWaiter=SHARED,则CLH队列是“独占锁”队列;
// 若nextWaiter=EXCLUSIVE,(即nextWaiter=null),则CLH队列是“共享锁”队列。
Node nextWaiter;

你可以把node节点简单看作 thread + waitStatus + pre + next 四个属性的封装,从本质上来说,这是没错的,node几乎所有的api也都服务于这四个属性。

2.3 AQS的独占锁实现(以ReentrantLock的公平锁和非公平锁为例)

从上图可以看到,AQS的实现有许多种,我们以最典型的在ReentrantLock类内部定义的公平锁FairSync和非公平锁NonFairSync为例,来探讨一下AQS独占模式的同步原理。(ReentrantLock是典型的独占锁,真正管理锁的也是其内部实现类FairSync或者NonFairSync)

独占锁是独占的,排他的,因此在独占锁中有一个exclusiveOwnerThread属性,用来记录当前持有锁的线程。

我们一般怎么使用ReentrantLock呢?很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void needLockFunction() {
// 比如我们对下面这段逻辑加锁
reentrantLock.lock();
// 通常,lock 之后紧跟着 try 语句
try {
// 这块代码同一时间只能有一个线程进来(获取到锁的线程),
// 其他的线程在lock()方法上阻塞,等待获取到锁,再进来
// 执行代码...
// 执行代码...
// 执行代码...
} finally {
// 释放锁
reentrantLock.unlock();
}
}

所以,我们可以从lock方法看起

2.3.1 公平锁的加锁逻辑

reentrantLock的lock方法调用的是reentrantLock内部的sync字段的lock方法,sync字段在reentrantLock的构造方法中就开始初始化默认是非公平锁:

ReentrantLock默认使用非公平锁是基于性能考虑,公平锁为了保证线程规规矩矩地排队,需要增加阻塞和唤醒的时间开销。如果直接插队获取非公平锁,跳过了对队列的处理,速度会更快

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Creates an instance of {@code ReentrantLock}.
* This is equivalent to using {@code ReentrantLock(false)}.
*/
public ReentrantLock() {
sync = new NonfairSync();
}

/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}

2.3.1.1 FairSync.lock()

我们来看看FairSync的lock(),很简单直接调用了acquire(); ↓

1
2
3
final void lock() {
acquire(1);
}

2.3.1.2 AQS.acquire()

所以lock()的重点都在acquire(),FairSync调用了AQS类中实现的acquire();

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
/**
* Acquires in exclusive mode, ignoring interrupts. Implemented
* by invoking at least once {@link #tryAcquire},
* returning on success. Otherwise the thread is queued, possibly
* repeatedly blocking and unblocking, invoking {@link
* #tryAcquire} until success. This method can be used
* to implement method {@link Lock#lock}.
*
* @param arg the acquire argument. This value is conveyed to
* {@link #tryAcquire} but is otherwise uninterpreted and
* can represent anything you like.
*/
public final void acquire(int arg) {//记住此时arg=1
// 我们看到,这个方法,如果tryAcquire(arg) 返回true, 也就结束了。方法return,逻辑继续执行。
// 否则,acquireQueued方法会将当前线程压到队列中,阻塞在里面

//首先执行tryAcquire(1)一下,名字上就知道,这个只是试一试
// 因为有可能直接就成功了呢,也就不需要进队列排队了。
if (!tryAcquire(arg) &&
// tryAcquire(arg)没有成功,这个时候需要把当前线程挂起,放到阻塞队列中。
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
//能到这里,说明当前线程已经从acquireQueued中返回了,即出阻塞队列了
//此时当前线程是被其他线程唤醒的,被设置了中断状态。

//selfInterrupt方法就是调用:Thread.currentThread().interrupt();中断当前线程
//之所以有这个逻辑,是因为acquire方法没有中断异常的签名,
//所以为了外层能继续响应中断,需要感应到acquireQueued内的中断,并在这里重新设置中断状态
//可抛出中断异常的acquire方法为acquireInterruptibly(),除了中断处理不同以外,其他实现与acquire大同小异。
//同理,其他带有Interruptibly后缀的方法,都是原方法的中断模式实现。
selfInterrupt();
}

一句话总结,AbstractQueuedSynchronizer.acquire()方法的作用是:先尝试获取锁,若成功则不用进队列阻塞,逻辑往下走(其实就是返回了)。否则封装当前线程为node,塞进队列,然后在acquireQueued方法中一直尝试,先期会自旋,如果在自旋期间内获得锁了,那么返回,返回结果是false,表示不需要调用selfInterrupt()做自我中断。如果是阻塞后才获取锁,返回,返回结果是true,表示要设置自我中断。(只是设置中断状态,至于到底何时中断,由线程本身决定)


2.3.1.3 FairSync.tryAcquire()

AQS类中实现的acquire()又调用了FairSync中实现的tryAcquire(1),我们来看看:

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
38
39
40
41
    /**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
// 尝试直接获取锁,返回值是boolean,代表是否获取到锁
// 返回true:1.没有线程在等待锁;2.重入锁,线程本来就持有锁,也就可以理所当然可以直接获取
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
//获取AQS的状态,我们之前说过,0未加锁,1已经加锁了,大于1表示重入数。
int c = getState();
//如果AQS未加锁
if (c == 0) {
// 虽然此时此刻锁是可以用的,但是这是公平锁,既然是公平,就得讲究先来后到,
// 看看有没有别人在队列中等了半天了,没有才改变状态
//hasQueuedPredecessors()根据 当前线程是否是等待队列的第一个 来判断是否有等待更久的节点。
//记住,我们说过,等待队列不包括头结点head
//所以hasQueuedPredecessors判断的是当前线程是否为head的后驱next
if (!hasQueuedPredecessors() &&
//执行到这里,意味着队列中没有更老的节点,那么CAS置换状态为1或者大于1(重入时大于1)
compareAndSetState(0, acquires)) {
//设置拥有独占锁的线程是当前线程
//代码就一行,exclusiveOwnerThread = thread;
setExclusiveOwnerThread(current);
//获取到锁
return true;
}
}
//否则,说明AQS是被持有锁的状态,先判断持有锁的是不是自己
else if (current == getExclusiveOwnerThread()) {
//如果是自己,那么说明自己重入了,status+1
int nextc = c + acquires;
if (nextc < 0)
//nextc只有在重入次数超过int值上限,导致溢出为负时才会达到,报错超过最大锁计数
throw new Error("Maximum lock count exceeded");
setState(nextc);
//获取到锁
return true;
}
//上述逻辑都不满足,未获取到锁
return false;
}

一句话总结,FairSync.tryAcquire()方法的作用是:如果未加锁,那么判断自己是不是队列的头名,若是,设置独占锁线程,获得锁。否则,判断加锁的人是不是自己,如果是,那么重入,status+1,设置独占锁线程,获得锁。再否则,返回false,占锁失败。


2.3.1.2 AQS.addWaiter()

看完了tryAcquire方法,我们知道在acquire方法中,如果tryAcquire方法返回false,即没有获取到锁,那么将会执行addWaiter,将当前线程封装为node,addWaiter()是AbstractQueuedSynchronizer的方法:

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
/**
* Creates and enqueues node for current thread and given mode.
*
* @param mode Node.EXCLUSIVE for exclusive, Node.SHARED for shared
* @return the new node
*/
//mode值有Node.EXCLUSIVE 和Node.SHARED,表示封装为独占模式还是分享模式
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
//先使用快速入列法来尝试一下,如果失败,则进行更加完备的入列算法.
//只有在必要的情况下才会使用更加复杂耗时的算法,也就是乐观的态度
Node pred = tail;//tail为队尾指针
if (pred != null) {
//进行入列操作
node.prev = pred;//该节点的前趋指针指向tail
if (compareAndSetTail(pred, node)) {//cas将尾指针指向该节点
pred.next = node;//如果成功,让旧列尾节点的next指针指向该节点
return node;
}
}
//cas失败,或在pred == null时调用enq
enq(node);
return node;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private Node enq(final Node node) {
for (;;) { //cas无锁算法的标准for循环,不停的尝试,直到成功入队
Node t = tail;
if (t == null) { //t == null为真的话,说明队列为空,要初始化一个空队列,即只存在一个哨兵node的队列
//还是那句话,head是一个哨兵的作用,并不代表某个要获取锁的线程节点,所以并没有将node赋给head
//而是new了一个无关紧要的新node
//compareAndSetHead方法,用cas,期望head为null时将其更新为new Node()
if (compareAndSetHead(new Node()))
tail = head;
//注意,这里没有return,执行完后,还是要继续for循环,下一次,必定走else逻辑。
} else {
//和addWaiter中一致,不过有了外侧的无限循环,不停的尝试,相当于自旋锁
//将node的前驱改为原来的队尾node
node.prev = t;
//新创建的节点指向队列尾节点,毫无疑问并发情况下这里会有多个新创建的节点指向队列尾节点
//基于这一步的CAS,不管前一步有多少新节点都指向了尾节点,这一步只有一个能真正入队成功
//其他的都必须重新执行循环体
if (compareAndSetTail(t, node)) {//改变队尾指针的值
t.next = node;//原本的队尾的后驱设为node
return t;
}
}
}
}

一句话总结,AbstractQueuedSynchronizer.addWaiter()方法的作用是将当前线程封装为node,并将node节点塞入等待队列,塞入逻辑包括节点前后驱,head和tail指针的维护,以及必要时对空列表的初始化。然后返回封装好的node。


2.3.1.3 AQS.acquireQueued()

回到acquire方法

1
2
3
4
5
if (!tryAcquire(arg) &&
// tryAcquire(arg)没有成功,这个时候需要把当前线程挂起,放到阻塞队列中。
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
//该方法就是调用:Thread.currentThread().interrupt();中断当前线程
selfInterrupt();

addWaiter()将当前线程封装为node,并将node节点塞入等待队列,紧接着,执行的是AbstractQueuedSynchronizer.acquireQueued()方法,这个方法就是入列后的node节点在队列中等待的逻辑,是自旋等待还是阻塞等待。

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
/**
* Acquires in exclusive uninterruptible mode for thread already in
* queue. Used by condition wait methods as well as acquire.
*
* @param node the node
* @param arg the acquire argument
* @return {@code true} if interrupted while waiting
*/
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
//不会一直自旋,如果判断还没轮到自己,那么线程会阻塞在这个for循环中的parkAndCheckInterrupt里面
for (;;) {
final Node p = node.predecessor();
//node的前驱p是head,就说明,node是将要获取锁的下一个节点.
if (p == head && tryAcquire(arg)) {//所以再次尝试获取锁
setHead(node);//如果成功,那么就将自己设置为head,前文说过,node会被置为傀儡,然后赋给head
p.next = null; // help GC
failed = false;
return interrupted;
//此时,还没有进入阻塞状态,所以直接返回false,表示不需要中断调用后面的selfInterrupt函数
//此时方法返回后,在acquire方法内也走完了所有逻辑,acquire方法返回,执行lock操作后的业务逻辑。
}
//判断是否要进入阻塞状态。
//如果shouldParkAfterFailedAcquire返回true,表示需要进入阻塞,则调用parkAndCheckInterrupt,进行阻塞;
//否则表示还可以再次尝试获取锁,继续进行for循环
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
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
38
39
40
41
42
43
44
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL) //前一个节点在等待锁释放的通知,所以还没那么快轮到自己,当前节点可以阻塞
return true;
//static final int CANCELLED = 1;节点状态中,只有CANCELLED大于0
//前一个节点处于取消获取锁的状态,所以,可以跳过去
if (ws > 0) {
do {
//前驱的前驱变为自己的前驱,即前驱在链表中被剔除了。
node.prev = pred = pred.prev;
//如果前驱都是取消,那么一直剔除。
} while (pred.waitStatus > 0);
pred.next = node;
} else {
//将上一个节点的状态设置为signal,返回false。
//但因为前驱被置为SIGNAL,外面的for循环下一次获取还失败后,该node也会返回true了。
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}

private final boolean parkAndCheckInterrupt() {
LockSupport.park(this); //将AQS对象自己传入
//LockSupport.park()也能响应中断信号,但是跟Thread.sleep()之类不同的是它不会抛出InterruptedException
//那怎么知道线程是被unpark还是被中断的呢,根据线程的interrupted status。
//如果线程是被中断退出阻塞的那么该值被设置为true,通过Thread的interrupted和isInterrupted方法都能获取该值,不再赘述。

//线程waiting在park方法中,这个return返回,肯定是线程被唤醒后才会执行
//此时它表示的意思为:在waiting中时,是否是被中断唤醒了。如果是unpark,那么Thread.interrupted()为false。中断唤醒才为true;
//我们知道处理中断时最好不要将其吃掉,要么抛出新的中断异常,要么重新设置interrupted status


//结合上文,我们知道这里如果返回true,那么外面acquireQueued方法在获取到锁的时候也会返回interrupted = true;
//表示是中断唤醒的,届时acquireQueued方法会调用selfInterrupt();
//selfInterrupt()方法其实就是调用:Thread.currentThread().interrupt();中断当前线程
return Thread.interrupted();
}

public static void park(Object blocker) {
Thread t = Thread.currentThread();
setBlocker(t, blocker);//设置阻塞对象,用来记录线程被谁阻塞的,用于线程监控和分析工具来定位
UNSAFE.park(false, 0L);//让当前线程不再被线程调度,就是当前线程不再执行.
setBlocker(t, null);
}

最后我们回到acquireQueued方法的最后一步,finally模块。这里是针对锁资源获取失败以后做的一些善后工作,翻看上面的代码,其实能进入这里的就是tryAcquire()方法抛出异常,也就是说AQS框架针对开发人员自己实现的获取锁操作如果抛出异常,也做了妥善的处理,一起来看下源码:

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
38
39
40
//传入的方法参数是当前获取锁资源失败的节点
private void cancelAcquire(Node node) {
// 如果节点不存在则直接忽略
if (node == null)
return;

node.thread = null;

// 跳过所有已经取消的前置节点,跟上面的那段跳转逻辑类似
Node pred = node.prev;
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;
//这个是前置节点的后继节点,由于上面可能的跳节点的操作,所以这里可不一定就是当前节点
Node predNext = pred.next;

//把当前节点waitStatus置为取消,这样别的节点在处理时就会跳过该节点
node.waitStatus = Node.CANCELLED;
//如果当前是尾节点,则直接删除,即出队
//注:这里不用关心CAS失败,因为即使并发导致失败,该节点也已经被成功删除
if (node == tail && compareAndSetTail(node, pred)) {
compareAndSetNext(pred, predNext, null);
} else {
int ws;
if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) {
Node next = node.next;
if (next != null && next.waitStatus <= 0)
//这里的判断逻辑很绕,具体就是如果当前节点的前置节点不是头节点且它后面的节点等待它唤醒(waitStatus小于0)
//再加上如果当前节点的后继节点没有被取消就把前置节点跟后置节点进行连接,相当于删除了当前节点
compareAndSetNext(pred, predNext, next);
} else {
//进入这里,要么当前节点的前置节点是头结点,要么前置节点的waitStatus是PROPAGATE,直接唤醒当前节点的后继节点
unparkSuccessor(node);
}

node.next = node; // help GC
}
}

一句话总结,AbstractQueuedSynchronizer.acquireQueued()方法会一直循环来尝试获取锁,但并非一直自旋,而是会在每一次循环判断是否要进入阻塞,如果通过判断前置节点状态得知无法很快得到锁(这其中会将cancel状态的node踢出队列),那么该node会进入阻塞。

阻塞被唤醒后,如果是中断唤醒的,那么会将这个中断唤醒的标记往外层传,并再次尝试获取锁,如果还是失败,继续进入上述判断阻塞逻辑。直到获取到锁。

同时,如果tryAcquire()方法抛出异常,也会有体面的退出逻辑。

2.3.2 非公平锁的加锁逻辑

2.3.2.1 NonFairSync.lock()

再来看看NonFairSync的lock();

1
2
3
4
5
6
7
8
9
10
11
/**
* Performs lock. Try immediate barge, backing up to normal
* acquire on failure.
*/
final void lock() {//不管三七二十一,先尝试获取一次锁
//CAS,成功了就设置拥有独占锁的线程是当前线程
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}

2.3.2.2 AQS.acquire()

调用AQS.acquire()方法,前文已说过,不再赘述。可通过侧边导航快速回看。

2.3.2.3 NonFairSync.nonfairTryAcquire()

AQS.acquire()中重要的tryAcquire方法,非公平锁定义了自己的实现:

1
2
3
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
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
/**
* Performs non-fair tryLock. tryAcquire is implemented in
* subclasses, but both need nonfair try for trylock method.
*/
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//和公平锁一样,获取AQS的状态,我们之前说过,0未加锁,1已经加锁了,大于1表示重入数。
//如果AQS未加锁
if (c == 0) {
//注意,这里和公平锁的区别来了,还记得公平锁中这里的实现么?
//公平锁中,如果AQS未加锁,逻辑即便到了这里,也会调用hasQueuedPredecessors()来判断等待线程是否有等了更久的node
//但是非公平锁不管,我到了这里是我本事,本身不讲究公平,直接CAS设置锁状态,抢锁
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//否则,说明AQS是被持有锁的状态,先判断持有锁的是不是自己
else if (current == getExclusiveOwnerThread()) {
//如果是自己,那么说明自己重入了,status+1,同样的,如果int值溢出,抛异常
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
//上述逻辑都不满足,未获取到锁
return false;
}

一句话总结,NonFairSync.nonfairTryAcquire方法的作用是:如果未加锁,那么直接抢锁,而不是像公平锁一样去检查是否轮到自己。若AQS已经上锁,判断加锁的人是不是自己,如果是,那么重入,status+1,设置独占锁线程,获得锁。再否则,返回false,占锁失败。

剩下的加锁逻辑,则完全和公平锁没有区别了,因为实际都是调用的AQS的addWaiter()和acquireQueued()方法,不再赘述。

2.3.3 释放锁逻辑

释放锁的逻辑,公平锁和非公平锁没有区别,本质都是调用的AQS.release()方法和Sync.tryRelease()方法

2.3.3.1 ReentrantLock.unlock()

1
2
3
public void unlock() {
sync.release(1);
}

2.3.3.2 AQS.release()

1
2
3
4
5
6
7
8
9
10
public final boolean release(int arg) {
if (tryRelease(arg)) {//release就是先调用tryRelease来释放独占性变量。
//释放独占性变量,起始就是将status的值减1,因为acquire时是加1
Node h = head;
if (h != null && h.waitStatus != 0)//是否有等待锁的阻塞线程,0为waitStatus的初始值,表示未赋值任何状态
unparkSuccessor(h);//唤醒head的后继节点
return true;
}
return false;
}

2.3.3.3 AQS.tryRelease()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected final boolean tryRelease(int releases) {
//由于只有一个线程可以获得独占先变量,也只有这个线程才能有效调用unlock,所以所有操作不需要考虑多线程并发
int c = getState() - releases;//对于重入场景,重入数-1,非重入场景,解锁。
if (Thread.currentThread() != getExclusiveOwnerThread())
//如果不是持有独占锁的线程执行unlock,抛出异常。
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {//如果等于0,那么说明锁应该被释放了,否则表示当前线程有重入操作,该次解锁只是一次重入的释放
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}

2.3.3.4 AQS.unparkSuccessor()

unparkSuccessor负责在释放锁的时候,唤醒head的后继节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void unparkSuccessor(Node node) {
//注意这里的node是AQS.release()中塞进来的head节点
int ws = node.waitStatus;
if (ws < 0)//head的状态为非取消
compareAndSetWaitStatus(node, ws, 0);//将head的waitStatus置为0,即没有任何有实质意思的状态

//一般来说,需要唤醒的线程就是head的下一个节点,但是如果它获取锁的操作被取消,或在节点为null时
//就直接继续往后遍历,找到第一个未取消的后继节点.
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
LockSupport.unpark(s.thread);//将对应节点唤醒
}

调用了unpark方法后,进行lock操作被阻塞的线程就恢复到运行状态,就会再次执行acquireQueued中的无限for循环中的操作,再次尝试获取锁。

2.4 AQS的共享锁实现(以Semaphore为例)

前面我们学习了AQS独占锁的逻辑,对于独占锁而言,锁只能被一个线程独占持有,而对于共享锁而言,由于锁是可以被共享的,因此它可以被多个线程同时持有。

AQS的共享锁应用于Semaphore、ReentrantReadWriteLock等实现中,本次我们以Semaphore为例来剖析一下AQS的共享锁

共享锁的实现和独占锁是对应的,我们可以从下面这张表中看出

独占锁 共享锁
tryAcquire(int arg) tryAcquireShared(int arg)
tryAcquireNanos(int arg, long nanosTimeout) tryAcquireSharedNanos(int arg, long nanosTimeout)
acquire(int arg) acquireShared(int arg)
acquireQueued(final Node node, int arg) doAcquireShared(int arg)
acquireInterruptibly(int arg) acquireSharedInterruptibly(int arg)
doAcquireInterruptibly(int arg) doAcquireSharedInterruptibly(int arg)
doAcquireNanos(int arg, long nanosTimeout) doAcquireSharedNanos(int arg, long nanosTimeout)
release(int arg) releaseShared(int arg)
tryRelease(int arg) tryReleaseShared(int arg)
- doReleaseShared()
除了最后一个属于共享锁的doReleaseShared()方法没有对应外,其他的方法,独占锁和共享锁都是一一对应的。

2.4.1 Semaphore

在解析之前,我们先来了解一下Semaphore是什么。

Semaphore也叫信号量,在JDK1.5被引入,用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。还可以用来实现某种资源池,或者对容器施加边界。

打个比喻,Semaphore就像一道阀门,可以控制同时进入某一逻辑的线程数量(构造方法中指定),我们使用acquire方法来争取通行票,使用release方法来归还通行票。通行票只是一个比喻,一般我们称之为许可。

  • Semaphore内部维护了一组虚拟的许可,许可的数量可以通过构造函数的参数指定。
    1
    2
    3
    public Semaphore(int permits) {
    sync = new NonfairSync(permits);
    }
  • 访问特定资源前,必须使用acquire方法获得许可,如果许可数量为0,该线程则一直阻塞,直到有可用许可。
  • 访问资源后,使用release释放许可。
  • Semaphore和ReentrantLock类似,获取许可有公平策略和非公平许可策略,默认情况下使用非公平策略。
    1
    2
    3
    public Semaphore(int permits, boolean fair) {
    sync = fair ? new FairSync(permits) : new NonfairSync(permits);
    }
  • 当初始值为1时,可以用作互斥锁,并具备不可重入的加锁语义。
  • Semaphore将AQS的同步状态(status字段)用于保存当前可用许可的数量。

我们调用Semaphore方法时,其实是在间接调用其内部类或AQS方法执行的。Semaphore类结构与ReetrantLock类相似,内部类Sync继承自AQS,然后其子类FairSync和NoFairSync分别实现公平锁和非公平锁的获取锁方法tryAcquireShared(int arg),而释放锁的tryReleaseShared(int arg)方法则有Sync类实现,因为非公平或公平锁的释放过程都是相同的。

2.4.2 Semaphore公平锁争锁逻辑

不论是公平锁还是非公平锁,Semaphore使用acquire()方法来争锁

1
2
3
public void acquire() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}

acquireSharedInterruptibly方法,是定义在AQS中的,它可以响应中断异常,

1
2
3
4
5
6
7
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())//如果当前线程被中断,抛出中断异常。
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)//尝试获取共享锁,返回值小于0表示获取失败
doAcquireSharedInterruptibly(arg);//未获取成功则加入同步队列等待
}

2.4.2.1 Semaphore.FairSync.tryAcquireShared

我们知道AQS中,try开头的几个方法都是模板方法,需要各个实现自己重写,Semaphore的公平锁实现类FairSync同样实现了自己的tryAcquireShared。

tryAcquire的返回值是个boolean类型,表示是否成功获取到了锁,而tryAcquireShared的返回值是一个int类型,这表示tryAcquireShared的返回含义绝不止是或者否这么简单,它的返回有三种情况:

  • 小于0 : 表示获取锁失败,需要进入等待队列。
  • 等于0 : 表示当前线程获取共享锁成功,但它后续的线程是无法继续获取的。
  • 大于0 : 表示当前线程获取共享锁成功且它后续等待的节点也有可能继续获取共享锁成功。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protected int tryAcquireShared(int acquires) {
for (;;) {//一直执行,直到要么失败,要么成功
//hasQueuedPredecessors我们在上文见过了,它实现在AQS中
//根据 当前线程是否是等待队列的第一个 来判断是否有等待更久的节点。
//因为是公平锁,所以要先判断先来后到
if (hasQueuedPredecessors())
return -1;//还没轮到你,所以获取锁失败,需要进入等待队列
int available = getState();//Semaphore的status表示许可总数量
int remaining = available - acquires;//总数量-索取量
//如果索取量超过剩余量,返回的是小于0,表示获取许可失败。
//如果CAS成功,表示获取许可成功,那么返回剩余量。
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}

一句话总结,FairSync.tryAcquireShared方法的作用是:重复判断是否轮到自己来获取许可了,如果不是,返回获取失败。否则检查剩余量,若许可的剩余量满足索取量,那么CAS获取许可,返回索取后的剩余量。

2.4.2.2 AQS.doAcquireSharedInterruptibly()

acquireSharedInterruptibly中,如果tryAcquireShared获取许可失败,那么逻辑就进入了doAcquireSharedInterruptibly方法中。

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
38
39
40
41
/**
* Acquires in shared interruptible mode.
* @param arg the acquire argument
*/
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
//以共享模式调用addWaiter封装一个node,
//addWaiter前文已分析,具体可见右侧导航栏跳转2.3.1.2 AQS.addWaiter()
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
for (;;) {//重复执行
//获取node的前驱
final Node p = node.predecessor();
if (p == head) {//前驱如果是head,表示自己已经在队首
//tryAcquireShared再尝试一次
int r = tryAcquireShared(arg);
if (r >= 0) {
//这里是重点,获取到锁以后的唤醒操作,后面详细说
setHeadAndPropagate(node, r);
p.next = null; // help GC
failed = false;//获取许可成功
return;
}
}
//自己不在队首,或者取锁失败,调用AQS.shouldParkAfterFailedAcquire判断是否需要阻塞,
//若需要则调用parkAndCheckInterrupt,进行阻塞;
//否则表示还可以再次尝试获取锁,继续进行for循环
//这两个方法前文都已分析,具体可见右侧导航栏跳转2.3.1.3 AQS.acquireQueued()
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
//注意,非Interruptibly后缀的方法,在进入这里的时候是将它们定义的interrupted设为true;
//而Interruptibly后缀的方法则不需要向外传递中断状态,直接抛出中断异常即可
throw new InterruptedException();
}
} finally {
if (failed)
//cancelAcquire方法前文也已分析,体面的退出获取。只有报异常才会来到这里。
cancelAcquire(node);
}
}

一句话总结,AQS.doAcquireSharedInterruptibly方法的作用是:调用addWaiter封装当前线程,然后重复执行取锁逻辑,直到取到锁为止,如果取到锁,设置各个状态并唤醒后继线程,如果没有获取到锁,改变前驱节点状态,将其设置为signal,然后阻塞,等待唤醒。

2.4.2.3 AQS.setHeadAndPropagate()

获取到许可时,逻辑调用了AQS.setHeadAndPropagate(),从方法名就可以看出除了设置新的头结点以外还有一个传递动作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private void setHeadAndPropagate(Node node, int propagate) {
//两个入参,一个是当前成功获取共享锁的节点,一个就是tryAcquireShared方法的返回值
//注意上面说的,它可能大于0也可能等于0(小于0不可能来这)
Node h = head; //将老的头结点记录下来,用来下面if的check
//设置新的头节点,即把当前获取到锁的节点设置为头节点
//可以看到,每个获取到共享锁的线程,都会被设置为head
//获取到共享锁的线程有多个,head表示的是最近获取到共享锁的那个node
setHead(node);//此时head==node了
//这里意思是:有两种情况是需要执行唤醒操作
//1.propagate > 0 表示调用方指明了后继节点还可以被唤醒,因为许可还有
//2.头节点后面的节点需要被唤醒(waitStatus<0),不论是老的头结点(h = head)还是新的头结点(node)
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;//此时node已经是head了,node的next就是在等待队列的队首
//如果当前节点的后继节点是共享类型或者没有后继节点,则进行唤醒
//这里可以理解为除非明确指明不需要唤醒(后继等待节点是独占类型),否则都要唤醒
if (s == null || s.isShared())
//doReleaseShared不会释放锁,别被名字误导,它会唤醒后续节点,讲到释放锁时重点说
//为什么有新的node获取到共享锁之后,要唤醒后续的那个节点争锁呢?
//因为propagate>0,也就是说还有剩余的许可没被占用
doReleaseShared();
}
}
1
2
3
final boolean isShared() {
return nextWaiter == SHARED;
}

我们知道,在条件队列中,nextWaiter是指向条件队列中的下一个节点的,它将条件队列中的节点串起来,构成了单链表。但是在同步队列中,我们只用prev/next属性来串联节点,形成双向链表,nextWaiter属性在这里只起到一个标记作用,不会串联节点,这里不要被Node SHARED = new Node()所指向的空节点迷惑,这个空节点并不属于同步队列,不代表任何线程,它只起到标记作用,仅仅用作判断节点是否处于共享模式的依据。

一句话总结,AQS.setHeadAndPropagate方法的作用是:设置head节点,并在许可还有剩余或者后继新旧head节点的后驱都应该被唤醒时(waitStatus < 0),唤醒head的后继,让其参与争锁。

2.4.3 Semaphore非公平锁争锁逻辑

前文说过,不论是公平锁还是非公平锁,Semaphore都使用acquire()方法来争锁

1
2
3
public void acquire() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}

acquireSharedInterruptibly方法,是定义在AQS中的,它可以响应中断异常,这个前文介绍过了,不再多说,拷贝过来

1
2
3
4
5
6
7
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())//如果当前线程被中断,抛出中断异常。
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)//尝试获取共享锁,返回值小于0表示获取失败
doAcquireSharedInterruptibly(arg);//未获取成功则加入同步队列等待
}

2.4.3.1 Semaphore.nonfairTryAcquireShared()

acquireSharedInterruptibly方法中的tryAcquireShared是模板方法,在Semaphore的两个内部类NonfairSync和FairSync中有各自的实现,FairSync.tryAcquireShared我们讲过了,我们来看下NonfairSync.tryAcquireShared

1
2
3
protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}

直接调用了nonfairTryAcquireShared方法,该方法定义在Semaphore类中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
final int nonfairTryAcquireShared(int acquires) {
for (;;) {//一直执行,直到要么失败,要么成功
//在FairSync.tryAcquireShared中,此时会判断if (hasQueuedPredecessors())
//如果为true,则返回抢锁失败
//但非公平锁不讲究先来后到,直接争锁
int available = getState();//Semaphore的status表示许可总数量
int remaining = available - acquires;//总数量-索取量
//如果索取量超过剩余量,返回的是小于0,表示获取许可失败。
//如果CAS成功,表示获取许可成功,那么返回剩余量。
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}

一句话总结,Semaphore.nonfairTryAcquireShared方法的作用是:重复检查剩余量,若许可的剩余量满足索取量,那么CAS获取许可,返回索取后的剩余量。

2.4.4 Semaphore释放锁逻辑

2.4.4.1 AQS.releaseShared()

我们使用releaseShared(int arg)方法来释放共享锁:

1
2
3
4
5
6
7
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}

在独占锁模式下,由于头节点就是持有独占锁的节点,在它释放独占锁后,如果发现自己的waitStatus不为0,则它将负责唤醒它的后继节点。

在共享锁模式下,头节点也是持有共享锁的节点(每个获得共享锁的node都会当一段时间的head),在它释放共享锁后,它也应该唤醒它的后继节点,但是值得注意的是,我们在之前的setHeadAndPropagate方法中可能已经调用过该方法了,也就是说它可能会被同一个头节点调用两次,也有可能在我们从releaseShared方法中调用它时,当前的头节点已经易主了。

2.4.4.2 Semaphore.tryReleaseShared()

1
2
3
4
5
6
7
8
9
10
protected final boolean tryReleaseShared(int releases) {
for (;;) {
int current = getState();//当前剩余的许可数
int next = current + releases;//剩余许可数+这次释放的许可数
if (next < current) // overflow //int型溢出才会有这种情况
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next))//成功释放锁,重置剩余许可数
return true;
}
}

一句话总结,Semaphore.tryReleaseShared方法的作用是:一直尝试将锁释放,CAS控制并发,将state值加回来

2.4.4.3 AQS.doReleaseShared()

doReleaseShared是共享锁中最难理解的部分,我们来看一下。

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
38
39
private void doReleaseShared() {
for (;;) {//循环check
Node h = head;//当前的head节点,执行到这里时,head可能是当前线程之前绑定的节点,也可能节点已经易主了
//如果当前线程获取到锁后没有其他线程再获取到共享锁,那么这个head就是之前自己绑定的节点,否则,就不是了。
if (h != null && h != tail) {//等待队列不为空
int ws = h.waitStatus;
//表示后继节点需要被唤醒
if (ws == Node.SIGNAL) {
//将head节点状态置为0,0为默认值,无特殊含义。
//CAS不成功,重新执行for循环判断。
//正是因为这个CAS的存在,保证即便doReleaseShared入口有setHeadAndPropagate跟release两个
//但同一时间也只会唤醒一个后继节点。
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);//CAS成功,执行唤醒操作,唤醒h.next
//注意,这里唤醒h.next后,逻辑就到了if (h == head)这里
}
//如果后继节点暂时不需要唤醒,则把当前节点状态设置为PROPAGATE确保以后可以传递下去
//head的ws什么情况下会等于0?
//1.上面的if会将ws从Node.SIGNAL置为0
//但执行了if,在本次迭代中不会执行else,得等到下次循环,如果期间head节点没有易主,那就没有下次循环。
//如果易主了,这里的h和ws就指向的是新head节点和其waitStatus。
//所以情况1不成立。
//2.当前队列的最后一个节点成为了头节点
//因为只要有新节点入列,都会在shouldParkAfterFailedAcquire把前置节点的waitStatus置为Node.SIGNAL

//当队列里唯一的节点成为了头节点,那什么情况下compareAndSetWaitStatus(h, 0, Node.PROPAGATE)会失败呢?
//答案是:并发时,ws == 0判断刚过,就有新节点将ws改为Node.SIGNAL。
//但别忘了,for循环的第一个if,也是最外层的if是if (h != null && h != tail)
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue;
}
//如果这个方法期间head没有易主,说明没有其他线程在这个期间获取到共享锁,它就可以break了。
//如果head易主,说明方法执行过程中其他线程获取到了锁,
if (h == head) // loop if head changed
break;
}
}

该方法最难理解的是

1
2
3
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue;

为什么要有这个continue呢??

根据上述的注解,我们知道要进入continue,得满足如下条件:

  • 队列中的最后一个节点成为了head,不然head的ws不会为0;
  • 判断if (h != null && h != tail)时,队列中不能只有head一个节点,否则不会进入if语句。
  • 判断else if (ws == 0时,ws还要为0,但紧接着compareAndSetWaitStatus(h, 0, Node.PROPAGATE))时却不能成功,说明此时ws不为0;

所以总结下来,只有一个极其短暂的瞬间,逻辑有可能走到这里:

  • 队列中的最后一个节点成为了head
  • 在当前线程中(我们称线程A)判断if (h != null && h != tail)之前,等待队列中新加入一个节点(执行入列逻辑的线程我们称为线程B),该节点即head的后驱(等待队列不包括head),使得if (h != null && h != tail)通过。
  • 此时线程B中,head的后驱尝试获取锁失败,于是准备入列,刚刚执行addWaiter加入队列,还没有将head的ws改写,ws还是为0
  • 然后线程A中我们判断if (ws == Node.SIGNAL) 不成立,进而判断else if (ws == 0 ,逻辑通过
  • 紧接着线程A判断compareAndSetWaitStatus(h, 0, Node.PROPAGATE)之前的一瞬间,ws在线程B中被head后驱通过调用shouldParkAfterFailedAcquire改写
  • 改写后,线程B中,head后驱在下个循环就会进入阻塞了,而此时线程A执行compareAndSetWaitStatus(h, 0, Node.PROPAGATE)失败,触发了这个continue,而不是去进行if (h == head)判断。
    • 为什么要有这个continue?因为此时如果没有continue,那么进行if (h == head)判断,可能(其实是大概率)会通过,逻辑直接break了(break后就不会再唤醒后继了),逻辑A将唤醒后驱的逻辑break,而head的后驱却要阻塞,这使得head的后驱短时间内可能无人唤醒。
    • 有了这个continue,那么下次循环,if (ws == Node.SIGNAL)通过,会对head的后继进行一次unpark,如果此时线程B已经park,那么唤醒正好。如果线程B慢悠悠的还没执行到park,那么我们知道,当我们unpark一个并没有被park的线程时,该线程在下一次调用park方法时就不会被阻塞,这时候如果线程B再执行park,也不会被阻塞了。

(上述只是个人猜想总结,如有缺漏或错误,还请指正)

一句话总结,AQS.doReleaseShared方法的作用是:运用精细的逻辑控制,重复尝试唤醒head节点的后继。

2.5 AQS condition实现

2.5.1 condition是什么

在了解condition之前,我们先来说说java源生的condition:

用对象锁时,我们有Object.wait()、Object.notify()和Object.notifyAll()这三个api来操作对象的内部条件队列:

它的用法一般是这样(一个典型的生产者消费者模型,mBuf表示缓存队列):

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
static Object lockObj = new Object();

public class Producer{

public void produce() {
synchronized (lockObj) {
while (mBuf.isFull()) {
try {
lockObj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
mBuf.add();
lockObj.notifyAll();
}
}
}

public class Consumer{
public void consume() {
synchronized (lockObj) {
while (mBuf.isEmpty()) {
try {
lockObj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
mBuf.remove();
lockObj.notifyAll();
}
}
}

以Producer生产者为例,在实际中,我们会创建多个Producer实例出来,表示多个的生产者,他们并发的往mBuf里面add产品。

此时,使用一个静态的对象lockObj作为锁,那么produce产品的时候,所有的生产者都在争这个锁。

如果mBuf已经满了,那么lockObj.wait()方法会将获取到锁的当前线程休眠,并且释放锁。当前线程会被封装,然后进入到lockObj锁对象对应的monitor对象的waitSet队列中。

当消费者消费了一个产品,导致mBuf不满了,消费者会调用lockObj.notifyAll()唤醒所有在lockObj锁对象对应monitor对象的waitSet队列中的线程。

和notifyAll()类似,lockObj.notify()表示唤醒一个线程,只不过notify()方法到底唤醒哪一个,则由操作系统决定。

总结一下:

  • 调用某个锁对象的wait()方法,会释放当前的锁,然后让出CPU,最后让当前线程阻塞,有个前提,当前线程必须拥有此锁对象的monitor(即锁)。
  • 调用某个锁对象的notify()方法能够唤醒一个正在等待这个锁对象的monitor的线程,如果有多个线程都在等待这个锁对象的monitor,则只能唤醒其中一个线程;
  • 调用notifyAll()方法能够唤醒所有正在等待这个锁对象的monitor的线程;

2.5.1.1 简介

condition又叫做条件队列,是AQS的一个内部实现,它能实现线程之间的通信,condition对象维护了一个FIFO的单向node链表,我们称之为条件等待队列(单向体现在只有后驱)(上文中争锁的队列我们叫做同步队列或者阻塞队列,以示区分,但其实他们的元素都是AQS.Node对象)。

我们在利用condition可以特定的场景下使线程休眠或被唤醒,和wait、notify实现的功能是一样的,但condition将休眠的对象放入等待队列,使其变得更为灵活。比如我们知道notify无法唤醒特定的一个线程,而是随机唤醒一个线程,但condition基于等待队列就能做到唤醒特定的一个线程(队首的线程),甚至我们还可以定义多个condition,使其能够互不干扰的休眠或唤醒。

condition是AQS的一个内部类

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

private static final long serialVersionUID = 7373984972572414691L;
...
...
public class ConditionObject implements Condition, java.io.Serializable {
private static final long serialVersionUID = 1173984872572414699L;
/** First node of condition queue. */
private transient Node firstWaiter;
/** Last node of condition queue. */
private transient Node lastWaiter;

/**
* Creates a new {@code ConditionObject} instance.
*/
public ConditionObject() { }
...
...
}
}

2.5.1.2 语义

从语义上来看,AQS实现的锁机制对应的是对象内置锁(synchronized语义对应的同步机制)的语义,这很好理解,在AQS这种显性锁出现前,我们使用java内置的对象的monitor来当做锁(对象锁或者类锁,即synchronized关键字和 (lock)等 ),AQS使用精妙的逻辑,重新显性的实现了锁机制。

同理的,AQS.condition也是相对于内置锁的条件队列的一种显性存在。

  • 用AQS实现上述条件锁时,我们有condition.await(),condition.signal(),condition.signalAll()这三个api来操作AQS实现的锁的内部条件队列,分别对应Object的wait(),notify()和notifyAll()方法:
    • Condition提供了await()方法将当前线程阻塞,对应Object的wait()。线程调用await()方法前必须获取锁,调用await()方法时,将线程构造成节点加入条件等待队列,同时释放锁,并挂起当前线程。
    • Condition提供了signal()方法支持当前线程将已经阻塞的队首线程唤醒,让他们重新争锁。对应Object的notify()。当前线程调用signal()方法前也必须获取锁,当执行signal()方法时将条件等待队列的节点移入到同步队列,当线程退出临界区释放锁的时候,唤醒同步队列的首个节点。
    • Condition提供了signalAll()方法支持当前线程将已经阻塞的线程全部唤醒,让他们重新争锁。对应Object的notifyAll()方法。当前线程调用signalAll()方法前也必须获取锁,当执行signalAll()方法时将条件等待队列的节点全部移入到同步队列,当线程退出临界区释放锁的时候,唤醒同步队列的首个节点。

2.5.1.3 condition的使用

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
static Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();

public void doSomething(){
lock.lock();
System.out.println(String.format("%s线程,获取到锁了",Thread.currentThread().getName()));
try {
System.out.println(String.format("%s线程,await",Thread.currentThread().getName()));
TimeUnit.SECONDS.sleep(2L); //模拟耗时业务逻辑执行
condition.await(); //await
System.out.println(String.format("%s线程,await被唤醒",Thread.currentThread().getName()));
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(String.format("%s线程,业务执行完毕",Thread.currentThread().getName()));
lock.unlock();
}

public static void main(String[] args) throws InterruptedException {
ReentrantLockTest test = new ReentrantLockTest();
int total = 1;
while (total>0){
Thread t = new Thread(()->{
test.doSomething();
},"T-"+total);
t.start();

TimeUnit.MILLISECONDS.sleep(200L); //让子线程T-1率先获取到锁
lock.lock();
System.out.println(String.format("%s线程,获取到锁了",Thread.currentThread().getName()));
test.condition.signal();
System.out.println(String.format("%s线程,signal",Thread.currentThread().getName()));
lock.unlock();
total--;
}
}

得到返回

1
2
3
4
5
6
T-1线程,获取到锁了
T-1线程,await
main线程,获取到锁了
main线程,signal
T-1线程,await被唤醒
T-1线程,业务执行完毕

2.5.2 condition源码解析

看完demo,我们来看源码,以ReentrantLock实现的condition为例,看看ReentrantLock是如何在AQS的condition上继承和实现的。

2.5.2.1 ReentrantLock.newCondition

如之前的demo所示,我们使用如下方式来获得condition对象

1
2
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();

来看下newCondition()

1
2
3
final ConditionObject newCondition() {
return new ConditionObject();
}

2.5.2.2 AQS.ConditionObject

newCondition()返回的就是一个ConditionObject对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ConditionObject implements Condition, java.io.Serializable {
private static final long serialVersionUID = 1173984872572414699L;
/** First node of condition queue. */
private transient Node firstWaiter;
/** Last node of condition queue. */
private transient Node lastWaiter;

/**
* Creates a new {@code ConditionObject} instance.
*/
public ConditionObject() { }
...
...
...
}

condition通过firstWaiter字段和lastWaiter字段组成了一个单向队列,即等待队列,并且和AQS的同步队列相比,他们虽然都是FIFO的,但等待队列的首节点并不具备同步队列首节点的传播通知的功能。而且首节点是第一个阻塞的线程节点。

  • firstWaiter字段
    • 首个等待节点
  • lastWaiter字段
    • 最后一个等待节点

2.5.2.3 AQS.await()

我们先来看一下condition将当前线程挂起的方法:await()

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
38
39
40
public final void await() throws InterruptedException {
if (Thread.interrupted())//当前线程加入等待队列前先判断是否被中断,若是,得响应中断
throw new InterruptedException();
Node node = addConditionWaiter();//将同步队列中的当前线程构造成一个新的节点添加到等待队列尾部,后面详讲
//释放node的同步状态(即释放锁)并返回释放之前的同步状态,后面详讲
//因为condition的使用基于当前线程已经获取到锁了,所以release不会报错IllegalMonitorStateException
//IllegalMonitorStateException:未持有锁的线程去释放锁时报该异常
int savedState = fullyRelease(node);
int interruptMode = 0;
//第一次进入while,判断被唤醒的node是否已经转移到AQS的同步队列中,不在,则park线程,转移成功才退出循环
//后面被唤醒后的线程,将从await()方法中的while循环中退出
while (!isOnSyncQueue(node)) {//(isOnSyncQueue(Node node)方法返回true,表示节点状态不为condition,且已经在同步队列中)
//挂起线程,之后如果被被unpark或者发生中断时,也从此方法返回
LockSupport.park(this);
//被唤醒后来到这里,

//checkInterruptWhileWaiting方法有点绕,但其实不重要
//只需知道是为了发生中断的时候能够让node跳出while循环
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
//如果看到这里,说明被唤醒了,但不是因为发生中断
//这时还需要继续判断是否进入同步队列,如果没有则继续循环,继续park。
}
//跳出循环了,说明被唤醒了,调用AQS的acquireQueued()方法加入到获取同步状态的竞争中。State还是之前释放锁时保存的status
//interruptMode != THROW_IE:如果不是因为中断异常而退出循环的话;
//AQS的acquireQueued()前文已分析,具体可见右侧导航栏跳转2.3.1.3 AQS.acquireQueued()
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
//处理中断要么抛出中断异常,要么重设置中断态。
//这里排除了抛出异常,那么标记一下后面如果要处理中断,应该采用重置中断态的方式
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // clean up if cancelled
// 从队首开始往后溯,清空条件等待队列中节点状态不为 CONDITION 的节点
unlinkCancelledWaiters();
if (interruptMode != 0)
// 如果线程已经被中断,则根据之前获取的interruptMode的值来判断是继续中断还是抛出异常
//reportInterruptAfterWait方法作用:
//如果之前是抛出中断异常,那么这里要再次抛出
//如果之前是给自己设置中断状态,那么这里也要设置
reportInterruptAfterWait(interruptMode);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Adds a new waiter to wait queue.
* @return its new wait node
*/
private Node addConditionWaiter() {//该方法比较简单,构造node,并加入condition等待队列
Node t = lastWaiter;//获取等待队列队尾
// If lastWaiter is cancelled, clean out.
if (t != null && t.waitStatus != Node.CONDITION) {//如果队尾被取消了,将其清理
unlinkCancelledWaiters();//该方法从队首开始往后清理被取消的节点
t = lastWaiter;
}
Node node = new Node(Thread.currentThread(), Node.CONDITION);//构造node
if (t == null)//如果等待队列为空,加入队首
firstWaiter = node;
else
t.nextWaiter = node;//否则加入队尾
lastWaiter = node;
return node;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* Invokes release with current state value; returns saved state.
* Cancels node and throws exception on failure.
* @param node the condition node for this wait
* @return previous sync state
*/
final int fullyRelease(Node node) {//fully的意思是将持有的锁完全释放:也就是说,即便有n次重入或许可,也要全部释放。
boolean failed = true;
try {
int savedState = getState();//因为要释放所有重入次数或者许可,所以要获取总量
if (release(savedState)) {//调用AQS的release一次释放总量,详见上文2.3.3.3 AQS.tryRelease()
failed = false;
return savedState;
} else {
//IllegalMonitorStateException:未持有锁的线程去释放锁时报该异常
//这里如果
throw new IllegalMonitorStateException();
}
} finally {
if (failed)
//释放锁失败的场景,只有IllegalMonitorStateException异常。这时可以认为该节点已经被取消。
node.waitStatus = Node.CANCELLED;
}
}

2.5.2.4 AQS.signal()

看完了挂起线程的方法,我们来看下唤醒线程的方法,signal方法会唤醒condition等待队列中队首的那个线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* Moves the longest-waiting thread, if one exists, from the
* wait queue for this condition to the wait queue for the
* owning lock.
*
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
public final void signal() {
if (!isHeldExclusively())//该方法AQS未实现,ReentrantLock的实现是判断当前线程是不是持有锁的线程,是,则true
throw new IllegalMonitorStateException();
Node first = firstWaiter;//获取等待队列的队首
if (first != null)
doSignal(first);//下面我们来看看这个方法
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* Removes and transfers nodes until hit non-cancelled one or
* null. Split out from signal in part to encourage compilers
* to inline the case of no waiters.
* @param first (non-null) the first node on condition queue
*/
private void doSignal(Node first) {
do {
if ( (firstWaiter = first.nextWaiter) == null)
lastWaiter = null;
first.nextWaiter = null;
} while (!transferForSignal(first) &&
(first = firstWaiter) != null);
}

doSignal进来就是一个do-while,我们先看transferForSignal,回头再看doSignal

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
/**
* 将一个node从等待队列转移至同步队列,
* 如果成功,返回true
* @param node the node
* @return true if successfully transferred (else the node was
* cancelled before signal)
*/
final boolean transferForSignal(Node node) {
/*
* If cannot change waitStatus, the node has been cancelled.
*/
//尝试将node的状态置为无意义的0,如果失败,说明该节点已经被取消。
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
return false;

/*
* Splice onto queue and try to set waitStatus of predecessor to
* indicate that thread is (probably) waiting. If cancelled or
* attempt to set waitStatus fails, wake up to resync (in which
* case the waitStatus can be transiently and harmlessly wrong).
*/
Node p = enq(node);//node进入同步队列的入列操作,在前文2.3.1.2 AQS.addWaiter()有过介绍
//enq会返回入队前同步队列的队尾指针,即刚入队的node的前驱。
int ws = p.waitStatus;
//只有取消ws才会 大于0
//我们知道同步队列中的节点都是依靠前驱节点来唤醒
//如果入队后node的前驱已经被取消或者设置SIGNAL状态不成功,那么尝试唤醒当前node
//虽然唤醒了可能还是争不到锁,但该操作至少是无害的。
if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
LockSupport.unpark(node.thread);
return true;
}

总结:传进transferForSignal的node节点被取消,会返回false,成功入同步队列了,会返回true。这时我们再看doSignal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

/**
* Removes and transfers nodes until hit non-cancelled one or
* null. Split out from signal in part to encourage compilers
* to inline the case of no waiters.
* @param first (non-null) the first node on condition queue
*/
private void doSignal(Node first) {
do {
//第一次进来,先把firstWaiter设置为first.nextWaiter,这相当于first已经脱离等待队列了。
//为什么这么果断呢,因为transferForSignal如果返回true,while循环结束
//那么说明first已经进入同步队列,确实应该取消

//如果transferForSignal返回false,那么说明first节点已经被canceled了,也应该脱离等待队列。
if ( (firstWaiter = first.nextWaiter) == null)//如果条件满足,说明等待队列已经为空了
lastWaiter = null;//则将lastWaiter指针也置空
first.nextWaiter = null;//first此时已经脱队,将其单向链表的后继也置空,first彻底脱离等待队列

//如果transferForSignal返回true,说明first入同步队列成功,while条件不满足,退出循环
//如果transferForSignal返回false,说明first被取消,如果此时等待队列已经空了,那么也退出循环,
//否则,将新的等待队列队首设置为first,执行重复逻辑。
} while (!transferForSignal(first) &&
(first = firstWaiter) != null);
}

2.5.2.5 AQS.signalAll()

signalAll可唤醒等待队列中的全部线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* Moves all threads from the wait queue for this condition to
* the wait queue for the owning lock.
*
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
public final void signalAll() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
Node first = firstWaiter;
if (first != null)//等待队列不为空,执行doSignalAll
doSignalAll(first);
}

不赘述,直接看doSignalAll

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Removes and transfers all nodes.
* @param first (non-null) the first node on condition queue
*/
private void doSignalAll(Node first) {
lastWaiter = firstWaiter = null;//先把标示性的指针lastWaiter和firstWaiter清空,因为等待队列即将要空了。
do {
Node next = first.nextWaiter;
first.nextWaiter = null;
transferForSignal(first);//现将first转移至同步队列,不管成功或者失败,都要继续执行。
first = next;//将first改为原来first的后继
} while (first != null);//只要等待队列不空,就一直将队首移入同步队列。
}

逻辑很简单,将等待队列从队首无脑移入同步队列。

1…567
cherish-ls

cherish-ls

纸上得来终觉浅

68 posts
27 categories
92 tags
GitHub
© 2021 cherish-ls | Site words total count: 457.5k
Powered by Hexo
|
Theme — NexT.Muse v5.1.4
访问人数 访问总量 次
0%