引言
Java程序在运行过程中会产生大量的对象,但是内存大小是有限的,如果仅仅使用而不去释放它,那内存迟早被耗尽,但是Java又不像C/C++需要手动释放内存,而是通过GC回收器来进行自动回收释放。
垃圾回收器来进行内存回收的时候概括的来讲分为两步:
1.标记垃圾
2.回收垃圾
上面两步是思想,所以基于此诞生了很多算法,但是在进行内存回收之前我们是需要一个前提,就是判断哪些对象是“垃圾”,才能给予打上回收的标记,下面我们进入正题
垃圾判断算法
即判断JVM的堆栈中对象哪些是存活的,哪些是可回收的的算法。(PS:网上很多帖子仅仅说对象是存在堆上,但是当JVM开启逃逸分析的时候,会出现栈上分配的情况,此时,堆与栈上同时存放的对象,也是JVM防止OOM的一种优化)
1.引用计数器算法
在对象中添加一个属性用于标记对象被引用的次数,每被一个外部对象引用时,计数器+1,当断开引用或引用失效时,计数器-1,当计数器为0时,就表示该对象已处于不被引用的状态,即可以被回收。
这是最简单的垃圾判断算法,但是该算法无法解决循环依赖的问题,如下图所示
因为对象A与对象B互相调用对方,导致调用链一直存在,计数器不可能等于0,故无法被释放
2.可达性分析算法
该算法也被成为“GC Roots Tracing”算法,根据英文翻译过来就是垃圾节点搜索,即通过被称为“GC Roots Object”的对象集作为起始节点开始,根据引用关系链向下搜索,能够被搜索到的(或者说可达到的)节点,则判定该对象处于存活状态,不可回收;否则为断开引用的无用对象,可被回收。
上面所提到的“GC Roots Object”的对象集,指的是哪些对象呢:
1.局部变量
2.静态变量
3. OOP
4. 常量池
内存池
首先我们先看下内存池的模型图,再来逐步分析
内存分配算法
1.指针碰撞
openjdk的内存分配也是基于指针碰撞实现的,原理很简单,其实就是两个指针地址通过自旋锁(无限CAS)来进行交换,当指针要指向的下一位置是在内存区间内且空闲,即满足条件实现地址交换,否则交换失败不断 Retry(失败原因:1.并发导致被占用 2.对象大小超过可分配的内存空间,指向内存区间外)
直接上底层C++源码,然后进行原理分析
retry:
HeapWord* compare_to = *Universe::heap()->top_addr();
HeapWord* new_top = compare_to + obj_size;
if (new_top <= *Universe::heap()->end_addr()) {
if (Atomic::cmpxchg_ptr(new_top, Universe::heap()->top_addr(), compare_to) != compare_to) {
goto retry;
}
result = (oop) compare_to;
}
}
在分配对象前,指针A(上述代码的compare_to)的位置为图下所示
分配一个对象后,指针要进行偏移,偏移量offset(上述代码的object_size)也就是对象的大小,指针B(上述代码的new_top)所指向的位置(内存地址)即为A要被交换过去的内存地址
所以当指针B指向的内存地址被使用时,则会不断重试,直到交换到合理的内存地址才算成功
ps:有一种情况比较特殊,当对象申请的内存大小超出内存区域则不会进行retry,上述代码的 if 判断就已经将该场景拦截在外,不会进行无限CAS,但会有其他处理方法,需要研究一下C++源码才能总结,此处先不描述
2.空闲链表
这部分我也是跟着大神才略知一二,他的思路其实就是运用了这四个链表从而实现的内存分配
具体理解还是需要先理解内存池相关算法然后再回过头来研究这部分会容易许多,跳过此部分向下看
list<MemoryCell *> m_available_table;//所有可使用的内存
list<MemoryCell *> m_used_table;//所有被使用的内存
list<MemoryCell *> m_idle_table;//空闲内存
list<MemoryCell *> m_transer_table;//待交换内存
内存池及相关算法
内存池有几种角色
1.OS堆 --操作系统堆
2.Memory Pool --内存池
3.Memory Chunk --内存块
4.Memory Cell --内存细胞
分别的作用
1.OS堆
VM的内存就是向OS堆申请的
不仅是JVM,操作系统也是有内存模型的,也分为:
堆(OS堆)
栈
静态区域
代码区域(可读可写可执行,这就是为什么mac无法开启JIT的原因,具体原因记有点描述不清,先跳过)
2.Memory Pool
职责
1.向OS堆申请内存(malloc、calloc方法)
2.管理内存块(Memory Chunk)
3.不直接持有内存
4.释放内存
用C/C++写程序时需要手动释放内存,它没有垃圾回收器
申请的内存是有内存对齐的,比如向OS申请一个55B的内存,Memory Pool会自动对齐计算出要向OS申请的内存大小(56B,补1B),来实现8字节对齐
PS:这个8是C++源码里面定义写死的,具体可不可以改还不清楚
3.Memory Chunk
直接持有内存,并且管理这个内存块,比如像刚才说的内存对齐,在C++里面源码如下
#define ALIGN_SIZE (sizeof(Align))//对齐步数
4.Memory Cell
因为是8字节对齐,所以一个Memory Cell的大小即为8B,比如向OS堆申请了80B内存,那就是分为了10个Memory Cell
至于上图为什么不是10个块呢?
个人理解:10个Memory Cell块仅仅是一个单位,其中被合并的应为被分配对象的大小,所以即为一块连续的内存区域
标记-清除算法
面向整个堆
并且在过程中只会清除被标记的无用垃圾对象,产生碎片,即导致了内存碎片化
缺点
当需要分配大对象时,需要连续的内存区域,但由于标记-清除算法导致的内存碎片化,无法提供连续区域,导致OOM
标记-整理算法
该算法是对基于标记-清除算法导致的内存碎片进行合并
缺点
1.消耗CPU,花费时间过长
2.合并碎片时需要STW(Stop The World,暂停所有用户线程)
该场景不太适用于Eden区(在新生代中,分为Eden区、From区、To区),适合Old区,原因
Eden区的对象存活时间很短,通常在第一次gc的时候就会被释放掉,产生大量的内存碎片,此时再进行碎片整理,效率不高
Old区一般生命周期较长,相较Eden区不会有太多内存碎片的现象产生,因此可以使用标记-整理算法
标记-复制算法
因为新生代分为三个区域,分别为 Eden区、From区、To区,在JVM中的比例为8:1:1
模型图如下
当Eden区发生gc时,仍然存活的对象不会被回收,为了减少整理内存碎片所带来的性能损耗,此时采用将剩余存活对象全部复制到From区,那么接下来该怎么处理这些剩余对象呢?
分代+复制算法
这个时候就不得不提到分代复制算法,分代即将一块完整的区域划分成若干块来进行分开管理(一般都是五五开,即分成大小相等的两块内存区域,一半处于工作状态、一半处于空闲状态),新生代中其实From区与To区就是分代复制算法的一种实现
当对象都被收集到From区时,若此时进行标记整理,同样会损耗CPU性能,影响新一轮的内存分配,此时有内存一块大小相同的To区恰好处于空闲状态,那么我们是否可以让To区来接替From区的工作,让From区在后台慢慢的执行标记整理呢?其实与消息队列还有一点点相似点,就是把耗时长的工作留给后台慢慢处理,前台用户是无感的。
说到这里,如果我来总结一下,这是基于内存交换来实现的,大家应该就可以理解了吧