JVM内存模型
JVM堆内存划分(JDK1.8以前)
JVM堆内存划分(JDK1.8之后)
主要变化在于:
- java8没有了永久代(虚拟内存),替换为了元空间(本地内存)。
- 常量池:1.7又把他放到了堆内存中;1.8之后出现了元空间,它又回到了方法区。
年轻代:新生成的对象都放在年轻代,主要存放一些生命周期比较短的对象
新生代一般分三个区:一个Eden区,两个 Survivor区:
大部分对象在Eden区中生成,当Eden区满时,还存活的对象将被复制到Survivor区,当这个 Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制到老年代(Tenured)。
同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。
老年代:在年轻代中经历多次垃圾回收后仍存活的对象会被放入老年代中,一般存放生命周期较长的对象
java7永久代:用于存放静态文件,如Java类、方法等。永久带对垃圾回收没有显著影响,一般不做垃圾回收,在JVM内存中划分空间。
java8元空间:类似于永久带,不过它是直接使用物理内存而不占用JVM堆内存。
垃圾回收机制
Java不用像C++一样自己释放内存,通过垃圾回收器GC来进行内存的回收释放。
不需要进行垃圾回收:程序计数器、JVM栈、本地方法栈。
需要进行回收垃圾的区:堆和方法区
方法区,也不太需要被回收, 它存放的是类对象, 主要工作就是 "类加载", 但是很少会涉及到 "类卸载", 需要 GC 但不迫切。堆,才是 GC工作的主战场, 很多 new 出来的对象, 用完之后, 就需要被及时回收!!
什么时候进行垃圾回收?该类的所有实例对象都已经被回收。
加载该类的ClassLoader已经被回收。
该类对应的反射类java.lang.Class对象没有被任何地方引用。
几种垃圾收集器:Minor GC:新生代GC,即发生在新生代(包括Eden区和Survivor区)的垃圾回收操作,当新生代无法为新生对象分配内存空间的时候,会触发Minor GC。因为新生代中大多数对象的生命周期都很短,所以发生Minor GC的频率很高,虽然它会触发stop-the-world,但是它的回收速度很快。
Major GC:Tenured区GC,用于回收老年代,出现Major GC通常会出现至少一次Minor GC。
Full GC:是针对整个新生代、老生代、元空间(metaspace,java8以上版本取代perm gen)的全局范围的GC。Full GC不等于Major GC,也不等于Minor GC+Major GC,发生Full GC需要看使用了什么垃圾收集器组合,才能解释是什么样的垃圾回收。
垃圾回收机制流程
GC主要处理的是年轻代与老年代的内存清理操作,元空间(永久代)一般很少用GC。具体流程如下:
① 当一个新对象产生,需要内存空间,为该对象进行内存空间申请。
② 首先判断Eden区是否有有内存空间,有的话直接将新对象保存在Eden区。
③ 如果此时Eden区内存空间不足,会自动触发MinorGC,将Eden区不用的内存空间进行清理,清理之后判断Eden区内存空间是否充足,充足的话在Eden区分配内存空间。
④ 如果执行MinerGC发现Eden区不足,判断存活区,如果Survivor区有剩余空间,将Eden区部分活跃对象保存在Survivor区,随后继续判断Eden区是否充足,如果充足在Eden区进行分配。
⑤ 如果此时Survivor区也没空间了,继续判断老年区,如果老年区空间充足,则将Survivor区中活跃对象保存到老年代,而后存活区有空余空间,随后Eden区将活跃对象保存在Survivor区之中,在Eden区为新对象开辟空间。
⑥ 如果老年代满了,此时将产生MajorGC进行老年代内存清理,进行完全垃圾回收。
⑦ 如果老年代执行MajorGC发现依然无法进行对象保存,此时会进行OOM异常(OutOfMemoryError)。
上面流程就是整个垃圾回收机制流程,总的来说,新创建的对象一般都会在Eden区生成,除非这个创建对象太大,那有可能直接在老年区生成。
如何判断一个对象是否为 "垃圾"
引用计数方案:
引用计数非 Java, 别的编程语言中使用的方法, 此处也简单介绍一下, 因为在 <<深入理解 Java 虚拟机>> 这本书中, 这两种方法都提到了.
"引用计数": 是使用额外的计数器, 记录某个对象, 被多少个引用指向. 如果某一时刻, 计数器为 0 了, 就说明此时没有引用指向它了, 此时这个对象就可以视为 "垃圾" 了.
引用计数就类似上图, 每新产生一个引用, 引用计数就 + 1, 每销毁一个引用, 引用计数就-1,
销毁可以理解为如果引用是局部变量, 出了作用域就是销毁了. 如果引用是成员变量, 则该引用对应的对象被销毁时, 引用本身才被销毁.
"引用计数" 的缺陷1 . 在多线程中, 需要修改同一个引用计数, 需要考虑到线程安全问题.
2 . 有可能会造成不必要的开销!! 如果本身是大的对象, 多引入一个引用计数器, 负担不大; 如果本身是小的对象, 并且数量还多, 引入引用计数器就会造成不小的空间开销.
3. 可能会带来循环引用的问题 (最致命的)
有一个 Test 类, 类中有一个 Test 类型的成员变量, 操作1 是将这个类实例化 2 个对象, 操作 2 是分别将两个对象中的引用类型的成员变量互相指向对方.
此时计数器为 2, 当 a, b 对象某一时刻被销毁时:
此时, 当前这俩对象引用计数都不为 0, 因此就都不会被当成垃圾, 但是这俩引用的地址都在对方手里, 就导致无法使用这俩对象, 最终就成了既不能被回收, 又不能被使用, 非常类似于 "死锁". 正因为引用计数有上述三个缺陷, 所以在 Java 中就不太合适, 就没有采取这个方案.
可达性分析Java 中真正采用判断对象是否为垃圾的方案就是 "可达性分析".
可达性分析: 以代码中一些特殊的变量作为起点(GCRoot), 然后以起点出发, 判断哪些对象能够被访问到. 如果对象能被访问到, 就标记为 "可达", 当所有能被访问到的对象都被标记成 "可达" 时, 剩下的对象就是 "不可达" 的了, 就需要被当做垃圾回收了.
>>> 什么样的变量可以被称作 "起点" - GCRoot
- 局部变量表中的引用. (栈里面的局部变量)
栈有多个, 每个线程一个栈, 每个栈里面有很多栈帧, 每个栈帧里面有一个自己的局部变量表. 所有线程的所有栈的所有栈帧的所有局部变量表中的所有变量, 都可以视为起点 - GCRoot.
常量池中对应的对象.
方法区中, 静态引用类型的成员.
>>> 什么叫做能够被访问到
对于二叉树而言, 只要记住根节点, 就能判断其他结点能否被访问到了.
如果在代码中写了 root.right.right = null , 那么 F 结点就不可达了, 就可以被回收了.
如果在代码中写了 root.right = null, 那么 C 结点就不可达了, 同时 F 结点也被一起带走了(回收).
>>> 可达性分析相较于引用计数解决了两个缺陷:
可达性分析不需要引用计数器, 没有占用额外的空间;
可达性分析不会涉及到循环引用的问题.
几种垃圾回收算法
标记-清除算法(Mark-Sweep):最基础的GC算法,将需要进行回收的对象做标记,之后扫描,有标记的进行回收,这样就产生两个步骤:标记和清除。这个算法效率不高,而且在清理完成后会产生内存碎片,这样,如果有大对象需要连续的内存空间时,还需要进行碎片整理。
复制算法(Copying):新生代内存分为了三份,Eden区和2块Survivor区,保证有一块Survivor区是空闲的,这样,在垃圾回收的时候,将不需要进行回收的对象放在空闲的Survivor区,然后将Eden区和第一块Survivor区进行完全清理,这样有一个问题,就是如果第二块Survivor区的空间不够大怎么办?这个时候,就需要当Survivor区不够用的时候,暂时借持久代的内存用一下。此算法适用于新生代。
标记-整理(或叫压缩)算法(Mark-Compact):和标记-清楚算法前半段一样,只是在标记了不需要进行回收的对象后,将标记过的对象移动到一起,使得内存连续,这样,只要将标记边界以外的内存清理就行了。此算法适用于持久代。
分代回收算法
分代回收算法是通过区域划分, 实现不同区域和不同的垃圾回收策略, 从而实现更好的垃圾回收。
分带算法给对象引入了 "年龄" 的概念, 此处的年龄单位不是年, 而是对象活过 GC 的轮次. 对象刚创建出来, 没经历过 GC 的洗礼, 就认为年龄是 0, 没经过一轮 GC, 如果没被回收, 年龄就 + 1.
>>> 它是将 "复制算法" 和 "标记-整理算法" 的方案综合起来了, 只是根据对象存活周期的不同将内存划分为 "新生代" 和 "老年代" 两块大的区域. (具体要经过多少轮 GC 才会变成老年代, 我们不关心, 我们只关注策略本身.)
- 新创建的对象都放在伊甸区中. 伊甸区中的对象有个特点: 绝大部分对象都活不过一轮 GC ,
基本上就是 "朝生夕死" .
经过一轮 GC 的考验, 还存活下来的对象就通过 "复制算法" 放到了 "幸存区".
幸存区中的对象又会经过下一轮 GC 的考验, 且每经过一轮 GC 都会淘汰一部分对象, 没被淘汰的对象, 就继续通过 "复制算法" 拷贝到下一个幸存区中. (在 2 号幸存区考验时, 存活的又复制到 1 号幸存区) 这个过程, 既保留了复制算法的高效, 无内存碎片的优势, 又避免了过多的浪费空间, 因为此处浪费的幸存区的空间, 它相对整体空间来说, 微乎其微.
当对象在幸存区中经历了多轮 GC , 仍然没有被销毁, 就认为该对象一时半会不会被销毁, 于是就把这个对象拷贝到 "老年代" 了.
老年代的对象, 也要经历 GC 的考验, 只是考验的频率大大降低了, 如果老年代的对象即将要被回收, 就是用 "标记-整理" 算法, 因为老年代对象被回收的频率不高, 就可以接受 "标记-整理" 算法带来的时间开销.
还要注意的是: 有一个小的例外, 如果当前有一个特别大的对象, 就不经历上述分代回收过程, 直接就进入老年代, 因为大的对象在复制算法中, 是不太友好的.