上一节是把大概的流程给过了一遍, 但是还有很多地方没有说到, 后续的慢慢会涉及到, 敬请期待!
这次我们说说垃圾收集器, 又名 gc, 顾名思义, 就是收集垃圾的容器, 那什么是垃圾呢? 在我们这里指的就是堆中那些没人要的对象.
1. 垃圾收集器的由来
为什么要有垃圾收集器啊? 不知道有没有想过这个问题, 你说我运行一个程序要什么垃圾收集器啊?
随意看一下下面两行代码:
- User user = new User("root","123456")
- user = new User("lisi","123123")
简单画一下内存图, 可以看到 user 这个局部变量本来是指向 root 这个对象, 现在改为指向 lisi 这个对象, 那么此时这个 root 对象没有人用, 假如类似 root 这样的对象非常多的话, 那么 jvm 性能就会越来越低, 直至最后创建个对象可能都要十几秒, 而且堆内存总有一天会装满就会报内存溢出异常;
所以我们就要想办法把类似 root 这种对象给清理掉, 这样才能保证 jvm 高效的运行;
假如虚拟机没有提供 gc 你觉得会怎么样? 其实也行, 只不过你每次需要你用代码手动释放不需要的对象, 关于这点有好处有坏处, 好处就是有利于我们对堆内存的控制, 坏处就是我们在一些比较复杂的程序之中由于手动释放内存难免会出错, 但是这中错误还不怎么明显, 可能要你去慢慢调试好久才能看到!
所以 java 就把这种工作自己处理了, 让一个 gc 线程一直在后台运行, 随时准备清理不需要用的对象, 虽然相当程度上会对 jvm 性能造成一些影响, 但是由于 gc 太好用了, 我们不用再人为的去关心垃圾对象的释放, 简化了我们编写程序的难度, 所以这种影响程度完全可以接受!
这里顺便一提两个基本概念, 内存泄漏和内存溢出:
内存溢出 (Memory Overflow) 比较好理解, 就是我们保存对象需要的空间太大了, 但是申请内存比较小, 于是装不下, 于是就会报内存溢出异常, 比如说你申请了一个 integer, 但给它存了 long 才能存下的数, 那就是内存溢出; 专业点的说法就是: 你要求分配的内存超出了系统能给你的, 系统不能满足需求, 于是产生溢出.
内存泄漏 (Memory Leak) 指的就是我们 new 出来的对象保存在堆中但是没有释放, 于是堆中内存会越来越少, 会导致系统运行速度减慢, 严重情况会使程序卡死; 专业点的说法就是: 你用 malloc 或 new 申请了一块内存, 但是没有通过 free 或 delete 将内存释放, 导致这块内存一直处于占用状态.
对于我们 jvm 来说, 通常情况下我们不用担心内存泄漏, 因为有一个强大的 gc 在我们程序的背后默默地为我们清理, 但是也会有特殊情况, 比如当被分配的对象可达但已无用 (未对作废数据内存单元的赋值 null) 即会引起, 至于这个可达是什么意思, 后面会慢慢说到;
相对而言内存溢出我们比较常见, 还有 gc 只会对堆内存进行回收, 所以静态变量是不会回收的;
再顺便提一下另外两个小概念, 非守护线程 (也叫用户线程) 和守护线程, 看下面这个丑陋的程序运行会有几个线程啊?
- public class User{
- public static void main(String[] args){
- System.out.println("我是 java 小新人");
- }
- }
两个线程, 一个是执行 main 方法的线程, 后台还有 gc 执行 gc 的线程, 在这里, 用户线程就是执行 main 方法的那个线程, 执行 gc 的线程就是守护线程, 默默地守护者 jvm, 假如 jvm 是雅典娜, 那么守护线程就是黄金圣斗士;
当用户线程停止之后整个程序直接停止, 守护线程也会终止; 但是黄金圣斗士挂了雅典娜还是可以好好活着的继续愉快的玩耍的;
2. 堆内存结构
哎, 内存中的结构如果真的要通过源代码去看, 简直让人崩溃, 除了专业搞这方面的不然真的很难懂, 本来我想自己画一下草图了, 发现太丑陋了, 于是去顺手借了一张图:
途中可以很清楚的看到, 整块堆内存分为年轻人聚集的地方和老年人聚集的地方, 年轻人比较少趋势占用 1/3 空间 (新生代), 老年人比较多就占用 2/3 的空间(老年代), 然而啊, 年轻人又要分分类, 分别是 Eden 区占新生代 8/10,From Survivor 区占新生代 1/10,To Survivor 区占新生代 1/10,emmm... 我特意查了一下百度翻译, Eden----> 乐园, Survivor----->幸存者; 哦~~~ 我感觉我仿佛明白了命名人的意图!
那么新生代和老年代到底是干什么的呢? 我们创建的对象是放在哪里啊?
新生代: java 对象申请内存和存放对象的地方, 而且存放的对象都是那种死的比较快的对象, 很多时候创建没多久就清理掉了, 那些活的时间比较长的对象都被移动到了老年代.
老年代: 存大对象比如长字符串, 数组由于需要大量连续的内存空间, 可以直接进入老年代; 还有长期存活的对象也会进入老年代, 具体是多长时间呢, 其实默认就是经过 15 对新生代的清理 (Minor Gc) 还能活着的对象.
而垃圾收集器对这两块内存有两种行为, 一种是对新生代的清理, 叫做 Minor Gc, 另外一种是对老年代的清理被叫做 Major Gc.
顺便提一点: 很多博客中都把 Major GC 和 Full GC 说成是一种, 其实还是有区别的, 因为很多 java 虚拟机的实现不一样, 所以就有各种各样的名称, 比如 Minor Gc 又叫做 Young GC,Major GC 也可以叫做 Old GC, 但是 Full GC 却有点不同, Full GC 是清理整个堆空间 -- 包括年轻代, 老年代和永久代(也叫做方法区). 因此 Full GC 可以说是 Minor GC 和 Major GC 的结合. 当然在我们这里, 为了好理解我们也就把 Full GC 当作 Major GC 就可以了.
3. 筛选清理对象
GC 要工作的话, 必须首先知道哪些对象要被清理, 你想一下, 在新生代和老年代有这么多对象, 怎么筛选会又快又省事呢? 可以有以下两种方法
1. 引用计数算法, 相当于给你创建的对象偷偷的添加一个计数器, 每引用一次这个对象, 计数器就加一, 引用失效就减一, 当这个计数器为 0 的时候, 说明这个对象没有变量引用了, 于是我们就可以说这个对象可以被清理了
2. 根搜索算法(jvm 用的就是这个), 这个怎么理解呢? 你可以想象现在有一个数组, 这个数组里面包含了一些东西的引用, 我们将这个数组叫做 "GC Root", 然后我们根据这个数组中的引用去找到对应的对象, 看看这个对象中又引用了哪些对象, 一直往下找, 这样就形成了很多线路, 在这个线路上的对象就叫做 "可达对象", 不在这个线路上的对象就是不可达对象, 而不可达对象也就是我们要清理的对象;
其中可以作为 GC Root 的对象:
(1). 类中的静态变量, 当它持有一个指向一个对象的引用时, 它就作为 root
(2). 活动着的线程, 可以作为 root
(3). 一个 Java 方法的参数或者该方法中的局部变量, 这两种对象可以作为 root
(4).JNI 方法中的局部变量或者参数, 这两种对象可以作为 root
(5). 其它.
关于这个根搜索算法专业一点的说法就是: 通过一系列的名为 "GC Root" 的对象作为起始点, 从这些节点开始向下搜索, 搜索所有走过的路径称为引用链(Reference Chain), 当一个对象到 GC Root 没有任何引用链相连时(用图论来说就是 GC Root 到这个对象不可达时), 证明该对象是可以被回收的.
4. 进行垃圾回收
前面已经筛选出了我们要清理的对象, 但是怎么清理比较快呢? 难道要一个一个对象慢慢删除嘛? 就好像你要清理手机中的垃圾, 你会一个应用一个应用去慢慢清理数据吗? 当然不可能, 这也太浪费时间了! 我们当然是用手机管家或者 360 管家先把要清理的东西给收集起来放在一起, 然后我们一清理就是全部, 一个字, 爽!
ok, 在这里也一样, 我们要想办法把所有的要清理的对象给放在一起清理, 有什么办法呢?
1. 标记 ----- 清除算法: 这种方法分为两步, 先标记然后清除, 其实就是需要回收的对象标记一下, 然后就是把有标记的对象全部清理即可; 这种方式比较适合对象比较少的内存, 假如对象太多标记都要好半天, 更别说清除了, 而且用这种方法清除的内存空间会东一块西一块, 下次再创建一个大的对象可能会出问题 1
2. 复制算法: 按内存容量将内存划分为等大小的两块. 每次只使用其中一块, 当这一块内存满后将尚存活的对象复制到另一块上去, 把已经使用的那块内存直接全部清理掉; 这种方法最大的缺陷就是耗内存啊, 只能用总内存的一半, 而且如果对象很多复制都要花很多时间.
3. 标记 ---- 整理算法: 结合以上两种方法优缺点进行改良的一种方法, 标记和第一种方法一样把要清理的对象做好标记, 然后把所有标记的对象移动到本内存的一个小角落, 最后集中力量对那个小角落进行消灭
4. 分代收集算法: 这是集中了上面三种方法的优点所实现的一种最好的方法, 是目前大部分 JVM 所采用的方法, 这种算法的核心思想是根据对象存活的时间不同将内存划分为不同的域, 一般情况下将 GC 堆划分为新生代和老年代; 新生代的特点是每次垃圾回收时都有大量垃圾需要被回收, 少数对象存活, 因此可以使用复制算法; 老年代的特点是每次垃圾回收时只有少量对象需要被回收, 可以选用 "标记 -- 清除方法"" 或者标记 -- 整理算法 "
所以目前大部分 JVM 的 GC 都是使用分代收集算法.
5. 执行 GC 的步骤
前面说了这么多无非是介绍堆的内部结构, 然后怎么找到要被清理的对象, 然后为了提高效率怎么清理最快!
现在我们就大概说说 GC 的清理步骤(详细版):
1. 我们创建对象的时候会进行一个判断, 极少数很大的对象直接放进老年代中, 除此之外所有新创建的对象都放进新生代的 Eden 区中;
2. 此时新生代中只有 Eden 区中有对象, 两个 Survivor 区中是空的; 当我们创建了很多对象, 使得 Eden 区快满的时候第一次 GC 发生 (就是执行了一次 Minior GC),Eden 区和 "From" 区(此时 "From" 区是空的) 存活的对象将会被移动到 Surviver 区的 "To" 区, 并且为每个对象设置一个计数器记录年龄, 初始值为 1; 每进行一次 GC, 会给那些存活的对象设置一个年龄 + 1 的操作, 默认是当年龄达到 15 岁, 下次 GC 就会直接把这种 "老油条" 丢到老年代中.
3.Minior GC 之后, 会进行一个比较厉害的操作, 就是将 "To" 区和 "From" 换个名字, 没错, 就是换个名字, 然后进行下一次 Minior GC.
4. 由于又创建了很多对象使得 Eden 区要满了, 于是又一次 Minior GC,Eden 区还存活的对象会直接移动到 Surviver 区的 "To" 区, 此时 "From" 区 (这里就是交换名字之前的 "To" 区) 中的对象有两个地方可以去, 要么年龄满 15 岁了去老年代, 要么就移动到 "To" 区
5. 此时我们看一下, 只有 "To" 区的对象是活着的, Eden 区都是垃圾对象可以直接全部清理,"From" 区是空的; 不管怎样, 在进行下一次 Minior GC 之前保证名为 "To" 的 Survivor 区域是空的就 ok 了
6. 当老年代中快要装满之后, 就会进行一次 Major GC, 这个清理事件很慢, 至少比 Minior GC 慢十几倍, 甚至更多, 所以我们尽量要少执行 Major GC
注意: 如果在移动过程中 "To" 区被填满了, 剩余的对象会被直接移动到老年代中. 还有在每次 Minior GC 之前会先进性判断, 只要老年代里面的连续空间大于新生代对象总大小或者历次晋升的平均大小进行 Minor GC, 否则进行 Major GC.
简化版:
(1)Eden 区活着的对象 + From Survivor 存储的对象被复制到 To Survivor ;
(2)清空 Eden 和 From Survivor ;
(3)颠倒 From Survivor 和 To Survivor 的逻辑关系: From 变 To , To 变 From .
(4)老年代的 Major GC 执行时间很长, 尽量少执行
只有在 Eden 空间快满的时候才会触发 Minor GC . 而 Eden 空间占新生代的绝大部分, 所以 Minor GC 的频率得以降低. 当然, 使用两个 Survivor 这种方式我们也付出了一定的代价, 如 10% 的空间浪费, 复制对象的开销等.
6. 知识点补充
通过查看了很多大佬的博客看到的很多有关的东西还是挺有趣的, 于是简单做个小笔记:
6.1. 新创建的对象是在堆中的新生代的 Eden 区, 由于堆中内存是所有线程共享, 所以在堆中分配内存需要加锁. 而 Sun JDK 为提升效率, 会为每个新建的线程在 Eden 上分配一块独立的空间由该线程独享, 这块空间称为 TLAB(Thread Local Allocation Buffer). 在 TLAB 上分配内存不需要加锁, 因此 JVM 在给线程中的对象分配内存时会尽量在 TLAB 上分配. 如果对象过大或 TLAB 用完, 则仍然在堆上 Eden 区或者老年代进行分配. 如果 Eden 区内存也用完了, 则会进行一次 Minor GC(young GC).
6.2. 很多人认为方法区 (或者 HotSpot 虚拟机中的永久代) 是没有垃圾收集的, Java 虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集, 而且在方法区进行垃圾收集的 "性价比" 一般比较低: 在堆中, 尤其是在新生代中, 常规应用进行一次垃圾收集一般可以回收 70%~95% 的空间, 而永久代的垃圾收集效率远低于此.
6.3 对象调用. finalize 方法被调用后, 对象一定会被回收吗?
在经过可达性分析后, 到 GC Roots 不可达的对象可以被回收 (但并不是一定会被回收, 至少要经过两次标记), 此时对象被第一次标记, 并进行一次判断, 如果该对象没有调用过或者没有重写 finalize() 方法, 那么在第二次标记后可以被回收了; 否则, 该对象会进入一个 FQueue 中, 稍后由 JVM 建立的一个 Finalizer 线程中去执行回收, 此时若对象中 finalize 中 "自救", 即和引用链上的任意一个对象建立引用关系, 到 GC Roots 又可达了, 在第二次标记时它会被移除 "即将回收" 的集合; 如果 finalize 中没有逃脱, 那就面临被回收. 因此 finalize 方法被调用后, 对象不一定会被回收.
6.4. 如果在 Survivor 空间中相同年龄所有对象大小总和大于 Survivor 空间的一半, 年龄大于或者等于该年龄的对象直接进入老年代. 不需要等到 15 岁.
总结
这篇说的就是 java 虚拟机怎么去收集对内存的垃圾, 首先是要通过可达性分析判断哪些对象是可达的, 哪些是不可达的, 那些不可达的对象就是我们要处理的对象! 这些不可达对象可能在新生代和老年代都有, 在新生代用复制算法去处理垃圾, 老年代用标记整理算法处理垃圾, 这种处理方式也可以叫做分代收集算法! 而且还简单说了一下 Minor GC 和 Major GC 的触发方式!
基本的东西就这么多, 假如要深入的话可以深入很多, 比如我们可以控制新生代的大小, 还有很多种垃圾处理器的实现产品等等, 都是可以去慢慢了解的.
来源: https://www.cnblogs.com/wyq1995/p/10726998.html