Android开发经验谈Android开发

关于JVM我们必须要知道的知识点(一)

2018-07-07  本文已影响66人  Android_Jian

无论是做Java开发,还是做Android开发,关于JVM这块的知识我们还是很有必要去了解的,有助于我们扩展知识深度。之前有看过一些JVM的文章,前段时间把《深入理解Java虚拟机》这本书买回来,趁着工作之余好好拜读了一下。但是现在回想起来,书中的知识章节只能记个大概,具体细节早就忘记了。同样对于之前学习的知识、翻看的源码,现在也只能记个大概,实属不该啊。所以,做好知识总结、梳理真的很重要!很重要!!!这几天再次将这本书拜读一遍,对书中的知识做个总结,同时也希望能够帮助到大家。


1. 运行时数据区域

Java虚拟机在执行Java程序的过程中,会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁时间,有的区域随着虚拟机进程的启动而存在,有的区域则依赖用户线程的启动和结束而建立和销毁。Java虚拟机所管理的内存主要包括以下几个运行时数据区域:

(1)程序计数器

程序计数器是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。我们都知道,Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻一个处理器(对于多核处理器来说是一个内核)都只会执行一个线程中的指令,为了使线程切换后能正确恢复到原来的执行位置,每个线程都需要有一个自己的程序计数器,即程序计数器是线程私有的。

注:如果线程正在执行的是一个Java方法,这个程序计数器记录的是正在执行的虚拟机字节码指令的地址;如果线程正在执行的是一个native方法,这个计数器的值为空。同时程序计数器是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError的区域。

(2)Java虚拟机栈

虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用到方法执行结束,都对应一个栈帧在虚拟机栈中从入栈到出栈。我们之前经常说的“栈内存”和“堆内存”中的“栈内存”指的就是虚拟机栈中的局部变量表部分。局部变量表中存放了编译期可知的各种基本数据类型、对象引用和returnAddress类型。局部变量表所需的内存空间在编译期完成分配,在方法的运行期间不会改变局部变量表的大小。Java虚拟机栈也属于线程私有,Java虚拟机规范对该区域规定了两种异常:StackOverflowError异常和OutOfMemoryError异常。

(3)本地方法栈

本地方法栈的作用和Java虚拟机栈是相似的,只不过Java虚拟机栈是为Java方法服务,本地方法栈是为native方法服务。

(4)Java堆

Java堆的唯一目的就是存放对象实例,几乎所有的对象都在这里分配内存。当然Java堆也是垃圾回收器主要管理的区域,该区域被所有线程所共享。Java虚拟机规范规定:Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。如果在堆中没有内存完成实例分配,并且堆也无法扩展时,将抛出OutOfMemoryError异常。

(5)方法区

方法区与Java堆一样,也是各个线程所共享的区域,用于存储已被虚拟机加载的类信息、常量、静态变量等数据。垃圾收集在方法区是比较少出现的,回收目标主要是针对常量池的回收和对类型的卸载。当方法区内存不足时,会抛出OutOfMemoryError异常。在方法区中包括了运行时常量池,用于存放编译期生成的各种字面量和符号引用。

下面放上一张图:

运行时数据区域

2. 新生对象分配内存:

对象所需的内存大小在类加载完毕后便可确定。分配内存的方式主要有两种,分别为“指针碰撞”和“空闲列表”。两种分配方式的依据在于Java堆是否规整,而Java堆是否规整是由JVM所采用的垃圾回收器是否带有压缩整理功能决定的。在分配内存过程中,考虑到线程安全问题,有两种解决方案,一种是对分配内存的动作进行同步处理,另一种是TLAB(本地线程分配缓冲)。

3. 对象的内存布局:

对象在内存中存储的布局可以分为三块区域:对象头、实例数据和对其填充。

对象头包括两部分信息,第一部分是用于存储对象自身的运行时数据,如哈希吗、GC分代年龄、锁状态标志等;另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据部分是真正存储的有效信息;对其填充没有特别含义,仅仅起着占位符的作用。

4. 对象的定位方式:

对象的定位方式主要有两种:“句柄定位”、“直接指针定位”。这两种访问对象的方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据地址,而reference本身不需要修改。使用直接指针访问的最大好处就是速度更快,它节省了一次指针定位的时间开销。

5. 判断一个对象是否存活

垃圾回收器(Garbage Collection),也称作GC,在进行垃圾回收之前,首先要确定哪些对象还“活着”,哪些对象已经“死去”。判断一个对象是否存活的算法主要有两种,分别是:引用计数法、可达性算法。

(1)引用计数法:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;当对象中的引用计数器的值为0时,就表明该对象再无被其他地方引用,那么这个对象就成为可被回收的对象了。引用计数法有一个致命的缺陷,就是它很难解决对象之间相互引用的问题。

(2)可达性算法:这个算法的基本思想就是以一系列称为“GC Roots”的对象为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时(换做图论的话就是GC Root到该对象不可达),则表明该对象不再使用了,这个对象就被判定为是可回收的对象。

可作为GC Roots的对象包括下面几种:

1.虚拟机栈中的局部变量表中引用的对象。

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

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

4.本地方法栈中的native方法引用的对象。

6. Java中的引用分类

Java中根据对象引用强度的不同,将引用类型分成了四种,分别是:强引用、软引用、弱引用、虚引用。

强引用(StrongReference):强引用在我们的程序代码中普遍存在,类似于“Person p = new Person();”JVM在内存不足时,宁愿抛出OutOfMemoryError异常也不会回收持有强引用的对象。

软引用(SoftReference):软引用用来描述一些有用但非必须的对象,JVM在内存不足时,会回收掉持有软引用的对象,如果这次回收还没有足够的内存,才会抛出OutOfMemoryError异常。

弱引用(WeekReference):弱引用的引用强度相对于软引用还要弱一点,持有弱引用的对象只能存活到下一次垃圾回收器回收之前。

虚引用(PhantomReference):虚引用是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的就是在该对象被垃圾回收器回收时收到一个系统通知。

在这里我举个栗子用来说明下我们在代码中是如何使用到弱引用(WeekReference)的,那就是解决Handler引发的内存泄漏问题。如果大家还不了解的话可以搜一下相关知识进行学习一下,当然后续我在分析Handler消息机制源码的时候也会顺带写一下。

7. 垃圾收集算法

常见的垃圾收集算法有:标记-清除算法、复制算法、标记-整理算法、分代收集算法。下面我们一一介绍:

1.标记-清除算法:

标记-清除算法作为最基础的收集算法,算法分为“标记”和“清除”两个阶段:首先要标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。对于判定哪些对象需要回收,我们在前面也讲述到了,那就是引用计数法和可达性算法。标记清除法主要有两方面的不足:(1)效率问题,标记和清除两个阶段的效率都不高。(2)内存碎片,标记和清除后会产生大量不连续的内存碎片。

2.复制算法:

复制算法将内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后把这一块的内存空间一次性清理掉。这样使得每次都是对整个半区进行内存回收,内存后续分配时,也不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,简单高效。不足:算法将使用内存缩小为原来的一半,代价未免太高了点,相比较标记-清除算法而言,以空间换时间。

注:现在的商业虚拟机都采用这种算法来回收新生代。我们都知道,Java堆分为新生代和老年代两个部分,更细化来说,新生代又分为:Eden区、From Survivor区、To Survivor区。新生代中的对象98%都是“朝生夕死”,所以并不需要按照1:1的比例来划分内存空间。Eden区、From Survivor区、To Survivor区内存所占比为8:1:1,每次使用Eden区和Survivor区中的一个,当回收时,将Eden区和Survivor区中还存活的对象一次性复制到另一块Survivor区中,最后清理掉Eden区和刚才用过的Survivor区的内存空间。

在这里我们考虑一种情况,回收时,将Eden区和Survivor区中还存活的对象一次性复制到另一块Survivor区的过程中,如果出现存活对象占有内存>另一块Survivor区内存的情况时怎么办?这个时候这些存活对象将通过分配担保机制直接进入老年代。

3.标记-整理算法:

标记-整理算法的标记过程仍然和“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是将所有存活对象移动到一端,然后直接清理掉端边界以外的内存。该算法应用于老年代。

4.分代收集算法:

当前商业虚拟机的垃圾收集都采用“分代收集”算法。这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆划分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时,都发现有大批对象死去,只有少量存活,那就选用复制算法。而老年代中的对象存活率高、没有额外空间进行分配担保,那就选用“标记-清除”或者“标记-整理”算法。

8.HotSpot虚拟机何时开始GC

在上述5和7中我们介绍了对象存活判定算法和垃圾收集算法,这些都是GC过程中所使用到的算法。那么你有没有想过,JVM在什么时候开始GC操作呢?下面以HotSpot虚拟机为例简单了解下。

在HotSpot虚拟机中,使用了一组称为OopMap的数据结构来直接记录哪些地方存在GC Roots,这样子在GC过程中,使用可达性算法进行对象存活判定的时候就可以直接在OopMap中查找到所有的GC Roots,避免了全局查找带来的时间和性能浪费。

在进行GC的过程中需要“Stop the World”,即必须停止所有Java执行线程。因为使用可达性算法进行对象存活判定的时候,为了使判定结果准确无误,不可以出现分析过程中对象的引用关系还在不断变化的情况(Java线程执行会导致对象的引用关系不断变化)。

HotSpot只是在特定位置生成了OopMap,这些位置称为“安全点(SafePoint)”,也就是说在程序执行过程中,只有到达SafePoint,才停顿下来进行GC操作。

关于HotSpot中有哪些具体的垃圾收集器以及它们的优势及不足,在这里就不详细列举了,参见《深入理解Java虚拟机》这本书。

9.内存分配规则

1.对象优先在Eden区分配内存。

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

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

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

2.大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。虚拟机提供了一个 -XX:PretenureSizeThreshold参数,令内存大小大于这个设置值的对象直接在老年代中分配。

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

虚拟机为每个对象定义了一个对象年龄计数器。对象在Eden区中出生,在第一次Minor GC后,如果对象仍然存活并且能被另一个Survivor区容纳的话,该对象将被移动到Survivor区中,并且对象年龄设为“1”。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置。

4.动态对象年龄判定

为了更好适应不同程序的内存状况,虚拟机并不是永远要求对象的年龄必须达到阈值才能晋升到老年代。如果在Survivor区中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

5.内存分配担保

我们前面提到过,新生代的垃圾收集使用复制算法,但为了内存利用率,只使用其中一个Survivor区来作为轮换备份,因此当出现大量对象在Minor GC依然存活的情况(最极端的情况就是内存回收新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。


如果你觉得文章有帮助到你,欢迎给颗小心心支持一下啦。

上一篇下一篇

猜你喜欢

热点阅读