多线程(一)JMM内存模型

必须前置声明JMM是Java Memory Model 即Java 内存模型,而JVM 是 Java Virtual Machine ,即Java 虚拟机,两者完全不同!JVM的知识会在后续补充。先了解JUC并发编程的知识能帮助更好的了解JVM。

Java多线程基础知识

一、基础认知(入坑必备)
直接先看代码
在这里插入图片描述
两个线程(main线程和T1线程),三个输出语句在运行时的输出情况。需要说明的是new Thread()动作产生之后,并没有输出任何信息,而是在t1.start();方法执行的时候才会去寻找t1线程对象需要执行的语句,也就是去执行run方法的内容,但是顺序似乎有与传统从上而下的运行顺序不一样,这就是多线程问题。为了更好的说明问题,之后的代码会写成这样,他们的原理和上图的是完全一样的。只不过在运用了java8的lambda表达式之后更加简洁了而已。
在这里插入图片描述
在这里插入图片描述
再次强调,在进入main线程之后,先执行那个线程的代码已经变得不确定起来,而这正是开发人员所面临的实际情况

二、两个关键字
入坑完毕,接下来是两个关键字 synchronized和volatile

1、synchronized

案例:多线程计算乘法。每个线程将数字a加1,共有10个线程,每个线程循环100次加1操作,也就是计算10乘以100.
在这里插入图片描述
这时候就出现了多线程的安全问题,运气比较好的时候计算结果正确,但总归会发生线程不安全的现象,导致计算结果错,为什么会出现这样的结果呢,(为了更好的说明问题,我将代码改变成了100个线程,每个线程做加1操作10次)将计算过程输出出来之后如下:在这里插入图片描述
最终的结果是997,说明在其中有三次的加一操作是执行失败的,而通过打印信息也发现在程序开始的时候,T1线程和T3线程同时将数据从0改变为了1,并且他们都不知道对方正在操作这个数据,或者说,他们没有建立一种良好的机制来避免这个问题,这就是多线程在操作数据的时候会面临的安全问题,也就是数据的最终一致性不能得到保证。
所以,怎么解决这个问题呢?
要说清楚怎么解决,相当于就要把整个JUC并发编程的知识说清楚。之后进行梳理统计吧。但先把目前的问题解决了再说!

综上案例,synchroized,同步代码锁,最常用的是将一段代码加锁,意思是,这一部分代码不能被多个线程同时执行,只能由一个线程来运行它。代码如下,将线程的数量扩大到1万个,在计算数据的时候并没有出现线程不安全问题。
在操作数据的方法上添加synchronized关键字能很好的解决数据不一致性问题,但是坚决不推荐使用该方法,因为他会导致并发量急剧下降,因为他是一种锁的机制,将这段代码锁定,只允许一个线程去运行,其他线程调用的时候若已经被锁定只能等待

在这里插入图片描述
2、初识volatile
如下案例,两个线程T1线程和main线程,一个比较经典的情况出现了,T1线程在进去之后暂停一秒钟,随后把i的值加一,变为1,然后退出。main线程判断i的值跟0是否一致,若不一致才会结束main线程,但实际的情况是main线程一致没有结束(左边还是小红点),等等,i的值不是已经改变成为1了吗,此时程序应该退出才对啊,为什么没有按照“正常逻辑”来呢?这里需要引入一个概念:JMM内存模型,在这之前,让我们先把程序正常结束吧!如下两种代码:
在这里插入图片描述
在这里插入图片描述

JMM内存模型

JMM : Java Memory Model 即Java内存模型。是一种抽象的内存计算模型。这个模型规定,内存数据计算要保证:原子性、内存可见性和指令有序性。这里要说明的是,内存模型只有放在多线程环境下才是有意义的,并且这是一种理想化的模型,在进行高并发的多线程编程的时候,要尽最大努力保证满足这三点。
说清楚这个概念其实很简单,只要牢记主物理内存和线程自己的工作内存。就拿刚刚volatile的例子来说:刚开始,main线程和T1线程都获在主物理内存上读取到 i 的数据为 0,此时,他们都会将数据0拷贝到自己的工作内存中,由于T1线程比较快,他在计算完毕之后立马将 i 的最新值 1 放回主物理内存中,而由于数据 i 没有加volatile关键字,main线程无法得知当前的数据 i 已经被其他线程修改为了 1 ,故他自身会继续使用已经“过时”的数据i 也就是0 进行数据运算。所以,mian线程无法结束,程序也就没办法终止。 而当数据 i 被关键字volatile修饰之后,当T1线程修改为 1之后,其他线程能知道最新的值是 1 ,会重新将 1 拷贝回自己的工作内存中进行运算,运算完毕之后,其他线程也能知道当先数据的最新版本值。 这就是volatile保证数据可见性。

volatile关键字详解
根据JMM内存模型,volatile关键字可以满足其中的两点,可见性、有序性,但不能保证原子性。可见性刚刚的案例已经解释过,那么,原子性呢?
原子性指的是不可分割!也就是正在某个线程正在做某个具体业务的时候,中间不可以被加载或者是被分割,需要整体完整的同时成功,或者同时失败。而很遗憾,volatile并不能保证数据操作过程中的原子性。案例如下,最终的结果还是存在着多线程计算时不安全问题。
在这里插入图片描述
但要特别说明的是,volatile关键字却是在实际中使用最多、最频繁、最受欢迎的解决多线程安全问题的方法,它被称为轻量级的“锁”!
怎么解决当前的困难呢,在强大的JUC中已经给出了解决办法,也就是:原子类变量,以及强大的导包装一个对象的原子引用

原子类变量
案例:
在这里插入图片描述
除了对一个整形变量的原子性支持,JUC并发变成包下同样还有其他基本数据类型的支持。
说完原子性,volatile还有剩下一个强大的属性,禁止指令重排序!
这个案例实在是想不出什么很好的案例来引入问题,概念性较强!

指令重排

CPU为了保证计算的效率,会对指令进行排序。粗浅的举例,比如:有3条指令等待着cpu的计算,A指令将显示器某个位置的颜色变为蓝色,B指令代表将一个文件从一个文件夹移动到另一个文件夹,C指令代表将一个数据从内存的某个位置取出来。本来他们送给CPU的顺序是ABC,再假设B指令操作需要耗费的计算资源更多,所以CPU为了提高效率把B排序到最后,就变成了ACB的执行顺序。而这仅仅只是CPU的指令重排序,实际的情况是不光CPU会进行指令重排序,Java编译器也会,更不用说这些源程序经过编译器解析成Java识别的字节码文件,再输入到内存,交给寄存器取地址,CPU计算等等一系列操作,很可能写出来的Java代码到CPU执行的时候已经面目全非。

int a,b,x,y=0;
线程1、线程2
x=a;
y=b;
b=1;
a=2;
x=0 y=0
CPU、编译器对这段代码进行执行重排优化后,可能出现下列情况。
线程1、线程2
b=1;
a=2;
x=a;
y=b;
x=2 y=1
面目全非的顺序导致了面目全非的执行结果

源代码 -> 编译器优化重排 -> 指令并行排序 -> 内存系统排序 -> 最终执行的命令
而本身程序代码层面的排序有,为了提高性能,编译器和处理器会对指令进行排序:
1、单线程环境中,确保程序最终执行的结果和代码的瞬息一致。
2、处理器在进行重排序的时候必须考虑指令之间的数据依赖性。
3、多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
由于编译器存在指令重排,顺序和数据会被打乱,但编译器会认为这是一种优化,而刚好程序在多线程环境下面运行,这个时候会导致执行的顺序不一样,最终的结果就是数据的一致性没有办法保证。所有有时候我们需要用volatile关键字去禁止指令的重新排序。

也许会有这样的以为,类似 i++ 这样的简单数据操作是不是不管怎么排序都不会有问题,其实不然,下面是一段Java编译之后的字节码文件,可以看到一个简单的赋值操作都会变成多条指令,所以指令重排序在多线程环境中是要竭力避免发生的。
在这里插入图片描述
volatile关键字禁止指令重拍的底层原理
内存屏障(Memory Barrier) 又称为内存栅栏,是一个CPU指令,他的作用有两个:
1、保证特定操作的执行顺序
2、保证某些变量的内存的可见性(利用该特性可以实现volatile的内存可见性)
由于编译器和处理器都能执行指令重排序。如果在指令之间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能喝这条Memory Barrier指令重新排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障的另一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

对Volatile变量进行写操作的时候,会在写操作之后加入一条store屏障指令,将工作内存中的共享变量值刷新到主内存。
对Volatile变量进行读操作的时候,会在读操作之前加入一条load指令,从内存中读取共享变量。

双端检索的单例模式

volatile关键字在并发编程中很重要,读写锁、缓存、单例模式等等场合都会使用到它,并且在JUC并发包的底层也是大量的使用volatile关键字,本篇结尾将使用volatile写一个双端检索,能使用在多线程环境下的单例模式。
经典模式
在这里插入图片描述
并发编程模式
在这里插入图片描述
DCL(双端检索) 机制不一定线程安全,原因是有指令重排序的存在,加入volatile关键字可以禁用指令重新排序。
原因在于某一个线程执行到第一次监测,读取代的instance不为null时,instance的引用对象可能没有完成初始化。
instance = new SingletonDemo(); 可以分解为以下3步完成:

  • 1、分配对象内存空间 memory = allocate();
  • 2、初始化对象 instance(memory);
    3、设置instance指向刚分配的内存地址,此时instance !=null
    instance=memory

步骤2和步骤3不存在数据依赖关系,而且无论是重排之前还是重排之后的执行结果在单线程中并没有改变,因此这种重新排序优化是允许的。
有时候,编译器重新排序之后,顺序会变成 1、3、2

  • 1、分配对象内存空间 memory = allocate();
  • 3、设置instance指向刚分配的内存地址,此时instance !=null,但是对象初始化还没有完成。 instance=memory
  • 2、初始化对象 instance(memory);

但是指令重排只会保证串行语义的执行一致性(单线程),并不会关系多线程之间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未初始化完成,也就造成了线程安全问题。

加volatile的DCL
在这里插入图片描述
volatile未完待续,下一篇将讲解Volatile底层的CAS算法,自旋锁,ABA问题!

猜你喜欢

转载自blog.csdn.net/XiaoA82/article/details/103317733