jtlgb

导航

 

运行时数据区域

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 合二为一。


相关文章:
Java永久代去哪了:http://www.infoq.com/cn/articles/Java-PERMGEN-Removed?utm_campaign=infoq_content&

JVM内部原理:http://ifeve.com/jvm-internals/

了解String intern():http://blog.csdn.net/seu_calvin/article/details/52291082

posted on 2019-05-17 15:20  jtlgb  阅读(327)  评论(0编辑  收藏  举报