Java并发系列「2」-- 并发的特性;
Posted 喜欢编码的老胡
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java并发系列「2」-- 并发的特性;相关的知识,希望对你有一定的参考价值。
@TOC# Java并发系列
记录在程序走的每一步___auth:huf
并发模块权限已经全部开放;非粉丝也可以观看。 ^.^ 大家一起来愉快的学习吧~
我们上一篇文章讲解了 并发的特性 —【可见性】 。 并且 我说明了并行以及并发之间的区别; 讲了JMM内存模型 流程; 结合流程 解释了更深层 更清晰的了解到可见性 是什么意思。 怎么解决;
我们以Demo开局; 我们可以认真看一下以下代码
先看代码
package com.huf;
public class ThreadTest2
private volatile static int count = 0;
public static void main(String[] args) throws InterruptedException
for (int i = 0; i < 10 ; i++)
new Thread(()->
for (int j = 0; j < 1000 ; j++)
count++;
).start();
Thread.sleep(3000);
System.out.println(count);
我们看到 这段代码 就是开启了十条线程; 每条线程 我们都对count进行了+1 然后再volatile 进行了修饰。 我们不妨大胆的猜一下结果 是什么?
结果是8949; 预期结果 应该是 10000;
为什么会出现这样的事情?难道没执行完吗?
我们抱着这个疑问;然后进行接下来的学习;
我们先来讲一下硬件CPU的架构; 我们现在 系统CPU 一般是分为三层缓存;
画一张图来了解 CPU内部结构;
假设我们程序 在Registers 没有找到需要的参数 就会继续去 CPU 缓存中进行命中目标 ;
一般来说: 越低等级速度越快(被访问的速度);越高等级容量越高(CPU缓存容量);
CPU 存储 有两个特性 不管是读 还是取;
时间局部性
时间局部性,意思是 我们在使用一个参数 ,那么它 近期很有可能再次被访问
空间局部性
空间局部性,我们读取内存是一片一片读取的。 也就是说 一个位置被引用 那么它附件的位置也有可能被引用。 (例:我们mysql 每一次取数据 都会抓取4KB)
cpu 内部的 多核 多缓存 架构;
我们可以通过一张图 来清晰明了的知道他们之间的关系
下面我们来开始升级这张图;
我们在这张图上做文章;
我们的主内存中 有一个变量 a = 3
我们《线程一》 执行 a+ 6的操作
我们《线程二》 执行 a+ 8的操作
我们现在可以预测:
我们《线程一》 去加载 a 然后 load 进CPU :
开始进行运算 a 此时等于 3 那么 core 1的结果 是: 9
我们《线程二》 也去加载 a 然后load 进入CPU :
开始运算 a 此时也等于3 那么 core 2的结果 是: 11
我们的主内存中 的结果 有可能 == 9 也有可能 ==11 结果是不确定的
如何解决缓存不一致的问题;
窥探机制(snooping)与 基于目录的机制(directory- based)
确保一致性的两种最常见的机制是窥探机制(snooping )和基于目录的机制(directory- based),这两种机制各有优缺点。如果有足够的带宽可用,基于协议的窥探往往会更快,因为 所有事务都是所有处理器看到的请求/响应。其缺点是窥探是不可扩展的。每个请求都必须广播 到系统中的所有节点,这意味着随着系统变大,(逻辑或物理)总线的大小及其提供的带宽也必须 增加。另一方面,目录往往有更长的延迟(3跳 请求/转发/响应),但使用更少的带宽,因为消息 是点对点的,而不是广播的。由于这个原因,许多较大的系统(>64处理器)使用这种类型的缓存 一致性。
当特定数据被多个缓存共享时,处理器修改了共享数据的值,更改必须传播到所有其他具有 该数据副本的缓存中。这种更改传播可以防止系统违反缓存一致性。
窥探机制有两种协议:
Write-invalidate 写失效; 也就是 在我们 x在我们的core被运算成为 9的时候 直接告诉其他副本 失效 常用协议有 MSI MOSI MESI MOESI等等
Write-update :写 更新 常用协议有: Dragon和firefly 但是不是经常能看到
缓存锁定
由于总线锁定阻止了被阻塞处理器和所有内存之间的通信,而输出LOCK#信号的CPU可能 只需要锁住特定的一块内存区域,因此总线锁定开销较大。 缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那 么当它执行锁操作回写到内存时,处理器不会在总线上声言LOCK#信号(总线锁定信号),而 是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制 会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的 数据时,会使缓存行无效。
缓存锁定不能使用的特殊情况:
- 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会 调用总线锁定。
- 有些处理器不支持缓存锁定。
- MESI
MESI 不难; 他代表的是数据在CPU高速缓存中的一种状态;(我个人是这样记忆的)
MESI协议是一个基于写失效的缓存一致性协议,是支持回写(write-back)缓存的最常用 协议。也称作伊利诺伊协议 (Illinois protocol,因为是在伊利诺伊大学厄巴纳-香槟分校被发明 的)。与写通过(write through)缓存相比,回写缓冲能节约大量带宽。总是 有“脏”(dirty)状态表示缓存中的数据与主存中不同。MESI协议要求在缓存不命中(miss) 且数据块在另一个缓存时,允许缓存到缓存的数据复制。与MSI协议相比,MESI协议减少了主 存的事务数量。这极大改善了性能。
M:-Modified 修改:
当数据从 CPU缓存中 进入CPU 寄存器 并且在计算因子ALU计算完成
计算后 回写到CPU高速缓存中 这时候 数据状态就是M
这时候 会通过Lock前缀指令立即刷新到缓存中
并且通知其他 副本缓存立即失效(I)
这时候数据只有它自己独一份 是不失效的 此时状态转变为E
E:-Exclusive 独占:
数据是E的时候 代表数据它是独占的。
如果此时进来第二条线程。这是访问该数据
那么就会产生一摸一样的副本进入另外一个core
S:-Shared 共享
当独享的时候 这时候进来了第二条线程
这时候产生了副本 同时后也改变这两个数据的状态
这时候状态就是 S
I:-Invalid 失效
当数据被CPU 计算因子 ALU计算出来 并且回写到内存中的时候。
这时候 其他CPU副本的数据 状态就会变成 I
总线锁定
这样去理解总线锁定。
我们黄色的区域 一旦锁定了内存中的 某个值的时候 我们core2 是没有办法读取内存的。 这样我们就变成了串行化执行了。
比较专业一点的解答:
总线锁定就是使用处理器提供的一个 LOCK#信号,当其中一个处理器在总线上输出此信号 时,其它处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
伪共享
首先我们来 看一下什么叫做伪共享;
我们的数据再内存中 存储 叫做 缓存行 其中缓存行的大小一般为64byte
我们再内存中开辟一个空间 并且 用两个线程来对该空间的数据进行操作;
package com.huf;
另外一个class
class Test volatile long a; volatile long b;
public class ThreadTest3
public static void main(String[] args) throws Exception
long startTime = System.currentTimeMillis();
Test test = new Test();
Thread thread = new Thread(()->
for (int i = 0; i < 100000000; i++)
test.a++;
);
Thread thread2 =new Thread(()->
for (int i = 0; i < 100000000; i++)
test.b++;
);
thread.start();
thread2.start();
thread.join();
thread2.join();
System.out.println(test.a);
System.out.println(test.b);
System.out.println(System.currentTimeMillis()-startTime);
happens-before 原则
从JDK 5 开始,JMM使用happens-before的概念来阐述多线程之间的内存可见性。在JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens- before关系。 happens-before和JMM;
happens-before原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依 靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。下面我们就一个
简单的例子稍微了解下happens-before :
i = 1; //线程A执行
j = i ; //线程B执行
j 是否等于1呢?假定线程A的操作(i = 1)happens-before线程B的操作(j = i),那么可以 确定线程B执行后j = 1 一定成立,如果他们不存在happens-before原则,那么j = 1 不一定成 立。这就是happens-before原则的威力。
happens-before原则定义如下:
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作 可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则 制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果 一致,那么这种重排序并不非法。
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操 作;
- 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作;
- .volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A 先行发生于操作C;
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件 的发生;
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
- .对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
上面八条是原生Java满足Happens-before关系的规则,但是我们可以对他们进行推导出其他满 足happens-before的规则:
- 将一个元素放入一个线程安全的队列的操作Happens-Before从队列中取出这个元素的操作
- 将一个元素放入一个线程安全容器的操作Happens-Before从容器中取出这个元素的操作
- 在CountDownLatch上的倒数操作Happens-Before CountDownLatch#await()操作
- 释放Semaphore许可的操作Happens-Before获得许可操作
- Future表示的任务的所有操作Happens-Before Future#get()操作
- 向Executor提交一个Runnable或Callable的操作Happens-Before任务开始执行操作
这里再说一遍happens-before的概念:如果两个操作不存在上述 任 一 一 个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排 序。如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的。
happens-before原则是JMM中非常重要的原则,它是判断数据是否存在竞争、线程是否安全的 主要依据,保证了多线程环境下的可见性。
## 总结:
- 来回答一开始文章之前的问题; 首先volatile 可以保证其可见性 但是却不可保证它的原子性。在CORE竞争这一份资源; 那么 就会有core计算的数据会失效; 这时候 得出来的数据 肯定是少于预期值的;
- cpu的储存特性;
- 我们通过cpu 内部的 多核 多缓存 架构 因此得出 会有缓存不一致的 情况
- 解决缓存不一致的情况 引出 解决缓存一直 机制 : 窥探机制 与 基于目录的机制;
- 又重点介绍了 MESI 一致性协议 以及 总线锁定 等方式 保证缓存一致性;
- 我们同过CPU内存运算以及缓存大小 引出了 伪共享 以及其解决方案;
- 最后我们通过介绍Happens-before 原则 让其来判断数据是否存在竞争 线程是否安全的 主要依据,保证了多线程环境下的可见性。
Seeyou
以上是关于Java并发系列「2」-- 并发的特性;的主要内容,如果未能解决你的问题,请参考以下文章
数据结构与算法+Java线程并发教程+Java设计模式系列 + Java8 新特性教程
Java并发编程系列之二十八:CompletionService