java并发编程-内存模型
一、前言
首先我们需要了解java内存模型与我们经常谈到的Java内存结构(JVM内存结构)并不是同一个概念,这两个概念估计有不少人会混淆,经常面试时在问内存模型时,很多小伙伴会理解为内存结构,这也就导致面试过程中经常答非所问。
它们都可以说是 JVM 规范的一部分,但真不是一回事!
- Java 内存模型,描述的是多线程允许的行为。
- Java 内存结构,描述的是线程运行所设计的内存空间。(常说的 JVM 内存结构指的就是上文提交到运行时数据区,其中堆、方法区被线程共享,程序计数器、栈、运行时常量池被线程独享。它描述的是,在运行时,字节码和代码数据存储的位置)。
二 Java内存模型
2.1 什么是内存模型?
先抛开Java我们看下什么是内存模型,在计算中,内存模型描述了多线程如何正确的通过内存进行交互和使用共享数据。换句话说,内存模型约束了处理器对内存的读写。
现代计算机一般都有2个以上CPU,而且每个CPU还有可能包含多个核心。因此,如果我们的应用是多线程的话,这些线程可能会在各个CPU核心中并行运行。
在CPU内部有一组CPU寄存器,也就是CPU的储存器。CPU操作寄存器的速度要比操作计算机主存快的多。在主存和CPU寄存器之间还存在一个CPU缓存,CPU操作CPU缓存的速度快于主存但慢于CPU寄存器。某些CPU可能有多个缓存层(一级缓存和二级缓存)。计算机的主存也称作RAM,所有的CPU都能够访问主存,而且主存比上面提到的缓存和寄存器大很多。
当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存,进而在读取CPU缓存到寄存器。当CPU需要写数据到主存时,同样会先flush寄存器到CPU缓存,然后再在某些节点把缓存数据flush到主存。
CPU 和内存之间通常会存在一层或多层高速缓存,这对单处理器可能没问题,但在多处理器系统中,可能就会出现缓存一致性问题,也就是当两个处理器(线程)同时读取相同内存位置会发生什么?什么情况下会看到相同的值?
缓存一致性问题,在并发编程中,又被称作可见性问题。内存模型在处理器级别,为处理器彼此之间对内存写入结果的可见性,定义了充分必要条件:
- 强内存模型,一般说的是顺序一致性,所有内存操作存在一个全序关系,每个操作都是原子的且立即对所有处理器可见
- 弱内存模型,不限制处理器的内存操作顺序,而使用特殊指令刷新或者使本地缓存失效,以便看到其他处理器的写入,或使此处理器的写入对其他处理器可见,这些特殊指令被称为内存屏障
大多数处理器不会限制内存操作的顺序,多线程在执行时可能会出现让人困惑和违背直觉的结果。这是因为 CPU 为了充分利用不同类型存储器(寄存器、高速缓存、主存)的总线带宽,会将内存操作重新排序,以无序执行,这个动作称为内存排序或指令重排序。
重排序,也被称为编译器优化和处理器优化,因为它既可以发生在编译期间,也可以发生在 CPU 运行时。为了保证多线程的有序性,需要使用内存屏障禁止重排序。
所以说,内存模型就是在硬件层面描述了使用内存屏障(刷新缓存或禁用指令重排序)解决多线程编程中的可见性和有序性的问题。
2.2 什么是Java内存模型?
Java内存模型的相关知识在 JSR-133: Java Memory Model and Thread Specification 中描述的。JMM是和多线程相关的,他描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。
Java 内存模型中规定了所有的变量都存储在主内存中,每个线程还有自己的工作内存(类比缓存理解),线程的工作内存中保存了该线程使用到主内存中的变量拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递(通信)均需要在主内存来完成,线程、主内存和工作内存的交互关系如下图所示
Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键。
三 Java内存模型面临问题
当对象和变量存储到计算机的各个内存区域时,必然会面临一些问题,其中最主要的两个问题是
- 共享对象对各个线程的可见性
- 共享对象的竞争现象
3.1 共享对象的可见性
想象一下我们的共享对象存储在主存,一个CPU中的线程读取主存数据到CPU缓存,然后对共享对象做了更改,但CPU缓存中的更改后的对象还没有flush到主存,此时线程对共享对象的更改对其它CPU中的线程是不可见的。最终就是每个线程最终都会拷贝共享对象,而且拷贝的对象位于不同的CPU缓存中。
下图展示了上面描述的过程。左边CPU中运行的线程从主存中拷贝共享对象obj到它的CPU缓存,把对象obj的count变量改为2。但这个变更对运行在右边CPU中的线程不可见,因为这个更改还没有flush到主存中:
要解决共享对象可见性这个问题,我们可以使用java volatile关键字。 Java’s volatile keyword. volatile 关键字可以保证变量会直接从主存读取,而对变量的更新也会直接写到主存。
3.2 竞争现象
如果多个线程共享一个对象,如果它们同时修改这个共享对象,这就产生了竞争现象。
如下图所示,线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中。
如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。然而下图中两个加1操作是并行的,不管是线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次变成2,尽管一共有两次加1操作。
要解决上面的问题我们可以使用java synchronized代码块。synchronized代码块可以保证同一个时刻只能有一个线程进入代码竞争区,synchronized代码块也能保证代码块中所有变量都将会从主存中读,当线程退出代码块时,对所有变量的更新将会flush到主存,不管这些变量是不是volatile类型的。
四 总结
- Java并发编程需要理解Java内存模型与Java内存结构的区别。
- Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,JMM规范定义了一些语法集volatile、synchronized等解决多线程间通信问题。
- Java并发编程必须注意可见性和竞争现象