JVM
虚拟机
文章目录
- `JVM`虚拟机
- 1. Java内存区域
- 2. 虚拟机的对象揭秘
- 3. 垃圾收集器和内存分配策略
- 4. 虚拟机类加载机制
- 5. `JVM`中调优常用的参数配置
- 引用
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/74e0cbb419c02f8ee5c66a7e4ab421be.png)
class字节码文件
JVM
> 字节码 ==>本地机器码
1. Java内存区域
Java
虚拟机在执行java
程序地过程中会把它所管理地内存划分为若干个不同地数据区域。
1.1 程序计数器(线程私有)
程序计数器又叫PC寄存器
程序计数器可以看作是当前线程所执行的字节码的行号指示器。每个线程之间的计数器独立存储,互不影响。
- 字节码解释器就是通过行号指示器中的值,来选取下一条需要执行的字节码指令。
- 分支、循环、跳转、异常处理、线程恢复都要通过程序计数器来完成。
如果当前线程正在执行的是一个Java
方法,那么程序计数器记录的就是虚拟机正在执行的虚拟机字节码指令的地址,如果执行的是本地方法,这个计数器的值应该是空 (undefined
)
1.2 Java虚拟机栈(线程私有)
虚拟机栈描述的是Java方法
执行的内存模型。每个方法在执行时都会在内存区域内(虚拟机栈)创建一个栈帧。虚拟机栈的生命周期和线程的生命周期相同。
栈帧:(存储局部变量表、操作数栈、动态链接、方法出口等信息),每一个方法被调用直至执行完毕的过程,就对应着栈帧在虚拟机栈中入栈到出栈的过程;
1.2.1 栈帧:局部变量表
局部变量表中存储了编译期可知的各种Java
虚拟机基本数据类型、对象引用和 returnAddress
类型。
基本数据类型:
java
的八大基础数据类型:byte、short、int、long、float、double、boolean、char
对象引用:reference
类型, 对象引用不等同对象的本身,可能是执行对象起始地址的引用指针,也有可能是指向一个代表对象的句柄或者其他与此对象相关的位置。(reference
类型的访问定位有多种方式,由虚拟机决定。主流的对象访问方式有使用句柄和直接两种。对象的访问定位此处不做详解,详情信息见下文 “对象的访问定位” )
returnAddress
:指向一个字节码指令地址。
1.2.2 栈帧:操作数栈
主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
1.2.3 栈帧:动态链接
动态链接主要就是指向运行时常量池的方法引用,在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference )保存在class文件的常量池里。比如,描述一个方法调用其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
符号引用转换为调用方法的直接引用:符号引用如果指向一个接口的方法,这时候如果没有这个指向常量池的这个引用,调用时就无法知道接口的实现方法是哪个,这时候不能动态链接,也就是不能达成符号引用==>直接引用。
1.2.4 栈帧:方法出口
指的是该方法在PC寄存器中的值。(该方法的指令地址)
方法退出的方式:
- 正常退出:有返回值,返回。
- 异常退出:如果方法在执行的过程中遇到了异常,并且这个异常没有在方法中进行处理(方法的异常表中没有找到匹配的异常处理器)就会导致方法退出,且不会给上层调用者产生任何的返回值。
1.3 本地方法栈(线程共有)
虚拟机栈为执行Java方法
服务,本地方法栈为虚拟机使用本地库中的本地方法
服务。
1.4 堆(线程共有)
堆是在虚拟机启动时创建,用来存放对象的实例。Java堆可以处于物理上不连续的内存空间中。“所有的对象实例和数组都应该在堆上分配内存”。
类是对象的模板,对象是类的实例(new)
从内存分配的角度来看,堆中的空间并非全是内存共享的,堆中还可以划分出一部分线程的私有的分配缓冲区 TLAB(thread local allocation buffer)
,以提升对象分配时的效率。
java堆的参数设定:
-Xmx:最大堆空间内存(默认为物理内存的1/4)
-Xms:初始堆空间内存(默认为物理内存的1/64)
-Xmn:设置新生代的大小。(初始值及最大值)
-XX:NewRatio:配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio:设置新生代中 Eden 和 S0/S1 空间的比例
-XX:HandlePromotionFalilure:是否设置空间分配担保
1.5 方法区(线程共有)
方法区用来存储被虚拟机加载的类型信息、常量(final
)、静态变量(static
)、即时编译器编译后的代码缓存等数据,运行时常量池是方法区的一部分。
类型信息:
- 类型的完整有效名
- 直接父类的完整有效名(除非是
Interface
或Object
,两者都没有父类)- 类型的修饰符(
public、private、default、protect ,abstract,final
等)
不同JDK
版本中,方法区的迭代
方法区是抽象,元空间和永久代是具体实现
在JDK 6
的时候,HotSpot
开发团队就有放弃永久代,逐步改为采用本地内存来实现方法区的计划了。到了JDK 7
的Hotspot
。已经把原来放在永久代的字符串常量池、静态变量等移至 Java
堆中了,到了JDK 8
中,完全废弃了永久代的概念,改用本地内存中实现的元空间来代替,把JDK7
中永久代还剩余的内容(主要是类型信息)全部移到元空间中。
1.5.1 字符串常量池
字符串常量池 JDK1.7
之前存放在方法区中(永久代),1.7之后
字符串常量池存放在堆中。
方法区中的内存回收
并非只有堆内存中才有垃圾回收,方法区中同样也存在有内存的回收利用。方法区中内存回收的目标主要是针对常量池的回收和对类型的卸载。
1.5.2 方法区中的运行时常量池
Class
文件中除了有类的版本、字段、方法、接口等描述信息,还有一项信息是常量池表。(Class文件的详解见后文)常量池表用来存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
字面量:被声明为final的常量值,文本字符串(1.文本字符串 2.八种基本类型的值 3.被声明为final的常量等;)
符号引用包括:1.类和方法的全限定名 2.字段的名称和描述符 3.方法的名称和描述符。
运行池常量池和常量池表的差异
1. 运行时常量池时方法区的内容,常量池表是class文件中的信息
2. 运行时常量池和常量池表相比具有动态性。Java语言规定常量并非一定是编译期内产生,也就是说并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入运行时常量池。比如String类的 intern()方法
1.6 直接内存
直接内存不是虚拟机运行时数据区的一部分,也不是《java
虚拟机规范》中定义的内存区域。
直接内存 = 服务器总内存 - 运行时内存
2. 虚拟机的对象揭秘
Java
堆中对象分配、布局和访问的全过程;
2.1 对象的创建过程
当Java虚拟机遇到一条字节码new
指令时,
-
首先去检查这个指令的参数能否在常量池中定位到一个符号引用。并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,就进行相关的
类加载过程
。(类加载过程详情见后文)在类检查通过后, -
接下来虚拟机将为新生对象分配内存。
对象内存的分配任务就相当于把一块确定大小的内存块从
java
堆中分配出来(对象所需的内存在类加载完成后即可完全确定)
内存分配的两种模式:- 指针碰撞(
bump the pointer
):指针向空闲方向挪动一段与对象内存大小相等的距离 - 空闲列表(
Free list
):虚拟机维护一个空闲列表,记录哪些内存块时可用的。在分配内存时,从列表中找出一块足够大的空间划分给对象实例,并更新列表上的记录。
内存分配的两种模式是由Java堆
是否规整决定的,而Java堆
上的内存是否规整是由垃圾回收器决定。serial
、parnew
这种带压缩整理过程的收集器采用的时指针碰撞的方式,CMS
这种清除算法的收集器,采用的是空闲列表的方式。
- 指针碰撞(
-
内存分配完成后,进行对象头设置
虚拟机内存分配 ☞ 初始化零值设置 ☞ 对象头设置
虚拟机在对象内存分配完成之后,会对分配到的内存空间进行初始化零值的设定。(如果使用了TLAB
,那么初始化零值的工作也会提前至TLAB
分配时顺便进行)
对象头设置:初始化零值设置完成后,虚拟机还需要对对象进行必要的设置,例如这个对象头(对象是那个实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄、是否启用偏向锁。。。这些信息都是存放在对象的对象头中)
在对象头设置都完成之后,在虚拟机的视角来看,一个新的对象已经产生。但是从Java执行的角度来讲,对象的创建才刚刚开始(才执行完构造函数)。
Class文件中的init
方法还没有执行。
2.1.1 并发情况下的内存分配问题:
对象在虚拟机中的对象创建行为是非常频繁的,仅仅就是修改指针指向的位置,在并发情况下也不是内存安全的。(可能会出现在给A分配内存的情况下,指针还没有来得及修改,对象B又同时使用了原来的指针来给对象分配内存的情况)
虚拟机处理并发内存分配的两种方式:
1. CAS配失败重试的方式:对分配内存空间的动作做同步处理,采用CAS配失败重试机制保障更新操作的原子性。
2. TLAB方式:将内存分配的工作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,称为TLAB(thread local allocation buffer)本地线程分配缓冲。哪个线程要分配内存,就在本地缓冲区进行内存分配,只有当缓冲区的内存容量分配完了,需要分配新的缓存区时,才会进行同步锁定。
虚拟机是否使用TLAB,采用参数:-XX:+/-UserTLAB 来设定。
2.2 对象的内存布局
对象在堆内存中的存储布局可以划分为三个部分:对象头,实例数据,对齐填充
2.2.1 对象头
对象头部分包括两类信息。
- 存储对象自身的运行时数据,官方称为
Mark Word
(哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳) - 类型指针:对象指向它的类型元数据的指针,
Java
虚拟机通过这个指针来确定该对象是哪个类的实例。
mark word:这部分数据的长度在32位和64位的虚拟机中分别是32个比特和64个比特,由于mark word 是对象自身定义的数据无关的额外成本,所以为了在极小的空间内存储尽量多的数据。mark word 被设计成一个有着动态定义的数据结构。
2.2.2 实例数据
实例数据:对象真正存储的有效信息。(我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来)
2.2.3 对象填充
对象填充:这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。HotSpot
虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节
的整数倍,换句话说就是对象的大小必须是 8 字节
的整数倍。对象头已经被精心设计为 8 字节
的倍数。所以实例数据中没有对齐的话,就需要通过对齐填充来补全。
2.3 对象的访问定位
Java程序会通过栈上的 reference
数据来操作堆上的具体对象。reference
类型在《Java虚拟机规范》里面规定了它是一个指向对象的引用。具体通过什么方式来实现访问定位,通过虚拟机实现而定,主流的访问定位有句柄
和直接指针
两种
- 句柄访问:通过句柄访问,Java堆中可能会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自具体的地址信息
- 直接指针访问:Java堆中对象的内存布局需要考虑如何访问类型数据的相关信息,reference 中存储的直接就是对象地址,不需要多一次间接访问的开销。
2.3.1 句柄定位和直接指针定位的比较
两种对象定位方式的比较:
句柄的优势:reference
类型中存储的是稳定的句柄地址,在对象被移动时值会改变句柄中的示例数据指针,reference
本身不需要被修改。
直接访问:访问速度更快,因为它减少了一次指针定位时间的开销。
3. 垃圾收集器和内存分配策略
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
3.1 对象已死?(垃圾标记算法)
3.1.1 引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加一;当引用失效时,计数器就减一。当计数器为零的对象即表示不被使用的对象。
引用计数法会存在循环引用的问题,导致垃圾对象无法被回收。
如下面代码所示:除了对象ObjA
和ObjB
互相引用着对方之外,这两个对象之间再无任何引用。但是因为他们互相引用对方,导致他们的引用计数器都不为0,于是引用计数算法无法通知GC
回收他们
public class ReferenceCountingGc {
Object instance = null;
public static void main(String[] args) {
ReferenceCountingGc objA = new ReferenceCountingGc();
ReferenceCountingGc objB = new ReferenceCountingGc();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
}
}
3.1.2 可达性分析算法
将GCRoots
对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象标记为非垃圾对象,其余未标记的对象都是垃圾对象。
根节点枚举的过程中需要
STW
。Q:为什么可达性分析的过程中会造成
STW
?A:可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能进行分析,意味着必须全程冻结用户线程的运行。
GC Roots
根节点:
- 虚拟机栈中引用的对象。譬如当前正在运行的方法所使用到的参数、局部变量、临时变量。
- 静态变量
- 常量(字符串常量池里的引用)
- native方法引用的对象
- 同步锁持有的对象
- 等等
3.2 对象已死的生存和自救
finalize()
方法最终判断对象消亡还是自救
在可达性分析算法中标记为不可达的对象,也并非是“非死不可”的,这个时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,最多会经历两次标记过程:
-
当对象在进行可达性分析法后发现没有与
GC Roots
相连接的引用链,此时会进行第一次标记,随后进行一次筛选,筛选的条件时此对象有没有必要执行finalize()
方法。当对象没有覆盖
finalize()
方法或者finalize()
方法已经被虚拟机调用过,这种就是没有必要执行finalize()
的。 -
如果对象被判定为有必要执行
finalize()
方法,那么该对象会被放置在一个F-Queue
的队列中。稍后虚拟机会建立一个低优先级的线程finalizer
去执行它们的finalize()
方法。finalize()
方法是对象逃脱死亡命运的最后一次机会,收集器会对F-Queue
队列中的对象进行第二次小规模的标记,如果对象要在finalize()
中拯救自己,只需要重新与引用链上的任何一个对象建立关联即可。此时第二次标记过程中会将对象移出F-Queue
队列。
注意:一个对象的
finalize()
方法只会被系统自动执行一次,也就是说通过调用finalize
方法自我救命的机会就一次.
一次对象自我拯救的演示:
/**
* 下列代码演示两点
* 1. 在gc中能自救
* 2. 自救机会只有一次,因为一个对象的finalize()方法最多只会被系统给自动调用一次
*/
public class FinalizeEscapeGCDemo {
private static FinalizeEscapeGCDemo SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes,i am still alive");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed");
FinalizeEscapeGCDemo.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGCDemo();
//对象第一次拯救自己
SAVE_HOOK = null;
System.gc();
//finalize()方法优先级很低,睡眠一会,等待finalize()方法执行
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no,i am dead");
}
//与上面代码一模一样,却自救失败
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no,i am dead");
}
}
}
执行结果:
finalize method executed
yes,i am still alive
no,i am dead
3.3 方法区的内存回收
方法区的垃圾回收主要包含两个部分:
- 废弃的常量
- 不在使用的类型
3.3.1 常量的回收
回收废弃常量和堆中的对象回收比较类似。当没有引用时,就代表着可以被回收,从常量池中被清除。常量池中的其他类(特指接口)、方法、字段的符号引用也与常量类似。
3.3.2 类型的回收
判断一个常量是否能被回收比较简单,但是判断一个类型是否属于”不在被使用的类“的条件就很苛刻了,需要同时满足以下三个条件:
- 该类的所有实例都被回收,
Java
堆中没有存在该类及派生子类的任何实例 - 加载该类的
ClassLoader
被回收(只有自定义的类加载器才能被回收) - 该类的对应的
Java.lang.Class
对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
需要注意的是,同时满足这三个条件也只是代表类型可以被回收。并不代表一定会被回收
3.4 垃圾收集算法
3.4.1 分代收集理论
分代收集算法根据对象的存活周期将内存划分区域。根据不同的内存区域,回收也按照MinorGC
、MajorGC
、FullGC
三种回收类型进行了多种划分。并且也根据不同内存区域对象存活的特点发展出相匹配的垃圾回收算法:标记--清除
、标记--复制
、标记--整理
。
“新生代”,“老年代”,“永久代”,“Eden空间”,“From survivor空间”,“To survivor空间”
的由来;
由于从内存回收的角度来讲,由于现代垃圾回收器都是基于 分代收集理论 设计的。所以这些区域划分都是这些垃圾收集器的共同特性和设计风格。并非Java虚拟机的固有内存布局。
3.4.1.1 跨代引用假说
跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
如果某个新生代对象存在着跨代引用,由于老年代对象难以消亡,这引用会使得新生代对象在收集时同样得以存活。
当我们进行
MinorGC
时,新生代对象存在跨代引用,我们是否需要扫描整个老年代来检查关联的老年代对象依旧存活呢?或者有什么更优的方式?
3.4.1.2 Remembered Set
Remembered Set
:(记忆集)一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
我们不应该为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需要在新生代上建立一个全局的数据结构(该结构称为记忆集, Remembered Set
),这个结构把老年代划分为若干小块,标识出老年代的哪一块内存会存在跨代引用。
- 部分收集(
Partial GC
)
- 新生代收集
MinorGC
- 老年代收集
MajorGC
:目前只有CMS收集器会有老年代的收集行为。- 混合收集
MixedGC
:收集整个新生代和部分老年代。目前只有G1收集器
有这种行为
- 整堆收集(
Full GC
):针对整个Java
堆和方法区的垃圾收集。
跨代引用的垃圾回收解决方案
我们知道当进行
MinorGC
时,回收的年轻代的垃圾对象,当有老年代的引用指向的是年轻代的对象,这种老年代的垃圾对象该如何进行回收?虚拟机提供了一种Remembered Set
记忆集的方式来解决这种问题,他把老年代划分为 N 个区域,标志出哪个区域存在着跨代引用。以后在进行MinorGC
的时候,只要把这些包含了跨代引用的内存区域加入GC Roots
一起扫描就行了。
3.4.1.3 Remembered Set
的实现
Remembered Set
(记忆集)只是一种抽象的数据结构,具体的实现有多种精度可供选择。下面列举三种
- 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32,64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象中有字段含有跨代指针
- 卡精度:每个记录精确到一块内存区域,该区域有对象含有跨代指针。
3.4.1.4 卡表
卡表是采用上面第三种精度实现的记忆集,也是目前最常用的一种。它定义了记忆集的记录精度、与堆内存的映射关系。
卡表实际上就是记忆集的一种实现方式,如果说记忆集是接口的话,那么卡表就是他的实现类。
卡表最简单的形式可以只是一个字节数组。
CARD_TABLE[this address >> 9] = 1;
字节数组CARD_TABLE
的每一个元素都对应着其标识的内存区域中一块特定大小的内存块。这个内存块称为卡页。卡页的大小通常为2的幂次方,上面的卡页是2的9次幂,即512个字节。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个对象的字段存在着跨代指针,那么对应卡表的数组元素的值标识为1,称这个元素变脏。没有则标识为0。
在
GC
的时候,就直接把值为1对应的卡页对象指针加入GC Roots
一起扫描即可。
虚拟机通过写屏障的手段来保证对象赋值操作时,保证卡表中的元素变脏。
卡表解决了跨代收集和根节点枚举的性能问题。而有了这些措施实际上枚举根节点这个过程造成的STW
停顿已经属于可控范围。
3.4.2 标记算法
-
标记–清除:分为两个阶段,标记和清除阶段。首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。
存在两个问题:
- 执行效率不稳定。需要回收的对象特别多和特别少时,大量标记和大量清除之间的效率难以保障。
- 内存碎片化问题。会产生大量不联系的内存碎片。
-
标记–复制
为了解决标记–清除算法所产生的问题。发展出标记–复制算法。
标记–复制算法:(半区复制),将可用内存分为大小相同的两块区域,每次只使用其中一块区域。一块内存区域用完了就将还存活的对象复制到另一块区域,然后把已使用的内存空间一次性清理掉。这种方式虽然解决了标记–清除算法带来的问题,但也引入了新的问题就是将可用内存缩小成为了原来的一半。
随着对内存区域的研究,发现新生代内存存在“朝生夕灭”的特点,故提出了一种新的更优化的半区复制分代策略,称为“Apple式回收”。目前
Hotspot
虚拟机的Serial,Parnew
等新生代收集器均采用了这种策略来设计新生代的布局。将内存区域分为一块Eden区
和两块survivor区
。Eden区
:survivor区
:survivor区
=:1:1
。每次只使用一块
Eden区
和survivor区
,垃圾回收时直接将存活对象复制到另一块survivor区
上。当
survivor区
内存不够时,虚拟机是如何处理的?从
Eden
和survivor区
复制到一块小容量的survivor区
难免会发生内存空间不足的情况,这些东西会被虚拟机以分配担保机制直接进入老年代。 -
标记–整理
标记–复制算法在面对“朝生夕灭”情况时,能够很好的处理。但是在对象存活率比较高的情况下(老年代),显然是不合适的。且标记–复制算法会浪费部分内存空间,这时,又衍生出一种新的标记–整理算法。
标记–整理算法:标记过程和标记–清除中一样,但是后续的步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间的一侧移动。效率比较低下。
整理的过程中会引发STW问题。
3.4.3 并发的可达性分析(三色标记算法)
我们知道,在进行可达性分析的过程中会造成STW
。我们知道是因为需要一致性的快照。那么为什么非得在一致性的快照上进行对象图的遍历?接下来我们通过三色标记作为工具来进行演示为什么并发情况下非得需要一致性的快照。
3.4.3.1 什么是三色标记法?
在三色标记法中,把从GC Roots
开始遍历的对象标记为以下三种颜色:
- 白色,在刚开始遍历的时候,所有的对象都是白色的
- 灰色,被垃圾回收器扫描过,但是至少还有一个引用没有被扫描
- 黑色,被垃圾回收器扫描过,并且这个对象的引用也全部都被扫描过,是安全存活的对象
3.4.3.2 三色标记法图解
一开始全部标记为白色
接着A/G对象被扫描到变成灰色,然后A/G对象的引用也都被扫描,A/G对象变成黑色。
B/C对象开始被扫描变成灰色,他们的引用也被扫描完成后自己也就都变成了黑色。
而后D对象也一样会经历从灰色到黑色的过程(偷点懒,省略一张无关紧要的过程图吧)
最后剩下的E/F节点就是可以被回收的对象了。
3.4.3.3 三色标记存在的问题(对象消失问题)
当一下两个条件同时满足时,会产生对象消失问题:
- 插入了一条或者多条黑色到白色对象的引用
- 删除了全部从灰色到白色对象的引用
对象消失问题的产生
假设A扫描完,刚好C成为灰色,此时C->D
的引用删除,同时A->D
新增了引用(同时满足两个条件了吧),这样本来按照顺序接下来D应该会变成黑色(黑色对象不应该被清理),但是由于C->D
没有引用了,A已经成为了黑色对象,他不会再被重新扫描了,所以即便新增了A->D
的引用,D也只能成为白色对象,最终被无情地清理。
对象消失的两种解决方案
- 增量更新:把新插入的引用记录下来,扫描结束之后,再以黑色对象为根重新扫描一次。新插入的记录再扫一次
- 原始快照:把这个要删除的引用记录下来,扫描结束之后,以灰色对象为根重新扫描一次。所以就像是快照一样,不管你删没删,其实最终还是会按照之前的关系重新来一次。
3.5 垃圾收集器
新生代的垃圾回收器:serial ,ParNew,Parallel Scavenge
老年代的垃圾回收器:CMS
,Serial Old
、Parallel Old
整堆垃圾回收器: G1
垃圾回收器
并行收集和并发收集:
- 并行收集:多条垃圾收集线程并行工作,此时用户线程处于等待状态
- 并发收集:用户线程和收集线程同时工作,用户程序在继续执行,垃圾收集程序在另一个CPU上
两个收集器之间存在连线代表可以配合使用
3.5.1 Serial
收集器
Serial
收集器:单线程的垃圾收集器,在进行垃圾收集时,会引起STW
。相对于其他的单线程垃圾收集器,更加的简单、高效。没有线程交互带来的开销。且是所有收集器中额外消耗内存最少的收集器。
Serial
收集器垃圾收集算法使用的是标记–复制算法
3.5.2 ParNew
收集器
ParNew
收集器:是serial
的多线程版本,垃圾回收时也会暂停其他的用户线程。
ParNew
收集器垃圾收集算法使用的是标记–复制算法
ParNew
收集器是激活CMS
后的默认新生代收集器。
3.5.3 Parallel Scavenge
收集器
吞吐量: 吞吐量 = 用户代码运行时间/(用户代码运行时间 + 垃圾回收时间)
虚拟机完成某个任务,用户代码和垃圾收集总共花了
100min
,其中垃圾收集花了1min
。那么吞吐量就是99%
Parallel Scavenge
收集器:吞吐量优先的并行收集多线程收集器。
Parallel Scavenge
提供了两个参数用于精确控制吞吐量
- 控制最大垃圾收集停顿时间:
-XX:MaxGCPauseMillis
- 设置吞吐量大小:
-XX:GCTimeRatio
-XX:MaxGCPauseMillis
一个大于0的毫秒数,收集器尽力保证在这个时间内完成垃圾回收任务。并非停顿时间越小越好,停顿时间减少意味着是用吞吐量和新生代空间为代价换取的。停顿时间越小也可能会造成垃圾回收越频繁。-XX:GCTimeRatio
值为正整数。默认值为99。代表吞吐量为99。
3.5.3.1 自适应调节策略
Parallel Scavenge
收集器 中存在一个参数-XX:UseAdaptiveSizePolicy
,这是一个开关参数。当这个参数被激活后,意味着就不需要人工指定新生代的大小(-Xmn
)、Eden
和survivor
区的比例(-XX:SurvivorRatio
)、晋升老年代对象大小(-XX:PretenureSizeThreShold
)等细节参数。虚拟机会根据当前系统的运行状况收集性能监控信息,动态调整这些参数以提供最适合的停顿时间或者最大的吞吐量。这种调节被称为垃圾收集器的自适应策略(GC Ergonomics
)
Parallel Scavenge
收集器垃圾收集算法使用的是标记–复制算法
3.5.4 Serial Old
收集器
Serial Old
是Serial的老年代版本,同样是单线程收集器
Serial Old
收集器垃圾收集算法使用的是标记–整理算法
3.5.5 Parallel Old
收集器
Parallel Old收集器是Parallel Scavenge的老年代版本,支持多线程并发收集。
Serial Old
收集器垃圾收集算法使用的是标记–整理算法
3.5.6 CMS
收集器
CMS(Concurrent Mark Sweep)收集器
是一种以获取最短回收停顿时间的收集器。(低停顿,并发收集)
CMS收集器
运行的四个步骤:
- 初始标记:标记
GC ROOT
能直接关联到的对象,标记很快但是会STW
- 并发标记:从
GC ROOT
直接关联对象开始遍历整个对象图的过程,执行过程会很长 (并发执行不会有STW
) - 重新标记:修正并发标记期间,用户程序继续运行导致标记产生变动的那一部分记录。会有
STW
问题 - 并发清除:对标记的对象进行清除回收(并发进行) 不会
STW
CMS收集器
的几个缺点:
-
对处理器资源比较敏感。
CMS
默认启动的回收线程是:(处理器核心数量+3)/4
,当处理器核心数量不足四个时,CMS
对程序的影响会变得很大。 -
CMS收集器
无法处理“浮动垃圾“,有可能出现Con-current Mode Failure
失败进而导致另一次完全stop the world
的full gc
的产生。浮动垃圾:在
CMS收集器
并发标记和并发清理阶段,用户线程还是在继续运行的,程序在运行自然就会有新的垃圾不断产生,这一部分垃圾对象是出现在标记过程结束之后,CMS
无法在当次收集中处理掉他们,只能等待下次在进行处理。这一部分垃圾就称为浮动垃圾。CMS收集器
垃圾收集阶段用户线程是持续运行的,所以还需要预留一定的内存空间给到用户线程运行使用,不能像其他收集器等到老年代满了再进行垃圾收集工作。这样就存在一个问题,当CMS收集器
预留的空间不够用户线程使用,就会触发full gc
的操作,导致停顿时间变长。预留的空间比例可以通过
-XX:CMSInitiatingOccupancyFraction
设置 -
由于采用标记清除算法,导致的内存碎片问题。
CMS
收集器采用的是标记–清除算法,会有内存碎片问题。CMS
收集器平时大多数都采用标记–清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经影响到对象的分配时,会采用标记–整理算法收集一次。
3.5.7 G1
收集器
可预测的停顿的垃圾垃圾收集器。
G1
不在坚持固定大小及固定数量的分代区域划分,而是把连续的Java
堆划分成大小相等的独立内存区域(Region)
,每一个Region
都可以根据需要,扮演新生代的Eden
空间,Survivor
空间,或者老年代空间。收集器能够对扮演不同角色的Region
采取不同的策略去处理。
G1
和其他收集器的区别其他收集器的工作范围是整个新生代或者老年代,
G1
收集器的工作范围是整个堆、它将堆划分为多个大小相等的独立区域region
。保留了新生代和老年代的概念虽然保留了老年代,新生代的概念。但是不在是固定的了。都是一系列区域的动态集合。
什么是Mixed GC
?
在G1
出现之前的所有的垃圾收集器,包括CMS
在内,垃圾收集的主要目标范围要么是整个新生代(Minor GC/Young GC
),要么是整个老年代(Majar GC/Old GC
),再要么是整个Java堆(Full GC)
。而G1
跳出了这个樊笼,他可以面向堆内存任何部分组成回收集(Collect Set一般称CSet
)进行回收,衡量标准不是他属于那个分代,而是那块内存中存放的垃圾数量多,回收收益最大,这就是G1
收集器的Mixed GC
模式。
什么是Humongous
区域?
Region
中存在一种Humongous
区域,专门同来存储大对象。G1
认为只要大小超过一个Region
容量的一半的对象就能称之为大对象。每个Region
的大小可以通过-XX:G1HeapRegionSize
设置,大小为1MB~~31MB
,且为2的幂次方。
可预测的停顿时间模型
G1
跟踪收集各个Region
区域的回收价值,维护一个优先级列表。根据用户设置的允许收集停顿时间(-XX:MaxGCPauseMillis
参数控制),优先回收价值收益大的Region
。
多个Region
区域的跨代引用问题。
同新生代老年代一样,G1
中收集器使用的同样是记忆集的方式解决跨代引用的问题。详情见上面的章节分代收集理论。
但是G1
的记忆集相对于CMS
的记忆集复杂很多。每个Region
都有维护自己的记忆集并且存储结构还类似于一种双向卡表的结构(卡表是我指向谁,这种结构还记录谁指向我),多个Region
和复杂的存储结构导致G1
至少要耗费大约 Java堆10%~~20%
的额外内存来维持收集器工作。
G1
收集器的四个步骤:
-
初始标记: 仅标记
GC root
可直接关联到的对象。 (会造成STW
) -
并发标记:从
GC Root
开始对堆中的对象进行可达性分析,递归扫描整个堆里的对象图,找到需要回收的对象,这个阶段耗时很长,但是可以和用户线程并发执行。(不会STW
) -
最终标记:修正并发标记期间,用户线程修改的引用标记 (
STW
) -
筛选回收:对各个
region
的回收价值和回收成本进行排序。根据用户所期望的时间来进行回收。(STW
)根据筛选出来的决定回收的那一部分Region区域的存活对象复制到空的Region中,再清理掉旧的
Region
的全部空间。
G1
从整体上来看采用的是标记–整理算法,当时从局部(两个Region
)看使用的是标记复制算法。无论如何两种算法都不会产生内存碎片
3.5.7.1 G1
收集器 与 CMS
收集器的对比
优点:可以指定最大停顿时间,分Region的内存布局,按收益动态确定回收集且没有内存碎片
缺点:G1
会产生更多的内存占用。(至少要耗费大约 Java堆10%~~20%
的额外内存来维持收集器工作。)
3.5.8 低延迟收集器Shenandoah
相比CMS
、G1
,Shenandoah
是一个不仅进行并发垃圾标记,还要并发进行对象清理后的整理动作的垃圾收集器。
3.5.8.1 G1
,Shenandoah
间比较
相同:
- 相同的
Region
布局 - 相同的存放大对象的
Humongous Region
- 默认回收策略都是优先回收价值大的
不同:
Shenandoah
支持并发的收集算法Shenandoah
不使用分代收集,没有新生代老年代的概念- 摒弃了消耗大量内存的记忆集,使用”连接矩阵“的数据结构
3.5.8.2 Shenandoah
的执行步骤
具体详情见《深入理解Java虚拟机》第三版
- 初始标记
- 并发标记
- 最终标记
- 并发清理
- 并发回收
- 初始引用更新
- 并发引用更新
- 最终引用更新
- 并发清理
3.5.9 低延迟收集器ZGC
具体详情见《深入理解Java虚拟机》第三版
ZGC
收集器是一款基于Region
内存布局的,(暂时)不设分代,使用了读屏障、染色指针和内存多重映射等技术实现的可并发的标记–整理算法的,以低延迟为首要目标的一款垃圾收集器。
执行步骤
- 并发标记
- 并发预备重分配
- 并发重分配
- 并发重映射
3.5.10 Epsilon
收集器
这是一款不能够进行垃圾收集的垃圾收集器。
从JDK10
开始,为了隔离垃圾收集器与Java
虚拟机解释、编译、监控等子系统的关系,RedHat
提出了垃圾收集器的统一接口,即JEP 304
提案,Epsilon
是这个接口的有效性验证和参考实现。
使用场景:
长时间以来,Java
技术体系重心都在向着长时间、大规模的企业级应用和服务端应用,少有移动平台和桌面平台支持。传统Java
有着内存占用大,在容器中启动时间长,即时编译需要缓慢优化等特点。这些对于大型应用来说不算问题,但是对于短时间、小规模的服务形式就会存在诸多不便。
Epsilon
收集器,如果一个应用只需要运行数分钟甚至是数秒,只要Java
虚拟机能够正确分配内存,在内存耗尽之前退出结束应用。那么Epsilon
收集器无疑是最合适的选择。
3.5.11 收集器的选择
如何选择合适的垃圾收集器,无非需要从三个方面考虑
- 内存占用(嵌入式应用等等肯定是优先考虑内存)
- 吞吐量
- 延迟(停顿时间)
3.5.12 内存分配和回收策略
收集器的内存分配规则:
- 对象优先在
Eden
分配 - 大对象直接进入老年代
- 长期存活的对象将进入老年代
- 对象动态年龄判定
- 空间分配担保
3.5.12.1 对象优先在Eden
分配
大多数情况下,对象在新生代Eden
区中分配。当Eden
中没有足够空间时,虚拟机将会发起一次MinorGC
。
3.5.12.2 大对象直接进入老年代
JVM
参数-XX:PretenureSizeThreshold
可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,设置的目的是为了防止大对象在Eden
和survivor
间相互复制,影响效率。
这个参数只在
Serial和ParNew
两个收集器下有效。
3.5.12.3 长期存活的对象将进入老年代
JVM
采用了分代年龄收集的思想,那么回收这个对象的时候就需要考虑放在survivor
还是老年代,考虑的依据虚拟机会为每个对象分配一个年龄计算器,如果对象在Ede
n区经过一次Monir GC
后存活下的对象,移动到Survivor
后年龄+1,之后的Minor GC
每存活一次年龄再次+1
, 一直加到15(CMS默认是6,不同的垃圾收集器策略不同)
,就会被移动到老年代。可以通过参数-XX:MaxTenuringThreshold
来设置
3.5.12.4 对象动态年龄判定
如果在survivor
空间中低于或等于某年龄所有对象大小的总和大于survivor
空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold
参数中要求的年龄。
例如:[
年龄1,年龄2,年龄3,年龄4,年龄N
]的对象 其中[年龄1,2,3]的对象总和超过了survivor
区域的百分之50
(-XX:TargetSurvivorRatio
可以指定),那么就会将3和3以上的对象都进入老年代。
3.5.12.5 空间分配担保
-Xms20M、-Xmx20M、-Xmn10M
限制堆大小为20M
,新生代老年代分别为10M
。新生代
10M
,默认的Eden
和survivor
比例为8:1:1
,那么eden
为8M
。新生代可用的空间为9M
(Eden
加一个survivor
的容量)假设
Eden区
已被三个大小为2M
的对象占用6M
。此时需要插入一个4M
大小的对象,剩余空间不足分配,此时会触发一次MinorGC
。gc
过程中由于三个2M
的对象又无法放入survivor
(大小只有1M
,空间不足),所以只能通过分配担保机制提前转移到老年代中去。
MinorGC
之前,虚拟机会检查老年代中剩余可用空间,如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象)或者历次晋升的平均大小,就会触发 Full GC
,否则直接 MinorGC
-XX:-HandlePromotionFailure
(是否允许担保失败)参数在JDK6
后就不再使用了。老年代分配担保机制担保的就是存在
Full GC
的情况下,减少一次Minor GC
,如果没有担保那么就是Minor GC
==>Full GC
4. 虚拟机类加载机制
Class
文件中描述的各类信息,最终都需要加载到虚拟机中之后才能被运行和使用。而虚拟机如何加载这些Class
文件,Class
文件中的信息进入到虚拟机后会发生什么变化,这些都是本章将要讲解的内容。
类加载机制:Java
虚拟机把描述类的数据从Class
文件加载到内存,并对数据进行校验、转换解析和初始化,最 终形成可以被虚拟机直接使用的Java
类型,这个过程被称作虚拟机的类加载机制。
4.1 类的生命周期
一个类型从被加载到虚拟机内存,到卸载出内存。生命周期会经历加载(Loading
) 、 验 证 (Verification
) 、 准 备 (Preparation
) 、 解 析 (Resolution
) 、 初 始 化(Initialization
) 、 使 用 (Using
) 和 卸 载 (Unloading
) 七 个 阶 段 , 其 中 验 证、准 备、解 析三 个 部 分 统 称 为连接(Linking
)。
4.2 类加载过程
4.2.1 加载
在加载阶段,虚拟机需要完成以下三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口
加载阶段结束后,
Java虚拟机
外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,并且会在堆内存中实例化一个java.lang.Class
对象,这个对象作为程序访问方法区中的类型数据的外部接口。
4.2.2 验证
连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证的四个阶段:
- 文件格式验证:保证输入的字节流能正确的解析并存储于方法区之内。
- 魔数是否以
0xCAFEBABE
开头 - 主次版本号是否正确等等等…
- 魔数是否以
- 元数据验证:对字节码描述的信息进行语义分析,保证其描述符合《Java语言规范》
- 这个类是否继承了不该继承的类(
final
修饰的类) - 这个类不是抽象类,是否实现了父类或接口中要求实现的所有方法等等
- 这个类是否继承了不该继承的类(
- 字节码验证:通过数据流分析和控制流分析,确保程序语义是合法的、符合逻辑的。
- 保证不会出现操作数栈中
int类型
的数据,使用时要求long类型
来加载如本地变量表中的情况发生 - 保证任何跳转指令都不会跳转到方法体意外的字节码指令上等等
- 保证不会出现操作数栈中
- 符号引用验证:检查该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。
- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 符号引用中的类、字段、方法的可访问性(
public、private等
)是否能被当前类所访问等等等
4.2.3 准备
正式为类中定义的静态变量分配内存并设置类变量初始值的阶段。
仅仅只是静态变量分配内存,不包含实例变量。
静态变量:
static
修改的变量实例变量:无
static
修改的变量
这里所说的初始值仅仅只是数据类型的零值,例如
public static int value = 123;
这个时候所说的初始值是0,而不是123。
把
value
赋值为123的putstatic
指令是在程序被编译后,存放在类构造器<clinit>()
方法中,所以赋值为123的操作要等到初始化阶段才会被执行。
但是当value
被final
修饰,那么在准备阶段初始值就会被赋值为123。
public static final int value = 123;
4.2.4 解析
Java
虚拟机将常量池内的符号引用替换为直接引用的过程。
解析过程可以按照以下四种来分析
-
类或接口的解析
假设当前代码所处的类D,要解析一个从未解析过的符号引用N解析成为一个类或接口C的直接引用
- 如果C不是一个数组类型,虚拟机会将代表N的全限定名传递给D的类加载器去加载类C。在加载过程中有会涉及到元数据验证字节码验证的需要又会触发其他相关类的加载过程。若过程发生异常,也就代表解析失败。
- 如果C是一个数组类型,并且数组的元素为对象。那么会按照上面一点进行加载。
-
字段解析
要解析一个从被解析过的 字段符号引用,首先会对字段表内
class_index
项中索引的CONSTANT_Class_info
符号引用进行解析,就是字段所属的类或接口的符号引用。解析过程同上1。接下来,解析如果通过的化,就对后续字段进行搜索。如果解析的类中直接含有字段,那么直接返回。没有的话,按照实现或者继承关系(先实现关系后继承关系)递归依次从下往上搜索。有则直接返回。最终若查找不到则返回
java.lang.NoSuchFieldError
异常; -
方法解析
方法解析的第一个步骤同字段解析一样,先找到
class_index
==>CONSTANT_Class_info
后续的步骤是检查第一步查找到的是否是一个接口,若是直接抛出异常结束,不是则检查类中是否有匹配的方法。若类中没有匹配的方法则从下往上递归父类。查找父类中是否存在方法,有直接返回,没有则递归类的父接口。若父接口中存在方法,则代表当前查找的类虽然不是接口,但是是一个抽象类,抛出
java.lang.AbstractMethodError
。否则抛出java.lang.NoSuchMethodError
-
接口方法解析
第一步解析接口方法表的
class_index
==>CONSTANT_Class_info
- 如果解析到的索引是个类,则直接抛出异常。(和上面的方法解析刚好相反)
- 在解析的接口中直接查找是否存在,存在则返回。不存在则递归父接口。
- 最终实在没有则抛出
java.lang.NoSuchMethodError
4.2.5 初始化
在准备阶段,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据代码中程序编写的主观计划去初始化类变量和其他资源。
初始化阶段就是执行类构造器
<clinit>()
方法的过程。<clinit>()
不是Java
代码中直接编写的,而是Javac
编译器的自动生成物。
<clinit>()
是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static块
)中的语句合并产生的,(收集的顺序由语句在源文件中出现的顺序决定)静态语句块只能访问定义在静态语句块之前的变量,定义在之后的变量,只能赋值不能访问。
public class Test { static { i = 1; //正常编译通过 System.out.println(i);// 编译器提示“非法前向引用” } static int i = 0; }
父类的
<clinit>()
一定前于子类的<clinit>()
执行。意味着父类的静态语句块要优先于子类的。
<clinit>()
对于类和接口不是必需的。
- 如果类中没有静态语句块,且没有对变量赋值操作,那么编译器可以不给这个类生成
<clinit>()
方法- 接口中不能使用静态语句块,但仍然有变量初始化赋值的操作,因此接口和类一样都会生成
<clinit>()
。但是接口和类不同的地方在于,执行接口的<clinit>()
不需要先执行父接口的<clinit>()
。只有当父接口中定义的变量被使用时,父接口才会初始化。Java虚拟机在多线程初始化一个类的环境下,通过加锁阻塞等待的方式保证同步。只有一个线程去执行这个类的
<clinit>()
方法。
4.3 六种必须进行类初始化的场景
- 遇到
new、getstatic、putstatic或invokestatic
这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码
场景有:- 使用new关键字实例化对象的时候。
- 读取或设置一个类型的静态字段(被
final
修饰、已在编译期把结果放入常量池的静态字段除外)的时候。 - 调用一个类型的静态方法的时候。
- 使用
java.lang.reflect
包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。 - 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含
main()
方法的那个类),虚拟机会先初始化这个主类。 - 当使用
JDK 7
新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle
实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial
四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。 - 当一个接口中定义了
JDK 8
新加入的默认方法(被default
关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
4.3.1 主动引用、被动引用
如上六种会触发类型进行初始化的场景,这些行为称为对一个类型进行主动引用,除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。
public class ClassDemo {
public static void main(String[] args) {
test1();
}
/**
* 被动引用案例一:
* 通过子类引用父类的静态字段,不会导致子类初始化
*
* 执行结果:
* SuperClass init!
* 123
*/
static void test1(){
System.out.println(SubClass.value);
}
/**
* 被动引用案例二:
* 通过数组定义来引用类,不会触发此类的初始化
*
* 执行结果:
* SuperClass init!
*/
static void test2(){
SuperClass[] sca = new SuperClass[10];
}
}
在介绍第三种被动引用之前,需要先了解一下final
修饰的静态字段,会在编译期间直接放入常量池。
public class ConstClass {
static {
System.out.println("ConstClass init");
}
public static final String HELLOWORLD = "helloworld";
}
/**
* 被动引用三:
* 常量在编译阶段会存入调用类的常量池,本质上没有直接引用到定义常量的类,
* 因此不会触发定义常量的类的初始化
* 执行结果:helloworld
*/
public class ClassDemo2 {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);
}
}
被动引用三提到没有初始化,但并不代表着没有进行加载、验证、准备、解析的阶段。
虽然确实用到了
ConstClass
类的常量,但是编译阶段通过常量传播优化,已经将常量的值直接存储到了ClassDemo2
类的常量池中。
4.3.2 类和接口在初始化阶段的区别
当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求父接口全部都完成了初始化,只有在真正使用到父接口的时候才会初始化。
4.3.3 类中各个部分的初始化顺序
上面六种必须进行类初始化的场景中提到:当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
正常情况下一个类的组成部分包含以下几种:
- 静态资源(静态属性,静态方法,静态代码块)
- 非静态资源(非静态属性,非静态方法,非静态代码块)
- 构造方法
- 子类,父类
那么各个部分在类加载过程中的加载顺序是怎样的呢?我们通过下面的案例来了解一下。
类加载过程中,类的各个组成加载顺序
通过案例的结果我们可以得出,当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。(父类的父类也是如此循环)。
静态代码块、普通代码块、属性值、静态属性值、构造器等部分的加载顺序是
- 静态>普通(静态代码块/静态属性值)>(普通代码块/普通属性值)
- (静态/普通)代码块和(静态/普通)属性值的加载顺序根据两者代码在类中的书写顺序
- 构造器在最后执行
下方执行结果节选:
A 的静态代码块被加载
A 的普通代码块被加载
son:普通属性A的构造方法被调用
public class A {
static {
System.out.println("A 的静态代码块被加载");
}
{
System.out.println("A 的普通代码块被加载");
}
public A(){
System.out.println("son:普通属性A的构造方法被调用");
}
}
public class B {
static {
System.out.println("B 的静态代码块被加载");
}
{
System.out.println("B 的普通代码块被加载");
}
public B(){
System.out.println("father:普通属性B的构造方法被调用");
}
}
public class C {
static {
System.out.println("C 的静态代码块被加载");
}
{
System.out.println("C 的普通代码块被加载");
}
public C(){
System.out.println("son:静态属性C的构造方法被调用");
}
}
public class D {
static {
System.out.println("D 的静态代码块被加载");
}
{
System.out.println("D 的普通代码块被加载");
}
public D(){
System.out.println("father:静态属性D的构造方法被调用");
}
}
public class Father {
static {
System.out.println("Father 的静态代码块被加载");
}
{
System.out.println("Father 的普通代码块被加载");
}
public Father(){
System.out.println("Father的构造方法被调用");
}
public void test1(){
System.out.println("Father的普通方法被调用");
}
public static void test2(){
System.out.println("Father的静态方法被调用");
}
private B b = new B();
private static D d = new D();
}
public class Son extends Father{
private A a = new A();
private static C c= new C();
static {
System.out.println("Son 的静态代码块被加载");
}
{
System.out.println("Son 的普通代码块被加载");
}
public Son(){
System.out.println("Son的构造方法被调用");
}
public void test1(){
System.out.println("Son的普通方法被调用");
}
public static void test2(){
System.out.println("Son的静态方法被调用");
}
}
public class InitClassDemo {
public static void main(String[] args) {
Son son = new Son();
son.test1();
}
}
执行结果:
Father 的静态代码块被加载
D 的静态代码块被加载
D 的普通代码块被加载
father:静态属性D的构造方法被调用
C 的静态代码块被加载
C 的普通代码块被加载
son:静态属性C的构造方法被调用
Son 的静态代码块被加载
Father 的普通代码块被加载
B 的静态代码块被加载
B 的普通代码块被加载
father:普通属性B的构造方法被调用
Father的构造方法被调用
A 的静态代码块被加载
A 的普通代码块被加载
son:普通属性A的构造方法被调用
Son 的普通代码块被加载
Son的构造方法被调用
Son的普通方法被调用
4.4. 类加载器
“通过一个类的全限定名来获取描述该类的二进制字节流” 这个动作的代码称之为“类加载器”。
对于任何一个类,必须由类加载器和这个类本身共同确定,才能保证其在虚拟机中的唯一性
**不同类加载器对instanceof 关键字计算结果的影响**
public class Test {
public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".")+1)+".class";
InputStream in = getClass().getResourceAsStream(fileName);
if (in == null){
return super.loadClass(name);
}
byte[] bytes = new byte[in.available()];
in.read(bytes);
return defineClass(name,bytes,0,bytes.length);
}catch (Exception e){
throw new ClassNotFoundException();
}
}
};
Object o = myLoader.loadClass("com.wddong.classload.Test").newInstance();
System.out.println(o.getClass());
System.out.println(o instanceof com.wddong.classload.Test);
}
}
执行结果:
class com.wddong.classload.Test
false
从执行结果可以看出,这个对象确实是类
com.wddong.classload.Test
实例化得出的,但是从第二行结果却发现这个对象与类com.wddong.classload.Test
做属性检查的时候返回了false
。这是因为Java虚拟机
中存在了两个Test
类,一个由虚拟机的应用程序类加载器所加载,另一个由我们自定义的类加载加载,虽然他们都来自同一个Class
文件。
4.4.1 类加载器的种类
-
启动类加载器(
Bootstrap ClassLoader
)启动类加载器负责加载存放在
<JAVA_HOME>\lib
目录,或者被-Xbootclasspath
参数所指定的路径中存放,而且Java
虚拟机能够识别的类库加载到虚拟机的内存中。。 -
扩展类加载器(
Extension ClassLoader
)负责加载
<JAVA_HOME>\lib\ext
目录中的类库,或者被java.ext.dirs
系统变量所指定的路径中所有的类库 -
应用程序类加载器(
Application ClassLoader
)又称为应用程序类加载器,负责加载用户类路径上所有的类库。
-
自定义类加载器(
User ClassLoader
)
自定义类加载器,只需要重写ClassLoader
类的loadClass()
即可。通过自定义的类加载器来进行拓展。
自定义类加载器典型案例:
- 增加除了磁盘位置外的
Class
文件来源- 通过类加载器实现类的隔离、重载等功能
4.4.2 双亲委派
4.4.2.1 什么是双亲委派模型?
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围没有找到所需的类)时,子加载器才会尝试自己去完成加载。
4.4.2.2 双亲委派模型的好处?
双亲委派模型,所有类的加载最终都会委托到最顶层的启动类加载器中,这样能够保证核心类库中的类不会被篡改。
4.4.2.3 类加载的作用都是将Class
文件加载进虚拟机,为什么需要这么多种类的类加载器?
Java
虚拟机启动时,不会一次去加载所有的Class
文件,而是根据需要动态的去进行加载;
4.4.2.4 双亲委派模型的代码实现
代码的逻辑:首先检查请求加载的类型是否有被加载过,如果没有则调用父类加载器的 loadClass()
方法,若父加载器为空则默认使用 启动类加载器作为父加载器,假如父加载器加载失败,抛出 ClassNotFoundException
异常的话,才会调用自己的findClass()
方法尝试进行加载。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
4.4.2.6 双亲委派模型能否被破坏?
可以,只需要继承ClassLoad
类的loadClass()
方法,就可以破坏双亲委派模型了。(重写的loadClass
方法中,不向上委托就好了)
4.4.2.7 一个类的静态块是否可能被执行两次?
可能,不可能?
public class A{
static int i = 1;
static {
System.out.println(String.format("A 静态块被执行第 %d 次",i));
i++;
}
}
public class B{
public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".")+1)+".class";
InputStream in = getClass().getResourceAsStream(fileName);
if (in == null){
return super.loadClass(name);
}
byte[] bytes = new byte[in.available()];
in.read(bytes);
return defineClass(name,bytes,0,bytes.length);
}catch (Exception e){
throw new ClassNotFoundException();
}
}
};
Object o = myLoader.loadClass("com.wddongtt.wddong.test.A").newInstance();
ClassLoader classLoader = o.getClass().getClassLoader();
System.out.println(classLoader);
CommonUtils commonUtils1 = new CommonUtils();
ClassLoader classLoader1 = commonUtils1.getClass().getClassLoader();
System.out.println(classLoader1);
CommonUtils commonUtils2 = new CommonUtils();
ClassLoader classLoader2 = commonUtils2.getClass().getClassLoader();
System.out.println(classLoader2);
}
}
执行结果:
commonUtils 静态块被执行第 1 次
com.wddongtt.wddong.test.B$1@5a07e868
commonUtils 静态块被执行第 1 次
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$AppClassLoader@18b4aac2
通过自定义类加载器和应用程序类加载器加载类A发现类A的静态块确实被执行了两次,但是众所周知,对于一个类,必须有加载它的类加载器和这个类本身一起共同确定,才能保证它在Java虚拟机中的唯一性。所以回到问题中来,一个类的静态块是否可能被执行两次。被两种类加载器加载的类,还能算是同一个类吗?
4.4.2.8 沙箱安全机制?
沙箱安全机制是由基于双亲委派机制上采取的一种JVM
的自我保护机制,假设我们自定了一个java.lang.String
的类,由于双亲委派机制,加载请求会先交给BootstrapClassLoader
启动类加载器试图去进行加载,但是BootstrapClassLoader
在加载类时首先通过包和类名查找rt.jar
中有没有java.lang.String
,有则优先加载rt.jar
包中的类,由于rt.jar
中已经包含了java.lang.String
类,所以我们自定义的String
类永远得不到加载(当然编译是不会报错的),它保证了Java
源代码的安全。
5. JVM
中调优常用的参数配置
-Xms : 初始堆的大小(默认1/64)
-Xmx: 最大堆内存(默认为系统的1/4)
-Xmn:堆内存年轻代的大小
-XX:NewSize=n :设置年轻代的大小
-XX:MaxNewSize: 设置年轻代最大值
-XX:MaxPermSize=n:(1.8之后改为MaxMetaspaceSize)设置最大持久代大小。
-Xss:每个线程的堆栈大小。
-XX:PermSize(1.8之后改为MetaspaceSize) 设置持久代(perm gen)初始值,默认是物理内存的1/64。
-XX:NewRatio=n:设置年轻代和年老代的比值。如:为3表示年轻代和年老代比值为1:3,年轻代占整个年轻代年老代和的1/4
-XX:SurvivorRatio=n:年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如3表示Eden: 3 Survivor:2,一个Survivor区占整个年轻代的1/5
-XX:MaxPermSize=n:设置持久代大小
收集器设置
-XX:+UseSerialGC:设置串行收集器
-XX:+UseParallelGC:设置并行收集器
-XX:+UseParalledlOldGC:设置并行年老代收集器
-XX:+UseConcMarkSweepGC:设置并发收集器
java -Xmx3550m-Xms3550m-Xss128k-XX:NewRatio=4-XX:SurvivorRatio=4
-XX:MaxPermSize=16m-XX:MaxTenuringThreshold=0
-XX:NewRatio=4:设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为4,则年轻代和年老代所占比值为1:4,年轻代占整个堆栈的1/5
-XX:SurvivorRatio=4:设置年轻代中Eden区与Survivor区的大小比值。设置为4,则两个Survivor区与一个Eden区的比值为2:4,一个Survivor区占整个年轻代的1/6
-XX:MaxPermSize=16m:设置持久代大小为16m
-XX:MaxTenuringThreshold=0:设置垃圾最大年龄。如果设置为0,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,提高效率,如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象在年轻代的存活时间。
hotSpot
虚拟机
热点代码探测能力
通过计数器找出最具有编译价值的代码,然后通知即时编译器以方法为单位进行编译。可以在最优化地程序响应时间与最佳执行性能中取得平衡。
最具有价值的代码:(一个方法频繁调用或者有效循环执行次数很多)
执行计数器找出具有编译价值地代码,即时编译器编译为物理硬件可以直接执行机器码
即时编译器
C1
(客户端)编译器:编译时间短,代码优化质量差C2
(服务器)编译器:编译时间长,代码优化质量好即时编译器编译后的代码缓存:当虚拟机发现某个方法或者代码块执行的特别频繁,就会通过即时编译器把这些代码编译成本地机器码,优化运行时的速度。(热点代码)
机器码:机器语言指令(原声码),电脑CPU可以直接解读的数据;(二进制文件)
字节码:二进制文件代码,字节码是一种中间码,需直译器转译后,才能成为机器码的中间代码。
常量:被final 修饰的变量
静态变量:static修饰的变量
native关键字:扩展 java 的使用,可以调用其他编程语言的接口
内存泄漏:程序申请内存后,无法释放已申请的内存空间
内存溢出:程序申请内存时,没有足够的内存;
STW:stop the world
(全程暂停用户应用程序)符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。
直接引用:可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
符号引用转换为调用方法的直接引用:符号引用如果指向一个接口的方法,这时候如果没有这个指向常量池的这个引用,调用时就无法知道接口的实现方法是哪个,这时候不能动态链接,也就是不能达成符号引用==>直接引用。
引用
《深入理解JVM
虚拟机》