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

 2019-12-10 15:58  阅读(849)
文章分类:Java 并发编程

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

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

java内存模型

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

20191210001454\_1.png

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

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

计算机硬件架构

20191210001454\_2.png

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

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

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

20191210001454\_3.png

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

线程安全

先说说原子性,其实就是指线程的任务是一个原子操作。那么什么是原子操作呢,原子操作就是不可分割的操作,其结果只有两种状态:要么全部成功,要么全部失败。在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写缓冲区的值强制刷新回主内存;

    1. 读变量之前加读屏障,使缓存失效,从而强制从主内存读取变量最新值;

根据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可以;

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

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

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> java多线程解说【贰】_java内存模型

相关推荐