JVM
JVM各区域划分
- 共享:堆、方法区。
- 私有:虚拟机栈、本地方法栈、程序计数器。
就 hotSpot 虚拟机而言,在 JDK1.8 之前,方法区的实现是永久代,JDK1.8 之后是元空间。
程序计数器(Program Counter Register)
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处 理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“ 线程私有”的内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址; 如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。
此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。
Java虚拟机栈(Java Virtual Machine Stack)
Java虚拟机栈也是线程私有的,它的生命周期与线程相同。
虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都 会同步创建一个栈帧 (Stack Frame) 用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:
- 如果线程请求的栈深度大于虚 拟机所允许的深度,将抛出StackOverflowError异常;
- 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
局部变量表存放了编译期可知的各种Java虚拟机基本数据类型 (boolean、byte、char、short、int、 float 、long、double)、 对象引用 (reference类型) 和 returnAddress 类型(指向了一条字节码指令的地址)。
这些数据类型在局部变量表中的存储空间以局部变量槽 (Slot) 来表示,其中 64 位长度的 long 和 double 类型的数据会占用两个变量槽,其余的数据类型只占用一个。
本地方法栈(Native Method Stacks)
本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地 (Native) 方法服务。
堆
对于Java应用程序来说,Java 堆 (Java Heap) 是虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。 此内存区域的唯一目的就是存放对象实例,Java 世界里 “几乎” 所有的对象实例都在这里分配内存。
就 JVM 规范来说,只有堆的定义,并没有各种分区分代定义。这些都是各种虚拟机根据不同的垃圾回收策略而设计的。在 G1 垃圾回收器出现前,JVM 堆几乎都是分代设计的,把整个堆分为 新生代、老年代、永久代、survivor from、survivor to 等,而 G1 等垃圾回收器的出现,就代表着垃圾回收并不一定完全是分代设计了。
有人说 TLAB (Thread Local Allocation Buffer) 的存在,是不是说堆也有部分是属于线程私有的,线程私有的分配缓冲区,是用来提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将 Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存。
方法区
方法区 (Method Area) 与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与 Java 堆区分开来。
永久代是 HotSpot 虚拟机独有的概念,当时的 HotSpot 虚拟机设计团队选择把垃圾收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得 HotSpot 的垃圾收集器能够像管理 Java 堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是对于其他虚拟机实现,譬如 BEA JRockit、IBM J9等来说,是不存在永久代的概念的。 JDK8 开始 HotSpot 也移除了永久代。
当 Oracle 收购 BEA 获得了 JRockit的所有权后,准备把JRockit中的优秀功能,譬如 Java Mission Control 管理工具,移植到 HotSpot 虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。考虑到HotSpot未来的发展,在 JDK6 的时候 HotSpot 开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了 JDK7 的 HotSpot,已经把原本放在永久代的 字符串常量池、静态变量等移出,而到了 JDK 8,终于完全废弃了永久代的概念,改用与 JRockit、J9 一样在本地内存中实现的元空间 (Metaspace) 来代替,把 JDK7 中永久代还剩余的内容(主要是类型信息)全部移到元空间中。
运行时常量池(方法区中)
运行时常量池(Runtime Constant Pool)是方法区的一部分。
Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
Java虚拟机对于 Class 文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行。
但对于运行时常量池,《Java虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域,不过一般来说,除了保存 Class 文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。 运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存 时会抛出 OutOfMemoryError 异常。
字符串常量池(堆中)
字符串常量池在堆中,是 JVM 为了提高 String 对象效率提供的一片区域,主要是为了避免字符串的重复创建,字符串常量池放入堆中也是为了提供垃圾回收效率。
JDK 1.7 之前静态变量和字符串常量池在方法区,为了提高回收效率,在 JDK1.7 中,移到了堆中。
静态变量(堆中)
JDK 1.7 之前静态变量和字符串常量池在方法区,为了提高回收效率,在 JDK1.7 中,移到了堆中。
运行时常量池、方法区、字符串常量池这些都是不随虚拟机实现而改变的逻辑概念,是公共且抽象的,Metaspace、Heap 是与具体某种虚拟机实现相关的物理概念,是私有且具体的。
对象
当Java虚拟机遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定。
内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视 角看来,对象创建才刚刚开始——构造函数,即Class文件中的 <init>() 方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。 new指令之后会接着执行<init>()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
对象创建过程
通过上边描述,对象创建过程为:
- 类加载检查
- 分配内存(指针碰撞(内存规整),空闲列表(内存不规整))
- 初始化0值
- 设置对象头
- 执行 init 方法
对象结构
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分: 对象头 (Header)、实例数据 (Instance Data) 和对齐填充 (Padding)。
对象头
HotSpot虚拟机对象的对象头部分包括两类信息。第一类是用于存储对象自身的运行时数据,如哈希码 (HashCode) 、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,被称为 Mark Word。
MarkWord 不同状态的内容如下:
存储内容 | 标志位 | 锁状态 |
---|---|---|
对象的哈希码或GC标记 | 01 | 无锁 |
指向栈中锁记录的指针 | 00 | 轻量级锁 |
指向重量级锁记录的指针 | 10 | 重量级锁 |
空 | 11 | GC 标记 |
偏向线程ID,偏向时间戳,对象分代年龄 | 01 | 可偏向 |
对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。
如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据。
实例数据
实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
HotSpot虚拟机默认的分配顺序为
longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),
从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。
如果HotSpot虚拟机的 +XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空 隙之中,以节省出一点点空间。
对齐填充
对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。
由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
对象的访问定位
由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种。
使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就本书讨论的主要虚拟机 HotSpot 而言,它主要使用直接指针进行对象访问。
对象的引用
四个字,强软弱虚。
- 强引用是最传统的
引用
的定义,是指在程序代码之中普遍存在的引用赋值,即类似 “Object obj = new Object() ” 这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。 - 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。
- 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。
- 虚引用也称为 “幽灵引用” 或者 “幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了 PhantomReference 类来实现虚引用。
垃圾回收
垃圾回收判定
引用计数
引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。
但是,在 Java 领域,至少主流的 Java 虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。
可达性分析
当前主流的商用程序语言 (Java、C#,Lisp) 的内存管理子系统,都是通过可达性分析 (Reachability Analysis) 算法来判定对象是否存活的。
这个算法的基本思路就是通过一系列称为 GC Roots
的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为 引用链
(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。
GC Roots
在Java技术体系里面,固定可作为 GC Roots 的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的
参数
、局部变量
、临时变量
等。 - 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中JNI(即通常所说的 Native 方法)引用的对象。
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError) 等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象。
- 反映Java虚拟机内部情况的 JMXBean、JVM TI 中注册的回调、本地代码缓存等。
除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象
临时性
地加入,共同构成完整 GC Roots 集合。
譬如分代收集和局部回收 (Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时,必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入 GC Roots 集合中去,才能保证可达性分析的正确性。
方法区回收
《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK11 时期的 ZGC 收集器就不支持类卸载),方法区垃圾收集的性价比
通常也是比较低的,在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收 70% 至 99% 的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。
方法区的垃圾收集主要回收两部分内容: 废弃的常量和不再使用的类型。
- 常量回收
假如一个字符串java
曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是java
,换句话说,已经没有任何字符串对象引用常量池中的java
常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个java
常量就将会被系统清理出常量池。 - 类型回收
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾回收算法
垃圾收集算法
当前商业虚拟机的垃圾收集器,大多数都遵循了 “分代收集”(Generational Collection)
的理论进 行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则。
它建立在两个分代假说之上:
- 弱分代假说(Weak Generational Hypothesis): 绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis): 熬过越多次垃圾收集过程的对象就越难以消亡。
标记-清除算法
最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,在1960年由Lisp之父 John McCarthy 所提出。如它的名字一样,算法分为 “标记”
和 “清除”
两个阶段: 首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。
它的主要缺点有两个:
- 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
- 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
标记-复制算法
标记-复制算法常被简称为复制算法。为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年 Fenichel 提出了一种称为 “半区复制”(Semispace Copying)
的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代。
HotSpot虚拟机默认 Eden 和 Survivor 的大小比例是
8∶1
,也即每次新生代中可用内存空间为整个新生代容量的 90% (Eden 的 80% 加上一个Survivor的 10% ),只有一个 Survivor 空间,即 10% 的新生代是会被“浪费”的。
分配担保: 如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。
标记-整理算法
针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了另外一种有针对性的 “标记-整理”(Mark-Compact)算法,其中的标记过程仍然与 “标记-清除” 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作, 而且这种对象移动操作必须全程暂停用户应用程序才能进行,设计者形象地描述为
“Stop The World”
。
但如果跟 标记-清除 算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。 譬如通过 “分区空闲分配链表” 来解决内存分配问题。
内存的访问是用户程序最频繁的操作,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。
垃圾回收器
Serial/Serial Old 收集器
一个单线程工作的收集器,但它的 “单线程”
的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
Serial收集器 同时支持新生代和老年代的垃圾收集,分布才用
复制算法
和标记整理
算法。
ParNew收集器
ParNew 收集器实质上是 Serial 收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外, 其余的行为控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
ParNew 收集器在单核心处理器的环境中绝对不会有比Serial收集器更好的效果。
Parallel Scavenge/Parallel Old 收集器
Parallel Scavenge 收集器也是一款新生代收集器,它同样是基于 标记-复制 算法实现的收集器,也是能够并行收集的多线程收集器,Parallel Scavenge 的诸多特性从表面上看和ParNew非常相似,Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于 标记-整理 算法实现。这个收集器是直到 JDK6 时才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于相当尴尬的状态,原因是如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old(PSMarkSweep) 收集器以外别无选择,其他表现良好的老年代收集器,如 CMS无法与它配合工作。
如果服务对响应时间不敏感,可以使用该收集器。 Parallel Scavenge 收集器也经常被称作
“吞吐量优先收集器”
。
前几个垃圾回收器的执行过程
他们的垃圾回收都比较简单,用户线程 -> STW -> 执行垃圾回收 -> STW -> 执行垃圾回收。
当然不同的分区,执行不同的算法。
CMS收集器
CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。目前大部分的 Java 都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS 收集器就非常符合这类应用的需求。
它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤:
- 初始标记(CMS initial mark)
初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快; (STW) - 并发标记(CMS concurrent mark)
并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行; - 重新标记(CMS remark)
重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短; (STW) - 并发清除(CMS concurrent sweep)
并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
其中初始标记、重新标记这两个步骤仍然需要 “Stop The World”。
-XX:+UseCMSCompactAtFullCollection 默认是开启的,用于在 CMS 收集器不得不进行 Full GC 时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,是无法并发的,停顿时间又会变长。
-XX:CM SFullGCsBeforeCompaction 这个参数的作用是要求 CMS 收集器在执行过若干次不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。
Garbage First收集器
G1有单独的文章进行了详细的介绍: 传送门
ZGC
ZGC 也有单独的文章进行了详细的介绍: 传送门
垃圾回收策略
对象优先在Eden分配
HotSpot虚拟机提供了 -XX:+PrintGCDetails
这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。
大对象直接进入老年代
-XX:PretenureSizeThreshold
被设置为3MB(就是3145728,这个参数不能与 -Xmx 之类的参数一样直接写 3MB),因此超过 3MB 的对象都会直接在老年代进行分配。
-XX:PretenureSizeThreshold
参数只对 Serial 和 ParNew 两款新生代收集器有效,HotSpot 的其他新生代收集器,如 Parallel Scavenge 并不支持这个参数。如果必须使用此参数进行调优,可考虑 ParNew 加 CMS 的收集器组合。
长期存活的对象将进入老年代
虚拟机给每个对象定义了一个对象年龄 (Age) 计数器,存储在对象头中。对象通常在 Eden 区里诞生,如果经过第一次 MinorGC 后仍然存活,并且能被 Survivor 容纳的话,该对象会被移动到 Survivor 空间中,并且将其对象年龄设为1岁。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。
动态对象年龄判定
为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到 -XX:MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 -XX:MaxTenuringThreshold 中要求的年龄。
空间分配担保
- 在发生 MinorGC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 MinorGC 可以确保是安全的。
- 如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败 (Handle Promotion Failure);
- 如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 MinorGC,尽管这次 MinorGC 是有风险的;
- 如果小于,或者 -XX:HandlePromotionFailure 设置不允许空间担保,那这时就要改为进行一次 Full GC。
新生代使用复制收集算法,但为了内存利用率, 只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况,最极端的情况就是内存回收后新生代中所有对象都存活,需要老年代进行分配担保,把 Survivor 无法容纳的对象直接送入老年代,这与生活中贷款担保类似。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,但一共有多少对象会在这次回收中活下来在实际完成内存回收之前是无法明确知道的,所以只能取之前每一次回收晋升到老年代对象容量的平均大小作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间。
虽然担保失败时绕的圈子是最大的,但通常情况下都还是会将 -XX:HandlePromotionFailure 开关打开,避免 Full GC 过于频繁。
在JDK 6 Update 24之后,-XX:HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,虽然源码中还定义了 -XX:HandlePromotionFailure 参数,但是在实际虚拟机中已经不会再使用它。JDK6 Update24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。
性能监控,故障处理
JVM 基础工具
VisualVM
Arthas
类结构
类结构
类型 | 名称 | 数量 |
---|---|---|
u4 | Magic | 1 |
u2 | Minor Version | 1 |
u2 | Major Version | 1 |
u2 | Constant Pool Count | 1 |
cp_info | Constant Pool | N-1 |
u2 | Access Flags | 1 |
u2 | This Class | 1 |
u2 | Super Class | 1 |
u2 | Interfaces Count | 1 |
u2 | Interfaces | N |
u2 | Fields Count | 1 |
field_info | Fields | N |
u2 | Methods Count | 1 |
methed_info | Methods | N |
u2 | Attributes Count | 1 |
attribute_info | Attributes | N |
- Magic 魔数,是固定的
0xCAFEBABE
。 - 版本号,对应不同的 JDK 编译版本,兼容性就是通过版本来控制的。
- 常量池
紧接着主、次版本号之后的是常量池入口,常量池可以比喻为 Class 文件里的资源仓库,它是 Class 文件结构中与其他项目关联最多的数据,通常也是占用 Class 文件空间最大的数据项目之一,另外,它还是在Class文件中第一个出现的表类型数据项目。
由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项 u2 类型的数据,代表常量池容量计数值(constant_pool_count)。与 Java 中语言习惯不同,这个容量计数是从 1 而不是 0 开始的。
常量池中主要存放两大类常量: 字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于 Java 语言层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,主要包括下面几类常量:
- 被模块导出或者开放的包(Package)
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
- 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
- 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant) Java 代码在进行 Javac 编译的时候,并不像 C 和 C++ 那样有 “连接” 这一步骤,而是在虚拟机加载 Class 文件的时候进行动态连接。也就是说,在 Class 文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
访问标记
常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括,这个 Class 是类还是接口, 是否定义为 public 类型, 是否定义为 abstract 类型,如果是类的话,是否被声明为 final 等等。类索引、父类索引与接口索引集合
类索引、父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引用两个 u2 类型的索引值表示,它们各自指向一个类型为 CONSTANT_Class_info 的类描述符常量,通过 CONSTANT_Class_info 类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info 类型的常量中的全限定名字符串。字段表集合
字段表 (field_info) 用于描述接口或者类中声明的变量。Java 语言中的字段(Field)
包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。
字段可以包括的修饰符有作用域(public、private、protected 修饰符)、是实例变量还是类变量(static 修饰符)、可变性(final)、并发可见性(volatile)、可否被序列化(transient)、字段数据类型(基本类型、对象、数组)、字段名称。
上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫做什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。方法表集合
Class 文件存储格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样,依次包括访问标志 (access_flags)、名称索引(name_index)、描述符索引 (descriptor_index)、属性表集合(attributes)几项。属性表集合
todo
字节码指令
todo
类加载
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
Java 语言,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略让 Java 语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为Java应用提供了极高的扩展性和灵活性。
比如编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类,再比如,用户可以通过 Java 预置的或自定义类加载器,让某个本地的应用程序在运行时从网络或其他地方上加载一个二进制流作为其程序代码的一部分。
类加载、初始化时机
关于在什么情况下需要开始类加载过程的第一个阶段
加载
,《Java虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。对于初始化阶段,《Java虚拟机规范》 则是严格规定了有且只有六种情况必须立即对类进行 “初始化” (而加载、验证、准备自然需要在此之前开始)
- 遇到
new
、getstatic
、putstatic
或invokestatic
这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。
能够生成这四条指令的典型Java代码场景有:
- 使用new关键字实例化对象的时候。
- 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
- 调用一个类型的静态方法的时候。
使用java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需 要先触发其初始化。
当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
当使用 JDK7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
- 遇到
类加载过程/类的生命周期
真个类的什么周期:加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载
类加载过程指的是:加载 -> 验证 -> 准备 -> 解析 -> 初始化
链接包含:验证 -> 准备 -> 解析
加载
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
验证
这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。准备
准备阶段是正式为类中定义的变量(即 静态变量,被static
修饰的变量)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都应当在方法区
中进行分配。
但 方法区 是一个逻辑上的区域,在 JDK7 及之前,HotSpot 使用永久代来实现方法区时,类的变量,即静态变量自然是在永久代中。
而在 JDK8 及之后,类变量则会随着 Class 对象一起存放在 Java 堆中,这时候类变量在方法区
就完全是一种对逻辑概念的表述了,实际类的变量被创建在堆中。解析
解析阶段将类的符号引用替换为直接引用的过程,就是得到类引用的类,方法,字段在内存中的实际位置。初始化
类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由 Java 虚拟机来控制。直到初始化阶段,Java 虚拟机才真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。
进行准备阶段时,变量被赋过了系统要求的初始零值,在初始化阶段,则会根据程序编码制定的主观计划去初始化类变量和其他资源。
初始化时机在 类加载、初始化时机 已详细介绍过。
类加载器
类加载器主要用来加载类,对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性,即同一个 Class 文件,只要加载它们的类加载器不同,那这两个类就必定不相等。
JDK内置的加载器
- BootstrapClassLoader 启动类加载器,加载JDK核心代码,C++实现,无父加载器
- extensionClassLoader 加载JDK扩展包Class
- AppClassLoader 应用类加载器,加载我们写的程序,ClassPath 下的所有类
什么是双亲委派模型
类加载总是交给父加载器去加载,如果父加载器加载不了,再使用本加载器。
先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败, 抛出 ClassNotFoundException 异常的话,才调用自己的 findClass() 方法尝试进行加载。
- 双亲委派模型的好处
双亲委派模型可以避免类的重复加载,例如我定义了一个 Object 类,双亲委派模型可以保证加载的是java.lang.Object 而不是我们自己定义的 Object - 自定义类加载器
如果我们不想打破模型重写 findClass 即可,如果想打破模型,就要重写 loadClass 类了。 - 打破双亲委派模型
可以实现加载的类互相隔离。
SPI 的加载,SPI实现类需要使用 启动类加载器,但是代码又不在 启动类加载器的范围呢,可以使用线程上下文加载器,让启动类加载器借助子类加载器加载。
Tomcat 的类加载优先自己加载,自己加载不了再让父加载器加载,来保证多个war的多个应用隔离。
打破双亲委派模型
双亲委派模型自身是有缺陷的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被 称为基础,是因为它们总是作为被用户代码继承、调用的,但是针对 JDBC 这种 SPI 设计,即基础类型又要调用回用户的代码,双亲委派模型就无能为力了。
为了解决这个困境,Java 的设计团队引入了线程上下文类加载器 (Thread Context ClassLoader)。 这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
有了线程上下文类加载器,程序就可以做一些“舞弊”的事情了。例如数据库驱动使用这个线程上下文类加载器去加载所需的 SPI 服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打破了双亲委派模型,Java 中涉及 SPI 的加载基本上都采用这种方式来完成。
栈结构
todo
编译优化
todo
高效并发&线程&锁
详细请参考并发这篇文章:并发
JVM 常用参数
-Xms4g -Xmx4g -Xmn2g -Xss1024K
-XX:ParallelGCThreads=4
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC
-XX:+UseCMSCompactAtFullCollection
-XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly
如果需要调查 GC,加如下参数。
- 基本 GC 参数
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintHeapAtGC
- 如果需要更详细的日子
# 软弱虚引用回收打印
-XX:+PrintReferenceGC
# 输出自适应大小策略的调整信息,通常用于调优堆的大小
-XX:+PrintAdaptiveSizePolicy
# 在对象从年轻代晋升到老年代失败时打印日志
-XX:+PrintPromotionFailure
# 在每次 GC 后打印对象的晋升年龄分布。
-XX:+PrintTenuringDistribution
# 在每次 GC 后打印字符串表的统计信息
-XX:+PrintStringTableStatistics
# 打印由于 GC 造成的应用程序停顿时间
-XX:+PrintGCApplicationStoppedTime
# 打印应用程序并发运行时间和每次 GC 事件之间的间隔
-XX:+PrintGCApplicationConcurrentTime
# 启用安全点统计信息的输出
-XX:+PrintSafepointStatistics
# 打印安全点统计信息的输出次数
-XX:PrintSafepointStatisticsCount=1
CMS 专属参数
# CMS GC 开始时的统计信息。
-XX:+PrintCMSInitiationStatistics
# 打印 CMS GC 的详细统计信息,包括每个阶段的时间消耗。
-XX:+PrintCMSStatistics
# 在 CMS 垃圾回收的每个阶段打印日志。
-XX:+PrintCMSInitiation
# 打印 CMS 并发阶段的详细日志。
-XX:+PrintCMSConcurrentPhases
# 在 CMS 垃圾回收的最终标记阶段打印详细日志。
-XX:+PrintCMSFinalRemark
# 在 CMS 垃圾回收的预清理阶段打印详细日志。
-XX:+PrintCMSPrecleaning
# 打印 CMS 触发的 Full GC 日志。
-XX:+PrintCMSFullGCs
# 在 CMS Full GC 完成后,打印类直方图。
-XX:+PrintClassHistogramAfterFullGC
# 在 CMS GC 的每个阶段打印类直方图。
-XX:+PrintCMSClassHistogram
# 在 CMS 重新标记后,打印线程堆栈跟踪。
-XX:+PrintCMSRemarPostThreadStackTrace
- GC 文件相关日志
# 开启 GC 日志文件滚动
-XX:+UseGCLogFileRotation
# GC 文件最大数量
-XX:NumberOfGCLogFiles=10
# GC 文件大小
-XX:GCLogFileSize=500M
# GC 日志文件地址
-Xloggc:/home/work/gc.log
-Xms 最小堆
-Xmx 最大堆
-Xmn 新生代
-XX:MetaspaceSize 误区,它并不是元空间初始大小,元空间大小初始值是 20.8M,是触发GC的值
-XX:MaxMetaspaceSize(元空间最大值)