彻夜怒肝!Java中的锁到底是怎么样的一种存在?吃透真的是太香了

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

程序世界中的锁

如果有人问你:”如何确保房屋不被陌生人进入”?我想你可能很容易想到:“上锁就可以了嘛!”。而如果有人问你:”如何处理多个线程的并发问题”?我想你可能脱口而出:”加锁就可以了嘛!”。类似的场景在现实世界中很容易理解,但是在程序世界中,这几个字却充满了疑惑。

我们见过现实世界中各种各样的锁,那Java中的锁长什么样子?我们现实世界中通常需要钥匙打开锁进入房屋,那打开程序世界中的锁的那把钥匙是什么?现实中锁通常位于门上或者橱柜上或者其他位置,那程序世界中的锁存在于哪里呢?现实世界中上锁开锁的通常是我们人,那程序世界中加锁解锁的又是谁呢?

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

先来看下面这段代码:

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

  • Synchronized修饰静态方法的时候(对应testMethod1),锁的是当前类的class对象,对应到这里就是LockTest.class对象
  • Synchronized修饰实例方法的时候(对应testMethod2),锁的是当前类实例的对象,对应到这里就是LocKTest中的this引用对象
  • Synchronized修饰同步代码块的时候(对应testMethod3),锁的是同步代码块括 里的对象实例,对应到这里就是obj对象
  • 从这里我们可以看到,Synchronized的使用都要依赖特定的对象,从这里可以发现锁与对象存在某种关联。那么我们下一步看看对象中到底有什么关于锁的蛛丝马迹。

    对象的组成

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

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

    锁升级的过程

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

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

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

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

    偏向锁

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

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

    轻量级锁

    从这里我们可以看出,当锁开始时是偏向锁的时候是以一种怎样的形态存在,前面我们也说了偏向锁是在不存在多个线程竞争锁的情况下存在的,然而高并发环境下竞争锁是不可避免的,此时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将变成以下内容:

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

    重量级互斥锁

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

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

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

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

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

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

    我们看一下ObjectMonitor的真容:

    锁形态的变迁

    现在我们可以回答文章开头“ 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的非阻塞修改与替换,这在很多方面都值得我们学习。

    想要获取完整版资料的小伙伴,关注+转发后私信小编【666】即可获取哦

    声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!

    上一篇 2021年3月15日
    下一篇 2021年3月15日

    相关推荐