多线程进阶
1.常见的锁策略
如果你自己实现一把锁,你认为标准库给你提供的锁不够用,这个时候你就需要关注锁策略,其实synchronized已经非常好用了足够覆盖大多数的使用场景。这里的锁策略不是和java强相关的,其他语言但凡涉及到并发编程,设计到锁都可以谈到这样的锁策略。
1.1 乐观锁VS悲观锁
悲观锁:加锁的时候预测接下来的锁竞争的情况非常激烈,就需要针对这样的激烈情况额外做一些工作
有一把锁有二十个线程,尝试获取锁,每个线程加锁的评率都很高,一个线程加锁的时候可能被另一个线程占用着,这就乐观锁
乐观锁:加锁的时候预测接下来的锁竞争的情况不激烈,就不需要做额外工作
有一把锁假设只有两个线程尝试获取这个锁,每个线程加锁的频率都很低,一个线程加锁的时候,大概率另一个线程没有和他竞争,这就是乐观锁
以上两种形式都是描述加锁时候遇到的场景,不是针对某一个具体的锁,而是某个具体锁具有悲观特性或者乐观特性。synchronized初始使用乐观锁策略,当发现竞争比较频繁的时候,就会自动切换成悲观锁策略。
1.2 重量级锁vs轻量级锁
遇到上述场景的解决方案,
重量级锁,当悲观的场景下,此时要付出更多的代价(更低效)
轻量级锁,应对乐观的场景,此时付出的代价就会更小(更高效)
1.3 挂起等待锁 vs 自旋锁
挂起等待锁,重量级锁的典型实现
操作系统内核级别的,加锁的时候发现竞争,就会使线程进入阻塞状态,后续需要内核进行唤醒。这种情况下获取锁的周期更长,很难做到及时获取,但是在这个工程中不必一直消耗cpu,把cpu省出来做别的事情
自旋锁,轻量级锁的典型实现
应用程序级别,加锁的时候发现竞争,一般也不是进入阻塞,而是通过忙等的形式来进行等待。获取锁的周期更短,及时获取到锁,过程会一直消耗cpu
针对上述的几种锁状态我们可以知道:悲观锁对应重量级锁对应挂起等待锁,乐观锁对应轻量级锁对应自旋锁。那么synchronized是悲关锁还是乐观锁,可以说synchronized既是悲观锁又是乐观锁。设计JVM的大佬们操碎了心,JVM内部会统计每个锁竞争的激烈程度,如果竞争不激烈此时synchronized就会按照轻量级锁(自旋),如果竞争激烈,此时synchronized就会按照重量级锁(挂起等待)。
1.4 普通互斥锁 VS 读写锁
普通锁:synchronized只有加锁和解锁两个状态
读写锁:有读方式加锁解锁还有写方式加锁解锁
如果是普通互斥锁,多个线程读取一个数据本身就是线程安全的,但是如果一个线程读取一个线程修改导致读与读线程也是不安全,但在读写锁的情况下,确保读锁和读锁之间不是互斥的(不会产生堵塞),写锁和写锁之间才产生互斥,写锁和读锁之间也会互斥。读写锁适合读多写少的情况,在保证线程安全的前提下降低锁冲突的概率,提高效率。读写锁就是把读操作和写操作区分对待,Java标准库中提供了ReentrantReadWriteLock类,实现读写锁。
-
ReentrantReadWriteLock.ReadLock类表示一个读锁,这个对象提供了lock/unlock方法进行加锁解锁
-
ReentrantReadWriteLock.WriteLock类表示一个写锁,这个对象也提供了lock/unlock方法进行加锁解锁
1.5 可重入锁和不可重入锁
synchronized是“可重入锁”,可重入锁是一个线程一把锁连续加锁多次不会死锁相反就是不可重入锁。核心要点就是锁要记录当前是哪个线程拿到这把锁,使用计数器记录加锁多少次,在合适的时候进行解锁。
1.6 公平锁vs非公平锁
公平锁:按照先来后到的顺序,B比C先来的,当A释放锁之后,B就能先于C获取到锁
非公平锁:不遵守先来后到,B和C都有可能获取到锁
使用公平锁就需要付出额外的东西,比如需要使用一个队列记录一下各个线程获取锁的顺序,synchronized是自适应的,不是读写锁,是可重入锁,是非公平锁,其他的锁也能嵌套到上述的这些词语中。
2.synchronized加锁过程
结合上面的锁策略,我们可以总结出synchronized的加锁过程,无锁、偏向锁、自旋锁,重量级锁
那什么是偏向锁呢?进行synchronized刚一上来不是真的加锁,而是简单做一个标记,这个标记非常轻量,相比于加锁来说,效率高很多,如果有其他线程来竞争就抢先一步拿到锁进行真加锁此时偏向锁升级到了轻量级锁,其他线程只能阻塞等待,如果没有其他线程来竞争最终当前线程执行到解锁代码,也就只是简单清楚上述标记即可,不涉及真加锁真解锁。本质上也是懒汉模式的思想体现。
无锁升级到偏向锁:代码进入synchronized的代码块
偏向锁升级到轻量级锁:拿到偏向锁的线程运行的过程中遇到其他线程尝试竞争这个锁
轻量级锁升级到重量级锁:JVM发现当前竞争非常激烈
当前JVM中只提供了“锁升级”不能“锁降级”
3.其他的优化操作
3.1锁消除
也是编译器优化的一种体现,编译器会判定当前这个代码逻辑是否真的需要加锁,如果确实不需要加锁但是你写了synchronized就会自动把synchronized给去掉。
3.2锁粗化
锁的粒度,加锁和解锁之间包含的代码越多就认为锁的粒度就越粗,如果包含的代码越少,就认为锁的粒度就越细。包含的代码不是代码行数而是实际执行的指令、时间。一个代码中,如果反复针对细粒度的代码加锁就可能被优化成更粗粒度的加锁,比如你在代码中反复的加锁解锁操作就会使锁的粒度粗化,如果把执行多次的加锁解锁操作优化成一次加锁操作锁的粒度就会细。
4.CAS
4.1什么是CAS
CAS全称是compare and swap,字面意思“比较并交换”,解下我们可以看一段伪代码来理解CAS的基本操作:
boolean CAS(address, expectValue, swapValue) {if (&address == expectedValue) {&address = swapValue;return true;}return false;
}
上面的代码不属于原子的,但是CAS是一个原子的硬件指令完成操作。我们可以试着理解这份伪代码,其中的address指的是内存地址,expectValue指的是寄存器的值,swapValue是另一个寄存器的值。先判断内存中的值和寄存器1的值是否一致,如果一致就把内存值和寄存器2进行交换,但是基本上只是关心交换后内存的值,不关心寄存器2的值,这里也可以理解成赋值。
CAS本质上是CPU的指令,操作系统会把这个指令进行封装,提供一些api,就可以在c++中被调用了,JVM又是基于C++实现的,JVM也能够使用C++调用的这样CAS操作。
4.2 CAS的主要用途
4.2.1实现原子类
CAS的主要用途实现原子类,但是需要注意的是这里的原子类是专有名词特指的是atomic这个包里的类,之前谈到的通过SYchronized保证一个修改的原子性和原子类这个概念是不相关的。
以下是atomic包里的类:
我们在java的原子类中可以看到这些类,这个其实就是对boolean,int,long这些类型进行封装,如count++就是需要加锁来解决问题。但是又认为加锁的效率比较低,于是通过CAS来实现count++确保线程安全。接下来我们来展示代码的实现:
public class Demo2 {//使用原子类代替intprivate static AtomicInteger count = new AtomicInteger(0);//初始化为0//private static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for (int i = 0; i < 50000; i++) {count.getAndIncrement();//表示count++//count.incrementAndGet();表示++count//count.addAndGet(n);count+=n}});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实现不涉及加锁。
那为什么不会出现线程安全问题接下来我们可以举个例子,假设有两个线程同时修改变量如下图所
示,我们可以通过分析判断内存的值和我们要修改的值是否相等,另一方面赋值和判断是原子的一个指令就完成了不会出现调度顺序这样的问题,从而使结果不出现错误。
4.2.2实现自旋锁
同时我们还可以基于CAS实现自旋锁,接下来我们看看基于CAS实现自旋锁的伪代码:
我们可以试着理解这段代码,这里的owner如果为null的话锁是空闲,如果非null锁就是已经被某个线程占有了。在接下来的代码中我们就需要判断锁是否被人占用,如果未别占用就把当前线程的引用设置到owner中,如果已经别人占用,就等待。在这里就开始自旋了,如果发现锁已经被占用,CAS不会进行交换返回false进入循环,再进入下一次的判定。由于循环是空着的,整个循环速度非常快,但是一旦其他线程释放了锁,此时该线程就能第一时间拿到这里的锁。
4.3 CAS的一个典型缺陷ABA问题
使用CAS能够进行线程安全的编程,核心就是判断了内存和寄存器的值是否相等。这里本质上在判定是否有其他线程插入进来做了一些修改,这里认为如果寄存器的值和内存的值一致就没有线程修改,但是也有例外情况一个线程修改过后又该回去了,比如一个线程把内存从A修改成B,又从B修改回A。CAS中的ABA情况一般不会出现大问题,只有一些极端的场景ABA问题才会产生严重的bug。
比如我们在银行取钱,在余额1000的情况下取500,点下取款的时候卡了一下,紧接着我咔咔的狂按几下取款,此时就有可能出现两个线程并发执行扣款。假设按照CAS的方式执行扣款操作,首先判断寄存器中的balance和内存中的是否相当,相等就把寄存器二500赋值到内存中此时余额修改成功。但是如果此时出现第三个线程在第二个线程之前进行转账500操作,之后再执行线程二内存的值和寄存器的值又相当又进行扣款将寄存器500赋值带内存中,这样余额就不正确了出现了·ABA问题了A是原来的账户余额1000 B是取出来的500 但是此时又有转账500 相当于上面所说的线程更改之后又改回来了 结果应该是A 但是最后却是B 如果第三个线程转账的不是500的话就不会出现这样的情况。
解决方案,上述问题,使用钱(余额)数值来判定中间是否有线程修改余额,可以加也可以减。如果换成其他的指标约定只能加不能减,有效避免ABA问题,例如引入另一个概念版本号,每次修改一次余额版本号就加一。假设余额还是1000版本号是1,这次判断寄存器中的版本是否和执行的版本相同,相同的话就将内存中的版本修改为2再执行余额修改操作。