JVM笔记

1. 内存管理

1. 运行时数据区

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁,主要包括以下几个部分:

1.1 程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码行号指示器。每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。程序计数器放在寄存器中。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

Java 源代码通过编译以后生成二进制字节码,JVM 根据程序计数器拿到下一条需要执行的指令,交给解释器即使成机器码,然后交给 CPU 执行。

1.2 虚拟机栈

虚拟机栈描述的是Java方法执行的线程内存模型每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表操作数栈动态连接方法出口等信息。

一个方法调用对应一个栈帧。每个线程只能有一个活动栈帧,即栈顶栈帧,当前正在执行的方法

Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

垃圾回收不涉及栈中垃圾对象回收,因为出栈就表示被回收了

栈内存不是越大越好,栈内存越大,可以承受更多次的方法递归调用,也就是栈深度增加,但是会减少系统最大线程数量

方法的局部变量并且变量(如果局部变量引用了对象)没有逃逸出方法,那么该变量就是线程安全的

1.2.1 线程运行诊断

1.2.1.1 CPU占用过多

  • 用 top 指令定位哪个进程对 CPU 的占用过高

  • ps H -eo pid,tid,%cpu | grep 进程id(用 ps 指令进一步定位是哪个j进程引起的 cpu 占用过高)

  • jstack 进程id(列出进程中所有 Java 线程)

    • 可以根据线程 id 找到有问题的线程,进一步定位到问题代码的源码行号

1.2.1.2 程序运行很长时间将没有结果

可能项目发生了死锁问题,此时可以运行 jsack 查看尾部信息确认是否有死锁,如果有的话,jstack 是会指出死锁发生的线程,发生的源码行号等信息的

1.3 本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。(HotSpot虚拟机直接将虚拟机栈和本地方法栈合二为一)

1.4 堆

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例

Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”。

Java堆中经常会出现“新生代”“老年代”“永久代”“Eden空间”“From Survivor空间”“To Survivor空间”等名词,这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》里对Java堆的进一步细致划分,将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存

Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常

1.4.1 堆内存诊断

1.4.1.1 jps 工具

查看当前系统有哪些 Java 进程

1.4.1.2 jmap 工具

jmap -heap 进程id,查看堆内存占用情况,可以详细查看 eden、from、to、old 的占用

1.4.1.3 jconsole 工具

图形界面的,多功能的监测工具,可以连续监测

1.4.1.4 垃圾回收后,内存占用仍旧很高

dump 堆转储内存占用,然后进行分析

# 将 JVM 的堆 dump 到指定文件,如果堆中对象较多,需要的时间会较长,子参数 format 只支持 b,即二进制格式
jmap -dump:format=b,file=FILE_WITH_PATH
​
# 如果 JVM 进程未响应命令,可以加上参数 -F 尝试
jmap -F -dump:format=b,file=FILE_WITH_PATH
​
# 可以只 dump 堆中的存活对象,加上 live 子参数,但使用 -F 时不支持 live
jmap -dump:live,format=b,file=FILE_WITH_PATH

1.5 方法区(永久代(堆)->源空间(本地内存))

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息常量静态变量即时编译器编译后的代码缓存等数据

在JDK 8以前,当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。因此许多Java程序员都习惯把方法区称呼为“永久代”

在JDK 6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的

运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。(符号引用转换为直接引用)

1.5.1 StringTable 特性

  • 常量池中的字符串仅是符号,第一次用到时才变为对象(懒惰)

  • 利用串池的机制,来避免重复创建字符串对象

  • 字符串变量拼接的原理是 StringBuilder(1.8)

  • 字符串常量拼接的原理是编译器优化

  • 可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池

    • 1.7 及以上版本,将字符串对象尝试放入串池,如果有则并不会放入,没有则放入,并且会将串池中的对象返回(调用 intern 方法的对象,此时不在堆中而是属于 StringTable 了)

    • 1.6 及以下版本,将字符串对象尝试放入串池,如果有则并不会放入,没有则会复制一份放入,并且会将串池中的对象返回(调用 intern 方法的对象永远不会离开堆,而是新复制的对象会属于 StringTable)

public static void main(String[] args) {
    String s1 = "a";                // StringTable ["a"]
    String s2 = "b";                // StringTable ["a", "b"]
    String s3 = s1 + s2;            // StringTable ["a", "b"],字符串变量拼接
    String s4 = "a" + "b";          // StringTable ["a", "b", "ab"],字符串常量拼接
    String s5 = "ab";               // StringTable ["a", "b", "ab"]
​
    System.out.println(s3 == s4);
    System.out.println(s3 == s5);
    System.out.println(s4 == s5);
​
    String s6 = "1";                // StringTable ["1"]
    String s7 = s6 + s6;            // StringTable ["1"]
    String s8 = s7.intern();        // StringTable ["1", "11"]
    String s9 = "11";
    System.out.println(s7 == s9);   // jdk7 及以上版本中为 true,其余版本为 false
    System.out.println(s8 == s9);
}
​
// 输出:
false
false
true
true
true

1.5.2 StringTable 位置

1.5.3 StringTable 性能调优

  • 调整 -XX:StringTableSize = 桶个数

  • 考虑将字符串对象是否入池

1.6 直接内存(堆外内存->NIO->Netty)

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。

本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存,大小以及处理器寻址空间的限制。

1.6.1 定义

  • 常见于 NIO 操作时,用于数据缓冲区

  • 分配回收成本较高,但读写性能高(Netty 中 ByteBuf 通过池化技术能稍微解决分配回收成本高的问题)

  • 不受 JVM 内存回收管理

1.6.2 分配和回收

  • 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法

  • ByteBuffer 的实现类内部,使用了 Cleaner(虚引用)来检测 ByteBuffer 对象,一旦 ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存

2. 对象详解

2.1 对象的创建

类型检查:指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过

分配内存指针碰撞(规整)空闲列表(不规整)。选择方式取决于堆是否规整,是否规整取决于垃圾收集器是否带有空间压缩整理能力

Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效,适用于client模式;

当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。

分配可能存在冲突,同步或者线程本地分配缓冲TLAB解决

同步使用CAS+失败重试

线程本地分配缓冲TLAB,每个线程在Java堆中预先分配一小块内存,哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

初始化零值:内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值(<cinit>()?)

对象头设置:Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息,是否启用偏向锁等,对象头会有不同的设置方式。

初始化:上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始——构造函数,即Class文件中的<init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好,new指令之后会接着执行<init>()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

2.2 对象在内存中的布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

2.2.1 对象头

对象头部分包括两类信息。第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启指针压缩)中分别为 4B 和 8B,官方称它为“Mark Word”,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。

对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。这部分数据的长度在 32 位和 64 的虚拟机(未开启指针压缩)中分别为 4B 和 8B。

指针压缩

从 JDK 1.6 update14 开始,64 bit JVM 正式支持了 -XX:+UseCompressedOops 这个可以压缩指针,起到节约内存占用的新参数。

如果 UseCompressedOops 是打开的,则以下对象的指针会被压缩:

所有对象的 klass 属性

所有对象指针实例的属性

所有对象指针数组的元素(objArray)

由此我们可以计算出对象头大小(开启指针压缩):

32位虚拟机对象头大小= Mark Word(4B)+ kclass(4B) = 8B

64位虚拟机对象头大小= Mark Word(8B)+ kclass(4B) = 12B

数组对象对象头除了 markword 和类型指针外,还有数组长度,数组长度 8B。

2.2.2 实例数据

实例数据部分是对象真正存储的有效信息,HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前

2.2.3 对齐填充

对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

2.3 对象的访问定位

使用句柄直接指针两种

reference类型(32位机器上reference类型占用 4B,64位机器上reference类型占用 8B)在Java虚拟机规范里只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java堆中的对象的具体位置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:句柄直接指针

使用句柄访问,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,使用句柄方式最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。其结构如图:

使用直接指针访问,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,最大的好处就是速度更快,如图所示:

HotSpot而言,他使用的是直接指针访问方式进行对象访问,但从整个软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见。

3. 垃圾收集与内存分配

垃圾收集需要完成的三件事情:

  • 哪些内存需要回收?(witch)

  • 什么时候回收?(when)

  • 如何回收?(how)

Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。

3.1 对象死活

判断对象是否存活,主要有两种方法:引用计数、可达性分析

引用计数:引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。(循环引用问题)

可达性分析:通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。(三色标记)

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。

  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。

  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象

  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

  • 所有被同步锁(synchronized关键字)持有的对象

  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

3.2 再谈引用

在JDK 1.2版之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象。

在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

  • 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

  • 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用

  • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。

  • 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知

  • 软和弱引用,引用对象被 GC 时,如果软或者弱引用创建时关联了引用队列,则会被被放入引用队列等待回收,但是虚引用则是一定会被放入引用队列中被回收

3.3 生存还是死亡

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程

  • 对象不可达

  • 对象是否有必要执行finalize()方法,假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

3.4 回收方法区

方法区垃圾收集的“性价比”通常也是比较低的:在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。

方法区的垃圾收集主要回收两部分内容:废弃的常量不再使用的类型

常量是否“废弃”:判断相对简单,当前系统又没有任何一个字符串对象的值是它,如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

类型不再被使用:判断条件比较苛刻,需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收

  • 加载该类的类加载器已经被回收

  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

3.5 垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。下面所有算法均为追踪式算法。

3.5.1 分代收集理论

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,它建立在两个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。

  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”、“Major GC”、“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”、“标记-清除算法”、“标记-整理算法”等针对性的垃圾收集算法。

假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:

  • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

要解决该问题,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。

  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。

  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。

  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

分代垃圾回收流程

  • 对象首先分配在 eden(新生代)区

  • eden 区空间不足时,触发 minor gc,eden 和 from 存活的对象使用 copy 复制到 to 中,存活的对象年龄加 1 并且交换 from 和 to,那么此时 to 就清空了,等待下一次 minor gc 对象 copy 到 to

  • minor gc 会触发 STW(stop the world),暂停用户线程,等待垃圾回收结束,用户线程才恢复运行

  • 当对象寿命超过阈值时,会晋升至老年代,最大寿命默认是 15

  • 当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么会触发 full gc,full gc 的 STW 的时间更长

VM 参数

含义参数
堆初始大小-Xms
堆最大大小-Xmx 或 -XX:MaxHeapSize=size
新生代大小-Xmn 或(-XX:NewSize=size + -XX:MaxNewSize=size)
幸存区比例(动态)-XX:InitialSuvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy
幸存区比例-XX:SurvivorRatio=ratio
晋升阈值-XX:MaxTenuingThreshold=threshold
晋升详情-XX:PrintTenuringDistribution
GC 详情-XX:+PrintGCDetails -verbose:gc
Full GC 前 Minor GC-XX:+ScavengeBeforeFullGC

3.5.2 标记-清除算法

标记-清除算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

标记-清除算法是最基础的收集算法,后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。它的主要缺点有两个:

  • 第一个是执行效率不稳定

如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。

  • 内存空间的碎片化问题

标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

3.5.3 标记-复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

新生代中的对象有98%熬不过第一轮收集,因此该算法适合对新生代进行回收

新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1。也就是说新生代实际内存其实只有 9/10。

如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的

3.5.4 标记-整理算法

“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,“标记-整理”算法的示意图如图所示:

对于对象内存分配更快,因为可以使用指针碰撞方式进行内存分配,但是整理内存本身会有较大开销(对象移动以及引用的维护),整体而言,标记-整理可提升吞吐量,但是延迟较高

HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的

让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

3.6 HotSpot实现细节

3.6.1 根节点枚举

就是找出所有的GC Roots,固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,但是查找过程要做到高效并非一件容易的事情,现在Java应用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,若要逐个检查以这里为起源的引用肯定得消耗不少时间。

目前主流Java虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的,使用一组称为 OopMap 的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。

[Verified Entry Point]
0x026eb730: mov    %eax,-0x8000(%esp)
…………
;; ImplicitNullCheckStub slow case
0x026eb7a9: call   0x026e83e0       ; OopMap{ebx=Oop [16]=Oop off=142} // 记录OopMap的地方
                                    ; *caload
                                    ; - java.lang.String::hashCode@48 (line 1489)
                                    ;   {runtime_call}
    0x026eb7ae: push   $0x83c5c18   ;   {external_word}
    0x026eb7b3: call   0x026eb7b8
    0x026eb7b8: pusha
    0x026eb7b9: call   0x0822bec0   ;   {runtime_call}
    0x026eb7be: hlt

3.6.2 安全点

HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置被称为安全点

用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷

方法调用循环跳转异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

抢先式中断:抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。

主动式中断:当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。

HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。

0x01b6d627: call   0x01b2b210          ; OopMap{[60]=Oop off=460}
                                       ; *invokeinterface size
                                       ; - Client1::main@113 (line 23)
                                       ;   {virtual_call}
    0x01b6d62c: nop                    ; OopMap{[60]=Oop off=461}
                                       ; *if_icmplt
                                       ; - Client1::main@118 (line 23)
    0x01b6d62d: test   %eax,0x160100   ;   {poll} // 轮询指令 test
    0x01b6d633: mov    0x50(%esp),%esi
    0x01b6d637: cmp    %eax,%e

3.6.3 安全区域

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。

3.6.4 记忆集和卡表(卡表是一种实现记忆集的方式)

记忆集是一种用于记录从非收集区域指向收集区域的指针集合抽象数据结构。解决分代收集第三个问题的方式(对象跨代引用)

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。

  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。

  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。

卡表最简单的形式可以只是一个字节数组,字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”。一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

3.6.5 写屏障

有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻,如何变脏,通过把维护卡表的动作放到每一个赋值操作之中。

HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。

3.6.6 并发的可达性分析(三色标记)

增量更新和原始快照两个办法解决并发的可达性分析问题,但是不是太懂

按照我的理解,在并发标记时,根据三色标记原则对对象进行标记,标记结束后有黑、白、灰三种颜色,然后在再标记阶段,遍历灰色对象引用链,完成最后标记

并发标记中最危险的是发生这种错误,把本来应该是黑色的对象误标为白色,对象被错误回收,这是致命的错误,要造成这种错误,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;

  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此我们要解决该问题,只需要破坏其中一个条件即可,而破环这两个条件的方法就是增量更新(破坏条件一)和原始快照(破坏条件二)

增量更新:当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

原始快照:当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。

CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。

3.7 经典垃圾收集器

如果说收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。HotSpot垃圾收集器如下所示:

3.7.1 Serial收集器

单线程工作的收集器,在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束

它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以,Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

3.7.2 ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本

ParNew收集器除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处,但是除了Serial收集器外,目前只有它能与CMS收集器配合工作。

3.7.3 Parallel Scavenge收集器

Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量,停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。

3.7.4 Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。

3.7.5 Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。Parallel Old收集器的工作过程如图所示:

3.7.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

  • 初始标记(CMS initial mark)

  • 并发标记(CMS concurrent mark)

  • 重新标记(CMS remark)(增量更新)

  • 并发清除(CMS concurrent sweep)

初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快(可以通过 OopMap 来查找 GC Roots 对象);并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

  • -XX:+UseConcMarkSweepGC(CMS 老年代)~-XX:+UseParNewGC(ParNew 新生代)~SerialOld(CMS失败时使用 SerialOld 进行兜底,因为 SerialOld 会整理内存)

  • -XX:+CMSScanvengeBeforeRemark(重新标记前先进行 minor gc 回收新生代)

  • -XX:CMSInitiatingOccupancyFraction=percent(执行 CMS 的老年代内存占比,作用是预留空间给浮动垃圾使用)

  • -XX:ParallelGCThreads=n(并行线程数)~-XX:ConcGCThreads=threads(并发线程数,建议并行线程数=并发线程数 * 4)

3.7.7 G1收集器(Garbage First)

G1是一款主要面向服务端应用的垃圾收集器,被Oracle官方称为“全功能的垃圾收集器”。同时注重吞吐量和低延迟,默认的暂停目标是 200ms。G1从整体来看是基于“标记-整理”算法实现的收集器。两个区域之间是复制算法。

JDK 9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器。

可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域,并且在超大堆内存时,表现更为优异(小堆和 CMS 没有太大差异)

G1收集器的运作过程大致可划分为以下四个步骤:

  • 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。

  • 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。

  • 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。

  • 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望。

G1 中区域有 eden、survivor、old、huge,其中 huge 为巨型对象区

  • 一个对象大于 region 的一半时,称之为巨型对象

  • G1 不会对巨型对象进行拷贝

  • 回收时被优先考虑

  • G1 会跟踪处理老年代所有 incoming 引用,这样老年代 incoming 引用为 0 的巨型对象就可以在新生代垃圾回收时被处理掉

3.8 垃圾收集器的选型

从以下三个因素进行分析:

  • 应用程序的主要关注点是什么?如果是数据分析、科学计算类的任务,目标是能尽快算出结果,那吞吐量就是主要关注点;如果是SLA应用,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点;而如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的。

  • 运行应用的基础设施如何?譬如硬件规格,要涉及的系统架构是x86-32/64、SPARC还是ARM/Aarch64;处理器的数量多少,分配内存的大小;选择的操作系统是Linux、Solaris还是Windows等。

  • 使用JDK的发行商是什么?版本号是多少?

推荐的选择方法如下:

  • 如果你有充足的预算但没有太多调优经验,那么一套带商业技术支持的专有硬件或者软件解决方案是不错的选择,Azul公司以前主推的Vega系统和现在主推的Zing VM是这方面的代表,这样你就可以使用传说中的C4收集器了。

  • 如果你虽然没有足够预算去使用商业解决方案,但能够掌控软硬件型号,使用较新的版本,同时又特别注重延迟,那ZGC很值得尝试。

  • 如果你对还处于实验状态的收集器的稳定性有所顾虑,或者应用必须运行在Win-dows操作系统下,那ZGC就无缘了,试试Shenandoah吧。

  • 如果你接手的是遗留系统,软硬件基础设施和JDK版本都比较落后,那就根据内存规模衡量一下,对于大概4GB到6GB以下的堆内存,CMS一般能处理得比较好,而对于更大的堆内存,可重点考察一下G1

3.9 内存分配与回收策略

Java技术体系的自动内存管理,最根本的目标是自动化地解决两个问题:自动给对象分配内存以及自动回收分配给对象的内存

新生对象通常会分配在新生代中,少数情况下(例如对象大小超过一定阈值)也可能会直接分配在老年代。

3.9.1 对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,新生代总可用空间为Eden区+1个Survivor区的总容量

3.9.2 大对象直接进入老年代

在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。

3.9.3 长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。

3.9.4 动态对象年龄判定

如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。

3.9.5 空间分配担保(作用于minor gc)

在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。

4. 虚拟机性能监控、故障处理工具

4.1 基础故障处理工具

4.1.1 jps:虚拟机进程状况工具

可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID。

4.1.2 jstat:虚拟机统计信息监视工具

用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据,在没有GUI图形界面、只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的常用工具。

4.1.3 jinfo:Java配置信息工具

jinfo(Configuration Info for Java)的作用是实时查看和调整虚拟机各项参数.

4.1.4 jmap:Java内存映像工具

令用于生成堆转储快照(一般称为heapdump或dump文件)。

-XX:+HeapDumpOnOutOfMemoryError参数,可以让虚拟机在内存溢出异常出现之后自动生成堆转储快照文件

-XX:+HeapDumpOnCtrlBreak参数则可以使用[Ctrl]+[Break]键让虚拟机生成堆转储快照文件,又或者在Linux系统下通过Kill-3命令发送进程退出信号“恐吓”一下虚拟机,也能顺利拿到堆转储快照。

jmap的作用并不仅仅是为了获取堆转储快照,它还可以查询finalize执行队列、Java堆和方法区的详细信息,如空间使用率、当前用的是哪种收集器等。

4.1.5 jhat:虚拟机堆转储快照分析工具

与jmap搭配使用,来分析jmap生成的堆转储快照,一般很少使用

4.1.6 jstack:Java堆栈跟踪工具

用于生成虚拟机当前时刻的线程快照,线程出现停顿时通过jstack来查看各个线程的调用堆栈,就可以获知没有响应的线程到底在后台做些什么事情,或者等待着什么资源。

5. 类文件结构

Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集、符号表以及若干其他辅助信息

5.1 Class 类文件结构

任何一个Class文件都对应着唯一的一个类或接口的定义信息,但是反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)。

Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8个字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8个字节进行存储。

Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“

无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时候称这一系列连续的某一类型的数据为某一类型的“集合”.

public class DemoTest extends Super implements Serializable {
​
    private static final String name = "foo";
    private final int age = 18;
    private List<String> hobbies = new ArrayList<>();
​
    public static void main(String[] args) {
        System.out.println(name);
        int a = 10;
        int b = 2;
        System.out.println(a / b);
        DemoTest demoTest = new DemoTest();
        demoTest.printHobbies();
        demoTest.hobbies.clear();
        System.out.println(demoTest.getAge(8));
        System.out.println(demoTest.age);
    }
​
    private void printHobbies() {
        for (String s : hobbies) {
            System.out.println(s);
        }
    }
​
    private int getAge(int age) {
        return this.age + age;
    }
}
Classfile /C:/Users/802612523/IdeaProjects/netty-study-demo/bytebuffer-demo/target/classes/com/gcl/nio/test/DemoTest.class
  Last modified 2022-7-12; size 1581 bytes
  MD5 checksum 4b09be0452bf290fe1533c125d8159a1
  Compiled from "DemoTest.java"
public class com.gcl.nio.test.DemoTest extends com.gcl.nio.test.Super implements java.io.Serializable
  minor version: 0
  major version: 56
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #20.#54        // com/gcl/nio/test/Super."<init>":()V
   #2 = Fieldref           #7.#55         // com/gcl/nio/test/DemoTest.age:I
   #3 = Class              #56            // java/util/ArrayList
   #4 = Methodref          #3.#54         // java/util/ArrayList."<init>":()V
   #5 = Fieldref           #7.#57         // com/gcl/nio/test/DemoTest.hobbies:Ljava/util/List;
   #6 = Fieldref           #58.#59        // java/lang/System.out:Ljava/io/PrintStream;
   #7 = Class              #60            // com/gcl/nio/test/DemoTest
   #8 = String             #61            // foo
   #9 = Methodref          #62.#63        // java/io/PrintStream.println:(Ljava/lang/String;)V
  #10 = Methodref          #62.#64        // java/io/PrintStream.println:(I)V
  #11 = Methodref          #7.#54         // com/gcl/nio/test/DemoTest."<init>":()V
  #12 = Methodref          #7.#65         // com/gcl/nio/test/DemoTest.printHobbies:()V
  #13 = InterfaceMethodref #66.#67        // java/util/List.clear:()V
  #14 = Methodref          #7.#68         // com/gcl/nio/test/DemoTest.getAge:(I)I
  #15 = Methodref          #69.#70        // java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
  #16 = InterfaceMethodref #66.#71        // java/util/List.iterator:()Ljava/util/Iterator;
  #17 = InterfaceMethodref #49.#72        // java/util/Iterator.hasNext:()Z
  #18 = InterfaceMethodref #49.#73        // java/util/Iterator.next:()Ljava/lang/Object;
  #19 = Class              #74            // java/lang/String
  #20 = Class              #75            // com/gcl/nio/test/Super
  #21 = Class              #76            // java/io/Serializable
  #22 = Utf8               name
  #23 = Utf8               Ljava/lang/String;
  #24 = Utf8               ConstantValue
  #25 = Utf8               age
  #26 = Utf8               I
  #27 = Integer            18
  #28 = Utf8               hobbies
  #29 = Utf8               Ljava/util/List;
  #30 = Utf8               Signature
  #31 = Utf8               Ljava/util/List<Ljava/lang/String;>;
  #32 = Utf8               <init>
  #33 = Utf8               ()V
  #34 = Utf8               Code
  #35 = Utf8               LineNumberTable
  #36 = Utf8               LocalVariableTable
  #37 = Utf8               this
  #38 = Utf8               Lcom/gcl/nio/test/DemoTest;
  #39 = Utf8               main
  #40 = Utf8               ([Ljava/lang/String;)V
  #41 = Utf8               args
  #42 = Utf8               [Ljava/lang/String;
  #43 = Utf8               a
  #44 = Utf8               b
  #45 = Utf8               demoTest
  #46 = Utf8               printHobbies
  #47 = Utf8               s
  #48 = Utf8               StackMapTable
  #49 = Class              #77            // java/util/Iterator
  #50 = Utf8               getAge
  #51 = Utf8               (I)I
  #52 = Utf8               SourceFile
  #53 = Utf8               DemoTest.java
  #54 = NameAndType        #32:#33        // "<init>":()V
  #55 = NameAndType        #25:#26        // age:I
  #56 = Utf8               java/util/ArrayList
  #57 = NameAndType        #28:#29        // hobbies:Ljava/util/List;
  #58 = Class              #78            // java/lang/System
  #59 = NameAndType        #79:#80        // out:Ljava/io/PrintStream;
  #60 = Utf8               com/gcl/nio/test/DemoTest
  #61 = Utf8               foo
  #62 = Class              #81            // java/io/PrintStream
  #63 = NameAndType        #82:#83        // println:(Ljava/lang/String;)V
  #64 = NameAndType        #82:#84        // println:(I)V
  #65 = NameAndType        #46:#33        // printHobbies:()V
  #66 = Class              #85            // java/util/List
  #67 = NameAndType        #86:#33        // clear:()V
  #68 = NameAndType        #50:#51        // getAge:(I)I
  #69 = Class              #87            // java/util/Objects
  #70 = NameAndType        #88:#89        // requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
  #71 = NameAndType        #90:#91        // iterator:()Ljava/util/Iterator;
  #72 = NameAndType        #92:#93        // hasNext:()Z
  #73 = NameAndType        #94:#95        // next:()Ljava/lang/Object;
  #74 = Utf8               java/lang/String
  #75 = Utf8               com/gcl/nio/test/Super
  #76 = Utf8               java/io/Serializable
  #77 = Utf8               java/util/Iterator
  #78 = Utf8               java/lang/System
  #79 = Utf8               out
  #80 = Utf8               Ljava/io/PrintStream;
  #81 = Utf8               java/io/PrintStream
  #82 = Utf8               println
  #83 = Utf8               (Ljava/lang/String;)V
  #84 = Utf8               (I)V
  #85 = Utf8               java/util/List
  #86 = Utf8               clear
  #87 = Utf8               java/util/Objects
  #88 = Utf8               requireNonNull
  #89 = Utf8               (Ljava/lang/Object;)Ljava/lang/Object;
  #90 = Utf8               iterator
  #91 = Utf8               ()Ljava/util/Iterator;
  #92 = Utf8               hasNext
  #93 = Utf8               ()Z
  #94 = Utf8               next
  #95 = Utf8               ()Ljava/lang/Object;
{
  public com.gcl.nio.test.DemoTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method com/gcl/nio/test/Super."<init>":()V
         4: aload_0
         5: bipush        18
         7: putfield      #2                  // Field age:I
        10: aload_0
        11: new           #3                  // class java/util/ArrayList
        14: dup
        15: invokespecial #4                  // Method java/util/ArrayList."<init>":()V
        18: putfield      #5                  // Field hobbies:Ljava/util/List;
        21: return
      LineNumberTable:
        line 7: 0
        line 10: 4
        line 11: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      22     0  this   Lcom/gcl/nio/test/DemoTest;
​
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=4, args_size=1
         0: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #8                  // String foo
         5: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: bipush        10
        10: istore_1
        11: iconst_2
        12: istore_2
        13: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
        16: iload_1
        17: iload_2
        18: idiv
        19: invokevirtual #10                 // Method java/io/PrintStream.println:(I)V
        22: new           #7                  // class com/gcl/nio/test/DemoTest
        25: dup
        26: invokespecial #11                 // Method "<init>":()V
        29: astore_3
        30: aload_3
        31: invokevirtual #12                 // Method printHobbies:()V
        34: aload_3
        35: getfield      #5                  // Field hobbies:Ljava/util/List;
        38: invokeinterface #13,  1           // InterfaceMethod java/util/List.clear:()V
        43: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
        46: aload_3
        47: bipush        8
        49: invokevirtual #14                 // Method getAge:(I)I
        52: invokevirtual #10                 // Method java/io/PrintStream.println:(I)V
        55: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
        58: aload_3
        59: invokestatic  #15                 // Method java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
        62: pop
        63: bipush        18
        65: invokevirtual #10                 // Method java/io/PrintStream.println:(I)V
        68: return
      LineNumberTable:
        line 14: 0
        line 15: 8
        line 16: 11
        line 17: 13
        line 18: 22
        line 19: 30
        line 20: 34
        line 21: 43
        line 22: 55
        line 23: 68
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      69     0  args   [Ljava/lang/String;
           11      58     1     a   I
           13      56     2     b   I
           30      39     3 demoTest   Lcom/gcl/nio/test/DemoTest;
}
SourceFile: "DemoTest.java"

5.1.1 魔数与Class文件版本(0xCAFEBABE(咖啡宝贝?))

每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。使用魔数而不是扩展名来进行识别主要是基于安全考虑,因为文件扩展名可以随意改动。

紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。

高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,因为《Java虚拟机规范》在Class文件校验部分明确要求了即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。

5.1.2 常量池

紧接着主、次版本号之后的是常量池入口,常量池可以比喻为Class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一,另外,它还是在Class文件中第一个出现的表类型数据项目。

在Class文件格式规范制定之时,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于,如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为0来表示。Class文件结构中只有常量池的容量计数是从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)

Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中

常量池中每一项常量都是一个表,截至JDK 13,常量表中分别有17种不同类型的常量。这17类表都有一个共同的特点,表结构起始的第一位是个u1类型的标志位(tag,取值见表6-3中标志列),代表着当前常量属于哪种常量类型。

5.1.3 访问标志

在常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final;等等。

5.1.4 类索引、父类索引与接口索引集合

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定该类型的继承关系。

类索引用于确定这个类的全限定名

父类索引用于确定这个类的父类的全限定名

接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements关键字(如果这个Class文件表示的是一个接口,则应当是extends关键字)后的接口顺序从左到右排列在接口索引集合中。

5.1.5 字段表集合

字段表(field_info)用于描述接口或者类中声明的变量。Java语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括方法内部声明的局部变量

描述符的作用是用来描述字段的数据类型方法的参数列表(包括数量、类型以及顺序)和返回值

用描述符来描述方法时,按照先参数列表、后返回值的顺序描述。

字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能出现原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字段。

在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于Class文件格式来讲,只要两个字段的描述符不是完全相同,那字段重名就是合法的。

5.1.6 方法表集合

Class文件存储格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样,依次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项,这些数据项目的含义也与字段表中的非常类似,仅在访问标志和属性表集合的可选项中有所区别。

方法里的Java代码,经过Javac编译器编译成字节码指令之后,存放在方法属性表集合中一个名为“Code”的属性里面。

与字段表集合相对应地,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。

在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名。特征签名是指一个方法中各个参数在常量池中的字段符号引用的集合,也正是因为返回值不会包含在特征签名之中,所以Java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。

5.1.7 属性表集合

只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识的属性。

5.2 字节码指令

Java虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中

6. 虚拟机类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。在Java语言里面,类型的加载连接初始化过程都是在程序运行期间完成的。

Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的

6.1 类加载时机

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如图所示:

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。请注意,这里笔者写的是按部就班地“开始”,而不是按部就班地“进行”或按部就班地“完成”,强调这点是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

对于初始化阶段,有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

1)遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:

  • 使用new关键字实例化对象的时候。

  • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。

  • 调用一个类型的静态方法的时候。

2)使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。 3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。 4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。 5)当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。(这一块儿不太懂) 6)当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

通过数组定义来引用类,不会触发此类的初始化

常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

接口的加载过程与类加载过程稍有不同,编译器仍然会为接口生成“<clinit>()”类构造器,用于初始化接口中所定义的成员变量。接口与类真正有所区别的是前面讲述的六种“有且仅有”需要触发初始化场景中的第三种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

6.2 类加载过程(加载、验证、准备、解析、初始化)

6.2.1 加载

在加载阶段,Java虚拟机需要完成以下三件事情:

1)通过一个类的全限定名来获取定义此类的二进制字节流。 2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。 3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

加载阶段既可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的findClass()(一般重写该方法,因为loadClass方法中含有双亲委派的逻辑,如果不希望打破双亲委派,就不要重写loadClass方法)或loadClass()方法),实现根据自己的想法来赋予应用程序获取运行代码的动态性。

数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。一个数组类创建过程遵循以下规则:

  • 如果数组的组件类型是引用类型,递归采用加载过程去加载这个组件类型,数组将被标识在加载该组件类型的类加载器的类名称空间上

  • 如果数组的组件类型不是引用类型,Java虚拟机将会把数组标记为与引导类加载器关联。

  • 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类和接口访问到。

类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。

加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。

6.2.2 验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从代码量和耗费的执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。

验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证元数据验证字节码验证符号引用验证

6.2.3 准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域。

准备阶段进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。假设一个类变量的定义为:

public static int value = 123;

那变量value在准备阶段过后的初始值为0而不是123,因为这时尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作要到类的初始化阶段才会被执行。

如果类字段的字段属性表中存在ConstantValue属性(常量),那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值,假设上面类变量value的定义修改为:

public static final int value = 123;

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据Con-stantValue的设置将value赋值为123。

6.2.4 解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程:

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。

  • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

解析动作主要针对类或接口字段类方法接口方法方法类型方法句柄调用点限定符这7类符号引用进行

6.2.5 初始化(<cinit>())

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。

初始化阶段就是执行类构造器<clinit>()方法的过程。<clinit>()并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物。

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。

  • <clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()方法的类型肯定是java.lang.Object。

  • 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

  • <clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

  • Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行完毕<clinit>()方法。

<clinit>()方法是在类加载过程中执行的,而<init>()是在对象实例化执行的,所以<clinit>()一定比<init>()先执行。所以整个顺序就是<clinit>() + <init>()

  1. 父类静态变量初始化

  2. 父类静态语句块

  3. 子类静态变量初始化

  4. 子类静态语句块

  5. 父类变量初始化

  6. 父类语句块

  7. 父类构造函数

  8. 子类变量初始化

  9. 子类语句块

  10. 子类构造函数

总结:<clinit>() 完成后 Class 对象可用,<init>() 完成后实例对象可用

6.3 类加载器

Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”。

6.3.1 类与类加载器

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

6.3.2 双亲委派模型

自JDK 1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构

三层类加载器:启动类加载器、扩展类加载器、应用类加载器

  • 启动类加载器(Bootstrap Class Loader):负责加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。

  • 扩展类加载器(Extension Class Loader):负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。在JDK 9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。

  • 应用程序类加载器(Application Class Loader):有些场合中也称它为“系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

双亲委派模型

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

双亲委派加载流程为:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。

7. 虚拟机字节码执行引擎

7.1 运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。

每一个栈帧都包括了局部变量表操作数栈动态连接方法返回地址和一些额外的附加信息

7.1.1 局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。

局部变量表的容量以变量槽(Variable Slot)为最小单位,一个变量槽可以存放一个32位以内的数据类型,Java语言中明确的64位的数据类型只有long和double两种,把long和double数据类型分割存储的做法与“long和double的非原子性协定”中允许把一次long和double数据类型读写分割为两次32位读写的做法有些类似,由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。

对于两个相邻的共同存放一个64位数据的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个。

当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。

如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用,这种为节省存储空间所使用的方法,可能会带来一定的副作用,在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为。

7.1.2 操作数栈

Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈,操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。

7.1.3 动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。

Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。(用于多态场景?)

7.1.4 方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法:正常调用完成异常调用完成,其中一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。

无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。

方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值

方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

7.1.5 附加信息

《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在讨论概念时,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

7.2 方法调用

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。

Class文件的编译过程中不包含传统程序语言编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。(多态)

7.2.1 解析

所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。

在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要有静态方法私有方法两大类,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。(没有重写的可能)

调用不同类型的方法,字节码指令集里设计了不同的指令。在Java虚拟机支持以下5条方法调用字节码指令,分别是:

  • invokestatic。用于调用静态方法。

  • invokespecial。用于调用实例构造器<init>()方法、私有方法父类中的方法

  • invokevirtual。用于调用所有的虚方法。

  • invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。

  • invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法私有方法实例构造器父类方法4种,再加上被final修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方法就被称为“虚方法”(Virtual Method)。

解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。

7.2.2 分派

分派可能是静态的也可能是动态的,按照分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况。

7.2.2.1 静态分派(解析阶段就能将符号引用转换为直接引用)

静态分派发生在编译阶段,编译时就能决定方法分派的结果,最典型应用表现就是方法重载。解释静态分配需要引入两个概念:静态类型(外观类型)和实际类型(运行时类型),下面举例:

package org.fenixsoft.polymorphic;
​
/**
 * 方法静态分派演示
 */
public class StaticDispatch {
​
    static abstract class Human {
    }
​
    static class Man extends Human {
    }
​
    static class Woman extends Human {
    }
​
    public void sayHello(Human guy) {
        System.out.println("hello,guy!");
    }
​
    public void sayHello(Man guy) {
        System.out.println("hello,gentleman!");
    }
​
    public void sayHello(Woman guy) {
        System.out.println("hello,lady!");
    }
​
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }
}

打印结果:

hello,guy!
hello,guy!
Human man = new Man();

Human是变量man的静态类型,后面的Man是变量man的实际类型,编译时,方法重载调用,会找变量静态类型为Human的重载方法进行调用,因此上面调用结果是这样的

7.2.2.2 动态分派

Java语言里动态分派的实现过程,它与Java语言多态性的另外一个重要体现——重写(Override)有着很密切的关联。重写又与多态有关系。

动态分派方法不根据静态类型进行分派,而是根据实际类型进行分派,其实这就是我们常说的多态

根据《Java虚拟机规范》,invokevirtual指令的运行时解析过程大致分为以下几步:

  • 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。

  • 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。

  • 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。

  • 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

7.2.2.3 单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

Java语言的静态分派属于多分派类型。Java语言的动态分派属于单分派类型。

Java语言是一门静态多分派动态单分派的语言。

8. 高效并发

8.1 Java内存模型与线程

8.1.1 Java内存模型

8.1.1.1 主内存与工作内存

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段静态字段构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。

不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图所示:

8.1.1.2 内存见交互操作

Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的:

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。

除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。

  • 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。

  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。

  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。

  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。

  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。

  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

8.1.1.3 对于volatile型变量的特殊规则(可见性与禁止指令重排)

当一个变量被定义成volatile之后,它将具备两项特性:

第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。volatile变量在各个线程的工作内存中是不存在一致性问题的,但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的。

第二个语义是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

假定T表示一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、store和write操作时需要满足如下规则:

  • 只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load动作。线程T对变量V的use动作可以认为是和线程T对变量V的load、read动作相关联的,必须连续且一起出现。 这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改。

  • 只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store动作;并且,只有当线程T对变量V执行的后一个动作是store的时候,线程T才能对变量V执行assign动作。线程T对变量V的assign动作可以认为是和线程T对变量V的store、write动作相关联的,必须连续且一起出现。 这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改。

  • 假定动作A是线程T对变量V实施的use或assign动作,假定动作F是和动作A相关联的load或store动作,假定动作P是和动作F相应的对变量V的read或write动作;与此类似,假定动作B是线程T对变量W实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的对变量W的read或write动作。如果A先于B,那么P先于Q。 这条规则要求volatile修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。(assign 变量操作之前的操作不会重排到操作之后)

8.1.1.4 针对long和double型变量的特殊规则

允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”

8.1.1.5 原子性、可见性与有序性

Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的。

8.1.1.5.1 原子性

由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子性协定)

8.1.1.5.2 可见性

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。(通过读写屏障来实现

除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得的。而final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见final字段的值。

8.1.1.5.3 有序性

Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

8.2 Java与线程

8.2.1 线程的实现

每个已经调用过start()方法且还未结束的java.lang.Thread类的实例就代表着一个线程。我们注意到Thread类与大部分的Java类库API有着显著差别,它的所有关键方法都被声明为Native。在Java类库API中,一个Native方法往往就意味着这个方法没有使用或无法使用平台无关的手段来实现。

实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)。三种实现示意图如下:

内核线程

用户线程

混合线程

Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型。

线程模型只对线程的并发规模和操作成本产生影响,对Java程序的编码和运行过程来说,这些差异都是完全透明的。

8.2.2 Java线程调度

线程调度方式有两种:协同式和抢占式

如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。

如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。

Java使用的线程调度方式就是抢占式调度

线程优先级并不是一项稳定的调节手段,很显然因为主流虚拟机上的Java线程是被映射到系统的原生线程上来实现的,所以线程调度最终还是由操作系统说了算。

8.2.3 状态转换

Java语言定义了6种线程状态,在任意一个时间点中,一个线程只能有且只有其中的一种状态,并且可以通过特定的方法在不同状态之间转换。这6种状态分别是:

  • 新建(New):创建后尚未启动的线程处于这种状态。

  • 运行(Runnable):包括操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。

  • 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态:

    • 没有设置Timeout参数的Object::wait()方法;

    • 没有设置Timeout参数的Thread::join()方法;

    • LockSupport::park()方法。

  • 限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:

    • Thread::sleep()方法;

    • 设置了Timeout参数的Object::wait()方法;

    • 设置了Timeout参数的Thread::join()方法;

    • LockSupport::parkNanos()方法;

    • LockSupport::parkUntil()方法。

  • 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。

  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行。

上述6种状态在遇到特定事件发生的时候将会互相转换,它们的转换关系如图所示:

9. 线程安全与锁优化

9.1 线程安全

当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

9.1.1 Java中的线程安全

Java语言中各种操作共享的数据分为以下五类:不可变绝对线程安全相对线程安全线程兼容线程对立

9.1.1.1 不可变

在Java语言里面,不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。

保证对象行为不影响自己状态的途径有很多种,最简单的一种就是把对象里面带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的。

9.1.1.2 绝对线程安全

绝对的线程安全要求一个类达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”,在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。

9.1.1.3 相对线程安全

相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

9.1.1.4 线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。

9.1.1.5 线程对立

线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。由于Java语言天生就支持多线程的特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。

9.1.2 线程安全的实现方法

9.1.2.1 互斥同步(阻塞同步,悲观锁)

互斥同步(Mutual Exclusion & Synchronization)是一种最常见也是最主要的并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候)线程使用。而互斥是实现同步的一种手段临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。因此在“互斥同步”这四个字里面,互斥是因,同步是果互斥是方法,同步是目的

在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构(Block Structured)的同步语法。synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。

如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。

如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

  • 被synchronized修饰的同步块对同一条线程来说是可重入的。

  • 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。

synchronized是Java语言中一个重量级的操作。

自JDK 5起(实现了JSR 166),Java类库中新提供了java.util.concurrent包(下文称J.U.C包),其中的java.util.concurrent.locks.Lock接口便成了Java的另一种全新的互斥同步手段。基于Lock接口,用户能够以非块结构(Non-Block Structured)来实现互斥同步

重入锁(ReentrantLock)是Lock接口最常见的一种实现,顾名思义,它与synchronized一样是可重入的。

ReentrantLock与synchronized相比增加了一些高级功能,主要有以下三项:等待可中断可实现公平锁锁可以绑定多个条件

  • 等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助。

  • 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。不过一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量。

  • 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁;而ReentrantLock则无须这样做,多次调用newCondition()方法即可。

ReentrantLock在功能上是synchronized的超集,在性能上又至少不会弱于synchronized,但是于以下理由,推荐在synchronized与ReentrantLock都可满足需要时优先使用synchronized:

  • synchronized是在Java语法层面的同步,足够清晰,也足够简单。

  • Lock应该确保在finally块中释放锁,否则一旦受同步保护的代码块中抛出异常,则有可能永远不会释放持有的锁。

  • Java虚拟机更容易针对synchronized来进行优化,因为Java虚拟机可以在线程和对象的元数据中记录synchronized中锁的相关信息,而使用J.U.C中的Lock的话,Java虚拟机是很难得知具体哪些锁对象是由特定线程锁持有的。

9.1.2.2 非阻塞同步

基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronization),使用这种措施的代码也常被称为无锁(Lock-Free)编程。

硬件保证某些从语义上看起来需要多次操作的行为可以只通过一条处理器指令就能完成,这类指令常用的有:

  • 测试并设置(Test-and-Set);

  • 获取并增加(Fetch-and-Increment);

  • 交换(Swap);

  • 比较并交换(Compare-and-Swap,下文称CAS);

  • 加载链接/条件储存(Load-Linked/Store-Conditional,下文称LL/SC)。

Java里最终暴露出来的是CAS操作(Compare and swap),CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示),CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。

但是CAS有个漏洞:ABA操作(在改变A的时候,如果A先变为B,然后再变回A,那么CAS是无法感知的)

9.1.2.3 无同步方案

保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系。同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的。

可重入代码和线程本地存储

9.2 锁优化

适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率。

9.2.1 自旋锁与自适应自旋

虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。现在绝大多数的个人电脑和服务器都是多路(核)处理器系统,如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁

如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费,自旋次数的默认值时十次。

自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。

9.2.2 锁消除(去除无竞争的锁)

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。

9.2.3 锁粗化(锁的范围扩大)

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。 大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

9.2.4 轻量级锁

轻量级锁是JDK 6时加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量

要理解轻量级锁,以及后面会讲到的偏向锁的原理和运作过程,必须要对HotSpot虚拟机对象的内存布局(尤其是对象头部分)有所了解。对象头信息是与对象自身定义的数据无关的额外存储成本,Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。对象几种状态Mark Word信息如下:

在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word),这时候线程堆栈与对象头的状态如图所示:

虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如图:

 

如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。

如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

9.2.5 偏向锁

偏向锁目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。

偏向锁的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。

根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去执行。偏向锁、轻量级锁的状态转化及对象Mark Word的关系如图所示:

作为绝大多数对象哈希码来源的Object::hashCode()方法,返回的是对象的一致性哈希码(Identity Hash Code),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁

10. 优化

10.1 泛型

泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。

Java选择的泛型实现方式叫作“类型擦除式泛型”(Type Erasure Generics),而C#选择的泛型实现方式是“具现化式泛型”(Reified Generics)。

C#里面泛型无论在程序源码里面、编译后的中间语言表示(Intermediate Language,这时候泛型是一个占位符)里面,抑或是运行期的CLR里面都是切实存在的,List<int>List<string>就是两个不同的类型,它们由系统在运行期生成,有着自己独立的虚方法表和类型数据。

Java语言中的泛型则不同,它只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type)了,并且在相应的地方插入了强制转型代码,因此对于运行期的Java语言来说,ArrayList<int>ArrayList<String>其实是同一个类型

Java中不支持的泛型用法:

public class TypeErasureGenerics<E> {
    public void doSomething(Object item) {
        if (item instanceof E) {   // 不合法,无法对泛型进行实例判断
            ...
        }
        E newItem = new E();       // 不合法,无法使用泛型创建对象
        E[] itemArray = new E[10]; // 不合法,无法使用泛型创建数组
    }
}

Java的类型擦除式泛型无论在使用效果上还是运行效率上,几乎是全面落后于C#的具现化式泛型,而它的唯一优势是在于实现这种泛型的影响范围上:擦除式泛型的实现几乎只需要在Javac编译器上做出改进即可,不需要改动字节码、不需要改动Java虚拟机,也保证了以前没有使用泛型的库可以直接运行在Java 5.0之上。

以ArrayList为例来介绍Java泛型的类型擦除具体是如何实现的。直接把已有的类型泛型化。要让所有需要泛型化的已有类型,譬如ArrayList,原地泛型化后变成了ArrayList<T>,而且保证以前直接用ArrayList的代码在泛型新版本里必须还能继续用这同一个容器,这就必须让所有泛型化的实例类型,譬如ArrayList<Integer>ArrayList<String>这些全部自动成为ArrayList的子类型才能可以,否则类型转换就是不安全的。由此就引出了“裸类型”(Raw Type)的概念,裸类型应被视为所有该类型泛型化实例的共同父类型

接下来的问题是该如何实现裸类型。这里又有了两种选择:一种是在运行期由Java虚拟机来自动地、真实地构造出ArrayList<Integer>这样的类型,并且自动实现从ArrayList<Integer>派生自ArrayList的继承关系来满足裸类型的定义;另外一种是索性简单粗暴地直接在编译时把ArrayList<Integer>还原回ArrayList,只在元素访问、修改时自动插入一些强制类型转换和检查指令,这样看起来也是能满足需要,这两个选择的最终结果大家已经都知道了。

类型擦除带来的缺陷有如下几种:

首先,使用擦除法实现泛型直接导致了对原始类型(Primitive Types)数据的支持又成了新的麻烦,一旦把泛型信息擦除后,到要插入强制转型代码的地方就没办法往下做了,因为不支持int、long与Object之间的强制转型。(这就是泛型不支持原始类型的原因)

第二,运行期无法取到泛型类型信息,会让一些代码变得相当啰嗦。

最后,笔者认为通过擦除法来实现泛型,还丧失了一些面向对象思想应有的优雅,带来了一些模棱两可的模糊状况。

由于Java泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有的基础产生影响并带来新的需求,如在泛型类中如何获取传入的参数化类型等。所以JCP组织对《Java虚拟机规范》做出了相应的修改,引入了诸如Signature、LocalVariableTypeTable等新的属性用于解决伴随泛型而来的参数类型的识别问题,Signature是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息。修改后的虚拟机规范要求所有能识别49.0以上版本的Class文件的虚拟机都要能正确地识别Signature参数。

从Signature属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们在编码时能通过反射手段取得参数化类型的根本依据。

10.2 自动装箱、拆箱

public static void main(String[] args) {
    Integer a = 1;
    Integer b = 2;
    Integer c = 3;
    Integer d = 3;
    Integer e = 321;
    Integer f = 321;
    Long g = 3L;
    System.out.println(c == d);
    System.out.println(e == f);
    System.out.println(c == (a + b));
    System.out.println(c.equals(a + b));
    System.out.println(g == (a + b));
    System.out.println(g.equals(a + b));
}

结果:

true
false
true
true
true
false

解析:当 "=="运算符的两个操作数都是包装器类型的引用,则是比较指向的是否是同一个对象,而如果其中有一个操作数是表达式(即包含算术运算)则比较的是数值(即会触发自动拆箱的过程)。另外,对于包装器类型,equals方法并不会进行类型转换

在通过valueOf方法创建Integer对象的时候,如果数值在[-128,127]之间,便返回指向IntegerCache.cache中已经存在的对象的引用;否则创建一个新的Integer对象。(享元模式)

第一个为true,第二个为false是因为再进行==比较时,比较引用,而对于[-128,127]之间的数,会有常量池进行缓存,也就是说a和b指向同一个引用,e和f则是不同引用,因此一个为true,一个为false

三为true是由于等号右边为表达式,引发自动拆箱,然后计算结果在[-128,127]之间,使用常量池中的缓存,因此为true,四的话一样,使用equals方法比较值,相等为true

至于五和六,右边为表达式,自动拆箱,==会触发自动装箱操作,因此结果为true,equals不会触发类型转换,因此Long和Integer比较,结果为false

11. GC 调优

11.1 调优领域

  • 内存

  • 锁竞争

  • cpu 占用

  • io

11.2 确定目标

  • 低延迟还是高吞吐量,选择何使的回收器

  • CMS,G1,ZGC(低延迟)

  • ParallelGC(高吞吐)

java -XX:PrintFlagsFinal -version | findstr "GC"

11.3 最快的 GC 是不发生 GC

  • 查看 FullGC 前后的内存占用,考虑下面几个问题

    • 数据是不是太多

    • 数据表示是否太臃肿

      • 对象图

      • 对象大小

    • 是否存在内存泄漏

11.4 新生代调优

新生代特点

  • 所有的 new 操作的内存分配非常廉价

    • TLAB thread-local allocation buffer

  • 死亡对象的回收价值是零

  • 大部分对象用过即死

  • Minor GC 的时间远远低于 Full GC

新生代调优一般方式是增大新生代大小,这样可以减少 Minor GC 次数,但是新生代大小并不是越大越好,因为新生代越大,老年代就越小,这样 Full GC 就会更为频繁,而 Full GC 比 Minor GC 慢了一个数量级以上,因此推荐的新生代的大小为整堆大小的 25%~50%

因为新生代是标记-复制算法,时间大部分花费在复制上,标记所花费时间较少,因此新生代越大,虽然标记时间以及复制时间会增加,但是整体而言 Minor GC 次数减少,整体 Minor GC 的时间是减少的,因此新生代调优要尽可能增大新生代大小

新生代能容纳所有【并发量*(请求-响应)】的数据

比如一次请求响应1KB,并发量10000,那么新生代大小设置为100MB 以上,这样可以确保并发请求过程中,所有对象都可以被放入新生代

幸存区大到能保留【当前活跃对象+需要晋升对象】

幸存区如果过小,那么 JVM 会动态调整今生老年代对象的生命周期阈值,因为幸存区放不下了,必须要晋升到老年代存放,因此可能造成一些对象在年龄较小时就因为幸存区内存不足导致晋升老年代,这样的话该对象必须要等到老年代发生 GC 才能被回收,变相延长对象存活时间

晋升阈值需要配置的当,让长时间存活对象尽快晋升,减少 Minor GC 时这些对象的拷贝消耗

  • -XX:MaxTenuringThreshold=threshold

  • -XX:+PrintTenuringDistribution

11.5 老年代调优

以 CMS 为例

  • CMS 的老年代内存越大越好

  • 先尝试不做调优,如果没有 Full GC,那么已经……,否则现尝试新生代调优

  • 观察发生 Fill GC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3

    • -XX:CMSInitiatingOccupancyFranction=percent

11.6 案例

  • 案例一 Full GC 和 Minor GC 频繁

    • 新生代空间较小,导致 Minor GC 频繁,并且会降低对象晋升老年代阈值,老年代对象增多,导致 Full GC 次数增加,因此尝试扩大新生代大小

  • 案例二 请求高峰发生 Full GC,单次暂停时间特别长(CMS)

    • 经查,CMS 回收过程中重新标记花费时间较长,可以打开重新标记前回收新生代的开关,这样新生代在重新标记前被回收一次,可以大大减少新生代存活对象数量,进而减少重新标记所花费的时间

  • 案例三 老年代充裕情况下,发生 Full GC(1.7)

    • 经查,是永久代导致 Full GC,1.7 及以前版本永久代会导致 Full GC,1.8 及以后版本为 meta space,使用的是系统内存,一般很少由于 meta space 导致 Full GC,除非认为限定 meta space 空间大小,因此由于永久代导致的频繁 Full GC,可以尝试增大永久代大小或者升级 JDK 版本解决该问题

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值