彻底讲明白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,如果我的分享对你有帮助,请动动手指一键三连分享给更多的人。关注我,面试不再难!

相关文章
|
2月前
|
安全 Java 调度
Java编程时多线程操作单核服务器可以不加锁吗?
Java编程时多线程操作单核服务器可以不加锁吗?
44 2
|
10天前
|
缓存 Java
java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁
本文介绍了几种常见的锁机制,包括公平锁与非公平锁、可重入锁与不可重入锁、自旋锁以及读写锁和互斥锁。公平锁按申请顺序分配锁,而非公平锁允许插队。可重入锁允许线程多次获取同一锁,避免死锁。自旋锁通过循环尝试获取锁,减少上下文切换开销。读写锁区分读锁和写锁,提高并发性能。文章还提供了相关代码示例,帮助理解这些锁的实现和使用场景。
java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁
|
11天前
|
存储 设计模式 分布式计算
Java中的多线程编程:并发与并行的深度解析####
在当今软件开发领域,多线程编程已成为提升应用性能、响应速度及资源利用率的关键手段之一。本文将深入探讨Java平台上的多线程机制,从基础概念到高级应用,全面解析并发与并行编程的核心理念、实现方式及其在实际项目中的应用策略。不同于常规摘要的简洁概述,本文旨在通过详尽的技术剖析,为读者构建一个系统化的多线程知识框架,辅以生动实例,让抽象概念具体化,复杂问题简单化。 ####
|
16天前
|
Java 数据库连接 数据库
如何构建高效稳定的Java数据库连接池,涵盖连接池配置、并发控制和异常处理等方面
本文介绍了如何构建高效稳定的Java数据库连接池,涵盖连接池配置、并发控制和异常处理等方面。通过合理配置初始连接数、最大连接数和空闲连接超时时间,确保系统性能和稳定性。文章还探讨了同步阻塞、异步回调和信号量等并发控制策略,并提供了异常处理的最佳实践。最后,给出了一个简单的连接池示例代码,并推荐使用成熟的连接池框架(如HikariCP、C3P0)以简化开发。
35 2
|
1月前
|
Java
Java 中锁的主要类型
【10月更文挑战第10天】
|
1月前
|
Java
【编程进阶知识】揭秘Java多线程:并发与顺序编程的奥秘
本文介绍了Java多线程编程的基础,通过对比顺序执行和并发执行的方式,展示了如何使用`run`方法和`start`方法来控制线程的执行模式。文章通过具体示例详细解析了两者的异同及应用场景,帮助读者更好地理解和运用多线程技术。
26 1
|
2月前
|
Java API 容器
JAVA并发编程系列(10)Condition条件队列-并发协作者
本文通过一线大厂面试真题,模拟消费者-生产者的场景,通过简洁的代码演示,帮助读者快速理解并复用。文章还详细解释了Condition与Object.wait()、notify()的区别,并探讨了Condition的核心原理及其实现机制。
|
2月前
|
算法 Java 关系型数据库
Java中到底有哪些锁
【9月更文挑战第24天】在Java中,锁主要分为乐观锁与悲观锁、自旋锁与自适应自旋锁、公平锁与非公平锁、可重入锁以及独享锁与共享锁。乐观锁适用于读多写少场景,通过版本号或CAS算法实现;悲观锁适用于写多读少场景,通过加锁保证数据一致性。自旋锁与自适应自旋锁通过循环等待减少线程挂起和恢复的开销,适用于锁持有时间短的场景。公平锁按请求顺序获取锁,适合等待敏感场景;非公平锁性能更高,适合频繁加解锁场景。可重入锁支持同一线程多次获取,避免死锁;独享锁与共享锁分别用于独占和并发读场景。
|
1月前
|
安全 Java 开发者
java的synchronized有几种加锁方式
Java的 `synchronized`通过上述三种加锁方式,为开发者提供了从粗粒度到细粒度的并发控制能力,满足了不同场景下的线程安全需求。合理选择加锁方式对于提升程序的并发性能和正确性至关重要,开发者应根据实际应用场景的特性和性能要求来决定使用哪种加锁策略。
16 0
|
1月前
|
Java 应用服务中间件 测试技术
Java21虚拟线程:我的锁去哪儿了?
【10月更文挑战第8天】
32 0