这一次,完全搞懂Java中的synchronized关键字

多线程并发是Java语言中很是重要的一块内容,同时,也是Java基础的一个难点。说它重要是由于多线程是平常开发中频繁用到的知识,说它难是由于多线程并发涉及到的知识点很是之多,想要彻底掌握Java的并发相关知识并不是易事。也正所以,Java并发成了Java面试中最高频的知识点之一。本系列文章将从Java内存模型、volatile关键字、synchronized关键字、ReetrantLock、Atomic并发类以及线程池等方面来系统的认识Java的并发知识。经过本系列文章的学习你将深刻理解volatile关键字的做用,了解到synchronized实现原理、AQS和CLH队列锁,清晰的认识自旋锁、偏向锁、乐观锁、悲观锁...等等一系列让人眼花缭乱的并发知识。html

多线程并发系列文章:java

这一次,完全搞懂Java内存模型与volatile关键字linux

这一次,完全搞懂Java中的synchronized关键字git

这一次,完全搞懂Java中的ReentranLock实现原理github

这一次,完全搞懂Java并发包中的Atomic原子类面试

深刻理解Java线程的等待与唤醒机制(一)数组

深刻理解Java线程的等待与唤醒机制(二)markdown

Java并发系列终结篇:完全搞懂Java线程池的工做原理数据结构

本文是Java并发系列的第二篇文章,将详细的讲解synchronized关键字以及其底层实现原理。多线程

开始以前先给你们推荐一下AndroidNote这个GitHub仓库,这里是个人学习笔记,同时也是我文章初稿的出处。这个仓库中汇总了大量的java进阶和Android进阶知识。是一个比较系统且全面的Android知识库。对于准备面试的同窗也是一份不可多得的面试宝典,欢迎你们到GitHub的仓库主页关注。

1、synchronized基本使用

上篇文章详细讲解了volatile关键字,咱们知道volatile关键字能够保证共享变量的可见性和有序性,但并不能保证原子性。若是既想保证共享变量的可见性和有序性,又想保证原子性,那么synchronized关键字是一个不错的选择。

synchronized的使用很简单,能够用它来修饰实例方法和静态方法,也能够用来修饰代码块。值的注意的是synchronized是一个对象锁,也就是它锁的是一个对象。所以,不管使用哪种方法,synchronized都须要有一个锁对象

1.修饰实例方法

synchronized修饰实例方法只须要在方法上加上synchronized关键字便可。

public synchronized void add(){
       i++;
}
复制代码

此时,synchronized加锁的对象就是这个方法所在实例的自己。

2.修饰静态方法

synchronized修饰静态方法的使用与实例方法并没有差异,在静态方法上加上synchronized关键字便可

public static synchronized void add(){
       i++;
}
复制代码

此时,synchronized加锁的对象为当前静态方法所在类的Class对象。

3.修饰代码块

synchronized修饰代码块须要传入一个对象。

public void add() {f synchronized (this) {
        i++;
    }
}
复制代码

很明显,此时synchronized加锁对象即为传入的这个对象实例。

到这里不是道你是否有个疑问,synchronized关键字是如何对一个对象加锁实现代码同步的呢?若是想弄清楚,那就不得不先了解一下Java对象的对象头了。

2、Java对象头与Monitor对象

在JVM中,对象在内存中存储的布局能够分为三个区域,分别是对象头、实例数据以及填充数据。

  • 实例数据 存放类的属性数据信息,包括父类的属性信息,这部份内存按4字节对齐。
  • 填充数据 因为虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
  • 对象头 在HotSpot虚拟机中,对象头又被分为两部分,分别为:Mark Word(标记字段)、Class Pointer(类型指针)。若是是数组,那么还会有数组长度。对象头是本章内容的重点,下边详细讨论。

1.对象头

在对象头的Mark Word中主要存储了对象自身的运行时数据,例如哈希码、GC分代年龄、锁状态、线程持有的锁、偏向线程ID以及偏向时间戳等。同时,Mark Word也记录了对象和锁有关的信息。

当对象被synchronized关键字当成同步锁时,和锁相关的一系列操做都与Mark Word有关。因为在JDK1.6版本中对synchronized进行了锁优化,引入了偏向锁和轻量级锁(关于锁优化后边详情讨论)。Mark Word在不一样锁状态下存储的内容有所不一样。咱们以32位JVM中对象头的存储内容以下图所示。

object_header.png

从图中能够清楚的看到,Mark Word中有2bit的数据用来标记锁的状态。无锁状态和偏向锁标记位为01,轻量级锁的状态为00,重量级锁的状态为10。

  • 当对象为偏向锁时,Mark Word存储了偏向线程的ID;
  • 当状态为轻量级锁时,Mark Word存储了指向线程栈中Lock Record的指针;
  • 当状态为重量级锁时,Mark Word存储了指向堆中的Monitor对象的指针。

当前咱们只讨论重量级锁,由于重量级锁至关于对synchronized优化以前的状态。关于偏向锁和轻量级锁在后边锁优化章节中详细讲解。

能够看到,当为重量级锁时,对象头的MarkWord中存储了指向Monitor对象的指针。那么Monitor又是什么呢?

2.Monitor对象

Monitor对象被称为管程或者监视器锁。在Java中,每个对象实例都会关联一个Monitor对象。这个Monitor对象既能够与对象一块儿建立销毁,也能够在线程试图获取对象锁时自动生成。当这个Monitor对象被线程持有后,它便处于锁定状态。

在HotSpot虚拟机中,Monitor是由ObjectMonitor实现的,它是一个使用C++实现的类,主要数据结构以下:

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 ; // 阻塞队列,线程被唤醒后根据决策判读是放入cxq仍是EntryList
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 没有抢到锁的线程会被放到这个队列
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }
复制代码

ObjectMonitor中有五个重要部分,分别为_ower,_WaitSet,_cxq,_EntryList和count。

  • _ower 用来指向持有monitor的线程,它的初始值为NULL,表示当前没有任何线程持有monitor。当一个线程成功持有该锁以后会保存线程的ID标识,等到线程释放锁后_ower又会被重置为NULL;
  • _WaitSet 调用了锁对象的wait方法后的线程会被加入到这个队列中;
  • _cxq 是一个阻塞队列,线程被唤醒后根据决策判读是放入cxq仍是EntryList;
  • _EntryList 没有抢到锁的线程会被放到这个队列;
  • count 用于记录线程获取锁的次数,成功获取到锁后count会加1,释放锁时count减1。

若是线程获取到对象的monitor后,就会将monitor中的ower设置为该线程的ID,同时monitor中的count进行加1. 若是调用锁对象的wait()方法,线程会释放当前持有的monitor,并将owner变量重置为NULL,且count减1,同时该线程会进入到_WaitSet集合中等待被唤醒。

另外_WaitSet,_cxq与_EntryList都是链表结构的队列,存放的是封装了线程的ObjectWaiter对象。若是不深刻虚拟机查看相关源码很难理解这几个队列的做用,关于源码会在后边系列文章中分析。这里我简单说下它们之间的关系,以下:

在多条线程竞争monitor锁的时候,全部没有竞争到锁的线程会被封装成ObjectWaiter并加入_EntryList队列。 当一个已经获取到锁的线程,调用锁对象的wait方法后,线程也会被封装成一个ObjectWaiter并加入到_WaitSet队列中。 当调用锁对象的notify方法后,会根据不一样的状况来决定是将_WaitSet集合中的元素转移到_cxq队列仍是_EntryList队列。 等到得到锁的线程释放锁后,又会根据条件来执行_EntryList中的线程或者将_cxq转移到_EntryList中再执行_EntryList中的线程。

因此,能够看得出来,_WaitSet存放的是处于WAITING状态等待被唤醒的线程。而_EntryList队列中存放的是等待锁的BLOCKED状态。_cxq队列仅仅是临时存放,最终仍是会被转移到_EntryList中等待获取锁。

了解了对象头和Monitor,那么synchronized关键字究竟是如何作到与monitor关联的呢?

3、synchronized底层实现原理

在Java代码中,咱们只是使用了synchronized关键字就实现了同步效果。那他究竟是怎么作到的呢?这就须要咱们经过javap工具来反汇编出字节指令一探究竟了。

1.同步代码块

经过javap -v来反汇编下面的一段代码。

public void add() {
    synchronized (this) {
        i++;
    }
}
复制代码

能够获得以下的字节码指令:

public class com.zhangpan.text.TestSync {
  public com.zhangpan.text.TestSync();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void add();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter    // synchronized关键字的入口
       4: getstatic     #2                  // Field i:I
       7: iconst_1
       8: iadd
       9: putstatic     #2                  // Field i:I
      12: aload_1
      13: monitorexit  // synchronized关键字的出口
      14: goto          22
      17: astore_2
      18: aload_1
      19: monitorexit // synchronized关键字的出口
      20: aload_2
      21: athrow
      22: return
    Exception table:
       from    to  target type
           4    14    17   any
          17    20    17   any
}
复制代码

从字节码指令中能够看到add方法的第3条指令处和第1三、19条指令处分别有monitorenter和moniterexit两条指令。另外第四、七、八、九、13这几条指令其实就是i++的指令。由此能够得出在字节码中会在同步代码块的入口和出口加上monitorenter和moniterexit指令。当执行到monitorenter指令时,线程就会去尝试获取该对象对应的Monitor的全部权,即尝试得到该对象的锁。

当该对象的 monitor 的计数器count为0时,那线程能够成功取得 monitor,并将计数器值设置为 1,取锁成功。若是当前线程已经拥有该对象monitor的持有权,那它能够重入这个 monitor ,计数器的值也会加 1。而当执行monitorexit指令时,锁的计数器会减1。

假若其余线程已经拥有monitor 的全部权,那么当前线程获取锁失败将被阻塞并进入到_WaitSet中,直到等待的锁被释放为止。也就是说,当全部相应的monitorexit指令都被执行,计数器的值减为0,执行线程将释放 monitor(锁),其余线程才有机会持有 monitor 。

2.同步方法的实现

同步方法的字节码指令与同步代码块的字节指令有所差别。咱们先来经过javap -v查看下面代码的字节码指令。

public synchronized void add(){
       i++;
}
复制代码

反汇编后可获得以下的字节指令

public synchronized void add();
    descriptor: ()V
    flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field i:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field i:I
        10: return
      LineNumberTable:
        line 5: 0
        line 6: 10

复制代码

能够看到这里并无monitorenter和moniterexit两条指令,而是在方法的flag上加入了ACC_SYNCHRONIZED的标记位。这其实也容易理解,由于整个方法都是同步代码,所以就不须要标记同步代码的入口和出口了。当线程线程执行到这个方法时会判断是否有这个ACC_SYNCHRONIZED标志,若是有的话则会尝试获取monitor对象锁。执行步骤与同步代码块一致,这里就再也不赘述了。

4、重量级锁存在性能问题

在Linux系统架构中能够分为用户空间和内核,咱们的程序都运行在用户空间,进入用户运行状态就是所谓的用户态。在用户态可能会涉及到某些操做如I/O调用,就会进入内核中运行,此时进程就被称为内核运行态,简称内核态。

linux_kernel.png

  • 内核: 本质上能够理解为一种软件,控制计算机的硬件资源,并提供上层应用程序运行的环境。
  • 用户空间: 上层应用程序活动的空间。应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。
  • 系统调用: 为了使上层应用可以访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。

上边咱们已经提到了使用monitor是重量级锁的加锁方式。在objectMonitor.cpp中会涉及到Atomic::cmpxchg_ptr,Atomic::inc_ptr等内核函数, 执行同步代码块,没有竞争到锁的对象会park()被挂起,竞争到锁的线程会unpark()唤醒。这个时候就会存在操做系统用户态和内核态的转换,这种切换会消耗大量的系统资源。试想,若是程序中存在大量的锁竞争,那么会引发程序频繁的在用户态和内核态进行切换,严重影响到程序的性能。这也是为何说synchronized效率低的缘由

为了解决这一问题,在JDK1.6中引入了偏向锁和轻量级锁来优化synchronized。

5、synchronized锁优化

JDK1.6中引入偏向锁和轻量级锁对synchronized进行优化。此时的synchronized一共存在四个状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁着锁竞争激烈程度,锁的状态会出现一个升级的过程。便可以从偏向锁升级到轻量级锁,再升级到重量级锁。锁升级的过程是单向不可逆的,即一旦升级为重量级锁就不会再出现降级的状况。

1.几种锁状态

接下来咱们来详细的认识一下这几种锁状态。

1).偏向锁

经研究发现,在大多数状况下锁不只不存在多线程竞争关系,并且大多数状况都是被同一线程屡次得到。所以,为了减小同一线程获取锁的代价而引入了偏向锁的概念。

偏向锁的核心思想是,若是一个线程得到了锁,那么锁就进入偏向模式,此时Mark Word的结构也变为偏向锁结构,即将对象头中Mark Word的第30bit的值改成1,而且在Mark Word中记录该线程的ID。当这个线程再次请求锁时,无需再作任何同步操做,便可获取锁的过程,这样就省去了大量有关锁申请的操做,从而也就提高了程序的性能。因此,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续屡次是同一个线程申请相同的锁。

可是,对于锁竞争比较激烈的状况,偏向锁就有问题了。由于每次申请锁的均可能是不一样线程。这种状况使用偏向锁就会得不偿失,此时就会升级为轻量级锁。

2).轻量级锁

轻量级锁优化性能的依据是对于大部分的锁,在整个同步生命周期内都不存在竞争。 当升级为轻量级锁以后,MarkWord的结构也会随之变为轻量级锁结构。JVM会利用CAS尝试把对象本来的Mark Word 更新为Lock Record的指针,成功就说明加锁成功,改变锁标志位为00,而后执行相关同步操做。

轻量级锁所适应的场景是线程交替执行同步块的场合,若是存在同一时间访问同一锁的场合,就会致使轻量级锁就会失效,进而膨胀为重量级锁。

3).自旋锁

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

自旋锁是基于在大多数状况下,线程持有锁的时间都不会太长。若是直接挂起操做系统层面的线程可能会得不偿失,毕竟操做系统实现线程之间的切换时须要从用户态转换到核心态,这个状态之间的转换须要相对比较长的时间,时间成本相对较高,所以自旋锁会假设在不久未来,当前的线程能够得到锁,所以虚拟机会让当前想要获取锁的线程作几个空循环(这也是称为自旋的缘由),不断的尝试获取锁。空循环通常不会执行太屡次,多是50个循环或100循环,在通过若干次循环后,若是获得锁,就顺利进入同步代码。若是还不能得到锁,那就会将线程在操做系统层面挂起,即进入到重量级锁。

这就是自旋锁的优化方式,这种方式确实也是能够提高效率的。

2.synchronized锁升级过程

在了解了jdk1.6引入的这几种锁以后,咱们来详细的看一下synchronized是怎么一步步进行锁升级的。

(1)当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0;

(2)当对象被当作同步锁并有一个线程A抢到了锁时,锁标志位仍是01,可是否偏向锁那一位改为1,前23bit记录抢到锁的线程id,表示进入偏向锁状态;

(3) 当线程A再次试图来得到锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A本身的id,表示线程A已经得到了这个偏向锁,能够执行同步中的代码;

(4) 当线程B试图得到这个锁时,JVM发现同步锁处于偏向状态,可是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操做试图得到锁,这里的得到锁操做是有可能成功的,由于线程A通常不会自动释放偏向锁。若是抢锁成功,就把Mark Word里的线程id改成线程B的id,表明线程B得到了这个偏向锁,能够执行同步代码。若是抢锁失败,则继续执行步骤5;

(5) 偏向锁状态抢锁失败,表明当前锁有必定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操做都是CAS操做,若是保存成功,表明线程抢到了同步锁,就把Mark Word中的锁标志位改为00,能够执行同步代码。若是保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6;

(6) 轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是表明不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。若是抢锁成功则执行同步代码,若是失败则继续执行步骤7;

(7) 自旋锁重试以后若是抢锁依然失败,同步锁会升级至重量级锁,锁标志位改成10。在这个状态下,未抢到锁的线程都会被阻塞。

6、总结

synchronized关键字的使用能够说很是简单,可是想要彻底搞懂synchronized实际上并无那么容易。由于它涉及到不少虚拟机底层的知识。同时,还要了解JDK1.6中对synchronized针对性的优化,其中牵扯到的东西又不少。好比,本篇文章并无讲解什么是CAS,若是你不懂CAS,就很难理解锁升级的过程。须要不懂读者自行去查阅相关资料。本篇文章对于synchronized的讲解相对来讲仍是很全面的。但愿你看完能有所收获。

参考&推荐阅读

深刻理解Java中synchronized关键字的实现原理

synchronized底层monitor原理

盘一盘 synchronized (一)—— 从打印Java对象头提及

相关文章
相关标签/搜索