码迷,mamicode.com
首页 > 编程语言 > 详细

java多线程与内存可见性

时间:2016-06-12 02:19:59      阅读:310      评论:0      收藏:0      [点我收藏+]

标签:

一、java多线程



二、内存可见性

1、什么是JAVA 内存模型

共享变量 :如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。
Java Memory Model (JAVA 内存模型)描述线程之间如何通过内存(memory)来进行交互,描述了java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。 具体说来, JVM中存在一个主存区(Main Memory或Java Heap Memory),对于所有线程进行共享,但线程不能直接操作主内存中的变量每个线程都有自己独立的工作内存(Working Memory),里面保存该线程使用到的变量的副本( 主内存中该变量的一份拷贝 ),线程对所有变量的操作并非发生在主存区,而是发生在工作内存中,而线程之间是不能直接相互访问,变量在程序中的传递,是依赖主存来完成的。
Java内存模型的抽象示意图如下:
 技术分享

两条规定 :

线程对共享变量的所有操作都必须在自己的 工作内存 ( working memory,是cache和寄存器的一个抽象,而并不是内存中的某个部分, 这 个解释源于《Concurrent Programming in Java: Design Principles and Patterns, Second Edition》§2.2.7,原文:Every thread is defined to have a working memory (an abstraction of caches and registers) in which to store values. 有不少人觉得working memory是内存的某个部分,这可能是有些译作将working memory译为工作内存的缘故,为避免混淆,这里称其为工作存储,每个线程都有自己的工作存储 )中进行,不能直接从相互内存中读写不同线程之间无法直接访问其他线程工作内存中的变量,  线程间变量值得传递需要通过主内存来完成。


2、什么是内存可见性?

可见性:线程对共享变量的修改能够被其他线程及时看到,就可以实现共享变量在线程之间的可见性。
共享变量可见性的实现原理:
线程A与线程B之间如要通信的话,必须要经历下面2个步骤:
1、线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2、线程B到主内存中去读取线程A之前已更新过的共享变量。
下面通过示意图来说明这两个步骤:
 技术分享
如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。
从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
要实现共享变量的可见性,必须保证两点:
1.线程修改后的共享变量值能够及时的从工作内存刷新到主内存中;
2.其他线程能够及时把共享变量的最新值从主内存中更新到自己的工作内存中。

3、导致共享变量在线程间不可见的原因

导致共享变量在线程间不可见的原因主要有指令重排序与线程交错执行;

指令重排序:代码书写的顺序与实际执行的顺序不同,指令重排序是编译器或处理器为了提高程序性能而做的优化。

1.编译器优化的重排序(编译器优化)

2.指令级并行重排序(处理器优化)

3.内存系统的重排序(处理器优化)

例如:

技术分享

2、是不是所有的语句的执行顺序都可以重排呢?答案是否定的。为了讲清楚这个问题,先讲解另一个概念:数据依赖性。如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖。数据依赖分下列三种类型:
 技术分享
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。所以,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。也就是说:在单线程环境下,指令执行的最终效果应当与其在顺序执行下的效果一致,否则这种优化便会失去意义。这句话有个专业术语叫做as-if-serial semantics (as-if-serial语义)

 ·as-if-serial语义:无论怎样重排序,程序实际执行的结果应该与代码书写顺序执行的结果一致 ( Java编译器、运行时和处理器都会保证Java在单线程下遵循as-if-serial语义 )

例如:

技术分享

在单线程中,第一行与第二行代码可以重排序,但第三行依赖于前两行,所以不能重排序,否则就会造成执行结果错误。重排序不会给单线程带来内存可见性问题,但在多线程中程序交错执行,重排序可能会造成内存可见性问题。

下面我们利用一个例子来分析指令重排序与线程交叉执行对内存可见性的影响:

3、重排序对多线程的影响

class ReorderExample {
int a = 0;
boolean flag = false;
 
public void writer() {
a = 1; // 1
flag = true; // 2
}
 
public void reader() {
if (flag) { // 3
int i = a * a; // 4
}
}
}

flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入?答案是:不一定能看到。

 
由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作1和操作2重排序时,可能会产生什么效果?请看下面的程序执行时序图:
 技术分享
 
上图的执行顺序是:2 -> 3 -> 4 -> 1 (这是完全存在并且合理的一种顺序,如果你不能理解,请先了解CPU是如何对多个线程进行时间分配的)
 
如上图所示,操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还根本没有被线程A写入,在这里多线程程序的语义被重排序破坏了!
 
下面再让我们看看,当操作3和操作4重排序时会产生什么效果。下面是操作3和操作4重排序后,程序的执行时序图:
 技术分享
 
在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。
从图中我们可以看出,猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了多线程程序的语义!
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

从上述两幅图中也可以发现线程交叉执行也是造成内存不可见的原因之一,如操作1和2是原子操作,且操作3和4是原子操作,那么程序会在启动线程A时,执行完线程A(操作1和操作2),才会执行线程B,并且在单线程中,重排序必须满足是as-if-serial语义,是不会改变执行结果的,这样就可以防止内存不可见。所以解决内存不可见的根本就是保持操作原子性保证原子性的方法:synchronized关键字、ReentrantLock可传入锁对象、AtomicInterger对象。


总结,导致共享变量在线程间不可见的原因 :

1.线程的交叉执行(解决方案:保证原子性 ,例如使用synchronized关键字)

 2.重排序结合线程交叉执行( 原子性 )

3.共享变量更新后的值没有在工作内存与主内存间及时更新( 可见性 )

    需要注意的是,一般在Java运行过程中,执行引擎会尽量揣摩用户的意图,所以很多时候都会看到正确的结果,但是哪怕只有一次不可预期的结果出现影响也是非常大的,所以,在需要内存可见性的时候,我们一定要保证线程的安全。

4、java实现线程共享变量的可见性(在语言层面)

Java语言层面支持的可见性实现方式 :synchronized、volatile、 final也可以保证内存可见性。

synchronized实现可见性

大多数情况下,我们认为synchronized可以实现互斥锁(原子性),即线程间同步。但很多人都忽略其内存可见性这一特性。

synchronized实现可见性原理,JMM关于synchronized的两条规定:

    线程解锁前,必须把共享变量的最新值刷新到主内存中。

    线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从内存中重新读取最新的值( 注意:加锁与解锁需要是同一把锁 )。这样,线程解锁前对共享变量的修改在下次加锁时对其他线程可见。

例子:通过多线程操作数字自增;

package testThread;
 
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
 
public class VolatileDemo {
 
//private Lock lock = new ReentrantLock();
private volatile int number = 0;
public int getNumber(){
return this.number;
}
public void increase(){
/*try {
Thread.sleep(100);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}*/
this.number++;
/*lock.lock();
try {
this.number++;//锁内执行程序可能会发生异常,为了保证锁能够释放,所以写在finally中;
} finally {
lock.unlock();
}*/
}
/**
* @param args
*/
public static void main(String[] args) {
// TODO Auto-generated method stub
final VolatileDemo volDemo = new VolatileDemo();
for(int i = 0 ; i < 500 ; i++){
new Thread(new Runnable() {
@Override
public void run() {
volDemo.increase();
}
}).start();
}
//如果还有子线程在运行,主线程就让出CPU资源,
//直到所有的子线程都运行完了,主线程再继续往下执行
while(Thread.activeCount() > 1){
Thread.yield();
}
System.out.println("number : " + volDemo.getNumber());
}
 
}
这里执行结果应该是500,但多执行几次发现有小于500的结果。因为自增操作不是原子操作,num++;//++操作非原子操作,分3步执行:1、从内存中读入num的值;2、temp=num+1;3、将中间值temp赋值给num,并写入内存;
这里我们就可以使用synchronized关键字来实现原子性,如:
public synchronized void increase(){
this.number++;
        /*synchronized (this) {
            this.number++;
        }*/
}
synchronized的本质是一把锁,所以我们还可以通过真正意义上的加锁和开锁来实现内存可见性(JDK1.5的高级并发特性,ReentrantLock可传入锁对象如上代码中注释部分;

volatile实现可见性

 1. 能够保证volatile变量的可见性(原理与synchronized关键字原理差不多)。当对 volatile变量执行读操作时,会在读操作前加入一条load屏障指令。当对volatile变量执行写操作时,会在写操作后加入一条store屏障指令;深入来说,通过加入内存屏障和禁止重排序优化来实现内存可见性。volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存。所以对其他线程是可见的。但是这里需要注意一个问题,volatile只能让被他修饰内容具有可见性,但不能保证它具有原子性。

 2. 不能保证volatile变量复合操作的原子性。如上述例子中,我们可否在num前面加volatile 达到内存可见性呢? 答案是否定的,volatile实现共享变量内存可见性有一个条件,就是对共享变量的操作必须具有原子性。比如 num = 10; 这个操作具有原子性,但是 num++ 或者num--由3步组成,并不具有原子性,所以是不行的。

技术分享

假如num=5,此时有线程A从主内存中获取num的值,并执行++,但在还未见修改写入主内存中,又有线程B取得num的值,对其进行++操作,造成丢失修改,明明执行了2次++,num的值却只增加了1.

3.volatile适用场合

在多线程中安全的使用volatile变量必须同时满足两个条件:

①对变量的写入操作不依赖其当前值,如number++不可以,boolean变量可以;操作本身必须是原子性的才能保证内存可见性;

 ②该变量没有包含在具有其他变量的不变式中,如果有多个volatile变量,则每个volatile变量必须独立于其他的volatile变量。

但大多数实际应用中,都会涉及到上述两个条件,所以volatile的使用时很少的。

synchronized和volatile比较

volatile不需要加锁,比synchronized更轻量级,不会阻塞线程,效率更高,从内存可见性角度讲,volatile读相当于加锁,volatile写相当于解锁。

synchronized技能保证可见性,又能保证原子性,而volatile只能保证可见性,不能保证原子性。 如果能用volatile解决问题,还是应尽量使用volatile,因为它的效率更高 。

  一个需要注意的点:

 问:即使没有保证可见性的措施,很多时候共享变量一人能够在主内存和工作内存见得到及时的更新?

答:一般只有在短时间内高并发的情况下才会出现变量得不到及时更新的情况,因为CPU在执行时会很快的刷新缓存,所以一般情况下很难看到这种问题,而且也 与硬件性能有很大的关系,所以,结果都是不可预测的,正是因为不可预测,所以我们才要保证线程的安全问题。

另:java中long、double是64位的,其读写会分成两次32位的操作,并不是原子操作,但很多商用虚拟机都进行了优化,所以,了解即可 。



三、Atomic变量和Thread局部变量

主要用于在高并发环境下的高效程序处理。使用非阻塞算法来实现并发控制。
java.util.concurrent  

java多线程与内存可见性

标签:

原文地址:http://blog.csdn.net/beatrice_g20/article/details/51628829

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!