概述
对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对 的delete/free代码,不容易出现内存泄漏和内存溢出问题。正是因为Java程序员把控制内存的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问 题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题将会成为一项异常艰难的工作。
运行时数据区域
根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域,如图所示:
程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时通过改变这个计数器的值来执行下一条字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
在多线程环境下,在任何一确定的时刻,CPU都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
程序计数器内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。
Java虚拟机栈
Java虚拟机栈(Java Virtual Machine Stack)是线程私有的,生命周期与线程相同。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
虚拟机栈中的局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、 float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress 类型(指向了一条字节码指令的地址)。
这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和 double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定 的,在方法运行期间不会改变局部变量表的大小。
在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度(例如递归调用),将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
本地方法栈
本地方法栈则是为虚拟机使用到的本地(Native)方法服务
《Java虚拟机规范》没有对本地方法栈有任何强制规定,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。
Java堆
Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,“几乎”所有对象实例都在这里分配内存。(随着发展,不一定对)
Java堆是垃圾收集器管理的内存区域,被称作“GC堆”(Garbage Collected Heap)
回收内存的角度
由于现代垃圾收集器大部分都是基于分 代收集理论设计的,后续章节分代概念会详细讨论。分配内存的角度
所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区 (Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。
如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载 的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
在JDK 6是使用永久代来实现方法区,到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了 JDK 8,在本地内存中实现的方法区元空间(Meta- space)来代替老年代的方法区,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。
《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
Java虚拟机对于Class文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池, 《Java虚拟机规范》并没有做任何细节的要求。除了保存Class文件中描述的符号引用外,还会将符号引用翻译成直接引用存储在运行时常量池中
运行时常量池具有动态性,在运行期可将新的常量放入池中,例如:String.intern()方法
因为运行时常量池是方法区的一部分,会受到方法区内存限制,当常量池无法再申请内存时会抛出OutOfMemoryError异常。
直接内存
直接内存(Direct Memory)不属于虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。也可能导致OutOfMemoryError异常出现
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区 (Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块内存的引用进行操作。(能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。)
HotSpot虚拟机对象探秘
对象的创建
- 类加载检查阶段
new字节码指令-->检查指令参数,定位常量池类的符号引用和检查符合引用的类是否已被加载、解析和初始化(没有则进行类加载) - 分配内存阶段
在堆中为对象分配内存(加载阶段已确定内存大小)
不同垃圾收集器采取不同的算法:
Serial、ParNew垃圾收集器:利用指针压缩(Compact)技术分配规整的内存空间(Java堆中内存规整,只需将一个指针挪动与对象大小相同的距离:指针碰撞(Bump The Pointer))
CMS:基于清除(Sweep)算法,采用空闲列表(Free List)进行内存分配时的管理
- 内存空间(不包括对象头)赋初始值
采用TLAB:在TLAB分配时顺便初始化
保证Java代码中不赋初始值也能直接访问,只不过访问的是各类型的零值 - 对象进行必要的对象头(Object Header)设置
- 对象是哪个类的实例
- 如何才能找到类的元数据信息
- 对象hashcode(调用Object::hashCode()时才延后计算)
- 对象的GC分代年龄
- Java程序初始化阶段
执行Class文件的<init>
方法,调用对象构造方法进行初始化
对象的内存布局
Java程序会通过栈上的reference数据(对象引用)来操作堆上的具体对象,针对如何定位和访问堆中对象具体位置,有以下主流访问方式:
-
句柄
Java堆中分配句柄池,reference存储对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息
-
直接指针
reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销
两种方式比较:
使用稳定的句柄地址,在对象被移动(特别垃圾回收)时只会改变句柄中实例数据指针,reference无需修改
使用直接指针访问速度快,因为减少一次指针定位的时间开销
实战:OutOfMemoryError异常
在《Java虚拟机规范》的规定里,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(下文称OOM)异常的可能
Java堆溢出
package part2;
/**
* Java堆内存溢出异常测试
*/
/**
* 限制Java堆的大小为20MB,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展),
* 通过参数-XX:+HeapDumpOnOutOf-MemoryError可以让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析
*/
import java.util.ArrayList;
import java.util.List;
/**
* VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {
static class OOMObject{}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}
}
//运行结果
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid24571.hprof ...
Heap dump file created [27812164 bytes in 0.080 secs]
分析:可使用内存映像分析工具mat(Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析。第一步确认导致OOM对象是否是必要的(出现内存泄漏(Memory Leak)还是内存溢出(Memory Overflow))
虚拟机栈和本地方法区溢出
在《Java虚拟机规范》中描述了两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度 , 将抛出StackOverflowError异常(递归)
package part2;
/**
* 虚拟机栈和本地方法栈溢出
*/
/**
* 1、使用-Xss参数减少栈内存容量
* 结果:抛出StackOverflowError异常 ,异常出现时输出的堆栈深度相应缩小
*/
/**
* VM Args:-Xss160k
*/
public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + oom.stackLength);
throw e;
}
}
}
//运行结果
stack length:773
Exception in thread "main" java.lang.StackOverflowError
at part2.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:21)
at part2.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:22)
- 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常
/**
* 虚拟机栈和本地方法栈测试
*/
public class JavaVMStackSOF2 {
private static int stackLength = 0;
public static void test() {
long unused1, unused2, unused3, unused4, unused5,
unused6, unused7, unused8, unused9, unused10, unused11, unused12, unused13, unused14, unused15, unused16, unused17, unused18, unused19, unused20, unused21, unused22, unused23, unused24, unused25, unused26, unused27, unused28, unused29, unused30, unused31, unused32, unused33, unused34, unused35, unused36, unused37, unused38, unused39, unused40, unused41, unused42, unused43, unused44, unused45, unused46, unused47, unused48, unused49, unused50, unused51, unused52, unused53, unused54, unused55, unused56, unused57, unused58, unused59, unused60, unused61, unused62, unused63, unused64, unused65, unused66, unused67, unused68, unused69, unused70, unused71, unused72, unused73, unused74, unused75, unused76, unused77, unused78, unused79, unused80, unused81, unused82, unused83, unused84, unused85, unused86, unused87, unused88, unused89, unused90, unused91, unused92, unused93, unused94, unused95, unused96, unused97, unused98, unused99, unused100;
stackLength++;
test();
unused1 = unused2 = unused3 = unused4 = unused5 = unused6 = unused7 = unused8 = unused9 = unused10 = unused11 = unused12 = unused13 = unused14 = unused15 = unused16 = unused17 = unused18 = unused19 = unused20 = unused21 = unused22 = unused23 = unused24 = unused25 = unused26 = unused27 = unused28 = unused29 = unused30 = unused31 = unused32 = unused33 = unused34 = unused35 = unused36 = unused37 = unused38 = unused39 = unused40 = unused41 = unused42 = unused43 = unused44 = unused45 = unused46 = unused47 = unused48 = unused49 = unused50 = unused51 = unused52 = unused53 = unused54 = unused55 = unused56 = unused57 = unused58 = unused59 = unused60 = unused61 = unused62 = unused63 = unused64 = unused65 = unused66 = unused67 = unused68 = unused69 = unused70 = unused71 = unused72 = unused73 = unused74 = unused75 = unused76 = unused77 = unused78 = unused79 = unused80 = unused81 = unused82 = unused83 = unused84 = unused85 = unused86 = unused87 = unused88 = unused89 = unused90 = unused91 = unused92 = unused93 = unused94 = unused95 = unused96 = unused97 = unused98 = unused99 = unused100 = 0;
}
public static void main(String[] args) {
try {
test();
} catch (Throwable e) {
System.out.println("stack length:" + stackLength);
throw e;
}
}
}
//运行结果
stack length:3600
Exception in thread "main" java.lang.StackOverflowError
at part2.JavaVMStackSOF2.test(JavaVMStackSOF2.java:10)
无论是否支持动态扩展栈内存容量的虚拟机,最大的栈内存都受到操作系统的对每个进程最大的内存限制。因此每个线程分配到的栈内存越大,可以建立的线程数量就越少
方法区和运行时常量池溢出
JDK 6使用永久代实现方法区,设置-XX:PermSize和-XX:MaxPermSize限制永久代的大小
package part2;
import java.util.HashSet;
import java.util.Set;
/**
* VM Args:-XX:PermSize=6M -XX:MaxPermSize=6M
*/
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
// 使用Set保持着常量池引用,避免Full GC回收常量池行为
Set<String> set = new HashSet<>();
// 在short范围内足以让6MB的PermSize产生OOM了
short i = 0;
while (true) {
set.add(String.valueOf(i++).intern());
}
}
}
//运行结果
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
at java.lang.String.intern(Native Method)
at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java: 18)
JDK 7后逐渐使用元空间来实现方法区,设置-XX:MaxMeta-spaceSize参数把方法区容量同样限制在6MB,也都不会重现JDK 6中的溢出异常,循环将一直进行下去,永不停歇。出现这种变化,是因为自JDK 7起,原本存放在永久代的字符串常量池被移至Java堆之中,所以在JDK 7及以上版本,限制方法区的容量对该测试用例来说是毫无意义的。这时候使用-Xmx参数限制最大堆到6MB就能够看到以下两种运行结果之一,具体取决于哪里的对象分配时产生了溢出:
package part2;
import java.util.HashSet;
import java.util.Set;
/**
* VM Args:-Xmx6m
*/
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
// 使用Set保持着常量池引用,避免Full GC回收常量池行为
Set<String> set = new HashSet<>();
// 在short范围内足以让6MB的PermSize产生OOM了
short i = 0;
while (true) {
set.add(String.valueOf(i++).intern());
}
}
}
//输出结果
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.HashMap.resize(HashMap.java:704)
at java.util.HashMap.putVal(HashMap.java:663)
- String.intern()不同版本体现
//jdk 6输出false false
//jdk 7以上输出false true
package part2;
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern() == str1);
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);
}
}
- 在JDK 6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串对象实例在 Java堆上,所以必然不可能是同一个引用
- JDK 7及以上,intern()方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引用即可 , 因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。
解析:“java”是在加载sun.misc.Version这个类的时候进入常量池的。所以JDK 8中的第二个是true
- 使用CGLib直接操作字节码运行时生成了大量的动态类。
当前的很多主流框架,如Spring、Hibernate对类进行增强时,都会使用到 CGLib这类字节码技术,当增强的类越多,就需要越大的方法区以保证动态生成的新类型可以载入内存。
package part2;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
/**
* 借助CGLib使得方法区出现内存溢出异常
* VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
*/
public class JavaMethodAreaOOM {
public static void main(String[] args) {
try {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
return proxy.invokeSuper(obj, args);
}
});
enhancer.create();
}
} catch (Exception e) {
e.printStackTrace();
}
}
static class OOMObject {
}
}
//JDK 7中运行结果
Caused by: java.lang.OutOfMemoryError: PermGen space
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632) at java.lang.ClassLoader.defineClass(ClassLoader.java:616)
... 8 more
在JDK 8以后,永久代的方法区实现被元空间替代,默认设置下,前面的测试用例很难使方法区溢出异常,不过为了让使用者有预防实际应用里出现类似于代码那样的破坏性的操作,HotSpot还是提供了一 些参数作为元空间的防御措施,主要包括:
- -XX:MaxMetaspaceSize
设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。 - -XX:MetaspaceSize
指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值 - -XX:MinMetaspaceFreeRatio
作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX:Max-MetaspaceFreeRatio,用于控制最 大的元空间剩余容量的百分比。
本机直接内存溢出
直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致
package part2;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* 使用unsafe分配本机内存
* 通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是Unsafe::allocateMemory ()。
* VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
*/
public class DirectMemoryOOMDirect {
private static final int _100MB = 1024 * 1024 * 100;
public static void main(String[] args) throws IllegalAccessException {
List<ByteBuffer> list = new ArrayList<>();
int i = 0;
while (true) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100MB);
list.add(byteBuffer);
i++;
System.out.println(i);
}
}
}
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
at java.nio.Bits.reserveMemory(Bits.java:694)
源自书籍:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)-周志明