锁到底是一种怎样的存在?

1,889 阅读18分钟

随着业务的发展与用户量的增加,高并发问题往往成为程序员不得不面对与处理的一个很棘手的问题,而并发编程又是编程领域相对高级与晦涩的知识,想要学好并发相关的知识,写出好的并发程序不是那么容易的。对于写Java的程序员说,在这一点上可能要相对幸福一些,因为Java中存在大量的封装好的同步原语以及大师编写的同步工具类,使得编写正确且高效的并发程序的门槛降低了许多。这种高度的封装抽象虽然简化了程序的书写,却对我们了解其内部实现机制产生了一定的阻碍,现在就让我们从现实世界中的锁的角度进行类比,看看程序世界中的锁到底是一种怎样的存在?

程序世界中的锁

如果有人问你:"如何确保房屋不被陌生人进入"?我想你可能很容易想到:“上锁就可以了嘛!”。而如果有人问你:"如何处理多个线程的并发问题"?我想你可能脱口而出:"加锁就可以了嘛!"。类似的场景在现实世界中很容易理解,但是在程序世界中,这几个字却充满了疑惑。我们见过现实世界中各种各样的锁,那Java中的锁长什么样子?我们现实世界中通常需要钥匙打开锁进入房屋,那打开程序世界中的锁的那把钥匙是什么?现实中锁通常位于门上或者橱柜上或者其他位置,那程序世界中的锁存在于哪里呢?现实世界中上锁开锁的通常是我们人,那程序世界中加锁解锁的又是谁呢?

带着这些疑问,我们想要深入的了解一下Java中锁到底是一种怎样的存在?从哪里开始了解呢,我想锁在程序中首先是被用来使用的,那就先从锁的使用开始侦查吧!

锁的使用

提到 Java中的锁,通常可以分为两类,一类是JVM级别提供的并发同步原语Synchronized, 另一类就是 Java API级别的Lock接口的那些若干实现类。Java API级别的锁比如Reentrantlock和ReentrantReadWriteLock等这些存在很详细的源码,大家可以去看看他们是怎么实现的,也许可以寻找到上面的答案,这里我们看一下Synchronized。

先来看下面这段代码:

public class LockTest
{
    Object obj=new Object();
    public static synchronized void testMethod1()
    {
        //同步代码。
    }
    public synchronized void testMethod2()
    {
        //同步代码
    }
    public void testMethod3()
    {
        synchronized (obj)
        {
            //同步代码
        }
    }
}

很多并发编程书籍对于Synchronized的用法都做了如下总结:

  • Synchronized修饰静态方法的时候(对应testMethod1),锁的是当前类的class对象,对应到这里就是LockTest.class对象
  • Synchronized修饰实例方法的时候(对应testMethod2),锁的是当前类实例的对象,对应到这里就是LocKTest中的this引用对象
  • Synchronized修饰同步代码块的时候(对应testMethod3),锁的是同步代码块括号里的对象实例,对应到这里就是obj对象

从这里我们可以看到,Synchronized的使用都要依赖特定的对象,从这里可以发现锁与对象存在某种关联。那么我们下一步看看对象中到底有什么关于锁的蛛丝马迹。

对象的组成

Java中一切皆对象,就好比你的对象有长长的头发,大大的眼睛(或许一切只是想象)... Java中的对象由三部分组成。分别是对象头、实例数据、对齐填充。

实例数据很好理解,就是我们在类中定义的那些字段数据所占用的空间。而对齐填充呢是因为Java特定的虚拟机要求对象的大小必须是8字节的整数倍,如果一个对象锁占用的存储空间最后会有一个不够8字节的碎片,那么要把他填充到8字节。看起来锁与这两个区域都不会有太大的关系,那么锁应该与对象头存在某种关系,如下图:

对象组成.png

下面来看一下对象头中的内容:

长度内容说明
32/64bitMark Word存储对象的HashCode或锁信息
32/64bitClass Metadata Address存储到对象类型数据的指针
32/64bitArray length数组的长度(如果当前对象是数组)

我们以32位虚拟机为例(64位的类比即可),Mark Word只有四个字节,而且还要存放HashCode等信息,难道锁就完全存在于这四个字节之内就可以实现嘛?这句话在Jdk1.6之前是完全不对的,在Jdk1.6之后在一部分情况下是对的。

为什么这么说呢?

这是因为Java中的线程是与本地的操作系统线程一一对应的,而操作系统为了保护系统内部的安全,防止一些内部指令等的随意调用,保证内核的安全,将系统空间分为了用户态与内核态,我们平时所运行的线程只是运行在用户态,当我们需要调用操作系统服务(这里被称为系统调用),比如read,writer等操作时,是没有办法在用户态直接发起调用的,这个时候就需要进行用户态与内核态的切换。而Synchronized早期被称为重量级锁的原因是因为使用Synchronized所进行的加锁与解锁都要进行用户态与内核态的切换,所以早期的Synchronized是重量级锁,需要实现线程的阻塞与唤醒,阻塞队列与条件队列的出队与入队等等,这些我们后面再说,显然是不可能存放在这四个字节之内的。但是Jdk1.6时对Synchronized进行了一系列优化,其中就包括了锁升级,使得这句话变得部分对了。

锁升级的过程

之所以说前面那句话在部分情况下是正确的,是因为在Jdk1.6时,虚拟机团队对Synchronized进行了一系列的优化,具体我们就不讨论了,很多的并发编程书籍中都有详细的记录。而这里我们要说的就是其中的一项重要的优化——锁升级。

Java中Synchronized的锁升级过程如下:无锁——>偏向锁——>轻量级锁——>重量级互斥锁。

也就是说除非存在很严重的多线程之间的锁竞争,否则Synchronized不会使用Jdk1.6之前那么重的互斥锁了。

我们知道现实世界中是由我们人来负责进行上锁和开锁的,那么程序世界中其实是由线程来扮演人的角色来进行加锁解锁的。

偏向锁

刚开始的时候,处于无锁状态,我们可以理解为宝屋的门没锁着,这时第一个线程运行到了同步代码区域(第一个人走到了门前),加上了一个偏向锁,这个时候锁是一种什么形态呢?这个时候其实是类似一种人脸识别锁的形态,第一个进入同步代码块的线程自身作为钥匙,将能够唯一标识一个线程的线程ID保存到了Mark Word中。

这个时候的Mark Word中的内容如下:

偏向锁.jpg

这里的四个字节的23位用来存储第一个获取偏向锁的线程的线程ID,2位的Epoch代表偏向锁的有效性,4位对象分代年龄,1位是否是偏向锁(1为是),2位锁标志位(01是偏向锁)。

当第一个线程运行到同步代码块的时候,会去检查Synchronized锁使用的那个对象的对象头,如果上面所谈的Synchronized所使用的三种对象其中之一的对象头的线程ID这个地方为空的话,并且偏向锁是有效的,说明当前还是处于无锁的状态(也就是宝屋还没有上锁),那么这个时候第一个线程就会使用CAS的方式将自己的线程ID替换到对象头Mark Word的线程ID,如果替换成功说明该线程获取到了偏向锁,那么线程就可以安全的执行同步代码了,以后如果线程再次进入同步代码的时候,在此期间如果其他线程没有获取偏向锁,只需要简单的对比一下自己的线程ID与Mark Word中的线程ID是否一致,如果一致就可以直接进入同步代码区域,这样性能损耗就小多了。

偏向锁是基于这样的一个事实,HotSpot的研发团队曾经做个一个研究并表明,通常情况下锁并不会发生竞争,并且总是由同一个线程多次的获取锁,在这种情况下引入偏向锁可以说好处大大的了!

相反如果这种情况不是很常见的话,也就是说锁的竞争很严重,或者通常情况下锁是由多个线程轮流获取,这样子偏向锁就没什么用处了。

轻量级锁

从这里我们可以看出,当锁开始时是偏向锁的时候是以一种怎样的形态存在,前面我们也说了偏向锁是在不存在多个线程竞争锁的情况下存在的,然而高并发环境下竞争锁是不可避免的,此时Synchronized便开启了他的晋升之路。

当存在多个线程竞争锁的时候,这时候简单的偏向锁就不是那么安全了,锁不住了,这时就要换锁,升级成一种更为安全的锁。此时的锁升级过程大概可以分为两步:(1)偏向锁的撤销(2)轻量级锁的升级。

首先偏向锁如何撤销呢,我们说偏向锁的锁其实就是Mark Work中的线程ID,这个时候只要更改Mark Word自然就相当于撤销了偏向锁,那么问题是偏向锁用线程ID表示,轻量级锁该用什么表示呢?答案是Lock Record(栈桢中的锁记录)。

这里我来解释一下:

我们知道JVM内存结构可以分为(1)堆(2)虚拟机栈(3)本地方法栈(4)程序计数器(5)方法区(6)直接内存。这其中程序计数器和虚拟机栈是线程私有的啊,每个线程都拥有自己独立的栈空间,看起来存放在栈中可以很好的区分开是哪个线程获取到了锁,事实上,JVM也确实是这么做的。

首先,JVM会在当前的栈中开辟一块内存,这块内存被称为Lock Record(锁记录),并把Mark Word中的内容复制到Lock Record中(也就是说Lock Record中存放的是之前的Mark Work中的内容,那为什么要存之前的内容呢?很简单,因为我们马上就要修改Mark Word的内容了,修改之前当然要保存一下,以便日后恢复啊),复制完了之后接下来就要开始修改Mark Word了,如何修改呢?当然是用CAS的方式替换Mark Word了!此时Mark Word将变成以下内容:

轻量级锁.jpg

可以看到Mark Word中使用30位来记录我们刚刚在栈桢中创建的Lock Record,锁标志位为00表示轻量级锁,这样就很容易知道是哪个线程获取到了轻量级锁啦。

轻量级锁是基于这样的一个事实,当存在两个或以上的线程竞争锁的时候,绝大多数情况下,持有锁的线程是会很快释放锁的,也就是当锁存在少量竞争时,通常情况下锁被持有的时间很短,此时等待获取锁的线程可以不必进行用户态与内核态的切换从而阻塞自己,而只要空循环(这个叫自旋)一会儿,期望在自旋的这段时候持有锁的线程可以马上释放掉锁。

很明显轻量级锁适用于锁的竞争并不激烈并且锁被持有的时间很短的情况,相反如果锁竞争激烈或者线程获取到锁之后长时间不释放锁,那么线程会白白的自旋(死循环)而浪费掉cpu资源。

重量级互斥锁

当想要进入宝屋的人太多时,轻量级也不行了,这个时候只能使用杀手锏了——重量级互斥锁。这也是Synchronized在Jdk1.6之前的默认实现。

当锁处于轻量级锁的时候,线程需要自旋等待持有锁的线程释放锁,然后去申请锁,但是存在两个问题:

  1. 自旋的线程很多,也就是有很多线程都在等待当前持有锁的线程释放锁,由于锁只能同一时刻被一个线程获取(就Synchronized而言),这样就导致大量的线程获取锁失败,总不能一直的自旋下去吧?
  2. 持有锁的线程长时间不释放锁,导致在外面等待获取锁的线程长时间自旋仍然获取不到锁,总不能一直自旋下去吧?

上述两种情况下分别来看,等待获取锁的线程就很难受了,如果两种情况同时满足(锁竞争激烈同时持有锁的线程长时间不释放锁),那就更难受了。于是JVM设定了一个自旋次数的限制,如果线程自旋了一定的次数之后仍然没有获取到锁,那么可以视为锁竞争比较激烈的情况了,这个时候线程请求撤销轻量级锁,晋升为重量级的互斥锁。

在轻量级锁的时候,锁是以Lock Record的形式存在的,那么到了重量级锁的时候,该以什么形式存在呢?

重量级锁的复杂度是最高的,由于持有锁的线程在释放锁时候需要唤醒阻塞等待的线程,线程获取不到锁的时候需要进入某一个阻塞区域统一阻塞等待,同时我们知道还有wait,notify条件的等待与唤醒需要处理,所以重量级锁的实现需要一个额外的大杀器——Monitor。

在《Java并发编程的艺术》一书中有着这样的描述:

JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有 详细说明。但是,方法的同步同样可以使用这两个指令来实现。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

我们以HotSpot虚拟机为例,其是用C++实现的,C++也是一门面向对象的语言,因此,虚拟机设计团队这一次选择以对象的形态表示锁,同时C++也支持多态,这里的Monitor其实是一种抽象,虚拟机中对于Monitor的实现使用ObjectMonitor实现,关于Monitor与ObjectMonitor的关系可以类比Java中Map与HashMap的关系。

我们看一下ObjectMonitor的真容:

  ObjectMonitor() 
  {
    _header       = NULL;
    _count        = 0;//用来记录该线程获取锁的次数
    _waiters      = 0,
    _recursions   = 0;//锁的重入次数
    _object       = NULL;
    _owner        = NULL;//指向持有ObjectMonitor的线程
    _WaitSet      = NULL;//存放处于Wait状态的线程的集合
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;//所以等待获取锁而被阻塞的线程的集合
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

这里强烈建议大家去看一下基于AQS(抽象队列同步器)实现的ReentrantLock的实现源码,因为ReentrantLock内部的同步器实现思路基本上就是Synchronized实现中的Monitor的缩影。

首先ObjectMonitor中需要有一个指针指向当前获取锁的线程,就是上面的owner,当某一个线程获取锁的时候,将调用ObjectMonitor.enter()方法进入同步代码块,获取到锁之后,就将owner设置为指向当前线程,当其他的线程尝试获取锁的时候,就找到ObjectMonitor中的owner看看是否是自己,如果是的话,recursions和count自增1,代表该线程再次的获取到了锁(Synchronized是可重入锁,持有锁的线程可以再次的获取锁),否则的话就应该阻塞起来,那么这些阻塞的线程放在哪里呢?统一的放在EntryList中即可。当持有锁的线程调用wait方法时(我们知道wait方法会使得线程放弃cpu,并释放自己持有的锁,然后阻塞挂起自己,直到其他的线程调用了notify或者notifyAll方法为止),那么线程应该释放掉锁,把owner置为空,并唤醒EntryList中阻塞等待获取锁的线程,然后将自己挂起并进入waitSet集合中等待,当其他持有锁的线程调用了notify或者或者notifyAll方法时,会将WaitSet中的某一个线程(notify)或者全部线程(notifyAll)从WaitSet中移动到EntryList中等待竞争锁,当线程要释放锁的时候,就会调用ObjectMonitor.exit()方法退出同步代码块。结合《Java并发编程的艺术》中的描述,一切都很清晰了。

锁升级为重量级锁同样需要两个步骤:(1)轻量级锁的撤销(2)重量级锁升级。

要撤销轻量级锁,当然要把保存在栈桢中的Lock Record中存储的内容再写回Mark Work中,然后将栈桢中的Lock Record清理掉。此后需要创建一个ObjectMonitor对象,并且将Mark Word中的内容保存到ObjectMonitor中(便于撤销锁的时候恢复Mark Word,这里是保存在了ObjectMonitor中)。那么如何寻找到这个ObjectMonitor对象呢?哈哈没错就是在Mark Word中记录指向ObjectMonitor对象的指针即可。如何修改替换Mark Word中的内容呢?当然会CAS啦!

锁在重量级互斥锁的形态下Mark Word中的内容如下:

重量级锁.jpg

可以看到Mark Word中使用30位来保存指向ObjectMonitor的指针,锁标记位为10,表示重量级锁。

重量级锁基于这样的一个事实,当锁存在严重的竞争,或者锁持有的时间通常很长的时候,等待获取锁的线程应该阻塞挂起自身,等待获得锁的线程释放锁的时候的唤醒,这样避免白白的浪费cpu资源。

锁形态的变迁

现在我们可以回答文章开头“ Java中的锁长什么样子?”这个问题了,在不同的锁状态下,锁表现出了不同的形态。

当锁以偏向锁存在的时候,锁就是Mark Word中的Thread ID,此时线程本身就是打开锁的钥匙,Mark Word中存了哪个线程的"身份证",哪个线程就获得了锁。

当锁以轻量级锁存在的时候,锁就是Mark Word中所指向栈桢中锁记录的Lock Record,此时的钥匙就是地盘,是虚拟机栈,谁的栈中有Lock Record,谁就获得了锁。

当锁以重量级锁存在的时候,锁就是C++中对于Monitor的实现ObjectMonitor,此时的钥匙就是ObjectMonitor中的owner。owner指向谁,谁就获得了锁。

之前的问题中,我们说32位的虚拟机Mark Word只有四个字节,难道锁就完全存在于这四个字节之内就可以实现嘛?这句话在Jdk1.6之前是完全不对的,在Jdk1.6之后在一部分情况下是对的。现在你是否对这句话有了更深刻的理解呢?

而现实世界中上锁开锁的是我们人类,通过前面的了解,程序世界中上锁开锁的又是谁呢?是的就是线程了。

现在再回头看文章开头的那些问题,就很容易给出答案了,原来一切真的就是从Synchronized使用的那个锁对象开始的!

关于CAS

尽管经历了一系列优化的Synchronized已经比原来性能好了很多,但是业务越来越追求低延迟高响应性,以乐观并发控制为代表的CAS并发控制方式越来越受到青睐。可以看到CAS在非阻塞式的原子替换上确实具有很好地应用效果,有趣的是,通过前面的了解,Synchronized的升级过程中大量的使用到了CAS进行Mark Word的非阻塞修改与替换,这在很多方面都值得我们学习。


感谢您耐心的看到了这里,希望这篇文章能为您在锁的学习上带来一些帮助!

觉得不错请点个赞吧,您的支持与鼓励是我创作的原动力!