Java内存模型与线程安全

原文链接:blog.edreamoon.com

Java内存模型

计算机cpu的运算能力强大,但是数据的存储相对于cpu运算能力需要消耗大量时间,为了充分利用运算能力引入了缓存,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题:缓存一致性。
线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行。
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须是工作内存中进行,而不能直接读写主内存中的变量。线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。交互关系如下图:

从更底层来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存。

 

线程安全

cpu计算时数据读取顺序优先级:寄存器-高速缓存-内存。线程耗费的是CPU,线程计算的时候,原始的数据来自内存,在计算过程中,有些数据可能被频繁读取,这些数据被存储在寄存器和高速缓存中,当线程计算完后,这些缓存的数据在适当的时候应该写回内存。
当多个线程同时读写某个内存数据时,各个线程都从主内存中获取数据,线程之间数据是不可见的,就可能会产生寄存器/高速缓存/内存之间的同步问题。线程安全的前提是该变量是否被多个线程访问,只要有多于一个的线程操作给定的状态变量,此时就可能产生多线程问题。比如,主内存变量A原始值为1,线程1从主内存取出变量A,修改A的值为2,在线程1未将变量A写回主内存的时候,线程2拿到变量A的值仍然为1,这就是多线程的可见性问题。

 

原子性、可见性与有序性

避免线程安全问题主要围绕着并发过程中的原子性、可见性、有序性这三个特征。

原子性(Atomicity)

原子性就是操作不能被线程调度机制中断,要么全部执行完毕,要么不执行。java内存模型确保基本类型数据的访问大都是原子操作,即多个线程在并发访问的时候是线程非安全的。比如”a = 2”、 “return a;”都具有原子性。但是类似”a += b”、”i++”的操作不具有原子性,所以如果add方法不是同步的就会出现难以预料的结果。在某些JVM中”a += b”可能要经过(取出a和b; 计算a+b; 将计算结果写入内存)步骤,如果有两个线程t1,t2在进行这样的操作。t1在第二步做完之后还没来得及把数据写回内存就被线程调度器中断了,于是t2开始执行,t2执行完毕后t1又把没有完成的第三步做完。这个时候就出现了错误,相当于t2的计算结果被无视掉了。再如,请分析以下哪些操作是原子性操作:

1. x = 10; //原子性操作 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
2. y = x; //非原子操作 实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
3. x++; //非原子操作 x++、x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。
4. x = x + 1; //非原子操作

注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性,这就导致了long、double类型的变量在32位虚拟机中是非原子操作。

可以使用AtomicXXX、synchronized和Lock保证原子性。synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,自然就不存在原子性问题了。

可见性

可见性就是一个线程对共享变量做了修改之后,其他的线程立即能够看到修改后的值。Java内存模型将工作内存中的变量修改后的值同步到主内存,在读取变量前从主内存刷新最新值到工作内存中,这种依赖主内存的方式来实现可见性的。
看下面这段代码:

1. int i = 0;//主内存
2. 3. //线程1执行的代码 4. i = 10; 5. //线程2执行的代码 6. j = i;

上面的代码可能出现下面情形,当线程1执行 i = 10时,会先把i的初始值加载到高速缓存中,然后赋值为10,那么高速缓存当中i的值变为10了,如果此时没有立即写入到主存当中,此时线程2执行 j = i,它会先去主存读取i的值并加载到缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。
Java提供了volatile关键字来保证可见性。当对非volatile变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中,非volatile变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。而声明变量是volatile的,会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会跳过CPU cache这一步去内存中读取新值。volatile只确保了可见性,并不能确保原子性。
synchronized也可以保证可见性。当ThreadA释放锁M时,它所写过的变量(比如,x和y,存在它工作内存中的)都会同步到主存中,而当ThreadB在申请同一个锁M时,ThreadB的工作内存会被设置为无效,然后ThreadB会重新从主存中加载它要访问的变量到它的工作内存中(这时x=1,y=1,是ThreadA中修改过的最新的值)。通过这样的方式来实现ThreadA到ThreadB的线程间的通信。

有序性(Ordering)

有序性:即程序执行的顺序按照代码的先后顺序执行。为了提高性能,编译器和处理器常常会对指令做重排序。CPU虽然并不保证完全按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致。重排序过程不会影响到单线程程序的执行,但会影响到多线程并发执行的正确性。
下面看一个例子:

1. //线程1:
2. context = loadContext();   //语句1
3. inited = true;             //语句2
4. 
5. //线程2:
6. while(!inited ){
7.   sleep()
8. }
9. doSomethingwithconfig(context);

代码中,由于语句1和语句2没有数据依赖性,可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

可以通过volatile关键字来保证一定的“有序性”,volatile关键字本身就包含了禁止指令重排序的语义,volatile前的代码还会在voaltile前,其后的代码还会在其后。另外可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性,synchronized标记的变量可以被编译器优化。

也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

 

volatile/synchronized/atomic

上面介绍中可以得知,synchronized是通过同一时刻只有一个线程执行共享代码来保证多线程三个特征的;volatile 变量具有 synchronized 的可见性特性,禁止指令重排,但是不具备原子特性。使用volatile变量,必须同时满足下面两个条件:

  • 对变量的写操作不依赖于当前值。
  • 该变量没有包含在具有其他变量的不变式中。

在目前大多数的处理器架构上,volatile 读操作开销非常低,几乎和非 volatile 读操作一样。而 volatile 写操作的开销要比非 volatile 写操作多很多,因为要保证可见性需要实现内存界定(Memory Fence),即便如此,volatile 的总开销仍然要比锁获取低,volatile 操作不会像锁一样造成阻塞。
AtomicXXX具有原子性和可见性,就拿AtomicLong来说,它既解决了volatile的原子性没有保证的问题,又具有可见性。它的实现使用了CAS(比较并交换)指令保证了原子性,AtomicLong的源码里也用到了volatile。

总结,当前常用的多线程同步机制可以分为下面三种类型:
volatile 变量:轻量级多线程同步机制,不会引起上下文切换和线程调度。仅提供内存可见性保证,不提供原子性。
CAS 原子指令:轻量级多线程同步机制,不会引起上下文切换和线程调度。它同时提供内存可见性和原子化更新保证。
内部锁和显式锁:重量级多线程同步机制,可能会引起上下文切换和线程调度,它同时提供内存可见性和原子性。

  

 

posted @ 2017-09-20 10:16  shindoyang  阅读(930)  评论(2编辑  收藏  举报