Loading

如何理解Java中眼花缭乱的各种并发锁?

在互联网公司面试中,很多小伙伴都被问到过关于锁的问题。
今天,我给大家一次性把Java并发锁的全家桶彻底讲明白。包括互斥锁、读写锁、重入锁、公平锁、悲观锁、自旋锁、偏向锁等等等等。视频有点长,大家一定要全部看完,保证你会醍醐灌顶。

1、锁的由来

在并发编程中,经常会遇到两个以上的线程访问同一个共享变量,当同时对共享变量进行读写操作时,就会产生数据不一致的情况。
在这里插入图片描述

随着线程并发技术的发展,在多线程环境中,对线程访问资源的限制也越来越多。为了保证资源获取的有序性和占用性,都是通过并发锁来控制的。

2、锁的应用场景

下面,我根据个人经验以及并发场景下线程的处理逻辑,总结为以下7个场景,不同场景使用不同的锁。

1)某个线程是否锁住同步资源的情况
如果要锁住同步资源则使用悲观锁,不锁住同步资源使用乐观锁。
所谓悲观锁,就是每次拿数据的时候都认为会有别人修改,所以在读数据的时候都会上锁,其他线程数据就会阻塞,直到拿到锁。
在这里插入图片描述

举个例子,假设厕所只有一个坑位,悲观锁就是上厕所会第一时间把门反锁上,这样其他人上厕所只能在门外等候,这就是阻塞。
在这里插入图片描述

而乐观锁就是开着门,当然在这个场景下一般也不会这么做。所以,乐观锁,就是每次拿数据的时候都假设为别人不会修改,所以不会上锁;只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新了,要么报错,要么自动重试。
在这里插入图片描述

乐观锁与悲观锁是一种广义上的概念,没有谁优谁劣。乐观锁适用于写少读多的场景,因为不用上锁、释放锁,省去了锁的开销,从而提升了吞吐量。
而悲观锁适用于写多读少的场景,因为线程间竞争激励,如果使用乐观锁会导致线程不断进行重试,这样反而还降低了性能。

2)多个线程是否共享一把锁的情况
如果在并发情况下,多个线程共享一把锁就是使用共享锁,如果不能共享一把锁就是排它锁或者叫独占锁、独享锁。
共享锁是指锁可被多个线程所持有。如果一个线程对数据加上共享锁后,那么其他线程只能对数据再加共享锁,不能加独占锁。获得共享锁的线程只能读数据,不能修改数据。
在这里插入图片描述

在 JDK 中 ReentrantReadWriteLock 就是一种共享锁。
而独占锁是指锁一次只能被一个线程所持有。如果一个线程对数据加上排他锁后,那么其他线程不能再对该数据加任何类型的锁。获得独占锁的线程即能读数据又能修改数据。
在这里插入图片描述

JDK中的synchronized和J.U.C(java.util.concurrent)包中Lock的实现类都是独占锁。
另外,互斥锁是独占锁的一种常规实现,是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。
在这里插入图片描述

互斥锁一次只能一个线程拥有互斥锁,其他线程只有等待。
而读写锁是共享锁的一种具体实现。读写锁管理一组锁,一个是只读的锁,一个是写锁。
读锁可以在没有写锁的时候被多个线程同时持有,而写锁是独占的。写锁的优先级要高于读锁,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容。
读写锁相比于互斥锁并发程度更高,每次只有一个写线程,但是同时可以有多个线程并发读。
在这里插入图片描述

在 JDK 中定义了一个读写锁的接口ReadWriteLock,如源码所示:

public interface ReadWriteLock {    
  /* 获取读锁 */   
  Lock readLock();     
  /* 获取写锁 */    
  Lock writeLock();
} 

ReentrantReadWriteLock 实现了ReadWriteLock接口,ReentrantReadWriteLock 支持锁降级不支持锁升级,可以由写锁降为读锁。

3)多个线程竞争时是否要排队的情况
多个线程竞争排队获取锁的情况,使用公平锁,如果,使用非公平锁。
所谓公平锁是指多个线程按照申请锁的顺序来获取锁,这里类似排队买票,先来的人先买,后来的人在队尾排着,这是公平的。

在 Java 中可以通过构造函数初始化公平锁,如代码所示:

/**
  * 创建一个可重入锁,
  * true 表示公平锁,
  * false 表示非公平锁。
  * 默认非公平锁
 */
Lock lock = new ReentrantLock(true); 

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转,或者某个线程一直得不到锁的饥饿状态。
在这里插入图片描述

在 Java 中 synchronized 关键字是非公平锁,ReentrantLock默认也是非公平锁,如代码所示:

/*** 创建一个可重入锁,true 表示公平锁,false 表示非公平锁。默认非公平锁*/
Lock lock = new ReentrantLock(false);

4)一个线程中的多个流程,是否获取同一把锁的情况
如果一个线程中的多个流程能获取同一把锁,就使用可重入锁,如果线程的多个流程不能获取通一把锁,就是用不可重入锁。
可重入锁又称为递归锁,是指同一个线程在外层方法获取了锁,在进入内层方法会自动获取锁。

对于Java ReentrantLock而言, 他的名字就可以看出是一个可重入锁。对于synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。以 synchronized 为例,来看这样一段代码:

public synchronized void mehtodA() throws Exception{ 
  // Do some magic tings 
  mehtodB();
} 
public synchronized void mehtodB() throws Exception{ 
  // Do some magic tings
} 

在这段代码中 methodA() 调用 methodB(),如果一个线程调用methodA() 已经获取了锁再去调用 methodB() 就不需要再次获取锁了,这就是可重入锁的特性。如果是不可重入锁的话,mehtodB() 可能不会被当前线程执行,可能造成死锁。

5)某个线程锁住同步资源失败,是否不阻塞的情况
如果某个线程锁住同步资源失败,但是希望这个线程不阻塞,就可以使用自旋锁或者自适应自旋锁。
自旋锁是指线程在没有获得锁时不是被直接挂起,而是执行一个忙循环,这个忙循环就是所谓的自旋。
在这里插入图片描述

自旋锁的目的是为了减少线程被挂起的几率,因为线程的挂起和唤醒也都是耗资源的操作。
如果锁被另一个线程占用的时间比较长,即使自旋了之后当前线程还是会被挂起,忙循环就会变成浪费系统资源的操作,反而降低了整体性能。因此自旋锁是不适应锁占用时间长的并发情况的。
在 Java 中,AtomicInteger 类就有自旋的操作,来看这样一段代码:

public final int getAndAddInt(Object o, long offset, int delta) {    
  int v;    
  do {        
    v = getIntVolatile(o, offset);    
  } while (!compareAndSwapInt(o, offset, v, v + delta));    
  return v;
} 

循环条件调用compareAndSwapInt()方法,被称为CAS操作,如果失败就会一直循环获取当前 value 值然后重试,这个过程叫自旋。
在JDK1.6引入了自适应自旋,这个就比较智能了,自旋时间不再固定,由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。如果虚拟机认为这次自旋也很有可能再次成功那就会次序较多的时间,如果自旋很少成功,那以后可能就直接省略掉自旋过程,避免浪费处理器资源。

6)线程竞争同步资源时,细节流程是否发生变化的情况
JDK1.6 为了提升性能减少获得锁和释放锁所带来的消耗,引入了4种锁的状态:无锁、偏向锁、轻量级锁和重量级锁,它会随着多线程的竞争情况逐渐升级,但不能降级。
在这里插入图片描述

如果多个线程中,只有一个线程能修改资源成功,其他资源只是重试,不锁住资源,称为无锁状态,其实就是乐观锁。
第一个线程访问加锁的资源自动获取锁,不存在多线程竞争的情况,资源偏向于第一个访问锁的线程,每次访问线程不需要重复获取锁,这种状态称为偏向锁。偏向锁的实现是通过控制对象Mark Word的标志位来实现的,如果当前是可偏向状态,需要进一步判断对象头存储的线程 ID 是否与当前线程 ID 一致,如果一致直接进入。
当线程竞争变得比较激烈时,偏向锁就会升级为轻量级锁,轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式等待上一个线程释放锁。
但如果线程并发进一步加剧,线程的自旋超过了一定次数,或者一个线程持有锁,一个线程在自旋,又来了第三个线程访问的时候,轻量级锁就会膨胀为重量级锁,重量级锁会使除了当时拥有锁的线程以外的所有线程都阻塞。
升级到重量级锁其实就是互斥锁了,一个线程拿到锁,其余线程都会处于阻塞等待状态。
在 Java 中,synchronized 关键字内部实现原理就是这样一个锁升级的过程。

7)最后,就是锁再设计和锁优化的一些情况
先来看分段锁,它是一种锁的再次设计,并不是具体的一种锁。
分段锁设计目的是将锁的粒度进一步细化,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
在这里插入图片描述

在 Java 语言中 CurrentHashMap 底层使用分段锁Segment,来支持多线程并发操作。
另外,就是锁优化,包括锁消除、锁粗化。
锁粗化就是将多个同步块的数量减少,并将单个同步块的作用范围扩大,本质上就是将多次上锁、解锁的请求合并为一次同步请求。
举个例子,一个循环体中有一个代码同步块,每次循环都会执行加锁解锁操作。如代码所示:

private static final Object LOCK = new Object(); 
for(int i = 0;i < 100; i++) {    
  synchronized(LOCK){        
    // do some magic things    
  }
} 

经过锁粗化后,就变成下面这个样子:

 synchronized(LOCK){     
   for(int i = 0;i < 100; i++) {       
     // do some magic things    
   }
 } 

锁消除是指虚拟机编译器在运行时检测到了共享数据没有竞争的锁,从而将这些锁进行消除。举个例子让大家更好理解,来看这样一段代码:

public String test(String s1, String s2){    
  StringBuffer stringBuffer = new StringBuffer();    
  stringBuffer.append(s1);    
  stringBuffer.append(s2);    
  return stringBuffer.toString();
} 

上面代码中的test() 方法,主要作用是将字符串 s1 和字符串 s2 串联起来。
test() 方法中的三个变量s1, s2,和StringBuffer都是局部变量,局部变量是存储在栈上的,而栈又是线程私有的,所以就算有多个线程访问 test() 方法也是线程安全的。
我们都知道 StringBuffer 是线程安全的类,因为append()方法是同步方法,如源码所示:

 // append 是同步方法
 public synchronized StringBuffer append(String str) {    
  toStringCache = null;    
  super.append(str);    
  return this;
} 

但是 test() 方法本来就是线程安全的,为了提升效率,虚拟机自动帮我们消除了这些同步锁,这个过程就被称为锁消除。

3、总结

好了,前面讲了这么多,相信大家已经理解了各种眼花缭乱的锁。最后,我用一张脑图完整地总结了各种锁的应用场景。大家可以在面试前拿出来看看,只要是被问到锁相关的问题,相信你一定能够吊打面试官了。
在这里插入图片描述
脑图分享链接:https://www.processon.com/view/link/633412ea07912955b20d7938
最后,分享几个关于锁的高频面试题,看看大家能不能回答出来

1、ReentrantLock与synchronized 的区别
2、synchronized和volatile的区别
3、synchronized和lock的区别
4、什么是死锁以及如何避免死锁问题
如果你回答不出来,可以去我的主页看看,这些面试题在往期的视频中都有分享过。

我是被编程耽误的文艺Tom,如果我的分享对你有帮助,请动动手指一键三连分享给更多的人。关注我,面试不再难!

posted @ 2022-11-14 20:40  Tom弹架构  阅读(711)  评论(0编辑  收藏  举报