Fork me on GitHub

synchronized介绍

概述

  synchronized是面试最高频的问题,比较简单的问题就是synchronized用在静态方法和非静态方法上的区别,复杂的问题就牵涉到synchronized如何保证原子性、有序性、可见性,以及synchronized锁优化和锁升级过程等,本文就介绍一下以上问题的原理。本文不涉及源码,如果有想要深入到源码级别的朋友,请看这篇文章

对象结构

  本来要介绍锁,为什么要先在这里介绍对象结构呢?因为锁只能锁对象,类锁其实也是一种对象锁,因为所有的实例对象都对应同一个类的Class对象,每个类有一个唯一的Class对象,那类锁其实就是锁的Class对象,所以类锁就是全局锁,这里为什么要强调对象呢?因为对象才有对象头信息,而对象头中放着锁相关的信息,OK,下面就贴一下对象结构的结构信息,其包括对象头。

    

 

    图片来源:Java并发基石——所谓“阻塞”:Object Monitor和AQS(1)

下面就对上面三个区域进行逐一说明:

  • 对齐区(Padding):这个区域的主要作用就是补全用的,因为HotSpot JVM规定对象头的大小必须是8字节的整数倍,其实不是必须存在的,如果对象头刚刚好就是8字节的整数倍就不需要了。
  • 对象数据:这个区域是真实对象的信息,包括所有的字段属性信息,他们可能是其他对象的引用或者实际的数据值。
  • 对象头(Header):对象头是重点关注的地方,下面会专门介绍

对象头

以32为Java虚拟机为例介绍。

普通对象的对象头

|--------------------------------------------------------------|
|                     Object Header (64 bits)                  |
|------------------------------------|-------------------------|
|        Mark Word (32 bits)         |    Klass Word (32 bits) |
|------------------------------------|-------------------------|

数组对象的对象头

|---------------------------------------------------------------------------------|
|                                 Object Header (96 bits)                         |
|--------------------------------|-----------------------|------------------------|
|        Mark Word(32bits)       |    Klass Word(32bits) |  array length(32bits)  |
|--------------------------------|-----------------------|------------------------|

下面就对每个区域做一个说明:

上图中第一行Object Header,就是这次要介绍的对象头了,对于非数组对象来说,对象头中就只包含两个信息:

  • Klass:这是一个指针区域,对于Java1.8来说,就是指向元空间,这里面存放的是被加载的.class文件信息,但是,不是初始化之后的Class对象,初始化之后的Class对象放在堆中。
  • Mark Word:终于介绍到今天的主角了,保存对象运行时相关数据,比如hashCode值、gc分代年龄、锁等信息,里面的内容不是固定的,随着对象的运行,里面的内容会发生变化。

Mark Word

Mark Word在不同的锁状态下存储的内容不同,32虚拟机是如下方式存储的。

锁状态

25bit

4bit

1bit

2bit

23bit

2bit

是否偏向锁

锁标志位

无锁

对象的HashCode

分代年龄

0

01

偏向锁

线程ID

Epoch

分代年龄

1

01

轻量级锁

指向栈中锁记录的指针

00

重量级锁

指向重量级锁的指针

10

GC标记

11

 

 

 

 

 

 

 

 

 

 

 

 

 

 

注意:上面表格中的写法,可能会引起误解,认为以上所有的信息都存在Mark Word中,其实不是,其实对象的锁状态只能处于其中一种,也就是说上面无锁、偏向锁、轻量级锁、重量级锁这几行其实每次只能一行存在,那对象头为什么设计成可变的呢?主要是为了节省空间,因为Java中一切皆对象,如果对象头占用了过多的空间,那所有对象的对象头累加起来占用的空间就很可观了。

下面就介绍一下synchronized的锁升级过程,从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,以及在锁升级的过程中对象头的变化。

无锁

没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其他修改失败的线程会不断重试直到修改成功。无锁状态对象头锁状态如下

无锁

对象的HashCode

分代年龄

0

01

 

 

 

偏向锁

HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

偏向锁的获取

当一个线程(线程A)访问同步块并获取锁时,会在对象头栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),并且锁状态是否是01(表示无锁状态),如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程

偏向锁

线程A ID

Epoch

分代年龄

1

01

 

 

 

 流程图如下:

        

      图片来源:jvm:ObjectMonitor源码

 以上流程图和上面的说法稍微有些出入,大家自己斟酌,不过这里还有一个疑问,首次将偏向锁标识修改成1是怎么进行的,等有时间好好研究下源码吧。

偏向锁撤销

上面如果线程B通过CAS加锁失败,就表明当前环境中存在锁竞争,当然这个时候并不会直接升级为轻量级锁,而是先把拥有偏向锁的线程的偏向锁给撤销了。有下面两种情况

  1. 如果拥有偏向锁的线程不处于活动状态或者已经退出同步代码块,这时把对象设置为无锁状态,然后重新偏向
  2. 如果拥有偏向锁的线程处于活动状态,而且依然需要使用偏向锁,则升级为轻量级锁

偏向锁的撤销并不是主动的,就是说拥有偏向锁的线程在执行完同步代码块的时候,并不会主动退出偏向锁,而是需要另一个线程来竞争偏向锁的时候,才会撤销,而且还需要程序运行到全局安全点(这个在GC垃圾回收时也有,我想和那个是一样的,在垃圾回收的时候,只有当线程运行到safepoint才会暂停等待GC的结束,否则就会一直运行),之后暂停线程,检查线程的状态是否处于活动状态,接下来就是上面介绍的那两点了。

流程图如下:

        

                               图片来源:jvm:ObjectMonitor源码

轻量级锁

引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

轻量级锁获取

当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,过程如下:

  1. 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),就是图中的1;否则执行步骤(3);
  2. JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,就是图中的2,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),加锁成功之后,会执行图中的3,owner指向对象的mark word,之后执行同步操作;如果失败则执行步骤(3);
  3. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;

轻量级锁释放

过程如下:

  1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据;
  2. 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
  3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

释放锁的过程非常简单,就是把栈帧中的Mark Word通过CAS给更新到对象的Mark Word中,问题是这个过程为什么会失败,因为此时只有这个线程获取到轻量级锁,释放的时候就只有一个线程,没有别的线程和他竞争,他的CAS操作为什么会失败呢?

原因如下:比如线程A获取到轻量级锁,如果这个时候线程B进来竞争锁,没有竞争到,然后自旋,自旋到一定时候,还没有获取到,膨胀为重量级锁,此时线程Mark Word已经发生了变更,在变更之前Mark Word的锁区域如下:

轻量级锁

指向栈中锁记录的指针

00

 

 

 

但是由于另一个线程已经膨胀为重量级锁,新建了ObjectMonitor(这个之后会介绍),这个时候Mark Word锁记录如下:

重量级锁

指向重量级锁的指针

10

 

 

 

此时的指针已经指向了ObjectMonitor,而不再是线程A的栈帧,所以当线程A通过CAS进行修改对象的Mark Word的时候会失败,此时线程A自旋等待锁膨胀结束,执行退出重量级锁。

流程图如下:

下图中所说的图A在上面。

        

               图片来源:jvm:ObjectMonitor源码

重量级锁

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。下面介绍一下重量级锁的加锁过程。

        

解释:

  • 最左边是要竞争重量级锁的线程,这些个线程还没有竞争到锁的呢,就放在对象Monitor的entrylist中,线程的状态为Blocked
  • entrylist中的线程通过CAS来竞争锁,就是通过CAS来修改count的值,如果成功就获取到锁,同时由于synchronized是可重入锁,每次重入,只要将count进行加1操作即可
  • 如果线程1在进入同步代码块之后,执行了wait操作,把自己给挂起了,就要把线程1放入到waitset中,然后重新执行第二步
  • 处于waitset集合中的线程需要别的线程执行notify/notifyall等操作才可以被唤醒继续执行。

各种锁对比

 

        

 锁优化

自旋锁:自旋锁就是如果线程没有获取到锁,就让线程空跑一段时间,为什么线程没有获取到锁,不直接挂起呢?因为线程挂起要进行上下文切换,对于CPU来说是一个很繁重的工作,所以就让线程空跑一下,但是空跑同样会浪费CPU,所以要加一个限制,限制空跑的时间,如果在限制时间内没有获取到锁就挂起。自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整; 如果通过参数-XX:preBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。

适应自旋锁:JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。 有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

锁消除:就是有些场景下,我们加了锁,但是JVM通过分析发现共享数据不存在竞争,这个时候JVM就会进行锁消除。

锁粗化:就是将多个加锁,解锁连接到一起,扩展成一个范围更大的锁。如下例子:

    public void vectorTest(){
        Vector<String> vector = new Vector<String>();
        for(int i = 0 ; i < 10 ; i++){
            vector.add(i + "");
        }

        System.out.println(vector);
    }

vector每次执行add方法都要进行加锁解锁的操作,效率非常低下,JVM会检测到对同一个对象vector连续加锁解锁,会合并成一个更大的加锁解锁,就是把锁移动到for之外。

synchronized如何保证可见性和有序性

上面介绍了那么一大堆,其实都是在介绍synchronized如何保证原子性,那可见性和有序性如何保证呢,其实synchronized保证可见性和有序性的方法和volatile类似,如下图:

        

上面内存屏障是什么意思,参考我的另一篇volatile的文章

总结

本文主要介绍了synchronized如何保证原子性、可见性、有序性,在介绍原子性的时候介绍了synchronized非常多的手段用来保证原子性,其实上面搞了一大堆锁优化,锁升级,就是为了提高效率,我们使用多线程的目的其实也是为了提高效率,那锁的作用是什么,为了保证多线程的安全,但是保证了安全,效率没了,那和单线程有什么区别,所以才会在锁上面做了那么多的优化。

 

 参考:

【死磕Java并发】—–深入分析synchronized的实现原理
posted @ 2020-09-02 12:18  猿起缘灭  阅读(396)  评论(0编辑  收藏  举报