快速上手JVM系列(整合篇)

  • JDK版本:jdk-11.0.7,文中所有代码都在JDK11环境下运行成功
  • 虚拟机版本:Java HotSpot(TM) 64-Bit Server VM 18.9

学习过程中主要参考资料

[1] 深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) - 周志明(2019)

[2] 尚硅谷宋红康JVM全套教程(详解java虚拟机)

[3] Java语言与虚拟机规范Java Language and Virtual Machine Specifications

注:相对于以上资料的完备性,Loki由于个人博客的实用性,也因为当今Java体系已经趋于稳定化,因此在学习整理过程中对于许多非技术性的内容就不多做记录,读者可以根据需要自行查找阅读

目前JDK已经发布了JDK18,JDK17为LTS版本

一、走进Java与JVM

引言:学习JVM的必要性

通常情况下,一个程序员只要了解了必要的Java类库API、Java语法,学习适当的第三方开发框架,就已经基本满足日常开发的需要了。虚拟机会在用户不知不觉中完成对硬件平台的兼容及对内存等资源的管理工作

因此,了解虚拟机的运作并不是普通开发人员必备的,或者说首要学习的知识

然而,凡事都具备两面性。随着Java技术的不断发展,它已被应用于越来越多的领域之中。其中一些领域,如互联网、能源、金融、通信等,对程序的性能、稳定性和扩展性方面会有极高的要求。一段程序很可能在10个人同时使用时完全正常,但是在10000个人同时使用时就会缓慢、死锁甚至崩溃。毫无疑问,要满足10000个人同时使用,需要更高性能的物理硬件,但是在绝大多数情况下,提升硬件性能无法等比例提升程序的运行性能和并发能力,甚至有可能对程序运行状况没有任何改善。这里面有Java虚拟机的原因:为了达到“所有硬件提供一致的虚拟平台”的目的,牺牲了一些硬件相关的性能特性。更重要的是人为原因:如果开发人员不了解虚拟机诸多技术特性的运行原理,就无法写出最适合虚拟机运行和自优化的代码
其实,目前商用的高性能Java虚拟机都提供了相当多的优化参数和调节手段,用于满足应用程序在实际生产环境中对性能和稳定性的要求。如果只是为了入门学习,让程序在自己的机器上正常工作,那么这些特性可以说是可有可无的;但是,如果用于生产开发,尤其是大规模的、企业级的生产开发,就迫切需要开发人员中至少有一部分人对虚拟机的特性及调节方法具有很清晰的认识。所以在Java开发体系中,对架构师、系统调优师、高级程序员等角色的需求一直都非常大

学习虚拟机中各种自动运作特性的原理也成为Java程序员成长路上最终必然会接触到的一课

​ ———摘录自《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》

进一步认识Java

相信各位来阅读JVM之前已经对Java语言有了不浅的理解,所以这里对于Java的安装、环境变量配置、语法特性等基础就不再多做描述

Oracle JDK与Open JDK

背景:

在编写这篇文章时,Oracle JDK已经更新到JKD18,最新的LTS(长期支持)版本为JDK17,市场最多使用的仍然是JDK8(主流的 JDK 8 在2019年01月之后就被宣布停止更新了)

Oracle JDK

Oracle JDK 由 Oracle 维护和开发。它符合 OpenJDK 规范,但不是开源代码。Oracle JDK 在 JVM 响应能力和生产力方面要好得多。由于其对企业客户的重要性,它更注重稳定性

OpenJDK

OpenJDK是开放源代码,由Oracle维护和开发,但允许社区和其他公司参与开发,如Red Hat、Azul Systems、IBM、Apple Inc等。OpenJDK既是一种JDK产品,也是一种规范,任何想要使用OpenJDK创建新变体的公司或组织都必须遵守这些规范

Oracle JDK和OpenJDK的异同点

  • Oracle JDKOpenJDK 更稳定,在JDK11中,我们可以认为OpenJDK和OracleJDK代码实质上已经完全一致的程度
  • OpenJDKOracle JDK的代码几乎相同,但Oracle JDK有更多的类和一些错误修复
  • 二者共同使用Hotspot虚拟机(它采用解释器与编译器并存的架构)
  • 许可协议不同:Oracle JDK 根据二进制代码许可协议获得许可,而 OpenJDK 根据 GPL v2 许可获得许可

如何对二者做出选择?

Oracle JDK的特点是单版本长期支持,如开发企业/商业软件,一般建议选择Oracle JDK,因为它经过了彻底的测试和稳定OpenJDK的特点是更新频繁,实现快速迭代和高效试错,为Oracle JDK LTS版本打下基础

商用收费,学习研究免费。如需在开源基础上开发及问题优化维护或不那么注重稳定性,则可以选择OpenJDK

Java语言的地位

每年都有很多新、旧编程语言的兴起躁动与消失,说明必然有其需求动力所在,譬如互联网之于JavaScript、人工智能之于Python,微服务风潮之于Golang等。大家都清楚不太可能有哪门语言能在每一个领域都尽占优势,Java已是距离这个目标最接近的选项,但若“天下第一”还要百尺竿头更进一步的话,似乎就只能忘掉Java语言本身,踏入无招胜有招的境界

附:TIOBE Index for April 2022(编程语言排行榜,每个月更新,只截取前10名)

image-20220411182014889

Java上层框架与JVM的关系

Java是目前用户最多、使用范围最广的软件开发技术,Java的技术体系主要由支撑Java程序运行的虚拟机、提供各开发领域接口支持的Java类库、Java编程语言及许许多多的第三方Java框架(如Spring、MyBatis等)构成。在国内,有关Java类库API、Java语言语法及第三方框架的技术资料和书籍非常丰富,相比而言,有关Java虚拟机的资料却显得异常贫乏
这种状况很大程度上是由Java开发技术本身的一个重要优点导致的:在虚拟机层面隐藏了底层技术的复杂性以及机器与操作系统的差异性。运行程序的物理机千差万别,而Java虚拟机则在千差万别的物理机上面建立了统一的运行平台,实现了在任意一台Java虚拟机上编译的程序,都能在任何其他Java虚拟机上正常运行。这一极大的优势使得Java应用的开发比传统C/C++应用的开发更高效快捷,程序员可以把主要精力放在具体业务逻辑,而不是放在保障物理硬件的兼容性上

即便如此,Loki认为正如大树扎根一样,对于内功和底层的修炼,才是我们能立足于越来越内卷的环境的根本

  • 数据结构与算法
  • 计算机网络
  • 计算机组成原理
  • 操作系统原理
  • 数据库系统

Java代码的执行流程

image-20220412164615294

初识JVM——跨语言的平台

首先理解JVM的两个特性

  • 跨平台

    通过不同平台指令集、不同机器码下的虚拟机,可以运行同样的字节码文件,达到同样的结果,以此达到虚拟机跨平台的特性

  • 语言无关性

    通过不同的编译器,可以编译出符合统一字节码规范的字节码文件,交由JVM处理,屏蔽了不同编程语言的特性

image-20220411183813926

解释Java为什么是半编译半解释型语言

我们需要格外注意的是 .class->机器码 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 Java 是半编译半解释型语言

虚拟机是什么

虚拟机(Virtual Machine),就是一台虚拟的计算机。它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机。

  • Visual BoxVMware就属于系统虚拟机,它们完全是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台
  • 程序虚拟机的典型代表就是Java虚拟机,它专门为执行单个计算机程序而设计,在Java虚拟机中执行的指令我们称为Java字节码指令

无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机提供的资源中

JVM发展历程

由于OracleJDK/OpenJDK在市场占有率上的绝对优势,它默认的HotSpot虚拟机不可避免地成为我们主要学习的对象,因此Loki的这篇JVM学习博客将以HotSpot虚拟机为学习目标,进行展开学习

(注:下文中JVM默认指Hotspot虚拟机)

来简单看一看JVM虚拟机家族的其他部分成员

  • 虚拟机始祖:Sun Classic/ExactVM

    以今天的视角来看,Sun Classic虚拟机的技术已经相当原始,这款虚拟机的使命也早已终结。但仅凭它“世界上第一款商用Java虚拟机”的头衔,就足够有令历史记住它的理由

  • 武林盟主:HotSpot VM

    它是Sun/OracleJDK和OpenJDK中的默认Java虚拟机,也是目前使用范围最广的Java虚拟机,通过热点代码探测技术在响应时间与执行性能中取得平衡

  • 小家碧玉: Mobile/EmbeddedVM
    Sun/Oracle公司面对移动和嵌入式市场所研发的虚拟机,目前国内市场几乎看不到应用了

  • 专注于服务器端应用:JRockit(已经被Oracle收购,整合到Hotspot中了)

    它可以不太关注程序启动速度,因此JRockit内部不包含解释器实现,全部代码都靠即时编译器编译后执行
    大量的行业基准测试显示,JRockit JVM是世界上最快的JVM

JVM架构模型

Java编译器输入的指令流基本上是一种基于栈的指令集架构(我们学习的Hotspot虚拟机就是这个架构),另外一种指令集架构则是基于寄存器的指令集架构。具体来说:这两种架构之间的区别

基于栈式架构的特点

  • 设计和实现更简单,适用于资源受限的系统

  • 避开了寄存器的分配难题:使用零地址指令方式分配

  • 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现

  • 不需要硬件支持,可移植性更好,更好实现跨平台

基于寄存器架构的特点

  • 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虚拟机

  • 指令集架构则完全依赖硬件,可移植性差

  • 性能优秀和执行更高效

  • 花费更少的指令去完成一项操作

  • 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主

举个栗子:int a = 2 + 3

基于栈的计算流程

iconst_2 //常量2入栈
istore_1
iconst_3 // 常量3入栈
istore_2
iload_1
iload_2
iadd //常量2/3出栈,执行相加
istore_0 // 结果5入栈

基于寄存器的运算流程

mov eax,2 //将eax寄存器的值设为1
add eax,3 //使eax寄存器的值加3

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

初探JVM内存结构

JVM规范指出:JVM的内存空间分为 5 个部分,不同虚拟机的实现有所不同

image-20220411225511956

注:

  1. 方法区和堆线程共享
  2. Java栈、本地方法栈、程序计数器线程私有

JVM的生命周期

虚拟机的启动

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

虚拟机的执行

  • 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序。

  • 程序开始执行时他才运行,程序结束时他就停止。

  • 执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程。

虚拟机的退出

有如下的几种情况:

  • 程序正常执行结束

  • 程序在执行过程中遇到了异常或错误而异常终止

  • 由于操作系统用现错误而导致Java虚拟机进程终止

  • 某线程调用Runtime类或system类的exit方法,或Runtime类的halt方法,并且Java安全管理器也允许这次exit或halt操作。

  • 除此之外,JNI(Java Native Interface)规范描述了用JNI Invocation API来加载或卸载 Java虚拟机时,Java虚拟机的退出情况。

二、类加载器与类加载过程

引言

Java 源代码首先需要使用 Javac 编译器编译成 .class 文件,然后由类加载器(ClassLoader)把描述类的数据从Class文件加载到内存,并对数据进行一系列处理,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制

总结:本小节就是是对以下这个图的详细学习整理

image-20220412192639497

.Class文件详解

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步

字节码文件结构

Class 文件是二进制文件,它的内容具有严格的规范,文件中没有任何空格,全都是连续的 0/1

Class 文件中的所有内容被分为两种类型:无符号数、表

  • 无符号数 无符号数表示 Class 文件中的值,这些值没有任何类型,但有不同的长度。u1、u2、u4、u8 分别代表 1/2/4/8 字节的无符号数。
  • 表是由多个无符号数或者其他表作为数据项构成的复合数据类型

附🛄Class字节码文件结构表

名称类型名称说明
魔数u4magic魔数,识别Class文件格式
版本号u2minor_version副版本号(小版本)
u2major_version主版本号(大版本)2个字节
常量池集合u2constant_pool_count常量池计数器
cp_infoconstant_pool常量池表n个字节
访问标识u2access_flags访问标识
索引集合u2this_class类索引
u2super_class父类索引2个字节
u2interfaces_count接口计数器2个字节
u2interfaces接口索引集合2个字节
字段表集合u2fields_count字段计数器
field_infofields字段表n个字节
方法表集合u2methods_count方法计数器
method_infomethods方法表n个字节
属性表集合u2attributes_count属性计数器
attribute_infoattributes属性表n个字节

对其中几个字段做出解释:

魔数

  • 每个Class文件开头的4个字节的无符号整数称为魔数(Magic Number)
  • 它的唯一作用是确定这个文件是否为一个能被虚拟机接受的有效合法的Class文件。即:魔数是Class文件的标识符。
  • 魔数值固定为0xCAFEBABE,不会改变。意思是cafe babe(本应该是Baby,但是16进制没有y,程序员的浪漫~)

文件版本号

  • 紧接着魔数的4个字节存储的是Class文件的版本号
  • 不同版本的Java编译器编译的Class文件对应的版本是不一样的。目前,高版本的Java虚拟机可以执行由低版本编译器生成的Class文件,但是低版本的Java虚拟机不能执行由高版本编译器生成的Class文件。否则JVM会抛出java.lang.UnsupportedClassVersionError异常。(向下兼容)

常量池

  • 常量池对于Class文件中的字段和方法解析也有着至关重要的作用
  • 常量池表项中,用于存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放

怎么去打开字节码文件

由于.class文件是一个16进制文件,EXE等二进制/16进制文件一般是不能被记事本等纯文本编辑打开的,否则会乱码

  • 要想查看这些16进制到底长什么样子,去下载UltraEdit这个工具,用这个打开文件后,右击选择16进制编辑,文件的数据就全变成16进制显示了,因为没什么作用(都是数字),我就不做展示了

要想查看二进制文件的16进制的内容 ,这里给出两种方法的详细步骤

javap命令——javap 是是JDK自带的反汇编器

根据class字节码文件,反解析出当前类对应的code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息

辨析反汇编和反编译

  • 反汇编:把目标代码转为汇编代码的过程,也可说是把机器语言转为汇编语言代码,低级转高级的意思

  • 反编译:显示.class文件的Java源代码

进入字节码文件目录,执行以下命令,即可在命令行得到字节码文件的信息

javap -v [文件名].class

image-20220413162815490

jclasslib工具会更方便,IDEA有这个插件,可以生成可视化字节码文件(阅读体验好一点)

第一步,idea设置中下载插件

第二步,打开字节码文件并且在IDEA中找到图中选项

image-20220413161943426

你可以在这里看到字节码文件的全部信息

image-20220413162246756

类加载器

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

如图所示,在.class文件->JVM->最终成为元数据模板被执行引擎使用,此过程就要一个运输工具(类装载器Class Loader),扮演一个快递员的角色

image-20220411225511956

类加载器的分类

JVM将类加载器划分为两大类

  1. 启动类加载器

    只有一个(Bootstrap ClassLoader)

  2. 自定义类加载器(User-Defined ClassLoader)

    **Java虚拟机规范定义:**将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器

系统提供了 3 种类加载器

1、启动类加载器(Bootstrap ClassLoader)

  • 加载JAVA_HOME/jre/lib目录下的类库,如rt.jarresources.jarcharsets.jar等。当然通过配置-Xbootclasspath 参数可以指定这些jar包的加载路径

    Jdk11以后默认不安装Jre,所以你在本地文件库中找不到这个jar包(可以自行安装寻找)

    image-20220414141652032

  • 不继承java.lang.ClassLoader,没有父加载器

  • 这个加载器是 C++ 编写的,无法直接引用,随着 JVM 启动,是虚拟机自身的一部分

  • 出于安全考虑,Bootstrap启动类加载器只加载包名为java,javax,sun等开头的类

2、扩展类加载器(Extension ClassLoader)

  • 主要用于加载 lib/ext 目录下的 jar 包和 .class 文件。同样的,通过系统变量 java.ext.dirs 可以指定这个目录
  • 这个加载器是个 Java 类,继承自URLClassLoader(由启动类加载器加载进内存的)
  • 可以直接引用

3、应用程序类加载器(Application ClassLoader)也叫做系统类加载器

  • 用户自定义的 Java 类的默认加载器,一般用来加载 classpath 下的其他所有 jar 包和 .class 文件,用户编写的代码,会首先尝试使用这个类加载器进行加载
  • 可以直接引用

自定义类加载器

开发人员可以通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求,例如:

  • 隔离加载类
  • 修改类加载的方式

  • 扩展加载源

  • 防止源码泄漏(加壳)

类加载器的关系如图所示,这些加载器并不是实际意义上的继承关系,也就是父加载器并不是它的父类,只是它自己的上一层加载器的意思

image-20220413200427260

  • .class文件存在于本地硬盘上,ClassLoader只负责class文件的加载,至于它是否可以运行,则由ExecutionEngine决定
  • .class文件被加载到JVM中,被称为DNA元数据模板,放在方法区

代码层面分析

jdk版本为11

获取应用程序类加载器

ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
//jdk.internal.loader.ClassLoaders$AppClassLoader@2437c6dc
System.out.println(systemClassLoader);

获取拓展类加载器

ClassLoader extClassLoader = systemClassLoader.getParent();
//jdk.internal.loader.ClassLoaders$PlatformClassLoader@58ceff1
System.out.println(extClassLoader);

获取引导类加载器

String类是由Bootstrap类加载器加载的,返回的是个null,是因为Bootstrap类加载器是由C++来实现的,java里面并没有一个class和它直接对应,所以返回null,所以当我们看到null值的时候,代表类加载器已经到头了

ClassLoader bootStrapClassloader = extClassLoader.getParent();
//null--->(Bootstrap ClassLoader)
System.out.println(bootStrapClassloader);

双亲委派机制

什么是双亲委派模型

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

工作过程

总结:交给父类加载,不行才自己加载

  1. 当我们通过自定义类加载器加一个类的时候,会先去自定义类加载器的缓存当中找(如果已经加载过一遍了就会存到缓存当中),如果从缓存中找到了就直接返回,没找到就委托父加载器找。
  2. 应用类加载器收到委托后去它的缓存当中找,找到就返回,没找到就委托它的父加载器
  3. 扩展类加载器收到委托后去它的缓存当中找,找到就返回,没找到就委托它的父加载器
  4. 启动类加载器收到委托后去它的缓存当中找,找到就返回,没找到就委派它的子加载器去寻找class文件并加载
  5. 扩展类加载器收到委派命令后尝试去加载,找到就返回,没找到就委派它的子加载器去寻找class文件并加载
  6. 应用类加载器收到委派命令后尝试去加载,找到就返回,没找到就委派它的子加载器去寻找class文件并加载
  7. 自定义加载器收到委派命令后尝试去加载,找到就返回,没找到就报错(classnotfound)

为什么需要双亲委派?

  1. 避免类的重复加载

    当父加载器已经加载过某一个类时,子加载器就不会再重新加载这个类

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

  • 如自定义类:java.lang.String(报错:阻止创建 java.lang开头的类)

image-20220414133435965

双亲委派机制

双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现并不复杂

实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中

源码阅读:jdk11–>ClassLoader类–>loadClass方法(与Jdk8的实现方式有很大的不同)

		@Override
        protected Class<?> loadClass(String cn, boolean resolve)
            throws ClassNotFoundException
        {
            // for compatibility reasons, say where restricted package list has
            // been updated to list API packages in the unnamed module.
            SecurityManager sm = System.getSecurityManager();
            if (sm != null) {
                int i = cn.lastIndexOf('.');
                if (i != -1) {
                    sm.checkPackageAccess(cn.substring(0, i));
                }
            }
			//调用父加载器
            return super.loadClass(cn, resolve);
        }

如何破坏双亲委派机制

首先来学习一下ClassLoader这个类

主要方法

  • loadClass() 就是主要进行类加载的方法,默认的双亲委派机制就实现在这个方法中。
  • findClass() 根据名称或位置加载.class字节码
  • definclass() 把字节码转化为Class

如何破坏双亲委派机制

  1. 需要自定义一个类加载器,并且需要破坏双亲委派原则时,我们会重写loadClass方法
  2. 想定义一个自己的类加载器,并且要遵守双亲委派模型,那么可以继承ClassLoader,并且在findClass中实现你自己的加载逻辑

JDK1.2之后已不再提倡用户直接覆盖loadClass()方法,而是建议把自己的类加载逻辑实现到findClass()方法中,因为在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载

破坏双亲委派机制的案例

  1. 在双亲委派出现之前

    由于双亲委派模型是在JDK1.2之后才被引入的,而在这之前已经有用户自定义类加载器在用了。所以,这些是没有遵守双亲委派原则的。

  2. JNDI、JDBC等需要加载SPI接口实现类的情况

  3. 为了实现热插拔热部署工具

    为了让代码动态生效而无需重启,实现方式时把模块连同类加载器一起换掉就实现了代码的热替换。

  4. 第四种时Tomcat等web容器的出现

  5. 第五种时OSGI、Jigsaw等模块化技术的应用。

案例一:Tomcat破坏双亲委派机制

Tomcat是web容器,那么一个web容器可能需要部署多个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的

问题

如果采用默认的双亲委派类加载机制,那么是无法加载不同版本的相同的类

解决方式

Tomcat破坏双亲委派原则,提供隔离的机制,为每个web容器单独提供一个WebAppClassLoader加载器

Tomcat的类加载机制:为了实现隔离性,优先加载 Web 应用自己定义的类,所以没有遵照双亲委派的约定,每一个应用自己的类加载器——WebAppClassLoader负责加载本身的目录下的class文件,加载不到时再交给CommonClassLoader加载,这和双亲委派刚好相反

案例二:JDBC破坏双亲委派机制

推荐阅读:Java如何在底层操作数据库

我们日常开发中,大多数时候会通过API的方式调用Java提供的那些基础类,这些基础类时被Bootstrap加载的。但是,调用方式除了API之外,还有一种SPI的方式,首先来看一下这两个方式

API Application Programming Interface
大多数情况下,都是实现方来制定接口并完成对接口的不同实现,调用方仅仅依赖却无权选择不同实现。
SPI Service Provider Interface
如果是调用方来制定接口,实现方来针对接口来实现不同的实现。调用方来选择自己需要的实现方

如典型的JDBC服务,我们通常通过以下方式创建数据库连接:

Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mysql", "root", "1234");

在以上代码执行之前,DriverManager会先被类加载器加载,因为java.sql.DriverManager类是位于rt.jar下面的 ,所以他会被根加载器加载。

类加载时,会执行该类的静态方法。其中有一段关键的代码是:

ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);

这段代码,会尝试加载classpath下面的所有实现了Driver接口的实现类

问题

DriverManager是被根加载器加载的,那么在加载时遇到以上代码,会尝试加载所有Driver的实现类,但是这些实现类基本都是第三方提供的,根据双亲委派原则,第三方的类不能被根加载器加载。

解决方案

JDBC中通过引入ThreadContextClassLoader(线程上下文加载器,默认情况下是AppClassLoader)的方式破坏了双亲委派原则。

JDK11源码阅读 --> ServiceLoader类 --> load方法

	@CallerSensitive
    public static <S> ServiceLoader<S> load(Class<S> service) {
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return new ServiceLoader<>(Reflection.getCallerClass(), service, cl);
    }

解释:第一行,获取当前线程的线程上下⽂类加载器 AppClassLoader,⽤于加载 classpath 中的具体实现类

类加载的过程(生命周期)

类加载概述

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在内存中创建一个java.lang.Class对象用来封装类在方法区(元空间)内的数据结构

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

(其中验证、准备、解析 3 个阶段统称为连接)

这七个阶段发生的顺序如图所示

image-20220413171313692

加载阶段

🌮 ​注意辨析这个加载和类加载的区别,这个是类加载的第一个阶段

什么是需要开始类第一个阶段“加载”,虚拟机规范没有强制约束,这点交给虚拟机的具体实现来自由把控

加载阶段虚拟机需要完成以下 3 件事情

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。(可以从硬盘上的字节码文件读取,也可以从网络中读取,或者动态代理生成等等)

    .Class文件并非特指某个存在于具体磁盘中的文件,而应当是一串二进制字节流,无论其以何种形式存在,包括但不限于磁盘文件、 网络、数据库、内存或者动态产生

  2. 将这个字节流所代表的 静态存储结构 转化为方法区的instanceKlass

    规范并未说明Class对象位于哪里,HotSpot虚拟机将其放在了方法区中

  3. 在堆中生成一个代表这个类的InstanceMirrorKlass对象,作为方法区的这个类的各种数据的访问入口

为什么有了instanceKlass还需要有InstanceMirrorKlass?

主要是为了安全性考虑,jvm的开发者不希望直接暴露instanceKlass里面类的全部元信息,而且作为Java程序员也没有必要去知道这些信息,使用权限标识符去控制就已经足够了。

如果暴露了,那么黑客可以使用C++或者JNI来写一些漏洞或者外挂,来绕过Java本身的权限判断,就会有很大的安全问题了

注:静态属性是存储在堆里面,也就是挂载到InstanceMirrorKlass上面的


类被加载后,就进入连接阶段。连接就是将经读入到内存的类的二进制数据合并到虚拟机的运行时环境中去

连接阶段

连接阶段分为三个小步骤: 1、验证 2、准备 3、解析

验证(Verify)
  • 这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
  • 验证阶段主要检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证

注:

验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了

如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间

准备(Prepare)
  • 为类变量分配内存并且设置该类变量的默认初始值,即零值
  • 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化
  • 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中
解析(Resolve)
  • 将常量池内的符号引用转换为直接引用的过程。
  • 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。
  • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
  • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info,CONSTANT_Fieldref_info、CONSTANT_Methodref_info等

初始化阶段

  • 初始化阶段就是执行类构造器方法<clinit>()的过程。

  • 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。

  • 构造器方法中指令按语句在源文件中出现的顺序执行。

  • <clinit>()不同于类的构造器。(关联:构造器是虚拟机视角下的())

  • 若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕。

  • 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁

三、JVM运行时数据区

引言

JVM虚拟机在Java在运行过程中向系统申请、分配内存,保证了JVM的高效稳定运行。

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域,如下图所示

注:JDK1.8 和之前的版本略有不同,下面会介绍到

JDK1.8之前

image-20220415193159652

JDK1.8之后的改变

注:

  • 直接内存不是运行时数据区的一部分

  • JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。

    永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义

    Hotspot指热点代码探测技术

    1. 通过计数器找到最具编译价值代码,触发即时编译或栈上替换
    2. 通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡

image-20220417175207241

程序计数器PC

定义

程序计数器是一块较小的内存空间,是当前线程正在执行的那条字节码指令的地址。若当前线程正在执行的是一个本地方法,那么此时程序计数器为Undefined

作用:当前线程行号指示器

  • 在Java虚拟机的概念模型里,字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成

特性

  • 程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域
  • 生命周期随着线程的创建而创建,随着线程的结束而死亡

🚀面试题

使用PC寄存器存储字节码指令地址有什么用呢?

为什么使用PC寄存器记录当前线程的执行地址呢?

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

👼 答:由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为线程私有的内存

Java虚拟机栈

定义

Java 虚拟机栈描述了 Java 方法运行过程的内存模型

作用:方法调用内存模型

压栈过程

当方法运行过程中需要创建局部变量时,就将局部变量的值存入栈帧中的局部变量表中。

Java 虚拟机栈的栈顶的栈帧是当前正在执行的活动栈,也就是当前正在执行的方法,PC 寄存器也会指向这个地址。只有这个活动的栈帧的本地变量可以被操作数栈使用,当在这个栈帧中调用另一个方法,与之对应的栈帧又会被创建,新创建的栈帧压入栈顶,变为当前的活动栈帧

出栈过程

方法结束后,当前栈帧被移出,栈帧的返回值变成新的活动栈帧中操作数栈的一个操作数。如果没有返回值,那么新的活动栈帧中操作数栈的操作数没有变化

每一个函数调用结束后,都会有一个栈帧被弹出,Java 方法有两种返回方式:

  1. return 语句
  2. 抛出异常

不管哪种返回方式都会导致栈帧被弹出

特性

  • 与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期和线程相同

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

  • 运行速度特别快,仅仅次于 PC 寄存器

  • Java 虚拟机栈会出现两种错误:StackOverFlowErrorOutOfMemoryError

    • StackOverFlowError 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误

      注:出现 StackOverFlowError 时,内存空间可能还有很多

    • OutOfMemoryError 若 Java 虚拟机堆中没有空闲内存,并且垃圾回收器也无法提供更多内存的话。就会抛出 OutOfMemoryError 错误。

虚拟机栈结构

Java 虚拟机栈会为每一个即将运行的 Java 方法创建一块叫做“栈帧”的区域,用于存放该方法运行过程中的一些信息,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息

如图所示

image-20220417141258470

局部变量表

  • 在java编译成class文件的时候,局部变量表随着栈帧的创建而创建,它的大小在编译时确定,创建时只需分配事先规定的大小即可。在方法运行过程中,局部变量表的大小不会发生改变

  • 主用于存放方法参数和方法内部定义的局部变量,数据类型包括各类基本数据类型,对象引用,以及 return address 类型

Slot

局部变量表最基本的存储单元是 slot,32 位占用一个 slot,64 位类型(long 和 double)占用两个slot

  • JVM 虚拟机会为局部变量表中的每个 slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
  • 栈帧中的局部变量表中的槽位(Slot)是可以重复的,如果一个局部变量过了其作用域,那么其作用域之后申明的新的局部变量就有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

在栈帧中,与性能调优关系最密切的部分,就是局部变量表,方法执行时,虚拟机使用局部变量表完成方法的传递局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收

操作数栈

  • 操作数栈和局部变量表一样,在编译时期就已经确定了该方法所需要分配的明确的栈深度
  • 栈顶缓存技术:由于操作数是存储在内存中,频繁的进行内存读写操作影响执行速度,将栈顶元素全部缓存到物理 CPU 的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。
  • 操作数栈的每一个元素可用是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型占用的栈容量为2
  • 并非采用访问索引方式进行数据访问,而是只能通过标准的入栈、出栈操作完成一次数据访问

动态连接

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

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中方法的符号引用为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用(静态方法,私有方法等),这种转化称为静态解析,另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。由于篇幅有限这里不再继续讨论解析与分派的过程,这里只需要知道静态解析与动态连接的区别就好

方法出口信息

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

  • 执行引擎遇到任意一个方法返回的字节码指令:传递给上层的方法调用者,是否有返回值和返回值类型将根据遇到何种方法来返回指令决定,这种退出的方法称为正常完成出口。
  • 方法执行过程中遇到异常: 无论是java虚拟机内部产生的异常还是代码中thtrow出的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出的方式称为异常完成出口,一个方法若使用该方式退出,是不会给上层调用者任何返回值的。无论使用那种方式退出方法,都要返回到方法被调用的位置,程序才能继续执行。方法返回时可能会在栈帧中保存一些信息,用来恢复上层方法的执行状态。一般方法正常退出的时候,调用者的pc计数器的值可以作为返回地址,帧栈中很有可能会保存这个计数器的值作为返回地址。方法退出的过程就是栈帧在虚拟机栈上的出栈过程,因此退出时的操作可能有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者的操作数栈每条整pc计数器的值指向调用该方法的后一条指令

方法重写的本质

  • 找到操作数栈顶的第一个元素所执行的对象的实际类型,记做 C。如果在类型 C 中找到与常量池中描述符和简单名称都相符的方法,则进行访问权限校验。
  • 如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回 java.lang.IllegalAccessError 异常。
  • 否则,按照继承关系从下往上依次对 C 的各个父类进行上一步的搜索和验证过程。
  • 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。

本地方法栈

  • 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈是为 JVM 运行 Native 方法准备的空间,由于很多 Native 方法都是用 C 语言实现的,所以它通常又叫 C 栈

  • 在 HotSpot 虚拟机中和虚拟机栈合二为一

  • 本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

  • 方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowErrorOutOfMemoryError 两种错误。

堆区

定义

堆是Java 虚拟机所管理的内存中最大的一块,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存(几乎是因为可能存储在栈上,另见逃逸分析)

特性

  • 在虚拟机启动时创建,线程共享,整个 Java 虚拟机只有一个堆,所有的线程都访问同一个堆

  • 在虚拟机启动时创建

  • 堆的大小既可以固定也可以扩展,但对于主流的虚拟机,堆的大小是可扩展的,因此当线程请求分配内存,但堆已满,且内存已无法再扩展时,就抛出 OutOfMemoryError 异常

  • Java 虚拟机规范规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的

    注:Java 堆所使用的内存不需要保证是连续的。而由于堆是被所有线程共享的,所以对它的访问需要注意同步问题,方法和对应的属性都需要保证一致性

垃圾回收的主要区域

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)

从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为

  • 年轻代

    可以分为Eden空间、Survivor0空间和Survivor1空间,两个 Survivor 区都属于新生代为了区分,这两个 Survivor 区域按照顺序被命名为 from Survivorto Survivor

  • 老年代

​为什么​要​分代​?🚀 不同的区域存放不同生命周期的对象,这样可以根据不同的区域使用不同的垃圾回收算法,更具有针对性

JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存,如图所示

image-20220417234455298

新生代与老年代

老年代比新生代生命周期长

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

调JVM参数,默认为-XX:NewRatio=2,表示新生代与老年代空间默认比例 1:2:,表示新生代占 1,老年代占 2,新生代占整个堆的 1/3。可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5

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

可以通过选项 -XX:SurvivorRatio 调整空间比例,如**-XX:SurvivorRatio=8**

浅谈堆中对象分配策略

对象优先在 eden 区分配

为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率,大对象直接进入老年代

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)

长期存活的对象进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。

为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器

  1. 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄加1(初始年龄为0)
  2. 对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度,就会被晋升到老年代中

Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值

动态年龄计算的代码如下

uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
    //survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
total += sizes[age];//sizes数组是每个年龄段对象大小
if (total > desired_survivor_size) break;
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
    ...
}

🏭 额外补充说明:关于默认的晋升年龄是 15,这个说法的来源大部分都是《深入理解 Java 虚拟机》这本书,如果你去 Oracle 的官网阅读相关的虚拟机参数-XX:MaxTenuringThreshold=threshold这里有个说明

Sets the maximum tenuring threshold for use in adaptive GC sizing. The largest value is 15. The default value is 15 for the parallel (throughput) collector, and 6 for the CMS collector

翻译:设置用于自适应GC大小调整的最大使用期限阈值。最大值为15。并行(吞吐量)收集器的默认值为15,CMS收集器的默认值为6

即:默认晋升年龄并不都是 15,这个是要区分垃圾收集器的,CMS 就是 6.

浅谈Hotspot的GC机制

GC方式

针对HotSpot VM的实现,它里面的GC按照回收区域准确分类只有两大种

(1)部分收集Partial GC):不是完整收集整个Java堆的垃圾收集。其中又分为:

  • 新生代收集(Minor GC):只是新生代的垃圾收集
  • 老年代收集(Major GC):只是老年代的垃圾收集,只有CMS的concurrent collection是这个模式
  • 混合收集 (Mixed GC) : 收集整个新生代以及部分老年代的GC。只有G1有这个模式

(2)整堆收集(Full GC:收集整个java堆和方法区的垃圾收集

注意:Major GC通常是跟full GC是等价的,收集整个GC堆。但因为HotSpot VM发展了这么多年,外界对各种名词的解读已经完全混乱了,当有人说"major GC"的时候一定要问清楚他想要指的是上面的full GC还是old GC

年轻代GC触发机制

年轻代GC(Minor GC)触发机制详解

  1. 大部分清空 Java 对象都是在 Eden 区被 new 出来的(详见本节堆中对象分配策略)

    🐰 如果创建新对象时,Eden 空间填满了,就会触发 GC:Major GC,将 Eden 不再被其他对象引用的对象进行销毁,再加载新的对象放到 Eden 区,特别注意的是是 Eden 空间填满了才会触发 Minor GC 的,而Minor GC只是顺便清理 Survivor

  2. 将 Eden 中剩余的对象移到 Survivor0 区,对象的年龄加 1(初始年龄为0)

  3. 如果再次触发垃圾回收,此时上次存活的对象,放在 Survivor0 区的,如果没有回收,就会放到 Survivor1 区,年龄+1

  4. 再次经历垃圾回收,又会将幸存者重新放回 Survivor0 区,依次类推,长期存活的对象会进入老年代(详见本节堆中对象分配策略)

注:如果幸存者区满了,对象直接放入老年代,如果此时打开了自适应开关,GC结束后会调整新生代的大小

总结

  • minor GCeden区满时触发,minor GC执行以后,部分存活对象会进入老年代,导致老年代占用率升高
  • Survivor区域是被动GC,不会主动GC
  • 因为Java对象大多都生命期短,所以Minor GC 非常频繁,一般回收速度也比较快,这一定义既清晰又利于理解
  • Minor GC 会引发STW(Stop the World),暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行
老年代GC触发机制

这个GC指发生在老年代的GC=>如Major GC或者Full GC

  • 出现了Major GC,经常会伴随至少一次的Minor GC

    不是绝对的,在Parallel Scavenge 收集器的收集策略里就有直接进行Major GC的策略选择过程

  • 老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC

    Major GC速度一般会比Minor GC慢10倍以上,STW时间更长,如果Major GC后,内存还不足,就报OOM了

Full GC触发机制

触发Full GC执行的情况有以下五种

  1. 调用System.gc()时,系统建议执行Full GC,但是不必然执行
  2. 老年代空间不足
  3. 元空间不足
  4. 通过Minor GC后进入老年代的平均大小,大于老年代的可用内存
  5. 由Eden区,Survivor S0(from)区向S1(to)区复制时,对象大小由于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

总结:

  1. 频繁在新生区收集,很少在老年区收集,几乎不在元空间搜集
  2. Full GC 是开发或调优中尽量要避免的,这样暂停时间会短一些

堆区常见错误

堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  1. OutOfMemoryError: GC Overhead Limit Exceeded : 当JVM花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存无关,和你配置的内存大小有关!)

元空间

JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。

常用参数

当你元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace

你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

  1. 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小
  2. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
  3. 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

运行时常量池

说明:

  • 运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)
  • 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误

版本演变

JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)

JVM 常量池中存储的是对象还是引用呢? todo2

结论:jdk1.8版本的字符串常量池存放的是字符串对象和字符串常量池,元空间的常量池寻访的是引用

直接内存

说明

直接内存并不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用

可能导致 OutOfMemoryError 错误出现

服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常

NIO

  • JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据
  • 本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制

直接内存与堆内存比较

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

文章字数: 21853字
上次更新时间: 4/19/2022
Oliverloki

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值