Java并发编程(二)同步锁的优化,膨胀升级过程

/ 默认分类 / 0 条评论 / 1078浏览

java中同步锁synchronized详细分析

一.前言

前面我们介绍了synchronized同步锁可以保证操作的原子性,内存可见性等,下面我们会详细分析下,java中的锁机制和同步锁的使用方法。

二.java中的锁

在并发编程中,锁是解决并发问题的关键,但事实上它也就是一些特殊共享变量(这里我又想插一句哈哈,关于golang和java的两种并发模型的区别后面我要详细总结一下) ,java中锁被称为Object Monitor,也就是对象监视器,当一个线程需要执行某段同步代码时,需要先获取到当前的同步锁(监视器锁or监视器对象or排他锁...),获取到 后就可以执行,执行完可以释放当前持有的锁,其他线程这期间无法获取到(排他特性)

下面来看下这段代码:

public class Test1 {

    static int a;

    public void method01(){
        synchronized (this) {
            a = 1;
        }
    }

}

javap -C Test1.class 得到jvm汇编指令如下:

Compiled from "Test1.java"
public class cn.zh.test.inner.test121.bfbc.Test1 {
  static int a;

  public cn.zh.test.inner.test121.bfbc.Test1();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void method01();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter  //先获取对象监视器锁
       4: iconst_1  //将a=1压栈
       5: putstatic     #2                  // Field a:I
       8: aload_1
       9: monitorexit //释放对象监视器锁
      10: goto          18
      13: astore_2
      14: aload_1
      15: monitorexit  //这里还有一个释放监视器锁的指令,这种情况是执行程序异常,自动释放监视器锁  
      16: aload_2
      17: athrow
      18: return
    Exception table:
       from    to  target type
           4    10    13   any
          13    16    13   any
}

可以很清楚的看到,整个加锁和释放锁的过程,所以synchronized是通过一个监视器对象来实现同步排他特性的。

ps:后面的并发知识需要大量使用到监视器锁,这里明确下面一些关键词,他们都是类似的含义:(释放监视器锁,退出锁,退出监视器,释放锁等等)(加锁,获取监视器锁,进入监视器等等)

下面是使用synchronized直接加在方法签名上

public class Test1 {

    int a;

    public synchronized void method01(){
            a = 8;
    }

}

javap -c -v Test1.class 得到的jvm汇编指令

int a;
    descriptor: I
    flags:

  public cn.zh.test.inner.test121.bfbc.Test1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 7: 0

  public synchronized void method01();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: bipush        8
         3: putfield      #2                  // Field a:I
         6: return
      LineNumberTable:
        line 12: 0
        line 13: 6

可以看到,在method01的标志中多了一个ACC_SYNCHRONIZED,当jvm执行的方法标志中有ACC_SYNCHRONIZED,那么就需要先获取监视器锁,获取 后才能执行这个方法体,执行完后自动释放锁,这里没有使用monitorenter和monitorexit的jvm指令来加锁释放锁,而是jvm在执行时遇到标志直接进行互斥操作, 但是这两种方式的本质都是一样的,都是调用操作系统底层的互斥原语mutex来实现的(操作系统书籍中有详细说明,可以参考操作系统导论一书),只不过,这种直接 写在方法签名上,那么排斥的部分就是整个方法体。

java中每个对象都天生就是一个监视器锁,因为java对象头中已经存在相关锁信息(对象头在我之前的jvm博客中有介绍,其中也包括了对象分代数据),所以我们可以 自己指定使用的监视器对象,所以一般来说,java中synchronized同步锁有以下几种使用方式:

  1. 直接加在方法签名上,这样默认使用的监视器对象时当前对象(this)
public class Test1 {

    int a;

    public synchronized void method01(){
            a = 8;
    }

}
  1. 指定使用任意一个对象的监视器锁
    使用成员变量a对象的监视器锁
public class Test1 {

    Integer a;

    public void method01(){
        synchronized (a) {
            a = 8;
        }
    }

}
  1. 指定使用当前对象的监视器锁
    使用当前的Test1的对象的监视器锁
public class Test1 {

    Integer a;

    public void method01(){
        synchronized (this) {
            a = 8;
        }
    }

}
  1. 使用当前对象的类Class对象的监视器锁
    这样加锁,等于Test1类的所有对象都会同步排他,这把锁等于是一般公共锁
public class Test1 {

    Integer a;

    public void method01(){
        synchronized (Test1.class) {
            a = 8;
        }
    }

}
  1. synchronized修饰在static静态方法上
    这种情况和上面4是一样的,也就是使用当前类Class对象的监视器锁
public class Test1 {

    Integer a;

    public static synchronized void method01(){
            a = 8;
    }

}

综上:无论使用哪个对象的监视器锁,都是一个作用,实现了排他效果,只不过这些不同的监视器锁的影响范围不同

可以认为对象监视器是java中实现同步锁,实现并发控制的一个工具,这些操作其实都是可以基于进出监视器来实现,下面是java中的对象监视器这个数据结构的的实现源码(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 ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 阻塞等待该监视器锁的线程,会被加入到该集合  
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

前面已经说了对象监视器,存在与每个对象的对象头,所以java中的对象天生就可以作为监视器锁

ps:现在可以了解到,调用notify,wait这些为什么需要再同步代码块中呢?(如果不在会抛出异常java.lang.IllegalMonitorStateException)因为他们也需要操作相关的监视器锁,比如wait,那么就需要将当前线程加入到 当前的关联的监视器锁的_WaitSet中,wait调用后会释放当前的监视器锁(比如上面的Integer对象a调用a.wait(),其实这里就是监视器对象调用了wait方法,所以 操作的是监视器,当前线程会释放监视器锁,然后当前线程被加入该监视器锁的_WaitSetLock集合中,并且当前线程也会阻塞挂起,如果调用wait的时候指定了 等待时间那么可以等时间超时或调用interrupt,之后wait结束后,如果获取到了cpu执行权,这个线程就会重新回到阻塞队列中,等待获取当前监视器锁,如果是线程调用sleep方法那么就是线程的api调用,当前线程 是直接进入睡眠状态,没有监视器啥事,所以sleep操作当前线程不会释放监视器锁,wait会)

三.可重入性

java中基本都是可重入锁,为了避免产生死锁的产生,可重入锁的概念:某个线程获取到锁之后,还可以继续获取该锁,不会产生自己等待自己(不会产生死锁)
可重入锁的可重入性,一定是某个线程可以多此获取自己的锁,不同的线程之间是不行的,这个时候就必须要等待了。

在java中,当一个线程获取了某个监视器锁(或者说进入了某个监视器),那么它可以再次进入,并且最开始的时候,该监视器计数为0,第一次进入监视器计数+1,之后 同一个线程再次进入则再+1,释放一次后-1,直到计数器为0,则表示当前监视器锁被释放了,其他线程才能继续获取。否则就会阻塞等待。

四.锁的优化

前面我们介绍了synchronized实现同步锁,这里的加锁和释放锁由jvm控制,我们可以控制加锁的范围,使用的是进出对象监视器来实现的,而这里的所谓的进出监视器对象,其实 底层使用的还是操作系统的mutex原子锁操作,所以我们会发现一个问题,这样进入监视器锁,会发生程序状态的切换,比如加锁的时候需要从用户态转换到内核态,之后哦 再切换为用户态,释放锁的操作也是一样的,另外发生锁争夺等待,等待获取锁的线程会发生阻塞挂起,所以无论是每次都需要加锁释放锁还是线程的阻塞挂起 都涉及到内核态和用户态的切换,降低性能,这样的同步锁我们称为重量级锁,在jdk1.6之后对synchronized的同步锁进行了很大的优化。具体方法就是使synchronized同步锁 针对不同的锁竞争的场景,使用不同的锁状态。

ps:由于此关键字的优化使得性能极大提高,同时语义清晰、操作简单、无需手动关闭,所以 推荐在合适的场景下尽量使用此关键字,在性能上此关键字还有优化的空间。

优化后的同步锁具备了下面之中状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态.下面我们就逐个讨论下.
之前我们提到了java对象头的概念,其实这里通过设置锁的状态来进行锁的优化就利用了java对象头.对象头主要包括Mark word标记和类指针,其中类指针是指向jvm方法区 该对象对应的Class字节码对象,Mark word结构如下所示,主要包含了对象相关状态和对象锁状态等等,可以认为是保存java对象在运行期间的状态信息的.
下面这张图非常非常重要,基本涵盖了java中锁的升级膨胀演变的基本原理.后面我会详细分析.

markword结构中锁状态位及其对应的含义

所以优化后的锁的几种状态就是在对象头的Mark word进行标记的,下面来详细看下这几种锁状态分别有什么作用.

ps:首先需要明确一点,锁的升级膨胀过程是这样的:无锁->偏向锁->轻量级锁->重量级锁,这个过程锁的力度逐步加大,逐步升级.

ps:线程是否占用锁主要是进行下面的操作:在线程第一次进入同步代码块的时候,如果此同步锁对象出于无锁状态,即markword锁标志位是01,那么jvm会在当前线程栈中创建Lock Record(锁记录),用于锁对象的Mark Word(拷贝进来的). Lock Record属于线程私有的,每一个线程都有一个可用Lock Record锁记录列表,每一个被锁住的对象Mark word都会和一个Lock Record记录关联(对象头的MarkWord中的Lock Word指向Lock Record的起始地址), 并且本条锁记录Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

(1).无锁状态
初始化的对象监视器锁,其对象头中markword锁标志位为01,此时表示无锁状态.
当锁第一次被访问获取时,线程首先会判断当前锁标志位是否存储了当前线程id,发现没有,然后使用CAS修改当前锁的markword,一般情况下当前锁状态会变为可偏向,即将当前线程id 加入该锁的markword中,这也算是锁的升级,即锁的状态从无锁状态升级为偏向锁.什么是偏向锁呢?如下:

(2).偏向锁
java开发者通过大量的测试发现,大多数情况下,不会存在多线程对锁的竞争,并且对于锁的获取,大多都是当前线程重复获取,所以一方面java中的锁都具有可重入性,另外,因为基本都是 本线程重复获取锁,而加锁和释放锁的过程其实就是使用轻量级锁的过程,所以本质也就是使用自旋的CAS操作,不断尝试获取锁,而每次都是本线程自己获取当前锁,那么重入锁需要再次 执行轻量级锁的加锁和释放锁过程,也就是需要再次进行大量的CAS原子操作指令,这会造成大量的性能消耗,所以引入了偏向锁概念,就是从无锁状态修改后,将当前线程id复制到锁的mark word中,然后再次需要获取锁的时候,如果又是当前线程获取,那么首先发现markword存在当前线程id,那么直接执行同步代码快,如果没有,那么说明当前是出现了其他线程来争夺 锁了,那么偏向锁就升级为了轻量级锁,什么是轻量级锁呢?如下:

(3).轻量级锁
上面这种情况偏向锁升级为了轻量级锁,也就是另一个线程(线程2)现在来争夺这个锁了,那么线程2首先复制锁的markword到自己的方法栈中,然后线程2想抢占锁,即线程2会使用自旋CAS修改 当前锁的markkword指向自己的lock record,如果成功,则线程2开始执行同步代码块,并且将当前锁对象的markword锁标志位修改为00(轻量级锁),至此该锁就归线程2拥有了;但是如果 线程2自旋CAS修改markdword失败了,或者此时又有其他的线程来争夺,那么当前锁就会再次升级为重量级锁,关于重量级锁,如下:

(4).重量级锁
重量级锁的竞争,获取锁失败的线程会被阻塞,只有在持有锁的线程释放锁后才会唤醒他们,然后他们再去竞争锁。

(5).自旋及优化的适应性自旋

对于synchronized同步锁,如果发生锁的争夺,那么就会出现线程阻塞,这样就会导致线程上线文切换,会造成资源消耗(线程的阻塞和唤醒也需要进行内核态和用户态切换),java开发者在经过大量的测试总结后发现,即使是在多线程 环境下,大多数情况下,线程发生线程争抢锁,阻塞等待的时间也是极短暂的,让线程在这段时间阻塞挂起然后其他线程释放锁之后再启动线程,这样耗费的时间和资源会很大,不如 直接让当前竞争锁的线程进入自旋循环中,不断的尝试获取锁,这样就不会发生线程阻塞挂起.

所以锁的状态可以是自旋状态,也就是自旋锁,所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循 环检测锁是否被释放,而不是进入线程挂起或睡眠状态。那么这样也会出现一个问题,如果一直循环检测,如果当前持有锁的线程很快释放了锁,那么这种情况自旋锁就 发挥了最大的作用,如果一直不释放,那么自旋岂不是要一直持续下去,这样会一直占用cpu资源,所以自旋的时间(自旋次数)需要有一个限制,如果超过了自旋最大时间还没有获取到 锁,那么就阻塞挂起线程(所以说自旋并不是要替代线程阻塞挂起),在jdk1.6中默认开启锁的自旋状态,并且默认的自旋最大次数为10,可以通过jvm参数-XX:PreBlockSpin自行 设置,但是如果设置自旋最大10,假如每次都是再多自旋1或2次就能获得到锁了,那么岂不是很亏,所以后来的改进中,jdk1.6支持了适应性自旋,自旋次数不再是固定的,虚拟机会根据历史的 获取该锁时自旋的次数和失败成功情况来决定当前自旋的次数,如果之前自旋获取该锁基本都成功获取到了,那么下一次就允许自旋更多的次数,因为虚拟机认为这个锁通过自旋很大可能 会获取到,反之如果之前自旋获取该锁基本都失败了,那么就设置下一次自旋获取该锁的次数减少,甚至直到直接不会自旋,避免浪费cpu资源.

ps:简单总结下,当没有锁竞争出现时,默认会使用偏向锁(如果开启了),这样避免了每次进入同步块都要加锁和释放锁的操作,之所以这样设定是因为大量的测试表明,大多数情况下不存在多线程竞争锁,并且很多竞争锁的情况都是 线程本身重复获取锁,偏向锁主要是JVM 会利用 CAS 操作,在对象头 Mark Word 部分设置当前线程 ID,以表示这个对象偏向于当前线程。但是如果有另外的线程试图锁定某个已经被偏向过的对象,JVM 就需要撤销(revoke)偏向锁, 并切换到轻量级锁实现。轻量级锁通过自旋CAS 操作 Mark Word 来试图获取锁,如果重试成功,就使用轻量级锁;否则,进一步升级为重量级锁,当前争夺锁的线程进入阻塞挂起,已获取锁的线程释放后会唤醒挂起的线程继续争夺锁。

下面是简单的锁升级膨胀过程: