【并发编程】从Java内存模型看并发数据共享与线程安全

Java内存模型规定了如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

Java内存模型原理

Java内存模型将Java虚拟机划分为线程栈和堆,如图:

《【并发编程】从Java内存模型看并发数据共享与线程安全》

每一个运行在Java虚拟机中的线程都有一个自己的线程栈,栈中包含这个线程调用的方法当前执行点相关的信息。一个线程仅能访问自己的线程栈,所以一个线程创建的本地变量只有其创建线程可以访问,其他线程不可见。

所有原始类型的本地变量都存放在线程栈上,对其他线程不可见。所以一个线程可能给另一个线程传递原始类型变量的拷贝,而不是共享这个原始类型的变量。

堆上用来存放Java程序中创建的任何对象类型数据,无论是哪一个线程创建的对象,或者这个对象创建后被赋值给一个局部变量,或者用来作为另外一个对象的成员成员变量,等等,都是存放在堆上。

如图可知,调用的方法和本地原始类型的本地变量是存在栈中,而对象则是存在堆中:

《【并发编程】从Java内存模型看并发数据共享与线程安全》

一个本地变量若是原始类型,放在栈上。

一个本地变量若是指向一个对象的引用,那么引用存放在栈上,而对象存放在堆上。

一个对象可能包含方法,而方法可能包含本地变量,这些本地变量存放在栈上,即使这些方法所属对象存放在堆上。

一个对象的成员变量可能随着对象存放在堆上,不管这个成员变量是原始类型还是引用类型。

静态成员变量也跟随类存放在堆上。

总结来说:本地变量,都是存放在栈上。对象,都是存放在堆上。对象的成员变量,会跟随对象存放在堆上。对象的成员函数包含的原始类型变量,则存放在栈上,因为它对于函数来说,就本地变量。

存放在堆上的对象可以被持有这个对象引用的线程所访问。当一个线程访问这个对象时,也可以访问它的成员变量。如果两个对象同时访问一个对象,他们会同时调用这个对象上的同一个方法,都将访问同一个成员变量,但是每一个线程得到的都是这个成员变量的私有拷贝。

《【并发编程】从Java内存模型看并发数据共享与线程安全》

如图,两个线程拥有各自的线程栈,其中有一个本地变量Local variable2指向堆上的Object3对象。两个线程都拥有一个对象Object3的不同引用,引用是本地本地变量,所以存放在栈上。

其中,Object3持有Object2和Object4对象的引用,因而Object2和Object4是Object3的成员变量,所以这两个线程也可以访问Object2和Object4。

图中示例代码:

public class MyRunnable implements Runnable() {

    public void run() {
        methodOne();
    }

    public void methodOne() {
        int localVariable1 = 45;

        MySharedObject localVariable2 =
            MySharedObject.sharedInstance;

        //... do more with local variables.

        methodTwo();
    }

    public void methodTwo() {
        Integer localVariable1 = new Integer(99);

        //... do more with local variable.
    }
}


public class MySharedObject {

    //static variable pointing to instance of MySharedObject

    public static final MySharedObject sharedInstance =
        new MySharedObject();


    //member variables pointing to two objects on the heap

    public Integer object2 = new Integer(22);
    public Integer object4 = new Integer(44);

    public long member1 = 12345;
    public long member1 = 67890;
}

有一点,MySharedObject类中的两个long类型的成员变量是原始类型的。因为,这些变量是成员变量,所以它们任然随着该对象存放在堆上,仅有本地变量存放在线程栈上。

硬件内存架构

现代计算机硬件架构简略图:

《【并发编程】从Java内存模型看并发数据共享与线程安全》

CPU的访问速度:寄存器>CPU缓存>主存。

当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。

当CPU需要在缓存层存放一些东西的时候,存放在缓存中的内容通常会被刷新回主存。

CPU缓存可以在某一时刻将数据局部写到它的内存中,和在某一时刻局部刷新它的内存。它不会在某一时刻读/写整个缓存,通常,在一个被称作“cache lines”的更小的内存块中缓存被更新。一个或者多个缓存行可能被读到缓存,一个或者多个缓存行可能再被刷新回主存。

Java内存模型与硬件架构的桥接

《【并发编程】从Java内存模型看并发数据共享与线程安全》

硬件内存架构中没有区分线程栈和堆,线程栈与堆分布在主存中,少量分布在CPU缓存或内部寄存器中。

当对象与变量被存放在计算机中的不同内存区域中时,可能出现如下两个问题:

1.线程对变量修改的可见性;

2.当读/写/检查 共享变量时出现的竞态条件。

共享变量的可见性

正常情况下,共享对象被初始化在主存中。跑在CPU上的一个线程将这个共享对象读到CPU缓存中。然后修改了这个对象。只要CPU缓存没有被刷新回主存,对象修改后的版本对跑在其它CPU上的线程都是不可见的。

这种方式可能导致每个线程拥有这个共享对象的私有拷贝,每个拷贝停留在不同的CPU缓存中。当同时将数据刷新回主存时,会造成数据的紊乱与错误。

解决这个问题可以使用Java中的volatile关键字。volatile关键字可以保证从主存中读取一个变量,如果这个变量被修改后,总是会在第一时间被写回到主存中去,这样线程对共享变量的修改对于其他线程是具有可见性的。

竞态条件

如果两个或者更多的线程共享一个对象,多个线程在这个共享对象上更新变量,就会发生竞态条件。

定义:当两个或者多个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。

    导致竞态条件发生的代码区称作临界区。

在临界区中使用适当的Java同步就可以避免竞态条件。

一个同步块可以保证在同一时刻仅有一个线程可以进入代码的临界区。同步块还可以保证代码块中所有被访问的变量将会从主存中读入,当线程退出同步代码块时,所有被更新的变量都会被刷新回主存中去,不管这个变量是否被声明为volatile。

线程安全

允许被多个线程同时执行的代码称为线程安全的代码。

线程安全的代码不包含竞态条件。当多个线程同时更新共享资源时会引发竞态条件。因此,了解Java线程执行时共享了什么资源很重要。

线程控制逃逸规则

如果一个资源的创建,使用,销毁都在同一个线程内完成,且永远不会脱离该线程的控制,则该资源的使用就是线程安全的。Java中无需主动销毁对象,所以“销毁”指不再有引用指向对象。

不可变性

可以通过创建不可变的共享对象来保证对象在线程间共享时不会被修改,从而实现线程安全。

例如:

public class ImmutableValue{
    private int value = 0;
    public ImmutableValue(int value){
        this.value = value;
    }
    public int getValue(){
        return this.value;
    }
    public ImmutableValue add(int valueToAdd){
        return new ImmutableValue(this.value + valueToAdd);
    }
}

ImmutableValue类的成员变量value是通过构造函数赋值的,并且在类中没有set方法。所以一旦ImmutableValue实例被创建,value变量就不能再被修改,这就是不可变性。

注意,“不变”(Immutable)和“只读”(Read Only)是不同的。当一个变量是“只读”时,变量的值不能直接改变,但是可以在其它变量发生改变的时候发生改变。比如,一个人的出生年月日是“不变”属性,而一个人的年龄便是“只读”属性,但是不是“不变”属性。随着时间的变化,一个人的年龄会随之发生变化,而一个人的出生年月日则不会变化。这就是“不变”和“只读”的区别。(摘自《Java与模式》第34章)

其中,add()函数是对Immutable类的实例进行操作,从代码可见,结果是将一个新的ImmutableValue类实例返回,而不是原来的实例。

引用不是线程安全的

即使对象本身线程安全,但如果该对象中包含其他资源(文件,数据库连接),整个应用也许就不再是线程安全的了。比如2个线程都创建了各自的数据库连接,每个连接自身是线程安全的,但它们所连接到的同一个数据库也许不是线程安全的。比如,检查记录X是否存在,如果不存在,插入X。

程序可能步骤:

线程1检查记录X是否存在。检查结果:不存在;

线程2检查记录X是否存在。检查结果:不存在;

线程1插入记录X;

线程2插入记录X。

这样就插入了两个X记录。

同样的问题也会发生在文件或其他共享资源上。

因此,区分某个线程控制的对象是资源本身,还是仅仅是某个资源的引用很重要。

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