Jvm基本结构

2016/01/02 JVM

JVM基本结构

Java虚拟机那么复杂,它的基本结构是什么?各个组成部分有何作用?又是如何相互协调工作的呢? 本文主要讨论Java虚拟机架构、Java堆、Java栈、永久区和元数据区的基本概念以及完整的说明。

本章涉及的主要知识点有:

  • Java虚拟机架构
  • JVM 内存区域概览
  • 程序计数器
  • 认识Java虚拟机中的堆。堆区的空间分配是怎么样?
  • 创建一个新对象内存是怎么分配的?
  • 了解有关栈的概念和使用。栈帧是什么?栈帧里有什么?怎么理解?本地方法栈
  • 了解存放类型描述的永久区和元数据区。方法区 到 Metaspace 元空间
  • Code Cache 是什么?

Java虚拟机架构

JVM包含堆、元空间、Java虚拟机栈、本地方法栈、程序计数器等内存区域,其中堆是占用内存最大的,如下图所示:

              
           [Java Class文件]   ------》   [Class Loader] 
                                             
           .——————————————————————————————————————————.
           |[方法区]     [虚拟机栈]        [本地方法栈]    | 
           |                                          |
           |[堆] [直接内存]         [程序计数器]          |
           |                                          |
           |  [垃圾回收系统]                             |
           .—————————————————————————————————————————-.
           
           [执行引擎]    [本地库接口]        [本地方法库]           
           

参考官网地址https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html Java虚拟机架构 Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范(Java SE 7版)》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域。在 Java 中,JVM 内存模型主要分为堆、方法区、程序计数器、虚拟机栈和本地方法栈。其中,堆和方法区被所有线程共享,虚拟机栈、本地方法栈、程序计数器是线程私有的。 Java虚拟机的基本结构:

  • 类加载子系统 负责从文件系统或者网络中加载Class信息,加载的类信息存放于一块称为方法区的内存空间。除了类信息外,方法区中可能还会存放运行时常量池信息,包括字符串量和数字常量。
  • 方法区 方法区用于存储类型信息,运行时常量池信息,包括字符串量和数字常量。和堆一样,方法区是一块所有线程共享的内存区域。它用于保存系统的类信息,比如类的字段、方法、常量池等。方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出的错误。 在JDK1.6、JDK1.7中,方法区可以理解为永久区。永久区可以使用参数-XX:PermSize和-XX:MaxPermSize指定,默认情况下,-XX:MaxPermSize为64M。在JDK1.8中,永久代已经被彻底移除。取而代之的是元数据区,元数据区大小可以使用参数 -XX:MaxMetaspaceSize指定,这是一块堆外的直接内存。与永久区不同,如果不指定大小,默认情况下,虚拟机会耗所有的可用系统内存。
  • Java堆 在虚拟机启动的时候建立的,他是Java程序最主要的内存工作区,几乎所有的对象实例都存放在java堆中。堆空间是线程共享的。Java堆是和Java应用程序关系最为密切的内存空间,几乎所有的对象都存放在堆中。并且Java堆是完全自动化管理的,通过垃圾回收机制,垃圾对象会被自动清理,而不需要显示的释放。根据垃圾回收机制的不同,Java堆有可能拥有不同的结构。
  • 直接内存 直接内存时在Java堆外的、直接向系统申请的内存空间。通常,访问直接内存的速度会优于Java堆。因此处于性能考虑,读写频繁的场合会考虑使用直接内存。由于直接内存在Java堆外,因此它的大小不会直接受限于Xmx指定的最大堆大小,但是系统内存时有限的,Java堆和直接内存的总和依然受限于系统给出的最大内存。
  • 垃圾回收系统 是java虚拟机的重要组成部分,垃圾回收器会对方法区,java堆和直接内存进行回收。
  • Java栈 每一个Java虚拟机线程都有一个私有的Java栈,一个线程的线程栈在线程创建的时候创建,Java栈中保存着帧信息,局部变量,方法参数,同时和Java方法的调用和返回密切相关。
  • 本地方法栈 用户存储本地方法的调用。作为对虚拟机的重要拓展,Java虚拟机允许Java直接调用本地方法。
  • PC寄存器 也是每个线程私有的空间,Java虚拟机会为每个Java线程创建PC寄存器,在任意时刻,一个Java线程总是在执行一个方法,这个正在被执行的方法被称为当前方法,如果当前方法不是本地方法,PC寄存器就会指向当前正在被执行的指令。如果当前方法是本地方法,那么PC寄存器的值就是undefined.
  • 执行引擎 是Java虚拟机最核心的组件之一,它扶着执行虚拟机的字节码。虚拟机为了提高执行效率,会使用即时编译技术将方法编译成字节码后在执行。

程序计数器(Program Counter Register)

JVM 中的程序计数寄存器(Program Counter Register)中,Register 的命名源于 CPU 的寄存器,寄存器存储指令相关的现场信息。CPU 只有把数据装载到寄存器才能够运行。这里,并非是广义上所指的物理寄存器,或许将其翻译为 PC 计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM 中的 PC 寄存器是对物理 PC 寄存器的一种抽象模拟。

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

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

程序计数器(Program Counter Register)也叫PC寄存器。程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。JVM支持多个线程同时运行,每个线程都有自己的程序计数器。倘若当前执行的是 JVM 的方法,则该寄存器中保存当前执行指令的地址;倘若执行的是native 方法,则PC寄存器中为空(undefined)。

  • 当前线程私有
  • 当前线程所执行的字节码的行号指示器
  • 不会出现OutOfMemoryError情况
  • 以一种数据结构的形式放置于内存中

注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

程序计数器会随着线程的启动而创建,先来直观的看下计数器中会存哪些内容

package com.demo;

public class ProgramCounterDemo {

    public int calc(){
        int a = 100;
        int b = 200;
        int c = 300;
        return ( a + b ) * c;
    }

}

这是一段非常简单的计算代码,我们先编译成Class 文件再使用 javap 反汇编工具看下class 文件中数据格式, 使用javap -c ProgramCounterDemo.class

public class com.demo.ProgramCounterDemo {
  public com.demo.ProgramCounterDemo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public int calc();
    Code:
       0: bipush        100
       2: istore_1
       3: sipush        200
       6: istore_2
       7: sipush        300
      10: istore_3
      11: iload_1
      12: iload_2
      13: iadd
      14: iload_3
      15: imul
      16: ireturn
}

我们程序运行过程中计数器中改变的只是值,而不会随着程序的运行需要更大的空间,也就不会发生溢出情况。

举例理解程序计数器

说线程恢复等基础功能都需要依赖这个程序计数器来完成,首先我们得知道:

线程是CPU 最小的调度单元 Java 虚拟机的多线程是通过切换线程并分配处理器执行时间的方式来实现的,在任何一个确定的时间,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令 当A 线程先向处理器发出指令,但当执行到中途一半时,B线程过来执行,且优先级高,此时处理器将A 挂起,B 执行,当B 执行结束需要唤醒A 同时得知道A 的执行位置,就可以查看线程A 中的计数器指令

为什么执行的是native 方法时,为undefined

由上我们知道计数器记录的字节码指令地址,但是native 本地(如:System.currentTimeMillis()/ public static native long currentTimeMillis();)方法是大多是通过C实现并未编译成需要执行的字节码指令所以在计数器中当然是空(undefined)

Java虚拟机栈(Java Virtual Machine Stacks)

虚拟机栈出现的背景

由于跨平台性的设计,Java 的指令都是根据栈来设计的。不同平台 CPU 架构不同,所以不能设计为基于寄存器的。

优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

Java 虚拟机栈是什么?

Java 虚拟机栈(Java Virtual Machine Stack),早期也叫 Java 栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的 Java 方法调用,是线程私有的。

JAVA虚拟机栈(Java Virtual Machine Stacks)是一块线程私有的内存空间,每个线程有一个私有的栈,随着线程的创建而创建,其生命周期与线程同进同退。如果说Java堆和程序数据密切相关,那么Java栈就是和线程执行密切相关的。线程执行的基本行为是函数调用,每次函数调用的数据都是通过Java栈传递的。

Java栈与数据结构中的栈有着类似的含义,它是一块先进后出的数据结构,只支持出栈和入栈两种操作。在Java栈中保存的主要内容为栈帧。每一次函数调用,都会有一个对应的栈帧被压入Java栈,每一次函数调用结束,都会有一个栈帧被弹出Java栈。所有的的栈帧都出栈后,线程也就完成了使命。

当函数返回时,栈帧从Java栈中被弹出。Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。

在一个栈帧中,至少要包含局部变量表、操作数栈和帧数据区几部分。

提示: 由于每次函数调用都会生成对应的栈帧,从而占用一定的栈空间。因此,如果栈空间不足,那么函数调用自然无法继续进行。当请求的栈深度大于最大可用栈深度时,系统就会抛出StackOverflowError栈溢出错误。

Java虚拟机提供了参数-Xss来指定线程的最大栈空间,这个参数也直接决定了函数调用的最大深度。

如图所示:

    [函数4]         [栈帧4]
    
    [函数3]         [栈帧3]
                                  [局部变量表]
    [函数2]         [栈帧2]     =》 [操作数栈]
                                  [帧数据区]                        
    [函数1]         [栈帧1]

函数1对应栈帧1,函数2对应栈帧2,依此类推。函数1中调用函数2,函数2中调用函数3,函数3中调用函数4。当函数1被调用时,栈帧1入栈;当函数2被调用时,栈帧2入栈;当函数3被调用时,栈帧3入栈;当函数4被调用时,栈帧4入栈。当前正在执行的函数所对应的帧就是当前的帧(位于栈顶),它保存着当前函数的局部变量、中间运算结果等数据。

栈的存储单位

对于每一个线程,JVM 都会在线程被创建的时候,创建一个单独的栈。也就是说虚拟机栈的生命周期和线程是一致,并且是线程私有的。除了Native方法以外,Java方法都是通过Java 虚拟机栈来实现调用和执行过程的(需要程序技术器、堆、元空间内数据的配合)。所以Java虚拟机栈是虚拟机执行引擎的核心之一。而Java虚拟机栈中出栈入栈的元素就称为「栈帧」。

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

每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

在活动线程中, 只有位于栈顶的帧才是有效的, 称为 当前栈帧 。正在执行的方法称为 当前方法 。在执行引擎运行时, 所有指令都只能针对当前栈帧进行操作。而 StackOverflowError 表示请求的 栈溢出 , 导致内存耗尽, 通常出现在递归方法中。

虚拟机栈通过pop和push的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会跳转到另一个栈帧上。在执行的过程中,如果出现了异常,会进行异常回溯,返回地址通过异常处理表确定。

栈运行原理

JVM 直接对 Java 栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”/“后进先出”原则。 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。

不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。

/**
 *
 * 方法的结束方式分为两种:① 正常结束,以return为代表  ② 方法执行中出现未捕获处理的异常,以抛出异常的方式结束
 *
 */
public class StackFrameTest {
    public static void main(String[] args) {
        try {
            StackFrameTest test = new StackFrameTest();
            test.method1();

        } catch (Exception e) {
            e.printStackTrace();
        }

        System.out.println("main()正常结束");

    }

    public void method1(){
        System.out.println("method1()开始执行...");
        method2();
        System.out.println("method1()执行结束...");
}

    public int method2() {
        System.out.println("method2()开始执行...");
        int i = 10;
        int m = (int) method3();
        System.out.println("method2()即将结束...");
        return i + m;
    }

    public double method3() {
        System.out.println("method3()开始执行...");
        double j = 20.0;
        System.out.println("method3()即将结束...");
        return j;
    }

}

栈帧的内部结构

每个栈帧中存储着:

  • 局部变量表(Local Variables)
  • 操作数栈(operand Stack)(或表达式栈)
  • 动态链接(DynamicLinking)(或指向运行时常量池的方法引用)
  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
  • 一些附加信息 并行每个线程下的栈都是私有的,因此每个线程都有自己各自的栈,并且每个栈里面都有很多栈帧,栈帧的大小主要由局部变量表 和 操作数栈决定的

    局部变量表

    局部变量表是栈帧的重要组成部分,局部变量表就是 存放方法参数和方法内部定义的局部变量的区域 。它用于保存函数的参数和局部变量,局部变量表中的变量只在当前函数调用中有效(因为局部变量存在于方法对应的栈帧上,所以工作空间只有方法级别),当函数调用结束后,函数栈帧销毁,局部变量表就会销毁。

局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小 。 这里直接上代码,更好理解。

publicint test(int a, int b) {
    Object obj = newObject();
    return a + b;
}

如果局部变量是Java的8种基本基本数据类型,则存在局部变量表中,如果是引用类型。如new出来的String,局部变量表中存的是引用,而实例在堆中。

由于局部变量表在栈帧之中,因此,如果函数的参数和局部变量较多,会使局部变量表膨胀,从而每一次函数调用就会占用更多的栈空间,最终导致嵌套调用的次数变少。所以减少函数的参数和局部变量就可以减少栈空间的使用。

局部变量表也被称之为局部变量数组或本地变量表

  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。
  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的 Code 属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
  • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。 补充: 栈的大小决定方法嵌套的次数,也就是栈帧的多少,栈帧的大小由局部变量表决定.

关于 Slot 的理解

局部变量表,最基本的存储单元是 Slot(变量槽) 参数值的存放总是在局部变量数组的 index 0 开始,到数组长度-1 的索引结束。 局部变量表中存放编译期可知的各种基本数据类型(8 种),引用类型(reference),returnAddress 类型的变量。 在局部变量表里,32 位以内的类型只占用一个 slot(包括 returnAddress 类型),64 位的类型(long 和 double)占用两个 slot。 byte、short、char 在存储前被转换为 int,boolean 也被转换为 int,0 表示 false,非 0 表示 true。 long和double则占据两个Slot JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 slot 上 如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可。(比如:访问 long 或 double 类型变量) 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 slot 处,其余的参数按照参数表顺序继续排列。

Slot 的重复利用

栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

静态变量与局部变量的对比

参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。

我们知道类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。

和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。

public void test(){
    int i;
    System. out. println(i);//System.out.println(num);//错误信息:Variable 'num' might not have been initialized
}

这样的代码是错误的,没有赋值不能够使用。

补充:变量的分类:

按照数据类型分:① 基本数据类型 ② 引用数据类型 按照在类中声明的位置分: 成员变量:在使用前,都经历过默认初始化赋值. 类变量(静态变量): linking的prepare阶段:给类变量默认赋值 —> initial阶段:给类变量显式赋值即静态代码块赋值 实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值 局部变量:在使用前,必须要进行显式赋值的!否则,编译不通过 补充说明 在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

操作数栈(Operand Stack)

操作数栈(Operand Stack)看名字可以知道是一个栈结构。Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。当JVM为方法创建栈帧的时候,在栈帧 中为方法创建一个 操作数栈 ,保证方法内指令可以完成工作。

操作数栈也是栈帧中重要的内容之一,它主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的储存空间。 操作数栈也是一个先进后出的数据结构,支支持入栈和出栈操作,许多Java字节码指令都需要通过操作数栈进行参数传递。 每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的 操作数栈,也可以称之为表达式栈(Expression Stack) 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)

某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈 比如:执行复制、交换、求和等操作

public void testAddOperation(){
    byte i = 15;
    int j = 8;
    int k = i + j;
}
public void testAddOperation();
    Code:
    0: bipush 15
    2: istore_1
    3: bipush 8
    5: istore_2
    6:iload_1
    7:iload_2
    8:iadd
    9:istore_3
    10:return

操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。

操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。

每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性中,为 max_stack 的值。

栈中的任何一个元素都是可以任意的 Java 数据类型

  • 32bit 的类型占用一个栈单位深度
  • 64bit 的类型占用两个栈单位深度 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问

如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。

另外,我们说 Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。

备注:操作数栈和局部变量表的底层都是数组,所以对于double和long类型数据需要两个单位存储.

public void testAddOperation() {
    byte i = 15;
    int j = 8;
    int k = i + j;
}
public void testAddoperation();        
    Code:    
     0 bipush 15
     2 istore_1
     3 bipush 8
     5 istore_2
     6 iload_1
     7 iload_2
     8 iadd
     9 istore_3
    10 return

操作栈是一个初始状态为空的桶式结构栈。在方法执行过程中,会有各种指令往栈中写入和提取信息。JVM的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的stack属性中,下面就通过一个例子来说明下操作栈与局部变量表的交互:

public int add() {  
    int x = 10;  
    int y = 20;  
    int z = x + y;  
  
    return z;  
}

字节码操作顺序如下:

public int add();  
  Code:  
     0: bipush        10 // 常量 10 压入操作栈  
     2: istore_1     // 并保存到局部变量表的 slot_1 中  (第 1 处)  
     3: bipush        20 // 常量 20 压入操作栈  
     5: istore_2     // 并保存到局部变量表的 slot_2 中  
     6: iload_1      // 把局部变量表的 slot_1 元素(int x)压入操作栈  
     7: iload_2      // 把局部变量表的 slot_2 元素(int y)压入操作栈  
     8: iadd      // 把上方的两个数都取出来,在 CPU 里加一下,并压回操作栈的栈顶  
     9: istore_3     // 把栈顶的结果存储到局部变量表的 slot_3 中  
    10: iload_3  
    11: ireturn      // 返回栈顶元素值  

第 1 处说明:局部变量表就像一个快递柜,有着很多的柜子,依次编号为1,2,3,…,n,字节码指令 istore_1 就代表打开了 1 号柜子,再把栈顶中的值 10 存进去。栈就好如一个桶,任何时候只能对桶口的元素进行操作,所以数据只能在栈顶进行存取。部分指令可以直接在柜子里面直接进行,比如 iinc指令,直接对抽屉里的数值进行 +1操作。

动态连接

每个栈帧中包含一个在常量池中 对当前方法的引用 , 目的是 支持方法调用过程的动态连接 。

栈顶缓存技术(Top Of Stack Cashing)技术

前面提过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。

由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM 的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。

寄存器的优点:指令更少,执行速度更快

帧数据区 或者 动态链接(Dynamic Linking)

动态链接、方法返回地址、附加信息 : 有些地方被称为帧数据区。每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokedynamic 指令

在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 class 文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。

除了局部变量表和操作数栈,Java栈帧还需要一些数据来支持常量池解析,正常方法返回和异常处理,大部分Java字节码指令需要进行常量池访问,帧数据区中保存着访问常量池的指针,方便程序访问常量池。

此外,当函数返回或者出现异常的时,虚拟机必须恢复调用者函数的栈帧,并让调用者继续执行,对于异常处理,虚拟机必须有一个异常处理表,方便在发生异常的时候找到处理异常的代码,因此异常处理表也是帧数据区中重要的一部分。

所以帧数据区中存储访问常量池的指针和异常处理表(记录方法出现异常时,异常处理代码的位置)

public class DynamicLinkingTest {

    int num = 10;

    public void methodA(){
        System.out.println("methodA()....");
    }

    public void methodB(){
        System.out.println("methodB()....");

        methodA();

        num++;
    }
}

为什么需要运行时常量池呢?

常量池的作用:就是为了提供一些符号和常量,便于指令的识别. 也便于变量或者方法引用的多次引用.另外字节码文件中需要很多数据的支持,通常这些数据很大,我们不能直接保存在字节码中,所以我们通过符号引用相关的结构

方法的调用:解析与分配

在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关

静态链接

当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接

动态链接

如果被调用的方法在编译期无法被确定下来,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。 静态链接和动态链接不是名词,而是动词,这是理解的关键。 对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

早期绑定

早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。

晚期绑定

如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。 随着高级语言的横空出世,类似于 Java 一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类的编程语言具备多态特征,那么自然也就具备早期绑定和晚期绑定两种绑定方式。 Java 中任何一个普通的方法其实都具备虚函数的特征,它们相当于 C++语言中的虚函数(C++中则需要使用关键字 virtual 来显式定义)。如果在 Java 程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字 final 来标记这个方法。

/**
 * 说明早期绑定和晚期绑定的例子
 */
class Animal{

    public void eat(){
        System.out.println("动物进食");
    }
}
interface Huntable{
    void hunt();
}
class Dog extends Animal implements Huntable{
    @Override
    public void eat() {
        System.out.println("狗吃骨头");
    }

    @Override
    public void hunt() {
        System.out.println("捕食耗子,多管闲事");
    }
}

class Cat extends Animal implements Huntable{

    public Cat(){
        super();//表现为:早期绑定
    }

    public Cat(String name){
        this();//表现为:早期绑定
    }

    @Override
    public void eat() {
        super.eat();//表现为:早期绑定
        System.out.println("猫吃鱼");
    }

    @Override
    public void hunt() {
        System.out.println("捕食耗子,天经地义");
    }
}
public class AnimalTest {
    public void showAnimal(Animal animal){
        animal.eat();//表现为:晚期绑定
    }
    public void showHunt(Huntable h){
        h.hunt();//表现为:晚期绑定
    }
}

虚方法和非虚方法

如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。 静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法。其他方法称为虚方法。 虚拟机中提供了以下几条方法调用指令: 普通调用指令:

  • invokestatic:调用静态方法,解析阶段确定唯一方法版本
  • invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
  • invokevirtual:调用所有虚方法
  • invokeinterface:调用接口方法 ```java /**
  • 解析调用中非虚方法、虚方法的测试 *
  • invokestatic指令和invokespecial指令调用的方法称为非虚方法 */ class Father { public Father() { System.out.println(“father的构造器”); }

    public static void showStatic(String str) { System.out.println(“father “ + str); }

    public final void showFinal() { System.out.println(“father show final”); }

    public void showCommon() { System.out.println(“father 普通方法”); } }

public class Son extends Father { public Son() { //invokespecial super(); } public Son(int age) { //invokespecial this(); } //不是重写的父类的静态方法,因为静态方法不能被重写! public static void showStatic(String str) { System.out.println(“son “ + str); } private void showPrivate(String str) { System.out.println(“son private” + str); }

public void show() {
    //invokestatic
    showStatic("atguigu.com");
    //invokestatic
    super.showStatic("good!");
    //invokespecial
    showPrivate("hello!");
    //invokespecial
    super.showCommon();

    //invokespecial:非虚方法,编译期便可知道.
    super.showFinal();
    //invokevirtual
    showFinal();//因为此方法声明有final,不能被子类重写(但是可以直接调用啊.),所以也认为此方法是非虚方法。备注:虽然invokevirtual一般调用的是虚方法,但是 final修饰的方法例外.

    //虚方法如下:
    //invokevirtual
    showCommon();//没有super调用,且当前类可能重写该方法.所以无法确定.

    //info 在编译期间无法确定下来.首先它不属于父类的方法,是子类中额外加入的功能方法.如果Son的子类对其进行重写,可以构成多态.
    //例:如果存在一个类继承了Son,那具体用的是Son的info还是Son子类的info,编译器就确定不了了
    info();

    MethodInterface in = null;
    //invokeinterface
    in.methodA();
}

public void info(){

}

public void display(Father f){
    f.showCommon();
}

public static void main(String[] args) {
    Son so = new Son();
    so.show();
} }

interface MethodInterface{ void methodA(); }

#### 动态调用指令:
- invokedynamic:动态解析出需要调用的方法,然后执行
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而 invokedynamic 指令则支持由用户确定方法版本。其中 invokestatic 指令和 invokespecial 指令调用的方法称为非虚方法,其余的(fina1 修饰的除外)称为虚方法。
  关于 invokedynamic 指令

JVM 字节码指令集一直比较稳定,一直到 Java7 中才增加了一个 invokedynamic 指令,这是Java 为了实现「动态类型语言」支持而做的一种改进。(Java本身还是一种静态类型语言)
但是在 Java7 中并没有提供直接生成 invokedynamic 指令的方法,需要借助 ASM 这种底层字节码工具来产生 invokedynamic 指令。直到 Java8 的 Lambda 表达式的出现,invokedynamic 指令的生成,在 Java 中才有了直接的生成方式。
Java7 中增加的动态语言类型支持的本质是对 Java 虚拟机规范的修改,而不是对 Java 语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在 Java 平台的动态语言的编译器。
```java
/**
 * 体会invokedynamic指令
 */
@FunctionalInterface
interface Func {
    public boolean func(String str);
}

public class Lambda {
    public void lambda(Func func) {
        return;
    }

    public static void main(String[] args) {
        Lambda lambda = new Lambda();

        Func func = s -> {
            return true;
        };

        lambda.lambda(func);

        lambda.lambda(s -> {
            return true;
        });
    }
}

动态类型语言和静态类型语言

动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。

说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。

Java:String info = “lxylovejava”; // info = 123 这样就会报错. info被赋予String的类型信息.
JS:var name = “lxy123456”; var name = 10;//两种写法都可以,因为是运行期根据值确定类型的 name没有类型信息
Python: info = 130.5; //Python更牛叉,类型声明都不需要了…

方法重写的本质

Java 语言中方法重写的本质:

找到操作数栈顶的第一个元素所执行的对象的实际类型,记作 C。(也就是说重写会去操作数栈栈顶获取到对象的引用类型,也就是符号引用,通过这个对象的符号引用就可以在堆中找到这个对象.) 如果在类型 C 中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回 java.lang.IllegalAccessError 异常。 否则,按照继承关系从下往上依次对 C 的各个父类进行第 2 步的搜索和验证过程。 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodsError 异常。 总结: 在编译阶段,编译器只知道对象的静态类型(类),而不知道实际类型,因此只能在class文件中确定调用父类的方法。 在执行过程中,它将判断对象的实际类型。如果实际类型实现了这种方法,它将被直接调用。如果没有实现,它将根据继承关系从下到上进行检索。只要检索到,它将被调用。如果没有检索到,它将被抛弃。继续向上层寻找.如果最后没有找到,则说明抽象方法没有被实现,则抛出AbstractMethodsError IllegalAccessError 介绍

程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。

方法的调用:虚方法表

在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派(invokevirtual)的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM 采用在类的方法区建立一个虚方法表 (virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。 每个类中都有一个虚方法表,表中存放着各个方法的实际入口。(每次调用方法,直接从虚方法表中找各个方法的是哪个类型的信息)

虚方法表是什么时候被创建的呢? 虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法表也初始化完毕。

方法返回地址(return address)

存放调用该方法的 pc 寄存器的值。一个方法的结束,有两种方式:

  • 正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、 IRETURN、 ARETURN等
  • 出现未处理的异常,非正常退出 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的 pc 计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

无论何种退出情况,都将返回至方法当前 被 调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:

  • 返回值压入上层调用栈帧
  • 异常信息抛给 能够处理 的栈帧
  • PC 计数器指向方法调用后的下一条指令

总结: 当执行到A调用B的方法时,pc记录的是A的下一条指令,当B的栈帧被创建并作为当前栈帧时同时也获取到pc中的值并生成了返回地址,当B方法return,pc的值就是返回地址 (注意:返回地址和返回值是两回事哦) 方法返回地址记录的是当前栈帧的上一级栈帧的执行位置 而pc寄存器存储的永远是当前栈帧的执行位置 当一个方法开始执行后,只有两种方式可以退出这个方法:

执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口; 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。 在字节码指令中,返回指令包含 ireturn(当返回值是 boolean,byte,char,short 和 int 类型时使用),lreturn(Long 类型),freturn(Float 类型),dreturn(Double 类型),areturn。另外还有一个 return 指令声明为 void 的方法,实例初始化方法,类和接口的初始化方法使用。 在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口。

/**
 *
 * 返回指令包含ireturn(当返回值是boolean、byte、char、short和int类型时使用)、
 * lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法、
 * 实例初始化方法、类和接口的初始化方法使用。
 *
 */
public class ReturnAddressTest {
    
    //构造方法返回指令:return
    
    public boolean methodBoolean() {
        return false;//ireturn;
    }

    public byte methodByte() {
        return 0;//ireturn;
    }

    public short methodShort() {
        return 0;//ireturn;
    }

    public char methodChar() {
        return 'a';//ireturn;
    }

    public int methodInt() {
        return 0;//ireturn;
    }

    public long methodLong() {
        return 0L;//lreturn;
    }

    public float methodFloat() {
        return 0.0f;//freturn;
    }

    public double methodDouble() {
        return 0.0;//dreturn;
    }

    public String methodString() {
        return null;//areturn;
    }

    public Date methodDate() {
        return null;//areturn;
    }

    public void methodVoid() {//return;

    }

    static {
        int i = 10;
    }


    //
    public void method2() {

        methodVoid();

        try {
            method1();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void method1() throws IOException {
        FileReader fis = new FileReader("atguigu.txt");
        char[] cBuffer = new char[1024];
        int len;
        while ((len = fis.read(cBuffer)) != -1) {
            String str = new String(cBuffer, 0, len);
            System.out.println(str);
        }
        fis.close();
    }
}

方法执行过程中,抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码,如下图所示:

Exception  table:
from  to   target  type  
4    16    19     any  //字节码4-16行出任何问题了,按照19行的解决方法处理.
19    21  19

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置 PC 寄存器值(用返回地址)等,让调用者方法继续执行下去。 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。 注意: 返回地址和返回值是两回事,当前方法正常执行两者都有,既能接续上层方法又向其传递返回值;当前方法异常且未处理两者都没,此时能否接续上层方法的依据是上层方法的异常表. 每个方法对应一个异常处理表,方法对应着栈帧,栈帧存在于Java虚拟机栈,Java虚拟机栈和本地方法栈两者是不一样的.

一些附加信息

栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。

栈上分配

栈上分配时Java虚拟机提供的一项优化技术,他的基本思想,对于那些线程私有的对象(这里只不可能被其他线程访问的对象),可以将他们打散分配在栈上,而不是分配在堆上,分配栈栈上的好处时可以在函数调用结束后自行销毁,而不需要垃圾回收的介入,从而提高系统的性能

逃逸分析

栈上分配的一个技术基础就是进行逃逸分析。逃逸分析的目的是判断对象的作用域是否有可能逃逸除函数体。

private static user u;

public static void alloc(){

	u  = new User();

 	u.id = 5;

	u.name = "Vstone"

}

对象User u 是类的成员变量,该字段有可能被任和线程访问,因此属于逃逸对象,而以下的代码片段显示了一个非逃逸的对象:

public static void alloc(){

	User u = new User();

	u.id = 5;

	u.name="Vstone";

}

在上述代码中,对象User以局部变量的形式存在,并且该对象没有被alloca()函数返回或者出现了任何形式的公开,因此它并未发生逃逸。所以者中情况下,虚拟机就有可能将User分配在栈上,而不是堆上。

如何启用栈上分配

server 在server模式下,才可以启用逃逸分析 -Xmx10m -Xms10m -XX:+DoEscapeAlalysis 启用逃逸分析 -XX:+PrintGC 打印GC日志 -XX:-UseTLAB 关闭TLAB -XX:+EliminateAllocations 开启标量替换(默认打开),允许将对象打散分配在栈上,比如对象拥有id和name两个字段,那么这两个字段会被视为两个独立的局部变量进行分配

本地方法栈(Native Method Stack)

本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

本地方法栈(Native Method Stacks)与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。在JVM规范中,并没有对本地方法栈的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。

简单地讲,一个 Native Method 是一个 Java 调用非 Java 代码的接囗。一个 Native Method 是这样一个 Java 方法:该方法的实现由非 Java 语言实现,比如 C。这个特征并非 Java 所特有,很多其它的编程语言都有这一机制,比如在 C++中,你可以用 extern “c” 告知 c++编译器去调用一个 c 的函数。

在定义一个 native method 时,并不提供实现体(有些像定义一个 Java interface),因为其实现体是由非 java 语言在外面实现的。本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++程序。

public class IHaveNatives {
    //native与abstract的区别?

    /**
     * native:表示有方法体,只不过方法体不是JAVA语言编写的
     * abstract:没有方法体.
     * 所以这两个修饰符不能够同时使用.
     */
    public native void Native1(int x);

    public native static long Native2();

    private native synchronized float Native3(Object o);

    native void Native4(int[] ary) throws Exception;

}

为什么使用 Native Method?

Java 使用起来非常方便,然而有些层次的任务用 Java 实现起来不容易,或者我们对程序的效率很在意时,问题就来了。

  • 与 Java 环境的交互 有时 Java 应用需要与 Java 外面的环境交互,这是本地方法存在的主要原因。你可以想想 Java 需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解 Java 应用之外的繁琐的细节。
  • 与操作系统的交互 JVM 支持着 Java 语言本身和运行时库,它是 Java 程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用 Java 实现了 jre 的与底层系统的交互,甚至 JVM 的一些部分就是用 c 写的。还有,如果我们要使用一些 Java 语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。
  • Sun’s Java Sun 的解释器是用 C 实现的,这使得它能像一些普通的 C 一样与外部交互。jre 大部分是用 Java 实现的,它也通过一些本地方法与外界交互。例如:类 java.lang.Thread 的 setPriority()方法是用 Java 实现的,但是它实现调用的是该类里的本地方法 setPriority()。这个本地方法是用 C 实现的,并被植入 JVM 内部,在 Windows 95 的平台上,这个本地方法最终将调用 Win32 setPriority() ApI。这是一个本地方法的具体实现由 JVM 直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被 JVM 调用。 现状

目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过 Java 程序驱动打印机或者 Java 系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用 Socket 通信,也可以使用 Web Service 等等,不多做介绍。

本地方法栈

Java 虚拟机栈于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用。

本地方法栈,也是线程私有的。

允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面和Java虚拟机栈是相同的)

如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常。 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么 Java 虚拟机将会抛出一个 OutOfMemoryError 异常。 本地方法是使用 C 语言实现的。(Java语言调用实现功能扩展.) 它的具体做法是 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载本地方法库。 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。 它甚至可以直接使用本地处理器中的寄存器 直接从本地内存的堆中分配任意数量的内存。 并不是所有的 JVM 都支持本地方法。因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果 JVM 产品不打算支持 native 方法,也可以无需实现本地方法栈。 在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一。

Java堆(Java Heap)

堆针对一个 JVM 进程来说是唯一的,也就是一个进程只有一个 JVM,但是进程包含多个线程,他们是共享同一堆空间的。 一句话:一个进程对应一个JVM实例,一个进程包含多个线程.一个进程中的多个线程共享堆空间,方法区.每个线程各自有一套自己的程序计数器,本地方法栈,虚拟机栈.

对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。

Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”(Garbage Collected Heap,幸好国内没翻译成“垃圾堆”)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。在本章中,我们仅仅针对内存区域的作用进行讨论,Java堆中的上述各个区域的分配、回收等细节将是第3章的主题。

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

堆内存(JAVA Heap)。是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。由于现代 VM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以细分为: 新生代(Eden区、From Survivor 区和 To Survivor 区)和老年代

堆区的介绍

根据Java虚拟机规范的规定, Java堆可以处于物理上不连续的内存空间中 ,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以在运行时动态地调整。

Java堆是垃圾收集器管理的主要区域,因此 很多时候也被称做“GC堆” 。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为: 新生代和老年代 。再细致一点的有 Eden空间、From Survivor空间、To Survivor空间 等。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。

如何设置堆内存大小

Java 堆区用于存储 Java 对象实例,那么堆的大小在 JVM 启动时就已经设定好了,大家可以通过选项”-Xmx”和”-Xms”来进行设置。

例如:通过设置如下参数,可以设定堆区的初始值和最大值,比如 -Xms256M-Xmx1024M,其中 -X这个字母代表它是JVM运行时参数, ms是 memory start的简称,中文意思就是内存初始值, mx 是 memory max的简称,意思就是最大内存。

-Xms用于表示堆区的起始内存,等价于-XX:InitialHeapSize -Xmx则用于表示堆区的最大内存,等价于-XX:MaxHeapSize 一旦堆区中的内存大小超过-Xmx所指定的最大内存时,将会抛出 OutOfMemoryError 异常。

通常会将-Xms 和-Xmx 两个参数配置相同的值,其目的是为了能够在 Java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。 值得注意的是,在通常情况下,服务器在运行过程中,堆空间不断地扩容与回缩,会形成不必要的系统压力 所以在线上生产环境中 JVM的 Xms和 Xmx会设置成同样大小,避免在GC 后调整堆大小时带来的额外压力。

堆内存细分

  • jdk7 新生代(Young Generation Space) +老年代(Tenure generation space)+永久区(Permanent space)。新生代又分为伊甸园区(Eden)+s1(Survlvor1)+s2(Survor2)
  • jdk8 新生代(Young Generation Space) +老年代(Tenure generation space)+元空间(Meta Space)。新生代又分为伊利园区(Eden)+s1(Survlvor1)+s2(Survor2)
  • 构成详述 Java堆进一步分可以分为年轻代和老年代,年轻代又可以分为伊甸园区(Eden),幸存者0(Survivor0)和幸存者1(Survivor1)空间,有时候也叫from区和to区。

堆区分为两大区:Young区和Old区,又称新生代和老年代。对象刚创建的时候,会被创建在新生代,到一定阶段之后会移送至老年代 ,如果创建了一个新生代无法容纳的新对象,那么这个新对象也可以创建到老年代。

新生代分为1个Eden区和2个S区,S代表Survivor。大部分的对象会在Eden区中生成,当Eden区没有足够的空间容纳新对象时,会触发Young Garbage Collection,即YGC。在Eden区进行垃圾清除时,它的策略是会把没有引用的对象直接给回收掉,还有引用的对象会被移送到Survivor区。

Survivor区有S0和S1两个内存空间,每次进行YGC的时候,会将存活的对象复制到未使用的那块内存空间,然后将当前正在使用的空间完全清除掉,再交换两个空间的使用状况。如果YGC要移送的对象Survivor区无法容纳,那么就会将该对象直接移交给老年代。

上面说了,到一定阶段的对象会移送到老年区,这是什么意思呢?每一个对象都有一个计数器,当每次进行YGC的时候,都会 +1。通过- XX:MAXTenuringThrehold参数可以配置当计数器的值到达某个阈值时,对象就会从新生代移送至老年代。

该参数的默认值为15,也就是说对象在Survivor区中的S0和S1内存空间交换的次数累加到15次之后,就会移送至老年代。如果参数配置为1,那么创建的对象就会直接移送至老年代。

如果Survivor区无法放下,或者创建了一个超大新对象,Eden和Old区都无法存放,就会触发Full Garbage Collection,即FGG,便再尝试放在Old区,如果还是容纳不了,就会抛出OOM异常。在不同的JVM实现及不同的回收机制中,堆内存的划分方式是不一样的。

堆的默认空间分配

命令行上执行如下命令,就可以查看当前JDK版本所有默认的JVM参数。

java -XX:+PrintFlagsFinal-version

对应的输出应该有几百行,我们这里去看和堆内存分配相关的两个参数

java -XX:+PrintFlagsFinal -version
[Global flags]
...
uintx InitialSurvivorRatio                      = 8
uintx NewRatio                                  = 2
...
java version "1.8.0_131"
Java(TM) SE Runtime Environment (build 1.8.0_131-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.131-b11, mixed mode)
  • -Xms:设置堆内存初始大小
  • -Xmx:设置堆内存最大值
  • -XX:InitialSurvivorRatio 新生代Eden/Survivor空间的初始比例
  • -XX:NewRatio Old区/Young区的内存比例
  • -XX:MaxTenuringThreshold:设置对象在新生代中存活的次数
  • -XX:PretenureSizeThreshold:设置超过指定大小的大对象直接分配在旧生代中

新生代相关参数**(注意:当新生代设置得太小时,也可能引发大对象直接分配到旧生代)

  • -Xmn:设置新生代内存大小
  • -XX:SurvivorRatio:设置Eden与Survivor空间的大小比例

因为新生代是由Eden + S0 + S1组成的,所以按照上述默认比例,如果eden区内存大小是40M,那么两个survivor区就是5M,整个young区就是50M,然后可以算出Old区内存大小是100M,堆区总大小就是150M。

默认情况下 初始内存大小:物理电脑内存大小 / 64 最大内存大小:物理电脑内存大小 / 4

/**
 * 1. 设置堆空间大小的参数
 * -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
 *      -X 是jvm的运行参数
 *      ms 是memory start
 * -Xmx 用来设置堆空间(年轻代+老年代)的最大内存大小
 *
 * 2. 默认堆空间的大小
 *    初始内存大小:物理电脑内存大小 / 64
 *             最大内存大小:物理电脑内存大小 / 4
 * 3. 手动设置:-Xms600m -Xmx600m
 *     开发中建议将初始堆内存和最大的堆内存设置成相同的值。(避免堆区的扩容和释放,降低性能消耗)
 *
 * 4. 查看设置的参数:方式一: jps   /  jstat -gc 进程id
 *                  方式二:-XX:+PrintGCDetails  //程序执行完之后打印
 */
public class HeapSpaceInitial {
    public static void main(String[] args) {

        //返回Java虚拟机中的堆内存总量
        long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
        //返回Java虚拟机试图使用的最大堆内存量
        long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;

        System.out.println("-Xms : " + initialMemory + "M");
        System.out.println("-Xmx : " + maxMemory + "M");

       System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
       System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");

        // try {
        //     Thread.sleep(1000000);
        // } catch (InterruptedException e) {
        //     e.printStackTrace();
        // }
    }
}

年轻代与老年代

存储在 JVM 中的 Java 对象可以被划分为两类:

一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与 JVM 的生命周期保持一致 Java 堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen)

其中年轻代又可以划分为 Eden 空间、Survivor0 空间和 Survivor1 空间(有时也叫做 from 区、to 区) 配置新生代与老年代在堆结构的占比。

默认-XX:NewRatio=2,表示新生代占 1,老年代占 2,新生代占整个堆的 1/3 可以修改-XX:NewRatio=4,表示新生代占 1,老年代占 4,新生代占整个堆的 1/5 在 HotSpot 中,Eden 空间和另外两个 survivor 空间缺省所占的比例是 8:1:1 (因为有个自适应的内存分配原则,实际测试6:1:1)

当然开发人员可以通过选项“-xx:SurvivorRatio”调整这个空间比例。比如-xx:SurvivorRatio=8

几乎所有的 Java 对象都是在 Eden 区被 new 出来的。绝大部分的 Java 对象的销毁都在新生代进行了。

IBM 公司的专门研究表明,新生代中 80%的对象都是“朝生夕死”的。 可以使用选项”-Xmn”设置新生代最大内存大小,这个参数一般使用默认值就可以了。

方法区(Method Area)

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

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、方法、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。

对于习惯在HotSpot虚拟机上开发、部署程序的开发者来说,很多人都更愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。对于其他虚拟机(如BEA JRockit、IBM J9等)来说是不存在永久代的概念的。原则上,如何实现方法区属于虚拟机实现细节,不受虚拟机规范约束,但使用永久代来实现方法区,现在看来并不是一个好主意,因为这样更容易遇到内存溢出问题(永久代有-XX:MaxPermSize的上限,J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB,就不会出现问题),而且有极少数方法(例如String.intern())会因这个原因导致不同虚拟机下有不同的表现。因此,对于HotSpot虚拟机,根据官方发布的路线图信息,现在也有放弃永久代并逐步改为采用Native Memory来实现方法区的规划了,在目前已经发布的JDK 1.7的HotSpot中,已经把原本放在永久代的字符串常量池移出。

Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是必要的。在Sun公司的BUG列表中,曾出现过的若干个严重的BUG就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

方法区(Method Area)用于存放虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

  • 又称之为:非堆(Non-Heap)或 永久区
  • 线程共享
  • 主要存储:类的类型信息、常量池(Runtime Constant Pool)、字段信息、方法信息、类变量和Class类的引用等
  • Java虚拟机规范规定:当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常

相关参数:

-XX:PermSize:设置Perm区的初始大小

-XX:MaxPermSize:设置Perm区的最大值

注意:

  • 在Java8中已经使用元空间来代替永久代,也就是在Java8中已经没有永久代了。类似-XX:MaxPermSize这些设置永久代内存大小的参数均已失效了。
    • 为什么元空间:类及相关的元数据的生命周期与类加载器的一致
      • 每个加载器有专门的存储空间
      • 只进行线性分配
      • 不会单独回收某个类
      • 省掉了GC扫描及压缩的时间
      • 元空间里的对象的位置是固定的
      • 如果GC发现某个类加载器不再存活了,会把相关的空间整个回收掉
  • JDK 1.7 的HotSpot中,将原本放在永久代的字符串常量池移出了

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小

  • -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值
  • -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。
  • -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
  • -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

方法区的基本理解

方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域。(当多个线程都需要调用某个类时,并且该类没有被加载,只需要其中一个线程加载即可) 方法区在 JVM 启动的时候被创建,并且它的实际的物理内存空间中和 Java 堆区一样都可以是不连续的。 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError: PermGen space​​​ 或者​​java.lang.OutOfMemoryError: Metaspace​​ 加载大量的第三方的 jar 包;Tomcat 部署的工程过多(30~50 个);大量动态的生成反射类 关闭 JVM 就会释放这个区域的内存。

HotSpot 中方法区的演进

在 jdk7 及以前,习惯上把方法区,称为永久代。jdk8 开始,使用元空间取代了永久代。本质上,方法区和永久代并不等价。仅是对 hotspot 而言的。《Java 虚拟机规范》对如何实现方法区,不做统一要求。例如:BEA JRockit / IBM J9 中不存在永久代的概念。 补充: 可以把方法区看成接口,永久代或者元空间看成其实现 元空间是方法区的一种实现. 方法区是一种规范,永久代和元空间是两种不同的实现方式 元空间不在虚拟机设置的内存中,而是使用了本地内存 现在来看,当年使用永久代,不是好的 idea。导致 Java 程序更容易 OOM(超过-XX:MaxPermsize上限)

设置方法区大小与 OOM

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

jdk7 及以前

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

JDK8 以后

元数据区大小可以使用参数-XX:MetaspaceSize 和-XX:MaxMetaspaceSize指定 默认值依赖于平台。windows 下,-XX:MetaspaceSize=21M -XX:MaxMetaspaceSize=-1//即没有限制。 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace -XX:MetaspaceSize:设置初始的元空间大小。对于一个 64 位的服务器端 JVM 来说,其默认的-XX:MetaspaceSize值为 21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于 GC 后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到 Full GC 多次调用。为了避免频繁地 GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。

/**
 *  测试设置方法区大小参数的默认值
 *
 *  jdk7及以前:
 *  -XX:PermSize=100m -XX:MaxPermSize=100m
 *
 *  jdk8及以后:
 *  -XX:MetaspaceSize=100m  -XX:MaxMetaspaceSize=100m
 */
public class MethodAreaDemo {
    public static void main(String[] args) {
        System.out.println("start...");
       try {
           Thread.sleep(1000000);
       } catch (InterruptedException e) {
           e.printStackTrace();
       }

        System.out.println("end...");
    }
}
/**
 * jdk6/7中:
 * -XX:PermSize=10m -XX:MaxPermSize=10m
 *
 * jdk8中:
 * -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
 *
 */
public class OOMTest extends ClassLoader {
    public static void main(String[] args) {
        int j = 0;
        try {
            OOMTest test = new OOMTest();
            for (int i = 0; i < 10000; i++) {
                //创建ClassWriter对象,用于生成类的二进制字节码
                ClassWriter classWriter = new ClassWriter(0);
                //指明版本号,修饰符,类名,包名,父类,接口
                classWriter.visit(Opcodes.V1_6, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
                //返回byte[]
                byte[] code = classWriter.toByteArray();
                //类的加载
                test.defineClass("Class" + i, code, 0, code.length);//Class对象
                j++;
            }
        } finally {
            System.out.println(j);
        }
    }
}

方法区(Method Area)存储什么?

《深入理解 Java 虚拟机》书中对方法区(Method Area)存储内容描述如下:

它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

类型信息

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

这个类型的完整有效名称(全名=包名.类名) 这个类型直接父类的完整有效名(对于 interface 或是 java.lang.object,都没有父类) 这个类型的修饰符(public,abstract,final 的某个子集) 这个类型直接接口的一个有序列表 问题:方法区包含域信息,也就是包含成员变量?

只是存放了成员变量的信息,具体的值会和对象一起放在堆中。类变量会存放在方法区

域(Field)信息

JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。

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

方法(Method)信息

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

  • 方法名称
  • 方法的返回类型(或 void)
  • 方法参数的数量和类型(按顺序)
  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
  • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外)
  • 异常表(abstract 和 native 方法除外) 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引 备注:以上代码皆来源于MethodInnerStrucTest 类的字节码文件, 可以通过 javap -v -p Met hodInnerStrucTest.class > test.txt 命令,将字节码导出到文件中,便于观察.

字节码文件中存放着类文件的类型详细信息.(如上所示) 类加载器将字节码文件加载到方法区后. 方法区中除了记录着类的详细信息,还包括该类是被哪个类加载器加载的等, 同时类加载器(也是类)也会记录加载了哪些类.

运行时常量池(Runtime Constant Pool)

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

Java虚拟机对Class文件每一部分(自然也包括常量池)的格式都有严格规定,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,Java虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。不过,一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

JVM常量池

JVM常量池主要分为Class文件常量池、运行时常量池、全局字符串常量池、以及基本类型包装类对象常量池

  • Class文件常量池:class文件是一组以字节为单位的二进制数据流,在java代码的编译期间,我们编写的java文件就被编译为.class文件格式的二进制数据存放在磁盘中,其中就包括class文件常量池。
  • 运行时常量池:运行时常量池相对于class常量池一大特征就是具有动态性,java规范并不要求常量只能在运行时才产生,也就是说运行时常量池的内容并不全部来自class常量池,在运行时可以通过代码生成常量并将其放入运行时常量池中,这种特性被用的最多的就是String.intern()。
  • 全局字符串常量池:字符串常量池是JVM所维护的一个字符串实例的引用表,在HotSpot VM中,它是一个叫做StringTable的全局表。在字符串常量池中维护的是字符串实例的引用,底层C++实现就是一个Hashtable。这些被维护的引用所指的字符串实例,被称作”被驻留的字符串”或”interned string”或通常所说的”进入了字符串常量池的字符串”。
  • 基本类型包装类对象常量池:java中基本类型的包装类的大部分都实现了常量池技术,这些类是Byte,Short,Integer,Long,Character,Boolean,另外两种浮点数类型的包装类则没有实现。另外上面这5种整型的包装类也只是在对应值小于等于127时才可使用对象池,也即对象不负责创建和管理大于127的这些类的对象。

运行时常量池(Runtime Constant Pool)是方法区的一部分。 常量池表(Constant Pool Table)是 Class 文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。 字节码中的常量池存放的都是符号引用,链接解析阶段将符号引用转化为直接引用.所以方法区的运行区常量池里面存放的都是直接引用. 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。 JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。 运行时常量池,相对于 Class 文件常量池的另一重要特征是:具备动态性。 以String.intern()为例,编译器会将字符串添加到常量池中(StringTable维护),并返回指向该常量的引用 运行时常量池类似于传统编程语言中的符号表(symboltable),但是它所包含的数据却比符号表要更加丰富一些。 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛 OutOfMemoryError 异常。 深入解析动态性: 动态性是运行时常量池可以动态的往里面添加本来没有的信息 而常量池,只能放代码中存在的信息,在编译期间,就确定了,不会再得到更改 运行时常量池,则可以通过代码动态的往里面塞信息。

public class MethodAreaDemo {
    public static void main(String args[]) {
        int x = 500;
        int y = 100;
        int a = x / y;
        int b = 50;
        System.out.println(a+b);
    }
}

五个本地变量,所以本地变量表尾5. args存在下标为0的位置上

运行时常量池 VS 常量池

方法区,内部包含了运行时常量池 字节码文件,内部包含了常量池 要弄清楚方法区,需要理解清楚 ClassFile,因为加载类的信息都在方法区。 要弄清楚方法区的运行时常量池,需要理解清楚 ClassFile 中的常量池。 官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html 一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外,还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用. 也就是说一个方法的具体实现细节都藏在常量池中 举例: 常量池中的信息相当于炒菜的基本原料,每个方法相当于一道道菜.每道菜都可能会用到那些基本原材料.对应到代码中,方法的字节码会用到常量池中的内容 为什么需要常量池? 一个 java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍。

public class SimpleClass {
    public void sayHello() {
        System.out.println("hello");
    }
}

虽然只有 194 字节,但是里面却使用了 String、System、PrintStream 及 Object 等结构。这里的代码量其实很少了,如果代码多的话,引用的结构将会更多,这里就需要用到常量池了。 常量池、可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型

StringTable 为什么要调整位置?

jdk7 中将 StringTable 放到了堆空间中。因为永久代的回收效率很低,在 full gc 的时候才会触发。而 full gc 是老年代的空间不足、永久代不足时才会触发。

这就导致 StringTable 回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

静态变量存放在那里?

/**
 * 静态引用对应的对象实体始终都存在堆空间
 * jdk7:
 * -Xms200m -Xmx200m -XX:PermSize=300m -XX:MaxPermSize=300m -XX:+PrintGCDetails
 * jdk8:
 * -Xms200m -Xmx200m-XX:MetaspaceSize=300m -XX:MaxMetaspaceSize=300m -XX:+PrintGCDetails
 */
public class StaticFieldTest {
    private static byte[] arr = new byte[1024 * 1024 * 100];
    public static void main(String[] args) {
        System.out.println(StaticFieldTest.arr);

        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e){
            e.printStackTrace();
        }
    }
}

结论:静态引用对应的对象实体始终都存在堆空间

/**
 * 《深入理解Java虚拟机》中的案例:
 * staticObj、instanceObj、localObj存放在哪里?
 */
public class StaticObjTest {
    static class Test {
        static ObjectHolder staticObj = new ObjectHolder();
        ObjectHolder instanceObj = new ObjectHolder();

        void foo() {
            ObjectHolder localObj = new ObjectHolder();
            System.out.println("done");
        }
    }

    private static class ObjectHolder {
    }

    public static void main(String[] args) {
        Test test = new StaticObjTest.Test();
        test.foo();
    }
}

使用 JHSDB 工具进行分析,这里细节略掉三个变量的引用:staticobj 随着 Test 的类型信息存放在方法区,instanceobj 随着 Test 的对象实例存放在 Java 堆,localobject 则是存放在 foo()方法栈帧的局部变量表中。 测试发现:三个对象的数据在内存中的地址都落在 Eden 区范围内,所以结论:只要是对象实例必然会在 Java 堆中分配。

接着,找到了一个引用该 staticobj 对象的地方,是在一个 java.lang.Class 的实例里,并且给出了这个实例的地址,通过 Inspector 查看该对象实例,可以清楚看到这确实是一个 java.lang.Class 类型的对象实例,里面有一个名为 staticobj 的实例字段:

从《Java 虚拟机规范》所定义的概念模型来看,所有 Class 相关的信息都应该存放在方法区之中,但方法区该如何实现,《Java 虚拟机规范》并未做出规定,这就成了一件允许不同虚拟机自己灵活把握的事情。JDK7 及其以后版本的 HotSpot 虚拟机选择把静态变量与类型在 Java 语言一端的映射 class 对象存放在一起,存储于 Java 堆之中,从我们的实验中也明确验证了这一点

备注:前面已经说过了,没有栈上分配这码事,如果未逃逸,做标量替换,把一个对象分解为多个成员变量,也还是存在堆上。

方法区的垃圾回收

有些人认为方法区(如 Hotspot 虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java 虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK11 时期的 zGC 收集器就不支持类卸载)。

一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前 sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄漏。

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。

先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:

类和接口的全限定名 字段的名称和描述符 方法的名称和描述符 HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。

回收废弃常量与回收 Java 堆中的对象非常类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的。 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。 Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot 虚拟机提供了​​-Xnoclassgc​​​参数进行控制,还可以使用​​-verbose:class​​​ 以及 ​​-XX:+TraceClassLoading​​​、​​-XX:+TraceClassUnLoading​​查看类加载和卸载信息

在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

直接内存(Direct Memory)

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。

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

显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

Metaspace 元空间

在JDK8版本中,元空间的前身Pern区已经被淘汰。在JDK7及之前的版本中,Hotspot还有Pern区,翻译为永久代,在启动时就已经确定了大小,难以进行调优,并且只有FGC时会移动类元信息。不同于之前版本的Pern(永久代),JDK8的元空间已经在本地内存中进行分配,并且,Pern区中的所有内容中字符串常量移至堆内存,其他内容也包括了类元信息、字段、静态属性、方法、常量等等都移至元空间内。

在 HotSpot JVM 中, 永久代( ≈ 方法区)中用于存放类和方法的元数据以及常量池 ,比如 Class和 Method。每当一个类初次被加载的时候,它的元数据都会放到永久代中。 永久代是有大小限制的,因此如果加载的类太多,很有可能导致永久代内存溢出,即万恶的 java.lang.OutOfMemoryError:PermGen,为此我们不得不对虚拟机做调优。

那么,Java 8 中 PermGen 为什么被移出 HotSpot JVM 了?(详见:JEP 122: Remove the Permanent Generation):

  • 由于 PermGen 内存经常会溢出,引发恼人的 java.lang.OutOfMemoryError:PermGen,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM
  • 移除 PermGen 可以促进 HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 没有永久代。

根据上面的各种原因,PermGen 最终被移除, 方法区移至 Metaspace,字符串常量池移至堆区 。 准确来说,Perm 区中的 字符串常量池被移到了堆内存 中是在Java7 之后,Java 8 时,PermGen 被元空间代替, 其他内容比如类元信息、字段、静态属性、方法、常量等都移动到元空间区 。比如 java/lang/Object类元信息、静态属性 System.out、整形常量 100000等。

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于: 元空间并不在虚拟机中,而是使用本地内存 。因此,默认情况下,元空间的大小仅受本地内存限制。(和后面提到的直接内存一样,都是使用本地内存)

In JDK 8, classes metadata is now stored in the native heap and this space is called Metaspace.

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。

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

显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制。如果内存区域总和大于物理内存的限制,也会出现OOM。

Code Cache

简而言之, JVM代码缓存是JVM将其字节码存储为本机代码的区域 。我们将可执行本机代码的每个块称为 nmethod 。该 nmethod可能是一个完整的或内联Java方法。

实时(JIT)编译器是代码缓存区域的最大消费者。这就是为什么一些开发人员将此内存称为JIT代码缓存的原因。

这部分代码所占用的内存空间成为CodeCache区域。一般情况下我们是不会关心这部分区域的且大部分开发人员对这块区域也不熟悉。如果这块区域OOM了,在日志里面就会看到 java.lang.OutOfMemoryErrorcode cache。


JVM运行时内存

JVM运行时内存又称堆内存(Heap)。Java 堆从 GC 的角度还可以细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代。

RuntimeDataArea

JVM堆内存划分

当代主流虚拟机(Hotspot VM)的垃圾回收都采用“分代回收”的算法。“分代回收”是基于这样一个事实:对象的生命周期不同,所以针对不同生命周期的对象可以采取不同的回收方式,以便提高回收效率。Hotspot VM将内存划分为不同的物理区,就是“分代”思想的体现。

一个对象从出生到消亡

JVM对象申请空间流程

一个对象产生之后首先进行栈上分配,栈上如果分配不下会进入伊甸区,伊甸区经过一次垃圾回收之后进入surivivor区,survivor区在经过一次垃圾回收之后又进入另外一个survivor,与此同时伊甸区的某些对象也跟着进入另外一个survivot,什么时候年龄够了就会进入old区,这是整个对象的一个逻辑上的移动过程。

新生代(Young Generation)

主要是用来存放新生的对象。一般占据堆的1/3空间。由于频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。新生代又分为 Eden区、ServivorFrom、ServivorTo三个区。

  • Eden区:Java新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当Eden区内存不够的时候就会触发MinorGC,对新生代区进行一次垃圾回收
  • ServivorTo:保留了一次MinorGC过程中的幸存者
  • ServivorFrom:上一次GC的幸存者,作为这一次GC的被扫描者

MinorGC流程

  • MinorGC采用复制算法
  • 首先把Eden和ServivorFrom区域中存活的对象复制到ServicorTo区域(如果有对象的年龄以及达到了老年的标准,则复制到老年代区),同时把这些对象的年龄+1(如果ServicorTo不够位置了就放到老年区)
  • 然后清空Eden和ServicorFrom中的对象
  • 最后ServicorTo和ServicorFrom互换,原ServicorTo成为下一次GC时的ServicorFrom区

为什么 Survivor 分区不能是 0 个?

如果 Survivor 是 0 的话,也就是说新生代只有一个 Eden 分区,每次垃圾回收之后,存活的对象都会进入老生代,这样老生代的内存空间很快就被占满了,从而触发最耗时的 Full GC ,显然这样的收集器的效率是我们完全不能接受的。

为什么 Survivor 分区不能是 1 个?

如果 Survivor 分区是 1 个的话,假设我们把两个区域分为 1:1,那么任何时候都有一半的内存空间是闲置的,显然空间利用率太低不是最佳的方案。

但如果设置内存空间的比例是 8:2 ,只是看起来似乎“很好”,假设新生代的内存为 100 MB( Survivor 大小为 20 MB ),现在有 70 MB 对象进行垃圾回收之后,剩余活跃的对象为 15 MB 进入 Survivor 区,这个时候新生代可用的内存空间只剩了 5 MB,这样很快又要进行垃圾回收操作,显然这种垃圾回收器最大的问题就在于,需要频繁进行垃圾回收。

为什么 Survivor 分区是 2 个?

如果Survivor分区有2个分区,我们就可以把 Eden、From Survivor、To Survivor 分区内存比例设置为 8:1:1 ,那么任何时候新生代内存的利用率都 90% ,这样空间利用率基本是符合预期的。再者就是虚拟机的大部分对象都符合“朝生夕死”的特性,所以每次新对象的产生都在空间占比比较大的Eden区,垃圾回收之后再把存活的对象方法存入Survivor区,如果是 Survivor区存活的对象,那么“年龄”就+1,当年龄增长到15(可通过 -XX:+MaxTenuringThreshold 设定)对象就升级到老生代。

总结

根据上面的分析可以得知,当新生代的 Survivor 分区为 2 个的时候,不论是空间利用率还是程序运行的效率都是最优的,所以这也是为什么 Survivor 分区是 2 个的原因了。

老年代(Old Generation)

主要存放应用程序中生命周期长的内存对象。老年代的对象比较稳定,所以MajorGC不会频繁执行。在进行MajorGC前一般都先进行了一次MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次MajorGC进行垃圾回收腾出空间。

MajorGC流程

MajorGC采用标记—清除算法。首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC的耗时比较长,因为要扫描再回收。MajorGC会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出OOM(Out of Memory)异常。

永久区(Perm Generation)

指内存的永久保存区域,主要存放元数据,例如Class、Method的元信息,与垃圾回收要回收的Java对象关系不大。相对于新生代和年老代来说,该区域的划分对垃圾回收影响比较小。GC不会在主程序运行期对永久区域进行清理,所以这也导致了永久代的区域会随着加载的Class的增多而胀满,最终抛出OOM异常。

JAVA8与元数据

在Java8中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入Native Memory,字符串池和类的静态变量放入java堆中,这样可以加载多少类的元数据就不再由MaxPermSize控制,而由系统的实际可用空间来控制。

内存分配策略

堆内存常见的分配测试如下:

  • 对象优先在Eden区分配
  • 大对象直接进入老年代
  • 长期存活的对象将进入老年代
参数 说明信息
-Xms 初始堆大小。如:-Xms256m
-Xmx 最大堆大小。如:-Xmx512m
-Xmn 新生代大小。通常为Xmx的1/3或1/4。新生代=Eden+2个Survivor空间。实际可用空间为=Eden+1个Survivor,即 90%
-Xss JDK1.5+每个线程堆栈大小为 1M,一般来说如果栈不是很深的话, 1M 是绝对够用了的
-XX:NewRatio 新生代与老年代的比例。如–XX:NewRatio=2,则新生代占整个堆空间的1/3,老年代占2/3
-XX:SurvivorRatio 新生代中Eden与Survivor的比值。默认值为 8,即Eden占新生代空间的8/10,另外两个Survivor各占1/10
-XX:PermSize 永久代(方法区)的初始大小
-XX:MaxPermSize 永久代(方法区)的最大值
-XX:+PrintGCDetails 打印GC信息
-XX:+HeapDumpOnOutOfMemoryError 让虚拟机在发生内存溢出时Dump出当前的内存堆转储快照,以便分析用

参数基本策略

各分区的大小对GC的性能影响很大。如何将各分区调整到合适的大小,分析活跃数据的大小是很好的切入点。

活跃数据的大小:指应用程序稳定运行时长期存活对象在堆中占用的空间大小,即Full GC后堆中老年代占用空间的大小。

可以通过GC日志中Full GC之后老年代数据大小得出,比较准确的方法是在程序稳定后,多次获取GC数据,通过取平均值的方式计算活跃数据的大小。活跃数据和各分区之间的比例关系如下:

空间 倍数
总大小 3-4 倍活跃数据的大小
新生代 1-1.5 活跃数据的大小
老年代 2-3 倍活跃数据的大小
永久代 1.2-1.5 倍Full GC后的永久代空间占用

例如,根据GC日志获得老年代的活跃数据大小为300M,那么各分区大小可以设为:

总堆:1200MB = 300MB × 4

新生代:450MB = 300MB × 1.5

老年代: 750MB = 1200MB - 450MB

这部分设置仅仅是堆大小的初始值,后面的优化中,可能会调整这些值,具体情况取决于应用程序的特性和需求。

实战:OutOfMemoryError异常

在Java虚拟机规范的描述中,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(下文称OOM)异常的可能,本节将通过若干实例来验证异常发生的场景(代码清单2-3~代码清单2-9的几段简单代码),并且会初步介绍几个与内存相关的 最基本的虚拟机参数。

本节内容的目的有两个:第一,通过代码验证Java虚拟机规范中描述的各个运行时区域存储的内容;第二,希望读者在工作中遇到实际的内存溢出异常时,能根据异常的信息快速判断是哪个区域的内存溢出,知道什么样的代码可能会导致这些区域内存溢出,以及出现这些异常后该如何处理。

下文代码的开头都注释了执行时所需要设置的虚拟机启动参数(注释中“VM Args”后面跟着的参数),这些参数对实验的结果有直接影响,读者调试代码的时候千万不要忽略。如果读者使用控制台命令来执行程序,那直接跟在Java命令之后书写就可以。如果读者使用Eclipse IDE,则可以参考图在Debug/Run页签中的设置。

下文的代码都是基于Sun公司的HotSpot虚拟机运行的,对于不同公司的不同版本的虚拟机,参数和程序运行的结果可能会有所差别。

Java堆溢出

Java堆用于存储对象实例,只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常。

代码限制Java堆的大小为20MB,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展),通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照以便事后进行分析。

Java堆内存溢出异常测试

/**
 * VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOOM {

    static class OOMObject {
    }

    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();

        while (true) {
            list.add(new OOMObject());
        }
    }
}

运行结果:

java.lang.OutOfMemoryError :Java heap space
Dumping heap to java_pid3404.hprof.
Heap dump file created[22045981 bytes in 0.663 secs]

Java堆内存的OOM异常是实际应用中常见的内存溢出异常情况。当出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟着进一步提示“Java heap space”。

要解决这个区域的异常,一般的手段是先通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。下图显示了使用Eclipse Memory Analyzer打开的堆转储快照文件。

如果是内存泄露,可进一步通过工具查看泄露对象到GC Roots的引用链。于是就能找到泄露对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄露对象的类型信息及GC Roots引用链的信息,就可以比较准确地定位出泄露代码的位置。

如果不存在泄露,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

以上是处理Java堆内存问题的简单思路,处理这些问题所需要的知识、工具与经验是后面3章的主题。

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

由于在HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此,对于HotSpot来说,虽然-Xoss参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只由-Xss参数设定。

关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

这里把异常分成两种情况,看似更加严谨,但却存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。

在笔者的实验中,将实验范围限制于单线程中的操作,尝试了下面两种方法均无法让虚拟机产生OutOfMemoryError异常,尝试的结果都是获得StackOverflowError异常,测试代码如代码清单2-4所示。

  • 使用-Xss参数减少栈内存容量。结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。
  • 定义了大量的本地变量,增大此方法帧中本地变量表的长度。结果:抛出StackOverflowError异常时输出的堆栈深度相应缩小。

虚拟机栈和本地方法栈OOM测试(仅作为第1点测试程序)

/**
 * VM Args:-Xss128k
 */
public class JavaVMStackSOF {

    private int stackLength = 1;

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

    public static void main(String[] args) throws Throwable {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}

运行结果:

stack length :2402
Exception in thread"main"java.lang.StackOverflowError
at org.fenixsoft.oom.VMStackSOF.leak (WIStackSOF.java :20 ) at org.fenixsoft.oom.VMStackSOF.leak (WIStackSOF.java :21 ) at org.fenixsoft.oom.VMStackSOF.leak (WIStackSOF.iava :21 ) 
.....后续异常堆栈信息省略

实验结果表明:在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。

如果测试时不限于单线程,通过不断地建立线程的方式倒是可以产生内存溢出异常,如代码清单2-5所示。但是这样产生的内存溢出异常与栈空间是否足够大并不存在任何联系,或者准确地说,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。

其实原因不难理解,操作系统分配给每个进程的内存是有限制的,譬如32位的Windows限制为2GB。虚拟机提供了参数来控制Java堆和方法区的这两部分内存的最大值。剩余的内存为2GB(操作系统限制)减去Xmx(最大堆容量),再减去MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略掉。如果虚拟机进程本身耗费的内存不计算在内,剩下的内存就由虚拟机栈和本地方法栈“瓜分”了。每个线程分配到的栈容量越大,可以 建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。

这一点读者需要在开发多线程的应用时特别注意,出现StackOverflowError异常时有错误堆栈可以阅读,相对来说,比较容易找到问题的所在。而且,如果使用虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说在大多数情况下)达到1000~2000完全没有问题,对于正常的方法调用(包括递归),这个深度应该完全够用了。但是,如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。如果没有这方面的处理经验,这种通过“减少内存”的手段来解决内存溢出的方式会比较难以想到。 创建线程导致内存溢出异常

/**
 * VM Args:-Xss2M (这时候不妨设大些)
 */
public class JavaVMStackOOM {

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

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

       public static void main(String[] args) throws Throwable {
              JavaVMStackOOM oom = new JavaVMStackOOM();
              oom.stackLeakByThread();
       }
}

注意,特别提示一下,如果读者要尝试运行上面这段代码,记得要先保存当前的工作。由于在Windows平台的虚拟机中,Java的线程是映射到操作系统的内核线程上的,因此上述代码执行时有较大的风险,可能会导致操作系统假死。 运行结果:

Exception in thread"main"java.lang.OutOfMemoryError :unable to create new native thread

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

由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。前面提到JDK 1.7开始逐步“去永久代”的事情,在此就以测试代码观察一下这件事对程序的实际影响。

String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。在JDK 1.6及之前的版本中,由于常量池分配在永久代内,我们可以通过-XX:PermSize和-XX:MaxPermSize限制方法区大小,从而间接限制其中常量池的容量,如代码清单2-6所示。

运行时常量池导致的内存溢出异常

/**
 * VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
 */
public class RuntimeConstantPoolOOM {

    public static void main(String[] args) {
        // 使用List保持着常量池引用,避免Full GC回收常量池行为
        List<String> list = new ArrayList<String>();
        // 10MB的PermSize在integer范围内足够产生OOM了
        int i = 0; 
        while (true) {
            list.add(String.valueOf(i++).intern());
        }
    }
}

运行结果:

Exception in thread"main"java.lang.OutOfMemoryError :PermGen space
at java.lang.String, intern (Native Method )
at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18)

从运行结果中可以看到,运行时常量池溢出,在OutOfMemoryError后面跟随的提示信息是“PermGen space”,说明运行时常量池属于方法区(HotSpot虚拟机中的永久代)的一部分。

而使用JDK 1.7运行这段程序就不会得到相同的结果,while循环将一直进行下去。关于这个字符串常量池的实现问题,还可以引申出一个更有意思的影响,如代码清单2-7所示。

String.intern()返回引用的测试

public class RuntimeConstantPoolOOM {

    public static void main(String[] args) {
        public static void main(String[] args) {
        String str1 = new StringBuilder("中国").append("钓鱼岛").toString();
        System.out.println(str1.intern() == str1);

        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);
    }   }
}

这段代码在JDK 1.6中运行,会得到两个false,而在JDK 1.7中运行,会得到一个true和一个false。产生差异的原因是:在JDK 1.6中,intern()方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,所以必然不是同一个引用,将返回false。而JDK 1.7(以及部分其他虚拟机,例如JRockit)的intern()实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此intern()返回的引用和由StringBuilder创建的那个字符串实例是同一个。对str2比较返回false是因为“java”这个字符串在执行StringBuilder.toString()之前已经出现过,字符串常量池中已经有它的引用了,不符合“首次出现”的原则,而“计算机软件”这个字符串则是首次出现的,因此返回true。

方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这些区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。虽然直接使用Java SE API也可以动态产生类(如反射时的GeneratedConstructorAccessor和动态代理等),但在本次实验中操作起来比较麻烦。在代码清单2-8中,笔者借助CGLib直接操作字节码运行时生成了大量的动态类。

值得特别注意的是,我们在这个例子中模拟的场景并非纯粹是一个实验,这样的应用经常会出现在实际应用中:当前的很多主流框架,如Spring、Hibernate,在对类进行增强时,都会使用到CGLib这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的Class可以加载入内存。另外,JVM上的动态语言(例如Groovy等)通常都会持续创建类来实现语言的动态性,随着这类语言的流行,也越来越容易遇到与代码清单2-8相似的溢出场景。

借助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 Throwable {
                    return proxy.invokeSuper(obj, args);
                }
            });
            enhancer.create();
        }
    }

    static class OOMObject {

    }
}

运行结果:

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

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

本机直接内存溢出

DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值(-Xmx指定)一样,代码越过了DirectByteBuffer类,直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有rt.jar中的类才能使用Unsafe的功能)。因为,虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是unsafe.allocateMemory()。

使用unsafe分配本机内存

/**
 * 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);
        }
    }
}

运行结果:

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

由DirectMemory导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常,如果读者发现OOM之后Dump文件很小,而程序中又直接或间接使用了NIO,那就可以考虑检查一下是不是这方面的原因。

OOM

JVM发生OOM的九种场景如下:

场景一:Java heap space

当堆内存(Heap Space)没有足够空间存放新创建的对象时,就会抛出 java.lang.OutOfMemoryError:Javaheap space 错误(根据实际生产经验,可以对程序日志中的 OutOfMemoryError 配置关键字告警,一经发现,立即处理)。

原因分析

Javaheap space 错误产生的常见原因可以分为以下几类:

  • 请求创建一个超大对象,通常是一个大数组
  • 超出预期的访问量/数据量,通常是上游系统请求流量飙升,常见于各类促销/秒杀活动,可以结合业务流量指标排查是否有尖状峰值
  • 过度使用终结器(Finalizer),该对象没有立即被 GC
  • 内存泄漏(Memory Leak),大量对象引用没有释放,JVM 无法对其自动回收,常见于使用了 File 等资源没有回收

解决方案

针对大部分情况,通常只需通过 -Xmx 参数调高 JVM 堆内存空间即可。如果仍然没有解决,可参考以下情况做进一步处理:

  • 如果是超大对象,可以检查其合理性,比如是否一次性查询了数据库全部结果,而没有做结果数限制
  • 如果是业务峰值压力,可以考虑添加机器资源,或者做限流降级
  • 如果是内存泄漏,需要找到持有的对象,修改代码设计,比如关闭没有释放的连接

场景二:GC overhead limit exceeded

当 Java 进程花费 98% 以上的时间执行 GC,但只恢复了不到 2% 的内存,且该动作连续重复了 5 次,就会抛出 java.lang.OutOfMemoryError:GC overhead limit exceeded 错误。简单地说,就是应用程序已经基本耗尽了所有可用内存, GC 也无法回收。

此类问题的原因与解决方案跟 Javaheap space 非常类似,可以参考上文。

场景三:Permgen space

该错误表示永久代(Permanent Generation)已用满,通常是因为加载的 class 数目太多或体积太大。

原因分析

永久代存储对象主要包括以下几类:

  • 加载/缓存到内存中的 class 定义,包括类的名称,字段,方法和字节码
  • 常量池
  • 对象数组/类型数组所关联的 class
  • JIT 编译器优化后的 class 信息

PermGen 的使用量与加载到内存的 class 的数量/大小正相关。

解决方案

根据 Permgen space 报错的时机,可以采用不同的解决方案,如下所示:

  • 程序启动报错,修改 -XX:MaxPermSize 启动参数,调大永久代空间
  • 应用重新部署时报错,很可能是没有应用没有重启,导致加载了多份 class 信息,只需重启 JVM 即可解决
  • 运行时报错,应用程序可能会动态创建大量 class,而这些 class 的生命周期很短暂,但是 JVM 默认不会卸载 class,可以设置 -XX:+CMSClassUnloadingEnabled-XX:+UseConcMarkSweepGC 这两个参数允许 JVM 卸载 class。

如果上述方法无法解决,可以通过 jmap 命令 dump 内存对象 jmap-dump:format=b,file=dump.hprof<process-id> ,然后利用 Eclipse MAT https://www.eclipse.org/mat 功能逐一分析开销最大的 classloader 和重复 class。

场景四:Metaspace

JDK 1.8 使用 Metaspace 替换了永久代(Permanent Generation),该错误表示 Metaspace 已被用满,通常是因为加载的 class 数目太多或体积太大。

此类问题的原因与解决方法跟 Permgenspace 非常类似,可以参考上文。需要特别注意的是调整 Metaspace 空间大小的启动参数为 -XX:MaxMetaspaceSize

场景五:Unable to create new native thread

每个 Java 线程都需要占用一定的内存空间,当 JVM 向底层操作系统请求创建一个新的 native 线程时,如果没有足够的资源分配就会报此类错误。

原因分析

JVM 向 OS 请求创建 native 线程失败,就会抛出 Unableto createnewnativethread,常见的原因包括以下几类:

  • 线程数超过操作系统最大线程数 ulimit 限制
  • 线程数超过 kernel.pid_max(只能重启)
  • native 内存不足

该问题发生的常见过程主要包括以下几步:

  • JVM 内部的应用程序请求创建一个新的 Java 线程
  • JVM native 方法代理了该次请求,并向操作系统请求创建一个 native 线程
  • 操作系统尝试创建一个新的 native 线程,并为其分配内存
  • 如果操作系统的虚拟内存已耗尽,或是受到 32 位进程的地址空间限制,操作系统就会拒绝本次 native 内存分配
  • JVM 将抛出 java.lang.OutOfMemoryError:Unableto createnewnativethread错误

解决方案

  • 升级配置,为机器提供更多的内存
  • 降低 Java Heap Space 大小
  • 修复应用程序的线程泄漏问题
  • 限制线程池大小
  • 使用 -Xss 参数减少线程栈的大小
  • 调高 OS 层面的线程最大数:执行 ulimia-a 查看最大线程数限制,使用 ulimit-u xxx 调整最大线程数限制

场景六:Out of swap space?

该错误表示所有可用的虚拟内存已被耗尽。虚拟内存(Virtual Memory)由物理内存(Physical Memory)和交换空间(Swap Space)两部分组成。当运行时程序请求的虚拟内存溢出时就会报 Outof swap space? 错误。

原因分析

该错误出现的常见原因包括以下几类:

  • 地址空间不足
  • 物理内存已耗光
  • 应用程序的本地内存泄漏(native leak),例如不断申请本地内存,却不释放
  • 执行 jmap-histo:live<pid> 命令,强制执行 Full GC;如果几次执行后内存明显下降,则基本确认为 Direct ByteBuffer 问题

解决方案

根据错误原因可以采取如下解决方案:

  • 升级地址空间为 64 bit
  • 使用 Arthas 检查是否为 Inflater/Deflater 解压缩问题,如果是,则显式调用 end 方法
  • Direct ByteBuffer 问题可以通过启动参数 -XX:MaxDirectMemorySize 调低阈值
  • 升级服务器配置/隔离部署,避免争用

场景七:Kill process or sacrifice child

有一种内核作业(Kernel Job)名为 Out of Memory Killer,它会在可用内存极低的情况下“杀死”(kill)某些进程。OOM Killer 会对所有进程进行打分,然后将评分较低的进程“杀死”,具体的评分规则可以参考 Surviving the Linux OOM Killer。不同于其它OOM错误, Killprocessorsacrifice child 错误不是由 JVM 层面触发的,而是由操作系统层面触发的。

原因分析

默认情况下,Linux 内核允许进程申请的内存总量大于系统可用内存,通过这种“错峰复用”的方式可以更有效的利用系统资源。然而,这种方式也会无可避免地带来一定的“超卖”风险。例如某些进程持续占用系统内存,然后导致其他进程没有可用内存。此时,系统将自动激活 OOM Killer,寻找评分低的进程,并将其“杀死”,释放内存资源。

解决方案

  • 升级服务器配置/隔离部署,避免争用
  • OOM Killer 调优

场景八:Requested array size exceeds VM limit

JVM 限制了数组的最大长度,该错误表示程序请求创建的数组超过最大长度限制。JVM 在为数组分配内存前,会检查要分配的数据结构在系统中是否可寻址,通常为 Integer.MAX_VALUE-2

此类问题比较罕见,通常需要检查代码,确认业务是否需要创建如此大的数组,是否可以拆分为多个块,分批执行。

场景九:Direct buffer memory

Java 允许应用程序通过 Direct ByteBuffer 直接访问堆外内存,许多高性能程序通过 Direct ByteBuffer 结合内存映射文件(Memory Mapped File)实现高速 IO。

原因分析

Direct ByteBuffer 的默认大小为 64 MB,一旦使用超出限制,就会抛出 Directbuffer memory 错误。

解决方案

  • Java 只能通过 ByteBuffer.allocateDirect 方法使用 Direct ByteBuffer,因此,可以通过 Arthas 等在线诊断工具拦截该方法进行排查
  • 检查是否直接或间接使用了 NIO,如 netty,jetty 等
  • 通过启动参数 -XX:MaxDirectMemorySize 调整 Direct ByteBuffer 的上限值
  • 检查 JVM 参数是否有 -XX:+DisableExplicitGC 选项,如果有就去掉,因为该参数会使 System.gc() 失效
  • 检查堆外内存使用代码,确认是否存在内存泄漏;或者通过反射调用 sun.misc.Cleanerclean() 方法来主动释放被 Direct ByteBuffer 持有的内存空间
  • 内存容量确实不足,升级配置

最佳实践

① OOM发生时输出堆dump:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=$CATALINA_HOME/logs

② OOM发生后的执行动作:

-XX:OnOutOfMemoryError=$CATALINA_HOME/bin/stop.sh

-XX:OnOutOfMemoryError=$CATALINA_HOME/bin/restart.sh

OOM之后除了保留堆dump外,根据管理策略选择合适的运行脚本。

本章小结

通过本章的学习,我们明白了虚拟机中的内存是如何划分的,哪部分区域、什么样的代码和操作可能导致内存溢出异常。虽然Java有垃圾收集机制,但内存溢出异常离我们仍然并不遥远,本章只是讲解了各个区域出现内存溢出异常的原因,第3章将详细讲解Java垃圾收集机制为了避免内存溢出异常的出现都做了哪些努力。

Search

    微信好友

    博士的沙漏

    Table of Contents