对于从事C、C++程序开发的开发人员来说,在内存管理领域,他们既是拥有最高全力的“皇帝”,又是从事最基础工作的劳动人民——既拥有每一个对象的“所有权”,又担负着每一个对象声明从开始到终结的维护责任。对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄露和内存溢出的问题,看起来由虚拟机管理内存一切都很美好。不过,也正是因为Java程序员把控制内存的权利交给了Java虚拟机,一旦出现内存泄露和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题将会成为一项异常艰难的工作。
1,运行时数据区域
JVM内存结构分为5大区域:程序计数器、虚拟机栈、本地方法栈、堆、方法区。
1.1,程序计数器
线程私有的:作为当前线程的行号指示器,用于记录当前虚拟机正在执行的线程指令地址。程序计数器主要有两个作用:
- 当前线程所执行的字节码的行号指示器,通过它实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,当线程被切换回来的时候能够知道它上次执行的位置。
【问题】没有程序计数器会怎么样?
【答案】没有程序计数器,Java程序中的流程控制将无法得到正确的控制,多线程也无法正确的轮换。
1.2,虚拟机栈
线程私有,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会创建一个栈帧用于存储:局部变量表、操作时栈、动态链接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个帧栈在虚拟机栈中从入栈到出栈的过程。
局部变量表:存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double),对象引用(reference类型,它等同于对象本身,可能是一个指向对象期起始地址的引用指针,也可以是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令地址)。
【问题】为什么要把堆和栈区分出来呢?栈中不是也可以存储数据吗?
【答案】
栈的使用效率比堆要高。栈的存储和访问都非常快速,因为栈是一种基于硬件支持的数据结构,栈指针的移动是由硬件直接实现的。而堆的存储和访问需要进行复杂的管理,包括申请、释放、分配等,效率较低。
堆可以动态分配内存。由于栈的大小是固定的,因此无法动态地分配内存。堆的大小是动态变化的,可以根据程序的需要进行动态调整。
堆和栈的生命周期不同。栈中的数据随着函数的执行结束而自动销毁,而堆中的数据需要手动释放,否则会发生内存泄漏。因此,堆和栈需要分别管理,以保证程序的正确运行。
【问题】堆栈的区别?
【答案】
堆的物理地址分配是不连续的,性能较慢;栈的物理地址分配是连续的,性能相对较快。
堆存放的是对象的实例和数组;栈存放的是局部变量,操作数栈,返回结果等。
堆是线程共享的;栈是线程私有的。
1.3,本地方法栈
虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的本地方法服务。本地方法栈中方法使用语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机直接就把本地方法栈和虚拟机栈合二为一。
1.4,Java堆
堆用于存放对象实例,是垃圾收集器管理的主要区域,因此也被称作GC堆。堆可以细分为:新生代(Eden空间、From Survivor、To Survivor空间)和老年代。
(1)新生代:是用来存放新生的对象。一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发MinorGC 进行垃圾回收。新生代又分为Eden区、ServivorFrom、ServivorTo 三个区。
- Eden区:Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当 Eden 区内存不够的时候就会触发 MinorGC,对新生代区进行 一次垃圾回收。
- ServivorFrom:上一次GC的幸存者,作为这一次GC的被扫描者。
- ServivorTo:保留了一次 MinorGC 过程中的幸存者。
(2)老年代:主要存放应用程序中生命周期长的内存对象。老年代的对象比较稳定,所以 MajorGC 不会频繁执行。在进行 MajorGC 前一般都先进行 了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。
(3)永久代(方法区):指内存的永久保存区域(使用虚拟机内存),主要存放 Class 和 Meta(元数据)的信息,Class在被加载的时候被放入永久区域,它和和存放实例的区域不同,GC不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的Class的增多而胀满,最终抛出OOM异常。
1.5,方法区&元空间
线程共享,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编辑器编译后的代码缓存等数据。(Class存放在方法区中)对方法区进行垃圾回收的主要目标是对常量池的回收和对类的卸载。
在Java虚拟机启动时,就会创建一个方法区,并且在整个程序运行期间都会存在,直到程序结束或虚拟机关闭。因此,类的信息也会在整个程序运行期间存在于方法区中。当程序需要使用某个类时,Java虚拟机会从方法区中加载对应的class对象,并且创建对应的实例对象。
Java 8之后,方法区被移除了,被称为元空间(Metaspace)。它是使用本机内存(即直接内存)实现的。元空间不再是JVM堆外的内存,而是通过使用本地内存来存储类的元数据和常量池等信息。但其作用和之前的方法区类似,用于存储类的信息、常量、静态变量、即时编译器编译后的代码等。因此,Java 8中的class对象也通常被放在元空间中。
与方法区不同的是:
- 元空间并不在虚拟机中,而是使用直接内存;
- 元空间的内存空间不再是固定的,而是可以根据需要进行扩展或收缩。这也是为啥扩展时,元空间的扩张程度小于方法区的原因。
由于元空间不再限制于固定大小,因此其收缩程度相对较小。
- Java 8中的元空间是在堆内存中动态分配的,因此不再有固定的最大容量限制,而是受到可用堆内存大小的限制,相比方法区内存溢出的概率更小。
1.6,运行时常量池
运行时常量池是方法区的一部分,在类加载之后,会将编译器生成的各种字面量和符号引号放到运行时常量池。在运行期间动态生成的常量,如 String 类的 intern()方法,也会被放入运行时常量池。
1.7,直接内存
直接内存并不是虚拟机运行时数据区的一部分,但是这部分也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。在Java1.4中新加入了NIO类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
2,Java堆中的对象
2.1,对象的创建
在语言层面上,创建对象通常仅仅是一个new关键字而已,而在虚拟机中,new 一个对象在堆中的过程主要分为五个步骤:
加载阶段:将类的字节码文件加载到JVM中,并创建对应的Class对象,这个过程可以通过类加载器完成。
验证阶段:对类的字节码进行验证,以确保字节码的格式是正确的,并且符合JVM规范。
准备阶段:为类的静态变量分配内存,并将其初始化为默认值(例如,数值类型的默认值为0,对象类型的默认值为null)。
解析阶段:将符号引用转化为直接引用,这个过程涉及到类或接口、字段、类方法、接口方法和方法类型的解析。
初始化阶段:执行类的初始化代码,包括静态初始化块和赋值语句等,这个阶段的触发条件包括类被实例化、类的静态方法或静态属性被调用等。
内存分配问题:假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配的内存就仅仅是把那个是指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。
但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。
选择那种分配方式由Java堆是否规整决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
指针移动问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存。
解决这个问题的有两种方案:
- 对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新原子性。
- 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,就在哪个线程的本地缓冲区分配缓冲,哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓冲区时才需要同步锁定。虚拟机是否使用本地线程分配缓冲区,可以通过-XX:+/-UseTLAB参数来设定。
2.2,对象的内存布局
在HotSpot虚拟机中,对象在堆内存中的存储分局可以划分为三个部分:对象头、示例数据和对齐填充。
HotSpot虚拟机对象的对象头部包括两类信息:
(1)第一类是用来存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32个比特和64个比特。官网称它为“Mark Word”,对象需要存储的运行时数据很多,其实已经超过了32、64位Bitmap结构能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。
例如32位的HotSpot虚拟机中,如对象未被同步锁锁定的情况下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态下对象的存储内容如下:
存储内容 标志位 状态 对象哈希码、对象分代年龄 01 未锁定 指向锁记录的指针 00 轻量级锁定 指向重量级锁的指针 10 膨胀(重量级锁定) 空,不需要记录 11 GC标志 偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向 (2)对象头的另外一部分是类型指针,即对象指向它的类型元数据指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有虚拟机事先都必须在对象数据上保留类型指针,换句话说,查找对象的元数据并不一定要经过对象本身。
如果对象是一个Java数组,那再对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度不确定的,将无法通过元数据中的信息推断出数组的大小。
实例部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
HotSpot虚拟机默认的分配顺序为longs/double、ints、shorts/chars、bytes/booleans、oops,从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true,那子类之前较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。
对象的第三部分是对齐填充,这并不是必然存在的,也没有特殊含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说任何对象的大小都必须是8字节的整数倍。对象头部信息已经被精心设计成正好8字节的整数倍,因此,如果对象实例数据没有对齐,就需要通过对齐填充来补全。
2.3,对象的访问定位
创建对象自然是为了后续使用该对象,Java程序会通过栈上的reference数据来操作堆上的具体对象。对象的访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种方式:
- 如果使用句柄访问的话:Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息
- 如果使用直接指针访问的话:Java堆中独享的内存布局就必须考虑如何防止访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
使用直接指针来访问的最大好处就是速度更快,它节省了一次指针定位的开销,由于对象访问在Java中非常频繁,因此这类开销及小成多也是一项极为可观的执行成本。
3,对象死亡判断
经过半个多世纪的发展,今天的内存动态分配与内存收回技术已经相当成熟,一切都进入了自动化时代,那么为什么还需要了解垃圾收集和内存分配?原因很简单:当需要排查各种内存溢出、内存泄露问题时,当垃圾收集成为系统高并发的瓶颈时,我们就必须对这些自动化的技术实施必要的监控和调节。
Java内存运行时区域的各个部分里面,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的帧栈随着方法的进入和退出有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟着回收了。
而Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正式这部分内存该如何管理。
3.1,引用计数法
【思想】在对象中添加一个引用计数器,每当一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为0的对象就是不可能再被使用的。
【优缺点】引用计数法虽然占用了一些额外的内存空间来进行计数,但它原理简单,判定效率高,在大多数情况下是个不错的算法。但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是这种算法有很多例外情况的发生,必须配合大量的额外处理才能保证正常地工作。
- 单纯的引用计数就很难解决对象之间的相互循环依赖引用的问题。
- 对每个对象维护引用计数需要一定的开销,尤其在并发环境下,还需要考虑线程安全问题。如果引用计数过多,可能会影响应用程序的性能。
无法解决孤岛对象问题:即使某个对象的引用计数为0,但如果它仍然存在于其他对象的引用链中,就无法被垃圾收集器回收,这种情况也被称为孤岛对象问题。
【应用场景】可达性分析算法通常应用于现代的垃圾收集器中,例如标记-清除算法、标记-整理算法、复制算法等;而引用计数法则在一些特定场景下应用较多,例如C++的垃圾收集器中。
3.2,可达性分析
当前主流的商用程序语言(Java、C#)的内存管理子系统,都是通过可达性分析算法来判断对象是否存活的。可达性分析算法相对于引用计数法,可以解决循环引用和孤岛对象等问题,但其本身存在效率问题和内存泄漏问题;这个算法的【思路】就是通过一系列称为“GC Roots”的根对象作为起始点集,从这些节点开始,然后遍历所有通过引用与“GC Root”对象相连的对象,这些对象被称为“可达对象”,其余未被遍历到的对象被认为是“不可达对象”,可以被垃圾收集器回收。
【缺点】
可达性分析算法实现相对较复杂,需要进行对象引用关系的遍历和分析,而引用计数法的实现相对较简单。
效率问题:可达性分析算法需要遍历整个堆内存中的对象,而堆内存中对象的数量很大,如果要对所有对象都进行可达性分析,需要消耗大量的计算资源和时间。
内存泄漏问题:可达性分析算法仅能够判断当前时刻某个对象是否可达,但无法判断未来某个时刻某个对象是否会变成垃圾对象。如果某个对象之后不再被应用程序所引用,但由于某些原因,该对象的引用链仍然存在,那么该对象就无法被垃圾收集器回收,导致内存泄漏。
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
虚拟机栈中的引用对象:虚拟机栈中的本地变量表中引用的对象,包括基本数据类型和对象的引用。
常量池中的引用对象:常量池中引用的对象,包括字符串常量和类常量等。
静态变量中的引用对象:静态变量中引用的对象,包括类变量和常量。
- 在本地方法栈中JNI(Native方法)引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象。
3.3,引用类型
无论是通过引用计数法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判断对象是否存活都和“引用”离不开关系。在JDK1.2版本之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。这种定义没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述“食之无味,弃之可惜”的对象显得无能为力。譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之内,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应用场景。
在JDK1.2版之后,Java对应用类型的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用:
- 强引用是最传统的“引用”定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联的关系,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出。在JDK1.2版之后提供了SoftReerence类来实现软引用。
- 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2版之后提供了WeakReference类来实现弱引用。
- 虚引用也被称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2版之后提供了PhantomReference类来实现虚引用。
3.4,对象的自我救赎
即使在可达性分析算法中判断为不可达对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真的宣布一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它就会被标记一次。随后会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行他们的finalize()方法。这里说的“执行”是只虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对想要在finalize()中拯救自己——只要重新与引用链上的任何一个对象建立关系即可,譬如把自己(this关键字)赋值给某个类变量或者成员变量,那再第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
public class FinalizeEscapeGC { private static FinalizeEscapeGC SAVE_HOOK = null; public void isAlive() { System.out.println("yes, i am still alive."); } protected void finalize() throws Throwable { super.finalize(); System.out.println("finalize method executed!"); FinalizeEscapeGC.SAVE_HOOK = this; } public static void main(String[] args) throws InterruptedException { SAVE_HOOK = new FinalizeEscapeGC(); SAVE_HOOK = null; System.gc(); //因为Finalizer方法优先级低,暂停0.5s,等待。 Thread.sleep(500); if (SAVE_HOOK!=null){ SAVE_HOOK.isAlive(); }else { System.out.println("no, i am ded!"); } SAVE_HOOK = null; System.gc(); //因为Finalizer方法优先级低,暂停0.5s,等待。 Thread.sleep(500); if (SAVE_HOOK!=null){ SAVE_HOOK.isAlive(); }else { System.out.println("no, i am ded!"); } } } ----------------------------------------------- finalize method executed! yes, i am still alive. no, i am ded!
从结果可以看出,SAVE_HOOK对象的finalize()方法确实被垃圾收集器触发过,并且在被收集前成功逃脱。另外代码中两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败了。这是因为任何一个对象finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码自救行动失败了。
关于对象死亡时finalize()方法的描述可带点悲情的艺术加工,并不鼓励使用这个方法来拯救对象。相反,建议大家避免尽量使用,因为它不能等同于C和C++语言中的析构函数,而是Java刚诞生时为了使传统C、C++程序员更容易接受Java所做出的一项妥协。它的运行代价昂贵,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。
finalize()描述为“适合去做关闭外部资源之类的请理性工作”,这完全是对finalize()方法用途的一种自我安慰。finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时,完全可以忘记。
3.5,回收方法区
有些人认为方法区是没有垃圾收集行为的,实际上确实有未实现或未能完整实现方法区类型卸载的收集器存在,方法区垃圾收集的“性价比”通常也是比较低的:在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收因为苛刻的判定条件,其区域垃圾收集的回收成果往往低于此。
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃的常量与回收Java堆中的对象非常类似:假如一个字符串“Java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“Java”,已经没有任何字符串对象引用常量池中的“Java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“Java”常量就将会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
判断一个常量是否“废弃”还是相对简单的,而要判断一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非精心设计的可替换类加载器的场景,如OSGi、JSP的重加载。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而不是像对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息。
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。