Java核心概念精讲:JVM内存模型、Java类加载全过程与 JVM垃圾回收算法等(51-55)
前言
本文需要有一定的java基础才能更好观看,背诵记忆的话只需继续略微精简即可,如果有疑问或者需要案例可以观看B站诸葛老师视频Java基础面试题100问大合集,小白面试学习(全套通俗易懂)_哔哩哔哩_bilibili。如果还有疑问或者想要讨论的话可以评论区留言。将会持续更新。
一、 说一说JVM的内存模型(JMM与运行时数据区)
首先,我们需要厘清两个常被混淆的概念:Java内存模型(JMM) 和 JVM运行时数据区。它们关注的角度不同,但都至关重要。
1.1 JVM运行时数据区(Runtime Data Areas)
这是JVM在执行Java程序过程中会使用到的内存区域,由《Java虚拟机规范》定义。它描绘了JVM的“物理”内存布局,主要包括以下几个部分:
-
程序计数器(Program Counter Register):
-
作用: 一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令。
-
特性: 线程私有。每条线程都有一个独立的程序计数器,各条线程之间互不影响,独立存储。这是保证线程切换后能恢复到正确执行位置的关键。
-
-
Java虚拟机栈(Java Virtual Machine Stacks):
-
作用: 描述Java方法执行的内存模型。每个方法在执行的同时都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法的调用和完成对应着栈帧在虚拟机栈中的入栈和出栈过程。
-
特性: 线程私有,生命周期与线程相同。
-
常见错误: 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出
StackOverflowError
异常;如果虚拟机栈可以动态扩展,但扩展时无法申请到足够内存,会抛出OutOfMemoryError
异常。
-
-
本地方法栈(Native Method Stack):
-
作用: 与虚拟机栈非常相似,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
-
特性: 线程私有。HotSpot虚拟机直接就把本地方法栈和虚拟机栈合二为一。
-
-
Java堆(Java Heap):
-
作用: 此内存区域的唯一目的就是存放对象实例。几乎所有通过
new
关键字创建的对象实例和数组都在这里分配内存。它是垃圾收集器管理的主要区域,因此很多时候也被称作“GC堆”。 -
特性: 线程共享,是JVM中最大的一块内存。从内存分配的角度看,为了更好的进行垃圾回收,Java堆可以细分为:新生代(Young Generation) 和 老年代(Old Generation/Tenured Generation)。新生代又可以分为Eden空间、From Survivor空间、To Survivor空间。
-
常见错误: 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将抛出
OutOfMemoryError
。
-
-
方法区(Method Area):
-
作用: 用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
-
特性: 线程共享。很多人愿意称它为“永久代”(Permanent Generation),但这只是HotSpot虚拟机的一种实现方式。在JDK 8及以后,HotSpot使用元空间(Metaspace) 取代了永久代,元空间使用本地内存(Native Memory)而非JVM内存,因此很大程度上避免了OOM问题。
-
运行时常量池(Runtime Constant Pool): 是方法区的一部分,用于存放编译期生成的各种字面量和符号引用。
-
1.2 Java内存模型(Java Memory Model, JMM)
JMM是一个概念模型和规范,它并不像运行时数据区那样真实存在。它定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问规则,以及在JVM中将变量存储到内存和从内存中取出变量这样的底层细节。
JMM的核心目标是解决在多线程环境下,由于存在CPU缓存、指令重排序等问题而导致的内存可见性、原子性和有序性问题。它通过以下关键概念来保障并发程序的正确执行:
-
主内存(Main Memory) vs 工作内存(Working Memory):
-
所有变量都存储在主内存中。
-
每条线程还有自己的工作内存,工作内存中保存了该线程使用到的变量的主内存副本拷贝。
-
线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。
-
不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
-
-
内存间交互操作: JMM定义了8种原子操作(如lock, unlock, read, load, use, assign, store, write)来规定主内存与工作内存之间如何同步。
-
happens-before原则: 判断数据是否存在竞争、线程是否安全的主要依据。这个原则非常重要,它保证了如果操作A happens-before 操作B,那么A操作所做的任何修改对B操作都是可见的。
总结: 运行时数据区是JVM管理的“物理”内存划分,而Java内存模型是控制多线程环境下如何、何时与这些内存区域安全交互的“协议”和“规范”。
二、 Java类加载全过程
Java的魅力之一是其“一次编写,到处运行”的能力,这背后离不开类加载(Class Loading) 机制。类加载指的是将类的.class
文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class
对象,用来封装类在方法区内的数据结构。这个过程分为三个主要步骤:加载、链接、初始化。
-
2.1 加载(Loading)
-
任务: 通过一个类的全限定名来获取定义此类的二进制字节流;将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。 -
特点: 加载阶段可以使用系统提供的类加载器,也可以由用户自定义的类加载器完成。加载阶段与链接阶段的部分动作(如验证)是交叉进行的。
-
-
2.2 链接(Linking)
链接过程又细分为三个子步骤:-
验证(Verification): 确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。主要包括:文件格式验证、元数据验证、字节码验证、符号引用验证。
-
准备(Preparation): 为类中定义的静态变量分配内存并设置初始值(通常是数据类型的零值,如0, false, null等)。注意,这里设置的是初始值,而非代码中赋予的值。例如
public static int value = 123;
在准备阶段过后,value
的值为0,而不是123。赋值为123的动作在初始化阶段的<clinit>()
方法中执行。但对于被final static
修饰的常量,准备阶段就会直接赋值为指定值。 -
解析(Resolution): 将常量池内的符号引用替换为直接引用的过程。符号引用是一组符号来描述所引用的目标,直接引用则是直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。
-
-
2.3 初始化(Initialization)
-
任务: 执行类的构造器
<clinit>()
方法的过程。<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块) 中的语句合并产生的。虚拟机会保证在子类的<clinit>()
方法执行前,父类的<clinit>()
方法已经执行完毕。 -
触发时机: 虚拟机规范严格规定了有且只有6种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
-
遇到
new
,getstatic
,putstatic
,invokestatic
这四条字节码指令时。 -
使用
java.lang.reflect
包的方法对类进行反射调用时。 -
当初始化一个类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
-
虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
-
当使用JDK 7新加入的动态语言支持时...
-
当一个接口中定义了JDK 8新加入的默认方法(default方法)时...
-
-
类加载器(ClassLoader) 在此过程中扮演着关键角色,它们遵循双亲委派模型(Parent Delegation Model):当一个类加载器收到加载请求时,它首先不会自己去尝试加载,而是将这个请求委派给父类加载器去完成,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。这保证了Java核心库的类型安全,避免了类的重复加载。
三、 对象在JVM中经历的过程
一个普通的Java对象,从被创建到被回收,在JVM中会经历一段完整的生命周期。
-
3.1 创建(Creation)
当虚拟机遇到一条new
指令时,首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,必须先执行相应的类加载过程。
在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定。 -
3.2 内存分配(Memory Allocation)
分配方式取决于Java堆的内存是否规整,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩(Compacting) 的能力决定。-
指针碰撞(Bump the Pointer): 如果内存是规整的,那么分配就是将指针向空闲空间那边挪动一段与对象大小相等的距离。Serial, ParNew等带压缩功能的收集器使用此方式。
-
空闲列表(Free List): 如果内存是不规整的,虚拟机就必须维护一个列表,记录哪些内存块是可用的。在分配时从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。CMS这种基于标记-清除算法的收集器采用此方式。
-
并发分配与TLAB: 创建对象在虚拟机中是非常频繁的行为,即使是修改一个指针的位置,在并发情况下也并不是线程安全的。为了解决这个问题,虚拟机采用CAS配上失败重试的方式保证更新操作的原子性,或者为每个线程在Eden区预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB),线程优先在自己的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。
-
-
3.3 初始化(Initialization)
内存分配完成后,虚拟机需要将分配到的内存空间(不包括对象头)都初始化为零值。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用。
接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header) 之中。 -
3.4 对象构建(Constructor Execution)
从虚拟机的视角看,一个新的对象已经产生了。但从Java程序的视角看,对象创建才刚刚开始——<init>
方法(构造器)还没有执行。执行new
指令之后会接着执行<init>
方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全构造出来。 -
3.5 使用(Usage)
对象在Java堆中,通过栈上的引用被程序使用。 -
3.6 回收(Reclamation)
当对象不再被任何存活实体引用时,它就变成了垃圾,等待着垃圾收集器对其进行回收,释放其占用的内存空间。
四、 怎么确定一个对象是不是垃圾
垃圾收集器在对堆进行回收前,第一件事情就是要确定哪些对象还“活着”,哪些已经“死去”(即不可能再被任何途径使用的对象)。判断对象是否存活主要有两种算法:
-
4.1 引用计数算法(Reference Counting)
-
原理: 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
-
优点: 实现简单,判定效率高。
-
缺点: 它很难解决对象之间相互循环引用的问题。例如对象A和B互相引用,除此之外再无任何引用,实际上它们已经无法被访问,但因为它们的引用计数都不为零,就无法被回收。因此,主流的Java虚拟机都没有选用引用计数算法来管理内存。
-
-
4.2 可达性分析算法(Reachability Analysis)
-
原理: 通过一系列称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain)。如果某个对象到GC Roots间没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达),则证明此对象是不可能再被使用的。
-
可作为GC Roots的对象包括:
-
在虚拟机栈(栈帧中的本地变量表)中引用的对象。
-
在方法区中类静态属性引用的对象。
-
在方法区中常量引用的对象。
-
在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
-
Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象等。
-
所有被同步锁(synchronized关键字)持有的对象。
-
反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
-
-
即使在可达性分析算法中判定为不可达的对象,也并非是“非死不可”的,它们暂时处于“缓刑”阶段。要真正宣告一个对象死亡,至少要经历两次标记过程:第一次标记后,会进行一次筛选(条件是此对象是否有必要执行 finalize()
方法)。如果对象没有覆盖 finalize()
方法,或者 finalize()
方法已经被虚拟机调用过,则被视为“没有必要执行”。如果判定为有必要执行,那么这个对象将会被放置在一个名为 F-Queue
的队列中,稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它们的 finalize()
方法。finalize()
方法是对象逃脱死亡命运的最后一次机会,稍后GC将对 F-Queue
中的对象进行第二次小规模标记。如果对象在 finalize()
中成功重新与引用链上的任何一个对象建立关联(例如把自己this
赋值给某个类变量),那么在第二次标记时它将被移出“即将回收”的集合。
五、 JVM有哪些垃圾回收算法
确定了哪些对象是垃圾之后,垃圾收集器的任务就是进行回收。以下是几种经典的垃圾收集算法:
-
5.1 标记-清除算法(Mark-Sweep)
-
过程: 算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。
-
优点: 是最基础的收集算法,后续很多算法都是在其基础上改进的。
-
缺点:
-
执行效率不稳定: 如果Java堆中包含大量对象,而且其中大部分是需要回收的,这时必须进行大量标记和清除动作,导致效率降低。
-
内存空间的碎片化问题: 标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集。
-
-
-
5.2 复制算法(Copying)
-
过程: 它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
-
优点: 实现简单,运行高效,解决了内存碎片问题。
-
缺点: 代价是将可用内存缩小为了原来的一半,空间浪费太多。
-
优化(Appel式回收): 现在的商用Java虚拟机大多都采用了这种复制算法来回收新生代。IBM的研究表明,新生代中的对象有98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间。HotSpot虚拟机将新生代分为一块较大的Eden空间和两块较小的Survivor空间(通常比例为8:1:1)。每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。这样只有10%的内存会被“浪费”。
-
-
5.3 标记-整理算法(Mark-Compact)
-
过程: 标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。
-
优点: 避免了内存碎片问题,也避免了复制算法浪费一半空间的代价。
-
缺点: 移动存活对象并更新所有引用这些对象的地方是一个极为负重的操作,而且这种操作必须全程暂停用户应用程序(Stop The World)。
-
应用: 该算法一般用于老年代的垃圾回收,因为老年代对象存活率高,没有额外的空间进行分配担保。
-
-
5.4 分代收集理论(Generational Collection)
当前商业虚拟机的垃圾收集器,大多都遵循了“分代收集”的理论。它建立在两个分代假说之上:-
弱分代假说(Weak Generational Hypothesis): 绝大多数对象都是朝生夕灭的。
-
强分代假说(Strong Generational Hypothesis): 熬过越多次垃圾收集过程的对象就越难以消亡。
根据这两个假说,收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
-
新生代(Young Gen): 区域中对象“朝生夕死”,回收频率高,适合使用复制算法,效率高。
-
老年代(Tenured Gen): 区域中对象存活率高,没有额外的空间对它进行分配担保,适合使用标记-清除或标记-整理算法。
-
现代先进的垃圾收集器(如G1, ZGC, Shenandoah)已经不再拘泥于固定的分代形式,但其思想内核依然是对对象存活周期的判断和针对不同特点区域采用最合适的算法。
总结:
本文对 Java 中常见的几个核心概念进行了梳理与总结,旨在帮助读者更好地区分和理解相关机制。若存在表述不准确之处,欢迎指正与讨论。