第八章软件构造的性能——构造性能的度量、原则与方法(java中的垃圾回收机制及算法)

这节内容真的是多到炸裂,而且全都是概念,不过很挺有用的,学完这节会对内存管理有一个比较深的理解和认识,就是……这也太多了……嘤嘤嘤T_T


1.性能度量(performance metrics)

第一点很少,因为我们主要来说运行时性能,其主要分为两种(就是时空啦):

  • 时间性能:每条指令、每个控制结构、整个程序的执行时间
  • 空间性能:每个变量、每个复杂结构、整个程序的内存消耗


2. 内存管理(Memory Management)

A.操作系统(OS)及应用层面中的内存管理

本机内存会分配给一个进程的物理内存,交给OS管理,注意OS通常用的是虚拟内存,其在虚拟内存空间给进程分配内存,并与物理内存建立映射表。(因为我们课程这是软件构造,这是计算机系统的硬件知识,所以我们只提到用的就可以了,具体细节大家不懂的话,感兴趣可以度娘,硬件有的东西还是挺有趣的,感触最深的是缓存那里很多地方都会有类似思想……不过笔者也在学硬件上有了点心里阴影@.@,不想在了解了,最后说一句GTMD硬件!)

扯远了,不过记住关于内存管理时刻围绕的两个方面——内存分配垃圾回收

因为内存不足,所以需要考虑高效率的内存管理问题,这就需要动态分配回收内存,还有复用已回收的内存。


内存对象模型(object model):

每个对象存储在内存中一段连续的空间中,如果是引用则存储他所指向的内存对象的内存地址。

对象在堆heap中分配内存

对象引用:指向其他对象在堆中的起始地址。

非基本数据类型的变量等价于对象引用,每个对象可包含一组变量,每个变量可指向其他对象的引用,对象引用只指向一个其他对象,但是一个对象可以被多个对象所引用。(挺拗口的,不过很简单)


B.对象管理的三种模式

首先先来谈一下静态内存分配与动态内存分配:

  • 静态内存分配:在编译阶段就已经确定好了内存分配。(静态变量等)
  • 动态内存分配:在运行时动态分配内存,建立新的内存对象。(基于堆栈的内存管理都是动态分配的)


好,现在来说一下对象管理的三种模式:

  • 静态(static)在将程序load进内存的时候或开始执行的时候,就确定了所有对象的分配。不支持递归,不支持动态创建的可变长的复杂数据类型。
  • 动态-基于堆(Stack-based)栈的存储方法调用以及方法执行中的局部变量,先进后出,无法支持复杂的而数据类型。
  • 动态-基于栈(Heap-based【free】)在一块内存中分为多个小块,每块包含一个对象,或者未被占用。代码中的一个变量可以在不同的时间被关联到不同的内存对象上,无法在编译阶段确定。内存对象也可以进一步指向其他对象。
    堆:自由模式的内存管理,动态分配,可管理复杂的动态数据结构。

他们的差异主要在于是如何与何时在程序对象与内存对象之间建立联系。

如果一些应用管理了一些限定数目的内存可以采用栈与静态组合的方式。而另一些只能采用动态分配,比如:

  • 某些对象延续时间比创建它的时间还要长(所以栈不行)
  • 递归的数据结构,长度可变的数据结构(所以静态与栈方法不行)
  • 经常要使用不限定长度的数据结构


3.java内存模型

java中内存管理是交给java虚拟机的,也就是我们平常说的JVM(java virtual machine)



java内部内存模型:栈:

Java中,在JVM中运行的每一个线程都有一个专属的线程栈。(Thread Stack)每个线程的栈负责管理其局部数据,各个线程栈之间不可见,所有的局部的基本数据类型都是在栈上创建的,多线程之间传递数据,是通过复制而非引用。



java内部内存模型:堆

在Java中,所有对象都是在堆上创建的,即使是在局部变量中的object也是在堆上创建的。


来几道例题帮助大家理解:


注意:堆上创建的对象可被所有的线程共享引用,可访问对象,就可访问对象内的成员变量。如果两个线程调用同一个对象上的某个方法,他们分别保存该方法的局部变量的拷贝。



4. 垃圾回收GC(Garbage Collection)

三种模式下的内存回收:

在静态模式下,无需进行内存回收,所有的都是已确定的。

在栈上进行内存空间回收:按block(某个方法)整体进行。

在堆中进行内存空间回收,最复杂,因为无法预知某个object是否变得无用,下面我们就来解决这个问题。


可达与不可达对象(Reachable and Unreachable Object):

我们可以这样认为,把静态区域数据域寄存器和当前执行栈中的数据所指向的内存对象看成一个根节点,如果引用到了一个对象,就在引用对象节点与被引用对象节点间画一条有向边,这样就形成了一个图:


那么我们把从根节点可达到的对象称为可达对象,不可达对象称为不可达对象,显然,不可达对象是我们要回收的对象,所以我们称之为已死对象,可达称为存活对象。


GC开销的度量:

  1. 执行时间:包括处理时间的总量、GC执行时间的分布、分配给一个新对象所需的时间
  2. 内存使用:额外的内存开销、内存碎片、虚拟内存与缓存性能
  3. 延迟时间:暂停执行GC的时间长度、幽灵时间(这货变成垃圾到到被回收所需要的时间)
  4. 其他重要的度量:综合考量、实现是否简单与健壮性如何


如果实现GC太少会导致内存泄漏,很多死的对象仍然占据着内存空间,如果太多的话原本活着的对象会被回收,程序会执行失败(悬空指针)



5.GC的几种基本算法

主要分为以下四种,下面会逐个详细说明:

  1. 引用计数(Reference counting)
  2. 标记-清除(Mark-Sweep)
  3. 标记-整理(Mark-Compact)
  4. 复制(Copying)


引用次数法:

基本思想:为每个object存储一个计数RC,如果有其他引用(reference)指向它时,RC++,当其他引用与其断开时,RC--;如果RC==0,则回收它。

过程图:


实现代码:



该算法优点:简单、计算代价分散,幽灵时间短接近于0

缺点:不全面,容易漏掉循环引用的对象、并发支持较弱,占用额外的存储空间等。


标记清除法:

基本思想:为每个object设定状态位(live/dead)并记录,这是mark阶段;将标记即为dead的对象进行清理,这是sweep阶段。

过程图:


该算法优点:回收彻底,在指针操作上没有运行开销、变值器低耦合、不会违反任何不变量、对优化程序友好

缺点:可能会出现较长的幽灵时间、回收垃圾是都在扫描阶段完成的,这时会扫描整个堆,而不是全部的存活对象,所以是极为耗时的、堆有效占用率低,如果堆快满了,那么堆中标记位会占有很大一部分、如果对象被清除了,可能会在旧内存中留下几个空白,这种碎片化会导致应用程序很严重的性能问题、扫描器必须要找到根等等。


标记-整理方法(Mark-Compact):

基本思想:沿用标记-清除法的思想,不过这种方法会在存活对象中加上一个标记,把带有标记的对象放到垃圾对象的后面,这样扫描的复杂度就降到了死亡对象数目,并处理了碎片化的问题。

值得一提的是:在内存中对象的相对顺序保持不变——也就是说,如果object X的内存地址比GC之前的Y要高,那么它之后仍然会有一个更高的地址。对于像数组这样的特定数据结构,该属性非常重要。

但缺点也更加明显:标记-整理集合的最大问题是时间。它需要比标记-清除收集更多的时间,这将严重影响性能。

过程图:



复制法(Fragmentation and copying)

啊,先来说一下碎片化吧,之前提了很多次。

碎片化:无法使用可用的内存外部:分配的内存分散到块中;自由块不能被合并在内部:内存管理器分配的空间比实际需要的公共原因要多。比如下图就是碎片化的后果:(这是一个很严肃很致命的问题)


而该算法与标记整理法处理碎片化的区别在于,标志整理法是对同一个区域内进行整理,本算法是将存活对象全部复制到另一个区域中。

过程图:


具体实现代码:


算法优点:整理阶段是没有代价的,并且分配阶段对所有大小的对象都是廉价的。只处理存活对象(通常比较少)、固定的空间开销、垃圾回收全面、实现较为简单,高效。

缺点:停止-复制阶段会导致程序中断一段时间、降低内存利用率(一半没用)、大型对象复制成本较高、存活时间久的数据可能会被反复复制、所有引用必须全部更新、可能会破坏某些不变量、第一次复制会干扰局部模式等等。

(终于说完了,揉揉脖子……)



6. JVM中的GC系统

Java中GC将堆分为不同的区域,各区域采用不同的GC策略,以提高GC效率。

偷个懒,这页大家就看看吧……


emm……良心不忍,还是解释一下下吧……虚拟机中主要将其分为三部分新生代、老生代与永生代。上图中新生代被分为了Eden、From、To三个代,新出生的对象会被放到新生代中,存活一段时间的对象会放到老年代中,至于永生代存放的是java中类的元数据,和一些静态数据还有常量池等(常量池在下下节会详细讲解)。From与To就是copy算法的两个空间。(Ahh还是没偷懒==!)下面这两张图帮助大家理解,已经说过了,真的不会再解释了哦……



java中会对不同的区域采取不同的GC策略:

  • 对于年轻代:只有小部分对象会长时间存活,所以采用copy算法减小GC代价。
    这里每次GC都会发现大量的死亡对象,而存活对象很少,所以采用copy算法合适。
  • 对于年老代:这里的对象有很高的幸存度,所以使用Mark-Sweep或者Mark-Compact算法。


那么什么时候会发生GC呢?只有当某个区域不能再为对象分配内存的时候才会启动GC,即有区域满了!

对于年轻代,使用minorGC进行垃圾回收,minorGC所需时间较短,如果经历多次的minorGC仍然存活下来,会将其复制到老年代中。

如果老年代满了,则启动FullGC,老年代满了也就意味着无法进行下一次minorGC了(因为经历过多次的对象无法移动到老年代)。另外值得一提的是,为了减小代价,minorGC与FullGC独立运行。

当永生代(perm generation)满了的时候,无法存储更多的元数据,也启动Full GC

minorGC过程图:



很形象就不解释啦……


7. java中的GC调整(Garbage Collection Tuning)

首先需要说一下几点:

  • 尽可能减少GC时间,一般不超过程序执行时间的5%。
  • 一旦初始分配给程序的内存满了,就抛出内存溢出异常。
  • 在启动程序中,可为其配置内存分配的具体大小。

调整javaGC步骤:

  1. 确定堆的大小
  2. 选择GC模式
  3. 使用verbose GC参数查看内存详细信息并确定堆的大小
  4. 自动记录内存将要不足的情况
  5. 手动请求GC
  6. 请求线程栈

调整参数:


需要说明的的是:堆的大小决定着VM将会以何种频度进行GC、每次GC的时间多长。至于这两个指标具体取何值为优,需要针对特定的应用进行分析。较大的堆会导致发生GC的次数较少,但每次GC的时间却很长。相反,较小的堆每次GC时间较短,但需要频繁GC。这一切都要根据程序需要来设置堆的大小。

//指令说明
//初始堆为1G,最大堆尺寸为2G
-Xms 1024M -Xmx 2048M

Ahh还是截图方便:


具体指令大家可以度娘,有好多帖子,这里肝疼就不细讲了……

另外唠叨一句:GC信息是可以记录到log中以便后续分析的。来一个例图,eclipse的最后一条指令是生成日志文件,参数是文件路径(啪!我怎么这么多话呢……):




8. java中的常见I/O方法:

还是,java语法的东西……给个方法列表,大佬跳过,不知道的同学可以逐个度娘啦……

  • Stream
  • Reader
  • Nio
  • Scanner
  • Lines


猜你喜欢

转载自blog.csdn.net/qq_37549266/article/details/80743566