黑马JVM笔记
1.引言
1.1.什么是JVM?
定义
Java Virtual Machine- Java程序运行环境(Java)
好处:
- 一次编写,到处运行
- 自动内存管理,垃圾回收功能
- 数组下标越界检查
- 多态
比较:
jvm jre jdk
2.内存结构
- 程序计数器
- 虚拟机栈
- 本地方法栈
- 堆
- 方法区
2.1.程序计数器
作用
记住下一条jvm指令的执行地址
特点
- 线程私有
- 不会存在内存溢出
2.2.虚拟机栈
- 每个线程运行时所需要的内存,称为虚拟机栈
- 每个栈由多个栈帧(Frame)组成,对应每个方法调用所占用的内存,栈帧中包括了局部变量,操作数栈,动态链接,方法返回地址
- 每个线程只能有一个活动栈帧,对应着当前正在执行的方法
问题辨析
- 垃圾回收是否涉及栈内存?
- 不需要。虚拟机栈由一个个栈帧组成,在方法执行完毕后,对应栈帧会弹出栈。所以无需通过垃圾回收机制回收内存。
- 栈内存分配越大越好?
- 不是,物理内存是一定,栈内存越大,可以支持更多的方法调用,但是可执行线程数越少。
- 方法内的局部变量是否线程安全?
- 如果方法内局部变量没有逃离方法的作用范围,则是线程安全。
- 如果局部变量引用对象,并逃离方法的作用范围,则需要考虑线程安全
2.3.本地方法栈
一些带有native关键字的方法需要Java去调用本地的C或者C++方法,因为Java有时候没法直接和操作系统交互,所以需要本地方法。
2.4.堆
定义
通过new关键字,创建对象都会使用堆内存
特点
- 线程共享,堆中对象都需要考虑线程安全问题
- 有垃圾回收机制
2.5.方法区
2.5.1.定义
It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods used in class and instance initialization and interface initialization.
2.5.2.组成
2.5.3.运行时常量池
- 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的表名、方法名、参数类型、字面量等信息
- 运行时常量池,常量池是*.class文件中,当该类被加载,它的常量池信息放入运行时常量池,并把里面的符号地址变成真实地址
- 1.8后运行时常量池还在方法区,方法区的实现为元空间,字符串常量池在堆
2.5.4.StringTable特性
- 常量池中的字符串仅是符号,第一次用到时才变成对象
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是StringBuilder
- 字符串常量拼接的原理是编译期优化
- 可以使用intern方法,主动将串池中还没有的字符串对象加入串池。JDK1.8将字符串对象尝试放入串池,如果有则不会放入,如果没有则将对象引用放入串池,而不会重新创建对象,因为StringTable在堆中
2.6.直接内存
2.6.1.定义
Direct Memory
- 常见于NIO操作时,用于数据缓冲区
- 分配回收成本高,但读写性能高
- 不受JVM内存回收管理
2.6.2.分配和回收原理
- 使用Unsafe对象完成直接内存的分配回收,并且回收需要主动调用freeMemory方法
- ByteBuffer的实现类内部,使用Cleaner(虚引用)来监控ByteBuffer对象,一旦ByteBuffer对象被垃圾回收,就会由ReferenceHandler线程通过cleaner的clean方法调用freeMemory来释放直接内存
3.垃圾回收
3.1.如何判断对象可以回收
3.1.1 引用计数法
弊端: 循环引用时,两个对象的计数都为1,导致两个对象都无法释放
3.1.2 可达性分析算法
- Java虚拟机中垃圾回收器采用可达性分析来探索所有存活对象
- 扫描堆中对象,看是否能够沿着
GC ROOT对象
为起点的引用链找到该对象,找不到,表示可以回收 - 哪些对象可以作为
GC ROOT
?- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(Native方法)引用的对象
3.1.3 五种引用
- 强引用
- 只有所有GC ROOT对象都不通过【强引用】引用该对象,该对象才能垃圾回收
- 软引用
- 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收,回收软引用对象
- 可以配合引用队列来释放软引用自身
- 弱引用
- 仅有软引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
- 可以配合引用队列释放弱引用自身
- 虚引用
- 必须配合引用队列使用,主要配合ByteBuffer使用,被引用对象回收时,会将虚引用入队,由Reference Handler线程调用虚引用相关方法释放直接内存
- 终结器引用
- 无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize方法,第二次GC时才能回收被引用对象
引用队列
- 软引用和弱引用可以配合引用队列
- 在软引用和弱引用所引用的对象被回收后,将这些引用放入引用队列中,方便一起回收这些软/弱引用对象
- 虚引用和终结器引用必须配合引用队列
- 虚引用和终结器引用在使用时会关联一个引用队列
3.2 垃圾回收算法
3.2.1 标记清除
定义
在虚拟机执行垃圾回收的过程中,先采用标记算法确定可回收对象,然后垃圾收集器根据标识清除相应内容,给堆内存腾出相应空间
- 这里腾出内存空间并不是将内存空间的字节清0,而是记录下这段内存的起始结束地址,下次分配内存的时候,直接覆盖这段内存
特点
- 速度较快
- 会造成内存碎片
3.2.2 标记整理
- 速度慢
- 没有内存碎片
3.2.3 复制
定义
将内存分为等大小的两个区域FROM和TO,先将被GC ROOT引用的对象从FROM放入TO,再回收不被GC ROOT引用的对象。然后交换FROM和TO。
特点
- 不会有内存碎片
- 占用双倍内存
3.3分代垃圾回收
- 对象首先分配在伊甸园区域
- 新生代空间不足时,触发minor gc,伊甸园和From存活的对象使用copy复制到to中,存活的对象年龄加1并且交换from to
- minor gc会引发stop the world,暂停其他用户线程,等垃圾回收结束,用户线程才恢复运行
- 当对象寿命超过阈值,会晋升老年代,最大寿命是15(4 bit)
- 当老年代空间不足,会首先尝试触发minor gc,如果之后空间仍不足,那么触发full gc,扫描新生代和老年代中所有不再使用的对象并回收,STW的时间更长
3.4垃圾回收器
- 串行
- 单线程
- 堆内存较小
- 吞吐量优先
- 多线程
- 堆内存较大,多核CPU
- 单位时间内,垃圾回收时间占比最低
- 响应时间优先
- 多线程
- 堆内存较大,多核CPU
- 单次STW时间最短
3.4.1 串行
3.4.2 Serial收集器
特点:单线程、采用复制算法。对于限定单CPU的环境,Serial收集器由于没有线程交互的开销,收集效率高。收集器进行垃圾回收时,必须暂停其他所有工作线程,直到它结束(STW)
3.4.3 ParNew收集器
特点:Serial收集器多线程版本,多线程,和Serial收集器一样存在STW问题。
3.4.4 serial Old收集器
Serial Old是Serial收集器的老年代版本
特点:同样单线程收集器,采用标记-整理算法(老年代没有幸存区)
3.4.5 吞吐量优先
3.4.6 Parallel Scavenge收集器
与吞吐量关系密切,也称为吞吐量优先收集器
特点: 属于新生代收集器,采用复制算法,并行多线程收集器
3.4.7 Parallel Old收集器
Parallel Scavenge收集器老年代版本
特点:多线程,采用标记-整理算法
3.4.8 响应时间优先
3.4.9 CMS收集器
Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器
特点:基于标记-清除算法。并发收集、低停顿,但是会产生碎片
运行过程分为下列4步:
- 初始标记:标记GC ROOT能直接到的对象。速度快但是存在STW问题
- 并发标记:进行GC ROOT Tracing过程,找出存活对象并且用户线程可并发执行
- 重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那部分对象的标记记录。存在STW问题
- 并发清除:对标记对象进行清除回收,内存回收过程是与用户线程并发执行
3.4.10 G1收集器
G1收集器的运作过程大致可划分为以下四个步骤:
- 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
- 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
- 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
- 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
从上述阶段的描述可以看出,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的
适用场景:
- 同时注重吞吐量(Throughput)和低延迟(Low latency)
- 超大堆内存,会将堆划分多个大小相等的Region
- 整体是标记+整理算法,两个区域之间是复制算法
G1垃圾回收阶段
新生代伊甸园垃圾回收—–>内存不足,新生代回收+并发标记—–>回收新生代伊甸园、幸存区、老年代内存——>新生代伊甸园垃圾回收(重新开始)
Young Collection
分区算法region
分代是按对象的生命周期划分,分区则是将堆空间划分连续几个不同小区间,每一个小区间独立回收,可以控制一次回收多少个小区间,方便控制 GC 产生的停顿时间
E:伊甸园 S:幸存区 O:老年代
- 会STW
Young Collection + CM
- 在 Young GC 时会进行 GC Root 的初始标记
- 老年代占用堆空间比例达到阈值时,进行并发标记(不会 STW),由下面的 JVM 参数决定
Mixed Collection
会对 E、S、O 进行全面垃圾回收
- 最终标记(Remark)会 STW
- 拷贝存活(Evacuation)会 STW
问:为什么有的老年代被拷贝了,有的没拷贝?
因为指定了最大停顿时间,如果对所有老年代都进行回收,耗时可能过高。为了保证时间不超过设定的停顿时间,会回收最有价值的老年代(回收后,能够得到更多内存)
Full GC
- SerialGC
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足发生的垃圾收集 - full gc
- ParallelGC
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足发生的垃圾收集 - full gc
- CMS
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足 - full gc
- G1
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足
G1在老年代内存不足时(老年代所占内存超过阈值)
- 如果垃圾产生速度慢于垃圾回收速度,不会触发Full GC,还是并发地进行清理
- 如果垃圾产生速度快于垃圾回收速度,便会触发Full GC
Young Collection 跨代引用
新生代回收的跨代引用(老年代引用新生代)问题
- 卡表与
Remembered Set
- Remembered Set 存在于E中,用于保存新生代对象对应的脏卡
- 脏卡:O被划分为多个区域,如果该区域引用了新生代对象,则该区域被称为脏卡
- Remembered Set 存在于E中,用于保存新生代对象对应的脏卡
- 在引用变更时通过
post-write barrier
+dirty card queue
concurrent refinement threads
更新Remembered Set
Remark
重新标记阶段
在垃圾回收时,收集器处理对象的过程中
黑色:已被处理,需要保留的
灰色:正在处理中的
白色:还未处理的
但是在并发标记过程中,有可能A被处理了以后未引用C,但该处理过程还未结束,在处理过程结束之前A引用了C,这时就会用到remark
过程如下
- 之前C未被引用,这时A引用了C,就会给C加一个写屏障,写屏障的指令会被执行,将C放入一个队列当中,并将C变为处理中 状态
- 在并发标记阶段结束以后,重新标记阶段会STW,然后将放在该队列中的对象重新处理,发现有强引用引用它,就会处理它
4.类加载与字节码技术
4.2 字节码指令
4.2.3 图解方法执行流程
1.原始Java代码
2.编译成字节码文件
3.常量池载入运行时常量池
常量池也属于方法区,只不过这里单独提出来了
4.方法字节码载入方法区
main线程开始运行,分配栈帧内存
执行引擎开始执行字节码
4.2.10多态原理
因为普通成员方法需要在运行时才能确定具体的内容,所以虚拟机需要调用invokevirtual指令
在执行invokevirtual指令时,经历了以下几个步骤
- 先通过栈帧中对象的引用找到对象
- 分析对象头,找到对象实际的Class
- Class结构中有vtable
- 查询vtable找到方法的具体地址
- 执行方法的字节码
4.4 类加载阶段
4.4.1 加载
《深入理解Java虚拟机》
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
- 将类的字节码载入方法区中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:
- _java_mirror 即 java 的类镜像,例如对 String 来说,它的镜像类就是 String.class,作用是把 klass 暴露给 java 使用
- _super 即父类
- _fields 即成员变量
- _methods 即方法
- _constants 即常量池
- _class_loader 即类加载器
- _vtable 虚方法表
- _itable 接口方法
- 如果这个类还有父类没有加载,先加载父类
- 加载和链接可能是交替运行的
- instanceKlass保存在方法区。JDK 8以后,方法区位于元空间中,而元空间又位于本地内存中
- _java_mirror则是保存在堆内存中
- InstanceKlass和*.class(JAVA镜像类)互相保存了对方的地址
- 类的对象在对象头中保存了*.class的地址。让对象可以通过其找到方法区中的instanceKlass,从而获取类的各种信息
4.4.2 链接
验证
验证类是否符合 JVM规范,安全性检查
准备
《深入理解Java虚拟机》
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。
为 static 变量分配空间,设置默认值
- static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾
- static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
- 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶
- 段完成
- 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成
解析
将常量池中的符号引用解析为直接引用
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。
直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚
拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
4.4.3 初始化
初始化阶段就是执行类构造器clinit()方法的过程,虚拟机会保证这个类的『构造方法』的线程安全
- clinit()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的
注意
编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如
发生的时机
概括得说,类初始化是【懒惰的】
- main 方法所在的类,总会被首先初始化
- 首次访问这个类的静态变量或静态方法时
- 子类初始化,如果父类还没初始化,会引发
- 子类访问父类的静态变量,只会触发父类的初始化
- Class.forName
- new 会导致初始化
不会导致类初始化的情况
-
访问类的 static final 静态常量(基本类型和字符串)不会触发初始化
-
类对象.class 不会触发初始化
-
创建该类的数组不会触发初始化
-
类加载器的 loadClass 方法
-
Class.forName 的参数 2 为 false 时
验证类是否被初始化,可以看改类的静态代码块是否被执行
4.5 类加载器
名称 | 加载哪的类 | 说明 |
---|---|---|
Bootstrap ClassLoader | JAVA_HOME/jre/lib | 无法直接访问 |
Extension ClassLoader | JAVA_HOME/jre/lib/ext | 上级为Bootstrap,显示为null |
Application ClassLoader | classpath | 上级为Extension |
自定义类加载器 | 自定义 | 上级为Application |
4.5.1 启动类加载器
4.5.2 扩展类加载器
4.5.3 双亲委派模式
所谓的双亲委派,就是指调用类加载器的 loadClass 方法时,查找类的规则
4.5.4 线程上下文类加载器
JDBC使用ServiceLoader机制加载Driver驱动,即Service Provider Interface (SPI)
ServiceLoader.load 方法由Class.forName 调用了线程上下文类加载器完成类加载,线程上下文类加载器是当前线程使用的类加载器,默认就是应用程序类加载器
4.6 运行期优化
即时编译器(JIT)与解释器的区别
- 解释器
- 将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
- 是将字节码解释为针对所有平台都通用的机器码
- 即时编译器
- 将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
- 根据平台类型,生成平台特定的机器码
对于大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。 执行效率上简单比较一下 Interpreter < C1 < C2,总的目标是发现热点代码(hotspot名称的由 来),并优化这些热点代码
逃逸分析
逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术
逃逸分析的 JVM 参数如下:
- 开启逃逸分析:-XX:+DoEscapeAnalysis
- 关闭逃逸分析:-XX:-DoEscapeAnalysis
- 显示分析结果:-XX:+PrintEscapeAnalysis
逃逸分析技术在 Java SE 6u23+ 开始支持,并默认设置为启用状态,可以不用额外加这个参数
对象逃逸状态
全局逃逸(GlobalEscape)
- 即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:
- 对象是一个静态变量
- 对象是一个已经发生逃逸的对象
- 对象作为当前方法的返回值
参数逃逸(ArgEscape)
- 即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的
没有逃逸
- 即方法中的对象没有发生逃逸
逃逸分析优化
针对上面第三点,当一个对象没有逃逸时,可以得到以下几个虚拟机的优化
锁消除
我们知道线程同步锁是非常牺牲性能的,当编译器确定当前对象只有当前线程使用,那么就会移除该对象的同步锁
例如,StringBuffer 和 Vector 都是用 synchronized 修饰线程安全的,但大部分情况下,它们都只是在当前线程中用到,这样编译器就会优化移除掉这些锁操作
锁消除的 JVM 参数如下:
- 开启锁消除:-XX:+EliminateLocks
- 关闭锁消除:-XX:-EliminateLocks
锁消除在 JDK8 中都是默认开启的,并且锁消除都要建立在逃逸分析的基础上
标量替换
首先要明白标量和聚合量,基础类型和对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,比如:对象
对象是聚合量,它又可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换。
这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能
标量替换的 JVM 参数如下:
- 开启标量替换:-XX:+EliminateAllocations
- 关闭标量替换:-XX:-EliminateAllocations
- 显示标量替换详情:-XX:+PrintEliminateAllocations
标量替换同样在 JDK8 中都是默认开启的,并且都要建立在逃逸分析的基础上
栈上分配
当对象没有发生逃逸时,该对象就可以通过标量替换分解成成员标量分配在栈内存中,和方法的生命周期一致,随着栈帧出栈时销毁,减少了 GC 压力,提高了应用程序性能
5.内存模型
内存模型内容参考 4. 共享模型之管程
么就会移除该对象的同步锁
例如,StringBuffer 和 Vector 都是用 synchronized 修饰线程安全的,但大部分情况下,它们都只是在当前线程中用到,这样编译器就会优化移除掉这些锁操作
锁消除的 JVM 参数如下:
- 开启锁消除:-XX:+EliminateLocks
- 关闭锁消除:-XX:-EliminateLocks
锁消除在 JDK8 中都是默认开启的,并且锁消除都要建立在逃逸分析的基础上
标量替换
首先要明白标量和聚合量,基础类型和对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,比如:对象
对象是聚合量,它又可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换。
这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能
标量替换的 JVM 参数如下:
- 开启标量替换:-XX:+EliminateAllocations
- 关闭标量替换:-XX:-EliminateAllocations
- 显示标量替换详情:-XX:+PrintEliminateAllocations
标量替换同样在 JDK8 中都是默认开启的,并且都要建立在逃逸分析的基础上
栈上分配
当对象没有发生逃逸时,该对象就可以通过标量替换分解成成员标量分配在栈内存中,和方法的生命周期一致,随着栈帧出栈时销毁,减少了 GC 压力,提高了应用程序性能
5.内存模型
内存模型内容参考 4. 共享模型之管程