JVM

1、简介

什么是JVM?

Java Virtual Machine -java 程序的运行环境(java二进制字节码的运行环境)

优点

  • 一次编写,到处执行
  • 自动内存管理,垃圾回收功能
  • 数组下标越界检查
  • 多态

比较:

JVM JRE JDK的区别

image-20230212180135682

学JVM有啥用:

  1. 面试(哈哈哈哈哈哈哈哈哈哈哈哈哈hhhhhhhhhhhhhhhhhhhhh)
  2. 理解底层的实现原理
  3. 中高级程序员的必备技能

学习路线

image-20230212205258356

JVM架构模型

Java编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构。

具体来说:这两种架构之间的区别:·

基于栈式架构的特点

  • 设计和实现更简单,适用于资源受限的系统;
  • 避开了寄存器的分配难题:使用零地址指令方式分配。
  • 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现。
  • 不需要硬件支持,可移植性更好,更好实现跨平台

基于寄存器架构的特点

  • 典型的应用是x86的二进制指令集:比如传统的Pc以及Android的Davlik虚拟机。
  • 指令集架构则完全依赖硬件,可移植性差性能优秀和执行更高效;
  • 花费更少的指令去完成一项操作。
  • 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主

由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。特点:跨平台性、指令集小、指令多;执行性能比寄存器差

JVM的运行周期

虚拟机的启动

Java虚拟机的启动是通过引导类加载器(bootstrap classloader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的

虚拟机的执行

  • 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序。
  • 程序开始执行时他才运行,程序结束时他就停止。
  • 执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程。

虚拟机的退出

  • 程序正常执行结束
  • 程序在执行过程中遇到异常或错误而异常终止
  • 由于操作系统出现错误导致Java虚拟机进程终止
  • 某线程调用Runtime类或System类的exit方法或Runtime类的halt方法,并且Java安全管理器也允许这次exit或halt操作
  • 除此之外,JNI(Java Native Interface)规范描述了用JNI Invocation API来加载或卸载虚拟机时,Java虚拟机退出的情况

理解执行引擎

image-20230620203120932

SUN Classic VM

当字节码文件到来时,在 传统的SUN Classic VM 中 只有解释器而没有即时编译器(JIT)但在之后的虚拟机(HotSpot)就对其进行整合二者可同时进行工作。解释器会对每条字节码进行解释,反应速度快,但执行速度慢,当遇到例如(for循环代码时,需逐行解释),但JIT反应速度很慢,他会将字节码代码进行编译为本地机器指令,并将其放到方法区中进行缓存。

可不可以将字节码文件全交给JIT去执行?这样执行效率多快。

首先,这是不可以的,因为当字节码文件到来时,JIT只能对其进行编译,而编译的速度没有解释快,所以在一开始,程序会进入卡顿(此时就是JIT在编译文件),最好的方式就是解释器和JIT一起使用,这样既可以反应速度快,并且使用JIT他监视热点代码,将其缓存到执行方法区中,提高了执行引擎的运行速度

Exact VM

  • 为了解决上一个虚拟机问题,JDK1.2时,SUN提供了该虚拟机
  • Exact Memory Management:准确式内存管理
    • 也可以叫Non-Conservative/Accurate Memory Management
    • 虚拟机可以知道内存的某个位置的数据具体是什么类型
  • 具备现代高性能虚拟机的雏形
    • 热点探测
    • 解释器与编译器混合工作模式
  • 只有在Solaris平台短暂使用,其他平台还是classic vm
    • 英雄气短,终被HotSpot虚拟机替代

HotSpot VM

  • Hotspot历史
    • 最初由一家名为“Longview Technologies”的小公司设计1997年,此公司被sun收购; 2009年,sun公司被甲骨文收购。JDK1.3时,HotSpot VM成为默认虚拟机
  • 目前Hotspot占有绝对的市场地位,称霸武林。
    • 不管是现在仍在广泛使用的JDK6,还是使用比例较多的JDK8中,默认的虚拟机都是HotSpot
  • sun/oracle JDK和 openJDK的默认虚拟机
  • 因此本课程中默认介绍的虚拟机都是HotSpot,相关机制也主要是指Hotspot的cc机制。(比如其他两个商用虚拟机都没有方法区的概念)
  • 从服务器、桌面到移动端、嵌入式都有应用。
  • 名称中的HotSpot指的就是它的热点代码探测技术。
    • 通过计数器找到最具编译价值代码,触发即时编译或栈上替换
    • 通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡

JRockit

  • 专注于服务器端应用
    • 它可以不太关注程序启动速度,因此JRockit内部不包含解析器实现,全部代碰都靠即时编译器编译后执行、
  • 大量的行业基准测试显示,JRockit JVM是世界上最快的JVM。
    • 使用JRockit产品,客户已经体验到了显著的性能提高(一些超过了70% )和硬件成本的减少(达50%)。
  • 优势:全面的Java运行时解决方案组合
    • JRockit面向延迟敏感型应用的解决方案JRockit Real Time提供以毫秒或微秒级的JVM响应时间,适合财务、军事指挥、电信网络的需要
    • MissionControl服务套件,它是一组以极低的开销来监控、管理和分析生产环境中的应用程序的工具。
  • 2008年,BEA被oracle收购。
  • oracle表达了整合两大优秀虚拟机的工作,大致在JDK 8中完成。整合的方式是在HotSpot的基础上,移植JRockit的优秀特性。
  • 高斯林:目前就职于谷歌,研究人工智能和水下机器人

2、内存结构

1、程序计数器

1、定义

Program Counter Register 程序计数器(寄存器)

2、作用

记住下一条JVM指令的执行地址

3、特点

  1. 是线程私有的
  2. 不会存在内存溢出

2、虚拟机栈(JVM stacks)

  • 每个线程运行时所需要的内存称为虚拟机栈
  • 每个栈由多个栈帧构成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

在JVM中可以设置指令设置虚拟机栈的大小

1
-Xss size 设置虚拟机栈的大小

在默认情况下

Linux的虚拟机栈内存大小为1024KB

MacOS的虚拟机栈内存大小为1024KB

Oracle solaris的虚拟机栈内存大小为1024KB

Window的虚拟机栈内存大小依据为操作系统的虚拟内存

栈内存划分越大越好吗,程序运行的约高效嘛?

实际上不是这样的,在物理内存一定的情况下,栈内存划分的越大,实际上对应的最大线程数就越少,因为总物理内存一定,而每个线程都有自己的虚拟机栈,所以虚拟机栈大了,对应的最大线程数就少了

问题辨析

  1. 垃圾回收是否涉及栈内存

    垃圾回收并不设计栈内存,每个方法啊在执行完后栈帧会自动弹出栈,并不需要垃圾回收

  2. 栈内存分配越大越好嘛

    并不是,在Linux与macOS中默认的栈内存大小为1024KB,Windows的栈内存大小与虚拟内存有关,物理内存是一定的,每个线程对应着一个栈内存,如果栈内存很大,那么线程的中的可存栈帧就越多,但线程就会越少。一般情况下我们会使用系统默认的栈内存大小

  3. 方法中的局部变量是否线程安全?

  • 如果方法内部变量没有逃离方法的作用访问,它是线程安全的
  • 如果是局部变量引用了对象,并且逃离方法的作用范围,考虑线程安全

栈内存溢出情况

  1. 栈帧过大,直接超出栈的内存大小(情况少见)
  2. 栈帧数量过多,超出栈帧的内存大小(情况多见,复杂的递归调用时容易出现该情况)

线上运行诊断

  1. 当一个java 程序跑起来的时候,将我们的服务器内存直接占满,那么我们该如何排错
    • 首先要对应用进程进行定位(top)
    • 使用top命令去查看进程,查看是哪个进程占用内存过大
    • 确认是java 进程后,查看当前系统下的所有线程的占用CPU量 (ps H -eo pid,tid,%cpu) ,查看到进程下的某线程id爆表
    • 使用 jstack pid 命令查看该进程的下的所有线程详细情况

image-20230619190911936

由于我们再第三步,已经查看到了是java 下的那个进程出现了内存爆表,此时我们可以将第三步得到的tid转化成16进制的数字用于查看对应进程的详细信息,根据线程的详细信息来,判断代码出现了哪些问题

  1. 当一个java程序跑起来之后,没有输出任何东西(内部原因是因为死锁,但我们一开始并未知道),我们该怎么查看

首先执行java程序时,会输出进程id,我们利用进程id,查看虚拟机栈出现了什么问题jstack pid,

当我们进入虚拟机栈中查看到两个线程报错时可以查看源码分析其原因,其次,我们要看看下面有没有报错信息

image-20230619192827481

我们发现该程序出现了死锁,根据源码分析在解决该问题

类加载器系统及其作用

概述

image-20230621175430309

  • 类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识
  • ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine(执行引擎)决定
  • 加载的类信息存放于一块成为方法区的内存空间,除了累的信息外,方法区还会存放运行时常量池的信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)

Car的Class文件的执行过程

image-20230621180136793

  1. class file 存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来根据这个文件实例化出n个一模一样的实例。
  2. class file 加载到JVM中,被称为DNA元数据模板,放在方法区。
  3. 在.class文件-> JVM->最终成为元数据模板,此过程就要一个运输工具(类装载器ClassLoader),扮演一个快递员的角色。

类的加载过程

image-20230621180416042

加载:

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

加载.class文件的方式

  • 从本地系统中直接加载
  • 通过网络获取,典型场景: web Applet
  • 从zip压缩包中读取成为日后jar、 war格式的基础
  • 运行时计算生成使用最多的是:动态代理技术
  • 由其他文件生成,典型场景:JSP应用
  • 从专有数据库中提取.class文件,比较少见
  • 从加密文件中获取,典型的防class文件被反编译的保护措施

类的链接过程

验证(Verify) :

  • 目的在子确保class文件的字节流中包含信息符合当前虚拟机要求保证被加载类的正确性,不会危害虚拟机自身安全。
  • 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。

准备(Prepare) :

  • 为类变量分配内存并且设置该类变量的默认初始值,即零值。
  • 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化;
  • 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。

解析(Resolve) :

  • 将常量池内的符号引用转换为直接引用的过程。
  • 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。
  • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
  • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class _info、CONSTANT_Fieldref info、CONSTANT_Methodref info等

类的初始化过程

  • 初始化阶段就是执行类构造器方法<clinit>()的过程。
  • 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
  • 构造器方法中指令按语句在源文件中出现的顺序执行。
  • <clinit>()不同于类的构造器。(关联:构造器是虚拟机视角下的<init> ())若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit> ()已经执行完毕。
  • 虚拟机必须保证一个类的<clinit> ()方法在多线程下被同步加锁。

类加载器的分类

JVM支持两种类加载器:引导类加载器(BootStrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)

自定义类加载器为直接或间接继承ClassLoader类的类

image-20230623200926054

这里的四者之间的关系是包含关系。不是上层下层,也不是子父类的继承关系。

当我们获取类加载器时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ClassLoaderTest {
public static void main(String[] args) {
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader(); //系统类加载器
System.out.println(systemClassLoader);

ClassLoader extClassLoader = systemClassLoader.getParent();//拓展类加载器
System.out.println(extClassLoader);

ClassLoader BootStrapClassLoader = extClassLoader.getParent(); //试图获取引导类加载器
System.out.println(BootStrapClassLoader);


ClassLoader classLoader = ClassLoaderTest.class.getClassLoader(); //获取本类的类加载器
System.out.println(classLoader);

ClassLoader classLoader2 = Integer.class.getClassLoader(); //获取Integer类的类加载器
System.out.println(classLoader2);


}
}

我们可以发现我们并不能获取引导类加载器(BootStrapClassLoader),并且本类的类加载器地址和系统类加载器一致,且Integer类的类加载器也为Null

BootStrapClassLoader:引导类加载器,Java的核心类库都是由引导类加载器加载的,这个类加载使用c/C++语言实现的,嵌套在JVM内部,它用来加载Java的核心库(JAVA HOME/jre/lib/rt.jar.resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类,并不继承自java.lang.classLoader,没有父加载器。加载扩展类和应用程序类加载器,并指定为他们的父类加载器。出于安全考虑,Bootstrap启动类加载器只加载包名为java、 javax、sun等开头的类

ExtClassLoader:扩展类加载器,Java语言编写,由sun.misc.Launcher$ExtClassLoader实现。派生于classLoader类, 父类加载器为启动类加载器
从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。

AppClassLoader:应用程序类加载器(系统类加载器),java语言编写,由sun.misc.Launcher$AppclassLoader实现派生于classLoader类,父类加载器为扩展类加载器,它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库,该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载,通过classLoader#getSystemclassLoader()方法可以获取到该类加载器

双亲委派机制

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理它是一种任务委派模式。

工作原理

  1. 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
  2. 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  3. 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

image-20230628211606026

image-20230628215708092

优势

避免类的重复加载

保护程序安全,防止核心API被随意篡改

  • 自定义类:java . lang.string

  • 自定义类: java. lang.shkStart

沙箱安全机制

自定义string类,但是在加载自定义string类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java \lang\string.class),报错信息说没有main方法,就是因为加载的是rt.jar包中的String类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制。

对类加载器的引用

JVM中表示两个Class对象是否为同一个类存在的必要条件

  • 类的完整类名必须一致,包括包名
  • 加载这个类的ClassLoader(指ClassLoader实例对象必须相同)

换句话说,在JVM中,即使这两个类对象(class对象)来源同一个class文件,被同一个虚拟机所加载,但只要加载它们的classLoader实例对象不同,那么这两个类对象也是不相等的。

JVM必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。|

Java程序对类的使用方式分为:主动使用和被动使用。·主动使用,又分为七种情况:
创建类的实例
访问某个类或接口的静态变量,或者对该静态变量赋值调用类的静态方法

反射(比如:Class.forName ( “com.atguigu. Test”) )初始化一个类的子类
Java虚拟机启动时被标明为启动类的类JDK 7开始提供的动态语言支持:
java. lang.invoke.MethodHandle实例的解析结果
REF getstatic、REF putstatic、REF invokestatic句柄应的类没有初始化,则初始化
除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用都不会导致类的初始化。

运行时数据区

JVM结构

当类被类加载器加载过后,就会被放到运行时数据区的本地方法区,等待被执行引擎执行后加载到内存

内存是非常重要的系统资源,是硬盘和cPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异。结合JVM虚拟机规范,来探讨一下经典的JVM内存布局。

image-20230629151854281

Java虚拟机定义了若千种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。

灰色的为单独线程私有的,红色的为多个线程共享的。即:灰色的为单独线程私有的,红色的为多个线程共享的.即:

image-20230629152352643

  • 每个线程:独立包括程序计数器、栈、本地栈。
  • 线程间共享:堆、堆外内存(永久代或元空间、代码缓存)

image-20230629152954887

JVM中的线程

线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行。

在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射。当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法。

JVM系统线程

如果你使用jconsole或者是任何一个调试工具,都能看到在后台有许多线程在运行。这些后台线程不包括调用public static void main (string[ ])的main线程以及所有这个main线程自己创建的线程。
这些主要的后台系统线程在Hotspot JVM里主要是以下几个:

  • 虚拟机线程︰这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括”stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销。
  • 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行。
  • GC线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持。
  • 编译线程:这种线程在运行时会将字节码编译成到本地代码。
  • 信号调度线程:这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理。

程序计数器(PC寄存器)

JVM中的程序计数寄存器(Program counter Regiser)中, Register 的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。这里,并非是广义上所指的物理寄存器,或许将其翻译为pc计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM中的Pc寄存器是对物理pc寄存器的一种抽象模拟

作用:PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。

介绍:

  • 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。

  • 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。

  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的TVM指令地址;或者,如果是在执行native方法,则是未指定值(undefned) 。

  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

  • 它是唯一一个在Java虚拟机规范中没有规定任何outOtMemoryError情况的区域。

将字节码文件反编译后我们去分析PC寄存器此时的作用

image-20230629161448819

PC寄存器常见的两个问题

使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?

因为cPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。
JVM的字节码解释器就需要通过改变Pc寄存器的值来明确下一条应该执行什么样的字节码指令。

PC寄存器为什么会被设定为线程私有?

  • 我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个Pc寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互千扰的情况。
  • 由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。
  • 这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。

虚拟机栈

由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。

优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

内存中的栈和堆

栈是运行时的单位,而堆是存储的单位。

即:栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪儿。

定义

Java虚拟机栈(Java virtual Machine stack),早期也叫Java栈每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(stack Frame) ,对应着一次次的Java方法调用。它是线程私有的,主管Java程序的运行,它保存方法的局部变量(8中基本数据类型,对象的引用地址)、部分结果,并参与方法日调用和返 回。

生命周期:与线程的生命周期一致

优点

栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。

JVM直接对Java栈的操作只有两个:

  • ​ 每个方法执行伴随着进栈(入栈、压栈)
  • 执行结束后的出栈工作
  • 对于栈来说不存在垃圾回收问题(GC、OOM)

栈可能出现的异常

Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的

如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个stackoverflowError异常。
如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈那Java虚拟机将会抛出一个outOfMemoryError异常。

栈的存储单位

每个线程都有自己的栈,栈中的数据都是以栈帧(stack Frame)的格式存在。

在这个线程上.正在执行的每个方法都各自对应一个栈帧(Stack Frame)

栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

栈运行原理

  • JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”/“后进先出”原则。

  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当市方法(CurrentMethod),定义这个方法的类就是当前类(current class) 。

  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。

  • 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。

  • 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。

  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧

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

栈帧的存储结构

每个栈帧中存储着:

  • 局部变量表(Local variables)
  • 操作数栈( operand stack)(或表达式栈)
  • 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用>
  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义).一些附加信息

image-20230705211139002

局部变量表
  • 局部变量表也被称之为局部变量数组或本地变量表

  • 定义为一个数字数组主要用于存储方法参数和定义在方法体内的局部变量这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。

  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题

  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。

  • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。

  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。

操作数栈

操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)/出栈(pop) 。某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如:执行复制、交换、求和等操作

  • 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。

  • 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。

  • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的code属性中,为max_stack的值。

  • 栈中的任何一个元素都是可以任意的Java数据类型。

    • 32bit的类型占用一个栈单位深度

    • 64bit的类型占用两个栈单位深度

  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push)和出栈(pop)操作来完成一次数据访问。

  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新Pc寄存器中下一条需要执行的字节码指今。

  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。

动态链接

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接( Dynamic Linking) 。比如: invokedynamic指令

在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用( symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。

方法调用

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。

  • 静态链接
    • 当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
  • 动态链接
    • 如果被调用的方法在编译期间无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。

对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定( Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

  • 早期绑定
    • 早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
  • 晚期绑定
    • 如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
虚方法和非虚方法
  • 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的.这样的方法称为非虚方法。
  • 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。
  • 其他方法称为虚方法。

子类对象的多态性的使用前提:1、类的继承关系 2、方法重写

虚拟机中提供了以下几条方法调用指令:·

  • 普通调用指令:
  1. invokestatic:调用静态方法,解析阶段确定唯一方法版本
  2. invokelspecial:调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本
  3. 0invokevirtual:调用所有虚方法
  4. invokeinterface:调用接口方法

动态调用指令:

invokedynamic:动态解析出需要调用的方法,然后执行

动态语言和静态语言

动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。

说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。

例如Java就是一个静态语言,但是Java试图在用Lambda表达式来让Java语言动态起来,但是Java本身就是静态语言

JS就是一门动态语言对比java来讲

1
2
int age="123";//java
var age=123; //JS

在java编译期间name这个变量的类型会被指定为int,因为前面有int修饰所以是指定的、是静态的,但在JS中则不然,JS中的变量是根据 后面所引用的值来决定的,并不是编译期间决定的,所以他是动态语言

返回地址
  • 存放调用该方法的pc寄存器的值。

  • 一个方法的结束,有两种方式:

    • 正常执行完成
    • 出现未处理的异常,非正常退出
  • 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

当一个方法开始执行后,只有两种方式可以退出这个方法:

1、执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;

  • 一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。
  • 在字节码指令中,返回指令包含ireturn (当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始值

2、在方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理(try catch),也就是只要在本方法的异常表中没有搜索到匹配的异常处理器(throws Exception),就会导致方法退出。简称异常完成出口。

方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

本地方法接口

本地方法

什么是本地方法

简单地讲,一个Native Method就是一个Java调用非Java代码的接口。一个Native Method是这样一个Java方涯:该方法的实现由非Java语言实现,比如c。这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在c++中你可以用extern “c”告知C++编译器去调用一个c的函数。

为什么要使用Native Method

Java使用起来非常方便,然而有些层次的任务用Java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。

与Java环境外交互:

  • 有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。你可以想想Java需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口而且我们无需去了解Java应用之外的繁琐的细节。
本地方法栈
  • Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。
  • 本地方法栈,也是线程私有的。
  • 允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)
    • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个stackoverflowError异常。
    • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个outofMemoryError异常。
  • 本地方法是使用c语言实现的。
  • 它的具体做法是Native Method stack中登记native方法,在Execution Engine执行时加载本地方法库。

当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。

  • 它甚至可以直接使用本地处理器中的寄存器

  • 直接从本地内存的堆中分配在意数量的内存。

并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。

  • 在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。

  • 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。
  • Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。
  • 堆内存的大小是可以调节的。
  • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
  • 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区( ThreadLocal Allocation Buffer,TLAB)。

在JVM运行.class文件时 添加的配置

1
2
3
-Xms10m:这个参数设置了JVM的初始堆大小。在JVM启动时,JVM会为堆分配至少10MB的内存空间。初始堆大小是堆的起始大小,如果应用程序需要更多的内存,JVM会根据需要进行动态扩展。

-Xmx10m:这个参数设置了JVM的最大堆大小。JVM能够使用的堆内存的最大限制为10MB。当JVM使用的内存接近最大堆大小时,JVM可能会触发垃圾回收以释放内存,或者抛出OutOfMemoryError异常。

需要注意的是,将初始堆大小和最大堆大小设置为相同的值,可以避免堆内存动态增长的开销,但也可能导致内存不足的问题,特别是对于需要较大内存的应用程序。因此,在实际使用中,你需要根据应用程序的内存需求进行合理的调整。

image-20230709104506755

现代垃圾回收器大部分都基于分代收集理论设计,堆空间细分为

Java 7及之前堆内存逻辑上分为三部分:新生区+养老区+永久区

  • Young Generation Space新生区 Young/New
    • 又被划分为Eden区和Survivor区
  • Tenure generation space养老区 old/Tenure
  • Permanent Space 永久区 Perm

Java 8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间

  • Young Generation Space新生区 Young/New

    • 又被划分为Eden区和Survivor区
  • Tenure generation space 养老区 old/Tenure

  • Meta Space 元空间 Meta

规定: 新生区《==》新生代《==》年轻代 养老区《==》老年区《==》老年代 永久区 《==》永久代

image-20230709154128915

当我们开启一个java进程并传入参数

1
-Xms10m -Xmx10m -XX:+PrintGCDetails  #启动进程后

image-20230709160630074

设置堆空间大小

Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项”-Xmx”和”一Xms”来进行设置。

  1. “-xms”用于表示堆区的起始内存,等价于-XX: InitialHeapsize
  2. “-xnte则用于表示堆区的最大内存,等价于-XX:MaxHeapsize
  3. 一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出outOfMemoryError异常。
  4. 通常会将-Xms和一Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
  5. 默认情况下,初始内存大小:物理电脑内存大小/ 64,最大内存大小:物理电脑内存大小/ 4

年轻代老年代

存储在JVM中的Java对象可以被划分为两类:

存储在JVM中的Java对象可以被划分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
  • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。
  • Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen)其中年轻代又可以划分为Eden空间、Survivor0空间和survivor1空间(有时也叫做from区、to区) 。

image-20230710150210620

image-20230710150551364

配置新生代与老年代在堆结构的占比。

  • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3

  • 可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5

  • 修改-XX:SurvivorRatio: 设置新生代区Eden与Survivor区的占比

  • 在HotSpot中,Eden空间和另外两个survivor空间缺省所占的比例是8:1:1

  • 当然开发人员可以通过选项“-XX:SurvivorRatio”调整这个空间比例。比如-xX :SurvivorRatio=8

  • 几乎所有的Java对象都是在Eden区被new出来的。绝大部分的Java对象的销毁都在新生代进行了。

  • IEM公司的专门研究表明,新生代中80% 的对象都是“朝生夕死”的。

  • 可以使用选项”-Xmn”设置新生代最大内存大小,这个参数一般使用默认值就可以了。

对象分配过程

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑Gc执行完内存回收后是否会在内存空间中产生内存碎片。

  1. new的对象先放伊甸园区。此区有大小限制。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
  3. 然后将伊甸园中的剩余对象移动到幸存者0区。
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
  5. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。6.啥时候能去养老区呢?可以设置次数。默认是15次。
    ·可以设置参数:-XX:MaxTenuringThreshold=<N>进行设置。
YGC(young GC)/Minor GC

当Eden满的时候,开始进行YGC,有我们JVM去判断Eden区哪个是垃圾哪个是保留下来的对象(幸存者 survivor),由图所示,红标为垃圾会被GC直接清除,而剩下的两个绿色的是幸存者,由于是首次进行YGC所以会将两个对象直接存入,首先会被放到幸存者0区(S0),我们将该部分标记为from区,将另一部分标记为to区(s1),然后将两个对象标注为1,意味着经历了一次YGC后幸存下来了,在经历第二次YGC时(Ednen又满了)这次的进幸存者会执行第一次幸存者的操作,但他会进入空的区中,在进行第n次YGC之前(n>2),JVM会对两个Survivor区进行判断,并执行一些其他GC算法,将一个区腾空,此时,腾空的区为fto区,另一个有对象的区叫from区,如果经历n次GC后仍然幸存下来,会被标记为n,当达n到阈值(我们所设置的垃圾回收)时,我们开始将次对象进行迁移(或叫晋升 promotion)进入如下二图所示的Tenured/Old(老年代)

image-20230710211016870

  • 针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to.
  • 关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。
Minor GC、Major GC 、Full GC

JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是新生代。

针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集( Partial Gc) ,一种是整堆收集(Full Gc)

  • 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
    • 新生代收集(Minor Gc / Young Gc):只是新生代的垃圾收集
    • 老年代收集(Major Gc / old Gc):只是老年代的垃圾收集。
      • 目前,只有CMS GC会有单独收集老年代的行为。
      • 注意,很多时候Major Gc会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。
  • 混合收集(Mixed Gc):收集整个新生代以及部分老年代的垃圾收集。
    • 目前,只有G1 Gc会有这种行为
  • 整堆收集(Full Gc):收集整个java堆和方法区的垃圾收集。

image-20230710212129116

年轻代Gc(Minor GC)触发机制
  • 当年轻代空间不足时,就会触发Minor Gc,这里的年轻代满指的是Eden代满,Survivor满不会引发GC。(母次M1nor GC云消理牛在代的内存。)
  • 因为Java对象大多都具备朝生夕灭的特性,所以 Minor Gc非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
  • Minor Gc会引发STw,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。
老年代Gc (Major GC/Fu1l GC)触发机制
  • 指发生在老年代的Gc,对象从老年代消失时,我们说“Major Gc”或“Full GC发生了。
  • 出现了Major Gc,经常会伴随至少一次的Minor Gc(但非绝对的,在Parallelscavenge收集器的收集策略里就有直接进行Major Gc的策略选择过程)
    • 也就是在老年代空间不足时,会先尝试触发Minor Gc。如果之后空间还不足,则触发Major GC
  • Major GC的速度一般会比Minor Gc慢10倍以上,STw的时间更长。如果Major GC后,内存还不足,就报OOM了。
  • Major cc的速度一般会比Minor Gc慢10倍以上。
Full GC触发机制:
触发Full GC执行的情况有如下五种:

(1)调用system.gc()时,系统建议执行Full Gc,但是不必然执行(2)老年代空间不足
(3)方法区空间不足
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
(5)由Eden区、survivor spaceB (From Space)区向survivor space1 (ToSpace)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
说明: full gc是开发或调优中尽量要避免的。这样暂时时间会短一些。

内存分配机制

如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被survivor容纳的话,将被移动到survivor空间中,并将对象年龄设为1 。对象在survivor区中每熬过一次Minorcc ,年龄就增加1岁,当它的年龄增加到一定程度(默认为15 岁,其实每个JVM、每个cc都有所不同)时,就会被晋升到老年代中。

对象晋升老年代的年龄阈值,可以通过选项**-XX:MaxTenuringThreshold**来设置。

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到Eden
  • 大对象直接分配到老年代
    • 尽量避免程序中出现过多的大对象
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断
    • 如果survivor区中相同年龄的所有对象大小的总和大于survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须MaxTenuringThreshold中要求的年龄。
  • 空间分配担保 :Xx:HandlePromotionFailure

对象分配过程(TLAB)

为什么会有TLAB?

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。

LAB是虚拟机在堆内存的eden划分出来的一块专用空间,是线程专属的。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。这里值得注意的是,我们说TLAB是线程独享的,但是只是在“分配”这个动作上是线程独享的,至于在读取、垃圾回收等动作上都是线程共享的。而且在使用上也没有什么区别。

也就是说,虽然每个线程在初始化时都会去堆内存中申请一块TLAB,并不是说这个TLAB区域的内存其他线程就完全无法访问了,其他线程的读取还是可以的,只不过无法在这个区域中分配内存而已。

并且,在TLAB分配之后,并不影响对象的移动和回收,也就是说,虽然对象刚开始可能通过TLAB分配内存,存放在Eden区,但是还是会被垃圾回收或者被移到Survivor Space、Old Gen等。

image-20230711145554777

LAB带来的问题

虽然在一定程度上,TLAB大大的提升了对象的分配速度,但是TLAB并不是就没有任何问题的。

前面我们说过,因为TLAB内存区域并不是很大,所以,有可能会经常出现不够的情况。在《实战Java虚拟机》中有这样一个例子:

比如一个线程的TLAB空间有100KB,其中已经使用了80KB,当需要再分配一个30KB的对象时,就无法直接在TLAB中分配,遇到这种情况时,有两种处理方案:

1、如果一个对象需要的空间大小超过TLAB中剩余的空间大小,则直接在堆内存中对该对象进行内存分配。

2、如果一个对象需要的空间大小超过TLAB中剩余的空间大小,则废弃当前TLAB,重新申请TLAB空间再次进行内存分配。

以上两个方案各有利弊,如果采用方案1,那么就可能存在着一种极端情况,就是TLAB只剩下1KB,就会导致后续需要分配的大多数对象都需要在堆内存直接分配。

如果采用方案2,也有可能存在频繁废弃TLAB,频繁申请TLAB的情况,而我们知道,虽然在TLAB上分配内存是线程独享的,但是TLAB内存自己从堆中划分出来的过程确实可能存在冲突的,所以,TLAB的分配过程其实也是需要并发控制的。而频繁的TLAB分配就失去了使用TLAB的意义。

为了解决这两个方案存在的问题,虚拟机定义了一个refill_waste的值,这个值可以翻译为“最大浪费空间”。

当请求分配的内存大于refill_waste的时候,会选择在堆内存中分配。若小于refill_waste值,则会废弃当前TLAB,重新创建TLAB进行对象内存分配。

前面的例子中,TLAB总空间100KB,使用了80KB,剩余20KB,如果设置的refill_waste的值为25KB,那么如果新对象的内存大于25KB,则直接堆内存分配,如果小于25KB,则会废弃掉之前的那个TLAB,重新分配一个TLAB空间,给新对象分配内存。

堆再JVM中的参数配置

JVM参数配置官网连接:java (oracle.com)

常用的关于堆的JVM配置参数

1
2
3
4
5
6
7
8
9
10
11
	-XX:+PrintFlagsInitial:  查看所有的参数配置的默认值
-XX:+printFlagsFinal : 查看所有的参数的最终值(可能会存在修改,不再是最初值)
-Xms: 初始化堆空间内存 (默认为物理内存的1/64
-Xmx: 最大堆空间内存(默认为物理内存的1/4
-Xmn:设置新生代的大小(默认值及最大值)
-XX:NewRatio:配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio:设置新生代Eden和S0/s1空间的比例
-XX:MaxTenuringThreshold: 设置新生代垃圾的最大年龄
-XX:+PrintGCDetails:输出详细的GC处理日志
打印GC简要信息: 一:-XX:+PrintGC 二: -verbose:gc
-XX:HandlePromotionFailure: 是否设置空间分配担保

堆是分配对象存储的唯一选择吗

在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:
随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现。一个对象如果没有逃逸出方法的话,那么就有可能被优化成栈上分配,这样就无需在堆上分配内存,也无须逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
此外,前面提到的基于openJDK深度定制的TaoBaoVM,其中创新的GCIH ( Gcinvisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且cc不能管理ccIH内部的Java对象,以此达到降低cc的回收频率和提升Gc的回收效率的目的。

逃逸分析

如何将堆上的对象分配到栈,需要使用逃逸分析手段。
这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。I
逃逸分析的基本行为就是分析对象动态作用域:

当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有
发生逃逸。
当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中

逃逸分析代码优化

一、栈上分配。将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
二、同步省略如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
三、分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。( 通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。)

方法区

堆、栈、方法区的交互关系

从数据共享的角度来看

image-20230807094011150

从代码的角度来看

image-20230807094357481

  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误: java.lang. outOfMemoryError,PermGen space 或者java.lang.outOfMemoryError: Metaspace
  • 关闭JVM就会释放这个区域的内存。

设置方法区大小

方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。jdk7及以前:

  • 通过-xx: PermSize来设置永久代初始分配空间。默认值是20.75M
  • -XX:MaxPermsize来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M
  • 当JVM加载的类信息容量超过了这个值,会报异常outOfMemoryError: PermGenspace 。

jdk1.8之后

  • 元数据区大小可以使用参数-XX:Metaspacesize和-XX:MaxMetaspaceSize指定替代上述原有的两个参数。

  • 默认值依赖于平台。windows下,-XX: Metaspacesize是21M,一XX:MaxMetaspacesize 的值是-1,即没有限制。

  • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常outOfMemoryError: Metaspace-XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器端JVM来说其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活)然后这个高水位线将会重置。新的高水位线的值取决于Gc后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。

  • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地Gc ,建议将

    -XX:Metaspacesize设置为一个相对较高的值。

如何解决OOM

1、要解决oOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(MemoryLeak)还是内存溢出(Memory overflow)。
2、如果是内存泄漏,可进一步通过工具查看泄漏对象到Gc Roots 的引用链能找到泄漏对象是通过怎样的路径与GC Root相关联并导致垃圾回收期无法自动回收,就可以比较准确它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置。
3、如果不存在内存泄漏,换可话说就是内存中的对象确实都还必须活着,那就应当检查虚拟机的参数(-Xmx与-Xms),与机器物理内存比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

方法区的内部结构

image-20230807104540446

《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

类型信息

对每个加载的类型(类class、接口interface、枚举enum、注解annotation) ,JVM必须在方法区中存储以下类型信息:

  • 这个类型的完整有效名称(全名=包名.类名)
  • 这个类型直接父类的完整有效名(对于interface或是java.lang.objelct,都没有父类)
  • 这个类型的修饰符(public,abstract, final的某个子集)
  • 这个类型直接接口的一个有序列表
域(field)信息
  • JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
  • 域的相关信息包括:域名称、域类型、域修饰符(public, private,protected,static,final, volatile,transient的某个子集)
方法(Method)信息

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:·方法名称

  • 方法的返回类型(或void)·方法参数的数量和类型(按顺序)

  • 方法的修饰符(public, private, protected,static, final,synchronized, native, abstract的一个子集)

  • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)

  • ·异常表( abstract和native方法除外)

    每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

non-final的类变量

静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分,类变量被类的所有实例共享,即使没有类实例时你也可以访问它。

常量池

一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍。

几种在常量池内存储的数据类型包括:

  • 数量值
  • 字符串值
  • 类引用
  • 字段引用
  • 方法引用
运行时常量池

运行时常量池(Runtime constant Poo1)是方法区的一部分。

  • 常量池表(Constant Pool Table)是class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
  • JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
  • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
    • 运行时常量池,相对于class文件常量池的另一重要特征是:具备动态性。
  • 运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些。
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛outofMemoryError异常。

方法区的演进

首先明确:只有Hotspot才有永久代。
BEA JRockit、IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一
Hotspot中方法区的变化:

image-20230807155259893

永久代为什么被元空间替代?

  • 随着Java8的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间( Metaspace ) 。
  • 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。

这项改动是很有必要的,原因有:
1)为永久代设置空间大小是很难确定的。
在某些场景下,如果动态加载类过多,容易产生Perm 区的OOM。比如某个实际web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存因此,默认情况下,元空间的大小仅受本地内存限制。

2)对永久代调优是比较困难的

字符串常量池为什么要调整

jdk7中将stringTable放到了堆空间中。因为永久代的回收效率很低,在full的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。
这就导致stringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

方法区的垃圾回收

  • 有些人认为方法区(如Hotspot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以个要水应拟机仕力法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK 11时期的zGc收集器就不支持类卸载)。
  • 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。
  • 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。

对象实例化

创建对象的方式

  • new :直接new 对象 例如: new String() , 变形1:Xxx的静态方法(单例模式,创建对象) 变形2:XxxBuilder//XxxFactory的静态方法
  • Class的newInstance(),通过反射的方式可以调用空参 的构造器,权限必须是public
  • Construtor的newInstance(Xxx):反射方式,可可以调用空参、带参构造器,没有权限要求
  • 使用clone():不使用任何构造器,当前类需要实现Cloneable接口,实现clone()
  • 使用反序列化:从文件中,从网络中获取一个对象的二进制流
  • 使用第三方库Objenesis

创建对象的步骤

说之前先捋清一个大致的思路:创建对象的过程大致分为 5 步:

  • Step1:类加载检查
  • Step2:分配内存
  • Step3:初始零值
  • Step4:设置对象头
  • Step5:执行 init
  1. 类加载检查

    当我们在 Java 程序中 new 一个对象的时候,在底层其实会有大概以下几步:

    • 首先它会检查这个指令是否能在常量池中能否定位到一个类的符号引用
    • 接着会检查这个符号引用代表的类是否已经被加载、解析、初始化。如果没有会进行一个**类加载**

    检查完类加载后就是分配内存了。那么该对象的具体内存是否确认呢?

    其实类加载完成后可以确认它所需要的内存了)

  2. 分配内存

    现在我们已经知道了对象所占的内存,那么虚拟机是如何给对象在 Java 堆中分配内存的呢?主要有两种分配方式:

    1. 指针碰撞
    2. 空闲列表

    接下来我们详细说说这两种分配内存的方式:

    指针碰撞

    其实这种方式理解起来比较简单的,假设 Java 堆中的内存是绝对完整的,它会把使用过的内存和未使用过的内存划分开来。此时一边就是使用过的内存,一边就是未使用过的内存;那么他如何去给一个新的对象去划分空闲内存中的某块区域呢?其实很简单,就是借助一个指针(这里是不是呼应上了所谓的指针碰撞);当我们分配内存的时候就是把指针在空闲的内存区域中移动一个与要被创建对象大小相等的距离。这就是指针碰撞的方式

    适用场景:内存规整,不碎片化

    空闲列表

    这个其实理解起来更为简单。它无非就是指在 Java 堆中的内存并非是规整的(使用的内存和未使用过的内存没有划分开来),比较杂乱无章,此时虚拟机就得需要列表记录内存中哪些是已经使用的哪些是没有使用的,然后在给对象分配内存空间的时候在该列表中找一个足够的内存分给对象实例;并更新维护的列表。这种就叫做空闲列表(Free List)

    适用场景:堆内存碎片化

    **Tip:**说到分配内存的两种方式,就顺便提一句,

    • 当使用的是Serial``ParNew等压缩整理过程的收集器的时候,系统采用的是指针碰撞的方式。
    • 而当使用的是CMS这种基于清除的算法收集器,理论上就只能采用空闲列表。
    分配内存如何保证线程安全的

    上面我们将给新的对象分配内存的方式以及分配内存前的逻辑大致理完了。你是不是觉得很简单。其实就是这么简单。但是其实我们忽略了一个很重要的问题。我们回想起本篇文中第一段话:Java 程序在运行过程中无时无刻不在创建对象,那么它是如何在并发环境下保证线程安全的呢?接下来我们简单的捋一下其实保证线程安全还是两种方式:

    1. 将分配内存空间的动作进行同步处理(虚拟机底层的实现逻辑就是CAS + 失败重试)来保证分配内存空间的原子性。
    2. 还有一种就是将分配内存的动作按照线程划分在不同的空间中进行,也就是每个线程在 Java 堆中有有属于自己的一小块内存,这种方式叫做本地线程分配缓冲 Thread Local Allocation Buffer TLAB,当本地线程缓冲使用完了,再分配缓存区时才需要同步锁定。至于虚拟机是否使用 TLAB 可通过参数-XX: +/-UseTLAB来控制。
  3. 属性初始零值

当分配完内存后,虚拟机必须将分配到的内存空间(不包含对象头)都初始化为零值。如果使用了 TLAB,那么这一步会在 TLAB 分配时进行。为什么虚拟机要有这番操作呢?

主要是为了保证对象的实例字段能够在 Java 代码中可以在不赋值的是否就可以访问直接使用,这样就能使 Java 程序访问这些字段所对应的数据类型的初始零值

  1. 设置对象头

    接下来,Java 虚拟机还需要对这些对象进行必要的设置,例如这些对象是哪些类的实例、以及如何才能找到类的元信息、对象的哈希码(实际对象的哈希码会延期到真正调用 Object::hashCode()方法时才计算)、对象 GC 的分代年龄等信息,这些信息都会保存在对象头中(Object Header)之中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式

  2. 执行init方法进行初始化

执行完上述操作后,对于 Java 虚拟机来说对象已经创建完了,但是对于 Java 视角来说,对象的创建才刚刚开始,还没有执行init方法。所有的字段还都为零。对象中需要的其它资源和状态信息还没有按照原有的意图去构造好。所以一般来说,new指令之后就会执行init方法,按照 Java 程序员的意图去对对象做一个初始化,这样之后一个真正完整可用的对象才构造出来

给对象的属性赋值的操作:

  • 属性的默认初始化:在Java中,当你创建一个对象实例时,对象的属性会被默认初始化为一些初始值,这些初始值根据属性的类型而有所不同。这些默认初始值是Java语言规范定义的,确保了对象在创建时始终具有一些合理的初始状态。例如:基本数据类型的属性会被赋予默认的零值
  • 显式初始化(在类中定义属性的时候直接进行赋值例如属性 String name = “xxx”),或者在构造函数中指直接对属性进行初始化
  • 代码块初始化
  • 构造器中的初始化

对象的内存布局

对象头(Object Header)

对象头中包括 Mark Word(标记字段)、Class MetaData Address(类元数据地址)、数组长度(数组长度)

Mark Wrod

这部分主要用来存储对象自身的运行时数据,如hashcode、gc分代年龄等。mark word的位长度为JVM的一个Word大小,也就是说32位JVM的Mark word为32位,64位JVM为64位。 为了让一个字大小存储更多的信息,JVM将字的最低两个位设置为标记位,不同标记位下的Mark Word示意如下:

image-20230810104711200

其中各部分含义如下

  • unused:未使用的
  • hashcode:指不经重写过由jvm计算的hashcode
  • thread: 偏向锁记录的线程标识
  • epoch: 验证偏向锁有效性的时间戳
  • age:分代年龄
  • biased_lock 偏向锁标志
  • lock 锁标志
  • pointer_to_lock_record 轻量锁lock record指针
  • pointer_to_heavyweight_monitor 重量锁monitor指针

对于锁标识位:image-20230810104402776

那什么是偏向锁、轻量锁、重量锁?

​ 首先,我们需要明确一点:这三种锁只针对synchronized, Java中锁主要存在四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,随着竞争的激烈而逐渐升级。锁只能升级而不能降级,即一个锁从偏向级锁升级到轻量锁时,不能再重新回到偏向级锁。

偏向锁

顾名思义,偏向某一个线程,当线程数目不多的时候,由于反复获取锁会使得我们的运行效率下降,于是出现了偏向级锁。JVM使用CAS操作把线程ID记录到对象的Mark Word当中,并修改标识位,name当前线程就拥有了这把锁。

在mark word中 带有偏向锁的对象 线程号占54位,是否偏向占1位 ,锁标识位占2位

image-20230810110420669

偏向级锁不需要操作系统的介入,JVM使用CAS操作将线程ID放入对象的Mark Word字段中,于是线程获得了锁,可以执行synchronized代码块的内容,当线程再次执行到这个synchronized的时候,JVM通过锁对象的Mark Word判断 :当前线程ID还存在,还持有这个对象的锁,于是就可以继续进入临界区执行,而不需要再次获得锁

偏向锁,在没有别的线程竞争的时候,一直偏向当前线程,当前线程就可以一直进入synchronized修饰的代码块一直运行。

如果在运行中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁升级到轻量级锁。

总结一点:偏向级锁就是为了消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。

轻量级锁

轻量级锁是由偏向级锁升级来的,当一个线程运行同步代码块时,另一个线程也加入想要运行这个同步代码块时,偏向锁就会升级为轻量级锁。

首先,JVM会将锁对象的Mark Word恢复成为无锁状态,在当前两线程的栈桢中各自分配一个空间,叫做Lock Record,把锁对象account的Mark Word在两线程的栈桢中各自复制了一份,官方称为:Displaced Mark Word

image-20230810112039785

​ 然后一个线程尝试使用CAS将对象头中的Mak Word替换为指向锁记录的指针,如果替换成功,则当前线程获得锁,如果失败,则当前线程自旋重新尝试获取锁。当自旋获取锁仍然失败时,表示存在其他线程竞争锁(两个或两个以上线程竞争同一个锁),则轻量级锁会膨胀成重量级锁

举个例子: 线程A、线程B同时想要执行一个同步代码块,假设线程A抢到了锁,则线程A的Lock Record的地址 会被CAS操作放到了锁对象Mark Word中,并且将锁标志位改为00,这意味着线程A就获取到了该锁,可以执行同步代码块。

而线程B没有抢到锁,但是线程B不会阻塞,而是通过自旋的方式,等待获取锁。(一般默认自旋10次),如果线程A释放掉锁,则将线程A中的Displaced mark word使用CAS复制回锁对象的Mark Word字段,此时线程B就可以获取锁对象,如果线程B还没有获取成功,则说明同时存在两个或两个以上的线程同时竞争这一把锁,轻量级锁会升级成为重量级锁。

重量锁

我们上面提到,当多个线程竞争同一个锁时,会导致除锁的拥有者外,其余线程都会自旋,这将导致自旋次数过多,cpu效率下降,所以会将锁升级为重量级锁。重量级锁需要操作系统的介入,依赖操作系统底层的Muptex Lock。JVM会创建一个monitor对象,把这个对象的地址更新到Mark Word中。

image-20230810112946796

当一个线程获取了该锁后,其余线程想要获取锁,必须等到这个线程释放锁后才可能获取到,没有获取到锁的线程,就进入了阻塞状态。

klass pointer

klass pointer一般占32个bit即4个字节,如果你有足够的原因关闭默认的指针压缩,即启动参数加上了-XX:-UseCompressedOops那么它就占64个bit,不过此处还有一个细节:根据计算,堆大小超过32GB后,就算不关指针压缩并不会报错,只是指针压缩会失效。

klass pointer的存储内容是一个指针,指向了其类元数据的信息,jvm使用该指针来确定此对象是类的哪个实例.

什么意思?如果你有一个Person实例的引用,那么找到元数据就靠它了,如图:

image-20230810114135918

实例数据

接下来实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字 段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会 受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。 HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存 放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的 +XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空 隙之中,以节省出一点点空间。

对齐填充

对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作 用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是 任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者 2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

对象的访问定位

JVM如何通过栈帧中的对象引用访问到其内部的对象实例的呢?定位,通过栈上reference访问

image-20230811103509732

对象访问的方式主要有两种:句柄访问,直接指针(Hotspot采用)

句柄访问

image-20230811103804024

直接指针

image-20230811103938155

对象的创建过程

image-20230811104534360

直接内存

概述

  • 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。

  • 直接内存是在Java堆外的、直接向系统申请的内存区间。

  • 来源于NIo,通过存在堆中的DirectByteBuffer操作Native内存

  • 通常,访问直接内存的速度会优于Java堆。即读写性能高。

    • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。
    • Java的NIo库允许Java程序使用直接内存,用于数据缓冲区

非直接缓冲区

读写文件,需要与磁盘交互,需要由用户态切换到内核态。在内核态时,需要内存如下图的操作。
使用IO,见下 图。这里需要两份内存存储重复数据,效率低。

image-20230811105639966

执行引擎

概述

  • 执行引擎是Java虚拟机核心的组成部分之一。

  • “虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。

  • JVM的主要任务是负责装载字节码到其内部,但字节码并 不能够直接运行在操作系统之上I因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。

  • 那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。

工作过程

image-20230811113605783

从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。

Java代码编译和执行的过程

image-20230811115049445

大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤。

Java字节码的执行是由JVM执行引擎来完成,流程图如下

image-20230811115455419

什么是解释器(Interpreter),什么么是JIT编译器?

解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将字节码文件中的内容”翻译”为对应平台的本地机器指令执行

JIT(Just In Time Compiler)编译器: 就是虚拟机将源代码直接编译成本地机器相关的机器语言

机器码、指令、汇编语言

机器码

  • 各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它采编写程序,这就是机器语言。
  • 机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。
  • 用它编写的程序一经输入计算机,CPU直接读取运行,因此和其他语言编的程序相比,执行速度最快。
  • 机器指令与cPU紧密相关,所以不同种类的CPU所对应的机器指令也就不同。

指令

  • 由于机器码是有o和1组成的二进制序列,可读性实在太差,于是人们发明了指令。

  • 指令就是把机器码中特定的o和1序列,简化成对应的指令(一般为英文简写,如mov,inc等),可读性稍好

  • 由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同。

指令集

  • 不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。

  • 如常见的

    • x86指令集,对应的是x86架构的平台

    • ARM指令集,对应的是ARM架构的平台

汇编语言

  • 由于指令的可读性还是太差,于是人们又发明了汇编语言。
  • 在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号(Symbol)或标号(Label)代替指令或操作数的地址。
  • 在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。
  • 由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。

高级语言

  • 为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言
  • 当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序或编译程序。

语言执行过程

image-20230811165551216

字节码

  • 字节码是一种中间状态(中间码)的二进制代码(文件),它比机器码更抽象,需要直译器转译后才能成为机器码
  • 字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。
  • 字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。

字节码的典型应用为 Java bytecode

image-20230811170423914

解释器

JVM设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。

  • 解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
  • 当一条字节码指令被解释执行完成后,接着再根据Pc寄存器中记录的下一条需要被执行的字节码指令执行解释操作。

有些开发人员会感觉到诧异,既然HotSpot VM中已经内置JIT编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?比如JRockit VM内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。

  • 首先明确:
    • 当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。
    • 编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。
  • 所以:
    • 尽管JRockit VM中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。在此模式下,当Java虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越 来越多的代码编译成本地代码,获得更高的执行效率。
  • 同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”。

案例:

  • 注意解释执行与编译执行在线上环境微妙的辩证关系。机器在热机状态可以承受的负载要大于冷机状态。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。
  • 在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的1/8。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后,当前l/2发布成功的服务器马上全部宕机,此故障说明了JIT 的存在。—阿里团队

image-20230811173835201

JIT编译器

  • Java语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把.java文件转变成.class文件的过程;

  • 也可能是指虚拟机的后端运行期编译器(JIT 编译器,Just In Time Compiler)把字节码转变成机器码的过程。

  • 还可能是指使用静态提前编译器(AOT编译器,Ahead of Time Compiler)直接把.java文件编译成本地机器代码的过程。

  • 前端编译器: sun 的 Javac、Eclipse JDT中的增量式编译器(ECJ)

  • JIT编译器:HotSpot VM的c1、c2编译器。

  • AOT编译器:GNU Compiler for the Java (GCJ)、Excelsior JET

热点代码及探测方式

当然是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能。

  • 一个被多次调用的方法或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为oSR (on StackReplacement)编译。
  • 一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。
  • 目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。采用基于计数器的热点探测
  • HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(BackEdge Counter)

方法调用计数器用于统计方法的调用次数
回边计数器则用于统计循环体执行的循环次数

方法调用器

  • 这个计数器就用于统计方法被调用的次数,它的默认阈值在 client模式下是 1500 次,在 Server模式下是10000 次。超过这个阈值,就会触发JIT编译。
  • 这个阈值可以通过虚拟机参数-xx:CompileThreshold来人为设定。
  • 当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

JIT执行过程

image-20230811180619647

热度衰减

  • 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,I即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)。
  • 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-X:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
  • 另外,可以使用-x:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

HotSpotVM可以设置程序的执行方式

  • 缺省情况下HotSpot VM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:
  • -xint:完全采用解释器模式执行程序;
  • -Xcomp:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。
  • -Xmixed:采用解释器+即时编译器的混合模式共同执行程序。

JIT分类

在HotSpot VM中内嵌有两个JIT编译器,分别为client Compiler和server
Compiler,但大多数情况下我们简称为c1编译器和c2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器,如下所示:

  • -client:指定Java虚拟机运行在Client模式下,并使用C1编译器
    • C1编译器会对字节码进行简单和可靠的优化,耗时短。以达到更快的编译速度
  • -server:指定Java虚拟机运行在Server模式下,并使用C2编译器
    • C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高

C1C2编译器不同的优化策略

  • 在不同的编译器上有不同的优化策略,c1编译器上主要有方法内联,去虚拟化、冗余消除。

  • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程

  • 去虚拟化:对唯一的实现类进行内联

  • 冗余消除:T在运行期间把一些不会执行的代码折叠掉

C2的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在c2上有如下几种优化:

  • 标量替换:用标量值代替聚合对象的属性值

  • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆

  • 同步消除:清除同步操作,通常指synchronized

  • 分层编译(Tiered Compilation)策略:程序解释执行(不开启性能监控)可以触发cl编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控信息进行激进优化。

  • 不过在Java7版本之后,一旦开发人员在程序中显式指定命令“-server”时,默认将会开启分层编译策略,由cl编译器和c2编译器相互协作共同来执行编译任务。

String

String的基本特性

  • string:字符串,使用一对””引起来表示。

  • string声明为final的,不可被继承

  • string实现了serializable接口:表示字符串是支持序列化的。实现了comparable接口:表示string可以比较大小

  • string在jdk8及以前内部定义了final char[] value用于存储字符串数据。jdk9时改为byte[]

  • 通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。

  • string :代表不可变的字符序列。简称不可变性

    • 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值。

    • 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。

    • 当调用string的replace ()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。

  • 字符串常量池中是不会存储相同内容的字符串的

  • string的string Pool是一个固定大小的Hashtable,默认值大小长度是1009。如果放进string Pool的string非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用string.intern时性能会大幅下降。

  • 使用-Xx :StringTablesize可设置stringTable的长度

  • 在jdk6中stringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTablesize设置没有要求在jdk7中,stringTable的长度默认值是60013,1009是可设置的最小值。

String内存分配

  • 在Java语言中有8种基本数据类型和一种比较特殊的类型string。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。|

  • 常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种。

    • 直接使用双引号声明出来的String对象会直接存储在常量池中。比如:string info = “atguigu. com” ;
    • 如果不是用双引号声明的string对象,可以使用string提供的intern ()方法。这个后面重点谈
  • Java 6及以前,字符串常量池存放在永久代。

  • Java 7 中oracle 的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的啦置调整到Java堆内。

    • 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
    • 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够 的理由让我们重新考虑在Java 7 中使用string.intern ( )。
  • Java8元空间,字符串常量在堆

StringTable为什么要调整?

  1. 永久代空间默认比较小
  2. 永久代垃圾回收频率低

String的拼接操作

  • 常量与常量的拼接结果在常量池,原理是编译期优化
  • 常量池中不会存在相同内容的常量。
  • 只要其中有一个是变量,结果就在堆中。变量拼接的原理是stringBuilder
  • 如果拼接的结果调用intern ()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。
1
2
3
4
5
6
7
8
9
10
11
12
String s1="hello";
String s2="world";
String s3="helloworld";
String s4="hello"+"world";
String s5=s1+"world";
String s6="hello"+s2;
String s7=s1+s2;
System.out.println(s3 == s4);
System.out.println(s4 == s5);
System.out.println(s5 == s6);
System.out.println(s5 == s7);
System.out.println(s6 == s7);

image-20230814174437843

intern()的使用

  • 如果不是用双引号声明的string对象,可以使用string提供的intern方法: intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
  • 比如: string myInfo = new string (“I love mmv bbb”) .Intern ();
  • 也就是说,如果在任意字符串上调用string.intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true:
  • ( “a” + “b” + “c” ) .intern ( ) == “abc”
  • 通俗点讲,Interned string就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池( string Intern Pool)。

对于以下面试题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    String s3=new String("1");
// 在下面代码执行之前 StringTable中已经有 1 了
s3.intern();
// 再利用字面量创建一个字符串对象 1
String s4="1";
//最终输出结果一定为false,因为s3是String对象s4指向的是StringTable中的值
System.out.println(s3==s4);
System.out.println("=========");
//经下行代码执行后对象为对象s1 ab
String s1=new String("a")+new String("b");
//此时StringTable中没有 常量ab
s1.intern();
//上行代码执行后底层数据常量池中的ab指向s1对象,此时s1.intern生成的对象 就是s1 ,s1==s1.intern()
String s2="ab"; //此时数据常量池中已有"ab" 所以s2指向的是常量池中的ab其实也间接引用了s1
System.out.println(s1==s2);//所以输出结果为true

最终输出结果为 false true

例如一个对象,他有一个方法setAddr(String name),其实就是当setAddr时由于Addr是一个经常重复的字符创所以就可以调用intern()方法,所以

1
2
3
4
5
6
public class person{ 
private String addr;
public void setaddr(Strubg addr){
this.addr=addr.intern;
}
}

对于程序中大量存在存在的字符串,尤其其中存在很多重复字符串时,使用intern()可以节省内存空间。

内存的分配回收

概述

垃圾收集,不是Java语言的伴生产物,早在1960年,第一门开始使用内存动态分配和垃圾收集技术Lisp语言诞生了

垃圾分类收集有三个经典问题:

  • 那些内存需要回收?
  • 什么时候回收?
  • 如何回收?

垃圾收集机制是Java的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战。

所以什么是垃圾?

  • 垃圾 是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾
  • 如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留着到应用程序结束,被保留的空间无法被其他对象使用,甚至可能导致内存溢出

为什么需要GC呢?

  • 对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫—样。

  • 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象。

  • 随着应用程序所应付 的业务越来越庞大、复杂,用户越来越多,没有Gc就不能保证应用程序的正常进行。而经常造成STw的Gc又跟不上实际的需求,所以才会不断地尝试对Gc进行优化。

  • 在早期的C/C++时代,垃圾回收基本上是手工进行的。开发人员可以使用new关键字进行内存申请,并使用delete关键字进行内存释放。

  • 这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃。

Java的垃圾回收机制

  • 自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险
    • 没有垃圾回收器,java也会和cpp一样,各种悬垂指针,野指针,泄露问题让人头疼不已。
  • 自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发

但垃圾回收也有担忧

  • 对于Java开发人员而言,自动内 存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力。
  • 此时,了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见outofMemoryError时,快速地根据错误异常日志定位问题和解决问题。
  • 当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。

GC回收区域

方法区与堆位GC的作用区 ,垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是全堆和方法区,其中Java堆是垃圾回收器的工作重点

从次数上将: 频繁手机Young区,较少收集Old区,基本不动Perm区(或元空间)

垃圾标记

对象存活判断

  • 在堆里存放着几乎所有的Java对象实例,在Gc执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。
  • 那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
  • 判断对象存活一般有两种方式:引用计数算法和可达性分析算法。
引用计数算法
  • 引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
  • 对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
  • 优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
  • 缺点:

它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。

每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。

引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法。

使用引用计数算法为何对循环引用无法回收

如下图,P指针是Java栈中的局部变量表的指针指向堆中的对象链表

当P的指针为空时(无局部变量表中的指针指向next rc=2的对象),但对象链表中(next rc=1依然指向rc=2的对象导致循环依赖),导致对象链表无法消除进而导致内存泄漏

image-20230816224552919

  • 引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的Python,它更是同时支持引用计数和垃圾收集机制。
  • 具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
  • Java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。
  • Python如何解决循环引用?
    • 手动解除:很好理解,就是在合适的时机,解除引用关系。
    • 使用弱引用weakref, weakref是Python提供的标准库,旨在解决循环引用。
可达性分析算法
  • 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
  • 相较于引用计数算法,这里的可达性分析就是Java、C#选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbagecollection) 。

所谓”GC Roots”根集合就是一组必须活跃的引用

基本思路:

  • 可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
  • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
  • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。

image-20230817213853674

在Java中,GCRoots包括以下几类元素:

  • 虚拟机栈中引用的对象
    • 比如:各个线程被调用的方法中使用到的参数、局部变量等。
  • 方法区中类静态属性引用的对象
  • 本地方法栈内JNI(通常说的本地方法)引用的对象
  • 比如: Java类的引用类型静态变量方法区中常量引用的对象
    • 比如:字符串常量池(string Table)里的引用所有被同步锁synchronized持有的对象
  • Java虚拟机内部的引用。
  • 基本数据类型对应的class对象,一些常驻的异常对象(如:NullPointerException、outofMemoryError),系统类加载器。
  • 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等.

用一个图来形象的描述,下图中蓝色的为可达对象,红色的为不可达对象

image-20230817215633696

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不 同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。譬如后文将会提到的分代收集 和局部回收(Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生 代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不 可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引 用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确 性。

  • 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
  • 这点也是导致Gc进行时必须”stop The world”的一个重要原因。>即使是号称(几乎)不会发生停顿的cMS 收集器中,枚举根节点时也是必须要停顿的。
对象的Finalization机制
  • Java语言提供了对象终止 (finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。

  • 当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法。

  • finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。

  • 永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用。理由包括下面三点:

    • 在finalize ()时可能会导致对象复活。
    • finalize ()方法的执行时间是没有保障的,它完全由cc线程决定,极端情况下,若不发生Gc,则finalize ()方法将没有执行机会。
    • 一个糟糕的finalize ()会严重影响Gc的性能。
  • 从功能上来说,finalize ()方法与c++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()万法在本质上个同于C++中的们构函数。

  • 由于finalize ( )方法的存在,虚拟机中的对象一般处于三种可能的状态。

对象的三种状态

如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:

  • 可触及的:从根节点开始,可以到达这个对象。

  • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize ()中复活。

  • 不可触及的:对象的finalize ()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。

    以上3种状态中,是由于finalize()方法的存在,进行的区分。只有在对象不可触乃时十可以被回收

垃圾标记的具体过程内部

  • 判定一个对象objA是否可回收,至少要经历两次标记过程:
  1. 如果对象objA到 GC Roots没有引用链,则进行第一次标记。
  2. 进行筛选,断此对象是否有必要执行finalize ()方法
    1. 如果对象objA没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。
    2. 如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize ()方法执行。
    3. finalize()方法是对象逃脱死亡的最后机会,稍后cc会对F-Queue队列中的对象进行 第二次标记。如果objA在finalize ()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次。

垃圾清除

当成功区分出内存中存活对象和死亡对象后,cC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。
目前在JVM中比较常见的三种垃圾收集算法是标记―清除算法( Marsweep )、复制算法( copying )、标记–压缩算法( Mark-Compact ) 。

标记-清除算法

背景:
标记–清除算法( Mark-Sweep )是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并并应用于Lisp语言。

执行过程:

当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。

  • 标记:collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。
  • ·清除:collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。

缺点

  • 效率不算高

  • 在进行Gc的时候,需要停止整个应用程序,导致用户体验差

  • 这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表

    何为清除?

这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。

复制算法

核心思想:
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。 前面的youngGC使用的就是这个算法,相当于S0,S1的垃圾清除算法

优点:

  • 没有标记和清除过程,实现简单,运行高效
  • 复制过去以后保证空间的连续性,不会出现“碎片”问题。

缺点:

  • ·此算法的缺点也是很明显的,就是需要两倍的内存空间。

  • 对于cl这种分拆成为大量region的Gc,复制而不是移动,意味着Gc需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小

特别的:

  • 如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大或者说非常低才行。

应用场景:

在新生代,对常规应用的垃圾回收,一次通常可以回收70%-99%的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。Eden区中的对象一般都是朝生夕死的,所以效率会更高,使用复制算法,不需要复制如此多的对象

image-20230825132614530

标记压缩算法
  • 复制算法的高 效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
  • 标记―清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生纳存碎片,所以JVM的设计者需要在此基础之上进行改进。标记-压缩(Mark - Compact)算法由此诞生。
  • 1970 年前后,G.L. steele ,c. J.Chene和D.s. wise 等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。

image-20230828195147181

执行过程:

  • 第一阶段和标记-清除算法一样,从根节点开始标记所有被引用对象
  • 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
  • 之后,清理边界外所有的空间。

标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。

二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

优点:

  • 消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,AVM只需要持有一个内存的起始地址即可。
  • ·消除了复制算法当中,内存减半的高额代价。

缺点:

  • ·从效率上来说,标记-整理算法要低于复制算法。
  • ·移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
  • ·移动过程中,需要全程暂停用户应用程序。即:STW
分代收集算法
  • 前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都己独特的优势和特点。分代收集算法应运而生。
  • 分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
  • 在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的session对象、线程、Socket姓按,及尖N豕跟业力直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:string对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

目前几乎所有的cC都是采用分代收集(Generational Collecting)算法执行垃圾回收的。
在Hotspot中,基于分代的概念,cc所使用的内存回收算法必须结合年轻代和老年代各自的特点。

  • ·年轻代(Young Gen)

    • 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。

    • 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。·老年代(Tenured Gen)

    • 老年代

  • 特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。

  • 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。

    • Mark阶段的开销与存活对象的数量成正比。
    • sweep阶段的开销与所管理区域的大小成正相关。
    • compact阶段的开销与存活对象的数据成正比。

以HotSpot中的CMS回收器为例,cMS是基于Mark-Sweep实现的,对于对象的回收效率很高。而对于碎片问题,cMS采用基于Mark-Compact算法的serial old回收器作为补偿措施:当内存回收不佳(碎片导致的concurrent Mode Failure时),将采用serial old执行Full Gc以达到对老年代内存的整理。

分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。

增量收集算法

上述现有的算法,在垃圾回收过程中,应用软件将处于一种stop the world的状态。在stop the world 状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时立圾收集算法的研究直接导致了增量收集(Incremental collecting)算法的诞生。

基本思想

如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成
总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。

缺点:
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。

分区算法
  • 一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
  • 分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。
  • 每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

垃圾回收的相关概念

System.gc()的理解

在默认情况下,通过system.gc()或者Runtime. getRuntime ( ).gc ()的调用,会显式触发Full Gc,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。
然而system.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用。
JVM实现者可以通过system.gc()调用来决定JVM的cc行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用system.gc ( )。

内存溢出

  • 内存溢出相对干内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。
  • 由于Gc一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现ooM的情况。
  • 大多数情况下,GC会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的Full Gc操作,这时候会回收大量的内存,供应用程序继续使用。
  • javadoc中对outofMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。

什么时候会出现内存溢出

·首先说没有空闲内存的情况:说明Java虚拟机的堆内存不够。

原因有二:

  • (1) Java虚拟机的堆内存设置不够。比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理比如我们要处理比较可观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过参数-Xms.-xmx来调整。

  • (2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)对于老版本的oracle JDK,因为永久代的大小是有限的,并且JVM对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现outofMemoryError也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致oOM问题。对应的异常信息,会标记出来和永久代相关:“java.lang.outOfMemoryError: PermGen space”。

    • 随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的ooM有所改观,出现OOM,异常信息则变了:“java.lang.OutofMemoryError: Metaspace”。直接内存不足,也会导致OOM。

内存泄漏

  • 也称作“存储渗漏”。严格来说,只有对象不会 再被程序用到了,但是Gc又不能回收他们的情况,才叫内存泄漏。
  • 但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致oOM,也可以叫做宽泛意义上的“内存泄漏”。
  • 尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现outOfMemory异常,导致程序崩溃。
  • 注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。

举例:
1、单例模式
单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
2、一些提供close的资源未关闭导致内存泄漏
数据库连接( dataSourse.getConnection( )),网络连接(socket)和io连接必须手动close,否则是不能被回收的。

Stop The World

  • stop-the-world ,简称sTw,指的是cc事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STw。

    • 可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。
      • 分析工作必须在一个能确保一致性的快照中进行
      • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
      • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
  • 被sTw中断的应用程序线程会在完成Gc之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少STw的发生。

  • STw事件和采用哪款Gc无关,所有的Gc都有这个事件。

  • 哪怕是G1也不能完全避免stop-the-world 情况发生,只能说垃圾回业器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。

  • STw是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。

  • 开发中不要用system.gc ();会导致stop-the-world的发生。

程序的并发与并行

  • 在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。

  • 并发不是真正意义上的“同时进行”,只是cPU把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于CPU处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。

  • 当系统有一个以上CPU时,当一个cPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行(Parallel)。

  • 其实决定并行的因素不是cPU的数量,而是CPU的核心数量,比如一个cPU多个核也可以并行。

  • 适合科学计算,后台处理等弱交互场景

垃圾回收的并发与并行

并发和并行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。如ParNew、Parallel scavenge、Parallel old;
  • 串行(Serial)
    • 相较于并行的概念,单线程执行。
    • 如果内存不够,则程序暂停,启动JVM垃圾回收器进行垃圾回收。回收完,再启动程序的线程。

image-20230829101408952

安全点与安全区域

安全点

程序执行时并非在所有地方都能停顿下来开始Gc,只有在特定的位置才能停顿下来开始Gc,这些位置称为“安全点(Safepoint) ”。
Safe Point的选择很重要,如果太少可能导致Gc等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。

如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?

  • 抢先式中断:(目前没有虚拟机采用了)

    • 首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
  • 主动式中断:

    • 设置一个中断标志,各个线程运行到safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。
  • safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的 Safepoint 。但是,程序“不执行”的时候呢?例如线程处于sleep 状态或Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

  • 安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始Gc都是安全的。我们也可以把 safe Region看做是被扩展了的safepoint。

1、当线程运行到safe Region的代码时,首先标识已经进入了Safe Region,如果这段时间内发生Gc,JVM会忽略标识为Safe Region状态的线程;
2、当线程即将离开safe Region时,会检查JVM是否已经完成Gc,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开safe Region的信号为止;

引用

  • 我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。

  • 【既偏门又非常高频的面试题】强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么?

  • 在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

  • 除强引用外,其他3种引用均可以在java.lang.ref包中找到它们的身影。如下图,显示了这3种引用类型对应的类,开发人员可以在应用程序中直接使用它们。

  • Reference子类中只有终结器引用是包内可见的,其他3种引用类型均为public,可以在应用程序中直接使用

  • 强引用(StrongReference):最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“object obj=new object()”这种引用关系。无论任何情况下,只要强引用关系还存在,[垃圾收集器就永远不会回收掉被引用的对象。

  • 软引用(SoftReference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

  • 弱引用(weakReference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。

  • 虚引用(PhantomReference) :一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

强引用
  • 在Java程序中,最常见的引用类型是强引用(普通系统99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型。
  • 当在Java语言中使用new操作符创建一个新的对象,并将其赋值给一个变量的时候,这个变量就成为指向该对象的一个强引用。
  • 强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象
  • 对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。
  • 相对的,软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在定条件下,都是可以被回收的。所以,强引用是造成Java内存泄漏的主要原因之一。
软引用
  • 软引用是用来描述 一些还有用,但非必需的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  • 软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
  • 垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue) 。
  • 类似弱引用,只不过Java虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理。
1
2
3
4
Object o=new Object(); //声明强引用
SoftReference<Object> softRef=new SoftReference<Object>(o);
o=null; //销毁强引用
System.out.println(softRef.get());
弱引用
  • 弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。
  • 但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。
  • 弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。
  • 软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。
虚引用
  • 也称为“幽灵引用”或者“么是引用”,是所 有引用类型中最弱的一个。
  • 一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。
  • 它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的get()方法取得对象时,总是null。
  • 为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。

垃圾回收器

评估GC的性能指标

  • 吞吐量:运行用户代码的时间占总运行时间的比例

    • (总运行时间:程序的运行时间+内存回收的时间)
  • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。

  • 暂停时间:执行垃圾收集时I程序的工作线程被暂停的时间。

  • 收集频率:相对于应用程序的执行,收集操作发生的频率。

  • 内存占用:Java堆区所占的内存大小。

  • 快速:一个对象从诞生到被回收所经历的时间。

吞吐量与暂停时间
  • 高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快。

  • 低暂停时间(低延迟)较好因为从最终用户的角度来看不管是Gc还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序。

  • 不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。

    • 因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致Gc需要更长的暂停时间来执行内存回收。
    • 相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。
  • 垃圾收集器的组合方式

image-20230829113954269

  • 串行回收器:Serial、Serial Old
  • 并发回收器: ParNew、Parallel Scavenge、Parallel Old
  • 并发回收器:CMS、G1

Serial垃圾回收器:串行回收

  • serial收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3之前回收新生代唯一的选择。
  • serial收集器作为HotSpot中client模式下的默认新生代垃圾收集器。serial收集器采用复制算法、串行回收和”stop-the-world”机制的方式执行内存回收。
  • 除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的serial old收集器。serial old收集器同样也采用了串行回收和”stop the world”机制,只不过内存回收算法使用的是标记-压缩算法。
  • Serial old是运行在client模式下默认的老年代的垃圾回收器
  • serial old在server模式下主要有两个用途:①与新生代的Parallel scavenge配合使用②作为老年代cMS收集器的后备垃圾收集方案

d

这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The world)。

  • 优势:简单而高效(与其他收集器的单线程比),对于限定单个CPU 的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

    • 运行在client模式下的虚拟机是个不错的选择。
  • 在用户的桌面应用场景中,可用内存一般不大(几十MB至一两百MB),可以在较短时间内完成垃圾收集(几十ms至一百多ms),只要不频繁发生使用串行回收器是可以接受的。

  • 在HotSpot虚拟机中,使用-XX:+UseSerialGc参数可以指定年轻代和老年代都使用串行收集器。

    • 等价于新生代用serial Gc,且老年代用serial old Gc

各种垃圾回收器的适用场景

HotSpot有这么多的垃圾回收器,那么如果有人问,serial GC、
Parallel GC、Concurrent Mark Sweep cc这三个Gc有什么不同呢?请记住以下口令:

  • 如果你想要最小化地使用内存和并行开销,请选serial GC;
  • 如果你想要最大化应用程序的吞吐量,请选Parallel GC;
  • 如果你想要最小化GC的中断或停顿时间,请选CMs GC。

G1回收器——区域划分代式

既然我们已经有了前面几个强大的Gc,为什么还要发布Garbage rirst (c1)Gc?

原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有Gc就不能保证应用程序正常进行,而经常造成STw的Gc又跟不上实际的需求,所以才会不断地尝试对Gc进行优化。G1 (Garbage-First)垃圾回收器是在Java7 update 4之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。

官方给c1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。

为什么名字叫做Garbage First (G1)呢?

  • 因为G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的Region来表示Eden、幸存者o区,幸存者1区,老年代等。
  • G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1 跟踪各个 Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。
  • 由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给G1一个名字:垃圾优先(Garbage First) 。

G1垃圾回收器的优点(优势)

与其他 cc收集器相比,G1使用了全新的分区算法,其特点如下所示:

并行与并发

  • 并行性: G1在回收期间,可以有多个cc线程同时工作,有效利用多核计算能力。此时用户线程sTw
  • 并发性: G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况

分代收集

  • 从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和survivor区。
  • 但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
  • 将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代;

空间整合

  • CMS:“标记-清除”算法、内存碎片、若干次Gc后进行一次碎片整理
  • G1将内存划分为一个个的region。内存的回收是以region作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次Gc。尤其是当Java堆非常大的时候,G1的优势更加明显。

可预测的停顿时间模型(即:软实时soft real-time)

这是G1 相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

  • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
  • G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1 收集器在有限的时间内可以获取尽可能高的收集效率。
  • 相比于cMs Gc,G1未必能做到cMs在最好情况下的延时停顿,但是最差情况要好很多。

G1垃圾收集器的缺点

  • 相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(overload)都要比CMS要高。
  • 从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。

G1的使用场景

  • 面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)

  • 最主要的应用是需要低cc延迟,并具有大堆的应用程序提供解决方案;

  • 如:在堆大少’全部的Region的增量式清理来保证母oe清理一部分而不是全部的Region的增量式清理来保证每次Gc停顿时间不会过长)。

  • 用来替换掉JDK1.5中的CMS收集器:在下面的情况时,使用G1可能比cMs好:

    • 超过50%的Java堆被活动数据占用

      ;对象分配频率或年代提升频率变化很大;

    • Gc停顿时间过长(长于0.5至1秒)。

  • HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行Gc的多线程操作,而G1 GC可以采用应用线程承担后台运行的Gc工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

分区region
  • 使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB,2MB,4MB,8MB,16MB,32MB。可以通过-Xx:G1HeapRegionsize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。
  • 虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。

image-20230904150940010

  • 一个region 有可能属于Eden,survivor或者 old/Tenured 内存区域。但是一个region只可能属于一个角色。图中的E表示该region属于Eden内存区域,s表示属于survivor内存区域,o表示属于old内存区域。图中空白的表示未使用的内存空间。
  • G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的 H块。主要用于存储大对象,如果超过1.5个region,就放到H。

设置H的原因:

对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full Gc。G1的大多数行为都把H区作为老年代的一部分来看待。

G1回收期垃圾回收过程

G1 GC垃圾回收过程主要包括如下三个环节:

  • 年轻代GC(Young GC)
  • 老年代并发标记过程(Concurrent marking)
  • 混合回收(Mixed GC)
  • (如果需要,单线程、独占式、高强度的Full GC还是继续存在的。他针对GC评估失败提供了一种失败保护机制,即强力回收 )

image-20230904155625466

顺时针,young gc -> young gc + concurrent mark-> Mixed cc顺序,进行垃圾回收。

  • 应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行独占式收集器。在年轻代回收期,G1 Gc暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到survivor区间或者老年区间,也有可能是两个区间都会涉及。
  • 当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。
  • 标记完成马上开始混合回收过程。对于一个混合回收期,G1 Gc从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。
  • 举个例子:一个web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。

  • 一个对象被不同区域引用的问题
  • 一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?
    • 在其他的分代收集器,也存在这样的问题(而G1更突出)
    • 回收新生代也不得不同时扫描老年代?
    • 这样的话会降低Minor Gc的效率;

解决方法:

  • 无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:每个Region都有一个对应的Remembered set;
  • 每次Reference类型数据写操作时,都会产生一个write Barrier暂时中断操作;
  • 然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);
  • 如果不同,通过cardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered set中;
  • 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。

回收过程一:年轻代的GC

  • JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。
  • 年轻代垃圾回收只会回收Eden区和survivor区。
  • YGc时,首先G1停止应用程序的执行(stop-The-world) ,G1创建回收集(collection set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。

然后开始如下回收过程:

  • 第一阶段,扫描根。
    • 根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。
  • 第二阶段,更新RSet.
    • 处理dirty card queue(见备注)中的card,更新RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用。
  • 第三阶段,处理RSet。
    • 识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
  • 第四阶段,复制对象。
    • 此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到survivor区中空的内存分段,survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到o1d区中空的内存分段。如果survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
  • 第五阶段,处理引用。
    • 处理Soft,weak,Phantom,Final,JNI weak 等引用。最终Eden空间的数据为空Gc停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
垃圾回收器的总结

image-20230908110220503

如何选择垃圾回收器

Java垃圾收集器的配置对于JVM优化来说是一个很重要的选择,选择合适的垃圾收集器可以让JVM的性能有一个很大的提升。
怎么选择垃圾收集器?
.优先调整堆的大小让JVM自适应完成。.如果内存小于100M,使用串行收集器
.如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
.如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择
.如果是多cPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,互联网应用),使用并发收集器
官方推荐G1,性能高。现在互联网的项目,基本都是使用G1。

类加载过程

在Java中数据类型分为基本数据类型和引用数据类型。基本数据类型由虚拟机预先定义,引用数据类型则需要进行类的加载
按照Java虚拟机规范,从class文件到加载到内存中的类,到类卸载出内存为止,它的整个生命周期包括如下7个阶段:

image-20230908161019916

加载的理解:

所谓加载,简而言之就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java类的原型——类模板对象。所谓类模板对象,其实就是Java类在JVM内存中的一个快照,JVM将从字节码文件中解析出的常量池、类字段、类方法等信息存储到类模板中,这样JVW在运行期便能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用。
反射的机制即基于这一基础。如果JVM没有将Java类的声明信息存储起来,则JVM在运行期也无法反射。

加载完成的操作

  • 加载阶段,简言之,查找并加载类的二进制数据,生成Class的实例。在加载类时,
  • Java虚拟机必须完成以下3件事情:
  • 通过类的全名,获取类的二进制数据流。
  • 解析类的二进制数据流为方法区内的数据结构(Java类模型)
  • 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口

二进制流的获取方式

对于类的二进制数据流,虚拟机可以通过多种途径产生或获得。(只要所读取的字节码符合JVM规范即可)

  • 虚拟机可能通过文件系统读入一个class后缀的文件(最常见)
  • 读入jar、zip等归档数据包,提取类文件。
  • 事先存放在数据库中的类的二进制数据
  • 使用类似于HTTP之类的协议通过网络进行加载
  • 在运行时生成一段class的二进制信息等

类模型与Class实例位置

1.类模型的位置
加载的类在JVM中创建相应的类结构,类结构会存储在方法区(JDK1.8之前:永久代;JDK1.8及之后:

2.class实例的位置
类将.class文件加载至元空间后,会在堆中创建一个Java.lang.Class对象,用来封装类位于方法区内的数据结构,该Class对象是在加载类的过程中创建的,每个类都对应有一个class类型的对象。

image-20230908164037195

外部可以通过访问代表Order类的Class对象来获取Order类的数据结构

数组类的加载

创建数组类的情况稍微有些特殊,因为数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载器去创建。创建数组类(下述简称A)的过程:

  1. 如果数组的元素类型是引用类型,那么就遵循定义的加载过程递归加载和创建数组A的元素类型;
  2. JVM使用指定的元素类型和数组维度来创建新的数组类。

如果数组的元素类型是引用类型,数组类的可访问性就由元素类型的可访问性决定。否则数组类的可访问性将被缺省定义为public。

int[] arr

String[] arr

object[] arr

链接过程

验证阶段

当类加载到系统后,就开始链接操作,验证是链接操作的第一步。

它的目的是保证加载的字节码是合法、合理并符合规范的。

验证的步骤比较复杂,实际要验证的项目也很繁多,大体上Java虚拟机需要做以下检查,如图所示。

image-20230908170127871

  • 格式验证:是否以魔数OxCAFEBABE开头,主版本和副版本号是否在当前Java虚拟机的支持范围内,数据中每一个项是否都拥有正确的长度等。

  • Java虚拟机会进行字节码的语义检查,但凡在语义上不符合规范的,虚拟机也不会给予验证通过。比如:·是否所有的类都有父类的存在(在Java里,除了object外,其他类都应该有父类)

    • 是否一些被定义为fihal的方法或者类被重写或继承了
    • 非抽象类是否实现了所有抽象方法或者接口方法
    • 是否存在不兼容的方法(比如方法的签名除了返回值不同,其他都一样,这种方法会让虚拟机无从下手调度; abstract情况下的方法,就不能是final的了)
  • Java虚拟机还会进行字节码验证,字节码验证也是验证过程中最为复杂的一个过程。它试图通过对字节码流的分析,判断字节码是否可以被正确地执行。比如:

    • 在字节码的执行过程中,是否会跳转到一条不存在的指令·函数的调用是否传递了正确类型的参数
    • 变量的赋值是不是给了正确的数据类型等
    • 栈映射帧(StackWapTable)就是在这个阶段,用于检测在特定的字节码处,其局部变量表和操作数栈是否有着正确的数据类型。但遗憾的是,100%准确地判断一段字节码是否可以被安全执行是无法实现的,因此,该过程只是尽可能地检查出可以预知的明显的问题。如果在这个阶段无法通过检查,虚拟机也不会正确装载这个类。但是,如果通过了这个阶段的检查,也不能说明这个类是完全没有问题的。

在前面3次检查中,已经排除了文件格式错误、语义错误以及字节码的不正确性。但是依然不能确保类是没有问题的。

  • 校验器还将进行符号引用的验证。Class文件在其常量池会通过字符串记录自己将要使用的其他类或者方法。因此,在验证阶段,虚拟机就会检查这些类或者方法确实是存在的,并且当前类有权限访问这些数据,如果一个需要使用类无法在系统中找到,则会抛出NoClassDefFoundError,如果一个方法无法被找到,则会抛出NoSuchMethodError。
    • 此阶段在解析环节才会执行。

准备阶段

准备阶段(Preparation),简言之,为类的静态变量分配内存,并将其初始化为默认值。
当一个类验证通过时,虚拟机就会进入准备阶段。在这个阶段,虚拟机就会为这个类分配相应的内存空间,并设置默认初始值。Java虚拟机为各类型变量默认的初始值如表所示。

image-20230908172800591

注意: Java并不支持boolean类型,对于boolean类型,内部实现是int,由于int的默认值是e ,故对应的,boolean的默认值就是false。

  • 这里不包含基本数据类型的乒段用static final修饰的情况,因为final在编译的时候就会分配了,准备阶段会显式赋值。
  • 注意这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中
  • 在这个阶段并不会像初始化阶段中那样会有初始化或者代码被执行。

解析阶段

符号引用就是一些字面量的引用,和虚拟机的内部数据结构和和内存布局无关。比较容易理解的就是在Class类文件中,通过常量池进行了大量的符号引用。但是在程序实际运行时,只有符号引用是不够的,比如当如下println()方法被调用时,系统需要明确知道该方法的位置。
举例:输出操作System.out. println()对应的字节码:invokevirtual #24 <java/io/PrintStream.println>

以方法为例,Java虚拟机为每个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法。通过解析操作,符号引用就可以转变为目标方法在类中方法表中的位置,从而使得方法被成功调用。

小结

所谓解析就是将符号引用转为直接引用,也就是得到类、字段、方法在内存中的指针或者偏移量。因此可以说,如果直接引用存在,那么可以肯定系统中存在该类、方法或者字段。但只存在符号引用,不能确定系统中一定存在该结构。

 不过Java虚拟机规范并没有明确要求解析阶段一定要按照顺序执行。在HotSpot VW中,加载、验证、准备和初始化会按照顺序有条不紊地执行,但链接阶段中的解析操作往往会伴随着JVM在执行完初始化之后再执行。

初始化

初始化阶段,简言之,为类的静态变量赋予正确的初始值。

具体描述

类的初始化是类装载的最后一个阶段。如果前面的步骤都没有问题,那么表示类可以顺利装载到系统中。此时,类才会开始执行Java字节码。(即:到了初始化阶段,才真正开始执行类中定义的Java 程序代码。)
初始化阶段的重要工作是执行类的初始化方法:()方法。

  • 该方法仅能由Java编译器生成并由JVM调用,程序开发者无法自定义一个同名的方法,更无法直接在Java程序中调用该方法,虽然该方法也是由字节码指令所组成。
  • ·它是由类静态成员的赋值语句以及static语句块合并产生的。

在加载一个类之前,虚拟机总是会试图加载该类的父类,因此父类的总是在子类之前被调用。也就是说,父类的static块优先级高于子类。

口诀:由父及子,静态先行。

哪些场景下,java编译器就不会生成<clinit>()方法

  • 场景1:对应非静态的字段,不管是否进行了显式赋值,都不会生成()方法
  • 场景2:静态的字段,没有显式的赋值,不会生成<cLinit>()方法
  • 场景3:比如对于声明为static final的基本数据类型的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法

使用static+final修饰字段显示复制的操作,到底实在哪个阶段进行的赋值

情况1:在链接阶段的准备环节赋值

情况2:在初始化的<clinit>()中赋值

结论:

在链接阶段的准备环节赋值的情况:

  1. 给予基本数据类型来说,如果使用static final去修饰,则显示赋值通常是在链接阶段的准备环节进行
  2. 对于String来说,如果使用字面量的方式赋值,使用static final修饰的话,则显示赋值通常是在链接阶段的准备环节进行的

总的来说,如果使用static final修饰的的成员变量在进行赋值时,需要new 对象的一般都会在clinit进行执行,如果给基本数据变量赋值的是字面量则在链接准备阶段进行赋值,如果是一个需要new 对象的操作例如: private satic final int a=new Random.nextInt(20);这个操作会在clinit执行

类的主动使用和被动使用

Java程序对类的使用分为两种:主动使用和被动使用。

一、主动使用
Class只有在必须要首次使用的时候才会被装载,Java虚拟机不会无条件地装载Class类型。Java虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。这里指的“使用” 7
1.当创建一个类的实例时,比如使用new关键字,或者通过反射、克隆、反序列化。
2.当调用类的静态方法时,即当使用了字节码invokestatic指令。
3.当使用类、接口的静态字段时(final修饰特殊考虑),比如,使用getstatic或者putstatic指令(对应访问变量、赋值变量操作)
4.当使用java.lang.reflect包中的方法反射类的方法时。比如: Class.forName( “com.atguigu.java.Test”)
5.当初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
6.如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化。

7.当虚拟机启动时,用户需要指定一个要执行的主类((包含main()方法的那个类),虚拟机会先初始化这个主类。
8.当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类。(涉及解析REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄对应的类)

二、被动使用
除了以上的情况属于主动使用,其他的情况均属于被动使用。被动使用不会引起类的初始化。
也就是说:并不是在代码中出现的类,就一定会被加载或者初始化.I如果不符合主动使用的条件,类就不会初始化。
1.当访问一个静态字段时,只有真正声明这个字段的类才会被初始化。
·当通过子类引用父类的静态变量,不会导致子类初始化
2.通过数组定义类引用,不会触发此类的初始化
3.引用常量不会触发此类或接口的初始化。因为常量在链接阶段就已经被显式赋值了。
4.调用ClassLoader类的loadClass()方法加载一个类,并不是对类的主动使用,不会导致类的初始化。

类的卸载

一、类、类的加载器、类的实例之间的引用关系
在类加载器的内部实现中,用一个Java集合来存放所加载类的引用。另一方面,一个Class对象总是会引用它的类加载器,调用Class对象的getClassLoader()方法,就能获得它的类加载器。由此可见,代表某个类的Class实例与其类的加载器之间为双向关联关系。
一个类的实例总是引用代表这个类的Class对象。在Object类中定义了getClass()方法,这个方法返回代表对象所属类的Class对象的引用。此外,所有的Java类都有一个静态属性class,它引用代表这个类的Class对象。

二、类的生命周期

当Sample类被加载、链接和初始化后,它的生命周期就开始了。当代表Sample类的Class对象不再被引用,即不可触及时,Class对象就会结束生命周期,Sample类在方法区内的数据也会被卸载,从而结束Sample类的生命周期。一个类何时结束生命周期,取决于代表它的Class对象何时结束生命周期。

image-20230911122511071

方法区的垃圾回收

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量不再使用的类型

HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。

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

  • 该类所有的实例都已经被回收。也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收。这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、3SP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。

如果程序运行过程中,将上图左侧三个引用变量都置为null,此时Sample对象结束生命周期,NyClassLoader对象结束生命周期,代表Sample类的Class对象也结束生命周期,Sample类在方法区内的二进制数据被卸载。
当再次有需要时,会检查Sample类的Class对象是否存在,如果存在会直接使用,不再重新加载;如果不存在Sample类会被重新加载,在Java虚拟机的堆区会生成一个新的代表Sample类的Class实例(可以通过哈希码查看是否是同一个实例)。

类的卸载

(1)启动类加载器加载的类型在整个运行期间是不可能被卸载的(jvm和jls规范)
(2)被系统类加载器和扩展类加载器加载的类型在运行期间不太可能被卸载,因为系统类加载器实例或者扩展类的实例基本上在整个运行期间总能直接或者间接的访问的到,其达到unreachable的可能性极小。
(3)被开发者自定义的类加载器实例加载的类型只有在很简单的上下文环境中才能被卸载,而且一般还要借助于强制调用虚拟机的垃圾收集功能才可以做到。可以预想,稍微复杂点的应用场景中(比如:很多时候用户在开发自定义类加载器实例的时候采用缓存的策略以提高系统性能),被加载的类型在运行期间也是几乎不太可能被卸载的(至少卸载的时间是不确定的)。
综合以上三点,一个己经加载的类型被卸载的几率很小至少被卸载的时间是不确定的。同时我们可以看的出来,开发者在开发代码时候,不应该对虚拟机的类型卸载做任何假设的前提下,来实现系统中的特定功能。

类的加载器

类加载器是JVM执行类加载机制的前提。

ClassLoader的作用

ClassLoader是Java的核心组件,所有的Class都是由ClassLoader进行加载的,ClassLoader负责通过各种方式将Class信息的二进制数据流读入JVM内部,转换为一个与目标类对应的java.lang.Class对象实例。然后交给Java虚拟机进行链接、初始化等操作。因此,ClassLoader在整个装载阶段,只能影响到类的加载,而无法通过ClassLoader去改变类的链接和初始化行为。至于它是否可以运行,则由Execution Engine决定。

image-20230916154818090

类加载器最早出现在Java1.0版本中,那个时候只是单纯地为了满足Java Applet应用而被研发出来。但如今类加载器却在oSGi、字节码加解密领域(大放异彩。这主要归功于Java虚拟机的设计者们当初在设计类加载器的时候,并没有考虑将它绑定在VM内部,这样做的好处就是能够更加灵活和动态地执行类加载操作。

类的加载分类:

显式加载vs 隐式加载

  • class文件的显式加载与隐式加载的方式是指JVM加载class文件到内存的方式。
  • 显式加载指的是在代码中通过调用ClassLoader加载class对象,如直接使用class.forName(name)或this.getClass( ).getClassLoader().loadClass()加载class对象。
  • 隐式加载则是不直接在代码中调用classLoader的方法加载class对象,而是通过虚拟机自动加载到内存中,如在加载某个类的class文件时,该类的class文件中引用了另外一个类的对象,此时额外引用的类将通过JVM自动加载到内存中。

  • 般情况下,Java开发人员并不需要在程序中显式地使用类加载器,但是了解类加载器的加载机制却显得至关重要。从以下几个方面说:
  • 避免在开发中遇到java.lang .ClassNotFoundException异常或java.lang.NoClassDefFoundError异常时,手足无措。只有了解类加载器的加载机制才能够在出现异常的时候快速地根据错误异常日志定位问题和解决问题
  • 需要支持类的动态加载或需要对编译后的字节码文件进行加解密操作时,就需要与类加载器打交道了。
  • ·开发人员可以在程序中编写自定义类加载器来重新定义类的加载规则,以便实现一些自定义的处理逻辑.

命名空间

1.何为类的唯一性?
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在Java虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义否则,即使这两个类源自同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。
2.命名空间

  • ·每个类加载器都有自己的命名空间,命名空间由该加载器及所有的父加载器所加载的类组成
  • 在同一命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类
  • 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类
  • 在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。

类加载机制的基本特征

  • 双亲委派模型,但不是所有类加载都遵守这个模型,有的时候,启动类加载器所加载的类型,是可能要加载用户代码的,比如JDK内部的ServiceProvider/ServiceLoader机制,用户可以在标准API框架上,提供自己的实现,JDK也需要提供些默认的参考实现。例如,Java中NDI、JDBC、文件系统、Cipher等很多方面,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器。
  • 可见性,子类加载器可以访问父加载器加载的类型,但是反过来是不允许的。不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。
  • 单一性,由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器“邻居”间,同一类型仍然可以被加载多次,因为互相并不可见。

类加载器的分类

  • JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined classLoader) 。

  • 从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。无论类加载器的类型如何划分,在程序中我们最常见的类加载器结构主要是如下情况:

image-20230916165326419

  • 除了顶层的启动类加载器外,其余的类加载器都应当有自己的“父类”加载器。
  • 不同类加载器看似是继承(Inheritance)关系,实际上是包含关系。在下层加载器中,包含着上层加载器的引用

引导类加载器

启动类加载器(引导类加载器,Bootstrap classLoader)

  • ·这个类加载使用C/C++语言实现的,嵌套在JVM内部。
  • 它用来加载Java的核心库(JAVA_HONE/jre/lib/rt.jar或sun.boot.class.path路径下的内容)。用于提供JVM自身需要的类。
  • 并不继承自java.lang.classLoader,没有父加载器。
  • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。

扩展类加载器

  • Java语言编写,由sun.misc.Launcher$ExtClassLoader实现。继承于classLoader类
  • 父类加载器为启动类加载器
  • 从java.ext.dins系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。

image-20230916172053054

系统类加载器

  • . java语言编写,由sun.misc.Launcher$AppClassLoader实现·继承于ClassLoader类
  • 父类加载器为扩展类加载器
  • 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库·应用程序中的类加载器默认是系统类加载器。
  • 是用户自定义类加载器的默认父加载器
  • 通过classLoader的getSystemClassLoader()方法可以获取到该类加载器

自定义类加载器

  • 在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的。在必要时,我们还可以自定义类加载器,来定制类的加载方式。
  • 体现Java语言强大生命力和巨大魅力的关键因素之一便是,Java开发斯可以自定义类加载器来实现类库的动态加载,加载源可以是本地的JAR包,也可以是网络上的远程资源。
  • 通过类加载器可以实现非常绝妙的插件机制,这方面的实际应用案例举不胜举。例如,著名的oSGI组件框架,再如Eclipse的插件机制。类加载器为应用程序提供了一种动态增加新功能的机制,这种机制无须重新打包发布应用程序就能实现。
  • 同时,自定义加载器能够实现应用隔离,例如Tomcat,Spring等中间件和组件框架都在内部实现了自定义的加载器,并通过自定义加载器隔离不同的组件模块。这种机制比C/C++程序要好太多,想不修改C/C++程序就能为其新增功能,几乎是不可能的,仅仅一个兼容性便能阻挡住所有美好的设想。
  • 自定义类加载器通常需要继承于classLoader。

获取类加载器的方法

获得当前线程的ClassLoader

1
clazz.getClassLoader() 

获得当前线程上下文的ClassLoader

1
Thread.currentThread().getContextClassLoader

获得系统的ClassLoader

1
ClassLoader.getSystemClassLoader()

站在程序的角度看,引导类加载器与另外两种类加载器(系统类加载器和扩展类加载器)并不是同一个层次意义上的加载器,引导类加载器是使用C++语言编写而成的,而另外两种类加载器则是使用Java语言编写而成的。由于引导类加载器压根儿就不是一个Java类,因此在Java程序中只能打印出空值。

数组类的Class对象,不是由类加载器去创建的,而是在Java运行期JVP根据需要自动创建的。对于数组类的类加载器来说,是通过Class.getClassLoader()返回的,与数组当中元素类型的类加载器是一样的;如果数组当中的元素类型是基本数据类型,数组类是没有类加载器的。

ClassLoader与现有的类加载器的关系

image-20230916221535903

双亲委派机制

定义

如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载。

本质

规定了类加载的顺序是:引导类加载器先加载,若加载不到,由扩展类加载器加载,若还加载不到,才会由系统类加载器或自定义的类加载器进行加载。

image-20230917114755846

双亲委派机制的优势

  • 避免类的重复加载,确保一个类的全局唯一性
  • Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子classLoader再加载一次。
  • 保护程序安全,防止核心API被随意篡改

代码支持

双亲委派机制在java.lang.ClassLoader.loadClass(String,boolean)接口中体现。该接口的逻辑如下:

  • 先在当前加载器的缓存中查找有无目标类,如果有,直接返回。
  • 判断当前加载器的父加载器是否为空,如果不为空,则调用parent.loadClass(name,false)接口进行加载。
  • 反之,如果当前加载器的父类加载器为空,则调用findBootstrapClass0rNull(name)接口,让引导类加载器进行加载。
  • 如果通过以上3条路径都没能成功加载,则调用findClass(name)接口进行加载。该接口最终会调用java.lang.classLoader接口的defineClass系列的native接口加载目标Java类,双亲委派的模型就隐藏在这第2和第3步中。

如果在自定义的类加载器中重写java.lang.classLoader.loadClass(String)或java.lang.ClassLoader.loadClass(String, boolean)方法,抹去其中的双亲委派机制,仅保留上面这4步中的第1步与第4步,那么是不是就能够加载核心类库了呢?

这也不行!因为JDK还为核心类库提供了一层保护机制。不管是自定义的类加载器,还是系统类加载器抑或扩展类加载器,最终都必须调用java.lang.ClassLoader.defineClass(String, byte[], int, int,ProtectionDomain)方法,而该方法会执行preDefineClass()接口,该接口中提供了对JDK核心类库的保护。

弊端:

检查类是否加载的委托过程是单向的,这个方式虽然从结构上说比较清晰,使各个classLoader的职责非常明确,但是同时会带来一个问题,即顶层的ClassLoader无法访问底层的ClassLoader所加载的类。

通常情况下,启动类加载器中的类为系统核心练,包括一些重要的系统接口,而在应用类加载器中,为应用类。按照这种模式,应用类访问系统类自然是没有问题,伸是系统类访问应用类就会出现问题。比如在系统类中提供了一个接口,该接口需要在应用类中得以实现,该接口还绑定一个工厂方法,用于创建该接口的实例,而接口和工厂方法都在启动类加载器中。这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题。

结论

由于Java虚拟机规范并没有明确要求类加载器的加载机制一定要使用双亲委派模型,只是建议采用这种方式而己。比如在Tomcat中,类加载器所采用的加载机制就和传统的双亲委派模型有一定区别,当缺省的类加载器接收到一个类的加载任务时,首先会由它自行加载,当它加载失败时,才会将类的加载任务委派给它的超类加载器去执行这同时也是Servlet规范推荐的一种做法。

破坏双亲委派机制

双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK 1.2面世以前的“远古”时代。
由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK1.2之后的
java.lang.ClassLoader中添加一个新的protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在loadClass()中编写代码。上节我们已经分析过loadClass()方法,双亲委派的具体逻辑就实现在这里面,按照loadClass()方法的逻辑,如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。

第二次破坏双亲委派机制:线程上下文类加载器

  • 双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题〈越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?
  • 这并非是不可能出现的事情,一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器来完成加载(在JDK 1.3时加入到rt.jar的),肯定属于Java中很基础的类型了。但JNDI存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口(Service Provider Interface,SPI)的代 码,现在问题来了,启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?(SPI:在Java平台中,通常把核心类rt.jar中提供外部服务、可由应用层自行实现的接口称为SPI)
  • 为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(ThreadContextClassLoader)。这个类加载器可以通过java.lang. Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
  • 有了线程上下文类加载器,程序就可以做一些“舞弊”的事情了。JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、JDBC、JCE、JAXB和BI等。不过,当SPI的服务提供者多于一个的时候,代码就只能根据具体提供者的类型来硬编码判断,为了消除这种极不优雅的实现方式,在JDK 6时,JDK提供了

双亲委派模型的第三次“被破坏”

是由于用户对程序动态性的追求而导致的。如:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等
IBM公司主导的SR-291(即oSGi R4.2)实现模块化热部署的关键是它自定义的类加载器机制的实现每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在oSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构。当收到类加载请求时,oSGi将按照下面的顺序进行类搜索:

  • 1)将以java.*开头的类,委派给父类加载器加载。

  • 2)否则,将委派列表名单内的类,委派给父类加载器加载。

  • 3)否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载。

  • 4)否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。

  • 5)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。

  • 6)否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。

  • 7)否则,类查找失败。

    说明:只有开头两点仍然符合双亲委派模型的原则,其余的类查找都是在平级的类加载器中进行的

这里,我们使用了“被破坏”这个词来形容上述不符合双亲委派模型原则的行为,但这里“被破坏”并不一定是带有贬义的。只要有明确的目的和充分的理由,突破旧有原则无疑是一种创新。

完结撒花