高并发教程二:Java 锁(锁分类、CAS、synchronized、DCL、AQS、reentrantLock)

358 阅读11分钟

锁分类

Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率

按场景分类 image.png

按用途分类 image.png

乐观锁 VS 悲观锁

悲观锁:

  • 概念:悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
  • 场景:synchronized关键字和Lock的实现类都是悲观锁

乐观锁:

  • 概念:乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作
  • 场景:CAS、数据库

image.png

自旋锁 VS 适应性自旋锁

在介绍自旋锁前,我们需要介绍一些前提知识来帮助大家明白自旋锁的概念。

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。

而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁

image.png

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

image.png

自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

适应性自旋锁:

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源

公平锁 VS 非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大

image.png

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

image.png

可重入 VS 不可重入

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

image.png 打水的例子,有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。这就是可重入锁

image.png 但如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。

CAS

CAS(compare and swap) 比较并替换,比较和替换是线程并发算法时用到的一种技术

  • CAS是非阻塞的、轻量级的乐观锁、无锁算法
  • CAS是原子操作,保证并发安全,而不是保证并发同步

image.png 如果V值等于E值,则将V的值设为N。若V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。通俗的理解就是CAS操作需要我们提供一个期望值,当期望值与当前线程的变量值相同时,说明还没线程修改该值,当前线程可以进行修改,也就是执行CAS操作,但如果期望值与当前线程不符,则说明该值已被其他线程修改,此时不执行更新操作,但可以选择重新读取该变量再尝试再次修改该变量,也可以放弃操作。

ABA问题

image.png 开始位置V得到的旧值是A,当进行赋值操作时再次读取发现仍然是A,并不能说明变量没有被其它线程改变过,有可能是其它线程将变量改为了B,后来又改回了A

解决方案:

  • 1、追加版本号:在变量前面追加版本号:每次变量更新就把版本号加1,则A-B-A就变成1A-2B-3A
  • 2、使用atomic包下的AtomicStampedReference类

其他问题

1、自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销

  • 解决:控制自旋的次数

2、当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性

  • 方案一:当然还有一种取巧的方式,就是把多个共享变量合并成一个共享变量来操作
  • 方案二: 使用JDK的原子操作类: AtomicXXXXX

Atomic保证原子性

image.png

synchronized

Synchronized是JVM实现的一种内置锁,锁的获取和释放是由JVM隐式实现,synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步、并且锁存在Java对象头中

synchronized 的特性

image.png

可见性:其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主存当中,保证资源变量的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待锁的释放。

有序性:synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

可重入:当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁

非公平:synchronized的底层实现主要依靠Lock-Free的队列,基本思路是自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性

重量级:Synchronized是基于底层操作系统的Mutex Lock实现的,每次获取和释放锁操作都会带来用户态和内核态的切换,从而增加系统性能开销。因此,在锁竞争激烈的情况下,Synchronized同步锁在性能上就表现得非常糟糕,它也常被大家称为重量级锁。

synchronized 锁升级

image.png JDK1.6版本之后、 synchronized引入了“偏向锁”和“轻量级锁”、甚至某些场景下,它的性能已经超越了Lock同步锁

synchronized锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级

image.png

image.png

synchronized 使用

修饰的代码块

synchronized修饰的代码块称为同步语句块,范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象实例

image.png

javac -encoding UTF-8 T01.java
javap -v T01.class

image.png Synchronized在修饰同步代码块时,是由 monitorenter和monitorexit指令来实现同步的。进入monitorenter 指令后,线程将持有Monitor对象,退出monitorenter指令后,线程将释放该Monitor对象。

修饰的方法

synchronized被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象实例

image.png

当Synchronized修饰同步方法时,并没有发现monitorenter和monitorexit指令,而是出现了一个ACC_SYNCHRONIZED标志

image.png 这是因为JVM使用了ACC_SYNCHRONIZED访问标志来区分一个方法是否是同步方法。当方法调用时,调用指令将会检查该方法是否被设置ACC_SYNCHRONIZED访问标志。如果设置了该标志,执行线程将先持有Monitor对象,然后再执行方法。在该方法运行期间,其它线程将无法获取到该Mointor对象,当方法执行完成后,再释放该Monitor对象。

修饰静态方法

Synchronized修饰静态方法是属于类的而不是对象实例的,其作用的范围是整个方法,作用对象是类的所有对象实例

image.png

修饰类

Synchronized修饰类,范围是大括号{}括起来的代码,作用对象是类的所有对象实例

image.png

DCL

双重检查锁定(Double Check Lock,DCL)、DCL 主要用于保证单例模式中懒汉模式线程安全

image.png

image.png 案例三问题:

举例:

  • A线程singleton = new T03()发生重排序,将分配的内存空间引用赋值给了静态属性singleton(即singleton != null),而对象还未初始化(即Integer a == null);
  • B线程此时调用getInstance()方法,因为singleton != null,直接返回singleton。当B线程使用singleton的a属性时就会空指针

AQS

AQS(AbstractQueuedSynchronizer) 是抽象队列同步器、是Doug Lea大作、用来构建Lock锁和同步组件的基础框架 image.png

image.png

image.png

image.png

CLH队列

CLH队列:是一个双向链表队列,其内部由head和tail分别记录头结点和尾结点,队列的元素类型是Node

image.png

入队列 入队列.gif

出队列 出队列.gif

共享式与独占式

image.png

公平与非公平

image.png

reentrantLock

ReentrantLock是Java并发包中互斥锁,底层通过AQS实现、它有公平锁和非公平锁两种实现方式

核心特性: image.png

核心逻辑: image.png

image.png

核心方法:

公平:Lock lock=new ReentrantLock(true);
非公平:Lock lock=new ReentrantLock(false);
阻塞等待获取锁:lock()
尝试加锁:tryLock()
释放锁:unlock()