(JUC)图文并茂!!!! 超详细 偏向锁VS轻量级锁VS重量级锁VS自旋

在开始介绍今天的内容之前,我们先来认识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位的情况举例)

普通对象的对象头:(包括MarkWordklass word两部分)
在这里插入图片描述

数组对象的对象头:(包括MarkWordklass 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。以后只要不发生竞争,这个对象就归该线程所有

几点说明:

  1. 在JDK1.6中.偏向锁是默认开启的,我们可以通过在VMoptions中设置参数(-XX:-UseBiasedLocking)来关闭偏向锁;

  2. 偏向锁默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以在VMoptions中设置参数:-XX:BiasedLockingStartupDelay=0来禁用延迟

  3. 如果我们禁用了偏向锁,那么给对象上锁时,会加轻量级锁

我们通过以下实验来测试一下相关说明:

这里利用 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;
}

  1. 这里说的请求应该来自于Object::hashcode()或者System::identityHashCode(Object)方法的调用,如果重写了对象的hashcode方法,计算哈希码是并不会产生这里所说的请求 ↩︎

  • 3
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值