记得在实习找工作的时候,有个面试官问了JVM调优的问题,当时好像是这么问的,你知道JVM调优吗? 我说我不是很了解,然后面试官跟我说,你面试官可以了解一下,JVM调一下,会运行的更好。当时的我并不理解JVM为什么要调优,我心里的想法是难道是JVM的某些参数一开始设置的并不合理吗?但是我又想JVM发展了这么长时间了,就算是一开始有这个问题? 到现在都没解决吗?

JVM运行的不佳,有没有可能不是JVM的错,是程序员写的代码质量太差,导致的呢。
后来查了一些资料: 一般的Java项目需要JVM调优吗?,发现JVM多数情况都不需要调优了,但是有的时候程序员写的程序性能不佳,问题并不能全推给JVM。

为什么要学习JVM?

定位问题,有可能系统表现不佳,但是我们一时也无法找出程序是哪里写的不好,所以我们需要给JVM做诊断,看看问题究竟是出在哪里。
要做诊断,首先得了解JVM,本篇并不适合刚学Java的人观看,这会让你迷失。那了解JVM我们具体了解哪里呢?
第一个是运行时的区域划分(这里要跟JMM区分开),第二个是垃圾回收器和垃圾回收算法,第三个是JVM监测工具。
通过这些,我们就可以给JVM做诊断,定位问题。

其实之前也写过类似的文章,只是这次做一个整体的讨论,更成系统。

JVM运行时区域

这也是我的第一篇博客讨论的问题,JVM在运行时,向操作系统申请的内存,都被划分为哪几个区域。也就是这篇文章:

本篇我们对这些概念再度进行讨论,更加系统,更加全面,我在看视频的时候,都会讲堆和栈,但我细究下并非如此。因为对于JVM,有oracle出品的HotSpot VM,也有淘宝出品的TaobaoVM,华为出品的毕昇 JDK等,不少知名的计算机厂商都有自己定制的JDK。那这些魔改,会不会让Java语言出现不同的版本呢? 影响Java一次编写处处运行特性呢? 导致Java四分五裂,答案是并不会,有一个东西叫JVM规范,上面描述了你要想定制JDK,魔改JVM,那么也请在这个范围内进行魔改,如果肆意魔改JDK,许多成熟的开源框架就很可能无法在你定制的JDK上成功运行,为了保证各个厂商定制JDK同时,不影响Java一次编写,处处运行的特性,Oracle推出了JVM规范,只要你不违背规范,就不影响字节码的运行,在规范之外,运行你自由定制。

写到这里,突然想起大学时代一个同学问过我的一个问题,为什么定义接口,让类去实现,我直接在类里面写不就行了吗?
当时的我用的是另一个例子,介绍的接口的用处,假如是团队合作,有一个功能让你完成,这个功能用接口完成,到时候人家调用你的接口就行,不必关心具体实现,这样能够有效的实现解耦,我们可以将接口理解为规约,约定这个方法到时候要实现什么。的确接口更像是规约,因为到时候的实现类不是由你来编写。

JVM规范地址:

现在已经是到15了,因为JDK已经到15了。本次我们研究的是JDK8,到JDK11,JVM的运行时区域基本没发生变化。
同样的JVM规范也对于运行时区域进行了约束,就像是接口,对应的虚拟机实现了规约,就像是实现类。JVM规范规定JVM运行时区域有以下这几个部分:

  • The pc Register 程序计数器
  • Java Virtual Machine Stacks 虚拟机栈
  • Heap 堆
  • Method Area 方法区
  • Run-Time Constant Pool 运行时常量池
  • Native Method Stacks 本地方法栈
    最新的JDK15也是这么划分的,好像一直都没有调整过啊。事实上是有调整的,只不过规范里仍然将运行时区域划分为这几个区域。但是不同的虚拟机似乎有着不同的实现,比如使用面最广的HotSpot VM,在IDEA中做测试:

image.png
image.png
随便找个main函数测试,你会发现控制台会输出以下信息:
image.png
Heap堆,我们知道,那这个Meta Space是什么鬼? HotSpot在规范中要求的运行时区域上又加了一块?并不是的,这其实是方法区的实现,在HotSpot 1.6 中对方法区的实现是永久代,也是垃圾回收的范围之内,与年轻代、年老代相对。1.7版本将字符串常量池移动至堆中。1.8版本彻底移除永久代的实现,用MetaSpace来实现方法区。那我们自然要问为什么HotSpot虚拟机要移除永久代,改用元数据区(MetaSpace)来实现方法区呢?注意上面我们提到了Hot Spot虚拟机,事实上在早期虚拟机有许多版本,不是1.6 1.7 1.8的这种版本,是各个厂商对JVM的实现,我们常用的一般是Oracle提供的JVM,全名应该是HotSpot JVM,其实还有JRockit(Oracle出品,原先Java属于Sun,所以Oracle又自己研发了一个JVM),除此之外还有IBM JVM、Apache Harmony等在Harmony的基础上,谷歌研发了Dalvik。
image.png
然后Oracle收购了Sun,开始了天下一统,将HotSpot和JRokit进行整合,所谓的整合肯定是将各自的优点集成,JRokit就没有永久代这种实现,而且运行良好。那方法区究竟用来存储什么信息呢? 其实对这里存储的信息,JVM规范也做了规定,方法区主要存储: 类和方法的元数据以及常量池,比如Class和Method。每当一个类初次被加载的时候,它的元数据都会放到方法区中。
永久代大小是有限制的,在1.8之前类加载过多,永久代内存设置不当 就可能会导致永久代永久溢出,即万恶的 java.lang.OutOfMemoryError: PermGen ,我们不得不来根据实际情况对调整永久代的大小。在JRokit中就并没有永久代的概念,而且运行良好,不会有恼人的java.lang.OutOfMemoryError: PermGen 。在JEP 122: Remove the Permanent Generation描述了移除永久代,改用元数据区域(就是Meta Space下文统一称元数据区域)实现的原因,还比较清晰,有兴致的同学可以翻翻这份草案。关于元数据区域相关的资料还比较少,也只有Oracle官方的Java虚拟机规范及Oracle Blog有相关的描述,官方的描述如下:

In JDK 8, classes metadata is now stored in the native heap and this space is called Metaspace.
类的元数据区被存储在本地堆中,这个区域被称作元空间。

本地堆也就是说直接使用操作系统提供的内存空间,默认的空间大小只受本地内存的限制,粗略的说就是本地内存剩多少,不够了我再跟操作系统申请(一般的操作系统都有虚拟内存),但是让元空间无限大又有些浪费资源,我们可以通过 -XX:MaxMetaspaceSize来指定。除此之外,默认情况下JVM会根据运行概况来动态的调整MaxMetaspaceSize的大小。如果Metaspace的空间占用达到了设定的最大值,那么就会触发GC来收集死亡对象和类的加载器。根据JDK 8的特性,G1和CMS都会很好地收集Metaspace区(一般都伴随着Full GC)。

堆是垃圾回收器的主要工作对象,不同的垃圾回收器对堆的划分又有些区别,在JDK8下面,加上-XX:+PrintGCDetails,打印出来的:

image.png
我们来介绍下加入-XX:+PrintGCDetails后,输出内容代表的含义:

  • PSYoungGen: Parallel Scavenge(并行垃圾回收器) Young Generation (我们常说的年轻代)

    • eden space (伊甸园) 创建对象时申请的内存空间优先使用该区域

      进行垃圾回收后,eden space还存活的对象将会移动至幸存者区域(Survivor Space),幸存者区域被分为两块,一块是To Survivor、From Survivor,这两个区域的空间大小是一样的。年轻代触发垃圾回收时,Eden Space中还存活的对象会被放入到空的幸存者区域(一般是 to Space),另一个幸存者区域(即From Space)里不能被回收的对象也会被放入To Space。然后To Space 和 From Space身份互换。年轻代触发GC之后,都会计算一个晋升阀值(tenuring threshold ,即该对象大于晋升阀值之后被移动至老年代)和各年代区的大小,以及适时地调整大小。
      当Eden区被完全占用,触发垃圾回收,恰巧To 区域不够容纳在Eden Space和From Space进行垃圾回收之后还存活的对象,从JVM角度是想通过晋升阀值来避免的,但是也无法完全保证。
    • from space
    • to space
  • ParOldGen Parallel Old (Parallel Scavenge的老年代版本)

    - object space
  • Metaspace 元空间
    接着我们在JDK11中,做同样的测试,在VM Options加入-XX:+PrintGCDetails,看一下输出与JDK8有什么不同:
    image.png
    从这段输出中,我们首先看到--XX:+PrintGCDetails这个参数在JDK 11 中时过时的状态,建议我们用-Xlog:gc*代替。
    然后发现输出好像除了Metaspace,其他的和JDK8完全不一样。
    这是因为JDK 11 默认使用的垃圾回收器是G1(Garbage-First Garbage Collector 直译为垃圾回收优先回收器),G1弱化了分代的概念,采用分区(Region)的概念来管理内存,G1将堆分成相同大小的分区:
    image.png
    old space 我们就称之为老年区(这是我自己取的名字),Eden(年轻代区),Survivor(幸存者区),Humongous(巨大对象区,下文我们简称为H区,当对象大小等于Region的一半时,对象会被分配到该区,该去属于老年区)。
    有了这些我们便能看懂JDK 11 下面输出的GC参数了,默认的分区大小是1M。G1在JDK6u14版本面世,在JDK7u4版本推出,在JDK8中,可以通过-XX:+UseG1GC指定使用G1垃圾回收器。

强调一下,关于堆、栈之类的叫JVM运行时区域划分,有些错误的资料会将其称为Java 内存模型(JMM Java Memory Model)。
简单的说Java内存模型是一种规范,主要是为了跨平台的解决并发编程遇到的问题。

总结一下

到现在我们对JVM的运行时区域划分已经有了一个比较清晰的理解,在JDK8之前的JVM运行时区域为:

  • The pc Register 程序计数器
  • Java Virtual Machine Stacks 虚拟机栈
  • 永久代( Method Area 方法区)
  • Run-Time Constant Pool 运行时常量池(位于永久代
  • Native Method Stacks 本地方法栈
  • 堆 heap

    -  年轻代
        -  Eden Space
        - To Space
        - From Space
    • 年老代
  • 永久代
在JDK8之后,永久代被移除,取而代之的是元空间,堆中的内存区域划分跟选择的垃圾回收器有关,G1(JDK9 成为默认的垃圾回收器,)和ZGC(JDK 11引入,JDK15默认的垃圾回收器)、Shenandoah GC(JDK12引入的垃圾回收器) 弱化了分代的概念,采用分区来管理内存。截止到JDK 16,除了上面三款的分区的垃圾回收器,其他垃圾回收器都采用分代来管理内存。
image.png
永久代不在堆里,只是和堆在物理上是一段连续的内存。
JDK8之后,假如你选择的是分代垃圾回收器:
image.png
假如你选择的是分区垃圾回收器,比如G1,那么java运行时区域划分,就变成了这样:
image.png
堆是垃圾回收器主要关注的区域,对象主要也是在堆上分配内存,也就是说对象还可以在堆外分配内存,讲清楚这一点并不是那么容易,这并不是本篇的主题,有兴致的可以参看下面两篇文章:

System.gc

Runs the garbage collector. Calling the gc method suggests that the Java Virtual Machine expend effort toward recycling unused objects in order to make the memory they currently occupy available for quick reuse. When control returns from the method call, the Java Virtual Machine has made a best effort to reclaim space from all discarded objects.
调用垃圾回收器,调用该gc方法意味着JVM将会投入一定精力去回收无用对象以减少内存占用,当方法返回时,JVM将尽最大努力从无用对象回收内存。

注意这里的尽最大的努力,也就是说啥也不干也行,本文研究的Hot Spot和其他JVM一样,默认在调用该方法的时候立即执行GC,并且等待GC完成时方法才返回。但是也有例外,ZGC是不支持通过System.gc()来触发的,也就是调用了System.gc()也没有用。截止到JDK17(在JDK17没有看到关于新的垃圾回收器的提案),其他垃圾回收器是可以通过System.gc()来触发GC的。

finalize()方法

如果一个对象实现了finalize()方法,那么在该对象的收集阶段,该方法即会被调用。注意JVM只是帮我们管理内存,这里说的管理包括分配和释放,也就是像其他资源就需要程序员自己去释放,比如堆外内存(主要被零拷贝和NIO所使用)、文件句柄、Socket等资源,程序员必须自己手动来管理。这个主要是为了避免对象死了以后它原本持有的资源泄漏,java才提供了finalize机制让用户注册finalize()这么个回调方法来定制GC清理对象的行为。在java.io.FileInputStream中就重写了finalize(),用于释放对应文件句柄资源。JDK 8以上该方法被标记为过时,取而代之的是Cleaner。有关这方面的讨论,可以参看:

  • 新版本的Java将会废弃Object.finalize(),并添加新的java.lang.ref.Cleaner

    走进GC

    GC: Garbage Collection 垃圾收集,当堆中的对象被JVM判定为无用的时候,JVM中的垃圾回收器就会将被判定无用的对象的内存给回收掉,清除该对象的实例数据。从上面可以看出垃圾回收可以被分为两部分,一是如何定义垃圾,二是垃圾如何回收。
    如果我们将内存为一个房间的话,用户程序可以理解为房间的使用者, 垃圾回收器就是房间的清洁者,从常理上推断,在你小的时候,妈妈给你打扫房间的时候,基本是暂停了你的房间使用权一段时间,然后再让你重新重新使用,这也就是Java世界的Stop-The-World,在垃圾回收的某个阶段,JVM会暂挂用户线程,等待该阶段结束才会恢复用户线程的运行。

如何定义垃圾

引用计数法

引用计算法是通过在对象头中分配一个空间来保存该对象的引用次数。如果该对象被其他对象引用,则它的引用计数加1,如果删除对该对象的引用,那么它的引用计数就减1,当该对对象的引用计数为0时,该对象就会被判定为垃圾。

Object object = new Object();

假设object指向对象的地址是0xaaff666,那么我们就称地址为0xaaff666中存储的对象有一个引用即为object。
然后将object置为null。那么0xaaff666这个地址上的对象就被认为是垃圾。该算法是将垃圾回收分摊到整个应用程序当中了,而不是在进行垃圾收集时需要挂起整个应用,直到对堆中所有对象的处理都结束。因此采用引用计算算法的垃圾回收机制并不算是严格意义上的"Stop-The-World"的垃圾回收机制。
这个算法被抛弃的主要原因就是无法解决循环引用问题,像下面这样:

public class GcDemo {
    public Object instance;

    public static void main(String[] args) {
        GcDemo a = new GcDemo();
        GcDemo b = new GcDemo();
        a.instance = b;
        b.instance = a;
        a = null;
        b = null;
    }
}

假设a之前指向的地址是0xfffa,b指向的地址是0xfffb。那么即使是a置为null,那么0xfffa地址上的对象中的instance仍然指向
0xfffb,同样的oxfffb地址上的对象的instance指向0xfffa。像下面这样:

image.png
由此我们引出在Java采用的垃圾判定算法可达性分析算法。

可达性分析算法

可达性分析算法(Reachability Analysis)的基本思路是,通过一些被引用链(Gc Roots)的对象作为七点,从这些节点开始向下搜索,搜索走过的路径被称为引用链(Reference Chain),当一个对象到gc Roots没有任何引用链相连时(即从GC Roots节点不可达),则证明该对象是不可用的。
那哪些对象可以做为gc roots呢,在Java语言中,可作为GC Root的对象有以下几种(未列出全部,具体的参看Eclipse 堆内存分析里列举出来的各种根对象类型):

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 类静态变量引用的对象
  • 类常量引用的对象
  • JNI的native方法栈中引用的对象
  • JNI中的global对象
  • 活着的线程所使用的对象

这些就跟我们上面讲的Java运行时区域联系上了,上面我们我们就提到了虚拟机栈,存储的基本单元就是栈帧,每调用一个方法,就会有一个栈帧入栈,我们可以理解为栈帧存储了方法执行所需要的的必要信息。

基于这种思路,那么循环引用问题就被解决了,我们知道程序是以方法为基本执行单位的,一个方法就是一个基本执行单元,java也是从main方法开始执行的,我们现在来结合具体的例子来看一下可达性分析算法是如何解决循环引用问题的:

private static void testGC() {
        GcDemo a = new GcDemo();
        GcDemo b = new GcDemo();
        a.instance = b;
        b.instance = a;
 }

testGC()执行完毕之后,a和b指向的对象不再是GC Roots,所以就可以被标定为垃圾。
那为什么这些对象可以是GC Roots,这些对象有什么特殊的呢? 因为这些对象如果被标定为垃圾,被垃圾回收器回收,那么就会影响程序的正常执行。JVM在调用一个方法的时候,会形成此方法的栈帧,如果这个栈帧所对应的对象回收了,那么这个方法就无法执行了。类静态变量和常量也是同样的道理,方法在运行时随时会用到这些。

垃圾如何回收

假如你比较富有,你不想整理你的房间,你雇了一个家政来帮你打扫房间,如果这个家政在收拾垃圾的时候比较理工科,先给
垃圾区域标记上贴纸,然后再统一清除垃圾。那么该家政采用的就是标记-清除算法。该家政存在的问题就是空间利用率不高,房间有很多小的空间,然后你有一个大对象,啊,不,是一个大家具想搬进来,你仔细算了算,发现这些空闲的空间加在一起足够的,但是这些空间没有连接在一起,这个家具就搬不进来,所以你就进行了一次房间整理。

为了避免这种情况,你让家政换了一种打扫策略,家政把你的房间切为两个区,每次只使用一块,然后使用的那块需要垃圾回收时,垃圾回收完毕之后,剩下的生活用品移动到另一块,这样买其他生活用品的时候,就避免了明明感觉房间放的下,但是就是没地方放尴尬问题。但是很快你就发现了新的问题,房间的利用率下降,因为你的房子是160平,按照这个家政的策略,你就只能采用80平。这也就是标记复制算法。

很快你对这种打扫策略感到了不适,你让家政在每次打扫完房间之后,再整理一下房间。但是很快你就发现了不适,你特别爱干净,一个小时要打扫一次,因为你的生活必须品位置变动频繁,家政打扫起来也慢。这个家政采用的就是标记-整理(也有资料称之为标记-压缩)算法。

于是聪明的你很快的开始跟阿姨讲,将房间进行分区,哪个区执行标记-清楚策略,哪个区执行标记-复制策略,哪个区执行标记-整理策略。

垃圾回收器简介

在JDK 1.3之前,Serial GC是唯一的选择,该垃圾回收器是单线程的,那就意味着标记和清除阶段都需要挂起整个JVM。这也是一个分代的垃圾回收器,新生代采用标记复制,老年代采用标记整理算法。
但是随着时代的发展,Serial GC已经不再满足服务端的需要了,Java开始将原来的串行转成并行处理,Java将原来的Serial改成了并行,这也就是ParNew(这个垃圾回收器,JDK已经不建议采用了,也不知道是不是这个原因,这个垃圾回收器的资料比较少)。如果你对系统的吞吐量(程序运行时间/程序运行时间+GC时间)比较看重的话,那么JDK 8目前采用的垃圾回收器Parallel Scavenge及Parallel Old就比较适合你,JVM提供了两个参数来精确控制吞吐量:

  • -XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间, 一个大于0的毫秒数
  • -XX:GCTimeRatio:直接设置吞吐量大小, 一个大于0小于100的整数
    这也是双刃剑,也不是你调的越低,JVM的垃圾收集停顿时间就越低,如果是那样的话,那JVM在出厂的时候,直接不对外提供参数,直接设置到最低不就行了吗?因为GC的耗时缩短是用于调小年轻代来获取的,如果调的过小,回收频率大大增加了,
    吞吐量随之下去了。相关的讨论可以参看这篇文章:
  • 简介JVM的Parallel Scavenge及Parallel Old垃圾收集器
    再接着就是CMS (Concurrent Mark Sweep 后文统一称CMS GC) GC(JDK9之后已经被废弃了,原因就是参数太多,有了理论上更优的选择G1),CMS收集器是一种获取最短回收停顿时间为目标的收集器,这是因为CMS收集器工作时,GC工作线程与用户线程可以并发执行,以此来达到降低收集停顿时间的目的。CMS收集器仅能用于老年代的收集,是基于标记清除算法。

在没有G1、ZGC 、Shenandoah出现之前, 假设JDK默认的垃圾回收器不能满足我们的需要,我们就需要根据业务来选择对应的垃圾回收器组合:

  • Serial GC(不同代有不同的版本)
  • ParNew(标记复制) + CMS(标记清除) GC
  • Parallel Scavenge + Parallel Old
    作为一个开发者,我的愿景就是能不能不让我关注那么多有的没的参数,就让我写代码不行吗?很快G1、ZGC、Shenandoah的出现就满足了我的需求,不用再选,性能优良(这个似乎有点争议,毕竟也没有银弹,按需选择才是正理),参数少。
    G1: Garbage First 采用分区的概念来管理堆内存,设计原则是"首先收集尽可能多的垃圾",目标是为了尽量缩短处理超大堆产生的停顿。因此G1并不会等内存耗尽(比如Serial 、Parallel)或者快耗尽的时候(比如CMS垃圾回收器)才开始垃圾回收,而是在内部采用了启发式算法,在老年代中找出具有高收集收益的分区(Region)进行收集。
    ZGC: 是JDK11(JDK11 只支持Linux)推出的一款低延时垃圾回收器,它的设计目标包括:
  • 停顿时间不超过10ms
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加
  • 支持8MB-4TB级别的堆(未来支持16TB)
    Shenandoah GC: JDK 12 引入 ,ZGC为了追求低停顿,吞吐量有所下降,而Shenandoah暂停时间与ZGC相近,平均暂停时长为10ms,ZGC平均暂停时间1ms,最长不超过10ms,但是吞吐量有所下降。

    Full GC 和 Mirror GC

    JVM回收对象占用的内存这个动作可以分为两大类:

  • Partial GC: 并不收集整个堆的模式

    - Young GC: 只收集年轻代的GC
    - Old GC: 只收集年老代的GC。只有CMS的并发收集时这个模式
    - Mixed GC: 收集整个年轻代以及部分年老代的GC。只有G1有这个模式。
  • Full GC: 收集整个堆,包括年轻代、年老代、永久代(如果存在的话,JDK8移除该代)等所有部分的模式。
    Major GC通常是跟full GC等价,收集整个GC堆,但是因为Hotspot VM发展了这么多年,外界对各种名词的解读已经完全混乱了。当有人问你XX GC的时候,你一定要问清楚他说的是哪种。

GC触发条件

其实上面已经讨论过了,各种young gc 触发的条件都是eden区满了。CMS GC只收集老年代,触发条件是老年代使用比率超过某值。G1触发条件是堆使用比率超过某值。

引用类型

在Java中,一切都是面向对象的世界,在Java中的数据类型分为两种,一种是基本类型,另一种为引用类型。引用类型是垃圾内存回收主要关注的对象。引用类型其实还可以在分为: 强引用类型(Object object = new Object()、软引用类型(SoftReference)、弱引用类型(WeakReference)、虚引用类型(PhantomReference)

强引用

通常情况下,我们在Java中创建对象最常使用的方式是这样的:

  Object object = new Object();

object存储了对象在堆中的地址,我们也称object指向new Object()。new Object()产生的对象即存在着一个指向它的引用,也就是object,我们称这种引用类型为强引用类型。强引用的对象失效大致上有两种情况:

  • 生命周期结束

    也就是该对象明显不会再被使用时,该对象就会等待被GC回收。不少网上的资料会说,对于强引用也就是new 方式创建的对象,JVM在任何时候都不会回收其内存。但是我认为这个是说法是有问题的,假设一个方法返回值是void,然后这个方法不断的被调用,内存不断的被占用,也不释放,那么JVM就会OOM。但是JVM并没有发生这样的情况,原因在于对于强引用的对象,就算是出现了OOM也不会被回收的这种说法是错误的。但是网上的博客基本也是互相抄,一个错,那就是都错。接下来我们验证一下上面的说法:
    public class JVMDemo {
        public static void main(String[] args) throws InterruptedException {
            for (int i = 0; i < 5; i++) {
                testGC();
            }
            System.out.println("--------------------------");
            TimeUnit.SECONDS.sleep(10);
            List<String> list = new ArrayList<>();
        }
    
        private static void testGC() throws InterruptedException {
            Byte[] bytes = new Byte[1024 * 1024 * 10];
        }
    }

    我们先用 JVM参数限制一下堆内存,我的电脑是16G的,不限制内存,跑出来垃圾回收现象可能会比较难。

  • image.png
  • 在VM Options中加入 -XX:+PrintGCDetails(告诉JVM,垃圾回收时打印垃圾回收信息) -Xmx128M(堆可用最大大小为128M) -Xms64M(堆起始内存为64M),-XX:+PrintHeapAtGC: 打印内存回收之前堆的内存使用情况。
    image.png
    输出结果:
    image.png
    这足以证明许多博客关于强引用类型,JVM在任何时候都不会回收其内存的说法是错误的。可能有同学还会说,你怎么证明打印出来的GC信息是testGC()方法造成的呢? 很简单,我们可以在for循环中注释掉对testGC()的调用。你会发现就不会输出GC信息了。
    这个故事告诉我们,要懂得理论与实践相结合,网上的博客要有在自己的甄别能力,因为可能都是错的。因为在写对象在什么时候被回收的时候也是翻了一堆资料,发现都是各说各的,都是存在着大量的问题。所幸最后还是找到了比较靠谱的资料:
  • Java对象生命周期
  • Java 中的引用类型、对象的可达性以及回收处理
  • The Truth About Garbage Collection

    对象的生命周期简介

    粗略的说一个对象的一生是这样的:

  • Created 创建阶段

    对应new,在堆上分配内存,变量初始化。
  • In Use 应用阶段

    至少被一个强引用持有着, 简单的说就是 Object object = new Object(); new 出来的对象就被object所持有。
  • Invisible 不可见阶段

    即使是强引用持有对象,但是这些引用是局部变量,就会进入这个阶段,该阶段非必经阶段,像下面这样:
    private void run() {
        try {
            Object object = new Object();
            TimeUnit.SECONDS.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // object对于值在try里面可见。那么程序执行到这里,
        // object指向的对象即进入不可见阶段
        System.out.println("hello world");
    }
  • Unreachable 不可达阶段

    对象处于不可达阶段是指该对象不再被任何由gc roots的强引用的引用链可达的状态。
    后文会讲gc roots配合可达性算法分析(该算法解决的是如何判断一个对象是垃圾).
  • Collected 收集阶段

    当垃圾回收器发现该对象已经处于不可达阶段,并且垃圾回收器已经对该对象的内存空间重新分配做好准备时,对象进入"收集阶段"。如果该对象已经重写了finalize()方法,并且没有被执行过,则执行该方法的操作。如果已经执行过了finalize方法,则该对象直接进入终结阶段。finalize()方法在某种程度上可以看做是对象的自救,尽管从某种意义上来看,这种自救颇为鸡肋,还存在着种种问题,在JDK8以上的版本,该方法已经被标记为过时的。
  • Finalized 终结阶段

    当对象执行完finalize()方法仍然处于不可达状态,该对象进入终结阶段。在该阶段,等待垃圾回收器回收该对象空间。
    注意这一点,是等待,等待被垃圾回收器回收,意味着对象自救失败。
  • Deallocated 重新分配阶段

    这是垃圾回收的最后一个阶段,如果经历上面的阶段进行该对象还被判定为不可达,那么该对象的所占用的内存空间进入重新分配的候选,至于是清空该内存空间还是再分配、在什么时候发生则取决去具体的虚拟机实现。
    image.png
    算上Servlet的生命周期、线程的生命周期、类的生命周期,刚好凑够四个,可以称之为Java的四大生命周期了。

    软引用

    根据JVM内存情况: 如果内存充足,GC不会随便的回收软引用对象 ; 如果JVM内存不足 , 则GC就会主动的回收软引用对象。经常被用作缓存。上面是网上常见的说法,那这里就有一个问题,就是多少内存算不足,是不是也有一个参数来控制呢?上面我们已经看到了,不同的垃圾回收触发垃圾回收时机不同。像我在使用JDK默认的垃圾回收器测试(Parallel Scavenge)时候,常常是内存溢出了,软引用也没被回收。这让我很奇怪上面的说法,JVM内存不足,GC会主动的回收软引用对象,我在想也许是多线程的原因,垃圾回收线程还没开始工作,制造内存泄漏的线程就已经把JVM搞崩了。
    然后我有翻了翻SoftRefeence的注释:

    All soft references to softly-reachable objects are guaranteed to have been cleared before the virtual machine throws an OutOfMemoryError. Otherwise no constraints are placed upon the time at which a soft reference will be cleared or the order in which a set of such references to different objects will be cleared. Virtual machine implementations are, however, encouraged to bias against clearing recently-created or recently-used soft references.
    在OOM发生之前,JVM保证所有的软引用对象会被清除,但是,究竟虚拟机要回收哪个软引用的对象或者回收顺序是怎么样的,是没有限制的。虚拟机的实现一般倾向于清除掉最新创建或者最新被使用的SoftReference。

在查了一些资料,发现内存不足也不仅跟可用内存有关,还跟时间有关。在JVM 官网我查到了这样一个参数: -XX:SoftRefLRUPolicyMSPerMB.参数说明如下:

Soft references are kept alive longer in the server virtual machine than in the client. The rate of clearing can be controlled with the command line option -XX:SoftRefLRUPolicyMSPerMB=<N>, which specifies the number of milliseconds a soft reference will be kept alive (once it is no longer strongly reachable) for each megabyte of free space in the heap. The default value is 1000 ms per megabyte, which means that a soft reference will survive (after the last strong reference to the object has been collected) for 1 second for each megabyte of free space in the heap. Note that this is an approximate figure since soft references are cleared only during garbage collection, which may occur sporadically.
软引用在JVM的服务端模式的存活时间比在客户端的存活时间要长一些,清楚的速度可以通过--XX:SoftRefLRUPolicyMSPerMB
来控制(速度就和时间扯上联系了),该参数指定了堆中每MB内存软引用存活的时间,单位是milliseconds 。默认参数是1秒,
SoftReference概览:
image.png
get方法用于获取对应的引用对象。
示例:
class SoftObject {

}

public class SoftReferenceDemo {
    public static void main(String[] args) {
        SoftReference<SoftObject> softRef = new SoftReference<>(new SoftObject());
        List<Byte[]> byteList = new ArrayList<>();
        while (true) {
            if (softRef.get() == null) {
                System.out.println("软引用对象已经被回收.....");
                System.exit(0);
            } else {
                System.out.println("填满。。。。。。");
                byteList.add(new Byte[1024 * 1024]);
            }
        }
    }
}

IDEA中指定 VM Options, -XX:SoftRefLRUPolicyMSPerMB=0。然后很快就跑出来了: 软引用对象已经被回收......
注意通过-Xms和-Xmx限制内存。

弱引用

对应的类是WeakReference,特点是只要GC执行,弱引用一定会被回收。

public class WeakReferenceDemo {
    public static void main(String[] args) throws InterruptedException {
        WeakReference<MyObject> weakReference = new WeakReference<>(new MyObject());
        System.out.println(weakReference.get() == null ? "已经被回收" : "未被回收");
        System.gc();
        // 让gc线程执行
        TimeUnit.SECONDS.sleep(5);
        System.out.println(weakReference.get() == null ? "已经被回收" : "未被回收");
    }
}

输出结果:
image.png

虚引用

对应的类为PhantomReference,一般不会单独使用,一般会和引用队列(ava.lang.ref.ReferenceQueue)一起使用。
当gc在回收一个对象时,如果发现此对象还有一个虚引用,就会将虚引用放入到引用队列中,之后(当虚引用出队之后)再去回收该对象。因此,我们可以使用虚引用实现在对象被GC之前,进行一些额外的其他操作.
image.png
示例代码:

class MyObject{

}
public class PhantomReferenceDemo {
    public static void main(String[] args) throws InterruptedException {
        MyObject myObject = new MyObject();
        ReferenceQueue<MyObject> myObjectReferenceQueue = new ReferenceQueue<>();
        // 引用对象+引用队列
        PhantomReference<MyObject> phantomReference = new PhantomReference<>(myObject,myObjectReferenceQueue);
        myObject = null;
        // 让GC执行
        System.gc();
        TimeUnit.MILLISECONDS.sleep(1000);
        System.out.println("GC 执行....");
        // 出队,打印对应的引用对象
        System.out.println(myObjectReferenceQueue.poll());
    }
}

当虚引用所引用的对象实现了finalize方法,会延迟入队时间。

当我们说起JVM调优

经过之上的讨论,我们可以说说JVM调优了,我原本对调优的理解是有些问题的,我以为的调优是原先是运行优良,调过之后运行的更加良好。但是真实的情况是,运行的不如人意了,然后看看JVM出了什么问题,一般情况下,也不是JVM的垃圾回收有问题,一般都是程序写的有问题,我们给JVM诊断,看看病在哪里。但是也有特殊情况,就是确实需要调的(大数据领域比较需要这个),但是也不能瞎调,我们需要分析程序的运行情况,综合给出最佳参数,但是还没搞清楚状况就开始跳的,一律叫瞎调。

内存泄漏

一般JVM运行的不尽如人意的原因就是内存泄漏,之前我对内存泄漏是有认知偏差的,我以前认为的内存泄漏是像C++那样,有析构函数,调了两次,就叫内存泄漏。所说我对Java为什么还会内存泄漏是不理解的,事实上内存泄漏的定义是:

内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。--《百度百科》

所以由以上定义,Java还是会发生内存泄漏的,一个典型的场景就是假设你有一张大的表,然后做了全表扫描,返回到Java中产生了一个大对象,然后频繁的产生。产生大对象之后(除了ZGC、Shenandoah GC、G1之外的垃圾回收器还是比较花时间的),如果内存不够用就会触发GC,然后内存泄漏就发生了。

JVM监测工具-MAT

Java发展了这么长时间,诊断工具也有不少,像jvisualvm、jconsole等,但是这里就只介绍简单的、好用的、免费的。也就是Eclipse出品的MAT,我个人最喜欢的JVM内存分析工具,轻松的诊断出内存泄漏问题。
下载地址如下:
https://www.eclipse.org/downl...
下载之后:
image.png
image.png
通常情况下,我们都是导出入VM的dump文件,后缀为hprof文件来进行分析。
jmap -dump:format=b,file=文件位置 进程号
image.png
然后我们启动一下导出hprof文件,导入MAT做分析。
image.png
image.png
image.png
image.png
image.png
image.png
image.png

总结一下

本篇是JVM系列的总纲,涵盖全局。基本上围绕着JVM调优,我觉得JVM调优就像是给JVM看病一样,dump工具相当于输出报告,MAT就是分析工具。

参考资料


北冥有只鱼
147 声望35 粉丝