JVM 运行时内存与垃圾回收

一、JVM运行时内存

​ 通过线程私有/共享分类进行描述;

1、线程私有内存

包括

  • 程序计数器
  • 虚拟机栈
    • 栈帧
      • 局部变量表
      • 操作数栈
      • 动态链接
      • 返回地址
  • 本地方法栈;

1.1 程序计数器

作用:一小块内存区域,每个线程都有一个,代表当前线程所执行到的字节码的行号指示器;

:行号,如果当前是 native方法,则为 undefined,未规定 OutOfMemoryError

1.2 Java虚拟机栈

作用:存储线程执行代码过程中数据的一块内存区域,描述方法执行的内存模型,由一组栈帧组成;

配置参数:

-Xss // 如 -Xss1m

JDK11后,64位机器,栈大小windows不能低于180K,linux下不能低于228K。

栈帧

​ 每个方法被执行时创建一个栈帧(存储局部变量表操作栈动态链接方法出口),每个方法的调用到执行完成,对应一个栈帧在虚拟机栈中从入栈到出栈的过程;

​ 可能产生 StackOverflowErrorOutOfMemoryError

局部变量表

​ 一组变量存储空间,变量槽Slot,每个占用一字节/32位(取决于虚拟机实现),故一个double long等类型变量占用两个槽位;

作用:存放方法参数和方法内部定义的局部变量

特点:进入方法之前,这个方法需要多大的局部变量空间完全确定,方法运行期间不会改变局部变量表的大小,通过索引访问;

操作数栈

作用:虚拟机的工作区,类似局部变量表,标准的入栈出栈操作来进行计算;

特点byte、short 等小于1字节的数据进入操作数栈前会被转换为 int 类型

动态链接

概念符号引用在运行时转换为直接引用的过程即动态链接,相对概念静态解析

​ 符号引用:即 .class文件的常量池中的方法名/成员变量名;

​ 直接引用:可以调用的引用,内存地址;

​ 静态解析:类加载时或第一次使用时将符号引用转换为直接应用;

返回地址

:方法调用者的程序计数器值 / 异常处理表;

特点:方法退出时,如果有返回值,把返回值压入调用者栈帧的操作数栈,PC计数器的值调整为调用入口下一指令;

1.3 本地方法栈

​ 类似 Java虚拟机栈 运行 Java字节码,本地方法栈 服务于虚拟机使用到的 native 方法;

​ 某些虚拟机将两者合二为一;

2、线程共享内存

包括:

  • 方法区
    • 新生代
      • Eden
      • From Survivor
      • To Survivor
    • 年老代

2.1 方法区

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

永久代是 HotSpot 虚拟机在 JDK7 及以前的方法区实现,JDK8后,已使用元空间Meta-Space存储相关的数据。

运行时常量池是方法区的一部分,Class文件的常量池表,存放着编译期生成的各种字面量与符号引用,在类加载后存放到方法区的运行时常量池中,一般符号引用翻译出的直接引用也存储在这里。

字符串常量池在JDK6及之前,在方法区/永久代中,之后及JDK7及之后,被放在了堆中,String::intern()将返回堆中首次出现的字符串引用:

String str1 = new StringBuilder("方法").append("区").toString();
str1.intern() == str1; // jdk6及之前:false, jdk7及之后,true(前提:之前没将“方法区”三个字放在常量池中);

String str2 = new StringBuilder("ja").append("va").toString();
str2.intern() == str2; // jdk6及之前:false, jdk7及之后,false,因为之前已将“java”放在常量池中,所以前者返回的不是str2在堆中的引用,而是java第一次出现时在堆中的引用);

String str3 = new String("堆"); // 将先在常量池中创建,再在堆中创建另一个
str2.intern() == str2; // false ,前者返回的是因为字面量创建在常量池中的;

存储的数据:

  • 类型信息、字段信息、方法信息;

  • 常量外的类静态变量对象;

  • 一个指向 ClassLoader (堆中)的指针;

  • 一个指向 Class 对象(堆中)的指针;

  • 常量池:

    • 字面量:常量字面量、基本类型值(静态成员在常量池,成员在堆,局部在栈);

    • 符号引用、直接引用;

特点:

  • 线程共享,需要考虑线程安全问题;
  • JVM 参数可以调整大小;
  • 可以垃圾回收,通过类的卸载;
  • HotSpot虚拟机中GC分代扩展至方法区,用永久代实现方法区;
  • 可能抛出 OutOfMemoryError

配置参数:

-XX:MaxMetaspaceSize // 方法区最大内存
-XX:MetaspaceSize // 方法区内存
-XX:MinMetaspaceFreeRatio //在垃圾收集之后控制最小的元空间剩余容量的百分比

不能通过字符串常量池验证,可通过cglib动态加载足够数量的类验证,可通过大的静态变量对象验证。

2.2 堆

​ 线程共享的一块内存区域,存放对象实例,可抛出 OutOfMemoryError

垃圾回收管理的主要区域,分代收集算法分为新生代和老年代,默认分配比例:新生代:老年代 = 1:2;

新生代

程序新创建的对象从新生代分配内存;有三块空间组成,Eden Space和两块相同的survivor 空间组成,后者分别为 from survivorto survivor ,默认分配比例为:8:1:1。

JVM每次只会使用Eden和其中一块Survivor空间为对象服务,另一块Survivor空闲,故新生代的可用内存空间为90%

年老代

存放经过多次新生代GC仍存活的对象,如缓存对象,新建对象直接进入老年代的情况有:

  • 大对象,可设启动参数;
  • 大的数组对象(中间无引用对象);

2.3 对象创建过程

运行时常量池中找到符号引用

-> 检查类是否加载、链接、初始化

-> 分配内存 (指针碰撞、空闲列表、本地线程分配缓冲)

-> 对象实例字段初始化为0值

-> 对象头信息设置(类型信息、元数据地址、hashCode、GC分代年龄)

-> 执行new后声明的构造方法

2.4 其他内存

堆外内存

不是JVM运行时数据区的一部分;

1.4之后,NIO,一种基于通道Channel和缓冲器ByteBufferIO方式,可以使用Native函数直接分配,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作,避免了Java堆和Native堆来回复制数据;– TODO

因为不受Java堆内存大小限制,但是受物理内存大小限制,因此设置堆内存-Xmx时需要考虑。

配置参数:

-XX:MaxDirectMemorySize // 若不指定,默认与堆内存最大值一致

总结

–TODO,绘制图

二、GC内存回收

从对象存活判断、垃圾收集算法、垃圾收集器、内存分配与垃圾回收策略几个方面整理。

1、对象存活判断

包括:

  • 引用计数算法
  • 可达性分析算法

1.1 引用计数算法

在对象中添加一个引用计数器,每当有一个地方 引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不需要再存活的。

JVM并不使用引用计数法来管理内存,单纯的引用计数

1.2 可达性算法

通过一系列GC Roots的根对象作为起始节点,根据引用关系向下搜索,搜索走过的路径成为引用链,与引用链不相连的对象即是可回收的;

可作为GC Roots的对象:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 在方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepitonOutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

引用涉及强引用、软引用、弱引用、虚引用。-- TODO

2、垃圾收集算法

包括:

  • 标记-清除算法
  • 标记-复制算法
  • 标记-整理算法

适用与分代有关,故先介绍分代收集理论。

2.1 分代收集理论

建立在两个分代假说上:

  • 弱分代假说:绝大多数对象是朝生夕灭的。
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

困难:跨代引用,导致仅回收新生代区域,却需要扫描全部老年代以确定是否可以进行回收。

  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

解决:新生代上建立一个全局的数据结构,记忆集,把老年代划分为若干个小块,标识出老年代的那一块内存会存在跨代引用,此后发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。

分代收集理论决定了后面产生的"Minor GC"、“Major GC”、"Full GC"等,进而产生了”标记-复制算法“、”标记-清除算法“、”标记-整理算法“等。

GC类型定义
  • Minor GC/Young GC:新生代的收集;
  • Major GC/Old GC:年老代的收集,目前只有CMS支持有单独收集老年代行为。Major GC不同上下文也可能指Full GC。
  • Mixed GC:收集整个新生代以及部分老年代,目前只有G1有这种行为。
  • Full GC:收集整个Java堆和方法区;

2.2 标记-清除算法

根据可达性分析算法将存活对象标记,然后将未标记的对象清理掉。

优点:

  • 停止用户线程时间更少甚至不停止用户线程。

问题:

  • 回收对象多时,需要大量单个对象的清理工作。
  • 经历多次回收后,产生许多内存碎片,更大的机会无法给大对象分配内存,则增加了收集次数。

故适用于回收对象较少时,在一些要求低延迟的垃圾收集器的老年代收集中组合标记-整理算法使用。

2.3 标记-复制算法

根据可达性分析算法将存活对象标记,然后标记的对象复制到另一块干净内存中去,将原内存块一次清除。

优点:

  • 回收对象少时,回收效率高
  • 每次回收后,存活对象在内存中紧密排列,没有垃圾碎片问题

问题:

  • 回收对象多时,要进行大量复制和改变引用操作,效率变低。
  • 因为回收过程需要一块干净内存,所以有一部分内存在分配时不可用。

根据弱分代假说,新生代对象大多很快死亡,这个收集算法适用于新生代的垃圾收集。Survivor区用于收集过程中存储存活对象,默认占新生代的1/10,也就是90%的新生代内存可用于分配。同时,需要有逃生门,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖老年代进行分配担保。

2.3 标记-整理算法

标记过程与标记清除一样,后续步骤直接让所有存活对象向内存空间一端移动,直接清理掉边界以外的内存。

优点:

  • 与标记-清除算法相比,不会产生内存碎片问题。
  • 与标记-复制算法相比,不需要保留空内存块。

问题:

  • 与标记-清除算法相比,存活对象多时,移动大量对象复制和修改引用操作,需要全程暂停用户程序才能进行,效率低,延迟相对较高。

适用于要求高吞吐量的垃圾收集器老年代的收集策略,CMS收集器将其和标记-清除算法组合使用,当后者导致垃圾碎片多到影响大对象分配时,使用前者进行收集。

3、垃圾收集器

– TODO

4、垃圾收集器组合

– TODO

5、内存分配与垃圾回收策略

主要包括以下几个策略:

  • 对象优先在Eden分配
  • 大对象直接进入老年代
  • 存活时间长对象进入老年代
  • 动态对象年龄判断
  • 空间分配担保(决定Full GC何时进行)

5.1 对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起 一次Minor GC。

配置参数测试:

-Xms20m -Xmx20m // 设置堆大小20M
-Xmn10m // 设置新生代10m
-XX:SurvivorRatio=8 // 设置eden:from survivor: to survivor = 8:1:1
-XX:+PrintGCDetails // 打印GC日志
public class TestGC {

    private static int BYTE_NUM_1M = 1024 * 1024;

    public static void main(String[] args) {
        byte[] a = new byte[BYTE_NUM_1M * 2],
                b = new byte[BYTE_NUM_1M * 2],
                c = new byte[BYTE_NUM_1M * 2],
                d = new byte[BYTE_NUM_1M * 4];
    }
}

以上代码正常情况下,应该能实现一次Minor GC,能够看到新生代GC后存储着数组d,而a、b、c被移动到年老代。

但是我用JDK11,使用的是G1垃圾回收器,从GC日志中已经不能直接看出上述表现了,这里留在以后看了调优工具后再做更新。–TODO

5.2 大对象直接进入老年代

大对象指需要大量连续空间的对象,如很长的字符串,元素数量庞大的数组,复制开销大,写程序时应该尽量避免分配短命大对象。可以设置JVM参数使占用内存大于设置值的对象直接进入老年代,以避免在新生代间频繁复制。

-XX:PretenureSizeThreshold=3145728 // 这里不能直接写3M
/**
 * VM 参数:-Xmn10m -Xms20m -Xmx20m -XX:PretenureSizeThreshold=4194304 -XX:+PrintGCDetails
 * 4M=4194304
 */
public class TestPretenure {
    private static int BYTE_NUM_1M = 1024 * 1024;
    public static void main(String[] args) {
        byte[] a = new byte[5 * BYTE_NUM_1M];
    }
}

同5.1,这里使用JDK11,不能看出效果,显示5M的数组全部分配在young region。–TODO

5.3 存活时间长的对象进入老年代

虚拟机给每个对象定义了一个年龄计数器(Age),存储在对象头中。经过一次Minor GC后,Age + 1,当年龄增加到一定程度(默认15),会被晋升到年老代。

-XX:MaxTenuringThreshold // 自定义进入老年代的年代数

5.4 动态对象年龄判定

为了更好适应不同程序的内存状况,对象不一定年龄大于MaxTenuringThreshold才可以进入老年代,如果在Survivor区相同年龄对象占用内存之和大于Survivor空间一半,年龄大于或等于该年龄的的对象即可进入老年代。

5.5 空间分配担保

发生Minor GC前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的空间,如果是,则此次Minor GC安全,不然会检查-XX:HandlePromotionFailure参数,如果允许且老年代剩余连续空间大于历次晋升到老年代的新生代对象大小,则可以冒险执行Minor GC,否则要改进为进行一次Full GC。

JDK6 Update24之后,只要老年代剩余连续空间大于新生代对象总大小历次晋升的平均大小,就会进行Minor GC,否则进行Full GC。不再使用上面那个参数。

文章中仍有许多需要完善的地方,后续继续读书和看博客会继续更新。
欢迎批评指正。

参考:

​ 《深入理解Java虚拟机 第三版》

​ https://www.jianshu.com/p/0ecf020614cb JVM运行时内存

​ https://www.cnblogs.com/warehouse/p/9466137.html 内存、GC、调优工具

​ https://www.cnblogs.com/anyehome/p/9071619.html JVM调优参数、经验规则

你可能感兴趣的:(JVM虚拟机,java)