并发编程之同步锁(上)

并发编程安全三大问题

原子性(Synchronized, AtomicXXX、Lock可以解决)

涉及到共享变量访问的操作,若该操作从执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,该操作具有原子性。即,其它线程不会“看到”该操作执行了部分的中间结果

老掉牙的代码

public class Demo {
    int i = 0;
    public void incr(){
        i++;
    }
    public static void main(String[] args) {
        Demo demo = new Demo();
        Thread[] threads=new Thread[2];
        for (int j = 0;j<2;j++) {
            threads[j]=new Thread(() -> { // 创建两个线程
                for (int k=0;k<10000;k++) { // 每个线程跑10000次
                    demo.incr();
                }
            });
            threads[j].start();
        }
        try {
            threads[0].join();
            threads[1].join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(demo.i);
    }
}

在这里插入图片描述
输出不是2000就是因为i++不是原子操作

在target的字节码用终端打开通过javap -v Demo.class 查看字节码指令如下。
注意看我的注释

 public void incr();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2         // 访问变量i
         5: iconst_1                 // 将整形常量i放入操作数栈
         6: iadd                     // 把操作数栈中的常量i出栈并相加,将相加的结果放入操作数栈
         7: putfield      #2         // 写回内存,即访问类字段(类变量),复制给Demo.count这个变量
        10: return
      LineNumberTable:

图解

注意:这张图的++是原子操作,不是代码里面的i++这种了。
一个CPU核心在同一时刻只能执行一个线程,如果线程数量远远大于CPU核心数,就会发生线程的切换,这个切换动作可以发生在任何一条CPU指令执行完之前。
在这里插入图片描述
执行的顺序如图所示。就会导致最终的结果是1,而不是2。

提前预告volatile也解决不了这个问题,还是因为原子性

  • 如果是上图这个顺序,那么volatile可以解决,因为写回内存后,另一个线程切换回来在count++之前会被通知count已经更新了,需要重新取。
  • 但是如果是这个顺序,当右边的count++执行完后但还未写回内存时,又切换会左边的线程了,那么左边的线程仍然不知道count已经更新了,它继续执行++写回内存,右边的线程这时会收到count更新了,需要重新取,然后就去内存重新取count到寄存器中,但是因为已经执行了++操作了,所以不会再执行++了,右边的线程也直接写入内存了,所以结果还是1

可见性(Synchronized,volatile可以解决)

有序性 (Synchronized,volatile可以解决)

Synchronized的基本应用

  • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  • 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

Synchronized的原理

由这种代码我们不难想象到是通过修改对象里面的信息来获得锁的

 synchronized (object){        
 }

对象头

在这里插入图片描述

  • mark-word对象标记字段占8个字节,用于存储一些列的标记位,比如:哈希值、轻量级锁的标
    记位,偏向锁标记位、分代年龄等。
  • 类元信息:Class对象的类型指针,Jdk1.8默认开启指针压缩后为4字节,关闭指针压缩( - XX:-UseCompressedOops )后,长度为8字节。其指向的位置是对象对应的Class对象(其对应的
    元数据对象)的内存地址。即:类的成员变量的指针(因为有些成员变量又是类嘛)
  • 实例数据:包括对象的所有成员变量,大小由各个成员变量决定,比如:byte占1个字节8比
    特位、int占4个字节32比特位。
  • 对齐:最后这段空间补全并非必须,仅仅为了起到占位符的作用。由于HotSpot虚拟机的内存管理
    系统要求对象起始地址必须是8字节的整数倍
    ,所以对象头正好是8字节的倍数。因此当对象实例
    数据部分没有对齐的话,就需要通过对齐填充来补全。

通过ClassLayout打印对象头

引入依赖

<dependency> 
<groupId>org.openjdk.jol</groupId> 
<artifactId>jol-core</artifactId> 
<version>0.9</version> 
</dependency>
public class ClassLayoutDemo {
    Object o=new Object();
    public static void main(String[] args) {
        ClassLayoutDemo classLayoutDemo=new ClassLayoutDemo();
        System.out.println(ClassLayout.parseInstance(classLayoutDemo).toPrintable());
    }
}

在这里插入图片描述
解析
前面三行是对象头
一二行是mark-word(对象标记)
三行是类元指针
最后一行是实例数据
这个例子没有对齐填充,因为8+4+4已经是8的整数倍了也可以看到最后一行标注了丢失的空间为:0+0个字节,说明没有用额外的空间来填充

如果不要object
在这里插入图片描述
可以清晰地看到最后标注了丢失的空间为:0+4,说明多用了4个字节的空间来填充

对齐填充

64位处理器(即CPU)一次读取的数据最小工作行(缓存行)是8个字节

例:32位
4个字节起始地址刚好就在CPU读取的地址处,这种情况下,CPU可以一次就把这个指令读出
在这里插入图片描述
而当4个字节按照如下图所示分布时
在这里插入图片描述
假设CPU还在同一个地址取数据,则本次操作会进行两次内存读取才能读完想要的,相较第一种直接取出多了一次操作。CPU本来就会做大量的数据运算和操作,如果遇到这种情况很多的话,CPU将做出很多的“多余操作”,严重影响性能。所以就需要对齐填充,让CPU尽量只取一次

此外如果是volatile的情况,CPU读取到不必要的数据,还会容易缓存失效,频繁去内存重新取不需要的数据

  • 例:没有对齐填充时,A线程只想要读取a,但a只占4字节,因此会连带b也会一并读取到缓存行中;
  • 同一时刻B线程只想要读取b,但b只占4字节,因此会连带a也会一并读取到缓存行中;那么这种情况下就会存在伪共享问题,这会导致A和B线程在进行数据变更并读入到内存时CPU会频繁地直接去内存获取最新值,导致性能下降。

锁的状态

重量级锁的代价

  • 用户态和内核态的切换
  • 没有获得锁的线程又会继续被阻塞,又是用户态和内核态的切换

锁的优化

  • Jdk1.6对锁的实现引入了大量的优化,如自旋锁适应性自旋锁锁消除锁粗化偏向锁轻量级锁等技术来减少锁操作的开销。
  • 锁主要存在四中状态,依次是:无锁状态偏向锁状态轻量级锁状态重量级锁状态,他们会随着竞争的激烈而逐渐升级。
  • 设计的目的是为了减少重量级锁带来的性能开销尽可能的在无锁状态下解决线程并发问题,其中偏向锁轻量级锁的底层实现是基于自旋锁,它相对于重量级锁来说,算是一种无锁的实现。
    在这里插入图片描述
    解释流程
  • 一个A线程第一次直接顺利进入同步块,那么这里线程A就把对象的mark-word信息改为与它有关的了,就形成了偏向锁了。后面A线程再进来,就只需要对比一下了,而不需要再次修改mark-word信息了
  • 如果这时B线程过来了,且A没执行完,那么就会升级为轻量级锁了(即B自旋一会,等A执行完)。如果A执行完了,B线程过来,那么锁就重新偏向B(即B去修改mark-word信息标识为是它的了)。
  • 如果B自旋的时间或次数超过我们规定的,那么就会升级到重量级锁了。

轻量级锁即线程在自己的线程栈帧中会创建一个LockRecord,用CAS操作markword设置为指向自己这个线程的LR的指针设置成功后表示抢占到锁。伪代码如下

for(;;){
if(condition)break;//控制自旋条件,不满足就不能再自旋了,升级为重量级
CAS操作
}

根据流程不难发现思想

  • 轻量级锁就是为了避免线程阻塞(即内核态和用户态之间切换的开销)而优化的,轻量级锁就是自旋操作,但如果自旋也有限制,太久也不行,因为自旋太久的话,相当于代价已经超过内核态和用户态之间切换的开销了。就需要使用代价更小的重量级锁了

一般,线程超过10次自旋(-XX:PreBlockSpin参数配置),或者自旋线程数超过CPU核心数的一半就会升级为重量级锁,在1.6之后,加入了自适应自旋Adapative Self Spinning. JVM会根据上次竞争的情况来自动控制自旋的时间。

重量级锁是通过ObjectMonitor来实现的

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值