偏向锁,轻量级锁,重量级锁
在开始介绍今天的内容之前,我们先来认识java中的对象
,对象想必大家都已经很熟悉,但是大家有想过java对象在内存中"长"什么样吗??它是怎么组成的吗??
前言
java对象内存布局
java对象在内存中分为3部分,分别是对象头(Oject Header),实例数据(Instance Data),补齐填充(Padding)
对象头(Object Header)
注意:
(1)这里暂不考虑指针压缩的场景
(2)这里都是针对HotSpot虚拟机进行介绍
- MarkWord :用来存放一些标志位(比如锁的标记位)和hashcode,分代年龄等,在32位虚拟机下,MarkWord占4个字节,在64位虚拟机下,占8个字节
- Klass Pointer(或者Class Pointer) :用来存放该对象所对应的class类对象在方法区中的地址,在32位虚拟机下占4字节,在64位虚拟机下占8字节;
- Length :如果对象是数组对象,那么对象头中还有一个Length字段,用来记录数组的长度,占4个字节;
(这里以HotSpot虚拟机为32位的情况举例)
普通对象的对象头:(包括MarkWord
和klass word
两部分)
数组对象的对象头:(包括MarkWord
和klass word
和数组长度
三部分)
JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
MarkWord的状态变化(32位Hotspot虚拟机下)
在正常状态下(也即是无锁状态下):
32位虚拟机中的MarkWord包含了25位的hashcode,4位的分代年龄,1位偏向锁标志位,2位锁标志位;
MarkWord的状态变化(64位Hotspot虚拟机下)
实例数据(Instance Data)
实例数据部分是对象真正存储的有效信息,也就是我们在代码里面所定义的各种类型的字段内容(成员变量),无论是从父类继承下来的,还是在子类中定义的都需要记录下来;
对齐填充(Padding)
第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。对象头正好是8字节的倍数(1倍或者2倍),因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
在认识了对象头之后,我们接下来介绍JAVA中的几种锁以及优化,在此之前,我们有必要知道以下几点:
三者 优先级:
(上锁消耗的资源越少,优先级越高)
(1)偏向锁 > 轻量级锁 > 重量级锁 (这3个锁都是用synchronized关键字来上锁)
(2)轻量级锁的使用场景:
一个对象有多个线程需要加锁,但是加锁的时间是错开的(即没有竞争),此时可以使用轻量级锁来优化;
(3)偏向锁的使用场景:
轻量级锁在没有竞争,每次重入仍然需要CAS操作,Java 6 之后引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有
1 .在Java SE 1.6中为了减少获得锁和释放锁带来的性能消耗,引入了"偏向锁"和"轻量级锁",在Java SE 1.6中,锁一共有4中状态,级别从低到高依次是:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态,这几个状态会随着竞争情况逐渐升级;锁可以升级但是不能降级,意味着偏向锁升级为轻量级锁后不能降级成偏向锁.这种锁升级确不能降级的策略,目的是为了提高获得锁和释放锁的效率;
2.所谓的重量级锁和轻量级锁,是从性能消耗方面来命名的;轻量级锁是JDK1.6引入的,“轻量级"是相对于使用操作系统互斥量来实现的传统锁(重量级锁)而言的;重量级锁由于加锁和解锁性能消耗大,并且如果发生锁竞争,会发生线程的阻塞和唤醒,这个操作是借助操作系统的系统调用,然而系统调用会涉及到内核态和用户态的切换,因此正是由于重量级锁的性能消耗大,我们称作"重”,而轻量级锁相比较而言性能消耗较少,我们称作"轻";
轻量级锁并不是用来替代重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗
3.轻量级锁不会发生自旋,重量级锁才会有自旋
点击链接查看源码分析
(1)重量级锁(Moniter)
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中前62位就会被设置为 Monitor 对象的地址,后两位表示此时对象的状态,对象头中的信息暂时保存在Moniter中,等到解锁时才取出;
图解:
- 刚开始 Monitor 中 Owner 为 null
- 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一
个 Owner - 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入
EntryList进而BLOCKED - Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是公平的
- 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲
wait-notify 时会分析
注意:
synchronized 必须是进入同一个对象的 monitor 才有上述的效果
不加 synchronized 的对象不会关联监视器,不遵从以上规则
(2)轻量级锁
线程在执行同步代码块之前,JVM会在当前线程的中栈帧中创建用于存储锁记录(Lock Record)的空间,并将对象
轻量级锁对使用者是透明的,即语法仍然是 synchronized
- (1)创建锁记录(Lock Record)对象,每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
- (2)让锁记录中 Object reference 指向锁对象,并尝试用 CAS(原子操作) 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录
- (3)如果 cas 替换成功,对象头中存储了锁记录地址和状态 00 ,表示由该线程给对象加锁,这时图示如下
- (4)如果 cas 失败,有两种情况
1 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
2 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数
解释:所谓锁重入,就是给一个对象上锁多次,每一次重入都要进行CAS操作,当发现对象头中已经有锁记录的地址了,则添加一条Lock Record 作为重入的计数
假设有两个方法同步块,利用同一个对象加锁
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
- (5)当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头;
如果操作成功,则解锁成功
如果操作失败,说明轻量级锁进行了锁膨胀或已经升级为了重量级锁,那么此时进入重量级锁解锁流程
锁膨胀、自旋、自适应自旋
锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
自旋
自旋是让CPU执行一些没有意义的指令,目的是让线程不放弃对CPU的占用
重量级锁竞争的时候,还可以使用自旋来进行优化;
正常情况下锁获取失败就应该阻塞入队,但是有时候可能刚一阻塞,别的线程就释放锁了,然后再唤醒刚刚阻塞的线程,这就没必要了。
所以在线程竞争不是很激烈的时候,稍微自旋一会儿,指不定不需要阻塞线程就能直接获取锁,这样就避免了不必要的开销,提高了锁的性能。
如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
自旋重试成功的情况
自旋重试失败的情况
注意:自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
自适应自旋
但是自旋的次数又是一个难点,在竞争很激烈的情况,自旋就是在浪费 CPU,因为结果肯定是自旋一会让之后阻塞。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
Java 7 之后不能控制是否开启自旋功能
(3)偏向锁(以64位虚拟机为例)
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有
几点说明:
-
在JDK1.6中.偏向锁是默认开启的,我们可以通过在VMoptions中设置参数(-XX:-UseBiasedLocking)来关闭偏向锁;
-
偏向锁默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以在VMoptions中设置参数:-XX:BiasedLockingStartupDelay=0来禁用延迟
-
如果我们禁用了偏向锁,那么给对象上锁时,会加轻量级锁
我们通过以下实验来测试一下相关说明:
这里利用 jol 第三方工具来查看对象头信息(注意这里扩展了 jol 让它输出更为简洁)
不设置延迟为0
如果不设置延迟为0,那么新建的对象是处于正常状态,因为此时偏向锁还未生效,后3位为001,如果使当前线程睡眠4s,再新建一个对象,会发现此时偏向锁已经生效,后3位为101
设置延迟为0
禁用延迟后,偏向锁在程序启动时就生效,因此新建的两个对象都是处于可偏向状态
但我们发现,以上除了后3位有值外,其余的61位都是0,那是因为此时的对象处于可偏向状态,我们还没有给对象上锁,所以这时它的 thread、epoch、age 都为 0
现在给对象上锁:
// 添加虚拟机参数 -XX:BiasedLockingStartupDelay=0
class Dog {
}
public static void main(String[] args) throws IOException {
Dog d = new Dog();
ClassLayout classLayout = ClassLayout.parseInstance(d);
new Thread(() -> {
log.debug("synchronized 前");
System.out.println(classLayout.toPrintableSimple(true));
synchronized (d) {
log.debug("synchronized 中");
System.out.println(classLayout.toPrintableSimple(true));
}
log.debug("synchronized 后");
System.out.println(classLayout.toPrintableSimple(true));
}, "t1").start();
}
输出:
11:08:58.117 c.TestBiased [t1] - synchronized 前
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101
11:08:58.121 c.TestBiased [t1] - synchronized 中
00000000 00000000 00000000 00000000 00011111 11101011 11010000 00000101
11:08:58.121 c.TestBiased [t1] - synchronized 后
00000000 00000000 00000000 00000000 00011111 11101011 11010000 00000101
注意:这里我们发现解锁后,MarkWord没有变化,这里就体现的是偏向锁的"偏"的特性:这个被上锁的对象,会偏向于第一次给他上锁的线程,即使退出了同步代码块,该对象的MarkWord中仍然会存储该线程ID(注意:这里的线程ID是JVM中的ID,不同与操作系统中线程ID),直到有其他线程使用该对象或者其他条件发生时才会改变
测试禁用偏向锁:在上面测试代码运行时在添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁
11:13:10.018 c.TestBiased [t1] - synchronized 前
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
11:13:10.021 c.TestBiased [t1] - synchronized 中
00000000 00000000 00000000 00000000 00100000 00010100 11110011 10001000
11:13:10.021 c.TestBiased [t1] - synchronized 后
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
会发现此时进入Synchronized代码块,打印的后3位为000,说明此时给对象加的是轻量级锁
所以:如果我们禁用了偏向锁,那么给对象上锁时,会加轻量级锁
2.偏向锁撤销的3种情况
(1)当一个对象调用hashcode()方法时:会禁用偏向锁???
通过图可以发现确实禁用了偏向锁,因为在加锁之前,Markword的后3位是001,代表正常状态,并且填充了31位的哈希码
那这是为什么呢??为什么调用了对象的hashcode方法后,偏向锁就失效了呢???
我们知道在对象头的MarkWord中有一个字段是用来存放线程ID的;
当对象进入偏向状态的时候,MarkWord的中的54bit会拿来存放持有锁的线程ID,那原来对象的哈希码怎么办呢???
如果是轻量级锁会在锁记录中记录 hashCode
如果是重量级锁会在 Monitor 中记录 hashCode
如果是偏向锁会在当前对象的Markword中记录hashcode
对象新建时哈希码默认是0;当对象第一次调用hashcode()方法时才会产生哈希码,并填充到MarkWord中;
此时64位的MarkWord已经被占用了31位了,如果此时要开启偏向锁,那么就还得在MarkWord拿54位来存储线程ID号,显然空间不够,因此JVM规定了如果一个可偏向对象调用了hashcode方法,会使偏向锁失效;
我们这里是可偏向对象调用hashcode方法,那如果是已经处于偏向状态的对象,又收到需要计算其一致性哈希码请求时1。它的偏向状态会立即被撤销,并且锁会膨胀为重量级锁;在重量级锁的实现中,代表重量级锁的Moniter对象会存放非加锁状态下(标志位为"01")的MarkWord,其中自然可以存放原来的hashcode
(2) 当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
当一个对象加锁时,默认是加的偏向锁,当解锁时(syncronized中代码执行完),此时线程的ID号仍然会留在对象头的Markword中,因此当另一个线程再给这个对象加锁时,偏向锁就会失效,并升级为轻量级锁
(3)当使用wait(),notify()方法时,偏向锁也会撤销
wait()和notify()是在重量级锁中使用的方法,因此如果使用了这两个方法,表明偏向锁已经撤销
3.批量重偏向和批量撤销
JVM 基于一种启发式的做法判断是否应该触发批量撤销或批量重偏向
依赖三个阈值作出判断:
# 批量重偏向阈值
-XX:BiasedLockingBulkRebiasThreshold=20
# 重置计数的延迟时间
-XX:BiasedLockingDecayTime=25000
# 批量撤销阈值
-XX:BiasedLockingBulkRevokeThreshold=40
简单总结,对于一个类,按默认参数来说:
单个偏向撤销的计数达到 20,就会进行批量重偏向。
距上次批量重偏向 25 秒内,计数达到 40,就会发生批量撤销。
每隔 (>=) 25 秒,会重置在 [20, 40) 内的计数为0,这意味着可以发生多次批量重偏向。
注意:对于一个类来说,批量撤销只能发生一次,因为批量撤销后,该类禁用了可偏向属性,后面该类的对象都是不可偏向的,包括新创建的对象。
启发式源码:
static HeuristicsResult update_heuristics(oop o, bool allow_rebias) {
markOop mark = o->mark();
// 如果不是偏向模式直接返回
if (!mark->has_bias_pattern()) {
return HR_NOT_BIASED;
}
// 获取锁对象的类元数据
Klass* k = o->klass();
// 当前时间
jlong cur_time = os::javaTimeMillis();
// 该类上一次批量重偏向的时间
jlong last_bulk_revocation_time = k->last_biased_lock_bulk_revocation_time();
// 该类单个偏向撤销的计数
int revocation_count = k->biased_lock_revocation_count();
// 按默认参数来说:
// 如果撤销计数大于等于 20,且小于 40,
// 且距上次批量撤销的时间大于等于 25 秒,就会重置计数。
if ((revocation_count >= BiasedLockingBulkRebiasThreshold) &&
(revocation_count < BiasedLockingBulkRevokeThreshold) &&
(last_bulk_revocation_time != 0) &&
(cur_time - last_bulk_revocation_time >= BiasedLockingDecayTime)) {
// This is the first revocation we've seen in a while of an
// object of this type since the last time we performed a bulk
// rebiasing operation. The application is allocating objects in
// bulk which are biased toward a thread and then handing them
// off to another thread. We can cope with this allocation
// pattern via the bulk rebiasing mechanism so we reset the
// klass's revocation count rather than allow it to increase
// monotonically. If we see the need to perform another bulk
// rebias operation later, we will, and if subsequently we see
// many more revocation operations in a short period of time we
// will completely disable biasing for this type.
k->set_biased_lock_revocation_count(0);
revocation_count = 0;
}
if (revocation_count <= BiasedLockingBulkRevokeThreshold) {
// 自增计数
revocation_count = k->atomic_incr_biased_lock_revocation_count();
}
// 此时,如果达到批量撤销阈值,则进行批量撤销。
if (revocation_count == BiasedLockingBulkRevokeThreshold) {
return HR_BULK_REVOKE;
}
// 此时,如果达到批量重偏向阈值,则进行批量重偏向。
if (revocation_count == BiasedLockingBulkRebiasThreshold) {
return HR_BULK_REBIAS;
}
// 否则,仅进行单个对象的撤销偏向
return HR_SINGLE_REVOKE;
}
这里说的请求应该来自于Object::hashcode()或者System::identityHashCode(Object)方法的调用,如果重写了对象的hashcode方法,计算哈希码是并不会产生这里所说的请求 ↩︎