小peng哥聊下面试必问的Java内存模型(不是Java内存划分😂)

376 阅读11分钟

写于端午节假期第一天晚班值班。😭,你没有听错,多多有值班制度,节假日也不另外,😔,刚好这次排到我值班,所谓“死活存亡,听天由命去罢”。也㊗️各位出去玩的开心,毕竟工作的时候摸鱼,玩的时候更要玩的开心😂,开个玩笑。小peng哥祝各位节假日都能偶遇小姐姐小哥哥💪,早日脱单!

Java内存模型


Java内存模型(Java Memory Model,JMM)主要是为了解决Java跨平台中,不同操作系统底层内存模型的差异,特别是在并发访问下,造成的程序执行结果不一样的问题,在虚拟机规范定义的一种内存模型。

这里我看很多小哥哥小姐姐的文章明明写的是Java的内存区域划分,标题起的是Java内存模型😂,这就有点误人子弟的意思了😅,这两个没有任何的直接关系哦,小peng哥以前在面试别人的时候,明明问的是Java内存模型,却回答我Java的内存区域划分,这是很容易在面试官心中减分的回答,面试官只能被迫的慢慢把小哥哥小姐姐们引入到这个知识上去再去提问😁。

主内存与工作内存


内存就是存储变量用的,Java内存模型的目标也是定义程序中变量的访问规则,在虚拟机中将变量存储到内存,和从内存中取出变量的底层规则。

这里的变量包括了实例字段、静态字段、和数组组成元素,但不包括局部变量和方法参数,因为这两块是线程私有的,不会存在并发问题。为什么不包括局部变量和方法参数呢?前面说了下,Java内存模型主要是为了解决并发场景下的内存访问问题。

现代的处理器架构都是多核心,多线程架构,许多情况下,让CPU同时进行多任务计算,是提升服务响应的有效途径。

多任务处理一方面是因为处理器运算能力变强了,另一方面是计算机的计算速度太快了,快到让他的存储系统和通信系统与他的差距太大,实际业务中,大量的场景的处理时间都花费在磁盘I/O,网络I/O。为了不让处理器大多数时间都处于等待这些I/O资源,让处理同时处理多项任务,是一个非常有效压榨CPU资源的一种套路。

当然现在的服务优化途径也不单单是靠多线程,小peng哥的前几篇文章也是小小的提了一下,感兴趣的可以去下方的链接中查看。

  1. 响应式编程
  2. 协程在网路I/O中的探索

处理器在并发的进行多项任务的计算时,需要从内存中读取数据,存储运算结果。由于CPU的处理速度比内存读取存储速度快了好几个数量级,因此不得不在CPU和内存之间加入一层读写速度尽可能接近CPU计算速度的高速缓存,作为内存与处理器之间的缓冲。

将运算需要使用到的数据复制到高速缓存中,运算结束后在从缓存同步回内存,这样可以减少CPU等待内存的I/O时间。

这样又出现了另一个问题,现代的处理器都是多核心,每个核心都有自己的高速缓存,它们共享同一个主内存。当多个处理器的计算都涉及到主内存的同一块存储区域,将可能导致各自的高速缓存中的数据不一致,读取高速缓存中的数据时,也不知道原先主内存中的数据变化没有,同步会主内存时,也无法确定以最终的内存数据以哪一个高速缓存中的为准,这也成为缓存一致性问题。

对此,出现了一些协议,规定了CPU的每个核心访问缓存都要遵守的协议,读和写都要照着这个协议来操作。这类协议有很多,比如MSIMESI等。这里的Java内存模型也可以理解为在特定的操作协议下,对特定的内存或者高速缓存的读和写两种操作的抽象。

而且为了处理器内存的核心能充分地利用,处理器可能会对输入的代码进行乱序执行优化,计算完之后会对乱序执行的结果重组,使最终的结果一致,但是不保证代码的各个语句先后顺序与计算的顺序一致。

所以如果有一个计算任务的依赖另外一个计算任务的结果,不能通过代码的先后顺序来保证执行顺序。对应的,Java虚拟机的即时编译器中也有类似的指令重排序优化。

啰嗦了一大堆,那Java的内存模型到底是怎么样的呢?

Java内存模型规定了所有的变量都存储在主内存中,变量主要是非局部变量与方法参数,比如实例字段、静态字段、和构成数组的组成元素。局部变量和方法参数都是线程私有的,所以不存在竞争关系。

图中的主内存与工作内存与平时所说的Java内存区域的堆、栈、方法区等是没有关系的,虽然二者的概念上看起来似乎一样。图中的主内存是虚拟机内存的一部分,而线程的工作内存除了虚拟机中的部分内存,也包括上文中的CPU高速缓存部分。

每条线程有自己的工作内存,类似于CPU的高速缓存,工作内存中存储了主内存的变量副本拷贝,线程对变量的所有读写操作都必须在工作内存中进行,提醒一下,这里是必须都在工作内存中进行,而不能直接读取和希写入至内存。不同的线程之间也无法访问对方工作内存的变量,线程间的变量传递都需要通过主内存来完成。

主内存与工作内存之间的交互


比如一个变量如何从主内存拷贝到工作内存、如何从工作内存把变量同步回主内存。Java内存模型中定义了下面几种操作来实现内存的交互细节。

  1. lock,作用于主内存的变量,标识一个变量被一个线程独占的状态,平时说的锁定就是这个意思。
  2. unlock,作用于主内存,把一个变量从lock中释放出来,unlock之后的变量才可以被其他线程锁定。
  3. read,作用于主内存的变量,将一个变量的值从主内存传输到线程的工作内存中。
  4. load作用于工作内存,将read操作从主内存得到的变量的值,拷贝到工作内存的变量副本中。
  5. use,作用于工作内存,把工作内存中的一个变量的值传递给虚拟机,每当虚拟机遇到一个需要使用变量的字节码指令时,都会执行这个操作。
  6. assign,作用于工作内存的变量,将从执行引擎上得到的一个变量值,赋值给工作内存中的变量。当虚拟机执行到一个赋值字节码指令时,都会执行这个操作。
  7. store,作用于工作内存,把工作内存中的一个变量的值传递到主内存中,为后续的写入主内存服务。
  8. write,作用于内存的变量,将store操作从工作内存传输到主内存的变量的值,赋值给主内存的变量。

比如将一个变量从工作内存拷贝至主内存,就要经过storewrite,将一个变量从主内存拷贝至工作内存,则需要执行readload。Java内存模型规定了store、writeread、load两个操作之间必须是顺序执行的,但没有规定必须是连续执行。

意味着read、load之间,store、write中间可以插入别的命令,比如对主内存中的变量a,b进行访问时,最终的指令可能是以下几种:

  1. read a + read b + load a + load b
  2. read a + read b + load b + load a
  3. read a + load a + read b + load b
  4. read b + read a + load a + loadb
  5. ......

这里面还涉及到一个指令重排序,上文也提到过,为了充分利用CPU的计算单元,尽可能的不让CPU处于摸鱼中,将一些指令分配到空闲的CPU核心中执行。先mark一下,详细在后面。Java内存模型还对上述的8种操作在执行时必须要满足对的规则,感兴趣的可以去Oracle官网的文档中查看: Oracle官方文档

用volatile关键字举🌰

上文中都是一些理解性的内容,这里用volatile进行具体的贯通下。volatile也是面试中容易被问到的问题。

volatile修饰的变量有可见性,比如一个线程对一个volatile变量进行修改之后,修改后的值对于其他线程来说是可以立即得到的,因为在工作内存中每次使用volatile变量时,都要从主内存中重新read、load

由此可见,各个线程的工作内存中的volatile的变量值也有可能是不一致的,因为只保证了每次使用前从主内存刷新,至于在工作内存中对该变量执行use、assign操作,再刷回到主内存时,最终的主内存中的值可能是中间的某个线程的值,也有可能是最后的某个线程的值。

因此啊,用volatile修饰的变量也不是线程安全的,所以只常用在以下的场景中,在以下的场景中:

  • 只有一个线程对值进行修改,或者计算的结果不依赖当前的变量值。

对于要求线程安全的场景来说,只能通过synchronized关键字或者juc中的锁或者原子类来实现。

对于volatile修饰的变量,除了可见性之外,还有禁止指令重排序优化。对于普通的变量来说,如果有多条赋值语句存在,虚拟机是不会保证最终的代码执行顺序与代码中的编写顺序一致,因此如果有多个线程对同一个主内存中的变量进行赋值,可能最终的值会与期望不一样,Java内存模型描述的线程内表现为串行执行就是指的该问题。

比如下面这段代码说明了volatile变量的线程不安全性,最终得出的结果是一个小于20000的值。

    public static volatile int count = 0;
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 20; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 1000; i++) {
                        try {
                            Thread.sleep(new Random().nextInt(50));
                        } catch (InterruptedException e) {
                        }
                        count++;
                    }
                }
            }).start();
        }
        
        Thread.sleep(2000L);
        System.out.println("count = " + count);
    }

下面的代码反应了volatile变量的禁止指令重排序,如果变量result不是volatile变量,那么虚拟机由于指令重排序,可能会先执行线程A中的赋值语句,导致最后的表现就不一致了。

    volatile boolean result = false;
    // 线程A
    doSomethingA();
    result = true;
    
    // 线程B
    if (!result) {
        doWaiting();
    }

听过一句非常经典的话,如果在一个线程中观察java代码的执行都是有序的,如果在另外一个线程中观察该线程的,所有的操作都是无序的。

总结

回顾下Java内存模型,用土话说Java内存模型是围绕在并发过程中如何处理原子性可见性有序性三个方面的问题。

  1. 原子性,java内存模型保证了readloaduseassignstorewrite这些基本操作的原子性。
  2. 可见性,用土话描述就是指当一个线程修改了共享变量的值,其他线程可以立即读取到这个值。java内存模型中,当变量的值发生了变化之后,会把值store write回主内存,其他的线程使用这个变量的时候会重新read load,从主内存中刷新。
  3. 有序性,前面的线程内有序性和线程外的无序性,也被成为java程序的天然有序性。除了volatile关键字外,也提供了synchronized,表示一个变量在某一个确定的时刻,只能被一个线程lock。synchronized在这3种场景下都可以满足,因此他经常被大伙儿使用,但是这么强大的功能性,也造成了性能低下。

后面的文章小peng哥想聊一下关于线程安全和🔐优化这块的,这次先写到这吧,小peng哥去过过端午了,发一张下班的公司照片哈哈。小哥哥小姐姐别忘了点赞啊。