大家好,欢迎来到IT知识分享网。
4.锁
- Java内置锁是一个互斥锁,这就意味着最多只有一个线程能够获得该锁,当线程B尝试去获得线程A持有的内置锁时,线程B必须等待或者阻塞,直到线程A释放这个锁,如果线程A不释放这个锁,那么线程B永远等待下去
- Java中每个对象都可以用作锁,这些锁称为内置锁。线程进入同步代码块或方法时会自动获得该锁,在退出同步代码块或方法时会释放该锁。获得内置锁的唯一途径就是进入这个锁保护的同步代码块或方法
1.线程安全问题
- 线程安全:当多个线程并发访问某个Java对象(Object)时,无论系统如何调度这些线程,也无论这些线程将如何交替操作,这个对象都能表现出一致的,正确的行为,那么对这个对象的操作就是线程安全的
- 如果这个对象表现出不一致,错误的行为,那么对这个对象的操作不是线程安全的,发生了线程的安全问题。
- 自增运算不是线程安全的
package threadDemo; public class NotSafeThreadDemo1 {
private Integer amount = 0; public void selfAdd(){
amount++; } public Integer getAmount(){
return amount; } } package threadDemo; import java.util.concurrent.CountDownLatch; public class PlusTest {
static final int MAX_TREAD = 10; static final int MAX_TURN = 1000; public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(MAX_TREAD); NotSafeThreadDemo1 notSafeThreadDemo1 = new NotSafeThreadDemo1(); Runnable runnable = () -> {
for (int i = 0; i < MAX_TURN; i++) {
notSafeThreadDemo1.selfAdd(); } countDownLatch.countDown(); }; for (int i = 0; i < MAX_TREAD; i++) {
new Thread(runnable).start(); } countDownLatch.await(); System.out.println("理论结果:" + MAX_TURN * MAX_TREAD); System.out.println("实际结果:" + notSafeThreadDemo1.getAmount()); System.out.println("差距是:" + (MAX_TURN * MAX_TREAD - notSafeThreadDemo1.getAmount()) ); } }
从结果可以看出,++运算在多线程并发执行场景下出现了不一致的错误的行为,自增运算符++不是线程安全的
- 上面代码中,为了获得10个线程的结果,主线程通过CountDownLatch(闭锁)工具类进行了并发线程的等待
- 闭锁是一个非常使用的等待多线程并发的工具类。调用线程可以在闭锁上进行等待,一直等待闭锁的次数减少到0,才继续往下执行,每一个被等待的线程执行完成之后,闭锁的次数减少到0,调用线程可以往下执行,从而达到并发等待的效果
- 原因:自增运算符不是线程安全的
- 实际上一个自增运算符是一个复合操作,至少包括三个JVM指令:内存取值,寄存器增加1,存值到内存,这三个指令在JVM内部是独立进行的,中间完全可能会出现多个线程并发进行。
- 比如在amount=100时,假设有三个线程同一时间读取amount值,读到的都是100,增加1后结果为101,三个线程都将结果存入amount的内存,amount的结果是101,而不是103
- 内存取值,寄存器增加,存值到内存这三个JVM指令本身是不可再分的,他们都具备原子性是线程安全的,也叫原子操作。但是两个或者两个以上的原子操作合在一起进行操作就不在具备原子性了。比如先读后写,就有可能在读之后,其实这个变量被修改了,出现读和写数据不一致的情况。
2.临界区资源和临界区代码块
- 一般来说,只在多个线程对这个资源进行写操作的时候才会出现问题,如果是简单的读操作,不改变资源的话,显然是不会出现问题的
- 注意代码不仅会以线程,串行的方式执行,也可能多个线程并行执行
- 临界区资源:表示一种可以被多个线程使用的公共资源或共享数据,但是每一次只能有一个线程使用它,一旦临界资源被占用,想使用该资源的其他线程则必须等待
- 在并发情况下,临界区资源是受保护的对象。临界区代码段(Critical Section)是每个线程中访问临界资源的那段代码,多个线程必须互斥地对临界区资源进行访问,线程进入临界区代码段之前,必须在进入区申请资源,申请成功之后执行临界区代码段,执行完成之后释放资源
- 竞态条件(Race Conditions):可能是由于在访问临界区代码段时没有互斥地访问而导致的特殊情况。如果多个线程在临界区代码段的并发执行结果可能因为代码的执行顺序不同而不同。我们就说这时在临界区出现了竞态条件问题
- 例如:amount为临界区资源,selfAdd()可以理解为临界区代码段
- 当多个线程访问临界区的selfAdd()方法时,就会出现竞态条件的问题,更标准地说,当两个或多个线程竞争同一个资源时,对资源的访问顺序就变得非常关键
- 为了避免竞态条件的问题,必须保证临界区代码段操作具备排他性。这就意味着当一个线程进入临界区代码段执行时,其他线程不能进入临界区代码段执行。在Java中,我们可以用synchronized关键字同步代码块,对临界区代码段进行排他性保护
- 在Java中,使用synchronized关键字还可以使用Lock显示锁实例,或者使用原子变量(Atomic Variables)对临界区代码段进行排他性保护
3.synchronized关键字
- Java中,线程同步使用最多的方法是使用synchronized关键字。每个Java对象都隐含有一把锁,这里成为Java内置锁(或对象锁,隐式锁)。使用synchronized(syncObject)调用相当于获取syncObject的内置锁,所以可以使用内置锁对临界区代码段进行排他性保护
4.synchronized同步方法
- synchronized关键字是Java的保留字,当使用synchronized关键字修饰一个方法的时候,该方法被声明为同步方法
public synchronized void selfAdd(){
amount++; }
- 关键字synchronized的位置处于同步方法的返回类型之前。
- 在方法声明中设置synchronized同步关键字,保证其方法的代码执行流程是排他性的。任何时间只允许一个线程进入同步方法(临界区代码段),如果其他线程需要执行同一个方法,那么只能等待和排队
5.synchronized同步块
对于小的临界区,可以直接在方法声明中设置synchronized同步关键字,可以避免竞态条件的问题。但是对于较大的临界区代码段,为了执行效率,最好将同步方法分为小的临界区代码段
public class TwoPlus{
private int sum1 = 0; private int sum2 = 0; //同步方法 public synchronized void plus(int val1,int val2){
//临界区代码段 this.sum1 += val1; this.sum2 += val2; } }
- 以上代码中,临界区代码段包含对两个临界区资源的操作,这两个临界区资源分别为sum1和sum2。使用synchronized对plus进行同步保护之后,进入临界区代码段的线程拥有sum1和sum2的操作全,并且是全部占用。一旦线程进入,当线程在操作sum1而没有操作sum2时,也将sum2的操作权白白占用,其他的线程由于没有进入临界区,只能看着sum2被闲置而不能去执行操作
- 所以将synchronized加在方法上,如果其保护的临界区代码段包含的临界区资源(要求是相互独立)多于一个,就会造成临界区资源的闲置等待,进而会影响临界区代码段的吞吐量(单位时间内成功执行临界区代码段的线程数量),为了提升吞吐量,可以将synchronized关键字放在函数体内,同步一个代码块。
synchronized(syncObject){
//同步块而不是方法 //临界区代码段的代码块 }
- 在synchroninzed同步块后面的括号中是一个syncObject对象,代表着进入临界区代码段需要获取syncObject对象的监视锁。或者说将synchronized对象监视锁作为临界区代码段的同步锁。由于每一个Java对象都有一把监视锁,因此任何java对象都能作为synchronized的同步锁
- 单个线程在获得synchronized同步块后面的同步锁后才能进入临界区代码段;反过来说,当一个线程获得syncObject对象的监视锁后,其他线程就只能等待。
- 使用synchronized同步块对上面的TwoPlus类进行吞吐量的提升改造:
public class TwoPlus{
private int sum1 = 0; private int sum2 = 0; private Integer sum1Lock = new Integer(1);//同步锁一 private Integer sum2Lock = new Integer(2);//同步锁二 public void plus(int val1,int val2){
//同步块1 synchronized(this.sum1Lock){
this.sum1 += val1; } //同步块2 synchronized(this.sum2Lock){
this.sum2 += val2; } } }
- 改造之后,对两个独立的临界区资源sum1和sum2的加法操作可以并发执行了,在某个时刻,不同的线程可以对sum1和sum2同时进行加法操作,提升了plus()方法的吞吐量
- 在TowPlus代码中,由于同步块1和同步块2保护着两个独立的临界区代码段,需要两把不同的syncObject对象锁,因此TwoPlus代码新加了sum1Lock和sum2Lock两个新的成员属性,这两个属性没有参与业务处理,TwoPlus仅仅利用了sum1Lock和sum2Lock的内置锁功能
- synchronized方法和synchronized同步块的区别:synchronized方法是一种粗粒度的并发控制,某一时刻只能有一个线程执行该synchronized方法;而synchronized代码块是一种细粒度的并发控制,处于synchronized块之外的其他代码是可以被多个线程并发访问的。在一个方法中,并不一定所有代码都是临界区代码段,可能只有几行代码会涉及线程同步问题,所以synchronized代码块比synchronized方法更加细粒度地控制了多个线程的同步访问
- synchronized方法和synchronized代码块的联系:在Java的内部实现上,synchronized方法实际上等同于用一个synchronized代码块,这个代码块包含同步方法中的所有语句,然后在synchronized代码块的括号中传入this关键字,使用this对象锁作为进入临界区的同步锁(如果有两个synchronized方法,访问的则是同一把锁,synchronized锁的是当前方法所属的对象本身)
- 同一个类中多个方法上加synchronized其实是当前对象的锁,是同一把锁
public void plus(){
synchronized(this){
amount++; } } public synchronized void plus(){
amount++; }
- synchronized方法的同步锁实质上使用了this对象锁,这样就免去了手工设置同步锁的工作。而使用synchronized代码块需要手工设置同步锁
6.静态的同步方法
- Java一切皆对象,Java有两种对象:Object实例对象和Class对象。每个类运行时的类型信息用Class对象表示,它包含类名称,继承关系,字段,方法有关的信息。Jvm将一个类加载入自己的方法内存时,会为其创建一个Class对象,对于一个类来说其Class对象是唯一的
- Class类没有公共的构造方法,Class对象是在类加载的时候由Java虚拟机调用类加载器中的defineClass方法自动构建的,因此不能显式地声明一个Class对象
- 所有的类都是在第一次使用时被动态加载到JVM中的(懒加载),其各个类都是在必需时才加载的
- JVM为动态加载机制配套了一个判定一个类是否已经被加载的检查动作,使得类加载器首先检查这个类的Class对象是否已经被加载。如果尚未加载,类加载器就会根据类的全限定名查找.class文件,验证后加载到JVM的方法区内存,并构造其对应的Class对象
- 普通的synchronized实例方法,其同步锁是当前this的监视锁。如果某个synchronized方法是static(静态)方法而不是普通的对象实例方法,其同步锁又是什么?
public class SafeStaticMethodPlus{
//静态的临界区资源 private static Integer amount = 0; //使用synchronized关键字修饰static方法 public static synchronized void selfPlus(){
amount++; } }
- 静态方法属于Class实例而不是单个Object实例,在静态方法内部是不可以访问Object实例的this引用(也叫指针,句柄)。所以修饰static方法的synchronized关键字就没有办法获得Object实例的this对象的监视锁。
- 实际上,使用synchronized关键字修饰static方法时,synchronized的同步锁并不是普通Object对象的监视锁,而是类所对应的Class对象的监视锁
- 为了区分,这里将Object对象的监视锁叫做对象锁,将Class对象的监视锁叫做类锁,当synchronized关键字修饰静态成员方法时,同步锁为类锁,由于类的对象实例可以有很多,但是每个类只有一个Class实例,因此使用类锁作为synchronized的同步锁会造成同一个JVM内的所有线程只能互斥地进入临界区段
//对JVM内的所有线程同步 public static synchronized void selfPlus(){
//临界区代码 }
- 所以使用synchronized关键字修饰static方法是非常粗粒度的同步机制
- 通过synchronized关键字所抢占的同步锁什么时候释放?一种场景是synchronized块(代码块或者方法)正确执行完毕,监视锁自动释放;另一种场景是程序出现异常,非正常退出synchronized块,监视锁也会自动释放。所以,使用synchronized块时不必担心监视锁的释放问题
- 对象锁:如果某个类的实例很多,那么这个类的对象锁就会有很多,那么高并发的场景下,大家只抢用到的那个对象锁
- 类锁:整个jvm就只有一个,高并发的场景下,大家只能抢一个类锁
7.生产者-消费者问题
- 生产者-消费者(Producer-Consumer Problem)也称为有限缓冲问题(Bounded-Buffer Problem),是一个多线程同步问题的经典案例
- 生产者-消费者问题描述了两类访问共享缓存区的线程(生产者,消费者)在实际运行时会发生的问题
- 生产者线程的主要功能是生成一定量的数据放到缓冲区,然后重复此过程。消费者线程的主要功能是从缓冲区提取(或消耗)数据
- 生产者-消费者问题的关键是:
1.保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区空时消耗数据
2.保证在生产者加入过程,消费者消耗过程,不会产生错误的数据和行为- 生产者-消费者问题不仅仅是一个多线程同步问题的典型案例,而且业内已经将解决该问题的方法抽象成了一种设计模式:生产者-消费者模式
1.生产者-消费者模式
在该模式中,通常有两类线程,即生产者线程(若干个)和消费者线程(若干个)
- 生产者线程向数据缓冲区(DataBuffer)加入数据,消费者线程则从数据缓冲区消耗数据
关键:
1.生产者与生产者之间,消费者与消费者之间,对数据缓冲区的操作是并发进行的
2.数据缓冲区是有容量上限的,数据缓冲区满后,生产者不能再加入数据;数据缓冲区空时,消费者不能再取出数据
3.数据缓冲区是线程安全的,在并发操作数据缓冲区的过程中,不能出现数据不一致的情况;或者在多个线程并发更改共享数据后,不会造成出现脏数据的情况(脏数据:从目标中取出的数据已经过期,错误或者没有意义,这种数据就叫做脏数据;脏读:读取出来脏数据就叫脏读)
4.生产者或者消费者线程在空闲时需要尽可能阻塞而不是执行无效的空操作,尽量节约CPU资源(一般就绪态的线程是即将可以运行的线程,任何时候只有一个线程处于运行态。而从就绪变成运行是将线程从就绪队列里面移出来,交给CPU进行执行,如果阻塞就会把其加入对应的阻塞队列。如果被唤醒就是从对应的阻塞队列移出放入就绪队列,所以阻塞状态是没机会获取CPU执行权,只有就绪队列才有,所以阻塞队列不会浪费CPU资源,但是就绪队列就会不断的空跑,比如死循环,自旋。阻塞队列有很多,其中synchronized锁里也包含阻塞队列)
2.线程不安全的实现版本
- 其中包含数据缓冲区(DataBuffer)类,生产者(Producer)类和消费zhe(Consumer)类
8.Java内置对象结构与内置锁
- Java内置锁的很多重要信息都存放在对象结构中
- lock和biased_lock两个标记位组合在一起共同表示Object实例处于什么样的锁状态
- 3.age:4位的Java对象分代年龄。在GC中,对象在Survivor区复制一次,年龄就增加1。当对象达到设定的阈值时,将会晋升到老年代。默然情况下,并行的GC的年龄阈值为15,并发GC的年龄阈值为6,。由于age只有4位,因此最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因
- 4.identity_hashcode:31位的对象标识HashCode(哈希码)采用延迟加载技术,当调用Object.hashCode()方法或者System.identityHashCode()方法计算对象的HashCode后,其结果将被写到该对象头中。当对象被锁定时,该值会移动到Monitor(监视器)中
- 5.thread:54位的线程ID值为持有偏向锁的线程ID
- 6.epoch:偏向时间戳
- 7.ptr_to_lock_record:占62位,在轻量级锁的状态下指向栈帧中锁记录的指针
- 8.pro_to_heavyweight_monitor:占62位,在重量级锁的状态下指向对象监视器的指针
1.使用JOL工具查看对象的布局
- OpenJDK提供的JOL(Java Object Layout)包,可以帮我们在运行时计算某个对象的大小
- JOL是分析JVM中对象的结构布局的工具,该工具大量使用了Unsafe,JVMTI来解码内部布局情况,它的分析结果相比较精准
- 先引入Maven的依赖坐标
- 由于在JVM中的数据使用大端模式存储和计算,而JOL工具使用小端模式进行输出,因此在以上代码中,通过Java程序手工将hashCode从大端模式转换成小端模式
- 从运行结果中可以看出,当前JVM的运行环境为64位虚拟机。运行结果中输出了ObjectLock的对象布局,所输出的ObjectLock为16字节,其中对象头(Object Header)占12字节,剩下的4字节由amount属性(字段)占用。由于16字节为8字节的倍数,因此没有对齐填充字节
- 分析结果对象的哈希码,如果Java代码没有重写Object.hashcode()方法,那么默认通过Native方式调用os::random()方法产生哈希码,Java代码也可以调用System.identityHashCode(obj)为对象产生哈希码
- 对象一旦生成了哈希码,JVM会将其记录在对象头的Mark Word中。当然,只有调用未重写的Object.hashcode()方法或者调用System.IdentityHashCode(obj)方法时,其值才被记录到Mark Word中。如果调用的是重写的hashcode()方法,也不会记录到Mark Word中。
- 对象一旦生成了哈希码,它就无法进入偏向锁状态,也就是说,只要一个对象已经计算过哈希码,它就无法进入偏向锁状态,并且需要计算其哈希码的话,它的偏向锁会被撤销,并且锁会膨胀为重量级锁
- 偏向锁和对象hashcode不能共存,1.8版本JDK中,hashcodejvm生成一个随即数,然后放到对象头的hashcode段里,对象刚生成是不会有hashcode的,hashcode采用懒加载的方式,只有被调用到了才会生成,放到对象头是为了保证每次调用hashcode都获取到相同的值
- 在无锁状态下,Mark Word中可以存储对象的identity hash code值。当对象的hashCode()方法(非用户自定义)第一次被调用时,JVM会生成对应的identity hash code值,并将该值存储到Mark Word中。后续如果该对象的hashCode()再次被调用则不会再通过JVM进行计算得到,而是直接从Mark Word中获取。(以JDK8为例,JVM默认的计算identity hash code的方式得到的是一个随机数,因而我们必须要保证一个对象的identity hash code只能被底层JVM计算一次)
- 对于轻量级锁,获取锁的线程帧栈中有锁记录(Lock Record)空间,用于存储Mark Word的拷贝,官方称之为Displaced Mark Word,该拷贝中可以包含identity hash code,所以轻量级锁可以和identity hash code共存;对于重量级锁,ObjectMonitor类里有字段可以记录非加锁状态下的mark word,其中也可以存储identity hash code的值,所以重量级锁也可以和identity hash code共存
- 对于偏向锁,在线程获取偏向锁时,会用Thread ID和epoch值覆盖identity hash code所在的位置。如果一个对象的hashCode()方法一经被调用过一次之后,这个对象还能被设置偏向锁吗,不能,因为如果可以的化,那么Mark Word中identity hash code 必然会被偏向线程ID给覆盖,这就会造成同一个对象前后两次调用hashCode()方法得到的结果不一致
- 因为MarkWord中存储的哈希码和偏向锁线程id是一个字段,如果该字段被哈希码占有的化就无法存储线程id了,所以无法进入偏向锁状态
9.无锁,偏向锁,轻量级锁和重量级锁
- jdk1.6版本前,所有的Java内置锁都是重量级锁。重量级锁会造成CPU在用户态和核心态之间的频繁切换,所以代价高,效率低
- jdk1.6版本为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁和轻量级锁的实现
- jdk1.6版本中内置锁一共有4种状态:无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,这些状态随着竞争情况逐渐升级。
- 内置锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能再降级成偏向锁。这种能升级却不能降级的策略,其目的是提高获得锁和释放锁的效率
1.无锁状态
- Java对象刚创建时还没有任何线程来竞争,说明该对象处于无锁状态(无线程竞争它),这时偏向锁标识位是0,锁状态是01,无锁状态下对象的 Mark Word
2.偏向锁
- 偏向锁是指一段同步代码一直被同一个线程访问,那么该线程会自动获取锁,降低获取锁的代价。如果内置锁处于偏向锁状态,当有一个线程来竞争时,先用偏向锁,标识内置锁偏爱这个线程,这个线程要执行该锁关联的同步代码时,不需要再做任何检查和切换,偏向锁在竞争不激烈的情况下效率非常高。(所谓偏向:只要没有线程竞争,那么执行当前同步代码的线程就可以调过获取和释放锁的一系列操作,使得同步代码的执行效率接近于无锁状态)
- 偏向锁状态的Mark Word会记录内置锁自己偏爱的线程ID,内置锁会将该线程当成自己的熟人
3.轻量级锁状态
- 当有两个线程开始竞争这个锁对象时,情况就发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象,锁对象的Mark Word就指向哪个线程的栈帧中的锁记录
- 当锁处于偏向锁,又被另一个线程企图抢占时,偏向锁就会升级为轻量级锁,企图抢占的线程会通过自旋的形式尝试获取锁,不会阻塞抢锁线程,以便提高性能
- 线程通过自旋可以避免陷入内核态,所谓进入阻塞状态就是进行系统调用,由操作系统内核进程接手来管理线程对锁的分配,陷入内核态会导致操作系统进行上下文切换(保存用户线程信息,启动内核进程),而上下文切换是很低效的操作;如果每个线程对锁的持有不会持续很长时间,那么频繁在用户态和内核态之间切换就会浪费时间,降低高并发下的吞吐量,如果线程在自旋过程中能拿到锁,就避免了上下文切换,这样的优化让并发线程的执行更轻快,故称之为轻量级锁
- 自旋原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要进行内核态和用户态之间的切换来进入阻塞挂起状态,他们只需要等一等(自旋),等持有锁的线程释放锁后可立即获取锁,这样就避免了用户线程和内核切换的消耗
- 但是线程自旋是需要消耗CPU的,如果一直获取不到锁,那么线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。JVM对于自旋周期的选择,JDK1.6之后引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不是固定的,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定的,线程如果自旋成功了,下次自旋的次数就会更多,如果自旋失败了,自旋的次数就会减少
- 如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,就会导致其他争用锁的线程在最大等待时间内还是获取不到锁,自旋不会一直持续下去,这时争用线程会停止自旋进入阻塞状态,该锁膨胀为重量级锁
- Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量,参数给内核,内核也需要保护好用户态在切换时的一些寄存器值,变量等,以便内核态调用结束后切换回用户态继续工作
- 自旋其实就是空跑,循环去做一件事,并不会进行系统调用,频繁的进行内核态和用户态的切换,这个太影响性能了,其实自旋就是去争抢CPU时间片,这种也只能是临界资源区间不是小的情况下才可以
4.重量级锁
- 重量级锁会让其他申请的线程之间进入阻塞,性能降低。
- 重量级锁也叫同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,该监视器对象用集合的形式来登记和管理排队的线程,重量级锁状态下对象的Mark Word
2-1.偏向锁的原理与实战
- 偏向锁主要解决无竞争下的锁性能问题,所谓的偏向就是偏心,即锁会偏向于当前已经占有锁的线程
2.1.1偏向锁的核心原理
- 在实际场景中,如果一个同步块(或方法)没有多个线程竞争,而且总是由同一个线程多次重入获取锁,如果每次还有阻塞线程,唤醒CPU从用户态转为核心态,那么对于CPU是一种资源的浪费,为了解决这类问题,就引入了偏向锁概念
- 偏向锁的核心原理:如果不存在线程竞争的一个线程获得了锁,那么锁就进入偏向状态,此时Mark Word的结构变为偏向状态,此时Mark Word的结构变为偏向锁结构,锁对象的锁标志位(lock)被改为01,偏向标志位(biased_lock)被改为1,然后线程的ID记录在锁对象的Mark Word中(使用CAS操作完成)。以后该线程获取锁时判断一下线程ID和标志位,就可以直接进入同步块,连CAS操作都不需要,这样就省去了大量有关锁申请的操作,从而也就提升了程序的性能
- 偏向锁的主要作用是消除无竞争情况下的同步原语,进一步提升程序性能,所以在没有锁竞争的场合,偏向锁有很好的优化效果,但是,一旦有第二条线程需要竞争锁,那么偏向模式立即结束,进入轻量级锁的状态
- 假如在大部分情况下同步块是没有竞争的,那么可以通过偏向来提高性能。那么可以通过偏向来提高性能,即在无竞争时,之前获得锁的线程再次获得锁时会判断偏向锁的线程ID是否指向自己,如果是那么该线程将不用再次获得锁,直接就可以进入同步块;如果未指向当前线程,当前线程就会采用CAS操作将Mark Word中的线程ID设置为当前线程ID,如果CAS操作成功,那么获取偏向锁成功,执行同步代码块,如果CAS操作失败,那么表示有竞争,抢锁线程被挂起,撤销占锁线程的偏向锁,然后将偏向锁膨胀为轻量级锁
(其实所谓的CAS操作成功,是由于之前的无锁状态,CAS成功变成偏向锁状态,并且说明存在竞争,从而升级为轻量级锁状态)- 偏向锁的缺点:如果锁对象时常被多个线程竞争,偏向锁就是多余的,并且其撤销的过程会带来一些性能开销
- 运行演示案例之后,在看到第一行输出结果之前,程序要等待5秒,因为JVM在启动的时候会延迟启用偏向锁机制,JVM偏向锁延迟了4000毫秒,这就解释了为什么演示为什么要等待5秒才能看到对象锁的偏向状态
- 为什么偏向锁会延迟?因为JVM在启动的时候需要加载资源,这些对象加上偏向锁没有任何意义,不启用偏向锁能减少大量偏向锁撤销的成本
- 如果不想等待(在代码中让线程睡眠),可直接通过修改JVM的启动项来禁止偏向锁延迟,其具体的启动选项如下:
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
具体使用方式:
Java -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 mainclass
- 偏向锁的加锁过程为:新线程只需要判断内置锁对象的Mark Word中的线程ID是不是自己的ID,如果是就直接使用这个锁,而不使用CAS交换,新线程将自己的线程ID交换到内置锁的Mark Word中,如果交换成功,就加锁成功。
- 在演示案例的循环抢锁中,每执行一轮抢占,JVM内部都会比较内置锁的偏向线程ID与当前线程ID,如果匹配就表明当前线程已经获得了偏向锁,当前线程可以快速进入临界区,所以偏向锁的效率是非常高德,偏向锁是针对一个线程而言的,线程获得锁之后就不会再有解锁等操作了,可以节省很多开销
2.2.2偏向锁的膨胀与撤销
- 假如有多个线程来竞争偏向锁,此对象锁已经有所偏向,其他线程发现偏向锁并不是偏向自己,就说明存在了竞争,尝试撤销偏向锁(很可能引入安全点),然后膨胀到轻量级锁
2-2轻量级锁
- JDK1.6的轻量级锁使用的是普通自旋锁,且需要使用-XX:+UseSpinning选项手工开启。JDK1.7后,轻量级锁使用自适应自旋锁,JVM启动时自动开启,且自旋时间由JVM自动控制
- 轻量级也被称为非阻塞同步,乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待
轻量级锁的膨胀
- 轻量级锁问题:虽然大部分临界区代码的执行时间很短,但是也会存在执行很慢的临界区代码。临界区代码执行耗时长,在其执行期间,其他线程都在原地自旋等待,会空消耗CPU。因此,如果竞争这个同步锁的线程很多,就会有多个线程在原地等待继续空循环消耗CPU(空自旋。这会带来很大的性能损耗)
- 轻量级锁的本意是为了减少多线程进入操作系统底层的互斥锁(Mutex Lock)的概率,并不是要替代操作系统互斥锁。所以在争用激烈的场景下,轻量级锁会膨胀为基于操作系统内核互斥锁实现的重量级锁
2-3重量级锁的原理与实战
- 在JVM中,每个对象都关联一个监视器,这里的对象包含Object实例和Class实例。监视器是一个同步工具,相当于一个许可证,拿到许可证的线程可进入临界区进行操作,没有拿到则需要阻塞等待。重量级锁通过监视器的方式保障了任何时间只允许一个线程通过收到监视器保护的临界区代码
重量级锁的核心原理:
- JVM中每个对象都会有一个监视器,监视器和对象一起创建,销毁。监视器相对于一个用来监视这些线程进入的特殊房间,其义务是保证同一时间只有一个线程可以访问被保护的临界区代码块(每一个对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态,线程执行到monitorenter指令时,将会尝试获取对象锁对应的monitor的所有权,即尝试获得对象的锁,MarkWord里默认数据是存储对象的HashCOde等信息,但是在运行期间,MarkWord存储的数据会随着锁标志位的变化而变化。重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管理或监视器锁)的起始地址)
- 本质上,监视器是一种同步工具,也可以说是一种同步机制,主要特点是:
- 1.同步。监视器所保护的临界区代码是互斥地执行的。一个监视器是一个运行许可,任一线程进入临界区代码都需要获得这个许可,离开时把许可归还
- 2.协作。监视器提供Signal机制,允许正持有许可的线程暂时放弃许可进入阻塞等待状态,等待其他线程发送Signal去唤醒;其他拥有线程许可的线程可以发送Signal,唤醒正在阻塞等待的线程,让它可以重新获得许可并启动执行
- 在Hotspot虚拟机中,监视器是由C++类ObjectMonitor实现的,ObjectMonitor类定义在ObjectMonitor.hpp中,其构造器代码大致如下:
//Monitor结构体 ObjectMonitor::ObjectMonitor(){
_header = NULL; _count = 0; _waiters = 0, //线程的重入次数 _recursions = 0; _object = NULL; //标识拥有该Monitor的线程 _owner = NULL; //等待线程组成的双向循环链表 _WaitSet = NULL; _WaitSetLock = 0; _Responsible = NULL; _succ = NULL; //多线程竞争锁进入时的单向链表 cxq = NULL; FreeNext = NULL; //_owner从该双向循环链表中唤醒线程节点 _EntryList = NULL; _SpinFreq = 0; _SpinClock = 0; OwberIsThread = 0; }
- 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链表中
- ObjectMonitor的内部抢锁过程
1.CXq
- Cxq并不是一个真正的队列,只是一个虚拟队列,原因在于Cxq是由Node及其next指针逻辑构成的,并不存在一个队列的数据结构。每次新加入Node会在Cxq的队头进行,通过CAS改变第一个节点的指针为新增节点,同时设置新增节点的next指向后续节点;从Cxq取得元素时,会从队尾获得,显然Cxq是一个无锁结构,因为只有Owner线程才能从队尾取元素,即线程出列操作无争用,当然也就避免了CAS的ABA问题
- 在线程进入Cxq前,抢锁线程会先尝试通过CAS自旋获取锁,如果获取不到,就进入Cxq队列,这明显对于已经进入Cxq队列的线程是不公平的。所以synchronized同步块所使用的重量级锁时不公平锁(如果获取到就不取队列里的,之前排队就白排了,所以不公平)
- Cxq队列是对象监视器里的虚拟队列,要用到对象监视器的时候,说明已经由轻量级锁变为重量级锁,而这个变化过程,只有在发生过cas自旋获取不到锁以后才会发生没如果自旋拿到锁了,就不会进入队列,所以对于之前未进入队列的线程是不公平的)
2.EntryList
- EntryList与Cxq在逻辑上都属于等待队列。Cxq会被线程并发访问,为了降低对Cxq队尾的争用,而建立EntryList,在Owner线程释放锁时,JVM会从Cxq中迁移线程到EntryList,并会指定EntryList中的某个线程(一般为Head)为OnDeckThread(Ready Thread)。EntryList中的线程作为候选竞争线程而存在
3.OnDeck Thread 与 Owner Thread
- JVM不直接把锁传递给Owner Thread,而是把锁竞争的权利交给OnDeck Thread,OnDeck需要重新竞争锁,这样虽然牺牲了一些公平性,但是能极大地提升系统的吞吐量,在JVM中也把这种选择行为称为竞争切换(非公平锁体现之一,已进入entrylist队列的线程仍需要与外部线程竞争锁)
- OnDeck Thread获取到锁资源后会变为Owner Thread。无法获得锁的OnDeck Thread则会依然留在EntryList中,考虑到公平性,OnDeck Thread在EntryList中的位置不发生变化(依然在队头)
- 在OnDeck Thread成为Owner的过程中,还有一个不公平的事情,即后来的新抢线程可能直接通过CAS自旋成为Owner而抢到锁
- 可以类比Aqs中的队列和抢锁以及唤醒机制并不是唤醒所有线程去抢占锁而是唤醒队列中首个线程
4.WaitSet
- 如果Owner线程被Object.wait()方法阻塞,就转移到WaitSet队列中,知道某个时刻通过Object.notify()或者Object.notifyAll()唤醒,该线程就会重新进入EntryList中
2.3.1重量级锁的开销
- 处于ContentionList,EntryList,WaitSet中的线程都处于阻塞状态,线程的阻塞或者唤醒都需要操作系统来帮忙,Linux内核下采用pthread_mutex_lock系统调用实现,进程需要从用户态切换到内核态
- Linux系统的体系架构分为用户态(或者用户空间)和内核态(或者内核空间)
- Linux系统的内核是一组特殊的软件程序,负责控制计算机的硬件资源,如协调CPU资源,分配内存资源,并且提供稳定的环境供应应用程序运行。应用程序的活动空间为用户空间,应用程序的执行必须依托于内核提供的资源,包括CPU资源,存储资源,I/O资源等
- 用户态与内核态有各自专用的内存空间,专用的寄存器等,进程从用户态切换至内核态需要传递许多变量,参数给内核,内核也需要保护好用户态在切换时的一些寄存器值,变量等,以便内核态调用结束后切换回用户态继续工作。
- 用户态的进程能够访问的资源受到了极大地控制,而运行在内核态的进程可以为所欲为,一个进程可以运行在用户态,也可以运行在内核态,那么肯定存在用户态和内核态切换的过程,进程从用户态到内核态切换主要包括以下三种方式:
- 1.硬件中断。硬件中断也称为外设中断,当外设完成用户的请求时会向CPU发送中断信号
- 2.系统调用。其实系统调用本身就是中断,只不过是软件中断,跟硬件中断不同
- 3.异常。如果当前进程运行在用户态,这个时候发生了异常事件(例如缺页异常),就会触发切换
- 用户态是应用程序运行的空间,为了能访问到内核管理的资源(例如CPU,内存,I/O),可以通过内核态所提供的访问接口实现,这些接口就叫系统调用
- pthread_mutex_lock系统调用时内核态为用户态进程提供的Linux内核态下互斥锁的访问机制,所以使用pthread_mutex_lock系统调用时,进程需要从用户态切换到内核态,而这种切换是需要消耗很多时间的,有可能比用户执行代码的时间还要长
- 由于JVM轻量级锁使用CAS自旋抢锁,这些CAS操作都处于用户态下,进程不存在用户态和内核态之间的运行切换,因此JVM轻量级锁开销较小。而JVM重量级锁使用了Linux内核态下的互斥锁,这是重量级锁开销很大的原因
10.偏向锁,轻量级锁,重量级锁的对比
- 总结synchronized的执行过程:
- 1.线程抢锁时,JVM首先检查内置锁对象Mark Word中的biased_lock(偏向锁标识)是否设置成1,lock(锁标志位)是否为01,如果都满足,确认内置锁对象为可偏向状态
- 2.在内置锁对象确认为可偏向状态之后,JVM检查Mark Word中的线程ID是否为抢锁线程ID,如果是,就表示抢锁线程处于偏向锁状态,抢锁线程快速获得锁,开始执行临界区代码
- 3.如果Mark Word中的线程ID并未指向抢锁线程,就通过CAS操作竞争锁。如果竞争成功,就将Mark Word中的线程ID设置为抢锁线程,偏向标志位设置为1,锁标志位设置为01,然后执行临界区代码,此时内置锁对象处于偏向锁状态。
- 4.如果CAS操作竞争失败,就说明发生了竞争,撤销偏向锁,进而升级为轻量级锁
- 5.JVM使用CAS锁将锁对象的Mark Word替换为抢锁线程的锁记录指针,如果成功,抢锁线程就获得锁。如果替换失败,就表示其他线程竞争锁,JVM尝试使用CAS自旋替换抢锁线程的锁记录指针,如果自旋成功(抢锁成功),那么锁对象依然处于轻量级锁状态
- 6.如果JVM的CAS替换锁记录指针自旋失败,轻量级锁就膨胀为重量级锁,后面等待锁的线程也要进入阻塞状态
- 总体来说,偏向锁是在没有发生锁争用的情况下使用的;一旦有了第二个线程争用锁,偏向锁就会升级为轻量级锁;如果锁争用很激烈,轻量级锁的CAS自旋达到阈值后,轻量级锁就会升级为重量级锁
11.线程间通信
- 线程是操作系统调度的最小单位,有自己的栈空间,可以按照既定的代码逐步执行,但是如果每个线程间都孤立地运行,就会造成资源浪费
- 所以在现实中,如果需要多个线程按照指定的规则共同完成一个任务,那么这些线程之间就需要互相协调,这个过程被称为线程的通信
线程间通信的定义:
- 线程的通信可以被定义为:当多个线程共同操作共享的资源时,线程间通过某种方式互相告知自己的状态,以避免无效的资源争夺
- 线程间通信的方式可以有很多种:
- 1.等待-通知
- 2.共享内存
- 3.管道流
- 每种方式用不同的方法来实现,这里首先介绍等待-通知的通信方式
- 等待-通知通信方式是Java中使用普遍的线程间通信方式,其经典的案例是生产者-消费者模式
低效的线程轮询
- 首先回到前面的生产者-消费者安全版本的数据缓冲区类SafeDataBuffer。其存在一个隐蔽但又很耗性能的问题:消费者每一轮消费,无论数据区是否为空,都需要进行数据区的询问和判断,其轮询代码如下:
public synchronized IGoods get() throws Exception{
IGoods good = null; if(amount <= 0){
System.out.printf("队列已经空了"); return null; } }
当数据区空时(amount <= 0),消费者无法取出数据,但是仍然做无用的数据区询问工作,白白消耗了CPU的时间片,对于生产者来说,也存在类似的无效轮询问题。当数据区满时,生产者无法加入数据,这时生产者执行add(T element)方法也白白耗费了CPU的时间片,其中的轮询代码如下:
public synchronizedboid add(Telement) throws Exception{
if(zmount.get() > MAX_AMOUNT){
System.out.printf("队列已经满了"); return; } }
- 如何在生产者和消费者空闲时节约CPU时间片,免去巨大的CPU消费浪费呢?
- 一个非常有效的方法是:使用等待-通知方式进行生产者与消费者之间的线程通信
- 具体来说,在数据区满(amount.get() > MAX_AMOUNT)时,可以让生产者等待,等到下次数据区中可以加入数据时,给生产者发通知,让生产者唤醒。同样,在数据区为空(amount <= 0)时,可以让消费者等待,等到下次数据区中可以取出数据时,消费者才能被唤醒
- 那么,由谁去唤醒等待状态的生产者呢?可以在消费者取出一个数据后,由消费者去唤醒等待的生产者,同样,由谁去唤醒等待状态的消费者呢?可以在生产者加入一个数据后,由生产者去唤醒等待的消费者。
- Java语言中等待-通知方式的线程间通信使用对象的wait(),notify()两类方法来实现。每个Java对象都有wait(),notify()两类实例方法,并且wait(),notify()方法和对象的监视器是紧密相关的
- 说明:Wait(),notify()两类方法在数量上不止两个,wait(),notify()两类方法不属于Thread类,而是属于Java对象实例(Object实例或者Class实例)
wait方法和notify方法的原理
Java对象中的wait(),notify()两类方法就如同信号开关,等于等待方和通知方之间的交互
1.对象的wait()方法的主要作用是让当前线程阻塞并等待被唤醒。wait()方法与对象监视器紧密相关,使用wait()方法时一定要放在同步块中
synchronized(locko){
locko.wait(); }
Object 类中的wait()方法有三个版本:
- 1.void wait
- 这是一个基础版本,当前线程调用了同步对象的locko的wait()实例方法后,将导致当前线程等待,当前线程进入locko的监视器WaitSet,等待被其他线程唤醒
- 2.void wait(long timeout)
- 这是一个限时等待版本,导致当前的线程等待,等待被其他线程唤醒,或者指定的时间timeout用完,线程不再等待
- 3.void wait(long timeout,int nanos)
- 这是一个高精度限时等待版本,其主要作用是更精确地控制等待时间,参数nanos是一个附加的纳秒级别的等待时间,从而实现更加高精度的等待时间控制
- 1.当线程调用了locko(某个同步锁对象)的wait()方法后,JVM会将当前线程加入locko监视器的WaitSet(等待集),等待被其他线程唤醒
- 2.当前线程会释放locko对象监视器的Owner权利,让其他线程可以抢夺locko对象的监视器
- 3.让当前线程等待,其状态变成WAITING
在线程调用了同步对象locko的wait()方法之后,同步对象locko的监视器内部状态
对象的notify()方法
- 对象的notify()方法的主要作用是唤醒在等待的线程。notify()方法与对象监视器紧密相关,调用notify()方法时也需要放在同步块中
synchronized(locko){
locko.notify(); }
- notify()方法有两个版本:
- 版本一:void notify()
- notify()方法的主要作用为:locko.notify()调用后,唤醒locko监视器等待集中的第一条等待线程;被唤醒的线程进入EntryList,其状态从WAITING变成BLOCKED
- 版本二:void notifyAll()
- locko.notifyAll()被调用后,唤醒locko监视器等待集中的全部等待线程,所有被唤醒的线程进入EntryList,线程状态总长WAITING变成BLOCKED
notify()方法的核心原理
- 当线程调用了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收到通知后,重新进入就绪状态,准备开始执行
- 线程间的通信需要借助
CAS原理与JUC原子类
- 由于JVM的Synchronized重量级锁涉及操作系统(如Linux)内核态下互斥锁的使用,因此其线程阻塞和唤醒都涉及进程在用户态到内核态的频繁切换,导致重量级锁开销大、性能低。而JVM的Synchronized轻量级锁使用CAS(Compare And Swap,比较并交换)进行自旋抢锁,CAS是CPU指令级的原子操作,并处于用户态下,所以JVM轻量级锁的开销较小。
- 由于JVM的Synchronized重量级锁涉及操作系统(如Linux)内核态下互斥锁的使用,因此其线程阻塞和唤醒都涉及进程在用户态到内核态的频繁切换,导致重量级锁开销大、性能低。而JVM的Synchronized轻量级锁使用CAS(Compare And Swap,比较并交换)进行自旋抢锁,CAS是CPU指令级的原子操作,并处于用户态下,所以JVM轻量级锁的开销较小。
CAS
- JDK 5所增加的JUC(java.util.concurrent)并发包对操作系统的底层CAS原子操作进行了封装,为上层Java程序提供了CAS操作的API
Unsafe类中的CAS方法
- Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全的底层操作,如直接访问系统内存资源、自主管理内存资源等。Unsafe大量的方法都是native方法,基于C++语言实现,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。
- Unsafe类的全限定名为sun.misc.Unsafe,从名字中可以看出这个类对普通程序员来说是“危险”的,一般的应用开发都不会涉及此类,Java官方也不建议直接在应用程序中使用这些类
- 为什么此类取名为Unsafe呢?由于使用Unsafe类可以像C语言一样使用指针操作内存空间,这无疑增加了指针相关问题、内存泄漏问题出现的概率。总之,在程序中过度使用Unsafe类会使得程序出错的概率变大,使得安全的语言Java变得不再安全,因此对Unsafe的使用一定要慎重。
- 操作系统层面的CAS是一条CPU的原子指令(cmpxchg指令),正是由于该指令具备原子性,因此使用CAS操作数据时不会造成数据不一致的问题,Unsafe提供的CAS方法直接通过native方式(封装C++代码)调用了底层的CPU指令cmpxchg。
完成Java应用层的CAS操作主要涉及Unsafe方法的调用,具体如下:
(1)获取Unsafe实例。
(2)调用Unsafe提供的CAS方法,这些方法主要封装了底层CPU的CAS原子操作。
(3)调用Unsafe提供的字段偏移量方法,这些方法用于获取对象中的字段(属性)偏移量,此偏移量值需要作为参数提供给CAS操作。
1.获取Unsafe实例
- Unsafe类是一个final修饰的不允许继承的最终类,而且其构造函数是private类型的方法,具体的源码如下
public final class Unsafe { private static final Unsafe theUnsafe; ... private Unsafe() { } @CallerSensitive public static Unsafe getUnsafe() { Class var0 = Reflection.getCallerClass(); if (!VM.isSystemDomainLoader(var0.getClassLoader())) { throw new SecurityException("Unsafe"); } else { return theUnsafe; } } ...- 因此,我们无法在外部对Unsafe进行实例化,那么怎么获取Unsafe的实例呢?可以通过反射的方式自定义地获取Unsafe实例的辅助方法,代码如下:
package com.crazymakercircle.util; // 省略import public class JvmUtil {
//自定义地获取Unsafe实例的辅助方法 public static Unsafe getUnsafe() {
try {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe"); theUnsafe.setAccessible(true); return (Unsafe) theUnsafe.get(null); } catch (Exception e) {
throw new AssertionError(e); } } // 省略不相干代码 }
2.调用Unsafe提供的CAS方法
/ * 定义在Unsafe类中的三个“比较并交换”原子方法 * @param o 需要操作的字段所在的对象 * @param offset 需要操作的字段的偏移量(相对的,相对于对象头) * @param expected 期望值(旧的值) * @param update 更新值(新的值) * @return true 更新成功 | false 更新失败 */ public final native boolean compareAndSwapObject( Object o, long offset, Object expected, Object update); public final native boolean compareAndSwapInt( Object o, long offset, int expected,int update); public final native boolean compareAndSwapLong( Object o, long offset, long expected, long update);
- Unsafe提供的CAS方法包含4个操作数——字段所在的对象、字段内存位置、预期原值及新值。在执行Unsafe的CAS方法时,这些方法首先将内存位置的值与预期值(旧的值)比较,如果相匹配,那么CPU会自动将该内存位置的值更新为新值,并返回true;如果不匹配,CPU不做任何操作,并返回false。
- Unsafe的CAS操作会将第一个参数(对象的指针、地址)与第二个参数(字段偏移量)组合在一起,计算出最终的内存操作地址。
3.调用Unsafe提供的偏移量相关
- Unsafe提供的获取字段(属性)偏移量的相关操作主要如下
/ * 定义在Unsafe类中的几个获取字段偏移量的方法 * @param o 需要操作字段的反射 * @return 字段的偏移量 */ public native long staticFieldOffset(Field field); public native long objectFieldOffset(Field field);
- staticFieldOffset()方法用于获取静态属性Field在Class对象中的偏移量,在CAS中操作静态属性时会用到这个偏移量。objectFieldOffset()方法用于获取非静态Field(非静态属性)在Object实例中的偏移量,在CAS中操作对象的非静态属性时会用到这个偏移量。
- 一个获取非静态Field(非静态属性)在Object实例中的偏移量的示例代码如下
static {
try {
//获取反射的Field对象 OptimisticLockingPlus.class.getDeclaredField("value"); //取得内存偏移 valueOffset = unsafe.objectFieldOffset(); } catch (Exception ex) {
throw new Error(ex); } }
使用CAS进行无锁编程
- CAS是一种无锁算法,该算法关键依赖两个值——期望值(旧值)和新值,底层CPU利用原子操作判断内存原值与期望值是否相等,如果相等就给内存地址赋新值,否则不做任何操作。
- 使用CAS进行无锁编程的步骤大致如下:
(1)获得字段的期望值(oldValue)。
(2)计算出需要替换的新值(newValue)。
(3)通过CAS将新值(newValue)放在字段的内存地址上,如果CAS失败就重复第(1)步到第(2)步,一直到CAS成功,这种重复俗称CAS自旋。
do {
获得字段的期望值(oldValue); 计算出需要替换的新值(newValue); } while (!CAS(内存地址,oldValue,newValue))
- 下面用一个简单的例子对以上伪代码进行举例说明。假如某个内存地址(某对象的属性)的值为100,现在有两个线程(线程A和线程B)使用CAS无锁编程对该内存地址进行更新,线程A欲将其值更新为200,线程B欲将其值更新为300,如图3-1所示。>* 线程是并发执行的,谁都有可能先执行。但是CAS是原子操作,对同一个内存地址的CAS操作在同一时刻只能执行一个。因此,在这个例子中,要么线程A先执行,要么线程B先执行。假设线程A的CAS(100,200)执行在前,由于内存地址的旧值100与该CAS的期望值100相等,因此线程A会操作成功,内存地址的值被更新为200。
线程A执行CAS(100,200)成功之后,内存地址的值如图3-2所示。
- 接下来执行线程B的CAS(100,300)操作,此时内存地址的值为200,不等于CAS的期望值100,线程B操作失败。线程B只能自旋,开始新的循环,这一轮循环首先获取到内存地址的值200,然后进行CAS(200,300)操作,这一次内存地址的值与CAS的预期值(oldValue)相等,线程B操作成功。
- 当CAS将内存地址的值与预期值进行比较时,如果相等,就证明内存地址的值没有被修改,可以替换成新值,然后继续往下运行;如果不相等,就说明内存地址的值已经被修改,放弃替换操作,然后重新自旋。当并发修改的线程少,冲突出现的机会少时,自旋的次数也会很少,CAS的性能会很高;当并发修改的线程多,冲突出现的机会多时,自旋的次数也会很多,CAS的性能会大大降低。所以,提升CAS无锁编程效率的关键在于减少冲突的机会
使用无锁编程实现轻量级安全自增
- 在第1章开头讲到的第二个面试典故中,临行时笔者给候选人Y君建议回去做一个线程安全的自增小实验:使用10个线程,对一个共享的变量,每个线程自增100万次,看看最终的结果是不是1000万。
- 在第2章学习synchronized关键字时提供了一个线程安全的自增实现版本。由于在争用激烈的场景下,synchronized内置锁会膨胀为重量级锁,因此第2章的实现版本实际上是一个低性能的实现版本。这里使用CAS无锁编程算法实现一个轻量级的安全自增实现版本:总计10个线程并行运行,每个线程通过CAS自旋对一个共享数据进行自增运算,并且每个线程需要成功自增运算1000次。
- 基于CAS无锁编程的安全自增实现版本的具体代码如下:
package com.crazymakercircle.cas; // 省略import public class TestCompareAndSwap {
// 基于CAS无锁实现的安全自增 static class OptimisticLockingPlus {
//并发数量 private static final int THREAD_COUNT = 10; //内部值,使用volatile保证线程可见性 private volatile int value;//值 //不安全类 private static final Unsafe unsafe = getUnsafe();; //value 的内存偏移(相对于对象头部的偏移,不是绝对偏移) private static final long valueOffset; //统计失败的次数 private static final AtomicLong failure = new AtomicLong(0); static {
try {
//取得value属性的内存偏移 valueOffset = unsafe.objectFieldOffset( OptimisticLockingPlus.class.getDeclaredField("value")); Print.tco("valueOffset:=" + valueOffset); } catch (Exception ex) {
throw new Error(ex); } } //通过CAS原子操作,进行“比较并交换” public final boolean unSafeCompareAndSet(int oldValue, int newValue) {
//原子操作:使用unsafe的“比较并交换”方法进行value属性的交换 return unsafe.compareAndSwapInt( this, valueOffset,oldValue ,newValue ); } //使用无锁编程实现安全的自增方法 public void selfPlus() {
int oldValue = value; //通过CAS原子操作,如果操作失败就自旋,一直到操作成功 do {
// 获取旧值 oldValue = value; //统计无效的自旋次数 if (i++ > 1) {
//记录失败的次数 failure.incrementAndGet(); } } while (!unSafeCompareAndSet(oldValue, oldValue + 1)); } //测试用例入口方法 public static void main(String[] args) throws InterruptedException {
final OptimisticLockingPlus cas = new OptimisticLockingPlus(); //倒数闩,需要倒数THREAD_COUNT次 CountDownLatch latch = new CountDownLatch(THREAD_COUNT); for (int i = 0; i < THREAD_COUNT; i++) {
// 提交10个任务 ThreadUtil.getMixedTargetThreadPool().submit(() -> {
//每个任务累加1000次 for (int j = 0; j < 1000; j++) {
cas.selfPlus(); } latch.countDown(); // 执行完一个任务,倒数闩减少一次 }); } latch.await(); //主线程等待倒数闩倒数完毕 Print.tco("累加之和:" + cas.value); Print.tco("失败次数:" + cas.failure.get()); } } }
- 从上面的输出结果可以看出,调用Unsafe.objectFieldOffset(…)方法所获取到的value属性的偏移量为12。为什么value属性的偏移量为12呢?接下来为大家详细地分析。
字段偏移量的计算
- 调用Unsafe.objectFieldOffset(…)方法获取到的Object字段(也叫Object成员属性)的偏移量值是字段相对于Object头部的偏移量,是一个相对的内存地址值,不是绝对的内存地址值。
- 首先回顾一下3.1.3节用到的OptimisticLockingPlus类,该类所包含的字段如下:
// 模拟CAS 算法 static class OptimisticLockingPlus {
//静态常量:线程数 private static final int THREAD_COUNT = 10; //成员属性:包装的值 volatile private int value; //静态常量:JDK不安全类的实例 private static final Unsafe unsafe = JvmUtil.getUnsafe(); //静态常量:value 成员的相对偏移(相对于对象头) private static final long valueOffset; //静态常量:CAS的失败次数 private static final AtomicLong failure = new AtomicLong(0); // 省略其他不相干的代码 }
- // 模拟CAS 算法
static class OptimisticLockingPlus
{ //静态常量:线程数
private static final int THREAD_COUNT = 10;
//成员属性:包装的值
volatile private int value;
//静态常量:JDK不安全类的实例
private static final Unsafe unsafe = JvmUtil.getUnsafe();
//静态常量:value 成员的相对偏移(相对于对象头)
private static final long valueOffset;
//静态常量:CAS的失败次数
private static final AtomicLong failure = new AtomicLong(0);
// 省略其他不相干的代码
}
- 通过图3-3可以看出,在64位的JVM堆区中一个OptimisticLockingPlus对象的Object Header(头部)占用了12字节,其中Mark Word占用了8字节(64位),压缩过的Class Pointer占用了4字节。接在Object Header之后的就是成员属性value的内存区域,所以value属性相对于Object Header的偏移量为12。
- 通过图3-3可以看出,在64位的JVM堆区中一个OptimisticLockingPlus对象的Object Header(头部)占用了12字节,其中Mark Word占用了8字节(64位),压缩过的Class Pointer占用了4字节。接在Object Header之后的就是成员属性value的内存区域,所以value属性相对于Object Header的偏移量为12。
package com.crazymakercircle.cas; // 省略import public class TestCompareAndSwap {
@Test public void printObjectStruct() {
//创建一个对象 OptimisticLockingPlus object=new OptimisticLockingPlus(); //给成员赋值 object.value=100; //通过JOL工具输出内存布局 String printable = ClassLayout.parseInstance(object).toPrintable(); Print.fo("object = " + printable); } // 省略不相关代码 }
[TestCompareAndSwap.printObjectStruct]:object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 50 08 01 f8 (0 00001000 00000001 ) (-) 12 4 int OptimisticLockingPlus.value 100 Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
- 从以上JOL输出的结果可以看出,一个TestCompareAndSwap对象的Object Header占用了12字节,而value属性的内存位置紧挨在Object Header之后,所以value属性的相对偏移量值为12。
JUC原子类
- 在多线程并发执行时,诸如“++”或“–”类的运算不具备原子性,不是线程安全的操作。通常情况下,大家会使用synchronized将这些线程不安全的操作变成同步操作,但是这样会降低并发程序的性能。所以,JDK为这些类型不安全的操作提供了一些原子类,与synchronized同步机制相比,JDK原子类是基于CAS轻量级原子操作的实现,使得程序运行效率变得更高。
JUC中的Atomic原子操作包
- Atomic操作翻译成中文是指一个不可中断的操作,即使在多个线程一起执行Atomic类型操作的时候,一个操作一旦开始,就不会被其他线程中断。所谓Atomic类,指的是具有原子操作特征的类。
JUC并发包中原子类的位置
- JUC并发包中的原子类都存放在java.util.concurrent.atomic类路径下
- 根据操作的目标数据类型,可以将JUC包中的原子类分为4类:基本原子类、数组原子类、原子引用类和字段更新原子类
1.基本原子类
2.数组原子类
数组原子类的功能是通过原子方式更新数组中的某个元素的值。数组原子类主要包括以下三个:·AtomicIntegerArray:整型数组原子类。·AtomicLongArray:长整型数组原子类。·AtomicReferenceArray:引用类型数组原子类。
3.引用原子类
4.字段更新原子类
可见性与有序性的原理
- 原子性、可见性、有序性是并发编程所面临的三大问题。Java通过CAS操作解决了并发编程中的原子性问题,本章为大家介绍Java如何解决剩余的两个问题——可见性和有序性问题
1.CPU物理缓存结构
- 由于CPU的运算速度比主存(物理内存)的存取速度快很多,为了提高处理速度,现代CPU不直接和主存进行通信,而是在CPU和主存之间设计了多层的Cache(高速缓存),越靠近CPU的高速缓存越快,容量也越小。
- PU高速缓存结构如图4-1所示。按照数据读取顺序和与CPU内核结合的紧密程度,CPU高速缓存有L1和L2高速缓存(即一级高速缓存和二级缓存高速),部分高端CPU还具有L3高速缓存(即三级高速缓存)。每一级高速缓存中所存储的数据都是下一级高速缓存的一部分,越靠近CPU的高速缓存读取越快,容量也越小。所以L1高速缓存容量很小,但存取速度最快,并且紧靠着使用它的CPU内核。L2容量大一些,存取速度也慢一些,并且仍然只能被一个单独的CPU核使用。L3在现代多核CPU中更普遍,容量更大、读取速度更慢些,能被同一个CPU芯片板上的所有CPU内核共享。最后,系统还拥有一块主存(即主内存),由系统中的所有CPU共享。拥有L3高速缓存的CPU,CPU存取数据的命中率可达95%,也就是说只有不到5%的数据需要从主存中去存取
- 图4-1中的L1高速缓存和L2高速缓存都只能被一个单独的CPU内核使用,L3高速缓存可以被同一个CPU芯片上的所有CPU内核共享,而主存可以由系统中的所有CPU共享。
- CPU内核读取数据时,先从L1高速缓存中读取,如果没有命中,再到L2、L3高速缓存中读取,假如这些高速缓存都没有命中,它就会到主存中读取所需要的数据。
- 高速缓存大大缩小了高速CPU内核与低速主存之间的速度差距。以三层高速缓存架构为例:
·L1高速缓存最接近CPU,容量最小(如32KB、64KB等)、存取速度最快,每个核上都有一个L1高速缓存。
·L2高速缓存容量更大(如256KB)、速度低些,在一般情况下,每个内核上都有一个独立的L2高速缓存。
·L3高速缓存最接近主存,容量最大(如12MB)、速度最低,由在同一个CPU芯片板上的不同CPU内核共享。
知名Java专家Martin和Mike在QCon Presentation演讲中给出了一些缓存未命中情况下的时间消耗参考数据,如表4-1所示。
JUC显示锁的原理与实战
与Java内置锁不同,JUC显式锁是一种非常灵活的、使用纯Java语言实现的锁,这种锁的使用非常灵活,可以进行无条件的、可轮询的、定时的、可中断的锁获取和释放操作。由于JUC锁加锁和解锁的方法都是通过Java API显式进行的,因此也叫显式锁。
显式锁
- 使用Java内置锁时,不需要通过Java代码显式地对同步对象的监视器进行抢占和释放,这些工作由JVM底层完成,而且任何一个Java对象都能作为一个内置锁使用,所以Java的对象锁使用起来非常方便。但是,Java内置锁的功能相对单一,不具备一些比较高级的锁功能,比如:
(1)限时抢锁:在抢锁时设置超时时长,如果超时还未获得锁就放弃,不至于无限等下去。
(2)可中断抢锁:在抢锁时,外部线程给抢锁线程发一个中断信号,就能唤起等待锁的线程,并终止抢占过程。
(3)多个等待队列:为锁维持多个等待队列,以便提高锁的效率。比如在生产者-消费者模式实现中,生产者和消费者共用一把锁,该锁上维持两个等待队列,即一个生产者队列和一个消费者队列。- 除了以上功能问题之外,Java对象锁还存在性能问题。在竞争稍微激烈的情况下,Java对象锁会膨胀为重量级锁(基于操作系统的Mutex Lock实现),而重量级锁的线程阻塞和唤醒操作需要进程在内核态和用户态之间来回切换,导致其性能非常低。所以,迫切需要提供一种新的锁来提升争用激烈场景下锁的性能。
- Java显式锁就是为了解决这些Java对象锁的功能问题、性能问题而生的。JDK 5版本引入了Lock接口,Lock是Java代码级别的锁。为了与Java对象锁相区分,Lock接口叫作显式锁接口,其对象实例叫作显式锁对象。
显式锁Lock接口
- JDK 5版本引入了java.util.concurrent并发包,简称为JUC包,里面提供了各种高并发工具类,通过此JUC工具包可以在Java代码中实现功能非常强大的多线程并发操作。所以,Java显式锁也叫JUC显式锁。
- JUC出自并发大师Doug Lea之手,Doug Lea对Java并发性能的提升做出了巨大的贡献。除了实现JUC包外,Doug Lea还提供了高并发IO模式——Reactor模式多个版本的参考实现。Reactor模式是Java高并发服务端编程的一个至关重要的模式,有关其原理和详细知识请参考本书的上一卷《Java高并发核心编程 卷1:NIO、Netty、Redis、ZooKeeper》。
- Lock接口位于java.util.concurrent.locks包中,是JUC显式锁的一个抽象,Lock接口的主要抽象方法如表5-1所示。
- JUC包中提供了一系列的显式锁实现类(如ReentrantLock),当然也允许应用程序提供自定义的锁实现类。
- 与synchronized关键字不同,显式锁不再作为Java内置特性来实现,而是作为Java语言可编程特性来实现。这就为多种不同功能的锁实现留下了空间,各种锁实现可能有不同的调度算法、性能特性或者锁定语义。
- 从Lock提供的接口方法可以看出,显式锁至少比Java内置锁多了以下优势:
(1)可中断获取锁使用synchronized关键字获取锁的时候,如果线程没有获取到被阻塞,阻塞期间该线程是不响应中断信号(interrupt)的;而调用Lock.lockInterruptibly()方法获取锁时,如果线程被中断,线程将抛出中断异常。
(2)可非阻塞获取锁使用synchronized关键字获取锁时,如果没有成功获取,线程只有被阻塞;而调用Lock.tryLock()方法获取锁时,如果没有获取成功,线程也不会被阻塞,而是直接返回false。
(3)可限时抢锁调用Lock.tryLock(long time,TimeUnit unit)方法,显式锁可以设置限定抢占锁的超时时间。而在使用synchronized关键字获取锁时,如果不能抢到锁,线程只能无限制阻塞。
除了以上能通过Lock接口直接观察出来的三点优势之外,显式锁还有不少其他的优势,稍后在介绍显式锁种类繁多的实现类时,大家就能感觉到。
可重入锁ReentrantLock
- ReentrantLock是JUC包提供的显式锁的一个基础实现类,ReentrantLock类实现了Lock接口,它拥有与synchronized相同的并发性和内存语义,但是拥有了限时抢占、可中断抢占等一些高级锁特性。此外,ReentrantLock基于内置的抽象队列同步器(Abstract Queued Synchronized,AQS)实现,在争用激烈的场景下,能表现出表内置锁更佳的性能。
- 抽象队列同步器是JUC包同步机制的基础设施,更是JUC锁框架的基础,会在第6章进行重点和专题介绍。
- ReentrantLock是一个可重入的独占(或互斥)锁,其中两个修饰词的含义为
(1)可重入的含义:表示该锁能够支持一个线程对资源的重复加锁,也就是说,一个线程可以多次进入同一个锁所同步的临界区代码块。比如,同一线程在外层函数获得锁后,在内层函数能再次获取该锁,甚至多次抢占到同一把锁。下面是一段对可重入锁进行两次抢占和释放的伪代码,具体如下:
lock.lock(); // 第一次获取锁 lock.lock(); // 第二次获取锁,重新进入 try {
// 临界区代码块 } finally {
lock.unlock(); // 释放锁 lock.unlock(); // 第二次释放锁 }
(2)独占的含义:在同一时刻只能有一个线程获取到锁,而其他获取锁的线程只能等待,只有拥有锁的线程释放了锁后,其他的线程才能够获取锁。
- 一个简单地使用ReentrantLock进行同步累加的演示案例如下:
package com.crazymakercircle.demo.lock; // 省略import public class LockTest {
@org.junit.Test public void testReentrantLock() {
// 每个线程的执行轮数 final int TURNS = 1000; // 线程数 final int THREADS = 10; //线程池,用于多线程模拟测试 ExecutorService pool = Executors.newFixedThreadPool(THREADS); //创建一个可重入、独占的锁对象 Lock lock = new ReentrantLock(); // 倒数闩 CountDownLatch countDownLatch = new CountDownLatch(THREADS); long start = System.currentTimeMillis(); //10个线程并发执行 for (int i = 0; i < THREADS; i++) {
pool.submit(() -> {
try {
//累加 1000 次 for (int j = 0; j < TURNS; j++) {
//传入锁,执行一次累加 IncrementData.lockAndFastIncrease(lock); } Print.tco("本线程累加完成"); } catch (Exception e) {
e.printStackTrace(); } //线程执行完成,倒数闩减少一次 countDownLatch.countDown(); }); } try {
//等待倒数闩归零,所有线程结束 countDownLatch.await(); } catch (InterruptedException e) {
e.printStackTrace(); } float time = (System.currentTimeMillis() - start) / 1000F; //输出统计结果 Print.tcfo("运行的时长为:" + time); Print.tcfo("累加结果为:" + IncrementData.sum); } // 省略其他代码 }
& 分离变与不变是软件设计的一个基本原则。本章后续会演示多种锁(包括乐观锁、悲观锁、公平锁、可中断锁、自旋锁等等)的使用,在这些使用案例中,变化的部分为锁的创建代码,而不变的部分为锁的使用代码。因为JUC中的显式锁都实现了Lock接口,所以对于不同锁对象的使用代码是模板化的、套路化的。我们可以将演示案例中创建锁的代码(变化的部分)和使用锁的代码(不变的部分)进行分离。出于“分离变与不变”的设计原则,这里将临界区使用锁的代码进行了抽取和封装,形成一个可以复用的独立类——IncrementData累加类,具体代码如下:
package com.crazymakercircle.demo.lock; // 省略import //封装锁的使用代码 public class IncrementData {
public static int sum = 0; public static void lockAndFastIncrease(Lock lock) {
lock.lock(); //step1:抢占锁 try {
//step2:执行临界区代码 sum++; } finally {
lock.unlock(); //step3:释放锁 } } // 省略其他代码 }
使用显式锁的模板
- 代码上一小节讲到,因为JUC中的显式锁都实现了Lock接口,所以不同类型的显式锁对象的使用方法都是模板化的、套路化的,本小节专门介绍一下使用显式锁的模板代码。
1.使用lock()方法抢锁的模板代码
- 通常情况下,大家会调用lock()方法进行阻塞式的锁抢占,其模板代码如下:
//创建锁对象,SomeLock为Lock的某个实现类,如ReentrantLock Lock lock = new SomeLock(); lock.lock(); //step1:抢占锁 try {
//step2:抢锁成功,执行临界区代码 } finally {
lock.unlock(); //step3:释放锁 }
- 以上抢锁模板代码有以下几个需要注意的要点:
- (1)释放锁操作lock.unlock()必须在try-catch结构的finally块中执行,否则,如果临界区代码抛出异常,锁就有可能永远得不到释放。
- (2)抢占锁操作lock.lock()必须在try语句块之外,而不是放在try语句块之内。为什么呢?原因之一是lock()方法没有申明抛出异常,所以可以不包含到try块中;原因之二是lock()方法并不一定能够抢占锁成功,如果没有抢占成功,当然也就不需要释放锁,而且在没有占有锁的情况下去释放锁,可能会导致运行时异常。
- (3)在抢占锁操作lock.lock()和try语句之间不要插入任何代码,避免抛出异常而导致释放锁操作lock.unlock()执行不到,导致锁无法被释放。
- 以上代码的抢锁操作在try语句块之内,如果抢锁操作没有成功,也就是当前线程没有获取到锁,在finally语句块调用unlock()方法时就会抛出异常。
2.调用tryLock()方法非阻塞抢锁的模板代码
- lock()是阻塞式抢占,在没有抢到锁的情况下,当前线程会阻塞。如果不希望线程阻塞,可以调用tryLock()方法抢占锁。tryLock()是非阻塞抢占,在没有抢到锁的情况下,当前线程会立即返回,不会被阻塞。
- 调用tryLock()方法非阻塞抢占锁,大致的模板代码如下:
//创建锁对象,SomeLock为Lock的某个实现类,如ReentrantLock Lock lock = new SomeLock(); if (lock.tryLock()) {
//step1:尝试抢占锁 try {
//step2:抢锁成功,执行临界区代码 } finally {
lock.unlock(); //step3:释放锁 } } else {
//step4:抢锁失败,执行后备动作 }
- 调用tryLock()方法时,线程拿不到锁就立即返回,这种处理方式在实际开发中使用不多,但是其重载版本tryLock(long time,TimeUnit unit)方法在限时阻塞抢锁的场景中非常有用。
3.调用tryLock(long time,TimeUnit unit)方法抢锁的模板代码
- tryLock(long time,TimeUnit unit)方法用于限时抢锁,该方法在抢锁时会进行一段时间的阻塞等待,其中的time参数代表最大的阻塞时长,unit参数为时长的单位(如秒)。
- 调用tryLock(long time,TimeUnit unit)方法限时抢锁,其大致的代码模板如下:
//创建锁对象,SomeLock为Lock的某个实现类,如ReentrantLock Lock lock = new SomeLock(); //抢锁时阻塞一段时间,如1秒 if (lock.tryLock(1, TimeUnit.SECONDS)) {
//step1:限时阻塞抢占 try {
//step2:抢锁成功,执行临界区代码 } finally {
lock.unlock(); //step3:释放锁 } } else {
//限时抢锁失败,执行后备操作 }
- 对lock()、tryLock()、tryLock(long time,TimeUnit unit)这三个方法总结如下:
- (1)lock()方法用于阻塞抢锁,抢不到锁时线程会一直阻塞。
- (2)tryLock()方法用于尝试抢锁,该方法有返回值,如果成功就返回true,如果失败(锁已被其他线程获取)就返回false。此方法无论如何都会立即返回,在抢不到锁时,线程不会像调用lock()方法那样一直被阻塞。
- (3)tryLock(long time,TimeUnit unit)方法和tryLock()方法类似,只不过这个方法在抢不到锁时会阻塞一段时间。如果在阻塞期间获取到锁就立即返回true,超时则返回false。
基于显式锁进行“等待-通知”方式的线程间通信
- 在前面介绍Java的线程间通信机制时,基于Java内置锁实现一种简单的“等待-通知”方式的线程间通信:通过Object对象的wait、notify两类方法作为开关信号,用来完成通知方线程和等待方线程之间的通信。
- “等待-通知”方式的线程间通信机制,具体来说是指一个线程A调用了同步对象的wait()方法进入等待状态,而另一线程B调用了同步对象的notify()或者notifyAll()方法去唤醒等待线程,当线程A收到线程B的唤醒通知后,就可以重新开始执行了。
- 需要特别注意的是,在通信过程中,线程需要拥有同步对象的监视器,在执行Object对象的wait、notify方法之前,线程必须先通过抢占到内置锁而成为其监视器的Owner。
- 与Object对象的wait、notify两类方法相类似,基于Lock显式锁,JUC也为大家提供了一个用于线程间进行“等待-通知”方式通信的接口——java.util.concurrent.locks.Condition。
1.Condition接口的主要方法
public interface Condition {
//方法1:等待。此方法在功能上与 Object.wait()语义等效 //使当前线程加入 await() 等待队列中,并释放当前锁 //当其他线程调用signal()时,等待队列中的某个线程会被唤醒,重新去抢锁 void await() throws InterruptedException; //方法2:通知。此方法在功能上与Object.notify()语义等效 // 唤醒一个在 await()等待队列中的线程 void signal(); //方法3:通知全部。唤醒 await()等待队列中所有的线程 //此方法与object.notifyAll()语义上等效 void signalAll(); //方法4:限时等待。此方法与await()语义等效 //不同点在于,在指定时间time等待超时后,如果没有被唤醒,线程将中止等待 //线程等待超时返回false,其他情况返回true boolean await(long time, TimeUnit unit) throws InterruptedException; }
- 以上是Condition接口的常用方法,await(系列)方法对应于Object.wait方法,signal方法对应于Object.notify方法,signalAll方法对应于Object.notifyAll方法。
- 为了避免与Object中的wait/notify/notifyAll方法在使用时发生混淆,JUC对Condition接口的方法改变了名称,同样的wait/notify/notifyAll方法,在Condition接口中名称被改为await/signal/signalAll方法。
- Condition的“等待-通知”方法和Object的“等待-通知”方法的语义等效关系为:
·Condition类的await方法和Object类的wait方法等效。
·Condition类的signal方法和Object类的notify方法等效。
·Condition类的signalAll方法和Object类的notifyAll方法等效。
Condition对象的signal(通知)方法和同一个对象的await(等待)方法是一一配对使用的,也就是说,一个Condition对象的signal(或signalAll)方法不能去唤醒其他Condition对象上的await线程。- Condition对象是基于显式锁的,所以不能独立创建一个Condition对象,而是需要借助于显式锁实例去获取其绑定的Condition对象。不过,每一个Lock显式锁实例都可以有任意数量的Condition对象。具体来说,可以通过lock.newCondition()方法去获取一个与当前显式锁绑定的Condition实例,然后通过该Condition实例进行“等待-通知”方式的线程间通信。
2.显式锁Condition演示案例
- 下面是一个简单的通过Condition完成线程间“等待-通知”方式通信的演示实例:
package com.crazymakercircle.demo.lock; // 省略import public class ReentrantCommunicationTest {
// 创建一个显式锁 static Lock lock = new ReentrantLock(); // 获取一个显式锁绑定的Condition对象 static private Condition condition = lock.newCondition(); // 等待线程的异步目标任务 static class WaitTarget implements Runnable {
public void run() {
lock.lock(); // ①抢锁 try {
Print.tcfo("我是等待方"); condition.await(); // ② 开始等待,并且释放锁 Print.tco("收到通知,等待方继续执行"); } catch (InterruptedException e) {
e.printStackTrace(); } finally {
lock.unlock();//释放锁 } } } //通知线程的异步目标任务 static class NotifyTarget implements Runnable {
public void run() {
lock.lock(); //③抢锁 try {
Print.tcfo("我是通知方"); condition.signal(); // ④发送通知 Print.tco("发出通知了,但是线程还没有立马释放锁"); } finally {
lock.unlock(); //⑤释放锁之后,等待线程才能获得锁 } } } public static void main(String[] args) throws InterruptedException {
//创建等待线程 Thread waitThread = new Thread(new WaitTarget(), "WaitThread"); //启动等待线程 waitThread.start(); sleepSeconds(1); //稍等一下 //创建通知线程 Thread notifyThread = new Thread(new NotifyTarget(), "NotifyThread"); //启动通知线程 notifyThread.start(); } }
LockSupport
// 无限期阻塞当前线程 public static void park(); // 唤醒某个被阻塞的线程 public static void unpark(Thread thread); // 阻塞当前线程,有超时时间的限制 public static void parkNanos(long nanos); // 阻塞当前线程,直到某个时间 public static void parkUntil(long deadline); // 无限期阻塞当前线程,带blocker对象,用于给诊断工具确定线程受阻塞的原因 public static void park(Object blocker); // 限时阻塞当前线程,带blocker对象 public static void parkNanos(Object blocker, long nanos); // 获取被阻塞线程的blocker对象,用于分析阻塞的原因 public static Object getBlocker(Thread t);
- LockSupport的方法主要有两类:park和unpark。park的英文意思为停车,如果把Thread看成一辆车的话,park()方法就是让车停下,其作用是将调用park()方法的当前线程阻塞;而unpark()方法是让车启动,然后跑起来,其作用是将指定线程Thread唤醒。
2.LockSupport的演示实例
下面是一个简单的通过LockSupport阻塞和唤醒线程的演示实例:
package com.crazymakercircle.demo.lock; // 省略import public class LockSupportDemo {
public static class ChangeObjectThread extends Thread {
public ChangeObjectThread(String name) {
super(name); } @Override public void run() {
Print.tco("即将进入无限时阻塞"); //阻塞当前线程 LockSupport.park(); if (Thread.currentThread().isInterrupted()) {
Print.tco("被中断了,但仍然会继续执行"); } else {
Print.tco("被重新唤醒了"); } } } //LockSupport 测试用例 @org.junit.Test public void testLockSupport() {
ChangeObjectThread t1 = new ChangeObjectThread("线程一"); ChangeObjectThread t2 = new ChangeObjectThread("线程二"); //启动线程一 t1.start(); sleepSeconds(1); //启动线程二 t2.start(); sleepSeconds(1); //中断线程一 t1.interrupt(); //唤醒线程二 LockSupport.unpark(t2); } }
4.LockSupport.park()与Object.wait()的区别
package com.crazymakercircle.demo.lock; // 省略import public class LockSupportDemo {
@org.junit.Test public void testLockSupport2() {
Thread t1 = new Thread(() -> {
try {
Thread.sleep(1000); //使sleep阻塞当前线程,时长为1秒 } catch (InterruptedException e) {
e.printStackTrace(); } Print.tco("即将进入无限时阻塞"); //使用LockSupport.park()阻塞当前线程 LockSupport.park(); Print.tco("被重新唤醒了"); }, "演示线程"); //通过匿名对象创建一个线程 t1.start(); //唤醒一次没有使用 LockSupport.park()阻塞的线程 LockSupport.unpark(t1); //再唤醒一次没有调用 LockSupport.park()阻塞的线程 LockSupport.unpark(t1); sleepSeconds(2); //中断线程一 //第三唤醒调用 LockSupport.park()阻塞的线程 LockSupport.unpark(t1); } // 省略其他 }
通过结果可以看出,前两次LockSupport.unpark(t1)唤醒操作没有发生任何作用,因为线程t1还没有被LockSupport.park()阻塞。只有在被LockSupport.park()阻塞之后,LockSupport.unpark(t1)唤醒操作才能将线程t1唤醒。
显式锁的分类
- 显式锁有很多种,从不同的角度来看,显式锁大概有以下几种分类:可重入锁和不可重入锁、悲观锁和乐观锁、公平锁和非公平锁、共享锁和独占锁、可中断锁和不可中断锁。
1.可重入锁和不可重入锁
- 从同一个线程是否可以重复占有同一个锁对象的角度来分,显式锁可以分为可重入锁与不可重入锁。可重入锁也叫作递归锁,指的是一个线程可以多次抢占同一个锁。例如,线程A在进入外层函数抢占了一个Lock显式锁之后,当线程A继续进入内层函数时,如果遇到有抢占同一个Lock显式锁的代码,线程A依然可以抢到该Lock显式锁。
- 不可重入锁与可重入锁相反,指的是一个线程只能抢占一次同一个锁。例如,线程A在进入外层函数抢占了一个Lock显式锁之后,当线程A继续进入内层函数时,如果遇到有抢占同一个Lock显式锁的代码,线程A不可以抢到该Lock显式锁。除非线程A提前释放了该Lock显式锁,才能第二次抢占该锁。
- JUC的ReentrantLock类是可重入锁的一个标准实现类。
2.悲观锁和乐观锁
- 从线程进入临界区前是否锁住同步资源的角度来分,显式锁可以分为悲观锁和乐观锁
悲观锁
- 悲观锁就是悲观思想,每次进入临界区操作数据的时候都认为别的线程会修改,所以线程每次在读写数据时都会上锁,锁住同步资源,这样其他线程需要读写这个数据时就会阻塞,一直等到拿到锁。总体来说,悲观锁适用于写多读少的场景,遇到高并发写时性能高。
- Java的synchronized重量级锁是一种悲观锁。
乐观锁
- 乐观锁是一种乐观思想,每次去拿数据的时候都认为别的线程不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样就更新),如果失败就要重复读-比较-写的操作。总体来说,乐观锁适用于读多写少的场景,遇到高并发写时性能低。
- Java中的乐观锁基本都是通过CAS自旋操作实现的。CAS是一种更新原子操作,比较当前值跟传入值是否一样,是则更新,不是则失败。在争用激烈的场景下,CAS自旋会出现大量的空自旋,会导致乐观锁性能大大降低。
- Java中的乐观锁基本都是通过CAS自旋操作实现的。CAS是一种更新原子操作,比较当前值跟传入值是否一样,是则更新,不是则失败。在争用激烈的场景下,CAS自旋会出现大量的空自旋,会导致乐观锁性能大大降低。
- Java的synchronized轻量级锁是一种乐观锁。另外,JUC中基于抽象队列同步器(AQS)实现的显式锁(如ReentrantLock)都是乐观锁。
- 注意
- 既然在争用激烈的场景下乐观锁的性能非常低,那么为什么JUC的显式锁都是乐观锁呢?根本的原因是,JUC的显式锁都是基于AQS实现的,而AQS通过对队列的使用很大程度上减少了锁的争用,极大地减少了空的CAS自旋。所以,即使在争用激烈的场景下,基于AQS的JUC乐观锁也能表现出比悲观锁更佳的性能。
详情
- 独占锁其实就是一种悲观锁,Java的synchronized是悲观锁。悲观锁可以确保无论哪个线程持有锁,都能独占式访问临界区。虽然悲观锁的逻辑非常简单,但是存在不少问题。
悲观锁存在的问题
- 悲观锁总是假设会发生最坏的情况,每次线程读取数据时,也会上锁。这样其他线程在读取数据时就会被阻塞,直到它拿到锁。传统的关系型数据库用到了很多悲观锁,比如行锁、表锁、读锁、写锁等。
- 悲观锁机制存在以下问题:
(1)在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
(2)一个线程持有锁后,会导致其他所有抢占此锁的线程挂起。
(3)如果一个优先级高的线程等待一个优先级低的线程释放锁,就会导致线程的优先级倒置,从而引发性能风险。- 解决以上悲观锁的这些问题的有效方式是使用乐观锁去替代悲观锁。与之类似,数据库操作中的带版本号数据更新、JUC包的原子类,都使用了乐观锁的方式提升性能。
通过CAS实现乐观锁
- 乐观锁的操作主要就是两个步骤:
- (1)第一步:冲突检测。
- (2)第二步:数据更新。
- 乐观锁一种比较典型的就是CAS原子操作,JUC强大的高并发性能是建立在CAS原子之上的。CAS操作中包含三个操作数:
需要操作的内存位置(V)、
进行比较的预期原值(A)和
拟写入的新值(B)。- 如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置的值更新为新值B;否则处理器不做任何操作。
- CAS操作可以非常清晰地分为两个步骤:
(1)检测位置V的值是否为A。
(2)如果是,就将位置V更新为B值;否则不要更改该位置。- CAS操作的两个步骤其实与乐观锁操作的两个步骤是一致的,都是在冲突检测后进行数据更新。
- 乐观锁是一种思想,而CAS是这种思想的一种实现。实际上,如果需要完成数据的最终更新,仅仅进行一次CAS操作是不够的,一般情况下,需要进行自旋操作,即不断地循环重试CAS操作直到成功,这也叫CAS自旋。
- 通过CAS自旋,在不使用锁的情况下实现多线程之间的变量同步,也就是说,在没有线程被阻塞的情况下实现变量的同步,这叫作“非阻塞同步”(Non-Blocking Synchronization),或者说“无锁同步”。使用基于CAS自旋的乐观锁进行同步控制,属于无锁编程(Lock Free)的一种实践。
- 接下来为大家介绍如何基于CAS自旋实现一个简单的自旋锁。
不可重入的自旋锁
- 自旋锁的基本含义为:当一个线程在获取锁的时候,如果锁已经被其他线程获取,调用者就一直在那里循环检查该锁是否已经被释放,一直到获取到锁才会退出循环。
- CAS自旋锁的实现原理为:抢锁线程不断进行CAS自旋操作去更新锁的owner(拥有者),如果更新成功,就表明已经抢锁成功,退出抢锁方法。如果锁已经被其他线程获取(也就是owner为其他线程),调用者就一直在那里循环进行owner的CAS更新操作,一直到成功才会退出循环。
- 作为演示,这里先实现一个简单版本的自旋锁——不可重入的自旋锁,具体的代码如下:
package com.crazymakercircle.demo.lock.custom; // 省略import public class SpinLock implements Lock {
/当前锁的拥有者 * 使用Thread 作为同步状态 */ private AtomicReference<Thread> owner = new AtomicReference<>(); / * 抢占锁 */ @Override public void lock() {
Thread t = Thread.currentThread(); //自旋 while (owner.compareAndSet(null, t)) {
// DO nothing Thread.yield();//让出当前剩余的CPU时间片 } } / * 释放锁 */ @Override public void unlock() {
Thread t = Thread.currentThread(); //只有拥有者才能释放锁 if (t == owner.get()) {
// 设置拥有者为空,这里不需要 compareAndSet操作 // 因为已经通过owner做过线程检查 owner.set(null); } } // 省略其他代码 }
- 仔细分析以上代码就可以看出,上述SpinLock是不支持重入的,即当一个线程第一次已经获取到了该锁,在锁没有被释放之前,如果又一次重新获取该锁,第二次将不能成功获取到。
可重入的自旋锁
- 为了实现可重入锁,这里引入一个计数器,用来记录一个线程获取锁的次数。一个简单的可重入的自旋锁的代码大致如下:
package com.crazymakercircle.demo.lock.custom; // 省略import public class ReentrantSpinLock implements Lock {
/当前锁的拥有者 * 使用拥有者 Thread 作为同步状态,而不是使用一个简单的整数作为同步状态 */ private AtomicReference<Thread> owner = new AtomicReference<>(); / * 记录一个线程重复获取锁的次数 * 此变量为同一个线程在操作,没有必要加上volatile保障可见性和有序性 */ private int count = 0; / * 抢占锁 */ @Override public void lock() {
Thread t = Thread.currentThread(); // 如果是重入,增加重入次数后返回 if (t == owner.get()) {
++count; return; } //自旋 while (owner.compareAndSet(null, t)) {
// DO nothing Thread.yield();//让出当前剩余的CPU时间片 } } / * 释放锁 */ @Override public void unlock() {
Thread t = Thread.currentThread(); //只有拥有者才能释放锁 if (t == owner.get()) {
if (count > 0) {
// 如果重入的次数大于0, 减少重入次数后返回 --count; } else {
// 设置拥有者为空 // 这里不需要 compareAndSet, 因为已经通过owner做过线程检查 owner.set(null); } } } // 省略其他代码 }
- 自旋锁的特点:线程获取锁的时候,如果锁被其他线程持有,当前线程将循环等待,直到获取到锁。线程抢锁期间状态不会改变,一直是运行状态(RUNNABLE),在操作系统层面线程处于用户态。
- 自旋锁的问题:在争用激烈的场景下,如果某个线程持有锁的时间太长,就会导致其他空自旋的线程耗尽CPU资源。另外,如果大量的线程进行空自旋,还可能导致硬件层面的“总线风暴”。
CAS可能导致总线风暴
- 这里通过从CPU(以Intel X86为例)平台下的汇编代码入手为大家分析一下CAS的实现原理。下面是sun.misc.Unsafe类的compareAndSwapInt()方法的源代码:
public final class Unsafe {
//Unsafe中的CAS操作 public final native boolean compareAndSwapInt( Object o, //操作对象 long offset, //字段偏移 int expected, //预期值 int x); //待更新的值 // 省略不相干代码 }
- sun.misc.Unsafe类的compareAndSwapInt()方法是一个Native方法调用,该本地方法在JDK中依次调用的C++代码为:
#define LOCK_IF_MP(mp) __asm cmp mp, 0 \ __asm je L0 \ __asm _emit 0xF0 \ __asm L0: inline jint Atomic::cmpxchg(jint exchange_value, volatile jint* dest, jint compare_value) {
// alternative for InterlockedCompareExchange int mp = os::is_MP(); __asm {
mov edx, dest mov ecx, exchange_value mov eax, compare_value LOCK_IF_MP(mp) cmpxchg dword ptr [edx], ecx } }
- 以上程序会根据当前CPU的类型是否为多核CPU来决定是否为cmpxchg指令添加lock前缀。如果程序在多核CPU上运行,就为cmpxchg指令加上lock前缀(lock cmpxchg)。反之,如果程序在单核CPU上运行,就省略lock前缀,因为单核CPU不需要lock前缀提供的内存屏障效果。
- 接下来,以SMP架构的CPU为例分析一下CAS可能导致的“总线风暴”。
- 目前的CPU架构大体可以分为三类:多处理器结构(Symmetric Multi-Processor,SMP);非一致存储访问结构(Non-Uniform Memory Access,NUMA)和海量并行处理结构(Massive Parallel Processing,MPP)。常见的PC、手机、老式服务器都是SMP架构,其架构简单,但拓展性能非常差。第4章在介绍volatile关键字原理时讲到,lock前缀指令有以下三个作用:
(1)将当前CPU缓存行的数据立即写回系统内存。
(2)lock前缀指令会引起在其他CPU中缓存了该内存地址的数据无效。
(3)lock前缀指令禁止指令重排。- 由于在Intel X86平台下CAS的汇编指令lock cmpxchg也是一个lock前缀指令,因此CAS操作和volatile一样,也需要CPU保障变量的缓存一致性。
- 在SMP架构的CPU平台上,所有的Core(内核)会共享一条总线(BUS),靠此总线连接主存。每个核都有自己的高速缓存,各核相对于BUS对称分布。因此,这种结构称为“对称多处理器”。一个8核的SMP架构CPU大致如图5-1所示。
- 假设Core 1和Core 2可同时把某个变量加载到自己的高速缓存中,当Core 1在自己的高速缓存中修改这个位置的值时,会通过总线使Core 2中L1高速缓存对应的值“失效”,而Core 2一旦发现自己缓存中的值失效,就会通过总线从内存中读取最新的值,使得Core 2和Core 1中的值再次一致,这样CPU就保障了变量的“缓存一致性”。
- 前面讲到,CPU会通过MESI协议保障变量的缓存一致性。为了保障“缓存一致性”,不同的内核需要通过总线来回通信,因而所产生的流量一般被称为“缓存一致性流量”。因为总线被设计为固定的“通信能力”,如果缓存一致性流量过大,总线将成为瓶颈,这就是所谓的“总线风暴”。
- 总线风暴当然与CPU的架构和设计有关,并不是所有的CPU都会产生总线风暴。
- 由于使用lock前缀指令的Java操作(包括CAS、volatile)恰恰会产生缓存一致性流量,当有很多线程同时执行lock前缀指令操作时,在SMP架构的CPU平台上必然会导致总线风暴。
- 前面讲到,在争用激烈的场景下,Java轻量级锁会快速膨胀为重量级锁,其本质上一是为了减少CAS空自旋,二是为了避免同一时间大量CAS操作所导致的总线风暴。
- 那么,JUC基于CAS实现的轻量级锁如何避免总线风暴呢?答案是:使用队列对抢锁线性进行排队,最大程度上减少了CAS操作数量。
CLH自旋锁
- CLH锁其实就是一种基于队列(具体为单向链表)排队的自旋锁,由于是Craig、Landin和Hagersten三人一起发明的,因此被命名为CLH锁,也叫CLH队列锁。
- 简单的CLH锁可以基于单向链表实现,申请加锁的线程首先会通过CAS操作在单向链表的尾部增加一个节点,之后该线程只需要在其前驱节点上进行普通自旋,等待前驱节点释放锁即可。由于CLH锁只有在节点入队时进行一下CAS的操作,在节点加入队列之后,抢锁线程不需要进行CAS自旋,只需普通自旋即可。因此,在争用激烈的场景下,CLH锁能大大减少CAS操作的数量,以避免CPU的总线风暴。
- JUC中显式锁基于AQS抽象队列同步器,而AQS是CLH锁的一个变种,为了方便大家理解AQS的原理(此为Java工程师的必备知识),这里详细介绍一下CLH锁的实现和核心原理。
- 1.实现CLH锁的一个学习版本
首先为大家提供一个CLH锁的简单实现版本,代码如下:
package com.crazymakercircle.demo.lock.custom; // 省略import public class CLHLock implements Lock {
/ * 当前节点的线程本地变量 */ private static ThreadLocal<Node> curNodeLocal = new ThreadLocal(); / * CLHLock队列的尾部指针,使用AtomicReference,方便进行CAS操作 */ private AtomicReference<Node> tail = new AtomicReference<>(null); public CLHLock() {
//设置尾部节点 tail.getAndSet(Node.EMPTY); } //加锁操作:将节点添加到等待队列的尾部 @Override public void lock() {
Node curNode = new Node(true, null); Node preNode = tail.get(); //CAS自旋:将当前节点插入队列的尾部 while (!tail.compareAndSet(preNode, curNode)) {
preNode = tail.get(); } //设置前驱节点 curNode.setPrevNode(preNode); // 自旋,监听前驱节点的locked变量,直到其值为false // 若前驱节点的locked状态为true,则表示前一个线程还在抢占或者占有锁 while (curNode.getPrevNode().isLocked()) {
//让出CPU时间片,提高性能 Thread.yield(); } // 能执行到这里,说明当前线程获取到了锁 // Print.tcfo("获取到了锁!!!"); //将当前节点缓存在线程本地变量中,释放锁会用到 curNodeLocal.set(curNode); } //释放锁 @Override public void unlock() {
Node curNode = curNodeLocal.get(); curNode.setLocked(false); curNode.setPrevNode(null); //help for GC curNodeLocal.set(null); //方便下一次抢锁 } //虚拟等待队列的节点 @Data static class Node {
public Node(boolean locked, Node prevNode) {
this.locked = locked; this.prevNode = prevNode; } // true:当前线程正在抢占锁,或者已经占有锁 // false:当前线程已经释放锁,下一个线程可以占有锁了 volatile boolean locked; // 前一个节点,需要监听其locked字段 Node prevNode; // 空节点 public static final Node EMPTY = new Node(false, null); } // 省略其他代码 }
- 2.CLHLock锁的测试用例
下面实现一个CLHLock的测试用例:基于前面抽取出来的公共IncrementData累加类,编写一个10个线程各种累加100 000次的累加程序,并使用CLHLock作为累加的同步锁。测试用例的代码具体如下:
package com.crazymakercircle.demo.lock; // 省略import public class LockTest {
@org.junit.Test public void testCLHLockCapability() {
// 速度对比 // ReentrantLock 1 000 000 次 0.154 秒 // CLHLock 1 000 000 次 2.798 秒 // 每个线程的执行轮数 final int TURNS = ; // 线程数 final int THREADS = 10; //线程池,用于多线程模拟测试 ExecutorService pool = Executors.newFixedThreadPool(THREADS); Lock lock = new CLHLock(); // Lock lock = new ReentrantLock(); // 倒数闩 CountDownLatch countDownLatch = new CountDownLatch(THREADS); long start = System.currentTimeMillis(); for (int i = 0; i < THREADS; i++) {
pool.submit(() -> {
for (int j = 0; j < TURNS; j++) {
IncrementData.lockAndFastIncrease(lock); } Print.tcfo("本线程累加完成"); //倒数闩减少1次 countDownLatch.countDown(); }); } try {
//等待倒数闩归0,所有线程结束 countDownLatch.await(); } catch (InterruptedException e) {
e.printStackTrace(); } float time = (System.currentTimeMillis() - start) / 1000F; //输出统计结果 Print.tcfo("运行的时长为:" + time); Print.tcfo("累加结果为:" + IncrementData.sum); } // 省略其他代码 }
- 通过以上结果可以看出通过CLHLock进行累加同步,10个线程累加100?000次之后结果为1?000?000。实际上,该累加结果是正确的,这也说明以上CLHLock实现版本没有功能问题。
- 但是,由于仅仅是一个学习版本,以上CLHLock实现版本存在严重的性能问题。经过对比,其性能足足比JUC的ReentrantLock锁差20倍左右。尽管如此,以上CLHLock实现版本用于学习CLHLock的原理还是非常有价值的。
- 3.CLH锁的原理分析
简单回顾一下CLH的算法:抢锁线程在队列尾部加入一个节点,然后仅在前驱节点上进行普通自旋,它不断轮询前一个节点状态,如果发现前一个节点释放锁,当前节点抢锁成功。CLH的算法有以下几个要点:
(1)初始状态队列尾部属性(tail)指向一个EMPTY节点。
/ * CLHLock队列的尾部指针,使用AtomicReference,方便进行CAS操作 */ private AtomicReference<Node> tail = new AtomicReference<>(null); public CLHLock() {
//设置尾部节点 tail.getAndSet(Node.EMPTY); }
- tail属性使用AtomicReference类型是为了使得多个线程并发操作tail时不会发生线程安全问题。
(2)Thread在抢锁时会创建一个新的Node加入等待队列尾部:tail指向新的Node,同时新的Node的preNode属性指向tail之前指向的节点,并且以上操作通过CAS自旋完成,以确保操作成功。
(3)Thread加入抢锁队列之后,会在前驱节点上自旋:循环判断前驱节点的locked属性是否为false,如果为false就表示前驱节点释放了锁,当前线程抢占到锁。
(4)Thread抢到锁之后,它的locked属性一直为true,一直到临界区代码执行完,然后调用unlock()方法释放锁,释放之后其locked属性才为false。释放锁的代码如下:
3.公平锁和非公平锁
- 公平锁是指不同的线程抢占锁的机会是公平的、平等的,从抢占时间上来说,先对锁进行抢占的线程一定被先满足,抢锁成功的次序体现为FIFO(先进先出)顺序。简单来说,公平锁就是保障各个线程获取锁都是按照顺序来的,先到的线程先获取锁。
- 使用公平锁,比如线程A、B、C、D依次去获取锁,线程A首先获取到了锁,然后它处理完成释放锁之后,会唤醒下一个线程B去获取锁。后续不断重复前面的过程,线程C、D依次获取锁。
- 非公平锁是指不同的线程抢占锁的机会是非公平的、不平等的,从抢占时间上来说,先对锁进行抢占的线程不一定被先满足,抢锁成功的次序不会体现为FIFO(先进先出)顺序。
- 使用公平锁,比如线程A、B、C、D依次去获取锁,假如此时持有锁的是线程A,然后线程B、C、D尝试获取锁,就会进入一个等待队列。当线程A释放掉锁之后,会唤醒下一个线程B去获取锁。在唤醒线程B的这个过程中,如果有别的线程E尝试去请求锁,那么线程E是可以先获取到的,这就是插队。为什么线程E可以插队呢?因为CPU唤醒线程B需要进行线程的上下文切换,这个操作需要一定的时间,线程E可能与线程A、B不在同一个CPU内核上执行,而是在其他的内核上执行,所以不需要进行线程的上下文切换。在线程A释放锁和线程B被唤醒的这段时间,锁是空闲的,其他内核上的线程E此时就能趁机获取非公平锁,这样做的目的主要是利用锁的空档期,提高其利用效率。
- 默认情况下,ReentrantLock实例是非公平锁,但是,如果在实例构造时传入了参数true,所得到的锁就是公平锁。另外,ReentrantLock的tryLock()方法是一个特例,一旦有线程释放了锁,正在tryLock的线程就能优先取到锁,即使已经有其他线程在等待队列中。
4.可中断锁和不可中断锁
- 什么是可中断锁?如果某一线程A正占有锁在执行临界区代码,另一线程B正在阻塞式抢占锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以让它中断自己的阻塞等待,这种就是可中断锁。
- 什么是不可中断锁?一旦这个锁被其他线程占有,如果自己还想抢占,只能选择等待或者阻塞,直到别的线程释放这个锁,如果别的线程永远不释放锁,那么自己只能永远等下去,并且没有办法终止等待或阻塞。
- 简单来说,在抢锁过程中能通过某些方法终止抢占过程,这就是可中断锁,否则就是不可中断锁。Java的synchronized内置锁就是一个不可中断锁,而JUC的显式锁(如ReentrantLock)是一个可中断锁。
5.共享锁和独占锁
- 独占锁指的是每次只有一个线程能持有的锁。独占锁是一种悲观保守的加锁策略,它不必要地限制了读/读竞争,如果某个只读线程获取锁,那么其他的读线程都只能等待,这种情况下就限制了读操作的并发性,因为读操作并不会影响数据的一致性。
- JUC的ReentrantLock类是一个标准的独占锁实现类。
- 共享锁允许多个线程同时获取锁,容许线程并发进入临界区。与独占锁不同,共享锁是一种乐观锁,它放宽了加锁策略,并不限制读/读竞争,允许多个执行读操作的线程同时访问共享资源。
- JUC的ReentrantReadWriteLock(读写锁)类是一个共享锁实现类。使用该读写锁时,读操作可以有很多线程一起读,但是写操作只能有一个线程去写,而且在写入的时候,别的线程也不能进行读的操作。
- 用ReentrantLock锁替代ReentrantReadWriteLock锁虽然可以保证线程安全,但是也会浪费一部分资源,因为多个读操作并没有线程安全问题,所以在读的地方使用读锁,在写的地方使用写锁,可以提高程序执行效率。
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://haidsoft.com/113786.html





















