Java并发--锁

volatile关键字

volatile可以保证变量的可见性,如果我们将变量声明为volatile,那么每次使用它都在主存内进行读取,保证其他线程能准确无误的读取到本线程对变量的修改。

底层:

volatile关键字修饰变量的时候,本线程对变量进行操作的时候,会插入一个内存屏障,会使当前处理器缓存中的数据强制写回主存,然后其他线程(处理器)中的缓存失效 ,如果还需要操作变量,需要从主存中重新读取,这样就保证了变量的可见性,也就是他线程能准确无误的读取到本线程对变量的修改。同时也会禁止指令重排序。

synchronized 关键字

如何使用

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法

  2. 修饰静态方法

  3. 修饰代码块

1、修饰实例方法 (锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

复制代码
synchronized void method() {
    //业务代码
}

此时,synchronized加锁的对象就是这个方法所在实例的本身。

2、修饰静态方法 (锁当前类)

给当前类加锁,会作用于这个类的所有对象实例

这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

复制代码
synchronized static void method() {
    //业务代码
}

3、修饰代码块 (锁指定对象/类)

对括号里指定的对象/类加锁:

  • synchronized(object) 给对象加锁

  • synchronized(类.class) 给类加锁

复制代码
synchronized(this) {
    //业务代码
}

synchronized关键字是如何对一个对象加锁实现代码同步的呢?

synchronized原理 Java对象头和Monitor

在JVM中,对象在内存中存储的布局可以分为三个区域,分别是对象头、实例数据以及填充数据。

  • 实例数据 存放类的属性数据信息,包括父类的属性信息,这部分内存按4字节对齐。

  • 填充数据 由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

  • 对象头 在HotSpot虚拟机中,对象头又被分为两部分,分别为:Mark Word(标记字段)、Class Pointer(类型指针)。如果是数组,那么还会有数组长度。对象头是本章内容的重点,下边详细讨论。

对象头

在对象头的Mark Word中主要存储了对象自身的运行时数据,例如哈希码、GC分代年龄、锁状态、线程持有的锁、偏向线程ID以及偏向时间戳等。同时,Mark Word也记录了对象和锁有关的信息。

当对象被synchronized关键字当成同步锁时,和锁相关的一系列操作都与Mark Word有关。Mark Word在不同锁状态下存储的内容有所不同。

GC标记是垃圾回收机制

可以看到重量级锁对象头的MarkWord中存储了指向Monitor对象的指针,那么什么是Monitor?

Monitor对象

Monitor对象被称为管程或者监视器锁。在Java中,每一个对象实例都会关联一个Monitor对象。这个Monitor对象既可以与对象一起创建销毁,也可以在线程试图获取对象锁时自动生成。当这个Monitor对象被线程持有后,它便处于锁定状态。

Monitor是由ObjectMonitor实现的,它是一个使用C++实现的类,主要数据结构如下:

复制代码
ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;  // 线程重入次数
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; // 调用wait方法后的线程会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; // 阻塞队列,线程被唤醒后根据决策判读是放入cxq还是EntryList
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 没有抢到锁的线程会被放到这个队列
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }
​

ObjectMonitor中有五个重要部分,分别为ower, WaitSet,*cxq,*EntryList和count。

  • _ower 用来指向持有monitor的线程,它的初始值为NULL,表示当前没有任何线程持有monitor。当一个线程成功持有该锁之后会保存线程的ID标识,等到线程释放锁后_ower又会被重置为NULL;

  • _WaitSet 调用了锁对象的wait方法后的线程会被加入到这个队列中;如果调用锁对象的wait()方法,线程会释放当前持有的monitor,并将owner变量重置为NULL,且count减1,同时该线程会进入到_WaitSet集合中等待被唤醒。

  • _cxq 是一个阻塞队列,线程被唤醒后根据决策判读是放入cxq还是EntryList;

  • _EntryList 没有抢到锁的线程会被放到这个队列;

  • count 用于记录线程获取锁的次数,成功获取到锁后count会加1,释放锁时count减1。

synchronized底层实现原理
修饰对象

通过javap -v来反汇编下面的一段代码。

复制代码
java复制代码public void add() {
    synchronized (this) {
        i++;
    }
}

可以得到如下的字节码指令:

复制代码
java复制代码public class com.zhangpan.text.TestSync {
  public com.zhangpan.text.TestSync();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return
​
  public void add();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter    // synchronized关键字的入口
       4: getstatic     #2                  // Field i:I
       7: iconst_1
       8: iadd
       9: putstatic     #2                  // Field i:I
      12: aload_1
      13: monitorexit  // synchronized关键字的出口
      14: goto          22
      17: astore_2
      18: aload_1
      19: monitorexit // synchronized关键字的出口
      20: aload_2
      21: athrow
      22: return
    Exception table:
       from    to  target type
           4    14    17   any
          17    20    17   any
}

从字节码指令中可以看到add方法的第3条指令处和第13、19条指令处分别有monitorenter和moniterexit两条指令。另外第4、7、8、9、13这几条指令其实就是i++的指令。***由此可以得出在字节码中会在同步代码块的入口和出口加上monitorenter和moniterexit指令。***当执行到monitorenter指令时,线程就会去尝试获取该对象对应的Monitor的所有权,即尝试获得该对象的锁。

当对象的Monitor的计数器count为0 的时候,线程可以取得Monitor,并将count设置为1,即获得该对象的锁 ,表示只有这个线程可以对该对象进行操作。如果当前线程已经拥有该对象monitor的持有权,那它可以重入这个 monitor ,计数器的值也会加 1。而当执行monitorexit指令时,锁的计数器会减1。

如果对象的Monitor的count为1时,那么当前线程获取锁失败将被阻塞并进入到_EntryList中,直到等待的锁被释放为止。也就是说,当所有相应的monitorexit指令都被执行,计数器的值减为0,执行线程将释放 monitor(锁),其他线程才有机会持有 monitor 。

流程
synchronized 修饰方法
复制代码
public class SynchronizedDemo2 {
    public synchronized void method() {
        System.out.println("synchronized 方法");
    }
}

synchronized关键字原理

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。

锁的升级

无锁->偏向锁->轻量级锁->重量级锁

锁只能由这个方向进行升级,不可以逆着来。

偏向锁

由于大部分情况下啊,一般都是同一线程反复获得锁,每一次获得锁的过程中,需要获得锁和解锁的操作,所以,如果只有一个线程的话,那可以创建一种锁,当线程获得锁之后,就不进行解锁操作了,每次这个线程进入或退出同步快的时候,只需要比较下线程id就可以了

偏向锁加锁

当一个线程访问同步块的时候(同一时刻只有一个线程能执行同步块之中的代码),会在对象头和栈帧中存储锁偏向的线程id,如果该线程再进入的时候,只需要比较下线程id就可以。

偏向锁撤销
  • 如果原来持有偏向锁线程已经 退出同步代码块或者已经死亡,那么偏向锁可以直接撤销,转变为无锁状态。

  • 如果还在代码块之中,那么偏向锁升级为轻量级锁,原来的线程仍持有锁,其他进程需要cas,来竞争锁

优点

保证在只有一个线程的情况下,线程获得锁之后,该线程进入或退出同步代码块不需要进行cas操作,如果有多个锁竞争,那么锁会进行升级。

轻量级锁

轻量级锁是一种Java中实现线程同步的锁,它比重量级锁更高效,但也需要一定的条件才能使用。轻量级锁的加锁和撤销过程如下:

加锁过程

当一个线程要执行同步代码块时,它会先在自己的栈帧中创建一个锁记录,然后把对象头中的Mark Word复制到锁记录中,这叫做Displaced Mark Word。接着,线程会用CAS操作尝试把对象头中的Mark Word替换成指向锁记录的指针。如果成功,说明线程获得了轻量级锁,可以继续执行同步代码块。如果失败,说明有其他线程也在竞争这个锁,那么当前线程就会进行自旋,即不断重试CAS操作,直到成功或者超过一定次数。

撤销过程:

当一个线程执行完同步代码块时,它会用CAS操作尝试把对象头中的Mark Word恢复成原来的Displaced Mark Word。如果成功,说明没有其他线程竞争该锁,轻量级锁就被释放了。如果失败,说明有其他线程在自旋等待该锁,那么当前线程就会通知其他线程停止自旋,然后把锁升级为重量级锁,释放该锁后唤醒一个等待的线程。

具体CAS过程

CAS(Compare And Swap)是一种原子操作,它可以保证多个线程同时对同一个内存地址进行更新时的正确性。CAS操作需要三个参数:内存地址、期望值和新值。

CAS操作的过程是这样的:

  • 首先,从内存地址中读取当前值,与期望值进行比较,如果相等,说明没有其他线程修改过该内存地址,那么就用新值替换当前值,并返回成功。

  • 如果不相等,说明有其他线程修改过该内存地址,那么就放弃替换,并返回失败。

在轻量级锁的加锁和撤销过程中,CAS操作的内存地址就是对象头中的Mark Word,期望值就是Displaced Mark Word,新值就是指向锁记录的指针或者原来的Displaced Mark Word。CAS操作可以保证只有一个线程能成功获取或释放轻量级锁,其他线程则需要自旋或者阻塞。

轻量级锁优缺点

轻量级锁的优点是可以避免线程的阻塞和切换,因为得不到锁的线程不会被挂起,而是进行自旋 ,如果锁释放,可以第一时间知道,提高程序的响应速度。轻量级锁的缺点是如果一直不能获取到锁,长 时间的自旋会造成CPU消耗。轻量级锁适用于少量线程竞争锁对象,且线程持有锁的时间不长,追求响应速度的场景。

#总结

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

不过两者的本质都是对对象监视器 monitor 的获取。

乐观锁和悲观锁

乐观锁和悲观锁是在并发编程中用于处理共享资源的两种不同的锁策略。

  • 乐观锁:乐观锁是一种乐观的假设,认为并发操作不会产生冲突,因此在读取和修改共享资源时不会加锁。它使用一种称为比较并交换(Compare and Swap,CAS)的机制来确保数据的一致性。在乐观锁中,当对共享资源进行修改时,会先读取当前值并进行比较,如果值没有被其他线程修改,则进行更新;否则,采取相应的冲突处理策略,如重试或放弃操作。

  • 悲观锁是一种悲观的假设,认为并发操作会产生冲突,因此在读取和修改共享资源时会加锁。在悲观锁中,当一个线程获取到锁后,其他线程需要等待锁的释放才能访问共享资源。 悲观锁通常使用互斥锁(如 synchronized 关键字)来保护共享资源,确保同一时间只有一个线程可以访问。

优缺点
  • 乐观锁的优点在于它能够提供更好的并发性能,因为大部分时间不需要加锁,减少了线程的等待时间。然而,乐观锁需要依赖于CAS等机制来确保数据的一致性,而CAS操作可能会失败并导致重试,增加了一定的开销。乐观锁适用于读多写少的场景,即并发冲突较少的情况。
GPT解读
讲讲乐观锁和悲观锁

悲观锁和乐观锁是数据库用来保证数据并发安全防止更新丢失的两种方法¹。它们的区别在于对数据冲突的假设和处理方式不同。

悲观锁是一种预防数据冲突的锁,它总是假设最坏的情况,认为别人会同时修改数据。因此,操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据²。悲观锁的实现方式是加锁,加锁既可以是对代码块加锁(如Java的synchronized关键字),也可以是对数据加锁(如MySQL中的排它锁)²。

乐观锁是一种并发类型的锁,它总是假设最好的情况,认为别人不会同时修改数据。因此,操作数据时不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据,如果没有修改则执行操作,如果有修改则放弃操作或者重试²。乐观锁的实现方式主要有两种:CAS机制和版本号机制²。CAS机制是利用CPU提供的原子操作来比较和交换内存中的值,版本号机制是在数据表中增加一个版本字段,每次更新数据时比较和更新版本号。

悲观锁和乐观锁各有优缺点,不能一概而论说一种锁好于另一种锁。一般来说,悲观锁可以完全保证数据的独占性和正确性,但是会造成锁的开销和等待的代价,降低了并发性和性能²。乐观锁可以提高并发性和性能,但是会增加重试的开销和复杂度,而且在高冲突的场景下,成功率会降低²。因此,选择悲观锁还是乐观锁,要根据具体的场景和需求来决定。

一般来说,如果需要非常高的响应速度,建议采用乐观锁方案,成功就执行,不成功就失败,不需要等待其他并发去释放锁¹。如果冲突频率非常高,建议采用悲观锁,保证成功率,如果冲突频率大,乐观锁会需要多次重试才能成功,代价比较大¹。如果对数据的一致性要求非常高,也建议采用悲观锁,避免数据的不一致和脏读²。

如果读操作更多一些,用那种锁?

如果读操作多一些,一般来说,乐观锁比悲观锁更合适。因为乐观锁不会在读操作时加锁,所以可以提高读的并发性和性能,而且避免了锁的开销和等待的代价¹²。当然,这也要取决于数据的冲突频率和一致性要求,如果冲突频率很高或者一致性要求很高,那么悲观锁可能更好,因为它可以保证数据的独占性和正确性,而且避免了重试的开销和复杂度²³。所以,具体的场景还是要根据具体的需求和测试来决定使用哪一种锁。

反之?

如果写操作多一些,一般来说,悲观锁比乐观锁更合适。因为悲观锁可以保证写操作时数据的独占性和正确性,而且避免了重试的开销和复杂度¹²。当然,这也要取决于数据的冲突频率和一致性要求,如果冲突频率很低或者一致性要求不高,那么乐观锁可能更好,因为它可以提高并发性和性能²³。所以,具体的场景还是要根据具体的需求和测试来决定使用哪一种锁。😊

ReentrantLock

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

ReentrantLock 里面有一个内部类 SyncSync 继承 AQS(AbstractQueuedSynchronizer ,添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。

ReentrantLock 默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。

复制代码
// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
公平锁和非公平锁有什么区别?
  • 公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。

  • 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。

synchronized 和 ReentrantLock 有什么区别?
[#](# 两者都是可重入锁) 两者都是可重入锁

可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。

JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。

在下面的代码中,method1()method2()都被 synchronized 关键字修饰,method1()调用了method2()

复制代码
public class SynchronizedDemo {
    public synchronized void method1() {
        System.out.println("方法1");
        method2();
    }
​
    public synchronized void method2() {
        System.out.println("方法2");
    }
}

由于 synchronized锁是可重入的,同一个线程在调用method1() 时可以直接获得当前对象的锁,执行 method2() 的时候可以再次获取这个对象的锁,不会产生死锁问题。假如synchronized是不可重入锁的话,由于该对象的锁已被当前线程所持有且无法释放,这就导致线程在执行 method2()时获取锁失败,会出现死锁问题。

synchronized是一个关键字 JVM层面 | | ReentrantLock是由Api(接口)实现
synchronized是隐式锁,而ReentrantLock是显式锁

隐式锁可以自动给释放锁,显式锁需要去手动释放锁。ReentrantLock需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成

相对来说 新功能

等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情

实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来指定是否是公平的。

可实现选择通知ReentrantLock结合Condition类可以实现的一种高级功能,它可以让线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活¹²。例如,如果您有一个生产者-消费者模型,您可以为生产者和消费者分别创建不同的Condition,然后根据队列的状态来决定唤醒哪个Condition下的线程³。这样,您就可以避免使用notifyAll方法来唤醒所有的线程,从而提高效率和性能。synchronized关键字与wait()notify()/notifyAll()方法相结合可以实现等待/通知机制

可中断锁 || 不可中断锁
  • 可中断锁 :获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。

  • 不可中断锁 :一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

相关推荐
purpleseashell_Lili6 分钟前
react 基本写法
java·服务器·前端
咕噜咕噜啦啦21 分钟前
Python爬虫入门
开发语言·爬虫·python
oneDay++28 分钟前
# IntelliJ IDEA企业版高效配置指南:从主题到快捷键的终极优化
java·经验分享·intellij-idea·学习方法
dubochao_xinxi29 分钟前
✅ TensorRT Python 安装精简流程(适用于 Ubuntu 20.04+)
开发语言·python·ubuntu
感谢地心引力33 分钟前
【Matlab】最新版2025a发布,深色模式、Copilot编程助手上线!
开发语言·windows·matlab·copilot
Jasmin Tin Wei35 分钟前
idea中的vcs不见了,如何解决
java·ide·intellij-idea
Java程序员-小白1 小时前
使用java -jar命令指定VM参数-D运行jar包报错问题
java·开发语言·jar
ClearViper32 小时前
Java的多线程笔记
java·开发语言·笔记
敷啊敷衍2 小时前
深入探索 C++ 中的 string 类:从基础到实践
开发语言·数据结构·c++
学地理的小胖砸2 小时前
【Python 面向对象】
开发语言·python