【java多线程】对象头、synchronized锁的升级、monitor
很久之前写的一篇文章,简单整理了下发表出来。整理的不够好,但是内容很充分很多内容是长期积累的舍不得删了,读者尽量把前面的看懂吧。源码部分可忽略一、对象头在 JVM 中,对象在内存中分为三块区域:对象头:标记字段和类型指针。一会介绍实例数据:这部分主要是存放类的数据信息,父类的信息。对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。HotSpot虚
很久之前写的一篇文章,简单整理了下发表出来。整理的不够好,但是内容很充分很多内容是长期积累的舍不得删了,读者尽量把前面的看懂吧。源码部分可忽略
一、对象头
在 JVM 中,对象在内存中分为三块区域:
- 对象头:标记字段和类型指针。一会介绍
- 实例数据:这部分主要是存放类的数据信息,父类的信息。
- 对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。
HotSpot虚拟机中,设计了一个OOP&Klass Model
。它用来标识一个对象的特征,注意并不包括我们的数据。
- OOP(Ordinary Object Pointer)指的是普通对象指针。他是一个完整的java对象
- 而Klass用来描述对象实例的具体类型。
- 它在每个类被JVM加载的时候创建,每个类对应一个
instanceKlass
,保存在方法区,用来在JVM层表示该Java类。
- 它在每个类被JVM加载的时候创建,每个类对应一个
当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc
对象,这个对象中包含了对象头以及实例数据。
以下面一段代码为例我们分析他们的对象头
class Model {
public static int a = 1;
public int b;
public Model(int b) {
this.b = b;
}
}
public static void main(String[] args) {
int c = 10;
Model modelA = new Model(2); //对应一个instanceOopDesc
Model modelB = new Model(3);
}
OOP包含了markWord+Klass+实例数据。
- markWord用于synchronized锁
- Kclass指向方法区实例数据包含属性
这就是一个简单的Java对象的OOP&Klass
模型,即Java对象模型。
上图标识:
- p在
栈
中 - new Person()在
堆
中,它包含了对象头和实例对象 - 上面对象头里有个Kclass,就是JVM加载的java类,它指向
方法区
。同一类型的java对象指向同一个Kclass
// 对象头,它是其他对象类的基类
class oopDesc {
friend class VMStructs;
private:
volatile markOop _mark; // mark word
union _metadata {
wideKlassOop _klass; // kclass
narrowOop _compressed_klass;
} _metadata; // 元数据
}
1.1 klass pointer
Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据。如果对象是数组对象,那么对象头占用3个字宽(Word),如果对象是非数组对象,那么对象头占用2个字宽。(1word = 2 Byte = 16 bit)
\1. 每个Class的属性指针(即静态变量)
\2. 每个对象的属性指针(即对象变量)
\3. 普通对象数组的每个元素指针
当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针( JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。
二、从synchonized看对象头
- 对于同步方法,JVM采用
ACC_SYNCHRONIZED
标记符来实现同步。 - 对于同步代码块。JVM采用
monitorenter
、monitorexit
两个指令来实现同步。
方法级的同步是隐式的。同步方法的常量池中会有一个
ACC_SYNCHRONIZED
标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED
,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。可以把执行
monitorenter
指令理解为加锁,执行monitorexit
理解为释放锁。 每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter
)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit
指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。
0、mark word
我们到synchronized有锁升级的过程,他的原理就是操作对象头的mark word。
mark word一般有64bit(在64位
虚拟机下),各状态下每个bit的组成如图
如上图,关键字段分别指向的是
- 线程id
- 栈中锁记录的指针
- 重量级锁monitor的指针
在源码中各字段的bit:
// markOop.hpp
public:
// Constants
enum {
age_bits = 4, // 为什么最大年龄是15,就是因为只能是1111
lock_bits = 2,
biased_lock_bits = 1,
max_hash_bits = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
hash_bits = max_hash_bits > 31 ? 31 : max_hash_bits,
cms_bits = LP64_ONLY(1) NOT_LP64(0),
epoch_bits = 2
};
// 锁状态的枚举:
enum {
locked_value = 0,//轻量级锁 000
unlocked_value = 1,// 无锁 001
monitor_value = 2,//重量级锁 010
marked_value = 3,//GC标记 011
biased_lock_pattern = 5//可偏向 101
};
在
32位
虚拟机下,Mark Word
是32bit大小的
1 无锁
无锁状态下就是我们上面的第一行,此时对象刚创建。
最后的状态位为0 01
2 偏向锁
最后的状态位为1 01
2.1 无锁升级偏向锁
偏向锁解决一把锁与锁之间使用不冲突的情况,不用等待
为什么要引入偏向锁?:在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。
偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及ThreadID即可。
开始获取锁:(结合这张图看https://i-blog.csdnimg.cn/blog_migrate/20b8de3c2d250a8a5c35a4e3ea49fddb.png)
1)先看后两位:看后两位是否01(代表无锁或偏向锁),是的话看倒数第三位的偏向锁位
2)无锁检查:偏向锁位为0,则直接去CAS。或者如果是1代表是当前线程ID,那么也获取成功。
3)无锁时用CAS升级偏向锁:通过CAS把mark word
的Thread ID
从0改为当前线程的。
- 如果CAS换线程ID成功,获得偏向锁,执行同步代码
- 复用该线程下次进入锁时,因为是自己的线程ID,直接获取到了锁(无操作,效率高)。
- 如果CAS失败,说明自己栈帧里保存的mark word副本已经和对象头里mark word不符了,接下来看是否真要升级为轻量级锁(上个线程可能用完了但是没有擦除(偏向锁不会主动释放锁),所以先看前个线程是否存活,如果存活去看前线程的栈帧信息(从栈帧可以知道该线程执行到哪了,栈里是否有该方法或对象),)
- 暂停持有锁的线程,检查原线程消亡/退出同步块没有,
- 退出了的话线程ID位设置为0,重新走CAS逻辑
- 没有退出,升级轻量级锁
- 暂停持有锁的线程,检查原线程消亡/退出同步块没有,
是:获取到偏向锁,执行代码
倒数第三位是1(偏向锁)-检查Thread ID是否是自己
否:CAS尝试从0到自己,当然失败,于是撤销偏向锁,准备到轻量级锁(在正式到轻量之前还会检查一遍原线程状态)
01
倒数第三位是0-CAS从0到当前线程
总的来说就是看线程和栈帧
2.2 偏向锁的升级
验证偏向锁是否还在
当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
偏向锁撤销
此时锁使用发生碰撞了,需要一个等待的情况,那就进入轻量级锁的步骤。
发生偏向锁碰撞的时候,我们要升级轻量级锁,但升级之前,要先进行偏向锁的撤销
撤销的要点:
\1. 偏向锁的撤销动作必须等待全局安全点
\2. 暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,如果偏向位是1的话就变成0
\3. 撤销偏向锁,恢复到无锁(标志位为 01)或轻量级锁(标志位为 00)的状态
此时谁都没有偏向锁
2.3 偏向锁的设置
偏向锁是在只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向锁可以提高带有同步但无竞争的程序性能。
它同样是一个带有效益权衡性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问比如线程池,那偏向模式就是多余的。
- 关闭偏向锁:可以通过
-XX:-UseBiasedLocking = false
来设置; - 偏向锁在1.6之后是默认开启的,但在应用程序启动几秒钟之后才激活,可以使用
-XX:BiasedLockingStartupDelay=0
参数关闭延迟,如果确定应用程序中所有锁通常情况下处于竞争状态,可以关闭偏向锁。 - 1.5中是关闭的,需要手动开启参数是
xx:-UseBiasedLocking=true
。
2.4 例子
线程1复用自己偏向锁的情况 | 对象头-Mark word |
---|---|
访问同步块 A,检查 Mark 中是否有线程 ID | 101(无锁可偏向) |
尝试加偏向锁 | 101(无锁可偏向)对象 hashCode |
成功 | 101(无锁可偏向)线程ID |
执行同步块 A | 101(无锁可偏向)线程ID |
访问同步块 B,检查 Mark 中是否有线程 ID | 101(无锁可偏向)线程ID |
是自己的线程 ID,锁是自己的,无需做更多操作 | 101(无锁可偏向)线程ID |
执行同步块 B | 101(无锁可偏向)线程ID |
执行完毕 | 101(无锁可偏向)对象 hashCode |
3 轻量级锁
轻量级锁中是指向栈中锁记录的指针的
偏向锁升级轻量级锁
有几个概念区分一下:
对象头的Mark Word
线程的锁记录Lock Record中
轻量级锁每个线程都会把Mark Word拷贝到他的线程中,重要的是谁能让MarkWord中的指针指向自己
轻量级锁的释放更复杂,首先他释放时候得看对象头里的Mark Word是否还指向自己(可能已经重量级锁了),其次要判断当前线程Mark Word的信息与对象头的Mark Word信息是否一致(万一是其他的对象锁呢)
当(关闭偏向锁功能)或者(多个线程竞争偏向锁导致偏向锁升级为轻量级锁),则会尝试获取轻量级锁,其步骤如下: 获取轻量级锁
- 1、判断当前对象是否处于无锁状态(
hashcode...-0-01
),如果是无锁,则JVM首先将在当前线程的栈帧中建立一个名为锁记录
(Lock Record
)的空间,用于存储锁对象目前的Mark Word的拷贝(这份栈中的拷贝叫Displaced Mark Word
),将对象的Mark Word复制到栈帧中的Lock Record中,将Lock Record中的owner指向当前对象(从栈帧指向对象头,object,也就是互相引用了,这样别的线程就知道是不是自己持有了锁)。否则执行步骤(3);- Mark Word在对象头中,Displaced Mark Word在栈帧中,Lock Record在Displaced Mark Word中,owner在Lock Record中
- 2、JVM利用
CAS
操作尝试将对象头的Mark Word
(的除后2位的bit)更新为指向Lock Record的指针(从对象头指向栈帧),如果成功表示竞争到锁,则将锁标志位变成00
(表示此对象处于轻量级锁状态),执行同步操作。;如果失败则执行步骤(3); - 3、如果前面执行成功了,那么就去执行业务代码了。如果1、2步失败,则判断当前对象的
Mark Word
是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,对象头的锁标志位变成10
(代表我来过了),后面等待的线程将会进入阻塞状态。
自旋
有自旋锁的情况:
- 线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;
- 如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。
- 但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
自旋
情景:
开车去学校,一条路是多路灯路段,一条路是无路灯路段(绕一点远),你选择走哪个?
答案:绕一点远的比较省时省油,因为车启动那一脚油门比较费油
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景(你没看错,是发生碰撞了,但是等会就获取到了,空转而不阻塞)。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋着等待锁释放。
自旋的由来:
- JDK1.6之前:当轻量级锁发发生竞争的时候会升级为重量级锁,但是重量级锁性能开销大,所以应该尽量避免升级为重量级锁。所以从轻量级升级为重量级的时候还会挣扎一下,避免升级为重量级。
- JDK1.6后:引入了 自旋锁(默认开启,默认自旋次数为
10
)、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
关于自旋的操作:
-XX:-UseSpinning
关闭自旋锁(1.6后默认开启)-XX:preBlockSpin
:调整自旋次数(默认10次)- 自适应自旋锁:可以按运行条件自动改变自旋次数
为什么要挣扎:因为线程切换上下文耗时,线程的阻塞和唤醒需要CPU从用户态转为核心态。如果cas不到锁直接阻塞万一别的线程用锁时间很短呢,没必要阻塞,等一会即可。所谓挣扎就是在阻塞之前循环几次cas拿锁,实在cas不到再阻塞
java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
- 如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;
- 如果对于那些需要同步的简单的代码块,获取锁挂起的耗时比用户代码执行的耗时还要长,这种同步策略显然非常糟糕的。
自适应自旋锁
在JDK 6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100次循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虛拟机就会变得越来越“聪明”了。
- 如果平均负载小于CPUs则一直自旋
- 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞
- 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
- 如果CPU处于节电模式则停止自旋
- 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
- 自旋时会适当放弃线程优先级之间的差异
它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。
- Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
- Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;
- Wait Set:那些调用wait方法被阻塞的线程被放置在这里;
- OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;
- Owner:当前已经获取到所资源的线程被称为Owner;
- !Owner:当前释放锁的线程。
JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。
OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。
处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。
- Java 6 之后自旋锁是自适应的
- Java 7 之后不能控制是否开启自旋功能
轻量级锁的释放
轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
- 取出在获取轻量级锁保存在
Displaced Mark Word
(就是自己线程栈帧中复制过mark word)中的数据。 - 用CAS操作将取出的数据替换当前java对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
- 如果CAS操作替换失败,说明有其他线程尝试获取该锁(被别人改为
10
了),则需要将轻量级锁需要膨胀升级为重量级锁。
4、重量级锁
重量级锁后Mark Word就不够用了,需要引入monitor来形成阻塞队列了,于此同时Mark Word对应的位也是monitor指针了
场景:好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等待时间长了划算)
自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。但是一直自旋也不好,所以要阻塞释放CPU给别人
轻量级锁升级重量级锁
monitor
monitor是重量级锁里的东西
重量级锁由monitor实现,当线程经monitor锁,得不到monitor锁的会阻塞。当拥有monitor锁的线程释放的时候会唤醒正在阻塞的线程来竞争锁。
synchronized方法的反编译会出现
{
monitorenter;
业务;
monitorexit;
}
Synchronize
的实现原理,无论是同步方法还是同步代码块,无论是ACC_SYNCHRONIZED
还是monitorenter
、monitorexit
都是基于Monitor
实现的
- 同步代码块:
monitorenter
指令插入到同步代码块的开始位置,monitorexit
指令插入到同步代码块的结束位置,JVM需要保证每一个monitorenter
都有一个monitorexit
与之相对应。任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter
指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁; - 同步方法:依靠的是方法修饰符上的
ACC_SYNCHRONIZED
实现。synchronized方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn指令,在VM字节码层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象。(摘自:http://www.cnblogs.com/javaminer/p/3889023.html)
线程尝试获取monitor的所有权,如果获取失败说明monitor被其他线程占用,则将线程加入到的同步队列中,等待其他线程释放monitor,当其他线程释放monitor后,有可能刚好有线程来获取monitor的所有权,那么系统会将monitor的所有权给这个线程,而不会去唤醒同步队列的第一个节点去获取,所以synchronized是非公平锁。如果线程获取monitor成功则进入到monitor中,并且将其进入数+1。
重量级锁下,当一个线程想要执行一段被synchronized圈起来的同步方法或者代码块时,该线程得先获取到synchronized修饰的对象对应的monitor。
monitor并不是随着对象创建而创建的。我们是通过synchronized修饰符告诉JVM需要为我们的某个对象创建关联的monitor对象。每个线程都存在两个ObjectMonitor
对象列表,分别为free
和used
列表。同时JVM中也维护着global locklist。当线程需要ObjectMonitor对象时,首先从线程自身的free表中申请,若存在则使用,若不存在则从global list中申请。
ObjectMonitor() {
_count = 0; //用来记录该对象被线程获取锁的次数
_waiters = 0;
_recursions = 0; //锁的重入次数
_owner = NULL; //指向持有ObjectMonitor对象的线程
_WaitSetLock = 0 ;
_WaitSet = NULL; // .wait()方法//处于wait状态的线程,会被加入到_WaitSet
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
}
排队流程
- 当多个线程同时访问该方法,那么这些线程会先被放进
_EntryList
队列,此时线程处于blocking阻塞状态 - 当一个线程获取到了实例对象的监视器(monitor)锁,那么就可以进入running状态,执行方法,此时,ObjectMonitor对象的
_owner
指向当前线程,_count
加1表示当前对象锁被一个线程获取 - 当running状态的线程调用wait()方法,那么当前线程释放monitor对象,进入waiting状态,ObjectMonitor对象的
_owner
变为null,_count
减1,同时线程进入_WaitSet
队列,直到有线程调用notify()方法唤醒该线程,则该线程重新获取monitor对象进入_Owner
区 - 如果当前线程执行完毕,那么也释放monitor对象,进入waiting状态,ObjectMonitor对象的
_owner
变为null,_count
减1
同时 Entrylist集合和 Waitsets集合中的线程会进入阻塞状阻塞的线程会进入内核调度状态,因为阻塞状态是通过 Linux的 pthread_ mutex_lock来实现的,所以线程要想阻塞就会发生用户态和内核态的切换此时会严重影响性能
lock锁是await和signal
synchronized是wait和notify
monitor代码
在HotSpot虚拟机中,monitor是由ObjectMonitor
实现的。其源码是用c++来实现的
// `src/share/vm/runtime/objectMonitor.hpp`
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; // 线程的重入次数
_object = NULL;//存储该monitor的对象 //即java对象引用了monitor,monitor又引用了java对象
// _owner初始时为NULL。当有线程占有该monitor时,owner标记为该线程的唯一标识。当线程释放monitor时,owner又恢复为NULL。owner是一个临界资源,JVM是通过CAS操作来保证其线程安全的。
_owner = NULL;//标识拥有该monitor的线程
// 因为调用wait方法而被阻塞的线程会被放在该队列中。
_WaitSet = NULL;//处于wait状态的线程,会被加入到waitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
// 竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)。`_cxq`是一个临界资源,JVM通过CAS原子指令来修改`_cxq`队列。修改前`_cxq`的旧值填入了node的next字段,`_cxq`指向新值(新线程)。因此`_cxq`是一个后进先出的stack(栈)。
_cxq = NULL ;//多线程竞争锁时的单向列表
FreeNext = NULL ;
// _cxq队列中有资格成为候选资源的线程会被移动到该队列中。
_EntryList = NULL ;//处于等待block状态的线程,会被加入到该列表 //从cxq移过来的
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
5、总结
轻量加锁成功演示
下面演示两个线程不冲突地获取锁的过程,即轻量加锁的1、2步的操作
线程 1 | 对象 Mark Word | 线程 2 |
---|---|---|
访问同步块 A,把 Mark 复制到线程 1 的锁记录 | 01(无锁) | - |
CAS 修改 Mark 为线程 1 锁记录地址 | 01(无锁) | - |
1 成功(加锁) | 00(轻量锁)线程 1 锁记录地址 | - |
执行同步块 A | 00(轻量锁)线程 1 锁记录地址 | - |
访问同步块 B,把 Mark 复制到线程 1 的锁记录 | 00(轻量锁)线程 1 锁记录地址 | - |
CAS 修改 Mark 为线程 1 锁记录地址 | 00(轻量锁)线程 1 锁记录地址 | - |
2 失败(但是发现是自己的锁) | 00(轻量锁)线程 1 锁记录地址 | - |
锁重入 | 00(轻量锁)线程 1 锁记录地址 | - |
执行同步块 B | 00(轻量锁)线程 1 锁记录地址 | - |
同步块 B 执行完毕 | 00(轻量锁)线程 1 锁记录地址 | - |
同步块 A 执行完毕 | 00(轻量锁)线程 1 锁记录地址 | - |
成功(解锁) | 01(无锁) | - |
- | 01(无锁) | 访问同步块 A,把 Mark 复制到线程 2 的锁记录 |
- | 01(无锁) | CAS 修改 Mark 为线程 2 锁记录地址 |
- | 00(轻量锁)线程 2 锁记录地址 | 成功(加锁) |
- | … | … |
轻量加锁失败演示:锁膨胀(轻量->重量)
下面演示轻量加锁第三步,由00轻量变10重量
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
线程 1 | 对象 Mark | 线程 2 |
---|---|---|
访问同步块,把 Mark 复制到线程 1 的锁记录 | 01(无锁) | - |
CAS 修改 Mark 为线程 1 锁记录地址 | 01(无锁) | - |
成功(加锁) | 00(轻量锁)线程 1 锁记录地址 | - |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | - |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | 访问同步块,把 Mark 复制到线程 2 |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | CAS 修改 Mark 为线程 2 锁记录地址 |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | 失败(发现别人已经占了锁) |
执行同步块 | 00(轻量锁)线程 1 锁记录地址 | CAS 修改 Mark 为重量锁 |
执行同步块 | 10(重量锁)重量锁指针 | 阻塞中 |
执行完毕 | 10(重量锁)重量锁指针 | 阻塞中 |
解锁(失败) | 10(重量锁)重量锁指针 | 阻塞中 |
释放重量锁,唤起阻塞线程竞争 | 01(无锁) | 阻塞中 |
- | 10(重量锁) | 竞争重量锁 |
- | 10(重量锁) | 成功(加锁) |
- | … | … |
线程1 (cpu 1 上)重量自旋重试成功的情况 | 对象Mark | 线程2 (cpu 2 上) |
---|---|---|
- | 10(重量锁) | - |
访问同步块,获取 monitor | 10(重量锁)重量锁指针 | - |
成功(加锁) | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取 monitor |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行完毕 | 10(重量锁)重量锁指针 | 自旋重试 |
成功(解锁,把标志位变为01) | 01(无锁) | 自旋重试 |
- | 10(重量锁)重量锁指针 | 成功(加锁) |
- | 10(重量锁)重量锁指针 | 执行同步块 |
- | … | … |
线程 1(cpu 1上)重量自旋重试失败的情况 | 对象Mark | 线程2(cpu 2 上) |
---|---|---|
- | 10(重量锁) | - |
访问同步块,获取 monitor | 10(重量锁)重量锁指针 | - |
成功(加锁) | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | - |
执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取 monitor |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重量锁指针 | 阻塞 |
- | … | … |
6、jol实战验证
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.13</version>
</dependency>
对象头的大小:Java对象头一般占有两个机器码(注意是机器码不是字节码。在32位虚拟机中,1个机器码等于4字节,也就是32bit),
但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
public static void main(String[] args) {
// 声明一枚长度为3306的数组
int[] intArr = newint[3306];
// 使用jol的ClassLayout工具分析对象布局
System.out.println(ClassLayout.parseInstance(intArr).toPrintable());
}
print:
------------就是对象刚创建好的状态-------------------------
[I object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01000000 (00000001000000000000000000000000) (1) // 这个1是偏向锁标志
4 4 (object header) 00000000 (00000000000000000000000000000000) (0)
8 4 (object header) 6d 0100 f8 (01101101000000010000000011111000) (-134217363) // 这行如果是非数组对象是没有的
12 4 (object header) ea 0c 0000 (11101010000011000000000000000000) (3306)
1613224int [I.<elements> N/A
Instance size: 13240 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
import org.openjdk.jol.info.ClassLayout;
public class Main13 {
public static void main(String[] args) {
Main13 main13 = new Main13();
System.out.println(ClassLayout.parseInstance(main13).toPrintable());
}
}
//---------输出------------------
Main13 object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c1 00 20 (00000101 11000001 00000000 00100000) (536920325)
12 4 (loss due to the next object alignment)
// 0-11共16个字节,即64位×2=8×2
// 如果加个int a成员变量,最后一行改为
/*
12 4 int Main13.a 0
*/
// 如果加个boolean a,最后一行改为如下,会有字节对齐
/*
12 1 boolean Main13.a false
13 3 (loss due to the next object alignment)
*/
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
// 对齐填充:
对齐填充并不是必然存在的,也没有什么特别的意义,他仅仅起着占位符的作用,由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
8、synchronized使用技巧
锁消除
Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间
锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下要求同步呢?实际上有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中的普遍程度也许超过了大部分读者的想象。下面这段非常简单的代码仅仅是输出3个字符串相加的结果,无论是源码字面上还是程序语义上都没有同步。
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这时JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。 如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法:
// 在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。
public static void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
}
public static void main(){contactStr("aa", "bb", "cc");}
public static String contactStr(String s1, String s2, String s3) {
return new StringBuffer().append(s1)
.append(s2)
.append(s3)
.toString();
// StringBuffer的append()是一个同步方法,锁就是this也就是(new StringBuilder())。虚拟机发现它的动态作用域被限制在concatString( )方法内部。也就是说, new StringBuilder()对象的引用永远不会“逃逸”到concatString ( )方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
}
锁粗化
按理来说,同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
但是加锁解锁也需要消耗资源,如果存在一个线程一系列的连续加锁解锁操作,甚至加锁操作是出现在循环体中的,可能会导致不必要的性能损耗。
锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。
什么是锁粗化?JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可。
public class Demo01 {
public static void main(String[] args) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 100; i++) {
//
b.append("aa");
}
System.out.println(sb.toString());
}
}
32位的mark word
在32位
虚拟机下,Mark Word
是32bit大小的,其存储结构如下:
源码
术语参考: http://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html
JVM源码下载
http://openjdk.java.net/ --> Mercurial --> jdk8 --> hotspot --> zip
C++ IDE(Clion )下载 https://www.jetbrains.com/
oop体系:
当一个线程尝试访问synchronized修饰的代码块时,它首先要获得锁,那么这个锁到底存在哪里呢?是存在锁对象的对象头中的。
HotSpot采用instanceOopDesc
和arrayOopDesc
来描述对象头。arrayOopDesc对象用来描述数组类型。instanceOopDesc
的定义的在Hotspot源码的 instanceOop.hpp
文件中。arrayOopDesc
的定义对应 arrayOop.hpp
。
//
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() {
// offset computation code breaks if UseCompressedClassPointers
// only is true
return (UseCompressedOops && UseCompressedClassPointers) ?
klass_gap_offset_in_bytes() :
sizeof(instanceOopDesc);
}
static bool contains_field_offset(int offset, int nonstatic_field_size) {
int base_in_bytes = base_offset_in_bytes();
return (offset >= base_in_bytes &&
(offset-base_in_bytes) < nonstatic_field_size * heapOopSize);
}
};
下面列出的是整个Oops模块的组成结构,其中包含多个子模块。每一个子模块对应一个类型,每一个类型的OOP都代表一个在JVM内部使用的特定对象的类型。
//定义了oops共同基类
typedef class oopDesc* oop;
//表示一个Java类型实例
typedef class instanceOopDesc* instanceOop;
//表示一个Java方法
typedef class methodOopDesc* methodOop;
//表示一个Java方法中的不变信息
typedef class constMethodOopDesc* constMethodOop;
//记录性能信息的数据结构
typedef class methodDataOopDesc* methodDataOop;
//定义了数组OOPS的抽象基类
typedef class arrayOopDesc* arrayOop;
//表示持有一个OOPS数组
typedef class objArrayOopDesc* objArrayOop;
//表示容纳基本类型的数组
typedef class typeArrayOopDesc* typeArrayOop;
//表示在Class文件中描述的常量池
typedef class constantPoolOopDesc* constantPoolOop;
//常量池告诉缓存
typedef class constantPoolCacheOopDesc* constantPoolCacheOop;
//描述一个与Java类对等的C++类
typedef class klassOopDesc* klassOop;
//表示对象头
typedef class markOopDesc* markOop;
基类
从上面的代码中可以看到,有一个变量opp的类型是oppDesc
,OOPS类的共同基类型为oopDesc
。
// oop.hpp // instanceOopDesc继承自oopDesc
class oopDesc {//对象头的父类
friend class VMStructs;
private:
volatile markOop _mark;// Mark World
union _metadata { //类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址
Klass* _klass;// 类型指针 Klass
narrowKlass _compressed_klass;// 压缩类指针
} _metadata;
// Fast access to barrier set. Must be initialized.
static BarrierSet* _bs;
// 省略其他代码
};
**在Java程序运行过程中,每创建一个新的对象,在JVM内部就会相应地创建一个对应类型的OOP对象。**在HotSpot中,根据JVM内部使用的对象业务类型,具有多种oopDesc
的子类。除了oppDesc
类型外,opp体系中还有很多instanceOopDesc
、arrayOopDesc
等类型的实例,他们都是oopDesc
的子类。
这些OOPS在JVM内部有着不同的用途,例如**,instanceOopDesc
表示类实例,arrayOopDesc
表示数组。**也就是说,当我们使用new
创建一个Java对象实例的时候,JVM会创建一个instanceOopDesc
对象来表示这个Java对象。同理,当我们使用new
创建一个Java数组实例的时候,JVM会创建一个arrayOopDesc
对象来表示这个数组对象。
在HotSpot中,oopDesc类定义在oop.hpp中,instanceOopDesc定义在instanceOop.hpp中,arrayOopDesc定义在arrayOop.hpp中。
简单看一下相关定义:
class instanceOopDesc : public oopDesc {
}
class arrayOopDesc : public oopDesc {
}
通过上面的源码可以看到,instanceOopDesc
实际上就是继承了oopDesc
,并没有增加其他的数据结构,也就是说**instanceOopDesc
中包含两部分数据:markOop _mark
和union _metadata
。**
这里的markOop
你可能又熟悉了,这不就是OOPS体系中的一部分吗,上面注释中已经说过,他表示对象头。 _metadata
是一个联合体,这个字段被称为元数据指针。指向描述类型Klass对象的指针。
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头、实例数据和对齐填充。在虚拟机内部,一个Java对象对应一个instanceOopDesc
的对象,该对象中有两个字段分别表示了对象头和实例数据。那就是_mark
和_metadata
。
文章开头我们就说过,之所以我们要写这篇文章,是因为对象头中有和锁相关的运行时数据,这些运行时数据是synchronized
以及其他类型的锁实现的重要基础。因为本文主要介绍的oop-klass
模型,在这里暂时不对对象头做展开,下一篇文章介绍。
前面介绍到的_metadata
是一个共用体,其中_klass
是普通指针,_compressed_klass
是压缩类指针。在深入介绍之前,就要来到oop-Klass
中的另外一个主角klass
了。
klass
每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个instanceKlass
,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc
对象,这个对象中包含了对象头以及实例数据。
klass体系
//klassOop的一部分,用来描述语言层的类型
class Klass;
//在虚拟机层面描述一个Java类
class instanceKlass;
//专有instantKlass,表示java.lang.Class的Klass
class instanceMirrorKlass;
//专有instantKlass,表示java.lang.ref.Reference的子类的Klass
class instanceRefKlass;
//表示methodOop的Klass
class methodKlass;
//表示constMethodOop的Klass
class constMethodKlass;
//表示methodDataOop的Klass
class methodDataKlass;
//最为klass链的端点,klassKlass的Klass就是它自身
class klassKlass;
//表示instanceKlass的Klass
class instanceKlassKlass;
//表示arrayKlass的Klass
class arrayKlassKlass;
//表示objArrayKlass的Klass
class objArrayKlassKlass;
//表示typeArrayKlass的Klass
class typeArrayKlassKlass;
//表示array类型的抽象基类
class arrayKlass;
//表示objArrayOop的Klass
class objArrayKlass;
//表示typeArrayOop的Klass
class typeArrayKlass;
//表示constantPoolOop的Klass
class constantPoolKlass;
//表示constantPoolCacheOop的Klass
class constantPoolCacheKlass;
和oopDesc
是其他oop类型的父类一样,Klass类是其他klass类型的父类。
Klass向JVM提供两个功能:
- 实现语言层面的Java类(在Klass基类中已经实现)
- 实现Java对象的分发功能(由Klass的子类提供虚函数实现)
文章开头的时候说过:之所以设计oop-klass
模型,是因为HotSopt JVM的设计者不想让每个对象中都含有一个虚函数表。
HotSopt JVM的设计者把对象一拆为二,分为klass
和oop
,其中oop
的职能主要在于表示对象的实例数据,所以其中不含有任何虚函数。而klass为了实现虚函数多态,所以提供了虚函数表。所以,关于Java的多态,其实也有虚函数的影子在。
_metadata
是一个共用体,其中_klass
是普通指针,_compressed_klass
是压缩类指针。这两个指针都指向**instanceKlass
对象,它用来描述对象的具体类型。**
instanceKlass
JVM在运行时,需要一种用来标识Java内部类型的机制。在HotSpot中的解决方案是:为每一个已加载的Java类创建一个instanceKlass
对象,用来在JVM层表示Java类。
来看下instanceKlass的内部结构:
//类拥有的方法列表
objArrayOop _methods;
//描述方法顺序
typeArrayOop _method_ordering;
//实现的接口
objArrayOop _local_interfaces;
//继承的接口
objArrayOop _transitive_interfaces;
//域
typeArrayOop _fields;
//常量
constantPoolOop _constants;
//类加载器
oop _class_loader;
//protected域
oop _protection_domain;
....
可以看到,一个类该具有的东西,这里面基本都包含了。
这里还有个点需要简单介绍一下。
在JVM中,对象在内存中的基本存在形式就是oop。那么,对象所属的类,在JVM中也是一种对象,因此它们实际上也会被组织成一种oop,即klassOop。同样的,对于klassOop,也有对应的一个klass来描述,它就是klassKlass,也是klass的一个子类。klassKlass作为oop的klass链的端点。关于对象和数组的klass链大致如下图:
在这种设计下,JVM对内存的分配和回收,都可以采用统一的方式来管理。oop-klass-klassKlass关系如图:
内存存储
关于一个Java对象,他的存储是怎样的,一般很多人会回答:对象存储在堆上。稍微好一点的人会回答:对象存储在堆上,对象的引用存储在栈上。今天,再给你一个更加显得牛逼的回答:
对象的实例(instantOopDesc)保存在堆上,对象的元数据(instantKlass)保存在方法区,对象的引用保存在栈上。
其实如果细追究的话,上面这句话有点故意卖弄的意思。因为我们都知道。方法区用于存储虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。 所谓加载的类信息,其实不就是给每一个被加载的类都创建了一个 instantKlass对象么。
talk is cheap ,show me the code :
class Model {
public static int a = 1;
public int b;
public Model(int b) {
this.b = b;
}
}
public static void main(String[] args) {
int c = 10;
Model modelA = new Model(2);
Model modelB = new Model(3);
}
存储结构如下:
总结
每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个instanceKlass
,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc
对象,这个对象中包含了两部分信息,方法头以及元数据。对象头中有一些运行时数据,其中就包括和多线程相关的锁的信息。元数据其实维护的是指针,指向的是对象所属的类的instanceKlass
。
重量级锁Moniter
的实现原理
操作系统中的管程
如果你在大学学习过操作系统,你可能还记得管程(monitors)在操作系统中是很重要的概念。同样Monitor在java同步机制中也有使用。
管程 (英语:Monitors,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。这些共享资源一般是硬件设备或一群变量。管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计。 管程提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。
Moniter相关方法
获得锁
释放锁
除了enter和exit方法以外,objectMonitor.cpp中还有
void wait(jlong millis, bool interruptable, TRAPS);
void notify(TRAPS);
void notifyAll(TRAPS);
等方法。
更多推荐
所有评论(0)