java——无锁、偏向锁、轻量级锁、重量级锁的synchronize锁升级笔记

本章所需基础知识:

  • 懂得多线程和锁的基础知识就行
  • 或者看完我上一篇的《java多进程和多线程简单复习(不涉及原理)》就可以了

如果没基础建议别看


推荐视频:

B站马士兵老师的视频:无锁、偏向锁、轻量级锁、重量级锁的锁升级




本文章目录:

  1. 几个概念和细节
  2. 对象在内存中的存储布局和工具JOL
  3. 偏向锁的概念和出现原因
  4. 自旋锁(轻量级锁,属于乐观锁、非阻塞同步)
  5. 重量级锁(阻塞同步、悲观锁
  6. 锁升级(synchronize锁的自动升级)




本章部分图片照旧用网课的图,太懒,侵权请通知我


一、几个概念和细节:

重量级锁和非重量级锁的区别:
       重量级锁需要申请资源时 ,必须通过内核(kernel),由操作系统来调用

用户态与内核态简单概念:
用户态:一般的普通程序只在JVM中运行,不经过操作系统的内核
内核态:使用到重量级锁、映射、网络等,经过了操作系统的内核。
(后面简称操作系统OS)


synchronized在JDK1.6以前是重量级锁,在1.6以后有了优化,现在是自动的锁升级,现在则是看JVM的两个参数决定最初是偏向锁或轻量级锁



现在先来理清几个细节:

  • synchronize的锁升级是JVM自动实现的
  • 轻量级锁别称:自旋锁无锁无重量锁 ,建议少用这个别称)
  • (重点)轻量级锁的实现原理是CAS,CAS的底层实现是汇编语言的lock cmpxchg这条极其极其重要的命令。
  • CAS全称是Compare And Swap,也有说是Compare And Exchange。CAS即比较和交换


二、对象在内存中的存储布局和工具JOL

                     ————要了解锁的底层实现,那必须先了解对象在内存中到底是什么样的。

上个内存图先:


对象在内存中的存储布局就是上面那样。
解释一波:

普通对象分四个部分

  1. (重点)markword:八个字节。标志字,即常说的对象头。这章的重点,锁相关的操作就在这里。
  2. 类型指针:四个字节。记录自己是什么类型的
  3. 实例数据:具体大小不定,空则是0字节
  4. 对齐:或者说补齐,(0到7个字节)。当上面三部分的总字节数不是八的倍数时,自动补齐到八的倍数,方便系统操作记录

所以一个普通对象的最小的字节是16个字节:8+4+0+4(补齐)


数组对象分五个部分:(比普通对象就了多一个数组长度)

  1. markword
  2. 类型指针
  3. 数组长度:四个字节
  4. 实例数据
  5. 对齐



工具JOL:
            学习本章需要的工具JOL(一个jar包),查看对象的内存数据用的,不然你怎么知道它是偏向锁还是其他锁,它又不会长嘴告诉你。


有maven的:往你的项目pom.xml中的标签对里添加

  <dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.9</version>
        </dependency>

没有maven的,jar包资源(我的百度网盘,放心无毒免费永久):
jol的jar包,点击直达

链接:https://pan.baidu.com/s/1Ps0aZi4awDh_Pvy1Rkhl3A
提取码:h4j8


有没有maven的,也都可以自己去中央仓库这里挑版本
maven中央仓库点击直达jol
链接:https://mvnrepository.com/artifact/org.openjdk.jol/jol-core


三、偏向锁的概念和出现原因

谈到锁就先简单看下这张图:
java——无锁、偏向锁、轻量级锁、重量级锁的synchronize锁升级笔记_第1张图片
这图是对象的对象头(markword)表示意义图
先简单知道对象头最低位字节的锁标志位:(不用背,方便学习而已)

  • 001:无锁态(没上锁的对象)
  • 101:偏向锁
  • 00:轻量级锁
  • 10:重量级锁
  • 11:GC标记信息:垃圾收集器相关的



概念:偏向某一个线程(首个使用它)的锁,给线程只加一次锁,该线程重复调用加锁对象时只对比线程ID号,一致则直接通过,不用重复加锁。 不经过OS的调度。

简单理解:只为一个线程服务的锁(如果没竞争),不经过OS内核


底层实现:
            将该线程的ID直接写进要上锁对象的markword(对象头)中的最低位字节里第二部分(31个字节那里),锁标志位改为101,就完事了




偏向锁出现的原因:
            JDK团队统计时发现一种现象:OS在同一个任务里,在没有其他线程竞争的时候,重复给同一个线程上锁解锁。这不瞎搞么,浪费资源
            例如:StringBuffer的多次append,加来加去都是同个线程在执行append命令(一般情况下),OS在每次append时都要给该线程重复加锁解锁,这就很浪费了。



四、自旋锁(轻量级锁)

4.1概念

         概念:线程在尝试获取锁失败后不会立即阻塞,而是执行空循环一段时间,然后会再次尝试获取锁的,一直重复这两个步骤(不断自旋),直到 达到某个条件线程才会挂起(wait) 或者 获取到锁。

         小细节:使用重量级锁时,没抢到锁的所有线程都会立马wait

         简单说:其他线程没抢到锁后,不放手CPU资源,休息一会后,继续抢好多次锁,不会立马睡觉(wait)

         自旋锁是偏向锁的升级,偏向锁是一个线程的,自旋锁是多个线程的,它不偏向任何一个线程,也不经过内核。靠CAS来实现的。

优点:减少线程切换的消耗,不经过OS,不用多次wait
缺点:每个线程抢锁失败一次就空循环一次,消耗CPU.



适用场景:

  • 临界代码段的操作不能过于复杂,不然一次执行完外面全wait了,没意义还多了消耗
  • 总线程数不能太大。不然抢锁失败的n-1个线程空循环一次就够你电脑遭罪了(例如5000个)


4.2 CAS的原理实现(特地上标题,重点)

流程图:
java——无锁、偏向锁、轻量级锁、重量级锁的synchronize锁升级笔记_第2张图片
多线程操作共享变量时,首先得抢锁,那么:
问题1:怎么判断该线程抢到了锁?
问题2:怎么保证同一时间只有一个线程抢到锁(多CPU)?

问题1,CAS是这样实现的:

  1. 将 主运行空间的共享变量的当前值E 存到自己的缓存文件中(E2)
  2. 然后执行线程要做的操作,得出结果V。
  3. 接着再读一次 主运行空间的共享变量的当前值E ,将新的E与缓存文件的E2进行对比是否一致
  4. 相同则表示该线程抢到锁了,将该线程的结果V写进共享对象,结束本次操作。(这里有个ABA问题)
  5. 不同则表示抢锁失败,回到第一步重新抢锁。

3步骤对比的意义:看看其他线程有没有在自己线程执行计算的过程里就抢锁成功,修改过E了,避免脏读


ABA问题:E和E2对比相同是不能保证百分百保证,其他线程没有在自己线程执行计算的过程里抢锁成功过。有可能其他线程操作后新E值和旧E值一样!

ABA问题解决:在E对象里加个操作次数变量就行,每次判断时对比两个,E和操作次数就OK了,因为ABA问题中就算E相同操作次数也绝不相同



问题2:怎么保证同一时间只有一个线程抢到锁(多CPU)?
            你可能疑惑都有两个对比变量E和操作次数了,不只能一个抢到锁么,怎么还有这种问题?

出现这种问题的原因在于两个

  1. 在对比两个都成功,和写入结果V(改变操作次数),是多个操作命令!存在时间间隔
  2. 多个CPU在同一时间对比成功,那么他们就同时抢到锁了。这是多核即多CPU才会出现的(单核没这问题),因为多CPU是分开的,可以真正的并发

CAS是这样解决问题2的:底层加锁
          汇编语言的lock cmpxchg这条极其极其重要的命令
java——无锁、偏向锁、轻量级锁、重量级锁的synchronize锁升级笔记_第3张图片
lock命令:
如果是单CPU,lock不加锁
如果是多CPU,lock加锁

多CPU时 lock命令锁住了一个北桥信号保证了单个线程执行这个cmpxchg命令(即对比和写入)
不采用锁总线的方式

注意点:这时的底层加锁并没有经过操作系统内核!

lock cmpxchg底层实现是跟系统和版本都有关的,略有差别。


4.3 自旋锁的分类

1.按膨胀条件(即升级条件)分为:简单自旋锁和自适应自旋锁:

     简单自旋锁:就上面那样

     自适应自旋锁:比简单自旋锁多了个功能,能够自动调整自旋次数这个参数,即升级重量级锁的条件。 后面再详写。



自适应自旋锁解决的是“锁竞争时间不确定”的问题。即根据实际情况去决定是多自旋几次再wait,还是少自旋几次再wait


简单说就是更灵活点,别所有没抢到的线程傻乎乎的自旋同样次数,给线程分了“天赋”:抢成功锁的几率大不大,大就多自旋几次,小就扣它生命值(自旋次数),趁早让它挂掉(wait),别浪费老子CPU资源。



2.按能否重入分类:可重入自旋锁和不可重入自旋锁

可重入是什么意思呢?

      可重入锁,也叫做递归锁,指的是可以多次对同一个锁进行加锁操作,而不会被阻塞。即持有锁的线程在未释放掉锁的情况下,能否继续多次获取这把锁的意思。

举个例子:现在有三个方法,a()、b()、c(),我把这三个方法都用synchronized(“k”)锁住,这就是同一把锁,然后用线程 T 抢到 k 锁,进去 a方法,然后 T 在 a 里面又去调用 b 方法和 c 方法,能调用成功的就是可重入,不能成功的是不可重入。

简单说:就是你在店里办了会员卡,在他家的其他连锁店能不能用,能用是可重入(麦当劳的会员卡),不能用是不可重入(大部分健身房会员卡)。



java——无锁、偏向锁、轻量级锁、重量级锁的synchronize锁升级笔记_第4张图片

  • 在锁对象的markword的前62位里,锁对象的Lock Record指针是指向线程栈里的lock record指针。
  • 线程重入一次,就在抢到锁的线程的线程栈里写一次记录,解锁一次就消除一个记录,记录空了就是完全解锁。
  • 没清空的话,其他线程还是抢不到锁的。


可重入锁与不可重入锁这块下面的博主讲的不错还有代码例子
深山猿的博客,点击直达
https://blog.csdn.net/h2604396739/article/details/81255028






五、重量级锁(悲观锁)

            重量级锁(互斥锁)也称为阻塞同步、悲观锁,在JVM中又叫对象监视器(Monitor),它很像C中的Mutex。它至少包含一个竞争锁的队列(entityset),和一个信号阻塞队列(waitset),前者负责做互斥,后一个用于做线程同步。

            重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也称为互斥锁

重量级锁开销很大的原因:
            当系统检查到锁是重量级锁之后,在每次抢锁的流程中,都会把所有没抢到锁的线程进行阻塞,等锁被释放后又会唤醒所有线程,重新抢锁。

            虽然被阻塞的线程不会消耗cpu,但线程的切换是需要从用户态转换到内核态,而转换状态是需要消耗很多CPU时间的(微观的很多,相对而言)。

            上面重量锁很长的概念是引用的下面两个博主的话(别问,问就是虎):

三名狂客,点击直达
不羁朔风,点击直达

            简单说就是:重量级锁是由OS调度的,有竞争锁队列和等待队列,每次抢锁,失败的线程全部立即wait,等锁释放后全部唤醒,这消耗可想而知是很大的。

            重量级锁的底层实现是objectMonitor.hpp(C++层面的) 我也没多少了解,这里就不写了,有兴趣的自己去扒扒。


            这里知道重量级锁的锁标志位是11,由OS调度,有两个队列,线程切换频繁,消耗很大就好






六、锁升级(synchronize锁的自动升级)

            本章的锁升级其实就是在讲JDK团队对synchronize锁的优化成果:synchronize锁的自动升级
java——无锁、偏向锁、轻量级锁、重量级锁的synchronize锁升级笔记_第5张图片
首先讲条synchronize的升级主线(一般情况下):
       new ——> 普通对象——> 偏向锁 ——> 自旋锁(轻量级锁 ) ——> 重量级锁


主线:

  • new ——> 普通对象:你简单new了个对象,不加锁,即无锁态(001)
  • 普通对象——> 偏向锁: 你给普通对象加了个synchronize锁,一开始就是偏向锁(101)(这里不是绝对是偏向锁,暂定问题3)
  • 偏向锁 ——> 自旋锁(轻量级锁 ,00):JVM发现有第二个线程去竞争该上锁对象,自动将上锁对象的markword里的线程ID 撕下来(锁撤销),进行升级,重新给该对象上个自旋锁,然后再让想要这把锁的两个(或多个)线程去重新竞争。
  • 自旋锁(轻量级锁 ) ——> 重量级锁(10):当JVM发现线程竞争激烈的时候,就把自旋锁升级为重量级锁。判断竞争激烈的标准有两个。一是竞争的线程里有某个线程自旋的次数超过10次,二是wait的线程个数超过总CPU数的一半(例如,八核就5个)。自旋10次和一半wait的标准是普通自旋锁的升级条件,而自适应自旋锁的就看调优的算法和实际情况而定的)



问题3:一开始给普通对象加锁为什么有可能不是偏向锁?

        JVM对于偏向锁有六个参数,这里涉及其中两个:

  1. UseBiasedLocking(使用偏向锁参数):默认是true。这个参数实际意思是 ——JVM是否默认 synchronize最开始启动的是偏向锁。
  2. BiasedLockingStartupDelay(偏向锁启动延时参数):默认是4秒

       第一个参数就不用继续解释了,最开始肯定默认使用最省CPU资源的锁
       第二个参数BiasedLockingStartupDelay(偏向锁启动延时参数) 为什么要存在?换句话说为什么不直接启动偏向锁,而多此一举的延时4秒


原因是这样的:
       如果有某些加锁对象是JVM(或者说是你)非常确信且肯定它不会只有一个线程去使用,而是一定会被很多线程去竞争! (例如运行空间的内存分配,或者你直接设定了多线程竞争)

       那么你还有给它上偏向锁的需求吗?加了反而还要去进行锁撤销、锁升级的浪费资源操作,这就是延时四秒的意义。

       在JVM启动后的四秒内,如果有线程竞争你想上锁的对象,那么该对象直接上自旋锁(轻量级锁)!没有竞争才去给它上偏向锁。( 这就是上图的普通对象——> 自旋锁(轻量级锁 )的路线)



那么来讲讲上图的new——>匿名偏向是怎么一回事:

       当你new了一个对象然后给它加了个synchronize锁(默认最初偏向锁),然后你又没有用线程去使用这把锁(起码刚开始没用到它),那么在JVM启动四秒后,JVM得给它加偏向锁啊,可又没有那个线程去使用它,这时JVM就给它加匿名偏向锁

       简单说你给对象加了synchronize锁,却没有去使用它(起码四秒内没用到它这把锁),那么就是匿名偏向锁。

       匿名偏向锁很简单,就是没有把线程ID写进对象锁的对象头(markword)里的偏向锁。 当首个用到它的线程时,就把该线程的ID写进去。匿名偏向锁是可重偏向的偏向锁。






题外话——————————

锁从宏观上分类,分为悲观锁与乐观锁。


乐观锁

乐观锁是一种乐观思想,认为读多写少,遇到并发写的可能性低。
每次去拿数据的时候都认为别人没修改过,所以不会提前上锁。
在执行完线程操作后,更新的时候再去判断一下在此期间别人有没有去更新这个数据。
采取在写时先读出当前值,然后加锁操作,再去对比(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。

java中的乐观锁基本都是通过CAS操作实现的。


悲观锁

悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高。 每次去拿数据的时候都认为别人修改过了,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会阻塞,直到拿到锁。

简单说:
乐观锁则是大家一起随便读取共享变量,等到我执行完所有操作后,再给共享变量加锁,我再开始对比和写入(或者只读不写)。(适用于读多的情况,读多写少)

悲观锁在最开始读取共享变量的时候,就给共享变量加锁了,然后执行完全部操作再解锁。(适用于写多的情况)

乐观锁:大家都是好朋友,信你们比我慢,不慌
悲观锁:大家都是单身二十年的手速,苟来的,不加锁还写不了了

差别就是加锁的时机。 乐观锁的优点是阻塞比悲观锁少,缺点是没抢到锁的线程会白白执行一遍你想要的操作;悲观锁的缺点则是一个线程读取了共享变量,其他线程全部歇菜,没得读,但是却不会出现执行了一遍线程操作却拿不到锁的浪费资源情况。


import org.openjdk.jol.info.ClassLayout;

import java.util.concurrent.TimeUnit;

public class HelloJOL {
    public static void main(String[] args) {
        new HelloJOL();
    }

    public HelloJOL(){
          Object o = new Object();
          String s = ClassLayout.parseInstance(o).toPrintable();
          System.out.println(s);

        //默认四秒后启动偏向锁例子
//        BiasedLockingDelay();
		//四秒内启动,轻量级锁例子
//        LightweightLocking();


    }
/*笔记
    new 到 未启动偏向锁的普通对象: 线程启动后 四秒内 创建的普通对象
    new 到 匿名偏向锁的对象:线程启动后 四秒后 创建的对象,且没有指定线程去使用这个对象(即默认上偏向锁了,却不知上给谁)
*/
//默认四秒后启动偏向锁例子
    public void BiasedLockingDelay(){
        try {
            Object o = new Object();
            String s = ClassLayout.parseInstance(o).toPrintable();
            System.out.println(s);
            //默认四秒后启动偏向锁例子,查看对象头最低位字节(即下面打印最前面的字节)的后三位
            // 是不是从上面的(无锁态)001 变成 (偏向锁)101
            TimeUnit.SECONDS.sleep(5); //多睡一秒
            Object o2 = new Object();
            String s2 = ClassLayout.parseInstance(o2).toPrintable();
            System.out.println(s2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    //jvm启动四秒内加锁,直接加轻量级锁例子
    public void LightweightLocking(){
        Object o = new Object();
        String s = ClassLayout.parseInstance(o).toPrintable();
        System.out.println(s);
            //默认四秒后启动偏向锁例子,查看对象头最低位字节(即下面打印最前面的字节)的后三位
            // 是不是从上面的(无锁态)001 变成 后两位的(轻量级锁)00
            Object o2 = new Object();
            synchronized (o2){
                System.out.println("jvm启动四秒内加锁,直接加轻量级锁");
                String s2 = ClassLayout.parseInstance(o2).toPrintable();
                System.out.println(s2);
            }
            //如果结果是101(偏向锁),那是你机器太慢了,超过四秒,就只打印加锁的,删了对比那些代码
            //不能在同步代码外打印,因为在外面打印时线程已经结束了,o2解锁,变成无锁态了(001)
//        String s2 = ClassLayout.parseInstance(o2).toPrintable();
//        System.out.println(s2);
    }



}

偏向锁和自旋锁启动例子,点击直达
就上面那段,一样的
链接:https://pan.baidu.com/s/17QS6zG2oDzppaKbt8RiOrA
提取码:m1qh
其他的就不演示了,别问,问就是lan




如有错误,请不吝指正,学这个真是脑阔热,不如吃鸡


你可能感兴趣的:(JAVA)