浅谈Java内存模型和运行时内存区域

334 阅读26分钟

前言

一直想了解Java内存模型。但是我们要知其然并且知其所以然,才算是真正的了解Java内存模型。然而网上的相关文档都是开篇一张图,后面配上一堆生涩难懂的解释,看完一张黑人问号脸。

当网上找不到自己想要的答案时,只能按照自己的想法,自己写文章总结。

本篇文章篇幅过长,目的是尽可能的详细介绍Java内存管理,在介绍Java内存管理的同时,同时引入了一些其他的概念,但是并没有花大篇幅介绍,目的是能够循序渐进的、深入浅出的帮助大家理解一下Java内存管理,并尽量避免篇幅过长,影响大家阅读体验。

文章内有错误之处,希望大家及时指正。希望大家能多多提出自己宝贵的意见。

写到最后才发现其实Java内存模型和Java内存区域不是一个东西,我想了解的应该是Java内存区域为何如此划分,但是既然是踩过的坑,还是老老实实的写在这吧。

Java 为什么需要要进行内存划分

我们都知道虚拟机的内存划分了多个区域,并不是一张大饼。那么为什么要划分为多块区域呢,直接搞一块区域,所有用到内存的地方都往这块区域里扔不就行了,岂不痛快。是的,如果不进行区域划分,扔的时候确实痛快,可用的时候再去找怎么办呢,这就引入了第一个问题,分类管理,类似于衣柜、系统磁盘等等,为了方便查找,我们会进行分区分类。另外如果不进行分区,内存用尽了怎么办呢?这里就引用了内存划分的第二个原因,就是为了方便内存的回收。如果不分,回收内存也就可以根据每个区域的特定进行回收,比如像栈内存中的栈帧,方法执行完毕就出栈了,而对于像堆内存的回收就需要使用经典的回收算法来进行回收了,所以看起来分类这么麻烦,其实是大有好处的。

Java内存区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范(第2版)》的规定,Java虚拟机所管理的内存将会包括一下几个运行时数据区域,如图所示。

为什么Java内存要如此划分?

这其实是我想问的一个问题,但是我翻了好多资料,包括《Java虚拟机规范》,《深入理解Java虚拟机》等等,都没有我想要的答案。

既然如此,我尝试着换一种思路来解决此问题。

  1. 这么划分是否会有遗漏(如新定义的某个东西不属于所定义内存的任意一个区域)
  2. 这么划分是否会有重复(比如一个对象既属于堆又属于栈)
  3. 能不能把两块内存区域合并到一块(原因类似与第2个问题)
  4. 能不能把2块内存区域拆分成3块内存区域(原因类似与第2个问题)
  5. 能不能把1块内存区域分成2块(比如这部分内存区域存储的东西不完全类似,使用的时候无法快速区分出来)
  6. ……………………

这些幼稚的问题获取会帮助我来理解一下Java虚拟机内存区域为何如此划分,毕竟如果我们找不到更好的方案,这种方案就是最好的,内存区域就该如此划分

Java内存区域和Java内存模型是一个东西吗?

事实上,当我第一次听到Java内存区域和Java内存模型的时候,我一直以为是一个东西,而且网上的大部分介绍Java内存模型的文章其实都是在介绍Java运行时内存区域。至于Java内存区域和Java内存模型到底有什么区别,我把答案放在了文章的最后,在大致理解了Java内存区域的时候,再去了解两者的区别会更加容易一些。

Java内存区域

以下部分简单的介绍一下Java运行时内存区域的各个部分的功能。

分类

是否被线程共享

线程独占区域

每个线程都有这么一份空间,每个线程的这片空间都是独有的,有多少个线程就有多少个这么个空间。

  • Java栈
  • 本地方法栈
  • 程序计数器

线程共享区域

这片区域是被所有线程一起使用的,不管有多少个线程,这片空间始终就这一个。

  • 方法区

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确切的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域

深入理解Java虚拟机

作用

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制。
  • 在多线程情况下,程序计数器记录的是当前线程执行的位置,从而当线程切换回来时,就知道上次线程执行到哪了。

由此可见,我们确实需要一个程序计数器的功能,因为我们需要知道下一条指令执行的是什么,但是我们需要保证各个程序计数器之间不互相影响,所以我们就应该将程序计数器设置为线程私有。

Java虚拟机栈

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

深入理解Java虚拟机

每一条Java虚拟机线程都有自己私有的Java虚拟机栈(Java Virtual Machine stack),这个栈与线程同时创建,用于存储栈帧(Frame)。Java虚拟机栈的作用与传统语言(例如C语言)中的栈非常类似,用于存储局部变量与一些尚未算好的结果。另外,它在方法调用和返回中也扮演了很重要的角色。因为除了栈帧的出栈和入栈之外,Java虚拟机栈不会再受其他因素的影响,所以栈帧可以在堆中分配,Java虚拟机栈所使用的内训不需要保证是连续的。

在《Java虚拟机规范》第1版中,Java虚拟机栈也称为“Java栈”。

Java虚拟机规范

由于Java虚拟机栈是与线程对应的,数据不是线程共享的,因此不用关心数据一致性问题,也不会存在同步锁的问题。

栈帧

栈帧(frame)是用来存储数据和部分过程结果的数据结构,同时也用来处理动态链接(dynamic linking)、方法返回值和异常分派(dispatch exception)。

栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(跑出了在方法内未被捕获的异常)都算作方法结束。栈帧的存储空间由创建它的线程分配在Java虚拟机栈之中, 每一个栈帧都有自己的本地变量表(local variable)、操作数栈(operand stack)和指向当前方法所属的类的运行时常量池的引用。

栈帧中还允许携带与Java虚拟机实现相关的一些附加信息,例如,对程序调试提供支持的信息。

本地变量表和操作数栈的容量在编译期确定,并通过相关方法的code属性保存及提供给栈帧使用。因此,栈帧数据结构的消息仅仅取决于Java虚拟机的实现。实现者可以在调用方法时给它们分配内存。

在某条线程执行过程中的某个时间点上,只有目前正在执行的那个方法的栈帧是活动的,这个栈帧称为当前栈帧(current frame),这个栈帧对应的方法称为当前方法(current method),定义这个方法的类称为**当前类(current class)。对局部变量表和操作数据栈的各种操作,通常都指的是对当前栈帧的局部变量表和操作数栈所进行的操作。

如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。调用新的方法时,新的栈帧也会随之而创建,并且会随着程序控制权移交到新方法而成为新的当前栈帧。方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,然后,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧,

请特别注意,栈帧是线程本地私有的数据,不可能在一个栈帧之中引用另外一个线程的栈帧。

Java虚拟机规范

本地方法栈

Java虚拟机实现可能会使用到传统的栈(通常称为C stack)来支持native方法(指使用Java以外的其他语言编写的方法)的执行,这个栈就是本地方法栈(native method stack)。当Java虚拟机使用其他语言(例如C语言)来实现指令集解释器时,也可以使用本地方法栈。如果Java虚拟机不支持native方法,或是本身不依赖传统栈,那么可以不提供本地方法栈,如果支持本地方法栈,那这个栈一般会在线程创建的时候按线程分配。

Java虚拟机规范允许本地方法栈实现成固定大小或者根据计算来动态扩展和收缩。如果采用固定大小的本地方法栈,那么每一个线程的本地方法栈容量可以在创建栈的时候独立选定。

Java虚拟机实现应当提供给程序员或者最终用户调节本地方法栈初始容量的手段,对于长度可动态变化的本地方法栈来锁,则应当提供调节其最大、最小容量的手段。

Java虚拟机规范

无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好的回收内存,或者更快的分配内存。

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中。只要逻辑上是连续的即可,就像我们的磁盘空间一样。

在Java虚拟机中,堆(heap)是可供各个线程共享的运行时内存区域,也是供所有类实例和数组对象分配内存的区域。

Java堆在虚拟机启动的时候就被创建,它存储了被自动内存管理系统(automatic storage management system,也就是常说的garbage collector(垃圾收集器))所管理的各种对象,这些受管理的对象无需也无法显式地销毁

Java虚拟机规范

方法区

Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少见的,但并非数据进入了方法区就如永久代的名字一样“永久”的存在了。这个区域的内存回收目标主要是针对常量值池的回收对类型的卸载。一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。

根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

深入理解Java虚拟机

在Java虚拟机中,方法区(method area)是可供各个线程共享的运行时内存区域。方法区与传统语言中的编译代码存储区(storage area for compiled code)或者操作系统进程的正文段(text segment)的作用非常类似,它存储了每一个类的结构信息,例如,运行时常量池(runtime constant pool)、字段和方法数据、构造函数和普通方法的字节码内容,还包括一些在类、实例、接口初始化时用到的特殊方法。

方法区在虚拟机启动的时候创建了,虽然方法区是堆的逻辑组成部分,但是简单的虚拟机实现可以选择在这个区域不实现垃圾收集和压缩。方法区的容量可以是固定的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩。方法区在世纪内存空间中可以是不连续的。

Java虚拟机规范

运行时常量池

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

Java虚拟机对Class文件的每一部分(自然包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。但对于运行时常量池,Java虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。不过,一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只能在编译期产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用的多的便是String类的intern()方法。

Java虚拟机为每个类型都维护着一个常量池。该常量池是Java虚拟机中的运行时数据结构,像传统编程语言实现中的符号表一样有很多用途。

当类或接口创建时,它的二进制表示中的常量池表被用来构造运行时常量池。运行时常量池中的所有引用最初都是符号引用。

直接内存(堆外内存)

事实上,我们确实存在着不属于Java虚拟机规范中定义的内存区域的内存,是存在于虚拟机内存之外的内存。我们称之为直接内存。

直接内存也称为堆外内存,堆外内存有广义的堆外内存和狭义的堆外内存区分。广义的堆外内存是指jvm本身在运行过程中分配的内存,codecache,jni里分配的内存,DirectByteBuffer分配的内存等等。作为Java开发者,我们说的堆外内存通常是指狭义的堆外内存,也就是java.nio.DirectByteBuffer在创建的时候分配的内存

NIO的Buffer提供了一个可以不经过JVM内存直接访问系统物理内存的类——DirectBuffer。 DirectBuffer类继承自ByteBuffer,但和普通的ByteBuffer不同,普通的ByteBuffer仍在JVM堆上分配内存,其最大内存受到最大堆内存的限制;而DirectBuffer直接分配在物理内存中,并不占用堆空间,其可申请的最大内存受操作系统限制。

直接内存的读写操作比普通Buffer快,但它的创建、销毁比普通Buffer慢(猜测原因是DirectBuffer需向OS申请内存涉及到用户态内核态切换,而后者则直接从堆内存划内存即可)。

因此直接内存使用于需要大内存空间且频繁访问的场合,不适用于频繁申请释放内存的场合。

注意:DirectBuffer并没有真正向OS申请分配内存,其最终还是通过调用Unsafe的allocateMemory()来进行内存分配。不过JVM对Direct Memory可申请的大小也有限制,可用-XX:MaxDirectMemorySize=1M设置,这部分内存不受JVM垃圾回收管理。

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁的使用,而且也可能导致OutOfMemoryError异常出现

在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然而通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

显然,本机直接内存的分配不会收到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括RAM及SWAP区或者分页文件)的大小及处理器寻址空间的限制。服务器管理员配置虚拟机参数时,一般会根据实际内存设置 -Xmx等参数信息,但经常会忽略直接内存,使得各个区域的总和大于物理内存限制(包括物理上的和操作系统级的限制),从而导致动态扩展时出现OutOfOmemoryError异常。

深入理解Java虚拟机

使用堆外内存的原因:

  • 在进程间可以共享,减少虚拟机之间的复制
  • 对垃圾回收停顿的改善。如果应用某些长期存活并大量存在的对象,经常会触发YGC或者FGC,可以考虑把这些对象放到堆外。过大的堆会影响Java的性能。如果使用堆外内存的话,堆外内存是直接受操作系统管理(而不是虚拟机)。这样做的结果就是能保持一个较小的堆内内存,以减少垃圾收集对应用的影响。
  • 提升程序I/O操作的性能。通常在I/O通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到堆外内存。

YGC 和 FGC

什么是 YGC 和 FGC

YGC:对新生代堆进行GC。频率比较高,因为大部分对象的存活寿命比较短,在新生代里面被回收,性能耗费比较小。

FGC:全堆范围的GC。默认堆空间使用到达80%(可调整)的时候触发FGC。

什么时候执行YGC

  • edn空间不足,执行 YGC
  • old空间不足,perm空间不足,调用方法System.gc(),YGC时的悲观策略,dump live的内存信息时(jmap -dump:live),都会执行 FGC。
jmap

jmap主要可以用于打印Java进程的内存映射或堆内存(Heap Dump文件)细节。(如:产生哪些对象,以及数量等)。主要是用在检查内存泄漏、一些严重影响性能的大对象,检查系统中什么对象创建的最多,分析各种对象所占用的大小等。

dump文件

dump文件是进程的内存副本。堆Dump是反映Java堆使用的内存镜像,其中主要包括系统信息、虚拟机属性、完整的线程Dump、所有类和对象的状态等。一般,在内存不足、GC异常等情况下,我们就会怀疑有内存泄漏。这个时候我们就可以制作堆Dump来查看具体情况,分析原因。

CMS

CMS,全称Concurrent Low Pause Controller,或 Concurrent Markup Sweep Controller,是jdk1.4后期版本开始引入的新GC算法。在jdk5和jdk6中得到了进一步改进。

主要适用场景是:对响应时间的重要性需求大于对吞吐量的要求、能够承受垃圾回收线程和应用线程共享处理器资源、并且应用中存在比较多的长生命周期的对象的应用。

CMS是用于对tenured generation的回收,也就是年老代的回收,目标是尽量减少应用的暂停时间,减少full GC发生的几率,利用和应用程序线程并发的垃圾回收线程来标记清除年老代。在我们的应用中,因为有缓存的存在,并且对于响应时间也有比较高的要求,因此希望尝试使用CMS来替代默认的server型JVM使用的并行收集器,以便获得更短的垃圾回收的暂停时间,提高程序的响应性。

STW(Stop The World)

我们知道垃圾回收首先是要经过标记的。对象被标记后会根据不同的区域采用不同的收集方法。看上去很完美的一件事情,其实并不然。

大家有没有想过一件事情,当虚拟机完成两次标记后,便确认了可以回收的对象。但是,垃圾回收并不会阻塞我们程序的线程,它是与当前程序并发执行的。所以问题就出在这里,当GC线程标记好了一个对象的时候,此时我们程序的线程又将该对象重新加入了“关系网”中,当执行二次标记的时候,该对象也没有重写finalize()方法,因此回收的时候就会回收这个不该回收的对象。

虚拟机的解决方法就是在一些特定指令位置设置一些“安全点”,当程序运行到这些“安全点”的时候就会暂停所有当前运行的线程(Stop The World,STW),暂停之后再找到“GC Roots”进行关系的组建,进而执行标记和清除。 这些特定的指令位置主要在:

  • 循环的末尾
  • 方法临返回前/调用方法的call指令后
  • 可能抛出异常的位置

Java内存区域和Java内存模型区别

上面的内容我们大致了解了什么是Java内存区域。我们也可以在这里总结一下。我们先来看看下面这段话:

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干的不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

所以Java内存区域的最主要目的是存放数据。既然虚拟机作为一个虚拟的计算机,来执行我们的程序,那么在执行的过程中,必须要有地方存放我们的代码(class文件),在执行的过程中,总会创建很多对象,必须有地方存储这些对象;在执行的过程中,还需要保存一些执行的状态,比如, 将要执行哪个方法, 当前方法执行完成之后, 要返回到哪个方法等信息, 所以, 必须有一个地方来保持执行的状态。 上面的描述中, “地方”指的当然就是内存区域, 程序运行起来之后, 就是一个动态的过程, 必须合理的划分内存区域, 来存放各种数据。 所以, 我们有了运行时数据区域的概念。

Java内存模型

JMM(Java Memory Model)Java内存模型其实只是一个概念,并不像Java内存结构一样真实存在。

那么我们为什么要引入这么晦涩难懂的概念呢?这与我们实际的场景有关,当我们理解了场景,我们就知道JMM到底是做什么的了。

为什么要有Java内存模型

Java内存模型的提出其实是和计算机的发展有关系的,因此我们从计算机发展过程的角度来理解一下Java内存模型会更容易一些。

阶段一

在计算机发展的第一个阶段,程序是在CPU中运行,数据在主存中保存。随着技术的发展,CPU的速度越来越多高,但是主存的速度却没有提高太多。根据木桶原理,计算机实际的速度是主存的速度。

阶段二

为了解决上面的问题,于是乎出现了缓存,里面存放了一些CPU经常使用的主存数据,缓存的速度和CPU差不多,当CPU查找数据的时候,首先从缓存中查找,没有的话再从主存中查找。写数据的时候,先写缓存的数据,然后再更新到主存中。这样一种机制使得速度提高很多。

阶段三

技术继续发展,在上面缓存的基础上出现了一级二级三级缓存,查找也是逐层的,第一级缓存没有就到第二层,就这样以此类推。这时候CPU也得到了快读发展,由之前的一个核变成了多核CPU(一个CPU变成了多部分)。

这时候呢,之前只能同时跑一个线程,现在就能跑很多个线程了。所以会导致了一个问题,每一个核都有对应的缓存区,但是主内存却只有一个。所以,速度提上去了,却导致了许多其他的问题。

问题一:缓存一致性问题

也就是说,每一个核都有自己的缓存区,但是这些缓存区保存的数据却不一样。一张图就明白了:

问题二:处理器优化和指令重排

这问题的意思是,既然CPU有这么多内核,肯定是想让资源得到充分利用,于是把我们写的程序拆分,对一些代码进行乱序处理,这就是处理器优化。而且,java虚拟机一看CPU的这个操作真的强,于是就模仿了一下,创建了即时编译器(JIT),这个编译器也会做指令重排的操作。很明显,我们的代码顺序被打乱,指令被重排,就可能不会按照我们的意愿去执行了。

问题三 原子性、可见性、有序性问题

上述的问题都是从硬件角度分析的,反映到软件层面就是我们一直所说的原子性、可见性、有序性。

Java内存模型如何解决上述问题

由上述问题我们提出了Java内存模型,有效的解决了上面出现的三个问题。

从这张图我们分析一下java内存模型是如何解决上面的三个问题的。 规则一:所有的数据都在主内存中。 规则二:每个线程都保留一份共享变量的副本。线程对变量的所有操作都必须在这个副本内存中进行,而不能直接读写主内存。 规则三:不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

总结

Java内存模型只是个概念,实际上并不存在,提出这个模型主要是为了解决Java中多个线程共享数据的问题。

参考