首先,如果需要真正深入了解jvm的内部情况,可以登录java的官网下载对应版本的规范,可下载对应的pdf文件做详细了解。也可以阅读《深入理解Java虚拟机:JVM高级特性与最佳实践》。
本文主要为读者描绘jvm的大致情况,方便读者在之后的学习中有方向性地了解更细节的内容。主要基于当前常用的JAVA8。
java虚拟机规范并不是关于虚拟机的实现,而是介绍了一些规则,因此读者会发现为什么虚拟机还有什么hotspot之类。并且由于虚拟机对应的时class文件,因此其它语言可以依赖自己的编译器获得一个可以运行在java虚拟机上的文件。
| 本文内容较杂,但由浅入深,将各部分知识敲碎再组合。适合一次性读完。 |
虚拟机主要内容类加载简要概述加载过程内存管理内存公有区内存私有区垃圾回收器(GC)对象存储实例存储对象定位垃圾回收操作前期准备回收器操作Class文件结构类加载过程类的初始化类的加载对象存储对象存储结构指针压缩class文件加载自定义的类加载器类的验证class文件加密与解密多线程或多并发环境的JVM对象创建垃圾回收其它的类加载机制Tomcat字节码生成模块化附录class文件3DES加密和解密(自定义的类加载器)调优可视化
首先,从代码运行开始,虚拟机需要加载class文件,此时需要依赖类加载器。
在类加载之后,就到了内存之中。内存管理则是一个大内容。
首先,宏观上,内存包含了以下两类,公有区和私有区:
公有区:
私有区:
其次,垃圾回收器(GC)用于垃圾对象占用内存的回收
对象创建流程
首先将对应的class文件内容加载到方法区中,【在hotspot中,先生成一个对应的c++对象,之后在堆中生成实际的java对象】
java对象的保存是在堆中,但内部仍然包含一系列选择,包含栈存储,TLAB,Eden,S1/S0,Old
所谓类加载,是指将class文件加载到内存中,由JVM对数据做校验、解析、初始化,最终得到java类型。而class文件本身是按照规范的一组指令的有序集合。
类加载是在运行期间完成,而不是c++那种提前编译,导致运行效率相对低,但是由此我们可以在运行期间对class文件做操作,更具灵活性和扩展性。例如cglib的动态代理,就是在运行期间完成一个代理对象的class实现并加载的内存中替代原有对象,实现功能增强。 更厉害的是,Alibaba的arthas软件,可以在项目运行期间,替换其中的某个class文件【避免小问题导致的项目整体停止】,这些是提前编译完全做不到的。
------------------------------------------------------------已有的类加载器-------------------------------------------------------------------------
BootstrapClassLoader(最底层)【实际是由c++实现的(针对hotspot)】ExtClassLoader(扩展类)AppClassLoader例如,我们希望创建一个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中关于加载器的定义的是:
【需要注意的是,类的继承关系,和踢皮球的关系可不相同,新定义的类加载器无论继承自谁,它的parent都是AppClassLoader。】
这里主要先介绍内存的各个组成部分的作用。
元空间(方法区):方法区本身是逻辑概念,相当于接口,是由永久代或元空间实现出来的,由于这个区域没有垃圾回收,因此可以适当调大一些,防止报OutOfMemoryError:Java heap space。
堆:包含多个部分,Eden、S0/S1(From Survior,To SUrvior)、Old(老年代)。主要存储实例对象,包括数组之类的。【-Xmx,-Xms负责调节堆的大小】
实例对象生成后,将放在堆中,位置顺序是,Eden->S0<-->S1->老年代。
方法中的字符串都是直接放在这里
jdk1.7实现的方法区称为永久代,1.8则实现为元空间,本质上是与堆保存在一起,但概念上有所差别(jdk1.7合并给堆)【1.7之前,hotSpot称之为永久代】,如果需要调整该区域的内存大小,可用jvm命令【-XX:MaxMetaspaceSize=..】(这是1.8的命令,之前的版本估计也没人在乎)
堆的内存分配比例:
| 老年代 | 新生代 | ||
| 2/3 | 1/3 | ||
| Eden | S0 | S1 | |
| 80% | 10% | 10% | |
直接内存,这是不属于JVM的内存,主要由java的NIO方法操作,效率高。比如nio中有空拷贝的概念,就是利用一块直接内存存储需要放入磁盘中的数据,【因为磁盘中的对应的位置只需要被覆盖即可,因此不需要实际加载到内存中,因此只需要一个IO操作即可】
由于现在的系统以及程序要求多线程,导致需要为不同的线程分配特有的空间进行独立运行。于是,需要分出这样一块内存用于划分成多个独立的小块。
每个小块内部包含虚拟机栈、本地方法栈以及程序计数器。
虚拟机栈:本身是一个栈,内部保存的是进程的局部变量和临时结果。内部每个方法又有独立的栈帧。
栈帧内部包含:局部变量表,返回地址(方法结束位置),操作数栈,动态链接。
局部变量表中,引用类型保存为对应实例的地址。变量表由变量槽(slot)为单位计算大小。
操作数栈,用于执行方法对变量的计算操作。不过是将变量表中的数据进行入栈/出栈操作。(栈的运行深度有限制,否则产生StackOverFlowError,主要取决于给定的内存大小,可通过命令-Xss指定内存大小,IDEA中,可在运行配置中找到VM option选项填写命令,更多常用的配置命令可看一下阿里的文档)
动态链接,一些方法中的常量在运行时常量池或对应方法在元空间中的地址,或对应字符串的地址,用于调用。主要是hotSpot中的C++负责实现。
需要额外说明的是,运行时常量池是在元空间中,或称之为方法区。jdk1.6及之前,字符串常量池包含在运行时常量池,但之后,字符串常量池划分到堆中。【字符串的移动,本质是为了称为共享而节省空间】
若栈在运行期间需要扩展时,发现无法提供足够的空间,则会产生OutOfMemoryError。
因为方法中的常量在编译时是作为符号引用,因为没有正式落入内存中,就指定个符号说明这个就是那个常量
就是因为是常量,是不太会变动的,才给一个符号作为引用,其它的普通变量朝生夕死,以一通操作之后,就无人关心它的死活,自认没有要还专门给个符号标记
所以,其它那些普通变量,就放到了局部变量表中,包括方法中定义的对象实例。也是将对应的地址作为变量放入表中。
本地方法栈:这个栈实际与虚拟机栈是非常类似的,虽然给出了差别介绍,但实际的jvm将二者视为一体。只是,Java中有些内部的方法或可以自定义一些方法,它们的底层实现可以是其它语言,Java中有一些底层方法是由C/C++实现,并在方法前标记为native方法。包含这类native方法的将交由本地方法栈负责
程序计数器的值是由字节码执行引擎进行修改,毕竟就是它负责执行的。
【虚拟机栈和本地方法栈有时直接统称Java栈】
GC的内容的内容较多,内部包含很多机制和算法,但真正需要做的就是对利用回收器做调优。
首先,判断垃圾
这是一个非常老掉牙的知识点:
主要是,最初有一个引用计数法,当一个实例对象的身上带有的引用数量不为0,则代表它仍有用,则不视为垃圾,但实际上可以有垃圾抱团的现象;
实际使用的根可达性方法。
由于程序的运行通常从GC root作为入口进入,那么从这个入口开始,内部开始产生多个对象(这里我们姑且称之为main对象),而这些对象之后可能会使用另外一些对象,如调用一些外部方法之类的,总之,之后产生的所有对象都与这些main对象有着引用关系。
更具体一些,GC root可以是:
- 虚拟机栈引用的对象、
- 方法区中类静态属性引用的对象、
- 方法区中常量引用的对象、
- 本地方法栈中JNI(即通常所说的Native方法)引用的对象、
- Java虚拟机内部的引用、
- 所有被同步锁(synchronized关键字)持有的对象、
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
类似于Java类的引用类型静态变量,字符串常量池里的引用,基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器
另外,也会有其它可能的对象称为gc root。如
分代收集和局部回收(Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。
引用分类:
起死回生:通常一个对象和其它对象无关后,即开始作为一个垃圾存在,如果它的finalize()方法没有覆盖或没有调用,就直接放到F-Queue中,被JVM再标记一遍。
jvm内部保存着所有的对象,从main对象开始,按照引用关系,一步步向下搜索,只要被搜索到,即视为有用的对象,而其它对象自然是垃圾。
识别到垃圾对象后,需要做的就是回收垃圾
实际的垃圾回收,也是一个老掉牙的知识点了。
需要告知的是,由于回收垃圾可能会影响程序的运行效率,jvm并不是发现垃圾就回收,即使有一些方法表面意思是释放对象内存,也只是做个样子,只有在内存真的紧张时,才可能出发垃圾回收机制
垃圾回收器:
新生/老年代:在jdk1.8及之前,是做上述堆的划分的。经典回收器也是基于此作相应的设计。
空间大小占比,新生代:老年代=1:2
如果是新生代的容量不够了,则会触发Minor GC,也可以把它看作是新生代GC,对应的也有负责老年代的Old GC。另外还有G1回收器特有的Mixed GC。前面这些都属于Partial GC。如果整个JVM内存都告急,就会触发Full GC。
首先要注意,类产生的对象和作为数组的对象在创建机理上是不同的。类对象有类加载器一层层负责,而数组则直接由Bootstrap负责。 【在JVM规范中,指出了底层的实现中,类对象使用了常用的new,而数组则有自己的newarray等方法】
对象创建后,具体的存储流程如上图。
首先是,试图放在方法栈中,其次考虑TLAB,最后分配到熟悉的Eden,S0/S1,Old区。
下面简单介绍一下上述的一些过程细节:
为了提高效率,首先试图将对象放在栈中,这里的栈为方法栈。但首先不能过大,否则存不下;其次,由于对象是相应的方法产生的,改对象需要在此对象的控制之下,而不能被其它外部的变量调用,否则则发生了逃逸。
TLAB(Thread Local Allocation Buffer)【本地分配缓存区】,是线程在Eden区预留的一块内存,空间非常小。
如果对象够小,且有足够的空间,就直接放在这里。
Eden区是一个新对象的集中区,基本上新产生的实例都放在这里,而新的实例大多也短命。在系统触发了垃圾回收机制后,仍存活的实例就转移到S0或S1中。
S0/S1,是遵循复制清除的垃圾处理机制,S0/S1就是被一份为二的内存。每次触发了垃圾回收机制,双方就进行转移。
Old区,如果Old区发现自己可用的空间< Eden区中全部对象的和,
对象的定位,自然需要获取它在内存中的地址,而这个地址则放在Java栈的本地变量表中,但真正获取到对象,这里有两种方式:【为了得到完善的对象信息,我们一方面要获得对象在堆中的数据信息,另一方面要获得方法区中对象的类型信息】
由于hotspot底层为c++,需要创造一种数据结构来描述java类与对象的信息。 hotspot中对于类及其对象的描述使用OOP-Klass模型【OOP(Ordinary Object Pointer)描述实例信息,Klass描述类信息,是与对应java类相关的c++对等体】
OOP一定程度上包含了java对象的地址信息
Klass则是一个抽象基类,描述了对应Java类的信息
OOP-Klass导致了在hotspot的底层上,调用实例的过程实际上是先到达c++的对等体上,获得了Java对象的地址,再到达堆中实例的位置。
前面已经简单介绍了垃圾回收器的概况,但只是介绍了大致的思路,具体如何操作,则涉及到垃圾如何记录,以及安全地清除垃圾。
首先是垃圾回收的触发
由于假设不同对象的寿命有所区分可划分出了不同的区域,就有了分代收集。
当Eden区内存耗尽,则触发Minor Collection。当老年代已满,则触发Full Colleciton。
查找根节点【又称根节点枚举】,即我们在进行可达性分析首先需要的起始点。
由于线程是具有一个私有区域,私有区域中又包含了虚拟机栈,虚拟机栈中又包含了局部变量表,局部变量表中又包含了各种引用地址。Hotspot定义一个数据结构(Oop Map),表示对象内部各偏移量对应的数据类型,以及以及特定位置栈和寄存器中引用的位置。
通过Oop Map可快速地分析各个引用,但是不能使用过多,否则存储空间成本过大。因此,我们的特定位置就是设定一个安全点。
当到达安全点时,线程将不会继续向下执行,直到jvm完成当前的要做的步骤
但是,如果一个线程处于休眠状态,而没有进行适当的中断,可能会错过这样的安全点。因此需要额外引入安全区域
记忆集,前面提到了分代收集,例如收集新生代垃圾时,如何判断这些对象没有被老年代或方法栈中的对象引用着,就需要这样一个记录跨代引用的数据结构。【避免了从头开始遍历所有引用】
为了优化记忆集的存储和维护成本,需要考虑其记录精度,(字长精度,即处理器的寻址位数,直接记录跨代指针;对象精度,记录的对象中包含一个跨代指针的字段;卡精度,记录一块内存区域,内存中包含对象,对象又包含跨代指针)
前面已经提及理论当前Java自带的10种垃圾回收器,但其中一些并不是直接使用,而是根据分代收集而组合完成工作。其中G1,Shenandoah,ZGC是可以独立使用的,Epsilon稍微特殊。
上图中,相连的双方代表可以组合使用。(X JDK9 表示这种组合在JDK9及之后被废弃)
传统
Serial:单线程,且必须暂停其它工作线程【Stop The World (STW)】,主要应用与新生代。
ParNew【简称PN】:多线程并行版Serial
其中线程的数量默认是当前CPU的核心数,但是可以通过
-XX:ParallelGCThreads指定线程数量,但是没事就别随便改。从之前的图中可以看出,只有这个PN可以和CMS做组合,
- 因此一些JDK在
server模式下运行时,会首选这个ParNew
Parallel Scavenge【简称PS】:多线程的新生代收集器。可设定吞吐量相关的参数。
新生代复制算法,老年代标记-整理算法。
该收集器注重的是吞吐量,可使用自适应调节策略将内存管理的优化任务交给虚拟机负责。
server模式下的默默人收集器。
Serial Old【简称SO】:单线程,使用标记整理算法,主要用于老年代。
可作为CMS失败时的后备方案
Paralle Scavenge内部具有PS MarkSweep收集器进行老年代收集,这一收集器的实现本质上与SO相同。【JDK5及之前】,通常是PS与SO组合使用。
Parallel Old【简称PO】:Parallel Scavenge的老年代版本,多线程,标记整理算法。【JDK6伊始】。常常优先考虑PS-PO组合
CMS(Concurrent-Marking-Sweep):着重于低延迟,多线程并发,老年代,标记清除算法。
是HotSpot真正的并发收集器。
缺点:
- 对CPU资源敏感
- 无法处理浮动垃圾
- 它的标记清除算法仍会产生大量碎片
- 执行不确定。可能出现上一次的垃圾回收还没结束,这一次的已经开始了。
默认的回收线程数为
核心数较少时,反而影响实际程序性能。
- 增量式并发收集器:(JDK7就已经不提倡)效果一般,类似多任务抢占式运行,利用有限的线程交替执行不同任务,运行反而更慢。
CMS激活,JDK5默认老年代空间到达68%,JDK9默认92%。老年代必须预留一定空间共回收进程使用。否则将并发失败,需冻结用户进程,并启用Serial Old。
G1(Garbage-First):JDK9及之后的默认。一定程度上是CMS的继承者。可收集堆内存任意区域。
面向服务器场景。针对多处理器和大容量内存机器,具备高吞吐量,能极大满足GC停顿时间的要求。
Region:G1将Java的堆划分为多个独立等大的区域【是垃圾回收的最小单位】,让这些区域根据需要充当Eden或老年代等角色,因此角色也是可以随时变化的【实际仍然是分代收集算法】。数量最多有2048个。
Humongous:存储大对象【标准:超过Region容量的一半】,超过整个Region容量的对象为超级大对象,将存放在多个Humongous中。
Region大小可通过-XX:G1HeapRegionSize设置,范围为1MB~32MB,2的次幂。
不过一般是计算.
G1中已没有传统的新生代和老年代。而是将所有的Region区域按照回收空间大小以及回收时间设定优先级并进行排列,有效地提高了垃圾回收的效率。【对应的概念还是有的】
默认年轻代在堆中的占比是5%,
Region也需要维护自己的卡表,G1的卡表同时记录了两种形式,我被谁引用了以及我引用了谁
TAMS(Top at Mark Start),Region中包含两个TAMS(pre,next),pre对应的是在垃圾回收时,该区域内上次扫描开始的位置,next代表这次扫描开始的位置。【则区域内处于TAMS另一侧的】
G1的大致操作如下:
Mixed GC 是 G1 中特有的概念。一旦老年代占据堆内存的 45%,就要触发 Mixed GC,此时对年轻代和老年代都会进行回收。
-XX:InitiatingHeapOccupancyPercent:设置触发标记周期的 Java 堆占用率阈值,默认值是 45%。这里的Java 堆占比指的是 non_young_capacity_bytes,也就是非新生代的空间,包括 old + humongous
低延迟
ZGC:基于Page或ZPage(相当于Region)的堆内存布局,但这里的Region根据动态性,可动态创建和销毁,并且有不同型号的容量。采用了染色指针技术进行标记。
shenandoah:目前,这一回收器似乎还是只有OpenJDK支持,而Oracle是故意反对的【不是sun自己做的】。
从代码上看是对G1的继承者。均使用Region概念,以及相同的回收策略。不同点主要在于:
步骤阶段为:初始标记、并发标记、最终标记、并发清理、并发回收、初始引用更新、最终引用更新、并发清理
Epsilon
class文件包含的主要内容有:
魔数,(副版本号,主版本号),(常量池计数器,常量池),
(访问标志,类索引,父类索引,接口计数器,接口表),
(字段计数器,字段表),(方法计数器,方法表),(属性计数器,属性表)。
关于class文件的内部信息,非常繁多,以至于java虚拟机规范专门列出一章讨论,更具体的细节,读者可查看规范,这里仅阐述整体的框架。
魔数(magic):class文件的头4个字节。仅用于确定是否为一个class文件。【不同文件都具有这样的魔数,Java从class对应的魔数值为“0xCAFEBABE"(咖啡宝贝)】
版本号:魔数之后的2个字节为副版本后,再之后的2个字节为主版本号。主版本号从45开始计算。读者可将自己的class文件读取为16进制【推荐用sublime text打开】,不仅可以发现前面的魔数,也会发现后面对应的版本号,我这里使用的是Java11编译的结果:
cafe babe 0000 0037
主版本号对应的16进制的37,转换后为10进制的55。副版本号在JDK1.2~JDK12,都固定为0。
常量池计数器:版本号之后的两个字节用于存放常量池的项数,即对应的容量。假如对应的容量值是0x37,即55,但实际容量大小是54,且项数的索引值是1~54。索引值0是作为特殊存在。
常量池:常量池计数器之后便是记录常量池中的各个量。包括如字符串,final常量值的字面量,也包含如全限定名,方法名及其描述符的符号引用(因为java的类是在加载后才知道对应的内存地址,因此符号引用不是指针)
常量池中的每一个项都是一组包含多个量的组合,规范中称其为表。
tag,标记类型,判断当前的表是何种的字面量,又或是何种的引用访问标志:常量池结束之后,就是2个字节的访问标志,用于识别接口或类的访问信息。
类索引,父类索引:均为2个字节。访问标志后便是类索引,用于确定对应的全限定名称,类中除了java.lang.Object都具有父类索引。
接口计数器,接口表:在父类 索引之后就是接口计数器,记录该类实现的接口数量,2个字节。紧随其后,是一组接口索引的集合,索引各自占据2个字节。若未实现接口,则接口计数器为0,不存在接口表。
计数器均占据2个字节
下述表中,均会对各个元素做详细 的描述,如方法的访问权限等,总之将代码中的各种信息转化到表中对应符号标志
前文介绍的双亲委派机制,这一机制是jdk1.2之后引入的推荐方法,而不是强制的,具体的实现代码在java.lang.ClassLoader中的loadClass()方法中。
初始化,主要是处理其中的静态变量和静态代码块
这里需要注意的是其中的初始化问题,何时需要真正初始化一个类,首先必须有一个类的情况下则必须初始化,如:
【特别的例子说明】
如果B继承了A,且二者内部均有各自的静态代码,例如A中有静态变量a,B中有静态语句输出。如果调用了B.a,此时并不会返回B中的静态输出,即没有实现B的初始化(但有可能完成了加载、验证)。
如果我们定义了一个B类型的数组,实际也还是不会有所输出。我们当前只是完成了数组的分配,相当于盖房子,入住手续后面可以进行,没那么急。而且,这个定义的数组本质上也是一个类,是一个继承自Object由JVM自动生成的关于B的类,内部包装了数组操作。
类似地,如果A中的静态变量是一个常量,那么调用A.a时,A是不会初始化的。因为在编译期间,这些常量已提前放到了常量池中。此时这个变量和A有关系,但又不完全有关系。
前一节提前说了一下类的初始化,但这一切的开头都需要类的加载。
其中的字节流可以通过一切手段获得。例如网络、jar包、数据库等。
其中的jar包读取,在对应的位置字符串中指明对应的协议和jar包位置,再接上“!/"表明读取包内的文件,之后就是接上对应jar包内文件的位置。如”jar:file:\\【jar包位置】!/【文件位置】“
前文中提及了如果有需要的话,可以自定义类加载器。现在,我们就可以继承URLClassLoader,再覆盖对应的类加载方法,包括从哪里读取类文件(其中,就可以用上述jar包读取的方式从自定义的jar包中读取自己的class文件并加载到内存中)。
如果我们有多个类似的jar包,但是内部class文件的代码逻辑有所差别【类似于人类的双标行为】,此时就可以通过自定义的类加载器,视情况加载不同的jar包。
【需要注意数组】
需要说明一下数组的组件类型,即数组去掉一个维度后的类型,如String[] 组件类型就是String,而String[][] [ ][ ]的组件类型就是String[]。
首先,数组不同于普通java对象,它是直接由JVM创建 ,而不是类加载器完成。就如前面的【特别的例子说明】中提及的,数组是被JVM直接创建了一个对应的类。
如果组件类型是String这类的非引用型的,那么就直接交给BootstrapClassLoader进行创建。
否则,就仍然是个数组的引用,
java8的虚拟机规范中介绍是:此时数组被标记为已由组件类型的定义类加载器定义。
对象在堆内存的存储布局为:对象头(header),实例数据(Instance Data),对齐填充(Padding)。
对齐填充是保证对象的大小为8字节的倍数。这与CPU的缓存行有关,使得每次加载后都能立刻被CPU刷新执行,提高效率。【现在一些CPU的缓存行已经较大,如64字节,为了提高效率,一些项目会在对象中故意加若干个long或其它类型的变量以进行主动填充】
其中对象头中包含了mark word、元数据指针,若为数组则另外有一个数组长度。这些元素的大小均与对应JVM的位数有关,例如64位的JVM,则均为64bit。
元数据指针(klass word)则是用于指向方法区中目标类的类型信息,也就是说通过元数据指针可以准确定位到当前对象的具体目标类型。
mark word中可以包含大量信息,如锁状态,GC年龄。且占用的空间位数与相应的CPU处理位数相同。
32位情况:
| 锁状态 | 25bit | 4bit | 1bit | 2bit | |
|---|---|---|---|---|---|
| 23bit | 2bit | 是否偏向锁 | 锁标志位 | ||
| 无锁状态 | 对象的hashcode | 分代年龄 | 0 | 01 | |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
| 重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | |||
| GC标记 | 空 | 11 | |||
| 偏向锁 | 线程ID | Epoch | 分代年龄 | 1 | 01 |
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的实现方式是:
通过前面填充对齐的了解,我们知道JVM中存储的对象大小均是8字节的倍数,因此我们在定位对象时,不需要考虑对象中间的7个字节,引用的地址只需要是8的倍数就可以完成工作。
在64位的系统中,对象的地址仍然使用32位表示,但是当引用实际被使用时,即进入了寄存器时,则左移3位,变成了35位,可定位32GB的内存空间。
因此当堆内存大小超过32GB,就不能使用压缩指针来工作了。
而且当堆的大小仅仅略大于32GB,由于指针扩大为两倍,需要消耗更多的空间,可能导致实际能用的空间不如32GB的情况。
在实际使用中,我们当前线程的类加载默认为AppClassloader。如果我们自定义了一个类加载器从外部的jar包或其它途径读取了一组字节流加载为对应的类,由于当前默认的类加载器对这个类是不做负责的,导致我们无法直接的在代码中用普通的类形式调用相关的方法,只能通过类的newInstance()方法生成一个实例,而实例也只能通过getMethod()方法调用指定的方法,invoke读取结果中的对应数据。
可使用当前线程即Thread.currentThread().setContextClassloader()来设置当前需要的类加载器,此时就可以像正常操作那样调用类并使用,但最后作为临时操作【可放在某个方法中,临时改变方便自己的操作,最后该还原】。
下面是摘自官方文档的一些介绍:
支持并发加载类的类加载器称为并行能力类加载器,需要通过调用ClassLoader.registerAsParallelCapable方法在其类初始化时间注册自身【默认情况下,
ClassLoader类注册为并行】(它的子类仍然需要注册自己,如果它们是并行的能力)。 在委托模式不是严格层次化的环境中,类加载器需要并行,否则加载类可能导致死锁,因为加载程序锁定在类加载过程中保持(参见loadClass方法)。方法
defineClass将字节数组转换为类别类的实例。 这个新定义的类的实例可以使用Class.newInstance创建。类加载器创建的对象的方法和构造函数可以引用其他类。 要确定所引用的类,Java虚拟机调用最初创建该类的类加载器的
loadClass方法。网络类加载器子类必须定义从网络加载类的方法
findClass和loadClassData。 一旦下载构成类的字节,它应该使用方法defineClass创建一个类实例。
上述的官方描述,已经说明了一个类加载中主要的方法,因此我们在自定义时只需要覆盖这些方法即可,主要的步骤包括:
大致的效果是:
自定义一个类加载器,
public class MyClassLoader extends ClassLoader{ //省略 protected Class<?> findClass(String name) throws ClassNotFoundException { //这里如果继承自URLClassLoader,可以使用url读取网络或本地位置的文件 File file = new File("目录"+文件name); try{ byte[] bytes = getMyClassBytes(file);//读取字节流,可自定义读取方法 Class<?> c = this.defineClass(name, bytes, 0, bytes.length)//生成类 return c; } catch (Exception e) { e.printStackTrace(); } //省略 }}调用类加载器,
MyClassLoader myClassLoader = new MyClassLoader(); //我们的类加载器Class<?> clazz = Class.forName("类名", true, myClassLoader); Object obj = clazz.newInstance();//生成一个原始的类对象//因为默认发类加载器中并不负责这个类,因此一旦脱离的自定义的类加载器,无法给他一个合适的名分,只能作为纯粹的对象,不能直接调用它原有的各种技能另外,如果定义的类的全限定名与上层的类加载器有冲突,不希望被双亲委派机制交由上级处理,可以覆盖loadClass方法指明当对象为null时,由自定义的加载器负责创建。
protected Class<?> loadClass(String name, boolean resolve)//name为类的全限定名 throws ClassNotFoundException{ synchronized (getClassLoadingLock(name)) { Class<?> c = findLoadedClass(name);//查看缓存中是否已存在对应的类 if (c == null) { /*try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { e.printStackTrace(); }*/ //上述为常用的双亲委派机制 //我们这里不希望委派 if (c == null) { //直接调用自己的方法生成类 c = findClass(name); } } //略 return c; }}----------------------------------------【疲倦的话,稍微缓一缓吧!】----------------------------------
在获得了类的字节流后,首先需要判断这是不是一个合格的类文件。
验证操作主要有:格式验证、语义分析、操作验证,以及符号引用验证等。
格式验证主要检查字节码文件的字节流是否是符合class文件的格式要求。
语义验证是在上述验证结束后,字节流加载到方法区后执行。主要检验是否符合语法
操作验证则是对类的方法审核其不会导致崩溃等问题
符号引用验证,主要是对常量池中的各种符号引用执行验证(这一段实际是发生在解析阶段)【将常量池中所有的符号引用全部转换为直接引用】
由于Java生成的class文件非常方便就可以被反编译,一些重要的逻辑如果不希望被人看见,则必须要对class文件进行加密。当然,如果只是想稍微隐藏一下,也可利用前面提及的自定义类加载,让自己的class字节流隐藏在某个地方的某个文件中。【常见的反编译工具为jd-gui,当然Idea肯定也可以,VsCode安装个插件也没问题】
下面的内容主要来源于书本《Java虚拟机精讲》的7.3节。
首先,加密算法主要有对称加密和非对称加密。在需要双方互通信息时,主要需要非对称加密,用户将自己的公钥传输过去,对方利用公钥加密自己的内容,用户可以使用私钥解读。而对称加密则使用单一的密钥加密和解密,在传输加密信息时,必须双方均持有密钥,导致密钥分发过程造成不安全。但对于本地使用的文件而言,对称加密就足够了。【更多密码学知识,可参考《图解密码技术》】
该书中则使用了对称加密的3DES算法。
加密过程为:C=Ek3( Dk2( Ek1( P ) ) )。
解密过程为:P=Dk1( EK2( Dk3( C ) ) )。
Ek()和 Dk()代表 DES 算法的加密和解密过程,K 代表 DES 算法使用的密钥,P 代表明文,C 代表密文。
具体的代码实现如下:
import javax.crypto.SecretKey;import javax.crypto.Cipher;import javax.crypto.spec.SecretKeySpec;public class Use3DES { private static final String ALGORITHM = "DESede"; // 定义加密算法, //-------------加密----------------------------- public static byte[] encrypt(byte[] key, byte[] src) { byte[] value = null; try { SecretKey deskey = new SecretKeySpec(key, ALGORITHM);// 生成秘钥 key Cipher cipher = Cipher.getInstance(ALGORITHM);//对目标数据执行加密操作 cipher.init(Cipher.ENCRYPT_MODE, deskey); value = cipher.doFinal(src); } catch (Exception e) { e.printStackTrace(); } return value; } //--------------解密----------------------------- public static byte[] decrypt(byte[] key, byte[] src) { byte[] value = null; try { SecretKey deskey = new SecretKeySpec(key, ALGORITHM);/* 生成秘钥 key */ /* 对目标数据执行解密操作 */ Cipher cipher = Cipher.getInstance(ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, deskey); value = cipher.doFinal(src); } catch (Exception e) { e.printStackTrace(); } return value; } public static void main(String[] args) { try { byte[] key = "01234567899876543210abcd".getBytes(); byte[] encoded = encrypt(key, "测试数据...".getBytes("utf-8")); System.out.println("加密后的数据->" + new String(encoded)); System.out.println("解密后的数据->" + new String(decrypt(key, encoded), "utf-8")); } catch (Exception e) { e.printStackTrace(); } }}上述代码的运行结果是:
加密后的数据->P湻9浪k0肟 解密后的数据->测试数据...
类似地,我们可以对class文件的字节流做同样的加密和解密操作,具体的代码放在附录。
此类情况下,由于面临当前操作可能会被其它进程或线程修改,因此重点在于使用锁技术,以及如何维护当前的工作。
根据前文的介绍,我们知道对象的创建主要的工作有:可能需要先进行类加载,再判断对象可以存放的位置,划分出指定的内存空间后,再加载自己的各种变量和方法,最后将各种引用关联起来,得到一个可以使用的对象。当然,最后需要将对象是引用与对应的对象名关联起来。
其中,在划分内存空间的时候,如果其它线程或进程也在试图使用内存,则会导致内存分配失败。例如Eden区使用的指针碰撞技术。
对象名与实例连接:
在单例模式下,我们只需要创建一个对象并重复使用即可,但是本节的情况下,如何保证仅创建了一个对象。
大致的代码如下:
public class T { private static T t=null;//准备好对象名 //外部有大量的线程准备使用里面的对象 //但此时还没创建出来 if(t==null){//此时还是没有实例 synchronized (T.class){//锁定,并进行创建 if (t==null){ t=new T(); } } }}上述流程为DCL(双重校验锁机制),首先评估是否需要创建,避免直接进行锁竞争,获得锁之后,再次确认,避免重复创建。
但这里存在一个问题,JVM会进行指令重排,在对象创建期间,应该是完成对象的彻底构建,才会将对象的引用交给对象名。但是指令重排,可能会提前将引用赋予对象名,导致其它线程发现已有对象并使用了不完整的实例。
为此,需要额外加上volatile指令,即
private static volatile T t=null;以禁止上述的指令重排序。
valatile功能的实现则来自内存屏障:
| 屏障类型 | 指令示例 | 说明 |
|---|---|---|
| LoadLoad | Load1;LoadLoad;Load2 | 确保Load1数据的装载先于Load2及其后所有装载指令的的操作 |
| StoreStore | Store1;StoreStore;Store2 | 确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作 |
| LoadStore | Load1;LoadStore;Store2 | 确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作 |
| StoreLoad | Store1;StoreLoad;Load2 | 确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令 |
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。在每个volatile写操作的前面插入一个StoreStore屏障,确保前面的写操作完成。在每个volatile写操作的后面插入一个StoreLoad屏障,确保自己的写操作完成。
此外为了实现单例模式的安全性,可以使用holder模式,即插入静态内部类,以避免竞争。
public class Singleton { /** * 类级内部类,也就是静态的成员内部类,该内部类的实例与外部类的实例没有绑定关系 * 只有被调用的时候才会装载,从而实现了延迟加载,即懒汉式 */ private Singleton() { } private static class SingletonHolder { /** * 静态初始化器,由JVM来保证线程安全 */ public static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return SingletonHolder.INSTANCE; }}
垃圾回收器在CMS之后,都开始了并发标记,即在用户线程运行期间对系统中的对象实例进行垃圾标记。这期间可能发生某些垃圾被重新引用,因此又需要进行结果修正。
而如何与用户线程共存且完成标记的任务则包含了许多思考。
卡表:前文中就提及了卡表是维护区域之间引用关系的。但如何在对象被引用后,就将对应的卡表标记为变脏,就需要写屏障来工作。
写屏障其实就是类似spring中的AOP编程(面向切面编程)。引用对象是切点,写屏障就是在这个切点前后增加语句执行额外的操作,以此完成卡表的标记工作。
-XX:+UseCondCardMark命令可开启有条件的写屏障,即对应的卡页已经是脏的,就不去再标记了。
伪共享,还是关于CPU缓存行的问题,如果多个卡表元素落入同一个缓存行中,不同的线程修改各自的元素,线程之间需要频繁第争夺缓存行的使用权。通过有条件的写屏障一定程度上避免了这种争夺。
在利用卡表标记了对象之间存在引用关系之后,借助卡表就可以快速地对引用链进行遍历。但由于此时用户进程也在继续,则需要三色标记来判断对象的存活状态。
三色标记
扫描是自上而下不断递归深入地进行。利用不同颜色标记对应的对象状况,如果是黑色,则认为上下均完成扫描,不会再从这个对象开始递归扫描。灰色代表自己被扫描,但是自己引用的对象未全部完成扫描。白色即代表那些未被扫描过的,最后指示为不可达,即被当作是垃圾对象。
若同时发生了上图中的状况,最后都会导致某些存活的对象被视为垃圾。
为避免对象误判为垃圾:
CMS:采用增量更新
G1:采用原始快照(STAB)
上述两种方式,读者会发现,都发生了一旦引用变化就会立即记录,这就是再次利用了写屏障的技术。
作为一个Web应用,Tomcat需要管理多个不同的网站,即对应的不同的jar/war包,导致Tomcat内部包含了多个应用类库。常规的委派机制无法解决类与类的全限定名的冲突,故,Tomcat内部支持委派机制,但有自己的一套加载系统。
Tomcat的类加载器关系如下:【版本为Tomcat7之后】
其中,WebApp0和WebApp1是随着用户自定的jar包而创建,用于加载对应目录下所有class,资源,jar文件,即负责加载指定Web的资源,且只对该Web可见。
下面是关于上图中各类加载器的解释,具体细节可查看官方文档
JAVA_HOME/jre/lib/ext下的所有jar包中的类。Tomcat中类的实际加载顺序,需要考虑类加载器的delegate属性值,【默认false】(通过在对应的conf/Context.xml中<Loader delegate="true" /> 进行修改。
将依此从下述位置中查找类或资源,
false
true,此时将使用java默认的委派机制
由于这里的类初次加载时无论怎么样,都需要先经过BootstrapClassLoader(且,这里包含了也包含了扩展类),因此恶意创建的JDK基类,是无法加载的。
这部分就非常简单了,就是动态代理,既包含了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来 处理。
import java.io.FilterInputStream;import java.lang.ClassLoader;import java.lang.ClassNotFoundException;import java.io.BufferedInputStream;import java.io.IOException;import java.io.BufferedOutputStream;import java.io.FileOutputStream;public class MyClassLoader extends ClassLoader { private String byteCode_Path; private byte[] key; public MyClassLoader(String byteCode_Path, byte[] key) { this.byteCode_Path = byteCode_Path;this.key = key;} protected Class<?> findClass(String className) throws ClassNotFoundException { byte value[] = null; BufferedInputStream in = null; try { in = new BufferedInputStream(new FileInputStream(byteCode_Path + className + ".class")); value = new byte[in.available()]; in.read(value); } catch (IOException e) {e.printStackTrace();} finally {if (null != in) { try {in.close();} catch (IOException e) {e.printStackTrace();}} } value = Use3DES.decrypt(key, value);//解密,自定义的关于3DES类 return defineClass(value, 0, value.length);//将 byte 数组转换为一个类的 Class 对象实例 } public static void main(String[] args) { BufferedInputStream in = null; try { in = new BufferedInputStream(new FileInputStream("class文件地址")); byte[] src = new byte[in.available()]; in.read(src); in.close(); byte[] key = "01234567899876543210abcd".getBytes();//设置密钥 BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream("加密后的文件地址")); out.write(Use3DES.encrypt(key, src));//解密并输出 out.close(); MyClassLoader classLoader = new MyClassLoader("文件上级目录",key); System.out.println(classLoader.loadClass("对应的类名").getClassLoader(). getClass().getName()); } catch (Exception e) { e.printStackTrace(); } }}使用命令 jvisualvm ,开启jdk自带的调优工具。
开启的页面内,将包含我们启动的所有java进程,可以在其中查看新生代和老年代以及元空间的空间使用情况。