JVM概述
一、概念
JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码字节码,就可以在多种平台上不加修改地运行。
JVM体系结构概述
Java程序是怎么运行
二、类加载器子系统
- 类加载子系统负责从文件系统或者网络中加载Class文件,Class文件在文件开头有特定的文件标识。
- ClassLoader只负责Class文件的加载。
- 加载的类信息放在“方法区”内存空间。
类加载机制
类是在运行期间第一次使用时动态加载的,而不是一次性加载所有类。因为如果一次性加载,那么会占用很多的内存。
类加载过程
包括以下 5 个阶段:
- 加载(Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
1. 加载
加载是类加载的一个阶段,注意不要混淆。
加载过程完成以下三件事:
- 通过类的完全限定名称获取定义该类的二进制字节流。
- 将该字节流表示的静态存储结构转换为方法区的运行时存储结构。
- 在内存中生成一个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口。
其中二进制字节流可以从以下方式中获取:
- 从 ZIP 包读取,成为 JAR、WAR 格式的基础。
- 从网络中获取,最典型的应用是 Applet。
- 运行时计算生成,例如动态代理技术,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 的代理类的二进制字节流。
- 由其他文件生成,例如由 JSP 文件生成对应的 Class 类。
2. 验证
==字节码(CA FE BA BE)开头==
确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
3. 准备
类变量是被 static 修饰的变量,==准备阶段为类变量分配内存并设置初始值(零值)==,使用的是方法区的内存。
实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中。应该注意到,实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。
初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123。
1 | public static int value = 123; |
如果类变量是常量,那么它将初始化为表达式所定义的值而不是 0。例如下面的常量 value 被初始化为 123 而不是 0。
1 | public static final int value = 123; |
4. 解析
将常量池的符号引用替换为直接引用的过程。
其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定。
5. 初始化
初始化阶段才真正开始执行类中定义的 Java 程序代码。初始化阶段是虚拟机执行类构造器 <clinit>() 方法的过程。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。
特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。例如以下代码:
1 | public class Test { |
由于父类的
1 | static class Parent { |
接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成
虚拟机会保证一个类的
类初始化时机
在JVM中表示两个class对象是否为同一个类存在的两个必要条件:
- 类的完整类名必须一致,包括包名
- 加载这个类的ClassLoader必须相同
1.对加载器的引用
JVM必须知道一个类型是由启动类加载器加载的还是由用户类加载器加载的。如果一个类型时由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的的时候,JVM需要保证这两个类型的类加载器是相同的。
2. 主动引用
虚拟机规范中并没有强制约束何时进行加载,但是规范严格规定了有且只有下列五种情况必须对类进行初始化(加载、验证、准备都会随之发生):
- 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类没有进行过初始化,则必须先触发其初始化。最常见的生成这 4 条指令的场景是:使用 new 关键字实例化对象的时候;读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候;以及调用一个类的静态方法的时候。
- 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类;
- 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化;
3. 被动引用
以上 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。被动引用的常见例子包括:
- 通过子类引用父类的静态字段,不会导致子类初始化。
1 | System.out.println(SubClass.value); // value 字段在 SuperClass 中定义 |
- 通过数组定义来引用类,不会触发此类的初始化。该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法。
1 | SuperClass[] sca = new SuperClass[10]; |
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
1 | System.out.println(ConstClass.HELLOWORLD); |
类加载器的分类
应用程序是由三种类加载器互相配合从而实现类加载,除此之外还可以加入自己定义的类加载器。
下图展示了类加载器之间的层次关系,称为双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,其它的类加载器都要有自己的父类加载器。这里的父子关系一般通过组合关系(Composition)来实现,而不是继承关系(Inheritance)。
从 Java 虚拟机的角度来讲,只存在以下两种不同的类加载器:
- 启动类加载器(Bootstrap ClassLoader),使用 C++ 实现,是虚拟机自身的一部分;
- 所有其它类的加载器,使用 Java 实现,独立于虚拟机,继承自抽象类 java.lang.ClassLoader。
从 Java 开发人员的角度看,类加载器可以划分得更细致一些:
- 启动类加载器(Bootstrap ClassLoader)此类加载器负责将存放在
\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。 - 扩展类加载器(Extension ClassLoader)这个类加载器是由 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将
/lib/ext 或者被 java.ext.dir 系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。 - 应用程序类加载器(Application ClassLoader)这个类加载器是由 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
双亲委派机制
1.工作过程
一个类加载器==首先将类加载请求转发到父类加载器==,只有==当父类加载器无法完成时才尝试自己加载==。
2.源码分析
以下是抽象类 java.lang.ClassLoader 的代码片段,其中的 loadClass() 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出 ClassNotFoundException,此时尝试自己去加载。
1 | public abstract class ClassLoader { |
3.好处
- 使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一。
- 避免类的重复加载。
- 保护程序安全,防止核心API被随意修改(==沙箱安全机制==)。
自定义的类加载器实现
以下代码中的 FileSystemClassLoader 是自定义类加载器,继承自 java.lang.ClassLoader,用于加载文件系统上的类。它首先根据类的全名在文件系统上查找类的字节代码文件(.class 文件),然后读取该文件内容,最后通过 defineClass() 方法来把这些字节代码转换成 java.lang.Class 类的实例。
java.lang.ClassLoader 的 loadClass() 实现了双亲委派模型的逻辑,自定义类加载器一般不去重写它,但是需要重写 ==findClass()== 方法。
1 | public class FileSystemClassLoader extends ClassLoader { |
三、内存模型
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域。
程序计数器(PC寄存器)
程序计数器是一块比较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 记录正在执行的虚拟机字节码指令的地址(如果正在执行的是本地方法则为空)。
- 是一块很小的内存空间,也是运行速度最快的内存区域。
- 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致。
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复的基础功能都依赖它完成。
- 没有GC、没有OOM(OutOfMemoryError)。
Java虚拟机栈
背景
由于跨平台设计,Java的指令都是根据栈来设计的,不同平台的cpu架构不同,所以不能设计为基于寄存器的。
- 优点:跨平台、指令集小、编译器容易实现
- 缺点:性能低、需要大量指令
基本内容
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:
每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧[插图](Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口、常量池引用等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小,在 JDK 1.4 中默认为 256K,而在 JDK 1.5+ 默认为 1M:
1 | java -Xss2M HackTheJava |
在《Java虚拟机规范》中,对于栈不存在垃圾回收,该区域可能抛出以下异常:
- 当线程请求的栈深度超过最大值,会抛出 StackOverflowError 异常;
- 栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常。
本地方法栈
本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务。
《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。
与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。
堆
对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。
Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。
注:在《Java虚拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配”,“几乎”是指从实现角度来看,随着Java语言的发展,由于即时编译技术的进步,尤其是**逃逸分析技术的日渐强大,栈上分配、标量替换**优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。
Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。
Java堆既可以被实现成固定大小的,也可以是可扩展的。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
可以通过 -Xms 和 -Xmx 这两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。
1
java -Xms1M -Xmx2M HackTheJava
是执行垃圾回收的主要区域,GC(Garbage Collection)。
现代的垃圾收集器基本都是采用分代收集算法,其主要的思想是针对不同类型的对象采取不同的垃圾回收算法。可以将堆分成两块:
- 新生代(Young Generation)
- 老年代(Old Generation)
方法区
基本概述
- 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
- 用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 和堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。
- 虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,对于HotSpot 虚拟机,它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。
- 对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。
- HotSpot 虚拟机把它当成永久代来进行垃圾回收。但很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至元空间。
永久代/元空间
- 方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式。在 JDK 8 之后,原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,静态变量和常量池等放入堆中。
- 在jdk7及以前,习惯上把方法区称为永久代,jdk8开始,使用元空间取代了永久代。
- 两者区别:元空间不再虚拟机设置的内存当中,而是使用本地内存。
运行时常量池
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。
- 常量池表( Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
- 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
- 运行时常量池相对于Class文件常量池的一大重要特征:具备动态性。如:除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern()。
直接内存
- 直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
- 但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
- 本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
- 在 JDK 1.4 中新引入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。
Minor GC 和 Full GC
- Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
- Full GC:回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。
内存分配策略
1. 对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。
2. 大对象直接进入老年代
大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。
经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
- XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。
3. 长期存活的对象进入老年代
为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。
- XX:MaxTenuringThreshold 用来定义年龄的阈值。
4. 动态对象年龄判定
虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
5. 空间分配担保
在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。
Full GC 的触发条件
对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:
1、调用 System.gc()
只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
2、老年代空间不足
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
3、空间分配担保失败
使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。具体内容请参考上面的第 5 小节。
4、 JDK 1.7 及以前的永久代空间不足
在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
5、Concurrent Mode Failure
执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
四、对象在堆中分配过程
概述
为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
一般过程
- new的对象先放着伊甸园区,此区有大小限制。
- 当伊甸园区空间满了时,程序又要创建新的对象时,JVM垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区的不再被其他对象所引用的对象进行销毁,再加载新的对象到伊甸园区。
- 然后将伊甸园区剩余的对象移动到幸存者0区。
- 如果再次触发垃圾回收,此时上次放到幸存者0区的,如果没有回收,就会放到幸存者1区。每个对象被分配了一个年龄计数器(age),每进行一次GC,幸存下来的对象age累加
- 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
- 第N次GC时,如果有对象的age已经是15了,如果此时这些对象还是被引用的,则将其晋升到Old区;其中15为默认阈值,这个阈值是可以自己设置:
XX:MaxTenuringThreshold=<N>
注意:
- Eden、S0、S1,默认内存比例为6:1:1。
- 当Eden区满了会触发垃圾回收(Eden区和S区一起回收),S区满了则不会触发。
特殊过程
- 创建一个新的对象时,首先判断Eden区是否放的下,如果放的下,就为其分配内存,放不下的话,就进行YGC;
- 然后再判断Eden区是否放的下,如果此时放的下的话,就为其分配内存,如果还是放不下,说明这个对象比伊甸园区的空间还要大,这个对象是个超大对象,此时将其放入Old区,如果Old区也放不下,就进行FGC,然后再判断Old区能不能放下,放的下就存在Old区,如果还是放不下,就会出现OOM异常;
- 在进行YGC的时候,幸存的对象会放入幸存区,此时判断是否能放下,如果幸存区放不下,就会直接放入Old区。
总结
- S0区和S1区,也被叫做From区和To区,判断二者很简单,谁是空的谁是To,则另外一个就是From区;
- 关于幸存者S0和S1区:复制之后有交换,谁空谁是To;
- 每次GC时,幸存的对象则会被放入To区,以上面的为例,第一次GC时,S0是To区,当GC完成后,S1区是空的就变成了To区;
- 对于垃圾回收:频繁在新生区进行回收,很少在老年区收集,几乎不在永久区/元空间收集。
五、垃圾回收
Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人想出来。—《深入理解Java虚拟机》
概述
垃圾收集(Garbage Collection)
在Java虚拟机的堆和方法区中,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分的内存。
程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行垃圾回收。
什么是垃圾
垃圾是指在运行程序当中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
判断一个对象是否可被回收
1、引用计数算法
为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
在两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正是因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
1 | public class Test { |
在上述代码中,a 与 b 引用的对象实例互相持有了对象的引用,因此当我们把对 a 对象与 b 对象的引用去除之后,由于两个对象还存在互相之间的引用,导致两个 Test 对象无法被回收。
2、可达性分析算法
以根对象集合( GC Roots ) 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。
Java 虚拟机使用该算法来判断对象是否可被回收,GC Roots 一般包含以下内容:
- 虚拟机栈中局部变量表中引用的对象
- 本地方法栈中 JNI (本地方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
3. finalize()
类似 C++ 的析构函数,用于关闭外部资源。但是 try-finally 等方式可以做得更好,并且该方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。
当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会再调用该方法。
4.方法区的回收
因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。
主要是对常量池的回收和对类的卸载。
为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。
类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:
- 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
引用类型
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。
Java 提供了四种强度不同的引用类型。
1. 强引用
被强引用关联的对象不会被回收。
使用 new 一个新对象的方式来创建强引用。
1 | Object obj = new Object(); |
2. 软引用
被软引用关联的对象只有在内存不够的情况下才会被回收。
使用 SoftReference 类来创建软引用。
1 | Object obj = new Object(); |
3. 弱引用
被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。
使用 WeakReference 类来创建弱引用。
1 | Object obj = new Object(); |
4. 虚引用
又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。
为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。
由于虚引用可以跟踪对象的回收时间,因此,可以将一些资源的释放操作放置在虚引用中执行和记录。
使用 PhantomReference 来创建虚引用。
1 | Object obj = new Object(); |
垃圾回收算法
1. 标记 - 清除(Mark-Sweep)
在标记阶段,程序会检查每个对象是否为活动对象,如果是活动对象,则程序会在对象头部打上标记。
在清除阶段,会进行对象回收并取消标志位,另外,还会判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。回收对象就是把对象作为分块,连接到被称为 “空闲链表” 的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块。
在分配时,程序会搜索空闲链表寻找空间大于等于新对象大小 size 的块 block。如果它找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 (block - size) 的两部分,返回大小为 size 的分块,并把大小为 (block - size) 的块返回给空闲链表。
不足:
- 标记和清除过程效率都不高;
- 会产生大量不连续的内存碎片,导致无法给大对象分配内存。
2.复制(Copying)
将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
主要不足是只使用了内存的一半。
现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。
HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。
3.标记 - 整理(Mark-Compact)
让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
优点:
- 不会产生内存碎片
不足:
- 需要移动大量对象,处理效率比较低。
4. 分代收集
现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般将堆分为新生代和老年代。
特点:
- 年轻代(Young Gen):区域相对老年代较小,对象生命周期短,存活率低,回收频繁
- 老年代(Tenured Gen):区域较大,对象生命周期长、存活率高,回收不及年轻代频繁
使用:
- 新生代:复制算法
- 老年代:标记 - 清除 或者 标记 - 整理 算法
垃圾收集器
以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。
- 单线程与多线程:单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程;
- 串行与并行:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。
串行、并行与并发的理解
名词概念:
- 串行(Serial):串行是线程排队一个一个来执行
- 并行(Parallel):指多条线程一起执行,是真正的同时运行,在同一个时刻多个任务同时执行。例如多核处理器上,有多个线程同时执行同一段代码。单核处理器无法在同一时刻执行多个任务,因此无法并行。
- 并发(Concurrent):多条线程同时工作,但不一定是并行执行。
此处垃圾回收里的解释:
- 串行(Serial):只会使用一个线程进行垃圾收集工作,此时用户线程处于等待状态。
- 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
- 并发(Concurrent):指用户线程和垃圾收集线程同时执行(不一定是并行的),用户查询在继续运行,而垃圾收集查询运行于另一个CPU上。
1. Serial 收集器
- Serial 翻译为串行,也就是说它以串行的方式执行。
- 它是单线程的收集器,只会使用一个线程进行垃圾收集工作,使用“Stop - the - World”(暂停其他所有的用户线程)机制。
- 它是 Client 场景下的默认新生代收集器,因为在该场景下内存一般来说不会很大。
- 它的优点是简单高效,在单个 CPU 环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
- 它采用复制算法,它收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿时间是可以接受的。
2. Serial Old 收集器
- 串行回收,“Stop - the - World”机制。
- 采用“标记-整理”算法。
- 是 Serial 收集器的老年代版本,也是给 Client 场景下的虚拟机使用。如果用在 Server 场景下,它有两大用途:
- 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
- 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
3. ParNew 收集器
- ParNew是Serial收集器的多线程版本。
- ParNew采用并行回收、“Stop - the - World”机制。
- 是很多JVM运行在Server模式下新生代的默认垃圾收集器。
4.Paralle Scavenge 收集器
- 跟ParaNew一样基于并行回收,采用复制算法、“Stop - the - World”机制。
- 跟ParaNew不同的是,它的目标是达到一个可控制的吞吐量,因此它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户程序的时间占总时间的比值。
- 自适应调节策略也是Paralle Scavenge与ParaNew的一个重要区别。
- 可以通过一个开关参数打开 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
注:
- 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。
- 而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。
- 缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。
5. Parallel Old 收集器
- 是 Parallel Scavenge 收集器的老年代版本。
- 采用“标记-整理”算法,基于并行和“Stop-the-World”机制。
- 在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
- Java8中的默认垃圾收集器。
6. CMS 收集器
- CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。
- 基于并发和“Stop-the-World”机制。
- 低延迟,无法与Paralle Scavenge一起使用。
工作流程
- 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
- 并发标记:进行GC Roots Tracing的过程,它在整个回收过程中耗时最长,不需要停顿。
- 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
- 并发清除:不需要停顿。
在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。
具有以下缺点:
- 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
- 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
- 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
7. G1 收集器
1、概述:
- G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。
- 采用并发和并行,G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU或者多个CPU核心,来缩短Stop-the-World的时间。
- 堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。
特点:
- 空间整合:G1从整体上来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)来看是基于“复制”算法来实现的,这两种算法都意味着不会产生内存空间碎片。
- 可预测的停顿时间模型:能让使用者在指定一个固定的时间片段(M毫秒)内,消耗在垃圾收集上的时间不超过某个长度的时间片段(N毫秒),这点几乎已经成为Java(RTSJ)的垃圾收集器的特征了。
运行原理
- G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。
- 通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
- 每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
Region:
- 使用G1收集器时,它将整个Java堆划分为约2048个相同的独立的Region块,每个Region块大小根据对空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂。
- 可以通过-XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。
- 虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们还都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。
- G1垃圾收集器还新增了一种新的内存区域,叫做Humongous,主要用于存储大对象,如果超过了1.5个Region,就放到H区。
2、工作流程
如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:
- 初始标记
- 并发标记
- 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
- 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
3、G1的垃圾回收详细过程:
- 年轻代GC(Young GC):年轻代Eden区用尽时,并行独占式收集(Stop-The-World)
- 老年代并发标记(Concurrent Marking):堆内存达到一定值(默认45%)
- 混合回收(Mixed GC):标记完成后开始
- 如果需要,但线程、独占式、高强度的Full GC还是继续存在的。它是针对GC的评估失败题空了一种失败保护机制,即强力回收。
Remembered Set
- 使用Remembered Set来避免全局扫描
- 每个Region都有一个对应的Remembered Set
- 在进行垃圾收集的时候,在GC根节点的枚举范围加入Remembered Set,就可以保证不进行全局扫描
年轻代GC(Young GC)
- 扫描根,根是指static变量指向的对象,正在执行的方法滴哦啊用链条上的局部变量等,根引用连同RSet记录的外部引用作为扫描存活对象的入口。
- 处理Dirty card queue中的card,更新RSet。此时的Rset可以准确的反映老年代对所在的内存分段中的对象的引用。
- 处理Rset,识别被老年代对象指向的Eden的对象,这些被指向的Eden中的对象被认为是存活的对象。
- 遍历对象树,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段。
- 处理引用。
注:
dirty card queue : 脏卡队列,再执行引用赋值语句时再写屏障中会将判断当前赋值是否跨代,如果跨代则将其对应的card标记为脏并加入dirty card queue中,后续会由其他线程异步处理更新到rset中。
并发标记
初始标记阶段:
标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC。根区域扫描(Root Region Scanning):
G1 GC扫描survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在young GC之前完成。并发标记(Concurrent Marking):
在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。
同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。再次标记(Remark):
由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照法: snapshot-at-the-beginning(SATB)。独占清理(cleanup,STW):
计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。并发清理阶段:
识别并清理完全空闲的区域。
混合回收
当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。这里需要注意:
是一部分老年代,而不是全部老年代
。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。- 并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收。
- 混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。
- 由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。
垃圾占内存分段比例越高,越会被先回收
。并且有一个阈值会决定内存分段是否被回收。-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。 - 混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。
Full GC(G1回收可选的过程)
G1的初衷就是要避免Full GC的出现。按时如果上述方式不能正常工作,G1会
停止应用程序的执行
(Stop-The-World),使用单线程
的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如
堆内存太小
,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc,这种情况可以通过增大内存解决。导致G1Full GC的原因可能有两个:
- Evacuation(回收)的时候没有足够的to-space来存放晋升的对象;
- 并发处理过程完成之前空间耗尽。