字节码翻译成机器码

解释执行:

逐条将字节码翻译成机器码并执行。无需等待编译

即时编译(JIT):

将一个方法中包含的所有字节码编译成机器码后再执行。实际运行速度更快。

HotSpot的解决方案:

HotSpot默认采用混合模式,综合了解释执行和即时编译两者的优点。它会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。

即时编译建立在程序符合二八定律的假设上,也即百分之二十的代码占据了百分之八十的计算资源。

理论上讲,即时编译后的Java程序的执行效率,是可能超过C++程序的。这是因为与静态编译相比,即时编译拥有程序的运行时信息,并且能够根据这个信息做出相应的优化。

HotSpot的即时编译器:

  • C1 面向的是对启动性能有要求的客户端GUI程序,采用的优化手段相对简单,因此编译时间较短。
  • C2 又叫做Server编译器,面向的是对峰值性能有要求的服务端程序,采用的优化手段相对复杂,因此编译时间较长,但同时生成的代码的执行效率较高。
  • Graal (Java10引入的实验性即时编译器)

从Java7开始,HotSpot默认采用分层编译的方式:热点方法首先会被C1编译,而后热点方法中的热点会进一步被C2编译。


JVM内存模型:

  • 方法区
  • PC寄存器
  • Java方法栈
  • 本地方法栈

方法区(线程共享):

方法区用于存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

Java程序编译而成的class文件,需要先加载至方法区中,方能在Java虚拟机中运行

堆(线程共享):

堆是虚拟机所管理的内存中最大的一块,被所有线程共享,在虚拟机启动时创建,垃圾回收也主要在堆中进行。堆用来存放对象实例,Java里几乎所有对象实例都在堆中分配内存。堆可以处于物理上不连续的内存空间,逻辑上应该连续,但对于数组这样的大对象,多数虚拟机实现出于简单、存储高效的考虑会要求连续的内存空间。

堆既可以被实现成固定大小,亦可以是可扩展的,可以通过-Xms-Xmx设置堆的最小和最大容量,当前主流JVM都按照可扩展实现。如果堆没有内存完成实例分配也无法扩展,抛出OutOfMemoryError

堆的区域划分:

  1. 堆被分成了两份,新生代和老年代(1:2)
  2. 对于新生代,内部又被划分成三个区域,Eden区,幸存者区Survivor(分为from和to)(8:1:1)

PC寄存器(程序计数器)(线程私有):

存放各个线程执行位置的PC寄存器。如果线程正在执行Java方法,计数器记录正在执行的虚拟机字节码指令地址。如果是本地方法,计数器值为Undefined。程序计数器是唯一在虚拟机规范中没有规定内存溢出情况的区域。

Java方法栈(又名虚拟机栈)(线程私有):

在运行过程中,每个线程都会创建一个Java方法战 ,每当调用进入一个Java方法,Java虚拟机会在当前线程的Java方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。当退出当前执行的方法时,不管是正常返回还是异常返回,Java虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。

有两类异常:

  1. 线程请求的栈深度大于虚拟机允许的深度会抛出 StackOverflowError 比如递归调用时候就有可能出现。
  2. 如果JVM栈容量可以动态扩展,栈扩展无法申请足够内存抛出OutOfMemoryError(HotSpot不可动态扩展,不存在此问题)。

本地方法栈(线程私有):

服务于C++写的native方法,与Java方法栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowErrorOutOfMemoryError异常。

在HotSpot虚拟机中,本地方法栈和Java方法栈合二为一。


类加载器

什么是类加载器?

JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到JVM中 ,从而让Java程序能够启动起来。

类加载器有哪些种类?

  1. 启动类加载器(加载JAVA_HOME/jre/lib目录下的库)
  2. 扩展类加载器(加载JAVA_HOME/jre/lib/ext目录下的库)
  3. 应用类加载器(加载开发者自己编写的Java类)
  4. 自定义类加载器(实现自定义类加载规则)

image.png

什么是双亲委派模型?

加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,就会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类。

JVM为什么采用双亲委派机制?

  1. 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
  2. 为了安全,保障类库API不会被修改。

垃圾回收

如何判断对象是否是垃圾?

引用计数:

在对象中添加一个引用计数器,如果被引用则计数器加一,引用失效时计数器减一,如果计数器为0则被标记为垃圾。原理简单、效率高,但在Java中很少使用,因为存在对象间循环引用的问题,导致计数器无法清零。目前主流的Java虚拟机中都没有选用引用计数算法进行内存管理。

可达性分析:

主流语言的内存管理都使用可达性分析判断对象是否存活。基本思路是通过一系列称为GC Roots的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径称为引用链,如果某个对象到GC Roots没有任何链相连,则会被标记为垃圾。可作为GC Roots的对象包括虚拟机栈和本地方法栈中引用的对象、类静态属性引用的对象、常量引用的对象。

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

  • 在虚拟机栈中引用的对象,比如当前正在运行的方法所使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,比如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,比如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对象的Class对象,一些常驻的异常(比如NullPointException、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

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

Java的引用有哪些类型?

强引用:

最常见的引用,例如Object obj = new Object()就属于强引用。只要对象有强引用指向且GC Roots可达,在内存回收时即使濒临内存耗尽也不会被回收。

软引用:

弱于强引用,描述非必需对象。在系统将发生内存溢出前,会把软引用关联的对象加入回收范围以获得更多内存空间。用来缓存服务器中间计算结果及不需要实时保存的用户行为等。
需要配合SoftReference使用

弱引用:

弱于软引用,描述非必需对象。弱引用关联的对象只能生存到下次YGC前,当垃圾收集器开始工作时无论当前内存是否足够都会回收只被弱引用关联的对象。由于YGC具有不确定性,因此弱引用何时被回收也不确定。
需要配合WeakReference使用

虚引用:

最弱的引用,定义完成后无法通过该引用获取对象。唯一目的就是为了能在对象被回收时收到一个系统通知。虚引用必须与引用队列联合使用,垃圾回收时如果出现虚引用,就会在回收对象前把这个虚引用加入引用队列。

分代收集理论

  • 弱分代假说:绝大多数对象都是朝生夕灭的。
  • 强分带假说:熬过越多次垃圾收集过程的对象就越难以消亡。
  • 跨代引用假说:跨代引用相对于同代引用仅占极少数。

根据这些经验法则,设计者一般至少会把Java堆划分为新生代(Young Generation)老年代(Old Generation)两个区域。在新生代中每次垃圾回收时都有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

依据跨代引用假说,我们不应为了少量的跨代引用区扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(这个数据结构被称为“记忆集”),这个结构把老年代划分为若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Young GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。

不同的GC名称:

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

    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集,暂停时间(STW)短。
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集,暂停时间(STW)长,应尽量避免。

垃圾回收算法

标记-清除算法:

首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

主要缺点:

  • 执行效率不稳定,如果堆中存在大量对象,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
  • 内存空间碎片化,标记、清除后会产生大量的内存碎片,内存空间碎片太多会导致以后程序需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾回收。

标记-复制算法:

将可用的内存区域分为两块,每次只用其中一块,当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已经使用的内存空间一次清理掉。现在商用的Java虚拟机大多都优先采用了这种收集算法去回收新生代。

更优化的是Appel式回收,HotSpot虚拟机的Serial、ParNew等新生代收集器都采用了这种策略来设计新生代的内存布局。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。当发生垃圾回收时,将Eden和已经用过的那块Survivor中仍存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已经用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例时8:1,也就是每次新生代中可用的内存空间为整个新生代容量的90%(Eden加上其中一块Survivor)。当出现Survivor空间不足以容纳一次Young GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保。

标记-整理算法:

标记-复制算法在对象存活率高时要进行较多的复制操作,效率低。如果不想浪费空间,就需要有额外的空间分配担保,以应付被使用内存中所有对象都存活的极端情况,所以老年代一般不使用此算法。

老年代使用标记-整理算法,标记过程与标记-清除算法一样,但不直接清理可回收对象,而是让所有存活对象都向内存空间一端移动,然后清理掉边界以外的内存。

标记-清除与标记-整理的差异在于前者是一种非移动式算法,而后者是移动式的。如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活的区域,是一种极为负重的操作,而且移动必须全程暂停用户线程(Stop The World)。如果不移动对象就会导致空间碎片问题,只能依赖更复杂的内存分配器和访问器解决。

最后修改:2024 年 04 月 08 日
如果觉得我的文章对你有用,请随意赞赏