• 📝 个人主页:程序员阿红🔥
  • 🎉 支持我:点赞👍收藏⭐️留言📝
  • 🍓欢迎大家关注哦,互相学习🍓
  • 🍋欢迎大家访问哦,互相学习🍋
  • 🍑欢迎大家收藏哦,互相学习🍑

🍗1. 引言

记得开始学习Java的时候,一遇到多线程情况就使用synchronized,相对于当时的我们来说synchronized是这么的神奇而又强大,那个时候我们赋予它一个名字“同步”,也成为了我们解决多线程情况的百试不爽的良药。

😈😈😈synchronized俗称:对象锁,专门用来锁对象的(这里不用过多关心对象的问题,反正你也不会有的😅)。

😺😺😺synchronized采用互斥的方式来保证同一时刻至多只有一个线程拥有对象锁,其他线程想获取这个锁就会阻塞组,这样就能保证拥有锁的线程可以安全的执行临界区内的代码,从而不用担心线程上下问切换。

临界资源:在一段时间内只允许一个进程访问的资源。又称独占资源。

临界区:是进程访问临界资源的那段代码

🍗2.synchronized的主要作用

  1. 原子性:确保线程互斥的访问同步代码;
  2. 可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值” 来保证的;
  3. 有序性:有效解决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”;

🍗3.synchronized的三种用法

  1. 当synchronized作用在实例方法时,监视器锁(monitor)便是对象实例(this);
  2. 当synchronized作用在静态方法时,监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁;
  3. 当synchronized作用在某一个对象实例时,监视器锁(monitor)便是括号括起来的对象实例;

🥩3.1 synchronized小试牛刀

既然了解了synchronized的用法,那就举例说说:

synchronized语法:

synchronized(对象) {
	//临界区
}
  • synchronized加在方法上:
    • 加在成员方法上,锁住的是对象(🎃等同于上面第一点)
public class Test {
	// 在方法上加上synchronized关键字
	public synchronized void test() {
	
	}
	// 等价于
	public void test() {
		synchronized(this) { // 锁住的是对象
		
		}
	}
}
  • 加在静态方法上,锁住的是类(🎃等同于上面第二点)
public class Test {
	// 在静态方法上加上 synchronized 关键字
	public synchronized static void test() {
	
	}
	//等价于
	public void test() {
		synchronized(Test.class) { // 锁住的是类
		
		}
	}
}

  • 作用在实例对象上(🎃等同于上面第三点)
public class Test {

    public static void main(String[] args) {
        Test t1 = new Test();
        Test t2 = new Test();
        synchronized (t1){
            t1.getCount();
        }
    }

    public  void getCount(){
        int sum = 0;
        for (int i = 1 ; i <= 10 ; i++){
             sum = sum + i;
        }
        System.out.println(sum);
    }
}

🍗4.变量的线程安全问题分析

🎨线程安全:所谓线程安全就是在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。(好比多个类对象同时调用的一个方法时,方法的返回结果完全一致,则说明该方法是线程安全的)。

  1. 成员变量和静态变量的线程安全分析
  • 如果变量没有在线程间共享,那么线程对该变量操作是安全的
  • 如果变量在线程间共享
    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码就是临界区,需要考虑线程安全问题
  1. 局部变量线程安全分析
  • 局部变量【局部变量被初始化为基本数据类型】是安全的
  • 局部变量是引用类型或者是对象引用则未必是安全的
    • 如果局部变量引用的对象没有引用线程共享的对象,那么是线程安全的
    • 如果局部变量引用的对象引用了一个线程共享的对象,那么要考虑线程安全问题

🍗5.synchronized原理深入

🥩5.1Monitor概念

一个对象的结构如下:

image-20220503170430587

简写为:

image-20220503170552744

这里我们这种介绍对象头。

以32位虚拟机为例,普通对象的对象头结构如下

  • 对象头,又包括三部分:Mark Word、(Klass Word)元数据指针、数组长度
    • MarkWord:用于存储对象运行时的数据,比如HashCode、锁状态标志、GC分代年龄等。这部分在64位操作系统下,占8字节(64bit),在32位操作系统下,占4字节(32bit)。
    • Klass Word:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。这部分就涉及到一个指针压缩的概念,在开启指针压缩的情况下,占4字节(32bit),未开启情况下,占8字节(64bit),现在JVM在1.6之后,在64位操作系统下都是默认开启的。
    • 数组长度:这部分只有是数组对象才有,如果是非数组对象,就没这部分了,这部分占4字节(32bit)。

我们着重来看MarkWord,其结构为:

image-20220503172236562

  • hashcode: 对象的标识码,也就是对象标识的哈希码。哈希值不是地址值,两者间的区别和联系,我现在也还不是很清晰。
  • age: 4bit的Java对象年龄,我们知道当对象在survivor区随着GC0反复横跳的时候,每跳一次,年龄加1,到了15的时候,就会晋升老年代,所以其最大值就是15,那么4bit刚好能表达完。
  • biased_lock: 1位的偏向锁标识位,0标识否,1表示是偏向锁。
  • lock: 2bit的锁状态位。

image-20220503172948292

(1)Monitor原理

monitor被翻译为监视器或者管程。

每个java对象都可以关联一个monitor,如果使用synchronized给对象上锁(重量锁),该对象头的 Mark Word 中就被设置为指Monitor 对象的指针。

Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。

image-20220503173559117

  1. 刚开始时 Monitor 中的 Owner 为 null

  2. 当 Thread-2 执行 synchronized(obj){} 代码时就会将Monitor 的所有者Owner 设置为 Thread-2,上锁成功,Monitor 中同一时刻只能有一个 Owner

  3. 当 Thread-2 占据锁时,如果线程 Thread-3 ,Thread-4 也来执行synchronized(obj){} 代码,就会进入 EntryList(阻塞队列) 中变成BLOCKED(阻塞) 状态

  4. Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的

  5. 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲 wait-notify 时会分析。

    注意:synchronized必须是进入同一个对象的monitor才有上述效果,不加 synchronized 的对象不会关联监视器,不遵从以上规则

🥩5.2 synchronized进阶原理

🍤5.2.1 锁的优化

从JDK5引入了现代操作系统新增加的CAS原子操作( JDK5中并没有对synchronized关键字做优化,而是体现在J.U.C中,所以在该版本concurrent包有更好的性能 ),从JDK6开始,就对synchronized的实现机制进行了较大调整,包括使用JDK5引进的CAS自旋之外,还增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。由于此关键字的优化使得性能极大提高,同时语义清晰、操作简单、无需手动关闭,所以推荐在允许的情况下尽量使用此关键字,同时在性能上此关键字还有优化的空间。

锁主要存在四种状态,依次是**:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态**,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

在 JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。

CAS 是一条 CPU 的原子指令(cmpxchg指令),不会造成所谓的数据不一致问题。

CAS 全称是 compare and swap,是一种用于在多线程环境下实现同步功能的机制。

🍤5.2.2 轻量级锁

轻量级锁的使用场景是:如果一个对象虽然有多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的),那么可以使用轻量级锁来进行优化。轻量级锁对使用者是透明的,即语法还是synchronized ,假设有两个方法同步块,利用同一个对象加锁。

static final Object obj = new Object();
public static void method1() {
     synchronized( obj ) {
         // 同步块 A
         method2();
     }
}
public static void method2() {
     synchronized( obj ) {
         // 同步块 B
     }
}
  1. 每次指向到 synchronized 代码块时,都会创建锁记录(Lock Record)对象,每个线程都会包括一个锁记录的结构,锁记录内部可以储存对象的 Mark Word 和对象引用 reference

image-20220504161909348

  1. 让锁记录中的 Object reference 指向对象,并且尝试用 cas(compare and sweep) 替换 Object 对象的 Mark Word ,将 Mark Word 的值存入锁记录中。

image-20220504161841428

  1. 如果 cas 替换成功,那么对象的对象头储存的就是锁记录的地址和状态 00 表示轻量级锁,如下所示

image-20220504162245182

  1. 如果cas失败,有两种情况
    1. 如果是其他线程已经持有了该Object的轻量级锁,那么表示有竞争,首先会进行自旋锁,自旋锁一定次数后,如果还是失败就进入锁膨胀阶段。
    2. 如果是自己的线程已经执行了synchronized所从入。那么再添加一条 Lock Record 作为重入的计数

image-20220504164428062

  1. 当线程退出 synchronized 代码块的时候,如果获取的是取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一

    image-20220504164835298

  2. 当退出synchronized代码块(解锁时)所记录的值不为null,这是使用cas将mark word的值恢复给对象头

    1. 成功,则解锁成功
    2. 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程。

🍤5.2.3 锁膨胀

所谓锁膨胀,就是让解锁操作进入重量级锁的解锁操作。

如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),将轻量级锁变为重量级锁。

  1. 当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁

image-20220504170809179

  1. 这时Thread-1加轻量级锁失败,进入锁膨胀流程
    1. 即为Object对象申请Monitor锁,让Object指向重量级锁地址。
    2. 其次自己进入Monitor的EntryList BLOCKED(阻塞)
    3. Monitor的后两位会变成10,表示为重量级锁,前30位表示重量级锁的地址。

image-20220504170749997

image-20220504171522072

  1. 当 Thread-0 退出 synchronized 同步块时,使用 cas 将 Mark Word 的值恢复给对象头,对象的对象头指向 Monitor,那么会进入重量级锁的解锁过程,即按照 Monitor 的地址找到 Monitor 对象,将 Owner 设置为 null ,唤醒 EntryList 中的 Thread-1 线程

🍤5.2.4 自旋优化

🏍当CAS操作失败,Thread-1会进入阻塞队列,为了降低CPU消耗资源,进一步引入了锁的自旋。如果当前线程自旋成功(即在自旋的时候持锁的线程释放了锁),那么当前线程就可以不用进行上下文切换就获得了锁。

  1. 自旋重试成功的情况

image-20220504172458233

  1. 自旋失败的情况,自旋了一定次数还是没有等到持锁的线程释放锁。

image-20220504172952232

🚓自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。Java 7 之后不能控制是否开启自旋功能。

轻量级锁类似于翻书包(小秘密)

🍤5.2.5 偏向锁

🎟在轻量级的锁中,我们可以发现,如果同一个线程对同一个对象进行重入锁时,也需要执行 CAS 操作,这是有点耗时滴。

🎡那么 java6 开始引入了偏向锁来进行进一步优化,只有第一次使用 CAS 时将对象的 Mark Word 头设置为偏向线程 ID,之后这个入锁线程再进行重入锁时,发现线程 ID 是自己的,表示没有竞争,那么就不用再进行CAS了。(类似在门口挂一个铭牌) 分析代码,比较轻量级锁与偏向锁

static final Object obj = new Object();
public static void m1() {
	synchronized(obj) {
		// 同步块 A
		m2();
	}
}
public static void m2() {
	synchronized(obj) {
		// 同步块 B
		m3();
	}
}
public static void m3() {
	synchronized(obj) {
		// 同步块 C
	}
}

⛑分析如图:

image-20220504174129103

image-20220504174218256

🎵偏向状态

对象头格式如下:

image-20220504174640903

🎁一个对象的创建过程:

  1. 如果开启了偏向锁(默认是开启的),那么对象刚创建之后,Mark Word 最后三位的值101,并且这是它的 Thread,epoch,age 都是 0 ,在加锁的时候进行设置这些的值.

  2. 偏向锁默认是延迟的,不会在程序启动的时候立刻生效,如果想避免延迟,可以添加虚拟机参数来禁用延迟: -XX:BiasedLockingStartupDelay=0 来禁用延迟

    注意:处于偏向锁的对象解锁后,线程 id 仍存储于对象头中

🎈撤销偏向

以下几种情况会使对象的偏向锁失效

  • 调用对象的 hashCode 方法
  • 多个线程使用该对象
  • 调用了 wait/notify 方法(调用wait方法会导致锁膨胀而使用重量级锁)

🎈批量重偏向

  • 如果对象虽然被多个线程访问,但是线程间不存在竞争,这时偏向 t1 的对象仍有机会重新偏向 t2
    • 重偏向会重置Thread ID
  • 当撤销超过20次后(超过阈值),JVM 会觉得是不是偏向错了,这时会在给对象加锁时,重新偏向至加锁线程。

🎈批量撤销

撤销偏向锁的阈值超过 40 以后,就会将整个类的对象都改为不可偏向的

🥩5.3小结

从JDK1.6开始,synchronized锁的实现发生了很大的变化;JVM引入了相应的优化手段来提升synchronized锁的性能,这种提升涉及到偏向锁,轻量级锁以及重量级锁,从而减少锁的竞争带来的用户态与内核态之间的切换;这种锁的优化实际上是通过java对象头中的一些标志位去实现的;对于锁的访问与改变,实际上都是与java对象头息息相关。

🎉对象实例在堆中会被划分为三个部分:对象头,实例数据与对其填充。对象头也是由三块内容来构成:

  1. Mark Word
  2. 指向类的指针
  3. 数组长度

🎉其中Mark Word(它记录了对象,锁及垃圾回收的相关信息,在64位的JVM中,其长度也是 64bit 的)的位信息包括如下组成部分:

  1. 无锁标记(hashcode、分代年龄、偏向锁标志)
  2. 偏向锁标记 (偏向线程 id)
  3. 轻量级锁标记 (锁记录)
  4. 重量级锁标记 (Monitor)
  5. GC标记

对于 synchronized 锁来说,锁的升级主要是通过 Mark Word 中的锁标记位与是否是偏向锁标记为来达成的;synchronized 关键字所对象的锁都是先从偏向锁开始,随着锁竞争的不断升级,逐步演化至轻量级锁,最后变成了重量级锁。

  1. 偏向锁:针对一个线程来说的,主要作用是优化同一个线程多次获取一个锁的情况, 当一个线程执行了一个 synchronized 方法的时候,肯定能得到对象的 monitor ,这个方法所在的对象就会在 Mark Work 处设为偏向锁标记,还会有一个字段指向拥有锁的这个线程的线程 ID 。当这个线程再次访问同一个 synchronized 方法的时候,如果按照通常的方法,这个线程还是要尝试获取这个对象的 monitor ,再执行这个 synchronized 方法。但是由于 Mark Word 的存在,当第二个线程再次来访问的时候,就会检查这个对象的 Mark Word 的偏向锁标记,再判断一下这个字段记录的线程 ID 是不是跟第二个线程的 ID 是否相同的。如果相同,就无需再获取 monitor 了,直接进入方法体中。

如果是另一个线程访问这个 synchronized 方法,那么实际情况会如何呢?:偏向锁会被取消掉。

  1. 轻量级锁:若第一个线程已经获取到了当前对象的锁,这是第二个线程又开始尝试争抢该对象的锁,由于该对象的锁已经被第一个线程获取到,因此它是偏向锁,而第二个线程再争抢时,会发现该对象头中的 Mark Word 已经是偏向锁,但里面储存的线程 ID 并不是自己(是第一个线程),那么她会进行 CAS(Compare and Swap),从而获取到锁,这里面存在两种情况:
    1. 获取到锁成功(一共只有两个线程):那么它会将 Mark Word 中的线程 ID 由第一个线程变成自己(偏向锁标记位保持不表),这样该对象依然会保持偏向锁的状态
    2. 获取锁失败(一共不止两个线程):则表示这时可能会有多个线程同时再尝试争抢该对象的锁,那么这是偏向锁就会进行升级,升级为轻量级锁
  2. 旋锁,若自旋失败,那么锁就会转化为重量级锁,在这种情况下,无法获取到锁的线程都会进入到 moniter(即内核态),自旋最大的特点是避免了线程从用户态进入到内核态。

💖💖💖 完结撒花

💖💖💖 路漫漫其修远兮,吾将上下而求索

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

最后,还不动手进你的收藏夹吃灰😎😎😎

🎉 支持我:点赞👍收藏⭐️留言📝