Java 垃圾收集算法有哪些?

本文主要介绍几种 Java 垃圾收集算法的原理及其优缺点。

标记清除(Mark-Sweep)算法

首先进行标记工作,标识出所有要回收的对象,然后进行统一回收被标记的对象。

对象标记的过程在 Java 对象的自我救赎 一文中有介绍。执行过程如下图:

mark_sweep

它的不足之处在于

1、标记、清除的效率都不高。

2、清除后产生大量的内存碎片,空间碎片太多会导致在分配大对象时无法找到足够大的连续内存,从而不得不触发另一次垃圾回收动作。

复制(Copying)算法

将可用内存按容量分成大小相等的两块,每次只使用其中的一块。

当这一块内存用完了,就将还存活的对象复制到另外一块上面,再把已使用过的内存空间一次清理掉。

商用虚拟机都采用这种算法回收新生代的对象。因为新生代的对象每次回收都基本上只有 10% 左右的对象存活,需要复制的对象少,效率高。执行过程如下图:

copying

优点:

因为是对整个半区进行内存回收,内存分配时不用考虑内存碎片等情况。实现简单,效率较高。

不足之处:

既然要复制,需要提前预留内存空间,有一定的浪费。

在对象存活率较高时,需要复制的对象较多,效率将会变低。

标记整理(Mark-Compact)算法

与标记清除算法类似,但不是在标记完成后对可回收对象进行清理,而是将所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。执行过程如下图:

mark_compact

优点:

消除了标记清除导致的内存分散问题,也消除了复制算法中内存减半的高额代价。

不足之处:

效率低下,需要标记所有存活对象,还要标记所有存活对象的引用地址。效率上低于复制算法。

分代收集(Generational Collection)算法

根据对象存活周期的不同将内存划分为几块。对不同周期的对象采取不同的收集算法。

新生代:每次垃圾收集会有大批对象回收,所以采取复制算法。

老年代:对象存活率高,采取标记清理或者标记整理算法。

参考

深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)

Java 对象的自我救赎

JVM 通过可达性分析算法判断一个对象是否可以被回收 ,但并不是一个对象不可达时,就宣告“死刑”的,此时只是暂时处于”缓刑“阶段。要宣告一个对象“死刑”,至少还要经历两次标记过程。

java_object_self_redemption

没有必要执行 finalize() 方法的筛选条件取决于:

1、 finalize() 方法已经被执行过(finalize()`只会执行一次)。

2、对象没有重写 finalize()方法。

如果一个对象有必要执行 finalize() 方法,会进入 F-Queue 队列,等待 Finalizer 线程执行。

因此如果想要完成对象自救, finalize()是逃脱死亡的最后一次机会,重新与引用链上的任何一个对象关联起来就可以,在第二次标记时,对象会被移出回收队列,完成自救。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class FinalizeEscapeGC {

public static FinalizeEscapeGC SAVE_HOOK = null;

public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("我挂了");
}
}

public void isAlive() {
System.out.println("我还活着");
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("执行 finalize 方法");
// 把当前对象( this )赋值给某个类变量, 重新与引用链建立引用
SAVE_HOOK = this;
}
}

扩展:

finalize() 方法的执行线程 Finalizer 优先级级别低,无法保证 finalize() 方法什么时候执行,执行是否符合预期,使用不当会影响性能。

Java 9 中已经将 finalize() 方法标记为废弃了,如果没有特别的原因,不要重写 finalize() 方法,也别指望它能回收资源。相反,尽量使用 try-finallytry-with-resources 等机制是非常好的资源回收方法。

参考资料

深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)

JVM 中如何判断对象可以被回收?

JVM 的垃圾回收器主要关注的是堆上创建的实例对象,在每次对这些对象进行回收前,需要确定哪些对象是可以去进行回收的。

主要有下面两种方法。

引用计数算法

给对象添加一个引用计数器,当有一个地方引用它,计数器值加 1;当引用失效时,计数器值减 1。任何时刻计数器值为 0 表示这个对象可以被回收了。

优点

判断效率高,实现简单。

不足之处

难以解决对象之间相互循环引用的问题。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class GCDemo {

public static void main(String[] args) {
GCObject objA = new GCObject(); // step 1
GCObject objB = new GCObject(); // step 2

objA.instance = objB; // step 3
objB.instance = objA; // step 4

objA = null; // step 5
objB = null; // step 6

System.gc(); // 执行 GC
}
}

class GCObject {
public Object instance = null;
}

堆栈结构如下图:

jvm_judge_object_recycle_1

main 方法中执行的 6 个步骤对应的引用计数结果:

step 1、实例A 引用计数加 1,引用计数 = 1;

step 2、实例B 引用计数加 1,引用计数 = 1;

step 3、实例B 引用计数加 1,引用计数 = 2;

step 4、实例A引用计数加 1,引用计数 = 2;

step 5、objA 引用不再指向实例 A,实例 A 的引用计数减为 1;

step 6、objB 引用计数不再指向实例 B,实例B的引用计数减为 1。

到此,GCObject 的实例 A 和 实例 B 的引用计数都不为 0, 此时如果执行垃圾回收,实例 A 和实例 B 是不会被回收的,也就出现内存泄漏了。

上述代码中,假设在 main 方法的最后执行 GC 操作,GC 日志如下:

1
2
3
4
5
6
7
8
9
10
11
[GC (System.gc()) [PSYoungGen: 2668K->776K(38400K)] 2668K->784K(125952K), 0.0095289 secs] [Times: user=0.00 sys=0.00, real=0.02 secs] 
[Full GC (System.gc()) [PSYoungGen: 776K->0K(38400K)] [ParOldGen: 8K->624K(87552K)] 784K->624K(125952K), [Metaspace: 3395K->3395K(1056768K)], 0.0057008 secs] [Times: user=0.08 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 38400K, used 998K [0x00000000d5f80000, 0x00000000d8a00000, 0x0000000100000000)
eden space 33280K, 3% used [0x00000000d5f80000,0x00000000d6079b20,0x00000000d8000000)
from space 5120K, 0% used [0x00000000d8000000,0x00000000d8000000,0x00000000d8500000)
to space 5120K, 0% used [0x00000000d8500000,0x00000000d8500000,0x00000000d8a00000)
ParOldGen total 87552K, used 624K [0x0000000081e00000, 0x0000000087380000, 0x00000000d5f80000)
object space 87552K, 0% used [0x0000000081e00000,0x0000000081e9c068,0x0000000087380000)
Metaspace used 3415K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 371K, capacity 388K, committed 512K, reserved 1048576K

实例 A、实例 B 都被放在新生代, Full GC 表示垃圾收集发生了 Stop-The-World 。所以直接看这一行,[PSYoungGen: 776K->0K(38400K)] ,JVM 并没有因为实例 A 和 实例 B 相互引用就没有去回收它们。表明了 JVM 并没有采用引用计数算法判定对象是否可以被回收。

JVM 中采用的是可达性分析算法判断对象是否可以被回收的。

可达性分析算法

基本思路:

通过一系列称为 “GC Roots” 的对象作为起始点,从这个节点向下搜索,搜索走过的路径就是引用链,当一个对象到 GC Roots 没有任何引用链相连,也就是从 GC Roots 到这个对象不可达,则这个对象不可达,可以被回收。

可作为 GC Roots 的对象有

  • 虚拟机栈中的引用的对象
  • 方法区的静态变量和常量引用的对象
  • 本地方法栈中 JNI 引用的对象

在上面的例子中,当执行第 5、6 步后,内存堆栈结构如下图。虽然实例 A 和实例 B 相互引用,但是它们到 GC Roots 都是不可达的了,所以它们都会被判定成可回收对象。

jvm_judge_object_recycle_2

参考

深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)

JVM 中的内存溢出

内存溢出,通俗一点,就是 JVM 内存不足了,没有空闲内存,并且垃圾收集器也无法提供更多内存。

这里的意思是说,通常在抛出 OutOfMemoryError 之前,垃圾收集器会被触发,尽其所能去清理空间。

但也不是在所有情况下垃圾回收器都会被触发,比如分配了一个大对象,超过了堆的最大值,JVM 可能判断出垃圾收集并不能解决这个问题,直接抛出 OutOfMemoryError 。

JVM内存结构 中,除了程序计数器,其他区域都有可能发生 OutOfMemoryError 。

堆溢出

通过-XmsXmx分别设定堆最小值和最大值。

错误信息:

1
java.lang.OutOfMemoryError: Java heap space

可能原因:

  • 内存泄漏
  • 堆的大小不合理,比如处理可观的数据量,但是没有显示指定 JVM 堆大小或者指定数值太小
  • JVM 处理引用不及时,导致堆积起来,内存无法释放

栈溢出

通过 --Xss 设置栈容量大小。

这里的栈包括虚拟机栈和本地方法栈。

比如递归操作,没有退出条件,会导致不断的压栈,JVM 就会抛出 StackOverFlowError。

如果 JVM 试图去扩展栈空间的时候失败,则会抛出 OutOfMemoryError。

方法区溢出

通过 -XX:PermSize-XX:MaxPermSize 限制方法区的大小。

String.intern() 的作用是:如果字符串常量池中已经包含一个等于此 String对象的字符串,则返回代表池中这个字符串的 String 对象,否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。所以,当字符串缓存占用太多空间,也会导致 OOM 问题。

错误信息:

1
java.lang.OutOfMemoryError: PermGen space

JDK 1.7 后,方法区引入元数据区,元数据区默认自增,方法区内存不再那么窘迫。

元数据区错误信息:

1
java.lang.OutOfMemoryError: Metaspace

直接内存溢出

通过 -XX:MaxDirectMemorySize 指定直接直接内存容量大小。

特征:

Heap Dump 文件中不会看见明显的异常,如果 Dump 文件很小,程序中有使用 NIO,可以考虑检查是否是直接内存溢出。

参考资料

深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)

JVM内存结构

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分为若干个不同的数据区域。

java_memory_structure

这些区域中,一些是线程私有的,一些是线程共享的。

线程私有的:程序计数器、虚拟机栈、本地方法栈

线程共享的:堆、方法区、直接内存

程序计数器

一块较小的内存空间,用于标记当前线程所执行字节码的行号。

Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式实现,所以确定的时刻一个处理器只会执行一个线程中的指令。

为了线程切换后能恢复到正确的执行位置,每个线程都需要一个独立的程序计数器,用于记录线程所执行字节码指令的地址。

虚拟机栈

Java 虚拟机栈是由一个个帧栈组成。

每个方法执行时会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

当方法调用时,栈帧入栈,方法结束时,栈帧出栈。

局部变量表的所需的内存空间在编译期间完成分配,运行时不会改变大小。

虚拟机栈定义了两种异常状况:StackOverFlowError 和 OutOfMemoryError 。

  • StackOverFlowError:线程请求的栈深度大于虚拟机所允许的深度。
  • OutOfMemoryError :大多数虚拟机都允许动态扩展虚拟机栈的大小,所以线程可以一直申请栈,直到内存不足时,抛出 OutOfMemoryError。

本地方法栈

本地方法栈为虚拟机使用到的 native 方法服务。

HotSpot 虚拟机直接把虚拟机栈与本地方法栈合二为一。与虚拟机栈一样,也会抛出 StackOverFlowError 和 OutOfMememoryError 异常。

Java 堆是垃圾收集器管理的主要区域,也称为 GC 堆。所有实例和数组都在这里分配内存,也是线程共享的内存区域。

-Xms 设置最小值;-Xmx 设置最大值。

堆内存分配会另写一篇文章介绍。

方法区

与 Java 堆一样,也是线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。

在 HotSpot 虚拟机中,这块区域对应永久代,不易被回收。

运行时常量池:属于方法区一部分。用于存放编译期生成的常量和引用。

JDK 1.7 之后已经将运行时常量池从移出,在堆上开辟了一块区域存放运行时常量池。

直接内存

直接内存并不是虚拟机内存的一部分,也不是 Java 虚拟机规范中定义的内存区域。

jdk1.4 中新加入的 NIO,引入了通道与缓冲区的 IO 方式,它可以调用 Native 方法直接分配堆外内存,这个堆外内存就是本机内存,不会影响到堆内存的大小。

参考资料

深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)

为什么 byte 的范围是 -128 ~ 127?

这是一个很基础的问题。但是在昨天之前,我都是只知其然,不知其所以然。

于是我搜索了大量网络资料。说实话,看完大部分文章,我还是没有弄明白为什么。直到我看到了知乎上面的一个回答:

在8位二进制中,-128 没有原码、反码形式,那么它的补码是怎么计算出来的?还是约定的? ,醍醐灌顶。

这里尝试自己阐述一遍,如果你没有看懂,那是我的问题,还是直接看参考链接。

首先,忘记原码、补码、反码的概念。

从 「」开始。什么是模?

想象日常见到的时钟,它可以表示 0 - 12 点的时间。假设当前时针指向 8 点,而准确时间是 5 点。那么调整方法有两种:

一种方法是将逆时针拨 3 小时,8 - 3 = 5。

另一种方法是顺时针拨 9 小时,8 + 9 = 12 + 5 = 5。

可以看出,减 3 和加 9 的效果是一样的,这里的 12 就是模,3 和 9 在模 12 中互为补数,也就是相加等于模的大小。

所以,在模的范围内做减法,可以将 X - Y 的减法等价于 X + Y 的补数。

但是新的问题又出现了,这种算法的结果永远是正数。

比如 3 - 8 = 3 + (12 - 8) = 7,实际上 3 - 8 = -5。很明显,7 不等于 -5 的。

怎么办呢,当初那些先贤想出来很简单的方法,就是把两个数直接划等号,正好解决了负数的表达方式。-5 的绝对值的补数正好是 7。

但是,又出现了新的问题,7 已经用来表示正数 7 了,现在又来表示 -5,当 7 出现时就很难辨别它到底代表谁了。

为了保留这套规则,只能牺牲 7 了,确定 7 只代表 -5。所以给这套规则划定一个范围,原来 0 - 11 的正数,拆分为两部分,0 - 5 这个区间代表正数,6 - 11 这个区间就用来代表各自的补数的负值。

回到二进制的计算机世界。

我们知道 byte 占用 8 位,一共可以表示 2 的 8 次方,256 个数,即 0 ~ 255。模是 256。

按照上面的规则, 0 - 127 代表其正数本身,128 - 255 代表其补数的负值,也就是 “-1 ~ - 128”。

所以,byte 的表示范围就是 -128 ~ 127 了。

总结下:

1、计算机中负值的表达方式就是它绝对值的补数。

2、这样表达的方式是为了实现数值运算而决定的。

3、几乎所有的课本和老师都只给出结论,没有给出解释原理的做法真是简直了。

2018 年过去了,我很怀念它

每到年底,大家都会总结过去的一年里的得与失,收获与不足,也会为新的一年立下新的 Flag。

有时候在想为什么要有新年的感念,也许就是希望吧。每个人都需要一个时间周期去不断给自己寄予新的希望,而这个周期不能太长,以便可以安慰自己的平庸,这个周期不能太短,至少可以笃定的注视前方。

回望这一年,也是有几件值得纪念的事情。

1、3 月 8 日,老鲍回国,大学兄弟们聚得最齐的一次。

2、5 月 20 日和女友去第一次去看了朴树的演唱会。

3、6 月 19 日和女朋友领了证。

4、11 月 09 日进新房,双方父母第一次见面。

5、12 月 15 日,看了许巍的演出,第一次与许巍合影。

其实每一件事情对于个人来说都算挺有意义的,都值得细说。

这一年里认知能力提升不少,如 这篇文章,我目前处在知道自己不知道这一阶段,其实处在这个阶段挺痛苦的,经常性会心情失落,因为短时间很难做出巨大的改变。但幸好我愿意去做出改变。

这一年的技术能力有提升,但不是很满意,反省下,自己的执行力、坚持能力还是不够,其实可以做到更好些,为后面的改变、选择做更充分的准备。但时间过去了,只能安慰下平庸的自己。

编程、写作、英语是最重要的三大能力,无论是现在还是未来。所以,新的一年里,在此立下几个 Flag。

1、看准机会,增加收入。

2、做一个像样点的开源软件,向郭霖大神学习。

3、每周不低于一篇技术博客,逼着自己学习技术。

4、每周不低于一篇公众号文章,技术、思考、认知、感悟都可以。

5、报一个英语班,坚持下去。

6、主动一些,多接触一些段位比我高的人。

7、多出去走走,见见外面的世界,多接触些有趣的人和事。

8、保持身心健康。

最后,感谢这一年帮助我的亲人朋友,祝愿你们 2019 年健康平安,万事如意。

也谢谢你的观看,愿你如意平安。

Android 音视频学习资源汇总

1、Android 音视频开发入门指南 :音视频学习思路,一系列学习任务。

2、Android 音视频开发学习思路:音视频学习路径,教程。

3、《雷霄骅的专栏》:http://blog.csdn.net/leixiaohua1020

4、《Jhuster的专栏》:http://blog.51cto.com/ticktick

5、《FFMPEG Tips》:http://ticktick.blog.51cto.com/823160/d-17

6、《Learn OpenGL 中文》:https://learnopengl-cn.github.io/

7、《Android Graphic 架构》:https://source.android.com/devices/graphics/

8、《ywl5320的专栏》:https://blog.csdn.net/ywl5320

9、《灰色飘零》:https://www.cnblogs.com/renhui/

达克效应

达克效应(英语:D-K effect),全称为邓宁-克鲁格效应(英语:Dunning–Kruger effect),是一种认知偏差,能力欠缺的人有一种虚幻的自我优越感,错误地认为自己比真实情况更加优秀。简言之即:庸人容易因欠缺自知之明而自我膨胀。

KrugerDunning将其归咎于元认知上的缺陷,能力欠缺的人无法认识到自身的无能,不能准确评估自身的能力。他们的研究还表明,反之,非常能干的人会低估自己的能力,错误地假定他们自己能够很容易完成的任务,别人也能够很容易地完成。

参考网上,动手绘制了下面这张图,更为清晰。

d-k-effect

从左往右,智慧从低到高,从下往上,是自信程度从低到高。

不是所有人都能完整的经历这条曲线。

大多数人都是在攀爬愚昧山峰,到顶之后可能就不动了。不动的原因是什么 ?认知停滞。认知停滞,会导致个人对世界的看法难以发生改变。对待新事物,只会用自己的思维框架强行套用,自信心爆棚,根本没有耐心去了解未知的领域。

当掌握的领域知识越来越多,认知逐渐提高后,一些人会进入「自信崩溃区」,因为这个时候知道了自己未知的领域太多太多,自信心备受打击,不知道如何是好。

到达「绝望之谷」后不一定马上就能反弹,有时需要经历很多波折,才能对新的领域有正确的认识,而新的开悟之坡非常漫长,有些人终其一生都不能进入「平稳高原」。

人与人之间的差异,真的是认知的差异。

身处「愚昧之巅」的人,更有倾向会去攻击辱骂更加智慧的人。主要原因就是认知差距,无知无畏。

认知层次高的人,对未知领域会更有敬畏心。

这张图值得时不时拿出来看看,就某件事情,对号入座,反思,避免成为达克效应描述的对象。

二零一八年农历十月二十一

昨天同事喊我去参加面试,公司的其他部门技术岗。想来很久没面试了,于是就去试下水。

有几点感受。

首先,发现原来自己真的还差得远呢。随便一个 Http 的知识点就难倒我了。工作中只知道使用,压根没深入学习原理。这也提醒了我多去钻研技术,不要只停留在表面。

其次,技术面试官的更关注的是学历、职级、绩效,这是我很诧异的。因为我发现有时候学历、职级、绩效真得不见得和个人能力成正比,如果是 HR 问这些还情有可原,技术面试官问这些就感觉挺 Low 比的。因为他问我今年绩效拿了几个 D,这个时候我就挺毛躁的了。

上个月的绩效领导给我打了个 D,如果真是如他所说是轮流来,不会这么快就轮到我,我其实知道是什么原因的,我也没有多问多说了。

说不在乎那是假话,但是能怪谁呢,还不是自己不够强大。

我很感谢工作上的一些不顺利和不公平,因为它们很好地折射了自己的工作确实做得还不够好,同时也敦促我更加积极努力,更加快速地成长。

今天是我的生日,说点感动的。

早上坐车听了个电台节目,讲述者是导演张涛,讲述的是他和他的姥姥和奶奶的故事,听的过程中,眼泪一直在眼眶中打转。想起了我的奶奶,想起她给我过生日的场景,想起她带我们堂兄弟四个孩子在一起生活的过往,真的太不容易了。如今她老人家已经走了好几年了,我时常会怀念她。

昨晚母亲给我打电话,专门提醒我今天过生日要买些好吃的,心里头真是温暖感动极了。父母对子女的爱,真的是太无私了,真的是可以倾尽所有。我想我能做的,就是尽快成长,成为他们的依靠。

很感谢老丈人和丈母娘的生日祝福,感谢你们的帮助,我定好好回报。

女朋友今天专门给我买了蛋糕,我们在温暖的家里过生日,非常感谢她一路陪伴。

群里的小伙伴送来了真挚的祝福,很是感动。

爱你们。愿你们健康平安。