1、内存与垃圾回收

JVM与java体系

java虚拟机可以当作成一个抽象的电脑,我们只要把写好的代码编译成字节码标准规范的文件,java虚拟机就可以运行它,不用在意代码是哪种语言写的,比如下面这些语言都可以编译成字节码文件,当然也包括java语言
这是在JDK7的时候正式发布,Java虚拟机的设计者通过JSR-292规范实现java虚拟机平台上运行非java语言编写的程序
image
java不是最强大的语言,但java虚拟机是最强大的虚拟机

在2000年时候发布的JDK1.3版本,其中正式发布了HotSpot 版本的java虚拟机,成为java默认的虚拟机,直至今日。

2006年JDK1.6发布,同年还建立了OpenJDK,HotSpot顺理成章成为了OpenJDK的默认虚拟机。

2010年,Oracle收购了sun,获得了java商标和最具价值的HotSpot虚拟机,同时对HotSpot和JRockit虚拟机进行整合,名为HotRockit虚拟机,虽然现在有时我们jdk版本显示的还是HotSpot版本,但已经是整合过后的了

2011年JDK1.7发布,正式启用了新的垃圾回收器G1

2017年JDK1.9发布,将G1设置成默认GC。

2018年JDK11发布,发生了革命性的ZGC,调整JDK授权许可

虚拟机和java虚拟机

虚拟机就是一台虚拟的计算机,它是一款软件,用来执行虚拟计算机指令。虚拟机大体上分为系统虚拟机程序虚拟机

系统虚拟机是完全对物理计算机的模仿。
程序虚拟机,比如java虚拟机,是专门为执行单个计算机程序而设计,java虚拟机执行的指令成为字节码指令。

java虚拟机就是二进制字节码的运行环境
特点:

  • 一次编译,到处运行
  • 自动内存管理
  • 自动垃圾回收

JVM的位置

JVM是运行在操作系统执行的,和硬件没有直接交互

硬件上面是操作系统,jvm是安装在操作系统上面的,jvm运行的是字节码文件。

JVM整体结构

简图:HotSpot虚拟机的

大致分为3部分:

  1. 类加载子系统加载calss文件,加载到内存当中,生产大的class对象,中间过程经过:加载、连接(分为3步)、初始化。
  2. 中间部分运行时数据区,方法区和堆 是线程共享的,java栈(现在都叫虚拟机栈) 和 本地方法栈 和 程序计数器(也叫pc寄存器) 都是每个线程独有一份的。
  3. 执行引擎,分为3部分:解释器、JIT及时编译器(2次编译的作用)、以及垃圾回收器,这3部分包含在执行引擎当中。
    字节码加载到内存中以后,下一步就要解释运行了,解释运行用到的就是解释器,但如果只用解释器,体验会很差,对于反复执行的热点代码需要提前解释出来,所以就用到了JIT编译器。垃圾回收器就是执行后需要用的垃圾回收。

操作系统只能识别执行机器指令,字节码指令要想执行,就要用执行引擎,执行引擎就是把高级语言翻译成机器语言的一个翻译者。

java代码执行流程

JVM的指令集架构

java编译器输入的指令基于栈的指令,另一种指令是机器寄存器的指令。

两种架构区别:

  • 栈式架构特点:
    设计和实现简单,使用零地址指令分配
    指令集更小,但指令多
    不需要硬件支持,可移植性好,更好实现跨平台

  • 寄存器指令架构特点:
    二进制指令集,完全依赖已经,可移植性差
    性能更加优秀高效,花费更少的指令完成一项操作
    大部分是以一级指令、二级指令、三级指令为主。(比如一级指令和二级指令,一个是指令的地址,一个是要执行的指令,但栈的零地址指令直接就是执行执行)

可以使用IDEA编译器自己验证:javap -v 【class文件的全名称】
可以自己写个测试类试试

JVM生命周期

分为:启动、执行、退出,3个部分

虚拟机的启动
java虚拟机的启动通过引导类加载器(bootstrap class loader)创建以一个初始类来完成,这个类由不同的虚拟机有不同的实现类,会加载很多。

虚拟机的执行
程序开始执行时候它就开始运行,程序结束时候它也就停止。
执行java程序时候,执行的是java虚拟机的进程。

可以写个测试类,让线程休眠一会,然后查看进程
image

线程休眠结束后,再查看进程
image

虚拟机的退出
退出有很多情况:

  • 正常执行完结束
  • 遇到异常或错误,异常终止
  • 操作系统出现错误导致进程终止
  • 调用System类的exit方法也会退出(因为最终会调用本地方法halt0方法,就直接结束进程)

类加载子系统

image

类加载过程

加载类信息存放于方法区的内存空间,除了类的信息,方法区还存放运行时常量池信息,可能还包括字符串和数据的常量(这部分常量是class文件中常量部分的内存映射)

过程一:Loading

通过类的全限定名获取这个类的二进制字节流
这个字节流代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表这个类的大的class对象,作为方法区这个类的各种数据访问入口(想想反射)

过程二:Linking

分为3步:

  1. 验证(Verify):
    确保class文件的字节流符合当前虚拟机的要求与正确性,
    包括4种验证:文件格式、元数据、字节码、符号引用

  2. 准备(Prepare):
    为类变量分配内存并且设置类变量默认初始值,也就是零值。
    这里不包括final修饰,final修饰的变量在编译的时候就会分配了。
    不会为实例变量分配初始化,类变量分配在方法区中,而实例变量随着对象一起分配到java的堆中。

  3. 解析(Resolve):
    将常量池内的符号引用转换为直接引用,
    解析操作往往会伴随着jvm在执行完初始化后再操作,
    符号引用就是一组符号来描述引用的目标。

过程三:Initialization

初始化阶段就是执行类构造器方法(clinit())的过程,
这个方法不需要定义,就是javac编译器自动收集所有类的变量赋值动作 和 静态代码块中的语句,合并来的一个clinit() 方法,
如果这个类有父类,JVM会保证子类的clinit()方法执行前,父类的clinit()方法已经执行完毕。
虚拟机必须保证一个类的clinit()方法在多线程下被同步加锁,对一个类的clinit()方法,虚拟机只加载一次clinit()方法。

类加载器

JVM支持两种类型的类加载器:引导类加载器(Bootstrap Classloader) 和 自定义类加载器。
实现抽象类ClassLoader的类加载器都划分为自定义类加载器。

public class ClassLoaderTest {
    public static void main(String[] args) {

        //获取系统类加载器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

        //获取其上层:扩展类加载器
        ClassLoader extClassLoader = systemClassLoader.getParent();
        System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d

        //获取其上层:获取不到引导类加载器
        ClassLoader bootstrapClassLoader = extClassLoader.getParent();
        System.out.println(bootstrapClassLoader);//null

        //对于用户自定义类来说:默认使用系统类加载器进行加载
        ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
        System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

        //String类使用引导类加载器进行加载的。---> Java的核心类库都是使用引导类加载器进行加载的。
        ClassLoader classLoader1 = String.class.getClassLoader();
        System.out.println(classLoader1);//null

    }
}

虚拟机自带的加载器

扩展类加载器(Extension ClassLoader)
派生于ClassLoader类,父类加载器为启动类加载器,
从java.ext.dirs系统属性所指定的目录中加载类库,或者从JDK安装目录的jre/lib/ext 子目录(扩展目录)下加载类库。
如果用户创建的jar 放在这个目录下,也会由扩展类加载器加载

应用程序加载器(系统类加载器,AppClassLoader)
派生于ClassLoader类,父类加载器为扩展类加载器,
负责加载环境变量classpath或系统属性java.class.path 指定路径下的类库,
该类加载器是程序中默认的类加载器,一般java应用的类都由它来完成加载
通过ClassLoader#getSystemClassLoader()方法获得该类加载器

public class ClassLoaderTest1 {
    public static void main(String[] args) {
        System.out.println("**********启动类加载器**************");
        //获取BootstrapClassLoader能够加载的api的路径
        URL[] urLs = sun.misc.Launcher.getBootstrapClassPath().getURLs();
        for (URL element : urLs) {
            System.out.println(element.toExternalForm());
        }
        //从上面的路径中随意选择一个类,来看看他的类加载器是什么:引导类加载器
        ClassLoader classLoader = Provider.class.getClassLoader();
        System.out.println(classLoader);  // null

        System.out.println("***********扩展类加载器*************");
        String extDirs = System.getProperty("java.ext.dirs");
        for (String path : extDirs.split(";")) {
            System.out.println(path);
        }

        //从上面的路径中随意选择一个类,来看看他的类加载器是什么:扩展类加载器
        ClassLoader classLoader1 = CurveDB.class.getClassLoader();
        System.out.println(classLoader1);//sun.misc.Launcher$ExtClassLoader@1540e19d

    }
}

用户自定义加载器

为什么需要自定义加载器

  • 隔离加载类
  • 修改类的加载方式
  • 扩展加载源
  • 防止源码泄露

自定义类加载器时,继承ClassLoader类,重写findClass()方法,
如果没有特别复杂的需求,直接继承URLClassLoader类,可以避免自己编写findClass()方法及获取字节码流的方式,更加简洁。

ClassLoader获取方法

ClassLoader类是一个抽象类,所有的类加载器(除了启动类加载器)都继承自ClassLoader

双亲委派机制

如:我们自己创建个java.lang包下,创建一个String.java类,
image

然后我们正常去使用的话,它是不生效的,用的还是我们原来的最顶级的那个String
image

可以看到String类里面的那个静态代码块的代码没有执行,这样确保了安全性,如果这个String生效的话,那假如项目里之前的很多地方肯定偶会用的String,岂不是乱套了。
这就是双亲委派机制。

原理:

  1. 如果一个类加载器收到了类加载请求,并不会自己先加载,而是把这个请求委托给父类的加载器去执行

  2. 如果父类加载器还存在父类加载器,会进一步向上委托,依次递归,请求到达顶层的启动类加载器

  3. 如果父类加载器可以完成加载任务,就返回,如果无法完成任务,子加载器才会尝试自己加载

如此就可以说明一个问题,比如:
image

沙箱安全机制

如上面,如果在自定义的String 类里面写个main方法,启动肯定会报错,这个就是因为存在沙箱安全机制,就是不让篡改

运行时数据区

方法区和堆都是红色的,表示的是和JVM进程同步的,进程执行它俩也执行,进程结束它俩也关闭,
其他的三个:程序计数器、本地方法栈、虚拟机栈,都是和进程中的线程对应同步的,比如有5个线程,那就分别有5组的程序计数器、本地方法栈、虚拟机栈,它们共用的方法区和堆空间。

JVM支持多线程的,每个线程都和操作系统的本地线程直接映射,一个Java线程准备执行时候,本地线程也同时创建,然后执行java线程中的run()方法,java线程执行终止,本地线程也回收。
当run()方法执行异常时,java线程终止,但本地线程会进行初始化,来判断JVM要不要终止,JVM要不要终止取决于最后一个线程是不是非守护线程,如果都是守护线程,本地线程就终止了。

所谓守护线程,是指在程序运行的时候在后台提供一种通用服务的线程,比如垃圾回收线程就是一个很称职的守护者,并且这种线程并不属于程序中不可或缺的部分。因此,当所有的非守护线程结束时,程序也就终止了,同时会杀死进程中的所有守护线程。反过来说,只要任何非守护线程还在运行,程序就不会终止。

用户线程和守护线程两者几乎没有区别,唯一的不同之处就在于虚拟机的离开:如果用户线程已经全部退出运行了,只剩下守护线程存在了,虚拟机也就退出了。 因为没有了被守护者,守护线程也就没有工作可做了,也就没有继续运行程序的必要了。

将线程转换为守护线程可以通过调用Thread对象的setDaemon(true)方法来实现。在使用守护线程时需要注意一下几点:

  1. thread.setDaemon(true)必须在thread.start()之前设置,否则会跑出一个IllegalThreadStateException异常。你不能把正在运行的常规线程设置为守护线程。

  2. 在守护线程中产生的新线程也是守护的。

  3. 守护线程应该永远不去访问固有资源,如文件、数据库,因为它会在任何时候甚至在一个操作的中间发生中断。

程序计数器

JVM中的pc寄存器 是对物理pc寄存器的一种抽象模拟,它是软件层面的概念,也称为程序钩子。(表示的是去钩一行一行的代码)

pc寄存器用来存储指向下一条指令的地址

指令都压在栈当中,pc寄存器去指向下一条指令,然后执行引擎就去执行这条指令,依此类推一直执行这些指令,可以理解pc寄存器就是存储这些指令的地址。所以它是很小的内存空间,几乎可以忽略不计,也是运行速度最快的存储区域。

JVM中每个线程都有自己的程序计数器,是线程私有的,生命周期和线程的生命周期是保持一致的。

任何时间一个线程都有一个方法在执行,也就是所谓的当前方法。
程序计数器存储当前线程正在执行的java方法的JVM指令,如果在执行native方法,则是未指定值(undefined)。因为这是java虚拟机,如果指定C的指令,那就指定不出来了。

pc寄存器没有垃圾回收,也没有OOM异常

举例
定义一个测试类,然后使用javap -v 【class文件名】来反编译

自己追着指令找找就可以得到下面结论
image

常见问题:
为什么使用pc寄存器记录字节码指令?
如下:

CPU中的执行引擎在执行不同线程来回切换,执行完一个线程的指令后,回头来不知道执行到哪了??

pc寄存器在每个线程中是私有的,因为如果是共有在每个线程执行中,会被覆盖指定地址,这也不行。

虚拟机栈 *

概念
基于跨平台性的设计,java指令都是根据栈来设计的。
栈的优点是跨平台,指令集小,编译器容易实现,相比较pc寄存器来说,缺点是性能下降,实现同样的功能需要更多的指令。

一些Java开发人员提到java内存结构,就会理解为只有堆和栈,这是为啥呢?
首先这样理解肯定是不够全面的,不过堆和栈确实是java内存结构非常重要的两个概念。

栈是运行时的单位,堆是存储的单位
意思就是,栈解决程序运行问题,即程序怎么执行,怎么处理数据。 堆解决的是数据存储的问题,即数据怎么放,放到哪。

java虚拟机栈,之前也叫java栈,每个线程创建时都会创建一个虚拟机栈,内部保存的是一个一个的栈帧,对应着一次一次的java方法调用。
虚拟机栈是线程私有的。 作用就是保存方法的局部变量(8种基本数据类型、对象引用地址等)、部分结果,参与方法的调用和返回。
虚拟机的生命周期和线程一致。

举例
如果现在要执行一段代码:

调用methodA()方法时,它里面调用了methodB()方法,那么在栈里面是这样的:

栈的优点就是快速有效分配存储方式,访问速度仅次于程序计数器,对于栈的操作只有两个(压栈、出栈),对于栈来说不存在垃圾回收问题,但存在OOM异常。

栈中异常

栈中可能出现的异常
虚拟机允许栈的大小是动态的或者时固定的。
如果是固定大小的栈,每个线程的虚拟机栈容量可以在线程创建时独立选定,线程请求分配的栈容量超过java虚拟机栈允许的最大容量,虚拟机就会抛出一个StackOverflowError异常。

如果是动态大小的虚拟机栈,在尝试扩展时候无法申请到足够的内存,或者在创建新的线程时候没有足够的内存对应着虚拟机栈,就会抛出OutOfMemoryError异常。

演示栈的异常
写个异常的测试类:

运行这个类抛出异常:

设置栈内存大小

先定义一个异常类测试

先不设置栈的内存大小,启动后看执行多少次报错

这个相当于默认大小,设置栈的大小-Xss256k,设置它的VM options 的值
image

再次启动测试类,报错:打印的次数

windows系统的默认栈的大小根据电脑内存的大小决定的,如果是其它系统
image

栈的存储结构

每个线程都有自己的栈,栈中的数据以栈帧为单位。
方法和栈帧时对应的,一个方法的执行对应一个栈帧的入栈,一个方法的执行结束对应一个栈帧的出栈。

一个时间点上只有一个活动的栈帧,只有这个正在执行的栈帧是有效的,这个栈帧称为当前栈帧,与当前栈帧对应的方法称为当前方法,定义这个方法的类称为当前类
执行引擎运行的字节码指令只针对当前栈帧进行操作,如果方法中调用了其他方法,对应新的栈帧会被创建出来,成为新的当前栈帧

比如几个方法互相嵌套,方法1调用方法2,方法2调用方法3,方法3调用方法4

出栈的顺序刚好是相反的,先进后出原则,不会出现嵌套情况,就比如写代码时候的大括号,不可能是嵌套,只能包裹着里面的另一个代码块。

栈的运行原理

不同线程包含的栈帧不允许相互调用,就是一个栈帧不能引用另一个线程的栈帧。
如果当前方法调用其他方法,方法返回时候,当前栈帧会得到调用的方法直接的结果给前一个栈帧(就是上一个调用这个方法,当前方法的栈帧),调用的方法那个栈帧就会出栈,下一个栈帧称为当前栈帧。

java方法返回函数方式有两种:一种是正常的返回,使用return,另一种是出现异常就返回,不管哪种方式返回,这个栈帧都会出栈。
意思就是一直向上抛异常,只要上面一直没有处理这个异常,就一直向上抛异常。

栈帧内部结构

栈帧存着:

  • 局部变量表*
  • 操作数栈 *(操作栈、表达式栈)
  • 动态链接(指向运行时常量池的方法引用)
  • 方法返回地址(方法正常或异常退出的定义)
  • 一些附加信息

image

同理,如果是多个线程,每个线程都有多个栈帧,每个栈帧也都存放这5样

局部变量表

局部变量表也叫局部变量数组。
表的感觉是二维的,但在局部变量里是一维的,一个数字数据,用来存储方法的参数和定义在方法内的局部变量。
局部变量里存储的包括8中基本数据类型,对象引用,返回类型。

因为局部变量表是建立在线程的栈里面,是线程私有的数据,所以不存在数据安全问题。

局部变量表需要的容量大小是在编译时候确定下来的,所以在方法运行时候不会更改局部变量表的容量大小。

举例
写个测试类,然后用jclasslib打开,可以看到Maximum local variables表示的是局部变量表的容量大小是3,Code length是表示16个指令。
image

方法嵌套调用的次数由栈的大小决定,栈越大表示方法嵌套调用的次数越多。
局部变量表的变量只在当前方法调用中有效,方法调用结束,随着对应方法的栈帧销毁,局部变量表也会跟着销毁。

字节码中方法解析

使用jclasslib工具查看,首先打比方看main方法
image
组合起来就是public static void main

看下面这个code,Bytecode 表示的是0到15,一共执行16条指令
image
Exception table 表示异常信息

查看下Misc 可以查看局部变量表容量大小,执行指令的最大行号大小
image

再往下看LineNumberTable,表示的是指令的行数和java代码中行数的对应关系
image

继续看下面的LocalVariableTable 表示变量信息
image

然后滑倒右边,可以查看变量的引用类型
image

变量槽slot

参数值存在总是在局部变量表下标index0位置开始,到长度-1时结束。

局部变量表最基本的存储单元时Slot(变量槽)
在局部变量表里面,32位以内的类型只占用一个slot(包括返回值类型),64位的类型(long和duble)占用2个slot。
byte、short、char 在存储前被转为int,boolean也转为int,0表示false,非0表示true。

JVM会为局部变量表中每个slot分配一个访问的索引,通过索引可以访问局部变量表中指定的值。当一个实例方法调用,它的方法参数和方法内定义的局部变量都会按顺序复制到局部变量表中的每个slot上

如果访问一个64位的局部变量,只需要使用前一个索引就行了。
如果当前栈帧有构造方法或实例方法创建的,该对象引用this将会存放在index为0的slot上。

举例
image
如图:test2 方法时非静态的,所以起始位置0存放的时this,dateP和name2是两个入参,weight是double类型,所以占用3和4两个slot,所以gender变量直接就到5的位置了。

栈帧中的局部变量表槽位是可以重用的,如果局部变量过了作用域,可以在这个槽存放其他新的变量,这个设计是为了节省资源。

栈帧中,与性能调优关系最密切的部分就是局部变量表,方法执行时,虚拟机使用局部变量表完成方法传递。

局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表直接或间接引用的对象都不会被回收。

操作数栈

操作数栈:在方法执行过程中(执行字节码指令过程),根据字节码指令往栈中写入或读取数据,就是入栈、出栈的操作。

某些字节码指令将值压入操作数栈,其余字节码指令将操作数取出栈,执行后,把结果的数值再压入栈。

java虚拟机的解释引擎是基于栈的执行引擎,这里的栈就是操作数栈。
如果被调用的方法带有返回值的话,返回值会被压入当前栈帧的操作数栈,并更新PC寄存器中下一条需要执行的字节码指令。

操作数栈主要用于保存计算过程的中间结果,作为计算过程中变量临时的存储空间。
当一个方法刚开始执行时候,一个新的栈帧也会被创建出来,这个方法的操作数栈是空的,但是这个操作数栈是定义好固定大小了。

image

右边是把java代码解析成字节码指令,字节码指令CPU是不认识的,执行引擎把字节码指令翻译成机器指令,然后执行。

操作数栈的字节码指令执行分析
第一步: PC寄存器、局部变量表、操作数栈,都是空的,定义的15放在了操作数栈的栈顶

istore_1的意思:把int类型的15这个数值取出来放在局部变量表下标1的位置,因为0的位置是this,PC寄存器也一直在同步更新自己的调用指令地址。

第二步:和第一步一样,8的数值放在局部变量表
image

第三步: 把15 和 8 这两个数值从局部变量表(局部变量表索引1和2的位置)取出来,放在操作数栈
image

第四步: 把两个数值求和操作,然后把结果放在局部变量表索引3的位置,然后return结束。
image

动态链接

字节码的一些指令可能是定义的常量,指向常量池,栈帧内部都包含一个指向运行时常量池里面的所属方法的引用,这个方法为了支持当前方法代码能够实现动态链接,动态链接也有的叫 指向运行时常量池的方法引用

java源文件被编译到字节码文件中时候,所有的变量和方法引用都作为符号引用保存在class文件的常量池中。
image
动态连接的作用就是把这些符号引用转换为调用方法的直接引用。

字节码中的常量池运行起来后,这个常量池就放到方法区了,这个方法区是运行时候进来的,所以就叫运行时常量池,栈帧就指向常量池的值。
image

为啥需要常量池?
为了提供一些符号和常量,便于指令识别。
意思就是一个类编译成字节码后,字节码里面的东西并不是这个类本身的所有东西,比如具体的数据类型和其他很多东西,但也不可能全都放在字节码文件中,有的数值可能是相等的,那就可以用同一个数值引用到不同的类使用,这样也能优化,所以字节码中只要存下符号引用就行了。

方法返回地址

就是PC寄存器的值。
一个方法的结束分两种:

  • 正常执行完
  • 未执行完结束(可能异常了)

无论哪种方式结束,方法结束后都返回到该方法的被调用位置。方法结束时,调用者的PC计数器的值作为返回地址,也就是该方法执行的下一条指令的地址。如果是异常结束,返回地址要通过异常表来确定,栈帧中一般不会保存这部分信息。就是说异常结束后,不会给它的上层调用者产生任何返回值。

本地方法栈

java虚拟机栈是管理java方法的调用,本地方法栈是管理本地方法的调用。
本地方法栈,也是线程私有的。允许被实现成固定或可动态扩展的内存大小,本地方法是C语言实现的。

具体做法是虚拟机栈当中登记本地方法,需要调用本地方法时候把它压入本地方法栈。
主要是和本地方法库打交道的。

当一个线程调用本地方法是,就会进入全新的并且不受虚拟机限制的世界,和虚拟机拥有同样的权限。通过本地方法接口访问虚拟机的运行时数据区、本地处理器的寄存器、堆中分配的任意内存。

并不是所有jvm都支持本地方法,因为java虚拟机规范没有明确要求本地方法栈的使用语言和实现方式以及数据结构。如果jvm不打算支持本地方法,也可以不用实现本地方法栈。

Hotspot JVM 虚拟机中,直接把本地方法栈和虚拟机栈合二为一了。

堆 *

一个jvm实例对应着本地操作系统的一个进程,这个jvm里主要有类加载系统、运行时数据区、执行引擎,堆和方法区是运行时数据区里面作用在整个进程范围内的,一个jvm实例只有一个堆内存。栈、本地方法栈、pc寄存器 都是作用在线程范围内。

栈主要来负责执行,堆主要用来存放,
堆在jvm启动时候就被创建,堆空间大小也确定了,堆是jvm里面最大的一块内存空间。堆内存大小是可以调节的。

验证 一个jvm实例只有一个堆内存
编写两个测试demo:

public class HeapDemo {
    public static void main(String[] args) {
        System.out.println("start...");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("end...");
    }

}

第二个:

public class HeapDemo1 {
    public static void main(String[] args) {
        System.out.println("start...");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("end...");
    }
}

分别设置这两个类的堆空间,初始化大小(-Xms) 和 最大大小(-Xmx)

设置第二个类

设置好以后,把这两个类都启动起来,不要结束
使用一个工具来观察验证:
以jdk1.8 版本为例,在jdk安装位置,找到bin目录下的jvisualvm.exe 文件,双击打开

打开后,如果没有Visual GC插件,要安装这个插件,
如果在安装插件时报代无法连接Java VisualVM插件中心,先到 https://visualvm.github.io/pluginscenters.html 这个网址上找到与你jdk版本相对应的url

然后在 可用插件 搜索Visual GC,安装
然后重新打开jvisualvm.exe,确认那两个类还在启动中
可以发现
image

第二个类:
image

所以就验证了,每个jvm实例只存在一个堆内存。

堆空间对象创建

所有线程共享堆空间,但还可以划分线程私有的缓冲区,也就是TLAB。
因为堆空间是线程共享的,就会出现线程安全问题,所以在堆空间划分出一块空间(TLAB),这个空间分为多个很小的空间,给不同线程使用,避免线程安全问题。

数组和对象可能永远不会存在栈上面,因为栈帧保存的引用,这个引用指向对象或数组 在堆中的位置。

在方法执行结束后,对应的栈帧已经出栈了,但是这个时候虽然堆里面的这些对象没有栈里面对应的引用,这些对象也不会立马就被移除,会等到垃圾回收的时候再做判断是否移除。

我们平常使用new 来创建对象时候,执行new 的时候,就会在堆里面创建对象,开辟一个空间,jvm在堆空间去初始化实例变量。

堆是GC(垃圾回收)的重点区域。
一直执行GC的话就一直清理对象,很影响用户访问性能,所以要GC调优时候避免总是GC。

堆内存细分

细化就是分代,分为三部分:
image

image

堆内存其实指的是新生代和老年代,永久区是不算在堆内存空间大小的

虚线部分是永久代,
之前用jvisualvm.exe测试的时候,可以发现新生代(伊甸园、幸存0、幸存1区)和老年代 的堆空间大小加起来刚好等于设置的堆空间大小。

堆空间大小设置和查看

堆空间大小在jvm启动时候就设定好了,可以通过-Xmx-Xms设置

  • -Xms 等价于 -XX:InitialHeapSize
  • -Xmx 等价于 -XX:MaxHeapSize

一旦堆内存大小超过指定的最大内存,就会抛出OutOfMemoryError异常

通常会把最小起始内存(-Xms)和最大内存(-Xmx)两个参数设置相同的值,目的是为了垃圾回收清理完堆空间后,不需要重新分隔堆的大小,从而提高性能。

默认情况,初始大小是物理内存大小的 1/64,最大内存为1/4。

查看堆内存 方式1

public class HeapSpaceInitial {
    public static void main(String[] args) {

        //返回Java虚拟机中的堆内存总量
        long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
        //返回Java虚拟机试图使用的最大堆内存量
        long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;

        System.out.println("-Xms : " + initialMemory + "M");
        System.out.println("-Xmx : " + maxMemory + "M");

        System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
        System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");
    }
}

运行后:
image
我的电脑本身是16G内存,除了操作系统和其他的消耗内存,剩下的大约14、15个G。

设置堆的大小

然后修改代码,线程休眠一会儿,别让它那么快结束

public class HeapSpaceInitial {
    public static void main(String[] args) {

        //返回Java虚拟机中的堆内存总量
        long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
        //返回Java虚拟机试图使用的最大堆内存量
        long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;

        System.out.println("-Xms : " + initialMemory + "M");
        System.out.println("-Xmx : " + maxMemory + "M");
        
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

启动后打印575M 的堆内存大小

打开命令行窗口,使用jstat -gc 【进程号】查看新生代和老年代
image

  • S0C 表示幸存0区
  • S1C 表示幸存1区
  • S0U 表示幸存0区使用多少内存
  • S1U 表示幸存1区使用多少内存
  • EC 表示伊甸园区
  • EU 表示伊甸园区使用多少内存
  • OC 表示老年代
  • OU 表示老年代使用多少内存

为什么设置的是600m 堆空间,而最终得到575m 大小?
因为幸存0区 和 幸存1区 只有一个使用的状态,另一个是保持为空的,所以就没计算其中的一个内存,故而是575m

查看堆内存 方式2

代码修改:去掉线程休眠

public class HeapSpaceInitial {
    public static void main(String[] args) {

        //返回Java虚拟机中的堆内存总量
        long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
        //返回Java虚拟机试图使用的最大堆内存量
        long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;

        System.out.println("-Xms : " + initialMemory + "M");
        System.out.println("-Xmx : " + maxMemory + "M");
    }
}

添加堆的启动配置-XX:+PrintGCDetails打印参数

启动后就会打印出来
image
因为有一个幸存区是不放数据的,所以只加一个25600

OOM问题说明

OOM 就是 堆空间溢出了,想要出现这个异常很容易,不停的往堆空间一直存放,给他挤爆就行了

测试代码:

public class OOMTest {
    public static void main(String[] args) {
        ArrayList<Picture> list = new ArrayList<>();
        while(true){
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            list.add(new Picture(new Random().nextInt(1024 * 1024)));
        }
    }
}

class Picture{
    private byte[] pixels;

    public Picture(int length) {
        this.pixels = new byte[length];
    }
}

给这个测试类设置堆大小为600m,然后启动,使用jvisualvm.exe 点开查看
可以看到堆空间没一会就满了,相应的程序也应该结束报错了
image

如果程序没有停止的情况下,选择【抽样器】,点击【内存】,可以明显查看报错的原因,可以看到byte数组特别大

年轻代和老年代

jvm中java对象可以分为2类:

  • 一种是生命周期很短的对象,创建和销毁都很快的
  • 一种是生命周期很长,某些情况下能和jvm生命周期保持一直

堆区进一步细分的话,可以分为年轻代 和 老年代。

年轻代又可以分为伊甸园区(Eden) 和 幸存0区(Survivor0)、幸存1区(Survivor1),有时也叫form区 和 to区。

配置新生代和老年代在堆中的占比:

  • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,这样新生代就是占整个堆的1/3
  • 可以修改:-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5大小。

代码举例验证

public class EdenSurvivorTest {
    public static void main(String[] args) {
        System.out.println("我只是来打个酱油~");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

设置这个类的堆空间:-Xms600m -Xmx600m
然后启动后打开jvisualvm.exe

可以看到默认确实是1:2 的占比

也可以通过命令行看占比

伊甸园区和两个幸存区比例的问题
在HotSpot中,Eden空间和两个幸存区空间的占比是8:1:1,可以通过选项-XX:SurvivorRatio调整这个占比。
如:-XX:SurvivorRatio=8

命令查看一下

但是150:25:25 明明是6:1:1 的比例,显然不是8:1:1,
因为这里会有一个自适应的问题,如果想关闭自适应,要设置:-XX:-UseAdaptiveSizePolicy,打开和关闭取决于加减号
image

  • -XX:-UseAdaptiveSizePolicy 关闭自适应
  • -XX:+UseAdaptiveSizePolicy 打开自适应

但测试过后发现设置自适应没什么用的,还是6:1:1的比例,那就只能通过-XX:SurvivorRatio来设置伊甸园区和幸存区比例
image

设置好以后启动,重新打开jvisualvm.exe发现比例正确

可以看到160:20:20 也就是8:1:1

几乎所有的java对象都在伊甸园区被new出来的,
绝大部分的java对象销毁是在新生代进行的。

可以使用-Xmn设置新生代的空间的大小。 (一般不设置)

对象分配

为新对象分配内存是件非常严谨和复杂的任务,不仅要考虑在哪分配,还要考虑分配算法和内存回收算法,所以还要考虑GC执行完内存回收后是否产生内存碎片。

一般情况过程

new对象先放到伊甸园区
当伊甸园区满的时候,程序有需要创建对象,垃圾回收器就对伊甸园区进行轻GC,判断哪些是垃圾,如果不是垃圾就放到空的幸存区,也就是to区
幸存区满的时候是不会触发GC的,伊甸园区触发GC的时候,会把幸存者区一起进行回收

如果再次触发垃圾回收,上次幸存下来的在幸存0区的对象没有被回收,就会放在幸存1区,因为这时候幸存1区是空的,所以幸存1区是to区
以此类推,每次放在空的to区,

在每次切换幸存区时候会给这些存活的对象添加一个年龄属性,记录经历的回收次数,如果超过默认的15次还没有被回收掉,就进入到养老区。
可以通过设置参数修改默认的15次:-XX:MaxTenuringThreshold=【次数】

特殊情况

GC回收的逻辑流程

情况1:
伊甸园区进程轻GC的时候,如果要存活的对象在幸存者区放不下,就会直接放到养老区。

情况2:
伊甸园区经过轻GC后,创建的对象太大了在伊甸园区放不下,直接放在老年代,
老年代如果放得下,就分配内存,
老年代如果放不下,执行重GC,
如果执行完重GC还是放不下,就OOM了

代码演示分配过程

public class HeapInstanceTest {
    byte[] buffer = new byte[new Random().nextInt(1024 * 200)];

    public static void main(String[] args) {
        ArrayList<HeapInstanceTest> list = new ArrayList<HeapInstanceTest>();
        while (true) {
            list.add(new HeapInstanceTest());
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

配置堆的大小
image

启动后,打开jvisualvm.exe查看

可以通过图像看出来,伊甸园区是每次都经过了轻GC,然后又满了
两个幸存者是交替存放的
老年代是持续往里面存放,直至放慢抛出异常

常用工具概述

  • jdk命令行
  • Jconsole
  • VisualVM
  • Jprofiler
  • GCViewer
  • GC Easy

JProfiler

推荐安装JProfiler11版本,
然后idea也安装JProfiler 插件,

安装好以后点击使用JProfiler启动
image

选择jprofiler.exe

点击

点击ok

然后就可以使用了,左边栏是个分栏查看不同的内存使用情况

Minor GC 、Major GC 和 Full GC

JVM进行GC时,并不是对三个内存(新生代、老年代、整个堆空间)一起回收的,大部分指的是回收新生代。

HotSpot VM 的实现GC安装回收区域又分为两种类型:

  • 部分收集:表示不是收集整个堆的垃圾收集,分为:
    • 新生代收集(Minor GC / Young GC):只是新生代垃圾收集
    • 老年代收集(Major GC / Old GC):只是老年代垃圾收集
      目前只有CMS GC 会有收集老年代的行为。
      很多时候Major GC 和 Full GC 混淆使用,需要分辨时老年代回收还是整堆回收
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代
      目前只有G1 GC 会有这种行为
  • 整堆收集(Full GC):收集整个java堆 和 方法区 的垃圾收集。

年轻代GC(Minor GC)触发机制:

  • 当年轻代空间不足时会触发,表示的时伊甸园区满的时候,幸存区满是不会触发的。
  • java对象大多都是朝生夕灭的特性,所以Minor GC 非常频繁,回收速度也快。
  • Minor GC 会引发STW,暂停其他用户线程,垃圾回收结束其他线程才恢复运行。

老年代收集(Major GC / Old GC)触发机制:

  • 表示对象从老年代消失
  • 大多数出现Major GC 时至少执行过一次Minor GC
  • Major GC 的速度比Minor GC慢10倍以上,STW时间更长。
  • 如果Major GC 后,内存还是不足,就OOM了

Full GC 触发机制:
大概分为5种:

  • 调用System.gc()时,系统建议执行Full GC
  • 老年代空间不足
  • 方法区空间不足
  • 通过Minor GC 后进入老年代的对象大于老年代的可用空间大小
  • 通过伊甸园区、幸存区时,对象进入到老年代后,对象大于老年代的可用空间大小

开发或调优中要尽量避免使用Full GC

举例GC 日志分析

public class GCTest {
    public static void main(String[] args) {
        int i = 0;
        try {
            List<String> list = new ArrayList<>();
            String a = "abiu";
            while (true) {
                list.add(a);
                a = a + a;
                i++;
            }

        } catch (Throwable t) {
            t.printStackTrace();
            System.out.println("遍历次数为:" + i);
        }
    }
}

设置打印GC信息
image

启动
image

可以看到,经历过Young GC 和 Full GC,
第一行2006k:表示的是执行Young GC 之前的新生代(包括幸存区)大小,
488k:表示的是Young GC之后的新生代(包括幸存区)大小,
2560k:表示记录新生代(包括幸存区)总空间大小,
后面的2006k 和 792k 表示堆空间执行前后的大小,
9728k 表示堆空间总大小

下面其他的包括Full GC 也是一样的意思,最后到了老年代(OldGen)空间不足,抛了异常

堆空间分代思想

其实不分代也可以,分代的理由就是优化GC。
如果没有分代,那就是把所有的对象都放在一个空间,每次GC都要对所有的对象再次判断是否要垃圾回收。
如果分代的话,就可以把一些不用经常回收的对象放在另一个地方(老年代),新生代放的都是需要频繁回收的对象。

内存分配策略

  • 优先分配到Eden
  • 大的对象直接分配到老年代
    比如一个对象比伊甸园区都大,就算没那么夸张,它是个很大的对象,在伊甸园区的话就要频繁的进程gc。
  • 长期存活的对象分配到老年代
    意思就是根据这个对象的年龄来判断
  • 动态对象年龄判断
    如果两个幸存区在一直交替转换很多年龄相同的对象,这些对象的总大小超过了幸存区大小的一半,这些对象就不用等到15次了,直接就进入老年代。
  • 空间分配担保
    -XX:HandlePromotionFailure

测试验证

public class YoungOldAreaTest {
    public static void main(String[] args) {
        byte[] buffer = new byte[1024 * 1024 * 20];//20m
    }
}

配置堆空间,并配置打印gc日志
image

直接设置好总大小60m,那么老年代就是40m,新生代就是20m,然后代码里是直接new了一个20m大小的对象,这个对象按理说应该是直接放在老年代,因为伊甸园区肯定放不下

启动,查看控制台输出

可以看到没有GC的打印信息,只显示了执行完后,栈的空间信息

TLAB

TLAB(Thread Local Allocation Buffer),本地线程缓冲区

堆区是线程的共享区域,任何线程都可以访问堆区共享数据。
因为对象实例创建在JVM中很频繁,所以并发环境下从堆中划分出内存空间是线程不安全的。
所以为了避免多线程操作同一地址,需要加锁,也就影响了分配速度。

内存模型角度来说,对Eden区继续划分区域,这个区域是JVM为每个线程分配的一个私有缓存区,是在Eden区的。

多线程同时分配内存时,使用TLAB可以避免非线程安全问题,还能提高内存分配吞吐量,这种内存分配方式称 快速分配策略

所有OpenJDK衍生出来的JVM都提供了TLAB的设计。

可以使用-XX:UseTLAB 设置是否开启TLAB空间。

public class TLABArgsTest {
    public static void main(String[] args) {
        System.out.println("我只是来打个酱油~");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

不用配置,直接启动,然后打开命令行

可以看到TLAB默认是开启的。

默认情况下,TLAB内存空间非常小,占有整个Eden区1%,可以使用-XX:TLABWasteTargetPercent 设置TLAB空间和Eden区比例大小。

一旦对象在TLAB空间分配失败,JVM尝试通过使用加锁机制确保数据操作原子性,直接在Eden区分配内存。

TLAB分配过程
image

堆空间常用参数设置

  • 测试堆空间常用的jvm参数:
  • -XX:+PrintFlagsInitial : 查看所有的参数的默认初始值
  • -XX:+PrintFlagsFinal :查看所有的参数的最终值(可能会存在修改,不再是初始值)
    • 具体查看某个参数的指令:
      • jps:查看当前运行中的进程
      • jinfo -flag SurvivorRatio 进程id
  • -Xms:初始堆空间内存 (默认为物理内存的1/64)
  • -Xmx:最大堆空间内存(默认为物理内存的1/4)
  • -Xmn:设置新生代的大小。(初始值及最大值)
  • -XX:NewRatio:配置新生代与老年代在堆结构的占比
  • -XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
  • -XX:+PrintGCDetails:输出详细的GC处理日志
    • 打印gc简要信息:① -XX:+PrintGC ② -verbose:gc
  • -XX:HandlePromotionFailure:是否设置空间分配担保
    • 表示轻GC之前,JVM会检查老年代的最大可用空间是否大于新生代的所有对象总大小,
      • 如果大于,轻GC就是安全的,
      • 如果小于,就会查看-XX:HandlePromotionFailure的设置是否允许担保失败,
        • 如果设置为true,会检查是否大于之前的老年代最大可用空间的平均值,
          • 如果大于就会进行轻GC,但这个仍然有风险的。
            & 如果小于,就直接进行一次Full GC。
      • 如果设置为false,就直接进行Full GC。

逃逸分析查看堆

JVM中对象在堆中分配内存是一个普遍的常识。
但是有特殊情况,如果经过逃逸分析(Escape Analysis)后发现一个对象并没法逃逸出方法的话,就可能被优化成栈上分配。只有就无需在堆上分配,也无需进行垃圾回收。这是常见的堆外存储技术。

逃逸分析基本行为就是分析对象动态作用域:

  • 一个对象在方法中定义,对象只在方法内使用,就认为没有发生逃逸。
  • 一个对象在方法中定义后,被外部方法引用,就认为发生逃逸。比如作为参数传递其他方法中。

没有发生逃逸,就分配到栈上。

比如:
image

上面代码如果想要sb对象不逃出方法,可以优化成这样写:
image

总结一句话就是,如果想快速判断是否发生逃逸,就看new的对象是否在外部调用。
开发中能使用局部变量的,就不要在方法外部调用。

在JDK6 以后,HotSpot中默认就开启的逃逸分析。

如果没有发生逃逸,可以对代码优化:

  1. 栈上分配。把堆分配转化为栈分配。
    如果一个对象在子程序中被分配,要想指向这个对象指针永远不会逃逸,对象可能是栈分配,不是堆分配

  2. 同步省略。
    如果一个对象被发现只能从一个线程被访问,这个对象可以不考虑同步。

  3. 分离对象或标量替换。
    有的对象肯不需要作为连续的内存结构存储也可以被访问到,那对象可以不存储在内存,而是存在CPU寄存器。

优化之栈上分配

没有发生逃逸的,就可以考虑栈上分配。

JIT编译器在编译期间如果发现一个对象没有发生逃逸出方法的话,就可能被栈上分配。
分配完后,继续调用栈内存执行,最后线程结束,栈空间也被回收,那局部变量对象也被回收,就不用进行垃圾回收了。

常见的栈上分配场景比如:
给成员变量赋值,方法返回值,实例引用传递。

代码测试

public class StackAllocation {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        for (int i = 0; i < 10000000; i++) {
            alloc();
        }
        // 查看执行时间
        long end = System.currentTimeMillis();
        System.out.println("花费的时间为: " + (end - start) + " ms");
        // 为了方便查看堆内存中对象个数,线程sleep
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    }

    private static void alloc() {
        User user = new User();//未发生逃逸
    }

    static class User {

    }
}

配置:-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
设置堆内存1G,关闭逃逸分析,打印GC信息
image

执行后:

打开jvisualvm.exe
内存中有10000000 个实例

然后修改配置为开启逃逸分析:-Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
image

执行后:

打开jvisualvm.exe

这个对象实例就不会有1000000这么多在内存中了

还可以测试GC次数
设置:-Xmx256m -Xms256m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
堆的内存为256m,这样肯定要触发GC的,然后关闭逃逸分析
image

执行后:

修改设置为:-Xmx256m -Xms256m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
堆内存还是256m,但要开启逃逸分析
image

执行后查看:

执行依然很快,并且连GC都没有触发,可见效率有多高。

优化之同步省略

线程同步的代价很高,因为降低了并发性和性能。

JIT编译器借助逃逸分析判断同步块使用的锁对象是否只能被一个线程访问没有发布到其他线程。
如果没有,那JIT编译器在编译同步块时候就会取消代码块同步,这就提高并发性和性能。
这个取消同步的过程叫同步省略,也叫锁消除。

比如:

上面这个代码的虽然有点问题的,锁的hollis对象生命周期在f()方法内,不会被其他线程访问,所以JIT编译器在编译时就会优化为:

优化之标量替换

标量是指一个无法分解成更小的数据了,这个数据。
java中原始数据类型就是标量。

还可以分解的数据叫做聚合量。Java对象就算聚合量,比如一个类作为属性存在另一个类,那么这个属性还可以分解,所以调用这个属性类的类肯定就是聚合量。

在JIT编译时候,经过逃逸分析发现一个对象不会被外界访问,就把这个对象拆解成多个包含多个成员变量来代替。
这个过程叫标量替换

如下代码:

经过标量替换后,这个alloc()方法就变成:

这样的好处就是减少堆内存的占用,因为不用创建对象了,就不用分配堆内存了。

变量替换设置: -XX:+EliminateAllocations默认是开启的,允许把对象打散放在栈上

方法区 *

方法区保存的是类的信息,类加载系统执行的时候,把class字节码文件类的信息都加载到方法区了

方法区和堆都是线程共享的数据
方法区可以看作是一块独立于堆的内存空间

方法区在JVM启动时候就被创建,方法区的大小跟堆空间一样可以选择固定大小或者可扩展。
方法区的大小决定系统可以保存多少个类,如果定义太多的类,方法区就溢出了,报错OOM(OutOfMemoryError),jdk7叫PernGen space,jdk8叫做Meatespace

关闭JVM后就会释放方法区的内存。

方法区的类是非常多的,我们随便写个很简单的类,启动后打开jvisualvm.exe可以看到,加载的类非常多的
image

JDK7之前,都把方法区叫做永久代,JDK8开始,方法区叫元空间。
JDK8元空间的内存使用的是本地物理内存,不是JVM里面的内存。所以说元空间不在虚拟机设置的内存中。

方法区设置内存大小

jdk7及以前

  • -XX:PermSize 设置永久代初始分配空间,默认是20.75m
  • -XX:MaxPermSize 设定永久代最大分配空间,32位的机器默认是64m,64位的机器默认是82m

JVM加载的类信息超过设定的值,就报错OOM

上面的是jdk7及之前的,如果是jdk8,是没有PermSize参数的
把测试的模块使用JDK8版本,然后启动
image

启动后使用命令查看可以发现问题

切换成jdk7试试
image
image

启动,然后使用命令行查看

然后用21757952 / 1024 / 1024 = 20.75
用85983232 / 1024 / 1024 = 82

jdk8及以后

元空间大小在windows下

  • -XX:MetaspaceSize 是21m左右
  • -XX:MaxMetaspaceSize 是-1,表示没有限制

元空间和永久代不同,如果不指定大小,默认情况会耗尽所有可用的系统内存,如果元空间发生溢出,JVM一样会报错OOM

把测试的模块设置称JDK8
image
image

启动,打开命令行

计算21807104 / 1024 / = 20.796875

测试验证,设置:-XX:MetaspaceSize=100m -XX:MaxMetaspaceSize=100m
image

然后启动

使用104857600 / 1024 /1024 = 100

如果设置的参数改成jdk7的,会报错过时

方法区内部结构

方法区存放的信息如下:
image

类型信息

每个加载的类型(类class、接口interface、枚举enum、注解annotation),jvm必须在方法区存储以下信息:

  1. 类型的完整有效名称(全面=包名.类名)
  2. 类型直接父类的完整名(对于接口或是java.lang.Object,都没有父类)
  3. 类型的修饰符(public、abstract、final 的某个子集)
  4. 类型直接接口的有序列表

在保存类型信息的时候,域信息也都保存了

域信息

也就是成员变量

  • jvm必须在方法区保存所有域信息的相关信息及声明顺序
  • 相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient的某个子集)

方法信息

JVM必须保存所有方法信息,同域信息一样要包括生命顺序

  • 方法名称
  • 方法放回类型
  • 方法参数数量和类型
  • 方法修饰符
  • 方法字节码、操作数栈、局部变量表及大小
  • 异常表
    每个异常处理的开始位置、结束位置、代码执行在程序计数器偏移地址、被捕获的异常类常量池索引

演示方法区存放的类型信息、域信息、方法信息
测试代码:

public class MethodInnerStrucTest extends Object implements Comparable<String>,Serializable {
    //属性
    public int num = 10;
    private static String str = "测试方法的内部结构";
    //构造器
    //方法
    public void test1(){
        int count = 20;
        System.out.println("count = " + count);
    }
    public static int test2(int cal){
        int result = 0;
        try {
            int value = 30;
            result = value / cal;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    @Override
    public int compareTo(String o) {
        return 0;
    }
}

把这个类Build以后,找到对应的class文件,使用命令反编译
javap -v -p 【class文件】

  • -v 反编译class文件
  • -p 反编译的时候,有一些小的比如定义的局部变量,也可以反编译显示出来查看

image

因为在Terminal窗口可能不太好看,所以我把它输出到相对路径下的一个test.txt文件
打开这个test.txt文件
image

non-final类变量

静态变量和类关联一起,随着类的加载而加载,静态变量是类数据的一部分。
类变量被类的所有实例共享,就算没有new对象也可以通过类名直接调用它。

测试代码:

public class MethodAreaTest {
    public static void main(String[] args) {
        Order order = null;
        order.hello();
        System.out.println(order.count);
    }
}

class Order {
    public static int count = 1;
    public static final int number = 2;


    public static void hello() {
        System.out.println("hello!");
    }
}

虽然赋值了为null,运行后可以看到是不会抛异常的
image

全局变量:static final
被声明final类变量处理方法不同,在编译时候就被分配了。

找到build后字节码路径,把这个Order.class文件反编译字节码
image

找到对应定义的两个变量:count和number

number加了final修饰,所以直接赋值2,而count可以看到没有赋值1

运行时常量池

运行时常量池 VS 常量池
方法区内部包含了运行时常量池
字节码文件内部包含了常量池,是非常大的
image

为什么需要常量池
一段代码就算再小,也会有很多引用的结构,比如输出打印:System.out.println("hello");使用到了String、System、PrintStream、Object等结构,常量池只存符号的引用,在使用的时候只要通过符号的引用关联使用就好了。(自己可以通过反编译出来字节码的#井号查找,可以对应上具体的代码)

总结一句话就是,常量池可以看作是一张表,虚拟机质量根据这常量池表找到要执行的类名、方法名、参数类型、字面量等类型。

运行时常量池
运行时常量池就是字节码里面的常量池经过类加载子系统存放在方法区内的,叫做运行时常量池。

在加载类和接口到虚拟机后,就会创建对应的运行时常量池。

方法区演进细节

首先明确,只有HotSpot才有永久代,其他的很多是不存在永久代的概念。

HotSpot中方法区的变化

  • jdk1.6及之前:

  • jdk1.7:

  • jdk1.8及之后:
    image

  • jdk1.6及之前 有永久代,静态变量放在静态变量上

  • jdk1.7 有永久代,但已经逐步失去,字符串常量池、静态变量移除,保存在堆中

  • jdk1.8及以后 没有永久代,类型信息、字段、方法、常量,保存在本地内存元空间,但是字符串常量池、静态变量还是保存在堆中

jdk1.8之后基本固定下来了

永久代为什么要被元空间替换

  1. 永久代设置空间大小很难确定。
    某些场景动态加载类过多,容易产生方法区OOM,在运行过程中,要不断动态加载很多,经常造成致命错误。

  2. 对永久代进行调优很复杂。
    方法区主要回收的内容:常量池中废弃的常量 和 不再使用的类型。
    常量来说相对简单,没人使用那就回收。对于类的话,校验非常多,校验完以后还要再判断是否回收。
    总之就是说判断类中不再使用这件事是很浪费时间的。
    所以就用本地内容,避免Full GC。

本地方法接口

主要来说本地方法库,执行引擎过后的东西

什么是本地方法

一个Native Method就是一个java调用非java代码的接口,该方法由非java语言实现。比如C,这个特征不是java特有的。

主要和java环境外交互,是本地方法存在的主要原因。比如java需要和一些底层系统的某些硬件交互时,她为我们提供一个简洁的接口(C和C++),而且我们不需要了解java之外的那些繁琐的细节。

jvm毕竟是虚拟的系统,不是真正的操作系统,而操作系统是C编写的,所以需要java和它交互。

目前为止本地方法越来越少了,除非是和硬件有关的应用。

对象实例化

创建对象的方式

  • new
  • 反射的newInstance(),通过构造器
  • 使用clone():不调用构造器,实现Cloneable接口
  • 反序列化:把文件或网络中获取的二进制流转成对象
  • 第三方库

创建对象的步骤

  1. 判断对象对于的类是否加载、链接、初始化
    虚拟机收到new指令,先去检查指令的参数能否在方法区的常量池定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经加载、链接、初始化。(就是判断这个类元信息是否存在)

    如果没有,双亲委派模式下,使用类加载器以ClassLoader+包名+类名作为key进行查找class文件。如果没有找到文件,就抛异常ClassNotFoundException

    如果有找到,就进行类加载,生成class类对象

  2. 给对象分配内存
    先计算对象需要空间大小,在堆中划分内存,如果实例成员变量是引用变量,只分配引用变量空间,也就是4个字节。

    • 如果堆内存规整,虚拟机使用指针碰撞法给对象分配内存。
    • 如果堆内存不规整,虚拟机需要维护一个列表。意思是虚拟机维护了一个列表,记录了那些内存是可用的,分配的时候从列表找到一块够用的空间划分给对象实例,再更新列表内容。这个分配方式叫空闲列表

    选择哪种方式由堆是否规则决定的,对是否规则由采用的垃圾回收算法决定。

  3. 处理并发安全问题

    • 采用CAS失败重试、区域加锁保证更新原子性
    • 每个线程在Eden区分配一块TLAB。通过-XX+UseTLAB设置默认是开启的
  4. 初始化分配的空间
    就是赋值默认的初始化的值
    默认初始化、显示初始化、代码块中初始化、构造器初始化

  5. 设置对象头
    把对象所属类元数据、HashCode、GC信息、锁信息等数据存储在对象的对象头中。具体设置方式取决于JVM实现。

  6. 执行init方法,真正开始初始化

对象内存布局

  • 对象头(Header)

    • 运行时元数据:
      • 哈希值
      • GC分代年龄
      • 锁状态标志
      • 线程持有锁
      • 偏向线程ID
      • 偏向时间戳
    • 类型指针
      • 指向类元数据,确定对象所属类型

    如果是数组,还需记录数组长度

  • 实例数据
    是对象真正存储的有效信息,包括程序代码定义各种类型和父类继承下来的字段

    规则:

    • 相同宽度的字段总在分配在一起
    • 父类中定义的变量会出现在子类
    • 如果CompactFields参数为true(默认就为ture),子类窄变量可能插入父类变量空隙
  • 对其填充
    不是必须的,也没特别定义,是占位符的作用

代码说明:

public class Customer{
    int id = 1001;
    String name;
    Account acct;

    {
        name = "匿名客户";
    }
    public Customer(){
        acct = new Account();
    }

}
class Account{

}

在main方法newCustomer实例对象

public class CustomerTest {
    public static void main(String[] args) {
        Customer cust = new Customer();
    }
}

image

对象访问定位

  • 句柄访问

好处:如果实例池的对象数据发生修改位置,句柄池的指向也要发生修改,但是reference是不用修改的

  • 直接指针(HotSpot采用这种)

好处:和句柄访问相比,reference是要修改的

直接内存

直接内存不是虚拟机运行时数据区里面的,直接内存是堆外的、直接向系统申请的内存空间。
来源于NIO,通过存在堆的DirectByteBuffer操作Native内存。
访问直接内存速度比java堆的读写性能高,所以读写频繁的场景下可以采用直接内存,NIO允许java程序使用直接内存,用于数据缓冲区。

读写数据需要和磁盘交互,需要由用户态切换到内核态,在内核态需要内存如上图操作
但是如果使用直接内存,就避免了用户态和内核态的切换,如下图:

因为系统内存也是有限的,直接内存如果超出系统的可用内存,也会报错OOM。

直接内存缺点:

  • 分配回收成本较高
  • 不受JGM内存回收管理

设置直接内存大小

默认和堆的最大值-Xmx一样大
可以通过-XX:MaxDirectMemorySize=【值大小】设置

执行引擎

虚拟机是一个相对于物理机的概念,两种机器都有指向代码能力,区别是物理机的执行引擎是直接建立在处理器、缓存、指令集、和操作系统的层面,而虚拟机的执行引擎是由软件自行实现的,可以不受物理条件约制指令集和执行引擎的结构体系,能执行不被硬件直接支持的指令集格式。

JVM主要是负责装在字节码到内部,但是字节码并不能直接运行在操作系统上,因为字节码并不等于本地机器指令,它里面是只能被JVM识别的字节码指令、符号表、和其他信息。

执行引擎的任务就是把字节码指令解释/编译成对应平台上的本地机器指令才可以让程序运行。

执行引擎就是把高级语言翻译成机器语言的翻译者。

编译和解释

大部分程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要以上步骤。

Java是半编译半解释的语言,是因为既可以使用解释器,也可以使用编译器。

  • 解释器(Interpreter):java虚拟机启动时根据定义的规范把字节码一行一行的解释出来,每个字节码文件的内容翻译成本地机器的指令。

  • JIT编译器:虚拟机把源代码编译成本地机器使用的机器语言的指令。

为何解释、编译 共存

解释器是一行一行解释出来的

JIT编译器是直接编译成平台指令,所以应能高很多。
既然这样,为什么还需要解释器?

当程序启动,解释器可以马上发挥作用,省去编译的时间,立即执行。
编译器要想发挥作用,把代码编译成本地代码,需要一定时间,编程为本地代码后,执行效率高。

何时用编译器

java的编译期是一段不确定操作,可能是前编译器(Java语言编译成class文件),也可能是后编译器(JIT编译器)

当jvm执行到使用解释器还是编译器的时候,根据代码被调用执行的频率判断,需要被编译成本地代码的字节码,称为热点代码。JIT编译器运行时对频繁被调用的热点代码做出深度优化,编译成对应平台的本地机器指令,以此提升程序执行性能。

设置执行方式

开发人员可以根据具体应用场景,使用命令为虚拟机指定到底是完全才哦那个解释器执行,还是完全采用编译器执行

  • -Xint 完全采用解释器执行
  • -Xcomp 完全采用即时编译器执行,如果编译器出问题,解释器就会顶上
  • -Xmixed 采用解释器+编译器混合模式执行

也可以使用idea在启动时候设置,如:
image

HotSpot虚拟机中内嵌的是两个JIT编译器,分别为Client和Server,简称为C1和C2编译器。
开发人员可以通过命令指定虚拟机使用哪种编译器:

  • -client 指定虚拟机在Client模式下,并使用C1编译器。
    C1编译器对字节码进行简单和可靠的优化,耗时短,达到编译最快速度。

  • -server 指定运行在Server模式下,并使用C2编译器。
    C2耗时长以及激进优化,但优化代码执行效率更高。

64位操作系统默认的就是server的使用C2编译器。

StringTable

字符串是final 不可被继承的,

jdk1.8使用的是char数组

jdk1.9使用的是byte数组

因为国外很多语言都是字母形式的,但中文是汉字,一个汉字肯定不能用char,要用一个byte,所以人家会根据encodeing-flag 一个标识来判断,如果是字母就用一个byte去存,如果是其他的就用两个字节去存。

String不用char[]来存储,改成byte加上编码标识,节约了一些空间。
StringBuffer和StringBuilder相应的也会做一些修改。

String具有不可变性,如果定义过了一个字符串,正常情况下不管用什么方法再去修改这个字符串,虽然打印的返回结果是修改过后的,但其实原来定义的那个还是存在常量池的,它是又重新去定义了一个新的字符串。

String的Hashtable结构

String的String Pool是一个固定大小的Hashtable,jdk1.6时默认长度是1009,如果放进的Stirng很多,就会造成Hash冲突,导致链表很长,会影响调用时的性能。

使用-XX:StringTableSize=【值大小】 设置StringTable的长度
jdk1.6 StringTable是固定的,就是1009长度,修改长度没有限制

jdk1.7 StringTable默认长度60013

jdk1.8 如果要设置长度,1009是可设置的最小值。否则启动报错,我设置1000试一试

String内存分配位置

为了让String和基本数据类型在运行过程中速度更快,更节省内存,提供了常量池的概念。
常量池类似java系统提供的缓存,8种基本数据类型的常量池都是系统协调的,String常量池比较特殊,主要方法两种:

  • 直接使用双引号生命出来,如果String对象存在常量池,就直接使用之前存在的这个数值
    比如:String info = "abiu";

  • 如果没有双引号声明的String对象,可以使用String的intern()方法。

jdk6及以前,字符串常量池在永久代。
jdk7中,字符串常量池在堆中,和其他普通对象一样。
jdk8虽然是元空间,字符串常量池还是在堆中。

因为永久代默认比较小,容易溢出,永久代的垃圾回收频率也低,如果有很多字符串不用了,也没有进行回收,加上本来空间小,很容易OOM

字符串变量拼接的原理

	String s1 = "javaEE";
        String s2 = "hadoop";

        String s3 = "javaEEhadoop";
        String s4 = "javaEE" + "hadoop";//编译期优化
        //如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果:javaEEhadoop
        String s5 = s1 + "hadoop";
        String s6 = "javaEE" + s2;
        String s7 = s1 + s2;

        System.out.println(s3 == s4);//true
        System.out.println(s3 == s5);//false
        System.out.println(s3 == s6);//false
        System.out.println(s3 == s7);//false
        System.out.println(s5 == s6);//false
        System.out.println(s5 == s7);//false
        System.out.println(s6 == s7);//false
        
        //intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址;
        //如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回次对象的地址。
        String s8 = s6.intern();
        System.out.println(s3 == s8);//true

原因:

	String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        /*
        如下的s1 + s2 的执行细节:(变量s是我临时定义的)
        ① StringBuilder s = new StringBuilder();
        ② s.append("a")
        ③ s.append("b")
        ④ s.toString()  --> 约等于 new String("ab")

        补充:在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer
         */
        String s4 = s1 + s2;
        System.out.println(s3 == s4);//false
  1. 字符串拼接操作不一定使用的是StringBuilder!
    如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式。
  2. 针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。
	// 改成常量
        final String s1 = "a";
        final String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2;
        System.out.println(s3 == s4);//true

拼接字符 和 append()

执行下面代码:

	long start = System.currentTimeMillis();
        String src = "";
        for (int i = 0; i < highLevel; i++) {
            src = src + "a";//意味着每次循环都会创建一个StringBuilder、String
        }
        long end = System.currentTimeMillis();
        System.out.println("花费的时间为:" + (end - start));

使用时间:

执行如下代码:

	long start = System.currentTimeMillis();
        //只需要创建一个StringBuilder
        StringBuilder src = new StringBuilder();
        for (int i = 0; i < highLevel; i++) {
            src.append("a");
        }
        long end = System.currentTimeMillis();
        System.out.println("花费的时间为:" + (end - start));

使用时间:

可以体会到执行效率:通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式!

详情:

  1. StringBuilder的append()的方式:自始至终中只创建过一个StringBuilder的对象
    使用String的字符串拼接方式:创建过多个StringBuilder和String的对象
  2. 使用String的字符串拼接方式:内存中由于创建了较多的StringBuilder和String的对象,内存占用更大;如果进行GC,需要花费额外的时间。

改进的空间:
在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化:

StringBuilder s = new StringBuilder(highLevel);//new char[highLevel]

intern()

在String类找到intern()方法,如果是jdk1.8的话,在String的最下面:

使用的native,这个方法上面的注释大概意思是:如果调用这个intern()方法
当使用定义了一个字符串,会先使用equsls()方法,如果字符串常量池已经有了相同的字符串,就不会再次存放到常量池,而是把之前已经存在的这个字符串的引用返回给你。
反之,如果字符串常量池没有要定义的字符串,就会去new一块空间返回返回引用地址。

垃圾回收

什么是垃圾

指的是在运行程序中没有任何指针指向的对象,这个对象就是需要回收的垃圾

如果不及时进行垃圾回收清理内存,这些垃圾对象一直占用内存空间直至程序结束,占用的空间不能被其他对象使用,还可能导致内存溢出

为啥需要GC

如果不进行垃圾回收,内存迟早会被消耗完,因为不断的分配内存空间而不进行回收,就好比不停生产垃圾但不清理。

除了回收垃圾对象,也可以清理内存里面的记录碎片,碎片整理把堆内存移到堆的一端,JVM把整理出来的内存分配给新的对象。

Java是自动垃圾回收的,无序开发人员手动参与内存分配和回收,降低了内存泄漏和内存溢出的风险。
把程序员从繁重的内存管理中释放出来,可以更专心专注于业务开发。

自动内存管理像是黑匣子,过度依赖将会是一场灾难,最严重的是弱化了开发人员在出现内存溢出问题后的定位和解决问题的能力。所以一定要理解垃圾回收。

垃圾回收算法

垃圾回收分标记和回收,所以就分为两部分,怎么标记?怎么回收?

标记阶段

堆里面放着几乎所有的对象实例,在GC执行前,需要分出内存中哪些是存活对象,那些事已经死亡的对象。标记为已经死亡的对象,GC才会回收,这个过程称垃圾标记阶段

当一个对象不再被任何存活对象继续引用,就可以宣判为以及死亡。
判断对象存活的方式一般2种:引用计数算法可达性分析算法

引用计数算法

引用计数算法是对每个对象保存一个整型的引用计数器属性,记录对象被引用的情况。
只要有一个对象引用了这个对象,这个对象的引用计数器就+1,引用失效时就-1,如果这个对象的引用计数器为0,表示该对象不再被引用,可以回收。

优点:实现简单,便于辨识,判断的效率高,回收也没有延迟性。

缺点

  • 需要单独的存储计数器,增加了存储空间
  • 每次赋值需要更新计数器,一直增减操作,增加了时间
  • 无法处理循环引用的情况,这个缺陷很致命,所以垃圾回收器没有使用这类算法

可达性分析算法

相对于引用计数算法而言,可达性分析算法不仅实现简单和执行高效,更重要的时可以有效解决在引用计数算法中的循环引用的问题,防止内存泄漏。

GC Roots根集合是一组必须活跃的引用。
基本思路:

  • 以根对象集合为起点,从上往下搜索被根对象集合连接的目标对象是否可达。
  • 使用可达性分析算法后,内存中的存活对象都会被根对象直接或间接连接着,搜索走过的路径称为引用链
  • 如果目标对象没有任何引用链相连 ,是不可达的,意味着该对象已经死亡,标记为垃圾对象。
  • 只有能被根对象集合连接着的对象才是存活对象。

比如上图中,5、6、7 对象因为不是根对象关连的,所以都是垃圾对象。

finalization机制

是允许对象被销毁之前处理开发人员自定义的逻辑。当对象被回收之前,总会调用这个对象的finalize()方法。

finalize()方法是Object类中的,允许在子类中被重写,用于对象回收时释放资源,这个方法通常进行一些资源释放和清理的工作,如:关闭文件、数据库连接等

永远不要主动调用对象的finalize()方法,应该交给垃圾回收器,理由:

  • 调用finalize()时可能导致对象复活
  • finalize()执行时间没有保证,完全由GC线程决定,一般如果不发生GC,finalize()就没有机会执行
  • 糟糕的finalize()严重影响GC性能

如果所有根节点对象都无法访问一个对象,说明这个对象已经不再使用,但也不一定会被回收,分情况的:虚拟机中的对象分为3种状态

  • 可触及的:从根节点开始可以访问到
  • 可复活的:对象所有引用都被释放,但对象在finalize()中被复活
  • 不可触及的:对象的finalize()被调用,并且没有复活,就是不可触及的状态。这种状态不可能被复活,因为finalize()只会被调用1次!

所以对象只有是不可触及的状态才会被回收。

也就是说判断对象是否可回收,至少需要2次标记过程:

  1. 对象和GC Roots(根对象)没有引用链,就标记一次
  2. 进行筛选判断对象有没有必要执行finalize()方法
    1. 如果对象没有重写finalize()方法,或者finalize()方法已经被调用过了,肯定就要被回收了
    2. 如果重写了finalize()方法但还没有执行过,这个对象会插入到一个队列中,由低优先级的Finalizer线程触发finalize()方法执行
    3. finalize()方法是对象逃脱死亡的最后机会,如果对象的finalize()方法中存在和任何一个对象的连接,就会被移除“即将回收”的集合。
      image

JProfiler查看GC Roots

测试代码:

public class GCRootsTest {
    public static void main(String[] args) {
        List<Object> numList = new ArrayList<>();
        Date birth = new Date();

        for (int i = 0; i < 100; i++) {
            numList.add(String.valueOf(i));
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println("数据添加完毕,请操作:");
        new Scanner(System.in).next();
        numList = null;
        birth = null;

        System.out.println("numList、birth已置空,请操作:");
        new Scanner(System.in).next();

        System.out.println("结束");
    }
}

使用JProfiler把程序跑起来
image

点击OK

点击左边的Live memory,可以看到动态内存的情况,可以发现有个char数组很大
image

点击view -> Mark Current Values,标记当前对象的值

可以发现这些对象占用很大,并且还无法回收,
image

点击右键Show Selection In Heap Walker,然后点击OK
image

可以看到它的分配(Allocations),是否有大对象(Biggest Objects),有关的引用(References),这个引用是我们最关心的,所以点击References
image

选择Incoming references显示对象相关引用,点击要溯源的对象(选中第二行char数据),点击右边的Show Paths To GC Root查看它相关的引用,点击OK
image

image

JProfiler分析OOM

测试代码:

public class HeapOOM {
    byte[] buffer = new byte[1 * 1024 * 1024];//1MB

    public static void main(String[] args) {
        ArrayList<HeapOOM> list = new ArrayList<>();

        int count = 0;
        try{
            while(true){
                list.add(new HeapOOM());
                count++;
            }
        }catch (Throwable e){
            System.out.println("count = " + count);
            e.printStackTrace();
        }
    }
}

设置参数:-Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
image

-XX:+HeapDumpOnOutOfMemoryError表示有OOM的时候,生成一个堆里面的大文件

允许代码,会报错:

然后在程序根目录可以找到这个文件

双击这个文件
image

点击Biggest Objects 可以看到这个产生的超大对象

清除算法

当成功区分内存中存活和死亡的对象,GC接下来就是执行垃圾回收。

目前JVM常见的3种垃圾收集算法:

  • 标记-清除算法(Mark-Sweep)
  • 复制算法(Copying)
  • 标记-压缩算法(Mark-Compact)

标记-清除算法

标记-清除算法(Mark-Sweep)是非常基础和常见的垃圾收集算法。

执行过程:
堆内存的有效空间被用完时,就会停止整个程序,然后进行两项工作:标记、清除

  • 标记:Collector从引用的根节点遍历,标记所有被引用的对象,一般对象的Header中记录可达对象
  • 清除:Collector从头到尾线性遍历,如果某些对象在Header中没有标记为可达对象,就回收

优点是比较简单,容易理解

缺点:

  • 效率不高
  • GC时候,需要停止整个应用程序,用户体验差
  • 清理出来的空闲内存不是连续的,产生内存碎片,所以需要维护一个空闲列表

何为清除?
清楚不是真正的置空,是把清楚的对象地址保存在空闲的地址列表里面,下次有新对象使用,判断是否够用,够用的话就存放进去。

复制算法(Copying)

为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky在1963年发表论文,使用双存储区的垃圾收集器,被人们称为复制算法

核心思想:
把活着的内存空间分为两块,每次只使用一块,垃圾回收时把正在使用的存活对象赋值到另一个未被使用的内存块中,然后进行垃圾回收,之前使用的那个内存块就是空的了,两个内存块形成角色互换,完成了垃圾回收。

优点:

  • 没有标记和清除的过程,实现简单,运行高效
  • 复制过去后保证空间的连续性,不会出现内存碎片

缺点:

  • 使用了双倍的内存空间
  • 对G1这种拆分称大量的分区GC,复制而不是移动,意味着GC需要维护分区之间对象引用关系。
  • 系统中垃圾对象很多时,复制算法需要复制的存活对象不能太大,应该说非常少才行

标记-压缩算法(Mark-Compact)

复制算法的高效性时建立在存活对象少、垃圾对象多的前提下,这种情况在新生代经常发生,但是在老年代大部分对象都是存活的,所以要使用其他算法。

标记-清除算法的确可以应用在老年代,但是效率低,而且容易尝试内存碎片,所以JVM进行改进,标记-压缩算法由此诞生。

执行过程:

  • 第一阶段:和标记-清除算法一样,标记所有被引用的对象
  • 第二阶段:把所有存活对象压缩到内存的一端,按顺序排放,然后清理边界外的所有空间

标记-压缩算法的最终效果等同于标记-清除算法执行后,再进行一次内存碎片整理,所以也可以称为标记-清除-压缩算法。

优点:

  • 消除了标记-清除算法中,内存区域分散的缺点,给新对象分配内存时,JVM只需要持有内存起始地址即可
  • 消除了复制算法中,内存减半的高额代价

缺点:

  • 效率上来说,标记-压缩算法低于复制算法
  • 移动对象同时,如果对象被其他对象引用,需要调整引用地址
  • 对象移动过程中,需要全程暂停用户应用程序,即:STW

分代收集算法

不同的对象生命周期不同,所以不同生命周期的对象采用不同的收集方式,以便提高效率。
一般是把java堆分为新生代和老年代,根据各个年代不同的特点使用不同回收算法。

目前所有的GC都是采用分代收集算法进行垃圾回收的。

年轻代:
年轻代特点:空间相比老年代来说较小,对象的生命周期短,存活率低,回收频繁。
这种情况使用复制算法,速度最快,幸存区相比伊甸园区是8:1:1的比例,所以每次复制的对象也不会太多,可以得到缓解。

老年代:
老年代特点:区域大,对象生命周期长,存活率高,回收不频繁。
所以采用标记-清除算法或标记-压缩算法混合实现。

增量收集算法

之前上面所说的算法,垃圾回收时应用程序处于STW状态,程序的所有线程都会挂起,如果垃圾回收时间过长,严重影响用户体验和系统稳定性,所以有了增量收集算法。

基本思想:
如果一次性把所有垃圾处理,造成系统长时间停顿,所以让垃圾收集线程和应用程序的线程交替执行,垃圾收集线程只收集一小块内存空间,接着切换到应用程序线程,依次反复,知道垃圾收集完成

增量收集算法基础还是标记-清除和复制算法,是对线程冲突的处理,允许垃圾收集线程分阶段的标记、清理、复制。

缺点:
这种方式造成线程切换和上下文转换的消耗,使垃圾回收成本上升,造成系统吞吐量下降

分区算法

一般来说,堆空间越大,一次GC执行的时间就越长,GC产生的停顿也越长。
所以将一块大的内存分割成多个小块,根据停顿时间,每次合理回收若干个小区间,从而减少一次GC产生的停顿。

分代算法按照对象生命周期分为两部分,分区算法将堆空间分为连续的小区间。

每个小区间独立使用,独立回收,这样可以控制一次回收多少个小区间。

把堆空间分为一个一个的小块,这些小块有的是存放伊甸园区的,有的是存放幸存区的,有的是存放老年代的

垃圾回收概念

System.gc()

调用System.gc()时候,会显示触发Full GC,对老年代和新生代同时回收,释放内存。

System.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用。(调用了不一定就真的执行,只是提醒要进行垃圾回收)

一般情况垃圾回收是自动进行的,无需手动触发,否则太过于麻烦。
特殊情况下,可以在编写性能基准时候调用System.gc()

内存溢出

虽然栈也会溢出,但一般都是堆溢出,栈没那么容易溢出。

一般情况,除非应用程序内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现OOM的情况

大多数情况,GC会进行各种年龄段的垃圾回收,实在不行了就进行一次Full GC,回收大量内存,程序继续使用。

java中对OOM的解释是:内存空间不够了,并且进行垃圾回收后空间还是不够,就OOM了

堆内存不够的原因有二:

  • 堆内存设置不够,也可能时内存泄漏的原因
  • 代码中创建了大量的大对象,并且这些对象不能被回收(因为存在被引用)

随着元空间引入,方法区没有那么窘迫,OOM有所改观。

内存泄漏

只有对象不再被程序使用,但是GC又不能回收这些对象,才叫做内存泄漏。

实际情况很多时候,我们在写代码时的疏忽导致对象生命周期很长导致OOM,也可以叫做内存泄漏

比如:一些提供close的资源链接:数据库连接、网络连接等,连接必须手动close,但是自己没有进行close,导致引用没有关闭,不能被回收。

STW(Stop-the-World)

指GC时间发生过程中,会产生程序的停顿,整个应用程序被暂停,没有任何响应,这种停顿称为STW。

比如:GC垃圾回收时候,要根据GC Roots遍历查找对象垃圾,这时候如果程序还在允许中,对象的连接一直在发生变化,无法保证一致性,所以要STW。所有的垃圾回收器都有STW。

STW会使程序暂停,用户体验极不好,所以要减少STW的发生。

安全点

程序不是在所有地方都能停顿下来开始GC,只有特定的位置才可以GC,这些位置称为安全点。

安全点选择很重要,如果太少可能导致GC等待时间太长,如果安全点太多会很频繁的GC导致运行时性能的问题。通常根据是否具有让程序长时间执行的特征为标准。
比如:选择一些执行时间较长的指令,如:方法调用、循环跳转、异常跳转等

如果保证GC时所有线程跑到安全点停顿?

  • 抢险式中断(目前没有虚拟机采用了)
    中断所有线程,如果线程在不安全点,就恢复线程,让线程跑到安全点。

  • 主动式中断
    设置中断标志,所有线程运行到安全点时就轮询这个标志,判断为真,就中断挂起。

安全区域

安全点保证了程序运行时,在不太长的时间内就进入到GC的安全点,但如果程序不执行呢?
比如线程处于休眠或阻塞状态,就无法响应JVM中断请求,这时就需要设置一个安全区域来解决。

安全区域值代码段中,对象的引用关系不发生变化,这个区域的任何位置都可以GC。也可以把安全区域看成扩展的安全点。

强引用--不回收

比如new对象方式创建出来的对象,就属于强引用,任何情况下,只有强引用关系存在,垃圾回收机器就不会回收被引用的对象。就算OOM也不回收。

强引用是最常见的,系统上90%都是强引用,就算常见的对象引用,也是默认类型引用。

强引用时可触及的(能根据GC Roots找到),所以垃圾收集器永远不会回收。所以强引用是造成OOM主要原因。

示例代码:

public class StrongReferenceTest {
    public static void main(String[] args) {
        StringBuffer str = new StringBuffer ("Hello");
        StringBuffer str1 = str;

        str = null;
        System.gc();

        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(str1);
    }
}

把str对象引用给了str1,然后进行了一次GC,但是执行代码后还是可以打印出hello

软引用--内存不足回收

系统发生内存溢出之前,因为内存不足了,会把软引用的对象回收。内存够的话就不会回收。

软引用用来描述还有用,但不是必需的对象(必需的话那就使用强引用了),GC回收之前会把这些软引用对象进行回收,如果还是没有足够的内存,就会报OOM

比如:高速缓存就用到软引用。

软引用类似于弱引用,只不过虚拟机让它存活的时间更久一点,不得已才会回收。

弱引用--发现就回收

只要进行垃圾回收,不管堆内存空间是否充足,软引用对象就会被回收。

软引用和弱引用都非常适合保存可有可无的缓存数据,系统内存不足时就被回收,不会导致OOM,内存资源充足时,这些缓存数据又可以存活很长时间,加速了系统使用。

虚引用--回收跟踪

对象有没有虚引用完全不会对生存时间构成什么影响,也无法通过虚引用获得对象实例。
虚引用唯一目的是能在对象被回收时收到一个系统通知。

垃圾回收器

GC的性能指标

  • 吞吐量:运行用户代码时间占总运行时间的比例。(总运行时间 = 程序运行时间 + 内存回收的时间)

  • 垃圾收集开销:垃圾收集所用时间和总运行时间的比例。

  • 暂停时间:执行垃圾收集时,工作线程被暂停的时间。(就是STW的时间)

  • 收集频率:相对于程序的执行,收集发生的频率。(频率越高,每次收集越快,频率越低,每次收集越久)

  • 内存占用:堆区所占用的内存大小。

  • 快速:对象从创建到回收经历的时间。

吞吐量和暂停时间对比

吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)

暂停时间 指一个时间段内应用程序线程暂停,然后让GC线程执行

高吞吐量 和 低暂停时间 是一对矛盾的目标

  • 如果选择吞吐量优先,必然需要降低回收执行频率,这样会导致GC需要更长时间暂停,来执行内存回收。

  • 如果选择低延迟优先,为了降低每次垃圾回收的时间,只能频繁的执行内存回收,这又导致吞吐量下降。

设计GC算法时,只能针对一个目标,
G1回收器的标准:最大吞吐量优先的情况,降低停顿时间。
比如Parallel回收器主要是吞吐量优先,CMS回收器主要是低延迟。

垃圾收集器组合关系

7款经典的收集器:

  • 串行回收器:Serial、Serial Old
  • 并行回收器:ParNew、Parallel Scavenge、Parallel Old
  • 并发回收器:CMS、G1

与垃圾分代之间的关系:

  • 新生代:Serial、ParNew、Parallel Scavenge
  • 老年代:Serial Old、Parallel Old、CMS
  • 整堆收集:G1

垃圾收集器的组合关系:

垃圾收集器有很多种,因为场景不同,所以根据不同场景提供不同的垃圾收集器。

查看默认垃圾收集器

  • -XX:+PrintCommandLineFlags查看命令行相关参数(包括使用的垃圾收集器)

  • jinfo -flag 查看相关垃圾回收器参数,进程id

代码演示:

public class GCUseTest {
    public static void main(String[] args) {
        ArrayList<byte[]> list = new ArrayList<>();

        while(true){
            byte[] arr = new byte[100];
            list.add(arr);
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

设置打印相关参数:
image

运行代码后:
image

然后使用命令行窗口查看:

可以看到使用了ParallelGC,也就使用了ParallelOldGC,对着它俩的搭配关系

如果查看是否使用了G1GC的话:

显示没有使用G1GC

如果把运行环境改成JDK9的版本,再运行代码:
image

打开命令行窗口,可以使用了G1GC,没有使用UseParallelGC了

Serial和Serial Old回收器:串行回收

Serial回收器是最基本、历史最悠久的垃圾收集器,jdk1.3之前是回收新生代的唯一选择。

Serial回收器采用复制算法、串行回收、STW机制,执行内存回收。

Serial Old回收器执行老年代的垃圾回收,同样采用串行和STW机制,只不过回收算法使用的是标记-压缩。

优点:针对于单线程来说(1个核心的CPU),没有线程交互的开销,可以获得最高单线程收集效率。

-XX:+UseSerialGC设置使用Serial和Serial Old,老年代自动会使用Serial Old垃圾回收器。

ParNew回收器:并行回收

如果说Serial GC是单线程的,那ParNew收集器就是多线程的。

Par是Parallel的缩写,New:只能处理新生代。

ParNew除了采用并行回收,和Serial没有什么区别,也是采用复制算法和STW机制。

因为是多线程的,ParNew是很多JVM再server模式下的新生代垃圾收集器。

除了Serial外,只有ParNew GC能和CMS收集器配合工作。

-XX:+UseParNewGC设置使用parNew收集器,表示年轻代使用并行,不影响老年代。
-XX:ParallelGCThreads设置线程数量,一般不去设置,就算设置,注意不要超过CPU的核数。

如果是jdk8之后的版本,设置使用ParNew 的话,会提示不推荐使用了,所以测试时候要用jdk8版本。
另外要配合CMS使用的话,要加上配置:-XX:+UseConcMarkSweepGC

Parallel回收器:吞吐量优先

HotSpot的年轻代除了有ParNew收集器是并行回收,Parallel Scavenge收集器也采用复制算法、并行回收、STW机制。

那为什么Parallel收集器要出现?

  • 和ParNew收集器不同,Parallel Scavenge收集器目标是达到可控制的吞吐量,它是吞吐量优先的垃圾收集器。
  • 另外Parallel Scavenge 自适应调节策略。

高吞吐量可以高效利用CPU尽快完成程序运算,适合在后台运算而不需要太多交互任务,因此常在服务器环境中使用。
如:批量处理、订单处理、工资支付、科学计算等应用程序。

Parallel收集器在jdk1.6时提供用于执行老年代垃圾收集的Parallel Old收集器,用来代替Serial Old收集器。

Parallel Old收集器采用了标记-压缩算法,同时也是基于并行回收和STW机制。

在java8中,Parallel 收集器 和 Parallel Old收集器作为默认的垃圾收集器。

Parallel垃圾收集器相关参数配置

  • -XX:+UseParallelGC:表明新生代使用Parallel GC
  • -XX:+UseParallelOldGC : 表明老年代使用 Parallel Old GC

说明:二者可以相互激活(如果开启新生代使用Parallel GC,老年代自动就会使用 Parallel Old GC,如果老年代使用 Parallel Old GC,新生代就会自动使用Parallel GC)

  • -XX:ParallelGCThreads 设置年轻代并行收集器的线程数。一般最好是和CPU核数相等。

如果和CPU核数相等,可以最大限制利用CPU,响应的话STW时间也会越短,
如果大于CPU核数,就会有多出来的线程,导致线程的切换。

默认情况下,如果CPU核数小于8,ParallelGCThreads 就和CPU核数相等。
如果CPU核数大于8个,ParallelGCThreads的值等于 3 + (5 * CPU核数 / 8)

  • -XX:MaxGCPauseMillis 设置垃圾收集器最大停顿时间,也就是STW,单位毫秒。(这个参数要慎用)

  • -XX:GCTimeRatio 垃圾收集器占总时间的比例 1 / (N + 1),用于衡量吞吐量大小。默认值99,也就说是 1% 的时间进行垃圾回收。
    -XX:MaxGCPauseMillis参数有一定矛盾性,STW暂停时间越长,就越容易超过设定的比例。

  • -XX:+UseAdaptiveSizePolicy 设置Parallel Scavenge收集器自适应调节策略。默认开启
    这种模式下,年轻代大小,Eden 和 幸存区的比例,晋升老年代的对象年龄等参数会被自动调整。

CMS收集器:低延迟

CMS(Concurrent-Mark-Sweep)收集器,是HotSpot虚拟机中第一款真正意义上的并发处理器,实现了让垃圾收集线程和用户线程同时工作。

CMS垃圾收集器采用标记-清除算法,当然也会STW。

CMS作为老年代的收集器,无法与新生代收集器的Parallel Scavenge配合工作,只能选择ParNew或者Serial收集器中的一个组合使用。

在G1出现之前,CMS使用还是很广泛的,到了jdk14时候,CMS已经被干掉了。

image

  • 初始标记:这个阶段主要任务仅仅是标记出GC Roots能直接关联到的对象,一旦标记成功,就会恢复之前被暂停的应用程序线程。因为直接关联的对象比较小,所以这里的速度非常快。

  • 并发标记:GC Roots的直接关联对象开始遍历整个对象的过程,这个过程耗时比较长,但是不需要停顿用户线程,可以和垃圾收集器的线程一起执行。

  • 重新标记:并发标记时候,程序工作线程和垃圾收集线程同时运行,所以为了修正并发标记时候,因为用户线程继续执行导致标记产生变动的那部分对象的标记记录。这个阶段没有初始标记快,但远比并发标记速度要快。

  • 并发清除:清理删除标记阶段判断为死亡的对象,释放内存。因为不需要移动存活对象,所以这个阶段是和用户线程同时并发的。

CMS的特点

虽然CMS收集器是并发回收,但是初始化标记和再次标记两个阶段时仍热需要STW机制,不过暂停时间不会太长。所以说明目前的所有垃圾收集器都做不到完全不需要STW,只是尽可能缩短时间。

因为最耗时的并发标记和并发清除阶段都不需要暂停,所以整体回收是低停顿的。

垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存空间可用。
当堆内存使用率达到某一阈值,就开始进行回收。

CMS收集器的算法使用标记-清除算法,每次内存回收后,被执行内存回收的无用对象占用的空间很可能是不连续的一些内存块,就很可能产生一些内存碎片。
CMS为新对象分配内存时,无法使用指针碰撞技术,只能选择空闲列表执行内存分配。

CMS弊端

  1. 会产生内存碎片

  2. 对CPU资源非常敏感。
    虽然不会造成用户线程停顿,但因为占用一部分线程导致应用变慢,总吞吐量会降低。

  3. 无法处理浮动垃圾。
    并发标记阶段工作线程和垃圾回收线程是同时运行的,在并发标记阶段如果产生新的垃圾对象,CMS无法对这些对象进行标记,最终导致这些新的垃圾对象没有及时回收,只能等下一次GC去释放之前未被回收的内存空间。

CMS参数设置

  • -XX:+UseConcMarkSweepGC: 手动设置使用CMS垃圾回收器。开启后自动将-XX:UseParNewGC打开。即ParNew + CMS + Serial Old 的组合。

  • -XX:CMSlnitiatingOccupanyFraction:设置堆内存使用的阈值,一旦达到阈值,就进行回收。

  • -XX:+UseCMSCompactAtFullCollection:指定执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片产生。不过因为内存压缩整理过程无法进行并发,所以停顿会很长。

  • -XX:CMSFullGCsBeforeCompaction:设置多少次Full GC后对内存空间进行压缩整理。

  • -XX:ParallelCMSThreads:设置CMS线程数量。默认是(ParallelGCThreads + 3) / 4

如果想要最小化使用内存和并发的开销,选择Serial GC;
如果想要最大化应用程序的吞吐量,选择Parallel GC;
如果想要最小化GC的中断或停顿时间,选择CMS。

G1回收器:区域化分代式

前面有几个强大的GC,为什么还需要Garbage First GC?
随着业务越来越庞大、复杂,用户越来越多,没有GC就不能保证程序正常进行,造成STW的GC跟不上实际的需求,所以不断尝试对GC进行优化。
为了适应不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间,同时兼顾良好的吞吐量。

为什么名字叫做Garbae First (G1) 呢?
因为G1是一个并行回收器,把对内存分割为很多不相关的区域(物理上不连续的),使用不同的Regin表示Eden、幸存区、老年代等。

G1 GC 跟踪各个Regin里面的垃圾堆积的价值大小(回收获得的空间大小及回收所需要的时间),后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Regin。

这种方式侧重点在于回收垃圾最大量的区间,所以起名字:垃圾优先(Garbage First)

在JDK1.7版本中正式启用,JDK9以后,是默认的垃圾回收器,取代了CMS回收器以及Parallel + Parallel Old 组合,被Oracle官方称为“全功能的垃圾收哦机器”
同时CMS在JDK9中被标记为废弃,在JDK8中还不是默认的垃圾回收器,需要使用-XX:+UseG1GC来启用。

G1回收器的特点

G1使用了全新的分区算法:

  • 并行与并发

    • 并行性:G1回收期间,多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW
    • 并发性:G1拥有与程序交替执行的能力,部分工作可以和应用程序同时执行,所以一般来说,不会在回收阶段发生阻塞程序的情况。
  • 分代收集

    • 分代上看,G1依然属于分代型垃圾回收机器,但从堆的结构上看,不要求整个Eden、年轻代或老年代都是连续的,也不再坚持固定大小和固定数量。

    • 堆空间分为若干个区域(Region),这些区域包含逻辑上的年轻代和老年代。

    • 之前的各类回收器不同,同时兼顾年轻代和老年代。

  • 空间整合

    • CMS: 标记-清除算法、内存碎片在若干次GC后进行一次碎片整理。
    • G1内存回收时候以region作为基本单位,Region之间是复制算法,但整体上可以看作是标记-压缩,这两种算法都可以避免内存碎片。这种特性有利于程序长时间运行、分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC,尤其堆非常大的时候,G1优势更加明显。
  • 可预测的停顿时间模型
    G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不能超过N秒。

    • 由于分区原因,G1可以只选取部分区域进行内存回收,这样缩小了回收范围,所以对于全局停顿情况的发生也能得到较好的控制。
    • G1 跟踪各个Region 里面的垃圾堆积的价值大小(回收获得空间大小及回收所需要的时间),后台维护一个优先列表,每次根据允许收集的时间,优先回收价值最大的Region。保证G1收集器在有限时间内获取尽可能高的收集效率。
    • 相比较CMS,G1未必能做到CMS在最好情况下的延迟停顿,但是最差情况要好很多。

G1的缺点:
相比较于CMS,G1还不具备压倒性的优势,比如用户线程执行过程中,G1无论是为了垃圾收集产生的内存占用还是程序允许时的额外执行负载,多要比CMS要高。

G1的参数设置

  • -XX:+useG1GC:指定使用G1收集器回收

  • -XX:G1HeapRegionSize:设置每个Region的大小,值是2的幂次数,范围是1M - 32M 之间大小,目标是根据最小堆大小划分出约2048个区域,默认是堆内存的1/2000

  • -XX:MaxGCPauseMillis:设置期望达到最大的GC停顿时间指标,不保证肯定能达到。默认是200ms

  • -XX:ParallelGCThread:设置STW时GC工作线程的数量,最多设为8

  • -XX:ConcGCThreads:设置并发标记的线程数,通常设置为并发垃圾回收线程(ParallelGCThreads)的1/4左右。

  • -XX:InitiatingHeapOccupancyPercent:设置并发出发GC周期堆占用率阈值,超过此值,就触发GC,默认值是45.

G1 设计原则就是简化JVM性能调优,开发人员只需3步即可完成:

  1. 开启G1垃圾收集器
  2. 设置堆的最大内存
  3. 设置最大停顿时间

G1回收器的使用场景:
主要应用与GC低延迟,并具有大堆的应用程序。
如:堆大小约6GB或更大时。

分区Region使用

G1垃圾收集器把整个堆分成大约2048个大小相同的独立Region块,每个Region块大小根据堆空间实际大小决定,整体被控制在1 - 32MB之间,并且时2的N次幂。
可以通过-XX:G1HeapRegionSize设定。
所有的Region大小相同,并在JVM生命周期内不会被改变。

一个Region有可能属于Eden,幸存区,或者是老年代区域,但一个Region只可能属于一个角色。
G1垃圾收集器还增加了一种新的区域,叫做Humongous内存区域,主要存储大对象,如果超过1.5个Region,就放到Humongous

设置H区的原因:
对于堆中的大对象,默认会直接分配到老年代,但如果这个大对象是一个短期存活的对象,就会对垃圾收集器造成负面影响(因为老年代不会经常回收),所以有了Humongous区,专门存放大对象。
如果一个H区放不下大对象,G1就会寻找连续的H区存储。
为了找到连续的H区,有时候不得不启动Full GC,G1大多数把H区看作为老年代的一部分。

G1主要回收环境

G1垃圾回收过程主要包括3个环节:

  • 年轻代(Young GC)
  • 老年代并发标记过程(Concurrent Marking)
  • 混合回收(Mixed GC)

G1回收过程:

顺时针,young gc -> young gc + concurrent mark -> Mixed GC 顺序,进行垃圾回收。

应用程序分配内存,当年轻代用完时开始年轻代回收,G1对年轻代回收阶段是一个并行的独占式回收,回收年轻代时候,G1 GC暂停所有应用程序线程,启动多线程回收年轻代,然后把年轻代里面的存活对象放到幸存区或者老年代,也有可能两个区间都涉及。

当堆内存使用45%(默认值)时,开始老年代并发标记。

标记完成马上开始混合回收,G1 GC 从老年区移动存活对象到空闲区域,这些空闲区间就成为老年代的一部分,和年轻代不同,老年代的G1回收和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只扫描/回收一小部分老年代的Region就可以了。
同时老年代Region和年轻代一起回收的。

混合回收中其实也包含了轻GC和老年代的回收,这样就会发现,在第1、2、3阶段都发生了轻GC,那Full GC肯定也包括了轻GC。
年轻代垃圾回收只会回收Eden区和两个幸存区,幸存区满的时候,不会触发轻GC,幸存区是被动的进行轻GC回收的。

Remembered Set(记忆集)

G1 相比较与CMS,还要额外的占用10% - 20%的内存空间,这块占用的就是存放记忆集的。

一个对象被不同区域引用的问题。
一个Region不可能是孤立的,一个Region中的对象可能被任意Region中的对象引用,判断对象存活时,需要扫描整个堆?

解决方法:
无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描,每个Region都有一个对应的Remembered Set。
每次Reference类型写数据时,都会产生一个Write Barrier (写屏障)暂停中断操作。然后检查写入的引用指向的对象是否和Reference类型在不同Region。
如果不同,通过CardTable把相关引用信息记录引用指向对象的所在Region对应的Remembered Set中。
当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set,保证不进行全面扫描,也不会有遗漏。

G1垃圾回收的优化建议

Oracle官方透露出来的消息,回收阶段其实有想过设计成和用户程序一起并发执行,但这件事做起来比较复杂,考虑G1只是回收一部分Region,停顿时间是用户可控的,所以不迫切的去实现,而是选择把这个特性放到了G1之后出现的低延迟垃圾收集器(ZGC)中。

另外G1不是光面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以选择完全暂停用户线程的实现方案。

建议:

  • 年轻代大小
    避免使用-Xmn-XX:NewRatio等相关项设置年轻代大小,因为固定年轻代大小会覆盖暂停时间,要让JVM动态的自己去整理

  • 暂停时间目标不要太严苛
    G1的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间,评估G1吞吐量时,暂停时间目标太过严苛表示愿意承受更多的垃圾收集开销,但这些会直接影响吞吐量。

7种经典回收器总结

image

GC发展阶段:
image

查看GC日志的参数

  • -XX:+PrintGCDetails 输出GC日志
  • -XX:+PrintGCDetails 输出GC详细日志
  • -XX:+PrintGCTimeStamps 输出GC时间戳(以基准时间形式)
  • -XX:+PrintGCDateStamps 输出GC时间戳(以日期的形式)
  • -XX:+PrintHeapAtGC 在GC前后打印堆的信息
  • -Xloggc:../logs/gc.log 日志文件输出路径

GC日志分析

轻GC:
image

Full GC:
image

日志分析工具

命令:-Xloggc:./logs/gc.log

image

必须先创建一个logs目录,不然允许会提示:没有logs目录

得到gc.log 文件后,可以通过分析日志工具查看,
常用的日志分析工具有:GCViewer、GCEasy、GCHisto、GCLogViewer、Hpjmeter、garbagecat 等

  • GCViewer
    直接双击打开jar包以后,左上角File -> Open File

然后选择gc.log文件就好了

image

JDK11的GC特点

Epsilon 无操作回收器

官方称为无行为垃圾回收器,意思就是只做内存分配,不做垃圾回收。
适用于内存分配完以后,直接程序结束。

ZGC

在尽可能对吞吐量影响不大的情况下,实现在任意堆内存大小都可以把垃圾收集的停顿时间限制在10ms以内的低延迟。

ZGC收集器是一块基于Region内存布局的,不设置分代的,使用了读屏障、染色指针和内存多重映射等技术实现可并发的标记-压缩算法的,以低延迟为首要目标的一块垃圾收集器。

ZGC的工作过程分为4个阶段:并发标记 - 并发预备重分配 - 并发重分配 - 并发重映射等。

ZGC几乎在所有地方都是并发执行的,除了初始阶段是STW,所有停顿时间就耗费在初始标记上,这部分时间实际是非常少的。

未来在服务端、大内存、低延迟应用,首选的垃圾收集器。

命令:-XX:+UnlockExperimentalVMOptions -XX:+UseZGC 设置使用ZGC

posted @ 2021-11-01 20:44  aBiu--  阅读(160)  评论(0编辑  收藏  举报