简单介绍
什么是垃圾回收机制?
什么是垃圾回收机制?
Java语言的一个显著的特点就是引入了「垃圾回收机制」,使C++程序员最头疼的内存管理的问题迎刃而解,它使得Java程序员在编写程序的时候 不再需要考虑内存管理。
由于有个「垃圾回收机制」,Java中的对象不再有“作用域的概念,只有对象的引用才有“作用域”。
「垃圾回收器」通常是作为一个单独的低级别的线程运行,不可预知的情况下对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收,程序员不能实时的调用「垃圾回收器」对某个对象或所有对象进行垃圾回收(自动执行,且不可控)。
「回收机制」有分代复制垃圾回收和标记垃圾回收,增量垃圾回收。「垃圾回收器」通常是作为一个单独的低级别的线程运行,不可预知的情况下对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收,程序员不能实时的调用「垃圾回收器」对某个对象或所有对象进行垃圾回收(自动执行,且不可控)。
「回收机制」有分代复制垃圾回收和标记垃圾回收,增量垃圾回收。
「垃圾回收」可以有效的防止内存泄露,有效的使用可以使用的内存。
Java 堆的内存结构
关于Java 堆的内存结构的具体内容可以看我的这篇博客:从零开始的JVM学习--Java运行时数据区域
理解堆内存结构对垃圾回收机制有什么用?
Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。并且我们知道Java对象是存储在「堆」中的,所以Java 自动内存管理最核心的功能是「堆」内存中对象的分配与回收。
「堆」是「垃圾收集器」管理的主要区域,因此也被称为“垃圾堆”(GC
堆)。
程序计数器、虚拟机栈、本地方法栈随线程而生,也随线程而灭;栈帧随着方法的开始而入栈,随着方法的结束而出栈。 这几个区域的内存分配和回收都具有确定性,在这几个区域内不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。
而对于「堆」和「方法区」,我们只有在程序运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的 ,「垃圾收集器」所关注的正是这部分内存(也就是线程共享的几个区域)。
JDK1.7 及以前的堆结构
-
新生代
Eden
区- 两个
Survivor
区
-
老年代
-
永久代
JDK1.8 的堆结构
JDK1.8 开始,PermGen
(永久代)被 MetaSpace
(元空间)替代,元空间使用的是 直接内存 。
垃圾收集
哪些内存需要回收?
判断对象是否可以被回收
引用计数法
什么是引用计数法?
在对象头维护着一个 counter 计数器,对象被引用一次则计数器 +1;若引用失效则计数器 -1。当计数器为 0 时,就认为该对象无效了可以回收。
引用计数算法的缺点
- 需要额外的内存来计数
- 运行期间需要维护计数器,带来额外的开销
- 无法解决循环引用的问题
主流 JVM 为什么不用引用计数算法?
「引用计数算法」的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是主流的JVM
里没有选用「引用计数算法」来管理内存,主要是 因为它很难解决对象之间「循环引用」的问题 。
循环引用问题
在两个对象出现「循环引用」的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正是因为「循环引用」的存在,JVM
不使用「引用计数算法」。
public class Test {
public Object instance = null;
public static void main(String[] args) {
Test a = new Test();// 引用+1
Test b = new Test();// 引用+1
a.instance = b;// 引用+1
b.instance = a;// 引用+1
a = null;// 引用-1
b = null;// 引用-1
doSomething();
// a、b均不可能再被访问到,但是引用计数器为1,无法被回收。
}
}
a 与 b 引用的对象实例互相持有了对方的引用,因此当我们把对 a 对象与 b 对象的引用去除之后,由于两个对象还存在互相之间的引用,导致两个 Test 对象无法被回收。这样就造成了内存的泄漏了。
可达性分析法
主流JVM
都是通过可达性分析来判断对象是否可以被回收的。
什么是可达性分析算法?
「可达性分析算法」 以 GC Roots
为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。
搜索走过的路径称为「引用链」,所以算法也可以总结为:如果某个对象到GC Roots
没有任何「引用链」相连,就说明该对象不可达,即可以被回收。
JVM
使用该算法来判断对象是否可被回收。
什么是 GC Roots?
GC Roots
就是对象,而且是JVM
确定当前绝对不能被回收的对象(如方法区中类静态属性引用的对象 )。
只有找到这种对象,后面的搜索过程才有意义, 不能被回收的对象所依赖的其他对象肯定也不能回收。
可达性算法搜索的过程
当JVM
触发GC
时,首先会让所有的用户线程到达安全点SafePoint
时阻塞(暂停用户线程),也就是STW
(stop the world)。
然后JVM
要找到所有的GC Roots
,这个过程也称作「枚举根节点」。
然后就可以从这些GC Roots
向下搜寻,可达的对象就保留,不可达的对象就回收。
GC Roots一般可以是什么样的对象?
GC Roots
是一种特殊的对象,是Java程序在运行过程中所必须的对象,而且是根对象。
作为根肯定是满足一定条件的,使它向上查找不到结果,也就是 具备最大性 。GC Roots
一般是以下对象:
-
「虚拟机栈」中「局部变量表」中引用的对象
属于执行上下文中的对象。线程在执行方法时,会将方法打包成一个栈帧入栈执行,方法里用到的局部变量会存放到栈帧的「局部变量表」中。
只要方法还在运行,还没出栈,就意味这「局部变量表」的对象还会被访问,
GC
就不应该回收,所以这一类对象也可作为GC Roots
。 -
「本地方法栈」中
JNI
中引用的对象和上一条本质相同,无非是一个是Java虚拟机栈中的变量引用,一个是「本地方法栈」中的变量引用。
-
「方法区」中类静态属性引用的对象
属于全局对象。Class对象本身很难被回收,回收的条件非常苛刻,只要Class对象不被回收,静态成员就不能被回收。
-
「方法区」中的常量池引用的对象
属于全局对象。比如字符串常量池,常量本身初始化后就不再改变,因此作为
GC Roots
也是合理的。 -
被同步锁持有的对象
被
synchronized
锁住的对象不能被回收的。当前有线程持有对象锁,如果GC
回收了对象,锁就失效了。
GC Roots
并不包括「堆」中对象所引用的对象,这样就不会有循环引用的问题。
引用类型
什么是引用类型?
在 JDK 1.2 以前,Java 中的引用定义很传统,一个对象只有被引用或者没有被引用两种状态。
但是我们希望能描述这一类对象:
当内存空间还足够时,则保留在内存中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。 (很多系统的缓存功能都符合这样的应用场景)
在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为了以下四种:
- 强引用(Strong Reference)
- 软引用(Soft Reference)
- 弱引用(Weak Reference)
- 虚引用(Phantom Reference)
这也就是我们这里所要介绍的「引用类型」了。
引用类型有什么用?
判定对象是否「存活」和「引用」有关 。
无论是通过「引用计数算法」判断对象的引用数量,还是通过「可达性分析算法」判断对象是否可达,判定对象是否可被回收都与「引用」有关。
以上所列举的不同的「引用类型」, 主要体现的是对象不同的「可达性状态」和「垃圾收集」的影响 。
不同引用类型详细分析
-
强引用
什么是强引用
最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似
0bject obj=new object()
这种引用关系。「强引用」的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。
对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为
null
,就是可以当做垃圾被收集了,当然具体回收时机还是要看「垃圾收集策略」。强引用是造成Java内存泄漏的主要原因之一。
强引用关联对象和垃圾回收的关系
被「强引用」关联的对象 不会被回收。
创建方式
new 一个新对象就可以创建「强引用」。
Object obj = new Object();
-
软引用
什么是软引用
「软引用」用来描述一些还有用,但非必须的对象。
在系统将要发生内存溢出之前,将会把「软引用」关联对象列入回收范围之中进行第二次回收 。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
「软引用」通常用来实现内存敏感的缓存。比如: 高速缓存就有用到「软引用」。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
软引用关联对象和垃圾回收的关系
被「软引用」关联的对象只有 在内存不够的情况下才会被回收。
创建方式
使用
SoftReference
类来创建「软引用」。
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 消除掉强引用,使obj只被软引用关联
-
弱引用
什么弱引用?
弱引用也是描述那些非必需对象。
被「弱引用」关联的对象只能生存到下一次垃圾收集之前。 当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被「弱引用」关联的对象。
由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有「弱引用」的对象。在这种情况下,「弱引用」对象可以存在较长的时间。
软引用、「弱引用」都非常适合来保存那些可有可无的缓存数据。 当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。
弱引用关联对象和垃圾回收的关系
被「弱引用」关联的对象 一定会被回收 ,也就是说它只能存活到下一次垃圾回收发生之前。
创建方式
使用
WeakReference
类来创建「弱引用」。
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null; // 消除掉强引用,使obj只被弱引用关联
-
虚引用
什么是虚引用?
「虚引用」又称为幽灵引用或者幻影引用。一个对象是否有「虚引用」的存在,不会对其生存时间造成影响,也无法通过「虚引用」得到一个对象。
虚引用关联对象和垃圾回收的关系
为一个对象设置「虚引用」的唯一目的是能在这个对象被回收时收到一个系统通知。
创建方式
使用
PhantomReference
来创建「虚引用」。
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null; // 消除掉强引用,使obj只被虚引用关联
何时回收?
什么时候触发GC?
什么时候触发
GC
,以及触发什么类型的GC
?
不同的垃圾收集器实现不一样,你还可以通过设置参数来影响JVM
的决策。
-
新生代会在
Eden
区用尽后才会触发GC
-
老年代不能等
Old
区用尽才触发GC
因为有的并发收集器在清理过程中,用户线程可以继续运行,这意味着程序仍然在创建对象、分配内存,这就需要老年代进行「空间分配担保」(新生代放不下的对象会被放入老年代)
如果老年代的回收速度比对象的创建速度慢,就会导致「分配担保失败」,这时
JVM
不得不触发Full GC
,以此来获取更多的可用内存。Full GC
的执行效率是比较差的,我们应该尽量的避免它的出现。
这里的介绍我们引入了
GC
的类型的概念,并且不同的GC
的触发机制也不同,于是本章我们将「GC
类型」的部分和与GC
类型相关度比较高的「内存分配与回收策略」归档在本章。
各种GC
JVM
在进行GC
时,不是每次都是新生代、老年代、永久代一起回收的,大部分时候回收的都是指新生代。这里的GC
的类型实际是针对回收堆内存区域的不同进行的抽象划分,具体的实现还是得看「如何回收」章节。本章先以简单的GC
分类开头:
针对HotSpot VM
的实现,它里面的GC
按照回收区域又分为两大种类型:
-
部分收集(
Partial GC
)不是完整收集整个Java堆的垃圾收集。其中又分为:
-
新生代收集(
Young GC
):只是新生代的垃圾收集。
一般来说就是
Minor GC
-
老年代收集(
Old GC
):只是老年代的垃圾收集。
只有
CMS GC
会有单独收集老年代的行为。很多时候
Major GC
会和Full GC
混淆使用,需要具体分辨是老年代回收还是整堆回收。 -
混合收集(
Mixed GC
)收集整个新生代以及部分老年代的垃圾收集。
目前,只有
G1 GC
会有这种行为
-
-
整堆收集(
Fu1l GC
)收集整个Java堆的垃圾收集。
Minor GC
什么是Minor GC?
回收新生代,因为新生代对象存活时间很短,因此 Minor GC
会频繁执行,执行的速度一般也会比较快。
触发条件
当 Eden
区的空间耗尽了。这个时候 JVM
便会触发一次 Minor GC
来收集新生代的垃圾,存活下来的对象,则会被送到 Survivor
区。
Minor GC 的过程
新生代有两个 Survivor
区,我们分别用 from 和 to来指代。其中 to 指向的 Survivor
区是空的。
当发生 Minor GC
时,Eden
区和 from 指向的 Survivor
区中的存活对象会被复制(「复制算法」)到 to 指向的 Survivor
区中,然后 交换 from 和 to指针,以保证下一次 Minor GC
时,to 指向的 Survivor
区还是空的 。
对象在经过一次复制以后年龄要+1。
Survivor 区对象晋升到老年代对象
JVM
会记录 Survivor
区中的对象一共被来回复制了几次。 如果一个对象被复制的次数为 15 (对应虚拟机参数 -XX:+MaxTenuringThreshold
),那么该对象将被晋升为至老年代 ,(至于为什么是 15次,原因是 HotSpot
会在对象头的中的标记字段里记录年龄,分配到的空间只有4位,所以最多只能记录到15)。另外, 如果单个 Survivor
区已经被占用了 50% (对应虚拟机参数: -XX:TargetSurvivorRatio
),那么较高复制次数的对象也会被晋升至老年代
Survivor
区对象晋升到老年代对象以后,会导致老年代的占用率升高。
在Minor GC
过程中,Survivor
可能不足以容纳Eden
和另一个Survivor
中的存活对象。如果Survivor
中的存活对象溢出,多余的对象将被移到老年代,这称为 过早提升(Premature Promotion) 也可以被称为「分配担保机制」。
-
过早提升的问题
这会导致老年代中短期存活对象的增长,可能会引发严重的性能问题。
在
Minor GC
过程中,如果老年代满了而无法容纳更多的对象,Minor GC
之后通常就会进行Full GC
,这将导致遍历整个Java堆,这称为 提升失败(Promotion Failure) 。
Full GC
什么是Full GC?
Full GC
就是收集整个堆,包括新生代,老年代,永久代(JDK1.8以前)。
老年代对象其存活时间长,因此 Full GC
很少执行,执行速度会比 Minor GC
慢很多。
触发条件
-
调用
System.gc()
只是建议虚拟机执行
Full GC
,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。 -
老年代空间不足
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。
为了避免以上原因引起的
Full GC
,应当尽量不要创建过大的对象以及数组。除此之外,可以通过-Xmn
虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过-XX:MaxTenuringThreshold
调大对象进入老年代的年龄,让对象在新生代多存活一段时间。 -
空间分配担保失败
使用复制算法的
Minor GC
需要老年代的内存空间作担保,如果担保失败会执行一次Full GC
。 -
JDK 1.7 及以前的永久代空间不足
在 JDK 1.7 及以前,
HotSpot
虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用
CMS GC
的情况下也会执行Full GC
。如果经过Full GC
仍然回收不了,那么虚拟机会抛出java.lang.OutOfMemoryError
。为避免以上原因引起的
Full GC
,可采用的方法为增大永久代空间或转为使用CMS GC
。 -
Concurrent Mode Failure
执行
CMS GC
的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是GC
过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发Full GC
。
Major GC
什么是Major GC?
还有一个名词是所谓的 Major GC
,这个其实一般用的比较少,他是一个非常容易混淆的概念。
Full GC
定义是相对明确的。而Major GC
是俗称。Major GC
通常是跟Full GC
是等价的,收集整个GC
堆。但因为HotSpot VM
发展了这么多年,外界对各种名词的解读已经完全混乱了,当有人说“Major GC
”的时候一定要问清楚他想要指的是上面的Full GC
还是Old GC
。
内存分配与回收策略
-
对象优先在Eden分配
大多数情况下,对象在新生代
Eden
区中分配。当Eden
区没有足够空间进行分配时,虚拟机将发起一次Minor GC
。 -
大对象直接进入老年代
大对象是指需要大量连续内存空间的 Java 对象,如字符串或数据。
一个大对象能够存入
Eden
区的概率比较小,发生「分配担保」的概率比较大,而「分配担保」需要涉及大量的复制,就会造成效率低下。JVM
提供了一个-XX:PretenureSizeThreshold
参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在Eden
区及两个Survivor
区之间发生大量的内存复制(复制算法)。 -
长期存活的对象将进入老年代
JVM
采用了「分代收集」的思想来管理内存,因此内存回收时需要能识别出哪些对象应该放在新生代,哪些对象应该放到老年代中。因此
JVM
给每个对象定义了一个对象年龄计数器。当新生代发生一次Minor GC
后,存活下来的对象年龄 +1,当年龄超过一定值时,就将超过该值的所有对象转移到老年代中去。使用
-XXMaxTenuringThreshold
设置新生代的最大年龄,只要超过该参数的新生代对象都会被转移到老年代中去。 -
动态对象年龄判定
如果当前新生代的
Survivor
中,相同年龄所有对象大小的总和大于Survivor
空间的一半,年龄 >= 该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold
中要求的年龄。 -
空间分配担保
「空间分配担保」是为了确保在
Minor GC
之前老年代本身还有容纳新生代所有对象的剩余空间。JDK 1.6 Update 24 之前的规则是这样的:
在发生
Minor GC
之前,虚拟机会先检查 老年代最大可用的连续空间是否大于新生代所有对象总空间 , 如果这个条件成立,Minor GC
可以确保是安全的; 如果不成立,则虚拟机会查看HandlePromotionFailure
值是否设置为允许担保失败, 如果是,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于,将尝试进行一次Minor GC
,尽管这次Minor GC
是有风险的; 如果小于,或者HandlePromotionFailure
设置不允许冒险,那此时也要改为进行一次Full GC
。JDK 1.6 Update 24 之后的规则变为:( 主要还是看这里 )
只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小(老年代空间完全可以容纳新生代对象的晋升),就会进行
Minor GC
,否则将进行Full GC
。通过清除老年代中的废弃数据来扩大老年代空闲空间,以便给新生代作担保。
这个过程就是分配担保。
如何回收?
垃圾收集算法
本章将从分代收集理论开始,以此介绍垃圾收集相关算法,这些算法的执行过程都是
GC
的 具体实现 。
分代收集理论
什么是分代收集理论?
JVM
将「堆」划分成不同的代,不同的代中存放的对象特点不一样, 针对不同的代使用不同的GC
回收算法进行回收可以提升GC
的效率。
目前大多数JVM
的「垃圾收集器」都遵循「分代收集」理论,「分代收集理论」建立在三个假说之上:
-
弱分代假说
绝大多数对象都是朝生夕死的。
绝大多数时候,我们创建一个对象,只是为了进行一些业务计算,得到计算结果后这个对象也就没什么用了,即可以被回收了。 再例如:客户端要求返回一个列表数据,服务端从数据库查询后转换成
JSON
响应给前端后,这个列表的数据就可以被回收了。 诸如此类,都可以被称为「朝生夕死」的对象。 -
强分代假说
熬过越多次
GC
的对象就越难以回收。这个假说完全是基于概率学统计来的,经历过多次
GC
都无法被回收的对象,可以假定它下次GC
时仍然无法被回收,因此就没必要高频率的对其进行回收,将其挪到「老年代」,减少回收的频率,让GC
去回收效益更高的「新生代」。 -
跨代引用假说
「跨代引用」相对于同代引用是极少的。
这是根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,应该倾向于同时生存或者同时消亡的。
如果某个新生代对象存在「跨代引用」,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时「跨代引用」也随即被消除了。
解决跨代引用
「跨代引用」虽然极少,但是它还是可能存在的。如果为了极少的「跨代引用」而去扫描整个老年代,那每次GC
的开销就太大了,GC
的暂停时间会变得难以接受。如果忽略「跨代引用」,会导致新生代的对象被错误的回收,导致程序错误。
一般的解决方式有以下几种:
-
Remembered Set (记忆集)
JVM
通过「记忆集」(Remembered Set)来解决。通过在新生代建立「记忆集」的数据结构,来避免回收新生代时把整个老年代也加进GC Roots
的扫描范围,减少GC
的开销。什么是记忆集?
「记忆集」是一种由非收集区域指向收集区域的指针集合的抽象数据结构,也就是把「年轻代中被老年代引用的对象」给标记起来。「记忆集」可以有以下三种记录精度:
- 字长精度:记录精确到一个机器字长,也就是处理器的寻址位数。
- 对象精度:精确到对象,对象的字段是否存在跨代引用指针。
- 卡精度:精确到一块内存区域,该区域内的对象是否存在跨代引用。
什么是卡表、卡页?
字长精度和对象精度太精细化了,需要花费大量的内存来维护「记忆集」,因此许多
JVM
都是采用的「卡精度」,也被称作“卡表”(Card Table)。「卡表」是「记忆集」的一种实现,也是目前最常用的一种形式,它定义了记忆集的记录精度、与对内存的映射关系等。
HotSpot
使用一个字节数组来实现「卡表」 ,它将堆空间划分成一系列2次幂大小的内存区域,这个内存区域就被称作「卡页」(Card Page)。「卡页」的大小一般都是2的幂次方数,
HotSpot
采用2的9次幂,即512字节。字节数组的每一个元素都对应着一个「卡页」 ,如果某个「卡页」内的对象存在「跨代引用」,
JVM
就会将这个「卡页」标记为「Dirty」脏的,GC
时只需要扫描「脏页」对应的内存区域即可,避免扫描整个堆。 -
写屏障
请将这里的「写屏障」和并发编程中内存指令重排序的「写屏障」区分开,避免混淆。
卡表只是用来标记哪一块内存区域存在跨代引用的数据结构,
JVM
如何来维护卡表呢?HotSpot
是通过「写屏障」(Write Barrier)来维护「卡表」的,JVM
拦截了「对象属性赋值」这个动作,类似于AOP
的切面编程。JVM
可以在对象属性赋值前后介入处理,赋值前的处理叫作「写前屏障」,赋值后的处理叫作「写后屏障」,伪代码如下:
void setField(Object o){
before();//写前屏障
this.field = o;
after();//写后屏障
}
> 什么时候将卡页变脏呢?
开启「写屏障」后,`JVM`会为所有的赋值操作生成相应的指令。
一旦出现老年代对象的引用指向了年轻代的对象,`HotSpot`就会将对应的「卡表」元素置为脏的。
> 伪共享
除了「写屏障」本身的开销外,「卡表」在高并发场景下还面临着「伪共享」的问题。
* > 现代计算机CPU缓存和内存的关系
![202301011503330985.png][]
* > Cache Line
现代CPU的缓存系统是以「缓存行」(`Cache Line`)为单位存储的,Intel 的 CPU「缓存行」的大小一般是64字节。
当一行 `Cache Line` 被从内存拷贝到 Cache 里,Cache 里会为这个 `Cache Line` 创建一个条目。这个 Cache 条目里既包含了拷贝的内存数据,即 `Cache Line`,又包含了这行数据在内存里的位置等元数据信息。
**当 CPU 访问某个变量时,首先会去看 CPU Cache 内是否有该变量,如果有则直接从中获取,否则就去主存里面获取该变量,然后把该变量所在内存区域的一个 `Cache Line` 大小的内存复制到 Cache 中。**
由于存放到 `Cache Line` 的是内存块而不是单个变量,所以可能会把多个连续的变量存放到一个 `Cache Line` 中。
**所以通常情况下访问连续存储的数据会比随机访问要快。** (毕竟只需要读集中的内存,块数少)
* > 什么是伪共享?
**多线程修改互相独立的变量时,如果这些变量在同一个缓存行中,就会导致彼此的缓存行无故失效** ,线程不得不频繁发起`load`指令重新加载数据,而导致性能降低。
* > 具体的例子:
一个`Cache Line`是64字节,每个「卡页」是512字节,64\*512字节就是 32KB。
如果不同的线程更新的对象处在这 32KB 之内,就会导致更新「卡表」时正好写入同一个「缓存行」而影响性能。
![202301011503338816.png][]
如上图,变量 X 和 Y 同时被放到了 CPU 的 L1、L2 以及 L3,一个 Core 一次只能运行一条线程。
当 线程1 使用 Core 1 对变量 X 进行更新时,首先会修改 Core 1 的一级缓存变量 X 所在的「缓存行」,这时候在缓存一致性协议(`MESI`)下,Core 2 中变量 X 对应的「缓存行」失效。那么 线程2 在写入变量 Y 时就只能去二级缓存里查找,这就破坏了一级缓存。而一级缓存比二级缓存更快,这也说明了多个线程不可能同时去修改自己所使用的 CPU 中相同「缓存行」里面的变量。更坏的情况是,如果 CPU 只有一级缓存,则会导致频繁地访问主内存。
因为缓存与内存交换数据的单位就是「缓存行」,这就造成了造成多个变量被放入了一个「缓存行」中,为了保证缓存数据一致性,根据`MESI`协议,会使其他 Core 相同「缓存行」数据过期,如果多个线程同时去写入缓存行中不同的变量,虽然明面上不同线程读写不同的数据,但是由于数据在同一「缓存行」上,造成缓存频繁失效,就会无意中影响彼此的性能,这就是伪共享。由于从代码中很难看出是否会出现「伪共享」,有人将其描述成无声的性能杀手。
* > 如何解决?
为了避免这个问题,`HotSpot`支持只有当元素未被标记时,才将其置为脏的,这样会增加一次判断,但是可以避免「伪共享」的问题,设置-`XX:+UseCondCardMark`来开启这个判断。
标记-清除算法
什么是标记-清除算法?
当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),进行「标记-清除算法」。
「标记清除算法」分成两个过程 :标记、清除:
-
标记
遍历所有的
GC Roots
,然后将所有GC Roots
可达的对象「标记」为存活对象(一般是再对象的Header中记录为可达对象)。 -
清除
遍历堆中所有的对象,将没有标记的对象全部「清除」掉。与此同时,「清除」那些被标记过的对象的标记(对象Header中没有标记为可达对象),以便下次的垃圾回收。
同时「清除」不是真的置空,而是把需要清除的对象地址保存在「空闲地址列表」中。下次有新对象需要加载时,判断垃圾对象的位置空间是否够,如果够,就存放。
-
空闲列表
虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式称为:空闲列表(Free List)。
-
缺点
-
效率问题( 执行效率不稳定 )
标记和清除两个过程的效率都不高。标记和清除的时间消耗随着Java堆中的对象不断增加而增加。
在进行
GC
的时候要停止整个应用程序,导致用户体验差。 -
空间问题( 内存碎片 )
标记清除之后会产生大量不连续的内存碎片,碎片太多可能导致以后需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法
什么是复制算法?
为了解决标记-清除算法产生的内存碎片问题,「复制算法」被提出。
在新生代,对常规应用的GC
,一次通常可以回收70%~99%的内存空间,回收性价比很高。现在的商业JVM
都是用复制算法来回收新生代的。
-
解决空间问题(复制操作)
为了解决空间利用率问题,「复制算法」将内存分为三块:
Eden
、From Survivor
、To Survivor
,比例是 8:1:1。每次使用
Eden
和其中一块Survivor
。回收时,将Eden
和Survivor
中还存活的对象一次性「复制」到另外一块Survivor
空间上,最后清理掉Eden
和刚才使用的Survivor
空间。 这样只有 10% 的内存被浪费。大部分对象都会再第一次
GC
时被回收,需要被「复制」的往往是极少数对象。但是我们无法保证每次回收都只有不多于 10% 的对象存活,当Survivor
空间不够,需要依赖其他内存(指老年代)进行「分配担保」。-
分配担保
「分配担保」可以说是复制操作的保险了。
为对象分配内存空间时,如果
Eden+Survivor
中空闲区域无法装下该对象,会触发MinorGC
进行垃圾收集。但如果Minor GC
过后依然有超过 10% 的对象存活(一个Survivor
区装不下),这样存活的对象直接通过「分配担保机制」进入老年代,然后再将新对象存入Eden
区。
-
优点
- 没有标记和清除过程,实现简单,运行高效(复制算法的高效性是建立在存活对象少、垃圾对象多的前提下)
- 复制过去以后保证空间的连续性,不会出现“碎片”问题。
- 如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大(复制较少对象,
STW
更短)。
缺点
- 需要两倍的内存空间。(对于存活对象来说)
- 对于
G1
这种分拆成为大量region
的GC
,复制而不是移动,意味着GC
需要维护region
之间对象引用关系,不管是内存占用或者时间开销也不小。
标记-整理算法
什么是标记-整理算法?
复制算法除了在对象大量存活时需要进行较多的复制操作外,还需要额外的内存空间老年代进行分配担保。其次复制算法的高效性是建立在存活对象少、垃圾对象多的前提下。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。
所以在老年代中一般不采用复制算法。
基于老年代垃圾回收的特性,JVM
设计者在标记-清除算法的基础上改进并发明了「标记-整理算法」。
-
整理操作(压缩操作)
被标记存活的对象会被移动,将所有存活对象「压缩」到内存的一端,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。当我们需要给新对象分配内存时,
JVM
只需要持有一个内存的起始地址即可,这比维护一个空闲列表少了很多开销。
「标记-整理算法」的最终效果等同于标记-清理算法执行完成后,再进行一次内存碎片整理。但是这两个算法是有本质区别的,标记-清除算法是一种非移动式的算法,「标记-整理算法」是移动式的。
优点
- 消除了「标记-清除算法」当中,内存区域分散的缺点,我们需要给新对象分配内存时,
JVM
只需要持有一个内存的起始地址即可。 - 消除了复制算法当中,内存减半的高额代价。
缺点
- 从效率上来说,「标记-整理算法」要低于复制算法。
- 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。移动过程中,需要全程暂停用户应用程序(
STW
)
三种算法比较
指标 | 标记-清除(Mark-Sweep) | 复制算法(Copying) | 标记-整理(Mark-Compact) |
---|---|---|---|
速度 | 中等 | 最快 | 最慢 |
空间开销 | 少(但会堆积碎片) | 通常需要活对象的2倍大小(不堆积碎片) | 少(不堆积碎片) |
移动对象 | 否 | 是 | 是 |
分代收集算法
根据「分代收集理论」,几乎所有的
JVM
都会采用「分代收集算法」——根据对象存活周期将内存划分为几块,不同快采用适当的收集算法。
一般把Java堆分成新生代和老年代:
-
新生代
特点
区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
使用的垃圾回收算法
复制算法
这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于新生代的回收。而复制算法内存利用率不高的问题,通过
hotspot
中的两个survivor
的设计得到缓解。 -
老年代
特点
区域较大,对象生命周期长、存活率高,回收不及新生代频繁。
使用的垃圾回收算法
标记-清除 或者 标记-整理 算法
这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
- Mark(标记)阶段的开销与存活对象的数量成正比。
- Sweep(清除)阶段的开销与所管理区域的大小成正相关。
- Compact(整理)阶段的开销与存活对象的数据成正比。
增量收集算法
什么是增量收集算法?
在垃圾回收过程中,应用软件将进入STW
的状态。在STW
状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。
为了解决这个问题, 「增量收集算法」让垃圾收集线程和应用程序线程交替执行。每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,直到垃圾收集完成。
「增量收集算法」通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
缺点
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码, 所以能减少系统的停顿时间。 但是因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
小结
本篇文章我们介绍了垃圾回收机制的相关概念,和这个知识点关联比较紧密的是垃圾收集器,关于垃圾收集器的内容我将专门分出一篇博客来介绍。
本文参考: