写点什么

「 代码性能优化 」作为一名 Java 程序员,你真的了解 synchronized 吗?(二)

作者:小刘学编程
  • 2023-02-15
    陕西
  • 本文字数:3860 字

    阅读完需:约 13 分钟

「 代码性能优化 」作为一名Java程序员,你真的了解 synchronized 吗?(二)

前言

文接上篇,本文将继续介绍 Synchronized,感兴趣的小伙伴继续跟博主一起讨论下。上一篇文章地址:「 代码性能优化 」作为一名 Java 程序员,你真的了解 synchronized 吗?(一)https://xie.infoq.cn/article/e8eba8a7bdd7360a9d8c76874

您的 关注、点赞、收藏 都将是小编持续创作的动力!

一、synchronized 锁的底层实现

在探讨 synchronized 锁的底层实现原理之前,我们先来了解下 java 对象在内存中的结构

1. 对象的内存布局

以 64 位虚拟机为例:



从上面的这张图里面可以看出,对象在内存中的结构主要包含以下几个部分:


  • 对象头

  • **Mark Word(标记字段)**:关于锁的信息。对象的 Mark Word 部分占 4 个字节/8 个字节,表示对象的锁状态(比如轻量级锁的标记位,偏向锁标记位),另外还可以用来配合 GC 分代年龄、存放该对象的 hashCode 等。

  • Klass Pointer(Class 对象指针):Class 对象指针的大小也是 4 个字节/8 个字节,其指向的位置是对象对应的 Class 对象(其对应的元数据对象)的内存地址。

  • 数组长度:如果对象是数组类型,占用 4 个字节/8 个字节,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

  • Instance Data(对象实际数据):这里面包括了对象的所有成员变量,其大小由各个成员变量的大小决定,比如:byte 和 boolean 是 1 个字节,short 和 char 是 2 个字节,int 和 float 是 4 个字节,long 和 double 是 8 个字节,reference 是 4 个字节。

  • padding data(对齐):如果上面的数据所占用的空间不能被 8 整除,padding 则占用空间凑齐使之能被 8 整除。被 8 整除在读取数据的时候会比较快.

2. 对象的创建过程

2.1. 检查类对象是否被实例化过


​ jvm 要检查类 A 是否已经被加载到了内存,即类的符号引用是否已经在常量池中,并且检查这个符号引用代表的类是否已被加载、解析和初始化过的。如果还没有,需要先触发类的加载、解析、初始化。然后在堆上创建对象。


2.2. 为新生对象分配内存


2.3. 完成实例数据部分的初始化


  内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用 TLAB 的话,这一个工作也可以提前至 TLAB 分配时进行。这步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。


2.4. 完成对象头的填充


  接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。


​ 在上面工作都完成之后,java 程序开始调用方法完成初始复制和构造函数,所有的字段都为零值,这样一个真正可用的对象才算完全创建出来。

3. synchronized 锁基于对象内存模型和对象创建过程的实现原理

对象头是我们需要关注的重点,它是 synchronized 实现锁的基础,因为 synchronized 申请锁、上锁、释放锁都与对象头有关。对象头主要结构是由Mark WordClass Metadata Address组成,其中Mark Word存储对象的 hashCode、锁信息或分代年龄或 GC 标志等信息Class Metadata Address是类型指针指向对象的类元数据,JVM 通过该指针确定该对象是哪个类的实例


ObjectMonitor() {    _header       = NULL;    _count        = 0;  //锁计数器    _waiters      = 0,    _recursions   = 0;    _object       = NULL;    _owner        = NULL;    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet    _WaitSetLock  = 0 ;    _Responsible  = NULL ;    _succ         = NULL ;    _cxq          = NULL ;    FreeNext      = NULL ;    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表    _SpinFreq     = 0 ;    _SpinClock    = 0 ;    OwnerIsThread = 0 ;  }
复制代码



​ 每一个锁都对应一个 monitor 对象,在 HotSpot 虚拟机中它是由 ObjectMonitor 实现的(C++实现)。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态.


​ ObjectMonitor 中有两个队列_WaitSet 和_EntryList,用来保存 ObjectWaiter 对象列表(每个等待锁的线程都会被封装 ObjectWaiter 对象),_owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时,首先会进入_EntryList 集合,当线程获取到对象的 monitor 后进入 _owner 区域并把 monitor 中的 owner 变量设置为当前线程同时 monitor 中的计数器 count 加 1,若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSe t 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor(锁)并复位变量的值,以便其他线程进入获取 monitor(锁).


​ monitor 对象存在于每个 Java 对象的对象头中(存储的指针的指向),synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因,同时也是 notify/notifyAll/wait 等方法存在于顶级对象 Object 中的原因.

二、JVM 对 synchronized 的优化

上一篇文章中提到 JVM 对 synchronized 的优化机制中的一种:锁膨胀 ,这篇文章我们将继续介绍其它集中机制:锁消除锁粗化自旋锁


感兴趣的小伙伴可以回过头去阅读一下:「 代码性能优化 」作为一名Java程序员,你真的了解 synchronized 吗?(一)

1、锁消除

​ 先解释一个概念:JIT


​ Java 程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块运行的特别频繁时,会把这些代码认定为“热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机会把这些代码编译成本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT 编译器,不是 Java 虚拟机内必须的部分)。


​ 锁消除即代码中不存在锁竞争的地方使用了 synchronized,jvm 会自动帮你优化掉。具体解释就是:只有一个线程会用到,不会引起多个线程竞争的就没必要加锁了。


实例



/** * 锁消除 */public class Demo1 { static Object object = new Object(); public static void fun1(){ Object o = new Object(); synchronized(o){ System.out.println("Hello World!"); } } public static void main(String[] args) { for (int i = 0; i < 10; i++) { new Thread(()->{ Demo1.fun1(); },String.valueOf(i)).start(); } }}
复制代码


​ main 函数中,每个线程都会创建一个对象,各线程都有自己的私有资源,并不会引起线程之间的竞争,相当于每个线程都有一把锁,所以 synchronized 的存在毫无意义,程序编译过程中,JIT 即时编译器会无视它。

2、锁粗化

​ 锁粗化即 JIT 会将首尾相接,前后相邻且都是锁同一个对象的代码块,JIT 即时编译器就会把这几个 synchronized 块合并为一个大块。通过扩大锁的范围,避免反复加锁和释放锁。比如下面 fun2 经过锁粗化优化之后就和 fun1 执行效率一样了。


实例


public class HelloWorld {   public static void main(String[] args) throws Exception {                String user = new String("小明");        int count = 0;                //调用func1        long start1 = System.nanoTime();        fun1(user,count);        System.out.println( System.nanoTime() - start1);                //调用func2        long start2 = System.nanoTime();        fun2(user,count);        System.out.println( System.nanoTime() - start2);    }        private static void  fun1(String user,int count){        for (int i = 0; i < 100; i++) {            count++;        }    }        private static void  fun2(String user,int count){        for (int i = 0; i < 100; i++) {            synchronized (user) {                count++;            }        }    }    }
复制代码


执行结果:


50345007
复制代码

3、自旋锁与自适应自旋锁

轻量级锁失败后,JVM 虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。


自旋锁:一般情况下,共享数据的锁定状态持续时间较短,切换线程影响程序执行效率,通过让线程执行循环等待锁的释放,不释放内存资源。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。但是这种机制也有缺点:如果锁被其他线程长时间占用,一直不释放资源,会带来许多的性能开销。


自适应自旋锁:自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的影响性能开销缺点。

总结

以上内容 从 synchronized 锁的底层实现JVM 对 synchronized 的优化两方面介绍了 synchronized 的知识,请继续关注博主,接下里会继续就 synchronized 的用法 展开讨论。


下一篇主题


根据获取的锁分类来分析 synchronized 的用法

参考

1、深入Synchronized的实现原理与源码分析

2、java对象在内存中的结构(HotSpot虚拟机)



致谢

莫笑少年江湖梦,谁不少年梦江湖.

本篇内容参考自互联网及开源社区,感谢前人的经验、分享和付出,让我们可以有机会站在巨人的肩膀上眺望星辰大海!


发布于: 刚刚阅读数: 3
用户头像

后端工程师@代码都困了💤 2023-02-07 加入

啊呜(゚▽゚)/ . Talk is cheap (‾◡◝) ! , show me your code ! ヾ(๑╹◡╹)ノ"

评论 (1 条评论)

发布
用户头像
感谢阅读此文,公众号搜索「 重载 」"chóng zài" 并关注,不定期分享Java相关技术栈资料、后端硬核技术干货、实用笔试面试题。您的每次阅读、每个点赞、每条评论都会激励到博主,持续输出优质内容!啊呜(゚▽゚)/ . Talk is cheap (‾◡◝) ! , show me your code ! ヾ(๑╹◡╹)ノ"
刚刚 · 陕西
回复
没有更多了
「 代码性能优化 」作为一名Java程序员,你真的了解 synchronized 吗?(二)_Java_小刘学编程_InfoQ写作社区