并发进阶
- 前言
- 常见的锁策略
-
- [乐观锁 VS 悲观锁](#乐观锁 VS 悲观锁)
- [重量级锁 VS 轻量级锁](#重量级锁 VS 轻量级锁)
- [自旋锁 VS 挂起等待锁](#自旋锁 VS 挂起等待锁)
- 读写锁
- [可重入锁 VS 不可重入锁](#可重入锁 VS 不可重入锁)
- [公平锁 VS 非公平锁](#公平锁 VS 非公平锁)
- [synchronized 锁的策略](#synchronized 锁的策略)
- CAS
- [synchronized 原理](#synchronized 原理)
- 全文总结
- [🔗 系列文章导航](#🔗 系列文章导航)
前言
本篇承接上一篇《四大并发实战:单例、阻塞队列、定时器与线程池》,深挖 Java 并发底层锁机制与无锁 CAS 编程,覆盖面试必考的各类锁区分、CAS 原子操作、synchronized 三大优化(锁升级 / 消除 / 粗化),全部配套伪代码 + 可运行实战案例,吃透底层原理类面试核心考点。
常见的锁策略
乐观锁 VS 悲观锁
这是"锁的一种特性"。
此处的悲观和乐观,是对后续锁冲突是否激烈给出的预测。
- 乐观锁:预测接下来锁冲突的概率不大,就可以少做一些工作。
- 悲观锁:预测接下来锁冲突的概率很大,就应该多做一些工作。
重量级锁 VS 轻量级锁
- 重量级锁:锁的开销比较大。
- 轻量级锁:锁的开销比较小。
乐观锁,通常是轻量级的锁;悲观锁,通常是重量级的锁。
自旋锁 VS 挂起等待锁
- 自旋锁:一种轻量级锁的典型实现。
(1)往往在纯用户态实现。
(2)比如一个while循环,不停检查当前锁是否被释放,若没有,就继续循环;释放了就获取到锁,从而结束循环。忙等,消耗CPU,换来更快的响应速度。- 挂起等待锁:一种重量级锁的典型实现。
(1)要借助系统API实现。
(2)一旦出现锁竞争,就会在内核中触发一系列的动作,比如让这个线程进入阻塞状态,暂时不参与CPU调度。阻塞的开销很大。
读写锁
读写锁把加锁分成两种:读加锁、写加锁
- 读加锁:读的时候,可以读,但是不可以写。
- 写加锁:写的时候,不可以读,也不可以写。
- 两个线程加锁过程中:
(1)读锁和读锁之间,不会产生竞争;
(2)读锁和写锁之间,有竞争;
(3)写锁和写锁之间,有竞争。
可重入锁 VS 不可重入锁
- 可重入锁:一个线程针对同一把锁,连续加锁两次,不会死锁。
- 不可重入锁:一个线程针对同一把锁,连续加锁两次,会死锁。
公平锁 VS 非公平锁
当很多线程尝试加同一把锁时,一个线程能够拿到锁,其他线程阻塞等待,一旦第一个线程释放锁之后,接下来哪个线程能够拿到锁?
- 公平锁:按照先来后到的顺序。
- 非公平锁:剩下的线程以均等的概率来重新竞争锁。
操作系统提供的加锁API默认是非公平锁。
synchronized 锁的策略
- 乐观锁 VS 悲观锁:自适应;
- 轻量级锁 VS 重量级锁:自适应;
- 自旋锁 VS 挂起等待锁:自适应。
- 自适应:
(1)初始情况下,synchronized会预测当前的锁冲突的概率不大,此时以乐观锁模式运行(轻量级锁,基于自旋锁的方式实现)
(2)在实际使用过程中,如果发现锁冲突的情况比较多,synchronized就会升级成悲观锁(重量级锁,基于挂起等待的方式实现)- 不是读写锁,是可重入锁,是非公平锁
CAS
认识CAS
- CAS(Compare and swap):比较交换的是内存和寄存器。
- 比如有一个内存M,两个寄存器A,B
- CAS(M,A,B):如果M和A的值相同,就把M和B里的值进行交换,同时整个操作返回true;否则,无事发生,同时整个操作返回false;交换的本质是为了把B赋值给M。
- CAS其实是一个CPU指令。单个CPU指令是原子的,就可以使用CAS完成一些操作,进一步替代加锁。
- 基于CAS实现线程安全的方式为"无锁编程"。
应用CAS
实现原子类
java
public class Demo {
public static AtomicInteger count=new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
// TODO 自动生成的方法存根
Thread t1=new Thread(()->{
for(int i=0;i<50000;i++) {
count.getAndIncrement();//count++
}
});
Thread t2=new Thread(()->{
for(int i=0;i<50000;i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count.get());
}
}
原子类里面是基于CAS实现的。
伪代码实现:
java
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) { //这里的判定就是在判断是否有别的线程穿插过来
oldValue = value;
}
return oldValue;
}
}
加锁是通过阻塞的方式避免穿插执行,CAS是通过重试的方式避免执行
实现自旋锁
伪代码:
java
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就⾃旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner,null,Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
CAS的ABA问题
CAS进行操作的关键:通过值"没有发生变化"来作为"没有其他线程穿插执行"的判定依据,但这种判定方式不严谨,极端情况下,可能会有另一个线程穿插进来,发生将值从A->B->A,针对第一个线程,虽然值没变,但是实际上已经被穿插执行。
解决方法:
- 让判定的数值,按照一个方向增长,不要反复横跳(有增有减,就会发生ABA)。
- 引入一个额外的变量(版本号),约定每次修改,版本号就自增一次,此时在使用CAS判定时就不是判定值了,而是判定版本号,看版本号是否变化了,若版本号没变,就代表没有线程穿插执行。
synchronized 原理
锁升级
synchronized的状态变化:无锁 -> 偏向锁 -> 自旋锁(轻量级锁) -> 重量级锁。
- 锁升级的过程是单向的,不能再降级了。
- 偏向锁:不是真正加锁,只是做了一个标记,完全是运行时的优化策略。当锁冲突出现时,偏向锁就升级成轻量锁,就真正加锁了。
- 锁升级的过程就是在性能和线程安全之间尽量进行权衡。
锁消除
- 编译器会自动针对当前写的加锁的代码,做出判定,如果编译器觉得这个场景,不需要加锁,此时就会把写的synchronized优化掉。
- 例如:StringBuilder 不带synchronized;StringBuffer 带有synchronized;
写了synchronized也不一定线程安全;
若在单个线程中使用StringBuffer,编译器就会把synchronized优化掉;- 编译器只会在自己非常有把握时,才进行锁消除
- 锁消除:编译期锁消除;运行时锁消除。
- 保守保留锁:编译器针对synchronized锁的处理策略。
- 核心:编译期无法预判运行时的线程竞争情况,为了保证程序正确性,不会擅自删除或修改代码逻辑,只会把锁的语义完整保留到字节码中
锁粗化
锁的粒度:synchronized中,代码越少,就认为锁的粒度越粗;代码越少,锁的粒度越细。
java
for(...) {
sync(lock){
n++ ;
} //锁粒度细
}
sync(lock){
for(...) {
n++ ;
} //锁粒度粗
}
锁的粒度细的时候,能够并发执行的逻辑更多,更有利于充分利用CPU资源。
若粒度细的锁,被反复进行加锁解锁,可能实际效果还不如粒度粗的锁(涉及到频繁的锁竞争)。
全文总结
本文完整梳理并发底层核心面试考点:
- 六类锁策略区分,掌握不同锁适用业务场景;
- CAS 无锁编程底层 CPU 指令原理、原子类实战、手写自旋锁,以及 ABA 问题解决方案;
- synchronized 底层三大核心优化:单向锁升级、编译期锁消除、锁粗化,理解 JVM 锁性能优化逻辑。
后续会更新 JUC 工具类、线程安全集合全套实战内容,欢迎点赞收藏,评论区交流面试学习心得!
🔗 系列文章导航
本篇是「Java并发编程系列」的连载内容,点击链接查看完整系列: