JVM 整体架构

根据 JVM 规范,JVM 内存共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分。

名 称 特征 作用 配置参数 异常
程 序 计 数 器 占用内存小,线 程私有,生命周 期与线程相同 大致为字节码行号指示器
虚 拟 机 栈 线程私有,生命 周期与线程相 同,使用连续的 内存空间 Java 方法执行的内存模 型,存储局部变量表、 操作栈、动态链接、方 法出口等信息 -Xss StackOverflowError/ OutOfMemoryError
线程共享,生命 周期与虚拟机相 同,可以不使用 连续的内存地址 保存对象实例,所有对 象实例(包括数组)都 要在堆上分配 -Xms
-Xsx
-Xmn
OutOfMemoryError
方 法 区 线程共享,生命 周期与虚拟机相 同,可以不使用 连续的内存地址 存储已被虚拟机加载的 类信息、常量、静态变 量、即时编译器编译后 的代码等数据 -XX:PermSize:16M
-XX:MaxPermSize64M
-XX:MetaspaceSize=16M
-XX:MaxMetaspaceSize=64M
OutOfMemoryError
本 地 方 法 栈 线程私有 为虚拟机使用到的 Native 方法服务 StackOverflowError/ OutOfMemoryError

JVM分为五大模块: 类装载器子系统 、 运行时数据区 、 执行引擎 、 本地方法接口 和 垃圾收集模块 。

JVM 运行时内存

Java 虚拟机有自动内存管理机制,如果出现面的问题,排查错误就必须要了解虚拟机是怎样使用内存的。

Java7和Java8内存结构的不同主要体现在方法区的实现,方法区是java虚拟机规范中定义的一种概念上的区域,不同的厂商可以对虚拟机进行不同的实现。我们通常使用的Java SE都是由Sun JDK和OpenJDK所提供,这也是应用最广泛的版本。而该版本使用的VM就是
HotSpot VM。通常情况下,我们所讲的java虚拟机指的就是HotSpot的版本。



线程私有的:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:

  • 方法区
  • 直接内存(非运行时数据区的一部分)

对于Java8,HotSpots取消了永久代,那么是不是就没有方法区了呢?

当然不是,方法区只是一个规范,只不过它的实现变了。在Java8中,元空间(Metaspace)登上舞台,方法区存在于元空间(Metaspace)。同时,元空间不再与堆连续,而且是存在于本地内存(Native memory)。

方法区Java8之后的变化

  • 移除了永久代(PermGen),替换为元空间(Metaspace)
  • 永久代中的class metadata(类元信息)转移到了native memory(本地内存,而不是虚拟机)
  • 永久代中的interned Strings(字符串常量池) 和 class static variables(类静态变量)转移到了Java heap
  • 永久代参数(PermSize MaxPermSize)-> 元空间参数(MetaspaceSize MaxMetaspaceSize)

Java8为什么要将永久代替换成Metaspace?

  • 字符串存在永久代中,容易出现性能问题和内存溢出。
  • 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
  • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
  • Oracle 可能会将HotSpot 与 JRockit 合二为一,JRockit没有所谓的永久代。

程序计数器

程序计数器(Program Counter Register):也叫PC寄存器,是一块较小的内存空间,它可以看做是当前线程所执行
的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条
需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

特点:

  1. 区别于计算机硬件的pc寄存器,两者不略有不同。计算机用pc寄存器来存放“伪指令”或地址,而相对于虚拟机,pc寄存器它表现为一块内存,虚拟机的pc寄存器的功能也是存放伪指令,更确切的说存放的是将要执行指令的地址。
  2. 当虚拟机正在执行的方法是一个本地(native)方法的时候,jvm的pc寄存器存储的值是undefined。
  3. 程序计数器是线程私有的,它的生命周期与线程相同,每个线程都有一个。
  4. 此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器只会执行一条线程中的指令。

因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,即生命周期和线程相同。Java虚拟机栈和线程同时创建,用于存储栈帧。每个方法在执行时都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直到执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

package com.lagou.unit;
public class StackDemo {
    public static void main(String[] args) {
        StackDemo sd = new StackDemo();
        sd.A();
    }

    public void A(){
        int a = 10;
        System.out.println(" method A start");
        System.out.println(a);
        B();
        System.out.println("method A end");
    }

    public void B(){
        int b = 20;
        System.out.println(" method B start");
        C();
        System.out.println("method B end");
    }

    private void C() {
        int c = 30;
        System.out.println(" method C start");
        System.out.println("method C end");
    }
}

什么是栈帧?

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。


设置虚拟机栈的大小

-Xss 为jvm启动的每个线程分配的内存大小,默认JDK1.4中是256K,JDK1.5+中是1M

- Linux/x64 (64-bit): 1024 KB
- macOS (64-bit): 1024 KB
- Oracle Solaris/x64 (64-bit): 1024 KB
- Windows: The default value depends on virtual memory

-Xss1m
-Xss1024k
-Xss1048576

局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。包括8种基本数据类型、对象引用(reference类型)和returnAddress类型(指向一条字节码指令的地址)。其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。

操作数栈

操作数栈(Operand Stack)也称作操作栈,是一个后入先出栈(LIFO)。随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。

动态链接

Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接(Dynamic Linking)。

方法返回地址

方法返回地址存放调用该方法的PC寄存器的值。一个方法的结束,有两种方式:正常地执行完成,出现未处理的异常非正常的退出。无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。无论方法是否正常完成,都需要返回到方法被调用的位置,程序才能继续进行。

本地方法栈

本地方法栈(Native Method Stacks) 与虚拟机栈所发挥的作用是非常相似的, 其区别只是虚拟机栈为虚拟机执行 Java方法(也就是字节码) 服务, 而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。

特点:

  1. 本地方法栈加载native的但是方法, native类方法存在的意义当然是填补java代码不方便实现的缺陷而提出的。
  2. 虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的Native方法服务。
  3. 是线程私有的,它的生命周期与线程相同,每个线程都有一个。

在Java虚拟机规范中,对本地方法栈这块区域,与Java虚拟机栈一样,规定了两种类型的异常:

  • StackOverFlowError :线程请求的栈深度>所允许的深度。
  • OutOfMemoryError:本地方法栈扩展时无法申请到足够的内存。

对于Java应用程序来说, Java堆(Java Heap) 是虚拟机所管理的内存中最大的一块。 Java堆是被所有线程共享的一块内存区域, 在虚拟机启动时创建。 此内存区域的唯一目的就是存放对象实例, Java 世界里“几乎”所有的对象实例都在这里分配内存。“几乎”是指从实现角度来看, 随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持, 即使只考虑现在, 由于即时编译技术的进步, 尤其是逃逸分析技术的日渐强大, 栈上分配、 标量替换优化手段已经导致一些微妙的变化悄然发生, 所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。

特点:

  1. 是Java虚拟机所管理的内存中最大的一块。
  2. 堆是jvm所有线程共享的。堆中也包含私有的线程缓冲区 Thread Local Allocation Buffer (TLAB)
  3. 在虚拟机启动的时候创建。
  4. 唯一目的就是存放对象实例,几乎所有的对象实例以及数组都要在这里分配内存。
  5. Java堆是垃圾收集器管理的主要区域。
  6. 因此很多时候java堆也被称为“GC堆”(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代;新生代又可以分为:Eden 空间、FromSurvivor空间、To Survivor空间。
  7. java堆是计算机物理存储上不连续的、逻辑上是连续的,也是大小可调节的(通过-Xms和-Xmx控制)。
  8. 方法结束后,堆中对象不会马上移出仅仅在垃圾回收的时候时候才移除。
  9. 如果在堆中没有内存完成实例的分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常

设置堆空间大小

  1. 内存大小-Xmx/-Xms

使用示例: -Xmx20m -Xms5m

说明: 当下Java应用最大可用内存为20M, 最小内存为5M

测试:

public class TestVm {
    public static void main(String[] args) {
        //补充
        //byte[] b=new byte[5*1024*1024];
        //System.out.println("分配了1M空间给数组");
        System.out.print("Xmx=");
        System.out.println(Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");
        System.out.print("free mem=");
        System.out.println(Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");
        System.out.print("total mem=");
        System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");
    }
}

Output:

Xmx=20.0M
free mem=4.1877593994140625M
total mem=6.0M

可以发现,打印出来的Xmx值和设置的值之间是由差异的,total Memory和最大的内存之间还是存在一定差异的,就是说JVM一般会尽量保持内存在一个尽可能底的层面,而非贪婪做法按照最大的内存来进行分配。

在测试代码中新增如下语句,申请内存分配:

byte[] b=new byte[4*1024*1024];
System.out.println("分配了1M空间给数组");

在申请分配了4m内存空间之后,total memory上升了,同时可用的内存也上升了,可以发现其实JVM在分配内存过程中是动态的, 按需来分配的。

堆的分类

现在垃圾回收器都使用分代理论,堆空间也分类如下:

在Java7 Hotspot虚拟机中将Java堆内存分为3个部分:

  • 青年代Young Generation
  • 老年代Old Generation
  • 永久代Permanent Generation

在Java8以后,由于方法区的内存不在分配在Java堆上,而是存储于本地内存元空间Metaspace中,所以永久代就不
存在了,在几天前(2018年9约25日)Java11正式发布以后,我从官网上找到了关于Java11中垃圾收集器的官方文档,
文档中没有提到“永久代”,而只有青年代和老年代。

年轻代 & 老年代

JVM中存储java对象可以被分为两类:

  • 年轻代(Young Gen):年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代分成1个Eden Space和2个Suvivor Space(from 和to)。
  • 年老代(Tenured Gen):年老代主要存放JVM认为生命周期比较长的对象(经过几次的Young Gen的垃圾回收后仍然存在),内存大小相对会比较大,垃圾回收也相对没有那么频繁。

默认 -XX:NewRatio=2 , 标识新生代占1 , 老年代占2 ,新生代占整个堆的1/3修改占比 -XX:NewPatio=4 , 标识新生代占1 , 老年代占4 , 新生代占整个堆的1/5

Eden空间和另外两个Survivor空间占比分别为8:1:1。可以通过操作选项 -XX:SurvivorRatio 调整这个空间比例。 比如 -XX:SurvivorRatio=8

几乎所有的java对象都在Eden区创建, 但80%的对象生命周期都很短,创建出来就会被销毁.

从图中可以看出: 堆大小 = 新生代 + 老年代。其中,堆的大小可以通过参数 –Xms、-Xmx 来指定。

默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 ),即:新生代 ( Young ) = 1/3 的堆空间大小。老年代 ( Old ) = 2/3 的堆空间大小。其中,新生代 ( Young ) 被细分为 Eden 和 两个Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分。 默认的,Edem : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 ),即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。

JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。

对象分配过程

JVM设计者不仅需要考虑到内存如何分配,在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,因此还需要考虑GC执行完内存回收后是否存在空间中间产生内存碎片。

分配过程:

  1. new的对象先放在伊甸园区。该区域有大小限制
  2. 当伊甸园区域填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园预期进行垃圾回收(Minor GC),将伊甸园区域中不再被其他对象引用的额对象进行销毁,再加载新的对象放到伊甸园区
  3. 然后将伊甸园区中的剩余对象移动到幸存者0区
  4. 如果再次触发垃圾回收,此时上次幸存下来的放在幸存者0区的,如果没有回收,就会放到幸存者1区
  5. 如果再次经历垃圾回收,此时会重新返回幸存者0区,接着再去幸存者1区。
  6. 如果累计次数到达默认的15次,这会进入养老区。可以通过设置参数,调整阈值 -XX:MaxTenuringThreshold=N
  7. 养老区内存不足是,会再次出发GC:Major GC 进行养老区的内存清理
  8. 如果养老区执行了Major GC后仍然没有办法进行对象的保存,就会报OOM异常.



GC 堆

Java 中的堆也是 GC 收集垃圾的主要区域。GC 分为两种:一种是部分收集器(Partial GC)另一类是整堆收集器(Fu’ll GC)

部分收集器: 不是完整收集java堆的的收集器,它又分为:

  • 新生代收集(Minor GC / Young GC): 只是新生代的垃圾收集
  • 老年代收集 (Major GC / Old GC): 只是老年代的垃圾收集 (CMS GC 单独回收老年代)

混合收集(Mixed GC):收集整个新生代及老年代的垃圾收集 (G1 GC会混合回收, region区域回收)

整堆收集(Full GC):收集整个java堆和方法区的垃圾收集器

年轻代GC触发条件:

年轻代空间不足,就会触发Minor GC, 这里年轻代指的是Eden代满,Survivor不满不会引发GCMinor GC会引发STW(stop the world) ,暂停其他用户的线程,等垃圾回收接收,用户的线程才恢复.

老年代GC (Major GC)触发条件:

老年代空间不足时,会尝试触发MinorGC. 如果空间还是不足,则触发Major GC。如果Major GC , 内存仍然不足,则报错OOM,Major GC的速度比Minor GC慢10倍以上.

FullGC 触发条件:

  • 调用System.gc() , 系统会执行Full GC ,不是立即执行.
  • 老年代空间不足
  • 方法区空间不足
  • 通过Minor GC进入老年代平均大小大于老年代可用内存

元空间

在JDK1.7之前,HotSpot 虚拟机把方法区当成永久代来进行垃圾回收。而从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。 HotSpots取消了永久代,那么是不是也就没有方法区了呢?当然不是,方法区是一个规范,规范没变,它就一直在,只不过取代永久代的是元空间(Metaspace)而已。

它和永久代有什么不同的?

  • 存储位置不同:永久代在物理上是堆的一部分,和新生代、老年代的地址是连续的,而元空间属于本地内存。
  • 存储内容不同:在原来的永久代划分中,永久代用来存放类的元数据信息、静态变量以及常量池等。现在类的元信息存储在元空间中,静态变量和常量池等并入堆中,相当于原来的永久代中的数据,被元空间和堆内存给瓜分了。

为什么要废弃永久代,引入元空间?

相比于之前的永久代划分,Oracle为什么要做这样的改进呢?

  • 在原来的永久代划分中,永久代需要存放类的元数据、静态变量和常量等。它的大小不容易确定,因为这其中有很多影响因素,比如类的总数,常量池的大小和方法数量等,-XX:MaxPermSize 指定太小很容易造成永久代内存溢出。
  • 移除永久代是为融合HotSpot VM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代
  • 永久代会为GC带来不必要的复杂度,并且回收效率偏低。

废除永久代的好处

  • 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。不会遇到永久代存在时的内存溢出错误。
  • 将运行时常量池从PermGen分离出来,与类的元数据分开,提升类元数据的独立性。
  • 将元数据从PermGen剥离出来到Metaspace,可以提升对元数据的管理同时提升GC效率。

Metaspace相关参数

  • -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
  • -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。如果没有使用该参数来设置类的元数据的大小,其最大可利用空间是整个系统内存的可用空间。JVM也可以增加本地内存空间来满足类元数据信息的存储。但是如果没有设置最大值,则可能存在bug导致Metaspace的空间在不停的扩展,会导致机器的内存不足;进而可能出现swap内存被耗尽;最终导致进程直接被系统直接kill掉。如果设置了该参数,当Metaspace剩余空间不足,会抛出:java.lang.OutOfMemoryError: Metaspace space
  • -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
  • -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

方法区

方法区(Method Area) 与Java堆一样, 是各个线程共享的内存区域, 它用于存储已被虚拟机加载 的类型信息、常量、 静态变量、 即时编译器编译后的代码缓存等数据。

《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但些简单的实现可能不会选择去进行垃圾收集或者进行压缩”。对HotSpot而言,方法区还有一个别名叫做Non-Heap(非堆),的就是要和堆分开。

元空间、永久代是方法区具体的落地实现。方法区看作是一块独立于Java堆的内存空间,它主要是用来存储所加载
的类信息的


方法区的特点

  • 方法区与堆一样是各个线程共享的内存区域
  • 方法区在JVM启动的时候就会被创建并且它实例的物理内存空间和Java堆一样都可以不连续
  • 方法区的大小跟堆空间一样 可以选择固定大小或者动态变化
  • 方法区的对象决定了系统可以保存多少个类,如果系统定义了太多的类 导致方法区溢出虚拟机同样会跑出(OOM)异常(Java7之前是 PermGen Space (永久带) Java 8之后 是MetaSpace(元空间) )
  • 关闭JVM就会释放这个区域的内存

方法区结构

类加载器将Class文件加载到内存之后,将类的信息存储到方法区中。

方法区中存储的内容:

  • 类型信息(域信息、方法信息)
  • 运行时常量池

类型信息

对每个加载的类型(类Class、接口 interface、枚举enum、注解 annotation),JVM必须在方法区中存储以下类型信息:

  • 这个类型的完整有效名称(全名 = 包名.类名)
  • 这个类型直接父类的完整有效名(对于 interface或是java.lang. Object,都没有父类)
  • 这个类型的修饰符( public, abstract,final的某个子集)
  • 这个类型直接接口的一个有序列表

域信息

域信息,即为类的属性,成员变量。JVM必须在方法区中保存类所有的成员变量相关信息及声明顺序。

域的相关信息包括:域名称、域类型、域修饰符(pυblic、private、protected、static、final、volatile、transient的某个子集)

方法信息

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  1. 方法名称方法的返回类型(或void)
  2. 方法参数的数量和类型(按顺序)
  3. 方法的修饰符public、private、protected、static、final、synchronized、native,、abstract的一个子集
  4. 方法的字节码bytecodes、操作数栈、局部变量表及大小( abstract和native方法除外)
  5. 异常表( abstract和 native方法除外)。每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

方法区设置

方法区的大小不必是固定的,JVM可以根据应用的需要动态调整。

jdk7及以前

  • 通过-xx:Permsize来设置永久代初始分配空间。默认值是20.75M
  • -XX:MaxPermsize来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M
  • 当JVM加载的类信息容量超过了这个值,会报异常OutofMemoryError:PermGen space。

查看JDK PermSpace区域默认大小

jps #是java提供的一个显示当前所有java进程pid的命令
jinfo -flag PermSize 进程号 #查看进程的PermSize初始化空间大小
jinfo -flag MaxPermSize 进程号 #查看PermSize最大空间

JDK8以后

元数据区大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize指定

默认值依赖于平台。windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1,即没有限制。

与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace

-XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-xx:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,FullGC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活)然后这个高水位线将会置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。

如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到FullGC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。

jps #是java提供的一个显示当前所有java进程pid的命令
jinfo -flag PermSize 进程号 #查看进程的PermSize初始化空间大小
jinfo -flag MaxPermSize 进程号 #查看PermSize最大空间
jps #查看进程号
jinfo -flag MetaspaceSize 进程号 #查看Metaspace 最大分配内存空间
jinfo -flag MaxMetaspaceSize 进程号 #查看Metaspace最大空间

运行时常量池

字节码文件中,内部包含了常量池。方法区中,内部包含了运行时常量池

  • 常量池:存放编译期间生成的各种字面量与符号引用
  • 运行时常量池:常量池表在运行时的表现形式

编译后的字节码文件中包含了类型信息、域信息、方法信息等。通过ClassLoader将字节码文件的常量池中的信息加载到内存中,存储在了方法区的运行时常量池中。

可以理解为字节码中的常量池 Constant pool 只是文件信息,它想要执行就必须加载到内存中。而Java程序是靠JVM,更具体的来说是JVM的执行引擎来解释执行的。执行引擎在运行时常量池中取数据,被加载的字节码常量池中的信息是放到了方法区的运行时常量池中。
它们不是一个概念,存放的位置是不同的。一个在字节码文件中,一个在方法区中。

对字节码文件反编译之后,查看常量池相关信息:

要弄清楚方法区的运行时常量池,需要理解清楚字节码中的常量池。

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表( Constant pool table),包括各种字面量和对类型、域和方法的符号引用。常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。

常量池表Constant pool table:

在方法中对常量池表的符号引用

为什么需要常量池?

举例来说:

public class Solution {
    public void method() {
        System.out.println("are you ok");
    }
}

这段代码很简单,但是里面却使用了 String、 System、 PrintStream及Object等结构。如果代码多,引用到的结构会更多!这里就需要常暈池,将这些引用转变为符号引用,具体用到时,采取加载。

直接内存

直接内存(Direct Memory) 并不是虚拟机运行时数据区的一部分。

在JDK 1.4中新加入了NIO(New Input/Output) 类, 引入了一种基于通道(Channel) 与缓冲区 (Buer) 的I/O方式, 它可以使用Native函数库直接分配堆外内存, 然后通过一个存储在Java堆里面的 DirectByteBuer对象作为这块内存的引用进行操作。 这样能在一些场景中显著提高性能, 因为避免了 在Java堆和Native堆中来回复制数据。

NIO的Buer提供一个可以直接访问系统物理内存的类——DirectBuer。DirectBuer类继承自ByteBuer,但和普通的ByteBuer不同。普通的ByteBuer仍在JVM堆上分配内存,其最大内存受到最大堆内存的 限制。而DirectBuer直接分配在物理内存中,并不占用堆空间。在访问普通的ByteBuer时,系统总是会使用一个“内核缓冲区”进行操作。

而DirectBuer所处的位置,就相当于这个“内核缓冲区”。因此,使用DirectBuer是一种更加接近内存底层的方法,所以它的速度比普通的ByteBuer更快。

通过使用堆外内存,可以带来以下好处:

  1. 改善堆过大时垃圾回收效率,减少停顿。Full GC时会扫描堆内存,回收效率和堆大小成正比。Native的内存,由OS负责管理和回收。
  2. 减少内存在Native堆和JVM堆拷贝过程,避免拷贝损耗,降低内存使用。
  3. 可突破JVM内存大小限制。

OOM 常见异常

Java堆溢出

堆内存中主要存放对象、数组等,只要不断地创建这些对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾收集回收机制清除这些对象,当这些对象所占空间超过最大堆容量时,就会产生 OutOfMemoryError 的异常。堆内存异常示例如下:

/**
* 设置最大堆最小堆:-Xms20m -Xmx20m
*/
public class HeapOOM {
    static class OOMObject {
    }
    public static void main(String[] args) {
        List<OOMObject> oomObjectList = new ArrayList<>();
        while (true) {
            oomObjectList.add(new OOMObject());
        }
    }
}

运行后会报异常,在堆栈信息中可以看到 java.lang.OutOfMemoryError: Java heap space 的信息,说明在堆内存空间产生内存溢出的异常。

新产生的对象最初分配在新生代,新生代满后会进行一次 Minor GC ,如果 Minor GC 后空间不足会把该对象和新生代满足条件的对象放入老年代,老年代空间不足时会进行 Full GC ,之后如果空间还不足以存放新对象则抛出 OutOfMemoryError 异常。

常见原因:

  • 内存中加载的数据过多,如一次从数据库中取出过多数据;
  • 集合对对象引用过多且使用完后没有清空;
  • 代码中存在死循环或循环产生过多重复对象;
  • 堆内存分配不合理

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

由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈, 因此对于HotSpot来说, -Xoss参数(设置本地方法栈大小) 虽然存在, 但实际上是没有任何效果的, 栈容量只能由-Xss参数来设定。 关于虚拟机栈和本地方法栈, 在《Java虚拟机规范》 中描述了两种异常:

  1. 如果线程请求的栈深度大于虚拟机所允许的最大深度, 将抛出StackOverflowError异常。
  2. 如果虚拟机的栈内存允许动态扩展, 当扩展栈容量无法申请到足够的内存时, 将抛出 OutOfMemoryError异常。

《Java虚拟机规范》 明确允许Java虚拟机实现自行选择是否支持栈的动态扩展, 而HotSpot虚拟机的选择是不支持扩展, 所以除非在创建线程申请内存时就因无法获得足够内存而出现 OutOfMemoryError异常, 否则在线程运行时是不会因为扩展而导致内存溢出的, 只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。

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

前面曾经提到HotSpot从JDK 7开始逐步“去永久代”的计划, 并在JDK 8中完全使用元空间来代替永久代的背景故事, 在此我们就以测试代码来观察一下, 使用“永久代”还是“元空间”来实现方法区, 对程序有什么 实际的影响。

String::intern()是一个本地方法, 它的作用是如果字符串常量池中已经包含一个等于此String对象的 字符串, 则返回代表池中这个字符串的String对象的引用; 否则, 会将此String对象包含的字符串添加到常量池中, 并且返回此String对象的引用。 在JDK 6或更早之前的HotSpot虚拟机中, 常量池都是分配在永久代中, 我们可以通过-XX:PermSize和-XX: MaxPermSize限制永久代的大小, 即可间接限制其中常量池的容量, 具体实现如代码清单

// OOM异常一:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.base/java.lang.Integer.toString(Integer.java:440)
    at java.base/java.lang.String.valueOf(String.java:3058)
    at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:12)

// OOM异常二:
//根据Oracle官方文档,默认情况下,如果Java进程花费98%以上的时间执行GC,并且每次只有不到2%的堆被恢复,则JVM抛出
此错误
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
    at java.lang.Integer.toString(Integer.java:401)
    at java.lang.String.valueOf(String.java:3099)
    at com.lagou.unit.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:17)

方法区的其他部分的内容, 方法区的主要职责是用于存放类型的相关信息, 如类名、 访问修饰符、 常量池、 字段描述、 方法描述等。 对于这部分区域的测试, 基本的思路是运行时产 生大量的类去填满方法区, 直到溢出为止。

虽然直接使用Java SE API也可以动态产生类(如反射时的 GeneratedConstructorAccessor和动态代理等) , 但在本次实验中操作起来比较麻烦。 在代码清单

借助CGLib使得方法区出现内存溢出异常

/**
* VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
*/
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() {
                public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy)
                throws ThrowException{
                    return proxy.invokeSuper(obj, args);
                }
            });
            enhancer.create();
        }
    }
    static class OOMObject {
    }
}

在JDK 6中的运行结果

Caused by: java.lang.OutOfMemoryError: PermGen space
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632)
at java.lang.ClassLoader.defineClass(ClassLoader.java:616)
... 8 more

方法区溢出也是一种常见的内存溢出异常, 一个类如果要被垃圾收集器回收, 要达成的条件是比较苛刻的。 在经常运行时生成大量动态类的应用场景里, 就应该特别关注这些类的回收状况。 这类场 景除了之前提到的程序使用了CGLib字节码增强和动态语言外, 常见的还有: 大量JSP或动态产生JSP 文件的应用(JSP第一次运行时需要编译为Java类) 、 基于OSGi的应用(即使是同一个类文件, 被不同 的加载器加载也会视为不同的类) 等。

在JDK 8以后, 永久代便完全退出了历史舞台, 元空间作为其替代者登场。 在默认设置下, 前面列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了。 不过 为了让使用者有预防实际应用里出现破坏性的操作, HotSpot还是提供了一 些参数作为元空间的防御措施, 主要包括:

  • -XX: MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存 大小。
  • -XX: MetaspaceSize: 指定元空间的初始空间大小, 以字节为单位, 达到该值就会触发垃圾收集 进行类型卸载,同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放 了很少的空间, 那么在不超过-XX: MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。
  • -XX: MinMetaspaceFreeRatio: 作用是在垃圾收集之后控制最小的元空间剩余容量的百分比, 可 减少因为元空间不足导致的垃圾收集的频率。 类似的还有-XX: Max-MetaspaceFreeRatio, 用于控制最 大的元空间剩余容量的百分比。

直接内存溢出

直接内存(Direct Memory) 的容量大小可通过-XX: MaxDirectMemorySize参数来指定, 如果不去指定, 则默认与Java堆最大值(由-Xmx指定) 一致, 越过了DirectByteBuer类直接通 过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实例, 体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能, 在JDK 10时才将Unsafe 的部分功能通过VarHandle开放给外部使用) , 因为虽然使用DirectByteBuer分配内存也会抛出内存溢出异常, 但它抛出异常时并没有真正向操作系统申请分配内存, 而是通过计算得知内存无法分配就会 在代码里手动抛出溢出异常, 真正申请分配内存的方法是Unsafe::allocateMemory()

/**
* VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
*/
public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) throws Exception {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}

Output:

Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at org.fenixsoft.oom.DMOOM.main(DMOOM.java:20)

由直接内存导致的内存溢出, 一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常 情况, 如果发现内存溢出之后产生的Dump文件很小, 而程序中又直接或间接使用了 DirectMemory(典型的间接使用就是NIO) ,那就可以考虑重点检查一下直接内存方面的原因了。

评论