悟了!原来Java多线程和内存模型这么简单

线程概念

为什么要引入线程

  1. 在出现线程之前,进程有2个基本的属性:作为操作系统分配资源的最小单位以及CPU调度的最小单位,那为什么要引入线程呢?其实这个主要是因为进程太重了,对于一个进程来说,需要经历创建进程、撤销进程、进程切换等等场景,而因为进程是操作系统分配资源的最小单位,所以会涉及到资源分配的回收(如I/O回收、CPU的上下文切换),都是比较耗费时间,所以出现了线程的概念
  2. 聪明的人们就想了,既然进程主要是因为一次切换需要耗费大量的时空开销(包括资源回收再分配、CPU上下文切换),那么能不能把进程的2个属性分离,让进程作为资源分配的最小单位让线程作为CPU调度的最小单位,如此一来,对于一个进程内的多个线程执行任务时,就无需考虑资源的回收,也就不会对拥有资源的单位进行频繁的切换,仅仅需要考虑保留CPU运行环境即可,而线程仅仅拥有少量资源运行时所需要的资源(如程序计数器),所以在CPU切换时也是比较快的

并行和并发

  1. 并行是指任务A和任务B在同一时刻在不同的CPU上运行,并发是指任务A和任务B在某一段时间内在同个CPU上交替执行
  2. 在上个世纪,在以进程为CPU调度单位的前提下,并发解决了单核CPU无法同时处理多个任务的请求(如听歌、玩游戏无法同时进行),随着慢慢的有了多核CPU,出现了并行的情况,但是对于一个CPU中的某个核来说,由于进程的创建、撤销、切换还是太耗费时空资源,因此提出一个进程内包含多个线程,多个线程之间并发运行
  3. 一个进程可包含多个线程,多个线程之间共享进程的资源,只保留自身一点点运行时所需要的资源,可想而知线程的创建、切换、撤销远远比进程的切换要快的多,从而多线程可以提高系统资源利用率和吞吐量

CPU多级缓存模型

为什么需要多级缓存模型

局部性原理

在介绍多级缓存模型之前我们先介绍一下局部性原理:

  1. 时间局部性原理:如果该内存地址被访问过,那么在不久以后该内存地址可能再次被访问(例如程序中定义一个变量,我们正常情况下不可能只调用一次,如for循环中的i会被循环使用多次)
  2. 空间局部性原理:如果程序访问了某个内存地址,在不久以后其附近的内存地址也将被访问(例如当使用for循环访问数组时则可体现空间局部性原理)

多级缓存

  1. 根据局部性原理,CPU在极小的一段时间内所需要访问的内存地址其实是局限在某个范围的内存地址中,又因为内存和CPU之间的读取速度差异太大,如果我们使用缓存,在缓存中每次缓存CPU需要访问的内存地址及其附近的内存区域,那么则可以提高其命中概率,据统计,命中概率达80%以上

  2. 对于缓存中未加载的数据,缓存也可以从内存中对该数据及其附近范围的数据进行加载,再传递给CPU,从而形成了内存 -> 缓存 -> CPU三层架构,大大解决了CPU与内存间的读取速度不匹配的矛盾

  3. 反过来,如果不使用缓存机制的话,那么CPU每次都需要去内存中取数据,可能上个数据已经处理完毕,下个数据还未从内存中取出,导致CPU被迫摸鱼睡大觉~

  4. 我们画个图看看多核CPU下的多级缓存
    CPU多级缓存模型

  5. 多级缓存主要有L1、L2、L3三级缓存,序号越小越接近CPU,速度更快,CPU访问数据时从上到下依次进行缓存的读取,如果没有命中则到内存中将数据加载进缓存

缓存的数据一致性问题

  1. 设想这样一个场景:如果CPU中的2个小CPU(称为CPU1和CPU2)同时运行,程序中有一个全局变量a=1,CPU1的缓存中有缓存int类型的变量a,CPU2的缓存中也有缓存int类型变量a
  2. 如果CPU1对变量a进行修改为a=2,此时如果CPU2也对其进行修改,由于CPU2的缓存中有a且a的值为1,则CPU2会对其直接从缓存中读取a的值而不会再从主内存中读取,会直接进行a+1的操作,此时CPU2的a值为2而非3,这就是从硬件的角度缓存带来的数据不一致问题,我们画个图再理解一下:
    缓存数据不一致问题
MESI缓存一致性协议
  1. MESI:主要解决的就是上文提及的多核CPU缓存间的数据不一致问题
  2. 解决方案:在CPU1在取a的值时,必须有一种机制来通知CPU2的缓存,告诉它“a的值已经被CPU1使用啦,你要用a的值得回去内存中重新取哦“,这个就是缓存一致性协议的大体内容
  3. 具体细节就不加叙述了,只要知道这个协议是通过通知机制解决缓存一致性问题即可~

java内存模型

  1. java内存是jvm屏蔽了底层的硬件,将其进行抽象的模型
  2. java内存模型中,每个线程都有自己的私有内存(例如独立的程序计数器、栈),对于共享变量来说,每个工作内存中都有一份共享变量的副本
  3. 线程操作共享变量时,先从主内存将数据读取进入自己的私有内存,再从自身的私有内存中读取数据,再修改完成后再刷回私有内存,最后再刷回主内存
  4. 本地内存中存储了该线程以读、写的共享变量副本,共享变量副本类型有数组、实例域、静态域
  5. java内存模型的本质是规定了线程读取内存中数据的规则以及线程与线程通信的规则
  6. 画个图再加深对内存模型的印象:
    java内存模型
  7. 有没有觉得java内存模型与CPU缓存模型很相似,本质上java内存模型可以理解为就是对硬件层面的抽象,阐述了多核CPU下多线程运行及通信的规则

软件层面的一致性问题

  1. 由于java内存模型也是在硬件缓存上的抽象,所以同样也存在数据一致性问题(java层面我们通常称为线程之间不具有可见性),我们写个demo验证一下:
public class JavaMemoryModel {


    static boolean flag = false;

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("未改变flag前,flag为:" + flag);
                flag = true;
                System.out.println("改变flag后,flag为:"+ flag);
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
               while(!flag) {

               }
            }
        });
        t2.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        t1.start();
    }
}

未添加volatile的并发结果
2. 可以从结果中看到由于线程t2的私有内存中已经有了flag,那么就不会去主内存中取flag,也就不知道flag已经从false改为true了,结果t2线程就会一直不间断运行,即产生了线程之间不可见性问题(注意:不要在t2线程中加上sleep语句,否则可能就体现不出不可见性了,因为sleep时CPU可能会再从主内存中取数据刷新数据到私有内存中)

volatile解决一致性问题

  1. volatile是java为了解决java线程的不可见性所提出来,只需在共享变量前加上volatile关键字,则该共享变量具有可见性,因为java内存模型也是建立在多级缓存模型的基础上,所以具有数据不一致问题,可想而知,volatile的可见性的底层原理其实就是通过软件层面的抽象间接地使用了硬件层面的MESI协议
  2. 按照上面的demo,我们在共享变量flag前加上volatile,此时结果不再无限循环
    添加了volatile的并发结果
  3. 事实上,其实线程将缓存的数据中刷回主存也就有一定规则的,该规则也会限制可见性,这里我们先做个伏笔,下文再把坑给填上~
  4. 总结:
    1. 线程不会主动读取主存中的共享变量,只会从缓存中读取,因此为了保持可见性需要加上volatile关键字
    2. 线程将数据刷会主存的时间其实是未知的,但是有一整套规则限制其刷回主存的时间,下文再解释~
    3. 我们从多核CPU的硬件缓存讲到了硬件方面的缓存一致性问题,使用MESI协议解决了缓存一致性问题;再从java内存模型对硬件进行抽象,讲到了java多线程并行时的数据一致性问题,使用volatile关键字解决数据一致性问题;事实上,数据一致性和缓存一致性是相同概念对于不同角度的理解,数据一致性是软件层面的叫法,缓存一致性是硬件层面的叫法,但其本质都是一样的~

线程安全3大特性

原子性

Java内存模型中的原子操作

  1. 原子性指某个操作是不可分割的、不可被中断的,有点类似于MySQL中的事务,在多线程中具体体现为CPU执行一条语句的时候是原子性的
    2 CPU中有lock(锁定)、unlock(解锁)、read(读取)、load(载入)、use(使用)、assign(赋值)、store(存储)、write(写入)等原子操作,即操作这些指令时都是原子性,例如不能执行lock指令执行一半去执行read指令
  2. 我们对8种原子操作做个解释:
    1. lock(加锁):把主内存中的一个共享变量标记为一个线程独享的状态
    2. unlock(解锁):把主内存的变量从线程独享的lock状态进行解锁
    3. read(读取):把主内存的一个共享变量传输进行工作内存中
    4. load(载入):把read操作传入工作内存的共享变量进行赋值
    5. use(使用):将工作内存中的变量副本的值传递给CPU进行计算
    6. assign(赋值):将CPU的计算结果重新赋值给工作内存中的副本
    7. store(存储):将修改过的工作变量的值传递到主内存中
    8. write(写入):把传递到主内存中的变量值重新写回给主内存的共享变量
  3. 还是以a=a+1为例,我们画个图加深一下8大操作的执行过程:
    java内存模型8大原子操作
  4. 这里的lockunlock操作是指广义上的加锁和解锁,此举是为了保证原子性,防止多线程对共享变量同时操作,而lock和unlock是一个概念,落实到实现就是java中的Synchronized关键字CAS操作,Synchronized称为悲观锁,CAS称为乐观锁
  5. 除了lock和unlock之外的其他原子操作,其他原子操作实际上是java内存模型为我们定义的一系列规范,我们可以根据该原子操作来判断执行语句是否是原子操作,我们举个例子说明一下:
    1. 线程A执行a=1和线程B执行a=2,无论取值、赋值、刷回内存的先后,赋值操作是原子操作,不管赋值操作是否会出现结果覆盖,至少该赋值语句是一次性执行成功的,因此该语句肯定为原子操作
    2. 又如a++,如果a++是原子性操作,那么在5个线程同时并发操作共享变量a时a肯定为5,实际上a可能出现小于5的情况,因为没有进行锁操作,任何线程对a的读取都不是互斥的,因此可能在线程A读取a值为3且未进行a的自增操作并刷入主内存的操作前,线程B读取到一样的a值也是为3,此时就会出现并发问题,因此自增操作不是原子性的,我们可以写个demo验证一下:
public class JavaMemoryModel {
    static int a = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            int i = 0;
            // 担心运行结果太快看不出来,循环一万次可提高验证自增操作为非原子性操作
            while(i++ < 10000) {
                a++;
            }
        });
        Thread t2 = new Thread(() -> {
            int i = 0;
            while(i++ < 10000) {
                a++;
            }
        });
        t1.start();
        t2.start();
        // 睡眠保证2个线程都已经执行完成
        Thread.sleep(3000);
        System.out.println(a);
    }
}

每个线程循环1万次,结果如图,并非a=20000,验证了自增操作的非原子性
自增结果

可见性

定义

其实上文我们对可见性已经有了大概的了解,我们总结一下:可见性就是线程A修改了共享变量的值需要通知其他线程缓存中的共享变量已经失效,如果需要使用该变量需要到内存中重新取,而如果线程B不知道共享变量的值已经发生了改变,此时则体现了不可见性,我们可以通过volatile关键字解决不可见性问题

可见性并不能解决原子性

可见性与原子性的区别

  1. 上文我们讲到java内存模型的可见性问题,但是可见性并不能解决原子性问题,举个例子,在共享变量a加上volatile关键字后,事实上,只要CPU2是在CPU1将a的值刷回主内存之前的任意时刻从内存中读取共享变量a,读取到的a的值都是1,从而CPU2刷会主内存的a值也肯定为2,其实这个例子也可以说明a+1并不是一个原子操作,所以会产生原子性问题
  2. 请格外注意可见性和原子性,可见性是由于线程读取私有内存而忽视主内存可能已经更新的值造成的结果;而原子性是由于线程读取主内存中的值但是不进行互斥访问导致的

不可重排序

为什么要有重排序

  1. 在执行程序时,为了提高程序的运行效率,会对指令进行重排序,举个例子:线性A首先进行加锁环境下a++操作,再进行加锁b++操作,线程B也是先进行加锁a++操作,再进行加锁b++操作,那么此时如果按照执行语句顺序的话线程B需要等待线程A执行完成a++才能进行a++操作,需要等待A线程的b++操作执行完成后再进行b++操作,但是如果进行重排序,在线程A执行a++时线程B进行b++则可提高效率,因此引入了重排序。
  2. jvm会保证在单程序的运行情况下重排序后的运行结果和重排序前的运行结果相同,因此重排序对于具有前后依赖的执行语句并不会进行重排序,如a = 1,b = a,肯定会先执行a = 1再执行b = a才能保证执行结果的正确性,但是并不保证多线程环境下重排序结果的正确性,运行效率如图:
    重排序和未重排序

为什么要禁止重排序

  1. 单线程环境下进行重排序不会有任何问题,而且可以提高程序的运行效率,但是在多线环境下会发生一些错误,举个例子,在tomcat启动后完成初始化我们才能开启一些功能,如tomcat的Initial()方法执行完后我们才会去加载某些插件,但是重排序的话,此时可能会出现一些问题:
		static flag = false;
 		Thread A = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("完成Tomcat的初始配置后再设置flag为true");
                flag = true;
            }
        });
        
        Thread B = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!flag) {
                    System.out.println("进行循环等待");
                }
                System.out.println("进行插件加载");
            }
        });
  1. 如果在多线程情况下,因为重排序只是承诺不会改变单线程重排序后的运行结果,所以实际上线程A是可以在Tomcat完成初始化配置前将flag设置为true(因为初始配置和设置flag为true在线程A中实际上是无前后关系的,符合重排序规则),所以此时线程B如果知道flag为true后就会去加载插件,此时就会发生错误!,所以多线程情况下必须禁止重排序
  2. volatile关键字不仅使线程之间具有可见性,而且还具有禁止重排序的功能,使用volatile即可禁止重排序,所以可在上面代码的flag前加上volatile即可,根据作者理解,volatile实际上是先使线程具有可见性,因为可见性和重排序之间的矛盾,所以volatile才加上了禁止重排序的功能

双重检查锁定与延迟初始化

  1. 在多线程中我们会使用延迟初始化和双重检查来降低创建对象的开销,我们看一下如何进行多线程环境下的双重检查:
    双重检查
  2. 事实上,上文的双重检查是错误的!,这个涉及到对象的创建过程:
    1. 给创建的对象开辟内存空间
    2. 进行对象的初始化
    3. 对象的引用指向该片内存空间
  3. 我们前面讲过在不影响单线程执行结果的情况下可以进行重排序,对象的创建也是如此,可以先让对象的引用指向系统分配给对象的内存空间,再进行对象初始化,那么问题就来了,我们画个图说明一下重排序后的问题在哪:
    延迟加载出现的问题
  4. 从上图我们可以知道,当对象创建出现重排序时,由于先进行引用赋值再进行对象初始化,所以线程B一旦知道了引用已经被赋值则会调用对象中的方法,此时因为对象还未初始化,所以会发生错误,解决这个错误也很简单,只需在创建对象的类前加上volatile即可,则不会出现对象创建时重排序的情况

多线程并发的2个语义

as-series-if

as-series-if的语义实际上我们上面已经讲过了:无论如何进行重排序,单线程的执行结果都不会被改变,为了遵守该语义,编译器和处理器都不会对具有单线程环境下具有依赖关系的指令进行重排序,因为这样会导致程序执行的结果并非按照程序顺序执行的结果

happens-before

  1. 上文我们讲过因为可见性问题我们引入了volatile关键字解决了多线程之间对于共享变量的可见性问题,但是对于线程与线程之间呢?这个就是我们接下来要讲到的happens-before原则,用happens-before的概念来阐述操作之间的内存可见性
  2. 概念:如果A线程中的操作先于在B线程操作的发生,那么只要遵循happens-before的8大原则则可实现数据之间的可见性, 两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前
    1. 同一线程内Java内存模型(Java Memory Module:简称JMM)保证了指令会按照程序的执行顺序进行执行,前面的指令对后面的指令具有可见性,但是实际上对于无前后依赖的操作是可以进行重排序的
      举个例子:a=3;b=a;c=1,此时happens-before会保证a=3是在b=a操作前执行,但是对于c的赋值语句因为该语句并无与之前的语句具有依赖关系,所以可以进行重排序来提高运行效率
    2. 监视器锁:A线程对共享变量进行加锁后,在进对共享变量修改后进行解锁,此时如果B线程要对其加锁时A线程对共享变量的修改结果对B线程可见
      举个例子:线程A使用Synchronized关键字加锁后对共享变量a将其从a=0修改为a=1,当B线程要再次对共享变量a进行一个自增a++操作时,此时线程A对共享变量a的修改结果a=1对于B线程是可见的,此时如果B线程进行自增操作时a值会从a=1自增为a等于2
    3. volatile变量:修改共享变量后再进行读取该共享变量,则修改的共享变量对读取共享变量可见,即可读取修改后的共享变量
    4. 线程启动:线程A中启动线程B,则A在启动线程B前的操作对线程B可见
    5. 线程加入:线程A执行join()线程B,那么当线程B执行成功返回后,线程B中的任意操作都对A线程可见
    6. 传递性:如果线程Ahappens-before线程B,线程Bhappens-before线程C,则线程Ahappens-before线程C,即线程A的操作结果对线程C是可见的
    7. 对象finalize:一个对象的初始化完成先于发生它的finalize()方法,因为一个对象得先创建再销毁,所以肯定先初始化再调用finalize()
    8. 线程中断:对于调用了interrupt()方法对线程进行中断的操作,其对线程进行中断检测的是具有可见的,即如果线程A执行了interrupt()方法后,对于检测中断的线程B来说,线程A的操作结果对线程B是可见的
  3. 本质上happens-before的所有规则都是规定了当某个特定操作完成后,数据必须立刻从私有内存中写回主内存,当要进行某个数据的读取时必须从主内存中进行读取进私有内存,从而实现可见性,而happens-before原则则是规定了哪些操作需要遵守写回主内存以及从主内存读取数据的规则
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值