Skip to content

JVM 虚拟机

JVM 从编译到执行

Java 程序的执行过程

一个 Java 程序,首先经过 javac 编译成 .class 文件,然后 JVM 将其加载到方法区,执行引擎将会执行这些字节码。执行时,会翻译成操作系统相关的函数。JVM 作为 .class 文件的翻译存在,输入字节码,调用操作系统函数。

过程如下:Java 文件->编译器>字节码->JVM->机器码。

JVM 全称 Java Virtual Machine,也就是我们耳熟能详的 Java 虚拟机。它能识别 .class 后缀的文件,并且能够解析它的指令,最终调用操作系统上的函数,完成我们想要的操作。

JVM、JRE、JDK 的关系

JVM 只是一个翻译,把 Class 翻译成机器识别的代码,但是需要注意,JVM 不会自己生成代码,需要大家编写代码,同时需要很多依赖类库,这个时候就需要用到 JRE。

JRE 是什么,它除了包含 JVM 之外,提供了很多的类库(就是我们说的 jar 包,它可以提供一些即插即用的功能,比如读取或者操作文件,连接网络,使用 I/O 等等之类的)这些东西就是 JRE 提供的基础类库。JVM 标准加上实现的一大堆基础类库,就组成了 Java 的运行时环境,也就是我们常说的 JRE (Java Runtime Environment)。

但对于程序员来说,JRE 还不够。我写完要编译代码,还需要调试代码,还需要打包代码、有时候还需要反编译代码。所以我们会使用 JDK,因为 JDK 还提供了一些非常好用的小工具,比如 javac(编译代码)、java、jar (打包代码)、javap(反编译<反汇编>)等。这个就是 JDK。

具体可以文档可以通过官网去下载:https://www.oracle.com/java/technologies/javase-jdk8-doc-downloads.html

JVM 的作用是:从软件层面屏蔽不同操作系统在底层硬件和指令的不同。这个就是我们在宏观方面对 JVM 的一个认识。

同时 JVM 是一个虚拟化的操作系统,类似于 Linux 或者 Windows 的操作系统,只是它架在操作系统上,接收字节码也就是 class,把字节码翻译成操作系统上的机器码且进行执行。

从跨平台到跨语言

跨平台:我们写的一个类,在不同的操作系统上(Linux、Windows、MacOS 等平台)执行,效果是一样,这个就是 JVM 的跨平台性。 为了实现跨平台型,不同操作系统有对应的 JDK 的版本。

https://www.oracle.com/java/technologies/javase/javase-jdk8-downloads.html

跨语言(语言无关性):JVM 只识别字节码,所以 JVM 其实跟语言是解耦的,也就是没有直接关联,JVM 运行不是翻译 Java 文件,而是识别 class 文 件,这个一般称之为字节码。还有像 Groovy 、Kotlin、Scala 等等语言,它们其实也是编译成字节码,所以它们也可以在 JVM 上面跑,这个就是 JVM 的跨 语言特征。Java 的跨语言性一定程度上奠定了非常强大的 java 语言生态圈。

JVM 的发展(非重点)

常见的 JVM 实现

  • Hotspot: 目前使用的最多的 Java 虚拟机。在命令行 java –version。它会输出你现在使用的虚拟机的名字、版本等信息、执行模式。
bash
C:\Users\mengweijin>java -version
java version "1.8.0_251"
Java(TM) SE Runtime Environment (build 1.8.0_251-b08)
Java HotSpot(TM) 64-Bit Server VM (build 25.251-b08, mixed mode)
  • Jrocket: 原来属于 BEA 公司,曾号称世界上最快的 JVM,后被 Oracle 公司收购,合并于 Hotspot
  • J9: IBM 有自己的 java 虚拟机实现,它的名字叫做 J9. 主要是用在 IBM 产品(IBM WebSphere 和 IBM 的 AIX 平台上)
  • TaobaoVM: 只有一定体量、一定规模的厂商才会开发自己的虚拟机,比如淘宝有自己的 VM,它实际上是 Hotspot 的定制版,专门为淘宝准备的,阿里、天 猫都是用的这款虚拟机。
  • LiquidVM: 它是一个针对硬件的虚拟机,它下面是没有操作系统的(不是 Linux 也不是 windows),下面直接就是硬件,运行效率比较高。
  • zing: 它属于 zual 这家公司,非常牛,是一个商业产品,很贵!它的垃圾回收速度非常快(1 毫秒之内),是业界标杆。它的一个垃圾回收的算法后来被 Hotspot 吸收才有了现在的 ZGC。

JVM 整体知识模块

JVM 能涉及非常庞大的一块知识体系,比如内存结构、垃圾回收、类加载、性能调优、JVM 自身优化技术、执行引擎、类文件结构、监控工具等。 但是在所有的知识体系中,都或多或少跟内存结构有一定的关系:

比如垃圾回收回收的就是内存、类加载加载到的地方也是内存、性能优化也涉及到内存优化、执行引擎与内存密不可分、类文件结构与内存的设计有关系,监控工具也会监控内存。所以内存结构处于 JVM 中核心位置。也是属于我们入门 JVM 学习的最好的选择。

同时 JVM 是一个虚拟化的操作系统,所以除了要虚拟指令之外,最重要的一个事情就是需要虚拟化内存,这个虚拟化内存就是我们马上要讲到的 JVM 的内存区域。

JVM 的内存区域(重点)

运行时数据区域(重点)

  • 运行时数据区的定义:Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域
  • Java 引以为豪的就是它的自动内存管理机制。相比于 C++的手动内存管理、复杂难以理解的指针等,Java 程序写起来就方便的多。
  • 所以要深入理解 JVM 必须理解内存虚拟化的概念。
  • 在 JVM 中,JVM 内存主要分为堆、程序计数器、方法区、虚拟机栈和本地方法栈等。
  • 同时按照与线程的关系也可以这么划分区域:
    • 线程私有区域:一个线程拥有单独的一份内存区域。
    • 线程共享区域:被所有线程共享,且只有一份。
  • 这里还有一个直接内存,这个虽然不是运行时数据区的一部分,但是会被频繁使用。你可以理解成没有被虚拟机化的操作系统上的其他内存(比如操作系统上有 8G 内存,被 JVM 虚拟化了 3G,那么还剩余 5G, JVM 是借助一些工具使用这 5G 内存的,这个内存部分称之为直接内存)

image

JAVA 方法的运行与虚拟机栈(重点)

虚拟机栈是线程运行 java 方法所需的数据,指令、返回地址。其实在我们实际的代码中,一个线程是可以运行多个方法的。 比如:

java
public class MethodAndStack{
    public static void main(String[] args) {
        A();
    }
    public static void A() { B(); };
    public static void B() { C(); };
    public static void C() {  };
}

这段代码很简单,就是起一个 main 方法,在 main 方法运行中调用 A 方法,A 方法中调用 B 方法,B 方法中运行 C 方法。 我们把代码跑起来,线程 1 来运行这段代码, 线程 1 跑起来,就会有一个对应的虚拟机栈,同时在执行每个方法的时候都会打包成一个栈帧。比如 main 开始运行,打包一个栈帧送入到虚拟机栈。 image

C 方法运行完了,C 方法出栈,接着 B 方法运行完了,B 方法出栈、接着 A 方法运行完了,A 方法出栈,最后 main 方法运行完了,main 方法这个栈帧就 出栈了。 这个就是 Java 方法运行对虚拟机栈的一个影响。虚拟机栈就是用来存储线程运行方法中的数据的。而每一个方法对应一个栈帧。

虚拟机栈

  • 栈的数据结构:先进后出(FILO)的数据结构,
  • 虚拟机栈的作用:在 JVM 运行过程中存储当前线程运行方法所需的数据,指令、返回地址。
  • 虚拟机栈是基于线程的:哪怕你只有一个 main() 方法,也是以线程的方式运行的。在线程的生命周期中,参与计算的数据会频繁地入栈和出栈,栈的生命周期是和线程一样的。
  • 虚拟机栈的大小缺省为 1M,可用参数 –Xss 调整大小,例如-Xss256k。 参数官方文档(JDK1.8):https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
  • -Xss size

    Sets the thread stack size (in bytes). Append the letter k or K to indicate KB, m or M to indicate MB, g or G to indicate GB. The default value depends on the platform:

    • Linux/ARM (32-bit): 320 KB
    • Linux/i386 (32-bit): 320 KB
    • Linux/x64 (64-bit): 1024 KB
    • OS X (64-bit): 1024 KB
    • Oracle Solaris/i386 (32-bit): 320 KB
    • Oracle Solaris/x64 (64-bit): 1024 KB

The following examples set the thread stack size to 1024 KB in different units:

  • -Xss1m
  • -Xss1024k
  • -Xss1048576

This option is equivalent to -XX:ThreadStackSize.

  • 栈帧:在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦方法完成相应的调用,则出栈。
  • 栈帧大体都包含四个区域:(局部变量表、操作数栈、动态连接、返回地址)
    1. 局部变量表: 顾名思义就是局部变量的表,用于存放我们的局部变量的(方法中的变量)。首先它是一个 32 位的长度,主要存放我们的 Java 的八大基础数据类型,一般 32 位就可以存放下,如果是 64 位的就使用高低位占用两个也可以存放下,如果是局部的一些对象,比如我们的 Object 对象,我们只需要存放它的一个引用地址即可。
    2. 操作数据栈: 存放 java 方法执行的操作数的,它就是一个栈,先进后出的栈结构,操作数栈,就是用来操作的,操作的的元素可以是任意的 java 数据类型,所以我们知道一个方法刚刚开始的时候,这个方法的操作数栈就是空的。 操作数栈本质上是 JVM 执行引擎的一个工作区,也就是方法在执行,才会对操作数栈进行操作,如果代码不不执行,操作数栈其实就是空的。
    3. 动态连接: Java 语言特性多态(后续章节细讲,需要结合 class 与执行引擎一起来讲)。
    4. 返回地址: 正常返回(调用程序计数器中的地址作为返回)、异常的话(通过异常处理器表<非栈帧中的>来确定)

同时,虚拟机栈这个内存也不是无限大,它有大小限制,默认情况下是 1M。 如果我们不断的往虚拟机栈中入栈帧,但是就是不出栈的话,那么这个虚拟机栈就会爆掉。

java
public class StackError{
    public static void main(String[] args) {
        A();
    }
    public static void A() { A(); };
}

Exception in thread "main" java.lang.StackOverflowError

程序计数器

较小的内存空间,当前线程执行的字节码的行号指示器;各线程之间独立存储,互不影响。

程序计数器是一块很小的内存空间,主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。

由于 Java 是多线程语言,当执行的线程数量超过 CPU 核数时,线程之间会根据时间片轮询争夺 CPU 资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的 CPU 资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令。

因为 JVM 是虚拟机,内部有完整的指令与执行的一套流程,所以在运行 Java 方法的时候需要使用程序计数器(记录字节码执行的地址或行号),如果是遇到本地方法(native 方法),这个方法不是 JVM 来具体执行,所以程序计数器不需要记录了,这个是因为在操作系统层面也有一个程序计数器,这个会记录本地代码的执行的地址,所以在执行 native 方法时,JVM 中程序计数器的值为空(Undefined)。

另外程序计数器也是 JVM 中唯一不会 OOM(OutOfMemory)的内存区域。

栈帧执行对内存区域的影响(重点)

对 class 进行反汇编 javap –c XXXX.class 字节码助记码解释地址:https://cloud.tencent.com/developer/article/1333540

image

在 JVM 中,基于解释执行的这种方式是基于栈的引擎,这个说的栈,就是操作数栈。

运行时数据区及 JVM 的整体内存结构

本地方法栈

本地方法栈跟 Java 虚拟机栈的功能类似,Java 虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是用 Java 实现的,而是由 C 语言实现的(比如 Object.hashcode 方法)。

本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法。你甚至可以认为虚拟机栈和本地方法栈是同一个区域。 虚拟机规范无强制规定,各版本虚拟机自由实现,HotSpot 直接把本地方法栈和虚拟机栈合二为一 。

方法区

方法区主要是用来存放已被虚拟机加载的类相关信息,包括类信息、静态变量、常量、运行时常量池、字符串常量池等。 方法区是 JVM 对内存的“逻辑划分”,在 JDK1.7 及之前很多开发者都习惯将方法区称为“永久代”,是因为在 HotSpot 虚拟机中,设计人员使用了永久代来实现了 JVM 规范的方法区。在 JDK1.8 及以后使用了元空间来实现方法区。

JVM 在执行某个类的时候,必须先加载。在加载类(加载、验证、准备、解析、初始化)的时候,JVM 会先加载 class 文件,而在 class 文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池 (Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用。

字面量包括字符串(String a=“b”)、基本类型的常量(final 修饰的变量),符号引用则包括类和方法的全限定名(例如 String 这个类,它的全限定名 就是 Java/lang/String)、字段的名称和描述符以及方法的名称和描述符。

符号引用

一个 java 类(假设为 People 类)被编译成一个 class 文件时,如果 People 类引用了 Tool 类,但是在编译时 People 类并不知道引用类的实际内存地址,因此只能使用符号引用来代替。

而在类装载器装载 People 类时,此时可以通过虚拟机获取 Tool 类的实际内存地址,因此便可以既将符号 org.simple.Tool 替换为 Tool 类的实际内存地址,及直接引用地址。

即在编译时用符号引用来代替引用类,在加载时再通过虚拟机获取该引用类的实际地址. 以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局是无关的,引用的目标不一定已经加载到内存中。

常量池与运行时常量池

而当类加载到内存中后,JVM 就会将 class 文件常量池中的内容存放到运行时的常量池中;在解析阶段,JVM 会把符号引用替换为直接引用(对象的索引值)。

例如,类中的一个字符串常量在 class 文件中时,存放在 class 文件常量池中的;在 JVM 加载完类之后,JVM 会将这个字符串常量放到运行时常量池中,并在解析阶段,指定该字符串对象的索引值。运行时常量池是全局共享的,多个类共用一个运行时常量池,class 文件中常量池多个相同的字符串 在运行时常量池只会存在一份。

常量池有很多概念,包括运行时常量池、class 常量池、字符串常量池。

虚拟机规范只规定以上区域属于方法区,并没有规定虚拟机厂商的实现。 严格来说是静态常量池和运行时常量池,静态常量池是存放字符串字面量、符号引用以及类和方法的信息,而运行时常量池存放的是运行时一些直接引用。

运行时常量池是在类加载完成之后,将静态常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。 运行时常量池在 JDK1.7 版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区(方法区是逻辑分区)。

元空间

方法区与堆空间类似,也是一个共享内存区,所以方法区是线程共享的。假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入 JVM, 那么此时就只允许一个线程去加载它,另一个线程必须等待。

在 HotSpot 虚拟机、Java7 版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在 JVM 的非堆内存中,而 Java8 版本 已经将方法区中实现的永久代去掉了,并用元空间(class metadata)代替了之前的永久代,并且元空间的存储位置是本地内存。

元空间大小参数:

Java8 为什么使用元空间替代永久代,这样做有什么好处呢?

官方给出的解释是: 移除永久代是为了融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,所以不需要配置永久代。

永久代内存经常不够用或发生内存溢出,抛出异常 java.lang.OutOfMemoryError: PermGen。这是因为在 JDK1.7 版本中,指定的 PermGen 区大小为 8M,由于 PermGen 中类的元数据信息在每次 FullGC 的时候都可能被收集,回收率都偏低,成绩很难令人满意;还有为 PermGen 分配多大的空间很难确定,PermSize 的大小依赖于很多因素,比如,JVM 加载的 class 总数、常量池的大小和方法的大小等。

堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。 堆空间一般是程序启动时,就申请了,但是并不一定会全部使用。堆一般设置成可伸缩的。 随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GC(Garbage Collection)。

那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?

这和两个方面有关:对象的类型和在 Java 类中存在的位置。 Java 的对象可以分为基本数据类型和普通对象。

对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。

对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。 当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。

  • 堆大小参数: _ -Xms:堆的最小值; _ -Xmx:堆的最大值; _ -Xmn:新生代的大小; _ -XX:NewSize;新生代最小值; * -XX:MaxNewSize:新生代最大值; 例如- Xmx256m

https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

  • -Xms size

    Sets the initial size (in bytes) of the heap. This value must be a multiple of 1024 and greater than 1 MB. Append the letter k or K to indicate kilobytes, m or M to indicate megabytes, g or G to indicate gigabytes.

The following examples show how to set the size of allocated memory to 6 MB using various units:

  • -Xms6291456
  • -Xms6144k
  • -Xms6m If you do not set this option, then the initial size will be set as the sum of the sizes allocated for the old generation and the young generation. The initial size of the heap for the young generation can be set using the -Xmn option or the -XX:NewSize option.
  • -Xmx size

    Specifies the maximum size (in bytes) of the memory allocation pool in bytes. This value must be a multiple of 1024 and greater than 2 MB. Append the letter k or K to indicate kilobytes, m or M to indicate megabytes, g or G to indicate gigabytes. The default value is chosen at runtime based on system configuration. For server deployments, -Xms and -Xmx are often set to the same value. See the section "Ergonomics" in Java SE HotSpot Virtual Machine Garbage Collection Tuning Guide at http://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/index.html.

The following examples show how to set the maximum allowed size of allocated memory to 80 MB using various units:

  • -Xmx83886080
  • -Xmx81920k
  • -Xmx80m The -Xmx option is equivalent to -XX:MaxHeapSize.

直接内存(堆外内存)

直接内存有一种更加科学的叫法,堆外内存。

JVM 在运行时,会从操作系统申请大块的堆内存,进行数据的存储;同时还有虚拟机栈、本地方法栈和程序计数器,这块称之为栈区。操作系统剩余的内存也就是堆外内存。

它不是虚拟机运行时数据区的一部分,也不是 java 虚拟机规范中定义的内存区域;如果使用了 NIO, 这块区域会被频繁使用,在 java 堆内可以用 directByteBuffer 对象直接引用并操作; 这块内存不受 java 堆大小限制,但受本机总内存的限制,可以通过-XX:MaxDirectMemorySize 来设置(默认与堆内存最大值一样),所以也会出现 OOM 异常。

小结:

  1. 直接内存主要是通过 DirectByteBuffer 申请的内存,可以使用参数“MaxDirectMemorySize”来限制它的大小。
  2. 其他堆外内存,主要是指使用了 Unsafe 或者其他 JNI 手段直接直接申请的内存。堆外内存的泄漏是非常严重的,它的排查难度高、影响大,甚至会造成主机的死亡。后续章节会详细讲。

同时,要注意 Oracle 之前计划在 Java 9 中去掉 sun.misc.Unsafe API。这里删除 sun.misc.Unsafe 的原因之一是使 Java 更加安全,并且有替代方案。 目前我们主要针对的 JDK1.8,JDK1.9 暂时不放入讨论范围中,我们大致知道 java 的发展即可。

深入理解 JVM 的内存区域

深入理解运行时数据区

java
/**
 * -Xms30m -Xmx30m -Xss1m -XX:MaxMetaspaceSize=30m
 */
public class JVMObject {
    public final static String MAN_TYPE = "man";
    public static String WOMAN_TYPE = "woman";
    public static void main(String[] args) throws Exception {
        Teacher T1 = new Teacher();
        T1.setName("aaa");
        T1.setSexType(MAN_TYPE);
        T1.setAge(12);

        Teacher T2 = new Teacher();
        T2.setName("bbb");
        T2.setSexType(MAN_TYPE);
        T2.setAge(13);

        Thread.sleep(Integer.MAX_VALUE);
    }
}
@Data
class Teacher{
    String name;
    String sexType;
    int age;
}
  1. JVM 向操作系统申请内存: JVM 第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间,根据内存大小找到具体的内存分配表,然后把内存段的起始地址和终止地址分配给 JVM,接下来 JVM 就进行内部分配。
  2. JVM 获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小。 -Xms30m -Xmx30m -Xss1m -XX:MaxMetaspaceSize=30m
  3. 类加载(类加载的细节后续章节会讲): 这里主要是把 class 放入方法区、还有 class 中的静态变量和常量也要放入方法区
  4. 执行方法及创建对象: 启动 main 线程,执行 main 方法,开始执行第一行代码。此时堆内存中会创建一个 Teacher 对象,对象引用 Teacher 就存放在栈中。 后续代码中遇到 new 关键字,会再创建一个 Teacher 对象,对象引用 Teacher 就存放在栈中。

image

总结一下 JVM 运行内存的整体流程

JVM 在操作系统上启动,申请内存,先进行运行时数据区的初始化,然后把类加载到方法区,最后执行方法。 方法的执行和退出过程在内存上的体现上就是虚拟机栈中栈帧的入栈和出栈。 同时在方法的执行过程中创建的对象一般情况下都是放在堆中,最后堆中的对象也是需要进行垃圾回收清理的。

从底层深入理解运行时数据区

堆空间分代划分

堆被划分为新生代和老年代(Tenured),新生代又被进一步划分为 Eden 和 Survivor 区,最后 Survivor 由 From Survivor 和 To Survivor 组成。 (先需要有概念,后续对象分配和垃圾回收会细讲这块)

image

GC 概念

GC- Garbage Collection 垃圾回收,在 JVM 中是自动化的垃圾回收机制,我们一般不用去关注,在 JVM 中 GC 的重要区域是堆空间。 我们也可以通过一些额外方式主动发起它,比如 System.gc(),主动发起。(项目中切记不要使用)

JHSDB 工具

JHSDB 是一款基于服务性代理实现的进程外调试工具。服务性代理是 HotSpot 虚拟机中一组用于映射 Java 虚拟机运行信息的,主要基于 Java 语言实现的 API 集合。

从底层深入理解运行时数据区(总结)

深入辨析堆和栈

  • 功能
    • 以栈帧的方式存储方法调用的过程,并存储方法调用过程中基本数据类型的变量(int、short、long、byte、float、double、boolean、char 等)以及对象的引用变量,其内存分配在栈上,变量出了作用域就会自动释放;
    • 而堆内存用来存储 Java 中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中;
  • 线程独享还是共享
    • 栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。
    • 堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。
  • 空间大小
    • 栈的内存要远远小于堆内存

虚拟机内存优化技术

栈的优化技术——栈帧之间数据的共享

在一般的模型中,两个不同的栈帧的内存区域是独立的,但是大部分的 JVM 在实现中会进行一些优化,使得两个栈帧出现一部分重叠。(主要体现在方法中有参数传递的情况),让下面栈帧的操作数栈和上面栈帧的部分局部变量重叠在一起,这样做不但节约了一部分空间,更加重要的是在进行方法调用时就可以直接公用一部分数据,无需进行额外的参数复制传递了。

image

内存溢出(重点)

栈溢出

参数:-Xss1m, 具体默认值需要查看官网:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html#BABHDABI

HotSpot 版本中栈的大小是固定的,是不支持拓展的。

java.lang.StackOverflowError 一般的方法调用是很难出现的,如果出现了可能会是无限递归。

虚拟机栈带给我们的启示:方法的执行因为要打包成栈桢,所以天生要比实现同样功能的循环慢,所以树的遍历算法中:递归和非递归(循环来实现)都有存在的意义。递归代码简洁,非递归代码复杂但是速度较快。

OutOfMemoryError:不断建立线程,JVM 申请栈内存,机器没有足够的内存。(一般演示不出,演示出来机器也死了)

同时要注意,栈区的空间 JVM 没有办法去限制的,因为 JVM 在运行过程中会有线程不断的运行,没办法限制,所以只限制单个虚拟机栈的大小。

堆溢出

内存溢出:申请内存空间,超出最大堆内存空间。

如果是内存溢出,则通过 调大 -Xms,-Xmx 参数。

如果不是内存泄漏,就是说内存中的对象却是都是必须存活的,那么久应该检查 JVM 的堆参数设置,与机器的内存对比,看是否还有可以调整的空间,再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行时的内存消耗。

方法区溢出

  • 运行时常量池溢出
  • 方法区中保存的 Class 对象没有被及时回收掉或者 Class 信息占用的内存超过了我们配置。

注意 Class 要被回收,条件比较苛刻(仅仅是可以,不代表必然,因为还有一些参数可以进行控制):

  1. 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
  2. 加载该类的 ClassLoader 已经被回收。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
  • -Xnoclassgc
    • 禁用类的垃圾收集(GC)。这可以节省一些 GC 时间,从而缩短应用程序运行期间的中断。
    • 当在启动时指定-Xnoclassgc 时,应用程序中的类对象在 GC 期间将保持不变,并始终被认为是活动的。这可能导致更多的内存被永久占用,如果不小心使用,将抛出一个 out of memory 异常。

CGLIB

cglib 是一个强大的,高性能,高质量的 Code 生成类库,它可以在运行期扩展 Java 类与实现 Java 接口。

CGLIB 包的底层是通过使用一个小而快的字节码处理框架 ASM,来转换字节码并生成新的类。除了 CGLIB 包,脚本语言例如 Groovy 和 BeanShell, 也是使用 ASM 来生成 java 的字节码。当然不鼓励直接使用 ASM,因为它要求你必须对 JVM 内部结构包括 class 文件的格式和指令集都很熟悉。

本机直接内存溢出

直接内存的容量可以通过 MaxDirectMemorySize 来设置(默认与堆内存最大值一样),所以也会出现 OOM 异常;

由直接内存导致的内存溢出,一个比较明显的特征是在 HeapDump 文件中不会看见有什么明显的异常情况,如果发生了 OOM,同时 Dump 文件很小,可 以考虑重点排查下直接内存方面的原因。

常量池

Class 常量池(静态常量池)

在 class 文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池 (Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用

字面量:给基本类型变量赋值的方式就叫做字面量或者字面值。 比如:String a=“b” ,这里“b”就是字符串字面量,同样类推还有整数字面值、浮点类型字面量、字符字面量。

符号引用 :符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,JAVA 在编译的时候一个每个 java 类都会被编译成一个 class 文件,但在编译的时候虚拟机并不知道所引用类的地址(实际地址),就用符号引用来代替,而在类的解析阶段(后续 JVM 类加载会具体讲到)就是为了把这个符号引用转化成为真正的地址的阶段。

一个 java 类(假设为 People 类)被编译成一个 class 文件时,如果 People 类引用了 Tool 类,但是在编译时 People 类并不知道引用类的实际内存地址,因此只能使用符号引用(org.simple.Tool)来代替。而在类装载器装载 People 类时,此时可以通过虚拟机获取 Tool 类的实际内存地址,因此便可以既将符号 org.simple.Tool 替换为 Tool 类的实际内存地址。

运行时常量池

运行时常量池(Runtime Constant Pool)是每一个类或接口的常量池(Constant_Pool)的运行时表示形式,它包括了若干种不同的常量: 从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。(这个是虚拟机规范中的描述,很生涩)

运行时常量池是在类加载完成之后,将 Class 常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。 运行时常量池在 JDK1.7 版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区(方法区是逻辑分区)。

在 JDK1.8 中,使用元空间代替永久代来实现方法区,但是方法区并没有改变,所谓"Your father will always be your father"。变动的只是方法区中内容的物理存放位置,但是运行时常量池和字符串常量池被移动到了堆中。但是不论它们物理上如何存放,逻辑上还是属于方法区的。

字符串常量池

字符串常量池这个概念是最有争议的,很多正式文档,发现没有这个概念的官方定义,所以与运行时常量池的关系不去抬杠,我们从它的作用和 JVM 设计它用于解决什么问题的点来分析它。

以 JDK1.8 为例,字符串常量池是存放在堆中,并且与 java.lang.String 类有很大关系。设计这块内存区域的原因在于:String 对象作为 Java 语言中重要的数据类型,是内存中占据空间最大的一个对象。高效地使用字符串,可以提升系统的整体性能。所以要彻底弄懂,我们的重心其实在于深入理解 String。

String

String 类分析(JDK 1.8)

String 对象是对 char 数组进行了封装实现的对象,主要有 2 个成员变量:char 数组,hash 值。

java
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0

    // ......
}

String 对象的不可变性

了解了 String 对象的实现后,你有没有发现在实现代码中 String 类被 final 关键字修饰了,而且变量 char 数组也被 final 修饰了。 我们知道类被 final 修饰代表该类不可继承,而 char[]被 final+private 修饰,代表了 String 对象不可被更改。Java 实现的这个特性叫作 String 对象的不可变性,即 String 对象一旦创建成功,就不能再对它进行改变。

Java 这样做的好处在哪里呢?

  • 第一, 保证 String 对象的安全性。假设 String 对象是可变的,那么 String 对象将可能被恶意修改。
  • 第二, 保证 hash 属性值不会频繁变更,确保了唯一性,使得类似 HashMap 容器才能实现相应的 key-value 缓存功能。
  • 第三, 可以实现字符串常量池。在 Java 中,通常有两种创建字符串对象的方式,一种是通过字符串常量的方式创建,如 String str = "abc"; 另一种是字符串变量通过 new 形式的创建,如 String str = new String("abc")。

String 的创建方式及内存分配的方式

  1. String str = "abc";
    • 当代码中使用这种方式创建字符串对象时,JVM 首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存。(str 只是一个引用)
  2. String str = new String( "abc");
    • 首先在编译类文件时,"abc"常量字符串将会放入到常量结构中,在类加载时,"abc" 将会在常量池中创建;其次,在调用 new 时,JVM 命令将会调用 String 的构造函数,同时引用常量池中的"abc" 字符串,在堆内存中创建一个 String 对象;最后,str 将引用 String 对象。
  3. Location location = new Location(); location.setCity("xi'an");
    • 使用 new,对象会创建在堆中,同时赋值给对象中的 String 的话,就不会在字符串常量池中创建对象了,而是直接放在 location 对象的属性中,即在堆中。
  4. String str2 = "ab"+ "cd"+ "ef";
    • 编程过程中,字符串的拼接很常见。前面我讲过 String 对象是不可变的,如果我们使用 String 对象相加,拼接我们想要的字符串,是不是就会产生多个对象呢?
    • 例如上面的代码: 分析代码可知:首先会生成 ab 对象,再生成 abcd 对象,最后生成 abcdef 对象,从理论上来说,这段代码是低效的。 编译器自动优化了这行代码,编译后的代码,你会发现编译器自动优化了这行代码,如下 String str= "abcdef";
  5. 大循环使用+
    java
    public void mode5(){
        String str = "abcdef";
        for(int i = 0; i < 1000; i++) {
            str = str + i;
        }
    }
    // 编译器同样对这段代码进行了优化。Java 在进行字符串拼接时,偏向使用 StringBuilder,这样可以提高程序的效率。如下为编译后的代码:
    public void mode6(){
        String str = "abcdef";
        for(int i = 0; i < 1000; i++) {
            str = (new StringBuilder(String.valueOf(str))).append(i).toString();
        }
    }

intern 方法

String 的 intern 方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用。

java
String a = new String("aaa").intern();
String b = new String("aaa").intern();
if(a == b) {
    System.out.println("a==b");
} else {
    System.out.println("a!=b");
}
  1. new Sting() 会在堆内存中创建一个 a 的 String 对象,"aaa" 将会在常量池中创建
  2. 在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。
  3. 调用 new Sting() 会在堆内存中创建一个 b 的 String 对象。
  4. 在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。 所以 a 和 b 引用的是同一个对象。

如果需要查看 String 的编译优化,需要使用到反编译工具,推荐 JD-GUI:http://java-decompiler.github.io/

玩转 JVM 中的对象及引用

JVM 中对象的创建过程

image

对象的内存分配

虚拟机遇到一条 new 指令时,首先检查是否被类加载器加载,如果没有,那必须先执行相应的类加载过程。 类加载就是把 class 加载到 JVM 的运行时数据区的过程(类加载后面有专门的专题讲)。

  1. 检查加载

    • 首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用(符号引用 :符号引用以一组符号来描述所引用的目标),并且检查类是否已经被加载、 解析和初始化过
  2. 分配内存

    • 接下来虚拟机将为新生对象分配内存。为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。

    • 指针碰撞

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

      • 如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。
      • 选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。 (这部分知识先了解,后续结合垃圾回收器一起去理解)
      • 如果是 Serial、ParNew 等带有压缩的整理的垃圾回收器的话,系统采用的是指针碰撞,既简单又高效。 如果是使用 CMS 这种不带压缩(整理)的垃圾回收器的话,理论上只能采用较复杂的空闲列表。
    • 并发安全

      • 除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。

      • CAS 机制 解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性;

      • 分配缓冲

        • 另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块私有内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),JVM 在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个 Buffer,如果需要分配内存,就在自己的 Buffer 上分配,这样就不存在竞争的情况,可以大大提升分配效率,当 Buffer 容量不够的时候,再重新从 Eden 区域申请一块继续使用。 TLAB 的目的是在为新对象分配内存空间时,让每个 Java 应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。 TLAB 只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个 TLAB 用满(分配指针 top 撞上分配极限 end 了),就新申请一个 TLAB。
        • 参数: -XX:+UseTLAB
        • 允许在年轻代空间中使用线程本地分配块(TLAB)。默认情况下启用此选项。要禁用 TLAB,请指定-XX:-UseTLAB。
        • https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
  3. 内存空间初始化

    • (注意不是构造方法)内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(如 int 值为 0,boolean 值为 false 等等)。这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  4. 设置

    • 接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息(Java classes 在 Java hotspot VM 内部表示为类元数据)、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头之中。
  5. 对象初始化

    • 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。 所以,一般来说,执行 new 指令之后会接着把对象按照程序员的意愿进行初始化(构造方法),这样一个真正可用的对象才算完全产生出来。

对象的内存布局

image

在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。

对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 如果对象是一个 java 数组,那么在对象头中还有一块用于记录数组长度的数据。

第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于 HotSpot VM 的自动内存管理系统要求对对象的大小必须 是 8 字节的整数倍。当对象其他数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位

建立对象是为了使用对象,我们的 Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。目前主流的访问方式有使用句柄和直接指针两种。

句柄

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

直接指针

如果使用直接指针访问, reference 中存储的直接就是对象地址。 这两种对象访问方式各有优势, 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。 对 Sun HotSpot 而言,它是使用直接指针访问方式进行对象访问的。

判断对象的存活

在堆里面存放着几乎所有的对象实例,垃圾回收器在对对进行回收前,要做的事情就是确定这些对象中哪些还是“存活”着,哪些已经“死去”(死去代表着不可能再被任何途径使用得对象了)

垃圾回收算法

引用计数法

在对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1,当引用失效时,计数器减 1.

Python 在用,但主流虚拟机没有使用,因为存在对象相互引用的情况,这个时候需要引入额外的机制来处理,这样做影响效率,

在 Java 代码中看到,只保留相互引用的对象还是被回收掉了,说明 JVM 中采用的不是引用计数法。

可达性分析(面试重点)

来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。

作为 GC Roots 的对象包括下面几种(重点是前面 4 种):

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;各个现场被调用方法堆栈中使用到的参数、局部变量、临时变量等。
  • 方法区中类静态属性引用的对象;java 类的引用类型静态变量。
  • 方法区中常量引用的对象;比如:字符串常量池里的引用。
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
  • JVM 的内部引用(class 对象、异常对象 NullPointException、OutofMemoryError,系统类加载器)。(非重点)
  • 所有被同步锁(synchronized 关键)持有的对象。(非重点)
  • JVM 内部的 JMXBean、JVMTI 中注册的回调、本地代码缓存等(非重点)
  • JVM 实现中的“临时性”对象,跨代引用的对象(在使用分代模型回收只回收部分代的对象,这个后续会细讲,先大致了解概念)(非重点)

以上的回收都是对象,类的回收条件: 注意 Class 要被回收,条件比较苛刻,必须同时满足以下的条件(仅仅是可以,不代表必然,因为还有一些参数可以进行控制):

  1. 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。

  2. 加载该类的 ClassLoader 已经被回收。

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

  4. 参数控制:禁用垃圾回收

    • -Xnoclassgc

      Disables garbage collection (GC) of classes. This can save some GC time, which shortens interruptions during the application run.

      When you specify -Xnoclassgc at startup, the class objects in the application will be left untouched during GC and will always be considered live. This can result in more memory being permanently occupied which, if not used carefully, will throw an out of memory exception.

废弃的常量和静态变量的回收其实就和 Class 回收的条件差不多。

Finalize 方法

即使通过可达性分析判断不可达的对象,也不是“非死不可”,它还会处于“缓刑”阶段,真正要宣告一个对象死亡,需要经过两次标记过程,一次是没有找到与 GCRoots 的引用链,它将被第一次标记。随后进行一次筛选(如果对象覆盖了 finalize),我们可以在 finalize 中去拯救。

建议大家尽量不要使用 finalize,因为这个方法太不可靠。在生产中你很难控制方法的执行或者对象的调用顺序,建议大家忘了 finalize 方法!因为在 finalize 方法能做的工作,java 中有更好的,比如 try-finally 或者其他方式可以做得更好。

各种引用

强引用 =

一般的 Object obj = new Object() ,就属于强引用。在任何情况下,只有有强引用关联(与根可达)还在,垃圾回收器就永远不会回收掉被引用的对象。

软引用 SoftReference

一些有用但是并非必需,用软引用关联的对象,系统将要发生内存溢出(OuyOfMemory)之前,这些对象就会被回收(如果这次回收后还是没有足够的空间,才会抛出内存溢出)。参见代码:

java
/**
 * VM 参数 -Xms10m -Xmx10m -XX:+PrintGC
 */
public static void main(String[] args) {
    User u = new User(1, "aaa");
    SoftReference<User> userSoft = new SoftReference<>(u);
    // 干掉强引用,确保这个实例只有userSoft的软引用
    u = null;
    System.out.println(userSoft.get());
    // 进行一次垃圾回收
    System.gc();
    System.out.println("After GC");
    System.out.println(userSoft.get());
    // 在堆中填充数据,导致 OOM
    List<byte[]> list = new LinkedList<>();
    try{
        for(int i = 0; i < 100; i++) {
            System.out.println("****************" + userSoft.get());
            // 1M 的对象
            list.add(new byte[1024 * 1024 * 1]);
        }
    } catch (Throwable e){
        // 抛出 OOM 异常时打印软引用对象
        System.out.println("Exception ************" + userSoft.get());
    }
}

运行结果:

com.mengweijin.learning.basic.parallel.Test$User@71bc1ae4
[GC (System.gc())  2017K->740K(9728K), 0.0046762 secs]
[Full GC (System.gc())  740K->671K(9728K), 0.0091373 secs]
After GC
com.mengweijin.learning.basic.parallel.Test$User@71bc1ae4
****************com.mengweijin.learning.basic.parallel.Test$User@71bc1ae4
****************com.mengweijin.learning.basic.parallel.Test$User@71bc1ae4
****************com.mengweijin.learning.basic.parallel.Test$User@71bc1ae4
****************com.mengweijin.learning.basic.parallel.Test$User@71bc1ae4
****************com.mengweijin.learning.basic.parallel.Test$User@71bc1ae4
****************com.mengweijin.learning.basic.parallel.Test$User@71bc1ae4
****************com.mengweijin.learning.basic.parallel.Test$User@71bc1ae4
****************com.mengweijin.learning.basic.parallel.Test$User@71bc1ae4
[GC (Allocation Failure) -- 7920K->7920K(9728K), 0.0024614 secs]
[Full GC (Ergonomics)  7920K->7766K(9728K), 0.0138717 secs]
[GC (Allocation Failure) -- 7766K->7766K(9728K), 0.0008774 secs]
[Full GC (Allocation Failure)  7766K->7750K(9728K), 0.0220654 secs]
Exception ************null

例如,一个程序用来处理用户提供的图片。如果将所有图片读入内存,这样虽然可以很快的打开图片,但内存空间使用巨大,一些使用较少的图片浪费内存空间,需要手动从内存中移除。如果每次打开图片都从磁盘文件中读取到内存再显示出来,虽然内存占用较少,但一些经常使用的图片每次打开都要访问磁盘,代价巨大。这个时候就可以用软引用构建缓存。

弱引用 WeakReference

一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC 发生时,不管内存够不够,都会被回收。 参看代码

java
public static void main(String[] args) {
    User u = new User(1, "aaa");
    WeakReference<User> userWeak = new WeakReference<>(u);
    // 干掉强引用,确保这个实例只有弱引用
    u = null;
    System.out.println(userWeak.get());
    // 进行一次垃圾回收
    System.gc();
    System.out.println("After GC");
    System.out.println(userWeak.get());
}

运行结果:

com.mengweijin.learning.basic.parallel.Test$User@71bc1ae4
[GC (System.gc())  2016K->692K(9728K), 0.0022419 secs]
[Full GC (System.gc())  692K->670K(9728K), 0.0100958 secs]
After GC
null

注意:软引用 SoftReference 和弱引用 WeakReference,可以用在内存资源紧张的情况下以及创建不是很重要的数据缓存。当系统内存不足的时候,缓存中的内容是可以被释放的。 实际运用(WeakHashMap、ThreadLocal)

虚引用 PhantomReference

幽灵引用,最弱(随时会被回收掉) 垃圾回收的时候收到一个通知,就是为了监控垃圾回收器是否正常工作。

对象的分配策略

栈上分配

没有逃逸(了解即可)

即方法中的对象没有发生逃逸。

逃逸分析的原理: 分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用。 比如:调用参数传递到其他方法中,这种称之为方法逃逸。甚至还有可能被外部线程访问到,例如:赋值给其他线程中访问的变量,这个称之为线程逃逸。 从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。 如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高 JVM 的效率。

  • 开启逃逸分析(JVM 默认开启)VM 参数:-XX:+DoEscapeAnalysis
  • 关闭逃逸分析 VM 参数:-XX:-DoEscapeAnalysis

逃逸分析

如果是逃逸分析出来的对象可以在栈上分配的话,那么该对象的生命周期就跟随线程了,就不需要垃圾回收,如果是频繁的调用此方法则可以得到很大的性能提高。 采用了逃逸分析后,满足逃逸的对象在栈上分配

没有开启逃逸分析,对象都在堆上分配,会频繁触发垃圾回收(垃圾回收会影响系统性能),导致代码运行慢

开启 GC 打印日志 VM 参数:-XX:+PrintGC

对象优先在 Eden 区分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间分配时,虚拟机将发起一次 Minor GC。

大对象直接进入老年代

大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。 大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,我们写程序的时候应注意避免。 在 Java 虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们。 而当复制对象时,大对象就意味着高额的内存复制开销。

HotSpot 虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在 Eden 区及两个 Survivor 区之间来回复制,产生大量的内存复制操作。

这样做的目的:1.避免大量内存复制,2.避免提前进行垃圾回收,明明内存有空间进行分配。 PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款收集器有效。-XX:PretenureSizeThreshold=4m

长期存活对象进入老年区

HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。 为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1,对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1,当它的年龄增加到一定程度(并发的垃圾回收器默认为 15), CMS 是 6 时,就会被晋升到老年代中。

-XX:MaxTenuringThreshold 调整

对象年龄动态判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中,相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄

空间分配担保

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

本地线程分配缓冲(TLAB)

具体见上面的章节:分配缓冲

垃圾回收机制及算法

垃圾回收基础知识

什么是 GC

Java 与 C++等语言最大的技术区别:自动化的垃圾回收机制(GC)

为什么要了解 GC 和内存分配策略?

  1. 面试需要
  2. GC 对应用的性能是有影响的;
  3. 写代码有好处
  • 栈:栈中的生命周期是跟随线程,所以一般不需要关注
  • 堆:堆中的对象是垃圾回收的重点
  • 方法区/元空间:这一块也会发生垃圾回收,不过这块的效率比较低,一般不是我们关注的重点

分代回收理论

当前商业虚拟机的垃圾回收器,大多遵循“分代收集”的理论来进行设计,这个理论大体上是这么描述的:

  1. 绝大部分的对象都是朝生夕死。
  2. 熬过多次垃圾回收的对象就越难回收。 根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一个区域,这个就构成了新生代和老年代。

GC 分类

市面上发生垃圾回收的叫法很多,我大体整理了一下:

  1. 新生代回收(Minor GC/Young GC):指只是进行新生代的回收。
  2. 老年代回收(Major GC/Old GC):指只是进行老年代的回收。目前只有 CMS 垃圾回收器会有这个单独的回收老年代的行为。 (Major GC 定义是比较混乱,有说指是老年代,有的说是做整个堆的收集,这个需要你根据别人的场景来定,没有固定的说法)
  3. 整堆回收(Full GC):收集整个 Java 堆和方法区(注意包含方法区)

垃圾回收算法

垃圾回收算法的实现设计到大量的程序细节,并且每一个平台的虚拟机操作内存的方式都有不同,所以不需要去了解算法的实现,我们重点讲解 3 种算法的思想。

复制算法(Copying)

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

但是要注意:内存移动是必须实打实的移动(复制),所以对应的引用(直接指针)需要调整。 复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。

Appel 式回收

一种更加优化的复制回收分代策略:具体做法是分配一块较大的 Eden 区和两块较小的 Survivor 空间(你可以叫做 From 或者 To,也可以叫做 Survivor1 和 Survivor2)

专门研究表明,新生代中的对象 98%是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor[1]。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上, 最后清理掉 Eden 和刚才用过的 Survivor 空间。

HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10%的内存会被 “浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要 依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)

标记-清除算法(Mark-Sweep)

算法分为“标记”和“清除”两个阶段:首先扫描所有对象标记出需要回收的对象,在标记完成后扫描回收所有被标记的对象,所以需要扫描两遍。 回收效率略低,如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率要低。

它的主要问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。 回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代。

标记-整理算法(Mark-Compact)

首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。标记整理算法虽然没有内存碎片,但是效率偏低。

我们看到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用对象的地方都需要更新(直接指针需要调整)。 所以看到,老年代采用的标记整理算法与标记清除算法,各有优点,各有缺点。

JVM 中常见的垃圾回收器

在新生代中,每次垃圾回收时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成回收。

而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

请记住下图的垃圾收集器和之间的连线关系。 Oracle 官方也有对应英文解释 (比较难理解,基于 java13 的)

Serial/Serial Old

配置 VM 参数:-XX:+UseSerialGC

JVM 刚诞生就只有这种,最古老的,单线程,独占式,成熟,适合单 CPU,一般用在客户端模式下。 这种垃圾回收器只适合几十兆到一两百兆的堆空间进行垃圾回收(可以控制停顿时间再 100ms 左右),但是对于超过这个大小的内存回收速度很慢,所以对于现在来说这个垃圾回收器已经是一个鸡肋。

参数设置

-XX:+UseSerialGC 新生代和老年代都用串行收集器

Stop The World(STW)

单线程进行垃圾回收时,必须暂停所有的工作线程,直到它回收结束。这个暂停称之为“Stop The World”,但是这种 STW 带来了恶劣的用户体验,例如:应用每运行一个小时就需要暂停响应 5 分。这个也是早期 JVM 和 java 被 C/C++语言诟病性能差的一个重要原因。所以 JVM 开发团队一直努力消除或降低 STW 的时间。

Parallel Scavenge(ParallerGC)/Parallel Old

配置 VM 参数:-XX:+UseParallelGC

为了提高回收效率,从 JDK1.3 开始,JVM 使用了多线程的垃圾回收机制,关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。 所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。该垃圾回收器适合回收堆空间上百兆~几个 G。

参数设置

开启参数 JDK1.8 默认就是以下组合 -XX:+UseParallelGC 新生代使用 Parallel Scavenge,老年代使用 Parallel Old 收集器提供了两个参数用于精确控制吞吐量,分别控制的停顿时间的-XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的-XX:GCTimeRatio 参数。 https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

-XX:MaxGCPauseMillis

不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集 300MB 新生代肯定比收集 500MB 快,但这也直接导致垃圾收集发生得更频繁,原来 10 秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次、 每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。

-XX:GCTimeRatio

-XX:GCTimeRatio 参数的值则应当是一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。

例如:把此参数设置为 19, 那允许的最大垃圾收集时占用总时间的 5% (即 1/(1+19)), 默认值为 99,即允许最大 1% (即 1/(1+99))的垃圾收集时间

由于与吞吐量关系密切,ParallelScavenge 是“吞吐量优先垃圾回收器”。

-XX:+UseAdaptiveSizePolicy

-XX:+UseAdaptiveSizePolicy (默认开启)。这是一个开关参数, 当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio)、 晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

ParNew

多线程垃圾回收器,与 CMS 进行配合,对于 CMS(CMS 只回收老年代),新生代垃圾回收器只有 Serial 与 ParNew 可以选。和 Serial 基本没区别,唯一的区别:多线程,多 CPU 的,停顿时间比 Serial 少。(在 JDK9 以后,把 ParNew 合并到了 CMS 了) 大致了解下搭配关系即可,后续版本已经接近淘汰。

Concurrent Mark Sweep (CMS)

配置 VM 参数:-XX:+UseConcMarkSweepGC

收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。

从名字(包含“Mark Sweep”)上就可以看出,CMS 收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些, 整个过程分为 4 个步骤,包括:

  • 初始标记-短暂,仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快。
  • 并发标记-和用户的应用程序同时进行,进行 GC Roots 追踪的过程,标记从 GCRoots 开始关联的所有对象开始遍历整个可达分析路径的对象。这个时间比较长,所以采用并发处理(垃圾回收器线程和用户线程同时-作)
  • 重新标记-短暂,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
  • 并发清除 由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

-XX:+UseConcMarkSweepGC ,表示新生代使用 ParNew,老年代的用 CMS

CPU 敏感: CMS 对处理器资源敏感,毕竟采用了并发的收集、当处理核心数不足 4 个时,CMS 对用户的影响较大。

浮动垃圾: 由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。 由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。

在 1.6 的版本中老年代空间使用率阈值(92%) 如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。

会产生空间碎片: 标记 - 清除算法会导致产生不连续的空间碎片

总体来说,CMS 是 JVM 推出了第一款并发垃圾收集器,所以还是非常有代表性。 但是最大的问题是 CMS 采用了标记清除算法,所以会有内存碎片,当碎片较多时,给大对象的分配带来很大的麻烦,为了解决这个问题,CMS 提供一个参数:-XX:+UseCMSCompactAtFullCollection,一般是开启的,如果分配不了大对象,就进行内存碎片的整理过程。 这个地方一般会使用 Serial Old ,因为 Serial Old 是一个单线程,所以如果内存空间很大、且对象较多时, CMS 发生这样情况会很卡。

CMS 总结

CMS 问题比较多,所以现在没有一个版本默认是 CMS,只能手工指定。但是它毕竟是第一个并发垃圾回收器,对于了解并发垃圾回收具有一定意义,所以我们必须了解。 为什么 CMS 采用标记-清除,在实现并发的垃圾回收时,如果采用标记整理算法,那么还涉及到对象的移动(对象的移动必定涉及到引用的变化,这个需要暂停业务线程来处理栈信息,这样使得并发收集的暂停时间更长),所以使用简单的标记-清除算法才可以降低 CMS 的 STW 的时间。 该垃圾回收器适合回收堆空间几个 G~ 20G 左右。

在 JDK1.8 中,配置参数:-XX:+UseConcMarkSweepGC https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

Garbage First(G1)

配置 VM 参数:-XX:+UseG1GC

设计思想

随着 JVM 中内存的增大,STW 的时间成为 JVM 急迫解决的问题,但是如果按照传统的分代模型,总跳不出 STW 时间不可预测这点。 为了实现 STW 的时间可预测,首先要有一个思想上的改变。

G1 将堆内存“化整为零”,将堆内存划分成多个大小相等独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。回收器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region

Region 可能是 Eden,也有可能是 Survivor,也有可能是 Old, 另外 Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。 G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。每个 Region 的大小可以通过参数-XX:G1HeapRegionSize 设定,取值范围为 1MB~32MB,且应为 2 的 N 次 幂。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中,G1 的进行回收大多数情况下都把 Humongous Region 作为老年代的一部分来进行看待。

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

初始标记( Initial Marking)

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

TAMS 是什么?

要达到 GC 与用户线程并发运行,必须要解决回收过程中新对象的分配,所以 G1 为每一个 Region 区域设计了两个名为 TAMS(Top at Mark Start)的指针, 从 Region 区域划出一部分空间用于记录并发回收过程中的新对象。这样的对象认为它们是存活的,不纳入垃圾回收范围。

并发标记( Concurrent Marking)

从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后 ,并发时有引用变动的对象,这些对象会漏标(后续再讲三色标记的时候会细讲这个问题),漏标的对象会被一个叫做 SATB(snapshot-at-the-beginning) 算法来解决(这个下节课会细讲)

最终标记( Final Marking)

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

筛选回收( Live Data Counting and Evacuation)

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

特点

  • 并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿的时间,部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
  • 分代收集:与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。
  • 空间整合:与 CMS 的“标记—清理”算法不同,G1 从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
  • 追求停顿时间: -XX:MaxGCPauseMillis 指定目标的最大停顿时间,G1 尝试调整新生代和老年代的比例,堆大小,晋升年龄来达到这个目标时间。

怎么玩? 该垃圾回收器适合回收堆空间上百 G。一般在 G1 和 CMS 中间选择的话平衡点在 6~8G,只有内存比较大 G1 才能发挥优势。

垃圾回收器整理

收集器收集对象和算法收集器类型说明适用场景
Serial新生代,复制算法单线程简单高效; 适合内存不大的情况;
ParNew新生代,复制算法并行的多线程收集器ParNew 垃圾收集器是 Serial 收集器的多线程版本搭配 CMS 垃圾回收器的首选
Parallel Scavenge 吞吐量优先收集器新生代,复制算法并行的多线程收集器类似 ParNew,更加关注吞吐量,达到一个可控制的吞吐量;本身是 Server 级别多 CPU 机器上的默认 GC 方式,主要适合后台运算不需要太多交互的任务;
Serial Old老年代,标记整理算法单线程Client 模式下虚拟机使用
Parallel Old老年代,标记整理算法并行的多线程收集器Parallel Scavenge 收集器的老年代版本,为了配合 Parallel Scavenge 的面向吞吐量的特性而开发的对应组合;在注重吞吐量以及 CPU 资源敏感的场合采用
CMS老年代,标记清除算法并行与并发收集器尽可能的缩短垃圾收集时用户线程停止时间;缺点在于: 1.内存碎片 2.需要更多 cpu 资源 3.浮动垃圾问题,需要更大的堆空间重视服务的响应速度、系统 停顿时间和用户体验的互联网网站或者 B/S 系统。互联网后端目前 cms 是主流的垃圾回收器;
G1跨新生代和老年代;标记整理 + 化整为零并行与并发收集器JDK1.7 才正式引入,采用分区回收的思维,基本不牺牲吞吐量的前提下完成低停顿的内存回收;可预测的停顿是其最大的优势;面向服务端应用的垃圾回收器,目标为取代 CMS
  • 并行:垃圾收集的多线程的同时进行。
  • 并发:垃圾收集的多线程和应用的多线程同时进行。
  • 注:吞吐量=运行用户代码时间/(运行用户代码时间+ 垃圾收集时间)
  • 垃圾收集时间= 垃圾回收频率 * 单次垃圾回收时间

垃圾回收器串讲及 HostSpot 的细节实现(JVM 面试“核武器”)

并发标记与三色标记

三色标记

在三色标记法之前有一个算法叫 Mark-And-Sweep(标记清除)。这个算法会设置一个标志位来记录对象是否被使用。最开始所有的标记位都是 0,如果发现对象是可达的就会置为 1,一步步下去就会呈现一个类似树状的结果。等标记的步骤完成后,会将未被标记的对象统一清理,再次把所有的标记位设置成 0 方便下次清理。

这个算法最大的问题是 GC 执行期间需要把整个程序完全暂停,不能异步进行 GC 操作。因为在不同阶段标记清扫法的标志位 0 和 1 有不同的含义, 那么新增的对象无论标记为什么都有可能意外删除这个对象。对实时性要求高的系统来说,这种需要长时间挂起的标记清扫法是不可接受的。所以就需要一个算法来解决 GC 运行时程序长时间挂起的问题,那就三色标记法。

三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个 GC。 三色标记法很简单。首先将对象用三种颜色表示,分别是白色、灰色和黑色。

  • 黑色: 根对象,或者该对象与它的子对象都被扫描过。
  • 灰色: 对本身被扫描,但是还没扫描完该对象的子对象。
  • 白色: 未被扫描对象,如果扫描完所有对象之后,最终为白色的为不可达对象,既垃圾对象。

三色标记的问题 —— GC 并发情况下可能发生漏标

CMS 中的解决方案 —— Incremental Update 算法

当一个白色对象被一个黑色对象引用,将黑色对象重新标记为灰色,让垃圾回收器重新扫描

G1 中的解决方案 —— SATB(snapshot-at-the-beginning)

刚开始做一个快照,当 B 和 C 消失的时候要把这个引用推到 GC 的堆栈,保证 C 还能被 GC 扫描到,最重要的是要把这个引用推到 GC 的堆栈,是灰色对象指向白色的引用,如果一旦某一个引用消失掉了,我会把它放到栈(GC 方法运行时数据也是来自栈中),我其实还是能找到它的,我下回直接扫描他就行了,那样白色就不会漏标。

对应 G1 的垃圾回收过程中的:

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

对比

SATB 算法是关注引用的删除。(B->C 的引用)

Incremental Update 算法关注引用的增加。(A->C 的引用)

G1 如果使用 Incremental Update 算法,因为变成灰色的成员还要重新扫,重新再来一遍,效率太低了。

所以 G1 在处理并发标记的过程比 CMS 效率要高,这个主要是解决漏标的算法决定的。

跨代引用

堆空间通常被划分为新生代和老年代。由于新生代的垃圾收集通常很频繁,如果老年代对象引用了新生代的对象,那么回收新生代的话,需要跟踪从老年代到新生代的所有引用,所以要避免每次 YGC 时扫描整个老年代,减少开销。

RSet(记忆集)

记录了其他 Region 中的对象到本 Region 的引用, RSet 的价值在于使得垃圾收集器不需要扫描整个堆, 找到谁引用了当前分区中的对象,只需要扫描 RSet 即可。 RSet 本身就是一个 Hash 表,如果是在 G1 的话,则是在一个 Region 区里面。

CardTable

由于做新生代 GC 时,需要扫描整个 OLD 区,效率非常低,所以 JVM 设计了 CardTable, 如果一个 OLD 区 CardTable 中有对象指向 Y 区, 就将它设为 Dirty (标志位 1), 下次扫描时,只需要扫描 CARDTABLE 上是 Dirty 的内存区域即可。

字节数组 CARDTABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。 一般来说,卡页大小都是以 2 的 N 次幂的字节数,假设使用的卡页是 2 的 10 次幂,即 1M,内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 0、1、2 号元素,分别 对应了地址范围为 0x0000~0x03FF、0x0400 ~ 0x07FF、0x0800~0x011FF 的卡页内存.

总结

这里描述的是 G1 处理跨代引用的细节,其实在 CMS 中也有类似的处理方式,比如 CardTable, 也需要记录一个 RSet 来记录,我们对比一下,在 G1 中是每一个 Region 都需要一个 RSet 的内存区域,导致有 G1 的 RSet 可能会占据整个堆容量的 20%乃至更多。但是 CMS 只需要一份,所以就内存占用来说,G1 占用的内存需求更大,虽然 G1 的优点很多,但是我们不推荐在堆空间比较小的情况下使用 G1,尤其小于 6 个 G。

安全点与安全区域

安全点

用户线程暂停,GC 线程要开始工作,但是要确保用户线程暂停的这行字节码指令是不会导致引用关系的变化。所以 JVM 会在字节码指令中,选一些指令, 作为“安全点”,比如方法调用、循环跳转、异常跳转等,一般是这些指令才会产生安全点。

为什么它叫安全点,是这样的,GC 时要暂停业务线程,并不是抢占式中断(立马把业务线程中断)而是主动是中断。 主动式中断是设置一个标志,这个标志是中断标志,各业务线程在运行过程中会不停的主动去轮询这个标志,一旦发现中断标志为 True,就会在自己最近的“安全点”上主动中断挂起。

安全区域

为什么需要安全区域?

要是业务线程都不执行(业务线程处于 Sleep 或者是 Blocked 状态),那么程序就没办法进入安全点,对于这种情况,就必须引入安全区域。 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,这段时间里 JVM 要发起 GC 就不必去管这个线程了。

当线程要离开安全区域时,它要 JVM 是否已经完成了(根节点枚举,或者其他 GC 中需要暂停用户线程的阶段)

  1. 如果完成了,那线程就当作没事发生过,继续执行。
  2. 否则它就必须一直等待, 直到收到可以离开安全区域的信号为止。

低延迟的垃圾回收器

垃圾回收器三项指标

传统的垃圾回收器一般情况下 内存占用、吞吐量、延时 只能同时满足两个。但是现在的发展,延迟这项的目标越来越重要。所以就有低延迟的垃圾回收器。

Eplison(了解即可)

这个垃圾回收器不能进行垃圾回收,是一个“不干活”的垃圾回收器,由 RedHat 推出,它还要负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责,主要用于需要剥离垃圾收集器影响的性能测试和压力测试。

ZGC(了解即可)

有类似于 G1 的 Region,但是没有分代。 标志性的设计是染色指针 ColoredPointers(这个概念了解即可),染色指针有 4TB 的内存限制,但是效率极高,它是一种将少量额外的信息存储在指针上的技术。 它可以做到几乎整个收集过程全程可并发,短暂的 STW 也只与 GC Roots 大小相关而与堆空间内存大小无关,因此考科一实现任何堆空间 STW 的时间小于十毫秒的目标。

Shenandoah(了解即可)

第一款非 Oracle 公司开发的垃圾回收器,有类似于 G1 的 Region,但是没有分代。 也用到了染色指针 ColoredPointers。 效率没有 ZGC 高,大概几十毫秒的目标。

GC 参数

GC 日志详解

image

GC 常用参数

  • -Xmn -Xms -Xmx –Xss 年轻代 最小堆 最大堆 栈空间
  • -XX:+UseTLAB 使用 TLAB,默认打开
  • -XX:+PrintTLAB 打印 TLAB 的使用情况
  • -XX:TLABSize 设置 TLAB 大小
  • -XX:+DisableExplicitGC 启用用于禁用对的调用处理的选项 System.gc()
  • -XX:+PrintGC 查看 GC 基本信息
  • -XX:+PrintGCDetails 查看 GC 详细信息
  • -XX:+PrintHeapAtGC 每次一次 GC 后,都打印堆信息
  • -XX:+PrintGCTimeStamps 启用在每个 GC 上打印时间戳的功能
  • -XX:+PrintGCApplicationConcurrentTime 打印应用程序时间(低)
  • -XX:+PrintGCApplicationStoppedTime 打印暂停时长(低)
  • -XX:+PrintReferenceGC 记录回收了多少种不同引用类型的引用(重要性低)
  • -verbose:class 类加载详细过程
  • -XX:+PrintVMOptions 可在程序运行时,打印虚拟机接受到的命令行显示参数
  • -XX:+PrintFlagsFinal -XX:+PrintFlagsInitial 打印所有的 JVM 参数、查看所有 JVM 参数启动的初始值(必须会用)
  • -XX:MaxTenuringThreshold 升代年龄,最大值 15, 并行(吞吐量)收集器的默认值为 15,而 CMS 收集器的默认值为 6。

Parallel 常用参数

  • -XX:SurvivorRatio 设置 eden 空间大小与幸存者空间大小之间的比率。默认情况下,此选项设置为 8
  • -XX:PreTenureSizeThreshold 大对象到底多大,大于这个值的参数直接在老年代分配
  • -XX:MaxTenuringThreshold 升代年龄,最大值 15, 并行(吞吐量)收集器的默认值为 15,而 CMS 收集器的默认值为 6。
  • -XX:+ParallelGCThreads 并行收集器的线程数,同样适用于 CMS,一般设为和 CPU 核数相同
  • -XX:+UseAdaptiveSizePolicy 自动选择各区大小比例

CMS 常用参数

-XX:+UseConcMarkSweepGC 启用 CMS 垃圾回收器 -XX:+ParallelGCThreads 并行收集器的线程数,同样适用于 CMS,一般设为和 CPU 核数相同 -XX:CMSInitiatingOccupancyFraction 使用多少比例的老年代后开始 CMS 收集,默认是 68%(近似值),如果频繁发生 SerialOld 卡顿,应该调小,(频繁 CMS 回收) -XX:+UseCMSCompactAtFullCollection 在 FGC 时进行压缩 -XX:CMSFullGCsBeforeCompaction 多少次 FGC 之后进行压缩 -XX:+CMSClassUnloadingEnabled 使用并发标记扫描(CMS)垃圾收集器时,启用类卸载。默认情况下启用此选项。 -XX:CMSInitiatingPermOccupancyFraction 达到什么比例时进行 Perm 回收,JDK 8 中不推荐使用此选项,不能替代。 -XX:GCTimeRatio 设置 GC 时间占用程序运行时间的百分比(不推荐使用) -XX:MaxGCPauseMillis 停顿时间,是一个建议时间,GC 会尝试用各种手段达到这个时间,比如减小年轻代

G1 常用参数

-XX:+UseG1GC 启用 CMS 垃圾收集器 -XX:MaxGCPauseMillis 设置最大 GC 暂停时间的目标(以毫秒为单位)。这是一个软目标,并且 JVM 将尽最大的努力(G1 会尝试调整 Young 区的块数来)来实现它。默认情况下,没有最大暂停时间值。 -XX:GCPauseIntervalMillis GC 的间隔时间 -XX:+G1HeapRegionSize 分区大小,建议逐渐增大该值,1 2 4 8 16 32。随着 size 增加,垃圾的存活时间更长,GC 间隔更长,但每次 GC 的时间也会更长 -XX:G1NewSizePercent 新生代最小比例,默认为 5% -XX:G1MaxNewSizePercent 新生代最大比例,默认为 60% -XX:GCTimeRatioGC 时间建议比例,G1 会根据这个值调整堆空间 -XX:ConcGCThreads 线程数量 -XX:InitiatingHeapOccupancyPercent 启动 G1 的堆空间占用比例,根据整个堆的占用而触发并发 GC 周期

Class 文件结构及深入字节码指令

JVM 的无关性

与平台无关性是建立在操作系统上,虚拟机厂商提供了许多可以运行在各种不同平台的虚拟机,它们都可以载入和执行字节码,从而实现程序的“一次 编写,到处运行”

https://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html

各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode)是构成平台无关性的基石,也是语言无关性的基础。Java 虚拟机不和包括 Java 在内的任何语言绑定,它只与“Class 文件”这种特定的二进制文件格式所关联,Class 文件中包含了 Java 虚拟机指令集和符号表以及若干其他辅助信息。

Class 类文件(了解即可)

Java 技术能够一直保持非常好的向后兼容性,这点 Class 文件结构的稳定性功不可没。Java 已经发展到 14 版本,但是 class 文件结构的内容,绝大部分在 JDK1.2 时代就已经定义好了。虽然 JDK1.2 的内容比较古老,但是 java 发展经历了十余个大版本,但是每次基本上知识在原有结构基础上新增内容、扩充 功能,并未对定义的内容做修改。 任何一个 Class 文件都对应着唯一一个类或接口的定义信息,但反过来说,Class 文件实际上它并不一定以磁盘文件的形式存在(比如可以动态生成、或者 直接送入类加载器中)。 Class 文件是一组以 8 位字节为基础单位的二进制流。 Class 文件结构这些内容在面试的时候很少有人问,因此大家学这个东西要当成一个兴趣去学,这个是自身内力提升的过程。

工具介绍

javap

javap 是 JDK 自带的反解析工具。它的作用是将 .class 字节码文件解析成可读的文件格式。 在使用 javap 时我一般会添加 -v 参数,尽量多打印一些信息。同时,我也会使用 -p 参数,打印一些私有的字段和方法。

jclasslib

如果你不太习惯使用命令行的操作,还可以使用 jclasslib,jclasslib 是一个图形化的工具,能够更加直观的查看字节码中的内容。它还分门别类的对类中的各个部分进行了整理,非常的人性化。同时,它还提供了 Idea 的插件,你可以从 plugins 中搜索到它。 jclasslib 的下载地址:https://github.com/ingokegel/jclasslib

阅读字节码作为了解 Java 虚拟机的基础技能,有需要的话可以去掌握常见指令。

字节码助记码解释地址:https://cloud.tencent.com/developer/article/1333540

字节码指令——装箱拆箱

装箱拆箱

Java 中有 8 种基本类型,但鉴于 Java 面向对象的特点,它们同样有着对应的 8 个包装类型,比如 int 和 Integer,包装类型的值可以为 null(基本类 型没有 null 值,而数据库的表中普遍存在 null 值。 所以实体类中所有属性均应采用封装类型),很多时候,它们都能够相互赋值。

java
public Integer calc() {
    // 实际调用了 Integer.valueOf(); 方法 装箱
    Integer a = 1000;
    // 实际调用了 Integer.intValue(); 方法 拆箱
    int b = a * 10;
    return b;
}

通过观察字节码,我们发现:

  1. 在进行乘法运算的时候,调用了 Integer.intValue 方法来获取基本类型的值。
  2. 赋值操作使用的是 Integer.valueOf 方法。
  3. 在方法返回的时候,再次使用了 Integer.valueOf 方法对结果进行了包装。 这就是 Java 中的自动装箱拆箱的底层实现。

IntegerCache

但这里有一个陷阱问题,我们继续跟踪 Integer.valueOf 方法

java
public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

这个 IntegerCache,缓存了 low 和 high 之间的 Integer 对象

一般情况下,缓存是的-128 到 127 之间的值,但是可以通过 -XX:AutoBoxCacheMax 来修改上限。

下面是一道经典的面试题,请考虑一下运行代码后,会输出什么结果?

java
public static void main(String[] args) {
    Integer n1 = 123;
    Integer n2 = 123;
    Integer n3 = 128;
    Integer n4 = 128;
    System.out.println(n1 == n2);
    System.out.println(n3 == n4);
}

一般情况下输出的是 true, false 因为缓存的原因。(在缓存范围内的值,返回的是同一个缓存值,不在的话,每次都是 new 出来的)

当我加上 VM 参数 -XX:AutoBoxCacheMax=256 执行时,结果是 true, ture,扩大缓存范围,第二个为 true 原因就在于此。

字节码指令——foreach

无论是 Java 的数组,还是 List,都可以使用 foreach 语句进行遍历,虽然在语言层面它们的表现形式是一致的,但实际实现的方法并不同。

java
// 数组:它将代码解释成了传统的变量方式,即 for(int i;i < length; i++) 的形式。
void loop(int[] arr) {
    for(int i: arr) {
        System.out.println(i);
    }
}

// List 的它实际是把 list 对象进行迭代并遍历的,在循环中,使用了 Iterator.next() 方法。
void loop(List<Integer> arr) {
    for(int i: arr) {
        System.out.println(i);
    }
}

使用 IntelliJ IDEA、jd-gui 等反编译工具,可以看到实际生成的代码:

java
void loop(int[] arr) {
    int[] var2 = arr;
    int var3 = arr.length;
    for(int var4 = 0; var4 < var3; ++var4) {
        int i = var2[var4];
        System.out.println(i);
    }
}

void loop(List<Integer> arr) {
    Iterator var2 = arr.iterator();
    while(var2.hasNext()) {
        int i = (Integer)var2.next();
        System.out.println(i);
    }
}

字节码指令总结

Java 的特性非常多,这里不再一一列出,但都可以使用这种简单的方式,从字节码层面分析了它的原理,一窥究竟。

比如异常的处理、finally 块的执行顺序;以及隐藏的装箱拆箱和 foreach 语法糖的底层实现。 还有字节码指令,可能有几千行,看起来很吓人,但执行速度几乎都是纳秒级别的。Java 的无数框架,包括 JDK,也不会为了优化这种性能对代码进行限制。了解其原理,但不要舍本逐末,比如减少一次 Java 线程的上下文切换,就比你优化几千个装箱拆箱动作,速度来的更快一些。

玩转类加载与类加载器

类生命周期 7 个阶段

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、 初始化(Initialization)、使用(Using)和卸载(Unloading)7 个阶段。其中验证、准备、解析 3 个部分统称为连接(Linking)

阶段顺序

加载、校验、准备、初始化和卸载这五个阶段的顺序是确定的,但是对于“解析”阶段则不一定,它在某些情况下可以在初始化之后再开始,这样做是为了支持 java 的运行时绑定特征(也称为动态绑定或晚期绑定)。

加载的时机

什么是需要开始类第一个阶段“加载”,虚拟机规范没有强制约束,这点交给虚拟机的具体实现来自由把控。 “加载 loading”阶段是整个类加载(class loading)过程的一个阶段。 加载阶段虚拟机需要完成以下 3 件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。 注意:比如“通过一个类的全限定名来获取定义此类的二进制字节流”没有指定一定得从某个 class 文件中获取,所以我们可以从 zip 压缩包、从网络中获取、运行时计算生成、数据库中读取、或者从加密文件中获取等等

验证

是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。但从整体上看,验证阶段大致上会完成下面 4 个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

文件格式验证(非重点)

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

  • 是否以魔数 OxCAFEBABE 开头。
  • 主、次版本号是否在当前 Java 虚拟机接受范围之内。
  • 常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志)。
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
  • CONSTANT Utf8 info 型的常量中是否有不符合 UTF-8 编码的数据。
  • Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息。
  • ...... 以上的部分还只是一小部分,没必要进行深入的研究。

总结一下:

这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进人 Java 虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构(内存)上进行的,不会再直接读取、操作字节流了。

元数据验证(非重点)

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

  • 这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类)。
  • 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)。
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方法重载,例如方法参数都-致,但返回值类型却不同等)。
  • ...... 以上的部分还只是一小部分,没必要进行深入的研究。 元数据验证是验证的第二阶段,主要目的是对类的元数据信息进行语义校验,保证不存在与《Java 语言规范》定义相悖的元数据信息。
字节码验证(非重点)

字节码验证第三阶段是整个验证过程中最复杂的一一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class 文件中的 Code 属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,例如:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中”这样的情况。
  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
  • 保证方法体中的类型转换总是有效的,例如可以把-个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
  • ...... 以上的部分还只是一小部分,没必要进行深入的研究。 如果一个类型中有方法体的字节码没有通过字节码验证,那它肯定是有问题的。
符号引用验证(非重点)

最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段一解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
  • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
  • 符号引用中的类、字段、方法的可访问性( private、 protected. public、 package )
  • 是否可被当前类访问。
  • ...... 符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,将会抛出异常
验证总结

验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

准备

准备阶段是正式为类中定义的变量(被 static 修饰的变量)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。 这个阶段中有两个容易产生混淆的概念需要强调一下:

首先,这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。

其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为: public static int value=123; 那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 123 是后续的初始化环节。

基本数据类型的零值表
数据类型零值数据类型零值
int0booleanfalse
long0Lfloat0.0f
short(short) 0double0.0d
char‘\u0000’referencenull
byte(byte)0

解析

解析阶段是 JVM 将常量池内的符号引用替换为直接引用的过程。 符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。 直接引用的对象都存在于内存中,你可以把通讯录里的女友手机号码,类比为符号引用,把面对面和你吃饭的女朋友,类比为直接引用。

解析大体可以分为:(不重要)

  • 类或接口的解析
  • 字段解析
  • 类方法解析
  • 接口方法解析 我们了解几个经常发生的异常,就与这个阶段有关。
  • java.lang.NoSuchFieldError 根据继承关系从下往上,找不到相关字段时的报错。(字段解析异常)
  • java.lang.IllegalAccessError 字段或者方法,访问权限不具备时的错误。(类或接口的解析异常)
  • java.lang.NoSuchMethodError 找不到相关方法时的错误。(类方法解析、接口方法解析时发生的异常)

初始化

初始化主要是对一个 class 中的 static{}语句进行操作(对应字节码就是 clinit 方法)。 < clinit >()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成< clinit >()方法。

初始化阶段,虚拟机规范则是严格规定了有且只有 6 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  1. 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:
    • 使用 new 关键字实例化对象的时候。
    • 读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候
    • 调用一个类的静态方法的时候。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了 JDK1.8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

线程安全

虚拟机会保证一个类的< clinit >()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类 的< clinit >()方法,其他线程都需要阻塞等待,直到活动线程执行< clinit >()方法完毕。如果在一个类的< clinit >()方法中有耗时很长的操作,就 可能造成多个进程阻塞。所以类的初始化是线程安全的,项目中可以利用这点。

类加载器

整个类加载过程任务非常繁重,虽然这活儿很累,但总得有人干。类加载器做的就是上面 5 个步骤的事(加载、验证、准备、解析、初始化)。

JDK 提供的三层类加载器

Bootstrap ClassLoader

这是加载器中的扛把子,任何类的加载行为,都要经它过问。它的作用是加载核心类库,也就是 rt.jar、resources.jar、charsets.jar 等。当然这些 jar 包的路径是可以指定的,-Xbootclasspath 参数可以完成指定操作。 这个加载器是 C++ 编写的,随着 JVM 启动。

Extention ClassLoader

扩展类加载器,主要用于加载 lib/ext 目录下的 jar 包和 .class 文件。同样的,通过系统变量 java.ext.dirs 可以指定这个目录。 这个加载器是个 Java 类,继承自 URLClassLoader。

Application ClassLoader

这是我们写的 Java 类的默认加载器,有时候也叫作 System ClassLoader。一般用来加载 classpath 下的其他所有 jar 包和 .class 文件,我们写的代码, 会首先尝试使用这个类加载器进行加载。

Custom ClassLoader

自定义加载器,支持一些个性化的扩展功能。

类加载器的问题

如果你在项目代码里,写一个 java.lang 的包,然后改写 String 类的一些行为,编译后,发现并不能生效。JRE 的类当然不能轻易被覆盖,否则会被别有用心的人利用,这就太危险了。 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一 个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

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

双亲委派机制

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

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。

我们可以翻阅 JDK 代码的 ClassLoader#loadClass 方法,来看一下具体的加载过程。和我们描述的一样,它首先使用 parent 尝试进行类加载,parent 失败后才轮到自己。同时,我们也注意到,这个方法是可以被覆盖的,也就是双亲委派机制并不一定生效。

自定义类加载器

Tomcat 类加载机制

tomcat 通过 war 包进行应用的发布,它其实是违反了双亲委派机制原则的。简单看一下 tomcat 类加载器的层次结构。

image

对于一些需要加载的非基础类,会由一个叫作 WebAppClassLoader 的类加载器优先加载。等它加载不到的时候,再交给上层的 ClassLoader 进行加载。 这个加载器用来隔绝不同应用的 .class 文件,比如你的两个应用,可能会依赖同一个第三方的不同版本,它们是相互没有影响的。

如何在同一个 JVM 里,运行着不兼容的两个版本,当然是需要自定义加载器才能完成的事。那么 tomcat 是怎么打破双亲委派机制的呢?可以看图中的 WebAppClassLoader,它加载自己目录下的 .class 文件,并不会传递给父类的加载器。但是,它却可以使用 SharedClassLoader 所加载的类,实现了共享和分离的功能。

但是你自己写一个 ArrayList,放在应用目录里,tomcat 依然不会加载。它只是自定义的加载器顺序不同,但对于顶层来说,还是一样的。

SPI (Service Provider Interface)

Java 中有一个 SPI 机制,全称是 Service Provider Interface,是 Java 提供的一套用来被第三方实现或者扩展的 API,它可以用来启用框架扩展和替换组件。

这个说法可能比较晦涩,但是拿我们常用的数据库驱动加载来说,就比较好理解了。在使用 JDBC 写程序之前,通常会调用下面这行代码,用于加载所需要的驱动类。

Class.forName("com.mysql.jdbc.Driver") 这只是一种初始化模式,通过 static 代码块显式地声明了驱动对象,然后把这些信息,保存到底层的一个 List 中。这种方式我们不做过多的介绍,因为这明显就是一个接口编程的思路(这里不进行细讲)。

但是你会发现,即使删除了 Class.forName 这一行代码,也能加载到正确的驱动类,什么都不需要做,非常的神奇,它是怎么做到的呢?

MySQL 的驱动代码,就是在这里实现的。 路径:mysql-connector-java-8.0.11.jar!/META-INF/services/java.sql.Driver 里面的内容是:com.mysql.cj.jdbc.Driver

通过在 META-INF/services 目录下,创建一个以接口全限定名为命名的文件(内容为实现类的全限定名),即可自动加载这一种实现,这就是 SPI。 SPI 实际上是“基于接口的编程+策略模式+配置文件”组合实现的动态加载机制,主要使用 java.util.ServiceLoader 类进行动态装载。

这种方式,同样打破了双亲委派的机制。 DriverManager 类和 ServiceLoader 类都是属于 rt.jar 的。它们的类加载器是 Bootstrap ClassLoader,也就是最上层的那个。 而具体的数据库驱动,却属于业务代码,这个启动类加载器是无法加载的。这就比较尴尬了,虽然凡事都要祖先过问,但祖先没有能力去做这件事情,怎么办?

通过代码你可以发现它把当前的类加载器,设置成了线程的上下文类加载器。那么,对于一个刚刚启动的应用程序来说,它当前的加载器是谁呢?也就是说,启动 main 方法的那个加载器,到底是哪一个? 所以我们继续跟踪代码。找到 Launcher 类,就是 jre 中用于启动入口函数 main 的类。

到此为止,事情就比较明朗了,当前线程上下文的类加载器,是应用程序类加载器。使用它来加载第三方驱动。

总结一下

  • 第一, 可以让你更好的看到一个打破规则的案例(虽然应该是属于 BootStrap 类加载器加载的,但是还是在 app 类加载器去加载的它)。
  • 第二, 这个问题面试时出现的几率也是比较高的,你需要好好理解。

OSGi(了解)

OSGi 曾经非常流行,Eclipse 就使用 OSGi 作为插件系统的基础。OSGi 是服务平台的规范,旨在用于需要长运行时间、动态更新和对运行环境破坏最小的系统。

OSGi 规范定义了很多关于包生命周期,以及基础架构和绑定包的交互方式。这些规则,通过使用特殊 Java 类加载器来强制执行,比较霸道。 比如,在一般 Java 应用程序中,classpath 中的所有类都对所有其他类可见,这是毋庸置疑的。但是,OSGi 类加载器基于 OSGi 规范和每个绑定包的 manifest.mf 文件中指定的选项,来限制这些类的交互,这就让编程风格变得非常的怪异。但我们不难想象,这种与直觉相违背的加载方式,这些都是由专用的类加载器来实现的。

随着 JPMS 的发展(JDK9 引入的,旨在为 Java SE 平台设计、实现一个标准的模块系统),现在的 OSGi,深入研究意义已经不是很大了。OSGi 是一个庞大的话题(技术上),你只需要了解到,有这么一个复杂的东西,实现了模块化,每个模块可以独立安装、启动、停止、卸载,就可以了。

OSGI 一般的公司玩不转,都是阿里这些大公司在用。从大家研究技术的角度上来,就算你去这些公司,再去学习也没问题(阿里不可能要求一个小厂出来的程序员对 OSGI 精通)。主要精力还是把放在类加载、双亲委派,以及如何打破这些问题解决即可。

方法调用的底层实现

我们写的代码,经过编译、经过类加载的各种阶段,进入了 JVM 的运行时数据区。 但作为程序员真正关心是代码的执行,代码的执行其实本质上是方法的执行,站在 JVM 的角度归根到底还是字节码的执行。 main 函数是 JVM 指令执行的起点,JVM 会创建 main 线程来执行 main 函数,以触发 JVM 一系列指令的执行,真正地把 JVM 跑起来。 接着,在我们的代码中,就是方法调用方法的过程,所以了解方法在 JVM 中的调用是非常必要的。

方法调用的字节码指令

关于方法的调用,Java 字节码 共提供了 5 个指令,来调用不同类型的方法:

  • invokestatic 用来调用静态方法;
  • invokespecial 用于调用私有实例方法、构造器及 super 关键字等;
  • invokevirtual 用于调用非私有实例方法,比如 public 和 protected,大多数方法调用属于这一种;
  • invokeinterface 和上面这条指令类似,不过作用于接口类;
  • invokedynamic 用于调用动态方法。

非虚方法

如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法称为非虚方法。

只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java 语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法 4 种,再加上被 final 修饰的方法(尽管它使用 invokevirtual 指令调用),这 5 种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。不需要在运行时再去完成。

虚方法

与非虚方法相反,不是非虚方法的方法就是虚方法。主要包括以下字节码中的两类

  • invokevirtual 用于调用非私有实例方法,比如 public 和 protected,大多数方法调用属于这一种(排除掉被 final 修饰的方法);
  • invokeinterface 和上面这条指令类似,不过作用于接口类;

为什么叫做虚方法呢?就是方法在运行时是可变的。

很多时候,JVM 需要根据调用者的动态类型,来确定调用的目标方法,这就是动态绑定的过程;相对比,invokestatic 指令加上 invokespecial 指令,就属于静态绑定过程。 因为 invokeinterface 指令跟 invokevirtual 类似,只是作用与接口,所以我们只要熟悉 invokevirtual 即可。

分派

要了解虚方法我们必须了解以下基础: Java 是一门面向对象的程序语言,因为 Java 具备面向对象的 3 个基本特征: 继承、封装和多态。 分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在 Java 虚拟机之中是如何实现的

静态分派

多见于方法的重载。(重载:一个类中允许同时存在一个以上的同名方法,这些方法的参数个数或者类型不同)

“Human”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type)。 静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

代码中定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human) 作为调用目标。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。 静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

总结:例子很简单,方法会根据你送入的参数有不同的表现形式,这个就是分派。 举个简单的例子:你在酒吧遇到一个你心动的人,但这个人看上去不男不女,你怎么去与他/她打招呼?这个时候我至少知道是一个人, 所以打招呼说:地球人!你好,我是来自火星的某某老师(hello guy!)。

我调用它最原始的外观类型(至少是个人)

动态分派

多见于方法的重写。(重写:在子类中将父类的成员方法的名称保留,重新编写成员方法的实现内容,更改方法的访问权限,修改返回类型的为父类返回类型的子类。)

重写也是使用 invokevirtual 指令,只是这个时候具备多态性。 invokevirtual 指令有多态查找的机制,该指令运行时,解析过程如下:

  • 找到操作数栈顶的第一个元素所指向的对象实际类型,记做 c;
  • 如果在类型 c 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法直接引用,查找过程结束,不通过则返回 java.lang.IllegalAccessError;
  • 否则,按照继承关系从下往上依次对 c 的各个父类进行第二步的搜索和验证过程;
  • 如果始终没找到合适的方法,则抛出 java.lang.AbstractMethodError 异常,这就是 Java 语言中方法重写的本质。
    • 另外一点,这个时候我如果结合之前课程中讲过虚拟机栈中的内容,我就知道动态链接是干嘛的:
    • invokevirtual 可以知道方法 call()的符号引用转换是在运行时期完成的,在方法调用的时候。部分符号引用在运行期间转化为直接引用,这种转化就是动态链接。

方法表

动态分派会执行非常频繁的动作,JVM 运行时会频繁的、反复的去搜索元数据,所以 JVM 使用了一种优化手段,这个就是在方法区中建立一个虚方法表。 使用虚方法表索引来替代元数据查找以提高性能。

在实现上,最常用的手段就是为类在方法区中建立一个虚方法表。虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那 子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。PPT 图中,Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。

接口调用

invokeinterface 和 invokevirtual 指令类似,不过作用于接口类;

Lambda 表达式

invokedynamic 这个字节码是比较复杂。和反射类似,它用于一些动态的调用场景,但它和反射有着本质的不同,效率也比反射要高得多。

invokedynamic

这个指令通常在 Lambda 语法中出现。 使用 javap -p -v 命令可以在 main 方法中看到 invokedynamic 指令

方法句柄(MethodHandle)

官方文档解释:https://docs.oracle.com/javase/7/docs/api/java/lang/invoke/MethodHandles.html

invokedynamic 指令的底层,是使用方法句柄(MethodHandle)来实现的。方法句柄是一个能够被执行的引用,它可以指向静态方法和实例方法,以及虚构的 get 和 set 方法,从以下案例中可以看到 MethodHandle 提供的一些方法。

java
MethodHandles.Lookup lookup = MethodHandles.lookup();
// lookup.findConstructor();
// lookup.findGetter();
// lookup.findSetter();
// lookup.findSpecial();
// lookup.findStatic();
// lookup.findStaticGetter();
// lookup.findStaticSetter();
// lookup.findVirtual();

MethodType methodType = MethodType.methodType(String.class);

MethodHandle methodHandle = lookup.findVirtual(obj.getClass(), "sound", methodType);
String obj = (String) methodHandle.invoke(obj);
return obj;

可以看到,使用 MethodHandles 也可以执行类似于反射的方法调用,但 MethodHandles 的执行效率比反射要高。Lambda 表达式就是用 MethodHandles 实现的动态调用。

MethodHandle 是什么?简单的说就是方法句柄,通过这个句柄可以调用相应的方法。 用 MethodHandle 调用方法的流程为:

  • 创建 MethodType,获取指定方法的签名(出参和入参)
  • 在 Lookup 中查找 MethodType 的方法句柄 MethodHandle
  • 传入方法参数通过 MethodHandle 调用方法

MethodType

MethodType 表示一个方法类型的对象,每个 MethodHandle 都有一个 MethodType 实例,MethodType 用来指明方法的返回类型和参数类型。其有多个工厂方法的重载。

Lookup

MethodHandle.Lookup 可以通过相应的 findxxx 方法得到相应的 MethodHandle,相当于 MethodHandle 的工厂方法。查找对象上的工厂方法对应于方法、构造函数和字段的所有主要用例。

findStatic 相当于得到的是一个 static 方法的句柄(类似于 invokestatic 的作用),findVirtual 找的是普通方法(类似于 invokevirtual 的作用)

invoke

其中需要注意的是 invoke 和 invokeExact, 前者在调用的时候可以进行返回值和参数的类型转换工作,而后者是精确匹配的。 所以一般在使用是,往往 invoke 使用比 invokeExact 要多,因为 invokeExact 如果类型不匹配,则会抛错。

Lambda 表达式的捕获与非捕获

当 Lambda 表达式访问一个定义在 Lambda 表达式体外的非静态变量或者对象时,这个 Lambda 表达式称为“捕获的”

那么“非捕获”的 Lambda 表达式来就是 Lambda 表达式没有访问一个定义在 Lambda 表达式体外的非静态变量或者对象

Lambda 表达式是否是捕获的和性能悄然相关。一个非捕获的 lambda 通常比捕获的更高效,非捕获的 lambda 只需要计算一次. 然后每次使用到它都会返回一个唯一的实例。而捕获的 lambda 表达式每次使用时都需要重新计算一次,而且从目前实现来看,它很像实例化一个匿名内部类的实例。 lambda 最差的情况性能内部类一样, 好的情况肯定比内部类性能高。

Oracle 公司的性能比较的文档,详细而全面的比较了 lambda 表达式和匿名函数之间的性能差别。 lambda 开发组也有一篇 PPT 其中也讲到了 lambda 的性能(包括 capture 和非 capture 的情况)。 lambda 最差的情况性能内部类一样, 好的情况肯定比内部类性能高。

总结

Lambda 语言实际上是通过方法句柄来完成的,大致这么实现(JVM 编译的时候使用 invokedynamic 实现 Lambda 表达式,invokedynamic 的是使用 MethodHandle 实现的,所以 JVM 会根据你编写的 Lambda 表达式的代码,编译出一套可以去调用 MethodHandle 的字节码代码,参考实例类:MethodHandleDemo)

句柄类型(MethodType)是我们对方法的具体描述,配合方法名称,能够定位到一类函数。访问方法句柄和调用原来的指令基本一致,但它的调用异常,包括一些权限检查,在运行时才能被发现。

案例中,我们完成了动态语言的特性,通过方法名称和传入的对象主体,进行不同的调用,而 Bike 和 Man 类,可以没有任何关系。

可以看到 Lambda 语言实际上是通过方法句柄来完成的,在调用链上自然也多了一些调用步骤,那么在性能上,是否就意味着 Lambda 性能低呢?对于 大部分“非捕获”的 Lambda 表达式来说,JIT 编译器的逃逸分析能够优化这部分差异,性能和传统方式无异;但对于“捕获型”的表达式来说,则需要通过方法句柄,不断地生成适配器,性能自然就低了很多(不过和便捷性相比,一丁点性能损失是可接受的)。

invokedynamic 指令,它实际上是通过方法句柄来实现的。和我们关系最大的就是 Lambda 语法,我们了解原理,可以忽略那些对 Lambda 性能高低的争论,同时还是要尽量写一些“非捕获”的 Lambda 表达式。

Java 语法糖及实现

Java 中的泛型

泛型是什么?

泛型,即“参数化类型”,最熟悉的就是定义方法时有形参,然后调用此方法时传递实参。 引入一个类型变量 T(其他大写字母都可以,不过常用的就是 T,E,K,V 等等),并且用<>括起来,并放在类名的后面。泛型类是允许有多个类型变量的。

按照约定,类型参数名称命名为单个大写字母,以便可以在使用普通类或接口名称时能够容易地区分类型参数。以下是常用的类型参数名称列表:

  • E - 元素,主要由 Java 集合(Collections)框架使用。
  • K - 键,主要用于表示映射中的键的参数类型。
  • V - 值,主要用于表示映射中的值的参数类型。
  • N - 数字,主要用于表示数字。
  • T - 类型,主要用于表示第一类通用型参数。
  • S - 类型,主要用于表示第二类通用类型参数。
  • U - 类型,主要用于表示第三类通用类型参数。
  • V - 类型,主要用于表示第四类通用类型参数。

泛型类,泛型接口,泛型方法

可以为任何类、接口增加泛型声明。泛型方法,是在调用方法的时候指明泛型的具体类型 ,泛型方法可以在任何地方和任何场景中使用,包括普通类和泛型类。

为什么我们需要泛型? 泛型的好处就是:

  • 适用于多种数据类型执行相同的代码
  • 泛型中的类型在使用时指定,不需要强制类型转换

虚拟机是如何实现泛型的?

泛型擦除

Java 语言中的泛型,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型(Raw Type,也称为裸类型)了,并且在相应的地方插入了强制转型代码,因此,对于运行期的 Java 语言来说,ArrayList < int >与 ArrayList < String >就是同一个类,所以泛型技术实际上是 Java 语言的一颗语法糖,Java 语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。

将一段 Java 代码编译成 Class 文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了,程序又变回了 Java 泛型出现之前的写法,泛型类型都变回了原生类型。

弱记忆

JVM 版本兼容性问题:JDK1.5 以前,为了确保泛型的兼容性,JVM 除了擦除,其实还是保留了泛型信息(Signature 是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类 型的信息)----弱记忆

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

Stream

什么是 Stream?

Java8 中,Collection 新增了两个流方法,分别是 Stream() 和 parallelStream() Java8 中添加了一个新的接口类 Stream,相当于高级版的 Iterator,它可以通过 Lambda 表达式对集合进行大批量数据操作,或者各种非常便利、高效的聚合数据操作。

为什么要使用 Stream?

在 Java8 之前,我们通常是通过 for 循环或者 Iterator 迭代来重新排序合并数据,又或者通过重新定义 Collections.sorts 的 Comparator 方法来实现,这两种方式对于大数据量系统来说,效率并不是很理想。

Stream 的聚合操作与数据库 SQL 的聚合操作 sorted、filter、map 等类似。我们在应用层就可以高效地实现类似数据库 SQL 的聚合操作了,而在数据操作方面,Stream 不仅可以通过串行的方式实现数据操作,还可以通过并行的方式处理大批量数据,提高数据的处理效率。

Stream 操作分类

官方将 Stream 中的操作分为两大类:终结操作(Terminal operations)和中间操作(Intermediate operations)。

中间操作会返回一个新的流,一个流可以后面跟随零个或多个中间操作。其目的主要是打开流,做出某种程度的数据映射/过滤,然后会返回一个新的流,交给下一个操作使用。这类操作都是惰性化的(lazy),就是说,仅仅调用到这类方法,并没有真正开始流的遍历。而是在终结操作开始的时候才真正开始执行。

中间操作又可以分为无状态(Stateless)与有状态(Stateful)操作,无状态是指元素的处理不受之前元素的影响,有状态是指该操作只有拿到所有元素之后才能继续下去。

终结操作是指返回最终的结果。一个流只能有一个终结操作,当这个操作执行后,这个流就被使用“光”了,无法再被操作。所以这必定这个流的最后一个操作。终结操作的执行才会真正开始流的遍历,并且会生成一个结果。

终结操作又可以分为短路(Short-circuiting)与非短路(Unshort-circuiting)操作, 短路是指遇到某些符合条件的元素就可以得到最终结果,非短路是指必须处理完所有元素才能得到最终结果。

操作分类详情如下图所示:

image

因为 Stream 操作类型非常多,总结一下常用的

  • map():将流中的元素进行再次加工形成一个新流,流中的每一个元素映射为另外的元素。
  • filter(): 返回结果生成新的流中只包含满足筛选条件的数据
  • limit():返回指定数量的元素的流。返回的是 Stream 里前面的 n 个元素。
  • skip():和 limit()相反,将前几个元素跳过(取出)再返回一个流,如果流中的元素小于或者等于 n,就会返回一个空的流。
  • sorted():将流中的元素按照自然排序方式进行排序。
  • distinct():将流中的元素去重之后输出。
  • peek():对流中每个元素执行操作,并返回一个新的流,返回的流还是包含原来流中的元素。

Stream 的底层实现

Stream 操作叠加 一个 Stream 的各个操作是由处理管道组装,并统一完成数据处理的。 我们知道 Stream 有中间操作和终结操作,那么对于一个写好的 Stream 处理代码来说,中间操作是通过 AbstractPipeline 生成了一个中间操作 Sink 链表。当我们调用终结操作时,会生成一个最终的 ReducingSink,通过这个 ReducingSink 触发之前的中间操作,从最后一个 ReducingSink 开始,递归产生一个 Sink 链。

Stream 源码实现(不重要)

  • stream 方法 因为 names 是 ArrayList 集合,所以 names.stream() 方法将会调用集合类基础接口 Collection 的 Stream 方法。
  • 然后,Stream 方法就会调用 StreamSupport 类的 Stream 方法,
  • Stream 方法中初始化了一个 ReferencePipeline 的 Head 内部类对象。 filter 方法
  • 再调用 filter,这个方法都是无状态的中间操作,所以执行 filter 时,并没有进行任何的操作,而是分别创建了一个 Stage 来标识用户的每一次操作。而通常情况下 Stream 的操作又需要一个回调函数,所以一个完整的 Stage 是由数据来源、操作、回调函数组成的三元组来表示。 分别是 ReferencePipeline 的 filter 方法和 map 方法。 max 方法
  • 当 Sink 链表生成完成后,Stream 开始执行,通过 spliterator 迭代集合,执行 Sink 链表中的具体操作。 java8 中的 Spliterator 的 forEachRemaining 会迭代集合,每迭代一次,都会执行一次 filter 操作,如果 filter 操作通过,就会触发 map 操作,然后将结果放入到临时数组 object 中,再进行下一次的迭代。完成中间操作后,就会触发终结操作 max。

Stream 并发 Stream 源码实现(不重要)

  • 并发的处理函数对比 parallelStream()方法
  • 这里的并行处理指的是,Stream 结合了 ForkJoin 框架,对 Stream 处理进行了分片,Splititerator 中的 estimateSize 方法会估算出分片的数据量。通过预估的数据量获取最小处理单元的阈值,如果当前分片大小大于最小处理单元的阈值,就继续切分集合。每个分片将会生成一个 Sink 链表,当所有的分片操作完成后,ForkJoin 框架将会合并分片任何结果集。

Stream 的性能

常规数据迭代(100 条数据)

常规的迭代 > Stream 并行迭代> Stream 串行迭代

为什么这样:

  1. 常规迭代代码简单,越简单的代码执行效率越高。
  2. Stream 串行迭代,使用了复杂的设计,导致执行速度偏低。所以是性能最低的。
  3. Stream 并行迭代 使用了 Fork-Join 线程池, 所以效率比 Stream 串行迭代快,但是对比常规迭代还是要慢(毕竟设计和代码复杂)

大数据迭代(一亿条数据,默认 Stream 线程池)

Stream 并行迭代 > 常规的迭代> Stream 串行迭代

为什么这样:

  1. Stream 并行迭代 使用了 Fork-Join 线程池, 而线程池线程数为 cpu 的核心数(我的电脑为 12 核),大数据场景下,能够利用多线程机制,所以效率比 Stream 串行迭代快,同时多线程机制切换带来的开销相对来说还不算多,所以对比常规迭代还是要快(虽然设计和代码复杂)
  2. 常规迭代代码简单,越简单的代码执行效率越高。
  3. Stream 串行迭代,使用了复杂的设计,导致执行速度偏低。所以是性能最低的。

大数据迭代(一亿条数据,Stream 线程池数量=2)

常规的迭代 > Stream 并行迭代> Stream 串行迭代

为什么这样:

  1. Stream 并行迭代 使用了 Fork-Join 线程池,大数据场景下,虽然利用多线程机制,但是线程池线程数为 2,所以多个请求争抢着执行任务,想象对请求来说任务是被交替执行完成,所以对比常规迭代还是要慢(虽然用到了多线程技术)

大数据迭代(一亿条数据,Stream 线程池数量=240)

常规的迭代 > Stream 并行迭代> Stream 串行迭代

为什么这样:

  1. Stream 并行迭代 使用了 Fork-Join 线程池, 而线程池线程数为 240,大数据场景下,虽然利用多线程机制,但是线程太多,线程的上下文切换成本过高,所以导致了执行效率反而没有常规迭代快。

如何合理使用 Stream?

我们可以看到:在循环迭代次数较少的情况下,常规的迭代方式性能反而更好;而在大数据循环迭代中, parallelStream(合理的线程池数上)有一定的优势。 但是由于所有使用并行流 parallelStream 的地方都是使用同一个 Fork-Join 线程池,而线程池线程数仅为 cpu 的核心数。 切记,如果对底层不太熟悉的话请不要乱用并行流 parallerStream(尤其是你的服务器核心数比较少的情况下)