JVM相关知识

75 阅读15分钟

类加载过程


类的生命周期

一个类的完整生命周期

类加载过程

Class文件需要加载到虚拟机中之后才能允许和使用,JVM如何加载Class文件的呢

类加载过程

加载

这一步主要通过类加载器完成,类加载器有很多种,当我们想要加载一个类的时候,具体时哪个类加载器加载由双亲委派模型决定。

主要做以下三件事儿(非常灵活,没有规定从哪获取):

  1. 通过全类名获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 在内存中生成一个代表该类的Class对象,作为方法区这些数据的访问入口

每一个Java类都有一个引用指向加载他的ClassLoader。不过,数组类不是通过ClassLoader创建的。而是JVM在需要的时候自动创建的,数组类通过getClassLoader()方法获取ClassLoader的时候和该数组的元素类型的ClassLoader是一致的

注意,加载阶段和连接阶段的部分动作是交叉进行的(如一部分字节码文件格式验证动作)

验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全

耗费的资源多,但是有必要,可以防止恶意代码的指向。任何时候,程序安全都是第一位

验证阶段分为:

  1. 文件格式验证(Class文件格式检查)
  2. 元数据验证(字节码语义检查)
  3. 字节码验证(程序语义检查)
  4. 符号引用验证(类的正确性检查)
验证阶段示意图

准备

是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都在方法区中分配

  1. 这时候进行内存分配的仅包含类变量(静态变量,只和类有关,因此称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在Java堆中
  2. 从概念上来说,类变量所使用的内存都应在方法区中进行分配。不过有一点:JDK7以前,HotSpot使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。
  3. 这里所设置的初始值通常情况下是数据类型默认的零值(如0,0L,null,false等)比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111。

基本数据类型的零值

解析

是虚拟机将常量池内的符号引用替换成直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行

举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。

综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量

初始化

是执行初始化方法<clinit> ()方法的过程,是类加载的最后一步,这一步JVM才开始真正执行类中定义的Java程序代码(字节码)

<clinit> ()方法是编译之后自动生成的

对于<clinit> ()方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为<clinit> ()方法是带锁线程安全的,索引在多线程环境下进行类初始化的化可能会引起多个线程阻塞,并且这种阻塞很难被发现

虚拟机严格规定了有且只有6种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):

  1. 当遇到 newgetstaticputstaticinvokestatic 这 4 条字节码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
    • 当JVM执行new指令时会初始化类。即当程序创建一个类的实例对象
    • 当JVM执行getstatic指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。
    • 当JVM执行putstatic指令时会初始化类。即程序给类的静态变量赋值
    • 当JVM执行invokestatic指令时会初始化类。即程序调用类的静态方法
  2. 使用java.lang.reflect包的方法对类进行反射调用时如CLass.forName("....")newInstance()等,如果类没初始化,需要其初始化
  3. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化
  4. 当虚拟机启动时,用户需要定义一个要执行的主类(包含main方法的那个类),虚拟机会先初始化这个类
  5. MethodHandleVarHandle可以看作是轻量级的反射调用机制,而要想使用这2个调用,就必须先使用findStaticVarHandle来初始化要调用的类

垃圾回收


堆空间结构

Java自动内存管理主要针对对象内存的回收和对象内存的分配。同时,Java自动内存管理最核心的功能是内存中对象的分配与回收。

Java堆是垃圾收集器管理的主要区域,因此也被称为GC堆

从垃圾回收的角度来说,由于现在收集器基本采用分代垃圾收集算法,所以Java堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法

内存分配和回收原则


对象优先在Eden区分配

大多数情况,对象在新生代Eden区分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

public class GCTest {
  public static void main(String[] args) {
    byte[] allocation1, allocation2;
    allocation1 = new byte[30900*1024];
    allocation2 = new byte[900*1024];
  }
}

先给allocation1分配内存,可以发现Eden区基本被占用完了(即使程序什么也不做,新生代也会使用 2000 多 k 内存)。

img

img

allocation2分配内存的时候Eden区内存几乎已经被分配完了

当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。GC期间虚拟机又发现allocation1无法存入Survivor空间,所以只好通过分配担保机制把新生代的对象提前转移到老年代,老年代上的空间足够存放allocation1,所以不会出现Full GC。执行Minor GC后,后面分配的对象如果能够存在Eden区的话,还会在Eden区分配内存

大对象直接进入老年代

大对象:需要连续内存空间的对象(比如:字符串,数组)

大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代。从而减少新生代的垃圾回收频率和成本

  • G1垃圾回收器会根据-xx:G1HeapRegionSize参数设置的堆区域大小和-xx:G1MixedGCLiveThresholdPercent参数设置的阈值,来决定哪些对象会直接进入老年代
  • Parallel Scavenge垃圾回收器中,默认情况下,并没有一个固定的阈值(xx:ThresholdTolerance是动态调整的)来决定何时直接在老年代分配大对象。而是由虚拟机根据当前的堆内存情况和历史数据动态决定

长期存活的对象进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中,所以虚拟机给每个对象一个对象年龄计数器

大部分情况,对象都会首先在Eden区域分配。如果对象在Eden出生并经过第一次Minor GC后仍然能够存活,并且能被Survivor容纳的话,将被移动到Survivor空间(s0或s1中),并将对象年龄设为1(Eden区->Survivor区后对象的初始年龄变为1)

对象在Survivor中每熬一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就会被晋升到老年代。对象晋升到老年代的年龄阈值,可以通过参数-xx:MaxTenuringThreshold来设置

主要进行GC的区域

针对HotSpot VM的实现,它里面的GC其实准确分类只要两大种:

部分收集:

  • 新生代收集:只对新生代进行垃圾收集
  • 老年代收集:只对老年代进行垃圾收集。需要注意的是Major GC 在有的语境中也用于指代整堆收集
  • 混合收集:对整个新生代和部分老年代进行垃圾收集

整堆收集:收集整个Java堆和方法区

空间分配担保

为了确保在Minor GC之前老年代本身还有容纳新生代所有对象的剩余空间

死亡对象判断方法


堆中几乎放着所有的对象实例。对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)

引用计数法

给对象中添加一个引用技术器:

  • 每当有一个地方引用它,计数器就加1
  • 当引用失效,计数器就减1
  • 任何时候计数器为0的对象就是不可能再被使用的

这方法为啥没人用呢?没法解决对象之间循环引用的问题

比如对象A和B互相引用着对方,且无其他引用,导致其引用计数器都不为0,于是引用技术算法无法通知GC回收器回收他们

可达性分析算法

通过一系列的称为GC Roots的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的话,则证明此对象是不可用的,需要被回收

可达性分析算法

GC Roots

  • 虚拟机栈中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象
  • JNI引用的对象

对象可以被回收,就代表一定会被回收吗?

即使在可达性分析法中不可达的对象,也并非是“非死不可”的,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析算法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法。当对象没有覆盖finalize方法,或finalize方法已经被虚拟机调用过时,虚拟机将这两张情况视为没有必要执行。

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

引用类型

  1. 强引用:大部分引用其实都是强引用,类似必不可少的生活用品,垃圾回收器绝对不会回收它,即使内存不足

  2. 软引用:类似可有可无的生活用品,如果内存空间足够,垃圾回收器就不会回收它,如果不够,它就会被回收内存,只要没回收就可以被程序使用。软引用可用来实现内存敏感的高速缓存

    软引用可以和一个引用队列联合使用,如果软引用所引用的对象被垃圾回收,JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中

  3. 弱引用:如果一个对象只具有弱引用,那就类似可有可无的生活用品。和软引用的区别:只具有弱引用的对象拥有更短暂的生命周期。当垃圾回收器扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象

    弱引用可以和一个引用队列联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

  4. 虚引用:虚引用不会绝对对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收,常用来跟踪对象被垃圾回收的活动

一般不用弱引用和虚引用,用软引用较多

软引用可以加快JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出等问题的产生

垃圾收集算法


标记-清除算法

先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象

最基础的算法,其他算法都是在它的基础上改进的,这算法会带来两个问题

  1. 效率问题:标记和清除两个过程效率都不高
  2. 空间问题:标记清除后会产生大量不连续的内存碎片
标记-清除算法

复制算法(适合新生代)

新生代每次收集都会有大量对象死去,用此算法只需要付出少量对象的复制成本就可以完成每次垃圾收集

目的:解决标记-清除算法的效率和内存碎片问题

将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收

复制算法

仍存问题:

  • 可用内存变小:可用内存缩小为原来的一半
  • 不适合老年代:如果存活对象数量比较大,复制性能会很差

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

老年代对象存活几率比较高,而没有额外的空间对它进行分配担保,必须使用第一种和第三种

根据老年代的特点提出的

标记过程和“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一段移动,然后直接清理掉端边界以外的内存

标记-整理算法

由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景