本文整理自周志明老师的《深入理解Java虚拟机-JVM高级特性与最佳实践》第3版的第二章和第三章。
加上了一些网上拼拼凑凑的图片,个人认为很多博客复制来复制去,最后的东西都看不懂,所以从书里码了一下知识点,也用作自己记忆。
一、一个命令
上面的结果显示了 jvm 的模式:
Client VM(-client),为在客户端环境中减少启动时间而优化;
Server VM(-server),为在服务器环境中最大化程序执行速度而设计。
在文件路径:jdk-11.0.7+10\lib 下面可以更改 jvm.cfg 文件来决定是采用哪个模式,具体操作就是更改文件里面 Client 和 Server 这两行的位置,谁在上就是选择谁。
二、JVM 的内存区域与内存溢出异常
如上图所示,是 Java 虚拟机规范规定的,jvm 管理的内存区域。
- 灰色部分,即方法区和堆这两个数据区,是所有线程共享的数据区。
- 而白色部分,包括程序计数器、java虚拟机栈、本地方法栈,叫线程隔离的数据区,或者叫线程私有的内存。这三块内存区域随线程生,随线程死。
每个部分的详细介绍如下:
2.1 pc 寄存器( Program Counter)
也可叫程序计数器。是一块较小的内存空间,可以看作是当前线程执行的字节码的行号指示器。
在虚拟机的概念模型(注意只是概念)里,字节码解释器工作的时候就是通过改变这个计数器的值来选取吓一跳需要执行的字节码指令,显然,分支循环等基础功能都要靠这个计数器。
由于多线程实际上是线程轮流切换实现的,所以线程切换后为了能恢复到正确的执行位置,每个线程都要有一个独立的程序计数器。如果线程执行的是一个 java 方法,计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地方法,计数器的值则为空(Undefined)。
此内存区域是唯一个在java虚拟机规范里没有规定任何 OutOfMemoryError情况的区域。
2.2 java 虚拟机栈
栈是方法执行的线程内存模型。每个方法执行的时候,jvm都会同步创建一个栈帧用于存储局部变量表、操作数栈等到,方法被调用直到执行完毕,就是对应一个栈帧在虚拟机栈里从入栈到出栈的过程。
大多情况栈主要指的是虚拟机栈里局部变量表的部分(实际上的划分要更复杂)。局部变量表存放了各种基本java数据类型、对象引用和 returnAddress 类型(指向了一条字节码指令的地址)。这些数据类型在局部变量表中以局部变量槽(Slot)来表示,其中64位长的long和double类型占用两个槽,其他的占一个。在编译期间,局部变量表的空间就会分配完成,方法运行期间不会改变局部变量表的大小。
java虚拟机规范对这个内存区域规定了两种异常:如果线程请求的栈深度大于虚拟机允许的深度,会抛出StackOverflowError;如果Java栈容量可以动态扩展,当扩展的时候无法申请到足够的内存会抛出OutOfMemoryError。
2.3 本地方法栈
本地方法栈和 java 虚拟机栈类似,区别只是虚拟机栈为虚拟机执行 Java 方法,本地方法栈是为虚拟机使用到的本地方法服务。
因此本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出抛出 StackOverflowError 和 OutOfMemoryError 异常。
2.4 java 堆
java 堆在虚拟机启动的时候建立,它是 java 程序最主要的内存工作区域。
java堆的唯一目的就是存放对象实例。在JVM所管理的内存中,堆区是最大的一块,堆区也是Java GC机制所管理的内存区域。需要注意,java堆只是逻辑上的连续区域,物理上可以不连续。
提到垃圾回收的时候总会说堆的区域划分,但是实际上java虚拟机规范没有规定,所谓的划分是各种虚拟机实现的风格决定的。这部分后面垃圾回收的时候还会讲。
java堆可以固定大小,也可以实现成可扩展,当前主流的虚拟机都是按照可扩展来实现,基于 -Xmx和-Xms参数来设定。
异常:如果堆内存不够,并且堆也无法扩展,抛出OutOfMemoryError。
2.5 方法区
用来存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
在java虚拟机里把他描述为堆的一个逻辑部分,但是又要和堆区分开,还有一个别名叫“非堆”。类加载子系统负责从文件系统或者网络中加载 Class 信息( ClassLoader 就是这个区域下的组件),加载的类信息就存放于方法区。(可以看到,这里保存的东西都是唯一份的东西)
关于垃圾回收的永久代,一般都是指的方法区,原因是当时的hotspot虚拟机设计团队把垃圾收集器的分代设计扩展到了这里,或者说使用永久代实现了方法区,后来因为这种方法更容易内存溢出,永久代的设计已经被取消:到jdk8完全放弃永久代,使用本地内存的中元空间来替代这部分的功能。
异常:无法满足新的内存分配需求,抛出OutOfMemoryError。
- 运行时常量池:是方法区的一部分,用来存放编译器生成的各种字面量和符号引用,在类加载后这些内容都会进入方法区的常量池。
既然是方法区的一部分,显然是受到方法区内存的限制,如果常量池无法再申请到内存,会抛出抛出OutOfMemoryError。
2.6 直接内存
直接内存指的就已经不属于虚拟机运行时数据区域的部分了,java虚拟机规范也没有定义这块内存。
java在jdk1.4 后,引入了 **NIO **类,允许 java 程序通过native函数库直接分配堆外的内存,然后通过java堆里的 DirectByteBuffer对象作为对这一块内存的引用进行操作,在某些场景中能够提高性能,因为避免了 java 堆和 native 堆的数据来回复制。
异常:频繁使用也可能导致抛出OutOfMemoryError。毕竟虽然没有收到java堆的限制,可是还是会受到本机的内存、以及处理器寻址空间的限制
三、垃圾回收算法
3.1 概述
上面的内存区域里,线程独有的三个区域,并不需要过多考虑回收问题,因为分配和回收比较确定。
Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。
对于方法区,永久代的遗留问题关注比较多,最主要的垃圾回收算法还都是关注堆内存。
垃圾收集器所关注的正是这部分内存该如何管理,我们讨论的相关算法也是针对这部分内存。
从如何判定对象消亡的角度处罚,垃圾收集算法可以分为“引用计数式”(Reference Counting GC)和 “ 追踪式”(Tracing GC)两类。主流的 java 虚拟机都采用的第二种。所以下面讲的算法都是这种模式下面的。
3.2 判断对象是否需要回收
垃圾回收第一件事要做的就是,确定哪些对象死了,哪些活着,死了的才要进行回收。对于判断,一般有两种算法。
- 引用计数法(Reference Counting)
给对象添加一引用计数器,被引用一次计数器值就加 1;当引用失效时,计数器值就减 1;计数器为 0 时,对象就是不可能再被使用的,简单高效。
存在问题:无法解决对象之间相互循环引用的问题,要想采用这个算法,还需要很多的额外处理。
- 可达性分析算法
通过一系列的称为 "GC Roots" 的对象作为起始节点集合,从这些节点开始,根据引用关系向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可能再被使用的。
可达性分析算法是当前主流商用程序语言的内存管理子系统采用的算法。
- 哪些对象可以作为 GC Roots 呢?
java 技术体系里,固定可作为 GC Roots 的对象包括以下几种:
- 在虚拟机栈中引用的对象。比如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;
- 方法区中类静态属性引用的对象,比如java类的引用类型静态变量;
- 方法区中常量引用的对象,比如字符串常量池里的引用;
- 本地方法栈JNI(也就是通常说的本地方法)中引用的对象;
- java虚拟机内部的引用,比如基本数据类型对应的 Class 对象,一些常驻的异常对象,和系统类加载器;
- 所有被同步锁(synchronized关键字)持有的对象;
- 反应 java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。
除了这些,还会有一些临时加入的对象,共同构成 GC Roots 集合。
- 方法区的垃圾回收
前面已经说过,主要的收集区域是堆,而且方法区垃圾收集的性价比也比较低。比如在 hotspot 虚拟机采用了元空间来实现永久代,在这个区域没有垃圾收集行为。
如果要回收,方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。
回收废弃常量与回收Java堆中的对象非常类似。都是基于判断是否还有对象引用指向这个常量。常量池中其他类(接口)、方法、字段的符号引用也与此类似。
而判断类型的回收要满足三个条件:
- 该类的所有实例已经被回收,也就是堆中不再存在该类以及任何派生子类的实例;
- 加载该类的类加载器已经被回收(这个条件很难达成);
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
3.3 分代收集理论
网上很多都说是分代收集算法,但是显然这并不是具体的算法,更像一种策略,选择组合各种具体的算法,周志明老师的书上写的是分代收集理论。
分代就是结合堆的区域划分,然后讲回收对象根据年龄不同放到不同区域,这样的基础上可以对某一区域单独进行垃圾回收,当然,分代收集策略、对应的内存分代,都有消亡的趋势。
分代至少会分为新生代和老年代两个区域,新生代垃圾收集结束后,还存活的对象会逐步晋升到老年代存放,具体结合这一节的收集算法,区域的划分下一节会讲解。
在内存分出不同的区域后,对不同区域的回收也起了不同的名字:
- Minor GC / Young GC(新生代收集):目标只是新生代区域的收集;
- Major GC / Old GC(老年代收集):目标只是老年代的垃圾收集;
- Mix GC(混合收集):目标是收集整个新生代+部分老年代的垃圾收集。目前只有 G1 收集器有这种行为。
以上三个都叫 Partial GC,也就是部分收集。还有一种收集:
- Full GC(整堆收集):收集整个 java 堆和方法区的垃圾收集。
3.4 具体的垃圾回收算法
之前看网上有的说法讲最早、基本的垃圾回收算法是
引用计数(Reference Counting):有一个引用就加一个技术,少一个引用就减一个计数,垃圾回收的时候就收集计数为 0 的。
但是现在我明白了,这玩意确实划分的有点乱,引用计数正如 3.2 讲到的,应该算到 如何判断垃圾是否需要回收的算法里,不应该算在垃圾回收算法里。
所以仍然按照深入理解java虚拟机书里讲的,分为三个算法。
3.4.1 标记-清除算法(Mark-Sweep)
扫描GC Roots集合:
- 第一阶段,从引用根节点,开始标记所有被引用的对象;
- 第二阶段,遍历整个堆,把未标记的对象清除。
- 也可以反过来,标记未被引用的对象,然后清除未被标记的。
缺点:
- 效率很不稳定,如果堆包含大量对象,而大部分都要被收集,那么这个操作过程执行效率一直降低;
- 内存空间碎片化,如上图可以看的很明显,垃圾收集执行完后空间碎片过多,可能会导致以后程序运行的时候需要分配大对象的时候找不到连续内存从而又提前触发垃圾收集。
3.4.2 标记-复制算法(简称复制算法)
把内存划分为两个相等的区域,每次只用一个区域,一个内存用完就开始执行算法:
- 把这个区域仍然存活的对象复制到另一个区域(这一步显然还是要先标记的);
- 然后把这个区域一次清理(省掉了上一种方法的第二次遍历)。
优点:
简单、高效,而且解决了产生空间碎片的问题。
缺点:
需要 2 倍内存,总是有一半空的,可用的也只有一半,太浪费了。
3.4.3 标记-整理(Mark-Compact)
结合标记清除算法的第一步,第二步并不采用直接清理,而是让所有对象都向内存空间的固定一端挪动,最后清理掉边界之外的内存。
优点:
显然,进行垃圾收集后不会产生碎片。
缺点:
“整理”的过程,或者说移动,如果是在老年代,每次都沉积着大量对象,移动的过程显然会是一个很负重的操作,必须全程暂停用户应用程序。这种停顿还被设计者描述为 Stop the world。
权衡::
- 如果移动,那么缺点已经说过了;
- 如果不移动,那么要通过更复杂的策略解决内存碎片问题,而内存的访问本身又是用户程序最频繁的操作,额外的负担会影响应用程序的吞吐量。
也就是说,如果移动,内存回收会更复杂,如果不移动,内存分配会更复杂。从整个程序的吞吐量来看,移动会更划算。
注意:因为有标记的过程,通常都是需要停顿用户线程来进行的,只是总体来说,最后一种有整理的过程,前两种的停顿时间就会短一些。
四、JVM堆内存分代策略
需要再次强调的是:
从回收内存的角度看,由于现代垃圾收集器大部分都是基于上一节所说的,分代收集理论设计的,区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》里对Java堆的进一步细致划分。
尤其到 G1 收集器的出现后,已经打破了固有的策略,往后,垃圾收集器技术的更新也会带来更多的策略,而不是分代。
因此我们从分水岭的前后来分别介绍。
内存分代策略:也就是根据对象存活的周期不同,将堆内存划分为几块,一般分为新生代、老年代、永久代。
4.1 为什么要分代?
很好理解,因为各种对象示例需要回收的频率是不一样的,分区操作能缩小操作的范围,结合上一节的垃圾回收策略,更好理解。
- 如果没有区域划分,频繁进行垃圾收集的时候,遍历范围都是所有的对象,会严重影响的 GC 效率。
- 有了内存分代,根据不同区域,采用不同的垃圾收集算法。
4.2 内存划分具体策略
新生代、老年代、永久代(如上一节所介绍的,永久代后来已经被取缔)。
4.2.1 新生代(Young)
新生代又分为了三块区域,他们的空间比例默认为 8:1:1。
- Eden(伊甸园,人类创建的地方),就是所有对象产生的地方;
- From,属于第一块 Survivor 区域;
- To,属于第二块 Survivor 区域。
这么个比例划分是因为新生代的垃圾回收算法是标记-复制算法,设置这个比例是为了充分利用内存空间。
新生对象在 Eden 区分配,除了大对象,大对象直接进入老年代。
大对象就是指需要大量连续内存的对象,就是很大的数组,或者很长的字符串。比大对象更糟糕的就是遇到一个朝生夕灭的大对象。
结合一般在这个区域采用标记-复制算法,看一看新生代的垃圾收集过程:
- 如果 Eden 区不够了,就会开始一次 Minor GC,将 Eden 里存活的复制到 From(Eden空了);
- 下次 Eden 区满了,再执行一次 Minor GC,将存活的对象复制到 To 中 (Eden空了),同时,将 From 中消亡的对象清理掉,将存活的对象也复制到 To 区,然后清空 From 区(此时 From空);
在 From 和 To 两个区域的这种切换,显然就是标记复制的算法,他们两个的空间也确实是 1 : 1。此后从 Eden 区满了后再往他们两个区域移动的时候就是交替进行。
注意事项:
- 当两个存活区切换了几次(HotSpot虚拟机默认15次)之后,仍然存活的对象,将被复制到老年代。实现方式,就是在不断的 Minor GC ,这个复制的过程会给对象计算年龄,年龄计数器是存储在对象头里的(关于虚拟机的对象头信息)。
- 除了年龄判断,hotspot 虚拟机还有动态对象年龄判定的策略,如果 survivor 空间相同年龄所有对象大小总和 >= Survivor 空间的一半,这部分对象都直接进入老年代。
所以可以总结出有 3 类对象都会进入老年代:1.大对象直接进;2.在Minor GC 存活15岁后进;3.相同年龄对象成为众数,一起进。
4.2.2 老年代(Old)
这里的对象GC 频率低。
4.2.3 永久代(Permanent)
正如前面所说,jdk8以前,很多人愿意把方法区称为永久代,本质上是因为当时的hotspot虚拟机选择把垃圾收集的设计扩展到了方法区,或者说使用永久代实现方法区,使得垃圾收集器能够管理这部分内存,其他虚拟机不存在这个概念。
到jdk8就完全放弃了,因为实现方法区的内容已经改为用本地内存的元空间。
这里其实我有一个疑问,逻辑上本来方法区是属于堆的一块特殊区域,现在改用本地直接内存来实现,那么在内存区域的划分上,是应该定义为直接内存的一块特殊区域?
反正说 jvm 的内存区域的时候迷迷糊糊的。
五、垃圾回收器
这里指的都是“经典”垃圾回收器,是因为目前的新技术实现的高性能低延迟收集器还处于实验状态。
所以记录一下时间:现在是2020.09.04,参考的书是基于 jdk11 的。
5.1 Serial 收集器(复制算法)
是新生代单线程收集器,标记和清理都是单线程,需要其它工作线程暂停,优点是简单高效。
这也是虚拟机在Client模式下运行的默认值,可以通过 -XX:+UseSerialGC 来强制指定。
5.2 Serial Old 收集器(标记-整理算法)
老年代单线程收集器,Serial收集器的老年代版本,需要其它工作线程暂停,简单高效。
5.3 ParNew 收集器(复制算法)
新生代收集器,实质上是 Serial 收集器的多线程版本,各种策略都和 Serial 收集器一样。除了支持多线程并行,没有别的优点,但是在 jdk7 之前,都会用他,原因和性能无关,原因是:只有他能和 CMS 配合工作。(之后有 G1 了,他就没这么高地位了)
5.4 Parallel Scavenge 收集器(复制算法)
新生代收集器,并行,表面上看起来的特性和 ParNew 一样。
但是他的特点是,关注点不在缩短线程停顿时间,而关注如何达到一个可控制的吞吐量,什么是吞吐量?
Parallel Scavenge+Serial Old 收集器组合回收垃圾(这也是在Server模式下的默认值)可用 -XX:+UseParallelGC 来强制指定,用 -XX:ParallelGCThreads=4 来指定线程数。
5.5 Parallel Old 收集器(标记-整理算法)
Parallel Scavenge 收集器的老年代版本,并行收集器。
Parallel Scavenge 和 Parallel Old 搭配,产生了一种“吞吐量”优先的收集器方案。
5.6 CMS(Concurrent Mark Sweep)收集器(标记-清除算法)
老年代收集器。从名字就可以看出来,是并发+标记清除。一些官方公开文档里害称之为Concurrent Low Pause Collector,并发低停顿收集器。
他的收集过程比较复杂,分为四步:
- 初始标记;(需要停顿用户线程,标记GC roots能直接关联到的对象,快)
- 并发标记;(从上一步关联到的对象遍历整个图,但是是并发运行的,不用停顿用户线程,慢)
- 重新标记;(修正上一个阶段可能因为用户继续操作又产生变动的部分,需要停顿用户线程,快)
- 并发清除。(并发执行,因为不需要整理移动存活对象)
最大的优点就是名字体现出来的:并发收集、低停顿。
缺点:
- 对处理器资源非常敏感,原因就是,虽然你是并发的,但是你本身相当于其他的线程,这是境地总吞吐量的(空间换时间嘛);
- 无法处理浮动垃圾。浮动垃圾就是说,他的四个步骤里,并发的两个步骤,用户线程都是在同时产生垃圾的,只能等到下一次才能处理。所以垃圾收集还需要有额外预留的空间,否则还会产生问题;
- 因为是标记清除算法,所以有空间碎片以及后续会产生的问题。
5.7 G1/Garbage First 收集器
这是一个里程碑式的成果。实验期完成后,正式商用,到jdk8后,官方称之为全功能垃圾收集器(Fullly-Featured Garbage Collector)。
jdk 9 后,G1 也替代了Parallel Scavenge 和 Parallel Old 搭配的组合,称为服务端模式下的默认收集器,CMS 直接沦落到了不推荐使用。
之前垃圾收集的目标都是基于分代的内存,要么在新生代工作、要么老年代、要么整个 java 堆。G1 则跳出了这个牢笼,可以面向堆内存的任何部分来组成回收集(Collection Set,简称 CSet),衡量标准不再是哪个分代,而是哪块垃圾最多我去哪快,这就是 G1 收集器的 Mixed GC 模式。
G1 把堆内存分为了不同的 Region ,这些 Region 大小相等,各自独立。这个划分不像以前遵循的那种固定比例,这样,每个 Region 都可能扮演以前的新生代的 Eden 空间、Survivor空间或者老年代空间,然后垃圾收集器采用不同的策略去收集。
缺点:比CMS有更高的内存占用,更高的额外执行负载。