JDK1.8-Java虚拟机运行时数据区域和HotSpot虚拟机的内存模型

介绍

  • 初学Java虚拟机几天, 被方法区, 永久代这些混杂的概念搞混了. 我觉得学习这部分知识应该把官方定义的虚拟机运行时数据区域和虚拟机内存结构分开叙述, 要不然容易误导.
  • 本文先介绍官方文档规定的运行时数据区域, 然后以JDK1.8的HotSpot虚拟机为例, 介绍虚拟机的内存结构.

官方文档规定的运行时数据区域

  • 官方文档中规定的运行时数据区一共就几块: PC计数器, 虚拟机栈, 本地方法栈, 堆区, 方法区, 运行时常量池. 这里的官方规定是说, 如果你要做一个Java虚拟机的话, 必须要包含这几个区域, 但是这几个区域在你的虚拟机中是用哪块内存实现的, 这由虚拟机制作者决定.

程序计数器

  • The pc Register, 程序计数器. 如果了解过计算机系统, 对这个名词应该不陌生了, 它指向下一条指令的地址, 程序靠它跑起来.
  • Java虚拟机支持多线程, 每条线程都有自己的程序计数器.
  • 如果当前线程正在执行一个Java方法, 它的计数器记录的是正在执行的Java虚拟机指令的地址. 如果执行的是本地方法(比如系统的C语言函数), 计数器中的值为空(Undefined).
  • 正因为程序计数器记录的是指令地址, 所以它占用的空间较少, Java虚拟机规范中并没有规定这块内存有OutOfMemoryError(内存溢出)的情况.

 

Java虚拟机栈

  • Java Virtual Machine Stacks, Java虚拟机栈.
  • Java虚拟机栈是线程私有的, 生命周期与线程相同. 虚拟机栈存放栈帧, 栈帧用于存储局部变量表, 部分结果值, 方法的初始化参数和返回信息, 方法的执行通过栈帧的压栈和出栈实现.

本地方法栈

  • 本地方法栈和上面的虚拟机栈是相似的, 从名字也看出, 虚拟机方法栈是用来执行Java代码的, 而本地方法栈则是用来执行本地系统代码的, 比如C代码.
  • 也因为规范中没有规定本地方法栈执行的代码, 如果想执行Java代码也是可以的, 我们可以看到Oracle官方的虚拟机HotSpot虚拟机把Java虚拟机栈和本地方法栈合二为一, 这么做避免了要为不同的语言设计栈, 提高了虚拟机的性能.

虚拟机栈和本地方法栈溢出

  • 那么当出现错误信息后, 我们在什么错误信息下可以去排查是否虚拟机栈和本地方法栈这两块内存出错呢? 这里以HotSpot虚拟机为例讲解(HotSpot把两块栈结构合在一起实现了), 在JDK1.8的虚拟机规范中对这两块栈空间可能出现的错误给出了相同的描述.
  • 一: 如果一条线程所需要的内存大于虚拟机所分配给它的内存, 将抛出StackOverflowError异常.
  • 二: 如果栈内存可以扩展并尝试扩展时可用的内存不足, 或者创建新线程并为其分配栈内存时可能的内存不足, 会抛出OutOfMemoryError
  • 下面先演示第一个StackOverflowError异常
//设置虚拟机参数 -Xss128k, 设置单个线程的栈空间大小为128k
public class StackErrorTest1 {
    private int stackLength = 1;

    public void stackLeak(){
         stackLength++;
         stackLeak();
    }

    public static void main(String[] args) {
        StackErrorTest1 set1 = new StackErrorTest1();
        try{
            set1.stackLeak();
        }catch (Throwable e){
            System.out.println("stack length:" + set1.stackLength);
            e.printStackTrace();
        }
    }
}
//输出异常信息
stack length:1000
java.lang.StackOverflowError
	at jvm.StackErrorTest1.stackLeak(StackErrorTest1.java:7)
	at jvm.StackErrorTest1.stackLeak(StackErrorTest1.java:8)
    ...
  • 所以当遇到StackOverflowError时可以考虑是否是是虚拟机的栈容量太小, 比如这里的无穷递归, 栈空间不够用. 当然生产环境中肯定不会写无穷递归, 这时可以通过设置-Xss参数调整单条线程的栈内存大小.
  • 上面描述的栈内存可以扩展并尝试扩展时可用的内存不足导致出现OutOfMemoryError的情况暂时没有好的演示代码, 在周志明的《深入理解Java虚拟机》中提到"定义了大量本地变量,增大方法帧中本地变量表的长度, 结果仍抛出StackOverflowError". 不知道是不是没有触发虚拟机动态扩充栈空间, 所以仍然判定是栈所需的空间超出了虚拟机规定的大小. 总结来说无论是栈帧太大还是栈空间太小都会抛出StackOverflowError, 可以考虑调整-Xss参数.
  • 上面还提到当创建新线程并分配新的栈空间时, 如果可用的内存不够, 会抛出OutOfMemoryError异常, 下面是这种情况的代码演示.
public class StackErrorTest2 {

    private void keepRunning(){
        while(true){
        }
    }

    public void stackLeakByThread(){
        while(true){
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    keepRunning();
                }
            });
            thread.start();
        }
    }

    public static void main(String[] args){
        StackErrorTest2 set2 = new StackErrorTest2();
        set2.stackLeakByThread();
    }
}
//运行结果, 来源《深入理解Java虚拟机》
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
  • 这段代码也来自深入理解jvm, 书中也说明跑这段代码要小心, 因为Java的线程是映射到内核线程上的, 果不其然我的机子一跑就死机了.
  • 问什么会出现这样的错误? 32位Windows系统分配给一个进程的内存最大为2GB(32位能寻址4GB地址空间, 除去内核的空间剩2GB, 64位则大得多). 这2GB减去最大堆容量, 减去方法区的容量, 剩下的就是虚拟机栈和本地方法区栈的内存空间了. (补充: PC计数器占的空间很小, 运行时常量池在方法区中, HotSpot中虚拟机栈和本地方法栈一起实现, 所以能分成这么三大块内存).
  • 了解了三大块内存区后(HotSpot下), 解决思路也出来了: 1. 减小最大堆内存, 腾出更多位置给栈空间. 2. 如果程序的线程数量不可以减少, 那么就看看是否可以减少每条线程的栈内存.
  • 当然用一台配置高的机器, 该用64位的Java虚拟机也是一种方法.

Java堆

  • Java堆是随着虚拟机的启动而创建的, 用于存放对象实例, 所有的对象实例和数组都在堆内存分配, 它被所有线程共享. Java堆是Java虚拟机管理的内存中最大的一块, 也是垃圾回收器管理的主要区域. 从内存回收的角度看, Java堆内存还可以被继续划分, 并且和具体的虚拟机实现有关.
  • 当前主流的虚拟机都是支持堆内存动态扩展的, 就是说当堆内存的大不够时, 它会扩充容量; 当不要太多的空间时, 它能自己进行压缩. 我们可以人为地通过-Xmx和-Xms设定堆内存的最大值和最小值(初始大小). 如果我们把-Xmx和-Xms设置为相同的值, 就等同于设定了固定大小的Java堆. (这是gc调优的一种手段)
  • 若堆内存分配内存时发现已经没有更过可用空间时, 会抛出OutOfMemoryError.

演示堆内存溢出

  • 堆内存是存放对象实例的地方, 这个应该比较好理解, 直接上代码
/**
 * VM Args: -Xms20m -Xmx20m
 */
public class HeapErrorTest {
    static class Object{
    }

    public static void main(String[] args) {
        List<Object> list = new ArrayList<>();
        while(true){
            list.add(new Object());
        }
    }
}
//运行结果
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3210)
	at java.util.Arrays.copyOf(Arrays.java:3181)
  • 由结果可以看到当堆内存溢出后除了有java.lang.OutOfMemoryError外, 还会提示Java heap space. 在这个例子中, 我们明确地知道了是由于堆内存不够大而造成的溢出. 然而在生产环境中, 当系统报出堆内存溢出时, 我们首先要搞清楚是因为内存泄漏导致的内存溢出, 还是纯粹的内存溢出.
  • 内存溢出指的是分配内存的时候, 没有足够的空间供其使用. 内存泄漏指的是在分配一块内存使用完后没有释放, 在Java中对应的场景是没有被垃圾回收器回收. 一点点的内存泄漏用户可能感受不到, 但是当泄漏的内存积少成多的时候, 会耗尽内存, 导致内存溢出.
  • 有一些常用的分析内存溢出的手段和工具, 这里就不详细叙述了, 可以参考书籍或网上的资料. 当我们判断是内存泄漏导致的溢出后, 可以根据工具定位出现泄漏的代码位置; 如果不存在泄漏只是单纯的溢出的话, 可以通过设置虚拟参数调整堆内存大小(前提是机器的配置能够支持相应的内存大小), 或者看看代码中是否存在一些生命周期很长的对象实例, 看看能否作出修改.

方法区

  • 方法区用于存储以被虚拟机加载的类信息, 常量, 静态变量, 即时编译器编译后的代码数据等, 它是所有线程共享的. 虚拟机规范中说方法区在逻辑上是堆的一部分, 但是它的别名叫"non-Heap"也就是非堆的意思, 表明它和堆内存是两块独立的内存. 至于说在逻辑上是堆区的一部分, 是因为在物理实现上, 方法区的内存地址包含于堆中, 所以说是逻辑上的一部分, 实际用的时候是完全不同的部分. 这么设计可能是因为便于垃圾收集器统一管理吧.

运行时常量池

  • 运行时常量池的内存由方法区分配, 也就是说它属于方法区的一部分. 它用于存储Class文件中的类版本, 字段, 方法, 接口和常量池等, 也用于存放编译期生成的各种字面量和符号引用.
  • 运行时常量池区别于Class文件常量池的一个重要特征是具备动态特性. 也就说并非在Class文件中定义的常量才能进入运行时常量池, 在程序运行的过程中也有可能将新的常量放入池中.

演示方法区溢出

  • 演示方法区溢出和堆区的思路一样, 不断往方法堆中加入东西使其溢出. 只是方法区中保存的是类信息, 我们通过不断动态生成类演示
  • 本代码示例来源于深入理解jvm, 但是其中的参数需要改变, 该书的最新版本是基于JDK1.7的, JDK1.7中方法区是在永久代中实现的, 而JDK1.8中已经没有永久代了, 方法区中Metaspace元数据区中, 通过设置-XX:MetaspaceSize-XX:MaxMetaspaceSize来指定方法区的大小
/**
 * VM Args: -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
 */
public class MethodAreaTest {

    static class Object{
    }

    public static void main(String[] args) {
        int count = 0;
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(Object.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public java.lang.Object intercept(java.lang.Object o, Method method, java.lang.Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(objects, objects);
                }
            });
            enhancer.create();
            System.out.println(++count);
        }
    }
}

运行结果:
Caused by: java.lang.OutOfMemoryError: Metaspace
	at java.lang.ClassLoader.defineClass1(Native Method)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
	... 8 more

 

HotSpot虚拟机的内存模型

  • 在介绍完Java虚拟机运行时数据区域后, 接着以HotSpot虚拟机为例介绍虚拟机内存模型.
  • 首先有一个重要的概念要搞清楚, 要不然容易犯晕.
  • 在前面介绍Java运行时数据区域时我们谈到PC计数器, 虚拟机栈, 本地方法栈这3块内存都是线程私有的, 它们的随线程的创建而分配, 随线程的结束而释放, 也就是说Java虚拟机是明确知道这三块内存是什么时候该被回收的, 只要线程没执行完就不能回收, 否则线程跑不起来.
  • 而我们在谈论虚拟机的内存模型时, 通常要和垃圾回收结合在一起讨论. 既然上面的三块内存回收的时间已定, 暂时不需要过多考虑, 虚拟机分配内存时给它们留有空间就行.
  • 但另外的两块内存堆内存和方法区则不一样, 它们是所有线程共享的, 在这里面内存的分配和释放具有不确定性. 比如说在多态的情况下, 一个接口对应的实现类不同, 具体的实现方法也不同, 虚拟机只有在程序运行的过程中才知道要创建哪些对象, 这部分内存的分配和释放都是动态的, 垃圾收集器关注的也是这部分的内容.
  • 所以说我们后续描述的虚拟机内存模型是建立在Java堆内存和方法区上的.

JVM实现的堆内存和方法区

  • 正如上述所说, 当谈论JVM的内存结构时, 讨论的重点就由整个运行时数据区域转为对堆内存和方法区的讨论, 因为这两部分是垃圾回收的重点区域(如果两者要比较的话, 重点收集区域是堆区).
  • 而HotSpot虚拟机的内存结构由三大部分组成: 新生代, 老年代和元数据区(JDK1.7及以前叫老年代). 其中新生代和老年代是虚拟机规范中Java堆内存的实现, 元数据区是规范中方法区的实现. 在讲述为什么这么定义之前, 先明确这个关系对于理解概念是很重要的, 下面有幅图帮助理解.
  • 这里有个小失误, 题目中明明讲的是JDK1.8, 为什么还提永久代呢? 由于永久代存在的时间长, 永久代的说法经过这么多年可能已经深入人心, 所以先并列讲, 要知道永久代和元数据区是有本质的差别的, 这留到后面讲, 先认清概念.
  • 希望图片加描述能够帮助你立即规范定义的数据区域和JVM内存结构之间的关系. 下面将对HotSpot虚拟机的内存模型做进一步分析.

新生代和老年代.

  • Java堆内存被实现为新生代和老年代, 是为了更方便地进行垃圾回收. 我们知道对象是存储在堆内存中的, 从字面上理解新生代就是新创建的对象区域, 老年代就是使用多次生命周期长的对象区域. 新生代对象生命周期通常较短, 很多用完即可以释放; 老年代对象的生命周期较长, 可能在整个程序的运行过程中都是有用的.
  • 由于新对象和老对象具有不同的性质, 为对这两种对象设计的垃圾回收算法也不同, 所以要把它们分开.

新生代中的内存划分

  • 新生代的内存被分为一个Eden区和两个Survivor区. 为了讲述为什么要这么分, 需简单引入垃圾回收算法.
  • 首先最基础, 最简单的垃圾回收算法叫标记-清除算法. 算法流程和算法名完全一致: 首先标记出哪些是可以回收的对象, 标记完后把对象清除. 如果按照这么个流程, 新生代应该就是一块简单的内存就行, 现实结论告诉我们这个算法是可以优化的.
  • 标记清除算法的不足在于一块完整的内存在经过标记-清除算法后有些内存会被释放掉, 这时会造成内存空间不连续, 可能不能够存放一些较大的对象.
  • 标记-清除算法的升级版是复制算法, 它在标记-清除的思路上作出了些改变. 首先将内存分为两块, 当创建新对象分配内存的时候只用两块中的一块A. 当进行垃圾回收的时候只对有对象的一块A内存使用标记-清除算法进行回收, 回收后剩余的存活对象从内存A移到另一块空的内存B中, 这样A内存重新变为空内存, 继续重复此分配回收过程. 这个算法似乎更好一些, 但是也只是两块内存, 说明还不是现实中的最优解.
  • 考虑新的算法, 把内存分配成均等两块, 等同于能够使用的内存变为原来的二分之一了, 根据IBM专门部分研究新生代中百分之98%的对象都是"朝生夕死"的, 也就是说在进行垃圾回收时98%的对象都被回收掉, 只有2%会从A内存移动到B内存. 这么一想我们把两块内存割为相同的两块是不是有点太亏了?
  • 下面揭晓答案: HotSpot虚拟机回收虚拟机时使用的是复制算法, 但是它分成三块内存, 一个占80%内存的Eden区(堆内存), 两个分别占10%的Survivor区. 具体操作是这样的: 程序运行时, 用Eden区和一个Survivor区A存放新创建的对象. 当发生垃圾回收时, 把存活下来的对象(很少)复制到另一块Survivor区B中, 使得Eden区和Survivor区A重新为空, 然后继续重复这个分配回收的过程.
  • 所以说详细点的Jvm的内存模型是下面这样的

由JDK1.7及以前的永久代到JDK1.8的元数据区

  • 搞定完堆区在JVM内存模型中的实现, 下面谈论方法区的实现.
  • 在JDK1.7及以前, JVM使用永久代来实现方法区. 这里用"实现"二字是经过斟酌的, 因为永久代并不等同于方法区. 从名字也可以看出它和新生代, 老年代是一脉相承的, 逻辑上是一体的, 命名为永久代是因为这部分内存很少几乎不被回收. 这一很少几乎不被回收的特性正好对应方法区中存储的类信息, 常量, 静态变量等元素. 所以说用永久代来实现方法区.
  • 但是用永久代来实现方法区并不是最优解, 比如容易出现内存溢出问题(具体分析去除永久代, 改用Metaspace的原因可以参考文章末尾所列出的资料). 在JDK1.8中JVM改为使用元数据区来实现方法区.
  • 元数据区和永久代有着本质的区别, 永久代属于虚拟机内存的一部分, 也就是说当在操作系统中启动虚拟机进程时为它分配了一块内存, 而虚拟机为永久代分配内存时用的是它自己分配得的内存.
  • 而元数据区Metaspace是直接在本地内存(Native Memory)中申请的, 这样元数据区的大小(方法区大小)只会受本地内存大小限制, 和虚拟机进程所分得内存无关.
  • 所以最后JVM内存模型图的终极版应该是这样子
  • 到此为止, 本篇结束, 希望对你有帮助.

参考资料

往期推荐:

  1. 写代码解释什么是api,什么是sdk
  2. 飞机大战小游戏全制作过程分享
  3. 仿flappy bird小游戏制作分享
  4. 如何开始编写技术博客?markdown语法入门,分享使人进步
  5. 请求参数、表单参数、url参数、header参数、Cookie参数有什么区别?
posted @ 2019-04-17 07:42  胡涂阿菌  阅读(1114)  评论(0编辑  收藏  举报