JVM垃圾回收原理以及JVM,DVM,ART对比

21 阅读11分钟

看完这篇垃圾回收,和面试官扯皮没问题了

一 .JVM垃圾回收机制(Garbage Collection)

1.HotSpot类型的JVM内存区域

image.png

image.png

  • 虚拟机栈 (线程私有) 为虚拟机执行Java方法时服务,执行的时候,每个方法调用都会创建一个新的栈帧(存储了执行该方法相关的所有信息),栈帧包含局部变量表、操作数栈、动态链接和方法出口信息,将该方法入栈,执行完毕时就将该方法出栈。 操作数栈:是指JVM 中用于存储方法执行过程中中间结果和操作数的数据结构; 动态链接:是在程序运行时解析和绑定符号的过程,动态链接支持多态性,即子类可以重写父类的方法。在运行时,JVM 会根据对象的实际类型调用相应的方法实现。 方法出口:是指在方法执行完毕后,从当前方法返回到调用者的过程

  • 本地方法栈 (线程私有) 为虚拟机执行Nativie方法时服务。 image.png

  • 程序计数器 (线程私有) 当前线程执行java字节码的行号指示器

-本地内存(堆外内存)(线程共享) 包含元空间(方法区)和直接内存,方法区就是在元空间上实现的,存储类信息,常量,静态变量,及时编译器编译后的代码.

  • 堆 (线程共享) (线程共享) 存储对象实例和数组.

2.JVM内存分配机制

2.1.每个线程在执行方法时,都会创建一个Stack Frame 【栈帧】,并根据方法的局部变量区和操作数栈的大小来分配【栈内存】,然后压入虚拟机栈或者本地方法栈。

2.2 在Java中,当使用new 关键字创建一个对象时,JVM会在堆内存中为该对象分配一块内存空间,存储该对象,并返回给对象的内存地址:0x11;

//变量dog存储的是这个地址,即对象的引用,这个地址指向了堆内存中该对象的具体位置。
Dog dog = new Dog("Buddy"); 

2.3.那么问题来了,dog作为对象的引用,它是存在哪里的呢?

  • 局部变量中的对象引用 :当在方法内部声明一个局部变量并为其分配一个对象时,这个局部变量【发生逃逸的话】(dog)的引用存储在栈内存中,但是局部变量dog引用的对象实际是存储在堆内存中.【如果未发生逃逸,完全在方法内部,那么这个对象和对象引用很有可能会被JVM优化存储在栈内存中】

  • 成员变量中的对象引用: 当在类的成员变量中声明一个对象时,如果这个成员变量dog作为类实例的一部分存储在堆内存中,换句话说,当Test创建的时候,dog的引用和Test对象一起被分配在了堆内存当中.

  • 静态变量和常量,二者对象的引用和对象本身都是存储在方法区或者元空间中。

2.4.Java中的对象都是在堆上进行分配的吗?

  • Java中的对象不一定都是在堆上进行分配的,因为JVM发现某些对象没有逃逸出方法,就有很大可能(不是一定,也有可能在堆上进行分配)被优化为在栈上进行分配。此时对象的引用和对象本身都会被存储在栈内存中,相反逃逸出方法的对象引用可能会保留在栈内存中一段时间,随后可能被传递其他变量,对象本身会被分配在堆上.
  • 对象逃逸分析 对象被赋值给成员变量,可能被外部使用,此时就发生了逃逸.
public class Test01{
    User user;
    public void init(){
        user=User(); //对象被赋值给成员变量,可能被外部使用,此时就发生了逃逸
    }
}

对象通过Return 语句返回,可能为外部使用,此时就发生了逃逸。

public class Test02{
       public User createUser(){
           User user=new User();
          return user;
      }
}

3.如何识别垃圾

  • 引用计数法 对象被引用一次,在它的对象头上加一次引用次数,如果没有被引用(引用次数为0),则此对象可回收.
  String ref=new String("Java");

image.png 如果在ref=null,则由于对象没有被引用,引用次数置为0,此时可以被回收。看起来引用计数确实没啥问题,不过它无法解决一个主要的问题:循环引用.

//测试类
public class TestRC {
    TestRC instance;
    public TestRC(String name) {
    }
}
 public static  void main(String[] args) {
        // 第一步
	TestRC a = new TestRC("a");
	TestRC b = new TestRC("b");
        // 第二步
	a.instance = b;
	b.instance = a;
        // 第三步
	a = null;
	b = null;
    }

image.png 到了第三步,虽然 a ,b都被置为null,但是由于它们之前指向的对象互相指向了对方(引用次数为1),所以无法回收,也正是由于无法解决循环引用的问题,所以现代虚拟机都不用引用计数法来判断对象是否回收。

  • 可达性算法 如果相关对象不在任意一个以GC Root为起点的引用链中,则这些对象会被判断为可回收对象,会被GC回收。

可以作为GC Root的对象有哪些呢? 0c11ed8dcc879eb06f0aef1da428a80.jpg

(1).虚拟机栈中引用的对象.

  public class Test{
   
       public static void main(String[] args){
           Test a=new Test();
            a=null;   
      }
  }

a是栈帧中的类变量,当a=null时,此时a充当了GC Root的作用,a与 new Test()断开了连接,所以对象会被回收. (2).本地方法栈中JNI引用的对象。 当调用java方法时,虚拟机会创建一个栈帧并压入虚拟机栈,而当它调用本地方法时,虚拟机会保持虚拟机栈不变,不会在Java栈帧中压入新的栈帧,虚拟机只是简单地动态链接并直接调用指定的本地方法。 image.png

JNIEXPORT void JNICALL Java_com_pecuyu_jnirefdemo_MainActivity_newStringNative(JNIEnv *env, jobject instance,jstring jmsg) {
...
   // 缓存String的class
   jclass jc = (*env)->FindClass(env, STRING_PATH);
}

当Java调用以上本地方法时,jc会被本地方法栈压入栈中,jc就是我们说的本地方法栈中JNI的对象引用. (3).方法区中常量引用的对象.

    public class Test{
            public static final Test s=new Test();
            public static  void main(){
                 Test a=new Test();
                 a=null;
           }
   }

常量s指向的对象,并不会因为a指向的对象被回收而回收。 (4).方法区中类静态变量引用的对象.

public class Text{
     public static Test s;
     public static void main(String[] args){
           Test a=new Test();
           a.s=new Test();
     }
}

当栈帧的本地变量a=null时,由于a原来指向的对象与GC Root(变量a)断开了连接,所以a原来指向的对象会被回收,而由于我们给s赋值了变量的引用,s在此时是类静态属性引用,充当了GC Root的作用,它指向的对象依然存活。

4.垃圾回收的主要方法

  • 标记清除法(会产生内存碎片) image.png

  • 复制算法 (会导致一半的内存无法使用,需要移动对象,效率低下) image.png

  • 标记整理法(整理的过程会频繁的移动对象,效率十分低下) image.png

  • 分代回收算法(整合了标记清除法+复制算法+标记整理法) image.png (1).对象在新生代的分配与回收 image.png image.png

对象一般分配在Eden区,当Eden区将满时,触发Minor GC,会对可回收对象进行标记,然后把存活对象复制到 from Survivor(S0)区,对象年龄+1,最后把Eden区全部清空,以释放空间。(大部分(98%)的对象会被回收,只有少部分(2%)对象会存活,这就是为啥空间大小 Eden: S0: S1 = 8:1:1),当触发下一次Minor GC时,会对Eden区和S0区可回收对象进行标记,然后把Eden区和S0存活的对象复制到S1区,对象年龄+1,然后清空Eden区和S0区...重复上序步骤,S0和S1角色互换即可。

(2).对象如何晋升到老年代

  • 当对象的年龄达到了我们设定的阀值(默认15),会把对象从S0(或S1)区复制到老年代。
  • 大对象,当某个对象分配需要大量的连续内存时,此时对象的创建会直接分配在老年代。
  • 动态对象年龄判定 当S0或S1区,相同年龄的对象空间总和大于S0或者S1空间的一半,大于或者等于该年龄的对象都会被分配到老年代.
  • 在每次Minor GC之后,当S0(或S1)区空间不足时,就会把这些新生代存活对象晋升到老年代。

(3).空间分配担保机制 在每次Minor GC之后,如果S0区或者S1区不够存放这些存活对象,那么就会把这些对象放到老年代,如果老年代最大可用连续空间不足以容纳这些新生代中晋升的对象时,分二种情况:

  • HandlePromotionFailure=true,表示虚拟机允许担保失败。 比较老年代最大可用连续空间是否大于历次minorGc后进入老年代对象的平均值? 如果大于,那么就会冒险进行一次Minor GC 如果小于,Full GC

  • HandlePromotionFailure=false,表示虚拟机不允许担保失败 ,Full GC

如果Full GC之后,老年代还是没有足够的空间存放剩余的存活对象,那么就会导致"OOM"内存溢出。

(4).Stop The World(Full GC期间,只有垃圾回收器线程在工作,其他工作线程则被挂起。) 如果老年代满了,或触发Full GC,会同时回收新生代和老年代(即对整个堆进行GC),她会导致Stop the world(STW),造成挺大的内存开销。 MinorGC 也会造成轻微的STW,Eden 区的对象大部分都被回收了,只有极少数存活对象会通过复制算法转移到 S0 或 S1 区,所以相对还好.

5.垃圾回收器的种类

image.png 备注:G1也可以像CMS一样,也是边污染边收集,而且不会产生内存碎片.

image.png

二.DVM、JVM 与 ART 虚拟机的比较

JVM、Dalvik VM和ART虚拟机之间的区别

一、不同点

  1. 架构设计

• JVM:基于栈架构,数据操作依赖栈的入栈和出栈,指令紧凑但效率较低,适合跨平台场景。

• DVM:基于寄存器架构,直接通过寄存器传递数据,减少了栈操作的开销,执行速度更快,更适合移动设备的资源限制。

• ART:继承自 DVM 的寄存器架构,但通过 AOT(预编译)技术将字节码直接编译为机器码,进一步优化性能。

2.执行文件格式:

• JVM:执行 .class 文件,每个类对应一个 .class 文件,存在冗余信息。

• DVM:通过 dx 工具将多个 .class 合并为 .dex 文件,去重并压缩,减少存储和加载时间。

• ART:安装时预编译 .dex 文件为本地机器码(.oat格式),运行时直接执行机器码,无需即时编译,提升启动速度和运行效率。

  1. 编译技术

• JVM:采用解释执行 + JIT(即时编译)混合模式,对热点代码动态编译为机器码。

• DVM:仅支持 JIT,运行时动态编译部分代码,但每次启动需重复编译。

• ART:采用 AOT(预编译)+ JIT 混合模式(Android 7.0 后),安装时预编译大部分代码,运行时仅对少量新代码使用 JIT,平衡效率和存储。

4.内存与性能优化

• JVM:主要采用的分代回收算法.

• DVM:标记清除算法,容易产生碎片,需要经常GC,时常导致STW。

• ART:采用分代回收算法.(新生代:复制算法|老年代:并发拷贝)

  1. 应用场景与兼容性

• JVM:跨平台性强,支持多种操作系统,但移动端性能不足。

• DVM/ART:专为 Android 设计,DVM 已被 ART 取代(Android 5.0 后);ART 支持更高效的硬件利用,但安装时间更长、存储占用增加约 10-20%。


二、相同点

  1. 核心功能 三者均提供内存管理、线程调度、垃圾回收等基础功能,支持面向对象编程。

  2. 字节码与编译
    • JVM 和 DVM 均通过编译生成中间字节码(.class.dex),再转换为机器码执行。

    • ART 虽直接运行机器码,但安装阶段仍依赖 .dex 文件作为输入源。

  3. 安全与隔离
    • JVM 和 DVM/ART 均通过进程隔离保障应用安全性(如 DVM 每个应用独立进程)。

    • 均支持沙箱机制,限制应用权限。

  4. 技术演进 • DVM 和 ART 均基于 JVM 的设计理念改进,例如 DVM 的寄存器架构优化了移动端性能,ART 的预编译进一步突破效率瓶颈。