JVM学习笔记(详细)
目录
为对象分配内存:TLAB(Thread Local Allocation Buffer)
参考https://www.cnblogs.com/yanl55555/p/12610952.html
01 JVM与Java体系结构
简介
- JVM虚拟机:是一台执行Java字节码的虚拟计算机,它拥有独立的运行机制,其运行的java字节码未必有java语言编译而成(JVM平台的各种语言可以共享java虚拟机带来的跨平台性、优秀的垃圾回收器以及可靠的即时编译器)
- JVM的作用:JVM就是二进制字节码的运行环境。特点:一次编译,到处运行;自动内存管理;自动垃圾回收功能
JVM整体架构,HotSpot
JDK默认的虚拟机,采用解释器与即时编译器并存的架构(JIT,Just-In-Time Compiler)
- 类加载器:加载、链接、初始化
- 加载:引导类加载器,扩展类加载器,应用加载器
- 链接:验证,准备,解析
- 初始化
- 运行时数据区:方法区和堆区是所有线程共享的内存区域;虚拟机栈、本地方法栈和程序计数器是运行时线程私有的内存区域
- 方法区(永久代)在jdk8中又叫做元空间Metaspace,
- 方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器(JIT编译器,英文写作Just-In-Time Compiler)编译后的代码等数据。
- 虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
- 执行引擎:解释器,即时编译器JIT,垃圾回收器
java代码执行流程
- java源程序--编译javac-->字节码文件.class-->类装载子系统生成反射类(存入方法区)--->运行时数据区(五大块儿)--->执行引擎-->解释执行+编译执行(JIT)-->操作系统(Win,Linux,Mac JVM)
JVM架构模型
由于跨平台的设计,java的指令都是根据栈来设计的,不同平台CPU架构不同,所以不能设计为基于寄存器的
- 基于栈的指令集架构:跨平台,指令集小,指令多;性能比寄存器差
- 基于寄存器的指令集架构:指令少
反编译查看指令集代码:javap -v xxxx.class
JVM生命周期
JVM发展历程
02 类加载子系统
JVM细节版架构
类加载器的作用
- 1.类加载子系统负责从文件系统或者网络中加载class文件,class文件在文件开头有特定的文件标识即16进制CA FE BA BE;
- 2.加载后的Class类信息存放于一块成为方法区的内存空间。除了类信息之外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
- 3.ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定
- 4.如果调用构造器实例化对象,则其实例存放在堆区
类的加载过程
- 加载模块
- 1.通过一个类的全限定明获取定义此类的二进制字节流;
- 2.将这个字节流所代表的的静态存储结构转化为方法区的运行时数据;
- 3.在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
- 链接模块
-
验证
- 1.目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
- 2.主要包括四种验证,文件格式验证,源数据验证,字节码验证,符号引用验证。
-
准备
- 1.为类变量分配内存并且设置该类变量的默认初始值,即零值;
- 2.这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化;
- 3.这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到java堆中。
-
解析
- 1.将常量池内的符号引用转换为直接引用的过程。
- 2.事实上,解析操作网晚会伴随着jvm在执行完初始化之后再执行
- 3.符号引用就是一组符号来描述所引用的目标。符号应用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
- 4.解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info/CONSTANT_Fieldref_info、CONSTANT_Methodref_info等。
-
- 初始化模块
- 1.clinit()即“class or interface initialization method”,注意他并不是指构造器init()
- 2.此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
3.我们注意到如果没有静态变量c,那么字节码文件中就不会有clinit方法
- 构造器方法clinit()中指令按语句在源文件中出现的顺序执行
-
虚拟机必须保证一个类的clinit()方法在多线程下被同步加锁,即一个类只需被clinit一次,之后该类的内部信息就被存储在方法区。
类加载器分类
1.JVM支持两种类型的加载器,分别为引导类加载器C/C++实现(BootStrap ClassLoader)和自定义类加载器由Java实现
2.从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。
3.注意上图中的加载器划分关系为包含关系,并不是继承关系
4.按照这样的加载器的类型划分,在程序中我们最常见的类加载器是:引导类加载器BootStrapClassLoader、自定义类加载器(Extension Class Loader、System Class Loader、User-Defined ClassLoader)
双亲委派机制
- 双亲委派机制,双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,他首先不会自己去尝试加载这个类,而是把这个请求委派父类加载器去完成。每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个请求(他的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
- 线程上下文类加载器(Thread Context ClassLoader)可以打破双亲委派机制
- 沙箱安全机制,比如,自定义String类,放在java.lang包下,使用时是使用引导类加载器加载的系统的String类
其他
- 在JVM中两个对象是否是同一个类存在的两个必要条件:类的完整类名,加载这个类的ClassLoader(实例)
- 对类加载器的引用
- 类的主动使用和被动使用
03 运行时数据区概述及线程
- 方法区
- 堆
- 程序计数器
- 虚拟机栈
- 本地方法栈
04 程序计数器
存储指向下一条指令的地址,由执行引擎读取下一条指令
为什么线程私有?cpu时间片轮转,需要记录当前线程的执行指令
05 虚拟机栈
- 概述
- 栈的存储单位:栈帧(局部变量表,操作数栈,动态链接,方法返回地址,一些附加信息)
- 局部变量表:存储的基本单位是slot,4个字节,可重复利用
- 操作数栈:在方法执行过程中,根据字节码指令,在栈中写入数据或提取数据
- 代码追踪:javap -v xxx.class或者使用idea的jclasslib插件(view->show Bytecode With jclasslib)
- 栈顶缓存技术(Top-of-Stack-Caching):将栈顶元素全部缓存在物理cpu的寄存器中,降低对内存的读写次数,提升引擎的执行效率
- 动态链接:每一个栈帧内部都包含一个指向运行时常量池中该帧所属方法的引用。(常量池的作用:提供一些符号和常量,便于指令的识别)
- 方法的调用:早期绑定;晚期绑定(如参数为接口);非虚方法(静态,final等);虚方法(晚期绑定的方法)
- invokestatic
- invokespecial
- invokevirtual
- invokeinterface
- invokedynamic:动态解析出需要调用的方法,java8 lambda之后才有直接生成的方法
- 虚方法表
- 方法返回地址:存放调用该方法的程序计数器的值,即调用该方法的指令的下一条指令的地址
- 一些附加信息:虚拟机实现相关,比如对程序调试支持的信息
- 面试题
最后一个得看定义的局部变量的生命周期,逃逸分析,比如变量是参数,变量返回了
06 本地方法接口
- native标识,java代码调用非java代码的接口,方法的实现由非java语言实现
07 本地方法栈
- java虚拟机栈用于管理java方法的调用,本地方法栈用于管理本地方法的调用
08 堆
概述
- 一个JVM实例只存在一个堆内存,只有一个运行时实例(Runtime是饿汉式单例模式),对也是JVM内存管理的核心区域
- java堆区在JVM启动的时候即被创建,其空间大小也就确定了,大小可以调节
- 所有的线程共享java堆,在这里还可以划分线程私有的缓冲区TLAB(Thread Local Allocation Buffer)
- 几乎所有的对象实例及数组都在堆分配,数组和对象可能永远不会分配在栈上,因为栈帧中保存引用,这个引用指向对象或数组在堆中的位置
- 方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除,(GC工作时会停止用户线程)
- 堆是GC(Garbage Collection)执行垃圾回收的重点区域,虚拟机栈不会GC的
内存细分:jdk8之后堆内存逻辑上分为三部分,新生区(又划分分伊甸园区Eden和2个Survivor区),老年区,元空间
设置堆内存大小与OOM
- -Xms设置堆区起始内存,等价于-XX:InitialHeapSize
- -Xmx设置堆区的最大内存,等价于-XX:MaxHeapSize,一般设置两个参数相等,减少内存抖动,避免垃圾回收机制清理完堆后不需要重新计算堆区的大小,从而提高性能
- 默认情况下,初始内存大小:物理内存/64,最大:物理内存/4
- 查看进程jps,查看内存jstate -gc 进程id;jdk自带的jvm可视化工具jvisualvm
- 代码查看显示的总的堆内存大小等于新生代(伊甸园区+1个幸存者区,总有1个survivor是空的)+老年代
新生代与老年代
- java堆中的对象可以被划分为两类,一类生命周期比较短,一类比较长
- java堆可以划分为新生代,老年代,新生代又划分为Eden和2个Survivor
- -XX:NewRatio=2,老年代与新生代的比例,默认值为2,
- -XX:-UseAdaptiveSizePolicy,关闭自适应的内存分配策略,没有用,实际伊甸园区与幸存者区是6:1:1,默认开启的,有可能S0和S1大小不一样
- -XX:SurvivorRatio=8,设置伊甸园区与幸存者区内存比例,显示指定 ,默认是8
- 几乎所有的对象都是在Eden被new出来的
- -Xmn设置新生代内存大小(一般不设置)
对象分配过程
- 先放Eden区,放不下触发YGC,同时也会对S0/S1进行回收,YGC之后还放不下就会放在养老区,如果养老区也放不下会触发FGC,FGC之后还放不下就OOM
- YGC时,S0/S1中达到阈值的对象会被放在养老区,默认阈值是15,从Eden移动到S0/S1为1,每移动一次age加1
常用调优工具
- jps,jstate -gc 进程id查看堆使用情况,-XX:+PrintGCDetails显示垃圾回收细节
- jvisualvm,jdk自带的内存可视化工具
- jprofiler,idea中使用需要添加插件,安装
Minor GC,Major GC,Full GC
- 新生代的垃圾回收:Minor GC / Young GC ,只是新生代的垃圾收集
- 老年代的垃圾回收:Major GC / Old GC,只是老年代的垃圾收集
- 正堆收集:Full GC,收集整个java堆和方法区的收集(jdk8叫元空间,之前叫永久代)
- 触发:
- 年轻代空间不足会触发Minor GC,这里的年轻代指Eden区,Survivor满不会触发GC,但每次Eden满会清理Survivor区
- 出现Major GC经常会伴随至少一次的Minor GC(并非绝对,在Parallel Scavenge里可以直接Major GC),也就是在老年代空间不足时,会先尝试触发Minor GC,如果之后还不足会触发Major GC,之后还不足会OOM
- Major GC速度一般会比Minor GC慢10倍以上,STW时间更长
- Full GC
- 调用System.gc,系统建议执行Full GC,但是不必然执行
- 老年代不足
- 方法区不足
- 通过Minor GC进入老年代的平均大小大于老年代的可用内存
- 由Eden区、S0向S1复制时,对象大于S1可用内存,则把该对象转存到老年代,而老年代内存又不足
- Full GC是开发和调优中要尽量避免的,这样暂停时间会短一些
堆空间分代思想
不同对象的生命周期不同,70%-99%的对象都是临时对象,分代是为了优化性能
内存分配策略
- 优先分配到Eden
- 大对象直接分配到老年代
- 长期存活的对象分配到老年代
- 动态对象年龄判断,如果Survivor区中相同年龄的所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到阈值年龄
- 空间分配担保
为对象分配内存:TLAB(Thread Local Allocation Buffer)
JVM为每个线程在Eden区分配了一个私有的缓存区域,默认占1%,可以修改,一旦分配失败,会尝试使用加锁机制确保数据操作的原子性,从而直接在Eden区分配内存
堆空间参数设置小结
空间分配担保参数在jdk7之后不在影响虚拟机的空间分配策略,也即是,在发送Minor GC之前,会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,此次MinorGC是安全的,如果小于,则Full GC
堆是分配对象的唯一选择吗
可能在栈帧中分配,判断是否发生逃逸分析,如果new的对象的实体有可能在方法外被调用
回归本篇的提问:堆是分配对象的唯一选择么?一开始我们是否定的态度,现在我们又去肯定这一观点。这岂不是自相矛盾。。正所谓否定之否定的观点,通过这篇文章,我们还是有进步的,至少分离对象或标量替换这一点还是存在的,优化了代码运行的效率,减少了堆空间的占用,体现了栈上分配的思想。
09 方法区
堆、栈、方法区的交互关系
方法区的理解
- 对于HotSpot而言,方法区还有一个别名叫Non-Heap,所以方法区看作是独立于java堆的内存空间
- 和java堆一样,方法区是线程共享的内存区域
- 方法区在jvm启动时创建,它的实际物理内存和java堆一样,都可以是不连续的
- 方法区的大小和堆一样,可以选择固定大小或者可扩展
- jdk7及以前,习惯上称方法区为永久代,jdk8之后,使用元空间取代了永久代,最大的区别是元空间不在虚拟机设置的内存中,而是使用本地内存
设置方法区大小与OOM
- jdk7及之前:-XX:PermSize设置初始分配空间,默认值是20.75,-XX:MaxPermSize设置永久代最大内存空间,32位默认64M,64位默认82M
- jdk8默认值依赖于平台:-XX:MetaspaceSize默认值21M,-XX:MaxMetaspaceSize为-1,表示没有限制,使用系统内存
- 元空间发生溢出会报OOM,如果-XX:MetaspaceSize比较小的话会触发Full GC,然后会调整这个水平线,要避免GC将此值设置为一个相对较高的值
- jps,jinfo -flag PermSize 进程id
- 如何解决OOM:
- 分析dump文件,如果是内存泄漏,查找泄漏的代码(内存泄漏:程序申请内存后无法释放,比如未关闭资源)
- 不存在内存泄漏,则调整-Xms -Xmx,从代码上判断是否存在对象周期过长,尝试减少程序运行期的内存消耗
方法区的内部结构
经典的
- 类型信息
- 域信息
- 方法信息
- non-final的类变量:加载时链接阶段的第二个准备阶段会赋一个默认值(验证,准备,解析),初始化阶段进行初始化,final变量在编译时就分配了
- 运行时常量池(常量池加载到内存中)(存放一些符号引用,字面量)
方法区的演进
- jdk6及之前:方法区的实现是永久代,静态变量存放在永久代,字符串常量存放在运行时常量池
- jdk7:方法区的实现是永久代,静态变量,字符串常量存放在堆,(放在堆方便进行垃圾回收,在永久代只有fullGC时才会回收,效率低)
- jdk8:方法区的实现是元空间,静态变量,字符串常量存放在堆,(只要是对象实例,都是在堆中分配的)
方法区的垃圾回收
- 回收效果不好,尤其是类的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的
- 方法区的垃圾回收主要回收两部分:常量池中废弃的常量和不在使用的类型
- 常量:字面量和引用,只要常量池中没有被任何地方引用,就可以被回收,与java堆中的对象很类似
10 对象实例化、内存布局与访问定位
对象实例化
- 对象创建方式:
- new
- 反射:Class.newInstance,Constructor的newInstance(Xxx)
- 使用clone:实现clonable接口
- 使用反序列化
- 第三方库
- 创建对象的步骤
- 判断对象的类是否加载、链接、初始化
- 分配内存:
- 处理并发问题:每个线程预先分配TLAB(伊甸园区1%?),采用CAS配上失败重试保证更新的原子性
- 所有属性的默认初始化
- 设置对象头
- 显式初始化:构造方法,代码块
对象的内存布局
- 对象头:
- 运行时元数据:哈希值,GC分代年龄,锁状态标志,线程持有的锁、偏向锁ID、偏向时间戳等,如果是数组还会记录数组的长度
- 类型指针:指向方法区的类的元信息,如果是句柄访问,类型指针是在句柄池中的,与对象指针一起
- 实例数据:父类的变量会出现在子类之前,字符串指向字符串常量池(jdk8中字符串常量池和静态变量在堆中)
- 对齐填充
对象访问定位
- 通过栈上reference访问
- 对象访问的两种方式:句柄访问,直接指针(HotSpot,有例外情况,如果使用了Shenandoah收集器的话也会有一次额外的转发
11 直接内存
- 不是JVM运行时数据区的一部分,也不是规范中定义的,是java堆外的、直接向系统申请的内存区间,来源NIO,通过堆中的DirectByteBuffer操作native内存,通常性能优于java堆
- 默认大小与堆最大值相同,也可以通过MaxDirectMemorySize设置,也会报OOM:Direct buffer memory
- 可以理解为java程序的内存=堆内存+直接内存(包含元空间占用的)
12 执行引擎
概述
- 执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令
Java代码编译和执行的过程
- 源代码到字节码的编译
- 字节码的解释执行(解释器,对字节码采用逐行解释的方式执行)
- 字节码的即时编译(JIT编译器,直接将源代码编译成机器语言,放在方法区)
- java是半编译半解释型语言
机器码、指令、汇编语言
- 机器码:01组成的计算机可以直接执行的命令
- 指令:将特定的01机器码简化成特定的指令,如MOV
- 指令集:不同架构有不同的指令集,x86,ARM
- 汇编语言:使用助记符代替指令的操作码,使用汇编语言编写的程序也必须编译成机器码才能执行
- 高级语言:先编译成汇编语言,才编译成机器码,才能执行
- 字节码:比机器码更抽象,主要为了实现跨平台性
解释器
- 为了满足java程序跨平台的特性,避免了采用动态编译的方式直接生成本地机器指令
- 是运行时的“翻译者”,将字节码文件中的内容翻译成对应平台的本地机器指令执行,当一条字节码指令被解释执行完成后,再根据程序计数器中记录的下一条字节码指令执行解释操作
- 分类:字节码解释器,模板解释器
- 解决解释器执行慢的问题,使用即时编译器的技术,避免函数被解释执行,将函数体编译成机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以大大提高执行效率
JIT编译器
- HotSpot采用解释器与即时编译器并存的架构,在jvm运行时,解释器和即时编译器相互协作,取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间
- 为什么还要解释器:
- 程序启动后,解释器可以马上发挥作用,省去编译时间
- 在编译器激进优化不成立的时候,作为编译器的“逃生门”
- 如何选择?热点代码及探测方式
- 方法调用计数器,热度衰减
- 回边计数器
- -Xint:完全解释器执行,-Xcomp:完全即时编译器执行,-Xmixed:混合模式执行
- JIT分类:-client:C1,简单可靠的优化,-server:C2,耗时长,激进优化,分层编译策略:jdk7之后C1和C2协作完成编译
- C1和C2的优化策略
- 总结:一般来讲,即时编译器出来的机器码性能比解释器高,C2编译器启动时间比C1长,系统稳定后执行以后,C2编译器执行速度远远快于C1编译器
- 其他:
- jdk10引入Graal编译器
- jdk9引入AOT编译器:静态提前编译,破坏了一次编译到处运行,降低Java链接过程的动态性
13 String Table
String基本特性
- 代表不可变的字符序列,String是final修饰的类,不可被继承,实现了Serializable,Comparable接口
- jdk8之前,内部定义了final char[],jdk9改为final byte[]
- 通过字面量的方式定义,此时的字符串声明在字符串常量池中(jdk7及之后字符串常量池和静态变量在堆中)
- StringTable的大小可以设置,jdk6默认1009,jdk7默认60013,jdk8及之后,设置的话最小值是1009
String内存分配
- 两种方法:字面量值,使用方法intern()
- jdk6及之前放在永久代,jdk7及之后放在了堆中(永久代默认内存小,垃圾回收频率低),jdk8把永久代改成了元空间,使用本地内存
String的基本操作
- java语言规范要求完全相同的字符串字面量,应该包含同样的unicode字符序列,并且必须指向同一个String类实例,也即是相同字符串字面量在字符常量池里只有一个
- 方法返回的字符串也会在字符串常量池中创建
字符串拼接操作
- 常量+常量的拼接结果在常量池(包括final修饰的String),原理是编译器优化,编译的时候值就确定了
- 常量池中不会有相同内容的常量
- 只要有一个变量,拼接结果就在堆中,原理是底层是new StringBuilder,然后toString
- 如果拼接结果调用intern(),方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
StringBuilder()拼接字符串的效率远高于使用+,改进:给StringBuilder传入容量参数。对于final修饰类,方法,基本数据类型,引用数据类型的量的结构时,能使用final的尽量使用final,编译期值就确定下来了
intern()方法
- native方法,jdk6中字符串常量池在永久代,调用s.intern()方法会尝试在常量池创建字符串s,拷贝一份s,jdk7/8字符串常量池在堆中,调用s.intern()方法会尝试在常量池中创建字符串s,为了节省内存,会放入指向s的指针,拷贝s的地址,4个字节
- 总结:jdk7/8之后,常量池的中的字符串不一定是实体,有可能是指向堆中实体的引用,这是因为在没有字符串实体是调用intern()方法造成的,此时他们的地址是相同的,都是指向堆中的字符串
- 面试题:
- new String("ab")会创建几个对象?字符串常量池中有"ab"吗?2个,有,y,堆中一个(new关键字),字符串常量池中一个(ldc),字节码中new,ldc;,jdk6时,字符串常量池在永久代,此时会在永久代new String("ab"),也就是字符串常量池有一份,堆中有一份;jkd7/8中,字符串常量池在对空间,为了节省内存,字符串常量池的字符串"ab"实际是一个指向堆空间"ab"的4字节引用
- new String("a") + new String("b")会创建几个对象?常量池中有"ab"吗?6个,没有,每个new String("x")会创建两个,两个字符串变量相加会使用new StringBuilder(),然后调用toString()方法,而StringBuilder的toString()方法是new String(),但是没有ldc,所以没有在字符串常量池中
对于程序中大量存在的字符串,尤其其中存在很多重复的字符串时,使用intern(),可以节省很多内存空间,使用字符串常量池中的字符串,堆中的就可以垃圾回收了
StringTable的垃圾回收,G1中的String去重
- -Xms15m,-Xmx15m,-XX:+PrintStringTableStatistics -XX:PrintGCDetails
- G1中的String去重操作,默认关闭
14 垃圾回收概述
什么是垃圾,为什么需要GC
- 垃圾是指程序中没有任何指针指向的对象,不GC内存迟早会用完,随着程序业务越来越复杂,没有GC不能保证程序正常运行,而经常造成STW的GC又跟不上实际的需求,所以才会不断的对GC进行优化
Java垃圾回收机制
- GC的作用区域:方法区,堆,(程序计数器没有OOM,StackOverFlow和GC,虚拟机栈和本地方法栈有可能StackOverFlow,没有GC,方法区和堆会OOM,有GC)
- java堆是垃圾回收的重点,频繁收集年轻代,较少收集老年代,基本不动永久代(方法区,jdk8元空间)
15 垃圾回收相关算法
垃圾标记阶段:引用计数算法
- 对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。只要任何对象引用了A,A的引用计数器就加1,引用失效时就减1,只要引用计数器的值为0,表示A就可以被回收
- 优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性(不用STW,随时都可以)
- 缺点:增加存储开销;每次都要更新计数器,增加时间开销;无法处理循环引用的问题(导致java没有采用这类算法)
- python支持引用计数和垃圾回收机制:如何解决循环引用?①手动解除 ②使用弱引用
垃圾标记阶段:可达性分析算法
- 可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达,使用可达性分析算法后,内存中的存活对象都会被根对象直接或间接的连接着,搜索走过的路径称为引用连。如果对象没有任何引用连相连,则是不可达,可以标记为垃圾对象
- GC Roots包含以下元素
- 虚拟机栈中的引用对象,本地方法栈中的引用对象
- 方法区中静态属性引用的对象(类变量),方法区中常量引用的对象(字符串常量池)
- 所有被同步锁synchronized持有的对象
- java虚拟机内部的引用(基本类型的Class对象,一些常驻的异常对象,系统类加载器)
- 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
- 根据当前回收的内存区域不同,还有一些临时的对象,比如回收年轻代的时候,老年代对年轻代区域引用的对象
- 技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不在堆里,那么它就是一个Root
- 注意:分析工作必须在一个能保障一致性的快照中进行,这点不满足的话无法保证分析结果的正确性,这也是导致GC必须Stop The World的一个重要原因,
finalization机制
- java语言提供了对象终止机制来允许开发人员提供对象被销毁之前的自定义处理逻辑,重写finalize()方法,用于对象在被回收之前进行资源释放,如关闭文件,套接字和数据库连接等
- 永远不要主动调用finalize()方法,由GC自动调用,理由:
- finalize()可能会导致对象复活
- finalize()执行时间没有保证,它完全由GC线程finalizer决定,极端情况下,若不发生GC,则finalize()不会执行
- 一个糟糕的finalize()会严重影响性能,GC调用时会Stop The World
- 由于finalize()方法的存在,虚拟机中的对象存在三种状态:
- 可触及的:从根节点可以达到这个对象,正常情况下的对象
- 可复活的:对象的所有引用都被释放,但有可能在fianlize()方法中复活
- 不可触及的:对象的finalize()方法被调用,并且没有复活,那么就会进入不可触及状态,因为finalize()只能调用1次
- 死亡过程:
- 第一次标记不可达的对象,
- 第二次进行筛选,如果没有重写finalize()方法或者已经调用过,则标记为可回收(死亡,没有必要执行);如果重写了finalize()方法,没有调用过,会被插入F-Queue队列中,由虚拟机自动创建的低优先级的Finalizer触发finalize()方法,之后进行二次标记;如果执行finalize()时复活了,之后再次没有引用存才的情况下,finalize()不会被再次调用,直接变成不可触及的状态
MAT与JProfiler的GC Roots溯源
- jvisualvm导出dump文件,导入MAT分析
- JProfiler进行GC Roots
- JProfiler分析OOM,-XX:+HeapDumpOnOutOfMemoryError生成dump文件
垃圾清除阶段:标记-清除算法
- 当区分出内存中存活对象和垃圾对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象占用的内存空间
- 执行过程:停止整个程序,然后进行两项工作,标记,清除
- 标记:Collector从引用根节点开始遍历,标记所有被引用的对象,一般是在对象的header中记录为可达对象
- 清除:Collector对堆内存从头到尾进行线性遍历,如果发现某个对象在其header中没有标记为可达对象,则将其回收
- 缺点:
- 效率不算高
- GC时,需要停止整个程序,用户体验差
- 这种方式清理出来的空闲空间不连续,产生内存碎片,需要维护一个空闲列表
垃圾清除阶段:复制算法
- 将内存空间分为两块,每次只是用其中的一块,在垃圾回收时,将正在使用的内存中的存活对象复制到为被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收
- 优点:
- 没有标记和清除,实现简单,运行高效
- 复制过去以后保证空间的连续性,不会出现碎片问题
- 缺点:
- 需要两倍内存空间
- 对于G1这种拆分为大量的region的GC,需要维护region之间对象的引用关系(栈中对于堆的引用地址需要改变),不管是内存还是时间开销也不小
- 特别的:特别适合系统中垃圾对象很多,存活的对象很少,如年轻代的S0和S1区
垃圾清除阶段:标记-压缩算法
- 在标记-清除算法上的改进,最终效果等同于标记清除算法执行完成后再进行一次内存碎片整理,二者本质上的区别是前者是非移动式的回收算法,后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策(不移动会产生碎片,移动的话需要修改引用,复制,有时间开销)
- 优点:
- 消除了标记清除算法的碎片化
- 消除了复制算法的内存减半的高额代价
- 缺点:
- 从效率上来说,低于复制算法
- 需要调整对象的引用地址
- 需要停止用户程序,即STW,因为要复制,时间会比较长
小结
- 效率上来说,复制算法效率最高,但是却浪费了太多内存
- 为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上却不尽人意,比标记-清除算法多了一个整理的阶段,比复制算法多了一个标记的阶段
分代收集算法
- 年轻代,生命周期短,存活率低,回收频繁,适合复制算法,速度快
- 老年代,一般是由标记-清除和标记-整理的混合实现
- Mark阶段与存活对象的数量成正比
- Sweep阶段的开销与所管理区域的大小成正比
- Compact的开销与存活对象的数据成正比
增量收集算法,分区算法
- 增量收集算法:如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用线程交替执行,每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用线程,依次反复,直到垃圾收集完成。增量收集算法通过对线程间冲突的妥善处理,允许垃圾回收机制以分阶段的方式完成标记、清理或复制工作
- 缺点:减少系统的停顿时间,但是因为线程切换和上下文转换的消耗,使得垃圾回收的总成本上升,系统吞吐量下降
- 分区算法:将一块内存区域分割成多个小块,根据目标的停顿时间,每次合理的回收若干个小区间region,而不是整个堆空间,从而减少一次GC所产生的停顿,可以控制一次回收多少个小区间
16 垃圾回收的相关概念
System.gc()的理解
- System.gc()(底层调用Runtime.getRuntime().gc(),本地方法),会显式的触发Full GC,对新生代,老年代进行垃圾回收。但是无法保证一定会被调用,System.runFinalization()强制调用失去引用对象的finalize()方法
- 代码块中的对象:在局部变量表中依然占据一个slot,如果之后这个slot被占用,那么就可以被垃圾回收了,否则不会
内存溢出与内存泄漏
- OOM:没有空闲内存,并且垃圾收集器也无法提供更多内存
- 原因:①java堆内存设置不够②代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)
- 内存泄漏:严格来说,只有对象不会再被程序使用了,但是GC又不能回收他们的情况,叫内存泄漏。但实际情况很多时候,一些不太好的实践或疏忽导致对象的生命周期很长甚至导致OOM,也可以叫做宽泛意义上的内存泄漏
- 举例:①单例模式,单例的生命周期和应用程序一样长,所以单例程序中,如果持有外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏②一些提供close的资源未关闭导致内存泄漏,数据库连接,网络连接,io必须手动close,否则不能被回收的
Stop The World
- 简称STW,指GC事件发生时,会产生应用程序的卡顿,可达性分析算法中枚举根节点会导致所有的java线程停顿(保证一致性)
- 所有的GC都会发生,是JVM自动发起和自动完成的,System.gc()会导致STW
垃圾回收的并发与并行
- 并发:在一个时间段上有几个程序都处于已启动到运行完毕之间,且这几个程序都是运行在同一个cpu上,通过时间片轮转进行运行程序,程序之间抢占资源
- 并行:多个cpu执行多个程序,程序之间互不抢占资源,同时进行,在多个cpu或多个核的情况下才会发生并行
- 垃圾回收的并发:指用户线程与垃圾回收线程同时进行,垃圾回收线程在执行时不会停顿用户程序的运行
- 垃圾回收的并行:指多条垃圾回收线程并行工作,但此时用户线程仍处于等待状态
- 串行:单线程执行
安全点与安全区域
- 安全点:只有在特定的位置才能停下来进行GC,称为安全点,太少可能导致GC等待时间太长,太多可能会导致运行时的性能问题,通常会根据是否具有让程序长时间执行的特征,如方法调用,循环跳转
- 如何在GC发生时,检查所有的线程都跑到最近的安全点停顿下来?抢占式中断(目前没有虚拟机采用了,首先中断所有线程,如果有线程不在安全点,就恢复线程,让线程跑到安全点),主动式中断(设置中断标志,当各个线程运行到安全点时主动轮训这个标志,如果标志为真,则将自己中断挂起)
- 安全区域:安全点机制保证了程序执行时在不太长时间内就会遇到进入GC的安全点,但是程序不执行时呢?比如sleep,blocked,这时候需要安全区域。指在一段代码片段中,对象的引用关系不会变化,在这个区域的任何位置GC都是安全的
- 实际执行时:
再谈引用 - 强引用,软引用,弱引用,虚引用,终结器引用
- 强引用,软引用,弱引用,虚引用有什么区别?具体使用场景是什么?
- 强引用:最传统的引用,只要引用关系存在,垃圾收集器就不会回收,平常使用的默认情况,OOM也不会收
- 软引用:在系统OOM之前,会对这些软引用进行回收,如果回收之后还没有足够的内存,才会报OOM,内存不足即回收
- 弱引用:被弱引用关联的对象只能生存到下一次垃圾回收之前,当垃圾收集器工作时,都会回收掉,发现即回收
- 虚引用:一个对象是否有虚引用的存在,完成不会影响其生存时间,也无法通过一个弱引用获得一个实例,它的唯一目的就是在对象被收集器回收时收到一个系统通知,用来跟踪垃圾回收过程
- 终结器引用:用来实现对象的finalize()方法,内部配合引用队列使用,在GC时,终结器引用入队,由Finalizer线程通过终结器引用找到被引用的对象并调用它的finalize()方法,在第二次GC时才能回收被引用的对象
17 垃圾回收器
GC分类与性能指标
- GC分类:
- 按线程:串行垃圾回收器,并行垃圾回收器
- 按工作模式:并发式,独占式
- 按碎片处理方式:压缩式,非压缩式
- 按工作区间:年轻代,老年代
- 评价GC的性能指标:
- 吞吐量:运行用户代码的时间占总运行时间的比例
- 暂停时间:执行垃圾回收时,程序的工作线程被暂停的时间
- 收集频率
- 现在的标准:在最大吞吐量优先的情况下,降低停顿时间
不同的垃圾回收器概述
7个经典的垃圾回收器
7个经典的垃圾回收器与分代的关系
组合关系
查看默认的垃圾收集器:-XX:+PrintCommandLineFlags,或者命令行指令:jinfo -flag 相关垃圾收集器参数 进程id
总结:
- jkd8之前,不包括jdk8,可以把虚线看出实线
- 新生代使用Serial GC,老年代可以使用Serial Old GC,或者CMS GC
- 新生代使用ParNew GC,老年代可以使用Serial Old GC,或者CMS GC
- 新生代使用Parallel GC,老年代可以使用Serial Old GC,或者Parallel Old GC
- Serial Old GC是CMS的一个备选方案,因为CMS是并发执行的,需要提前执行,有可能执行失败,那么就使用Serial Old GC
- jdk8中,弃用了红色虚线的组合,deprecated,jdk9中彻底去掉了,remove
- 新生代使用Serial GC,老年代可以使用Serial Old GC
- 新生代使用ParNew GC,老年代可以CMS GC
- jdk9中,ParNew GC也被弃用了,CMS也被弃用了
- jdk9中,G1 GC为默认的垃圾回收器,新生代老年代都可以
- jdk14中,①弃用了绿色虚线的组合,②去掉了青色标出的CMS
所以:当前jdk8中,只看其中的黑线,有三种组合,默认的是Parallel GC和Parallel Old GC;如果用ParNew GC,则可以用CMS GC回收老年代;如果是jdk的客户端,则可以使用Serial GC和Serial Old GC;在jdk14中,CMS又被remove了,那么就只剩下Serial GC和Serial Old GC,Parallel GC和Parallel Old GC,G1三种
在最新的jdk中,只有三种组合,默认G1 GC,还有Serial GC和Serial Old GC,Parallel GC和Parallel Old GC
Serial回收器
- 最基本,历史最悠久的回收器,client模式下默认的,年轻代采用复制算法,老年代采用标记压缩算法,都会stop the world
- 单线程下简单高效,现在都不用了,cpu都不是单核的了
ParNew回收器
- Serial的多线程版本,多线程下stop the world时间短一点,老年代还是使用Serial Old GC或CMS,jdk9已经弃用了
Parallel回收器:吞吐量优先
- 与ParNew不同,Parallel的目标是达到一个可控的吞吐量,及自适应策略,采用复制算法,并行回收和stop the world,Parallel Old GC采用标记压缩,并行回收和stop the world
- jdk8默认,互相激活
- 参数:配置使用此回收器2个,线程数,垃圾回收最大停顿时间,吞吐量,自适应策略(使得伊甸园区、幸存者区比例611,自动调整的)
CMS回收器:低延迟
- 用户线程与垃圾回收线程同时工作,尽可能缩短垃圾收集时用户线程的停顿时间,采用标记清除算法,也会stop the world
- 工作原理:
- 初始标记:仅仅标记GC Roots直接关联到的对象,stop the world,速度很快
- 并发标记:标记整个对象图,并发,耗时长但是不需要停止用户线程
- 重新标记:修正并发标记期间,因用户线程继续运作导致标记对象产生变动的那一部分对象的标记记录
- 并发清理:清除掉标记阶段已经死亡的对象,释放内存空间
- 因为回收过程中用户线程还在运行,所以当堆内存使用到达一定阈值时,便开始进行回收
- 优点:并发收集,低延迟
- 缺点:
- 会产生内存碎片
- 对cpu资源敏感,在并发阶段,虽然不会导致用户停顿,但是占用了一部分线程导致应用程序变慢,总吞吐量降低
- 无法回收浮动垃圾,必须等到下一次垃圾回收时才能回收
- 参数设置:使用CMS回收器,设置内存阈值,指定执行完进行压缩,指定Full GC次数后压缩,设置线程数量
G1回收器:区域化分代式
- 目标:为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间,同时兼顾良好的吞吐量。G1的目标是在延迟可控的情况下获得尽可能高的吞吐量
- 名字:把堆内存分割成很多不相关的区域,跟踪每个区域里面的垃圾堆积的价值大小(回收的空间大小和回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的区域,侧重点在于回收垃圾最大量的区间
- 特点:
- 并行与并发
- 分代收集,将堆分成若干个区域
- 空间整合,区域之间是复制算法,整体上实际是标记压缩算法,都可以避免内存碎片
- 可预测的停顿时间模型
- 参数设置:使用G1,设置区域大小,设置最大GC停顿时间等,YGC,Mixed GC,Full GC
- G1应用场景:大内存、多处理器的机器
- 分区Region:化整为零,Edon,Survivor,Old,Humongous,指针碰撞,TLAB
- G1垃圾回收过程:年轻代GC,年轻代GC+老年代并发标记,混合回收,Full GC
- RSet,对每个区域,记录区域内对象引用外部区域的引用,reference类型数据写数据是都会产生写屏障(判断是不是引用外部的区域的对象)
垃圾回收器总结
GC日志分析
垃圾回收器的新发展
- ZGC
本文来自博客园,作者:Bingmous,转载请注明原文链接:https://www.cnblogs.com/bingmous/p/15643697.html