JVM原理¶
约 4046 个字 27 行代码 3 张图片 预计阅读时间 14 分钟
内存区域(Java运行时数据区)划分¶
JVM本身是一个进程,操作系统会为每一个进程划分一块内存空间,而JVM自己的内存区域划分就是该进程在操作系统给他的内存中划分出的不同区域,用于存储程序运行期间的各种数据。因为操作系统的内存区域划分已经比较成熟,所以JVM也参考着实现了自己的一套内存区域划分
在JVM中,内存被划分了下面几块区域:
- 程序计数器(PC):用于存储下一条指令的地址
- Java虚拟机栈:用于存储方法执行时创建的栈帧,包含局部变量表、操作数栈、动态链接和方法出口等信息,每一个线程私有的
- 本地方法栈:用于存储本地方法(Native Method)执行时的相关信息,其功能与Java虚拟机栈类似,但专为本地方法服务
- 堆:用于存放程序中创建的所有对象实例,是JVM中最大的一块内存区域,也是垃圾回收器管理的主要区域
- 元数据区(或称方法区):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
参考图如下:
类加载机制¶
Java中的类加载需要经历下面几个阶段:
- 加载
- 连接(验证、准备、解析)
- 初始化
在加载阶段,JVM通过类的全限定名获取定义此类的二进制字节流,并将该字节流所代表的静态存储结构转化为方法区的运行时数据结构,同时在内存中生成一个代表该类的java.lang.Class对象,作为方法区对该类数据的访问入口
加载完成后,便进入连接阶段,连接阶段包括验证、准备和解析三个步骤。验证的目的是确保Class文件的字节流符合Java虚拟机规范的约束,不会危害虚拟机自身安全;准备阶段是为静态变量分配内存并设置初始零值;解析阶段则将常量池中的符号引用替换为直接引用
进入初始化阶段,JVM开始真正执行类中定义的Java代码,即执行类构造器方法<clinit>()的过程。该阶段会将静态变量的赋值操作和静态代码块中的语句按顺序合并执行,确保类的初始化按预期完成
为什么静态代码块会先于类的构造方法执行?
因为静态代码块在类加载期间就已经完成了,而类的构造方法需要等实际创建出当前类的对象才会执行
类加载时机与类加载器¶
JVM规范规定了6种情况必须立即对类进行初始化:
- 遇到
new、getstatic、putstatic、invokestatic这4条字节码指令时。对应的 Java 代码就是 new 对象、读取或设置类的静态字段(被 final 修饰的常量除外)、调用静态方法 - 使用
java.lang.reflect包对类进行反射调用时 - 初始化子类时发现父类还没初始化,先把父类初始化了
- JVM启动时指定的主类(包含
main方法的那个类) - JDK 7开始的动态语言支持,如果 MethodHandle 实例解析结果是
REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial这四种句柄,对应的类要先初始化 - 接口中定义了
default方法,实现类初始化前要先初始化这个接口
JVM 的类加载采用双亲委派模型,有三种内置的类加载器:
- Bootstrap ClassLoader:最顶层的加载器,C++ 实现的,负责加载
JAVA_HOME/lib目录下的核心类库,比如rt.jar、tools.jar。在 Java 代码里拿不到它的引用,返回的是null - Extension ClassLoader:负责加载
JAVA_HOME/lib/ext目录下的扩展类库。JDK 9 之后改名叫 Platform ClassLoader - Application ClassLoader:加载
classpath下的类,也就是我们自己写的代码和引入的第三方 jar 包
如果要在代码中获取一个类的类加载器,可以使用下面的两种方式:
Class对象中的方法:getClassLoader(),使用时:类名.class.getClassLoader(),作用是获取某一个使用的类加载器ClassLoader类中的方法:getParent(),使用时:ClassLoader对象.getParent(),作用是获取指定类加载器的父类加载器
| Java | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | |
双亲委派模型与缓存机制¶
双亲委派模型,也称为全盘负责委托机制,表示类加载器在加载类时,首先不会自己去尝试加载这个类,而是把请求交给父类加载器来完成,每一层的类加载器都会遵循这样的规则。
类加载器的缓存机制:一个类加载到内存之后,缓存中也会保存一份儿,后面如果再使用此类,如果缓存中保存了这个类,就直接返回他;如果没有才加载这个类,下一次如果有其他类在使用的时候就不会重新加载了,直接去缓存中拿,参考图如下:
一个类准备加载进内存时,首先遇到的类加载器就是AppClassLoader,如果AppClassLoader中的缓存已经存在该类,则直接返回该类,否则向上找类加载器ExtClassLoader,同样,如果ExtClassLoader的缓存已经存在该类,则直接返回,否则向上找类加载器BootStrapClassLoader,如果BootStrapClassLoader缓存依旧没有该类,则判断类是否是该类加载需要加载的,不是继续向下直到遇到属于加载该类的类加载器
所以,类加载器的双亲委派和缓存机制共同造就了加载类的特点:保证了类在内存中的唯一性且保证标准库的类优先级最高,不会因为第三方库而覆盖
垃圾回收机制¶
判断对象是否是垃圾¶
在垃圾回收之前,首先需要判断哪些对象已经"死亡"(不再被使用),可以被回收。JVM中主要通过两种算法来判断对象是否存活:引用计数算法和可达性分析算法。
引用计数算法的核心思想是给每个对象添加一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1。任何时刻计数器为0的对象就是不再被使用的对象。这种算法实现简单、判定效率高,但无法解决对象之间循环引用的问题,因此主流的JVM并未采用此算法
可达性分析算法是JVM实际采用的算法。其核心思想是通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为"引用链"。当一个对象到GC Roots没有任何引用链相连(即从GC Roots到这个对象不可达)时,证明此对象是不可用的,可以被回收。参考图如下:
在Java中,可作为GC Roots的对象包括:
- 虚拟机栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象等
基于可达性分析算法,JDK 1.2之后Java对引用概念进行了扩充,分为强引用、软引用、弱引用和虚引用四种,强度依次递减:
- 强引用:指的是在程序代码中普遍存在的,类似于
Object obj = new Object()这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象实例 - 软引用:用来描述一些还有用但不是必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出之前,会把这些对象列入回收范围之中进行第二次回收。在JDK 1.2之后,提供了
SoftReference类来实现软引用 - 弱引用:用来描述非必需对象,强度比软引用更弱。被弱引用关联的对象只能生存到下一次垃圾回收发生之前,当垃圾回收器开始工作时,无论当前内存是否够用,都会回收掉只被弱引用关联的对象。在JDK 1.2之后提供了
WeakReference类来实现弱引用 - 虚引用:也被称为幽灵引用或幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2之后提供了
PhantomReference类来实现虚引用
需要注意的是,可达性分析算法分析的过程中会造成STW(Stop The World)问题,具体来说:在进行可达性分析时,必须在一个能确保一致性的快照中进行,即要冻结所有用户线程的执行,防止在分析过程中对象的引用关系发生变化,从而导致分析结果不准确。不过现代垃圾收集器比如 G1、ZGC 已经通过三色标记、写屏障等技术把大部分标记工作做成并发的了,STW 时间能压到毫秒级
垃圾回收算法¶
垃圾回收算法是垃圾回收的具体实现方法论,目前主流的算法主要有三种:标记-清除算法、复制算法和标记-整理算法,下面是这些算法的具体介绍:
- 标记-清除算法是最基础的收集算法。算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。后续的收集算法都是基于这种思路并对其不足加以改进而已。“标记-清除”算法主要有两个不足:第一是效率问题,标记和清除两个过程的效率都不高;第二是空间问题,标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集
- 复制算法是为了解决标记-清除算法的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效
- 标记-整理算法是针对标记-清除算法产生大量内存碎片以及复制算法在对象存活率较高时效率低下的问题而提出的。标记过程与标记-清除算法一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向内存空间的一端移动,然后直接清理掉端边界以外的内存。这种算法在对象存活率较高的老年代区域中应用广泛,因为移动对象并整理内存可以避免内存碎片,同时确保有足够大的连续空间来分配大对象
但是JVM并没有单独采用上面的任何一种算法,而是根据实际情况,采用分代收集算法,对不同区域的对象采用不同的回收策略。根据对象存活周期的不同将内存划分为新生代和老年代,对不同区域采用不同的回收策略。在新生代中,对象存活率低,适合采用复制算法进行快速回收;而在老年代中,对象存活率高,则采用标记-清除或标记-整理算法,以实现更高效的垃圾回收
现在大部分的商用虚拟机(包括HotSpot)都是采用分代收集算法来管理堆内存,由于新生代中98%的对象都是“朝生夕死”的,因此并不需要按1:1的比例来划分内存空间,而是将新生代内存划分为一块较大的Eden区和两块较小的Survivor区(From区和To区),默认比例为Eden:Survivor=8:1。每次垃圾回收时,将Eden和其中一块Survivor中存活的对象一次性复制到另一块Survivor上,再清理掉Eden和已用过的Survivor空间,这样每次新生代可用的内存空间就达到了整个新生代容量的90%。当Survivor空间不够用时,需要依赖老年代进行分配担保,即把放不下的对象直接存入老年代。
HotSpot实现复制算法的具体流程为:当Eden区首次满时,触发Minor GC,将Eden中存活的对象复制到Survivor From区;随后Eden区再次满时,触发下一次Minor GC,此时会扫描Eden区和From区,将两区域中存活的对象复制到Survivor To区,并清空Eden和From区;之后每次Minor GC都在Eden和当前存放存活对象的Survivor区之间交替进行,存活对象会在两个Survivor区之间来回复制,每经历一次Minor GC,对象年龄就加1,默认经过15次复制后仍存活的对象,就会被晋升到老年代
垃圾收集器¶
HotSpot 虚拟机的垃圾收集器按作用区域分成两类:新生代收集器和老年代收集器,它们需要搭配使用
新生代收集器:
- Serial:单线程,用标记-复制算法。GC 时所有应用线程停下来等着,简单粗暴。在客户端模式下是默认收集器,几十MB 的新生代几秒就能收完
- ParNew:Serial 的多线程版本,除了能并行收集,别的都一样。它存在的意义是能跟 CMS 配合,JDK 9 之后跟 CMS绑定了,单独用不了了
- Parallel Scavenge:也叫吞吐量收集器,多线程并行收集。它的目标不是缩短单次停顿,而是最大化 CPU 用在业务代码上的时间占比。适合后台跑批、大数据计算这种不在乎偶尔卡一下的场景
老年代收集器:
- Serial Old:Serial 的老年代版本,单线程,用标记-整理算法
- Parallel Old:Parallel Scavenge 的老年代搭档,多线程并行标记-整理。要发挥吞吐量优先的效果,新生代老年代得配套用
- CMS:全称 Concurrent Mark Sweep,追求低停顿。大部分工作跟应用线程并发执行,只有初始标记和重新标记需要短暂停顿。缺点是用标记-清除算法会产生碎片,还有并发失败的风险。JDK 9 标记为废弃,JDK 14 正式移除
- G1:JDK 9 之后的默认收集器,把堆切成 2048 个左右的 Region,不再严格区分新生代老年代。能设定目标停顿时间,让 GC 变得可预测
- ZGC:JDK 11 引入的低延迟收集器,停顿时间控制在 10ms 以内,跟堆大小无关。支持 TB 级别的堆内存。JDK 15 转正


