一、线程存在哪些安全性问题
- 可见性,原子性,有序性
1.可见性问题出现的原因
带有高速缓存的 CPU 执行计算的流程
- 程序以及数据被加载到主内存
- 指令和数据被加载到CPU的高速缓存
- CPU执行指令,把结果写到高速缓存
- 高速缓存中的数据写回主内存
因为存在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层面来了解什么是内存屏障
store barrier (写屏障storestore barrier)
强制所有在storestore 内存屏障之前的所有指令先执行、发送缓存失效的信号;
所有在storestore 内存屏障指令之后的store指令,必须在storestore 内存屏障之前的指令执行完再执行
load barrirer (读屏障loadload barrier)
强制所有在loadload 内存屏障之前的所有指令先执行。
full barrier (全屏障 storeload barrier)
当 上一条指令为 store, 下一条指令为load 时,可以插入fullbarrier,防止load 先于store执行导致结果不正确。
编译器层面如何解决指令重排序的问题
- storestore barrier 写写之间
- loadload barrier 读读之间
- storeload barrier 写读之间
- loadstore barrier 读写之间
2. synchronized
可以保证 原子性、可见性、有序性
synchronized 怎么实现锁
- 添加一个 访问标志位: ACC_SYNCHRONIZED
- 在
synchronized
包括的代码块 的前后 在字节码层面添加 monitorenter 和 monitorexit
为什么任何一个对象都可以成为锁
普通对象
- 对象头(markword):存储锁的信息,GC信息,比如使用synchronized 对这个对象加锁,占用 8个字节
- 类型指针(class pointer): 指向哪个类, 开启压缩后4个字节,否则8个字节
- 实例数据(instance data):成员变量
- 对齐(padding) 将字节补齐到能被8整除
- 对象头(markword):存储锁的信息,GC信息,比如使用synchronized 对这个对象加锁,占用 8个字节
锁的升级过程: 线程竞争加剧,才会出现锁升级过程
偏向锁
在大多数情况下,锁不仅不存在竞争,并且大多数情况下会由同一个线程获得, 因此,可以引入偏向锁。
偏向锁,指的就是,把markword的线程ID改为自己线程ID的过程,下次再需要访问时直接访问不需要重新获取锁。
偏向锁不可重偏向 批量偏向 批量撤销轻量级锁(自旋锁, 自适应自旋锁)
如果有线程竞争,撤销偏向锁,升级轻量级锁
线程在自己的线程栈生成LockRecord ,用CAS操作将markword设置为指向自己这个线程的LR的指针,设置成功者得到锁重量级锁
竞争加剧:有线程超过10次自旋, -XX:PreBlockSpin, 或者自旋线程数超过CPU核数的一半, 1.6之后,加入自适应自旋 Adapative Self Spinning , JVM自己控制
升级重量级锁:-> 向操作系统申请资源,linux mutex , CPU从3级-0级系统调用,线程挂起,进入等待队列,等待操作系统的调度,然后再映射回用户空间注意: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://li-weijian.github.io/
####欢迎关注我的CSDN:https://blog.csdn.net/qq352642663
####需要联系请加QQ:352642663
####欢迎联系我共同交流