synchronized 锁的是什么?(一)

简介: JOL 的全称是 Java Object Layout。是一个用来分析 JVM 中 Object 布局的小工具。包括 Object 在内存中的占用情况,实例对象的引用情况等等。

概述


本文我们将回答两个问题:


  1. synchronized 锁的是什么?


  1. 为什么 wait() 和 notify() 需要搭配 synchonized 关键字使用 ?


我将通过先介绍基础知识再回答问题的方式来解答这两个问题,了解了前面的基础知识后,问题也就迎刃而解了。


前知识-对象头(mark word)


内存布局


我们知道 java 对象的内存布局如下图所示:


42.jpg


而其中对象头区域包含  markword 和 class pointer


利用 JOL 可以分析内存中的对象布局


“JOL 的全称是 Java Object Layout。是一个用来分析 JVM 中 Object 布局的小工具。包括 Object 在内存中的占用情况,实例对象的引用情况等等。”


添加依赖


<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.10</version>
</dependency>


public class A {
    //占一个字节的 boolean 字段
    private boolean flag;
    public static void main(String[] args) {
        A a = new A();
        //打印对应的对象头信息
        System.out.println(ClassLayout.parseInstance(a).toPrintable());
    }
}


我们利用上面的程序对对象头的内存情况进行一下探究。上面程序执行后的结果如下图:


43.jpg


这里 一共 16 个字节


  • mark word 占了 8 个字节
  • class pointer 类型指针占了 4 个字节
  • 实例数据 1 个字节
  • 对齐填充部分 3 个字节


其中由于 JVM 开启了指针压缩,所以 class pointer  是 4 个字节,如果关闭指针压缩(添加 vm 参数:-XX:-UseCompressedOops),则是 8 个字节。


另外,64 位虚拟机上对象的大小必须是 8 的倍数,上图中一共 16 个字节,是 8 的倍数。


对象头


根据  文档 (http://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html) 得知 对象头有两个 word , 其一为 markword ,另一为 klass pointer


44.jpg


通过上面的例子我们已经知道了,在开启指针压缩的情况下 对象头(mark workd + klass pointer) 一般占 12 个字节。


但是,如果对象是数组,情况就不一样了。当对象是一个数组对象时,那么在对象头中有一个保存数组长度的空间,占用 4 字节(32bit)空间


public class A {
    //占一个字节的 boolean 字段
    private boolean flag;
    public static void main(String[] args) {
        A[] a = new A[2];
        //打印对应的对象头信息
        System.out.println(ClassLayout.parseInstance(a).toPrintable());
    }
}


45.jpg


可以看到 对象头(object header)又多了 4 个字节用于存放数组长度。


46.jpg


klass pointer


Klass Pointer是一个指向方法区中Class信息的指针,虚拟机通过这个指针确定该对象属于哪个类的实例。


在 64 位的 JVM 中,支持指针压缩功能,根据是否开启指针压缩,Klass Pointer占用的大小将会不同:


  • 未开启指针压缩时,类型指针占用 8B (64bit)
  • 开启指针压缩情况下,类型指针占用 4B (32bit)


指针压缩原理


我们将程序从 32 位移到 64 位是为了程序性能的提升,但是涉及 JVM 的情况并非总是如此,造成这种性能下降的主要原因是 64 位对象引用。


64 位引用占用的空间是 32 位引用的两倍,这通常导致更多的内存消耗和更频繁的 GC 周期,而且对象的引用完全用不到 64 位,因为 64 位代表的内存大小为 2^64,其内存大小完全达不到,因此就需要压缩指针来获取性能上的提升。


内存寻址是以字节为单位 ,32 位寻址空间约 4GB (4 * 1024 * 1024 * 1024 Byte) = 2 的 32 次方。同理 64 位理论上可以达到 2 的 64 次方字节,2097152T

我们知道 JVM 对象对齐会使对象的大小都是 8 字节的倍数,这会使 oops 的最后三位始终为零,这是因为 8 的倍数始终以二进制 000 结尾。


这 3 位 000 在堆中的存储是完全没有意义的,因此我们可以将这 3 位用来存储更多的内存地址,相当于 35 位的地址压缩在 32 位地址上使用,这样我们内存使用就从原来的 2^32=4G 提升为 2^35=32G。


47.jpg


何为 Oop?


Oop(ordinary object pointer),可以翻译为普通对象指针,指向的是 Java 对象在内存中的引用。


哪些对象会被压缩?


如果配置 JVM 参数 UseCompressedOops 为 true,则代表启用压缩指针,则将压缩堆中的以下 oops:


  • 每个对象头的 klass 字段
  • 每个 oop 实例字段
  • oop 数组(objArray)的每个元素


需要注意的是,在 UseCompressedOops 已经开启的基础上,klass 可以通过 UseCompressedClassPointers 单独设置是否开启。UseCompressedClassPointers 必须基于 UseCompressedOops 开启的情况下才可以设置是否开启,如果 UseCompressedOops 设为 false,则 UseCompressedClassPointers 无法设置为 ture。


mark word


具体来看一下 markword  的内部结构


48.jpg


根据 JVM 源码


49.jpg


具体我们写代码看一下:


public class A {
    //占一个字节的 boolean 字段
    private boolean flag;
    public static void main(String[] args) {
        A a = new A();
        out.println("before hash");
        out.println(ClassLayout.parseInstance(a).toPrintable());
        //jvm 计算 HashCode
        out.println("jvm----------" + Integer.toHexString(a.hashCode()));
        //当计算完 HashCode 之后,我们可以查看对象头的信息变化
        out.println("after hash");
        out.println(ClassLayout.parseInstance(a).toPrintable());
    }
}


50.jpg


可以看到我们在没有进行 hashcode 运算的时候,所有的值都是空的。当我们计算完了 hashcode,对象头就是有了数据。因为是小端存储,所以你看的值是倒过来的。前 25bit 没有使用所以都是 0,后面 31bit 存的 hashcode。这跟上图 64 位 markword 所描述的一样。


那么在无锁状态下  ojbect header  第一个字节 8 位存储的就是:


51.jpg


即 00000001 。


最后一位代表的锁标志为 1 ,表示该对象 无锁


然而锁标志位 2bit 只能表示 4 种状态(00,01,10,11)JVM 的做法将偏向锁和无锁的状态表示为同一个状态,然后根据上图中偏向锁的标识再去标识是无锁还是偏向锁状态。


Java 的对象头在对象的不同的状态下会有不同的表现形式,主要有三种状态


  • 无锁状态
  • 加锁状态
  • GC 标记状态


那么就可以理解 Java 当中的上锁其实可以理解给对象上锁,也就是改变对象头的状态 synchronized 锁的是什么?


当 Java 处在偏向锁、重量级锁状态时,hashcode 值存储在哪?

“简单 答案 是:

  • 当一个对象已经计算过 identity hash code,它就无法进入偏向锁状态;
  • 当一个对象当前正处于偏向锁状态,并且需要计算其 identity hash code 的话,则它的偏向锁会被撤销,并且锁会膨胀为重量锁;
  • 重量锁的实现中,ObjectMonitor 类里有字段可以记录非加锁状态下的 mark word,其中可以存储 identity hash code 的值。或者简单说就是重量锁可以存下 identity hash code。


请一定要注意,这里讨论的 hash code 都只针对 identity hash code。用户自定义的 hashCode() 方法所返回的值跟这里讨论的不是一回事。


Identity hash code 是未被覆写的 java.lang.Object.hashCode() 或者 java.lang.System.identityHashCode(Object) 所返回的值。


前知识-JAVA 中的锁


偏向锁


作用


偏向锁是为了消除无竞争情况下的同步原语,进一步提升程序性能。其目标就是在只有一个线程执行同步代码块时能够提高性能。


与轻量级锁的区别


轻量级锁是在无竞争的情况下使用CAS操作来代替互斥量的使用, 从而实现同步;而偏向锁是在无竞争的情况下完全取消同步。


与轻量级锁的相同点


它们都是乐观锁,都认为同步期间不会有其他线程竞争锁。


撤消


偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。


偏向锁的延迟


虚拟机在启动的时候对于偏向锁有延迟。为什么要延迟呢?


JVM 刚启动的时候,一定是有很多的线程在运行,操作系统也是知道的,所以明明知道有高并发的场景,所以就延迟了 4s。


原理


当线程请求到锁对象后, 将锁对象的状态标志位改为 01, 即偏向模式。然后使用CAS操作将线程的 ID 记录在锁对象的 Mark Word 中。


以后该线程可以直接进入同步块, 连CAS操作都不需要。但是,一旦有第二条线程需要竞争锁,那么偏向模式立即结束,进入轻量级锁的状态。


优点


偏向锁可以提高有同步但没有竞争的程序性能。但是如果锁对象时常被多条线程竟争,那偏向锁就是多余的。


匿名偏向


刚刚 new 完这个对象还没有任何线程持有这把锁,那它偏向谁呢,这种的谁也不偏向,叫做匿名偏向。


我们刚刚 new 出来的对象,如果偏向锁启动是匿名偏向,没有启动就是普通对象。


public class A {
    //占一个字节的 boolean 字段
    private boolean flag;
    public static void main(String[] args) throws InterruptedException {
        //延迟 5s
        // TimeUnit.SECONDS.sleep(5);
        A a = new A();
        out.println("before hash");
        out.println(ClassLayout.parseInstance(a).toPrintable());
        //jvm 计算 HashCode
        out.println("jvm----------" + Integer.toHexString(a.hashCode()));
        //当计算完 HashCode 之后,我们可以查看对象头的信息变化
        out.println("after hash");
        out.println(ClassLayout.parseInstance(a).toPrintable());
    }
}


52.jpg


这里要联系对象头一起理解


JVM 参数


-XX:BiasedLockingBulkRebiasThreshold = 20   // 默认偏向锁批量重偏向阈值
-XX:BiasedLockingBulkRevokeThreshold = 40   // 默认偏向锁批量撤销阈值
-XX:+UseBiasedLocking // 使用偏向锁,jdk6 之后默认开启
-XX:BiasedLockingStartupDelay = 0 // 延迟偏向时间,默认不为 0,意思为 jvm 启动多少 ms 以后开启偏向锁机制(此处设为 0,不延迟)


偏向锁可以通过虚拟机的参数来控制它是否开启。


批量重偏向与批量撤消


渊源  从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到 safe point 时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。


原理:以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,每一次该 class 的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认 20)时,JVM 就认为该 class 的偏向锁有问题,因此会进行批量重偏向。


每个 class 对象会有一个对应的 epoch 字段,每个处于偏向锁状态对象的 Mark Word 中也有该字段,其初始值为创建该对象时 class 中的 epoch 的值。每次发生批量重偏向时,就将该值+1,同时遍历 JVM 中所有线程的栈,找到该 class 所有正处于加锁状态的偏向锁,将其 epoch 字段改为新值。下次获得锁时,发现当前对象的 epoch 值和 class 的 epoch 不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过 CAS 操作将其 Mark Word 的 Thread Id 改成当前线程 Id。当达到重偏向阈值后 ,假设该 class 计数器继续增长,当其达到批量撤销的阈值后(默认 40),JVM 就认为该 class 的使用场景存在多线程竞争,会标记该 class 为不可偏向,之后,对于该 class 的锁,直接走轻量级锁的逻辑。


解决场景:批量重偏向(bulk rebias)机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。批量撤销(bulk revoke)机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。


具体例子可以参考:https://www.cnblogs.com/LemonFive/p/11248248.html


流程


53.jpg


轻量级锁


轻量级锁 是相对于重量级锁 而言的,而重量级锁就是传统的锁。


本质


使用 CAS 取代互斥同步。


轻量级锁与重量级锁的比较:


重量级锁是一种悲观锁,它认为总是有多条线程要竞争锁,所以它每次处理共享数据时,不管当前系统中是否真的有线程在竞争锁,它都会使用互斥同步来保证线程的安全;


而轻量级锁是一种乐观锁,它认为锁存在竞争的概率比较小,所以它不使用互斥同步,而是使用 CAS 操作来获得锁, 这样能减少互斥同步所使用的『互斥量』带来的性能开销。


实现原理


当线程请求锁时, 若该锁对象的 Mark Word 中标志位为 01(未锁定状态) , 则在该线程的栈帧中创建一块名为锁记录 的空间, 然后将锁对象的 Mark Word 拷贝至该空间;最后通过 CAS 操作将锁对象的 Mark Word 指向该锁记录;


若 CAS 操作成功, 则轻量级锁的上锁过程成功;·若 CAS 操作失败, 再判断当前线程是否已经持有了该轻量级锁;若已经持有, 则直接进入同步块;若尚未持有,则表示该锁已经被其他线程占用,此时轻量级锁就要膨胀成重量级锁。


前提


轻量级锁比重量级锁性能更高的前提是,在轻量级锁被占用的整个同步周期内,不存在其他线程的竞争。若在该过程中一旦有其他线程竞争,那么就会膨胀成重量级锁,从而除了使用互斥量以外, 还额外发生了 CAS 操作, 因此更慢!


流程


54.jpg


有偏向锁为什么还要用轻量级锁呢?


轻量级锁设计之初是为了应对线程之间交替获取锁的场景,而偏向锁的场景则是用于一个线程不断获取锁的场景


通过源码我们可以看出当一个线程获取偏向锁后,这个锁就会永久偏向这个线程,因为一旦发生偏向锁撤销,就代表锁要升级成为轻量级锁。虽然偏向锁在加锁时会进行一次 cas 操作,但是后续的获取只会进行简单的判断,不会再进行 cas 操作。但是轻量级锁的加锁和释放都需要进行 cas 操作。


我们看下如果把轻量级锁使用在偏向锁的场景下对比:


55.jpg


我们可以看到轻量级锁情况下每次获取都需要进行加锁和释放,每次加锁和释放都会进行 cas 操作,所以单个线程获取锁的情况使用偏向锁效率更高。


在看下如果把偏向锁使用在轻量级锁的场景下对比:


56.jpg


重量级锁


升级为重量级锁时,锁标志的状态值变为“10”,此时 Mark Word 中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。


java 对象与 monitor 的关联图


57.jpg


锁升级


整体的锁状态升级流程如下:


58.jpg


59.jpg


60.jpg


锁粗化和锁消除


  • 锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。
  • Java 虚拟机在 JIT 编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间


小结


偏向锁通过对比 Mark Word 解决加锁问题,避免执行 CAS 操作。而轻量级锁是通过用 CAS 操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。



相关文章
|
4月前
多线程线程安全问题之synchronized和ReentrantLock在锁的释放上有何不同
多线程线程安全问题之synchronized和ReentrantLock在锁的释放上有何不同
|
安全 算法 Java
synchronized 同步锁
Java中的synchronized关键字用于实现线程同步,可以修饰方法或代码块。 1. 修饰方法:当一个方法被synchronized修饰时,只有获得该方法的锁的线程才能执行该方法。其他线程需要等待锁的释放才能执行该方法。 2. 修饰代码块:当某个对象被synchronized修饰时,任何线程在执行该对象中被synchronized修饰的代码块时,必须先获得该对象的锁。其他线程需要等待锁的释放才能执行同步代码块。Java中的每个对象都有一个内置锁,当一个对象被synchronized修饰时,它的内置锁就起作用了。只有获得该锁的线程才能访问被synchronized修饰的代码段。使用synch
62 0
ReentrantLock和Synchronized简单比较
ReentrantLock和Synchronized简单比较
46 0
|
存储 Java
09.什么是synchronized的重量级锁?
大家好,我是王有志。今天我们学习synchronized升级过程中的最后一部分,从轻量级锁升级到重量级锁的过程。
183 0
09.什么是synchronized的重量级锁?
|
安全 Java
synchronized 锁与 ReentrantLock 锁的区别
synchronized 锁与 ReentrantLock 锁的区别
110 0
|
程序员
ReentrantLock与synchronized的区别
ReentrantLock与synchronized的区别
|
安全 Java 对象存储
浅谈synchronized锁原理
保证线程安全的一个重要手段就是通过加锁的形式实现,今天盘点一下Java中锁的八股文
155 0
线程同步的方法:Synchronized、Lock、ReentrantLock分析
线程同步的方法:Synchronized、Lock、ReentrantLock分析
synchronized 锁的是什么?(二)
每个对象都存在着一个 Monitor 对象与之关联。执行 monitorenter 指令就是线程试图去获取 Monitor 的所有权,抢到了就是成功获取锁了;执行 monitorexit 指令则是释放了 Monitor 的所有权。
synchronized 锁的是什么?(二)
|
安全 Java
多线程详解p18、Lock锁
多线程详解p18、Lock锁