Java内存模型(JMM)与线程安全
Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。用来屏蔽不同硬件和操作系统的内存访问差异,让java程序在各种平台下都能达到一致的内存访问效果。具体要点:
- Java内存模型中规定所有变量都存储在主内存;
- JVM调度的实体是线程,JVM为各个线程创建了私有内存空间(工作内存);
- 线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存(不能直接操作主内存)。
- 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
- JMM是围绕原子性,有序性、可见性展开的。
主内存,工作内存理解
-
主内存:主要存储的是Java实例对象,包括成员变量和局部变量,还有共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会出现线程安全问题。
-
工作内存:主要存储当前方法的所有局部变量(工作内存中存储着主内存中的变量副本拷贝);每个线程只能访问自己的工作内存,即线程中的局部变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的局部变量,当然也包括了字节码行号指示器、相关Native方法的信息;注意由于工作内存中数据是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
由于不同的线程间无法访问对方线程的工作内存,线程间的通信(传值)必须通过主内存来完成。下面是传值的模型图:
JAVA线程安全之内存模型3主内存与工作内存的数据存储类型以及操作方式
存储数据类型,根据虚拟机规范,对于一个实例对象中的成员方法而言:
- 局部变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存(JVM栈的帧栈)中;局部变量是引用类型,变量的引用会存储在工作内存(JVM栈的帧栈)中,而对象实例将存储在主内存(堆)中;
- 成员变量不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,变量引用和实例都会被存储到主内存(堆区);
- 全局变量(也就是static修饰的变量,也可以称为类变量)存储在主内存中(方法区中);
操作:主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存。
内存模型和内存区域划分的区别
- JMM与Java内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,而后者是实际的区域划分;
- JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域。在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区;而工作内存(线程私有数据区域),从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。
- 某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。
Java线程与硬件处理器
先来看几个概念,帮助理解Java线程与硬件处理器的关系。
- 进程和线程的概念:进程是资源管理(分配)的最小单位,线程是程序执行(系统调度)的最小单位。
- 线程和进程各自有优缺点,进程方便系统资源的管理与维护,但是执行创建进程的系统开销较大,通常为线程创建系统开销的几百倍,而线程则刚好相反,线程的系统开销小,并发性更好。因此线程是一种更加“节俭”,更高效的一种机制。
Linux 线程(叫轻量级进程)模型
- Linux最开始没有线程的概念,那么带来问题运行效率低。多进程中上下文切换效率低,于是有了LinuxThreads。线程机制LinuxThreads所采用的就是线程-进程”一对一”模型;LinuxThreads 最初的设计相信相关进程之间的上下文切换速度很快,因此每个内核线程足以处理很多相关的用户级线程。这就导致了一对一 线程模型(即一个用户线程对应一个轻量级进程,而一个轻量级进程对应一个特定的内核线程);但仍有明显缺点,后来有了改进版本也就是NPTL。
- NPTL,或称为 Native POSIX Thread Library,是 Linux 线程的一个新实现,它克服了 LinuxThreads 的缺点,同时也符合 POSIX 的需求。与 LinuxThreads 相比,它在性能和稳定性方面都提供了重大的改进。与 LinuxThreads 一样,NPTL 也实现了一对一的模型(即一个用户线程对应一个轻量级进程,而一个轻量级进程对应一个特定的内核线程)。
三个主要的概念——内核线程、轻量级进程、用户线程
-
内核线程:内核线程就是内核的分身,一个分身可以处理一件特定事情。内核线程只能由内核管理并像普通进程一样被调度。内核线程的使用是廉价的,唯一使用的资源就是内核栈和上下文切换时保存寄存器的空间。支持多线程的内核叫做多线程内核(Multi-Threads kernel )。
-
轻量级线程(LWP)是一种由内核支持的用户线程。它是内核线程的高度抽象。每一个轻量级进程都与一个特定的内核线程关联。因此每个LWP都是一个独立的线程调度单元。即使有一个LWP在系统调用中阻塞,也不会影响整个进程的执行。轻量级进程由clone()系统调用创建,参数是CLONE_VM。
轻量级进程局限性:
- 首先,大多数LWP的操作,如建立、析构以及同步,都需要进行系统调用。系统调用的代价相对较高:需要在user mode和kernel mode中切换。
- 其次,每个LWP都需要有一个内核线程支持,因此LWP要消耗内核资源(内核线程的栈空间)。因此一个系统不能支持大量的LWP。
-
用户线程:这里的用户线程指的是完全建立在用户空间的线程库,用户线程的建立,同步,销毁,调度完全在用户空间完成,不需要内核的帮助。因此这种线程的操作是极其快速的且低消耗的。
JVM中线程的实现原理
- 调用一个线程,调用的是用户空间的线程库,线程库中每个线程(用户线程)对应着一个轻量级进程,而一个轻量级进程对应一个内核线程,所有的内核线程经内核线程调度器调度交由CPU完成相应操作。
- 我们在使用Java线程时,Java虚拟机内部是转而调用当前操作系统的内核线程来完成当前任务;
- 内核线程(Kernel-Level Thread,KLT):它是由操作系统内核(Kernel)支持的线程,操作系统内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因。
流程示意图
JAVA线程安全之内存模型6Java内存模型与硬件内存架构
硬件内存流程:
- CPU从内存(类比主内存,但不完全相同)中取数据,然后进行处理,但是内存的处理速度远远低于CPU,于是在寄存器和内存中间加了一个CPU缓存(类比工作内存,但不完全相同),虽小但是速度比内存快。
- 寄存器不一定每次都能从缓存中取到数据,取不到就去内存中直接取。寄存器从缓存中取到数据的概率叫做命中率,影响着CPU的执行性能。
- CPU中处理完数据更新到内存中过程是一个相反的过程。
Java内存模型与硬件内存架构的关系
- Java内存模型(JMM)只是一个抽象的概念,是一种规则,并不是真正存在的结构; 硬件内存结构是存在的物理结构。
- 通过对Java内存模型,硬件内存架构、以及Java多线程的实现原理的了解,我们应该已经意识到,多线程的执行最终都会映射到硬件处理器上进行执行。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。
理解线程安全问题的原因
Java内存模型的承诺
- 原子性:原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。如果不能够保证原子性操作,在多线程环境中就会带来线程安全问题。
-
可见性:在理解可见性之前先看一下指令重排(在JVM层面/硬件层面,本质都是为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响)
- 编译器优化的重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令并行的重排:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序
-
内存系统的重排:由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
ps:上面第一种属于编译器重排,后两种属于处理器重排。在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题,可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。
- 有序性:单线程中:认为代码按照顺序依次执行的,是有序的执行。多线程中:指令分配给不同的线程执行,并且加上上面指令重排现象,其实就带来了无序性。也是造成线程安全问题的原因。
ps:如果出现数据依赖,那么就不会出现指令重排!
单线程中肯定不存在这个问题,因为程序按照串行顺序进行。
多线程情况下,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题,从而带来线程安全问题。
- 总结:多线程指令重排 -- > 导致可见性问题 -- > 线程安全问题
- 解决方案:volatile、syncronized关键字、内存屏障、happen-before的八个规则。
内存屏障:也叫内存栅栏,是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。ps:Store:将处理器缓存的数据刷新到内存中/刷新数据到主内存(写入)。Load:将内存存储的数据拷贝到处理器的缓存中/数据加载(读取)。
通过volatile标记,可以解决编译器层面的可见性与重排序问题。而内存屏障则解决了硬件层面(处理器层面)的可见性与重排序问题!!
- LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,该屏障确保Load1数据的加载先于Load2及其后所有加载指令的的操作
- StoreStore屏障:对于这样的语句Store1; StoreStore; Store2, 该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
- LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,确保Load1的数据加载先于Store2及其后所有的存储指令刷新数据到内存的操作
- StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有加载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令。StoreLoad Barriers同时具备其他三个屏障的效果,因此也称之为全能屏障(mfence),是目前大多数处理器所支持的;但是相对其他屏障,该屏障的开销相对昂贵。
happen-before原则
编写的程序都要经过优化(编译器和处理器会对程序进行优化)后才会被运行,优化分为很多种,其中有一种优化叫做重排序,重排序需要遵守happens-before规则。不能说你想怎么排就怎么排,如果那样岂不是乱了套。
happens-before原则的核心是:可见性,并不是说 一个操作发生在另一个操作前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的。(无论他们是否在同一个线程),一共有八个规则:包括单线程、锁、volatile、传递规则、线程的启动/中断/终止与对象的创建。
- 单线程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 C操作。
- 线程启动的happen-before原则:同一个线程的start方法happen-before此线程的其它方法。
- 线程中断的happen-before原则 :对线程interrupt方法的调用happen-before被中断线程的检测到中断发送的代码。
- 线程终结的happen-before原则: 线程中的所有操作都happen-before线程的终止检测。
- 对象创建的happen-before原则: 一个对象的初始化完成先于他的finalize方法调用。
拓展:Java 内存模型也规定了 JVM 如何按需提供禁用缓存与编译优化的方法,这些方法就是 volatile、syncronized、final 三个关键字与 happen-before 原则。
- volatile 其实是一种稍弱的同步机制,在并发场景下它并不能保证线程安全。
- 加锁机制既可以保证可见性又可以保证原子性,而 volatile 变量则只能保证可见性。在 jdk1.5 之前 volatile 给我们最深刻的印象就是 禁用缓存,即每次读写都是直接操作内存(不是 CPU 缓存),从内存中取值。Java 从 jdk1.5 开始对 volatile 进行了优化:happen-before 原则中的传递性规则。
- final域 内存模型:保障构造函数中对象不溢出的情况下,其他线程拿到的是初始化后的final 对象。和volatile相比较,对final域的读和写更像是普通的变量访问
巨人的肩膀:
https://www.huaweicloud.com/articles/08fb82013db0fbcb43f060033d563b55.html
https://mp.weixin.qq.com/s/6Laqv1ryS_EobJNOvKcSCA