我是这样学Synchronized关键字的

前言

大家好,我是狂聊君。

今天来聊synchronized关键字,高频面试问题。

这篇文章 构思 + 画图 + 文字整整一个星期,我已经彻底废了,看完希望你能有所收获。

话不多说,直接干货。

正文

一、synchronized的用法

1.1、三种使用方式

  1. 静态方法
  2. 非静态方法
  3. 代码块

代码示例:

public class Test {
    //对象
    Object object=new Object();
    //共享变量
    private static int num;
    //静态方法
    public synchronized static void lock1(){
        num ++;
    }
    //普通方法
    public synchronized  void lock2(){
        num ++;
    }

    public synchronized  void lock3(){
        //代码块
        synchronized (object){
            num ++;
        }
    }
}

1.2、作用范围

面试时经常会问:synchronized 关键字锁的是什么?或者说它的作用范围是什么?

总结一下:

  1. 非静态方法锁的是当前对象 (就是 this)
  2. 静态方法锁的是类对象 Test.class
  3. 代码块锁的是自定义的 Object 对象

1.3、原子性、可见性、有序性

我们都知道并发编程需要考虑三个问题:原子性、可见性、有序性。

那么,使用synchronized关键字是如何保证这三个问题的?

  1. 原子性:synchronized关键字能保证只有一个线程能拿到锁,能够进入同步代码块
  2. 可见性:执行synchronized时,对应lock原子操作会将会清空工作内存中此变量的值,并重新read来刷新内存
  3. 有序性:执行synchronized后,依然可能发生重排序,只不过,我们有同步代码块,可以保证只有一个线程执行同步代码中的代码,不会出现问题

二、对象内存布局

上面说了,这三种方式都是锁的是对象、对象、对象(说三遍),但是听起来好像很抽象的样子,对象还能被锁?如何操作

其实是和对象内存布局有关系。

耳听为虚,眼见为实,下面让你亲眼看到对象是由啥组成的。

示例代码:

//1、需要导入包
import org.openjdk.jol.info.ClassLayout;
//2、定义Lock类
public class Lock {
    int i;
    boolean flag;
}
//3、将Lock对象打印出来
public class Test {
    public static void main(String[] args){
        Lock lock = new Lock();
        System.out.println(ClassLayout.parseInstance(lock).toPrintable());
    }
}

打印出来的结果是这样的:

 OFFSET  SIZE      TYPE DESCRIPTION                               VALUE
      0     4           (object header)                           01 47 70 9d (00000001 01000111 01110000 10011101) (-1653586175)
      4     4           (object header)                           11 00 00 00 (00010001 00000000 00000000 00000000) (17)
      8     4           (object header)                           43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
     12     4           int L.i                                   0
     16     1           boolean L.flag                            false
     17     7           (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 7 bytes external = 7 bytes total

对打印结果,详细解释一下:

2.1、对象头(Object Header)

Object Header 是 MarkWord 和 Class Pointer 组成的,后面会详细解释

打印结果:占用 4+4+4=12 个 bytes。

2.2、实例数据(Interface Data)

对象实例数据,对象实例数据包括了对象的所有成员变量,其大小由各个成员变量大小决定的。

当然,不包括静态成员变量,因为它是在方法区维护的!

打印结果:可以看到 int L.i 和 boolean L.flag 就是实例数据,占用 4+1=5 个 bytes。

2.3、填充数据(Padding)

Java 对象占用空间是 8 字节对齐的,即所有 Java 对象占用 bytes 数必须是 8 的倍数,因为当我们从磁盘中取一个数据时,不会是一个字节的去读,都是按照一整块来读取的,这一块大小就是 8 个字节,所以为了完整,padding 的作用就是补充字节,保证对象是 8 字节的整数倍。

打印结果:可以看到(loss due to the next object alignment) 这个就是填充数据,占用 7 个字节。

这样的话,12+5+7=24 一共是 24 个 bytes,正好是 8 的倍数。

所以说,一个对象的内存布局是由对象头、实例数据、填充数据组成的。

接下来:重点关注这个对象头。

三、细说对象头

上面提到了对象头,直接看官网上的解释,官网地址在文末:

3.1、对象头(object header)

object header:Common structure at the beginning of every GC-managed heap object. (Every oop points to an object header.) Includes fundamental information about the heap object's layout, type, GC state, synchronization state, and identity hash code. Consists of two words. In arrays it is immediately followed by a length field. Note that both Java objects and VM-internal objects have a common object header format.

翻译:在每个 gc 管理的堆对象开始处的公共结构。(每个 oop 都指向一个对象头)包括关于堆对象的布局、类型、GC 状态、同步状态和标识哈希码的基本信息。由两个词组成。在数组中,紧随其后的是长度字段。注意,Java 对象和 vm 内部对象都有一个通用的对象头格式。

3.2、Klass Point

The second word of every object header. Points to another object (a metaobject) which describes the layout and behavior of the original object. For Java objects, the "klass" contains a C++ style "vtable".

翻译:每个对象头的第二个字。指向另一个对象(元对象),该对象描述原始对象的布局和行为。对于 Java 对象,“klass”包含一个 c++风格的“虚函数表”。

3.3、Mark Word

The first word of every object header. Usually a set of bitfields including synchronization state and identity hash code. May also be a pointer (with characteristic low bit encoding) to synchronization related information. During GC, may contain GC state bits.

翻译:每个对象头的第一个字。通常是一组位域,包括同步状态和身份哈希码。也可能是同步相关信息的指针(具有低比特编码特征)。在 GC 期间,可能包含 GC 状态位。

总结一下:其实对象头就是 MarkWord 和 Klass Point 组成的。
MarkWord 是用来存储对象的 hashCode、锁信息或分代年龄或 GC 标志等信息。
Klass Point 是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

那么问题来了!!

问题:那上面说的 MarkWord 是存储的 hashcode、锁信息或分代年龄或 GC 标志是在那定义的呢?

你可以下载 OpenJDK 的源码,在 markOop.hpp 的文件中可以看到 Mark Word 的状态信息:

我是这样学Synchronized关键字的

可以看到还是写的非常清晰的,画图总结一下:

我是这样学Synchronized关键字的

四、synchronized 深入分析

把 Test.java 编译为 Test.class ,并在对应目录下执行javap -v Test.class 这个命令,你能看到对应的字节码,如下:

我是这样学Synchronized关键字的

上图可以看到 JVM 对于同步方法和同步代码块的处理方式是不同的。

对于同步代码块:采用 monitorenter 和 monitorexit 两个指令来实现同步。

我是这样学Synchronized关键字的

monitorenter 指令可以理解为加锁,monitorexit 可以理解为释放锁。

进入 monitorenter 指令后,线程将持有 Monitor 对象,退出 monitorenter 指令后,线程将释放该 Monitor 对象。

对于方法:出现了ACC_SYNCHRONIZED 标识。

当出现了 ACC_SYNCHRONIZED 标识符的时候,Jvm 会隐式调用 monitorenter 和 monitorexit。在执行同步方法前会调用 monitorenter,在执行完同步方法后会调用 monitorexit,释放 Monitor 对象。

你可以发现,不管是同步代码块还是同步方法,都和 Monitor 对象有关系。

那么问题又来了!!

问题:这个 Monitor 对象是啥呢?monitorenter 和 monitorexit 又是什么呢?

4.1、monitorenter

直接看 JVM 规范里对它的描述,地址在文末:

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:

If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.

If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.

If another thread already owns the monitor associated with objectref, the thread blocks until the monitor's entry count is zero, then tries again to gain ownership.

翻译:每一个对象都会和一个监视器 Monitor 关联。监视器被占用时会被锁住,其他线程无法来获取该 Monitor。
当 JVM 执行某个线程的某个方法内部的 onitorenter 时,它会尝试去获取当前对象对应的 Monitor 的所有权。

执行过程如下:

  1. 若 Monior 的进入数为 0,线程可以进入 Monitor,并将 monitor 的进入数置为 1。当前线程成为 Monitor 的 owner 拥有者。
  2. 若线程已拥有 Monitor 的所有权,允许它重入 Monitor,则进入 Monitor 的进入数加 1。
  3. 若其他线程已经占有 Monitor 的所有权,那么当前尝试获取 Monitor 的所有权的线程会被阻塞,直到 Monitor 的进入数变为 0,才能重新尝试获取 Monitor 的所有权。

4.2、monitorexit

看 JVM 规范里对它的描述,地址在文末:

The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.

The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

执行过程如下:

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

4.3、Monitor 监视器

每个对象都会关联一个 Monitor 对象,也叫做监视器

在 HotSpot 虚拟机中,Monitor 是由 ObjectMonitor 实现的。其源码是用 c++来实现的,位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件中(路径:src/share/vm/runtime/objectMonitor.hpp)

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 ; //等待获取锁的线程,会放到这里
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

看到这里,我相信你就能明白为啥之前要解释对象内存布局、对象头,因为这三者之间是有对应关系的。

画图总结一下:

我是这样学Synchronized关键字的

可以看到 ObjectMonitor 的数据结构中包含:_owner、_WaitSet 和_EntryList。

它们之间的关系转换如下:

  1. 当多个线程同时访问同一段代码块或者某个同步方法的时候,这些线程会首先被放进_EntryList 队列中,处于 blocked 状态的线程,都会放入该队列中。
  2. 当某个线程获取到对象的 Monitor 时,此时就就可以进入 running 状态,执行代码逻辑,此时,ObjectMonitor 对象的_owner 指向当前线程,_count 加 1 表示当前对象锁被一个线程获取。而没有获取到锁的线程,会再次进入_EntryList 被挂起。
  3. 当 running 状态的线程调用 wait()方法,当前线程就会释放 Monitor 对象,进入 waiting 状态,ObjectMonitor 对象的_owner 变为 null,_count 减 1,同时线程进入_WaitSet 队列,直到有线程调用 notify()方法唤醒该线程,则该线程再次进入_EntryList 队列,直到再次竞争到锁再进入_owner 区。
  4. 如果当前线程执行完毕,那么也释放 monitor 对象,ObjectMonitor 对象的_owner 变为 null,_count 减 1。

这个过程大致就是在 JDK6 之前 实现的原理。

但是,JDK6 之前,synchronized关键字的效率是非常低的。

原因如下:

Monitor 对象是依靠底层操作系统的 Mutex Lock 来实现互斥的,线程申请 Mutex 成功,则持有该 Mutex,其它线程将无法获取到该 Mutex。

既然 Mutex Lock 涉及到底层操作系统,那这个时候就存在操作系统用户态和核心态的转换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等。

所以,在JDK 6 之后,从Jvm层面进行了优化,分为了偏向锁,轻量级锁,自旋锁,重量级锁。

五、锁升级

下面就依此来说锁是如何一步步升级的。

5.1、偏向锁

1、什么是偏向锁

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

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

我是这样学Synchronized关键字的

不过一旦出现多个线程竞争时必须撤销偏向锁,所以撤销偏向锁消耗的性能必须小于之前节省下来的CAS原子操作的性能消耗,不然就得不偿失了。

2、偏向锁原理

无锁到偏向锁的转换流程图:

我是这样学Synchronized关键字的

参数:-XX:+UseBiasedLocking 开启偏向锁

简单来说:

  1. 线程访问同步代码块,使用CAS 操作将 Thread ID 放到 MarkWord 当中
  2. 如果线程CAS 成功,此时线程就会获取到偏向锁
  3. 如果线程CAS 失败,证明已经有别的线程持有锁,这个时候启动偏向锁撤销,执行下面的操作

3、偏向锁的撤销

流程如下:

  1. 偏向锁的撤销动作必须等待全局安全点
  2. 暂停原持有偏向锁的线程
  3. 将Thread ID置为null,使其变成无锁状态
  4. 恢复原持有偏向锁线程,开始进行轻量级加锁流程

5.2 轻量级锁

1、什么是轻量级锁

轻量级锁是JDK 6之中加入的锁机制,它名字中的“轻量级”是相对于使用monitor的传统锁而言的,因此传统的锁机制就称为“重量级”锁。需要强调一点的是,轻量级锁并不是用来代替重量级锁的。

引入轻量级锁的目的:在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。

2、轻量级锁原理

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

流程图如下:

我是这样学Synchronized关键字的

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

5.3 自旋锁

1、为什么会有自旋锁?

前面聊monitor实现锁的时候,知道monitor会阻塞和唤醒线程,线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,这些操作给系统的并发性能带来了很大的压力。

同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。

如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个循环(自旋) , 这就是所谓的自旋锁

2、自旋锁的优缺点

自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的。

如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长。那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。

所以,自旋等待的时间必须要有一定的限度,如果在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗。

自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,你可以使用参数 -XX : PreBlockSpin 来更改。

总结

还总结啥?说的都这么明白啦!

其实就想说可以多看看官网,比如说monitorenter和monitorexit,虽然都是英文,但是这些都是第一手资料,看完真的不容易忘记。

官网地址

1、openjdk地址:http://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html

2、monitorenter: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorenter

3、 monitorexit:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorexit

最后(求关注)

原创不易,如果能帮到你的话,你的关注是给我最大的动力!

公众号:狂聊Java

我是这样学Synchronized关键字的
上一篇:29. secure world对smc请求的处理------monitor模式中的处理【转】


下一篇:Prometheus 收集指标插件工具