资讯专栏INFORMATION COLUMN

浅谈计算机架构与java内存模型

null1145 / 1959人阅读

摘要:主机架构与内存模型多任务处理器在现代计算机系统中几乎已是一项必备的功能了。在计算机系统中,可能存在多个处理器,每个处理器都有自己的高速缓存,而他们又共享同一主内存。

计算机:辅助人脑的好工具

计算机的定义:

接受使用者输入指令与数据, 经由中央处理器的数学与逻辑单元运算处理后,以产生或储存成有用的信息

我们的个人电脑也是计算机的一种,,依外观来看这家伙主要分三部分:

输入单元:包括键盘、鼠標、读卡机、扫描器、手写板、触控萤幕等等一堆;

主机部分:这个就是系统单元,被主机机壳保护住了,里面含有 CPU 与主内存等;

输出单元:例如萤幕、打印机等等

中央处理器(Central Processing Unit)

而我们今天研究的主题就是计算机其中的主机部分。整部主机的重点在于中央处理器(cpu),cpu是一个具有特定功能的芯片,
里面含有很多微指令集,计算机所有的功能都需要微指令集的支持才可以完成。cpu的主要作用在于管理和运算,因此cpu内部又可分为两个单元,分别为:算数逻辑单元和控制单元。其中算数逻辑单元主要负责程序运算和逻辑判断,控制单元主要负责和各周边主件与各单元之间的工作。

上图所展示的系统单元其实就是主机的主要组件,其中的核心就是cpu和主内存。基本上所有数据都要经过主内存,至于是流入还是流出则是cpu所发布的控制指令,而cpu实际要处理的数据则全部来自于主内存!

cpu的外频与倍频

cpu作为计算机的大脑,因为许多运算和逻辑都在cpu里处理,所以需要其拥有很强大的处理能力,但外部组件的速度和cpu的速度相差实在太多,才啊有了所谓的外频和倍频。
所谓外频指的是cpu与外部组件进行数据传输的速度。倍频则是cpu内部用来加速工作的一个倍数。两者相乘才是cpu自己的主频。

高速缓存

程序的启动和运转有着一个重要的问题,即系统花费了大量的时间把信息从一个地方挪到另一个地方。数据最初放在磁盘上,当程序被加载时,将其移动到主内存,当程序运行时,指令又从内存复制到cpu上。从程序员的角度来看,这些复制就是开销,是减慢了程序运行速度的罪魁祸首。因此,系统设计者设计了高速缓存来使这些复制操作尽可能快地完成。
一个系统上磁盘驱动器可能比主内存大100倍,但是对处理器来说,从磁盘驱动器读取一个字的开销比从主内存读取的开销大1000万倍。类似的,一个寄存器只可以储存几百字节的信息,而主内存里可以放几十亿字节。然而寄存器的速度大约是主内存的100倍。而且,随着半导体技术的进步,这种处理器与主存之间的差距还在持续增大。
针对这种处理器与主存之间的差异,系统设计者采用了更小更快的存储设备,称为高速缓存存储器。其中又分为L1、L2、L3高速缓存,限于篇幅,在这里就不给大家详细介绍了.系统通过让高速缓存里存放可能经常访问的数据,大部分的内存操作都能在快速的高速缓存中完成。

主机架构与java内存模型

多任务处理器在现代计算机系统中几乎已是一项必备的功能了。所有的运算任务至少都要与主内存交互才能完成,由于计算机的存储设备和处理器的运算速度之间存在着几个数量级的差距。所以现代计算机系统都不得不加入一层读写速度尽可能接近于处理器的高速缓存来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算高速进行,当运算结束后,再将缓存中的结果复制到主内存中。这样处理器就不需要等待缓慢的内存读写了。如下图所示:

看似很美好,实际上并没有想象中的那么容易。在计算机系统中,可能存在多个处理器,每个处理器都有自己的高速缓存,而他们又共享同一主内存。当多个处理器的运算任务涉及到统一块内存区域,将可能导致高速缓存间的不一致,那同步到主内存以哪个为准呢?为了解决一致性问题,需要各个处理器访问缓存需要遵循一些一致性协议来进行操作。java内存模型定义的内存访问操作和硬件的访问操作是有可比性的。
java虚拟机规范试图定义一种java内存模型来屏蔽掉各种硬件和操作系统的访问差异,以实现让java程序在任何机器上都能达到一致的相同效果。因此定义java内存模型是一件非常麻烦的事,既要足够严谨,让java的并发操作不会发生歧义;但也必须足够宽松,使虚拟机的实现有足够的自由空间去利用硬件的各种特性(寄存器、高速缓存等)来获取更好的执行速度。内存模型如下图所示:

重排序

在讲重排序之前,我们先来看一段代码:

public class ReOrderTest {

    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            i++;
            x = 0;y = 0;a = 0;b = 0;
            CountDownLatch latch = new CountDownLatch(1);

            Thread one = new Thread(() -> {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                }
                a = 1;
                x = b;
            });

            Thread other = new Thread(() -> {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                }
                b = 1;
                y = a;
            });
            one.start();other.start();
            latch.countDown();
            one.join();other.join();

            String result = "第" + i + "次 (" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }


}

看完这段代码,或许没有接触过重排序的同学会认为这是一个死循环,其输出结果只会有(1,1),(1,0),(0,1)三种结果。但实际上只需要运行几秒钟,就会break出来,出现x=0;y=0的情况。
重排序由以下几种机制引起的:

编译器优化:对于没有数据依赖关系的操作,编译器在编译的过程中会进行一定程度的重排。
解释:编译器可以将线程1的a=1和x=b互换下位置的,因为他们不存在数据依赖,同理线程2也可以互换位置,就 可以得到x=0,y=0的结果了

指令重排序:CPU 优化行为,也是会对不存在数据依赖关系的指令进行一定程度的重排。
解释:这个和编译器优化是一个道理,代码编译成指令,不存在依赖关系也就有可能进行重排

内存系统重排序:内存系统没有重排序,但是由于有缓存的存在,使得程序整体上会表现出乱序的行为。
解释:线程1执行a=1,将其写入缓存但可能还没有同步到主内存,这个时候线程2访问a的值当然就是0了。同理线程2对b的赋值操作也有可能没有刷新到主内存当中

内存可见性

刚才再讲重排序的时候,就提到了内存可见性。线程1执行a=1,这个结果对于线程2来说不一定可见。这种不可见不是由于多处理器造成的,而是由于多缓存造成的。现在每个处理器上都会有寄存器,L1、L2、L3缓存等等,问题就发生在每个处理器都独占一个缓存,数据修改刷入缓存,然后从缓存刷入内存,所以就会导致有些处理器读到的是过期的值。java作为高级语言,为我们抽象jmm模型,定义了读写数据的规范,使我们不用关心缓存的概念,但是jmm也同时给我们抽象出了工作内存和主内存。(ps:这里说的工作内存是对寄存器,L1、L2、L3缓存等的一个抽象)

happens-before(先行发生原则)

happens-before是理解jmm最核心的概念。对于java程序员来说,如果你想理解并写好并发程序,happens-before是理解jmm模型的关键。
《JSR-133:Java Memory Model and Thread Specification》对happens-before关系的定义如下:
1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

public static int getz() {
        int x=1;            //A
        int y=1;            //B
        int z=x+y;          //C
        return z;
    }

上面的代码示例存在了3个happens-before规范:

Ahappens-beforeB

Bhappens-beforeC

Ahappens-beforeC

其中2、3是必须的,而1不是必需的。因此jmm又把happens-before要求禁止的重排序分为了以下两种:

会改变程序结果的重排序(jmm要求编译器和处理器严格禁止这种重排序)

不会改变程序结果的重排序(允许,指的是单线程程序或者经过正确同步的多线程程序)

happens-before规则

《JSR-133:Java Memory Model and Thread Specification》定义了如下happens-before规则。
1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的
读。
4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的
ThreadB.start()操作happens-before于线程B中的任意操作。
6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作
happens-before于线程A从ThreadB.join()操作成功返回。

我们其中最常见的就是1、2、3、4.其中1、4的情况在前面已经讨论过。3)将会在volatile的内存语义中进行讨论。现在我们来看下锁的释放-获取建立的happens-before关系:

    int a=0;
    
    public synchronized void read(){//1
        a++;//2
    }//3

    public synchronized void writer(){//4
        int i=a+1;//5
    }//6

由程序顺序规则来判断:1happens-before2,2happens-before3,4happens-before5,5happens-before6.
由监视器锁规则来判断:3happens-before4
由传递性来判断:1happens-before2,2happens-before3,3happens-before4,4happens-before5,5happens-before6
怎么实现的呢?进入锁的时候将会使工作内存失效,读取变量必须从主内存中读取。释放锁得时候会将该变量刷新回主内存。这里的锁包括conuurent包下的锁.

volatile的内存语义

关于volatile,大家只需要牢记两点:内存可见和禁止重排序.
关于volatile的可见性,经常被大家误解。认为volatile变量对所有线程是立即可见的,对volatile变量所有的写操作都能立刻反映到其他县城中,换句话说,volatile变量的运算在并发下是安全的。这个结论是错误的,虽然volatile变量可以保证可见性,但是java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。请看代码示例:

public class BubbleSort {

    static volatile int a;

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[20];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new Runnable() {
                public void run() {
                    for (int x = 0; x < 10000; x++) {
                        add();
                    }
                }
            });
            threads[i].start();
        }
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("a=" + a);
    }
    
    private static void add() {
        a++;
    }
    
}
输出结果:a=159957

结果具有不确定性,原因就是a++自增运算,不是一个原子性操作。通过javap -c BubbleSort.class反编译这段代码得到add()的字节码文件,如下图所示:

可以看到a++这个运算操作产生了4条字节码(return 不是a++产生的),volatile只能保证getstatic时获得到a的值是正确的,当执行其他指令时,很有可能a已经是过期数据了。事实上这样分析是不太严谨的,因为字节码最终会变成cpu指令执行,即使只编译出一条字节码指令也不能保证这个指令就是原子操作。所以如果当我们进行运算的时候,仍要通过加锁或者使用concurrent并发包下的原子类才能保证其原子性。
禁止重排序有一个非常经典的例子,就是DCL单例模式.关于这篇文章,大神们早已发过文章对此进行阐述了,这里搬运一下:

来膜拜下文章署名中的大神们:David Bacon (IBM Research) Joshua Bloch (Javasoft), Jeff Bogda, Cliff Click (Hotspot JVM project), Paul Haahr, Doug Lea, Tom May, Jan-Willem Maessen, Jeremy Manson, John D. Mitchell (jGuru) Kelvin Nilsen, Bill Pugh, Emin Gun Sirer,至少 Joshua Bloch 和 Doug Lea 大家都不陌生吧。

话不多说,上例子:

public class Singleton {

    private static Singleton instance = null;

    private Singleton() {
        this.v = 3;
    }

    public static Singleton getInstance() {
        if (instance == null) { 
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

jvm接收到new指令时,简单分为3步(实际更多,可参考深入理解虚拟机),1分配内存2实例化对象3将内存地址指向引用。java的内存模型并不限制指令的重排序,也就说当执行步骤从1-》2-》3变成1-》3-》2。当线程a访问走到第2步,未完成实例化对象前,线程b访问此对象的返回一个引用,但若是进行其他操作,因为对象并没有实例化,会造成this逃逸的问题。解决的方法很简单,就是加上volatile关键字。
volatile小结

volatile 修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值。在并发包的源码中,它使用得非常多。

volatile 属性的读写操作都是无锁的,它不能替代 synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。

volatile 只能作用于属性,我们用 volatile 修饰属性,这样 compilers 就不会对这个属性做指令重排序。

volatile 提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile 属性不会被线程缓存,始终从主存中读取。

volatile 提供了 happens-before 保证,对 volatile 变量 v 的写入 happens-before 所有其他线程后续对 v 的读操作。

volatile 可以使得 long 和 double 的赋值是原子的,前面在说原子性的时候提到过。

小结

描述该类知识需要非常严谨的描述,虽然我仔细检查了好几遍,但仍担心会出错,一来受限于有限的知识储备,二来受限于蹩脚的文字表达能力。希望读者可以帮助我指正表达错误的地方.

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/68799.html

相关文章

  • 浅谈Java并发编程系列(二)—— Java内存模型

    摘要:物理计算机并发问题在介绍内存模型之前,先简单了解下物理计算机中的并发问题。基于高速缓存的存储交互引入一个新的问题缓存一致性。写入作用于主内存变量,把操作从工作内存中得到的变量值放入主内存的变量中。 物理计算机并发问题 在介绍Java内存模型之前,先简单了解下物理计算机中的并发问题。由于处理器的与存储设置的运算速度有几个数量级的差距,所以现代计算机加入一层读写速度尽可能接近处理器的高速缓...

    Edison 评论0 收藏0
  • Java学习路线总结,搬砖工逆袭Java架构师(全网最强)

    摘要:哪吒社区技能树打卡打卡贴函数式接口简介领域优质创作者哪吒公众号作者架构师奋斗者扫描主页左侧二维码,加入群聊,一起学习一起进步欢迎点赞收藏留言前情提要无意间听到领导们的谈话,现在公司的现状是码农太多,但能独立带队的人太少,简而言之,不缺干 ? 哪吒社区Java技能树打卡 【打卡贴 day2...

    Scorpion 评论0 收藏0
  • 后台开发常问面试题集锦(问题搬运工,附链接)

    摘要:基础问题的的性能及原理之区别详解备忘笔记深入理解流水线抽象关键字修饰符知识点总结必看篇中的关键字解析回调机制解读抽象类与三大特征时间和时间戳的相互转换为什么要使用内部类对象锁和类锁的区别,,优缺点及比较提高篇八详解内部类单例模式和 Java基础问题 String的+的性能及原理 java之yield(),sleep(),wait()区别详解-备忘笔记 深入理解Java Stream流水...

    spacewander 评论0 收藏0
  • 后台开发常问面试题集锦(问题搬运工,附链接)

    摘要:基础问题的的性能及原理之区别详解备忘笔记深入理解流水线抽象关键字修饰符知识点总结必看篇中的关键字解析回调机制解读抽象类与三大特征时间和时间戳的相互转换为什么要使用内部类对象锁和类锁的区别,,优缺点及比较提高篇八详解内部类单例模式和 Java基础问题 String的+的性能及原理 java之yield(),sleep(),wait()区别详解-备忘笔记 深入理解Java Stream流水...

    xfee 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<