前言
推荐大家一定多看看这本神书
《深入理解java虚拟机》
, 本人对JVM虚拟机的一直是一知半解,曾经也是 在业务上发现Full GC
毫无头绪,所以还是要去了解产生 GC 的原因,以及 虚拟机 垃圾回收的原理。只有自己基础知识牢固了,才能不怕突如其来的问题。本文算是 我阅读完书籍后的整理!可能也有不足的地方!大家可以给我指出!
1. 类加载
程序被计算机执行需要编译成 0 和 1 的 二进制格式。 在虚拟机不断的发展中,转成二进制机器码 不再是唯一的选择。
通过
Java虚拟机
: 进行一次编译 生成Class文件
,可以实现在不同的环境下运行
【虚拟机不关注你的来源是什么,只关注你的Class文件是否符合规范】
类的加载过程
那虚拟机又是如何 将
Class文件
加载为 使用的对象
呢????
注
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的。 而 解析
阶段则不一定.
解析操作: 可能发生在 初始化之前 也有可能 发生在初始化之后,这是 为了支持java语言的运行时动态绑定
加载
加载: 整个“类加载”(Class Loading)过程中的一个阶段
类加载的时机
其实并没有相关的约束,一般都是交由虚拟机去 判断的 可以简单的理解: 当一个类被需要使用的时候,会进行加载
虽然加载没有强制约束,但是Java虚拟机规范指定了必须初始化的情况
- 遇到new、getstatic、putstatic或invokestatic这四条字节码指令
- 使用java.lang.reflect包的方法对类型进行反射调用的时候
- 当初始化类的时候,如果发现其父类还没有进行过初始化
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类)
- 对动态语言的支持
- 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化
加载阶段的具体过程
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
加载阶段类对象的存储区域
- 将类的元信息,存储在 方法区 中
- 将
java.lang.Class
的类对象,存放在堆中
验证
验证:是连接的第一步 目的:确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全
由于: Class文件
不一定是编译器编译而来,也可以通过人为在二进制编辑器中编辑出来,所以 验证
阶段非常重要。
并且: 验证阶段
类加载过程中占了相当大的比重
不符合Class文件格式的约束,就应当抛出一个java.lang.VerifyError异常或其子类异常
检测的四个阶段
- 文件格式验证
- 验证Class文件的格式是否符合规范
- 只有符合条件的Class文件 才能能 被解析 并 存储与 方法区中使用
- 元数据验证
- 验证字节码的描述信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求
- 即:对 类 或 接口的 基本语法、字段、方法 、父类 等进行验证
- 字节码验证
- 对类的方法体 即 方法中的 代码 进行校验,确保在 方法运行时 不会做出危害虚拟机安全的行为
- 符号引用验证
- 最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用
- 即:除类本身,对常量池,对其他各类应用的信息进行匹配性校验
类加载的一种优化方式
-Xverify:none
命令,可以关闭大部分类验证措施,以缩短虚拟机类加载的时间。
如果能保证运行中的代码已经被反复使用和验证过,则可以考虑开启
准备
准备阶段: 为类中定义的变量 (即静态变量,被static修饰的变量)分配内存并设置
类变量 初始值
的阶段
准备阶段的赋值操作
准备阶段的 此处的 初始化赋值,并不是将变量进行实例化赋值,只是进行赋值为 0
值 或者 null
值,只有在初始化阶段,在会进行真正的对其赋值
# 即准备阶段 此时 初始值 是 0 而不是123 (“通常情况”下初始值是零值)
public static int value = 123;
# 如果使用 final 关键字,则初始值为 123
public static final int value = 123;
解析
Java虚拟机将常量池内的
符号引用
替换为直接引用
的过程
- 符号引用(Symbolic References): 符号引用以一组符号来描述所引用的目标
- 符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可
- 符号引用与虚拟机实现的内存布局无关
- 引用的目标并不一定是已经加载到虚拟机内存当中的内容
- 直接引用(Direct References): 直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄
- 直接引用是和虚拟机实现的内存布局直接相关的
- 直接引用的目标必定已经在虚拟机的内存中存在
案例
# 符号引用
String s=”adc”;
System.out.println(“s=”+s);
# 直接引用
System.out.println(“s=”+adc);
同一个引用进行多次解析问题
引用解析会进行缓存:
- 当第一次解析成功:则后续也会成功
- 当第一次解析失败:其他指令解析也是报异常,就是后续请求的符合成功加载
解析的主要对象
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7 类符号引用进行
初始化
类的初始化阶段是类加载过程的 最后一个步骤 直到初始化阶段: Java虚拟机才真正开始执行类中编写的Java程序代码
初始化阶段的作用
会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源
什么时候进行初始化
初始化阶段: 就是执行类构造器
<clinit>()方法
的过程,<clinit>()方法
不是由程序员编写的,而是由Java虚拟机创建的
<clinit>()
方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit>()
方法
类加载器
类加载器 是为了 通过一个类的全限定名来获取描述该类的二进制字节流。完成类加载的具体实现方式
不同版本确定类的唯一性
- Java8:
- 由加载它的类加载器 和 这个类本身一起共同确立 (即 一个类 被同一个类加载器加载的)
- Java9:
- 引入
模块化系统
, - 即同一个模块内 同一个类 被同一个加载器加载的类,我们认为是同一个类
- 引入
双亲委派机制
各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)
类加载器之间的关系 不是继承, 而是 组合 目的: 优先去在父类中找类有没有加载
工作过程
- 一个类加载器收到了类加载的请求
- 不会自己去尝试加载这个类, 而是把这个请求委派给父类加载器去完成
- 所有的加载请求最终都应该传送到最顶层的启动类加载器中
- 当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载
使用双亲委派的好处
类随着他的类加载器一起具备了 一种带有优先级的层级关系
- 保证同一个类由同一个类加载器加载(模型最顶层的类加载器)
- 防止加载同一个.class,避免重复加载(全局的类不会被重复加载)
- 保证了程序的稳定性 和 安全性
- 避免了 系统中存在相同类时的加载问题
- 以 Object类在程序的各种类加载器环境中都能够保证是同一个类
- 保证核心.class不被篡改
Java开发人员将类加载器分为三层:
- 启动类加载器(Bootstrap Class Loader)
- 扩展类加载器(Extension Class Loader)
- 应用程序类加载器(Application Class Loader)
启动类加载器(Bootstrap Class Loader)
- 加载:
jdk
目录 下的lib
目录的 类 - 引用: 无法被Java程序直接引用
扩展类加载器(Extension Class Loader)
- 加载:
jdk
目录 下的lib\ext
目录的 类- 被
java.ext.dirs系统变量
所指定的路径中所有的类库 - 主要是 加载一些 通用的 类库
- 引用:开发者可以直接在程序中使用扩展类加载器来加载Class文件
应用程序类加载器(Application Class Loader) 【系统类加载器】
- 加载:加载ClassPath下面的类,简单理解就是加载我们自己写的类
- 引用:当应用程序中没有自定义过 自己的类加载器,默认情况下使用的 类加载器
自定义类加载器
主要作用: 实现类的隔离、重载等功能
- 实现类的隔离:通过不同的类加载器,进行加载类
- 进行加解密操作:对类文件,源码进行加解密,防止源码泄露
- 破坏双亲委派模式
如何打破双亲委派机制
双亲委派模型主要出现过3次较大规模“被破坏”的情况。
第一次: 由于双亲委派机制的出现导致的
因为 双亲委派机制 还没有出现之前,类加载器 和 抽象类等已经在Java中存在, 引入 双亲委派机制 是时 做了一些妥协
即:向前兼容,因为之前版本使用的项目,已经存在对 classload()
方法的重写
第二次: SPI 加载服务功能
常见的:JNDI、JDBC 功能 通过 SPI
实现的机制
例
JDBC 通过 JAVA基础类定义,让不同的 数据库厂商 提供 不同的 JDBC 连接驱动方式
JDBC 基础类 存放在 核心包 中 只能通过 启动类加载器 进行加载,而具体实现却在 指定服务的 classPath
上。需要 启动类加载器 去调用子类 应用类加载器 进行加载
总结2
由于虚拟机中的加载规则是按需加载的,即需要用到什么类的时候才会去加载那个类。并且在加载该类时用的是什么加载器,那么加载该类引用的类也需要用到对应的加载器,在java中的SPI机制,加载jdbc时由于Driver类不在rt.jar中因此不能被Bootstrap加载器进行加载,因此使用了线程上下文类加载器委派子类进行加载。所以打破了双亲委派机制,并且在tomcat类加载器中也存在打破双亲委派机制的情况。
解决方案:
- JDK6之前:JAVA 团队构建了 线程上下文类加载器(Thread Context ClassLoader),在为设置的前提下 默认为:应用加载器
- 设置方式
thread.setContextClassLoader()
- 设置方式
- JDK6之后: 提供了java.util.ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式
第三次:为了 满足热部署导致
- 用户对程序动态性的追求而导致的
- 代码热替换(Hot Swap)、模块热部署(Hot Deployment)等
第四次:JDK9引入模块化
由于模块化的加入,在委派给父类加载器加载前 要先进行判断该类是否能够归属到某一个系统模块中 如果可以找到归属,则会优先委派给负责那个模块的加载器
2. JVM运行时内存区域
JVM运行时的内存区域可以划分为两类:
线程私有:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共有:
-
堆
-
方法区
-
直接内存 (非运行时数据区的一部分)
程序计数器
程序计数器
是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器
为了线程切换后确保恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为 **“线程私有” ** 的内存。
其核心作用:
- 多线程的情况下,用于记录当前线程执行的位置,从而线程被切换回来的时候能够准确的知道该线程上次运行到哪里了
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理
注: 程序计数器 是唯一个不会出现 OutOfMemoryError
的内存区域
虚拟机栈
虚拟机栈
与程序计数器
一样 是线程私有的, 虚拟机栈描述的是Java方法执行的线程内存模型。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧
每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
栈帧
栈帧
是 虚拟机栈的 最小组成单元。一个栈帧一个方法。而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息
局部变量表
用于存放 方法的参数
和 内部定义的局部变量
-
基本的8中数据类型 (boolean、byte、char、short、int、float、long、double)
-
对象引用(reference类型)
-
returnAddress类型
操作数栈
操作数栈 也称为 操作栈
是一个 后入先出的 栈 主要是:
-
方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈
-
再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作
动态链接
- 每一个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接。
- 动态链接的作用是将符号引用转换为直接引用
方法返回地址
-
方法正常退出时:调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址
-
方法异常退出时: 返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息
本地方法栈
与
虚拟机栈
的作用相似。区别是: 虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的native
发不方法服务也是线程
线程私有
, 生命周期 和 线程生命周期一致
注 也会出现两种异常:
StackOverFlowError
: 线程请求的栈深度>所允许的深度OutOfMemoryError
: 本地方法栈扩展时无法申请到足够的内存
堆
堆:虚拟机所管理的内存中最大的一块内存区域,被所有线程共享的一块内存区域。
堆中存放什么?
- 对象
- 数组
- 字符串常量池
Java堆也是 垃圾收集器管理的 主要区域,也被称为 GC堆
从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为
JDK7
- 新生代(Young Generation)
- 伊甸园区 Eden space
- 2个幸存者区 Suvivor space
- 老年代Old Generation
- 永久代Permanent Generation
**JDK8 **
- 青年代Young Generation
- 伊甸园区 Eden space
- 2个幸存者区 Suvivor space
- 老年代Old Generation
- 元空间 Metaspace (存储于本地内存中)
JDK 8 版本之后 方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存
方法区
方法区:各个线程共享的内存区域,它用来存储已经被虚拟机加载的 类型信息、常量、 静态变量、
即时编译器编译后
的代码缓存等数据
-
JDK1.7之前:方法区 由
永久代
实现 -
JDK1.8之后:废弃了
永久代
使用 元空间 实现元空间、永久代 只不过是的方法区的具体的落地实现方案
元空间主要存储:
- 类信息:
- 类的版本
- 类的字段
- 方法
- 接口 和 父类的描述
- 静态常量池
- 存放编译期生成的各种字面量与符号引用
- 运行池常量池:
- 存放编译期生成的各种字面量与符号引用 (当类加载后存入)
直接内存
直接内存并不是虚拟机运行时数据区的一部分,大小不受 JAVA堆大小的限制, 而是由操作系统直接管理,也称为
堆外内存
。但也会受到 本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制。
直接内存的原理
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作
引入的好处
提高性能, 因为避免了在 Java堆
和 Native堆
中来回复制数据
可能存在的问题
我们经常会忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError
异常
可以通过设置直接内存的使用的最大值
-XX:MaxDirectMemerySize
直接内存使用场景
- 有很大的数据需要存储,它的生命周期很长
- 适合频繁的IO操作,例如网络并发场景
3. JVM内存溢出
在《Java虚拟机规范》的规定里,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(下文称OOM)异常的可能
区域 | 异常 | 异常原因 | 解决方式 |
---|---|---|---|
虚拟机栈 | 1、StackOverflowError 2、OutOfMemoryError | 1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常 2. 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常 | -Xss:设置每个线程的堆栈大小 |
本地方法栈 | 1、StackOverflowError 2、OutOfMemoryError | 1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常 2. 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常 | |
堆 | OutOfMemoryError | 1、java.lang.OutOfMemoryError: GC Overhead Limit Exceeded ,当垃圾回收并且只能回收很少的堆空间时,就会发生此错误2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误 | 1、增加 堆内存大小 2. 调整垃圾回收器、以及相关垃圾回收参数 |
方法区和运行时常量池溢出 | java.lang.OutOfMemoryError: MetaSpace | 1、循环反射(动态)创建对象 2、不断的创建运行是常量 | JDK1.7 : -XX:PermSize= 方法区 (永久代) 初始大小 -XX:MaxPermSize= 方法区 (永久代) 最大大小 JDK1.8: - XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。 ·-XX:MetaspaceSize:指定元空间的初始空间大小 |
直接内存 | OutOfMemoryError | 1、直接内存空间不足,过多的IO操作、或 进行大文件操作 | 通过-XX:MaxDirectMemorySize参数来指定, |
4. 垃圾回收
那些内存需要回收?
通常我们认为 当对象不在被其他对象所持有 我们就认为这些对象是需要被回收的
寻找不可用对象的方式
- 引用计数法
- 可达性分析
引用计数法
- 在对象中添加一个引用计数器
- 当有地方引用该对象时,计数器加1
- 当引用失效,计数器减1
- 引用计数器值为 0 时,则任务对象
已死
,需要被回收
优缺点
- 优点:实现简单、执行效率高
- 缺点:
- 无法检测出循环依赖 问题
- 需要额外的内存空间记录每个对象的被引用的次数
可达性分析
可达性分析, 通过 一系列的
GC Rootss
的对象作为起始节点, 从起始节点开始,向下扫描对象的引用链路当一个对象 没有和 任何一个
GC Roots
存在,则认为是不可用
、死亡
的对象, 需要进行回收
注: Java中的垃圾收集器,都是以 可达性分析 为基础实现的 对象死亡 的判断
可以做为 GC Roots 对象类型
- 虚拟机栈:(栈帧中的本地变量表) 引用的对象
- 参数、局部变量、临时变量
- 本地方法栈:(JNI(Native方法)引用的对象)引用的对象
- 局部变量
- 方法区:
- static静态引用 (类中的静态变量)
- final 常量引用 (String 等字符串常量池)
- 同步锁(synchronized关键字): 持有的对象
- Java虚拟机内部的引用:
- 基本数据类型对应的 Class 对象
- 常驻的异常对象 (比如 NullPointExcepiton、 OutOfMemoryError)
- 系统类加载器
什么时候回收?
JVM 二次标记,确认对象存活 (给对象一次自救的机会)
当通过可达性分析判断为 不可达的对象,也并不是 “非死不可” 而是 处于缓刑期,要真正的死亡需要。两次标记,避免误杀
第一次标记过程
当 进行可达分析 没有 发现与 GC roots 相连接的引用链,会进行标记
确认是否有必要进行自救
- 没必要:没有覆盖
finalize()
方法 或者finalize()
方法已经被虚拟机调用过 - 有必要:
- 会将该对象放置到 F-Queue 队列之中, 后续通过 finalizer 线程 去执行
finalize()
方法
- 会将该对象放置到 F-Queue 队列之中, 后续通过 finalizer 线程 去执行
第二次标记过程
当 通过调用 finalize()
方法 对象如果建立起与 GC Roots 的引用,则认为是自救成功
未自救成功的 会第二次打上标签
如何回收?
垃圾回收,基本都是基于分代收集理论 和 几种算法思想实现的
分代收集理论
- 弱分代假说: 绝大多数对象都是朝生夕灭的
- 强分代假说: 熬过越多次垃圾收集过程的对象就越难以消亡
- 跨代引用假说: 跨代引用相对于同代引用来说仅占极少数
弱分代假说
绝大多数对象都是朝生夕灭的。所以我们只需要关注 少量存活
对象,大部分对象都需要被回收:根据其概念 设计对应的 新生代
强分代假说
难以消亡的对象,使用较低的频率来回收这个区域: 根据其概念设计的 老年代
跨代引用假说
存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。 由于占用极少数,某个新生代对象存在跨代引用 。由于老年代对象难以消亡 ,可能造成新生代对象的晋升,这是跨代引用就消失了
所以 没必要为了少数情况,而进行扫描老年代
通过 在新生代上建立一个全局的数据结构(该结构被称为“记忆集”(Remembered Set)
) [后续介绍]
Java堆收集分类
更具分代理论的思想,对 垃圾收集,又进行分类
-
部分收集:
- 新生代收集
- Minor GC: 对 伊甸园区 (Eden)进行垃圾回收
- Young GC: 对 Eden区、S0、S1 整个年轻代进行垃圾回收
- 老年代收集
- Major GC/Old GC: 只是老年代的垃圾收集
- 新生代收集
-
整堆收集: Full GC 整堆收集 (堆 和 方法区) 垃圾收集器
对象的引用
在Java 1.2 之前 对象只会有两种状态:“被引用”和“未被引用” 但 在实际使用中 我们希望某些特殊的对象 如果 当内存空间足够时能够留在内存中 如果 当内存空间不足、或 进行垃圾后任空间不足时要进行抛弃的对象,为了完善这些使用场景,对引用进行了扩展,分为4中引用,从强到弱
- 强引用:
Object obj = new Object()
;- 只要是强引用在,就是垃圾回收时,也不会被回收,永远不会被回收
- 软引用
SoftReference<Object> sf = new SoftReference<Object>(obj)
;- 用来做一些没必要的对象,可以用来实现本地缓存
- 当程序将要抛出 内存溢出(OutOfMemoryError)之前,会被回收 【内存足够时,不会被回收】
- 弱引用
WeakReference<Object> wf = new WeakReference<Object>(obj)
;- 只能存活到下一次垃圾回收发生为止 【无论内存是否足够,都会被回收】
- 虚引用:
PhantomReference<Object> pf = new PhantomReference<Object>(obj)
;- 称为“幽灵引用”或者“幻影引用”,在任何时候都可能被回收
- 作用:为了能在这个对象被收集器回收时收到一个系统通知
垃圾回收算法
当我们通过可达性分析法来定位对象是否存活后,我们就需要通过某种策略把这些已死的对象进行清理、然后对存活的对象进行整理,这个过程就涉及到三种算法,分别为标记清除法、标记复制法、标记整理法。
标记 - 清除 (老年代)
标记清除法:相对来说简单,总共分为2个阶段
- 从 GC Roots 节点开始进行扫描,对所有存活的对象进行标记,将其记录为可达对象
- 对整堆进行扫描,如果发现某个对象未被标记为可达对象,那么就将其回收
缺点
- 执行效率不稳定问题:标记 和 清除随着对象数量的增长 且 大量需要被回收时,效率降低
- 内存空间碎片化问题: 清除之后会产出大量的内存碎片。当需要分配大对象是找不到可用的空间时,则会提前触发一次垃圾回收
标记 - 复制 (新生代)
将整个 内存分为 两个区域,每次只有一个区域内的空间能进行使用。 标记复制的过程:
- 当一个内存区域,内存放满后,进行标识那些是存活对象
- 将存活的对象,复制到另一块内存区域中
- 将之前使用过的内存区域一次清理
优点
- 解决标记清除算法面对大量可回收对象时执行效率低的问题 只关注存活的对象空间
缺点
- 需要提前预留一半的内存区域来存放存活的对象,由于存放区域变小了,更容易GC
- 存活对象多的时候,复制对象较多,会对应用程序的吞吐量有影响
- 当 99%都存存活,则进行了全部复制操作
标记 - 整理 (老年代)
与 标记 - 清除算法的之前步骤类似,只是在 回收的步骤上进行了对象移动的步骤 让所有存活的对象都向内存空间一端移动, 然后直接清理掉边界以外的内存
优点
- 解决了 标记 - 清除 内存空间碎片化的问题
- 解决了 标记 - 复制 50%内存空间的浪费问题
- 整体吞吐量提高:
- 相对于 不移动对象,存在大量内存碎片,会导致大对象创建空间不足,GC次数增多,导致应用总GC时间增加,吞吐量降低
- 计算公式: 吞吐量 = 应用总执行时间 - GC总时间 / 应用执行总时间
缺点
- STW时间增加:由于移动存活对象,当大量对象存活,移动操作会更复杂,需要暂停用户线程
JVM 中对象
对象创建过程
步骤
- 先检测 常量池中是否存在,不存在则需要先执行类加载过程(类加载过程)
- 为新生对象分配内存 (内存分配的方式)
- 指针碰撞 (假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离)
- “空闲列表” : ,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例 (而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上[1]就只能采用较为复杂的空闲列表来分配内存。)
并发问题解决方式
- 采用
CAS
+失败重试
的方式保证更新操作的原子性 - 本地线程分配缓冲
(Thread Local Allocation Buffer,TLAB)
:在对象创建时,在当前线程中分配一块本地缓冲区中分配
对象内存的布局
对象访问定位方式
-
句柄访问:
-
直接指针访问
直接指针访问 速度更快,中间减少了一次句柄 需要通过句柄池 获取 真正的的对象指针的过程
对象分配策略
堆内存空间介绍
对象分配策略
- 对象优先在Eden区分配
- 大对象直接进入老年代
- 长期存活的对象将进入老年代
- 动态对象年龄判定
- 空间分配担保
空间分配担保
规则:
- 老年代最大可用连续空间 > 新生代对象总大小
- 老年代最大可用连续空间 > 历次晋升的平均大小
- 不满足: 进行 Full GC
- 满足:
- 进行Minor GC
- 判断 Survivor 空间是否有用
- 不足: 进入老年代
- 足够: 进入 Survivor 空间
垃圾收集器
垃圾算法 是 内存回收的方法论,那么垃圾收集器 就是内存回收的实践者
我们可以将收集器分为几大类
- 新生代收集器
- Serial
- ParNew
- Parallel Scavenge
- 老年代收集器
- Serial Old
- Parallel Old
- CMS
- 整堆收集器/低延迟垃圾收集器
- G1
- Shenandoah
- ZGC
不断升级: 主要是为了降低用户线程因垃圾回收而导致长时间的停顿,影响用户体验 (减少 Stop The world 的时间) —> 虽然不断涌现新的收集器,用户线程的停顿时间也在持续缩短,但是任然没有办法彻底消失
详细升级原因
垃圾收集 | 提升了什么 | 有什么问题 |
---|---|---|
Serial/Serial old (串行收集器) | 串行执行,停顿用户线程时间过长 | |
preNew | 从单线程执行 变为 多线程执行,提高停顿时间,用户等待时间 | 并行执行效率提升,但是停顿时间还是未知,无法保证吞吐量 |
Parallel | 目标:达到一个可控制的吞吐量,降低用户等待的时间 | 无法降低用户停顿时长 缩短垃圾回收的时间,是通过增加GC次数 和 牺牲了新生代空间 |
CMS | 目标:尽可能地缩短垃圾收集时用户线程的停顿时间。在并发标记、并发清除阶段 可以与用户线程同时执行,减少用户线程停顿时间 | 由于并发执行: 对资源明敏感,会降低用户线程的吞吐量。 并发标记:会产生浮动垃圾使用标记-清除算法会产生空间碎片。 大内存的JVM回收时长,不可控 |
G1 | 设置可控的垃圾回收时间,使用 标记 - 整理,解决CMS的空间碎片问题 | G1解决跨代引用比较复杂,需要对每个Region创建 记忆集 会占有堆内大量空间 |
Shenandoah | 使用 连接矩阵 解决 跨代引用占用空间大的问题,通过 转向指针 实现 回收整理阶段 和 用户线程 并发执行 | 在并发回收是 会使用 读屏障 ,带来更大的性能开销 |
ZGC | 使用 染色指针 减少读屏障性能问题,不设置分代没有跨代引用问题 | 并发收集时间长,对象分配速率高,创建大量新对象,产生大量浮动垃圾 |
Serial /Serial Old 收集器
串行工作的单线程收集器,不能同用户线程同时运行,必须暂停用户线程,直到收集结束
- Serial (新生代收集): 使用 标记 - 复制算法
- Serial Old (老年代收集): 使用 标记 - 整理算法
Serial/Serial Old:
- 优点:单核处理或核心数较少的服务器使用,由于是串行收集没有线程交互小小,可以获得最高的单线程收集效率
- 缺点:单线程收集效率低,GC期间 Stop The World , 停顿时间长
Serial Old
- 在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用
- 作为 CMS 收集器 发生失败时的后备预案,在并发收集发生
Concurrent Mode Failure
时使用
启动配置
- -XX:-UseSerialGC
ParNew收集器
本质上是 Serial 收集器的多线程并行版本,除了支持多线程并行收集之外,其他与Serial 收集器相比并没有太多创新之处。 也是新生代收集器,同样基于
标记 - 复制
算法实现
图为: ParNew + Serial Old 收集
ParNew
可以配合:
- Serial Old 收集
- CMS
ParNew的优缺点
- 优点:支持多线程处理垃圾回收
- 缺点:在单核处理器的环境上 未必比 Serial 收集器效果好,由于存在多线程交互
配置参数
- 启用服务:**-XX:+UseParNewGC ** (新生代使用:ParNew 老年代使用:Serial Old )
- 设置回收线程数: -XX:ParallelGCThreads
Parallel Scavenge/Parallel Old收集器
Parallel Scavenge 新生代收集器, 类似于
ParNew
同样是 多线程并行收集 也是 基于标记 - 复制
算法, 只是关注的特性不一样而已. Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)
Parallel Old: 可以看作是 Parallel Scavenge 老年代收集器
Parallel Scavenge收集器 关注点 与 其他垃圾回收器不同?
- 其他回收器关注: 尽可能的缩短收集过程时用户线程的停顿时间
- Parallel Scavenge 关注: 服务在回收时能否保证一个可控的吞吐量
吞吐量 = 运行用户代码时间 / 运行用户代码时间 + 运行垃圾收集时间
核心参数
- 控制最大垃圾收集停顿时间: -XX:MaxGCPauseMillis (大于0的毫秒数)设置的过小,缩短收集时间,会导致新生代回收资源过少,导致频繁回收,使得降低吞吐量
- 设置吞吐量大小: -XX:GCTimeRatio (0~100的整数)
- 自适应的条件回收策略: -XX:+UseAdaptiveSizePolicy 设置该参数后
- 就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数
在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合
CMS收集器
使用在老年代 垃圾收集器,是一种获取最短回收停顿时间为目标的收集器,使用
标记 - 清除
· 算法实现
整个过程分为四部:
- 初始标记
- 会产生 STW、耗时短、只遍历与GC Roots 直接关联的对象
- 并发标记
- 不会 STW , 和用户线程并发执行、耗时长、从初始标记的对象中进行遍历整个对象图
- 重新标记
- 会产生 STW、耗时短、修改并发标记期用户线程导致的标记变动记录
- 并发清除
- 不会STW, 和用户线程并发执行、产生浮动垃圾、并发失败
(Concurrent Mode Failure)
会启用备用方案,触发一次 Serial Old 进行收集,但这样停顿时间就更长了
- 不会STW, 和用户线程并发执行、产生浮动垃圾、并发失败
存在 Stop The World的步骤: 初始标记 、 重新标记
优点
- 提供并发收集,缩短回收停顿时间
缺点
- 对处理器资源非常敏感: 由于并发回收阶段 需要占用 25%的处理器资源,会降低对用户的响应
- 浮动垃圾: 并发阶段,对象引用发送变更,导致出现浮动垃圾
- 并发失败: 并发过程,用户线程使用空间不足,导致并发失败
- 空间碎片: 由于使用
标记 - 清除
算法,无法避免碎片空间的问题
并发失败
在并发阶段,由于用户线程需要运行,必须预留内存空间给 用户线程使用(JDK1.6 之后 CMS启动阀值 92%, 当预留空间不足是,会出现 “并发失败” (Concurrent Mode Failure)
并发失败,会执行后备方案 启动Serial Old 收集器重新进行收集
相关参数
- -XX: CMSInitiatingOccupancyFraction: CMS触发回收的内存占用百分比
- -XX:+UseCMS-CompactAtFullCollection: 是否在 Full GC 时 进行回收 内存碎片整理 (默认开启,JDk9 已失效)
并发标记策略
三色标记法
详见: 垃圾收集细节: GC Roots 之后的遍历对象,随着堆容量增大,停顿的时间增长,如何缩短后续时间
问题
,
浮动垃圾
并发标记的过程,由于对象引用发生变更,在 遍历 GC Roots 关联对象图时 会产生 浮动垃圾
解决方式:增量更新
跨代引用问题
详见: 垃圾收集细节: Minor GC 时,如何解决跨代引用,避免把整个老年代加进GC Roots扫描范围
问题
Garbage First收集器 (G1)
之前的收集器 要么针对 新生代(Minor GC) 、 要么 针对 老年代 GC (Major GC), 而 G1 面向堆内存(整堆)回收, 它主要是开创了收集器面向局部收集的设计思路和基于Region的内存布局形式
开启选项:-XX:+UseG1GC
Region
G1堆内存划分为多个大小相等的独立区域称为:Region, 每个 Region都可以扮演不同的区局
- E: (Eden空间)
- S: (Survivor空间)
- O: (老年代空间)
- H: (Humongous区域)
- 用于存放大对象
- G1 认为: 大小超过了一个Region容量一半的对象即可判定为大对象
-XX:G1HeapRegionSize
设定,取值范围为1MB~32MB
注: G1 并没有完全的不遵守 分代理论
G1回收策略:
G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小
- 价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region
相关JVM参数:
- -XX:+UserG1GC :在JDK8中可以通过手动指定使用G1收集器进行回收
- -XX:G1HeapRegionSize=size 指定每一个Region的大小
- -XX:MaxGCPauseMillis=time 指定收集的停顿时间,默认是200ms
跨代引用问题
在每个 Region 中维护这 记忆集 和 卡表 CMS 是跨代引用 而 G1 则是卡区域引用 实际上更复杂 并且 G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作
漏标问题
通过 原始快照
解决问题
为什么G1用SATB?CMS用增量更新?
SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。
G1 除了 写后屏障
维护卡表、还有 写前屏障
来跟踪并发时的指针变化情况。在执行负载上要高于 CMS,但 相对应 增量更新 ,原始快照: 能够减少并发标记和重新标记阶段的消耗。避免CMS那样在最终标记阶段停顿时间过长的缺点
执行过程
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
优点
- 可以设置停顿的时间
- 使用 标记 - 整理 算法,解决
CMS
的空间碎片问题 - 可以支持更大的内存 6G ~ 8G
缺点
- G1 需要记忆集 (具体来说是卡表)来记录新生代和老年代之间的引用关系,这种数据结构在 G1 中需要占用大量的内存,可能达到整个堆内存容量的 20% 甚至更多。而且 G1 中维护记忆集的成本较高,带来了更高的执行负载,影响效率
Shenandoah收集器
Shenandoah 并不是 Oracle 自行开发的 而是 RedHat 公司 开发 提供给 OpenJDK 中的, OracleJDK 是 被排除在外的。 我们可以将 Shenandoah 看做是 G1 的改进版本,从堆空间的内存分配 在初始标记、并发标记等许多阶段的处理思路上都高度一致,甚至还直接共享了一部分实现代码 G1就是由于合并了Shenandoah的代码才获得多线程Full GC的支持
核心修改
- 支持并发 - 整理 (G1 只支持多线程并行执行)
- 转发指针 + 读屏障
- 新增
连接矩阵
(解决G1 跨代引用 - 浪费大量内存和资源去维护记忆集)
执行流程
-
初始标记
-
并发标记
-
最终标记 (前3个阶段 和 G1 基本一样)
-
并发清除: 将整个区域内 没有一个存活对象都没有找到的Region 清除
-
并发回收:把回收集里面的存活对象先复制一份到其他未被使用的Region之中 (
存在 移动对象 和 用户使用对象同时对象的情况
) -
初始引用更新:复制完新对象后,进行
引用更新
, 把堆中所有指向旧对象的引用修正到复制后的新地址 -
并发引用更新:真正开始进行引用更新操作 (与用户线程并发执行)
-
最终引用更新:修改 正存在于
GC Roots
中的引用 (需要停顿) -
并发清理: 将讲过
并发回收
和引用更新
之后 , 剩余的 空闲 Region 进行再次清除回收过程
** 核心可以理解为3个大阶段: 并发标记
、并发回收
、并发引用更新
**
**连接矩阵 **
连接矩阵可以简单理解为一张二维表格,如果
Region N
有对象指向Region M
,就在表格的N行M列中打上一个标记
**Brooks Pointers **
Brooks Pointers (转发指针) 【实现对象移动与用户程序并发的一种解决方案】
历史上的一些方案:
保护陷阱 :当访问 被移动的就对象时, 可以使用 (设置保护陷阱(Memory Protection Trap)) 即预设异常处理器, 在通过代码逻辑 把访问转发到新的对象内存地址上 【这种方案将导致用户态频繁切换到核心态】
Shenandoah 使用方案:
转发指针
: 在原有的 对象头上 增加一个引用指针。
- 在正常
不处于并发移动
的情况下,该引用指向对象自己 - 在并发移动期间,该引用执行 新的对象地址
并发写问题
- 收集器线程复制了新的对象副本
- 用户线程更新对象的某个字段
- 收集器线程更新转发指针的引用值为新副本地址
并发写可能会导致,2,3 步骤出现修改顺序问题,导致 用户线程对对象的变更发生在旧对象上,Shenandoah
使用 CAS
进行保障并发写操作
优缺点
-
优点
-
不在设置分代收集
-
Shenandoah 摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率
-
-
缺点:
-
只支持 OpenJDK
-
高运行负担使得吞吐量下降;使用大量的读写屏障,尤其是读屏障,增大了系统的性能开销;
-
ZGC
ZGC收集器是一款基于Region内存布局的, (暂时) 不设分代的,使用了
读屏障
、染色指针
和内存多重映射
等技术来实现可并发的标记-整理
算法的,以低延迟为首要目标的一款垃圾收集器
ZGC也采用基于Region的堆内存布局 (在一些官方资料中将它称为Page或者ZPage),ZGC 下 将Region分为3大类容量
- 小型Region : 容量固定为2MB,用于放置小于256KB的小对象
- 中型Region: 容量固定为32MB,用于放置大于等于256KB但小于4MB的对象
- 大型Region: 容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象
- 容量 可能会 小于 中型 Region (最小值为4M)
- 大型Region 是不会被
重分配
- 因为复制一个大对象的代价非常高昂
并发 - 整理
通过
染色指针技术
染色指针是一种直接将少量额外的信息存储在指针上的技术
ZGC 通过64指针的 前18位不能用于寻址 ,所以用其剩余的 46位的高4位用户记录信息: 三色标记的状态、是否进入重分配集(即 被移动过)、 是否通过 finalize() 方法才能被访问到
染色指针的劣势
- 不支持32位平台
- 不支持压缩指针
- ZGC 能够管理的内存不可以超过 4TB (2的42次幂)
染色指针的三大优势
-
对象移动后,里面可以释放旧对象的空间
-
大幅减少在垃圾收集过程中内存屏障的使用数量
- ZGC现在还不支持分代收集,天然就没有跨代引用的问题
- ZGC只使用了读屏障
-
可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能
-
即想办法使用 前18位不能用于寻址的指针的空间
-
读屏障
当对象从堆中加载的时候,就会使用到读屏障(Load Barrier)。这里使用读屏障的主要作用就是检查指针上的三色标记位,根据标记位判断出对象是否被移动过,如果没有可以直接访问,如果移动过就需要进行“自愈”(对象访问会变慢,但也只会有一次变慢),当“自愈”完成后,后续访问就不会变慢了。
读写屏障可以理解成对象访问的“AOP”操作
ZGC的工作流程
- 并发标记
- 并发标记是遍历对象图做可达性分析的阶段
- 其中 也会有 G1 的初始标记、最终标记 存在短暂的停顿
- 并发预备重分配
- 这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些
Region
, 将这些Region
组成重分配集(Relocation Set)
- 相对于 G1 优先级回收 维护 优先级回收集合 而 ZGC 每次扫描所有Region 通过 大范围的查询 换取 G1 中记忆集的维护成本
- 这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些
- 并发重分配 (核心功能)
- 重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上, 并为重分配集中的每个Region维护一个
转发表(Forward Table)
,记录从旧对象到新对象的转向关系。 - 通过预置的的内存屏障,获取对象的访问记录,进行转移到新对象上,并同时修正更新该引用的值 (只有第一次慢,不用像G1 每次都需要进行转换)
- 重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上, 并为重分配集中的每个Region维护一个
- 并发重映射
- 重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用 (个人理解是将没有改变引用的,进行改变引用并回收内存空间)
- 由于 存在
自愈
所以并不是需要立即触发的操作(最多就是慢一下点) - 所以 ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成, (减少 遍历所有对象图的开销)
垃圾收集细节
可达性分析 必须
Stop The world
(暂停用户线程,保证标记记录期间对象的引入关系不在发生变化),那虚拟机又是通过什么手段进行缩短停顿时间???
如何提高 GC Roots 根节点的停顿时间?
确认
GC Roots
根节点 的过程为了保证其准确性,需要停顿用户线程 (Stop The World
)现在服务器的配置越来越高,可以使用的内存也更多. 停顿的时间也会更长 ,如何缩短停顿时间?
使用 空间换时间的方式,引入 OopMap
映射表 ,将栈中的 对象的引用关系记录在 映射表中
-
为了提升 GC Roots 的遍历效率,缩短 Stop The Wold 时间
-
帮助 HotSpot 实现 准确式 GC
写入OOPMap的时机
- 类加载完成时候,HotSpot就把对象内什么偏移量是什么类型数据计算出来,对象引用自然也算出来
- JIT编译,也会在特定位置记录下栈和寄存器的哪些位置是引用;
引入 oopMap,空间成本问题?
如果将所有的引用关系的指令都写入
oopMap
会产生大量的额外存储空间如何减少 存储成本??
引入 安全点
概念,只有在 安全点 才会记录 oopMap
安全点位置的选定 (选定特点: 是否具有让程序长时间执行的特征)
-
方法调用前
-
循环跳出的尾部
-
异常抛出位置
-
方法返回前
如何保证所有线程都在安全点
- 抢占式中断:
- 当垃圾收集发生时,系统首先会中断所有用户线程,再进行判断 中断地点是否在安全点,如果不在恢复该线程执行 直到跑到安全点上,在进行中断
- 主动式中断:
(JVM使用的方式)
- JVM设置一个标志位
- 当发生回收时,线程会判断是否执行到了标志位,如果已经到达,则进行中断用户线程
如果线程处于 Sleep 或 Blocked 状态,根本走不到安全点,怎么办
引入
安全区域
指的是一段代码片段中,每当抵达安全区域,会先标识自己进入安全区域,不会阻止垃圾收集的发生(即在安全区域 是可以被垃圾收集器处理的
过程:
- 当用户线程 执行到安全区域的代码,会进行标识
- 垃圾收集器 不会去管 在安全区域的线程
- 线程离安全区: 会判断是否完成 根节点枚举的操作
- 完成:则继续执行
- 否则: 必须一直等待,直到收到可以离开安全区域的信号为止
Minor GC 时,如何解决跨代引用,避免把整个老年代加进GC Roots扫描范围
使用
记忆集
用于记录从非收集区域指向收集区域的指针集合JVM 中 使用
卡表”(Card Table)
的方式去实现记忆集
CARD_TABLE 是一个字节数组,每一个元素都对应着其标识的内存区域中一块特定大小的内存块,称为 卡页
每个 Card page
卡页对应的 是一个 内存区域,只要卡页 内存区域 中有一个对象存在跨代引用,则会标识为 1,称为 脏页
,没有 则标识为 0。
垃圾回收时,我们只需要筛选 变脏 的元素 对对其内存区域中的跨代指针,进行一并加入 GC Roots 扫描
JVM 如何维护卡表
虚拟机通过
写屏障(Write Barrier)
技术维护卡表状态。 (这里的写屏障
其实是虚拟机的AOP切面编程)
写屏障 又分为
写前屏障
: 在赋值前的部分的写屏障写后屏障
: 在赋值后的写屏障
即: 当引用对象赋值后增加了更新卡表的操作
GC Roots 之后的遍历对象,随着堆容量增大,停顿的时间增长,如何缩短后续时间
使用
并发的可达性分析 - 三色分析法
把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:
-
白色: 尚未访问
-
黑色: 对象已经访问过, 其 对象 引用到 的其他对象 也全部访问过了
-
灰色: 对象已经访问过, 而 对象 引用到 的其他对象 尚未全部访问完
- 当全部访问后,灰色 转为 黑色
三色标记遍历过程 假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:
- 初始时,所有对象都在 【白色集合】中;
- 将GC Roots 直接引用到的对象 挪到 【灰色集合】中;
- 从灰色集合中获取对象:.
- 将本对象 引用到的 其他对象 全部挪到 【灰色集合】中;
- 将本对象 挪到 【黑色集合】里面。
- 重复步骤3,直至【灰色集合】为空时结束。
- 结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收。
由于是并发标记,期间用户线程也会执行,在标记期间,对象的引用关系会发生改变 可能会出现 多标
和 漏标
- 浮动垃圾(多标):将原本应该被清除的对象,误标记为存活对象。后果是垃圾回收不彻底,不过影响不大,可以在下个周期被回收;
- 对象消失(漏标):将原本应该存活的对象,误标记为需要清理的对象。后果很严重,影响程序运行,是不可容忍的。
漏标必须要同时满足以下两个条件:
- 赋值器插入了一条或者多条从黑色对象到白色对象的新引用;
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
解决漏标的方式
- 增量更新:黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次
(CMS)
- 原始快照:当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次
(G1)
为什么G1用SATB?CMS用增量更新?
SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。
扩展
- JVM三色标记 (讲解比较详细)
5. 高效并发
计算机的内存模型
由于计算机CPU的运行速度 和 存储系统读取的速度差距,基本上大量的时间花费在
磁盘I/O
、网络 或 数据库 上。为了不让CPU长时间的等待。使用多任务进行去压榨
CPU的性能
提升方式
- CPU高速缓存(作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了)
- CPU指令重排序(CPU会对指令进行乱序执行,使处理器内部的运算单元能尽量被充分利用,)
引入的问题
- 缓存一致性:不同核的中的内存数据不一致
- 解决方案:MESI 协议等
- CPU指令重排序:虽然能保证乱序执行 和 顺序执行的结果一致,但并不能保证 相互依赖的计算任务的中间结果
- 禁止指令重排序的 关键字
Java内存模型
- 主内存:所有的变量都存储在主内存(
不包括局部变量与方法参数
) - 工作内存:每个线程都有自己的工作内存,线程对变量的读写操作必须在工作内存中进行,不能直接读取主内存
不通线程之间无法访问对方的工作内存 : 每个线程的工作内存是线程私有的
Java内存模型的8种操作lock (锁定): 作用于主内存的变量,把一个变量标识为一条线程独占的状态
unlock (解锁): 作用于主内存的变量,释放一个属于锁定状态的变量
read (读取): 作用于主内存的变量,把一个变量从主内存传输到线程的工作内存中
load (载入): 作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中
use (使用): 作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个【需要使用变量的值】的字节码指令是执行
assign (赋值): 作用于工作内存的变量,把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个 【给变量赋值的】字节码指令时执行
store (存储): 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中
write (写入): 作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
操作时必须满足规则
- 不允许
read和load
、store和write
操作之一单独出现 (一组命令中不要求连续,中间可以插入其他指令) - 线程执行了
assign
操作 ,变量发送了变化,就必须同步回主内存 - 未执行
assign
操作,不允许 执行store、write
操作,将工作内存中的变量同步会主内存 - 变量只能在主内存中“诞生” (use前必须要 load)
- 一个变量 同一时刻,只允许一条线程进行
lock
、unlock
操作(但操作可以重复执行多次) - 执行
lock
操作时,会清空工作内存中的值 - 没有被
lock
的变量,不允许进行unlock
操作。只允许 对自己lock
的变量unlock
unlock
操作前,必须将变量同步回主内存
并发的三大特性
原子性
一个操作是不可中断的,要么全部执行成功要么全部执行失败
保持原子性:
- Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store、write
- 虚拟机提供了更高层次的字节码指令
monitorenter
和monitorexit
来隐式地使用 (lock
,unlock
)操作 ---->(对应synchronized
关键字)
可见性
当多线程访问同一个变量时,一个线程修改了变量的值,其他线程能立即看到改变后的变量值
保证可见性的方式:
- Volatile:
- 每次使用变量都必须从主内存刷新最新的值
- 每次修改变量后都必须立即同步回主内存
- synchronized: 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中
- final 可见性:final修饰的字段一旦被初始化,其他线程就能看见final字段的值
有序性
重排序不是必然会出现的,但是出现重排序会导致线程安全问题,保证有序性,避免重排序从而保证线程中的所有操作都是有序执行s
保证有序性:
- volatile: 关键字本身就包含了禁止指令重排序的语义
- synchronized: 则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作
线程安全
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的
简单的说: 多线程访问对象 或 方法时,不需要额外的处理,程序也不会出错,就叫 线程安全
锁优化
锁粗化
如果对连续的操作 或 对同一个对象进行反复加锁 和 解锁 操作。导致不必要的性能损耗,对于这种情况进行锁粗化
# 粗化前
for(int i=0;i<100000;i++){
synchronized(this){
do();
}
# 粗化后
synchronized(this){
for(int i=0;i<100000;i++){
do();
}
}
```
锁消除
虚拟机 即时编译器在运行时, 会对使用了同步代码进行 逃逸分析,如果发现不会出现共享数据竞争(线程安全)问题,则会对锁消除
// 优化前
public void f() {
Object oliver = new Object();
synchronized(oliver) {
System.out.println(oliver);
}
}
// 优化后
public void f() {
Object oliver = new Object();
System.out.println(oliver);
}
自旋锁
很多时候,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,我们可以让线程执行一个忙循环(自旋),这就是自旋锁
实现方式
- CAS
- 自适应自旋
- 自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的
- 如果:自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功
- 如果:对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,
优缺点
- 优点:
- 乐观锁,减少对资源的堵塞的
- 减少线程上下文切换的开销
- 缺点:
- 长时间的自旋,会大量销毁CPU资源 (
JAVA默认自旋十次
-XX:PreBlockSpin) - ABA问题
- 长时间的自旋,会大量销毁CPU资源 (
偏向锁
偏向锁就是在无竞争的情况下把整个同步都消除掉, 连CAS操作都不去做了
偏向锁(启用参数-XX:+UseBiased Locking,这是自JDK 6 起HotSpot虚拟机的默认值)
当对象第一次被线程获取时:
- 会设置偏向状态 【1】,并通过 CAS操作,在对象头(23bit) 空间 这个锁的线程的ID记录
当对象被同一个线程获取时:
- 线程可以重复进入
当对象处于偏向状态,其他线程进行请求时:
- 偏向锁会撤销,并膨胀为重量级锁
轻量级锁
轻量级锁并不是用来代替重量级锁的,它设计的初衷是在 没有多线程竞争的前提下,减少传统的 重量级锁 使用操作系统 互斥量产生的性能消耗
- 在无竞争的情况下使用CAS操作去消除同步使用的互斥量
- 把锁对象的 Mark Word 信息拷贝到线程自己的内存中,Mark Word 用指针指向这 个displaced-mark-word,当下一个线程再来干这件事时,因为使用了 CAS 所以就会失败,此时再把锁改为重量级锁
6. 相关参数
基本内存分配参数
参数 | 介绍 | 例 |
---|---|---|
-xms | 堆初始值 | |
-xmx | 堆的最大值 | |
-xmn | 年轻代大小 相当于 -XX:newSize = -XX:MaxnewSize = -xmn | |
-XX:newSize | 新生代初始内存的大小,应该小于-Xms的值 | |
-XX:MaxnewSize | 新生代内存的最大上线 | |
-Xss | 每个线程的栈大小 | |
-XX:PermSize | 设置永久代初始值 | |
XX:MaxPermSize | 设置永久代最大值 | |
-XX:MetaspaceSize | 设置元空间初始值 | |
-xx:MaxMetaspaceSize | 设置元空间的最大值 | |
-XX:MaxDirectMemorySize | 直接内存大小 | |
-XX:NewRatio | 设置新生代 和 老年代的比例 | -XX:NewPatio=4 , 标识新生代占1 , 老年代占4 , 新生代占整个堆的1/5 |
-XX:SurvivorRatio | eden 与 Suvivor 比例 | -XX:NewPatio=8 标识eden占8 From 和 To 各一份 |
-XX:MaxTenuringThreshold | 设置对象晋升的最大年龄 |
垃圾回收配置参数