JVM学习记录

文章目录

一、JVM的自动内存管理机制

 JVM在运行java程序时,将把他所管理的内存划分为若干个不同的数据区,每个数据区都有各自的用途和生命周期,JVM管理的内存主要包括下图的几个区域:
这里写图片描述

各个内存区域的作用如下:

  • 程序计数器:它是一块较小的内存区域,用于记录当前线程所执行的字节码行号指示器,线程私有。在字节码解释器工作时就是通改变程序计数器来选取下一条需要执行的字节码命令,分支、循环、跳转基础功能都离不开程序计数器。在多线程运行过程中,我们知道是通过CPU的时间片不断分配到各个线程来实现,在某一个具体的时刻,CPU其实只在执行一个线程,在这种情况下,为了每个线程能够正确的恢复到执行位置,每个线程都会有一个独立的程序计数器,它们之间互不影响,独立存储,这类的内存区域为“线程私有”的内存。还需要注意的是如果当前线程执行是java方法,程序计数器记录是正在执行的虚拟机字节码指令的行号,如果当前线程执行的是native方法,那么此时的计数器的值为空(Undefined)。程序计数器内存区域是java虚拟机中唯一一个没有规定OutOfMemoryError的情况的区域。(注:上述过程中,java方法和native方法区别在于:java方法是指由java编写的,然后编译成字节码存储在class文件中,和平台无关;native方法是指由java之外语言编写,编译为和处理器相关的机器代码,本地方法保存到动态链接库中,在windows平台是.dll文件中,各个平台所体现的文件形式是不一样的。在java方法中调用本地方法时,JVM装载包含该本地方的动态库,这样这些java代码就由于本地方法和平台的相关性变得和平台相关了,不再是和平台无关了,但是通过本地方法java可以直接访问底层系统资源,如果希望使用特定机器中的资源,但是又无法从Java API得以访问,那么此时就可以通过在java方法中调用本地方法来完成一个和特定平台相关的功能代码);

  • java虚拟机栈:其实它就是“堆栈”中的栈,这里又具体到了虚拟机栈(和后面的本地方法栈作区分,实则它就是我们通常所讲的“栈”的全部内容,用于存放局部变量,包含8种数据类型和对象引用,我经常用“基站”这个谐音来记忆),它是描述java方法执行的内存模型,每个方法在执行时都会创建一个栈帧(Stack Frame)来存储局部变量、操作数栈、动态链接库、方法出口等信息,每个方法从调用直到执行完的过程,就对应一个栈帧在虚拟机中从入栈到出栈的过程,和程序计数器一样,java虚拟机栈也是线程私有。在java规范中对于这区域定义了两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常,如果虚拟机可以动态扩展(java虚拟机大部分都是动态可扩展的,只不过它也允许固定长度的虚拟机栈),但是在扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

  • 本地方法栈:它和虚拟机栈类似,区别在于虚拟机栈是为java方法服务,而本地方法栈为本地方法服务,这块内存的存在的异常也是StackOverflowError异常和OutOfMemoryError异常。

  • 堆:java堆是java虚拟机所管理的内存中最大的一块,这一块的内存是被所有线程共享的,在虚拟机启动时创建,这一块内存区域唯一目的就是存放对象实例,几乎所有的对象实和数组对象例都是在堆中分配,但是随着即时编译器(JIT)的发展和逃逸分析技术的发展,所有的对象实例在堆上分配并不是绝对的。由于堆占据了虚拟机内存的大部分,所以在垃圾回收器工作时也主要负责堆的垃圾回收,所以堆有时候也叫GC堆,在垃圾回收工作机制中,当前主要采用是分代回收(年轻代分为Eden区和from区以及to区,所有新创建的对象都在Eden区,当Eden区满了就会执行一次Minor GC,然后将还有用的复制到from区,然后不断的将存活下来的对象在from区和to区之间进行复制转移,最后将存活下来的复制到老年代,在老年代满了就会触发Full GC来清理整个堆空间,包括年轻代和老年代)。java虚拟机规范中对于堆,规定java堆可以处于物理上不连续的内存空间,只要逻辑上连续即可,在实现时,可固定堆的大小也可以作可扩展的方式,当前的java虚拟机都是按照可扩展的方式来实现的(通过-Xmx-Xms控制),如果在堆中没有内存完成实例分配,并且堆也无法扩展时将会抛出OutOfMemoryError异常。

  • 方法区:用于存放已被虚拟机加载的信息、常量、静态变量、即时编译器编译后的代码等数据,是线程共享的内存区域。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。在java虚拟机规范中,如何实现方法区属于虚拟机实现细节,不受其约束,但是如果利用永久代(HotSpot虚拟机中存在,诸如BEA JRockit和ISM J9等虚拟机中是不存在永久代这一概念的)实现,将更容遇到内存溢出的问题(-XX:MaxPerSize的上线),方法区可以不在连续的内存上、可以固定或者可扩展、可以选择是否是否实现垃圾回收(在方法区垃圾回收比较少出现)。

  • 运行时常量池:它是方法区的一部分,class文件中除了类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译生成的各种字面量和符号引用,这些内容将在类加载进入方法区的运行时常量池中存放。它相对于文件常量池具备动态性,在Java中并不要求一定常量只有编译才能产生,也不是只有预置的Class文件中常量池中的内容才能进入方法区的运行时常量池,运行期间也是可能将新的常量放入常量池,其中String类就是我们遇到的比较多的intern()方法。运行时常量池是方法区的一部分,它必然也是受到方法区内存的限制,在无法再申请到内存时会抛OutOfMemoryError异常。

  • 直接内存:它并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但这一部分的内存是被频繁的使用的,也是可能导致OutOfMemoryError异常。在JDK1.4之后引入NIO(基于通道和缓冲区的I/O方式),他可以使用Native函数库直接分配堆外内存,然后通过存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,避免了Java堆和Native堆之间来回复制数据,可以提高性能。

1.1 HotSpot虚拟机中对象的创建、内存布局和访问定位

 上面是说的虚拟机对内存的管理,关于某一块内存中具体的创建、布局、访问等细节,以HotSpot虚拟机和Java堆为例作如下说明:

1.1.1 对象的创建

 在表现上,通常通过new关键字就可以创建一个对象,但是虚拟机中创建一个对象并没有那么容易,虚拟机遇到一个new指令,会有如下几个流程:

  1. 首先虚拟机将会去进行类加载检查(检查这个指令的参数是否能在常量池中定位一个类的符号的引用,并检查这个符号引用代表的类是否已被加载、解析和初始化,如果没有,那么必须先执行相应类的加载过程);
  2. 然后虚拟机为新生对象分配内存(对象所需要内存的大小在类加载完后就可以完全确定,这一过程等同将一块内存从java堆中划分出来,加入java堆内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放一指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向移动一段与对象大小相等的距离,这就是我们所讲的“指针碰撞”;

 如果java堆内存不是规整的,已使用和未使用的内存都是揉在一块儿的,那就不能简单的进行指针碰撞了,虚拟机必须维护一个列表,这个列表用于记录哪些内存是空闲的,在分配时从列表中找到一块足够大的空间划分给对象实例,并同时更新该列表,这种方式称为“空闲列表”。

 具体选择“指针碰撞”还是“空闲列表”的方式是由java堆是否规整决定的,Java堆是否规整又是由所采用垃圾收集器是否带有压缩整理功能决定的,因此:

  • 在使用Serial、ParNew等带jCompact过程的垃圾收集器时,系统采用的是指针碰撞;
  • 在使用CMS这种基于Mark-Sweep算法的垃圾收集器时,通常采用空闲列表的方式;

 除此之外,对象创建在虚拟机中是非常频繁的行为,但这种行为在并发场景下又不是线程安全的,经常会出现虚拟机正在给A分配内存但还没有分配完即指针还没来得及修改,这时B串出来也要求分配内存也是使用上面A使用的指针的场景,对于这种情况,两种解决方案:

  • 第一种,对分配内存空间的动作进行同处理,虚拟机采用CAS(比较交换,高并发控制的一种方式)加上失败重试的方式保证更新操作的原子性;
  • 第二种,把内存分配的动作就按照线程划分到不同的空间中进行,每个线程在java堆中预先分配一小块内存(线程分配缓冲TLAB),哪个线程需要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定,虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定,然后进行内存分配,虚拟机需要将分配到的内存空间初始化为0(不包括对象头),如果使用了TLAB,内存分配的工作可以提前到TLAB分配时进行,它保证了对象的实例字段在java代码中可以不赋初始值就可以直接使用,程序可以访问到这些对象的数据类型对应的零值;最后虚拟机需要对这个对象进行必要的设置(对象属于那个类、对象的哈希码、对象的GC分代年龄等信息,这些信息存放在对象头中),当虚拟机当前的运行状态不同时(是否启用偏向锁),对象头会有不同的设置方式,上述就是虚拟机创建对象的概要过程(在java中就是简单的new关键字),总体流程如下图:

这里写图片描述

1.1.2 对象的内存布局

 在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头、实例数据、对齐填充。

对象头包括两部分信息:

  • 第一部分存储对象自身运行时的数据(如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等),这部分数据长度在32位和64位虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称为“Mark Word”,对象需要存储的运行时数据很多,其实已经超过了32位、64位Bitmap结构所能记录的限度,但是对象头信息是对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计为一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间;
  • 第二部分存储的是类型指针,即对象指向它的类元数据的指,虚拟机通过这个指针来确定这个对象是属于哪个类,但并不是所有的虚拟机都必须在对象数据上保留类型指针,即查找对象的元数据信息并不一定需要经过对象本身,另外如果是数组对象,那么对象头中还必须有一块用于记录数组长度的数据(虚拟机可以通过普通java对象的元数据信息确定对象的大小,但是从数组的元数据中是无法确定数组对象的大小的)。

实例数据是对象真正存储的有效信息,也是代码中定义的各种类型的字段内容,这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响,在HotSpot虚拟机中,默认的分配策略为:longs/doublesintsshorts/charsbytes/booleansoops(Ordinary Object Pointers),可以看出,相同宽度的字段总是被分配到一起,在满足该条件时,在父类中定义的变量会出现在子类之前,如果CompactFields参数为true(默认为true),那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。

对齐填充不是一定存在的,她起到占位符的作用,由于HotSpot虚拟机自动内存管理系统要求对象起始地址必须是8字节的整数倍,因此当对象实例部分没有对齐时,就需要通过对其填充来补全。

1.1.3 对象的访问定位

 这部分主要是为了定位对象的位置,像我这样的小白菜一直以为对象放到内存中后直接通过内存地址去找即可,这是直接使用指针访问的方式,其实主流的方式还有一种是通过句柄(即对象的引用)来访问对象,对上述两种方式有如下的说明:

  • 句柄访问:java堆中会专门划分一块内存作为句柄池,reference中存储的就是对象的句柄地址,包含了对象实例数据与类型数据各自的具体信息。使用句柄访问的优势就是稳定,在对象移动(如垃圾回收)时只会改变句柄中的示例数据的指针,而reference本身不会被修改;
  • 指针访问:最大的好处就是速度快,它节省了一次指针定位的时间开销,由于对象的访问十分频繁,所以在一定量的程度下这种方式可以节省大部分的访问时间;

1.2 代码测试1:Hello OutOfMemoryError

 这里主要测试堆内存溢出异常OutOfMemoryError,堆中唯一存放的目的就是对象实例,所以只要有一定数量的对象实例就可以达到上述的异常效果。配合IDEA中参数启动和JDK自带的Java VisualVM工具可以查看到具体的运行情况,下面进行测试,代码:

public class HeapOOM {
    static class OOMObject {

    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();
        while (true) {
            list.add(new OOMObject());
            System.out.println("the size of list is " + list.size());
        }

    }
}

配置虚拟机的启动参数:Edit Configurations…–>VM options一栏中填写:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError,上述参数各自的含义为:

  • -Xms20m表示堆的最小值(可以理解为java程序启动时分配得到的内存,即初始内存);
  • -Xmx20m表示堆的最大值(可以理解为java程序在运行时最大可用内存);
  • -XX:+HeapDumpOnOutOfMemoryError表示让虚拟机在出现内存溢出异常时Dump出当前内存堆转储快照的形式以便时候分析;

注:当堆的最小值和最大值设置为一样就表示此时堆是不可扩展的,已经被定死为20M,这样一来,由于在OOMObject对象实例都是存放在堆中的,在该对象实例的数量达到最大堆的容量限制后就会产生内存溢出异常,上述是设置的20M;

运行上述程序,我的结果如下:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid5836.hprof ...
Heap dump file created [30741951 bytes in 0.131 secs]

 正如我们所期待的内存溢出异常,在OutOfMemoryError后面进一步的提示Java heap space,通过内存映像分析工具对Dump出来的堆进行分析,有必要分清是内存泄漏还是内存溢出

1.3 代码测试2:虚拟机栈和本地方法栈溢出

 在HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,所以对于HotSpot来说,虽然-Xoss参数(用于设置本地方法栈大小)存在,但实际上是没有作用的,栈的容量只由-Xss参数决定。在虚拟机栈和本地方法栈中存在两种可能的异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常;
  • 如果虚拟机在扩展栈的时无法申请到足够的内存空间,将抛出OutOfMemoryError异常。

 要实现上述的异常,首先必须搞清楚两者的区别,其实当栈空间无法继续分配时到底是内存太小还是已使用的栈空间太大其实是一个意思(这句话我不是很懂),当然栈中存放是什么(立刻联想“基站”,对存放是8中基本数据类型的和对象引用),所以我们只要不断往栈里面塞上述的东西,然后将栈-Xoss参数调至有限小必定会发生异常,代码如下:

/**
启动参数:-Xss128k
**/
public class JavaVMStackSOF {
    private int stackLength = 1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
            //这里不要想当然的写作Exception,该类的父类是Throwable
        } catch (Throwable e) {
            System.out.println("stack length: " + oom.stackLength);
            throw e;
        }
    }
}

结果:

Exception in thread "main" stack length: 985
java.lang.StackOverflowError
	at com.hhu.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:11)
	at com.hhu.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12)
    ...

发生了栈溢出异常StackOverflowError(注意985并不是一个固定的值),但是正如书中作者的所讲他无法获取到栈空间的内存溢出异常,多线程创建的是属于对象,它造成的内存溢出并不能说是栈空间内存溢出。

1.3 代码测试3:方法区和运行时常量池溢出

 之前已经提过,运行时常量池是属于方法区的一部分,在JDK1.7开始逐步去“永久代”(Oracle从1.7发布就一直宣布要完全移除永久代),到了JDK1.8中,正式终结了永久代,用元数据空间取代:PermGen–>Metaspace。

 内存溢出异常可以利用如下代码获取:

/**
启动参数:-XX:PermSize=10M -XX:MaxPermSize=10M
**/
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        int i = 0;
        while (true) {
            //使用intern()方法可以复用生成的字符串
            list.add(String.valueOf(i++).intern());
        }
    }
}

通过-XX:PermSize-XX:MaxPermSize参数来限制方法区的大小,从而间接限制方法区中的常量池的大小,从而运行上述程序得到内存溢出的信息,因为String类的intern()方法是一个Native方法,在JDK1.6之前常量池是分配在永久代中,并且提示PermGen space。但是在JDK8中,上述的程序会一直运行,不再存在永久代的概念。

 方法区是用于存放Class的相关信息,如类名、访问权限修饰符、常量池、字段描述、方法描述等信息,所以可以不断的创造类来方法来将方法区填满以便出现方法区的内存异常。

1.4 代码测试4:本机直接内存溢出

 本机直接内存可以通过-XX:MaxDirectMemorySize来指定,否则默认和Java的最大堆(通过Xmx指定)一样,代码如下:

public class DirectMemoryOOM {
    private static int _1MB = 1024 * 1024;

    public static void main(String[] args) throws IllegalAccessException {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe)unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}

结果遇到期待的内存溢出异常。

二、垃圾收集器和内存分配策略

 垃圾回收是java的一种特性,就是我们通常所说的GC,虽然java中的垃圾回收是自动的,但是作为一名合格的程序员需要排查各种内存泄漏、内存溢出的问题,这两个问题一直是小白菜们的两个噩梦(甚至有些小白菜梦里都没见过,更别谈去排查、去深入理解了),垃圾回收尤其咋高并发场景下追求的一种指标更要好好研究。

 在虚拟机中,程序计数器、虚拟机栈、本地方法栈都是随着线程的生而生、灭而灭:栈中的栈帧随着方法的进入和退出而进行这出栈和入栈的操作,每个栈帧中分配多少内存基本是在类结构确定下来时也就已知了,因此这些区域内存分配和回收都是确定的,所以不必再去考虑垃圾的回收(方法或者线程结束时,内存就自然跟着被回收了);而Java堆和方法区不一样,一个接口中的多个实现类需要的内存可能不太一样,只有在程序处于运行的时候才能知道会创建哪些对象,所以这部分的内存分配和回收都是动态的,垃圾回收器关注的就是这部分的内存。

2.1 对象的生死

 之前已经提过,java中的对象实例是存在于堆内存中的,垃圾收集器在对堆进行回收前,首先要做的就是辨别对象的生死(对象是否还被使用)。Java中判断对象的生死主要有下面两种方式:

  • 引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加1,当引用失效时,计数器就减1,任何时刻计数器为0的对象就是不可能再被引用了。这个方法实现简单,判断效率也很高,大部分情况都是不错的方案(微软的COM技术、使用ActionScript3的FlashPlayer、Python语言和游戏脚本都是采用的引用计数算法进行内存管理),但是主流的Java虚拟机没有选用引用计数算法来管理内存,原因是它很难解决对象之间的相互循环引用的问题;

  • 可达性分析算法:主流商用语言都是通过可达性分析算法来判断对象是否存活,他是通过一系列的“GC Roots”对象(在Java中,GC Roots包括虚拟机栈中引用的对象、方法区中静态类属性引用的对象、方法区中常量引用的对象、本地方法栈中一般Native方法引用对象,)作为起点,从这个节点开始向下搜索,搜索所走过的路径称为引用链(即GC Roots),当一个对象到GC Roots没有任何引用链相连时,则证明一个对象是不可用的;

 关于“引用”这个词,在之前的定义为如果reference中存储的数值代表的是另一个内存的起始地址,那么就称这个reference为引用。这样的定义就把对象的状态分为了引用和未被引用两种状态,但是Java中往往存在着一些无聊的对象:当内存还是足够的,则将其保留在内存中,如果内存空间在垃圾回收后还是很紧张,那么此时就抛弃这些对象,这些无聊的对象就是我们通常所说的缓存。在JDK1.2以后将引用的概念的扩展下面的四种:强引用、软引用、弱引用、虚引用。(引用的4中在我求职笔试时经常遇到),上述4中引用的强度是逐渐减弱的,记首字母:“强、软、弱、虚”。下面简述下4中引用的概念:

  • 强引用:在程序中普遍存在,类似于Object obj = new Object();这样的引用,只要强引用存,垃圾收集器就不永远不会回收被引用的对象;
  • 软引用:它是用来描述一些有用但非必须的对象,在系统将要发生内存溢出异常之前,将软引用对象进行第二次垃圾回收,若这次回收还没有得到足够的内存才会出现内存溢出异常,在JDK1.2后,提供SoftReference类来实现软引用;
  • 弱引用:它用来描述非必须对象,强度更弱,被弱引用关联的对象只能生存在就下一次垃圾收集发生之前(注意软引用是在第二次垃圾回收进行回收),当垃圾收集器工作之前,无论内存是否充足,都会回收掉被弱引用关联的对象,在JDK1.2后,提供了WeakReference类来实现弱引用;
  • 虚引用:也称为幽灵/幻影引用,是最弱的一种引用,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象实例,为一个对象设置虚引用关联的唯一目的就是在这个对象被收集器回收时收到一个系统通知,在JDK1.2之后,提供了PhantomReference类来实现虚引用。

 在上面提过判定对象的生死我们通常的方式有两种:引用计数算法、可达性分析算法,主流的正式虚拟机采用可达性分析算法,但是在可达性分析算法中即使对象不可达,也不是一定是死亡的,但一定是将要死亡的,一个对象死亡必然要讲过两次标记过程:第一次是对象在可达性分析算法中和GC Roots没有相连的引用链即不可达,此时会被第一标记并进行一次筛选(筛选的条件是对象是否有必要执行finalize()方法即垃圾回收在清理对象前调用的方法,当对象没有覆盖该方法或该方法已经被虚拟机调用,那么这两种情况对虚拟机来讲都是没有必要执行;若这个对象是有必要执行finalize()方法,那么该对象将会被放在F-Queue队列中,并在稍后由虚拟机自动建立的、低优先级的Finalizer线程去执行,虚拟机会触发这个线程的执行但不承诺一定让它执行完毕,因为如果队列中某个或者某些对象的finalize()方法执行缓慢或者发生死循环,可能导致F-Queue队列一直处于等待,甚至导致整个内存回收系统崩溃)。finalize()方法是对象可能生存的最后机会,随后GC将对F-Queue中的休想进行第二次小规模的标记,如对象不想死,对象需要重新与引用链上的任何一个对象(这句话有点么懵逼,不是应该是到GC Roots之间存在引用链才行吗,这里怎么任意对象了)建立关联即可,如将自己赋值给某个变量或者对象的成员变量,那在第二次标记时它将会被移出“即将被回收”的队列,如果这时没有建立引用,这个对象基本就挂了(既然第一次可能发生意外导致对象不能被回收,为什么第二次就能保证一定会被回收呢?)。下面是一个对象的拯救成功和失败的案例:

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes, I am still alive :) -- " + SAVE_HOOK);
    }

    //重写finalize方法,该方法只被调用一次,但并不是调用后立刻被回收
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();

        /*
        自救成功
         */
        //第一次自救
        SAVE_HOOK = null;
        //提醒虚拟机进行垃圾回收,但是虚拟机具体什么时候进行回收就不知道了
        System.gc();

        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("No, I am dead :(");
        }

        /*
        自救失败
         */
        SAVE_HOOK = null;
        System.gc();
        //finalize方法的优先级比较低所以等待它0.5秒
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("No, I am dead :(");
        }
    }
}

瞄一眼输出结果:

finalize method executed!
yes, I am still alive :) -- com.hhu.FinalizeEscapeGC@1761e840
No, I am dead :(

上述结果表明对象在第一次拯救成功和第二次拯救失败的案例。调用System.gc()时是出发了垃圾回收,在执行finalize()方时, 将SAVE_HOOK重新用this关键字挂上和当前对象关系,所以在第二次标记时他已经不再“待回收”的队列中了,所以此时对象还是存活的;但是第二次逃亡的时候,不再执行了finalize()方法了(之前执行过一次,对象的finalize()方法必定只执行一次),在SAVE_HOOK置为null后不再可达,finalize()方法也是没有必要执行的情况,所以它就直接为null了,没有指向任何对象,此时对象已死。这种方式运行代价比较大而且不确定性大,不建议使用。关于finalize()方法可以用于“关闭外部资源”,但是这一块我们之前一直用的是try-finally在做,而且效果也比finalize()效果要好,所以这个方法放在心里,别瞎用。

2.1.1 回收方法区

 在Java虚拟机规范中曾经说过可以不要求虚拟机在方法区实现垃圾收集(在方法区进行垃圾收集性价比较低,在堆中,尤其在新生代中,常规的一次垃圾回收可以回收70%~95%的空间,但是永久代的垃圾收集效率远低于此),因为学习时,作者是以JDK1.6和JDK1.7为例来进行讲解的,但是现在JDK1.8已经出来了,所以这话就有毛病了,现在的永久代已经从方法区移除了,并且改成了元数据区,不存在永久代这一说法了。但学习还是要继续(●ˇ∀ˇ●),从JDK1.7看呗。

 永久代的垃圾收集主要回收两部分东西:废弃常量和无用的类。常量池的常量如果不再被其他地方引用,那么在垃圾回收时就会被回收;无用的类判定标准有三:该类的所有实例都已经被回收(Java堆中不再该类的任何实例);加载该类的ClassLoader已经被回收;该类对应的java.lang.Class对象没有任何地方引用,无法在任何地方通过反射访问该类的方法,但是注意的是这里虚拟可以对满足上述3个条件的无用类进行回收,但不像对象那样必然被回收,是否对类进行回收,HotSpot提供了-Xnoclassgc参数来控制。在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备卸载的功能来保证永久代不会溢出。

2.2 垃圾收集算法

2.2.1 标记-清除算法

 标记-清除算法(Mark-Sweep)是最基础的收集算法(下面的其他两种回收算法都是基于它的思路进行的改进),它分为“标记”和“清除”两个阶段,关于标记过程在前面已经提过,将所有需要对手的对象都标记一下,然后统一回收,过程如下:
这里写图片描述
它的不足有两个:

  • 效率不高,标记和清除两个过程的效率都不高;
  • 空间问题,标记清除后会产生大量不连续的内存碎片,碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次的GC。
2.2.2 复制算法

 为了解决效率的问题,复制算法出现,它将可用内存按照容量划分为大小相等的两块,就每次只使用其中的一块,当这块内存用完,就将还存活的对象复制到另一块上面,然年再把已使用的内存空间一次清理掉,这样每次都是对整个半区进行内存回收,内存分配时也不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,运行效率提高,但是它要付出的代价是将原内存缩小为原来的一半,有点不划算。主要过程如下:

这里写图片描述

这个算法是被用在新生代的垃圾回收机制中的,在实际应用中,新生代的垃圾回收并不按照上述将整个堆存对半分,而是将新生代分成了较大的Eden区和两个较小的Survivor区(及我们通常所说的from区和to区),每次使用Eden和from,当回收垃圾时,将Eden和from区中还存活的对象一次性的复制到to区,最后清理刚刚的Eden和from区,在HotSpot中,Eden和Survivor默认的比例是8:1(这个说法比较别扭,其实就是新生代中3个区域的内存所占比比例为8:1:1),如下所示:
这里写图片描述

所以下次再有人问你,关于新生代的问题应该可以应付了哈,而且关于内部内存的划分也很清楚。每次新生代可用的内存空间其实都是Eden区+1个Survivor区的内存和(8+1=90%),只有10%的内存用来“浪费”(是一种有意义的浪费)。新生代的的对象98%都是“朝生夕死”的,所以才将新生代像上述那样划分具备很高的利用率,不要担心说你怎么知道每次存活下来的对象必然少于10%呢(因为用于年轻代复制存放的内存在HotSpot中就是设计的10%内存),这里厉害了,当我们这里10%的Survivor内存不够用来存放Eden和其中一个Survivor中存活的对象时,还有一个内存依赖,就是用老年代进行分配担保,将他们全部复制到老年代(这货有点像银行担保人的角色),所以新生代和老年代两这个共同组成了java的堆内存,这么说就很好理解了,知识点哈。

2.2.3 标记-整理算法

 扯了那么多关于新生代的内存划分,让我们回到正常的复制算法,如果每次存活的对象比较少,那么需要复制的对象就比较少,但是如果对象存活率比较高呢,此时复制算法的效率明显会降低,关键的是,如果不想浪费50%的内存空间,就需要有额外的内存空间进行分配担保,以应对使用的内存中所有的对象100%存活的极端情况(关于这里,其实不是新生代,就算是100%存活,那也是占了50%内存,为什么还要担保内存?),所以老年代一般不能直接选用复制算法。

 好了,来看标记-整理算法,这是老年代的回收算法,标记和“标记-清除”算法是一样的,但是后面不是直接对对象进行回收清除,而是让所有的存活对象都向一端移动,然后直接清理掉端界处边界以外的内存,示意图如下所示:

这里写图片描述

2.2.4 分代收集算法

 这玩意儿是当前商业虚拟机垃圾收集的采用的方式,它根据对象存活周期的不同将内存划分为几块,一般将Java堆划分为新生代和老年代两类,然后根据各自的特点选用不同的回算法:在新生代中,对象“朝生夕死”,死亡率极高,所以选用了复制算法,只要极少的进行复制就可以完成工作;在老年代中,存活率较高,没有额外的空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。

2.3 HotSpot算法的实现

 上述是从理论上说明了对象存活判定算法和垃圾回收算法,在实际HotSpot上实现这些算法时,必须对算法的执行下效率有严格的考量,才能保证虚拟机高效运行。

2.3.1 枚举根节点

 从之前对象存活判定的可达性分析算法来看,从GC Roots节点(全局性引用与执行上下文)找引用链的行为来看,许多应用中仅仅是方法就有数百兆,如果逐个检查,必然需要耗费巨大的时间。另外除此之外,可达性分析算法执行的时间还存在GC停顿,算法中的分析工作必须在一个能确保一致性(指在整个分析周期间整个系统执行起来就想被冻结在某个时间点上,不能出现分析过程中对象引用关系还在不断的变化,如果这样分析的结果准确性就无法得到保证)的快照中进行,一致性的无法得到保证是导致GC必须停顿所有的Java执行线程的一个重要原因,及时在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

 当前主流的JVM使用的是准确式GC,所以在执行系统停顿时,并不需要检查所有执行上下文和全局引用位置,虚拟机知道对象引用的存放位置,在HotSpot中,就是使用OopMap的数据结构来达到这个目的,在类加载完成时,HotSpot就把相应的信息计算出来,在JIT编译过程中,也会在特定的位置记录栈和寄存器中的哪些位置是引用,这样GC在扫描时就可以直接得知这些信息。

2.3.2 安全点

 在OopMap协助下,HotSpot可以快速且准确的完成GC Roots的枚举,但是可能导致引用关系变化,或者可以这么理解,如果么为每一条指令都生成OopMap,那么需要很多额外的空间,这样GC的空间成本就会很高。HotSpot并没有说为每一个指令都生成一个OopMap,只是在特点的位置记录这些信息,这些特定的点就是安全点,也就是说并不是程序在所有地方都停下来开始GC,只有到安全点才会停顿下来,安全点的数量不能太少让GC等待的时间太长,也不能太多增加运行负担。关于安全点的选取,基本是以程序“是否有让程序长时间执行的特征”为标准来进行选取,每个指令的执行时间很短,程序不太可能因为指令流太长而长时间运行,“长时间执行”的最明显的特征就是指令序列复用(比如方法调用、循环跳转、异常跳转等,这些功能指令都会产生Safepoint)。

 还有一点就是如何在发生GC时,让所有线程都在最近的安全点上停顿下来,两种方案:抢先式中断和主动式中断。抢先式中断在GC发生时,首先把所有的线程全部中断,如果发现有线程中断的地方不是安全点就恢复线程,让它执行到安全点,这种方式现在几乎不使用了;主动式中断就是在发生GC时,不直接对线程操作,仅仅设置一个标志,各个线程执行时主动去轮询这个标志,发现这个中断标志为真时就让自己自动挂起,轮询标志的地方和安全点重合。

2.3.3 安全区域

 安全点保证了程序执行时,那程序在不执行时(即CPU没有给它分配时间),比如线程处于睡眠或者锁定的情况下,此时线程是无法相应JVM的中断请求的,这时安全区域就发挥作用了,在安全区域中,引用关系不会发生改变,在该区域中任意地方开始GC都是安全的。

2.4 垃圾收集器

 垃圾收集器是用来具体实现垃圾的回收,Java虚拟机规范中对于垃圾收集器没有任何规定,所以不同厂商都会提供参数让用户自己根据应用的特点来组合出各个内存年代使用的收集器,下图是JDK1.7u14版本中包含的收集器:

这里写图片描述

总共是7种作用于不同年代内存的垃圾收集器,如果两个收集器之间存在连线,那表明它们可以搭配使用。首先在垃圾收集器中,并没有最好的垃圾收集器出现,需要我们根据情况选择最合适的垃圾收集器,HotSpot之所以实现这么多的垃圾收集器就是因为没有一个完美的收集器存在。

2.4.1 Serial收集器

 Serial收集器是最基本、历史最悠久的收集器,在JDK1.3.1之前是新生代收集的唯一选择,它是一个单线程的收集器,但这不是说它只用一个CPU或者一个线程去完成垃圾收集的工作,在它进行垃圾收集时,其他工作线程必须全部停止,直到Serial收集器收集完成(这个特性被称之为“Stop the World”),过程如下:

这里写图片描述

这个特性确实很蛋疼,莫名其妙(用户不可见,是由虚拟机自动执行的)的就使程序暂停相应来做GC的工作,作为用户肯定无法忍受,但是作为开发人员却很好理解这一行为,如果在收集垃圾的同时程序还在不断生产垃圾,那么这些垃圾还能收集的完吗,所以这是一个很能理解的矛盾,要解决这个问题一直都很难,但是随着收集器的不断发展,这种停顿感的时间是越来短了(从Serial收集器到Parallel收集器,再到Concurrent Mark Sweep即CMS,再到最高级的G1收集器)。尽管如此,Serial收集器依然是虚拟机在Client模式下的默认新生代收集器,它简单高效,对于限定单个CPU的环境而言,,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获取最高单线程收集效率。在用户桌面应用中,分配虚拟机管理的内存一般不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本不会再大了),停顿的时间可以控制在几十毫秒最多一百多毫秒以内,只要不是频繁发生,这中停顿是可以接受的,所以Serial收集器对于运行在Client模式下的虚拟机是一个就很好的选择。

2.4.2 ParNew收集器

 ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集以外,其他行为包括Serial收集器可用的所有控制参数(如:-XX:SurvivorRation-XX:PretenureSizeThreshold-XX:HandlePromotionFailure等)、收集算法、Stop the World、对象分配规则、回收策略等都是和Serial收集器完全一样,在代码实现层面也有很多一样,ParNew收集器的工作过程如下:

这里写图片描述

 ParNew收集器相对与Serial收集器而言并没有太多的创新点,但它是许多运行在Server模式下虚拟机首选的新生代收集器,最重要的原因是除了Serial收集器外,只有它可以与CMS(Concurrent Mark Sweep)收集器配合工作(这不是说ParNew有多好,而是说CMS收集器很棒)。CMS收集器是HotSpot虚拟机中第一款真正意义的并发(Concurrent)收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。在使用CMS作为老年代的收集器时,与其配合工作只能是ParNew收集器或者Serial收集器。ParNew收集器也是使用-XX:+UseConcMarkSweepGC选项后默认的新生代收集器,也可以是使用-XX:+UseParNewGC选项来强制指定它。

 ParNew收集器在单CPU环境下决对不会比Serial收集器有更好的效果,甚至由于线程的交互开销,该收集器在通过超线程技术实现的两个CPU环境中都不绝对保证可以超越Serial收集器的效果,当然随着CPU数量的增加,他对于GC发生时资源的有效利用是肯定有好处的。ParNew默认开启的收集线程与CPU的数量相同,在CPU数量很多的环境下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

 最后对并行和并发这两个词的概念做一个界定:

  • 并行(Parallel):多条垃圾回收线程并行工作,但此时用户线程仍然处于等待的状态;
  • 并发(Concurrent):用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集程序在另一个CPU上。
2.4.3 Parallel Scavenge收集器

 Parallel Scavenge收集器是一个新生代收集器,使用复制算法,也是并行的多线程收集器,它的目标是达到一个控制的吞吐量(Throughput),即CPU用于运行用户代码时间与CPU总消耗时间的比值(吞吐量=用户代码运行时间/(用户代码运行时间+垃圾收集时间)),这里我们肯定希望吞吐量越高越好,高吞吐量可以高效利用CPU时间,尽快完成运算任务,主要适合在后台运算而不需要太多交互任务。Parallel Scavenge收集器提供了控制最大垃圾停顿时间参数-XX:MaxGCPauseMills和直接设置吞吐量小的参数-XX:GCTimeRatio。收集器将尽可能地保证内存回收话费的时间不超过-XX:MaxGCPauseMills参数设定的值,但需要注意的是-XX:MaxGCPauseMills参数不是越小越好,GC的停顿时间是以牺牲吞吐量和新生代的空间来换取的(系统将新生代调小,GC停顿时变短了,但是GC频率变高了,吞吐量也变低了)。

 Parallel Scavenge收集器与吞吐量关系密切,所以也称为“吞吐量优先”收集器,它有一个开关参数-XX:UseAdaptiveSizePolicy,当这个参数打开后,就不需要手动指定新生代大小(-Xmn)、Eden与Survivor的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统的运行情况手机性能监控信息,动态调整这些参数以提供合适的提顿时间或者最大吞吐量,这种方式就GC自适应的调节策略。

GCTimeRation参数应当是一个大于0且小于100的整数,表示垃圾收集时间占总时间的比率(这个定义有些问题,应该是最大垃圾收集时候不超过1/(1+n)),相当于吞吐量的倒数,若设置成19,那么允许的最大GC时间占总时间的5%(1/(1+19)),默认是99,就是最大的允许垃圾收集时间占总时间的1%(1/(1+99))。

2.4.4 Serial Old收集器

 Serial Old是Serial收集器的老年代版本,同样是单线程收集器,使用标记-整理算法,该收集器主要意义是给Client模式下的虚拟机使用,如果在Server模式下,那么它还可以在JDK1.5之前的版本中与Parallel Scavenge收集器搭配使用,此外,可以作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。Serial Old的工作流程如下:

这里写图片描述

2.4.5 Parallel Old收集器

 同样的,Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法,这个收集器在JDK1.6中才开始提供,之前新生代的Parallel Scavenge收集器一直处于比较尴尬的状态(若新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器后没有其他选择),老年代Serial Old收集器在服务端应用性能上的“拖累”,使用了Parallel Scavenge收集器也未必可以让整体应用获得最大吞吐,单线程的老年代收集中无法利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量还不一定有ParaNew+CMS组合高。直到Parallel Old收集器出现后,“吞吐量优先”收集器Parallel Scavenge才有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的应用场景,都可以优先考虑Parallel Scavenge + Parallel Old收集器的组合方式,Parallel Old收集器工作方式如下:

这里写图片描述

2.4.6 CMS收集器

 CMS(Concurrent Mark Sweep)收集器是一种获取最短垃圾s收集时间为目标的收集器,使用标记-清除算法,CMS收集器最大的优点就是GC发生时,停顿的时间会非常短,同时CMS也是划时代真正意义上的并发垃圾收集器,在目前Java在服务器和互联网应用基本都会有这种需求以求得良好的用户体验,很多都是使用CMS收集器。

 CMS的运作过程主要分为4步:

  • 初始标记(CMS initial mark);
  • 并发标记(CMS concurrent mark);
  • 重新标记(CMS remark);
  • 并发清除(CMS concurrent sweep);

 上述的四步中,初始标记和重新标记这两步仍然需要“Stop the World”(即停止所有工作线程),初始标记是标记GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC RootsTracing的过程,而重新标记阶段是为饿了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段停留的时间会比前面的初始标记阶段更长,但又比并发标记的时间短。整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与线程一起工作,所以总体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的,工作过如下:

这里写图片描述

 总的来说,CMS是一款优秀的收集器:并发收集、低停顿,所以也称之为并发地停顿收集器(Concurrent Low Collector),但是CMS并不是完美的,主要有3个明显的缺点:

  • CMS收集器对CPU资源非常敏感。面向并发设计的程序都对CPU资源都比较敏感,在并发阶段,它不会导致用户线程停顿,但会因为占用一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量降低。CMS默认启动的线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾线程不少于25%的CPU资源,并随着CPU的增多而下降,但是当CPU不足4个时,CMS对用户程序的影响就比较大了,如果本来CPU负载就大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度突然降低50%,为了解决这种情况,虚拟机提供了一种称为“增量式并发收集器”(i-CMS)的CMS收集器变种,所作的事情和单CPU年代PC机操作系统使用抢占式来模拟多任务机制的思想一样,在并发标记、清理的时候让GC线程、用户线程交替运行,减少GC线程的独占资源的时间,这样整个GC的收集时间变长了,但对用户的影响会减少(下降的速度没有那么面明显),但是实践证明i-CMS效果一般,已经声明被丢弃。

  • CMS无法处理浮动垃圾,可能出现Concurrent Mode Failure而导致另一次Full GC的产生。在CMS并发清理过程中,用户线程还在运行,就会有新的垃圾产生,这部分垃圾出现在标记之后,CMS无法在目前的收集中处理它们,留待下一次GC再作清理,这部分的垃圾就是浮动垃圾,由于用户线程运行需要足够的内存空间,因此CMS不能向其他收集器那样等到老年代几乎满了再收集,需要预留空间提供并发收集时用户线程的运行。

  • CMS是基于“标记-清除”算法的收集器,所以它在收集后会存在大量的空间碎片,在为较大对象分配内存时会出现麻烦,往往出现老年代还有很大空间,但是无法找到足够大的连续空间来分配,不得不提前触发一个Full GC,为了解决这个问题,CMS收集器提供了-XX:UseCMSCompactAtFullCollection开关参数(默认开启),用于在CMS收集器顶不住要进行Full GC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没了,但停顿的时间肯定会变长,-XX:CMSFullGCsBeforeCompaction参数可以用于设置执行多少次压缩的Full GC后,跟着来一个带压缩的(默认为0,表示每次进入Full GC时都会进行碎片整理)。

2.4.7 G1收集器

 G1(Garbage-First)收集器是收集器最新的成果,G1是一款面向服务端应用的垃圾收集器,它的目标是可以替换掉JDK1.5发布的CMS收集器,G1具备如下的特点:

  • 并行与并发:G1可以充分利用多CPU、多核环境下的硬件优势,使用多CPU或CPU核心来缩短停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让其继续运行;

  • 分代收集:与其他收集器一样,分代的概念在G1中仍然存在,虽然G1可以不需要其他收集器配合就可以独立管理整个GC堆,但它可以次采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的就对象以获取更好的收集效果;

  • 空间整合:与CMS“标记-清理”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,从局部来看是基于“复制”算法实现的,不论是哪一种,G1收集后不会产生空间碎片,可以提供规整的可用内存,有利于程序的长时间运行,分配大对象不会提前触发Full Gc;

  • 可预测的停顿:G1可以有计划的避免在整个Java堆中进行全区域的垃圾回收。G1跟踪各个Region里的垃圾堆积的价值大小(回收所获得的空间大小和回收所需要的时间的经验值),在后台维护一个优先列表,优先回收价值最大的Region(这也是G1名字的由来,Garbage-First),这种使用Region划分内存空间以及有优先级的区域回收方式,保障饿了G1在有限的时间内可以获取尽可能高的收集效率。

 在G1之前的其他收集器进行收集时都是整个新生代或者老年代,G1并不是这样,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但是新生代和老年代就不再是物理隔离的,它们都是一部分Region(不需要连续)的集合。

 G1在进行垃圾回收时,在GC根节点的枚举范围内加入Remembered Set即可保证整个堆扫描也不会有遗漏。如果不计算维护Remembered Set的操作,G1收集器的运作可以分为以下的4个步骤:

  • 初始标记(Initial Marking);
  • 并发标记(Concurrent Marking);
  • 最终标记(Final Marking);
  • 筛选回收(Live Data Counting and Evacuation);

 G1的工作步骤和CMS回收器有些类似,初始标记阶段仅仅是标记GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短(这点和CMS简直一模一样);并发标记阶段是从GC Roots开始对堆中对象进行可达性分析,找出存活的对象,这个阶段耗时较长,但可以与用户程序并发执;最终标记阶段是为了修正在并发标记期间因用户线程继续运行而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在Remembered Set Logs里面,最终标记阶段需要将Remembered Set Logs的数据合并到Remembered Set中,这个阶段需要停顿线程,但是可并行执行;筛选回收这个阶段其实也可以做到与用户工作线程并发执行,但是因为只是回收一部分的Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率,整个过程如下所示:

这里写图片描述

2.4.8 GC日志

 每种垃圾收集器的日志格式都是由各自的具体实现决定,为了保证一定阅读质量,它们也有一定的共性,下面是两个小栗子,日志如下:

这里写图片描述

 上面两条记录首数字33.125100.667表示GC开始的时间(这里的开始时间是从Java虚拟启动到GC发生经历的秒数);然后[GC[Full GC表示的是垃圾收集的停顿类型,它不是用来区分新生代GC还是老年代GC的,如果出现了Full,说明这次GC是发生了“Stop the World”;然后[DefNew[Tenured以及[Perm表示的是GC发生的区域,这里显示的这些个名字与使用的GC收集器是乡姑纳的,上面使用Serial收集器中新生代的名字为Default New Generation,所以显示的是[DefNew,如果是ParNew收集器,新生代名字就是ParNew,表示的是Parallel New Generation,如果采用Parallel Scavenge收集器,那么它配置的新生代名字就为PSYoungGen,老年代和和永久代同理;方括号内部在区域名字冒号后面的3324K->152K(3712K)表示的是“GC前该内存区域已经使用的空间大小->GC后该内存区域已使用的空间大小(该内存区域的总空间大小)”;在方括号外部的3324K->152K(11904K)表示“GC前Java堆已使用的空间大小->GC后Java堆已使用的空间大小(Java堆总容量)”;再向后0.0025925secs表示该内存区域GC所占用的时间,单位为秒,这里有些收集器会给出更为具体的数据如[Times:user=0.01 sys=0.00, real=0.02secs],这里的usersysreal和Linux的time命令输出的时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU时间、操作从开始到结束所经过的墙钟时间, 需要注意的是墙钟时间包括了各种非运算的等待耗时(如等待磁盘的I/O、等待线程阻塞),而CPU时间是不包括这些的,但如果系统有多CPU或者多核的话,多线程操作会叠加这些CPU时间,所以user或者sys时间超过real就很正常了。

2.4.9 垃圾收集器参数总结

 虚拟机非稳定运行常用参数如下:

参数描述
UseSerialGC虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial + Serial Old的收集器组合进行内存回收
UseParNewGC打开此开关后,使用ParNew + Serial Old的收集器组合进行内存回收
UseConcMarkSweepGc打开此开关后,使用ParNew + CMS + Serial Old的收集器组合进行内存回收,Serial Old收集器作为CMS收集器出现Concurrent Mode Failure后的后备预案收集器使用
UseParallelGc虚拟机运行在Server模式下的默认值,打开后,使用Parallel Scavenge + Serial Old(PS MarkSweep)的收集器组合进行内存回收
UseParallelOldGc打开后,使用Parallel Scavenge + Parallel Old的收集器组合进行内存回收
SurvivorRation新生代中Eden区和Survivor区域容量的比值,默认为8
PretenureSizeThreshold直接晋升到老年代的对象大小,设置这个参数后,大于该参数的对象直接在老年代中分配
MaxTenuringThreshold晋升到老年代的对象年龄,每个对象在坚持过Minor GC之后年龄就加1,年龄超过该参数的对象就进入老年代
UseAdaptiveSizePolicy动态调整Java堆中各个区域的大小以及进入老年代的年龄
HandlePromotionFailure是否允许分配担保失败,即老年代剩余空间不足以应付新生代的整个Eden和Survivor区所有对象都存活的极端情况
ParallelGCThreads设置并行GC时进行内存回收的线程数
GCTimeRatioGC时间占总时间的比率,默认值为99,即允许1%的GC时间(1/(n+1)),仅在使用Parallel Scavenge收集器时有效
MaxGCPauseMills设置GC的最大停顿时间,仅在使用Parallel Scavenge收集器时有效
CMSInitiatingOccupancyFraction设置CMS收集器在老年代使用多少后触发垃圾回收,默认为68%,仅在使用CMS时有效
UseCMSCompactAtFullCollection设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片整理,仅在使用CMS时有效
CMSFullGCsBeforeCompaction设置CMS收集器在进行若干次垃圾收集后再启动一次内存碎片整理,仅在使用CMS时有效

2.5 内存分配与回收策略

 Java中自动内存管理机制通过自动化方式解决了两个问题:给对象分配内存以及回收分配给对象的内存。总的来说,对象分配内存就是在堆上就行分配(也可能经过即时编译器JIT编译后被拆分为标量类型并简介在栈上分配),具体分配到新生代的Eden区,如果启动本地线程分配缓冲,将按线程优先在TLAB上分配,少数情况下也可能会直接分配在老年代,分配的规则并不固定,细节取决于使用的收集器组合和相关参数的配置。

 下面的栗子是在Client模式虚拟机下运行,未指定收集器组合(即使用Serial/Serial Old收集器),来看内存分配和回收策略。

2.5.1 对象优先在Eden上分配

 通常新对象都是在Eden区域上分配,当Eden区域没有足够空间进行分配时,就会发起一次Minor GC。虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出时输出当前的内存区域分配情况,实际应用中,内存日志一般是打印到文件后通过日志工具进行分析,人肉分析在日志量比较大的时候会有些麻烦。

 下面是一个小栗子:

/*
*虚拟机运行参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
public class MinorGCTest {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;
        //分配2M空间到Eden,Eden剩余6M,两个Survivor各剩1M
        allocation1 = new byte[2 * _1MB];
        //分配2M,Eden剩余4M,两个Survivor各剩1M
        allocation2 = new byte[2 * _1MB];
        //分配2M,Eden剩余2M,两个Survivor各剩1M
        allocation3 = new byte[2 * _1MB];
        //分配4M,Eden剩余2M,Eden区空间不够分配,执行一次Minor GC
        allocation4 = new byte[4 * _1MB];
    }
}

首先虚拟机的运行参数为:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8,一个个的来吧:

  • 首先-verbose:gc表示打印GC简要信息;
  • -Xms20M表示最小堆为20M;
  • -Xmx20M表示最大堆为20M,因为最小堆也为20m,其实就是将堆固定为20M,不让它扩展;
  • -Xmn10M表示新生代大小指定为10M;
  • -XX:+PrintGCDetails表示输出GC详细日志(-XX:+PrintGC表示输出GC日志);
  • -XX:SurvivorRatio=8表示Eden区和Survivor比值为8:1,分配一下就10M的年轻代中有8M为Eden区,两个Survivor区各为1M,剩余的10M分配给老年代,关于内存的分配已经在程序上通过注解给出了一些说明。

JDK7的32位版本运行结果为:

[GC[DefNew: 7512K->438K(9216K), 0.0049140 secs] 7512K->6582K(19456K), 0.0442279 secs] [Times: user=0.00 sys=0.00, real=0.04 secs]
Heap
 def new generation   total 9216K, used 4784K [0x33600000, 0x34000000, 0x34000000)
  eden space 8192K,  53% used [0x33600000, 0x33a3e658, 0x33e00000)
  from space 1024K,  42% used [0x33f00000, 0x33f6dab0, 0x34000000)
  to   space 1024K,   0% used [0x33e00000, 0x33e00000, 0x33f00000)
 tenured generation   total 10240K, used 6144K [0x34000000, 0x34a00000, 0x34a00000)
   the space 10240K,  60% used [0x34000000, 0x34600030, 0x34600200, 0x34a00000)
 compacting perm gen  total 12288K, used 234K [0x34a00000, 0x35600000, 0x38a00000)
   the space 12288K,   1% used [0x34a00000, 0x34a3aa08, 0x34a3ac00, 0x35600000)
    ro space 10240K,  44% used [0x38a00000, 0x38e7b1c8, 0x38e7b200, 0x39400000)
    rw space 12288K,  52% used [0x39400000, 0x39a431d0, 0x39a43200, 0x3a000000)

从上面的运行结果可以看,我们之前的分析是正确的,通过DefNew可以容易判别出此版本的HotSpot默认的垃圾收集器使用的是Serial收集器,新生代总容量为9216K(一个Eden区加一个Survivor区,即(8+1)*1024=9216,后面的输出也很清楚,def new generation total 9216K,eden space 8192,from space 1024K,to space 1024K,tenured generation total 10240K即老年代10M),GC前已经使用了7512K,GC后已使用438K空间(说面新生代内存中很多对象都是“朝生夕死”的),Eden区的GC占用了0.0049140秒时间,Java堆的总容量为19456K(有1M的其中一个Survivor是用来浪费的,所以少的这1M就是少的这一块的内存),GC前堆空间已使用7512K,GC后堆空间已使用6582K,堆空间的GC耗时0.0442279秒时间。在分配前3个2M对象时,Eden剩余2M,两个Survivor各剩1M,第4个对象4M,Eden无法找到连续的4M空间分配给allocation4对象,提前进行了Minor GC,Serial收集器在新生代使用复制算法,发现Survivor大小只有1M,不能将之前的6M东西复制过去,通过分配担保机制(担保人:老年代),所以将这6M的内容复制到老年代中,然后在从清理过后的Eden区分配4M给allocation4对象,所以最后新生代使用了4M(used 4784K),老年代使用了6M(used 6144K),但是整体来讲,由于创建的4个对象都是存活的,所以堆空间的整体使用量并未受太大影响(7512K->6582K)。

JDK8的64位版本运行结果为:

Heap
 PSYoungGen      total 9216K, used 8192K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 100% used [0x00000000ff600000,0x00000000ffe00000,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 4096K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 40% used [0x00000000fec00000,0x00000000ff000010,0x00000000ff600000)
 Metaspace       used 3283K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 359K, capacity 388K, committed 512K, reserved 1048576K

结果类似,只不过JDK8中默认使用的Parallel Scavenge收集器。新生代的GC通常称之为Minor GC,新生代的对象大都是朝生夕死,所以Minor GC相对比较频繁,一般回收速度也比较快;老年代GC称之为Major GC或者Full GC,经常会伴随着至少一次Minor GC(但并非一定,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择),Major GC的速度一般比Minor GC慢10倍以上。

2.5.2 大对象直接进入老年代

 典型的大对象就是很长的字符串以及数组,如果经常出现大对象就需要在内存还有很多时(但不足以分配给下一个大对象)就要提前触发Minor GC,虚拟机提供了-XX:PretenureSizeThreshold参数(字面可以理解为提前进入老年代的域值),单位是字节,使得大于这个数值的对象直接分配到老年代,这样就避免了在Eden区以及两个Survivor区之间大量的复制工作,但是这个参数只在Serial和ParNew两种收集器中有效,比如直接将上面的程序改成:

/*
* 虚拟机启动参数为-XX:+PrintGC -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728
*/
public class MinorGCTest {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) {
        byte[] allocation4;
        allocation4 = new byte[4 * _1MB];
    }
}

虚拟机的启动参数加上了-XX:PretenureSizeThreshold=3145728,即将大对象设置成3M(3M=31024KB=31024*1024B=3145728B),超过3M的对象全部分配到老年代,所以这个对象直接分配到了老年代,而不没有在新生代中逗留(tenured generation total 10240K, used 4096K),新生代几乎没有被使用,日志如下:

Heap
 def new generation   total 9216K, used 1532K [0x33600000, 0x34000000, 0x34000000)
  eden space 8192K,  18% used [0x33600000, 0x3377f080, 0x33e00000)
  from space 1024K,   0% used [0x33e00000, 0x33e00000, 0x33f00000)
  to   space 1024K,   0% used [0x33f00000, 0x33f00000, 0x34000000)
 tenured generation   total 10240K, used 4096K [0x34000000, 0x34a00000, 0x34a00000)
   the space 10240K,  40% used [0x34000000, 0x34400010, 0x34400200, 0x34a00000)
 compacting perm gen  total 12288K, used 234K [0x34a00000, 0x35600000, 0x38a00000)
   the space 12288K,   1% used [0x34a00000, 0x34a3a9b8, 0x34a3aa00, 0x35600000)
    ro space 10240K,  44% used [0x38a00000, 0x38e7b1c8, 0x38e7b200, 0x39400000)
    rw space 12288K,  52% used [0x39400000, 0x39a431d0, 0x39a43200, 0x3a000000)
2.5.3 长期存活的对象进入老年代

 HotSpot中采用的是分代收集的思想来管理内存,虚拟机为每个对象定义了一个“年龄”的属性,如果对象在Eden畜生经过一次Minor GC后仍然存活,并且能被Survivor容纳的话,就将被移动到Survivor中,并且年龄变为1,在Survivor中每经过一次Minor GC没有被清理年龄就增加1,当年龄增加到一定程度(默认是15岁),将该对象一移动到老年代中,关于多大年龄就会被移动到老年代通过参数-XX:MaxTenuringThreshold设置。下面是一个小栗子:

/**
* 虚拟机运行参数为:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1
*/
public class TenurThreshold {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3;
        //即256KB
        allocation1 = new byte[_1MB/4];
        allocation2 = new byte[4 * _1MB];
        allocation3 = new byte[4 * _1MB];
        allocation3 = null;
        allocation3 = new byte[4 * _1MB];
    }
}

结果为:

[GC[DefNew: 5556K->694K(9216K), 0.0050503 secs] 5556K->4790K(19456K), 0.0051049 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
[GC[DefNew: 5039K->0K(9216K), 0.0015177 secs] 9135K->4786K(19456K), 0.0015555 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 4178K [0x33600000, 0x34000000, 0x34000000)
  eden space 8192K,  51% used [0x33600000, 0x33a14828, 0x33e00000)
  from space 1024K,   0% used [0x33e00000, 0x33e000e0, 0x33f00000)
  to   space 1024K,   0% used [0x33f00000, 0x33f00000, 0x34000000)
 tenured generation   total 10240K, used 4786K [0x34000000, 0x34a00000, 0x34a00000)
   the space 10240K,  46% used [0x34000000, 0x344aca70, 0x344acc00, 0x34a00000)
 compacting perm gen  total 12288K, used 234K [0x34a00000, 0x35600000, 0x38a00000)
   the space 12288K,   1% used [0x34a00000, 0x34a3a9f8, 0x34a3aa00, 0x35600000)
    ro space 10240K,  44% used [0x38a00000, 0x38e7b1c8, 0x38e7b200, 0x39400000)
    rw space 12288K,  52% used [0x39400000, 0x39a431d0, 0x39a43200, 0x3a000000)

来分析一下,这段代码在执行的流程,首先从,虚拟的的运行参数我们可以简单的将堆空间描述成下图:

原始堆

其中这里-XX:MaxTenuringThreshold=1表示如果对象的年龄大于1岁就将其复制到老年代(Tenured Generation),下面正式开始程序的工作流程,首先

第一个对象到来:allocation1 = new byte[_1MB/4],Eden区还有8M,对象只有1/4M完全够用,此时内存结构如下:

在这里插入图片描述

然后第二个对象到来:allocation2 = new byte[4 * _1MB],Eden区还剩(8-1/4)M,足够为4M的对象分配空间,此时内存结构如下:

在这里插入图片描述

然后allocation3 = new byte[4 * _1MB],新来的第三个对象为4M,虚拟机发现此时Eden区剩余内存空间(8-1/4-4)M,明显不够为第三个对象分配空间了,此时触发Minor GC行为,也就是上述运行结果的第一行,看一下此次的GC都干了些什么:Survivor区为1M,a1对象为1/4M,一看a1还或者就将其复制到了其中一个Survivor区,这里我们暂先理解为from区,再看a2对象,这货还挺大4M,Survivor完全塞不下,怎么办,通过分配担保机制直接复制到老年代Tenured generation,这是第一次GC,我们的a1对象成功长大1岁,对象a2已经脱离了新生代不计算年龄,内存结构如下:

这里写图片描述

这是我们的第一次GC,看一下GC日志[GC[DefNew: 5556K->694K(9216K), 0.0050503 secs] 5556K->4790K(19456K), 0.0051049 secs],新生代回收了5556-694=4862K的内存空间,而a2对象是4M=4096K大小,差不多,整个Java堆已使用空间变化基本不变(5556K->4790K),因为对象都还存活;

在第一次GC完成后,正式为第三个对象分配内存空间,此时Eden区8M空间足够分配:

这里写图片描述

再然后allocation3 = null,a3对象置空后原来它在Eden区占据的4M空间变成可回收的状态:

这里写图片描述

最后allocation3 = new byte[4 * _1MB],重新为对象a3分配4M空间,好达到Eden的最大容量(此时默认已超,所有对象的内存和要小于容量,不包括等于),从而触发了第二次GC,原来对象a3置空前所分配的4M空间在置空后直接被回收,重新分配空间的对象a3到Eden区域,对象a1仍然存活将其复制到to区且年龄加1变成2岁,大于设置的-XX:MaxTenuringThreshold的值1,所以直接丢进老年代,内存分配如下:

最终内存分布

此次GC后,新生代是没有对象的,所以出现了5039K->0K的情况,整个Java出现了一个大幅度的下降9135K->4786K,下降的就是原本a3对象占用的那4M空间后来被回收了,最终的情况是Eden区由对象a3占用了4M空间(约为4096K),两个Survivor区为空,符合GC日志的描述eden space 8192K, 51% used; from space 1024K, 0% used; to space 1024K, 0% used,老年代占用(4+1/4)*1024=4352K,GC日志描述为tenured generation total 10240K, used 4867K,也基本符合。

 上述是-XX:MaxTenuringThreshold=1的情况,然后将其设置为15,在上述第二次GC时应该是对象a1占用的1/4M空间没有进入老年代,而是岁数变成了2岁(2<15),但是这一块我做的时候新生代使用的空间居然还是变成了0,就是我们这里设置的15是不能生效的,这里不用猜肯定是JDK不同版本造成的影响了,想要一样的结果要作者那个版本的JDK,这里我就不再去试了。

2.5.4 动态对象年龄的判定

 虚拟机为了更好的适应程序的状况,它并不要求对象必须达到MaxTenuringThreshold(对象年龄必须大于它)才能晋升老年代,如果Survivor中相同年龄的所有对象占据的总内存大于Survivor总容量的一半,那么年龄大于或等于上述对象的年龄的对象就可以直接进入老年代(包括这些对象本身也会直接被丢进老年代),不再要求它的年龄必须大于MaxTenuringThreshold,下面的是一个小栗子:

/*
* 虚拟机运行参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
*/
public class DynamicGC {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) {
        byte[] a1, a2, a3, a4;
        a1 = new byte[_1MB/4];
        a2 = new byte[_1MB/4];
        a3 = new byte[4*_1MB];
        a4 = new byte[4*_1MB];
        a4 = null;
        a4 = new byte[4*_1MB];
    }
}

上述程序我们自己分析下来最终Survivor区域最后是还有对象a1和a2的,不是为空,但是通过GC日志发现Survivor区竟然是0% used,就是因为对象a1和a2占据的空间为1/4 + 1/4 = 1/2M刚好是Survivor区域的容量(1M)的一半,所以这两个对象在它们刚进入from区的时候就直接被丢进了老年代而非Survivor区。当然,我们可以删除a1或者a2就可以不让它们被丢进老年代(我这个版本的JDK并不能产生上述的结果,但是我们可以从理论上分析出来)。

2.5.5 空间分配担保

 这是个很有意思的概念,首先搞清楚担保机制的担保人是指:老年代(Tenured Generation)。在Minor GC时,虚拟机会首先检查老年代Tenured Generation可用的最大连续空间是否大于新生代所有对象的总和,如果大于,那么此次的Minor GC就是安全的;如果不成立,虚拟机就会检查HandlePromotionFailure设置的值是否允许担保失败,如果允许失败,那么虚拟机会继续检查老年代最大可用连续空间是否大于历次晋升老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管此次的Minor GC存在风险,如果小于设置值或者HandlePromotionFailure参数设置的不允许冒险,那么此次的Minor GC就要改成Full GC。

 上述提到了如果HandlePromotionFailure设置的值是允许担保失败,虚拟机会检查老年代最大可用连续空间是否大于历次晋升老年代对象的平均大小(这里取平均值是一种动态手段,若某次Minor GC后存活的对象突增,即使老年代可用连续空间远远高于每次GC平均值,仍然会出现担保失败的情况),如果出了上述担保失败的情况,只能在失败后重新发起一次FULL GC,这就是风险。虽然担保失败需要扰一个圈子,但大部分情况还是会将HandlePromotionFailure开关打开(即允许担保失败),这样可以避免Full GC过于频繁(万一担保冒险成功了呢,对吧,就省去了一次Full GC,要知道Full GC的时间一般是Minor GC所花时间的十倍以上),下面是一个打开HandlePromotionFailure开关的小栗子:

/*
* 虚拟机运行参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:HandlePromotionFailure=false
*/
public class PromotionFailureTest {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) {
        byte[] a1, a2, a3, a4, a5, a6, a7;
        a1 = new byte[2*_1MB];
        a2 = new byte[2*_1MB];
        a3 = new byte[2*_1MB];
        a1 = null;
        a4 = new byte[2*_1MB];
        a5 = new byte[2*_1MB];
        a6 = new byte[2*_1MB];
        a4 = null;
        a5 = null;
        a6 = null;
        a7 = new byte[2*_1MB];
    }
}

可以将HandlePromotionFailure参数改成true看不同的GC情况,不过现在好像不再使用他了,默认是它开启即允许担保失败的情况再进行一次Full GC。
【注意】虚拟机之所以提供这么多的收集器就是因为没有固定收集器、参数组合、最优方式,需要根据实际应用需求、实现方式选择方案最佳的组合才能获得最好的效果。

三、虚拟机性能监控与故障处理工具

3.1 JDK命令行工具

 对比高低版本的JDK会发现,一般高版本的JDK的bin目录下项目都比低版本JDK该目录下项目要多。JDK在bin目录下,其实也提供了一些非常强大且稳定的工具作为礼物送给程序员,但是它声明了“没有技术支持且是实验性质的”(unsuppoted and experimental)产品,提供的工具主要如下:

名称作用
jpsJVM Process Status Tool,显示指定系统内所有的HotSpot虚拟机进程
jstatJVM Statistics Monitoring Tool,用于手机HotSpot虚拟机各方面的运行数据
jinfoConfiguration Info for java,显示虚拟机的配置信息
jmapMemory Map for java,生成虚拟机的内存转储快照(heapdump文件)
jhatJVM Heap Dump Browser,用于分析heapdump文件,它会建立一个HTTP/HTML服务器,让用户可以在浏览器上查看分析结果
jstackStack Trace for java,显示虚拟机的线程快照

应该有不少熟悉的字词吧(之前搞Hadoop的时候,配置成功标志就有上述的一些名词,不过不是很懂,还挺会玩儿(_))。

3.1.1 jps:虚拟机进程状况工具

 它可以列出正在运行的虚拟机进程,并显示虚拟机执行主类的名称以及这些进程的本地虚拟机唯一的ID(LVMID),很多其他JDK工具都需要用它来查询LVMID来确定要监控哪一个虚拟机进程,对于本地虚拟机进程来说,LVMID与操作系统的进程ID是一致的,使用windows任务管理器或者UNIX的ps命令也可以查询到LVMID,但是如果同时启动了多个虚拟机线程,无法根据进程的名字进行定位时,那就只能依赖jps命令显示主类的功能才能区分。

 看一下该命令的用法:jsp [options] [hostid],其中主要options如下:

选项作用
-q只输出LVMID,省略主类的名称
-m输出虚拟机进程启动时传递给主类main()函数的参数
-l输出主类的全名,如果进程执行的是jar包,输出jar的路径
-v输出虚拟机进程启动时JVM参数
3.1.2 jstat:虚拟机统计信息监视工具

 jstat(JVM Statistics Monitoring Tool)用于监视虚拟机各种运行状态信息的命令行工具,可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据,在没有GUI界面、只提供就纯文本控制台环境的服务器上,它是运行期定位虚拟机性能问题的首选工具。

 命令格式为:jstat [option vmid[interval[s|ms][count]]],其中VMID与LVMID是一致的,如果是远程虚拟机进程,那VMID的格式应该为:[protocol:][//]vmid[@hostname][:port]/servername],参数intervalcount表示查询间隔和次数,如果省略这两个参数,说明只查询一次。比如每250毫秒查询一次进程2764的垃圾收集状况,一共查询20次,命令为:jstat -gc 2764 250 20。option表示用户希望查询虚拟机信息,主要有3类:类装载、垃圾收集、运行期编译状况:

选项 | 作用
-class | 监视类装载、卸载数量、总空间以及类装载所耗费的时间
-gc | 监视Java堆状况,包括Eden、2个Survivor、老年代、永久代等信息
-gccapacity | 与-gc基本相同,但输出主要关注Java堆各个区域使用到的最大、最小空间
-gcutil | 与-gc基本相同,但输出主要关注使用空间占总空间的百分比
-gccause | 与-gcutil一样,但会额外输出导致上一次GC的原因
-gcnew | 监视新生代GC状况
-gcnewcapacity | 与-gcnew基本相同,但输出主要关注使用到的最大、最小空间
-gcold | 监视老年代的GC状况
-gcoldcapacity | 与-gcold基本相同,但输出主要关注使用到的最大、最小空间
-gcpermcapacity | 输出永久代使用到的最大、最小空间
-compiler | 输出即时编译器JIT编译过的方法、耗时等信息
-printcompilation | 输出已被JIT编译的方法

3.1.3 jinfo:java配置信息工具

 jinfo(Configuration Info fro java)的作用是实时查看和调整虚拟机各项参数,利用-v可以显式指定查看的参数,如果想知道未被显式指定的参数,只能使用-flag选项进行查询了,还可以使用-sysprops选项将虚拟机进程的System.getProperties()的内容打印出来,在windows平台下只提供了最基本的-flag选项,命令格式:jinfo [option] pid

3.1.4 jmap:Java内存映像工具

 jmap(Memory Map fro java)命令用于生成堆转储快照(一般称为heapdump或dump文件),如果不是这个命令,也可以用-XX:+HeapDumpOnOutOfMemoryError参数来让虚拟机在出现OOM异常出现后自动生成dump文件。jamp命令除了嗯可以获取dump文件,它还可以查询finalize队列、Java堆和永久代的详细信息,同样在windows平台下功能受限,除了生成dump文件选项-d和查看每个类的实例、空间占用统计的-histo选项外,其他的选项只能在Linux/Solaris下使用,命令格式:jamp [option] vmid,选项作用如下:

选项 | 作用
-dump | 生成Java堆转储快照,格式:-dump:[live, ]format=b, file=<filename>,其中live子参数说明是否只dump出存活的对象
-finalizerinfo | 显示在F-Queue中等待执行finalize方法的对象
-heap | 显示Java堆详细信息
-histo | 显示堆中对象统计信息
-permstat | 以ClassLoader为统计口径显示永久代内存状态
-F | 当虚拟机进程对-dump选项没有响应时,可使用该选项强制生成dump快照。

3.1.5 jhat:虚拟机堆转储快照分析工具

 jhat与jmap搭配使用来分析堆转储快照,jhat内置了微型HTTP/HTML服务器,生成dump文件的分析结果后可以在浏览器中查看。

3.1.6 jstack:java堆栈跟踪工具

 jstack命令用于生成当前时刻的线程快照(一般生成threaddump或者javacore文件),线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,它主要用来定位线程长时间停顿的原因(如线程间的死锁、死循环、请求外部资源导致的长时间等待等),在线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有相应的线程在后台到底在干嘛。格式:jstack [option] vmid,其中选项如下:

选项作用
-F正常输出的请求不被相应时,强制输出线程堆栈
-l除堆栈外,显示关于锁的附加信息
-m如果调用Native可以显示C/C++的堆栈信息
3.1.7 HSDIS:JIT生成代码反汇编

 HSDIS是官方推荐的HotSpot虚拟机JIT即时编译代码的反汇编插件,它包含在HotSpot的源码中,但没有提供编译好的代码,它的作用是让HotSpot的-XX:+PrintAssemby指令调用它把动态生成的本地代码还原为汇编代码输出,同时还可以生成大量的注释,可以方便问题的分析。

3.2 JDK可视化工具

 在JAVA_HOME的bin目录下,存在着jconsole.exe和jvisualvm.exe两款可视化工具,其中jconsole是虚拟机监控工具,而visualvm是官方主力推动的多合一故障处理工具。

3.2.1 JConsole:Java监视与监管平台

 JConsole(Java Monitor and Management Console)是一种基于JMX的可视化监视、管理工具,它里面的管理功能是针对JMX MBean进行管理的,由于MBean可以使用代码、中间件服务器的管理控制平台或者所有符合JMX规范的软件进行访问。

 在Java程序在指定虚拟机参数运行后,在Java的bin目录下打开JConsole选择本地连接(当程序运行后,JConsole在创建连接时会自动扫描JVM运行的进程,选择自己的测试代码即可进行监控),下面是一个监控案例的监控概览图:

这里写图片描述

运行代码如下:

/*
* 虚拟机运行参数:-Xms100m -Xmx100m -XX:+UseSerialGC
*/
public class JConsoleTest {
    static class OOMObject {
        public byte[] placeholder = new byte[64*1024];
    }
    public static void fillHeap(int num) throws InterruptedException {
        List<OOMObject> list = new ArrayList<>();
        for (int i = 0; i < num; i++) {
            Thread.sleep(50);
            list.add(new OOMObject());
        }
        //建议虚拟机开始垃圾回收
        System.gc();
    }

    public static void main(String[] args) throws Exception {
        fillHeap(1000);
    }
}

上面的图中一开始就概览图(包含了堆内存使用情况、线程活动情况、类加载情况、CPU占用情况),其实所谓的概览图在后面的几个菜单中都可以有详细的配图说明(后面有内存、线程、类、VM概要、MBean等5个菜单)。下面对上述的几个菜单做详细说明:

内存菜单:“内存”页面标签其实相当于可视化jstat命令,用于监视受收集器管理的虚拟机内存(Java堆和永久代)的变化趋势,比较直观方便。

线程菜单:“线程”页面相当于可视化的jstack命令,遇到线程停顿时可以使用这个页面进行监控分析,线程等待的主要原因有:等待外部资源(数据库连接、网络资源、设备资源等)、死循环、锁等待(活锁和死锁),通过代码来看一下使用:

public class ThreadMonitor {
    public static void createBusyThread() {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true);
            }
        }, "testBusyThread");
        thread.start();
    }

    /*
    线程锁等待演示
     */
    public static void createLockThread(final Object lock) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized(lock) {
                    try {
                        System.out.println("I am waiting.....");
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("I finshed waiting!");
                }
            }
        }, "testLockThread");
        thread.start();
    }

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        br.readLine();
        createBusyThread();
        br.readLine();
        Object obj = new Object();
        createLockThread(obj);
    }

}

启动后,使用JConsole连接到上述程序的端口,进入线程菜单如下:

这里写图片描述

左下方的线程栏罗列了当前程序中所有的线程,活动线程稳定在12,点击线程中的“main”,查看“main”线程的详细状态:

这里写图片描述

从上图中可以看到main线程此时处于运行状态(RUNNABLE),堆栈追踪显示BufferedReader在readBytes方法中等待输入;此时在控制台输入一行,触发了testBusyThread线程的创建,线程中多了一个名字为testBusyThread的线程,如下图:

这里写图片描述

活动线程数从12变成了13,点击查看一下这个testBusyThread的线程,堆栈追踪中看到代码的第15行停留(就是上述代码在IDEA中while(true)的位置),此时线程在空循环上,这种等待就比较消耗CPU资源。然后我们在控制台上再输入一行,触发创建testLockThread线程创建并运行:

这里写图片描述

此时testLockThread线程正处于正常的活锁等待,只要对象的notify()或者notifyAll()方法被调用,该线程就可以被继续执行。

下面是一个死锁的案例:

public class DeadThreadTest {
    static class SynAddRunable implements Runnable {
        int a, b;
        public SynAddRunable(int a, int b) {
            this.a = a;
            this.b = b;
        }

        @Override
        public void run() {
            synchronized (Integer.valueOf(a)) {
                synchronized (Integer.valueOf(b)) {
                    System.out.println(a+b);
                }
            }
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(new SynAddRunable(1, 2)).start();
            new Thread(new SynAddRunable(2, 1)).start();
        }
    }
}

上述代码必然会发生死锁,此时控制台的不再有输出,线程卡住,处于等待的情况,我们点击一下下面的“检测死锁”的按钮:

这里写图片描述

随便点击一个死锁线程,如下:

这里写图片描述

199线程装态显示:该线程正在等待139线程持有的java.lang.Integer@147e4f0对象,既然出现死锁,我们很容易就能想到139线程肯定也在等待199线程的一个对象,验证一下:

这里写图片描述

可惜不是,139线程在等126线程持有的java.lang.Integer@1e2ad75对象,那我们之前是错的吗,并不是,再来126线程状态:

这里写图片描述

我们发现126线程此时在等139线程持有的java.lang.Integer@147e4f0对象,有点类似与传递的关系,139线程持有的java.lang.Integer@147e4f0对象有199和126两个线程都在等,所以139阻止了2个线程的正常运行,所以出现卡住的现象。

3.2.2 VisualVM:多合一故障处理工具

 VisualVM(All-in-One Java Troublesshooting Tool)是比较强大的运行监视和故障处理程序,它可以做到:

  • 显示虚拟机进程和进程配置、环境信息(jps、jinfo);
  • 监视应用程序的CPU、GC、堆、方法区以及线程信息(jstat、jstack);
  • dump以及分析堆转储快照(jmap、jhat)
  • 方法级的程序运行性能分析、找出被调用最多、运行时间最长的方法。
  • 离线程序快照:收集程序的运行配置、线程dump、内存dump等信息建立一个快照,可以将快照发送开发者处进行Bug反馈

等许多的功能,下面具体看一下用法:

生成、浏览堆转储快照,使用该工具生成dump文件有两种方式:

  • 在“应用程序”窗口中右键应用程序节点,选择“堆Dump”;
  • 在“应用程序”窗口中双击应用程序打开应用程序标签,然后在“监视”标签中单继“堆Dump”;

具体的操作如下图所示:

这里写图片描述

在生成dump文件之后,应用程序页面将在该堆的应用程序下增加一个以[heapdump]开头的子节点,并且在主页面中打开该存储快照,如果想把dump文件保存或者发送出去,需要在该文件上右击选择“另存为”进行本地保存(我这里是保存为heapdump-1526551477689.hprof文件),否则当VisualVM关闭时,生成的dump文件会被当成临时文件清理掉。

这里写图片描述

如果要打开一个已经存在的dump文件,比如上述保存的文件,通过“文件”菜单–>装入–>选择刚刚保存的文件打开即可,下面打开后的主页面:

这里写图片描述

可以看到在摘要里面可以看到一些应用程序dump时的运行时参数、线程堆栈等信息,,“类”面板则是以类为统计口径统计类的实例数量、容量信息,“实例”面板不能直接使用,因为不确定用户想看哪一个类的实例,所以需要通过“类”面板进入,选择某个类后即可在“实例”中看到具体的属性。

关于dump文件打住,在打开应用程序后,“Profiler”菜单可以提供程序运行期间方法级的CPU执行时间分析以及内存分析,做Profiling分析肯定对程序运行性能有较大的影响,所以一般不在生产环境中使用该功能。

Btrace动态日志跟踪
 BTrace是该工具的一个插件,它可以在不停止目标程序运行的前提下,通过HotSpot虚拟机的HotSwap技术动态加入原本并不存在的调式代码,在需要调试的应用上右击选择"Trace Application…"菜单即可调式

四、Class文件

class文件是java编译之后可以被JVM执行的特殊格式的二进制文件,但是并不是仅仅局限于java这一种语言,其他语言也可以编译产生class文件,只要是class文件,那么JVM就可以执行,JVM并不关心class文件的来源。

class文件的头4个字节称为“魔数”,魔数用来确定该class文件是否能够被JVM接受,很多存储标准中都是用魔数来做身份识别,主要是基于安全方面的考虑,文件扩展名可以随便改动,而魔数则不能随意改动,文件的制定者可以自由选择,只要该魔数没有被广泛使用且不会引起混淆,Class文件的魔数为“0xCAFEBABE”;再后面的4个字节是class文件的版本号(第5、6字节是次版本号,第7、8字节是主版本号,java的版本是从45开始,即java1.1),JVM会拒绝超过其版本的class文件,随便打开一个之前编译产生后的class文件,使用winHex打开如下:

这里写图片描述

前4个字节为CAFEBABE就是class文件16进制的魔数,第5、6个字节为0000,第7、8个字节为0034(十进制为52,即主版本号),那么依次对应推算,我们可以知道这个class文件是JDK1.8编译出来的(45-1、46-2……52-8)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值