前言
众所周知,计算机CPU的运算速度远超磁盘IO和网络通信IO,CPU许多时间都消耗在等待IO操作上,那么如何尽可能的让CPU完全的发挥出来呢?在高并发的场景下,又如何让多线程更快的处理每个任务,从而提高服务的TPS?
1 硬件角度思考并发现象
CPU进行计算任务时,往往需要和内存进行交互,比如对运算内容的读取,对运算结果的存储。但是内存的存取速度要远慢于CPU的计算速度,所以一般计算机会在CPU和内存间添加一层高速缓存来作为两者间数据交互的缓冲。这样CPU直接与缓存交互,缓存再将结果同步给内存。
那么问题来了:每个CPU都有一个高速缓存,多个CPU多个缓存对主内存中的同一份数据进行操作,如何保证一致性呢?
好的,既然有这个问题,那么计算机是如何解决的呢?缓存与内存间的交互需要满足一致性协议。如图:
2 Java内存模型
2.1 主内存与工作内存
Java内存模型面向的是共享变量的访问规则,所以局部变量与方法参数这些线程私有数据,不做考虑,为啥?因为它们很安全呀。所有的共享变量都存在主内存中,每条线程都有自己的工作内存,线程对变量的操作都在工作内存中进行,不能直接与主内存进行交互,如图:
那么工作内存和主内存间的交互协议到底是如何呢?具体有那些操作呢?
操作主内存中的变量:
- lock:锁定,标识为被某线程独占
- unlock:解锁,标识为未占用状态
- read:读取,将主内存变量传输到工作内存,用于load
- write:写入,将store操作从工作内存传入的值放入主内存的变量
操作工作内存中的变量:
- load:载入,把read操作从主内存传入的值放到工作内存的变量副本中
- use:使用,将工作内存变量的值交给处理器
- assign:赋值,将处理器返回结果赋给工作内存变量
- store:存储,将工作内存变量传输到主内存,用于write
那么这8项操作需要满足什么规则呢?
- read-load,store-write,必须成对(不一定连续)出现,即数据从主内存传入工作内存,工作内存必须加载该数据
- 如果发生了assign(赋值)操作,那么必须执行store(存储),如果没发生,那么不允许执行store
- use的变量只能来源于load,即变量必须由主内存传过来
- 一个变量同个时刻只能被一个线程lock,但可以被同一线程lock多次,对应需要unlock同样次数才能解锁
- 不能unlock一个未被当前线程lock的变量,unlock之前要先同步工作内存中的变量(如果没有assign呢?)
2.2 volatile:可见性与指令不重排
- 可见性
当一个线程修改了一个被volatile修饰的变量,那么该修改值,其他线程可以立即得知。
实现规则:保证read+load+use和assign+store+write两个操作集的原子性
- 线程执行use操作前的操作必须是read+load操作,即处理器使用变量前必须从主内存获取最新值
- 线程执行assign操作后的操作必须是store+write操作,即处理器修改变量后必须向主内存同步最新值
不可见的例子(工作内存让人迷茫):
//private static volatile int i;//方法一:添加volatile,让数据可见
private static int i;
private static int threadNum = 100;
private static int addNum = 10000;//方法二:减小addNum次数,比如改为100
private static int expect = threadNum * addNum;
public static void main(String[] args) {
test01();
while (i != expect) {
// System.out.println(i);//方法三:通过输出i,触发提取i值
// try {
// TimeUnit.MILLISECONDS.sleep(1);//方法四:休眠
// } catch (InterruptedException e) {
// e.printStackTrace();
// }
}
System.out.println(i);//永远无法触发
}
/**
* 并发修改数据
*/
private static void test01() {
for (int i = 0; i < threadNum; i++) {
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < addNum; i++) {
add();
}
}
}).start();
}
}
/**
* 安全自增
*/
private static void add() {
synchronized (FutureTaskTest.class) {
i++;
}
}
通过代码中的四种方法,可以恢复正常,推荐方法一,合理有据。
可见性无法保证并发操作线程安全,因为它无法保证操作的原子性,一个操作可以包含多条字节码指令,单条字节指令在编译后也可能包含多个机器码指令,所以上述样例中自增还是添加了synchronized关键字(但是不推荐,性能不好,推荐使用原子类AtomicInteger,下章线程优化会介绍)。
- 指令不重排
指令重排样例(指令重排让人迷茫):
A线程:
-----------------------------------
a变量的初始化
b=true//处理器先把b值刷新回主内存
-----------------------------------
B线程:
-----------------------------------
if(b){
对a变量进行处理//发现此时还未初始化
}
-----------------------------------
不重排实现规则(前提use或assign顺序与代码顺序保持一致?):
- 同一线程对不同变量的操作,如果某个变量的use或assign更先发生,那么它的read或write操作也将更先发生,这样代码顺序与指令顺序一致
2.3 原子性,可见性,有序性
- 原子性
内存模型的八个操作都保证了原子性,基本数据类型的读写也保证了原子性(double和long是允许拆分为两个32位操作,但各个牌子的虚拟机大多都实现了他们操作的原子性),synchronized关键字同样也可以保证操作的原子性。
- 可见性
除了volatile之外,synchronized和final也可以保证可见性。但是final修饰字段必须保证在构造器中初始化成功后,没有发生this引用逃逸。
this引用逃逸场景:1、构造器中this引用传递出去可被外部线程访问;2、外部类构造器初始化内部类,且内部类访问了外部类数据。危害:外部线程或内部类可能访问到还未完成初始化的变量。
- 有序性
无序产生的原因:1、指令重排序2、工作内存与主内存数据同步延迟。通过volatile和synchronized可以修复这些问题。
2.4 先行发生(happens-before)原则
该原则是判断数据是否存在竞争、线程是否安全的主要依据。
前提:先行发生针对的是互有依赖的程序,比如都使用同一变量。时间上的先执行不代表先行发生。
- 程序次序规则:一个线程内,程序控制顺序即先行发生顺序(非同一变量会出现指令重排)
- 管程锁定规则:同一个锁的unlock先行发生于后面的lock操作。
- volatile变量规则:volatile变量的写操作先行发生于后面对这个变量的读操作。
- 线程启动停止规则:start先行发生于此线程的每一个动作,线程所有操作先行发生于终止检测,如Thread.isAlive(),join()
- 线程中断规则:interrupt()先行发生于中断检测代码,如interrupted()方法
- 对象终结规则:一个对象初始化完成,先行发生于他的finalize方法开始
- 传递性规则:A先行发生于B,B先行发生于C,那么......
这些天然先行发生规则之外的操作,都需要进行并发控制。
3 Java与线程
3.1 线程的实现
- 内核线程实现
内核线程(Kernel-Level Thread):内核通过操纵调度器(Scheduler)对线程进行调度,并映射到CPU上。它提供了轻量级进程(Light Weight Process)来实现线程,与内核线程一对一映射。LWP阻塞不影响整个进程,缺点是需要进行系统调用,需要在用户态和内核态中来回切换,会消耗一定的内核资源,因此系统可支持的LWP数量有限。
- 用户线程实现
用户线程的建立、同步、销毁和调度完全在用户态中完成,无需内核支持,操作快速且低耗,也可以支持更大的线程数量,部分高性能数据库的多线程就是这么实现。缺陷是线程的创建、切换和调度都需要自己考虑,所以特别复杂,Java和Ruby都尝试失败了。
- 用户线程+LWP
- Java线程的实现
在这只提Sun JDK,因为我常用的就是这个,Windows和Linux都是一对一的线程模型,一个Java线程映射一条轻量级进程。
3.2 Java线程调度与状态切换
线程调度即系统为线程分配CPU使用权,它包含两种实现方式:
- 协同式:由线程本身去控制线程切换,如果一直通知系统切换,则会造成程序阻塞,持续占用CPU
- 抢占式:由系统来控制线程切换,某个线程阻塞不会造成程序阻塞,Java采用的就是这种方式
线程状态包含五种:
- 新建(New):创建后尚未运行
- 运行(Runnable):包含Running和Ready,可能在运行,也可能在等待CPU为它分配执行时间
- 无限期等待(Waiting):不会被分配CPU执行时间,需等待其他线程唤醒,如:LockSupport.park(),没有设置Timeout参数的Object.wait(),Thread.join()
- 限期等待(Timed Waiting):不会被分配CPU执行时间,但在等待一定时间后会由系统自动唤醒,如:Thread.sleep(),LockSupport.parkNanos(),LockSupport.parkUntil(),设置Timeout参数的Object.wait(),Thread.join()
- 阻塞(Blocked):同步场景下,等待获取一个排他锁
- 结束(Terminated):已终止线程
了解了Java内存模型和线程,那么接下来就要聊聊核心话题:线程安全与锁优化。
爱家人,爱生活,爱设计,爱编程,拥抱精彩人生!