Java内置锁的核心原理
线程安全问题
什么是线程安全呢?当多个线程并发访问某个Java对象(Object)时,无论系统如何调度这些线程,也无论这些线程将如何交替操作,这个对象都能表现出一致的、正确的行为,那么对这个对象的操作是线程安全的。如果这个对象表现出不一致的、错误的行为,那么对这个对象的操作不是线程安全的,发生了线程的安全问题。
自增运算不是线程安全的
粗看上去,感觉这是一件不可思议的事情:对一个整数进行自增运算(++),怎么可能不是线程安全的呢?这可是只有一个完整的操作,看上去是那么的不可分割。
使用10个线程,对一个共享的变量,每个线程自增1000次,看看最终的结果是不是1万。
1 | import java.util.concurrent.CountDownLatch; |
原因分析
为什么自增运算符不是线程安全的呢?实际上,一个自增运算符是一个复合操作,至少包括三个JVM指令:“内存取值”“寄存器增加1”和“存值到内存”。这三个指令在JVM内部是独立进行的,中间完
全可能会出现多个线程并发进行。
比如在amount=100时,假设有三个线程同一时间读取amount值,读到的都是100,增加1后结果为101,三个线程都将结果存入amount的内存,amount的结果是101,而不是103。
“内存取值”“寄存器增加1”和“存值到内存”这三个JVM指令本身是不可再分的,它们都具备原子性,是线程安全的,也叫原子操作。但是,两个或者两个以上的原子操作合在一起进行操作就不再具
备原子性了。比如先读后写,就有可能在读之后,其实这个变量被修改了,出现读和写数据不一致的情况。
临界区资源与临界区代码段
Java工程师在进行代码开发时,常常倾向于认为代码会以线性的、串行的方式执行,容易忽视多个线程并行执行,从而导致意想不到的结果。
前面的线程安全小实验展示了在多个线程操作相同资源(如变量、数组或者对象)时就可能出现线程安全问题。一般来说,只在多个线程对这个资源进行写操作的时候才会出现问题,如果是简单的读操作,不改变资源的话,显然是不会出现问题的。
临界区资源表示一种可以被多个线程使用的公共资源或共享数据,但是每一次只能有一个线程使用它。一旦临界区资源被占用,想使用该资源的其他线程则必须等待。
在并发情况下,临界区资源是受保护的对象。临界区代码段(Critical Section)是每个线程中访问临界资源的那段代码,多个线程必须互斥地对临界区资源进行访问。线程进入临界区代码段之前,必须在进入区申请资源,申请成功之后执行临界区代码段,执行完成之后释放资源。
竞态条件(Race Conditions
)可能是由于在访问临界区代码段时没有互斥地访问而导致的特殊情况。如果多个线程在临界区代码段的并发执行结果可能因为代码的执行顺序不同而不同,我们就说这时在临界区出现了竞态条件问题。在前面的线程安全小实验的代码中,amount为临界区资源,selfPlus()可以理解为临界区代码段,具体如下:
1 | private Integer amount = 0; //临界区资源 |
当多个线程访问临界区的selfPlus()方法时,就会出现竞态条件的问题。更标准地说,当两个或多个线程竞争同一个资源时,对资源的访问顺序就变得非常关键。
为了避免竞态条件的问题,我们必须保证临界区代码段操作具备排他性。这就意味着当一个线程进入临界区代码段执行时,其他线程不能进入临界区代码段执行。
在Java中,我们可以使用synchronized
关键字同步代码块,对临界区代码段进行排他性保护,示意代码如下:
1 | synchronized(syncObject) { |
在Java中,使用synchronized
关键字还可以使用Lock显式锁
实例,或者使用原子变量(Atomic Variables)对临界区代码段进行排他性保护。Lock显式锁、原子变量将在后续章节中介绍,接下来将介
绍synchronized关键字。
synchronized关键字
在Java中,线程同步使用最多的方法是使用synchronized
关键字。每个Java对象都隐含有一把锁,这里称为Java内置锁(或者对象锁、隐式锁)。使用synchronized(syncObject)调用相当于获取syncObject的内置锁,所以可以使用内置锁对临界区代码段进行排他性保护。
任何时间只允许一个线程进入同步方法(临界区代码段),如果其他线程需要执行同一个方法,那么只能等待和排队。
synchronized同步方法
synchronized关键字是Java的保留字,当使用synchronized关键字修饰一个方法的时候,该方法被声明为同步方法,具体的例子如下:
1 | //同步方法 |
关键字synchronized的位置处于同步方法的返回类型之前。回到前面的线程安全小实验,现在使用synchronized关键字对临界区代码段进行保护,代码如下:
1 | private Integer amount = 0; |
再次运行测试用例程序,累加10000次之后,最终的结果不再有偏差,与预期的结果(10000)是相同的。
synchronized同步块
对于小的临界区,我们直接在方法声明中设置synchronized同步关键字,可以避免竞态条件的问题。但是对于较大的临界区代码段,为了执行效率,最好将同步方法分为小的临界区代码段。通过下面这个例子来具体讲述:
1 | public class TwoPlus { |
两个临界区资源分别为sum1和sum2。使用synchronized对plus(int val1,int val2)进行同步保护之后,进入临界区代码段的线程拥有sum1和sum2的操作权,并且是全部占用。一旦线程进入,当线程在操
作sum1而没有操作sum2时,也将sum2的操作权白白占用,其他的线程由于没有进入临界区,只能看着sum2被闲置而不能去执行操作。所以,将synchronized加在方法上,如果其保护的临界区代码段
包含的临界区资源(要求是相互独立的)多于一个,就会造成临界区资源的闲置等待,进而会影响临界区代码段的吞吐量。为了提升吞吐量,可以将synchronized关键字放在函数体内,同步一个代码块。synchronized同步块的写法是:
1 | synchronized(syncObject) //同步块而不是方法 |
在synchronized同步块后边的括号中是一个syncObject对象,代表着进入临界区代码段需要获取syncObject对象的监视锁,或者说将syncObject对象监视锁作为临界区代码段的同步锁。
由于每一个Java对象都有一把监视锁,因此任何Java对象都能作为synchronized的同步锁。
单个线程在synchronized同步块后面的同步锁后,才能进入临界区代码段;反过来说,当一个线程获得syncObject对象的监视锁后,其他线程就只能等待。
synchronized方法和synchronized同步块有什么区别呢?
总体来说,synchronized方法是一种粗粒度的并发控制,某一时刻只能有一个线程执行该synchronized方法;而synchronized代码块是一种细粒度的并发控制,处于synchronized块之外的其他代码是可以被多个线程并发访问的。在一个方法中,并不一定所有代码都是临界区代码段,可能只有几行代码会涉及线程同步问题。所以synchronized代码块比synchronized方法更加细粒度地控制了多个线程的同步访问。
synchronized方法和synchronized代码块有什么联系呢?
在Java的内部实现上,synchronized方法实际上等同于用一个synchronized代码块,这个代码块包含同步方法中的所有语句,然后在synchronized代码块的括号中传入this关键字,使用this对象锁作为
进入临界区的同步锁。synchronized方法的同步锁实质上使用了this对象锁,这样就免去了手工设置同步锁的工作。而使用synchronized代码块需要手工设置同步锁。
静态的同步方法
在Java世界里一切皆对象。Java有两种对象:Object实例对象和Class对象。每个类运行时的类型信息用Class对象表示,它包含与类名称、继承关系、字段、方法有关的信息。JVM将一个类加载入自己的方法区内存时,会为其创建一个Class对象,对于一个类来说其Class对象是唯一的。
Class类没有公共的构造方法,Class对象是在类加载的时候由Java虚拟机调用类加载器中的defineClass方法自动构造的,因此不能显式地声明一个Class对象。
所有的类都是在第一次使用时被动态加载到JVM中的(懒加载),其各个类都是在必需时才加载的。这一点与许多传统语言(如C++)都不同,JVM为动态加载机制配套了一个判定一个类是否已经被加载的检查动作,使得类加载器首先检查这个类的Class对象是否已经被加载。如果尚未加载,类加载器就会根据类的全限定名查找.class文件,验证后加载到JVM的方法区内存,并构造其对应的Class对象。
普通的synchronized实例方法,其同步锁是当前对象this的监视锁。如果某个synchronized方法是static(静态)方法,而不是普通的对象实例方法,其同步锁又是什么呢?
下面展示一个使用synchronized关键字修饰static方法的例子,具体如下:
1 | package com.crazymakercircle.plus; |
大家都知道,静态方法属于Class实例而不是单个Object实例,在静态方法内部是不可以访问Object实例的this引用(也叫指针、句柄)的。所以,修饰static方法的synchronized关键字就没有办法获
得Object实例的this对象的监视锁。
实际上,使用synchronized关键字修饰static方法时,synchronized的同步锁并不是普通Object对象的监视锁,而是类所对应的Class对象的监视锁。为了以示区分,这里将Object对象的监视锁叫作对象锁,将Class对象的监视锁叫作类锁。当synchronized关键字修饰static方法时,同步锁为类锁;当synchronized关键字修饰普通的成员方法(非静态方法)时,同步锁为类锁。由于类的对象实例可以有很多,但是每个类只有一个Class实例,因此使用类锁作为synchronized的同步锁时会造成同一个JVM内的所有线程只能互斥地进入临界区段。
所以,使用synchronized关键字修饰static方法是非常粗粒度的同步机制。
通过synchronized关键字所抢占的同步锁什么时候释放呢?
一种场景是synchronized块(代码块或者方法)正确执行完毕,监视锁自动释放;另一种场景是程序出现异常,非正常退出synchronized块,监视锁也会自动释放。所以,使用synchronized块时不必担心监视锁的释放问题。
生产者-消费者问题
生产者-消费者问题(Producer-Consumer Problem)也称有限缓冲问题(Bounded-Buffer Problem),是一个多线程同步问题的经典案例。
生产者-消费者问题描述了两类访问共享缓冲区的线程(所谓的“生产者”和“消费者”)在实际运行时会发生的问题。生产者线程的主要功能是生成一定量的数据放到缓冲区中,然后重复此过程。消
费者线程的主要功能是从缓冲区提取(或消耗)数据。
生产者-消费者问题的关键是:
- 保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区空时消耗数据。
- 保证在生产者加入过程、消费者消耗过程中,不会产生错误的数据和行为。
生产者-消费者问题不仅仅是一个多线程同步问题的经典案例,而且业内已经将解决该问题的方案抽象成了一种设计模式——“生产者消费者”模式。“生产者-消费者”模式是一个经典的多线程设计模
式,它为多线程间的协作提供了良好的解决方案。
生产者-消费者模式
在生产者-消费者模式中,通常有两类线程,即生产者线程(若干个)和消费者线程(若干个)。生产者线程向数据缓冲区(DataBuffer)加入数据,消费者线程则从数据缓冲区消耗数据。
在生产者-消费者模式中,至少有以下关键点:
- 生产者与生产者之间、消费者与消费者之间,对数据缓冲区的操作是并发进行的。
- 数据缓冲区是有容量上限的。数据缓冲区满后,生产者不能再加入数据;数据缓冲区空时,消费者不能再取出数据。
- 数据缓冲区是线程安全的。在并发操作数据缓冲区的过程中,不能出现数据不一致的情况;或者在多个线程并发更改共享数据后,不会造成出现脏数据的情况。
- 生产者或者消费者线程在空闲时需要尽可能阻塞而不是执行无效的空操作,尽量节约CPU资源。
线程不安全的实现版本
根据上面对生产者-消费者问题的描述先来实现一个非线程安全版本,包含数据缓冲区(DataBuffer)类、生产者(Producer)类和消费者(Consumer)类。
通用生产者类的代码具体如下:
1 | public class Producer implements Runnable { |
通用Consumer类的代码具体如下:
1 | public class Consumer implements Runnable { |
数据区缓冲区实例
1 | public class NotSafeDataBuffer<T> { |
生产动作、消费动作的定义
1 | public class NotSafePetStore { |
启动main()方法,程序开始并发执行,稍微等待一段时间,问题
就出来了,部分结果截取如下:
1 | java.lang.IndexOutOfBoundsException: Index: 0, Size: 1 |
从以上异常可以看出,在向数据缓冲区进行元素的增加或者提取时,多个线程在并发执行对amount、dataList两个成员操作时次序已经混乱,导致出现数据不一致和线程安全问题。
线程安全的实现版本
解决线程安全问题很简单,为临界区代码加上synchronized关键字即可,主要修改的是涉及操作两个临界区资源amount和dataList的代码,具体为DataBuffer的add(…)和fetch()方法。
创建一个安全的数据缓存区类SafeDataBuffer,在其add(…)和fetch()两个实例方法的public声明后面加上synchronized
关键字即可。其他的代码一行不动,与NotSafeDataBuffer的代码相同。
1 | // 向数据区增加一个元素 |
虽然线程安全问题顺利解决了,但是以上解决方式使用了SafeDataBuffer实例的对象锁作为同步锁,这样一来,所有的生产、消费动作在执行过程中都需要抢占同一个同步锁,最终的结果是所有
的生产、消费动作都被串行化了。
高效率的生产者-消费者模式,生产、消费动作肯定不能串行执行,而是需要并行执行,而且并行化程度越高越好。如何既保障没有线程安全问题,又能提高生产、消费动作的并行化程度呢?这就是后续的实现版本需要解决的问题。
如果需要开发出并行化程度更高的生产者-消费者模式实现版本,需要彻底地掌握和理解对象锁、synchronized等机制的内部原理,这就需要从Java对象的头部结构等基础知识学起。
Java对象结构
Java内置锁的很多重要信息都存放在对象结构中。作为铺垫,在介绍Java内置锁之前,先为大家介绍一下Java对象结构。
- Java对象(Object实例)结构包括三部分:
对象头
、对象体
和对齐字节
对象头
对象头包括三个字段,第一个字段叫作
Mark Word
(标记字),用于存储自身运行时的数据,例如GC标志位、哈希码、锁状态等信息。32位JVM的Mark Word为32位,64位JVM的Mark Word为64位。第二个字段叫作
Class Pointer
(类对象指针),用于存放方法区Class对象的地址,虚拟机通过这个指针来确定这个对象是哪个类的实例。第三个字段叫作
Array Length
(数组长度)。如果对象是一个Java数组,那么此字段必须有,用于记录数组长度的数据;如果对象不是一个Java数组,那么此字段不存在,所以这是一个可选字段。
对象体
对象体包含对象的实例变量(成员变量),用于成员属性值,包括父类的成员属性值。这部分内存按4字节对齐。
对齐字节
对齐字节也叫作填充对齐,其作用是用来保证Java对象所占内存字节数为8的倍数, HotSpot VM的内存管理要求对象起始地址必须是8字节的整数倍。对象头本身是8的倍数,当对象的实例变量数据不是8的倍数时,便需要填充数据来保证8字节的对齐。
UseCompressedOops
对于对象指针而言,如果JVM中的对象数量过多,使用64位的指针将浪费大量内存,通过简单统计,64位JVM将会比32位JVM多耗费50%的内存。为了节约内存可以使用选项+UseCompressedOops
开启指针压缩。UseCompressedOops中的Oop为Ordinary object pointer(普通对象指针)的缩写。,
在堆内存小于32GB的情况下,64位虚拟机的UseCompressedOops选项是默认开启的,该选项表示开启Oop对象的指针压缩会将原来64位的Oop对象指针压缩为32位。
1 | // 手动开启Oop对象指针压缩的Java指令为: |
如果开启UseCompressedOops选项,以下类型的指针将从64位压缩至32位:
- Class对象的属性指针(静态变量)。
- Object对象的属性指针(成员变量)。
- 普通对象数组的元素指针。
当然,也不是所有的指针都会压缩,一些特殊类型的指针不会压缩,比如指向PermGen(永久代)的Class对象指针(JDK 8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。
如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度(Array Length字段)。Array Length字段的长度也随着JVM架构的不同而不同:在32位JVM上,长度为32位;在64位JVM上,长度为64位。64位JVM如果开启了Oop对象的指针压缩,Array Length字段的长度也将由64位压缩至32位。
Mark Word的结构信息
Java内置锁涉及很多重要信息,这些都存放在对象结构中,并且存放于对象头的Mark Word字段中。Mark Word的位长度为JVM的一个Word大小,也就是说32位JVM的Mark Word为32位,64位JVM为64位。Mark Word的位长度不会受到Oop对象指针压缩选项的影响。
Java内置锁的状态总共有4种,级别由低到高依次为:无锁、偏向锁、轻量级锁和重量级锁
。其实在JDK 1.6之前,Java内置锁还是一个重量级锁,是一个效率比较低下的锁,在JDK 1.6之后,JVM为了提高锁的获取与释放效率,对synchronized的实现进行了优化,引入了偏向锁和轻量级锁,从此以后Java内置锁的状态就有了4种(无锁、偏向锁、轻量级锁和重量级锁),并且4种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别)。
不同锁状态下的Mark Word字段结构Mark Word字段的结构与Java内置锁的状态强相关。为了让Mark
Word字段存储更多的信息,JVM将Mark Word最低两个位设置为Java内置锁状态位,不同锁状态下的32位Mark Word结构如表所示。
- lock:锁状态标记位,占两个二进制位,由于希望用尽可能少的二进制位表示尽可能多的信息,因此设置了lock标记。该标记的值不同,整个Mark Word表示的含义就不同。
- biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。lock和biased_lock两个标记位组合在一起共同表示Object实例处于什么样的锁状态。
- age:4位的Java对象分代年龄。在GC中,对象在Survivor区复制一次,年龄就增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由
于age只有4位,因此最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。 - identity_hashcode:31位的对象标识HashCode(哈希码)采用延迟加载技术,当调用Object.hashCode()方法或者System.identityHashCode()方法计算对象的HashCode后,其结果将被写到该对象头中。当对象被锁定时,该值会移动到Monitor(监视器)中。
- thread:54位的线程ID值为持有偏向锁的线程ID。
- epoch:偏向时间戳。
- ptr_to_lock_record:占62位,在轻量级锁的状态下指向栈帧中锁记录的指针。
- ptr_to_heavyweight_monitor:占62位,在重量级锁的状态下指向对象监视器的指针。
使用JOL工具查看对象的布局
JOL工具是一个jar包,使用它提供的工具类可以轻松解析出运行时java对象在内存中的结构,使用时首先需要引入maven GAV信息
1 | <!--Java Object Layout --> |
1 | import org.openjdk.jol.info.ClassLayout; |
结果分析
1 | # VM mode: 64 bits |
- VM mode: 64 bits - 表示当前虚拟机是64位虚拟机
- Compressed references (oops): 3-bit shift - 开启了对象指针压缩,在64位的Java虚拟机上,对象指针通常需要占用8字节(64位),但通过使用压缩指针技术,可以减少对象指针的占用空间,提高内存利用率。”3-bit shift” 意味着使用3位的位移操作来对对象指针进行压缩。通过将对象指针右移3位,可以消除指针中的一些无用位,从而减少对象指针的实际大小,使其占用更少的内存。
- Compressed class pointers: 3-bit shift - 开启了类指针压缩,其余同上。
- Object alignment: 8 bytes - 字节对齐使用8字节
- 接下来这部分输出表示引用类型、boolean、byte、char、short、int、float、long、double类型的数据所占的字节数大小以及在数组中的大小和偏移量。需要注意的是数组偏移量的概念,数组偏移量的数值其实就是对象头的大小,在上图中的16字节表示如果当前对象是数组,那对象头就是16字节,不要忘了,对象头中还有数组长度,在未开启对象指针压缩的情况下,它要占据4字节大小。
- 对象结构输出结果
- OFF:偏移量,单位字节
- SZ:大小,单位字节
- TYPE DESCRIPTION:类型描述,这里显示的比较直观,甚至可以看到是对象头的哪一部分
- VALUE:值,使用十六进制字符串表示,注意一个字节是8bit,占据两个16进制字符串,JOL0.15版本之前是小端序展示,0.15(包含0.15)版本之后使用大端序展示。
大小端问题
有关字节序列的存放格式目前有两大阵营:第一大阵营是PowerPC系列CPU,采用大端模式存放数据;第二大阵营是X86系列CPU,采用小端模式存放数据。那么究竟什么是大端模式,什么又是小端模式呢?
大端模式
是指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中。大端存放模式有点类似于把数据当作字符串顺序处理:地址由小向大增加,而数据从高位往低位放。小端模式
是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中,这种存储模式将地址的高低和数据位权有效地结合起来,高地址部分权值高,低地址部分权值低,此模式和日常的数字计算在方向上是一致的。
在处理器(即CPU)的计算过程中,因为使用小端模式在数据类型转换的时候(尤其是指针转换)不用考虑地址问题,所以小端模式是处理器的主流字节存放模式。
JVM所采用的字节存放模式是小端模式
。
由于所有网络协议都是采用大端模式来传输数据的,因此有时也会把大端模式称为“网络字节序”。当两台采用不同字节存放模式的主机通信时,在发送数据之前,都必须经过字节次序转换,转成“网
络字节序”(大端模式)后再进行传输。
例如,假设你有一个32位的整数 0x12345678
。在大端序下,这个整数会被存储为:
- 地址 0: 0x12
- 地址 1: 0x34
- 地址 2: 0x56
- 地址 3: 0x78
而在小端序(Little-endian)下,同样的整数会被存储为:
- 地址 0: 0x78
- 地址 1: 0x56
- 地址 2: 0x34
- 地址 3: 0x12
1 | import java.nio.ByteBuffer; |
无锁、偏向锁、轻量级锁和重量级锁
在JDK 1.6版本之前,所有的Java内置锁都是重量级锁。重量级锁会造成CPU在用户态和核心态之间频繁切换,所以代价高、效率低。JDK 1.6版本为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁和轻量级锁的实现。所以,在JDK 1.6版本中内置锁一共有4种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这些状态随着竞争情况逐渐升级。内置锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能再降级成偏向锁。这种能升级却不能降级的策略,其目的是提高获得锁和释放锁的效率。
无锁状态
Java对象刚创建时还没有任何线程来竞争,说明该对象处于无锁状态(无线程竞争它),这时偏向锁标识位是0,锁状态是01。
偏向锁状态
偏向锁是指一段同步代码一直被同一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。如果内置锁处于偏向状态,当有一个线程来竞争锁时,先用偏向锁,表示内置锁偏爱这个线程,这个线程要执行该锁关联的同步代码时,不需要再做任何检查和切换。偏向锁在竞争不激烈的情况下效率非常高。
偏向锁状态的Mark Word会记录内置锁自己偏爱的线程ID,内置锁会将该线程当作自己的熟人。
轻量级锁状态
当有两个线程开始竞争这个锁对象时,情况就发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象,锁对象的Mark Word就指向哪个线程的栈帧中的锁记录。
当锁处于偏向锁,又被另一个线程企图抢占时,偏向锁就会升级为轻量级锁。企图抢占的线程会通过自旋的形式尝试获取锁,不会阻塞抢锁线程,以便提高性能。
自旋原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要进行内核态和用户态之间的切换来进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免了用户线程和内核切换的消耗。
但是,线程自旋是需要消耗CPU的,如果一直获取不到锁,那么线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。JVM对于自旋周期的选择,JDK 1.6之后引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不是固定的,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定的。线程如果自旋成功了,下次自旋的次数就会更多,如果自旋失败了,自旋的次数就会减少。
如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,就会导致其他争用锁的线程在最大等待时间内还是获取不到锁,自旋不会一直持续下去,这时争用线程会停止自旋进入阻塞状态,该锁膨胀为重量级锁。
重量级锁状态
重量级锁会让其他申请的线程之间进入阻塞,性能降低。重量级锁也叫同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,该监视器对象用集合的形式来登记和管理排队的线程。
偏向锁的原理与实战
偏向锁主要解决无竞争下的锁性能问题,所谓的偏向就是偏心,即锁会偏向于当前已经占有锁的线程。
JDK 15 之前,偏向锁默认是 enabled,从 JDK 15 开始,默认就是 disabled,除非显示的通过 UseBiasedLocking 开启
。
核心原理
在实际场景中,如果一个同步块(或方法)没有多个线程竞争,而且总是由同一个线程多次重入获取锁,如果每次还有阻塞线程,唤醒CPU从用户态转为核心态,那么对于CPU是一种资源的浪费,为了解决这类问题,就引入了偏向锁的概念。
偏向锁的核心原理是:如果不存在线程竞争的一个线程获得了锁,那么锁就进入偏向状态,此时Mark Word的结构变为偏向锁结构,锁对象的锁标志位(lock)被改为01,偏向标志位(biased_lock)被改为1,然后线程的ID记录在锁对象的Mark Word中(使用CAS操作完成)。以后该线程获取锁时判断一下线程ID和标志位,就可以直接进入同步块,连CAS操作都不需要,这样就省去了大量有关锁申请的操作,从而也就提升了程序的性能。
偏向锁的主要作用是消除无竞争情况下的同步原语,进一步提升程序性能,所以,在没有锁竞争的场合,偏向锁有很好的优化效果。但是,一旦有第二条线程需要竞争锁,那么偏向模式立即结束,进入轻量级锁的状态。假如在大部分情况下同步块是没有竞争的,那么可以通过偏向来提高性能。即在无竞争时,之前获得锁的线程再次获得锁时会判断偏向锁的线程ID是否指向自己,如果是,那么该线程将不用再次获得锁,直接就可以进入同步块;如果未指向当前线程,当前线程就会采用CAS操作将Mark Word中的线程ID设置为当前线程ID,如果CAS操作成功,那么获取偏向锁成功,执行同步代码块,如果CAS操作失败,那么表示有竞争,抢锁线程被挂起,撤销占锁线程的偏向锁,然后将偏向锁膨胀为轻量级锁。
偏向锁的缺点:如果锁对象时常被多个线程竞争,偏向锁就是多余的,并且其撤销的过程会带来一些性能开销。
演示案例
-XX:-UseCompressedOops -XX:+UseBiasedLocking
1 | public static void main(String[] args) throws InterruptedException { |
为什么要等待5秒呢?因为JVM在启动的时候会延迟启用偏向锁机制。JVM默认把偏向锁延迟了4000毫秒,这就解释了为什么演示案例要等待5秒才能看到对象锁的偏向状态。
为什么偏向锁会延迟?因为JVM在启动的时候需要加载资源,这些对象加上偏向锁没有任何意义,不启用偏向锁能减少大量偏向锁撤销的成本。如果不想等待(在代码中让线程睡眠),可以直接通过修改JVM的
启动选项来禁止偏向锁延迟,其具体的启动选项如下:
1 | -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 |
- non-biasable; age: 0 表示无锁
- thin lock: 0x0000004164dff358 ,为轻量级锁
1 | # 未睡眠时结果: |
- 0x000001a430882005 (biased: 0x00000000690c2208; epoch: 0; age: 0),后3位101,打印了偏量锁的信息
1 | 未进入同步块,MarkWord 为: |
偏向锁的膨胀和撤销
假如有多个线程来竞争偏向锁,此对象锁已经有所偏向,其他的线程发现偏向锁并不是偏向自己,就说明存在了竞争,尝试撤销偏向锁(很可能引入安全点),然后膨胀到轻量级锁。
偏向锁的撤销:
从偏向状态撤回到原来的状态,也就是将 MarkWord 的第 3 位(是否偏向撤销)的值,
从 1 变回 0
偏向锁撤销的开销花费还是挺大的,其大概过程如下:
(1)在一个安全点停止拥有锁的线程。
(2)遍历线程的栈帧,检查是否存在锁记录。如果存在锁记录,就需要清空锁记录,使其变成无锁状态,并修复锁记录指向的MarkWord,清除其线程ID。
(3)将当前锁升级成轻量级锁。
(4)唤醒当前线程。
撤销偏向锁的条件:
(1)多个线程竞争偏向锁。
(2)调用偏向锁对象的hashcode()方法或者System.identityHashCode()方法计算对象的HashCode之后,将哈希码放置到Mark Word中,内置锁变成无锁状态,偏向锁将被撤销。
偏向锁是特定场景下提升程序效率的方案,可并不代表所有程序都满足这些特定场景,比如这些场景(在开启偏向锁的前提下):
- 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种 case 下,会导致大量的偏向锁撤销操作
- 明知有多线程竞争(生产者/消费者队列),还要使用偏向锁,也会导致各种撤销
很显然,这两种场景肯定会导致偏向撤销的,一个偏向撤销的成本无所谓,大量偏向撤销的成本是不能忽视的。那怎么办?
- 某些临界区存在两个及两个以上的线程竞争,那么偏向锁反而会降低性能,可以在启动JVM时就把偏向锁的默认功能关闭。
- 既不想禁用偏向锁,还不想忍受大量撤销偏向增加的成本,这种方案就是设计一个有阶梯的底线。
批量重偏向(Bulk Rebiasing)
这是第一种场景的快速解决方案,以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,只要 class 的对象发生偏向撤销,该计数器 +1,当这个值达到重偏向阈值(默认 20)时:
BiasedLockingBulkRebiasThreshold = 20
, JVM 就认为该 class 的偏向锁有问题,因此会进行批量重偏向, 它的实现方式就用到了我们上面说的 epoch。Epoch,如其含义「纪元」一样,就是一个时间戳。每个 class 对象会有一个对应的epoch字段,每个处于偏向锁状态对象的mark word 中也有该字段,其初始值为创建该对象时 class 中的epoch的值(此时二者是相等的)。
每次发生批量重偏向时,就将该值加 1,同时遍历 JVM 中所有线程的栈:找到该 class 所有正处于加锁状态的偏向锁对象,将其epoch字段改为新值, class 中不处于加锁状态的偏向锁对象(没被任何线程持有,但之前是被线程持有过的,这种锁对象的 markword 肯定也是有偏向的),保持 epoch 字段值不变
这样下次获得锁时,发现当前对象的epoch值和 class 的epoch,本着今朝不问前朝事 的原则(上一个纪元),就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过 CAS 操作将其mark word的线程 ID 改成当前线程 ID,这也算是一定程度的优化,毕竟没升级锁;如果 epoch 都一样,说明没有发生过批量重偏向, 如果 markword 有线程 ID,还有其他锁来竞争,那锁自然是要升级的(如同前面举的例子 epoch=0)。
批量重偏向是第一阶梯底线,还有第二阶梯底线
批量撤销(bulk revoke)
当达到重偏向阈值后,假设该 class 计数器继续增长,当其达到批量撤销的阈值后(默认 40)时,
BiasedLockingBulkRevokeThreshold = 40
JVM 就认为该 class 的使用场景存在多线程竞争,会标记该 class 为不可偏向。之后对于该 class 的锁,直接走轻量级锁的逻辑。
这就是第二阶梯底线,但是在第一阶梯到第二阶梯的过渡过程中,也就是在彻底禁用偏向锁之前,还会给一次改过自新的机会,那就是另外一个计时器:
BiasedLockingDecayTime = 25000
如果在距离上次批量重偏向发生的 25 秒之内,并且累计撤销计数达到 40,就会发生批量撤销(偏向锁彻底 game over)
如果在距离上次批量重偏向发生超过 25 秒之外,就会重置在 [20, 40) 内的计数, 再给次机会
大家有兴趣可以写代码测试一下临界点,观察锁对象 markword 的变化。至此,整个偏向锁的工作流程可以用一张图表示:
偏向锁的膨胀
如果偏向锁被占据,一旦有第二个线程争抢这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到内置锁偏向状态,这时表明在这个对象锁上已经存在竞争了。JVM检查原来持有该对象锁的占有线程是否依然存活,如果挂了,就可以将对象变为无锁状态,然后进行重新偏向,偏向为抢锁线程。
如果JVM检查到原来的线程依然存活,就进一步检查占有线程的调用堆栈是否通过锁记录持有偏向锁。如果存在锁记录,就表明原来的线程还在使用偏向锁,发生锁竞争,撤销原来的偏向锁,将偏向锁膨
胀(INFLATING)为轻量级锁。
偏向锁的好处
经验表明,其实大部分情况下进入一个同步代码块的线程都是同一个线程。这也是JDK会引入偏向锁的原因。所以,总体来说,使用偏向锁带来的好处还是大于偏向锁撤销和膨胀所带来的代价。
轻量级锁的原理与实战
引入轻量级锁的主要目的是在多线程竞争不激烈的情况下,通过CAS机制竞争锁减少重量级锁产生的性能损耗。重量级锁使用了操作系统底层的互斥锁(Mutex Lock),会导致线程在用户态和核心态之间频繁切换,从而带来较大的性能损耗。轻量级锁也被称为非阻塞同步、乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待。
核心原理
轻量锁存在的目的是尽可能不动用操作系统层面的互斥锁,因为其性能比较差。线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁地阻塞和唤醒对CPU来说是一件负担很重的工作。同时我们可以发现,很多对象锁的锁定状态只会持续很短的一段时间,例如整数的自加操作,在很短的时间内阻塞并唤醒线程显然不值得,为此引入了轻量级锁。轻量级锁是一种自旋锁,因为JVM本身就是一个应用,所以希望在应用层面上通过自旋解决线程同步问题。
轻量级锁的执行过程:在抢锁线程进入临界区之前,如果内置锁(临界区的同步对象)没有被锁定,JVM首先将在抢锁线程的栈帧中建立一个锁记录(Lock Record),用于存储对象目前Mark Word的拷贝,这时的线程堆栈与内置锁对象头大致如图:
然后抢锁线程将使用CAS自旋操作,尝试将内置锁对象头的MarkWord的ptr_to_lock_record(锁记录指针)更新为抢锁线程栈帧中锁记录的地址,如果这个更新执行成功了,这个线程就拥有了这个对象锁。然后JVM将Mark Word中的lock标记位改为00(轻量级锁标志),即表示该对象处于轻量级锁状态。抢锁成功之后,JVM会将Mark Word中原来的锁对象信息(如哈希码等)保存在抢锁线程锁记录的Displaced Mark Word(可以理解为放错地方的Mark Word)字段中,再将抢锁线程中锁记录的owner指针指向锁对象。在轻量级锁抢占成功之后,锁记录和对象头的状态如图所示。
锁记录是线程私有的,每个线程都有自己的一份锁记录,在创建完锁记录后,会将内置锁对象的Mark Word复制到锁记录的DisplacedMark Word字段。这是为什么呢?
- 避免频繁的内存屏障(Memory Barrier):在某些情况下,直接修改Mark Word可能会触发内存屏障,这会影响性能。通过将内置锁对象的Mark Word复制到锁记录中的Displaced Mark Word字段中,可以减少这些不必要的屏障。
- 提供临时状态信息:线程私有的锁记录可以在不立即执行完整锁定操作的情况下保存某些临时状态信息(如锁的对象引用),这有助于后续快速决定是否需要真正进入同步代码块。
- 简化实现和优化:使用锁记录可以减少直接对对象头的访问,避免复杂的内存模型约束。通过复制Mark Word到锁记录中,可以在一定程度上保持原有状态,同时简化了逻辑处理流程。
- 支持自旋(Spin)机制:在轻量级锁中,线程会在获取锁前先尝试自旋一段时间,如果发现锁已经被其他线程持有,则可以基于Displaced Mark Word更快地判断出当前线程是否继续等待或放弃。
演示案例
1 | public static void main(String[] args) throws InterruptedException { |
1 | 未进入同步块,MarkWord 为: |
现在执行第一个同步代码块后,在抢占完成之后,o实例的锁状态还是为偏向锁,只不过o实例的Mark Word记录了第一个抢占线程的ID。这一步的输出与前面偏向锁演示实例的输出相同,这里不再赘述。接着开始第二个抢锁线程,在第二个线程抢锁成功之后,o实例的锁状态为轻量级锁, lock标记位改为00(轻量级锁标志),其ptr_to_lock_record(锁记录指针)更新为抢锁线程栈帧中锁记录的地址,此时的锁为轻量级锁。轻量级锁被释放之后,o实例变成无锁状态.
轻量级锁的分类
轻量级锁主要有两种:普通自旋锁和自适应自旋锁。
普通自旋锁
所谓普通自旋锁,就是指当有线程来竞争锁时,抢锁线程会在原地循环等待,而不是被阻塞,直到那个占有锁的线程释放锁之后,这个抢锁线程才可以获得锁。
锁在原地循环等待的时候是会消耗CPU的,就相当于在执行一个什么也不干的空循环。所以轻量级锁适用于临界区代码耗时很短的场景,这样线程在原地等待很短的时间就能够获得锁了。
默认情况下,自旋的次数为10次,用户可以通过XX:PreBlockSpin
选项来进行更改。
自适应自旋锁
所谓自适应自旋锁,就是等待线程空循环的自旋次数并非是固定的,而是会动态地根据实际情况来改变自旋等待的次数,自旋次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。自适应自旋锁的大概原理是:
- 如果抢锁线程在同一个锁对象上之前成功获得过锁,JVM就会认为这次自旋很有可能再次成功,因此允许自旋等待持续相对更长的时间。
- 如果对于某个锁,抢锁线程很少成功获得过,那么JVM将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。
自适应自旋解决的是“锁竞争时间不确定”的问题。自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争程度趋于稳定。总的思想是:根据上一次自旋的时间与结果调整下一次自旋的时间。
JDK 1.6的轻量级锁使用的是普通自旋锁,且需要使用XX:+UseSpinning选项手工开启。JDK 1.7后,轻量级锁使用自适应自旋锁,JVM启动时自动开启,且自旋时间由JVM自动控制。
轻量级锁的膨胀
轻量级锁的问题在哪里呢?虽然大部分临界区代码的执行时间都是很短的,但是也会存在执行得很慢的临界区代码。临界区代码执行耗时较长,在其执行期间,其他线程都在原地自旋等待,会空消耗CPU。因此,如果竞争这个同步锁的线程很多,就会有多个线程在原地等待继续空循环消耗CPU(空自旋),这会带来很大的性能损耗。
轻量级锁的本意是为了减少多线程进入操作系统底层的互斥锁(Mutex Lock)的概率,并不是要替代操作系统互斥锁。所以,在争用激烈的场景下,轻量级锁会膨胀为基于操作系统内核互斥锁实现的重量级锁。
重量级锁的原理与实战
在JVM中,每个对象都关联一个监视器,这里的对象包含Object实例和Class实例。监视器是一个同步工具,相当于一个许可证,拿到许可证的线程即可进入临界区进行操作,没有拿到则需要阻塞等待。重量级锁通过监视器的方式保障了任何时间只允许一个线程通过受到监视器保护的临界区代码。
核心原理
JVM中每个对象都会有一个监视器,监视器和对象一起创建、销毁。监视器相当于一个用来监视这些线程进入的特殊房间,其义务是保证(同一时间)只有一个线程可以访问被保护的临界区代码块。
本质上,监视器是一种同步工具,也可以说是一种同步机制,主要特点是:
- 同步。监视器所保护的临界区代码是互斥地执行的。一个监视器是一个运行许可,任一线程进入临界区代码都需要获得这个许可,离开时把许可归还。
- 协作。监视器提供Signal机制,允许正持有许可的线程暂时放弃许可进入阻塞等待状态,等待其他线程发送Signal去唤醒;其他拥有许可的线程可以发送Signal,唤醒正在阻塞等待的线程,让它可以重新获得许可并启动执行。
在Hotspot虚拟机中,监视器是由C++类ObjectMonitor实现的,ObjectMonitor类定义在ObjectMonitor.hpp文件中,其构造器代码大致如下:
1 | //Monitor结构体 |
ObjectMonitor的Owner(_owner)
、WaitSet(_WaitSet
、Cxq(_cxq)
、EntryList(_EntryList)
这几个属性比较关键。
ObjectMonitor的WaitSet、Cxq、EntryList这三个队列存放抢夺重量级锁的线程,而ObjectMonitor的Owner所指向的线程即为获得锁的线程。
- Cxq:竞争队列(Contention Queue),所有请求锁的线程首先被放在这个竞争队列中。
- EntryList:Cxq中那些有资格成为候选资源的线程被移动到EntryList中。
- WaitSet:某个拥有ObjectMonitor的线程在调用Object.wait()方法之后将被阻塞,然后该线程将被放置在WaitSet链表中。
Cxq
Cxq并不是一个真正的队列,只是一个虚拟队列,原因在于Cxq是由Node及其next指针逻辑构成的,并不存在一个队列的数据结构。每次新加入Node会在Cxq的队头进行,通过CAS改变第一个节点的指针为
新增节点,同时设置新增节点的next指向后续节点;从Cxq取得元素时,会从队尾获取。显然,Cxq结构是一个无锁结构。
因为只有Owner线程才能从队尾取元素,即线程出列操作无争用,当然也就避免了CAS的ABA问题。
在线程进入Cxq前,抢锁线程会先尝试通过CAS自旋获取锁,如果获取不到,就进入Cxq队列,这明显对于已经进入Cxq队列的线程是不公平的。所以,synchronized同步块所使用的重量级锁是不公平锁
EntryList
EntryList与Cxq在逻辑上都属于等待队列。Cxq会被线程并发访问,为了降低对Cxq队尾的争用,而建立EntryList。在Owner线程释放锁时,JVM会从Cxq中迁移线程到EntryList,并会指定EntryList中的某个线程(一般为Head)为OnDeck Thread(Ready Thread)。EntryList中的线程作为候选竞争线程而存在。
OnDeck Thread与Owner Thread
JVM不直接把锁传递给Owner Thread,而是把锁竞争的权利交给OnDeck Thread,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大地提升系统的吞吐量,在JVM中,也把这种选择行为称为“竞争切换”。
OnDeck Thread获取到锁资源后会变为Owner Thread。无法获得锁的OnDeck Thread则会依然留在EntryList中,考虑到公平性,OnDeckThread在EntryList中的位置不发生变化(依然在队头)。
在OnDeck Thread成为Owner的过程中,还有一个不公平的事情,就是后来的新抢锁线程可能直接通过CAS自旋成为Owner而抢到锁。
WaitSet
如果Owner线程被bject.wait()
方法阻塞,就转移到WaitSet队列中,直到某个时刻通过Object.notify()
或者Object.notifyAll()
唤醒,该线程就会重新进入EntryList中。
重量级锁的开销
处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,线程的阻塞或者唤醒都需要操作系统来帮忙,Linux内核下采用pthread_mutex_lock系统调用实现,进程需要从用户态切换到内核态。
Linux系统的体系架构分为用户态(或者用户空间)和内核态(或者内核空间),Linux系统的内核是一组特殊的软件程序,负责控制计算机的硬件资源,例如协调CPU资源、分配内存资源,并且提供稳定的环境供应用
程序运行。应用程序的活动空间为用户空间,应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。
用户态与内核态有各自专用的内存空间、专用的寄存器等,进程从用户态切换至内核态需要传递许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
用户态的进程能够访问的资源受到了极大的控制,而运行在内核态的进程可以“为所欲为”。一个进程可以运行在用户态,也可以运行在内核态,那么肯定存在用户态和内核态切换的过程。进程从用户态到内核态切换主要包括以下三种方式:
- 硬件中断。硬件中断也称为外设中断,当外设完成用户的请求时会向CPU发送中断信号。
- 系统调用。其实系统调用本身就是中断,只不过是软件中断,跟硬件中断不同。
- 异常。如果当前进程运行在用户态,这个时候发生了异常事件(例如缺页异常),就会触发切换。
用户态是应用程序运行的空间,为了能访问到内核管理的资源(例如CPU、内存、I/O),可以通过内核态所提供的访问接口实现,这些接口就叫系统调用。pthread_mutex_lock系统调用是内核态为用户态进程提供的Linux内核态下互斥锁的访问机制,所以使用pthread_mutex_lock系统调用时,进程需要从用户态切换到内核态,而这种切换是需要消耗很多时间的,有可能比用户执行代码的时间还要长。
由于JVM轻量级锁使用CAS进行自旋抢锁,这些CAS操作都处于用户态下,进程不存在用户态和内核态之间的运行切换,因此JVM轻量级锁开销较小。而JVM重量级锁使用了Linux内核态下的互斥锁,这是重量级锁开销很大的原因。
演示案例
wait 方法是互斥量(重量级锁)独有的,一旦调用该方法,就会升级成重量级锁
1 | public static void main(String[] args) throws InterruptedException { |
通过以上输出可以看出0x000001ee12fc0caa (fat lock: 0x000001ee12fc0caa)
,此时锁状态已经从轻量级锁膨胀为重量级锁,其lock标记为10,说明此时存在激烈的锁争用。
1 | 未进入同步块,MarkWord 为: |
偏向锁、轻量级锁与重量级锁的对比
总结一下synchronized的执行过程,大致如下:
- 线程抢锁时,JVM首先检测内置锁对象Mark Word中的biased_lock(偏向锁标识)是否设置成1,lock(锁标志位)是否为01,如果都满足,确认内置锁对象为可偏向状态。
- 在内置锁对象确认为可偏向状态之后,JVM检查Mark Word中的线程ID是否为抢锁线程ID,如果是,就表示抢锁线程处于偏向锁状态,抢锁线程快速获得锁,开始执行临界区代码。
- 如果Mark Word中的线程ID并未指向抢锁线程,就通过CAS操作竞争锁。如果竞争成功,就将Mark Word中的线程ID设置为抢锁线程,偏向标志位设置为1,锁标志位设置为01,然后执行临界区代码,此时内置锁对象处于偏向锁状态。
- 如果CAS操作竞争失败,就说明发生了竞争,撤销偏向锁,进而升级为轻量级锁。
- JVM使用CAS将锁对象的Mark Word替换为抢锁线程的锁记录指针,如果成功,抢锁线程就获得锁。如果替换失败,就表示其他线程竞争锁,JVM尝试使用CAS自旋替换抢锁线程的锁记录指针,如果自旋成功(抢锁成功),那么锁对象依然处于轻量级锁状态。
- 如果JVM的CAS替换锁记录指针自旋失败,轻量级锁就膨胀为重量级锁,后面等待锁的线程也要进入阻塞状态。
总体来说,偏向锁是在没有发生锁争用的情况下使用的;一旦有了第二个线程争用锁,偏向锁就会升级为轻量级锁;如果锁争用很激烈,轻量级锁的CAS自旋到达阈值后,轻量级锁就会升级为重量级锁。
线程间通信
线程是操作系统调度的最小单位,有自己的栈空间,可以按照既定的代码逐步执行,但是如果每个线程间都孤立地运行,就会造资源浪费。
所以在现实中,如果需要多个线程按照指定的规则共同完成一个任务,那么这些线程之间就需要互相协调,这个过程被称为线程的通信。
线程间通信的定义
线程的通信可以被定义为:当多个线程共同操作共享的资源时,线程间通过某种方式互相告知自己的状态,以避免无效的资源争夺。
线程间通信的方式可以有很多种:等待-通知、共享内存、管道流。每种方式用不同的方法来实现,这里首先介绍等待-通知的通信方式。
“等待-通知”通信方式是Java中使用普遍的线程间通信方式,其经典的案例是“生产者-消费者”模式。
低效的线程轮询
首先回到前面的生产者-消费者安全版本的数据缓冲区类 SafeDataBuffer。其存在一个隐蔽但又很耗性能的问题:消费者每一轮消费,无论数据区是否为空,都需要进行数据区的询问和判断。其轮询代码如下:
1 | public synchronized T fetch() throws Exception { |
当数据区空时(amount <= 0),消费者无法取出数据,但是仍然做无用的数据区询问工作,白白耗费了CPU的时间片。对于生产者来说,也存在类似的无效轮询问题。当数据区满时,生产者无法加入数据,这时生产者执行add(T element)方法也白白耗费了CPU的时间片。
如何在生产者或者消费者空闲时节约CPU时间片,免去巨大的CPU资源浪费呢?
一个非常有效的办法是:使用“等待-通知”方式进行生产者与消费者之间的线程通信。
具体来说,在数据区满(amount.get() > MAX_AMOUNT)时,可以让生产者等待,等到下次数据区中可以加入数据时,给生产者发通知,让生产者唤醒。同样,在数据区为空(amount <= 0)时,可以让消费者等待,等到下次数据区中可以取出数据时,消费者才能被唤醒。那么,由谁去唤醒等待状态的生产者呢?可以在消费者取出一个数据后,由消费者去唤醒等待的生产者。同样,由谁去唤醒等待状态的消费者呢?可以在生产者加入一个数据后,由生产者去唤醒等待的消费者。
Java语言中“等待-通知”方式的线程间通信使用对象的wait()、notify()两类方法来实现。每个Java对象都有wait()、notify()两类实例方法,并且wait()、notify()方法和对象的监视器是紧密相关的。
wait方法和notify方法的原理
Java对象中的wait()、notify()两类方法就如同信号开关,用于等待方和通知方之间的交互。
对象的wait()方法
对象的
wait()
方法的主要作用是让当前线程阻塞并等待被唤醒。wait()方法与对象监视器紧密相关,使用wait()
方法时一定要放在同步块中。Wait()
方法的调用方法如下:1
2
3
4
5synchronized(locko){
//同步保护的代码块
locko.wait();
...
}Object类中的wait()方法有三个版本:
void wait()
这是一个基础版本,当前线程调用了同步对象locko的wait()实例方法后,将导致当前的线程等待,当前线程进入locko的监视器WaitSet,等待被其他线程唤醒。
void wait(long timeout)
这是一个限时等待版本,导致当前的线程等待,等待被其他线程唤醒,或者指定的时间timeout用完,线程不再等待。void wait(long timeout,int nanos)
这是一个高精度限时等待版本,其主要作用是更精确地控制等待时间。参数nanos是一个附加的纳秒级别的等待时间,从而实现更加高精度的等待时间控制。1秒=1000毫秒=1000 000微秒=1000 000 000纳秒
。
wait()方法的核心原理
对象的wait()方法的核心原理大致如下:
- 当线程调用了locko(某个同步锁对象)的wait()方法后,JVM会将当前线程加入locko监视器的WaitSet(等待集),等待被其他线程唤醒。
- 当前线程会释放locko对象监视器的Owner权利,让其他线程可以抢夺locko对象的监视器。
- 让当前线程等待,其状态变成WAITING。
对象的notify()方法
对象的notify()方法的主要作用是唤醒在等待的线程。notify()方法与对象监视器紧密相关,调用notify()方法时也需要放在同步块中。notify()方法的调用方法如下:
1 | synchronized(locko){ |
notify()方法有两个版本:
- 版本一:
void notify()
notify()
方法的主要作用为:locko.notify()
调用后,唤醒locko监视器等待集中的第一条等待线程;被唤醒的线程进入EntryList,其状态从WAITING变成BLOCKED。
- 版本二:void notifyAll()
locko.notifyAll()
被调用后,唤醒locko监视器等待集中的全部等待线程,所有被唤醒的线程进入EntryList,线程状态从WAITING变成BLOCKED。
notify()方法的核心原理
对象的notify()或者notifyAll()方法的核心原理大致如下:
- 当线程调用了locko(某个同步锁对象)的notify()方法后,JVM会唤醒locko监视器WaitSet中的第一条等待线程。
- 当线程调用了locko的notifyAll()方法后,JVM会唤醒locko监视器WaitSet中的所有等待线程。
- 等待线程被唤醒后,会从监视器的WaitSet移动到EntryList,线程具备了排队抢夺监视器Owner权利的资格,其状态从WAITING变成BLOCKED。
- EntryList中的线程抢夺到监视器的Owner权利之后,线程的状态从BLOCKED变成Runnable,具备重新执行的资格。
“等待-通知”通信模式演示案例
Java的“等待-通知”机制是指:一个线程A调用了同步对象的wait()方法进入等待状态,而另一线程B调用了同步对象的notify()或者notifyAll()方法通知等待线程,当线程A收到通知后,重新进入就绪状态,准备开始执行。线程间的通信需要借助同步对象(Object)的监视器来完成,Object对象的wait()、notify()方法就如开关信号,用于完成等待方和通知方之间的通信。
下面的演示示例定义了一个独立的同步对象locko,然后借助其wait()、notify()方法完成两个线程aitThread、NotifyThread之间的通信,具体代码如下:
- LOCK_OBJECT:用于临界区同步,临界区资源为数据缓冲区的dataList变量和amount变量。
- NOT_FULL:用于数据缓冲区的未满条件等待和通知。生产者在添加元素前需要判断数据区是否已满,如果是,生产者就进入NOT_FULL的同步区等待被通知,只要消费者消耗一个元素,数据区就是未满的,进入NOT_FULL的同步区发送通知。
- NOT_EMPTY:用于数据缓冲区的非空条件等待和通知。消费者在消耗元素前需要判断数据区是否已空,如果是,消费者就进入NOT_EMPTY的同步区等待被通知,只要生产者添加一个元素,数据区就是非空的,生产者会进入NOT_EMPTY的同步区发送通知。
1 | private final Object LOCK_OBJECT = new Object(); |
synchronized同步块的内部使用wait和notify
在调用同步对象的wait()和notify()系列方法时,“当前线程”必须拥有该对象的同步锁,也就是说,wait()和notify()系列方法需要在同步块中使用,否则JVM会抛出类似如下的异常IllegalMonitorStateException
:
1 | java.lang.IllegalMonitorStateException |
为什么wait和notify不在synchronized同步块的内部使用会抛出异常呢?这需要从wait()和notify()方法的原理说起。
wait()方法的原理:首先,JVM会释放当前线程的对象锁监视器的Owner资格;其次,JVM会将当前线程移入监视器的WaitSet队列,而这些操作都和对象锁监视器是相关的。所以,wait()方法必须在synchronized同步块的内部调用。在当前线程执行wait()方法前,必须通过synchronized()方法成为对象锁的监视器的Owner。
notify()方法的原理:JVM从对象锁的监视器的WaitSet队列移动一个线程到其EntryList队列,这些操作都与对象锁的监视器有关。所以,notify()方法也必须在synchronized同步块的内部调用。在执行notify()方法前,当前线程也必须通过synchronized()方法成为对象锁的监视器的Owner。
调用wait()和notify()系列方法进行线程通信的要点如下:
- 调用某个同步对象locko的wait()和notify()类型方法前,必须要取得这个锁对象的监视锁,所以wait()和notify()类型方法必须放在synchronized(locko)同步块中,如果没有获得监视锁,JVM就会报IllegalMonitorStateException异常。
- 调用wait()方法时使用while进行条件判断,如果是在某种条件下进行等待,对条件的判断就不能使用if语句做一次性判断,而是使用while循环进行反复判断。只有这样才能在线程被唤醒后继续检查wait的条件,并在条件没有满足的情况下继续等待。