目录
一、初识JVM
二、JVM执行流程
三、内存区域划分(JVM运行时数据区)
3.1 本地方法栈(线程私有)
3.2 程序计数器(线程私有,无并发问题)
3.3 JVM虚拟机栈(线程私有)
3.4 堆(线程共享)
3.5 元数据区
四、类加载
4.1 类加载的过程
4.2 类加载的时机
4.2.1 如何理解类的加载是一个递归的过程
4.2.2 JVM对递归加载时候产生的循环依赖问题怎么做限制?
4.3 双亲委派模型
4.3.1 什么是双亲委派模型?
4.3.2 上述类加载器如何配合工作?
五、垃圾回收机制
5.1 什么是GC?
5.2 STW问题(stop the world)
5.3 引入ZGC解决STW问题
5.4 GC回收的是什么?
5.5 GC的工作流程
5.5.1 找到垃圾/判定垃圾
5.5.2 如何清理垃圾,进行对象释放
一、初识JVM
JVM是Java Virtual Machine的缩写,是Java虚拟机的意思。它是一个虚拟的计算机,可以在不同的平台上运行Java字节码文件。
二、JVM执行流程
程序在执行之前先要把java代码转换成字节码(class文件),JVM 首先需要把字节码通过一定的方式
类加载器(ClassLoader) 把文件加载到内存中 运行时数据区(Runtime Data Area), 而字节码
文件是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine)将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言的接口 本地库接口(Native Interface) 来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。
三、内存区域划分(JVM运行时数据区)
JVM运行时数据区域也叫做内存布局,但需要注意的是它和Java内存模型(Java Memory Model,简称JMM)完全不同,属于两个不同的概念,JVM可以被理解为一个“应用程序”,当JVM启动的时候需要从操作系统那申请内存,接着JVM会根据需要,把整个空间,分成好几个部分:
3.1 本地方法栈(线程私有)
native表示的是JVM内部的C++代码,因此本地方法栈的意思是:给调用native(JVM内部方法)方法所准备的栈空间。
3.2 程序计数器(线程私有,无并发问题)
在JVM中,程序计数器(Program Counter)是一块较小的内存区域,它可以看作是当前线程所执行的字节码的行号指示器,也可以看作是下一条指令的地址的存储器,它用于记录当前线程执行到哪个指令。
程序计数器在线程切换时才会进行保存,用于恢复执行现场。当一个线程执行 Java 方法时,程序计数器记录的是正在执行的虚拟机字节码指令地址。如果执行的是 Native 方法,计数器值为空。
程序计数器是内存区域唯一一个在JVM中不会出现OOM(Out Of Memory,内存不足)情况的区域。
3.3 JVM虚拟机栈(线程私有)
虚拟机栈是JVM中的一块内存区域,用于存储方法执行中的栈帧:每一个线程在执行的过程中都会创建一个虚拟机栈,用于存储线程执行的方法。当一个方法被调用的时候,虚拟机会为该方法创建一个新的栈帧,并将它放入虚拟机栈中。
由于这里的虚拟机栈是线程所私有的,也就意味着虚拟机栈往往不止有一个,而是多个,我们可以根据jconsole来查看Java进程内部的情况:
需要注意的是:这里的栈也是遵循“先进后出”的原则的。
当一个方法被调用时,虚拟机会为该方法创建一个新的栈帧,并把它压入当前线程的虚拟机栈顶。这是因为当前线程的虚拟机栈顶部是最后一个进入的栈帧,也是最先被执行的栈帧。因此,将新的栈帧压入虚拟机栈顶,可以保证方法的调用顺序是正确的,即最后进入的方法先被执行,最先进入的方法最后被执行。如果将新的栈帧压入栈底,会破坏栈的先进后出的规则,导致程序出现错误。
每一个栈帧被用于存储以下信息:局部变量表、操作数栈、动态链接、方法出口等信息。
- 局部变量表:JVM中的局部变量表是一种用于存储方法执行过程中局部变量的表格数据结构。它是JVM虚拟机栈中的一个重要组成部分,用于暂存方法参数和方法内部定义的局部变量,包括基本数据类型(如int、long、float、double等)、对象引用和returnAddress类型。
- 操作栈:操作栈(Operand Stack)是一种后进先出(LIFO)的数据结构,用于执行方法时保存临时变量和运算结果。
- 动态链接:动态链接是指将程序代码和库函数链接的过程推迟到程序运行时进行,这样可以在程序运行时根据需要加载库函数并将其链接到程序中,从而实现代码共享和动态加载的功能
- 返回地址:用于指示在方法执行完毕后需要返回到哪个指令继续执行。
动态链接有什么优点?
-
减小可执行程序的体积:静态链接时,所有的库文件都会被链接到可执行程序中,这样会导致可执行程序的体积非常大,而动态链接时,库文件并不会被链接到可执行程序中,而是在程序运行时动态加载,这样可以减小可执行程序的体积。
-
减少内存占用:动态链接库会在多个进程中共享,而静态链接库则会被每个进程都载入一份,导致内存占用增大。动态链接可以减少内存占用。
-
更方便的升级和维护:使用动态链接时,可以将动态链接库独立于应用程序升级和维护,不需要重新编译整个程序。
JVM中也采用了动态链接的方式。Java程序在运行时会动态加载类文件并进行链接,而不是在编译时就将类文件链接到可执行程序中。这样可以减小应用程序的体积,同时也方便升级和维护。
以下是虚拟机栈的简图:
说明:栈上的内存空间是跟着方法走的,调用一个方法就会创建栈帧,方法执行结束了,这个栈帧就会销毁了.
需要注意的是虚拟机栈这里的栈帧都是一帧一帧连续的,另外,栈空间具有上限,JVM启动的时候其实是可以设置参数的,其中有一个参数就是设置栈空间的大小。
可以通过JVM参数-Xss来设置线程栈空间的大小,由于设置栈空间大小在实际业务场景中并不常见,这里就不作过多介绍。
3.4 堆(线程共享)
堆的作用:程序中创建的所有对象都保存在堆中,因此类的成员变量也就存放在堆上了。
堆里面分为两个区域:新生代和老生代,新生代放新建的对象,当经过一定GC次数之后还存活的对象会放入老生代。新生代还有3个区域:一个Endn(伊甸园)+两个Survivor区(幸存区):S0,S1。
3.5 元数据区
在JVM中,元数据区是指用于存储Java类信息的内存区域。Java类的元数据信息包括类名、方法名、字段名、访问修饰符,静态成员变量等,这些信息在JVM中都被视为元数据。
哪些信息会被存入元数据区呢?
以下数据是一定会被存入JVM的元数据中:
-
类型信息:JVM会在元数据中存储每个类的类型信息,包括类的名称、访问标志、父类信息、接口信息等。
-
字段信息:JVM会在元数据中存储每个类的字段信息,包括字段名称、访问标志、类型信息等。
-
方法信息:JVM会在元数据中存储每个类的方法信息,包括方法名称、访问标志、返回值类型、参数类型等。
-
字符串常量:在Java程序中,字符串常量会被自动存储到常量池中,而常量池的数据又会被存储到JVM的元数据中。
除此之外,Java还有一些内置类型和常量,例如数字0、1等,它们也会被存储到JVM的元数据中。
需要注意的是:
被final修饰的基本类型和字符串常量不一定被存入JVM元数据区。这取决于JVM的具体实现和对代码的优化策略。在一些情况下,如果被final修饰的变量的值是编译时确定的常量,并且在程序中被频繁使用,JVM会将其存入元数据区以便快速访问,这也是一种常见的优化策略。
但是,这不是Java语言规范中强制要求的行为。
大家不要把被final修饰的常量和字符串常量混淆。
被final修饰的字符串实例是不可变的,也就是说它的值在初始化之后不能被修改,因此可以看作是字符串常量。但是,它是否会被存入JVM的元数据区中取决于具体的实现细节和JVM对代码的优化策略。一般情况下,如果该字符串被频繁使用,JVM会将其放入常量池或元数据区中以提高访问效率。但是,如果该字符串不被频繁使用,JVM可能不会将其放入常量池或元数据区中,而是直接在堆内存中创建一个新的字符串对象。
扩展:
在JDK8之前,JVM中的元数据区被分配在永久代(Permanent Generation)中。但由于永久代的大小是固定的,当存储的元数据过多时,会导致永久代内存溢出的问题。因此,从JDK8开始,元数据区被移动到了堆中的一个叫做"元空间"(Metaspace)的区域中。
元空间不再像永久代一样有着固定的大小限制,而是默认情况下使用本地内存来存储元数据,因此可以动态地增长或缩小。同时,元空间也可以通过JVM参数来配置大小限制。
需要注意的是,在使用元空间时,我们需要注意避免元数据泄漏的问题,因为元空间并没有内存大小的限制,如果出现了泄漏,可能会导致系统的内存耗尽。因此,需要使用一些工具来检测和监控元数据的使用情况,例如使用JDK自带的jstat工具进行监控,以及使用第三方的内存分析工具来定位和解决问题。
小结:
局部变量在栈上,普通成员变量在堆中,静态成员变量在方法区/元数据区。
四、类加载
4.1 类加载的过程
在讲解类加载之前,我们先来看看类的生命周期:
简单来说:类加载就是将.class文件,从文件(硬盘)加载到内存中(元数据区)这样的过程。
可能有人问.class(字节码文件)文件是哪里来的
- 通常我们会先创建一个Java源文件(.java文件),其中包含一个或者多个类的定义。
- 通过命令行或者集成开发环境(IDE)调用Java编译器(javac)
- Java编译器将Java源文件编译成Java字节码文件(.class文件)
- Java字节码文件可以在Java虚拟机上运行,该虚拟机将Java字节码解释成机器码并执行它。
进入正题:类加载的过程主要分为以下五点:加载、验证、准备、解析、初始化。
我们这里不分析JVM的底层实现,因为这些步骤主要是根据C++代码完成的。
- 加载:把.class文件找到,读取文件内容。
- 验证:根据JVM虚拟机规范,检查.class文件的格式是否符合要求。
- 准备:给类对象分配内存空间(先在元数据区占个位置),此时内存初始化全为0,静态成员也就是设为0值了。
- 解析:初始化字符串常量,把 符号引用 转为 直接引用。
- 初始化:调用构造方法,进行成员初始化(真正的针对类对象里面的内容进行初始化),执行代码块,静态代码块,加载父类......
可以通过浏览官方文档Java SE Specifications (oracle.com)来查看JVM的虚拟机规范:
可能有人会问:到底什么是类对象?
Java代码中写的类的所有信息都会包含在这里,是使用二进制的方式重新组织了。
可能很多人会对什么是符号引用,什么是直接引用感到困惑。
我们知道类加载的解析是:初始化字符串常量,把符号引用转为直接引用。
分析:在类加载之前,字符串常量(需要为其分配一个内存空间,存这个字符的实际内容,还得需要一个引用,来保存这个内存空间的起始地址),此时是在.class文件中的,此时这个引用记录的并不是字符串常量的真正地址,而是记录一个偏移量(相当于文件中的),也可以理解此时记录的是一个占位符。
当类加载之后,才真正把这个字符串常量给放到内存中,此时才有”内存地址“。这时的引用才真正的被赋值成指定的内存地址。
上述过程中:
- 引用记录的是偏移量代表的是——符号引用。
- 引用记录的是真正内存地址代表的是——直接引用。
可能还有的朋友对这个偏移量/占位符没有理解,举个例子:
相当于是小学时候组织出去看电影,这时大家都排好队,一个个依次进场,此时张三同学是并不知道这个时候他应该坐在电影院哪个位置上的(类加载之前),但是他能知道自己的前后都是谁(相当于知道偏移量),当进入了电影院后,老师组织学生们坐下(相当于类加载之后),这时张三同学才知道自己到底坐哪。(引用最终被赋值为内存地址)
4.2 类加载的时机
- 一个类在Java程序中被第一次使用时会被加载,一般来说包括以下几种情况:
- 创建类的实例
- 访问类的静态成员变量或者静态方法
- 使用Class.forName()方法动态加载类
- 调用类的静态方法
- 子类被加载时,父类也被加载。
- 当使用JDK的工具进行反射,类比,动态代理等操作时候,也会触发类的加载。
需要注意的是类的加载是一个递归的过程,当一个类被加载后,它依赖的其他类也会被加载。同时,类加载也是一个缓存机制,已经被加载的类会被缓存到内存中,以提高加载的效率。
4.2.1 如何理解类的加载是一个递归的过程
在Java中,类的加载是一个递归的过程,它的含义是:当一个类被加载时,如果它所依赖的其他类还没有被加载,那么JVM会先加载这些依赖的类,然后再加载这个类本身。这些依赖的类可能又会依赖其他的类,于是就形成了一棵依赖树,JVM需要递归地加载这棵树上的所有类。
这个递归的过程可以用以下示意图表示:
+----------------+
| A.class |
+----------------+
|
|
+----------------+
| B.class |
+----------------+
|
|
+----------------+
| C.class |
+----------------+
在上面的示意图中,类A依赖于类B,类B又依赖于类C。当加载类A时,JVM会先加载类B,然后再加载类C,最后才加载类A本身。在实际的开发中,依赖树可能非常复杂,递归加载的过程也会非常深入,这就需要JVM具有良好的递归处理能力。
递归加载的过程还需要注意避免循环依赖的情况,即A依赖于B,而B又依赖于A,这种情况会导致无限递归,从而使JVM陷入死循环。为了避免这种情况的发生,JVM需要对类的加载过程进行控制和限制。
4.2.2 JVM对递归加载时候产生的循环依赖问题怎么做限制?
JVM通过使用双亲委派模型来限制递归加载时可能产生的循环依赖问题。
双亲委派模型可以解决类加载中的循环依赖问题,因为它的加载顺序是从父类加载器开始逐级向下加载,直到找到所需类为止。在这个过程中,如果一个类已经被父类加载器加载过了,那么子类加载器就不会再去加载这个类,从而避免了循环依赖的问题。
具体来说,当一个类需要被加载时,它的类加载器会首先将这个请求委派给它的父类加载器去完成。如果父类加载器无法完成这个加载请求,那么子类加载器才会尝试自己去加载这个类。这个过程会一直递归下去,直到找到所需的类或者所有的父类加载器都无法完成加载请求。
通过这种方式,双亲委派模型可以确保每个类都只被加载一次,并且在类加载器的层次结构中,父类加载器的加载器路径总是在子类加载器的加载器路径之前。这样就可以避免出现循环依赖的情况。如果出现循环依赖,由于子类加载器会优先委派给父类加载器,所以最终只会加载其中一个类,从而打破了循环依赖。
4.3 双亲委派模型
4.3.1 什么是双亲委派模型?
如果一个类加载器收到了类加载的请求,它首先不会自己尝试去加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
双亲委派模型其实描述的就是类加载过程中的 加载(找.class文件的过程) 的过程。
分析:JVM默认提供了三个类加载器,各有分工:
- BootstrapClassLoader:负责加载标准库中的类(标准库:Java规范要求提供哪些类。ps:无论是哪种JVM的实现,都会提供这些一样的类)
- ExtensionClassLoader:负责加载JVM扩展库中的类(扩展库:规范之外,由实现JVM的厂商/组织,提供额外的功能)
- ApplicationClassLoader:负责加载用户提供的第三方库/用户项目 中的类
上述三个类,存在”父子关系“,非父类子类的关系,相当于每个class loader 有一个parent属性指向自己的父类加载器。
4.3.2 上述类加载器如何配合工作?
首先加载一个类的时候,是先从ApplicationClassLoader开始,但是ApplicationClassLoader会把加载任务,交给父亲,让父亲去进行,于是ExtensionClassLoader要去加载了,但是也不是真正加载,而是再委托给自己的父亲,当BootstrapClassLoader要去加载了,也是想委托给自己的父亲,结果发现自己的父亲为null。
当没有父亲/父亲加载完成,没找到类,才由自己进行加载。
此时BootstrapClassLoader就会搜索自己负责的标准库目录的相关的类,如果找到就加载,如果没找到,就继续由子类加载器进行加载。
而ExtensionClassLoader会真正的搜索扩展库相关的目录,如果找到就加载,如果没有找到,就由子类加载器进行加载。
而ApplicationClassLoader真正搜索用户项目相关的目录,如果找到就加载,没找到,就会由子类加载器进行加载,但是由于没有子类了,就只能抛出找不到这样的异常。
为什么会出现上述这样类似于递归的过程?直接从最上面的BootstrapClassLoader加载不可以吗?
首先我们要明白,上述这套顺序其实是出自于JVM实现代码的逻辑,这段代码大概类似于”递归“的方式写的。
这样做的目的最主要是为了保证BootstrapClassLoader能够先加载,ApplicationClassLoader能够后加载这样就可以避免用户创建了一些奇怪的类,引起不必要的bug:
比如用户自己的代码中写了一个java.lang.String这个类,按照上述加载流程,此时JVM加载的还是标准库的类,不会加载到用户自定义的类。
需要注意的是:上述三个类加载是JVM自带的,用户自定义的类加载器也可以加入到上述流程中,就可以和现有的加载配合使用了。
另一方面:用户自定义的类加载器可以加入到双亲委派模型中的任何位置,但是通常情况下会将其作为子加载器添加到某个已有的加载器下,这样可以确保其符合双亲委派模型的约定。
五、垃圾回收机制
5.1 什么是GC?
GC(Garbage Collection)是指垃圾回收,在计算机内存管理中是一种自动回收无用内存的机制。Java中的GC是由JVM自动执行的,它可以在程序运行过程中监控对象的生命周期,并回收那些不再被使用的内存空间。GC可以有效避免内存泄漏和程序运行时出现OutOfMemoryError等内存相关异常。
内存泄漏和内存溢出有什么区别?
内存泄漏(Memory Leak)是指程序在运行过程中,动态分配的内存没有释放,导致系统的总内存越来越少,最终耗尽所有内存资源,无法继续运行。内存泄漏通常由程序设计不当或者错误的编码造成,例如忘记释放动态分配的内存、循环引用等。
内存溢出(Out of Memory,OOM)则是指当程序运行需要使用的内存超过了系统所能分配的内存空间时,导致程序运行失败。内存溢出通常是由于程序本身需要使用大量内存、系统配置不当、内存泄漏等原因造成的。
因此,虽然内存泄漏和内存溢出都会导致程序运行失败,但它们的原因和表现是不同的。在实际编程过程中,需要注意检查和避免出现内存泄漏和内存溢出的情况。
5.2 STW问题(stop the world)
Java中虽然引入了GC机制,让程序猿可以写代码简单些,不容易出错。但是GC有一个比较关键的问题是不能够忽略的:STW问题(stop the world)。
STW(Stop-The-World)问题指的是垃圾回收器在进行垃圾回收时,会暂停整个应用程序的执行,直到垃圾回收完成为止。这个过程中,所有的应用线程都会被暂停,等待垃圾回收结束后才会继续执行。这个暂停时间可能会很长,甚至会影响应用的性能和可用性。
虽然有时候释放没有那么及时,可能导致程序卡顿,但是不至于会引入bug之类的。
STW问题的出现是由于Java的内存管理机制,垃圾回收器需要扫描整个Java堆来寻找和回收不再使用的对象,而这个过程需要在整个Java应用暂停的情况下进行,因为在扫描过程中如果应用程序继续运行,可能会产生新的对象,导致垃圾回收器无法准确地判断哪些对象是可以回收的。
为了缓解STW问题带来的影响,JVM不断优化GC算法,比如增量式GC、并发GC等。同时,应用程序也可以通过一些优化手段来尽可能地避免产生大量的垃圾对象,从而减少GC的频率和STW的时间。
5.3 引入ZGC解决STW问题
ZGC(Z Garbage Collector)是 JDK 11 版本中引入的一款低延迟垃圾收集器。相较于传统的垃圾收集器,ZGC 在极低的停顿时间下,处理了超大堆的内存回收问题。
ZGC 有以下几个特点:
-
并发处理 ZGC 使用了与 G1 相似的算法,将堆空间分成多个区域,同时使用了读屏障和写屏障技术,在应用程序运行的同时,以并发的方式处理垃圾回收。
-
处理超大堆 ZGC 可以处理 TB 级别的堆内存,也就是说,ZGC 可以在不牺牲太多吞吐量的情况下,提供极低的停顿时间。
-
软件和硬件优化 ZGC 基于 C++ 实现,并针对 x86 平台做了优化,还通过 JVM 运行时层面的软件优化,减少垃圾回收时应用程序的停顿。
总体来说,ZGC 的主要目标是在超大堆场景下,提供低延迟的垃圾收集。当然,这不是一个完美的解决方案,根据不同的应用场景,可能需要选择不同的垃圾收集器。
5.4 GC回收的是什么?
JVM中有好多区域,主要为堆,栈,程序计数器,元数据区。首先我们需要明白,GC主要是针对 堆 进行释放。
GC是以”对象“为基本单位,进行回收的(而不是字节)。
:GC回收的是整个对象不再使用的情况,而一部分使用,一部分不再使用的对象,暂且先不回收
什么是一部分使用,一部分不再使用的对象?
比如,一个对象,里面有很多属性,可能其中的10个属性后面再用,10个属性后面不在使用了。
总结:要回收就是回收整个对象,而不是回收所谓的”半个“对象。
5.5 GC的工作流程
5.5.1 找到垃圾/判定垃圾
如何判断一个对象是否为垃圾,关键思路是抓住这个对象,看看他到底有没有”引用“指向它。
这说因为:Java中,使用对象只有一条路,那就是通过引用来使用,如果一个对象,有引用指向它,就可以被使用到,如果一个对象没有引用指向它,就不会再被使用了。
引用计数(非Java的做法,而是Python/php)
大致的实现思路就是:给每个对象分配一个计数器(整数),每次创建一个引用指向该对象,计数器就+1,每次该引用被销毁了,计数器就-1。
如下所示:
{
Test t = new Test();//Test对象的引用计数为1
Test t2 = t;//t2也指向了t,引用计数为2
Test t3 = t;//引用计数为3
}
//大括号结束,上述三个引用超出作用域,失效了,此时引用计数就是0了。
虽然这个方法简单有效,但是Java并没有使用,这是因为它存在以下两个问题:
- 内存空间浪费多(利用率低):假设每个对象要分配一个计数器,这个计数器按照4个字节算,代码中的对象非常少,这时候是无所谓的,但是如果对象特别多的时候,占用的额外空间就会很多,尤其是每个对象都比较小的情况(一个对象占用1k内存,多4个字节是无所谓的,但是一个对象如果是4个字节,那么此时多4个字节,相当于体积扩大了一倍)。
- 存在循环引用问题:
接下来,如果a和b引用同时被销毁, 此时1号对象和2号对象引用计数都-1,但是结果还是1,不是0,但是这个时候其实是应该释放内存的,但是由于不是0,无法完成释放。class Test { Test t = null; } { Test a = new Test(); //1号对象,引用计数是1 Test b = new Test(); //2号对象,引用计数是1 a.t = b; //a.t也指向2号对象,2号对象引用计数是2 b.t = a; //b.t也指向1号对象,1号对象引用计数也是2 }
因此,Python/PHP使用引用计数时,需要搭配其他机制来避免循环引用。
可达性分析(Java的做法)
此算法的核心思想为 : 通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为"引用链",当一个对象到GC Roots没有任何的引用链相连时(从GC Roots到这个对象不可达)时,证明此对象是不可用的。以下图为例:
进行可达性分析遍历的起点,称为GCroots:
栈上的局部变量,常量池中的对象,静态成员变量等。(一个代码中有很多这样的起点,把每个七点都往下遍历一遍,就完成了一次扫描过程)
下面我们用伪代码举个例子:
class TreeNode{
int value;
TreeNode left;
TreeNode right;
}
public class Demo27 {
public static TreeNode build() {
TreeNode a = new TreeNode();
TreeNode b = new TreeNode();
TreeNode c = new TreeNode();
TreeNode d = new TreeNode();
TreeNode e = new TreeNode();
TreeNode f = new TreeNode();
TreeNode g = new TreeNode();
a.value = 1;
b.value = 1;
c.value = 1;
d.value = 1;
e.value = 1;
f.value = 1;
g.value = 1;
a.left = b;
a.right = c;
b.left = d;
b.right = e;
e.left = g;
c.right = f;
return a;
}
public static void main(String[] args) {
TreeNode root = build();
}
}
结构如下:
如上图所示,虽然这里只有root引用,但是上述7个对象都是可达的:
- root-> a
- root.left -> b
- root.left.left ->d
- root.left.right ->e
- root.left.right.left -> g
root.right.right = null 会导致f不可达:f就会被当成垃圾回收了。
root.right = null 会导致c不可达,如果c不可达,那么f就一定不可达。
可能有的同学会想:如果叶子节点引用了根节点该怎么办?是一直循环遍历下去吗?
答案是不会的,可达性分析是给对象标记为可达,如果这个对象已经通过别的路径遍历过了,那么就被标记为可达,既然已经是可达了,那么就没有必要继续往下遍历了。
四大引用
从上面我们可以看出“引用”的功能,除了最早我们使用它(引用)来查找对象,现在我们还可以使用“引用”来判断死亡对象了。所以在 JDK1.2 时,Java 对引用的概念做了扩充,将引用分为强引用(StrongReference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)四种,这四种引用的强度依次递减:
- 强引用 : 强引用指的是在程序代码之中普遍存在的,类似于"Object obj = new Object()"这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象实例。
- 软引用 : 软引用是用来描述一些还有用但是不是必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出之前,会把这些对象列入回收范围之中进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用。
- 弱引用 : 弱引用也是用来描述非必需对象的。但是它的强度要弱于软引用。被弱引用关联的对象只能生存到下一次垃圾回收发生之前。当垃圾回收器开始进行工作时,无论当前内容是否够用,都会回收掉只被弱引用关联的对象。在JDK1.2之后提供了WeakReference类来实现弱引用。
- 虚引用 : 虚引用也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。
总结:
可达性分析需要进行类似于”树遍历“这个操作,相比于引用计数来说,肯定是更慢一些的。
但是速度慢,并没有关系,上述可达性分析遍历操作,并不需要一直执行,只需要每隔一段时间,分析一遍就可以了。
5.5.2 如何清理垃圾,进行对象释放
标记清除
"标记-清除"算法是最基础的收集算法。算法分为"标记"和"清除"两个阶段 : 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
”标记-清除"算法的不足主要有两个:
- 效率问题 : 标记和清除这两个过程的效率都不高
- 空间问题 : 由于标记清除算法的缺点是产生大量的内存碎片,这些碎片难以重复利用,因此对于大型对象或长期存活的对象,标记清除算法的效率很低
例如:总的空闲空间是10k,分成1k一个,一共10个,此时如果申请2k内存,就会申请失败了。
复制算法
复制算法是一种用于垃圾回收的算法,它将堆内存分为两个区域,一般称为“from”区和“to”区。在GC过程中,所有存活的对象都会被从“from”区复制到“to”区,然后“from”区的所有内存都会被清空。
复制算法是一种很简单的垃圾回收算法,它不需要考虑如何解决碎片问题,因为每次回收之后,内存的使用情况都会变成“to”区使用,而“from”区不使用的状态。
缺点:
由于需要将所有存活对象复制到“to”区,因此需要耗费一定的时间和空间,如果垃圾比较少,有效对象比较多,复制成本就大大增加,且空间利用率低。
标记整理
标记-整理算法是一种内存回收算法,也是基于标记-清除算法的改进。与标记-清除算法不同的是,标记-整理算法在标记可达对象后,会将这些对象移动到内存的一端,然后将另一端的内存全部释放。这样可以保证内存的连续性,避免了内存碎片的问题。
ps:这会有点像数组的移动。
基于上述策略,引入了一个新的算法:分代回收
分代算法和上面讲的 3 种算法不同,分代算法是通过区域划分,实现不同区域和不同的垃圾回收策略,从而实现更好的垃圾回收。这就好比我们国家的一国两制方针一样,对于不同的情况和地域设置更符合当地的规则,从而实现更好的管理,这就时分代算法的设计思想。
分析:刚new出来的对象,即年龄是0的对象,放到伊甸区,熬过一轮GC后,对象就被放入幸存区,虽然看起来幸存区比较小,伊甸区比较大,但是一般情况下是够用的(因为大部分的Java对象都是“朝生夕死”,生命周期很短)。
而伊甸区到幸存区,则是利用复制算法,到达幸存区之后,也要周期性的接受GC的考验,如果变成垃圾,就要被释放,如果不是垃圾,就拷贝到另外一个幸存区中(这两个幸存区同一个时刻只会使用一个),两者之间互相拷贝来拷贝去(复制算法实现)。
如果一个对象已经在两个幸存区中来回拷贝多次,那么这个时候就会进入老年代。
老年代是年纪最大的对象,生命周期很长,针对老年代的对象,也是要进行GC扫描,但是频率更低,但是如果老年代的对象也变成垃圾,那么就会使用标记整理方式进行释放。
总结:
上述GC中典型的垃圾回收算法大致分为两个策略:
如何确认垃圾,如何清理垃圾。
实际上JVM在实现的时候,会有一定的差异,因为有很多不同的垃圾回收器(垃圾回收实现)。
回收器具体的实现做法,会根据上述算法思想展开,但是会有一些变化/改进。
不同的垃圾回收器可能侧重点不同:比如有的追求扫的快,有的追求扫的好,有的追求对用户的打扰少。(STW尽量短)