java之并发概念(1)

线程与进程

我们知道计算机有软件系统与硬件系统组成,所以入门并发编程就需要知道其在软件/硬件系统之中是如何表现的。在java的并发编程这一块我们主要操作的是线程,这里就引出了第一个问题:线程与进程有什么区别?
"进程是操作系统分配资源的单位,线程是调度的基本单位,线程之间共享进程资源"相信大家对这一段已经背的滚瓜烂熟了,但是我们真的了解其中缘由吗?我们今天就展开讲解一下。

  1. 我们要知道java是不能直接new一个线程的,必须要调用本地方法才能创建线程,底层是调用jvm的c++代码创建线程。
  2. 进程是系统分配的最小单位,就像一个手机,你要聊天那么就需要先打开一个软件(微信),而这个软件就是一个进程。线程是cpu调度的最小单位,例如你在和你的女朋友聊天,你发过去一条信息的瞬间,你同时收到了一条信息,这时候你发信息和收信息就是两条线程在微信这个进程中同时跑。
  3. 通过第二点我们知道一个进程可以跑多个线程,这样单个进程的执行效率也会大大提高,想象一下如果我在发送信息的时候无法接受信息那是多么令人奔溃的场景,如果发了一个文件难道我要等待传输完成才能接受信息或再次发信息过去吗?这个多线程是依靠cpu快速切换运行线程实现的,并且无序。
  4. 线程切换时会保存其线程上下文(运行到一半被人打断需要先保存里面的数据)再进行切换,为了尽可能的提高cpu使用效率,保存的数据存储在寄存器中(cpu),不存储在内存的原因是内存离cpu太远了,如果每一次都存储在内存、从内存中读取就会浪费cpu的运行时间,脱离了并发编程的意义。
  5. 进程也可以进行切换,但是会更加浪费cpu的性能。所以为了提高cpu的性能而采用更加细粒度的线程的上下文切换,并且现在cpu拥有了多个核心。

接下来我们来看一下并行与并发的区别:
首先两个线程的并发在同一时间段,从微观层面看它们不是同时运行的:
请添加图片描述
在宏观层面,我们感觉不到它们有切换的卡顿和动作,所以可以认为它们是同时运行的。
如果在同一条件下两个线程是并行的,那么从宏观或者微观的角度去看,它们都是同时运行的:
请添加图片描述
并行在多处理器系统中存在,而并发可以在单处理器和多处理器系统中都存在,并发能够在单处理器系统中存在是因为并发是并行的假象,并行要求程序能够同时执行多个操作,而并发只是要求程序假装同时执行多个操作(每个小时间片执行一个操作,多个操作快速切换执行)


cpu缓存架构

首先看一下计算机组成的结构图(大概),详细可以搜一下冯诺依曼计算机模型
请添加图片描述
从图中可以看到: 如果线程每次切换上下文存储数据到内存就要经过好几个总线才能到达内存,严重浪费了cpu的性能。下面的图中就大大体现了cpu读取L1-L3缓存的时钟周期与主内存的时钟周期,主内存的时钟周期是L1缓存的40多倍,所以线程的上下文存储在cpu中(寄存器)大大提高了运行效率。
请添加图片描述
在CPU访问存储设备时,无论是存取数据或存取指令,都趋于聚集在一片连续的区域中,这就是局部性原理,分为两种:

  • 时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。
    比如循环、递归、方法的反复调用等。
  • 空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。
    比如顺序执行的代码、连续创建的两个对象、数组等。
    顺序访问二维数组数组比非顺序访问的速度更快:
public class Test {

    private static final int RUNS = 100;
    private static final int DIMENSION_1 = 1024 * 1024;
    private static final int DIMENSION_2 = 6;
    private static long[][] longs;

    public static void main(String[] args) throws Exception {
        /*
         * 初始化数组
         */
        longs = new long[DIMENSION_1][];
        for (int i = 0; i < DIMENSION_1; i++) {
            longs[i] = new long[DIMENSION_2];
            for (int j = 0; j < DIMENSION_2; j++) {
                longs[i][j] = 1L;
            }
        }
        System.out.println("Array初始化完毕....");

        long sum = 0L;
        long start = System.currentTimeMillis();
        for (int r = 0; r < RUNS; r++) {
            for (int i = 0; i < DIMENSION_1; i++) {//DIMENSION_1=1024*1024
                for (int j=0;j<DIMENSION_2;j++){//6
                    sum+=longs[i][j];
                }
            }
        }
        System.out.println("spend time1:"+(System.currentTimeMillis()-start));
        System.out.println("sum1:"+sum);

        sum = 0L;
        start = System.currentTimeMillis();
        for (int r = 0; r < RUNS; r++) {
            for (int j=0;j<DIMENSION_2;j++) {//6
                for (int i = 0; i < DIMENSION_1; i++){//1024*1024
                    sum+=longs[i][j];
                }
            }
        }
        System.out.println("spend time2:"+(System.currentTimeMillis()-start));
        System.out.println("sum2:"+sum);
    }
}

Array初始化完毕....
spend time1:686
sum1:629145600
spend time2:1649
sum2:629145600

带有高速缓存的CPU执行计算的流程

  1. 程序以及数据被加载到主内存
  2. 指令和数据被加载到CPU的高速缓存
  3. CPU执行指令,把结果写到高速缓存
  4. 高速缓存中的数据写回主内存

还有一点值得注意的是:缓存是由最小的存储区块-缓存行(cacheline)组成,缓存行大小通常为64byte。

L1缓存对应着cpu的一级缓存,L2、L3以此类推。对于多核cpu,一个核共享L1和L2缓存,多个核心共享L3缓存,如果一个pc中有多个cpu那么它们之间只共享着内存,即:

  • L1 Cache,分为数据缓存和指令缓存,逻辑核独占
  • L2 Cache,物理核独占,逻辑核共享
  • L3 Cache,所有物理核共享
    请添加图片描述

CPU读取存储器数据过程

  1. CPU要取寄存器X的值,只需要一步:直接读取。
  2. CPU要取L1 cache的某个值,需要1-3步(或者更多):把cache行锁住,把某个数据拿来,解锁,如果没锁住就慢了。
  3. CPU要取L2 cache的某个值,先要到L1 cache里取,L1当中不存在,在L2里,L2开始加锁,加锁以后,把L2里的数据复制到L1,再执行读L1的过程,上面的3步,再解锁。
  4. CPU取L3 cache的也是一样,只不过先由L3复制到L2,从L2复制到L1,从L1到CPU。
  5. CPU取内存则最复杂:通知内存控制器占用总线带宽,通知内存加锁,发起内存读请求,等待回应,回应数据保存到L3(如果没有就到L2),再从L3/2到L1,再从L1到CPU,之后解除总线锁定。

缓存一致性

缓存一致性是共享资源数据的一致性,这些数据最终存储在多个本地缓存中。当系统中的客户机维护公共内存资源的缓存时,可能会出现数据不一致的问题,这在多处理系统中的cpu中尤其如此。
请添加图片描述
在共享内存多处理器系统中,每个处理器都有一个单独的缓存内存,共享数据可能有多个副本:一个副本在主内存中,一个副本在请求它的每个处理器的本地缓存中。当数据的一个副本发生更改时,其他副本必须反映该更改。缓存一致性是确保共享操作数值(数据)的变化能够及时地在整个系统中传播的规程。
请添加图片描述

缓存一致性的要求

  • 写传播(Write Propagation)
    – 对任何缓存中的数据的更改都必须传播到对等缓存中的其他副本(该缓存行的副本)即通知相同变量的其他副本有数据变化。
  • 事务串行化(Transaction Serialization)
    – 对单个内存位置的读/写必须被所有处理器以相同的顺序看到。理论上,一致性可以在加载/存储粒度上执行。然而,在实践中,它通常在缓存块的粒度上执行。
  • 一致性机制(Coherence mechanisms)
    – 确保一致性的两种最常见的机制是窥探机制(snooping )和基于目录的机制(directory-based),这两种机制各有优缺点。如果有足够的带宽可用,基于协议的窥探往往会更快,因为所有事务都是所有处理器看到的请求/响应。其缺点是窥探是不可扩展的。每个请求都必须广播到系统中的所有节点,这意味着随着系统变大,(逻辑或物理)总线的大小及其提供的带宽也必须增加。另一方面,目录往往有更长的延迟(3跳 请求/转发/响应),但使用更少的带宽,因为消息是点对点的,而不是广播的。由于这个原因,许多较大的系统(>64处理器)使用这种类型的缓存一致性。

总线仲裁机制

在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。总线事务包括读事务(Read Transaction)和写事务(WriteTransaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写
请添加图片描述
两个处理器发起总线事务,这是总线裁决会对竞争做出裁决,如果处理器1胜利(总线仲裁会确保所有处理器都能公平的访问内存),那么处理器1继续它的事务操作,等处理器1事务完成,处理器2才能开始自己的总线事务。所以总线的这种工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。

原子是不可分割的,所以原子操作是指不可被中断的一个或者一组操作处理器会自动保证基本的内存操作的原子性,也就是一个处理器从内存中读取或者写入一个字节时,其他处理器是不能访问这个字节的内存地址。最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的(例如32位系统中,加载64位数据),但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性

总线锁定就是使用处理器提供的一个 LOCK#信号,当其中一个处理器在总线上输出此信号时,其它处理器的请求将被阻塞住,那么该处理器可以独占共享内存。表明所有IO都会串行化
由于总线锁定阻止了被阻塞处理器和所有内存之间的通信,而输出LOCK#信号的CPU可能只需要锁住特定的一块内存区域,因此总线锁定开销较大

缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不会在总线上输出LOCK#信号(总线锁定信号),而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。

无法使用的特殊情况:

  • 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(缓存块)时,则处理器会调用总线锁定。
  • 有些处理器不支持缓存锁定。

总线窥探

总线窥探是缓存中的一致性控制器监视或窥探总线事务的一种方案,其目标是在分布式共享内存系统中维护缓存一致性。包含一致性控制器的缓存称为snoopy缓存。当特定数据被多个缓存共享时,处理器修改了共享数据的值,更改必须传播到所有其他具有该数据副本的缓存中。这种更改传播可以防止系统违反缓存一致性。数据变更的通知可以通过总线窥探来完成。所有的窥探者都在监视总线上的每一个事务。如果一个修改共享缓存块的事务出现在总线上,所有的窥探者都会检查他们的缓存是否有共享块的相同副本。如果缓存中有共享块的副本,则相应的窥探者执行一个动作以确保缓存一致性。这个动作可以是刷新缓存块或使缓存块失效。它还涉及到缓存块状态的改变,这取决于缓存一致性协议。这里就分开了两种窥探的协议。

  • Write-invalidate
    当处理器写入一个共享缓存块时,其他缓存中的所有共享副本都会通过总线窥探失效。这种方法确保处理器只能读写一个数据的一个副本。其他缓存中的所有其他副本都无效。这是最常用的窥探协议。MSI、MESI、MOSI、MOESI和MESIF协议属于该类型。

  • Write-update
    当处理器写入一个共享缓存块时,其他缓存的所有共享副本都会通过总线窥探更新。这个方法将写数据广播到总线上的所有缓存中。它比write-invalidate协议引起更大的总线流量。这就是为什么这种方法不常见。Dragon和firefly协议属于此类别。

MESI

MESI协议是一个基于写失效的缓存一致性协议,是支持回写(write-back)缓存的最常用协议。也称作伊利诺伊协议 (Illinois protocol,因为是在伊利诺伊大学厄巴纳-香槟分校被发明的)。与写通过(write through)缓存相比,回写缓冲能节约大量带宽。总是有“脏”(dirty)状态表示缓存中的数据与主存中不同。MESI协议要求在缓存不命中(miss)且数据块在另一个缓存时,允许缓存到缓存的数据复制。与MSI协议相比,MESI协议减少了主存的事务数量。这极大改善了性能。

共有四种状态对应名称每个字母的缩写:

  • 已修改Modified (M)
    缓存行是脏的(dirty),与主存的值不同。如果别的CPU内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享(S).
  • 独占Exclusive (E)
    缓存行只在当前缓存中,但是干净的–缓存数据同于主存数据。当别的缓存读取它时,状态变为共享;当前写数据时,变为已修改状态。
  • 共享Shared (S)
    缓存行也存在于其它缓存中且是未修改的。缓存行可以在任意时刻抛弃。
  • 无效Invalid (I)
    缓存行是无效的

它们之间的转换关系:
请添加图片描述
两个副本,当副本1变为修改状态(M),那么副本2就会置未无效状态(I)
请添加图片描述
接下来我们用图来简单演示一下它们之间的状态转换:
请添加图片描述

T1读取主内存的数据x,这时CPU缓存就会加载到x=5,如果只有这一个副本那么就是独占状态(E)。这时T2也从内存中加载了数据x,那么这时候就会有两个数据x的副本了,这是两个副本的状态为共享状态(S)。需要注意的是副本是在CPU的寄存器中,但是L1-L3这些CPU缓存也会有。

请添加图片描述
T1执行运算x=x+3,这时候x=8,数据发生变化,T1变为修改状态(S->M)。我们知道MESI是总线窥探中写失效(Write-invalidate)的一种,会使其他副本置为无效状态(S->I)。

请添加图片描述
T1刷新数据到内存后,T2会从内存中将新数据读取到T2。

请添加图片描述
如果T1和T2同时修改数据x,那么窥探机制保证只有一个修改会生效,剩下的一个操作会直接置为无效。缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。这也是我们并发编程中最常遇到的问题。

public class Test{

    private static volatile int counter = 0;

    public static void increment() {
        counter++;
    }

    public static void decrement() {
        counter--;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                increment();
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                decrement();
            }
        }, "t2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("counter="+counter);
    }
}

counter=-3662

伪共享

我们现在用的一般都是64位系统了,那么cpu加载的缓存块大小一般为64byte。如果多个核的线程在操作同一个缓存行中的不同变量数据,那么就会出现频繁的缓存失效,即使在代码层面看这两个线程操作的数据之间完全没有关系。这种不合理的资源竞争情况就是伪共享。

public class Test{
    public static void main(String[] args) throws InterruptedException {
        testPointer(new Pointer());
    }

    private static void testPointer(Pointer pointer) throws InterruptedException {
        long start = System.currentTimeMillis();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                pointer.x++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                pointer.y++;
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(pointer.x+","+pointer.y);

        System.out.println(System.currentTimeMillis() - start);


    }
}


class Pointer {
    // 避免伪共享: @Contended +  jvm参数:-XX:-RestrictContended  jdk8支持
    //@Contended
    volatile long x;
    
    //避免伪共享: 缓存行填充
//    long p1, p2, p3, p4, p5, p6, p7;
    volatile long y;
}

不去掉上面注释的解决方法时间为:3222
去掉注释,使用解决方法时间为:925

这里截取了网上的一段例子,我们知道long类型占用8byte,这里使用多线程分别加载x,y两个long变量进行100000000次累加操作,在这个过程中每次修改数据和读取数据都是一个缓存行大小(64byte),都会把x,y两个变量加载在同一个线程运算中,所以每次刷新数据到内存后,其他所有线程都需要重新加载这个缓存块数据,不管运算是否需要x或者y。

解决方法:

  • 给填充缓存块,既然一个缓存块是64位,污染到了其他的使用数据,那么我们可以在数据前多定义几个变量,填充到64位为止。
  • 使用@Contended注解标注在变量上 并配合jvm的参数:-XX:-RestrictContended

最后

所有流程图都可以在以下链接🔗查看。

请添加图片描述

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值