JVM知识点整理
2021-08-19 15:02:49 16 举报
AI智能生成
JVM知识点整理
作者其他创作
大纲/内容
方法区
栈、堆、方法区交互关系
子主题
子主题
方法区的理解
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
-XX:MaxPermSize
设置方法区大小与OOM
方法区大小不是固定的,jvm可以根据应用动态调整
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的某个子集)
方法信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序
方法名称
方法的返回类型
或void
方法参数的数量和类型
按顺序
方法的修饰符
public,private,protected,static,final,synchronized,native,abstract的一个子集
方法的字节码bytecodes,操作数栈,局部变量表及大小
异常表
abstract和native方法除外
每个异常处理的开始位置,结束位置,代码处理在程序计数器中的偏移地址,被捕获的异常类的常量池索引。
non-final的类变量
静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
类变量被类的所有实例共享,即使没有类实例时,你也可以访问他
全局常量
static final
被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。
常量池
方法区,内部包含了运行时常量池
字节码文件,内部包含了常量池
运行时将常量池加载到方法区,就是运行时常量池
要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区
要弄清楚方法区的运行时常量池,需要理解清楚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及之后
无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池,静态变量仍在堆
子主题
永久代为什么被元空间替换
随着 JAVA8的到来,HotSpotVM中再也见不到永久代了,但是并不意味着类的元数据信息也消失了,这些数据被转移到了一个与堆不相连的本地内存区域,这个区域叫做元空间MetaSpace
由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统的可用内存空间
为永久代设置空间大小很难确定,在某些场景下,如果动态加载类过多,就容易产生OOM
而元空间并不在虚拟机中,而是使用本地内存,因此默认情况下,元空间的大小仅受本地内存限制
对永久代进行调优是很困难的
方法区的垃圾回收
有些人认为方法区是没有垃圾收集行为的,其实不然。Java虚拟机规范对方法区的约束非常宽松,提到过可以不要求虚拟机在方法区实现垃圾收集。事实上,也确实有未实现或未能完整实现方法区类型卸载的收集器,如JDK11 ZGc
方法区的垃圾收集主要回收两部分内容
常量池中废弃的常量
HotSpot对常量池的回收策略很明确,只要常量池中的常量没有被任何地方引用,就可以被回收
回收废弃常量与回收Java堆中对象非常类似
不再使用的类型
需要同时满足三个条件
该类所有的实例已经被回收
java堆中不存在该类及其任何派生子类的实例
加载该类的类加载器已经被回收
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问改类的方法
满足以上三个条件后,并不是和对象一样立即被回收,仅仅是允许。
HotSpot虚拟机提供了-Xnoclassgc参数进行控制
在大量使用反射,动态代理,CGLib等字节码框架,动态生成JSP以及OSGI这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力
方法区内常量池中主要存放的两大类常量:
字面量
比较接近Java语言层次的常量概念,如文本字符串,被声明为final的常量值等
符号引用
属于编译原理方面的概念
类和接口的全限定名
字段的方法和描述符
方法的名称和描述符
HotSpot虚拟机对象探秘
对象的实例化
创建对象的方式
new
最常见的方式
变形:Xxx的静态方法
XxxBuilder/XxxFactory的静态方法
Class的newInstance
JDK9标记过时,反射的方式,只能调用空参的构造器,权限必须是public
Constructor的newInstance
反射的方式,可以调用空参,带参的构造器,权限没有要求。
使用clone
不调用任何构造器,当前类需要实现Cloneable接口,实现clone方法
使用反序列化
从文件、网络等获取一个对象的二进制流
第三方库Objenesis
创建对象的步骤
1、判断对象对应的类是否加载、链接、初始化
当虚拟机遇到一条字节码new指令时。首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载解析初始化过。如果没有,在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为key值进行查找对应的.class文件,如果没有找到文件,则抛出ClassNotFoundException异常
2、为对象分配内存
首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象,如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小
如果Java堆内存中不规则,虚拟机就必须维护一个列表,记录哪些内存可用,哪些不可用。分配的时候在列表中找一个足够大的空间分配,然后更新列表。这种分配方式叫空闲列表(Free List)。
选择哪种由Java堆是否规整决定,Java堆是否规整由所采用的的垃圾收集器是否带有空间压缩整理(Compact)的能力决定
当使用Serial,ParNew等带有压缩整理过程的收集器,指针碰撞简单高效;
当使用CMS基于清除(Sweep)算法收集器时,只能采用空闲列表来分配内存;(CMS为了能在多数情况下分配内存更快,设计了一个Linear Allocatioin Buffer的分配缓冲区,通过空闲列表拿到一大块分配缓冲区后,在它里面仍可使用指针碰撞方式分配)
假设Java 堆中内存时绝对规整的,所有被使用过的内存放在一边,空闲的内存放在另一边,中间放一个指针作为分界点指示器。那么内存分配就是指针指向空闲的方向,挪动一段与对象大小相等的举例。这种分配方式成为指针碰撞(Bump The Pointer)。
3、处理并发安全问题
对象创建是非常频繁的行为,还需要考虑并发情况下,仅仅修改一个指针所指向的位置也是不安全的,例如正在给对象A分配内存,指针还未修改,对象B又使用原来的指针分配内存。解决问题有两种可选方案:
a、对分配内存空间的动作进行同步处理。实际上虚拟机采取CAS配上失败重试的方式保证更新操作的原子性。
b、把内存分配的动作按照线程划分到不同的空间中进行,每个线程在Java堆中,预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
虚拟机是否使用TLAB,可以通过-XX: +/-UseTLAB参数来设定。
4、初始化分配到的空间
内存分配完成后,虚拟机将分配到的内存空间(不包括对象头)都初始化为零值。如果使用了TLAB,这个工作可以提前到TLAB分配时进行。
这步操作保证对象的实例字段在Java代码中,可以不赋初始值就直接使用,程序可以访问到字段对应数据类型所对应的零值。
这步操作保证对象的实例字段在Java代码中,可以不赋初始值就直接使用,程序可以访问到字段对应数据类型所对应的零值。
5、设置对象的对象头
接下来Java虚拟机还要对对象进行必要的设置,例如对象时哪个类的实例、如何才能找到类的元数据信息,对象的哈希码(实际上对象的HashCode会延后真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放到对象的对象头(Object Header)
6、执行init方法进行初始化
上面工作完成后,从虚拟机角度来说,一个新的对象已经产生了,但是从Java程序的视角来说,对象创建才刚刚开始,对象的构造方法(Class文件中init()方法)还未执行,所有字段都是默认的零值。new指令之后接着执行init方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全构造出来
对象的内存布局
对象头
包含两部分
这部分数据的长度在32位和64位的虚拟机(未开启指针压缩中)分别是32bit和64bit,官方称为【Mark Word】运行时元数据
哈希值
GC分代年龄
锁状态标志
线程持有的锁
偏向线程ID
偏向时间戳
对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,根据对象状态的不同,Markword可以复用自己的空间。
类型指针
即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确认该对象属于哪个类的实例
说明:如果是数组,还需要记录数组的长度
实例数据
对象的实例数据部分,是对象的真正存储的有效信息,即我们在程序代码中定义的各种类型的字段内容,无论是父类继承下来,还是子类中定义的字段都要鸡柳下来。
1、这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
2、分配策略参数-XX:FieldsAllocationStyle
3、HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)
4、从默认的分配策略中可以看出,相同宽度的字段总被分配到一起存放。
5、在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。
6、如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认也是true),那么子类中较窄的变量也允许插入父类变量的空隙之间,以节省一点点空间。
2、分配策略参数-XX:FieldsAllocationStyle
3、HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)
4、从默认的分配策略中可以看出,相同宽度的字段总被分配到一起存放。
5、在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。
6、如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认也是true),那么子类中较窄的变量也允许插入父类变量的空隙之间,以节省一点点空间。
对齐填充
对其填充,这并不是必然存在,没有特别的意义,它仅仅起着占位符的作用。
因为HotSpot虚拟机自动内存管理系统,要对对象的起始地址必须是8字节的整数倍,换句话就是任何对象的大小都必须是8字节的整数倍。
对象头已经精心设计为8字节的整数倍,1倍或者2倍。
对象实例数据部分如果没有对齐的话,就需要通过对其填充来补全。
对象头已经精心设计为8字节的整数倍,1倍或者2倍。
对象实例数据部分如果没有对齐的话,就需要通过对其填充来补全。
对象的访问定位
【使用句柄】
使用句柄,Java堆中将划出一块内存作为句柄池,reference中存储的就是对象的句柄地址,句柄包含对象实例数据与类型数据各自的具体信息。
截图
【直接指针】
使用指针,reference中存储的直接就是对象地址,如果访问对象本身,不需要多一次的间接访问的开销。
截图
两种方式各有优势:
使用句柄最大好处是reference中存放的是稳定句柄地址,在对象被移动(垃圾搜集时会产生)时只改变句柄中实例数据指针,reference本身不用改变。
使用指针最大好处就是速度快,节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,所以积少成多也是一项可观的执行成本。
HotSpot主要是用指针,进行对象访问(例外情况,如果使用Shenandoah收集器的话,也会有一次额外的转发)。
直接内存
不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域
直接内存是在java堆外的,直接向系统申请的内存区间
来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
通常,访问直接内存的速度会优于Java堆,即读写性能高
因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存
Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
也可能导致OOM异常
直接内存在堆外,所以大小不受限于-Xmx指定的最大堆大小
但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存
缺点
分配回收成本较高
不受JVM内存回收管理
直接内存大小可以通过MaxDirectMemorySize设置
如果不指定,默认与堆的最大值-Xmx参数值一致
执行引擎
执行引擎概述
执行引擎是Java虚拟机核心的组成部分之一
虚拟机的执行引擎由软件自行实现,物理机的执行引擎是操作系统层面上
能够执行不被硬件直接支持的指令格式
执行引擎的工作过程
1、执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器。
2、每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址
3、当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息
Java代码编译和执行过程
大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤
为什么说Java是半编译半解释型语言
JVM在执行Java代码的时候,通常会将解释执行与编译执行二者结合起来进行
机器码,指令,汇编语言
机器码
各种采用二进制编码方式表示的指令,叫做机器指令码。机器语言。机器指令与CPU紧密相关,不同种类的CPU所对应的机器指令也就不同
指令
由于机器码由01组成,可读性太差。于是人们发明了指令
指令就是把机器码特定的0和1序列,简化成对应的指令,一般为英文编写如mov,inc等,可读性稍好
由于不同的硬件平台,执行同一个操作,对应的机器码可能不同。所以不同的硬件平台的同一种指令,对应的机器码也可能不同
指令集
不同硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集
x86指令集,对应的x86架构的平台
ARM指令集,对应的是ARM架构的平台
汇编
由于指令的可读性太差,于是又有了汇编语言
汇编语言用助记符代替机器指令的操作码,用地址符号或标号,代替指令或操作数的地址。
汇编语言要翻译成机器指令码,计算机才能识别和执行
解释器
当Java虚拟机启动时,会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容翻译为赌赢平台的本地机器指令执行
解析器真正意义上所承担的角色就是一个运行时翻译者,将字节码文件中的内容翻译为对应的平台的本地机器指令执行
当一条字节码指令被解释执行完成后,接着在根据PC寄存器中的记录下一条需要被执行的字节码执行解释执行
古老的字节码解释器
现在普遍使用的模板解释器
模板解释器将每一条字节码和一个模板函数相关联,模板函数直接产生这条字节码执行时的机器码,提高解释器的性能
HotSpot中
Interpreter模块
实现了解释器的核心功能
Code模块
用于管理HotSpot在运行时生成的本地机器指令
JIT编译器
就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言
JVM平台支持一种叫做即时编译的技术,目的是避免解释执行,而是将整个函数体编译成机器码,每次函数执行时,只执行编译后的机器码即可。使执行效率大幅提升
为什么两条腿走路?
首先程序启动后,解释器可以马上发挥作用,省去编译时间,立即执行
编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后执行效率更高
对于服务端应用,启动时间并非关注重点,但是对于看重启动时间的应用场景,就需要找到一个平衡点。
当Java虚拟机启动时,解释器可以首先发挥作用,而不是等待即时编译器全部编译完成后再执行,这样可以省去很多不必要的编译时间,随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率
概念解释
前端编译器
把.java文件转换为.class文件的过程
sun的Javac,
后端运行期编译器
把字节码转为机器码的过程
JIT编译器:hotSpot的C1,C2编译器
静态提前编译器
Ahead of Time Compliler AOT,直接把.java文件编译器本地机器代码的过程
GNU Compiler for the Java(GCJ)
什么时候选择JIT
热点代码及探测方式
需要根据代码被调用执行的频率而定,需要被编译为本地代码的字节码,也称之为热点代码。
JIT编译器会在运行时针对频繁调用的热点代码做出深度优化,将其直接编译为对应平台的本地机器指令。以此提升Java程序的执行性能
一个被多次调用的方法,后者一个方法体内部循环次数较多的循环体,都可以被称之为热点代码
因此可以通过JIT编译器编译为本地机器指令,由于这种编译方法发生在方法的执行过程中,因此也被称之为栈上替换,OSR On Statck Replacement
一个方法调用都少次才能达到标准?这个依靠热点探测功能
hotspot采用的基于计数器的热点探测
方法调用计数器
统计方法调用次数
默认阈值,Client模式下是1500次,Server模式下是10000次
-XX:CompileThreshold
回边计数器
统计循环体执行的循环次数
截图
当一个方法被调用时,如果不存在已被编译过的版本,则将此方法的调用计数器+1,然后判断方法调用计数器与回边计数器之和,是否超过方法调用计数器的阈值。如果已经超过,会向即时编译器提交一个该方法的代码编译请求。
截图
热度衰减
当超过一定的时间限度,如果方法调用次数仍然不足以提交即时编译器编译,那么这个方法的调用计数器就会被减少一半。
-XX:UseCounterHalfLifeTime参数设置半衰周期的时间,单位是秒
hotspot可以设置程序执行的方式
-Xint:完全采用解释器模式执行
-Xcomp完全采用即时编译器模式执行,如果即时编译器出现问题,解释器会介入执行
-Xmixed采用解释器+即时编译器的混合模式共同执行
hotspot中JIT分类
内嵌两个JIT编译器
client
server
大多情况下简称C1,C2
-client:指定Java虚拟机在Client模式下,并使用C1编译器
C1编译器会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度
方法内联
将引用的函数代码编译到引用点处,减少栈帧的生成,减少参数传递以及跳转过程
去虚拟化
对唯一的实现类进行内联
冗余消除
在运行期把一些不会执行的代码折叠掉
-server:指定虚拟机在server模式下,并使用C2编译器
C2进行耗时较长的优化,以及激进优化,单优化后的代码执行效率更高
逃逸分析是优化的基础,基于逃逸分析在C2上有几种优化
标量替换
用标量值代替聚合对象的属性值
栈上分配
对于未逃逸的对象分配在栈而不是堆
同步消除
清除同步操作,通常指synchronized
最后
jdk10起,hotspot又引入了个全新的即时编译器Graal编译器
JDK9引入了AOT编译器
StringTable
StringTable为什么要调整
jdk7中将StringTable放到了堆空间中,因为永久代的回收效率很低。在fullGC的时候才触发,而fullGC是老年代空间不足,永久代不足时才触发
这就导致了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类实例
字符串拼接操作
常量与常量的拼接结果在常量池,原理是编译期优化
截图
常量池中不存在相同内容的常量
只要其中有一个变量,拼接结果就在堆中(常量池以外的堆),变量的拼接原理是StringBuilder
截图
截图
如果拼接的结果调用intern方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
字符串拼接操作不一定使用的是StringBuilder如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式
针对final修饰类,方法,基本数据类型,引用数据类型变量的结构时,能使用final尽量使用上
截图
对比用+号拼接字符串和StringBuilder.append操作对比
拼接10万次,+号4000,append用了7毫秒,原因是+号每次循环创建一个StringBuilder,还要通过toString创建一个String对象
内存中由于创建了较多的对象,内存占用更大,如果需要GC需要花费额外的时间
改进空间:StringBuilder默认是16长度的char型数组,不够的时候会扩容,可以一次建一个比较大长度的数组。
intern()方法
如果字符串常量池中,通过equals判断是否相同,如果没有则在常量池中生成
确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度,注意,这个值会被存放在字符串内部池。(String intern pool)
面试题
截图
jdk6执行结果
false false
调用s.intern方法之前,字符串常量池已经有1
jdk7/8
false true
s3的变量地址为:new String (“11”)。执行完字符串常量池中不存在11,s3.intern会在字符串常量池生成11,s4变量记录的地址是常量池中的。
JDK7:此时常量池中并没有创建11,而是创建一个指向堆空间的中newString(“11”)的地址
变形
截图
newString("ab")会创建几个对象?
2个对象,查看字节码验证。一个是常量池ab,一个是new出来在堆空间。(前提是常量池没有ab)
new String("a")+new String("b")?
对象1,有拼接操作就newStringBuilder
对象2,new一个String
对象3,常量池a
对象4,new String
对象5,常量池b
对象6,StringBuilder,toString方法会new String返回
此时字符串常量池中没有ab
总结
jdk1.6中,将这个字符串对象放入串池
如果串池中有,则并不会放入,返回已有串池中的对象的地址,
如果没有,会把对象复制一份,放入串池,并返回串池中的对象地址
jdk1.7起,将这个字符串对象尝试放入串池
如果串池中有,则并不会放入,返回已有的串池中的对象的地址
如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址
练习
截图
intern()的效率测试
大的网站平台,需要内存中存储大量的字符串,比如社交网站,很多人存储:北京市,海淀区等信息,这时候如果字符串调用intern方法,则会明显降低内存的大小
Stringtable的垃圾回收
-XX:+PrintStringTableStatistics
G1中String去重操作
背景:对许多Java应用,做的测试结果如下
堆存货数据集合里面String对象占了25%
堆存活数据集合里面重复的String对象有13.5%
String对象的平均长度是45
许多大规模的Java应用的瓶颈在于内存。Java堆中存活的数据集合差不多25%是String对象,这里差不多一半的String对象是重复的, 重复是指equals方法=true,堆上重复的String对象必然是一种内存的浪费。G1垃圾收集器中实现自动持续对重复的String对象进行去重,这样避免浪费。
垃圾回收概述
什么是垃圾
垃圾是指运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾
垃圾回收相关算法
标记阶段:引用计数算法
对每个对象保存一个整型的引用计数器属性,用于记录被对象引用的情况
被对象引用了就+1,引用失效就-1,0表示不可能再被使用,可进行回收
优点:实现简单,垃圾便于辨识,判断效率高,回收没有延迟性
缺点
需要单独的字段存储计数器,增加了存储空间的开销
每次赋值需要更新计数器,伴随加减法操作,增加了时间开销
无法处理循环引用的情况,致命缺陷,导致JAVA的垃圾回收器中没有使用这类算法
小结
引用计数算法,是很多语言的资源回收选择,例如python,它更是同时支持引用计数和垃圾回收机制
Python如何解决循环引用
手动解除
使用弱引用,weakref,python提供的标准库,旨在解决循环引用
标记阶段:可达性分析算法
java c#选择的
基本思路
是以根对象(GCRoots)为起始点,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达
使用可达性分析算法后,内存中存活的对象都被被根对象集合直接或间接连接着,搜索所走过的路径称为引用链
如果目标对象没有任何引用链相连,则是不可达的,意味着该对象已经死亡,可以标记为垃圾对象
在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活的对象
GC Roots包括
虚拟机栈中引用的对象
比如各个线程被调用的方法中使用到的参数、局部变量
本地方法栈内JNI,引用的对象
方法区中静态属性引用的对象
比如:java类的引用类型静态变量
方法区中常量引用的对象
比如字符串常量池里的引用
所有被同步锁synchronized持有的对象
Java虚拟机内部的引用
基本数据类型对应的class对象,一些常驻的异常对象,如nullpointerException,OOMerror,系统类加载器
反映java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等
除了固定的GC Roots集合之外,根据用户选择的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象临时性的加入,共同构成完整GCRoots集合,比如分代收集和局部回收
如果只针对Java堆中某一块内存区域进行垃圾回收,必须要考虑这个区域的对象可能被其他区域对象所引用,这是需要一并将关联的区域对象加入GC Roots集合中去考虑,才能保证可达性分析的准确性。
小技巧
由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那么它就是一个Root
如果需要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话,分析结果的准确性就无法保证。
这也是GC进行时必须STW的一个重要原因,即使是号称几乎不会发生停顿的CMS收集器中,枚举根节点也是必须要停顿的。
对象的finalization机制
Java语言提供了对象终止finaliztion机制来允许开发人员提供对象被销毁之前的自定义处理逻辑
当垃圾回收器发现没有引用指向一个对象,即垃圾回收此对象之前,总会先调用这个对象的finalize()方法
finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放,通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件,套接字和数据库链接等
定义虚拟机的对象可能的三种状态
可触及的
从根节点开始,可以到达这个对象
可复活的
对象的所有引用都被释放了,但是对象有可能在finalize()中复活
不可触及的
对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次
只有对象再不可触及时才可以被回收
具体过程
判断一个对象ObjA是否可以被回收,至少需要经历两次标记过程
1、如果对象到GCRoots没有引用链,则进行第一次标记
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开发的一款免费性能分析工具
清除阶段:标记-清除算法
标记
从引用根节点开始遍历,标记所有被引用的对象,一般是在对象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
内存溢出与内存泄露
OOM
java 虚拟机的堆内存设置不够
代码创建大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)
内存泄露
只有对象不再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄露
实际情况有一些疏忽导致对象的生命周期变的很长甚至OOM,宽泛意义上的内存泄露
举例
单例的生命周期和程序是一样长,如果单例程序中,持有对外部对象的引用的话,那么这个外部对象是不能被回收的,导致内存泄露
一些提供close的资源未关闭导致内存泄露,如数据库链接,网络链接,和IO
StopTheWorld
垃圾回收的并行与并发
并发
同一时间段内,几个程序都在同一个处理器上运行
CPU切换
并行
一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互相不抢占资源,可以同时进行,我们称之为并行
并行因素取决于CPU的核心数量
并发的多个任务之间抢占资源
并行多个任务之间不互相抢占资源
垃圾回收的并发与并行
并行
多条垃圾收集器并行工作,用户线程处于等待状态
串行
单线程执行。
安全点与安全区域
安全点
程序执行并非在所有地方都能停顿下来开始GC,只有特定的位置才能停顿下来开始GC,这些位置称为安全点
如果太少,导致GC等待时间长,如果太多导致运行时性能问题,,大部分指令执行都比较短,通常会根据是否具有让程序长时间执行的特征为标准选择一些执行时间较长的指令作为安全点,比如方法调用,循环跳转和异常跳转等
抢先式中断
中断所有线程,如果还有线程不在安全点,就恢复线程,让线程跑到安全点
没有虚拟机采用
主动式中断
设置一个中断标志,各个线程运行到安全点的时候,主动轮询这个标志,如果标志为真,则将自己进行中断挂起
安全区域
如果线程处于sleep或者blocked状态,这时候线程无法响应jvm中断请求,走到安全点去中断挂起。对于这种情况,就需要安全区域来解决
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中任何位置开始GC都是安全的。
当线程运行到安全区域代码时,首先标志已经进入了安全区域,如果GC,JVM会忽略标识为安全区域状态的线程
当线程即将离开安全区域时,会检查JVM是否已经完成GC,如果完成了,则继续运行。否则线程必须等待直到收到可以安全离开安全区域的信号为止
强引用
截图
最传统的引用定义,程序代码中普遍存在的引用赋值,类似new Object这种引用关系,无论任何情况下,强引用存在,垃圾收集器永远不会回收掉被引用的对象
强引用是造成java内存泄露的主要原因之一
强引用可以直接访问目标对象
软引用
系统将要发生内存溢出之前,会将这些对象列入回收范围之中进行第二次回收,如果这些回收后还没有足够内存,才会抛出内存溢出异常
软引用通常用来实现内存敏感的缓存,高速缓存就有用到软引用
垃圾回收器在某个时间决定回收软可达的对象的时候,会清理软引用,并可选的把引用存放到一个引用队列
弱引用
只被弱引用关联的对象只能够生生存到下一次垃圾收集器之前,当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象
虚引用
一个对象是否有虚引用存在,完全不会对其生存时间构成影响。唯一目的就是在这个对象被收集器回收时收到一个系统通知
他不能单独使用,也无法通过虚引用获取被引用的对象。
终结器引用
用以实现对象的finalize方法,所以被称为终结器引用
无需手动编码,其内部配合引用队列使用
GC时,终结器引用入队,由finalize线程通过终结器引用找到被引用对象并调用 他的finalize方法,第二次GC时才能回收被引用对象
垃圾回收器
GC分类与性能指标
垃圾回收器分类
按垃圾回收线程数
可以分为串行垃圾回收器
截图
串行回收指同一个时间段内,只允许一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直到垃圾收集工作结束
在单CPU处理器或者较小应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以串行回收默认被应用在客户端的client模式下的JVM中
在并发能力比较强的CPU上,并行回收器产生的停顿时间要短于串行回收器
并行垃圾回收器
和串行相反,并行收集可以运用在多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了STW机制
按照工作模式分
并发式
垃圾回收器与应用程序交替工作,以尽可能减少应用程序的停顿时间
独占式
一旦运行,就停止应用程序中所有的用户线程,直到垃圾回收过程完全结束
按照碎片处理方式
压缩式
非压缩式
按个工作内存区间分
年轻代
老年代
性能指标
吞吐量
运行用户代码的时间占总运行时间的比例
总运行时间:程序的运行时间+内存回收的时间
吞吐量优先,意味着单位时间内,STW的时间最短
垃圾收集开销
吞吐量的补数,垃圾收集所占用的时间与总运行时间的比例
暂停时间
执行垃圾收集时,程序的工作线程被暂停的时间
暂停时间优先,意味着单次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款经典垃圾收集器和垃圾分代之间的关系
截图
垃圾收集器的组合关系
截图
jdk8之前,可以用虚线参考关系
CMS下面的实线,是CMS回收失败的后备方案
JDK8中取消了红线的组合,标记为废弃的。如果要用也可以用。
JDK9中将红线做了remove
jdk14中弃用了绿线组合
jdk14中删除了CMSGC
JDK9默认G1
JDK8默认Parallel Scavenge 和Parallel old Gc
新生代用了Parallel Scavenge 则老年代自动触发用Parallel old
Parallel底层与ParNew底层不同,所以不能和CMS组合
如何查看默认的垃圾收集器
-XX:+PrintCommandLineFlags
jinfo -flag 相关垃圾回收器参数 进程ID
截图
Serial回收器:串行回收
Serial收集器采用复制算法,串行回收和STW机制的方式执行内存回收
除了年轻代,还有用于执行老年代的Serial old收集器,同样采取了串行回收,但是用标记压缩算法
截图
使用一个CPU或者一条收集线程去完成垃圾收集工作,在进行垃圾收集时,必须暂停其他所有工作线程
优势
简单而高效,对于限定单个CPU的环境来说,由于没有线程交互的开销,可以获取最高的单线程收集效率
HotSpot虚拟机中,使用-XX:+UseSerialGC指定年轻代和老年代使用串行收集器
对于交互强的应用而言,不会采取串行垃圾收集器
ParNew回收器:并行回收
除了采用并行回收,其他方面和Serial之间几乎没有任何区别
截图
-XX:UseParNewGC手工指定ParNew收集器执行内存回收任务,它表示年轻代使用,不影响老年代
-XX:ParallelGCThreads限制线程数量,默认开启和CPU数据相同的线程数
Parallel回收器:吞吐量优先
也是并行回收
和ParNew不同,它的目标是达到一个可控制的吞吐量
自适应调节策略也是Parallel 与ParNew的一个重要区别
适合后台运算不需要太多交互的任务,例如执行批量处理,订单处理,工资支付,科学计算的应用程序
Parallel old采取标记压缩算法,同样基于并行回收和STW机制
参数配置
-XX:+UseParallelGC
手动指定年轻代使用此收集器执行内存回收任务
-XX:+UseParallelOldGC
手工指定老年代使用并行回收收集器,分别适用于新生代和老年代,默认jdk8是开启的
与上面这两个参数关联,开启一个,默认开启另一个。
-XX:ParallelGCThreads
设置年轻代并行收集器的线程数,一般与CPU数量相同,如果CPU数量大于8个,则值=3+(5*N/8)
-XX:MaxGCPauseMillis
设置收集器最大停顿时间,单位毫秒
改参数谨慎使用
-XX:GCTimeRatio
垃圾收集占总时间比,用于衡量吞吐量大小
默认99,取值范围0-100,也就是垃圾回收时间不超过1%
与上一个参数矛盾,暂停时间越长,Ratio参数就容易超过设定比例
-XX:+UseAdaptiveSizePolicy
开启自适应调节策略
这种模式下,年轻代大小,Eden和Survivor的比例,晋升老年底对象年龄参数都会被自动调整
为了达到堆大小,吞吐量和停顿时间之间的平衡点
在手动调优比较困难的场景下,可以直接用自适应方式,仅指定虚拟机最大堆,目标吞吐量和停顿时间,让虚拟机自己完成调优工作
CMS回收器:低延迟
jdk1.5推出Concurrent Mark Sweep 并发的标记清除,第一次实现了让垃圾收集线程与用户线程同时工作
截图
初始标记:STW,仅仅只是标记处GC Roots能直接关联的对象,一旦标记完成后就会恢复之前被暂停的所有应用线程,由于直接关联对象比较小,所以这里速度非常快
并发标记:从GCRoots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要停顿用户线程。可以与垃圾收集线程一起并发运行
重新标记:为了修正并发标记期间,因用户程序继续运作导致标记产生变动的那一部分对象的标记记录
并发清除:清理删除标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也可以与用户线程同时并发
初始标记和重新标记阶段仍然需要STW机制
由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此CMS收集器不能像其他收集器那样等到老年代几乎填满再进行回收,而是当堆内存使用率达到某一阈值时,便开始进行回收。
要是CMS运行期间预留的内存无法满足程序需要,就会出现一次Concurrent Mode Failure失败,这时虚拟机启用备用方案,临时启用Serial old 收集器来重新进行老年代的垃圾收集,这样停顿时间就长了。
CMS采取标记清除算法,会产生内存碎片,只能够选择空闲列表执行内存分配
为什么不采取标记压缩呢?
因为并发清除时,如果用压缩整理内存,原来的用户线程使用的内存就无法使用了。标记压缩更适合STW场景下使用
优点
并发收集
低延迟
缺点
会产生内存碎片
对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:ParallelCMSThreads
设置CMS的线程数量
默认启动的线程数是(ParallelGCThreads+3)/4
ParallelGCThreads是年轻代并行收集器的线程数
小结
如果想要最小化使用内存和并行开销,选择Serial GC
如果最大化应用程序的吞吐量,选择ParallelGC
如果想要最小化的GC的中断或停顿时间,选择CMS GC
jdk9标记为废弃的,jdk14已经删除了
G1回收器:区域化分代式
官方给G1设定的目标
就是在延迟可控的情况下,获得尽可能高的吞吐量,所以才担当起全功能收集器的重任和期望
Garbage First
G1是一个并行回收器,他把堆内存分割为很多不相关的区域(Region)(物理上不连续)
使用不同的region表示Eden,s0,s1,老年代等
G1跟踪各个region里面垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region
JDK1.7版本正式启用,jdk9以后默认垃圾回收器
JDK8还不是默认的,需要用-XX:+UseG1GC来启用
优势
并行与并发
分代收集
同时兼顾年轻代与老年代
空间整合
region之间用复制算法,整体可以看做是标记压缩算法。
两种算法都避免内存碎片,有利于程序长时间运行,分配大对象不会因为无法找到连续空间提前触发下一次GC,尤其当Java堆非常大的时候,G1优势更加明显
可预测的停顿时间模型
能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不能超过N毫秒
缺点
相较于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
-XX:ParallelGCThread
设置STW工作线程数的值,最多设置8
-XX:ConcGCThreads
设置并发标记的线程数,将N设置为并行垃圾回收线程数(parallelGCThreads)的1/4左右
-XX:InitiatingHeapOccupancyPercent
设置触发并发GC周期的Java堆占用率阈值,超过此值就触发GC,默认是45
常见调优
第一步开启G1垃圾收集器
第二步,设置堆的最大内存
第三步,设置最大的停顿时间
G1提供了三种垃圾回收模式在不同的条件下触发
YoungGC
MixedGC
FullGC
适用场景
面向服务器端应用,针对具有大内存,多处理器的机器
最主要应用是需要低GC延迟
如:在堆大小约6GB或更大,可预测的暂停时间可以低于0.5s,G1每次清理一部分region来保证每次GC停顿时间不会过长
用来替换1.5中的CMS
超过50%的Java堆被活动数据占用
对象分配频率或年代提升频率变化很大
GC停顿时间过长,长于0.5~1秒
region
所有region大小相同,且在JVM生命周期内不会改变
截图
region可以充当多个角色
垃圾回收过程
年轻代GC
当年轻代eden区用尽时
并行独占式收集器
老年代并发标记过程
当堆内存使用到一定值,默认45%
混合回收
标记完成马上开始混合回收
G1老年代回收器不需要整个老年底都被回收,一次只需要扫描回收一小部分老年代的region就可以了。
同时这个老年代回收是和年轻代一起被回收的。
有可能fullGC
记忆集
每个region对应一个记忆集
通过记忆集避免全局扫描
每次引用类型数据写操作时,会产生一个写屏障暂时中断操作
然后检查将要希尔的引用指向的对象是否和该引用对象类型数据在不同的region,如果不同就通过CardTable把相关的引用信息记录到引用指向对象所在的Region对应的记忆集中
当进行垃圾收集时,在GC根节点枚举范围加入记忆集,就可以保证不进行全局扫描,也不会有遗漏
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回收过程二、并发标记过程
初始标记阶段STW
标记从根节点直接可达的对象,并且触发一次年轻代GC
根区域扫描阶段
扫描Survivor区直接可达老年代区域对象,并标记被引用的对象,这个过程在youngGC之前完成
并发标记
和应用程序并发执行,并发标记阶段若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。
并发标记过程中,会计算每个区域的对象活性,存活对象的比例
再次标记
由于应用程序持续进行,需要修正上次标记结果,STW,G1采取比CMS更快的初始快照算法
独占清理
计算各个区域存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下个阶段做铺垫,STW,
这个阶段并不会实际上去做垃圾的收集
并发清理阶段
识别并清理完全空闲的区域
G1回收过程三:混合回收
当越来越多的对象晋升到老年代old region时,为了避免内存被耗尽,虚拟机会触发一次混合的垃圾收集器,该算法除了回收整个young region,还会回收一部分的old region。也要注意Mixed gc并不是fullgc
并发标记结束后,老年代中百分百为垃圾的内存分段被回收了。部分为垃圾的内存分段被计算出来了,默认情况下,这些老年代的内存分段会分8次被回收-XX:G1MixedGCCountTarget设置
混合回收的回收集包括八分之一的老年代,Eden区内存分段,Survivor区内存分段。
由于老年代中内存分段默认分8次回收,G1会优先回收垃圾多的内存分段,并且有一个阈值会决定内存分段是否被回收。-XX:G1MixedGCLiveThresholdPercent,默认为65%。意思是垃圾占比达到65%才会被回收。如果垃圾占比比较低,意味存活对象较高,复制的时候花更多时间。
混合回收不一定要进行8次,有一个阈值:-XX:G1HeapWastePercent
默认值是10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存比例低于10%,则不再进行混合回收,因为GC花费更多的时间,但是回收到的内存却很少。
G1可选过程四:fullGC
G1初衷就是要避免FULLGC,如果上述方式不能正常工作,G1会停止应用程序的执行。使用单线程的内存回收算法进行垃圾回收,性能非常差。应用程序停顿时间长
比如堆太小,当G1复制存活对象的时候没有空的内存分段可用,则会回退到FullGC
导致FullGC原因可能有两个
回收阶段的时候没有足够的to-space存放晋升的对象
并发处理过程完成之前空间耗尽了。
优化建议
避免使用-Xmn或-XX:NewRatio等相关选项显式设置年轻代大小
固定的年轻代大小会覆盖暂停时间目标
暂停时间目标不要太苛刻,太苛刻会影响吞吐量
垃圾回收器总结
截图
GC日志分析
截图
截图
截图
截图
截图
截图
GC EASY
垃圾回收器的新发展
截图
Shenandoah GC
强项
低延迟时间
弱项
高运行负担下的吞吐量下降
ZGC
在尽可能堆吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾回收的停顿时间限制在10毫秒以内的低延迟
并发标记,并发预备重分配,并发重分配,并发重映射
除了初始标记是STW,其他地方几乎都是并发执行的
JVM与Java体系结构
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虚拟机退出的情况
JVM发展历程
sun Classic VM
世界第一款商用Java虚拟机
JDK1.4时被淘汰
只提供了解释器
如果使用JIT编译器,就需要外挂,但是JIT和解释器不能配合工作
Exact VM
为了解决上一个虚拟机问题,JDK1.2时,sun提供了此虚拟机
Exact Memory Management:准确式内存管理
虚拟机知道内存中某个位置的数据是什么类型
具有现代高性能虚拟机的雏形
热点探测
编译器与解释器混合工作模式
只在Solaris平台短暂使用,其他平台还是Classic vm
英雄气短,被hotspot虚拟机替换
hotspot虚拟机
最初由Longview Technologies的小公司设计,1997年被sun公司收购,2009年sun公司被甲骨文收购
JDK1.3时,HotSpot VM成为默认虚拟机
绝对市场地位,称霸武林
JDK6,8等均默认
HotSpot就是他的热点代码探测技术
通过计数器找到最具编译价值代码,触发即时编译或栈上替换
通过编译器与解释器协同工作,在优化响应时间和最佳执行性能中取得平衡
走路去目的地,
JRockit
BEA公司
专注服务器端应用
不太关注程序启动速度,引起JRockit内部不包括解析器实现,全部代码靠即时编译器编译后执行
世界上最快的JVM
全面的Java运行时解决方案组合
JRockit Real Time提供毫秒或微秒级的JVM响应时间,适合财务、军事指挥,电信网络的需要
MissionControl服务套件,极低的开销,来监控、管理和分析生成环境中的应用程序的工具
2008年BEA被oracle收购
JDK8中,在HOTSPOT的基础上,移植JRockit的优秀特性
IBM J9
全称:IBM Technology for java Virtual Machine 简称IT4J,内部代号J9
市场定位与HotSpot接近,服务器端、桌面应用,嵌入式等多用途VM
广泛应用于IBM的各种Java产品
IBM产品结合使用性能最好
有影响力的三大商用虚拟机之一
2017开源,OPEN J9
KVM和CDC/CLDC HotSpot
JavaME产品线产品
智能控制器、传感器、老人手机等
Azul VM
与特定硬件平台绑定、软硬件配合的专有虚拟机
运行于Azul Systems公司的专有硬件Vega系统上的虚拟机
每个实例可以管理至少数十个CPU和数百GB内存的硬件资源,并提供在巨大内存范围内实现可控的GC时间的垃圾收集器,专有硬件优化的线程调度等优秀特性
2010年,发布自己的Zing JVM,可以在通用X86平台上提供接近于vega系统的特性
Liquid VM
BEA公司开发的,运行在自家Hypervisor系统上
不需要操作系统支持,本身实现了一个专用操作系统的必要功能,如线程调度、文件系统、网络支持等
随着JRockit虚拟机终止开发,Liquid VM项目也停止了
Apache Harmony
JDK1.5,1.6兼容
IBM和Intel联合开发的开源JVM,2011年退役
Java类库代码吸纳进了Android SDK
Microsoft JVM
只能在Windows平台运行,xp系统中不用了。
TaobaoJVM
基于OpenJDK开发了自己的定制版本AlibabaJDK
深度定制且开源的高性能服务器版JAVA虚拟机
GCIH:GC invisible heap,将生命周期较长的Java对象从heap中已到heap之外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率
GCIH中的对象还能够在多个JAVA虚拟机进程中实现共享
使用crc32指令实现JVM intrinsic降低JNI的调用开销
针对大数据场景的ZenGC
在阿里产品上性能高,硬件严重依赖intel的CPU,损失了兼容性,但是提高了性能
淘宝、天猫上线,把oracle官方JVM版本全部替换
Dalvik VM
谷歌开发,应用于Android系统,安卓2.2提供了JIT,发展迅猛
只能称作虚拟机,不能称作Java虚拟机,没有遵循Java虚拟机规范
不能直接执行Java的Class文件
基于寄存器架构,不是jvm的栈架构
执行的是编译后的dex文件,执行效率比较高
安卓5.0使用支持提前编译AOT的ART VM替换Dalvik VM
Grall VM
2018年4月,Oracle labs公开了GraalVM。
跨语言全栈虚拟机,可以作为任何语言的运行平台使用
类加载子系统
作用
负责从文件系统或者网络中加载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、如果父类的加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式
4、原创整理不易,还请扫码,运行时数据区详图关注支持
优势
避免类的重复加载
保护程序安全,防止核心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位占用一个slot,64位类型(long和double)占用两个slot
局部变量表中的变量只有在当前方法调用中有效,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。
方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁
关于Slot的理解
JVM虚拟机会为局部变量表中的每个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this,会存放在index为0的slot处,其余的参数表顺序继续排列
截图
this截图
栈帧中的局部变量表中的槽位是可以重复的,如果一个局部变量过了其作用域,那么其作用域之后申明的新的局部变量就有可能会复用过期局部变量的槽位,从而达到节省资源的目的
静态变量与局部变量对比及小结
变量的分类
按照数据类型分
基本数据类型
引用数据类型
按照声明的位置
成员变量,在使用前经历过初始化过程
类变量
链接的准备阶段给类变量默认赋值,初始化阶段显示赋值,即静态代码块赋值
实例变量
随着对象的创建,会在堆空间分配实例变量空间,并进行默认赋值
局部变量
在使用前,必须进显式赋值,否则编译不通过
补充:
在栈帧中,与性能调优关系最密切的部分,就是局部变量表,方法执行时,虚拟机使用局部变量表完成方法的传递
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
操作数栈
在方法执行的过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈/出栈
截图
如果被调用方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新程序计数器中下一条需要执行的字节码指令
Java虚拟机的解释引擎是基于栈的执行引擎,其中栈就是操作数栈
主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
每一个操作数栈会拥有一个明确的栈深度,用于存储数值,最大深度在编译期就定义好
栈中,32bit类型占用一个栈单位深度,64bit类型占用两个栈单位深度
操作数栈并非采用访问索引方式进行数据访问,而是只能通过标准的入栈、出栈操作完成一次数据访问
栈顶缓存技术
由于操作数是存储在内存中,频繁的进行内存读写操作影响执行速度,将栈顶元素全部缓存到物理CPU的寄存器中,依此降低对内存的读写次数,提升执行引擎的执行效率
动态链接
指向常量池的方法 引用
每一个栈帧内部都包含一个指向运行时常量池中,该帧所属方法的引用
目的是为了支持当前方法的代码能够实现动态链接,比如invokedynamic指令
在java源文件被编译成字节码文件中时,所有的变量、方法引用都作为符号引用,保存在class文件的常量池中。
描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的。
动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
原创整理不易,还请扫码JVM运行时数据区详图,谢谢支持
常量池、运行时常量池
常量池在字节码文件中,运行时常量池,在运行时的方法区中
方法返回地址
存放调用该方法的pc寄存器的值
方法的结束
正常执行完成
出现未处理异常,非正常退出
无论哪种方式退出,方法退出后,都会返回该方法被调用的位置。方法正常退出时,调用者的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
动态解析出需要调用的方法,然后执行
直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式
截图
静态语言和动态语言
区别在于对类型的检查是编译器还是运行期,满足编译期就是静态类型语言,反之就是动态类型语言。
Java是静态类型语言,动态调用指令增加了动态语言的特性
方法重写的本质
找到操作数栈顶的第一个元素所执行的对象的实际类型,记做C
如果在类型C中找到与常量池中描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束,如果不通过,则返回java.lang.IllegalAccessError异常
否则,按照继承关系从下往上依次对C的各个父类进行上一步的搜索和验证过程。
如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常
虚方法表
面向对象的编程中,会很频繁的使用动态分配,如果每次动态分配的过程都要重新在类的方法元数据中搜索合适的目标的话,就可能影响到执行效率,因此为了提高性能,JVM采用在类的方法区建立一个虚方法表,使用索引表来代替查找
每个类都有一个虚方法表,表中存放着各个方法的实际入口
虚方法表会在类加载的链接阶段被创建,并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法也初始化完毕
截图
本地方法接口
什么是本地方法
简单讲,就是一个Java调用非Java代码的接口
为什么使用native method
与Java环境外交互
例如与操作系统底层或硬件交换信息时的情况
例如启动一个线程
本地方法栈
Java虚拟机栈管理Java方法的调用,而本地方法栈用于管理本地方法的调用
本地方法栈,也是线程私有的。
允许被实现成固定或者是可动态扩展的内存大小。
内存溢出情况和Java虚拟机栈相同
使用C语言实现
具体做法是Native Method Stack 中登记native方法,在Execution Engine执行时加载到本地方法库
当某个线程调用一个本地方法时,就会进入一个全新,不受虚拟机限制的世界,它和虚拟机拥有同样的权限。
并不是所有的JVM都支持本地方法,因为Java虚拟机规范并没有明确要求本地方法栈的使用语言,具体实现方式,数据结构等
Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一
堆
堆的核心概述
一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域
Java堆区在JVM启动的时候即被创建,其空间大小也就确认了。堆内存的大小是可调节的
Java虚拟机规范规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(TLAB)
“几乎”所有的对象实例都在这里分配内存
数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,引用指向对象或者数组在堆中的位置
方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
堆是GC执行垃圾回收的重点区域
堆空间细分为:
Java7及之前
内存逻辑上分为:
新生区
Eden区
Survivor区
from
to
谁空谁是to
养老区
永久区
Java8及之后
内存逻辑上分为:
新生区
Eden区
Survivor区
from
to
谁空谁是to
养老区
元空间
约定
新生区==新生代==年轻代
养老区==老年区==老年代
永久区==永久代
jvisualvm工具
安装插件后可查看
子主题
-XX:+PrintGCDetails
可开启打印查看方法区实现
设置堆内存的大小与OOM
-Xms :小秘书表示堆空间的起始内存。
-Xmx:小明星表示堆空间的最大内存
超过最大内存将抛出OOM
通常将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾会后清理完堆区后,不需要重新分隔计算堆区的大小,从而提高性能
默认情况下
初始内存大小
物理电脑内存大小/64
截图
最大内存大小
物理电脑内存/4
jps命令
查看当前程序运行的进程
jstat
查看JVM在gc时的统计信息
jstat -gc 进程号
年轻代与老年代
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:-表示关闭自适应,实际没有用。直接用Ratio分配即可
几乎所有的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
针对HotSpotVM的实现
GC按照内存回收区域分为
部分收集
新生代收集
MinorGC (YoungGC)
老年代收集
MajorGC/old
GC
GC
目前只有CMS GC会单独收集老年代的行为
很多时候MajorGC与FullGC混淆使用,具体分辨是老年代回收还是整堆回收
混合收集
收集整个新生代以及部分老年代的垃圾收集
目前只有G1 GC会有这种行为
整堆收集
收集整个Java堆和方法区的垃圾收集
MajorGC
FullGC
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是开发或调优中尽量要避免的,这样暂停时间会短一些。
堆空间分代思想
其实不分代也可以,分代的理由是优化GC性能
内存分配策略
如果对象再Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor区容纳,则被移动到Survivor空间中,并将对象年龄设置为1,对象再Survivor区每熬过一次MinorGC,年龄就+1,当年龄增加到一定程度(默认为15,不同Jvm,GC都所有不同)时,就会被晋升到老年代中
-XX:MaxTenuringThreshold
优先分配到Eden
大对象直接分配到老年代
尽量避免程序中出现过多的大对象
长期存活的对象分配到老年代
动态对象年龄分配
如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄
空间分配担保
-XX:HandlePromotionFailure
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间,是否大于新生代所有对象的总空间
如果大于,则此次MinorGC是安全的
如果小于,则查看-XX:HandlePromotionFailure设置是否允许担保失败
true
会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
大于,则尝试进行一次MinorGC,但是这次MinorGC依然是有风险的
小于,则改为进行一次FullGC
false
则改为进行一次FullGC
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虚拟机规范等
单击打开博客
截图
堆是分配对象的唯一选择吗
随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术,将会导致一些微秒变化,所有对象分配到堆上渐渐变得不那么绝对了。
有一种特殊情况,如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配,这样无需堆上分配,也不需要垃圾回收了,也是最常见的堆外存储技术
TaoBaoVM,其中创新的GCIH(GC invisible heap)技术实现了off-heap,实现了将生命周期较长的Java对象从heap中移动heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的
逃逸分析概述
逃逸分析的基本行为就是分析对象动态作用域
当一个对象在方法中定义后,对象只在方法内部使用,则认为没有发生逃逸
当一个对象在方法中被定义后,它被外部方法引用,则认为发生逃逸,例如作为调用参数传递到其他地方中
截图
栈上分配
将堆分配转为栈分配,如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
同步策略
如果一个对象被发现只能从一个线程被访问到,对于这个对象的操作可以不考虑同步
JIT编译器可以借助逃逸分析来判断同步块所使用的的锁对象,是否只能够被一个线程访问,而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候,就会取消对这部分代码的同步。这样就大大提高并发性和性能,这个取消同步的过程就叫同步省略,也叫锁消除
截图
分离对象或标量替换
有的对象可能不需要作为一个连续的内存结构存在,也可以被访问到,那么对象的部分(或全部)可以不存储在内存。而是存储在CPU寄存器中
子主题
标量是指一个无法再分解的更小的数据的数据。Java中原始数据类型就是标量
可以分解的数据叫聚合量,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量
截图
子主题
标量替换参数:-XX:EliminateAllocations,默认打开
0 条评论
下一页