彻底讲明白Java中眼花缭乱的各种并发锁

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


在互联网公司面试中,很多小伙伴都被问到过关于锁的问题。

今天,我给大家一次性把Java并发锁的全家桶彻底讲明白。包括互斥锁、读写锁、重入锁、公平锁、悲观锁、自旋锁、偏向锁等等等等。视频有点长,大家一定要全部看完,保证你会醍醐灌顶。

1、锁的由来

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

b115ca0d4db542948d88866264e3583b.png

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

2、锁的应用场景

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


1)某个线程是否锁住同步资源的情况

如果要锁住同步资源则使用悲观锁,不锁住同步资源使用乐观锁。

所谓悲观锁,就是每次拿数据的时候都认为会有别人修改,所以在读数据的时候都会上锁,其他线程数据就会阻塞,直到拿到锁。

140d548803314f70824b4bbb41c1aab4.png

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

dcf5126512d042d295d9301a10172404.png

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

f881cc7e4b02411bae88500eb25cf6fa.png

乐观锁与悲观锁是一种广义上的概念,没有谁优谁劣。乐观锁适用于写少读多的场景,因为不用上锁、释放锁,省去了锁的开销,从而提升了吞吐量。

而悲观锁适用于写多读少的场景,因为线程间竞争激励,如果使用乐观锁会导致线程不断进行重试,这样反而还降低了性能。


2)多个线程是否共享一把锁的情况

如果在并发情况下,多个线程共享一把锁就是使用共享锁,如果不能共享一把锁就是排它锁或者叫独占锁、独享锁。

共享锁是指锁可被多个线程所持有。如果一个线程对数据加上共享锁后,那么其他线程只能对数据再加共享锁,不能加独占锁。获得共享锁的线程只能读数据,不能修改数据。

de306683536a424d8ca4540b692ccef5.png

在 JDK 中 ReentrantReadWriteLock 就是一种共享锁。

而独占锁是指锁一次只能被一个线程所持有。如果一个线程对数据加上排他锁后,那么其他线程不能再对该数据加任何类型的锁。获得独占锁的线程即能读数据又能修改数据。

24c57daf20c04e758bc4a0f6ba06f10b.png

JDK中的synchronized和J.U.C(java.util.concurrent)包中Lock的实现类都是独占锁。

另外,互斥锁是独占锁的一种常规实现,是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。

c2705cb4b6d3498485bd6446666ba231.png

互斥锁一次只能一个线程拥有互斥锁,其他线程只有等待。

而读写锁是共享锁的一种具体实现。读写锁管理一组锁,一个是只读的锁,一个是写锁。

读锁可以在没有写锁的时候被多个线程同时持有,而写锁是独占的。写锁的优先级要高于读锁,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容。

读写锁相比于互斥锁并发程度更高,每次只有一个写线程,但是同时可以有多个线程并发读。

d88d6db58f6a4775987b65d7adf185d3.png

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

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

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


3)多个线程竞争时是否要排队的情况

多个线程竞争排队获取锁的情况,使用公平锁,如果,使用非公平锁。

所谓公平锁是指多个线程按照申请锁的顺序来获取锁,这里类似排队买票,先来的人先买,后来的人在队尾排着,这是公平的。


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

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

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

c9fc1795cfce4054b1c1b813dc840d0c.png

在 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)某个线程锁住同步资源失败,是否不阻塞的情况

如果某个线程锁住同步资源失败,但是希望这个线程不阻塞,就可以使用自旋锁或者自适应自旋锁。

自旋锁是指线程在没有获得锁时不是被直接挂起,而是执行一个忙循环,这个忙循环就是所谓的自旋。

08e05e69862b4fe39bc11972ef25f3ff.png

自旋锁的目的是为了减少线程被挂起的几率,因为线程的挂起和唤醒也都是耗资源的操作。

如果锁被另一个线程占用的时间比较长,即使自旋了之后当前线程还是会被挂起,忙循环就会变成浪费系统资源的操作,反而降低了整体性能。因此自旋锁是不适应锁占用时间长的并发情况的。

在 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种锁的状态:无锁、偏向锁、轻量级锁和重量级锁,它会随着多线程的竞争情况逐渐升级,但不能降级。

2ba42e0a187d47da986def0e28815732.png

如果多个线程中,只有一个线程能修改资源成功,其他资源只是重试,不锁住资源,称为无锁状态,其实就是乐观锁。

第一个线程访问加锁的资源自动获取锁,不存在多线程竞争的情况,资源偏向于第一个访问锁的线程,每次访问线程不需要重复获取锁,这种状态称为偏向锁。偏向锁的实现是通过控制对象Mark Word的标志位来实现的,如果当前是可偏向状态,需要进一步判断对象头存储的线程 ID 是否与当前线程 ID 一致,如果一致直接进入。

当线程竞争变得比较激烈时,偏向锁就会升级为轻量级锁,轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式等待上一个线程释放锁。

但如果线程并发进一步加剧,线程的自旋超过了一定次数,或者一个线程持有锁,一个线程在自旋,又来了第三个线程访问的时候,轻量级锁就会膨胀为重量级锁,重量级锁会使除了当时拥有锁的线程以外的所有线程都阻塞。

升级到重量级锁其实就是互斥锁了,一个线程拿到锁,其余线程都会处于阻塞等待状态。

在 Java 中,synchronized 关键字内部实现原理就是这样一个锁升级的过程。


7)最后,就是锁再设计和锁优化的一些情况

先来看分段锁,它是一种锁的再次设计,并不是具体的一种锁。

分段锁设计目的是将锁的粒度进一步细化,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

4b78893df7d642239a5c4b79f310715f.png

在 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、总结

好了,前面讲了这么多,相信大家已经理解了各种眼花缭乱的锁。最后,我用一张脑图完整地总结了各种锁的应用场景。大家可以在面试前拿出来看看,只要是被问到锁相关的问题,相信你一定能够吊打面试官了。

3e40ac95278248a98c6049ac90f30b75.png

脑图分享链接:https://www.processon.com/view/link/633412ea07912955b20d7938

最后,分享几个关于锁的高频面试题,看看大家能不能回答出来


1、ReentrantLock与synchronized 的区别

2、synchronized和volatile的区别

3、synchronized和lock的区别

4、什么是死锁以及如何避免死锁问题

如果你回答不出来,可以去我的主页看看,这些面试题在往期的视频中都有分享过。

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

相关文章
|
6天前
|
存储 缓存 安全
【Java面试题汇总】多线程、JUC、锁篇(2023版)
线程和进程的区别、CAS的ABA问题、AQS、哪些地方使用了CAS、怎么保证线程安全、线程同步方式、synchronized的用法及原理、Lock、volatile、线程的六个状态、ThreadLocal、线程通信方式、创建方式、两种创建线程池的方法、线程池设置合适的线程数、线程安全的集合?ConcurrentHashMap、JUC
【Java面试题汇总】多线程、JUC、锁篇(2023版)
|
2天前
|
Java 数据库
JAVA并发编程-一文看懂全部锁机制
曾几何时,面试官问:java都有哪些锁?小白,一脸无辜:用过的有synchronized,其他不清楚。面试官:回去等通知! 今天我们庖丁解牛说说,各种锁有什么区别、什么场景可以用,通俗直白的分析,让小白再也不怕面试官八股文拷打。
|
2天前
|
安全 Java 开发者
Java并发编程中的锁机制解析
本文深入探讨了Java中用于管理多线程同步的关键工具——锁机制。通过分析synchronized关键字和ReentrantLock类等核心概念,揭示了它们在构建线程安全应用中的重要性。同时,文章还讨论了锁机制的高级特性,如公平性、类锁和对象锁的区别,以及锁的优化技术如锁粗化和锁消除。此外,指出了在高并发环境下锁竞争可能导致的问题,并提出了减少锁持有时间和使用无锁编程等策略来优化性能的建议。最后,强调了理解和正确使用Java锁机制对于开发高效、可靠并发应用程序的重要性。
11 3
|
22天前
|
存储 Java
Java锁是什么?简单了解
在高并发环境下,锁是Java中至关重要的概念。锁或互斥是一种同步机制,用于限制多线程环境下的资源访问,确保排他性和并发控制。例如,超市储物柜仅能存放一个物品,若三人同时使用,则需通过锁机制确保每次只有一个线程访问。Java中可以通过`synchronized`关键字实现加锁,确保关键代码段的原子性,避免数据不一致问题。正确使用锁可有效提升程序的稳定性和安全性。
Java锁是什么?简单了解
|
22天前
|
小程序 Java 开发工具
【Java】@Transactional事务套着ReentrantLock锁,锁竟然失效超卖了
本文通过一个生动的例子,探讨了Java中加锁仍可能出现超卖问题的原因及解决方案。作者“JavaDog程序狗”通过模拟空调租赁场景,详细解析了超卖现象及其背后的多线程并发问题。文章介绍了四种解决超卖的方法:乐观锁、悲观锁、分布式锁以及代码级锁,并重点讨论了ReentrantLock的使用。此外,还分析了事务套锁失效的原因及解决办法,强调了事务边界的重要性。
46 2
【Java】@Transactional事务套着ReentrantLock锁,锁竟然失效超卖了
|
14天前
|
Oracle Java 关系型数据库
【颠覆性升级】JDK 22:超级构造器与区域锁,重塑Java编程的两大基石!
【9月更文挑战第6天】JDK 22的发布标志着Java编程语言在性能和灵活性方面迈出了重要的一步。超级构造器和区域锁这两大基石的引入,不仅简化了代码设计,提高了开发效率,还优化了垃圾收集器的性能,降低了应用延迟。这些改进不仅展示了Oracle在Java生态系统中的持续改进和创新精神,也为广大Java开发者提供了更多的可能性和便利。我们有理由相信,在未来的Java编程中,这些新特性将发挥越来越重要的作用,推动Java技术不断向前发展。
|
24天前
|
Java 开发者
Java多线程教程:使用ReentrantLock实现高级锁功能
Java多线程教程:使用ReentrantLock实现高级锁功能
23 1
|
17天前
|
安全 Java API
Java线程池原理与锁机制分析
综上所述,Java线程池和锁机制是并发编程中极其重要的两个部分。线程池主要用于管理线程的生命周期和执行并发任务,而锁机制则用于保障线程安全和防止数据的并发错误。它们深入地结合在一起,成为Java高效并发编程实践中的关键要素。
10 0
|
19天前
|
开发者 C# Windows
WPF与游戏开发:当桌面应用遇见游戏梦想——利用Windows Presentation Foundation打造属于你的2D游戏世界,从环境搭建到代码实践全面解析新兴开发路径
【8月更文挑战第31天】随着游戏开发技术的进步,WPF作为.NET Framework的一部分,凭借其图形渲染能力和灵活的UI设计,成为桌面游戏开发的新选择。本文通过技术综述和示例代码,介绍如何利用WPF进行游戏开发。首先确保安装最新版Visual Studio并创建WPF项目。接着,通过XAML设计游戏界面,并在C#中实现游戏逻辑,如玩家控制和障碍物碰撞检测。示例展示了创建基本2D游戏的过程,包括角色移动和碰撞处理。通过本文,WPF开发者可更好地理解并应用游戏开发技术,创造吸引人的桌面游戏。
52 0
|
19天前
|
开发者 C# 存储
WPF开发者必读:资源字典应用秘籍,轻松实现样式与模板共享,让你的WPF应用更上一层楼!
【8月更文挑战第31天】在WPF开发中,资源字典是一种强大的工具,用于共享样式、模板、图像等资源,提高了应用的可维护性和可扩展性。本文介绍了资源字典的基础知识、创建方法及最佳实践,并通过示例展示了如何在项目中有效利用资源字典,实现资源的重用和动态绑定。
35 0