性能优化-Java垃圾回收机制

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/fengluoye2012/article/details/80361376

前言

Android开发中经常会遇见应用内存不断增加,或者在处理不当的情况下,造成内存泄漏,严重会导致OOM;但是Java有自动垃圾回收机制,为什么还会造成这种情况呢,那我们通过new关键字创建出来的对象、开启的Activity在什么情况下会被回收呢?带着这个问题,我们来了解下Java内存区域和Java的垃圾回收机制。

Java虚拟机内存区域

通过Zygote进程浅析我们知道所有的Android应用程序都是通过Zygote进程fork(孵化)出来的,每一个应用程序运行在各自独立的Dalvik虚拟机中。

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有的区域则依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范(Java SE7版)》的规定,Java虚拟机所管理的内存将会包括以下几个运行时区域,如下图Java虚拟机运行时数据区所示;

通常情况下,我们将虚拟机内存区域分为三部分:栈、堆、方法区;

Java虚拟机栈

Java虚拟机栈是线程私有的,他的生命周期和线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用到执行完成的过程,就是对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表存放了编译器可知的各种基本数据类型、对象引用和returnAddress类型;

  • 基本数据类型:boolean、byte、char、short、int、float、long、double;
  • 对象引用(reference类型):不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他于此对象相关的位置;
  • returnAddress类型:指向了一条字节码指令的地址;

Java堆

对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。

方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

对象的创建

Java是一门面向对象的编程语言,在Java程序运行中无时无刻都有对象被创建出来。在语言层面上,创建对象(例如克隆、反序列化)通常仅仅是一个new关键字。虚拟机遇到一条new指令时,首先将去检查这个指令是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有,那必须先执行相应的类加载过程;

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。内存分配方式有两种分别是指针碰撞和空闲列表。

  • 假设Java堆中内存是绝对规整的,所用过的内存都是放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是吧那个指针向空闲空间挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump the Pointer)。

  • 如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上那些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给实例对象,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的的垃圾收集器是否带有压缩整理功能决定的。

除了如何划分可用空间之外,对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这两问题有两种方案:方法一:对分配内存空间的动作进行同步处理–实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;方法二:把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。那个线程要分配内存,就在那个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。

对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对其填充(Padding)。

  • HotSpot虚拟机的对象头包括两部分信息;

第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方它为“Mark Word”。

另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

  • 实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。
  • 对其填充并不是必然存在的,也没特殊的含义,仅仅起着占位符的作用。

对象的访问定位

建立对象是为了使用对象,Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中之规定了一个执行对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定。目前主流的访问方式有使用句柄和直接指针两种。

  • 使用句柄访问的话,那么Java堆中将会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息如下图通过句柄访问对象所示:

  • 使用直接指针访问,那么Java堆对象的布局中就必须考虑如何防止访问类型数据的相关信息,而reference中存储的直接就是对象地址,如下图通过直接指针访问对象所示:

这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本省不需要修改。

使用直接指针访问方式的最大好处就是速度更快,他节省了一次指针定位的事件开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

现在我们来看个实例:Student s = new Student();在内存中做了哪些事情?

 1:加载Student.class文件进内存
 2:在栈内存为s开辟空间
 3:在堆内存为Student对象开辟空间
 4:对Student对象的成员变量进行默认初始化
 5:对Student对象的成员变量进行显示初始化
 6:通过构造代码块对Student对象进行初始化(若没有就不执行)
 7:通过构造方法对Student对象进行初始化 (通过构造方法对Student对象的成员变量赋值),对象初始化完毕。
 8:Student对象初始化完毕,把对象地址赋值给s变量,让变量s指向Student对象。

如何判断对象可回收

垃圾收集器在对堆进行回收之前,首先要确定哪些对象还“存活”着,哪些对象已经“死去”(即不可能再被任何途径使用的对象);

  • 引用计数算法

很对教课书判断对象是否存活的算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。

客观的说,引用计数算法的实现简单,判断效率也高,在大部分情况下他都是一个不错的;但是,至少在主流的Java虚拟机里面都没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象相互循环引用的问题。

  • 可达性分析算法

在主流的商用程序语言的主流实现中,都是称通过可达性分析(Reachability Analysis)来判断对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。如下图所示,对象object5、object6、object7虽然相互关联,但是他们到GC Roots是不可达的,所以他们将会被判定为是可回收对象。如下图可达性分析算法判定对象是否可回收所示:

在Java语言中,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候他们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程,这也是为什么我们在判断内存泄漏获取hprof文件之前多点击几次GC的原因。

引用类型

在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4中,这4种引用强度依次递减的。

  • 强引用就是指在程序代码中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

  • 软引用是用来描述一些还有用但是并非需要的对象。对于软引用关联的对象,在系统将要发生内存溢出前,将会把这些对象裂锦回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用。

  • 弱引用也是用来描述非必需对象的,但是他的强度比软引用更弱些,被弱引用关联的对象,只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。

  • 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。

垃圾收集算法

标记-清除算法

最基础的收集算法是”标记-清除”(Mark-Sweep)算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成之后统一回收所有被标记的对象,它的标记过程可见如何判断对象可回收;之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这个思路对其不足进行改进而得到的。它的不足有两个:1)效率问题,标记和清除这两个过程的效率都不高;2)空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后再程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发GC;如下图“标记-清除”算法示意图所示:

复制算法

为了解决效率问题,一种称为“复制”(Copying)的手机算法出现了,将可用内存安容量划分为相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次性清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,未免太高了。复制算法的执行过程如下图:

现在的商业虚拟机都是采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另一块Survivor的空间上,最后清理掉Eden和刚才使用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被“浪费”。98%的对象可回收只是一般场景下的数据,无法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里老年代)进行分配担保。

标记-整理算法

复制收集算法在对象存活率较高的时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记工程和“标记-清除”算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法的示意图如下:

分代收集算法

当前商业虚拟机的垃圾收集都是采用“分代收集”(Generational Collection)算法,这种算法只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-整理”算法进行回收。

分代收集器GC的过程图解可以参考图解Java中的GC(分代收集器)非常形象、详细的解释了分代GC过程;

分代收集器内存分配规则

  • 大多数情况下,对象在新生代Eden区分配。当Eden区没有足够空间使用分配时,虚拟机将发起一次Minor GC。

新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多具备长生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。

老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的), Major GC的速度一般会比Minor GC慢10倍以上。

  • 大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说就是一个坏消息(比遇到一个大对象更加坏的消息就是遇到一群”朝生夕灭”的”短命大对象”,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集器以获取足够的连续的空间来“安置”它们。

  • 长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经过一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1.对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁)就将会被晋升到老年代中。

  • 动态对象年龄判断

虚拟机并不是永远地要求对象的年龄必须达到指定年龄才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

  • 空间分配担保

再发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代中最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置为不允许冒险,那这时也要改为进行一次Full GC。

所有的内容到此结束了,以上只是根据《深入理解Java 虚拟机—JVM高级特性与最佳实践(第二版)》的个人理解,如有问题,请多指教!

参考文档

《深入理解Java 虚拟机—JVM高级特性与最佳实践(第二版)》
https://blog.csdn.net/jiucongtian/article/details/52712538

猜你喜欢

转载自blog.csdn.net/fengluoye2012/article/details/80361376
今日推荐