JVM
类加载子系统
在加载阶段,Java虚拟机主要完成以下三件事情:
- 通过一个类的全限定名来获取此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的进行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口
类的生命周期
加载->链接->初始化
链接:验证->准备->解析
- 验证:进行字节码文件的各种验证,比如:文件格式验证(是否以魔数
0xCAFEBABE
开头)等,元数据验证(这个类是否有父类,是否继承了不允许被继承的类)等,字节码验证(对类的方法体进行验证,保证类的方法在运行时不会做出危害虚拟机安全的行为),符号引用验证(该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源) - 准备:为类变量定义初始值(被
final
修饰的常量会直接赋程序员定义的值,而不是默认值) - 解析:将符号引用替换为直接引用
初始化:就是执行类构造器<clinit>
方法的过程。clinit
方法是编译器自动收集类中所有的类变量的赋值动作和静态代码块中的语句合并而成的。方法会被自动的加锁同步
类加载器
类加载器分类
JVM
中有三个重要的ClassLoader
,除了BoottsrapClassLoader
外,其它类加载器均由Java
实现,并且全部继承自java.lang.ClassLoader
:
BootstrapClassLoader(启动类加载器)
:最顶层的加载类,由C++
实现,负责加载<JAVA_HOME>\lib
目录,或者被-Xbootclasspath
参数指定的路径中的所有类ExtensionClassLoader(扩展类加载器)
:该类是在类sun.misc.Launcher&ExtClassLoader
中以Java
代码形式实现的。负责加载<JAVA_HOME>\lib\ext
目录中,或者被java.ext.dirs
系统变量所指定的路径中所有的类库。AppClassLoader(应用程序类加载器)
:也叫系统类加载器
。面向我们的用户的加载器,负责加载用户类路径上所有的类库。
双亲委派模型
在类加载的时候,系统会先判断当前类是否被加载过。已经被加载过的类会直接返回,否则才会尝试加载。加载的时候,首先会将该请求委派给父类加载器的loadClass()
处理,因此所有的请求最终都会传送到顶层的启动类加载器BootstrapClassLoader
中。当父类加载器无法处理时,都由自己来处理。当父类加载器为null
时,会使用启动类加载器BootstrapClassLoader
作为父类加载器。
类的双亲委派模型是用 组合 实现的,而不是继承实现的。双亲并不意味着有一个父亲,一个母亲。
双亲委派模型的好处:
- 避免类的重复加载,父类加载器已经加载一次之后,子类加载器没有必要再加载一次
- 避免核心
API
被修改- 自定义类:
java.lang.String
- 自定义类:
java.lang.A
- 自定义类:
loadClass()
的源码:
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;
}
获取类加载器的几种方式
//1、通过Class对象的getClassLoader()方法
ClassLoader classLoader = A.class.getClassLoader();
System.out.println(classLoader);
//2、获取当前线程上下文的ClassLoader
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
System.out.println(contextClassLoader);
//3、获取系统的ClassLoadeer
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);
JVM运行时数据区
线程私有的:程序计数器、本地方法栈、虚拟机栈
线程共享的:堆、方法区
程序计数器
没有GC
。 没有OOM
虚拟机栈(Java
栈)
栈管运行,堆管存储
没有GC
以栈帧存储。
栈帧
- 局部变量表
(Local Variables)
- 操作数栈
- 动态链接
- 方法返回地址
- 一些额外的附加信息
局部变量表(Local Variables
)
- 存放方法参数和方法内部定义的局部变量
- 局部变量表所需的容量大小是在编译期间确定下来的,就在方法的
code
属性的max_locals
数据项中 - 存储单元是
Slot
(变量槽)
操作数栈(Operand Stack
)
也称为表达式栈。
操作数栈的深度在编译期间就确定了,保存在方法的code
属性的max_stacks
中。
栈顶缓存技术
动态链接(Dynamic Linking
)
或叫 指向运行时常量池的方法引用
方法调用
静态链接 和 动态链接
非虚方法(解析)
:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。“编译期确定,运行期不可变”,有:私有方法,静态方法,父类方法,实例构造器,final
方法
调用方法的字节码指令:
invokestatic
:用于调用静态方法invokespecial
:用于调用实例构造器<init>()
方法、私有方法和父类方法invokevirtual
:用于调用所有的虚方法invokeinterface
:用于调用接口方法,会在运行时再确定一个实现该接口的对象invokedynamic
:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法
被invokestatic
,invokespecial
和被invokevirtual
调用的被final
修饰的方法都是非虚方法。
修改:invokestatic
调用静态方法,invokespecial
调用
动态分派是很频繁的动作,需要搜索类型元数据。为了优化,在方法区中建立了虚方法表(Virtual Method Table)
。虚方法表一般在类加载的连接阶段进行初始化。
分派(Dispatch
)
-
静态分派
- 编译器(编译期间)进行的是静态分派。
- 虚拟机(编译器)在重载时是通过参数的静态类型来确定方法的重载版本的。
- 所有依赖静态类型来决定方法执行版本的分派动作,都叫静态分派
- 最典型的表现就是方法重载
- 变长参数的重载优先级是最低的
-
动态分派
- 在运行期根据实际类型确定方法执行版本的分派过程称为动态分派(运行期间进行的是动态分派)。
invokevirtual
指令时解析过程大致分为以下几步:- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作
C
- 如果在类型
C
中找到与常量中的描述符和简单名称都对应的方法,则进行访问权限验证,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回IllegalAccessError
异常 - 否则,按照继承关系从下往上依次对
C
的各个父类进行第二步的搜索和验证过程 - 如果始终没有找到合适的方法,则抛出
java.lang.AbstractMethodError
异常
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作
- 正是因为
invokevirtual
第一步就是在运行期确定接收者的实际类型,所以调用过程中不是把常量池中的符号引用解析到直接引用上就结束了。还会根据接收者的实际类型来选择方法版本,这个过程就是方法重写的本质。
-
多分派与单分派
-
首先来看编译器
-
编译器进行的是静态分派
-
编译时选择目标方法的依据有两点:一是静态类型是
Father
还是Son
;二是方法参数是QQ
还是360
。这次方法的选择结果是产生了两条invokevirtual
指令,两条指令的参数分别为常量池中指向Father::hardChoice(360)
及Father::hardChoice(QQ)
方法的符号引用。因为是根据两个宗量进行选择,所以**java
语言的静态分派属于多分派类型**
-
-
再看看运行阶段虚拟机的选择,也就是动态分派的过程。
- 这时参数的静态类型、实际类型都不会对方法的选择造成任何影响。唯一可以影响选择的因素只有该方法的接受者的实际类型是
Father
还是Son
。因为只有一个宗量可以作为选择依据,所以**java
语言的动态分派属于单分派类型**
- 这时参数的静态类型、实际类型都不会对方法的选择造成任何影响。唯一可以影响选择的因素只有该方法的接受者的实际类型是
-
总结:如今(直到Java 12和预览的Java 13)的
Java
语言是一门 静态多分派、动态单分派的语言。方法返回地址(
Return Address
)
-
虚拟机栈中,栈帧作为存储单位,而栈帧中比较重要的结构是 局部变量表
和操作数栈
;其余三个部分:方法返回地址
、附加信息
和动态链接
可以并称为栈帧信息
。
方法有两种返回类型:
- 正常退出。此时 :主调方法的PC寄存器的值就可以作为返回地址。
- 异常退出。此时 :通过异常处理器表来确定。
附加信息
与调试、性能收集相关的信息。
虚拟机栈的五道面试题
-
举例栈溢出的情况?
答:通过
-Xss
来设置栈的大小。StackOverflowError
,OOM
-
调整栈大小,就能保证不出现溢出吗?
答:不一定。本来就是一个死循环的话……
-
分配的栈内存越大越好吗?
答: 不是。
-
垃圾回收是否会涉及到虚拟机栈?
答:不会。
-
方法中定义的局部变量是否线程安全?
答:~~是。虚拟机栈是线程安全的。~~具体情况具体分析。如果局部变量被返回值返回了出去……
本地方法接口
native
该部分不属于运行时数据区
本地方法栈(Native Method Stacks
)
与Java 虚拟机栈
类似,Java 虚拟机栈
管理Java
方法的调用,本地方法栈
管理非Java方法
的调用。
堆(Java Heap
)
堆空间大小的设置:
-Xms
和-Xmx
-XX:InitialHeapSize
和-XX:MaxHeapSize
默认情况下,初始内存大小:物理电脑内存大小 / 64
最大内存大小:物理电脑内存大小 / 4
-XX:NewRatio=2
:默认是2 。代表老年代与新生代的比例。
在HotSpot
中,Eden
空间和另外两个Survivor
空间缺少所占的比例是8:1:1
。 开发人员可以通过-XX:SurvivorRatio
调整这个空间比例。比如:-XX:SurvivorRatio=8
.
-XX:-UseAdaptiveSizePolicy
:关闭自适应的内存分配策略。
-Xmn
:设置新生代的空间大小。一般不设置。
各种GC的对比
总结内存分配策略
- 优先分配在
Eden
- 大对象直接进入老年代
HotSpot
虚拟机提供了-XX:PretenureSizeThreshold
参数,指定大于该设置值的对象直接分配在老年代。该参数只对Serial
和ParNew
两款新生代收集器有效。- 大对象容易导致内存明明还有不少的空间就提前触发垃圾收集。
- 长期存活的对象将进入老年代
- 默认晋升的阈值是15,可以通过
-XX:MaxTenuringThreshold=
参数进行设置。
- 默认晋升的阈值是15,可以通过
- 动态对象年龄判定
- 如果在
Survivor
区中低于或等于某年龄的对象大小的总和大于Survivor
空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,而无须等到-xx:MaxTenuringThreshold
中的数值。
- 如果在
- 空间分配担保
- 在发生一次
Minor GC
之前,虚拟机必须检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果这个条件成立,则这一次Minor GC
可以确保是安全的。 - 如果不成立,则先检查
-XX:HandlePromotionFailure
是否允许担保失败。
- 在发生一次
TLAB
可以通过-XX:+/-UseTLAB
来确认是否启用
代码优化(逃逸分析)
逃逸可以分为三种类型:
- 不逃逸
- 方法逃逸
- 当对象在一个方法里面被定义以后,可能被外部方法所引用,例如作为调用参数传递进其它方法中,这种称为方法逃逸。
- 线程逃逸
- 可能被外部方法访问到,例如赋值给可以在其它线程中访问的实例变量,这种称为线程逃逸。
栈上分配:
如果对象没有发生线程逃逸,可以采用栈上分配的策略。
支持方法逃逸
标题替换
如果一个对象不会逃逸出方法,并且这个对象可以被拆分,那么程序真正执行的时候,可能不去创建这个对象,而改为直接创建它的若干个被 这个方法使用的成员变量来代替。
同步省略
如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其它线程访问,那么这个变量的读写就肯定不会有竞争,对这个变量实施的同步措施就可以安全地消除掉。
方法区
HotSpot
中方法区的演进
在jdk 7
及以前,习惯上把方法区,称为永久代。jdk 8
开始,使用元空间取代了永久代。元空间使用的是直接内存。
设置方法区大小及OOM
- 在
jdk 7
及以前的时候,通过设置永久代的大小来设置方法区的大小:-XX:PermSize=N
:方法区(永久代)的初始大小-XX:MaxPermSize=N
:方法区(永久代)的最大大小,超过这个值将会抛出OOM:PermGen
- 在
jdk 8
及以后,通过设置元空间的大小来设置方法区的大小:-XX:MetaspaceSize=N
:设置Metaspace
的初始大小-XX:MaxMetaspaceSize=N
:设置Metaspace
的最大大小
方法区的内部结构
常量池和运行时常量池
class
文件中有常量池,内存中有运行时常量池。
方法区的演进细节
HotSpot
中方法区的变化:
jdk 版本 | 说明 |
---|---|
jdk1.6 及以前 | 在永久代(permanent generation),静态变量存放在永久代上。 |
jdk1.7 | 在永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中。 |
jdk1.8 | 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆。 |
StringTable
为什么要调整位置?
jdk7中将StringTable
放到了堆中。因为永久代的回收效率很低,在full gc
的时候都会触发。而full gc
是老年代空间不足、永久代不足时才会触发。
这就导致StringTable
回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
如何证明静态变量放在哪里?
对象的实例化内存布局与访问定位
对象的实例化
- 加载类元信息
- 划分对象的内存空间(为对象分配空间)
- 处理并发安全问题
- 初始化分配到的空间
- 设置对象的对象头
- 执行init初始化
对象的内存布局
对象的访问定位
直接内存(Direct Memory
)
缺点:
- 分配回收成本较高
- 不受JVM内存回收管理
大小可以通过MaxDirectMemorySize
设置。
虚拟机执行引擎
解释器
StringTable
- 字符串常量池中是不会存储相同的字符串的。
拼接操作与append
操作的效率对比
StringBuilder
的append()
的方式:自始至终只创建过一个StringBuilder
的对象- 使用字符串拼接方式:创建过多个
StringBuilder
和String
的对象- 内存中由于创建了较多的
String
和StringBuilder
的对象,内存占用更大;如果进行GC
, 需要花费更多的时间。
- 内存中由于创建了较多的
改进的空间:如果基本确定添加的长度不高于某个数值,建议使用StringBuilder(int capacity)
的构造器。
intern()
方法的使用
- 在
jdk 1.6
及之前:- 如果字符串常量池中已经有了该字符串,则直接返回该串的地址。
- 如果没有该字符串,则将该串复制一份,放入常量池,然后返回池中对串的引用
- 在
jdk 1.7
及之后:- 如果字符串常量池中已经有了该字符串,则直接返回该串的地址。
- 如果没有该字符,则在池中引用一下当前堆中串的地址,然后再返回这个地址。
new String()
到底创建了几个对象
StringTable
的垃圾回收问题
垃圾回收相关章节的说明
什么是垃圾?为什么要GC
?
垃圾回收相关算法
标记阶段相关算法
引用计数算法(Reference Counting
)
当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
对于一个对象A
,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性
缺点:
- 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销
- 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
- 引用计数器有一个更严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在
Java
的垃圾回收器中没有使用这类算法。
可达性分析算法(根搜索算法、追踪性垃圾收集(Tracing Garbage Collection
))
所谓GC Roots
根集合就是一组必须活跃的引用。
基本思路:
- 可达性分析算法是以根对象集合(
GC Roots
)为起始点,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达 - 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所超过的踒称为引用链(
Reference Chain
) - 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以被标记为垃圾对象
- 在可达性分析算法中,只有根对象集合直接或间接连接的对象才是存活对象。
GC Roots
的选取
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象
- 比如:各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量
- 本地方法栈
JNI
中引用的对象 - 方法区中类静态属性引用的对象
- 如:
Java
类的引用类型静态变量
- 如:
- 方法区中常量引用的对象
- 如:字符串常量池中的引用的对象
- 所有被同步锁(
synchronized
关键字)持有的对象 Java
虚拟机内部的引用- 基本数据类型对应的
Class
对象 - 常驻的异常对象
- 系统类加载器
- 基本数据类型对应的
- 反映
Java
虚拟机内部情况的JMXBean、JVMTI
中注册的回调、本地代码缓存等
对象的finalization
机制
Java
虚拟机中的对象可以分为有三种状态:
可触及的
:从根结点开始,可以到达这个对象可复活的
:对象的所有引用都被释放,但是对象有可能在finilize()
方法中被复活。不可触及的
:对象的finalize()
方法被调用,并且没有复活,那么就会进入不可触及状态。 不可触及状态的对象不可能被复活,因为**finalize()
方法只会被调用一次**。
以上三种状态中,只有在对象不可被触及时才可被回收。
判定一个对象objA
是否可被回收,至少要经过再次标记过程:
- 如果对象
objA
到GC Roots
没有引用链,则进行第一次标记。 - 进行筛选,判断此对象是否有必要执行
finalize()
方法。- 如果对象
objA
没有重写finalize()
方法,或者finalize()
方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA
被判定为不可触及的 - 如果对象
objA
重写了finalize()
方法,且还未执行过,那么objA
会被放置在一个名为F-Queue
的队列之中,并在稍后由一条虚拟机自动建立的、低调度优先级的Finalizer
线程去执行它们的finalize()
方法。 finalize()
方法是对象逃脱死亡的最后机会,稍后GC
会对F-Queue
队列中的对象进行第二次标记。如果objA
在finalize()
方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA
会被移出**“即将回收”**集合。之后,对象会再次出现没有引用的情况。在这个情况下,finalize
方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize()
方法只会被调用一次。
- 如果对象
JVM类型
- SUN Classic JVM
- 解释器和JIT不能同时使用
- Exact VM
- 准确式内存管理
- 具备现代高性能虚拟机的雏形
- 热点探测
- 编译器和解释器混合工作模式
- Hotspot虚拟机
- 它的名称中的HotSpot指的就是它的热点代码探测技术
- BEA公司的JRockit
- 专注于服务器端应用
- IBM 的J9
- 有影响力的三大商用虚拟机之一
- KVM和CDC/CLDC HotSpot
- JavaME产品
- Azul VM
- 高性能Java虚拟机中的战斗机
- 与特定硬件平台绑定、软硬件配合的专有虚拟机
- Liquid VM
- Apache Harmony
- Microsoft JVM
- TaobaoJVM
- Dalvik VM
- Graal VM
类加载器
获取类加载器的方法:
- 利用
ClassLoader.getSystemClassLoader()
方法 - 利用某个类的
getClassLoader()
方法 - 获取线程的上下文加载器:
Thread.currentThread.getContextClassLoader()
- 本地方法:
DriverManager.getCallerClassLoader()
JVM参数设置
堆空间相关的参数
-
-XX:+PrintFlagsInitial
:打印参数的初始值 -
-XX:+PrintFlagsFinal
:打印参数的最终值 -
-Xms
:堆空间的初始大小(默认为机器内存的1/64) -
-Xmx
:堆空间的最大大小(默认为机器内存的1/4) -
-Xmn
:新生代的大小 -
-XX:SurvivorRatio
: -
-XX:NewRatio
: -
-XX:MaxTenuringThreshold
:设置对象晋升的阈值 -
-XX:+PrintGCDetails
:输出详细的GC
处理日志-verbose:gc
:打印GC简要信息-XX:+PrintGC
:打印GC简要信息
-
-XX:HandlePromotionFailure
:是否启用空间分配担保。在jdk6 update24
之后,(JDK7之后),该参数不会再造成影响,始终为true
-
-Xss:Java虚拟机栈的大小
-
-Xms:堆的初始大小(年轻代+老年代) 等价于:
-XX:InitialHeapSize
-
-Xmx:堆的最大大小 等价于:
-XX:MaxHeapSize
-
默认
-XX:NewRatio=2
:表示新生代占1,老年代占2,新生代占整个堆的1/3.可以修改
-XX:NewRatio=4
:表示新生代占1,老年代占4,新生代占老年代的1/5. -
-Xmn
:设置新生代的最大空间的大小 -
-XX:MaxTenuringThreshold=<N>
:设置什么时候去养老区 -
-XX:+PrintFlagsInitial -XX:+PrintFlagsFinal -Xmn:设置新生代的大小 -MaxTenuringThreshold:设置新生代的最大年龄 //打印GC简要信息 -XX:+PrintGC -verbose:gc //JDK7及以后,该参数不会再有实际影响,默认为true -XX:HandlePromotionFailure:是否设置空间分配担保 -XX:UseTLAB:设置是否开启TLAB -XX:TLABWasteTargetPercent:设置TLAB空间所占用Edan空间的百分比 -XX:+EliminateAllocation:开启了标量替换(默认打开),允许将对象打散分配在栈上。
-
代码优化(逃逸分析):栈上分配、同步省略、标量替换。
逃逸分析只有在服务器端才能开启,参数:
-server
关于对象分配过程的总结
- 针对幸存者S0,S1区的总结:复制之后有交换,谁空谁是to
- 关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不再永久区/元空间收集。