导航菜单
路很长,又很短
博主信息
昵   称:Cocodroid ->关于我
Q     Q:2531075716
博文数:359
阅读量:1984104
访问量:240317
至今:
×
云标签 标签球>>
云标签 - Su的技术博客
Tags : java,同步,Synchronized,锁,锁升级发表时间: 2022-06-05 16:34:23


前言

相信大家对Synchronized这个关键字并不陌生,在解决多线程并发操作下数据安全问题时,都会想到这个关键字,用来对共享资源进行加锁。但在JDK1.6版本之前,Synchronized是一个重量级锁,阻塞或唤醒java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比执行用户代码的时间还要长。
JDK1.6对Synchronized加锁进行了优化,引入了 “偏向锁” 和 “轻量级锁”。于是目前Synchronized加锁共有四种状态,级别由低到高分别是:无锁、偏向锁、轻量级锁和重量级锁
由低到高的状态变化则称为锁的升级,并且锁状态只能升级不能降级(偏向锁状态在线程竞争时会降级到无锁的一个过渡状态,本文暂不考虑该情况)。

锁的升级流程大致如下图:
在这里插入图片描述
在讲解锁升级流程前,我们先了解下Java对象头,Synchronized用的锁就是放在Java对象头里。
我们知道对象是放在堆内存中,对象大致可以分为三个部分,分别是对象头、实例变量和填充字节。这边主要讲解和锁相关的对象头。以Hotspot为例,Hopspot对象头主要包括两部分数据:Mark Word(标记字段)Klass Point(类型指针)

Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Synchronized用的锁就是存在锁对象的对象头Mark Word中。Mark Word在对象头中存储的数据如下图所示:

在64位的虚拟机中:
在这里插入图片描述

在32位的虚拟机中:
在这里插入图片描述

我们以64位虚拟机为例,来看一下锁在Mark Word中的存储。

  • 无锁:对象开辟了1bit空间用来存放是否偏向锁的标志位,无锁状态下该标志位为0,2bit用来存放锁标志位01。
  • 偏向锁:可以看到,偏向锁中多了一些锁相关的信息,比如使用25bit的空间存储偏向锁的线程ID,用于指向持有偏向锁的线程,还多了一个2bit的Epoch用于记录偏向时间戳。是否偏向锁的标志位是1,表明该对象已被偏向锁锁住。
  • 轻量级锁:在轻量级锁中直接开辟62bit的空间存放指向栈中锁记录的指针,2bit存放锁的标志位,其标志位为00。
  • 重量级锁:重量级锁中和轻量级锁一样,62bit的空间存放指向重量级锁的指针,2bit存放锁的标志位,其标志位为11。

锁的升级

1、无锁

无锁: 指并没有对资源进行加锁,既没有加Synchronized等关键字进行加锁,所有线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点是修改操作会一个循环体内进行,线程会不断尝试去修改共享资源,若没有冲突,直接修改成功就退出循环,否则就继续重试,直到修改成功。如果有多个线程修改同一个值,必定会有一个线程能修改成功,其他修改失败的线程会继续重试直到成功。

2、偏向锁

偏向锁: 指当一段同步代码一直被同一线程访问,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低锁带来的消耗,提高性能。

使用Synchronized对代码块进行加锁,初次执行到加锁代码块时,当前线程判断到当前锁对象处于无锁状态,则通过CAS将对象头中是否偏向锁的标志位改为1,并记录当前偏向锁的线程ID,以及偏向时间戳。锁对象变成偏向锁。 执行完同步代码块后,线程并不会主动释放偏向锁。当该线程再次执行到加锁代码块时,线程会判断对象头Mark Word中偏向锁的线程ID是否就是自己,如果是则正常往下执行。由于之前没有释放锁,所以这里无需重新加锁,大大提高了效率。如果自始至终都只有同一个线程使用锁,偏向锁几乎没有额外开销,性能极高。
如果有另一个线程(我们这里定为线程2,占有偏向锁的线程定为线程1)执行到加了偏向锁的代码块时,比较当前的线程ID与对象头中的线程ID不一致,那么需要查看对象头中线程ID指向的线程ID(线程1)是否存活,如果没有存活,就将锁对象重置为无锁状态,后面的线程(线程2)就可以竞争将锁对象设置为偏向锁,并将线程ID执行自己的线程ID;如果原有对象头中线程ID指向的线程ID(线程1)存活,那么立刻查找线程1的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁, 如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,线程2竞争获得偏向锁。

3、轻量级锁

轻量级锁: 指当锁是偏向锁,且被占用时,却被另一个线程访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。轻量级锁用于线程不用等待很久就能成功获取锁的情况。

轻量级锁的获取主要有两种情况:

  • 偏向锁功能被关闭时
  • 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁

锁竞争: 如果多个线程轮流获取一个锁,每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。
在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即循环判断锁是否能够被成功获取。获取轻量级锁的过程,我们分两种场景讨论:

  • 由偏向锁直接升级为轻量级锁:获取轻量级锁的线程会先把锁对象的对象头Mark Word复制一份到该线程的栈帧中创建的用于存储锁记录的空间(Lock Record)。官方称之为 Displaced Mark Word(所以这里我们认为Lock Record和 Displaced Mark Word其实是同一个概念)。复制成功后,虚拟机将使用CAS操作尝试将对象头的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象头的Mark Word。
  • 已经是轻量级锁,需要竞争:若某个线程需要竞争轻量级锁,先判断当前持有轻量级锁的线程是否已经释放,如果没有释放,则自旋等待。若已释放,则将锁对象的对象头Mark Word复制到该线程的Lock Record,然后通过CAS操作将对象头的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象头的Mark Word。

当出现以下情况时,轻量级锁将会升级为重量级锁:

  • 线程自旋一定次数(默认10次
  • 一个线程在持有锁,一个在自旋,又有第三个线程尝试获取锁时

自旋锁 vs 适应性自旋锁
轻量级锁中的自旋用到的就是自旋锁,即让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。但自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
那么什么是适应性自旋锁呢?
适应性意味着自旋次数不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

4、重量级锁

重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。

升级为重量级锁后,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针。

锁的其他优化

锁消除

锁消除: 指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

但是作为一个合格开发者来说,我们是不会对不需要同步的代码进行同步的。所以我们写的代码一般来说都不会用到锁消除。但是结合Java源码就不一定了,同步的代码在Java源码中的普遍程度也许超过了大部分读者的想象。比如常用到字符串线程安全类StringBuffer,有可能我们用到它,但是不会出现线程并发争夺的情况,这时这里的锁就会被安全消除掉。

锁粗化

我们在写同步块代码时,一般都会将锁的作用范围限制越小越好,这样加锁的时间就不会太长,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。大部分情况,这样确实可以提高程序的执行效率。但是如果一系列的连续操作都对同一个对象反复加锁和解锁,比如在循环体中进行加锁操作,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

public class SynchronizedTest {

    public static void main(String[] args) {
        StringBuffer stringBuffer = new StringBuffer();
        for (int i = 0; i < 100; i++) {
            // stringBuffer.append方法使用synchronized加锁
            stringBuffer.append("abcd");
        }
    }

}

JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可。 上面的例子,就会将加锁操作放到for循环外,这样只进行一次加锁操作就可以了。

总结

本文总结了锁的升级过程,以及其他一些优化。然而锁升级过程还有很多细节,文中没有深入分析,比如轻量级锁的解锁。这块我也不是太了解,后续有机会再补上吧。有什么问题和建议欢迎随时交流~

参考链接
https://segmentfault.com/a/1190000022904663
https://blog.csdn.net/tongdanping/article/details/79647337
https://tech.meituan.com/2018/11/15/java-lock.html
https://www.cnblogs.com/zhai1997/p/13546652.html


来源: https://blog.csdn.net/Java_Mike/article/details/118050991

...阅读原文
推荐文章