JVM知识点总结

写在前面

本文是最近学习JVM的知识点总结,主要包含以下内容:

  • JVM 内存结构
  • 对象的内存布局、访问方式
  • 类文件结构和加载过程
  • 垃圾收集策略与算法

JVM概述

JVM 是 Java Virtual Machine 的缩写,即Java虚拟机,是一种抽象计算机,它有一个指令集,在运行时操作各种内存区域。虚拟机有很多种,不同厂商提供了不同实现,只要遵循虚拟机规范即可,目前我们所说的虚拟机一般指的是Hot Spot。JVM对Java语言一无所知,只知道一种特定的二进制格式,即类文件格式,我们写好的程序最终交给JVM执行的时候会被编译成二进制格式,JVM只认识二进制格式,所以任何语言只要编译后的格式符合要求,都可以在JVM上运行。

JVM 内存结构

image.png
Java 虚拟机的内存空间分为 5 个部分:

  • 程序计数器
  • Java 虚拟机栈
  • 本地方法栈
  • 方法区

程序计数器

程序计数器是一块较小的内存空间,是当前线程正在执行的那条字节码指令的地址。字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制;在多线程情况下,程序计数器记录的是当前线程执行的位置,当线程切换回来时,就知道上次线程执行到哪了。

  • 线程私有,每条线程都有自己的程序计数器。
  • 生命周期:随着线程的创建而创建,随着线程的结束而销毁。
  • 是唯一一个不会出现 OutOfMemoryError 的内存区域。

Java 虚拟机栈

虚拟机栈是线程独有的空间,每个线程都有一个与线程同时创建的私有的虚拟机栈。虚拟机栈中存储栈帧,每个被线程调用的方法都会产生一个栈帧。调用一个方法就是执行一个栈帧的过程,一个方法调用完成,对应的栈帧就会出栈。

栈帧用于存放该方法运行过程中的一些信息,如:

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 方法出口信息

局部变量表

定义为一个数字数组,主要用于存储方法参数、定义在方法体内部的局部变量,数据类型包括各类基本数据类型,对象引用,以及 return address 类型。

局部变量表容量大小是在编译期确定下来的。最基本的存储单元是 slot(变量槽)。

操作数栈

每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的 操作数栈,也可以称之为表达式栈(Expression Stack)

操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。

动态链接

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokedynamic指令

在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

虚拟机栈可能有以下两种异常:

  • 如果线程执行所需栈深度大于Java虚拟机栈深度,就会抛出StackOverFlowError,其实方法调用的过程就是入栈和出栈的过程,如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,就容易发生异常(递归调用)
  • 如果Java虚拟机栈可以动态扩展,但是扩展大小的时候无法申请到足够的内存,则会抛出OutOfMemoryError。
public static void main(String[] args) {
    test();
}
public static void test() {
    test();
}
//抛出异常:Exception in thread"main"java.lang.StackoverflowError
//程序不断的进行递归调用,而且没有退出条件,就会导致不断地进行压栈。

本地方法栈

本地方法栈是为 JVM 运行 Native 方法准备的空间,由于很多 Native 方法都是用 C 语言实现的,所以它通常又叫 C 栈。它与 Java 虚拟机栈实现的功能类似,只不过本地方法栈是描述本地方法运行过程的内存模型。

堆是用来存放对象的内存空间,几乎所有的对象都存储在堆中。

堆是java虚拟机管理内存最大的一块,在虚拟机启动时创建,所有线程共享,堆中的对象永远不会被显式释放,必须由GC回收,所以GC也主要回收堆中的对象实例,我们平常讨论的垃圾回收就是回收堆内存。堆可以处于物理上不连续的空间,可以固定大小,也可以动态扩展,通过参数-Xms和-Xmx两个参数控制堆的最小值和最大值。

  • Java 7及之前堆内存逻辑上分为三部分:新生区+养老区+永久区

  • Java 8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间

其中永久区/元空间就是方法区,逻辑上属于堆的一部分,下面方法区部分会有讲解

约定:新生区(代)<=>年轻代 、 养老区<=>老年区(代)、 永久区<=>永久代

Java堆区进通常可以划分为年轻代(YoungGen)和老年代(oldGen)

其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)

image.png

新生代与老年代

  • 新生代与老年代空间默认比例 1:2:JVM 调参数,XX:NewRatio=2,表示新生代占 1,老年代占 2,新生代占整个堆的 1/3。
  • HotSpot 中,Eden 空间和另外两个 Survivor 空间缺省所占的比例是:8:1:1
  • 几乎所有的 Java 对象都是在 Eden 区被 new 出来的,Eden 放不了的大对象,就直接进入老年代了。

对象分配过程

  1. new的对象先放伊甸园区。此区有大小限制。

  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区

  3. 然后将伊甸园中的剩余对象移动到幸存者0区。

  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。

  5. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。

  6. 啥时候能去养老区呢?可以设置次数。默认是15次。

  • *可以设置参数:进行设置*`-Xx:MaxTenuringThreshold= N`
    
  1. 在养老区,相对悠闲。当养老区内存不足时,再次触发GC:Major GC,进行养老区的内存清理

  2. 若养老区执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常。

Full GC /Major GC 触发条件

  • 当年轻代空间不足时,就会触发MinorGC(年轻代GC),这里的年轻代满指的是Eden代满
  • 显示调用System.gc(),老年代的空间不够,方法区的空间不够等都会触发 Full GC,同时对新生代和老年代回收,FUll GC 的 STW 的时间最长,应该要避免
  • 在出现 Major GC(老年代GC) 之前,会先触发 Minor GC,如果老年代的空间还是不够就会触发 Major GC,STW 的时间长于 Minor GC

对象和数组并非都是在堆上分配内存的

  • 《深入理解 Java 虚拟机中》关于 Java 堆内存有这样一段描述:随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配,标量替换优化技术将会导致一些变化,所有的对象都分配到堆上也渐渐变得不那么"绝对"了。
  • 这是一种可以有效减少 Java 内存堆分配压力的分析算法,通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
  • 当一个对象在方法中被定义后,它可能被外部方法所引用,如作为调用参数传递到其他地方中,称为方法逃逸
  • 再如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸

TLAB

  • TLAB 的全称是 Thread Local Allocation Buffer,即线程本地分配缓存区,是属于 Eden 区的,这是一个线程专用的内存分配区域,线程私有,默认开启的(当然也不是绝对的,也要看哪种类型的虚拟机)
  • 堆是全局共享的, 在同一时间,可能会有多个线程在堆上申请空间,但每次的对象分配需要同步的进行(虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性)但是效率却有点下降
  • 所以用 TLAB 来避免多线程冲突,在给对象分配内存时,每个线程使用自己的 TLAB,这样可以使得线程同步,提高了对象分配的效率
  • 当然并不是所有的对象都可以在 TLAB 中分配内存成功,如果失败了就会使用加锁的机制来保持操作的原子性
  • -XX:+UseTLAB使用 TLAB,-XX:+TLABSize 设置 TLAB 大小

方法区

方法区的定义

Java 虚拟机规范中定义方法区是堆的一个逻辑部分。方法区存放以下信息:

  • 已经被虚拟机加载的类信息
  • 常量
  • 静态变量
  • 即时编译器编译后的代码

方法区的特点

  • 线程共享。 方法区是堆的一个逻辑部分,因此和堆一样,都是线程共享的。整个虚拟机中只有一个方法区。
  • 永久代。 方法区中的信息一般需要长期存在,而且它又是堆的逻辑分区,因此用堆的划分方法,把方法区称为“永久代”。
  • 内存回收效率低。 方法区中的信息一般需要长期存在,回收一遍之后可能只有少量信息无效。主要回收目标是:对常量池的回收;对类型的卸载。
  • Java 虚拟机规范对方法区的要求比较宽松。 和堆一样,允许固定大小,也允许动态扩展,还允许不实现垃圾回收。

运行时常量池

方法区的一部分,用于存储编译生成的字面量(基本数据类型或被final修饰的常量或字符串)和符号引用,类或接口的运行时常量池是在java虚拟机创建类或接口时创建的。 在jdk1.6以及之前的版本,Java中的字符串是放在方法区中的运行时常量池内,但是在jdk1.7以后将字符串常量池拿出来放在了堆中。

public class GcDemo {

    public static void main(String [] args) {
        String str = new String("abc")+new String("xyz");
        System.out.println(str == str.intern());
    }
}

这段代码在jdk1.6中打印false,在jdk1.7和jdk1.8中打印true。 关于intern()方法:

  • jdk1.6:调用String.intern()方法,会先去检查常量池中是否存在该字符串,如果不存在,则会在方法区中创建一个字符串,而new String()创建的字符串在堆中,两个字符串的地址当然不相等。
  • jsk1.8:字符串常量池从方法区的运行时常量池移到了堆中,调用String.intern()方法,首先会检查常量池是否存在,如果不存在,那么就会创建一个常量,并将引用指向堆,也就是说不会再重新创建一个字符串对象了,两者都会指向堆中的对象,所以返回true。

jdk1.7和jdk1.8实现方法区的区别

  • jdk1.7之前方法区使用永久代实现,方法区大小可以通过参数-XX:PermSize和-XX:MaxPermSize来控制方法区的大小和所能允许的最大值。
  • jdk1.8移除了永久代,采用元空间实现,所以在jdk1.8中永久代的参数改成-XX:MetaspaceSize和-XX:MaxMetaspaceSize。元空间和永久代的一个很大的区别就是元空间已经不在jvm内了,直接存储到了本地内存。

对象的内存布局

在 HotSpot 虚拟机中,对象的内存布局分为以下 3 块区域:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

object-memory-layout.png

对象头

对象头记录了对象在运行过程中所需要使用的一些数据:

  • 哈希码
  • GC 分代年龄
  • 锁状态标志
  • 线程持有的锁
  • 偏向线程 ID
  • 偏向时间戳

对象头可能包含类型指针,通过该指针能确定对象属于哪个类。如果对象是一个数组,那么对象头还会包括数组长度。

实例数据

实例数据部分就是成员变量的值,其中包括父类成员变量和本类成员变量。

对齐填充

用于确保对象的总长度为 8 字节的整数倍。

HotSpot VM 的自动内存管理系统要求对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对齐填充并不是必然存在,也没有特别的含义,它仅仅起着占位符的作用。

对象的创建过程

1、类加载检查

虚拟机在解析.class文件时,若遇到一条 new 指令,首先它会去检查常量池中是否有这个类的符号引用,并且检查这个符号引用所代表的类是否已被加载、解析和初始化过。如果没有,那么必须先执行相应的类加载过程。

2、为新生对象分配内存

对象所需内存的大小在类加载完成后便可完全确定,接下来从堆中划分一块对应大小的内存空间给新的对象。分配堆中内存有两种方式:

  • 指针碰撞
    如果 Java 堆中内存绝对规整(说明采用的是“复制算法”或“标记整理法”),空闲内存和已使用内存中间放着一个指针作为分界点指示器,那么分配内存时只需要把指针向空闲内存挪动一段与对象大小一样的距离,这种分配方式称为“指针碰撞”。
  • 空闲列表
    如果 Java 堆中内存并不规整,已使用的内存和空闲内存交错(说明采用的是标记-清除法,有碎片),此时没法简单进行指针碰撞, VM 必须维护一个列表,记录其中哪些内存块空闲可用。分配之时从空闲列表中找到一块足够大的内存空间划分给对象实例。这种方式称为“空闲列表”。

3、初始化

分配完内存后,为对象中的成员变量赋上初始值,设置对象头信息,调用对象的构造函数方法进行初始化。

至此,整个对象的创建过程就完成了。

对象的访问方式

所有对象的存储空间都是在堆中分配的,但是这个对象的引用却是在堆栈中分配的。也就是说在建立一个对象时两个地方都分配内存,在堆中分配的内存实际建立这个对象,而在堆栈中分配的内存只是一个指向这个堆对象的指针(引用)而已。 那么根据引用存放的地址类型的不同,对象有不同的访问方式。

句柄访问方式

堆中需要有一块叫做“句柄池”的内存空间,句柄中包含了对象实例数据与类型数据各自的具体地址信息。

引用类型的变量存放的是该对象的句柄地址(reference)。访问对象时,首先需要通过引用类型的变量找到该对象的句柄,然后根据句柄中对象的地址找到对象。

handle-access

直接指针访问方式

引用类型的变量直接存放对象的地址,从而不需要句柄池,通过引用能够直接访问对象。但对象所在的内存空间需要额外的策略存储对象所属的类信息的地址。

direct-pointer

需要说明的是,HotSpot 采用第二种方式,即直接指针方式来访问对象,只需要一次寻址操作,所以在性能上比句柄访问方式快一倍。但像上面所说,它需要额外的策略来存储对象在方法区中类信息的地址。

Class 文件结构

Class 文件是二进制文件,它的内容具有严格的规范,文件中没有任何空格,全都是连续的 0/1。Class 文件 中的所有内容被分为两种类型:无符号数、表。

  • 无符号数 无符号数表示 Class 文件中的值,这些值没有任何类型,但有不同的长度。u1、u2、u4、u8 分别代表 1/2/4/8 字节的无符号数。
  • 表 由多个无符号数或者其他表作为数据项构成的复合数据类型。
ClassFile {
    u4             magic;//魔数 用来表示这个 Class 文件的类型
    u2             minor_version;//次版本号
    u2             major_version;//主版本号
    u2             constant_pool_count;//常量池数量 
    cp_info        constant_pool[constant_pool_count-1];//常量池信息
                            //常量池中存放字面值常量(字面值常量就是我们在程序中定义的字符串、被 final 修饰的值)和符号引用(符号引用就是我们定义的各种名字:类和接口的全限定名、字段的名字和描述符、方法的名字和描述符)
    u2             access_flags;//访问标志  用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否被 abstract/final 修饰
    u2             this_class;//类索引  用于确定这个类的全限定名
    u2             super_class;//父类索引  用于确定这个类的父类的全限定名
    u2             interfaces_count;//接口数(2位,所以一个类最多65535个接口)
    u2             interfaces[interfaces_count];//接口索引 
    u2             fields_count;//字段数
    field_info     fields[fields_count];//字段表集合 字段表集合存储本类涉及到的成员变量,包括实例变量和类变量
    u2             methods_count;//方法数
    method_info    methods[methods_count];//方法集合
    u2             attributes_count;//属性数
    attribute_info attributes[attributes_count];//属性表集合
}

类的加载过程(类的生命周期)

按照Java虚拟机规范,从class文件到加载到内存中的类,到类卸载出内存为止,它的整个生命周期包括如下7个阶段:
image.png
其中,验证、准备、解析3个部分统称为链接

加载

加载指的是通过一个完整的类或接口名称来获得其二进制流的形式,并将其按照Java虚拟机规范将数据存储到运行时数据区域,类加载主要做三件事:

  • 通过一个类的全限定名获得定义此类的二进制字节流。
  • 将这个二进制字节流所代表的的静态存储转化为方法区运行时数据结构。
  • 在Java堆中生成一个代表此类的java.lang.Class对象,作为方法区中这些数据的访问入口。

上面第一步在虚拟机规范中并没有说明Class来源于哪里,也没有说明怎么获取,所以就会产生很多的实现方式,下面就是一些常用的实现方式:

  • 最正常的方式:读取本地经过编译的.class文件
  • 从压缩包如zip,jar,war中读取。
  • 从网络中读取
  • 通过动态代理动态生成.class文件
  • 从数据库读取

执行Class的加载需要一个类加载器,而一个良好合格的类加载器需要具有以下两个属性:

  • 对于同一个Class名称,任何时候都应该返回相同的Class对象
  • 如果类加载器L1委派给类加载器L2去加载一个Class对象C,那么以下场景出现的任意类型T,两个类加载器L1和L2都应该返回相同的Class对象:

(1)C的直接父类或者父接口类型
(2)C中的字段类型
(3)C中方法或者构造函数的参数类型
(4)C中方法的返回类型
在Java中类加载器不止一种,对于同一个类用不同的类加载器加载出来的对象是不相等的,那么Java是如何保证上面两点呢?这就是双亲委派模型,Java通过双亲委派模型防止恶意加载,也确保了安全性。

双亲委派模型

定义:当一个类加载器收到加载请求时,自己不去加载,而是交给它的父加载器去加载,以此类推,知道传递到顶层的类加载器,只有当父加载器加载不了这个类,子加载器才会尝试加载这个类。

image.png

上图就是双亲委派模型,顶层加载器使用了虚线表示顶层加载器没有父加载器,从实现上来说,也没有子加载器,是一个独立的加载器,因为扩展类加载器和应用程序加载器从继承关系上来看,是有父子关系的,都继承了URLClassLoader,但是虽然从类的继承关系上启动类加载器没有子加载器,但是逻辑上扩展类加载器还是会将收到的请求优先交给启动类加载器进行优先加载。

  • 启动类加载器:负责加载$JAVA_HOME\lib下的类或者被参数-Xbootclasspath指定的能够被虚拟机识别的类(通过jar名字识别,如rt.jar),启动类加载器由java虚拟机直接控制,开发者不能直接使用启动类加载器。
  • 扩展类加载器:负责加载$JAVA_HOME\lib\ext下的类或者被java.ext.dirs系统变量指定的路径中所有类库,开发者可以直接使用这个类加载器。
  • 应用程序类加载器:负责加载$CLASS_PATH中指定的类库,开发者能直接使用这个类加载器,正常情况下如果我们在应用程序中没有自定义类加载器,一般用的就是这个类加载器。
  • 自定义类加载器:如果需要可以通过java.lang.ClassLoader的子类来定义自己的类加载器,一般我们选择继承URLClassLoader来进行适当改写就行了。

破坏双亲委派模型

双亲委派模型并不是一个强制性的约束模型,只是一种推荐的加载模型,也有不遵守这个模型的:比如JNDI,JDBC等相关的SPI动作并没有完全遵守双亲委派模型,破坏双亲委派模型的一个最简单的方式就是:继承ClassLoader类,然后重写其中的loadClass方法(因为双亲委派的逻辑就在loadClass方法中)

常见异常

如果加载过程出现异常,可能抛出以下异常

  • ClassCircularityError:extends或者implements了自己的类或接口
  • ClassFormatError:类或接口的二进制格式不正确
  • NoClassDefFoundError:根据提供的全限定名找不到对应的类或者接口。

连接

连接:获取类或接口的二进制形式并将其结合到java虚拟机的运行时状态以便执行的过程。连接包括三个步骤:验证、准备和解析。

验证

类加载进来需要格式校验,验证以下几个方面:

  • 文件格式验证:比如说是不是以魔数开头,jdk版本号的正确性等等。
  • 元数据验证:比如说类中的字段是否合法,是否有父类,父类是否合法等等。
  • 字节码验证:主要是确定程序的语义和控制流是否符合逻辑。

如果验证失败,会抛出一个VerifyError。

准备

准备阶段是正式为类变量(或称“静态成员变量”)分配内存并设置初始值的阶段,这些内存都将在方法区中分配。

这里所设置的初始值"通常情况"下是数据类型默认的零值(如 0、0L、null、false 等),比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111。

解析

在准备阶段完成后,就进入了解析阶段。解析阶段(Resolution),简言之,将类、接口、字段和方法的符号引用转为直接引用。

符号引用就是一些字面量的引用,和虚拟机的内部数据结构和和内存布局无关。比较容易理解的就是在Class类文件中,通过常量池进行了大量的符号引用。但是在程序实际运行时,只有符号引用是不够的,比如当如下println()方法被调用时,系统需要明确知道该方法的位置。

初始化

类的初始化时机

  1. 创建类的实例
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射(比如:Class.forName(“com.atguigu.Test”))
  5. 初始化一个类的子类
  6. Java虚拟机启动时被标明为启动类的类
  7. JDK7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化

除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化,即不会执行初始化阶段(不会调用 clinit() 方法和 init() 方法)

clinit()

  1. 初始化阶段就是执行类构造器方法<clinit>()的过程
  2. 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。也就是说,当我们代码中包含static变量的时候,就会有clinit方法
  3. <clinit>()方法中的指令按语句在源文件中出现的顺序执行
  4. <clinit>()不同于类的构造器。(关联:构造器是虚拟机视角下的<init>()
  5. 若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕
  6. 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁

使用

经过上面5个步骤,一个完整的对象已经加载到内存了,接下来我们在代码中就可以直接使用了。

卸载

当一个对象不再被使用了,会被垃圾回收掉。

垃圾收集策略与算法

判定对象是否存活

主要方法:引用计数和可达性分析。

引用计数法

在对象头维护着一个 counter 计数器,对象被引用一次则计数器 +1;若引用失效则计数器 -1。当计数器为 0 时,就认为该对象无效了。

引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是主流的 Java 虚拟机里没有选用引用计数算法来管理内存,主要是因为它很难解决对象之间循环引用的问题。(虽然循环引用的问题可通过 Recycler 算法解决,但是在多线程环境下,引用计数变更也要进行昂贵的同步操作,性能较低,早期的编程语言会采用此算法。)

可达性分析法

所有和 GC Roots 直接或间接关联的对象都是有效对象,和 GC Roots 没有关联的对象就是无效对象。

GC Roots 是指:

  • Java 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中常量引用的对象
  • 方法区中类静态属性引用的对象

GC Roots 并不包括堆中对象所引用的对象,这样就不会有循环引用的问题。

引用的种类

强引用>软引用>弱引用>虚引用

强引用

我们写的代码一般都是强引用,如Object obj = new Object()这种就属于强引用,强引用主要还存在就不会回收,空间不够直接抛出OOM

软引用

软引用通过SoftReference类来实现,软引用用来表示一些还有用但又是非必需的对象,系统在即将溢出之前,如果发现有软引用的对象存在,会对其二次回收,回收之后内存还是不够就会抛出OOM

弱引用

弱引用通过WeakRerefence实现,弱引用也是用来表示非必需的对象,但是相比于软引用,弱引用的对象会在第一次垃圾回收时就被回收掉

虚引用

虚引用通过PhantomReference实现,称为幽灵引用或幻影引用,最弱的一种引用,一个对象是否有虚引用对其生存时间没有影响,也无法通过虚引用来取得一个对象实例。设置为虚引用的唯一用处就是当这个对象被回收时可以收到一个系统通知。

垃圾收集算法

常见的垃圾收集算法有以下几个:

标记-清除算法

标记的过程是:遍历所有的 GC Roots,然后将所有 GC Roots 可达的对象标记为存活的对象

清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。与此同时,清除那些被标记过的对象的标记,以便下次的垃圾回收。

这种方法有两个不足

  • 效率问题:标记和清除两个过程的效率都不高。
  • 空间问题:标记清除之后会产生大量不连续的内存碎片,碎片太多可能导致以后需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法(新生代)

为了解决效率问题,“复制”收集算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完,需要进行垃圾收集时,就将存活者的对象复制到另一块上面,然后将第一块内存全部清除。这种算法有优有劣:

  • 优点:不会有内存碎片的问题。
  • 缺点:内存缩小为原来的一半,浪费空间。

为了解决空间利用率问题,可以将内存分为三块: Eden、From Survivor、To Survivor,比例是 8:1:1,每次使用 Eden 和其中一块 Survivor。回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才使用的 Survivor 空间。这样只有 10% 的内存被浪费。

但是我们无法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够,需要依赖其他内存(指老年代)进行分配担保。

标记-整理算法(老年代)

标记:它的第一个阶段与标记-清除算法是一模一样的,均是遍历 GC Roots,然后将存活的对象标记。

整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。

这是一种老年代的垃圾收集算法。老年代的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,如果采用复制算法,每次需要复制大量存活的对象,效率很低。

分代收集算法

根据对象存活周期的不同,将内存划分为几块。一般是把 Java 堆分为新生代和老年代,针对各个年代的特点采用最适当的收集算法。

  • 新生代:复制算法
  • 老年代:标记-清除算法、标记-整理算法