Java同C++的内存管理不一样, 不需要开发者自己维护对象的生命周期. JVM的自动内存管理机制会帮助回收需要被回收的对象, 并且不会轻易出现内存泄露等问题. 这种机制极大得提高了日常的开发效率并降低项目的维护成本, 但是由于内存回收的过程封闭在JVM中, 为了避免出了问题两眼一抹黑的情况, 我们应该更多得了解JVM的内存回收机制, 要知其然知其所以然才能能好得帮助我们排查问题.
我们从下面几个步骤来分别介绍内存回收的过程. 首先需要了解Java的内存结构, 在JVM的管理下内存是如何分区的, 对象的实例存放在哪里? JVM是如何知道哪个对象需要被回收? JVM有哪些回收内存的方式? JVM内存回收的整体方案有哪些?
内存结构
由于线程是JVM中CPU调度的最小单位, 所以JAVA的整个内存区从这个角度可以分为共享内存(多个线程间共享)和线程私有内存(只在线程内部可见). 从内存的功能和作用可以分为程序计数器, 虚拟机栈, 本地方法栈, 方法区和堆这五个区域. 如下表所示.
分区 | 共享|私有 | 描述 |
---|---|---|
程序计数器 | 私有内存 | 当前线程执行字节码的行号记录器. 保证线程中指令的运行顺序. |
VM Stack虚拟机栈 | 私有内存 | Java方法执行的内存模型. 每个方法在运行时都会创建栈帧stack frame, 用来存储局部变量表, 动态链接, 方法签名和方法出口等信息. 如果虚拟机栈的调用深度超过JVM参数设置会抛出stack overflow异常, 如果在创建栈帧时不能申请到充足的内存抛出OOM异常. |
Native Method Stack本地方法栈 | 私有内存 | native方法执行的内存模型. 在Hotspot虚拟机中, 本地方法栈跟虚拟机栈合并在了一起. |
Method Area方法区 | 共享内存 | 用来存放类信息, 常量, 静态属性, 编译后的代码等信息. 有些场景也会被叫做永生代. |
Heap堆 | 共享内存 | 几乎所有对象的实例都在堆上存储. GC工作的主要场所. 可以在物理内存上不连续, 但是逻辑要连续的空间. |
Direct Memory直接内存 | 公共内存 | 直接内存的分配不受堆栈的限制, 但是需要受总机内存的限制. 使用不当也会造成OOM. 例如NIO为了避免在Java堆和native堆来回拷贝缓存数据, 使用直接内存来存放缓存提高效率. |
对象引用分析
简单来说我们都是通过对象是否有被有效引用来判断对象是否存活的. JVM有提供四种不同强度的引用, 方便开发者在有限的内存空间下结合自己实际的需求更加合适和高效得配合JVM来回收内存. 下表引用强度依次降低.
引用类型 | GC回收时机 | 补充 |
---|---|---|
强引用 | 只要有引用就不会被回收 | - |
软引用 | OOM前对软引用进行GC回收 | - |
弱引用 | 创建引用后, 只要GC就回收 | - |
虚引用 | 不影响对象的存活周期 | 通常用来监控对象是否被回收 |
通常有两种做法来判断对象是否存活: 引用计数法和可达性分析法.
Reference Counting, 引用计数法. 这种方式实现最简单, 每个对象维护一个引用计数器, 如果被引用了就加一, 取消引用就减一. 在GC时如果对象的引用计数器值等于零就说明对象可以被回收. 但是在JVM中一般不用引用计数法来回收内存. 虽然他的效率比较高, 但是有循环引用问题需要解决. 例如在一个作用域中对象A中引用了对象B, 对象B中也引用了A, 退出作用域后按道理来讲应该要回收这两个对象的内存, 但是由于AB两个对象内部有互相引用他们的引用计数器都不为0, AB两个对象不会被回收造成内存泄露. 主流的高级语言都是用可达性分析法来判断对象是否需要回收的.
Reachability Analysis, 可达性分析法. 基本思路是通过一系列的GC Root对象向下搜索, 搜索的路径就是GC Reference Chain引用链. 如果一个对象没有在任何引用链引上, 就说明当前对象可以被回收了. 可以被当做GC Root的点:
- 虚拟机栈, 本地方法栈中的对象引用.
- 方法区中静态, 常量属性的对象引用.
GC回收算法
上节讲了JVM通过对象引用的可达性分析来区分出来哪些对象是存活的, 哪些对象是可以被回收的. 接下来有下面四种回收方式来整理和回收内存, 他们都有自己的特点和优缺点, 可以在不同的场景下使用.
- 标记清除算法. 最基础的回收方式, 先用可达性分析法知道存活的对象, 再遍历内存反向标记出可以回收的对象, 最后回收标记过的对象. 这种方式需要遍历整个内存不仅效率低, 还容易出现内存碎片问题. 回收后的空余内存会分散在各个角落, 如果有稍微大点的对象要申请内存可能需要再次GC才能有足够的空间.
- 复制算法. 优化了标记清除算法的效率问题和内存碎片问题, 例如复制算法将内存分成2份A区和B区. 先用A区来给对象分配内存, 触发GC后直接将存活的对象复制到B区的连续内存中, 再回收整个A区回收的效率变高. 由于B区是连续内存, 再有对象申请内存时可以直接指针碰撞. 相遇于复制算法会牺牲一部分的内存容量来提高效率并解决内存碎片问题. 根据IBM的研究, 绝大多数的对象存活时间都很短, 所以没有必要按照1:1的比例去划分内存区. Hotspot虚拟机默认将新生代划分为三个区, Eden, Survivor, Survivor比例为8:1:1. 在GC时将Eden和一个Survivor区的内存GC拷贝到另一个Survivor区, 这样就只是浪费了10%的内存. 如果Survivor区不够存放GC后的对象, 则向老年代申请分配担保.
- 标记整理算法. 基于标记清除算法, 优化了内存碎片问题. 在标记后将存活对象往一个方向移动, 碰到可回收对象直接pass或覆盖, 直到碰到边界或不可回收对象, 最后将存活对象边界外的内存全部回收. 通过整理内存移动存活对象的方式来解决内存碎片问题. 不同于复制算法适用于存活期较短的对象管理(直接清空几个区来回收内存), 标记整理算法更适合管理生命周期较长的对象.
- 分代算法. 基于上面的基础, 将内存分为新生代和老年代. 根据不同的分代特性采用不同的GC算法. 在新生代每次GC都会回收大量的对象, 所以使用复制算法. 在老年代对象存活率高, 一般使用标记整理算法.
GC回收器
回收器是对象引用分析和GC回收算法的具体实现. 因为算法有多种, 回收器作用场景也不止一个, 所以延伸出了多个版本的回收器.
- Serial. 单线程. 复制算法. GC时要暂停全部工作线程. 效率高但是GC暂停时间长, 在JVM Client下还可以在新生代使用.
- Serial Old. 单线程. 标记整理算法. JVM Client下在老年代使用. JVM Server下可以配合CMS作为备选使用.
- ParNew. Serial的多线程版本. JVM Server下新生代使用.
- Parallel Scavenger. 多线程. 复制算法. 新生代使用, 可以通过JVM参数控制GC时CPU的吞吐量.
- Parallel Old. 同上, 标记整理算法.
- CMS(Concurrent Mark Sweep). 并发老年代. 标记清除算法. 以缩短GC时暂停工作线程时间为主. 并发收集低停顿. 由于用了标记清除算法, 所以会有内存碎片问题. 并且由于GC时一些步骤是和工作线程并行的, 会出现标记过程中产生新的垃圾, 这些浮动垃圾要等下次GC才能被回收(为了浮动垃圾还要预留内存, 所以CMS触发GC的时机是内存占用xx%后).
- 初始标记. 暂停工作线程. 标记GC Root关联的对象, 速度快.
- 并发标记. 并行工作线程. 绘制从GC Root的引用链. 耗时长.
- 重新标记. 暂停工作线程. 修正并发标记后, 一些标记对象引用发生变化的记录.
- 并发清除. 并行工作线程.
- G1(Garbage First). 新的并行并发收集器, 想要取代CMS. 不同于CMS, G1可以分代收集, 新生代使用复制算法, 老年代使用标记整理算法. 避免了内存碎片. 另外G1还可以设置GC的时间, G1将内存分区管理并维护每个区的性价比和回收时间, GC时按照优先级进行回收, 在时间范围内优先回收高性价比的内存区.
- 初始标记. 同CMS,
- 并发标记. 同CMS
- 最终标记. 同CMS的重新标记.
- 筛选回收. 根据优先级, 性价比和GC时间来回收内存区.
识别到某个对象可以被回收, JVM也不会立刻就回收内存. 先对对象做一次标记, 并判断当先对象是否调用过finalize方法. 如果调用过直接回收. 没有调用过的话, 将对象放入F-Queue队列中, 由JVM创建的低优先级Finalizer线程去调用队列中对象的finalize方法. 稍后GC会对F-Queue中对象做第二次标记. 如果在第二次标记之前有引用链了, 就放弃回收. 否则就直接回收对象.
GC时一些步骤需要所有线程暂停运行防止出现引用不一致的情况. 通常需要判断所有线程是否进入一个安全的状态后先中断, 才能计算引用关系. 线程在GC时的安全状态, 主要有下面三个概念
- OopMap. JVM在类加载时计算出对象内部的引用情况. 给GC时用.
- Safe Point. GC时JVM不知道线程运行到哪个指令, 系统也不可能为所有的指令上都附带OopMap信息, 那样开销太大了. 所以JVM定义了一些合适的点, 用来存放OopMap信息. 例如方法跳转, 循环条件跳转, 异常跳转等. 只有当线程运行到这些Safe Point上时, 才会暂停等待GC.
- Safe Region. 由于Safe Point是线程主动执行才能触发的, 那还有没有获取CPU时间片或者正在等待或阻塞的线程是不会运行到线程中的Safe Point的. 这里新增一个Safe Region, 相当于是一个安全区域. 当GC时, 线程处于上述情况下时GC就会认为当前线程也是安全的. 当线程退出Safe Region时会先判断GC状态, 再决定是挂起还是正常运行.
内存分配规则
- 大部分对象在新生代的Eden区分配内存. Eden: Survivor = 8:1:1.
- 大对象直接进入老年代. 如果过大的对象在新生代创建频繁复制会挤占其他新生代对象的空间, 从而频繁GC. 所以当对象的体积超过JVM参数后, 直接在老年代中分配内存.
- 新生代自然生长到老年代. 每次GC对象的年龄会 + 1(Mark Word中). 达到最大15后就会从新生代转移到老年代.
- 动态年龄判断. 如果在新生生代, 某一个年龄段的对象占用内存已经超过了Survivor区中的一半, 那么这批对象和更老的对象直接升入老年代.
- 空间分配担保. 如果新生代在GC复制内存时, Survivor区放不下了, 则将超出的对象直接放入老年代中.
转载请注明出处:https://blog.youkuaiyun.com/l2show/article/details/104152298