高并发相关基础知识
概念
并发
程序同时拥有两个或者多个线程。如果程序在单核处理器上运行,多个线程将交替地换入或者换出内存,这些线程是同时”存在”的,每个线程都处于执行过程中的某个状态。如果程序运行在多核处理器上,此时,每个线程都将分配到一个处理器核上,因此可以同时运行。
高并发
高并发是互联网分布式系统架构设计中必须考虑的因素之一,它通常是指,通过设计保证系统能够同时并行处理很多请求。
CPU多级缓存
CPU的频率太快了,快到主存跟不上,这样在处理器时钟周期内,CPU常常需要等待主存,浪费资源,所以cache的出现,是为了缓解CPU和内存之间速度的不匹配问题。
局部性原理
- 时间局部性:如果某个数据被访问,那么在不久的将来它很可能被再次访问。
- 空间局部性:如果某个数据被访问,那么与他相邻的数据也很可能被访问。
乱序执行优化
处理器为提高运算速度而做出违背代码原有顺序的优化。
多核CPU多级缓存一致性协议MESI
多核CPU的情况下有多个一级缓存,如何保证缓存内部数据的一致,不让系统数据混乱。这里就引出了一个一致性的协议MESI。
MESI协议缓存状态
MESI 是指4中状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:
缓存行(Cache line):缓存存储数据的单元。
状态 | 描述 | 监听任务 |
---|---|---|
M 修改 (Modified) | 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 | 缓存行必须时刻监听所有试图读该缓存行的对应主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。 |
E 独享(Exclusive) | 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 | 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。 |
S 共享 (Shared) | 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。 | 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。 |
I 无效 (Invalid) | 该Cache line无效。 | 无 |
MESI状态转换
- 触发事件
触发事件 | 描述 |
---|---|
本地读取(Local read) | 本地cache读取本地cache数据 |
本地写入(Local write) | 本地cache写入本地cache数据 |
远端读取(Remote read) | 其他cache读取其他cache数据 |
远端写入(Remote write) | 其他cache写入其他cache数据 |
cache分类
前提:所有的cache共同缓存了主内存中的某一条数据。
本地cache:指当前CPU的cache
- 触发cache:触发读写事件的cache
- 其他cache:指既除了以上两种之外的cache
多核缓存协同操作
假设有三个CPU A、B、C,对应三个缓存分别是cache a、b、 c。在主内存中定义了x的引用值为0。
单核读取
执行流程:
- CPU A发出了一条指令,从主内存中读取x。、
- 从主内存通过bus读取到缓存中(远端读取Remote read),这是该Cache line修改为E状态(独享)。
双核读取
执行流程:
- CPU A发出了一条指令,从主内存中读取x。
- CPU A从主内存通过bus读取到 cache a中并将该cache line 设置为E状态。
- CPU B发出了一条指令,从主内存中读取x。
- CPU B试图从主内存中读取x时,CPU A检测到了地址冲突。这时CPU A对相关数据做出响应。此时x 存储于cache a和cache b中,x在cache a和cache b中都被设置为S状态(共享)。
修改数据
执行流程:
- CPU A 计算完成后发指令需要修改x.
- CPU A 将x设置为M状态(修改)并通知缓存了x的CPU B, CPU B将本地cache b中的x设置为I状态(无效)
- CPU A 对x进行赋值。
同步数据
执行流程:
- CPU B 发出了要读取x的指令。
- CPU B 通知CPU A,CPU A将修改后的数据同步到主内存时cache a 修改为E(独享)
- CPU A同步CPU B的x,将cache a和同步后cache b中的x设置为S状态(共享)。、
MESI与Java
缓存的一致性消息传递是要时间的,这就使其切换时会产生延迟。当一个缓存被切换状态时,其他缓存收到消息完成各自的切换,并且发出回应消息后,这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题。这对于程序员来说简直是一个灾难。幸好java解决了这个问题,至于如何解决的请关注JMM。
Java内存模型
JMM
Java内存模型(Java Memory Model)即JMM,是一个抽象的概念。Java内存模型(JMM)定义了Java虚拟机(JVM)在计算机内存(RAM)中的工作规范。在硬件内存模型中,各种CPU架构的实现是不尽相同的,Java作为跨平台的语言,为了屏蔽底层硬件差异,定义了Java内存模型(JMM)。JMM作用于JVM和底层硬件之间,屏蔽了下游不同硬件模型带来的差异,为上游开发者提供了统一的使用接口。说了这么多其实就是想说明白JMM——JVM——硬件的关系。总之一句话,JMM是JVM的内存使用规范,是一个抽象的概念。
JMM主内存和本地内存交互操作
计算机硬件内存模型有缓存和主内存的交互协议MESI,同样JMM也规范了主内存和线程工作内存进行数据交换操作。一共包括如上图所示的8中操作,并且每个操作都是原子性的。
- lock(锁定):作用于主内存的变量,一个变量在同一时间只能一个线程锁定。该操作表示该线程独占锁定的变量。
- unlock(解锁):作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定。
- read(读取):作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用。
- load(载入):作用于线程的工作内存的变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)。
- use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作。
- assign(赋值):作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作。
- store(存储):作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用。
- write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。
JMM规定了以上8中操作需要按照如下规则进行
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
- 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
以上8中规则看着也是比较生涩的,其实如果你没看明白也没关系,其实这些规则就是保障数据同步的一些规则。不是很重要,重要的在后面的happens-before原则。
并发环境下JMM存在的问题
原子性
JMM保证了上文的8个操作是原子性的,以及Java语言本身对除了double
和long
的基本数据类型的变量的读取和赋值操作是原子性操作。其他的就算是简单的操作,比如x ++
,就不是原子操作,因为这行代码包含三个操作:
加载x的值
执行 ++
写入新值
在并发环境下,通常采用synchronized
或者Lock
对代码块加锁保证原子性。
可见性
在Java中提供了一个volatile
关键字来保证可见性。当一个主内存中的共享变量被volatile
关键字修饰时,一个线程对该变量的修改会被立即刷新(store)到主内存,保证其他线程看到的值一定是最新的。JMM层面上volatile
是通过load/store操作实现的可见性。
当然我们也可以通过synchronized
和Lock
通过加锁将多线程进行同步也就是串行执行来保证共享变量的可见性。
有序性
happens-before原则
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于后面的操作。
- 锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。(先释放锁,才能加锁)
- volatile变量规则:对同一个变量的写操作先行发生于后面对这个变量的读操作。
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于C,则A先行发生于操作C。
- 线程启动规则:Thread对象的
start()
方法先行发生于此线程的每一个动作。 - 线程终结规则:对线程
interrupt()
方法的调用先行发生于被中断线程的代码检测到中断事件的发生。 - 线程终结规则:线程中所有的操作都先行发生于线程的终结检测,我们可以通过
Thread.join()
方法结束、Thread.isAlive()
的返回值手段检测到线程已经终止执行。 - 对象终结规则:一个对象的初始化完成先行发生于他的
finalize()
方法的开始。
happens-before与可见性
happens-before通过以上8中规则保证可见性,如果一个操作A happens-before 另一个操作B,那么操作A的结果是对操作B可见的,这不难理解。
happens-before与重排序
两个操作如果存在happens-before关系,并不意味着一定是有序进行的,这是因为JVM存在指令重排优化,如果JVM认为两个操作重排序有利于性能提升并且重排序后的操作和未重排结果一致,将进行指令重排序。当然JVM层面的重排序发生于编译期,运行时的指令重排是处理器决定的。Java语言通过volatile关键字通过向主内存加入内存屏障实现禁止指令重排。