• 并发编程带来的安全性挑战之同步锁


    如果多个线程在做同一件事情,或者共享同一个资源变量时,就会产生线程安全星问题,此时就需要保证三要素:

    • 原子性:指的是一个或者多个操作,要么全部执行并且在执行的过程中不能被其他操作打断,要么全部不执行。也不存在上下文切换,线程切换会带来原子性问题。
    • 可见性:指的是多个线程操作一个共享变量时,其中一个线程对变量进行修改后,其他线程可以立即看到修改的结果。
    • 有序性:程序执行的顺序按照代码的先后顺寻执行,因为处理器看能会对指令进行重排序。jvm在编译java代码或者cpu执行jvm字节码时,对现有的指令进行重新排序,主要目的是优化运行效率(前提是不改变程序结果)

    原子性问题

    用一个案例来说明原子性问题,在下面的案例中,我们创建了两个线程,都去调用incr()方法来对i这个变量进行叠加,每个线程都调用10000次,预期的结果是20000,但是实际结果却是小于20000的值

    1. /**
    2. * 原子性问题
    3. */
    4. public class StomicityDemo {
    5.    int i = 0;
    6.    public void incr() {
    7.        i++;
    8.   }
    9.    public static void main(String[] args) {
    10.        StomicityDemo demo = new StomicityDemo();
    11.        Thread[] threads = new Thread[2];
    12.        for (int j = 0; j < threads.length; j++) {
    13.            // 创建两个线程
    14.            threads[j] = new Thread(()->{
    15.                // 每个线程跑10000
    16.                for (int i = 0; i < 10000; i++) {
    17.                    demo.incr();
    18.               }
    19.           });
    20.            threads[j].start();
    21.       }
    22.        try {
    23.            threads[0].join();
    24.            threads[1].join();
    25.       } catch (InterruptedException e) {
    26.            e.printStackTrace();
    27.       }
    28.        System.out.println(demo.i);
    29.   }
    30. }
    31. 复制代码

    问题的原因

    这个就是典型的线程安全问题中原子性问题的体现,就如原子性的定义说的,当一个线程在执行的过程中不被其他线程打断,我们得到的值应该就是20000,但是现在小于20000,说明其中一个线程在执行的过程,被其他线程打断了,导致结果不是预期值,具体是怎么被打断的呢?

    在上面的代码中,i++是属于java高级语言中的编程指令,而这些指令最终可能会有多条cpu指令来组成,我们通过javap -v StomicityDemo.class查看字节码指令如下:

    1. public incr()V
    2. L0
    3. LINENUMBER 13 L0
    4. ALOAD 0
    5. DUP
    6. GETFIELD zhl.thread.threadDemo1/StomicityDemo.i : I // 访问变量i
    7. ICONST_1 // 将整形常量1放入操作数栈
    8. IADD // 把操作数栈中的常量1出栈并相加,将相加的结果放入操作数栈
    9. PUTFIELD zhl.thread.threadDemo1/StomicityDemo : I // 访问类字段(类变量),复制给Demo.i这个变量
    10. 复制代码

    这三个操作,如果满足原子性,那么就需要保证某个线程在执行这个指令时,不允许其他线程干扰,然而实际上却存在这个问题

    图解问题本质

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

    对于i++这三个cpu指令来说,如果线程A先拿到执行权限,执行指令GETFIELD将i=0加载到寄存器中后,做了线程切换,假设切换到了线程B,此时线程B同样执行指令GETFIELD将i=0加载到寄存器中,然后线程B按下图顺序执行剩余指令,执行完后再切换到线程A中,此时线程A接着之前未执行完的指令继续往下执行,就会导致最终的结果时1,而不是2.

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

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

    也就是说我们只要保存,i++这个指令在运行期间,在同一时刻只能由一个线程来访问,就可以解决这个问题。可以加Synchronized这个关键字来解决。

    Synchronized的基本应用

    Synchronized由三种方式来实现加锁,不同的修饰类型,代表锁的控制粒度。

    1. 修饰实力方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁。

      1. /**
      2. * Synchronized修饰实例方法
      3. */
      4. public class SynchronizedDemo {
      5.    // 共享资源
      6.    static int num = 0;
      7.    // 修饰实力方法
      8.    public synchronized void incr() {
      9.        num++;
      10.   }
      11.    public static void main(String[] args) throws InterruptedException {
      12.        SynchronizedDemo demo1 = new SynchronizedDemo();
      13.        SynchronizedDemo demo2 = new SynchronizedDemo();
      14.        Thread t1 = new Thread(()->{
      15.            for (int i = 0; i < 10000; i++) {
      16.                demo1.incr();
      17.           }
      18.       });
      19.        Thread t2 = new Thread(()->{
      20.            for (int i = 0; i < 10000; i++) {
      21.                demo2.incr();
      22.           }
      23.       });
      24.        t1.start();
      25.        t2.start();
      26.        t1.join();
      27.        t2.join();
      28.        System.out.println(num);
      29.   }
      30. }
      31. 复制代码

      以上代码num的输出小于20000,虽然使用了synchronized修改了incr()方法,但是是通过不同的实例demo1和demo2来调用的incr()方法,这就意味着存在两个不同实例的对象锁,因此t1和t2都会进入各自的对象锁,也就是说t1和t2使用的是不同的对象锁,因此线程安全是无法保证的。

      1. /**
      2. * Synchronized修饰实例方法
      3. */
      4. public class SynchronizedDemo1 {
      5.    // 共享资源
      6.    static int num = 0;
      7.    // 修饰实例方法
      8.    public synchronized void incr() {
      9.        num++;
      10.   }
      11.    public static void main(String[] args) throws InterruptedException {
      12.        SynchronizedDemo1 demo = new SynchronizedDemo1();
      13.        Thread t1 = new Thread(()->{
      14.            for (int i = 0; i < 10000; i++) {
      15.                demo.incr();
      16.           }
      17.       });
      18.        Thread t2 = new Thread(()->{
      19.            for (int i = 0; i < 10000; i++) {
      20.                demo.incr();
      21.           }
      22.       });
      23.        t1.start();
      24.        t2.start();
      25.        t1.join();
      26.        t2.join();
      27.        System.out.println(num);
      28.   }
      29. }
      30. 复制代码

      以上代码num的输出结果为20000,synchronized修饰了incr方法,线程t1和t2在执行时,是通过同一个实例对象demo来调用的incr方法,两个线程调用的是同一实例锁,其中一个线程拿到锁后另一个线程无法进入到实例方法,保证了线程的安全性问题。

      1. 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁。

        1. **
        2. * Synchronized修饰实例方法
        3. */
        4. public class SynchronizedDemo2 {
        5.    // 共享资源
        6.    static int num = 0;
        7.    // 作用于静态方法
        8.    public static synchronized  void incr() {
        9.        num++;
        10.   }
        11.    public static void main(String[] args) throws InterruptedException {
        12.        Thread t1 = new Thread(()->{
        13.            for (int i = 0; i < 10000; i++) {
        14.                incr();
        15.           }
        16.       });
        17.        Thread t2 = new Thread(()->{
        18.            for (int i = 0; i < 10000; i++) {
        19.                incr();
        20.           }
        21.       });
        22.        t1.start();
        23.        t2.start();
        24.        t1.join();
        25.        t2.join();
        26.        System.out.println(num);
        27.   }
        28. }
        29. 复制代码

        当synchronized作用于静态方法时,其锁就是当前类的class对象锁。由于静态成员不属于任何一个实例对象,是类成员,因此通过class对象锁可以控制静态成员的并发操作。

        1. 修饰代码块,指定加锁对象,对给定对象锁,进入同步代码库前要获得给定的对象锁。
    1. /**
    2. * Synchronized修饰代码块
    3. */
    4. public class SynchronizedDemo1 {
    5.    // 共享资源
    6.    static int num = 0;
    7.    // 修饰代码块
    8.    public void incr() {
    9.        // this,当前实例对象锁
    10. //       synchronized (this){
    11. //           num++;
    12. //       }
    13.        // class对象锁
    14.        synchronized (SynchronizedDemo1.class){
    15.            num++;
    16.       }
    17.   }
    18.    public static void main(String[] args) throws InterruptedException {
    19.        SynchronizedDemo1 demo1 = new SynchronizedDemo1();
    20.        SynchronizedDemo1 demo2 = new SynchronizedDemo1();
    21.        Thread t1 = new Thread(()->{
    22.            for (int i = 0; i < 10000; i++) {
    23.                demo1.incr();
    24.           }
    25.       });
    26.        Thread t2 = new Thread(()->{
    27.            for (int i = 0; i < 10000; i++) {
    28.                demo2.incr();
    29.           }
    30.       });
    31.        t1.start();
    32.        t2.start();
    33.        t1.join();
    34.        t2.join();
    35.        System.out.println(num);
    36.   }
    37. }
    38. 复制代码

    如果传入的是this,表示修饰的是当前实例对象锁,如果两个不同的对象demo1和demo2调用incr方法,存在线程安全问题

    如果我们传入的是SynchronizedDemo1.class,表示class对象锁,此时两个不同的对象demo1和demo2调用incr方法,如果一个线程拿到锁,另一个线程就需要等待,不存在线程安全问题。

    当前我们也可以传入其他,比如new一个新的对象等等。

    1. // 实例锁
    2. Object object = new Object();
    3. synchronized (object){
    4.    num++;
    5. }
    6. // class
    7. synchronized(Lock.class){
    8.    num++;
    9. }
    10. 复制代码

    总之,是否能保证线程的原子性,主要取决于synchronized的作用范围。

    锁的实现模型理解

    synchronized到底帮我们做了什么,为什么能解决原子性。

    在没有加锁之前,多个线程去调用incr()方法时,没有任何限制,都是可以同时拿到这个i值进行++操作的,但是当加了synchronized锁之后,线程A和B就由并行执行变成了串行执行。

    synchronized的原理

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

    synchronized加锁在对象上,锁标记就在这个对象的对象头(markword)中。

    Markword对象头

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

    1. 对象头:java对象头一般占用两个机器码(在32位虚拟机中,1个机器码等于4个字节,也就是32bit,在64位虚拟机中,1个机器码时8个字节,也就是64bit),但是如果对象是数组类型,则需要3个机器码,因为jvm虚拟机可以通过java对象的元数据信息确定java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组的长度。
    2. 实例数据:存放类的属性数据信息,包括父类的属性信息。大小由各个成员变量来决定,比如:byte占1个字节8个bit,int占4个字节32bit。
    3. 对其填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了自己对齐。

    通过ClassLayout打印对象头

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

    • 添加jol依赖

      1. <dependency>
      2.    <groupId>org.openjdk.jol</groupId>
      3.    <artifactId>jol-core</artifactId>
      4.    <version>0.9</version>
      5. </dependency>
      6. 复制代码
    • 编写单元测试,在不加锁的情况下,对象头信息打印

      1. public class LayoutDemo {
      2.    Object o = new Thread();
      3.    public static void main(String[] args) {
      4.        LayoutDemo layoutDemo = new LayoutDemo();
      5.        System.out.println(ClassLayout.parseInstance(layoutDemo).toPrintable());
      6.   }
      7. }
      8. 复制代码
    • 输入内容如下

    1. zhl.thread.threadDemo1.LayoutDemo object internals:
    2. OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
    3.      0     4                   (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
    4.      4     4                   (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
    5.      8     4                   (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
    6.     12     4   java.lang.Object LayoutDemo.o                             (object)
    7. Instance size: 16 bytes
    8. Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
    9. 复制代码

    关于synchronized锁的升级

    在jdk1.5之前一直是一种重量级锁的状态,也就是每次加锁和释放锁都直接和操作系统打交道。

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

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

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

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

    轻量级锁的获取及原理

    接下来,我们通过下面的例子来演示一下,通过加锁之后继续打印对象布局信息,来关注对象头里面的变化。

    1. public class LayoutDemo {
    2.    Object o = new Thread();
    3.    public static void main(String[] args) {
    4.        LayoutDemo layoutDemo = new LayoutDemo();
    5.        System.out.println(ClassLayout.parseInstance(layoutDemo).toPrintable());
    6.        synchronized (layoutDemo){
    7.            System.out.println(ClassLayout.parseInstance(layoutDemo).toPrintable());
    8.       }
    9.   }
    10. }
    11. 复制代码

    得到的对象布局信息如下,对照上面markword状态变化图

    1. // 在未加锁之前,对象头中的第一个字节最后三位为 [001], 其中最后两位 [01]表示无锁,第一位[0]也表示无锁
    2. zhl.thread.threadDemo1.LayoutDemo object internals:
    3. OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
    4.      0     4                   (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
    5.      4     4                   (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
    6.      8     4                   (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
    7.     12     4   java.lang.Object LayoutDemo.o                             (object)
    8. Instance size: 16 bytes
    9. Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
    10. // 下面部分是加锁之后的对象布局变化
    11. // 其中在前4个字节中,第一个字节最后三位都是[000], 后两位00表示轻量级锁,第一位为[0],表示当前不是偏向锁状态。
    12. zhl.thread.threadDemo1.LayoutDemo object internals:
    13. OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
    14.      0     4                   (object header)                           c8 f2 7f 56 (11001000 11110010 01111111 01010110) (1451225800)
    15.      4     4                   (object header)                           0c 00 00 00 (00001100 00000000 00000000 00000000) (12)
    16.      8     4                   (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
    17.     12     4   java.lang.Object LayoutDemo.o                             (object)
    18. Instance size: 16 bytes
    19. Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
    20. 复制代码

    按上面说的,锁的升级是基于线程竞争情况,来实现从偏向锁到轻量级锁再到重量级锁的升级,但是此处明明没有线程的竞争,为什么是轻量级锁呢?

    偏向锁的获取及原理

    默认情况下,偏向锁的开启是有延迟的,默认是4秒。这么设计的目的是因为jvm虚拟机自己有一些默认的启动线程,这些线程里面有很多的synchronized代码,这些synchronized代码启动的时候就会出发竞争,如果使用偏向锁,就会造成偏向锁不断进行锁升级和撤销,效率低下。

    通过这个jvm参数可以将延迟设置为0

    -XX:BiasedLockingStartupDelay=0

    再次运行下面代码

    1. public class LayoutDemo {
    2.    Object o = new Thread();
    3.    public static void main(String[] args) {
    4.        LayoutDemo layoutDemo = new LayoutDemo();
    5.        System.out.println(ClassLayout.parseInstance(layoutDemo).toPrintable());
    6.        synchronized (layoutDemo){
    7.            System.out.println(ClassLayout.parseInstance(layoutDemo).toPrintable());
    8.       }
    9.   }
    10. }
    11. 复制代码

    得到如下的对象布局,可以看到对象头中的高位第一个字节最后三位数为【101】,表示当前为偏向锁。

    这里的第一个对象和第二个对象的锁状态都是101,是因为偏向锁打开状态下,默认会有配置匿名的对象获取偏向锁

    1. zhl.thread.threadDemo1.LayoutDemo object internals:
    2. OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
    3.      0     4                   (object header)                           01 00 00 00 (00000101 00000000 00000000 00000000) (1)
    4.      4     4                   (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
    5.      8     4                   (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
    6.     12     4   java.lang.Object LayoutDemo.o                             (object)
    7. Instance size: 16 bytes
    8. Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
    9. zhl.thread.threadDemo1.LayoutDemo object internals:
    10. OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
    11.      0     4                   (object header)                           c8 f2 7f 56 (00000101 00110000 01001010 00000011) (1451225800)
    12.      4     4                   (object header)                           0c 00 00 00 (00001100 00000000 00000000 00000000) (12)
    13.      8     4                   (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
    14.     12     4   java.lang.Object LayoutDemo.o                             (object)
    15. Instance size: 16 bytes
    16. Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
    17. 复制代码

    重量级锁的获取

    在竞争激烈的情况下,线程一直无法获得锁得情况下,就会升级到重量级锁。

    通过两个线程来模拟竞争得场景

    1. public class LayoutDemo {
    2.    public static void main(String[] args) {
    3.        LayoutDemo layoutDemo = new LayoutDemo();
    4.        Thread t1 = new Thread(()->{
    5.            synchronized (layoutDemo){
    6.                System.out.println("t1 lock ing");
    7.                System.out.println(ClassLayout.parseInstance(layoutDemo).toPrintable());
    8.           }
    9.       });
    10.        t1.start();
    11.        synchronized (layoutDemo){
    12.            System.out.println("main lock ing");
    13.            System.out.println(ClassLayout.parseInstance(layoutDemo).toPrintable());
    14.       }
    15.   }
    16. }
    17. 复制代码

    从结果可以看出,在竞争的情况下锁的标记为 [010] ,其中所标记 [10]表示重量级锁

    1. main lock ing
    2. zhl.thread.threadDemo1.LayoutDemo object internals:
    3. OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
    4.      0     4       (object header)                           7a c6 a0 a1 (01111010 11000110 10100000 10100001) (-1583298950)
    5.      4     4       (object header)                           fd 01 00 00 (11111101 00000001 00000000 00000000) (509)
    6.      8     4       (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
    7.     12     4       (loss due to the next object alignment)
    8. Instance size: 16 bytes
    9. Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    10. t1 lock ing
    11. zhl.thread.threadDemo1.LayoutDemo object internals:
    12. OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
    13.      0     4       (object header)                           7a c6 a0 a1 (01111010 11000110 10100000 10100001) (-1583298950)
    14.      4     4       (object header)                           fd 01 00 00 (11111101 00000001 00000000 00000000) (509)
    15.      8     4       (object header)                           05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
    16.     12     4       (loss due to the next object alignment)
    17. Instance size: 16 bytes
    18. Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    19. 复制代码

    CAS

    CAS这个在synchronized底层用的非常多,他的全称有两种

    • Compare and swap
    • Compare and exchange

    就是比较并交换的意思。它可以保证在多线程环境下对于一个变量修改的原子性。

    CAS的原理很简单,包含三个值当前内存值(V)、预期原来的值(E)以及期待更新的值(N)。

  • 相关阅读:
    对rust语言的一些理解
    安卓基础知识:Context解析
    JS篇章高频面试题【2023】
    金融科技论文D部分
    【LeetCode】118. 杨辉三角 - Go 语言题解
    ACFS文件系统系统重启后权限不能保持的问题
    一个网络空间安全的小游戏
    hive 中正则表表达式使用
    Git常用命令
    Android 系统开发人员的权限说明文档
  • 原文地址:https://blog.csdn.net/m0_71777195/article/details/126602495