Java并发 --- 锁相关问题

简介: Java并发 --- 锁相关问题

Java中有两种加锁的方式:一种是用synchronized关键字,另一种是用Lock接口的实现类。


如果你只是想要简单的加个锁,对性能也没什么特别的要求,用synchronized关键字就足够了。自Java 5之后,才在java.util.concurrent.locks包下有了另外一种方式来实现锁,那就是Lock。也就是说,synchronized是Java语言内置的关键字,而Lock是一个接口,这个接口的实现类在代码层面实现了锁的功能。


image.png

ReentrantLock、ReadLock、WriteLock 是Lock接口最重要的三个实现类。对应了“可重入锁”、“读锁”和“写锁”。


ReadWriteLock其实是一个工厂接口,而ReentrantReadWriteLock是ReadWriteLock的实现类,它包含两个静态内部类ReadLock和WriteLock。这两个静态内部类又分别实现了Lock接口。


悲观锁 vs 乐观锁?CAS(乐观锁实现的基础)实现及存在的问题?


悲观锁和乐观锁是一种宏观的分类方式,并不是特指某个锁,而是在并发情况下两种不同的策略。划分依据:线程要不要锁住同步资源,锁住同步资源失败后,线程要不要阻塞(不阻塞-自旋锁)。


  • 悲观锁(Pessimistic Lock), 就是很悲观,每次去拿数据的时候都认为别人会修改。所以每次在拿数据的时候都会上锁。这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
  • 乐观锁(Optimistic Lock), 就是很乐观,每次去拿数据的时候都认为别人不会修改。所以不会上锁,不会上锁!但是如果想要更新数据,则会在更新前检查在读取至更新这段时间别人有没有修改过这个数据。如果修改过,则重新读取,再次尝试更新,循环上述步骤直到更新成功(当然也允许更新失败的线程放弃操作),即CAS实现,在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。除此之外乐观锁可以使用版本号机制实现,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。


应用场景


  • 悲观锁适合写操作多,先加锁可以保证写操作时数据正确。
  • 乐观锁适合读操作多,可以省去锁的开销,提升系统的吞吐量和性能。


简言之,悲观锁阻塞事务,乐观锁回滚重试


乐观锁实现方式


  • CAS实现:CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。CAS算法涉及到三个操作数:
  • 需要读写的内存值 V。
  • 进行比较的值 A。
  • 要写入的新值 B。
  • 当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。java.util.concurrent.atomic 包下的类大多是使用 CAS 操作来实现的。
  • 版本号机制:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。


CAS会产生哪些问题?


  • ABA问题:CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
    JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
  • 循环时间长,开销大:对于资源竞争严重(线程冲突严重)的情况,CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销,效率低于 synchronized。
  • 只能保证一个共享变量的原子操作:当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。
    Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。


synchronized锁升级(多个资源竞争同步资源):偏向锁 → 轻量级锁 → 重量级锁


划分依据:多个线程竞争同步资源的流程细节不同


这四种锁是指锁的状态,专门针对synchronized的。在介绍这四种锁状态之前还需要介绍一些额外的知识。


首先为什么Synchronized能实现线程同步?在此之前,需要了解两个重要的概念:“Java对象头”、“Monitor”。


  • Java对象头:synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
    Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
    Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • Monitor:Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。
    Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
    现在话题回到synchronized,synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。


为什么会出现synchronized三种锁?


“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。


  • 偏向锁:偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
  • 多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
  • 当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
  • 偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
  • 轻量级锁:当偏向锁被另外的线程访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。若当前只有一个等待线程,则该线程通过自旋进行等待(cpu在做无用功,一般设定最大等待时间,达到最大等待时间,停止自旋进入阻塞状态)。
  • 在代码进入同步块的时候,如果同步对象锁状态为无锁状态,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
  • 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
  • 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
  • 如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
  • 重量级锁当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
  • 升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。


三种锁的区别和总结:


  • 偏向锁:通过对比Mark Word解决加锁问题,避免执行CAS操作带来的额外消耗。但是存在锁竞争会带来额外的锁撤销的消耗,只适用一个线程访问同步代码的场景。
  • 轻量级锁:是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能,程序响应快。但是如果线程始终得不到锁会自旋消耗cpu,适用于追求响应时间,同步代码块执行快的场景。
  • 重量级锁:是将除了拥有锁的线程以外的线程都阻塞,线程竞争不使用自旋消耗cpu。但是线程会出现阻塞,响应时间慢,适用于追求吞吐量、同步代码块执行慢的场景。


ps:同步代码块(资源):即有synchronized修饰符修饰的语句块,被该关键词修饰的语句块,将加上内置锁,实现同步。


公平锁 vs 非公平锁?


划分依据:多个线程竞争锁时要不要排队


  • 公平锁:公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。
    公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
  • 非公平锁:非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。
    但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

public ReentrantLock() {
   sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
   sync = fair ? new FairSync() : new NonfairSync();
}
//创建一个非公平锁,默认是非公平锁
Lock lock = new ReentrantLock();
Lock lock = new ReentrantLock(false);
//创建一个公平锁,构造传参true
Lock lock = new ReentrantLock(true);


根据参数决定其内部是公平锁还是非公平锁。公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()。源码自行查看,该限制条件作用:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。


  • 对ReentrantLock类而言,通过构造函数传参可以指定该锁是否是公平锁,默认是非公平锁。一般情况下,非公平锁的吞吐量比公平锁大,如果没有特殊要求,优先使用非公平锁。
  • 对于synchronized而言,它也是一种非公平锁,但是并没有任何办法使其变成公平锁。


综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。


可重入锁 vs 非可重入锁? ReentrantLock 的可重入是怎么实现的?


划分依据:一个线程中的多个流程能不能获取同一把锁


可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。


Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。


为什么非可重入锁在重复调用同步资源时会出现死锁?


通过重入锁ReentrantLock以及非可重入锁NonReentrantLock为例:首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。


  • 当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。
  • 释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。


读写锁(共享锁、独享锁)?


划分依据:多个线程能不能共享一把锁,独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。


读锁(共享锁):该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。


写锁(互斥锁/独享锁/排他锁):该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。


读写锁是悲观锁策略!因为读写锁并没有在更新前判断值有没有被修改过,而是在加锁前决定应该用读锁还是写锁。


Lock 和 synchronized 有什么区别


  • 性质不同:synchronized是Java内置关键字,在JVM层面,Lock是个Java类;
  • 加锁对象不同:synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁。
  • 是否会产生死锁:synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。
  • 是否知道成功获取锁:通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。
  • 是否是中断锁:Lock是可以中断锁,Synchronized是非中断锁,必须等待线程执行完成释放锁。


ps:可中断锁:可以响应中断的锁,Java并没有提供任何直接中断某线程的方法,只提供了中断机制


Java的中断不能直接终止线程,而是需要被中断的线程自己决定怎么处理。如果线程A持有锁,线程B等待获取该锁。由于线程A持有锁的时间过长,线程B不想继续等待了,我们可以让线程B中断自己或者在别的线程里中断它,这种就是可中断锁


synchronized和 ReentrantLock 区别是什么?如何选择呢?


可重入锁 ReentrantLock 是 Lock 最常见的实现,与 synchronized 一样可重入,不过它增加了一些高级功能:


  • 等待可中断:持有锁的线程长期不释放锁时,正在等待的线程可以选择放弃等待而处理其他事情。
  • 自定义公平锁:synchronized 是非公平的,ReentrantLock 在默认情况下是非公平的,可以通过构造方法指定公平锁。一旦使用了公平锁,性能会急剧下降,影响吞吐量。
  • 锁绑定多个条件:一个 ReentrantLock 可以同时绑定多个 Condition。synchronized 中锁对象的 wait 跟 notify 可以实现一个隐含条件,如果要和多个条件关联就不得不额外添加锁,而 ReentrantLock 可以多次调用 newCondition 创建多个条件。


一般优先考虑使用 synchronized


  • synchronized 是语法层面的同步,足够简单。
  • Lock 必须确保在 finally 中释放锁,否则一旦抛出异常有可能永远不会释放锁。使用 synchronized 可以由 JVM 来确保即使出现异常锁也能正常释放。
  • JVM 更容易针对synchronized 优化,因为 JVM 可以在线程和对象的元数据中记录 synchronized 中锁的相关信息,而使用 Lock 的话 JVM 很难得知具体哪些锁对象是由特定线程持有的。
相关文章
|
4月前
|
安全 Java 调度
Java编程时多线程操作单核服务器可以不加锁吗?
Java编程时多线程操作单核服务器可以不加锁吗?
51 2
|
2月前
|
缓存 Java
java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁
本文介绍了几种常见的锁机制,包括公平锁与非公平锁、可重入锁与不可重入锁、自旋锁以及读写锁和互斥锁。公平锁按申请顺序分配锁,而非公平锁允许插队。可重入锁允许线程多次获取同一锁,避免死锁。自旋锁通过循环尝试获取锁,减少上下文切换开销。读写锁区分读锁和写锁,提高并发性能。文章还提供了相关代码示例,帮助理解这些锁的实现和使用场景。
java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁
|
2月前
|
存储 安全 Java
Java多线程编程中的并发容器:深入解析与实战应用####
在本文中,我们将探讨Java多线程编程中的一个核心话题——并发容器。不同于传统单一线程环境下的数据结构,并发容器专为多线程场景设计,确保数据访问的线程安全性和高效性。我们将从基础概念出发,逐步深入到`java.util.concurrent`包下的核心并发容器实现,如`ConcurrentHashMap`、`CopyOnWriteArrayList`以及`BlockingQueue`等,通过实例代码演示其使用方法,并分析它们背后的设计原理与适用场景。无论你是Java并发编程的初学者还是希望深化理解的开发者,本文都将为你提供有价值的见解与实践指导。 --- ####
|
2月前
|
Java 开发者
Java 中的锁是什么意思,有哪些分类?
在Java多线程编程中,锁用于控制多个线程对共享资源的访问,确保数据一致性和正确性。本文探讨锁的概念、作用及分类,包括乐观锁与悲观锁、自旋锁与适应性自旋锁、公平锁与非公平锁、可重入锁和读写锁,同时提供使用锁时的注意事项,帮助开发者提高程序性能和稳定性。
120 3
|
2月前
|
存储 设计模式 分布式计算
Java中的多线程编程:并发与并行的深度解析####
在当今软件开发领域,多线程编程已成为提升应用性能、响应速度及资源利用率的关键手段之一。本文将深入探讨Java平台上的多线程机制,从基础概念到高级应用,全面解析并发与并行编程的核心理念、实现方式及其在实际项目中的应用策略。不同于常规摘要的简洁概述,本文旨在通过详尽的技术剖析,为读者构建一个系统化的多线程知识框架,辅以生动实例,让抽象概念具体化,复杂问题简单化。 ####
|
2月前
|
Java 数据库连接 数据库
如何构建高效稳定的Java数据库连接池,涵盖连接池配置、并发控制和异常处理等方面
本文介绍了如何构建高效稳定的Java数据库连接池,涵盖连接池配置、并发控制和异常处理等方面。通过合理配置初始连接数、最大连接数和空闲连接超时时间,确保系统性能和稳定性。文章还探讨了同步阻塞、异步回调和信号量等并发控制策略,并提供了异常处理的最佳实践。最后,给出了一个简单的连接池示例代码,并推荐使用成熟的连接池框架(如HikariCP、C3P0)以简化开发。
75 2
|
3月前
|
Java
Java 中锁的主要类型
【10月更文挑战第10天】
|
3月前
|
Java
【编程进阶知识】揭秘Java多线程:并发与顺序编程的奥秘
本文介绍了Java多线程编程的基础,通过对比顺序执行和并发执行的方式,展示了如何使用`run`方法和`start`方法来控制线程的执行模式。文章通过具体示例详细解析了两者的异同及应用场景,帮助读者更好地理解和运用多线程技术。
46 1
|
4月前
|
Java API 容器
JAVA并发编程系列(10)Condition条件队列-并发协作者
本文通过一线大厂面试真题,模拟消费者-生产者的场景,通过简洁的代码演示,帮助读者快速理解并复用。文章还详细解释了Condition与Object.wait()、notify()的区别,并探讨了Condition的核心原理及其实现机制。
|
4月前
|
算法 Java 关系型数据库
Java中到底有哪些锁
【9月更文挑战第24天】在Java中,锁主要分为乐观锁与悲观锁、自旋锁与自适应自旋锁、公平锁与非公平锁、可重入锁以及独享锁与共享锁。乐观锁适用于读多写少场景,通过版本号或CAS算法实现;悲观锁适用于写多读少场景,通过加锁保证数据一致性。自旋锁与自适应自旋锁通过循环等待减少线程挂起和恢复的开销,适用于锁持有时间短的场景。公平锁按请求顺序获取锁,适合等待敏感场景;非公平锁性能更高,适合频繁加解锁场景。可重入锁支持同一线程多次获取,避免死锁;独享锁与共享锁分别用于独占和并发读场景。