1. JVM的内存结构
① 总体结构
- 《深入理解Java虚拟机(第二版)》中的描述是下面这个样子的:
② 程序计数器(Program Counter Register)
每个线程私有
,用于指向当前线程正在执行的字节码指令地址
。通过修改程序计数器的值,可以选择下一条将要执行的指令。- 分支、循环、跳转、线程恢复等基础功能都需要通过程序计数器去实现。
- 比如,当CPU从A线程切换到B线程,再切回到A线程时,CPU应该在A线程的哪里继续执行呢?就需要使用程序计数器,通过获取它的值,就可以知道A线程下一条需要执行的字节码指令,即
找到它离开时的位置来继续执行
。这也是为什么程序计数器是每个线程私有的原因。 - 注意: 当CPU执行的是一个
Java方法
时,程序计数器记录的是正在执行的虚拟机字节码指令的地址
。如果执行的是Native方法
,这个计数器值为Undefined
,即不发挥作用。 - 使用程序计数器
不需要考虑OutOfMemoryError异常
。
③ Java虚拟机栈
- 虚拟机栈也是
线程私有
,与线程的生命周期一致,描述的是Java方法执行的内存模型
:每个方法在执行的同时都会创建一个栈帧
用于存储局部变量表,操作数栈,动态链接,方法出口 等信息。 - 每一个方法从调用直至执行完成的过程,就对应着一个
栈帧在虚拟机栈中入栈和出栈
的过程。 - 局部变量表
存放了编译期间可以知道大小的各种类型的变量
,它所需要的内存空间大小在编译期间就已经分配,当一个方法被调用时,栈帧进入虚拟机栈,在运行期间,局部变量表大小是不会变化的
。 - Java虚拟机栈可能抛出的异常:
① 当线程请求的栈深度超过JVM所允许的深度
,会抛出StackOverflowError
异常;
② 栈进行动态扩展时如果无法申请到足够内存
,会抛出OutOfMemoryError
异常。 - 可以通过
-Xss
这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小
,在 JDK 1.4 中默认为 256K,而在 JDK 1.5+ 默认为 1M。想要设置java虚拟机栈大小为2M:-Xss2M
。
④ 本地方法栈
- 本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且
被编译为基于本机硬件和操作系统的程序
,对待这些方法需要特别处理。 - 本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务。
- 本地方法栈也是
线程私有
,也会发生StackOverflowError
异常和OutOfMemoryError
异常。 - 注意: 由于虚拟机规范对于本地方法栈的具体实现没有做强制要求,所以
Sun HotSpot
直接把本地方法栈和虚拟机栈合二为一。
⑤ 堆(GC堆)
- 堆是JVM内存管理中最大的一块,被
所有线程所共享
,在JVM初始化时创建。几乎所有的实例对象和数组都在堆上分配内存
,是垃圾收集的主要区域(GC 堆)
。 - 为什是几乎所有的实例对象和数组? 因为随着相关技术的成熟,
栈上分配
和标量替换优化技术
使得它发生了一些微妙的变化。 - 现代的垃圾收集器基本都是采用分代收集算法,其主要的思想是
针对不同类型的对象采取不同的垃圾回收算法
。可以将堆分成两块:新生代(Young Generation)
、老年代(Old Generation)
。 - 堆不需要使用一块连续内存,因此可以
动态扩展堆内存
,扩展失败会抛出OutOfMemoryError
异常。 - 可以通过
-Xms
和-Xmx
这两个虚拟机参数来指定一个程序的堆内存大小:
①-Xms
设置程序堆内存的初始值,如-Xms1M
②-Xmx
设置程序堆内存的最大值,如-Xmx2M
⑥ 方法区(永久代)
- 用于存储已经被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- HotSpot设计团队把
GC分代收集扩展至方法区
,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存。因此方法区又叫永久代(Permanent Generation)
。 - 方法区是一个 JVM 规范,
永久代与元空间
都是其一种实现方式
。在JDK 1.8 之后
,原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,静态变量和常量池等放入堆中。 - 注意: 对于其他虚拟机,如J9,是没有永久代概念的。
- 方法区和堆一样不需要连续的内存,并且可以
动态扩展
,动态扩展失败一样会抛出OutOfMemoryError
异常。 - HotSpot中永久代的大小配置,使用
-XX:MaxPermSize
参数。
⑦ 直接内存(不属于JVM的内存结构)
- 直接内存不是Java虚拟机规范的内存区域。但是这部分也
被Javaer频繁使用
,而且也会导致OutOfMememeryError
异常。 - 在
JDK 1.4
中新引入了NIO (New Input/OutPut)类
,它可以使用 Native 函数库直接分配堆外内存
,然后通过 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作
。 - 这样能在一些场景中显著提高性能,因为
避免了在堆内存和堆外内存来回拷贝数据
。 - 直接内存
不受任何虚拟机参数控制
,但是不能大于物理内存大小。
2. 如何判断一个对象是否能被回收?
① JVM的哪些地方需要进行垃圾回收
- 在JVM内存模型中,有三个是不需要进行垃圾回收的:程序计数器、JVM栈、本地方法栈。因为它们的生命周期是和线程同步的,随着线程的销毁,它们占用的内存会自动释放。
- 方法区和堆 是线程共享的,线程被销毁时它们占用的内存不会自动释放,需要进行垃圾回收。
- 垃圾回收主要发生在堆中,因此又叫
GC堆
。
② 引用计数算法(Reference Counting Collector) —— JVM中不使用
- 为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
- 在两个对象出现
循环引用
的情况下,此时引用计数器永远不为 0
,导致无法对它们进行回收。 - 正是因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
- 循环引用的例子:
public class Test {
public static void main(String[] args) {
Student a = new Student();
Student b = new Student();
a.object = b;// 循环引用
b.object = a;
a = null;
b = null;
}
}
③ 可达性分析算法(Reachability Analysis)—— JVM使用该算法
- 可达性分析算法,又叫
根搜索算法
:以GC Roots
为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。
- Java 虚拟机使用该算法来判断对象是否可被回收,
GC Roots
一般包含以下内容:
- Java虚拟机栈中的局部变量表中引用的对象
- 本地方法栈中JNI(Java本地接口)引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
④ 方法区的回收
- 因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。
- 方法区的回收:主要是
对常量池的回收和对类的卸载
。 - 类的卸载条件很多,需要满足以下三个条件,并且
满足了条件也不一定会被卸载
:
该类所有的实例都已经被回收
,此时堆中不存在该类的任何实例。- 加载该类的
ClassLoader
已经被回收。 该类对应的 Class 对象没有在任何地方被引用
,也就无法在任何地方通过反射访问该类方法。
⑤ finalize()
方法—— 可以避免被回收,但是最好不要使用
- 在Java创建之初,为了让C++程序员更容易接受它所创建的一种方法,用于进行对象的释放。
- 虽然可以用于关闭外部资源,但是
try-finally
等方式可以做得更好。而且该方法运行代价很高,不确定性大
,无法保证各个对象的调用顺序,因此最好不要使用。 - 当一个对象可被回收时,如果需要执行该对象的
finalize() 方法
,那么就有可能在该方法中让对象重新被引用
,从而实现自救
- 由于任何对象的
finalize()
方法只会被系统调用一次,如果回收的对象之前调用了finalize()
方法自救,后面回收时不会再调用该方法。因此自救只能进行一次
。
3. 如何进行回收?(垃圾回收算法)
① 标记 - 清除算法(Mark-Sweep)—— 一般没有虚拟机采用
- 该算法分为两个阶段:标记结算和清除阶段。
- 使用
可达性分析算法
标记出需要回收的对象,在标记完成后统一回收所有被标记的对象。 - 另外,还会判断回收后的分块与前一个空闲分块是否连续。若连续,会合并这两个分块,构成更大的分块。
- 注意: 是连续的分块才会发生合并,而不是因为对象移动发生的合并。
- 将回收的内存碎片作为分块,连接到被称为
空闲链表
的单向链表。之后进行分配时只需要遍历这个空闲链表,就可以找到分块。 - 在分配时,程序会搜索
空闲链表
寻找空间大于等于新对象size的块 block。如果它找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 b l o c k − s i z e block - size block−size 的两部分,返回大小为 size 的分块,并把大小为 b l o c k − s i z e block - size block−size的块返回给空闲链表。 - 不足:
- 标记和清除过程效率都不高
- 会产生大量不连续的内存碎片,导致无法给大对象分配内存。
② 复制算法(Copying)
- 将内存划分为大小相等的两块,每次只使用其中一块。当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理,这样一来就不容易出现内存碎片的问题。
- 主要不足:
- 只使用了内存的一半,浪费空间
- 复制的代价较高,
适合新生代
。因为新生代的对象存活率较低
,需要复制的对象较少。
- 现在的商业虚拟机都采用复制算法回收新生代,但是并不是划分为大小相等的两块。
- 具体做法:
- 将内存划分为
一块较大的 Eden 空间
和两块较小的 Survivor 空间
,每次使用 Eden 和其中一块 Survivor。 - 在回收时,将 Eden 和 Survivor 中还存活着的对象全部
复制到另一块 Survivor
上,最后清理 Eden 和使用过的那一块 Survivor
。
- HotSpot 虚拟机的
Eden 和 Survivor 大小比例默认为 8:1
,保证了内存的利用率达到 90%。注意: 每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要借用老年代的空间存储放不下的对象。
③ 标记 - 整理算法(Mark-Compact)
- 标记整理算法的标记过程
类似标记清除算法
,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动
,然后直接清理掉端边界以外的内存
,类似于磁盘整理的过程。 - 该垃圾回收算法适用于对象存活率高的老年代。
- 优点:
不会产生内存碎片
;不足: 需要移动大量对象,处理效率比较低
。
④ 分代收集(因地制宜,选择之前的三种算法)
- 现在的商业虚拟机采用
分代收集算法
,它根据对象存活周期将内存划分为几块
,不同块采用适当的收集算法。 - 一般将堆分为
新生代
(Young Generation)和老年代
(Tenured Generation)。
- 新生代中对象的存活率比较低,
每次垃圾回收时都有大量的对象需要被回收
,因此使用复制算法。 - 老年代中对象存活率比较高,
每次垃圾收集时只有少量对象需要被回收
,因此使用:标记 - 清除 或者 标记 - 整理 算法。
- 在堆区之外还有一个代就是
永久代
(Permanet Generation),主要是类的卸载
和常量池的回收
。
4. 垃圾回收器
- 以下是 HotSpot 虚拟机中的
7 个垃圾收集器
,连线表示垃圾收集器可以配合使用。 - 包括Serial收集器、ParNew收集器、Parallel Scavenge收集器、Serial Old收集器、Parallel Old收集器、CMS收集器、G1收集器。
- 可以按线程分为单线程和多线程,但串并行分为串行和并行。
- 单线程与多线程:指的是垃圾收集器是否使用多个线程。
- 串行与并发:串行指的是
垃圾收集器与用户程序交替执行
,这意味着在执行垃圾收集的时候需要停顿用户程序;并发
指的是垃圾收集器和用户程序并发执行
。除了CMS 和 G1 是并发执行
,其它垃圾收集器都是以串行的方式执行吗,即进行卡机回收期间需要停顿用户程序。
① Serial 收集器(复制算法)
新生代
的串行执行
的单线程收集器
,标记和清理都是单线程。
Serial收集器和Serial Old收集器运行示意图 - Serial收集器有如下特点:
优点是简单高效
,在单个 CPU 环境下,由于没有线程交互的开销,拥有最高的单线程收集效率。- 它是
Client 场景下的默认新生代收集器
,通过冻结所有应用程序线程进行工作
,所以可能不适合服务器环境
。 - 它为单线程环境设计,只使用一个单独的线程进行垃圾回收,它
最适合的是简单的命令行程序
(单CPU、新生代空间较小及对停顿时间要求不是非常高的应用)。
- 注意: 停顿时间是指Serial收集器进行垃圾回收期间,应用程序线程停止运行的时间。它收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿时间是可以接受的。
- 可以通过
-XX:+UseSerialGC
来强制指定。
② ParNew 收集器(复制算法)
- 新生代收集器,它是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。
- ParNew收集器是
Server场景下默认的新生代收集器
:一是性能原因,二是因为除了 Serial 收集器,只有它能与 CMS 收集器配合使用。 - 可以通过参数
-XX:+USeParNewGC
强制指定ParNew收集器。
③ Parallel Scavenge收集器(复制算法)
- 与 ParNew 一样是多线程收集器,其它收集器目标是
尽可能缩短垃圾收集时用户线程的停顿时间
,而它的目标是达到一个可控制的吞吐量
,因此它被称为吞吐量优先 收集器。
Parallel Scavenge收集器和 Parallel Old收集器运行示意图 - Parallel Scavenge收集器的吞吐量一般为
99%
, 吞 吐 量 = 用 户 线 程 时 间 / ( 用 户 线 程 时 间 + G C 线 程 时 间 ) 吞吐量 = 用户线程时间/(用户线程时间 + GC线程时间) 吞吐量=用户线程时间/(用户线程时间+GC线程时间)
停顿时间
越短就越适合需要与用户交互的程序
,良好的响应速度能提升用户体验。- 高吞吐量则可以
高效率地利用 CPU 时间
,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务
。
- 可以通过
-XX:+UseParallelGC
来强制指定Parallel Scavenge收集器,用-XX:ParallelGCThreads=4
来指定Parallel Scavenge收集器的线程数。
④ Serial Old收集器(标记-整理算法)
- Serial收集器的老年代版本,即是老年代的单线程收集器。
- Serial Old收集器是给 Client 场景下的虚拟机使用。如果用在 Server 场景下,它有两大用途:
- 在 JDK 1.5 以及之前版本(
Parallel Old 诞生以前
)中与 Parallel Scavenge 收集器搭配使用
。 - 作为
CMS 收集器的后备预案
,在并发收集发生Concurrent Mode Failure
时使用。
- 可以通过参数
-XX:+UseSerialOldGC
强制指定Serial Old收集器。
⑤ Parallel Old 收集器(标记-整理算法)
- 是Parallel Scavenge收集器的老年代版本,多线程并行收集器,
吞吐量优先
。 - 在
注重吞吐量
以及CPU 资源敏感
的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器
。 - 可以通过
-XX:+UsrParallelOldGC
指定Parallel Old收集器。
⑥ CMS 收集器(标记-清除算法)
- CMS(Concurrent Mark Sweep),
Mark Sweep 指的是标记 - 清除算法
,并发地使用
标记-清除算法进行老年代的回收。
Concurrent Mark Sweep收集器运行示意图 - 分为以下四个流程:
- 初始标记:仅仅只是
标记一下 GC Roots 能直接关联到的对象
,速度很快
,需要停顿。 - 并发标记:进行 GC Roots Tracing 的过程,它在
整个回收过程中耗时最长
,不需要停顿。 - 重新标记:为了
修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
,需要停顿。 - 并发清除:清除可回收的对象,不需要停顿。
- 在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。
- CMS收集器的缺点:
- 吞吐量低:
低停顿时间是以牺牲吞吐量为代价
的,导致 CPU 利用率不够高。 - 无法处理浮动垃圾,可能出现
Concurrent Mode Failure
。浮动垃圾 是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure
,这时虚拟机将临时启用 Serial Old 来替代 CMS
。 标记 - 清除算法导致的空间碎片
,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次Full GC
。
- 可以通过
-XX:+UseConcMarkSweepGC
指定CMS收集器。
⑦ G1 收集器
- G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,
在多 CPU 和大内存的场景下有很好的性能
。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。 - 堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而
G1 可以直接对新生代和老年代一起回收
。
G1 把堆划分成多个大小相等的独立区域(Region)
,新生代和老年代不再物理隔离
。
- 引入 Region 的概念,将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,
使得可预测的停顿时间模型成为可能
。 - 通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,
优先回收价值最大的 Region
。 - 每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
- 如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:
- 初始标记
- 并发标记
- 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
- 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
- G1具备如下特点:
- 空间整合:
整体来看是基于标记 - 整理算法
实现的收集器,从局部(两个 Region 之间)上来看是基于复制算法
实现的,这意味着运行期间不会产生内存空间碎片
。 - 可预测的停顿: 能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。