Java中的锁

一、乐观锁和悲观锁

1、悲观锁

悲观锁: 认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改,synchronized和Lock的实现类都是悲观锁,适合写操作多的场景,先加锁可以保证写操作时数据正确,显示的锁定之后再操作同步资源-----狼性锁

2、乐观锁

乐观锁: 认为自己在使用数据的时候不会有别的线程修改数据或资源,不会添加锁,Java中使用无锁编程来实现,只是在更新的时候去判断,之前有没有别的线程更新了这个数据,如果这个数据没有被更新,当前线程将自己修改的数据成功写入,如果已经被其他线程更新,则根据不同的实现方式执行不同的操作,比如:放弃修改、重试抢锁等等。判断规则有:版本号机制Version,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。-----适合读操作多的场景,不加锁的特性能够使其读操作的性能大幅提升,乐观锁则直接去操作同步资源,是一种无锁算法,得之我幸不得我

🌟概念区别
特性 乐观锁(Optimistic Lock) 悲观锁(Pessimistic Lock)
思想 认为并发冲突很少,操作时先不加锁,提交时再检测是否有冲突。 认为并发冲突很常见,操作前先加锁,确保别人无法同时修改。
实现方式 版本号机制时间戳机制:更新前先比较版本,不一致则更新失败。 数据库锁机制 :如 SELECT...FOR UPDATE,或者 synchronized / ReentrantLock
性能影响 冲突少时,性能很好;冲突多时,频繁重试会影响性能。 并发高时,线程等待多,吞吐量较低。
应用场景 读多写少,冲突概率低的场景。 写多,冲突概率高的场景。
🔥实际案例

乐观锁:

  1. 数据库表有一个 version 字段。

  2. 取数据时,连同 version 一起读出。

  3. 更新时,使用:

    java 复制代码
    UPDATE table_name SET value = ?, version = version + 1 WHERE id = ? AND version = ?;
  4. 如果 version 不一致,说明有别的线程修改了,更新失败,可以重试。


悲观锁:

  1. 数据库级别:

    java 复制代码
    SELECT * FROM table_name WHERE id = ? FOR UPDATE;

    查询时就加锁,其他事务不能修改,等当前事务提交/回滚后才能解锁。

  2. Java并发:

    java 复制代码
    synchronized (obj) { // 临界区代码 }
    java 复制代码
    Lock lock = new ReentrantLock(); lock.lock();
     try   { // 临界区代码 } 
    finally { lock.unlock(); }

总结一句话:
  • 乐观锁:适合冲突很少的业务,提升并发性能,失败时重试。

  • 悲观锁:适合冲突很频繁的业务,直接锁住资源,确保安全但牺牲并发。

二、synchronized关键字分析

1、阿里Java规范:

高并发时,同步调用应该去考置锁的性能损耗。能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个方法体﹔能用对象锁,就不要用类锁。

说明︰尽可能使加锁的代码块工作量尽可能的小,避免在锁代码块中调用RPC方法。

2、案例分析
java 复制代码
/**
 * @author Guanghao Wei
 * @create 2023-04-10 14:57
 */

class Phone {
    public synchronized void sendEmail() {
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("------sendEmail");
    }

    public synchronized void sendSMS() {
        System.out.println("------sendSMS");
    }

    public void hello() {
        System.out.println("------hello");
    }
}

/**
 * 现象描述:
 * 1 标准访问ab两个线程,请问先打印邮件还是短信? --------先邮件,后短信  共用一个对象锁
 * 2. sendEmail钟加入暂停3秒钟,请问先打印邮件还是短信?---------先邮件,后短信  共用一个对象锁
 * 3. 添加一个普通的hello方法,请问先打印普通方法还是邮件? --------先hello,再邮件
 * 4. 有两部手机,请问先打印邮件还是短信? ----先短信后邮件  资源没有争抢,不是同一个对象锁
 * 5. 有两个静态同步方法,一步手机, 请问先打印邮件还是短信?---------先邮件后短信  共用一个类锁
 * 6. 有两个静态同步方法,两部手机, 请问先打印邮件还是短信? ----------先邮件后短信 共用一个类锁
 * 7. 有一个静态同步方法 一个普通同步方法,请问先打印邮件还是短信? ---------先短信后邮件   一个用类锁一个用对象锁
 * 8. 有一个静态同步方法,一个普通同步方法,两部手机,请问先打印邮件还是短信? -------先短信后邮件 一个类锁一个对象锁
 */

public class Lock8Demo {
    public static void main(String[] args) {
        Phone phone = new Phone();
        new Thread(() -> {
            phone.sendEmail();
        }, "a").start();

        try {
            TimeUnit.MILLISECONDS.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        new Thread(() -> {
            phone.sendSMS();
        }, "b").start();
    }

}

第1 第2中案例说明,只要我的一个类中有方法加了synchronized 关键字,这个synchronized 锁的并不是当前该方法,而是整个资源类,也就是说可能该类有多个方法都加了synchronized 关键字,但是多线程的环境中,只有一个线程能够进入众多加了synchronized 方法中的一个方法。然后依次排队。换句话说,某一个时间内,只能有唯一的一个线程去访问这些synchronized 方法,锁的是当前对象this,被锁定后,其他的线程都不能进入到当前对象的其他的synchronized 方法。

第3个案例说明,普通方法不需要竞争锁,直接执行。

第4个案例说明,对于普通的方法加上了synchronized关键字,我们锁的是当前实例对象(this),如果有两个不同的实例对象,等于说不同的资源,所以也不会竞争锁。

第5 第6种案例说明,如果在静态方法上加锁,那么锁的是类,也就是说不管你有多少个实例,但其实都是同一个类,所以不同的实例也会被锁住。

第7 第8种案列说明,静态方法上加synchronized代表类锁,普通方法加锁是实例锁,这两种锁不产生冲突,不会互相竞争。

3、synchronized的三种应用方式:

1)作用于实例方法,当前实例加锁,进入同步代码前要获得当前实例的锁;
2)作用于代码块,对括号里配置的对象加锁。
3)作用于静态方法,当前类加锁,进去同步代码前要获得当前类对象的锁;

第一 第三种跟上述案例分析的是一样的,着重说一下第二中静态代码块:

对于代码块:

代码块的锁也有两种,一种是synchronized中加一个实例对象,一种是加类.class.这两种锁也分别代表实例锁跟类锁。

⚙️重点区别:

场景 锁对象 影响范围
synchronized(obj) 普通对象实例 obj 同一个对象的线程互斥,不同对象互不影响。
synchronized(类名.class) 类的Class对象(全局唯一) 所有线程,不管用哪个对象实例,都会互斥。

📌形象解释:

1️⃣ synchronized(obj):

假如你 new 出很多个对象,每个对象自己用synchronized(this),那每个对象都像是自己的小屋,互不干扰。

2️⃣ synchronized(类名.class):

锁住的是整个类,哪怕不同的对象实例,线程也必须抢同一个锁,类似一个"工厂总门",只有拿到钥匙的人才能进去,别人都得等。

🌰举个例子:

java 复制代码
class MyClass {

    public void instanceMethod() {
        synchronized(this) {
            System.out.println("对象锁:锁住的是当前实例");
        }
    }

    public static void staticMethod() {
        synchronized(MyClass.class) {
            System.out.println("类锁:锁住的是整个类");
        }
    }
}
  • synchronized(this):只会锁住这个对象实例,不同实例之间互不干扰。

  • synchronized(MyClass.class):锁住整个类,无论用哪个对象调用这个代码,只要一个线程进了,其他线程必须等!

4、解释分析

在java虚拟机种,class loader类加载器把 Car.class文件读进来,Car class就是类锁,这个就是模板,由一份模板可以生成 car1、car2、car3 三个实例对象,这是三个不同的对象但是均来自于一个模板。所以类锁对应的就是Car Class,在方法区中有且仅有一份,但是对于我们的对象锁,new出来的实例对象,在jvm的堆中。所以类锁跟对象锁,加锁的对象跟地方都不一样,自然就会产生不同的效果。

5、从字节码角度分析synchronized实现

从字节码角度分析,需要借助两个命令:

javap -c ***.class 对代码进行反编译

javap -v ***.calss 对文件进行反编译,但是会输出更多附加信息(包括行号、本地变量表、反汇编等详细信息)

1)使用javap -c 反编译一个同步代码块的class文件:

代码如下:

java 复制代码
    public void m1() {
        synchronized(object) {
            System.out.println("----hello synchronized code block");
        }
    }

输出如下:

上图就是改代码反编译后的源码,可以看到编译后的代码中,进入m1方法后,monitorenter代表获得锁并进入,正常执行完代码后monitorexit 代表释放锁退出。

所以可以得出结论对于java synchronized同步代码块,底层是靠monitorenter和monitorexit指令来保证锁的获取和释放。

第二个 monitorexit 代表有异常发生时,也会正常释放锁。正常情况下走前面的monitorexit,异常情况走后面的monitorexit。极端情况下,也会出现一对一的情况,在退出同步代码前抛出异常,此时就是一对一的情况,因为就没有正常情况了,无论那种情况都会抛出异常。

2)使用javap -v 反编译一个加了synchronized 普通同步方法的class文件:

因为需要看到详细的内容,所以使用javap -v.

代码如下:

java 复制代码
    public synchronized void m2() {
           System.out.println("----hello synchronized code block");
    }

反编译后的代码如下所示:

根据反编译后的代码可以代码可以发现,只要方法上加了synchronized关键字,该方法上面就会加一个 ACC_SYNCHRONIZED的标识 ,java虚拟机它来读这些字节码,发现有这个标识他就会这个方法加锁,保证只能有一个线程访问。

调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程会将现持有monitor锁,然后再执行该方法,最后在方法完成(无论是否正常结束)时释放monitor

3)使用javap -v 反编译一个 加了synchronized静态同步方法的class文件:

可以发现,静态同步方法反编译后也加上了 ACC_SYNCHRONIZED 标识, 但是除了ACC_SYNCHRONIZED 标识,还加上了一个ACC_STATIC 标识,这个标识就是区分类锁 和 对象锁的标识。

ACC_STATIC、ACC_SYNCHRONIZED访问标志区分该方法是否是静态同步方法

6、反编译synchronized锁的是什么

为什么任何一个对象都可以成为一个锁?

6.1 首先解释这个问题需要先明白什么是管程(Monitors)?

概念层面: 管程(Monitor)是一种高层同步原语 ,可以看成是操作系统或程序语言中,专门用来解决多线程安全访问共享资源问题的一个抽象结构。

它的主要作用是:在多线程并发访问共享资源 时,确保同一时间只有一个线程能够访问 ,并通过条件变量机制协调线程之间的执行顺序。

结构层面:管程 = 锁 + 条件队列 + 资源

一个完整的管程由三部分组成:

组成部分 作用
互斥锁(Lock) 保证在同一时刻,只有一个线程能访问共享资源。
条件变量(Condition Variable) 用来实现线程间的等待/通知机制(wait/notify)。
共享资源(Shared Resource) 被多个线程并发访问的资源,管程保护的核心对象。

通俗理解:

1)管程就像一个房间

2)共享资源放在房间里;

3)房间只有一把锁 ,同一时刻只能让一个线程进来,其他线程要么排队(进入互斥队列),要么主动 wait() 等待某个条件满足;

3)当条件满足,调用 notify()notifyAll(),唤醒等待的线程。

所以,管程不仅负责互斥访问 ,还负责线程协作

**但其实上述的理解还是有点抽象,我们可以做一个形象的比喻:
首相我们先捋一下涉及到的部分:

  1. 管程的3个组成部分:** 互斥锁(Lock)、条件变量(Condition Variable)、共享资源(Shared Resource)构成了管程的核心框架。
    2)管程中提供的三个方法: wait()、notify()、notifyAll()管程提供的方法,用来配合条件变量完成"等待"和"唤醒"的逻辑。

我们要先理解一下这三个组成部分跟这三个方法之间是怎么协同工作的:

场景:食堂窗口打饭系统:

想象一个学校食堂窗口,学生排队打饭,窗口一次只能接待一个人,而且饭要做好才能领!

各元素比喻:

Java并发概念 食堂比喻
共享资源(Shared Resource) 食堂窗口里的热饭,所有人都想要。
互斥锁(Lock) 打饭窗口------同一时间只能一个人站在窗口打饭。
条件变量(Condition Variable) 窗口旁边的"等待通知区",专门给没饭的人等待使用。
wait() 饭没好,站在窗口的人去"等待通知区"排队等消息,并让出窗口。
notify() 饭做好了,服务员喊:"下一位可以来打饭啦!"
notifyAll() 饭做好了,服务员喊:"所有等着的同学都可以过来了!"

一个完整的流程:

1️⃣ 互斥访问资源:

  • 同学想打饭(线程访问临界区)。

  • 需要先站到窗口(获取锁)。

  • 窗口一次只能有一个人(锁的互斥性)。


2️⃣ 条件不满足,等待:

  • 如果饭还没做好(共享资源不可用)。

  • 服务员会说:"去旁边等,等我叫你再来。"(调用 wait())。

  • 同学自动走到"等待通知区",并且让出窗口(释放锁)。

  • 别的同学可以来尝试排队。


3️⃣ 资源可用,通知唤醒:

  • 饭终于做好了,服务员喊:"可以打饭啦!"

    • 如果用 notify():随机叫醒一个等待通知区的同学。

    • 如果用 notifyAll():通知所有等待区的人都可以回来打饭了。


4️⃣ 重新竞争锁:

  • 被叫到的同学们重新排队,等待窗口空出来(重新竞争锁)。

  • 谁先站上窗口(获取锁),谁先打饭。


5️⃣ 正常打饭:

  • 打完饭,离开窗口(释放锁),其他同学继续排队。

🎯 总结一句话:

共享资源 = 热饭,大家都想要。
= 窗口,防止多人同时打饭。
条件变量 = 旁边的等待通知区,饭没好时在这排队。
wait() = 主动离开窗口,去等通知区排队,释放窗口。
notify() = 叫醒一个排队的同学,回来尝试打饭。
notifyAll() = 叫醒所有排队的同学,大家抢着回来排队打饭。


🧠 💡 精髓记忆法:

复制代码
没有锁 → 没法进食堂;
饭没好 → 被服务员赶去“等待区” → wait();
饭好了 → 服务员喊人 → notify() / notifyAll();
被喊到的人 → 重新去排队 → 获取锁;
打到饭的人 → 走人,释放锁;
其他人 → 继续排队,循环往复。

所以管程包含了互斥访问 + 等待通知机制,帮我们自动完成排队、等待和唤醒。

**管程(Monitors):**可以看做一个软件模块,它是将共享的变量和对于这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,进程可以调用管程来实现进程级别的并发控制。

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,更常见的是直接将它称为"锁")来实现的。

方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为同步方法。当方法调用时,调用指令将会检查方法的

ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。

6.2 虚拟机中是如何实现的

如果要弄清楚虚拟机中的synchronized是如何实现的,需要先弄清楚几个类的定义:

Object.java
ObjectMonitor
ObjectMonitor.cpp
ObjectMonitor.hpp

1)Object 首先object是所有类的根类,所有类默认都继承它,但在底层,它不仅仅是一个类名,而是再内存中的一段数据结构。

JVM中Object的内存结构:
注意这是在底层jvm中的结构,并不是java代码中类结构。

┌─────────────────────┐

│ 对象头 (Object Header) │ ←重点!锁信息、GC信息都在这里

├─────────────────────┤

│ 实例数据 (Instance Data) │

├─────────────────────┤

│ 对齐填充 (Padding) │

└─────────────────────┘

对象头 (Object Header) :一般有两部分组成

部分 描述
Mark Word 存储运行时数据(锁状态、哈希码、GC标记等)。
Klass Pointer 指向类元数据的指针,说明对象的类型(对应哪个Java类)。

注意:我们所要了解的锁的相关信息就存储在 Mark Word 中!

实例数据(Instance Data):存储对象的真正字段内容,例如:

java 复制代码
public class Person {
    int age;
    String name;
}

实例数据就会包含 agename 的具体值,按照 JVM 对齐规则排列。

填充(Padding): 为了让对象的内存地址按照 8 字节或 16 字节对齐,JVM会根据实际情况补充空白字节,以提升 CPU 访问效率。

这是我们最基础的object的结构。

2)ObjectMonitor

ObjectMonitor 并不是java内部的类,而是底层HotSpot JVM内部实现的锁对象,它是使用c++代码实现的。也称为独立的对象监视器。

Object 跟 ObjectMonitor 的关系:

首先Java 中的Object 对象跟ObjectMonitor是一一对应的关系, 但是并不是在Java代码中的一一对应,而是当创建了一个对象时,在JVM的底层会有一个ObjectMonitor与Object对象 进行对应。

上面我们介绍过Object在jvm中的结构是 对象头 (Object Header)、实例数据 (Instance Data) 、对齐填充 (Padding)吗,那我们ObjectObjectMonitor 对应就是通过对象头中的Mark Word来进行关联的。

Mark Word

  • 如果对象处于无锁状态,Mark Word 存储哈希码。

  • 如果被线程加锁,Mark Word 存储线程ID或 ObjectMonitor 地址。

  • 如果对象被GC标记,Mark Word 存储 GC 标记位。

关系图:

ObjectMonitor 的作用:

ObjectMonitor 是 JVM 底层负责线程等待、唤醒、排队看门人,意思就是说 Java 对象本身不执行加锁逻辑,真正的加锁控制是由 ObjectMonitor 完成的。当锁升级为"重量级锁"Mark Word 中会保存一个指针,指向一个 ObjectMonitor 对象

重量级锁 涉及到锁的状态,下面详细说。

总之我们可以总结一下,在java层面使用 synchronized(obj) 关键,进行加锁时,加锁的操作并不是在java层面实现的,而是在jvm的底层 ObjectMonitor 来实现线程之间的等待、唤醒、排队的功能,所以可以说**ObjectMonitor是主要负责加锁相关逻辑的实现的。**

ObjectMonitor 的结构:

ObjectMonitor其主要数据结构如下(hotspot源码ObjectMonitor.hpp):

java 复制代码
ObjectMonitor() {
    _header = NULL; //对象头 markOop
    _count = 0;
    _waiters = 0,
    _recursions = 0; // 锁的重入次数
    _object = NULL; //存储锁对象
    _owner = NULL; // 标识拥有该monitor的线程(当前获取锁的线程)
    _WaitSet = NULL; // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点
    _WaitSetLock = 0;
    _Responsible = NULL ;
    _succ = NULL ;
    _cxq = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构)
    FreeNext = NULL ;
    _EntryList = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失败的线程)
    _SpinFreq = 0;
    _SpinClock = 0;
    OwnerIsThread = 0;
    _previous_owner_tid = 0;
}

ObjectMonitor的基本工作机制:

1 当多个线程同时访问一段同步代码时,首先会进入 _EntryList 队列中。

2 当某个线程获取到对象的Monitor后进入临界区域,并把Monitor中的 _owner 变量设置为当前线程,同时Monitor中的计数器 _count 加1。即获得对象锁。

3 若持有Monitor的线程调用 wait() 方法,将释放当前持有的Monitor,_owner变量恢复为null,_count自减1,同时该线程进入 _WaitSet 集合中等待被唤醒。

4 在_WaitSet 集合中的线程会被再次放到_EntryList 队列中,重新竞争获取锁。

5 若当前线程执行完毕也将释放Monitor并复位变量的值,以便其它线程进入获取锁

ObjectMonitor ObjectMonitor.cpp ObjectMonitor.hpp 这三个类之间的关系

这个涉及到C++编程的知识,我们只需要简单了解知道:

ObjectMonitor→ObjectMonitor.cpp→objectMonitor.hpp

ObjectMonitor.cpp、objectMonitor.hpp 是真正实现 ObjectMonitor 底层逻辑的代码。

名称 编写语言 作用
ObjectMonitor.hpp C++头文件 用C++语法声明 ObjectMonitor 类的结构、成员变量、方法接口。
ObjectMonitor.cpp C++源文件 用C++语法实现 ObjectMonitor 的方法,完成锁的加锁、解锁、等待、唤醒等行为。
ObjectMonitor 对象 JVM运行时实例 C++写好的 ObjectMonitor 类在JVM运行时根据需要创建的内存对象,管理线程同步。

管程与Object、ObjectMonitor ObjectMonitor.cpp ObjectMonitor.hpp的关系:

Object(Java对象)
ObjectMonitor(监视器对象)
ObjectMonitor.hpp/.cpp(实现代码)

管程是一种思想模型,Java对象通过和 ObjectMonitor 的组合,把管程的功能落地了,ObjectMonitor.hpp/.cpp 则是实现这个机制的 C++代码。

7)相关面试题

1 ObjectMonitor 的 _object 字段有什么作用?

答:_object 字段指向被锁定的 Java 对象。当我们使用 synchronized 关键字时,被锁定的对象就会与一个 ObjectMonitor 关联,_object 就指向这个对象。

2 _owner 字段记录的是什么?它有什么用?

答:_owner 字段记录当前持有锁的线程。它类似图书馆的借阅记录,记录着哪个线程正在使用这个锁。当一个线程成功获取锁时,_owner 就会被设置为这个线程;当锁被释放时,_owner 被清空。

3 _WaitSet 和 _EntryList 分别存放什么样的线程?它们有什么区别?

答:_WaitSet 存放调用了 wait() 方法的线程,这些线程在等待特定条件满足。它类似图书馆的休息区,读者(线程)在这里等待被通知。

_EntryList 存放正在竞争锁的线程。它类似图书馆门口的排队队伍,读者(线程)在这里排队等待获取锁。

它们的主要区别在于,_WaitSet 中的线程是主动释放锁并等待条件满足,而 _EntryList 中的线程是在竞争锁的过程中被阻塞。

4 _recursions 字段的作用是什么?为什么需要记录重入次数?

答:_recursions 字段记录锁的重入次数。重入是指同一个线程多次获取它已经持有的锁。_recursions 的作用就是跟踪同一线程重复获取锁的次数,类似记录同一读者多次借阅同一本书。

记录重入次数是为了支持锁的重入特性。因为 Java 的 synchronized 是可重入的,允许一个线程多次获取同一个锁。记录重入次数可以避免重入时出现死锁,同时也简化了编程。

5 当多个线程竞争同一个锁时,ObjectMonitor 是如何协调的?

答:当多个线程竞争锁时,ObjectMonitor 会按照如下方式协调:

如果锁是空闲的,第一个到达的线程会直接获得锁,_owner 被设置为这个线程,_recursions 设为 1。

如果锁已经被其他线程持有,到达的线程会被放入 _EntryList,并被挂起(park)。

当锁被释放时,_owner 被清空,_recursions 被重置为 0,_EntryList 中的一个线程会被唤醒并重新尝试获取锁。

如果一个线程在持有锁的情况下调用 wait(),它会被放入 _WaitSet,并释放锁。当它被 notify() 唤醒时,会重新进入 _EntryList 竞争锁。

6 ObjectMonitor 如何实现锁的重入?

答:ObjectMonitor 通过 _recursions 字段实现锁的重入。当一个已经持有锁的线程再次获取锁时,ObjectMonitor 会检查 _owner 是否为当前线程,如果是,就将 _recursions 加 1,允许线程重入。

当线程退出一层 synchronized 块时,_recursions 会减 1。只有当 _recursions 减为 0 时,锁才会被真正释放,其他线程才有机会获得锁。

7 ObjectMonitor 在 wait()/notify() 中扮演什么角色?

答:ObjectMonitor 的 _WaitSet 字段是 wait()/notify() 机制的核心。当一个线程调用 wait() 时,ObjectMonitor 会将这个线程移入 _WaitSet,并释放锁。这个线程会一直等待,直到其他线程调用 notify()。

当 notify() 被调用时,ObjectMonitor 会从 _WaitSet 中选一个线程,将其移入 _EntryList。这个线程then会重新参与锁的竞争。如果它成功获得锁,就会从 wait() 调用中返回,继续执行。

8 wait和sleep的区别?

wait()方法属于Object类;sleep()方法属于Thread类的静态方法;

wait()方法让自己让出锁资源进入等待池等待,直接让出CPU,后续要继续竞争monitor锁才能可运行;sleep是继续占用锁(依赖于系统时钟和CPU调度机制),会让出CPU;

sleep()必须指定时间,wait()可以指定时间也可以不指定;sleep()时间到,线程处于可运行状态,超时或者interrupt()能唤醒

wait()方法只能在同步方法或同步代码块中调用,否则会报illegalMonitorStateException异常,需使用notify()方法来唤醒;而sleep()能在任何地方调用;

wait()方法只能在同步方法或同步代码块中调用原因是:避免CPU切换到其它线程,而其它线程又提前执行了notify方法,那这样就达不到我们的预期(先wait,再由其它线程来notify),所以需要一个同步锁来保护。

wait是对象的方法,java锁是对象级别的,而不是线程级别的;同步代码块中,使用对象锁来实现互斥效果

三、公平锁和非公平锁

1、什么是公平锁、非公平锁、

公平锁:

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

java 复制代码
    //true表示公平锁,先来先得    
    Lock lock = new ReentrantLock(true);

非公平锁:

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

java 复制代码
    //false表示非公平锁,后来的也可能先获得锁。空参默认非公平锁
    Lock lock = new ReentrantLock(false); 
2、面试题:

为什么会有公平锁/非公平锁的设计?为什么默认非公平?

恢复挂起的线程到真正锁的获取还是有时间差的,从开发人员来看这个时间微乎其微,但是从CPU的角度来看,这个时间差存在的还是很明显的。所以非公平锁能更充分地利用CPU的时间片,尽量减少CPU空间状态时间。使用多线程很重要的考量点是线程切换的开销,当采用非公平锁时,当一个线程请求锁获取同步状态,然后释放同步状态,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得很大,所以就减少了线程的开销。

什么时候用公平?什么时候用非公平?

如果为了更高的吞吐量,很显然非公平锁是比较合适的,因为节省了很多线程切换的时间,吞吐量自然就上去了;否则就用公平锁,大家公平使用。

3、预埋伏AQS

后续深入分析

四、可重入锁(递归锁)

1、概念

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

如果是1个有 synchronized修饰的递归调用方法,程序第2次进入被自己阻塞了岂不是天大的笑话,出现了作茧自缚。所以Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

一个线程中的多个流程可以获取同一把锁,持有这把同步锁可以再次进入。自己可以获取自己的内部锁

2、可重入锁的分类

1)隐式锁(即synchronized关键字使用的锁)默认是可重入锁

指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁。

简单的来说就是:在一个synchronized修饰的方法或代码块的内部调用本类的其他synchronized修饰的方法或代码块时,是永远可以得到锁

2)显式锁(即Lock)也有ReentrantLock这样的可重入锁

我觉得区别就是一个显示调用一个隐式调用,显示调用时,一定要保证 lock 跟 unlock 一一对应。

3)演示代码:
java 复制代码
/**
 * @author Guanghao Wei
 * @create 2023-04-10 16:05
 */
public class ReEntryLockDemo {

    public static void main(String[] args) {
        final Object o = new Object();
        /**
         * ---------------外层调用
         * ---------------中层调用
         * ---------------内层调用
         */
        new Thread(() -> {
            synchronized (o) {
                System.out.println("---------------外层调用");
                synchronized (o) {
                    System.out.println("---------------中层调用");
                    synchronized (o) {
                        System.out.println("---------------内层调用");
                    }
                }
            }
        }, "t1").start();

        /**
         * 注意:加锁几次就需要解锁几次
         * ---------------外层调用
         * ---------------中层调用
         * ---------------内层调用
         */
        Lock lock = new ReentrantLock();
        new Thread(() -> {
            lock.lock();
            try {
                System.out.println("---------------外层调用");
                lock.lock();
                try {
                    System.out.println("---------------中层调用");
                    lock.lock();
                    try {
                        System.out.println("---------------内层调用");
                    } finally {
                        lock.unlock();
                    }
                } finally {
                    lock.unlock();
                }
            } finally {
                lock.unlock();
            }
        }, "t2").start();
    }
}
4)Synchronized的重入的实现机理

每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。

当执行monitorenter时,如果目标锁对象的计数器为零,那么说明它没有被其他线程所持有,Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1。

在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么Java虚拟机可以将其计数器加1,否则需要等待,直至持有线程释放该锁。

当执行monitorexit时,Java虚拟机则需将锁对象的计数器减1。计数器为零代表锁已被释放。

五、死锁

1、概念

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉那它们都将无法推进下去,如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。

产生死锁主要原因:

系统资源不足

资源分配不当

进程运行推进的顺序不合适

2、死锁代码演示
java 复制代码
/**
 * @author Guanghao Wei
 * @create 2023-04-10 16:20
 */
public class DeadLockDemo {
    static  Object a=new Object();
    static  Object b=new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (a){
                System.out.println("t1线程持有a锁,试图获取b锁");
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (b){
                    System.out.println("t1线程获取到b锁");
                }
            }
         },"t1").start();

        new Thread(() -> {
            synchronized (b){
                System.out.println("t2线程持有a锁,试图获取a锁");
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (a){
                    System.out.println("t2线程获取到a锁");
                }
            }
        },"t2").start();
    }
}
3、死锁如何排查

1)纯命令行:

jps -l //查出当前进程编号是多少

jstack 进程编号 //打印出进程编号所在的栈信息

2)图形化

jconsole

六、小总结

synchronized 小总结(重要)

指针指向monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个monitor与之关联。当一个montor被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

相关推荐
若水晴空初如梦24 分钟前
QT聊天项目DAY06
开发语言·qt
h汉堡2 小时前
C++入门基础
开发语言·c++·学习
橘猫云计算机设计2 小时前
基于Springboot的自习室预约系统的设计与实现(源码+lw+部署文档+讲解),源码可白嫖!
java·spring boot·后端·毕业设计
HtwHUAT2 小时前
实验四 Java图形界面与事件处理
开发语言·前端·python
鄃鳕2 小时前
QSS【QT】
开发语言·qt
汤姆_5112 小时前
【c语言】深度理解指针4——sizeof和strlen
c语言·开发语言
秋书一叶2 小时前
SpringBoot项目打包为window安装包
java·spring boot·后端
碎梦归途2 小时前
23种设计模式-结构型模式之外观模式(Java版本)
java·开发语言·jvm·设计模式·intellij-idea·外观模式
极客先躯3 小时前
高级java每日一道面试题-2025年4月13日-微服务篇[Nacos篇]-Nacos如何处理网络分区情况下的服务可用性问题?
java·服务器·网络·微服务·nacos·高级面试
muyouking113 小时前
4.Rust+Axum Tower 中间件实战:从集成到自定义
开发语言·中间件·rust