JVM知识

第一部分 基础篇

名词解释:

**GC:**垃圾收集器

**Minor GC:**新生代GC,指发生在新生代的垃圾收集动作,所有的Minor GC都会触发全世界(所有用户线程)的暂停(stop-the-world),停止应用程序的线程,不过这个过程非常短暂。

**Major GC/Full GC:**老年代GC,指发生在老年代的GC。

一、跨平台的真相:Java虚拟机来做中介

1、理解虚拟机的原理

​ 所谓虚拟机,就是一台虚拟计算机。它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机程序虚拟机

​ 大名鼎鼎的Visual BOX、VMware就属于系统虚拟机,它们完全是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台。**程序虚拟机的典型代表就是Java虚拟机,它专门为执行单个计算机程序而设计,**在Java虚拟机中执行的指令我们称之为Java字节码指令。无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机所提供的资源中。

​ 图1.1显示了同一个Java程序(Java字节码集合),通过Java虚拟机运行于各大主流系统平台,该程序以虚拟机作为中介,实现了跨平台。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K2T3WF5H-1625121076899)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210611170818297.png)]

2、Java语言规范

Java语言规范是用来描述Java语言的,它定义了Java语言的语言特性,比如Java的语法、词法、支持的数据类型、变量类型、数据类型转换的约定、数组、异常等内容。Java语言规范的目的是告诉开发人员“Java代码是如何编写的”。

二、Java虚拟机的基本结构

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Rcv6IJIc-1625121076972)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210611222137104.png)]

类加载子系统负责从文件系统中或网络中加载Class信息,加载的类信息存在一块被称为方法区的内存空间中。除了类信息之外,方法区还可能存放运行时常量池信息,包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)。详见 第四节 Java类加载过程

垃圾回收系统是Java虚拟机的重要组成部分,垃圾回收器可以对方法区、Java堆和直接内存进行回收。其中Java堆是垃圾收集器的工作重点。和C/C++不同,Java中所有的对象的释放都是隐式的。也就是说,Java中没有类似free()或者delete()这样的函数释放指定的内存区域。对于不再使用的对象,垃圾回收系统会在后台默默工作,查找、标识并释放垃圾对象,完成包括Java堆、方法区、直接内存中的全自动化管理。详见 第五节 JVM垃圾回收。

执行引擎是Java虚拟机的最核心组件之一,它负责执行虚拟机的字节码。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT)将方法编程成机器码后在执行。

​ 其余部分可查看 第三节 JVM内存划分。

第二部分 自动内存管理

三、JVM内存区域与内存溢出异常

名词解释:

JIT即时编译器(Just In Time Compiler),简称 JIT 编译器。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,比如锁粗化等。

1、JVM运行时数据区域

**运行时数据区域:**堆、方法区(包含在元空间中)、虚拟机栈、本地方法栈、程序计数器、直接内存。

xxx

线程可以说是一种更轻量级的进程:进程与线程关系如下图所示:

1、栈内存的大小直接决定着一个JVM进程可以创建多少个线程。

2、进程的内存大小:堆内存 + 线程数量 * 栈内存。总内存不变的情况下,堆内存越大创建的线程数越少、栈内存数量越大创建的线程数越少。

操作系统中一个进程内存大小有限制,这个限制称为地址空间,比如32位的Windows系统最大的地址空间约为2G多一代女,操作系统则会将进程内存的大小控制在最大地址空间以内。

计算公式: 线程数量 = (最大地址空间(MaxProcessMemory) - JVM堆内存 -ReservedOsMemory) / ThreadStackSize(XSS)

img

1.1、Java 堆

Java堆在虚拟机启动的时候建立,它是Java程序最主要的内存共存工作区域。几乎所有的Java对象实例以及数组都要在Java堆上进行分配。堆空间是所有线程空间共享的,用来存放对象实例,也是垃圾回收(GC)的主要区域;开启逃逸分析后,

某些未逃逸的对象可以通过标量替换的方式在栈中分配。(详见堆内存分配策略部分)

堆细分:新生代、老年代。对于新生代又分为:Eden区Surviver1Surviver2区,Surviver1和Surviver2也被称为from 和 to区域,它们是大小相等可以互换角色的内存空间。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JpuPtXVa-1625121076982)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210611231752824.png)]

1.2、方法区:

​ 对于JVM的方法区也可以称之为永久区,它储存的是已经被java虚拟机加载的类信息、常量(final 和 static)、静态变量、即时编译器编译后的代码(字节码)缓存等数据。Jdk 1.8 以后取消了方法区这个概念,称之为元空间(MetaSpace);方法区包含运行时常量池。

常量池一共有三种:

  • 字符串常量池:
    • 用于存放字符串常量。
    • 在JDK 7版本之后,字符串常量池被已到了堆中。大概是由于方法区的内存空间太小了。
  • **Class文件常量池: ** (具体查看Class文件结构)
    • 我们写的每一个Java类被编译后,就会形成一份class文件;class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References);
    • 每个class文件都有一个class常量池。
    • 字面量:文本字符串、八种基本类型的值、被声明为final的常量等。
    • 符号引用:类和方法的全限定名、字段名称和描述符、方法名称和描述符等。
  • 运行时常量池:(方法区中)
    • 运行时常量池存在于内存中,也就是class常量池被加载到内存之后的版本,不同之处是:它的字面量可以动态添加,符号引用可以被解析为直接引用。
    • JVM在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到方法区的运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在解析阶段,会把符号引用替换为直接引用,解析的过程会去查询字符串常量池,也就是我们上面所说的StringTable,以保证运行时常量池所引用的字符串与字符串常量池中是一致的。

注:运行时常量池属于方法区的一部分,用于存储编译期生成的各种字面量(常量、静态变量等)与符号引用存放到方法区的运行时常量池中。符号引用指的在类解析阶段生成的直接引用放置在运行时常量池中。运行时常量池型对于Class文件常量池的另一个重要特征是具备动态性,Java语言并不要求常量一定只有在编译期产生,运行期间也能将新的常量放入池中,这种特征被开发人员利用比较多的是String类的intern()方法。

1.3、虚拟机栈:

​ 虚拟机栈是线程私有的,它的生命周期和线程的生命周期是一致的。里面主要内容是栈帧,每一次函数调用,都会有一个对应的栈帧被压入Java栈,每一个函数调用结束,都会有一个栈帧被弹出Java栈。

栈帧和函数调用的关系

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-S91rUt5Y-1625121076984)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210612092445354.png)]

如图2.5中,函数1对应栈帧1,函数2对应栈帧2,以此类推。当函数被调用时,对应的栈帧入栈。当前正在执行的函数所对应的栈帧就是当前的帧(位于栈顶),它保存着当前函数的局部变量、动态链接等信息。

当函数返回时,栈帧从Java栈中弹出。Java有两种返回函数的形式,一种时正常的函数返回,使用return命令;另外一种是抛出异常。不管哪种方式都会导致栈帧被弹出。

栈帧中用来存放对应方法的(局部变量表操作数栈动态链接返回地址);

  • 局部变量表:局部变量表是一组变量值存储空间,用来存放方法的参数、方法内部定义的局部变量

    • 底层是变量槽(variable slot)(局部变量表存放的是 8 大基础类型加上一个引用类型,所以还是一个指向地址的指针)
    • 局部变量表中的变量只在当前函数中有效,当函数调用结束后,随栈帧的销毁,局部变量表也会随之销毁。
    • 局部变量表位于栈帧中,如果方法参数和局部变量过多,会使得局部变量表膨胀,从而每一次函数调用就会占用更多栈内存,最终导致函数的嵌套次数减少。
  • **操作数栈:**主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的临时存储空间。

    • 操作数栈也是一个先进后出的数据结构,只支持入栈和出栈两种操作。
    • 许多Java字节码指令都需要通过操作数栈进行参数传递。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-h57Gqdjl-1625121076985)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210612095232270.png)]

  • 动态链接:因为字节码文件中有很多符号的引用,这些符号引用一部分会在类加载的解析阶段第一次使用的时候转化成直接引用,这种称为静态解析;另一部分会在运行期间转化为直接引用,称为动态链接

  • **返回地址(returnAddress):**类型(指向了一条字节码指令的地址)。

  • 异常处理表:方便在发生异常的时候找到处理异常的代码。

异常退出的情况

在Java虚拟机规范中,对此区域规定了两种异常状况:

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

1.4、本地方法栈:

​ 本地方法栈和虚拟机栈类似,不同的是虚拟机栈服务的是Java方法,而本地方法栈服务的是Native方法。作为对Java虚拟机的扩展,Java虚拟机允许Java直接调用本地方法(通常使用C语言编写)。

​ 在HotSpot虚拟机实现中是把本地方法栈和虚拟机栈合二为一的,同理它也会抛出StackOverflowErrorOOM异常。

堆、方法区、栈的关系:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-By0DqGgj-1625121076985)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210612090648095.png)]

1.5、PC程序计数器:

​ 程序计数器是一块较小的内存空间,且是线程私有的,它可以看作是当前线程所执行的字节码的行号指示器。由于线程的切换,CPU在执行的过程中,需要记住原线程的下一条指令的位置,所以每一个线程都需要有自己的PC。

​ 字节码解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。

1.6、直接内存:

​ Java NIO 库允许Java程序直接使用直接内存。 **直接内存是在Java堆外、直接像系统申请的内存区域。**通常,访问直接内存的速度会优于Java堆。因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。

​ 由于直接内存在Java堆外,因此它的大小不会直接受限于Xmx指定的最大堆大小,但是系统内存有限,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。

2、HotSpot 虚拟机对象探秘

2.1、对象的创建步骤

步骤:类加载检查、分配内存、初始化零值、设置对象头、执行init方法

①类加载检查:

​ 虚拟机遇到 new 指令时,⾸先去检查是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。

②分配内存:

​ 在类加载检查通过后,接下来虚拟机将为新⽣对象分配内存,分配⽅式有 “指针碰撞”“空闲列表” 两种,选择那种分配⽅式由 Java 堆是否规整决定,⽽Java堆是否规整⼜由所采⽤的垃圾收集器是否带有压缩整理功能决定。(参考堆内存分配策略)

③初始化零值:

​ 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。

④设置对象头:

​ 初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。

⑤执⾏ init ⽅法:

​ 从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从Java 程序的视⻆来看, ⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说(除循环依赖),执⾏ new 指令之后会接着执⾏ ⽅法,这样⼀个真正可⽤的对象才算产⽣出来。

名词解释

指针碰撞:如果堆内存是绝对规整的,所有被使用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲区方向挪动一段与对象大小相等的距离,这种分配方式叫做”指针碰撞“。

**空闲列表:**如果Java堆并不是规整的,已被使用的内存和空闲的内存相互交错在一起,没办法使用指针碰撞,虚拟机就必须维护一个列表,记录那些内存块可以使用,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式叫”空闲列表“。

**Java堆是否规整:**选择那种分配⽅式由 Java 堆是否规整决定,⽽Java堆是否规整⼜由所采⽤的垃圾收集器是否带有压缩整理功能决定。因此,当使用Serial、ParNew等带压缩整理过程(复制算法)的收集器时,系统采用的分配算法是指针碰撞,既简单又高效。当使用CMS这种基于清除算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。

2.2、堆内存分配策略

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fmdbs9QA-1625121076986)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210613105305570.png)]

  • 对象优先分配在Eden区。如果Eden区没有足够的空间进行分配时,虚拟机执行一次MinorGC。而那些无需回收的存活对象,将会进到 Survivor 的 From 区(From 区内存不足时,直接进入 Old 区)。
  • 大对象直接进入老年代。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
  • 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄(Age Count)计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,直到达到阀值(默认15次),对象进入老年区。
    • 动态对象年龄判定:为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到默认阈值才能进入老年代。程序从年龄最小的对象开始累加,如果在 Survivor 区中年龄同时增加的所有对象的空间总和大于 Survivor 区空间的一半,则将当前的对象 age 作为新的阈值,年龄大于此阈值的对象则直接进入老年代。
    • **空间分配担保:**在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的空间总和,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立则进行 Full GC

MinorGC、MajorGC、FullGC 之间的区别?什么时候触发?

  • MinorGC :从新生代空间回收内存被称为Minor GC。在年轻代空间不足的时候发生。

  • MajorGC:指的是老年代的 GC,出现 MajorGC 一般经常伴有 MinorGC。

  • FullGC:针对整个新生代、老年代、元空间的全局范围的GC。

    触发时机:

    • 1、当老年代无法再分配内存的时候;
    • 2、元空间不足的时候;
    • 3、显示调用 System.gc 的时候。另外,像 CMS 一类的垃圾回收器,在 MinorGC 出现 promotion failure 的时候也会发生 FullGC。
    • 4、通过Minor GC后进入老年代的平均大小大于老年代的可用内存。
    • 5、由Eden区、Surivivor From区向Surivivor To区复制时,对象大小大于To Space可用内存,则把该对象转入老年代,且老年代可用内存小于该对象大小。

详细介绍对象在分代内存区域的分配过程?

  1. JVM试图为相关Java对象在Eden中初始化一块内存区域。
  2. 当Eden空间足够时,内存申请结束;否则到下一步。
  3. JVM释放部分Eden中所有的不活跃的对象(垃圾回收)。释放后若Eden空间仍然不足以放入新对象,则试图将部分Eden中活跃对象放入Survivor区。
  4. Survivor区被用来作为Eden及Old的中间交换区域,当Old区空间足够时,Surivivor区的对象会被转移到Old区,否则会被保留在Surivivor区。
  5. 当Old区空间不够时,JVM会在Old区进行完全的垃圾回收。
  6. 完全垃圾回收后,若Surivivor及Old区仍然无法存放从Eden复制过来的部分对象,导致JVM无法再Eden区为对象创建内存区域,出现OOM错误。

补充:TLAB分配

​ TLAB全称 Thread Local Allocation Buffer,即线程本地分配缓存。TLAB是一个线程专用的内存分配区域。

​ 为什么会有TLAB这个区域呢?是为了加速对象分配而生的。由于对象一般会分配到堆上,而堆是全局共享的。因此在同一时间,可能会有多个线程在堆上申请空间。因此,每一次对象分配都必须进行同步,而在竞争激烈的场合分配的效率又会进一步下降。考虑到对象分配几乎是Java最常用的操作,因此Java虚拟机就是用TLAB这种线程专属的区域来避免多线程冲突,提高对象分配的效率。TLAB本身占用了eden区的空间。在TLAB启用的情况下,虚拟机会为每一个线程分配一块TLAB空间。

​ TLAB本身的空间不会太大,因此大对象无法在TLAB上进行分配,总是会直接分配在堆上。

补充:栈上分配(SLAB)

栈上分配是Java虚拟机提供的一项优化技术,它的基本思想是,对于那些线程私有的对象(这里指不可能被其他线程访问的对象),可以将他们打散分散在栈上,而不是分配在堆上。

分配在栈上的好处就是可以在函数调用结束后自行销毁,而不需要垃圾回收器的接入,从而提高了系统的性能。

栈上分配的一个技术基础是进行逃逸分析逃逸分析的主要目的是判断对象的作用域是否有可能逃出函数体。

如下代码显示了一个逃逸的对象,在于User u是类成员变量,可能被任何线程访问,因此属于逃逸对象。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IQHLB84K-1625121076987)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210612100724039.png)]

如下代码是一个非逃逸对象。对象User 以局部变量的形式存在,并且该对象没有被 alloc() 函数返回,或者出现任何形式的公开,因此,它并未发生逃逸,所以对于这种情况,虚拟机就有可能将User分配在栈上,而不是堆上。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GfDbWz0w-1625121076988)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210612100845353.png)]

使用栈上分配可以防止堆上出现大量的GC。

栈上分配依赖逃逸分析和标量替换的实现,如果关闭逃逸分析或者标量替换中任何一个,执行程序时,会发生大量的GC。

对于大量的零散小对象,栈上分配提供了一种很好的对象分配优化策略,栈上分配速度快,并且可以有效避免垃圾回收带来的负面影响,但由于和对空间相比,栈空间较小,因此对于大对象无法也不适合在栈上分配。

2.3、对象的内存布局

​ 在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三部分:对象头、实例数据、齐填充。

2.3.1 对象头

Java的对象头由以下三个部分组成:

  • Mark Word:用于存储对象自身的运行时数据
  • 指向类型的指针:用于存储指向方法区对象类型数据的指针
  • 数组长度(只有数组对象才有)

1、Mark Word

​ Mark Word 记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Work有关。Mark Word被设计成了有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。

​ Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。主要包含哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等相关数据。

Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MWtxRsA9-1625121076988)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210613222224643.png)]

​ 其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。

​ JDK1.6以后的版本在处理同步锁时存在锁升级的概念,JVM对于同步锁的处理是从偏向锁开始的,随着竞争越来越激烈,处理方式从偏向锁升级到轻量级锁,最终升级到重量级锁。

JVM一般是这样使用锁和Mark Word的

  1. 当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。

  2. 当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。

  3. 当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。

  4. 当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。

  5. 偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。

  6. 轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。

  7. 自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。

2、指向类的指针(类型指针)

​ 类型指针即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定这个对象属于哪个类的实例。该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit。

​ Java对象的类数据保存在方法区。

​ 但是并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定经过对象本身。(类型指针是直接指针)(参考对象的访问定位)

3、数组长度

只有数组对象保存了这部分数据。

该数据在32位和64位JVM中长度都是32bit


2.3.2 实例数据

​ 实例部分是对象真正存储的有效信息,即我们在程序代码里所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录下来。


2.3.3 对齐填充

​ 因为JVM要求java的对象占的内存大小应该是8bit的倍数,所以后面有几个字节用于把对象的大小补齐至8bit的倍数,没有特别的功能。

2.4 锁的优化

​ 高并发是从JDK 5升级到JDK 6后一项重要的改进项。

2.4.1 自旋锁与自适应锁

自旋锁主要用于解决频繁线程切换,避免用户态和内核态互相转换带来的性能消耗。默认的自旋次数是十次,用户也可以使用参数 -XX:PreBlockSpin来自行更改。

自适应锁意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很可能会成功,进而允许自旋等待的时间持续相对更长的时间。另一方面,如果对于某个锁,自旋很少成功获得锁,那么以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。

2.4.2 锁消除

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

2.4.3 锁粗化

​ 大多数情况下,编写代码时总是推荐将同步块的作用范围限制得尽量小-只在共享数据的实际作用域中才进行同步。但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。

如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

2.4.4 轻量级锁的加锁过程:
  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为**锁记录(Lock Record)**的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如下图1所示。
  2. 拷贝对象头中的Mark Word,复制到锁记录中。
  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5)。
  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图2所示。
  5. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

img

​ 图1 轻量级锁CAS操作之前堆栈与对象的状态

img

​ 图2 轻量级锁CAS操作之后堆栈与对象的状态

2.4.5 重量级锁的加锁过程:

img

对象头会关联到一个monitor对象,即监视器对象。此外synchronized关键字经过Javac编译后,会在同步块的前后分别形成monitorenter和monitorexit这个两个字节码指令。下面来看一下synchronized的执行过程:

  • 假设此时锁对象没有被锁定,当线程进入同步块的时候,遇到monitorenter,就会获取当前对象的一个所有权,这个时候monitor进入数为1,当前的这个线程就是这个monitor的owner
  • 如果该线程已经是这个monitor的owner了,它再次进入,就会把进入数+1。(可重入性)
  • 当该线程执行完monitorexit,对应的进入数就-1,直到为0,才可以被其他线程持有。
  • 如果对象已经被锁定且owner不是该线程,该线程就进入阻塞状态。

为什么称之为重量级锁呢,关键就是“阻塞”这两个字。Java线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一个线程,则需要操作系统来帮忙完成,这就要从用户态转换为内核态,这会耗费很多的处理器时间。尤其是对于很简单的同步块,状态转换消耗的时间甚至比用户代码本身的执行时间还要长,这是得不偿失的。

2.4.6 偏向锁:

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

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

​ 上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。

​ 偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。

1、偏向锁获取过程:

1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。
  2. 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)。
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
  5. 执行同步代码。

2、偏向锁的释放:

偏向锁的撤销在上述第四步骤中有提到**。**偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

重量级锁、轻量级锁和偏向锁之间转换

img

​ 图 2.3三者的转换图

该图主要是对上述内容的总结,如果对上述内容有较好的了解的话,该图应该很容易看懂。

对象的哈希码问题

当对象进入偏向状态的时候, Mark Word大部分的空间都存储持有锁的线程ID了,这部分空间占用了原有存储对象哈希码的位置,那原来对象的哈希码怎么办?

​ 在Java里面一个对象如果计算过哈希码,就应该一直保持该值不变(强烈推荐但不强制。用户可以重载hashCode()方法),否则很多依赖哈希码的API可能存在出错风险。

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

​ 在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里面有字段可以记录非加锁状态下的Mark Word,其中自然可以存储原来的哈希码。

总结

本文重点介绍了JDk中采用轻量级锁和偏向锁等对Synchronized的优化,但是这两种锁也不是完全没缺点的,比如竞争比较激烈的时候,不但无法提升效率,反而会降低效率,因为多了一个锁升级的过程,这个时候就需要通过**-XX:-UseBiasedLocking**来禁用偏向锁。下面是这几种锁的对比:

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。如果线程间存在锁竞争,会带来额外的锁撤销的消耗。适用于只有一个线程访问同步块场景。
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度。如果始终得不到锁竞争的线程使用自旋会消耗CPU。追求响应时间。 同步块执行速度非常快。
重量级锁线程竞争不使用自旋,不会消耗CPU。线程阻塞,响应时间缓慢。追求吞吐量。 同步块执行速度较长。
2.5、对象的访问定位

创建对象自然是为了后续使用该对象,Java程序会通过栈上的reference数据来操作堆上的具体对象。

对象的访问方式取决于虚拟机实现,目前主流的访问方式有使用句柄直接指针两种。

句柄,可以理解为指向指针的指针,维护指向对象的指针变化,而对象的句柄本身不发生变化;指针,指向对象,代表对象的内存地址。


句柄

​ Java堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

句柄访问对象

​ **优势:**引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。


直接指针

​ 如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而引用中存储的直接就是对象地址。(即对象头中的类型指针

直接内存访问对象

​ **优势:**速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。(例如HotSpot)

3、对象引用

普通的对象引用关系就是强引用

软引用用于维护一些可有可无的对象。只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。

弱引用对象相比软引用来说,要更加无用一些,它拥有更短的生命周期,当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。

虚引用是一种形同虚设的引用,在现实场景中用的不是很多。虚引用必须和引用队列一起使用,它主要用来跟踪对象被垃圾回收的过程。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入到引用队列,以通知应用程序对象的回收情况。

4、OutOfMemoryError异常

4.1、Java堆溢出

​ Java堆用于储存对象实例,只要我们不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。

​ 首先通过内存映像分析工具对Dump出来的堆转储快照进行分析。第一步首先应确认内存中导致OOM的对象是否是必要的,也就是分析出到底是出现了内存泄漏还是内存溢出。

​ 如果是内存泄漏,可进一步通过工具查看泄露对象到GC Roots的引用链,找到泄露对象是怎样通过引用链与哪些GC Roots相连,导致垃圾回收器无法回收它们。

​ 如果是内存溢出,就是说这些对象确实都必须存货,应该检查虚拟机参数,与机器内存对比,看看是否还有向上调整的空间。


4.2、虚拟机栈和本地方法栈溢出

规定了两种异常:

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

HotSpot虚拟机不支持动态扩展,所以除非是在创建线程申请内存时就因无法获得足够内存出现OutOfMemoryError异常,否则在线程运行期间不会因为扩展而导致内存溢出,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。

无论是栈帧太大还是虚拟机栈容量太小,当线程正在运行并且新的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常。


4.3、方法区和运行时常量池溢出

常量池包含在方法区中,当运行常量池溢出时,会抛出OutOfMemoryError异常。

JDK 7中,字符串常量池已经移动到了Java 堆中,在运行时常量池里只需要记录一下首次出现的实例引用即可。

JDK 8中,元空间作为永久代的替代品出场。相比较永久代,正常的动态创建新类型的类等,已经很难再迫使虚拟机产生方法区的异常了。


4.4、本机直接内存溢出

​ 直接内存的容量大小可通过 -XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆的最大值(由 -Xmx 指定)一致。

​ 由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果发现内存溢出后产生的Dump文件很小,而程序中又直接或简介使用了DirectMemory,那就可以考虑重点检查直接内存方面的原因。

四、垃圾回收器和内存分配策略

1、标记方法、两次标记过程

1.1 引用计数法:

​ 给对象添加一个引用计数器,每当由一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。

  • 优点:虽占用了一些额外内存空间来计数,但实现简单,判定效率也很高。

  • 缺点:它很难解决对象之间相互循环引用的问题,基本上被抛弃。而且每次因引用的产生和消除时,需要伴随一个加法操作和减法操作,对系统性能会有一定的影响。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-p5fDxpmn-1625121076995)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210612105353028.png)]

1.2 可达性分析法:

​ 通过一系列称为“GC Roots”的根对象作为起始点,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为**“引用链”(ReferenceChains)**,当一个对象到GC Roots 之间没有任何引用链相连,则证明此对象是不可达的、不能再被使用的。

è¿éåå¾çæè¿°

名词解释:

  • 可达对象:指通过根对象进行引用搜索,最终可以达到的对象。
  • 不可达对象:指通过根对象进行引用搜索,最终没有被引用到的对象。

什么是GC Roots

GC Roots可以理解为由堆外指向堆内的引用, 一般而言,GC Roots包括(但不限于)以下几种:

  1. Java虚拟机栈中引用的对象,比如各个线程中调用的方法栈堆中使用的参数、局部变量、临时变量等。

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

  3. 在方法区中,常量引用的对象,比如字符串常量池里的引用。

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

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

  6. 所有被同步锁持有的对象。

    除了上述固定的GC Roots集合外,根据用户所选的垃圾回收器以及当前回收的内存区域不同,还可以有其他对象临时性地加入,共同构成完整地GC Roots集合。

1.3 生存还是死亡(最终判定):

finalize()方法最终判定对象是否存活:

​ 即使在可达性分析算法中判定为不可达对象,也不是 “非死不可” 的,这时候它们还处于 “缓刑” 阶段,要真正宣告对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后没有与GC Roots相连的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是对象是否有必要执行finalize()方法。假如对象没有覆盖(实现)finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况是为“没有必要执行”。


1.3.1、方法finalize()对垃圾回收的影响

Java提供了一个类似于C++中析构函数的机制-finalize()函数,他在java.lang.Object中被声明,形式如下:

protected void finalize() throws Trowable { }

该函数允许在子类中被重载,用于在对象被回收时进行资源的释放。目前,普遍的认识是,尽量不要使用finalize()函数,原因如下:

  • finalize()函数可能会导致对象复活。
  • finalize()函数的执行时间没有保障,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()将没有执行机会。
  • 一个糟糕的finalize()会严重影响GC的性能。

1.3.2 finalize()判断可触及性

​ 垃圾回收的基本思想是考察每一个对象的可达性,即从根节点开始是否可以访问到这个对象。如果不能访问到,说明这个对象需要被回收。但事实上,一个不可达对象有可能在某一个条件下复活自己,如果这样就是不合理的。为此,需要给出一个对象可触及性状态的定义,并规定在什么状态下,才可以安全地回收对象。用于保证对象回收的安全性。

可触及性可以包含以下三种状态:可触及的(即对象可达),可复活、不可触及(即对象不可达时地两种情况)

  • 可触及的:从根节点开始,可以到达这个对象。此时不会对对象进行回收。
  • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()函数中复活。
  • 不可触及的:对象的finalize()函数被调用,并且没有复活,那么就会进入不可触及状态。不可触及状态的对象不可能被复活,因为finalize()函数只会被调用一次。

以上三种状态中,只有在对象不可触及时才可以被回收。


1.3.3、详细的两次标记过程
  • 首先,对象被回收之前,该对象的finalize()方法会被调用。
  • 两次标记过程:如果对象在可达性分析中没有与GC Root的引用链,那么此时就会被第一次标记。
  • 随后进行一次筛选,筛选的条件是就要先判断该对象有没有必要执行finalize()方法,如果没有实现finalize()方法或者finalize()方法已经被虚拟机调用过,就直接判断该对象是不可触及的、可回收
  • 如果实现并且有必要执行finalize()方法,那么这个对象将会被放在一个称为 F-Queue 的队列中,并由虚拟机建立的一个低优先级的Finalizer线程去执行它们的finalize()方法。但是虚拟机不会承诺一直等待它运行完,这是因为如果 finalize()执行缓慢或者发生了死锁,那么就会造成 F-Queue 队列一直等待,造成了内存回收系统的崩溃。
  • finalize()方法是对象逃脱死亡命运的最后一次机会,随后 GC 对处于 F-Queue 中的对象进行第二次小规模标记。
    • 如果对象在finalize()中成功复活自己—只需重新与引用链上的任何一个对象建立关联即可,那么第二次标记时它将被移除 “即将回收” 的集合。
    • 如果对象在这个时候还没有逃脱,那么在这次被标记的对象就是不可触及的,就会真正的被回收了。

1.4 回收方法区

​ 方法区的垃圾回收主要回收两部分内容:废弃的常量和不再使用的类型。判断常量的废弃,只需要看还有没有引用的存在。常量池中其他类(接口)、方法、字段的符号引用也是如此。

​ 判断一个常量是否“废弃”还是相对简单,要判断一个类型是否属于“不再被使用的类”的条件比较苛刻,需要同时满足下述三点:

  • 该类的所有实例都被已经被回收了。
  • 加载该类的类加载器已经被回收了。这个条件除非是精心设计的可替换类加载器场景,如OSGi、JSP的重加载等,否则很难实现。
  • 该类对象的java.lang.Class对象没有在任何地方被引用,无法通过发射访问该类的方法。

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

2、垃圾回收算法

垃圾回收算法:标记-复制算法、标记-清除、标记-整理、分代收集。

标记-复制算法:(young)

​ 将内存分为⼤⼩相同的两块,每次使⽤其中的⼀块。当这⼀块的内存使⽤完后,就将还存活的对象复制到另⼀块去,然后再把使⽤的空间⼀次清理掉。这样就使每次的内存回收都是对内存区间的⼀半进⾏回收;

​ 优点:实现简单,内存效率高,不易产生碎片

​ 缺点:内存压缩了一半,倘若存活对象多,Copying 算法的效率会大大降低

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0YwDGN8P-1625121076997)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210612130644345.png)]

标记-清除:(Mark-Sweep)

​ 分为两个步骤:首先使用可达性分析法标记出所有可达对象。需要回收的对象,在标记完成后统⼀清除所有被标记的对象

​ 缺点:效率低,标记清除后会产⽣⼤量不连续的碎⽚,需要预留空间给分配阶段的浮动垃圾

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DPGfx77G-1625121076998)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210612105901324.png)]

标记-整理:(Mark-Compact)

​ 标记过程仍然与“标记-清除”算法⼀样,之后让所有存活的对象向⼀端移动,然后直接清理掉端边界以外的内存;解决了产生大量不连续碎片问题。

标记-整理算法的最终效果相当于标记-清除算法执行完成后,再进行一次内存碎片整理。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UA56had9-1625121076999)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210612130802963.png)]

分代收集:

​ 根据各个年代的特点选择合适的垃圾收集算法。

新生代采用复制算法,新生代每次垃圾回收都要回收大部分对象(大约90%),存活对象较少,即要复制的操作比较少,一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。

​ 老年代的对象存活⼏率是⽐较⾼的,⽽且没有额外的空间对它进⾏分配担保,所以我们必须选择**“标记-清除”或“标记-整理”**算法进⾏垃圾收集。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fFAeS8m6-1625121077000)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210612131756757.png)]

3、HotSpot的算法实现细节

名词解释:垃圾回收时的停顿现象:Stop - The - World

​ 垃圾回收器的任务是识别和回收垃圾对象进行内存清理,为了让垃圾回收器可以正常且高效地执行,大部分情况下,会要求系统进入一个停顿地状态。停顿的目的是终止所有应用线程的执行,只有这样,系统中才不会有新的垃圾产生,同时停顿保证了系统状态在某一瞬间的一致性,也有益于垃圾回收器更好的标记垃圾对象。停顿产生的时候,整个应用程序会被卡死,没有任何响应,因此这个停顿也叫做“Stop - The - World”(STW)。

常用的实现细节:

3.1、Safepoint(安全点)

Safepoint 当发生 GC 时,用户线程必须全部停下来,才可以进行垃圾回收,这个状态我们可以认为 JVM 是安全的(safe),整个堆的状态是稳定的。如果在 GC 前,有线程迟迟进入不了 safepoint,那么整个 JVM 都在等待这个阻塞的线程,造成了整体 GC 的时间变长。

​ 安全点的选择基本上以“是否具有让程序长时间执行的特征”。长时间执行的最明显特征是指令复用,例如方法调用、循环跳转、异常跳转等,只有这些功能的指令才能产生安全点。

img

3.2、SafeRegion(安全区)

​ 安全区是指能够确保在某一段代码片段中,引用关系不会发生改变,因此,在这个区域中的任意地方开始垃圾回收都是安全的。安全区可以看作被扩展拉伸了的安全点。

后续需要了解:根节点枚举、记忆集和卡表、写屏障

4、垃圾收集器

img

JDK 1.6 中 Sun HotSpot 虚拟机的垃圾收集器如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ci1MBr7z-1625121077003)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210510171020636.png)]

回收器介绍:

串行回收器

新生代 :Serial(复制算法);老年代:Serial Old(标记-整理算法);都是串行回收器

串行回收器是所有垃圾回收器中最古老的一种,也是JDK中最基本的垃圾回收器。主要有两个特点:

  • 第一,它仅仅使用单线程进行垃圾回收。
  • 第二,它是独占式的垃圾回收。

在进行垃圾回收时,Java应用程序中的线程都需要暂停,等待垃圾回收的完成。如下图所示,在串行回收器运行时,应用程序中的所有线程都停止工作,进行等待。这种现象称之为:STW。它将造成非常糟糕的用户体验,在实时性要求较高的应用场景中,这种现象往往是不能被接受的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mYFX2bgs-1625121077003)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210612221357589.png)]

并行回收器

新生代:Parnew ;老年代:Parnew Old;都是并行回收器。Parnew 是 Serial的多线程版本;

并行回收器在收集过程中同样会暂停所有应用程序的线程(STW)。但由于并行回收器使用多线程进行垃圾回收,因此,在并发能力比较强的CPU上,它产生的停顿时间更短于串行回收器。而在单CPU或者并发能力比较弱的系统中,并行回收器的效果不会比串行回收器好,由于多线程的压力,它的实际表现很可能比串行回收器差。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cTVgBala-1625121077004)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210612222611173.png)]

新生代垃圾收集器

JDK 3 :Serial、Parnew 关注垃圾回收的效率

**Serial:**串行回收器,适合用于客户端垃圾收集器。

  • **使用算法:**复制算法
  • 缺点:用户停顿时间较长

**Parnew: **Serial 收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余的行为和 Serial 收集器完全一样。可以配合CMS使用。

  • **使用算法:**复制算法
  • 缺点:用户停顿时间较长

JDK 5: parallel Scavenge+(Serial old/parallel old)关注吞吐量

parallel Scavenge:

  • Parallel Scavenge收集器关注点是达到一个可控的吞吐量(⾼效率的利⽤CPU)。CMS等垃圾收集器的关注点更多的是⽤户线程的停顿时间(提⾼⽤户体验);高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。
  • **使用算法:**复制算法。
  • **关注点:**达到一个可控的吞吐量。
  • 吞吐量 = (运行用户代码时间) / (运行用户代码时间 + 运行垃圾回收器时间)

老年代垃圾收集器

Serial old: Serial收集器的⽼年代版本,它同样是⼀个单线程收集器。

  • 主要有两个用途:
    • 在 JDK 1.5 之前版本中与新生代的 Parallel Scavenge 收集器搭配使用。
    • 作为年老代中使用 CMS 收集器的备用回收器。
  • 使用算法:标记-整理算法
  • 缺点:用户停顿时间较长

parallel old: Parallel Scavenge收集器的⽼年代版本。

  • 使用算法:标记-整理算法。
  • 缺点:用户停顿时间较长。
  • 可与新生代的parallel Scavenge搭配使用。

JDK 8-CMS:(关注最短垃圾回收停顿时间)

​ CMS收集器是一种老年代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间,和其他老年代使用标记-整理算法不同,它使用多线程的标记-清除算法。最短的垃圾回收停顿时间可以为交互比较高的程序提高用户体验。

​ CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下 4 个阶段:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CXi6wEj2-1625121077005)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210510215940887.png)]

​ **初始标记:**只是标记一下 GC Roots 能直接关联的对象,速度很快,STW。

​ **并发标记:**进行 ReferenceChains跟踪的过程,和用户线程一起工作,不需要暂停工作线程。

​ **重新标记:**为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,STW。

​ **并发清除:**清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。

​ 由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。

​ **优点:**并发收集、低停顿。

​ **缺点:**对CPU资源敏感,并发阶段虽然不会导致用户线程变慢,但是却因为占用了一部分线程而导致应用程序变慢,降低吞吐量;⽆法处理浮动垃圾;使⽤“标记清除”算法,会导致⼤量空间碎⽚产⽣。

新生代、老年代垃圾收集器

JDK 9-G1:(精准控制停顿时间,避免垃圾碎片)

​ 是⼀款⾯向服务器的垃圾收集器,主要针对配备多颗处理器及⼤容量内存的机器。以极⾼概率满⾜GC停顿时间要求的同时,还具备⾼吞吐量性能特征;相比于CMS 收集器,G1 收集器两个最突出的改进是:

​ 【1】基于标记-整理算法,不产生内存碎片。

​ 【2】可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。

​ G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率。

使用算法:从整体来看是”标记-整理算法“,从局部(两个 Region 之间)上来看是基于“复制”算法实现的

主要步骤:

  • 初始标记:**Stop The World,**仅使用一条初始标记线程对GC Roots关联的对象进行标记

  • 并发标记:使用一条标记线程与用户线程并发执行。此过程进行可达性分析,速度很慢

  • 最终标记Stop The World,使用多条标记线程并发执行

  • 筛选回收:回收废弃对象,此时也要 Stop The World,并使用多条筛选回收线程并发执行

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cHScNGJk-1625121077006)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210612225812673.png)]

必要时的Full GC

​ 和CMS类似,并发收集由于应用程序和GC程序交替工作,因此总是不能完全避免在特别繁忙的场合会出现在回收过程中内存不足的情况。当遇到这种情况时,G1 也会转入一个 Full GC进行回收。


JDK 11-ZGC:(在不关注容量的情况获取最小停顿时间5 TB/10 ms)

​ 着色笔技术:加快标记过程

​ 读屏障:解决GC和应用之间并发导致的STW问题

  • 支持 TB 级堆内存(最大 4 T, JDK 13 最大16 TB)

  • 最大 GC 停顿 10 ms

  • 对吞吐量影响最大,不超过 15%

5、堆内存分配策略

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nAtOgNQp-1625121077008)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210613105305570.png)]

  • 对象优先分配在Eden区。如果Eden区没有足够的空间进行分配时,虚拟机执行一次MinorGC。而那些无需回收的存活对象,将会进到 Survivor 的 From 区(From 区内存不足时,直接进入 Old 区)。
  • 大对象直接进入老年代。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
  • 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄(Age Count)计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,直到达到阀值(默认15次),对象进入老年区。
    • 动态对象年龄判定:为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到默认阈值才能进入老年代。程序从年龄最小的对象开始累加,如果在 Survivor 区中年龄同时增加的所有对象的空间总和大于 Survivor 区空间的一半,则将当前的对象 age 作为新的阈值,年龄大于此阈值的对象则直接进入老年代。
    • **空间分配担保:**在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的空间总和,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立则进行 Full GC

6、常见的细节问题

1、禁用System.gc()

​ 在默认情况下,System.gc()会显示触发 Full GC,同时对老年代和新生代进行回收。而一般情况下,垃圾回收应该是自动进行的,无需手动触发。如果过于频繁地触发垃圾回收对系统性能是没有好处的。

​ 虚拟机提供了一个参数 DisableExplicitGC 来控制是否手工触发GC,如果设置了此参数,条件判断无法成立,那么就会金庸显式GC,使得System.gc()等待于一个空函数调用。

2、System.gc()使用并发回收

​ 默认情况下,System.gc()会显式直接出发Full GC,它会使用传统的Full GC方式回收整个堆

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aeyT5uGy-1625121077008)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210613095833932.png)]

3、并发GC前额外触发的新生代GC

​ 在触发Full GC之前,会先进行了一次新生代GC。因此,这里的 System.gc()实际上触发了两次GC。这样做的目的是先将新生代进行收集一次,避免将所有回收工作同时交给一次 Full GC进行,从而尽可能地缩短一次停顿时间。

7、配置垃圾收集器

  • 首先是内存大小问题,基本上每一个内存区域我都会设置一个上限,来避免溢出问题,比如元空间。
  • 通常,堆空间我会设置成操作系统的 2/3,超过 8GB 的堆,优先选用 G1
  • 然后我会对 JVM 进行初步优化,比如根据老年代的对象提升速度,来调整新生代和老年代之间的比例
  • 依据系统容量、访问延迟、吞吐量等进行专项优化,我们的服务是高并发的,对 STW 的时间敏感
  • 我会通过记录详细的 GC 日志,来找到这个瓶颈点,借用 GCeasy 这样的日志分析工具,定位问题

8、JVM性能调优

对应进程的JVM状态以定位问题和解决问题并作出相应的优化

**常用命令:**jps、jinfo、jstat、jstack、jmap

jps:查看java进程及相关信息

jps -l 输出jar包路径,类全名
jps -m 输出main参数
jps -v 输出JVM参数

jinfo:查看JVM参数

jinfo 11666
jinfo -flags 11666
Xmx、Xms、Xmn、MetaspaceSize

jstat:查看JVM运行时的状态信息,包括内存状态、垃圾回收

jstat [option] LVMID [interval] [count]
其中LVMID是进程id,interval是打印间隔时间(毫秒),count是打印次数(默认一直打印)
  
option参数解释:
-gc 垃圾回收堆的行为统计
-gccapacity 各个垃圾回收代容量(young,old,perm)和他们相应的空间统计
-gcutil 垃圾回收统计概述
-gcnew 新生代行为统计
-gcold 年老代和永生代行为统计

jstack:查看JVM线程快照,jstack命令可以定位线程出现长时间卡顿的原因,例如死锁,死循环

jstack [-l] <pid> (连接运行中的进程)
  
option参数解释:
-F 当使用jstack <pid>无响应时,强制输出线程堆栈。
-m 同时输出java和本地堆栈(混合模式)
-l 额外显示锁信息

jmap:可以用来查看内存信息(配合jhat使用)

jmap [option] <pid> (连接正在执行的进程)

option参数解释:
-heap 打印java heap摘要
-dump:<dump-options> 生成java堆的dump文件

9、JDK新特性

JDK8

支持 Lamda 表达式、集合的 stream 操作、提升HashMap性能

JDK9

//Stream API中iterate方法的新重载方法,可以指定什么时候结束迭代
IntStream.iterate(1, i -> i < 100, i -> i + 1).forEach(System.out::println);

默认G1垃圾回收器

JDK10

其重点在于通过完全GC并行来改善G1最坏情况的等待时间。

JDK11

ZGC (并发回收的策略) 4TB

用于 Lambda 参数的局部变量语法

JDK12

Shenandoah GC (GC 算法)停顿时间和堆的大小没有任何关系,并行关注停顿响应时间。

JDK13

增加ZGC以将未使用的堆内存返回给操作系统,16TB

JDK14

删除cms垃圾回收器、弃用ParallelScavenge+SerialOldGC垃圾回收算法组合

将ZGC垃圾回收器应用到macOS和windows平台

五、虚拟机类加载机制

1、类加载的时机

1.1、类的声明周期

​ 一个类型从被加载到虚拟机内存开始,到卸载出内存为止,他的整个生命周期将会经历加载、验证、准备、解析、初始化、使用、卸载七个阶段。其中验证、准备、解析三个部分统称为连接。

发生顺序如下所示:

在这里插入图片描述

​ 加载、验证、准备、初始化、卸载这五个部分顺序是确定的,类型的加载过程必须按照这种顺序按部就班的开始,而解析阶段则不一定:它在某些情况下可以在初始化之后再开始,这是为了支持Java语言的运行时绑定特定(也成为动态绑定)

​ 强调是按部就班的开始,而不是按部就班地进行或按部就班的完成,强调这点是因为这些阶段通常都是互相交叉的混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

连接阶段是可以交叉工作的,但加载阶段一定出现在连接阶段之前开始。


1.2、类的主动加载和被动加载

《JVM规范》严格规定了有且只有以下6种情况必须立即对类进行”初始化“(加载、验证、准备、解析自然需要在此之前开始),即主动加载:

  • 遇到new 、getstatic、putstatic或者invokestaic这四个字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化过程。
    • 使用 new 关键字实例化对象时,会导致类的初始化。

    • 读取或设置一个类型的静态字段(被final修饰、已在编译器把结果放入常量池地静态字段除外)的时候,会导致类的初始化。

    • 调用一个类型的静态方法,会导致类的初始化。

  • 对某个类进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
  • 初始化子类时如果发现父类还没有进行初始化,则需要先触发其父类的初始化。(注意:通过子类使用父类的静态变量只会导致父类的初始化,子类则不会初始化)
  • 启动类:也就是执行main函数所在的类会导致该类的初始化。
  • 当使用 JDK 7新加入的动态语言支持时,如果方法句柄对应的类没有进行初始化,则需要先触发其初始化。
  • 当一个接口中定义了 JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,呢吗该接口要在其之前进行初始化。

​ 上述流中会触发初始化,被称为对一个类型的主动加载(主动引用)。除此之外,所有的引用类型的方式都是被动加载(被动引用),不会导致类的加载和初始化:

  • 构造某个类的数组时并不会导致该类的初始化。
  • 通过子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
  • 引用类的静态常量(常量将会放在常量池中)并不会导致导致类的初始化。

​ 接口和类真正有所区别是在前面六种“有且仅有”需要触发初始化场景中的第三种:当一个类在初始化,要求其父类全部都已经初始化过。但是一个接口在初始化的时候,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候才会初始化。

2、JVM类加载过程

过程:加载、验证、准备、解析、初始化

img
2.1、加载阶段:

”加载“是整个”类加载”过程中的一个阶段。在加载阶段,Java虚拟机需要完成以下三件事情:

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

类加载的最终产物是堆内存中的class对象,对同一个ClassLoader来讲,不管某个类被加载了多少次,对应到堆内存中的class对象始终只有一个。JVM规范中指出类的加载是通过一个全限定名来获取二进制数据流,但是没有限定通过某种方式去获得。

  • 运行时动态生成
  • 通过网络获取
  • 读取zip文件获得类的二进制字节流
  • 将类的二进制数据存储在数据库的BLOB字段类型中
  • 运行时生成class文件,并且动态加载
2.2、验证阶段:

主要目的是确保class文件的字节流所包含的信息符合当前的JVM规范的全部约束要求,保证这些信息被当作代码运行运行后,不会危害JVM自身的安全。

  1. 文件格式验证(是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理)

    第一阶段要验证字节流是否符合 Class文件格式的规范, 井且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:

    • 是否以魔数 0xCAFEBABE开头
    • 主、次版本号是否在当前虚拟机处理范围之内 。
    • 常量池的常量中是否有不被支持的常量类型(检査常量tag 标志)。
    • 指向常量的各种索引值中是否有指向不存在的常量或不符合装型的常量 。
    • CONSTANT_Utf8_info型的常量中是否有不符合 UTF8编码的数据
    • Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息
  2. 元数据验证(对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范要求)

    第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,这个阶段可能包括的验证点如下:

    • 这个类是否有父类(除了 java.lang.0bject之外,所有的类都应当有父类)
    • 这个类的父类是否继承了不允许被继承的类(被finaI修饰的类)
    • 如果这个类不是抽象类, 是否实現了其父类或接口之中要求实现的所有方法
    • 类中的字段、 方法是否与父类产生了矛盾(例如覆盖了父类的final字段, 或者出現不符合规则的方法重载, 例如方法参数都一致, 但返回值类型却不同等)

    第二阶段的验证点同样远不止这些,这一阶段的主要目的是对类的元数据信息进行语义检验, 保证不存在不符合 Java语言规范的元数据信息。

  3. 字节码验证

    ​ 第三阶段是整个验证过程中最复杂的一个阶段, 主要目的是通过数据流和控制流的分析,确定语义是合法的,符号逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,例如:

    • 保证任意时刻操作数栈的数据装型与指令代码序列都能配合工作, 例如不会出现类似这样的情况:在操作栈中放置了一个 int类型的数据, 使用时却按long类型来加载入本地变量表中。
    • 保证跳转指令不会跳转到方法体以外的字节码指令上。
    • 保证方法体中的类型转换是有效的, 例如可以把一个子类对象赋值给父类数据装型,这是安全的,但是把父类对象意赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、 完全不相干的一个数据类型, 则是危险和不合法的。

    即使一个方法体通过了字节码验证, 也不能说明其一定就是安全的。

  4. 符号引用验证(虚拟机将符号引用转化为直接引用时,解析阶段中发生):确保解析行为能正常执行。

    ​ 最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候 , 这个转化动作将在连接的第三个阶段——解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用) 的信息进行匹配性的校验, 通常需要校验以下内容:

    • 符号引用中通过字将串描述的全限定名是否能找到对应的类。
    • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段 。
    • 符号引用中的类、字段和方法的访问性(private、 protected、 public、 default)是否可被当前类访问

​ 符号引用验证的目的是确保解析动作能正常执行, 如果无法通过符号引用验证, 将会抛出一个 java.lang.IncompatibleClassChangError异常的子类, 如 java.lang.IllegalAccessError、 java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。


​ 对于虚拟机的装加载机制来说 ,验证阶段是一个非常重要的、 但不一定是必要的阶段(因为对程序没有影响)。如果所运行的全部代码(包括自己编写的以及第三方包中的代码)都已经被反复使用和验证过 , 那么在实施阶段就可以考虑使用一Xverify;none 参数来关闭大部分的验证措施, 以缩短虚拟机类加载的时间。

2.3、准备阶段:

​ 准备阶段是正式为类静态变量分配内存并设置类变量初始值的阶段。从概念上讲,这些变量所使用的内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。准备阶段所说的初始值“通常情况”下是数据类型的零值

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aDePLEln-1625121077010)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210607163157532.png)]

public class LinkedPrepare
{
	private static int a = 10;
	private final static int b = 10;
}

​ 其中static int a = 10在准备阶段不是10,而是初始值0,但是 b 会是 10。

​ 原因:final修饰的静态变量不会导致类的初始化,是一种被动引用,因此不存在连接阶段,在类编译阶段javac会将其value生成一个ConstantValue属性,直接赋予10。

2.4、解析阶段:

​ 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程, 解新动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_IntrfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info7种常量类型,解析阶段中所说的直接引用与符号引用关系如下:

  • 符号引用(Symlxiuc References):符号引用以一组符号来描述所引用的日标,符号可以是任何形式的字面量, 只要使用时能无歧义地定位到目标即可,。符号引用与虚拟机的内存布局无关 , 引用的目标并不一定是已经加载到虚拟机内存中的内容。符号引用的字面量形式明确定义在JVM规范的Class文件格式中。
  • 直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的 , 同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同. 如果有了直接引用, 那引用的目标必定已经在内存中存在。

字符串常量池:堆上,默认class文件的静态常量池

运行时常量池:在方法区,属于元空间

2.5、初始化阶段:

​ 初始化阶段时加载过程的最后一步,前面的几个阶段, 除了在加载阶段用户应用程序可以通过自定 义类加载器參与之外, 其余动作完全由虚拟机主导和控制。而这一阶段才是真正意义上开始执行类中定义的Java程序代码。从代码角度,初始化阶段是执行类构造器()方法的过程。我们先看一下()方法执行过程中可能会影响程序运行行为的特点和细节:

  • ()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的, 编译器收集的顺序是由语句在源文件中出现的顺序所决定的, 静态语句块中只能访问到定义在静态语句块之前的变量, 定义在它之后的変量 , 在前面的静态语句块可以赋值 , 但是不能访问。
  • ()方法与类的构造函数 (或者说实例构造器()方法)不同,它不需要显式地调用父类构造器, 虚期机会保证在子类的()方法执行之前, 父类的()方法已经执行完毕, 因此在虚期机中第一个被执行的()方法的类肯定是 java,lang.Object。
  • 由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作()方法对于类或接口来说并不是必须的, 如果一个类中没有静态语句块,也没有对变量的赋值操作, 那么编译器可以不为这个类生成()方法。
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作, 因此接口与类一样都会生成()方法。 但接口与类不同的是, 执行接口的()方法不需要先执行父接口的()方法。只有当父接口中定义的变量被使用时, 父接口才会被初始化。 另外, 接口的实现类在初始化时也一样不会执行接口的()方法。
  • 虚拟机会保证一个类的()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()法,其他线程部需要阻塞等待,直到活动线程执行()方法完毕。如果在一个类的()方法中有耗时很长的操作, 那就可能造成多个进程阻塞, 在实际应用中这种阻塞往往是隐蔽的。

Java类的初始化顺序如下所示:

​ 对于静态变量、静态初始化块、变量、初始化块、构造器,它们的初始化顺序依次是(静态变量、静态初始化块)>(变量、初始化块)>构造器。

img

img

静态变量和静态代码块的执行顺序与代码书写顺序一致。

在静态代码块中可以使用静态变量,为了使表达更加明确,应该尽量将静态变量写在静态代码块之前。

1、父类静态变量和静态代码块(先声明的先执行);

2、子类静态变量和静态代码块(先声明的先执行);

3、父类的变量和代码块(先声明的先执行);

4、父类的构造函数;

5、子类的变量和代码块(先声明的先执行);

6、子类的构造函数。


实例化讲解初始化顺序

public class Test {
	//1、如果在这里进行声明,最终输出结果 x = 0, y = 1
	private static Test instance = new Test();
    private static int x = 0;
    private static int y;
    //2、如果在这里进行声明,最终输出结果 x = 1, y = 1
  //private static Test instance = new Test();
    private Test(){
        x++;
        y++;
    }

    public static Test getInstance() {
        return instance;
    }

    public static void main(String[] args) {
        Test test = Test.getInstance();
        System.out.println(Test.x);
        System.out.println(Test.y);
    }
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FfP5X4rO-1625121077010)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210607182820324.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-D4xhG7Xe-1625121077011)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210607182919021.png)]


3、类加载器

类加载器用于通过一个类的全限定名来获取描述该类的二进制字节流。

img

3.1、类与类加载器

类加载器命名空间的概念:

  • 每一个类加载器都有各自的命名空间,命名空间是由该加载器及其所有父加载器所构成,因此在每一个加载器中同一个calss都是独一无二的。

  • 在同一个命名空间中,不会出现类的完整名字(全限定名)相同的两个类。

  • 在不同的命名空间下,有可能会出现类的完整名字(全限定名)相同的两个类。

命名空间的关系如下:(请结合下图一起看,想明白)

  • 同一个命名空间的类是相互可见的。
  • 子加载器的命名空间包含所有父加载器的命名空间。因此由子加载器加载的类能看见父加载器加载的类。例如系统类加载器能看见根类加载器加载的类。
  • 由父类加载器加载的类不能看见子加载器加载的类。
  • 如果两个加载器没有父子关系,那么他们自己加载的类互相不可见。

img



类的唯一性:

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性。这两点必须相同才代表类是一样的。


运行时包:

​ 我们编写代码时通常会给一个类指定一个包名,包的作用是为了组织类,防止不同包下同样名称的class引起冲突,还能起到封装的作用,包名和类名构成了类的全限定名称

​ JVM在运行时class会有一个运行时包,运行时包是由类加载器的命名空间 + 类的全限定名共同组成。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QeMzFgEp-1625121077017)(C:\Users\AdministratorGUET\AppData\Roaming\Typora\typora-user-images\image-20210607203800120.png)]

3.2、双亲委派模型
3.2.1、类加载器的总类
  • 启动(Bootstrap)类加载器

    ​ 负责将 Java_Home/lib下面的类库(核心类)加载到内存中(比如rt.jar)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用(输出为null),所以不允许直接通过引用进行操作。该类加载器是最为顶层的加载器,其没有任何父类加载器,由C++编写。

  • 扩展类加载器

    ​ 是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将Java_Home /lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。由于扩展类加载器是由Java实现的,开发者可以直接使用标准扩展类加载器来加载Class文件。

  • 应用类加载器

    是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。它负责将系统类路径(CLASSPATH)中指定的类库加载到内存中。开发者可以直接使用系统类加载器。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,因此一般称为系统(System)加载器

  • 自定义类加载器

    自定义类加载器需要重写findClass()方法,实现类的加载。

    若要实现自定义类加载器,只需要继承java.lang.ClassLoader 类,并且重写其findClass()方法即可。java.lang.ClassLoader 类的基本职责就是根据一个指定的类的名称,找到或者生成其对应的字节代码,然后从这些字节代码中定义出一个 Java 类,即 java.lang.Class 类的一个实例。除此之外,ClassLoader 还负责加载 Java 应用所需的资源,如图像文件和配置文件等,ClassLoader 中与加载类相关的方法如下:

    方法说明

    • getParent() 返回该类加载器的父类加载器。
    • loadClass(String name) 加载名称为 二进制名称为name 的类,返回的结果是 java.lang.Class 类的实例。
    • findClass(String name) 查找名称为 name 的类,返回的结果是 java.lang.Class 类的实例。
    • findLoadedClass(String name) 查找名称为 name 的已经被加载过的类,返回的结果是 java.lang.Class 类的实例。
    • resolveClass(Class<?> c) 链接指定的 Java 类。

    注意:在JDK1.2之前,类加载尚未引入双亲委派模式,因此实现自定义类加载器时常常重写loadClass方法,提供双亲委派逻辑,从JDK1.2之后,双亲委派模式已经被引入到类加载体系中,自定义类加载器时不需要在自己写双亲委派的逻辑,因此不鼓励重写loadClass方法,而推荐重写findClass方法。


​ 它们之间的层次关系被称为类加载器的双亲委派模型。该模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器,而这种父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)。

​ 使用类加载器的loadClass()方法并不会导致类的主动初始化,它只是执行了加载过程中的加载阶段而已。

img

类加载器的双亲委派模型

双亲委派模型过程

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

使用好处:

​ 使用双亲委派模型的好处在于Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存在在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的Bootstrap ClassLoader进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object的同名类并放在ClassPath中,那系统中将会出现多个不同的Object类,程序将混乱。因此,如果开发者尝试编写一个与rt.jar类库中重名的Java类,可以正常编译,但是永远无法被加载运行。

双亲委派模型的系统实现

​ 在java.lang.ClassLoader的loadClass()方法中,先检查是否已经被加载过,若没有加载则调用父类加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父加载失败,则抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。

protected synchronized Class<?> loadClass(String name,boolean resolve)throws ClassNotFoundException{
    //check the class has been loaded or not
    Class c = findLoadedClass(name);
    if(c == null){
        try{
            if(parent != null){
                c = parent.loadClass(name,false);
            }else{
                c = findBootstrapClassOrNull(name);
            }
        }catch(ClassNotFoundException e){
            //if throws the exception ,the father can not complete the load
        }
        if(c == null){
            c = findClass(name);
        }
    }
    
    if(resolve){
        resolveClass(c);
    }
    return c;
}

3.2.2、如何保证加载类唯一

命名空间的概念:

  • 每一个类加载器都有各自的命名空间,命名空间是由该加载器及其所有父加载器所构成,因此在每一个加载器中同一个calss都是独一无二的。

  • 在同一个命名空间中,不会出现类的完整名字(全限定名)相同的两个类。

  • 在不同的命名空间下,有可能会出现类的完整名字(全限定名)相同的两个类。

得出命名空间的关系如下:(请结合下图一起看,想明白)

  • 同一个命名空间的类是相互可见的。
  • 子加载器的命名空间包含所有父加载器的命名空间。因此由子加载器加载的类能看见父加载器加载的类。例如系统类加载器能看见根类加载器加载的类。
  • 由父类加载器加载的类不能看见子加载器加载的类。
  • 如果两个加载器没有父子关系,那么他们自己加载的类互相不可见。

因为我们自己定义的类默认父类加载器是系统类加载器,因此可以看见父类加载器所加载的类。

img

类的唯一性
在运行期,一个类的唯一性是由以下2点共同决定:

  1. 该类的完全限定名(binary name)。
  2. 用于加载该类的[定义类加载器],即defining class loader。

在Java中,任意一个类都需要由加载它的类加载器和这个类本身一同确定其在java虚拟机中的唯一性,即比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提之下才有意义,否则,即使这两个类来源于同一个Class类文件,只要加载它的类加载器不相同,那么这两个类必定不相等(这里的相等包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法和instanceof关键字的结果)。

3.3、破坏双亲委派机制:

  • 可以⾃⼰定义⼀个类加载器,重写loadClass方法;

  • Tomcat 可以加载自己目录下的 class 文件,并不会传递给父类的加载器;

  • Java 的 SPI,发起者 BootstrapClassLoader 已经是最上层了,它直接获取了 AppClassLoader 进行驱动加载,和双亲委派是相反的。

3.3、破坏双亲委派模型

​ 类加载器双亲委派模型是从JDK1.2以后引入的,并且只是一种推荐的模型,不是强制要求的,因此有一些没有遵循双亲委派模型的特例。

​ Java模块化出现之前,双亲委派模型出现过三次较大规模**“被破坏”**的情况:

  1. 在JDK1.2之前,自定义类加载器都要覆盖loadClass方法去实现加载类的功能,JDK1.2引入双亲委派模型之后,loadClass方法用于委派父类加载器进行类加载,只有父类加载器无法完成类加载请求时才调用自己的findClass方法进行类加载,因此在JDK1.2之前的类加载的loadClass方法没有遵循双亲委派模型,因此在JDK1.2之后,自定义类加载器不推荐覆盖loadClass方法,而只需要覆盖findClass方法即可。
  2. 双亲委派模式很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的类加载器进行加载)
    1. 但是这个基础类统一有一个不足,当基础类想要调用回下层的用户代码时无法委派子类加载器进行类加载
    2. JNDI服务存在的目的是对资源进行查找和集中管理。如程序中的SPI接口,需要回调各个厂商实现并部署在SPI接口的代码。
    3. 为了解决这个问题JDK引入了ThreadContext线程上下文,通过线程上下文的setContextClassLoader方法可以设置线程上下文类加载器。
  3. 近年来的热码替换,模块热部署等应用要求不用重启java虚拟机就可以实现代码模块的即插即用,催生了OSGi技术,在OSGi中类加载器体系被发展为网状结构。OSGi也没有完全遵循双亲委派模型。
3.3、线程上下文类加载器

​ 严格的双亲委派机制导致在加载类的时候,存在一些局限性。当我们更加基础的框架需要用到应用层面的类的时候,只有当这个类是在我们当前框架使用的类加载器可以加载的情况下我们才能用到这些类。换句话说,我们不能使用当前类加载器的子加载器加载类。这个限制就是双亲委派机制导致的,因为类加载请求的委派是单向的。

虽然这种情况不多,但是还是会有这种需求。比较典型的,JNDI服务。JNDI提供了查询资源的接口,但是具体实现由不同的厂商实现。这个时候,JNDI的代码是由JVM的Bootstrap类加载器加载,但是具体的实现是用户提供的JDK之外的代码,所以只能由System类加载器或者其他用户自定义的类加载器去加载,在双亲委派的机制下,JNDI获取不到JNDI的SPI的实现。

为了解决这个问题,引入了线程上下文类加载器。通过java.lang.Thread类的setContextClassLoader()设置当前线程的上下文类加载器(如果没有设置,默认会从父线程中继承,如果程序没有设置过,则默认是System类加载器)。有了线程上下文类加载器,应用程序就可以通过java.lang.Thread.setContextClassLoader()将应用程序使用的类加载器传递给使用更顶层类加载器的代码。比如上面的JNDI服务,就可以利用这种方式获取到可以加载SPI实现的类加载器,获取需要的SPI实现类

img

3.4、OSGi热部署

OSGi实现热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉实现代码的热替换。

OSGI与SOA

通过前面的说明,我们可以发现,OSGI可以看成是一个服务发布规范,每个模块可以看成是一个服务包,模块可以进行注册、监听,模块间通过暴露服务进行联系,很显然,这是SOA的思想嘛。在OSGI RFC 119之前,OSGI只能运用于单体架构,与主要用于分布式架构的SOA有着本质的区别,RFC 119增加了分布式领域规范,这使得OSGI适用于实现SOA


OSGI现状

OSGI目前在国内只有为数不多的公司和项目有在使用,究其原因,还是它的弊端太大了。OSGI过于复杂,似乎每个程序员用过了都说不好,主要问题有以下几点:

(1)入门门槛高,OSGI规范多达几十个,并包含上千个API;

(2)增加系统不稳定性,由于OSGI类加载机制比较特别,经常会出现不明原因的ClassNotFoundException等异常;

(3)应用性不强,运用OSGI大部分是因为其“热插拔”和Jar隔离特性,但是,如果不是对动态性要求特别高的项目,引入OSGI似乎只是徒增麻烦。

目前,OSGI的应用更多的是因为其模块性和服务性,这与主流的微服务也是融合的,但是其复杂性使得它很难成为主流

3、tomcat的类加载机制

步骤:

  1. 先在本地cache查找该类是否已经加载过,看看 Tomcat 有没有加载过这个类。
  2. 如果Tomcat 没有加载过这个类,则从系统类加载器的cache中查找是否加载过。
  3. 如果没有加载过这个类,尝试用ExtClassLoader类加载器类加载,重点来了,这里并没有首先使用 AppClassLoader 来加载类。这个Tomcat 的 WebAPPClassLoader 违背了双亲委派机制,直接使用了 ExtClassLoader来加载类。这里注意 ExtClassLoader 双亲委派依然有效,ExtClassLoader 就会使用 Bootstrap ClassLoader 来对类进行加载,保证了 Jre 里面的核心类不会被重复加载。 比如在 Web 中加载一个 Object 类。WebAppClassLoader → ExtClassLoader → Bootstrap ClassLoader,这个加载链,就保证了 Object 不会被重复加载。
  4. 如果 BoostrapClassLoader,没有加载成功,就会调用自己的 findClass 方法由自己来对类进行加载,findClass 加载类的地址是自己本 web 应用下的 class。
  5. 加载依然失败,才使用 AppClassLoader 继续加载。
  6. 都没有加载成功的话,抛出异常。

总结一下以上步骤,WebAppClassLoader 加载类的时候,故意打破了JVM 双亲委派机制,绕开了 AppClassLoader,直接先使用 ExtClassLoader 来加载类。

4、Java 模块化系统

4.1、模块化系统简介

​ java模块化系统是JDK9引入的一个重要系统。在介绍Java模块化系统之前先简单介绍下在JDK9之前开发一个Java应用程序的大致过程

  1. 一般以Java类的形式编写程序,不同的Java类被安排在一个包(package)中。一个包是一个逻辑的类型集合,本质上为它包含的类型提供一个命名空间, 即使声明为public,包可能包含公共类型,私有类型和一些内部实现类型。

  2. 编译的代码被打包成一个或多个JAR文件,也称为应用程序JAR,因为它们包含应用程序代码, 一个程序包中的代码可能会引用多个JAR。

  3. 应用程序可能使用类库, 类库作为一个或多个JAR文件提供给应用程序使用。

  4. 将所有的JAR文件(应用程序JAR文件和JAR类库)放在类路径上来部署应用程序。

这一个开发过程存在的一些问题:

  1. 每次运行程序时,不管rt.jar中的类是否被classloader加载,都会加载整个rt.jar(Java基础类库)JVM启动的时候,一般会有30~60MB的内存加载(而模块化可以根据模块的需要加载程序运行 需要的class)

  2. 系统并没有对不同部分(也就是 JAR 文件)之间的依赖关系有个明确的概念。每一个公共类都可以被类路径之下任何其它的公共类所访问到,很难对代码进行封装,会导致无意中使用了并不想被公开访问的 API(应用程序接口)。 3.类路径本身也存在问题: 无法判断需要的jar文件是否重复了。

4.2、模块化系统解决的问题

针对之前Java程序开发流程中存在的问题,Java官方提出了模块化系统,虚拟机通过实现可配置的封装隔离机制来实现模块化系统。模块是代码和数据集合, 它可以包含Java代码和本地代码,用模块来管理各个java包,虚拟机通过实现可配置的封装隔离机制来实现模块化系统。模块化系统,可以解决以下问题;

  • 解决基于类路径(ClassPath)来查找依赖的可靠性问题

    此前,如果类路径中缺失了运行时依赖的类型,那就只能等程序运行到发生该类型的加载、链接 时才会报出运行的异常。而在JDK 9以后,如果启用了模块化进行封装,模块就可以声明对其他模块的显式依赖,这样Java虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备,如有缺失那就直接启动失败,从而避免了很大一部分由于类型依赖而引发的运行时异常。使得虚拟机有更可靠的配置。

  • 解决了跨JAR文件的public类型的可访问性问题

    JDK 9中 的public类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性控制,必须明确声明其中哪一些public的类型可以被其他哪一些模块访问,这种访问控制也主要是在类 加载过程中完成的。使得类具有更强大的封装。

  • 解决了rt.jar文件需要全部加载的问题。

4.3、模块化系统包含的内容

模块定义包含以下内容:

  1. 依赖其他模块的列表。

  2. 导出的软件包列表(其公共API),即其他模块可以使用的列表。

  3. 开放的软件包(其整个API,公共和私有)列表,即其他模块可反射访问模块的列表。

  4. 使用的服务列表(或使用java.util.ServiceLoader类发现和加载)

  5. 提供的服务的实现列表

其中反射访问,我的理解就是我们常用的ClassName.GetMethon的形式。

一个模块jar文件与一个普通的jar文件很相像,区别就是模块jar文件在根目录包含了一个modle-info.class文件,这个文件是用来存储模块信息的,位于 java 代码结构的顶层,模块定义了的需要什么依赖关系,以及哪些模块被外部使用等信息就是保存在这个文件中。在 exports 子句中未提及的所有包默认情况下将封装在模块中,不能在外部使用。

注意:与包的名字一样,模块的名字必须不能重复。

4.4、模块的兼容性
4.4.1、向后兼容性

​ 为了使使用传统的类路径的程序,升级到 JDK 9后对应用没有影响,即使可配置的封装隔离机制能够兼容传统的类路径查找机制,提出了“类路径”(ClassPath)相对应的“模块路径”(ModulePath)的概念:即某个类库到底是模块还是传统的JAR包只取决于它存放在哪种路径上。只要是放在类路径上的JAR文件,无论其中是否包含模块化信息(是否包含了module-info.class文件),它都会被当作传统的JAR包来对待;相应地,只要放在模块路径上的JAR文件,即使没有使用JMOD后缀,甚至说其中并不包含module-info.class文 件,它也仍然会被当作一个模块来对待。并提出了三条访问规则:

  • 类路径访问规则

    所有类路径下的JAR文件及其他资源文件,都被视为自动打包在一个匿名模块(Unnamed Module)里,这个匿名模块几乎是没有任何隔离的,它可以看到和使用类路径上所有的包、JDK系统模块中所有的导出包,以及模块路径上所有模块中导出的包

  • 模块访问规则

    模块路径下的具名模块(Named Module)只能访问到它依赖定义中列明依赖的模块和包,匿名模块里所有的内容对具名模块来说都是不可见的,即具名模块看不见传统JAR包的内容。

  • JAR文件在模块路径的访问规则

    如果把一个传统的、不包含模块定义的JAR文件放置到模块路 径中,它就会变成一个自动模块(Automatic Module)。尽管不包含module-info.class,但自动模块将默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自己所有的包。

Java模块化系统目前不支持在模块定义中加入版本号来管理和约 束依赖,本身也不支持多版本号的概念和版本选择功能。

4.4.2、模块之间的兼容性

​ 如果同一个模块发行了多个不同的版本,那只能由开发者在编译打包时人工选择好正确版本的模块来保证依赖的正确性。Java模块化系统目前不支持在模块定义中加入版本号来管理和约束依赖,本身也不支持多版本号的概念和版本选择功能。

​ 出现这一兼容性的原因是Oracle官方希望维持一个足够简单的模块化系统,避免技术过于复杂。

4.4.5、模块化下的类加载器

与双亲委派模型不同,模块化的类加载器在双亲委派模型上进行了一些改进。

1.扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代

2.平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader,现在启动类加载器、平台类加载器、应用程序类加载器全都继承于 jdk.internal.loader.BuiltinClassLoader。如果程序直接依赖了URLClassLoader类的特定方法,那代码很可能会在JDK 9及更高版本的JDK中崩溃。

img

在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,也许这可以算是对双亲委派的第四次破坏

线上故障排查

JVM性能监控工具

先简单介绍一下
top指令:查看当前所有进程的使用情况,CPU占有率,内存使用情况,服务器负载状态等参数。除此之外它还是个交互命令,使用可参考完全解读top
jps:与linux上的ps类似,用于查看有权访问的虚拟机的进程,可以查看本地运行着几个java程序,并显示他们的进程号。当未指定hostid时,默认查看本机jvm进程。
jinfo:可以输出并修改运行时的java 进程的一些参数。
jstat(常):可以用来监视jvm内存内的各种堆和非堆的大小及其内存使用量。
jstack(常):堆栈跟踪工具,一般用于查看某个进程包含线程的情况。
jmap(常):打印出某个java进程(使用pid)内存内的所有对象的情况。一般用于查看内存占用情况。
jconsole(常):一个java GUI监视工具,可以以图表化的形式显示各种数据。并可通过远程连接监视远程的服务器的jvm进程。

1、硬件故障排查

如果一个实例发生了问题,根据情况选择,要不要着急去重启。如果出现的CPU、内存飙高或者日志里出现了OOM异常

第一步是隔离,第二步是保留现场,第三步才是问题排查

隔离

就是把你的这台机器从请求列表里摘除,比如把 nginx 相关的权重设成零。

现场保留

瞬时态和历史态

img

查看比如 CPU、系统内存等,通过历史状态可以体现一个趋势性问题,而这些信息的获取一般依靠监控系统的协作。

保留信息

(1)系统当前网络连接

ss -antp > $DUMP_DIR/ss.dump 2>&1

使用 ss 命令而不是 netstat 的原因,是因为 netstat 在网络连接非常多的情况下,执行非常缓慢。

后续的处理,可通过查看各种网络连接状态的梳理,来排查 TIME_WAIT 或者 CLOSE_WAIT,或者其他连接过高的问题,非常有用。

(2)网络状态统计

netstat -s > $DUMP_DIR/netstat-s.dump 2>&1

它能够按照各个协议进行统计输出,对把握当时整个网络状态,有非常大的作用。

sar -n DEV 1 2 > $DUMP_DIR/sar-traffic.dump 2>&1

在一些速度非常高的模块上,比如 Redis、Kafka,就经常发生跑满网卡的情况。表现形式就是网络通信非常缓慢。

(3)进程资源

lsof -p $PID > $DUMP_DIR/lsof-$PID.dump

通过查看进程,能看到打开了哪些文件,可以以进程的维度来查看整个资源的使用情况,包括每条网络连接、每个打开的文件句柄。同时,也可以很容易的看到连接到了哪些服务器、使用了哪些资源。这个命令在资源非常多的情况下,输出稍慢,请耐心等待。

(4)CPU 资源

mpstat > $DUMP_DIR/mpstat.dump 2>&1
vmstat 1 3 > $DUMP_DIR/vmstat.dump 2>&1
sar -p ALL  > $DUMP_DIR/sar-cpu.dump  2>&1
uptime > $DUMP_DIR/uptime.dump 2>&1

主要用于输出当前系统的 CPU 和负载,便于事后排查。

(5)I/O 资源

iostat -x > $DUMP_DIR/iostat.dump 2>&1

一般,以计算为主的服务节点,I/O 资源会比较正常,但有时也会发生问题,比如日志输出过多,或者磁盘问题等。此命令可以输出每块磁盘的基本性能信息,用来排查 I/O 问题。在第 8 课时介绍的 GC 日志分磁盘问题,就可以使用这个命令去发现。

(6)内存问题

free -h > $DUMP_DIR/free.dump 2>&1

free 命令能够大体展现操作系统的内存概况,这是故障排查中一个非常重要的点,比如 SWAP 影响了 GC,SLAB 区挤占了 JVM 的内存。

(7)其他全局

ps -ef > $DUMP_DIR/ps.dump 2>&1
dmesg > $DUMP_DIR/dmesg.dump 2>&1
sysctl -a > $DUMP_DIR/sysctl.dump 2>&1

dmesg 是许多静悄悄死掉的服务留下的最后一点线索。当然,ps 作为执行频率最高的一个命令,由于内核的配置参数,会对系统和 JVM 产生影响,所以我们也输出了一份。

(8)进程快照,最后的遗言(jinfo)

${JDK_BIN}jinfo $PID > $DUMP_DIR/jinfo.dump 2>&1

此命令将输出 Java 的基本进程信息,包括环境变量和参数配置,可以查看是否因为一些错误的配置造成了 JVM 问题。

(9)dump 堆信息

${JDK_BIN}jstat -gcutil $PID > $DUMP_DIR/jstat-gcutil.dump 2>&1
${JDK_BIN}jstat -gccapacity $PID > $DUMP_DIR/jstat-gccapacity.dump 2>&1

jstat 将输出当前的 gc 信息。一般,基本能大体看出一个端倪,如果不能,可将借助 jmap 来进行分析。

(10)堆信息

${JDK_BIN}jmap $PID > $DUMP_DIR/jmap.dump 2>&1
${JDK_BIN}jmap -heap $PID > $DUMP_DIR/jmap-heap.dump 2>&1
${JDK_BIN}jmap -histo $PID > $DUMP_DIR/jmap-histo.dump 2>&1
${JDK_BIN}jmap -dump:format=b,file=$DUMP_DIR/heap.bin $PID > /dev/null  2>&1

jmap 将会得到当前 Java 进程的 dump 信息。如上所示,其实最有用的就是第 4 个命令,但是前面三个能够让你初步对系统概况进行大体判断。因为,第 4 个命令产生的文件,一般都非常的大。而且,需要下载下来,导入 MAT 这样的工具进行深入分析,才能获取结果。这是分析内存泄漏一个必经的过程。

(11)JVM 执行栈

${JDK_BIN}jstack $PID > $DUMP_DIR/jstack.dump 2>&1

jstack 将会获取当时的执行栈。一般会多次取值,我们这里取一次即可。这些信息非常有用,能够还原 Java 进程中的线程情况。

top -Hp $PID -b -n 1 -c >  $DUMP_DIR/top-$PID.dump 2>&1

为了能够得到更加精细的信息,我们使用 top 命令,来获取进程中所有线程的 CPU 信息,这样,就可以看到资源到底耗费在什么地方了。

(12)高级替补

kill -3 $PID

有时候,jstack 并不能够运行,有很多原因,比如 Java 进程几乎不响应了等之类的情况。我们会尝试向进程发送 kill -3 信号,这个信号将会打印 jstack 的 trace 信息到日志文件中,是 jstack 的一个替补方案。

gcore -o $DUMP_DIR/core $PID

对于 jmap 无法执行的问题,也有替补,那就是 GDB 组件中的 gcore,将会生成一个 core 文件。我们可以使用如下的命令去生成 dump:

${JDK_BIN}jhsdb jmap --exe ${JDK}java  --core $DUMP_DIR/core --binaryheap
  1. 内存泄漏的现象

稍微提一下 jmap 命令,它在 9 版本里被干掉了,取而代之的是 jhsdb,你可以像下面的命令一样使用。

jhsdb jmap  --heap --pid  37340
jhsdb jmap  --pid  37288
jhsdb jmap  --histo --pid  37340
jhsdb jmap  --binaryheap --pid  37340

一般内存溢出,表现形式就是 Old 区的占用持续上升,即使经过了多轮 GC 也没有明显改善。比如ThreadLocal里面的GC Roots,内存泄漏的根本就是,这些对象并没有切断和 GC Roots 的关系,可通过一些工具,能够看到它们的联系。

2、报表异常 | JVM调优

有一个报表系统,频繁发生内存溢出,在高峰期间使用时,还会频繁的发生拒绝服务,由于大多数使用者是管理员角色,所以很快就反馈到研发这里。

业务场景是由于有些结果集的字段不是太全,因此需要对结果集合进行循环,并通过 HttpClient 调用其他服务的接口进行数据填充。使用 Guava 做了 JVM 内缓存,但是响应时间依然很长。

初步排查,JVM 的资源太少。接口 A 每次进行报表计算时,都要涉及几百兆的内存,而且在内存里驻留很长时间,有些计算又非常耗 CPU,特别的“吃”资源。而我们分配给 JVM 的内存只有 3 GB,在多人访问这些接口的时候,内存就不够用了,进而发生了 OOM。在这种情况下,没办法,只有升级机器。把机器配置升级到 4C8G,给 JVM 分配 6GB 的内存,这样 OOM 问题就消失了。但随之而来的是频繁的 GC 问题和超长的 GC 时间,平均 GC 时间竟然有 5 秒多。

进一步,由于报表系统和高并发系统不太一样,它的对象,存活时长大得多,并不能仅仅通过增加年轻代来解决;而且,如果增加了年轻代,那么必然减少了老年代的大小,由于 CMS 的碎片和浮动垃圾问题,我们可用的空间就更少了。虽然服务能够满足目前的需求,但还有一些不太确定的风险。

第一,了解到程序中有很多缓存数据和静态统计数据,为了减少 MinorGC 的次数,通过分析 GC 日志打印的对象年龄分布,把 MaxTenuringThreshold 参数调整到了 3(特殊场景特殊的配置)。这个参数是让年轻代的这些对象,赶紧回到老年代去,不要老呆在年轻代里。

第二,我们的 GC 时间比较长,就一块开了参数 CMSScavengeBeforeRemark,使得在 CMS remark 前,先执行一次 Minor GC 将新生代清掉。同时配合上个参数,其效果还是比较好的,一方面,对象很快晋升到了老年代,另一方面,年轻代的对象在这种情况下是有限的,在整个 MajorGC 中占的时间也有限。

第三,由于缓存的使用,有大量的弱引用,拿一次长达 10 秒的 GC 来说。我们发现在 GC 日志里,处理 weak refs 的时间较长,达到了 4.5 秒。这里可以加入参数 ParallelRefProcEnabled 来并行处理Reference,以加快处理速度,缩短耗时。

优化之后,效果不错,但并不是特别明显。经过评估,针对高峰时期的情况进行调研,我们决定再次提升机器性能,改用 8core16g 的机器。但是,这带来另外一个问题。

高性能的机器带来了非常大的服务吞吐量,通过 jstat 进行监控,能够看到年轻代的分配速率明显提高,但随之而来的 MinorGC 时长却变的不可控,有时候会超过 1 秒。累积的请求造成了更加严重的后果。

这是由于堆空间明显加大造成的回收时间加长。为了获取较小的停顿时间,我们在堆上改用了 G1 垃圾回收器,把它的目标设定在 200ms。G1 是一款非常优秀的垃圾收集器,不仅适合堆内存大的应用,同时也简化了调优的工作。通过主要的参数初始和最大堆空间、以及最大容忍的 GC 暂停目标,就能得到不错的性能。修改之后,虽然 GC 更加频繁了一些,但是停顿时间都比较小,应用的运行较为平滑。

到目前为止,也只是勉强顶住了已有的业务,但是,这时候领导层面又发力,要求报表系统可以支持未来两年业务10到100倍的增长,并保持其可用性,但是这个“千疮百孔”的报表系统,稍微一压测,就宕机,那如何应对十倍百倍的压力呢 ? 硬件即使可以做到动态扩容,但是毕竟也有极限。

使用 MAT 分析堆快照,发现很多地方可以通过代码优化,那些占用内存特别多的对象:

1、select * 全量排查,只允许获取必须的数据

2、报表系统中cache实际的命中率并不高,将Guava 的 Cache 引用级别改成弱引用(WeakKeys)

3、限制报表导入文件大小,同时拆分用户超大范围查询导出请求。

每一步操作都使得JVM使用变得更加可用,一系列优化以后,机器相同压测数据性能提升了数倍。

3、大屏异常 | JUC调优

有些数据需要使用 HttpClient 来获取进行补全。提供数据的服务提供商有的响应时间可能会很长,也有可能会造成服务整体的阻塞。

img

接口 A 通过 HttpClient 访问服务 2,响应 100ms 后返回;接口 B 访问服务 3,耗时 2 秒。HttpClient 本身是有一个最大连接数限制的,如果服务 3 迟迟不返回,就会造成 HttpClient 的连接数达到上限,概括来讲,就是同一服务,由于一个耗时非常长的接口,进而引起了整体的服务不可用

这个时候,通过 jstack 打印栈信息,会发现大多数竟然阻塞在了接口 A 上,而不是耗时更长的接口 B,这个现象起初十分具有迷惑性,不过经过分析后,我们猜想其实是因为接口 A 的速度比较快,在问题发生点进入了更多的请求,它们全部都阻塞住的同时被打印出来了。

为了验证这个问题,我搭建了一个demo 工程,模拟了两个使用同一个 HttpClient 的接口。fast 接口用来访问百度,很快就能返回;slow 接口访问谷歌,由于众所周知的原因,会阻塞直到超时,大约 10 s。 利用ab对两个接口进行压测,同时使用 jstack 工具 dump 堆栈。首先使用 jps 命令找到进程号,然后把结果重定向到文件(可以参考 10271.jstack 文件)。

过滤一下 nio 关键字,可以查看 tomcat 相关的线程,足足有 200 个,这和 Spring Boot 默认的 maxThreads 个数不谋而合。更要命的是,有大多数线程,都处于 BLOCKED 状态,说明线程等待资源超时。通过grep fast | wc -l 分析,确实200个中有150个都是blocked的fast的进程。

问题找到了,解决方式就顺利成章了。

1、fast和slow争抢连接资源,通过线程池限流或者熔断处理

2、有时候slow的线程也不是一直slow,所以就得加入监控

3、使用带countdownLaunch对线程的执行顺序逻辑进行控制

4、接口延迟 | SWAP调优

有一个关于服务的某个实例,经常发生服务卡顿。由于服务的并发量是比较高的,每多停顿 1 秒钟,几万用户的请求就会感到延迟。

我们统计、类比了此服务其他实例的 CPU、内存、网络、I/O 资源,区别并不是很大,所以一度怀疑是机器硬件的问题。

接下来我们对比了节点的 GC 日志,发现无论是 Minor GC,还是 Major GC,这个节点所花费的时间,都比其他实例长得多。

通过仔细观察,我们发现在 GC 发生的时候,vmstat 的 si、so 飙升的非常严重,这和其他实例有着明显的不同。

使用 free 命令再次确认,发现 SWAP 分区,使用的比例非常高,引起的具体原因是什么呢?

更详细的操作系统内存分布,从 /proc/meminfo 文件中可以看到具体的逻辑内存块大小,有多达 40 项的内存信息,这些信息都可以通过遍历 /proc 目录的一些文件获取。我们注意到 slabtop 命令显示的有一些异常,dentry(目录高速缓冲)占用非常高。

问题最终定位到是由于某个运维工程师删除日志时,定时执行了一句命令:

find / | grep “xxx.log”

他是想找一个叫做 要被删除 的日志文件,看看在哪台服务器上,结果,这些老服务器由于文件太多,扫描后这些文件信息都缓存到了 slab 区上。而服务器开了 swap,操作系统发现物理内存占满后,并没有立即释放 cache,导致每次 GC 都要和硬盘打一次交道。

解决方式就是关闭 SWAP 分区。

swap 是很多性能场景的万恶之源,建议禁用。在高并发 SWAP 绝对能让你体验到它魔鬼性的一面:进程倒是死不了了,但 GC 时间长的却让人无法忍受。

5、内存溢出 | Cache调优

有一次线上遇到故障,重新启动后,使用 jstat 命令,发现 Old 区一直在增长。我使用 jmap 命令,导出了一份线上堆栈,然后使用 MAT 进行分析,通过对 GC Roots 的分析,发现了一个非常大的 HashMap 对象,这个原本是其他同事做缓存用的,但是做了一个无界缓存,没有设置超时时间或者 LRU 策略,在使用上又没有重写key类对象的hashcode和equals方法,对象无法取出也直接造成了堆内存占用一直上升,后来,将这个缓存改成 guava 的 Cache,并设置了弱引用,故障就消失了。

关于文件处理器的应用,在读取或者写入一些文件之后,由于发生了一些异常,close 方法又没有放在 finally 块里面,造成了文件句柄的泄漏。由于文件处理十分频繁,产生了严重的内存泄漏问题。

内存溢出是一个结果,而内存泄漏是一个原因。内存溢出的原因有内存空间不足、配置错误等因素。一些错误的编程方式,不再被使用的对象、没有被回收、没有及时切断与 GC Roots 的联系,这就是内存泄漏。

举个例子,有团队使用了 HashMap 做缓存,但是并没有设置超时时间或者 LRU 策略,造成了放入 Map 对象的数据越来越多,而产生了内存泄漏。

再来看一个经常发生的内存泄漏的例子,也是由于 HashMap 产生的。代码如下,由于没有重写 Key 类的 hashCode 和 equals 方法,造成了放入 HashMap 的所有对象都无法被取出来,它们和外界失联了。所以下面的代码结果是 null。

//leak example
import java.util.HashMap;
import java.util.Map;
public class HashMapLeakDemo {
    public static class Key {
        String title;
    public Key(String title) {
        this.title = title;
    }
}

public static void main(String[] args) {
    Map<Key, Integer> map = new HashMap<>();
    map.put(new Key("1"), 1);
    map.put(new Key("2"), 2);
    map.put(new Key("3"), 2);
    Integer integer = map.get(new Key("2"));
    System.out.println(integer);
    }
}

即使提供了 equals 方法和 hashCode 方法,也要非常小心,尽量避免使用自定义的对象作为 Key。

再看一个例子,关于文件处理器的应用,在读取或者写入一些文件之后,由于发生了一些异常,close 方法又没有放在 finally 块里面,造成了文件句柄的泄漏。由于文件处理十分频繁,产生了严重的内存泄漏问题。

6:CPU飙高 | 死循环

我们有个线上应用,单节点在运行一段时间后,CPU 的使用会飙升,一旦飙升,一般怀疑某个业务逻辑的计算量太大,或者是触发了死循环(比如著名的 HashMap 高并发引起的死循环),但排查到最后其实是 GC 的问题。

(1)使用 top 命令,查找到使用 CPU 最多的某个进程,记录它的 pid。使用 Shift + P 快捷键可以按 CPU 的使用率进行排序。

top

(2)再次使用 top 命令,加 -H 参数,查看某个进程中使用 CPU 最多的某个线程,记录线程的 ID。

top -Hp $pid

(3)使用 printf 函数,将十进制的 tid 转化成十六进制。

printf %x $tid

(4)使用 jstack 命令,查看 Java 进程的线程栈。

jstack $pid >$pid.log

(5)使用 less 命令查看生成的文件,并查找刚才转化的十六进制 tid,找到发生问题的线程上下文。

less $pid.log

我们在 jstack 日志搜关键字DEAD,以及中找到了 CPU 使用最多的几个线程id。

可以看到问题发生的根源,是我们的堆已经满了,但是又没有发生 OOM,于是 GC 进程就一直在那里回收,回收的效果又非常一般,造成 CPU 升高应用假死。接下来的具体问题排查,就需要把内存 dump 一份下来,使用 MAT 等工具分析具体原因了。

六、分析Java堆

七、锁和并发

八、Class文件结构

九、Class装载系统

十、JVM常用参数

C20210620000498 + 任英杰 + 公共开发部 + 软件开发工程师 +

其他:

6、JVM性能调优

对应进程的JVM状态以定位问题和解决问题并作出相应的优化

**常用命令:**jps、jinfo、jstat、jstack、jmap

jps:查看java进程及相关信息

jps -l 输出jar包路径,类全名
jps -m 输出main参数
jps -v 输出JVM参数

jinfo:查看JVM参数

jinfo 11666
jinfo -flags 11666
Xmx、Xms、Xmn、MetaspaceSize

jstat:查看JVM运行时的状态信息,包括内存状态、垃圾回收

jstat [option] LVMID [interval] [count]
其中LVMID是进程id,interval是打印间隔时间(毫秒),count是打印次数(默认一直打印)
  
option参数解释:
-gc 垃圾回收堆的行为统计
-gccapacity 各个垃圾回收代容量(young,old,perm)和他们相应的空间统计
-gcutil 垃圾回收统计概述
-gcnew 新生代行为统计
-gcold 年老代和永生代行为统计

jstack:查看JVM线程快照,jstack命令可以定位线程出现长时间卡顿的原因,例如死锁,死循环

jstack [-l] <pid> (连接运行中的进程)
  
option参数解释:
-F 当使用jstack <pid>无响应时,强制输出线程堆栈。
-m 同时输出java和本地堆栈(混合模式)
-l 额外显示锁信息

jmap:可以用来查看内存信息(配合jhat使用)

jmap [option] <pid> (连接正在执行的进程)

option参数解释:
-heap 打印java heap摘要
-dump:<dump-options> 生成java堆的dump文件

7、JDK新特性

JDK8

支持 Lamda 表达式、集合的 stream 操作、提升HashMap性能

JDK9

//Stream API中iterate方法的新重载方法,可以指定什么时候结束迭代
IntStream.iterate(1, i -> i < 100, i -> i + 1).forEach(System.out::println);

默认G1垃圾回收器

JDK10

其重点在于通过完全GC并行来改善G1最坏情况的等待时间。

JDK11

ZGC (并发回收的策略) 4TB

用于 Lambda 参数的局部变量语法

JDK12

Shenandoah GC (GC 算法)停顿时间和堆的大小没有任何关系,并行关注停顿响应时间。

JDK13

增加ZGC以将未使用的堆内存返回给操作系统,16TB

JDK14

删除cms垃圾回收器、弃用ParallelScavenge+SerialOldGC垃圾回收算法组合

将ZGC垃圾回收器应用到macOS和windows平台
,记录线程的 ID。

top -Hp $pid

(3)使用 printf 函数,将十进制的 tid 转化成十六进制。

printf %x $tid

(4)使用 jstack 命令,查看 Java 进程的线程栈。

jstack $pid >$pid.log

(5)使用 less 命令查看生成的文件,并查找刚才转化的十六进制 tid,找到发生问题的线程上下文。

less $pid.log

我们在 jstack 日志搜关键字DEAD,以及中找到了 CPU 使用最多的几个线程id。

可以看到问题发生的根源,是我们的堆已经满了,但是又没有发生 OOM,于是 GC 进程就一直在那里回收,回收的效果又非常一般,造成 CPU 升高应用假死。接下来的具体问题排查,就需要把内存 dump 一份下来,使用 MAT 等工具分析具体原因了。

六、分析Java堆

七、锁和并发

八、Class文件结构

九、Class装载系统

十、JVM常用参数

C20210620000498 + 任英杰 + 公共开发部 + 软件开发工程师 +

其他:

6、JVM性能调优

对应进程的JVM状态以定位问题和解决问题并作出相应的优化

**常用命令:**jps、jinfo、jstat、jstack、jmap

jps:查看java进程及相关信息

jps -l 输出jar包路径,类全名
jps -m 输出main参数
jps -v 输出JVM参数

jinfo:查看JVM参数

jinfo 11666
jinfo -flags 11666
Xmx、Xms、Xmn、MetaspaceSize

jstat:查看JVM运行时的状态信息,包括内存状态、垃圾回收

jstat [option] LVMID [interval] [count]
其中LVMID是进程id,interval是打印间隔时间(毫秒),count是打印次数(默认一直打印)
  
option参数解释:
-gc 垃圾回收堆的行为统计
-gccapacity 各个垃圾回收代容量(young,old,perm)和他们相应的空间统计
-gcutil 垃圾回收统计概述
-gcnew 新生代行为统计
-gcold 年老代和永生代行为统计

jstack:查看JVM线程快照,jstack命令可以定位线程出现长时间卡顿的原因,例如死锁,死循环

jstack [-l] <pid> (连接运行中的进程)
  
option参数解释:
-F 当使用jstack <pid>无响应时,强制输出线程堆栈。
-m 同时输出java和本地堆栈(混合模式)
-l 额外显示锁信息

jmap:可以用来查看内存信息(配合jhat使用)

jmap [option] <pid> (连接正在执行的进程)

option参数解释:
-heap 打印java heap摘要
-dump:<dump-options> 生成java堆的dump文件

7、JDK新特性

JDK8

支持 Lamda 表达式、集合的 stream 操作、提升HashMap性能

JDK9

//Stream API中iterate方法的新重载方法,可以指定什么时候结束迭代
IntStream.iterate(1, i -> i < 100, i -> i + 1).forEach(System.out::println);

默认G1垃圾回收器

JDK10

其重点在于通过完全GC并行来改善G1最坏情况的等待时间。

JDK11

ZGC (并发回收的策略) 4TB

用于 Lambda 参数的局部变量语法

JDK12

Shenandoah GC (GC 算法)停顿时间和堆的大小没有任何关系,并行关注停顿响应时间。

JDK13

增加ZGC以将未使用的堆内存返回给操作系统,16TB

JDK14

删除cms垃圾回收器、弃用ParallelScavenge+SerialOldGC垃圾回收算法组合

将ZGC垃圾回收器应用到macOS和windows平台

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值