synchronized()_synchronized 底层详解

synchronized 详解

java对象
  1. 对象头

  • Mark Word(标记字段):默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

ad44b3a501cf3c432b675f45413246f4.png

  • Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

  1. 实例数据

  • 这部分主要是存放类的数据信息,父类的信息。

  1. 对其填充

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

  • Tip:不知道大家有没有被问过一个空对象占多少个字节?就是8个字节,是因为对齐填充的关系哈,不到8个字节对其填充会帮我们自动补齐。

Monitor详解
  • 当Thread-2调用synchronized (obj),底层其实都是对Monitor做操作,Monitor是操作系统的,对象头的makeWord指向对象的Monitor,会将Monitor的Owner指向Thread-2

389cb8bf6c401b0fb7f1bc6fc9264d21.png

  • 当在thread-2执行过程中,thread-1,thread-3调用synchronized (同一对象时),发现Monitor Owner已经有线程指向了,则会放到EntryList等待队列,线程状态变为阻塞

8257e6bb9f0bba73d3fcc5aa07220d91.png

  • 当线程2执行完同步代码块内容,然后唤醒EntryList等待的线程来竞争锁,竞争时是非公平的

67cde058f8c6eeeeda9b5e34379fcd32.png

synchronized字节码详解

下面我们将从字节码层面解析synchronized底层monitor实现

public class synchronizedTest {    static final Object lock =new Object();    static int count =0;    public static void main(String[] args) {        synchronized(lock) {            count++;        }    }}
 public static void main(java.lang.String[]);    Code:       0: getstatic     #2// lock引用, synchronized开始           3: dup            // 复制一份       4: astore_1       // 临时存储       5: monitorenter   //将lock对象MakeWord指向monitor       6: getstatic     #3                  // Field count:I       9: iconst_1      // 准备常量      10: iadd          // count+      11: putstatic     #3                  // Field count:I      14: aload_1      // lock引用      15: monitorexit  //退出synchronized块      16: goto          24  //跳到24行执行      19: astore_2      20: aload_1      21: monitorexit      22: aload_2      23: athrow      24: return    Exception table:       from    to  target type           6    16    19   any          19    22    19   any

下面from6  to16表示6-16行代码发生异常时,执行target第19行

synchronized优化原理

因为如果每次调用synchronized都直接调用底层monitor会对程序性能有影响,java6开始对synchronized做了大的优化,对获取锁的方式做出了改进,下面我们来讲解其进化的原理

轻量级锁
  • 当执行synchronized时,创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个所锁记录的结构,内部可以存储锁定对象的Mark Word

868449b5c8831b99697ad36691d6f8e6.png

  • 让锁记录中Object reference指向锁对象,并尝试使用cas替换Object的Mark Word,将Mark Word的值存入锁记录

99de12f5221631c9ba258ccc384c43e6.png

  • 如果cas替换成功,对象头中存储了锁记录地址和状态00,表示由该线程加锁成功

    4fc3926c0f038e310a17c0f9421725a1.png

  • 如果cas失败,有两种情况

    如果是其他线程持有了该Object的轻量级锁,这时候表明有竞争,进入锁膨胀过程

    如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数

    64fa232457aef56d6983d77856cff4a7.png

    当退出synchronized代码快(解锁时)如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一

  • 当退出synchronized代码快(解锁时)锁记录不为null,这时使用cas将Mark Word的值恢复给对象头

    成功,则解锁成功

    失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

    注:cas原理可在后面内容中查看

    锁膨胀

    当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁,这时Thread-1会进入锁膨胀流程

  1. 为Object对象申请Monitor锁,让Object指向重量级锁地址

  2. 然后自己进入Monitor的EntryList BLOCKED

30c94b9c2a32df22d5e08e4c53037301.png

  1. 当Thread-0退出同步块解锁时,使用cas将Make Word的值恢复给对象头,失败。这时会进入重量级锁流程,即按照Monitor地址找到Monitor对象,设置Owner为null,唤醒EntryList中BLOCKED线程

自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

自旋重试成功的情况

742cf830e9d19eef0a32ff0defc0f3d4.png

自旋重试失败的情况

cfa71756549063df2853bb1c2d4297c7.png

  • 在java6之后自旋锁是自适应的,比如刚刚的依次自旋操作时成功的,那么认为这次自旋成功的可能性会高,就会多自旋几次,反之,就少自旋甚至不自旋

  • 自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势

  • 旋锁的默认大小是10次,-XX:PreBlockSpin可以修改。

    自旋都失败了,那就升级为重量级的锁

偏向锁

轻量级锁在没有竞争(就自己这个线程),每次重入仍然需要执行CAS操作。

java6中引入了偏向锁来做进一步优化,只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有

public class PianXiangTest {    static final Object obj = new Object();    public static void m1() {        synchronized (obj) {            m2();        }    }    public static void m2() {        synchronized (obj) {            m3();        }    }    public static void m3() {        synchronized (obj) {        }    }}

55250d9338c529eaf73327ee66530a64.png

偏向状态

回忆一下对象头格式

12dba3fa38b67f0c1cbcb1df5346fa3f.png

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,makedown值最后3位为101,这时它的thread epoch age都为0

  • 偏向锁是默认延迟的,不会在程序启动时立即生效,如果想避免演出,可以加VM参数 -xx:BiasedLockingStartupDelay=0来禁用延迟

  • 如果没有开启偏向锁,那么对象创建后,markward即最后3位为001,这时它的hashcode,age都为0,第一次使用hashcode时才会赋值

偏向锁撤销

  1. 调用对象的hashcode

   调用了对象的hashCode,但偏向锁的对象MarkWord中存储的是线程id,如果调用hashCode会导致偏向锁被撤销

  • 轻量级锁会在锁记录中记录hasCode

  • 重量级锁则会在Monitor中记录hashCode

  1. 其他线程使用对象

批量重偏向

如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍然有机会重新偏向T2,重偏向会重置对象的Thread ID

当撤销偏向锁阈值超过20次后,jvm就会觉得,我是不是偏向错了呢,于是会再给这些对象加锁时重新偏向至加锁线程

批量撤销

当撤销偏向锁阈值超过40次后,jvm就会觉得,自己确实偏向错了,根本就不该偏向,于是整个累的所有对象都会变为不可偏向的,新建的对象也 是不可偏向的

锁消除

锁消除是Java虚拟机在JIT编译是,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。

public class SuoXiaoChu {    public static String getString(String s1, String s2) {        StringBuffer sb = new StringBuffer();        sb.append(s1);        sb.append(s2);        return sb.toString();    }    public static void main(String[] args) {        long tsStart = System.currentTimeMillis();        for (int i = 0; i < 1000000; i++) {            getString("TestLockEliminate ", "Suffix");        }        System.out.println("一共耗费:" + (System.currentTimeMillis() - tsStart) + " ms");    }}

getString()方法中的StringBuffer数以函数内部的局部变量,进作用于方法内部,不可能逃逸出该方法,因此他就不可能被多个线程同时访问,也就没有资源的竞争,但是StringBuffer的append操作却需要执行同步操作:

@Override    public synchronized StringBuffer append(String str) {        toStringCache = null;        super.append(str);        return this;    }

逃逸分析和锁消除分别可以使用参数-XX:+DoEscapeAnalysis和-XX:+EliminateLocks(锁消除必须在-server模式下)开启。使用如下参数运行上面的程序:

-XX:-DoEscapeAnalysis -XX:-EliminateLocks

507358513510a1d91caab0391a82431b.png

-XX:+DoEscapeAnalysis -XX:+EliminateLocks

b68517f2f0a43a3b1b68b5ab74ec9894.png

锁粗化

原则上,我们都知道在加同步锁的时候,尽可能的将同步块的作用范围限制在尽量小的范围,比如下面这两种情况:

package com.util.xgb;public class SynchronizedTest {private int count;public void test() {        System.out.println("test");        int a = 0;        synchronized (this) {            count++;        }}}
ackage com.util.xgb;public class SynchronizedTest {private int count;public void test() {        synchronized (this) {            System.out.println("test");            int a = 0;            count++;        }}}

很明显,第一种实现方式好于第二种,它并不会将对非共享数据的操作划分到同步代码块中,使得同步需要的操作数量更少,在存在锁竞争的情况下,也可以使得等待锁的线程尽快的拿到锁。

对于大多数情况,这种思想是完全正确的,但是如果存在一连串的操作都对同一个对象进行反复的加锁和解锁,甚至加锁的操作出现在循环体中,那么即使没有线程竞争共享资源,频繁的进行加锁操作也会导致性能损耗。

锁粗化的思想就是扩大加锁范围,避免反复的加锁和解锁。这里还是拿 StringBuffer 举例,如下所示

public String test(String str){             int i = 0;       StringBuffer sb = new StringBuffer():       while(i < 100){           sb.append(str);           i++;       }       return sb.toString():}

在这种情况下,JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 虚幻体外),使得这一连串操作只需要加一次锁即可。

总结

锁的升级方向

无锁-》偏向锁-》轻量级锁-》重量级锁

83c96d32d9c9f4870ff68ece8b0557f9.png

Tip:切记这个升级过程是不可逆的

用synchronized还是Lock呢

我们先看看他们的区别:

  • synchronized是关键字,是JVM层面的底层啥都帮我们做了,而Lock是一个接口,是JDK层面的有丰富的API。

  • synchronized会自动释放锁,而Lock必须手动释放锁。

  • synchronized是不可中断的,Lock可以中断也可以不中断。

  • 通过Lock可以知道线程有没有拿到锁,而synchronized不能。

  • synchronized能锁住方法和代码块,而Lock只能锁住代码块。

  • Lock可以使用读锁提高多线程读效率。

  • synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。

两者一个是JDK层面的一个是JVM层面的,我觉得最大的区别其实在,我们是否需要丰富的api,还有一个我们的场景。

比如我现在是滴滴,我早上有打车高峰,我代码使用了大量的synchronized,有什么问题?锁升级过程是不可逆的,过了高峰我们还是重量级的锁,那效率是不是大打折扣了?这个时候你用Lock是不是很好?

场景是一定要考虑的,我现在告诉你哪个好都是扯淡,因为脱离了业务,一切技术讨论都没有了价值。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值