并发编程中的原子性问题及Synchronized的基本原理

原子性问题

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

public class ThreadTest04 {
    private int count= 0;

    public void add() {
       count++;
    }

    public static void main(String[] args) throws InterruptedException {
        ThreadTest04 threadTest04 = new ThreadTest04();
        Thread threads[] = new Thread[2];
        //创建两个子线程,每个子线程循环1000次进行加法
        for (int j = 0; j < 2; j++) {
            threads[j] = new Thread(() -> {
                for (int c = 0; c < 10000; c++) {
                    threadTest04.add();
                }
            });
        }
        for (int j = 0; j < 2; j++) {
            threads[j].start();
        }
        threads[0].join();//暂时阻塞主线程获取cpu执行权
        threads[1].join();
        System.out.println("执行后的结果----" + threadTest04.count);
    }

}

结果:

执行后的结果----13247

Process finished with exit code 0

问题的本质

这个就是典型的线程安全问题中原子性问题的体现。那什么是原子性呢?
在上面这段代码中,count++是属于Java高级语言中的编程指令,而这些指令最终可能会有多条CPU指令来组成,而count++最终会生成3条指令, 通过javap -v xxx.class 查看字节码指令如下。

 public void add();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field count:I 访问变量count
         5: iconst_1   // 将整形常量1放入操作数栈
         6: iadd          // 把操作数栈中的常量1出栈并相加,将相加的结果放入操作数栈
         7: putfield      #2                  // Field count:I  / 访问类字段(类变量),复制给 threadTest04.count这个变量
        10: return
      LineNumberTable:
        line 17: 0
        line 18: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   Lcom/demo/ThreadTest04;

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

图解问题本质

一个CPU核心在同一时刻只能执行一个线程,如果线程数量远远大于CPU核心数,就会发生线程的切换,这个切换动作可以发生在任何一条CPU指令执行完之前。对于 count++ 这三个cpu指令来说,如果线程A在执行指令1之后,做了线程切换,假设切换到线程B,线程B同样执行CPU指令,像线程A一样,将count=0加载到寄存器,同时count++,并且写入到内存,这个时候,count=1。但是如果这个时候,cpu时间片又切回到线程A,A会继续从上一次中断的位置继续执行,这个位置上,加载出的count=0,所以count++,count最后还是等于1,而实际上count自加了两次。
在这里插入图片描述
这就是在多线程环境下,存在的原子性问题,那么,怎么解决这个问题呢?
其实在多线程下的原子性,主要是指:一个操作是不可中断的。即使是多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
也就是说,我们只需要保证,count++这个指令在运行期间,在同一时刻只能由一个线程来访问,就可以解决上述的问题。解决上面的问题,一般可以使用Synchronized,原子类AtomicXXX、Lock等方法来解决。今天主要讲一下Synchronized。

Synchronized的基本应用

在上述代码上,只要将add方法加上synchronized修饰,就能够输出预期值20000

public class ThreadTest04 {
    private int count = 0;

    public synchronized void add() {
        count++;
    }

    public static void main(String[] args) throws InterruptedException {
        ThreadTest04 threadTest04 = new ThreadTest04();
        Thread threads[] = new Thread[2];
        //创建两个子线程,每个子线程循环1000次进行加法
        for (int j = 0; j < 2; j++) {
            threads[j] = new Thread(() -> {
                for (int c = 0; c < 10000; c++) {
                    threadTest04.add();
                }
            });
        }
        for (int j = 0; j < 2; j++) {
            threads[j].start();
        }
        threads[0].join();//暂时阻塞主线程获取cpu执行权
        threads[1].join();
//        TimeUnit.SECONDS.sleep(100);
        System.out.println("执行后的结果----" + threadTest04.count);
    }

}

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

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

锁的实现模型理解

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

Synchronized的原理

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

Markword对象头

这就要引出Markword对象头这个概念了,它是对象头的意思,简单理解,就是一个对象,在JVM内存中的布局或者存储的形式。
在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,然后线程被挂起进入到等待队列。

偏向锁和轻量级锁就是为了达到线程安全性和程序效率之间的平衡,减少为了减少重量级锁带来的性能开销。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值