0%

jvm两万字详解

运行一个 Java 应用程序,必须要先安装 JDK 或者 JRE 包。因为 Java 应用在编译后会变成字节码,通过字节码运行在 JVM 中,而 JVM 是 JRE 的核心组成部分。JVM 不仅承担了 Java 字节码的分析和执行,同时也内置了自动内存分配管理机制。这个机制可以大大降低手动分配回收机制可能带来的内存泄露和内存溢出风险,使 Java 开发人员不需要关注每个对象的内存分配以及回收,从而更专注于业务本身。
在 Java 中,JVM 内存模型主要分为堆、方法区、程序计数器、虚拟机栈和本地方法栈。其中,堆和方法区被所有线程共享,虚拟机栈、本地方法栈、程序计数器是线程私有的。

程序计数器

每一个运行的线程都会有它的程序计数器(PC寄存器),与线程的生命周期一样。执行某个方法时,PC寄存器的内容总是下一条将被执行的地址,这个地址可以是一个本地指针,也可以是在方法字节码中相对于该方法起始指令的偏移量。如果该线程正在执行一个本地方法,那么此时PC寄存器的值是 undefined。
程序计数器是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。多线程环境下,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。

虚拟机栈

每当启动一个新的线程,虚拟机都会在虚拟机栈里为它分配一个线程栈,线程栈与线程同生共死。线程栈以 栈帧 为单位保存线程的运行状态,虚拟机只会对线程栈执行两种操作:以栈帧为单位的压栈或出栈。每个方法在执行的同时都会创建一个栈帧,每个方法从调用开始到结束,就对应着一个栈帧在线程栈中压栈和出栈的过程。方法可以通过两种方式结束,一种通过 return 正常返回,一种通过抛出异常而终止。方法返回后,虚拟机都会弹出当前栈帧然后释放掉。
当虚拟机调用一个Java方法时.它从对应类的类型信息中得到此方法的局部变量区和操作数栈的大小,并据此分配栈帧内存,然后压入Java栈中。
每个栈帧都包含了局部变量表、操作数栈、动态连接、方法出口和一些额外的附加信息

局部变量表

局部变量表是一组局部变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java文件编译为Class文件时,就在方法表的Code属性的max_locals数据项中确定了该方法需要分配的最大局部变量表的容量。
局部变量表是以变量槽slot为最小单位,为了尽可能的节省空间,slot是可以重用的,但是可能会影响到系统的垃圾收集行为。

操作数栈

操作数栈也常被称为操作栈,它是一个后入先出栈。JVM底层字节码指令集是基于栈类型的,所有的操作码都是对操作数栈上的数据进行操作,对于每一个方法的调用,JVM会建立一个操作数栈,以供计算使用。和局部变量一样。操作数栈的最大深度也是编译的时候写入到方法表的code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long、double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。栈容量的单位为“字宽”,对于32位虚拟机来说,一个“字宽”占4个字节,64位虚拟机来说,一个“字宽”占8个字节。当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指向操作数栈中写入和提取值,也就是入栈与出栈操作。例如,在做算术运算的时候就是通过操作数栈来进行的,又或者调用其它方法的时候是通过操作数栈来行参数传递的。 另外,在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但是大多数虚拟机的实现里都会作一些优化处理,令两个栈帧出现一部分重叠。让下栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用返回时就可以共用一部分数据,而无须进行额外的参数复制传递了。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接

方法返回地址

当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法方式称为**正常完成出口(Normal Method Invocation Completion)。另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)**。一个方法使用异常完成出口的方式退出,是不会给它的调用都产生任何返回值的。 无论采用何种方式退出,在方法退出之前,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。 方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用都栈帧的操作数栈中,调用PC计数器的值以指向方法调用指令后面的一条指令等。

附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与高度相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息。

本地方法栈

本地方法栈与虚拟机栈所发挥的作用是相似的,当线程调用Java方法时,会创建一个栈帧并压入虚拟机栈;而调用本地方法时,虚拟机会保持栈不变,不会压入新的栈帧,虚拟机只是简单的动态链接并直接调用指定的本地方法,使用的是某种本地方法栈。比如某个虚拟机实现的本地方法接口是使用C连接模型,那么它的本地方法栈就是C栈。
本地方法可以通过本地方法接口来访问虚拟机的运行时数据区,它可以做任何他想做的事情,本地方法不受虚拟机控制。

堆是 JVM 内存中最大的一块内存空间,该内存被所有线程共享,几乎所有对象和数组都被分配到了堆内存中。堆被划分为新生代和老年代,新生代又被进一步划分为 Eden 和 Survivor 区,最后 Survivor 由 From Survivor 和 To Survivor 组成。

但需要注意的是,这些区域的划分因不同的垃圾收集器而不同。大部分垃圾收集器都是基于分代收集理论设计的,就会采用这种分代模型。而一些新的垃圾收集器不采用分代设计,比如G1 收集器就是把堆内存拆分为多个大小相等的 Region,比如下图:

方法区

在 jdk8 之前,HotSopt 虚拟机的方法区又被称为永久代,由于永久代的设计容易导致内存溢出等问题,jdk8 之后就没有永久代了,取而代之的是元空间(MetaSpace)。元空间并没有处于堆内存上,而是直接占用的本地内存,因此元空间的最大大小受本地内存限制。
方法区与堆空间类似,是所有线程共享的。方法区主要是用来存放已被虚拟机加载的类型信息、常量、静态变量等数据。方法区是一个逻辑分区,包含元空间、运行时常量池、字符串常量池,元空间物理上使用的本地内存,运行时常量池和字符串常量池是在堆中开辟的一块特殊内存区域。这样做的好处之一是可以避免运行时动态生成的常量的复制迁移,可以直接使用堆中的引用。要注意的是,字符串常量池在 jvm 中只有一个,而运行时常量池是和类型数据绑定的,每个 Class 一个。

  • 类型信息(类或接口)
    • 这个类型的全限定名
    • 这个类型的直接超类的全限定名(只有 java.lang.Object 没有超类)
    • 这个类型的访问修饰符(public、abstract、final)
    • 这个类型是接口类型还是类类型
    • 任何直接超接口的的全限定名的有序列表
  • 运行时常量池
    • Class 文件被装载进虚拟机后,Class 常量池表中的字面量和符号引用都会存放到运行时常量池中,平时我们说的常量池一般指运行时常量池。
    • 运行时常量池相比Class常量池具备动态性,运行时可以将新的常量放入池中,比如调用 String.intern() 方法使字符串驻留。
    • 字段信息
    • 字段名
    • 字段的类型(包括 void)
    • 字段的修饰符(public、private、protected、static、final、volatile、transient)
  • 方法信息
    • 方法名
    • 方法的返回类型
    • 方法参数的数量和类型
    • 方法的修饰符(public、private、protected、static、final、synchronized、native、abstract)
    • 方法的字节码
    • 操作数栈和该方法的栈帧中的局部变量的大小
    • 异常表
  • 指向类加载器的引用
    • jvm 使用类加载器来加载一个类,这个类加载器是和这个类型绑定的,因此会在类型信息中存储这个类加载器的引用
  • 指向 Class 类的引用
    • 每一个被加载的类型,jvm 都会在堆中创建一个 java.lang.Class 的实例,类型信息中会存储 Class 实例的引用
    • 在代码中,可以使用 Class 实例访问方法区保存的信息,如类加载器、类名、接口等

常量池

  • 方法区:运行时常量池,就是当你的class文件一旦编译后,你的class常量池就是确定了的,而运行时常量池在运行期间也可能有新的常量放入池中(如String类的intern()方法)
  • Class文件:常量池,.java文件经过编译后生成.class文件,常量池可以理解为class文件的资源仓库。
    堆:String常量池

类加载

加载

通过一个类的全限定名来获取其定义的二进制字节流,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,在堆中生成一个代表这个类的class对象,作为方法区这些数据的入口。

验证

保证class文件是否安全,格式是否正确,
(1) 文件格式验证:验证字节流是否符合class文件的格式的规范,并且能够被当前版本的虚拟机处理。、
(2) 元数据验证:对字节码描述的信息进行语义分析,保证其描述的信息符合java语言规范的要求。比如这个类是否有父类、这个类的父类是否继承了不允许继承的类..
(3) 字节码验证:对数据流跟控制流进行分析,确定程序语义是合法并且符合逻辑的,主要是针对方法体的验证,如:方法中的类型转换是否正确等,跳转指令是否正确等。
(4) 符号引用验证:这个动作在后面的解析过程中发生,主要是为了确保解析动作能正确执行。

准备

为类的静态变量(即 static 修饰的变量)分配内存并设置默认值

解析

虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。
理解下符号引用和直接应用:

  • 符号引用:java 文件在编译期间,class 文件并不知道它引用的那些类、方法、字段的具体地址,不能被class文件中的字节码直接引用。因此使用符号引用来代替,运行时再动态连接到具体引用上。符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
  • 直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。在运行时,Java虚拟机从常量池获得符号引用,然后在运行时解析引用项的实际地址。

初始化

直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,初始化阶段就是执行类构造器 方法的过程。
方法

  • 方法是由编译器自动收集类中的所有类变量的赋值语句和静态代码块合并产生的,代码执行的顺序就是源文件中的顺序。
  • Java虚拟机会保证在子类的 方法执行前,父类的 方法会先执行完毕,即先初始化直接超类。
  • 方法对于类或接口来说不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 方法。
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 方法。
  • 执行接口的 方法不需要先执行父接口的 方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的 方法。
  • Java虚拟机会保证一个类的 方法在多线程环境中被正确地加锁同步, 一定是线程安全的。

双亲委派模型

Java 1.8 之前采用三层类加载器、双亲委派的类加载架构。三层类加载器包括启动类加载器、扩展类加载器、应用程序类加载器。

三层类加载器

  • 启动类加载器(Bootstrap ClassLoader):负责将 $JAVA_HOME/lib 或者 -Xbootclasspath 参数指定路径下面的文件(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载) 加载到虚拟机内存中。它用来加载 Java 的核心库,是用原生代码实现的,并不继承自 java.lang.ClassLoader,启动类加载器无法直接被 java 代码引用。
  • 扩展类加载器(Extension ClassLoader):负责加载 $JAVA_HOME/lib/ext 目录中的文件,或者 java.ext.dirs 系统变量所指定的路径的类库,它用来加载 Java 的扩展库。
  • 应用程序类加载器(Application ClassLoader):一般是系统的默认加载器,也称为系统类加载器,它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般 Java 应用的类都是由它来完成加载的,可以通过 ClassLoader.getSystemClassLoader() 来获取它。

    双亲委派模型

    除了启动类加载器之外,所有的类加载器都有一个父类加载器。应用程序类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是启动类加载器。一般来说,开发人员自定义的类加载器的父类加载器一般是应用程序类加载器。

先要明白,Java 虚拟机判定两个 Java 类是否相同,不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两个类来源于同一个Class文件,并且被同一个类加载器加载,这两个类才相等。不同类加载器加载的类之间是不兼容的。
双亲委派模型就是为了保证 Java 核心库的类型安全的。所有 Java 应用都至少需要引用 java.lang.Object 类,也就是说在运行的时候,java.lang.Object 这个类需要被加载到 Java 虚拟机中。如果这个加载过程由 Java 应用自己的类加载器来完成或者自己定义了一个 java.lang.Object 类的话,很可能就存在多个版本的 java.lang.Object 类,而这些类之间是不兼容的。通过双亲委派模型,对于 Java 核心库的类加载工作由引导类加载器来统一完成,保证了 Java 应用所使用的都是同一个版本的 Java 核心库的类,是互相兼容的。有了双亲委派模型,就算自己定义了一个 java.lang.Object 类,也不会被加载。

ClassLoader

类加载器之间的父子关系一般不是以继承的关系来实现的,通常是使用组合、委托关系来复用父加载器的代码。ClassLoader 中有一个 parent 属性来表示父类加载器,如果 parent 为 null,就会调用本地方法直接使用启动类加载器来加载类。类加载器在成功加载某个类之后,会把得到的 java.lang.Class 类的实例缓存起来。下次再请求加载该类的时候,类加载器会直接使用缓存的类的实例,而不会尝试再次加载。

对象的内存布局

在 HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

对象头

对象头主要由两部分组成:Mark Word 类型指针,如果是数组对象,还会包含一个数组长度

  • Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。synchronized 锁升级就依赖锁标志、偏向线程等锁信息,垃圾回收新生代对象转移到老年代则依赖于GC分代年龄。
  • 类型指针:对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。
  • 数组长度:有了数组长度,虚拟机就可以通过普通Java对象的元数据信息确定Java对象的大小,如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

这三部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特。64 位虚拟机中,为 了节约内存可以使用选项 +UseCompressedOops 开启指针压缩,某些数据会由 64位压缩至32位。

实例数据

实例数据部分是对象真正存储的有效信息,即对象的各个字段数据,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

对齐填充

对齐填充仅仅起着占位符的作用,由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是 8字节 的整数倍,就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被设计成正好是8字节的倍数,因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

计算对象占用内存大小

从上面的内容可以看出,一个对象对内存的占用主要分两部分:对象头和实例数据。在64位机器上,对象头中的 Mark Word 和类型指针各占 64 比特,就是16字节。实例数据部分,可以根据类型来判断,如 int 占 4 个字节,long 占 8 个字节,字符串中文占3个字节、数字或字母占1个字节来计算,就大概能计算出一个对象占用的内存大小。当然,如果是数组、Map、List 之类的对象,就会占用更多的内存。

对象访问定位

创建对象后,这个引用变量会压入栈中,即一个 reference,它是一个指向对象的引用,这个引用定位的方式主要有两种:使用句柄访问对象和直接指针访问对象。

通过句柄访问对象

使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。
使用句柄来访问的最大好处就是 reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。

通过直接指针访问对象
如果使用直接指针访问的话,Java堆中对象的内存布局就必须放置访问类型数据的相关信息(Mark Word 中记录了类型指针),reference 中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,HotSpot 虚拟机主要就是使用这种方式进行对象访问。
就HotSpot而言,他使用的是直接指针访问方式进行对象访问

GC

最大年龄为15,因为Object header采用4bit来保存年龄,最大值为15

Java的引用

强引用(StrongReference)

强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。  ps:强引用其实也就是我们平时A a = new A()这个意思。

软引用(SoftReference)

如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存(下文给出示例)。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。

弱引用(WeakReference)

弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。

弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

虚引用(PhantomReference)

“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。

虚引用主要用来跟踪对象被垃圾回收器回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。

识别垃圾方法

引用计数法

对每个对象的引用进行计数,每当有一个地方引用它时计数器 +1、引用失效则 -1,引用的计数放到对象头中,大于 0 的对象被认为是存活对象。虽然循环引用的问题可通过 Recycler 算法解决,但是在多线程环境下,引用计数变更也要进行昂贵的同步操作,性能较低,早期的编程语言会采用此算法。

可达性分析

又称引用链法(Tracing GC): 从 GC Root 开始进行对象搜索,可以被搜索到的对象即为可达对象,此时还不足以判断对象是否存活/死亡,需要经过多次标记才能更加准确地确定,整个连通图之外的对象便可以作为垃圾被回收掉。目前 Java 中主流的虚拟机均采用此算法。
GC Root 包括:
1、虚拟机栈中引用的对象
2、方法区中类静态属性引用的对象
3、方法区中常量引用的对象
4、本地方法栈中JNI(一般说的Native方法)引用的对象

在可达性分析中不可达的对象里,它们会暂时处于一个缓刑的过程,要真正宣布一个对象的死亡,至少要再次经历标记过程,标记的前提是不可达对象。
第一次标记并进行筛选,当对象没有覆盖finalize方法或者已经被虚拟机执行过了,则该对象被回收
第二次标记,进行此过程的对象会被放到一个F-Queue的队列里,并由虚拟机建立的低优先级的Finalizer线程去执行相应的finalize方法,但不会承诺等待它运行结束,防止执行过程中一些问题导致F-Queque其他对象永久处于等待状态。稍后GC将对F-Queque中的对象进行第二次小规模标记,如果对象在finalize重新与引用链上任意对象建立关联,则逃脱被回收的命运。

垃圾收集算法

Mark-Sweep(标记-清除)

回收过程主要分为两个阶段,第一阶段为追踪(Tracing)阶段,即从 GC Root 开始遍历对象图,并标记(Mark)所遇到的每个对象,第二阶段为清除(Sweep)阶段,即回收器检查堆中每一个对象,并将所有未被标记的对象进行回收,整个过程不会发生对象移动。整个算法在不同的实现中会使用三色抽象(Tricolour Abstraction)、位图标记(BitMap)等技术来提高算法的效率,存活对象较多时较高效。

Mark-Compact (标记-整理)

 这个算法的主要目的就是解决在非移动式回收器中都会存在的碎片化问题,也分为两个阶段,第一阶段与 Mark-Sweep 类似,第二阶段则会对存活对象按照整理顺序(Compaction Order)进行整理。主要实现有双指针(Two-Finger)回收算法、滑动回收(Lisp2)算法和引线整理(Threaded Compaction)算法等。

Copying(复制)

将空间分为两个大小相同的 From 和 To 两个半区,同一时间只会使用其中一个,每次进行回收时将一个半区的存活对象通过复制的方式转移到另一个半区。有递归(Robert R. Fenichel 和 Jerome C. Yochelson提出)和迭代(Cheney 提出)算法,以及解决了前两者递归栈、缓存行等问题的近似优先搜索算法。复制算法可以通过碰撞指针的方式进行快速地分配内存,但是也存在着空间利用率不高的缺点,另外就是存活对象比较大时复制的成本比较高。

垃圾收集器

分代收集理论

大部分虚拟机的垃圾回收器都是遵循分代收集的理论进行设计的,它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般至少将堆划分为新生代和老年代两个区域,然后可以根据不同代的特点采取最适合的回收算法。在新生代中,每次垃圾回收时都有大量对象死去,因为程序创建的绝大部分对象的生命周期都很短,朝生夕灭。而新生代每次回收后存活的少量对象,将会逐步晋升到老年代中存放。老年代每次垃圾收集时只有少量对象需要被回收,因为老年代的大部分对象一般都是全局变量引用的,生命周期一般都比较长。
在Java堆划分出不同的区域之后,垃圾回收器就可以每次只回收其中某一个或者某些部分的区域,因而也有了“Young GC”、“Old GC”、“Full GC”这样的回收类型的划分。也能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾回收算法,因而发展出了“标记-复制算法”、“标记-清除算法”、“标记-整理算法”等针对性的垃圾回收算法。
GC类型:

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集,包括新生代、老年代、方法区的回收,一般 Full GC 等价于 Old GC。

Serial

Serial 垃圾回收器是一个单线程回收器,它进行垃圾回收时,必须暂停其他所有用户线程,直到它回收结束。Serial 主要用于新生代垃圾回收,采用复制算法实现。
服务端程序几乎不会使用 Serial 回收器,服务端程序一般会分配较大的内存,可能几个G,如果使用 Serial 回收器,由于是单线程,标记、清理阶段就会花费很长的时间,就会导致系统较长时间的停顿。
Serial 一般用在客户端程序或占用内存较小的微服务,因为客户端程序一般分配的内存都比较小,可能几十兆或一两百兆,回收时的停顿时间是完全可以接受的。而且 Serial 是所有回收器里额外消耗内存最小的,也没有线程切换的开销,非常简单高效。

Serial old

Serial Old 是 Serial 的老年代版本,它同样是一个单线程回收器,主要用于客户端程序。Serial Old 用于老年代垃圾回收,采用标记-整理算法实现。
Serial Old 也可以用在服务端程序,主要有两种用途:一种是与 Parallel Scavenge 回收器搭配使用,另外一种就是作为 CMS 回收器发生失败时的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

Parnew

ParNew 回收器实质上是 Serial 回收器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为都与 Serial 回收完全一致,控制参数、回收算法、对象分配规则等都是一致的。除了 Serial 回收器外,目前只有 ParNew 回收器能与 CMS 回收器配合工作,ParNew 是激活CMS后的默认新生代回收器。
ParNew 默认开启的回收线程数与处理器核心数量相同,在处理器核心非常多的环境中,可以使用 **-XX: ParallelGCThreads **参数来限制垃圾回收的线程数。

Parallel Scanvenge

Parallel Scavenge 是新生代回收器,采用复制算法实现,也是能够并行回收的多线程回收器。Parallel Scavenge 主要关注可控制的吞吐量,其它回收器的关注点是尽可能地缩短垃圾回收时的停顿时间。吞吐量就是处理器用于运行程序代码的时间与处理器总消耗时间的比值,总消耗时间等于运行程序代码的时间加上垃圾回收的时间。
Parallel Scavenge 提供了两个参数用于精确控制吞吐量:

  • -XX: MaxGCPauseMillis:控制最大垃圾回收停顿时间,参数值是一个大于 0 的毫秒数,回收器将尽力保证垃圾回收花费的时间不超过这个值。
  • -XX: GCTimeRatio:直接设置吞吐量大小,参数值是一个大于 0 小于 100 的整数,就是垃圾回收时间占总时间的比率。默认值为 99,即允许最大1%(即1/(1+99))的垃圾收集时间。

Parallel Scavenge 还有一个参数 -XX: +UseAdaptiveSizePolicy,当设置这个参数之后,就不需要人工指定新生代的大小、Eden与Survivor区的比例等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

Parallel Old

Parallel Old 是 Parallel Scavenge 的老年代版本,支持多线程并发回收,采用标记-整理算法实现。在注重吞吐量或者处理器资源较为稀缺的场合,可以优先考虑 Parallel Scavenge 加 Parallel Old 这个组合。

Cms

concurrent mark sweep是一种以获取最短回收停顿时间为目标的收集器。
CMS 垃圾回收总体分为四个步骤:

  • 1)初始标记(会STW):初始标记需要 Stop The World,初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快。
  • 2)并发标记:并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象引用链的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾回收线程一起并发运行。
  • 3)重新标记(会STW):重新标记需要 Stop The World,重新标记阶段是为了修正并发标记期间,因程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  • 4)并发清除:清除阶段是清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发进行的。

最耗时的并发标记和并发清除阶段是和用户线程并发进行的,总体上来说,CMS 回收过程是与用户线程一起并发执行的,是一款并发低停顿的回收器。

触发cms的时机

CMS GC 在实现上分成 **foreground collector **和 background collector

foreground collector

foreground collector 触发条件比较简单,一般是遇到对象分配但空间不够,就会直接触发 GC,来立即进行空间回收。采用的算法是 mark sweep,不压缩。

background collector

background collector 是通过 CMS 后台线程不断的去扫描,过程中主要是判断是否符合 background collector 的触发条件,一旦有符合的情况,就会进行一次 background 的 collect。每次扫描过程中,先等 CMSWaitDuration 时间(默认2秒),然后再判断是否满足 background collector 的触发条件。
background collector 的触发条件:

  • 并行 Full GC,如调用了 System.gc()
  • 未配置 UseCMSInitiatingOccupancyOnly 时,会根据统计数据动态判断是否需要进行一次 CMS GC。如果预测 CMS GC 完成所需要的时间大于预计的老年代将要填满的时间,则进行 GC。这些判断是需要基于历史的 CMS GC 统计指标,第一次 CMS GC 时,统计数据还没有形成,是无效的,这时会跟据 Old Gen 的使用占比来判断是否要进行 GC。
  • 未配置 UseCMSInitiatingOccupancyOnly 时,判断 CMS 的使用率大于 CMSBootstrapOccupancy(默认50%)时触发 Old GC。
  • 老年代内存使用率阀值超过 CMSInitiatingOccupancyFraction(默认为92%)时触发 OldGC,CMSInitiatingOccupancyFraction 默认值为 -1,没有配置时默认阀值为 92%。
  • 未配置 UseCMSInitiatingOccupancyOnly 时,因为分配对象时内存不足导致的扩容等触发GC
在没有配置 UseCMSInitiatingOccupancyOnly 参数的情况下,会多出很多种触发可能,一般在生产环境会配置 UseCMSInitiatingOccupancyOnly 参数,配了之后就不用设置 CMSBootstrapOccupancy 参数了。

CMSInitiatingOccupancyFraction 设置得太高将会很容易导致频繁的并发失败,性能反而降低;太低又可能频繁触发CMS background collector,一般在生产环境中应根据实际应用情况来权衡设置。
-XX:+UseConcMarkSweepGC 使用CMS
-XX:CMSInitiatingOccupancyFraction=92 老年代内存使用率阀值超过指定比例时触发OldG
-XX:+UseCMSInitiatingOccupancyOnly 指定被使用的内存空间的阈值,达到该阈值则触发OldGC
-XX:CMSBootstrapOccupancy=92 没配置上面那个参数时,老年代使用达到比较阈值触发OldGC
-xx:CMSWaitDuration=2000 每次扫描等待的毫秒时间

CMS导致的问题

1、并发回收导致CPU资源紧张
在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低程序总吞吐量。CMS默认启动的回收线程数是:(CPU核数 + 3)/ 4,当CPU核数不足四个时,CMS对用户程序的影响就可能变得很大。
2、无法清理浮动垃圾
在CMS的并发标记和并发清理阶段,用户线程还在继续运行,就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留到下一次垃圾收集时再清理掉。这一部分垃圾称为“浮动垃圾”。
3、并发失败(Concurrent Mode Failure)
由于在垃圾回收阶段用户线程还在并发运行,那就还需要预留足够的内存空间提供给用户线程使用,因此CMS不能像其他回收器那样等到老年代几乎完全被填满了再进行回收,必须预留一部分空间供并发回收时的程序运行使用。默认情况下,当老年代使用了 92% 的空间后就会触发 CMS 垃圾回收,这个值可以通过 -XX: CMSInitiatingOccupancyFraction 参数来设置。
这里会有一个风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure) ,这时候虚拟机将不得不启动后备预案:Stop The World,**临时启用 Serial Old **来重新进行老年代的垃圾回收,这样一来停顿时间就很长了。
4、内存碎片问题
CMS是一款基于“标记-清除”算法实现的回收器,这意味着回收结束时会有内存碎片产生。内存碎片过多时,将会给大对象分配带来麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况。
为了解决这个问题,CMS收集器提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认开启),用于在 Full GC 时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,是无法并发的,这样停顿时间就会变长。还有另外一个参数 -XX:CMSFullGCsBeforeCompaction,这个参数的作用是要求CMS在执行过若干次不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为0,表示每次进入 Full GC 时都进行碎片整理)。

G1

G1 (Garbage First) 回收器采用面向局部收集的设计思路和基于Region的内存布局形式,是一款主要面向服务端应用的垃圾回收器。G1设计初衷就是替换 CMS,成为一种全功能收集器。G1 在JDK9 之后成为服务端模式下的默认垃圾回收器,取代了 Parallel Scavenge 加 Parallel Old 的默认组合,而 CMS 被声明为不推荐使用的垃圾回收器。G1从整体来看是基于 标记-整理 算法实现的回收器,但从局部(两个Region之间)上看又是基于 标记-复制 算法实现的。
G1 可以指定垃圾回收的停顿时间,通过 **-XX: MaxGCPauseMillis **参数指定,默认为 200 毫秒。这个值不宜设置过低,否则会导致每次回收只占堆内存很小的一部分,回收器的回收速度逐渐赶不上对象分配速度,导致垃圾慢慢堆积,最终占满堆内存导致 Full GC 反而降低性能。
G1之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次回收到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾回收。G1会去跟踪各个Region的垃圾回收价值,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的回收停顿时间,优先处理回收价值收益最大的那些 Region。这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 回收器在有限的时间内得到尽可能高的回收效率。
由于 Region 数量比传统回收器的分代数量明显要多得多,因此G1回收器要比其他的传统垃圾回收器有着更高的内存占用负担。G1至少要耗费大约相当于Java堆容量 10%至20% 的额外内存来维持回收器工作。

G1不再是固定大小以及固定数量的分代区域划分,而是把堆划分为多个大小相等的Region,每个Region的大小默认情况下是堆内存大小除以2048,因为JVM最多可以有2048个Region,而且每个Region的大小必须是2的N次冥。每个Region的大小也可以通过参数 -XX:G1HeapRegionSize 设定,取值范围为1MB~32MB,且应为2的N次幂。

G1 回收器的运作过程大致可分为四个步骤:

  • 初始标记(会STW):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理在并发时有引用变动的对象。
  • 最终标记(会STW):对用户线程做短暂的暂停,处理并发阶段结束后仍有引用变动的对象。
  • 清理阶段(会STW):更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条回收器线程并行完成的。

G1有一个参数,**-XX:InitiatingHeapOccupancyPercent,它的默认值是 45%,就是如果老年代占堆内存 45% 的 Region 的时候,此时就会触发一次年轻代+老年代的混合回收
混合回收阶段,因为我们设定了最大停顿时间,所以 G1 会从新生代、老年代、大对象里挑选一些 Region,保证指定的时间内回收尽可能多的垃圾。所以 G1 可能一次无法将所有Region回收完,它就会执行多次混合回收,先停止程序,执行一次混合回收回收掉一些Region,接着恢复系统运行,然后再次停止系统运行,再执行一次混合回收回收掉一些Region。可以通过参数 -XX:G1MixedGCCountTarget 设置一次回收的过程中,最后一个阶段最多执行几次混合回收,默认值是8次。通过这种反复回收的方式,避免系统长时间的停顿。
G1还有一个参数
-XX:G1HeapWastePercent,默认值是 5%。就是在混合回收时,Region回收后,就会不断的有新的Region空出来,一旦空闲出来的Region数量超过堆内存的5%,就会立即停止混合回收,即本次混合回收就结束了。
G1还有一个参数
-XX:G1MixedGCLiveThresholdPercent,默认值是85%。意思是回收Region的时候,必须存活对象低于Region大小的85%时才可以进行回收,一个Region存活对象超过85%,就不必回收它了**,因为要复制大部分存活对象到别的Region,这个成本是比较高的。

Zgc

分为Mark(标记)、Relocate(迁移)、Remap(重映射)三个阶段

  • Mark: 所有活的对象都被记录在对应Page的Livemap(活对象表,bitmap实现)中,以及对象的Reference(引用)都改成已标记(Marked0或Marked1)状态
  • Relocate: 根据页面中活对象占用的大小选出的一组Page,将其中中的活对象都复制到新的Page, 并在额外的forward table(转移表)中记录对象原地址和新地址对应关系
  • Remap: 所有Relocated的活对象的引用都重新指向了新的正确的地址

新生代晋升条件

1、Java 默认启用了分代 GC
2、启用分代 GC 的,在发生 Young GC,更准确地说是在 Survivor 区复制的时候,存活的对象的分代年龄会加1。
3、当分代年龄 = -XX:MaxTenuringThreshold 指定的大小时,对象进入老年代
4、还有动态晋升到老年代的机制,首先根据 -XX:TargetSurvivorRatio (默认 50,也就是 50%) 指定的比例,乘以 survivor 一个区的大小,得出目标晋升空间大小。然后将分代对象大小,按照分代年龄从小到大相加,直到大于目标晋升空间大小。之后,将得出的这个分代年龄以上的对象全部晋升。
对于一些的GC 算法,还可能直接在老年代上面分配,例如 G1 GC 中的 humongous allocations(大对象分配),就是对象在超过 Region 一半大小的时候,直接在老年代的连续空间分配。

GC触发条件

当 JVM 无法为一个新的对象分配空间时会触发 Young GC。
虚拟机在进行Young GC之前会判断老年代最大的可用连续空间是否大于新生代的所有对象总空间。
1、如果大于的话,直接执行Young GC
2、如果小于,判断是否开启HandlerPromotionFailure,没有开启直接FullGC
3、如果开启了HanlerPromotionFailure, JVM会判断老年代的最大连续内存空间是否大于历次晋升的大小,如果小于直接执行FullGC
4、如果大于的话,执行Young GC

Full GC触发条件

Full GC定义是相对明确的,就是针对整个新生代、老生代、元空间(metaspace,java8以上版本取代perm gen)的全局范围的GC;
1、调用System.gc()时,系统建议执行Full GC,但是不必然执行
2、老年代空间不足
3、方法区空间不足
4、通过Minor GC后进入老年代的平均大小大于老年代的可用内存
由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
System.gc()这个东西提醒的是Full GC,不是Minor GC。

GC调优

合理分配年轻代内存

假设一个秒杀下单接口,估计一次成功下单创建的对象总和是30KB,那每秒30个下单的话就是900KB,半小时就是1.5GB左右,我们可以考虑增大新生代内存,同时使用内存大一点的机器,比如使用4核8G,那么JVM分4G,给堆空间分配3G,新生代给1.5G,老年代给1.5G,Eden 区差不多1.2G,Survivor区150M,这个时候Eden区差不多要半个小时才会占满,然后触发一次YoungGC,而其中99%都是垃圾对象,采用标记-复制算法基本上很能就能完成YoungGC,这就大大降低了YoungGC的频率。
如果业务量更大,还可以考虑横向多部署几台机器,这样分到每台机器的请求就更少了,压力也更小。

为了防止Young GC时复制对象到survivor,但因为它太小频繁把对象移到老年代,所以survivor区设置大一些。

优化gc年龄阈值

在5分钟左右就Young GC的情况下,可以通过** -XX:MaxTenuringThreshold=5** 参数降低年龄阀值,比如设置为 5,这样的话可减少那些长时间存活的比如bean对象直接进入老年代,不用在新生代来回复制。

优化大对象阈值

还有一种情况就是大对象将直接进入老年代,大对象阀值一般设置1M就够了,一般来说很少有一个对象超过1M的。如果我们确定系统中会频繁创建生命周期短的大对象,我们可以适当调大这个阀值,避免其进入老年代。
可以通过参数** -XX:PretenureSizeThreshold=1M** 来设置大对象阀值。

选择合适的gc收集器

如果关注的是可控制的吞吐量,则选择 paraller scanvenge + paraller old,如果是关注的是程序停顿时间(在高并发时)可选择 parnew + cms。

Jvm一些参数

-Xms525m,初始堆大小,默认为物理内存的1/64
-Xmx525m,最大堆大小,默认为物理内存的1/4
-Xmn252m,新生代的堆大小,通常为Xmx的1/3\1/4,
-Xss,每个线程堆栈大小,一般为1M
-XX:NewRatio:新生代与老年代的比例,如–XX:NewRatio=2,则新生代占整个堆空间的1/3,老年代占2/3
-XX:SurvivroRatio:新生代中Eden 与 Survivor 的比值。默认值为 8。即 Eden 占新生代空间的 8/10,另外两个 Survivor 各占 1/10
-XX:NewSize:新生代初始大小
-XX:MaxNewSize:新生代最大大小
-XX:PermSize:方法区的初始大小
-XX:MaxPermSize:方法区的最大大小
-XX:+PrintGCDetails:打印GC信息,java8已弃用,改成 -Xlog:gc*
-XX:HeapDumpOnOutOfMemoryError 虚拟机在发生内存溢出时Dump 出当前的内存堆转储快照,以便分析用
-XX:HeapDumpPath=/home/liuke/jvmlogs/ 生成堆文件地址
-XX:UserSerialGC
-XX:MaxTenuringThreshold=size:进入老年代的年龄

jvm参数设置模板

对于一般的系统,我们可能使用4核8G的机器来部署,那么总结一套模板如下:

  • 堆内存分配4G,新生代3G,老年代1G,Eden区2.4G,Survivor区各300M,一般来说YoungGC后存活的对象小于150M就没太大问题
  • 元空间给个 512M 一般就足够了,如果系统会运行时创建很多类,可以调大这个值
  • -XX:MaxTenuringThreshold 对象GC年龄调整为5岁,让长期存活的对象更快的进入老年代
  • -XX:PretenureSizeThreshold 大对象阀值设置为1M,如果有超过1M的大对象,可以调整下这个值
  • -XX:+UseParNewGC、-XX:+UseConcMarkSweepGC,垃圾回收器使用 ParNew + CMS 的组合
  • -XX:CMSFullGCsBeforeCompaction 设置为0,cms每次FullGC后都进行一次内存碎片整理
  • -XX:+CMSParallelInitialMarkEnabled,CMS初始标记阶段开启多线程并发执行,降低FullGC的时间
  • -XX:+CMSScavengeBeforeRemark,CMS重新标记阶段之前,先尽量执行一次Young GC
  • -XX:+DisableExplicitGC,禁止显示手动GC
  • -XX:+HeapDumpOnOutOfMemoryError,OOM时导出堆快照便于分析问题
  • -XX:+PrintGC,打印GC日志便于出问题时分析问题

Jvm工具

jps

1、查看Java进程PID
命令:jps -l
左边一列就是Java进程的PID。

2、输出传递给JVM的参数
命令:jps -vl

jstat

参数列表 jstat xxx pid

  • -class:显示 ClassLoad 的相关信息;
  • -compiler:显示 JIT 编译的相关信息;
  • -gc:显示和 gc 相关的堆信息;
  • -gccapacity:显示各个代的容量以及使用情况;
  • -gcmetacapacity:显示 Metaspace 的大小;
  • -gcnew:显示新生代信息;
  • -gcnewcapacity:显示新生代大小和使用情况;
  • -gcold:显示老年代和永久代的信息;
  • -gcoldcapacity:显示老年代的大小;
  • -gcutil:显示垃圾收集信息;
  • -gccause:显示垃圾回收的相关信息(同 -gcutil),同时显示最后一次或当前正在发生的垃圾回收的诱因;
  • -printcompilation:输出 JIT 编译的方法信息

其中** jstat -gc **是最完整、最常用、最实用的命令,基本足够分析jvm的运行情况了。

jmap

使用 jmap 可查看堆内存初始化配置信息以及堆内存的使用情况,输出堆内存中的对象信息,包括产生了哪些对象,对象数量多少等。

1、查看堆内存情况
命令:jmap -heap
这个命令会打印出堆内存相关的一些参数设置以及各个区域的情况,要查看这些信息一般使用 jstat 命令就足够了。
2、查看系统运行时对象分布
命令:jmap -histo[:live] ,带上 live 则只统计活对象
这个命令会按照各种对象占用内存空间的大小降序排列,把占用内存最多的对象放在最上面。通过这个命令可以简单的了解下当前jvm中的对象对内存占用的情况以及当前内存里到底是哪个对象占用了大量的内存空间。
生成堆内存转储快照
命令:jmap -dump:format=b,file=
命令:jmap -dump:live,format=b,file=
jmap -dump 是输出堆中所有对象;jmap -dump:live 是输出堆中所有活着的对象,而且 jmap -dump:live 会触发 FullGC,线上使用要注意。format=b 是以二进制格式输出;file 是文件路径,格式为 hrpof 后缀。
这个命令会在当前目录下生成一个 dump.hrpof 文件,这是个二进制的格式,无法直接打开,可以使用MAT等工具来分析。这个命令把这一时刻VM堆内存里所有对象的快照放到文件里去了,供你后续去分析。

jstack

jstack 是一种线程堆栈分析工具,最常用的功能就是使用 jstack pid 命令查看线程的堆栈信息,通常会结合 top -Hp pid 或 pidstat -p pid -t 一起查看具体线程的状态,也经常用来排查一些死锁的异常、CPU占用高的线程等。

top

top 命令是我们在 Linux 下最常用的命令之一,它可以实时显示正在执行进程的 CPU 使用率、内存使用率以及系统负载等信息。其中上半部分显示的是系统的统计信息,下半部分显示的是进程的使用率统计信息。

看第一行:主要展示了CPU的负载情况

  • 02:37:55 :指的是当前时间
  • up 4 min, 12:18:指的是机器已经运行了多长时间
  • 1 user:当前机器有一个用户在使用
  • load average: 0.02, 0.10, 0.05:指 CPU 在1分钟、5分钟、15分钟内的负载情况。
  • Tasks:328 total 表示当前系统的进程总数。
  • 2 running 表示当前系统中有 2 个正在运行的进程。
  • 250 sleeping 表示当前系统中有 250个休眠的进程。
  • 0 stopped 表示停止状态的进程数为 0。
  • 0 zombie 表示处于僵死状态的进程数为 0。
  • us,进程在用户地址空间中消耗 CPU 时间的百分比。像 shell程序、各种语言的编译器、数据库应用、web 服务器和各种桌面应用都算是运行在用户地址空间的进程。这些程序如果不是处于 idle 状态,那么绝大多数的 CPU 时间都是运行在用户态。
  • sy,进程在内核地址空间中消耗 CPU 时间的百分比。所有进程要使用的系统资源都是由 Linux 内核处理的。当处于用户态(用户地址空间)的进程需要使用系统的资源时,比如需要分配一些内存、或是执行 IO 操作、再或者是去创建一个子进程,此时就会进入内核态(内核地址空间)运行。事实上,决定进程在下一时刻是否会被运行的进程调度程序就运行在内核态。对于操作系统的设计来说,消耗在内核态的时间应该是越少越好。在实践中有一类典型的情况会使 sy 变大,那就是大量的 IO 操作,因此在调查 IO 相关的问题时需要着重关注它。
  • ni,ni 是 nice 的缩写,可以通过 nice 值调整进程用户态的优先级。这里显示的 ni 表示调整过 nice 值的进程消耗掉的 CPU 时间。如果系统中没有进程被调整过 nice 值,那么 ni 就显示为 0。
  • id,CPU 处于 idle 状态的百分比。一般情况下, us + ni + id 应该接近 100%。
  • wa,CPU 等待磁盘 IO 操作的时间。和 CPU 的处理速度相比,磁盘 IO 操作是非常慢的。有很多这样的操作,比如:CPU 在启动一个磁盘读写操作后,需要等待磁盘读写操作的结果。在磁盘读写操作完成前,CPU 只能处于空闲状态。Linux 系统在计算系统平均负载时会把 CPU 等待 IO 操作的时间也计算进去,所以在我们看到系统平均负载过高时,可以通过 wa 来判断系统的性能瓶颈是不是过多的 IO 操作造成的。
  • hi & si,这两个值表示系统处理中断消耗的时间。中断分为硬中断和软中断,hi 表示处理硬中断消耗的时间,si 表示处理软中断消耗的时间。硬中断是硬盘、网卡等硬件设备发送给 CPU 的中断消息,当 CPU 收到中断消息后需要进行适当的处理(消耗 CPU 时间)。软中断是由程序发出的中断,最终也会执行相应的处理程序(消耗 CPU 时间)。
  • st,只有 Linux 在作为虚拟机运行时 st 才是有意义的。它表示虚机等待 CPU 资源的时间(虚机分到的是虚拟 CPU,当需要真实的 CPU 时,可能真实的 CPU 正在运行其它虚机的任务,所以需要等待)。

最重要的就是看** load average**,比如机器是4核CPU,那么0.02, 0.10, 0.05,说明4核中连一个核都没用满,4核CPU基本很空闲。如果CPU负载是1,说明有1个核被使用的比较繁忙了。如果负载是4,说明4核CPU都跑满了;如果超过4,说明4核CPU被繁忙的使用还不够处理当前的任务,很多进程可能一直在等待CPU去执行自己的任务。

pidstat

如果是监视某个应用的上下文切换,可以使用 pidstat 命令监控指定进程的上下文切换。
pidstat 是 Sysstat 中的一个组件,也是一款功能强大的性能监测工具,我们可以通过命令:yum install sysstat 安装该监控组件。top 和 vmstat 两个命令都是监测进程的内存、CPU 以及 I/O 使用情况,而 pidstat 命令则是深入到线程级别。
查看所有进程的 CPU 使用情况
命令:pidstat -u -p ALL

arthas工具

Arthas 是一款开源在线 Java 诊断工具,采用命令行交互模式,支持 web 端在线诊断,同时提供丰富的 Tab 自动补全功能,进一步方便进行问题的定位和诊断。得益于 Arthas 强大且丰富的功能,让 Arthas 能做的事情超乎想象。
它可以帮你解决这些问题:

  1. 这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception?
  2. 我改的代码为什么没有执行到?难道是我没 commit?分支搞错了?
  3. 遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?
  4. 线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!
  5. 是否有一个全局视角来查看系统的运行状况?
  6. 有什么办法可以监控到 JVM 的实时运行状态?
  7. 怎么快速定位应用的热点,生成火焰图?

    运行原理

命令

1、sc 和 sm
通过sc可以查看已加载类的相关信息,比如该类是从哪个jar包加载的,被哪个类加载器加载的,以及是否是接口等等。
sm查看已加载类的方法详情。
2、dashboard
进入当前系统的实时数据面板,按 ctrl+c 退出。这个面板会实时刷新,其中包括线程信息、内存信息、gc信息、还有一些运行时的数据。
另外,当运行在Ali-tomcat时,会显示当前tomcat的实时信息,如HTTP请求的qps, rt, 错误数, 线程池信息等等。

3、thread
通过thread命令可以查看当前jvm进程的线程详情。可以查看线程的cpu使用时间占比,通过指定各种参数可以找出最忙的几个线程,以及阻塞其他线程的线程。具体如何使用这里不多做介绍,大家可以去看arthas的官方文档。

4、jvm
通过jvm命令直接输出当前jvm的各种信息。
5、getstatic
通过getstatic命令可以方便的查看类的静态属性。

6、sysprop和sysenv
通过sysprop可以查看所有的系统变量,也可以设置某个系统变量。
同理,通过sysenv可以查看所有的操作系统环境变量,也可以查看设置某个环境变量。

7、jad
有时我们经常会不确定线上或者测试环境的包是否是我们修改过的,这时候就可以通过jad反编译来看下。

8、watch
让你能方便的观察到指定方法的调用情况。能观察到的范围为:返回值、抛出异常、入参,通过编写 OGNL 表达式进行对应变量的查看。
watch的使用姿势比较丰富,可以在四个不同的场景观察方法的执行。比如方法调用之前、方法调用之后、方法异常之后、方法结束之后。默认观察的是方法结束之后。
如果观察的是方法结束之后的场景,由于入参可能在执行方法时被改变,所以此时输出的可能不是真正的入参。因此,要看真正的入参,要看方法调用之前的,也就是加上-b的参数。
另外,使用-b参数观察的话,则观察不到方法返回的结果以及抛出的异常了。
9、monitor
monitor命令可以监控方法的执行情况。比如调用成功次数,失败次数,失败率、平均执行时间等等。默认120秒输出一次,也就是说,当我们输入monitor命令之后,每120秒就会输出一次统计结果。

10、trace
方法内部调用路径,并输出方法路径上的每个节点上耗时,tt命令会记录每次方法调用的各种信息。它和watch有些相似但是它能记录下各个时间点的调用信息,之后随时查看,甚至replay这次调用。

OOM的排查思路

根据理论基础进行分析, 然后一步步排查。
1、首先我们知道oom 的原因: 内存泄漏和内存溢出,其中内存泄漏的堆积是会导致内存溢出的,所以我们要解决的问题主要是为啥导致内存泄漏了。
a、内存泄漏: 对象分配了内存, 在方法调用结束之后没有进行回收,直接进入了老年代中。
b、内存溢出: 我们的内存容量不够。
2、通过 jstat -gc 查看我们的gc 次数, 可以粗略的查看到我们的系统gc情况,一般频繁gc都是gg了。
3、通过 jmap -heap 可以查看到我们的堆内存使用情况,看看有没有哪个对象占了不可思议的大小。
4、通过 jmap -dump:format=b,file= ,或者在系统启动时加上 -XX:HeapDumpOnOutOfMemoryError 命令来保存 oom 时产生的堆栈信息,通过 eclise的MAT 工具来进行分析内存使用情况。
5、确定问题后,查看代码,优化代码,或者是如果是没啥问题,那就加内存,或者是调整堆的配置。