1.锁策略
1.1 乐观锁 vs 悲观锁
乐观锁:世界大概率是和平的,多个线程竞争一把锁的概率会很低
悲观锁:世界大概率是出问题的,多个线程竞争一把锁的概率会很高,恢复出更多的成本来进行锁冲突的处理
两种想法没有优劣之分,要根据具体场景来进行使用
1.2 读写锁
把加锁操作分成了两种:读锁、写锁
读锁和读锁之间是没有互斥的(不存在锁竞争),读锁和写锁、写锁和写锁之间才进行互斥
如果某个场景下“一写多读”,使用读写锁效率就很高
只进行读数据,若果多个线程同时读取一个数据,不会造成线程不安全的情况,只有修改同一个数据才会线程不安全
1.3 重量级锁 vs 轻量级锁
加锁需要保证原子性,原子性功能来源自硬件(硬件提供了相关的原子操作的指令,操作系统封装一下成为原子操作的接口,应用程序才能使用这样的操作)
在加锁过程中,如果整个加锁逻辑都是依赖于操作系统内核的,此时就是重量级锁(代码在内核中的执行开销会比较大),对应的,如果大部分操作都是用户自己完成,少数操作由内核完成,这种就是轻量级锁
1.4 挂起等待锁 vs 自旋锁
挂起等待锁表示获取锁失败之后,对应的线程就要在内核中挂起等待(放弃 CPU ,进入等待队列),需要在锁被释放之后由操作系统唤醒【通常都是重量级锁】
自旋锁表示在获取锁失败之后,不会立刻放弃 CPU ,而是快速频繁的再次询问锁的持有状态,一旦锁被释放了,就能立刻获取到锁【通常都是轻量级锁】
自旋锁的效率更高,但是会浪费一些 CPU 资源(自选过程相当于 CPU 在空转),线程能够获取CPU 是一件来之不易的事情,一旦线程挂起,下次什么时候能被调度上,就不可预期了,(时间可能很久,能够达到 ms 级,也有可能是 s 级)
Windows、Linux 这样的系统,调度是没那么快的,调度时间不可预期
1.5 公平锁 vs 非公平锁
如果多个线程都在等待一把锁的释放,当锁释放之后,恰好又来了一个线程也要获取锁
公平锁:能保证之前先来的线程优先获取锁
非公平锁:新来的线程直接获取到锁,之前的线程还在等待
1.6 可重入锁
一个线程针对一把锁,连续加锁两次,不会死锁,这就是可重入锁
若果锁记录自己是被谁持有的,就可以进行特殊判定了,当锁的持有至正好就是新的锁的申请者,此时就特殊处理下让加锁操作成功即可。
死锁的经典场景:
- 一个线程,一把锁,连续加锁两次
- 两个线程,两把锁,相互获取对方的锁
- N个线程,N把锁,哲学家就餐问题
可重入锁就是为了解决第一种场景
2. CAS compare and swap
硬件设备提供的一种基础指令,基于这样的基础指令,就可以实现一些特殊的功能(实现锁)
2.1应用场景
无锁编程
不使用锁,而是直接用 CAS 来保证线程安全
例如,针对某个变量进行 ++ 操作
2.2 CAS缺陷
2.2.1ABA问题
该线程看到 size 是 0
- 该 size 没有变过
- size 变了,以后又被其他线程改成 0 了
要想解决 ABA 就需要引入额外的信息,给变量加一个版本号,每次进行修改,就递增版本号
3.锁优化
以 sychronized 为例
优化操作是 编译器+JVM 两者配合进行的
3.1 锁消除
编译器+JVM 会根据代码运行的情况智能判定当前的锁是否必要,如果不必要,就直接把锁的代码干掉
想上面的代码,每次执行都会涉及加锁和解锁操作,事实上当前的额 StringBuffer 只是在一个线程中使用,不涉及线程安全问题
3.2 偏向锁
第一个尝试加锁的线程,不会真的加锁,而是进入偏向锁状态。(很轻量的标记),直到其他线程也来竞争这把锁,才会取消偏向锁状态,真正进行加锁
3.3 自旋锁
真的有多个线程经整改锁的时候,偏向锁状态被消除,此时线程使用自旋锁的方式来尝试进行获取锁。
3.4 膨胀锁(无奈之举)
严格上锁不能算优化,当锁竞争更加激烈,此时就会从自旋锁状态膨胀成重量级锁(挂起等待锁)
3.5 锁粗化
如果一段逻辑中,需要多次解锁解锁,并且解锁时没有其他线程来竞争,此时就会把锁粗化
优化前:
append append append append append
加锁 解锁 加锁 解锁 加锁 解锁 加锁 解锁 加锁 解锁
优化后:
append append append append append
加锁 解锁
粗化就是把多组加锁解锁合并成一组,每次加锁解锁操作,都有开销,减少加锁的次数,就能提高效率了,就比如我们要搬家,没搬一个东西出门就开一次门、锁一次门,N个行李就重复 N 次开门锁门操作,为了而提高效率可以把门一直开着,搬完了行李再锁门