JAVA学习笔记(并发编程-壹)- 并发体验和基础

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/bingdianone/article/details/83375525

并发编程初体验

实现一个计数功能

package com.mmall.concurrency;

import com.mmall.concurrency.annoations.NotThreadSafe;
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

@Slf4j
@NotThreadSafe
/**
 * 代码模拟并发
 */
public class ConcurrencyTest {
    //请求总数
    public static int clientTotal=5000;
    //同事并发执行的线程数
    public static int threadTotal=200;
    //计数
    public static int count=0;

    public static void main(String[] args) throws InterruptedException {
        //定义一个线程池
        ExecutorService executorService = Executors.newCachedThreadPool();
        //信号量(允许的并发数)
        final Semaphore semaphore = new Semaphore(threadTotal);
        //计数器(线程总数)
        final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
        for (int i = 0; i < clientTotal; i++) {
            executorService.execute(()->{
                try {
                    semaphore.acquire();//当前进程是否可以执行(判断并发是否小于200)
                    add();
                    semaphore.release();//释放进程
                } catch (InterruptedException e) {
                    log.error("exception",e);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();//关闭线程池
        log.info("count:{}",count);
    }

    private static void add(){
        count++;
    }
}

/**
 * 运行结果
 * 13:51:56.338 [main] INFO com.mmall.concurrency.ConcurrencyTest - count:4987
 */

当线程是1和200时处理结果不同;线程200时结果不稳定。

并发与高并发基本概念

基本概念

  • 并发: 同时拥有两个或者多个线程, 如果程序在单核处理器上运行, 多个线程将交替地换入或者换出内存, 这些线程是同时 “存在”, 例如google的, 每个线程都处于执行过程中的某个状态, 如果运行在多核处理器上, 此时, 程序中的每个线程都将分配到一个处理器核上, 因此可以同时运行.
  • 高并发: 高并发 (High Concurrency) 是互联网分布式系统架构设计中必须考虑的因素之一, 它通常是指, 通过设计保证系统能够同时并行处理很多请求.
  • 并发: 多个线程操作相同的资源, 保证线程安全, 合理使用资源
  • 高并发: 服务能同时处理很多请求, 提高程序性能

并发基础

cpu多级缓存

cpu多级缓存
  上图左半图简单的展示了最简单的高速缓存的配置,数据的读取和存储都经过高速缓存,CPU核心与高速缓存有一条特殊的快速通道;主存与高速缓存都连在系统总线上(BUS)这条总线同时还用于其他组件的通信。
  
  在高速缓存出现后不久,系统变得越来越复杂,高速缓存与主存之间的速度差异被拉大,直到加入了另一级缓存,新加入的这级缓存比第一缓存更大,但是更慢,而且经济上不合适,所以有了二级缓存,甚至有些系统已经拥有了三级缓存,于是就演变成了多级缓存,如上图。

  • 为什么需要CPU cache:
    • Cpu的频率太快了, 快到主存跟不上, 这样在处理器时钟周期内, Cpu常常需要等待主存, 浪费资源 。所以cache的出现, 是为了缓解cpu和内存之间速度的不匹配问题 (结构: CPU-> Cache-> memory)
  • CPU Cache有什么意义
    • 时间局部性: 如果某个数据被访问, 那么在不久的将来它很可能被再次访问
    • 空间局部性: 如果某个数据被访问, 那么与它相邻的数据很快也可能被访问

缓存一致性(MESI)

MESI以及状态转换
MESI用于保证多个cpu Cache之间缓存共享数据的一致
M代表被修改;E独享;S共享;I无效的;

  • local read读本地缓存中的数据;
  • local write将数据写入本地缓存中;
  • remote read将内存中的数据读取出来;
  • remote write将数据写回主存里面。

首字母缩略词MESI中的字母表示可以标记高速缓存行的四种独占状态(使用两个附加位编码):

  1. 修改(M)
    高速缓存行仅存在于当前高速缓存中,并且是脏的 - 它已从主存储器中的值修改(M状态)。在允许对(不再有效)主存储器状态的任何其他读取之前,需要高速缓存在将来的某个时间将数据写回主存储器。回写将该行更改为共享状态(S)。
  2. 独家(E)
      缓存行仅存在于当前缓存中,但是干净 - 它与主内存匹配。它可以随时更改为共享状态,以响应读取请求。或者,可以在写入时将其改变为修改状态。
  3. 共享(S)
      表示此高速缓存行可能存储在计算机的其他高速缓存中并且是干净的 - 它与主存储器匹配。可以随时丢弃该行(更改为无效状态)。
  4. 无效(I)
      表示此缓存行无效(未使用)。对于任何给定的高速缓存对,给定高速缓存行的允许状态如图中。当块标记为M(已修改)时,其他高速缓存中块的副本将标记为I(无效)。

在一个典型的多核系统中,每一个核都会有自己的缓存来共享主存总线,每个相应的CPU会发出读写(I/O)请求,而缓存的目的是为了减少CPU读写共享主存的次数。一个缓存除了在 Invalid 状态之外,都可以满足CPU的读请求。
  
  一个写请求只有在该缓存行是M状态,或者E状态的时候才能够被执行。如果当前状态是处在S状态的时候,它必须先将缓存中的该缓存行变成无效的(Invalid)状态,这个操作通常作用于广播的方式来完成。这个时候它既不允许不同的CPU同时修改同一个缓存行,即使修改该缓存行不同位置的数据也是不允许的,这里主要解决的是缓存一致性的问题。
  
  一个处于M状态的缓存行它必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S状态之前被延迟执行。
  
  一个处于S状态的缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
  
  一个处于E状态的缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S状态。
  
  因此,对于M和E两种状态而言总是精确的,他们在和该缓存行的真正状态是一致的。而S状态可能是非一致的,如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。
  
  从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。

乱序执行优化

处理器为提高运算速度而做出违背代码原有顺序的优化
在这里插入图片描述
什么是乱序执行优化:如上图
处理器为提高运算速度而做出违背代码原有顺序的优化
例如,我现在有两个变量a和b,a的值为10,b的值为200,我要计算a乘以b的结果。而我在代码上写的是:

a=10;
b=200;
result=a*b;

但是到了CPU上的乱序执行优化后,可能就变成了:

b=200;
a=10;
result=a*b;

从上图中,可以看到CPU乱序执行优化后的代码并不会对计算结果造成影响,但这也只是其中一种没被影响的情况而已。在单核时代,处理器保证做出的优化不会导致执行结果远离预期目标。但是在多核环境下则并非如此,因为在多核环境下同时会有多个核心在执行指令,每个核心的指令都可能被乱序。另外处理器还引入了L1、L2等多级缓存机制,而每个核心都有自己的缓存,这就导致了逻辑次序上后写入的数据未必真的写入了。如果我们不做任何防护措施,那么处理器最终处理的结果可能与我们代码的逻辑结果大不相同。比如我们在一个核心上执行数据写入操作,并在最后写一个标记用来表示之前的数据已经准备好了。然后从另外一个核心上通过判断这个标记来判定所需要的数据是否已准备就绪,这种做法就存在一定的风险,标记位可能先被写入,而数据并未准备完成,这个未完成既有可能是没有计算完成,也有可能是缓存没有被及时刷新到主存之中,这样最终就会导致另外的核心使用了错误的数据,所以我们才经常在多线程的情况下保证线程安全。

Java内存模型

Java Memory Model (JMM)

以上简单介绍了在多核并发的环境下CPU进行乱序执行优化时所带来的线程安全问题,为了保证线程安全,需要采取一些额外的手段去防止这种问题的发生。

不过在介绍如何采用实际手段解决这种问题之前,我们先来看看Java虚拟机是如何解决这种问题的:为了屏蔽各种硬件和操作系统内存的访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,所以Java虚拟机规范中定义了Java内存模型(Java Memory Model简称JMM)。
  
  Java内存模型是一种规范,它定义了Java虚拟机与计算机内存是如何协同工作的。它规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步地访问共享变量。
JMM
JVM是Java Virtual Machine(Java虚拟机)的缩写
  在明确了Java内存模型是做什么的之后,我们来看一下其中内存分配的两个概念

  • Head(堆):java里的堆是一个运行时的数据区,堆是由垃圾回收机制来负责的。堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,而且Java的垃圾回收机制也会自动的收走那些不再使用的数据。但是它也有缺点,由于是运行时动态分配内存,因此它的存取速度相对要慢一些。
  • Stack(栈):栈的优势是存取速度比堆要快,仅次于计算机里的寄存器,栈的数据是可以共享的。而栈的缺点则是存在栈中的数据的大小以及生存期必须是确定的,缺乏一些灵活性,所以栈中主要用来存储一些基本数据类型的变量,比如:int,short,long,byte,double,float,boolean,char以及对象句柄等。

Java内存模型要求调用栈和本地变量存放在线程栈(Thread Stack)上,而对象则存放在堆上。一个本地变量也可能是指向一个对象的引用,这种情况下这个保存对象引用的本地变量是存放在线程栈上的,但是对象本身则是存放在堆上的。
  
  一个对象可能包含方法,而这些方法可能包含着本地变量,这些本地变量仍然是存放在线程栈上的。即使这些方法所属的对象是存放在堆上的。一个对象的成员变量,可能会随着所属对象而存放在堆上,不管这个成员变量是原始类型还是引用类型。静态成员变量则是随着类的定义一起存放在堆上。
  
  存放在堆上的对象,可以被持有这个对象的引用的线程访问。当一个线程可以访问某个对象时,它也可以访问该对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,那么它们都将会访问这个方法中的成员变量,但是每一个线程都拥有这个成员变量的私有拷贝。

硬件架构:

硬件架构
现代硬件内存模型与Java内存模型有一些不同。理解内存模型架构以及Java内存模型如何与它协同工作也是非常重要的。这部分描述了通用的硬件内存架构,下面的部分将会描述Java内存是如何与它“联手”工作的。

  • CPU:一个现代计算机通常由两个或者多个CPU,其中一些CPU还有多个核心。从这一点可以看出,在一个有两个或者多个CPU的现代计算机上同时运行多个线程是可能的。每个CPU在某一时刻运行一个线程是没有问题的。这意味着,如果你的Java程序是多线程的,在你的Java程序中每个CPU上一个线程可能同时(并发)执行。
  • CPU Registers(寄存器):每个CPU都包含一系列的寄存器,它们是CPU内内存的基础。CPU在寄存器上执行操作的速度远大于在主存上执行的速度。这是因为CPU访问寄存器的速度远大于主存。
  • CPU Cache(高速缓存):由于计算机的存储设备与处理器的处理设备有着几个数量级的差距,所以现代计算机都会加入一层读写速度与处理器处理速度接近的高级缓存来作为内存与处理器之间的缓存。这就是CPU中的缓存层,实际上绝大多数的现代CPU都有一定大小的缓存层。因为CPU访问缓存层的速度快于访问主存的速度,所以可以将运算时使用到的数据复制到缓存中,让运算能够快速的执行,当运算结束后,再从缓存同步到内存之中,这样CPU就不需要等待缓慢的内存读写了。但通常访问缓存比访问内部寄存器的速度还要慢一点。而一些CPU还有多层缓存,但这些对理解Java内存模型如何和内存交互不是那么重要。只要知道CPU中可以有一个缓存层就可以了。
  • 主(内)存:一个计算机还包含一个主存。所有的CPU都可以访问主存。主存通常比CPU中的缓存大得多。

运作原理:通常情况下,当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。
  
  当CPU需要在缓存层存放一些东西的时候,存放在缓存中的内容通常会被刷新回主存。CPU缓存可以在某一时刻将数据局部写到它的内存中,和在某一时刻局部刷新它的内存。它不会再某一时刻读/写整个缓存。通常,在一个被称作“cache lines”的更小的内存块中缓存被更新。一个或者多个缓存行可能被读到缓存,一个或者多个缓存行可能再被刷新回主存。

jvm与硬件架构

Java内存模型与硬件内存架构之间存在差异。硬件内存架构没有区分线程栈和堆。对于硬件而言,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。下图简单展示了现代计算机硬件内存架构:
jvm与硬件架构

java内存模型抽象架构图

java内存模型抽象架构图
  每个线程之间的共享变量存储在主内存里面,每个线程都有一个私有的本地内存,本地内存是Java内存模型的一个抽象的概念,并不是真实存在的。它涵盖了缓存、写缓存区、寄存器以及其他的硬件和编译器的优化,本地内存中存储了该线程已读或写共享变量的拷贝的一个副本。
  
  从一个更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
  
  Java内存模型中的线程的工作内存(working memory)是cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。

如果上图中的线程A和线程B要通信,必须经历两个步骤:

  1. 首先线程A要把本地内存A中更新过的共享变量刷新到主内存里
  2. 然后线程B再到主内存中去读取线程A更新的共享变量,这样就完成了两个线程之间的通信了

因此,多线程的环境下就会出现线程安全问题。例如我们要进行一个计数的操作:线程A在主内存中读取到了变量值为1,然后保存到本地内存A中进行累加。就在此时线程B并没有等待线程A把累加后的结果写入到主内存中再进行读取,而是在主内存中直接读取到了变量值为1,然后保存到本地内存B中进行累加。此时,两个线程之间的数据是不可见的,当两个线程同时把计算后的结果都写入到主内存中,就导致了计算结果是错误的。这种情况下,我们就需要采取一些同步的手段,确保在并发环境下,程序处理结果的准确性。

java内存模型-同步八种操作

java内存模型-同步八种操作

  • Lock (锁定): 作用于主内存的变量, 把一个变量标识为一条线程独占状态
  • unlock (解锁): 作用于主内存的变量, 把一个处于锁定状态的变量释放出来, 释放后的变量才可以被其他线程锁定
  • Read (读取): 作用于主内存的变量, 把一个变量值从主内存, 传输到线程的工作内存中, 以便随后的load动作使用
  • Load (载入): 作用于工作内存的变量, 它把read操作从主内存中得到的变量值放入工作内存的变量副本中
  • Use (使用): 作用于工作内存的变量, 把工作内存中的一个变量值传递给执行引擎
  • Assign (赋值): 作用于工作内存的变量, 它把一个从执行引擎接收到的值赋值给工作内存的变量
  • Store (存储): 作用于工作内存的变量, 把工作内存中的一个变量的值传送到主内存中, 以便随后的write的操作.
  • Write (写入): 作用于主内存的变量, 它把store操作从工作内存中一个变量的值传送到主内存的变量中

java内存模型-同步规则

 java内存模型-同步规则

  • 如果要把一个变量从主内存中复制到工作内存, 就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中, 就要按顺序地执行store和write操作 。但java内存模型只要求上述操作必须按顺序执行, 而没有保证必须是连续执行
  • 不允许read和load, Store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作, 即变量在工作内存中改变了之后必须同步到主内存中
  • 不允许一个线程无原因地 (没有发生过任何assign操作) 把数据从工作内存同步回主内存中
  • 一个新的变量只能在主内存中诞生, 不允许在工作内存中直接使用一个未被初始化 (load或assign) 的变量 。 即就是对一个变量实施use和store操作之前, 必须先执行过了assign和load操作
  • 一个变量在同一时刻只允许一条线程对其进行lock操作, 但lock操作可以被同一条线程重复执行多次, 多次执行lock后, 只有执行相同次数的unlock操作, 变量才会被解锁 。Lock和unlock必须, 成对出现
  • 如果对一个变量执行lock操作, 将会清空工作内存中此变量的值, 在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定, 则不允许对它执行 Unlock操作; 也不允许去unlock一个被其他线程锁定的变量
  • 对一个变量执行unlock操作之前, 必须先把此变量同步到主内存中 (执行store和write操作)

并发的优势与风险

并发的优势与风险

猜你喜欢

转载自blog.csdn.net/bingdianone/article/details/83375525