技术

数据湖 高性能计算与存储 Linux2.1.13网络源代码学习 《大数据经典论文解读》 三驾马车学习 Spark 内存管理及调优 Yarn学习 从Spark部署模式开始讲源码分析 容器狂占内存资源怎么办? 多角度理解一致性 golang io使用及优化模式 Flink学习 c++学习 学习ebpf go设计哲学 ceph学习 学习mesh kvm虚拟化 学习MQ go编译器以及defer实现 学习go 为什么要有堆栈 汇编语言 计算机组成原理 运行时和库 Prometheus client mysql 事务 mysql 事务的隔离级别 mysql 索引 坏味道 学习分布式 学习网络 学习Linux go堆内存分配 golang 系统调用与阻塞处理 Goroutine 调度过程 重新认识cpu mosn有的没的 负载均衡泛谈 单元测试的新解读 《Redis核心技术与实现》笔记 《Prometheus监控实战》笔记 Prometheus 告警学习 calico源码分析 对容器云平台的理解 Prometheus 源码分析 并发的成本 基础设施优化 hashicorp raft源码学习 docker 架构 mosn细节 与微服务框架整合 Java动态代理 编程范式 并发通信模型 《网络是怎样连接的》笔记 go channel codereview gc分析 jvm 线程实现 go打包机制 go interface及反射 如何学习Kubernetes 《编译原理之美》笔记——后端部分 《编译原理之美》笔记——前端部分 Pilot MCP协议分析 go gc 内存管理玩法汇总 软件机制 istio流量管理 Pilot源码分析 golang io 学习Spring mosn源码浅析 MOSN简介 《datacenter as a computer》笔记 学习JVM Tomcat源码分析 Linux可观测性 学习存储 学计算 Gotty源码分析 kubernetes operator kaggle泰坦尼克问题实践 kubernetes扩缩容 神经网络模型优化 直觉上理解深度学习 如何学习机器学习 TIDB源码分析 什么是云原生 Alibaba Java诊断工具Arthas TIDB存储——TIKV 《Apache Kafka源码分析》——简介 netty中的线程池 guava cache 源码分析 Springboot 启动过程分析 Spring 创建Bean的年代变迁 Linux内存管理 自定义CNI IPAM 共识算法 spring redis 源码分析 kafka实践 spring kafka 源码分析 Linux进程调度 让kafka支持优先级队列 Codis源码分析 Redis源码分析 C语言学习 《趣谈Linux操作系统》笔记 docker和k8s安全访问机制 jvm crash分析 Prometheus 学习 Kubernetes监控 容器日志采集 Kubernetes 控制器模型 容器狂占资源怎么办? Kubernetes资源调度——scheduler 时序性数据库介绍及对比 influxdb入门 maven的基本概念 《Apache Kafka源码分析》——server Kubernetes类型系统 源码分析体会 《数据结构与算法之美》——算法新解 Kubernetes源码分析——controller mananger Kubernetes源码分析——apiserver Kubernetes源码分析——kubelet Kubernetes介绍 ansible学习 Kubernetes源码分析——从kubectl开始 jib源码分析之Step实现 jib源码分析之细节 线程排队 跨主机容器通信 jib源码分析及应用 为容器选择一个合适的entrypoint kubernetes yaml配置 《持续交付36讲》笔记 mybatis学习 程序猿应该知道的 无锁数据结构和算法 CNI——容器网络是如何打通的 为什么很多业务程序猿觉得数据结构和算法没用? 串一串一致性协议 当我在说PaaS时,我在说什么 《数据结构与算法之美》——数据结构笔记 PouchContainer技术分享体会 harbor学习 用groovy 来动态化你的代码 精简代码的利器——lombok 学习 《深入剖析kubernetes》笔记 编程语言那些事儿 rxjava3——背压 rxjava2——线程切换 spring cloud 初识 《深入拆解java 虚拟机》笔记 《how tomcat works》笔记 hystrix 学习 rxjava1——概念 Redis 学习 TIDB 学习 如何分发计算 Storm 学习 AQS1——论文学习 Unsafe Spark Stream 学习 linux vfs轮廓 《自己动手写docker》笔记 java8 实践 中本聪比特币白皮书 细读 区块链泛谈 比特币 大杂烩 总纲——如何学习分布式系统 hbase 泛谈 forkjoin 泛谈 看不见摸不着的cdn是啥 《jdk8 in action》笔记 程序猿视角看网络 bgp初识 calico学习 AQS——粗略的代码分析 我们能用反射做什么 web 跨域问题 《clean code》笔记 《Elasticsearch权威指南》笔记 mockito简介及源码分析 2017软件开发小结—— 从做功能到做系统 《Apache Kafka源码分析》——clients dns隐藏的一个坑 《mysql技术内幕》笔记 log4j学习 为什么netty比较难懂? 递归、回溯、动态规划 apollo client源码分析及看待面向对象设计 学习并发 docker运行java项目的常见问题 OpenTSDB 入门 spring事务小结 分布式事务 javascript应用在哪里 《netty in action》读书笔记 netty对http2协议的解析 ssl证书是什么东西 http那些事 苹果APNs推送框架pushy apple 推送那些事儿 编写java框架的几大利器 java内存模型和jvm内存布局 java exception Linux IO学习 netty内存管理 测试环境docker化实践 netty在框架中的使用套路 Nginx简单使用 《Linux内核设计的艺术》小结 Go并发机制及语言层工具 Linux网络源代码学习——数据包的发送与接收 《docker源码分析》小结 docker namespace和cgroup zookeeper三重奏 数据库的一些知识 Spark 泛谈 链式处理的那些套路 netty回顾 Thrift基本原理与实践(二) Thrift基本原理与实践(一) 回调 异步执行抽象——Executor与Future Docker0.1.0源码分析 java gc Jedis源码分析 深度学习泛谈 Linux网络命令操作 JTA与TCC 换个角度看待设计模式 Scala初识 向Hadoop学习NIO的使用 以新的角度看数据结构 并发控制相关的硬件与内核支持 systemd 简介 quartz 源码分析 基于docker搭建测试环境(二) spring aop 实现原理简述 自己动手写spring(八) 支持AOP 自己动手写spring(七) 类结构设计调整 分析log日志 自己动手写spring(六) 支持FactoryBean 自己动手写spring(九) 总结 自己动手写spring(五) bean的生命周期管理 自己动手写spring(四) 整合xml与注解方式 自己动手写spring(三) 支持注解方式 自己动手写spring(二) 创建一个bean工厂 自己动手写spring(一) 使用digester varnish 简单使用 关于docker image的那点事儿 基于docker搭建测试环境 分布式配置系统 JVM执行 git maven/ant/gradle/make使用 再看tcp kv系统 java nio的多线程扩展 《Concurrency Models》笔记 回头看Spring IOC IntelliJ IDEA使用 Java泛型 vagrant 使用 Go常用的一些库 Python初学 Goroutine 调度模型 虚拟网络 《程序员的自我修养》小结 Kubernetes存储 访问Kubernetes上的Service Kubernetes副本管理 Kubernetes pod 组件 Go基础 JVM类加载 硬币和扑克牌问题 LRU实现 virtualbox 使用 ThreadLocal小结 docker快速入门

架构

controller-runtime细节分析 finops学习 kubevela多集群 kubevela中cue的应用 基于k8s的工作流 容器和CPU那些事儿 kubevela源码分析 数据集管理fluid 应用管理平台kubevela karmada支持crd 多集群及clusternet学习 AutoML和AutoDL 特征平台 实时训练 分布式链路追踪 helm tensorflow原理——python层分析 如何学习tensorflow 数据并行——allreduce 数据并行——ps 机器学习中的python调用c 机器学习训练框架概述 embedding的原理及实践 tensornet源码分析 大模型训练 X的生成——特征工程 tvm tensorflow原理——core层分析 模型演变 《深度学习推荐系统实战》笔记 keras 和 Estimator tensorflow分布式训练 分布式训练的一些问题 基于Volcano的弹性训练 图神经网络 pytorch弹性分布式训练 从混部到统一调度 RNN pytorch分布式训练 CNN 《动手学深度学习》笔记 pytorch与线性回归 多活 volcano特性源码分析 推理服务 kubebuilder 学习 mpi 学习pytorch client-go学习 tensorflow学习 提高gpu 利用率 GPU与容器的结合 GPU入门 AI云平台梳理 tf-operator源码分析 k8s批处理调度 喜马拉雅容器化实践 Kubernetes 实践 学习rpc BFF openkruise学习 可观察性和监控系统 基于Kubernetes选主及应用 《许式伟的架构课》笔记 Admission Controller 与 Admission Webhook 发布平台系统设计 k8s水平扩缩容 Scheduler如何给Node打分 Scheduler扩展 controller 组件介绍 openkruise cloneset学习 controller-runtime源码分析 pv与pvc实现 csi学习 client-go源码分析 kubelet 组件分析 调度实践 Pod是如何被创建出来的? 《软件设计之美》笔记 mecha 架构学习 Kubernetes events学习及应用 CRI 资源调度泛谈 业务系统设计原则 grpc学习 元编程 以应用为中心 istio学习 下一代微服务Service Mesh 《实现领域驱动设计》笔记 概率论 serverless 泛谈 《架构整洁之道》笔记 处理复杂性 那些年追过的并发 服务器端编程 网络通信协议 架构大杂烩 如何学习架构 《反应式设计模式》笔记 项目的演化特点 反应式架构摸索 函数式编程的设计模式 服务化 ddd反模式——CRUD的败笔 研发效能平台 重新看面向对象设计 业务系统设计的一些体会 函数式编程 《左耳听风》笔记 业务程序猿眼中的微服务管理 DDD实践——CQRS 项目隔离——案例研究 《编程的本质》笔记 系统故障排查汇总及教训 平台支持类系统的几个点 代码腾挪的艺术 abtest 系统设计汇总 《从0开始学架构》笔记 初级权限系统设计 领域驱动理念入门 现有上传协议分析 移动网络下的文件上传要注意的几个问题 推送系统的几个基本问题 做配置中心要想好的几个基本问题 不同层面的异步 分层那些事儿 性能问题分析 当我在说模板引擎的时候,我在说什么 用户认证问题 资源的分配与回收——池 消息/任务队列

标签

controller-runtime细节分析 finops学习 kubevela多集群 kubevela中cue的应用 基于k8s的工作流 容器和CPU那些事儿 kubevela源码分析 数据集管理fluid 应用管理平台kubevela karmada支持crd 多集群及clusternet学习 helm 从混部到统一调度 volcano特性源码分析 kubebuilder 学习 client-go学习 tf-operator源码分析 k8s批处理调度 喜马拉雅容器化实践 Kubernetes 实践 openkruise学习 基于Kubernetes选主及应用 Admission Controller 与 Admission Webhook k8s水平扩缩容 Scheduler如何给Node打分 Scheduler扩展 controller 组件介绍 openkruise cloneset学习 controller-runtime源码分析 pv与pvc实现 csi学习 client-go源码分析 kubelet 组件分析 调度实践 Pod是如何被创建出来的? Kubernetes events学习及应用 CRI 资源调度泛谈 如何学习Kubernetes 以应用为中心 kubernetes operator kubernetes扩缩容 serverless 泛谈 自定义CNI IPAM docker和k8s安全访问机制 Kubernetes监控 Kubernetes 控制器模型 Kubernetes资源调度——scheduler Kubernetes类型系统 Kubernetes源码分析——controller mananger Kubernetes源码分析——apiserver Kubernetes源码分析——kubelet Kubernetes介绍 Kubernetes源码分析——从kubectl开始 kubernetes yaml配置 CNI——容器网络是如何打通的 当我在说PaaS时,我在说什么 《深入剖析kubernetes》笔记 Kubernetes存储 访问Kubernetes上的Service Kubernetes副本管理 Kubernetes pod 组件

java gc

2016年06月17日

简介

进程不同的区域存储不同性质的数据,除了程序计数器区域不会OOM外,其它的都有可能因为存储本区域数据过多而OOM。有一个梗:说在食堂里吃饭,吃完把餐盘端走清理的是 C++ 程序员,吃完直接就走的是 Java 程序员。清理工作由保洁来做,这里的“保洁”就是gc

Garbage collection is the JVM’s process of freeing up unused Java objects in the Java heap.The Java heap is where the objects of a Java program live. It is a repository for live objects, dead objects, and free memory. When an object can no longer be reached from any pointer in the running program, it is considered “garbage” and ready for collection.

垃圾回收技术降低了程序员的心智负担,将程序员从繁重的内存管理工作中解放出来,这才使得淘宝这样的大型应用成为可能。但是随着业务越来越复杂,GC 算法中固有的停顿造成业务卡顿等问题也变得越来越严重。在一些时延敏感型业务中,业务响应时间和 GC 停顿的矛盾就更加突出。所以,理解 GC 算法的基本原理并对其加以优化,是现代 Java 程序员的一项必备技能。

性能/为什么要关注GC

Java中9种常见的CMS GC问题分析与解决评判 GC 的两个核心指标:

  1. 延迟(Latency):也可以理解为最大停顿时间,即垃圾收集过程中一次 STW 的最长时间,越短越好,一定程度上可以接受频次的增大,GC 技术的主要发展方向。
  2. 吞吐量(Throughput):应用系统的生命周期内,由于 GC 线程会占用 Mutator 当前可用的 CPU 时钟周期,吞吐量即为 Mutator 有效花费的时间占系统总运行时间的百分比,例如系统运行了 100 min,GC 耗时 1 min,则系统吞吐量为 99%,吞吐量优先的收集器可以接受较长的停顿。

目前各大互联网公司的系统基本都更追求低延时,避免一次 GC 停顿的时间过长对用户体验造成损失。简而言之,即为一次停顿的时间不超过应用服务的 TP9999,GC 的吞吐量不小于 99.99%。举个例子,假设某个服务 A 的 TP9999 为 80 ms,平均 GC 停顿为 30 ms,那么该服务的最大停顿时间最好不要超过 80 ms,GC 频次控制在 5 min 以上一次。如果满足不了,那就需要调优或者通过更多资源来进行并联冗余。

新一代垃圾回收器ZGC的探索与实践很多低延迟高可用Java服务的系统可用性经常受GC停顿的困扰。GC停顿指垃圾回收期间STW(Stop The World),当STW时,所有应用线程停止活动,等待GC停顿结束。以美团风控服务为例,部分上游业务要求风控服务65ms内返回结果,并且可用性要达到99.99%。但因为GC停顿,我们未能达到上述可用性目标。当时使用的是CMS垃圾回收器,单次Young GC 40ms,一分钟10次,接口平均响应时间30ms。通过计算可知,有( 40ms + 30ms ) * 10次 / 60000ms = 1.12%的请求的响应时间会增加0 ~ 40ms不等,其中30ms * 10次 / 60000ms = 0.5%的请求响应时间会增加40ms。为了降低GC停顿对系统可用性的影响,一般从降低单次GC时间和降低GC频率两个角度出发进行了调优。

ZGC和G1在HBase集群中的GC性能对比我们的某些核心业务要求 100ms 内返回结果,并且可用性要达到 99.9%甚至 99.99%,我们做过数据请求测试,持续用一个 rowKey 来循环请求 HBase 集群,统计查询耗时,一直无法满足 99.9%的查询目标,而且,在耗时查询发生的相同时间点,也伴随着 GC 的发生。单次 GC 的停顿,可能是导致我们在这种查询场景下,出现耗时查询的最大元凶。降低单次 GC 的时间和降低 GC 发生的频率,可能会进一步提升我们集群的查询性能。

java垃圾收集器的历史

目前在 Hotspot VM 中主要有分代收集和分区收集两大类,未来会逐渐向分区收集发展。从 JDK 8 到 JDK 18,Java 垃圾回收的十次进化

垃圾回收器是如何演进的?

  1. Serial(串行)收集器
  2. Parallel(并行)收集器
  3. CMS(并发)收集器
  4. copy gc
  5. G1(并发)收集器,G1 的垃圾回收是分代的,整个堆分成一系列大小相等的分区(Region),分区回收。5张图带你彻底理解G1垃圾收集器
  6. ZGC,单代垃圾回收器,全并发的标记-复制算法(在标记、转移和重定位阶段几乎都是并发的) 无暂停 GC。新一代垃圾回收器ZGC的探索与实践与go gc 很相似

gc 算法梳理

从JVM 堆空间分配对象时, 必须锁定整个堆,以便不会被其它线程影响。为了解决这个问题,jvm 为每个线程分配一个TLAB 缓冲区,当尝试分配一个对象时,优先从当前线程的TLAB 中分配对象。 不同线程的TLAB 都位于Eden 区, 所有的TLAB 对所有的线程都是可见的,只不过每个线程有一个TLAB 数据结构,用于保存待分配内存区域的起始和结束地址,在分配的时候只在这个区间做分配,从而达到无锁分配。 若从 TLAB 分配不成功,则进入慢速分配,即从堆中分配对象。

有分配就有回收,还是自动回收。极客时间《深入拆解Java虚拟机》垃圾回收的三种方式

  1. 标记-清除,将死亡对象占据的内存标记为空闲,回收被标记的对象。
  2. 标记-压缩/整理,将存活的对象聚在一起(比如内存的一端),需要将所有对象的引用指向新位置,工作量和存活对象量成正比。
  3. 标记-复制,将内存两等分,同一时间只利用其中一块来存放对象。说白了是一个以空间换时间的思路。
  4. 分代算法。基于一个假说(Generational Hypothesis):绝大多数对象都是朝生夕灭的。
    1. 基于 copy 的垃圾回收算法比较适合管理短生命周期对象,Mark-Sweep 算法适合管理长生命周期对象。为了发挥两种算法的优点,GC 的开发者就基于对象的生命周期引入了分代垃圾回收算法。
    2. 怎么区分那些存活时间长的对象呢?可以在对象的头部记录一个名为 age 的变量,存活的对象往 Survivor 空间中复制一次,我们就相应把这个对象的 age 加一,以此来代表它的生命周期比较长。
    3. 记录集:维护跨代引用。可以想象,把对象放到两个空间,肯定会有跨空间的对象引用。显然,这就会带来一个问题,年轻代 GC 执行时,我们就要考虑从老年代到年轻代的引用了。反过来说,当老年代 GC 执行时,也同样要考虑从年轻代到老年代的引用。gc还是会对全部对象进行遍历,分代就没意义了。
  5. 增量算法
  6. 并发算法。

前三种是最基础的算法,标记阶段是把所有活动对象都做上标记的阶段,有对象头标记和位图标记(bitmap marking)这两种方式。标记-清除算法执行完成后,会让堆出现碎片化,后面两种可以视为是对标记-清除算法中「清除」阶段的优化,最后面三种是对前面三种算法在某些方面的改进。

大概脉络:为了高效 ==> 大部分生命周期短:分代/分区 ==> 对象移动 ==> STW ==> 着色指针 + 内存屏障保证读取/标记一致性

整体思路

Java虚拟机浅谈——垃圾收集器与内存分配策略早在1960年的时候,MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。当Lisp还在胚胎时期时,人们就在思考GC需要完成的三件事情:

  1. 哪些内存需要回收?
  2. 什么时候回收?分配对象时发生回收;外部调用,比如System.gc;
  3. 如何回收?

jdk8 内存结构

GC 主要工作在 Heap 区和 MetaSpace 区(上图蓝色部分)。堆的结构:Java 堆主要分为 2 个区域-年轻代与老年代,其中年轻代又分 Eden 区和 Survivor 区,其中 Survivor 区又分 From 和 To 2个区。当Eden区的空间满了,Java虚拟机会触发一次Minor GC,以收集新生代的垃圾,存活下来的对象会转移到surviror区。YGC回收之后,大多数的对象会被回收,活着的进入s0,再次YGC,活着的对象eden + s0 -> s1,再次YGC,eden + s1 -> s0。如果对象在Eden出生,并经过第一次Minor GC后仍然存活,并且被Survivor容纳的话,年龄设为1,每熬过一下Minor Gc(YGC)年龄+1,若年龄超过一定限制( -XX:MaxTenuringThreshold 15,CMS 6),则被晋升到老年代(如果没有 Survivor/幸存者 区,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满)。老年代满了而无法容纳更多的对象就会进行Full GC,Full GC经常会伴随至少一次Minor GC,比Minor GC 慢10倍以上。 堆的总大小由Xmx 控制,新生代与老年代大小比例由 -XX:NewRatio 控制。

可以使用jstat 查看jvm 各种状态信息,下例可以看到 EU(eden使用)快接近了EC(eden大小),可以考虑扩大下新生代大小。

// 每个2s 打印下jvm 各区的大小,打印3次
jstat -gc <pid> 2s 3
 S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT
153600.0 153600.0 1421.7  0.0   1228800.0 481956.1 6144000.0   96211.3   78376.0 75475.7 8892.0 8317.7    150    3.789   4      0.464    4.253
153600.0 153600.0 1421.7  0.0   1228800.0 922349.3 6144000.0   96211.3   78376.0 75475.7 8892.0 8317.7    150    3.789   4      0.464    4.253
153600.0 153600.0  0.0   1322.9 1228800.0 120407.9 6144000.0   96211.4   78376.0 75475.7 8892.0 8317.7    151    3.809   4      0.464    4.273

部分实现细节

如何判断对象已经死亡

垃圾可以分成语义垃圾和语法垃圾两种,语义垃圾(Semantic Garbage)是计算机程序中永远不会被程序访问到的对象或者数据;语法垃圾(Syntactic Garbage)是计算机程序内存空间中从根对象无法达到(Unreachable)的对象或者数据。 语义垃圾是不会被使用的的对象,可能包括废弃的内存、不使用的变量,垃圾收集器无法解决程序中语义垃圾的问题。

对象是否存活,是由整体应用其它部分是否对其有引用决定的。

  1. 引用计数法。记录对象被引用的次数。
    1. 底层原理:垃圾回收算法是如何设计的?基于引用计数法的 GC,天然带有增量特性(incremental),GC 可与应用交替运行,不需要暂停应用;同时,在引用计数法中,每个对象始终都知道自己的被引用数,当计数器为0时,对象可以马上回收,而在可达性分析类 GC 中,即使对象变成了垃圾,程序也无法立刻感知,直到 GC 执行前,始终都会有一部分内存空间被垃圾占用。
    2. 使用了引用计数的地方,就会存在循环引用。GC实例:Python和Go的内存管理机制是怎样的? 可以看下python 如何解决 循环引用。
  2. 可达性分析算法。将对象之间的引用关系抽象成图这种数据结构以后,我们就可以使用图算法来研究垃圾回收问题了。以一系列GC Roots对象作为起点,从这写节点向下检索,当GC Roots到这些对象不可达时,则证明此对象是不可用的。实现起来比较简单。其缺点在于 GC 期间,整个应用需要被挂起(STW,Stop-the-world),后面很多此类算法的提出,都是在解决这个问题(缩小 STW 时间)。

真正的工业级实现一般是这两类算法的组合,但是总体来说,基于可达性分析的 GC 还是占据了主流,究其原因

  1. 首先,引用计数算法无法解决「循环引用无法回收」的问题,即两个对象互相引用,所以各对象的计数器的值都是 1,即使这些对象都成了垃圾(无外部引用),GC 也无法将它们回收。
  2. 当然上面这一点还不是引用计数法最大的弊端,引用计数算法最大的问题在于:计数器值的增减处理非常繁重,譬如对根对象的引用,此外,多个线程之间共享对象时需要对计数器进行原子递增/递减,这本身又带来了一系列新的复杂性和问题,计数器对应用程序的整体运行速度的影响。

GC Roots

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。因为这一点,引发了“变量不用时,是否要专门为其赋值为null” 的讨论,参见Java中当对象不再使用时,不赋值为null会导致什么后果 ?,即局部变量作用域虽然已经结束,但仍存在于运行时栈中(方法还未运行结束),被GC 判断为对象仍然是存活的。
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

新生代的gc和老年的代的gc回收的区域是不一样,那么这里的GC Root是不是应该不一样呢?肯定是不一样的。

jvm 从GC roots出发标记所有存活的对象,然后遍历对象的每一个字段进行标记, 直到所有的对象都标记完毕。在分代GC 中,新生代和老年代处于不同的收集阶段,假设我们只收集新生代,按照上述方法老年代的对象也会被标记,却没有收集老年代,浪费时间。仅收集老年代时也会碰到同样问题,当且仅当我们要进行Full GC才需要全部标记。 所以算法设计者做了一个设计,用一个RSet 记录从非收集部分指向收集部分的指针的集合。比如 老年代对象到 新生代对象的引用关系记录下来,在Young GC的时候有两种根,一种是栈空间/全局变量空间的引用,另外一个就是老生代分区到新生代分区的引用。PS:这里还涉及到卡表等辅助数据结构

copy gc

《编程高手必学的内存知识》Scavenge:基于copy的垃圾回收算法copy 内存管理的分配与回收。PS:jvm接管了 堆内存的分配与回收

  1. (from区的)内存分配:只需要记录一个头部指针,有分配空间的需求就把头部指针向后移就可以了。因为后一个对象是顶着前一个对象分配的,所以,这种方式也叫做碰撞指针。
  2. 内存回收:就是下面的copy gc逻辑了。 copy 算法每一次都会搬移对象,在搬移的过程中就已经完成了内存的整理。所以对象与对象之间是没有空隙的,也就是说没有内存碎片。
  3. 五个特点:没有内存碎片、分配效率高、回收效率取决于存活对象比例(搬移的是活跃对象)、总的内存利用率不高和需要暂停(需要搬移对象) ==> 比较适合管理短生命周期对象。PS:STW 一个理由

每一个 Java 对象都有一个字段记录该对象的类型。我们把描述 Java 类型的结构称为 Klass。Klass 中记录了该类型的每一个字段是值类型,还是对象类型。因此,我们可以根据对象所关联的 Klass 来快速知道,对象体的哪个位置存放的是值字段还是引用字段。如果是引用字段,并且该引用不是 NULL,那么就说明当前对象引用了其他对象。这样从根引用出发,就可以构建出一张图了。进一步地,我们通过图的遍历算法,就可以找到所有被引用的活对象。很显然,没有遍历到的对象就是垃圾。

考虑到有两个对象 A 和 B,它们都引用了对象 C,而且它们都是活跃对象,现在我们对这个图进行深度优先遍历。在遍历过程中,A 先拷到 to space,然后 C 又拷过去。A 和 C 都拷到新的空间里了,原来的引用关系还是正确的。但我们的算法在拷贝 B 对象的时候,先完成 B 的拷贝,然后你就会发现,此时我们还会把 C 再拷贝一次。这样,在 To 空间里就会有两个 C 对象了,这显然是错的。我们必须要想办法解决这个问题。

  1. 通常来说,在一般的深度优先搜索算法中,我们只需要为每个结点增加一个标志位 visited,以表示这个结点是否被访问过。但这只能解决重复访问的问题,还有一件事情我们没有做到:新空间中 B 对象对 C 对象的引用没有修改。这是因为我们在对 B 进行拷贝的时候,并不知道它所引用的对象在新空间中的地址。
  2. 解决这个问题的办法是使用 forwarding 指针。也就是说每个对象的头部引入一个新的域(field),叫做 forwarding。正常状态下,它的值是 NULL,如果一个对象被拷贝到新的空间里以后,就把它的新地址设到旧空间对象的 forwarding 指针里。当我们访问完 B 以后,对于它所引用的 C,我们并不能知道 C 是否被拷贝,更不知道它被拷贝到哪里去了。此时,我们就可以在 C 上留下一个地址,告诉后来的人,这个地址已经变化了,你要找的对象已经搬到新地方了,请沿着这个新地址去寻找目标对象。PS: 移动gc 都免不了forwarding 指针

copy gc 伪代码

void copy_gc() {
    for (obj in roots) {
        *obj = copy(obj);
    }          
}
obj * copy(obj) {
    if (!obj.visited) {
        new_obj = to_space.allocate(obj.size);
        copy_data(new_obj, obj, size);
        obj.visited = true;
        obj.forwarding = new_obj;
        for (child in obj) {
            *child = copy(child);
        }
    }
    return obj.forwarding;
}

这样一来,我们就借助深度优先搜索算法完成了一次图的遍历。Scavenge:基于copy的垃圾回收算法 还详细分析了广度优先遍历和深度优先遍历的优缺点。

Scavenge 算法:基于 copy 的算法要将堆空间分成两部分:一部分是 From 空间,一部分是 To 空间。不管什么时刻,总有一半空间是空闲的。所以,它总体的空间利用率并不高。为了提升空间的利用率,Hotspot 在实现 copy 算法时做了一些改进。它将 From 空间称为 Eden 空间,To 空间在算法实现中则被分成 S0 和 S1 两部分,这样 To 空间的浪费就可以减少了。Hotspot 的内存管理器在 Eden 空间中分配新的对象,每次 GC 时,如果将 S0 做为 To 空间,则 S1 与 Eden 合起来成为 From 空间。PS: 复制算法From/To 和 Eden/S1/S2 就串起来了

Mark-Sweep

Scavenge:基于copy的垃圾回收算法 Mark-Sweep 算法

  1. 分配,与 malloc 分配算法类似,都是用一个链表将所有的空闲空间维护起来,这个链表就是空闲链表(freelist)。当内存管理器需要申请内存空间时,便向这个链表查询,如果找到了合适大小的空闲块,就把空闲块分配出去,同时将它从空闲链表中移除。如果空闲块比较大,就把空闲块进行分割,一部分用于分配,剩余的部分重新加到空闲链表中。不同之处在于:在 Hotspot 里,当一个空闲块分配给一个新对象后,如果剩余空间大于 24 字节,便将剩余的空间加入到空闲链表,当剩余空间不足 24 字节的话,就做为碎片空间填充一些无效值。而 malloc 则会分成多个空闲链表进行管理。
  2. 回收,由 Mark 和 Sweep 两个阶段组成。
    1. 在 Mark 阶段,从根引用出发,根据引用关系进行遍历,所有可以遍历到的对象就是活跃对象。如何对活跃对象进行标记呢?一种是在每个对象前面增加一个机器字,采用其中的某一位作为“标记位”。如果该位置位,就表示这个对象是活跃对象;如果该位未置位,那么表示这个对象是要回收的对象。另一种方法是采用标记位图,将每一个机器字映射成位图中的一个比特。真正的实现中,往往会采用两个位图,其中一个标记活跃对象的起始位置,另一个标记活跃对象的结束位置。
    2. 在 Sweep 阶段,回收器遍历整个堆,然后将未被标记的区域回收,重新将它们放回空闲链表中。这个阶段的操作是比较简单的,只涉及了链表的添加操作。因为搬移/回收的是垃圾对象,如果垃圾对象比较少,回收阶段所做的事情就比较少。所以它适合于存活对象多,垃圾对象少的情况。

Rset 管理

记录集:维护跨代引用。如何维护记录集呢?写屏障(write barrier)

  1. 在引用计数法中,在对象的引用发生变化的时候,来维护对象的引用计数。
  2. 在分代式垃圾回收算法中
    1. 当对象的属性进行写操作时,跨代引用就有可能出现,在这个时候可以检查是否存在跨代引用。写屏障的伪代码如下所示:
       void do_oop_store(Value obj, Value* field, Value value) {
           // 被引用的对象是否在年轻代;发出引用的对象,也就是引用者,是否在老年代
           if (in_young_space(value) && in_old_space(obj) &&
               !rs.contains(obj)) {
           rs.add(obj);
           }
           *field = &value;
       }
      
    2. 晋升可能产生跨代引用,假设 A 对象引用 B 对象,它们都在年轻代里,经过一次年轻代 GC,A 对象晋升到老年代,那这也会产生跨代引用。晋升的完整伪代码,如下所示:
       void promote(obj) {
           new_obj = allocate_and_copy_in_old(obj);
           obj.forwarding = new_obj;
                  
           for (oop in oop_map(new_obj)) {
               if (in_young_space(oop)) {
               rs.add(new_obj);
               return;
               }
           }
       }
      

在上面所讲的写屏障实现里,一个对象写操作中要进行三个判断,如果条件成立,还要再执行一次记录集的添加对象操作,效率是比较差的。为了提升效率,又有人提出了 Card table 这种实现方式来提升写屏障的效率。随着对象的增多,记录集会变得很大,而且每次对老年代做 GC,正确地维护记录集也是一件复杂的事情。另外,写屏障的效率也不高,为了解决这个问题,可以借鉴位图的思路,这就是 Card table。

Hotspot 的分代垃圾回收将老年代空间的 512bytes 映射为一个 byte,当该 byte 置位,则代表这 512bytes 的堆空间中包含指向年轻代的引用;如果未置位,就代表不包含。这个 byte 其实就和位图中标记位的概念很相似,人们称它为 card。多个 card 组成的表就是 Card table。一个 card 对应 512 字节,压缩比达到五百分之一,Card table 的空间消耗还是可以接受的。PS:Card table维护的是老年代到年轻代的跨代引用

安全点/如何实现STW

为了实现STW,JVM 设计了安全点safepoint,当Mutator 到达这个位置时放弃cpu 执行,让VMThread/GC线程进入执行。安全点是mutator 主动进入的,而且进入的方式不一样

  1. G1线程,比如refine线程,并发标记线程
  2. 执行解释代码的线程
  3. 执行编译代码的线程
  4. 一直运行的本地代码线程
  5. 从本地代码返回执行Java 代码的线程

并发标记

《JVM G1源码分析和调优》并发标记的主要问题是垃圾回收器在标记对象的过程中,Mutator可能正在改变对象引用关系图,从而造成漏标和错标。错标不会影响程序的正确性, 只会造成所谓的浮动垃圾,但漏标会导致可达对象被当做垃圾收集掉, 从而影响程序的正确性。所以,早期的 Mark-Sweep 算法会让业务线程都停下来。这就是 GC 停顿产生的原因。为了减少 GC 停顿,我们可以在做 GC Mark 的时候,让业务线程不要停下来。这意味着 GC Mark 和业务线程在同时工作,这就是并发(Concurrent)的 GC 算法。

Java虚拟机——三色标记法与垃圾回收器(CMS、G1)

三色标记

底层原理:垃圾回收算法是如何设计的?可达性分析类 GC 都属于「搜索型算法」(标记阶段经常用到深度优先搜索),这一类算法的过程可以用 Edsger W. Dijkstra 等人提出的三色标记算法(Tri-color marking)来进行抽象(算法详情可以参考论文:On-the-fly Garbage Collection:An Exercise in Cooperation)。三色标记算法背后的首要原则就是把堆中的对象根据它们的颜色分到不同集合里面,这三种颜色和所包含的意思分别如下所示:

  1. 白色:还未被垃圾回收器标记的对象
  2. 灰色:自身已经被标记,但其拥有的成员变量还未被标记
  3. 黑色:自身已经被标记,且对象本身所有的成员变量也已经被标记

直觉上一边对图进行遍历,一边修改图中的边,这样肯定会产生问题。图的遍历过程,就是不断地对结点进行搜索和扩展的过程。以标记算法来说,如果采用广度优先遍历,那么搜索就是对结点进行标记,扩展就是将结点所引用的其他对象都添加到辅助队列中。我们以广度优先搜索的标记算法为例,白色对象就是未被标记的对象;灰色则是已经被标记,但还没有完成扩展的对象,也就是还在队列中的对象;黑色则是已经扩展完的对象,即从队列中出队的对象。PS: 深度遍历的辅助结构是栈。引入三色标记是为了更形象的表述/定义漏标,就好比红黑两色来表达红黑树的约束一样

在 GC 开始阶段,刚开始所有的对象都是白色的,在通过可达性分析时,首先会从根节点开始遍历,将 GC Roots 直接引用到的对象 A、B、C 直接加入灰色集合,然后从灰色集合中取出 A,将 A 的所有引用加入灰色集合,同时把 A 本身加入黑色集合。最后灰色集合为空,意味着可达性分析结束,仍在白色集合的对象即为 GC Roots 不可达,可以进行回收了。

在标记对象是否存活的过程中,对象间的引用关系是不能改变的,这对于串行 GC 来说是可行的,因为此时应用程序处于 STW 状态。对于并发 GC 来说,在分析对象引用关系期间,对象间引用关系的建立和销毁是肯定存在的,如果没有其他补偿手段,并发标记期间就可能出现对象多标(假设 C 被标为灰色后,在进行下面的标记之前,A 和 C 之间的引用关系解除了)和漏标(对象 C 在被标记为灰色后,对象 C 断开了和对象 E 之间的引用,同时对象 A 新建了和对象 E 之间的引用。在进行后面的标记时,因为 C 没有对 E 的引用,所以不会将 E 放到灰色集合,虽然 A 重新引用了 E,但因为 A 已经是黑色了,不会再返回重新进行深度遍历了)的情况。

多标不会影响程序的正确性,只会推迟垃圾回收的时机,漏标会影响程序的正确性,需要引入读写屏障来解决漏标的问题(在几乎所有gc算法中都用到了)。GC 里的读屏障(Read barrier)和写屏障(Write barrier)指的是程序在从堆中读取引用或更新堆中引用时,GC 需要执行一些额外操作,其本质是一些同步的指令操作,在进行读/写引用时,会额外执行这些指令。

对象在并发标记阶段会被漏标的充分必要条件是:

  1. Mutator 插入了一个从黑色对象到白色对象的新引用。因为黑色对象已经被标记, 如果不对黑色对象重新处理,那么白色对象将被漏标,造成错误。
  2. Mutator 删除了所有从灰色对象到白色对象的直接或间接引用。因为灰色对象正在标记,字段引用的对象还没有被标记,如果这个引用的白色对象被删除了(引用发生了变化),那么这个引用对象也可能被漏标。PS没懂

分代算法:基于生命周期的内存管理要避免对象的漏标,只要打破上诉两个条件的任意一个即可,所以有三种不同的实现。PS:反正不能让黑色直接引用白色

  1. 往前进,当黑色对象引用白色对象时,把白色对象直接标灰。也就是说将 被引用 对象直接标记,然后放入队列中待扩展。
  2. 往后退一步,当黑色对象引用白色对象时,把黑色结点变成灰色,也就是把引用对象放入队列中待扩展。
  3. CMS 的实现和上面的两种思路有关系,但又不完全一样:在 write barrier 中,只要一个对象 A 引用了另外一个对象 B,不管 A 和 B 是什么颜色的,都将 A 所对应的 card 置位。
  4. (G1使用)当对象 B 对 C 的引用关系消失以后,再将 C 标记为灰色,即便将来 A 对 C 的引用消失了,也会在当前 GC 周期内被视为活跃对象。我们把这种在删除引用的时候进行维护的屏障叫做 deletion barrier。这种做法的特点是,在 GC 标记开始的一瞬间,活跃的对象(ABC)无论在标记期间发生怎样的变化,都会被认为是活跃的对象。就好像在 GC 开始的瞬间,内存管理器为所有活跃对象做了一个快照一样。因为写屏障的逻辑是由业务线程执行的,如果都放在写屏障中做,会极大地影响程序性能,为了解决这个问题,GC 开发者将“C 对象标记为灰色”这件事情往后推迟了。业务线程只需要把 C 对象记录到一个本地队列中就可以了。每个业务线程都有一个这样的线程本地队列,它的名字是 SATB 队列。当业务线程发现对象 C 的引用被删除之后,直接将 C 放到 SATB 队列中,并不去做标记,真正做标记的工作交给 GC 线程去做,这样就减少了写屏障的开销。GC 线程则会将 SATB 队列集合中的对象标记为灰色,至于什么时候标记,并不需要业务线程关心。

屏障

在 CMS 中,写屏障主要有两个作用:

  1. 在并发标记阶段解决活跃对象漏标问题;wirte barrier 主要是通过拦截写动作,在对象赋值时加入额外操作。
  2. 在写屏障里使用 card table 维护跨代引用。

注意:JVM 里还有另外一组内存屏障的概念:读屏障(Load Barrier)和写屏障(Store Barrier),这两组指令和上面我们谈及的屏障不同,Load Barrier 和 Store Barrier主要用来保证主缓存数据的一致性以及屏障两侧的指令不被重排序。

分区回收算法/G1

JVM中的G1垃圾回收器

  1. Java垃圾回收器是一种“自适应的、分代的、停止—复制、标记-清扫”式的垃圾回收器。
  2. 在G1中没有物理上的Young(Eden/Survivor)/Old Generation,它们是逻辑的、使用一些非连续的区域(Region)组成的。一个新生代的Region gc 后成为可用Region,可能会被作为老年代Region

G1 GC:分区回收算法说的是什么? 以下为整理,未细读。

在分代算法的启发下,人们进一步想,如果把对象分到更多的空间中,根据内存使用的情况,每一次只选择其中一部分空间进行回收不就好了吗?根据这个思路,GC 开发者设计了分区回收算法。比如说 Hotspot 中的 G1 GC 就是分区回收算法的一种具体实现,G1 的老年代和年轻代不再是一块连续的空间,整个堆被划分成若干个大小相同的 Region,也就是区。Region 的类型有 Eden、Survivor、Old、Humongous 四种,而且每个 Region 都可以单独进行管理。目的是在进行收集时不必在全堆范围内进行,在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代。G1会通过一个合理的计算模型,计算出每个Region的收集成本并量化,这样一来,收集器在给定了“停顿”时间限制的情况下,总是能选择一组恰当的Regions作为收集目标,让其收集开销满足这个限制条件,以此达到实时收集的目的。

Humongous 是用来存放大对象的,如果一个对象的大小大于一个 Region 的 50%(默认值),那么我们就认为这个对象是一个大对象。为了防止大对象的频繁拷贝,我们可以将大对象直接放到 Humongous 中。Eden、Survivor、Old 三种区域 与上文类似。

G1 的垃圾回收模式有两种:分别是 young GC 和 mixed GC。

  1. young GC:只回收年轻代的 Region。
  2. mixed GC:回收全部的年轻代 Region,并回收部分老年代的 Region。

实际上,分区垃圾回收算法最大的特点是维护跨分区引用。CMS 的跨代引用和 G1 的跨区引用的原理是相同的。我们需要知道当一个 Region 需要被回收时,有哪些其他的 Region 引用了自己。相应地,为了加快定位速度,分区回收算法为每个 Region 都引入了记录集(Remembered Set,RSet),每个 Region 都有自己的专属 RSet。和 Card table 不同的是,RSet 记录谁引用了我,这种记录集被人们称为 point-in 型的,而 Card table 则记录我引用了谁,这种记录集被称为 point-out 型。

RSet 的维护策略,也就是说哪些引用关系需要加入到 RSet:

  1. 如果是同一个 Region 的对象,它们之间相互引用是不必维护的,这个很好理解,因为不存在跨 Region 的问题;
  2. 由年轻代 Region 出发到其他 Region 的,无论目标是年轻代还是老年代,这一类引用也都不用维护。因为结合 young GC 和 mixed GC 的策略可以知道,无论是什么回收模式,年轻代的全部 Region 都会被清理,这就意味着一定会对年轻代的所有对象进行遍历;
  3. 从 CSet 集合的 Region 出发指向其他 Region 的,也不需要维护,理由和第 2 点是一样的。

总的来说,RSet 需要维护的引用关系只有两种,非 CSet 老年代 Region 到年轻代 Region 的引用,和非 CSet 老年代 Region 到 CSet 老年代 Region 的引用。RSet 具体是何时被记录的呢?答案也是写屏障。G1 在 RSet 中记录的也是 card。比如 Region1 中的对象 A 引用了 Region2 的对象 B,那么对象 A 所对应的 card 就会被记录在 Region2 的 RSet 中(注意!不是 Region1 的 RSet)。在 G1 中,我们把这种 card 称为 dirty card。和 SATB 相似,业务线程也不是直接将 dirty card 放到 RSet 中的。而是在业务线程中引入一个叫做 dirty card queue(DCQ)的队列,在写屏障中,业务线程只需要将 dirty card 放入 DCQ 中,而不做非常细致的检查。接下来,GC 线程中,有一类特殊的线程,它们会从 DCQ 中找到这种 dirty card,然后再去做更精细的检查,只有确实不属于上面所描述的三种情况的跨区引用,才真正放到专属 RSet 中去。这一类特殊的线程就是 G1 GC 中的 Refine 线程。

无暂停 GC/ZGC

Pauseless GC:挑战无暂停的垃圾回收 无暂停 GC的停顿时间不会随着堆大小的增加而线性增加。以 ZGC 为例,它的最大停顿时间不超过 10ms ,注意不是平均,也不是随机,而是最大不超过 10ms 。

  1. ZGC 和 G1 有很多相似的地方,主体思想也是采用复制活跃对象的方式来回收内存。在回收策略上,它也同样将内存分成若干个区域,回收时也会选择性地先回收部分区域
  2. ZGC 与 G1 的区别在于:它可以做到并发转移(拷贝)对象。并发转移指的是在对象拷贝的过程中,应用线程和 GC 线程可以同时进行。前面我们介绍的垃圾回收算法,在进行对象转移时都是需要STW,而对象转移随着堆的增大,这个时间也会跟着增加。并发转移不需要很长时间的STW,是 ZGC 停顿时间很小的主要原因。

ZGC和G1在HBase集群中的GC性能对比CMS 新生代的 Young GC、G1 和 ZGC 都基于标记-复制算法,但算法具体实现的不同就导致了巨大的性能差异。标记-复制算法应用在 CMS 新生代(ParNew 是 CMS 默认的新生代垃圾回收器)和 G1 垃圾回收器中。标记-复制算法可以分为三个阶段:

  1. 标记阶段,即从 GC Roots 集合开始,标记活跃对象;
  2. 转移阶段,即把活跃对象复制到新的内存地址上;
  3. 重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上。

G1 的 Young GC 和 CMS 的 Young GC,其标记-复制全过程 STW

G1 的混合回收过程可以分为标记阶段、清理阶段和复制阶段。

  1. 标记阶段停顿分析
    1. 初始标记阶段,从 GC Roots 出发标记全部直接子节点的过程,该阶段是 STW 的。由于 GC Roots 数量不多,通常该阶段耗时非常短。
    2. 并发标记阶段,从 GC Roots 开始对堆中对象进行可达性分析,找出存活对象。该阶段是并发的,即应用线程和 GC 线程可以同时活动。并发标记耗时相对长很多,但因为不是 STW,所以我们不太关心该阶段耗时的长短。
    3. 再标记阶段,重新标记那些在并发标记阶段发生变化的对象。该阶段是 STW 的。因为对象数少,耗时也较短。
  2. 清理阶段停顿分析
    1. 清理阶段清点出有存活对象的分区和没有存活对象的分区,该阶段不会清理垃圾对象,也不会执行存活对象的复制。该阶段是 STW 的。因为内存分区数量少,耗时也较短。
  3. 复制阶段停顿分析
    1. 复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是 STW 的,其中内存分配通常耗时非常短,但对象成员变量的复制耗时有可能较长,这是因为复制耗时与存活对象数量与对象复杂度成正比。对象越复杂,复制耗时越长。

因此,G1 停顿时间的瓶颈主要是标记-复制中的转移阶段 STW。为什么转移阶段不能和标记阶段一样并发执行呢?主要是 G1 未能解决转移过程中准确定位对象地址的问题。ZGC 在标记、转移和重定位阶段几乎都是并发的,这是 ZGC 实现停顿时间小于 10ms 目标的最关键原因。

并发转移

ZGC 能够做到并发转移,背后有两大关键技术,分别是 read barrier 和 colored pointer。

如何能在应用线程修改对象引用关系的同时,GC 线程还能正确地转移对象,或者说 GC 线程将对象转移的过程中,应用线程是如何访问正在被搬移的对象呢?CMS 算法和 G1 算法都使用了 write barrier 来保证并发标记的完整性,防止漏标现象。ZGC 的并发标记也不例外,除此之外,ZGC 提升效率的核心关键在于并发转移阶段使用了 read barrier。当应用线程去读一个对象时,GC 线程刚好正在搬移这个对象。如果 GC 线程没有搬移完成,那么应用线程可以去读这个对象的旧地址;如果这个对象已经搬移完成,那么可以去读这个对象的新地址。那么判断这个对象是否搬移完成的动作就可以由 read barrier 来完成。

对象 a 和对象 b 都引用了对象 foo,当 foo 正在拷贝的过程中,应用线程 A 可以访问旧的对象 foo 得到正确的结果,当 foo 拷贝完成之后,应用线程 B 就可以通过 read barrier 来获取对象 foo 的新地址,然后直接访问对象 foo 的新地址。

如果这里只用 write barrier 是否可行?当 foo 正在拷贝的过程中,应用线程 A 如果要写这个对象,那么只能在旧的对象 foo 上写,因为还没有搬移完成;如果当 foo 拷贝完成之后,应用线程 B 再去写对象 foo,是写到 foo 的新地址,还是旧地址呢?如果写到旧地址,那么对象 foo 就白搬移了,如果写到新地址,那么又和线程 A 看到的内容不一样?所以使用 write barrier 是没有办法解决并发转移过程中,应用线程访问一致性问题,从而无法保证应用线程的正确性。因此,为了实现并发转移,ZGC 使用了 read barrier。

在大多数的应用中,读操作要比写操作多一个数量级,所以 read barrier 对性能更加敏感,这就要求 read barrier 要非常高效。为了达到这个目的,ZGC 采用了用空间换时间的做法,也就是染色指针(colored pointer)技术。

在 64 位系统下,当前 Linux 系统上的地址指针只用到了 48 位,寻址范围也就是 256T。但实际上,当前的应用根本就用不到 256T 内存。所以, ZGC 就借用了地址的第 42 ~ 45 位作为标记位(第 0 ~ 41 位共 4T 的地址空间留做堆使用),它将地址划分为 Marked0、Marked1、Remapped、Finalizable 四个地址视图(由于 Finalizable 与弱引用的实现有关系,我们这里只讨论前三个)。地址视图的巧妙之处就在于,一个在物理内存上存放的对象,被映射在了三个虚拟地址上。三个地址视图映射的是同一块物理内存,映射地址的差异只在第 42-45 位上。这样一个对象可以由三个虚拟地址访问,其访问的内容是相同的。

read barrier干了啥?当 foo 对象发生转移之后,对象 a 再访问 foo 时就会触发 read barrier。read barrier 会查找 forwarding table 来确定对象是否发生了转移,确定 foo 被转移到新地址 foo(new)之后,直接将这一次对 foo 的访问更改为 foo(new)。由于整个过程是依托于 read barrier 自动完成的,这个过程也叫“自愈”。PS:初步分析 read barrier 干活靠 forwarding table, forwarding table 的新增靠colored pointer。

回收原理

ZGC 虽然在实现上有十个左右的小步骤,但在总体思想上可以概括为三个核心步骤:Mark、Relocate 和 Remap。 Mark 阶段负责标记活跃对象、Relocate 阶段负责活跃对象转移、ReMap 阶段负责地址视图统一。

  1. Mark,ZGC 也不是完全没有 STW 的。在进行初始标记时,它也需要进行短暂的 STW。不过在这个阶段,ZGC 只会扫描 root,之后的标记工作是并发的。也正是因为这一点,ZGC 的最大停顿时间是可控的,也就是说停顿时间不会随着堆的增大而增加。在 GC 开始之前,地址视图是 Remapped。那么在 Mark 阶段需要做的事情是,将遍历到的对象地址视图变成 Marked0,也就是修改地址的第 42 位为 1。前面我们讲过,三个地址视图映射的物理内存是相同的,所以修改地址视图不会影响对象的访问。除此之外,应用线程在并发标记的过程中也会产生新的对象,,新分配的对象都认为是活的,它们地址视图也都标记为 Marked0。至此,所有标记为 Marked0 的对象都认为是活跃对象,活跃对象会被记录在一张活跃表中。而视图仍旧是 Remapped 的对象,就认为是垃圾。
  2. Relocate,主要任务是搬移对象:选择一块区域,将其中的活跃对象搬移到另一个区域;将搬移的对象放到 forwarding table。在 Relocate 阶段,应用线程新创建的对象地址视图标记为 Remapped。如果应用线程访问到一个地址视图是 Marked0 的对象,说明这个对象还没有被转移,那么就需要将这个对象进行转移,转移之后再加入到 forwarding table,然后再对这个对象的引用直接指向新地址,完成自愈。这些动作都是发生在 read barrier 中的,是由应用线程完成的。当 GC 线程遍历到一个对象,如果对象地址视图是 Marked0,就将其转移,同时将地址视图置为 Remapped,并加入到 forwarding table ;如果访问到一个对象地址视图已经是 Remapped,就说明已经被转移了,也就不做处理了。
  3. Remap,主要是对地址视图和对象之间的引用关系做修正。因为在 Relocate 阶段,GC 线程会将活跃对象快速搬移到新的区域,但是却不会同时修复对象之间的引用(请注意这一点,这是 ZGC 和以前我们遇到的所有基于 copy 的 GC 算法的最大不同)。这就导致还有大量的指针停留在 Marked0 视图。这样就会导致活跃视图不统一,需要再对对象的引用关系做一次全面的调整,这个过程也是要遍历所有对象的。不过,因为 Mark 阶段也需要遍历所有对象,所以,可以把当前 GC 周期的 Remap 阶段和下一个 GC 周期的 Mark 阶段复用。但是由于 Remap 阶段要处理上一轮的 Marked0 视图指针,又要同时标记下一轮的活跃对象,为了区分,可以再引入一个 Mark 标记,这就是 Marked1 标志。可以想象,Marked0 和 Marked1 视图在每一轮 GC 中是交替使用的。在 Remap 阶段,新分配对象的地址视图是 Marked1,如果遇到对象地址视图是 Marked0 或者 Remaped,就把地址视图置为 Marked1。这个过程结束以后,就完成了地址视图的调整,同时也完成了新一轮的 Mark。可以看到,Marked0 和 Marked1 其实是交替进行的,通过地址视图的切换,在应用线程运行的同时,默默就把活对象搬走了,把垃圾回收了。

其它

新一代垃圾回收器ZGC的探索与实践ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。

ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。

读屏障,是JVM向应用代码插入一小段代码的技术。当应用线程从(且仅从)堆中读取对象引用时,就会执行这段代码。ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。

着色指针和读屏障技术不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在ZGC中,只需要设置指针地址的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。

JVM 的设计理想是做到开箱即用,不用调优

内存泄漏

内存垃圾主要可以分为语义垃圾和语法垃圾两类,语义垃圾(semantic garbage),有些场景也被称为内存泄露,指的是从语法上可达(可以通过局部、全局变量被引用)的对象,但从语义上来讲他们是垃圾,垃圾回收器对此无能为力。另外一种内存垃圾就是语法垃圾(syntactic garbage),讲的是那些从语法上无法到达的对象,这些才是垃圾收集器主要的收集目标。

一个最简单的C的内存泄漏的例子:

char *ptr1 = (char *)malloc(10);
char *ptr2 = (char *)malloc(10);
ptr2 = ptr1;
free(ptr1)

一开始ptr2指向的那块内存发生了泄漏,没人用了,因为没有指针指向,用不了,却又回收不掉(内存管理数据结构,一直记录此块内存是被分配的)。

What is a PermGen leak?a memory leak in Java is a situation where some objects are no longer used by an application, but the Garbage Collector fails to recognize them as unused. This leads to the OutOfMemoryError if those unused objects contribute to the heap usage significantly enough that the next memory allocation request by the application cannot be fulfilled.

如何分析内存泄漏,精确到对象?用弱引用堵住内存泄漏 关注hprof工具, google 分析程序内存和cpu 使用的工具:gperftools

内存泄漏既可以是堆内也可以是堆外内存,还可以是PermGen/MetaspaceWhat is a PermGen leak?

如上图所示,如果一个对象 被另一个有效对象引用,则其Class 对象、Class 对象引用的ClassLoader对象、ClassLoader对象引用的所有归其加载的Class对象也将“可达”,可能导致不需要的Class无法被“卸载”。

用弱引用堵住内存泄漏

  1. 程序有内存泄漏的第一个迹象通常是它抛出一个 OutOfMemoryError,或者因为频繁的垃圾收集而表现出糟糕的性能。
  2. 用一个普通的(强)引用拷贝一个对象引用时,限制 referent 的生命周期至少与被拷贝的引用的生命周期一样长。如果不小心,将一个对象放入一个全局集合中的话,那么它可能就与程序的生命周期一样(也就是对对象引用的操作会影响对象的生命周期)。另一方面,在创建对一个对象的弱引用时,完全没有扩展 referent 的生命周期,只是在对象仍然存活的时候,保持另一种到达它的方法。
  3. 引用队列是垃圾收集器向应用程序返回关于对象生命周期的信息的主要方法。如果创建弱引用时将弱引用与引用队列关联,则当referent被回收时,gc会将弱引用加入到引用队列中。

规避GC

jvm 提供自动垃圾回收机制,但免费的其实是最贵的,一些追求性能的框架会自己进行内存管理。资源的分配与回收——池

  1. 对象池
  2. 堆外内存/offheap。JVM源码分析之堆外内存完全解读堆外内存是指不在 Java 堆中管理的用户数据,堆外内存其实并无特别之处,线程栈,应用程序代码,NIO缓存用的都是堆外内存。事实上在C或者C++中,你只能使用堆外内存/未托管内存,因为它们默认是没有托管堆(managed heap)的。PS:笔者之前总是把“堆外” 当成了jvm进程外内存。
    1. jvm 堆内内存或者堆内的对象 都包含 mark word 等数据,用于辅助线程争用、gc回收等功能实现。也因此,网络io 数据读取到堆内 时需要经过 “内核 ==> 进程用户态 ==> 堆内” 两次拷贝。没有第二次拷贝,io 数据(也就是byte[]) 在堆内是没有 mark word的。
    2. 广义的堆外内存包括jvm本身在运行过程中分配的内存、codecache、jni里分配的内存、DirectByteBuffer等,狭义的堆外内存主要指java.nio.DirectByteBuffer在创建的时候分配的内存。堆外内存则是纯粹的 byte[] 空间。

Java中9种常见的CMS GC问题分析与解决要分析 GC 的问题,先要读懂 GC Cause,即 JVM 什么样的条件下选择进行 GC 操作,具体 Cause 的分类可以看一下 Hotspot 源码:src/share/vm/gc/shared/gcCause.hpp 和 src/share/vm/gc/shared/gcCause.cpp 中,重点需要关注的几个GC Cause:

  1. System.gc(): 手动触发GC操作。
  2. CMS: CMS GC 在执行过程中的一些动作,重点关注 CMS Initial Mark 和 CMS Final Remark 两个 STW 阶段。
  3. Promotion Failure: Old 区没有足够的空间分配给 Young 区晋升的对象(即使总可用内存足够大)。
  4. Concurrent Mode Failure: CMS GC 运行期间,Old 区预留的空间不足以分配给新的对象,此时收集器会发生退化,严重影响 GC 性能。
  5. GCLocker Initiated GC: 如果线程执行在 JNI 临界区时,刚好需要进行 GC,此时 GC Locker 将会阻止 GC 的发生,同时阻止其他线程进入 JNI 临界区,直到最后一个线程退出临界区时触发一次 GC。

jdk8 MetaSpace 主要由 Klass Metaspace 和 NoKlass Metaspace 两大部分组成。

  1. Klass MetaSpace: 就是用来存 Klass 的,就是 Class 文件在 JVM 里的运行时数据结构,这部分默认放在 Compressed Class Pointer Space 中,是一块连续的内存区域,紧接着 Heap。Compressed Class Pointer Space 不是必须有的,如果设置了 -XX:-UseCompressedClassPointers,或者 -Xmx 设置大于 32 G,就不会有这块内存,这种情况下 Klass 都会存在 NoKlass Metaspace 里。
  2. NoKlass MetaSpace: 专门来存 Klass 相关的其他的内容,比如 Method,ConstantPool 等,可以由多块不连续的内存组成。虽然叫做 NoKlass Metaspace,但是也其实可以存 Klass 的内容。 如果 ClassLoader 不停地在内存中 load 了新的 Class,就会导致 MetaSpace OOM

Java 中堆的概念和 Linux 进程中堆的概念范围不同,不管是 Java 堆还是堆外内存,其本质还是在进程的堆空间分配的。进程堆是指进程中可以使用 malloc 和 free 进行分配和释放的一块用户态内存区域。而 Java 堆则专指创建普通 Java 对象的地方,这一段内存是由虚拟机所管理的。

引用

Understanding Java heap memory and Java direct memory

三个实例演示 Java Thread Dump 日志分析

Java 理论与实践: 用弱引用堵住内存泄漏

成为JavaGC专家(1)—深入浅出Java垃圾回收机制