Java线程安全问题原理以及解决方案

一、线程存在哪些安全性问题

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

1.可见性问题出现的原因

  • 带有高速缓存的 CPU 执行计算的流程

    1. 程序以及数据被加载到主内存
    2. 指令和数据被加载到CPU的高速缓存
    3. CPU执行指令,把结果写到高速缓存
    4. 高速缓存中的数据写回主内存
  • 因为存在CPU 高速缓存, 会出现缓存一致性问题,缓存一致性协议 (MESI), 因此会导致可见性问题

    M(Modify) – 修改

    E (Exclusive) – 独享、互斥

    S(Shared) – 共享的

    I(Invalid)– 无效的

2. JMM内存模型

是一种规范。用来解决对线程状态下, 通过共享内存进行通信的时候,存在本地内存数据不一致性的问题

  • 可见性导致的原子性问题

    在Java线程和工作内存的交互时,有可能导致两个线程同时修改一个变量时导致的问题,这称作变量可见性导致的原子性问题

  • 有序性

    • 编译器的执行重排序
    • 处理器的指令重排序
    • 内存系统的重排序

3. JMM 如何解决原子性、可见性、有序性的问题

volatile/ synchronized / final / juc

  • 原子性如何解决

    syschronized (monitorenter / monitorexit)

  • 可见性如何解决

    volatile、synchronzied、final

  • 有序性如何解决

    你volatile 、synchronzied

二、解决线程安全问题

1. volatile

  • 轻量级的所,解决了可见性(lock缓存锁)、防止指令重拍排(内存屏障)

  • 从CPU层面来了解什么是内存屏障

    1. store barrier (写屏障storestore barrier)

      强制所有在storestore 内存屏障之前的所有指令先执行、发送缓存失效的信号;

      所有在storestore 内存屏障指令之后的store指令,必须在storestore 内存屏障之前的指令执行完再执行

    2. load barrirer (读屏障loadload barrier)

      强制所有在loadload 内存屏障之前的所有指令先执行。

    3. full barrier (全屏障 storeload barrier)

      当 上一条指令为 store, 下一条指令为load 时,可以插入fullbarrier,防止load 先于store执行导致结果不正确。

  • 编译器层面如何解决指令重排序的问题

    1. storestore barrier 写写之间
    2. loadload barrier 读读之间
    3. storeload barrier 写读之间
    4. loadstore barrier 读写之间

2. synchronized

  • 可以保证 原子性、可见性、有序性

  • synchronized 怎么实现锁

    1. 添加一个 访问标志位: ACC_SYNCHRONIZED
    2. synchronized 包括的代码块 的前后 在字节码层面添加 monitorentermonitorexit
  • 为什么任何一个对象都可以成为锁

    1. 普通对象

      • 对象头(markword):存储锁的信息,GC信息,比如使用synchronized 对这个对象加锁,占用 8个字节
      • 类型指针(class pointer): 指向哪个类, 开启压缩后4个字节,否则8个字节
      • 实例数据(instance data):成员变量
      • 对齐(padding) 将字节补齐到能被8整除
    2. 锁的升级过程: 线程竞争加剧,才会出现锁升级过程

      1. 偏向锁

        在大多数情况下,锁不仅不存在竞争,并且大多数情况下会由同一个线程获得, 因此,可以引入偏向锁。

        偏向锁,指的就是,把markword的线程ID改为自己线程ID的过程,下次再需要访问时直接访问不需要重新获取锁。
        偏向锁不可重偏向 批量偏向 批量撤销

      2. 轻量级锁(自旋锁, 自适应自旋锁)

        如果有线程竞争,撤销偏向锁,升级轻量级锁
        线程在自己的线程栈生成LockRecord ,用CAS操作将markword设置为指向自己这个线程的LR的指针,设置成功者得到锁

      3. 重量级锁

        竞争加剧:有线程超过10次自旋, -XX:PreBlockSpin, 或者自旋线程数超过CPU核数的一半, 1.6之后,加入自适应自旋 Adapative Self Spinning , JVM自己控制
        升级重量级锁:-> 向操作系统申请资源,linux mutex , CPU从3级-0级系统调用,线程挂起,进入等待队列,等待操作系统的调度,然后再映射回用户空间

      4. 注意:JDK8 默认的对象头是无锁,JDK11默认是偏向锁

  • synchronized的可重入怎么实现

    每个锁关联一个线程持有者和一个计数器。当计数器为0时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应方法。当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个synchronized方法/块时,计数器会递减,如果计数器为0则释放该锁。

三、面试总结

1. 说说volatile
  • 保证线程可见性,防止指令重排序
  • 在字节码层面使用 #lock 指令(缓存锁)实现缓存一致性(MESI)
  • 使用内存屏障防止指令重排序(操作系统层面:3种, 编译器层面4种)
  • 使用场景
    • 通过标志变量 关闭线程
    • JUC中大量使用了volatile
2. wait 或者 notify 为什么要先获取锁
  • 当调用wait() 方法时,会将线程 放入 WaitSet 等待队列中,并调用park() 方法,并释放锁。
  • 当调用notify() 方法时, 同样会将线程 从 WaitSet 等待队列取出中,但并不会释放锁, 必须要等 notify() 所在线程执行完 synchronized(obj)块中的所有代码才会释放这把锁。
  • yield(), sleep()不会释放锁。
3. Java 中 sleep() 和 wait() 的区别
  • 这两个方法来自不同的类分别是 Thread 和 Object
  • 最主要是 sleep 方法没有释放锁,而 wait 方法释放了锁,使得其他线程可以使用同步控制块或者方法(锁代码块和方法锁)。
  • wait,notify 和 notifyAll 只能在同步控制方法或者同步控制块里面使用,而 sleep 可以在任何地方使用(使用范围)
  • sleep 必须捕获异常,而wait,notify 和 notifyAll 不需要捕获异常
4. Java synchronized 关键字相关的一道题目解析

https://mp.weixin.qq.com/s?__biz=MzI3ODc3NzQ4NQ==&mid=2247484310&idx=1&sn=c7d31e0eefb45e7668530b92cd391487&chksm=eb509874dc2711627433d34819a08df6c46e4dcac7ac4781490103cba37a17b47ed96f9ad5b4&scene=21#wechat_redirect


####感谢阅读本博客。

####欢迎关注我的博客:https://li-weijian.github.io/

####欢迎关注我的CSDN:https://blog.csdn.net/qq352642663

####需要联系请加QQ:352642663

####欢迎联系我共同交流