常见的锁策略以及Synchronized的实现
1.1 常见的锁策略
请注意,下面所叙述的关于锁策略的话题不仅仅针对于Java语言,事实上锁策略是针对整个并发编程系统设计的。不同的操作系统、编程语言层面会采用不同的锁策略进行实现与封装。
1.1.1 乐观锁与悲观锁
乐观锁:预估当前发生锁冲突的概率会比较小,因此对于加锁过程没有做过多的操作
悲观锁:预估当前发生锁冲突的概率会比较大,因此对于加锁过程中会做很多的操作
举个例子:同学A与同学B中午下课后都想去食堂吃饭
同学A认为:现在才刚下课,如果我走快点就可以赶在大家前面吃上饭,这样别人就抢不过我了,因此同学A会直接赶往食堂(没有加锁,直接访问资源),如果这会食堂人确实不多,那么可以直接吃上饭,但是如果这时食堂人很多,那么A同学就会先回寝室休息一下再来食堂吃饭(虽然没有加上锁,但是可以识别出数据冲突),这个是乐观锁
同学B认为:现在正是下课的时候,等我过去的时候食堂可能人已经非常多了,我大概率得排长时间的队,因此同学B会先打电话咨询食堂阿姨:“现在食堂人多不多呀?”如果发现人不多同学B就可以前往食堂,反之同学B就会先等待一会,之后在确定时间,这就是悲观锁。
注意:这里两种方式优劣不能一概而论
1、如果现在食堂确实人很多,那么悲观锁的策略更合适,如果采用乐观锁会导致“多跑很多趟”,浪费资源
2、如果现在食堂人不多,那么乐观锁的策略比较合适,而悲观锁会降低效率。
1.1.2 重量级锁和轻量级锁
首先我们需要知道锁的“原子性”特征的由来:
- 底层硬件CPU提供原子操作指令
- 操作系统依据原子指令,实现互斥锁
mutex
- JVM在操作系统提供的
mutex
的基础上,实现了synchronized
和ReentranLock
等关键字和类
重量级锁:加锁机制重度依赖OS提供的mutex
- 大量用户态与内核态的切换
- 容易引发线程调度
上述两种行为的成本都比较高,一旦涉及到内核态与用户态的切换,就意味着“沧海桑田”
轻量级锁:加锁机制尽可能不使用mutex
锁,而是尽量在用户态中完成,实在不行借助mutex
- 少量用户态与内核态的切换
- 不容易发生线程调度问题
这里简述内核态 VS 用户态
想象一下银行办理业务的场景:
如果全部交由用户自己来做,就是用户态,时间成本可控
但是如果交由工作人员代办,就是内核态,时间成本不可控
1.1.3 自旋锁和挂起等待锁
自旋锁(Spin Lock):按照之前的方式,线程在竞争锁失败的情况下就会进入阻塞状态,放弃被CPU调度,过了一段时间才能被CPU调度,但是有时候虽然竞争锁失败,但是锁很快就会被释放,就没必要放弃CPU,这个时候就可以使用自旋锁来处理这样的场景。
自旋锁伪代码
while (竞争锁(lock) == 失败) {}
上述伪代码中我们可以看出自旋锁的实现思路:如果线程获取锁失败,立即再次尝试获取锁,直到获取成功为止,一旦锁被其他线程释放就很容易获取锁。
理解自旋锁 VS 挂起等待锁
假设下课五分钟时间,你此时准备上厕所,但是坑位被占了,你发现此时那个人正在冲水,你预估他马上就要出来了,就一直站在坑位前面等待,此时只要里面的人出来,你就立马可以抢占坑位,这就是自旋锁。
如果是挂起等待锁,那么你需要离开一段时间,等到上课铃响后再进去,这时可能坑位已经被很多个人占过了
自旋锁是一种很经典的轻量级锁实现方式:
- 优点:没有放弃CPU,不涉及线程阻塞以及调度,一旦锁被释放很容易第一时间获取到锁
- 缺点:如果锁对象被其他线程持有时间过长,那么就会持续的消耗CPU资源
1.1.4 公平锁和非公平锁
考虑这样一个场景:线程A、B、C尝试获取同一锁对象,A成功获取到锁对象,此时线程B来了,进入阻塞等待状态,又过了一会C线程来了,进入阻塞等待状态,如果此时锁被释放之后,B线程和C线程谁会获取到锁对象呢?
公平锁:遵守“先来后到的原则”,B比C先来,因此B线程先与C线程获取到锁
非公平锁:不遵守“先来后到的原则”,B和C都有可能获取到锁
注意:
- 操作系统内部的线程调度策略可以认为是随机的,如果不做任何额外的限制,实现的就是“非公平锁”,如果想要实现“公平锁”,就需要借助额外的数据结构,来记录线程的先后顺序
- 公平锁和非公平锁也没有优劣之分,适用于不同的场景
1.1.5 可重入锁和不可重入锁
可重入锁:允许同一个线程对象多次获取同一把锁
不可重入锁:不允许同一个线程对象多次获取同一把锁
理解如下代码:
// 第一次加锁, 加锁成功
synchronized(locker) {
// 第二次加锁, 加锁成功
synchronized(locker) {
//TODO...
}
}
其中Java的synchronized
就是可重入锁,内部会依靠一套计数器机制,判断如果当前锁对象被当前线程持有,那么再次加锁不会发生死锁现象。除此以外,Java中的以Reentran
开头的锁均为可重入锁。
如果是不可重入锁执行以上的代码,此时线程获取锁对象之后尝试再次获取该锁对象,由于此时锁对象已经被其他线程占有(就是线程本身),所以线程会进入阻塞等待,此时就发生了死锁!
1.1.6 普通互斥锁和读写锁
在多线程的场景下,数据的读取方之间不会发生线程安全问题,但是数据的写入方之间以及与读取方之间都需要进行互斥,但是各种场景都采用一种锁就会产生不必要的性能消耗,读写锁应运而生。
读写锁:见名知意,读写锁即需要在加锁的时候表明读写意图,读者之间并不互斥,但是写者和写着者以及读者和写者之间需要进行互斥
- 两个线程都只是读取一个数据,没有线程安全问题,不需要进行互斥
- 两个线程写同一个数据,可能引发线程安全问题,需要进行互斥
- 一个线程读数据,一个线程写数据,可能存在线程安全问题,需要进行互斥
1.2 synchronized原理
结合上面的锁策略,我们可以总结出,synchronized具有以下特性(JDK1.8)
- 一开始是乐观锁,若锁冲突频繁,会切换为悲观锁
- 一开始是轻量级锁,如果需要频繁使用到mutex,就切换为重量级锁
- 一开始是自旋锁,如果锁被线程占用时间长就切换为挂起等待锁
- 是一种非公平锁
- 是一种非读写锁
- 是一种可重入锁
1.2.1 加锁升级工作过程
JVM将synchronized锁分为无锁、偏向锁、轻量级锁、重量级锁状态,会根据不同情况依次升级。
-
阶段一:偏向锁
第一个尝试加锁的线程就会优先进入偏向锁阶段,偏向锁不是真正的加锁,而是在锁对象头中做一个“偏向锁标记”,记录这个锁属于哪个线程,如果后续没有其他线程来竞争该锁,那么就省去了加锁解锁过程的开销,如果后续有线程来竞争该锁,因此就依据锁对象头中的“偏向锁标记”,优先让该线程持有锁,此时锁升级为轻量级锁阶段。
偏向锁本质是一种“懒汉模式”,遵循能不加锁就不加锁,能晚加锁就晚加锁的原则
-
阶段二:轻量级锁
随着其他线程进入竞争,偏向锁状态被解除,进入轻量级锁阶段(自旋锁实现)此处的自旋锁依据CAS实现
- 通过CAS检查并更新一块内存(null => 该线程引用)
- 如果更新成功,认为加锁成功
- 如果更新失败,继续自旋式等待(不放弃CPU)
-
阶段三:重量级锁
如果线程竞争进一步激烈,自旋方式无法立即获取到锁,就会升级为重量级锁。此处的重量级锁就是依赖操作系统内核提供的
mutex
锁
1.2.2 锁消除策略
JVM帮我们在synchronized内部进行了优化,可以在一定情况下帮我们消除不必要的锁
来分析如下代码:
StringBuffer sBuffer = new StringBuffer();
sBuffer.append("a");
sBuffer.append("b");
sBuffer.append("c");
sBuffer.append("d");
此时由于StringBuffer
对象是线程安全的,但是在单线程运行环境下,这些加锁解锁操作完全没有必要,因此编译器就会帮我们省去这些锁
注意:synchronized消除锁的策略是比较保守的,明显不会发生线程安全问题的代码才会消除锁,例如:
- 变量只涉及局部变量,没有全局变量
- 多个线程只对变量做读取操作,不涉及修改操作
1.2.3 锁粗化策略
如果在同一段代码逻辑中,多次频繁的加锁解锁操作,编译器和JVM会帮助我们将其合并为一次加锁解锁操作
如上图所示,当一段逻辑代码中涉及多个细粒度的锁,JVM和编译器就会将其优化为一个粗粒度的锁,在实际开发的过程中,使用细粒度的锁是希望一个线程使用完锁后释放锁,另外的线程可以使用以此加快效率,但是当没有别的线程使用时,就会优化成粗粒度的锁,省去了频繁加锁解锁的额外开销。