Skip to content

《面渣逆袭》Java并发编程 篇 · 第 4/7 章。原版 PDF(下载 / 打印)

24.synchronized用过吗?怎么使用?

synchronized经常用的,用来保证代码的原子性。
synchronized主要有三种用法:

修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

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

修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得当前

class 的锁。因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。

如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态

synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

synchronized void staic method() {
 //业务代码
}
修饰代码块 :指定加锁对象,对给定对象/类加锁。 synchronized(this|object) 表示进入同步代
码库前要获得给定对象的锁。 synchronized(类.class) 表示进入同步代码前要获得 当前 class 的

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

25.synchronized的实现原理?

synchronized是怎么加锁的呢?
我们使用synchronized的时候,发现不用自己去lock和unlock,是因为JVM帮我们把这个事情做了。
1.synchronized修饰代码块时,JVM采用monitorenter 、monitorexit 两个指令来实现同

步,monitorenter指令指向同步代码块的开始位置,monitorexit指令则指向同步代码块的结束位置。

反编译一段synchronized修饰代码块代码,javap -c -s -v -l SynchronizedDemo.class ,可以

看到相应的字节码指令。

配图

2.synchronized修饰同步方法时,JVM采用ACC_SYNCHRONIZED 标记符来实现同步,这个标

识指明了该方法是一个同步方法。

同样可以写段代码反编译看一下。

配图

synchronized锁住的是什么呢?

monitorenter 、monitorexit 或者ACC_SYNCHRONIZED 都是基于Monitor 实现的。

实例对象结构里有对象头,对象头里面有一块结构叫MarkWord ,MarkWord 指针指向了monitor

所谓的Monitor 其实是一种同步工具,也可以说是一种同步机制。在Java 虚拟机(HotSpot )中,

Monitor 是由ObjectMonitor 实现的,可以叫做内部锁,或者Monitor 锁。

ObjectMonitor 的工作原理:

ObjectMonitor有两个队列:WaitSet、EntryList,用来保存ObjectWaiter 对象列表。
_owner,获取 Monitor 对象的线程进入 _owner 区时, _count + 1。如果线程调用了 wait() 方

法,此时会释放 Monitor 对象, _owner 恢复为空, _count-1 。同时该等待线程进入_WaitSet 中,等待被唤醒。

ObjectMonitor() {
    _header = NULL;

_count =0;// 记录线程获取锁的次数_waiters =0, _recursions =0; // 锁的重入次数

    _object = NULL;

owner =NULL; // 指向持有ObjectMonitor 对象的线程_WaitSet =NULL; // 处于wait 状态的线程,会被加入到 WaitSet

java
    _WaitSetLock = 0 ;
    _Responsible = NULL ;
    _succ = NULL ;
    _cxq = NULL ;
    FreeNext = NULL ;
    _EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq = 0 ;
    _SpinClock = 0 ;
    OwnerIsThread = 0 ;
  }

可以类比一个去医院就诊的例子[ 18] :

首先,患者在门诊大厅前台或自助挂号机进行挂号

随后,挂号结束后患者找到对应的诊室就诊

诊室每次只能有一个患者就诊;

如果此时诊室空闲,直接进入就诊;

如果此时诊室内有其它患者就诊,那么当前患者进入候诊室,等待叫号;

就诊结束后,走出就诊室,候诊室的下一位候诊患者进入就诊室。

配图

这个过程就和Monitor 机制比较相似:

门诊大厅:所有待进入的线程都必须先在入又EntrySet挂号才有资格;

就诊室:就诊室**_Owner**里里只能有一个线程就诊,就诊完线程就自行离开

候诊室:就诊室繁忙时,进入等待区(Wa itSet ),就诊室空闲的时候就从等待区(Wa itSet )

叫新的线程

配图

所以我们就知道了,同步是锁住的什么东西:

monitorenter ,在判断拥有同步标识 ACC_SYNCHRONIZED 抢先进入此方法的线程会优先拥有

Monitor 的 owner ,此时计数器 +1 。

monitorexit ,当执行完退出后,计数器 -1 ,归 0 后被其他进入的线程获得。

26.除了原子性,synchronized可见性,有序性,可重入性怎么实现?

synchronized怎么保证可见性?

线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。

线程加锁后,其它线程无法获取主内存中的共享变量。

线程解锁前,必须把共享变量的最新值刷新到主内存中。

synchronized怎么保证有序性?

synchronized 同步的代码块,具有排他性,一次只能被一个线程拥有,所以synchronized 保证同一时刻,代码是单线程执行的。

因为a s-if-serial 语义的存在,单线程的程序能保证最终结果是有序的,但是不保证不会指令重排。

所以synchronized 保证的有序是执行结果的有序性,而不是防止指令重排的有序性。

synchronized怎么实现可重入的呢?

synchronized 是可重入锁,也就是说,允许一个线程二次请求自己持有对象锁的临界资源,这种情况称为可重入锁。

synchronized 锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会- 1 ,直到计数器清零,就释放锁了。

之所以,是可重入的。是因为 synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线程执行完毕后 -1 ,直到清零释放锁。

27.锁升级?synchronized优化了解吗?

了解锁升级,得先知道,不同锁的状态是什么样的。这个状态指的是什么呢?

Java 对象头里,有一块结构,叫MarkWord标记字段,这块结构会随着锁的状态变化而变化。

64 位虚拟机 MarkWord 是 64bit ,我们来看看它的状态变化:

配图

MarkWord 存储对象自身的运行数据,如哈希码、GC分代年龄、锁状态标志、偏向时间戳(Epoch )

等。

synchronized做了哪些优化?
在JDK1.6之前,synchronized的实现直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级

。从JDK6 开始,HotSpot 虚拟机开发团队对Java 中的锁进行优化,如增加了适应性自旋、锁消除、

锁粗化、轻量级锁和偏向锁等优化策略,提升了synchronized 的性能。

偏向锁:在无竞争的情况下,只是在MarkWord 里存储当前线程指针,CAS 操作都不做。

轻量级锁:在没有多线程竞争时,相对重量级锁,减少操作系统互斥量带来的性能消耗。但是,如果存在锁竞争,除了互斥量本身开销,还额外有CAS 操作的开销。

自旋锁:减少不必要的CPU 上下文切换。在轻量级锁升级为重量级锁时,就使用了自旋加锁的方式锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

锁消除:虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

锁升级的过程是什么样的?

锁升级方向:无锁- -> 偏向锁- --> 轻量级锁- ---> 重量级锁,这个方向基本上是不可逆的。

配图

我们看一下升级的过程:

偏向锁:

偏向锁的获取:

  1. 判断是否为可偏向状态- -MarkWord 中锁标志是否为‘ 01 ’ ,是否偏向锁是否为‘ 1 ’

  2. 如果是可偏向状态,则查看线程ID是否为当前线程,如果是,则进入步骤' 5' ,否则进入步骤‘ 3 ’

  3. 通过CAS 操作竞争锁,如果竞争成功,则将MarkWord 中线程ID设置为当前线程I D ,然后执行

‘ 5 ’ ;竞争失败,则执行‘ 4 ’

  1. CAS 获取偏向锁失败表示有竞争。当达到safepoint 时获得偏向锁的线程被挂起,偏向锁升级为轻

量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块

  1. 执行同步代码

偏向锁的撤销:

  1. 偏向锁不会主动释放( 撤销) ,只有遇到其他线程竞争时才会执行撤销,由于撤销需要知道当前持

有该偏向锁的线程栈状态,因此要等到safepoint 时执行,此时持有该偏向锁的线程(T )有‘ 2 ’ ,‘ 3 ’ 两种情况;

  1. 撤销- ---T 线程已经退出同步代码块,或者已经不再存活,则直接撤销偏向锁,变成无锁状态- ---

该状态达到阈值2 0 则执行批量重偏向

  1. 升级- ---T 线程还在同步代码块中,则将T 线程的偏向锁升级为轻量级锁,当前线程执行轻量级锁

状态下的锁获取步骤- --- 该状态达到阈值4 0 则执行批量撤销

轻量级锁:

轻量级锁的获取:

  1. 进行加锁操作时,j vm 会判断是否已经时重量级锁,如果不是,则会在当前线程栈帧中划出一块

空间,作为该锁的锁记录,并且将锁对象MarkWord 复制到该锁记录中

  1. 复制成功之后,j vm 使用CAS 操作将对象头MarkWord 更新为指向锁记录的指针,并将锁记录里

的owner 指针指向对象头的MarkWord 。如果成功,则执行‘ 3 ’ ,否则执行‘ 4 ’

  1. 更新成功,则当前线程持有该对象锁,并且对象MarkWord 锁标志设置为‘ 00 ’ ,即表示此对象处

于轻量级锁状态

  1. 更新失败,j vm 先检查对象MarkWord 是否指向当前线程栈帧中的锁记录,如果是则执行‘ 5 ’ ,否

则执行‘ 4 ’

  1. 表示锁重入;然后当前线程栈帧中增加一个锁记录第一部分(DisplacedMarkWord )为null ,

并指向MarkWord 的锁对象,起到一个重入计数器的作用。

  1. 表示该锁对象已经被其他线程抢占,则进行自旋等待(默认1 0 次),等待次数达到阈值仍未获取

到锁,则升级为重量级锁

大体上省简的升级过程:

配图

完整的升级过程:

配图

28.说说synchronized和ReentrantLock的区别?

可以从锁的实现、功能特点、性能等几个维度去回答这个问题:

锁的实现: synchronized是Java语言的关键字,基于JVM实现。而ReentrantLock是基于JDK的
API层面实现的(一般是lock()和unlock()方法配合try/finally 语句块来完成。)
性能: 在JDK1.6锁优化以前,synchronized的性能比ReenTrantLock差很多。但是JDK6开始,

增加了适应性自旋、锁消除等,两者性能就差不多了。

功能特点: ReentrantLock 比 synchronized 增加了一些高级功能,如等待可中断、可实现公平

锁、可实现选择性通知。

ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来

实现这个机制

ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的

公平锁就是先等待的线程先获得锁。

synchronized与wait()和notify()/notifyAll()方法结合实现等待/通知机制,ReentrantLock
类借助Condition接又与newCondition()方法实现。
ReentrantLock需要手工声明来加锁和释放锁,一般跟finally配合释放锁。而synchronized

不用手动释放锁。

下面的表格列出出了两种锁之间的区别:

配图

29.AQS了解多少?

AbstractQueuedSynchronizer 抽象同步队列,简称 AQS ,它是Java 并发包的根基,并发包中的锁就是基于AQS 实现的。

AQS 是基于一个FIFO 的双向队列,其内部定义了一个节点类Node ,Node 节点内部的 SHARED用来标记该线程是获取共享资源时被阻挂起后放入AQS 队列的, EXCLUSIVE 用来标记线程是取独占资源时被挂起后放入AQS 队列

AQS 使用一个 volatile 修饰的 int 类型的成员变量 state 来表示同步状态,修改同步状态成功即为获得锁,volatile 保证了变量在多线程之间的可见性,修改 State 值时通过 CAS 机制来保证修改的原子性获取state 的方式分为两种,独占方式和共享方式,一个线程使用独占方式获取了资源,其它线程就会在获取失败后被阻塞。一个线程使用共享方式获取了资源,另外一个线程还可以通过CAS 的方式进行获取。

如果共享资源被占用,需要一定的阻塞等待唤醒机制来保证锁的分配,AQS 中会将竞争共享资源失败的线程添加到一个变体的 CLH 队列中。

配图

配图

AQS 中的队列是 CLH 变体的虚拟双向队列,通过将每条请求共享资源的线程封装成一个节点来实现锁的分配:

配图

AQS 中的 CLH 变体等待队列拥有以下特性:

AQS 中队列是个双向链表,也是 FIFO 先进先出的特性通过 Head 、Ta il 头尾两个节点来组成队列结构,通过 volatile 修饰保证可见性

Head 指向节点为已获得锁的节点,是一个虚拟节点,节点本身不持有具体线程获取不到同步状态,会将节点进行自旋获取锁,自旋一定次数失败后会将线程阻塞,相对于 CLH队列性能较好

ps:AQS 源码里面有很多细节可问,建议有时间好好看看AQS 源码。

30.ReentrantLock实现原理?

ReentrantLock 是可重入的独占锁,只能有一个线程可以获取该锁,其它获取该锁的线程会被阻塞而被放入该锁的阻塞队列里面。

看看ReentrantLock 的加锁操作:

java
// 创建非公平锁
ReentrantLock lock = new ReentrantLock();
// 获取锁操作
lock.lock();
try {
    // 执行代码逻辑
} catch (Exception ex) {
    // ...
} finally {
    // 解锁操作
    lock.unlock();
}
new ReentrantLock() 构造函数默认创建的是非公平锁 NonfairSync。

公平锁 FairSync

  1. 公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线

程才能获得锁

  1. 公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除

第一个线程以外的所有线程都会阻塞,CPU 唤醒阻塞线程的开销比非公平锁大

非公平锁 NonfairSync

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU 不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁默认创建的对象lock() 的时候:

如果锁当前没有被其它线程占用,并且当前线程之前没有获取过该锁,则当前线程会获取到该锁,然后设置当前锁的拥有者为当前线程,并设置 AQS 的状态值为1 ,然后直接返回。如果当前线程之前己经获取过该锁,则这次只是简单地把 AQS 的状态值加1 后返回。

如果该锁己经被其他线程持有,非公平锁会尝试去获取锁,获取失败的话,则调用该方法线程会被放入 AQS 队列阻塞挂起。

配图

31.ReentrantLock怎么实现公平锁的?

java
new ReentrantLock() 构造函数默认创建的是非公平锁 NonfairSync
public ReentrantLock() {
    sync = new NonfairSync();
}

同时也可以在创建锁构造函数中传入具体参数创建公平锁 FairSync

java
ReentrantLock lock = new ReentrantLock(true);

---ReentrantLock

java
// true 代表公平锁,false 代表非公平锁
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

FairSync 、NonfairSync 代表公平锁和非公平锁,两者都是 ReentrantLock 静态内部类,只不过实现不同锁语义。

非公平锁和公平锁的两处不同:

  1. 非公平锁在调用 lock 后,首先就会调用 CAS 进行一次抢锁,如果这个时候恰巧锁没有被占用,

那么直接就获取到锁返回了。

2.非公平锁在 CAS 失败后,和公平锁一样都会进入到 tryAcquire 方法,在 tryAcquire 方法中,

如果发现锁这个时候被释放了(state==0 ),非公平锁会直接 CAS 抢锁,但是公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,乖乖排到后面。

配图

相对来说,非公平锁会有更好的性能,因为它的吞吐量比较大。当然,非公平锁让获取锁的时间变得更加不确定,可能会导致在阻塞队列中的线程长期处于饥饿状态。

32.CAS呢?CAS了解多少?

CAS 叫做CompareAndSwap ,比较并交换,主要是通过处理器的指令来保证操作的原子性的。

CAS 指令包含 3 个参数:共享变量的内存地址 A 、预期的值 B 和共享变量的新值 C 。

只有当内存中地址 A 处的值等于 B 时,才能将内存中地址 A 处的值更新为新值 C 。作为一条 CPU指令,CAS 指令本身是能够保证原子性的 。

33.CAS 有什么问题?如何解决?

CAS 的经典三大问题:

配图

ABA 问题

并发环境下,假设初始条件是A ,去修改数据时,发现是A 就会执行修改。但是看到的虽然是A ,中间可能发生了A 变B ,B 又变回A 的情况。此时A 已经非彼A ,数据即使成功修改,也可能有问题。

怎么解决ABA 问题?

加版本号每次修改变量,都在这个变量的版本号上加1 ,这样,刚刚A ->B->A ,虽然A 的值没变,但是它的版本号已经变了,再判断版本号就会发现此时的A 已经被改过了。参考乐观锁的版本号,这种做法可以给数据带上了一种实效性的检验。

Java 提供了AtomicStampReference类,它的compareAndSet 方法首先检查当前的对象引用值是否等于预期引用,并且当前印戳(Stamp )标志是否等于预期标志,如果全部相等,则以原子方式将引用值和印戳标志的值更新为给定的更新值。

循环性能开销

自旋CAS ,如果一直循环执行,一直不成功,会给CPU 带来非常大的执行开销。

怎么解决循环性能开销问题?

在Java 中,很多使用自旋CAS 的地方,会有一个自旋次数的限制,超过一定次数,就停止自旋。

只能保证一个变量的原子操作

CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的。

怎么解决只能保证一个变量的原子操作问题?

可以考虑改用锁来保证操作的原子性可以考虑合并多个变量,将多个变量封装成一个对象,通过AtomicReference 来保证原子性。

34.Java有哪些保证原子性的方法?如何保证多线程下i++ 结果正确?

配图

使用循环原子类,例如AtomicInteger ,实现i ++ 原子操作

使用juc包下的锁,如ReentrantLock ,对i++操作加锁lock.lock()来实现原子性
使用synchronized,对i++操作加锁

35.原子操作类了解多少?

当程序更新一个变量时,如果多线程同时更新这个变量,可能得到期望之外的值,比如变量i =1 ,A 线程更新i +1 ,B 线程也更新i +1 ,经过两个线程操作之后可能i 不等于3 ,而是等于2 。因为A 和B 线程在更新变量i 的时候拿到的i 都是1 ,这就是线程不安全的更新操作,一般我们会使用synchronized 来解决这个问题,synchronized 会保证多线程不会同时更新变量i 。

其实除此之外,还有更轻量级的选择,Java 从JDK1.5 开始提供了java.util.concurrent.atomic 包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。

因为变量的类型有很多种,所以在Atomic 包里一共提供了1 3 个类,属于4 种类型的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段)。

配图

Atomic 包里的类基本都是使用Unsafe 实现的包装类。

使用原子的方式更新基本类型,Atomic 包提供了以下3 个类:

AtomicBoolean :原子更新布尔类型。

AtomicInteger :原子更新整型。

AtomicLong :原子更新长整型。

通过原子的方式更新数组里的某个元素,Atomic 包提供了以下4 个类:

AtomicIntegerArray :原子更新整型数组里的元素。

AtomicLongArray :原子更新长整型数组里的元素。

AtomicReferenceArray:原子更新引用类型数组里的元素。

AtomicIntegerArray 类主要是提供原子的方式更新数组里的整型原子更新基本类型的AtomicInteger ,只能更新一个变量,如果要原子更新多个变量,就需要使用这个原子更新引用类型提供的类。Atomic 包提供了以下3 个类:

AtomicReference :原子更新引用类型。

AtomicReferenceFieldUpdater:原子更新引用类型里的字段。

AtomicMarkableReference:原子更新带有标记位的引用类型。可以原子更新一个布尔类型的标记位和引用类型。构造方法是AtomicMarkableReferenc e(VinitialRef ,boolean

initialMark )。

如果需原子地更新某个类里的某个字段时,就需要使用原子更新字段类,Atomic 包提供了以下3 个类进行原子字段更新:

AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。

AtomicLongFieldUpdater:原子更新长整型字段的更新器。

AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用CAS 进行原子更新时可能出现的 ABA 问题。

36.AtomicInteger 的原理?

一句话概括:使用CAS 实现

以AtomicInteger 的添加方法为例:

java
    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }

通过Unsafe类的实例来进行添加操作,来看看具体的CAS 操作:

java
public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    return var5;
}

compareAndSwapInt 是一个native 方法,基于CAS 来操作i nt 类型变量。其它的原子操作类基本都是大同小异。

37.线程死锁了解吗?该如何避免?

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。

配图

那么为什么会产生死锁呢? 死锁的产生必须具备以下四个条件:

配图

互斥条件:指线程对己经获取到的资源进行它性使用,即该资源同时只由一个线程占用。如果此时还有其它线程请求获取获取该资源,则请求者只能等待,直至占有资源的线程释放该资源。

请求并持有条件:指一个 线程己经持有了至少一个资源,但又提出了新的资源请求,而新资源己被其它线程占有,所以当前线程会被阻塞,但阻塞 的同时并不释放自己已经获取的资源。

不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其它线程抢占,只有在自己使用完毕后才由自己释放该资源。

环路等待条件:指在发生死锁时,必然存在一个线程— — 资源的环形链,即线程集合 {T0 ,T 1 ,

T2, … … ,T n} 中 T0 正在等待一 T1 占用的资源,T l1 正在等待 T2 用的资源,… … Tn 在等待己被 T0 占用的资源。

该如何避免死锁呢?答案是至少破坏死锁发生的一个条件

其中,互斥这个条件我们没有办法破坏,因为用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?

对于“ 请求并持有” 这个条件,可以一次性请求所有的资源。

对于“ 不可剥夺” 这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。

对于“ 环路等待” 这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后就不存在环路了。

38.那死锁问题怎么排查呢?

可以使用j dk 自带的命令行工具排查:

  1. 使用j ps 查找运行的Java 进程:j ps-l

  2. 使用jstack 查看线程堆栈信息:jstack-l 进程i d

基本就可以看到死锁的信息。

还可以利用图形化工具,比如JConsole 。出现线程死锁以后,点击JConsole 线程面板的检测到死锁按钮,将会看到线程的死锁信息。

配图

本文整理自三分恶《面渣逆袭》系列的公开内容,仅供个人学习使用

本站仅供个人学习使用,请勿外传