目录
(一)锁策略
(1)悲观锁vs乐观锁
(2)重量级锁vs轻量级锁
(3)挂起等待锁vs自旋锁
(4)读写锁
(5)可重入锁与不可重入锁
(6)公平锁vs非公平锁
(二)CAS
(1)CAS实现无锁编程
(2)AtomicInteger
2.1典型的线程安全问题
2.2AtomicInteger用CAS解决
(3)ABA问题
(三)synchronized原理
(1)锁膨胀(锁升级)
1.1java对象构成
1.2Monitor
1.3早期sychronized问题
1.4sychronized的改进
(2)锁消除
(3)锁粗化
(四)总结
(一)锁策略
这里说的锁策略不是说的某一种锁,而是锁的一些特性
(1)悲观锁vs乐观锁
悲观与乐观,顾名思义就是这个锁对可能的锁冲突是持有悲观态度,还是乐观态度
悲观锁:悲观锁会假定要操作的数据会频繁发生锁冲突,所以会在每次操作数据的时候都会进行加锁,知道数据操作完成后才会释放锁。
实现机制:互斥锁,同步块等
乐观锁:乐观锁会假定要操作的数据不会频繁发生锁冲突,并且不会真的加锁,而是在修改数据前检查是否有别的线程修改过数据,如果修改过就重试报错等。
实现机制:版本号机制,CAS等
(2)重量级锁vs轻量级锁
轻与重, 可以看出是与锁的开销大小有关联的,重量级锁开销较大,轻量级锁开销较小。
重量级锁:重量级锁一般会涉及到系统api,一般通过互斥量(Mutex)实现,当一个线程尝试获取另一个线程持有的重量级锁,该线程会被阻塞,直到获取到锁为止。
轻量级锁:轻量级锁一般是在用户态操作的,减少了线程阻塞的开销,当一个线程尝试获取另一个线程持有的轻量级锁,该线程不会被阻塞,而是尝试通过CAS操作来实现获取到锁,如果成功则线程获取到锁继续执行,如果失败则尝试重新获取锁。
(3)挂起等待锁vs自旋锁
挂起等待和自旋,是一个线程尝试获取锁之前会选择什么策略
挂起等待锁:当一个线程尝试获取一个被其他线程持有的锁时,该线程会被阻塞,放入等待队列中,直到持有锁的线程释放锁
自旋锁:当一个线程尝试获取一个被其他线程持有的锁时,该线程不会阻塞,而是在一个循环中不断检查锁是否被释放(忙等)
小结:到这我们可以察觉到,其实上述的几种锁都有或多或少的共性 ,比如
乐观锁和自旋锁通常情况下就是轻量级锁,因为他们一般都不涉及系统api的调用。
挂起等待锁通常可以认为是重量级锁,因为挂起等待会调用系统api,开销较大。
悲观锁如果调用了系统api,一般可以认为是重量级锁。但如果是基于程序语言的一些原子操作(如java中的原子类)来实现的悲观锁,没有导致线程的挂起等待和大量系统资源消耗,就不属于重量级锁
(4)读写锁
在java中,读写锁通过ReentrantReadWriteLock类来实现,读写锁通常用于解决读多写少的场景。当多个线程同时读取共享数据时,使用读写锁可以允许多个线程同时获取读锁,但当某一线程需要更新数据,需要获取写锁时,此时其他所有读线程和写线程都会被阻塞,保证数据一致性
(5)可重入锁与不可重入锁
重入与不可重入的区别,关键在于一个线程能不能加锁多次
如果一个锁是不可重入锁,那么当同一个线程两次获取同一个锁时就会发生死锁(因为要第一个锁释放后才能获取到第二个锁)
如果一个锁时可重入锁,当一个线程第二次获取锁,但发现这个锁的拥有者恰好是自己,那么就可以加一次锁,将计数器+1,释放一次锁将计数器-1,直到计数器为0才真正释放锁
如下图
不可重入锁
可重入锁
(6)公平锁vs非公平锁
公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列排队,永远是队列中的第一位得到锁
优点:避免线程饥饿
缺点:cpu需要唤醒阻塞线程,资源开销大,吞吐量下降
非公平锁:多个线程去获取锁,都会直接尝试获取锁,获取不到,再去等待队列等候,若能获取到就直接获取到锁
优点:减少cpu线程开销,提升吞吐率
缺点:可能会导致线程饿死
(二)CAS
(1)CAS实现无锁编程
CAS全称是compare and swap(比较并交换),那么这里比较和交换的是什么呢,其实比较的是内存和寄存器,下面我来用一个情景来解释CAS的工作过程
现在我们有一个内存M,一个寄存器A和一个寄存器B,写做CAS(M,A,B),
如果寄存器A中的值和内存M中的值相等,则将寄存器B中的值赋值给M,并返回true
如果寄存器A中的值和内存M中的值不相等,则什么都不做,返回false
寄存器B中有什么,我们不关心,我们最需要关注的是内存M和寄存器A中的值是否相同
如下是CAS操作伪代码,如果address(内存M)与expectValue(寄存器A)中的值相同,则将swapValue(寄存器B)的值赋值到address(内存M)中,并返回true,否则返回false
boolean CAS(address, expectValue, swapValue) {//CAS操作伪代码if (&address == expectedValue) {&address = swapValue;return true;}return false;}
那么CAS怎么实现无锁编程呢?
CAS的本质是一个CPU指令,而一个CPU指令是原子的,而我们加锁的目的,也是为了保证代码操作的原子性,那么我们就可以通过CAS来达到线程安全的目的,同时能够避免线程阻塞
(2)AtomicInteger
在 java.util.concurrent.atomic包下,有许多原子类,AtomicInteger是其中之一,这些类的原子操作都是通过CAS来实现的,接下来我们以AtomicInteger为例,介绍CAS是怎么保证操作原子性的
2.1典型的线程安全问题
我们知道两个线程对同一个变量进行++操作会导致线程安全问题
如下代码中,两个线程同时对count进行++操作,期望结果是20000,但每次的结果总会比20000少,如下图
具体原因我已经在另一篇文章中详细讲过(第三段线程安全,案例解析中)——>进程,线程,线程池
public class Demo3 {static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {for (int i = 0; i < 10000; i++) {count++;}});Thread t2 = new Thread(() -> {for (int i = 0; i < 10000; i++) {count++;}});t.start();t2.start();t.join();t2.join();System.out.println(count);//期望结果是20000}
}
每次结果都不同且比期望结果小
2.2AtomicInteger用CAS解决
如下代码,我们只是将普通int类型,替换成为了原子类,那么结果就会变成我们期望的20000值,如下图
public class Demo3 {private static AtomicInteger count = new AtomicInteger(0);//替换为了原子类public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {for (int i = 0; i < 10000; i++) {count.getAndIncrement();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 10000; i++) {count.getAndIncrement();}});t.start();t2.start();t.join();t2.join();System.out.println(count);//期望结果为20000}
}
每次结果都为期望值
我们将getAndIncrement方法点开,最终发现他的底层是用compareAndSwapInt的本地方法实现的,也就是说这个方法调用了系统的CAS指令
具体工作流程我们用一段伪代码来模拟一下
lass AtomicInteger {private int value;public int getAndIncrement() {int oldValue = value;while ( CAS(value, oldValue, oldValue+1) != true) {oldValue = value;}return oldValue;}}
先明确一下对应关系,这样不容易迷
value(内存M),oldvalue(寄存器A),oldvalue(寄存器B)
现在我们有两个线程进行的getAndIncrement操作,也就是后置++操作,当t1线程刚完成赋值操作后,t2线程进行了穿插操作,等在轮到t1线程操作时,t1线程通过CAS发现oldvalue和value值并不相等,说明++操作已经被别的线程穿插了,于是进入循环,更新oldvalue,再进入循环执行++操作,返回oldvalue,下图是详细过程
(3)ABA问题
通过上面的例子我们已经了解到,CAS是通过“值有没有发生变化”,来作为“线程有没有被穿插”的依据,但是在极端的情况下,如果其他线程将值从A->B->A,那么第一个线程就不会发现值已经被穿插执行了。
本身如果值被其他线程篡改过,也不会发生什么bug,毕竟值还是相同的,进行的操作逻辑还没有变,但是在一些极端情况下还是会发生bug,比如下面这个场景
我去银行取钱,银行卡中有1000元,我想取500元,但ATM机反应比较慢,我就连续点了两下取钱按钮,恰好在这两次取钱操作的空隙,在第一次取钱操作刚完成后,又有人向这个银行卡中打了500元,这时第二次取钱操作可能发现值没有变,就又取了500元,于是我们实际上只取了500元,但实际扣款却扣了1000元
下图为详细过程
我们可以发现,出现ABA问题的关键要素,是会使值反复横跳,有增有减,只要我们这是将值朝着一个固定的方向变化,我们就能够避免ABA问题,但是,像存取款这种天生就有增有减的情景,我们可以引入版本号控制来规避,我们约定每次修改值,都会让版本号自增,此时CAS判定不只是判定余额值,而且还判定版本号是否变化,如果版本号没有发生变化,那么线程操作就一定没有被别的线程穿插
(三)synchronized原理
(1)锁膨胀(锁升级)
1.1java对象构成
在聊锁膨胀之前,我们先聊聊java中的对象,Java中的对象一般可分为三部分对象头,实例数据,对齐填充
对象头:对象头中一般包含两类信息
1:一类是用于存储对象运行时的数据,如哈希码,GC分代年龄,锁标志位信息等,这部分我们称它为"Mark Word"(标记字段)
2:另一类是,对象指向它的类元数据的指针(Klass Point),虚拟机通过这个指针来确定这个对象是哪个类的实例
ps:由于对象要存储的运行时数据很多,已经超出32,64位Bitmap结构所能承受的,但是在对象头中的数据是和对象自身定义无关的数据,为提升虚拟机的空间效率,Mark Word被设计称一个可以动态定义的数据结构,以便根据对象状态复用自己的存储空间
关于MarkWord“复用自己的存储空间”这一说法,实际上是指它在锁升级过程中的行为。在JVM中,锁的状态可以分为无锁状态、偏向锁、轻量级锁和重量级锁。当一个对象被用作锁对象时,其MarkWord中的锁状态标志会发生变化。在锁升级的过程中,MarkWord的内容可能会被复制到线程的栈帧中,以便进行轻量级锁的实现。当锁从轻量级锁升级为重量级锁时,MarkWord的内容可能会再次发生变化,以反映新的锁状态
实例数据:这部分主要存放我们定义的各种数据信息,父类信息等。
对齐填充:这个东西不是必要存在的,主要是因为JVM要求对象起始地址必须是8字节的整数倍,所以如果对象不是8字节的整数倍会被这部分自动填充为8字节的整数倍
1.2Monitor
我们先来看一段测试代码,以及它的反编译文件
public class Test{public synchronized void husband(){synchronized(new Demo3()){}}}
反编译文件
我用红框框标记了几处地方
1:ACC_SYCHRONIZED标志位,当我们执行同步方法的时候(被sychronized修饰的方法),会先判断这个方法是或否有ACC_SYNCHRONIZED标志位,如果有就去隐式地调用monitorenter和monitorexit。
2:monitorenter和monitorexit
我们上面提到的对象头,它会关联到一个monitor对象。
当一个线程使用sychronized关键字获取对象的锁时,JVM会在对象头中设置一个指向Monitor对象的指针
此时,Monitor对象中的owner字段会指向当前持有锁的线程
如果其他线程尝试获取同一个对象的锁,他们会发现Monitor对象中的owner不是自己,就会进入等待队列并阻塞
当我们进入一个同步方法,执行monitorenter,获取当前对象的所有权,monitor的进入数+1
若一个monitor已经被一个线程持有,而这个线程恰好是自己,那么当再次进入时,monitor进入数再+1
若执行完monitorexit,对应的monitor进入数-1,直到减到0时,释放这个锁,才能被其他线程持有
1.3早期sychronized问题
在jdk1.6之前,sychronized时一个效率比较低下的重量级锁,主要因为在1.6之前,sychronized的实现是基于上述的监视器锁(monitor)来实现的,而上述过程需要涉及到操作系统底层的Mutex Lock来实现,这需要操作系统从用户态到核心态频繁的切换,而这个状态的切换成本较高,也就是说这种重量级锁实现的sychronized效率很低。
1.4sychronized的改进
既然前面说到早期sychronized效率低下,那么这些大佬们就着手对sychronized进行了优化,这个优化就是引入了锁升级策略,在无锁和重量级锁间,加入了其他状态的锁来实现过渡
下图是锁升级的过程,且升级的过程是不可逆的。
偏向锁:偏向锁的意思是这个锁会偏向于第一个获得它的线程,本质上是一个乐观锁,当这个线程再次请求锁的时候不需要进行任何的同步操作,从而提升性能。此时对象头的Mark Word结构就是偏向锁结构。
轻量级锁:如果此时另外一个线程也要尝试获取这个锁,那么偏向模式就会结束,如果并发数量不大时,会优先使用轻量级锁,此时对象头中的Mark Word结构是轻量级锁结构
自旋锁:自旋锁是从轻量级锁到重量级锁的过渡锁,通过CAS来实现自旋,防止线程挂起导致需要调用操作系统接口使效率变慢,但如果自旋超出阈值(默认大小是10),就会升级成重量级锁
重量级锁:就是上方讨论的monitor争夺的过程。
升级过程简单示例,图片来自——三太子敖丙
(2)锁消除
锁消除是存在于编译器层面的手段,当一个程序,在代码层面要求同步,但是在检测时发现不可能存在共享数据竞争。此时编译器就会将这种不必要的锁进行消除来提升效率。
比如我们使用StringBuffer只是作为某个方法的局部变量使用,此时当每个线程调用包含StringBuffer类的方法时,都会创建一个StringBuffer类的对象,此时根本不会发生线程安全问题,而StringBuffer类中的线程安全的操作就是完全没有必要的,此时编译器就会考虑进行锁消除。
而锁消除的判定依据主要来源于逃逸分析的数据支持,如果判断一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那么就可以把它们当作栈上的数据对待,认为它们是线程私有的,加锁操作就无需进行。
(3)锁粗化
说锁粗化之前,我们先讲讲锁粒度,我们认为,如果sychronized同步代码中,代码越多,锁的粒度越粗,代码越少,锁的粒度越细。
通常情况下,我们为了保证多线程之间的有效并发,会要求每个线程持有的锁时间尽可能的短,来提升效率,但是在有些情况下一个粒度细的锁被反复的进行加锁解锁的操作(涉及反复的锁竞争),可能实际的效果还不如粗的锁
举个例子:假如你想领导汇报工作,一个工作汇报了两三分钟,汇报完了,过了一会,又给领导汇报另外一个工作,汇报完了,又过了几分钟,又给领导汇报另外一个工作,虽然每次汇报的时间短,但你这样频繁的骚扰领导,此时领导想砍你的心都有了,这样分次汇报的效果,还不如一次汇报完的效果好。
(四)总结
1:这篇我们讲到了一些不同特性的锁,如悲观乐观,重量轻量等
2:然后我们又讲到了CAS比较并交换的工作原理,以及它的ABA问题
3:同时我们又谈到了sychronized的底层原理,以及早期sychronized的问题,并进行的锁升级的优化改进等。
4:然后又对锁消除和锁粗化进行了大概的意思阐述。