Java内存区域与内存溢出异常(jdk 6,7,8)
运行时数据区域Java虚拟机在执行Java程序的过程中会把它关联的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范(Java SE 7版)》的规定,Java虚拟机所管理的内存将会包括以下几个运行时的数据区域。如图所示:1.1程
·
运行时数据区域
Java虚拟机在执行Java程序的过程中会把它关联的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范(Java SE 7版)》的规定,Java虚拟机所管理的内存将会包括以下几个运行时的数据区域。如图所示:
1.1程序计数器
程序计数器是一块较小的内存空间,它可以看成是当前线程所执行的字节码的行号指示器。程序计数器记录线程当前要执行的下一条字节码指令的地址。由于Java是多线程的,所以为了多线程之间的切换与恢复,每一个线程都需要单独的程序计数器,各线程之间互不影响。这类内存区域被称为“线程私有”的内存区域。 由于程序计数器只存储一个字节码指令地址,故此内存区域没有规定任何OutOfMemoryError情况。
1.2虚拟机栈
Java虚拟机栈也是
线程私有
的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储信息如下:
- 局部变量表
- 返回值
- 操作数栈
- 当前方法所在的类的运行时常量池引用
每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。我们平时所说的“局部变量存储在栈中”就是指方法中的局部变量存储在代表该方法的栈帧的局部变量表中。而方法的执行正是从局部变量表中获取数据,放至操作数栈上,然后在操作数栈上进行运算,再将运算结果放入局部变量表中,最后将操作数栈顶的数据返回给方法的调用者的过程。
虚拟机栈可能出现两种异常:
由线程请求的栈深度过大超出虚拟机所允许的深度而引起的StackOverflowError异常;
以及由虚拟机栈无法提供足够的内存而引起的OutOfMemoryError异常。
局部变量存放数据如下:
- boolean
- byte
- char
- long
- short
- int
- float
- double
- reference(对象引用)
- returnAddress(指向了一条字节码指令的地址)
在局部变量表里,除了long和double,所有类型都是占了一个槽位,它们占了2个连续槽位,因为他们是64位宽度。
1.3本地方法栈
本地方法栈与虚拟机栈类似,他们的区别在于:本地方法栈用于执行本地方法(Native方法);虚拟机栈用于执行普通的Java方法。在HotSpot虚拟机中,就将本地方法栈与虚拟机栈做在了一起。 本地方法栈可能抛出的异常同虚拟机栈一样。
1.4Java堆
Java堆是被所有
线程共享
的一块内存区域,此内存区域的唯一目的就是存放对象实例。
为了支持垃圾收集,堆被分为三个部分:
- 年轻代
- 常常又被划分为Eden区和Survivor(From Survivor To Survivor)区
- 老年代
- 永久代 (jdk 8已移除永久代,后续会详细讲解)
1.5方法区
方法区与Java堆一样,是各个
线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、及时编译器编译后的代码等数据。这也是开发者常说的永久代。具体存放信息如下:
- 类加载器引用
- 运行时常量池
- 所有常量
- 字段引用
- 方法引用
- 属性
- 字段数据
- 每个方法
- 名字
- 类型
- 修饰符
- 属性
- 方法数据
- 每个方法
- 名字
- 返回类型
- 参数类型(按顺序)
- 修饰符
- 属性
- 方法代码
- 每个方法
- 字节码
- 操作数栈大小
- 局部变量大小
- 局部变量表
- 异常表
- 每个异常处理
- 开始位置
- 结束位置
- 代码处理在程序计数器中的偏移地址
- 被捕获的异常类的常量池索引
1.6直接内存
JDK1.4中引用了NIO,并引用了Channel与Buffer,可以使用Native函数库直接分配堆外内存,并通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。
Java8以及之后的版本中方法区已经从原来的JVM运行时数据区中被开辟到了一个称作元空间的直接内存区域。
JDK 6,7,8 方法区的区别
在Java7之前,HotSpot虚拟机中将GC分代收集扩展到了方法区,使用永久代来实现了方法区。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,但是在之后的HotSpot虚拟机实现中,逐渐开始将方法区从永久代移除。Java7中已经将运行时常量池从永久代移除,在Java堆(Heap)中开辟了一块区域存放运行时常量池。而在Java8中,已经彻底没有了永久代,将方法区直接放在一个与堆不相连的本地内存区域,这个区域叫元空间。
实战OutOfMemoryError异常
2.1Java堆溢出
Java堆用来存储对象实例,只要不断的创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象到达最大堆的容量限制后就会产生内存溢出异常。
java.lang.OutOfMemoryError:Java heap space 示例:
设置
-Xms20m -Xmx20m
package com.tlk.jvm;
import java.util.ArrayList;
import java.util.List;
/**
* 一直创建对象,堆内存溢出
* VM Args: -Xms20m -Xmx20m
* Created by tanlk on 2017/8/30 21:16.
*/
public class HeapOOM {
static class OOMObject{
}
public static void main(String[] args){
List<Object> list = new ArrayList<Object>();
while (true){
list.add(new OOMObject());
}
}
}
2.2虚拟机栈和本地方法栈溢出
HotSpot 虚拟机中并不区分虚拟机栈和本地方法栈。栈容量只由-Xss参数设定。
如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
如果虚拟机在扩展栈时,无法申请到足够的内存空间,则将抛出OutOfMemoryError异常。
(本地测试了一个OOM,但没有出现)
package com.tlk.jvm;
/**
* 栈溢出
* VM Args:-Xss128k
*/
public class StackErrorMock {
private static int index = 1;
public void call(){
index++;
call();
}
public static void main(String[] args) {
StackErrorMock mock = new StackErrorMock();
try {
mock.call();
}catch (Throwable e){
System.out.println("Stack deep : "+index);
e.printStackTrace();
}
}
}
当栈调用深度大于JVM所允许的范围,会抛出StackOverflowError的错误,不过这个深度范围不是一个恒定的值
2.3方法区和运行时常量池溢出
package com.tlk.jvm;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
/**
* jdk 1.6
* VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
* Created by tanlk on 2017/9/1 15:48.
*/
public class JavaMethodAreaOOM {
public static void main(String[] args) {
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();
}
}
static class OOMObject{}
}
其实,移除永久代的工作从JDK1.7就开始了。JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。我们可以通过一段程序来比较 JDK 1.6 与 JDK 1.7及 JDK 1.8 的区别,以字符串常量为例:
这段程序以2的指数级不断的生成新的字符串,这样可以比较快速的消耗内存。我们通过 JDK 1.6、JDK 1.7 和 JDK 1.8 分别运行:
package com.tlk.jvm;
import java.util.ArrayList;
import java.util.List;
/**
*
* VM Args: -Xmx200M -XX:PermSize=10M -XX:MaxPermSize=10M
* Created by tanlk on 2017/9/1 21:48.
*/
public class StringOomMock {
static String base = "string";
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
for (int i=0;i< Integer.MAX_VALUE;i++){
String str = base + base;
base = str;
list.add(str.intern());
}
}
}
JDK1.6运行结果:
JDK1.7运行结果:
JDK1.8运行结果:
从上述结果可以看出,JDK 1.6下,会出现“PermGen Space”的内存溢出,而在 JDK 1.7和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。
另外JDK8 设置了-XX:MaxMetaspaceSize=10m,结果也是报Java heap space
并且笔者用jconsole监控发现堆内存使用非常大。
因此,可以大致验证 JDK 1.7 和 1.8 将字符串常量由
永久代转移到堆中
,并且 JDK 1.8 中已经不存在永久代的结论。
上诉测试代码简单解释:
运行时常量池在
JDK1.6及之前版本的JVM中是方法区的一部分
,而在HotSpot虚拟机中方法区放在了”永久代(Permanent Generation)”。所以运行时常量池也是在永久代的。
但是
JDK1.7及之后版本的JVM已经将运行时常量池从方法区中移了出来,在Java 堆(Heap)中开辟了一块区域存放运行时常量池
。
String.intern()是一个Native方法,它的作用是:如果运行时常量池中已经包含一个等于此String对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此String内容相同的字符串,并返回常量池中创建的字符串的引用。
JDK1.7改变
当常量池中没有该字符串时,JDK7的intern()方法的实现不再是在常量池中创建与此String内容相同的字符串,而改为
在常量池中记录
java
Heap中首次出现的该字符串的引用,并返回该引用
。
Metaspace(元空间)
3.1元空间是什么?
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是
使用本地内存
。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:
-XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
-XX:MaxMetaspaceSize,最大空间,默认是没有限制的。
除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:
-XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
-XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集
3.2元空间内存溢出的例子
package com.tlk.jvm;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
/**
* -XX:MaxMetaspaceSize=10m
* jdk1.8没有永久代了,取而代之的是Metaspace
* Created by tanlk on 2017/9/4 20:48.
*/
public class JavaMethodAreaOOM {
public static void main(String[] args) {
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();
}
}
static class OOMObject{}
}
java.lang.OutOfMemoryError: Metaspace
3.3元空间总结:
通过上面分析,大家应该大致了解了 JVM 的内存划分,也清楚了 JDK 8 中永久代向元空间的转换。不过大家应该都有一个疑问,就是为什么要做这个转换?所以,最后给大家总结以下几点原因:
1、字符串存在永久代中,容易出现性能问题和内存溢出。
2、类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
3、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
4、Oracle 可能会将HotSpot 与 JRockit 合二为一。
相关文章:
JVM内部原理:
http://ifeve.com/jvm-internals/
了解String intern():
http://blog.csdn.net/seu_calvin/article/details/52291082
更多推荐
已为社区贡献1条内容
所有评论(0)