Java中的锁原理

阅读时间 > 20min

《Java中的锁原理》 image.png

最近在复习Java并发相关内容,突然发现日记本躺了一篇一年前写好的文章,估计是写完,忘了点发布了,😂

《Java中的锁原理》 image.png

随着集成电路越来越发达,多计算核心的机器大行其道,为了解决多个并行执行分支对某一块资源的同步访问,操作系统层面提供了 互斥信号量 的概念。在几乎所有的支持多线程编程模型的语言中,基本上都提供了与互斥信号量对应的概念,在Java中我们称之为,我们今天就来讨论一下Java中的锁。

说起Java中的锁,其实大部分人第一反应就是synchronized,我们可以把这个关键字放到方法或者代码块上,表示使用某一个对象作为锁,去同步这个方法或者代码块。在Java中任何非NULL对象(我们称这个对象为锁对象)都可以作为一把锁给synchronized使用,这就是我们常说的内置锁,也叫监视锁。多个线程只有去访问同一个监视锁保护的临界区时才会发生竞争。

为什么叫内置锁,因为它是作为Java的一个关键字被引入进来的,可以理解为Java内置的功能;为什么也叫监视锁?因为JVM内部是通过monitorentermonitorexit这两个字节指令来获取锁和释放锁的,monitor就是监视的意思,同时它的Java层实现类名字为ObjectMonitor

一、内置锁的使用

内置锁最大的有时就是它的使用非常简单,从前面的描述中我们可以确定使用synchronized时,我们只需要给这个关键字一个对象作为锁即可,所以不管
茴香豆的茴字有几种写法,本质上它都是给synchronized一个对象作为监视的对象。

// (1):加静态方法上面,表示会监视这个类对象
public static synchronized void staticFunc() {
    //dosomething
}

//(2):加实例方法上面,表示监视当前这个实例对象,我们常说的this
public synchronized void virtualFunc() {
    //dosomething
}

public void monitorThis() {
    //(3):加代码块上面,括号里面传入的是需要监视的对象,这里是this
    synchronized (this){
    }
}

private Object lock = new Object();
public void monitorObject() {
    //(4):自己new了一个lock对象,然后监视lock对象
    synchronized (lock) {
    }
}

如果你在阅读一些SDK代码的时候,可能会发现有些场景下SDK开发人员会使用第四种方式,通过自己new一个对象,然后监视这个对象,那这种方式和前面三种相比,有什么优势呢?如果在一个类中存在两个临界区需要同步,即需要两把锁,Lock1Lock2,那么此时一个this对象就不够用了…

二、内置锁的特点

  • synchronized锁是可重入锁
    可重入指的是如果一个线程已经持有了一把监视锁时,这个线程如果需要再次获取这把锁,不需要再次竞争,可以直接得到。

      public synchronized void test() {
        reentrant();
      }
    
      public synchronized void reentrant() { 
      }
    

test()reentrant()都需要this对象上面的监视锁,由于synchronized是 可重入的,所以test()获取了锁之后,调用reentrant()时,需要再次获取锁,由于可重入性,test()方法是没有问题的。

  • synchronized锁是阻塞的
    如果线程1试图去获取监视锁,失败之后,线程1会加入到阻塞队列中等待锁的释放。

  • 内置锁是不公平锁
    公平锁指的是,比如一把锁现在正在被线程1持有,此时线程2试图获取锁,当然线程2会失败,然后放到阻塞队列中,如果线程1长时间持有锁,那么阻塞队列中的线程会越来越多;如果此时线程1使用锁完毕,开始释放锁,此时,JVM会唤醒那个线程呢?如果是按照加入阻塞队列的顺序来依次唤醒,那么就是公平锁;否则,就是非公平锁;由于公平锁的性能通常来说比不上不公平锁(自己脑补一下,公平锁明显需要一些额外的消耗,比如记录加入顺序;同时如果在线程释放锁时,刚好有一个线程在获取锁,那么公平锁需要把这个线程阻塞,然后从队列总取出对头,而非公平锁就可以直接把锁分配给先来的线程),JVM对内置锁的实现是非公平的,实际上Java的内置锁在进入阻塞队列前,会使用自旋锁等待一段超时时间,这样这样就形成了后来先用,当然不公平啦。

三 内置锁的状态

由于内置锁是通过JVMmonitorentermonitorexit指令实现的,所以可想而知它的加锁释放锁都会在JVM中实现。我们前面说了,操作系统会提供一个叫互斥信号量的东西,它的本质就是一把锁。在早期的JVM实现中,monitorentermonitorexit比较严重的依赖于操作系统的互斥信号量,这就存在一个问题:使用系统的互斥信号量就需要将线程从用户态切换到内核态,由于这种切换存在一定的性能问题,所以Java开发人员对synchronized使用都比较谨慎,甚至给它扣了一顶影响性能的帽子。

实际上从1.6版本开始,针对这个问题Java进行了一系列的优化。首先就是对锁的状态进行了区分,监视锁不在是简单的一把锁,它还有各种状态。锁的状态流转其实就对应着多个线程对锁的竞争程度,如果对这个锁的竞争比较低,那么JVM不会去通过系统信号量来实现同步,随着竞争加剧,获取锁的代价越来越大,最后退化成依赖于系统信号量的 重量级锁。

3.1 锁信息存储

我们前面说了监视锁可以加在任何一个对象上面,那么对象如何去表示自己当前被监视了呢?在HotSpot虚拟机中,每个对象在内存中分为三个部分:对象头、实例数据和对齐填充。而锁信息就存放在对象头中,由于对象头的长度和具体机器字长相关,一般来说目前存在两种字长,32和64位,对象头的格式如下图:

《Java中的锁原理》 Java对象头.png

对象头大致分为三部分,Mark Word,类型指针和数组长度(如果对象是数组)。而我们的锁就是存放在Mark Word中的。我们先说后面两部分:类型指针,指向当前对象类型,表明这个实例是什么类型的;数组长度是可选的,只有当前对象是数组的时候才会存在,表明数组的长度。最后我们来看看最复杂的Mark Word,它的格式如下:

《Java中的锁原理》 Mark_Word.png

Mark Word中涉及到了Java中的太多核心内容,比如
GC分代的年龄,对象的
hashCode、是否被
GC标记、是否有监视锁等等,虚拟机从执行的效率来考虑,它给了一个字长(32 / 64)。在不同的状态下,
Mark Word中每个字段代表的含义不一样。
所谓状态,指的就是Mark Word的最后两位,图中的标志位。这个看起来好像很复杂的样子,你可以先不关心各种名词,什么偏向锁 轻量级锁 xxx(这些一会儿我们后面会说),因为它们只代表各种状态。我们从最简单的开始:

  • 可GC 标志位:11
    这个状态就是Mark Word最后两个Bit是11,如果设置了这个标记,那么表示当前这个类是可GC的,前面的bitfields里面的内容已经不重要了,因为这个类马上就要被回收。

  • 无锁 标志位:01
    我们注意到无锁和偏向锁都使用了01标志位,这样没有办法,我们只好再往前占一个bit来区分,这个位用来标识偏向锁是否禁止,无锁状态就表示当前对象禁止偏向;

  • 偏向锁 标志位:01
    由于此时这个对象是可以偏向的,它存在三种情况:
    (1): 匿名偏向(Anonymously biased)
    表示当前还没有线程偏向这个对象,第一个试图获取锁的线程可以使用CAS指令去改变锁对象的Mark Word指向自己。这个状态是可偏向对象锁的初始状态。
    (2): 可重偏向(Rebiasable)
    epoch字段无效,可以理解为之前这个锁对象偏向于某个线程,但是这个线程已经退出了临界区,这个时候如果另外一个线程来获取锁,可以使用CAS指令去改变锁对象的Mark Word指向自己。
    (3): 已偏向(Biased)
    epoch字段有效,表示锁对象当前已经偏向某一个线程。
    以上内容参考:偏向锁

  • 轻量级锁 标志位:00
    偏向锁存在竞争时,进入轻量级锁的状态,此时获取锁的线程开始自旋等待。

  • 重量级锁 标志位:10
    竞争锁的各个线程开始使用系统的信号量做同步,回到最原始的状态。

3.2 锁状态流转

从上面一节中我们已经从方法头里面已经看到一个锁对象有各种四种状态:
无锁 -> 偏向锁->轻量级锁->重量级锁
在1.6中,偏向锁是默认打开的,所以和锁相关的状态其实只有三种。随着获取锁的竞争加剧,锁的状态会从偏向锁升级到轻量级锁,最后到重量级锁。

3.2.1 偏向锁

大部分情况下,对一个锁的获取都是同一个线程(不存在竞争),为了减少获取锁的代价,引入偏向锁。因为每次加锁/解锁都会涉及到一些CAS操 作(比如对等待队列的CAS操作),CAS操作会延迟本地调用,因此偏向锁的想法是一旦线程第一次获得了监视对象,之后让监视对象“偏向”这个 线程,之后的多次调用则可以避免CAS操作。说白了就是给锁对象设置个变量,线程在获取时,只需要看一下当前这个变量是不是自己,如果是自己,就不需要再去走获取锁的逻辑了。
获取偏向锁主要有以下几个步骤:

(1): 检查锁的偏向是否打开,如果没有打开,则进入轻量级锁路径来获取锁;
(2): 检查当前对象偏向锁的状态,1>如果是匿名偏向,那么简单的CAS获取偏向,如果成功,那么当前锁对象偏向当前线程;如果失败,表明当前存在竞争,退化到轻量级锁;2>如果是可重偏向,那么CAS获取偏向,如果成功,那么当前锁对象偏向当前线程;如果失败,表明当前存在竞争,退化到轻量级锁;3>已偏向,表明当前锁已经有偏向线程,此时退化到轻量级锁。
基本上就是一条原则,如果获取偏向失败,那么就撤销锁的偏向,转入轻量级锁的路径来获取。

释放偏向锁就是最简单的事情,那就是啥都不做,因为这就是偏向锁的意义,下次需要获取锁的时候,判断一下是否还是偏向自己就ok了。

有没有细心的盆友发现,锁对象处于偏向状态下,和 无锁状态下,Mark Word的内容区别在于 偏向的状态下没有hashcode字段?那么hashcode信息不就丢失了?
hash的对象不可被用作偏向锁。 注意,Mark Word的hashcode是依据内存地址计算的那个,也就是说一旦我们调用了系统的hashcode计算,那么这个对象就不能被偏向啦~
对于允许偏向的对象在进行hashcode计算时,首先要吊销(revoke)所有的偏向(不管是有效的还是无效的),然后使用CAS将计算好的hashcode值放到MarkWord中,尽管这仅仅适用于“identity hashcode(使用Object类的hashcode()方法进行计算)”。普通Java类型hashcode的计算需要重载Objecthashcode()方法,但不必要去显示调用这个方法;因此,对于没有显示调用Object#hashcode()方法的类的对象,仍然适用于偏向锁的机制——可被用作锁对象使用。关于Hashcode更多信息,可以戳这里

轻量级锁

每一个线程都会有一个私有的数据结构,称为Moniter Record列表,每一个Moniter Record都是用来做什么的呢?它会记录这把锁的对象是谁、锁重入数,这个锁上阻塞或者等待的线程列表,以及从锁对象中copy来的Mark Word,它包括了对象的HashCode GC age等信息。

每一个被锁住的对象都会和一个Moniter Record关联,对象头中的内容就指向这个线程的Moniter RecordMoniter Record中的Owner指向锁对象。

为啥Moniter Record需要copy一份Mark Word,我们可以看到处于锁定过程中的Mark Word中除了标志位,其实只有一个地址,它就指向当前这个Moniter Record,试想一下如果Moniter Record不存Mark Word,那么这个对象的GC年龄就有可能丢失了..

轻量级锁获取过程如下:
(1)当对象处于无锁状态时(状态位为001),线程首先从自己的可用Moniter Record列表中取得一个空闲的Moniter Record,线程通过CAS原子指令设置该Moniter Record的起始地址到对象头,如果存在其他线程竞争锁的情况而调用CAS失败,则只需要简单的回到monitorenter重新开始获取锁的过程即可。

(2)对象已经处于轻量级锁情况下(状态为00),说明当前锁已经被其他线程锁住,这个时候当前线程自旋一定次数(或时间),看看锁的状态是否改变,如果次数到了状态还没有改变,那么这个线程升级锁的状态为重量级锁,请求系统接入调度;

重量级锁

通过系统 互斥信号量接介入来达到同步,代价最高,因为涉及到了当前线程在用户态和核心态的切换,这也是为什么Java中要做前面优化的原因。

内置锁三种状态的比较

  • 偏向锁
    优点:代价最低,在低竞争的情况下,如果大部分情况都是同一个线程进入临界区,一旦锁对象进行了偏向,那么几乎没有什么成本(仅仅是多了一次是否是自己持有锁的判断);
    缺点:如果锁上有激烈的竞争,那么偏向带来的性能优势就会消失殆尽,因为偏向之后,还需要撤销偏向。

  • 轻量级锁
    优点:竞争的线程不会阻塞,提高了程序的响应性
    缺点:消耗CPU资源

  • 重量级锁
    优点:不用消耗CPU自旋
    缺点:阻塞线程,响应时间长

    原文作者:楚云之南
    原文地址: https://www.jianshu.com/p/b691968c2834
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞