JVM 上篇
2024-05-18 13:47:56 11 举报
AI智能生成
JVM 上篇
作者其他创作
大纲/内容
StringTable
StringTable 为什么要调整
JDK7 中将 StringTable 放到了堆空间中,因为永久代的回收效率很低。在 Full GC 的时候才触发,而 Full GC 是老年代空间不足,永久代不足时才触发。这就导致了 StringTable 回收效率不高,而我们开发中会创建大量的字符串,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存
String 的基本特性
字符串,用""引起来表示
声明为 final 的不可被继承的
实现了 Serializable 接口,表示支持序列化
实现了 Comparable 接口,表示可以比较大小
JDK8 及以前,内部定义了 final char[] value 用于存储字符串数据
JDK9 时改为 byte[]
动机
char 数组一个 char 占 16bits ,String 是堆空间的主要部分,大部分是 latin-1 字符,,一个字节就够了,这样会有一半空间浪费
中文等 UTF-16 的用两个字节存储
StringBuffer,StringBuilder 同样做了修改
String 代表不可变的字符序列(简称不可变性)
当字符串重新赋值,需要重写指定内存区域赋值,不能使用原有的 value 进行赋值
当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能对使用原有的 value 进行赋值
当调用 String 的 replace 方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值
通过字面量的方式,区别与 new 给一个字符串赋值,此时的字符串值声明在字符串常量池中
字符串常量池中不会存储相同的字符串的
String 的 String pool 是一个固定大小的HashTable,默认大小长度是1009,如果放进 String Pool 的 String 非常多,就会造成 Hash 冲突严重,从而导致链表会很长,而链表长了,直接影响就是调用 String.intern 时性能会大幅下降
-XX:StringTableSize 可设置 StringTable 的大小
JDK6 固定 1009,JDK7 中 StringTable 默认的长度是 60013,JDK8 时默认是 60013,1009 是可设置的最小值(设置小于它报错)
String 的内存分配
Java 语言中有 8 种基本数据类型和一种比较特殊的类型 String,这些类型为了使他们再运行过程中速度更快,更节省内存,都提供了一种常量池的概念
String的常量池比较特殊,主要使用方法有两种
直接使用双引号,声明出来的String对象会直接存储在常量池中
如果不是双引号声明的String对象,可以使用String提供的intern()方法
JDK6 及之前,字符串常量池存在永久代
JDK7中,字符串常量池调整到 Java 堆中
调优时仅需调整堆大小就可以
JDK8中,元空间,字符串常量在堆
为什么要调整?
永久代默认情况下比较小,大量字符串容易导致 OOM
永久代垃圾回收频率低
验证不同版本常量池在哪的程序(报哪里溢出说明它在哪)
String的基本操作
Java 语言规范要求完全相同的字符串字面量,应该包含同样的 Unicode 字符序列,包含同一份码点序列的常量,并且必须指向同一个 String 类实例
验证程序
程序1
程序2
字符串拼接操作
1.常量与常量的拼接结果在常量池,原理是编译期优化
2.常量池中不存在相同内容的常量
3.只要其中有一个变量,拼接结果就在堆中(常量池以外的堆),变量的拼接原理是 StringBuilder
4.如果拼接的结果调用 intern 方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
补充说明
字符串拼接操作不一定使用的是 StringBuilder 如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非 StringBuilder 的方式
字符串的 toString 方法会返回一个新的 String 对象引用(在堆中),所以当调用该方法再进行拼接操作走的是变量拼接那一步
对比用 + 号拼接字符串和 StringBuilder.append 操作对比
拼接 10万次,+号 4000,append 用了 7 毫秒,原因是 + 号每次循环创建一个 StringBuilder,还要通过 toString 创建一个 String 对象
内存中由于创建了较多的对象,内存占用更大,如果需要 GC 需要花费额外的时间
改进空间:StringBuilder 默认是 16 长度的 char 型数组,不够的时候会扩容,可以一次建一个比较大长度的数组
intern() 方法
如果字符串常量池中,通过 equals 判断是否相同,如果没有则在常量池中生成
确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度,注意,这个值会被存放在字符串内部池。(String intern pool)
面试题
前置知识
new String("a")+new String("b")?
对象1,有拼接操作就newStringBuilder
对象2,new一个String
对象3,常量池a
对象4,new String
对象5,常量池b
对象6,StringBuilder,toString 方法会 new String 返回
此时字符串常量池中没有 ab
newString("ab") 会创建几个对象?
2 个对象,查看字节码验证。一个是常量池 ab,一个是 new 出来在堆空间(前提是常量池没有ab)
题目
JDK6 执行结果:false false
调用 s.intern 方法之前,字符串常量池已经有 1
JDK7/8:false true
s3 的变量地址为:new String (“11”)。执行完字符串常量池中不存在11,s3.intern会在字符串常量池生成11,s4变量记录的地址是常量池中的
JDK7/8:此时常量池中并没有创建11,而是创建一个指向堆空间的中newString(“11”)的地址
变形
总结
JDK1.6 中,将这个字符串对象放入串池
如果串池中有,则并不会放入,返回已有串池中的对象的地址
如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址
JDK1.7 起,将这个字符串对象尝试放入串池
如果串池中有,则并不会放入,返回已有的串池中的对象的地址
如果没有,则会把此对象的引用地址复制一份,放入串池,并返回串池中的引用地址
练习
练习 1
JDK 6 运行分析
JDK 7/8 运行分析
修改一下代码继续分析
练习 2
intern()的效率测试
结论:对于程序中大量存在存在的字符串,尤其其中存在很多重复字符串时,使用 intern()可以节省内存空间
比如社交网站,很多人存储:北京市,海淀区等信息,这时候如果字符串调用 intern 方法,则会明显降低内存的大小,存储时间貌似也会降低
测试截图
程序
非 intern()方法结果
intern()方法结果
Java VirtualVM 结果说明/一些自己思考的问题
1.为什么“intern()方法结果” String[] ,String 和 char[] 的实例数那么多,不是 intern()后回收利用原本的值,也就是 String 最多 9 个实例
因为启动运行一个 Java 进程加载的类不仅仅是我们运行的,可能还要加载其它很多类,这些类可能用到 String[] ,String 和 char[] 啥的,所以这里看到的是我们程序和其它类产生的实例总和
2.为什么 String[] 的大小无论是哪种方法占用字节基本不变?
因为 String[] 存储的是 String 的引用/地址,在 Java 中每个引用的大小占 4 字节,所以这里 String[] 大小是 10.000.000 则这里占 40.000.000 字节
3.为什么“非 intern()方法结果” char[] 占的总字节这么多,我们的程序每个 String 存储的是 1-9,也就是 1 个字符, char[] 大小应该 1 个字符占 2 个字节。也就是这里 10.000.000 个 char[],每个占 2 字节,总共 20.000.000
因为这个结果可能与 Java VisualVM 工具显示的方式有关。工具可能会将对象的内存占用以字节数为单位进行展示,并且包括了额外的开销,如对象头、对齐和其他元数据。因此,即使一个字符数组只有一个字符,它在实际内存中被分配的空间可能要比字符本身的大小大得多
一个 char[] 数组的额外开销通常会在几个字节到几十个字节之间。这些额外开销的大小可能因 JVM 版本、平台架构(32 位或 64 位)和配置参数的不同而有所不同。具体的数值可能难以精确估计,因为这些开销会受到多种因素的影响
额外开销是与实例相关的。如 String[] 并不会随着数组中每个字符串内容的不同而变化。这些开销通常是固定的,并且与数组的大小有关,而不是与数组中每个字符串的内容有关
此处小结(除了内容占用字节是准确的,其它占用字节都是大约):这么一算 char[] 内容两个字节,额外开销算 22 字节,实例 10.000.000 个。总字节占用 = 22 * 10.000.000 + 2 * 10.000.000 = 240.000.000,差不多正确。至于 String[] 内容占两字节,额外开销算 22字节,实例 2000 个。总字节占用 = 22 * 2000 + 2 * 10.000.000 = 40.044.000,差差不多,有差别也是额外开销估算不准带来的。
小技巧:有其它不懂的地方可以进行控制变量法对照试验。空代码运行看看原本实例,字节等等大概多少,再真正跑代码看看又占多少,很多问题如问题1都是因为程序运行不仅仅加载我们要目标类,还可能加载一些其它的类导致的
Stringtable 的垃圾回收
-XX:+PrintStringTableStatistics
G1 中 String 去重操作
背景:对许多 Java 应用,做的测试结果如下
堆存货数据集合里面 String 对象占了 25%
堆存活数据集合里面重复的 String 对象有 13.5%
String 对象的平均长度是 45
许多大规模的 Java 应用的瓶颈在于内存。Java 堆中存活的数据集合差不多 25% 是 String 对象,这里差不多一半的 String 对象是重复的, 重复是指 equals 方法 =true,堆上重复的 String 对象必然是一种内存的浪费。G1 垃圾收集器中实现自动持续对重复的 String 对象进行去重,这样避免浪费
垃圾回收概述
如同我们生活的房子一样,房子空间(内存)有限,如果垃圾太多,房子空间迟早被占满。只不过现在我们管理的是 JVM 这个房子(或内存)的垃圾
经典三个问题
哪些内存需要回收
什么是垃圾
垃圾是指运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾
什么时候回收
如何回收
垃圾回收相关算法(方法论)
标记阶段:引用计数算法
对每个对象保存一个整型的引用计数器属性,用于记录被对象引用的情况
被对象引用了就 +1,引用失效就 -1,0 表示不可能再被使用,可进行回收
优点:实现简单,垃圾便于辨识,判断效率高,回收没有延迟性
缺点
需要单独的字段存储计数器,增加了存储空间的开销
每次赋值需要更新计数器,伴随加减法操作,增加了时间开销
无法处理循环引用的情况,致命缺陷,导致 Java 的垃圾回收器中没有使用这类算法
小结
引用计数算法,是很多语言的资源回收选择,例如 python,它更是同时支持引用计数和垃圾回收机制
Python如何解决循环引用
手动解除
使用弱引用,weakref,python 提供的标准库,旨在解决循环引用
标记阶段:可达性分析算法
Java c# 选择的
基本思路
是以根对象(GC Roots)为起始点,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达
使用可达性分析算法后,内存中存活的对象都被被根对象集合直接或间接连接着,搜索所走过的路径称为引用链
如果目标对象没有任何引用链相连,则是不可达的,意味着该对象已经死亡,可以标记为垃圾对象
在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活的对象
GC Roots包括
虚拟机栈中引用的对象
比如各个线程被调用的方法中使用到的参数、局部变量
本地方法栈内 JNI,引用的对象
方法区中静态属性引用的对象
比如:Java类的引用类型静态变量
方法区中常量引用的对象
比如字符串常量池里的引用
所有被同步锁 synchronized 持有的对象
Java 虚拟机内部的引用
基本数据类型对应的 class 对象,一些常驻的异常对象,如 nullpointerException,OOMerror,系统类加载器
反映 Java 虚拟机内部情况的 JMXBean,JVMTI 中注册的回调,本地代码缓存等
除了固定的 GC Roots 集合之外,根据用户选择的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象临时性的加入,共同构成完整 GC Roots 集合,比如分代收集和局部回收
如果只针对 Java 堆中某一块内存区域进行垃圾回收,必须要考虑这个区域的对象可能被其他区域对象所引用,这是需要一并将关联的区域对象加入 GC Roots 集合中去考虑,才能保证可达性分析的准确性
其它说明
小技巧
由于 Root 采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那么它就是一个 Root
如果需要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话,分析结果的准确性就无法保证
这也是 GC 进行时必须 STW 的一个重要原因,即使是号称几乎不会发生停顿的 CMS 收集器中,枚举根节点也是必须要停顿的
对象的 finalization 机制
Java 语言提供了对象终止 finaliztion 机制来允许开发人员提供对象被销毁之前的自定义处理逻辑
当垃圾回收器发现没有引用指向一个对象,即垃圾回收此对象之前,总会先调用这个对象的 finalize()方法
finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放,通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件,套接字和数据库链接等
定义虚拟机的对象可能的三种状态
可触及的
从根节点开始,可以到达这个对象
可复活的
对象的所有引用都被释放了,但是对象有可能在finalize()中复活
不可触及的
对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次
只有对象再不可触及时才可以被回收
具体过程
判断一个对象 ObjA 是否可以被回收,至少需要经历两次标记过程
1、如果对象到 GC Roots 没有引用链,则进行第一次标记
2、进行筛选,判断此对象是否有必要执行 finalize()方法
如果对象 A 没有重写 finalize 方法,或者 finalize 方法已经被虚拟机调用过,则虚拟机视为没有必要执行,对象 A 被判定为不可触及的
如果对象 A 重写 finalize()方法,且还未执行过,那么 A 会被插入到 F-queue 队列中,有一个虚拟机自动创建的,低优先级的 Finalizer 线程触发其 finalize()方法执行
finalize 方法是对象逃脱死亡的最后机会,稍后 GC 会对 F-queue 队列中的对象进行第二次标记,如果 A 在 finalize 方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,A 会被移除即将回收集合。之后,对象会再次出现没有引用存在的情况下,finalize 方法不会再被调用,对象直接变为不可触及状态
MAT 与 JProfiler 的 GC Roots 溯源
MAT 是 Memory Analyzer 的简称,是一款功能强大的 Java 堆内存分析器。用于查找内存泄露以及查看内存消耗情况,基于 Eclipse 开发的一款免费性能分析工具
MAT 和 jvisualvm 是不同公司开发的,我们需要熟练掌握 JProfiler 即可,这两款熟悉即可
清除阶段:标记-清除算法
标记
从引用根节点开始遍历,标记所有被引用的对象,一般是在对象Header中记录为可达对象
注意标记引用对象,不是垃圾对象
清除
对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收
缺点
效率不算高
在 GC 的时候,,需要停止整个应用程序,导致用户体验差
这种方式清理出来的空闲内存不连续,产生内存碎片,需要维护一个空闲列表
为何清除?
所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里,下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够就存放
清除阶段:复制算法
将或者的内存空间分为两块,每次使用其中一块。在垃圾回收时,将正在使用的内存中的存活的对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有的对象,交换两个内存的角色,最后完成垃圾回收
优点
没有标记和清除的过程,实现简单高效
复制过去以后的保证空间的连续性,不会出现碎片的问题
缺点
需要两倍的内存空间
对于 G1 这种拆分为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间的引用关系,不管是内存占用或者时间开销也不小
如果系统中的垃圾对象很多,需要复制的存活对象数量并不会太大,或者非常低才行
清除阶段:标记-压缩算法(标记整理算法)
第一个阶段和标记清除算法一样,从根节点开始标记所有被引用的对象
第二阶段将所有的存货对象压缩在内存的一端,按照顺序排放,之后清理边界外所有的空间。最终效果等同于标记清除算法执行完成后,再进行一次内存碎片整理
与标记清除算法本质区别,标记清除算法是非移动式的算法,标记压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策
优点
消除了标记清除算法内存区域分散的缺点
消除了复制算法中,内存减半代价
缺点
从效率上来讲,标记整理算法要低于复制算法
移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
移动的过程中,需要全程暂停用户应用程序,即 STW
小结
分代收集算法
不同生命周期的对象可以采取不同额收集方式,以便提高回收效率
几乎所有的 GC 都采用分代收集算法执行垃圾回收的
HotSpot 中
年轻代:生命周期短,存活率低,回收频繁
老年代:区域较大,生命周期长,存活率高,回收不及年轻代频繁
增量收集算法、分区算法
增量收集算法思想
每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,直到垃圾收集完成
通过对线程间冲突的妥善管理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作
缺点
线程和上下文切换导致系统吞吐量的下降
分区算法
为了控制 GC 产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理的回收若干个小区间,而不是整个堆空间,从而减少一次 GC 所产生的时间
分代算法是将对象按照生命周期长短划分为两个部分,分区算法是将整个堆划分为连续的不同的小区间。每一个小区间都独立使用,独立回收,这种算法的好处是可以控制一次回收多少个小区间
垃圾回收相关概念
System.gc() 的理解
System.gc 或 Runtime.getRuntime().gc() 的调用,会显示触发 FullGC ,同时会对老年代和新生代进行回收,尝试释放被丢对象占用的内存
然而 System.gc 调用无法保证对垃圾收集器的调用(即调用了不一定会垃圾回收)
一些特殊情况下,比如编写性能基准,我们可以在运行之间调用 System.gc
案例:主要第 3 和 4 个方法说明局部变量表的引用还存在就无法被 GC
内存溢出与内存泄露
OOM
Java 虚拟机的堆内存设置不够
代码创建大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)
内存泄漏
狭义上:只有对象不再被程序用到了,但是 GC 又不能回收他们的情况,才叫内存泄露
广义上:实际情况有一些疏忽导致对象的生命周期变的很长甚至 OOM
举例
单例的生命周期和程序是一样长,如果单例程序中,持有对外部对象的引用的话,那么这个外部对象是不能被回收的,导致内存泄露
一些提供 close 的资源未关闭导致内存泄露,如数据库链接,网络链接,和 IO
StopTheWorld
概述:Stop-The-World ,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW
作用:它存在的意义是保证一致性,如引用对象的引用关系一致性
其它
1. 被 STW 中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少 STW 的发生
2. STW 事件和采用哪款 GC 无关,所有的 GC 都有这个事件
3. STW 是 JVM 在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉
4. 开发中不要用 System.gc();会导致 Stop-The-World 的发生
垃圾回收的并行与并发
并发
同一时间段内,感觉几个程序都在同一个处理器上同时运行,CPU 快速切换实现
并行
同一个时间点,多个程序同时运行
其实决定并行的因素不是 CPU 的数量,而是 CPU 的核心数量,比如一个 CPU 多个核也可以并行
二者区别
并发,指的是多个事情,在同一时间段内同时发生了
并行,指的是多个事情,在同一时间点上同时发生了
并发的多个任务之间抢占资源
只有在多CPU或者一个CPU多核的情况中,才会发生并行。否则,看似同时发生的事情,其实都是并发执行的
并行多个任务之间不互相抢占资源
垃圾回收的并发与并行
并行:多条垃圾收集器并行工作,用户线程处于等待状态,但此时用户线程仍处于等待状态
串行:单线程执行。如果内存不够,则程序暂停,启动 JVM 垃圾回收器进行垃圾回收。回收完,再启动程序的线程
安全点与安全区域
安全点
程序执行并非在所有地方都能停顿下来开始 GC,只有特定的位置才能停顿下来开始 GC,这些位置称为安全点
如果太少,导致 GC 等待时间长,如果太多导致运行时性能问题,大部分指令执行都比较短,通常会根据是否具有让程序长时间执行的特征为标准选择一些执行时间较长的指令作为安全点,比如方法调用,循环跳转和异常跳转等
抢先式中断(被淘汰,没有虚拟机采用)
中断所有线程,如果还有线程不在安全点,就恢复线程,让线程跑到安全点
主动式中断
设置一个中断标志,各个线程运行到安全点的时候,主动轮询这个标志,如果标志为真,则将自己进行中断挂起
安全区域
如果线程处于 sleep 或者 blocked 状态,这时候线程无法响应 JVM 中断请求,走到安全点去中断挂起。对于这种情况,就需要安全区域来解决
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中任何位置开始GC都是安全的
当线程运行到安全区域代码时,首先标志已经进入了安全区域,如果 GC,JVM会忽略标识为安全区域状态的线程
当线程即将离开安全区域时,会检查 JVM 是否已经完成 GC,如果完成了,则继续运行。否则线程必须等待直到收到可以安全离开安全区域的信号为止
几种引用
小结
强软弱引用前提是存在引用关系,没有被引用早就被垃圾回收给收了
这几种引用属于又偏门又热门的知识点,偏门在于实际开发基本用不到(只用的到一个强引用),冷门在于面试喜欢考这个
强引用(不回收)
在 Java 程序中,最常见的引用类型是强引用(普通系统99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型
强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。相对的,软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下,都是可以被回收的。所以,内存溢出或泄漏和非强引用类型无关(因为它们在内存不足必会被回收)
对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略
例子:最常见的 new 操作就是强引用
软引用(内存不足即回收)
当内存足够的时候,不会回收软引用的可达对象。当内存不够的时候,从会回收软引用的可达对象
系统将要发生内存溢出之前,会将这些对象列入回收范围之中进行第二次回收,如果这些回收后还没有足够内存,才会抛出内存溢出异常
软引用通常用来实现内存敏感的缓存,高速缓存就有用到软引用
垃圾回收器在某个时间决定回收软可达的对象的时候,会清理软引用,并可选的把引用存放到一个引用队列
实现方式
弱引用(发现即回收)
弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统 GC 时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象
但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间
弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况
如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用
弱引用对象与软引用对象的最大不同就在于,当 GC 在进行回收时,需要通过算法检查是否回收软引用对象(就是判断内存够不够),而对于弱引用对象, GC 总是进行回收。弱引用对象更容易、更快被GC回收
实现
虚引用(对象回收跟踪)
只被弱引用关联的对象只能够生生存到下一次垃圾收集器之前,当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象
他不能单独使用,也无法通过虚引用获取被引用的对象
终结器引用
用以实现对象的 finalize 方法,所以被称为终结器引用
无需手动编码,其内部配合引用队列使用
GC 时,终结器引用入队,由 finalize 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能回收被引用对象
垃圾回收器(方法论的落地实现)
GC 分类与性能指标
垃圾回收器分类
按垃圾回收线程数
串行垃圾回收器
串行回收指同一个时间段内,只允许一个 CPU 用于执行垃圾回收操作,此时工作线程被暂停,直到垃圾收集工作结束
在单 CPU 处理器或者较小应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以串行回收默认被应用在客户端的 client 模式(即 32 位电脑)下的 JVM 中
在并发能力比较强的 CPU 上,并行回收器产生的停顿时间要短于串行回收器
并行垃圾回收器
和串行相反,并行收集可以运用在多个 CPU 同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了 STW 机制
说白了单核串行更好,其他情况并行更好
按照工作模式
并发式
垃圾回收器与应用程序交替工作,以尽可能减少应用程序的停顿时间
独占式
一旦运行,就停止应用程序中所有的用户线程,直到垃圾回收过程完全结束
按照碎片处理方式
压缩式
非压缩式
按个工作内存区间
年轻代
老年代
性能指标(主要看吞吐量、暂停时间、内存占用,其它都是次要的)
吞吐量(高吞吐量隐含的意思是 适合在后台运算而不需要太多交互的任务)
垃圾收集开销
吞吐量的补数,垃圾收集所占用的时间与总运行时间的比例
暂停时间(低延迟(暂停时间短)隐含的意思是 交互多)
收集频率
相对于应用程序的执行,收集操作发生的频率
内存占用
Java 堆区所占的内存大小
快速
一个对象从诞生到被回收经历的时间
不可能三角(吞吐量、暂停时间、内存占用)
简单来说抓住两点,吞吐量和暂停时间
这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果
高吞吐量与低暂停时间,是一对互相竞争的。因为如果高吞吐量优先,必然需要降低内存回收的执行频率,导致 GC 需要更长的暂停时间来执行内存回收。如果选择低延迟优先为原则,也只能频繁的执行内存回收,引起程序吞吐量的下降。现在的标准,在最大吞吐量优先的情况下,降低停顿时间
不同的垃圾回收器概述
垃圾回收器的发展迭代史
Serial GC
1999年JDK1.3.1
第一款GC
ParNew
是SerialGC收集器的多线程版本
Parallel GC和Concurrent Mark SweepGC
JDK1.4.2
2002年2月26日
ParallelGC在JDK1.6之后称为HotSpot默认GC
G1(分水岭)
2012年
JDK1.7u4
2017年JDK9中G1变成默认的垃圾收集器,以替代CMS
2018年3月,JDK10中G1垃圾回收器的并行完整垃圾回收,实现并行性改善最坏情况下的延迟
Epsilon 垃圾回收器、ZGC,可伸缩低延迟垃圾回收器
2018年9月JDK11
Shenandoah GC:低停顿时间的GC,实验版
2019年3月JDK12
增强 ZGC
2019年9月JDK13
删除 CMS 垃圾回收器,扩展 ZGC 在 macOS 和 Windows 上的应用
2020年3月JDK14
7 款经典垃圾收集器和垃圾分代之间的关系
垃圾收集器的组合关系
(图版本JDK 14)
JDK8 之前,可以用虚线参考关系
CMS 下面的实线,是 CMS 回收失败的后备方案
JDK8 中取消了红线的组合,标记为废弃的。如果要用也可以用
JDK9 中将红线做了彻底的废除
JDK14 中弃用了绿线组合
JDK14 中青色虚线边框删除了 CMS GC
JDK9 默认 G1
JDK8 默认 Parallel Scavenge 和 Parallel old GC
新生代用了 Parallel Scavenge 则老年代自动触发用 Parallel old
Parallel 底层与 ParNew 底层框架不同,所以不能和 CMS 组合
为什么要有很多收集器,一个不够吗?因为 Java 的使用场景很多,移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器
如何查看默认的垃圾收集器
-XX:+PrintCommandLineFlags
jinfo -flag 相关垃圾回收器参数 进程 ID
Serial 回收器:串行回收(适合单线程/单个 CPU)
Serial 收集器采用复制算法,串行回收和 STW 机制的方式执行内存回收
除了年轻代,还有用于执行老年代的 Serial old 收集器,同样采取了串行回收,但是用标记压缩算法
这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)
优势
简单而高效,对于限定单个 CPU 的环境来说,由于没有线程交互的开销,可以获取最高的单线程收集效率
对于交互强的应用而言,不会采取串行垃圾收集器,因为延迟过高(相对于其它回收器而言),体验不好
目前应用场景:运行在 Client 模式下的虚拟机是个不错的选择
如何使用:HotSpot 虚拟机中,使用 -XX:+UseSerialGC 表明新生代使用 Serial GC ,同时老年代使用 Serial old GC
ParNew 回收器:并行回收(Serial 的并行版本,目前/未来和被完全淘汰没什么区别,不使用了)
ParNew 回收器除了采用并行回收,其他方面和 Serial 之间几乎没有任何区别
优势
由于 ParNew 收集器是基于并行回收,那么是否可以断定 ParNew 收集器的回收效率在任何场景下都会比 Serial 收集器更高效?
ParNew 收集器运行在多 CPU 的环境下,由于可以充分利用多 CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量
但是在单个 CPU 的环境下, ParNew 收集器不比 Serial 收集器更高效。虽然 Serial 收集器是基于串行回收,但是由于 CPU 不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销
如何使用
-XX:UseParNewGC 手工指定 ParNew 收集器执行内存回收任务,它表示年轻代使用,不影响老年代
-XX:ParallelGCThreads 限制线程数量,默认开启和 CPU 数据相同的线程数(尽量不要超过 CPU 数量,因为超过不同线程争抢 CPU,CPU 不停切换消耗性能)
Parallel Scavenge 回收器(并行回收):吞吐量优先
Parallel Scavenge 回收器 和 ParNew 区别
Parallel Scavenge 的目标是达到一个可控制的吞吐量,也称为吞吐量优先的垃圾收集器
自适应调节策略也是 Parallel Scavenge 与 ParNew 的一个重要区别
高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序
Parallel old 采取标记压缩算法,同样基于并行回收和 STW 机制
在程序吞吐量优先的应用场景中, Parallel 收集器和 Parallel old 收集器的组合,在 Server 模式下的内存回收性能很不错。JDK 8 默认的垃圾回收器
参数配置
-XX:+UseParallelGC
手动指定年轻代使用此收集器执行内存回收任务
-XX:+UseParallelOldGC
手工指定老年代使用并行回收收集器,分别适用于新生代和老年代,默认JDK8是开启的
与上面这两个参数关联,开启一个,默认开启另一个
-XX:ParallelGCThreads
设置年轻代并行收集器的线程数,一般与CPU数量相同,如果CPU数量大于8个,则值=3+(5*N/8)
-XX:MaxGCPauseMillis()
设置收集器最大停顿时间,单位毫秒
该参数谨慎使用。因为为了尽可能地把仔顿时间控制在 MaxGCPauseMillis 以内,收集器在工作时会调整 Java 堆大小或者其他一些参数。如果最大停顿时间调小,垃圾回收器就把堆调小,堆小了每次垃圾回收时间自然而然就短一些,暂停时间是 ok 了,但是堆小了,就容易触发 GC,GC 频率增大,从而吞吐量就降低了。而最大停顿时间调大,则会把堆调大,这样触发 GC 频率降低,每次 GC 耗时也更长
-XX:GCTimeRatio
垃圾收集占总时间比,用于衡量吞吐量大小
默认99,取值范围0-100,也就是垃圾回收时间不超过1%
与上一个参数矛盾,暂停时间越长,Ratio参数就容易超过设定比例
-XX:+UseAdaptiveSizePolicy
开启自适应调节策略
这种模式下,年轻代大小,Eden和Survivor的比例,晋升老年底对象年龄参数都会被自动调整
为了达到堆大小,吞吐量和停顿时间之间的平衡点
在手动调优比较困难的场景下,可以直接用自适应方式,仅指定虚拟机最大堆,目标吞吐量和停顿时间,让虚拟机自己完成调优工作
CMS 回收器:低延迟
JDK1.5 推出 Concurrent Mark Sweep 并发的标记清除,第一次实现了让垃圾收集线程与用户线程同时工作
初始标记:STW,仅仅只是标记处 GC Roots 能直接关联的对象,一旦标记完成后就会恢复之前被暂停的所有应用线程,由于直接关联对象比较小,所以这里速度非常快
并发标记:从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要停顿用户线程。可以与垃圾收集线程一起并发运行
重新标记:为了修正并发标记期间,因用户程序继续运作导致标记产生变动的那一部分对象的标记记录
并发清除:清理删除标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也可以与用户线程同时并发
初始标记和重新标记阶段仍然需要 STW 机制
由于在垃圾收集阶段用户线程没有中断,所以在 CMS 回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此 CMS 收集器不能像其他收集器那样等到老年代几乎填满再进行回收,而是当堆内存使用率达到某一阈值时,便开始进行回收
要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次 Concurrent Mode Failure 失败,这时虚拟机启用备用方案,临时启用 Serial old 收集器来重新进行老年代的垃圾收集,这样停顿时间就长了
CMS 采取标记清除算法,会产生内存碎片,只能够选择空闲列表执行内存分配
为什么不采取标记压缩呢?
答案其实很简答,因为当并发清除的时候,用 Compact 整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark Compact 更适合 "Stop the world" 这种场景下使用
优点
并发收集
低延迟
缺点
会产生内存碎片(致命缺点)
对 CPU 资源非常敏感
在并发阶段会占用一部分线程导致应用程序变慢
无法处理浮动垃圾
并发标记阶段是与工作线程同时运行,如果并发阶段产生垃圾对象,CMS 无法进行标记,导致新产生的垃圾对象没有被及时回收,只能在下一次执行 GC 时释放空间
参数
-XX:+UseConcMarkSweepGC
手工指定CMS收集器执行内存回收任务
开启后,自动将-XX:UseParNewGC打开,即ParNew(Young区)+CMS(old区)+Serial GC组合
-XX:CMSlnitiatingOccupanyFraction
设置堆内存使用率的阈值
一旦达到该阈值,则开始进行回收
JDK5及之前默认68,即老年代的空间使用率达到68%时会执行一次CMS回收
JDK6及以上默认值为92%
如果内存增长缓慢,可以设置一个稍大的值,有效降低CMS的触发频率,减少老年代回收的次数
如果应用程序内存使用率增加很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。
-XX:+UseCMSCompactAtFullCollection
用于执行完Full GC后对内存空间进行压缩整理
不过内存压缩无法并发执行,会带来停顿时间更长的问题
-XX:CMSFullGCsBeforeCompaction
设置执行多少次FullGC后对内存空间进行压缩整理
-XX:CMSFullGCsBeforeCompaction
设置CMS的线程数量
默认启动的线程数是(ParallelGCThreads+3)/4
ParallelGCThreads是年轻代并行收集器的线程数
G1 回收器:区域化分代式
官方给 G1 设定的目标
就是在延迟可控的情况下,获得尽可能高的吞吐量(即尽量控制住暂停时间,然后提高吞吐量),所以才担当起全功能收集器的重任和期望
介绍
Garbage First
G1 是一个并行回收器,他把堆内存分割为很多不相关的区域(Region)(物理上不连续)
使用不同的 region 表示Eden,s0,s1,老年代等
G1 跟踪各个 region 里面垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 region
G1 (Garbage-First) 是一款面向服务端应用的垃圾收集器,主要针对配备多核 CPU 及大容量内存的机器,以极高概率满足 GC 停顿时间的同时,还兼具高吞吐量的性能特征
发展历史
JDK1.7 版本正式启用
JDK8 还不是默认的,需要用 -XX:+UseG1GC 来启用
JDK9 以后默认垃圾回收器
优点
并行与并发
并行性(垃圾回收并行): G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程 STW
并发性(垃圾回收和应用进程并发): G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
分代收集
它会区分年轻代和老年代,年轻代依然有 Eden 区和 Survivor 区。但从堆的结构上看,它不要求整个 Eden 区、年轻代或者老年代都是连续的(之前内存是连续的),也不再坚持固定大小和固定数量
将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代
和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代
空间整合
CMS:“标记-清除”算法、内存碎片、若干次 GC 后进行一次碎片整理
region 之间用复制算法,整体可以看做是标记压缩算法两种算法都避免内存碎片,有利于程序长时间运行,分配大对象不会因为无法找到连续空间提前触发下一次 GC,尤其当 Java 堆非常大的时候,G1 优势更加明显
可预测的停顿时间模型(即软实时,用于限制暂停时间)
软实时的含义:尽可能在规定暂停时间内完成 GC,如规定在 10STW 内完成,那 90% 的 GC 就在该时间内完成,部分可能不行,毕竟 CPU 也有自己的任务
这是 G1 相对于 CMS 的另一大优势, G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒
由于分区的原因,G1 可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制
G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率
相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多
缺点
相较于 CMS,G1 不具备全方位,压倒性优势。比如用户程序运行中,G1 无论是为了垃圾收集产生的内存占用,还是程序运行时的额外执行负载都要比 CMS 要高(即有额外的内存占用才能发挥它的高性能)
经验上来说,小内存应用 CMS 表现大概率优于 G1 ,在大内存上 G1 优势发挥更多,平衡点在 6-8GB
参数
-XX:+UseG1GC
-XX:G1HeapRegionSize
设置每个 Region 大小,值是 2 的幂,范围是 1MB 到 32MB 之间,目标是根据最小的 Java 堆划分出约 2048 个区域,默认是堆内存的 1/2000
-XX:MaxGCPauseMillis
设置期望达到的最大 GC 停顿时间指标,JVM 尽力但不保证,默认 200ms
建议:不要一味设置太小,如 5ms,那样每次可回收有价值的 region 可能会减少,此时用户进程如果占用 region 速度比较快,很快就会占满所有 region,最后会触发 Full GC
-XX:ParallelGCThread
设置 STW 工作线程数的值,最多设置8
-XX:ConcGCThreads
设置并发标记的线程数,将 N 设置为并行垃圾回收线程数(parallelGCThreads)的 1/4 左右
-XX:InitiatingHeapOccupancyPercent
设置触发并发 GC 周期的 Java 堆占用率阈值,超过此值就触发 GC,默认是 45
常见调优
第一步:开启 G1 垃圾收集器
第二步:设置堆的最大内存
第三步:设置最大的停顿时间
剩下的交给 G1 默认调优
G1提供了三种垃圾回收模式在不同的条件下触发
YoungGC
MixedGC
FullGC
适用场景
面向服务器端应用,针对具有大内存,多处理器的机器
最主要应用是需要低 GC 延迟
如:在堆大小约 6GB 或更大,可预测的暂停时间可以低于 0.5s,G1 每次清理一部分 region 来保证每次 GC 停顿时间不会过长
用来替换 JDK1.5 中的 CMS,在下面的情况时,使用 G1 可能比 CMS 好
超过 50% 的 Java 堆被活动数据占用
对象分配频率或年代提升频率变化很大
GC 停顿时间过长,长于 0.5~1 秒
region
所有 region 大小相同,且在 JVM 生命周期内大小不会改变
region 在可变换角色
设置 H 区的原因
对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题, G1 划分了一个 Humongous 区,它用来专门存放大对象。如果一个 H 区装不下一个大对象,那么G1会寻找连续的 H 区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。 G1 的大多数行为都把H区作为老年代的一部分来看待
G1 垃圾回收过程概述
年轻代 GC
当年轻代 eden 区用尽时
并行独占式收集器,在年轻代回收期,G1 GC 暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到 Survivor 区间或者老年区间,也有可能是两个区间都会涉及
老年代并发标记过程
当堆内存使用到一定值,默认 45%,开始老年代并发标记
混合回收
标记完成马上开始混合回收
G1 老年代回收器不需要整个老年代都被回收,一次只需要扫描回收一小部分老年代的 region 就可以了
同时这个老年代回收是和年轻代一起被回收的
有可能 Full GC
案例:一个 Web 服务器, Java 进程最大堆内存为 4G,每分钟响应 1500 个请求,每 45 秒钟会新分配大约 2G 的内存。 G1 会每 45 秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到 45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收
记忆集
作用:一个对象被不同区域引用,判断对象是否存活问题(如回收新生代也不得不同时扫描老年代,一般需要扫描整个 Java 堆内存才能保证准确),JVM 都是使用 Remembered Set 来避免全局扫描
每个 Region 都有一个对应的 Remembered Set:
通过记忆集避免全局扫描
每次引用类型数据写操作时,会产生一个写屏障暂时中断操作
然后检查将要希尔的引用指向的对象是否和该引用对象类型数据在不同的 region,如果不同就通过 CardTable 把相关的引用信息记录到引用指向对象所在的 Region 对应的记忆集中
当进行垃圾收集时,在 GC 根节点枚举范围加入记忆集,就可以保证不进行全局扫描,也不会有遗漏
G1 垃圾回收具体过程
G1回收过程一,年轻代GC
1、扫描根
根是指 static 变量指向的对象,正在执行的方法调用链上的局部变量等。根引用连同 Rset 记录的外部引用作为扫描存活对象的入口
2、更新 Rset
处理 dirty card queue 中的 card,更新 Rset,此阶段完成后,Rset 可以准确的反应老年代所在的内存分段中对象的引用
3、处理 Rset
识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象
4、复制对象(和之前学的一样)
对象树被遍历,Eden 区内存段中存活的对象会被复制到 Survivor 去中空的内存分段,Survivor 区内存段中存活的对象如果年龄未达阈值,会加一,达到阈值会被复制到 old 区中空的内存分段,如果 Survivor 区空间不够,Eden 空间的部分数据会直接晋升到老年代空间
5、处理引用
处理强软弱虚,终结器引用,本地方法接口引用等,最终 eden 空间的数据为空,GC 停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片
G1回收过程二、并发标记过程
1、初始标记阶段 STW
标记从根节点直接可达的对象,并且触发一次年轻代 GC
2、根区域扫描阶段
扫描 Survivor 区直接可达老年代区域对象,并标记被引用的对象,这个过程在 young GC 之前完成
3、并发标记
和应用程序并发执行,并发标记阶段若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收
并发标记过程中,会计算每个区域的对象活性,存活对象的比例
4、再次标记
由于应用程序持续进行,需要修正上次标记结果,STW,G1 采取比 CMS更 快的初始快照算法
5、独占清理
计算各个区域存活对象和 GC 回收比例,并进行排序,识别可以混合回收的区域。为下个阶段做铺垫,STW
这个阶段并不会实际上去做垃圾的收集
6、并发清理阶段
识别并清理完全空闲的区域
G1回收过程三:混合回收
当越来越多的对象晋升到老年代 old region 时,为了避免内存被耗尽,虚拟机会触发一次混合的垃圾收集器,该算法除了回收整个 young region,还会回收一部分的 old region。也要注意 Mixed gc 并不是 Full gc
并发标记结束后,老年代中百分百为垃圾的内存分段被回收了。部分为垃圾的内存分段被计算出来了,默认情况下,这些老年代的内存分段会分 8 次被回收 -XX:G1MixedGCCountTarget 设置
混合回收的回收集包括八分之一的老年代,Eden 区内存分段,Survivor 区内存分段
由于老年代中内存分段默认分 8 次回收,G1会优先回收垃圾多的内存分段,并且有一个阈值会决定内存分段是否被回收。-XX:G1MixedGCLiveThresholdPercent,默认为 65%。意思是垃圾占比达到 65% 才会被回收。如果垃圾占比比较低,意味存活对象较高,复制的时候花更多时间
混合回收不一定要进行 8 次,有一个阈值:-XX:G1HeapWastePercent
默认值是 10%,意思是允许整个堆内存中有 10% 的空间被浪费,意味着如果发现可以回收的垃圾占堆内存比例低于 10%,则不再进行混合回收,因为 GC 花费更多的时间,但是回收到的内存却很少
G1 可选过程四:Full GC
G1 的初衷就是要避免 Full GC 的出现。但是如果上述方式不能正常工作,G1 会停止应用程序的执行(Stop-The-World) ,使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长
要避免 Full GC 的发生,一旦发生需要进行调整。什么时候会发生 Full GC 呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到 Full gc,这种情况可以通过增大内存解决
导致 Full GC 原因可能有两个
回收阶段的时候没有足够的 to-space 存放晋升的对象
并发处理过程完成之前空间耗尽了
G1 优化建议
年轻代大小
避免使用 -Xmn 或 -XX:NewRatio 等相关选项显式设置年轻代大小
固定年轻代大小会覆盖暂停时间目标(因为年轻代回收是独占式的),建议让 JVM 自己动态调整
暂停时间目标不要太苛刻,太严苛会有更多的垃圾回收开销,而这些会直接影响到吞吐量
垃圾回收器总结(截至 JDK 1.8)
一般隐含意思:响应速度 -> 关注点是客户端/用户交互相关 吞吐量 -> 关注点是服务端
怎么选择垃圾回收器(思想:具体问题具体分析)
1、优先调整堆的大小让 JVM 自适应完成(即自己不要去设置堆大小)
2、如果内存小于 100M ,使用串行收集器
3、如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
4、如果是多 CPU、需要高吞吐量、允许停顿时间超过 1 秒,选择并行或者 JVM 自己选择(即 JVM 默认的)
5、如果是多 CPU、追求低停顿时间,需快速响应(比如延迟不能超过 1 秒,如互联网应用),使用并发收集器官方推荐 G1,性能高。现在互联网的项目,基本都是使用 G1
总之Java 垃圾收集器的配置对于 JVM 优化来说是一个很重要的选择,选择合适的垃圾收集器可以让 JVM 的性能有一个很大的提升。
最后应该明确的观点
1、没有最好的收集器,更没有万能的收集
2、调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器
GC 日志分析
案例
分析
结果
生成日志方面
生成日志
分析生成的日志:建议使用 GC EASY
垃圾回收器的新发展(展望未来)
Shenandoah GC
强项
低延迟时间(低暂停时间)
弱项
高运行负担下的吞吐量下降
ZGC
在尽可能堆吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾回收的停顿时间限制在10毫秒以内的低延迟
并发标记,并发预备重分配,并发重分配,并发重映射
除了初始标记是 STW,其他地方几乎都是并发执行的
其它
习惯总结
针对 final 修饰类,方法,基本数据类型,引用数据类型变量的结构时,能使用 final 尽量使用上。一点好处是 final 修饰变量在编译时值就确定下来,早点确定早点适用,其次在这里修饰字符串直接变成引用常量,引用常量拼接仍然使用编译期优化,非 StringBuilder 的方式
为什么避免 Full GC
Full GC 是单线程的,它会停止用户线程进行垃圾回收。在执行 Full GC 期间,所有的应用程序线程都会被挂起,无法继续执行。这是因为 Full GC 需要扫描整个堆空间,并对其中的对象进行标记、清理和整理操作,而为了保证数据一致性和正确性,需要停止其他线程的执行。
由于 Full GC 的单线程执行特性,它的停顿时间会相对较长,可能几百毫秒甚至更长。这段长时间的停顿会对应用程序的性能产生较大的影响,可能导致应用程序的请求处理变得缓慢,响应时间延长,甚至出现请求超时或系统不可用的情况
减少 Full GC 的好习惯
调整堆空间大小:通过合理配置堆空间的大小,在避免频繁 Full GC 的同时,尽量减少 Full GC 的执行时间。
使用并行或并发垃圾回收器:一些垃圾回收器提供了并行或者并发的执行方式,可以在一定程度上减少 Full GC 的停顿时间,使得垃圾回收与应用程序的执行可以同时进行
分析和优化对象的生命周期:通过减少对象的生命周期长、引用关系复杂的情况,可以减少 Full GC 的频率和执行时间
Java 不同版本的新特性主要关注哪些方面?
1. 语法层面:如Lambda 表达式、switch、自动装箱、自动拆箱、enum、<>、...
2. API 层面:Stream API、新的日期时间、Optional、String、集合框架...
3. 底层优化:JVM 的优化、GC 的优化、元空间、静态域、字符串常量池...(总之就是这里学的相关内容都是底层优化)
JVM 与 Java 体系结构
学习 JVM 的目的:最终目的是在软件层面性能优化(我自己的展开理解:学习JVM的目的,一是有利于以 JVM 的角度去写 Java 程序(如内存方面写出节约内存的高质量程序),其二是可以以 JVM 的角度去理解程序)
要想进行性能调优我们得知道哪里需要优化,就需要性能监控,要想看得懂性能监控内容(如堆空间,垃圾回收算法等等)我们就得学习内存的分配与回收,懂内存分配和回收的前提又得懂内存结构,懂内存结构就得懂上面的类加载器、class文件结构、执行引擎。一切都那么自然
一个进程对应一个 JVM 实例,一个 JVM 实例对应一个运行时数据区,一个运行时数据区对应一个堆和方法区
Java 虚拟机整体架构详图
Java 代码执行过程详图
汇编语言、机器语言、高级语言关系
JVM 的架构模型
基于栈式
优点
设计和实现简单,适用于资源受限的系统
避开了寄存器的分配难题:使用零地址指令方式分配
指令流中大部分都是零地址指令,执行过程依赖操作栈,指令集更小,编译器容易实现
8 位字节码,所以说指令集更小,但是完成一项操作花费的指令相对多
不需要硬件支持,可移植性更好,更好实现跨平台
缺点
性能下降,实现同样的功能需要更多的指令,毕竟还要入栈出栈等操作
指令
地址、操作数
零地址只有操作数
基于栈式的,因为是操作栈顶的元素,所以不需要地址
一地址有一个地址,一个操作数
二地址有两个地址,一个操作数
基于寄存器式
优点
性能优秀,执行更高效
花费更少的指令去完成一项操作
缺点
指令集架构完全依赖硬件,可移植性差
典型应用是X86的二进制指令集,比如传统的PC以及安卓的Davlik虚拟机
16位字节码
大部分情况下,指令集往往以一地址指令,二地址指令和三地址指令为主
Javap 查看字节码
-v 输出附加信息
-l 输出行号和本地变量表
-p 显示所有类和成员
-c 对代码进行反汇编
JVM 的生命周期
虚拟机的启动
通过引导类加载器 bootstrap class loader 创建一个初始类来完成的,这个类是由虚拟机的具体实现指定的。
虚拟机的执行
执行一个所谓的 Java 程序的时候,真正执行的是一个叫 Java 虚拟机的进程
虚拟机的退出
程序正常执行结束
执行过程遇到异常或错误而异常终止
操作系统错误导致Java虚拟机进程终止
Runtime 类或 System 类的 exit 方法、runtime 类的 halt 方法,并且 Java 安全管理器允许这次 exit 或 halt 操作
halt 停止、停下、阻止
exit 方法源码:static native void halt0(int status)
JNI(Java Native Interface) 规范描述了用 JNI Invocation API 来加载或卸载 Java 虚拟机时,Java 虚拟机退出的情况
类加载子系统
作用
负责从文件系统或者网络中加载 Class 文件,Class 文件开头有特定标识,魔术,咖啡杯壁
Classloader 只负责 class 文件的加载,至于是否可运行,则由执行引擎决定
加载的类信息存放于称为方法区的内存空间,除了类信息,方法区还会存放运行时常量池信息,还可能包括字符串字面量和数字常量
常量池运行时加载到内存中,即运行时常量池
角色
分支主题
类的加载过程
加载
加载刚好是加载过程的一个阶段,二者意思不能混淆
通过一个类的全限定名获取定义此类的二进制字节流
本地系统获取
网络获取,Web Applet
zip 压缩包获取,jar,war
运行时计算生成,动态代理
有其他文件生成,jsp
专有数据库提取 .class 文件,比较少见
加密文件中获取,防止 Class 文件被反编译的保护措施
将这个字节流所代表的的静态存储结果转化为方法区的运行时数据结构
在内存中生成一个代表这个类的 Java.lang.Class 对象,作为方法区这个类的各种数据访问入口
链接
验证
目的
确保 Class 文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全
四种验证
文件格式验证
CA FE BA BE (魔数,Java 虚拟机识别)
主次版本号
常量池的常量中是否有不被支持的常量类型
指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
元数据验证
对字节码描述的信息进行语义分析,保证描述符合Java规范
类是否有父类,除了 Object 之外,所有的类都应该有父类
类的父类是否继承了不允许被继承的类(被final修饰的类)
如果这个类不是 抽象类,是否实现了其父类或接口中要求实现的所有方法。
类的字段,方法是否与父类的产生矛盾。例如方法参数都一样,返回值不同
字节码验证
通过数据流分析和控制流分析,确定程序语义是合法的,符合逻辑的。
对类的方法体,进行校验分析,保证在运行时不会做出危害虚拟机的行为
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现类似于在操作数栈放了一个 int类型的数据,使用时却按照 long 类型加载到本地变量表中的情况。
保障任何跳转指令都不会跳转到方法体之外的字节码指令上。
符号引用验证
通过字符串描述的全限定名是否能找到对应的类
符号引用中的类、字段、方法的可访问性是否可被当前类访问
准备
目的
为类变量分配内存,并且设置该类变量的初始值,即零值
零值
不包含用 final 修饰的 static,因为 final 在编译的时候就会分配了,准备阶段会显示初始化
不会为实例变量分配初始化,类变量会分配在方法区中,实例变量会随着对象一起分配到 Java 堆中
解析
目的
将常量池内的符号引用转换为直接引用的过程
事实上,解析操作往往会伴随着 JVM 在执行完初始化之后再执行
符号引用就是一组符号来描述引用的目标。符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中
直接引用就是直接指向目标的指针,相对偏移量或一个间接定位到目标的句柄
解析动作主要针对类,或接口,字段,类方法,接口方法,方法类型等。对应常量池中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info
初始化
目的
初始化阶段是执行类构造器方法 <clinit>() 的过程
此方法不需要定义,是 Javac 编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来
非法的前向引用问题
如果没有类变量和静态代码块,也不会有clinit
构造器方法中指令按照语句在源文中出现的顺序执行
<clinit>() 不同于类的构造器(关联:构造器是虚拟机视角下的<init>())
若该类具有父类,JVM 会保证子类的 <clinit>() 执行前,父类的 <clinit>() 已经执行完毕
虚拟机必须保证一个类的 <clinit>() 方法在多线程下被同步加锁
使用
卸载
补充说明:
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的。
解析阶段不一定,在某些情况下可以在初始化阶段之后再开始,为了支持 Java 语言的运行时绑定特性(也称为动态绑定或晚期绑定)
Java 虚拟机规范严格规定了,有且只有六种情况,必须立即对类进行初始化
1、遇到 new,getstatic,putstatic 或 invokestatic 这四条字节码指令时。
使用 new 关键字实例化对象
读取或设置一个类型的静态字段(final 修饰已在编译期将结果放入常量池的静态字段除外)
调用一个类型的静态方法的时候
2、对类型进行反射调用,如果类型没有经过初始化,则需要触发初始化
3、初始化类的时候,发现父类没有初始化,则先触发父类初始化
4、虚拟机启动时,用户需要指定一个要执行的主类(包含 main 方法的那个类),虚拟机会初始化这个主类
5、只用 JDK7 中新加入的动态语言支持,如果一个 Java.lang.invoke.MethodHandler 实例最后的解析结果为 REF_getStatic,REF_putStatic,REF_invokeStatic,REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法对应的类没有进行初始化,则先触发其初始化
6、当一个接口中定了JDK8 新加入的默认方法时,如果这个接口的实现类发生了初始化,要先将接口进行初始化
除了以上几种情况,其他使用类的方式被看做是对类的被动使用,都不会导致类的初始化
类加载器分类
引导类加载器和自定义加载器
概念上,将所有派生于抽象类ClassLoader的类加载器都划分为自定义加载器
代码样例,获取类加载器
对于用户来说定义器来说,默认使用系统类加载器进行加载
Java 的核心类库,使用引导类加载器进行加载
启动类加载器
C/C++ 语言实现,嵌套JVM内部
用来加载 Java 核心类库,rt.jar,resources.jar,sun.boot.class.path 路径下的内容
代码获取加载路径
并不继承 Java.lang.ClassLoader,没有父加载器
加载扩展类和应用程序类加载器,并指定为他们的父类加载器
出于安全考虑,Bootstrap 启动类加载器只加载包名为 Java\Javax\sun 等开头的类
扩展类加载器
Java 语言编写,由 sun.misc.Launcher$ExtClassLoader 实现
派生于 ClassLoader 类
父类加载器为启动类加载器
从 Java.ext.dirs 系统属性所指定的目录中加载类库,或从 jre/lib/ext 子目录下加载类库
应用程序类加载器(系统类加载器)
Java语言编写,由 sun.misc.Launcher$AppClassLoader 实现
派生于 ClassLoader 类
父类加载器为扩展类加载器
负责加载环境变量 classpath 或系统属性 Java.class.path 指定路径下的类库
该类加载器是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
通过 ClassLoader#getSystemClassLoader()方法可以后去到改类加载器
用户自定义类加载器
为什么要用自定义类加载器
隔离加载类
例如使中间件的 Jar 包与应用程序 Jar 包不冲突
修改类加载的方式
启动类加载器必须使用,其他可以根据需要自定义加载
扩展加载源
防止源码泄露
对字节码进行加密,自定义类加载器实现解密
实现步骤
继承抽象类 Java.lang.ClassLoader 类的方式,实现自己的类加载器
1.2 之前,继承并重写 loadClass 方法,
1.2 之后,建议把自定义的类加载逻辑写在 findClass() 方法中
如果没有太过复杂的需求,可以直接继承 URLClassLoader 类,可以避免自己编写 findClass()方法,及其获取字节码流的方式,使自定义类加载器编写更加简洁
关于ClassLoader
是一个抽象类,除了启动类加载器,其他类加载器都继承自它
双亲委派
原理
Java 虚拟机对 Class 文件采用的是按需加载,而且加载 class 文件时,Java 虚拟机使用的是双亲委派模式,即把请求交由父类处理,它是异种任务委派模式
流程
1、如果一个类加载器收到了类加载请求,它并不会自己先去加载。而是把这个请求委托给父类的加载器去执行
2、如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将达到顶层的启动类加载器
3、如果父类的加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式
优势
避免类的重复加载
保护程序安全,防止核心API被篡改
沙箱安全机制
保证对 Java 核心源代码的保护
补充
在JVM中表示两个class对象,是否为同一个类存在两个必要条件
类的完整类名必须一致,包括包名
加载这个类的 ClassLoader 必须相同
JVM必须知道一个类型是由启动类加载器加载的,还是由用户类加载器加载的。如果是用户类加载器加载的,JVM会将这个类加载器的一个引用作为类型信息的一部分,保存到方法区中。
运行时数据区概述
不同的JVM对于内存的划分方式和管理机制存在部分差异,后续针对HotSpot虚拟机进行介绍
分支主题
其中堆和方法区是所有线程共享的,而虚拟机栈、程序计数器、本地方法栈则是一个线程对应一份
程序计数器(PC 寄存器)
运行时数据区中唯一不会出现OOM的区域,没有垃圾回收
当前线程所执行的字节码的行号指示器
为了线程切换后能恢复到正确的位置
每个线程有一个独立的程序计数器,线程之间互不影响。
如果线程执行的Java方法,则计数器记录正在执行的虚拟机字节码的指令的地址
如果正在执行的本地方法,这个计数器值则应为空。(undefined)
虚拟机栈
内存中的栈与堆
栈是运行时的单位,而堆是存储的单位,栈解决程序如何执行,如何处理数据。堆解决的是数据存储问题,即数据怎么放,放在哪里。
基本内容
Java虚拟机栈,早起也叫Java栈,每个线程创建时都会创建一个虚拟机栈,内部保存一个个栈帧,对应着一次次的Java方法调用
生命周期和线程的一致
主管Java程序的运行,保存方法的局部变量(8种基本数据类型,对象的引用地址),部分结果,并参与方法的调用和返回。
局部变量 vs 成员变量
基本数据类型 VS 引用类型变量(类,数组,接口)
优点
快速有效的存储方式,访问速度仅次于程序计数器
JVM直接对JAVA栈的操作只有两个
每个方法执行,伴随着进栈(入栈,压栈)
执行结束的出栈
栈不存在垃圾回收,但是存在OOM
Java栈大小是动态或者固定不变的。如果是动态扩展,无法申请到足够内存OOM,如果是固定,线程请求的栈容量超过固定值,则StackOverflowError
使用-Xss (记忆:站着做一个小手术,栈Xss),设置线程的最大栈空间
分支主题
栈的存储单位
每个线程都有自己的栈,栈中的数据以栈帧格式存储
线程上正在执行的每个方法都各自对应一个栈帧
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各个数据信息
栈运行原理
分支主题
先进后出,后进先出
一条活动的线程中,一个时间点上,只会有一个活动的栈帧。只有当前正在执行的方法的栈顶栈帧是有效的,这个称为当前栈帧,对应方法是当前方法,对应类是当前类
执行引擎运行的所有字节码指令只针对当前栈帧进行操作
如果方法中调用了其他方法,对应的新的栈帧会被创建出来,放在顶端,成为新的当前帧
不同线程中包含的栈帧不允许存在相互引用。
当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为新的栈帧。
Java方法有两种返回方式
一种是正常的函数返回,使用 return 指令
另外一种是抛出异常,不管哪种方式,都会导致栈帧被弹出
栈的内部结构
局部变量表
定义为一个数字类型的数组,主要用于存储方法参数,定义在方法体内部的局部变量,数据类型包括各类基本数据类型,对象引用,以及return address类型
局部变量表建立在线程的栈上,是线程私有的,因此不存在数据安全问题(不是共享数据)
局部变量表容量大小是在编译期确定下来的
局部变量表存放编译期可知的各种基本数据类型(8种),引用类型(reference),return address 类型
最基本的存储单元是slot
32位(即4字节)占用一个slot,64位类型(即8字节)(long和double)占用两个slot
局部变量表中的变量只有在当前方法调用中有效,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。
方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁
关于Slot的理解(局部变量表的基本单位)
JVM虚拟机会为局部变量表中的每个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this,会存放在index为0的slot处,其余的参数表顺序继续排列
分支主题
this
栈帧中的局部变量表中的槽位是可以重复的,如果一个局部变量过了其作用域,那么其作用域之后申明的新的局部变量就有可能会复用过期局部变量的槽位,从而达到节省资源的目的
静态变量与局部变量对比及小结
变量的分类
按照数据类型分
基本数据类型
引用数据类型
按照声明的位置
成员变量,在使用前经历过初始化过程
类变量
链接的准备阶段给类变量默认赋值,初始化阶段显示赋值,即静态代码块赋值
实例变量
随着对象的创建,会在堆空间分配实例变量空间,并进行默认赋值
局部变量
在使用前,必须进显式赋值,否则编译不通过
补充
在栈帧中,与性能调优关系最密切的部分,就是局部变量表,方法执行时,虚拟机使用局部变量表完成方法的传递
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
操作数栈
在方法执行的过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈/出栈
如果被调用方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新程序计数器中下一条需要执行的字节码指令
Java虚拟机的解释引擎是基于栈的执行引擎,其中栈就是操作数栈
主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
每一个操作数栈会拥有一个明确的栈深度,用于存储数值,最大深度在编译期就定义好
栈中,32bit类型占用一个栈单位深度,64bit类型占用两个栈单位深度
操作数栈并非采用访问索引方式进行数据访问,而是只能通过标准的入栈、出栈操作完成一次数据访问
栈顶缓存技术
由于操作数是存储在内存中,频繁的进行内存读写操作影响执行速度,将栈顶元素全部缓存到物理CPU的寄存器中,依此降低对内存的读写次数,提升执行引擎的执行效率
动态链接(指向运行时常量池的方法引用)
指向常量池的方法引用
每一个栈帧内部都包含一个指向运行时常量池中,该帧所属方法的引用
目的是为了支持当前方法的代码能够实现动态链接,比如invokedynamic指令
在Java源文件被编译成字节码文件中时,所有的变量、方法引用都作为符号引用,保存在class文件的常量池中
描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的
动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
常量池、运行时常量池
常量池在字节码文件中,运行时常量池,在运行时的方法区中
方法返回地址
作用
存放调用该方法的pc寄存器的值
方法的结束
正常执行完成
出现未处理异常,非正常退出
说明
在方法调用方法的时候, 会把调用者的下一个指令的地址给它,被调用方法执行完后返回调用者pc寄存器的指令地址. 同时还要把返回的值push调用则的操作数栈中
无论哪种方式退出,方法退出后,都会返回该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。
异常退出的,返回地址是通过异常表来确定,栈帧中一般不会保存这部分信息
执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口
返回指令包括
ireturn返回值是boolean,byte,char,short,和int类型时使用
lreturn
dreturn
areturn
引用类型
还有一个return指供声明为 void的方法、实例初始化方法、类和接口的初始化方法使用
本质上,方法的退出就是当前栈帧出栈的过程。此时需要恢复上层方法的局部变量表,操作数栈,将返回值压入调用者栈帧的操作数栈,设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值
一些附加信息
允许携带与Java虚拟机实现相关的一些附加信息,例如对程序调试提供支持的信息。不确定有,可选情况
扩展内容:方法的调用
静态链接
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行时期间保持不变,这种情况下降调用方的符号引用转为直接引用的过程称为静态链接
动态链接
如果被调用的方法无法再编译期被确定下来,只能在运行期将调用的方法的符号引用转为直接引用,这种引用转换过程具备动态性,因此被称为动态链接
方法的绑定
绑定是一个字段、方法、或者类在符号引用被替换为直接引用的过程。仅仅发生一次。
早期绑定
被调用的目标方法如果再编译期可知,且运行期保持不变
晚期绑定
被调用的方法在编译期无法被确定,只能够在程序运行期根据实际的类型绑定相关的方法。
Java中任何一个普通方法都具备虚函数的特征(运行期确认,具备晚期绑定的特点),C++中则使用关键字virtual来显式定义
如果在Java程序中,不希望某个方法拥有虚函数的特征,则可以使用关键字final来标记这个方法
虚方法和非虚方法
非虚方法
如果方法在编译期就确定了具体的调用版本,则这个版本在运行时是不可变的。这样的方法称为非虚方法
静态方法,私有方法,final方法,实例构造器,父类方法都是非虚方法
其他方法称为虚方法
方法调用指令
普通调用指令
invokestatic
调用静态方法,解析阶段确定唯一方法版本
invokespecial
调用<init>方法,私有及父类方法,解析阶段确定唯一方法版本
invokevirtual
调用所有虚方法
invokeinterface
调用接口方法
其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法
动态调用指令JDK1.7新增
invokedynamic
动态解析出需要调用的方法,然后执行
静态语言和动态语言
区别在于对类型的检查是编译器还是运行期,满足编译期就是静态类型语言,反之就是动态类型语言(说的再直白些就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,如python的info = 130)
Java是静态类型语言,动态调用指令增加了动态语言的特性
直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式
方法重写的本质
找到操作数栈顶的第一个元素所执行的对象的实际类型,记做C
如果在类型C中找到与常量池中描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束,如果不通过,则返回Java.lang.IllegalAccessError异常
否则,按照继承关系从下往上依次对C的各个父类进行上一步的搜索和验证过程
如果始终没有找到合适的方法,则抛出Java.lang.AbstractMethodError异常
虚方法表
面向对象的编程中,会很频繁的使用动态分配,如果每次动态分配的过程都要重新在类的方法元数据中搜索合适的目标的话,就可能影响到执行效率,因此为了提高性能,JVM采用在类的方法区建立一个虚方法表,使用索引表来代替查找
每个类都有一个虚方法表,表中存放着各个方法的实际入口
虚方法表会在类加载的链接阶段被创建,并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法也初始化完毕
本地方法接口
什么是本地方法
简单讲,就是一个Java调用非Java代码的接口(本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序)
为什么使用 native method
与 Java 环境外交互
例如与操作系统底层或硬件交换信息时的情况,不可避免要用到 C/C++,因为它们对硬件效率确实高
例如启动一个线程
现状:目前该方法使用的越来越少了,除非是与硬件有关的应用
本地方法栈
Java 虚拟机栈管理Java方法的调用,而本地方法栈用于管理本地方法的调用
本地方法栈,也是线程私有的。
允许被实现成固定或者是可动态扩展的内存大小(和Java 虚拟机栈一样)
内存溢出情况和 Java 虚拟机栈相同
使用C语言实现
具体做法是 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载到本地方法库
当某个线程调用一个本地方法时,就会进入一个全新,不受虚拟机限制的世界,它和虚拟机拥有同样的权限(也是它效率高的一些原因,如果是C/C++可以直接调用操作系统的寄存器或内存啦)
分支主题
本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
它甚至可以直接使用本地处理器中的寄存器
直接从本地内存的堆中分配任意数量的内存
并不是所有的 JVM 都支持本地方法,因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言,具体实现方式,数据结构等
Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一
堆
堆空间分代思想
其实不分代也可以,分代的唯一理由是优化 GC 性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
在大内存(OOM)和频繁的 GC(影响用户进程)情况下会成为性能的瓶颈
堆的核心概述
一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域
Java堆区在JVM启动的时候即被创建,其空间大小也就确认了。堆内存的大小是可调节的
Java虚拟机规范规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的
所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(TLAB)
“几乎”所有的对象实例都在这里分配内存
数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,引用指向对象或者数组在堆中的位置
方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
堆是GC执行垃圾回收的重点区域
堆空间细分为:
Java7及之前
内存逻辑上分为:
新生区
Eden区
Survivor区
from
to
谁空谁是to
养老区
永久区
Java8及之后
内存逻辑上分为:
新生区
Eden区
Survivor区
from
to
谁空谁是to
养老区
元空间
约定
新生区==新生代==年轻代
养老区==老年区==老年代
永久区==永久代
设置堆内存的大小与 OOM
-Xms:小秘书表示堆空间的起始内存。
-Xmx:小明星表示堆空间的最大内存
超过最大内存将抛出OOM
通常将 -Xms 和 -Xmx 两个参数配置相同的值,其目的是为了能够在Java垃圾回收清理完堆区后,不需要重新分隔计算堆区的大小,从而提高性能(即内存不够不用扩容,也不用频繁GC,减这些操作提高系统性能。很想集合如果知道大小就指创建集合时指定大小,防止自动扩容消耗性能)
默认情况下
初始内存大小 = 物理电脑内存大小/64
最大内存大小 = 物理电脑内存/4
查看命令/工具
jps命令
查看当前程序运行的进程
jstat
查看JVM在gc时的统计信息
jstat -gc 进程id
jvisualvm工具
-XX:+PrintGCDetails
可开启打印查看方法区实现
年轻代与老年代
Java对象划分为两类:生命周期短和长的
新生代与老年代空间默认比例1:2
-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
ratio:比率比例的意思
jinfo -flag NewRatio 进程号,查看参数设定值
在 HotSpot 中,Eden 空间和另外两个 Survivor 空间缺省所占的比例是:8:1:1
-XX:SurvivorRatio 调整这个空间比例
Eden 与 Survivor 区的比例
实际是 6:1:1,因为有自适应机制
-XX:-UseAdaptiveSizePolicy:-表示关闭,实际没有用。直接用-XX:SurvivorRatio分配即可
几乎所有的 Java 对象都是在 Eden 区被 new 出来的
Eden放不了的大对象,直接进入老年代了
IBM 研究表明,新生代 80% 的对象都是朝生夕死
-Xmn:洗面奶,设置新生代最大内存大小,如果同时设置了新生代比例与此参数冲突,此命令优先级更高
图解对象分配一般过程
1、new 的对象先放在 Eden 区,此区有大小限制
2、当创建新对象,Eden 空间填满时,会触发 Minor GC,将 Eden 不再被其他对象引用的对象进行销毁。再加载新的对象放到 Eden 区
3、将 Eden 中剩余的对象移到幸存者 0 区
4、再次触发垃圾回收,此时上次幸存者下来的,放在幸存者 0 区的,如果没有回收,就会放到幸存者1区
5、再次经历垃圾回收,又会将幸存者重新放回幸存者 0 区,依次类推
6、可以设置一个次数,默认是 15 次,超过 15 次,则会将幸存者区幸存下来的转去老年区
-XX:MaxTenuringThreshold=N进行设置
总结:
针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是 to
频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间搜集
对象分配特殊过程
触发YGC,幸存者区就会进行回收,不会主动进行回收
超大对象 eden 放不下,就要看 Old 区大小是否可以放下
old区也放不下,需要FullGC(MajorGC),这两GC概念还是有区别的。下面详解
常用调优工具
JDK命令行
Eclipse:Memory Analyzer Tool
Jconsole
VisualVM
Jprofiler
Java Flight Recorder
MinorGC,MajorGC,FullGC
对于 GC 的调优是减少 GC 的次数,因为 GC 会占用用户线程,GC 的 STW 还会暂停用户线程。而对于 GC 的减少重点是 MajorGC 和 FullGC ,因为它们两个产生的暂停时间要多于 MinorGC 的时间
针对 HotSpotVM 的实现
GC按照内存回收区域分为
部分收集
新生代收集
MinorGC (YoungGC)
老年代收集
MajorGC/oldMinorGC
目前只有 CMS GC 会单独收集老年代的行为
很多时候 MajorGC 与 FullGC 混淆使用,具体分辨是老年代回收还是整堆回收
混合收集
收集整个新生代以及部分老年代的垃圾收集
目前只有 G1 GC 会有这种行为
整堆收集
收集整个Java堆和方法区的垃圾收集
MinorGC 的触发条件
当年轻代空间不足时,就会触发MinorGC,这里的年轻代指的是Eden代满,Survivor满不会触发GC。每次MinorGC会清理年轻代的内存
因为Java对象大多朝生夕灭,所以MinorGC非常频繁
Minor翻译,较小的,未成年的
MinorGC会引发STW
后面详解
老年代 GC(MajorGC/FullGC)触发条件
指发生在老年代的GC,对象从老年代消失,我们说“MajorGC”“FullGC”发生了
出现了MajorGC,经常会伴随至少一次MinorGC
非绝对,在Parallel Scavenge收集器的收集策略里就直接进行MajorGC的策略选择过程
也就是老年代空间不足,会先尝试触发MinorGC,如果之后空间还不足,则触发MajorGC
MajorGC的速度比MinorGC慢10倍以上,STW的时间更长
如果MajorGC后,内存还不足,就报OOM了
FullGC的触发机制
1、调用System.gc()时,系统建议执行FullGC,但是不必然执行
2、老年代空间不足
3、方法区空间不足
4、通过MinorGC后进入老年代的平均大小,大于老年代的可用内存
5、由Eden区,Survivor 0区向Survivor 1区复制时,对象的大小大于ToSpace可用内存,则把改对象转存到老年代,且老年代的可用内存小于该对象的大小
FullGC是开发或调优中尽量要避免的,这样暂停时间会短一些。
内存分配策略(总结)
如果对象再 Eden 出生并经过第一次 MinorGC 后仍然存活,并且能被 Survivor 区容纳,则被移动到 Survivor 空间中,并将对象年龄设置为1,对象再 Survivor 区每熬过一次 MinorGC,年龄就 +1,当年龄增加到一定程度(默认为15,不同 Jvm,GC 都所有不同)时,就会被晋升到老年代中
-XX:MaxTenuringThreshold
优先分配到Eden
大对象(长字符串/大集合数组等等都属于大对象)直接分配到老年代
尽量避免程序中出现过多的大对象
长期存活的对象分配到老年代(即年龄到达阈值)
动态对象年龄分配
如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄
空间分配担保
-XX:HandlePromotionFailure
在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间,是否大于新生代所有对象的总空间
如果大于,则此次 MinorGC 是安全的
如果小于,则查看 -XX:HandlePromotionFailure 设置是否允许担保失败
JDK6update24 之后,这个参数不会再影响到虚拟机的空间分配担保策略。
规则改为只要老年代的连续空间大于新生代对象总大小,或者历次晋升的平均大小,就会进行 MinorGC
否则进行 FullGC
为对象分配内存 TLAB(Thread Local Allocation Buffer)
堆区是线程共享区域,任何线程都可以访问到堆区的共享数据
由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
TLAB
从内存模型而不是垃圾收集的角度,对 Eden 区域进行划分,JVM 为每个线程分配了一个私有缓存区域,包含在 Eden 空间中
多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们将这种内存分配方式成为快速分配策略
openJDK 衍生出来的 JVM 都提供了 TLAB 的设计
补充
尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但是 JVM 确实是将 TLAB 作为内存分配的首选
开发人员通过 -XX:UseTLAB 设置是否开启 TLAB 空间
默认情况下,TLAB 空间内存非常小,仅占有整个 Eden 空间的1%,通过 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小
一旦对象在 TLAB 空间分配内存失败,JVM 就会尝试通过使用加锁机制确保数据操作的原子性,从而直接在 Eden空间中分配内存
小结堆空间的参数设置
学会怎么在官网查找 Java 语言规范,Java 虚拟机规范等 https://docs.oracle.com/Javase/8/docs/technotes/tools/unix/Java.html
测试堆空间常用的 JVM 参数
-XX:+PrintFlagsInitial : 查看所有的参数的默认初始值
-XX:+PrintFlagsFinal :查看所有的参数的最终值(可能会存在修改,不再是初始值)
具体查看某个参数的指令:
jps:查看当前运行中的进程
jinfo -flag SurvivorRatio 进程 id
-Xms:初始堆空间内存(默认为物理内存的 1/64)
-Xmx:最大堆空间内存(默认为物理内存的 1/4)
-Xmn:设置新生代的大小(初始值及最大值)
-XX:NewRatio:配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio:设置新生代中 Eden 和 S0/S1 空间的比例
-XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
-XX:+PrintGCDetails:输出详细的 GC 处理日志
堆是分配对象的唯一选择吗
随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术,将会导致一些微秒变化,所有对象分配到堆上渐渐变得不那么绝对了。
有一种特殊情况,如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配,这样无需堆上分配,也不需要垃圾回收了,也是最常见的堆外存储技术
TaoBaoVM,其中创新的 GCIH(GC invisible heap)技术实现了 off-heap ,实现了将生命周期较长的 Java 对象从 heap 中移动 heap 外,并且 GC 不能管理 GCIH 内部的 Java 对象,以此达到降低 GC 的回收频率和提升 GC 的回收效率的目的
逃逸分析概述
逃逸分析的基本行为就是分析对象动态作用域
如何快速的判断是否发生了逃逸分析,大家就看new的对象是否有可能在方法外被调用
当一个对象在方法中定义后,对象只在方法内部使用,则认为没有发生逃逸
当一个对象在方法中被定义后,它被外部方法引用,则认为发生逃逸,例如作为调用参数传递到其他地方中
逃逸分析开启关闭参数:-XX:-DoEscapeAnalysis (Do 前的减号为关闭,加号为开启,默认开启)
代码优化
栈上分配(目前未实现)
将堆分配转为栈分配,如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
同步策略
如果一个对象被发现只能从一个线程被访问到,对于这个对象的操作可以不考虑同步
JIT编译器可以借助逃逸分析来判断同步块所使用的的锁对象,是否只能够被一个线程访问,而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候,就会取消对这部分代码的同步。这样就大大提高并发性和性能,这个取消同步的过程就叫同步省略,也叫锁消除
分离对象或标量替换
个人感觉:其实就是分配到局部变量表中,而局部变量表中只能存放基本类型的变量和对象引用,所以必须通过标量替换将对象打散成基本类型变量,这前提是又要依赖逃逸分析,即对象未发生逃逸
有的对象可能不需要作为一个连续的内存结构存在,也可以被访问到,那么对象的部分(或全部)可以不存储在内存。而是存储在 CPU 寄存器中
标量是指一个无法再分解的更小的数据的数据。Java中原始数据类型就是标量
可以分解的数据叫聚合量,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量
标量替换参数:-XX:-EliminateAllocations,默认打开
结论(自己总结的)
开发中能使用局部变量的,就不要使用在方法外定义
个人感觉对于堆对象的优化目前只有标量替换没有栈上分配:亲测打开逃逸分析-XX:+DoEscapeAnalysis 和关闭标量替换 -XX:-EliminateAllocations,对象还是全部存储到堆里,而只有打开逃逸分析和标量替换(关闭逃逸分析打开标量替换效果和前面一样,毕竟要实现标量替换的前提是打开逃逸分析)才能使得对象很少部分存储到堆中(其实我以为是全部都不在堆中),1000w 对象大概有 17w在堆中,或许是一些算法分配到堆中了
弹幕:因为逃逸分析后只是标记为可以在进行前面的“代码优化”,而不是必须“代码优化”,应该还有其他的一些算法在里面起作用(ai回答:对于未逃逸的对象是否被替换到栈的局部变量表里,取决于编译器的决策和优化策略。编译器会根据具体情况进行权衡和判断,以最优的方式进行代码优化),但是总的来说能大大减少在堆中的对象数量,用 jmap 去看,堆中的对象数量是比创建的要少很多的
堆是分配对象的唯一选择吗?答案是也不是,是是因为对象只能在对堆中被分配(因为目前栈上分配还未实现)。不是是因为开启逃逸分析之后对象可以被标量替换分配到栈的局部变量表里,此时它的内容都是基本类型的变量和对象引用,不具有对象的特征了。
方法区
栈、堆、方法区交互关系
方法区的理解
Java虚拟机规范中明确说明:尽管所有的方法区在逻辑上是属于堆的一部分,但是一些简单的实现,可能不会选择去进行垃圾收集或者进行压缩。对于HotSpot而言,方法区还有一个别名叫Non-Heap(非堆),目的就是要和堆分开
所以方法区看作是一块独立于Java堆的内存空间
方法区和Java堆一样,是各个线程共享的内存区域
方法区在JVM启动的时候被创建,并且它的实际的物理内存空间和Java堆区一样,都是可以不连续的
方法区的大小和堆空间一样,可以选择固定大小或者可扩展
方法区的大小决定了系统可以保存多少个类,如果定义太多类,加载大量的第三方的Jar包,Tomcat部署过多工程,导致方法区溢出,虚拟机同样会抛出内存溢出OOM:PermGen space或者Metaspace
关闭JVM就会释放这个区域的内存
HotSpot中方法区的演进
在 JDK7 及以前,习惯上把方法区,称为永久代,JDK8 开始,使用元空间取代了永久代
元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
根据 JVM 规范,如果方法区无法满足新的内存分配需求,将抛出 OOM 异常
本质上,方法区和永久代并不等价,仅是对 HostSpot 而言的
Java 虚拟机规范,对如何实现方法区,不做统一要求,例如 BEA JRockit/IBM J9 中不存在永久代的概念
现在来看,当年使用永久代,不是好的点子,导致 Java 程序更容易 OOM
设置方法区大小与OOM
JDK7 及以前
通过-XX:PermSize 来设置永久代初始分配空间,默认值是20.75M
XX:MaxPermSize 来设定永久代最大可分配空间
32位机器默认是64M
64位机器默认是82M
如果 JVM 加载的类信息容量超过了这个值,会报 OOM:PermGenspace
JDK8 及以后
-XX:MetaspaceSize
-XX:MaxMetaspaceSize
默认值依赖平台
windows 下初始为 21M,最大是 -1 即没有限制(一般不会调整最大值)
如果不指定大小,虚拟机耗用所有可用系统内存,元数据区发生溢出,一样 OOM:Metaspace
对于一个 64 位服务端 JVM 来说,默认的初始元数据区空间为 21M,这就是初始的高水位线。一旦触及这个水位线,FULLGC 会触发并卸载没有用的类,然后高水位线会被重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放空间不足,在不超过最大设定值时,适当提高该值。如果释放空间过多,则适当降低该值
如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,fullGC多次调用。为了避免频繁FullGC,建议将-XX:MetaspaceSize设置为一个相对较高的值
如何解决OOM
要解决 OOM 或 heap space 异常,一般的手段是通过内存映像分析工具,对 dump 出来的堆转存储快照进行分析,重点确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄露,还是内存溢出
如果是内存泄露,可进一步通过工具查看泄露对象到 GC Roots 的引用链,于是就能找到内存泄露对象时通过怎样的路径与 GC Roots 相关联,导致垃圾收集器无法自动回收他们。根据引用链信息,可以较准确的定位出泄露代码的位置
如果不存在内存泄漏。换就话说就是,内存中的对象却是都还必须存活着,那就应当检查虚拟的堆参数(-Xmx -Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象某些对象生命周期过长,持有状态过长的情况,尝试减少程序运行期的内存消耗,比如说,开发中能使用局部变量的,就不要在方法外定义。
方法区的内部结构
方法区存储什么
它用于存储已被虚拟机加载的类型信息,常量,静态变量,即时编译器编译后的代码缓存
类型信息
对于每个加载的类型(类Class,接口Interface,枚举Enum,注解annotation),JVM必须在方法区中存储以下类型信息
这个类的完整有效名称(全名=包名.类名)
这个类型直接父类的完整有效名,对于 interface 或 Object 没有父类
这个类型的修饰符,public,abstract,final
这个类型直接接口的一个有序列表
域(属性)信息
JVM 必须在方法区中保存类型的所有域的相关信息,以及域的声明顺序
域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient 的某个子集)
方法信息
VM必须保存所有方法的以下信息,同域信息一样包括
声明顺序
方法名称
方法的返回类型(或 void 类型)
方法参数的数量和类型(按顺序)
方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
方法的字节码 bytecodes,操作数栈,局部变量表及大小
异常表(abstract 和 native 方法除外)。每个异常处理的开始位置,结束位置,代码处理在程序计数器中的偏移地址,被捕获的异常类的常量池索引
non-final 的类变量
静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
类变量被类的所有实例共享,即使没有类实例时,你也可以访问他
全局常量
static final (被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了)
常量池
方法区,内部包含了运行时常量池
运行时将常量池加载到方法区,就是运行时常量池
字节码文件,内部包含了常量池
要弄清楚方法区,需要理解清楚 ClassFile,因为加载类的信息都在方法区
一个有效的字节码文件中除了包含的类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用
为什么要用常量池?
一个 Java 源文件中的类、接口、编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大,以至于不能直接存到字节码里。换一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接会用到运行时常量池
常量池有什么?
数量值
字符串值
类引用
字段引用
方法引用
常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型
运行时常量池
运行时常量池是方法区的一部分
常量池表是 class 文件的一部分,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
在加载类和接口到虚拟机后,就会创建对应的运行时常量池
JVM为每个已加载的类型都维护一个常量池,池中的数据像数组项一样,通过索引访问
运行时常量池包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后,才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里转换为真实地址
运行时常量池,相对于 class 文件常量池的另一个重要特征是:具备动态性。例如:String.intern 可以将字符串也放入运行时常量池
当创建类或接口的运行时常量池,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值。则JVM会抛出OOM异常
这里注意,常量池数量为 N,则索引为 1 到 N - 1
方法区使用举例
方法区的演进细节
首先明确,只有 HotSpot 才有永久代
HotSpot 中方法区的变化
JDK1.6 及之前
有永久代,静态变量存放在永久代上
JDK1.7
有永久代,但已经逐步去永久代,字符串常量池,静态变量保存在堆中
JDK1.8 及之后
无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池,静态变量仍在堆
永久代为什么被元空间替换
JDK8 官网解释
这是 JRockit 和 Hotspot 融合工作的一部分。JRockit 客户不需要配置永久代(因为 JRockit 没有永久代) ,并且习惯于不配置永久代。这是JRockit和Hotspot融合工作的一部分。JRockit客户不需要配置永久代(因为JRockit没有永久代) ,并且习惯于不配置永久代
老师解释
随着 JAVA8 的到来,HotSpotVM 中再也见不到永久代了,但是并不意味着类的元数据信息也消失了,这些数据被转移到了一个与堆不相连的本地内存区域,这个区域叫做元空间 MetaSpace。由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统的可用内存空间
① 为永久代设置空间大小很难确定。在某些场景下,如果动态加载类过多,就容易产生 OOM。而元空间并不在虚拟机中,而是使用本地内存,因此默认情况下,元空间的大小仅受本地内存限制
② 对永久代进行调优是很困难的。因为对象生命周期长且稳定,不像堆上的对象具有明显的回收周期。这导致调优工作相对困难,因为无法依赖于垃圾回收来释放内存
StringTable 和静态变量为什么要调整?
主要目的是为了减少永久代的内存压力,避免 OutOfMemoryError 错误,并解决旧实现中动态字符串管理的问题。JDK7 中将 StringTable 放到了堆空间中。因为永久代的回收效率很低,在 full gc 的时候才会触发。而 full gc 是老年代的空间不足、永久代不足时才会触发。这就导致 stringTable 回收效率不高。而我们开发中会有大量的字符串被创建,放在永久代里,回收效率低,会导致永久代内存不足。放到堆里,能及时回收内存。
开发具体问题(感觉主要都是程序可能动态生成类或字符串)
永久代的大小是有限的,并且不支持自动扩展。对于大量使用字符串的应用程序,特别是在动态生成类和字符串的场景下,永久代的内存容量可能很快达到极限,导致 OutOfMemoryError 错误
在旧的实现中,字符串常量池中的字符串对象是无法被回收的,即使它们不再被引用。这可能导致内存泄漏。而在 JDK 7 中,字符串常量池中的字符串对象是普通的堆对象,由垃圾回收器进行管理和回收。这样就可以更好地处理动态生成的字符串,避免不必要的内存消耗
方法区的垃圾回收
有些人认为方法区是没有垃圾收集行为的,其实不然。Java 虚拟机规范对方法区的约束非常宽松,提到过可以不要求虚拟机在方法区实现垃圾收集。事实上,也确实有未实现或未能完整实现方法区类型卸载的收集器,如 JDK11 ZGc
方法区的垃圾收集主要回收两部分内容
常量池中废弃的常量
HotSpot 对常量池的回收策略很明确,只要常量池中的常量没有被任何地方引用,就可以被回收
回收废弃常量与回收Java堆中对象非常类似
不再使用的类型(回收条件过于苛刻,属于费力不讨好了)
需要同时满足三个条件
该类所有的实例已经被回收( Java 堆中不存在该类及其任何派生子类的实例)
加载该类的类加载器已经被回收
该类对应的 Java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问改类的方法
满足以上三个条件后,并不是和对象一样立即被回收,仅仅是允许
HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制
在大量使用反射,动态代理,CGLib 等字节码框架,动态生成 JSP 以及 OSGI 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力
方法区内常量池中主要存放的两大类常量
字面量
比较接近 Java 语言层次的常量概念,如文本字符串,被声明为 final 的常量值等
符号引用
属于编译原理方面的概念
属于编译原理方面的概念
字段的方法和描述符
方法的名称和描述符
HotSpot 虚拟机对象探秘
对象的实例化
创建对象的方式
new
最常见的方式
变形1:Xxx 的静态方法
变形2:XxxBuilder/XxxFactory 的静态方法
Class 的 newInstance(要求苛刻,反射建议使用下面那个)
JDK9 标记过时,反射的方式,只能调用空参的构造器,权限必须是 public
Constructor 的 newInstance
反射的方式,可以调用空参,带参的构造器,权限没有要求
使用 clone
不调用任何构造器,当前类需要实现 Cloneable 接口,实现 clone 方法
使用反序列化
从文件、网络等获取一个对象的二进制流
第三方库 Objenesis
创建对象的步骤
1、判断对象对应的类是否加载、链接、初始化
2、为对象分配内存
如果内存规整
指针碰撞
如果内存不规整
虚拟机需要维护一个列表
空闲列表分配
说明
3、处理并发安全问题
对象创建是非常频繁的行为,还需要考虑并发情况下,仅仅修改一个指针所指向的位置也是不安全的,例如正在给对象 A 分配内存,指针还未修改,对象 B 又使用原来的指针分配内存。解决问题有两种可选方案
a、对分配内存空间的动作进行同步处理。实际上虚拟机采取CAS配上失败重试的方式保证更新操作的原子性
b、把内存分配的动作按照线程划分到不同的空间中进行,每个线程在 Java 堆中,预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定
虚拟机是否使用 TLAB,可以通过 -XX: +/-UseTLAB 参数来设定
4、初始化分配到的空间
所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用
5、设置对象的对象头
6、执行init方法进行初始化
上面工作完成后,从虚拟机角度来说,一个新的对象已经产生了,但是从 Java 程序的视角来说,对象创建才刚刚开始,对象的构造方法(Class 文件中 init() 方法)还未执行,所有字段都是默认的零值。new 指令之后接着执行init方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全构造出来
对象的内存布局
对象的访问定位
【使用句柄】
使用句柄,Java 堆中将划出一块内存作为句柄池,reference 中存储的就是对象的句柄地址,句柄包含对象实例数据与类型数据各自的具体信息
【直接指针(HotSpot采用)】
使用指针,reference中存储的直接就是对象地址,如果访问对象本身,不需要多一次的间接访问的开销
两种方式各有优势
使用句柄最大好处是 reference 中存放的是稳定句柄地址,在对象被移动(垃圾搜集时会产生)时只改变句柄中实例数据指针,reference 本身不用改变
使用指针最大好处就是速度快,节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,所以积少成多也是一项可观的执行成本
直接内存
优点
不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域
直接内存是在 Java 堆外的,直接向系统申请的内存区间
来源于 NIO,通过存在堆中的 DirectByteBuffer 操作 Native 内存
通常,访问直接内存的速度会优于 Java 堆,即读写性能高
因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存
Java 的 NIO 库允许 Java 程序使用直接内存,用于数据缓冲区
缺点
也可能导致 OOM 异常(这里 NIO OOM为本地内存的OOM: OutofMemoryError: Direct buffer memory)
直接内存在堆外,所以大小不受限于 -Xmx 指定的最大堆大小
但是系统内存是有限的,Java 堆和直接内存的总和依然受限于操作系统能给出的最大内存
不受 JVM 内存回收管理
分配回收成本较高
直接内存大小可以通过 MaxDirectMemorySize 设置,如果不指定,默认与堆的最大值 -Xmx 参数值一致
执行引擎
执行引擎概述
执行引擎是Java虚拟机核心的组成部分之一
虚拟机的执行引擎由软件自行实现,物理机的执行引擎是操作系统层面上
能够执行不被硬件直接支持的指令格式
执行引擎的工作过程
1、执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器。
2、每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址
3、当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在 Java 堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息
概念解释
前端编译器
把 .Java 文件转换为 .class 文件的过程
sun 的 Javac
后端运行期编译器
把字节码转为机器码的过程
JIT 编译器:hotSpot 的 C1,C2 编译器
静态提前编译器
Ahead of Time Compliler AOT,直接把.Java文件编译器本地机器代码的过程
GNU Compiler for the Java(GCJ)
Java 代码编译和执行过程
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤
为什么说 Java 是半编译半解释型语言:JVM 在执行 Java 代码的时候,通常会将解释执行与编译执行二者结合起来进行
机器码,指令,汇编语言
机器码
各种采用二进制编码方式表示的指令,叫做机器指令码。机器语言。机器指令与CPU紧密相关,不同种类的CPU所对应的机器指令也就不同
指令
由于机器码由01组成,可读性太差。于是人们发明了指令
指令就是把机器码特定的0和1序列,简化成对应的指令,一般为英文编写如mov,inc等,可读性稍好
由于不同的硬件平台,执行同一个操作,对应的机器码可能不同。所以不同的硬件平台的同一种指令,对应的机器码也可能不同
指令集
不同硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集
x86 指令集,对应的 x86 架构的平台
ARM 指令集,对应的是 ARM 架构的平台
汇编
由于指令的可读性太差,于是又有了汇编语言
汇编语言用助记符代替机器指令的操作码,用地址符号或标号,代替指令或操作数的地址。
汇编语言要翻译成机器指令码,计算机才能识别和执行
解释器(目前为低效的代名词)
当 Java 虚拟机启动时,会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容翻译为对应平台的本地机器指令执行
解析器真正意义上所承担的角色就是一个运行时翻译者,将字节码文件中的内容翻译为对应的平台的本地机器指令执行
当一条字节码指令被解释执行完成后,接着在根据 PC 寄存器中的记录下一条需要被执行的字节码执行解释执行
古老的字节码解释器
现在普遍使用的模板解释器
模板解释器将每一条字节码和一个模板函数相关联,模板函数直接产生这条字节码执行时的机器码,提高解释器的性能
HotSpot 中
Interpreter 模块
实现了解释器的核心功能
Code 模块
用于管理 HotSpot 在运行时生成的本地机器指令
JIT 编译器
就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言
JVM 平台支持一种叫做即时编译的技术,目的是避免解释执行,而是将整个函数体编译成机器码,每次函数执行时,只执行编译后的机器码即可。使执行效率大幅提升
为什么两条腿走路?
首先程序启动后,解释器可以马上发挥作用,省去编译时间,立即执行
编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后执行效率更高
对于服务端应用,启动时间并非关注重点,但是对于看重启动时间的应用场景,就需要找到一个平衡点。
当 Java 虚拟机启动时,解释器可以首先发挥作用,而不是等待即时编译器全部编译完成后再执行,这样可以省去很多不必要的编译时间,随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率
什么时候选择 JIT
热点代码及探测方式
需要根据代码被调用次数决定(具体多少次呢:一个被多次调用的方法,后者一个方法体内部循环次数较多的循环体,都可以被称之为热点代码),需要被编译为本地代码的字节码,也称之为热点代码。JIT 编译器会在运行时针对频繁调用的热点代码做出深度优化,将其直接编译为对应平台的本地机器指令。以此提升 Java 程序的执行性能
因此可以通过 JIT 编译器编译为本地机器指令,由于这种编译方法发生在方法(方法在栈里)的执行过程中,因此也被称之为栈上替换,OSR On Statck Replacement
一个方法调用都少次才能达到标准?这个依靠热点探测功能
hotspot 采用的基于计数器的热点探测
方法调用计数器(统计方法调用次数)
默认阈值,Client 模式下是 1500 次,Server 模式下是 10000 次
-XX:CompileThreshold
回边计数器(统计循环体执行的循环次数)
当一个方法被调用时,如果不存在已被编译过的版本,则将此方法的调用计数器 +1,然后判断方法调用计数器与回边计数器之和,是否超过方法调用计数器的阈值。如果已经超过,会向即时编译器提交一个该方法的代码编译请求。优化后的本地机器码由操作系统和处理器来执行,而不再需要 Java 虚拟机(JVM)的解释器参与。
热度衰减
当超过一定的时间限度,如果方法调用次数仍然不足以提交即时编译器编译,那么这个方法的调用计数器就会被减少一半(不然的话这个程序执行够久,那么很多代码执行次数迟早会达到方法调用阈值次数,全部变成热点代码)
-XX:UseCounterHalfLifeTime 参数设置半衰周期的时间,单位是秒
hotspot 可以设置程序执行的方式
-Xint:完全采用解释器模式执行
-Xcomp:完全采用即时编译器模式执行,如果即时编译器出现问题,解释器会介入执行
-Xmixed:采用解释器 + 即时编译器的混合模式共同执行
hotspot 中 JIT 分类
内嵌两个JIT编译器
client
server
大多情况下简称C1,C2
-client:指定Java虚拟机在Client模式下,并使用C1编译器
C1编译器会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度
方法内联
将引用的函数代码编译到引用点处,减少栈帧的生成,减少参数传递以及跳转过程
去虚拟化
对唯一的实现类进行内联
冗余消除
在运行期把一些不会执行的代码折叠掉
-server:指定虚拟机在 server 模式下,并使用 C2 编译器
C2进行耗时较长的优化,以及激进优化,单优化后的代码执行效率更高
逃逸分析是优化的基础,基于逃逸分析在C2上有几种优化
标量替换
用标量值代替聚合对象的属性值
栈上分配
对于未逃逸的对象分配在栈而不是堆
同步消除
清除同步操作,通常指 synchronized
最后
JDK 10 起,hotspot 又引入了个全新的即时编译器 Graal 编译器
JDK 9 引入了 AOT 编译器
0 条评论
下一页