首先,如果需要真正深入了解jvm的内部情况,可以登录java的官网下载对应版本的规范,可下载对应的pdf文件做详细了解。也可以阅读《深入理解Java虚拟机:JVM高级特性与最佳实践》。

本文主要为读者描绘jvm的大致情况,方便读者在之后的学习中有方向性地了解更细节的内容。主要基于当前常用的JAVA8。

java虚拟机规范并不是关于虚拟机的实现,而是介绍了一些规则,因此读者会发现为什么虚拟机还有什么hotspot之类。并且由于虚拟机对应的时class文件,因此其它语言可以依赖自己的编译器获得一个可以运行在java虚拟机上的文件。

本文内容较杂,但由浅入深,将各部分知识敲碎再组合。适合一次性读完。

虚拟机主要内容

WgTZM4.png

类加载

简要概述

所谓类加载,是指将class文件加载到内存中,由JVM对数据做校验、解析、初始化,最终得到java类型。而class文件本身是按照规范的一组指令的有序集合。

类加载是在运行期间完成,而不是c++那种提前编译,导致运行效率相对低,但是由此我们可以在运行期间对class文件做操作,更具灵活性和扩展性。例如cglib的动态代理,就是在运行期间完成一个代理对象的class实现并加载的内存中替代原有对象,实现功能增强。 更厉害的是,Alibaba的arthas软件,可以在项目运行期间,替换其中的某个class文件【避免小问题导致的项目整体停止】,这些是提前编译完全做不到的。

------------------------------------------------------------已有的类加载器-------------------------------------------------------------------------

例如,我们希望创建一个HashMap的对象,由于这种属于jdk较为底层的类,不是你开发者可以随便搞的,也算不上是扩展的,就需要交由BootstrapClassLoader负责创建,类似地,不同层级的类由对应的加载器负责。 如此划分,加载器能够各司其职,防止底层的类被后来的类覆盖。同时,如果项目过大,包含了众多的依赖或模块,出现多个类的全限定名相同,这一机制能保证只有一个类能实现,而不是出现多个造成歧义。

各加载器的加载路径获得方式: BootstrapClassLoader:System.getProperty("sun.boot.class.path")

ExtClassLoader:System.getProperty("java.ext.dirs") AppClassLoader:System.getProperty("java.class.path")

通过路径可获悉不同加载器负责的类的范围。其中ext可以在本地安装路径中的jre\lib\ext中看到不少jar文件。

加载过程

类的加载需要先查找现有的类,再创建没有的类

每个加载器有自己的缓存保存已创建的类。 当一个类需要加载时,首先到达AppClassLoader中,由它查找自身管理的类中是否有现成,则直接返回,否则询问ExtClassLoader,由它再查找它管理的类中是否有现成的,类似地,到达bootBootstrapClassLoader,当发现没有的现成的,它将看看这个类是否是由它负责的,则创建并返回,否则把任务交给ExtClassLoader,类似地,再传递给AppClassLoader,总会在这里创建出来的。 ---------这一过程被称为双亲委派机制【就是可以向上或向下踢皮球】

双亲委派机制只是JVM的一种推荐做法,实际的一些实现可能有自己的加载方式,可以了解一下Tomcat的方式,所有的类都会自行加载,只有实现不了才会委派上层完成。

如果学习过spring系列,或操作过动态代理的同学,估计会注意到有时参数需要传递一个类的class对象或赋予一个类加载器作为参数。 即使用方法getclassloader()获得,通过这个方法可以获悉对象是由哪个加载的,进一步的getParent()可以得知加载器的上层是哪个。但是在查询ExtClassLoader的父加载器时,返回的是null【因为前文告知了,hotspot最底层的加载器是c++实现的,没有对应的java代码】(常用的JVM多是HotSpot,它本身是c++编写的,而其它JVM,如MRP、Maxine是Java编写的,对应的bootstrap则不为null)

【BootstrapClassLoader是与对应的JVM为一个整体,而其它类加载器则属于后来者】

另外加载器可以实现自定义,jdk中关于加载器的定义的是:

  1. 除bootstrap】均继承自ClassLoader抽象类
  2. 下面有SecureClassLoader实现类
  3. 再下面有URLClassLoader类
  4. 再下面就有了AppClassLoader、ExtClassLoader类,可以继承自上面的类自定义加载器。

【需要注意的是,类的继承关系,和踢皮球的关系可不相同,新定义的类加载器无论继承自谁,它的parent都是AppClassLoader。】

内存管理

这里主要先介绍内存的各个组成部分的作用。

内存公有区

jdk1.7实现的方法区称为永久代,1.8则实现为元空间,本质上是与堆保存在一起,但概念上有所差别(jdk1.7合并给堆)【1.7之前,hotSpot称之为永久代】,如果需要调整该区域的内存大小,可用jvm命令【-XX:MaxMetaspaceSize=..】(这是1.8的命令,之前的版本估计也没人在乎)

堆的内存分配比例:

老年代新生代
2/31/3
EdenS0S1
80%10%10%

直接内存,这是不属于JVM的内存,主要由java的NIO方法操作,效率高。比如nio中有空拷贝的概念,就是利用一块直接内存存储需要放入磁盘中的数据,【因为磁盘中的对应的位置只需要被覆盖即可,因此不需要实际加载到内存中,因此只需要一个IO操作即可】

内存私有区

由于现在的系统以及程序要求多线程,导致需要为不同的线程分配特有的空间进行独立运行。于是,需要分出这样一块内存用于划分成多个独立的小块。

每个小块内部包含虚拟机栈、本地方法栈以及程序计数器。

【虚拟机栈和本地方法栈有时直接统称Java栈】

垃圾回收器(GC)

GC的内容的内容较多,内部包含很多机制和算法,但真正需要做的就是对利用回收器做调优。


对象存储

首先要注意,类产生的对象和作为数组的对象在创建机理上是不同的。类对象有类加载器一层层负责,而数组则直接由Bootstrap负责。 【在JVM规范中,指出了底层的实现中,类对象使用了常用的new,而数组则有自己的newarray等方法】

实例存储

YES
NO
YES
NO
YES
NO
仍存活
已结束
YES
NO
退出
退出
实例产生
方法区存放引用
堆中存放实际对象
是否发生逃逸或评估对象占用空间过大
尝试在TLAB中存储
进入栈上分配
TLAB是否具有足够的空间
分配在TLAB
对象是否过大且超过阈值
分配到Eden
分配到TLAB
发生垃圾回收
实例是否结束
转移到S0或S1
回收其内存
S0或S1内存已满
转移到Old区
发生垃圾回收
是否到达指定寿命
等待内存回收

对象创建后,具体的存储流程如上图。

首先是,试图放在方法栈中,其次考虑TLAB,最后分配到熟悉的Eden,S0/S1,Old区。

下面简单介绍一下上述的一些过程细节:

对象定位

对象的定位,自然需要获取它在内存中的地址,而这个地址则放在Java栈的本地变量表中,但真正获取到对象,这里有两种方式:【为了得到完善的对象信息,我们一方面要获得对象在堆中的数据信息,另一方面要获得方法区中对象的类型信息】

由于hotspot底层为c++,需要创造一种数据结构来描述java类与对象的信息。 hotspot中对于类及其对象的描述使用OOP-Klass模型【OOP(Ordinary Object Pointer)描述实例信息,Klass描述类信息,是与对应java类相关的c++对等体】

OOP-Klass导致了在hotspot的底层上,调用实例的过程实际上是先到达c++的对等体上,获得了Java对象的地址,再到达堆中实例的位置。

垃圾回收操作

前期准备

前面已经简单介绍了垃圾回收器的概况,但只是介绍了大致的思路,具体如何操作,则涉及到垃圾如何记录,以及安全地清除垃圾。

由于线程是具有一个私有区域,私有区域中又包含了虚拟机栈,虚拟机栈中又包含了局部变量表,局部变量表中又包含了各种引用地址。Hotspot定义一个数据结构(Oop Map),表示对象内部各偏移量对应的数据类型,以及以及特定位置栈和寄存器中引用的位置。

回收器操作

前面已经提及理论当前Java自带的10种垃圾回收器,但其中一些并不是直接使用,而是根据分代收集而组合完成工作。其中G1,Shenandoah,ZGC是可以独立使用的,Epsilon稍微特殊。

可独立运行
XJDK9
XJDK9
G1
Shenandoah
ZGC
Serial
CMS
Serial Old
ParNew
Parallel Scavenge
Parallel Old

上图中,相连的双方代表可以组合使用。(X JDK9 表示这种组合在JDK9及之后被废弃)

Serial/Serial Old示意图

 

Class文件结构

class文件包含的主要内容有:

魔数,(副版本号,主版本号),(常量池计数器,常量池),

(访问标志,类索引,父类索引,接口计数器,接口表),

(字段计数器,字段表),(方法计数器,方法表),(属性计数器,属性表)。

关于class文件的内部信息,非常繁多,以至于java虚拟机规范专门列出一章讨论,更具体的细节,读者可查看规范,这里仅阐述整体的框架。

计数器均占据2个字节

下述表中,均会对各个元素做详细 的描述,如方法的访问权限等,总之将代码中的各种信息转化到表中对应符号标志

 

类加载过程

前文介绍的双亲委派机制,这一机制是jdk1.2之后引入的推荐方法,而不是强制的,具体的实现代码在java.lang.ClassLoader中的loadClass()方法中。

类的初始化

初始化,主要是处理其中的静态变量和静态代码块

这里需要注意的是其中的初始化问题,何时需要真正初始化一个类,首先必须有一个类的情况下则必须初始化,如:

  1. new一个对象,使用类的静态字段或方法。反射调用一个类。还有一个是动态语言生成的方法句柄需初始化对应的类。
  2. 由于对象的继承关系,首先需要初始化其父类。或程序的main函数所在的类也必须初始化。
  3. 特别的,由于jdk8后接口存在default方法,继承此接口的类初始化,必定带动这个接口初始化。

【特别的例子说明】

类的加载

前一节提前说了一下类的初始化,但这一切的开头都需要类的加载。

其中的字节流可以通过一切手段获得。例如网络、jar包、数据库等。

其中的jar包读取,在对应的位置字符串中指明对应的协议和jar包位置,再接上“!/"表明读取包内的文件,之后就是接上对应jar包内文件的位置。如”jar:file:\\【jar包位置】!/【文件位置】“

前文中提及了如果有需要的话,可以自定义类加载器。现在,我们就可以继承URLClassLoader,再覆盖对应的类加载方法,包括从哪里读取类文件(其中,就可以用上述jar包读取的方式从自定义的jar包中读取自己的class文件并加载到内存中)。

如果我们有多个类似的jar包,但是内部class文件的代码逻辑有所差别【类似于人类的双标行为】,此时就可以通过自定义的类加载器,视情况加载不同的jar包。

【需要注意数组】

需要说明一下数组的组件类型,即数组去掉一个维度后的类型,如String[] 组件类型就是String,而String[][] [ ][ ]的组件类型就是String[]。

首先,数组不同于普通java对象,它是直接由JVM创建 ,而不是类加载器完成。就如前面的【特别的例子说明】中提及的,数组是被JVM直接创建了一个对应的类。

  1. 如果组件类型是String这类的非引用型的,那么就直接交给BootstrapClassLoader进行创建。

  2. 否则,就仍然是个数组的引用,

    • java8的虚拟机规范中介绍是:此时数组被标记为已由组件类型的定义类加载器定义。

      • 但之后,被去掉一个维度的数组再次进入这样一个加载流程中,不断地被去掉一个维度,并不断地被标记,直到最后不再是引用类型,到达BootstrapClassLoader进行创建。

对象存储

对象存储结构

对象在堆内存的存储布局为:对象头(header),实例数据(Instance Data),对齐填充(Padding)。

对齐填充是保证对象的大小为8字节的倍数。这与CPU的缓存行有关,使得每次加载后都能立刻被CPU刷新执行,提高效率。【现在一些CPU的缓存行已经较大,如64字节,为了提高效率,一些项目会在对象中故意加若干个long或其它类型的变量以进行主动填充】

其中对象头中包含了mark word元数据指针,若为数组则另外有一个数组长度。这些元素的大小均与对应JVM的位数有关,例如64位的JVM,则均为64bit。

元数据指针(klass word)则是用于指向方法区中目标类的类型信息,也就是说通过元数据指针可以准确定位到当前对象的具体目标类型。

mark word中可以包含大量信息,如锁状态,GC年龄。且占用的空间位数与相应的CPU处理位数相同。

32位情况:

锁状态25bit 4bit1bit 2bit
23bit 2bit是否偏向锁锁标志位
无锁状态 对象的hashcode分代年龄001
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10
GC标记11
偏向锁线程IDEpoch分代年龄101

64位情况:

 

指针压缩

如果所使用的 64 位虚拟机的版本在 Update14-Update22 之间,可以通过选项

“-XX:+UseCompressedOops”显式开启指针压缩功能,而在 Update23 版本之后,指针压缩功能将会被缺省开启。

JDK7及之后是默认开启的。

以下举例说明对象在64位环境下空间的冗余:

32位操作系统 花费的内存空间为:对象头(8字节) + 实例数据(int类型(4字节) + 引用类型(4字节)) +对齐(0字节) 共16个字节

64位操作系统:对象头(16字节) + 实例数据 (int类型(4字节) + 引用类型(8字节))+对齐(4字节)

共 32个字节

同样的对象需要将近两倍的容量,所以需要开启压缩指针:

64位开启压缩指针后:对象头(12字节) + 实例数据(int类型(4字节) + 引用类型(4字节))+对齐(0字节)

共24个字节

JVM的实现方式是:

因此当堆内存大小超过32GB,就不能使用压缩指针来工作了。

而且当堆的大小仅仅略大于32GB,由于指针扩大为两倍,需要消耗更多的空间,可能导致实际能用的空间不如32GB的情况。

class文件加载

在实际使用中,我们当前线程的类加载默认为AppClassloader。如果我们自定义了一个类加载器从外部的jar包或其它途径读取了一组字节流加载为对应的类,由于当前默认的类加载器对这个类是不做负责的,导致我们无法直接的在代码中用普通的类形式调用相关的方法,只能通过类的newInstance()方法生成一个实例,而实例也只能通过getMethod()方法调用指定的方法,invoke读取结果中的对应数据。

可使用当前线程即Thread.currentThread().setContextClassloader()来设置当前需要的类加载器,此时就可以像正常操作那样调用类并使用,但最后作为临时操作【可放在某个方法中,临时改变方便自己的操作,最后该还原】。

自定义的类加载器

下面是摘自官方文档的一些介绍:

支持并发加载类的类加载器称为并行能力类加载器,需要通过调用ClassLoader.registerAsParallelCapable方法在其类初始化时间注册自身【默认情况下, ClassLoader类注册为并行】(它的子类仍然需要注册自己,如果它们是并行的能力)。 在委托模式不是严格层次化的环境中,类加载器需要并行,否则加载类可能导致死锁,因为加载程序锁定在类加载过程中保持(参见loadClass方法)。

方法defineClass将字节数组转换为类别的实例。 这个新定义的类的实例可以使用Class.newInstance创建。

类加载器创建的对象的方法和构造函数可以引用其他类。 要确定所引用的类,Java虚拟机调用最初创建该类的类加载器的loadClass方法。

网络类加载器子类必须定义从网络加载类的方法findClassloadClassData 。 一旦下载构成类的字节,它应该使用方法defineClass创建一个类实例。

上述的官方描述,已经说明了一个类加载中主要的方法,因此我们在自定义时只需要覆盖这些方法即可,主要的步骤包括:

  1. 在findClass()中指明类的来源,使之能够读入字节流。这里的操作空间就非常大了,可以从任意地方的任意位置的任意文件中获取流,只要是能够执行的。例如,可以放在某个服务器上的某个目录下的txt文件,只要这个文件磁盘上的内容是class文件的形式即可。这样我们可以随便定义目标文件的后缀名让人无法察觉。
  2. 在获取了字节流之后,findClass()方法内部需要调用一个defineClass()方法从字节流生成一个类。这也可以自主发挥一下如何搞些奇怪的手段改变字节流搞事。

大致的效果是:

自定义一个类加载器,

调用类加载器,

另外,如果定义的类的全限定名与上层的类加载器有冲突,不希望被双亲委派机制交由上级处理,可以覆盖loadClass方法指明当对象为null时,由自定义的加载器负责创建。


----------------------------------------【疲倦的话,稍微缓一缓吧!】----------------------------------


类的验证

在获得了类的字节流后,首先需要判断这是不是一个合格的类文件。

验证操作主要有:格式验证、语义分析、操作验证,以及符号引用验证等。

class文件加密与解密

由于Java生成的class文件非常方便就可以被反编译,一些重要的逻辑如果不希望被人看见,则必须要对class文件进行加密。当然,如果只是想稍微隐藏一下,也可利用前面提及的自定义类加载,让自己的class字节流隐藏在某个地方的某个文件中。【常见的反编译工具为jd-gui,当然Idea肯定也可以,VsCode安装个插件也没问题】

下面的内容主要来源于书本《Java虚拟机精讲》的7.3节。

首先,加密算法主要有对称加密和非对称加密。在需要双方互通信息时,主要需要非对称加密,用户将自己的公钥传输过去,对方利用公钥加密自己的内容,用户可以使用私钥解读。而对称加密则使用单一的密钥加密和解密,在传输加密信息时,必须双方均持有密钥,导致密钥分发过程造成不安全。但对于本地使用的文件而言,对称加密就足够了。【更多密码学知识,可参考《图解密码技术》】

该书中则使用了对称加密的3DES算法。

Ek()和 Dk()代表 DES 算法的加密和解密过程,K 代表 DES 算法使用的密钥,P 代表明文,C 代表密文。

具体的代码实现如下:

上述代码的运行结果是:

加密后的数据->P湻9浪k0肟 解密后的数据->测试数据...

类似地,我们可以对class文件的字节流做同样的加密和解密操作,具体的代码放在附录。

多线程或多并发环境的JVM

此类情况下,由于面临当前操作可能会被其它进程或线程修改,因此重点在于使用锁技术,以及如何维护当前的工作。

对象创建

根据前文的介绍,我们知道对象的创建主要的工作有:可能需要先进行类加载,再判断对象可以存放的位置,划分出指定的内存空间后,再加载自己的各种变量和方法,最后将各种引用关联起来,得到一个可以使用的对象。当然,最后需要将对象是引用与对应的对象名关联起来。

垃圾回收

垃圾回收器在CMS之后,都开始了并发标记,即在用户线程运行期间对系统中的对象实例进行垃圾标记。这期间可能发生某些垃圾被重新引用,因此又需要进行结果修正。

而如何与用户线程共存且完成标记的任务则包含了许多思考。

在利用卡表标记了对象之间存在引用关系之后,借助卡表就可以快速地对引用链进行遍历。但由于此时用户进程也在继续,则需要三色标记来判断对象的存活状态。

状况1:新插入
状况2:删除
上下均已扫描
上下均已扫描
下面未完成扫描
上下均已扫描
下面未完成扫描
自己未被扫描
自己未被扫描

若同时发生了上图中的状况,最后都会导致某些存活的对象被视为垃圾。

为避免对象误判为垃圾:

CMS:采用增量更新

G1:采用原始快照(STAB)

上述两种方式,读者会发现,都发生了一旦引用变化就会立即记录,这就是再次利用了写屏障的技术。

其它的类加载机制

Tomcat

作为一个Web应用,Tomcat需要管理多个不同的网站,即对应的不同的jar/war包,导致Tomcat内部包含了多个应用类库。常规的委派机制无法解决类与类的全限定名的冲突,故,Tomcat内部支持委派机制,但有自己的一套加载系统。

Tomcat的类加载器关系如下:【版本为Tomcat7之后】

System
Bootstrap
Common
Catalina
Shared
WebApp0
WebApp1

其中,WebApp0和WebApp1是随着用户自定的jar包而创建,用于加载对应目录下所有class,资源,jar文件,即负责加载指定Web的资源,且只对该Web可见。

下面是关于上图中各类加载器的解释,具体细节可查看官方文档

Tomcat中类的实际加载顺序,需要考虑类加载器的delegate属性值,【默认false】(通过在对应的conf/Context.xml中<Loader delegate="true" /> 进行修改。

将依此从下述位置中查找类或资源,

字节码生成

这部分就非常简单了,就是动态代理,既包含了JDK自带的Proxy类,也包含CGLib等,都属于根据已有的条件额外创建了一个class文件,也属于一种类加载机制。

模块化

JDK9才引入了JPMS模块系统,还是静态的。而之前已经有了OSGi(Open Service Gateway Initiative)【动态模块化规范】,其中的模块一般就是已jar包的形式封装,用以实现热插拔功能。

OSGi的Bundle类加载器 之间只有规则,没有固定的委派关系。例如,某个Bundle声明了一个它依赖的Package,如果有其他 Bundle声明了发布这个Package后,那么所有对这个Package的类加载动作都会委派给发布它的Bundle类 加载器去完成。不涉及某个具体的Package时,各个Bundle加载器都是平级的关系,只有具体使用到某 个Package和Class的时候,才会根据Package导入导出定义来构造Bundle间的委派和依赖。 另外,一个Bundle类加载器为其他Bundle提供服务时,会根据Export-Package列表严格控制访问范 围。如果一个类存在于Bundle的类库中但是没有被Export,那么这个Bundle的类加载器能找到这个类, 但不会提供给其他Bundle使用,而且OSGi框架也不会把其他Bundle的类加载请求分配给这个Bundle来 处理。

附录

class文件3DES加密和解密(自定义的类加载器)

调优

可视化

使用命令 jvisualvm ,开启jdk自带的调优工具。

开启的页面内,将包含我们启动的所有java进程,可以在其中查看新生代和老年代以及元空间的空间使用情况。