第一次读这本书时,就被文中的一句话所折服:
“Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的‘高墙’,墙外面的人想进去,墙里面的人却想出来。”
的确,对于使用 C++ 编程的程序员来说,他们肩负着对每一个对象所占内存空间的维护责任;而对于 Java 程序员来说,动态分配内存机制让我们无需对申请的内存进行 free,不容易出现内存的泄露和溢出。但是如果不了解虚拟机内部内存的划分,或是虚拟机如何与操作系统的内存管理进行合作,那么当我们真正面对由于内存引发的错误时,我们将无从下手。
本系列博客希望能够总结下 JVM 虚拟机相关的知识,和大家一起分享。
1.Java SE 7 虚拟机运行时数据区域
Java 虚拟机在执行 Java 程序时会将它管理的内存划分位几个不同的区域,让我们先来看一下 Java SE 7 版本的运行时数据区域,如下图:
需要注意的是上面的内存区域都是虚拟机规范中对虚拟机内存区域的逻辑划分,具体各个虚拟机都可能有不同的实现。
- 程序计数器
程序计数器(Program Counter Register) 可以被看作是当前线程所执行的字节码的行号指示器。由于在任一时刻,一个处理器(或一个内核)只能执行一条线程中的指令,因此每个线程都需要一个独立的程序计数器来保证 CPU 切换线程后能恢复到之前的执行位置。此内存区域是唯一一个在虚拟机规范中没有规定任何 OOM 情况的区域。
- Java 虚拟机栈
与程序计数器相同,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈主要是用来描述 Java 方法执行的内存模型:每个方法执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表,操作数栈,动态链接和方法出口等信息。一个方法从调用到执行完成,就是该方法所对应的栈帧从入栈到出栈的过程。
在栈帧中存放的局部变量表存放了各种编译期可知的基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用(reference)和 returnAddress 类型(指向了一条字节码指令的地址)。
当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
在这个区域内,如果线程所请求的栈深度大于虚拟机所允许的栈的深度,将抛出 StackOverflowError;如果虚拟机栈允许自动扩展,但却无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
- 本地方法栈
本地方法栈与 Java 虚拟机栈十分相似,不过虚拟机栈执行的是 Java 的方法(字节码)服务,而本地方法栈执行的是虚拟机需要的 Native 的方法。
- Java 堆
Java 堆是整个运行时数据区域中最大的一块的内存区域,它被所有线程共享,在虚拟机启动时创建。这个内存区域唯一的目的就是存放对象实例,我们在程序中 new 出的对象大多都存储在这个区域。
从 JavaGC 的角度来看,Java 堆是发生垃圾收集的主要区域,它也可以被细分为新生代和老年代;在细分还有 Eden 空间,From Survivor 空间, To Survivor 空间等,如下图所示:
具体的垃圾收集相关知识,将会在之后的博客中详细介绍,大家目前不用太担心它。
如果在 Java 堆中没有足够的内存来完成实例的分配,并且此时堆也无法自动扩展时,虚拟机将会抛出 OutOfMemoryError 异常。
- 方法区
方法区与 Java 堆一样,是所有线程共享的内存区域,它用来存储已经被虚拟机加载的类信息,常量,静态变量等数据。对于使用 HotSpot 虚拟机的开发者而言,大家也将方法区称为“永久代(Permanent Generation)”,因为 HotSpot 虚拟机的设计者将 GC 分代收集扩展到了方法区,换句话说,他们用永久代去实现了方法区,这样 HotSpot 的 GC 就可以像管理 Java 堆一样管理方法区的内存了。但是在 Java SE 8 之后,虚拟机已经完成了“去永久代”,这个我们稍后也会讲到。
在方法区中有一块区域叫做运行时常量池(Runtime Constant Pool)。Class 文件中除了有类的版本,字段,方法,接口等描述信息之外,还有一项是常量池(Constant Pool Table),用于存放编译期生成的更重字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。但从 JDK1.7 开始已经在逐渐去除永久代了,存储在永久代的部分数据就已经转移到了 Java Heap 或者是 Native Heap。譬如符号引用(Symbols)转移到了 native heap;字面量(interned strings)转移到了 java heap;类的静态变量(class statics)转移到了 java heap,但永久代仍存在于 JDK1.7 中,并没完全移除。
当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
- 直接内存
严格来讲,直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,甚至不是 Java 虚拟机规范中定义的内存区域,它本质上就是运行 Java 虚拟机的进程被分配的本机内存,在之前所提到的 Java 虚拟机栈或堆需要自动扩展时,实际上就会向直接内存申请空间,因此如果各个内存区域的总和大于了本机内存的限制,就会发生 OutOfMemoryError 异常。
2.Java SE 8 虚拟机“去永久代”
从 Java SE 7 就已经在推动“去永久代”了,到了 Java SE 8,虚拟机的设计团队终于彻底将永久代废除了,如下图:
从图中我们可以看到,永久代已经被替换为了在本地内存存储的 Metaspace 元空间。
在上节中我们提到,Java SE 7 使用永久代去实现方法区,但是使用永久代去实现方法区的一个问题就是,如果永久代的内存不够(比如存放了大量的类信息)则会抛出 java.lang.OutOfMemoryError: PermGen 异常。而元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存,理论上只受限于操作系统的虚拟内存大小。
我们可以通过参数来配置元空间的大小:
1.MetaspaceSize
初始化的 Metaspace 大小,控制元空间发生 GC 的阈值。GC 后,动态增加或降低 MetaspaceSize。在默认情况下,这个值大小根据不同的平台在 12M 到 20M 浮动。使用 Java -XX:+PrintFlagsInitial 命令查看本机的初始化参数
2.MaxMetaspaceSize
限制 Metaspace 增长的上限,防止因为某些情况导致 Metaspace 无限的使用本地内存,影响到其他程序。在本机上该参数的默认值为 4294967295B(大约 4096MB)。
3.MinMetaspaceFreeRatio
当进行过 Metaspace GC 之后,会计算当前 Metaspace 的空闲空间比,如果空闲比小于这个参数(即实际非空闲占比过大,内存不够用),那么虚拟机将增长 Metaspace 的大小。默认值为 40,也就是 40%。设置该参数可以控制 Metaspace 的增长的速度,太小的值会导致 Metaspace 增长的缓慢,Metaspace 的使用逐渐趋于饱和,可能会影响之后类的加载。而太大的值会导致 Metaspace 增长的过快,浪费内存。
4.MaxMetasaceFreeRatio
当进行过 Metaspace GC 之后, 会计算当前 Metaspace 的空闲空间比,如果空闲比大于这个参数,那么虚拟机会释放 Metaspace 的部分空间。默认值为 70,也就是 70%。
5.MaxMetaspaceExpansion
Metaspace 增长时的最大幅度。在本机上该参数的默认值为 5452592B(大约为 5MB)。
6.MinMetaspaceExpansion
Metaspace 增长时的最小幅度。在本机上该参数的默认值为 340784B(大约 330KB 为)。
3. 内存溢出实例
本节中博主和大家一起写一些例子来测试如何能使 Java 虚拟机内存溢出。
- 3.1 Java 堆溢出
Java 堆是用来为新建对象分配内存空间的地方,因此只要不断新建对象,同时保持 GC Roots 到对象之间的可达路径(防止该对象被垃圾回收),很快就会达到 Java 堆的最大容量限制。
public class HeapOOM{
static class OOMObject{
}
public static void main(String[] args){
List<OOMObject> list = new ArrayList<OOMObject>();
while(true){
list.add(new OOMObject());
}
}
}
我们在运行这个 Java 程序时,限制以下 Java 虚拟机堆的大小,并让虚拟机在出现内存时 Dump 出当前内存的堆存储快照以便事后分析:
java -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError HeapOOM
运行结果为下图:
我们看到 OutOfMemoryError 异常之后进一步提示出了“Java heap space”区域发生的异常
- 3.2 虚拟机栈溢出
在 Hotspot 虚拟机中并不区分虚拟机栈和本地方法栈,我们只需要通过设置 -Xss 参数设置虚拟机栈即可。
由于虚拟机栈是用来描述方法调用的内存模型,每次方法调用都会向虚拟机栈中插入一个栈帧,因此只要写一个没有退出条件的递归函数,即可将栈空间占满。
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;
}
}
}
我们在运行这个程序的时候,限制虚拟机栈的大小:
java -Xss256K JavaVMStackSOF
运行结果为下图:
- 3.3 方法区溢出
方法区用于存放 Class 相关信息,如类名方法描述符等,因此我们只需要生成大量的类去填满方法区即可。下面的代码使用 CGLib 来在运行时生成大量的动态类:
public class JavaMethodAreaOOM {
public static void main(String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOM.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object obj, Method arg1, Object[] args, MethodProxy proxy) throws Throwable {
return proxy.invokeSuper(obj, args);
}
});
OOM oom = (OOM) enhancer.create();
}
}
static class OOM {
}
}
或者使用 Jdk 自带的动态代理来生成大量的类去堆满方法区(有想要了解代理模式和动态代理源码的同学,可以直接看博主这两篇文章):
public interface HelloService {
void sayHello();
}
public class HelloServiceImpl implements HelloService {
@Override
public void sayHello() {
// TODO Auto-generated method stub
System.out.println("dasd");
}
}
public class MyInvocationHandler implements InvocationHandler {
private Object target;
public MyInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// TODO Auto-generated method stub
return method.invoke(target, args);
}
}
public class Test {
private static Map<String, HelloService> classLeakingMap = new HashMap<String, HelloService>();
public static void main(String[] args) throws MalformedURLException {
// TODO Auto-generated method stub
for (int i = 0; i < 5000; i++) {
String className = "file:" + i + ".class";
URL[] url = new URL[] { new URL(className) };
URLClassLoader loader = new URLClassLoader(url);
HelloService t = (HelloService) Proxy.newProxyInstance(loader,
new Class<?>[] { HelloService.class },
new MyInvocationHandler(new HelloServiceImpl()));
classLeakingMap.put(className, t);
}
}
}
我们在运行这两个程序的时候,限制永久代的大小
java -XX:PermSize=5M -XX:MaxPermSize=5M JavaMethodAreaOOM
楼主在测试方法区溢出的例子上浪费了很长的时间,由于 jdk1.7 的方法区仍然是由永久代(PermGen Space)实现的,因此我们写的方法区溢出的实例理论上应该出现下图的错误:
但无论博主如何尝试,在不同 jdk1.7 版本都试过....,都没有办法复现这个错误,如果有哪位同学能够成功在 jdk1.7 环境下复现上图的错误烦请留言告知博主一声,在此先行谢过了。下图是我自己的 demo 爆出的错误:
正如我们之前所说到的,jdk8 之后已经完全去永久代了,取而代之的是存储在直接内存上的 Metaspace,因此将上面的测试程序使用 jdk1.8 进行测试的话,显示的将会是 MetaspaceOOM 了:
在 jdk1.8 之后,虚拟机参数已经将 PermSize 和 MaxPermSize 去除掉了,如果继续在 1.8 上使用这两个参数会提示错误。取而代之的是 MetaspaceSize 和 MaxMetaspaceSize 两个参数:
java -XX:MetaspaceSize=8m -XX:MaxMetaspaceSize=8m Test
4. 总结
在本篇博客中,博主和大家一起学习了虚拟机中的运行时内存是如何划分的,以及每部分区域的作用。博主同时用一些 Java 代码模拟出了三个内存区域发生溢出的情形。但是上面的模拟还是很简单的 demo 模拟,尽管 Java 拥有 GC 机制,但是在实际编程中遇到内存溢出还是很常见的。希望同学们能记住这些内存区域的特点,当在某个区域发生内存溢出时,能够更好地定位问题。本篇博客到这里就结束了,下次见~。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于