JVM学习总结,全面介绍运行时数据区域、各类垃圾收集器的原理使用、内存分配回收策略

参考资料:《深入理解Java虚拟机》第三版

一,运行时数据区域(基础重中之重)

  Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有着各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁的。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:

image-20230122220805848

  • 程序计数器

​  程序计数器是一块较小的内存空间,可将它看作为是当前线程所执行的字节码的行号指示器。它工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,他是程序控制流的指示器,分支、循环、跳转、异常处线程恢复等基础功能都需要依赖这个计数器来完成(字节码指定的跳转与上下文切换)。

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

  • Java虚拟机栈

  与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

image-20230122223338997

  局部变量表存放了编译器可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,他并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)

  • 本地方法栈

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

​  《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需求自由实现,Hot-Spot虚拟机就直接讲本地方法栈和虚拟机栈合二为一了。

  • Java堆

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

  Java堆既可以被实现成固定大小的,也能设置为可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的,可通过-Xmx-Xms来设定。

  • 方法区

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

  说到方法区,不得不提一下“永久代”这个概念,尤其是在JDK 8以前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的Hotspot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而己,这样使得Hotspot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。

  但是对于其他虚拟机实现,譬如BEA JRockit、IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受 《Java虚拟机规范》管束,并不要求统一。但现在回头米看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了Java应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小,而J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题),而且有极少数方法(例如String:interno)会因永久代的原因而导致不同虚拟机下有不同的表现。当Oracle收购BEA获得了JRockit的所有权后,准备把JRockit中的优秀功能,譬如Java Mission Contro!管理工具,移植到HotSpot虛拟机时,但因为两者对方法区实现的差异而面临诸多困难。

  考虑到HotSpot末来的发展,在JDK 6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存 (Native Memory)来实现方法区的计划了口,到了JDK7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间 (Meta-space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

  • 方法区—运行时常量池

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

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

  • 直接内存

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

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

  • 对象的创建

image-20230123143716957

二,垃圾收集器与内存分配策略

带着三个问题看垃圾收集器:

1)哪些内存需要回收?

2)什么时候执行回收计划?

3)如何回收?

​  回望Java虚拟机的发展,我们已经了解到虚拟机中的程序计数器、虚拟机栈、本地方法栈这三个区域随线程而生、随线程而灭。栈中的栈帧随着方法的进入和退出有条不紊地执行着出栈和入栈的操作。每个栈帧中分配多少内存基本上是在类结构确定下来时就已经已知了,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。

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

1)对象已死

  在Java堆中存放着Java世界的几乎所有对象实例,GC在对堆进行回收之前,第一件事就是要确定这些对象之间哪些还“存活”,哪些已经“死去”了。

  • 引用计数器算法

  在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一。任何时刻计数器为0时该对象就是不可能再被使用的。

​  客观来说该方法虽然占用了一些额外的内存空间来计数,但它的原理简单,判定效率高,大多数情况下它都是个不错的选择。同时它也有自己的缺陷,看下面的伪代码:

/*
MyClass有字段instance,进行赋值令classA.instance = classB,classB.instance = classA。除此之外这两个对象再无任何的引用,实际上这两个对象已经不可能再被访问,但它们因为互相引用着对方,导致它们的引用计数都不为0,也自然无法回收它们。
*/
MyClass classA = new MyClass();
MyClass classB = new MyClass();
classA.instance = classB;
classB.instance = classA;

classA = null;
classB = null;
// 假设此时发生GC,A与B对象能否被回收?可使用-XX:+PrintGCDetails查看
System.gc();

// 此处我使用的是G1收集器,可以看到虚拟机并没有因为这两个对象互相引用就放弃它们,这也说明了Java虚拟机并不是通过引用计数算法来判断对象是否存活的

image-20230123153210376

  • 可达性分析算法

  当前主流的商用程序语言的内存管理子系统,都是通过可达性分析算法来判断对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索走过的路径就称之为“引用链”,如果某个对象到GC Roots间没有任何引用链相连的话,则证明该对象是不可能再被使用的。

image-20230123154849156

在Java的计数体系中,固定可作为GC Roots的对象包括以下几类:

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

  目前最新的几款垃圾收集器(G1/Shenandoah/ZGC/PGC/C4)都具备了局部回收的特征,为了避免GC Roots包含过多对象而过度膨胀,它们在实现上也做出了各种优化处理。

2)再谈引用

  无论是通过计数器算法还是可达性分析算法,判断对象是否存活都离不开引用关系。在jdk2之前,Java中的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据时代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看起来有点过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态。

  在jdk2之后,Java堆引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用,引用强度也随之递减:

  • 强引用:最传统的“引用”定义,是指在程序代码之中普遍存在的引用赋值,类似于Object o = new Object()这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象;
  • 软引用:用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列入回收范围之中进行第二次垃圾回收,如果这次回收还是没有足够的内存才会抛出OOM。jdk2之后提供了SoftReference类来实现软引用;
  • 弱引用:也是用来描述非必须的对象,但是它的强度比软引用更弱一点,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。**当垃圾收集器开始工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。**jdk2之后提供了WeakReference类来实现弱引用;
  • 虚引用:也被称之为“幽灵引用”或“幻影引用”,它是最弱的一种引用关系。**一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。**jdk2之后提供了PhantomReference类来实现虚引用。

3)对象回收

  要真正宣告一个对象的死亡,至少需要经历两次标记过程:如果可达性分析算法没有发现与之关联的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是否有必要执行finalize()方法,假设对象没有覆盖该方法或者已经被调用过了,那就被视为没有必要执行。

  如果该对象被判定为需要执行该方法,name它会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立、低调度优先级的Finalizer线程去执行它们的finalize方法,要注意这个等待执行的过程是非阻塞的。稍后,收集器将对F-Queue中的对象进行第二次小规模的标记,

// 看一段代码,理解一下对象回收的过程
public class Jvm {
   
    public static Jvm SAVE_HOOK = null;

    public void isAlive() {
   
        System.out.println("yes, i'm still alive!");
    }

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

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

        // 对象第一次尝试拯救自己
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500); // finalize() 方法优先级很低,等待一会
        if (SAVE_HOOK != null) {
   
            SAVE_HOOK.isAlive();
        } else {
   
            System.out.println("no, i'm dead");
        }

        // 对象第二次尝试拯救自己,但是这次却失败了
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500); // finalize() 方法优先级很低,等待一会
        if (SAVE_HOOK != null) {
   
            SAVE_HOOK.isAlive();
        } else {
   
            System.out.println("no, i'm dead");
        }
    }
}

4)内存分代收集理论(起源)

  当前商业虚拟机的垃圾收集器,大多数都遵循了分代收集的理论,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

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

  这两个分代假说共同奠定了多款常用的垃圾收集器一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象根据其年龄分配到不同的区域之中存储。Java堆划分出不同的区域之后,垃圾收集器才能每次只回收其中某一个或者某些部分的区域,因此才有了Minor GCMajor GCFull GC这样的回收类型划分,因而发

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值