Java并发锁概念整理
Java并发锁整理
各种锁的概念
显示锁 vs 内置锁(隐式锁)
显示锁(JDK1.5之后才有) | 内置锁(隐式锁) | |
---|---|---|
锁的控制对象 | 锁的申请和释放都可以由程序所控制 | 锁的申请和释放都是由 JVM 所控制 |
实现 | ReentrantLock、ReentrantReadWriteLock | synchronized |
优化趋势 | 无 | JDK1.6之后对synchronized做了大量优化,性能已经与显示锁基本持平 |
优缺点 | 使用不当可能造成死锁 | synchronized是 JVM 内置属性,能执行一些优化 |
能否响应中断 | √ | × |
超时机制 | √ | × |
支持公平锁 | √ | × |
支持共享 | √(ReentrantReadWriteLock读锁) | × |
支持读写分离 | √ | × |
悲观锁 vs 乐观锁
悲观锁 | 乐观锁 | |
---|---|---|
思想 | 总是假设并发操作一定会发生冲突,所以每次进行并发操作都加上锁 | 乐观地认为不会发生冲突,只在需要操作值的时候检查值有没有发生变化,没变化才去更新 |
实现 | synchronized、Lock | CAS + (使用版本号解决ABA问题) |
适用场景 | 适合写操作频繁而读操作少的场景 | 适合读操作频繁而写操作少的场景 |
公平锁 vs 非公平锁
ReentrantLock中的内部NoFairSync对应非公平锁
ReentrantLock中的内部类FairSync对应公平锁
公平锁 | 非公平锁 | |
---|---|---|
思想 | 多线程按照申请锁的顺序来获取锁 | 多线程不按照申请锁的顺序来获取锁 |
实现 | ReentrantLock、ReentrantReadWriteLock(也支持公平锁) | synchronized、ReentrantLock、ReentrantReadWriteLock(默认非公平锁) |
缺点 | 为了保证线程申请顺序,势必要付出一定的性能代价,因此其吞吐量一般低于非公平锁 | 饥饿现象(某线程总是抢不过别的线程,导致始终无法执行) |
独占锁 vs 共享锁
独享锁 | 共享锁 | |
---|---|---|
思想 | 锁一次只能被一个线程持有 | 锁可被多个线程所持有 |
实现 | synchronized、ReentrantLock、ReentrantReadWriteLock写锁 | ReentrantReadWriteLock读锁 |
实际用途 | 互斥 | 读读共享 |
可重入锁
概念
可重入锁又名递归锁,是指同一个线程在外层方法获取了锁,在进入内层方法会自动获取锁。
典型的可重入锁
- ReentrantLock
- ReentrantReadWriteLock
- synchronized
意义
在一定程度上避免死锁的发生。
例子
synchronized void setA() throws Exception{
Thread.sleep(1000);
setB();
}
synchronized void setB() throws Exception{
Thread.sleep(1000);
}
如果使用的锁不是可重入锁的话,setB
可能不会被当前线程执行,从而造成死锁。
分段锁
分段锁其实是一种锁的设计,并不是具体的一种锁。所谓分段锁,就是把锁的对象分成多段,每段独立控制,使得锁粒度更细,减少阻塞开销,从而提高并发性。
JDK1.7之前的ConcurrentHashMap就是分段锁的典型案例。
ConcurrentHashMap
维护了一个 Segment
数组,一般称为分段桶。
final Segment<K,V>[] segments;
当有线程访问 ConcurrentHashMap
的数据时,ConcurrentHashMap
会先根据 hashCode 计算出数据在哪个桶(即哪个 Segment),然后锁住这个 Segment
。
JDK1.8之后
,取消了 Segment 分段锁,采⽤ CAS 和 synchronized 来保证并发安全,数据结构跟 HashMap1.8 的结构类似。
轻量级锁/重量级锁/偏向锁
轻量级锁 vs 重量级锁
这里锁的量级指的是锁控制粒度的粗细,控制粒度越细,锁越轻量,并发时阻塞造成的开销就越小,并发度也就越高。
JDK 1.6之前 | 轻量级锁 | 重量级锁 |
---|---|---|
实现 | volatile | synchronized |
JDK1.6之后,针对synchronized做了大量的优化,引入了4种锁的状态:
- 无锁状态
- 偏向锁 —— 指一段同步代码
一直被一个线程所访问
,那么该线程会自动获取锁。降低获取锁的代价。 - 轻量级锁 —— 当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过
自旋
的形式尝试获取锁,不会阻塞
,提高性能。 - 重量级锁 —— 当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,
当自旋一定次数的时候,还没有获取到锁,就会进入阻塞
,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
锁可以单向地从偏向锁升级到轻量级锁,再从轻量级锁升级到重量级锁。
synchronized
锁的范围
- 在修饰静态方法时,锁的是类对象,如 Object.class。
- 修饰非静态方法时,锁的是对象,即 this。
使用
- synchronized
锁住的是对象而非代码
,只要访问的是同一个对象的synchronized 方法,即使是不同的代码,也会被同步顺序访问。 - 多个线程是可以同时执行同一个synchronized实例方法的,只要它们访问的对象是不同的。
- 在保护变量时,需要在所有访问该变量的方法上加上synchronized。
字节码实现
同步代码块
public class SynchronizedTest {
public void test2() {
synchronized(this) {
}
}
}
synchronized
关键字基于monitorenter指令和monitorexit指令实现了锁的获取和释放过程:
当执行monitorenter指令时,当前线程将试图获取objectref(即对象锁) 所对应的monitor 的持有权,当objectref 的 monitor 的进入计数器为0,那线程可以成功取得monitor,并将计数器值设置为1,取锁成功。如果当前线程已经拥有 objectref 的monitor 的持有权,那它可以重入这个monitor,重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放monitor(锁)并设置计数器值为0,其他线程将有机会持有monitor 。
同步方法
在 JVM 字节码层面并没有任何特别的指令来实现被synchronized 修饰的方法,而是在 Class 文件的方法表中将该方法的access_flags 字段中的synchronized 标志位置设置为1, 表示该方法是同步方法,并使用调用该方法的对象或该方法所属的Class。
锁优化
- 锁消除 —— Java虚拟机在JIT即时编译时, 通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁, 通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。
- 锁粗化 —— 将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
- 自旋锁 —— 线程的阻塞和唤醒,需要 CPU 从用户态转为核心态。频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。 同时,我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间。为了这一段很短的时间,频繁地阻塞和唤醒线程是非常不值得的。自旋锁的核心思想就是:当后面请求锁的线程没拿到锁的时候,不挂起线程,而是继续占用处理器的执行时间,让当前线程执行一个
忙循环
(自旋操作(默认10次)),也就是不断在盯着持有锁的线程是否已经释放锁。 - 自适应自旋锁 —— 所谓的“自适应”意味着对于同一个锁对象,线程的自旋时间是根据上一个持有该锁的线程的自旋时间以及状态来确定的。
- 锁升级 —— 锁可以单向地从偏向锁升级到轻量级锁,再从轻量级锁升级到重量级锁。
CAS
概念
CAS(compare and swap),即比较并交换。CAS是一种操作机制,不是某个具体的类或方法。在 Java 平台上对这种操作进行了包装。操作方法在Unsafe 类中:
@ForceInline
public final boolean compareAndSwapInt(Object o, long offset,
int expected,
int x) {
return theInternalUnsafe.compareAndSetInt(o, offset, expected, x);
}
它需要三个参数,分别是内存位置offset,旧的预期值x和新的值expected。操作时,先从内存位置读取到值,然后和预期值x比较。如果相等,则将此内存位置的值改为新值expected,返回 true。如果不相等,说明和其他线程冲突了,则不做任何改变,返回 false。
这种机制在不阻塞其他线程的情况下避免了并发冲突,比独占锁的性能高很多。 CAS 在 Java 的原子类和并发包中有大量使用。
重试机制
CAS 本身并未实现失败后的处理机制,它只负责返回成功或失败的布尔值,后续由调用者自行处理。
在CAS操作失败后,我们最常用的方法就是使用一个死循环进行 CAS 操作
,成功了就结束循环返回,失败了就重新从内存读取值和计算新值
,再调用 CAS。
底层实现
CAS主要分为三步:
读取
—— 从内存位置读到值。比较
—— 检测值是否与期待值一致,不一致,则CAS失败;一致,则可以去修改,但仍然无法保证修改后结果正确,因为在多线程环境下,可能其他的线程在此时也在修改。修改
—— 更新变量的值。
要保证CAS的正确性,则需要保证比较-修改
这两步操作的原子性
。
CAS 底层是靠调用 CPU 指令集的 cmpxchg 完成的,它是 x86 和 Intel 架构中的 compare and exchange 指令。在多核的情况下,这个指令也不能保证原子性,需要在前面加上lock 指令。lock 指令可以保证一个 CPU 核心在操作期间独占一片内存区域
。在处理器中,一般使用缓存锁
实现CAS比较和交换的原子性的。
值得注意的是, CAS 只是保证了操作的原子性,并不保证变量的可见性,因此变量需要加上 volatile 关键字。
volatile关键字
多线程情况下,读和写发生在不同的线程中,而读线程未能及时的读到写线程写入的最新的值。
作用
被volatile修饰的共享变量,就具有了以下两点特性:
- 保证了多线程环境下不同线程对该共享变量操作的内存可见性
- 禁止指令重排序
线程之间为何不可见?
计算机内存模型
从硬件角度来看
计算机内存模型如下图所示
其中,CPU 拥有最顶层的 4 层缓存结构(内置 3 层),高速缓存(Cache)来作为内存与处理器之间的缓冲,而主内存和硬盘是外部的存储介质。
由于CPU、内存和IO设备三者在处理速度上差异很大,最终整体的计算效率还是取决于最慢的那个设备。为了平衡三者之间的速度差异,最大化的利用CPU提升性能。无论是硬件方面,操作系统还是编译器方面都做了很多的优化:
- CPU增加了高速缓存(高速缓存作为内存和处理器之间的缓冲,通过将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存中同步到内存之中,这样就能平衡内存和处理器之间的处理速度差异)
- 操作系统灵活高效的调度策略最大化提升CPU性能
- 编译器进行指令优化,更加合理地去利用好CPU的高速缓存
缓存一致性的问题
CPU高速缓存的引入虽然很好的解决了处理器与内存之间的速度矛盾,但同时也引入了一个新的问题,缓存一致性。
有了高速缓存后,每个CPU处理过程变成这样:
- 将计算机需要用到的数据缓存在CPU高速缓存中
- 在CPU进行计算时,直接从高速缓存中读取数据并且计算完成之后写到缓存中
- 在整个运算完成后,再把缓存中的数据同步到内存
在多CPU中
,每个线程可能会运行在不同的CPU中,并且每个线程都有自己的高速缓存,同一份数据可能会被缓存到多个CPU中
,如果在不同CPU中运行的不同线程看到同一份内存的缓存值不一样,就会存在缓存不一致的问题。
怎么解决缓存一致性问题?
使用总线锁和缓存锁。
总线锁
缓存锁
总线锁开销较大,所以需要优化,最好的方法就是控制锁的粒度,我们只需要保证,对于被多个CPU缓存的同一份数据是一致的就行,所以引入了缓存锁,它的核心机制就是缓存一致性协议
。
缓存一致性协议
为了达成数据访问的一致性,需要各个处理器在访问内存时,遵循一些协议,在读写时根据协议来操作,常见的协议有,MSI,MESI,MOSI等等,最常见的就是MESI协议;
具体的缓存一致性怎么实现的,请参考一文吃透Volatile,征服面试官
使用总线锁和缓存锁后,CPU对于内存的操作大概可以抽象成下面这样的结构,从而达成缓存一致性效果。
如上图模型所示,计算机将需要的数据从主内存拷贝至高速缓存,之后将运算后的结果回写到主内存。这个执行计算的过程中,各个非公有变量的变化,各线程间都是不可见的
,这就会导致线程不安全。
JMM
JMM(Java内存模型)是一个抽象的概念,并不是真实的存在,它涵盖了缓冲区,寄存器以及其他硬件和编译器优化。在JMM中,
-
所有的
实例变量和类变量都存储于主内存
。(其不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。) -
线程对变量的所有的
读写操作都在工作内存中完成
,而不能直接读写主内存中的变量。
实际上JMM存在和计算机内存模型一样的问题,即实际上线程可见性还是没有得到解决。
如何解决可见性的问题
两种方式:
- 加悲观锁synchronized —— 保证了线程间变量的原子性和可见性,但是并发性差
- 用volatile修饰变量使得共享变量在线程之间可见 —— 不能保证原子性,即是线程不安全的
volatile的内存语义
即对用volatile关键字修饰的变量进行计算操作时,内存中要实现的功能或遵循的规则:
- 写一个volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存
- 读一个volatile 变量时,JMM 会把该线程对应的本地内存置为无效,并从主内存中读取共享变量
指令重排
为了优化性能,编译器会根据一些特定的规则,在as-if-serial(不管怎么重排序,单线程下的执行结果不能被改变)规则的前提下,进行重新排序。
要实现volatile的内存语义,要第一时间写 volatile 变量时,将更新的变量更新到主存;在线程读 volatile 变量时,要第一时间读到主存的变量。因此必须要禁止指令重排。
为什么volatile不能保证原子性
以++操作为例,当有了变量++操作时,会有以下三个步骤在一个线程中产生:
- 从主存中取值到工作内存
- 计算工作内存的值
- 将计算得到的新值更新到主内存
对应的CPU指令如下:
mov 0xc(%r10),%r8d ; Load
inc %r8d ; Increment
mov %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier
如果现在有两个线程执行同一个 volatile 变量的 ++ 操作,可能出现的情况:
- 线程1读取变量到工作内存后,由于没有锁,所以线程2在线程1拷贝后,抢到了CPU,立刻也将主存的变量拷贝至工作内存。
- 之后,线程1与线程2先后在工作内存进行了自增。
- 最后,线程1、2分别将自增后的值刷回主存。线程2回写时,会覆盖掉线程1回写的值,导致线程不安全,所以
volatile 计算不能保证原子性。
ABA问题
CAS 保证了比较和交换的原子性。但是从读取到开始比较这段期间,其他核心仍然是可以修改这个值的。如果核心将 A 修改为 B,CAS 可以判断出来。但是如果核心将 A 修改为 B 再修改回 A。那么 CAS 会认为这个值并没有被改变,从而继续操作。这是和实际情况不符的。解决方案是加一个版本号version
,相当于增加一个时间戳,来区分不同时间线上的同值变量。
volatile应用
单例模式的双重检查
public class Singleton {
private static volatile Singleton singleton =null;
private void Singleton(){}
public static Singleton getSingleton(){
if(singleton==null){
synchronized (Singleton.class){
if(singleton==null){
singleton =new Singleton();
}
}
}
return singleton;
}
}
ReentrantLock
概述
ReentrantLock 使用代码实现了和synchronized 一样的语义,包括可重入,保证内存可见性和解决竞态条件问题等。相比 synchronized,它还有如下好处:
- 支持以非阻塞方式获取锁
- 可以响应中断
- 可以限时
- 支持了公平锁和非公平锁
特性
-
ReentrantLock
提供了与synchronized
相同的互斥性、内存可见性和可重入性。 -
ReentrantLock
支持公平锁和非公平锁(默认)两种模式。 -
ReentrantLock
实现了Lock
接口,支持了synchronized
所不具备的灵活性。public interface Lock { void lock(); //无条件获取锁 void lockInterruptibly() throws InterruptedException; boolean tryLock(); //尝试获取锁 boolean tryLock(long time, TimeUnit unit) throws InterruptedException; void unlock(); //释放锁 Condition newCondition(); //返回一个绑定到Lock对象上的Condition实例 }
synchronized
无法中断一个正在等待获取锁的线程synchronized
无法在请求获取一个锁时无休止地等待
lock() and unLock()
lock()
—— 无条件获取锁。如果当前线程无法获取锁,则当前线程进入休眠状态不可用,直至当前线程获取到锁。如果该锁没有被另一个线程持有,则获取该锁并立即返回,将锁的持有计数设置为 1。unlock()
—— 用于释放锁。
使用注意
:获取锁操作 lock()
必须在 try catch
块中进行,并且将释放锁操作 unlock()
放在 finally
块中进行,以保证锁一定被被释放,防止死锁的发生。
tryLock()
与无条件获取锁相比,tryLock 有更完善的容错机制。
tryLock()
—— 可轮询获取锁。如果成功,则返回 true;如果失败,则返回 false。也就是说,这个方法无论成败都会立即返回,获取不到锁(锁已被其他线程获取)时不会一直等待。tryLock(long, TimeUnit)
—— 可定时获取锁。和tryLock()
类似,区别仅在于这个方法在获取不到锁时会等待一定的时间,在时间期限之内如果还获取不到锁,就返回 false。如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回 true。
案例 —— 单Lock对象采用多Condition实现精确唤醒
package com.youzikeji.juc;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
//单Lock对象采用多Condition精确通知唤醒
public class PCDemo03 {
public static void main(String[] args) {
Data3 data3 = new Data3();
new Thread(()-> { for (int i = 0; i < 10; i++) data3.printA();}, "A").start();
new Thread(()-> { for (int i = 0; i < 10; i++) data3.printB();}, "B").start();
new Thread(()-> { for (int i = 0; i < 10; i++) data3.printC();}, "C").start();
}
}
class Data3{
//资源,先给到printA
private int number = 1;
//Lock
Lock lock = new ReentrantLock();
Condition condition1 = lock.newCondition();
Condition condition2 = lock.newCondition();
Condition condition3 = lock.newCondition();
public void printA(){
//加锁
lock.lock();
try {
//业务
while (number != 1){
condition1.await();
}
//执行
System.out.println("AAAAAA");
//通知唤醒B
number = 2;
condition2.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
//解锁
lock.unlock();
}
}
public void printB(){
lock.lock();
try {
while (number != 2){
condition2.await();
}
System.out.println("BBBBBB");
//唤醒C
number = 3;
condition3.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public void printC(){
lock.lock();
try {
while (number != 3){
condition3.await();
}
System.out.println("CCCCCC");
//唤醒A
number = 1;
condition1.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}