zl程序教程

您现在的位置是:首页 >  其他

当前栏目

第四篇:synchronized,并发安全的守护神

并发安全 synchronized 第四篇
2023-09-14 09:15:23 时间

文章目录

一、前言

synchronized关键字在需要原子性、可见性和有序性这三种特性的时候都可以作为其中一种解决方案,看起来是“万能”的。的确,大部分并发控制操作都能使用synchronized来完成。在多线程并发编程中Synchronized一直是元老级角色,很多人都会称呼它为重量级锁,但是随着Java SE1.6对Synchronized进行了各种优化之后,有些情况下它并不那么重了,本文详细介绍了Java SE1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。

面试官问题:为什么说jdk6之后synchronized不再那么重量了?
JDK1.6之前的内建锁,即synchronized,jdk1.6之后,内建锁=无锁+偏向锁+轻量级锁+重量级锁,并加入锁升级机制,所以说,jdk6以后,synchronized不再那么重量了,其实,不是synchronized不再那么重量,而是一定要不得已才使用synchronized。
小结:对于为什么说jdk6之后synchronized不再那么重量了,解释是,jdk6以前,只有无锁+重量级锁两种状态,加锁就是重量级锁,阻塞调用,jdk6以后,程序员再使用synchronized的时候,底层有无锁+偏向锁+轻量级锁+重量级锁,四种状态,一定要不得已才使用synchronized,不那么重量的了,当然对于程序员的使用synchonized来说,是透明的,所以说jdk6之后synchronized不再那么重量了。

二、synchronized的使用

2.1 synchronized五种情况

修饰目标
方法实例方法当前实例对象(即方法调用者)
静态方法类对象
代码块this当前实例对象(即方法调用者)
class对象类对象
任意Object对象 任意示例对象

注意两点:

(1) synchronized两个维度:锁对象和锁范围,两个维度两两组合有四种。

锁对象当前对象this或类中的成员变量对象 + 锁范围是整个方法:即上表中第一种情况;

锁对象当前对象this或类中的成员变量对象 + 锁范围是方法或类中代码块:即上表中第三种情况和第五种情况;

锁对象当前类 + 锁范围是整个方法(即静态方法):即上表中第二种情况;

锁对象当前类 + 锁范围是方法或类中代码块:即上表中第四种情况。

(2) 两个锁对象:对于当前对象this或类中的成员变量对象,因为锁对象是this或者this的变量,所以该类的不同实例对象不互斥。对于锁对象当前类,因为锁对象是类的字节码,所以该类的不同实例对象互斥。

五种情况主要是方法级别锁和代码级别锁,还有就是锁对象的不同,方法级别锁和代码级别锁很好理解,看代码就懂,锁对象不同是什么意思?

第一种情况和第三种情况,形成锁竞争:普通方法和代码块中使用this是同一个监视器(锁),即某个具体调用该代码的对象

第二种情况和第四种情况,形成竞争:静态方法和代码块中使用该类的class对象是同一个监视器,任何该类的对象调用该段代码时都是在争夺同一个监视器的锁定

小结:同步两因素:第一多线程,第二锁对象
1、多线程:就是因为存在不止一个线程才能形成竞争,只有一个线程你和谁竞争,所以一旦设置同步,多线程必不可少;
2、锁对象:锁对象表示的是竞争的对象,即多个线程之间竞争什么,竞争的对象相同才能形成竞争,竞争的对象不同是无法形成竞争的,举一反三,比如非同步方式(没有synchronized修饰),就啥都不竞争,和同步原子没半毛钱关系,代码中通过对竞争的资源加锁解锁形成原子操作,所以一旦涉及同步,锁对象必不可少。

2.2 synchronized五种情况的使用

2.2.1 synchronized五种情况的使用

public class Synchronized {
    //synchronized关键字可放于方法返回值前任意位置,本示例应当注意到sleep()不会释放对监视器的锁定
    //实例方法
    public synchronized void instanceMethod() {
        for (int i = 0; i < 5; i++) {
            System.out.println("instanceMethod");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    //静态方法
    public synchronized static void staticMethod() {
        for (int i = 0; i < 5; i++) {
            System.out.println("staticMethod");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public void thisMethod() {
        //this对象
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                System.out.println("thisMethod");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public void classMethod() {
        //class对象
        synchronized (Synchronized.class) {
            for (int i = 0; i < 5; i++) {
                System.out.println("classMethod");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public void anyObject() {
        //任意对象
        synchronized ("anything") {
            for (int i = 0; i < 5; i++) {
                System.out.println("anyObject");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

2.2.2 第一种情况和第三种情况

普通方法和代码块中使用this是同一个监视器(锁),即某个具体调用该代码的对象

   public static void main(String[] args) {
        Synchronized syn = new Synchronized();
        for (int i = 0; i < 10; i++) {
            new Thread() {
                @Override
                public void run() {
                    syn.thisMethod();
                }
            }.start();
            new Thread() {
                @Override
                public void run() {
                    syn.instanceMethod();
                }
            }.start();
        }
    }

我们会发现输出结果总是以5个为最小单位交替出现,证明sychronized(this)和在实例方法上使用synchronized使用的是同一监视器。如果去掉任一方法上的synchronized或者全部去掉,则会出现instanceMethod和thisMethod无规律的交替输出。

2.2.3 第二种情况和第四种情况

静态方法和代码块中使用该类的class对象是同一个监视器,任何该类的对象调用该段代码时都是在争夺同一个监视器的锁定

public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
        Synchronized syn = new Synchronized();
        new Thread() {
            @Override
            public void run() {
                syn.staticMethod();
            }
        }.start();
        new Thread() {
            @Override
            public void run() {
                syn.classMethod();
            }
        }.start();

    }
}

输出以5个为最小单位交替出现,证明两段代码是同一把锁,如果去掉任一synchronnized则会无规律交替出现。

三、synchronized源码解析

3.1 对象头 + Mark Word

3.1.1 同步锁对象的对象头(对象头=Mark Word + Class Metadata Address + Array length,三个每一个占一个字宽)

线程同步两要素=多线程+锁对象,对于锁对象,这里介绍其对象头

对象头=Mark Word + Class Metadata Address + Array length,三个每一个占一个字宽。
Markdown存放对象的hashCode或锁信息;
Class Metadata Address存放存储到对象类型数据的指针;
ArrayLength,数组类型特有,存放数组类型长度。
如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头;
如果对象是非数组类型,则用2字宽存储对象头。

在32位虚拟机中,1字宽等于4字节,即32bit,64位虚拟机中,1字宽=8字节=64bit,如表所示:

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

3.1.2 对象头的Mark Word(无锁状态 + 四种数据)

Mark Word无锁状态,如下:

锁状态25bit4bit1bit是否偏向锁2bit锁标志位
无锁状态对象的hashCode对象分代年龄001

Mark Word可能变化为存储以下4种数据,如表所示:

锁状态25bit 4bit 1bit 2bit
23bit 2bit 是否偏向锁 锁标志位
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量(重量级锁)的指针10
GC标志 11
偏向锁 线程ID Epoch 对象分代年龄 1 01

PS: 我这里说的对象头的都是为下面的加锁解锁铺垫的,下面修改对象头的markword,操作的都是同步锁对象的对象头。

3.1.3 小结:对象头 + Mark Word

对象头三个部分:
对象头=Mark Word + Class Metadata Address + Array length,三个每一个占一个字宽。
Markdown存放对象的hashCode或锁信息;(无锁状态:29bit hashcode+ 3bit lock)
Class Metadata Address存放存储到对象类型数据的指针;
ArrayLength,数组类型特有,存放数组类型长度。

小结:五种状态:
1、无锁状态:
hashcode 哈希码 29bit
biased_lock: 偏向锁标识位,1bit
lock: 锁状态标识位,2bit 01

2、偏向锁(线程id 23 + 偏向时间戳 2 + 分代年龄 4 = 29 + lock 3):
JavaThread: 保存持有偏向锁的线程ID,23bit
epoch: 保存偏向时间戳,2bit(到40,升级为轻量级锁)
age: 保存对象的分代年龄,4bit
biased_lock: 偏向锁标识位,1bit
lock: 锁状态标识位,2bit 01
其中,线程id 23 + 偏向时间戳 2 + 分代年龄 4 都是偏向锁特有,后两个都是涉及时间

3、轻量锁和重量锁(30+2):
ptr: monitor的指针(就是锁指针,同步代码块的锁由monitorenter和monitorexit完成,同步方法的锁由ACC_SYNCHRONIZED修饰,底层是一致的),30bit
lock: 锁状态标识位,2bit 00 10

4、GC标志(30+2)
空,30bit
lock: 锁状态标识位,2bit 11

记忆方法:偏向锁和无锁一起记忆,其他三个,轻量级锁和重量级锁一起记忆。

3.2 javap命令:从.java文件到.class文件,synchronized代码级别和方法级别的区别(代码级别:monitorenter+monitorexit,方法级别:ACC_SYNCHRONIZED)

金手指:JVM第二篇就用过这个javap命令

3.2.1 synchronized代码级别锁底层:monitorenter+monitorexit

我们写个demo看下,使用javap命令,查看JVM底层是怎么实现synchronized

public class TestSynMethod1 {
    synchronized void hello() {

    }

    public static void main(String[] args) {
        String anything = "anything";
        synchronized (anything) {   // 任意字符串作为锁对象
            System.out.println("hello word");
        }
    }
}

线程同步两要素:多线程和对象锁,这里只有main线程,对象锁是可以随便找一个字符串变量,因为这里只要测试一个synchronized关键字底层是如何保证原子性的,synchronized关键字底层实现,不用模拟多线程对竞争资源争夺。

同步块的jvm实现,可以看到它通过monitorenter和monitorexit实现锁的获取和释放。通过图片中的注解可以很好的解释synchronized的特性2,当代码段执行结束或出现异常后会自动释放对监视器的锁定。

在这里插入图片描述

3.2.2 synchronized方法级别锁底层:ACC_SYNCHRONIZED

如果synchronized在方法上,那就没有上面两个指令,取而代之的是有一个ACC_SYNCHRONIZED修饰,表示方法加锁了。然后可以在常量池中获取到锁对象,实际实现原理和同步块一致,后面也会验证这一点

在这里插入图片描述

辨析方法级别锁和代码级别锁

synchronized代码级别锁底层:monitorenter+monitorexit
synchronized方法级别锁底层:ACC_SYNCHRONIZED
联系:如果synchronized在方法上,底层使用ACC_SYNCHRONIZED修饰该方法,然后在常量池中获取到锁对象,实际实现原理和同步块一致

四、原理:锁升级整个流程(偏向锁获取 + 偏向锁撤销 + 轻量锁加锁 + 轻量锁解锁)

1、只能升级不能降级:目的是为了提高 获得锁和释放锁的效率
2、升级顺序:无锁状态 0 01、偏向锁状态101、轻量级锁状 态000和重量级锁状态010

4.1 锁升级

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高 获得锁和释放锁的效率。

注意,虽然级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,但是偏向锁要延迟4s启动,所以默认情况下启动,偏向锁就是关闭的,所以无锁状态直接进入道轻量锁状态。

4.2 偏向锁(偏向锁获取 + 偏向锁撤销)

4.2.1 偏向锁获取过程(两个IF判断步骤)

1、偏向锁的引入是适应Java并发的实际需求:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,所以,Java并发中为了让线程获得锁的代价更低而引入了偏向锁。

2、当一个线程访问同步块并获取到同步锁的时候,会在同步锁对象的对象头和栈帧中的锁记录Lock Record里存储锁偏向的线程ID(金手指:偏向锁对象头的mark word:线程id 23 + 偏向时间戳 2 + 分代年龄 4 = 29 + lock 3,和无锁一起记忆),以后该线程再次进入和退出的时候,同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下当前同步锁对象的对象头的Mark Word里是否存储着指向当前线程的偏向锁。

tip: 检测的对象为thread id
当一个线程获取偏向锁的时候,会将这个线程的thread id写到同步锁对象的对象头的mark word里面去,所以判断的时候,根据threadid判断。
偏向锁检测的对象为线程id,这也就是为什么偏向锁需要23位线程id,其他四种情况的mark down都不需要线程id,无锁是29位字节码,轻量级锁和重量级锁是30位的monitor锁指针,gc标志是30位的空。

3、如果测试成功,表示线程已经获得了锁;如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成0?
如果偏向标志为0,则将它设置为1,更新自己的线程ID到对象头的mark word字段中;
如果偏向标志为1,表示此时偏向锁已经被别的线程获取,则不断尝试使用CAS获取偏向锁或者将偏向锁撤销,在不断CAS自旋过程中,大概率会升级为轻量级锁。

面试官:偏向锁获取的整个过程可以用下图来小结

在这里插入图片描述

1、当一个线程第一次访问同步代码块并获取锁时(使用cas操作获取到锁),会在同步锁对象的对象头的栈帧中的锁记录lock record中记录存储偏向锁的线程ID。以后该线程再次进入同步块时不再需要CAS来加锁和解锁,只需简单测试一下对象头的mark word中偏向锁线程ID是否是当前线程ID(所以说,对于一个线程来说,偏向锁的获取只需要第一次使用CAS操作,该线程后面的直接判断即可,最乐观,最高效,加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距);

2、如果成功,表示线程已获取到锁直接进入代码块运行;如果测试失败(要么无锁,要么就是有其他的线程,偏向锁不适合多线程,多次cas操作自旋(如上图),大概率会变成轻量级锁),检查当前偏向锁字段是否为0?

3、如果为0,表示是001,无锁,将偏向锁字段设置为1,并且更新自己的线程ID到同步锁对象的对象头的mark word字段当中(下面我们可以在程序中打印这个对象的对象头 good,用来查看锁升级的过程);如果为1,表示此时偏向锁已经被别的线程获取,则此线程不断尝试使用CAS获取偏向锁或者将偏向锁撤销,升级为轻量级锁(升级概率较大,偏向时间戳epoche默认达到40升级为轻量级锁)。

后面的测试类2中,main线程两次偏向锁,第一次需要cas,第二次直接判断就好了,很方便快捷。

问题:上图中,测试失败,不断自旋,然后两条路,升级为轻量级锁和撤销偏向锁是不同的两条路?
回答:嗯嗯,这个图告诉我们,多个线程下,偏向锁只有两个归宿,锁竞争失败方撤销偏向锁或者将锁更新为轻量级锁

金手指:关于获取偏向锁,就是两个对于同步锁对象的判断,同步锁对象中是否存储当前线程id?是的话直接执行同步代码块,不是的话判断同步锁对象中偏向标志是否为0,为0表示当前无锁,直接设置偏向标志为1和线程id即可,不为0表示已经有其他线程持有偏向锁,然后自旋,自旋两个结果,锁竞争失败方撤销偏向锁或者将锁更新为轻量级锁。

4.2.2 偏向锁撤销过程(四个步骤)

偏向锁释放的时机:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁撤销的两个阶段(考虑撤销 + 执行撤销):
1、考虑撤销:等待有线程尝试竞争偏向锁,才会考虑撤销。
2、执行撤销:考虑撤销完毕后,如果是确定要撤销,一定要等到JVM的safepoint点,才会执行撤销,因为这里没有正在执行的字节码。
tip:这里是第一点和第二点使用了考虑撤销和执行撤销,有先后顺序,考虑撤销并决定撤销后才会在safepoint执行撤销偏向锁,因为这里没有正在执行的字节码。

偏向锁撤销流程:
1、首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着;
2、如果线程活动状态,则将同步锁对象头设置成无锁状态;
3、如果线程非活动状态,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录lock record,栈中的锁记录lock record和同步锁对象的对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁;
4、最后唤醒暂停的线程。

下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。一个图概括了偏向锁的获取和撤销:

在这里插入图片描述

对于上图中概念的解释:
1、第一个判断,同步锁对象的thread id是否为当前线程id?返回为false,第一次一定是没有;
2、第二个判断,当前偏向字段是否为0?返回true,表示无锁,将threadid1设置到同步锁对象的对象头中的mark word(存放哈希码和锁对象)中,threadid1设置到锁对象成功,进入偏向锁状态;
此时,同步锁对象的对象头中的mark word存放者thread id1,末尾三位为101,表示进入偏向锁状态;
3、执行同步代码块:就是synchronized代码块;
4、当线程1正在执行同步代码块的时候,如果线程2要访问同步代码块,同样经过两个条件判断;
5、第一个判断:同步锁对象的thread id是否为当前线程id?返回为false,同步锁对象的thread id一定不是thread id2;
6、第二个判断:当前偏向字段是否为0?返回false,当前锁偏向字段为1,表示已有线程占用偏向锁;
7、线程2自旋,使用cas想要将同步锁对象的mark word中的thread id设置为自己的,但是没有成功(因为线程1还没执行完成,其实,多个线程下,偏向锁只有两个归宿,锁竞争失败方撤销偏向锁或者将锁更新为轻量级锁),这里要验证撤销偏向锁,所以这里线程2发起撤销偏向锁
8、撤销偏向锁第一步:首先暂停拥有偏向锁的线程,这里是暂停线程1,如上图,线程1被暂停,然后检查持有偏向锁的线程是否活着;
9、撤销偏向锁第二步:如果持有偏向锁的线程1 活动状态,则将同步锁对象头设置成无锁状态;
10、撤销偏向锁第三步:如果持有偏向锁的线程1 非活动状态,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录lock record和同步锁对象的对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁(三种);
11、撤销偏向锁第四步:最后唤醒暂停的线程。
12、这里,线程1还活着,恢复到无锁 001,最后唤醒被暂停的线程1,就是上图的恢复线程。

小结:这里,撤销偏向锁四步骤,先第一步,线程1被暂停,检查线程1是否活动状态;然后第二步,线程1 活动状态,将同步锁对象头设置为无锁状态;最后第四步,唤醒被暂停的线程1,恢复线程。
注意1:考虑撤销:撤销偏向锁触发条件是等到有竞争才撤销/释放偏向锁,这是考虑撤销。
注意2:执行撤销:考虑撤销并确定撤销后,才会执行撤销,而且要等待JVM safepoint点执行撤销,因为这里没有正在执行的字节码。
注意3:撤销/释放偏向锁的持有偏向锁的线程1,不是线程2,线程2啥都没有,撤销啥。

最后再上一张图补一下(获取偏向锁 + 撤销/释放偏向锁)

在这里插入图片描述

4.2.3 附:关闭偏向锁

偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

小结:关闭偏向锁注意两个:
JVM参数设置: XX:BiasedLockingStartupDelay=0 关闭偏向锁延迟
JVM参数设置:XX:UseBiasedLocking=false 关闭偏向锁,直接进入轻量级锁

4.3 轻量级锁(轻量级锁加锁 + 轻量级锁解锁)

4.3.1 引入两个新概念:Lock Record + Displaced Mark Word

上面关于偏向锁,我们在介绍了对象头和Mark Word之后,都是拿同步锁对象来研究的,比如获取偏向锁的两个判断,同步锁对象中的thread id是否是当前线程?同步锁对象中的偏向字段是否为0?无锁情况下直接更新同步锁对象的 thread id 和 设置偏向字段为1。

这里介绍轻量级锁,引入一个新的名词 Lock Record 锁记录 和一个新的动词 Displaced Mark Word。

Displaced Mark Word定义:线程在执行同步块之前,JVM会先在当前线程的栈桢(方法调用栈)中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,这个动作称之为 Displaced Mark Word。

tip1:Displaced Mark Word 是一个动作,发生在线程执行同步块之前。
tip2:Lock Record 是一个名词,是当前线程的帧栈中的一个空间名,用来存放对象的对象头的mark word。

4.3.2 轻量级锁加锁(三步骤)

接上面的,thread2获得偏向锁失败,只能自旋,只有两个归宿,要么让 thread1 撤销/释放偏向锁,要么thread1 升级轻量级锁。

流程(偏向锁升级为轻量级锁,即轻量级锁加锁):
1、线程尝试使用CAS操作,将同步锁对象头中的Mark Word替换为指向锁记录的指针。
2、如果成功,当前线程获得锁,轻量级锁加锁成功。
3、如果失败,表示其他线程已经竞争到轻量级锁(金手指:这里thread1成功,获得到锁,thread2失败,表示thread1已经成功得到锁,只能自旋),当前线程为了加锁成功,便尝试使用自旋来获取锁。

偏向锁加锁和轻量级锁加锁:
偏向锁和轻量级锁,锁竞争失败方都是cas自旋获取锁,修改mark word,如果加锁失败,也都是自旋。

4.3.3 轻量级锁解锁(三步骤)

流程(轻量级锁解锁):
1、同步代码块执行完成,轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头;
2、如果成功,则表示没有竞争发生,轻量级锁解锁成功;
3、如果失败,表示当前锁存在竞争(金手指:下图中,线程1执行完代码块,轻量级锁释放失败,因为thread2在竞争锁),锁就会膨胀成重量级锁(下图中markword第四个010)。

轻量级锁加锁 + 轻量级锁解锁:

在这里插入图片描述

上图中同步锁对象头中的mark word,一共涉及三个,从上到下,无锁、轻量级锁、重量级锁

无论是偏向锁还是轻量锁,锁竞争失败方都会自旋,这里讲轻量级锁自旋,因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了,继续自旋尝试就是浪费,不会成功的),所以规定,一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时, 都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮 的夺锁之争。

小结:
轻量级锁:多个线程在不同时间段请求同一把锁,也就是基本不存在锁竞争。针对此种情况,JVM采用轻量级锁来避免线程的阻塞以及唤醒。
只要在同一时间内有线程去竞争锁,那么线程执行一次CAS操作,然后发现已经被别的线程抢占,直接升级为重量级锁,不在进行CAS操作,避免无用自旋(将锁升级为重量锁、阻塞自旋线程这两招就是为了避免无用自旋获取轻量锁)

轻量锁加锁 + 轻量锁解锁

在这里插入图片描述

轻量锁加锁
线程在执行同步代码块之前,JVM先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的mark word字段直接复制到此空间中。然后线程尝试使用CAS将对象头的mark word替换为指向锁记录的指针(指当前线程),如果成功表示获取到轻量级锁。如果失败,表示其他线程竞争轻量级锁,当前线程便使用自旋来不断尝试。

轻量锁释放0
解锁时,会使用CAS将复制的mark word替换回对象头,如果成功,表示没有竞争发生,正常解锁。如果失败,表示当前锁存在竞争,进一步膨胀为重量级锁(下图中markword第四个010)。

4.3.4 对比:偏向锁、轻量级、重量级锁

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。如果线程间存在锁竞争,会带来额外的锁撤销的消耗。最乐观的锁,适用于一个线程访问同步代码块,偏向锁不适用与多个线程,因为多个线程竞争同步资源,一定会有失败,失败了就是偏向锁撤销,然后不但自旋,到达阈值epoche=40,就升级轻量级锁
轻量级锁竞争的线程不会阻塞,不断自旋,提高了程序的响应速度。如果始终得不到锁竞争的线程使用自旋会消耗CPU。响应时间很短,同步块执行速度非常快,适用于多个线程在不同时间段申请同一把锁
重量级锁线程竞争不使用自旋,不会消耗CPU。线程阻塞,响应时间缓慢。追求吞吐量。同步块执行速度较长。

重量级锁会阻塞,唤醒请求加锁的线程(这就是为了将锁升级重量级锁的原因,避免轻量锁无效自旋浪费CPU)。针对的是多个线程同一个时刻竞争同一把锁的情况,JVM采用自适应自旋,来避免线程在面对非常小的同步块时,仍会被阻塞以及唤醒。

轻量级锁采用CAS操作,将锁对象的标记字段替换为指向线程的指针,存储着锁对象原本的标记字段。针对的是多个线程在不同时间段申请同一把锁的情况。

偏向锁只会在第一次请求时采用CAS操作,在锁对象的mark word字段中记录下当前线程ID,此后运行中持有偏向锁的线程不再有加锁过程。针对的是锁仅会被同一线程持有

偏向锁是一个线程,多个线程大概率升级为轻量级锁,轻量级锁和重量级锁都是多个线程。既然是一个线程,偏向锁有什么用?因为对象一开始就是偏向锁,遇到synchronized大概率变为轻量级锁,你也可以一开始就是轻量级锁。

markword结构、加锁方式、竞争激烈程度三者对应关系:
markword: 无锁里面存放hashcode,偏向锁里面存放偏向threadid,用于同一线程重入判断,不需要再次cas加锁了,轻量级锁存放指向栈中锁记录的指针(这是轻量级锁加锁的第一步),重量级锁存放指向互斥量的指针,GC里面存放的是GC标志。
加锁方式:偏向锁只需要第一次cas加锁,以后同一线程重入即可,如果加锁不上,就自旋;轻量级锁需要每次cas加锁,如果加锁不上,就自旋;重量级锁加锁,如果加锁不上,就阻塞(放到单链表的等待队列中)或者自适应自旋。

4.3.5 同步锁对象的对象头中的mark word变化(获取偏向锁+撤销偏向锁+升级为轻量锁之前+升级为轻量锁/轻量锁加锁+轻量锁解锁)

同步锁对象的对象头中的mark word

1、第一次获得偏向锁的时候,会将thread id放到栈帧中的锁记录Lock Record和同步锁对象的对象头中,其余次只要对比就好
2、撤销偏向锁的时候,如果持有偏向锁的线程不存活,栈中的锁记录lock record和同步锁对象的对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁(三种)
3、升级为轻量级锁之前,线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到刚刚新建的锁记录空间中;
4、轻量级锁加锁的时候,线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,表示轻量级锁加锁成功;
5、轻量级锁解锁的时候,使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,解锁成功,如果失败,升级为重量级锁。

4.4 重量级锁(重量级锁加锁 + 重量级锁解锁)(了解即可)

4.4.1 重量级锁的底层支持

4.4.1.1 ObjectMonitor类

基于HotSpot实现的JVM中,关于synchronized锁的实现是靠ObjectMonitor(对象监视器)实现的,当多个线程同时请求一个对象监视器(请求同一个锁)时,对象监视器将设置几个状态以用于区分调用线程。

底层ObjectMonitor类的常用属性(获取重量锁 + 释放重量锁 用到):

属性意义
_headerMarkOop对象头,对象头
_waiters等待线程数,int
_recursionssynchronized可重入锁,记录重入次数,int
_owner指向获得ObjectMonitor的线程,指针,重要
_WaitSet用于线程通信的集合,调用了java中的wait()方法会被放入其中,Set 等待集合
_cxq_EntryList用于线程同步的集合,多个线程尝试获取锁时,List

4.4.1.2 节点ObjectWaiter类(双链表,链表节点中存放线程)

重量锁的并发竞争状态维护就是依靠三个队列来实现的,分别是 _WaitSet、_cxq | _EntryList 。这三个队列都是由ObjectWaiter类 实现的,其实就是双向链表实现(金手指:有_prev和_next指针),链表中每一个节点存放线程。

对于 _WaitSet、_cxq | _EntryList ,都是双链表,但是实现的功能不同。

_cxq | _EntryList 是用来实现线程同步的,

_WaitSet 是用来实现线程通信的,底层是等待队列,对应的Object类的 wait()/notify()/notifyAll() 方法,进入 _WaitSet 是失去参与同步锁竞争,弹出 _WaitSet 是有机会参与同步锁竞争。

4.4.1.3 线程同步:宏观synchronized 与 底层链表 _cxq|_EntryList

获取重量锁本质是修改_owner指针:当synchronized是重量锁的时候,线程获取锁底层实现就是改变_owner指针,让他指向自己。

在这里插入图片描述
对于上图中概念的解释:
Contention List:首先将锁定线程的所有请求放入竞争队列
OnDeck:任何时候只有一个线程是最具竞争力的,该线程称为OnDeck(由系统调度策略决定)

对于上图中流程的解释:
1、Contention List 包括 _cxq和_EntryList,用户实现线程同步,将锁定线程的所有请求放入竞争队列,接收多个线程;
2、选择出最有竞争力的线程(由系统调度策略决定,所以说synchronized是非公平的独占锁),该线程称为 OnDeck ,修改 _owner 指针,指向这个 OnDeck 线程(即获得ObjectMonitor的线程)
3、线程执行过程中,如果有线程通信的要求,调用notify()释放一个 _WaitSet 集合中的线程(即ObjectWaiter对象),放到_EntryList中,允许其参与下一次同步锁竞争。

4.4.1.4 线程通信:宏观的wait()/notify()/notifyAll()与底层链表_WaitSet

宏观Object类的wait()/notify()/notifyAll()方法,其实是调用内核的方法实现的,他们的逻辑是:
1、调用wait()的线程加入_WaitSet中,即失去竞争同步锁的机会;
2、调用notify()唤醒_WaitSet链表节点中的线程,就可以重新得到竞争同步锁的机会。
3、notify和notifyAll不同在于前者只唤醒一个线程,后者唤醒所有队列中的线程。
4、值得注意的是notify并不会立即释放锁,而是等到同步代码执行完毕。

4.4.2 重量锁加锁(ObjectMonitor类的enter()方法)

4.4.2.1 重量锁的获取过程,ObjectMonitor::enter

1、设置_owner字段,CAS操作成功表示获取锁 :通过CAS尝试把monitor的_owner字段设置为当前线程;
2、设置_recursions字段,记录重入次数 :如果设置之前的_owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行_recursions ++ ,记录重入的次数;
3、查看当前线程的锁记录空间中的Displaced Mark Word,即是否是该锁的轻量级锁持有者,如果是则是第一次加重量级锁,设置_recursions为1,_owner为当前线程,该线程成功获得锁并返回;
4、如果获取锁失败,则等待锁的释放。

4.4.2.2 ObjectMonitor::EnterI方法,自旋等待锁释放(核心:for循环)

monitor竞争失败的线程,通过自旋执行ObjectMonitor::EnterI方法等待锁的释放,EnterI方法的部分逻辑实现如下:

1、当前线程被封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ;
2、自旋CAS将当前节点使用头插法加入cxq队列
3、node节点push到_cxq列表如果失败了,再尝试获取一次锁(因为此时同时线程加入,可以减少竞争),如果还是没有获取到锁,则通过park将当前线程挂起,等待被唤醒。

当被系统唤醒时,继续从挂起的地方开始执行下一次循环也就是继续自旋尝试获取锁。如果经过一定时间获取失败继续挂起。

4.4.3 重量锁解锁(ObjectMonitor类的exit()方法)

当某个持有锁的线程执行完同步代码块时,会进行锁的释放。在HotSpot中,通过改变ObjectMonitor的值来实现,并通知被阻塞的线程,具体实现位于ObjectMonitor::exit方法中。

1、初始化ObjectMonitor的属性值,如果是重入锁递归次数减一,等待下次调用此方法,直到为0,该锁被释放完毕。
2、根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,通过ObjectMonitor::ExitEpilog方法唤醒该节点封装的线程,唤醒操作最终由unpark完成。

4.4.4 Object对象(即任意对象)调用hashCode()、wait()方法会使锁直接升级为重量级锁

调用hashCode()、wait()方法会使锁直接升级为重量级锁(在看jvm源码注释时看到的),下面测试一下。

4.4.4.1 调用wait()直接升级为重量级锁

构造demo的想法:要使用wait(),就要配合notify()/notifyAll()唤醒,使用一个线程是做不到的,一定要使用两个线程。

调用wait方法

public class TestWait {

    public static void main(String[] args) throws InterruptedException {
        TimeUnit.SECONDS.sleep(5);
        O object = new O();

        Thread thread1 = new Thread() {
            @Override
            public void run() {
                synchronized (object) {    // 因为thread2释放锁,所以这里thread1可以成功
                    System.out.println("thread1获取锁成功,开始执行,因为thread1调用了wait()方法,直接升级为重量级锁");
                    System.out.println("2\n" + ClassLayout.parseInstance(object).toPrintable());
                    object.notify();
                }
            }
        };

        Thread thread2 = new Thread() {
            @Override
            public void run() {
                synchronized (object) {
                    System.out.println("thread2 获取偏向锁成功开始执行");
                    System.out.println("1\n" + ClassLayout.parseInstance(object).toPrintable());   //打印对象头
                    try {
                        object.wait();  // 阻塞thread2,并将object锁对象变为重量级锁,同时thread2释放锁,给机会thread1
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        thread2.start();   // 先启动thread2

        //让thread1执行完同步代码块中方法。
        TimeUnit.SECONDS.sleep(3);   
        thread1.start();
    }
}

测试结果

在这里插入图片描述

面试语言组织:调用wait()直接将偏向锁升级为重量级锁,构造demo很简单,新建两个线程,休眠5秒,启动第一个线程,打印对象头为偏向锁,调用wait()进入阻塞状态,释放锁,将锁直接设置为重量级锁,启动第二个线程打印对象头就可以知道

4.4.4.2 调用hashCode()直接升级为重量级锁

构造demo想法:调用hashcode只需要一个线程就好了,直接使用main线程。

调用hashCode()

public class TestLightweightLock {
    public static void main(String[] args) throws InterruptedException {
        TimeUnit.SECONDS.sleep(5);
        O object = new O();
        synchronized (object) {   // objetc作为同步锁对象了,这句必须有,否则打印看不到锁升级
            System.out.println("thread1 获取偏向锁成功,开始执行代码");
            System.out.println(ClassLayout.parseInstance(object).toPrintable());
            object.hashCode();
            try {
                //等待对象头信息改变
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("hashCode() 调用后");
            System.out.println(ClassLayout.parseInstance(object).toPrintable());
        }
    }
}

面试语言组织:调用hashcode()直接将偏向锁升级为重量级锁,构造demo很简单,新建一个线程,休眠5秒,启动线程,打印对象头为偏向锁,调用hashcode(),打印对象头为重量级锁。

测试结果

在这里插入图片描述

4.4.5 锁的降级(重量级锁降级为轻量级锁)

锁也可以降级,在安全点判断是否有线程尝试获取此锁,如果没有进行锁降级(重量级锁降级为轻量级锁,和之前在书中看到的锁只能升级不同,可能理解的意思不一样)。

测试代码如下,顺便测试了一下重量级锁升级

public class TestMonitor {
    public static void main(String[] args) throws InterruptedException {
        TimeUnit.SECONDS.sleep(5);
        O object = new O();
        Thread thread1 = new Thread() {
            @Override
            public void run() {
                synchronized (object) {  // object作为同步锁对象
                    System.out.println("thread1 获得偏向锁");
                    System.out.println(ClassLayout.parseInstance(object).toPrintable());
                    try {
                        //让线程晚点儿死亡,造成锁的竞争
                        TimeUnit.SECONDS.sleep(6);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("thread2 获取锁失败导致锁升级,此时thread1还在执行");
                    System.out.println(ClassLayout.parseInstance(object).toPrintable());
                }
            }
        };
        Thread thread2 = new Thread() {
            @Override
            public void run() {
                synchronized (object) {
                    System.out.println("thread2 获取偏向锁失败,最终升级为重量级锁,等待thread1执行完毕,获取重量锁成功");
                    System.out.println(ClassLayout.parseInstance(object).toPrintable());
                    try {
                        TimeUnit.SECONDS.sleep(3);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        thread1.start();   // 启动thread1
        //对象头打印需要时间,先让thread1获取偏向锁
        TimeUnit.SECONDS.sleep(5);
        //thread2去获取锁,因为t1一直在占用,导致最终升级为重量级锁
        thread2.start();
        
        //确保t1和t2执行结束
        thread1.join();
        thread2.join();
        TimeUnit.SECONDS.sleep(1);
       

        Thread t3 = new Thread(() -> {
            synchronized (object) {
                System.out.println("再次获取");
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
            }
        });
        t3.start();
    }
}

测试结果

在这里插入图片描述

在这里插入图片描述

t1和t2由于争抢导致锁升级为重量级锁,等待它们执行完毕,启动t3获取同一个锁发现又降级为轻量级锁。

五、面试金手指(synchronizd底层实现:为面试而准备的内容,语言文字为主)

5.1 synchronizd底层实现:synchronized五种用法和同步锁对象头的五种状态

5.1.1 synchronized五种用法(多线程+锁对象)

修饰目标
方法实例方法当前实例对象(即方法调用者)
静态方法类对象
代码块this当前实例对象(即方法调用者)
class对象类对象
任意Object对象 任意示例对象

五种情况主要是方法级别锁和代码级别锁,还有就是锁对象的不同,方法级别锁和代码级别锁很好理解,看代码就懂,锁对象不同是什么意思?

第一种情况和第三种情况,形成锁竞争:普通方法和代码块中使用this是同一个监视器(锁),即某个具体调用该代码的对象

第二种情况和第四种情况,形成竞争:静态方法和代码块中使用该类的class对象是同一个监视器,任何该类的对象调用该段代码时都是在争夺同一个监视器的锁定

小结:同步两因素:第一多线程,第二锁对象
1、多线程:就是因为存在不止一个线程才能形成竞争,只有一个线程你和谁竞争,所以一旦设置同步,多线程必不可少;
2、锁对象:锁对象表示的是竞争的对象,即多个线程之间竞争什么,竞争的对象相同才能形成竞争,竞争的对象不同是无法形成竞争的,举一反三,比如非同步方式(没有synchronized修饰),就啥都不竞争,和同步原子没半毛钱关系,代码中通过对竞争的资源加锁解锁形成原子操作,所以一旦涉及同步,锁对象必不可少。

5.1.2 synchronized代码级别和方法级别区别

synchronized代码级别锁底层:monitorenter+monitorexit
synchronized方法级别锁底层:ACC_SYNCHRONIZED
如果synchronized在方法上,底层使用ACC_SYNCHRONIZED修饰该方法,然后在常量池中获取到锁对象,实际实现原理和同步块一致

5.1.3 对象头 + Mark Word

对象头三个部分:
对象头=Mark Word + Class Metadata Address + Array length,三个每一个占一个字宽。
Markdown存放对象的hashCode或锁信息;(无锁状态:29bit hashcode+ 3bit lock)
Class Metadata Address存放存储到对象类型数据的指针;
ArrayLength,数组类型特有,存放数组类型长度。

小结:五种状态:
1、锁状态:
hashcode 哈希码 29bit
biased_lock: 偏向锁标识位,1bit
lock: 锁状态标识位,2bit 01

2、偏向锁(线程id 23 + 偏向时间戳 2 + 分代年龄 4 = 29 + lock 3):
JavaThread: 保存持有偏向锁的线程ID,23bit
epoch: 保存偏向时间戳,2bit(到40,升级为轻量级锁)
age: 保存对象的分代年龄,4bit
biased_lock: 偏向锁标识位,1bit
lock: 锁状态标识位,2bit 01
其中,线程id 23 + 偏向时间戳 2 + 分代年龄 4 都是偏向锁特有,后两个都是涉及时间

3、轻量锁和重量锁(30+2):
ptr: monitor的指针(就是锁指针,同步代码块的锁由monitorenter和monitorexit完成,同步方法的锁由ACC_SYNCHRONIZED修饰,底层是一致的),30bit
lock: 锁状态标识位,2bit 00 10

4、GC标志(30+2)
空,30bit
lock: 锁状态标识位,2bit 11

记忆方法:偏向锁和无锁一起记忆,其他三个,轻量级锁和重量级锁一起记忆。

5.2 synchronizd底层实现:锁升级

5.2.1 锁升级的由来

1、只能升级不能降级,目的是为了提高 获得锁和释放锁的效率
2、升级顺序:无锁状态 0 01、偏向锁状态101、轻量级锁状 态000和重量级锁状态010

5.2.2 偏向锁获取 + 偏向锁撤销/解锁 + 偏向锁关闭

获取偏向锁:
关于获取偏向锁,就是两个对于同步锁对象的判断,同步锁对象中是否存储当前线程id?是的话直接执行同步代码块,不是的话判断同步锁对象中偏向标志是否为0,为0表示当前无锁,直接设置偏向标志为1和线程id即可,不为0表示已经有其他线程持有偏向锁,然后自旋,自旋两个结果,锁竞争失败方撤销偏向锁或者将锁更新为轻量级锁。

偏向锁撤销的两个阶段(考虑撤销 + 执行撤销):
1、考虑撤销:等待有线程尝试竞争偏向锁,才会考虑撤销。
2、执行撤销:考虑撤销完毕后,如果是确定要撤销,一定要等到JVM的safepoint点,才会执行撤销,因为这里没有正在执行的字节码。

tip:这里是第一点和第二点使用了考虑撤销和执行撤销,有先后顺序,考虑撤销并决定撤销后才会在safepoint执行撤销偏向锁,因为这里没有正在执行的字节码。

偏向锁撤销流程:
1、首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着;
2、如果线程活动状态,则将同步锁对象头设置成无锁状态;
3、如果线程非活动状态,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录lock record,栈中的锁记录lock record和同步锁对象的对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁;
4、最后唤醒暂停的线程。

5.2.3 轻量锁加锁 + 轻量锁解锁

轻量锁加锁流程(偏向锁升级为轻量级锁,即轻量级锁加锁)
1、线程尝试使用CAS操作,将同步锁对象头中的Mark Word替换为指向锁记录的指针。
2、如果成功,当前线程获得锁,轻量级锁加锁成功。
3、如果失败,表示其他线程已经竞争到轻量级锁(金手指:这里thread1成功,获得到锁,thread2失败,表示thread1已经成功得到锁,只能自旋),当前线程便尝试使用自旋来获取锁。

偏向锁加锁和轻量级锁加锁
偏向锁和轻量级锁,锁竞争失败方都是cas自旋获取锁,修改mark word,如果加锁失败,也都是自旋。

轻量锁解锁流程
1、同步代码块执行完成,轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头;
2、如果成功,则表示没有竞争发生,轻量级锁解锁成功;
3、如果失败,表示当前锁存在竞争(如线程1执行完代码块,轻量级锁释放失败,因为thread2在竞争锁),锁就会膨胀成重量级锁。

5.2.4 重量锁加锁 + 重量锁解锁 + 调用wait()和hashcode()直接变为重量锁

重量级锁加锁
1、_owner字段 :通过CAS尝试把monitor的_owner字段设置为当前线程;
2、synchronized和lock都是可重入锁 _owner、_recursions:如果设置之前的_owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行_recursions ++ ,记录重入的次数;
3、查看当前线程的锁记录空间中的Displaced Mark Word,即是否是该锁的轻量级锁持有者,如果是则是第一次加重量级锁,设置_recursions为1,_owner为当前线程,该线程成功获得锁并返回;
4、如果获取锁失败,则等待锁的释放;

重量级锁释放
1、初始化ObjectMonitor的属性值,如果是重入锁递归次数减一,等待下次调用此方法,直到为0,该锁被释放完毕。
2、根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,通过ObjectMonitor::ExitEpilog方法唤醒该节点封装的线程,唤醒操作最终由unpark完成。
调用wait()的线程加入_WaitSet中,然后等待notify唤醒他们,重新加入到锁的竞争之中,值得注意的是notify并不会立即释放锁,而是等到同步代码执行完毕。

调用wait()和hashcode()直接变为重量锁
1、调用wait()变为重量锁:调用wait()直接将偏向锁升级为重量级锁,构造demo很简单,新建两个线程,休眠5秒,启动第一个线程,打印对象头为偏向锁,调用wait()进入阻塞状态,释放锁,将锁直接设置为重量级锁,启动第二个线程打印对象头就可以知道
2、调用hashcode()变为重量锁:调用hashcode()直接将偏向锁升级为重量级锁,构造demo很简单,新建一个线程,休眠5秒,启动线程,打印对象头为偏向锁,调用hashcode(),打印对象头为重量级锁。

锁也可以降级,在安全点判断是否有线程尝试获取此锁,如果没有进行锁降级

六、尾声

synchronized,并发安全的守护神,完成了。

天天打码,天天进步!!!