运行时数据区域


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/

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐