文章目录
力推的JVM课程
标题 | 链接 |
---|---|
《宋红康JVM全套教程》课程 | 视频链接 |
JVM内存结构
内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。Java 虚拟机的内存空间可以分为下面 5 个部分:
- 程序计数器
- Java 虚拟机栈
- 本地方法栈
- 堆
- 方法区(JDK 1.8元数据区取代永久代,两者都是对 JVM 规范中
方法区
的实现。)
再补充一张阿里的JVM图解加强概念,这张图每个内存区域的细节多了一点,程序计数器、Java 虚拟机栈、本地方法栈线程私有,支持线程的方法执行;堆、方法区、堆外内存(元空间、代码缓存-jit编译器缓存的)线程共享,里面保存了类信息和大量的对象实例。
刚开始对JVM内存结构的理解先留个整体轮廓,慢慢补全细节。
再补充一张包含运行时数据区的上下文的图,类对象需要类加载器载入内存,需要执行引擎进行进一步编译。下面对各个内存区域详细说明。
- 虚拟机栈(线程私有)
虚拟机栈是用来描述 Java 方法的内存模型。每当有新线程创建时就会分配一个栈空间,线程结束后栈空间被回收,栈与线程拥有相同的生命周期。栈中元素用于支持虚拟机进行方法调用,可以通过参数-Xss来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
每个方法在执行时都会创建一个栈帧
,用来存储方法的局部变量表
、操作数栈
、动态链接
(指向运行时常量池的方法引用)和方法返回地址
等信息。方法从调用到执行完成,就是栈帧从入栈到出栈的过程。如果A方法调用了B方法,B方法返回之际,当前栈帧会传回B方法的执行结果给前一个(A方法的)栈帧,接着,虚拟机会丢弃(B方法的)栈帧,使得前一个栈帧重新成为当前栈帧。
虚拟机栈存在两类异常:
-
线程请求的栈深度大于虚拟机允许的深度抛出
StackOverflowError
。 -
如果 JVM 栈容量可以动态扩展,栈扩展无法申请足够内存抛出
OutOfMemoryError
(HotSpot 不可动态扩展,因此不存在OOM问题)。
(1)局部变量表
局部变量表也被称为局部变量数组或者本地变量表,主要用于存储方法参数和定义在方法体内的局部变量,包括编译器可知的基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄)和 returnAddress 类型(指向了一条字节码指令的地址,已被异常表取代)。
-
局部变量表所需要的容量大小是编译期确定下来的,并保存在方法的 Code 属性的 maximum local variables 数据项中,在方法运行期间是不会改变局部变量表的大小的。
-
方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。
-
在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。
-
当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
-
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
(2)操作数栈
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。在方法执行过程中,根据字节码指令,往操作数栈中写入数据或提取数据,即入栈(push)、出栈(pop)
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令
基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。由于操作数是存储在内存中的,因此频繁的执行内存读/写操作(相比于寄存器)必然会影响执行速度。为了解决这个问题,HotSpot JVM 设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
(3)动态链接
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法(方法区内)的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 Class 文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用,即 方法在实际运行时内存布局中的入口地址。
运行时常量池中的符号引用:
(4)方法返回地址
方法返回地址用来存放调用该方法的 PC 寄存器的值,
一个方法的结束,有两种方式:
- 正常执行完成
- 出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的 PC 计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
- 程序计数寄存器(线程私有)
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。如果线程正在执行 Java 方法,计数器记录正在执行的虚拟机字节码指令地址。如果是本地方法,计数器值为 Undefined。程序计数寄存器是唯一一个没有OOM的内存区域。
- 本地方法栈
处理本地(native)方法,或者跨语言交互,或者与操作系统交互。和虚拟机栈区别不大,只不过实现主体非java语言。比如,类 java.lang.Thread 的 setPriority() 的方法是用Java 实现的,但它实现调用的是该类的本地方法 setPrioruty(),该方法是C实现的,并被植入 JVM 内部。
- 堆(线程共享)
对象实例所在。垃圾收集器采用分代收集算法,堆空间由此分为新生代和老年代。
HotSpot 把新生代划分为一块较大的 Eden
和两块较小的 Survivor
,每次分配内存只使用 Eden 和其中一块 Survivor。垃圾收集时将 Eden 和 Survivor 中仍然存活的对象一次性复制到另一块 Survivor 上,然后直接清理掉 Eden 和已用过的那块 Survivor。HotSpot 默认Eden 和 Survivor 的大小比例是 8:1:1,即每次新生代中可用空间为整个新生代的 90%。
- 方法区
Non-Heap(非堆),这样命名应该是想与 Java 堆区分开。用于存储被虚拟机加载的类型信息
、常量
、静态变量
、即时编译器编译后的代码缓存
等元信息数据,很少被垃圾回收。
JDK8 之前使用永久代实现方法区,容易内存溢出,因为永久代有 -XX:MaxPermSize
上限,即使不设置也有默认大小。JDK7 把放在永久代的字符串常量池、静态变量等移出,JDK8 中永久代完全废弃,改用在本地内存
中实现的元空间
代替(元空间并不在虚拟机中,而是使用本地内存),把 JDK 7 中永久代剩余内容(主要是类型信息)全部移到元空间。
- 运行时常量池
方法区一部分,字面值、符号引用、常量。
- 直接内存
Direct memory
,堆外内存,不由jvm管理,与Java NIO
相关,jvm通过在堆上的DirectByteBuffer
来操作直接内存。
年轻代、老年代、方法区、本地方法栈OOM怎么处理?
- 堆溢出
(1)产生
堆用于存储对象实例,只要不断创建对象并保证 GC Roots
到对象有可达路径避免垃圾回收,随着对象数量的增加,总容量触及最大堆容量后就会 OOM,例如在 while 死循环中一直 new 创建实例。
(2)解决
堆 OOM 是实际应用中最常见的 OOM,处理方法是通过内存映像分析工具对 Dump 出的堆dump快照分析,确认内存中导致 OOM 的对象是否必要,分清到底是内存泄漏
还是内存溢出
。
如果是内存泄漏,通过工具查看泄漏对象到 GC Roots
的引用链,找到泄露对象是通过怎样的引用路径、与哪些 GC Roots
关联才导致无法回收,一般可以准确定位到产生内存泄漏代码的具体位置。
如果不是内存泄漏,即内存中对象都必须存活,应当检查 JVM 堆参数,与机器内存相比是否还有扩大的空间。再从代码检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。
- 栈溢出
由于 HotSpot 不区分虚拟机栈和本地方法栈,设置本地方法栈大小的参数没有意义,栈容量只能由 -Xss
参数来设定,存在两种异常:
-
StackOverflowError
: 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError
,例如一个递归方法不断调用自己。该异常有明确错误堆栈可供分析,容易定位到问题所在。 -
OutOfMemoryError
: 如果 JVM 栈可以动态扩展
,当扩展无法申请到足够内存时会抛出OutOfMemoryError
。HotSpot 不支持虚拟机栈扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现 OOM,否则在线程运行时是不会因为扩展而导致溢出的
- 运行时常量池(方法区一部分)溢出
String 的 intern
方法是一个本地方法,作用是如果字符串常量池中已包含一个等于此 String 对象的字符串,则返回池中这个字符串的 String 对象的引用,否则将此 String 对象包含的字符串添加到常量池并返回此 String 对象的引用。
在 JDK6 及之前常量池分配在永久代,因此可以通过 -XX:PermSize
和 -XX:MaxPermSize
限制永久代大小,间接限制常量池。在 while 死循环中调用 intern
方法导致运行时常量池溢出。在 JDK7 后不会出现该问题,因为存放在永久代的字符串常量池已经被移至堆中。
- 方法区溢出
(1)产生
方法区主要存放类型信息,如类名、访问修饰符、常量池、字段描述、方法描述等。只要不断在运行时产生大量类,方法区就会溢出。例如使用 JDK 反射
或 CGLib
直接操作字节码在运行时生成大量的类。很多框架如 Spring
、Hibernate
等对类增强时都会使用 CGLib
这类字节码技术,增强的类越多就需要越大的方法区保证动态生成的新类型可以载入内存,也就更容易导致方法区溢出。
JDK8 使用元空间取代永久代,HotSpot 提供了一些参数作为元空间防御措施,例如 -XX:MetaspaceSize
指定元空间初始大小,达到该值会触发 GC 进行类型卸载,同时收集器会对该值进行调整,如果释放大量空间就适当降低该值,如果释放很少空间就适当提高。
利用jvisualvm
排查oom:
可以利用jvisualvm
或者利用jvm参数命令:-XX:+HeapDumpOnOutOfMemoryError
自动dump
一个关于堆信息的hprof
快照文件,之后用jvisualvm
本身的系统去分析。(哪里溢出了,排查强弱引用,查看引用链等)
链接:
https://juejin.cn/post/6844903953004494856
https://tech.meituan.com/2020/11/12/java-9-cms-gc.html(强推)
说一下类加载机制
程序员编写的.java
文件经过javac
编译后生成对应的.class
文件。.class
文件中描述的各类信息都需要加载到虚拟机后才能使用。JVM 把描述类的数据从.class
文件加载到内存,并对数据进行验证、解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程称为虚拟机的类加载机制
。
与编译时需要连接的语言不同,Java 中类型的加载、连接和初始化都是在运行期间
完成的,这增加了性能开销,但却提供了极高的扩展性,Java 动态扩展的语言特性就是依赖运行期动态加载和连接实现的。
一个类型从被加载到虚拟机内存开始,到卸载出内存为止,整个生命周期经历加载、验证、准备、解析、初始化、使用和卸载
七个阶段,其中验证、解析和初始化三个部分称为连接。
- 具体过程
(1)加载
该阶段虚拟机需要完成三件事:
- 通过一个类的全限定类名获取定义类的二进制字节流。
- 将字节流所代表的静态存储结构转化为方法区的运行时数据区。
- 在内存中生成对应该类的
Class 实例
,作为方法区这个类的数据访问入口。
(2)连接
- 验证
确保 Class 文件的字节流符合约束。如果虚拟机不检查输入的字节流,可能因为载入有错误或恶意企图的字节流而导致系统受攻击。验证主要包含四个阶段:文件格式验证、元数据验证、字节码验证、符号引用验证。
验证重要但非必需,因为只有通过与否的区别,通过后对程序运行期没有任何影响。如果代码已被反复使用和验证过,在生产环境就可以考虑关闭大部分验证缩短类加载时间。
- 准备
为类静态变量分配内存并设置零值,该阶段进行的内存分配仅包括类变量,不包括实例变量。如果变量被 final
修饰,编译时 Javac
会为变量生成 ConstantValue
属性,准备阶段虚拟机会将变量值设为代码值。
- 解析
将常量池内的符号引用替换为直接引用。
符号引用
以一组符号描述引用目标,可以是任何形式的字面量,只要使用时能无歧义地定位目标即可。与虚拟机内存布局无关,引用目标不一定已经加载到虚拟机内存。
直接引用
是可以直接指向目标的指针、相对偏移量或能间接定位到目标的句柄。和虚拟机的内存布局相关,引用目标必须已在虚拟机的内存中存在。
(3)初始化
直到该阶段 JVM 才开始执行类中编写的代码。准备阶段时变量赋过零值,初始化阶段会根据程序员的编码去初始化类变量和其他资源。初始化阶段就是执行类构造方法中的 <clinit>()
方法,该方法是 Javac 自动生成的,在初始化阶段用来组织类变量和静态代码块中的赋值动作。
只有类被主动使用时才会初始化,以下是主动使用的七种情况。
类加载器有哪些
思考两个问题:
-
如何保证某个类被指定的类加载器加载?
-
相同的类被不同的类加载器加载了,这两个类还相同吗?
- Java虚拟机自带的加载器
- 启动类加载器(Bootstrap)
该加载器没有父加载器,由C/C++编写,它负责加载虚拟机中的核心类库。根类加载器从系统属性sun.boot.class.path
所指定的目录中加载类库。类加载器的实现依赖于底层操作系统,属于虚拟机的实现的一部分,它并没有集成java.lang.ClassLoader
类。
- 扩展类加载器(Extension)
它的父加载器为根类加载器。它从java.ext.dirs
系统属性所指定的目录中加载类库,或者从JDK的安装目录的jre\lib\ext
子目录(扩展目录)下加载类库,如果把用户创建的jar文件放在这个目录下,也会自动由扩展类加载器加载,扩展类加载器是纯java类,是java.lang.ClassLoader
的子类。
- 系统应用类加载器(AppClassLoader/System)
也称为应用类加载器,它的父加载器为扩展类加载器,它从环境变量classpath
或者系统属性java.class.path
所指定的目录中加载类,他是用户自定义的类加载器的默认父加载器。系统类加载器是纯java类,是java.lang.ClassLoader
的子类。
- 用户自定义的类加载器
java.lang.ClassLoader
的子类- 用户可以定制类的加载方式
继承关系:根类加载器–>扩展类加载器–>系统应用类加载器–>自定义类加载器
- 类的可见性
类加载器并不需要等到某个类被“首次主动使用”时再加载它。
- 每个类加载器都有自己的
命名空间
,命名空间由该加载器及所有父加载器所加载的类构成; - 在同一个命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类;
- 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类;
- 同一命名空间内的类是互相可见的,非同一命名空间内的类是不可见的;
- 子加载器可以见到父加载器加载的类,父加载器不能见到子加载器加载的类。
- 任意一个类都必须由类加载器和这个类本身共同确立其在虚拟机中的唯一性。
- 两个类只有由同一类加载器加载才有比较意义,否则即使两个类来源于同一个 Class 文件,被同一个 JVM 加载,只要类加载器不同,这两个类就必定不相等。
双亲委托机制是什么
- 概念
双亲委托机制是指当一个类加载器收到某个类加载请求时,该类加载器首先会把请求委派给父类加载器,当父类加载器在自己的搜索范围内找不到对应的类时,该类加载器才会尝试自己去加载。
2. 源码
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先,检查这类是否已经被加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
//如果存在父类加载器,则取找该类的父类加载器
c = parent.loadClass(name, false);
} else {
//返回由引导类加载器加载的类;如果未找到,则返回 null。
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException异常
// 则说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载时
// 再调用本身的findClass方法来进行加载
long t1 = System.nanoTime();
c = findClass(name);
// 这是定义类加载器;记录统计数据
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
链接:https://cloud.tencent.com/developer/article/2009648
- 优点
(1)确保Java核心库的安全。所有的Java应用都会引用java.lang中的类(Obejct、String),也就是说在运行期java.lang
中的类会被加载到虚拟机中,如果这个加载过程如果是由自己的类加载器所加载,那么很可能就会在JVM中存在多个版本的java.lang中的类,互相不兼容,而且这些类是相互不可见的(命名空间的作用)。借助于双亲委托机制,Java核心类库中的类的加载工作都是由启动根加载器去加载,从而确保了Java应用所使用的的都是同一个版本的Java核心类库,他们之间是相互兼容的;
(2)确保Java核心类库中的类不会被自定义的类所替代,或篡改。
(3)不同的类加载器可以为相同名称的类(binary name)创建额外的命名空间。相同名称的类可以并存在Java虚拟机中,只需要用不同的类加载器去加载即可。不同加载器(同一类型不同对象都可)加载的类不兼容,相当于在Java虚拟机内部建立了一个又一个相互隔离的Java类空间(不同的命名空间相互不可见)。
(4)能够提高软件系统的安全性。因此在此机制下,用户自定义的类加载器不可能加载应该由父类加载器加载的可靠类,从而防止不可靠甚至恶意的代码代替由父类加载器加载的可靠代码。例如,java.lang.Object
类是由跟类加载器加载,其他任何用哪个户自定义的类加载器都不可能加载含有恶意代码的java.lang.Object
类。
- 补充
类加载器本身也是类,类加载器又是谁加载的呢??(先有鸡还是现有蛋)
类加载器是由启动类加载器去加载的,启动类加载器是C++写的,内嵌在JVM中。内嵌于JVM中的启动类加载器会加载java.lang.ClassLoader以及其他的Java平台类。当JVM启动时,一块特殊的机器码会运行,它会加载扩展类加载器以及系统类加载器,这块特殊的机器码叫做启动类加载器。
启动类加载器是特定于平台的机器指令,它负责开启整个加载过程,它还会加载提供JRE正常运行所需要的基本组件,包括java.util、java.lang包中的类。
java如何打破双亲委派
双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。这个委派和加载顺序完全是可以被破坏的。
-
如果想自定义类加载器,就需要继承ClassLoader,并重写findClass,
-
如果想不遵循双亲委派的类加载顺序,还需要重写loadClass。
public class TestClassLoader extends ClassLoader {
public TestClassLoader(ClassLoader parent) {
super(parent);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 1、获取class文件二进制字节数组
byte[] data = null;
try {
System.out.println(name);
String namePath = name.replaceAll("\\.", "\\\\");
String classFile = "C:\\study\\myStudy\\ZooKeeperLearning\\zkops\\target\\classes\\" + namePath + ".class";
ByteArrayOutputStream baos = new ByteArrayOutputStream();
FileInputStream fis = new FileInputStream(new File(classFile));
byte[] bytes = new byte[1024];
int len = 0;
while ((len = fis.read(bytes)) != -1) {
baos.write(bytes, 0, len);
}
data = baos.toByteArray();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
// 2、字节码加载到 JVM 的方法区,
// 并在 JVM 的堆区建立一个java.lang.Class对象的实例
// 用来封装 Java 类相关的数据和方法
return this.defineClass(name, data, 0, data.length);
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException{
Class<?> clazz = null;
// 直接自己加载
clazz = this.findClass(name);
if (clazz != null) {
return clazz;
}
// 自己加载不了,再调用父类loadClass,保持双亲委托模式
return super.loadClass(name);
}
}
应用:
-
Java中所有涉及
SPI
的加载动作基本都打破了双亲委派机制。例如JNDI,JDBC等。JNDI服务使用线程上下文加载器
(Thread Context ClassLoader)去加载由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI, Service Provider Interface
)的代码。 -
为了实现热插拔,热部署,模块化,这样添加一个功能或减去一个功能不用重启,只需要把这模块连同类加载器一起换掉就实现了代码的热替换。
-
Tomcat中不同的应用程序依赖同一个第三方类库的不同版本,加载时需要相互隔离等。
链接:
https://blog.csdn.net/weixin_36586120/article/details/117457014
https://www.jianshu.com/p/abf6fd4531e7
new创建对象
- 加载类
当 JVM 接收到 new
指令时,首先在 metaspace
内检查需要创建的类元信息是否存在。 若不存在,那么在双亲委派模式下,使用当前类加载器以全限定类名 ClassLoader + 包名+类名
为 Key 进行查找对应的 .class 字节码文件
。 如果没有找到文件,则抛出 ClassNotFoundException
异常 ,如果找到,则进行类加载(加载 - 验证 - 准备 - 解析 - 初始化),并生成对应的 Class 类对象
。
- 分配对象内存
首先计算对象占用空间大小,如果实例成员变量是引用变量,仅分配引用变量空间即可,即 4 个字节大小,接着在堆中划分—块内存给新对象。 在分配内存空间时,需要进行同步操作,比如采用 CAS
(Compare And Swap) 失败重试、 区域加锁等方式保证分配操作的原子性。
- 设定默认值
成员变量值都需要设定为默认值, 即各种不同形式的零值。
- 设置对象头
设置新对象的哈希码
、 GC 信息
、锁信息
、对象所属的类元信息
等。这个过程的具体设置方式取决于 JVM 实现。
- 执行 init 方法
初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
对象分配内存的方式有哪些?
对象所需内存大小在类加载完成后便可完全确定,分配空间的任务实际上等于把一块确定大小的内存块从 Java 堆中划分出来。
- 指针碰撞
假设 Java 堆内存规整,被使用过的内存放在一边,空闲的放在另一边,中间放着一个指针作为分界指示器,分配内存就是把指针向空闲方向挪动一段与对象大小相等的距离。
- 空闲列表
如果 Java 堆内存不规整,虚拟机必须维护一个列表记录哪些内存可用,在分配时从列表中找到一块足够大的空间划分给对象并更新列表记录。
选择哪种分配方式由堆是否规整决定,堆是否规整由垃圾收集器是否有空间压缩能力决定。使用 Serial、ParNew 等收集器时,系统采用指针碰撞;使用 CMS 这种基于清除算法的垃圾收集器时,采用空间列表。
对象分配内存是否线程安全?
对象创建十分频繁,即使修改一个指针的位置在并发下也不是线程安全的,可能正给对象 A 分配内存,指针还没来得及修改,对象 B 又使用了指针来分配内存。
解决方法:
(1)CAS 加失败重试保证更新原子性。
(2)把内存分配按线程划分在不同空间,即每个线程在 Java 堆中预先分配一小块内存,叫做本地线程分配缓冲(Thread Local Allocation Buffer
),哪个线程要分配内存就在对应的 TLAB
分配,TLAB
用完了再进行同步。
对象的内存布局
对象在堆内存的存储布局可分为对象头
、实例数据
和对齐填充
。
1.对象头
占 12B,包括对象标记和类型指针。对象标记存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁标志、偏向线程 ID
等,这部分占 8B,称为 Mark Word
。Mark Word 被设计为动态数据结构,以便在极小的空间存储更多数据,根据对象状态复用存储空间。
类型指针
是对象指向它的类型元数据的指针,占 4B。JVM 通过该指针来确定对象是哪个类的实例。
2.实例数据
是对象真正存储的有效信息,即本类对象的实例成员变量和所有可见的父类成员变量。存储顺序会受到虚拟机分配策略参数和字段在源码中定义顺序的影响。相同宽度的字段总是被分配到一起存放,在满足该前提条件的情况下父类中定义的变量会出现在子类之前。
3.对齐填充
不是必然存在的,仅起占位符作用。虚拟机的自动内存管理系统要求任何对象的大小必须是 8B 的倍数,对象头已被设为 8B 的 1 或 2 倍,如果对象实例数据部分没有对齐,需要对齐填充补全。
对象的访问方式
Java 程序会通过栈上的 reference 引用操作堆对象,访问方式由虚拟机决定,主流访问方式主要有句柄
和直接指针
。
- 句柄
堆会划分出一块内存作为句柄池,reference 中存储对象的句柄地址,句柄包含对象实例数据与类型数据的地址信息。优点是 reference 中存储的是稳定句柄地址,在 GC 过程中对象被移动时只会改变句柄的实例数据指针,而 reference 本身不需要修改。
- 直接指针
堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference 存储对象地址,如果只是访问对象本身就不需要多一次间接访问的开销。优点是速度更快,节省了一次指针定位的时间开销,HotSpot 主要使用直接指针进行对象访问。
如何判断对象是否是垃圾?
- 引用计数
在对象中添加一个引用计数器,如果被引用计数器加 1,引用失效时计数器减 1,如果计数器为 0 则被标记为垃圾。
原理简单,效率高,但是在 Java 中很少使用,因为存在对象间循环引用的问题,导致计数器无法清零。虽然循环引用的问题可通过 Recycler 算法解决,但是在多线程环境下,引用计数变更也要进行昂贵的同步操作,性能较低,早期的编程语言会采用此算法。
- 可达性分析
主流语言的内存管理都使用可达性分析判断对象是否存活。基本思路是通过一系列称为 GC Roots
的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径称为引用链,如果某个对象到 GC Roots
没有任何引用链相连,则会被标记为垃圾。可作为 GC Roots 的对象包括:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 本地方法栈中JNI(即一般说的native方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
java的四种引用类型?
- 强引用
如果一个对象具有强引用,它就不会被垃圾回收器回收。即使当前内存空间不足,JVM也不会回收它,而是抛出 OutOfMemoryError
错误,使程序异常终止。
Object o = new Object(); //强引用
- 软引用
软引用指向的对象会在 JVM 需要新建对象并且其可用的堆内存不足以保存这个对象时,会被垃圾回收器回收。
// 软引用
SoftReference<Obeject> softRef=new SoftReference<Object>(o);
软引用可用来实现内存敏感的高速缓存
。比如按后退时,显示的网页内容是重新进行请求还是从缓存中取出呢?这就依赖于软引用。
Browser prev = new Browser(); // 获取页面进行浏览
SoftReference sr = new SoftReference(prev); // 浏览完毕后置为软引用
prev = null;//必须手动置空,否则prev还会一直是强引用
if(sr.get()!=null){
rev = (Browser) sr.get(); // 还没有被回收器回收,直接获取
}else{
prev = new Browser(); // 由于内存吃紧,所以对软引用的对象回收了
sr = new SoftReference(prev); // 重新构建
}
- 弱引用
与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
如果一个对象是偶尔的使用,并且希望在使用时随时就能获取到,但又不想影响此对象的垃圾收集,那么你应该用 Weak Reference
来记住此对象。比如ThreadLocal
中的map中entry的key、WeakHashMap
中的entry就是弱引用。
- 虚引用
如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
虚引用主要用来跟踪对象被垃圾回收器回收的过程,能在这个对象被垃圾器回收时收到一个系统通知。
虚引用,与软引用和弱引用的一个区别在于:虚引用必须和引用队列
(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中,这样可以通知应用程序对象的回收情况。虚引用无法单独使用,也无法通过虚引用的get方法获取被引用的对象。
虚引用可用于回收直接内存中分配的内存,当堆中的directByteBuffer
被回收时,系统会通知回收直接内存部分。
jvm如何判断这个对象可回收?以及finalize方法的作用?
- finalization机制
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。当垃圾回收器发现没有引用指向一个对象,即垃圾回收此对象之前,总会先调用这个对象的finalize()方法。finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用。理由包括下面三点:
- 在finalize()时可能会导致对象复活。
- finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下若不发生GC,则finalize()方法将没有执行机会。
- 一个糟糕的finalize()会严重影响GC的性能。
- 过程
确定一个对象死亡至少要经过两次标记,如果对象在可达性分析后发现没有与 GC Roots
连接的引用链,会被第一次标记。
当对象不可达被标记后,GC还会判断对象是否覆盖了finalize()
,若未覆盖则直接将其回收。若对象覆盖并且未执行过(手动调用的不计此列)finalize()
。将其放入F-Queue队列, 由一个低优先级线程执行该队列中对象的finalize()
方法。虚拟机会触发该方法但不保证会结束,这是为了防止某个对象的 finalize 方法执行缓慢或发生死循环。
只要对象在 finalize 方法中重新与引用链上的对象建立关联就会在第二次标记时被移出回收集合。执行finalize()
完毕后, GC会再次判断该对象是否可达,若不可达,则进行回收,否则,对象“再生”,即 finalize
方法中,可将待回收对象赋值给GC Roots可达的对象引用,从而达到对象再生的目的。
- finalize应用
java垃圾回收器只能回收创建在堆中的java对象,对于不是这种方式创建的对象(例如JNI本地对象),只能通过finalize()
保证使用之后进行销毁、释放内存。(可以理解为特别照顾这种对象,专门针对覆盖这种方法的对象判断可达性,并决定是否回收。)
finalize充当了保证使用之后释放资源的最后一道屏障,比如使用数据库连接之后未断开,并且由于程序员的个人原因忘记了释放连接, 这时就只能依靠finalize()
函数来释放资源。
各种垃圾回收算法
(1) 标记-清除算法(Mark-Sweep)
回收过程主要分为两个阶段,第一阶段为追踪(Tracing)阶段,即从 GC Root 开始遍历对象图,并标记(Mark)所遇到的每个对象,第二阶段为清除(Sweep)阶段,即回收器检查堆中每一个对象,并将所有未被标记的对象进行回收,整个过程不会发生对象移动。整个算法在不同的实现中会使用三色抽象(Tricolour Abstraction)、位图标记(BitMap)等技术来提高算法的效率,存活对象较多时较高效。
问题:
-
执行效率不稳定,如果堆包含大量对象需要回收,必须进行大量标记清除,导致效率随对象数量增长而降低。
-
存在内存空间碎片化问题,会产生大量不连续的内存碎片,导致以后需要分配大对象时容易触发 Full GC。
(2) 复制算法(Copying)
将空间分为两个大小相同的 From 和 To 两个半区,同一时间只会使用其中一个,每次进行回收时将一个半区的存活对象通过复制的方式转移到另一个半区。复制算法在对象存活率高时要进行较多复制操作,效率低。如果不想浪费空间,就需要有额外空间分配担保,应对被使用内存中所有对象都存活的极端情况,所以老年代一般不使用此算法。
优点:
- 实现简单、运行高效,可以通过碰撞指针的方式进行快速地分配内存
- 解决了内存碎片问题
缺点:
- 代价是可用内存缩小为原来的一半,空间利用率不高
- 存活对象比较大以及存活率高时,复制的成本比较高
(3) 标记-整理算法(Mark-Compact)
老年代可以使用标记-整理算法,这个算法的主要目的就是解决在非移动式回收器中都会存在的碎片化问题,也分为两个阶段,第一阶段与 Mark-Sweep 类似,第二阶段则会对存活对象按照整理顺序(Compaction Order)进行整理,让所有存活对象都向内存空间一端移动,然后清理掉边界以外的内存。
标记-清除与标记-整理的差异在于前者是一种非移动式算法而后者是移动式的。如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活的区域,是一种极为负重的操作,而且移动必须全程暂停用户线程。如果不移动对象就会导致空间碎片问题,只能依赖更复杂的内存分配器和访问器解决。
GC评价指标:延迟和吞吐量
在了解各种垃圾回收器之前,必须弄清楚评价GC性能的两个核心指标:
(1)延迟(Latency)
也可以理解为最大停顿时间,即垃圾收集过程中一次 STW 的最长时间,越短越好,一定程度上可以接受频次的增大,GC 技术的主要发展方向。比如下面追求暂停时间优先,尽可能让单次 STW 的时间最短:0.1+ 0.1+ 0.1 +0.1+0.1 = 0.5。
(2)吞吐量(Throughput)
应用系统的生命周期内,由于 GC 线程会占用 Mutator 当前可用的 CPU 时钟周期,吞吐量即为 Mutator 有效花费的时间占系统总运行时间的百分比,例如系统运行了 100 min,GC 耗时 1 min,则系统吞吐量为 99%,吞吐量优先的收集器可以接受单次较长的停顿,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。比如下面追求吞吐量更高,意味着在单位时间内,STW 的时间最短:0.2 + 0.2 = 0.4
Minor GC、Major GC、Full GC的区别
JVM在进行GC时,并非每次都对新生代、老年代、元空间三个内存区域一起回收的,大部分时候回收的都是指新生代。
针对Hotspot的实现,它里面的GC按照回收区域又分为两大种类型,部分收集(Partial Gc) 和 整堆收集(Full GC)。
- 部分收集
不是完整收集整个Java堆的垃圾收集。其中又分为:
① 新生代收集(MinorGc/YoungGc)
只是新生代的垃圾收集,当年轻代空间不足时,就会触发MinorGC,这里的年轻代满指的是Eden代满,survivor满不会引发GC。每次MinorGC清理年轻代的内存。因为 Java 对象大多都具备朝生夕灭的特性,所以MinorGC非常频繁,一般回收速度也比较快。
Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。
② 老年代收集(MajorGc/oldGc)
只是老年代的垃圾收集。注意,很多时候Major Gc会和Fulll GC混淆使用,需要具体分辨是老年代回收还是整堆回收。目前,只有CMS GC会有单独收集老年代的行为。
指发生在老年代的GC,对象从老年代消失时,我们说“MajorGc”或“Full GC”发生了。
Major Gc,经常会伴随至少一次的MinorGC(但非绝对的,在ParallelScavenge收集器的收集策略里就有直接进行MajorGc的策略选择过程)。也就是在老年代空间不足时,会先尝试触发MinorGc。如果之后空间还不足,则触发Major GC。Major Gc的速度一般会比Minor GC慢10倍以上,STW的时间更长。如果MajorGC后,内存还不足,就报OOM了。
Full GC触发机制有如下五种:
(1) 调用system.gc()时,系统建议执行Full GC,但是不必然执行
(2)老年代空间不足
(3)方法区空间不足
(4)通过MinorGc后进入老年代的平均大小大于老年代的可用内存
(5)由Eden区、survivor space0(From Space)区向survivor space1(ToSpace)区复制时,对象大小大于ToSpace可用内存,则把该对象转存到老年代,且老年代的可用连续内存小于该对象大小。
③ 混合收集(MixedGC)
收集整个新生代以及部分老年代的垃圾收集。目前,只有G1 GC会有这种行为。
- 整堆收集(Full GC)
收集整个java堆和方法区的垃圾收集。
各种垃圾回收器(重点掌握CMS和G1)
新生代收集器多基于复制算法,老年代收集器多基于标记-整理算法或者标记-清除算法。
(1)Serial
最基础的收集器,使用复制算法、单线程工作,只用一个处理器或一条线程完成垃圾收集,进行垃圾收集时必须暂停其他所有工作线程。
Serial
是虚拟机在客户端模式的默认新生代收集器,简单高效,对于内存受限的环境它是所有收集器中额外内存消耗最小的,对于处理器核心较少的环境,Serial 由于没有线程交互开销,可获得最高的单线程收集效率。
(2) ParNew
Serial 的多线程版本,使用复制算法,除了使用多线程进行垃圾收集外其余行为完全一致。
ParNew
是虚拟机在服务端模式的默认新生代收集器,一个重要原因是除了 Serial 外只有它能与 CMS 配合。自从 JDK 9 开始,ParNew + CMS
不再是官方推荐的解决方案,官方希望它被 G1 取代。
(3)Parallel Scavenge
新生代收集器,基于复制算法,是可并行的多线程收集器,与 ParNew 类似。
特点是它的关注点与其他收集器不同,Parallel Scavenge 的目标是达到一个可控制的吞吐量,吞吐量就是处理器用于运行用户代码的时间与处理器消耗总时间的比值。
(4)Serial Old
Serial 的老年代版本,单线程工作,使用标记-整理算法。
Serial Old
是虚拟机在客户端模式的默认老年代收集器,用于服务端有两种用途:
-
JDK5 及之前与 Parallel Scavenge 搭配。
-
作为CMS 失败预案。
(5)Parellel Old
Parallel Scavenge 的老年代版本,支持多线程,基于标记-整理算法。JDK6 提供,注重吞吐量可考虑 Parallel Scavenge 加 Parallel Old。
链接:https://tech.meituan.com/2020/11/12/java-9-cms-gc.html
(6)CMS算法
以获取最短回收停顿时间为目标,基于标记-清除算法,占用内存比例达到阈值时就会触发一次老年代CMS垃圾回收。过程相对复杂,分为四个步骤:
初始标记、并发标记、重新标记、并发清除。
初始标记和重新标记需要 STW(Stop The World,系统停顿),初始标记
仅是标记 GC Roots 能直接关联的对象,速度很快。并发标记
从 GC Roots 的直接关联对象开始遍历整个对象图,耗时较长但不需要停顿用户线程。重新标记
则是为了修正并发标记期间因用户程序运作而导致标记产生变动的那部分记录。并发清除
清理标记阶段判断的已死亡对象,不需要移动存活对象,该阶段也可与用户线程并发。
缺点:
- 对处理器资源敏感,并发阶段虽然不会导致用户线程暂停,但会降低吞吐量。
- 无法处理浮动垃圾,有可能出现并发模式失败(concurrent mode failure)而导致
Full GC
。 - 基于标记-清除算法,会产生空间碎片。
(7) G1收集器
G1 GC,全称Garbage-First Garbage Collector,通过-XX:+UseG1GC参数来启用,在JDK 9中,G1被提议设置为默认垃圾收集器。
G1是一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。它是专门针对以下应用场景设计的:
- 像CMS收集器一样,能与应用程序线程并发执行。
- 整理空闲空间更快。
- 需要GC停顿时间更好预测。
- 不希望牺牲大量的吞吐性能。
- 不需要更大的Java Heap。
几个概念:
- Region
heap被划分为一个个相等的不连续的内存区域(regions) ,每个region
都有一个分代的角色: eden、 survivor、 old、humongous
- GC安全点与安全区域
程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为安全点(Safepoint)
。Safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据 “是否具有让程序长时间执行的特征” 为标准。比如:选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。
如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?
① 抢先式中断(目前没有虚拟机采用了):首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
② 主动式中断:设置一个中断标志,各个线程运行到safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。
SafePoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的 Safepoint 。但是,程序“不执行”的时候呢?
例如线程处于 sleep 状态或Blocked状态,这时候线程无法响应JVM 的中断请求,“走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)
来解决。安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的 Safepoint。
- 记忆集与写屏障
一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?在其他的分代收集器,也存在这样的问题(而G1更突出),回收新生代也不得不同时扫描老年代?这样的话会降低MinorGc的效率。有解决办法吗?
有,记忆集Rememberedset
。
无论G1还是其他分代收集器,JVM都是使用Rememberedset来避免全局扫描。每个Region都有一个对应的Remembered set,每次Reference类型数据写操作时,都会产生一个WriteBarrier暂时中断操作,然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象),如果不同,通过cardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中。
当进行垃圾收集时,在GC根节点的枚举范围加入Rememberedset,就可以保证不进行全局扫描,也不会有遗漏。
- 三色标记算法
三色标记算法相比于标记-清除算法的优势在于,将一个相对大的STW分解成多个小的STW,做到了用户线程和垃圾回收的并发处理(这里并发也并非绝对并发,毕竟还存在少量的STW),减少了垃圾回收过程中STW的时间。
根据三色标记算法,我们知道对象存在三种状态:
① 白:对象没有被标记到,标记阶段结束后,会被当做垃圾回收掉。
② 灰:对象被标记了,但是它的field还没有被标记或标记完。是一个过渡色,最终都会被标记为黑色。
③ 黑:对象被标记了,且它的所有field也被标记完了。
由于并发阶段的存在(后面会详细说下G1流程),Mutator和Garbage Collector线程同时对对象进行修改,就会出现多标、漏标问题,造成未回收、错误回收, 多标造成该回收的垃圾未回收,事实上对系统影响不大,无需干预,因为下次垃圾回收周期会把它们清除掉。需要关注的是漏标,这是一个严重的问题!
举个栗子,在并发标记阶段,垃圾收集器线程和用户线程同时运行,垃圾收集器线程在扫描完对象E之后,如果用户线程此时执行代码 E.G = null;D.G = G;这时对象E和对象G之间的引用断开,对象D引用对象G。三色标记算法中黑色对象表明了其所有的引用的对象都已被扫描并标记为灰色,并且不会再次扫描黑色对象的下级引用,这样会导致一个结果:虽然对象G根可达,但是它不会被扫描和被标记为灰-黑色,标记环节结束时,会把对象G当做垃圾清除掉!
怎么解决呢?反向思考,破坏漏标问题的发生的两个充要条件之一即可。
① 增量更新
增量更新破坏了第一个条件:「至少有一个黑色对象新增了对白色对象的引用」
,在并发标记阶段,黑色对象D指向了白色对象G,这时会把黑色对象D记录下来,在重新标记阶段,会把黑色对象D标记为灰色对象D,然后以灰色对象D为根节点,扫描整个引用链,白色对象G就会被依次标记为灰色、黑色,白色对象G漏标的问题就解决了。
② 原始快照方案(SATB)
全称是Snapshot-At-The-Beginning,是GC开始时活着的对象的一个快照。它是通过Root Tracing得到的,作用是维持并发GC的正确性,用于三色标记算法。G1用的是SATB。
原始快照破坏了第二个条件:「所有灰色对象指向该白色对象的引用都断开了」
,在并发标记阶段,灰色对象E断开了对白色对象G的引用,这时会把白色对象G记录下来,在最终标记阶段,会把白色对象G标记为灰色,然后以灰色对象G为根节点,扫描整个引用链,如此原来的白色对象G就会被依次标记为灰色、黑色,白色对象G漏标的问题就解决了。
G1特点:
- 对每个角色的数量并没有强制的限定,也就是说对每种分代内存的大小,可以动态变化
- 在物理上不需要连续,则带来了额外的好处。有的分区内垃圾对象特别多,有的分区内垃圾对象很少,G1会在指定停顿时间内优先回收垃圾对象特别多的分区,这样可以花费较少的时间来回收这些分区的垃圾,这也就是G1名字的由来。
- G1使用了gc停顿可预测的模型,来满足用户设定的gc停顿时间,根据用户设定的目标时间,G1会自动地选择哪些region要清除,一次清除多少个region
- G1还是一种带压缩的收集器,在回收老年代的分区时,是将存活的对象从一个或多个分区拷贝到另一个可用分区,这个拷贝的过程就实现了局部的压缩。
模式:
G1提供了两种GC模式,Young GC和Mixed GC,两种都是完全Stop The World的。
- Young GC
选定所有年轻代里的Region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。
应用程序分配内存,当年轻代的Eden区用尽时,开始年轻代回收Young GC过程,G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到survivor区间或者老年区间,也有可能是两个区间都会涉及。
- Mixed GC
选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。
当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。标记完成马上开始混合回收Mixed GC过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。
注意,Mixed GC不是full GC,它只能回收部分老年代的Region,如果mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会使用serial old GC(full GC)来收集整个GC heap。所以我们可以知道,G1是不提供full GC的。
过程:
global concurrent marking的执行过程类似CMS,但不同的是,在G1 GC中,它主要是为Mixed GC提供标记服务的,并不是一次GC过程的必须环节。global concurrent marking的执行过程分为四个步骤:
① 初始标记(initial mark,STW)
标记 GC Roots
能直接关联到的对象,让下一阶段用户线程并发运行时能正确地在可用 Region 中分配新对象。需要 STW
但耗时很短,initial mark是共用了Young GC的暂停。
② 并发标记(Concurrent Marking)
从 GC Roots
开始对堆中对象进行可达性分析,递归扫描整个堆的对象图。耗时长但可与用户线程并发,扫描完成后要重新处理初始快照算法 SATB
记录的在并发时有变动的对象。
(3)最终标记(Remark,STW)
对用户线程做短暂暂停,处理并发阶段结束后仍遗留下来的少量 SATB
记录。
(4)筛选回收(Cleanup)
对各 Region
的回收价值排序,根据用户期望停顿时间制定回收计划。必须暂停用户线程,由多条收集线程并行完成。
链接:https://tech.meituan.com/2016/09/23/g1.html
下面G1垃圾回收过程详解是来自宋红康老师的课程ppt截图,有兴趣的同学可以继续了解,内容基本和上面的重合。
过程一、年轻代GC阶段
过程二、并发标记阶段
过程三、混合回收阶段
过程四、Full GC阶段
G1与其他收集器对比:
- 对比使用
mark sweep
的CMS,G1使用的复制算法
不会造成内存碎片; - 对比Parallel Scavenge(基于copying )、Parallel Old收集器(基于mark-compact-sweep),Parallel会对整个区域做整理导致gc停顿会比较长,而G1只是特定地整理几个region。
- G1并非一个实时的收集器,与parallelScavenge-样,对gc停顿时间的设置并不绝对生效,只是G1有较高的几率保证不超过设定的gc停顿时间。与之前的gc收集器对比,G1会根据用户设定的gc停顿时间,智能评估哪几个region需要被回收可以满足用户的设定
G1相对于CMS的优势:
- G1在压缩空间方面有优势。
- G1通过将内存空间分成区域(Region) 的方式避免内存碎片问题。
- Eden、Survivor、 Old区不再固定,在内存使用效率上来说更灵活。
- G1可以通过设置预期停顿时间( Pause Time) 来控制垃圾收集时间,避免应用雪崩现象。
- G1在回收内存后会马上同时做合并空闲内存的工作,而CMS默认是在STW ( stop the world) 的时候做。
- G1会在Young GC中使用,而CMS只能在Old区使用。
ZGC 了解吗?
-
JDK11 中加入的具有实验性质的低延迟垃圾收集器,目标是尽可能在不影响吞吐量的前提下,实现在任意堆内存大小都可以把停顿时间限制在 10ms 以内的低延迟。
-
基于 Region 内存布局,不设分代,使用了读屏障、染色指针和内存多重映射等技术实现可并发的标记-整理,以低延迟为首要目标。
-
ZGC
的 Region 具有动态性,是动态创建和销毁的,并且容量大小也是动态变化的。
链接:https://tech.meituan.com/2020/08/06/new-zgc-practice-in-meituan.html(新一代垃圾回收器ZGC的探索与实践)
你知道哪些内存分配与回收策略?
(1)对象优先在 Eden 区分配
大多数情况下对象在新生代 Eden
区分配,当 Eden
没有足够空间时将发起一次 Minor GC
。
(2)大对象直接进入老年代
大对象指需要大量连续内存空间的对象,比如很长的字符串或数量庞大的数组。大对象容易导致内存还剩余不少就提前触发垃圾收集的情况。
HotSpot 提供了 -XX:PretenureSizeThreshold
参数,大于该值的对象直接在老年代分配,避免在 Eden 和 Survivor 间来回复制。
(3)长期存活对象进入老年代
虚拟机给每个对象定义了一个对象年龄计数器,存储在对象头。如果经历过第一次 Minor GC
仍然存活且能被 Survivor 容纳,该对象就会被移动到 Survivor 中并将年龄设置为 1。对象在 Survivor 中每熬过一次 Minor GC
年龄就加 1 ,当增加到一定程度(默认15)就会被晋升到老年代。对象晋升老年代的阈值可通过 -XX:MaxTenuringThreshold
设置。
(4)动态对象年龄判定
为了适应不同内存状况,虚拟机不要求对象年龄达到阈值才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 的一半,年龄不小于该年龄的对象就可以直接进入老年代。
(5)空间分配担保
Minor GC
前虚拟机必须检查老年代最大可用连续空间是否大于新生代对象总空间,如果满足则说明这次 Minor GC
确定安全。如果不满足,虚拟机会查看 -XX:HandlePromotionFailure 参数是否允许担保失败,如果允许会继续检查老年代最大可用连续空间是否大于历次晋升老年代对象的平均大小,如果满足将冒险尝试一次 Minor GC,否则改成一次 FullGC。总结起来就是,当老年代最大可用连续空间小于 新生代对象总空间和历次晋升老年代对象的平均大小的较小值,则直接FullGC。
冒险是因为新生代使用复制算法,为了内存利用率只使用一个 Survivor,大量对象在 Minor GC 后仍然存活时,需要老年代进行分配担保,接收 Survivor 无法容纳的对象。
链接:https://www.nowcoder.com/discuss/353156569720365056
你知道哪些GC问题诊断工具?
- 命令行终端
- 标准终端类:jps、jinfo、jstat、jstack、jmap
- 功能整合类:jcmd、vjtools、arthas、greys
命令行首推 arthas ,阿里开源神器。
① jps(Java Process status)
虚拟机进程状况工具。显示指定系统内所有的HotSpot虚拟机进程(查看虚拟机进程信息),可用于查询正在运行的虚拟机进程。
说明:对于本地虚拟机进程来说,进程的本地虚拟机ID与操作系统的进程ID是一致的,且是唯一的。
// 输出应用程序主类的全类名或如果进程执行的是jar包,则输出jar完整路径
jps -l
② jinfo(Configuration Info for Java)
Java 配置信息工具。实时查看和调整虚拟机各项参数,使用 jps 的 -v 参数可以查看虚拟机启动时显式指定的参数,但如果想知道未显式指定的参数值只能使用 jinfo 的 -flag 查询。
// 查看赋过值的所有参数
jinfo -flags 62348
// 查看老年代年轻代内存分配比例,默认值2
jinfo -flag NewRatio 62348
jinfo还支持修改命令参数:
③ jstat(Java Statistics Monitor Tool)
虚拟机统计信息监视工具。用于监视虚拟机各种运行状态信息。可以显示本地或远程虚拟机进程中的类加载、内存、垃圾收集、即时编译器等运行时数据,在没有 GUI 界面的服务器上是运行期定位虚拟机性能问题的常用工具。
option参数:
- class:显示ClassLoader的相关信息:类的装载、卸载数量、总空间、类装载所消耗的时间等
- gc:显示与gc相关的堆信息。包括Eden区、两个Survivor区、老年代、永久代等的容量、已用空间、GC时间合计等信息。
- gccapacity:显示内容与-gc基本相同,但输出主要关注Java堆各个区域使用到的最大、最小空间。
- gcutil:显示内容与-gc基本相同,但输出主要关注已使用空间占总空间的百分比。
- gccause:与-gcutil 功能一样,但是会额外输出导致最后一次或当前正在发生的GC产生的原因。
- gcnew:显示新生代GC状况
- gcnewcapacity:显示内容与-gcnew基本相同,输出主要关注使用到的最大、最小空间
- gcold:显示老年代GC状况
参数含义:
S0 和 S1 表示两个 Survivor,E 表示新生代,O 表示老年代,YGC 表示 Young GC 次数,YGCT 表示 Young GC 耗时,FGC 表示 Full GC 次数,FGCT 表示 Full GC 耗时,GCT 表示 GC 总耗时。
④ jstack(JVM Stack Trace))
Java 堆栈跟踪工具。用于生成虚拟机指定进程当前时刻的线程快照(虚拟机堆栈跟踪)。线程快照就是当前虚拟机内指定进程的每一条线程正在执行的方法堆的集合。
生成线程快照的目的通常是用于定位线程出现长时间停顿的原因,如线程间死锁、死环、请求外部资源导致的长时间等待等问题。这些都是导致线程长时间停顿的常见原因。当线程出现停顿时,就可以用jstack显示各个线程调用的堆栈情况,可以获知没有响应的线程在后台做什么或等什么资源。
// 查看指定线程堆栈情况,主要定位停顿问题
jstack pid
在thread dump中,要留意下面几种状态
- 死锁,Deadlock(重点关注)
- 等待资源,Waiting on condition(重点关注)
- 等待获取监视器,Waiting on monitor entry(重点关注)
- 阻塞,Blocked(重点关注)
- 执行中,Runnable
- 暂停,suspended
死锁:
⑤ jmap(JVM Memory Map)
Java 内存映像工具。一方面可以获取dump文件(堆转储快照文件,二进制文件),另一方面可以获取目标]ava进程的内存相关信息,包括Java堆各区域的使用情况、堆中对象的统计信息、类加载信息等,还可以查询 finalize 执行队列、Java 堆和方法区的详细信息,如空间使用率,当前使用的是哪种收集器等。
jmap -dump
Heap Dump又叫做堆存储文件,指一个]ava进程在某个时间点的内存快照。Heap Dump在触发内存快照的时候会保存此刻的信息如下:
- All Objects:Class、fields、primitive values and references
- All Classes:ClassLoader、name、super class、static fields
- GC Roots:Objects defined to be reachable by the JVM
- Thread Stacks and Local Variables:The call-stacks of threads at the moment of the snapshot,and per-frame information about local objects
说明:
- 通常在写Heap Dump文件前会触发一次Full GC,所以heap dump文件里保存的都是FullGC后留下的对象信息。
- 由于生成dump文件比较耗时,因此大家需要耐心等待,尤其是大内存镜像生成dump文件则需要耗费更长的时间来完成。
导出dump文件分为手动和自动两种方式:
手动:
生成本地的hprof文件,可以指定文件格式,-dump:live表示只打印存活文件,可以节省文件传输时间成本,处理故障比较高效:
自动:
当程序发生OOM退出系统时,一些瞬时信息都随着程序的终止面消失,而重现OOM问题往往比较困难或者耗时。此时若能在OOM时,自动导出dump文件就显们非常迫切。这里介绍一种比较常用的取得堆快照文件的方法,即使用:-xx:+HeapDumpOnOutOfMemoryError:在程序发生OOM时,导出应用程序的当前堆快照。
-xx:HeapDumpPath:可以指定堆快照的保存位置。
比如:
-Xmx108m -XX:+HeapDumpOn0utOfMemoryError
-XX:HeapDumpPath=D:\m.hprof
jmap -heap
jmap -histo
由于jmap将访问堆中的所有对象,为了保证在此过程中不被应用线程干扰,jmap需要借助安全点机制,让所有线程停留在不改变堆中数据的状态。也就是说,由jmap导出的堆快照必定是安全点位置的。这可能导致基于该堆快照的分析结果存在偏差。
举个例子,假设在编译生成的机器码中,某些对象的生命周期在两个安全点之间,那么:live选项将无法探知到这些对象。
另外,如果某个线程长时间无法跑到安全点,jmap将一直等下去。与前面讲的jstat不同(垃圾回收器会主动将jstat所需要的摘要数据保存至固定位置之中,而jstat只需直接读取即可)。
⑥ jhat(Jvm Heap Analysis Tool)
虚拟机堆转储快照分析工具。Sun ]DK提供的jhat命令与jmap命令搭配使用,用于分析jmap生成的heap dump文件(堆转储快照)。jhat内置了一个微型的HTTP/HTML服务器,生成dump文件的分析结果后,用户可以在浏览器中查看分析结果(分析虚拟机转储快照信息)。
使用了jhat命令,就启动了一个http服务,端口是7000,即http://localhost:7000/,就可以在浏览器里分析。
说明:jhat命令在JDK9、JDK18中已经被删除,官方建议用VisualVM代替。
⑦ jcmd(JVM Command)
在JDK 1.7以后,新增了一个命令行工具jcmd,它是一个多功能的工具,可以用来实现前面除了jstat之外所有命令的功能。比如用它来导出堆、内存使用、查看Java进程、导出线程信息、执行GC、JVM运行时间等。
jcmd拥有jmap的大部分功能,并且在Oracle的官方网站上也推荐使用jcmd命令代jmap命令。
// 查看可用的所有命令
jcmd pid help
链接:https://docs.oracle.com/en/java/javase/11/tools/jcmd.html(官方文档)
⑧ arthas(阿尔萨斯)
visualVm和jprofiler的优点是可以图形界面上看到各维度的性能数据,使用者根据这些数据进行综合分析,然后判断哪里出现了性能问题。
但是这两款工具也有个缺点,就是必须在服务端项目进程中配置相关的监控参数。然后工具通过远程连接到项目进程,获取相关的数据。这样就会带来一些不便,比如线上环境的网络是隔离的,本地的监控工具根本连不上线上环境。并且类似于Jprofiler这样的商业工具,是需要付费的。那么有没有一款工具不需要远程连接,也不需要配置监控参数,同时也提供了丰富的性能监控数据呢?推荐性能分析神器Arthas(详细使用可以参考官方文档:)。
- 可视化界面
- 简易:JConsole、JVisualvm、HA、GCHisto、GCViewer
- 进阶:MAT、JProfiler
可视化界面首推 JProfiler。
下面以JVisualvm和JProfiler为例,简单介绍下使用,还需要结合项目实际问题加深印象。
① JVisualvm
- 监控应用程序的性能和内存占用情况
- 监控应用程序的线程
- 分析(Profile)应用程序性能和内存分配情况
- 进行线程转储(Thread Dump)或堆转储(Heap Dump)
- 分析核心转储(Core Dump)
- 保存快照以便脱机分析应用程序。
监视tab:
visual GC tab:
可以利用jvisualvm(可以生成堆和线程dump文件),或者利用jvm命令(-XX:+HeapDumpOnOutOfMemoryError 或 jmap -dump
)一个关于堆信息的hprof快照文件,或者让运维人员线下传给你一个dump文件(线上环境),之后导入jvisualvm去分析,比如哪里溢出了,排查强弱引用,查看引用链等。
链接:
https://blog.csdn.net/inthat/article/details/83317671(visualvm集成idea)
https://www.cnblogs.com/jackson1024/p/18051475(visualvm远程连接)
https://juejin.cn/post/6844903953004494856(偏理论)
https://www.nowcoder.com/discuss/353157357674897408( 入门级理解)
② JProfiler(更强大,收费)
性能查看工具JProfiler,可用于GC roots溯源、查看java执行效率、查看线程状态、查看内存占用与内存对象,还可以分析dump日志。
- 使用方便,界面操作友好(简单且强大)
- 对被分析的应用影响小(提供模板)
- CPU、Thread、Memory分析功能尤其强大
- 支持对jdbc、noSql、jsp、servlet、socket等进行分析支持多种模式(离线,在线)的分析
- 支持监控本地、远程的JVM
- 跨平台、拥有多种操作系统的安装版本
GC roots溯源:
分析dump日志:
启动添加JVM命令:-XX:+HeapDumpOnOutOfMemoryError,OOM时生成dump文件,载入jprofile分析。
heap walker分析对象:
分析线程,确定出现问题代码行:
CPU Views:
Threads分析:
通过heap walker分析内存泄露原因:
静态变量的周期和类保持一致,直到程序结束或者类卸载才会释放。
③ 火焰图(Flame Graphs)
在追求极致性能的场景下,了解你的程序运行过程中cpu在干什么很重要,火焰图就是一种非常直观的展示cpu在程序整个生命周期过程中时间分配的工具,火焰图可以非常直观的显示出调用钱中的CPU消耗瓶颈。
- Y-轴/高度:显示堆栈深度。
- X轴/宽度:表示在一个方法中花费了多少时间(样本数)。
平时项目是怎么排查和优化GC问题的?
在做 GC 问题排查和优化之前,我们需要先来明确下到底是 GC 直接导致的问题,还是业务代码导致的 GC 异常,最终出现问题?
- 设定评价标准
我们一般拿下面的标准去评判一个系统的gc性能是否符合要求,即一次停顿的时间不超过应用服务的 TP9999,GC 的吞吐量不小于 99.99%。
举个例子,假设某个服务 A 的 TP9999 为 80 ms,平均 GC 停顿为 30 ms,那么该服务的最大停顿时间最好不要超过 80 ms,GC 频次控制在 5 min 以上一次。如果满足不了,那就需要调优或者通过更多资源来进行并联冗余。
- 确定GC Cause
-Xmx 9M -Xms 9M -XX:+PrintGCDetails
可以查看gc日志,或者使用jstat -gccause(命令行)、gceasy(可视化工具)查看。
-
System.gc(): 手动触发GC操作。
-
CMS: CMS GC 在执行过程中的一些动作,重点关注 CMS Initial Mark 和 CMS Final Remark 两个 STW 阶段。
-
Promotion Failure: Old 区没有足够的空间分配给 Young 区晋升的对象(即使总可用内存足够大)。
-
Concurrent Mode Failure: CMS GC 运行期间,Old 区预留的空间不足以分配给新的对象,此时收集器会发生退化,严重影响 GC 性能,下面的一个案例即为这种场景。
- 根因是GC吗
到底是结果(现象)还是原因,在一次 GC 问题处理的过程中,如何判断是 GC 导致的故障,还是系统本身引发 GC 问题。比如 Case:”GC 耗时增大、线程 Block 增多、慢查询增多、CPU 负载高等四个表象,如何判断哪个是根因?“,根据经验大致有四种判断方法供参考:
- 时序分析
先发生的事件是根因的概率更大,通过监控手段分析各个指标的异常时间点,还原事件时间线,如先观察到 CPU 负载高(要有足够的时间 Gap),那么整个问题影响链就可能是:CPU 负载高 -> 慢查询增多 -> GC 耗时增大 -> 线程Block增多 -> RT 上涨。
- 概率分析
使用统计概率学,结合历史问题的经验进行推断,由近到远按类型分析,如过往慢查的问题比较多,那么整个问题影响链就可能是:慢查询增多 -> GC 耗时增大 -> CPU 负载高 -> 线程 Block 增多 -> RT上涨。
- 实验分析
通过故障演练等方式对问题现场进行模拟,触发其中部分条件(一个或多个),观察是否会发生问题,如只触发线程 Block 就会发生问题,那么整个问题影响链就可能是:线程Block增多 -> CPU 负载高 -> 慢查询增多 -> GC 耗时增大 -> RT 上涨。
- 反证分析
对其中某一表象进行反证分析,即判断表象的发不发生跟结果是否有相关性,例如我们从整个集群的角度观察到某些节点慢查和 CPU 都正常,但也出了问题,那么整个问题影响链就可能是:GC 耗时增大 -> 线程 Block 增多 -> RT 上涨。只有这个时候是根因,有必要gc调优。
不同的根因,后续的分析方法是完全不同的。如果是 CPU 负载高那可能需要用火焰图看下热点、如果是慢查询增多那可能需要看下 DB 情况、如果是线程 Block 引起那可能需要看下锁竞争的情况,最后如果各个表象证明都没有问题,那可能 GC 确实存在问题,可以继续分析 GC 问题了。
- 利用各种性能诊断工具查看gc情况和堆情况,找到异常点,可以结合《Java中9种常见的CMS GC问题分析与解决》学习理解。
链接:
https://blog.csdn.net/chuixue24/article/details/130511497(宋红康课程笔记)
https://tech.meituan.com/2020/11/12/java-9-cms-gc.html《Java中9种常见的CMS GC问题分析与解决》