概述

虚拟机JVM的内存管理是Java语言与C/C++语言最典型的差异之一。在虚拟机的帮助下,编写Java程序无需对每一个new操作编写其配对的free操作,一切都由JVM为你搞定,这也是Java语言开发效率比C/C++语言高效的来源之一。JVM内存管理降低了软件开发的门槛,对于刚入门的新手而言,这样的特性无疑是非常友好的。但是,同框架所带来的的问题一样,越方便的东西往往意味着其自由度越低,复杂度越高。要想编写优美、准确的Java程序,那么理解JVM内存管理的原理,了解它的优点与缺点,知道其特性的边界是非常有必要的。

数据区域

虚拟机在执行Java程序的过程中会将它所管理的内存划分为若干个不同的内存区域:程序计数器虚拟机栈本地方法栈方法区。在JDK1.8及以后的版本中,方法区被取消,其Class信息、符号引用、常量池、JIT信息等移入到MetaSpace中,静态变量区等被移入到堆中。MetaSpace属于本地内存,且带有内存回收机制。整个Java虚拟机运行时数据区如下所示: jvm-structure

程序计数器

程序计数器所占用的空间较小,它可以看做是当前线程所执行字节码的行号指示器,标志当前JVM执行到了字节码的哪一行。所有分支、跳转、循环、异常处理等基础功能都依赖于它。显然的,程序计数器所保存的值仅对Java方法有用,在Native方法中程序计数器始终为空。由于每个线程都有自己的执行逻辑,因此该区域也是每个线程所独有的。该内存空间不存在OOM问题。

Java虚拟机栈

Java虚拟机栈与程序计数器相互搭配使用,它也是线程私有。其核心在于栈帧:栈帧保存了一个Java方法在执行过程中需要的信息:局部变量表、操作数栈、动态链接、方法出入口等信息。当Java程序运行进入一个方法时,JVM就会将该方法所对应的栈帧压入虚拟机栈中,当其执行完毕时,对应的栈帧将执行出栈操作。该区域有以下两种关键异常需要注意

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。其中虚拟机所允许的最大深度并没有明确指定,它与栈帧大小/栈最大空间大小密切相关。
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。书中采用了一种很巧妙的方式来构建OutOfMemory异常:首先通过设置大容量堆及大容量方法区,将虚拟机栈所能使用的空间压榨得足够小;再通过建立本地线程的方式耗尽其内存资源,达到模拟出OOM的目的。对于这个方法,本人存在一点疑问:虚拟机栈在运行过程中创建的本地方法所使用的空间是否为虚拟机栈中的空间?因为Java程序在运行过程中还会使用JVM之外的本地内存,本地线程是由操作系统所直接管理的,那么本地线程的数据也很有可能是保存在直接内存之中,而非虚拟机栈中。

本地方法栈

Java语言提供了JNI接口,可以直接通过虚拟机调用本地方法,在调用这些方法时所使用的栈空间即为本地方法栈。JVM对此所作的规范较少,各JVM对其的实现方式也各不相同,这里就不着重介绍了。

Java堆

Java堆是JVM所管理的最大的一块内存区域,同时也是最重要的内存区域之一。除了静态常量池与使用直接内存的Buffer外,几乎所有的对象实例以及数组都在这里分配内存。需要注意的是,JVM的堆可以处于物理上不连续的内存空间中(Linux中的虚拟内存机制)。

JVM堆的空间是被所有线程所共享的,也就是说:对于堆中的对象,任何线程只要持有该对象的refence,就可以访问到堆中对应的对象。这样就带来了一个问题:多个线程同时在JVM堆中请求内存分配时,带来的并发冲突如何解决?JVM给出的答案是TLAB(Thread Local Allowcation Buffer),对于每一个线程,JVM将预分配一定的空间给各个线程用于对象的创建,这样就避免了多个线程同时请求空间所带来的的并发冲突。

为对象在JVM堆中分配内存的过程本质上就是把一块确定大小的内存划分出来。根据JVM堆所使用的的gc收集器的不同,一般有以下两种划分方式:

  1. 指针碰撞(Serial,ParNew等带Compact的收集器) 假设JVM堆中的内存是绝对规整的:所有使用过的内存都放在一边,空闲的内存放在另一边,中间放一个指针作为分界点的指示器,那么JVM的分配过程可以简单的看作将指针向空闲方向移动所需空间大小的距离。这样的分配方式被称为“指针碰撞”(Bump the Poniter)
  2. 空闲列表(CMS这种基于Mark-Sweep的收集器) 如果JVM堆中的内存不是规整的,已使用的内存与未使用的内存相互交错,在这种情况下将无法简单进行指针碰撞了,JVM必须维护一个记录有所有空闲内存的列表,分配时通过列表找到一块足够大的空间划分给对象实例,再更新列表记录。这样的方式被称为“空闲列表”(Free List)

JVM对象详细分配流程 obj-allocation

方法区

方法区存储的是已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,与堆一样,它属于各个线程共享的一片内存区域。

在JDK1.7及之前版本中,该区域也被称为永久代(Permanent Generation),意为堆中永远不会被回收的区域。这样的说法其实不准确,hotspot虚拟机其实也支持方法区的gc,只是默认为关闭状态。一般来说,虚拟机中所加载类的数量并不会很大,再加上JVM对于类的加载采用的是lazy-load的方式,即只有用到了类的时候才会去动态加载这个类,方法区没有GC也不会存在任何问题。但是CGLIB等动态生成类的框架出现之后,方法区中所保存的类信息爆炸式膨胀,这就是PermGen Out Of Memory异常的源头。

Class文件被加载到JVM中之后,其中的字面量常量将被保存至JVM的方法区中。除此之外,在程序运行的过程中调用String.inter()等方法也可将数据放入常量池中。JDK1.7版本之后,字面量区域被移入到堆中。

JDK1.8版本后,永生代被废弃,方法区中的数据被分别移至堆中或MetaSpace中。其中移入堆中的数据有:字面量区域、类信息中的static等。被移入MetaSpace中的数据有:类信息中的field与method、常量池、JIT信息等。

直接内存

直接内存并不属于JVM的一部分,但是Java程序在运行的过程中却会频繁用到这部分内存。需要注意的是,直接内存的大小并不受堆大小的限制。但是直接内存也会造成OutOFMemory错误,当程序所需的内存超过系统物理内存限制的时候。直接内存同样有垃圾回收机制,对于Buffer类对象而言,经过可达性分析之后,不被使用对象的引用将被放入一个虚引用的队列之后,随后将释放其占用的空间。

使用Java编程时最常接触到的直接内存是NIO中直接分配的Buffer对象,这样的对象分配耗时远远大于在JVM中分配的耗时,但是避免了数据在Native内存与Java堆中来回复制数据,能够显著的提高其读写效率。

JDK1.8引入的MetaSpace使用的就是直接内存。