并发编程(一)计算机模型&volatile关键字详解

系列文章目录

一:计算机模型&volatile关键字详解
二:java中的锁体系
三:synchronized关键字详解



一、现代计算机理论模型与工作方式

1、冯诺依曼计算机模型

现代计算机模型是基于-冯诺依曼计算机模型
计算机在运行时,先从内存中取出第一条指令,通过控制器的译码,按指令的要求,从存储器中取出数据进行指定的运算和逻辑操作等加工,然后再按地址把结果送到内存中去。接下来,再取出第二条指令,在控制器的指挥下完成规定操作。依此进行下去。直至遇到停止指令。
程序与数据一样存贮,按程序编排的顺序,一步一步地取出指令,自动地完成指令规定的操作是计算机最基本的工作模型。这一原理最初是由美籍匈牙利数学家冯.诺依曼于1945年提 出来的,故称为冯.诺依曼计算机模型

请添加图片描述
现代计算机硬件结构原理图

在这里插入图片描述

2、计算机硬件多CPU架构

在这里插入图片描述**多CPU:**一个现代计算机通常由两个或者多个CPU,如果要运行多个程序(进程)的话,假如只有一个CPU的话,就意味着要经常进行进程上下文切换,因为单CPU即便是多核的,也只是多个 处理器核心,其他设备都是共用的,所以 多个进程就必然要经常进行进程上下文切换,这个代价是很高的。
**CPU多核:**一个现代CPU除了处理器核心之外还包括寄存器、L1L2L3缓存这些存储设备、浮点运算 单元、整数运算单元等一些辅助运算设备以及内部总线等。一个多核的CPU也就是一个CPU上有多个处理器核心,这样有什么好处呢?比如说现在我们要在一台计算机上跑一个多线程的程序,因为是一个进程里的线程,所以需要一些共享一些存储变量,如果这台计算机都是单核单线程CPU的话,就意味着这个程序的不同线程需要经常在CPU之间的外部总线上通信,同时还要处理不同CPU之间不同缓存导致数据不一致的问题,所以在这种场景下多核单CPU的架构就能发挥很大的优势,通信都在内部总线,共用同一个缓存。
**CPU寄存器:**每个CPU都包含一系列的寄存器,它们是CPU内内存的基础。CPU在寄存器上执行操作的速度远大于在主存上执行的速度。这是因为CPU访问寄存器的速度远大于主存

3、缓存一致性协议(MESI)

在这里插入图片描述
大家都知道在计算机执行程序的时候每条指令都是在cpu中执行的,那么执行指令的同时势必会有读取和写入的操作,那么这样就引申出了一个问题。那么在程序运行时数据的存储是在计算机中的主存中(物理内存)的而内存的读取和写入的速度与cpu的执行指令速度相比差距是很大的,这样就造成了与内存交互时程序执行效率大大降低,因此在cpu中就有了高速缓存区。
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵 循 一 些 协 议 , 在读写时要根据协议来进行操作 这类协议有MSI 、MESI
MESI介绍
CPU1使用共享数据时会先数据拷贝到CPU1缓存中,然后置为独占状态(E),这时CPU2也使用了共享数据,也会拷贝也到CPU2缓存中。通过总线嗅探机制,当该CPU1监听总线中其他CPU对内存进行操作,此时共享变量在CPU1和CPU2两个缓存中的状态会被标记为共享状态(S);
若CPU1将变量通过缓存回写到主存中,需要先锁住缓存行,此时状态切换为(M),向总线发消息告诉其他在嗅探的CPU该变量已经被CPU1改变并回写到主存中。接收到消息的其他CPU会将共享变量状态从(S)改成无效状态(I),缓存行失效。若其他CPU需要再次操作共享变量则需要重新从内存读取
注意:当然,MESI也会有失效的时候,缓存的最小单元是缓存行,如果当前的共享数据的长度超过一个缓存行的长度的时候,就会使MESI协议失败,此时的话就会触发总线加锁的机制,第一个线程cpu拿到这个x的时候,其他的线程都不允许去获取这个x的值。

二、什么是线程

1、进程与线程

进程是系统分配资源的基本单位,线程是调度 CPU 的基本单位,一个进程至少包含一个执行线程,线程寄生在
进程当中。每个线程都有一个程序计数器(记录要执行的下一条指令),一组寄存器(保存当前线程的工作变
量),堆栈(记录执行历史,其中每一帧保存了一个已经调用但未返回的过程)

2、用户线程和内核线程

线程的实现可以分为两类:
1、用户级线程(User-Level Thread)
2、内核线线程(Kernel-Level Thread)

**用户线程:**指不需要内核支持而在用户程序中实现的线程,其不依赖于操作系统核心,应用进程利用线程库提供创建、同步、调度和管理线程的函数来控制用户线程。另外,用户线程是由应用进程利用线程库创建和管理,不依赖于操作系统核心。不需要用户态/核心态切换, 速度快。操作系统内核不知道多线程的存在,因此一个线程阻塞将使得整个进程(包括它的所有线程)阻塞。由于这里的处理器时间片分配是以进程为基本单位,所以每个线程执行的时间相对减少。
内核线程: 线程的所有管理操作都是由操作系统内核完成的。内核保存线程的状态和上下文信息,当一个线程执行了引起阻塞的系统调用时,内核可以调度该进程的其他线程执行。在 多处理器系统上,内核可以分派属于同一进程的多个线程在多个处理器上运行,提高进程执行 的并行度。由于需要内核完成线程的创建、调度和管理,所以和用户级线程相比这些操作要慢 得多,但是仍然比进程的创建和管理操作要快。大多数市场上的操作系统,如Windows, Linux等都支持内核级线程。

3、Java线程与系统内核线程关系

在这里插入图片描述

4、java线程的生命周期

新建、就绪、等待、运行、终止
在这里插入图片描述

三、为什么用到并发?并发会产生什么问题?

1、为什么用到并发

并发编程的本质其实就是利用多线程技术,在现代多核的CPU的背景下,催生了并发编程的趋势,通过并发编程的形式可以将多核CPU的计算能力发挥到极致,性能得到提升。除此之外,面对复杂业务模型,并行程序会比串行程序更适应业务需求,而并发编程更能吻合这种业务拆分 。
即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现 这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
并发不等于并行:并发指的是多个任务交替进行,而并行则是指真正意义上的“同时进 行”。实际上,如果系统内只有一个CPU,而使用多线程时,那么真实系统环境下不能并行, 只能通过切换时间片的方式交替进行,而成为并发执行任务。真正的并行也只能出现在拥有多个CPU的系统中。

2、并发的优点:

  • 充分利用多核CPU的计算能力
  • 方便进行业务拆分,提升应用性能

3、 并发产生的问题

  • 高并发场景下,导致频繁的上下文切换
  • 临界区线程安全问题,容易出现死锁的,产生死锁就会造成系统功能不可用
    线程上下文切换过程
    CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个 任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。

四、JMM模型

1、什么是JMM模型?

Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,并不真实在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈间),用于存储线程私有的数据,而Java内存模型中规 定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量, 工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区 域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

2、JMM不同于JVM内存区域模型

JMM与JVM内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过 这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子 性,有序性、可见性展开。JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数 据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈
在这里插入图片描述
**主内存:**主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静 态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安全问题。
**工作内存:**主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每 个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

3、JMM存在的必要性

在明白了Java内存区域划分、硬件内存架构、Java多线程的实现原理与Java内存模型的具体关系后,接着来谈谈Java内存模型存在的必要性。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数
据,线程与主内存中的变量操作必须通过工作内存间接完成,主要过程是将变量从主内存拷贝的每个线程各自的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。假设主内存中存在一个共享变量x,现在有A和B两条线程分别对该变量x=1进行操作,
A/B线程各自的工作内存中存在共享变量副本x。假设现在A线程想要修改x的值为2,而B线程 却想要读取x的值,那么B线程读取到的值是A线程更新后的值2还是更新前的值1呢?答案是, 不确定,即B线程有可能读取到A线程更新前的值1,也有可能读取到A线程更新后的值2,这是因为工作内存是每个线程私有的数据区域,而线程A变量x时,首先是将变量从主内存拷贝到A 线程的工作内存中,然后对变量进行操作,操作完成后再将变量x写回主内,而对于B线程的也是类似的,这样就有可能造成主内存与工作内存间数据存在一致性问题,假如A线程修改完后 正在将数据写回主内存,而B线程此时正在读取主内存,即将x=1拷贝到自己的工作内存中, 这样B线程读取到的值就是x=1,但如果A线程已将x=2写回主内存后,B线程才开始读取的话,那么此时B线程读取到的就是x=2
在这里插入图片描述

4、JMM-同步八种操作介绍

在这里插入图片描述

  1. lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
  2. unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  3. read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中, 以便随后的load动作使用
  4. load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
  5. use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
  7. store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中, 以便随后的write的操作
  8. write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中

5、可见性,原子性与有序性问题

原子性:原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响
可见性:可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享 变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但 此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题
有序性:有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解并没有毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白的是,在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象

6、JMM如何解决原子性&可见性&有序性问题

原子性问题:除了JVM自身提供的对基本数据类型读写操作的原子性外,可以通过 synchronized和Lock实现原子性。因为synchronized和Lock能够保证任一时刻只有一个线程访问该代码块
可见性问题:volatile关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。synchronized和Lock也可以保证可见性,因为它们可以保证任一时刻只有一个线程能 访问共享资源,并在其释放锁之前将修改的变量刷新到内存中
有序性问题:在Java里面,可以通过volatile关键字来保证一定的“有序性”,另外可以通过synchronized和Lock来保证有序性,很显然,synchronized 和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
Java内存模型:每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它 们进行重排序。
指令重排序:java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的意义是什么?JVM能根据处理器特性(CPU多级缓存系统、多核处理器等) 适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能

五、volatile关键字

1、volatile变量自身特性

可见性:即当一个线程修改了声明为volatile变量的值,新值对于其他要读该变量的线程来说是立即可见的。而普通变量是不能做到这一点的,普通变量的值在线程间传递需要通过主内存来完成。
有序性:volatile变量的所谓有序性也就是被声明为volatile的变量的临界区代码的执行是有顺序的,即禁止指令重排序。
受限原子性:这里volatile变量的原子性与synchronized的原子性是不同的,synchronized的原子性是指只要声明为synchronized的方法或代码块儿在执行上就是原子操作的。而volatile是不修饰方法或代码块儿的,它用来修饰变量,对于单个volatile变量的读/写操作都具有原子性,但类似于volatile++这种复合操作不具有原子性。所以volatile的原子性是受限制的。并且在多线程环境中,volatile并不能保证原子性

2、volatile可见性实现原理

public class VolatileVisibilitySample {
	//volatile 修饰	
    private boolean initFlag = false;
    static Object object = new Object();

    public void refresh(){
        //普通写操作,(volatile写)
        this.initFlag = true;
        String threadname = Thread.currentThread().getName();
        System.out.println("线程:"+threadname+":修改共享变量initFlag");
    }
    public void load(){
        String threadname = Thread.currentThread().getName();
        int i = 0;
        while (!initFlag){
//            synchronized (object){
//                i++;
//            }
            i++;
        }
        System.out.println("线程:"+threadname+"当前线程嗅探到initFlag的状态的改变"+i);
    }
    public static void main(String[] args){
        VolatileVisibilitySample sample = new VolatileVisibilitySample();
        Thread threadA = new Thread(()->{
            sample.refresh();
        },"threadA");

        Thread threadB = new Thread(()->{
            sample.load();
        },"threadB");

        threadB.start();
        try {
             Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        threadA.start();
    }
}

分析上面代码,线程B在initFlag变量为false的时候,无限循环叠加,线程A对变量initFlag设置成true,这时候,不加volatile修饰,启动测试类,会发现一直在循环,线程B感知不到线程A对变量initFlag的修改。当使用volatile修饰,那么线程A的修改线程B回立刻嗅探到。那么还有一个不使用volatile修改的办法,就是使用同步synchronized修饰,因为是同步,所以只能有一个线程进行变量++,那么中间就会存在线程的切换,线程切换会将当前执行的状态元数据信息刷新到主内存,所以另一个线程抢到CPU时间片,也是重新从主内存读取变量.
可见性实现原理:
volatile可见性的实现就是借助了CPU的lock指令,通过在写volatile的机器指令前加上lock前缀,使写volatile具有以下两个原则:

  • volatile写时处理器会将缓存强制回写到主内存
  • 一个处理器的缓存回写到主内存后会导致其他处理器的缓存失效

3、volatile有序性实现原理

public class VolatileReOrderSample {
    private static int x = 0, y = 0;
    private static int a = 0, b =0;
   public static void main(String[] args) throws InterruptedException {
        int i = 0;
         while (true){
             x = 0; y = 0;
             a = 0; b = 0;
             i++;
             Thread t1 = new Thread(new Runnable() {
                 @Override
                 public void run() {
                     a = 1;
                     x = b;
                 }
             });
             Thread t2 = new Thread(new Runnable() {
                 @Override
                 public void run() {
                     b = 1;
                     y = a;
                 }
             });
             t1.start();
             t2.start();
             t1.join();
             t2.join();
             String result = "第" + i + "次 (" + x + "," + y + ")";
             if(x == 0 && y == 0) {
                 System.err.println(result);
                 break;
             }
             System.out.println(result);
         }
    }
    }

在这里插入图片描述
上面的代码,若没有指令重排的情况下,应该只会有[1,1],[0,1],[1,0]三种情况,但是在运行后会发现还会出现[0,0]的情况,是因为发生的指令重排,加volatile关键字修饰即可,或者手动加内存屏障。
volatile在单例中也会使用,在单例中,使用双重检查锁来创建对象,但是若对象的创建过程发生了指令重排,那么还是会产生问题,因为对象的创建过程分为三步:1、首先在内存中开辟一个空间,2、初始化对象的信息 3、将对象的地址赋值给引用。后面两步也可能产生指令重排。所有在单例中也需要将单例对象用volatile修饰

volatile有序性的保证就是通过禁止指令重排序来实现的。指令重排序包括编译器和处理器重排序,JMM会分别限制这两种指令重排序。
那么禁止指令重排序又是如何实现的呢?答案是加内存屏障。JMM为volatile加内存屏障有以下4种情况:

  • 在每个volatile写操作的前面插入一个StoreStore屏障,防止写volatile与后面的写操作重排序。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障,防止写volatile与后面的读操作重排序。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障,防止读volatile与后面的读操作重排序。
  • 在每个volatile读操作的后面插入一个LoadStore屏障,防止读volatile与后面的写操作重排序

4、volatile受限原子性

public class VolatileAtomicSample {

    private static volatile int num = 0;
    private static AtomicInteger atomicInteger = new AtomicInteger();
    public static void main(String[] args){
        IntStream.range(0,10000).parallel().forEach(x->{
            num++;
            atomicInteger.incrementAndGet();
           //1 load num 到工作内存
            //2 add num 执行自加
        });
        System.out.println(num);
        System.out.println(atomicInteger);
    }
}

运行结果
原因是假如线程A在执行第一步的时候读取到此时num的值为3,然后在执行第二步之前,其他多个线程已经对该值进行了修改,使得num值变为了4。而线程A此时的num值就会失效,重新从主内存中读取最新值。也就是两个线程做了两次+1的动作,但实际的结果最后只加了一次1。所以这也就是最后的执行结果为什么大概率会是一个小于10000的值的原因。

六:总线风暴

通过前面内容可知,volatile可以保证变量的可见性,至于其他处理器是怎么知道数据是否失效的呢?
嗅探机制:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
可能产生问题:由于volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值。

总结

  • volatile修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如booleanflag;或者作为触发器,实现轻量级同步
  • volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁_上,所以说它是低成本的
  • volatile只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序
  • volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见,volatile属性不会被线程缓存,始终从主 存中读取
  • volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作
  • volatile可以使得long和double的赋值是原子的
  • volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值