02 java内存模型和java语言帮手

  1. 1 java内存模型
    1. 1.1 计算机物理内存模型
    2. 1.2 正确性三大问题
    3. 1.3 java内存模型
  2. 2 java语言对并发编程的支持

1 java内存模型

“如欲征服java并发,需先征服java内存模型,如欲征服java内存模型,需先征服计算机内存模型” -糖炒甜板栗

大佬讲话

咳!咳!,大家都做笔记了吧。虽然栗子不是什么大佬,但是这句话说的还是没有毛病的。不了解java的内存模型,就不会从根上理解java并发的一些行为和机制,而java内存模型毕竟是jvm模拟出来的一部分,其底子还是建立在现代计算机的物理内存模型上来的,所以我们就按照现代计算机的物理内存模型、java内存模型的顺序来仔细介绍,为彻底了解java并发机制打下底子。

1.1 计算机物理内存模型

现代计算机的物理内存模型:
现代计算机的物理内存模型
现在计算机最少的都是应该是两核心了,当然我们也经常在买个人电脑的时候听过四核四线程、四核八线程等,可以说现在个人电脑标配都是四核心了,为了方便上图只是列举了2个核心。现代计算机的内存在逻辑上还是一块。有人可能问栗子说,不对啊,我电脑就插了两块内存条,但是操作系统会把两块内存的地址统一抽象,比如每一块的内存是2048MB地址是000000000000-011111111111MB,两块就是0000000000000-1000000000000MB,操作系统会统一编址。所以整体上看还是一块内存。因为CPU的操作速度太快,如果让CPU直接操作内存,那么就是对CPU资源的一种巨大浪费,为了解决这个问题现在计算机都给CPU加上缓存,比如一级缓存,二级缓存,甚至三级缓存。缓存速度比内存快,但是还是赶不上CPU的速度级别,所以在缓存和CPU之间又有了register,register的存储速度比缓存就快了好多了。
存储速度上有如下关系:
register > 一级缓存 > 二级缓存 > … > n级缓存 > 内存
容量上一般有如下关系:
内存 > n级缓存 > … > 二级缓存 > 一级缓存 > register
之所以可以用缓存和register来缓解CPU和内存之间巨大的速度差别是基于如下原理:

CPU访问过的内存地址,很有可能在短时间内会被再次访问
比如CPU访问了地址为0x001fffff的内存地址,如果没有缓存和register,那么CPU再下次访问这个内存地址的时候就还要去内存读,但是如果有缓存,缓存会把CPU访问过的数据先存储起来,等CPU待会再找地址为0x001fffff的内存地址时候,发现其在缓存中就存在了,那么好了,这就不用在访问内存了,速度自然就提升了。这就涉及到计算机组成原理的知识了,如果想了解可以google一下,这里就不在做更深的介绍了到这里就够用了。

1.2 正确性三大问题

了解现代计算机物理内存模型工作原理后,那么再理解java并发编程中和正确性问题关系最紧密的三个问题就很好理解了。先介绍下三个问题:

  1. 操作原子性:一个操作要么全做,要么全不做,那么这个操作就符合原子性。比如栗子把刚发的500块工资转给老婆,就包括两个操作,自己账户先减500,老婆的账户加500,这个转账操作应该满足原子性。如果银行只执行了栗子账户的扣钱操作,没有执行给老婆达人账户的加钱操作。丢了500块钱是小事,被老婆大人罚跪搓衣板可就不得了了。所以你自己账户减钱,老婆账户加钱,这两个操作要么都做了,要么都别做。例如如下操作:

    1
    a = a + 1;

    结合我们上述的现代计算机的内存模型,计算机执行a=a+1时候会分成三个原子性操作:

    1. 把a的值(比如4)从内存中取出放到CPU的缓存系统中
    2. 从缓存系统中取出a的值加1(4+1)得到新结果
    3. 把新结果存回到内存中

    一个“a=a+1”操作在计算机中被拆分成三个原子性操作,那么完全可以出现CPU1执行完1.和2.操作后,去执行别的操作了,这时CPU2 从内存中获取到a的值就不是最新的值(因为操作3.没有执行)。这就是并发操作原子性问题的根本来源。

  2. 操作有序性:例如如下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class A {
    public int a;
    public boolean b = false;

    public void methodA(){
    a = 3;
    b = true;
    a = a + 1;
    }

    public void methodB(){
    a = 3;
    b = (a == 4);
    a = a + 1;
    }
    }

    methodA方法代码先经过java编译器编译成字节码,然后字节码被操作系统解释成机器指令,在这个解释过程中,操作系统可能发现,咦?在给变量b赋值为true后又操作了a变量,干脆我操作系统自己改改执行顺序,把对a变量的两个操作都执行完,然后再执行对b的操作,这就叫指令重排序。这样就会节省操作时间,如下图没有进行指令重排序时:
    没有指令重排序
    图中CPU和缓存系统要进行9次通信,缓存系统和内存要通信7次,假设cpu和缓存系统通信一次用时1ms,缓存系统和内存通信一次用时10ms,那么总用时 9乘1 + 7乘10 = 79ms。经过指令重排序后,总共用时 6乘1 + 6乘10 = 66ms 如下图所示:
    有指令重排序
    经过指令重排序的确可以提程序运行效率,所以java编译器、java虚拟机、操作系统都会对指令进行重排序,但是这种重排序也不是无脑重排序,重排序的基础是前后语句不存在依赖关系时,才有可能发生指令重排序。所以A类的methodB方法不会发生指令重排序,因为对比的赋值操作依赖变量a,而在单线程情况下methodA中对b的赋值在第二行还是第三行对结果没有影响。指令重排序在单线程环境里面这不会有什么问题,但是多线程中就可能发生意外。比如线程1中执行如下代码:

    1
    instance.methodA();

    另一个线程2执行如下代码:

    1
    2
    3
    4
    while(instance.a != 4){ //a只要不等4,线程就让出CPU,等待调度器再次执行此线程
    Thread.yield(); //让出CPU,线程进入就绪态
    }
    System.out.print(instance.b);

    其中instance是A类的一个实例。如果线程1 发生了指令重排序, 那么线程2的打印结果很有可能是false,这就和我们对代码的直观观察结果出处很大。如果线上程序出错的原因是指令重排序导致的,几乎不能可能排查出来。

  3. 操作可见性 :
    在“操作有序性” 中的线程线程2 ,还有可能会没有任何输出结果。因为线程2 要想有输出必须要满足instance.a =4,但这是在线程1中调用methodA 方法后instance.a 的值才为4 。而要想让线程2 看到这个新值,必须要把线程1的修改及时写回内存, 同时通知执行线程2的CPU的缓存系统中的instance.a值已经过期,需要去内存中获取最新值。如果我们的类A和线程1、线程2调用的代码没有特殊的声明,那么操作系统不能保证上述过程一定发生。即可能发生线程1对instance.a的修改对线程2不一定可见,这就是操作的可见性问题。

java并发编程关于“正确性问题”的错误都植根于多线程程序的“操作原子性”、“操作有序性”、“操作可见性”没有得到保证而引发的。

1.3 java内存模型

上面介绍了现代计算机的内存模型以及其引起的在java并发编程中关于正确性问题的三个核心概念,下面来介绍下java的内存模型。java为了实现其夸平台的特性,使用了一种虚拟机技术,java程序运行在这虚拟机上,那么不管你是windows系统,linux系统,unix系统,java虚拟机会屏蔽一切操作系统带来的差异,向java程序提供专用的、各系统无差别的虚拟机,那么java程序员就不需要关心底层到底是什么操作系统了。对于int类型的变量其取值范围永远是 -2^31 -1 至 2^31,即4个字节。但是对C\C++,这个操作系统的int可能是4字节,那个可能是8字节。C++程序员跨平台写代码,痛苦异常。这个给我们编程带来极大方便的虚拟机就是大名鼎鼎的JVM(Java Virtual Machine)。既然是虚拟机那么就需要模拟真正物理机的所有设备,像CPU,网络,存储等。和我们程序员最密切的就是JVM的存储部分,这就是java内存模型(Java Memory Model 简称JMM)。有别于我们真实的物理存储模型,JMM把存储分为线程栈区和堆区(当然还有元空间等,不过和本系列文章没太大关系)。堆区对于java中的线程是共享资源,栈区是每个线程自己私有的,java对象主要是存储在堆区的,所有java线程都可以操作和获得堆区中的java对象。

因为java内存模型是在具体的物理内存模型的基础上实现的,并且为了运行效率,java也支持指令重排序。所以java并发编程要想保证正确性也要处理好“原子性”、“有序性”、“可见性”三个问题。但是,我们的JMM也不是白吃干饭什么也做的,最起码运行在JVM上的代码就具备一些内存特性,来使得java代码有一定的“有序性”和“可见性”,这些特性也被称为 happen-before原则。下面先列举几个重要的内存特性。
JMM的 happen-before 特性:

  • 单线程happen-before原则:在同一个线程中,书写在前面的操作happen-before后面的操作。
  • 锁的happen-before原则:同一个锁的unlock操作happen-before此锁的lock操作。
  • volatile的happen-before原则:对一个volatile变量的写操作happen-before对此变量的读操作。
  • happen-before的传递性原则:如果A操作 happen-before B操作,B操作happen-before C操作,那么A操作happen-before B操作。

这里不懂这些“happen-before”原则没关系,后面的文章会慢慢解释这些原则,以及他们在java.util.concurrent包中的类中的使用。先讲明白java并发编程的原理再结合源码分析这些原理的应用,是本系列文章的一大特色。

2 java语言对并发编程的支持

除了平时开发接触不到的java内存模型的happen-before原则外,java从语言层面也给我们一些关键字和类来帮助我们克服java并发编程中的“原子性”、“有序性”、“可见性”三个难题,让我们编写的多线程程序最起码是正确的。下面先简单介绍一些明星级别的帮手:

  1. 锁。任何一个java开发者或者说任何一个开发人员没有没听说过“锁”的大名的,因为其使用简单且能“一锁治三性”而被大家广泛使用:把一段代码加上锁,就可以保证加锁代码在多线程环境下的“原子性”、“有序性”、“正确性”。但是正因为简单也导致锁被滥用,感觉一段代码可能有线程安全问题就无脑上锁导致程序虽然保证了“正确性”,但是“可行性”和“高效性”却出现问题。后面栗子会深入分析锁的优势和劣势。

  2. volatile关键字。volatile关键字的使用是java并发编程不可或缺的性能优化手段,其能保证“可见性”和一定程度的“有序性”且开销没有锁那么大。在java.util.concurrent包它经常和它的好基友CAS一起实现一些高性能的线程安全的类。但是对它的使用需要深刻掌握它的原理,否则稍有不慎就可能弄巧成拙:不仅仅程序的性能没优化好,连程序的“正确性”都给“优化”没了。栗子后面会专门和大家一起驯化它。

  3. final关键字。 final关键字在实现java并发编程的“正确性”上可能没有上面的两位老哥出名,开发者对其的了解更多的是javaSE语法的学习:修饰在类上保证类不能被继承、修饰在方法上让方法不能被重写(override)等等。但它在保证java并发编程的正确性上有独特的作用。后面栗子会慢慢揭开它不为人知的另一面。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 lmyuanwork@163.com

文章标题:02 java内存模型和java语言帮手

本文作者:aworker

发布时间:2020-05-20, 20:06:13

最后更新:2020-06-05, 09:49:26

原始链接:http://aworker.cn/2020/05/20/java/concurrency/post2/jvm_memory_model/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录
×

喜欢就点赞,疼爱就打赏