JVM 自动内存管理

Java 内存区域(运行时数据区域)

了解 Java 的内存区域主要是为了在出现 OOM 问题时快速定位与解决问题,运行时数据区包含以下几个方面
在这里插入图片描述

虚拟机栈

每个线程都会有一个虚拟机栈、一个程序计数器、一个本地方法栈,因为 Java 的虚拟机大都和操作系统 CPU 一一对应,这线程就对应 CPU 的核心

所有的 Java 方法调用都是通过栈来实现的

在调用方法时,一个栈帧入栈,方法结束,栈帧出栈

对象可以在栈上分配内存,这种方式只能未出现逃逸时使用(对象只在方法内使用),编译器经过逃逸分析结果,可以将代码优化。逃逸分析如栈上分配、同步省略(锁消除)、标量替换(将一个对象替换为组成它的多个标量)

HotSpot 虚拟机无法修改栈的大小,不会出现 OOM

栈帧的组成

1,局部变量表:是一个数字数组,在编译时已经确定大小,基本单位是局部变量槽,它的作用是存放方法中编译期可知的局部变量、方法入参的值、以及对象引用,在生成该表之后,其大小不会改变(指的是槽的数目不会改变,因为不同的虚拟机实现槽的大小有不同的方式,有的用32位,有的用64位)

this:值得注意的是,局部变量表中的第一个存放的对象引用,就是该方法的实例对象,这也是为什么可以从方法内可以找到对象属性

在局部变量表里,32位以内的类型只占用一个 slot(比如 short、int 类型包括 returnAddress 类型(指向了一条字节码指令的地址)),64位的类型(long 和 double)占两个 slot

关于局部变量表的入参,如果入参是八大数据类型,会直接存放在栈帧中,毕竟这些数据类型不大,因此方法内该数据的变化不会影响到全局,如果是一个对象,则放在栈中的是对象的引用 reference,对对象的修改会影响到全局

可重用:如果当前线程计数器的值已经超过局部变量表中某个值的作用域的时候(已经执行完某个方法),那么这块区域就是可重用的,可以重新赋值。但是,这块表中的区域不会立马被回收,只有在下次访问局部变量表的该区域的时候,才会将这部分内容清理掉,也就是惰性删除。最直接的例子就是,如果在某个代码块结束之后立马进行 gc,此时在代码块中定义的变量不会被回收掉

2,操作数栈:数组实现,在编译时已经确定大小,作为方法调用的中转站使用,用于存放该方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量、方法返回值、方法的参数也会放在操作数栈中

每个指令在执行时会明确指定需要从操作数栈中取出多少个操作数,并将计算结果再次压入操作数栈中

数据共享:虚拟机一般对该区域进行优化,将某个栈帧中的操作数栈与其他栈帧中的局部变量表形成一个重叠区域,以提高空间利用率
在这里插入图片描述

3,动态链接:它是指向运行时常量池中该栈帧所属方法的引用,它的作用是通过引用来找到方法的具体位置(方法区中),方法区中有一块运行时常量池,里面存放着所有属性与方法的引用

因为有些被调用的目标方法在编译期无法被确定下来到底是调用什么方法,只能够在程序运行期来根据方法的符号引用找到直接引用,这种引用转换的过程具备动态性,称为分派

由于符号引用是文字形式表示的引用关系,我们在运行时需要找到这个方法的本体。直接引用就是通过对符号引用进行解析,来获得真正的函数入口地址,也就是在运行的内存区域找到该方法字节码的起始位置

4,方法返回地址:存放了调用此方法的程序计数器的值。当一个方法开始执行后,只有两种方式退出这个方法,一种是 return,一种是遇到异常,无论那种,我们都要恢复栈中调用方法的运行状态

因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等

5,附加信息:虚拟机可以将一些规范里没有描述的信息添加进栈帧中

解析和分派(如何找到方法执行入口)

在方法调用的时候,我们如何确定方法的版本呢?方法可被重载也可被重写,虚拟机是如何分清他们的呢?

事实上,所有的方法在 class 文件中都存放在方法表中,他们的目录(符号引用)存放在常量池中,我们在读取 class 问题时,会将一部分的方法的符号引用转换成直接引用,另外一部分则还是根据符号引用在运行时去查询方法入口

符号引用转换成直接引用这部分方法调用,被称为解析。只有满足以下两个条件之一的方法才可以走解析调用流程:一是私有方法,二是静态方法,这两个方法的特点是他们不可能通过继承或者其他手段重写出其他版本。这两种方法也称为非虚方法。其他的方法叫做虚方法,需要走分派流程

静态分派简单一些,指在编译期根据方法接收者的静态类型(静态类型是在编译期可知的类型)来决定调用哪个重载方法的过程静态类型就是下图中的 Object,而实际类型则是 String。如果我们的代码中没有对应类型的方法,编译器还可以直接做转换
在这里插入图片描述

那重写又是这么实现的呢?动态分派指在运行时根据对象的实际类型来确定调用哪个方法的过程。比如 hashmap 与 treemap 都有 put 方法,虚拟机怎么知道我们要使用 hashmap 中的 put 还是 AbstractMap 中的 put 方法呢?

    private void justTest() {
        Map<Integer, Integer> hashMap = new HashMap<>();
        Map<Integer, Integer> treeMap = new TreeMap<>();
    }

事实上在字节码生成的时候调用重写方法时会生成 invokevirtual 指令,该指令会干以下这些事

  • 找到操作数栈顶的第一个元素所指向的对象的实际类型(这个是方法入参),记作 C
  • 如果在类型 C 中找到与常量中的描述符和简单名称相符合的方法,然后进行访问权限验证,如果验证通过则返回这个方法的直接引用,查找过程结束;如果验证不通过,则抛出 java.lang.IllegalAccessError 异常
  • 否则未找到,就按照继承关系从下往上依次对类型 C 的各个父类进行第2步的搜索和验证过程
  • 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常

这个过程被称为动态分派,java 的动态分派是一种单分派机制(宗量只有调用方法的类型一种),而 java 的静态分派则是多分派机制(宗量考虑到了调用方法的类型和入参类型),虚拟机在执行这个指令的时候不会去一个个反复去搜索类型元数据,会使用一些优化手段,就是编译时在方法区生成一个虚方法表,表中存放着各个方法的实际入口地址

总结一下:动态分派侧重于方法调用者,用来实现重写,静态分配侧重于方法入参,用来实现重载

栈可能会引发的问题

1,StackOverFlowError:有的栈不能自动增长栈,比如 HotSpot 实现的虚拟机栈,如果调用过多方法,就会导致栈超限

2,OutOfMemoryError:栈可以动态增长,但是在调用过多方法同时申请不到内存时,会 OOM

同时,如果为栈分配过多空间,使用多线程时创建了很多栈也会出现 OOM 的问题,当不能减少线程数目时,可以减少栈的大小或者堆的大小

基于栈的解释执行

先说一下虚拟机生成机器指令的发生,有两种:

1,解释执行,虚拟机读取 class 文件,读取后在运行时遇到方法时一遍读方法的指令一遍生成机器指令给电脑,电脑再去执行

2,编译执行,将一段程序直接翻译成机器码(对于 C/C++ 这种非跨平台的语言)或者中间码(Java 这种跨平台语言,需要 JVM 再将字节码编译成机器码)。编译执行是直接将所有语句都编译成了机器语言,并且保存成可执行的机器码。执行的时候,是直接进行执行机器语言,不需要再进行解释/编译。比如前端编译器与 JIT

而解释执行一般有两种方式:

1,基于栈的解释执行:通过一个栈暂存需要计算的中间数,这种方式的特点是实现比较偏软件,偏算法。从理论上来说,这种方式比较慢一点,并且实现相同的功能会比基于寄存器的解释执行要多,现在计算机硬件都是寄存器就证明了这一点。优点则是由于偏算法实现,因此移植性比较强,这里特指 java 虚拟机

2,基于寄存器的解释执行:通过一个栈存放指令、中间数等。快、指令少,一般需要带参数。同时因为寄存器由硬件直接提供,不可避免的需要受到硬件的约束。这里特指操作系统

本地方法栈

使用 native 调用的语句,为了让 java 运行非 java 语言的程序,在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一(有些其他的虚拟机可能不会这么实现)。其他与虚拟机栈一模一样

因为执行的不是字节码指令,这种编译执行的方式不需要使用程序计数器

与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出 StackOverflowError 和 OutOfMemoryError 异常

程序计数器

是线程私有的,唯一一个不会出现 OutOfMemoryError 的内存区域(因为它不会创建新的对象,里面不会存放一些杂七杂八的东西),它唯一的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,如果看过 class 文件中的常量表,应该会很好理解

它的主要作用有:

1,当前线程所执行的字节码的行号指示器,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
2,在上下文切换时,线程切换后能恢复到正确的执行位置。一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器

如果现在执行的是本地方法,该计数器的值应该为空

GC(垃圾回收)的主要区域,又称 GC 堆

它的作用就是储存对象实例、字符串常量池(java8 之后)、static 变量。《java 虚拟机规范》中说,所有的对象实例以及数组都应该被分配到堆上。在今天看来,这句话不是绝对的(栈上分配)

堆在物理上可以是连续空间,也可以不连续。堆的大小可以固定,也可以在运行时按需扩展。对于一些大对象而言(比如数组),多数虚拟机为了方便,会要求存放在一个连续空间中

字符串常量池中保存的是字符串对象的引用,字符串对象的引用指向堆中的字符串对象

分区

堆的分区是为了方便回收内存而设计的,由于现代垃圾收集器大部分都是基于分代收集理论设计的,因此这些区域只不过是垃圾收集器的设计风格而已,而非虚拟机必须实现的内存布局

以下是分代收集理论:

新生代:又分伊甸园区和幸存者 from 区、幸存者 to 区

  • 伊甸园区:对象新建时存放的区域
  • 幸存者区:对象 GC 后没有被清除,加入这里,并且年龄加1,to 区总是空的

老年代:一般来说,对象15(这个数字可以设置)次 GC 没有被回收掉之后,进入老年区

它们的空间划分大概是这样的
在这里插入图片描述

在 JDK1.7 之前还有一个永久代,JDK8 版本之后永久代已被 Metaspace 元空间取代

内存分配机制

对象不一定非要在新生代分配,甚至不用在堆上分配。对象的分配有以下的规则

  • 优先在伊甸园区分配,当空间不足时会发生一次 minor GC(只对新生代进行 GC),如果 GC 后空间还是不足,多出来的对象只能放到老年代去了
  • 大对象直接放到老年代,比如数组或者长字符串什么的,这么做的目的是为了避免这些大对象在执行复制算法的时候产生大量性能消耗,以及创建和删除东西时的性能损耗
  • 活得久的进入老年代:在幸存者区小于或者等于某一年龄的对象大于幸存者区空间的一半,大于这个年龄的对象就可以直接进入老年代
  • 空间分配担保机制,就是说如果老年代的最大连续内存空间大小大于新生代对象总和或者历次平均晋升空间大小,就会进行 minor GC(因为可能所有的新时代对象 GC 后都进入老年代)否则进行 full GC,担保失败不会产生任何坏处,这么做的目的只是为了避免频繁进行 full GC

方法区

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,方法区到底要如何实现那就是虚拟机自己要考虑的事情了。也就是说,在不同的虚拟机实现上,方法区的实现是不同的

它存储已被虚拟机加载的类信息、域信息(类中属性)、方法信息、存放编译期生成的各种字面量(Literal)和符号引用的运行时常量池、JIT 缓存代码块、常量、静态变量、虚方法表等。因为这些东西非常重要,是程序运行的根本,并且在程序运行的时候基本不可能新建

元空间与永久区都是方法区的实现,HotSpot 以前使用在主存中的永久区,现在改为放在本地内存里的元空间。这里的本地内存不是线程独享的那个本地内存,而是 JVM 数据区外的本地内存
在这里插入图片描述

原因是堆中内存分配后无法修改,更容易触碰到最大内存上限,而本地内存中的空间与硬件配置有关,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小

元空间不可以被 GC,但是可以由 Java 虚拟机自身负责管理和释放内存空间,主要释放内存对象是常量池、热点代码等

之前的永久区会触发 OOM,原因可能是加载了大量的 jar 包或者 tomcat 部署了很多工程。元空间一般不会,因为它会自动调节大小

运行时常量池这个重量级的东西也在方法区中。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表,用于存放编译期生成的各种字面量和符号引用(指向方法、属性等的引用),同时也会把翻译出来的直接引用也储存在这个池子中,这部分内容将在类加载后存放到方法区的运行时常量池中

运行时常量池还有一个特点是具有动态性,在运行时能将新的常量放入池中,比如说经常出现的 Integer 类型

直接内存(堆外内存)

直接内存就是不在虚拟机进程使用的内存,也叫堆外内存,不属于虚拟机运行时数据区的一部分,但是现在被频繁的使用,同时也容易出现 OOM

NIO、Netty 等技术使用到了直接内存,原理是操作系统把数据从内核态复制到用户态才能使用,直接操作堆外内存避免了赋值消耗

NIO 中的 FileChannel.map 方法其实就是采用了操作系统中的内存映射方式,底层就是调用 Linux mmap 实现的。将内核缓冲区的内存和用户缓冲区的内存做了一个地址映射。这种方式适合读取大文件,同时也能对文件内容进行更改

Java NIO 中提供的 FileChannel 拥有 transferTo 和 transferFrom 两个方法,可直接把 FileChannel 中的数据拷贝到另外一个 Channel,或者直接把另外一个 Channel 中的数据拷贝到 FileChannel,底层调用 sendfile。该接口常被用于高效的网络/文件的数据传输和大文件拷贝

为什么会出现 OOM?比如给虚拟机设置的内存过大,把堆外内存的空间抢走了,就会出现 OOM

对象的生命周期

对象的创建

在虚拟机层面如何创建一个对象呢?

类加载检查

首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程

划分内存区域

在类加载完成后,我们已经知道这个对象需要多少内存空间了,可以在堆中划分一块内存区域出来给这个对象

划分方式

1,碰撞指针:如果使用标记压缩算法,内存是完整的,将指针向后移动

2,空闲链表:内存空间不完整,使用链表来记录内存空间,找到可以足够存放的地方,并更新链表

这两种划分区域的方法灵感来自操作系统的内存分配策略

并发问题

在开发的过程中会频繁的创建对象,如果两个线程同时划分了一块内存区域就会发生对象信息被胡乱糅合的情况,JVM 会严格的控制内存划分。在这种情况下要加锁处理,如此一来就会造成分配效率下降。如果每个线程有独有的空间的话,就可以避免这种开销,因此,JVM 分配空间的流程如下:

1,在新生区(伊甸园区)为每个线程划分一个的空间,空间未满时存放在这些空间里,这些空间叫 TLAB(线程私有的缓冲区)
2,TLAB 其实时很小的一块,那肯定就会面临 TLAB 中剩余内存比申请对象小的问题。JVM 是这样处理的,它有一个比例值,当 TLAB 中空间小于该比例值的时候,会放弃当前的 TLAB 重建一个新的来分配
3,如果私有空间满了,为了安全应该上锁并分配内存,虚拟机使用 CAS(乐观锁的一种实现)加上重试的方式来分配内存。而且太大的对象是不会使用到 TLAB 的,因为塞不下

乐观锁:希望操作完成后没有并发问题,如果有就重做或撤销操作

虽然每个线程在初始化时都会去堆内存中申请一块 TLAB,并不是说这个 TLAB 区域的内存其他线程就完全无法访问了,其他线程的读取还是可以的,只不过无法在这个区域中分配内存而已

并且,在 TLAB 分配之后,并不影响对象的移动和回收,也就是说,虽然对象刚开始可能通过 TLAB 分配内存,存放在 Eden 区,但是还是会被垃圾回收或者被移到 Survivor Space、Old Gen 等

初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)

这一步保证了对象不赋值就可以被访问到,这个过程其实非常有用,比如在 Spring 中循环依赖的时候

设置对象头

对象头主要包括三类信息:MarkWord、Klass Pointer(类型指针)、数组的长度(只有数组里有)

MarkWord 中可能包括锁信息、hashcode(不同对象可以有相同的 code)、分代年龄等。MarkWord 可以动态变化的,虚拟机读不同对象的 MarkWord 有不同的方式,类似与 Linux 中的 stat、mysql 里的页头,用一个相对较少的数据块,去管理一个相对较多的整体数据

Mark Word 在 32 位 JVM 中的长度是 32bit,在 64 位 JVM 中长度是 64bit,同时,Mark Word 在不同的锁状态下存储的内容不同。以下是一个 64 位 JVM 的 Mark Word
在这里插入图片描述

类型指针是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

jdk8 版本默认开启指针压缩的,在压缩之前类型指针占 8 字节,压缩之前只占 4 字节

另外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中无法确定数组的大小

初始化

初始化就是执行初始化 init 方法,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,还有很多方法没有执行(比如构造方法,在初始化之后才会执行)

1,在 new 对象之后,构造方法和方法块之前,虚拟机会为实例变量赋上类型初始值,也就是变量初始化过程
2,方法执行顺序为:父类初始化 -> 变量赋值 -> 自身的代码块 -> 自身的构造方法(构造方法在初始化步骤中不执行)

    // 这个想要在类实例化的时候进行赋值
    private String nike = "nike";

    // 构造方法
    public InitClassStu(String name) {
        this.nike = name;
    }

	// 静态方法
    static {
        nike = "1";
    }
    
    // 实例"{}" 在生成<init>构造器时优先于构造函数
    {
        nike = "1";
    }

图中的静态代码块只在类加载时执行一次
在这里插入图片描述

3,代码块与变量赋值都是由编译器按照从上到下的顺序执行的,执行完代码块和变量赋值后,才会进入构造方法

对象的内存布局

  • 对象头:运行时数据加类型指针,MarkWord 占8字节,类型指针占4字节,一共12字节
  • 实例数据:包括此对象中有多少数据类型以及类型指针,如果对象有属性字段,则这里会有数据信息。如果对象无属性字段,则这里就不会有数据。根据字段类型的不同占不同的字节,例如 boolean 类型占1个字节,int类型占4个字节等等
  • 对齐填充:虚拟机规定对象必须是 8 字节的倍数(对象头已经被设计成 8 字节的倍数)如果一个对象不满 8 字节,就必须使用对齐填充。如果没有达到则自动填充

为什么需要对齐填充呢?因为 64 位系统一次获取 64 位数据,32 位系统一次读取 32 位数据。对齐填充的目的是让字段只出现在同一 CPU 的缓存行中,缓存行为 8 字节。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。当缓存行被拆分的时候,我们寻找下一个对象就会变得比较困难

对象和类的查找

对象放在堆中,类的信息放在方法区里,那么这两块数据是如何拼在一起的呢

1,使用句柄:栈帧中的引用存放的是句柄地址,堆中会有专门存放句柄的区域,句柄中存放对象地址(放在堆)和类型地址(放在方法区)
在这里插入图片描述

使用句柄的方式多使用了内存做储存

2,直接指针:引用指向堆中对象地址,对象头中放置访问类型数据的相关信息,目前 java 采用这种方式

使用直接指针的话,在垃圾回收对象位置发生改变的时候,会有很多的引用修改,不像句柄一样只需要改一处地方就可以了。但是使用直接指针访问速度更快,不用像句柄一样二次查找

判断对象是否死亡

  • 哪些对象需要回收?
  • 什么时候回收?
  • 怎么回收?

1,引用计数法:在对象中添加一个引用计数器,使用一次该对象引用记录器加1,当引用失效,计数器减1,如果该对象引用计数器为0则通知回收器进行回收,缺点是如果对象相互调用,就不能判断

那何为引用失效?比如 Integer a = new XXX,然后将这个 a 置为 null,此时这个对象的引用就断掉了,根据这个思路可以引申出更好的算法

2,可达性分析算法:设置一些对象为 GC Roots,GC Roots 引用的对象与它们引用的对象称为可达对象,其他对象为不可达对象,不可达对象会被干掉,目前 java 采用这种方式

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

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象(static)
  • 方法区中常量引用的对象(final)
  • 所有被同步锁持有的对象

那对象有没有复活甲呢?有!但对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要放入终结队列。否则对象会在这个队列中会进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收

GC(对象的回收)

GC 回收算法

在上古时期的垃圾收集器遵循一个重要的理论,分代收集理论:根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法

这个理论主要建立在强分代假说与弱分代假说上,强即熬过越多次收集的对象就越难消亡,弱即大多数对象都是朝生夕死的

因此,收集器给出了一个思路,即分区域储存与回收

1,复制收集算法:将区域分为两部分,对存活的对象进行标记,之后复制到另一边,清空原来的空间。优点是没有内存碎片,如果垃圾比较多时运行高效,缺点是需要两倍的内存空间

2,标记回收算法:首先根据算法把所有存活的对象标记出来,将使用的标记出来,第二次,遍历整个堆对象将未标记的对象回收。因为需要遍历整个堆所以效率比较低,并且回收后会产生内存碎片。而这里的”回收“是指把未标记的内存区域记录在一个空闲链表中,事实上在进行分配时对老对象进行覆盖

3,标记压缩算法:同上,并将回收后的区域进行整理,是得碎片空间减少,相比于复制算法,内存利用率比较高。从效率上来说,比回收算法低,并且移动对象时会 STW

还有一种奇怪的解决方案,比如先不进行压缩,等待内存碎片影响到对象分配的时候才进行压缩,CMS 就是采用这种方法

GC 分类

主要分为两大类:

Partial GC:收集部分

  • Young GC(Minor GC):伊甸园区满了会发生(幸存者区不会触发Young GC),收集整个新生区
  • Old GC(Major GC):老年代满了会发生,只收集老年代,只有 CMS 使用
  • Mixed GC:对整个新生代和部分老年代进行垃圾收集,只有 G1 使用

Full GC:收集整个堆,老年代、永久代空间不足时会触发(比如新生代的晋升对象大于老年代剩余容量的时候)、或者调用 System.gc() 时会触发(System.gc 方法事实上调用了 Runtime.getRuntime.gc 方法,并且不是百分百会进行 gc)

如果你听过 major GC,那它通常是指 Full GC,但是你一定要问他指的是 Full GC 还是 Old GC,因为现在大家的理解挺五花八门的

GC 收集器

当所有线程到达全局安全点(在这个时间点上没有正在执行的字节码)或者安全区域的时候,GC 线程主动式中断并开始工作,jdk 虽然有自己默认的收集器,但是我们需要具备在不同情况下使用合适的 GC 收集器的能力

GC 主要注重两个性能指标,一个是吞吐量(吞吐量越大越好,注重吞吐量代表高效利用 CPU 处理能力,一般是在后台运算并且不需要和用户交互的程序需要提高吞吐量,吞吐量是用户线程执行时间/程序执行时间,高吞吐量意味着每次垃圾收集的时间会比较长),一个是暂停时间(越短越好,代表用户的体验,用户可能不会在意10次20ms的暂停,但是一定会注意一次200ms的暂停)

1,Serial:应用程序停止(STW"Stop The World"),GC单线程执行,使用复制算法,优点是只有单核时性能好、开销小
2,Serial Old:收集老年代,使用压缩算法

3,ParNew:应用程序停止,GC 线程并行执行,使用复制算法
4,CMS:重视停顿时间,并行执行,使用清除算法,达到阈值会触发,如果 GC 失败有后续方案(Serial Old)

5,PS(Parallel Scavenge):重视吞吐量,使用复制算法,和 ParNew 差不多,但是使用的底层框架不一样
6,Parallel Old:收集老年代,使用压缩算法,在jdk8中,默认使用这两个组合

7,G1:注重低延迟下提高 CPU 性能的收集器,一般用在大容量内存的服务端,同时收集新生代和老年代,整体整理算法实现,局部复制算法实现

8,Shenandoah:Openjdk 中的新生代 GC
9,ZGC:实验性的性能较好的官方 GC

在选择垃圾收集器的时候,应该考虑主机系统,Java 版本,程序主要关注点是什么,具体情况具体分析

同时,在处理虚拟机内存问题的时候还需要会阅读虚拟机的日志,在 java9 之前读取日志的参数比较混乱,不过现在只要知道处理内存问题的时候知道要去找日志就行了

CMS 的过程

CMS 是收集老年代的数据的,CMS 的核心思想就是增量收集算法,指收集垃圾和用户程序交互进行

1,STW,标记与 GC Roots 直接相连的对象,过程很快
2,开启其他线程与 GC 线程,找到所有有连接的对象
3,STW,由于上一步其他线程的执行,无法找到所有的对象,现在重新标记,由此修正浮动垃圾
4,开启其他线程,并且进行回收

这里强调一下步骤三,因为步骤二是和其他的线程并发执行的,因此有可能在某个对象被标记之后,业务线程重新引用该对象,如果此时直接清理垃圾,会造成严重后果(被引用对象置为 null)。因此我们得重新扫一遍,这里面也有实现细节,我们当然不可能全部都扫一遍

垃圾收集器依据可达性分析算法判断对象是否存活时,将遍历 GC Roots 过程中遇到的对象,按照是否访问过这个条件,我们把对象标记成白色(white)、灰色(gray)、黑色(black)三种颜色,这个标记过程称为三色标记法

  • 白色:表示对象没有被垃圾收集器访问过。在可达性分析开始阶段,所有的对象都是白色的,在分析结束阶段,白色对象即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,安全存活的。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过

我们记录在步骤二发生时,黑色对象插入新的指向白色对象的引用,或者灰色对象要删除指向白色对象的引用关系。这些记录会在步骤三重新扫一遍,这里的正确性讲解略过

CMS 的弊端

1,使用清除算法,产生大量碎片空间
2,并发清理与标记时因为部分线程在 GC,会减少程序执行的效率
3,并发标记阶段无法标记所有的浮动垃圾,因此会频繁触动 CMS

由于每个弊端都是致命的,在 jdk14 中,CMS 彻底废弃

G First 的特点是什么

G1 采用区域化分代式,核心思想是分区算法,将整个堆分为多个(2的 x 次方个) Region(区域),每个区域既可以是新生代,又可以是老年代或者 Humingous(专门用来储存大对象)

可预测停顿时间模型指,G1 在允许运行时间下收集优先链表中价值最大的区域,同时 G1 的期望暂停时间可以设定,但是设定过小会影响吞吐量

G1 在 region 之间使用的是复制算法,从整体上看又是标记压缩算法。会使用记忆集来确记录对象被其他不同区域的对象引用

ZGC

在 JDK16中,ZGC 的停顿时间已经为常数。正常情况下,ZGC 停顿时间(STW)不会超过1ms,约为0.5ms。不超过10ms 将成为历史,上 JDK17,使用 ZGC,体验飞一样的感觉!

在这里插入图片描述

在标记阶段,与其说是标记对象(记录对象是否已经被标记),不如说是标记指针(记录GC堆里的每个指针是否已经被标记)。这就与传统的三色标记对象的GC算法有非常大的区别,虽然两者从收敛性上看是等价的——最终所有对象以及所有指针都会被遍历过

在标记和移动对象的阶段,每次从GC堆里的对象的引用类型字段里读取一个指针的时候,这个指针都会经过一个“Loaded Value Barrier”(LVB)。这是一种“Read Barrier”(读屏障),会在不同阶段做不同的事情。最简单的事情就是,在标记阶段它会把指针标记上并把堆里的这个指针给“修正”到新的标记后的值;而在移动对象的阶段,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而不需要通过stop-the-world这种最粗粒度的同步方式来让GC与应用之间同步

LVB中有一点很重要,就是“self healing”性质:如果堆上有指针当前处于“尚未更新”的状态,一旦经过LVB之后就会被就地更新,于是在同一个GC周期内再次访问这个字段的话就不需要再修正了。这样LVB带来的性能开销(吞吐量的下降)就是非常短暂的,而不像Shenandoah GC所使用的Brooks indirection pointer那样一直都慢

HotSpot 算法实现细节

我们现在知道垃圾收集算法是概念,垃圾收集器是各种不同的实现,那 HotSpot 的具体实现细节是什么呢,它在对于一些不可避免的开销(比如 STW)时是如何做的呢

根节点枚举

不同的收集器在根节点枚举阶段一定会 STW,就算有些收集器将时间较长的追踪引用的过程与用户线程一起并发执行也不可避免枚举,为了优化这一过程的时间,虚拟机想了很多办法

现在的问题是根节点枚举必须在一个保证一致性的快照中才能进行(因为线程的运行可能会修改引用),因此必须 STW,那我在 STW 的时候有线程正在创建对象怎么办,打断它,不太可能吧,一般只能等,这个等待的时间能否优化掉呢?而且将每一个 GC Roots 遍历一遍时间过长,能否也优化一下?

一是虚拟机用空间换时间,用一组称为 OopMap 的数据结构来存放所有的 GC Roots,比如在某个方法中引用了哪个对象,或者在静态变量被加载的时候,虚拟机会把这些东西直接放在 OopMap 中,这样就能直接拿到 GC Roots 了

但是由于改变 OopMap 的语句非常多,虚拟机适当的减少了这个过程,它设定了一个安全点,只有在这个安全点的时候才会修改 OopMap。而只有在循环、抛出异常、方法调用的时候才可能会生成这个安全点,因为这种时候既不会创建对象也不会引用对象

二是优化让所有的线程都在一个一致性状态的时间消耗。这时候安全点就发挥作用了,只有在这个安全点的时候才会修改 OopMap,那在这个安全点中生成一致性快照也没问题,现在问题就变成了如何让所有线程跑到这个安全点上

正常的程序员会怎么解决?当需要 GC 时通知所有线程跑到最近的安全点上即可,这样有两种解法,一是直接中断所有线程,再让没有在安全点的线程恢复跑到安全点,这就叫抢先式中断,中断和恢复的代价还是有一点的。一般不会这么用

第二种方法是设置一个标志位,程序要中断时将此标志位改变,所有的线程在到达安全点时都会检查一次标志位,如果判断成功直接暂停,这就叫主动式中断,一般都使用这个

还有一个小小小问题,就是一些线程可能在 sleep,此时它无法响应虚拟机,也没法到达安全点,那怎么办?解决方法是设置安全区域,在该区域时 OopMap 一定不会改变。当程序进入安全区域时,设置标识位直到离开,虚拟机看到这个标识位就不会去管这个线程了

跨代引用

跨代引用是指新生代中存在对老年代对象的引用,或者老年代中存在对新生代的引用。这样 YGC 时,为了找到年轻代中的存活对象,不得不遍历整个老年代;反之亦然。这种方案存在极大的性能浪费

记忆集就是用来记录跨代引用的表,为每一个区域(一块连续的内存空间)分配一个记忆集,通过引入记忆集避免遍历所有的区域。记忆集时抽象的,卡表是记忆集的一种实现

如果在该记忆集对应的对象有被其他代的对象引用时,该记忆集就存放该次引用的数据(我们可能想到在对象修改引用的时候加一个 AOP 操作以修改记忆集,事实上虚拟机也是这么做的,这种操作叫写屏障)。使用一个数组来存放所有的记忆集,在区域进行垃圾收集的时候,对该区域记忆集中的数据进行遍历,过滤掉记忆集中包含的对象进行了

JVM 调优

正常来说我们一般不会接触到 JVM 调优,一般也不会出现 OOM 问题(就算出现了也大概率不是我们业务代码的问题,而是框架或者工具的问题),不过我们需要看 gc 日志来判断虚拟机的性能怎么样,以优化业务代码

gc 日志主要包含的信息为收集类型(是老年代还是新生代等)、从开始的多少 K 收集到了多少 K、收集的耗时是多少等数据,我们还可以观察 GC 日志的频率看看是否出现了短时间内出现了大量的收集行为。尽量让日志中不要出现 Full GC,因为会 STW,非常影响性能

除了 gc 日志,一般来说,我们分析会用到以下的命令:

jstack:主要用来查看某个Java进程内的线程堆栈信息,根据堆栈信息我们可以定位到具体代码,具体用法需要先找到线程

第一步先找出 Java 进程 ID,我部署在服务器上的 Java 应用名称为 mrf-center

root@ubuntu:/# ps -ef | grep mrf-center

root     21711     1  1 14:47 pts/3    00:02:10 java -jar mrf-center.jar

得出进程 ID 为21711,通过进程可以找到进程中的线程,以及线程执行时间、消耗 CPU 和内存资源情况,找出异常数据,通过线程 ID 配合 jstack,就可以输出进程21711的堆栈信息了。里面会包含在哪个类执行哪些方法

root@ubuntu:/# jstack 21711 | grep 54ee

"PollIntervalRetrySchedulerThread" prio=10 tid=0x00007f950043e000 nid=0x54ee in Object.wait() [0x00007f94c6eda000]

jstat 是 JVM 统计监测工具,比如下面输出的是 GC 信息,采样时间间隔为 250ms,需要采样4次。通过这个命令可以看到大概发生了多少次 GC,以及平均 GC 耗时

root@ubuntu:/# jstat -gc 21711 250 4
 S0C    S1C    S0U    S1U      EC       EU        OC         OU       PC     PU    YGC     YGCT    FGC    FGCT     GCT   
192.0  192.0   64.0   0.0    6144.0   1854.9   32000.0     4111.6   55296.0 25472.7    702    0.431   3      0.218    0.649
192.0  192.0   64.0   0.0    6144.0   1972.2   32000.0     4111.6   55296.0 25472.7    702    0.431   3      0.218    0.649
192.0  192.0   64.0   0.0    6144.0   1972.2   32000.0     4111.6   55296.0 25472.7    702    0.431   3      0.218    0.649
192.0  192.0   64.0   0.0    6144.0   2109.7   32000.0     4111.6   55296.0 25472.7    702    0.431   3      0.218    0.649

除此之外,我们还可以根据 top 或者 ps 命令,来查看是否有异常的线程占用了过多的 CPU 或者内存资源

内存泄漏

没有实际场景的 JVM 调优都是空谈,我们来看看如何定位内存泄漏问题。如果 gc 日志频繁的发生 fullGC,并且回收的内存逐渐变小的话,我们就需要判断是否有内存泄漏问题了,我们可以先通过 jmap 查看堆内存

# jmap -heap pid
jmap -heap 27403

Attaching to process ID 27403, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.25-b02

using thread-local object allocation.
Mark Sweep Compact GC

Heap Configuration:
   MinHeapFreeRatio         = 40
   MaxHeapFreeRatio         = 70
   MaxHeapSize              = 1006632960 (960.0MB)
   NewSize                  = 20971520 (20.0MB)
   MaxNewSize               = 335544320 (320.0MB)
   OldSize                  = 41943040 (40.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
New Generation (Eden + 1 Survivor Space):
   capacity = 216989696 (206.9375MB)
   used     = 116865984 (111.45208740234375MB)
   free     = 100123712 (95.48541259765625MB)
   53.857849545077016% used
Eden Space:
   capacity = 192937984 (184.0MB)
   used     = 116865984 (111.45208740234375MB)
   free     = 76072000 (72.54791259765625MB)
   60.57178663170856% used
From Space:
   capacity = 24051712 (22.9375MB)
   used     = 0 (0.0MB)
   free     = 24051712 (22.9375MB)
   0.0% used
To Space:
   capacity = 24051712 (22.9375MB)
   used     = 0 (0.0MB)
   free     = 24051712 (22.9375MB)
   0.0% used
tenured generation:
   capacity = 481370112 (459.0703125MB)
   used     = 288820968 (275.4411392211914MB)
   free     = 192549144 (183.6291732788086MB)
   59.99977165179711% used

9569 interned Strings occupying 839416 bytes.

如果发现老年代的对象越来越多,并且无法被回收,我们就基本可以定位到是内存泄漏的问题了,接下来使用 jmap -dump 导出 dump 文件,可以查看是哪个实例对象过多

一般来说,使用 ThreadLocal 不当或者在将数据放进 map 时没有重写 equal 和 hashcode 方法的话,会出现内存泄漏问题

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值