Class类加载
- 加载Loading
通过全限定类名,把一个类从二进制的Class文件加载到内存当中
- 类加载器
Bootstrap
:启动类加载器。加载lib/rt.jar charset.jar核心类 C++实现Extension
:扩展类加载器。加载扩展jar包 jre/lib/ext/*.jarApplication/System
:系统类加载器。加载classpath指定内容,他是我们java中默认的类加载器,我们写的class都是用他加载的Custom ClassLoader
:用户类加载器。自定义ClassLoader
各个加载器之间并不是继承
的关系,只是一个上级下级的关系
- 双亲委派
当一个调用一个类的时候,会先去检查该类是否已经加载过,如果加载了就直接调用;否则会找相对应的类加载器来加载该类。因此分为检查过程
和加载过程
。
检查过程是一个自底向上的过程,这也是比较好理解的,因为越低层,就代表可控性越高,只有比较核心底层的才会放在顶层的加载器。
当询问到最顶层Bootstrap加载器都没有加载过这个类,那么就认为该类没有被加载过。
加载过程是一个自顶向下的过程,每一个类加载器都有自己负责的部分,如果这个类不属于该加载器负责的范围,则会向下询问。
- 双亲委派的意义
- 解耦
- 连接Linking
- 校验 verification
检验二进制流Class文件是否符合jvm虚拟机的要求 主要包括四种验证:文件格式的验证,元数据的验证,字节码验证,符号引用验证。
- 准备 Preparation
内存开辟空间,并且给成员变量赋默认值
注意:这里是赋默认值,并不是赋初始值,比如int a = 1; 在这个过程中是对a赋0,并不是赋值为1,在初始化过程
的时候才是赋值为1 这也是为什么DCL问题中,需要加volatile关键字的原因
- 解析 Resolution
将符号引用转换为直接引用
- 初始化Initializing
给变量赋初始值
以JDK1.8为例,JVM的组成部分如图
指令重排序问题
我们知道CPU的执行速度是远比内存执行的速度高的,我们的代码本质上就是一条一条的指令,或者是多条指令组合而成
我们通过debug
调试的时候,知道代码都是一行一行执行的,但是在CPU中,指令是按顺序一条一条的执行吗?
答案是不一定!
int a = 1;
int b = 2;
复制代码
比如在这个例子中,定义了两个变量,两行代码之间没有关系,CPU为了追求速度,可以不按顺序地去执行。
不管指令最后怎么排序,最后要保证该代码在单机(单线程)的情况下,运行的结果一致,也就是as-if-serial
原则
- as-if-serial
不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。
- DCL单例是否需要添加volatile
public class Instance {
private int a = 1;
//是否添加volatile 关键字
private volatile static Instance ins = null;
public static Instance getInstance(){
if (ins == null){
synchronized (Instance.class){
if (ins == null){
ins = new Instance();
}
}
}
return ins;
}
}
复制代码
答案是需要的!
我们对象创建的时候,要经过三个步骤。
- 申请开辟一个内存空间。给成员变量赋默认值
- 调用构造方法。给成员变量赋初始值
- 建立关联。把符号引用转换为直接引用
当线程1调用DCL单例创建实例对象的时候,当他完成了第一步,给成员变量赋默认值。此时该实例对象是可以被获取到的。
也就是说在DCL最外层判断 ins 是不等于null的,因此他会直接返回ins。
但是这个ins只完成了赋默认值里面的 a = 0
假设我们一个订单号a是从1000开始的,而当前获取到的a是0,那么就会引发很多问题。
加了volatile之后,就能保证线程的可见性,并且禁止了指令重排序。至于感兴趣的小伙伴可以去搜索关键字内存屏障
,volatile是通过LLLS、SSSL的内存屏障组合保证指令的禁止重排序。这里就点到为止,不做过多介绍。
JVM中各个部分的组成说明
堆
用来存放对象实例,包括数组(jdk1.7之后,字符串常量池从永久代剥离出来,存放在堆中)
堆空间的默认内存分配:
- 老年代占三分之二
- 新生代占三分之一
- 其中伊甸(Eden)区占十分之八
- from区、to区各自占十分之一
开发者可以通过参数对分区大小进行配置
-XX:SurvivorRatio=8 (新生代分区比例 8:2)
同时堆也是垃圾回收器(GC)发生的地方,当年轻代满的时候会发生YGC,当老年代满的时候会发生FGC,后面会介绍每种垃圾回收器的各个算法
- 字符串常量池
在jdk1.7之前,存在于方法区中
在jdk1.7及以后,搬到了堆
中
1.7之前String pool里面存放的都是字符串常量,在1.7之后,里面存放的是字符串对象的引用。
String s1 = "abc";
String s2 = "abc";
复制代码
他会先去常量池中找,是否存在有"abc"的对象引用,如果有则返回该地址;否则才会自己创建一个"abc"的String对象。
- 静态常量池
- 静态常量池,也叫做
Class常量池
,里面存放着一些class文件相关的信息,比如版本、字段、方法、接口等描述信息 - 每个class文件都有一个class常量池
- 运行时常量池
- 运行时常量池存在于内存当中,里面的
符号引用
可以被解析为直接引用
- 当类加载之后,常量池中的数据会在运行时常量池中存放
- 年轻代
指刚new出来的对象,尚未经过GC过程的对象,存放在伊甸区(Eden) 年轻代会频繁发生FGC,当发生FGC时,会把Eden区的对象通过复制算法(copy)
拷贝到from区。 当发生下一次FGC的时候,会把Eden区和from区的对象拷贝到to区;再再下次FGC的时候会把Eden区和to区拷贝到from区,然后重复上面的过程。每次都会计数+1,默认情况下加到15的时候(用户可以设置)就会变成老年代的对象。
- 老年代
老年代是指经过多次GC之后,依旧幸存下来的对象。 老年代的对象,要么是年轻代的对象转变过来。要么是一些大的对象直接变成老年代。 如果老年代满了之后,会触发FGC,FGC的频率并不高。
本地方法栈
本地方法栈为虚拟机使用到的native方法服务 虚拟机栈是为虚拟机执行java方法服务
- native方法
Native Method就是一个java调用非java代码的接口。一个Native Method是这样一个java的方法:该方法的实现由非java语言实现,比如C。
虚拟机栈
虚拟机栈,本质上就是一个栈的结构。它里面的每一个栈帧,其实就是一个方法。 我们可以看到栈帧的主要组成部分是4个,分别是局部变量表
、操作数栈
、动态链接
和方法出口信息
。
- 局部变量表
- 八大原始类型(java中小写开头的):boolean、bye、char、short、int、float、long、double以及对象引用
- 对象引用
对象引用
分为直接指针引用和间接(句柄)引用
- 直接指针引用
直接指引引用,指向对方起始地址的引用指针
优点:
- 访问速度快,直接到达
缺点:
当对象发生移动的时候,需要更改引用指针,比如发生GC的时候
- 句柄引用
指向某个数据结构,里面包含了该对象的实例数据指针和类型数据指针
优点:
- 当对象发生移动的时候,只需要改结构体中的实例数据指针即可,在GC的时候
缺点:
- 需要开辟额外的空间存放结构体
#-## 操作数栈
操作数栈也有的叫操作栈 比如执行一个简单的两个参数相加的函数 需要先从操作数栈中将2个数值出栈,然后运算完了之后再将结果入栈 其中涉及到的指令: iload iload iadd istore之类的
- 动态链接
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。 包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接( Dynamic Linking)。比如: invokedynamic指令(在1.7新增的指令)
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用( symbolic Reference)保存在class文件的常量池里。 比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
- 方法出口信息
每个java方法在被调用的时候,都会创建一个
栈帧
并入栈 一旦完成调用,会自动出栈,因此不需要gc进行回收,堆就需要gc进行回收
程序计数器
jvm中占用空间极小,几乎可以忽略不计,但是计算速度也是最快的区域
用于存储指向下一条指令的地址,也就是即将执行的指令代码。
直接内存(堆外内存)
属于堆外内存,可以通过native方法分配
直接内存IO读写的性能要优于普通的堆内存
不受GC的影响,需要手动回收
直接内存是通过Unsafe类来实现的
方法区
元数据区和永久代本质上都是方法区的实现,方法区是一个逻辑上的抽象概念,每个版本的jvm都有其不同的实现方式,元数据区是hotspot的实现方式
- 永久代(Permanent)
在java1.7版本时,hotspot中方法区位于永久代中,同时永久代和堆是相互隔离的,但是他们使用的物理内存是连续的
相关的jvm参数:
-XX:PermSize
:表示非堆区初始内存分配大小,其缩写为permanent size(持久化内存)-XX:MaxPermSize
:表示对非堆区分配的内存的最大上限
java1.8后都是用本地内存(native memory) 来实现方法区,并将方法区的常量池放在堆中
放弃永久代的原因:维护和合并
- 手动设置永久代的大小,一方面如果小了点的话,会出现异常(OOM),如果大了的话就会浪费空间,不太利于维护。使用元空间时,是由系统实际可用的空间来控制加载类的数据
- 更深层次的原因是,hotspot要合并jrockit的代码,jrockit没用所谓的永久代,但是性能也非常好
- 元空间(MetaSpace)
用来存放:
- 类的方法代码
- 变量名
- 方法名
- 访问权限
比较表面的东西
方法区存在于元空间,元空间不再与堆连续,而是存在于本地内存 (Native memory)
GC(Garbage collector)
到底什么是垃圾?为什么要垃圾回收器?想要搞清楚垃圾回收机制,那么我们首先要清楚,jvm对于垃圾的定义。
引用计数法(Reference Count)
直观的,我们认为,如果一个对象没有其他对象引用他,那么他就是垃圾。因此我们每当另外一个对象对该对象产生引用的时候,就计数。
bug: 引用计数法,无法解决循环依赖的问题。
根可达算法(RootSearching)
根可达算法,就是对象到达GcRoot的路径是否还有可达,即是否有可引用链,如果有,这表明对象还存在着引用, 如果没有,则表明该对象没有引用,在下一次垃圾回收时就会被回收
- GcRoot的种类
1.虚拟机栈:栈帧中的局部变量表引用的对象
2.native方法引用的对象
3.方法区中的静态变量和常量引用的对象
清除算法
- 复制算法(Copying)
年轻代的算法,将可用内存分为相同的两块,每次只用一块,当用完时,将存活的对象复制到另一块上,再把已用的空间清理掉。第一次扫描eden区的对象,活下来的复制到survivor1,然后释放eden区的。第二次扫描eden,survivor1区,活下来的对象放survivor2,然后释放survivor1和eden区。再下一次就扫eden和survivor2,存survivor1,交叉复制这样。
- 标记清除算法(Mark-Sweep)
标记清除:先标记,再统一回收,会产生大量不连续的内存碎片
- 标记整理算法(Mark-Compact)
标记整理:先标记,让所有存活的对象向一端移动,然后清理掉边界外的内存没有碎片
垃圾回收器
技术的驱动力是业务,脱离了业务,讲技术,都是耍流氓。
随着我们的业务发展,jvm产生的垃圾,也会慢慢变多。 最开始可能只有几兆~几十兆,到后面几十G以上。 并且垃圾回收器是搭配着用的,年轻代 + 老年代各用一款 比如Serial + Serial Old
- Serial
特点:单线程、STW 在执行垃圾回收的期间,采用单线程
的方法,通过STW
进行垃圾回收。通常搭配Serial Old使用。
如果垃圾太多,那么STW过程会很长,只适合轻量级系统使用
STW(stop the word)
Stop一the一World,简称STW,指的是Gc事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。
- Parallel
特点:多线程、STW
Serial算法的瓶颈在于他是单线程进行的,Parallel
则是发采用多线程进行,但是还是会有STW过程。
通常搭配:Parallel Scavenge + Parallel Old
JDK 1.8 默认就是 PS + PO
即便是采用了多线程,但还是会有瓶颈,同时回收的时候会影响业务无法进行
- CMS
CMS(concurrent mark sweep) 老年代
可以和年轻代地算法 ParNew一起使用 ParNew是Parallel 为了适合cms兼容后的算法
并发地
能在不停止业务的前提下,进行回收
初始标记
:也是STW,不过这个暂停的不是整个内存,而是从root根出发去找,因此花费的时间就比较少。并发标记
:这里会出现错标。比如一个对象被标记了垃圾,但是后面又有对象指向了这个对象。又或者是这个对象本来不是垃圾,后面又变成了垃圾。重新标记
:STW阶段,修正第二部的并发清理
:用的是标记清理算法,mark sweep
- 三色标记法
黑:自己已经被标记,并且引用对象也被标记完 灰:自己已经被标记,但是引用对象每被标记完 白:没有被标记到的
- Incremental Update
设想:三色标记法一定能保证不会出现漏标情况吗?
首先GC扫了A对象,及其引用对象B,所以A是黑色的 GC扫了B,但是没有扫他的引用对象C,所以B是灰色的 C还没有被扫描所以是白色的
此时由于业务需求,B对C的引用取消了,但是A对C建立了引用。
问题:但是由于A被标记为了黑色,因此GC不会再去扫A和A的引用。但是C被标记为白色,因此会被回收掉,如果A调用C,则会出现NullPointException
解决办法:重新标记,当建立新的引用的时候,把A标记为灰色,即可解决这个漏标的问题。
CMS的缺点: 由于采用的是Mark Sweep标记清楚算法,因此会产生内存碎片垃圾
- G1
G1垃圾回收器,摒弃了以往的分代算法,采用分区(Region)算法 虽然物理上不分代,但是逻辑上依旧分代。保留了Eden区、Old区的叫法。
分区算法 Region
每个区都可能是年轻代、老年代,但是在同一时刻都只属于某个代
分区算法指的是,有的分区垃圾对象特别多,有的分区垃圾对象比较少,那么G1就会优先把垃圾对象特别多的分区给回收掉,这样就能减少回收等待的时间
原因:
- 因为随+着内存的越来越大,年轻代越来越大,扫描的速度就越来越慢
- CMS的致命缺点
G1:
年轻代
:每一次回收年轻代,都会把所有的年轻代回收掉(YGC)老年代
:G1自带压缩的收集器,在回收老年代的分区时,是将存活的对象从一个分区拷贝到另一个可用分区,这个过程实现了局部的压缩。每一个分区的大小都是1到32M不等,都是2的幂次方
。
RSet
remember set
存在着于 Region里面的某个区域
记录其他Region引用当前Region
当引用消失的时候,会把这条引用记录到RSet里面取
CSet
clean set
记录当前GC需要清理的Region
YGC
发生YGC的时候,会把E区和S(from)区的对象合并拷贝到新的一个S(to)区
MixGC
G1没有单独的OGC,因此传统的OGC是和YGC一起清理,称为MixGC
- 初次标记:STW阶段
- 并发标记:同cms相同,只不过遍历范围缩小,只需要去遍历Rset中记录过的Region区
- 重新标记:同cms相同,只不过用了
SATB
也是SWT阶段 - 清理:只选出垃圾清理较多的Region
SATB
snapshot ai the beginning
在并发标记的时候做一个快照,在重新标记的时候处理RSet中的引用
灰色执行白色的引用消失的时候?
当B->D的引用消失的时候,把这个引用存到GC的堆栈,保证D还能被GC扫描到
配合RSet,只用扫描哪些Region引用到D这个Region了
当GC回来之后,发现有引用增加,代表有对象的引用消失了,那么就去扫一下那个D,看看是不是垃圾
相关参数
-XX:+UseG1GC 开启G1垃圾回收器
-XX:GCHeapRegionSize 分区大小
-XX:InitializingHeapOccupancyPercent=30 触发G1回收的最大堆内存占百分比
-XX:MaxGCPauseMillis 最大暂停时间的目标
-XX:GCPauseIntervalMillis GC的间隔时间