美文网首页
[笔记]Java的内存分配和垃圾回收——垃圾回收

[笔记]Java的内存分配和垃圾回收——垃圾回收

作者: 蓝灰_q | 来源:发表于2017-09-07 21:16 被阅读70次

    什么是Java中的垃圾

    Java的内存,主要分配给栈、堆和方法区。
    栈的内存是固定的,只与类结构有关,在运行前就是确定的,在方法或线程结束时就能回收。
    堆和方法区的内存是动态的,比如接口有不同的实现类,内存都不一样,比如方法可能走不同的逻辑分支,内存也不一样。所以堆和方法区的内存,只能在运行期间确定,必须动态地分配和回收,这就会产生垃圾。
    所以,Java的垃圾回收,针对的就是堆和方法区。
    堆和方法区都是引用对象(基本类型都在栈里),所以要回收的,其实就是不再被人引用的对象,如果一个内存(堆或方法区中)中的对象,没有任何引用,那么这个对象就是无用的对象,需要回收,腾出内存。

    如何判断垃圾

    引用
    要判断一个对象是不是垃圾,主要检查它有没有被引用。

    1. 引用计数
      最简单的做法,就是把对象的引用做一个统计,如果引用为0,就是垃圾,可以回收。
      但是,引用计数最大的bug在于循环引用,如果两个对象互相引用了,那么它们就没有机会被回收。
    2. 可达性分析
      现在最常用的是可达性分析法,我们定义了四种GC Roots,如果一个对象向上层层引用之后,能是直接或间接被GC Roots引用,就认为这个对象到GC Roots之间是可达的,就不是垃圾。
      所以也可以说,如果一个对象到GC Roots之间有连通的路径,就是可达的。
      Java中可以被作为GC Roots中的对象有:
      .虚拟机栈中的引用的对象。(栈里引用的对象)
      .本地方法栈(jni)即一般说的Native的引用对象。(JNI引用对象)
      .方法区中的类静态属性引用的对象。(方法区静态对象)
      .方法区中的常量引用的对象。(方法区常量对象)

    强软弱虚四种引用
    Java通过引用判断对象是否可以回收,为了增加一定的灵活性,Java给引用本身做了细分,分为强软弱虚四种引用:

    1. 强引用是不可回收。
    2. 软引用是在内存不足时回收。
    3. 弱引用是不管内存够不够用,只要遇上了GC扫描,都要回收,不过GC扫描的优先级很低,不会很快触发回收。弱引用可以和引用队列一起使用。
    4. 虚引用(java.lang.ref.PhantomReference)实际上是个引用关联,必须和引用队列(Reference Queue)一起使用,本身无法实例化,被虚引用关联的对象,可以在任何时候回收,设置虚引用,一般是为了在对象被回收时收到一个系统通知。

    另外,在引用里还有两个常用的类:

    1. 引用队列(ReferenceQueue),本身不属于引用类型,但是和引用类型配合使用,就可以在对象被回收时收到一个系统通知,如果ref类引用了obj,当obj被gc时,ref就会被放到ReferenceQueue中,我们可以对ref做额外处理,例如数据清理。
    2. WeakHashMap,它将WeakReference作为key对象,如果key被gc了,那么key的整个entry会被移除,WeakHashMap的size就会发生变化(在调用get时,先处理被gc的key值,再把entry重新链接起来)。

    二次标记
    稳妥起见,失去引用的对象并不会立即被回收,需要经过两次标记,才会真正被判定为可回收的垃圾对象,这就是二次标记。

    1. 第一次标记
      如果发现一个对象失去了可达性(无法链接到GC Roots),就会对它进行第一次标记。
    2. 过滤和finalize
      针对被第一次标记的对象,JVM判断它是否需要执行finalize。
      如果这个对象没有覆盖finalize函数,或者已经被执行过一次finalize函数(finalize函数只会被执行一次),就直接跳过,等待第二次标记;
      如果这个对象可以执行finalize,JVM会把它放进一个专门的F-Queue队列里,由一个低优先级的Finalizer线程去悠闲地触发finalize,如果在此期间或者在finalize函数中,这个对象又重新实现了可达性(比如被赋给某个对象引用),就取消它的标记。
    3. 第二次标记
      在下一次分析中,被标记过一次的对象,会被标记第二次,被标记两次的对象,就是垃圾对象,可以回收了。

    方法区的垃圾
    一般来说,堆中的对象会频繁生灭,垃圾回收效率较高,但是方法区的常量和类其实很少回收,所以一般来说它的垃圾回收效率低,但是对方法区的回收是有必要的,特别是反射、动态代理、自定义ClassLoader等,都是可能导致方法区内存溢出的。
    方法区的垃圾来源无非是常量和类。
    常量回收相对简单,如果一个常量,当下没有任何引用,就可以回收。
    类的回收就很苛刻了,要求如下:

    1. 堆中不再有该类的实例对象
    2. 加载这个类的ClassLoader已经被回收
    3. Class没有被引用,也无法通过反射访问到类的方法

    如何回收垃圾

    找到垃圾之后,就要收集回收,不同厂家的JVM,可能有不同的操作方式。

    1. 标记-清除法
      标记找到的垃圾,然后统一回收,在哪儿找到,就在哪儿回收,缺点很明显,效率不高+内存碎块化严重。
    2. 复制算法
      基本原理:复制算法的基本原理,就是把整个内存分成两半,每次只使用一半,另一半空着,这一半装满以后,就把其中还活着的对象,一个挨一个复制到另一半内存上,这样每次都能腾出大片的内存空间,没有内存碎块化的问题。
      改良:但是复制算法的内存浪费很严重,所以改善做法是,不分成两半,而是分成一大块和两小块(一般是8:1:1),大块称为Eden,小块称为Survivor,每次只保留一个小块,这样就从浪费50%降为浪费10%。在需要回收时,就把Eden和Survivor1里的存活对象全部放进空闲的那个Survivor2,然后清空后的Eden和这个Survivor2一起使用,而Survivor1改为空闲内存,然后不断循环下去。
      用途:复制算法适用的场景,是内存会被大量回收的场景,所以一般在新生代内存回收中采用这种方式,不过,即使是新生代,也有可能会遇到空闲Survivor不够用的情况(存活内存大于10%),这时候还需要使用分配担保。
      分配担保:分配担保会把内存放进老年代,背后的思想就是,如果新生代的内存不够,可以使用老年代的剩余内存,尽量避免Full GC。
    3. 标记-整理法
      复制算法比较适合新生代,因为它在回收较多,存活较少时才有效率,如果反过来存活较多,它就需要进行很多次复制,效率低下,所以复制算法不适合老年代。
      老年代一般可以用标记-整理法,它是把存活的内存全部向一端移动,这样空余下来的内存就是一大块完整内存,既能克服标记-清除法带来的碎片化问题,又能避免复制算法频繁复制效率低下的问题。
    4. 分代回收算法
      分代回收其实就是综合使用复制算法和标记-整理法,把Java堆分成新生代和老年代,然后分别使用不同的算法。

    内存的分配与回收策略

    Java的自动内存管理,其实就是实现了自动分配内存和自动回收内存,主要是在堆上操作,在分配和回收上有如下特点:

    1. 优先分配在Eden区
      一般的对象是很可能回收的,所以要放到新生代Eden区,如果Eden区空间不足,JVM会做一个小规模的GC(Minor GC)。
    2. 大对象直接进老年代
      大对象需要连续内存空间,很容易触发GC,直接放到老年代可以避免在Eden和Survivor区连续发生大量复制。
    3. 长期存活对象进老年代
      每个对象有一个年龄计数器,每次被移动到Survivor,年龄就+1,如果年龄足够(比如15),就认为这个对象需要长期保持,就可以放进老年代。
    4. 年龄动态判定
      某些情况下,对象可以提前进入老年代,比如,Survivor中某个年龄(如7)的对象占用的内存总和很大,超过Survivor的一半,那么这个年龄的对象就全部进入老年代。
    5. 分配担保
      分配担保最终是用来判断要不要进行Full GC,因为Full GC开销很大,JVM尽量进行Minor GC,这就需要老年代做分配担保,具体就是如果老年代的剩余最大连续内存空间足够大,一定能容纳新生代的所有对象,或者退一步(有一个HandlePromotionFailure设置值),能容纳历次晋升到老年代的对象的平均大小,就可以进行Minor GC(当然,在退一步额情况下是有风险的)。在分配担保的情况下,如果Survivor空间不足,就会把新生代的对象放进老年代,一般情况下就完成了Minor GC,万一老年代担保失败,就会触发Full GC。

    垃圾收集器

    垃圾收集器是由各厂商自己实现的,互相可能都不一样,性能各有侧重,我们有时候需要根据实际情况选择最合适的垃圾收集器,垃圾收集器可以粗略分为这么几种(实际上有很多种):
    1.串行垃圾回收器(Serial Garbage Collector)
    暂停所有用户线程,用一个单线程处理垃圾回收。
    2.并行垃圾回收器(Parallel Garbage Collector)
    暂停所有用户线程,用多线程处理垃圾回收。
    3.并发标记扫描垃圾回收器(CMS Garbage Collector)
    CMS可以尽量少的暂停用户线程,它有1初始标记-->2并发标记-->3重新标记-->4并发清除四个步骤,只有1初始标记和3重新标记会暂停用户进程,但是这两个步骤耗时很短,而2并发标记和4并发清除这两个步骤中,都是和用户线程并行的。
    所以CMS比较耗费CPU。
    4.G1垃圾回收器(G1 Garbage Collector)
    把堆分成很多部分,并发地进行垃圾回收。而且它会在回收内存后,压缩剩余的堆内存空间。

    这四种垃圾收集器有一张形象的表述:


    图片来自Java GC系列(3):垃圾回收器种类

    常见的内存泄露

    Java的垃圾回收并不是万能的,操作不当的话很容易引发内存泄露,常见的泄露场景包括:

    1. static静态集合类如HashMap,Vector等,因为他们的生命周期一般与应用程序一致,所以静态集合类中引用的对象,就无法被回收。
    2. 普通集合的元素属性修改(导致hashcode值变更),导致remove失败,及时调用了remove,但该元素仍保留在集合中,被集合引用,无法被回收。
    3. static静态变量的引用对象,比如单例对象会使用一个static静态变量,静态变量的生命周期与应用程序一致,所以如果该对象又引用了某个外部对象,该外部对象就会无法回收。
    4. 各种数据库连接、IO连接、网络连接等,这些连接不会自动回收,必须close连接后才能释放,否则也无法被回收,如果使用了连接池,还有关闭Resultset和Statement 对象。
    5. 各种listener,如果addlistener之后,没有及时删除监听器,造成监听器持有对象,也无法被回收。
    6. 内部类,内部类(包括匿名内部类)其实是通过编译器的语法糖实现的,在真正编译后的构造函数中会引用所在的外部类(外部类的对象作为内部类的构造参数),所以内部类持有外部类的引用对象,这样外部类就无法被回收。所以一般推荐使用静态内部类(不直接引用外部类)+软/虚引用(间接引用外部类)的方式,比如Handler对象,就最好使用静态内部类+虚引用来实现。
    7. 资源未关闭,主要有BroadcastReceiver,ContentObserver,File,游标 Cursor,Stream,Bitmap等。

    引用

    《深入理解Java虚拟机》
    Java:对象的强、软、弱和虚引用
    ReferenceQueue的使用
    Java 内存分配全面浅析
    Java虚拟机-----方法区和运行时常量池
    Java内存分配之堆、栈和常量池
    Java常量池理解与总结
    Java GC系列(3):垃圾回收器种类
    Java中关于内存泄漏出现的原因以及如何避免内存泄漏(超详细版汇总上)
    Java 类加载机制 ClassLoader Class.forName 内存管理 垃圾回收GC

    相关文章

      网友评论

          本文标题:[笔记]Java的内存分配和垃圾回收——垃圾回收

          本文链接:https://www.haomeiwen.com/subject/ocxvjxtx.html