线程安全
如今多核处理器发展如此之快, 对于多进程/多线程通信我们并不陌生.
在共享资源调度更新过程中, 难免会出现数据不安全的情况.
这里简单说明一下保证线程安全的三大原则
对于原子性, 可见性和有序性, 我们并不陌生.
- 原子性
在一次或者多次的操作中, 要么所有的操作全部都得到了执行并不会受到任何因素的干扰而中断, 要么所有的操作都不执行. - 可见性
当一个线程对共享变量做了修改, 另外运行的线程可以立即看到该变量修改后的最新值. - 有序性
程序代码在执行过程中的先后顺序, 由于Java在编译器以及运行期的优化, 导致代码的执行顺序未必就是开发者编写代码时的顺序. 编译器为了提升程序的运行效率, 可能会对代码进行优化, 也就是所谓的指令重排序
, 但是它会保证最终的运算结果是所期望的.
ps: 当然, 这仅限于单线程的场景.
说明完三大原则之后, 本是应该说明一下Java是如何保证该三大原则, 在此之前, 笔者认为对CPU Cache模型的了解是需要的.
CPU Cache
CPU 简介
在计算机中, 所有的运算操作都是由CPU的寄存器来完成的, CPU指令的执行过程需要涉及数据的读取和写入,
CPU能访问的数据只能是计算机的主存(RAM)
, 但是随着CPU的发展频率不断地得到提升, 但是内存受到制造工艺以及成本等的限制,CPU的处理速度和内存的访问速度之间的差距越来越大
.
在两边速度严重不对等的情况下,
通过传统FSB直连内存的访问方式很明显会导致CPU资源受到大量的限制, 降低了CPU的吞吐量
因此在CPU和主内存之间增加了缓存
的设计
像是一级缓存 L1, 二级缓存 L2, 三级缓存 L3
考虑到程序指令和程序数据的行为和热点分布差异很大
L1 Cache分为L1i(i是instruction的首字母)和L1d(d是data的首字母).
CPU Cache的出现解决了CPU直接访问内存效率低下的问题.
程序在运行过程中, 会将运算需要的数据从主内存复制一份到CPU Cache中, 这样CPu进行计算时就可以直接对CPU Cache中的数据进行读取和写入, 当运算结束之后, 再将CPU Cache中的最新数据刷新到主内存中.
CPU 缓存一致性问题
由于缓存的出现, 引入了缓存不一致的问题.
比如i++
这个操作, 在程序运行的过程中
- 读取主内存i到CPU Cache
- 对i进行+1操作
- 将结果写回CPU Cache
- 运算结果数据刷回主存
在多线程的情况下, 每个线程都有自己的工作内存(即对应CPU中的Cache), 变量i在多个线程的工作内存中都存在一个副本.在写回主内存的过程中, 会出现自增结果不为预期结果.
为了解决缓存不一致的问题, 主流的解决方案有如下两种
- 总线加锁
由于CPU和其他组件都是通过总线来通信的, 因此通过(数据总线
,控制总线
,地址总线
)加锁, 可以解决这个问题, 但是这样会阻塞其他CPU对组件的访问, 效率会非常低下. - 通过缓存一致性协议, 这里比较典型的是Intel的
MESI协议
Modify
,Exclusive
,Share
,Invalid
这个协议保证了每一个缓存中使用的共享变量都是一致的.
在cpu操作cache
中的数据时, 如果发现该变量是一个共享变量, 也就是说在其他cpu cache
中也存在一个副本.
在读取操作时, 不做任何处理, 只是将cache
中的数据读取到寄存器.
在写入操作时, 发出信号通知其他的cpu 将该变量的cache line
置于无效invalid
, 这样的话, 其他的cpu在进行该变量的读取操作时, 就必须去主内存中再次获取.
我们对CPU Cache模型有了一定了解后, 对后续了解Java内存以及保证三大原则会有一定帮助.
Java 内存模型
指定了JVM如何与计算机主内存进行工作.
Java的内存模型决定了一个线程对共享变量的写入何时对其他线程可见
ps: 这里的内存模型是抽象的, 并不存在, 涵盖了(缓存、寄存器、编译器优化以及硬件等)
Java内存模型定义了线程与主内存之间的抽象关系:
- 共享变量存储于主内存(可以理解为堆内存)中, 每个线程都可以访问.
- 每个线程都有属于自己的工作内存(栈内存)
- 工作内存只存储共享变量的副本
- 线程不能直接操作主内存, 只能将工作内存中的数据写入主内存.
Java内存模型保证三大原则
再回顾之前提的线程安全所需要保证的三大原则, 在了解了CPU Cache和Java内存模型之后, 我们再来了解一下, Java内存模型是如何保证的.
原子性
只保证了基本数据类型的读取和赋值操作是原子性的.
像是引用的赋值, 包含了读取和赋值两个操作, 因此操作非原子性.
可见性
提供三种方式来保证可见性
volatile
关键字修饰的变量, 对于共享资源的写入操作, 会先在工作内存中修改, 再写入主内存中.synchronized
关键字保证在同一时刻只有一个线程获取到锁, 并确保在锁释放之前, 将变量的修改刷新到主内存中.- juc提供的
Lock
锁保证在同一时刻只有一个线程获取到锁, 并确保在锁释放之前, 将变量的修改刷新到主内存中.
有序性
提供三种方式来保证有序性
volatile
关键字保证有序性syncrhonized
保证有序性Lock
保证有序性
Java 内存模型定义了一些有序性的规则, 称Happens-before
原则
- 程序次序规则
在一个线程内, 代码按照编写时的次序执行, 编写在后面的操作发生于编写在前面的操作之后. - 锁定规则
一个unlock
操作要先行发生于同一个锁的lock
操作. volatile
变量规则
对一个变量的写操作要早于这个变量之后的读操作.- 传递规则
如果操作A先于操作B, 操作B先于操作C, 那么得出操作A肯定要先于操作C, 说明了happens-before
原则具备传递性. - 线程启动规则
Thread
对象的start
方法先行发生于对该线程的任何动作,这也是为什么只有start后的线程才能真正运行, 否则Thread也只是一个对象而已.
- 线程中断规则
对线程执行interrupt
方法肯定要优先于捕获到中断信号 - 线程的终结规则
线程中所有的操作都要先行发生于线程的终止检测 - 对象的终结规则
一个对象初始化的完成先行于finalize
方法之前.
Synchronized
JDK 权威解释
Synchronized keyword enable a simple strategy for preventing thread interference and memory consistency errors:
if an object is visiable to more than one thread, all reads or writes to that object’s variables are done through synchronized methods.
译:
syncrhonized关键字可以实现一个简单的策略来防止线程干扰和内存一致性错误, 如果一个对象对多个线程是可见的, 那么对该对象的所有读或者写操作都将通过同步的方式进行.
synchronized
关键字提供了一种锁的机制(jvm锁), 能够确保共享变量的互斥访问, 从而防止数据不一致问题的出现.synchronized
关键字包括monitor enter
和monitor exit
两个jvm指令, 能够保证在任何时刻任何线程执行到monitor enter
成功之前都必须从主内存中获取数据, 在monitor exit运行成功之后, 共享变量被更新后的值必须刷入主内存.syncrhonized
的指令严格遵守happens-before
规则, 一个monitor exit
指令之前必定要有一个monitor enter
Volatile
上述提的happens-before
规则中, 第三条volatile
变量规则:
对一个变量的写操作要早于这个变量之后的读操作, 保证了可见性
.
volatile
关键字修饰的变量, 禁止jvm对其指令重排序, 保证了有序性
.
对于volatile
是否能保证原子性, 网上有说能的, 因为像是i++这种操作本身就不是原子性的, 何来原子性一说.
在Java 内存模型也提过, 只保证基本数据类的读取和赋值是原子性的.因此也有说不能保证原子性的.
笔者这里的个人看法是这样的, 如果拿synchronized
是如何保证原子性的角度来说, volatile
的确不能保证修饰的变量在进行运算时是原子性的.