什么是锁
在并发环境下,会出现多个线程对同一个资源进行争抢的情况,假设A线程对资源正在进行修改,此时B线程此时又对资源进行了修改,这就可能会导致数据不一致的问题。为了解决这个问题,很多编程语言引入了锁机制,通过一种抽象的“锁”来对资源进行锁定,当一个线程持有“锁”的时候,其他线程必须等待“锁”,我认为这本质上就是在临界资源上对线程进行一种串行化。
Java语言中的锁机制是怎么设计的?在谈锁之前,我们需要简单了解一些Java虚拟机的内存结构。关于内存结构不是本文的重点,暂时不会影响到你的理解。我们可以来看这张图:
JVM运行时内存结构主要包含了五个部分:程序计数器(PC寄存器)、JVM栈、Native方法栈、 堆、方法区。
可以看到图中红色区域是各个线程私有的。这个区域中的数据,不会出现线程竞争的关系。而蓝色区域中的数据被所有线程共享,其中Java堆中存放的是大量对象,方法区中存放类信息、常量、静态变量等数据。当多个线程在竞争其中的一些数据时,可能会发生难以预料的异常情况。在程序开发中,锁的主要应用范围就是在数据共享区域。
了解了“锁”这种抽象的概念,那么在代码层面,它究竟是如何实现的?在Java中,主要采用了两种实现方式: 1. 基于Object的悲观锁。2. 基于CAS的乐观锁。本文主要讲解基于Object的悲观锁。
尝试用一句话概括:在Java中,每个Object,也就是每个对象都拥有一把锁,这把锁存放在对象头中,记录了当前对象被哪个线程占用。
对象、对象头
刚才提到了锁是存储在对象头中的,那么对象和对象头的结构分别是什么呢?
我们先来谈对象本身的结构,Java对象分为三个部分:
对象头
实例数据
对齐填充字节
TIPS:其中对齐填充字节是为了满足“Java对象大小是8字节的倍数”这一条件而设计的,为对象对齐填充了一些无用字节,大可不必理会。实例数据就是你在初始化对象时设定的属性和状态等内容。
对象头是我们这期要讲的重点之一,它存放了一些对象本身的运行时信息。对象头包含了两部分:
Mark Word
Class Pointer
相较于实例数据,对象头属于一些额外的存储开销,所以它被设计得极小(一般为232bit或264bit)来提升效率。Class Pointer是一个指针,指向当前对象类型所在方法区中的Class信息;Mark Word存储了很多当前对象的运行时状态信息,比如HashCode、 锁状态标志、指向锁记录的指针、偏向线程ID、锁标志位等等。
可以通过下面这张表对Mark Word有一个更直观的认识:
上面也提到了,对象头被设计得很小,Mark Word则主要体现了这一点,通过这张表我们可以看到,Mark Word只有32bit (或64bit) 并且它是非结构化的。这样,在不同的锁标识位下,不同字段可以重用不同的比特位,节省了空间。
我们从这张表中能看到,这把抽象的“锁”的信息就存储在对象头的Mark Word中。重点关注最后两位,这两位代表锁标志位,分别对应“无锁”、“偏向锁” 、“轻量级锁” 、“重量级锁”这四种状态。在Java中,启用对象锁的方式是使用、synchronizedi关键字,那么synchronized背后的原理是什么,上面列举的这些状态又都是什么意思呢?
synchronized
大家都知道在Java中,synchronized关键词可以用来同步线程,synchronized被编译后会生成monitorenter和monitorexit两个字节码指令,依赖这两个字节码指令来进行线程同步。
这里要介绍一样新事物: Monitor。Monitor常常被翻译成监视器或管程。关于Monitor,简单来说,你可以把它想像成一个只能容纳一名客人房间,而把想要获取对象锁的线程想像成想要进入这个房间的客人。一个线程进入了Monitor,那么其他线程只能等待,只有当这个线程退出,其他线程才有机会进入。
来看这张图,并模拟流程:
第一步:Entry Set中聚集了一些想要进入Monitor的线程,它们处于waiting状态。
第二步:假设某个名为A线程成功进入了Monitor,那么它就处于active状态。
第三步:此时A线程执行途中,遇到一个判断条件,需要它暂时让出执行权,那么它将进入wait set,状态也被标记为waiting。
第四步:这时entry set中的其他线程就有机会进入Monitor,假设一个线程B成功进入并且顺利完成,那么它可以通过notify的形式来唤醒wait set中的线程A,让线程A再次进入Monitor,执行完成后便退出。
这就是synchronized关键字所实现的同步机制,但是synchronized可能存在性能问题,因为monitor的下层是依赖于操作系统的Mutex Lock来实现的。Java线程事实. 上是对操作系统线程的映射(上篇讲到),所以每当挂起或唤醒一个线程都要切换到操作系统的内核态,这个操作是比较重量级的。在某些情况下,甚至切换时间本身就会超出线程执行任务的时间,这样的话,使用synchronized将会对程序的性能产生影响。
但是从Java6开始,synchronized进行了优化,引入了“偏向锁” 、“轻量级锁”的概念。因此对象锁总共有四种状态,从低到高分别是“无锁”、“偏向锁”、“轻量级锁”、“重量级锁”,这就分别对应了Mark Word中锁标记位的四种状态。
目前为止,我们已经搞懂了什么是锁,什么是对象头,Mark Word中的字段,synchronized、
monitor的初步原理,四种锁状态的由来。
接下来,你一定会对synchronized是如何优化的?这四种状态是如何变化的产生好奇,那么我们就来仔细盘一盘“无锁”、“偏向锁”、“轻量级锁”、“重量级锁”这四种状态各是什么。
对象锁的四种状态
无锁:
无锁顾名思义就是没有对资源进行操作系统级别(Mutex Lock)的锁定。在这个基础上,我理解“无锁”其实有两种语义。
第一种比较简单,某种资源不会出现在多线程环境下,或者说即使出现在多线程环境下也不会出现线程竞争的情况,那么确实无需对这个资源进行任何同步保护,直接让他给各个线程随意调用就可以了。在这里,指的是这种语意。
另一种情况,资源会被竞争,但是不使用操作系统同步原语对共享资源进行锁定,而是通过一些其他机制来控制同步。比如CAS,通过诸如这种函数级别的锁,我们可以进行“无锁”编程。顺便一提的是,上面也分析了依赖操作系统Mutex Lock导致性能低下的原因,所以在大部分情况下,无锁的效率更高,但这并非意味着无锁能够全面代替有锁。
偏向锁:
现在我们给对象开始加锁,假如一个对象被加锁了,但在实际运行时,只有一条线程会获取这个对象锁,那么我们最理想的方式,是不要通过系统状态切换,只在用户态把这件事做掉。我们设想的是,最好对象锁能够认识这个线程,只要是这个线程过来,那么对象就直接把锁交出去。我们可以认为这个对象锁偏爱这个线程,所以被称为“偏向锁”。
那么偏向锁是怎么实现的呢?其实很简单,在Mark Word中,当锁标志位是01,那么判断倒数第三个bit是否为1,如果是1,代表当前对象的锁状态为偏向锁,于是再去读Mark Word的前23个bit,这23个bit就是线程ID,通过线程ID来确认想要获得对象锁的线程是不是“被偏爱的线程”。
假如情况发生了变化,对象发现目前不只有一个线程,而是有多个线程正在竞争锁,那么偏向锁将会升级为轻量级锁。
轻量级锁:
当锁的状态还是偏向锁时,是通过Mark Word中的线程ID来找到占有这个锁的线程,那么当锁的状态升级到“轻量级锁”时,如何判断线程和锁之间的绑定关系呢?难道是通过不断的变更Mark Word中的线程ID的值?
非也,我们还是来看Mark Word这张表,这边已经不再使用线程ID这个字段,而是将前30个bit变为了指向虚拟机栈中锁记录的指针。
当一个线程想要获得某个对象的锁时,假如看到锁标志位为00,那么就知道它是轻量级锁,这时,线程会在自己的虚拟机栈中开辟一块被称为“Lock Record”的空间,关于虚拟机栈,上面简单讲过,是线程私有的。
Lock Record中存放什么呢?存放的是对象头的Mark Word的副本以及Owner指针。线程通过CAS去尝试获取锁,一旦获得,那么将会复制该对象的Mark Word到虚拟机栈的Lock Record中,并且将Lock Record中的Owner指针指向该对象锁。另一方面,对象的Mark Word中的前30bit将生成一个指针,指向持有该对象锁的线程虚拟机栈中的Lock Record。这样一来就实现了线程和对象锁的绑定,它们因此互相知道对方的存在。
这时,这个对象被锁定了,获取了这个对象锁的线程就可以去执行一些任务。那么你肯定要问,这时候万一有其他线程也想要获取这个对象,怎么办呢?此时其他线程将会自旋等待。什么叫“自旋”?你可以理解为一种轮询,其他想要获取对象锁的线程自己不断在循环尝试去看一下锁有没有被释放,如果被释放了,那么就获取,如果没有释放那么就进行下一轮循环,这种方式区别于被操作系统挂起阻塞,因为如果对象锁很快就会被释放的话,自旋去获得锁完全在用户空间解决,不需要进行系统中断和现场恢复,所以它的效率更高。
顺便提一下,自旋相当于是CPU在空转,如果长时间自旋,将会浪费CPU资源,于是出现一种叫做“适应性自旋”的优化,简单来说就是自旋的时间不再固定了,而是由上一次在同一个锁上的自旋时间及锁的状态来决定。比如在同一个锁上,当前正在自旋等待的线程刚刚成功获得过锁,但是锁目前被其他线程持有,那么虚拟机就会认为下次自旋很有可能会再次成功,进而它将允许更长的自旋时间。这就是“适应性自旋”。
假如对象锁被一个线程持有着,此时也有一个线程正在自旋等待,如果同时又有多个线程想要获取这个对象锁。也就是说,一旦自选等待的线程数超过1个,那么轻量级锁将会升级为“重量级锁”。
重量级锁:
如果对象锁状态被标记为重量级锁,那么就和我最初讲的那样,需要通过Monitor来对线程进行控制,此时将会使用同步原语来锁定资源,对线程的控制也最为严格。