一、JVM的内存结构包括5大区域:
方法区,堆区,虚拟机栈,本地方法栈,程序计数器。
1.方法区
用于存储类结构信息的地方,包括常量池、静态变量、构造函数等。
-
类信息:包含类的版本、字段、方法、接口等描述信息。例如,一个 Student 类定义了
name
和age
两个字段以及say()
方法,这些类的结构相关的信息都会存储在方法区中,以便 JVM 在运行时能准确地知道如何去创建对象实例、调用类中的方法等操作。 -
常量池:存放字面量(如文本字符串常量
"Hello"
等)和符号引用(如类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等)。在编译阶段生成的字节码文件中就有常量池的相关内容,当类加载时,字节码文件里的常量池会被解析并存储到方法区的常量池中。比如String s = "Java"
,这个字符串"Java"
字面量就会被放到常量池中。 -
静态变量:类的静态变量也存放在方法区中。像定义了
public static int count = 10;
这样的静态变量,其值以及变量的相关信息会在类加载时被分配到方法区,并且在整个程序运行过程中,同一个类的所有实例都共享这个静态变量。
2.堆区
堆是 JVM 所管理的内存中最大的一块,被所有线程共享,主要用于存放对象实例以及数组。在程序运行过程中,通过 new
关键字创建的对象都会在堆中分配内存空间,它也是垃圾收集器重点管理的区域。
3.虚拟机栈
虚拟机栈是线程私有的内存区域,每个线程在创建时都会创建一个与之对应的虚拟机栈,它的生命周期与线程相同。虚拟机栈主要用于存储局部变量表、操作数栈、动态链接、方法出口等信息,用来支持 Java 方法的调用和执行过程。
-
局部变量表:用于存放方法参数和方法内部定义的局部变量。例如在如下方法中:
public int add(int a, int b) { int result = a + b; return result; }
当调用 add
方法时,参数 a
和 b
以及局部变量 result
都会被存储在局部变量表中,不同的基本数据类型和对象引用在局部变量表中占用不同的内存空间单元。
-
操作数栈:在方法执行过程中,用于进行算术运算、逻辑运算等操作时的数据临时存储和操作。比如在执行
int result = a + b;
这个运算时,会先将a
和b
的值压入操作数栈,然后在栈中执行加法运算,最后将结果压入操作数栈,再从操作数栈弹出赋值给局部变量result
。 -
动态链接:主要用于在运行时将符号引用转换为直接引用,也就是将类中定义的方法、字段等符号形式的引用解析为实际内存地址中的引用,使得 JVM 能准确地调用对应的方法或访问相应的字段。
-
方法出口:记录方法执行完成后返回到调用该方法的位置的相关信息,比如返回地址等,确保方法调用结束后能正确地回到调用处继续执行后续代码。
-
栈帧与方法调用: 每一个方法从调用开始到执行完成,对应着一个栈帧在虚拟机栈中的入栈和出栈操作。当一个方法被调用时,一个包含上述各种信息的栈帧会被压入虚拟机栈,方法执行完毕后,该栈帧会被弹出虚拟机栈,遵循后进先出(LIFO)的原则,这样就保证了方法调用和执行的顺序及逻辑正确性。
4.本地方法栈
本地方法栈与虚拟机栈非常相似,也是线程私有的,它主要用于为虚拟机使用到的 Native 方法(即通过 Java 以外的语言,如 C 或 C++ 编写的方法,一般用于访问底层系统资源等)服务,存储 Native 方法执行过程中的局部变量表、操作数栈、动态链接、方法出口等信息。
5.程序计数器
程序计数器是一块较小的内存空间,也是线程私有的。它可以看作是当前线程所执行的字节码的行号指示器,用于记录正在执行的字节码指令的地址,以便在线程切换回来后能继续执行原来的指令,保证线程执行的顺序和连续性。 在多线程环境下,当线程因为时间片用完或者其他原因被暂停执行(比如发生了线程调度),JVM 需要记录该线程当前执行到的字节码位置,这个位置信息就保存在程序计数器中。例如,线程 A 正在执行某个类的 compute()
方法中的字节码指令,执行到第 10 条指令时被暂停,此时程序计数器就会记录下这个第 10 条指令的地址,当线程 A 再次获得 CPU 时间片继续执行时,JVM 通过查看程序计数器的记录,就能从第 10 条指令处接着往下执行,确保 compute()
方法能正确地、不间断地完成执行流程(除去其他逻辑导致的中断情况)。
堆是垃圾收集器重点管理的区域。
二、Java中是怎么判断一个对象是垃圾?
1.引用计数算法
引用计数算法的核心思想是为每个对象添加一个引用计数器,用来记录指向该对象的引用的数量。当有一个新的引用指向这个对象时,计数器的值就加 1;而当一个引用不再指向该对象(比如引用变量超出了作用域或者被显式地赋值为 null
)时,计数器的值就减 1。当对象的引用计数器的值变为 0 时,就意味着该对象已经没有任何引用指向它了,此时可以认定这个对象为垃圾对象,可被回收。
2.可达性分析算法(Reachability Analysis)
可达性分析算法是 Java 虚拟机(JVM)中实际采用的判断对象是否为垃圾的主流算法。它的核心是从一系列被称为 “GC Roots”(垃圾收集根节点)的对象开始,通过引用关系向下遍历对象图,那些能够沿着引用链到达的对象被认为是存活对象,而无法通过任何引用链到达的对象则被判定为垃圾对象。
三、回收算法
1、标记-清除
标记和清除。标记所有需要回收的对象,然后统一回收。这是最基础的算法,后续的收集算法都是基于这个算法扩展的。
回收前:
1 存活对象 0 可回收
回收后:
1 存活对象 0 可回收
缺点: 1、效率问题:标记和清除这两个过程效率都不高 2、空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序中需要分配较大对象时,无法找到足够大的连续内存而不得不提前触发另一次垃圾收集。
2、复制
复制算法的核心思想是将内存空间划分为两个大小相等的区域。在垃圾回收时,它会把存活的对象从其中一个半区复制到另一个半区,然后一次性清理掉原来的 From 空间,这样使得整个内存空间在回收后就只有存活对象,且内存空间布局更加规整,方便后续为新对象分配内存。
回收前:
1 存活对象 0 可回收
回收后:
1 存活对象 0 可回收
3、标记-整理
分为三个阶段: 1、标记阶段:首先需要先标记出存活的对象。 2、整理阶段:将所有的存活对象压缩到内存的一端。 3、清除阶段:把存活边界外的内存空间都清除一遍。
回收前:
1 存活对象 0 可回收
回收后:
1 存活对象 0 可回收
此算法结合了“标记-清除”和“复制”两个算法的优点。也是分两阶段,第一阶段从根节点开始标记所有被引用对象,第二阶段遍历整个堆,把清除未标记对象并且把存活对象“压缩”到堆的其中一块,按顺序排放。此算法避免了“标记-清除”的碎片问题,同时也避免了“复制”算法的空间问题
4、分代收集算法
分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率,这是当前商业虚拟机常用的垃圾收集算法。 Java堆一般分为三大部分:新生代、老年代、永久代
1、新生代 主要是用来存放新生的对象,新生代通常存活时间较短。一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发 MinorGC(也叫新生代GC) 进行垃圾回收。 MinorGC:采用复制算法。 新生代分为 Eden 区、ServivorFrom、ServivorTo 三个区。
Eden 区:Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当 Eden 区内存不够的时候就会触发 MinorGC,对新生代区进行一次垃圾回收。 ServivorTo:保留了一次 MinorGC 过程中的幸存者。 ServivorFrom:上一次 GC 的幸存者,作为这一次 GC 的被扫描者。 当 JVM 无法为新建对象分配内存空间的时候 (Eden 满了),Minor GC 被触发。因此新生代空间占用率越高,Minor GC 越频繁。
可以看出触发新生代GC的条件是Eden 满了
2、老年代 老年代的对象比较稳定,对象存活的时间比较长。所以 MajorGC 不会频繁执行。在进行 MajorGC(也叫老年代GC) 前一般都先进行了一次 MinorGC(新生代GC),使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。
MajorGC: 采用标记—清除算法 ,有的地方等同于Full GC,有的地方单单指老年代的GC。
3、永久代 指内存的永久保存区域,主要存放 Class 和 Meta(元数据)的信息。
Class 在被加载的时候被放入永久区域。它和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。