美文网首页
java的内存分配和垃圾回收

java的内存分配和垃圾回收

作者: 积跬步以致千里_ylc | 来源:发表于2020-08-10 16:37 被阅读0次

    学习垃圾回收机制的意义

    java和C++等语言的最大技术区别:自动化的垃圾回收机制(GC: Garbage Collection)
    为什么要了解GC和内存分配策略:

    • GC对应用的性能是有影响的
    • 可以写成更优更健壮的代码
      1)栈:栈的生命周期是跟随线程的,所以一般是不需要关注的
      2)堆:堆中的对象是垃圾回收的重点
      3)方法区/元空间:这一个块也会发生垃圾回收,不过这一块的效率比较低,一般也不是应用开发关注的重点

    通个一个实例来进行垃圾回收的分析

    • 实例代码如下
     public static void main(String[] args) {
            List<Object> list = new LinkedList<>();
            int i = 0 ;
            while (true){
                i++;
                list.add(new User());
                if(i % 10000 == 0) System.out.println("第"+i+"次");
            }
        }
    
        static class User{
            String name = "name";
            int age = 26;
            String sex = "女";
            String description = "is a kindness girl";
        }
    
    • VM参数设置的命令
      1)-Xms:堆区内存初始内存分配的大小 (-Xms30m:堆的初始分配内存是30M)
      2)-Xmx:堆区内存可被分配的最大上限
      3)-XX:+PrintGCDetails:打印GC详情
      4)-XX:+HeapDumpOnOutOfMemoryError:当堆内存空间溢出时输出堆的内存快照
      5)在VM options中设置命令时 每个命令的有一个空格的间隔
    • 运行实例代码的结果:


      大概运行了500000次左右出现OOM

      6)GC overhead limit exceeded 超过98%的时间用来做GC并且回收了不到2%的堆内存时会抛出此异常
      -6.1).垃圾回收会占据资源
      -6.2)回收效率过低也会有限制

    堆的进一步划分

    • 新生代:PSYoungGen
      1)eden 空间
      2)from Survivor 空间
      3)to Survivor 空间
    • 老年代:ParOldGen

    堆中空间的大小设置和分配命令--VM参数

    • 新生代大小: -Xmn20m 表示新生代大小20m(初始和最大)
    • -XX:SurvivorRatio=8 表示Eden和两个Survivor的比值,缺省为8 表示 Eden:From:To= 8:1:1
      如果比值为2 则表示 Eden:From:To= 2:1:1

    GC如何判断对象的存活

    • 引用计数法:优点:快,方便,实现简单,缺点:对象相互引用时,很难判断对象是否该回收。(PHP语言在用)
    • 可达性分析: 这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,作为GC Roots的对象包括下面几种:
      1)虚拟机栈(栈帧中的本地变量表)中引用的对象。
      2)方法区中类静态属性引用的对象。
      3)方法区中常量引用的对象。
      4)本地方法栈中JNI(即一般说的Native方法)引用的对象。

    各种引用

    • 强引用
      一般的Object obj = new Object() ,就属于强引用。
    • 软引用 SoftReference
      一些有用但是并非必需,用软引用关联的对象,系统将要发生OOM之前,这些对象就会被回收。参见代码:VM参数 -Xms5m -Xmx5m -XX:+PrintGC ,编写如下测试代码:
     static class Student {
            int id;
            String name;
    
            public Student(int id, String name) {
                this.id = id;
                this.name = name;
            }
    
            @Override
            public String toString() {
                return "Student{" +
                        "id=" + id +
                        ", name='" + name + '\'' +
                        '}';
            }
        }
    
        public static void main(String[] args) {
            //new 是强应用
            Student student = new Student(20200810, "学霸A");
            //创建一个Student的软应用
            SoftReference<Student> softStu = new SoftReference<>(student);
            //干掉Student的强引用只留下 软引用
            student = null;
            System.out.println(softStu.get());
            //进行一次垃圾回收,为什么不能手动的回收软引用,因为这个软引用在虚拟机栈的本地变量表中
            System.gc();
            System.out.println("GC 回收后:");
            System.out.println(softStu.get());
            //向堆中无限制的添加数据 制造OOM
            List<byte[]> list = new LinkedList<>();
            try {
                while (true) {
                    //每一次循环就向list中添加1M的数据
                    System.out.println("------------>" + softStu.get());
                    list.add(new byte[1024 * 1024 * 1]);
                }
            } catch (Throwable e) {
                //抛出OOM异常时 查看软引用是否被回收
                System.out.println("报OOM异常时 软引用的值:" + softStu.get());
                // 为什么这个案例中,要oom的时候已经回收了软引用还是回爆出OOM异常
               //因为 while 循环还在不停的向list中添加对象 还在继续分配堆的内存,只是回收一个软引用
               //堆的内存仍然不够用的。
            }
        }
    

    运行结果:

    软引用在内存不够用的使用回收测试
    软引用使用的场景:假如需要一个程序用来处理用户提供的图片。如果将所有图片读入内存,这样虽然可以很快的打开图片,但内存空间使用巨大,一些使用较少的图片浪费内存空间,需要手动从内存中移除。如果每次打开图片都从磁盘文件中读取到内存再显示出来,虽然内存占用较少,但一些经常使用的图片每次打开都要访问磁盘,代价巨大。这个时候就可以用软引用构建缓存。
    • 弱引用 WeakReference
      一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC发生时,不管内存够不够,都会被回收。
      弱引代码说明
      ** 注意的事项:**软引用 SoftReference和弱引用 WeakReference,可以用在内存资源紧张的情况下以及创建不是很重要的数据缓存。当系统内存不足的时候,缓存中的内容是可以被释放的。
      WeakReference在JDK中的实际运用(WeakHashMap、ThreadLocal)
    • 虚引用 PhantomReference
      幽灵引用,最弱,随时会被回收,但是在被垃圾回收的时候会收到一个被回收的通知,主要用于JVM层。

    垃圾回收的算法

    • 复制算法(Copying)

      赋值算法示意图
      将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。
      注意:内存移动是必须实打实的移动(复制),不能使用指针玩。
      专门研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor[1]。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
      HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)
      赋值算法特点:
      1)实现简单、运行高效
      2)内存复制、没有内存碎片
      3)利用率只有一半
    • 标记-清除算法(Mark Sweep)

      标记-清除算法示意图
      算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
      它的主要缺点是:空间不足问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
      标记-清除算法特点:
      1)利用率百分之百
      2)不需要内存复制
      3)有内存碎片
    • 标记-整理算法(Mark Compact)

      标记-整理算法示意图
      首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
      标记-整理算法特点:
      1)利用率百分之百
      2)没有内存碎片
      3)需要内存复制
      4)效率一般般

    堆内存分配策略

    • 对象优先在Eden分配,如果说Eden内存空间不足,就会发生Minor GC
    • 大对象直接进入老年代,大对象:需要大量连续内存空间的Java对象,比如很长的字符串和大型数组,1、导致内存有空间,还是需要提前进行垃圾回收获取连续空间来放他们,2、会进行大量的内存复制。
    • **-XX:PretenureSizeThreshold 参数 **,大于这个数量直接在老年代分配,缺省为0 ,表示绝不会直接分配在老年代。
    • 长期存活的对象将进入老年代,默认15岁(在from 或 to 区经历了15次的回收),
      -XX:MaxTenuringThreshold调整 进入老年代的年龄。
    • 动态对象年龄判定,为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
    • 空间分配担保:新生代中有大量的对象存活,survivor空间不够,当出现大量对象在MinorGC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代.只要老年代的连续空间大于新生代对象的总大小或者历次晋升的平均大小,就进行Minor GC,否则FullGC。在新生代进行的GC叫做minor GC,在老年代进行的GC都叫major GC,Full GC同时作用于新生代和老年代。

    在JVM中实际使用分代收集(各种算法组合)

    分代收集示意图
    新生代和老年代的回收算法解析
    • Serial/Serial Old
      最古老的,单线程,独占式,成熟,适合单CPU 服务器
      -XX:+UseSerialGC 新生代和老年代都用串行收集器
      -XX:+UseParNewGC 新生代使用ParNew,老年代使用Serial Old
      -XX:+UseParallelGC 新生代使用ParallerGC,老年代使用Serial Old

    • **ParNew **
      和Serial基本没区别,唯一的区别:多线程,多CPU的,停顿时间比Serial少
      -XX:+UseParNewGC 新生代使用ParNew,老年代使用Serial Old

    • Parallel Scavenge(ParallerGC)/Parallel Old
      关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
      所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

    • Concurrent Mark Sweep (CMS)
      收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
      从名字(包含“Mark Sweep”)上就可以看出,CMS收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:
      1)初始标记-短暂:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
      2)并发标记:和用户的应用程序同时进行,进行GC RootsTracing的过程
      3)重新标记-短暂:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
      4)并发清除:由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
      -XX:+UseConcMarkSweepGC ,表示新生代使用ParNew,老年代的用CMS
      浮动垃圾:由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。

      CMS示意图
    • G1

      G1工作示意图
      开启参数 -XX:+UseG1GC
      并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
      分代收集:与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
      空间整合:与CMS的“标记—清理”算法不同,G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
      内存布局:在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。
      1)新生代GC
      回收Eden区和survivor区,回收后,所有eden区被清空,存在一个survivor区保存了部分数据。老年代区域会增多,因为部分新生代的对象会晋升到老年代。
      2)并发标记周期
      初始标记:短暂,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,产生一个全局停顿,都伴随有一次新生代的GC。
      根区域扫描:扫描survivor区可以直接到达的老年代区域。
      并发标记阶段:扫描和查找整个堆的存活对象,并标记。
      重新标记:会产生全局停顿,对并发标记阶段的结果进行修正。
      独占清理:会产生全局停顿,对GC回收比例进行排序,供混合收集阶段使用
      并发清理:识别并清理完全空闲的区域,并发进行
      3)混合收集
      对含有垃圾比例较高的Region进行回收。
      G1当出现内存不足的的情况,也可能进行的FullGC回收。
      G1中重要的参数:
      -XX:MaxGCPauseMillis 指定目标的最大停顿时间,G1尝试调整新生代和老年代的比例,堆大小,晋升年龄来达到这个目标时间。
      -XX:ParallerGCThreads:设置GC的工作线程数量
      JDK1.7才正式引入G1,采用分区回收的思维:基本不牺牲吞吐量的前提下完成低停顿的内存回收;可预测的停顿是其最大的优势.
      吞吐量= CPU的时间100% GC 10% 业务线程90% = 业务线程/总时间 (吞吐量90%), GC 98% 业务线程2% 吞吐量2%

    Stop The World现象

    在垃圾回收过程中经常涉及到对对象的挪动(比如上文提到的对象在from和to之间的复制),进而导致需要对对象引用进行更新。为了保证引用更新的正确性,Java将暂停所有其他的线程,这种情况被称为“Stop-The-World”,导致系统全局停顿。Stop-The-World对系统性能存在影响,因此垃圾回收的一个原则是尽量减少“Stop-The-World”的时间,GC收集器和我们GC调优的目标就是尽可能的减少STW的时间和次数。


    红色的GC线程,绿色是业务线程
    尽量减小GC暂停线程的时间

    内存泄漏和内存溢出辨析

    • 内存溢出:实实在在的内存空间不足导致。OOM
    • 内存泄漏:该释放的对象没有释放,多见于自己使用容器保存元素的情况下。 分成4个情况:
      1)常发性内存泄漏:发生内存泄漏的代码会被多次执行,每次被执行的时候都会导致一块内存泄漏。
      2)偶发性内存泄漏:发生内存泄漏的代码只有在某些特定环境或者操作过程才会发生,常发性和偶发性是相对的,对于特定的环境,偶发性的也许就变成了常发性,所以测试环境和测试方法对检测内存泄漏至关重要。
      3)一次性内存泄漏:发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,所以内存泄漏只会发生一次。
      4)隐式内存泄漏:程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。
    • 从用户使用程序的角度来看,内存泄漏本身不会产生什么危害,作为一般的用户,根本感觉不到内存泄漏的存在。真正有危害的是内存泄漏的堆积,这会最终消耗尽系统所有的内存。从这个角度来说,一次性内存泄漏并没有什么危害,因为它不会堆积,而隐式内存泄漏危害性则非常大,因为较之于常发性和偶发性内存泄漏它更难被检测到。

    相关文章

      网友评论

          本文标题:java的内存分配和垃圾回收

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