之前我们所了解的属于多线程的初阶内容。今天开始,我们进入多线程进阶的学习。
锁的策略
乐观锁 悲观锁
这不是两把具体的锁,应该叫做“两类锁”
乐观锁:预测锁竞争不是很激烈(这里做的工作可能就会少一些)
悲观锁,预测锁竞争会很激烈(这里的工作可能就会多一些)
这里都不绝对,悲观和乐观,唯一的区分主要就是看预测锁竞争激烈程度的结论~
轻量级锁 重量级锁
轻量级锁加锁解锁开销比较小~效率更高~
重量级锁加锁解锁开销比较大~效率更低~
多数情况下,乐观锁也是一个轻量级锁
多数情况下,悲观锁也是一个重量级锁
自旋锁 挂起等待锁
自旋锁,是一种典型的轻量级锁:当某个线程没有申请到锁的时候,该线程不会被挂起,而是每隔一段时间检测锁是否被释放。如果锁被释放了,那就竞争锁;如果没有释放,过一会儿再来检测。
挂起等待锁,是一种典型的重量级锁:当某个线程没有申请到锁的时候,此时该线程会被挂起,即加入到等待队列等待。当锁被释放的时候,就会被唤醒,重新竞争锁。
也就是说自旋锁在申请锁的过程中会一直重复申请,会占用大量的cpu资源。挂起等待锁也就可以把cpu省下来干别的事情~
互斥锁 读写锁
互斥锁:像synchronized这样的锁,提供加锁和解锁两个操作~
如果一个线程加锁了,另一个线程也尝试加锁就会阻塞等待~
读写锁:只有一组操作中有读也有写,才会产生竞争。
公平锁 非公平锁
此处应该把公平定义成“先来后到”。
操作系统和Java synchronized 原生都是“非公平锁”。操作系统这里的针对加锁的控制,本身就非常依赖线程调度顺序,这个调度顺序是随机的,不会考虑到这个线程等待多久了~
要想实现公平锁,就得在这个基础上,引入额外的东西(比如一个给锁排序的队列)
可重入锁 不可重入锁
不可重入锁:针对同一个线程连续加锁多次,会出现死锁
可重入锁:针对同一个线程连续加锁多滴,不会出现死锁
synchronized
1.synchronized既是一个悲观锁,又是一个乐观锁。
synchronized默认是一个乐观锁,但是如果发现当前锁竞争比较激烈,就会变成悲观锁。
2.synchronized既是一个轻量级锁,也是一个重量级锁。
synchronized默认是一个轻量级锁,如果发现当前锁竞争比较激烈,就会转换成重量级锁。
3.synchronized这里的轻量级锁,是基于自旋锁的方式来实现的。
synchronized这里的重量级锁,是基于挂起等待锁的方式来实现的。
4.synchronized不是读写锁
5.synchronized是非公平锁
6.synchronized是可重入锁
总结:上述谈到的6种锁策略,可以视为是“锁的形容词”。
CAS
CAS全称Compare and swap,字面意思“比较和交换”
一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
1. 比较 A 与 V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V。(交换)
3. 返回操作是否成功。
最重要的是:CAS的操作是原子的!
CAS的过程并非是通过一段代码实现的,而是通过一条CPU指令完成的。
既然是原子的,那么就可以一定程度上处理线程安全问题~
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
通过一段伪代码(不能编译运行,只是表达了一个大概的逻辑)可以更好地理解CAS。
CAS的应用场景
1.实现原子类:Java标准库里提供的类
标准库中提供了 java.util.concurrent.atomic 包,,里面的类都是基于这种方式来实现的.。典型的就是 AtomicInteger 类。其中的 getAndIncrement 相当于 i++ 操作。
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();
伪代码:
class AtomicInteger{ private int value; public int getAndIncrement(){ int oldvalue = value; while(CAS(value,oldvalue,oldvalue+1) != true){ oldvalue = value; } return oldvalue; } }此处为伪代码
可以把oldvalue理解成为寄存器里的值。
我们就拿伪代码来说明:
正常情况下,oldValue应该和value是一样的值,紧接着这里会产生CAS,把oldValue + 1写到value中去。
但是:可能会执行完把value的值写到oldvalue(寄存器)这一步后,线程发生切换了,另一个线程也进行修改了value的值
此时这个线程回来后,通过CAS判定,就认为value和oldvalue不相等了。
于是就返回false,不进行任何交换。进入循环,循环内部重新读取value的值到oldvalue中去。
此时在比较,发现相等了,进行CAS操作,并返回true,就不进入循环结束了。
原子类这里的实现,每次修改之前,再确认一下这个值是否符合要求。
通过形如上述代码就可以实现一个原子类,不需要使用重量级锁,就可以高效的完成多线程的自增操作。
2.实现自旋锁
自旋锁伪代码:
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;
}
}
while循环中:监测当前的owner是否是null,如果是null,就进行交换,也就是把当前线程的引用赋值给owner 。如果赋值成功,此时循环结束,加锁完成了。
如果当前锁,已经被别的线程占用了,CAS就会发现,,this.owner 不是null,CAS就不会产生赋值,也同时返回false.循环继续执行,并进行下次判定....
ABA问题
CAS在运行中的核心:检查value和oldValue是否一致。如果一致,就视为value 中途没有被修改过,所以进行下一步交换操作是没问题的。
但是这里的一致,可能是没改过,也可能是改过,但是改回来了。
把value的值设为A的话,CAS判定value为A,此时可能确实value始终是A,也可能是value本来是A,被改成了B,又被还原成了A。
ABA这个情况,大部分情况下其实是不会对代码产生太大影响的,但是不排除一些极端情况,也是可能造成影响的。
假设我有 100 存款.。我想从 ATM 取 50 块钱,取款机创建了两个线程,并发的来执行 -50 操作,我们期望一个线程执行 -50 成功, 另一个线程 -50 失败。 如果使用 CAS 的方式来完成这个扣款过程就可能出现问题。
正常的过程
1) 存款 100。线程1 获取到当前存款值为 100,期望更新为 50; 线程2 获取到当前存款值为 100,,期望更新为 50。
2) 线程1 执行扣款成功,存款被改成 50。线程2阻塞等待中。
3) 轮到线程2执行了,发现当前存款为 50,和之前读到的 100 不相同,执行失败。
异常的过程
1) 存款 100。线程1 获取到当前存款值为 100,期望更新为 50;线程2 获取到当前存款值为 100,,期望更新为 50。
2) 线程1执行扣款成功,存款被改成 50。线程2阻塞等待中。
3) 在线程2执行之前,我的朋友正好给滑稽转账 50,账户余额变成 100 。
4) 轮到线程2 执行了,发现当前存款为100,和之前读到的100相同,再次执行扣款操作
这个时候扣款就被执行了两次。
解决办法
给要修改的值,引入版本号。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。CAS 操作在读取旧值的同时,也要读取版本号。真正修改的时候,如果当前版本号和读到的版本号相同,则修改数据,并把版本号 + 1 。如果当前版本号高于读到的版本号,操作失败(认为数据已经被修改过了)。
synchronized原理
两个线程,针对一个对象加锁,就会产生阻塞等待。
synchronized内部其实还有一些优化机制,存在的目的就是为了让这个锁更高效,更好用。
1.锁升级、锁膨胀
1)无锁 2)偏向锁 3)轻量级锁 4)重量级锁
synchronized(locker){
}
当代码执行到这个代码块中之后,加锁过程会经历前面说的这几个阶段:
偏向锁:
进行加锁的时候,首先会进入到偏向锁的状态。偏向锁的过程就是:“非必要,不加锁”。
synchronized的时候,并不是真的加锁,先偏向锁状态,做个标记(这个过程是非常轻量的),如果整个使用锁的过程中,都没有出现锁竞争,在synchronized执行完之后,取消偏向锁即可。
(反正也没有锁竞争,这样就开销最低了~)
但是如果使用过程中,另一个线程也尝试加锁,在它加锁之前,迅速地把偏向锁升级成真正的加锁状态,另一个线程也就只能阻塞等待了~
轻量级锁:
当synchronized发生锁竞争的时候,就会从偏向锁,升级成轻量级锁。
此时,synchronized相当于是通过自旋的方式来进行加锁的。刚才那个CAS那里的那个伪代码一样~
synchronized内部的自旋循环中,有个计数器,记录了循环了多少次/多久了,达到一定程度,就结束循环,执行重量级锁的逻辑。
重量级锁:
此时如果线程进行了重量级锁的加锁,并且发生锁竞争,此时线程就会被放到阻塞队列中,暂时不参与CPU调度了~
然后锁被释放了这个线程才有机会被调度到,并且有机会获取到锁。
锁降级:
锁能升级了,但是不能降级。只有锁升级,没有锁降级。除非是另外搞一个对象,重复刚才的从偏向锁开始升级的过程~
2.锁消除
编译器智能的判定,看当前的代码是否是真的要加锁,如果这个场景不需要加锁,程序猿也加了,就自动把锁给干掉~~
StringBuffer关键方法都带有synchronized。
但是如果在单线程中使用StringBuffer, synchronized 加了也白加,此时编译器就会直接把这些加锁操作干掉了。
3.锁粗化
锁的粒度:synchronized包含的代码越多,粒度就越粗,包含的代码就越少,粒度就越细。
通常情况下,认为锁的粒度细一点比较好。加锁的部分,是不能并发执行的。锁的粒度越细,能并发的代码就越多,反之就越少。
但是有些情况下,锁的粒度粗一些反而更好~