深入理解Java虚拟机(后续更新)

CSDN话题挑战赛第2期
参赛话题:学习笔记

第1章 走进Java

1.1 概述

1、 Java不仅仅是一门编程语言,它还是一个由一系列计算机软件和规范组成的技术体系,这个技术体系提供了完整的用于软件开发和跨平台部署的支持环境,并广泛应用于嵌入式系统、移动终端、企业服务器、大型机等多种场合。

2、​Java能获得如此广泛的认可,除了它拥有一门结构严谨、面向对象的编程语言之外,还有许多不可忽视的优点:它摆脱了硬件平台的束缚,实现了"一次编写,到处运行"的理想;它提供了一种相对安全的内存管理和访问机制,避免了绝大部分内存泄漏和指针越界问题;它实现了热点代码检测和运行时编译及优化,这使得Java应用能随着运行时间的增长而获得更高的性能;它有一套完整的应用程序接口,还有无数来自商业机构和开源社区的第三方类库来帮助用户实现各种各样的功能…Java带来的这些好处,让软件的开发效率得到了极大的提升。

1.2 Java技术体系

  1. JDK:Java程序设计语言、Java虚拟机、Java类库
  2. JRE:Java SE API子集、Java虚拟机
  3. ​Java SE :支持面向桌面级应用的 Java平台,提供了完整的 Java核心API,这条产品线在JDK6以前被称为 J2SE
  4. Java EE:支持使用多层架构的企业应用的 Java平台,除了提供Java SE API外,还对其做了大量有针对性的扩充,并提供了相关的部署支持,这条产品线在 JDK6以前被称为 J2EE,在 JDK10以后被Oracle放弃,捐献给Eclipse基金会管理,此后被称为 Jakarta EE。

第2章 Java内存区域与内存溢出异常

2.1 概述

Java程序员把控制内存的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题将会成为一项异常艰难的工作。

2.2 运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。

2.2.1 程序计数器
  1. 程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成
  2. 由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对应多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为 ” 线程私有 “ 的内存。
2.2.2 Java虚拟机栈
  1. 与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。
  2. 在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
2.2.3 本地方法栈

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

2.2.4 Java Heap(堆)

对应Java应用程序来说,Java堆是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。Java世界里 “ 几乎 ” 所有的对象实例都在这里分配内存,会有内存逃逸就会导致有的对象存储在栈上。

Java堆是垃圾收集器管理的内存区域,因此一些资料中它被称为 “GC 堆“ 。Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。堆无法进行扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

2.2.5 方法区

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

2、说到方法区,不得不提一下永久代这个概念,尤其是在JDK1.8之前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区成为 ”永久代“ ,或者将两者混为一谈。本质上两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是对于其他虚拟机,是不存在永久代的概念的。当年使用永久代来实现方法区的决定不是一个好主意,导致Java应用更容易遇到内存溢出的问题,在JKD6的时候HotSpot开发团队就放弃永久代,逐步改为采用本地内存来实现方法区的计划,到了Jdk7的HotSpot,已经把原来存放在永久代的字符串常量池、静态变量等移出而到了JDK8,终于完全废弃了永久代的概念,改用在本地内存中实现的元空间来代替,把JDK7中永久代中还剩余的内容(主要是类型信息)全部移到元空间中。

2.2.6 运行时常量池

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

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

2.2.7 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存被频繁地使用,而且也可能导致OutOfMemoryError异常出现。

2.3 实战:OutOfMemoryError异常

2.3.1 Java堆溢出

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

/**
 * @User: 老潘
 * @date 2022年09月20日16:29
 * VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=自己的存储路径
 *
 */
public class HeapOOM {
    static class OOMObject{

    }

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

运行结果:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to D:\1DM\GC\java_pid2376.hprof ...
Heap dump file created [28506299 bytes in 0.271 secs]

Java堆内存的OutOfMemoryError异常是实际中最常见的内存溢出异常情况。可以看我一篇文章。

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

1)如果线程请求的栈深度大于虚拟机所允许的最大深度,将会抛出StackOverflowError异常。

2)如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

先将实验范围限制在单线程中操作:

  • 使用 -Xss参数减少栈内存容量。

    结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。

  • 定义了大量的本地变量,增大此方法帧中本地变量表的长度

    结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。

虚拟机栈和本地方法栈测试

/**
 * @User: 老潘
 * @date 2022年09月20日16:54
 * VM Args: -Xss128k
 */
public class JavaVMStackSOF {
    private int stackLength=1;
    public void stackLeak(){
        stackLength++;
        stackLeak();
    }

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

运行结果:
在这里插入图片描述
无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常。可是如果在允许动态扩展栈容量大小的虚拟机上,相同代码则会导致不一样的情况。

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

1、由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。使用 “永久代” 还是 ”元空间" 来实现方法区,对程序有什么实际的影响。

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

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

/**
 * @User: 老潘
 * @date 2022年09月20日18:17
 * VM Args: -XX:PermSize=6M -XX:MaxPermSize=6M
 */
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        // 使用Set保持常量池引用,避免Full GC回收常量池行为
        Set<String> set =new HashSet<>();
        // 在short范围内足以让6MB的PermSize产生OOM了
        short i=0;
        while(true){
            set.add(String.valueOf(i++).intern());
        }
    }
}

运行结果:

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

String.intern()返回引用测试

public class RuntimeConstantPoolOOM {
    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);
    }
}

3、这段代码在jdk6中运行,会得到两个false,而在jdk7中运行,会得到一个true和一个false。产生差异的原因是:在jdk6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串对象实例在Java堆上,所以必然不可能是同一个引用,结果返回false。

4、而jdk7的intern()方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到了java堆中,那只需要在常量池里记录一下首次出现的实例引用即可。

2.3.4 本机直接内存溢出

直接内存的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与java堆最大值一致。真正申请分配内存的方法是Unsafe::allocateMemory()。

使用unsafe分配本机内存

/**
 * @User: 老潘
 * @date 2022年09月20日18:47
 * -Xmx20M -XX:MaxDirectMemorySize=10M
 */
public class DirectMemorySize {
    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);
        }
    }
}

在JDK8运行结果:

Exception in thread "main" java.lang.OutOfMemoryError
	at sun.misc.Unsafe.allocateMemory(Native Method)
	at demo.OOM.DirectMemorySize.main(DirectMemorySize.java:19)

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

第3章 垃圾收集器与内存分配策略

3.1 概述

1、当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些 “自动化” 的技术实施必要的监控和调节

2、第2章介绍了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而死,栈中的栈帧随着方法的进入和退出而有条不紊地执行者出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域就不需要过多考虑如何回收的问题,当方法结束或线程结束时,内存自然就跟随着回收了。

3、而Java堆和方法区这两个区域则有着很明显的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存可能也不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。

3.2 对象已死?

在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就是要确定这些对象之中哪些还 “存活” 着,哪些已经 “死去”(不可能再被任何途径使用对象)了。

3.2.1 引用计数算法

1、很多教科书中判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计算器为0的对象就是不可能再被使用的。

2、客观来说:引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但是它原理简单,判断效率也很高,在大多数情况下它都是一个不错的算法。(很难解决对象之间的相互循环引用问题)

引用计数算法的缺陷

/**
 * @User: 老潘
 * @date 2022年09月21日15:44
 * testGC()方法执行后,objA和objB会不会被GC呢?
 * VM Args:-XX:+PrintGCDetails  输出GC的详细日志
 */
public class ReferenceCountingGC {
    public Object instance=null;
    private static final  int _1MB=1024*1024;
    /**
     * 这个属性的唯一意义就是占点内存 以便能在GC日志中看清楚是否有回收过
     */
    private byte[] bigSize=new byte[2*_1MB];

    public static void testGc(){
        ReferenceCountingGC objA=new ReferenceCountingGC();
        ReferenceCountingGC objB=new ReferenceCountingGC();
        objA.instance=objB;
        objB.instance=objA;

        objA=null;
        objB=null;

        // 假设在这行发送GC,objA和objB是否能被回收?
        System.gc();
    }

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

java8运行结果:

[GC (System.gc()) [PSYoungGen: 6721K->888K(37888K)] 6721K->896K(123904K), 0.0057594 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 888K->0K(37888K)] [ParOldGen: 8K->692K(86016K)] 896K->692K(123904K), [Metaspace: 3147K->3147K(1056768K)], 0.0141890 secs] [Times: user=0.16 sys=0.00, real=0.02 secs] 
Heap
 PSYoungGen      total 37888K, used 983K [0x00000000d6180000, 0x00000000d8b80000, 0x0000000100000000)
  eden space 32768K, 3% used [0x00000000d6180000,0x00000000d6275de0,0x00000000d8180000)
  from space 5120K, 0% used [0x00000000d8180000,0x00000000d8180000,0x00000000d8680000)
  to   space 5120K, 0% used [0x00000000d8680000,0x00000000d8680000,0x00000000d8b80000)
 ParOldGen       total 86016K, used 692K [0x0000000082400000, 0x0000000087800000, 0x00000000d6180000)
  object space 86016K, 0% used [0x0000000082400000,0x00000000824ad210,0x0000000087800000)
 Metaspace       used 3191K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 345K, capacity 388K, committed 512K, reserved 1048576K

从运行结果中可以清楚看到内存回收日志中包含 “6721K->896K",这意味着主流虚拟机并没有因为这两个对象互相引用就放弃回收它们,这也侧面说明了java虚拟机并不是通过引用计数算法来判断是否存活的。

3.2.2 可达性分析算法

这个算法在当前主流的商业程序语言的内存管理子系统中采用。基本思路就是通过一系列称为 "GC Roots" 的根对象作为起始节点集,从这些节点开始根据引用关系向下搜索,搜索过程所走的路径称为 ”引用链“ (Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明对象是不可能再被使用的。

在这里插入图片描述
在Java技术体系里面,固定客源作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧的本地变量表)中引用的对象,臂如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象、臂如java类的引用类型静态变量
  • 在方法区中常量引用的对象,臂如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常说的Native方法)引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(NullPonitException、OutOfMemoryError)等,还有系统类加载器。
  • 所以被同步锁(synchronized关键字)持有的对象。
  • 发映Java虚拟机内部情况的JMXBean 、JVMTI中注册的回调就、本地代码缓存等。
3.2.3 再谈引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判断对象是否存活都和 “引用” 离不开关系。在JDK1.2版本之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有 “被引用” 或者 “未被引用” 两种状态,对于描述一些 ”食之无味,弃之可惜“ 的对象就显得无能为力。臂如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象------很多系统的缓存功能都符合这样的应用场景。

在JDK1.2版之后,Java对引用的概念进行了补充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,引用强度依次减弱。

1、强引用是平常使用最多的引用,是指在程序代码中普通存在的引用赋值,强引用在程序内存不足(OOM)的时候也不会被回收,使用方式:

String str =new String("str");
System.out.println(str)

2、软引用是用来描述一些还有用,但非必须的对象。在系统将要发生内存溢出异常时,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还是没有足够的内存,才会抛出内存溢出异常。使用方法:

// 注意:wrf这个引用也是强引用,它是指向SoftReference这个对象的
// 这里的软引用指的是new String("str")的引用,也就是SoftReference类中T
SoftReference<String> wrf=new SoftReference<String>(new String("str"));

可用场景:创建缓存的时候,创建的对象放入缓存中,当内存不足时,JVM会回收早先创建的对象。

3、弱引用就是只要JVM垃圾回收期发现了它,就会将其回收,使用方法:

xxxxxxxxxx WeakReference<String> wrf=new WeakReference<String>(str);

可用场景:Java源码中的java.util.WeakHashMap中的key就是使用弱引用,我的理解就是,一旦我不需要某个引用,JVM就会自动帮我处理它,这样我就不需要做其他操作。

4、虚引用 跟弱引用机制差不多,但是在被回收之前会被放入到ReferenceQueue中,注意:其它引用是被JVM回收后才被传入ReferenceQueue中的。由于这个机制,所以虚引用大多被用于引用销毁前的处理工作。还有就是,虚引用创建的时候,必须带有ReferenceQueue,使用方法:

PhantomReference<String> prf=new PhantomReference<String>(new String("str"),
new ReferenceQueue<>());
3.2.4 生存还是死亡?

即使在可达性分析算法中判定为不可达的对象,也不是 ”非死不可“ 的,这时候它们暂时还处于 ”缓刑“ 阶段,要真正的宣告一个对象死亡,最多会经历两次标记过程:如果对象在可达性分析后没有与GC Roots想连接的引用链,那它将会被第一次标记,随着进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为 "没必要执行"。

一次对象自我拯救的演示


/**
 * @User: 老潘
 * @date 2022年09月21日16:54
 * 此代码演示了两点:
 *      1、对象可以在被GC时自我拯救
 *      2、这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 */
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK=null;

    public void isAlive(){
        System.out.println("yes ,i am still alive :)");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK=this;
    }

    public static void main(String[] args) throws Throwable{
        SAVE_HOOK=new FinalizeEscapeGC();

        //对象第一次成功拯救自己
        SAVE_HOOK=null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if(SAVE_HOOK!=null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("no i am dead :(");
        }
        // 下面这段代码和上面完全相同,但是这次自救却失败了
        SAVE_HOOK=null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if(SAVE_HOOK!=null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("no i am dead :(");
        }
    }
}

运行结果:

finalize method executed!
yes ,i am still alive :)
no i am dead :(

注意:代码中有两段完全相同的代码片段,执行结果却是一次逃脱成功,一次失败了。因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,就不会在执行finalize()方法。

3.2.5 回收方法区

1、方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收java堆中的对象非常相似。判定一个常量是否 “废弃” 还是相对简单,而要判定一个类型是否属于 “不再被使用的类” 需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类以及任何派生子类的实例。
  • 加载该类的类加载器已经被回收,
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

2、java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的 是仅仅是 “被允许” 而不是和对象一样,没有了引用就必然被回收。

3.3 垃圾回收算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为 ”引用计数式垃圾收集“ 和 ”追踪式垃圾收集“ 两大类,这两类也常被称作 ”直接垃圾收集“ 和 ”间接垃圾收集“ 。下面介绍的所有算法均属于追踪式垃圾收集的范畴。

3.3.1 分代收集理论

1)弱分代假说:绝大多数对象都是朝生夕灭的。

​2)强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

1、这两个分代假说共同奠定了多款常用的垃圾收集器的一致设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量的存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那么把他们集中起来,虚拟机便可以使用较低的频率来回收这个区域,,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

​2、在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域--------因此才有了“ Minor GC ” “Major GC " “Full GC” 这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法------因而发展了 ”标记-复制算法“ ”标记 - 整理算法“ ” 标记 - 清除算法“ 等针对性的垃圾收集算法。

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这样的行为
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
3.3.2 标记 - 清除算法

1、算法分为 “标记” 和 “清除” 两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收未被标记的对象。

2、​主要缺点:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后咋程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

3.3.2 标记 - 复制算法

1、标记 - 复制算法被简称为复制算法。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况。只要移动堆顶指针,按顺序分配即可。

​ 缺点:这种复制算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

​2、Andrew Appel针对具备 “朝生夕灭” 特点的对象,提出了一种更优化的半区复制分代策略,现在称为 “Appel式回收”。 HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。Apple式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另一块Survivor空间上,然后直接清理掉Eden和已使用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也即每次新生代中可用内存为整个新生代的90%。但是任何人都没办法百分百保证每次回收都只有不多于10%的对象存活,因此Apple式回收还有一个充当罕见情况的 “逃生门” 的安全设计,当Survivor空间不足以容纳一次Minor GC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保。

3.3.3 标记 - 整理算法

针对老年代对象的存亡特征,标记过程仍然与 “标记 - 清除” 算法一样,但后续不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。标记 - 清除算法与标记 - 整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。后续详细介绍如何解决。

3.4 HotSpot的算法细节实现(后续补充)

3.5 经典垃圾收集器

3.5.1 Serial收集器

Serial收集器是最基础、历史最悠久的收集器,在jdk1.3.1之前虚拟机新生代收集器的唯一选择。

3.5.2 ParNew收集器
3.5.3 Parallel Scavenge 收集器
3.5.4 Serial Old收集器
3.5.5 Parallel Old 收集器
3.5.6 CMS 收集器
3.5.7 Garbage First 收集器

3.6 低延迟垃圾收集器

3.6.1 Shenandoah 收集器
3.6.2 ZGC 收集器

3.7 实战:内存分配与回收策略

​ Java技术体系的 自动内存管理,最根本的目标是自动化地解决两个问题:自动给对象分配内存以及自动回收分配给对象的内存

​ 对象的内存分配,从概念上讲,应该都是在堆上分配(而实际上也有可能经过即时编译后被拆散为标量类型并间接地在栈上分配)。

3.7.1 对象优先在Eden分配

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

​2、HotSpot虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出时输出当前的内存各区域分配情况。
新生代Minor GC

/**
 * @User: 老潘
 * @date 2022年09月22日10:34
 * VM Args -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
 */
public class testAllocation {
    private static final int _1MB =1024*1024;
    public static void allocation(){
        byte[] allocation1,allocation2,allocation3,allocation4;
        allocation1=new byte[2*_1MB];
        allocation2=new byte[2*_1MB];
        allocation3=new byte[2*_1MB];
        allocation4=new byte[4*_1MB];  // 出现一次Minor GC
    }

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

运行结果:

Heap
 PSYoungGen      total 9216K, used 8192K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 100% used [0x00000000ff600000,0x00000000ffe00000,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 4096K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 40% used [0x00000000fec00000,0x00000000ff000010,0x00000000ff600000)
 Metaspace       used 3237K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 352K, capacity 388K, committed 512K, reserved 1048576K

3、在allocation()方法中,尝试分配三个20MB大小和一个4MB大小的对象,在运行时通过-Xms20M 、-Xmx20M、-Xmn10M这三个参数限制了Java堆大小为20MB,不可扩展,其中10MB分配给新生代,剩下的10MB分配给老年代。-XX:SurvivorRatio=8决定了新生代中Eden区和一个Survivor的空间比例是8:1,新生代总可用空间为9216K(Eden区和1个Survivor区的总容量)。

3.7.2 大对象直接进入老年代

1、大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。在Java虚拟机中避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提取触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。
大对象直接进入老年代

/**
 * @User: 老潘
 * @date 2022年09月22日15:27
 * VM Args:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728
 *
 */
public class testPretenureSizeThreshold {
    private static final int _1MB=1024*1024;

    public static void  threshold(){
        byte[] allocation;
        allocation=new byte[4*_1MB];  // 直接分配在老年代中
    }

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

运行结果:

Heap
 PSYoungGen      total 9216K, used 6264K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 76% used [0x00000000ff600000,0x00000000ffc1e2f0,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 0K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 0% used [0x00000000fec00000,0x00000000fec00000,0x00000000ff600000)
 Metaspace       used 3151K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 345K, capacity 388K, committed 512K, reserved 1048576K
3.7.3 长期存活的对象将进入老年代

1、虚拟机给每个对象定义了一个对象年龄计数器,存储在对象头中。对象通常在Eden区里诞生,如果经历第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象就会被移动到Survivor空间中,并且将对象的年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15),就会被晋升到老年代。对象晋升老年代的年龄阀门,可以通过参数-XX:MaxTenuringThreshold设置。

长期存活的对象进入老年代

/**
 * @User: 老潘
 * @date 2022年09月22日15:41
 * VM Args:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
 */
public class testMaxTenuringThreshold {
    private static final int _1MB=1024*1024;
    public static void testTenuringThreshold(){
        byte[] allocation1,allocation2,allocation3;
        allocation1=new byte[_1MB/4]; // 什么时候进入老年代决定于XX:MaxTenuringThreshold设置

        allocation2=new byte[4*_1MB];
        allocation3=new byte[4*_1MB];
        allocation3=null;
        allocation3=new byte[4*_1MB];
    }

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

运行结果:
以-XX:MaxTenuringThreshold=1参数来运行:

Heap
 PSYoungGen      total 9216K, used 6520K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 79% used [0x00000000ff600000,0x00000000ffc5e300,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 8192K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 80% used [0x00000000fec00000,0x00000000ff400020,0x00000000ff600000)
 Metaspace       used 3235K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 352K, capacity 388K, committed 512K, reserved 1048576K

以-XX:MaxTenuringThreshold=15参数来运行:

Heap
 PSYoungGen      total 9216K, used 6520K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 79% used [0x00000000ff600000,0x00000000ffc5e300,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 8192K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 80% used [0x00000000fec00000,0x00000000ff400020,0x00000000ff600000)
 Metaspace       used 3232K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 351K, capacity 388K, committed 512K, reserved 1048576K
3.7.4 动态对象年龄判定

1、为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中低于或等于某年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到-XX:MaxTenuringThreshold中要求的年龄。

动态对象年龄判定

/**
 * @User: 老潘
 * @date 2022年09月22日16:01
 * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
 */
public class testMaxTenuringThreshold2 {
    private static final int _1MB=1024*1024;
    public static void testTenuringThreshold2(){
        byte[] allocation1,allocation2,allocation3,allocation4;
        allocation1=new byte[_1MB/4];
        allocation2=new byte[_1MB/4];
        allocation3=new byte[4*_1MB];
        allocation4=new byte[4*_1MB];
        allocation4=null;
        allocation4=new byte[4*_1MB];
    }

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

运行结果:

Heap
 PSYoungGen      total 9216K, used 6776K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 82% used [0x00000000ff600000,0x00000000ffc9e310,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 8192K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 80% used [0x00000000fec00000,0x00000000ff400020,0x00000000ff600000)
 Metaspace       used 3237K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 352K, capacity 388K, committed 512K, reserved 1048576K
3.7.5 空间分配担保

1、在发生Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所以对象总空间,如果这个条件成立,那这一次Minor GC可以确保安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败;如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次GC是有风险的;如果小于,或者那个值设置不允许冒险,那么这时就要改为进行一次Full GC。

空间分配担保
XX:HandlePromotionFailure在jdk1.8不能设置参数,jdk7以后会有默认行为:只要老年代的连续空间大于新生代对象的总大小或者历次晋升到老年代的对象的平均大小就进行Minor GC,否则FullGC。

/**
 * @User: 老潘
 * @date 2022年09月22日16:11
 * VM Args: -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:HandlePromotionFailure
 */
public class testHandlePromotion {
    private static final int _1MB=1024*1024;

    public static void testHandle(){
        byte[] allocation1,allocation2,allocation3,allocation4,allocation5,allocation6,allocation7;
        allocation1=new byte[2*_1MB];
        allocation2=new byte[2*_1MB];
        allocation3=new byte[2*_1MB];
        allocation1=null;
        allocation4=new byte[2*_1MB];
        allocation5=new byte[2*_1MB];
        allocation6=new byte[2*_1MB];
        allocation4=null;
        allocation5=null;
        allocation6=null;
        allocation7=new byte[2*_1MB];
    }

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

运行结果:

[GC (Allocation Failure) [PSYoungGen: 8148K->872K(9216K)] 8148K->4976K(19456K), 0.0118993 secs] [Times: user=0.11 sys=0.05, real=0.02 secs] 
[GC (Allocation Failure) [PSYoungGen: 7258K->920K(9216K)] 11362K->5024K(19456K), 0.0027706 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 9216K, used 3268K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 28% used [0x00000000ff600000,0x00000000ff84b370,0x00000000ffe00000)
  from space 1024K, 89% used [0x00000000fff00000,0x00000000fffe6030,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 4104K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 40% used [0x00000000fec00000,0x00000000ff002020,0x00000000ff600000)
 Metaspace       used 3184K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 345K, capacity 388K, committed 512K, reserved 1048576K
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值