java多线程解说【贰】_java内存模型

上文:java多线程解说【壹】_什么是线程


上篇文章说到,在多线程下如果我们要保证原子性、有序性和可见性,那么我们就要采取一些措施来实现。首先就有一个问题,为什么在多线程下和单线程下的情况不同呢,因为这涉及到线程间通信。线程间通信的方式无非两种,一种是共享内存,一种是消息传递。可能都知道java是通过第一种方式实现的线程通信,那么具体是如何实现的呢,这就要从java内存模型说起。


java内存模型


这里要先简单说一下java虚拟机的知识。在java虚拟机中,主要有三块区域用于存放变量:堆(heap)用于存放引用类型的对象;栈(Stack)用于存放基本类型的对象及局部变量;永久区(Perm Gen)用于存放静态变量和final修饰的常量。其中,栈是线程私有的,而堆是公有的。每个线程都可以有自己的私有变量,保存在自己的线程栈中,如果变量是引用类型,则栈上保存的是引用地址,指向这个引用变量保存在堆上的真实地址。


《java多线程解说【贰】_java内存模型》


那么当线程间需要通信的时候,则分两种情况:如果需要传递的对象是基本类型的,由于基本类型的对象保存在私有的线程栈上,只能线程自己访问,所以传递的是该变量的拷贝副本;而当传递的对象是引用类型的时候,该对象保存在公有的堆上,因此只需传递该变量的引用地址即可。这里需要注意的一点是,如果一个引用类型变量的成员变量是基本类型,那么它依然会随该引用类型变量保存在堆上存储。


但是多个线程都拿到了对象的拷贝或引用并不就万事大吉了,因为有可能它拿到的并不是这个对象的最新状态,这就要从计算机底层的设计说起。



计算机硬件架构


《java多线程解说【贰】_java内存模型》

如上图所示,现代的计算机都是多核CPU,每个CPU中会维护一个CPU寄存器以提高计算速度。众所众知,CPU访问内存(主存)的速度是很慢的,因此CPU自身先集成了一级缓存和二级缓存以加快资源访问速度;而CPU内部寄存器的速度会比访问缓存更快一级,因此CPU会把正在或准备进行运算的数据保存在CPU内部寄存器


整体流程就是,当CPU需要访问主存时,会先读取一部分主存数据到CPU缓存,进而在读取CPU缓存到寄存器。当CPU需要写数据到主存时,同样会先flush寄存器到CPU缓存,然后再在某些节点把缓存数据flush到主存。


看了上面的图示我们也了解到,计算机各级存储并没有堆和栈的概念,也就是说不论是什么类型的对象,都是一样存储在各级存储器中的。而且根据该对象的使用状态,可能会保存在各级存储器中,如下图所示:


《java多线程解说【贰】_java内存模型》



这就引发了多线程并发时可能出现的原子性、有序性和可见性问题。


线程安全


先说说原子性,其实就是指线程的任务是一个原子操作。那么什么是原子操作呢,原子操作就是不可分割的操作,其结果只有两种状态:要么全部成功,要么全部失败。在java语言中,除了long和double外,其他的基本类型的操作都是原子操作。引用类型的赋值和引用操作也是原子的。但是需要注意的是,原子操作+原子操作!=原子操作。不用过多解释,从原子操作的定义也可以轻松得知。


long和double类型的读写操作不是原子操作的原因是,目前的JVM(java虚拟机)都是将32位作为原子操作,并非64位。当线程把主存中的 long和double类型的值读到线程内存中时,可能是两次32位值的写操作,这样就不符合原子操作的定义。


再说说有序性,有序性就是在多线程情况下,执行指令的过程中没有发生指令重排。指令重排说白了就是,源代码顺序和程序执行顺序不一致。如果在单线程下没有问题,因为编译器不会改变单线程程序语义;而在多线程下则有可能
出现如下三种情况:


1. 编译器优化重排序,比如编译器的优化;
2. 指令级并行的重排序,比如CPU指令执行的重排序;
3. 内存系统的重排序,比如缓存和读写缓冲区导致的重排序;


可见性就是一个线程修改的状态对另一个线程是可见的。如上文所说,由于现代CPU都有多级缓存,CPU的操作都是基于高速缓存的,而线程通信是基于内存的,这里就可能有个时间差。比如线程A和线程B都同时操作一个对象,线程A对对象加载到工作内存修改后还没来得及刷回主内存,线程B就去主存读取了对象,此时线程B获取的对象状态并不是最新的,这就是可见性问题。


volatile和synchronized


先简单粗暴一点。volatile可以解决有序性和可见性,synchronized可以解决原子性、有序性和可见性


先说说volatile。很多书介绍说它是轻量级锁,我感觉是不对的,因为它并没有排他性。首先看看volatile的作用,它是可以保证线程去访问对象时,每次都会从主存中获取而不是本地的副本。究其原理,它是借助于内存屏障(Memory Barrier)来实现的。内存屏障是一个CPU指令,它主要有两个作用:


1.管什么指令都不能和这条Memory Barrier指令重排序

2.强制刷出CPU缓存,保证CPU缓存和主存的数据实时一致;


在java语言中,如果一个变量是volatile修饰的,Java Memory Model(JMM)会在写入这个字段之后插进一个Write-Barrier指令,并在读这个字段之前插入一个Read-Barrier指令。这意味着操作一个volatile变量,就可以实现:

1. 写变量后加写屏障,保证CPU写缓冲区的值强制刷新回主内存;
2. 读变量之前加读屏障,使缓存失效,从而强制从主内存读取变量最新值;


根据java内存模型的happen-before原则,对volatile字段的写入操作先于读操作,即使两个线程同时修改和获取volatile字段,get操作也能拿到最新的值。


再说说synchronized,也就是我们常说的代码块。synchronized可以保证同一个时刻只能有一个线程进入临界区(synchronized后面的大括号内),synchronized还能保证代码块中所有变量都将会从主存中读,当线程退出代码块时,对所有变量的更新将会flush到主存,不管这些变量是不是volatile类型的。

其实synchronized就是java实现的一个隐式锁,每个对象在底层都维护了一个监视器锁(monitor)。当monitor被占用时(进入数=1)就会处于锁定状态,进入monitor的线程即为该对象的持有者;此时如果有其他线程想进入monitor将阻塞,直到之前的线程退出monitor(进入数=0)时才能进入而成为该对象的持有者。

总结一下volatile和synchronized的区别,有如下几点:


1.volatile不能保证原子性;而synchronized可以;
2. volatile仅能使用在变量级别,synchronized则可以使用在变量、方法、和类级别的;
3. volatile不会造成线程的阻塞,synchronized可能会造成线程的阻塞(其实就是上下文切换);
4. volatile的写成本较synchronized临界区低,但读成本高;
5. volatile标记的变量不会被编译器优化(重排序);synchronized标记的变量可以被编译器优化(重排序)


下篇文章:《java多线程解说【叁】_Thread的常用API实现



    原文作者:java内存模型
    原文地址: https://blog.csdn.net/xinzun/article/details/79136014
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞