写点什么

【得物技术】深入理解 synchronzied 底层原理

用户头像
得物技术
关注
发布于: 1 小时前

一、synchronized 简介

synchronized 是 Java 中的关键字,是一种同步锁。在多线程编程中,有可能会出现多个线程同时争抢同一个共享资源的情况,这个资源一般被称为临界资源。这种共享资源可以被多个线程同时访问,且又可以同时被多个线程修改,然而线程的执行是需要 CPU 的资源调度,其过程是不可控的,所以需要采用一种同步机制来控制对共享资源的访问,于是线程同步锁——synchronized 就应运而生了。

二、如何解决线程并发安全问题

多线程并发读写访问临界资源的情况下,是会存在线程安全问题的,可以采用的同步互斥访问的方式,就是在同一时刻,只能有同一个线程能够访问到临界资源。当多个线程执行同一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量会在类加载的时候存在每个线程的私有栈的局部变量表中,因此不属于共享资源,所有不会导致线程安全问题。

三、synchronized 用法

synchronized 关键字最主要有以下 3 种使用方式:


  1. 修饰类方法,作用于当前类加锁,如果多个线程不同对象访问该方法,则无法保证同步。

  2. 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁,锁的是包含这个方法的类,也就是类对象,这样如果多个线程不同对象访问该静态方法,也是可以保证同步的。

  3. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

四、Synchronized 原理分析

可以先通过一个简单的案例看一下同步代码块:


public class SynchTestDemo {        public void print() {        synchronized ("得物") {            System.out.println("Hello World");        }    }    }
复制代码


synchronized 属于 Java 关键字,没办法直接看到其底层源码,所以只能通过 class 文件进行反汇编。


先通过javac SynchTestDemo.java指令直接 SynchTestDemo.java 文件编译成 SynchTestDemo.class 文件;再通过javap -v SynchTestDemo.class指令再对 SynchTestDemo.class 文件进行反汇编,可以得到下面的字节码指令:



这些反编译的字节码指令这里就不详细解释了,对照着JVM指令手册也能看懂是什么意思。通过上图反编译的结果可以看出,monitorexit 指令实际上是执行了两次,第一次是正常情况下释放锁,第二次为发生异常情况时释放锁,这样做的目的在于保证线程不死锁。

monitorenter

首先可以看一下JVM规范中对于monitorenter的描述



翻译过来就是:任何一个对象都有一个 monitor 与其相关联,当且有一个 monitor 被持有后,它将处于锁定的状态,其他线程无法来获取该 monitor。当 JVM 执行某个线程的某个方法内部的 monitorenter 时,他会尝试去获取当前对应的 monitor 的所有权。其过程如下:


  1. 如果 monitor 的进入数为 0,则该线程进入 monitor,然后将进入数设置为 1,该线程即为 monitor 的所有者;

  2. 如果线程已经占有该 monitor,只是重新进入,则进入 monitor 的进入数加 1;

  3. 如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为 0,再重新尝试获取 monitor 的所有权;

monitorexit

也可以先看一下JVM规范中对monitorexit的描述



翻译过来就是:


  1. 能执行 monitorexit 指令的线程一定是拥有当前对象的 monitor 的所有权的线程;

  2. 执行 monitorexit 时会将 monitor 的进入数减 1。当 monitor 的进入数减为 0 时,当前线程退出 monitor,不再拥有 monitor 的所有权,此时其他被这个 monitor 阻塞的线程可以尝试去获取这个 monitor 的所有权;


synchronized 关键字被编译成字节码后会被翻译成 monitorenter 和 monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置,如下图所示:



每个同步对象都有一个自己的 Monitor(监视器锁),加锁过程如下图所示:



通过上面的描述可以看出 synchronized 的实现原理:synchronized 的底层实际是通过一个 monitor 对象来实现的,其实 wait/notify 方法也是依赖于 monitor 对象来实现的,这就是为什么只有在同步代码块或者方法中才能调用该方法,否则就会抛出出 java.lang.IllegalMonitorStateException 的异常的原因。


下面可以再通过一个简单的案例看一下同步方法:


public class SynchTestDemo {        public synchronized void print() {        System.out.println("Hello World");    }    }
复制代码


与上面同理可以查看到,该方法的字节码指令:



从字节码反编译的可以看出,同步方法并没有通过指令 monitorenter 和 monitorexit 来实现的,但是相对于普通方法来说,其常量池多了了 ACC_SYNCHRONIZED 标示符。JVM 实际就是根据该标识符来实现方法的同步的。


当方法被调用时,会检查 ACC_SYNCHRONIZED 标志是否被设置,若被设置,线程会先获取 monitor,获取成功才能执行方法体,方法执行完成后会再次释放 monitor。在方法执行期间,其他线程都无法获得同一个 monitor 对象。


其实两种同步方式从本质上看是没有区别的,两个指令的执行都是 JVM 调用操作系统的互斥原语 mutex 来实现的,被阻塞的线程会被挂起、等待重新调度,会导致线程在“用户态”和“内核态”进行切换,就会对性能有很大的影响。

五、什么是 monitor?

monitor 通常被描述为一个对象,可以将其理解为一个同步工具,或者可以理解为一种同步机制。所有的 Java 对象自打 new 出来的时候就自带了一把锁,就是 monitor 锁,也就是对象锁,存在于对象头(Mark Word),锁标识位为 10,指针指向的是 monitor 对象起始地址。在 Java 虚拟机(HotSpot)中,Monitor 是由其底层实际是由 C++对象 ObjectMonitor 实现的:


ObjectMonitor() {    _header = NULL;    _count = 0;            //用来记录该线程获取锁的次数    _waiters = 0,    _recursions = 0;         // 线程的重入次数     _object = NULL;         // 存储该monitor的对象    _owner = NULL;           // 标识拥有该monitor的线程    _WaitSet = NULL;         // 处于wait状态的线程,会被加入到_WaitSet    _WaitSetLock = 0 ;    _Responsible = NULL;    _succ = NULL;    _cxq = NULL;           // 多线程竞争锁时的单向队列    FreeNext = NULL;    _EntryList = NULL;         // 处于等待锁block状态的线程,会被加入到该列表    _SpinFreq = 0;    _SpinClock = 0;    OwnerIsThread = 0;}
复制代码


  1. _owner:初始时为 NULL。当有线程占有该 monitor 时,owner 标记为该线程的唯一标识。当线程释放 monitor 时,owner 又恢复为 NULL。owner 是一个临界资源,JVM 是通过 CAS 操作来保证其线程安全的;

  2. _cxq:竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)。cxq 是一个临界资源,JVM 通过 CAS 原子指令来修改 cxq 队列。修改前 cxq 的旧值填入了 node 的 next 字段,_cxq 指向新值(新线程)。因此_cxq 是一个后进先出的 stack(栈);

  3. _EntryList:_cxq 队列中有资格成为候选资源的线程会被移动到该队列中;

  4. _WaitSet:因为调用 wait 方法而被阻塞的线程会被放在该队列中。


举个例子具体分析一下_cxq 队列与_EntryList 队列的区别:


public void print() throws InterruptedException {    synchronized (obj) {        System.out.println("Hello World");        //obj.wait();    } }
复制代码


若多线程执行上面这段代码,刚开始 t1 线程第一次进同步代码块,能够获得锁,之后马上又有一个 t2 线程也准备执行这段代码,t2 线程是没有抢到锁的,t2 这个线程就会进入_cxq 这个队列进行等待,此时又有一个线程 t3 准备执行这段代码,t3 当然也会没有抢到这个锁,那么 t3 也就会进入_cxq 进行等待。接着,t1 线程执行完同步代码块把锁释放了,这个时候锁是有可能被 t1、t2、t3 中的任何一个线程抢到的。假如此时又被 t1 线程给抢到了,那么上次已经进入_cxq 这个队列进行等待的线程 t2、t3 就会进入_EntryList 进行等待,若此时来了个 t4 线程,t4 线程没有抢到锁资源后,还是会先进入_cxq 进行等待。


下面具体分析一下_WaitSet 队列与_EntryList 队列:



每个 object 的对象里 markOop->monitor() 里可以保存 ObjectMonitor 的对象。ObjectWaiter 对象里存放 thread(线程对象) 和 unpark 的线程, 每一个等待锁的线程都会有一个 ObjectWaiter 对象,而 objectwaiter 是个双向链表结构的对象。


结合上图 monitor 的结构图可以分析出,当线程的拥有者执行完线程后,会释放锁,此时有可能是阻塞状态的线程去抢到锁,也有可能是处于等待状态的线程被唤醒抢到了锁。在 JVM 中每个等待锁的线程都会被封装成 ObjectMonitor 对象,_owner 标识拥有该 monitor 的线程,而_EntryList 和_WaitSet 就是用来保存 ObjectWaiter 对象列表的,_EntryList 和_WaitSet 最大的区别在于前者是用来存放等待锁 block 状态的线程,后者是用来存放处于 wait 状态的线程。


当多个线程同时访问同一段代码时:


  • 首先会进入_EntryList 集合每当线程获取到对象的 monitor 后,会将 monitor 中的_ower 变成设置为当前线程,同时会将 monitor 中的计数器_count 加 1

  • 若线程调用 wait()方法时,将释放当前持有的 monitor 对象,将_ower 设置为 null,_count 减 1,同时该线程进入_WaitSet 中等待被唤醒


  • 若当前线程执行完毕,也将释放 monitor 锁,并将_count 值复原,以便于其他线程获取锁


monitor 对象存在于每个 Java 对象的对象头(Mark Word)中,所以 Java 中任何对象都可以作为锁,由于 notify/notifyAll/wait 等方法会使用到 monitor 锁对象,所以必须在同步代码块中使用。多线程情况下,线程需要同时访问临界资源,监视器 monitor 可以确保共享数据在同一时刻只会有一个线程在访问。


那么问题来了,synchronized 是对象锁,加锁就是加在对象上,那对象时如何记录锁的状态的呢?答案就是锁的状态是记录在每个对象的对象头(Mark Word)中的,那什么是对象头呢?

六、什么是对象头

在 JVM 中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:



对象头又包括两部分信息,第一部分用于存储对象自身的运行时数据(Mark Word),如 HashCode、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。对象头的另外一部分是类型指针(Klass pointer),即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。


Class<? extends SynchTestDemo> synchClass = synchTestDemo.getClass();


值得注意的是:类元信息存在于方法区,类元信息有区别与堆中的 synchClass 字节码对象,synchClass 可以理解为类加载完成后,JVM 将类的信息存在堆中,然后使用反射去访问其全部信息(包括函数和字段),然而在 JVM 内部大多数对象都是使用 C++代码实现的,对于 JVM 内部如果需要类信息,JVM 就会通过对象头的类型指针去拿方法区中类元信息的数据。


实例数据:存放类的属性数据信息,包括父类的属性信息。


对齐填充:由于虚拟机要求 对象起始地址必须是 8 字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。


下面可以看一下对像头的结构:


在 32 位虚拟机下,Mark Word 是 32bit 大小的,其存储结构如下:



在 64 位虚拟机下,Mark Word 是 64bit 大小的,其存储结构如下:



现在虚拟机基本是 64 位的,而 64 位的对象头有点浪费空间,JVM 默认会开启指针压缩,所以基本上也是按 32 位的形式记录对象头的。也可以通过下面参数进行控制 JVM 开启和关闭指针压缩:


开启压缩指针(-XX:+UseCompressedOops) 关闭压缩指针(-XX:-UseCompressedOops)


那为什么 JVM 需要默认开启指针压缩呢?原因在于在对象头上类元信息指针 Klass pointer 在 32 位 JVM 虚拟机中用 4 个字节存储,但是到了 64 位 JVM 虚拟机中 Klass pointer 用的就是 8 个字节来存储,一些对象在 32 位虚拟机用的也是 4 字节来存储,到了 64 位机器用的都是 8 字节来存储了,一个工程项目中有成千上万的对象,倘若每个对象都用 8 字节来存放的话,那这些对象无形中就会增加很多空间,导致堆的压力就会很大,堆很容易就会满了,然后就会更容易的触发 GC,那指针压缩的最主要的作用就是压缩每个对象内存地址的大小,那么同样堆内存大小就可以放更多的对象。


这里刚好可以再说一个额外的小知识点:对象头中有 4 个字节用于存放对象分代年龄的,4 个字节就是 2 的四次方等于 16,其范围就是 0~15,所以也就很好理解对象在 GC 的时候,JVM 对象由年轻代进入老年代的默认分代年龄是 15 了。

七、synchronized 锁的优化

操作系统分为“用户空间”和“内核空间”,JVM 是运行在“用户态”的,jdk1.6 之前,在使用 synchronized 锁时需要调用底层的操作系统实现,其底层 monitor 会阻塞和唤醒线程,线程的阻塞和唤醒需要 CPU 从“用户态”转为“内核态”,频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,这些操作给系统的并发性能 带来了很大的压力。同这个时候 CPU 就需要从“用户态”切向“内核态”,在这个过程中就非常损耗性能而且效率非常低,所以说 jdk1.6 之前的 synchronized 是重量级锁。如下图所示:



然后有位纽约州立大学的教授叫 Doug Lea 看到 jdk 自带的 synchronized 性能比较低,于是他利用纯 Java 语言实现了基于 AQS 的 ReentrantLock 锁(底层当然也调用了底层的语言),如下图所示,可以说 ReentrantLock 锁的出现完全是为了弥补 synchronized 锁的各种不足。



由于 synchronized 锁性能严重不足,所以 oracle 官方在 jdk1.6 之后对 synchronized 锁进行了升级,如上图所示的锁升级的整个过程。所以就有了以下的这些名词:

无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其底层是通过 CAS 实现的。无锁无法全方位代替有锁,但无锁在某些场合下的性能是非常高的。

偏向锁(无锁 -> 偏向锁)

偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程 ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及 ThreadID 即可。


一开始无锁状态,JVM 会默认开启“匿名”偏向的一个状态,就是一开始线程还未持有锁的时候,就预先设置一个匿名偏向锁,等一个线程持有锁之后,就会利用 CAS 操作将线程 ID 设置到对象的 mark word 的高 23 位上【32 位虚拟机】,下次线程若再次争抢锁资源的时,多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令,只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。

轻量级锁(偏向锁 -> 轻量锁)

当线程交替执行同步代码块时,且竞争不激烈的情况下,偏向锁就会升级为轻量级锁。在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

自旋锁

在很多场景下,共享资源的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋) , 这就是自旋锁。


当一个线程 t1、t2 同事争抢同一把锁时,假如 t1 线程先抢到锁,锁不会立马升级成重量级锁,此时 t2 线程会自旋几次(默认自旋次数是 10 次,可以使用参数-XX : PreBlockSpin 来更改),若 t2 自旋超过了最大自旋次数,那么 t2 就会当使用传统的方式去挂起线程了,锁也升级为重量级锁了。


自旋的等待不能代替阻塞,暂且不说对处理器数量的要求必须要两个核,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等 待的效果就会非常好,如果锁被占用的时间很长,那自旋的线程只会消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。


自旋锁在 jdk1.4 中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning 参数来开启,在 jdk1.6 之后自旋锁就已经默认是打开状态了。

重量级锁

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

锁消除

锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。


public class SynchRemoveDemo {    public static void main(String[] args) {      stringContact("AA", "BB", "CC");    }    public static String stringContact(String s1, String s2, String s3) {        StringBuffer sb = new StringBuffer();        return sb.append(s1).append(s2).append(s3).toString();    }}
//append()方法源码@Overridepublic synchronized StringBuffer append(String str) { toStringCache = null; super.append(str); return this;}
复制代码


StringBuffer 的 append()是一个同步方法,锁就是 this 也就是 sb 对象。虚拟机发现它的动态作用域被限制在 stringContact()方法内部。也就是说, sb 对象的引用永远不会“逃逸”到 stringContact()方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。


这里顺便说一个小的 JVM 知识点——“对象的逃逸分析”:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。JVM 通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象优先在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。上面 sb 对象的就是不会逃逸出方法 stringContact(),所以 sb 对象有可能优先分配在线程栈中,只是有可能哟,这里点到为止,需要了解可以自行学习哟~

锁粗化

JVM 会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可。可以通过下面的例子来看一下:


public class SynchDemo {    public static void main(String[] args) {        StringBuffer sb = new StringBuffer();        for (int i = 0; i < 50; i++) {            sb.append("AA");        }        System.out.println(sb.toString());    }}
//append()方法源码@Overridepublic synchronized StringBuffer append(String str) { toStringCache = null; super.append(str); return this;}
复制代码


StringBuffer 的 append()是一个同步方法,通过上面的代码可以看出,每次循环都要给 append()方法加锁,这时系统会通过判断将其修改为下面这种,直接将原 append()方法的 synchronized 的锁给去掉直接加在了 for 循环外。


public class SynchDemo {    public static void main(String[] args) {        StringBuffer sb = new StringBuffer();        synchronized(sb){            for (int i = 0; i < 50; i++) {            sb.append("AA");          }        }        System.out.println(sb.toString());    }}
//append()方法源码@Overridepublic StringBuffer append(String str) { toStringCache = null; super.append(str); return this;}
复制代码

八、通过对象头分析锁升级过程

可以通过对象头分析工具观察一下锁升级时对象头的变化:运行时对象头锁状态分析工具 JOL,是 OpenJDK 开源工具包,引入下方 maven 依赖


<dependency>   <groupId>org.openjdk.jol</groupId>   <artifactId>jol‐core</artifactId>   <version>0.10</version></dependency>
复制代码


观察无锁状态下的对象头【无锁状态】:

 public static void main(String[] args) throws InterruptedException {     Object object = new Object();     System.out.println(ClassLayout.parseInstance(object).toPrintable()); }
复制代码


运行结果:


java.lang.Object object internals: OFFSET  SIZE   TYPE DESCRIPTION                               VALUE      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)      第一行:对象头MarkWord      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)      第二行:对象头MarkWord      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 第三行:klass Pointer     12     4        (loss due to the next object alignment)                                  第四行:对齐填充Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
复制代码


这里先详细解释一下打印结果,后面就不做详细分析了:


OFFSET : 内存地址偏移量


SIZE : 本条信息对应的字节大小


Instance size: 16 bytes :本次 new 出的 Object 对象的大小


由于当前所使用的的机器是 64 位操作系统的机器,所以前两行代表的就是对象头 MarkWord,已经在上述运行结果中标出,刚好是 8 字节,每个字节 8 位,刚好是 64 位;由上文中 32 位对象头与 64 位对象头的位数对比可知,分析对象头锁升级情况看第一行的对象头即可。


第三行指的是类型指针(上文中有说过,指向的是方法区的类元信息),已经在上述运行结果中标出,Klass Pointer 在 64 位机器默认是 8 字节,这里由于指针压缩的原因当前是 4 字节。


第四行指的是对齐填充,有的时候有有的时候没有,JVM 内部需要保证对象大小是 8 个字节的整数倍,实际上计算机底层通过大量计算得出对象时 8 字节的整数倍可以提高对象存储的效率。


可以观察到本次 new 出的 Object 对象的大小实际只有 12 字节,这里对象填充为其填充了 4 个字节,就是为了让 Object 对象大小为 16 字节是 8 字节的整数倍。


JVM 采用的是小端模式,需要现将其转换成大端模式,具体转换如下图所示:



可以看出一开始对象没有加锁,通过最后三位的“001”也能观察到,前 25 位代表 hashcode,那这里为什么前 25 位是 0 呢?其实 hashcode 是通过 C 语言类似于“懒加载”的方式获取到的,所以看到该对象的高 25 位并没有 hashcode。

观察有锁无竞争状态下的对象头【无锁->偏向锁】:

 public static void main(String[] args) throws InterruptedException {     Object object = new Object();     System.out.println(ClassLayout.parseInstance(object).toPrintable());     synchronized (object){          System.out.println(ClassLayout.parseInstance(o).toPrintable());     } }
复制代码


运行结果(JVM 默认小端模式):


java.lang.Object 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)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)     12     4        (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 90 39 62 05 (10010000 00111001 01100010 00000101) (90323344) 4 4 (object header) 00 70 00 00 (00000000 01110000 00000000 00000000) (28672) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
复制代码


运行结果分析:


通过运行结果可以看到,先打印出来的是一个“001”无锁的状态,但是后打印出来的“000”并不是偏向锁的状态,查上面的表可以发现“000”直接就是轻量级锁的状态了。JVM 启动的时候内部实际上也是有很多个线程在执行 synchronized,JVM 就是为了避免无畏的锁升级过程(偏向锁->轻量级锁->重量级锁)带来的性能开销,所以 JVM 默认状态下会延迟启动偏向锁。只要将代码前面加个延迟时间即可观察到偏向锁:


public static void main(String[] args) throws InterruptedException {     TimeUnit.SECONDS.sleep(6);     Object o = new Object();     System.out.println(ClassLayout.parseInstance(o).toPrintable());     synchronized (o){       System.out.println(ClassLayout.parseInstance(o).toPrintable());    }}
复制代码


运行结果(JVM 默认小端模式):


java.lang.Object object internals: OFFSET  SIZE   TYPE DESCRIPTION                               VALUE      0     4        (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)     12     4        (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 05 90 80 de (00000101 10010000 10000000 11011110) (-561999867) 4 4 (object header) b2 7f 00 00 (10110010 01111111 00000000 00000000) (32690) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
复制代码


对未开启偏向锁与开启偏向锁的运行结果分析:


未开启偏向锁(大端模式),没加锁:00000000 00000000 00000000 00000001开启偏向锁(大端模式),没加锁   :00000000 00000000 00000000 00000101开启偏向锁(大端模式),加锁    :11011110 10000000 10010000 00000101
复制代码


开启偏向锁之后的无锁状态,会加上一个偏向锁,叫匿名偏向(可偏向状态),表示该对象锁是可以加偏向锁的,从高 23 位的 23 个 0 可以看出暂时还没有偏向任何一个线程,代表已经做好了偏向的准备,就等着接下来的某个线程能拿到就直接利用 CAS 操作把线程 id 记录在高 23 位的位置。

观察有锁有竞争状态下的对象头【偏向锁->轻量级锁】:

public static void main(String[] args) throws InterruptedException {                Thread.sleep(5000);                Object object = new Object();                //main线程        System.out.println(ClassLayout.parseInstance(object).toPrintable());
//线程t1 new Thread(() -> { synchronized (object) { System.out.println(ClassLayout.parseInstance(object).toPrintable()); } },"t1").start();
Thread.sleep(2000);
//main线程 System.out.println(ClassLayout.parseInstance(object).toPrintable()); //线程t2 new Thread(() -> { synchronized (object) { System.out.println(ClassLayout.parseInstance(object).toPrintable()); } },"t2").start(); }
复制代码


运行结果(JVM 默认小端模式):


java.lang.Object object internals: OFFSET  SIZE   TYPE DESCRIPTION                               VALUE      0     4        (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)        //main线程打印      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)     12     4        (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 05 90 94 2d (00000101 10010000 10010100 00101101) (764710917) //t1线程打印 4 4 (object header) c9 7f 00 00 (11001001 01111111 00000000 00000000) (32713) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 05 90 94 2d (00000101 10010000 10010100 00101101) (764710917) //main线程打印 4 4 (object header) c9 7f 00 00 (11001001 01111111 00000000 00000000) (32713) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 08 a9 d5 07 (00001000 10101001 11010101 00000111) (131442952) //t2线程打印 4 4 (object header) 00 70 00 00 (00000000 01110000 00000000 00000000) (28672) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
复制代码


运行结果分析:


一开始 main 线程打印出的 object 对象头可以看出是匿名偏向;


接着线程 t1 打印了 object 对象头,可以与第一个打印出来的对象头对比不难发现 t1 打印的也是偏向锁,但是 t1 打印的对象头已经把 t1 的线程 id 记录在了其对应的 23 位;


程序再次回到 main 线程,其还是打印出来刚刚 t1 的对象头数据,也就是说偏向锁一旦偏向了某个线程后,如果线程不能重新偏向的话,那么这个偏向锁还是会一直记录着之前偏向的那个线程的对象头状态;


接着线程 t2 又开始打印了 object 对象头,可以看出最后一次打印已经升级成了轻量级锁,因为这里已经存在两个线程 t1、t2 交替进入了 object 对象锁的同步代码块,并且锁的不激烈竞争,所以锁已经升级成了轻量级锁。

观察无锁升级成重量级锁状态下的对象头的整个过程【无锁->重量级锁】:

public static void main(String[] args) throws InterruptedException {        sleep(5000);        Object object = new Object();
System.out.println(ClassLayout.parseInstance(object).toPrintable());
new Thread(()->{ synchronized (object) { System.out.println(ClassLayout.parseInstance(object).toPrintable()); //延长锁的释放,造成锁的竞争 try { sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } } },"t0").start();
sleep(5000);
new Thread(() -> { synchronized (object) { System.out.println(ClassLayout.parseInstance(object).toPrintable()); //延长锁的释放,造成锁的竞争 try { sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } } },"t1").start();
new Thread(() -> { synchronized (object) { System.out.println(ClassLayout.parseInstance(object).toPrintable()); try { sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } },"t2").start();
}
复制代码


运行结果:


java.lang.Object object internals: OFFSET  SIZE   TYPE DESCRIPTION                               VALUE      0     4        (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)      //main线程打印      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)     12     4        (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 05 d8 8f ef (00000101 11011000 10001111 11101111) (-275785723) //t0线程打印 4 4 (object header) ce 7f 00 00 (11001110 01111111 00000000 00000000) (32718) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 00 e9 a9 09 (00000000 11101001 10101001 00001001) (162130176) //t1线程打印 4 4 (object header) ce 7f 00 00 (11001110 01111111 00000000 00000000) (32718) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 0a d8 80 f0 (00001010 11011000 10000000 11110000) (-259991542) //t2线程打印 4 4 (object header) ce 7f 00 00 (11001110 01111111 00000000 00000000) (32718) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment)Instance size: 16 bytesSpace losses: 0 bytes internal + 4 bytes external = 4 bytes total
复制代码


运行结果分析(JVM 默认小端模式):


程序一开始就是设置了 5 秒钟的睡眠,目的在于让 JVM 优先加载完成后,让 JVM 默认状态下会延迟启动偏向锁,可以开出一开始 main 线程打印的是“101”就是默认的匿名偏向锁,但是并没有设置线程 id;之后 t0 线程就立马打印了,此时只需利用 CAS 操作把 t0 的线程 id 设置进对象头即可,所以这个时候也是一个偏向锁状态;之后的程序睡眠 5 秒钟后,程序中 t1、t2 线程执行代码块时,有意的将其线程睡眠几秒钟,目的在于不管那个线程率先抢到锁,都能让另外一个线程在自旋等待中,所以 t1 线程打印的是“00”就已经是轻量级锁了,最后看程序执行结果,t2 打印的是“10”就已经升级为重量级锁了,显然 t2 线程已经超过了自旋的最大次数,已经转成重量级锁了。

九、总结

那平时写代码如何对 synchronized 优化呢?


我总结就是:


1、减少 synchronized 的范围,同步代码块中尽量短,减少同步代码块中代码的执行时间,减少锁的竞争。


2、降低 synchronized 锁的粒度,将一个锁拆分为多个锁提高并发度。这点其实可以参考 HashTable 与 ConcurrentHashMap 的底层原理。


HashTable 加锁实际上锁的是整个 hash 表,一个操作进行的时候,其他操作都无法进行了。



然而 ConcurrentHashMap 是局部锁定,锁得并不是一整张表,ConcurrentHashMap 锁得是一个 segment,当前的 segment 被锁了,不影响其他 segment 的操作。



文/harmony


关注得物技术,做最潮技术人!

发布于: 1 小时前阅读数: 5
用户头像

得物技术

关注

得物APP技术部 2019.11.13 加入

关注微信公众号「得物技术」

评论

发布
暂无评论
【得物技术】深入理解synchronzied底层原理