理解Java内存模型总结

版权声明:本文为天涯原创文章,未经天涯允许不得转载。 https://blog.csdn.net/tyyj90/article/details/73692905

java线程之间的通信对于java程序员来说是完全透明的,内存可见性问题很容易困扰着java程序员。Java内存模型规范了Java虚拟机与计算机内存是如何协同工作的。Java虚拟机是一个完整的计算机模型,因此这个模型自然也包含一个内存模型——又称为Java内存模型,简称JMM。

如果想要设计表现良好的并发程序,理解JMM是非常重要的。JMM规定了如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

1 堆内存和栈内存

1.1 关于堆内存和栈内存

Java中数据的存储位置分为以下5种:

1.寄存器

最快的存储区,位于处理器内部,但是数量极其有限。所以寄存器根据需求进行自动分配,无法直接人为控制。

2.栈内存

栈内存即虚拟机栈。Java虚拟机栈与程序计数器一样,也是线程私有的,其生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接和方法出口等信息。每一个方法被调用直至执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型)和returnAddress类型(指向了一条字节码指令的地址)。其中64位长度的long和double会占用2个局部变量空间(Slot),其余数据类型只占用1个。局部变量表所需的空间在编译期间完成分配,当进入一个方法时,其需要在帧中分配多大的局部变量空间是确定的,方法运行期间不会改变局部变量表的大小。

Java虚拟机规范中对该区域规定了两种异常情况:

1) 如线程请求的深度大于虚拟机所允许的深度,抛出StackOverflowError异常;

2) 虚拟机栈动态扩展无法申请到足够的内存时,抛出OutOfMemoryError异常。

3.堆内存

一种通用的内存池,也位于RAM当中。其中存放的数据由JVM自动进行管理。Java堆是Java虚拟机管理内存中最大的一块,是所有线程共享的内存区域,随虚拟机的启动而创建。该区域唯一目的是存放对象实例,几乎所有对象的实例都在堆里面分配。Java堆是垃圾收集器管理的主要区域,被称作“GC堆”。

Java虚拟机规范规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可。

Java虚拟机规范中对该区域规定了OutOfMemoryError异常:

如果堆中没有内存完成实例分配,并且堆无法再扩展则抛出OutOfMemoryError异常。

4.常量池

常量(字符串常量和基本类型常量)通常直接存储在程序代码内部(常量池)。这样做是安全的,因为它们的值在初始化时就已经被确定,并不会被改变。常量池在java用于保存在编译期已确定的,已编译的class文件中的一份数据。它包括了关于类、方法、接口等中的常量,也包括字符串常量,如String s = “java”这种申明方式。

5.非RAM存储区

如果数据完全存活于程序之外,那么它可以不受程序的任何控制,在程序没有运行时也可以存在。其中两个基本的例子是:流对象和持久化对象。


其实,Java内存不只局限于堆内存和栈内存两种,还有:

1.程序计数器,程序计数器是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的信号指示器。字节码解释器就是通过改变该计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需依赖计数器来完成。

每一个JVM线程都有独立的程序计数器,各线程间的计数器互不影响,独立存储,确保线程切换后能够恢复到正确的执行位置。在任意时刻,一条JVM线程只会执行一个方法的代码。该方法称为该线程的当前方法(Current Method),如果该方法是Java方法,那计数器保存JVM正在执行的字节码指令的地址;如果该方法是Native,那PC寄存器的值为空(Undefined)。

此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

2.本地方法栈,Java虚拟机可能会使用到传统的栈来支持native方法(使用Java语言以外的其它语言编写的方法)的执行,这个栈就是本地方法栈(Native Method Stack)。本地方法栈与虚拟机栈非常类似,区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,二本地方法栈则为虚拟机使用到的Native方法服务。虚拟机规范对本地方法栈中的方法是用语言、使用方式与数据结构没强制规定,因此虚拟机可以自由实现,如Sun HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一。

Java虚拟机规范中对该区域规定了两种异常情况:

1) 如线程请求的深度大于虚拟机所允许的深度,抛出StackOverflowError异常;

2) 虚拟机栈动态扩展无法申请到足够的内存时,抛出OutOfMemoryError异常。

3.方法区,与Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量和即时编译器编译后的代码等数据。Java虚拟机对这个区域的限制非常宽松,处理和Java对一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。

Java虚拟机规范中对该区域规定了OutOfMemoryError异常:

如果方法区的内存空间不能满足内存分配请求,那Java虚拟机将抛出一个OutOfMemoryError异常。

4.运行时常量池,运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法和接口等信息外,还有一项信息是常量池,用于存放编译期生成的各种字面常量和符号引用,这部分内容在类加载后存放到方法区的常量池中。

Java虚拟机规范中对该区域规定了OutOfMemoryError异常:

当常量池无法申请到内存时抛出OutOfMemoryError异常。

5.直接内存,直接内存并不是虚拟机运行时数据区域的一部分,也非Java虚拟规范中定义的内存区域,但这部分内存也被频繁使用,并且可能导致OutOfMemoryError异常出现。Java虚拟机需要根据实际内存的大小来设置-Xmx等参数信息,如果忽略了直接内存,使得各个内存区域的总和大于物理内存限制,从而导致动态扩展时抛出OutOfMemoryError异常。

在JDK 1.4中新加入的NIO类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用操作。这样可以显著提高性能,因为避免了在Java堆和Native堆中来回拷贝数据。

下图为Java 虚拟机运行时数据区,也就是Java虚拟机所管理的内存示意图。

Java 虚拟机运行时数据区


再来看关于堆内存和栈内存,首先,它们有一定的相同之处:

堆与栈都是用于程序中的数据在RAM(内存)上的存储区域。并且Java会自动地管理堆和栈,不能人为去直接设置。

其次,更关键的在于它们的不同之处:

1.存储数据类型:栈内存中存放局部变量(基本数据类型和对象引用)等,所有实例域和数组元素都存储在堆内存中。

2.存储速度:就存储速度而言,栈内存的存储分配与清理速度更快于堆,并且栈内存的存储速度仅次于直接位于处理器当中的寄存器。

3.灵活性:由于栈内存与堆内存存储机制的不同,堆内存灵活性更优于栈内存。

这样两种存储方式的不同之处,也是由于它们自身的存储机制所造成的。在Java中:

— 栈内存被要求存放在其中的数据的大小、生命周期必须是已经确定的;

— 堆内存可以被虚拟机动态的分配内存大小,无需事先告诉编译器的数据的大小、生命周期等相关信息。

1.2 从代码角度看java虚拟机内存分配

Object obj = new Object();

非常简单的一个新建对象的代码。代码在虚拟机中运行时:

1) “Object obj”这部分的语义反映到Java栈的本地变量表中,作为一个引用类型数据出现;

2) “new Object()”这个部分的语义反映到Java堆中,形成一块存储了Object类型所有实例数据值的结构化内存,以及查找到此对象类型的地址信息;

3) Object类的类型数据(如对象类型、父类、接口的实现和方法等)存储在方法区中。

2 JMM内部原理

2.1 JMM的抽象结构

Java线程之间的通信由JMM控制,通过上面的知识可以知道线程间的共享变量是通过堆内存共享的,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

JMM内存模型的抽象结构示意图

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。然后,线程B到主内存中去读取线程A之前已更新过的共享变量。下面通过示意图来说明这两个步骤:

线程之间通信示意图

如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

那么可以知道如果一个线程更新了本地内存中的共享变量副本,还没有刷新到堆中,而另一个线程此刻从堆内存中读取它到本地内存,就会出现内存不可见性。

2.2 JVM对JMM的实现

粗略的看,在JVM内部,JMM把内存分成了两部分:线程栈区和堆区。

Java内存模型在JVM中的逻辑示意图

在程序运行时,线程栈区和堆区内存分配可以简化看成下图:

线程栈区和堆区内存分配示意图

堆中的对象可以被多线程共享。如果一个线程获得一个对象的引用,它便可访问这个对象的成员变量。如果两个线程同时调用了同一个对象的同一个方法,那么这两个线程便可同时访问这个对象的成员变量。

这就通过JVM实现了两个线程之间的通信,实现了变量共享。

2.3 JMM与硬件的关系

JMM和硬件内存架构并不一致。硬件内存架构中并没有区分栈和堆。从硬件上看,不管是栈还是堆,大部分数据都会存到RAM中,
也可能出现在CPU缓存和寄存器(JMM本地内存)中。下图为JMM和硬件内存架构之间的对应关系。

JMM和硬件内存架构对应关系

3 并发问题的思考

由于存在本地内存和主内存之间的数据读写时差,多线程运行中就会出现共享变量的不可见性和竞争问题。

3.1 共享对象对各个线程的可见性问题

共享对象对各个线程的可见性问题

左边线程从主内存中读取obj.count到本地内存并做出修改obj.count = 2,还没有来得及从本地内存刷新到主内存中。此时,右边线程从主内存中读取obj.count到自己的本地内存,obj.count仍然为1。出现左边线程对obj.count修改对右边线程不可见。

3.2 共享对象的竞争问题

共享对象的竞争问题

左右两边线程同时从主内存中读取obj.count到本地内存,并行修改,此刻在左右两边线程的本地内存中都为obj.count = 2,当它们刷新到主内存时,主内存当然还是obj.count = 2。实际上我们期望最后结果为obj.count = 3,但由于存在两个线程竞争修改共享对象,导致结果出现了错误。

3.3 可见性需要程序员介入

从上面的两个问题可以看出想要保证正确的结果,在多线程编程中,对共享变量的修改保证每个线程可见,就需要手动介入,在一个线程修改了共享变量还没有刷新到主内存中时,其他读取这个共享变量的线程应该等待。

当多个线程同时操作同一个共享对象时,一个线程对共享对象的更新有可能导致其它线程不可见。为了保证可见性可以使用Java提供的关键字volatile和synchronized保证可见性。

4 支撑JMM的基础

4.1 指令重排序

在执行程序时,为了提高性能,编译器和处理器会对指令做重排序。但是,JMM确保在不同的编译器和不同的处理器平台之上,通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序,为上层提供一致的内存可见性保证。

编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

指令级并行的重排序:如果不存数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

内存系统的重排序:处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

指令重排序顺序图

数据依赖性

如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。

as-if-serial

不管怎么重排序,单线程下的执行结果不能被改变,编译器、runtime和处理器都必须遵守as-if-serial语义。

4.2 内存屏障(Memory Barrier)

通过内存屏障可以禁止特定类型处理器的重排序,从而让程序按我们预想的流程去执行。内存屏障,又称内存栅栏,是一个CPU指令,基本上它是一条这样的指令:

保证特定操作的执行顺序。影响某些数据(或则是某条指令的执行结果)的内存可见性。编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU:不管什么指令都不能和这条MemoryBarrier指令重排序。

Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入 cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。volatile是基于Memory Barrier实现的。

4.3 happens-before

从JDK5开始,java使用新的JSR-133内存模型,基于happens-before的概念来阐述操作之间的内存可见性。

在JMM中,如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,这个的两个操作既可以在同一个线程,也可以在不同的两个线程中。

与程序员密切相关的happens-before规则如下:

程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意的后续操作。

监视器锁规则:对一个锁的解锁操作,happens-before于随后对这个锁的加锁操作。

volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。

传递性规则:如果 A happens-before B,且 B happens-before C,那么A happens-before C。

注意:两个操作之间具有happens-before关系,并不意味前一个操作必须要在后一个操作之前执行!仅仅要求前一个操作的执行结果,对于后一个操作是可见的,且前一个操作按顺序排在后一个操作之前。

参考资料

1.《深入理解Java虚拟机 JVM高级特性与最佳实践》 周志明 著

2.《Java并发编程的艺术》 方腾飞等 著

3.http://blog.csdn.net/suifeng3051/article/details/52611310

猜你喜欢

转载自blog.csdn.net/tyyj90/article/details/73692905