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

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>