Java的GC

垃圾收集

在探究Jvm的过程中,有两个点特别需要关注,一是:内存的使用,分配策略,而这一点是在前一篇博客已经介绍过了。 二是:内存的回收。也就是这一篇博客所要探究的关键点。

内存回收需要关注的几个点:

  1. 什么样的内存需要被回收?
  2. 在什么时候回收内存?
  3. 通过怎样的方式进行内存回收?

哪些内存需要被回收?

在上一篇已经提到过,在Java中,内存区域的使用主要分为这样几个板块,线程级别的 程序计数器,虚拟机栈,本地方法栈,JVM级别的,方法区, 堆。

其中线程级别的虚拟机栈,本地方法栈,是不太需要考虑内存回收问题的,因为这基本就是在编译期就已经能够确定究竟需要多少内存的,同时在方法结束及线程结束的时候,相应的内存区域就已经可以被回收了。

而方法区,在Java7以前,其数据依然是存放在 堆中, 采用永久代的方式对方法区的内存进行回收,在Java8以后,方法区中存储的数据则被拆分到各个区域中去。

如字符串,其原先的运行时常量池则是被放在Java堆内存中,而相关的Class相关信息,则是被存储在元空间中,元空间则是Jvm开辟出来的,占据本地内存的一块内存。

而元空间的内存回收则是与类加载器息息相关,这点打算等看了类加载器之后,回过头来看看。

参考:Metaspace 之一:Metaspace整体介绍(永久代被替换原因、元空间特点、元空间内存查看分析方法)

那么Java内存回收的最大难点,也是最大的关注点则是被放在了堆内存的回收上。

那个问题依然存在,究竟哪些内存需要被回收?

这里就需要提到两个算法:

  1. 引用计数算法

    为每一个对象都维护一个引用计数器,每当有一个地方引用它时,计数器加一,当引用失效时,计数器减一,在任何时候计数器都为0的对象,则表示对象不可能再被使用,对象已死,需要被删除。

    但它依然存在一个很致命的问题,循环引用的问题:

     public class A {
         private Object instance;
     }
    
     public static void testGc() {
         A a = new A();
         A b = new A();
         a.instance = b;
         b.instance = a;
         a = null;
         b = null;
     }
    

    在上面的例子中,虽然a, b两个对象都再也没法使用,但其instance依然持有双方的引用,引用计数器不为0,对象则不能被清除掉。而事实上,相互持有对方引用这种情况并不罕见。

    而第二个缺点则是:

    (2)引用计数的方法需要编译器的配合,编译器需要为此对象生成额外的代码。如赋值函数将此对象赋值给一个引用时,需要增加此对象的引用计数。还有就是,当一个引用变量的生命周期结束时,需要更新此对象的引用计数器。引用计数的方法由于存在显著的缺点,实际上并未被JVM所使用。

  2. 根搜索算法

    在主流的语言中,大都通过 可达性分析 来判定对象是否存活的。

    其核心要点则是通过一系列称为 “GC Roots”的对象作为起始点,向下搜索,搜索所走过的路程称为引用链,当一个对象到 GC Roots没有任何引用链相连时,表示当前对象不可达。即表示对象已死。

    而在Java中, 可以作为GC Roots的对象包括以下几种:

    1、虚拟机栈中的引用的对象。

    2、方法区中的类静态属性引用的对象。

    3、方法区中的常量引用的对象。

    4、本地方法栈中JNI的引用的对象。

    第一和第四种都是指的方法的本地变量表,第二种表达的意思比较清晰,第三种主要指的是声明为final的常量值。

Java中的引用类型

很早之前就听说过,相关的引用概念:

强引用,软引用,弱引用,虚引用, 这样四种引用类型。

在网上有许多博客已经介绍相关概念, 我就不详细描述了。

参考:

Java中的四种引用类型 Strong, Soft, Weak And Phantom

Java 如何有效地避免OOM:善于利用软引用和弱引用

强引用是我们常规所使用的引用类型, Object obj = new Object();这种形式的引用。它完全遵守上面介绍过的 GC规则。

软引用:SoftReference,

这个对象并不参与垃圾收集的相关算法,而是在内存即将耗尽之前,会被回收。

使用方法则是:

import java.lang.ref.SoftReference;

public class Main {
    public static void main(String[] args) {
        
        SoftReference<String> sr = new SoftReference<String>(new String("hello"));
        System.out.println(sr.get());
    }
}

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被JVM回收,这个软引用就会被加入到与之关联的引用队列中。可以根据对应的引用队列,决定我们的后续操作。

软引用主要使用在哪些地方呢?

目前能看到的地方只有缓存,需要保证即使对象被清除也能够再度创建相应的对象,不影响正常功能的使用。

不仅仅是我们通常使用的缓存,还有临时创建的缓存文件,在上传大量图片的时候,将图片对象存储为弱引用。则可以在耗费内存过多时,清除相关内存。

具体用法则是:

private Map<String, SoftReference<Picture>> imageCache = 
new HashMap<>();

能够发现,在使用软引用的地方,首先需要能够重新创建对应的对象,如果数据需要被处理,则需要保存相关数据的标识。 满足这些条件, 根据不同的使用情况再决定是否使用 软引用。

弱引用:WeakReference

而弱引用,与软引用区别并不是非常大, 仅仅是回收机制略有区别, 弱引用会在下一次进行GC回收的时候,回收相关对象。

弱引用是在第二次垃圾回收时回收,短时间内通过弱引用取对应的数据,可以取到,当执行过第二次垃圾回收时,将返回null。

弱引用主要用于监控对象是否已经被垃圾回收器标记为即将回收的垃圾,可以通过弱引用的isEnQueued方法返回对象是否被垃圾回收器标记。

别人给的答案是上面那句, 我不是太理解,作用究竟是什么?

而在我理解,则是需要和Java的GC清理时间相结合理解, 有一个安全域的概念。在安全域之前使用并处理相关对象, 进入安全域之后 即使处理也并不会产生影响。

虚引用:PhantomReference

在前三种引用中,依然可以取到相关对象, 而在虚引用中和弱引用和软引用均不同。它控制其指向的对象非常弱(tenuous),以至于它不能获得这个对象。get()方法通常情况下返回的是null值。它唯一的作用就是跟踪列队在ReferenceQuene中的已经死去的对象。

WeakHashMap

在我理解来, WeakHashMap 是Java推出的,对弱引用的一种主要使用方式。

其源码什么的,也就不去解读,如果想了解的可以去看看这篇博客,WaskHashMap,在我目前的核心点,是GC,实现都是次要的。

WeakHashMap的主要特点则是在于, 其键 为 WeakReference, 当键被回收,其对应的值也会被回收, 并没有太多特别的地方。

需要注意到的一点则是:

对于以上提到的几种引用,虽然会被回收,但是不得不注意到的另一个事实则是:

String a = new String("Str");
WeakReference<String> b = new WeakReference<>(a);

只有当 a = null, 进行过这种类似的赋值之后, 消除强引用,(需要消除的前提是,这个强引用在别的地方依然被使用) 弱引用才会起到相关的作用。因此,不难看出, 其实最适合使用 虚引用, 弱引用的地方是在 集合中。

Map<String, String> map1 = new HashMap<>();
WeakHashMap<String, String> map2 = new WeakHashMap<>();
for (int i = 0; i < 100000; i++) {
    String a = new String("i" + i);
    String b = new String(i + 2 + "");
    WeakReference<String> realA = new WeakReference<>(a);
    map2.put(b, a);
    map1.put(b, a);
}
Thread.sleep(1000L);
System.gc();
//在当前模式下,会清空map2中的引用。
for (Object o : map2.keySet()) {
    System.out.println(o);
}
//这一步赋值如果缺少, 在之后并不会使用map1, 导致GC时map1对象并不//能被算作GC-roots对象,因此就会清除 map2;
Object c = map1;

方法区的内存回收

在Java8之前, 方法区是存储在永久代的, 而相应的内存回收也主要回收两部分,废弃常量,及无用的类。

废弃常量指的是字符串常量, 无用的类则是一个比较难判别的概念,有以下标准:

  1. 该类所有实例都已经被回收
  2. 加载类的classLoader也被回收
  3. 类对应的java.lang.Class 也不在任何地方被使用,反射也包含其中。

特别是第三条,这里需要进行测试才能得出相关结论,因为在项目中遇到过一种情况,即从数据库获取类名,加载,反射。这种动态获取类名的方式,又是怎样判断 类 可以被回收?

在这里经过测试之后发现, 在存在反射的地方, Java类是无法被回收的。

而我们在框架中常常会使用反射这种操作, 而相应的jsp Class文件是否也是一经加载就会出现无法卸载的情况? 也就是在日常项目中,类只会越加载越多,而无法卸载。

在Java8以后则换成了元空间, 如果Metaspace的空间占用达到了设定的最大值,那么就会触发GC来收集死亡对象和类的加载器。根据JDK 8的特性,G1和CMS都会很好地收集Metaspace区(一般都伴随着Full GC)。

在这里就可以通过创建一个比较大的类,在类中定义常量 byte数组,以占用所有内存空间, 通过反射再去创建对象,而后观察,类是否会被回收, 需要通过:

-XX:MaxMetaspaceSize -XX:MetaspaceSize来调控元空间内存大小,-XX:MinMetaspaceFreeRatio -XX:MaxMetaspaceFreeRatio 来调整相应比例。

内存的回收算法

标记-清除算法

这种方式,是先对对象进行标记,结束之后,统一回收。很简单,也有相应的问题,尤为突出的是会导致产生大量的 不连续的内存碎片, 当需要分配一个较大对象时, 就会因为找不到足够的内存而不得不提前触发另一次垃圾收集动作。

而我们先跑题一下,来关注下Java中大对象会是个什么概念,更准确的说是,关注一下需要连续内存的大对象究竟是个什么概念:

就我目前所了解到的,在Java中,乃至于任何语言中,数组其所需要的内存空间事实上都是连续的,这样才能够达成所谓的RandomAccess, 也就是只需要知道数组的起始地址, 索引, 就能够跳过中间步骤直接访问对应索引的内存空间。

而另一点则是, Java对象在创建的时候,其大小就已经确定,如果含有相应的数组属性,内存大小也是确定的,并不会因为是否填充而导致内存产生变化。

毋庸置疑的一点是:数组本身就会导致对象成为大对象,过大的话,会引发GC。

那么对于堆内存呢? String类型的底层是 char[], 也是同样的数组,因此特别长的字符串, 也会成为大对象。我们知道 char类型都只占用2个字节,需要1MB内存的的字符串长度则是 50w左右。

而另一种数据结构: HashMap, 又或者是List

底层也同样都是数组,对每一个对象,存储其引用,在64位机器上是8个字节,也就是说12w对象就需要连续的1M内存空间来存储。

目前这方面了解还太少,对于一个对象占用内存究竟是多是少只有一个模糊的概念。

而这种所谓的大对象是极为令人讨厌的, 比遇到一个大对象更令人讨厌的是遇到一堆朝生夕死的大对象。写程序时需要避免这个问题。

虚拟机提供了一个 -XX:PretenureSizeThreshold参数, 令大于这个设置值的数据直接被放在老年代分配。

回归正题: 正是因为这样的原因,又有了另一种算法

复制算法

复制算法将内存划分为两个区间,在任意时间点,所有动态分配的对象都只能分配在其中一个区间(称为活动区间),而另外一个区间(称为空闲区间)则是空闲的。

当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程。接下来GC线程会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址。

此时,空闲区间已经与活动区间交换,而垃圾对象现在已经全部留在了原来的活动区间,也就是现在的空闲区间。

不难看出,这种算法会导致仅有一半的内存拿出来被使用,这是一种极大的浪费。
同时如果存活对象过多,复制来复制去,浪费时间。

结论则是这种算法适合用在,存活对象较少的地方。

而新生代则是满足这一要求,每次存活对象极少。同样的,对内存的使用率也可以调高,不需要空闲50%, 百分之10左右就可以。

那么按照这种设计给 Eden空间分配90%内存, Survivor空间分配10%内存就可以了吧。

答案是不行的。

在这里忽略了一个很关键的问题, 无论是 Eden区还是 Survivor区,两个区域仍然都属于新生代,当Eden区域被复制之后,存活对象可以送至 Survivor区域,但这并不意味着Survivor区域就不存在需要被清理的对象了,那这个区域的存活对象又该如何处理呢?

因此,在HotSpot采取的实现中, 用了两块 Survivor内存, 各占10%, Eden区域占据80%, 每次使用一块 Survivor和Eden区域。

而能够确保每次迁移时,Survivor内存都够用吗? 存活的对象一定很少吗?

因此又需要其他内存来进行协助, 则是老年代内存, 进行内存担保。

这里也同样会有一个有趣的问题,如果新生代对象还没有满足被移入老年代的条件, 而此时 Survivor区域不足以放下此刻存活的对象。按照我们的说法,这部分对象需要被移入老年代。

而问题,在稍后会提到。

标记整理算法

自然而然的,对于老年代这种大量对象都是不满足GC条件的区域怎么办?

在这里采用的便是标记整理,顾名思义,先将对象进行标记,然后将对象统一移动位置,死亡对象与存活对象通过空间分割开来。而后统一清理死亡对象。

Java中GC

而在Java中的GC算法还是基于上述几种原理,组合而来的,也同样有了更适用的名字,叫做 分代搜集算法。

所谓分代,则是根据老年代,新生代的数据对象特性,分别采取不同的GC算法而已。

而Java中也应用的有几种收集器,在这里并不做相关介绍。

仅仅提出几个比较关键的概念:

  1. stop the world

    当我们通过可达性分析算法,也即GC-Roots这样的方式进行分析的时候,必须知道当前环境下所有的 可以用作GC-roots的对象,同时要顺着引用链查找,而后标记相关死亡,存活对象。

    可想而知,我们不可能在创造对象的同时又对每一个对象进行标记, 因此需要暂停Java世界。

  2. Java Client模式和Server模式
    而通过:

     java -version
    

    可以直接查看当前是Client模式还是Server模式。

    参考:JVM的Client模式与Server模式

    而总结起来就是说, Client是轻量级的比较适合在开发环境使用, Server适合运行在生产环境下, 启动较慢, 但是长期稳定运行之后比起client要快很多。

    同时在64位系统下,只支持Server模式。

  3. 并行和并发

    这里仅仅是指在GC语境下:

    并行:多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态

    并发:指用户线程与垃圾收集线程同时执行(但并不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上

    在windows下:

     java -XX:+PrintCommandLineFlags -version
    

    通过上述命令查看当前所使用的GC收集器。

  4. 迁移

    对象在何时会从新生代迁移到老年代呢? 对象在Survivor空间中每活过一次,计数加一, 当达到默认15时,就会进入老年代。

    这个值得配置是:

     -XX:MaxTenuringThreshold
    
posted @ 2018-10-25 06:46  千江月09  阅读(274)  评论(0编辑  收藏  举报