一,线程安全问题
线程的合理使用能够提升程序的处理性能,主要有两个方面,第一个是能够利用多核CPU以及超线程技术来实现线程的并行执行;第二个是线程的异步化执行相比于同步来说,异步执行能够很好的优化程序的处理性能提升并发吞吐量。
1.线程安全
一个变量i,假如一个线程去访问这个变量进行修改,这个时候对于数据的修改和访问没有任何问题。但是如果更多的线程对于着同一个变量进行修改,就会存在一个数据安全性问题。
一个对象是否是线程安全的,取决于他是否会被多个线程访问,以及程序中是如何去使用这个对象的。所以,如果多个线程访问同一个共享对象,在不需要额外的同步以及调用端代码不用做其他协调的情况下,这个共享对象的状态依然是正确的(正确性意味着这个对象的结果与我们预期规定的结果保持一致),那说明这个对象是线程安全的。
2.如何保证线程并行的数据安全
能够有一种方法使得线程的并行变成串行。加锁
什么是锁?他是处理并发的一种同步手段,如果达到前面的目的,那么这个锁一定需要实现互斥的特性。Java提供加锁的方法就是synchronized关键字。
二,synchronized实现原理
多线程并发编程中synchronized一直是元老级的,很多人都会称呼为重量级锁。但是随着jdk6对synchronized进行各种优化之后,有些情况下他就并不是那么重量级了。java6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。
synchronized实现同步的基础:Java中每一个对象都可以作为锁。具体表现为三种情况:
- 普通同步方法,锁是当前实例对象
- 静态同步方法,锁是当前类的Class对象
- 同步方法块,锁是sync括号里面配置的对象
当一个线程试图访问同步代码块时,他首先必须得到锁,退出或者抛出异常时必须释放锁。那么锁到底存储在哪里呢?锁里面会存储什么信息?
从JVM规范中可以看到synchronized在jvm里面的实现原理,jvm基于进入和退出monitor对象来实现方法同步和代码块的同步,但是两者的实现细节不一样。代码块同步使用monitorenter和monitorexit指令实现的,而方法的同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是方法的同步同样可以使用这两个指令来实现。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
/**
* @author 二十
* @since 2021/8/26 4:33 下午
*/
public class SyncTest {
{
synchronized (Object.class){
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
执行javap -v SyncTest
命令
三,锁的存储
分析:要实现多线程的互斥状态,那这把锁需要哪些因素?
- 锁需要有一个东西来表示,比如获得锁是什么状态,无锁状态是什么状态?
- 这个状态需要对多个线程共享
观察synchronized的整个语法发现,synchronized(lock)是基于lock这个对象的生命周期来控制锁粒度的,那是不是锁的存储和这个lock对象有关系呢?以对象在jvm内存中是如何存储的作为切入点,分析对象里面有什么特性能够实现锁。
1.对象在内存中的布局
在 Hotspot 虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
2.JVM源码实现
当我们在java代码里面使用new创建一个对象实例的时候,jvm层面实际上会创建一个instanceOopDesc对象。
Hotspot虚拟机采用OOP-Klass用来描述对象实例Java对象实例,OOP指的是普通对象指针,Klass用来描述对象实例的具体类型。Hotspot采用instanceOopDesc和arrayOopDesc对象用来描述数组类型。
instanceOopDesc的定义在Hotspot源码中的instanceOop.hpp文件中,另外,arrayOopDesc的定义对应arrayOop.hpp。
#ifndef SHARE_OOPS_INSTANCEOOP_HPP
#define SHARE_OOPS_INSTANCEOOP_HPP
#include "oops/oop.hpp"
// An instanceOop is an instance of a Java Class
// Evaluating "new HashTable()" will create an instanceOop.
class instanceOopDesc : public oopDesc {
public:
// aligned header size.
static int header_size() { return sizeof(instanceOopDesc)/HeapWordSize; }
// If compressed, the offset of the fields of the instance may not be aligned.
static int base_offset_in_bytes() {
return (UseCompressedClassPointers) ?
klass_gap_offset_in_bytes() :
sizeof(instanceOopDesc);
}
};
#endif // SHARE_OOPS_INSTANCEOOP_HPP
从 instanceOopDesc 代码中可以看到 instanceOopDesc继承自 oopDesc,oopDesc 的定义在Hotspot 源码中的oop.hpp 文件中。
在普通实例对象中,oopDesc 的定义包含两个成员,分别是 mark 和 metadata 。
_mark表示对象标记、属于 markOop 类型,也就是接下来要分析的 Mark World,它记录了对象和锁有关的信息。
_metadata 表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址,其中 Klass 表示普通指针、_compressed_klass 表示压缩类指针。
3.MarkWord
在Hotspot中,markOop 的定义在markOop.hpp 文件中。
Mark word 记录了对象和锁有关的信息,当某个对象被synchronized 关键字当成同步锁时,那么围绕这个锁的一系列操作都和 Mark word 有关系。Mark Word 在 32 位虚拟机的长度是 32bit、在 64 位虚拟机的长度是 64bit。Mark Word里面存储的数据会随着锁标志位的变化而变化,Mark Word 可能变化为存储以下 5 种情况。
4.为什么任何对象都可以实现锁
- 首先,java中的每个对象都派生自Object类,而每个Java Object 在JVM 内部都有一个native 的C++对象oop/oopDesc 进行对应。
- 线程在获取锁的时候,实际上就是获得了一个监视器对象(monitor),可以认为他是一个同步对象,所有的java对象天生携带monitor。在hotspot源码的markWord.hpp文件中,可以看到下面这段代码:
ObjectMonitor* monitor() const {
assert(has_monitor(), "check");
// Use xor instead of &~ to provide one extra tag-bit check.
return (ObjectMonitor*) (value() ^ monitor_value);
}
多线程访问同步代码块时相当于去争抢对象监视器修改对象中的锁标识,上面的代码中ObjectMonitor这个对象和线程争抢锁的逻辑有密切的关系。
5.不同锁状态下MarkWord中存储的内容
四,锁升级
使用锁能够实现数据的安全性,但是会带来性能的下降。不使用锁能够基于线程并行提升程序性能,但是却不能保证线程安全性。这两者之间似乎是没有办法达到既能满足性能也能满足安全性的要求。
hotspot 虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。所以基于这样一个概率,是的 synchronized 在JDK1.6 之后做了一些优化,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁、轻量级锁的概念。因此在 synchronized 中,锁存在四种状态分别是:无锁、偏向锁、轻量级锁、重量级锁; 锁的状态根据竞争激烈的程度从低到高不断升级。
1,偏向锁的基本原理
怎么理解偏向锁?
当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了。
对象一旦生成了hash码,他就无法进入偏向锁状态。也就是说,只要一个对象已经计算过hash码,她就无法进入偏向锁状态。当一个对象当前正处于偏向锁状态,并且需要计算其hash码,他的偏向锁会被撤销,并且锁会膨胀为重量级锁。
1)偏向锁的获取和撤销逻辑
首先获取锁 对象的 Markword,判断是否处于可偏向状态。(biased_lock=1、且 ThreadId 为空)
如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID写入到 MarkWord,锁标志位改成01,偏向标志位改成1.
如果 cas 成功,那么 markword 就会变成这样。表示已经获得了锁对象的偏向锁,接着执行同步代码块
如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行
如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的 ThreadID
偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。
对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:
原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无锁状态并且争抢锁的线程可以基于 CAS 重新偏向当前线程
如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块
在我们的应用开发中,绝大部分情况下一定会存在 2 个以上的线程竞争,那么如果开启偏向锁,反而会提升获取锁的资源消耗。所以可以通过 jvm 参数UseBiasedLocking 来设置开启或关闭偏向锁。
3)流程图分析
2,轻量级锁的基本原理
1)轻量级锁的加锁和解锁逻辑
锁升级为轻量级锁之后,对象的 Markword 也会进行相应的变化。升级为轻量级锁的过程:
轻量级锁在加锁过程中,用到了自旋锁。
所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。注意,锁在原地循环的时候,是会消耗 cpu 的,就相当于在执行一个啥也没有的 for 循环。
所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短的时间就能够获得锁了。
自旋锁的使用,其实也是有一定的概率背景,在大部分同步代码块执行的时间都是很短的。所以通过看似无异议的循环反而能提升锁的性能。
但是自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么这个线程不断的循环反而会消耗 CPU 资源。默认情况下自旋的次数是 10 次,可以通过 preBlockSpin 来修改。
在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
为什么要将内置锁对象的MarkWord复制到锁记录里面? 因为内置锁对象的MarkWord的结构会有所变化,MarkWord将会出现一个指向锁记录的指针,而不再存着无锁状态下锁对象哈希码等信息,所以必须将这些信息暂存起来,供后面在锁释放的时候使用。
JDK1.6使用的轻量级锁是普通自旋锁,且需要手动指定jvm参数开启。JDK1.7之后,轻量级锁使用自适应自旋锁,jvm启动的时候自动开启,且自旋时间由jvm自动控制。
轻量级锁也被称为非阻塞同步锁,乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待。
3)轻量级锁的解锁
轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑,通过CAS 操作把线程栈帧中的 LockRecord 替换回到锁对象的MarkWord 中,如果成功表示没有竞争。如果失败,表示当前锁存在竞争,那么轻量级锁就会膨胀成为重量级锁。
4)流程图分析
3,重量级锁的基本原理
当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞来等待被唤醒了。
1)重量级锁的 monitor
public class SyncTest {
public static void main(String[] args) {
synchronized (SyncTest.class){
}
test();
}
public static synchronized void test(){
}
}
加 了 同 步 代 码 块 以 后 , 在 字 节 码 中 会 看 到 一 个monitorenter 和 monitorexit。
每一个 JAVA 对象都会与一个监视器 monitor 关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被synchronized 修饰的同步方法或者代码块时,该线程得先获取到 synchronized 修饰的对象对应的 monitor。monitorenter 表示去获得一个对象监视器。monitorexit 表示释放 monitor 监视器的所有权,使得其他被阻塞的线程可以尝试去获得这个监视器。
monitor 依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。
2)重量级锁的加锁的基本流程
任意线程对 Object(Object 由 synchronized 保护)的访问,首先要获得 Object 的监视器。如果获取失败,线程进入同步队列,线程状态变为 BLOCKED。当访问 Object 的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。
3)重量级锁核心原理
jvm中每一个对象都会有一个监视器,监视器和对象一起创建,销毁。监视器相当于一个用来监视这些线程进入的特殊房间,其义务是保证同一时间只有一个线程可以访问被保护的临界区代码块。
本质上,监视器是一种同步工具,也可以说是一种同步机制,主要特点是:
- 同步:监视器所保护的临界区代码是互斥的执行的。一个监视器是一个运行许可,任一线程进入临界区代码都需要获得这个许可,离开时把许可归还。
- 协作:监视器提供Signal机制,允许正持有许可的线程暂时放弃许可进入阻塞等待状态,等待其他线程发送Signal去唤醒;其他拥有许可的线程可以发送Signal,唤醒正在阻塞等待的线程,让他可以重新获得许可并启动执行。
在HotSpot虚拟机中,监视器是由C++类ObjectMonitor实现的,ObjectMonitor类定义在ObjectMonitor.hpp文件中。
ObjectMonitor的Owner,WaitSet,Cxq,EntryList这几个属性比较关键。
ObjectMonitor的WaitSet,Cxq,EntryList这几个队列存放抢夺重量级锁的线程,而ObjectMonitor的Owner所指向的线程即为获得锁的线程。
- Cxq:竞争队列,所有请求锁的线程首先被放到这个竞争队列。
- EntryList:Cxq中那些有资格成为候选资源的线程被移动到EntryList中。
- WaitSet:某个拥有ObjectMonitor的线程在调用Object.wait()方法之后将被阻塞,然后该线程将会被放到WaitSet链表中。
Cxq
Cxq并不是一个真正的队列,只是一个虚拟队列,原因在于Cxq是由Node及其next指针逻辑构成的,并不存在一个队列的数据结构。每次新加入Node会在Cxq的队头进行,通过cas改变第一个节点的指针为新增节点,同时设置新增节点的next指向后续的节点;从Cxq取得元素时,会从队尾获取。显然,Cxq结构是一个无锁的结构。
因为只有Owner线程才能从队尾取元素,即线程出列操作无争用,当然就避免了cas的aba问题。
在线程进入Cxq前,抢锁线程会先尝试通过cas自旋锁获取锁,如果获取不到,就进入Cxq队列,这明显对于已经进入Cxq队列的线程是不公平的。所以,synchronized同步块所使用的重量级锁是不公平锁。
EntryList
EntryList与Cxq在逻辑上都属于等待队列。Cxq会被线程并发访问,为了降低对Cxq队尾的争用,而建立EntryList。在Owner线程释放锁时,jvm会从Cxq中迁移线程到EntryList,并会指定EntryList中的某个线程为OnDeck Thread。EntryList中的线程作为候选竞争线程而存在。
OnDeck Thread & Owner Thread
jvm不直接把锁传递给Owner Thread,而是把锁竞争的权利交给OnDeck Thread,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在jvm中,也把这种选择行为称为竞争切换。
OnDeck Thread 获取到锁资源后会变成Owner Thread。无法获得锁的OnDeck Thread则会依然留在EntryList中,考虑到公平性,OnDeck Thread在EntryList中的位置不发生变化。
在OnDeck Thread成为Owner的过程中,还有一个不公平的事情,就是后来的新抢锁线程可能直接通过cas自旋成为Owner而抢到锁。
WaitSet
如果Owner线程被Object.wait()阻塞,就转移到WaitSet队列中,直到某个时刻通过Object.notify()唤醒,该线程就会重新进入EntryList中。
4)重量级锁的开销
处于ContentionList,EntryList,WaitSet中的线程都处于阻塞状态,线程的阻塞或者唤醒都需要操作系统来帮忙,Linux内核下采用pthread_mutex_lock系统调用实现,进程需要从用户态切换到内核态。
Linux系统体系架构分为用户态和内核态。
Linux系统的内核是一组特殊的软件程序,负责控制计算机的硬件资源,例如协调cpu资源,分配内存资源,并且提供稳定的环境供应用程序运行。应用程序活动的空间为用户空间,应用程序的执行必须依托于内核提供的资源,包括cpu资源,存储资源,io资源等。
用户态与内核态有各自专用的内存空间,专用的寄存器等,进程从用户态切换到内核态需要传递许多变量,参数给内核,内核也需要保护好用户态在切换时的一些寄存器值,变量等,以便内核态调用结束后切换回用户态继续工作。
用户态的进程能够访问的资源受到了极大的控制,而运行在内核态的进程可以“为所欲为”。一个进程可以运行在用户态,也可以运行在内核态,那么肯定存在用户态和内核态切换的过程。进程从用户态到内核态切换主要包括以下三种方式:
(1)硬件中断。硬件中断也称为外设中断,当外设完成用户的请求时会向CPU发送中断信号。
(2)系统调用。其实系统调用本身就是中断,只不过是软件中断,跟硬件中断不同。
(3)异常。如果当前进程运行在用户态,这个时候发生了异常事件(例如缺页异常),就会触发切换。
用户态是应用程序运行的空间,为了能访问到内核管理的资源(例如CPU、内存、I/0),可以通过内核态所提供的访问接口实现,这些接口就叫系统调用。pthredmutexlock系统调用是内核态为用户态进程提供的Linux内核态下互斥锁的访问机制,所以使用pthread_mutex_lock系统调用时,进程需要从用户态切换到内核态,而这种切换是需要消耗很多时间的,有可能比用户执行代码的时间还要长。
由于jvm轻量级锁使用cas进行自旋抢锁,这些cas操作都处于用户态下,进程不存在用户态和内核态之间的运行切换,因此jvm轻量级锁开销比较小。而jvm重量级锁使用了Linux内核态下的互斥锁,这是重量级锁开销很大的原因。
4,回顾线程的竞争
假如有这样一个同步代码块,存在 Thread#1、Thread#2 等多个线程:
synchronized (lock) {
// do something
}
情况一:只有 Thread#1 会进入临界区;
情况二:Thread#1 和 Thread#2 交替进入临界区,竞争不激烈;
情况三:Thread#1/Thread#2/Thread3… 同时进入临界区,竞争激烈。
1)偏向锁
此时当 Thread#1 进入临界区时,JVM 会将 lockObject 的对象头 Mark Word 的锁标志位设为“01”,同时会用 CAS 操作把 Thread#1 的线程 ID 记录到 Mark Word 中,此时进入偏向模式。所谓“偏向”,指的是这个锁会偏向于 Thread#1,若接下来没有其他线程进入临界区,则 Thread#1 再出入临界区无需再执行任何同步操作。也就是说,若只有Thread#1 会进入临界区,实际上只有 Thread#1 初次进入临界区时需要执行 CAS 操作,以后再出入临界区都不会有同步操作带来的开销。
2)轻量级锁
偏向锁的场景太过于理想化,更多的时候是 Thread#2 也会尝试进入临界区, 如果 Thread#2 也进入临界区但是Thread#1 还没有执行完同步代码块时,会暂停 Thread#1并且升级到轻量级锁。Thread#2 通过自旋再次尝试以轻量级锁的方式来获取锁。
3)重量级锁
如果 Thread#1 和 Thread#2 正常交替执行,那么轻量级锁基本能够满足锁的需求。但是如果 Thread#1 和 Thread#2同时进入临界区,那么轻量级锁就会膨胀为重量级锁,意味着 Thread#1 线程获得了重量级锁的情况下,Thread#2就会被阻塞。
五,结合wait,notify
被阻塞的线程什么时候被唤醒,取决于获得锁的线程什么时候执行完同步代码块并且释放锁。那怎么做到显示控制呢?我们就需要借 助 一 个 信 号机 制 : 在 Object 对 象 中 ,提 供 了wait/notify/notifyall,可以用于控制线程的状态
1,wait/notify/notifyall的基本概念
wait:表示持有对象锁的线程 A 准备释放对象锁权限,释放 cpu 资源并进入等待状态。
notify:表示持有对象锁的线程 A 准备释放对象锁权限,通知 jvm 唤醒某个竞争该对象锁的线程 X。线程 Asynchronized 代码执行结束并且释放了锁之后,线程 X 直接获得对象锁权限,其他竞争线程继续等待(即使线程 X 同步完毕,释放对象锁,其他竞争线程仍然等待,直至有新的 notify ,notifyAll 被调用)。
notifyAll:notifyall 和 notify 的区别在于,notifyAll 会唤醒所有竞争同一个对象锁的所有线程,当已经获得锁的线程A 释放锁之后,所有被唤醒的线程都有可能获得对象锁权限。
三个方法都必须在 synchronized 同步关键字 所 限 定 的 作 用 域 中 调 用 , 否 则 会 报 错java.lang.IllegalMonitorStateException ,意思是因为没有同步,所以线程对对象锁的状态是不确定的,不能调用这些方法。
通过同步机制来确保线程从 wait 方法返回时能够感知到 notify 线程对变量做出的修改
2,waity /notify 的基本使用
public class SyncTest {
public static void main(String[] args) {
Share share = new Share();
new Thread(() -> {
for (int i = 0; i < 10; i++)
try {
share.add();
} catch (Exception e) {
e.printStackTrace();
}
}, "AAA").start();
new Thread(() -> {
for (int i = 0; i < 10; i++)
try {
share.sub();
} catch (Exception e) {
e.printStackTrace();
}
}, "BBB").start();
}
}
class Share {
private int num = 0;
public synchronized void add()throws Exception{
//判断
if (num!=0)
this.wait();
System.out.println(Thread.currentThread().getName()+"执行了:"+ ++num);
this.notify();
}
public synchronized void sub()throws Exception{
if (num==0)
this.wait();
System.out.println(Thread.currentThread().getName()+"执行了:"+ --num);
this.notify();
}
}
3,waity /notify 的基本原理
- 线程A执行MonitorEnter指令成功,获取到锁。
- 线程A执行Object.wait(),线程A将自己加入到等待队列并释放锁。
- 线程B执行MonitorEnter指令成功,获取到锁。
- 线程B执行Object.notify(),线程A从等待队列移动到同步队列,等到收到MonitorExit后,出队列。
六,synchronized的可重入实现原理
每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
当执行monitorenter时,如果目标锁对象的计数器为0,那么说明他没有被其他线程锁持有,Java虚拟机会将改锁对象的持有线程设置为当前线程,并且将其计数器+1.
在目标锁对象的计数器不为0的情况下,如果锁对象的持有线程是当前线程,那么Java虚拟机可以将其计数器+1,否则需要等待,直至持有线程释放锁。
当执行monitorexit时,Java虚拟机则需要将锁对象的计数器-1.计数器为0代表释放锁。
public class Demo3 {
private static Lock lock=new ReentrantLock();
public static void main(String[] args) {
m1();
}
public static void m1(){
new Thread(()->{
try {
lock.lock();
System.out.println("第一次加锁");
try {
lock.lock();
System.out.println("第2次加锁");
}finally {
lock.unlock();
}
}finally {
lock.unlock();
}
},"m1").start();
}
}