一、并发与高并发基本概念
并发:
从业务上简单解释就是多个用户(编码层面就是多个线程)共同竞争(修改或读取)一个资源,并发问题更多体现在业务代码操作数据上,例如:秒杀场景,瞬间会有大量用户共同抢购一个商品,这时候如果没有并发控制,则极有可能出现超卖情况,即库存被扣成了负数。
从操作系统以及硬件层面解释并发:有多个线程运行在CPU上,当在单核处理上运行的时候,多个线程在单核处理上交替执行(伪并行),不断的从内存中换入换出,在多核处理器上每个线程会被分配到某一个内核上运行(并行),我觉得更适合叫并行计算。
高并发(High Concurrency):
高并发更多是指系统级别的解决方案,解决方案中会包含并发相关的业务代码,同样是秒杀场景,根据用户量级对程序设计、数据库设计、硬件布局等等综合起来用于满足高并发场景(也可以理解为支持更多的并行计算)。
二、并发安全的代码演示(基本演示)
public class CountDownLatchExample1 {
// 模拟并发2000
private final static int threadCount = 2000;
// 模拟有100000个请求
private final static int threadClient = 100000;
// 计数器(资源)
private static int count=0;
public static void main(String[] args) throws InterruptedException {
// 线程池
ExecutorService executor = Executors.newCachedThreadPool();
// 信号量,用于模拟并发
final Semaphore semaphore = new Semaphore(threadCount);
for (int i = 0; i < threadClient; i++) {
executor.execute(new Runnable() {
public void run() {
try {
// 获取一个资源
semaphore.acquire();
add();
// 释放一个资源
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
executor.shutdown();
System.out.println(count);
}
// 加了同步关键字的方法,如果不加同步则最终会因为并发问题导致结果不固定
public synchronized static void add(){
count++;
}
}
三、CPU 多级缓存、缓存一致性、乱序执行优化
1、CPU 多级缓存,如一下两张图,是简化的结构图
左图为早期的缓存结构示意图,右图为后来演变的多级缓存结构示意图
为什么需要CPU缓存呢?因为CPU处理速度太快了,主存跟不上CPU的处理速度,在CPU处理时钟周期内经常会等待,浪费处理器资源,所以在主存与CPU之间增加了Cache,用于增加处理器利用率。
缓存的意义:
a、时间局部性:如果某个数据被访问,那么将来它可能还会被访问。
b、空间局部性:如果某个数据被访问,那么与它相邻的数据也可能会被访问。
由于缓存容量远远小于主存容量,所以缓存的数据也会有不命中的情况,即使这样也比直接访问主存要性能高。
通过右图看到,后来发展到多级缓存,级数越高缓存的数据内容越多,极大的提高了处理器利用率,越靠近CPU的缓存,使用频率越高,数据是从主存>Ln>L2>L1 这样被写入,CPU访问的时候则是L1>L2>Ln>主存
2、缓存一致性
缓存一致性有个专有名词,叫 MESI (Modified Exclusive Shared Or Invalid),这个协议为了保证多个CPU(或CPU内核)之间缓存共享数据的一致性定义了Cache line 的四种状态,被修改的,独享的,共享的,无效的。
a、被修改的(Modified)
该状态只被缓存在该CPU的缓存中,并且是被修改过,与主存中的数据不一致,该缓存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回主存。当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。
b、独享的(Exclusive)
该状态只被缓存在当前CPU缓存中,未被修改过的并且与主存中的数据一致,该状态下可以被其它CPU读取,从而变成共享状态,该状态可以变更为 Modified
c、共享的(Shared)
该状态,缓存可能存在于多个CPU 中,并且与主存中的数据一致,当某个CPU修改了数据后,其它CPU的缓存可以变为 Invalid 状态
d、无效的(Invalid)
该状态,当前CPU的缓存可能已经失效了,被其它CPU修改过,并且已经写回主存
MESI 状态转换图:
local read 读本地缓存
local write 写本地缓存
remote read 读主存数据
remote write 写主存数据
在一个典型系统中,可能会有几个缓存(在多核系统中,每个核心都会有自己的缓存)共享主存总线,每个相应的CPU会发出读写请求,而缓存的目的是为了减少CPU读写共享主存的次数。
一个缓存除在Invalid状态外都可以满足cpu的读请求,一个invalid的缓存行必须从主存中读取(变成S或者 E状态)来满足该CPU的读请求。
一个写请求只有在该缓存行是M或者E状态时才能被执行,如果缓存行处于S状态,必须先将其它缓存中该缓存行变成Invalid状态(也既是不允许不同CPU同时修改同一缓存行,即使修改该缓存行中不同位置的数据也不允许)。该操作经常作用广播的方式来完成。
缓存可以随时将一个非M状态的缓存行作废,或者变成Invalid状态,而一个M状态的缓存行必须先被写回主存。
一个处于M状态的缓存行必须时刻监听所有试图读该缓存行相对应的主存操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S状态之前被延迟执行。
一个处于S状态的缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
一个处于E状态的缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S状态。
对于M和E状态而言数据总是精确的,他们在和该缓存行的真正状态是一致的。而S状态可能是非一致的,如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。
从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。
MESI 转换关系表
MESI 原文(自备梯子):https://en.wikipedia.org/wiki/MESI_protocol
3、乱序执行优化
乱序执行优化解释:处理器为了提高运行速度而做出的一些违背代码原有执行顺序的优化。
例如:
(1) int a=10;
(2) int b=20;
(3) int c=a+b;
(4) System.out.println(c);
上述代码中本意的执行顺序是1->2->3->4,但是CPU为了提高效率,可能是 2->1->3->4 这样执行的。
这样的优化对于我们编写的程序来说在一些场景下不进行特殊处理,可能会产生与预期不符的结果。
例如(例子可能不对,欢迎读者指正):
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
// 执行一个线程(想先输出false)
new Thread(new Runnable() {
public void run() {
System.out.println(flag);
}}).start();
// 改变值
flag=!flag;
}
四、JAVA 内存模型(JMM)、同步操作与规则
1、JMM 简介 (Java Memory Model)
为了屏蔽各种硬件、系统之间访问内存的差异,以及让 JAVA 程序在各个平台上的并发处理保持一致,JVM(Java虚拟机) 中规定了JAVA内存模型,它规范了Java 虚拟机与内存是如何协同工作的,规定了一个线程如何以及何时可以看到其它线程修改过的共享变量的值,以及线程在必须时如何同步的访问共享变量。
a) JVM 内存分配简述
绿色区为栈,蓝色区为堆
堆为JAVA程序运行时的数据区域,堆是动态分配内存的(运行时分配),由于是运行时分配内存,所以存取效率上会有所损耗,堆也是GC回收的主要区域。
栈为JAVA程序运行时存放代码、原始类型的区域,栈的存取效率要高的多,仅次于CPU寄存器,栈的数据可以共享,栈中的数据大小以及生命周期必须是确定的,栈中存储的是JAVA当中的基本类型(byte,char,short,int,long,float,double,boolean)以及对象句柄(引用)。
JVM内存模型规定,调用栈与本地变量存放到线程栈上(Thread Stack),对象本身还是存在堆上(如图所示 Object 3),一个对象可能包含方法,这些方法中的本地变量也可能指向的是对象,这个对象同样存放在堆上,非对象类型的的本地变量都会存在于栈中。
静态成员变量跟随着类的定义也被存放到堆上,存放在堆上的对象可以被持有这个对象的线程访问,同时持有这个对象的线程可以访问这个对象中的成员变量,如果两个线程同时拥有一个对象的访问权,则两个线程都可以访问这个对象中的成员变量,但是对这个成员变量的访问都会在各自线程中生成私有拷贝,当两个线程都修改了各自的私有拷贝时,都会写回主存,在没有做特殊处理的时候执行结果往往都不是预期结果。
JMM 与硬件之间的关联关系(左图JMM,右图硬件结构)
从图中可以看到,在硬件层面上是不区分线程栈与堆的,而JMM中的栈、堆则可能分布在主存、高速缓存或者寄存器中。
JMM 抽象结构图(非计算机物理结构)
JMM主要是为了规定了线程和内存之间的一些关系。根据JMM的设计,系统存在一个主内存(Main Memory),Java中所有变量都储存在主存中,对于所有线程都是共享的。每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作都是在工作内存中进行,线程之间无法相互直接访问,变量传递均需要通过主存完成。
2、JMM规定的八种操作以及同步规则
lock | 将主内存中的变量锁定,标记为一个线程所独占 |
unclock | 将主内存中之前lock的变量解除锁定,此时其它的线程可以有机会访问此变量 |
read | 将主内存中的变量值读到工作内存当中 |
load | 将read读取的值保存到工作内存中的变量副本中。 |
use | 将工作内存中的值传递给线程的代码执行引擎 |
assign | 将执行引擎处理返回的值重新赋值给工作内存中的变量副本 |
store | 将工作内存中变量副本的值存储到主内存中。 |
write | 将store存储的值写入到主内存的共享变量当中。 |
规则一、
如果把一个变量从主存加载到工作内存中,必须按顺序执行read-load ,如果把工作内存中的变量写回主存,则必须按照顺序执行 store-write ,虽然说必须按照顺序执行,但是没有说明是必须连续执行,所以read/store 后可能会中断然后执行其它处理后再执行 load/write 。
规则二、
不允许 read&load 或 store&write 的单独执行,例如 read 后必须有load 操作,store 必须有 write,反之,load 前必须先read,write 必须先有store。
规则三、
不允许线程丢弃它最近的 assign 操作,既变量在工作内存改变后必须写回主存,如果没有发生 assign 则不允许执行 store-write 操作。
规则四、
一个新变量只能从主存中诞生,不允许工作线程使用未被 read-load 或 assign 的变量,既,在use前必须 read-load,在store 前必须 assign。
规则五、
一个变量在同一时刻只允许一个线程对其执行lock 操作,但lock可以反复被同一个线程执行多次,有多少次lock,则有多少次unlock,这样才能保证变量被解锁,lock 与 unlock 必须成对出现
规则六、
如果对一个变量进行lock 操作,则会清空该线程工作内存中对应的变量副本,在执行引擎使用这个变量前要重新进行 load 或 assgin 初始化变量
规则七、
如果一个变量事先没有lock ,则不能对其进行unlock 操作,也不允许unlock 其它线程lock的变量
规则八、
一个线程在执行unlock 之前必须先执行 store-write 操作,把数据同步回主存。
图形描述
五、volatile 的特殊性
Java 内存模型对 volatile 做了特殊规定,当一个变量被定义为了 volatile 后会具备两种特性。
a、保证变量对所有线程可见,volatile变量的写操作除了对它本身的读操作可见外,volatile写操作之前的所有共享变量对volatile读操作之后的操作可见。
b、禁止指令重排序
这里要注意一点,volatile 不保证原子性,例如 count++,count +=1 这样的操作在多线程下计算结果是不确定的。
六、并发的优势与风险
优势:
a、处理速度,比如同时处理多个请求或者大任务拆分成小任务进行并行处理
b、提高资源利用率,CPU可以在等待IO(磁盘、网络等)的时候去操作别的事情,充分发挥多核处理性能
风险:
a、安全性,主要体现在多个线程操作共享资源,没有合理使用加锁,则会造成未知结果
b、活跃性,某个线程无法继续执行下去时就容易产生该问题,例如多个线程竞争资源可能会造成死锁问题,或者由于编码不当导致死锁
c、性能,过多的线程会造成CPU频繁调度,返回会降低处理效率,而且线程过多会消耗更多的内存,因此要合理使用线程,比如使用线程池