详尽探究synchronized原理后,原来性能这么好

开发 前端
在使用synchronized的时候一定要注意hashcode生成对锁的影响,因为对象头的mark word是保存对象运行期数据的,这块区域在32位机器上是32字节,在64位机器上是64字节,所以空间是有限的,是无法同时保存hashcode和轻量级锁记录指针或者Monitor对象指针的,因为会做一些取舍。

前置思考

我们先来试着想一下实现一把锁应该考虑哪些问题

  1. 如何获取锁资源?
  2. 获取不到锁资源的线程如何处理?
  3. 如何释放锁资源?
  4. 资源释放后如何让其他线程再次获取锁资源?

带着上面几个问题更深层次的思考如何解决上面几个问题

  • 锁的标识
    需要有个标识或者状态来表示锁是否已经被占用。
  • 线程抢锁的逻辑
    多个线程如何抢锁,如何才算抢到了锁。
  • 线程挂起的逻辑
    线程如果没有抢到锁,我们都知道线程会阻塞挂起,那么如何阻塞挂起呢。
  • 线程存储机制
    线程在Java中的表现其实就是一个Java对象绑定内核中的一条线程,也可以理解为这个对象是底层线程的载体,没有抢到锁的线程会阻塞挂起,那么线程的载体如何处置呢。一般我们想到的是将这些载体保存到集合或者队列中。
  • 线程释放锁的逻辑
    线程在执行完业务逻辑后就要释放锁,如何才算释放了锁呢?
  • 线程唤醒的逻辑
    锁释放后需要做什么呢,当然是唤醒被阻塞的线程。

不管是哪一种锁我认为实现上必须都要考虑以上问题,而锁的性能好坏就在于对以上问题解决的思路上是否为最优的处理

synchronized的使用

在介绍原理前我们先介绍synchronized的使用

  1. synchronized可以修饰实例方法。
  2. synchronized可以修饰静态方法。
  3. synchronized可以修饰实例方法的代码块。
  4. synchronized可以修饰静态方法的代码块。
public void get(){
  synchronized(this){

  }
}
public synchronized void get(){
  
}

可见其使用是很简单的,只需要一个关键字,不需要程序员关心什么时候上锁,什么时候释放锁。

对象的生成

创建对象

想要了解synchronized的底层原理就必须先了解Java中的对象是怎么生成的,这对于掌握synchronized原理至关重要。

java中如何创建一个对象?java代码会被编译成字节码然后被jvm运行,jvm在遇到new关键字的时候就会启动对象的创建流程,对象的大致流程如下:

图片图片

默认情况下jvm加载类是懒加载的,所以创建对象的第一步是判断类是否已经加载,如果没有加载,需要先走类的加载流程。

接下来是分配内存,一个对象在类加载的时候就可以知道所需要的内存大小,此时就是在堆中划分一块区域出来作为对象的私密空间,具体如何分配和具体使用的垃圾回收器有关,jvm篇再细讲,在偌大的堆中怎么为一个对象划分区间呢?这里的分配主要是两种方法:指针碰撞和空闲列表,但是不管哪种划分方法都会存在并发问题,此时jvm的解决方案是TLAB和cas配合失败重试,这些内容也将会在jvm篇进行了解,这里只需要知道给对象分配了内存即可。

初始化零值这一步是给对象中的属性赋零值,比如int类型默认为0,这一步是避免属性不赋值的情况下出现空指针异常。

每个对象都会有一个对象头区域,这个区域包括Mark Word,元数据指针,数组长度三个部分,Mark Word用于保存对象的运行时数据,比如hashcode,分代年龄,锁标识等,元数据指针是当前对象所属类对象的地址,只有数组对象才会有数组长度。

最后初始化对象,这个时候一个完整的对象生成了。

一个完整对象的结构如下:

图片图片

可以看到结构中有一个对其填充,对其填充是为了满足对象的大小为8字节的整数倍,只有8字节的整数倍才是最高效的存取方式。所以一个对象的大小总是8字节的整数倍。

对象头

对象头是对象中用于保存实例数据外的运行时数据的区域。

我们知道java是面向对象的,在java的世界一切皆对象,所以整个jvm的设计都是围绕对象,包括对象所属类的加载,对象的创建,对象的保存,对象的销毁,对象的回收,锁的实现,以及jvm的内存结构等等都要围绕对象设计,这就导致对象自身会有很多的运行时数据,比如垃圾回收依赖的分代年龄,代码运行过程中用于标识对象唯一的hashcode,当用作锁对象的时候锁的相关信息存储,记录当前对象所属的类对象指针等等。

所以jvm设计了对象头,对象头包括Mark Word,MetaDate,数组长度三部分。

Monitor

Java中的对象与生俱来会携带一个Monitor对象,这个Monitor对象可以说是对象的影子,它平时没什么用,当对象作为锁资源被线程抢占的时候,它就排上用场了,可以说Monitor对象就是为实现锁而发明的,Monitor就类似一个监视器,所以说Java的老派锁是监视器锁。

这个Monitor对象是jvm级别实现的,是一个jvm级别的对象,所以我们在java端开发的时候是看不到摸不到的,但却是真实存在的。

Monitor对象结构如下:

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 ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

实现原理

synchronized锁实现就是依赖这个Monitor对象实现的,我们看到上面Monitor对象的结构中的几个属性:_count,_owner,_WaitSet,_EntryList,大概也能猜的出来的这些参数都跟线程抢锁有关系。

好了,我们直接来看这个锁的实现原理。

synchronized锁的写法很简单,简单到只有一个关键字,我们也知道Java代码是要编译成字节码文件然后被jvm虚拟机解析运行的,那就不难猜出这把锁的逻辑实现是在编译的字节码中。

synchronized关键字被编译后的大致表现如下:

图片图片

整个代码块前后被monitorenter 和 monitorexit包裹,也就是说synchronized关键字的在编译后就变为上面两个关键字。到这里其实就和ReentrantLock的lock和unlock是一个道理了。

monitorenter 和 monitorexit这两个是jvm级别字节码指令,不难想到jvm在运行代码的时候,遇到monitorenter关键字,一定会启动抢锁的逻辑,包括抢锁,入队,阻塞;而遇到monitorexit的时候一定会走释放锁逻辑,包括释放锁,唤醒阻塞线程。

而所谓的入队应该就可以联想到Monitor对象中的那几个属性了。阻塞的实现则是依赖于操作系统底层的互斥原语mutex。

上面做了一些相关知识的介绍,可能还比较碎片化,接下来我们就通过加锁流程将所有信息都串联起来。

Java1.5的锁

java最开始的锁的实现依赖锁对象,对象的Monitor对象,操作系统互斥原语mutex。

  • 线程运行到 monitorenter 关键字后,jvm判断此线程进入了互斥区域,jvm底层会调用操作系统底层的互斥原语Mutex实现线程线程互斥。
  • 如果在此之前没有其他线程占据互斥区域,则当前线程会占据互斥区域,意味着当前线程抢到了锁资源,会将自身的线程id存储到锁对象对应的Monitor对象的_owner属性,count属性加1。
  • 如果此时互斥区域已经有一个线程占有,当前线程会被阻塞,当前线程id则会被存储到锁对象对应的Monitor对象的_EntryList属性,并且将线程阻塞挂起。
  • 占有资源的线程继续运行,当遇到monitorexit关键字的时候就要释放锁,此时jvm会唤醒随机唤醒_EntryList里面的一个线程,被唤醒的线程会再次抢夺资源,没有抢到资源的线程将会再次被阻塞,所以说synchronized是一个非公平的锁。

那么锁对象和Monitor对象是怎么关联的呢?

是通过将Monitor对象的引用存储到对象头的Mark Word中实现的关联。

Java1.6之前的synchronized大概就是这样一个实现原理,之所以业界普遍认为其性能很低是因为其有一个很大的弊端,就是每个线程在抢锁的时候都要调用操作系统的底层api,这就导致用户态到内核态的切换,我们都知道Java程序性能最忌讳的就是用户态与内核态的来回切换。然而,我们程序并不每时每刻都会有很多线程竞争锁资源,相反,大多数时间里,只有一个线程在执行加锁的逻辑,那么这种情况下每次都发生用户态和内核态切换无疑是没有必要的性能消耗。所以业界对其性能持望而却步的态度。

1.5后的锁

JVM内置锁在1.5之后版本做了如下重大的优化,在做了优化后,其性能显著提高,基本与ReentrantLock保持同等性能。

  • 锁粗化
  • 锁消除
  • 锁升级:轻量级锁 偏向锁 适应性自旋

接下来由浅入深讲解优化内容

1. 锁消除

锁的消除,顾名思义就是将锁去除,因为有些场景下锁是可以去除的

public void sync()  {
        Sync sync=new Sync();
        synchronized(sync){

        }
}

如上这种情况,我们知道进出一个方法就是当前线程栈的入栈出栈,所以方法内部只要不涉及共享资源操作就是线程安全的,如上这段代码,sync对象声明在方法内部,其引用是局部变量,是线程独享资源不是共享资源,是线程独有资源,随着出栈发生,对象也就销毁了,因此此处是可以不用加锁的,锁消除优化就是对这种情况进行去除锁的处理。

jvm如何进行优化的呢?jvm在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,通过逃逸分析判断方法中的是否存在共享资源,如果无共享资源则去除不存在共享资源竞争的锁,从而节省请求锁时间。

典型的案例是StringBuffer的使用,后续会讲解。

通过上面我们知道锁消除依赖逃逸分析,逃逸分析是可以通过jvm参数配置的,如下:

-XX:+DoEscapeAnalysis 开启逃逸分析

-XX:+EliminateLocks 开启锁消除

逃逸分析可以简单理解为分析资源是否能逃逸到其他方法或者其他线程中。

2. 锁粗化

顾名思义,把小范围的多个锁变成大范围少个锁。

public Sync sync=new Sync();

public void sync()  {
        
        synchronized(sync){

        }
        synchronized(sync){

        }
        synchronized(sync){

        }
}

上面的代码可以看出,一个逻辑被多次加同一把锁,每一次上锁都是会耗时的,所以完全可以把多个锁合并为一个锁,这样只需要上一次锁就可以了,大大节省了时间。

同样jvm在即时编译的时候会扫描判断是否存在可以粗化的锁行为。

3. 锁膨胀

锁膨胀又叫锁升级。synchronized之所以能在性能上与ReentrantLock持平就得益于锁膨胀的优化。

锁升级是锁优化后的锁机制,这个机制中包含这样几个概念:偏向锁,轻量级锁,适应性自旋,重量级锁。在整个锁膨胀的过程中对对象头的依赖更加明显。

锁升级是依靠对象头的Mark Word来保存标志信息的,接下来以32位操作系统来看下锁升级过程中的对象头中运行时数据的变化。

图片图片

  • 无锁状态 当没有任何线程进入的时候,此时处于无锁状态,Mark Word中会有25bit的空间大小留给hashcode,4bit的空间大小留给对象的分代年龄信息,1bit的空间大小是标识是否偏向(0否,1是),2bit的空间大小是锁标识位。

此时锁标志位为01,是否偏向为0,代表无锁状态。但是此时并不一定有hashcode,因为hashcode是代码运行过程中调用生成方法才生成的,如果运行过程不调用就不会生成。

请注意,hashcode的生成是会影响锁的升级过程的。

  • 偏向锁状态

当第一个线程T1进入代码块后的步骤(前提条件是全程无hashcode生成)

  • 判断是否处于偏向中(通过Mark Word中的是否偏向判断)
  • 此时未处于偏向中,当前线程会将自己的线程id保存到Mark Word中,设置是否偏向为1,此时锁标志位依然是01

此时Mark Word为:23bit的线程id,4bit的分代年龄,是否偏向为1,锁标识位依然为01。

此时是偏向锁状态,它其实是一种特殊的无锁状态。

上面的过程是建立在全程无hashcode生成的基础上,我们知道了hashcode会占用25bit,线程id会占用23bit,如果过程有hashcode生成怎么办,这里涉及到两个问题。

第一个问题,T1进入前就已经生成了hashcode怎么处理?

jvm的做法是如果偏向前已经生成hashcode,那么就放弃偏向,直接进入轻量级锁。

第二个问题,T1进入后锁状态变为了偏向锁,此时生成hashcode怎么处理?

jvm的做法是撤销偏向,直接进入重量级锁。

所以我们在使用锁的时候要特别注意hashcode生成给锁升级带来的影响。

  • 轻量级锁状态

当第二个线程T2进入代码块后

  • 判断是否处于偏向中(通过Mark Word中的是否偏向判断)
  • 如果处于偏向中,T2会以cas的方式试图将Mark Word中的线程id替换为自己的线程id
  • 如果T1已经执行完代码块,T2一定是可以替换成功的,此时锁依然是偏向锁状态
  • 如果T1没有执行完代码块,T2一定是替换不成功的,此时将进入偏向锁撤销升级为轻量级锁的过程
  • 首先T1会进入到安全点,T1和T2会在自己的栈空间开辟一块区域用于保存锁记录,同时复制一份Mark Word到这个锁记录中,同时cas的方式将自己栈空间这个锁记录的指针设置到Mark Word中去,因为T1持有偏向锁,所以T1会优先设置成功,此时Mark Word中有30bit的锁记录指针和2bit的锁标志位,此时的锁标志位为00代表轻量级锁,锁记录指针指向当前持有轻量级锁的线程中栈空间的地址。T2没有替换成功,将会进入不断轮询失败重试过程。

轻量级锁是在资源竞争压力不是很大的情况下,避免每个线程都去获取锁而造成用户态到内核态的切换,这个切换是比较耗时的,这样就能提高性能,但是如果竞争压力大的情况下轻量级锁就不行了,因为压力大意味着有很多线程在轮序失败重试获取轻量级锁,短时间内会造成cpu压力飙升,甚至拖垮cpu,这个时候就必须升级为重量级锁。

那么如何才算竞争压力大,什么时候会升级为重量级锁呢?

jvm默认轮询次数限制值为十次,超过十次获取不到资源就代表竞争压力比较大了,用户也可以使用如下参数配置来自行更改这个次数

-XX:PreBlockSpin

但是有个问题,如果通过这个默认值或者这个jvm参数配置限制数量,那意味着jvm全系统的锁都要遵循,这个数字可能不适用于所有的锁,因此jvm引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了。

  • 重量级锁

重量级锁就是Monitor锁,也叫监视器锁,其实现是依靠操作底层的互斥原语Mutes Lock,因为每一次获取Monitor锁都需要用户态到内核态的切换,所以比较耗时,也是重量级锁的由来。 当自旋的条件破坏后,比如自旋次数达到限制或者竞争的压力越来越大,将不再自旋,轻量级锁升级为重量级锁,当前对象头中的Mark Word被复制一份到Monitor对象中,Mark Word中原来的轻量级锁的锁记录指针被换成Monitor对象的指针,然后所有的线程会抢夺Monitor锁的拥有权,以cas方式将自己的线程id填充到Monitor对象的_owner字段,同时_count字段加1,当然此时能够cas成功的只会是原来持有轻量级锁的线程,而那些没有获取到Monitor锁的线程将会被阻塞并放入Monitor对象的_EntryList字段等待唤醒。

此时锁的标志位为10,表示重量级锁。

当线程退出Monitor锁,便会将Monitor锁中的_count减1,清空_owner,jvm会随机唤醒_EntryList集合中一个线程重新获取Monitor锁,这个随机便突出了synchronized的不公平性。

总结

在使用synchronized的时候一定要注意hashcode生成对锁的影响,因为对象头的mark word是保存对象运行期数据的,这块区域在32位机器上是32字节,在64位机器上是64字节,所以空间是有限的,是无法同时保存hashcode和轻量级锁记录指针或者Monitor对象指针的,因为会做一些取舍。

synchronized锁的随机性决定了其非公平的特性。

synchronized锁为什么是重量级锁,为什么性能差,就是因为在优化前,每个线程的进入都会造成用户态到内核态的切换,而我们要的理想状态是只有一个线程或者只有少量线程竞争的时候不进行用户态到内核态的切换。从而提高性能。优化后做到了。

责任编辑:武晓燕 来源: 码农本农
相关推荐

2024-08-28 08:00:00

2019-03-15 10:55:12

通信系统手机

2023-10-05 11:12:06

JUCUnsafe安全

2021-04-19 05:42:51

Mmap文件系统

2023-11-01 14:49:07

2023-05-07 23:22:24

golang

2017-05-18 15:02:36

AndroidGC原理JVM内存回收

2022-12-06 17:30:04

2023-05-08 14:56:00

Kafka高可靠高性能

2020-09-24 06:44:54

HTTPS网站 HTTP

2020-11-27 10:34:01

HTTPHTTPS模型

2022-10-21 08:17:13

MongoDB查询Document

2023-09-22 08:00:00

分布式锁Redis

2017-12-06 16:28:48

Synchronize实现原理

2014-10-08 15:00:50

SUSE操作系统云计算

2021-08-29 18:13:03

缓存失效数据

2014-11-25 15:02:01

客服系统

2016-03-21 11:09:52

Tableau/大数据

2010-08-02 13:55:20

2018-10-28 17:54:00

分布式事务数据
点赞
收藏

51CTO技术栈公众号