实战Java虚拟机 JVM故障诊断与性能优化 笔记

第4章 垃圾回收概念与算法

认识垃圾回收

GC中的垃圾特指存在于内存中的、不会在被使用的对象。

常用的垃圾回收算法

引用计数法

对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1,当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,则对象A就不可能在被使用。

问题

  • 无法处理循环引用的情况
  • 每次因引用产生和消除的时候,需要伴随一个加法操作和减法操作,对系统性能会有一定的影响。

标记清除法 Mark-Sweep

标记清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段

  • 标记阶段:首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象。
  • 清除阶段:清除所有为被标记的对象

可能产生的最大问题是空间碎片,回收后的空间不不连续的

复制算法

将原有的空间内存分为两块,每次只使用其中的一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存块的角色,完成垃圾回收。

可确保回收后的内存空间是没有碎片的,代价是将系统内存折半。

在Java新生代串行垃圾回收器重,使用了复制算法的思想。新生代分为eden空间,from空间和to空间三个部分。其中from和to空间可以视为用于复制的两块大小相同、地位相等、且可进行角色互换的空间块。from和to空间也成为survivor空间,用于存放未被回收的对象。

在垃圾回收时,eden空间中的存活对象会被复制到未使用的survivor空间(假设是to)中,正在使用的survivor空间(假设是from)中的年轻对象也会被复制到to空间中。(大对象,或者老年对象会直接进入老年代,如果to空间已满,则对象也会直接进入老年代)。此时eden空间和from空间中的剩余对象就是垃圾对象,可以直接清空。to空间则存放此次回收后的存活对象。

标记压缩法(标记整理法)

复制算法的高效是建立在存活对象少,垃圾对象多的前提下的。但在老年代,更常见的情况是大部分对象都是存活对象。

标记压缩算法是一种老年代的回收算法,它在标记清除算法的基础上做了一些优化。首先需要从根节点开始,对所有可达对象做一次标记,将所有的存活对象压缩至内存的一端。之后清除边界外的所有空间。这种方法避免了碎片的产生,又不需要两块相同的内存空间。

分代算法

新生代比较适合使用复制算法。当一个对象经过几次回收后仍然存活,对象就会被放入老年代的内存空间。对老年带的回收使用标记压缩算法或者标记清除算法。

判断可触及性

引用和可触及性的强度

强引用

程序中一般使用的引用类型

  • 强引用可以直接访问目标对象
  • 强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出OOM异常也不会回收强引用所指向对象
  • 强引用可能导致内存泄漏

软引用

一个对象只持有软引用,那么当堆空间不足时,就会被回收。

弱引用

在系统GC时,只要发现弱引用,不管系统对空间使用情况如何,都会将对象进行回收。

虚引用

当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。

第5章 垃圾收集器和内存分配

串行回收器

串行回收期是指使用单线程进行垃圾回收的回收器。

新生代串行回收器

  • 仅仅使用单线程进行垃圾回收
  • 独占式的垃圾回收

Stop the world:在串行回收器进行垃圾回收时,Java应用程序中的线程都需要暂停,等待垃圾回收的完成。

老年代串行回收器

串行的独占式的垃圾回收器

并行回收器

使用多个线程同时进行垃圾回收

新生代ParNew回收器

简单地将串行回收器多线程化

新生代ParallelGC回收器

关注系统的吞吐量

老年代ParallelOldGC回收器

多线程并发的收集器

CMS回收器

主要关注系统停顿时间,并发标记清除

主要工作步骤

  • 初始标记:STW标记根对象
  • 并发标记:标记所有对象
  • 预清理:清理前准备以及控制停顿时间(可关闭)
  • 重新标记:STW修正并发标记数据
  • 并发清除:清理垃圾
  • 并发重置

由于CMS回收器不是独占式的回收器,在CMS回收过程中,应用程序仍然在不停地工作。在应用程序工作过程中,又会不断地产生垃圾。这些新生成的垃圾在当前CMS回收过程中是无法清除的。同时由于应用程序没有中断,所以在CMS回收过程中,还应该确保应用程序有足够的内存可用。因此,CMS回收器不会等待堆内存饱和时才进行垃圾回收,而是当堆内存使用率达到某一阈值(默认是68)时会执行一次CMS回收。

CMS是一个基于标记清除算法的回收器,会造成大量内存碎片。

G1回收器

G1回收器是在JDK1.7中正式使用的全新的垃圾回收器。从分代上来看会区分年轻代和老年代,依然有eden区和survivor区,但从堆的结构上看,它并不要求整个eden区、年轻代或者老年代都连续,它使用了分区算法。

分区算法

分区算法将整个堆空间划分成连续的不同小区间。每一个小区间都独立使用,独立回收。为了更好地控制GC产生地停顿时间,将一块大地内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。

G1的内存划分和主要收集过程

G1收集器将堆进行分区,划分为一个个的区域,每次收集的时候,只收集其中几个区域,以此来控制垃圾回收产生的一次停顿时间。

收集过程

  • 新生代GC
  • 并发标记周期
  • 混合收集
  • 如果需要,可能会进行Full GC

G1的新生代GC

主要工作是回收eden区和survivor区。一旦eden区被占满,新生代GC就会启动。

回收后,所有的eden区都应该被清空,而survivor区会被收集一部分数据,但是应该至少仍然存在一个survivor区。老年代的区域增多,因为部分survivor区或者eden区的对象可能会晋升到老年代。

G1的并发标记周期

  • 初始标记
  • 根区域扫描
  • 并发标记
  • 重新标记
  • 独占清除
  • 并发清除

混合回收

G1优先回收垃圾比例较高的区域。在这个阶段,既会执行正常的年轻代GC,又会选取一些被标记的老年代区域进行回收。

必要时Full GC

不能完全避免在特别繁忙的场合会出现回收过程中内存不充足的情况,当遇到这种情况时,G1也会转入一个Full GC进行回收。

有关对象内存分配和回收的一些细节问题

禁用System.gc()

1
-XX:-+DisableExplicitGC

对象何时进入老年代

  • 初创的对象在eden区

  • 老年对象进入老年代

    对象的年龄是由对象经历的GC次数决定的。在新生代中的对象每经历一次GC,如果它没有被回收,它的年龄就加1。默认情况下,在新生代的对象最多经历15次GC,就可以晋升到老年代。

  • 大对象进入老年代

在TLAB上分配对象

线程本地分配缓存。TLAB是一个线程专用的内存分配区域,为了加速对象分配而生,本身占用了eden区的空间。在TLAB启用的情况下,虚拟机会为每一个Java线程分配一块TLAB空间。

第6章 性能监控工具

JDK性能监控工具

  • jps:查看Java进程
  • jstat:查看虚拟机运行时信息
  • jinfo:查看虚拟机参数
  • jmap:导出堆到文件
  • jhat:JDK自带的堆分析工具
  • jstatck:查看线程堆栈
  • jstatd:远程主机信息收集
  • jcmd:多功能命令行
  • hprof:性能统计工具

第8章 锁与并发

锁的基本概念和实现

对象头和锁

在Java虚拟机的实现中每个对象都有一个对象头,用于保存对象的系统信息。对象头中有一个称为Mark Word的部分,它是实现锁的关键。一个对象是否占用锁,占有哪个锁,就记录在这个Mark Word中。

锁在Java虚拟机中的实现和优化

偏向锁

若某一锁被线程获取后,便进入偏向模式。当线程再次请求这个锁时,无需再进行相关的同步操作,从而节省了操作时间。如果在此之间有其他线程进行了锁请求,则锁退出偏向模式。

轻量级锁

如果偏向锁失败,Java虚拟机会让线程申请轻量级锁。当需要判断某一线程是否持有该对象锁时,只需简单地判断对象头的指针是否在当前线程的栈地址范围内即可。

锁膨胀

当轻量级锁失败,虚拟机就会使用重量级锁。

自旋锁

在锁膨胀后,虚拟机会使用自旋锁。自旋锁可以使线程在没有获得锁时不被挂起,而转而去执行一个空循环。在若干个空循环后,线程如果可以获得锁,则继续执行。若线程依然不能获得锁,才会被挂起。

锁消除

Java虚拟机在JIT编译时,通过对运行上下文的扫描,去除不能存在共享资源竞争的锁。

锁在应用层的优化思路

  • 减少锁持有时间
  • 减小锁粒度
  • 锁分离
  • 锁粗化

无锁

CAS

包含三个参数CAS(V, E, N)。V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才会将V的值设为N。如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后CAS返回当前V的真实值。

理解Java内存模型

  • 原子性
  • 有序性
  • 可见性
  • Happens-Before原则

第10章 Class装载系统

Class文件的装载流程

  • 加载
  • 连接
    • 验证
    • 准备
    • 解析
  • 初始化

装载类的条件

一个类或接口在初次使用(主动使用)前,必须要进行初始化。

  • 创建一个类的实例时,使用new关键字,或者通过反射、克隆、反序列化
  • 调用类的静态方法时
  • 使用类或接口的静态字段时(final常量除外)
  • 使用java.lang.reflect包的方法反射类的方法时
  • 初始化子类,要求先初始化父类
  • 作为启动虚拟机,含有main()方法的那个类

加载类

  • 通过类的全名,获取类的二进制数据流
  • 解析类的二进制数据流为方法区内的数据结构
  • 创建java.lang.Class类的实例,表示该类型

验证类

保证加载的字节码时合法、合理并符合规范的

  • 格式检查
  • 语义检查
  • 字节码验证
  • 符号引用验证

准备

虚拟机为这个类分配相应的内存空间,并设置初始值。如果类存在常量字段,那么常量字段也会在准备阶段被附上正确的值。

解析类

将类、接口、字段和方法的符号引用转为直接引用。

初始化

执行类的初始化方法,它是由编译器自动生成的,有类静态成员的赋值语句以及static语句块合并产生的。

ClassLoader

类加载

ClassLoader的分类

ClassLoader的层次自定往下

  • 启动类加载器:完全由C代码实现
  • 拓展类加载器
  • 应用类加载器(系统类加载器)
  • 自定义类加载器

当系统需要使用一个类时,在判断类是否已经被加载时,会先从当前底层类加载进行判断。当系统需要加载一个类时,会从顶层类开始加载,以此向下尝试,直至成功。

双亲委托模式

类加载时,系统会判断当前类是否已经被加载,如果已经被加载,就会直接返回可用的类,否则就会尝试加载。在尝试加载时,会先请双亲处理,如果双亲请求失败,则会自己加载。