前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >线程并发带来的安全性问题 之 同步锁(一)

线程并发带来的安全性问题 之 同步锁(一)

作者头像
架构探险之道
发布2023-03-04 11:01:50
2260
发布2023-03-04 11:01:50
举报
文章被收录于专栏:架构探险之道架构探险之道

线程并发带来的安全性问题 之 同步锁(一)

线程并发带来的安全性问题

  • 原子性
  • 可见性
  • 有序性

本地环境

  • jdk 11
  • gradle 6.6

本文主要介绍线程并发常见的安全性问题、锁的底层类型和对象结构的差异。

并发编程安全问题

原子性问题

在下面的案例中,演示了两个线程分别去去调用 demo.incr 方法来对 i 这个变量进行叠加,预期结果 应该是20000,但是实际结果却是小于等于20000的值。

代码语言:javascript
复制
 public class LockDemo {
    int i = 0;
    public void incr(){
        i++;
    }
    public static void main(String[] args) {
        LockDemo demo = new LockDemo();
        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);
    }
}

问题的原因

这个就是典型的线程安全问题中原子性问题的体现。那什么是原子性呢?

在上面这段代码中,count++ 是属于 Java 高级语言中的编程指令,而这些指令最终可能会有多条 CPU 指令来组成,而 count++ 最终会生成3条指令, 通过 javap -v xxx.class 查看字节码指令如下。

代码语言:javascript
复制
 public void incr();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field i:I // 访问变量i
         5: iconst_1 // 将整形常量1放入操作数栈
         6: iadd  // 把操作数栈中的常量1出栈并相加,将相加的结果放入操作数栈

         7: putfield      #2                  // Field i:I // 访问类字段(类变量),复制给LockDemo.i这个变量
        10: return
      LineNumberTable:
        line 25: 0
        line 26: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   Lcom/example/juccode/LockDemo;

这三个操作,如果要满足原子性,那么就需要保证某个线程在执行这个指令时,不允许其他线程干扰。如果未保证原子性,就会出现累加值不符合预期的情况。

本质原因

一个CPU核心在同一时刻只能执行一个线程,如果线程数量远远大于CPU核心数,就会发生线程的切换,这个切换动作可以发生在任何一条CPU指令执行完之前。

对于 i++ 这三个cpu指令来说,如果线程A在执行指令1之后,做了线程切换,假设切换到线程B,线程B 同样执行CPU指令,执行的顺序如下图所示。就会导致最终的结果是1,而不是2.

这就是在多线程环境下,存在的原子性问题,那么,怎么解决这个问题呢?

认真观察上面这个图,表面上是多个线程对于同一个变量的操作,实际上是count++这行代码,它不是原子的。所以才导致在多线程环境下出现这样一个问题。

也就是说,我们只需要保证,count++这个指令在运行期间,在同一时刻只能由一个线程来访问,就可 以解决问题。这就需要引出到今天的要介绍的内容, 同步锁 Synchronized。

同步锁 Synchronized

synchronized有三种方式来加锁,不同的修饰类型,代表锁的控制粒度

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

修饰实例方法

加 2w 次

锁当前 类的 实例对象

代码语言:javascript
复制
    public synchronized void  incr(){
        i++;
    }

修饰静态方法

加 2w 次,减 2w 次

锁的是当前的 类 对象,从输出结果不是0可以看出,静态方法锁和实例方法的锁没有同步效果

代码语言:javascript
复制
public class LockDemo3 {

    static int i = 0;

    public synchronized  static void  dec(){
        i--;
    }

    public synchronized void  incr(){
        i++;
    }

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

修饰代码块

1、对给定对象加锁,进入同步代码库前要获得给定对象的锁。

代码语言:javascript
复制
public class LockDemo4 {

    volatile int i = 0;

    public void dec() {
        synchronized (this) {
            i--;
        }
    }

    public synchronized void incr() {
        i++;
    }

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

输出为 0

2、指定锁的对象,如果是 this,等价于在方法上加 synchronized,只是代码块作用阈更小

代码语言:javascript
复制
public class LockDemo4_2 {

    volatile int i = 0;

    public synchronized void dec() {
            i--;
    }

    public synchronized void incr() {
        i++;
    }

   //...
}

输出为 0

3、指定类加锁,等价于给类的 static 方法加锁

代码语言:javascript
复制
public class LockDemo5 {

    volatile static int i = 0;

    public void dec() {
        synchronized (LockDemo5.class) {
            i--;
        }
    }

    public synchronized static void incr() {
        i++;
    }

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

输出为 0

锁的实现模型

Synchronized到底帮我们做了什么,为什么能够解决原子性呢? 在没有加锁之前,多个线程去调用incr()方法时,没有任何限制,都是可以同时拿到这个 i 的值进行 ++ 操作,但是当加了Synchronized锁之后,线程 A 和B 就由并行执行变成了串行执行。

Synchronized 的原理

Synchronized 是如何实现锁的,以及锁的信息是存储在哪里? 就拿上面分析的图来说,线程A 抢到锁了,线程B 怎么知道当前锁被抢占了,这个地方一定会有一个标记来实现,而且这个标记一定是存储在某个地方。

Markword对象头

这就要引出Markword对象头这个概念了,它是对象头的意思,简单理解,就是一个对象,在JVM内存中的布局或者存储的形式。

jdk8u: markOop.hpp

在Hotspot虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。

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

Synchronized 锁升级

Jdk1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

  • 锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。

这么设计的目的,其实是为了减少重量级锁带来的性能开销,尽可能的在无锁状态下解决线程并发问题:其中偏向锁和轻量级锁的底层实现是基于自旋锁,它相对于重量级锁来说,算是一种无锁的实现。

  • 默认情况下是偏向锁是开启状态,偏向的线程ID是0,偏向一个Anonymous BiasedLock 如果有线程去抢占锁,那么这个时候线程会先去抢占偏向锁,也就是把 markword 的线程ID 改为当前抢占锁的线程 ID 的过程
  • 如果有线程竞争,这个时候会撤销偏向锁,升级到轻量级锁,线程在自己的线程栈帧中会创建一个 LockRecord,用 CAS 操作把 markword 设置为指向自己这个线程的LR的指针,设置成功后表示抢占到锁。
  • 如果竞争加剧,比如有线程超过 10 次自旋(-XX:PreBlockSpin参数配置),或者自旋线程数超过 CPU核心数的一般,在1.6之后,加入了自适应自旋Adapative Self Spinning. JVM会根据上次竞争 的情况来自动控制自旋的时间。
  • 升级到重量级锁,向操作系统申请资源, Linux Mutex,然后线程被挂起进入到等待队列。

通过 ClassLayout 打印对象头

为了更加直观的看到对象的存储和实现,我们可以使用 JOL 查看对象的内存布局。

  • 添加 Jol 依赖
代码语言:javascript
复制
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>
  • 编写测试代码,不加锁的情况下,对象头的信息打印
代码语言:javascript
复制
public class LockDemo7 {

    static Object obj = new Object();

    public static void main(String[] args) {
        LockDemo7 demo = new LockDemo7(); // demo 这个对象,在内存中是如何存储和布局的。
        System.out.println(ClassLayout.parseInstance(demo).toPrintable());
        System.out.println("-----------");
        synchronized(demo){
            System.out.println(ClassLayout.parseInstance(demo).toPrintable());
        }
    }
}
  • JDK11 输出内容

101 第一位为 1 表示偏向锁

代码语言:javascript
复制
com.example.juccode.LockDemo7 object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 00 00 00 (00000[101] 00000000 00000000 00000000) (5)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           40 70 06 00 (01000000 01110000 00000110 00000000) (421952)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

-----------
com.example.juccode.LockDemo7 object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 28 80 1d (00000[101] 00101000 10000000 00011101) (494938117)
      4     4        (object header)                           9c 7f 00 00 (10011100 01111111 00000000 00000000) (32668)
      8     4        (object header)                           40 70 06 00 (01000000 01110000 00000110 00000000) (421952)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
  • JDK8 输出内容
  • 加锁后:其中在前4个字节中,第一个字节最后三位都是[000], 后两位00表示轻量级锁,第一位为[0],表示当前不是偏向锁状态;
  • 加锁前:默认 001,表示无锁。
代码语言:javascript
复制
# WARNING: Unable to attach Serviceability Agent. You can try again with escalated privileges. Two options: a) use -Djol.tryWithSudo=true to try with sudo; b) echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
com.example.juccode.LockDemo7 object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
// 在未加锁之前,对象头中的第一个字节最后三位为 [001], 其中最后两位 [01]表示无锁,第一位[0]也 表示无锁
   0     4        (object header)                           01 00 00 00 (00000[001] 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

-----------
// 下面部分是加锁之后的对象布局变化
// 其中在前4个字节中,第一个字节最后三位都是[000], 后两位00表示轻量级锁,第一位为[0],表示当前不是偏向锁状态。
com.example.juccode.LockDemo7 object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           f0 58 f4 03 (11110[000] 01011000 11110100 00000011) (66345200)
      4     4        (object header)                           00 70 00 00 (00000000 01110000 00000000 00000000) (28672)
      8     4        (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

锁的升级是基于线程竞争情况,如何实现从 偏向锁 到 轻量级锁 再到 重量级锁 的升级的? 为什么这里明明没有竞争,它的锁的标记是轻量级锁呢?

下一节我们继续...

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-12-19,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 架构探险之道 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 线程并发带来的安全性问题 之 同步锁(一)
  • 并发编程安全问题
    • 原子性问题
      • 同步锁 Synchronized
        • 修饰实例方法
        • 修饰静态方法
        • 修饰代码块
      • 锁的实现模型
      • Synchronized 的原理
        • Markword对象头
          • Synchronized 锁升级
            • 通过 ClassLayout 打印对象头
            相关产品与服务
            对象存储
            对象存储(Cloud Object Storage,COS)是由腾讯云推出的无目录层次结构、无数据格式限制,可容纳海量数据且支持 HTTP/HTTPS 协议访问的分布式存储服务。腾讯云 COS 的存储桶空间无容量上限,无需分区管理,适用于 CDN 数据分发、数据万象处理或大数据计算与分析的数据湖等多种场景。
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档