虽然说了解虚拟机的运作并不是一般开发人员必须掌握的知识,但是对于中高级开发人员来说,如果不了解JVM一些技术特性的运行原理,就无法写出更高效、更稳定的代码。并且在出现了内存相关的问题时,如果不了解虚拟机是如何使用内存的,那么进行错误排查及修复也会成为一个异常艰难的工作。本章将从JVM运行时区域和GC角度分析Java的内存分配,希望对大家有所帮助。
运行时区域
Java虚拟机在执行Java程序的过程中会把它管理的内存区域划分为若干个不同的数据区域。根据《java虚拟机规范》的规定,Java虚拟机所管理的内存包括以下几个运行时数据区域:
1.虚拟机栈
JVM栈是线程私有的内存区域。它描述的是java方法执行的内存模型,每个方法执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至完成的过程,都对应着一个栈帧从入栈到出栈的过程。每当一个方法执行完成时,该栈帧就会弹出栈帧的元素作为这个方法的返回值,并且清除这个栈帧,Java栈的栈顶的栈帧就是当前正在执行的活动栈,也就是当前正在执行的方法。就像是组成动画的一帧一帧的图片,方法的调用过程也是由栈帧切换来产生结果。
很多开发人员会把Java内存分为堆内存(Heap)和栈内存(Stack),这种划分的流行只能说明大多数开发人员最关注、与对象内存分配关系最密切的内存区域是这两块,其中所指的“堆”在后面会讲到,而所指的“栈”就是JVM栈,或者说是JVM栈中的局部变量表部分。实际上Java内存区域的划分远比这要复杂。
局部变量表存放了编译器可知的各种基本数据类型(int、short、byte、char、double、float、long、boolean)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一跳字节码指令的地址)。
在JVM规范中,对这个区域规定了两种异常情况:如果线程请求的栈深度大于虚拟机允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,在扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
2.本地方法栈
本地方法栈和虚拟机栈所发挥的作用是很相似的,它们之间的区别不过是 虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。Sun HotSpot 直接就把本地方法栈和虚拟机栈合二为一。本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。
3.程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机概念模型里(概念模型,各种虚拟机可能会通过一些更高效的方式实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令:分支、跳转、循环、异常处理、线程恢复等基础操作都会依赖这个计数器来完成。每个线程都有独立的程序计数器,用来在线程切换后能恢复到正确的执行位置,各条线程之间的计数器互不影响,独立存储。所以它是一个“线程私有”的内存区域。此内存区域是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域。
4.Java堆
对于大多数应用来说,Java堆(Heap)是JVM所管理的内存中最大的一块。它是被所有线程共享的一块内存区域,在虚拟机启动时创建。主要用来存放对象实例,所有的对象实例以及数组都要在堆上分配。堆是垃圾收集器管理的主要区域,也被称为“GC堆”,从内存回收的角度来看,堆可以细分为:新生代和老年代;再细致一点可分为:Eden空间、From Survivor空间、To Survivor空间(空间分配比例是8:1:1)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将抛出OutOfMemoryError异常。
5.方法区
方法区(Method Area)与堆一样,也是各个线程共享的区域,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,也就是用来存储类的描述信息—元数据的。方法区是堆的一个逻辑部分,为了区分Java堆,它还有一个别名Non-Heap(非堆)。相对而言,GC对于这个区域的收集是很少出现的。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
在Java 7及之前版本,我们也习惯称它为“永久代”(Permanent Generation),更确切来说,应该是“HotSpot使用永久代实现了方法区”。需要注意的是,从Java 8开始,“永久代”已经被彻底移除,使用了一个元空间(Metaspace)来代替它,后面我们会详细讲解。
6. 运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池( Constant pool table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入运行时常量池中存放。运行时常量池相对于class文件常量池的另外一个特性是具备动态性,java语言并不要求常量一定只有编译器才产生,也就是并非预置入class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中。
在近三个JDK版本(1.6、1.7、1.8)中, 运行时常量池(Runtime Constant Pool)的所处区域一直在不断的变化,在JDK1.6时它是方法区的一部分;1.7又把他放到了堆内存中;1.8之后出现了元空间,它又回到了方法区。其实,这也说明了官方对“永久代”的优化从1.7就已经开始了。
7.直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是JVM规范中定义的内存区域。但这部分内存也被频繁的使用,而且也可能导致OutOfMemoryError异常出现。它在JDK中最直观的表现就是NIO,基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
从GC角度看Java堆
堆和方法区都是线程共享的区域,主要用来存放对象的相关信息。我们知道,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序运行期间才能知道会创建哪些对象,因此, 这部分的内存和回收都是动态的,垃圾收集器所关注的就是这部分内存(本节后续所说的“内存”分配与回收也仅指这部分内存)。而在JDK1.7和1.8对这部分内存的分配也有所不同,下面我们来详细看一下
Java8中堆内存分配如下图:
从Java8开始,HotSpot已经完全将永久代(Permanent Generation)移除,取而代之的是一个新的区域—元空间(MetaSpace),它使用本地内存来存储类元数据信息。也就是说,只要本地内存足够,它不会出现像永久代中“java.lang.OutOfMemoryError: PermGen space”这种错误。同样的,对永久代的设置参数 PermSize 和 MaxPermSize 也会失效。默认情况下,“元空间”的大小可以动态调整,或者使用新参数MaxMetaspaceSize 来限制本地内存分配给类元数据的大小。
元空间特色
- 充分利用了Java语言规范:类及相关的元数据的生命周期与类加载器的一致。
- 每个类加载器都有它的内存区域-元空间
- 只进行线性分配
- 不会单独回收某个类(除了重定义类 RedefineClasses 或类加载失败)
- 没有GC扫描或压缩
- 元空间里的对象不会被转移
- 如果GC发现某个类加载器不再存活,会对整个元空间进行集体回收
GC
- Full GC时,指向元数据指针都不用再扫描,减少了Full GC的时间。
- 很多复杂的元数据扫描的代码(尤其是CMS里面的那些)都删除了。
- 元空间只有少量的指针指向Java堆。这包括:类的元数据中指向java.lang.Class实例的指针;数组类的元数据中,指向java.lang.Class集合的指针。
- 没有元数据压缩的开销
- 减少了GC Root的扫描(不再扫描虚拟机里面的已加载类的目录和其它的内部哈希表)
- G1回收器中,并发标记阶段完成后就可以进行类的卸载
元空间内存分配模型
- 绝大多数的类元数据的空间都从本地内存中分配
- 用来描述类元数据的对象也被移除
- 为元数据分配了多个映射的虚拟内存空间。
- 为每个类加载器分配一个内存块列表。
- 块的大小取决于类加载器的类型
- Java反射的字节码存取器(sun.reflect.DelegatingClassLoader )占用内存更小
- 空闲块内存返还给块内存列表
- 当元空间为空,虚拟内存空间会被回收
- 减少了内存碎片
异常
在JVM规范的描述中,除了程序计数器以外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError的可能。
运行时区域 | 异常 | 主要原因 |
---|---|---|
虚拟机栈和本地方法栈 | StackOverflowError、OutOfMemoryError | StackOverflowError:线程请求的栈深度大于虚拟机所允许的最大深度;OutOfMemoryError:虚拟机在扩展栈时无法申请足够的内存空间 |
程序计数器 | 无 | 无 |
堆 | OutOfMemoryError | 对象数量到达最大堆的容量,内存泄漏、内存溢出 |
方法区和运行时常量池 | OutOfMemoryError | 反射,动态代理:CGLib、JSP、OSGI等 |
- 内存泄露(Memory Leak):程序在申请内存后,对象没有被GC所回收,它始终占用内存,内存泄漏的堆积最终会造成内存溢出。
- 内存溢出(Memory Overflow):程序运行过程中无法申请到足够的内存而导致的一种错误。内存溢出通常发生于OLD段或Perm段垃圾回收后,仍然无内存空间容纳新的Java对象的情况。通常都是由于内存泄露导致堆栈内存不断增大,从而引发内存溢出。