JRE 仅包含运行 Java 程序的必需组件,包括 Java 虚拟机以及 Java 核心类库等。我们 Java 程序员经常接触到的 JDK(Java 开发工具包)同样包含了 JRE,并且还附带了一系列开 发、诊断工具。
Java 作为一门高级程序语言,它的语法非常复杂,抽象程度也很高。因此,直接在硬件上运行 这种复杂的程序并不现实。所以呢,在运行 Java 程序之前,我们需要对其进行一番转换。
主流思路是这样子的,设计一个面向 Java 语言特性的虚 拟机,并通过编译器将 Java 程序转换成该虚拟机所能识别的指令序列,也称 Java 字节码。
从虚拟机视角来看,执行 Java 代码首先需要将它编译而成的 class 文件加载到 Java 虚拟机 中。加载后的 Java 类会被存放于方法区(Method Area)中。实际运行时,虚拟机会执行方法 区内的代码。
Java 虚拟机会将栈细分为面向 Java 方法的 Java 方法栈,面向本地方法(用 C++ 写的 native 方法)的本地方法栈,以及存放各个线程执行位置的 PC 寄存器。
在 HotSpot 里面,编译过程有两种形式
前者的优势在于无需等待编译,而后者的优势在于实际运行速度更快。
HotSpot 默认采用混合 模式,综合了解释执行和即时编译两者的优点。它会先解释执行字节码,而后将其中反复执行的 热点代码,以方法为单位进行即时编译。
HotSpot 采用了多种技术来提升启动性能以及峰值性能
三种,C1、C2 和 Graal。Graal 是 Java 10 正式引入的实验性即时编译器,
是为了在编译时间和生成代码的执行效率之间进行取舍。
从 Java 7 开始,HotSpot 默认采用分层编译的方式:热点方法首先会被 C1 编译,而后热点方 法中的热点会进一步被 C2 编译。
为了不干扰应用的正常运行,HotSpot 的即时编译是放在额外的编译线程中进行的。HotSpot会根据 CPU 的数量设置编译线程的数目,并且按 1:2 的比例配置给 C1 及 C2 编译器。
在计算资源充足的情况下,字节码的解释执行和即时编译可同时进行。编译完成后的机器码会在 下次调用该方法时启用,以替换原本的解释执行。
分别为方法区、堆、PC 寄存器、Java 方法栈 和本地方法栈。Java 程序编译而成的 class 文件,需要先加载至方法区中,方能在 Java 虚拟机 中运行。
为了提高运行效率,标准 JDK 中的 HotSpot 虚拟机采用的是一种混合执行的策略。它会解释执行 Java 字节码,然后会将其中反复执行的热点代码,以方法为单位进行即时编译,翻译成机器码后直接运行在底层硬件之上。
对于 Java 虚拟机来说,它看到的 boolean 类型,早已被映射为整数类型。因此,将原本声明 为 boolean 类型的局部变量,赋值为除了 0、1 之外的整数值,在 Java 虚拟机看来是“合 法”的。
尽管他们的默认值看起来不一样, 但在内存中都是 0。
boolean 和 char 是唯二的无符号类型。在不考虑违反规范的情况下, boolean 类型的取值范围是 0 或者 1。char 类型的取值范围则是 [0, 65535]。
答案是可以的。而且,这些超出取值范围的数值同样会带来一些麻烦。比如说,声明为 char 类 型的局部变量实际上有可能为负数。当然,在正常使用 Java 编译器的情况下,生成的字节码会 遵守 Java 虚拟机规范对编译器的约束,因此你无须过分担心局部变量会超出它们的取值范围。
在内存中分别等同于十六进制整数 0x7F800000 和 0xFF800000。
整数 0x7F800000 等同于正无穷,那么 0x7F800001 这个数字对应的浮点数是 NaN,[0x7F800001, 0x7FFFFFFF] 和 [0xFF800001, 0xFFFFFFFF] 对应的都是 NaN, 0x7FC00000。这个 数值,我们称之为标准的NaN,而其他的我们称之为不标准的 NaN。
boolean、byte、char、short 这四种类型,在栈上占用的空间和 int 是一样的,和 引用类型也是一样的。因此,在 32 位的 HotSpot 中,这些类型在栈上将占用 4 个字节;而在 64 位的 HotSpot 中,他们将占 8 个字节。这种情况仅存在于局部变量
对于byte、char 以及 short 这三种类型的字段或者数组单元,它们在堆上占用的空间分别为一字节、两字节,以及两字节,也就是说,跟这些类型的值域相吻合。
,当我们将一个 int 类型的值,存储到这些类型的字段或数组时,相当于做了一次隐式的掩 码操作。
对于 boolean、char 这两个无符号类型来说,加载伴随着零扩展。举个例子,char 的大小为两个字节。在加载时 char 的值会被复制到 int 类型的低二字节,而高二字节则会用 0 来填充。
对于 byte、short 这两个类型来说,加载伴随着符号扩展。举个例子,short 的大小为两个字 节。在加载时 short 的值同样会被复制到 int 类型的低二字节。如果该 short 值为非负数,即最 高位为 0,那么该 int 类型的值的高二字节会用 0 来填充,否则用 1 来填充。
Java 虚拟机的算数运算几乎全部依赖于操作数栈。也就是 说,我们需要将堆中的 boolean、byte、char 以及 short 加载到操作数栈上,而后将栈上的值 当成 int 类型来运算。
“true”被映射为 1,而“false”被映射为 0。
基本类型(primitive types)和引用类型
:类、接口、数组类和泛型参数。由于泛型参数 会在编译过程中被擦除,因此 Java 虚拟机实际上只有前三种
从 class 文件到内存中的类,按先后顺 序需要经过加载、链接以及初始化三大步骤。
加载是指查找字节流,并且据此创建类的过程。加载需要借助类加载器,在 Java 虚拟机中,类加载器使用了双亲委派模型,即接收到加载请求时,会先将请求转发给父类加载器。
每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请 求的类的情况下,该类加载器才会尝试去加载。
举例来说,我们可以对 class 文件进行加密,加载时再利用自定义的类加载器对其解 密。
Java 9 引入了模块系统,并且略微更改了上述的类加载器1。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。
在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。
链接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。链接还分验证、准备和解析三个阶段。其中,解析阶段为非必须的。
它可分为验证、准备以 及解析三个阶段。
在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。
它仅规定了:如果某些字节码使用了符号 引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。
初始化便是为标记为常量值的字段赋值,以及执行方法的过程。Java 虚 拟机会通过加锁来确保类的方法仅被执行一次。只有当初始化完成之后,类才正式成为可执行的状态。
JVM 规范枚举了下述多种触发情况:
类初始化是线程安全的,并且仅被执行一次,因此程序可以确保多线程环境下有且仅有一个Singleton 实例。
通过 JVM 参数 -verbose:class
来打印类加载的先后顺序。
Java 编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:
如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。
无法通过编译。在正常情况下,如果我们想要在同一个类中定义名字相同的方法,那么它们 的参数类型必须不同。这些方法之间的关系,我们称之为重载。
如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法。如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法。
Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符(method descriptor)。
方法描述符,它是由方法的参数类型以及返回类型所构成。在同 一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么 Java 虚拟机会在类的验证 阶段报错。
方法名字、参数类型、返回类型 三个部分
方法重写的判定基于方法描述符。也就是说,如果子类定义了与父类中 非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,Java 虚拟机才会判定为重写。
Java 虚拟机会在类的验证阶段发现并报错。
于对重载方法的区分在编译阶段已经完成,由于 Java 编译器已经区 分了重载的方法,因此可以认为 Java 虚拟机中不存在重载。 因为,在某些文章中,重载也被称为静态绑定(static binding),或者编译时多态(compiletime polymorphism);而重写则被称为动态绑定(dynamic binding)。
Java 虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态 绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。
在编译过程中,我们并不知道目标方法的具体内存地址。因此,Java 编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。符号引用存储在 class 文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用。
假定该符号引用所指向的类为 C,
假定该符号引用所指向的接口为 I
对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。
重载指的是方法名相同而参数类型不相同的方法之 间的关系,重写指的是方法名相同并且参数类型也相同的方法之间的关系。
Java 虚拟机识别方法的方式略有不同,除了方法名和参数类型之外,它还会考虑返回类型。
Java 里所有非私有实例方法调用都会被编译成 invokevirtual 指令,而 接口方法调用都会被编译成 invokeinterface 指令。这两种指令,均属于 Java 虚拟机中的虚方 法调用。
虚方法调用包括 invokevirtual 指令和 invokeinterface 指令。如果这两种指令所声明的目标方法被标记为 final,那么 Java 虚拟机会采用静态绑定。否则,Java 虚拟机将采用动态绑定,在运行过程中根据调用者的动态类型,来决定具体的目标方法。
静态绑定包括用于调用静态方法的 invokestatic 指令,和用于调用构造器、私有实例方法以及超类非私有实例方法的 invokespecial 指令。如果虚方法调用指向一个标记为final 的方法,那么 Java 虚拟机也可以静态绑定该虚方法调用的目标方法。
Java 虚拟机采取了一种用空间换取时间的策略,通过方法表这一数据结构来实现的。方法表中每一个重写方法的索引值,与父类方法表中被重写的方法的索引值一致。在解析虚方法调用时,Java 虚拟机会纪录下所声明的目标方法的索引值,并且在运行过程中根据这个索引值查找具体的目标方法。
方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:其一,子类方法表中包含父类方法表中的所有方法;其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。
对于动态绑定的方法调用而言,实际引用则是方法表的 索引值(实际上并不仅是索引值)。在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。
内联缓存(inlining cache)和方法内联(method inlining)。
内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。
为了节省内存空间,Java 虚拟机只采用单态内联缓存。
当碰到新的调用者时,如果其动态类型与缓存中的类型匹配,则直接调用缓存的目标方法。否则,Java 虚拟机将该内联缓存劣化为超多态内联缓存,在今后的执行过程中直接使用方法表进行动态绑定。
所有异常都是 Throwable 类或者其子类的实例。Throwable 有两大直接 子类。第一个是 Error,涵盖程序不应捕获的异常。当程序触发 Error 时,它的执行状态已经无 法恢复,需要中止线程甚至是中止虚拟机。第二子类则是 Exception,涵盖程序可能需要捕获并 且处理的异常。而 Exception 又分为 RuntimeException 和其他类型。RuntimeException 和 Error 属于非检查异常。其他的 Exception 皆属于检查异常,在触发时需要显式捕获,或者在方法头用 throws 关键字声明。
RuntimeException 和 Error 属于 Java 里的非检查异常(unchecked exception)。其他异常则属于检查异常(checked exception)。在 Java 语法中,所有的检查异常都需要程序显式地捕获,或者在方法声明中用 throws 关键字标注。通常情况下,程序中自定义的异常应为检查异常,以便最大化利用 Java 编译器的编译时检查。
在编译生成的字节码中,每个方法都附带一个异常表。异常表中的每一个条目代表一个异常处理器,并且由 from 指针、to 指针、target 指针以及所捕获的异常类型构成。这些指针的值是字节码索引(bytecode index,bci),用以定位字节码。
其中,from 指针和 to 指针标示了该异常处理器所监控的范围,例如 try 代码块所覆盖的范围。target 指针则指向异常处理器的起始位置,例如 catch 代码块的起始位置。
当程序触发异常时,Java 虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java 虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,Java 虚拟机会将控制流转移至该条目 target 指针指向的字节码。
如果遍历完所有异常表条目,Java 虚拟机仍未匹配到异常处理器,那么它会弹出当前方法对应的 Java 栈帧,并且在调用者(caller)中重复上述操作。在最坏情况下,Java 虚拟机需要遍历当前线程 Java 栈上所有方法的异常表。
finally 代码块的编译比较复杂。当前版本 Java 编译器的做法,是复制 finally 代码块的内容,分别放在 try-catch 代码块所有正常执行路径以及异常执行路径的出口中。
是catch触发的另一个异常。也就是说原本的异常便会被忽略掉,这对于代码调试来说十分不利。
Supressed 异常是java 7之后引入的,新特性允许开发人员将一个异常附于另一 个异常之上。因此,抛出的异常可以附带多个异常的信息。然而,Java 层面的 finally 代码块缺少指向所捕获异常的引用,所以这个新特性使用起来非常繁 琐。
Java 7 专门构造了一个名为 try-with-resources 的语法糖,在字节码层面自动使用 Supressed 异常。当然,该语法糖的主要目的并不是使用 Supressed 异常,而是精简资源打开 关闭的用法。Java7的try-with-resources
反射是 Java 语言中一个相当重要的特性,它允许正在运行的 Java 程序观测,甚至是修改程序 的动态行为。比如
Method.invoke 的源代码,它实际上委派给 MethodAccessor 来 处理。MethodAccessor 是一个接口,它有两个已有的具体实现:一个通过本地方法来实现反 射调用,另一个则使用了委派模式。可以简称“本地实现”和“委派实现”
每个 Method 实例的第一次反射调用都会生成一个委派实现,它所委派的具体实现便是一个本地实现。本地实现非常容易理解。当进入了 Java 虚拟机内部之后,我们便拥有了 Method 实例所指向方法的具体地址。这时候,反射调用无非就是将传入的参数准备好,然后调用进入目标方法。
反射调用先是调用了 Method.invoke,然后进入委派实现 (DelegatingMethodAccessorImpl),再然后进入本地实现 (NativeMethodAccessorImpl),最后到达目标方法。
Java 的反射调用机制还设立了另一种动态生成字节码的实现(下称动态实现),直接使 用 invoke 指令来调用目标方法。之所以采用委派实现,便是为了能够在本地实现以及动态实现 中切换。
动态实现和本地实现相比,其运行效率要快上 20 倍 [2] 。这是因为动态实现无需经过 Java 到C++ 再到 Java 的切换,但由于生成字节码十分耗时,仅调用一次的话,反而是本地实现要快上 3 到 4 倍 [3]。
考虑到许多反射调用仅会执行一次,Java 虚拟机设置了一个阈值 15(可以通过 -Dsun.reflect.inflationThreshold=
来调整),当某个反射调用的调用次数在 15 之下时,采用本地实现;当达到 15 时,便开始动态生成字节码,并将委派实现的委派对象切换至动态实现,这个过程我们称之为 Inflation。
反射调用的 Inflation 机制是可以通过参数-Dsun.reflect.noInflation=true
来关闭的。这样一来,在反射调用一开始便会直接生成动态实现,而不会使用委派实现或者本地实现。
在默认情况下,方法的反射调用为委派实现,委派给本地实现来进行方法调用。在调用超过 15次之后,委派实现便会将委派对象切换至动态实现。这个动态实现的字节码是自动生成的,它将直接使用 invoke 指令来调用目标方法。
方法的反射调用会带来不少性能开销,原因主要有三个:变长参数方法导致的 Object 数组,基本类型的自动装箱、拆箱,还有最重要的方法内联。
参考==>java 反射API
-XX:TypeProfileWidth
,默认值为 2,这里设置为 3)。
在 Java 中,方法调用会被编译为 invokestatic,invokespecial,invokevirtual 以及 invokeinterface 四种指令。这些指令与包 含目标方法类名、方法名以及方法描述符的符号引用捆绑。
在方法的四个调用指令中,Java 虚拟机明确要求方法调用需要提供目标方法的类名。在 这种体系下,有两个解决方案。一是调用其中一种类型的方法; 另一种是通过反射机制,来查找并且调用各个类型中的赛跑方法。
比起直接调用,这两种方法都相当复杂,执行效率也可想而知。为了解决这个问题,Java 7 引入了一条新的指令 invokedynamic。该指令的调用机制抽象出调用点这一个概念,并允许 应用程序将调用点链接至任意符合条件的方法上。
方法句柄是一个强类型的,能够被直接执行的引用。该引用可以指向常规的静态方法或者实 例方法,也可以指向构造器或者字段。当指向字段时,方法句柄实则指向包含字段访问字节码的 虚构方法,语义上等价于目标字段的 getter 或者 setter 方法。
它仅关心所指向方法的参数类型以及返回类 型,而不关心方法所在的类以及方法名。
需要注意的是,它并不会直接指向目标字段所在类中的 getter/setter,毕竟你无法保证已 有的 getter/setter 方法就是在访问目标字段。
方法句柄的创建是通过 MethodHandles.Lookup 类来完成的。
方法句柄的类型(MethodType)是由所指向方法的参数类型以及返回类型组成的。它是用来 确认方法句柄是否适配的唯一关键。
对于用 invokestatic 调用 的静态方法,我们需要使用 Lookup.findStatic 方法;对于用 invokevirutal 调用的实例方法, 以及用 invokeinterface 调用的接口方法,我们需要使用 findVirtual 方法;对于用 invokespecial 调用的实例方法,我们则需要使用 findSpecial 方法。
invokeExact
invoke
@PolymorphicSignature
是方法句柄 API 的一个特殊的注解类 。在碰到被它注解的方法调用时,Java 编译器会根据所传入参数的声明类型来生成方法描述符,而不是采用目标方法所声明 的描述符。
invoke
会调用 MethodHandle.asType
方法,生成一个适配器方法句 柄,对传入的参数进行适配,再调用原方法句柄。调用原方法句柄的返回值同样也会先进行适 配,然后再返回给调用者。
举个例子,有一个指向 f(x, y) 的方法句柄,我们可以 通过将 x 绑定为 4,生成另一个方法句柄 g(y) = f(4, y)。在执行过程中,每当调用 g(y) 的方法 句柄,它会在参数列表最前面插入一个 4,再调用指向 f(x, y) 的方法句柄。
invokeExact 的目标方法就是方法句柄指向的方法。 java 虚拟机会对 invokeExact 调用做特殊处理,调用至一个共享的、与方法句柄类型 相关的特殊适配器中。这个适配器是一个 LambdaForm,我们可以通过添加虚拟机参数将之导 出成 class 文件(-Djava.lang.invoke.MethodHandle.DUMP_CLASS_FILES=true)。
启用-XX:+ShowHiddenFrames
方法句柄的执行次数超过一个阈值时会进行优化(对应参数 -Djava.lang.invoke.MethodHandle.CUSTOMIZE_THRESHOLD
,默认值为 127)。
方法句柄一开始持有的适配器是共享的。 当它被多次调用之后,Invokers.checkCustomized
方法会为该方法句柄生成一个特有的适配 器。这个特有的适配器会将方法句柄作为常量,直接获取其 MemberName
类型的字段,并继 续后面的 linkToStatic
调用。
都是间接调用,同样会面临无法内联的问题。
invokedynamic 是 Java 7 引入的一条新指令,用以支持动态语言的方法调用。具体来说,它将 调用点(CallSite)抽象成一个 Java 类,并且将原本由 Java 虚拟机控制的方法调用以及方法链 接暴露给了应用程序。在运行过程中,每一条 invokedynamic 指令将捆绑一个调用点,并且会调用该调用点所链接的方法句柄。
在第一次执行 invokedynamic 指令时,Java 虚拟机会调用该指令所对应的启动方法 (BootStrap Method),来生成前面提到的调用点,并且将之绑定至该 invokedynamic 指令 中。在之后的运行过程中,Java 虚拟机则会直接调用绑定的调用点所链接的方法句柄。
Java 编译器利用 invokedynamic 指令来生成实现了函数式接口的适配器。这里的 函数式接口指的是仅包括一个非 default 接口方法的接口,一般通过 @FunctionalInterface 注 解。不过就算是没有使用该注解,Java 编译器也会将符合条件的接口辨认为函数式接口。
在编译过程中,Java 编译器会对 Lambda 表达式进行解语法糖(desugar),生成一个方法来保存 Lambda 表达式的内容。该方法的参数列表不仅包含原本 Lambda 表达式的参数,还包含它所捕获的变量。(注:方法引用,如 Horse::race,则不会生成生成额外的方法。)
无法共享同一个适配器类的实例。在每次执行 invokedynamic 指令时,所调用的方法句柄都需要新建一个适配器类实例。
可以通过虚拟机参数 -Djdk.internal.lambda.dumpProxyClasses=/DUMP/PATH
导出这些 具体的适配器类。
测量结果显示,它与直接调用的性能并无太大的区别。也就是说,即时编译器能够将转换 Lambda 表达式所使用的 invokedynamic,以及对 IntConsumer.accept 方法的调用统统内联 进来,最终优化为空操作。
Lambda 表达式所使用的 invokedynamic 将绑定一个 ConstantCallSite, 其链接的目标方法无法改变。因此,即时编译器会将该目标方法直接内联进来。对于这类没有捕 获变量的 Lambda 表达式而言,目标方法只完成了一个动作,便是加载缓存的适配器类常量。
通过虚拟机参数 -XX:-DoEscapeAnalysis
来关闭逃逸分析。
它需要同时满足两件事:invokedynamic 指令所执行的方法句柄能够内联,和接下来的对 accept 方法的调用也能 内联。
只有这样,逃逸分析才能判定该适配器实例不逃逸。否则,我们会在运行过程中不停地生成适配器类实例。所以,我们应当尽量使用非捕获的 Lambda 表达式。
对于没有捕获其他变量的 Lambda 表达式,该 invokedynamic 指令始终返回同一个适配器类的实例。对于捕获了其他变量的 Lambda 表达式,每次执行 invokedynamic 指令将新建一个适配器类实例。
不管是捕获型的还是未捕获型的 Lambda 表达式,它们的性能上限皆可以达到直接调用的性能。其中,捕获型 Lambda 表达式借助了即时编译器中的逃逸分析,来避免实际的新建适配器类实例的操作
在 64 位的 Java 虚拟机中,对象头的标记字段占 64 位(bit),而类型指针又占了 64 位(bit)。也就是说, 每一个 Java 对象在内存中的额外开销就是 16 个字节(16byte=128bit)。
为了尽量较少对象的内存使用量,64 位 Java 虚拟机引入了压缩指针的概念(对应虚拟机选项 -XX:+UseCompressedOops
,默认开启),将堆中原本 64 位的 Java 对象指针压缩成 32位的。
这样一来,对象头中的类型指针也会被压缩成 32 位,使得对象头的大小从 16 字节降至 12 字 节。当然,压缩指针不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段,以及引 用类型数组。
有个概念我们称之为内存对齐(对应虚拟机选项 -XX:ObjectAlignmentInBytes
,默认值为 8)。
默认情况下,Java 虚拟机堆中对象的起始地址需要对齐至 8 的倍数。如果一个对象用不到 8N个字节,那么空白的那部分空间就浪费掉了。这些浪费掉的空间我们称之为对象间的填充(padding)。
在默认情况下,Java 虚拟机中的 32 位压缩指针可以寻址到 2 的 35 次方个字节,也就是 32GB的地址空间(超过 32GB 则会关闭压缩指针)。
内存对齐不仅存在于对象与对象之间,也存在于对象中的字段之间。比如说,Java 虚拟机要求 long 字段、double 字段,以及非压缩指针状态下的引用字段地址为 8 的倍数。
字段内存对齐的其中一个原因,是让字段只出现在同一 CPU 的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。
字段重排列,顾名思义,就是 Java 虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。Java 虚拟机中有三种排列方法(对应 Java 虚拟机选项 -XX:FieldsAllocationStyle
,默认值为1),但都会遵循如下两个规则。
假设两个线程分别访问同一对象中不同的 volatile 字段,逻辑上它们并没有共享内容,因此不需要同步。 然而,如果这两个字段恰好在同一个缓存行中,那么对这些字段的写操作会导致缓存行的写回,也就造成了实质上的共享。
Java 8 还引入了一个新的注释@Contended
,用来解决对象字段之间的虚共享,这个注释也会影响到字段的排列。
使用 JOL 工具并添加虚拟机选项 -XX:-RestrictContended
。如果在 Java 9 以上版本试验的话,在使用 javac 编译时 需要添加 --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAME
具体实现是这样子的:如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器 +1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器 -1。也就是 说,我们需要截获所有的引用更新操作,并且相应地增减目标对象的引用计数器。
除了需要额外的空间来存储计数器,以及繁琐的更新操作,引用计数法还有一个重大的漏洞,那 便是无法处理循环引用对象。
举个例子,假设对象 a 与 b 相互引用,除此之外没有其他引用指向 a 或者 b。在这种情况下,a和 b 实际上已经死了,但由于它们的引用计数器皆不为 0,在引用计数法的心中,这两个对象还活着。因此,这些循环引用对象所占据的空间将不可回收,从而造成了内存泄露
GC Roots包括(但不限于)如下几种:
可达性分析可以解决引用计数法所不能解决的循环引用问题。
举例来说,即便对象 a 和 b 相互引用,只要从 GC Roots 出发无法到达 a 或者 b,那么可达性分析便不会将它们加入存活对象合集之中。
在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将 引用设置为 null)或者漏报(将引用设置为未被访问过的对象)。
误报并没有什么伤害,Java 虚拟机至多损失了部分垃圾回收的机会。漏报则比较麻烦,因为垃 圾回收器可能回收事实上仍被引用的对象内存。一旦从原引用访问已经被回收了的对象,则很有可能会直接导致 Java 虚拟机崩溃。
通过Java 虚拟机中的 Stop-the-world 机制来实现的。当 Java 虚拟机 收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状 态下,Java 虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性 分析。
在生成机器码时,即时编译器需要插入安全点检测,以避免机器码长时间没有 安全点检测的情况。HotSpot 虚拟机的做法便是在生成代码的方法出口以及非计数循环的循环 回边(back-edge)处插入安全点检测。
不同的即时编译器插入安全点检测的位置也可能不同
即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。
清除这种回收方式的原理及其简单,但是有两个缺点。一是会造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。
另一个则是分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointerbumping)来做分配。而对于空闲列表,Java 虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。
即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。
即把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也极其明显,即堆空间的使用效率极其低下。
Java 虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为 Eden 区,以及 两个大小相同的 Survivor 区。
默认情况下,Java 虚拟机采取的是一种动态分配的策略(对应 Java 虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy
),根据生成对象的速率,以及 Survivor 区的使用情况动态调整 Eden 区和 Survivor 区的比例。可以通过参数 -XX:SurvivorRatio 来固定这个比例。但是需要注意的是,其中一个Survivor 区会一直为空,因此比例越低浪费的堆空间将越高。
当调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存。由于堆 空间是线程共享的,因此这里边划空间是需要进行同步的。否则,将有可能出现两个对象共用一段内存的事故。
接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。
如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。
有一项技术被称之为 TLAB(Thread Local Allcoation Buffer,对应虚拟机参数 -XX:+UseTLAB
,默认开启)。每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB。
这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。
Java 虚拟机便会触发一次 Minor GC,来收集新生 代的垃圾。存活下来的对象,则会被送到 Survivor 区。
我们分别用 from 和 to 来指代。其中 to 指向的Survivior 区是空的。
当发生 Minor GC 时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor区还是空的。
Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为15(对应虚拟机参数 -XX:+MaxTenuringThreshold
),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。
Survivor 区中的老存活对象晋升到老年代,然后将剩下的存活对象和 Eden 区的存活对象复制到另一个 Survivor 区中。理 想情况下,Eden 区中的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记- 复制算法的效果极好。 Minor GC 的另外一个好处是不用对整个堆进行垃圾回收。
有一个问题,那就是老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的 对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。这样一来,岂不是又做了一次全堆扫描呢?
HotSpot 给出的解决方案是一项叫做卡表(Card Table)的技术。该技术将整个堆划分为一个 个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表 对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。
在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。由于 Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。
Java 虚拟机需 要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。这个操作在解释执行器中比较容易实现。但是在即时编译器生成的机器码中,则需要插入额外的逻辑。这也就是所谓的写屏障(write barrier,注意不要和 volatile 字段的写屏障混淆)。
写屏障并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀,不可放过,一 律当成可能指向新生代对象的引用。Java 虚拟机便是通过这种方式来从地址映射到卡表中的索引的。
Minor GC 的吞吐率 = ( 应用运行时间/(应用运行时间 + 垃圾回收时间) )
在 HotSpot 中,卡表是通过 byte 数组来实现的。对于一个 64 字节的缓存行来说,如果用它来加载部分卡表,那么它将对应 64 张卡,也就是 32KB 的内存。如果同时有两个 Java 线程,在这 32KB 内存中进行引用更新操作,那么也将造成存储卡表的同一部分的缓存行的写回、无效化或者同步操作,造成虚共享,间接影响程序性能。
HotSpot 引入了一个新的参数 -XX:+UseCondCardMark
,来尽量减少写卡表的操作。
因为 Minor GC 只针对新生代进行垃圾回收,所以在枚举 GC Roots 的时候,它需要考虑从老年代到新生代的引用。为了避免扫描整个老年代,Java 虚拟机引入了名为卡表的技术,大致地标出可能存在老年代到新生代引用的内存区域。
针对新生代的垃圾回收器共有三个:
针对老年代的垃圾回收器有三个:
G1(Garbage First)是一个横跨新生代和老年代的垃圾回收器。实际上,它已经打乱了前面所说的堆结构,直接将堆分成极其多个区域。每个区域都可以充当 Eden 区、Survivor 区或者老年代中的一个。它采用的是标记 - 压缩算法,而且和 CMS 一样都能够在应用程序运行过程中并发地进行垃圾回收。
G1 能够针对每个细分的区域来进行垃圾回收。在选择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域。这也是 G1 名字的由来。