JVM的前世今生

前言

本篇内容大部分均来自周志明老师的《深入理解Java虚拟机》与网络上的资料。JVM 看这篇就差不多了。

一、跨平台

刚学Java的时候,听得最多的就是Java是跨平台的,编写一次到处运行(Write Once,Run Anywhere)。

  • 平台:Windows、Mac、Linux等平台系统
  • Java跨平台指的是:编译过后的 .class文件(字节码文件)可以跨平台。
  • Java编译的结果是字节码文件,而其他语言如C/C++编译过后的是机器码文件,机器码文件可以直接运行,但是字节码文件还要经过JVM翻译一遍成为机器码文件才能被运行。
  • 不同的平台有不同的JVM,同一.class文件就会编译成不同的机器码文件。
  • 重点结论:就是不同的平台他的机器码文件不同。

二、Java历史

Java 1995年5月推出至今,已有几十年的历史了呢。

1、Java体系

从广义上讲,Kotlin、Clojure、JRuby、Groovy等运行于Java虚拟机上的编程语言及其相关的程序,都属于Java技术体系中的一员。
如果仅从传统意义上来看,JCP官方(Java社区:Java Community Process)所定义的Java技术体系包括了以 下几个组成部分:

  • Java程序设计语言
  • 各种硬件平台上的Java虚拟机实现
  • Java类库API
  • Class文件格式
  • 来自商业机构和开源社区的第三方Java类库


我们可以把Java程序设计语言、Java虚拟机、Java类库这三部分统称为JDK(Java Development Kit),JDK是用于支持Java程序开发的最小环境。

2、Java发展史

1991年4月,由James Gosling博士领导的绿色计划(Green Project)开始启动,此计划最初的目标 是开发一种能够在各种消费性电子产品(如机顶盒、冰箱、收音机等)上运行的程序架构。
这个计划 的产品就是Java语言的前身:Oak(得名于James Gosling办公室外的一棵橡树)。Oak当时在消费品市 场上并不算成功,但随着1995年互联网潮流的兴起,Oak迅速找到了最适合自己发展的市场定位并蜕 变成为Java语言。

  • 1995年5月23日,Oak语言改名为Java。在申请注册商标时,发现Oak已经被人使用了,再想了一系列名字之后,最终,使用了提议者在喝一杯Java咖啡时无意提到的Java词语。
  • 1996年1月23日,JDK 1.0发布,Java语言有了第一个正式版本的运行环境。JDK 1.0提供了一个纯解释执行的Java虚拟机实现(Sun Classic VM)。JDK1.0版本的代表技术包括:Java虚拟机、Applet、 AWT等。
  • 在1996年5月底,Sun于美国旧金山举行了首届JavaOne大 会,从此JavaOne成为全世界数百万Java语言开发者每年一度的技术盛会。
  • 1997年2月19日,Sun公司发布了JDK 1.1。JDK 1.1版的技术代表有:JAR文件格式、JDBC、JavaBeans、RMI等。Java语言的语法也有了一定的增强,如内部类(Inner Class)和反射(Reflection)都是在这时候出现的。
  • 直到1999年4月8日,JDK 1.1一共发布了1.1.0至1.1.8这9个版本。
  • 1998年12月4日,JDK迎来了一个里程碑式的重要版本JDK 1.2,Sun在这个版本中把Java技术体系拆分为三个方向:面向桌面应用开发的J2SE、面向企业级开发的J2EE、面向手机等移动终端开发的J2ME。并且这个版本中Java虚拟机第一次内置了JIT(Just In Time)即时编译器Classic VM。
  • 1999年4月27日,HotSpot虚拟机诞生。HotSpot最初由一家名为“Longview Techno-logies”的小公司 开发,由于HotSpot的优异表现,这家公司在1997年被Sun公司收购。
  • 2000年5月8日,工程代号为Kestrel(美洲红隼)的JDK 1.3发布。HotSpot为默认Java虚拟机。
  • 2002年2月13日,JDK 1.4发布,工程代号为Merlin(灰背隼),同一年微软的.NET Framework发布
  • 2004年9月30日,JDK 5发布,工程代号为Tiger(老虎)。Sun公司从这个版本开始放弃了谦逊 的“JDK 1.x”的命名方式,将产品版本号修改成了“JDK x”。这个版本改进了Java的内存模型(Java Memory Model,JMM)、提供了java.util.concurrent并发包等。
  • 2005年6月,Sun公司发布了Java SE 6。在这个版本中,Sun公司终结了从 JDK 1.2开始已经有八年历史的J2EE、J2SE、J2ME的产品线命名方式,启用Java EE 6、Java SE 6、Java ME 6的新命名来代替。
  • 在2006年11月13日的JavaOne大会上,Sun公司宣布计划要把Java开源,在随后的一年多时间内, 它陆续地将JDK的各个部分在GPLv2(GNU General Public License v2)协议下公开了源码,并建立了 OpenJDK组织对这些源码进行独立管理。
  • 从2007年3月起,全世界所有的开发人员均可对Java源代码进行修改
  • 2009年4月20日,Oracle宣布正式以74亿美元的价格收购市值曾超过2000亿美元的Sun公司,传奇 的Sun Microsystems从此落幕成为历史.
  • 2011年7月28日,Oracle公司发布JDK 7
  • 2014年3月18日,Oracle公司发表JDK 8,技术代表:对Lambda表达式的支持、内置Nashorn JavaScript引擎的支持、新的时间、日期API、彻底移除HotSpot的永久代
  • 2017年9月21日,Oracle公司发表JDK 9
  • 2018年3月21日,Oracle公司发表JDK 10
  • 2018年9月25日,JDK 11发布,发行了OpenJDK 11和OracleJDK 11两个版本。这两个JDK共享绝大部分源码, 在功能上是几乎一样的,核心差异是前者可以免费在开发、测试或生产环境中使用,但是只有半年时间的更新支持;后者个人依然可以免费使用,但若在生产环境中商用就必须付费,可以有三年时间的更新支持。
  • …… JDK8以上的没用过,

3、JVM(虚拟机)历史

  • 1、Classic VM
    1996年1月23日,Sun发布JDK 1.0,Java语言首次拥有了商用的正式运行环境,这个JDK中所带的 虚拟机就是Classic VM。
    这款虚拟机只能使用纯解释器方式来执行Java代码,如果要使用即时编译器那就必须进行外挂,但是假如外挂了即时编译器的话,即时编译器就会完全接管虚拟机的执行系统,解释器便不能再工作了。
  • 2、Exact VM
    Exact Memory Management :准确式内存管理是指虚拟机可以知道内存中某个位置的数据具体是什么类型。
    使用短暂,该虚拟机还没怎么开始就被HotSpot替换了
  • 3、HotSpot VM
    它是Sun/OracleJDK和OpenJDK中的默认Java虚拟机,也是目前使用范围最广的Java虚拟机。但不一定所有人都知道的是,这个在今天看起来“血统纯 正”的虚拟机在最初并非由Sun公司所开发,而是由一家名为“Longview Technologies”的小公司设计;
    甚至这个虚拟机最初并非是为Java语言而研发的,它来源于Strongtalk虚拟机,而这款虚拟机中相当多的技术又是来源于一款为支持Self语言实现“达到C语言50%以上的执行效率”的目标而设计的Self虚拟机, 最终甚至可以追溯到20世纪80年代中期开发的Berkeley Smalltalk上。
    Sun公司注意到这款虚拟机在即时 编译等多个方面有着优秀的理念和实际成果,在1997年收购了Longview Technologies公司,从而获得了 HotSpot虚拟机。Oracle收购Sun以后,建立了HotRockit项目来把原来BEA JRockit中的优秀特性融合到 HotSpot之中。
    到了2014年的JDK 8时期,里面的HotSpot就已是两者融合的结果,HotSpot在这个过程 里移除掉永久代,吸收了JRockit的Java Mission Control监控工具等功能。
    HotSpot虚拟机中含有两个即时编译器,分别是编译耗时短但输出代码优化程度较低的客户端编译 器(简称为C1)以及编译耗时长但输出代码优化质量也更高的服务端编译器(简称为C2)
  • 4、JRockit VM
    是BEA System公司的JRockit,它是BEA在2002年从Appeal Virtual Machines公司收购获得的Java虚拟机。
    专注于服务器端应用:可以不关注启动速度,JRockit内部不包含解析器实现,全部代码靠即时编译器编译后执行.
    Oracle在JDK 8中大致整合两大优秀虚拟机,在HotSpot的基础上移植JRockit的优秀特性
  • 5、Graal VM
    自JDK 10起,HotSpot中又加入了一个全新的即时编译器:Graal编译器。今天的Graal编译器尚且年幼,还未经过足够多的实践验证,所以仍然带着“实验状态”的标签,需要用开关参数去激活。
    Graal VM在HotSpot VM基础上增强成跨语言全栈虚拟机,可以作为“任何语言”的运行平台使用。之前已经集成的 Java、Scala、Groovy、Kotlin;C、C++、JavaScript、Ruby、Python、R等.
    Graal VM的基本工作原理是将这些语言的源代码(例如JavaScript)或源代码编译后的中间格式 (例如LLVM字节码)通过解释器转换为能被Graal VM接受的中间表示(Intermediate Representation, IR),
    譬如设计一个解释器专门对LLVM输出的字节码进行转换来支持C和C++语言,这个过程称为程 序特化(Specialized,也常被称为Partial Evaluation)。
    Graal VM提供了Truffle工具集来快速构建面向一种新语言的解释器,并用它构建了一个称为Sulong的高性能LLVM字节码解释器。

三、JVM结构

用过Java的都知道,Java不像C++等语言需要我们手动释放内存,而把内存交给了JVM管理了。那JVM是怎么使用内存的呢?

1、Java运行流程图

1.1、JDK7

JDK7的结构如下:

由图可以看出堆和方法区连在了一起,但这并不能说堆和方法区是一起的,它们在逻辑上依旧是分开的。但在物理上来说,它们又是连续的一块内存。
而堆的组成架构大致如下:

  • 新生代
    • eden
    • From survivor
    • To survivor
  • 老年代

如图:

也就是说,方法区和Eden和老年代是连续的。对于习惯了在HotSpot虚拟机上开发、部署的程序员来说,很多都愿意将方法区称作永久代。
本质上来讲两者并不等价,仅因为Hotspot将GC分代扩展至方法区,或者说使用永久代来实现方法区。在其他虚拟机上是没有永久代的概念的。也就是说方法区是规范,永久代是Hotspot针对该规范进行的实现。
同时,永久代和堆是相互隔离的,但它们使用的物理内存是连续的。永久代的垃圾收集是和老年代捆绑在一起的,因此无论谁满了,都会触发永久代和老年代的垃圾收集。
在了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出。(感觉是准备移植JRockit优秀特性而做的准备,因为两者对方法区实现的不一样)

1.2、JDK8

在JDK8中,时代变了,Hotspot取消了永久代。永久代真的成了永久的记忆。永久代的参数-XX:PermSize和-XX:MaxPermSize也随之失效。
虽然取消了永久代,但是方法区还在,因为方法区是虚拟机的一种规范。
Jdk8结构图如下:

元空间(MetaSpace)存在于本地内存,意味着只要本地内存足够,它不会出现像永久代中“java.lang.OutOfMemoryError: PermGen space”这种错误。
默认情况下元空间是可以无限使用本地内存的。永久代的移除并不意味者类加载器泄露的问题就没有了。

1.3、元空间特点
  • 每个加载器有专门的存储空间。
  • 不会单独回收某个类。
  • 元空间里的对象的位置是固定的。
  • 如果发现某个加载器不再存活了,会把相关的空间整个回收
1.4 永久代为什么被替换了?
  • 字符串存在永久代中,容易出现性能问题和内存溢出
  • 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
  • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低

JRockit VM没用永久代,会不会因为Hotspot移植JRockit的优秀特性所以去掉了

1.5、小结

JDK8 同 JDK7 比,最大的差别就是:元数据区取代了永久代。元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。
不过元空间与永久代之间最大的区别在于:元数据空间并不在虚拟机中,而是使用本地内存。

2、内存结构

Java 虚拟机的内存空间分为 5 个部分:

  • 程序计数器(PC 寄存器)
  • Java虚拟机栈
  • 本地方法栈
  • 方法区

2.1、程序计数器(PC 寄存器)
  • 定义
    程序计数器是一块较小的内存空间,是当前线程正在执行的那条字节码指令的地址,它可以看作是当前线程所执行的字节码的行号指示器。
    在Java虚拟机的概念模型里[1],字节码解释器工作时就是通过改变这个计数器 的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处 理、线程恢复等基础功能都需要依赖这个计数器来完成。
    由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程 之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
    如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。
  • 特点
    是一块较小的内存空间。
    线程私有,每条线程都有自己的程序计数器。
    生命周期:随着线程的创建而创建,随着线程的结束而销毁
    是唯一一个不会出现OutOfMemoryError的内存区域
2.2、Java 虚拟机栈(Java 栈)

Java 虚拟机栈是描述 Java 方法运行过程的内存模型。
Java 虚拟机栈会为每一个即将运行的 Java 方法创建一块叫做“栈帧”的区域,用于存放该方法运行过程中的一些信息,如:

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 方法出口信息

  • 每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

    当方法运行过程中需要创建局部变量时,就将局部变量的值存入栈帧中的局部变量表中。
    Java 虚拟机栈的栈顶的栈帧是当前正在执行的活动栈,也就是当前正在执行的方法,PC 寄存器也会指向这个地址。只有这个活动的栈帧的本地变量可以被操作数栈使用,当在这个栈帧中调用另一个方法,与之对应的栈帧又会被创建,新创建的栈帧压入栈顶,变为当前的活动栈帧。
    方法结束后,当前栈帧被移出,栈帧的返回值变成新的活动栈帧中操作数栈的一个操作数。如果没有返回值,那么新的活动栈帧中操作数栈的操作数没有变化。

    由于Java 虚拟机栈是与线程对应的,数据不是线程共享的,因此不用关心数据一致性问题,也不会存在同步锁的问题。

栈的特点:

  • 局部变量表随着栈帧的创建而创建,它的大小在编译时确定,创建时只需分配事先规定的大小即可。在方法运行过程中,局部变量表的大小不会发生改变
  • Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
    StackOverFlowError 若 Java 虚拟机栈的大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度时,抛出 StackOverFlowError 异常。
    OutOfMemoryError 若允许动态扩展,那么当线程请求栈时内存用完了,无法再动态扩展时,抛出 OutOfMemoryError 异常。
  • Java 虚拟机栈也是线程私有,随着线程创建而创建,随着线程的结束而销毁。
2.3、本地方法栈(C 栈)
  • 定义
    本地方法栈是为 JVM 运行 Native 方法准备的空间,由于很多 Native 方法都是用 C 语言实现的,所以它通常又叫 C 栈。它与 Java 虚拟机栈实现的功能类似,只不过本地方法栈是描述本地方法运行过程的内存模型。
  • 栈帧变化过程
    本地方法被执行时,在本地方法栈也会创建一块栈帧,用于存放该方法的局部变量表、操作数栈、动态链接、方法出口信息等。
    方法执行结束后,相应的栈帧也会出栈,并释放内存空间。也会抛出 StackOverFlowErrorOutOfMemoryError 异常。
2.4、堆

定义
堆是用来存放对象的内存空间,几乎所有的对象都存储在堆中。Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”。

堆的特点:

  • 线程共享,整个 Java 虚拟机只有一个堆,所有的线程都访问同一个堆。而程序计数器、Java 虚拟机栈、本地方法栈都是一个线程对应一个。
  • 在虚拟机启动时创建。
  • 是垃圾回收的主要场所。

由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中进一步可划分为:新生代(Eden区 From Survior To Survivor)、老年代。
这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》里对Java堆的进一步细致划分。
Java 堆所使用的内存不需要保证是连续的。而由于堆是被所有线程共享的,所以对它的访问需要注意同步问题,方法和对应的属性都需要保证一致性

2.5、本地内存之方法区

定义
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载 的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
虽然《Java虚拟机规范》中把 方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区 分开来。

在JDK 8以前,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation).
本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已.

方法区的特点

  • 线程共享。 方法区是堆的一个逻辑部分,因此和堆一样,都是线程共享的。整个虚拟机中只有一个方法区。
  • 内存回收效率低。 方法区中的信息一般需要长期存在,回收一遍之后可能只有少量信息无效。主要回收目标是:对常量池的回收;对类型的卸载。
  • 《Java 虚拟机规范》对方法区的要求比较宽松。 和堆一样,允许固定大小,也允许动态扩展,还允许不实现垃圾回收。

运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字 段、方法、接口等描述信息外,
还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
方法区中存放:类信息、常量、静态变量、即时编译器编译后的代码。常量就存放在运行时常量池中。
.class文件的常量池,也可以理解为一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型,字面量等信息
当类被 Java 虚拟机加载后.class 文件中的常量就存放在方法区的运行时常量池中。而且在运行期间,可以向常量池中添加新的常量。如 String 类的 intern() 方法就能在运行期间向常量池中添加字符串常量。

在JDK1.7之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代
在JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区
在JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中

2.6、本地内存之直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现.

操作直接内存
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区 (Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块内存的引用进行操作。
这样能在一些场景中显著提高性能,因为避免了 在Java堆和Native堆中来回复制数据。

直接内存的大小不受 Java 虚拟机控制,但既然是内存,当内存不足时就会抛出 OutOfMemoryError 异常。

直接内存与堆内存比较

  • 直接内存申请空间耗费更高的性能
  • 直接内存读取 IO 的性能要优于普通的堆内存。
  • 直接内存作用链: 本地 IO -> 直接内存 -> 本地 IO
  • 堆内存作用链:本地 IO -> 直接内存 -> 非直接内存 -> 直接内存 -> 本地 IO

3、垃圾收集器

简单的说垃圾回收就是回收内存中不再使用的对象。回收的步骤:

  • 查找内存中不再使用的对象
  • 释放这些对象占用的内存
3.1、判断对象存活

如何判断哪些对象不再使用?有两种方法:

  • 引用计数法
    每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。这种方法的缺点就是无法解决对象相互循环引用的问题。
     public   class TestObject { 
    public Object instance = null;
    static final int _1MB = 1024 * 1024; /*** 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过 */
    private byte[] bigSize = new byte[2 * _1MB];

    public void testGC() {
    TestObject objA = new TestObject();
    TestObject objB = new TestObject();
    objA.instance = objB;
    objB.instance = objA;
    objA = null;
    objB = null;
    // 假设在这行发生GC,因为对象的instance 相互引用,所以用计数法是不会被回收的
    System.gc();
    }
    }

比如上面的代码,调用testGC()方法,假设用的是引用计数法是不会被垃圾回收的。

  • 可达性分析(根搜索算法)
    基本思路就是通过一系列名为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
    在Java语言中,GC Roots包括:
    • 虚拟机栈(栈帧中的本地变量表)中引用的对象
    • 方法去中类静态属性引用的对象
    • 方法区中常量引用的对象
    • 本地方法栈中JNI(即一般说的Native方法)引用的对象
    • 活跃线程的引用对象
      即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓 刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没 有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是 否有必要执行finalize()方法。
      假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用 过,那么虚拟机将这两种情况都视为“没有必要执行”。如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的 队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize() 方法。
public class TestGC {
public static TestGC SAVE_HOOK = null;

public void isAlive() {
System.out.println("oh Yeah ,我还活着!!!");
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize()方法执行");
TestGC.SAVE_HOOK = this;
}

public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new TestGC();
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("哎,最终还是死了!!!");
}

// 下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
Thread.sleep(500);
System.out.println("=====第二次调用了====");
System.gc();
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("哎,最终还是死了!!!");
}
}
}

/*
打印结果:

finalize()方法执行
oh Yeah ,我还活着!!!
=====第二次调用了====
哎,最终还是死了!!!

*/

第二次死了,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临 下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。

3.2、垃圾回收算法

既然已经知道了垃圾对象,该如何回收呢?在JDK8之前的垃圾收集器,大多数都遵循了“分代收集”的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  • 弱分代假说
    绝大多数对象都是朝生夕死的。
  • 强分代假说
    熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分 出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
显而易见,如果一个区域中大多数对象都是朝生夕死,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;
如果剩下的都是难以消亡的对象,那把它们集中放在一块, 虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域 ——因而才有了“Minor GC”(新生代回收)“Major GC”(老年代回收)“Full GC”这样的回收类型的划分。
因而发展出了“标记-复制算法”“标记-清除算 法”“标记-整理算法”等针对性的垃圾收集算法。

1、标记-清除算法
最基础的收集算法是“标记-清除”(Mark-Sweep)算法,分两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

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

2、复制算法
为了解决效率问题,一种称为复制(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另外一块上,然后再把已经使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。代价是内存缩小为原来的一半。

商业虚拟机用这个回收算法来回收新生代。IBM研究表明98%的对象是“朝生夕死“,不需要按照1-1的比例来划分内存空间,而是将内存分为一块较大的”Eden“空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活的对象一次性复制到另外一个Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。Hotspot虚拟机默认Eden和Survivor的比例是8-1.即每次可用整个新生代的90%, 只有一个survivor,即1/10被”浪费“。当然,98%的对象回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion).
如果另外一块survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

Eden survivor复制过程概述
Eden Space字面意思是伊甸园,对象被创建的时候首先放到这个区域,进行垃圾回收后,不能被回收的对象被放入到空的survivor区域。
Survivor Space幸存者区,用于保存在eden space内存区域中经过垃圾回收后没有被回收的对象。Survivor有两个,分别为To Survivor、 From Survivor,这个两个区域的空间大小是一样的。执行垃圾回收的时候Eden区域不能被回收的对象被放入到空的survivor(也就是To Survivor,同时Eden区域的内存会在垃圾回收的过程中全部释放),另一个survivor(即From Survivor)里不能被回收的对象也会被放入这个survivor(即To Survivor),然后To Survivor 和 From Survivor的标记会互换,始终保证一个survivor是空的。
为啥需要两个survivor?因为需要一个完整的空间来复制过来。当满的时候晋升。每次都往标记为to的里面放,然后互换,这时from已经被清空,可以当作to了。

3、标记-整理算法
有标记-整理算法些文章也叫标记-压缩算法。
复制收集算法在对象成活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以,老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出一种”标记-整理“Mark-Compact算法,标记过程仍然和标记-清除一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理端边界以外的内存.
图片如下:

3.3、垃圾收集器

如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现

  • 1、Serial 收集器
    标记-复制
    Serial收集器是最基础、历史最悠久的收集器,曾经(在JDK 1.3.1之前)是HotSpot虚拟机新生代 收集器的唯一选择,Serial 翻译为串行,也就是说它以串行的方式执行。
    它是单线程的收集器,只会使用一个线程进行垃圾收集工作,在单个 CPU 环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率。它是 Client 场景下的默认新生代收集器。
  • 2、ParNew 收集器
    ParNew是Serial收集器的多线程版本。Server模式下默认新生代收集器,除了Serial收集器之外,只有它能与CMS收集器配合工作。
  • 3、Parallel Scavenge 收集器
    Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器.
    Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS等收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间。而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间和CPU总小号时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间),虚拟机总共运行了100min,其中垃圾收集花费了1min,那吞吐量就是99%。
    Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间 -XX:MaxGCPauseMillis以及直接设置吞吐量大小的-XX:GCTimeRatio.
  • 4、Serial Old收集器
    Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器。给Client模式下的虚拟机使用。
    新生代采用复制算法,暂停所有用户线程。
    老年代采用标记-整理算法,暂停所有用户线程。
  • 5、Parallel Old 收集器
    是 Parallel Scavenge 收集器的老年代版本。在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
  • 6、CMS 收集器
    CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类尤其重视服务的响应速度,希望系统停顿时间最短。CMS收集器就非常符合这类应用的需求。
    CMS基于 标记-清除算法实现,整个过程步骤:
    • 初始标记:stop-the-world
    • 并发标记:并发追溯标记,程序不会停顿
    • 并发预清理:查找执行并发标记阶段从年轻代晋升到老年代的对象
    • 重新标记:暂停虚拟机,扫描CMS堆中的剩余对象(stop-the-world)
    • 并发清理:清理垃圾对象,程序不会停顿
    • 并发重置:重置CMS收集器的数据结构
      CMS特点:并发收集,低停顿。
    缺点:
    • CMS收集器对CPU资源非常敏感。默认启动的回收线程数是(CPU+3)/4. 当CPU 4个以上时,并发回收垃圾收集线程不少于25%的CPU资源。
    • CMS收集器无法处理浮动垃圾(由于CMS并发清理时,用户线程还在运行,伴随产生新垃圾,而这一部分出现在标记之后,只能下次GC时再清理。这一部分垃圾就称为”浮动垃圾“)
    • CMS基于 标记-清除算法实现,会产生大量空间碎片(当然可以开启-XX:+UseCMSCompactAtFullCollection(默认开),在CMS顶不住要FullGC时开启内存碎片合并整理过程。内存整理过程是无法并发的,空间碎片问题没了,但停顿时间变长。)。

面试题:CMS一共会有几次STW?
首先,回答两次,初始标记和重新标记需要。CMS并发的代价是预留空间给用户,预留不足的时候触发FullGC,这时Serail Old会STW
CMS是标记-清除算法,导致空间碎片,则没有连续空间分配大对象时,FUllGC, 而FUllGC会开始碎片整理,STW。即2次或多次。

  • 7、G1收集器
    G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。
    G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。
    不过,这些区域的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间。老年代也分成很多区域,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。
    这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有cms内存碎片问题的存在了。

    在G1中,还有一种特殊的区域,叫Humongous区域。 如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

  • 8、ZGC
    这款收集器的名字就叫作(Z Garbage Collector)是一款在 JDK 11中新加入的具有实验性质的低延迟垃圾收集器。

  • 9、Shenandoah
    这也是低延迟垃圾收集器,Shenandoah是由RedHat公司独立发展的新型收集器项目,在2014年RedHat把Shenandoah贡献 给了OpenJDK。

JDK8 默认垃圾收集器:-XX:+UseParallelGC=Parallel Scavenge(新生代)+Parallel Old(老年代)

java -XX:+PrintCommandLineFlags -version

java -XX:+PrintGCDetails -version

GC的分类

  • 新生代收集(Minor GC/Young GC)
    指目标只是新生代的垃圾收集
  • 老年代收集(Major GC/Old GC)
    指目标只是老年代的垃圾收集。
  • 混合收集(Mixed GC)
    指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收 集器会有这种行为。
  • 整堆收集(Full GC)
    收集整个Java堆和方法区的垃圾收集。

触发Full GC的条件

  • 老年代空间不足
  • 永久代空间不足(JDK1.8之前)
  • CMS GC时出现promotion failed,concurrent mode failure
  • Minor GC晋升到老年代的平均大小大于老年代的剩余空间
  • 调用System.gc()(仅是通知,不保证何时执行)
  • 使用RMI进行PRC调用或管理的JDK应用,每小时执行此Full GC

四、类文件结构

4.1、JVM 的“无关性”

谈论 JVM 的无关性,主要有以下两个:

  • 平台无关性:任何操作系统都能运行 Java 代码
  • 语言无关性: JVM 能运行除 Java 以外的其他代码
    Java 源代码首先需要使用 Javac 编译器编译成 .class 文件,然后由 JVM 执行 .class 文件,从而程序开始运行。

JVM 只认识 .class 文件,它不关心是何种语言生成了 .class 文件,只要 .class 文件符合 JVM 的规范就能运行。 目前已经有 JRuby、Jython、Scala 等语言能够在 JVM 上运行。
它们有各自的语法规则,不过它们的编译器 都能将各自的源码编译成符合 JVM 规范的 .class 文件,从而能够借助 JVM 运行它们。

Java 语言中的各种变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成的, 因此字节码命令所能提供的语义描述能力肯定会比 Java 语言本身更加强大。 因此,有一些 Java 语言本身无法有效支持的语言特性,不代表字节码本身无法有效支持。

4.2、Class 文件结构

Class 文件是二进制文件,它的内容具有严格的规范,文件中没有任何空格,全都是连续的 0/1。Class 文件 中的所有内容被分为两种类型:无符号数、表。

  • 无符号数
    无符号数表示 Class 文件中的值,这些值没有任何类型,但有不同的长度。u1、u2、u4、u8 分别代表 1/2/4/8 字节的无符号数。

  • 由多个无符号数或者其他表作为数据项构成的复合数据类型。

Class 文件具体由以下几个构成:

  • 魔数
  • 版本信息
  • 常量池
  • 访问标志
  • 类索引、父类索引、接口索引集合
  • 字段表集合
  • 方法表集合
  • 属性表集合

魔数
Class 文件的头 4 个字节称为魔数,用来表示这个 Class 文件的类型。
Class 文件的魔数是用 16 进制表示的CAFE BABE

魔数相当于文件后缀名,只不过后缀名容易被修改,不安全,因此在 Class 文件中标识文件类型比较合适。

版本信息

紧接着魔数的 4 个字节是版本信息,5-6 字节表示次版本号,7-8 字节表示主版本号,它们表示当前 Class 文件中使用的是哪个版本的 JDK。

高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件,即使文件格式并未发生任何变化,虚拟机也必需拒绝执行超过其版本号的 Class 文件。

常量池

版本信息之后就是常量池,常量池中存放两种类型的常量:

  • 字面值常量
    字面值常量就是我们在程序中定义的字符串、被 final 修饰的值。
  • 符号引用
    符号引用就是我们定义的各种名字:类和接口的全限定名、字段的名字和描述符、方法的名字和描述符。

常量池的特点

  • 常量池中常量数量不固定,因此常量池开头放置一个 u2 类型的无符号数,用来存储当前常量池的容量。
  • 常量池的每一项常量都是一个表,表开始的第一位是一个 u1 类型的标志位(tag),代表当前这个常量属于哪种常量类型。

常量池中常量类型

类型 tag 描述
CONSTANT_utf8_info 1 UTF-8编码的字符串
CONSTANT_Integer_info 3 整型字面量
CONSTANT_Float_info 4 浮点型字面量
CONSTANT_Long_info 5 长整型字面量
CONSTANT_Double_info 6 双精度浮点型字面量
CONSTANT_Class_info 7 类或接口的符号引用
CONSTANT_String_info 8 字符串类型字面量
CONSTANT_Fieldref_info 9 字段的符号引用
CONSTANT_Methodref_info 10 类中方法的符号引用
CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
CONSTANT_NameAndType_info 12 字段或方法的符号引用
CONSTANT_MethodHandle_info 15 表示方法句柄
CONSTANT_MethodType_info 16 标识方法类型
CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点

对于 CONSTANT_Class_info(此类型的常量代表一个类或者接口的符号引用),它的二维表结构如下:

类型 名称 数量
u1 tag 1
u2 name_index 1

tag 是标志位,用于区分常量类型;name_index 是一个索引值,它指向常量池中一个 CONSTANT_Utf8_info 类型常量,此常量代表这个类(或接口)的全限定名,这里 name_index 值若为 0x0002,也即是指向了常量池中的第二项常量。
CONSTANT_Utf8_info 型常量的结构如下:

类型 名称 数量
u1 tag 1
u2 length 1
u1 bytes length

tag 是当前常量的类型;length 表示这个字符串的长度;bytes 是这个字符串的内容(采用缩略的 UTF8 编码)

访问标志
在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否被 abstract/final 修饰。

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

类索引和父类索引都是一个 u2 类型的数据,而接口索引集合是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。

由于 Java 不允许多重继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。一个类可能实现了多个接口,因此用接口索引集合来描述。这个集合第一项为 u2 类型的数据,表示索引表的容量,接下来就是接口的名字索引。

类索引和父类索引用两个 u2 类型的索引值表示,它们各自指向一个类型为 CONSTANT_Class_info 的类描述符常量,通过该常量总的索引值可以找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串。

字段表集合

字段表集合存储本类涉及到的成员变量,包括实例变量和类变量,但不包括方法中的局部变量。

每一个字段表只表示一个成员变量,本类中的所有成员变量构成了字段表集合。字段表结构如下:

类型 名称 数量 说明
u2 access_flags 1 字段的访问标志,与类稍有不同
u2 name_index 1 字段名字的索引
u2 descriptor_index 1 描述符,用于描述字段的数据类型。 基本数据类型用大写字母表示; 对象类型用“L 对象类型的全限定名”表示。
u2 attributes_count 1 属性表集合的长度
u2 attributes attributes_count 属性表集合,用于存放属性的额外信息,如属性的值。

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

方法表集合

方法表结构与属性表类似。

volatile 关键字 和 transient 关键字不能修饰方法,所以方法表的访问标志中没有 ACC_VOLATILEACC_TRANSIENT 标志。

方法表的属性表集合中有一张 Code 属性表,用于存储当前方法经编译器编译后的字节码指令。

属性表集合

每个属性对应一张属性表,属性表的结构如下:

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u1 info attribute_length

五、类的加载

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。
类的加载的最终产品是位于堆区中的 Class对象, Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。

5.1、类的生命周期

类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括以下 7 个阶段:

  • 加载
  • 验证
  • 准备
  • 解析
  • 初始化
  • 使用
  • 卸载

验证、准备、解析 3 个阶段统称为连接。

加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始(注意是“开始”,而不是“进行”或“完成”),而解析阶段则不一定:它在某些情况下可以在初始化后再开始,这是为了支持 Java 语言的运行时绑定。

类加载过程中“初始化”开始的时机
Java 虚拟机规范没有强制约束类加载过程的第一阶段(即:加载)什么时候开始,但对于“初始化”阶段,有着严格的规定。有且仅有 5 种情况必须立即对类进行“初始化”:

  • 在遇到 new、putstatic、getstatic、invokestatic 字节码指令时,如果类尚未初始化,则需要先触发其初始化。
  • 对类进行反射调用时,如果类还没有初始化,则需要先触发其初始化。
  • 初始化一个类时,如果其父类还没有初始化,则需要先初始化父类。
  • 虚拟机启动时,用于需要指定一个包含 main() 方法的主类,虚拟机会先初始化这个主类。
  • 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类还没初始化,则需要先触发其初始化。

这 5 种场景中的行为称为对一个类进行主动引用,除此之外,其它所有引用类的方式都不会触发初始化,称为被动引用。

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

接口加载过程与类加载过程稍有不同。
当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,当真正用到父接口的时候才会初始化

5.2、类的加载过程

类加载过程包括 5 个阶段:加载、验证、准备、解析和初始化。
加载
“加载”是“类加载”过程的一个阶段,不能混淆这两个名词。在加载阶段,虚拟机需要完成 3 件事:

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

对于 Class 文件,虚拟机没有指明要从哪里获取、怎样获取。除了直接从编译好的 .class 文件中读取,还有以下几种方式:

  • 从 zip 包中读取,如 jar、war等
  • 从网络中获取,如 Applet
  • 通过动态代理技术生成代理类的二进制字节流
  • 由 JSP 文件生成对应的 Class 类
  • 从数据库中读取,如 有些中间件服务器可以选择把程序安装到数据库中来完成程序代码在集群间的分发。

非数组类”与“数组类”加载比较

  • 非数组类加载阶段可以使用系统提供的引导类加载器,也可以由用户自定义的类加载器完成,开发人员可以通过定义自己的类加载器控制字节流的获取方式(如重写一个类加载器的 loadClass() 方法)
  • 数组类本身不通过类加载器创建,它是由 Java 虚拟机直接创建的,再由类加载器创建数组中的元素类。

虚拟机规范未规定 Class 对象的存储位置,对于 HotSpot 虚拟机而言,Class 对象比较特殊,它虽然是对象,但存放在方法区中。
加载阶段与连接阶段的部分内容交叉进行,加载阶段尚未完成,连接阶段可能已经开始了。但这两个阶段的开始时间仍然保持着固定的先后顺序。

验证
验证阶段确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

验证的过程:

  • 文件格式验证 验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理,验证点如下:
    • 是否以魔数 0XCAFEBABE 开头
    • 主次版本号是否在当前虚拟机处理范围内
    • 常量池是否有不被支持的常量类型
    • 指向常量的索引值是否指向了不存在的常量
    • CONSTANT_Utf8_info 型的常量是否有不符合 UTF8 编码的数据
    • ……
  • 元数据验证 对字节码描述信息进行语义分析,确保其符合 Java 语法规范。
  • 字节码验证 本阶段是验证过程中最复杂的一个阶段,是对方法体进行语义分析,保证方法在运行时不会出现危害虚拟机的事件。
  • 符号引用验证 本阶段发生在解析阶段,确保解析正常执行。

准备
准备阶段是正式为类变量(或称“静态成员变量”)分配内存并设置初始值的阶段。这些变量(不包括实例变量)所使用的内存都在方法区中进行分配。

初始值“通常情况下”是数据类型的零值(0, null…),假设一个类变量的定义为:public static int value = 123;
那么变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时候尚未开始执行任何 Java 方法。
存在“特殊情况”:如果类字段的字段属性表中存在 ConstantValue 属性,那么在准备阶段 value 就会被初始化为 ConstantValue 属性所指定的值,假设上面类变量 value 的定义变为:public static final int value = 123;
那么在准备阶段虚拟机会根据 ConstantValue 的设置将 value 赋值为 123。

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

初始化
类初始化阶段是类加载过程的最后一步,是执行类构造器 <clinit>() 方法的过程。
<clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static {} 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
静态语句块中只能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但不能访问。如下方代码所示:

public class Test {
static {
i = 0; // 给变量赋值可以正常编译通过
System.out.println(i); // 这句编译器会提示“非法向前引用”
}
static int i = 1;
}

<clinit>() 方法不需要显式调用父类构造器,虚拟机会保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕。

由于父类的 <clinit>() 方法先执行,意味着父类中定义的静态语句块要优先于子类的变量赋值操作。如下方代码所示:

static class Parent {
public static int A = 1;
static {
A = 2;
}
}

static class Sub extends Parent {
public static int B = A;
}

public static void main(String[] args) {
System.out.println(Sub.B); // 输出 2
}

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

接口中不能使用静态代码块,但接口也需要通过 <clinit>() 方法为接口中定义的静态成员变量显式初始化。但接口与类不同,接口的 <clinit>() 方法不需要先执行父类的 <clinit>() 方法,只有当父接口中定义的变量使用时,父接口才会初始化。

虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法。

5.3、类加载器

判断类是否相等
任意一个类,都由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。

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

这里的“相等”,包括代表类的 Class 对象的 equals() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。

加载器种类

  • 启动类加载器(Bootstrap ClassLoader)
    负责将存放在 <JAVA_HOME>\lib 目录中的,并且能被虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。

  • 扩展类加载器(Extension ClassLoader)
    负责加载 <JAVA_HOME>\lib\ext 目录中的所有类库,开发者可以直接使用扩展类加载器。

  • 应用程序类加载器(Application ClassLoader)
    由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也称它为“系统类加载器”。它负责加载用户类路径(classpath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

    应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:

    • 在执行非置信代码之前,自动验证数字签名。
  • 动态地创建符合用户特定需要的定制化构建类。

  • 从特定的场所取得java class,例如数据库中和网络中。

JVM类加载机制

  • 全盘负责
    当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入
  • 父类委托
    先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类
  • 缓存机制
    缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效。

5.4、双亲委派模型

双亲委派模型是描述类加载器之间的层次关系。它要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。

父子关系一般不会以继承的关系实现,而是以组合关系来复用父加载器的代码

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

java.lang.ClassLoader 中的 loadClass() 方法中实现该过程。

为什么使用双亲委派模型
像 java.lang.Object 这些存放在 rt.jar 中的类,无论使用哪个类加载器加载,最终都会委派给最顶端的启动类加载器加载,从而使得不同加载器加载的 Object 类都是同一个。

相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在 classpath 下,那么系统将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证。

六、参数详解

参数 含义 示例 默认 说明
-Xms
初始堆大小
-Xms1g
物理内存的1/64(<1GB)
此设置控制Java 堆的初始大小。正确调整此参数有助于降低垃圾回收开销,从而缩短服务器响应时间并提高吞吐量。对于某些应用程序来说,此选项的缺省设置可能会太低,从而导致发生大量小型垃圾回收。.
-Xmx 最大堆大小 -Xmx1g 物理内存的1/4(<1GB) 此设置控制 Java 堆的最大大小。正确调整此参数有助于降低垃圾回收开销,从而缩短服务器响应时间并提高吞吐量。对于某些应用程序来说,此选项的缺省设置可能会太低,从而导致发生大量小型垃圾回收。
-Xmn 年轻代大小 -Xmn512m 物理内存的1/4(<1GB) 注意:此处的大小是(eden+ 2 survivor space).与jmap -heap中显示的New gen是不同的。整个堆大小=年轻代大小 + 年老代大小 + 持久代大小.增大年轻代后,将会减小年老代大小.此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8
-XX:NewRatio 年轻代与年老代的比值 -XX:NewRatio=1 -XX:NewRatio=4表示年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5 Xms=Xmx并且设置了Xmn的情况下,该参数不需要进行设置。
-XX:SurvivorRatio Eden区与Survivor区的大小比值 默认8:1:1 设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10
-Xss 每个线程的堆栈大小 JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K.更具应用的线程所需内存大小进行 调整.在相同物理内存下,减小这个值能生成更多的线程.但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右,一般小的应用, 如果栈不是很深, 应该是128k够用的 大的应用建议使用256k。这个选项对性能影响比较大,需要严格的测试。
-XX:MetaspaceSize 初始元数据空间大小 -XX:MetaspaceSize=256m -XX:MetaspaceSize=21807104(bit=20.79M),我通过jinfo -flag MetaspaceSize PID命令得到的 是指Metaspace扩容时触发FullGC的初始化阈值,也是最小的阈值。
-XX:MaxMetaspaceSize 元数据空间最大值 -XX:MaxMetaspaceSize=256m 如果没有使用-XX:MaxMetaspaceSize来设置类的元数据的大小,默认是没有限制的,其最大可利用空间是整个系统内存的可用空间 如果类元数据的空间占用达到MaxMetaspaceSize设置的值,将会触发对象和类加载器的垃圾回收。表示的是保证committed的内存不会超过这个值,一旦超过这个值就会触发GC。
-XX:MinMetaspaceFreeRatio 元数据最小的空闲比例 -XX:MinMetaspaceFreeRatio=30 40 当进行过Metaspace GC之后,会计算当前Metaspace的空闲空间比,如果空闲比小于这个参数,那么虚拟机将增长Metaspace的大小。在本机该参数的默认值为40,也就是40%。设置该参数可以控制Metaspace的增长的速度,太小的值会导致Metaspace增长的缓慢,Metaspace的使用逐渐趋于饱和,可能会影响之后类的加载。而太大的值会导致Metaspace增长的过快,浪费内存。
-XX:MaxMetaspaceFreeRatio 元数据最大的空闲比例 -XX:MaxMetaspaceFreeRatio=70 70 当进行过Metaspace GC之后, 会计算当前Metaspace的空闲空间比,如果空闲比大于这个参数,那么虚拟机会释放Metaspace的部分空间。在本机该参数的默认值为70,也就是70%
-XX:MinMetaspaceExpansion 元数据最小增长幅度 -XX:MaxMetaspaceFreeRatio=339968 Metaspace增长时的最小幅度。在本机上该参数的默认值为339968B(大约332KB)
-XX:MaxMetaspaceExpansion 元数据最大增长幅度 -XX:MaxMetaspaceFreeRatio=5451776 Metaspace增长时的最大幅度。在本机上该参数的默认值为5451776B(大约540KB)

参考:
周志明-《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》
https://doocs.gitee.io/jvm/#/docs/01-jvm-memory-structure
https://www.choupangxia.com/2019/10/22/interview-jvm-gc-02/

-------------本文结束 感谢您的阅读-------------