java并发编程实战之java内存模型

什么是内存模型?为什么需要它

假设一个线程为变量aVariable赋值:
aVariable=3; 内存模型需要解决这个问题: 在什么条件下,读取aVariable的线程将看到3? 这听起来似乎是个愚蠢的问题, 但如果缺少同步,那么将会有许多因素使得线程无法立即甚至永远,看到另一个线程的操作结果。在编译器生成的指令顺序,可以与源代码中的顺序不同,此外编译器还会把变量保存在寄存器而不是内存中,处理器可以采用乱序等方式来执行指令;缓存可能会改变将写入变量提交到主内存的次序;等等这些因素都会使得一个线程无法看到变量的最新值。

在单线程环境中,我们无法看到所有这些底层技术,他们除了提高程序的执行速度外,不会产生其他影响。在多线程环境下,维护程序的串行性将导致很大的性能开销,对于并发应用程序中的线程来说,他们大部分时间都执行各自的任务,因此线程之间的协调操作只会降低应用程序的运行速度,而不会带来任何好处。只有当多个线程要共享数据时,才必须协调他们之间的哦操作,并且jvm依赖程序通过同步操作来找出这些协调操作将在何时发生。

JMM规定了jvm必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对其他线程可见。

平台的内存模型
在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期地与主内存进行协调。在不同的处理器架构中提供了不同级别的缓存一致性,其中一部分只提供了最小的保证,即允许不同的处理器在任意时刻从同一个存储位置上看到不同的值。操作系统、编译器、有时候甚至包括应用程序需要弥合这种在硬件能力与线程安全需求之间的差异。

要想确保每个处理器都能在任意时刻知道其他处理器正在进行的工作,将需要非常大的开销。在大多数时间里,这种信息时不必要的,因此处理器会适当放宽存储一致性保证,以换取性能的提升。在架构定义的内存模型中将告诉应用程序可以从内存系统中获得怎样的保证,此外还定义了一些特殊的指令(称为内存栅栏)当需要共享数据时,这些指令就能实现额外的存储协调保证。为了使java开发人员无须关心不同架构上内存模型之间的差异,java还提供了自己的内存模型,并且jvm通过在适当的位置上插入内存栅栏来屏蔽在jmm与底层平台内存模型之间的差异。

程序执行一种简单的假设:想象在程序中只存在唯一的操作执行顺序,而不考虑这些操作在何种处理器上执行,并且在每次读取变量时,都能获取在执行序列中最近一次写入该变量的值。这种乐观的模型就被称之为串行一致性,软件开发人员经常错误地假设存在串行一致性,但在任何一款现代多处理器架构中都不会提供这种串行一致性,JMM也是如此,冯。诺依曼模型这种经典的串行计算模型,只能近似描述现代多处理器的行为。

在现代支持的共享内存多处理器(和编译器)中,当跨线程共享数据时,会出现一些奇怪的情况,除非通过使用内存栅栏来防止这种情况的发生。幸运的是,java程序不需要指定内存栅栏的位置,而只需通过正确地使用同步来找出何时将访问共享状态。

重排序

public class PossibleReordering {
    static int x = 0, y = 0;
    static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread one = new Thread(new Runnable() {
            public void run() {
                a = 1;
                x = b;
            }
        });
        Thread other = new Thread(new Runnable() {
            public void run() {
                b = 1;
                y = a;
            }
        });
        one.start();
        other.start();
        one.join();
        other.join();
        System.out.println("( " + x + "," + y + ")");
    }
}

在没有正确同步的情况下,即使要推断最简单的并发程序的行为也是困难的,很容易想象PossibleReordering是如何输出(1,0)或者(0,1)或者(1,1)的,奇怪的是它还可以输出(0,0)由于每个线程的各个操作之间不存在数据流依赖性,因此这些操作可以乱序执行。(即使这些操作按照顺序执行,但在将缓存刷新到主内存的不同时序中也可能出现这种情况,从线程b的角度看,线程A的赋值操作可能以相反的次序执行)

image.png

PossibleReording是一个简单程序,但要列举出它所有可能的结果却非常困难,内存级的重排序会使程序的行为变得不可预测,如果没有同步,那么推断出执行顺序是非常困难的。而要确保在程序中正确地使用同步却是非常容易的,同步将限制编译器、运行时和硬件对内存操作重排序的方式,从而在实施重排序时不会破坏JMM提供的可见性保证。

在大多数主流的处理器架构中,内存模型都非常强大,使得读取volatile变量的性能和读取非volatile变量的性能大致相当

java内存模型是通过各种操作来定义的,包括对变量的读/写等操作。JMM为程序中所有的操作定义了一个偏序关系,称之为Happens-Before,要想保证执行操作B的线程看到操作A的结果,(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系,如果两个操作之间缺乏Happens-Before关系,那么JVM可以对他们任意地重排序。

当一个变量被多个线程读取并且至少被一个线程写入时,如果在读操作和写操作之间没有依照happens-before来排序,那么就会产生数据竞争问题,在正确同步的程序中不存在数据竞争,并会表现出串行一致性。

Happens-Before规则:

  • 程序顺序规则: 如果程序中操作A 在操作B之前,那么线程中A操作将在B操作之前执行。
  • 监视器锁规则 在监视器锁上的解锁操作必须在同一个监视器锁的上锁操作之前执行
  • valotile变量规则。 对volatile变量的写入操作必须在对变量的读操作之前执行
    ...
image.png

在线程A内部的所有操作都按照它们在源程序中的先后顺序来排序,在线程B内部的操作也是如此,由于A释放了锁M,并且B随后获取了锁M,因此A中所有在释放锁之前的操作,也就位于B中请求锁之后的所有操作之前。

在类库中提供的其他Happens-Before排序包括:

  • 将一个元素放入一个线程安全容器的操作将在另一个线程从该容器中获取这个元素的操作之前执行
  • Future表示的任务的所有操作将在从Future.get中返回之前执行
    ....

不安全的发布
当缺少Happens-Before关系时,就可能出现重排序问题,这就解释了为什么在没有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象。

双重检查加锁

@NotThreadSafe
public class DoubleCheckedLocking {
    private static Resource resource;

    public static Resource getInstance() {
        if (resource == null) {
            synchronized (DoubleCheckedLocking.class) {
                if (resource == null)
                    resource = new Resource();
            }
        }
        return resource;
    }

    static class Resource {

    }
}

由于早期的jvm在性能上存在一些有待优化的地方,因此延迟初始化经常被用来避免不必要的高开销操作或者降低程序的启动时间,
DCL的问题在于:线程可以看到引用的当前值,当对象的状态值却是失效的。如果把resource声明为volatile类型,那么就能启用DCL。然而,DCL的这种使用方法已经被广泛地废弃了,促使该模式出现的驱动力(无竞争同步的执行速度很慢,已经jvm启动时很慢)已经不复存在,因而它不是高效的优化措施,并且不太容易理解。

final域
对于含有final域的对象,初始化安全性可以防止对对象的初始引用被重排序到构造过程之前,初始化安全性将确保,所有线程都能看到由构造函数为对象给各个final域设置的正确值。而且对于可以通过被正确构造对象中某个final域到达的任意变量(例如某个final数组中的元素,或者有一个final域引用的hashmap的内容)将同样对于其他线程是可见。

小结

java内存模型说明了某个线程的内存操作在哪些情况下对于其他线程是可见的,其中包括确保这些操作是按照一种Happens-Before的偏序关系进行排序。

你可能感兴趣的:(java并发编程实战之java内存模型)