前言
“Write Once Run Anywhere”这句口号,全靠JVM来兜底。做了一年左右的Ja va开发,越来越觉得,真正值钱的是思想结构和底层实现——毕竟新技术层出不穷,但多数只是在原有框架上锦上添花。
这篇文章是我读《深入理解Ja va虚拟机》时做的总结,如果有什么说得不对的地方,欢迎各位朋友指正,我会持续修正。

正文
JDK,JRE,JVM三者之间的关系
JDK包含JRE,JRE包含JVM。这个嵌套关系是基础中的基础,但不代表它不重要——理解了这个,才能说清后续所有的分层设计。
内存溢出诊断
通过一个VM参数进行设置:-XX:HeapDumpOnOutOfMemoryError。
这个参数会在内存溢出时自动导出分析文件(通常是一个heap dump),然后需要借助工具(比如Eclipse MAT、VisualVM)来进一步分析。
另外,JDK自带的可视化工具 jconsole.exe 也值得多用,它能实时监控内存、线程、类加载等情况。
JVM分类
Sun Classic VM(已淘汰,第一台商用的Ja va虚拟机)
解释器和编译器不能协同工作,只能用纯解释器的方式来执行Ja va代码。
Exact VM
编译器和解释器开始混合工作,即引入了两级即时编译器(JIT)。
HotSpot VM
就是我们如今最普遍使用的虚拟机。这个名字本身就说明了它的核心策略——热点代码检测与优化。
Ja va虚拟机内存管理
Ja va虚拟机在执行程序时,会把它所管理的区域划分成不同的数据区。整体来看,内存区域可以分为两大块:
- 线程共享区:方法区、Ja va堆
- 线程独占区:虚拟机栈、本地方法栈、程序计数器
共享区是所有线程都能访问的,而独占区则每个线程各自一份,互不干扰。
内存区域之程序计数器
这是一块很小的内存空间,本质上是当前线程所执行的字节码的行号指示器。字节码解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
如果执行的是Ja va方法,计数器记录的是虚拟机字节码的指令地址;如果执行的是native方法,那么这个计数器的值是空的(Undefined)。
为什么要记录这个?为了线程上下文切换。线程切换时,通过计数器恢复之前的执行位置,保证字节码执行的连续性——线程只负责执行,并不负责保存数据,计数器就是它的“记忆”。
内存区域之虚拟机栈
虚拟机栈描述的是Ja va方法运行的内存模型。每一个方法的调用到完成,都对应着栈帧在虚拟机栈中入栈和出栈的过程。
- 栈帧:每个方法执行时都会创建一个栈帧,伴随方法的开始到结束。它用于存储局部变量表(定长为32位槽)、操作数栈、动态链接(面向对象的多态性)、方法出口(两种出栈:return 或 exception)等。
- 局部变量表:存放的是编译期的基本数据类型、引用类型、returnAddress类型(指向一条字节码指令的地址)。
(Tips:这个局部变量表,就是我们平常所说的“栈”。)
栈的区域是固定的,当我们不断调用方法,就会不断产生栈帧压入栈中。如果超过栈的容量,就会出现 StackOverflowError。想象一下最常见的场景——递归,如果没写好,就是无底洞。
内存区域之本地方法栈
本地方法栈为虚拟机执行native方法服务,而虚拟机栈为虚拟机执行Ja va方法服务。这就是两者唯一的区别。在HotSpot VM中,这两个区域并没有明显区分。
之所以单独开辟本地方法栈,是为了方便与操作系统交互。Ja va跟操作系统直接打交道有些不便,因此最底层的ClassLoader是用C++编写的,本地方法栈也就顺理成章地存在了。
内存区域之堆
堆是内存中最大的一块,存放对象实例。它也是垃圾收集器管理的主要区域,很多人称之为“GC堆”。
如果堆内存溢出,会抛出 OutOfMemoryError。
可以通过 -Xmx 和 -Xms 这两个VM参数来调整堆的大小。
内存区域之方法区
很多人称它为“永久代”(PermGen),但在JDK 8以后已被元空间(Metaspace)取代。垃圾收集在这个区域比较少见,因为回收代价高但收益低。
方法区存储虚拟机加载的类信息(类的版本、字段、方法、接口)、常量、静态变量、即时编译器(JIT)编译后的代码等数据。
方法区也可能出现 OutOfMemoryError。
运行时常量池
运行时常量池属于方法区的一部分,存放编译时生成的字面量和符号引用。
举个简单的例子:
String str1 = "abc";
String str2 = "abc";
String str3 = new String("abc");
这里str1和str2的地址是相同的——这要归功于常量池。而str3与它俩不同,因为new关键字直接在堆中开辟了一块新内存,不受常量池影响。
直接内存(Direct Memory)大多时候也被称为堆外内存。自从JDK引入NIO后,直接内存的使用越来越普遍。通过native方法可以分配堆外内存,再通过 DirectByteBuffer 对象来操作。
对象创建
给对象分配内存有两种方式:指针碰撞和空闲列表。
具体用哪一种,取决于堆内存是否规整——而是否规整又取决于垃圾回收机制。如果垃圾回收能把区域整理得相对完整(比如复制算法、标记-整理算法),就用指针碰撞;如果内存是零散的(比如标记-清除算法),就只能用空闲列表了。
对象的结构
对象的大小必须是8的整数倍。
对象的结构主要包括以下几部分:
-
Header(对象头)
- Mark Word:存储对象自身的运行时数据,比如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。在32位和64位虚拟机(未开启压缩指针)中,这部分数据长度分别为32bit和64bit。
- Klass指针(类型指针):指向该对象所属类的类型指针,说明当前对象是哪个类的实例。
- 数组长度(只有数组对象有):记录了数组的长度,所以我们才能通过
length属性获取长度。
-
Instance Data(实例数据)
这是真正存储信息的地方。不管是从父类继承来的还是自己定义的,都需要记录在这里。 -
Padding(对齐填充)
填充内存用的。因为对象分配的内存必须是8的整数倍,如果实例数据没有对齐,就用填充来补齐。
对象的访问定位
有两种方式:
-
使用句柄:有点像间接寻址。堆内存中存在一个句柄池,引用指向句柄池中的一块地址,句柄池再指向实际的堆内存。好处是存储的是稳定的句柄地址,当对象被移动时(比如GC),只需修改句柄池中的指针,引用本身不用变。
-
使用指针:引用直接指向堆内存。好处是速度快,节省了一次指针定位的开销。HotSpot VM默认使用的是这种方式。
虚拟机参数
-Xms:设置堆的最小值-Xmx:设置堆的最大值-Xss:设置栈的容量
垃圾回收
想要打印GC的详细信息,需要在VM参数中加入 -verbose:gc -XX:+PrintGCDetails。
如何判定对象为垃圾对象?
-
引用计数法
在对象中添加一个引用计数器,有地方引用就+1,引用失效就-1。但这种方法很少用,因为存在循环引用的问题:两个对象互相引用,计数器永远不为0,就永远不会被回收。 -
可达性分析
从GC Roots节点向下搜索。如果一个对象到GC Roots没有任何引用链(即没有路径可达),那么这个对象就会被判定为可回收。GC Roots包括:虚拟机栈中局部变量表引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中引用的对象。
这里反复提到“引用”,Ja va中存在四种引用类型:
- 强引用:垃圾回收器永远不回收强引用的对象。
- 软引用:有用但非必须。内存即将溢出之前,如果发现软引用对象,会先触发第二次GC。如果仍溢出,才抛出OOM。通过
SoftReference使用。 - 弱引用:非必须的引用,只能存活到下一次GC之前,不管内存是否充足都会被回收。通过
WeakReference使用。 - 虚引用:这种引用存在的目的是,当被引用对象被回收时,系统会收到一个通知。通过
PhantomReference实现。
大多数情况下,垃圾回收都发生在堆区。方法区的回收很少见,因为性价比太低。如果要回收方法区,主要回收两类东西:废弃常量和无用的类。
回收策略
-
标记-清除算法
先通过可达性分析标记出需要清理的对象,然后再统一清除。算法虽然简单,但有两个问题:效率低(标记和清除都耗时间),以及空间碎片化(被标记的对象可能很分散,清理后内存出现大量不连续的空隙,不利于后续分配大对象)。 -
复制算法
把堆内存划分成两块(通常比例是8:1:1的Eden和Survivor),每次只使用其中一块。当进行GC时,把存活的对象复制到另一块,然后一次性清空原来那块的所有对象(不管存活还是死亡)。这样一来,每次只需处理一块区域,没有碎片问题,代价是浪费了一部分内存作为复制空间。
现在大多数虚拟机的新生代都采用这种策略。 -
标记-整理算法
一般用于老年代。因为复制算法需要额外的内存担保,老年代存活对象多,复制成本太高。标记-整理算法会将所有存活对象向内存一端移动,然后清理掉边界以外的全部内存。 -
分代收集算法
根据不同区域的特点选择不同的算法。比如新生代用复制算法,老年代用标记-清理或标记-整理。这样能做到因地制宜,兼顾效率与空间。
垃圾回收器
不同的垃圾回收器对应不同的场景。它们的不断演进,本质上就是一个不断缩短“Stop-the-World”时间的过程。
-
Serial
最基本的单线程回收器。回收时会暂停所有用户线程,只有回收线程在运行,直到回收结束。在Client模式下是个不错的选择。 -
ParNew
多线程版的Serial,开多个线程并行清理。虽然效率更高,但依然会阻塞用户线程。它是新生代收集器,使用复制算法,可以与CMS(老年代收集器)配合使用。 -
Parallel Sca venge
新生代收集器,使用复制算法,但不与CMS配合使用。它的关注点是可控的吞吐量(吞吐量 = CPU运行用户代码时间 / (用户代码时间 + GC时间))。适合对吞吐量有高要求的后台计算场景。 -
CMS(Concurrent Mark Sweep)
可以一边扔垃圾一边打扫,目标是低停顿。使用标记-清除算法,工作过程分为:初始标记(暂停)、并发标记、重新标记(暂停)、并发清理。优点:并发收集、低停顿。缺点:占用大量CPU资源,无法处理浮动垃圾(并发清理期间新产生的垃圾)。 -
G1(Garbage First)
使用标记-整理算法,目的是实现并行与并发、分代收集、空间整合。它的内存布局与传统回收器大不相同:将堆划分为一个个大小相等的Region(区域),虽然逻辑上保留新生代、老年代,但物理上不再连续。后台会维护一个优先列表,每次根据允许的收集时间,回收价值最大的Region区域。这也是它被命名为“Garbage First”的原因——优先处理垃圾最多的区域。
内存分配
内存分配遵循几个原则:
- 优先分配到Eden区。当Eden区空间不足时,会触发一次Minor GC。
- 大对象直接分配到老年代。因为Eden区频繁发生Minor GC,且采用复制算法,大对象频繁移动的成本太高,所以直接扔到GC发生频率低的老年代。老年代的GC叫Full GC / Major GC,速度比Minor GC慢10倍以上。
- 长期存活的对象分配到老年代。每个对象都有一个年龄计数器,每熬过一次Minor GC就加1,默认长到15岁就晋升到老年代。
- 空间分配担保
举个例子:假设现在有2M、2M、2M、4M四个对象,Eden区设置成8M。前三个对象会先进Eden,但发现4M对象放不进去,这时候会把Eden中已有的6M对象移动到别的空闲区域(比如Survivor或老年代),然后再把4M对象放入Eden。这就是空间分配担保。 - 动态对象年龄判断
即使年龄没到默认阈值(15),对象也可能直接进入老年代。条件是:Survivor区中同一年龄的所有对象大小加起来,超过了Survivor区总大小的一半。这时,大于或等于这个年龄的对象就会直接晋升到老年代。 - 逃逸分析与栈上分配
如果对象的作用域仅在方法内部有效,没有发生逃逸(即没有被外部引用),那么虚拟机可以把它分配在栈内存中,从而避免堆内存的GC压力。简单说:能用局部变量时尽量用局部变量。
虚拟机工具
这里列举一些实用的命令行工具和可视化工具:
- jps(Ja va Process Status)
-m:显示传入的参数;-v:显示虚拟机参数;-l:显示详细的类名或jar包路径。 - jstat:监控虚拟机各种运行状态,比如类装载、内存、垃圾回收、JIT编译等。
- jinfo:实时查看和调整虚拟机各项参数。
-v可以查看启动时指定的参数。 - jmap:生成堆转储快照(heap dump),用于分析OOM等内存问题。
- jhat:结合jmap生成的dump文件进行分析,并提供一个可视化界面。
- jstack:生成当前时刻的线程快照,用于排查死锁、线程阻塞等问题。
- HSDIS:生成JIT反编译的汇编代码,用于底层分析。
- jconsole:相当于jps的图形化版本,还能查看远程进程状态。
- VisualVM:多合一故障处理工具,集成了jmap、jstack、jstat等功能,非常方便。
性能调优例子
某系统需要将用户的绩效考核信息生成为一个Excel文件,但不定时会出现卡顿。
解决思路:
- 优化SQL
- 监控CPU
- 监控内存——经常发生Full GC
根本原因:一台Tomcat设置的堆内存过大,且用户生成Excel的时间比较集中,导致大对象不断生成,老年代频繁告急,于是频繁触发Full GC。Full GC会阻塞所有工作线程,于是出现了明显的时间空档。
解决方案:在一台服务器上部署多个Tomcat实例组成集群,每个实例的堆分配4G,分散大对象创建的压力。
后记
在今后的不断学习中,我会持续更新这篇文章。Ja va虚拟机的知识非常深,说实话看完一遍只是起点,真正掌握还需要在实战中反复印证。希望这份总结能帮你节省一点检索资料的时间。
