深入理解Java虚拟机之Java JVM运行时内存
0 Views jvm with
本文字数:2,918 字 | 阅读时长 ≈ 11 min

深入理解Java虚拟机之Java JVM运行时内存

0 Views jvm with
本文字数:2,918 字 | 阅读时长 ≈ 11 min

Java JVM内存区域

程序计数器

程序计数器(Program Counter Register)当前线程执行的字节码的行号指示器。每个线程都有独立的程序计数器。此内存区域是唯一一个在Java虚拟机中没有任何OutOfMemoryError 情况的区域。

Java虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks) 描述Java方法执行的内存模型,每个方法执行的同事都会创建一个栈帧(Stack Frame) 用于储存局部变量表、操作数栈、动态链接、方法出口等。

每个方法执行时都创建一个栈帧,并依次将栈帧压如到Java虚拟机栈中,当次方法执行完成,就再依次从Java虚拟机栈中弹出栈帧。如果压入栈帧的内存大小大于了Java虚拟机栈就会抛出 StackOverflowError。

局部变量表

方法的执行需要创建一个栈帧,这个栈帧中就储存了该方法执行所需的全部数据。其中栈帧中有一块区域局部变量表存放了编译器可知的各种基本数据类型(boolean, byte, char, short, int, float, long, double)、对象引用(这里的对象仅是一个对象地址的引用指针,该对象可能在堆内存中存放)。

其中64位位长度的long和double类型的数据会占用2个局部变量空间,其余的数据类型只占用一个。当一个方法执行时在创建的栈帧中需要分配的局部变量空间是完全确定的且整个运行期间不会改变局部变量表的大小,因为基本数据类型占用空间显然是确定的,而引用类型在局部变量表中储存的尽是对象地址的引用指针,所以内存大小也是确定的。

如果线程请求的栈帧深度大于虚拟机所允许的深度将抛出 StackOverflowError 异常;如果虚拟机栈无法申请到足够的内存空间来存放数据就会抛出 OutOfMemoryError 异常。

本地方法栈

本地方法栈类似于Java虚拟机栈,只不过Java虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务。

Java 堆

简单一句话:堆内存储存了对象实例(比如使用new关键字创建的对象都在堆内存中储存)。它是Java虚拟机内存中对打的一块区域,也是垃圾回收器管理的主要区域。

从内存分配的角度看,由于现在收集器基本都是采用分代式收集算法,所以Java堆中还可以细分为:新生代、老年代;或再细分为:Eden空间、From Survivor空间、To Survivor空间等。

方法区

方法区(Method Area)用于储存已被虚拟机加载的类信息、敞亮、静态变量、即时编译后的代码等数据。这区域的内存回收目标主要是针对常量池的回收和堆类型的卸载。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了基本的方法信息还有常量池(Constant Poll Table)信息,用于存放编译期生成的各种字面量和符号的引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

举个栗子:

public static void main(String[] args) {
    String s1 = "abc";
    String s2 = "abc";
    System.out.println(s1 == s2); //true
    String s3 = new String("abc");
    System.out.println(s1 == s3)    ; //false
      System.out.println(s1 == s3.intern()); //true
}

如上,s1s2由于是直接初始化的相同的String类型变量,这些变量储存在常量池中(类似HashSet无序唯一),因此她们的内存地址是相同的。而s3是显示的通过new关键字初始化,那么s3所代表的对象就直接在堆内存中存放,所以s3和另外两个的内存地址都不同:

而String类有一个intern()方法,这个方法可以将储存在堆内存中的对象放入到常量池中,所以此时的s3s1内存地址相同。

深入理解虚拟机对象

对象的创建

简单来说,Java中对象的创建仅需要通过new关键字就能完成,那么JVM又是如何解析这个new的对象呢?

  1. JVM遇到new指令时,首先去检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,则先执行类的加载过程。对比上面String的小案例。
  2. 在类检查通过后,接下来将为此对象在堆内存中分配固定大小的空间(这个内存大小在类加载完成后便可完全确定)。如果Java堆中内存分布规整(未使用和用过的分隔存放),将使用指针碰撞(Bump the Pointer)的分配方式;如果Java堆中内存分布相互交错,那么将采用空闲列表(Free List)这种分配方式。
  3. Java虚拟机还需要考虑对象的创建在JVM中是否是频繁的行为,如果并发较高,则可能出现线程安全问题(A对象还未创建,B对象就使用原来的指针分配内存)。解决方案:1.采用同步处理;2.把内存分配的动作按照超线程划分在不同的空间上进行。
  4. 内存分配完成,Java虚拟机将分配到的内存空间进行默认初始化,比如String类型初始化为null值等。
  5. 然后,虚拟机对该对象进行必要的设置,比如对象的类元数据指针(虚拟机根据这个指针来确定这个对象是哪个类的实例)哈希码、GC分代年龄等信息。这些信息分布在对象的对象头(Object Header)中。
  6. 至此,Java虚拟机已经完成了对象的创建,下面就是按照具体代码完成继续的初始化。

对象的内存布局

在 HotSpot 虚拟机中,对象在内存中储存的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。

  1. 对象头,官方称为Mark Word。其中储存了对象自身运行时数据;以及对象指向他的类元数据的指针(虚拟机通过这个指针来确定对象是哪个类的实例),如果对象是一个Java数组,那么对象头中还需要储存数组的长度等信息,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。
  2. 实例数据区域储存了该类的详细数据,包含自类和父类的字段信息。
  3. 对齐填充部分则是为内存地址进行填充规范格式。

对象的访问定位

通过new关键字创建的对象最终目的是要调用他,而在Java程序中通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机上值规定了一个指向对象的引用,并没有定义应该通过什么方式去定位、访问堆中的对象的具体位置,所以对象访问方式取决于虚拟机实现而定。目前主流通过句柄直接指针两种方式访问:

// TODO

OutOfMemoryError异常

public class Main {

    public static void main(String[] args) {

        List<String> list = new ArrayList<>();
        while (true) {
            list.add(new String());
        }
    }
}

经过一段时间的运行,会抛出如下异常:

Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded

JVM参数设置:

在IDEA中我们可以直接这样设置:

通过设置-XX:+HeapDumpOnOutMemoryError在内存溢出时会在项目根目录下生成一份java_pidxxx.hprof文件,这个文件中储存了该程序运行期间造成堆内存溢出的具体信息。我们可以直接通过 Eclipse Memory Analyzer 导入该文件即可分析,这里不再演示,

这里,我们可以尝试使用Java内置的内存分析工具:JConsole 来分析程序内存执行情况。

JConsole

JConsole是JDK内存的内存监控分析工具,他存放于/jdk/bin目录下。就像java javac命令一样,我们可以直接使用jconsole命令启动JConsole图形化程序(前提是我们已经将JDK注册为系统的全局变量了)。

如何监控我们启动的程序呢?首先我们将代码改写一下:

public class Main {

    public static void main(String[] args) {

        List<String> list = new ArrayList<>();
        while (true) {
            try {
                Thread.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            list.add(new String());
        }
    }
}

然后启动main方法,立即去终端输入jconsole命令,在JConsole图形化界面中选择我们刚启动的Main类:

点击链接->不安全链接,最终可以看到Main类执行时内存情况:

可以清晰的看到Main类执行的内存占用情况。(注意由于我们使用了Thread.sleep()最终产生内存溢出要花很长时间,可以再修改-Xmx参数给程序分配更少的内存空间以便更快的看到异常抛出)

最终会抛出如下异常:

java.lang.OutOfMemoryError: GC overhead limit exceeded
Dumping heap to java_pid882.hprof ...
Heap dump file created [5942640 bytes in 0.047 secs]
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded

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

栈容量通过-Xss参数设定。其中对于虚拟机栈和本地方法栈,Java虚拟机规范中描述了两种异常:

举个栗子:

public class Main {

    public static void main(String[] args) {
        stackLeak();
    }

    private static void stackLeak() {
        stackLeak();
    }
}

注:VM: -Xms0m -Xmx3m -Xss161k

很快会抛出栈溢出异常:

Exception in thread "main" java.lang.StackOverflowError

很容易想到,在上面我们提到了,方法的执行需要每次都创建栈帧,然后往虚拟机栈中压栈,当栈帧的深度超过了虚拟机栈所允许的最大深度就将抛出栈溢出异常。

方法区和运行时常量池溢出

运行时常量池属于方法区的一部分,可以通过-XX:PermSize-XX:MaxPermSize限制方法区大小。但配置后运行程序却发现:

Java HotSpot(TM) 64-Bit Server VM warning: ignoring option PermSize=10M; support was removed in 8.0
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=10M; support was removed in 8.0

也就是说-XX:PermSize-XX:MaxPermSize在JDK1.8版本中已经弃用了。

取而代之的是-XX:MetaspaceSize-XX:MaxMetaspaceSize参数。在JDK1.8之前通常将方法区成为持久带(Permanent Generation),PermSizeMaxPermSize是设置持久带大小的参数,而在JDK1.8中已经完全移除了持久代,多了一个元数据区(Metadata Space)。

// TODO


交流

以上仅是个人见解,欢迎提出意见或建议。

QQ交流群:671017003 欢迎各位前辈或萌新入驻


联系

If you have some questions after you see this article, you can contact me or you can find some info by clicking these links.

如果你觉得这篇文章帮助到了你,你可以帮作者买一杯果汁表示鼓励