美文网首页
Java并发 --- 锁相关问题

Java并发 --- 锁相关问题

作者: _code_x | 来源:发表于2021-04-27 21:22 被阅读0次

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

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

    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 很难得知具体哪些锁对象是由特定线程持有的。

    巨人的肩膀:

    https://zhuanlan.zhihu.com/p/71156910#ref_1
    https://zhuanlan.zhihu.com/p/50098743

    相关文章

      网友评论

          本文标题:Java并发 --- 锁相关问题

          本文链接:https://www.haomeiwen.com/subject/mfgprltx.html