JAVA内存模型

随着计算机CPU从单处理一步步进化为四处理器、八处理器,为了最大地利用处理器,提高程序的性能以及吞吐量,日常编程中越来越多地涉及到多线程并发编程;在多线程编程带来高并发的同时,也伴随着带来了一些新的问题;本篇文章主要分析使用多线程时所需要了解的JAVA内存模型。

硬件层内存与处理器架构


JVM的设计是基于硬件的基础上,要很好地理解JVM中的内存模型,首先需要理解硬件层的内存与处理器架构的设计。在内存与处理器之前有一个关键的角色:CPU缓存(Cache Memory);CPU缓存是位于CPU与内存之间的临时存储器,它的容量比内存小的多但是交换速度却比内存要快得多。高速缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快很多,这样会使CPU花费很长时间等待数据到来或把数据写入内存。在缓存中的数据是内存中的一小部分,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先缓存中调用,从而加快读取速度。


这里写图片描述

JAVA内存模型


我们可以JAVA的内存模型是基于CPU与内存,对其在软件上的实现。在处理器与内存之间存在高速缓存,在JAVA内存模型中,线程工作内存对应于高速缓存。


这里写图片描述


在JAVA内存模型中定义了一些规定:
1.所有的变量都必须存储在主内存中,每条线程有自己的工作内存
2.线程的工作内存保存了该线程所需要使用的变量在主内存中的一个副本
3.线程对变量的操作必须在线程工作内存中进行,不能操作主内存
4.线程不能访问其他线程的工作内存
5.在适当时候,线程工作内存同步到主内存

三个特性


因为每个线程都有自己的工作内存,线程对所有变量的操作都在工作内存中进行,线程间不能相互访问对方的工作内存,因此也带来了一些并发上的问题,涉及到三个特性:可见性原子性以及有序性

可见性

所谓可见性,因为每个线程都有自己的工作内存,线程间工作内存不能相互访问,因此在线程工作内存中对变量进行更新后没有同步到主内存时,其他的线程是没办法得知变量已经发生了变化,这就是常说的可见性的问题。

例如对int变量i进行累加 i ++的操作分为三步:从主内存中读取i的值到线程工作内存、在线程工作内存中进行赋值、将线程工作内存中i的值保存在主内存中。假如i当前值为1,线程1将i的值读到工作内存中,并进行++操作,此时i的值为2,与此同时,线程2对i也进行累加操作,从主内存中读取到i的值为1(线程2无法知道线程1在线程1的工作内存中已经更新了i),累加并将i的值更新到了主内存,此时主内存中的i被更新为2,此时线程1将工作内存中i的值为2也更新到主内存,最后主内存中i的值为2.但是我们对i进行了2次累加,我们期望的结果应该是i为3.

解决可见性的问题一般有:volatile、synchronized、Lock、final

无论是普通变量还是volatile变量都是如此,区别在于:volatile的特殊规则保证了volatile变量值修改后的新值立刻同步到主内存,每次使用volatile变量前立即从主内存中刷新,因此volatile保证了多线程之间的操作变量的可见性,而普通变量则不能保证这一点。

除了volatile关键字能实现可见性之外,还有synchronized,Lock,final也是可以的。

使用synchronized关键字,在同步方法/同步块开始时(Monitor Enter),使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在同步方法/同步块结束时(Monitor Exit),会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。

使用Lock接口的最常用的实现ReentrantLock(重入锁)来实现可见性:当我们在方法的开始位置执行lock.lock()方法,这和synchronized开始位置(Monitor Enter)有相同的语义,即使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在方法的最后finally块里执行lock.unlock()方法,和synchronized结束位置(Monitor Exit)有相同的语义,即会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。

final关键字的可见性是指:被final修饰的变量,在构造函数数一旦初始化完成,并且在构造函数中并没有把“this”的引用传递出去(“this”引用逃逸是很危险的,其他的线程很可能通过该引用访问到只“初始化一半”的对象),那么其他线程就可以看到final变量的值。


原子性

所谓原子性,指操作是不可再拆分的,对long、double以外的基本类型数据的读或者写操作属于原子性操作,
long和double在32位机器上的读或者写不属于原子性的操作,long和double的读或者写会分为两次进行;
例如上面的i++也是分为三步完成,属于非原子性操作。

有序性

对于一个线程的代码而言,我们总是以为代码的执行是从前往后的,依次执行的。这么说不能说完全不对,在单线程程序里,确实会这样执行;但是在多线程并发时,程序的执行就有可能出现乱序。用一句话可以总结为:在本线程内观察,操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行语义(WithIn Thread As-if-Serial Semantics)”,后半句是指“指令重排”现象和“工作内存和主内存同步延迟”现象。

在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。重排序分成三种类型:

1.编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
2.指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,3.处理器可以改变语句对应机器指令的执行顺序。

内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java源代码到最终实际执行的指令序列,会经过下面三种重排序:

这里写图片描述

首先来看一段代码

 int a = 3; //语句1
 int r = 6; //语句2
 int q = a * r; //语句3

上面的三行代码,在经过重排序之后,它的执行顺序可能为: 语句1 → 语句2 → 语句3
执行顺序也可能为:语句2 → 语句1 → 语句3
但是语句不可能为:语句1 → 语句3 → 语句2,因为语句3对语句1、语句2存在数据依赖关系,不会被编译器重排序。
不存在数据依赖关系的代码的顺序有可能被重排序,重排序只满足 As-if-Serial 原则,也就是单线程下重排序与原有顺序执行的结果是一致的,但是在多线程并发的情况下,就有可能会产生问题,看下面的代码

    private boolean hasInited;
    private Context context;

    private void initContext() {
        context = init();//进行初始化
        hasInited = true;
    }

    private void doSomething() {
        if(hasInited) {
            Intent intent = new Intent();
            context.startActivity(intent);
        }
    }

上面的代码doSomething()函数只有在context进行了初始化之后,才能继续执行里面的内容;在initContext()函数中,因为 context = init();hasInited = true;不能存在数据依赖关系,因此可能被重排序,hasInited = true;语句先执行,然后再执行context = init();进行初始化。若线程1执行完hasInited = true;但未执行context = init();时,线程2调用doSomething(),此时hasInitedtrue,会继续调用context的相关函数,但是线程1并未完成对context`的初始化,从而产生异常。

因此重排序在多线程并发的情况下,可能产生问题,因此如上面这种情况,我们应该禁止重排序,volatile在一定程度上能起到重排序的作用。

happens-before原则:

Java内存模型中定义的两项操作之间的次序关系,如果说操作A先行发生于操作B,操作A产生的影响能被操作B观察到,“影响”包含了修改了内存中共享变量的值、发送了消息、调用了方法等。

下面是Java内存模型下一些”天然的“happens-before关系,这些happens-before关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。

a.程序次序规则(Pragram Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。

b.管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而”后面“是指时间上的先后顺序。

c.volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读取操作,这里的”后面“同样指时间上的先后顺序。

d.线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。

e.线程终于规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等作段检测到线程已经终止执行。

f.线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生。

g.对象终结规则(Finalizer Rule):一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始。

g.传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

一个操作”时间上的先发生“不代表这个操作会是”先行发生“,那如果一个操作”先行发生“是否就能推导出这个操作必定是”时间上的先发生 “呢?也是不成立的,一个典型的例子就是指令重排序。所以时间上的先后顺序与happens-before原则之间基本没有什么关系,所以衡量并发安全问题一切必须以happens-before 原则为准。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值