一、类加载

  • 在以下几种情况下,Java 虚拟机将结束生命周期
  1. 执行了 System.exit() 方法
  2. 程序正常执行结束
  3. 程序在执行过程中遇到了异常或错误而异常终止
  4. 由于操作系统出现错误而导致 Java 虚拟机进程终止

1.1、类加载机制概述

Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验转换解析初始化,最终形成可以被虚拟机使用的 Java 类型,这个过程被称为虚拟机的类加载机制。

  • 与一些编译时需要进行连接的语言不同,在 Java 代码中,类型的加载、链接和初始化过程都是在程序允许期间完成的

这种策略让 Java 语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销

  • Java 的类加载策略为 Java 语言提供了更大的灵活性,增加了更多的可能性。

Java 天生可以动态扩展的语言特性就是基于运行期动态加载和动态连接这个特点实现的;比如说,编写一个面向接口的应用程序,可以等到运行时在指定其实际的实现类。

  • Java 运行时加载的 Class 文件并非指存在于具体磁盘的文件,而是一串二进制字节流,它的来源可以有多种多样

包括但不限于磁盘文件、数据库、网络、内存以及动态生成(使用动态代理时,Class 文件就是在运行时生成的)等

1.2、类加载的时机

一个类型从被加载(Loading)到虚拟机内存中开始,到卸载(Unloading)出内存位置,它的整个生命周期将会经历以下七个阶段:

  • 加载:查找并加载类的二进制数据
  • 连接
    • 验证:确保被加载的类的正确性
    • 准备:为类的静态变量分配内存,并将其初始化为默认值,比如说当前类中有一个 int 的静态变量 a ,那么在这个阶段,JVM 会为这个变量开辟内存,同时为 a 赋予一个 int 类型的默认值 0
    • 解析将类中的符号引用转换为直接引用
  • 初始化为类的静态变量赋予正确的初始值,依然以上面那个 int 类型的 a 举例,比如说在真正的 java 文件中,我们为 a 赋予的值为 100 ,但在之前的准备阶段中,我们已经为 a 赋了一个默认值 0 ,现在 JVM 将在这个阶段为 a 分配一个正确的初始值(符合我们预期的值)

这也解释了为什么在下面的代码中,a 的值是 0 的原因,当我们没有显示的为静态变量赋值的时候,静态变量会使用在类准备过程中 JVM 为它赋的默认值,即 0

1
2
3
class Test {
public static int a;
}

image.png

其中,以上的几个阶段发生在类加载的过程中。

  • 使用
  • 卸载

1.3、类的主动使用和被动使用

  • Java 程序对类的使用方式可以分为两种,即主动使用被动使用
  • 所有的 Java 虚拟机实现必须在每个类或接口被 Java 程序 首次主动使用 时才初始化它们

1、主动使用

《Java 虚拟机规范》中严格规定了有且只有六种情况必须立即对类进行初始化

  • 遇到 newgetstaticputstaticinvokestatic 这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。

能生成这四条字节码指令的典型场景有

  1. 使用 new 关键字实例化对象时
  2. 读取getstatic)或设置putstatic)一个 类型的静态字段时

注意,这个静态字段不能被 final 修饰,因为那样的字段会在编译期将结果放入常量池

  1. 调用一个类的静态方法时invokestatic
  • 使用 java.lang.reflect 包中的方法对类型进行反射调用时,如果该类型没有进行过初始化,则需要先触发其初始化
  • 当初始化一个类时,如果发现其父类还没有进行过初始化,那么需要先触发其父类的初始化
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的哪个类),虚拟机会先初始化这个类
  • 当使用 JDK 7 新加入的动态语言支持,java.lang.invoke.MethodHandle 实例的解析结果 REF_getStaticREF_putStaticREF_invokeStatic 句柄对应的类没有初始化,则初始化
  • 当一个接口中定义了 JDK 8 中新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了实例化,那该接口需要在实现类初始化之前被初始化

除了以上七种情况,其他使用 Java 类的方式都被看作是对类 被动使用 ,都不会导致类的初始化

2、被动使用

注意,被动使用只是不会导致类初始化,但可能导致类的加载和连接

  • 通过子类引用父类的静态字段,或者通过使用子类直接调用父类的静态方法,不会导致子类的初始化
  • 通过数组定义来引用类,不会触发此类的初始化

在下面的代码中,不会触发 MyClass 的初始化

1
2
3
4
5
public class Test {
public static void main(String[] args) {
MyClass[] myClasses = new MyClass[10];
}
}
  • 访问类的静态常量字段,不会触发此类的初始化

常量会在编译阶段存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发该类的初始化

注意,这里的静态常量字段必须是字面量,即在编译器就可以直接看出值是什么的常量,比如说 public static final int x = 3public static final String a = "abc" ;下面这种情况下就主动使用了类

1
public static final int x = new Random().nextInt(3);

上面的值不能在编译器就确定下来,所以在运行期还需要引用到常量所在的类,进行一次值的替换,所以属于主动使用,自然也会初始化类。

  • 直接使用类加载器对象的 loadClass 方法对某个类进行加载,不会触发这个类的初始化

1.4、类加载的过程

1、加载

类的加载(Loading)阶段是整个类加载过程中的第一个阶段,在本阶段,JVM 需要完成以下三件事情:

  • 通过一个类的全限定类名来定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的 java.lang.Class 对象,这个 Class 对象将作为这个类的各种数据的访问入口,Java 虚拟机规范中未说明 Class 对象的存放位置,但 HotSpot 虚拟机将其放在了堆中

简单来说,类的加载就是将类的 .class 文件中的二进制数据读取到内存中,将其放在运行时数据区的方法区内,然后在内存中创建了一个 Class 对象

  • 加载 .class 文件的几种方式
  1. 从本地系统中直接加载
  2. 通过网络下载 .class 文件
  3. zipjar 等归档文件中加载 .class 文件
  4. 从专有数据库中提取 .class 文件
  5. 从 Java 源文件中动态编译为 .class 文件,比如说 JSP 文件生成对应的 Class 文件
  6. 运行时计算生成,这种场景使用得最多的就是动态代理技术

java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass() 来为指定的接口生成形式为 xxx$Proxy 的代理类的二进制字节流。

  • 类加载器并不需要等到某个类被 首次主动使用 时再去加载它
  1. JVM 规范允许类加载器在预料到某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了 .class 文件缺失或确实错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError 错误
  2. 如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误

2、连接

连接阶段可大致分为三个过程,即 验证准备解析

  • 验证

验证是连接阶段的第一步,这一步的目标是确保 Class 文件的字节流中包含的信息符合 《Java 虚拟机规范》 的全部约束要求,确保这些信息被当作代码运行后不会危害虚拟机的安全

  • 准备

准备阶段是正式为类中定义的变量(静态变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应该在方法区中进行分配。

注意,这里是为类变量分配内存并设置初始值,而不是为实例变量。

  • 解析

解析阶段是 Java 虚拟机讲常量池中的符号引用替换为直接引用的过程。

  1. 符号引用(Symbolic References)

符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义地定位到目标即可

引用的目标不一定已经加载到虚拟机内存中的内容

  1. 直接引用(Direct References)

直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄

如果有了直接引用,那么要求引用的目标一定已经存在于虚拟机的内存中

3、初始化

  • 类的初始化阶段是类加载的最后一个步骤
  • 初始化阶段就是执行类构造器 <clinit>() 方法的过程,<clinit>() 并不是程序员在 Java 代码中直接编写的方法,它是 Javac 编译器的自动生成物。
  1. <clinit>() 方法是由编译器自动收集类中所有类变量的复制动作和静态语句块 (static 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的

静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。

  1. <clinit>() 方法与构建实例对象的构造函数(<init>())不同,它不需要显式地调用父类的类构造器,因为 Java 虚拟机会保证子类的类构造器执行前,父类的类构造器已经执行完毕。

所以,Java 虚拟机中第一个被执行的 <clinit>() 的方法的类型一定是 java.lang.Object

  1. <clinit>() 方法对于类和接口来说并不是必需的,如果一个类中没有惊天代码块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法
  2. 接口中不允许有静态代码块,但仍然有静态变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法,但与接口不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法
  3. Java 虚拟机必须保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行完 <clinit>() 方法

1.5、类加载器

在 Java 中,存在着两种类型的类加载器,分别是

  • Java 虚拟机自带的类加载器
    • 启动类加载器(BootStrap)
    • 扩展类加载器(Extension)
    • 系统(应用)类加载器(System)
  • 用户自定义的类加载器,即 java.lang.ClassLoader 的子类,用户可以通过自定义类加载器来定制类的加载方式

注意,类加载器并不需要等到某个类被 首次主动使用 时再去加载它,这一点和初始化不一样。

1、Java 虚拟机自带的几种类加载器

  • 启动类加载器(BootStrap)

这个类加载器负责加载存放在 <JAVA_HOME>/lib 目录,或者被 -Xbootclasspath 参数所指定的路径中存放的,而且是 Java 虚拟机能够识别的(按照文件名识别,如 rt.jartools.jar ,名字不符合的类库即使放到 <JAVA_HOME>/lib 目录下也不会被加载)类库加载到虚拟机的内存中。

  1. 启动类加载器无法被 Java 程序直接引用
  2. 它是由 CPP 实现的,是虚拟机自身的一部分
  • 扩展类加载器(Extension)
  1. 这个类加载器是纯 Java 类,它是 java.lang.ClassLoader 的子类
  2. 它的父加载器为启动类加载器,它从 java.ext.dirs 系统属性所指定的目录中加载类库,或者从 JDK 的安装目录的 jre\lib\ext 子目录下加载类库
  3. 如果用户将创建的 JAR 文件放在 jre\lib\ext 目录下,那么也会由扩展类加载器加载
  • 系统(应用)类加载器(System)
  1. 它的父加载器为扩展类加载器(Extension)
  2. 它从环境变量 classpath 或者系统属性 java.class.path 所指定的目录中加载类,它是用户自定义的类加载器的默认父加载器。
  3. 这个类加载器是纯 Java 类,它是 java.lang.ClassLoader 的子类

2、双亲委派模型

下图展示了各种类加载器之间的层级关系,这种关系被称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父加载器

  • 在类加载器之间的父子关系一般不是以继承的方式来实现的,而是通常使用组合来复用父加载器的代码

image.png

3、双亲委派模型的工作过程

  • 向上委托

如果一个类加载器收到了类加载的请求,那么它不会直接去尝试加载这个类,而是会将这个请求委托给它的父类加载器进行加载,如果它的父类加载器也有自己的父类加载器的话,那么请求还会继续向上委托,直到到达顶层的启动类加载器

由于有双亲委托机制的存在,所有的类加载请求最终都会被传送到启动类加载器中。

  • 向下尝试加载

当类加载请求发送到启动类加载器后,启动类加载器会尝试着去加载这个类,如果此时启动类加载器无法加载这个类,那么它会将类加载请求交由自己的子类加载器加载,如果它的子类加载器也无法加载这个类,那么请求将继续往下传递;

只有当它的父加载器无法加载这个类时,类加载请求才会尝试着自己去完成加载。

4、双亲委派模型的具体实现

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
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 首先,检查这个类是否已经被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// 如果父类抛出 ClassNotFoundException 异常
// from the non-null parent class loader
// 那么说明父类加载器无法完成加载请求
}

if (c == null) {
// If still not found, then invoke findClass in order
// 如果父类无法加载,那么调用本类的 findClass 方法进行加载
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);

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

5、自定义类加载器

  • 如果我们不希望破坏双亲委派机制,那么只需要重写 ClassLoaderfindClass 方法即可,在这个方法中,我们可以自定义类的查找顺序,根据某种规则查找类。

我们可以查看 ClassLoader 中的 findClass 方法,可以看到,这个方法是一个使用 protected 访问权限符修饰的方法,在这个方法中,仅仅抛出了一个异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Finds the class with the specified <a href="#name">binary name</a>.
* This method should be overridden by class loader implementations that
* follow the delegation model for loading classes, and will be invoked by
* the {@link #loadClass <tt>loadClass</tt>} method after checking the
* parent class loader for the requested class. The default implementation
* throws a <tt>ClassNotFoundException</tt>.
*
* @param name
* The <a href="#name">binary name</a> of the class
*
* @return The resulting <tt>Class</tt> object
*
* @throws ClassNotFoundException
* If the class could not be found
*
* @since 1.2
*/
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}

事实上,这个方法就是给开发者重写用的,如果我们不想破坏双亲委派机制,那么我们可以只重写这个方法,在这个方法中自定义我们查找类的规则,它会在 loadClass 方法中被调用,如果父类加载器无法加载要加载的类,那么就会调用我们自定义的 findClass 方法查找类,这样即保证了双亲委派模型不被破坏,又能根据我们自定义逻辑寻找类。

  • 如果我们希望破坏双亲委派机制,那么我们需要整个重写 loadClass 方法,因为双亲委派模型的实现就是在 loadClass 方法中实现的

6、.classgetClass() 的区别

  • 它们两者都可以获取一个唯一的 java.lang.Class 对象,区别在于
  1. .class 作用于类名,比如说 String.class ,而 getClass() 是一个使用 final native 修饰的方法,因此作用于实例

getClass() 方法定义在 java.lang.Object 类中

1
public final native Class<?> getClass();
  1. .class编译期间就确定了一个类的 java.lang.Class 对象,而 getClass() 方法在运行期间确定一个类实例的 java.lang.Class 对象.

7、命名空间

  • 每个类加载器都有自己的命名空间,命名空间由该加载器及所有父加载器所加载的类组成
  • 同一个命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类
  • 不同的命名空间中,可能会出现类的完整名字(包括类的包名)相同的两个类

也就是说,在不同的命名空间中,相同的类可能被加载多次。

image.png

  1. 同一个命名空间中的类是相互可见的
  2. 子加载器的命名空间包含了所有父加载器的命名空间,因此,子加载器中加载的类能看到所有父加载器中加载的类,而父加载器加载的类看不到子加载器加载的类。
  3. 如果两个加载器之间没有直接或间接的父子关系,那么它们各自加载的类互不可见。

二、Java 内存区域与内存溢出

2.1、运行时数据区域

1、程序计数器

程序计数器是一块较小的空间,它可以看作是当前线程所执行的字节码的行号指示器

  • 如果线程正在执行的是一个 Java 方法,那么计数器记录的是正在执行的虚拟机字节码指令的地址
  • 如果线程正在执行的是一个本地(Native)方法,那么计数器值则应为空(Undefined

2、Java 虚拟机栈

  • 和程序计数器一样,虚拟机栈也是线程私有的,它的生命周期与线程相同
  • 虚拟机栈描述的是 Java 方法执行的线程模型

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

  • 每个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程
  • 局部变量表

局部变量表存放了编译期可知的各种 Java 虚拟机基本数据类型(boolean、byte、short、int、float、double、char、long)、对象引用(reference 对象类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针)。

这些数据类型在局部变量表的存储空间以局部变量槽来表示,其中 64 bit 的 long 和 double 会占用两个槽,其余数据类型占用一个。

  • 如果线程请求的栈深度大于虚拟机所允许的深度,那么会抛出 StackOverFlowError
  • 如果虚拟机栈容量可以动态扩展,当栈无法扩展时,就会抛出 OutOfMemoryError

3、本地方法栈

  • 与虚拟机栈相似,本地方法栈存在的意义也是为了执行方法,只不过本地方法栈执行的是本地(Native) 方法
  • 和 Java 虚拟机栈一样,本地方法栈也会抛出 StackOverFlowErrorOutOfMemoryError 错误

4、堆

  • 堆空间是被线程共享的一块内存区域,此内存区域的唯一目的就是存放对象实例
  • 在 Java 世界中,几乎所有的对象实例都在堆上分配内存。

JVM 中对象是可以在栈中进行分配的,但是前提是需要判断逃逸状态,逃逸状态可以理解为逃出方法范围或当前线程

  1. 全局逃逸(Global Escape):即一个对象的作用范围逃出了当前方法或者当前线程
    1. 对象是一个静态变量
    2. 对象是一个已经发生逃逸的对象
    3. 对象作为方法的返回值
1
2
3
4
5
6
public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
  1. 参数逃逸(Arg Escape):一个对象被当作方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的
1
2
3
4
5
public static String createStringBuffer(String s1, String s2, StringBuffer sb) {
sb.append(s1);
sb.append(s2);
return sb.toString();
}
  1. 没有逃逸:即方法中的对象没有发生逃逸。

对于没有逃逸的对象,编译器可以对代码做如下优化:

  1. 同步省略。如果一个对象被发现只能从一个线程访问到,那么对于这个对象的操作可以不考虑线程安全

比如说,对于以下代码,由于对象 sb 没有发生逃逸(线程的出生和消亡都发生在方法内),那么我们可以不考虑它的线程安全,可以不使用线程安全的 StringBuffer ,而是使用效率更高的 StringBuilder

1
2
3
4
5
6
public static String createStringBuffer(String s1, String s2) {
StringBuilder sb = new StringBuilder();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
  1. 将对分配转换为栈分配

对于这种出生消亡均发生在一个方法内的对象(没有逃逸出方法的对象)而言,对象是可以不被分配在堆上的,当对象被分配到栈上后,它的生命周期就和方法的周期绑定到一起了,此时对象会随着栈帧出栈时销毁,减少了 GC 压力,提高了应用程序性能

  1. 标量替换

首先要明白标量和聚合量,基础类型和对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,比如:对象。

对象是聚合量,它又可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换。这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能

  • 对象逃逸的意义

逃逸分析是为了优化 JVM 内存和提升程序性能的,在开发时我们需要尽可能地控制变量的作用范围,让变量范围越小越好,让虚拟机尽可能有优化的空间。

5、方法区

  • 和 Java 堆一样,方法区也是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息常量静态变量即时编译器编译后的代码缓存等数据

需要注意的是,Class 对象是存储在堆中的。

  • 方法区、永久代和元空间三者的区别和联系
  1. 方法区是规范,而永久代和元空间是对方法区的实现
  2. 不能将方法区与永久代、方法区与元空间混为一谈
  3. 在 JDK 8 之前, HotSpot 使用永久代来实现方法区,在 JDK 7 时,HotSpot 已经将原本存放于永久代中的字符串常量池移出
  4. 到 JDK 8 ,HotSpot 已经将永久代完全废除,使用元空间来代替原来的永久代。
  • 相较于堆,垃圾回收行为在方法区中的确较少出现,但并非数据进入了方法区就永久存在,方法区的垃圾回收的主要目的时针对常量池的回收对类型的卸载

6、运行时常量池

  • 运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是 Class 常量池

  • 运行时常量池具备动态性,Java 语言并不要求常量一定只有编译器才能产生,也就是说,运行时也可以将新的常量放入到池中(String#intern)。

  • 运行时常量池、字符串常量池和 Class 常量池的联系和区别

  1. Class 常量池:每一个 Java 类被编译后,会生成一份 class 文件;Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是 Class 常量池,每个 class 文件都存在一个 Class 常量池。
  2. 字符串常量池:在 HotSpot 的实现中,字符串常量池是一个哈希表,在 JDK 7 后,它存在于堆中,且在每个 HotSpot 实例中仅有一份,被所有的类共享,字符串常量池中存储的是字符串值,但也会存有字符串的引用。
  3. 运行时常量池:在类加载执行解析的过程中,JVM 会将符号引用替换为直接引用,此时它会去查询字符串常量池,以确保运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。

字符串常量池逻辑上是运行时常量池的一部分,但运行时常量池每个类都有一个,而字符串常量池全局唯一。

  • 在无法申请到内存时,运行时常量池也会报 OutOfMemoryError 错误

7、直接内存

  • 直接内存并不是虚拟机运行时数据区的一部分,这部分内存会被频繁地使用,而且也可能导致 OutOfMemoryError 错误。
  • 在 JDK 1.4 中新加入了 NIO (New Input / Output) 类,引入了一种基于通道(Channel)和缓冲区(Buffer)的 I / O 方式,它可以使用 Native 函数库直接分配堆外内存。

这样可以在一些场景中显著提高性能,避免了在 Java 堆和 Native 堆中来回赋值数据。

2.2、HotSpot 虚拟机对象揭秘

1、对象的创建

  • 先判断该对象对应的类是否已被加载

当虚拟机执行到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那么需要先执行对应的类加载过程。(这里的 new 的对象不包括数组对象和 Class 对象

  • 虚拟机为新生对象分配内存

新生对象所需内存的大小其实在类加载完成之后便可完全确定,为对象分配空间的任务实际上就是把一块确定大小的内存块从 Java 堆中划分出来。

  1. 如果堆内存是绝对规整的(已分配的内存都被放在一块,空闲的内存放在另一边,两边使用一个指针作为分界点),那么我们分配内存时只需要将指针移动一段与对象大小相等的距离即可完成内存分配,这种分配方式被称为指针碰撞
  2. 如果堆内存不是绝对规整的,这种情况下我们无法使用简单的指针碰撞来分配内存,而是需要维护一个列表,这个列表中记录了内存中哪些空间是可用的,在分配时我们需要在列表中找到一块符合需要的空间给新对象,然后更新列表,这种分配方式被称为空闲列表
  • 内存分配完成后,JVM 必须将分配到的内存空间都初始化为零值,这一步保证对象实例字段在 Java 代码中可以不赋初始值就直接使用,让程序可以访问到这些字段的零值

零值和零是有区别的,比如说 int 类型的零值为 0 ,而 boolean 类型的零值为 false

  • 接下来,JVM 还需要堆对象进行必要的设置,比如说这个对象是哪个类的实例,如何才能找到该类的元数据信息、对象的 hashCode (实际上该对象的哈希码会延后到调用 hashCode() 方法时才计算)、对象的分代年龄等信息,这些信息放在对象头。

上面的工作完成后,对虚拟机来说,一个新的对象就已经产生了,但是此时这个对象可能还不是我们想要的对象,因为它的构造函数还没有执行

  • 根据程序员的意愿执行对应的构造函数

2、如何保证内存分配的线程安全?

对象创建在虚拟机中是一个非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也无法保证线程安全(比如说 A B 线程同时分配内存, A 还没来得及移动指针为它要创建的对象分配内存,而 B 线程就已经把指针抢过去了。),有以下两种方案保证线程安全:

  1. 一种是对分配内存空间的动作进行同步处理,而实际上虚拟机是采用 CAS + 失败重试的方案保证更新操作的原子性;
  2. 另一种将进行内存分配的线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配了一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在自己的缓冲区中分配,只有本地缓冲区用完了,分配新的缓冲区时才需要同步锁定。可以用过一个 JVM 参数 -XX:+/UseTLAB 参数开启 TLAB ,默认不使用。

3、对象的内存布局

在 HotSpot 虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)实例数据(Instance Data)对齐填充(Padding)

  • 对象头

HotSpot 虚拟机对象的对象头包括两部分.

  1. 第一部分是用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 bit 和 64 bit ,官方称之为 Mark Word,Mark Word 被设计为一个有着动态定义的数据结构,以便能在极小的空间内存储更多的数据。
  2. 第二部分是类型指针,即对象指向它的类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例,官方称之为 Klass Pointer,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 bit 和 64 bit
  3. 如果该对象是一个 Java 数组,那么对象头还必须有一块用于记录数组长度的数据。因为对于普通对象,虚拟机可以通过它的元数据信息确定对象大小,而对于数组对象,除了元数据外,还需要数组长度才能推断整个数组对象的大小。
  • 实例数据

这是对象真正存储的有效信息,即我们在程序代码中所定义的各种类型的字段内容,包括从父类继承下来的和在子类中定义的字段都必须记录起来

  • 对齐填充

对其填充并不是必然存在的,也没有特别的意义,它仅仅是起着占位符的作用。由于 HotSpot 虚拟机的自动内存管理系统要求对象的其实地址必须是 8 字节的整数倍,换句话来说就是任何对象的大小必须是 8 字节的整数倍。因此,如果实例数据没有对齐的话,那么需要对齐填充来补全。

  • 一个 Object 类型对象占多大空间

这里以 64 bit 的虚拟机为例

  1. 在开启指针压缩的情况下,Mark Word 占用 4 个字节,Klass Pointer 占用 8 个字节,实例数据中无数据,由于此时对象的总大小为 12 字节,不是 8 字节的整数倍,所以对齐填充 4 个字节,故整个 Object 对象会占用 16 字节的存储空间。
  2. 在没有开启指针压缩的情况下,Mark Word 和 Klass Pointer 都会占用 8 个字节,而实例数据中无数据,也不需要对齐填充,所以整个 Object 对象会占用 16 字节的存储空间。

4、对象的访问定位

在对象创建后,我们会通过栈上的 reference 数据来操作堆上的具体对象,对象的访问方式主要有使用句柄(一个是用来标识对象的标识符)和直接指针两种:

  • 如果使用句柄访问的话, Java 堆中将划分出一块内存来作为句柄池,**reference 中存储的就是该对象的句柄地址**,而句柄中包含了对象实例数据与类型数据各自具体的地址信息

使用句柄访问的好处是 reference 中存储的是该对象的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中到对象实例的指针,而 reference 本身不需要被修改

image.png

  • 如果使用直接指针访问的话,Java 堆中的对象的内存布局就必须考虑如何放置访问类型数据的相关信息,**reference 中存储的直接就是对象的内存地址**,如果只是访问对象本身的话,就不用多义词间接访问的开销

使用直接指针访问的速度更快,因为它节省了一次指针定位的时间开销,在 HotSpot 虚拟机中,它主要采取第二种方式进行对象访问。

image.png

2.3、String#intern() 方法

  • intern() 方法位于 java.lang.String 类中,它是一个 native 方法
1
public native String intern();

1、JDK 6 中的 String#intern() 方法

调用 intern() 方法后,会尝试向字符串常量池中添加字符串

  • 如果字符串常量池中没有相等的字符串,那么会将字符串的内容复制一份放到池中,然后返回池中的字符串地址
  • 如果字符串常量池中存在相等的字符串,那么会直接返回池中已经存在的字符串地址

如果我们在 JDK 6 中执行以下代码,那么输出的结果为 false

1
2
3
4
String str1 = new String("hello") + new String("world");
str1.intern();
String str2 = "helloworld";
System.out.println(str1 == str2); //false
  1. 执行第一句代码,JVM 底层会先使用 StringBuilder 拼接字符串,然后将 StringBuilder 对象转为 String 字符串,这个过程是在堆中进行的

image.png

  1. 执行第二句代码时,会将 helloworld 字符串复制一份放入到字符串常量池中,此时 str1 引用的堆中的 helloworld 和字符串常量池中的 helloworld 不是同一个,地址不一样

image.png

  1. 执行第三句代码时, str2 会直接引用字符串常量池中的 helloworld ,此时二者引用的根本不是同一个,所以第四句代码返回 false

image.png

2、JDK 7 及以后的 String#intern() 方法

调用 intern() 方法后,会尝试向字符串常量池中添加字符串

  • 如果字符串常量池中没有相等的字符串,那么会将 Java 堆中的字符串的地址复制一份到池中,然后返回池中的字符串的地址,也就是说,此时 intern() 返回的就是 Java 堆中字符串对象的地址,这样做是为了减少 Java 堆的内存开销

这是因为在 Java 7 及之后,字符串常量池已经移入堆中,此时我们不需要再拷贝整个字符串到字符串常量池了,既然大家都在堆里,那我只需要记一下你的地址就可以了。

  • 如果字符串常量池中存在相等的字符串,那么会直接返回池中已经存在的字符串地址
1
2
3
4
String str1 = new String("hello") + new String("world");
str1.intern();
String str2 = "helloworld";
System.out.println(str1 == str2); //true
  1. 我们从第二句代码开始分析,当调用 intern() 方法时,会将字符串的地址放入到字符串常量池中

image.png

  1. 调用第三句代码,此时 str2 依然会直接引用字符串常量池中的字符串,但是最终它会根据字符串常量池中的地址引用到堆中的 helloworld

image.png

  1. 此时 str1 和 str2 指向的字符串实际上都是同一个,所以返回 true

三、垃圾收集器和内存分配策略

3.1、可达性分析算法

1、概念

可达性分析算法以一系列 GC Roots 对象作为起始节点集合,从这些节点开始,根据引用关系向下搜索,搜索过程中所走过的路径称为引用链,如果一个对象到 GC Roots 之间没有任何的引用链项链,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。

image.png

2、可以作为 GC Roots 的对象

在 Java 技术体系中,可以充当 GC Roots 的对象包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,例如各个线程被调用的方法堆栈中使用的参数、局部变量和临时变量等。
  • 在方法区中类静态属性引用的对象,比如说 Java 类的引用类型静态变量。
  • 在方法区中常量引用的对象,比如说字符串常量池里的引用
  • 在本地方法栈中 JNI (通常说的 Native 方法)引用的对象
  • Java 虚拟机内部的引用,如基本数据类型对应 Class 对象,一些常驻的异常对象(NullPointerException 等),还有系统类加载器。
  • 所有被同步锁(synchronized 关键字)持有的对象

除了这些固定的 GC Roots 外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象 “临时性” 地加入,共同构成 GC Roots 集合

3、对象死亡与 finalize 方法

  • finalize() 方法位于 Object 类中
1
protected void finalize() throws Throwable { }

即使在可达性分析算法中判定为不可达的对象,也不是 非死不可 的,要真正宣告一个对象死亡,至少需要经历两次标记过程

  1. 如果对象进行可达性分析后发现没有与 GC Roots 相关联的引用链,那它会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法

如果对象所属的类没有覆盖对应的 finalize 方法,或者说该对象的 finalize() 方法已经被虚拟机执行过,那么虚拟机都会将这两种情况视为没必要执行

  1. 如果这个对象被 JVM 认为有必要执行 finalize() 方法,那么该对象会被放置到一个名为 F-Queue 的队列之中,并在稍后由一条虚拟机自动建立,低调度优先级的 Finalize 线程去执行它们的 finalize() 方法

这里的执行只是说虚拟机会触发这个方法开始运行,但并不会承诺一定执行完这个方法,因为如果某个对象的 finalize() 方法执行缓慢,或者发生了死循环等极端情况,那么就可能导致队列中的其他对象处于等待状态,甚至导致整个内存回收子系统的崩溃。

  • finalize() 方法是对象的最后一根救命稻草,如果对象需要在 finalize() 方法中自救,那么它只需要重新与引用链上的任何一个对象产生关联即可(比如将 this 赋值给某个变量或者对象的成员变量)

  • 收集器会对 F-Queue 队列中的对象进行第二次标记,对于那些自救成功(在 finalize() 方法中将子集与 GC Roots 扯上关联)的对象会被移除“死刑”区。

  • 由于 finalize() 方法存在运行代价高昂、不确定性大、无法保证各个对象的调用顺序的缺点,所以官方并不推荐使用 finalize() 方法,它不对应 C++ 的析构函数(finalize() 方法的调用时机不确定)

4、三色标记算法

当前 JVM 判断对象是否可回收采用的是可达性分析算法,这种算法基于 GC Roots 进行可达性分析,分析过程中采用三色标记法,三者标记法在之前的笔记中已经记录过,所以这里不详细说明

  • 如果垃圾回收的过程中,所有用户线程都是被冻结的,那么不会存在任何问题;但如果用户线程与垃圾回收器线程是并发工作(在垃圾回收线程标记颜色的同时,用户线程在修改引用关系)的呢?这个时候可能出现以下两个问题
  1. 一种是把原本应该消亡的对象标记为存活,这种现象称为浮动垃圾,浮动垃圾的出现不是好事,但在可容忍范围之内,对于浮动垃圾,我们只需要将它放到下一次垃圾回收时清理即可,并不会对程序造成致命性的损害。
  2. 另一种是把原本存活的对象误标记为已消亡,这种现象称为对象漏标,可能会对程序造成致命性的伤害.

5、如何解决对象漏标?

当且仅当以下两个条件同时满足时,会产生对象漏标的情况,即原本应该被标记为黑色的对象被误标记为白色

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用(此时白色对象就被 GC Roots 关联上了)
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

比如说,如果在垃圾回收线程标记时,用户线程全部停止的话,那么此时扫描的结果应如下,此时没有任何问题

image.png

如果垃圾回收线程标记过程中用户线程会继续工作,且

image.png

此时灰色节点与底下的白色节点的引用断了,然后黑色节点又引用了白色节点,此时的关系变为

image.png

那么灰色节点此时继续向下扫描,最终得到的结果如下,可以看到,此时在标记过程中被并发修改的 Obj 对象就被误标记为已消亡状态,而实际上它不应该被回收

image.png

既然产生对象漏标需要同时满足以上两个条件,那么我们就破坏这两个条件的其中任意一个即可,有两种方案可以解决这个问题,分别是增量更新原始快照

6、增量更新

  • 增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发结束后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一遍。

可以简单理解为,如果一个黑色对象插入了指向白色对象的新引用的话,那么这个黑色对象就变成了灰色对象

  • CMS 垃圾收集器 就是使用增量更新来做并发标记的,所以 CMS 无法解决浮动垃圾

7、原始快照

  • 原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描技术后,以记录过的这些引用关系中的灰色对象为根,重新扫描一遍。

可以简化理解为,无论引用关系删除与否,都会按照刚开始扫描的那一刻的对象图快照来进行搜索

  • G1 垃圾收集器 就是采用原始快照来做并发标记的。

3.3、经典的垃圾收集器

1、名词说明

  • 部分收集(Partial GC):指目标不是完整收集整个 Java 堆的垃圾收集,其中又可以分为
    • 新生代收集Minor GC / Young GC):指目标只是新生代的垃圾收集
    • 老年代收集Major GC / Old GC):指目标只是老年代的垃圾收集,目前只有 CMS 收集器会有单独收集老年代的行为
    • 混合收集Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集,目前只有 G1 垃圾收集器有这样的行为
  • 整堆收集Full GC):收集整个 Java 堆和方法区的垃圾收集行为
  • 吞吐量:处理器用于运行用户代码的时间与处理器总消耗时间的比值
1
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)

2、CMS 垃圾收集器

  • CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的垃圾收集器,见名知义,这是一款基于标记 - 清除算法的垃圾收集器.
  • CMS 于 JDK 5 推出,在 JDK 9 时就标记为 不推荐 ,到 JDK 14 时,CMS 已经被完全剔除。
  • CMS 是 HotSpot 虚拟机追求低停顿的第一次尝试,但是它还没有到完美的程度,至少有以下几个缺点
  1. CMS 对处理器资源非常敏感,在并发(并发标记、并发清除)阶段,它虽然不会使用户线程停顿,但却会因为占用一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量

CMS 默认启动的回收线程数是(处理器核心数量 + 3) / 4,也就是说,如果处理器核心数在 4 个或以上,并发回收时垃圾回收线程只占用不超过 25 % 的处理器运算资源,且会随着处理器核心的增加而降低。但如果处理器核心数量小于 4 个时,CMS 对用户程序的影响就可能变得很大。

  1. CMS 无法处理浮动垃圾,这是因为 CMS 在并发过程中用户线程仍然处于工作状态,所以程序会伴随着新的垃圾不断产生,但由于这一部分的垃圾是在标记阶段后产生的,所以本次处理中 CMS 无法处理掉

浮动垃圾的存在可能导致 Concurrent Mode Failure 失败进而导致另一次完全 Stop The WorldFull GC 产生,同理,由于在并发过程中用户线程依然处理工作状态,所以 CMS 无法像其他垃圾回收器那样等到老年代快被填满再进行收集,而必须预留一部分空间供并发收集时的程序运作

在 JDK 5 ,CMS 收集器当老年代使用了 68 % 的空间后就会被激活,这个值是可以被调节的,如果 CMS 运行期间预留的内存无法满足程序分配新对象的需要,那么就会出现一次并发失败 **Concurrent Mode Failure ,此时 JVM 将启动后备预案,即进行一次较长时间的 STW ,并临时启用 **Serial Old 收集器来重新进行老年代的垃圾收集。

  1. CMS 是一款基于 标记 - 清除 算法实现的垃圾收集器,在垃圾收集过程中会产生大量空间碎片。

空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有许多剩余空间,但就是无法找到一块足够大的连续空间来分配当前对象的情况,而这又会引发一次 Full GC 。

  • 为什么 CMS 是基于标记 - 清除算法的?

这是因为在并发阶段, CMS 的垃圾回收线程是与用户线程同时运行的,此时用户线程还在工作,程序还在进行,为了程序的稳定性,我们就不能移动对象的地址,所以只能采用标记 - 清除算法。

3、G1 垃圾收集器

  • G1 是 CMS 的替代者和继承人

  • G1 将 Java 堆划分为多个大小相等的独立区域,称之为 Region ,每个 Region 都可以根据需要去扮演不同的角色,如新生代的 Eden 区、Survivor 区,或者老年代区。

  • Region 中还有一类特殊的 Humongous 区域,它专门用于存储大对象。G1 认为大小超过一个 Region 一半的对象即可判定为大对象

  • Region 的大小可以通过参数 -XX:G1HeapRegionSize 设定,取值范围为 1MB - 32MB ,且应为 2 的 N 次幂

  • G1 回收步骤:

  1. 初始标记(Initial Marking:仅仅只是标记一下 GC Roots 能够直接关联到的对象,让下阶段用户线程并发运行时,可以正确地在可用的 Region 中分配新对象。这个阶段需要 STW ,但耗时非常短,而且是借用 Minor GC 时同步完成的,所以 G1 在这个阶段实际并没有额外的停顿

  2. 并发标记(Concurrent Marking从 GC Roots 开始对堆中的对象进行可达性分析、递归扫描整个堆中的对象图,找出要回收的对象,这阶段耗时比较长,但可以与用户线程并发执行。在对象图扫描完后,还需要重新处理 STAB (原始快照)记录下的在并发时有引用变动的对象

  3. 最终标记(Final Marking:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍留下来的最后那少量的 STAB 记录。

  4. 筛选回收(Live Data Counting And Evacuation负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序根据用户所期望的停顿时间来指定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。

这里的操作由于涉及到存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的。

  • 需要注意的是, G1 设置的停顿时间必须是符合实际的,因为 G1 是要冻结用户线程来复制对象的,这个停顿时间再怎么低也需要有个限度。

一般来说,回收阶段占用几十到一百甚至接近两百毫秒都很正常,如果停顿时间设置得非常低,那么可能会导致每次回收得垃圾只占堆内存的很小一部分,收集器回收的速度远远跟不上分配的速度,从而导致垃圾慢慢堆积,最终占满堆导致 Full GC 反而降低性能。一般来说,停顿时间设置为 100 - 300 毫秒都是比较正常的。

4、CMS 与 G1

  • CMS 基于 标记 - 清除 算法实现,会产生内存碎片,而 G1 从整体上来看是基于 标记 - 整理 算法实现的收集器,但从局部(两个 Region 之间)来看, G1 又是基于 标记 - 复制 算法实现,不会产生内存碎片。

  • 在用户程序运行的过程中, G1 无论是为了垃圾收集产生的内存占用(FootPrint)还是程序运行时的额外执行负载(Overload)都要比 CMS 要高

  1. 内存占用

虽然 G1 和 CMS 都需要使用卡表来处理跨代指针,但 G1 的卡表实现更为复杂,而且堆中的每个 Region ,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致 G1 的记忆集(和其他内存消耗)可能会占整个堆容量的 20% 乃至更多的内存空间

相比起来, CMS 的卡表就非常简单,只有唯一一份,而且只需要处理老年代到新生代的引用;反过来则不需要

  1. 执行负载

G1 使用的原始快照算法会在用户程序执行的过程中产生额外的执行负载

3.4、内存分配原则

1、对象优先在 Eden 区分配

大多数情况下,对象在新生代 Eden 区中分配当 Eden 区没有足够的空间进行分配时,虚拟机将发起一次 Minor GC.

2、大对象直接进入老年代

大对象就是需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串,或者元素数量很庞大的数组。

  • 当复制对象时,大对象就意味着高额的内存复制开销,比大对象更可怕的是朝生夕死的大对象,将大对象直接分配到老年代中,可以避免在 Eden 区及两个 Survivor 区之间来回复制,产生大量的内存复制操作。
  • HotSpot 虚拟机提供了 -XX:PretenureSizeThreshold 参数,指定大于该参数的对象直接在老年代分配。

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

  • 每个对象都定义了一个对象年龄(Age)计数器,这个计数器存储在对象头,是一个 4 bit 的数字,对象在 Eden 诞生,如果经过第一次 Minor GC 后仍然存活,并且能够被 Survivor 区容纳,那么它的 Age 会会被设置为 1 ,此后当它每熬过一次 Minor GC ,年龄就 + 1 ,当它的年龄到达一定程度(默认为 15 ,因为 4 bit 最大值为 15),这个对象就会被放入到老年代中
  • 对象年龄阈值可以通过 -XX:MaxTenuringThreshold 参数设置

4、动态对象年龄判定

为了更好地适应不同程序的内存状况,HotSpot 虚拟机并不是永远要求对象年龄必须到达 -XX:PretenureSizeThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄的对象大小的总和大于 Survivor 空间的一半,那么年龄大于或等于 该年龄的对象就可以直接晋升老年代,不需要等到到达**-XX:PretenureSizeThreshold** 中要求的年龄

5、空间分配担保

在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间

  • 如果该条件成立,那么证明这一次的 Minor GC 可以确保安全
  • 如果该条件不成立,那么虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败,如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
  1. 如果大于,那么尝试进行一次 Minor GC ,尽管这次 Minor GC 存在风险;
  2. 如果小于,或者 -XX:HandlePromotionFailure 参数设置为不允许冒险,那么这个时候需要进行一次 Full GC

四、Java 内存模型与线程

对于计算量相同的任务,程序线程并发协调得越有条不紊,效率自然就越高;反之,如果线程之间频繁争用数据,互相阻塞甚至死锁,将会大大降低程序的并发能力

4.1、硬件的效率及一致性

  • 绝大部分的运算任务都不可能只靠处理器计算就能完成。处理器至少只需要与内存交互,如读取运算数据、存储运算结果等,这个 IO 操作是很难消除的(无法只靠寄存器来完成所有运算任务)。
  • 由于计算器的存储设备和处理器的运算速度之间存在着几个数量级的差距,所以现代计算器都不得不加入一层或多层读写速度尽可能接近于处理器运算速度的高速缓存来作为内存与处理器之间的缓冲:

将运算需要的数据复制到高速缓存中,让运算得以高速进行,当运算结束后再将数据同步回内存之中,这样处理器就可以不用等待缓慢的内存读写了

  • 基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但也为计算机系统带来了更高的复杂度,它引入了缓存一致性这个新问题

在多路处理器系统中,每个处理器都有属于自己的高速缓存,而多个处理器之间有共享同一主存,这种系统称为共享内存多核系统

image.png

  • 当多个处理器的运算任务都涉及同一块主存区域时,将可能导致各自的缓存不一致,如果真的发生这种情况,那同步回主内存时应该以谁的缓存数据为准呢?

针对这个问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议有 MSIMESIMOSI

  • 内存模型

它可以理解为在特殊的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

  • 除了增加高速缓存外,为了使处理器内部的运算单元被尽可能地运用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但不保证程序中各个语句计算的先后顺序和输入代码的顺序一致,因此如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性不能靠代码的先后顺序来保证

Java 虚拟机的执行编译器也有执行重排序优化

4.2、Java 内存模型

1、简单介绍

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

2、主要目的

  • Java 内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存从内存中取出变量值的这些底层细节。
  • 这里的变量与 Java 编程中所说的变量有所区别,它包括了实例字段静态字段构成数组对象的元素,但不包括方法参数和局部变量,因为后者是线程私有的,不会被共享,也就不会存在竞争问题

3、规定

  • Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时提到的主内存名字一样,二者可以类比)中
  • 每条线程还有自己的工作内存(Working Memory),它可以与计算机内存模型中的高速缓存进行类比,线程的工作内存中保存了被该线程使用的变量的主内存副本。

如果一个线程访问一个大小为 10 MB 的对象,那么它不会将该对象完全地复制一遍,而是考虑将这个对象的引用,或对象中某个在线程访问到的字段复制一份到工作内存中

  • 线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据

对于使用 volatile 修饰的变量,在线程的工作内存中仍然有它的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般,因此这里对 volatile 也并不例外。

4、内存间交互操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存回到主内存这一类的实现细节,Java 内存模型中定义了以下 8 种操作来完成

Java 虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的;对于 double 和 long 类型的变量来说,loadstorereadwrite 操作在某些平台上允许有例外。

  • lock(锁定)

作用于主内存的变量,它将一个变量标识为一条线程独占的状态

  • unlock(解锁)

作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放出来的变量才可以被其他线程锁定

  • read(读取)

作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的 load 动作使用

  • load(载入)

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

  • use(使用)

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

  • assign(赋值)

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

  • store(存储)

作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用

  • write(写入)

作用于工作内存的变量,它把 store 操作从工作内存中得到的变量的值放入到主内存的变量中

image.png

也就是说,工作内存和主内存之间不是直接交互的,中间还有一层 Save / Load

  • 如果一个变量要从主内存拷贝到工作内存,那么必须按顺序执行 readload 操作
  • 如果一个变量要从工作内存同步回主内存,那么必须按顺序执行 storewrite 操作

需要注意的是,Java 内存模型只要求上述两个操作必须按顺序进行,但不要求连续执行,也就是说上述两个顺序操作之间可以插入其他的指令,比如说 read A , read B ,load B, load A

5、8 种基本操作需要满足的规则

  • 不允许 readloadstorewrite 操作之一单独出现,也就是说不允许一个变量从主内存中读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现;也就是说,除了执行顺序性外, readloadstorewrite 必须成对出现。
  • 不允许一个线程丢弃它最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主存
  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中
  • 一个新的变量只能在主内存 “诞生” ,不允许在工作内存中直接使用一个违背初始化(load 或 assign)的变量

换句话说,就是对一个变量实施 use 和 store 操作前,必须先执行 assign 和 load 操作。

  • 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。

有点类似可重入锁。

  • 如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值。在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作以初始化变量的值。
  • 如果一个变量事先没有被 lock 锁定,那就不允许对它进行 unlock 操作,也不能去 unlock 其他线程锁定的变量
  • 对一个变量执行 unlock 操作之前,必须把此变量同步回主内存中(执行 store 、 write 操作)

6、对于 volatile 型变量的特殊规则

  • 关键字 volatile 是 Java 虚拟机提供的最轻量级的同步机制。
  • 当一个变量被定义为 volatile 时,它将具备两项特性:
  1. 第一项是保证此变量对所有线程的可见性,这里的 “可见性” 是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的
  2. 使用 volatile 变量的第二个语义是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致
  • 在某些情况下,volatile 同步机制的性能确实要由于锁(使用 synchronized 关键字或者 java.util.concurrent 包中的锁),但是由于虚拟机对锁实行的许多消除和优化,使得我们很难确切地说 volatile 就会比 synchronized 快上多少。不过大多数情况下,volatile 的总开销仍然要比锁来得少。
  • volatile 变量读操作地性能消耗与普通变量几乎没有什么区别,但是写操作则会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
  • 每次使用 volatile 变量 value 之前,必须先从主存中刷新最新的值,用于保证能看见其他线程对变量 value 的修改
  • 每次修改 volatile 变量 value 之后,必须立刻将 value 的值同步回主内存中,用于保证其他线程可以看到本线程对 value 变量的修改。

7、先行发生(Happens - Before)原则

  • 简要介绍

Happens - Before 是 Java 内存模型中定义的两项操作之间的偏序关系,比如说 A 操作先行发生于操作 B ,其实就是说在发生操作 B 之前,操作 A 产生的影响能被操作 B 观察到,这里的影响包括修改了内存中共享变量的值、发送了消息、调用了方法等

  • Java 中常见的 Happens-Before 原则
  1. 程序次序规则:在一个线程内,按照控制流程序,书写在前面的操作 Happens - Before 于书写在后面的操作。

这里说的是控制流程序而不是代码程序,因为要考虑分支、循环等结构

  1. 管程锁定规则:一个 unlock 操作 Happens - Before 于后面(时间上的先后顺序)对同一个锁的 lock 操作。

如果线程 1 解锁了 monitor a ,接着线程 2 锁定了 a ,那么 线程 1 解锁 a 之前的写操作都对线程 2 可见(线程 1 和线程 2 可以是同一个线程)

  1. 线程启动规则:Thread 对象的 start() 方法 Happens - Before 于此线程的每一个动作

假设线程 A 在执行过程中,通过 ThreadB 的 start 方法来启动 B ,那么线程 A 对共享变量的修改在接下来线程 B 开始执行前对线程 B 可见

  1. 线程中断规则:对线程 interrupt() 的调用 Happens - Before 发生于被中断线程的代码检测到中断时事件的发生。

线程 t1 写入的所有变量,调用 Thread.interrupt(),被打断的线程 t2,可以看到 t1 的全部操作

  1. 对象终结规则:一个对象的初始化完成(构造函数执行结束)Happens - Before 于它的 finalize()方法的开始。

对象调用 finalize()方法时,对象初始化完成的任意操作,同步到全部主存同步到全部 cache。

  1. volatile 变量规则:对一个 volatile 变量的写操作 Happens - Before 于后面(时间上)对该变量的读操作。

如果线程 1 写入了 volatile 变量 v(临界资源),接着线程 2 读取了 v,那么,线程 1 写入 v 及之前的写操作都对线程 2 可见(线程 1 和线程 2 可以是同一个线程)

  1. 传递性:如果操作 A Happens - Before 操作 B,操作 B Happens - Before 操作 C,那么可以得出 A Happens - Before 操作 C。

4.3、Java 与线程

1、线程的实现

  • 线程是比进程更加轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分离开,每个线程即可以共享进程资源(内存资源、文件 I/O 等),又可以独立调度。
  • 目前线程是 Java 中进行处理器调度资源的最基本单位

主流的操作系统都提供了线程实现,Java 语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经调用过 start() 方法且还未结束的 java.lang.Thread 类的实例都代表这一个线程。

  • Thread 类中所有的关键方法都被声明为了 native 。
  • 实现线程通常由三种方式:
  1. 使用内核线程实现(1:1 实现)

  2. 使用用户线程实现(1:N 实现)

  3. 使用用户线程加轻量级进程混合实现(N:M 实现)

2、线程的实现方式 – 使用内核线程实现

使用内核线程实现也被称为 1: 1 实现

  • 内核线程就是直接由操作系统内核支持的线程,这种线程由内核(Kernel)来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。
  • 每个内核线程可以看为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核被称为多线程内核(Multi-Threads-Kernel)。

程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口 – 轻量级进程(Light Weight Process),而轻量级进程就是我们通常意义上所说的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。

这种轻量级进程与内核线程之间 1:1 的关系称为一对一的线程模型

  • 轻量级进程的局限性:
  1. 由于是基于内核线程实现的,所以各种线程操作,如创建、析构和同步,都需要进行系统调用。

系统调用的代价相对较高,需要在用户态和内核态之间来回切换

  1. 其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),一个系统支持轻量级进程的数量是有限的

3、线程的实现方式 – 使用用户线程实现

使用用户线程实现的方式被称为 1:N 实现

  • 一个线程只要不是内核线程,那么都可以认为是用户线程(User Thread,UT)的一种,因此从这个定义上看,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此效率会收到限制,并不具备通常意义上的用户线程的优点。

  • 狭义的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。

用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的协助。

  • 如果程序实现得当,那么这种线程不需要切换到内核态,因此操作是可以非常快速且低消耗的,也可以支持规模更大的线程数量。
  • 用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的操作都需要用户程序自己去处理,线程的创建、销毁、切换和调度都是用户必须考虑的问题
  • 一般的应用程序都不倾向于使用用户线程

4、线程的实现方式 – 混合实现

将内核线程与用户线程一起使用的实现方式被称为 N: M 实现

  • 这种混合实现下,既存在用户线程,又存在轻量级进程。

用户线程还是完全建立在用户空间中,因此用户线程的创建、切换等操作依然廉价,并可以支持大规模用户并发;

操作系统支持的轻量级进程可以作为用户线程和内核线程之间的桥梁,这样可以,这样可以使用内核提供的线程调度功能及处理映射,并且用户线程的系统调用要通过轻量进程来完成,这大大降低了整个进程被完全阻塞的风险。

5、Java 线程的实现

  • 在早期的 Classic 虚拟机上(JDK 1.2 以前),是基于一种被称为 绿色线程用户线程实现的。
  • 从 JDK 1.3 开始,主流平台上的主流商用 Java 虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用 1:1 的线程模型
  • HotSpot 虚拟机中的每一个 Java 线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构

所以 HotSpot 自己是不会去干涉线程调度的(但可以设置线程优先级给操作系统提供调度建议),它会将这部分工作完全交给底下的擦欧总系统去处理。所以何时冻结或唤醒线程、该给线程分配多少处理器时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的

6、Java 线程调度

线程调度是指系统为线程分配处理器使用权的过程,调度方式主要有两种,分别是协调性线程调度和抢占式线程调度。

  • 如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完后,要主动通知系统切换到另一个线程上去,协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么线程同步的问题。

LUA 语言中的 协同例程 就是这类实现,它的坏处也很明显:线程执行时间不可控制,甚至如果一个线程中的代码编写有问题,一直不告知系统来进行线程切换,那么程序就会一直阻塞在那里。(只要有一个进程坚持不让出处理器执行时间,那么就可能导致整个系统崩溃)

  • 如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身决定Java 采用的线程调度模式就是抢占式调度。

比如说在 Java 中,有 Thread::yieid() 方法可以自动让出执行时间,但是如果想要主动获取执行时间,那么线程本身是没有什么方法的。

在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程甚至整个系统阻塞的问题

  • 在 Java 中,我们可以通过设置线程优先级来 建议 操作系统给某些线程多分配一些执行时间。

在两个线程同时处于 Ready 状态时,优先级越高的线程越容易被操作系统选择执行;

7、状态转换

Java 语言定义了 6 种线程状态,在某一时刻下,一个线程只能有且仅有其中的一种状态,且可以通过特定方法进行状态切换。

  • 新建(New):创建后尚未启动的线程处于这种状态
  • 运行(Runnable):包括操作系统线程状态的 Running 和 Ready ,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。
  • 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒,下面的方法会使线程陷入无限期等待状态
  1. 没有设置 Timeout 参数的 Object::wait() 方法;
  2. 没有设置 Timeout 参数的 Thread::join() 方法;
  3. LockSupport::park() 方法
  • 限期等待(Time Waiting):处于这种状态的线程也不会被分配处理器执行 时间,不过无需等待被其他线程显示唤醒,在一定时间之后它们会有系统自动唤醒。下面的方法会使线程进行限期等待状态
  1. Thread::sleep() 方法
  2. 设置了 Timeout 参数的 Object::wait() 方法;
  3. 设置了 Timeout 参数的 Thread::join() 方法;
  4. LockSupport::parkNanos() 方法
  5. LockSupport::parkUntil() 方法
  • 阻塞(Blocked):线程被阻塞了,阻塞状态等待状态 的区别是阻塞状态在等待着获取到一把排他锁
  • 结束(Terminated):已终止线程的线程状态,线程已经执行结束。

转换关系图如下

image.png

五、线程安全和锁优化

5.1、Java 语言中的线程安全

我们可以将 Java 语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

1、不可变

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

只要一个 final 对象被正确地构造出来(this 引用没有逃逸的情况),那么其外部的可见状态将永远不会改变,永远都不会看到它在多线程之中处于不一致的状态。

  • 如果多线程共享的数据是一个基本数据类型,那么只需要在定义时使用 final 关键字修饰它就可以保证它是不可变的。
  • 如果多线程共享的数据是一个对象,由于 Java 语言目前暂时没有提供值类型的支持,那就需要对象自行保证其行为不会对其状态产生任何影响才行。

比如说 java.lang.String 类的实例对象就是一个典型的不可变对象。

2、绝对的线程安全

  • 比如说 java.util.Vector 是一个线程安全的容器,它的 add()get()size() 等方法都是被 synchronized 修饰的,尽管效率不高,但是它同时具备了原子性、有序性、可见性。

但即使这样,不意味着调用它就永远不再需要同步手段了。

3、相对线程安全

  • 相对线程安全就是我们通常意义上说的线程安全,它需要保证对这个对象的单次操作是线程安全的,我们在调用时不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

在 Java 语言中,大部分声称线程安全的类都属于这种类型,比如说 Vector 、 HashTable 和 Collections 类的 synchronizedCollection() 方法包装的集合等。

4、线程兼容

  • 线程兼容指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境下可以安全地使用。

我们平常说的一个类不是线程安全的,通常就是对应这种情况。如 ArrayList 和 HashMap

5、线程对立

  • 线程对立指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码,这种排斥多线程的代码是很少见的,应该避免。

5.2、线程安全的实现方法

1、互斥同步 – synchronized

  • 最常见也是最主要的并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量时)线程使用。
  • 互斥是实现同步的一种手段,互斥是因,同步是果;互斥是方法,而同步是目的
  • Java 中最基本的同步手段是 synchronized 关键字,这是一种块结构的同步语法。在经过 javac 关键字编译后,会在同步块的前后分别形成 monitorentermonitorexit 两个字节码指令。

这两个字节码指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象。

  • 在执行 monitorenter 指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,那么就把锁的计数器的值 + 1,而在执行 monitorexit 指令时会将锁计数器的值 - 1 .

一旦计数器的值归 0 ,那么就代表这个锁被释放了。如果获取对象锁失败,那么线程将会进入阻塞状态,直到请求持有这个锁对象的线程释放这个锁为止。

  1. 被 synchronized 修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况
  2. 被 synchronized 修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程地进入

这意味着无法像处理某些数据库的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。

2、互斥同步 – Lock

从 JDK 5 起,Java 类库中提供了 java.util.concurrent 包,其中的 Lock 接口便成为了另一种全新的互斥同步手段。

  • 基于 Lock 接口,用户可以以非块结构(Non-Block Structured)来实现互斥同步。
  • 可重入锁(ReentrantLock)是 Lock 接口最常见的一种实现,它与 synchronized 一样是可重入的,在基本用法上也是差不多的,只是代码略有区别,不过相较于 synchronized ,ReentrantLock 增加了一些高级功能,主要是一下三个功能
  1. 等待可中断

指当持有锁的线程长期不释放锁时,正在等待的线程可以选择放弃等待,改为处理其他事情。这个特性对处理执行时间非常长的同步块很有帮助。

  1. 公平锁

指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获取锁;而非公平锁不保证这一点,在锁释放时,任何一个等待锁的线程都可能获取到锁。

synchronized 中的锁就是非公平的,而 ReentrantLock 在默认情况下也是非公平锁,但可以通过构造函数要求使用公平锁。

一旦使用了公平锁,会导致 ReentrantLock 性能急剧下降,明显影响吞吐性能。

  1. 锁绑定多个条件

指一个 ReentrantLock 对象可以同时绑定多个 Condition 对象。

3、ReentrantLock 和 synchronized 如何选择?

  • 在 JDK 6 后, synchronized 的性能已经得到大幅优化,性能不再是选择 ReentrantLock 和 synchronized 的决定因素
  1. synchronized 是在 Java 语法层面的同步,足够清晰简单,因此在需要使用基础同步功能时,推荐使用 synchronized
  2. Lock 应该确保在 finally 中释放锁,否则一旦受同步保护的代码块抛出异常,则有可能永远都不释放持有的锁。这一点必须由程序员来保证,而 synchronized 就没有这个顾虑。
  3. Java 虚拟机可以在线程和对象的元数据中记录 synchronized 中锁的相关信息,使用 Lock 的话,Java 虚拟机难以知道具体哪些锁对象是由特定线程持有的。

4、非阻塞同步

  • 互斥同步面临的主要问题是线程阻塞和唤醒线程所带来的性能开销,阻塞同步是一种悲观的并发策略。
  • 非阻塞同步常用的手段是 CAS(Compare-and-Swap)

5、无同步方案

  • 对于某些情况,可以使用 ThreadLocal ,为线程提供变量副本的方式来保证线程安全。

5.3、锁优化

高效并发是从 JDK 5 升级到 JDK 6 后一项重要的改进项,HotSpot 虚拟机开发团队在这个版本上花费了大量资源去实现各种锁优化技术,如适应性自选、锁消除、锁膨胀、轻量级锁和偏向锁等。

1、自旋锁和适应性自旋

  • 让后续请求锁的线程稍等一会,但步放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。

为了让线程等待,我们只需要执行一个忙循环(自旋),这项技术就是所谓的自旋

  • 如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间非常长,那么自选的线程只会白白地消耗处理器资源,而不会进行任何有价值的工作,这就会带来性能的浪费。
  • JDK 6 对自旋锁的优化,引入了自适应的自旋。自适应意味着自选的时间不再是固定的了,而是根据上一次在同一个锁上自旋的时间及锁的拥有者的状态来决定的
  1. 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为此次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间
  2. 如果对于某个锁,自选很少获得过锁,那么在之后想要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。

2、锁消除

  • 锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。

锁消除的主要判断依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那么就可以将它当作栈上数据对待,认为它们是线程私有的,自然可以不用加锁

3、锁粗化

  • 大多数情况下, 我们推荐将同步块的作用范围限制得尽可能小,即旨在共享数据的实际作用域中进行同步,这样可以使得需要进行同步的操作数量尽可能小。

但是如果一系列的连续操作都对同一个对象重复加锁和解锁,甚至加锁操作出现在循环体内,那么即使没有线程竞争,频繁地进行互斥同步也会导致不必要的性能损耗

4、轻量级锁

  • JDK 6 加入了新型锁机制,它名字中的 轻量级 是相对于使用操作系统互斥量来实现的传统锁(重量级锁)而言的。
  • 它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗
  • 在对象头的 Mark Word 中,有 2 bit 用于当前同步对象的锁状态,1 bit 用于记录偏向模式,对应量和状态说明如下
  1. 01 表示该同步对象未被锁定(偏向模式对应的 bit 为 0)
  2. 00 表示轻量级锁定(偏向模式对应的 bit 为 0)
  3. 10 表示重量级锁定(偏向模式对应的 bit 为 0)
  4. 11 表示 GC 标记(偏向模式对应的 bit 为 0)
  5. 01 表示偏向锁(偏向模式对应的 bit 为 1)
  • 轻量级锁的工作过程
  1. 在代码即将进入同步块时,如果此同步对象没有被锁定(锁标志位为 01 状态),那么虚拟机将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝。
  2. 然后,虚拟机使用 CAS 操作尝试将对象的 MarkWord 更新为指向 Lock Record 的指针。如果更新成功,那么代表该线程拥有了这个对象的锁,并且对象 Mark Word 的锁标志位将变为 00 ,表示此对象正处于轻量级锁状态。

如果这个更新失败了,那么证明至少还存在一条线程与当前线程竞争获取该对象的锁。那么虚拟机会首先检查对象的 MarkWord 是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那么直接进入同步块执行代码即可,否则说明这个锁对象已经被其他线程抢占了。

如果出现两条以上的线程争抢同一把锁的情况,那么轻量级锁就不再有效,这个时候轻量级锁将膨胀为重量级锁,同步对象锁的标志位会变为 10 ,此时 Mark Word 中存储的就是指向重量级锁的指针,后面等待锁的线程必须进行阻塞状态。

  • 如果没有竞争,那么轻量级锁可以通过 CAS 操作成功避免使用互斥量的开销;
  • 如果确实存在锁竞争,那么除了互斥量本身的开销外,还额外发生了 CAS 的开销,因此有竞争的情况下,轻量级锁反而比传统的重量级锁更慢

5、偏向锁

  • 同样是 JDK 6 引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序性能。

如果说轻量级锁是在无竞争情况下使用 CAS 操作消除同步使用的互斥量,那么偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不去做了。

  • 偏向锁中的偏,就是偏心、偏袒的偏。它的意思是这个锁会偏向于第一个获取它的线程,如果在接下来的执行过程中,这个锁一直没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。
  • 假设当前虚拟机启用了偏向锁(启用参数为 -XX:+UseBiasedLocking ),那么当锁对象第一次被一个线程获取时,虚拟机会将对象头的锁标志位置为 01 ,同时将偏向模式设置为 1 ,表示进入偏向模式;同时使用 CAS 将获取到这个锁的线程 ID 记录在对象的 Mark Word 中。

如果操作成功,那么持有这个锁的线程以后每次进入这个锁相关的同步块时,虚拟机都不可以不再进行任何的同步操作(比如说加锁解锁、对 Mark Word 的更新操作)

  • 一旦出现另一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。然后根据锁对象目前是否处于被锁定的状态决定是否撤销偏向

撤销后标志位将恢复到未锁定(01)或者轻量级锁定(00)状态。

  • 当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;
  • 当一个对象正处于偏向锁状态,又收到需要计算其一致哈希码的请求时,它的偏向状态会立即被撤销,同时锁膨胀为重量级锁(代表重量级锁的 ObjectMonitor 类有字段可以记录非加锁状态下的 Mark Word ,自然可以用于存储 哈希码)

注意,上面所说的一致性哈希码是通过 Object::hashCode() 或者 System::identityHashCode(Object) 方法的调用获取的,如果重写了类的 hashCode() 方法,那么计算哈希码时不会产生这里所说的请求。