美文网首页Java 虚拟机程序员
【Java 虚拟机笔记】内存模型相关整理

【Java 虚拟机笔记】内存模型相关整理

作者: 58bc06151329 | 来源:发表于2019-02-22 23:07 被阅读5次

    文前说明

    作为码农中的一员,需要不断的学习,我工作之余将一些分析总结和学习笔记写成博客与大家一起交流,也希望采用这种方式记录自己的学习之旅。

    本文仅供学习交流使用,侵权必删。
    不用于商业目的,转载请注明出处。

    1. 运行时数据区域

    • Java 虚拟机在执行 Java 程序的过程中会把所管理的内存划分为若干个不同的数据区域。
      • 这些区域各自有各自的用途,以及创建和销毁的时间。
      • 有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。
    • 运行时数据区主要可以划分为 5 个区域。
      • 程序计数器/PC 寄存器(Program Counter Register)
      • Java 虚拟机栈(VM Stacks)
      • 本地方法栈(Native Method Stack)
      • Java 堆(Java Heap)
      • 方法区(Method Area)
    内存模型

    1.1 程序计数器(Program Counter Register)

    • 可用看作是当前线程所执行的字节码的 行号指示器
    • 任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条程序中的指令。
      • 每条线程都需要有一个 独立 的程序计数器。
      • 各线程之间计数器互不影响,独立存储(线程私有内存)。
      • 执行一个 Java 方法,计数器记录的是正在执行的虚拟机字节码指令的地址。
      • 执行一个 native 方法,计数器为空(Undefined)。
    • 程序计数器内存区域是 唯一 一个在 Java 虚拟机规范中 没有规定任何 OutOfMemoryError 情况 的区域。
    public static void main(java.lang.String[]);
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
          stack=2, locals=3, args_size=1
             0: ldc_w         #2                  
             3: dup           
             4: astore_1      
             5: monitorenter 
             6: aload_1       
             7: monitorexit
             8: goto          16
            11: astore_2      
            12: aload_1       
    

    1.2 Java 虚拟机栈(VM Stacks)

    • Java 虚拟机栈同程序计数器一样,都是 线程私有 的,生命周期跟线程相同,是后进先出(LIFO)栈。
      • 每个方法被执行的时候都会同时创建一个栈帧,用来存储局部变量表,操作栈,动态连接,方法出口等信息。
      • 每个方法从调用直到执行完成的过程,都对应一个栈帧在虚拟机栈中从 入栈到出栈 的过程。
    • 对于执行引擎来说,在活动线程中,只有处于栈顶的栈帧才是有效的,称为 当前栈帧,与这个栈帧相关联的方法称为当前方法。
    • 在 Java 虚拟机规范中,对这个区域规定了两种异常状况。
      • 如果线程请求的栈深度大于虚拟机所允许的深度,将 抛出 StackOverflowError 异常
      • 如果虚拟机栈可以动态扩展(大部分的 Java 虚拟机都可以动态扩展,只不过 Java 虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,将 抛出 OutOfMemoryError 异常
    • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作,在概念模型上典型的栈帧结构如下。
    Java 虚拟机栈概念模型

    1.2.1 栈帧(Frame)

    • 栈帧是一种数据结构,用于虚拟机进行方法的调用和执行。
      • 是虚拟机栈的栈元素,也就是入栈和出栈的一个单元。
      • 是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态连接 (Dynamic Linking)、方法返回值和异常分派(Dispatch Exception)。
    • 每个方法的执行和结束对应着栈帧的入栈和出栈。
      • 入栈表示被调用,出栈表示执行完毕或者返回异常。
    • 虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为 栈帧信息

    1.2.1.1 局部变量表(Local Variable Table)

    • 局部变量表是一组变量值存储空间,用以存储方法参数与方法内部定义的局部变量。
    • 局部变量表在 Java 程序被 编译 为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中 确定了该方法所需的局部变量表的最大容量
    • 局部变量表的容量以 变量槽(Variable Slot,下称 Slot)为最小单位,是一片逻辑连续的内存空间。用来存放方法参数和方法内部定义的局部变量。
    • 虚拟机规范中并没有明确指出一个 Slot 占应用内存的大小,只是很有导向性的指出一个 Slot 应该可以存放一个 byte、short、int、float、char、boolean、对象引用(reference)或者returnAddress(指向一个字节码指令的地址)。
      • 这 8 种类型的数据,都可以使用 32 位或者更小的空间去存储。
      • Slot 的长度可以随着处理器、虚拟机、操作系统的不同而发生变化,但是需保证即使在 64 位虚拟机下使用 64 位内存去实现 Slot,虚拟机仍需要使用对齐和补白的方式使之在外观上看起来和 32 位下一致。
      • Java 虚拟机规范没有规定 reference 类型的长度,它的实际长度与是 32 位还是 64 位虚拟机有关,如果是 64 位虚拟机,他的长度还与是否开启某些对象指针的压缩优化有关。
        • 一般情况来说,虚拟机通过这个引用应该至少做到两点。
          • 一是通过这个引用直接或间接的查找到对象在 Java 堆中数据存放的起始位置索引。
          • 二是通过此引用查找对象所属数据类型在方法区存储的类型信息,否则无法实现 Java 语言规范中定义的语法约束。
      • returnAddress 执行一条字节码指令的地址,为字节码指定 jsr、jsr_w 和 ret 服务的,很古老的 Java 虚拟机曾经使用这几条指令来实现异常处理,现在已经由异常表代替。
    • 对于 64 位的数据类型,虚拟机会通过高位补齐的方式为其分配两个连续的 Slot 空间,Java 中明确的 64 位的数据类型只有 long、double、(reference 类型可能是 32,也可能是 64 位)。
      • 把 long、double 分割存储的做法与 " long 和 double 的非原子性协定 " 把一次 long 和 double 的读写分割为两次 32 位的读写做法有些类型。不过由于局部变量表在虚拟机栈中,是线程私有的数据,所以无论读写两个连续的 Slot 是否是原子性操作,都不会出现线程安全的问题。
    • 虚拟机通过 索引定位 的方式定位局部变量表,索引的范围从 0 开始到局部变量表最大的 Slot 数量。
      • 访问 32 位数据类型,索引 n 则代表使用了第 n 个 Slot。
      • 访问 64 位数据类型,索引 n 则代表使用了第 n 和 n+1 个 Slot。
        • 对于两个相邻的存放 64 位数据的 Slot,不能单独访问其中一个,Java 虚拟机规范中明确要求如果遇到这种操作的字节码序列,虚拟机应该 在类加载的校验阶段抛出异常
    • 执行方法时,虚拟机使用局部变量表完成参数值到参数变量列表的传递过程,如果执行的是实例方法(非 static),那局部变量表的第 0 个 Slot 默认用来传递方法所属对象的引用,在方法中通过 this 关键字 可以访问这个隐含的参数。其余参数按照参数表顺序排列,参数表分配完毕,再根据方法内部局部变量的顺序和作用域分配 Slot。
      • static 方法则直接按照参数表顺序排列,参数表分配完毕,再根据方法内部局部变量的顺序和作用域分配 Slot。
    • 为了尽可能节省栈帧空间,局部变量表中的 Slot 是 可以重用 的。当程序计数器的值已经超出了某个变量的作用域时,下一个变量不必使用新的 Slot 空间,可以去覆盖之前的空间。

    局部变量表 Slot 的复用对垃圾回收的影响

    • 第一种情况,placeholder 对象还在作用域内并未被 GC 回收。
    public class Test {
    
        public static void main(String[] args) {
            byte[] placeholder = new byte[64 * 1024 * 1024];
            System.gc();
        }
    }
    //[GC (System.gc())  69191K->66104K(231936K), 0.0009956 secs]
    //[Full GC (System.gc())  66104K->65921K(231936K), 4.6295968 secs]
    

    局部变量表

    Start Length Slot Name Signature
    0 9 0 args [Ljava/lang/String;
    5 4 1 placeholder [B
    • 第二种情况,对于虚拟机而言,不回收 placeholder 对象对内存没有丝毫的影响,剩余的空间都是空闲的,回收反而浪费了时间,因此 placeholder 对象也没有被 GC 回收。
    public class Test {
    
        public static void main(String[] args) {
            {
                byte[] placeholder = new byte[64 * 1024 * 1024];
            }
            System.gc();
        }
    }
    //[GC (System.gc())  69191K->66104K(231936K), 0.0013618 secs]
    //[Full GC (System.gc())  66104K->65921K(231936K), 4.0038869 secs]
    

    局部变量表

    Start Length Slot Name Signature
    0 9 0 args [Ljava/lang/String;
    • 第三种情况,a=0 复用了 placeholder 对象的空间被 GC 回收。
      • placeholder 能否被回收在于局部变量表中的 Slot 是否还保存有关于 placeholder 的引用。
    public class Test {
    
        public static void main(String[] args) {
            {
                byte[] placeholder = new byte[64 * 1024 * 1024];
            }
            int a = 0;
            System.gc();
        }
    }
    //[GC (System.gc())  69191K->66008K(231936K), 0.0015096 secs]
    //[Full GC (System.gc())  66008K->385K(231936K), 0.0045977 secs]   --- 回收后剩余 385K
    

    局部变量表

    Start Length Slot Name Signature
    0 11 0 args [Ljava/lang/String;
    7 4 1 a I
    • 第四种情况,如果对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到 JIT 的编译条件下,可以手动设置 null,将对象回收。
    public class Test {
    
        public static void main(String[] args) {
            {
                byte[] placeholder = new byte[64 * 1024 * 1024];
                placeholder = null;
            }
            System.gc();
        }
    }
    //[GC (System.gc())  69191K->66040K(231936K), 0.0012288 secs]
    //[Full GC (System.gc())  66040K->385K(231936K), 0.0095720 secs]
    

    局部变量表

    Start Length Slot Name Signature
    5 2 1 placeholder [B
    0 11 0 args [Ljava/lang/String;

    1.2.1.2 操作栈(Operand Stack)

    • 操作栈也被称为操作数栈,是一个 后入先出 的栈。通常情况下,操作栈指的就是 当前栈帧 的操作栈。
    • 同局部变量表一样,操作栈的最大深度在 编译 的时候已经写入到 Code 属性的 max_stacks 数据项中。
      • 操作栈的每一个元素可以是任意 Java 类型,包括 long 和 double。
      • 32 位数据类型占用的容量为 1。
      • 64 位数据类型占用的容量为 2。
      • 在方法执行的任何时候,操作栈的深度最深不会超过 max_stacks。
    • 操作栈的作用。
      • 栈帧刚创建时,里面的操作栈是空的。
      • Java 虚拟机提供指令来让操作栈对一些数据进行入栈操作,比如可以把局部变量表里的数据、实例的字段等数据入栈。
      • 同时也有指令来支持出栈操作。
      • 向其他方法传参的参数,也存在操作栈中。
      • 其他方法返回的结果,返回时存在操作栈中。
    • 操作栈中元素的数据类型必须与字节码的序列严格匹配,在编译程序代码的时候编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。
      • 操作栈是区分类型的,操作栈中严格区分类型,而且指令和类型也需要严格匹配。
    • 在概念模型中,两个栈帧作为虚拟机栈的元素,是完全互相独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。
      • 例如栈帧 A 的部分操作栈与栈帧 B 的部分局部变量表重叠在一起,在进行方法调用时可以共用一部分数据,无须进行额外的参数复制传递。
    • Java 虚拟机的解释执行引擎被称为 基于栈的执行引擎,其中的栈就是指的操作栈。

    1.2.1.3 动态连接(Dynamic Linking)

    • 每一个栈帧内部都包含一个指向 运行时常量池 的引用来支持当前方法的代码实现动态连接。
      • 在 Class 文件里面,描述一个方法调用了其他方法,或者访问其成员变量是通过符号引用来表示的,动态连接的作用就是将这些 符号引用 所表示的方法转换为实际方法的直接引用。类加载的过程中将要解析这些符号引用,并且将变量访问转化为访问这些变量的存储结构所在的运行时内存位置的正确偏移量。
        • 例如,在内存地址 0X0300H 中存入了一个数 526,为了方便编程,将这个地址取别名为 A,编程的时候(运行之前)可以用别名 A 访问内存地址中的数据,程序运行后,实质还是寻找 0X0300H 地址获取 526 数这个据,这样的符号引用和直接引用在运行时进行解析和链接的过程,叫动态连接。
    • 由于动态连接的存在,通过延迟绑定(Late Binding)使用的其他类的方法和变量在发生变化时,将不会对调用它们的方法构成影响。
    • 这些符号引用的一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为 静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为 动态连接。(静态分派和动态分派)
    • 虚拟机解析过程中相对于符号引用执行的任务。
      • 查找被引用的类。(如果必要就装载它)
      • 将符号引用替换为直接引用,再次遇到相同的引用时,可以立即使用直接引用,而不必花时间再次解析符号引用。

    1.2.1.4 返回地址(Return Address)

    • 当一个方法被执行后,有两种方式退出这个方法。
      • 执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为 正常完成出口(Normal Method Invocation Completion)。
        • Java 虚拟机根据不同数据类型有不同的底层 return 指令。当被调用方法执行某条 return 指令时,会选择相应的 return 指令让值返回(如果该方法有返回值)。
      • 在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为 异常完成出口(Abrupt Method Invocation Completion)。
        • 一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
    • 无论采用何种方式退出后,都需要返回到方法被调用的位置程序才能继续执行。
      • 方法返回时需要在栈帧中保存一些信息,用以帮助恢复上层方法的执行状态。
        • 方法正常退出时,调用者的程序计数器的值作为返回地址保存在栈帧中。
        • 方法异常退出时,返回地址通过异常处理器确定,栈帧中一般不会保存这部分信息。
    • 方法退出的过程实际上等同于把当前栈帧出栈,退出时可能执行的操作。
      • 恢复上层方法的局部变量表和操作栈,把返回值(如果有的话)压入调用者栈帧的操作栈中,调整程序计数器的值以指向方法调用指令后面的一条指令等。

    1.3 本地方法栈(Native Method Stack)

    • 本地方法栈与虚拟机栈所发挥的作用相似,也是 线程私有 的后进先出(LIFO)栈。
      • 虚拟机栈为虚拟机执行 Java 方法(字节码)服务。
      • 本地方法栈为虚拟机使用 Native 方法服务。
    • 虚拟机规范中对本地方法栈中方式使用的语言、方式和数据结构并没有强制规定,具体由虚拟机自由实现。
      • 有一些虚拟机(如 HotSpot)将 Java 虚拟机栈和本地方法栈合并实现。
    • 与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
    线程调用 Java 方法和本地方法时的栈
    • 该线程首先调用了两个 Java 方法,而第二个 Java 方法又调用了一个本地方法,这样导致虚拟机使用了一个本地方法栈。假设这是一个 C 语言栈,其间有两个 C 函数,第一个 C 函数被第二个 Java 方法当做本地方法调用,而这个 C 函数又调用了第二个 C 函数。之后第二个 C 函数又通过本地方法接口回调了一个 Java 方法(第三个 Java 方法),最终这个 Java 方法又调用了一个 Java 方法(图中的当前方法)。

    1.4 Java 堆(Java Heap)

    • 对于大多数应用来说,Java 堆是 Java 虚拟机所管理的内存中最大的一块,Java 堆是备所有 线程共享 的一块内存区域,在虚拟机启动时创建。
      • 主要存放对象实例。
      • Java 堆是垃圾收集器管理的主要区域,也被称为 " GC 堆 "(Garbage Collected Heap)。
    • 内存回收角度,由于现在垃圾收集器基本都采用 分代收集算法
      • Java 堆可用分为 新生代(Young)、老年代(Old)
        • 默认的新生代与老年代的比例的值为 1 : 2(通过参数 -XX:NewRatio 设置),新生代 = 1/3 的堆空间大小老年代 = 2/3 的堆空间大小
      • 新生代(Young)又划分为 Eden、From Survivor 和 To Survivor 区域
        • 默认的 Eden : from : to = 8 : 1 : 1(通过参数 -XX:SurvivorRatio 设置),Eden = 8/10 的新生代空间大小from = to = 1/10 的新生代空间大小
      • Java 虚拟机每次只会使用 Eden 和其中一块 Survivor 区域为对象服务,无论何时,总有一块 Survivor 区域空闲。
        • 新生代实际可用的内存空间为 9/10(90%) 的新生代空间
      • Java 虚拟机可以通过 -XX:+PrintGCDetails 打印 GC 日志查看回收情况。
    [GC (System.gc()) [PSYoungGen: 3655K->432K(70656K)] 69191K->65968K(231936K), 0.0013816 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
    [Full GC (System.gc()) [PSYoungGen: 432K->0K(70656K)] [ParOldGen: 65536K->385K(161280K)] 65968K->385K(231936K), [Metaspace: 3433K->3433K(1056768K)], 0.0045779 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 
    Heap
     PSYoungGen      total 70656K, used 609K [0x0000000771e00000, 0x0000000776c80000, 0x00000007c0000000)
      eden space 60928K, 1% used [0x0000000771e00000,0x0000000771e98690,0x0000000775980000)
      from space 9728K, 0% used [0x0000000775980000,0x0000000775980000,0x0000000776300000)
      to   space 9728K, 0% used [0x0000000776300000,0x0000000776300000,0x0000000776c80000)
     ParOldGen       total 161280K, used 385K [0x00000006d5a00000, 0x00000006df780000, 0x0000000771e00000)
      object space 161280K, 0% used [0x00000006d5a00000,0x00000006d5a60478,0x00000006df780000)
     Metaspace       used 3440K, capacity 4494K, committed 4864K, reserved 1056768K
      class space    used 332K, capacity 386K, committed 512K, reserved 1048576K
    
    区域名称 说明
    新生代(Young Generation) 主要用来存放新生的对象。
    老年代(Old Generation 或者 Tenured Generation) 主要存放应用程序声明周期长的内存对象。
    • 根据 Java 虚拟机规范的规定,Java 堆可用处于物理上 不连续的内存 空间中,只要逻辑上连续即可。
      • 实现时,即可用实现成固定大小的,也可用可以扩展的,主流的虚拟机都是可以扩展的(通过 -Xmx 和 -Xms 控制)。
    • 如果 Java 堆内没有内存可以完成实例分配并且堆也无法再扩展,则 抛出 OutOfMemoryError 异常
    参数 说明
    -Xms 初始堆大小。如:-Xms256m。
    -Xmx 最大堆大小。如:-Xmx512m。
    -Xmn 新生代大小。通常为 Xmx 的 1/3 或 1/4。新生代 = Eden + 2 个 Survivor 空间。实际可用空间为 = Eden + 1 个 Survivor,即 90%。
    -Xss JDK 1.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 出当前的内存堆转储快照,以便分析使用。

    1.5 方法区(Method Area)

    • 方法区与 Java 堆一样,是所有 线程共享 的内存区域,在虚拟机启动的时候创建,Java 虚拟机规范把它描述为堆的一个逻辑组成部分。
    • 在不同的 JDK 版本中,方法区中存储的数据是不一样的。
      • JDK 1.6 及之前,运行时常量池 是方法区的一个部分,主要存储加载的类信息、常量区、静态变量、JIT(即时编译器)处理后的数据等,类的信息包含类的版本、字段、方法、接口等信息。
      • JDK 1.7 中,将 运行时常量池中包含的字符串常量池 从方法区中转移到堆中,剩余部分仍然保留在方法区。
      • JDK 1.8 及以后移除 永久代元空间(Metaspace)取而代之,这时候字符串常量池还在堆,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间。
        • 元空间没有使用堆内存,而是使用了与堆不相连的 本地内存区域
        • 理论上系统可以使用的内存有多大,元空间就有多大,所以不会出现永久代存在时的 内存溢出 问题。
    • Java 虚拟机规范没有限定实现方法区的内存位置和编译代码的管理策略,不同的虚拟机厂商,针对自己的虚拟机有不同的方法区实现方式。
      • HotSpot 虚拟机的设计团队选择把 GC 分代收集 扩展至方法区,省去了专门为方法区编写内存管理代码的工作。
        • 永久代是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。
        • Rockit 和 J9 不存在永久代这种说法。
        • 方法区和永久代的关系很像 Java 中接口和类的关系(类实现了接口),所以也有人将方法区称为永久代(Permanent Generation),但本质上两者并不等价。
        • 永久代有着 -XX:MaxPermSize 的上限(可能会出现内存溢出问题),Rockit 和 J9 只要没有触碰到进程可用内存的上限(例如 32 位系统中的 4GB),就不会出现问题。
    • Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要 连续的内存可以选择固定大小或者可扩展 外,还可以选择不实现垃圾收集。
      • 垃圾收集行为在这个区域是比较少出现的,这个区域的内存回收目标主要是 针对常量池的回收和对类型的卸载
      • 该区域回收条件相当苛刻,尤其是类型的卸载,但是这部分区域的回收还是很有必要,在 Sun 公司的 BUG 列表中,曾出现过若干个严重的 BUG 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致的内存泄漏。
    • 根据 Java 虚拟机规范的规定,方法区无法满足内存分配需求时,将 抛出 OutOfMemoryError 异常

    1.6 运行时常量池(Runtime Constant Pool)

    字符串常量池(String Constant Pool)

    • 在 HotSpot 虚拟机里实现字符串常量池功能的是一个 StringTable,它是一个 Hash 表,默认值大小长度是 1009
      • 这个 StringTable 只此一份被所有类共享,字符串常量由一个一个字符组成,放在了 StringTable 中。
      • 在 JDK 1.6 中,字符串常量池里存放的都是字符串常量,StringTable 的长度是固定的,如果放入字符串常量中的字符串非常多,就会造成 Hash 冲突,导致链表过长,当调用 String.intern() 时需要到链表上一个个寻找,从而导致性能大幅度下降。
      • 在 JDK 1.7 中,字符串常量池中可以存放 堆内字符串对象的引用,StringTable 的长度也可以通过参数指定:-XX:StringTableSize=100
        • 直接赋值和用字符串调用 String 构造函数都可能导致常量池中生成字符串常量。
        • intern() 方法会尝试将堆中对象的引用放入常量池中。

    Class 常量池(Class Constant Pool)

    • Java 类被编译后形成一份 Class文件,该文件中除了类的版本、字段、方法、接口等描述信息,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic References)。
      • 字面量包括文本字符串、八种基本类型的值、被声明为 final 的常量等。
      • 符号引用包括类和方法的全限定名、字段的名称和描述符、方法的名称和描述符。
    • 每个 Class 文件都有一个 Class 常量池

    • 方法区中常量池有 运行时常量池Class 文件常量池,JDK 1.7 及之后将运行时常量池中包含的 字符串常量池 转移到了堆中。
    • Java 虚拟机对 Class 文件中每一部分(包括常量池)的格式都有严格的要求,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,Java 虚拟机规范没有做任何细节的要求,不同的虚拟机可以按照自己的需要实现。
      • 虚拟机在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,虚拟机就会将 Class 常量池中的内容存放到运行时常量池中,由此可知 运行时常量池也是每个 Class 都有一个。在解析阶段,会把符号引用替换为直接引用,解析的过程会去查询字符串常量池,以保证运行时常量池所引用的字符串与字符串常量池中的一致。
      • 相较于 Class 文件常量池,运行时常量池更具动态性,在运行期间也可以将新的变量放入常量池中,而不是一定要在编译时确定的常量才能放入,其最主要的运用便是 String 类的 intern() 方法。
    • 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当无法再申请到内存时会 抛出 OutOfMemoryError 异常

    1.7 堆外内存/直接内存(Direct Memory)

    • 堆外内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。
    • JDK 1.4 中新加入 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,可以使用 Nactive 函数库直接分配堆外内存,通过一个存储在 Java 堆中的 DirectByteBuffer 对象 作为这块内存的引用进行操作。
    • 堆外内存不受 Java 堆大小的限制,但是受本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。
      • 如果忽略堆外内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时 抛出 OutOfMemoryError 异常
    • 堆外内存是不受 Java 虚拟机控制的内存,相比于堆内内存的特点。
      • 减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作。
      • 加快了复制的速度(I/O 效率)。
      • 堆外内存难以控制,如果内存泄漏很难排查。
      • 堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象或者扁平化的比较适合。

    DirectByteBuffer 对象

    • 堆外内存可以通过 java.nio 的 ByteBuffer 来创建,调用 allocateDirect() 方法申请,通过 unsafe.allocateMemory(size) 实现。
      • unsafe.allocateMemory(size) 的底层通过 C 语言的内存分配函数 malloc() 实现。
    DirectByteBuffer(int cap) {                 
     
            super(-1, 0, cap, cap);
            //内存是否按页分配对齐
            boolean pa = VM.isDirectMemoryPageAligned();
            //获取每页内存大小
            int ps = Bits.pageSize();
            //分配内存的大小,如果是按页对齐方式,需要再加一页内存的容量
            long size = Math.max(1L, (long)cap + (pa ? ps : 0));
            //用Bits类保存总分配内存(按页分配)的大小和实际内存的大小
            Bits.reserveMemory(size, cap);
     
            long base = 0;
            try {
               //在堆外内存的基地址,指定内存大小
                base = unsafe.allocateMemory(size);
            } catch (OutOfMemoryError x) {
                Bits.unreserveMemory(size, cap);
                throw x;
            }
            unsafe.setMemory(base, size, (byte) 0);
            //计算堆外内存的基地址
            if (pa && (base % ps != 0)) {
                // Round up to page boundary
                address = base + ps - (base & (ps - 1));
            } else {
                address = base;
            }
            cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
            att = null;
    }
    
    private static class Deallocator implements Runnable  {
        private static Unsafe unsafe = Unsafe.getUnsafe();
        private long address;
        private long size;
        private int capacity;
        private Deallocator(long address, long size, int capacity) {
            assert (address != 0);
            this.address = address;
            this.size = size;
            this.capacity = capacity;
        }
     
        public void run() {
            if (address == 0) {
                // Paranoia
                return;
            }
            unsafe.freeMemory(address);
            address = 0;
            Bits.unreserveMemory(size, capacity);
        }
    }
    
    • 在 Cleaner 内部中通过一个列表,维护了针对每一个 directBuffer 对象的一个回收堆外内存的线程对象(Runnable),是 PhantomReference 的子类,主要作用是跟踪 directBuffer 对象何时被回收。
      • 回收操作发生在 Cleaner 的 clean() 方法中。
      • unsafe.freeMemory(address) 是回收堆外内存的方法,底层通过 C 语言的内存释放函数 free() 实现。
    public static Cleaner create(Object ob, Runnable thunk) {
        if (thunk == null)
            return null;
        return add(new Cleaner(ob, thunk));
    }
    
    public void clean() {
        if (!remove(this))
            return;
        try {
            thunk.run();//此处会调用 Deallocator
        } catch (final Throwable x) {
            AccessController.doPrivileged(new PrivilegedAction<Void>() {
                    public Void run() {
                        if (System.err != null)
                            new Error("Cleaner terminated abnormally", x)
                                .printStackTrace();
                        System.exit(1);
                        return null;
                    }});
        }
    }
    
    • 当初始化一块堆外内存时,对象的引用关系如下。
      • 其中 first 是 Cleaner 类的静态变量,Cleaner 对象在初始化时被添加到 Cleaner 链表中,和 first 形成引用关系,ReferenceQueue 用来保存需要回收的 Cleaner 对象。
    初始化堆外内存
    • PhantomReference 不能影响 GC 决策,但是可以在 directBuffer 对象被 GC 回收时,将跟踪它的 Cleaner 对象放入到 ReferenceQueue 中,并触发 clean() 方法。
    DirectByteBuffer 对象被 GC 回收
    • Cleaner 对象 clean() 方法的主要作用。
      • 把自身从 Clener 链表删除,从而在下次 GC 时被回收。
      • 释放堆外内存。
    • 如果 Java 虚拟机一直没有执行 FGC,无效的 Cleaner 对象无法放入到 ReferenceQueue 中,从而造成堆外内存一直得不到释放。
      • 初始化 DirectByteBuffer 对象时,如果当前堆外内存的条件很苛刻时,会主动调用 System.gc() 强制执行 FGC。
    DirectByteBuffer(int cap) {                   // package-private
    ......
            Bits.reserveMemory(size, cap);
    ......
    static void reserveMemory(long size, int cap) {
    
            if (!memoryLimitSet && VM.isBooted()) {
                maxMemory = VM.maxDirectMemory();
                memoryLimitSet = true;
            }
    
            // optimist!
            if (tryReserveMemory(size, cap)) {
                return;
            }
    
            final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
    
            // retry while helping enqueue pending Reference objects
            // which includes executing pending Cleaner(s) which includes
            // Cleaner(s) that free direct buffer memory
            while (jlra.tryHandlePendingReference()) {
                if (tryReserveMemory(size, cap)) {
                    return;
                }
            }
    
            // trigger VM's Reference processing
            System.gc();
    
            // a retry loop with exponential back-off delays
            // (this gives VM some time to do it's job)
            boolean interrupted = false;
    ......
    

    堆外内存默认值大小

    • 如果没有通过 -XX:MaxDirectMemorySize 指定最大的堆外内存,如果 -Dsun.nio.MaxDirectMemorySize 等于 -1,那么最大堆外内存的值来自于 directMemory = Runtime.getRuntime().maxMemory(),这是一个 native 方法。
    public static void saveAndRemoveProperties(Properties var0) {
            if (booted) {
                throw new IllegalStateException("System initialization has completed");
            } else {
                savedProps.putAll(var0);
                String var1 = (String)var0.remove("sun.nio.MaxDirectMemorySize");
                if (var1 != null) {
                    if (var1.equals("-1")) {
                        directMemory = Runtime.getRuntime().maxMemory(); //获取默认堆外内存
                    } else {
                        long var2 = Long.parseLong(var1);
                        if (var2 > -1L) {
                            directMemory = var2;
                        }
                    }
                }
    
                var1 = (String)var0.remove("sun.nio.PageAlignDirectMemory");
                if ("true".equals(var1)) {
                    pageAlignDirectMemory = true;
                }
    
                var1 = var0.getProperty("sun.lang.ClassLoader.allowArraySyntax");
                allowArraySyntax = var1 == null ? defaultAllowArraySyntax : Boolean.parseBoolean(var1);
                var0.remove("java.lang.Integer.IntegerCache.high");
                var0.remove("sun.zip.disableMemoryMapping");
                var0.remove("sun.java.launcher.diag");
                var0.remove("sun.cds.enableSharedLookupCache");
            }
    }
    
    • 设置的 -Xmx 的值除去一个 survivor 的大小就是默认的堆外内存的大小

    什么时候使用堆外内存

    • 对于需要频繁操作的内存,并且仅仅是临时存在一会儿的,都建议使用堆外内存,并且做成缓冲池,不断循环利用这块内存。

    不能大面积使用堆外内存

    • 大面积使用堆外内存并且没有限制,迟早会导致内存溢出,毕竟程序是跑在一台资源受限的机器上,因为这块内存的回收不是直接能控制的。
    • 如果使用了堆外内存,并且 DisableExplicitGC 设置为 true,那么就是禁止使用 System.gc(),这样堆外内存将无从触发,极有可能造成内存溢出错误,在这种情况下可以考虑使用 ExplicitGCInvokesConcurrent 参数。
      • -XX:+DisableExplicitGC 设置为 true 可以禁用 System.gc()
      • 通过 -XX:+ExplicitGCInvokesConcurrent 可以做并行 gc。

    2. HotSpot 虚拟机对象

    普通对象的创建

    • 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化,如果没有则执行相应的类加载过程,在类加载检查通过后,虚拟机将为新生对象分配内存。
      • 对象所需内存的大小在类加载完成后便已确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
        • 如果 Java 堆中内存绝对工整,用过的内存在一边,空闲的内存在另一边,中间放着作为分界点的指示器,分配内存仅仅是将指针向空闲内存一边移动与对象大小相等的距离,这种分配方式为 指针碰撞
        • 如果 Java 堆中内存不工整,虚拟机维护一个列表,记录哪些内存块可用,分配时从列表中选择一块足够大的空间划分给对象并更新列表上记录,这种分配方式为 空闲列表
        • 分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
          • 在使用 Serial、ParNew 等带 Compact 过程的收集器时,系统采用指针碰撞。
          • 在使用 CMS 等基于 Mark-Sweep 算法的收集器时,采用空闲列表。
    • 在并发情况下并不是线程安全的,虚拟机采用有两种方案解决。
      • 第一种,CAS 配上失败重试的方式保证更新操作的原子性。
      • 第二种,把内存分配的动作按照线程划分在不同的空间中进行(每个线程在 Java 堆中预先分配一小块内存,称为 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)),只有 TLAB 用完并分配新的 TLAB 时,才使用同步锁定。
        • 是否使用 TLAB,可以通过 -XX:+/-UseTLAB 参数设定
    • 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),确保对象的实例字段在 Java 代码中可以不赋值初始化也能直接使用,程序能访问到字段的数据类型所对应的零值,接着虚拟机对对象进行一些必要的设置(这些信息保存在对象头中),之后执行 <init> 方法,按程序的意愿进行字段初始化。

    对象的内存布局

    • 对象在内存中存储的布局可以分为 对象头实例数据对齐填充

    对象头

    • Java 对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
      • Klass Pointer 是对象指向它的类(Class)元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
      • Mark Word 用于存储对象自身的运行时数据。
        • 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
    • 对象头一般占有 两个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32 bit),但是如果对象是数组类型,则需要 三个机器码,因为 JVM 可以通过 Java 对象的元数据信息确定 Java 对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度
    • 对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word 会随着程序的运行发生变化,变化状态如下(32 位虚拟机)。
    对象头信息

    实例数据

    • 记录代码中所定义的各种类型的字段内容。(无论是从父类继承还是子类中定义)

    对齐填充

    • 不是必然存在,只是起着占位符的作用。
      • 因为 HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍(对象大小必须是 8 字节的整数倍)。
      • 对象头正好为 8 字节的整数倍(1 倍或者 2 倍),当对象实例数据没有对齐时,就需要通过对齐填充补全。

    对象的访问定位

    • 对象的访问定位也取决于具体的虚拟机实现。
      • 在堆上创建一个对象实例后,就要通过虚拟机栈中的 reference 类型数据来操作堆上的对象。
      • 主流的访问方式有两种(HotSpot 虚拟机采用的是第二种)。
        • 使用句柄访问对象。即 reference 中存储的是对象句柄的地址,而句柄中包含了对象实例数据与类型数据的具体地址信息,相当于二级指针。
        • 直接指针访问对象。即 reference 中存储的就是对象地址,相当于一级指针。
    • 两种方式有各自的优缺点。
      • 从垃圾回收移动对象上看。方式一,reference 中存储的地址是稳定的地址,不需要修改,仅需要修改对象句柄的地址,方式二,则需要修改 reference 中存储的地址。
      • 从访问效率上看。方式二优于方式一,因为方式二只进行了一次指针定位,节省了时间开销。

    3. OutOfMemoryError 异常

    • 在 Java 虚拟机规范的描述中,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生 OutOfMemoryError 异常(下文称 OOM)的可能。

    Java 堆溢出

    • Java 堆用于存储对象实例,只要保证 GC Roots 到对象之间有可达路径避免垃圾回收机制清除对象,那么对象数量达到最大堆的容量限制后就会产生内存溢出异常。
      • 解决这类异常,可以先通过内存映像分析工具对 Dump 出现的堆转储快照进行分析,确认内存中对象是否必要(辨别是内存泄漏还是内存溢出)。
        • 内存泄漏,即对象没有必要存在,则通过工具分析泄漏对象是通过怎样的路径与 GC Roots 关联导致垃圾收集器无法自动回收,从而定位出泄漏代码位置。
        • 内存溢出,即对象必须存活,那么应当检查堆参数(-Xmx 与 -Xms),与物理内存对比是否可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
    • 显示为 Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

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

    • HotSpot 虚拟机并没有区分虚拟机栈与本地方法栈。
      • 对于 HotSpot 而言,-Xoss 参数存在,但实际无效,栈容量只由 -Xss 参数设定。
    • 关于虚拟机栈和本地方法栈,在 Java 虚拟机规范中描述了两种异常。
      • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
      • 如果虚拟机在扩展栈时无法申请到足够的内存空间,将抛出 OutOfMemoryError 异常。
    • 在单线程情况下,无论是由于栈太大还是虚拟机容量太小,当内存无法分配的时候,虚拟机抛出的都是 StackOverflowError 异常。
      • 显示为 Exception in thread "main" java.lang.StackOverflowError
    • 通过不断创建线程的方式可以可以产生内存溢出异常。
      • 显示为 Exception in thread "main" java.lang.OutOfMemoryError: java.lang.OutOfMemoryError: unable to create new native thread
      • 如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换 64 位虚拟机的情况下,可以通过减少最大堆和减少栈容量来换取更多的线程。

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

    • JDK 1.6 及之前的版本中,字符串常量池还包含在方法区中(永久代),可以通过 -XX:PermSize-XX:MaxPermSize 限制方法区大小,从而限制其中常量池的容量。
      • 显示为 Exception in thread "main" java.lang.OutOfMemoryError: java.lang.OutOfMemoryError: PermGen space
      • 使用 JDK 1.7 运行不会出现相同的结果。
    • 方法区用于存放 Class 类的相关信息(如类名、访问修饰符、常量池、字段描述、方法描述等),当运行时产生大量的类填满了方法区,就会产生内存溢出。
      • 方法区溢出一般是程序使用了 CGLib 字节码增强和动态语言,或者大量 JSP 和 动态产生 JSP 文件的应用(JSP 第一次运行时需要编译为 Java 类)、基于 OSGi 的应用(即使是同一个类文件、被不同的加载器加载也会视为不同的类)等。
    • String.intern() 方法。
      • 在 JDK 1.6 中,会将首次遇到的字符串实例复制到永久代(方法区)中,返回的是永久代中这个字符串实例的引用。
      • 在 JDK 1.7 中(以及部分其他虚拟机,例如 JRockit),实现不会再复制实例,只是在常量池中记录首次出现的实例引用,返回的是堆中这个字符串实例的引用。

    本机直接内存溢出

    • DirectMemory 容量可以通过 -XX:MaxDirectMemorySize 指定。
      • 显示为 Exception in thread "main" java.lang.OutOfMemoryError
      • 由 DirectMemory 导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见明显的异常,如果发现 OOM 之后 Dump 文件很小,而程序中又直接或间接使用了 NIO,那么就可以检查是不是这方面的问题。

    参考资料

    http://www.cnblogs.com/noKing/p/8167700.html
    https://blog.csdn.net/ychenfeng/article/details/77247807
    https://blog.csdn.net/zm13007310400/article/details/77534349
    https://www.jianshu.com/p/7a5fa718eb59?utm_campaign
    https://www.cnblogs.com/moonandstar08/p/5107648.html
    https://yq.aliyun.com/articles/2948
    https://blog.csdn.net/wangtaomtk/article/details/52267548
    https://blog.csdn.net/q5706503/article/details/84640762

    相关文章

      网友评论

        本文标题:【Java 虚拟机笔记】内存模型相关整理

        本文链接:https://www.haomeiwen.com/subject/zmwcyqtx.html