JVM优化
为什么要学习JVM优化:
1:深入地理解 Java 这门语言
我们常用的布尔型 Boolean,我们都知道它有两个值,true 和 false,但你们知道其实在运行时,Java 虚拟机是 没有布尔型 Boolean 这种类型的,Boolean 型在虚拟机中使用整型的 1 和 0 表示,一般所有的数据最底层都是1和0
2:更好的解决线上排查问题
我们知道我们一个Java 应用部署在线上机器上,肯定时不时会出现问题。除去网络、系统本身问题,很多时候 Java 应用 出现问题,基本就是 Java 虚拟机的内存出现了问题,要么是内存溢出了,要么是 GC 频繁导致响应慢等等,那如何解决这些问题就是学习JVM优化的一个原因
JVM回顾(对于一些概念来说,可以大致的过一遍):
什么是JVM:
JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算 机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的,一般用来操作java的
主流虚拟机:
JVM与操作系统:
为什么要在程序和操作系统中间添加一个JVM:
Java 是一门抽象程度特别高的语言,提供了自动内存管理等一系列的特性,这些特性直接在操作系统上实现是不太 可能的(或者难以实现,就如你从0到有的完成一件事,而不用其他封装好的东西),所以就需要 JVM 进行一番转换
从图中可以看到,有了 JVM 这个抽象层之后,Java 就可以实现跨平台了(实际上c也可以,但是现在不行,具体可以看看这个博客:https://www.zhihu.com/question/386866683/answer/2524741732?utm_id=0),JVM 只需要保证能够正确执行 .class 文 件,就可以运行在诸如 Linux、Windows、MacOS 等平台上了,只要他们也有JVM
而 Java 跨平台的意义在于一次编译,处处运行,能够做到这一点 JVM 功不可没,比如我们在 Maven 仓库下载同一 版本的 jar 包就可以到处运行,不需要在每个平台上再编译一次
现在的一些 JVM 的扩展语言,比如 Clojure、JRuby、Groovy 等,编译到最后都是 .class 文件,Java 语言的维护 者,只需要控制好 JVM 这个解析器,就可以将这些扩展语言无缝的运行在 JVM 之上了
应用程序、JVM、操作系统之间的关系:
我们用一句话概括 JVM 与操作系统之间的关系:JVM 上承开发语言,下接操作系统,它的中间接口就是字节码
JVM、JRE、JDK 的关系:
JVM 是 Java 程序能够运行的核心,但是需要注意,JVM 自己什么也干不了,你需要给它提供生产原料(.class 文 件)
且仅仅是 JVM,是无法完成一次编译,处处运行的,它需要一个基本的类库,比如怎么操作文件、怎么连接网络等,而 Java 体系很慷慨,会一次性将 JVM 运行所需的类库都传递给它,JVM 标准加上实现的一大堆基础类库,就组成 了 Java 的运行时环境,也就是我们常说的 JRE(Java Runtime Environment)
对于 JDK 来说,就更庞大了一些,除了 JRE,JDK 还提供了一些非常好用的小工具,比如 javac、java、jar 等,它 是 Java 开发的核心,让外行也可以炼剑! 我们也可以看下 JDK 的全拼,Java Development Kit,我非常怕 kit(装备)这个单词,它就像一个无底洞,预示着 你永无休止的对它进行研究(可以多装备),JVM、JRE、JDK 它们三者之间的关系,可以用一个包含关系表示
Java虚拟机规范和 Java 语言规范的关系:
左半部分是 Java 虚拟机规范,其实就是为输入和执行字节码提供一个运行环境(字节码最终变为0,1,就如c也会变成0,1一样,只是一般java需要编译,变成可以识别的(实际上class可以认为是变成c,只是是另外一种形式,因为jvm是c/c++写的),c/c++,代表都有,或者其中一个,一般表示都有),右半部分是我们常说的 Java 语法 规范,比如 switch、for、泛型、lambda 等相关的程序,最终都会编译成字节码,而连接左右两部分的桥梁依然是Java 的字节码
如果 .class 文件的规格是不变的,这两部分是可以独立进行优化的,但 Java 也会偶尔扩充一下 .class 文件的格式, 增加一些字节码指令,以便支持更多的特性
我们可以把 Java 虚拟机可以看作是一台抽象的计算机,它有自己的指令集以及各种运行时内存区域(若学习过《计算 机组成结构(原理)》或者相关的书籍,那么可以发现有类似的情况)
为了更好的进行理解,可以认为计算机是一个大的元件,我自己的理解(如果有问题,可以选择不看):如果玩过"我的世界"这个游戏,可以在网上搜索在"我的世界里知道电脑"的相关问题,即也的确是一个大的文件,在其中我们可以发现,通过点击某些东西,可以进行实现某些东西,只是这些东西在现实中是由电脑键盘(统称为键盘),使得里面的某个东西会进行点击,从而实现某些东西,所以可以认为键盘与点击中间,我们再次的创造一个可以识别你输入的东西,从而进行某些点击(既然你点击会实现某些东西,那么自然可以实现键盘来使得某些点击),我们可以你从键盘输入的东西称为二进制,而中间的就是识别二进制的,然后结果与二进制也自然有中间,综上所述,无论是c还是java都会有类似的中间的操作,都是将他们进行变成二进制,那么jvm由于是c写的,那么对应的class自然可能底层是通过变成c,然后变成二进制操作底层,所以可以认为java是c变二进制也没有错,但是我们大多数都会认为是jvm将class变成二进制的(虽然jvm可能将他变成c,我们可以先这样的认为,以后也是如此,在后面会根据理解进行改变的)
当然对应的中间是如何实现的,自然我并不知道,这太底层了(一般都可以分成很多部分,也就是为什么有些知识会分开说明的原因,就如二进制到实现某些东西的中间,可能存在类似的java的栈,堆等等,来保存信息,使得给结果使用(并且可能触发什么使得关闭释放,即该中间又何尝不是结果呢,所以二进制和结果的中间一般也是属于结果的,因为一般只有他会实现很多功能,其他的基本只有一种(如编译),所以就看成中间了,实际上主要的是该结果是结果,即到头了,所以才可以这样说明),但是通常只会说明作用,而不会说明如何实现),需要非常多的知识,且就算终其一生也未必能够学的明白:世上知识何其之多,掌握能力之内的知识足以
最后,我们简单看一下一个 Java 程序的执行过程,它到底是如何运行起来的:
所以具体分开是有两步的,首先是出现可以让c识别的class,然后c读取将他变成c(第二步,在c中也是c读取c文件,然后变成二进制,所以对于c来说,多出了出现class这一步),然后变成二进制,即class可以看成要变成我们写的c文件的意思(虽然并不是),这是因为更好的封装的,从而简便代码,才会有变成class这一步,即java是4步,c是2步
这里的 Java 程序是文本格式的,比如下面这段 HelloWorld.java,它遵循的就是 Java 语言规范,其中,我们调用了System.out 等模块,也就是 JRE 里提供的类库
package com. test1 ;
public class HelloWorld {
public static void main ( String [ ] args) {
System . out. println ( "Hello World" ) ;
}
}
我们可以选择在idea中查看对应的class文件:
难道class文件长这样吗,答:并不是,他只是idea给我们总结的而已,实际上并不是这样(你也可以去磁盘中打开就知道了。但是一般是乱码),而若要真正的看到对应的文件信息,需要如下操作:
使用 JDK 的工具 javac 进行编译后,会产生 HelloWorld 的字节码,我们一直在说 Java 字节码是沟通 JVM 与 Java 程序的桥梁,下面使用 javap 来稍微看一下字节码到底长什么样子
点击如下:
然后到该class文件所在的目录下(在对应目录下哦,这可是文件的命令操作,自然不会操作类指向的,即定位的,所以不要在java后缀文件的对应目录下操作,否则可能看不到结果,或者说找不到类的错误信息出现,虽然只是提示,一般来说错误都可以说成是提示的),在控制台里输入javap -v HelloWorld(也可以javap -v HelloWorld.class,即他默认加上.class的,有的话则不加,不加-v说明就是我们在idea中点击的class文件的内容,即明面上的整体内容,加上-v才基本上算是底层的class文件),我们可以找到如下:
0 getstatic #2 < java/ lang/ System . out>
3 ldc #3 < Hello World >
5 invokevirtual #4 < java/ io/ PrintStream . println>
8 return
但是要注意:随着idea或者说jdk版本的不同,可能并不会出现这些东西或者少些或者有些不同,所以这里以上面的说明为主即可(我修改并整理的)
比如我这里就是这样:
Code :
stack= 2 , locals= 1 , args_size= 1
0 : getstatic #2
3 : ldc #3
5 : invokevirtual #4
8 : return
Java 虚拟机采用基于栈的架构,其指令由操作码和操作数组成,这些 字节码指令 ,就叫作 opcode,其中,getstatic、ldc、invokevirtual、return 等,就是 opcode,可以看到是比较容易理解的,因为根据后面的说明就是一个完整的System.out.println(“Hello World”);的执行来源
JVM 就是靠解析这些 opcode 和操作数来完成程序的执行的,当我们使用 Java 命令运行 .class 文件的时候,实际上 就相当于启动了一个 JVM 进程,一般关闭程序也就是关闭jvm进程(相当于关闭class文件的执行),但一般来说,我们最好不要强制关闭,即最好优雅的关闭,比如手动执行System.exit(0)即可(大多数代码最好这样,特别是大型的代码(量),虽然Runtime.getRuntime().exit(0)一般也是优雅的,但是一般我们不会认为他是的,因为过程中并没有好的处理,再101章博客有具体说明)
然后 JVM 会翻译这些字节码,它有两种执行方式,常见的就是解释执行,将 opcode + 操作数翻译成机器代码直接执行,另 外一种执行方式就是 JIT,也就是我们常说的即时编译(编译执行),它会在一定条件下将字节码编译成机器码之后再(等下)执行,你可能会有疑问:解释执行,难道不会将关联的代码进行分开吗,答:并不会的,实际上在字节码中,解释执行的意思是通过字节码来执行,即如果是编译执行,那么在都编译成二进制后,才执行,而解释执行则是,先编译一段,然后我执行该一段,一路过去,你可能会有疑问,关联的代码,解释执行会不会破坏呢,答:不会,因为既然你变成了class,那么对应的编译,必然是整体的编译,而不是一部分,所以说,不会破坏,即解释执行可以看成是局部的编译执行,且局部的编译执行的代码是互相没有关联的,实际上就算是一个地址的执行他也算局部的编译执行,因为他执行是等待某些数据而已,所以并没有什么关联,如后面说明的iconst_1,他执行只是加载,虽然后一步在代码上看起来与他有联系,但是在字节码中是没有的,所以他们都是一步一步的执行,且没有问题
java虚拟机的内存管理:
JVM整体架构:
根据 JVM 规范,JVM 内存共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分
JVM分为五大模块: 类装载器子系统,运行时数据区,执行引擎,本地方法接口 和 垃圾收集模块
JVM运行时内存(也就是主要说明运行时数据区):
Java 虚拟机有自动内存管理机制,如果出现面的问题,排查错误就必须要了解虚拟机是怎样使用内存的
Java7和Java8内存结构的不同主要体现在方法区的实现:
方法区是java虚拟机规范中定义的一种概念上的区域,不同的厂商可以对虚拟机进行不同的实现
我们通常使用的Java SE都是由Sun JDK和OpenJDK所提供,这也是应用最广泛的版本,而该版本使用的VM就是HotSpot VM,通常情况下,我们所讲的java虚拟机指的就是HotSpot的版本
JDK7 内存结构:
JDK8 的内存结构(主要说明这个):
针对JDK8虚拟机内存详解(以后也是这样,一般jdk8之后的通常不会改变,如果改变了,可以去网上找资料,但一般以jdk8为主):
JDK7和JDK8变化小结(上面说明的都是概念,真正的存放位置看如下,特别是堆和方法区,在jdk8以前,是属于一个地方(下面框框),看下图就知道了):
直接内存一般代表电脑的内存(可能并不代表全部物理内存),既然电脑可以存在并利用内存(如c可以直接操作内存,虽然说是这样说,但是实际上最终还是二进制的,只是我们一般这样认为的,因为最主流和比较底层),那么我们也可以用一些内存来表示某些东西(如元空间,一般是c弄的,因为java都是c搞出来的,自然可以这样说)
对于Java8,HotSpots取消了永久代(也就是方法区那里,这就是一个名称而已,一般方法区看成永久代,所以你可以认为,方法区就是永久代),那么是不是就没有方法区了呢?
当然不是,方法区只是一个规范,只不过它的实现变了,即由别人实现了
在Java8中,元空间(Metaspace)登上舞台,方法区存在于元空间(Metaspace),同时,元空间不再与堆连续(这是好的,减少关联,或者其他原因,后面会说明为什么),而且是 存在于本地内存(Native memory),而不会向方法区一样与堆会操作连续,或者太多的关联,一般可以为GC带来好的操作,也就是解决下面说明的"永久代会为 GC 带来不必要的复杂度"
方法区Java8之后的变化:
移除了永久代(PermGen),替换为元空间(Metaspace)
永久代中的class metadata(类元信息)转移到了native memory(本地内存,而不是虚拟机)
永久代中的interned Strings(字符串常量池) 和 class static variables(类静态变量)转移到了Java heap(堆)
永久代参数(PermSize MaxPermSize)-> 元空间参数(MetaspaceSize MaxMetaspaceSize)
Java8为什么要将永久代替换成Metaspace?
字符串存在永久代中,容易出现性能问题和内存溢出
类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太 大则容易导致老年代溢出
永久代会为 GC 带来不必要的复杂度,并且回收效率偏低
Oracle 可能会将HotSpot 与 JRockit 合二为一,而JRockit没有所谓的永久代
PC 程序计数器:
什么是程序计数器:
程序计数器(Program Counter Register):也叫PC寄存器(后面都按照这个说明),是一块较小的内存空间,它可以看做是当前线程所执行 的字节码的行号指示器,在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
我们来看看这个代码:
package com. test1 ;
public class PC {
public static void main ( String [ ] args) {
int x = 1 ;
int y = 2 ;
System . out. println ( x+ y) ;
}
}
现在我们来换另外一种方式,看他的字节码,首先安装如下:
然后重启idea,一般来说安装只是放在一个地方,需要重写加载,但是可能会自动加载,这主要看idea或者插件的某些东西了,但最好重启一下,然后点击如下(在点击对应的类或者class类后,然后点击如下,基本才会出现,他主要看class里的内容,只是可以通过类可以定位到而已(不是命令操作哦,所以可以指向,即定位到),大多数操作类来看字节码信息的都是这样的(即操作定位)):
出现如下:
右边的就是字节码的主要信息了,一般都表示main方法里面的(所以并不是全部哦,自己使用javap查看就知道了,我们以后基本说明main里面的,所以我们以main字节码信息为主了,其他的大致忽略即可),你看一下对应的HelloWorld就知道了,就是之前给出的内容,注意:如果需要改变他,我们通常只能改变源码,但是直接改变并不会立即改变其class文件,你可以选择执行一下即可,这样一般都会改变
这里直接的给出进行分析:
指令地址 操作指令
0 iconst_1
1 istore_1
2 iconst_2
3 istore_2
4 getstatic #2 < java/ lang/ System . out : Ljava / io/ PrintStream ; >
7 iload_1
8 iload_2
9 iadd
10 invokevirtual #3 < java/ io/ PrintStream . println : ( I ) V >
13 return
0 getstatic #2 < java/ lang/ System . out>
3 ldc #3 < Hello World >
5 invokevirtual #4 < java/ io/ PrintStream . println>
8 return
PC寄存器的特点:
实际上在上面的说明中,存放指令地址的就是pc寄存器的作用,也就是说,他存放指令地址,注意:对应的pc寄存器只是存放下一个的指令地址(如果没有,那么自然不会保存,而没有的话,说明已经执行到最后的字节码了,自然是不用保存的),一般从0开始,当你的解释器(识别class的,可以认为是jvm,虽然他们都是jvm里面的)识别后(识别后,且在执行前,虽然可能到c,但最后由c执行,所以这个过程是可以这样说的),通常会让pc寄存器存放下一个指令地址,这是可以保证中途的改变,而不是固定,当然这不是主要的,由于即实际上PC寄存器是一块很小的内存空间(只存下一条指令的地址),几乎可以忽略不计,所以主要的是只存放下一个指令也是为了空间节省,实际上pc寄存器考虑很多问题,如果没有他,如果确定你的指令呢,如果有他,那么他是都存放,还是只存放一个呢,如果都存放,直接的运行的确快,但是要考虑空间,由于对应的快并没有快多少(因为反正你也只是一条一条的操作过去,那么后面的完全不需要一直保存),并且虽然切换的保存需要切换时间,但是对于所占空间来说是微不足道的,且由于是利用他执行的时间来保存,所以基本是没有切换时间的,那么综上所述,pc寄存器只保存一个地址是最好的结果,如果有其他好的方案自然会使用对应的好的方案,具体看以后的jdk版本吧
通过上面的说明,这里将前面的class变成c的说明进行修改,修改成,class通过类似的解释变成二进制,就如我们可以将一种爬行动物称为狗狗一样,也就是说jvm可以认为是c的另外一个解释器,专门解释class的,而不是解释c的,所以java-class-二进制,c-二进制,即c相当于我们手写的class,只是java(java文件)进行了封装,容易理解
1:区别于计算机硬件的pc寄存器,两者不略有不同,计算机用pc寄存器来存放"伪指令"或地址,而相对于虚拟 机,pc寄存器它表现为一块内存(因为他并不是一直存在,jvm关闭他一般也会关闭,因为当我们使用 Java 命令运行 .class 文件的时候,实际上 就相当于启动了一个 JVM 进程,当第一次运行那么jvm进程操作所有,当都停止,那么jvm进程也关闭,自然pc寄存器也没有了,当然了一般一个Java程序会开启一个JVM进程,如果一台机器上运行3个Java程序,那么就会有3个运行中的JVM进程,一般jvm之间可以通信,但是一般不会操作具体的变量通信,如果是不同的jvm,那么静态是不会共享的,只属于自身的jvm中(除了操作系统与jvm的共享),比如端口,只能一人进入,但他们都可以操作,并判断是否被占用),虚拟机的pc寄存器的功能也是存放伪指令,更确切的说存放的是将要执行指令的地址
2:当虚拟机正在执行的方法是一个本地(如native修饰的,一般他代表根本方法,而像什么java自带的方法,则是jdk自带的,而不是这个的jre,如String就是jdk自带的,当然,并不是所有不是native都是jdk的,有些基础的也是jre的,只是native基本都是jre的,因为对应的jar包或者zip(一般jre是jar且只保存class文件)是可以补充使用的,而由于可以补充,自然在jvm中不会给出对应的什么说明,因为只是一个位置而已,并不重要,具体可以百度,这里了解即可)方法的时候,jvm的pc寄存器存储的值是undefined
3:程序计数器是线程私有的,它的生命周期与线程相同,每个线程都有一个
4:此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域(只保存一个,在运行时保存,由于运行基本必然比保存慢,所以这里基本不会报错,且运行之前就会提醒保存的)
一般来说pc寄存器是每个线程都有的,因为他是线程私有,一般私有的,代表线程创建之前,都会给他一个pc寄存器(或者其他说明私有的东西),或者jvm给该线程一个pc寄存器(或者其他说明私有的东西)
Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的(也就是抢占cpu,在26章博客有说明),在任何一个确定的时刻,一个处 理器只会执行一条线程中的指令
因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数 器互不影响,独立存储,我们称这类内存区域为"线程私有"的内存,从这里可以发现,对应获取内容时,并没有修改地址,所以是一个副本,这就是为什么线程之间看起来可以操作同一个数据的原因,因为是副本(复制),总体来说:由于pc寄存器是基本操作,即基础操作,或者说程序的基础操作,所以才会使得线程中得到的是副本的,当然,一般在我们得到指令地址并执行后,才会考虑后续是否操作虚拟机中栈或者堆等等存在的操作的,即先操作pc寄存器后一般才会考虑操作栈或者堆等等存在的操作的
实际上之所以需要私有,是保证他们不会发生冲突或者出现问题,所以在一些会发生冲突或者出现问题的情况下,一般都会是私有的,必然pc寄存器,如果不是私有,那么可能他操作指令地址时,会跳过应该要操作的指令地址,即发生冲突或者出现问题
虚拟机栈:
什么是虚拟机栈:
Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的(注意:是私有的,所以每个线程独立拥有),即生命周期和线程相同,Java虚拟机栈和线程同时创 建,用于存储栈帧,每个方法在执行时都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态 链接、方法出口等信息,每一个方法从调用直到执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过 程
看如下代码:
package com. test1 ;
public class StackDemo {
public static void main ( String [ ] args) {
StackDemo sd = new StackDemo ( ) ;
sd. A( ) ;
}
public void A ( ) {
int a = 10 ;
System . out. println ( " method A start" ) ;
System . out. println ( a) ;
B ( ) ;
System . out. println ( "method A end" ) ;
}
public void B ( ) {
int b = 20 ;
System . out. println ( " method B start" ) ;
C ( ) ;
System . out. println ( "method B end" ) ;
}
private void C ( ) {
int c = 30 ;
System . out. println ( " method C start" ) ;
System . out. println ( "method C end" ) ;
}
}
你可以认为一个大栈里面包含栈,该栈也包含小栈(如变量所在的栈),即栈中的栈中的栈,虽然小栈没有给出,只给出栈(栈帧)和大栈(虚拟机栈),当然,虚拟机栈是在jvm里面的,利用jvm的内存,虽然jvm利用物理内存(电脑的,一般不会利用jvm存在的保存的物理内存,即jvm一般是没有上限的,当然,对应的jvm是一系列的结合,他们都有对应物理内存没有的报错处理,这里了解即可)
好了,一个图片你可能并不相信,那么现在我给出一个流程,看如下:
首先在对应的StackDemo sd = new StackDemo();进行调试
我们注意左下角,首先可以看到红色勾勾打上的是main,这里代表是main的线程,即主线程,即当前线程是主线程(main),而上面对应的Frames通常代表这里是大栈的地方,所以下面的main:8代表已经入栈了,现在我们继续执行,进入A方法:
可以看到A入栈了,即的确是放在上面的(栈的特性,记得是先入后出哦),我们执行到最后的C:
可以发现,都入栈了,执行完C,可以发现他会出栈,直到都执行完毕,那么都出栈,即程序结束,所以一般默认的栈都是main,main出栈,那么说明该程序执行完毕了(当然并不是出栈立即关闭,自然是优雅的关闭,101章博客有过说明),但是这只是针对该线程来说的,如果有其他线程,那么考虑的是他的出栈,虽然他的起始线程不是主线程,但是对于他来说,开启的第一个线程或者说第一个方法就是他的主线程,虽然我们可以设置了他的名称,至此,流程的确正确,演示完毕,其中A:16,B:23中的16和23代表是该16行或者23行开始加入其他的栈的,而16行是B();,23行是C();
什么是栈帧:
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息,每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入 栈到出栈的过程
当然了上面的放大结构自然可能有其他的信息,但是上面四个是主要的,所以主要给出这四个
设置虚拟机栈的大小:
-Xss 为jvm启动的每个线程分配的内存(虚拟机栈内存)大小,默认JDK1.4中是256K,JDK1.5+中是1M,当然在不同的操作系统上,可能也有不同:
Linux/x64 (64-bit): 1024 KB
macOS (64-bit): 1024 KB
Oracle Solaris/x64 (64-bit): 1024 KB
Windows: The default value depends on virtual memory
虽然都可以操作class,但是也只是class,即jvm可能会与操作系统相匹配
主要是这样的设置:
-Xss1m
-Xss1024k
-Xss1048576
我们来看看这个代码:
package com. test1 ;
public class StackTest {
static long count = 0 ;
public static void main ( String [ ] args) {
count++ ;
System . out. println ( count) ;
main ( args) ;
}
}
为了更加的明白他怎么回事,我们点击如下(若没有对应的类出现,可以选择启动,然后一般就有了):
在新版idea中,一般有些选项是不会显示出来的(对于老版本idea来说),即需要自己弄出来,所以我们点击右边的Modify options(英文意思:修改选项),然后点击如下:
那么就出来了,他代表虚拟机栈的大小设置,若没有设置,那么按照默认来操作对应的上限的,到这个上限就会出现报错
我们设置成-Xss256k:
点击ok退出,继续执行来看一看(记得,在执行class时jvm才会开启进程,所以会利用到该配置,而不是操作默认的,在idea中设置的配置,通常会到对应自己给idea设置的jdk里进行设置,但是只是副本而已,指定文件谁不会呢,为了验证,可以在其他类里也操作,但不进行设置即可,即发现的确是副本)
可以发现对应的结果到19xx(我的是)多就停止了,所以我大胆猜测,默认值是1m(JDK1.5+中是1M),虽然也的确是这样,即8个栈帧内存(其中一个栈帧中基本没有多余的变量或者其他操作,即可以认为是一个空方法进入的)大概是1k内存,很明显,我们可以进行设置来使得提前结束,即可以操作该方面的优化,使得不会占用(使用)多数的内存,即我们终于手动操作了jvm的第一个优化方案了(对于这个博客来说的)
局部变量表:
上面说明了虚拟机栈,现在我们说明他里面的内容(栈帧)的内部(包括栈,但是以栈为主,所以一般都会将该内部称为栈,虽然还有其他内容),有四个主要的内容哦,看之前的图就知道了
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量,包括8种基 本数据类型、对象引用(reference类型)和returnAddress类型(指向一条字节码指令的地址),其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),具体可以百度空间大小,一般可能与类型有关,其余的数据类型只占用1个(即以32为主)
可以看看这个图:
其中long类型的数据占用两个空间,而int类型的数据和byte类型的数据都只占用一个空间,现在我们来进行观察并验证,看如下:
package com. test1 ;
public class PC {
public static void main ( String [ ] args) {
int x = 1 ;
int y = 2 ;
System . out. println ( x+ y) ;
}
}
上面是之前的代码
0 iconst_1
1 istore_1
2 iconst_2
3 istore_2
4 getstatic #2 < java/ lang/ System . out : Ljava / io/ PrintStream ; >
7 iload_1
8 iload_2
9 iadd
10 invokevirtual #3 < java/ io/ PrintStream . println : ( I ) V >
13 return
上面是对应的字节码的主要内容
现在我们来点击如下:
我们看这个名称,可以知道是局部变量表,我们也点击这个:
看他的名称就知道是行号表,我们先说明他,我们可以看到他有 0 0 8,其中起始PC的0代表我们的指令地址,也就是pc寄存器的那个0(对应iconst_1),而后面的行号,则对应源码中的那一行,也就是int x = 1;,所以说对应的Code的点击他不只是保存了字节码的主要内容,也保存了对应的PC指令与源码行的关系,这也是使用对应插件的一个好处,但是很明显,他只会保存最小的,因为其中PC的指令地址中1也算那一行,但是我们只会保存一次,且从小到大,所以这里是0,那么同理第二个就是2了,主要:Nr.只是排列编号,并不参与任何说明,即行编号,但是我们可以进行观察,那么行号是11的为什么也存在呢,实际上在没有返回值时,默认最后一个大括号是返回的行数(所以中间有多个回车改变他在源码的行,会改变他的行号数的),即相当于返回了,否则就是return所在的行了,至此行号表说明完毕
现在我们继续看对应的局部变量表,只是现在给出完整的图:
你看到表,可能会有一个疑惑,其中为什么PC为0时,是args,实际上这代表上一个变量的存在,即记录上一个变量的信息(代表到我这里,已经使用了他这个空间了),其中cp_info #15代表该变量在常量池的位置,你点击就知道了:
常量池在java用于保存在编译期已确定的,已编译的class文件中的一份数据,它包括了关于类,方法,接口等中的常量,也包括字符串常量,如String s = "java"这种申明方式(一般"java"在常量池中,具体可以看20章博客),因为String一般是得到常量的值的,且他不能修改,所以在不是创建对象时,String的值通常会认为是常量,由于String不能修改,所以有时候我们也会将String类型叫做字符串常量类型
很明显,他是将所有的信息以字符串来表示,那么对应的描述符的"l"就代表int了(引用一般代表类似于全限定名的名称,看前面的图就知道了,虽然他是数组,但是我们只看基础类型,数组会让"["开头,而具体类型不会),所以加起来就是int x(且是上一个变量的意思,所以他对应表示是,我使用了x,还有该长度)
那么对应的长度代表什么,实际上长度代表局部变量表空间,他是计算出来的,当我们分配好后,若第一个是14,代表我们开辟了4个空间,那么如果是编号0,那么是14(起始默认为10,且是代表使用了args空间的),且一个空间代表长度2,那么该插件会利用计算,来记录总空间(编号0,加上开辟的空间的),每有一个对应类型,如int,那么对应进行减2,所以其中一开始是14,因为在减去args后还是10,且加上了x和y的总体4,那么是14,到最后回到10,为了进行验证,可以将y的类型变成long,发现,从16开始了,所以也验证了long是占用两个空间的缘故,并且,你可以选择不改变为long,而是凭空加上一段代码(也是一样的赋值代码,可以是int yy = 1;),会发现也从16开始了,所以对应的long占用两个的确是正确的,总结:局部变量表是变量使用空间的地方,简单来说就存放变量的地方,至此局部变量表说明完毕(注意:序号直接代表操作上一个变量名称(序号),那么1就是x,所以对应的是istore_1,这个1,要记得字节码还没有执行,他只是保存而已,我们可以发现他自身就有很多关系,使得执行时的确有对应的来源,虽然可能需要jvm的其他识别,如istore_1中1代表x外,istore_代表存储的意思,这就是jvm的作用了,并且虽然1代表x,但是也需要jvm来识别操作,所以class定义(初始)信息,jvm识别信息执行)
操作数栈(局部变量说明完毕,现在来说明这个):
操作数栈(Operand Stack)也称作操作栈,是一个后入先出栈(LIFO),随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者 返回给方法调用者,也就是出栈/入栈操作
通过以下代码演示操作栈的执行
package com. test1 ;
public class StackDemo2 {
public static void main ( String [ ] args) {
int i = 1 ;
int j = 2 ;
int l = j;
int z = i + j;
}
}
对应的主要class文件(main)里面的:
0 iconst_1
1 istore_1
2 iconst_2
3 istore_2
4 iload_2
5 istore_3
6 iload_1
7 iload_2
8 iadd
9 istore 4
11 return
现在我说明一下操作数栈的理解,你可以认为是存放数据的地方,也就是说上面的iconst_1或者iload_2或者iadd的结果都会放在操作数栈里面进行操作(如iadd相加)或者保存,而获取他们操作的变量就放在局部变量表里面,每次变量要操作对应数据时,就是从操作数栈里面进行拿取,从而实现赋值等等,至此操作数栈说明完毕,这里看明白了,那么操作数栈也就明白了
动态链接:
Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了 支持方法调用过程中的动态链接(Dynamic Linking)
动态链接的作用:将符号引用转换成直接引用
案例:
package com. test1 ;
public class DynamicLink {
public static void main ( String [ ] args) {
Math . random ( ) ;
}
}
0 invokestatic #2 < java/ lang/ Math . random : ( ) D >
3 pop2
4 return
像下面的类名和描述符就代表他们是一个整体的,所以不要认为不是整体的哦(前提是没有什么L开头的,否则可能说明他的执行一般不会带上下面的操作(因为一般只是代表类型,而不是真的具体方法,即他里面有操作变量调用,而不是直接操作方法),如之前的java/lang/System.out,而他引用对应显示的就是Ljava/io/PrintStream;)
从右边可以看到对应的cp info相关的信息(没有下滑线了),实际上对应的是进入的意思,即到#19,所以之前的也是这样,所以之前的cp_info #15就是代表到#15,虽然在局部变量表在有下划线,但是他只是去对应位置而已,就如我们操作前端的跳转a标签,可以设置名称,他只是表示不同的名称罢了(虽然没有统一)
现在我们继续看看这个#2,很明显,他也保存了对应的类名和对应的描述,当然他们总体来说一般指这个方法的说明(来源信息),最终当没有具体的跳转时,说明到头了,自己可以点击跳转,所以对应的步骤就是找到方法资源,执行方法,很明显,他是通过符号来进行引用的,所以我们也认为动态链接的作用是:将符号引用转换成直接引用(也就是通过符号来引用,来得到对应的引用,这样是为了保证指向的,要不然我怎么知道使用常量池的那个呢,或者说如何好的确定使用谁呢,因为对应的常量池不是栈帧里面的,而是方法区或者说元空间里面的(根据版本来决定不同的说明,前面有说明,比如图片信息))
一般来说,在不同的地方,通常需要引用,来进行更好的确定使用谁(就如字典,你都需要对应的笔画或者什么拼音来确定哪一页,而不是都进行找,实际上这不是主要的,因为对于字节码来说,可以直接的显示,所以最主要的是:若你不操作引用,那么你肯定是直接的操作显示出来,那好,如果有其他人也操作这个,那么你又要显示,很明显,要显示多个,而引用是一个总显示,只需要少的字节码(如#2,就可以替换掉<java/lang/Math.random : ()D>)就可以显示了,当然,一开始是少的,最后替换时还是需要替换回来,总而言之,一开始少10,最后多2,总体少8),这是基本的,只是我们这里将该引用称为一个名称而已(动态链接,可能动态链接是操作如何引用到常量池的说明的,所以我们应该要这样的理解,这里了解即可)
方法返回地址:
方法返回地址存放调用该方法的PC寄存器的值
一个方法的结束,有两种方式:正常地执行完成,出现未处理的异 常非正常的退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置,方法正常退出时,调用者 的PC计数器(pc寄存器)的值作为返回地址,即调用该方法的指令的下一条指令的地址,当然方法内容的指向,并不会与pc寄存器有关,pc寄存器只是给出对应字节码中要执行的地方,至于怎么执行他不会过问,或者说,他对应的执行可能是包含所有联系的,也就是说,也保存了对应方法的字节码(可以自己在对应类里加上方法,可以发现,会在对应插件的方法哪一类中多出了该方法,即除了main外,他也存在),那么他也会继续工作,而当返回时,会返回调用者的下一跳指令,当然,可能pc寄存器或者说给pc寄存器赋值的地方操作,会保留对应的调用者的,这里了解即可
而通过异常退出的,返回地址是要通过 异常表来确定(也就是异常信息),栈帧中一般不会保存这部分信息,无论方法是否正常完成,都需要返回到方法被调用的位置,程序才能继续进行,然后看是否处理异常(异常表)来决定是否直接返回,当然,在main中返回的话,自然说明程序结束了,其中异常表,一般是你操作了异常或者抛出什么,而出现的(你可以试着手动抛出异常,来看看是否有对应的异常表信息,可能没有,具体可以百度,这里了解即可)
本地方法栈:
本地方法栈(Native Method Stacks) 与虚拟机栈所发挥的作用是非常相似的, 其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码) 服务, 而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务
特点:
1:本地方法栈加载native的(所有)方法,native类方法存在的意义当然是填补java代码不方便实现的缺陷而提出的
2:虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的Native方法服务
3:是线程私有的,它的生命周期与线程相同,每个线程都有一个
在Java虚拟机规范中,对本地方法栈这块区域,与Java虚拟机栈一样,规定了两种类型的异常:
StackOverFlowError:线程请求的栈深度>所允许的深度(之前的虚拟机栈的溢出就是这个,即Exception in thread “main” java.lang.StackOverflowError,也就是说,虽然之前说明的是虚拟机栈的大小,但是实际上只是定义上限,因为上限也是可以说成大小的,并不是真的拿取的总内存,虽然他也能说成大小,所以最好建议以测试案例为主 ,看错误即可)
OutOfMemoryError:本地方法栈(虚拟机栈)扩展时无法申请到足够的内存(我们拿取的内存快没了(快用完了)(不代表物理主机没有),上面是超过上限,而不是没有内存,这里可以认为在栈帧中有上限,如果栈帧中的数据超过栈帧本身,即栈帧内的内存不够了,那么就报这个错误,很明显与堆不同的是,堆定义的上限中是直接的,超过就是超过,而这里由于虚拟机栈的原因,他还存在栈的深度,即栈帧有上司,而存在相应错误的堆没有,所以这也是为什么只有对应栈(即虚拟机栈和本地方法栈)有两个报错的主要原因)
在后面也会继续补充对OutOfMemoryError(OOM)的说明
这里来说明一下,为什么native会影响到java,实际上我们都知道,他们都要变成二进制执行的,那么实际上native也是变成二进制的,二进制的操作是可以进行互通的,所以会影响到,只是你并不知道他干啥了而已,或者说不知道他的二进制与你的二进制有什么关系而已
堆:
Java 堆概念:
简介:
对于Java应用程序来说, Java堆(Java Heap) 是虚拟机所管理的内存中最大的一块,Java堆是被所 有线程共享 的一块内存区域(引用操作指向,我们只保存引用,改变指向的内容,其他操作引用,即操作指向的也会改变,即共享),一般在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例, Java 世界里"几乎"所有的对 象实例都在这里分配内存,"几乎"是指从实现角度来看, 随着Java语 言的发展, 现在已经能看到些许迹象表明,日后可能出现值类型的支持, 即使只考虑现在, 由于即时编译技术的进步, 尤其是逃逸分析技术的日渐强大, 栈上 分配、 标量替换优化手段已经导致一些微妙的变化悄然发生, 所以说Java对象实例都分配在堆上也渐渐变得不是 那么绝对了,并不会一成不变哦
堆的特点:
1:是Java虚拟机所管理的内存中最大的一块
2:堆是jvm所有线程共享的(堆中也包含私有的线程缓冲区 Thread Local Allocation Buffer (TLAB),具体作用可以百度)
3:在虚拟机启动的时候创建
4:唯一目的就是存放对象实例,几乎所有的对象实例以及数组都要在这里分配内存,可能有其他目的(如上面说的"私有的线程缓冲"),具体可以百度
5:Java堆是垃圾收集器管理的主要区域
6:由于Java堆是垃圾收集器管理的主要区域,因此很多时候java堆也被称为"GC堆"(Garbage Collected Heap),从内存回收的角度来看,由于现在收集器 基本都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代,新生代又可以分为:Eden 空间、From Survivor空间(S0)、To Survivor空间(S1)
给出之前的图片:
7:java堆是计算机物理存储上不连续的、逻辑上是连续的(是堆自己的存放方式,不是他里面的内容哦,之所以是逻辑上,是因为通过某种方式连接起来的(即有连续方法连接),具体可以百度),也是大小可调节的(通过-Xms,或者-Xmx控制(虽然下图没有给出,图中可能有错误的,所以我们以案例为主))
继续给出图片:
8:方法结束后,堆中对象不会马上移出,仅仅在垃圾回收的时候时候才移除,一般来说垃圾回收机制就是识别到你这个对象没有人使用时就会移除,由于是识别到,所以并不是在一定时间内移除的,即不定期或者间歇的,当然,对应的回收有很多种,这里只是考虑始终操作(识别就移除,还有其他的GC,即其他的如"新生代收集",在后面会说明)
9:如果在堆中没有内存完成实例的分配,并且(或者说)堆也无法再扩展时,将会抛出OutOfMemoryError异常,没有内存了
设置堆空间大小:
内存大小-Xmx/-Xms:
使用示例:-Xmx20m -Xms5m,说明: 当下Java应用最大可用内存为20M, 最小内存为5M
测试:
package com. test1 ;
public class TestVm {
public static void main ( String [ ] args) {
System . out. println ( Runtime . getRuntime ( ) ) ;
System . out. print ( "Xmx=" ) ;
System . out. println ( Runtime . getRuntime ( ) . maxMemory ( ) / 1024.0 / 1024 + "M" ) ;
System . out. print ( "free mem=" ) ;
System . out. println ( Runtime . getRuntime ( ) . freeMemory ( ) / 1024.0 / 1024 + "M" ) ;
System . out. print ( "total mem=" ) ;
System . out. println ( Runtime . getRuntime ( ) . totalMemory ( ) / 1024.0 / 1024 + "M" ) ;
}
}
执行后,看你的结果,我的是
Xmx = 2012.0 M
free mem= 122.03204345703125 M
total mem= 126.0 M
设置成这样(记得中间要有空格(不管有多少,只要有就行),即符合格式 ,否则会报错,会检验的,既然idea可以操作建议,那么java自身也可以检验很合理吧):
对于的还是之前的操作,只是输入不同的,所以可以看出,对于的VM options是一个在某个位置加上信息的选项,你输入的不同,那么jvm读取对应的信息(虽然是副本),所操作的结果自然不同,设置好后,我们继续执行:
Xmx = 20.0 M
free mem= 4.757331848144531 M
total mem= 6.0 M
结果不同了哦
可以发现,这里打印出来的Xmx值和设置的值之间是由差异的,total Memory和最大的内存之间还是存在一定 差异的,就是说JVM一般会尽量保持内存在一个尽可能底的层面,而非贪婪做法按照最大的内存来进行分配,并且虽然我们最小的是5m,但是堆可能需要一些固定操作,使得要固定占用一些空间,所以是6m
在测试代码中新增如下语句,申请内存分配:
将上面的注释打开:
byte [ ] b= new byte [ 5 * 1024 * 1024 ] ;
System . out. println ( "分配了5M空间给数组" ) ;
在申请分配了5m内存空间之后,total memory上升了,同时可用的内存可能也会发生改变,可以发现其实JVM在分配内存过 程中是动态的, 按需来分配的
我们将5改成20,那么会出现如下:
Exception in thread "main" java. lang. OutOfMemoryError: Java heap space
之前的说明是:OutOfMemoryError:本地方法栈(虚拟机栈)扩展时无法申请到足够的内存
这里就要进行补充(回应之前的"在后面也会继续补充对OutOfMemoryError(OOM)的说明"),即堆也加上,我们可以发现是"扩展时无法申请",所以与虚拟机栈一样的,是超过上限才会出现报错的(自然也是设置的上限,虽然他们都也有默认的值),这里是对总的拿取的内存(不是所有的物理内存)设置上限(总堆或栈内存),而不是操作需要的内存设置上限(之前的栈错误,即在拿取里面的内存做限制,比如我从系统的100中拿取10(1-10的范围的内存),那么你在4-6这个范围地方做限制,比如刚好是5,这里认为虚拟机栈在该4-6部分进行操作的,那么超过5报对应的栈错误(有兜底,所以会保留,虽然有点夸张),而类似(只是类似的)的超过10(如果是栈帧),报这里的内存错误),至于虚拟机栈和本地方法栈的该错误,这里并没有演示,以及本地方法栈的另外的错误(包括堆)也是如此,具体演示可以看百度
堆的分类:
现在垃圾回收器都使用分代理论,堆空间也分类如下:
在Java7 Hotspot虚拟机中将Java堆内存分为3个部分:
青年代Young Generation,老年代Old Generation,永久代Permanent Generation(虽然是说明方法区那里的,由于这里与堆有太多联系,一般也可以说成是堆的)
在Java8以后,由于方法区的内存不在分配在Java堆上,而是存储于本地内存元空间Metaspace中,所以永久代就不 存在了,在以前(可能是2018年9月25日)Java11正式发布以后,官网上可以找到了关于Java11中垃圾收集器的官方文档, 文档中通常没有提到"永久代",而只有青年代和老年代了
我们可以通过之前的代码进行试验,来看到一些关于他们的信息(操作了GC,这里了解即可):
package com. test1 ;
public class test {
public static void main ( String [ ] args) {
System . out. print ( "Xmx=" ) ;
System . out. println ( Runtime . getRuntime ( ) . maxMemory ( ) / 1024.0 / 1024 + "M" ) ;
System . out. print ( "free mem=" ) ;
System . out. println ( Runtime . getRuntime ( ) . freeMemory ( ) / 1024.0 / 1024 + "M" ) ;
System . out. print ( "total mem=" ) ;
System . out. println ( Runtime . getRuntime ( ) . totalMemory ( ) / 1024.0 / 1024 + "M" ) ;
}
}
修改对应配置:
+PrintGCDetails代表加上GC的打印细节,即给出对应的(GC)打印信息,一般还有+PrintGC,代表基本信息,可用认为+PrintGCDetails里面包括了+PrintGC,即我有你的信息,但是也有你没有的信息
然后我们执行看看结果:
可以发现有很多信息了,当然不同jdk版本可能信息不同,可以在上图的位置里面,找到并自己设置版本,不知道可以百度,注意了低的版本操作原来的打印,可能会报错,因为可能原来的版本中没有或者规定了不兼容,具体看打印信息有没有报错吧,具体问题具体分析,一般情况下我们需要将项目版本进行修改(前提可以设置,通常项目版本和语言版本(语言版本不对没有事,因为我们通常操作副本,即编译版本和运行版本的副本,其中语言版本决定是否有idea的检查,也就是说,idea的功能,若没有可能没有启动按钮,需要在右上角那里启动了,但是项目版本一般需要目标语言版本一致,否则报错,目标语言版本后面会说明),和编译版本,和运行版本通常要对应,其中高版本可以兼容低版本),编译版本比如(这个就是副本,他都可以修改了不是吗,默认没有修改的话,一般是项目版本,运行版本也是如此):
项目版本和语言版本(点击这个):
这两个就是项目版本和语言版本(目标语言版本就是Modules里面的语言版本,也就是说,该语言版本覆盖了当前的语言版本,所以才说他语言版本不对没有事,若他不对应项目版本,那么运行时会提示报错,出现"java: 警告: 源发行版 11 需要目标发行版 11",目标发行版本就是项目版本,源发行版本就是目标语言版本,注意:目标语言版本可以低于项目版本,即高版本兼容低的),而运行版本是如下(自然也是副本,前面也对设置虚拟机栈的大小的对应参数进行加上时,也说明了):
这个11,按照高版本可以兼容低的(运行可以大于编译,反过来不行,因为是运行来识别的(class到二进制到执行)),那么很明显,11大于8,可以执行,不会报错,实际上就算报错,可能对应的打印信息还是会出现,因为并不会是对应的线程的,只是对应的不会识别而报错的而已
即不兼容那么会报错的,且一般(并不决定,主要看jdk的开发者,但一般都会这样做)高版本jdk可以兼容低版本jdk的,反过来不行,因为高版本是增加一些或者在兼容的情况下修改或者增加,而不是单纯的减少一些或者使得不兼容了,但是现在这些打印(GC)信息我们并不需要了解,所以这些信息看看就可
而之所以认为对应是副本,是因为在不同的类中可以选择不同的选项,当然,在编译版本那里一般也是副本,是因为在不同的项目里也可以进行设置,所以idea就是一系列版本的组合使用(理解更深了吧,这些组合可以认为是通过某种连接来导致使用对方,就如引用一样可以通过引用使用其他地方的东西),当然这里了解即可
当然还有一点:项目版本兼容编译版本,高兼容低,一般高兼容低的,反过来不能兼容,这里要注意
总体来说:他们基本都需要对应,其中项目对应整个项目的操作(如依赖版本等等信息,虽然运行版本可能覆盖,但是只会覆盖一样的,所以多余的不会,即在某些时候他确可能需要其中一个低版本,但是大多数时候可以不对应运行版本),编译是编译,运行是运行,加起来就是整个环境,使得操作成功
注意:实际上与其说高版本兼容低的,还不如是他们自己的判断方式,是认为这样的,因为项目版本比较重要,运行版本比较重要,所以项目兼容低(高兼容低,包括语言,编译),运行兼容低(编译),而不是覆盖者兼容低,所以这是自己的判断方法(一般是重要判断),且他们两个却是相反的(一个被覆盖,项目版本被覆盖,一个覆盖,运行版本覆盖项目,并没有覆盖编译哦,但都也是兼容低的,所以是自己的判断方式哦(一般是重要判断))
年轻代(我们可以将青年代称为年轻代或者新生代)和老年代:
JVM中存储java对象可以被分为两类:
年轻代(Young Gen):年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁,年轻代分 成1个Eden Space和2个Suvivor Space(from 和to)
年老代(Tenured Gen):年老代主要存放JVM认为生命周期比较长的对象(经过几次的Young Gen的垃圾回收后仍然存在,特别是其中的其他两个区的其中一个(一般有阈值,比如如果经过几次识别还存在,那么放在这里的年老代里面去)),为什么是其中一个,在后面会说明),内存大小相对会比较大,垃圾回收也相对没有那么频繁
配置新生代和老年代堆结构占比:
默认 -XX:NewRatio=2(注意,你复制粘贴记得自己观察一下是否正确,因为复制粘贴的可能有问题,最好手写),标识新生代占1 (即默认是设置比例的,即1:2,即新生代是1,且不变,因为是比例,只需要设置一人即可,因为可以将一人除以到1的), 老年代占2,新生代占整个堆的1/3
修改占比 -XX:NewRatio=4,标识新生代占1,老年代占4,新生代占整个堆的1/5(从而导致在占用的过程中也是按照这个比例,这里代表是总体的,即都占满后是堆的1/5,当然过程也是一样的,我们也需要堆自己扩大到上限的)
在新生代中Eden空间和另外两个Survivor空间一般是占比分别为8:1:1
可以通过操作选项 -XX:SurvivorRatio 调整这个空间比例,比如 -XX:SurvivorRatio=8,就算是上面的比例,即其他两个是1(且不变,因为是比例,只需要设置一人即可,因为可以将一人除以到1的),之所以他们两个是1,因为有一个是空闲的(在后面会说明,所以他们需要一样的)
几乎所有的java对象都在Eden区创建,但80%的对象生命周期都很短,创建出来就会被销毁,也有一些幸存者存放在对应的其他两个区中(其中一个)
从图中可以看出: 堆大小 = 新生代 + 老年代,其中,堆的大小可以通过参数 -Xms、-Xmx 来指定
默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 (该值可以通过参数 -XX:NewRatio 来指定 ),即:新生 代 ( Young ) = 1/3 的堆空间大小,老年代 ( Old ) = 2/3 的堆空间大小,其中,新生代 ( Young ) 被细分为 Eden 和 两个Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分
同样默认的,Eden : from : to = 8 : 1 : 1 ( 可以 通过参数 -XX:SurvivorRatio 来设定 ),即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小
JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域 是空闲着的(这里就解释之前的"为什么是其中一个,在后面会说明"这个地方,也解释了为什么占比是同样的原因,如果不一样,那么选择的可能少,或者多,即对判断不公平"发现为什么我重新运行一下程序(选择了另外一个),有变化了呢"),因此,新生代实际可用的内存空间为 9/10(即90%)的新生代空间
接下来我们来进行另外一个测试:
package com. test2 ;
public class test {
public static void main ( String [ ] args) throws InterruptedException {
System . out. println ( "hello" ) ;
Thread . sleep ( 30000 ) ;
}
}
设置如下(一般来说加上了-XX,通常代表赋值需要=号,具体还是以我的测试为主):
但是虽然设置了,那么如何看到效果呢,这就需要一个工具了,在jdk文件中找到如下(主要给出找jdk8版本里面的(其他版本中可能没有,但可以在网上直接下载该工具,但是最好下载jdk8,因为他这个工具可能需要某些关联,而jdk8是自带的,即单独的下载可能运行不了),这个工具一般不可以跨版本测试的,因为他是有联系的工具,且没有互相兼容的哦,即jdk8的只能操作jdk8的版本,11只能操作11,而没有高版本兼容低版本的(11操作8)):
我们双击进入,到如下(VisualVM:英文意思是:可视化虚拟机):
注意看本地里面的信息,现在只有三个,好了,我们执行上面的程序,再过了看看,如下:
可以发现多出来了一个,但是程序结束后,他也随着消失(自动出现,自动消失),所以我才会操作睡眠,最好再大一点,点击他:
可以再下面的JVM参数中看到相关的信息,我们也可以添加插件,点击工具里面的插件,当然,可能现在对应的服务器不开放了,所以下载不了,你可以选择使用其他服务器(或者说代理),具体可以百度,这里先给出一个解决方式:
到如下:
再设置中,点击右边的编辑,改变URL为:https://visualvm.github.io/archive/uc/7u60/updates.xml.gz(改变服务器地址了),具体可用再这里进行找URK:http://visualvm.github.io/pluginscenters.html
然后到可用插件(可用发现多出后缀了,说明有插件了,而不是之前的什么都没有)里面,选择如下进行安装:
点击安装后,点击下一步即可,等等吧,如果出现报错,说明该URL地址中,虽然可用给出可用插件,但是他可能并不能去下载,那么我们换一个URL,再上面的地址中,复制这个:
继续编辑URL,来进行安装吧,安装好后,可用选择重新打开对应的本地的那个程序(点击xx,再打开),会出现如下:
我们可用看到对于的年轻代:是90+15+15是120,而老年代是480,即是1:4,但是对应的年轻代中却是6:1:1,当然了,可能有些是没有记录的,他可能会有些冗余不会操作(冗余在这里代表保留空间),当然了,这只是比例,我们也知道,由于需要按照他们的比例进行分配堆大小,所以在达到最大值时,中间的数据(包括各自上限,且是当前的,即当前上限是因为堆也会慢慢的增加,所以是按照他当前上限来分配的,而由于堆有最大,那么一般也会使得对应的各自上限也有最大,那么当他们继续提升上限时,由于冗余的存在,那么最后还是看堆总体上限使得出现对应的错误,一般来说当操作冗余时不会进行移动了)也会按照这个比例(虽然这样说,但是实际上数据可能并不会,只有上限会,因为垃圾回收机制的清除,所以最好将这个数据看成当前上限值,而不是数据),来进行添加数据(看成当前上限,需要堆自己扩大上限的,各自上限的总和,直到设置的上限,其中各自的一个上限提升也会带着其他上限提升),这个冗余可以认为是之前说明的"但是,maxMemory的值一般会小于-Xmx",即主要是操作这个地方,当然了,上面对应的只是代表当前的上限 (即15和90,而并不是 真正的数据,因为一个hello怎么可能占用15m或者90m呢)
扩大的上限一般也是按照比例(老年代一般也是,即eden导致幸存者,他们总体(新生代)导致老年代)
好了,我们进行添加设置,在对应的设置后面加上-XX:SurvivorRatio=8,继续执行看这里:
可以发现,发生了改变,至此可以得出结论,如果你没有设置,按照默认的他会对最大的并且中间操作的进行冗余,而设置后,最大的冗余,而中间(上限)的不会了(虽然后面的最终上限是自动扩大了,或者说,隐藏起来,使得显示都是12了)
当然,他的显示是固定的,也就是说,就算你关闭程序,他也存在,除非你自己退出(固定的,自然不会改变数值)
对象分配过程:
JVM设计者不仅需要考虑到内存如何分配,在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关, 因此还需要考虑GC执行完内存回收后,是否存在空间的中间中产生了内存碎片,而对于内存来说,可以认为是操作数,内存的碎片可以认为是占用了他们,在前面我们直到二进制和结果之间有中间,而中间是有限的(对应我的世界来说,方块是有限的,那么你用一些少一些,即中间用来处理操作的方块也就少了),那么这个可以操作的,我们可以认为是内存
分配过程:
1:new的对象先放在伊甸园(Eden的别称)区,该区域有大小限制(到下面的第二步)
2:当伊甸园(Eden的别称)区域填满时,程序又需要创建对象,那么JVM的垃圾回收器将对伊甸园预期进行垃圾回收(Minor GC,新生代收集,后面会说明的,注意是这个GC,有些GC一般不需要条件,而这个一般需要在Eden填满后操作),他在将伊 甸园区域中不再被其他对象引用的对象进行销毁,并再加载新的对象放到伊甸园区时,剩余的进行移动,即他是考虑经历是否垃圾回收来进行移动的,当然,并不是所有的GC填满才回收,有些是始终进行操作的(前面说明的GC就是这个),但是这里需要进行补充 ,由于GC需要资源,所以并不会始终的操作自动,所以一般来说GC都需要条件,但是为了不进行多出的STW(后面会说明),所以通常也会存在自动的GC(这里与其他博客说明不同的是,代表虚拟机关闭的优雅关闭,了解即可,会直接进行遍历关闭,具体可以百度(上网查找具体操作),一般我们认为自动gc只会在关闭虚拟机触发(可以回收所有,一般没有限制),所以大多数gc我们只能操作手动来进行优化,即自动gc我们直接忽略)
3:然后将伊甸园区中的剩余对象移动到幸存者0区(要满足比例),注意:并非一开始一定是幸存者0区,可能是幸存者1区,这只是一个随机判断而已,随机的判断代码是可以做到的(如随机数操作即可),这里考虑幸存者0区
4:如果再次触发垃圾回收,此时上次幸存下来的放在幸存者0区的,如果没有回收,就会放到幸存者1区
5:如果再次经历垃圾回收,此时会重新返回幸存者1区(改变位置了),然后继续会接着再去幸存者0区(再次的经历后),所以他们只有一人是空闲的,这就是主要的原因,那么为什么不一直保留呢,这样的来回不是很麻烦吗,实际上他的空闲只是对他来的数据来说的,所以他一般有其他功能,由于要使得内存分配合理,那么他就应该进行分开,来使得他变得流畅,而不是都放在一起,使得变得卡顿,所以需要分开
6:如果累计次数到达默认的15次或者放不下了(上面的第3步就是第一次,即触发垃圾回收一次就算一次,根据上面流程,即幸存者0区开始,那么当经历15次时,也就是到幸存者0区了,也就是S0时,那么当再次经历时,就会到养老区了),这会进入养老区(老年代),可以通过设置参数,调整阈值 -XX:MaxTenuringThreshold=N
7:养老区内存不足时,会再次触发GC:Major GC 进行养老区的内存清理
8:如果养老区执行了Major GC后仍然没有办法进行对象的保存,就会报OOM异常(java内存溢出错误,也就是之前说的OutOfMemoryError错误)
可以看到S1中有1和2,说明的确是随机的,1代表第一次回收到的位置
注意:这个图片内容可能并没有满足前面说明的空闲的情况,这在标记-复制算法中会提到,这里你认为是特殊情况即可
分配对象的流程(“方法的下?“修改成"放的下?”,因为这个图是复制过了的,这里注意即可,可能有其他错误,如最后的两个否,往上指的是"是”,若还有其他错误,自己注意即可):
很明显,上面的YGC就是新生代收集的作用,由于他作用后,会考虑对应的S0和S1(他们两个在新生代里面的)和老年代,所以单纯的给出一个细节位置,当他操作完后,在决定最后的分配内存(包括在新生代和老年代的分配,因为之前你只是决定是否可以放入,而并没有分配内存给他),之所以YGC后面也会考虑,是老年代也会操作新生代,看后面的触发机制就知道了
堆GC:
Java 中的堆也是 GC 收集垃圾的主要区域,GC 分为两种:一种是部分收集器(Partial GC)另一类是整堆收集器 (Full GC)
部分收集器:不是完整收集java堆的的收集器,它又分为:
新生代收集(Minor GC / Young GC):只是新生代的垃圾收集
老年代收集 (Major GC / Old GC):只是老年代的垃圾收集 (如CMS GC会单独回收老年代)
混合收集(Mixed GC):收集整个新生代及老年代(可能有些gc操作部分老年代)的垃圾收集 (如G1 GC会混合回收(都回收),region会区域回收)
整堆收集(Full GC):收集整个java堆和方法区的垃圾收集器(自然包括上面的所有位置,但可能比较慢)
上面我们主要说明Minor GC,Major GC,Full GC其他的后面可能会说明
对于后面的触发条件,是对应当前上限触发的,在前面堆说明的上限中以及讲了,要注意哦,而报错是针对真正的上限的,这也要注意
年轻代GC触发条件:
年轻代空间不足,就会触发Minor GC, 这里年轻代指的是Eden代满则触发,而Survivor满不满都不会引发GC(操作gc回收了,会回收,并且移动,只是不会触发gc而已),那么如果条件没有满足,且eden满了,或者且并且幸存者也满了,且触发gc,并没有清除很多(或者没有清除),即还是认为内存不足的,那么通常有另外的规则使得他们会跳过条件直接到老年代(首先Survivor到老年,然后eden到Survivor),或者没有规则的直接到老年代(首先Survivor到老年,然后eden到Survivor),也有可能都放入老年代(一般是这个,因为这里就相当于幸存的很多,导致幸存者不能放入了,再后面会说明这样的"特殊情况"(优化那里,即"很明显,他并没有回收,因为对应的都是存在的"这个地方,可以全局搜索,先了解一下)),注意:该gc是新生代的gc,而新生代包含eden和另外两个幸存者,所以他回收这三个,且他满的条件自然是数据进入的条件,当满后,数据进入会卡住,等他有空间出现(回收),这个时候回收的操作会进行,考虑是否操作到老年代,同理老年代也是如此,只是他的考虑是直接报错(并且,可能当前添加的数据清除,即数据慢慢添加的,自然记录保存之前的数据(如在添加之前,判断若添加后是否溢出而进行添加,所以可以操作进行保存之前的数据的,判断不难,防止底层jvm操作是可以实现,虽然在jvm中我们不知道他是怎么实现的,即识别后,是否有其他操作,要不然溢出是怎么来的呢),而一般这个报错可能会导致程序结束,即导致jvm结束(关闭),那么若真的结束(关闭),自然都会是释放的,当然了一般只是报错,那么就按照报错来进行处理,并非一定结束,当然溢出后若程序没有结束,那么一般也会继续触发gc的)
Minor GC会引发STW(stop the world),暂停其他用户的线程(基本上所有的线程),等垃圾回收接收,用户的线程才恢复(堆可是线程共享的哦),而正是因为这样,一般我们尽量不要引发STW或者说该回收,来影响线程的操作,而自动触发GC基本不会操作这个,但有时候,我们需要自动触发更快操作完,就将变量指向为null等等,其他gc会进行监测,并移除(回收,销毁)
老年代GC (Major GC)触发机制:
老年代空间不足时,也会尝试触发MinorGC(然后考虑放入新生代,反过来操作了,而由于也会触发他,所以不只是数据大,且也考虑STW了),如果空间还是不足,则触发Major GC
如果Major GC后 , 内存仍然不足,则报错OOM,Major GC的速度比Minor GC慢10倍以上(空间一般比较大了,在数据大的情况下,就算是遍历都需要很久的,而我空间不足,自然说明快满了)
注意:触发GC并不代表一定会移除,因为垃圾回收只是移除不需要的,也就是垃圾,所以他也只是尝试而已,而前面说过"冗余的存在,那么最后还是看堆总体上限使得出现对应的错误",这就是主要原因(尝试的MinorGC,因为老年代也会操作新生代,所以有冗余也是正常的,保留空间来应对特殊情况,比例是考虑冗余的,冗余在这里代表保留空间)
FullGC 触发机制:
调用System.gc()(手动gc) ,系统会执行Full GC,不是立即执行(总要有过渡吧,这一般是我们手动GC的一个方法,gc都有过渡,也就是激活时间,就如你在电脑上显示1,那么电脑内部自然需要很多流程,使得出现1的)
老年代空间不足也会触发,他的触发可能需要Major GC的某些条件(这样要注意哦,一般是考虑老年代空间大,需要多个GC吧,当然gc优先是Major GC,然后这个,除非你自己手动操作)
方法区空间不足也会触发(方法区的GC考虑触发)
我们一般也不会使用他,因为他也会操作STW(并不是因为操作了Minor GC引发的哦,他自带的),且他考虑非常多,可能时间还要更长(即可能比单纯的老年代还要长,即慢)
通过Minor GC进入老年代平均大小大于老年代可用内存(与上面老年代空间不足的是,新生代也是满的,虽然老年代也是,所以这里可以认为是大数据的"老年代空间不足也会触发"),也就是说从新生代一次性过来的数据(在一定时间内移动的数据加起来是一次性的,一般包括总数据),即在一定时间内突然老年代满了,那么触发这个,使得将整个堆进行回收,因为老年代从新生代来的数据使得老年代慢了,说明在那一瞬间(一定时间)整个堆是满的,那么我需要整体回收(一般也包括的幸存者,而Minor GC好像也会,操作三个"而新生代包含eden和另外两个幸存者,所以他回收这三个")
元空间:
在JDK1.7之前,HotSpot 虚拟机把方法区当成永久代来进行垃圾回收,而从 JDK 1.8 开始,移除永久代,并把方法 区移至元空间,它位于本地内存中,而不是虚拟机内存中,HotSpots取消了永久代,那么是不是也就没有方法区了 呢?
当然不是,方法区是一个规范,规范没变,它就一直在,只不过取代永久代的是元空间(Metaspace)而已,它和永久代有什么不同?
1:存储位置不同:永久代在物理上或者联系上是堆的一部分,和新生代、老年代的地址可以认为是连续的,而元空间属于本地内存
2:存储内容不同:在原来的永久代划分中,永久代用来存放类的元数据信息、静态变量以及(运行时)常量池等,现在类的元信 息存储在元空间中,静态变量和常量池等并入堆中,相当于原来的永久代中的数据(或者说方法区的数据),被元空间和堆内存给瓜分了
为什么要废弃永久代,引入元空间?
相比于之前的永久代划分,Oracle为什么要做这样的改进呢?
1:在原来的永久代划分中,永久代需要存放类的元数据、静态变量和常量等,它的大小不容易确定,因为这其 中有很多影响因素,比如类的总数,常量池的大小和方法数量等,-XX:MaxPermSize 指定太小很容易造成永久 代内存溢出(一般代表永久代的内存上限,如堆一样的上限意思)
2:移除永久代是为融合HotSpot VM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代
3:永久代会为GC带来不必要的复杂度,并且回收效率偏低
废除永久代的好处:
1:由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间,不会遇到永久代存在 时的内存溢出错误,但是可能会遇到对应自己设置的上限内存给的溢出错误(后面会说明,即"当Metaspace剩余空间不足",与堆一样的上限意思,只不过他可以默认没有限制而已)
2:将运行时常量池从PermGen(永久代,也可以认为是方法区,因为是把方法区当成永久代)分离出来,与类的元数据分开,提升类元数据的独立性(放入堆中)
3:将元数据从PermGen剥离出来到Metaspace,可以提升对元数据的管理同时提升GC效率
Metaspace(元空间)相关参数:
1:-XX:MetaspaceSize,初始空间大小(可以认为是GC操作的临界点,初始上限,所以方法区与堆一样的慢慢提高当前上限),当实际数据达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如 果释放了大量的空间,就适当降低该值,如果释放了很少的空间,那么在不超过MaxMetaspaceSize时(后面会说明),适当提 高该值
2:-XX:MaxMetaspaceSize,最大空间,默认是没有限制的,如果没有使用该参数来设置类的元数据的大小,其最 大可利用空间是整个系统内存的可用空间(在本地内存里面,所以他说的就是物理内存,如你电脑的所有内存,那么只要你有内存,他就会进行挖取使用,当然,一般他不会挖很多,而不像堆一样,挖了很多,而不使用,实际上对于虚拟机来说(不是java的,而是linux的),一般也是挖很多的,当然一般他也只是设置上限,而不是真的挖过来,堆特殊一点),JVM也可以增加本地内存空间来满足类元数据信息的存储,但是如果没有设置最大值,则可能存在bug导致Metaspace的空间在不停的扩展,会导致机器的内存不足,进 而可能出现swap内存被耗尽,最终导致进程直接被系统直接kill掉(系统自身也会有保护的,不会使得出现剧增的情况,或者说系统也内置了给你的最大内存的挖取,总之,电脑很复杂,经过这么多年,不是我一人可以理解的),如果设置了该参数,当Metaspace剩余空间不足,会抛出:java.lang.OutOfMemoryError: Metaspace space,因为是内存,所有是内存的溢出(不是栈哦)
3:-XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的 垃圾收集,不要回收太多了
4:-XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的 垃圾收集,不要回收太少了(除非没有回收的了)
方法区:
方法区的理解:
方法区(Method Area) 与Java堆一样, 是各个线程共享的内存区域, 它用于存储已被虚拟机加载 的类型信息、 常量、 静态变量、 即时编译器编译后的代码缓存等数据(可能还有其他数据,比如方法的存放(定义),即一份方法多人调用),他们这些信息,我们统称为类信息,因为他们的数据就是从类来的,所以可以这样说明
这里说明一下:堆和方法区的区别,在《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但是简单的实现可能不会 选择去进行垃圾收集或者进行压缩”,对HotSpot而言,方法区还有一个别名叫做Non-Heap(非堆),的就是 要和堆分开,所以你认为永久代是堆的也是正确的,不是也是正确的,这里考虑不是(逻辑上那么就是具体联系,物理上就是存放不一样,无论是逻辑上还是物理上我们都可以认为,这里考虑物理上)
元空间、永久代是方法区具体的落地实现,所以这里说明的方法区就看作是一块独立于Java堆的内存空间(这里统一说明 ,所以我们先认为常量池也在方法区中,尽管他在jdk8及其以后分离了),即包括对应的所有相关内容的说明,它主要是用来存储所加载 的类信息的
我们来看看创建对象各数据区域的声明:
很明显,类的信息是在方法区中的
方法区的特点:
1:方法区与堆一样是各个线程共享的内存区域
2:方法区在JVM启动的时候就会被创建并且它实例的物理内存空间和Java堆一样都可以不连续(但逻辑可能连续,即有连续方式连接)
3:方法区的大小跟堆空间一样 可以选择固定大小或者动态变化
4:方法区的对象决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会出现
(OOM)异常(Java7之前是 PermGen Space (永久带) Java 8之后 是MetaSpace(元空间) )
5:关闭JVM就会释放这个区域的内存(当然,其他的也是如此,一般只要是jvm的,一般都会进行释放,这也是为什么程序结束,GC可以不用回收的原因,前提是jvm里面的,如果有利用外面的,可能需要等会(因为通知,元空间可能会),虽然jvm看起来是跟随class来启动或者关闭的)
方法区结构:
方法区的内部结构
类加载器将Class文件加载到内存之后,将类的信息存储到方法区中(然后操作解释执行或者识别执行),也就是说方法区就是存放类的信息的(包括很多信息,前面有说明,在"虚拟机加载 的类型信息、 常量、 静态变量、 即时编译器编译后的代码缓存等数据"这个地方)
方法区中存储的内容:
类型信息(域信息、方法信息),运行时常量池
类型信息:
对每个加载的类型(类Class、接口 interface、枚举enum、注解 annotation),JVM必须在方法区中存储以下类型信息:
1:这个类型的完整有效名称(全名 = 包名.类名)
2:这个类型直接父类的完整有效名(对于 interface或是java.lang. Object,都没有父类,一般有个没有的标志,或者不存放 )
3:这个类型的修饰符( public, abstract,final等等)
4:这个类型直接接口的一个有序列表(多个实现接口,可能按照实现顺序进行排列,如implements a,b,那么就是a,b排列)
域信息:
1:域信息,即为类的属性,成员变量
2:JVM必须在方法区中保存类所有的成员变量相关信息及声明顺序
3:域的相关信息包括:域名称、域类型、域修饰符(pυblic、private、protected、static、final、volatile、transient等等)
方法信息:
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
1:方法名称方法的返回类型(或void)
2:方法参数的数量和类型(按顺序)
3:方法的修饰符public、private、protected、static、final、synchronized、native,、abstract等等
4:方法的字节码bytecodes、操作数栈、局部变量表及大小( abstract和native方法除外)
5:异常表( abstract和 native方法除外),每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏 移地址、被捕获的异常类的常量池索引
现在我们看这个代码:
package com. test2 ;
import java. io. Serializable ;
public class test1 extends Object implements Serializable {
private static String name = "hello java" ;
public test1 ( String name) {
this . name = name;
}
public static void main ( String [ ] args) {
int x = 100 ;
int y = 100 ;
int result = testSum ( x, y) ;
System . out. println ( result) ;
}
public static int testSum ( int x, int y) {
return x + y;
}
}
现在我们执行这个命令:javap -v test1 > test.txt,实际上无论对应的test1是否存在,他都会创建test.txt文件(改动界面(如切屏)或者点击目录里面的任何文件,稍等就会出现,并不需要必须重新操作目录的打开和关闭,因为关闭打开也就是改动界面的一种操作,只是是局部的而已),若存在,那么前面得出的结果放入该文件里面(注意目录哦),我们看看里面的内容即可
注意:若test.txt有内容,那么清空内容然后放入(文字覆盖的原理基本都是这样,除非是操作指向的,那么只是改变指向而已)
对应的信息如下:
Classfile xxx/ test1. class
Last modified 2023 年2 月13 日; size 869 bytes
MD5 checksum 19036838 a5b651f22b003899693cf060
Compiled from "test1.java"
public class com. test2. test1 implements java. io. Serializable
minor version: 0
major version: 52
flags: ( 0x0021 ) ACC_PUBLIC , ACC_SUPER
this_class: #8
super_class: #2
interfaces: 1 , fields: 1 , methods: 4 , attributes: 1
Constant pool:
#1 = Methodref #2. #3
#2 = Class #4
#3 = NameAndType #5 : #6
#4 = Utf8 java/ lang/ Object
#5 = Utf8 < init>
#6 = Utf8 ( ) V
#7 = Fieldref #8. #9
#8 = Class #10
#9 = NameAndType #11 : #12
#10 = Utf8 com/ test2/ test1
#11 = Utf8 name
#12 = Utf8 Ljava / lang/ String ;
#13 = Methodref #8. #14
#14 = NameAndType #15 : #16
#15 = Utf8 testSum
#16 = Utf8 ( II ) I
#17 = Fieldref #18. #19
#18 = Class #20
#19 = NameAndType #21 : #22
#20 = Utf8 java/ lang/ System
#21 = Utf8 out
#22 = Utf8 Ljava / io/ PrintStream ;
#23 = Methodref #24. #25
#24 = Class #26
#25 = NameAndType #27 : #28
#26 = Utf8 java/ io/ PrintStream
#27 = Utf8 println
#28 = Utf8 ( I ) V
#29 = String #30
#30 = Utf8 hello java
#31 = Class #32
#32 = Utf8 java/ io/ Serializable
#33 = Utf8 ( Ljava / lang/ String ; ) V
#34 = Utf8 Code
#35 = Utf8 LineNumberTable
#36 = Utf8 LocalVariableTable
#37 = Utf8 this
#38 = Utf8 Lcom / test2/ test1;
#39 = Utf8 main
#40 = Utf8 ( [ Ljava / lang/ String ; ) V
#41 = Utf8 args
#42 = Utf8 [ Ljava / lang/ String ;
#43 = Utf8 x
#44 = Utf8 I
#45 = Utf8 y
#46 = Utf8 result
#47 = Utf8 < clinit>
#48 = Utf8 SourceFile
#49 = Utf8 test1. java
{
public com. test2. test1 ( java. lang. String) ;
descriptor: ( Ljava / lang/ String ; ) V
flags: ( 0x0001 ) ACC_PUBLIC
Code :
stack= 1 , locals= 2 , args_size= 2
0 : aload_0
1 : invokespecial #1
4 : aload_0
5 : pop
6 : aload_1
7 : putstatic #7
10 : return
LineNumberTable :
line 11 : 0
line 12 : 4
line 13 : 10
LocalVariableTable :
Start Length Slot Name Signature
0 11 0 this Lcom / test2/ test1;
0 11 1 name Ljava / lang/ String ;
public static void main ( java. lang. String[ ] ) ;
descriptor: ( [ Ljava / lang/ String ; ) V
flags: ( 0x0009 ) ACC_PUBLIC , ACC_STATIC
Code :
stack= 2 , locals= 4 , args_size= 1
0 : bipush 100
2 : istore_1
3 : bipush 100
5 : istore_2
6 : iload_1
7 : iload_2
8 : invokestatic #13
11 : istore_3
12 : getstatic #17
15 : iload_3
16 : invokevirtual #23
19 : return
LineNumberTable :
line 16 : 0
line 17 : 3
line 18 : 6
line 19 : 12
line 20 : 19
LocalVariableTable :
Start Length Slot Name Signature
0 20 0 args [ Ljava / lang/ String ;
3 17 1 x I
6 14 2 y I
12 8 3 result I
public static int testSum ( int , int ) ;
descriptor: ( II ) I
flags: ( 0x0009 ) ACC_PUBLIC , ACC_STATIC
Code :
stack= 2 , locals= 2 , args_size= 2
0 : iload_0
1 : iload_1
2 : iadd
3 : ireturn
LineNumberTable :
line 23 : 0
LocalVariableTable :
Start Length Slot Name Signature
0 4 0 x I
0 4 1 y I
static { } ;
descriptor: ( ) V
flags: ( 0x0008 ) ACC_STATIC
Code :
stack= 1 , locals= 0 , args_size= 0
0 : ldc #29
2 : putstatic #7
5 : return
LineNumberTable :
line 9 : 0
}
SourceFile : "test1.java"
至此,该字节码大致说明完毕,这就是字节码的所有信息了(其中类信息和运行时常量池,也基本就是所有的都被加载放入方法区的信息),可以发现,他的确解释了之前说明的"我们可以发现他自身就有很多关系",且满足对应的信息在方法区的说明(类加载器将Class文件加载到内存之后,将类的信息存储到方法区中)
方法区设置:
方法区的大小不必是固定的,JVM可以根据应用的需要动态调整
jdk7及以前:
通过-xx:Permsize来设置永久代初始分配空间,默认值是20.75M,JDK1.8之后-XX:MaxPermSize设置会被忽略(一般jdk8以前是没有-XX:MaxMetaspaceSize的,具体可以百度)
-XX:MaxPermsize来设定永久代最大可分配空间,32位机器默认是64M,64位机器默认是82M
当JVM加载的类信息容量超过了这个值,会报异常OutofMemoryError:PermGen space
查看JDK PermSpace区域默认大小:
jps
jinfo -flag PermSize 进程号
jinfo -flag MaxPermSize 进程号
这里可以先不操作,我们在jdk8里进行相关测试即可,因为我们主要说明jdk8(如果可以,你自己可以下载jdk7来进行测试,其他版本可能报找不到错误)
JDK8以后:
元数据区大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize指定
默认值依赖于平台,windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1,即没有限制(元空间在本地(物理)内存里面,所以没有像jdk7一样有对应默认的上限,虽然他的无限制可能也是有限的,看后面的值就知道了:18446744073709486080)
与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存,如果元数据区发生溢出,虚拟 机一样会抛出异常OutOfMemoryError:Metaspace
-XX:MetaspaceSize:设置初始的元空间大小,对于一个64位的服务器端JVM来说,其默认的-xx:MetaspaceSize值为21MB(大约),这就是初始的高水位线,一旦触及这个水位线,FullGC将会被触发(方法区的回收(卸载)操作一般需要他),并卸载没用的类(即这些类对应的类加载 器不再存活,一般来说只是没用的类,与自动gc是一样的,所以自动gc一般也是全局回收,或者也可以说整堆收集(全局比较好,因为虚拟机都要关闭了,即一般他可以认为是全局回收,因为大多数的数据都在堆和方法区里面,共享的吗,即一直保存,而其他由于私有,那么不会一直保存)),然后这个高水位线将会重置,新的高水位线的值取决于GC后释放了多少元空间,如果释放的空间不 足,那么在不超过MaxMetaspaceSize时,适当提高该值,如果释放空间过多,则适当降低该值,如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次(因为相对来说释放的少,当然,设置的大也是如此,只是一般不会设置的占用很多,所以以设置的小为主),通过垃圾回收器的日志可以观察到FullGC多次调用,为了避免频繁地GC(他这个会操作STW),建议将-XX:MetaspaceSize设置为一个相对较高的值,但不会太高"设置的大也是如此"
jps
jinfo -flag MetaspaceSize 进程号
jinfo -flag MaxMetaspaceSize 进程号
可以进行测试一下,首先给出代码:
package com. test2 ;
public class test3 {
public static void main ( String [ ] args) throws InterruptedException {
System . out. println ( "Hello World" ) ;
Thread . sleep ( 200000 ) ;
}
}
在控制台执行jps,随便那个目录就可,可以看到带有test3的线程,关闭程序他就没有了,然后我们执行jinfo -flag MetaspaceSize 进程号,进程号写自己的,因为每次启动他所占用的进程号并不相同,比如我的是:jinfo -flag MetaspaceSize 24276(对应的端口要存在,否则提示拒绝访问)
可以看到这个:-XX:MetaspaceSize=21807104,1024 * 1024 * 20=20971520,那么很明显,对应的中间有小数(20是取整的),通过百度的在线计算器得出是20.796875,即偏向于21,那么默认应该大致是21(所以之前说"大约"),即的确是默认
执行jinfo -flag MaxMetaspaceSize 14368,得出-XX:MaxMetaspaceSize=18446744073709486080,这么多字节,直接除以比较麻烦,我们直接除以1000来估算吧,那么他就是18446744073709m,很明显是100万亿mb,也就是说,在当前我们小众的电脑上他的确是无限制的,虽然他这是是-1,那么这个-1默认应该是这个值了,即无限制,除非你电脑内存实在够大,那么他在无限制的情况下,可能也会出现内存溢出的异常
我们进行设置修改(注意可以设置加上单位m,k,没有单位默认是字节单位,与之前的"主要是这样的设置:"这个地方的一样的看起来的说明):
继续查看会发现改变了,-XX:MetaspaceSize=104857600,-XX:MaxMetaspaceSize=524288000
1024 * 1025 * 100=104857600,那么1024 * 1025 * 500=524288000,即是正确的,同理对应的jdk7的对应设置也是如此,前提是jdk7,否则一般会提示没有(虽然说忽略,但是实际可能是没有的),如no such flag ‘PermSize’,或者no such flag ‘MaxPermSize’,no such flag的英文意思:没有这样的标志
运行时常量池:
常量池vs运行时常量池,虽然在前面我们会让常量池称为运行时常量池,那么为什么又要这样分了,看如下:
字节码文件中,内部包含了常量池
方法区中,内部包含了运行时常量池
很明显,实际上他们还是同一个常量池数据,只是分开成一个文件中(没有加载),一个加载(没有运行)中而已,这也是"运行时"这个说明的由来,而不是"运行后",所以说成是一个(都是一个数据来的)或者不是一个(是否加载)都行
所以可以这样说:
常量池:存放编译期间生成的各种字面量(如字符串)与符号引用
运行时常量池:常量池表在运行时的表现形式
编译后的字节码文件中包含了类型信息、域信息、方法信息等,通过ClassLoader将字节码文件的常量池中的信息加 载到内存中,存储在了方法区的运行时常量池中
理解为字节码中的常量池 Constant pool 只是文件信息,它想要执行就必须加载到内存中,而Java程序是靠JVM进行加载,更具体的来说是JVM的执行引擎来解释执行的,执行引擎在运行时,从常量池中取数据,而被加载的字节码常量池 中的信息是放到了方法区的运行时常量池中
它们不是一个概念,存放的位置是不同的,一个在字节码文件中,一个在方法区中
对字节码文件反编译(也就查看class文件,我们的javap就是这样操作的,因为原来的是乱码,所以这个反编译的意思并不是编译class文件到java的意思,而是查看class文件内容的信息的意思,即将里面的内容反编译成可以直观的看的信息)之后,查看常量池相关信息,也就是前面Constant pool对应的信息,所以这里就不做说明了
要弄清楚方法区的运行时常量池,需要理解清楚字节码中的常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量 池表( Constant pool table),包括各种字面量和对类型、域和方法的符号引用
常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型
常量池表Constant pool table:
以上面的内容为主:
Constant pool:
#1 = Methodref #2. #3
#2 = Class #4
#3 = NameAndType #5 : #6
#4 = Utf8 java/ lang/ Object
#5 = Utf8 < init>
#6 = Utf8 ( ) V
#7 = Fieldref #8. #9
#8 = Class #10
#9 = NameAndType #11 : #12
#10 = Utf8 com/ test2/ test1
#11 = Utf8 name
#12 = Utf8 Ljava / lang/ String ;
#13 = Methodref #8. #14
#14 = NameAndType #15 : #16
#15 = Utf8 testSum
#16 = Utf8 ( II ) I
#17 = Fieldref #18. #19
#18 = Class #20
#19 = NameAndType #21 : #22
#20 = Utf8 java/ lang/ System
#21 = Utf8 out
#22 = Utf8 Ljava / io/ PrintStream ;
#23 = Methodref #24. #25
#24 = Class #26
#25 = NameAndType #27 : #28
#26 = Utf8 java/ io/ PrintStream
#27 = Utf8 println
#28 = Utf8 ( I ) V
#29 = String #30
#30 = Utf8 hello java
#31 = Class #32
#32 = Utf8 java/ io/ Serializable
#33 = Utf8 ( Ljava / lang/ String ; ) V
#34 = Utf8 Code
#35 = Utf8 LineNumberTable
#36 = Utf8 LocalVariableTable
#37 = Utf8 this
#38 = Utf8 Lcom / test2/ test1;
#39 = Utf8 main
#40 = Utf8 ( [ Ljava / lang/ String ; ) V
#41 = Utf8 args
#42 = Utf8 [ Ljava / lang/ String ;
#43 = Utf8 x
#44 = Utf8 I
#45 = Utf8 y
#46 = Utf8 result
#47 = Utf8 < clinit>
#48 = Utf8 SourceFile
#49 = Utf8 test1. java
上面常量池就是常量池表,也称(运行时)常量池
在方法中对常量池表的符号引用(以main方法为主)
public static void main ( java. lang. String[ ] ) ;
descriptor: ( [ Ljava / lang/ String ; ) V
flags: ( 0x0009 ) ACC_PUBLIC , ACC_STATIC
Code :
stack= 2 , locals= 4 , args_size= 1
0 : bipush 100
2 : istore_1
3 : bipush 100
5 : istore_2
6 : iload_1
7 : iload_2
8 : invokestatic #13
11 : istore_3
12 : getstatic #17
15 : iload_3
16 : invokevirtual #23
19 : return
LineNumberTable :
line 16 : 0
line 17 : 3
line 18 : 6
line 19 : 12
line 20 : 19
LocalVariableTable :
Start Length Slot Name Signature
0 20 0 args [ Ljava / lang/ String ;
3 17 1 x I
6 14 2 y I
12 8 3 result I
那么他必然是操作引用的吗,答:是的,也就是说,常量池也是保存方法的地方(只是引用,虽然类信息也保存的,但是我们在运行时,是他来调用的),即方法区在获取类信息方法时,他识别时虽然是从常量池进行调用的,但是他在运行时常量池中,虽然也保存在类信息中,但是调用确需要运行时常量池来调用,所以这里重新定义(运行时)常量池,他就是一个存放引用的地方(可能有字面值,如字符串,字面值就是一个看的到值,如String a = “1”,这个1就是字面值),所以前面是这样说的:
常量池:存放编译期间生成的各种字面量(如字符串)与符号引用
运行时常量池:常量池表在运行时的表现形式
所以说虽然方法在方法区里,但是若更加的细分的话,可以认为他逻辑上(使用引用)在运行时常量池中,实际上(最终还是使用类信息)在类信息中,而其他被识别的信息(类信息),自然就是在方法区中的其他地方存放了(类信息),只是有运行时常量池我们会认为逻辑分开,实际上他们都是一起的(只是我们假装的分开而已),实际上运行时常量池可能也包括了其他的类型,域,方法的引用的,因为只是运行时的表现的
综上所述:方法区有实际存放的位置,和这些位置信息的引用来结合的,也就是class的操作者,其中由类加载器加载后,才被进行识别执行,所以,之前的识别都是建立在先加载,然后识别的 (虽然可能并没有说明加载过程,因为还没有说明到方法区这里),当然了,他们加载的信息与class基本是一样的,所以你认为是对着class识别也是没有问题
这里给出一个创建对象出现的字节码:
0 new #7 < com/ test1/ PC >
3 dup
为什么需要常量池?
举例来说:
public class Solution {
public void method ( ) {
System . out. println ( "are you ok" ) ;
}
}
这段代码很简单,但是里面却使用了 String、 System、 PrintStream及Object等结构,如果代码多,引用到的结构会更多,这里就需要常量池,将这些引用转变为符号引用,具体用到时,采取加载(可以认为,没有引用来帮你执行,你怎么能很好的知道操作那一个呢,或者说不会提取,这不是主要的,因为可以通过直接显示来解决,而最主要的是之前的说明的"而引用是一个总显示,只需要少的字节码(如#2,就可以替换掉<java/lang/Math.random : ()D>)就可以显示了"这个地方)
直接内存:
直接内存(Direct Memory) 并不是虚拟机运行时数据区的一部分
在JDK 1.4中新加入了NIO(New Input/Output) 类, 引入了一种基于通道(Channel) 与缓冲区 (Buffer) 的I/O方 式, 它可以使用Native函数库直接分配堆外内存, 然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块 内存的引用进行操作,这样能在一些场景中显著提高性能, 因为避免了 在Java堆和Native堆中来回复制数据
上面的物理内存映射文件的存储就是直接内存的主要存储(作用)
NIO的Bu提供一个可以直接访问系统物理内存的类——DirectByteBuffer,DirectByteBuffer类继承自ByteBuffer,但和普通 的ByteBuffer不同,普通的ByteBuffer仍在JVM堆上进行分配内存(在堆里面),其最大内存(自己占用堆)受到最大堆内存的 限制,而DirectByteBuffer直 接分配在物理内存中,并不占用堆空间,在访问普通的ByteBuffer时,系统总是会使用一个"内核缓冲区"进行操作,而DirectByteBuffer所处的位置,就相当于这个"内核缓冲区",因此,使用DirectByteBuffer是一种更加接近内存底层的方法, 所以它的速度比普通的ByteBuffer(可以将ffer进行清除,来简写,即Bu)更快,一般这里可以将DirectByteBuffer称为DirectBuffer,因为是他DirectByteBuffer的父类(接口是一个特殊的类)
通过使用堆外内存(jdk7也是,只是方法区不同而已,但他们也是操作直接内存的),可以带来以下好处:
1:改善堆过大时垃圾回收效率,减少停顿,Full GC时会扫描堆内存,回收效率和堆大小成正比,Native的内 存,由OS(操作系统,不是jvm中哦,因为他底层是c相关的,自然是利用操作系统内存,也就是物理内存,因为一般将操作系统认为就是电脑的)负责管理和回收
2:减少内存在Native堆和JVM堆拷贝过程,避免拷贝损耗,降低内存使用
3:可突破JVM内存大小限制(映射文件在外面 ,即物理内存里面)
注意:他一边与堆进行操作,在后面说明他时,实际上他又有堆的上限(后面的"直接内存溢出"会说明的)
实战OutOfMemoryError异常:
Java堆溢出:
堆内存中主要存放对象、数组等,只要不断地创建这些对象,并且保证 GC Roots 到对象之间有可达路径来避免垃 圾收集回收机制清除这些对象,当这些对象所占空间超过最大堆容量时,就会产生 OutOfMemoryError 的异常,堆 内存异常示例如下(主要代码,省略了如package或者import的相关代码):
public class HeapOOM {
static class OOMObject {
}
public static void main ( String [ ] args) {
List < OOMObject > oomObjectList = new ArrayList < > ( ) ;
while ( true ) {
oomObjectList. add ( new OOMObject ( ) ) ;
}
}
}
运行后会报异常,在堆栈信息中可以看到:
Exception in thread "main" java. lang. OutOfMemoryError: Java heap space
说明在堆内存空间产生内存溢出的异常(虽然前面也测试过)
新产生的对象最初分配在新生代,新生代满后会进行一次 Minor GC ,如果 Minor GC 后空间不足会把该对象和 新生代满足条件的对象(如前面的15次,他们统称在新生代的)放入老年代,老年代空间不足时会进行 Full GC(他也可以,还有Major GC和MinorGC(老年代空间不足一般也会触发他们,前面说明过了)),之后如果空间(那么一般是老年代了,老年代满才会这样,新生代是否满并不会,因为他们最终是操作到老年代的)还不足以存放新对象(或者说gc后面在一定时间内又出现,总不能又gc,所以对于触发某些操作时,在操作后若继续一样的情况,才会出现兜底操作,大多数的类似的都是如此,如这里gc的操作),则抛 出 OutOfMemoryError 异常(前提是真的上限)
常见原因:
1:内存中加载的数据过多,如一次从数据库中取出过多数据(一般引用包括,即对象数据,那么自然是堆)
2:集合对对象引用过多且使用完后没有清空
3;代码中存在死循环或循环产生过多重复对象
4:堆内存分配不合理
虚拟机栈和本地方法栈溢出:
由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈, 因此对于HotSpot来说, -Xoss参数(设置本地方法栈大 小,或者虚拟机栈大小,就是栈大小,一般只有远古版本才存在,即才会有效果) 虽然存在, 但实际上是没有任何效果的, 栈容量只能由-Xss参数来设定(也就是说他们共有一个栈,只是根据作用将该栈进行区分,逻辑考虑区分,物理考虑存放一样,这里考虑物理,与方法区的那个说明是相反的,即"逻辑上那么就是具体联系,物理上就是存放不一样,无论是逻辑上还是物理上我们都可以认为,这里考虑物理上"),关于虚拟机栈和本地方法栈, 在 《Java虚拟机规范》 中描述了两种异常:
1:如果线程请求的栈深度大于虚拟机所允许的最大深度, 将抛出StackOverflowError异常
2:如果虚拟机的栈内存不允许动态扩展, 当(扩展)栈容量无法申请到足够的内存时(申请对栈帧的空间,也就是拿内存), 将抛出 OutOfMemoryError异常(这里决定操作这个,由于本地方法栈与虚拟机栈一样的物理保存,所以这里也就相当于说明的本地方法栈的错误了)
《Java虚拟机规范》 明确允许Java虚拟机实现自行选择是否支持栈的动态扩展, 而HotSpot虚拟机的选择是支持 扩展(可能默认不支持,具体可以百度), 所以若在支持的情况下,除非在创建线程申请内存时就因无法获得足够内存而出现 OutOfMemoryError异常(一开始,不让他操作到扩展)), 否则在线程运行时 是不会因为扩展而导致内存溢出的(他自动(动态)扩展的,那么自然没有上限), 只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常,为了验证 这点, 我们可以做两个实验:
先将实验范围限制在单线程中操作, 尝试下面两种行为是 否能让HotSpot虚拟机产 生OutOfMemoryError异常:
1:使用-Xss参数减少栈内存容量,结果: 抛出StackOverflowError异常
2:异常出现时输出 的堆栈深度相应缩小,其中定义了大量的本地变量, 增大此方法帧中本地变量表的长度,结果: 抛出StackOverflowError异常, 且异常出现时输出的堆栈深度相应缩小
首先, 对第一种情况进行测试 :
package com. test3 ;
public class JavaVMStackSOF {
private int stackLength = 1 ;
public void stackLeak ( ) {
stackLength++ ;
stackLeak ( ) ;
}
public static void main ( String [ ] args) throws Throwable {
JavaVMStackSOF oom = new JavaVMStackSOF ( ) ;
try {
oom. stackLeak ( ) ;
} catch ( Throwable e) {
System . out. println ( "stack length:" + oom. stackLength) ;
throw e;
}
}
}
对应结果:
对于不同版本的Java虚拟机和不同的操作系统, 栈容量最小值可能会有所限制, 这主要取决于操 作系统内存分页 大小,假如上述方法中的对应参数设置-Xss128k可以正常用于32位Windows系统下的JDK 6,可能64的jdk8也行, 但 是如果用于64位Windows系 统下的JDK 11, 则会提示栈容量最小不能低于180K, 而在(一般也是64)Linux下这个值则 可能是228K, 如果低于这个最小限 制, HotSpot虚拟器启动时会给出如下提示(以jdk11为例):
The Java thread stack size specified is too small. Specify at least 180 k
现在我们继续验证第二种情况, 这次代码就显得有些"丑陋"了,但为了多占局部变量表空间, 不得不定义一长串变量, 具体看如下代码:
package com. test3 ;
public class JavaVMStackSOF1 {
private static int stackLength = 0 ;
public static void test ( ) {
long unused1, unused2, unused3, unused4, unused5,
unused6, unused7, unused8, unused9, unused10,
unused11, unused12, unused13, unused14, unused15,
unused16, unused17, unused18, unused19, unused20,
unused21, unused22, unused23, unused24, unused25,
unused26, unused27, unused28, unused29, unused30,
unused31, unused32, unused33, unused34, unused35,
unused36, unused37, unused38, unused39, unused40,
unused41, unused42, unused43, unused44, unused45,
unused46, unused47, unused48, unused49, unused50,
unused51, unused52, unused53, unused54, unused55,
unused56, unused57, unused58, unused59, unused60,
unused61, unused62, unused63, unused64, unused65,
unused66, unused67, unused68, unused69, unused70,
unused71, unused72, unused73, unused74, unused75,
unused76, unused77, unused78, unused79, unused80,
unused81, unused82, unused83, unused84, unused85,
unused86, unused87, unused88, unused89, unused90,
unused91, unused92, unused93, unused94, unused95,
unused96, unused97, unused98, unused99, unused100;
stackLength++ ;
test ( ) ;
unused1 = unused2 = unused3 = unused4 = unused5 =
unused6 = unused7 = unused8 = unused9 = unused10 =
unused11 = unused12 = unused13 = unused14 = unused15 =
unused16 = unused17 = unused18 = unused19 = unused20 =
unused21 = unused22 = unused23 = unused24 = unused25 =
unused26 = unused27 = unused28 = unused29 = unused30 =
unused31 = unused32 = unused33 = unused34 = unused35 =
unused36 = unused37 = unused38 = unused39 = unused40 =
unused41 = unused42 = unused43 = unused44 = unused45 =
unused46 = unused47 = unused48 = unused49 = unused50 =
unused51 = unused52 = unused53 = unused54 = unused55 =
unused56 = unused57 = unused58 = unused59 = unused60 =
unused61 = unused62 = unused63 = unused64 = unused65 =
unused66 = unused67 = unused68 = unused69 = unused70 =
unused71 = unused72 = unused73 = unused74 = unused75 =
unused76 = unused77 = unused78 = unused79 = unused80 =
unused81 = unused82 = unused83 = unused84 = unused85 =
unused86 = unused87 = unused88 = unused89 = unused90 =
unused91 = unused92 = unused93 = unused94 = unused95 =
unused96 = unused97 = unused98 = unused99 = unused100 = 0 ;
}
public static void main ( String [ ] args) {
try {
test ( ) ;
} catch ( Error e) {
System . out. println ( "stack length:" + stackLength) ;
throw e;
}
}
}
会发现异常还是Exception in thread “main” java.lang.StackOverflowError,且异常出现时输出的堆栈深度相应缩小,那么这个意思是什么呢,看上面的System.out.println(“stack length:” + stackLength);结果,当你删除递归后面的赋值操作,会发现该结构变化不大,因为他基本没有操作,可以忽略,当删除定义的变量时,结果变得很大,也就是类似之前的JavaVMStackSOF类的测试的结果
所以说,当定义了大量的变量,会使得深度变小,即System.out.println(“stack length:” + stackLength);结果变小,那么为什么会变小呢:
看这个解释:如果我们将虚拟机栈看成100,每个栈帧是10,当里面没有任何操作时,递归10个方法就会报错,那么深度是10,而如果有大量的变量,那么这个10会自动扩展(动态扩展,数据少可能不会,这也是为什么定义大量的变量来让他进行扩展的原因),很明显,10如果进行扩展,可以变成20或者更大,以20为例,那么递归只有5次,那么深度就是5,这也是为什么会变小的原因
实验结果表明: 无论是由于栈帧太大还是虚拟机栈容量太小, 当新的栈帧内存无法分配的时候, HotSpot虚拟机抛 出的都是StackOverflowError异常,注意:这是建立在他会扩展的情况下,可是如果在不允许动态扩展栈容量大小的虚拟机 上, 相同代码则会导致不一样的 情况,譬如远古时代的Classic虚拟机, 这款虚拟机不支持动态扩展 栈内存的容量(可能也支持,可能一般默认支持,可能并不对,可以去百度查看), 在Windows上的JDK 1.0.2运 行上面的代码话(如果这时候要调整栈容量就应该改 用-oss参数了) , 得到的结果可能是:
stack length: 3716
java. lang. OutOfMemoryError
可见类似的代码在Classic虚拟机中成功产生了OutOfMemoryError而不是StackOver-flowError异常,如果测试时不限 于单线程, 通过不断建立线程的方式, 在HotSpot上也是可以产生类似的内存溢出异常的
但是这样产生的内存溢出异常和栈空间是否足够并不存在任何直接的关系, 主要取决于操作系统本身的内存使用状 态(因为线程与虚拟机栈他们是一对一的,即线程私有),甚至可以说, 在这种情况下, 给每个线程的栈分配的内存越大, 反而越容易产生内存溢出异常,原因其实不 难理解, 操作系统分配给每个进程的内存是有限制的, 譬如32位Windows的单个进程 最大内存限制为2GB,如果没有分配,自然也会出现该异常,也就是物理内存不够了,就如之前的"虚拟机会耗尽所有的可用系统内存,如果元数据区发生溢出"一样也会出现这样的错误
HotSpot虚拟机提供了参数可以控制Java堆和方法区这两部分的内存的最大值, 那剩余的内存即为2GB(操作系统 限制) 减去最大堆容量, 再减去最大方法区容量, 由于程序计数器 消耗内存很小, 可以忽略掉, 如果把直接内 存和虚拟机进程本身耗费的内存也去掉的话, 剩下的内存 就由虚拟机栈和本地方法栈来分配了,因此为每个线程 分配到的栈内存越大, 可以建立的线程数量自然就越少, 建立线程时就越容易把剩下的内存耗尽, 代码如下:
package com. test3 ;
public class JavaVMStackOOM {
private void dontStop ( ) {
while ( true ) {
}
}
public void stackLeakByThread ( ) {
while ( true ) {
Thread thread = new Thread ( new Runnable ( ) {
@Override
public void run ( ) {
dontStop ( ) ;
}
} ) ;
thread. start ( ) ;
}
}
public static void main ( String [ ] args) throws Throwable {
JavaVMStackOOM oom = new JavaVMStackOOM ( ) ;
oom. stackLeakByThread ( ) ;
}
}
注意: 由于他是消耗物理内存的,所以我建议你不要执行这个代码,卡死了不要怪我,如果非要执行,记得要先保存当前的工作(包括其他工作),我测试时,都快要关不掉了,所以这里很难测试出来
至于为什么容易卡死,主要是因为Java的线程是映射到操作系统的内核线程上, 无限制地创建线程会对操作系统带来很大压力, 上述代码执行 时有很高的风险, 可能会由于创建线程数量过多而导致操作系统 假死,简单来说也就是内存被一直使用或者说消耗,手机没有运行内存都会很卡,电脑自然也是一样
Exception in thread "main" java. lang. OutOfMemoryError: unable to create native thread
出现StackOverflowError异常时, 会有明确错误堆栈可供分析, 相对而言比较容易定位到问题所在(错误信息一般会给出行数,如递归的方法执行(不是方法的定义,比如前面test方法里面的test();)在第几行,那么他就是该错误出现的原因)
如果使用HotSpot虚拟机默认参数, 栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是 一样的, 所以只能说大多 数情况下,这是因为方法并不是相同的,就算是相同的,可能也会不一样,忽上忽下) 到达1000~2000是完全没有问题, 对于正常的方法调用(包括不能 做尾递归优化的递归调用) , 这个 深度应该完全够用了,但是, 如果是建立过多线程导致的内存溢 出, 在不能减少线程数量或者更换64位虚拟机的 情况下(64位一般不会让你出现该错误,防止对系统的内存都使用(消耗)完毕,前面说明的"系统自身也会有保护的"), 就只能通过减少最大堆和减少栈容量来换取更多的线程,这种通过"减少内存"的手段来解决内存溢出的 方式, 如果没有这方面处理经验, 一般比 较难以想到, 这一点特别是需要在开发32位系统的多线程应用时注意,也 是由于这种问题较为隐蔽, 从 JDK 7起, 以上提示信息中"unable to create native thread"(英文翻译:无法创建本机线程)后面, 虚拟机可能会特别注明原因(在后面加上),可能是"possibly out of memory or process/resource limits reached"(英文翻译:可能已达到内存不足或进程/资源限制)
运行时常量池和方法区溢出:
在这之前可能只是大致的说明,但好像并没有测试过,现在进行测试:
由于运行时常量池是方法区的一部分, 所以这两个区域的溢出测试可以放到一起进行,前面曾经提到HotSpot从JDK 7开始逐步"去永久代"的计划, 并在JDK 8中完全使用元空间来代替永久代的背景故事, 在此我们就以测试代码 来观察一下, 使用"永久代"还是"元空间"来实现方法区, 对程序有什么 实际的影响
String的intern()是一个本地方法, 它的作用是如果字符串常量池中已经包含一个等于此String对象的 字符串, 则返 回代表池中这个字符串的String对象的引用,否则, 会将此String对象包含的字符串添加到常量池中, 并且返回此String对象的引用,在JDK 6或更早之前的HotSpot虚拟机中, 常量池都是分配在永久代中, 我们可以通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小, 即可间接限制其中常量池的容量, 具体实现看如下代码:
package com. test3 ;
import java. util. HashSet ;
import java. util. Set ;
import java. util. function. Function ;
public class RuntimeConstantPoolOOM {
public static void main ( String [ ] args) {
Set < String > set = new HashSet < String > ( ) ;
short i = 0 ;
while ( true ) {
set. add ( String . valueOf ( i++ ) . intern ( ) ) ;
}
}
}
注意,如果你没有jdk6,而有jdk8,那么操作这个:
package com. test3 ;
import java. util. HashSet ;
import java. util. Set ;
import java. util. function. Function ;
public class RuntimeConstantPoolOOM {
public static void main ( String [ ] args) {
Set < String > set = new HashSet < String > ( ) ;
int i = 0 ;
while ( true ) {
set. add ( String . valueOf ( i++ ) . intern ( ) ) ;
}
}
}
当然了,你可能得不到结果,或者需要等待许久(一般不会,除非超过默认堆大小,但是一般很难超过,其中long类型一般可以),后面会给出说明
运行结果(第一个案例错误):
Exception in thread "main" java. lang. OutOfMemoryError: PermGen space
从运行结果中可以看到, 运行时常量池溢出时, 在OutOfMemoryError异常后面跟随的提示信息 是"PermGen space", 说明运行时常量池的确是属于方法区(即JDK 6的HotSpot虚拟机中的永久代) 的 一部分
而使用JDK 7或更高版本的JDK来运行这段程序并不会得到相同的结果, 无论是在JDK 7中继续使 用-XX:MaxPermSize参数或者在JDK 8及以上版本使用-XX:MaxMeta-spaceSize参数把方法区容量同 样限制在6MB, 也都不 会重现JDK 6中的溢出异常, 循环将一直进行下去, 永不停歇,出现这种变 化, 是因为自JDK 7起, 原本存放在 永久代的字符串常量池被移至Java堆之中(虽然前面说明是jdk8开始的,实际上jdk7开始就这样的,所以前面的测试是jdk6,那么虽然前面我们以方法区统称,而经过这里的说明我们知道实际上(运行时)常量池是在堆里面的,而之前也说明过了"永久代中的interned Strings(字符串常量池) 和 class static variables(类静态变量)转移到了Java heap(堆)“), 所以在JDK 7及以上版本, 限制方法区的容量对该测试用例来说是毫无 意义的,这时候使用-Xmx参数限制最大堆到6MB(这里就可以试一下上面说明的9m了,然后修改8m看看是否报错,经过测试,9m不会,8m会,即前面结论正确,即"一个字面值内存空间大约在96到144字节之间”,记得等待一会哦,因为他添加数据也是需要时间的,虽然是true,只是非常快而已,在底层中,一个变量的操作都必须等待(超级底层),相当于"我的世界"中一个方块在一个时间段只能被操作一次移动,四人移动他,但是他只能移动一个方向,中间必然会使得另外一个进行等待(根据理论来说,会操作等待,我的世界也是可以的,虽然你可能认为没有操作),虽然他们加起来执行的时间非常短,但是也正是如此,所以true或者说程序执行,没有瞬发的直接得到结果,必然需要计算(执行)时间)就能 够看到以下两种运行结果之一, 具体取决于哪里的对象分配 时产生了溢出:
当你设置堆时,你就去运行一下,会发现出现异常:
Exception in thread "main" java. lang. OutOfMemoryError: Java heap space
这也更加证明了,常量池在加载时,变成的运行时常量池是在堆里面的(虽然我们前面说明时,是根据方法区来统一说明的),那么有个问题,他是在年轻代还是老年代呢,实际上就看成类似于对象即可,因为都是内存分配的吗
还有一个错误,这个错误需要看这个解释:根据Oracle官方文档,默认情况下,如果Java进程花费98%以上的时间执行GC(如堆的新生代的eden满,但是确一直满(回到老年代,那么对应的gc可能会一直操作,或者会出现总时间的98%以及以上操作了gc而不会报错)),在上面时间满足的条件下,且每次只有不到2%或者更少的总堆大小数据被释放(即回收),那么满足这两个条件则JVM抛出 此错误
Exception in thread "main" java. lang. OutOfMemoryError: GC overhead limit exceeded
一般情况下,我们可能测试不了这个错误(可以试着多运行几次),所以这个了解即可
最后一个问题:前面的Full GC回收对应常量池行为决定会避免吗,实际上会的,你可以选择继续打开之前的Java VisualVM,查看我们按照插件的那个界面,可以看到他一直进行动态的操作,然后观察老年代那个地方,最终可以发现是慢慢的增加的,注意了:对应的上限是慢慢突破的,所以你可以看到新生代是从上到下的看起来是一直上下动的
方法区内存溢出:
方法区的其他部分的内容, 方法区的主要职责是用于存放类型的相关信息, 如类名、 访问修饰符、 常量池、 字段描述、 方法描述等,对于这部分区域的测试, 基本的思路是运行时产生大量的类去填满方法区, 直到溢出为止,虽然直接使用Java SE API也可以动态产生类(如反射时的 GeneratedConstructorAccessor和动态代理等) , 但在本 次实验中操作起来比较麻烦,看后面代码:
这里借助CGLib使得方法区出现内存溢出异常(虽然前面有关jdk6的该溢出,那jdk8怎么操作呢,就是使用这个,一般jdk6也行,如果没有对应的类,自己可以操作添加依赖,点击红色地方,按照提示即可)
package com. test3 ;
import net. sf. cglib. proxy. Enhancer ;
import net. sf. cglib. proxy. MethodInterceptor ;
import net. sf. cglib. proxy. MethodProxy ;
import java. lang. reflect. Method ;
public class JavaMethodAreaOOM {
public static void main ( String [ ] args) {
while ( true ) {
Enhancer enhancer = new Enhancer ( ) ;
enhancer. setSuperclass ( OOMObject . class ) ;
enhancer. setUseCache ( false ) ;
enhancer. setCallback ( new MethodInterceptor ( ) {
@Override
public Object intercept ( Object o, Method method, Object [ ] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy. invokeSuper ( o, args) ;
}
} ) ;
enhancer. create ( ) ;
}
}
static class OOMObject {
}
}
设置方法区大小-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m,执行后,会出现:
Exception in thread "main" java. lang. OutOfMemoryError: Metaspace
方法区溢出也是一种常见的内存溢出异常, 一个类如果要被垃圾收集器回收, 要达成的条件是比较苛刻的(因为平常情况下,一个类必然会被使用,而不是无意识的没有使用(自然可以被自动gc)),在经常运行时生成大量动态类的应用场景里, 就应该特别关注这些类的回收状况,这类场 景除了之前提到的程序使用 了CGLib字节码增强和动态语言外, 常见的还有: 大量JSP或动态产生JSP 文件的应用(JSP第一次运行时需要编译 为Java类,所以一个jsp可以看成就是一个类) 、 基于OSGi的应用(即使是同一个类文件, 被不同的加载器加载也会视为不同的类) 等,在JDK 8以 后, 永久代便完全退出了历史舞台, 元空间作为其替代者登场,在默认设置下, 前面列举的那些正常的动态创建 新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了(通常等待一会),不过 为了让使用者有预防实际应用里出现类 似于上面代码那样的破坏性的操作, HotSpot还是提供了一 些参数作为元空间的防御措施, 主要包括:
-XX: MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存 大小
-XX: MetaspaceSize: 指定元空间的初始空间大小, 以字节为单位, 达到该值就会触发垃圾收集进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值,如果释放 了很少的空间, 那么在不超 过-XX: MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值
-XX: MinMetaspaceFreeRatio: 作用是在垃圾收集之后控制最小的元空间剩余容量的百分比, 可 减少因为元空间 不足导致的垃圾收集的频率,类似的还有-XX: Max-MetaspaceFreeRatio, 用于控制最 大的元空间剩余容量的百分 比
这些再前面都有说明,以对应错误来直接结束上面的无限循环,从而进行防御
直接内存溢出:
直接内存(Direct Memory) 的容量大小可通过-XX: MaxDirectMemorySize参数来指定(这就是为什么之前说的,他并不代表全部物理内存的原因,即他占用物理内存的一部分), 如果不去指定, 则默认与Java堆最大值(由-Xmx指定) 一致, 越过了DirectByteBu类直接通 过反射获取Unsafe实例进行内存分配 (Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实例, 体现了设计者希望只有虚拟机标准类库里面的 类才能使用Unsafe的功能(再101章博客中,就给出对应unsafe的一些说明,包括有不能使用的说明,虽然有具体操作地址给出,但是在除了这个地址外,基本没有直接的说明原因,这里就是主要原因,这里提一下,具体地址也给出:https://jiuaidu.com/jianzhan/1006040/), 在JDK 10时才将Unsafe 的部分功能通过VarHandle开放给外部使用) , 因为虽然使用DirectByteBu分配内存也会抛出内存溢出异常, 但它抛出异常时并没有真正向操作系统申请分配内存, 而是通 过计算得知内存无法分配就会 在代码里手动抛出溢出异常, 真正申请分配内存的方法是Unsafe的allocateMemory() ,由于直接内存是物理内存,那么我们需要去操作物理内存的方法,具体操作如下(注意:一般来说,jvm存在操作的物理内存就是直接内存,而jvm的直接内存可能自身也做限制(其他jdk版本基本也是如此),使得不会拥有非常多的物理内存,当然,像什么堆,栈等待并不是获取直接内存的,他们获取的是其他物理内存,所以该直接内存是独立出来的,一般直接内存的作用是"避免了 在Java堆和Native堆中来回复制数据",在前面说明过了,这里就提一下):
package com. test3 ;
import sun. misc. Unsafe ;
import java. lang. reflect. Field ;
public class DirectMemoryOOM {
private static final int _1MB = 1024 * 1024 ;
public static void main ( String [ ] args) throws Exception {
Field unsafeField = Unsafe . class . getDeclaredFields ( ) [ 0 ] ;
unsafeField. setAccessible ( true ) ;
Unsafe unsafe = ( Unsafe ) unsafeField. get ( null ) ;
while ( true ) {
unsafe. allocateMemory ( _1MB) ;
}
}
}
那么很明显,当你运行时,直接内存自然会溢出,因为一直在使用,自然最后就分配不了了(从直接内存分配),那么他为什么不从其他物理内存分配呢(堆和栈也是一样),实际上这就是防止java过于去分配内存的原因,使得造成操作系统内存使用完,所以给出直接内存让你有所限制(c/c++(语言)好像没有),当然,可能也是因为给某些直接分配内存的操作来使用而已,或者说他们的限制
运行结果(主要错误):
Exception in thread "main" java. lang. OutOfMemoryError
由直接内存导致的内存溢出, 一个明显的特征是在Heap Dump(英文意思:堆转储)文件中不会看见有什么明显的异常 情况, 如果发现 内存溢出之后产生的Dump文件很小(不操作), 而程序中又直接或间接使用了 DirectMemory(典型的间接使用就是NIO) , 那就可以考虑重点检查一下直接内存方面的原因了,这里了解即可
JVM加载机制详解:
类装载子系统:
类加载子系统介绍:
1:类加载子系统负责从文件系统或是网络中加载.class文件,class文件在文件开头有特定的文件标识
2:把加载后的class类信息存放于方法区,除了类信息之外,方法区(统一说明的)还会存放运行时常量池信息,可能还包括字符串字面量和 数字常量(这部分常量信息是class文件中常量池部分的内存映射,也就是常量池信息)
3:ClassLoader只负责class文件的加载,至于 它是否可以运行,则由Execution Engine(执行引擎,也就是解释加载的class,虽然加载的并没有改变文件总体内容,使得解释时(操作总体的运行时数据区),可以相当于像之前的说明class文件内容一样的说明)决定
4:如果调用构造器实例化对象,则该对象存放在堆区
执行引擎操作方法区内存和堆内容来进行识别,因为其他的区域是线程私有的,所以一般只有在识别时才会进行操作(可能因为主线程也有固定存在)
但是总体来说,大多数是方法区的操作被识别(可能包括堆,因为常量池),其他的是用来进行辅助识别操作
类加载器ClassLoader角色:
1:class file 存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来根据这个文件实例化出n个一模一样的实例
2:class file 加载到JVM中,被称为DNA元数据模板
3:在 .class文件 --> JVM --> 最终成为元数据模板,此过程就要一个运输工具(类装载器Class Loader),扮演一 个快递员的角色
看图可知(对象的图),我们通过类加载器,可以变成Class(加载并初始化),然后他可以操作多个实例,一个类是可以有多个对象的(实例化),所以前面我才写上n个一模一样的实例,而实例可以通过对应的方法得到Class(getClass()),在28章博客中有过操作,而Class也可以得到类加载器(getClassLoader()),在28章博客中也有过操作
类加载的执行过程:
我们知道我们写的程序经过编译后成为了.class文件,.class文件中描述了类的各种信息,最终都需要加载到虚拟机 之后才能运行和使用,而虚拟机如何加载这些.class文件?.class文件的信息进入到虚拟机后会发生什么变化
类使用的7个阶段(所有的阶段,也就是最后的执行,那么自然包括,类加载器,运行时数据区,执行引擎,垃圾回收器等等):
类从被加载到虚拟机内存中开始,到卸载出内存,它的整个生命周期包括:加载(Loading)、验证 (Verification)、准备(Preparation)、解析(Resolution)、初始化(Initiallization)、使用(Using)和卸载 (Unloading)这7个阶段,其中验证、准备、解析3个部分统称为连接(Linking),这七个阶段的发生顺序如下图:
图中,加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地 开始,而解析阶段不一定:它在某些情况下可以初始化阶段之后在开始,这是为了支持Java语言的运行时绑定(也称为动态绑定)
接下来讲解加载、验证、准备、解析、初始化五个步骤,这五个步骤组成了一个完整的类加载过 程(看前面的图就知道了(类加载子系统)),使用没什么好说的,卸载属于GC的工作
加载:
加载是类加载的第一个阶段,有两种时机会触发类加载:
1:预加载
虚拟机启动时加载,加载的是JAVA_HOME/lib/下的rt.jar下的.class文件(JAVA_HOME一般是我们环境变量对应的值,到这里,大多数人应该明白是什么,这里就不多说了,但这里也要注意:不同版本的jdk可能对应的.class文件的目录文件不同,所以并不是一定是lib/rt.jar,一般lib不会变,而rt.jar可能不是),这个jar包里面的内容是程序运行时通常会用到的,像java.lang.*、java.util.、java.io. 等等,因此随着虚拟机一起加载,且为了知道他加载的文件是那个(因为所以并不是一定是rt.jar),所以我们可以操作如下:
要证明是否一起加载且知道是那个文件,这一点很简单,写一个空的main函数,设置虚拟机参数为"-XX:+TraceClassLoading"来获取类加载信息,运行一下:
package com. test4 ;
public class test1 {
public static void main ( String [ ] args) {
System . out. println ( "hello" ) ;
}
}
参数设置在前面多次进行了操作,就不说明了,这里自己复制粘贴即可
通过运行,看如下(一部分信息):
[ Opened C : \Program Files \Java \jdk1. 8.0 \jre\lib\rt. jar]
[ Loaded java. lang. Object from C : \Program Files \Java \jdk1. 8.0 \jre\lib\rt. jar]
[ Loaded java. io. Serializable from C : \Program Files \Java \jdk1. 8.0 \jre\lib\rt. jar]
[ Loaded java. lang. Comparable from C : \Program Files \Java \jdk1. 8.0 \jre\lib\rt. jar]
[ Loaded java. lang. CharSequence from C : \Program Files \Java \jdk1. 8.0 \jre\lib\rt. jar]
[ Loaded java. lang. String from C : \Program Files \Java \jdk1. 8.0 \jre\lib\rt. jar]
[ Loaded java. lang. reflect. AnnotatedElement from C : \Program Files \Java \jdk1. 8.0 \jre\lib\rt. jar]
[ Loaded java. lang. reflect. GenericDeclaration from C : \Program Files \Java \jdk1. 8.0 \jre\lib\rt. jar]
[ Loaded java. lang. reflect. Type from C : \Program Files \Java \jdk1. 8.0 \jre\lib\rt. jar]
[ Loaded java. lang. Class from C : \Program Files \Java \jdk1. 8.0 \jre\lib\rt. jar]
很明显,他并不是在直接的lib里面,而是jre里面(jdk1.8来说的,其他版本不能确定,可以自己试一下),并且他加载时,如java.lang.String就是该文件里面的,即的确加载了,若要看其他jdk版本的,你主要改变运行版本即可,因为他是用来加载的
2:运行时加载:
虚拟机在用到一个其中(比如自己写的或者上面自带的).class文件的时候,会先去内存中查看一下这个.class文件有没有被加载,如果没有就会按照类的 全限定名来加载这个类,否则不会加载,因为只会加载一次,具体一般看class文件里面的"检验的数字",虽然在同一个目录下也通常不会有两个相同名称文件来进行加载,但他可以来区分不同目录的相同名称文件的,所以以他为主要区分,虽然全限定名也是一个区分方法
那么该加载阶段做了什么,其实该加载阶段做了有三件事情:
1:获取.class文件的二进制流(并不是二进制,相当于就是得到该文件的流,如IO流)
2:将类信息、静态变量、字节码、常量这些.class文件中的内容放入方法区中
3:在内存中生成一个代表这个.class文件的java.lang.Class对象,作为方法区这个类的各种数据的访问入口,一般 这个Class是在堆里的,不过HotSpot虚拟机比较特殊,这个Class对象通常是放在方法区中的(所以以后这里要记住哦,虽然并没有进行语义改变过),堆和方法区都是数据的存放形式,那么方法区自然可以操作出小的类似的堆空间,反正都是程序操作的而已
虚拟机规范对这三点的要求并不具体,因此虚拟机实现与具体应用的灵活度都是相当大的,例如第一条,根本没有 指明二进制字节流要从哪里来、怎么来,因此单单就这一条,就能变出许多花样来:
1:比如你可以从zip包中获取,这就是以后为什么可以是jar、ear、war格式的基础,就如自己写的class也是如此,只要可以保存他即可
2:从网络中获取,典型应用就是Applet
3:运行时计算生成,典型应用就是动态代理技术(程序生成,相当于自己写)
4:由其他文件生成,典型应用就是JSP,即由JSP生成对应的.class文件
5:从数据库中读取,这种场景比较少见
总而言之,在类加载整个过程中,这部分是对于开发者来说可控性最强的一个阶段,只要你可以生成class文件,就可以进行放入方法区,无论你是自己写,还是程序生成,还是其他生成等等都可以
链接(连接):
链接包含三个步骤: 分别是 验证Verification,准备Preparation,解析Resolution 三个过程
验证Verification:
连接阶段的第一步,这一阶段的目的是为了确保.class文件的字节流中包含的信息符合当前虚拟机的要求,并且不 会危害虚拟机自身的安全
Java语言本身是相对安全的语言(相对C/C++来说),但是前面说过,.class文件未必要从Java源码编译而来,可以 使用任何途径产生,甚至包括如用十六进制编辑器直接编写来产生.class文件,在字节码语言层面上,Java代码至少从 语义上是可以表达出来的,虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节 流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作
验证阶段将做一下几个工作,具体就不细讲了,这是虚拟机实现层面的问题:
文件格式验证,元数据验证,字节码验证,符号引用验证
实际上在前面说明class字节码时,会有一个存放的地址,而他能够很好的进行区分,但是他并不是验证的手段,主要手段是对应的"检验的数字",实际上在编译时,会生成数字,并且保存或者某些解析,那么手动的写自然不能进行解决,大多数的加密我们自己还并不能有这个能力解密,就算可以,他里面可能并没有保存对应的加密信息,但是他也可能并不会保存(也是防止被盗取),只是一个解密后的固定,所以若你可以解密,通常可以进行载入有害字节,当然了,既然你有这个能力,为什么做这个不好的事情呢
准备Preparation:
准备阶段是正式为类变量分配内存并设置其初始值的阶段,这些变量所使用的内存都将在方法区中分配(既然是加载,自然是在过程中操作到方法区的,所以才说加载到方法区,总不能你运行完才进行操作吧,那么为什么不出现一个"提交"的步骤呢),关于这 点,有两个地方注意一下:
1:这时候进行内存分配的仅仅是类变量(被static修饰的变量),而不是实例变量,实例变量将会在对象实例化 的时候随着对象一起分配在Java堆中
2:这个阶段赋初始值(初始值,并不是具体值)的变量指的是那些不被final修饰的static变量,比如"public static int value = 123",value在准 备阶段过后是0(初始值)而不是123,给value赋值为123的动作将在初始化阶段(也就是要操作()方法时,初始化那里会说明)才进行,比如"public static final int value = 123;"就不一样了(必须赋值,可不会操作默认值哦),在准备阶段,虚拟机就会给value赋值为123(不是初始值),但初始化不会,简单来说,常量在准备时进行赋值,而静态变量(这里不包括常量),只有初始值
各个数据类型的零值(初始值)如下表:
其中reference代表所有的引用,这里用他来表示而已,且也很明显,默认是int类型的初始值,虽然他是这样认为的,也就是分配这个空间(int),这个过程操作到方法区中,前面说过了"自然是在过程中操作到方法区的"
我们顺便看一道面试题,下面两段代码,code-snippet 1 将会输出 0,而 code-snippet 2 将无法通过编译(idea的检查,也是一个防线,虽然编译的检查是自带的,而不是idea的,但是他由于会给出具体的错误位置和对应的解决方式,所以说成idea的检查也可以)
code-snippet 1:
public class A {
static int a;
public static void main ( String [ ] args) {
System . out. println ( a) ;
}
}
code-snippet 2:
public class B {
public static void main ( String [ ] args) {
int a;
System . out. println ( a) ;
}
}
注意:
这是因为局部变量不像类变量那样存在准备阶段,类变量有两次赋初始值的过程,一次在准备阶段,赋予初始值 (也可以是指定值),另外一次在初始化阶段,赋予程序员定义的值
因此,即使程序员没有为类变量赋值也没有关系,它仍然有一个默认的初始值(若有final的必须有初始值,即final是只有在准备阶段赋值的属性),但局部变量就不一样了,如果没有 给它赋初始值,是不能使用的,而正是因为一开始就赋值,那么静态的需要有先后顺序,使得可以得到结果(在后面初始化中会大致说明),而静态构造,就是静态操作后的操作,算成一个方法,在初始化变量之前,main方法之前(并不是只有main里面才会有打印哦,否则,对应添加了-XX:+TraceClassLoading参数的打印信息是怎么来的呢,实际上打印是保留好的,在执行后,操作打印出现)
解析Resolution:
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,来了解一下符号引用和直接引用有什么区别:
1:符号引用
符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量,这个其实是属于编译原理方面的概念,符号引用包括了下面三类常量:
类和接口的全限定名,字段的名称和描述符,方法的名称和描述符
这么说可能不太好理解,结合实际看一下,写一段很简单的代码:
package com. test4 ;
public class TestMain {
private static int i;
private double d;
public static void print ( ) {
}
private boolean trueOrFalse ( ) {
return false ;
}
}
对应的class文件(主要看常量池):
Constant pool:
#1 = Methodref #3. #20
#2 = Class #21
#3 = Class #22
#4 = Utf8 i
#5 = Utf8 I
#6 = Utf8 d
#7 = Utf8 D
#8 = Utf8 < init>
#9 = Utf8 ( ) V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 LocalVariableTable
#13 = Utf8 this
#14 = Utf8 Lcom / test4/ TestMain ;
#15 = Utf8 print
#16 = Utf8 trueOrFalse
#17 = Utf8 ( ) Z
#18 = Utf8 SourceFile
#19 = Utf8 TestMain . java
#20 = NameAndType #8 : #9
#21 = Utf8 com/ test4/ TestMain
#22 = Utf8 java/ lang/ Object
看到Constant Pool也就是常量池中有22项内容,其中带"Utf8"的就是符号引用,比如#21,它的值 是"com/test4/TestMain",表示的是这个类的全限定名,又比如#4为i,#5为I,它们是一对的,表示变量时Integer(int)类型的,名字叫做i,#7为D、#6为d也是一样,表示一个Double(double)类型的变量,名字为d,大写的D表示Double,小写的d表示double,统称为d,上面的int和Integer也是如此,统称为i,#15、#16表示的都是方法的名字,所以这三个类和接口的全限定名,字段的名称和描述符,方法的名称和描述符,都说明完毕
那其实总而言之,符号引用和我们上面讲的是一样的,是对于类、变量、方法的描述,符号引用和虚拟机的内存布 局是没有关系的,引用的目标未必已经加载到内存中了
2:直接引用:
直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的 内存布局相关的,同一个符号引用在不同的虚拟机示例上翻译出来的直接引用一般不会相同,如果有了直接引用, 那引用的目标必定已经存在在内存中了,简单来说,原来某些地方是符号引用,那么解析就是将该符号,变成了该直接的引用,比如原来是#21,那么你就会变成com/test4/TestMain,当然,在保存上,该文件自然是少的,也是使用符号引用的原因(前面说明过"而引用是一个总显示,只需要少的字节码(如#2,就可以替换掉<java/lang/Math.random : ()D>)就可以显示了"这个地方)
解析阶段负责把整个类激活,串成一个可以找到彼此的网,过程不可谓不重要,那这个阶段都做了哪些工作呢?大 体可以分为:
类或接口的解析,类方法解析,接口方法解析,字段解析,也就是类,方法,字段(如变量),正好是类的所有信息
初始化:
类的初始化阶段是类加载过程的最后一个步骤, 之前介绍的几个类加载的动作里, 除了在加载阶 段用户应用程序 可以通过自定义类加载器的方式局部参与外, 其余动作都完全由Java虚拟机来主导控 制,直到初始化阶段, Java虚拟机才真正开始执行类中编写的Java程序代码, 将主导权移交给应用程序,这就说明,只有初始化后,应用程序才会真正执行
初始化阶段就是执行类构造器()方法的过程,类构造器()(简称为< cinit >(),别称为(),可以认为是操作创建Class的)并不是程序员在Java代码中直接编写 的方法, 它是Javac编译器的 自动生成物,()方法是由编译器自动收集类中的所有类变量(静态变量,类变量就是静态变量,前面说明过了"类变量(被static修饰的变量)")的赋值动作和静态语句块(static{}块) 中的 语句合并产 生的(实际上你就看成,静态语句块执行完,他就执行,或者变量使用,他也执行,因为你执行或者使用,最终都会使得他执行), 编译器收集的顺序是由语句在源文件中出现的顺序决定的, 静态语句块中只能访问 到定义在静态语句块之前的变量, 定义在它之后的变量, 在前面的静态语句块可以赋值, 但是不能访问,看如下代码:
public class TestClinit {
static {
i = 0 ;
System . out. print ( i) ;
}
static int i = 1 ;
}
()方法与类的构造函数(即在虚拟机视角中的实例构造器()方法) 不同,不是构造方法哦,它不需要显 式地调用父类构造器(在有参数的情况下,没有参数一般可以忽略), Java虚拟机会保证在子类的()方法执行前, 父类的()方法已经执行 完毕,因此在Java虚拟机中第一个被执行的()方法的 类型肯定是java.lang.Object
由于父类的()方法先执行, 也就意味着父类中定义的静态语句块必然是要优先于子类的变量赋值 (一般是使用就行)操作(包括他的静态代码块),就如下面的代码中, 字段B的值将会是2而不是1,看后面执行顺序:
package com. test4 ;
public class TestClinit02 {
static class Parent {
public static int A = 1 ;
static {
A = 2 ;
}
}
static class Sub extends Parent {
public static int B = A ;
public Sub ( ) {
System . out. println ( 1 ) ;
}
}
public static void main ( String [ ] args) {
System . out. println ( Sub. B ) ;
}
}
()方法对于类或接口来说并不是必需的, 如果一个类中没有静态语句块, 也没有对变量的 赋值(一般是使用就行)操作(看上面的代码就行), 那么编译器 可以不为这个类生成()方法,接口中不能使用静态语句块(规定的,因为他不是用来进行操作的,而静态语句块可以,所以没有静态语句块), 但实际上接口仍然有变量初始化的赋值操作, 但为了进行兜底,因此接口与类一样 都会生成 ()方法
但接口与类不同的是, 执行接口的()方法不需要先执行父接口的()方法, 因为只有当父接口中定义的变量被使用 时, 父接口才会被初始化(看成没有使用就存在标记),此外, 接口的实现类在初始化时也 一样不会执行接口的()方法
Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步, 如果多个线程同时去初始化一个类, 那 么只会有其中一个线程去执行这个类的()方法(也就是会执行静态代码块), 其他线程都需要阻塞等 待(只在创建对象那里阻塞), 直到活动线程执行完毕()方法,那么其他线程之间跳过执行静态代码块,不会继续执行哦,所以如果在 一个类的()方法中有耗时很长的操作, 那就 可能造成多个进程阻塞, 在实际应用中这种阻塞往往是很隐蔽的 ,看如下代码:
package com. test4 ;
public class TestDeadLoop {
static class DeadLoopClass {
static {
if ( true ) {
System . out. println ( Thread . currentThread ( ) + "init DeadLoopClass" ) ;
while ( true ) {
}
}
}
}
public static void main ( String [ ] args) {
Runnable script = new Runnable ( ) {
public void run ( ) {
System . out. println ( Thread . currentThread ( ) + "start" ) ;
DeadLoopClass dlc = new DeadLoopClass ( ) ;
System . out. println ( Thread . currentThread ( ) + " run over" ) ;
}
} ;
Thread thread1 = new Thread ( script) ;
Thread thread2 = new Thread ( script) ;
thread1. start ( ) ;
thread2. start ( ) ;
}
}
我们知道,只有对应的()加载完,类才初始化完毕,否则如果类没有初始化完毕,那么自然不能创建对象,因为对于该类来说没有Class,这也是为什么如果没有初始化完,会进行阻塞的原因,也就是在创建对象操作时,若没有初始化,那么阻塞,而有初始化需要之前的都加载好,也就需要静态构造块进行加载
注意:只有需要class文件加载才会执行之前上面的所有操作,否则,像加载,连接,初始化并不会操作,很明显,当我们需要new DeadLoopClass()时,需要加载Class类,那么就会经历这些过程,但只会在经历初始化时考虑是否创建对象,在前面说明了"初始化阶段就是执行类构造器()方法的过程",所以当你设置DeadLoopClass dlc = null他执行不阻塞,因为你只是利用类信息,这个在连接中就已经操作了,而new DeadLoopClass()就会操作静态代码块 ,因为当你需要提取对象,也就是需要Class,就必须要完成初始化这个步骤,这也是为什么之前说的"静态语句块执行完,他就执行",即静态语句块是构造方法之前执行的,构造方法也是激活静态语句块的一个操作(静态赋值也算的,而常量在准备时就已经赋值了,使得常量不可以使用的,即不会进行初始化操作,因为他不需要,而单纯静态需要,所以看起来初始化也的确是合并产生的,即该对应操作(使用)就可以认为是初始化,而并非先后执行的操作 ,因为看成一体不更好吗,反正没有什么拦截,虽然实际上是一个意思的(一体的)),但是之前也说明了"或者变量使用,他也执行",即对应的赋值也会执行该初始化,注意了:其中main方法由于是必然执行的,也就是说,当前类一定有Class,所以若对应的静态代码块在当前类,那么他必然执行(在main方法之前),也符合我们的说明
总而言之,初始化的操作需要考虑是否创建对象,而进行是否执行静态代码块,当然,构造块介于静态代码块和构造方法之前,优先于构造方法,慢于静态代码块,而无论有没有初始化,都不影响程序的运行(因为main必然是操作的,就在执行时,有操作就进行创建Class,否则继续执行即可,所以你可以将初始化看成另外一个空间就行,比如虚拟机栈帧,虽然他又会操作堆里面,这也是为什么在前面的图中,在"其他的是用来进行辅助识别操作"下面的图,会存在有双向箭头的原因(一般是原因之一)),只是可能没有对应的Class而已,也就是不能操作对应的实例,但是创建对象,会执行静态代码块,然后执行初始化,从而又会创建Class(Class可以有多个,也不是他一个),从而创建对象,所以正是因为比较麻烦,即只要你有第一个Class,那么以后创建对象就不会在创建Class了,即只有第一次需要
至此,我们的加载(类加载),连接(static),初始化(构造和静态代码块,该步骤可以不操作,虽然可能有默认的存在)说明完毕,很明显,他们的操作结合在加载后保留到方法区中,然后再解释执行时,会依次的解释然后执行,当然,他们必然是有顺序的,因为这个步骤就是顺序,那么解释起来自然也是按照顺序的,所以再说明他们的顺序执行时,又何尝不是代码运行的顺序呢,即我们的案例实际上就是对的,而使用我们就看成就是执行,卸载看成就是gc操作即可,至此,我们的class文件的执行流程说明完毕(类加载器就是操作他的)
< cinit >和< init >:
< cinit >方法和< init >方法有什么区别:
主要是为了让你弄明白类的初始化和对象的初始化之间的差别
前面我们说明的< cinit >方法大多数只是一种概念,通常静态代码块执行后,或者静态变量(按照main来说,因为不是静态的,必然是需要对象,main可是static哦)使用后,他才会执行
看如下代码:
package com. tets5 ;
public class ParentA {
static {
System . out. println ( "1" ) ;
}
public ParentA ( ) {
System . out. println ( "2" ) ;
}
}
class SonB extends ParentA {
static {
System . out. println ( "a" ) ;
}
public SonB ( ) {
System . out. println ( "b" ) ;
}
public static void main ( String [ ] args) {
ParentA ab = new SonB ( ) ;
ab = new SonB ( ) ;
}
}
1
a
2
b
2
b
静态只操作一次哦
其中 static 字段和 static 代码块,是属于类的,在类的加载的初始化阶段就已经被执行,类信息会被存放在方法 区,在同一个类加载器下,这些信息有一份就够了,所以上面的 static 代码块只会执行一次,它最终对应的是()方法,也就是< cinit >方法
所以,上面代码的 static 代码块只会执行一次,对象的构造方法执行两次,再加上继承关系的先后原则,不难分析 出正确结果
结论:
< cinit >方法 的执行时期:类初始化阶段(该方法只能被jvm调用,专门承担类变量的初始化工作),只执行一次
< init >方法 的执行时期:对象的初始化阶段
类加载器:
类加载器的作用:
类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在创建 一个java.lang.Class对象,用来封装类在方法区内的数据结构
注意:JVM主要在程序第一次主动使用类的时候,才会去加载该类(这里对应于Class),也就是说,JVM并不是在一开始就把一个 程序就所有的类都加载到内存中,而是到不得不用的时候才把它加载进来,而且只加载一次,就如之前的创建对象
类加载器分类:
1:jvm支持两种类型的加载器,分别是引导类加载器和 自定义加载器
2:引导类加载器是由c/c++实现的,自定义加 载器是由java实现的
3:jvm规范定义自定义加载器是指派生于(继承)抽象类ClassLoader的类加载器
4:按照这样的加载器的类型划分,在程序中我们最常见的类加载器是:引导类加载器BootStrapClassLoader、自定 义类加载器(Extension Class Loader(扩展类加载器)、System Class Loader(系统类加载器)、User-Defined ClassLoader(用户自定义类加载器),他们只是一个名称,并不是类)
上图中的加载器划分为包含关系而并非继承关系
下面一般是以jdk8来说明的(若有不同,可以自行改变版本查找,当然,也可能已经淘汰了,所以也并非一定存在,当然,也可能是idea查找不到,那么可以选择找ClassLoader,然后通过他找SecureClassLoader,然后找URLClassLoader,ExtClassLoader,他就是在Launcher里面,一般idea找不到sun.misc.Launcher,所以这里要注意),不同版本对应的目录或者说明可能不同
至于如何找,点击如下提示的地方即可:
启动类加载器:
1:这个类加载器使用c/c++实现,嵌套再jvm内部
2:它用来加载Java的核心类库(如类似的JAVA_HOME/jre/lib/rt.jar、resource.jar或sun.boot.class.path路径下的内容,具体路径内容可以百度),用于提供JVM自身需要的类
3:并不继承自java.lang.ClassLoader,没有父加载器
扩展类加载器:
1:java语言编写,由sun.misc.Launcher里面的内部类ExtClassLoader实现,具体可以百度查看(一般与版本有关)
2:从java.ext.dirs系统属性所指定的目录中加载类库, 或从JDK的安装目录的如类似的jre/lib/ext 子目录(扩展目录)下加载类库,如果用户创建的JAR 放在此目录下,也会自动由 扩展类加载器加载,派生于(派生于一般就是"继承") ClassLoader
3:父类加载器为启动类加载器,并不是继承的意思(上图中的加载器划分为包含关系而并非继承关系)
系统类加载器:
1:java语言编写,由 sun.misc.Lanucher里面的内部类(可以使用$表示)AppClassLoader 实现,具体可以百度查看(一般与版本有关)
上面使用$表示的是:
sun.misc.Lanucher$AppClassLoader
2:该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载的(也就是说,之前的类加载操作就是他),它负责加载环境变量classpath或系统属性java.class.path 指定路径下的类库,派生于 ClassLoader(最终继承)
3:父类加载器为扩展类加载器
4:通过 ClassLoader.getSystemClassLoader() 方法可以获取 到该类加载器,类似的一个图片(复制粘贴来的,这里一般是jdk8的结果)
用户自定义类加载器(启动,扩展,系统,自定义是启动类,扩展类,系统类,自定义类的简称):
在日常的Java开发中,类加载几乎是由三种加载器配合执行的,在必要时我们还可以自定义类加载器(上面三种又何尝不是自定义的呢,也是人写的),来定制类的 加载方式(通常也是派生于 ClassLoader),如果没有自定义,默认自定义是系统加载器,也就是使用系统加载器,那么流程就是,jvm通过了启动类加载器,扩展类加载器,系统类加载器后,操作后续自定义加载器,由于自定义默认系统加载器,那么没有后面的,可以认为自定义是一个节点,当jvm操作完系统后,看看后面是否有节点,没有,那么不操作,很明显,由于自定义是系统加载器类,那么后面自然没有节点了,就类似于这样的说明
当然了上面的图片也只是图片,那么现在给出所有获取这里加载器的代码(可以自己复制操作,这里是jdk11的结果):
package com. tets5 ;
public class test {
public static void main ( String [ ] args) {
ClassLoader systemClassLoader = ClassLoader . getSystemClassLoader ( ) ;
System . out. println ( systemClassLoader) ;
ClassLoader parent = systemClassLoader. getParent ( ) ;
System . out. println ( parent) ;
ClassLoader parent1 = parent. getParent ( ) ;
System . out. println ( parent1) ;
ClassLoader classLoader = test. class . getClassLoader ( ) ;
System . out. println ( classLoader) ;
ClassLoader classLoader1 = String . class . getClassLoader ( ) ;
System . out. println ( classLoader1) ;
}
}
至于如何看他加载了哪些文件,可以百度查找对应的API即可,这里就不做说明
双亲委派模型:
什么是双亲委派:
双亲委派模型工作过程是:如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类(一般是从系统开始往上,若有自定义,即从自定义开始的(虽然需要他操作才行)),而是把这个 请求委派给父类加载器完成(这也是为什么启动类加载器必然先操作的原因),每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时 (一般是:ClassNotFoundException),子加载器才会尝试自己去加载
注意:这里非常重要,如果不考虑任何因素,实际上他们都没有加载任何一个字节码文件,包括String,但是,我们知道,有main方法的操作且是入口,自然在这个过程中,会加载很多的字节码文件,也就会操作初始化(这样才会有Class),所以这里要注意了
这里给出一个例子:
package java. lang ;
public class String {
static {
System . out. println ( "我们自定义的String加载" ) ;
}
}
package test ;
public class StringTest {
public static void main ( String [ ] args) {
String s = new String ( ) ;
System . out. println ( "Stringtest" ) ;
}
}
执行后(一般需要jdk8的语言版本,否则在编译期会直接报错),一般不会打印System.out.println(“我们自定义的String加载”);,也就是说,他已经加载了,而你这个没有加载,也就是得不到我们自定义的String,所以在编写时,他只会给出父加载器的该相同包的该字节码
为什么需要双亲委派模型?
为什么需要双亲委派模型呢?假设没有双亲委派模型,试想一个场景:
黑客自定义一个 java. lang. String 类,该 String 类具有系统的 String 类一样的功能,只是在某个函数稍作修改,比如 equals 函数,这个函数经常使用,如果在这个函数中,黑客加入一些"病毒代码" ,并且通过自定义类加载器加入到JVM 中,此时,如果没有双亲委派模型,那么 JVM 就可能误以为黑客自定义的java. lang. String 类是系统的 String 类(既然没有双亲委派模型,那么他并不认为String 一定是加载的,或者说,没有加载,因为他没有操作父加载器),导致"病毒代码" 被执行(比如遍历c盘文件,然后都删除)
而有了双亲委派模型,黑客自定义的 java.lang.String 类永远都不会被加载进内存,因为首先是最顶端的类加 载器加载系统的 java.lang.String 类,最终自定义的类加载器无法加载 java.lang.String 类或者只会加载没有父的对应类的,只能加载一次哦,这也是为什么他加载是加载一次,而不是覆盖他的主要原因
这里还是给出一个例子:
package java. lang ;
public class String {
static {
System . out. println ( "我们自定义的String加载" ) ;
}
public static void main ( String [ ] args) {
System . out. println ( 1 ) ;
}
}
他会报如下错误:
错误: 在类 java. lang. String 中找不到 main 方法, 请将 main 方法定义为:
public static void main ( String [ ] args)
否则 JavaFX 应用程序类必须扩展javafx. application. Application
或许你会想,我在自定义的类加载器里面强制加载上面的自定义的 java.lang.String 类(自定义的),而不是他不加载,即不去通过调用父加载器不就好了吗?,确实,这样是可行,但是,在 JVM 中,实际上为了后续,也一般不会进行操作,因为你这样容易被黑客操作(你手写的文件就是一个最好的例子),实际上这并不是最重要的,最重要的是具体文件的内容,你一人也基本只能实现一部分,为什么这样说,你可以选择,看看原来的String文件的内容就知道了,你并不能保证都写出来,所以在这里的结果下,即在上面说明的情况下,也会判断一个对象是否是某个完全相同的类型的(这个判断手段太多的,比如反射就可以),如果该对象的实际类型与待比较 的类型的类加载器不同,那么会返回false(即不同类型),这并不是表示加载失败,反而是加载成功,因为在前面说明过"即使是同一个类文件, 被不同的加载器加载也会视为不同的类"
举个简单例子:
ClassLoader1 ,ClassLoader2 都加载 java. lang. String 类(可能是项目说明的包哦)或者其他相同的类,对应Class1 、Class2 对象,那么Class1 对象不属于 ClassLoadr2 对象加载的 java. lang. String 类型
那么如何保证他操作的equals是正确的类型的呢,实际上虽然看起来是相同的String,但是我们知道初始化是最后操作的,也就是说,中间的如解析操作还是存在,即加载的存在,一般来说,对应的String可能会保留,或者在解析时,进行操作,使得String是对应的String,这也就是导致,虽然你加载了,但是你后加载,那么我的为主,即由于启动的String先加载,那么以启动的为主
当然了,他们的流程只是一个明面上的操作,在高级黑客中,或者顶端黑客中,还是防不住的,因为我完全可以将原来的String文件改变,或者改变底层代码来实现,只要你是二进制实现的(而互联网就是二进制的操作,但硬件层面的必要操作是不可能改变的,就如我的世界中,将一个方块往前,那么他必然是往前的,而不会出现错误的可能性,除非该硬件改变,但是硬件只能由操作他的人改变的,而不会因为,在他之上的逻辑程序而改变),基本都可以黑掉,这也是为什么说,世界上没有100%安全的防护原因
所以由于双亲是父子,子先让父操作(先委派你操作),所以是双亲委派
如何实现双亲委派模型:
双亲委派模型的原理很简单,实现也简单,每次通过先委托父类加载器加载,当父类加载器无法加载时,再自己加 载,其实 ClassLoader 类默认的 loadClass 方法已经帮我们写好了,我们无需去写,所以这里说明一下几个重要函数:
几个重要函数:
loadClass 默认实现如下(不同jdk版本可能有些变量不同,或者说实现不同,但是大体相同,这里以jdk11为主):
public Class < ? > loadClass ( String name) throws ClassNotFoundException {
return loadClass ( name, false ) ;
}
再看看loadClass(String name,boolean resolve)函数:
protected Class < ? > loadClass ( String name, boolean resolve)
throws ClassNotFoundException
{
synchronized ( getClassLoadingLock ( name) ) {
Class < ? > c = findLoadedClass ( name) ;
if ( c == null ) {
long t0 = System . nanoTime ( ) ;
try {
if ( parent != null ) {
c = parent. loadClass ( name, false ) ;
} else {
c = findBootstrapClassOrNull ( name) ;
}
} catch ( ClassNotFoundException e) {
}
if ( c == null ) {
long t1 = System . nanoTime ( ) ;
c = findClass ( name) ;
PerfCounter . getParentDelegationTime ( ) . addTime ( t1 - t0) ;
PerfCounter . getFindClassTime ( ) . addElapsedTimeFrom ( t1) ;
PerfCounter . getFindClasses ( ) . increment ( ) ;
}
}
if ( resolve) {
resolveClass ( c) ;
}
return c;
}
}
从上面代码可以明显看出, loadClass(String, boolean) 函数即实现了双亲委派模型,整个大致过程如下:
1:首先,检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回
2:如果此类没有加载过,那么,再判断一下是否有父加载器,如果有父加载器,则由父加载器加载(即 调用 parent.loadClass(name, false); ),或者是调用 findBootstrapClassOrNull来使得加载(启动类加载器加载,这里简称 bootstrap 类加载器,也就是启动加载器的统称)
3:如果父加载器(自然使得 bootstrap 类加载器)都没有找到指定的类,那么调用当前类加载器(自定义)的 findClass 方 法来完成类加载(启动类加载完返回有值,否则没有值,具体看c/c++的操作,这里不做说明)
实际上我们看代码就知道,自定义类加载器,就必须重写 findClass 方法,否则他是不可能会加载的(默认是抛出异常,虽然他并不是抛出的意思,直接打印异常,但是一般该情况也可以说成是抛出,虽然他是直接的打印,打印异常就相当于停止后面的操作了,一般在try里面,他并不会操作catch的printStackTrace的打印,直接跳过,因为他本身就是一个打印信息,而printStackTrace会判断是否操作打印错误的,如果你是该直接的打印,那么我不打印,否则我打印)
所以抽象类 ClassLoader 的 findClass 函数默认是抛出异常的,而前面我们知道, loadClass 在父加载 器无法加载类的时候,就会调用我们自定义的类加载器中的 findeClass 函数,因此我们必须要在 loadClass 这 个函数里面实现将一个指定类名称转换为 Class 对象的操作,来完成加载,这就是自定义加载的核心操作,也是我们操作自定义加载器的核心操作
如果是读取一个指定的名称的类为字节数组的话,这很好办(虽然默认的加载器或者说jdk自带的一般没有这个名称,所以通常需要特殊的办法,但是二进制是不讲道理的,只要可以实现某些能力,那么在这个能力上是可以进一步的(数组类型),你要明确一点,但凡是关于数据的,那么二进制基本可以操作完毕,这也是为什么编程可以实现大多数生活中的某些情况的主要原因,无非就是一些数据操作,就如我们广泛认为的CRUD,增删改查也是如此,虽然按照顺序CRUD来说是,增查改删,但是实际操作顺序是增删改查,大多数我们都会说明增删改查,所以以增删改查为主),换句话说,就是如何将字节数组转为 Class 对象呢?很简单,Java 提供了 defineClass 方法,通过这个方法,就可以把一个字节数组转为Class对象,实际上字节数组的内容是通过class文件得到的,而通过这个方式可以直接操作到Class,相当于加载了,一般自定义加载都会使用他来完成,这就是自定义加载是可以自定义操作的主要原因
defineClass 主要的功能是(在ClassLoader里面):
将一个字节数组转为 Class 对象,这个字节数组是 class 文件读取后最终的字节数组,如,假设 class 文 件是加密过的,则需要解密后作为形参传入 defineClass 函数
defineClass 默认实现如下(后面会说明的,自定义加载器的实现):
protected final Class < ? > defineClass ( String name, byte [ ] b, int off, int len)
throws ClassFormatError
{
return defineClass ( name, b, off, len, null ) ;
}
自定义加类加载器:
为什么要自定义类加载器:
1:隔离加载类
模块隔离,把类加载到不同的应用程序中,比如tomcat这类web应用服务器,内部自定义了好几种类加载器,用于隔离web应用服务器上的不同应用程序,一个应用一个jvm,也就是一个main,那么多个应用可以是多个main,即多个jvm,当他们使用时,可以在除了(jvm)自带的加载器外,都会有自己的自定义加载器,即隔离不同应用操作功能,或者隔离代码操作
2:修改类加载方式
除了Bootstrap加载器外,其他的加载并非一定要引入,根据实际情况在某个时间点按需进行动态加载,即当我们需要时,可以随时加载,即并非一定会加载,这里就要提一下上面的代码了,只有执行到findClass才算加载,一般他就代表我们动态加载,这也是为什么默认自定义是系统加载器的原因,使得必然不会到对应的地方报错(虽然他也只是指向而已)
3:扩展加载源
比如还可以从数据库、网络、或其他终端上加载
4:防止源码泄漏
java代码容易被编译和篡改,可以进行编译加密,类加载需要自定义还原加密字节码,就相当于你自己加密文件一样,反正是按照你自己的方式来操作的
自定义函数调用过程:
自定义类加载器实现:
首先,我们定义一个待加载的普通 Java 类:Test.java,放在 com.lagou.demo 包下:
package com. lagou. dome ;
public class Test {
public static void main ( String [ ] args) {
System . out. println ( 1 ) ;
}
}
然后执行,使得出现class文件,看后面的代码,来决定放在那里
我们有个问题,我们在操作时,上面的类可以到其他类里使用,实际上是因为在考虑加载时,也就是加载Class时,里面的内容会导致也会加载对应的类,只是加载是从底层开始的,即自定义到系统到扩展到启动(当自定义是系统时,默认直接是系统),虽然自定义需要手动,但是大致流程是这样,但是如果没有手动,我们直接的操作他的使用,为什么也会出现Class呢,这就要注意一个操作,因为默认import的原因,会导致系统也会加载,这就是为什么之前说"Java应用的类都是由它来完成加载的"的原因,所以自定义实际上是手动的哦,但也可以实现流程,这点要注意
现在我们来编写自定义加载器的实现:
package com. lagou. dome ;
import java. io. * ;
public class MyClassLoader extends ClassLoader {
private String codePath;
public MyClassLoader ( String codePath) {
this . codePath = codePath;
}
public MyClassLoader ( ClassLoader parent, String codePath) {
super ( parent) ;
this . codePath = codePath;
}
@Override
protected Class < ? > findClass ( String name) throws ClassNotFoundException {
BufferedInputStream bis = null ;
ByteArrayOutputStream baos = null ;
try {
String path = codePath + name + ".class" ;
bis = new BufferedInputStream ( new FileInputStream ( path) ) ;
baos = new ByteArrayOutputStream ( ) ;
int len;
byte [ ] data = new byte [ 1024 ] ;
while ( ( len = bis. read ( data) ) != - 1 ) {
baos. write ( data, 0 , len) ;
}
byte [ ] byteCode = baos. toByteArray ( ) ;
Class < ? > defineClass = defineClass ( null , byteCode, 0 , byteCode. length) ;
return defineClass;
} catch ( Exception e) {
e. printStackTrace ( ) ;
} finally {
try {
bis. close ( ) ;
} catch ( IOException e) {
e. printStackTrace ( ) ;
}
try {
baos. close ( ) ;
} catch ( IOException e) {
e. printStackTrace ( ) ;
}
}
return null ;
}
}
现在,我们来使用他:
package com. lagou. dome ;
public class ClassLoaderTest {
public static void main ( String [ ] args) {
MyClassLoader classLoader = new MyClassLoader ( "d:/" ) ;
try {
Class < ? > clazz = classLoader. loadClass ( "Test" ) ;
System . out. println ( clazz. getName ( ) ) ;
System . out. println ( clazz. getClassLoader ( ) . getClass ( ) ) ;
System . out. println ( "我是由" + clazz. getClassLoader ( ) . getClass ( ) . getName ( ) + "类加载器加载的" ) ;
System . out. println ( clazz) ;
System . out. println ( String . class ) ;
String s = new String ( "1" ) ;
System . out. println ( s. getClass ( ) ) ;
System . out. println ( clazz. getClassLoader ( ) . getClass ( ) ) ;
} catch ( ClassNotFoundException e) {
e. printStackTrace ( ) ;
}
}
}
自己进行测试吧,这里给出一个测试,测试两个不同加载器的结果不同,你可以创建任何一个类来继承MyClassLoader,并操作出结果,然后比较Class的引用是否相同,根据测试结果为false,即的确不相同,也验证了之前的"也会判断一个对象是否是某个完全相同的类型的(这个判断手段太多的,比如反射就可以),如果该对象的实际类型与待比较 的类型的类加载器不同,那么会返回false(即不同类型)"
ClassLoader源码剖析:
类的关系图:
关系类图如下:
上面图中,虚线代表实现,否则一般是继承
Launcher核心类的源码剖析(可能需要看jdk版本):
我们先从启动类说起,有一个Launcher类 sun.misc.Launcher(这里以jdk8为主,可能会有所改变,但是大体相同)
public class Launcher {
private static URLStreamHandlerFactory factory = new Launcher. Factory ( ) ;
private static Launcher launcher = new Launcher ( ) ;
private static String bootClassPath = System . getProperty ( "sun.boot.class.path" ) ;
private ClassLoader loader;
private static URLStreamHandler fileHandler;
public static Launcher getLauncher ( ) {
return launcher;
}
public Launcher ( ) {
Launcher. ExtClassLoader var1;
try {
var1 = Launcher. ExtClassLoader . getExtClassLoader ( ) ;
} catch ( IOException var10) {
throw new InternalError ( "Could not create extension class loader" , var10) ;
}
try {
this . loader = Launcher. AppClassLoader . getAppClassLoader ( var1) ;
} catch ( IOException var9) {
throw new InternalError ( "Could not create application class loader" , var9) ;
}
Thread . currentThread ( ) . setContextClassLoader ( this . loader) ;
String var2 = System . getProperty ( "java.security.manager" ) ;
if ( var2 != null ) {
SecurityManager var3 = null ;
if ( ! "" . equals ( var2) && ! "default" . equals ( var2) ) {
try {
var3 = ( SecurityManager ) this . loader. loadClass ( var2) . newInstance ( ) ;
} catch ( IllegalAccessException var5) {
} catch ( InstantiationException var6) {
} catch ( ClassNotFoundException var7) {
} catch ( ClassCastException var8) {
}
} else {
var3 = new SecurityManager ( ) ;
}
if ( var3 == null ) {
throw new InternalError ( "Could not create SecurityManager: " + var2) ;
}
System . setSecurityManager ( var3) ;
}
}
构造方法 Launcher() 中做了四件事情:
其中launcher是staitc的,所以初始化的时候就会创建对象,也就是触发了构造方法,所以初始化的时候就会执行上面四 个步骤
通过观察,在应用加载器中存在如下类(虽然对应的参数可能还是false):
public Class < ? > loadClass ( String var1, boolean var2) throws ClassNotFoundException {
int var3 = var1. lastIndexOf ( 46 ) ;
if ( var3 != - 1 ) {
SecurityManager var4 = System . getSecurityManager ( ) ;
if ( var4 != null ) {
var4. checkPackageAccess ( var1. substring ( 0 , var3) ) ;
}
}
if ( this . ucp. knownToNotExist ( var1) ) {
Class var5 = this . findLoadedClass ( var1) ;
if ( var5 != null ) {
if ( var2) {
this . resolveClass ( var5) ;
}
return var5;
} else {
throw new ClassNotFoundException ( var1) ;
}
} else {
return super . loadClass ( var1, var2) ;
}
}
ClassLoader 源码剖析:
ClassLoader类,它是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器),这里我们 主要介绍ClassLoader中几个比较重要的方法
loadClass(String):
注意:可以选择设置jdk版本来查看,但反正大体相同
该方法加载指定名称(包括包名)的二进制类型,该方法在JDK1.2之后不再建议用户重写,但用户可以直接调用该方法,loadClass()方法是ClassLoader类自己实现的,该方法中的逻辑就是双亲委派模式的实现,其源码如在前面具体说明了,这里就不给出了其中loadClass(String name, boolean resolve)是一个重载方法,resolve参数代表是否生成class对象的同时进行 解析相关操作
使用指定的二进制名称来加载类,这个方法的默认实现按照以下顺序查找类: 调用findLoadedClass(String)方法检查这 个类是否被加载过,使用父加载器调用loadClass(String)方法,如果父加载器为Null,类加载器装载虚拟机内置的加载器调 用findBootstrapClassOrNull(String)方法装载类, 如果,按照以上的步骤成功的找到对应的类,并且该方法接收的resolve参数的值为
true(虽然一般是false,true主要操作自定义的,即主要看我们是否操作,但是我们一般只会操作加载,像双亲委派或者这个操作一般不会进行),那么就调用resolveClass(Class)方法来处理类, ClassLoader的子类最好覆盖findClass(String),而不是这个 方法,即被重写,这个方法默认在整个装载过程中都是同步的(线程安全的)
findClass(String):
在JDK1.2之前,在自定义类加载时,总会去继承ClassLoader类并重写loadClass方法,从而实现自定义的类加 载类,但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findClass()方法中,从前面的分析可知,findClass()方法是在loadClass()方法中被调用的,当loadClass()方法中 父加载器加载失败后,则会调用自己的findClass()方法来完成类加载,这样就可以保证自定义的类加载器也符 合双亲委托模式(操作了父,也就是前面默认的,自定义通常虽然也算,但是有时候根据定义"每次通过先委托父类加载器加载,当父类加载器无法加载时,再自己加 载"来看的话,一般只是代表对应的逻辑是双亲委派的,也就是该方法和包括该方法调用之前的(逻辑)代码的逻辑是双亲委派,即双亲委派代表的就是这个),需要注意的是ClassLoader类中并没有实现findClass()方法的具体代码逻辑,取而代之的是抛 出ClassNotFoundException异常,并且通过前面的自定义加载器的操作,我们应该知道的是findClass方法通常是和defineClass方法一起使用的
ClassLoader类中findClass()方法源码在前面也说明了,这里就不给出了
defineClass(String name, byte[] b, int off, int len)方法是用来将byte字节流解析成JVM能够识别的Class对象(defineClass中已实现该方法逻辑,所以我们只需要使用即可),通过这个方法不仅能够通过class文件创建Class对象从而可以实例化class对象,也可以通过其他方 式创建Class对象,从而实例化class对象,如通过网络接收一个类的字节码,然后转换为byte字节流创建对应的Class对象,defineClass()方法通常与findClass()方法一起使用,一般情况下,在自定义类加载器时,会直接覆盖ClassLoader的findClass()方法并编写加载规则,取得要加载类的字节码后转换成流来操作数据,然后调用defineClass()方法生成类的Class对象,前面就已经操作过了,这里就不演示了
需要注意的是,如果直接调用defineClass()方法生成类的Class对象,这个类的Class对象可能并没有解析(也就验证之前说的"它在某些情况下可以初始化阶段之后在开始"),或者说跳过了,也就是说,对象有,但是可能少些信息(也可以理解为链接阶段,毕竟解析是链接的最后一步),其解析操作需要等待初始化阶段进行,可以认为defineClass()有某些标志可以实现只能操作一次,这里了解即可
resolveClass() :
使用该方法可以使用类的Class对象创建完成也同时被解析(所以才会有后面的判断,虽然一开始是false),前面我们说链接阶段主要是对字节码进行验证, 为类变量分配内存并设置初始值同时将字节码文件中的符号引用转换为直接引用
注意:由于解析的存在(这里自定义并没有操作,具体可以百度),通常情况下,再次的操作可能会报错,这也是对应底层defineClass不能再次执行的原因,可以认为为了保留标志(因为他操作了)使得不能执行,具体原因可以百度,大概是他的判断的是否加载并不是直接返回,而是报异常吧
到这里我们就知道,实际上解析也是可以不操作的(一般默认不解析,所以有时候是使用符号引用的也行,因为实际上符合引用更好,所以是这样默认的)
还有一点:你看到前面的代码可以发现,自定义的并没有设置父加载器,实际上他有默认设置的,如果没有设置,默认为应用加载器,自己打印一下就知道了,主要是构造里面的隐藏的父构造造成的
实际上面的流程在对应的引用或者扩展里面可以找到的,只要你去看对应的他们的findClass方法就行了,仔细一点就能找到
垃圾回收机制及算法:
垃圾回收概述:
什么是垃圾回收:
说起垃圾收集(Garbage Collection, 简称GC) , 有不少人把这项技术当作Java语言的伴生产物,事实上, 垃圾收集的历史远远比Java久远, 在1960年诞生于麻省理工学院的Lisp是第一门开始使 用内存动态分配和垃圾收集 技术的语言,垃圾收集需要完成的三件事情:
哪些内存需要回收? 什么时候回收? 如何回收?
java垃圾回收的优缺点(一般来说java有自动的gc,和不自动gc的,前面也说明过了,这里考虑不自动,一般不自动看起来需要手动,但是里面的操作是自动的,所以才会有如下说明,gc简称不自动gc,所以看到gc就认为不是自动gc的):
优点:
a:不需要考虑内存管理
b:可以有效的防止内存泄漏,有效的利用可使用的内存
c:由于有垃圾回收机制,Java中的对象不再有"作用域"的概念,只有对象的引用才有"作用域"
缺点:
java开发人员不了解自动内存管理,内存管理就像一个黑匣子,过度依赖就会降低我们解决内存溢出/内存泄漏等问题的能力
垃圾回收-对象是否已死:
判断对象是否存活 - 引用计数算法:
引用计数算法可以这样实现:给每个创建的对象添加一个引用计数器,每当此对象被某个地方引用时,计数值+1, 引用失效时-1,所以当计数值为0时,表示对象已经不能被使用,引用计数算法大多数情况下是个比较不错的算法, 简单直接,也有一些著名的应用案例,但是对于Java虚拟机来说,并不是一个好的选择,因为它很难解决对象直接相 互循环引用的问题
优点: 实现简单,执行效率高,很好的和程序交织
缺点:
无法检测出循环引用
举个缺点的例子:
案例:
package com. lagou. lagou1 ;
public class App {
public static void main ( String [ ] args) {
Test object1 = new Test ( ) ;
Test object2 = new Test ( ) ;
object1. object = object2;
object2. object = object1;
object1 = null ;
object2 = null ;
}
}
class Test {
public Test object = null ;
}
这两个对象再无任何引用, 实际上这两个对象已 经不可能再被访问, 但是它们因为互相引用着对方, 导致它们的 引用计数都不为零(内部,因为你只是将外面的引用设置而已,内部的还存在), 引用计数算法也 就无法回收它们
但是在java程序中这两个对象仍然会被回收,因为java中并没有使用引用计数算法
要解决上面的问题,实际上我们除了操作外面的引用外,内部的也要进行设置为null(一个就行,因为单方面的会自然使得回收,看后面解释即可),这样就行了,所以与其说循环引用,还不如说内部循环矛盾,这是之所以说是内部循环矛盾,而不是内部矛盾,是因为根据计数,他们必须要循环,因为只有单方面的话,其中一方容易被回收,而回收后,自然引用也没有,那么另外一方也会回收的,所以他们需要循环
判断对象是否存活-可达性分析算法:
可达性分析算法:
在主流的商用程序语言如Java、C#等的主流实现中,都是通过可达性分析(Reachability Analysis)来判断对象是否存活的,此算法的基本思路就是通过一系列的"GC Roots"的对象作为起始点,从起始点开始向下搜索到对象的路径,搜索所经过的路径称为引用链(Reference Chain),当一个对象到任何GC Roots(下面没有起点为GC Root的)都没有引用链时,则表明对象"不可达",即该对象是不可用的(除非是循环,否则单方面最终是不可用的,这里默认单方面,所以是这样的说明"不可用的",当然,单方面可能还是存在,后面会说明的)
你可以说成,GC Root是引用,而后面的引用链对象是我们的对象,只要他们没有人在最终被GC Root指向,那么说明就是不可用的,当然,引用链本身也算GC Root,举个例子:如果下面的Object_B,没有被前面的Object_A,那么他会被回收,虽然他单方面的指向Object_D
在Java语言中,可作为GC Roots的对象包括下面几种:
1:栈帧中的局部变量表中的reference引用所引用的对象
2:方法区(总体说明的,因为不是元空间)中static静态引用的对象
3:方法区中final常量引用的对象
4:本地方法栈中JNI(Native方法)引用的对象
5:Java虚拟机内部的引用, 如基本数据类型对应的Class对象, 一些常驻的异常对象(比如 NullPointExcepiton,OutOfMemoryError) 等, 还有系统类加载器
6:所有被同步锁(synchronized关键字) 持有的对象
7:反映Java虚拟机内部情况的JMXBean、 JVMTI中注册的回调、 本地代码缓存等
总之,只要是一个对象,基本都可以算,因为最终对象实例基本都在堆中
JVM之判断对象是否存活:
finalize()方法最终判定对象是否存活:
即使在可达性分析算法中判定为不可达的对象, 也不是"非死不可"的, 这时候它们暂时还处于"缓刑"阶段, 要真正宣告一个对象死亡, 至少要经历两次标记过程(与引用计数器是不同的,一般手动gc,有时候也会看我们的策略的,也就是说,如果手动gc认为你需要回收,还要看看是否满足策略的回收(也就是只会找没有被引用的对象,这就是原因,要不然为什么这样找呢),才进行考虑,所以这里不要认为一定是单方面的会回收哦,可能策略还没有满足,对于引用来说,若是0,满足,那么回收,否则手动gc跳过,等待下次找到,而下面是第二次标记打上了或者没有必要执行而满足回收,但是他的手动gc虽然也会跳过,但是必须等你操作完去了,才考虑是否跳过(看是否复活),而引用并没有需要等待的操作,自动gc可以没有这样的满足限制或者等待限制,但是并非所有普通gc会考虑满足或者该等待限制(一般在这之前也包含了STW,后面会说明),这里注意即可):
第一次标记:
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链, 那它将会被第一次标记, 随后进行一次筛选, 筛选的条件是此对象是否有必要执行finalize()方法
没有必要:
假如对象没有覆盖finalize()方法, 或者finalize()方法已经被虚拟机调用过, 那么虚拟机将这两种情况都视为"没有必要执行"(满足回收)
protected void finalize ( ) throws Throwable {
}
有必要:
如果这个对象被判定为确有必要执行finalize()方法, 那么该对象将会被放置在一个名为F-Queue的队列之中, 并在 稍后由一条由虚拟机自动建立的、 低调度优先级的Finalizer线程去执行它们的finalize() 方法,finalize()方法是对象逃脱死亡命运的最后一次机会, 稍后收集器将对F-Queue中的对象进行第二次小规模的标记 , 如果对 象要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可, 譬如把自己 (this关键字) 赋值 给某个类变量或者对象的成员变量, 那在第二次标记时它将被移出"即将回收"的集合(方法执行后,会操作判断他是否被引用指向(这里没有说明具体代码在哪里,所以知道即可),来考虑是标记还是移除队列,这个时候可以看成队列是即将回收的时候,对于他来说是的,也就是执行该方法时到执行判断时,加起来是即将回收的时候),如果对象这时候还没有逃 脱(判断没有引用,那么就会被标记上,否则不会标记,因为上面是"稍后收集器",即稍后标记,也就是执行对应方法后,操作标记), 那基本上它就真的要被回收了
当然了,他们的方法操作是由于gc来操作的,这里与引用计数器(也就是引用计数算法)不同,虽然说需要满足,但是通常他会等待他出现满足,也就是说,如果你执行方法,在方法里面加上等待,那么我的gc会等待你操作完,所以,不要以为gc是不会等待的
简单来说,没有必要执行,第二次的标记,都是回收满足的条件
一次对象自我拯救的演示:
package com. lagou. lagou1 ;
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null ;
public void isAlive ( ) {
System . out. println ( "yes, i am still alive :)" ) ;
}
@Override
protected void finalize ( ) throws Throwable {
super . finalize ( ) ;
System . out. println ( "finalize method executed!" ) ;
FinalizeEscapeGC . SAVE_HOOK = this ;
}
public static void main ( String [ ] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC ( ) ;
SAVE_HOOK = null ;
System . gc ( ) ;
Thread . sleep ( 500 ) ;
if ( SAVE_HOOK != null ) {
SAVE_HOOK . isAlive ( ) ;
} else {
System . out. println ( "no, i am dead :(" ) ;
}
SAVE_HOOK = null ;
System . gc ( ) ;
Thread . sleep ( 500 ) ;
if ( SAVE_HOOK != null ) {
SAVE_HOOK . isAlive ( ) ;
} else {
System . out. println ( "no, i am dead :(" ) ;
}
}
}
注意:Finalizer线程去执行它们的finalize() 方法,这里所说的"执行"是指虚拟机会触发这个方法开始运行(可以认为有多个Finalizer线程来执行,或者会创建Finalizer线程来执行,多线程一般也会创建,实际上是gc的不同导致他们不同), 但并不承诺一定会等待它运行结束,这样做的原因是, 如果某个对象的finalize()方法执行缓慢, 或者更极端地发生了死循环, 将很可能导 致F-Queue队列中的其他对象永久处于等待, 甚至导致整个内存回收子系统的崩溃,就如前面我们知道一个gc会等待,而另外的gc操作,但是并不会一直等待,使得他被占用,所以在一点时间内,如果该gc还在等待,默认他没有必要,直接回收
再谈引用(这里了解即可 ):
在JDK1.2以前,Java中引用的定义很传统:如果引用类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用,这种定义有些狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,我们希望能描述这一类对象:当内存空间还足够时,则能保存在内存中,如果内存空间在进行垃圾回收后还是非常紧张,则可以考虑抛弃这些对象,很多系统中的缓存对象都符合这样的场景,在JDK1.2之后,Java对引用的概念做了扩充,将引用分为 强引用(Strong Reference)、 软引用(Soft Reference)、 弱引用(Weak Reference)和 虚引用(Phantom Reference)四种,这四种引用的强度依次递减
强引用(StrongReference):
强引用是使用最普遍的引用,如果一个对象具有强引用,那垃圾回收器绝不会回收它,当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问 题,强引用其实也就是我们平时A a = new A()这个意思
软引用(SoftReference):
如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对 象的内存,只要垃圾回收器没有回收它,该对象就可以被程序使用,软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到 与之关联的引用队列中
弱引用(WeakReference):
用来描述那些非必须对象, 但是它的强度比软引用更弱一些, 被弱引用关联的对象只能生存到下一次垃圾收集发 生为止,当垃圾收集器开始工作, 无论当前内存是否足够, 都会回收掉只被弱引用关联的对象,在JDK 1.2版之 后提供了WeakReference类来实现弱引用,弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用 所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中
弱引用与软引用的区别在于:
更短暂的生命周期,因为被弱引用关联的对象只能生存到下一次或者说垃圾收集发 生为止,也就是下一次垃圾回收后,就不存在了,所以一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存
虚引用(PhantomReference):
"虚引用"顾名思义,它是最弱的一种引用关系,如果一个对象仅持有虚引用,在任何时候都可能被垃圾回收器回收,虚引用主要用来跟踪对象被垃圾回收器回收的活动
虚引用与软引用和弱引用的一个区别在于:
1:虚引用必须和引用队列 (ReferenceQueue)联合使用
2:当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到 与之 关联的引用队列中
垃圾收集算法(也就是回收中的操作,在满足前面的判断是否存活而实现的回收操作):
分代收集理论:
思想也很简单,就是根据对象的生命周期将内存划分,然后进行分区管理,当前商业虚拟机的垃圾收集器, 大多 数都遵循了"分代收集(Generational Collection)"的理论进 行设计, 分代收集名为理论, 实质是一套符合大多数 程序运行实际情况的经验法则, 它建立在两个分代假说之上:
1:弱分代假说(Weak Generational Hypothesis) : 绝大多数对象都是朝生夕灭的
2:强分代假说(Strong Generational Hypothesis) : 熬过越多次垃圾收集过程的对象就越难以消亡
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则: 收集器应该将Java堆划分 出不同的区域, 然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数) 分配到不同的区 域之中存储,显而易见, 如 果一个区域中大多数对象都是朝生夕灭, 难以熬过垃圾收集过程的话, 那 么把它们集中放在一起, 每次回收时只 关注如何保留少量存活而不是去标记那些大量将要被回收的对 象, 就能以较低代价回收到大量的空间,如果剩下 的都是难以消亡的对象, 那把它们集中放在一块, 虚拟机便可以使用较低的频率来回收这个区域, 这就同时兼顾 了垃圾收集的时间开销和内存的空间有 效利用
在Java堆划分出不同的区域之后, 垃圾收集器才可以每次只回收其中某一个或者某些部分的区域,因而才有 了"Minor GC",“Major GC”,“Full GC"这样的回收类型的划分,也才能够针对不同的区域安 排与里面存储对象存亡特征相匹配的垃圾收集算法,因而发展出了"标记-复制算法”,“标记-清除算法”,"标记-整理算法"等针对性的垃圾收集算 法,注意之前的判断是否存活是考虑回收的,而这些算法是考虑回收后的操作,也就是说,在是否存活考虑回收时,虽然他们可能有标记记录,那么这个标记可用认为是这里的标记,而复杂,清除,整理,就是操作这些已经标记的(前面只是说明回收,并没有说明回收的如何回收,即这里的那个对应)
他针对不同分代的类似名词, 为避免产生混淆, 在这里统一定义 :
部分收集(Partial GC) : 指目标不是完整收集整个Java堆的垃圾收集, 其中又分为:
1:新生代收集(Minor GC/Young GC): 指目标只是新生代的垃圾收集
2:老年代收集(Major GC/Old GC): 指目标只是老年代的垃圾收集,其中一般CMS收集器会有单 独收集老年代的行为
3:混合收集(Mixed GC): 指目标是收集整个新生代以及(可能是部分)老年代的垃圾收集,其中一般G1收集器会有这种行为
整堆收集(Full GC) : 收集整个Java堆和方法区的垃圾收集
标记-清除算法:
什么是标记-清除算法?
最早出现也是最基础的垃圾收集算法是"标记-清除(Mark-Sweep)" 算法, 在1960年由Lisp之父 John McCarthy所 提出,如它的名字一样, 算法分为"标记"和"清除"两个阶段: 首先标记出所有需要回 收的对象, 在标记完成后, 统一回收(清除)掉所有被标记的对象, 也可以反过来, 标记存活的对象, 统一回 收所有未被标记的对象
标记过程就是对象是否属于垃圾的判定过程, 这在前面讲述垃圾对象标记 判定算法(判断对象是否存活)时其实已经介绍过了,之所 以说它是最基础的收集算法, 是因为后续的收集算法大多都是以标记-清除算法为基础, 对其 缺点进行改进而得到 的
标记-清除算法有两个不足之处:
第一个是执行效率不稳定, 如果Java堆中包含大量对象, 而且其中大部分是需要被回收的, 这时必须进行大量标 记和清除的动作, 导致标记和清除两个过 程的执行效率都随对象数量增长而降低,但这个并不是主要的,因为谁都要标记,所以主要的不足是如下第二个不足
第二个是内存空间的碎片化问题, 标记、 清除之后会产生大 量不连续的内存碎片, 空间碎片太多可能会导致当以 后在程序运行过程中需要分配较大对象时无法找 到足够的连续内存而不得不提前触发另一次垃圾收集动作
标记-复制算法:
什么是标记-复制算法(也有标记哦,只是在清除操作里进一步升级):
标记-复制算法常被简称为复制算法
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题, 1969年Fenichel提出了一种称为"半区复制(Semispace Copying)"的垃圾收集算法, 它将可用 内存按容量划分为大小相等的两块, 每次只使用其中的一 块,当这一块的内存用完了, 就将还存活着 的对象复制到另外一块上面, 然后再把已使用过的内存空间一次清理 掉,如果内存中多数对象都是存活的, 这种算法将会产生大量的内存间复制的开销, 但对于多数对象都是可回收 的情况, 算法需要复制的就是占少数的存活对象, 而且每次都是针对整个半区进行内存回收, 分配内存时也就不 用考虑有空间碎片的复杂情况, 只要移动堆顶指针(原来的进行初始化,比如设置为null,不指向), 按顺序分配即可
上图中有点问题,原来的是8个变成5个了,这里注意即可,后面的回收后状态那边应该是8个的(图是复制粘贴来的)
但是这种算法也有缺点(标记也算缺点,虽然因为可用比较少而减少了该缺点):
1:需要提前预留一半的内存区域用来存放存活的对象(经过垃圾收集后还存活的对象),这样导致可用的对 象区域减小一半,总体的GC更加频繁了
2:如果出现存活对象数量比较多的时候,需要复制较多的对象,成本上升,效率降低
3:如果99%的对象都是存活的(对于老年代来说),那么老年代是无法使用这种算法的,因为成本太高
注意事项:
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代, IBM公司曾有一项专门研 究对新生代"朝生 夕灭"的特点做了更量化的诠释:新生代中的对象总体来说有98%熬不过第一轮收集,大多数对象都是使用了然后清除(我们程序员比较规范的情况下),因此 并不需要按照1∶1的比例来划分 新生代的内存空间,Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的 Survivor空间, 每 次分配内存只使用Eden和其中一块Survivor,发生垃圾搜集时, 将Eden和Survivor中仍 然存活的对象一次性复制到 另外一块Survivor空间上(则就是为什么有一个是空闲的底层原因), 然后直接清理掉Eden和已用过的那块Survivor空 间,HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1 :1(给不规范一点余地,所以不是根据98%为主的比例哦), 也即每次新生代中可用内存空间为整个新 生代容量的90%(Eden的80%加上一个Survivor的10%) , 只有一个Survivor空间, 即10%的新生代是会 被"浪费"的
但是要注意,对应的复制过去后,并不是都初始化,而是移动的进行初始化,这就是为什么前面说明这里时,两个都利用到了,但总体来说,并不是一个幸存者空间空间(初始化),即也违背了空闲的说明,实际上这种是特殊情况,一般来说,可能受某些决策影响,他看起来可以保留更多的数据,但是复制的也更加多,看起来提高了上限,这里解释之前的:这在标记-复制算法中会提到,这里你认为是特殊情况即可
那么这里就要说明一下正常情况:实际上我们之前认为在移动后,起始放入幸存者也会改变成移动的,这就是为什么之前说明的:此时会重新返回幸存者1区(改变位置了)
标记-整理算法:
标记-复制算法在对象存活率较高时就要进行较多的复制操作, 效率将会降低,更关键的是, 如果不想浪费50%的 空间, 就需要有额外的空间进行分配担保, 以应对被使用的内存中所有对象都100%存活的极端情况, 所以在老年 代一般不能直接选用这种算法,因为老年代空间太多,以及存活可能太多,总不能少50%,或者大幅度的降低效率吧
针对老年代对象的存亡特征, 1974年Edward Lueders提出了另外一种有针对性的"标记-整 理"(Mark-Compact) 算 法, 其中的标记过程仍然与"标记-清除"算法一样, 但后续步骤不是直接对可回收对象进行清理, 而是让所有存活 的对象都向内存空间一端移动, 然后直接清理掉边界以外的内存
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法, 而后者是移动式的,是否移动 回收后的存活对象是一项优缺点并存的风险决策:
是否移动对象都存在弊端, 移动则内存回收时会更复杂, 不移动则内存分配时会更复杂,从垃圾收集的停顿时间 来看, 不移动对象停顿时间会更短, 甚至可以不需要停顿, 但是从整个程序的吞吐量来看, 移动对象会更划算,所以各有利弊,如果需要加入更多空间,那么一般整理比较好(少gc,总体来说好点),否则清除比较好
垃圾收集器:
垃圾收集器概述:
1:垃圾回收器与垃圾回收算法
垃圾回收算法分类两类:
第一类算法判断对象生死的算法,如引用计数法、可达性分析算法
第二类收集死亡对象方法(也就是垃圾收集算法),如标记-清除算法、标记-复制算法、标记-整理算法
一般的实现采用分代回收算法(第二类总称),根据不同代的特点应用不同的算法(第二类),垃圾回收算法是内存回收的方法论,垃圾收集器是算法的落地实现(因为我们总要有操作不,总不能说说而已),和回收算法一样,目前还没有出现完美的收集器(需要因为各自区域操作不同,要不然怎么会出现第二类的各种呢,所以没有完美的,就算你在这个地方是好的,那么在其他地方可能是坏的),所以要根据具体的应用场景选择最合适的收集器,进行分代收集
2:垃圾收集器分类
串行垃圾回收(Serial):
串行垃圾回收是为单线程环境设计且只使用一个线程进行垃圾回收,会暂停所有的用户线程,不适合交互性强的服务器环境
并行垃圾回收(Parallel):
多个垃圾收集器线程并行工作,同样会暂停用户线程,适用于科学计算、大数据后台处理等多交互场景
注意(先考虑他们可以都有和改变,后面会进行补充),这些回收收集器只是实现方式(包括前面说明的和后面说明的,具体回收可能受区域影响,来改变收集死亡对象方法),也就是无论新生代还是老年代也好或者是方法区也好,都会操作他们(但是我们设置时是各自的分开的),只是可能会在对应的区域分开执行gc(组合)或者根据区域改变对应gc的收集死亡对象方法,一般我们是操作并行的,前面操作的说明的gc不同就是这样,即前面说明了"也就是说gc是不同的,或者说会加gc线程,或者是多线程(可能也会加)"
并发垃圾回收(CMS,一般会优先考虑用于老年代收集,虽然都可以操作,但是我们一般用于老年代收集):
用户线程和垃圾回收线程同时执行,不一定是并行的,可能是交替执行,可能一边垃圾回收,一边运行应用线程, 不需要停顿用户线程(专门操作老年代的,虽然触发他可能会操作STW,但是对于CMS来说,他并不是操作STW,只是老年代会触发而已,在前面说明了:老年代空间不足时,也会尝试触发MinorGC(然后考虑放入新生代,反过来操作了,而由于也会触发他,所以不只是数据大,且也考虑STW了),如果空间还是不足,则触发Major GC),这里就代表Major GC,所以对于Major GC来说是不需要停顿的,而对于老年代需要,互联网应用程序中经常使用,适用对响应时间有要求的场景
G1垃圾回收:
G1垃圾回收器将堆内存分割成不同的区域然后并发地对其进行垃圾回收(混合收集)
3:七种垃圾收集器及其组合关系
根据分代思想,我们有7种主流的垃圾回收器
新生代垃圾收集器:Serial 、 ParNew 、Parallel Scavenge
老年代垃圾收集器:Serial Old 、 Parallel Old 、CMS
整理收集器:G1
很明显(先考虑他们可以都有和改变,后面会进行补充),虽然前面说虽然都可以操作,但是可能会在对应的区域分开执行gc来满足他们(回收),且具体性能可能不同(所以我们默认的组合也是不同的,在前面也具体说明了对应gc,那就是相对好的性能的gc),且一般也需要根据具体情况,即老年代空间多(并发,都并行的意思,不要以通常名称来定义),新生代空间少(同步或者并行(这里表示gc的并行)),当然,也因为都可以操作,所以一般在满足情况下(设置时分开,使得在新老存在对应gc使用,可能也因为这样使得对应gc虽然都是回收,但是回收算法使得必然按照区域了(但自然也会使得可能在新中,会区分是否是eden来进一步改变算法),也就在设置的情况,会认为不会使得都可以操作了),可能有如下多种组合(有多个实现对应操作的gc)
垃圾收集器的组合关系(关系可能根据jdk版本而发生改变):
上面的组合中,若没有,那么会报错,且不能设置多个新生代,否则也报错,一般只设置老年代,那么只会操作老年,但多个老年也是不行的,否则也报错,这些都只是对参数来说的,所以默认的不算
最后注意:若你只设置老年代,那么会覆盖默认的新生代,但是不会覆盖参数新生代(只覆盖他的老),其中先后顺序决定谁先替换默认的,以及在谁前面(替换默认的自然在前面),参数是参数,只要写上,自然我们要显示出来
上面你可以先大致了解,需要结合后面的学习,以及后面说明的"常用垃圾收集器参数"来进行了解
JDK8中默认使用组合是:Parallel Scavenge GC 、ParallelOld GC
JDK9默认是用G1为垃圾收集器
JDK14 弃用了:Parallel Scavenge GC 、Parallel OldGC
JDK14 移除了 CMS GC
虚线代表弃用,但是可以使用,只是不建议,上面的图中可能是按照jdk8来说明的
4:GC性能指标
吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时 间 + 垃圾收集时间 )),例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%,一般来说吞吐量越大越好,所以一般我们需要保证用户代码时间多,或者垃圾回收时间少,反正总值是100分钟,所以需要看gc算法的好坏了,快点回收完,所以一般并行的收集比较好,所以前面才会说"一般我们是操作并行的"
暂停时间:执行垃圾回收时,程序的工作线程被暂停的时间
内存占用:java堆所占内存的大小
收集频率:垃圾收集的频次
Serial收集器:
单线程收集器,"单线程"的意义不仅仅说明它只会使用一个CPU或一个收集线程去完成垃圾收集工作,更重要的是它在垃圾收集的时候,必须暂停其他工作线程,直到垃圾收集完毕
"Stop The World"这个词语也 许听起来很酷, 但这项工作是由虚拟机在后台自动发起和自动完成的, 在用户 不可知、 不可控的情况 下把用户的正常工作的线程全部停掉, 这对很多应用来说都是不能接受的
示意了Serial/Serial Old收 集器的运行过程(考虑):
Serial收集器也并不是只有缺点,Serial收集器由于简单并且高效,那么对于单CPU环境来说(多核),由于Serial收集器没有线程间的交互,专心做垃圾收集自然可以做获得最高的垃圾收集效率(总体来说,该一个gc线程比多线程中的某个线程要快点,因为他不用抢夺cpu而节省中间的时间了,相当于其他没有快速抢夺的来说,但是我的cpu充沛,那么算力会大点,因为cpu内部也是分开操作算力的,这就是为什么cpu越好(好和多都算,可能多有某个上限),那么计算越好的原因)
使用方式:-XX:+UseSerialGC
案例:
package heihei ;
import java. util. ArrayList ;
public class test {
public static void main ( String [ ] args) {
ArrayList < byte [ ] > list = new ArrayList < byte [ ] > ( ) ;
while ( true ) {
byte [ ] b = new byte [ 1024 ] ;
list. add ( b) ;
try {
Thread . sleep ( 10 ) ;
} catch ( InterruptedException e) {
e. printStackTrace ( ) ;
}
}
}
}
在说明后面之前,首先建议你全局搜索"常用垃圾收集器参数",来看看对应的功能
ParNew 收集器:
ParNew收集器实质上是Serial收集器的多线程并行版本, 除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数 、 收集算法、 Stop The World、 对象分配规则、 回收策略等都与Serial收集器完全一致, 在实现上这两种收集器也共用了相当多的代码
ParNew收集器的工作过程:
ParNew收集器在单CPU服务器上的垃圾收集效率绝对不会比Serial收集器高
但是在多CPU服务器上,效果会明显比Serial好(我可以能操作多个gc操作的,虽然我一人不行,但是2个打1个还是打的过的)
使用方式:-XX:+UseParNewGC
设置线程数: XX:ParllGCThreads
package heihei ;
import java. util. ArrayList ;
public class test1 {
public static void main ( String [ ] args) {
ArrayList < byte [ ] > list = new ArrayList < byte [ ] > ( ) ;
while ( true ) {
byte [ ] b = new byte [ 1024 ] ;
list. add ( b) ;
try {
Thread . sleep ( 10 ) ;
} catch ( InterruptedException e) {
e. printStackTrace ( ) ;
}
}
}
}
Parallel Scavenge收集器:
1:什么是Parallel Scanvenge
又称为吞吐量优先收集器,和ParNew收集器类似,是一个新生代收集器,使用复制算法的并行多线程收集器
Parallel Scavenge是Java1.8默认的收集器,特点是并行的多线程回收,以吞吐量优先
2:特点
Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput),吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间) (虚拟机总共运行100分钟,垃圾收集时间为1分钟,那么吞吐量就是99%)
自适应调节策略,自动指定年轻代、Eden、Suvisor区的比例,虽然默认是8:1:1,一般也会自动的设置这个(在后面会说明)
3:适用场景
适合后台运算,交互不多的任务,如批量处理,订单处理,科学计算等,因为太多不好控制,自适应调节更加的麻烦(需要时间)
3:参数
(1):使用方式:-XX:+UseParallelGC(对应老年代的那个后面会说明),后面的参数一般只能是这个gc才会生效,否则相当于没有加上(加上不会报错,只是不会生效而已,一般都代表设置什么值,使用时判断是否是这个gc,使得不是这个gc的话,就算设置也不使用),可能受jdk版本影响,一般在说明gc时,后面也给出参数,那么他们就是这样的关系,就如CMS的CMSInitiatingOccupancyFraction,但并非一定哦,所以这里建议主要参考后面说明的"常用垃圾收集器参数"
(2):最大垃圾收集停顿时间:-XX:MaxGCPauseMillis:
-XX:MaxGCPauseMillis参数允许的值是一个大于0的毫秒数(默认参数是毫秒,比如-XX:MaxGCPauseMillis=100,就是100毫秒,不要加上单位哦,否则可能报错), 收集器将尽力保证内存回收花费的时间不超过用户设定值,不过 不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快, 垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:
系统把新生代调得小一些, 收集300MB新生代肯定比收集500MB快, 但这也直接导致垃圾收集发生得更频繁, 就如原来10秒收集一次、 每次停顿100毫秒, 现在变成5秒收集一次、 每次停顿70毫秒,停顿时间 的确在下降, 但吞吐量也降下来了(运行时间变成5秒了,而30毫秒对于他5秒,是可以忽略的,即吞吐量的确下降了)
(3):吞吐量大小:-XX:GCTimeRatio(案例:-XX:GCTimeRatio=20,不要不加参数哦,否则可能会报错,且要是一个整数,否则也报错,也不能超过范围,注意:这里给出了很多的限制,实际上很多参数都有限制,我们只需要按照大众需求即可,这里特别的提一下)
-XX:GCTimeRatio参数的值则应当是一个大于0小于100的整数, 也就是垃圾收集时间占总时间的比率,具体如何进行比较,看后面就行,假设GCTimeRatio的值为n,那么系统将花费不超过1/(1+n)的时间用于垃圾收集,譬如把此参数设置为19, 那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)) , 默认值为99, 即允许最大1%(即1/(1+99)) 的垃圾收集时间,当然,这个最大表示垃圾收集时间的最大,但是对于设置来说是最小的最大
(4):设置年轻代线程数:XX:ParllGCThreads
当cpu核心数小于等于8,默认与cpu核数相同,当cpu核数超过8,ParllGCThreads设置为 3+(5*CPU_COUNT)/8 (绝对大于8,因为将极端的8放入,结果是8,但是大于8的,所以大于8,如果是9,可能因为取整,还是等于8的),CPU_COUNT是cpu核心数
(5):与Parallel Scavenge收集器有关的还有一个参数:-XX:+UseAdaptiveSizePolicy(有了这个参数之后,就不要手工 指定年轻代、Eden、Suvisor区的比例,晋升老年代的对象年龄等,因为虚拟机会根据系统运行情况进行自适应调节)
package heihei ;
import java. util. ArrayList ;
public class test2 {
public static void main ( String [ ] args) {
ArrayList < byte [ ] > list = new ArrayList < byte [ ] > ( ) ;
while ( true ) {
byte [ ] b = new byte [ 1024 ] ;
list. add ( b) ;
try {
Thread . sleep ( 10 ) ;
} catch ( InterruptedException e) {
e. printStackTrace ( ) ;
}
}
}
}
前面说明的3个基本都是新生代的,现在我们来说明老年代的,虽然他们(前面和后面)在设置上是这样(也根据区域),但是实际上他们都可以操作新老,或者方法区,所以我们说明的gc是设置的这样的,因为他们是根据性能来决定设置的操作的,在前面也说明了"且一般也需要根据具体情况",而区域自然操作对应算法,而他们都有
实际上在前面我虽然说,他们都有,那是因为在没有固定的情况下,由于我们已经选择好了,java自然会让他们固定,也就是说,使得不会有其他的操作,那么按照这样的逻辑,Serial Old收集器只能操作老年代,且采用"标记-整理"算法,其他的都是如此,这里就进行补充,即补充前面说明的"先考虑他们可以都有和改变,后面会进行补充",实际上是改变说明
Serial Old收集器:
Serial Old是Serial收集器的老年代版本, 它同样是一个单线程收集器, 使用标记-整理算法,这个收集器的主要意 义也是供客户端模式下的HotSpot虚拟机使用
特点:
1:针对老年代
2:采用"标记-整理"算法
3:单线程收集
执行流程:
应用场景:主要用于Client模式
1:在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配)
2:作为 CMS收集器的后备预案 ,在并发收集发生Concurrent Mode Failure时使用
这里再次给出之前的图:
上面的CMS的备用方案是Serial Old,所以他们之间有连接,就是防止CMS不能使用的情况(一般CMS可能会突然不能使用,一般是他自身的原因吧)
参数设置:
使用方式:-XX:+UseSerialGC,设置两个的,前面操作过了,所以具体测试就不给出了
注意事项:
需要说明一下, Parallel Scavenge收集器架构中本身可能有PS MarkSweep收集器来进行老年代收集, 并非直接调用Serial Old收集器, 但是这个PS MarkSweep收集器与Serial Old的实现几乎是一样的, 所以在官方的许多资料中都是 直接以Serial Old代替PS MarkSweep进行讲解
Parallel Old收集器:
来满足之前说明的"对应老年代的那个后面会说明"
Parallel Old是Parallel Scavenge收集器的老年代版本(虽然在jdk8中是默认的), 支持多线程并发收集, 基于标记-整理算法实现,这个收集器 是直到JDK 6时才开始提供的, 在此之前, 新生代的Parallel Scavenge收集器一直处于相 当尴尬的状态, 原因是如 果新生代选择了Parallel Scavenge收集器, 老年代除了Serial Old(PS MarkSweep) 收集器以外别无选择, 其他表 现良好的老年代收集器, 如CMS无法与它配合工作
Parallel Old收集器的工作过程(前面对应的年轻代没有给出):
应用场景:
JDK1.6及之后用来代替老年代的Serial Old收集器,特别是在Server模式(对于Client模式来说是多数的),多CPU的情况下,这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge加Parallel Old收集器的"给力"应用组合
设置参数:
-XX:+UseParallelOldGC:指定使用Parallel Old收集器
package heihei ;
import java. util. ArrayList ;
public class test3 {
public static void main ( String [ ] args) {
ArrayList < byte [ ] > list = new ArrayList < byte [ ] > ( ) ;
while ( true ) {
byte [ ] b = new byte [ 1024 ] ;
list. add ( b) ;
try {
Thread . sleep ( 10 ) ;
} catch ( InterruptedException e) {
e. printStackTrace ( ) ;
}
}
}
}
CMS 收集器:
CMS垃圾回收器:
CMS(concurrent mark sweep)是以获取最短垃圾收集停顿时间为目标的收集器,CMS收集器的关注点尽可能缩短 垃圾收集时用户线程的停顿时间,停顿时间越短就越适合与用户交互的程序,目前很大一部分的java应用几种在互联网 的B/S系统服务器上,这类应用尤其注重服务器的响应速度,系统停顿时间最短,给用户带来良好的体验,CMS收 集器使用的算法是标记-清除算法实现的(并非一定移动,因为是否移动对象都存在弊端,这里就进行考虑,那么他的固定就是不移动)
CMS垃圾收集过程:
整个过程分4个步骤:
1:初始标记
2:并发标记
3:重新标记
4:并发清除
其中 初始标记 和 重新标记 都需要stopTheWorld(STW)
CMS整个过程比之前的收集器要复杂,整个过程分为4个阶段即:初始标记,并发标记 、重新标记、并发清除
1:初始标记(Initial-Mark)阶段:这个阶段程序所有的工作线程都将会因为"Stop-the-Wold"机制而出现短暂的的暂停,这个阶段的主要任务是标记GC Roots 能够关联到的对象(直接关联的,关联的关联不标记),一旦标记完成后就恢复之前被暂停的的所有应用,由于直接关联对象比较小,所以这里的操作速度非常快
2:并发标记(Concurrent-Mark)阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较 长,但是不需要暂停用户线程,用户线程可以与垃圾回收器一起运行
3:重新标记(Remark)阶段:由于并发标记阶段,程序的工作线程会和垃圾收集线程同时运行或者交叉运行, 因此,为了修正并发标记期间因为用户继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段 的停顿时间通常比初始标记阶段长一些,但也远比并发标记阶段时间短
4:清除并发(Concurrent-Sweep)阶段:此阶段清理删除掉标记判断已经死亡的对象(也就是重新标记标记的,可能清除不会考虑可达性分析算法的判断,即不考虑复活,虽然我说gc基本都考虑,但是并非一定的),并释放内存空间,由于不需 要移动存活对象,所以这个阶段可以与用户线程同时并发运行,但是也需要考虑空间不连续(不连续的空间碎片)的问题了,各有好处,但是考虑到可以并行,那么总体还是不移动比较好
由于最消耗事件的并发标记与并发清除阶段都不需要暂停工作,因为整个回收阶段是低停顿(低延迟)的,总得来说,就是将延迟长的进行并行操作,短的进行处理或者修正操作,这就是CMS的好处
并发可达性分析:
当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象 是否存活的, 可达性分析算法理论上 要求全过程都基于一个能保障一致性的快照中才能够进行分析
垃圾回收器的工作流程大体如下:
1:标记出哪些对象是存活的,哪些是垃圾(可回收)
2:进行回收(清除/复制/整理),如果有移动过对象(复制/整理),还需要更新引用
三色标记:
三色标记(Tri-color Marking)作为工具来辅助推导, 把遍历对象图过程中遇到的对象, 按照"是否访问过"这个条 件标记成以下三种颜色:
要找出存活对象,根据可达性分析,从GC Roots开始进行遍历访问,可达的则为存活对象:
我们把遍历对象图过程中遇到的对象,按"是否访问过"这个条件标记成以下三种颜色:
白色:尚未访问过
黑色:本对象已访问过,而且本对象引用到 的其他对象 也全部访问过了
灰色:本对象已访问过,但是本对象 引用到 的其他对象尚未全部访问完,全部访问后,他会转换为黑色(不包括其他对象,只是他本对象)
假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:
1:初始时,所有对象都在 【白色集合】中
2:将GC Roots 直接引用到的对象 挪到 【灰色集合】中
3:从灰色集合中获取对象:
将本对象 引用到的 其他对象 全部挪到 【灰色集合】中(不存在其他对象也没有关系,照样本身变黑),并将本对象 挪到 【黑色集合】里面
4:重复步骤3,直至【灰色集合】为空时结束
5:结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收,上图就是最终结果
注:如果标记结束后对象仍为白色,意味着已经"找不到"该对象在哪了,不可能会再被重新引用
当Stop The World (简称 STW)时,对象间的引用 是不会发生变化的,可以轻松完成标记,而当需要支持并 发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生
多标-浮动垃圾:
假设已经遍历到E(变为灰色了),此时应用执行了 objD.fieldE = null :
此刻之后,对象E/F/G是"应该"被回收的,然而因为E已经变为灰色了,其仍会被当作存活对象继续遍历下去
最终的结果是:这部分对象仍会被标记为存活,即本轮GC不会回收这部分内存
这部分本应该回收 但是 没有回收到的内存,被称之为"浮动垃圾",浮动垃圾并不会影响应用程序的正确性,只是 需要等到下一轮垃圾回收中才被清除,因为当我们清除白色,下次会继续放入白色
漏标:
假设GC线程已经遍历到E(变为灰色了),此时应用线程先执行了:
var G = objE. fieldG;
objE. fieldG = null ;
objD. fieldG = G ;
此时切回GC线程继续跑,因为E已经没有对G的引用了,所以不会将G放到灰色集合,尽管因为D重新引用了G,但 因为D已经是黑色了,不会再重新做遍历处理(一路过去,自然黑色不处理了),最终导致的结果是:G会一直停留在白色集合中,最后被当作垃圾 进行清除,这直接影响到了应用程序的正确性,是不可接受的,因为g应该是不能被回收的,而他回收了我们操作的引用指向了
不难分析,漏标只有同时满足以下两个条件时才会发生:
条件一:灰色对象 断开了 白色对象的引用,即灰色对象 原来成员变量的引用 发生了变化
条件二:黑色对象 重新引用了 该白色对象,即黑色对象 成员变量增加了 新的 引用对象
从代码的角度看:
var G = objE. fieldG;
objE. fieldG = null ;
objD. fieldG = G ;
1:读取 对象E的成员变量fieldG的引用值,即对象G
2:对象E 往其成员变量fieldG,写入 null值
3:对象D 往其成员变量fieldG,写入 对象G
我们只要在上面这三步中的任意一步中做一些"手脚",将对象G记录起来,然后作为灰色对象再进行遍历即可,比 如放到一个特定的集合,等初始的GC Roots遍历完(并发标记),该集合的对象 遍历即可(对他考虑重新标记,使得移除白色,也就是从G开始并发标记,但他是灰色的开始,而并发标记就是给出初始标记对应对象后面的流程并顺便判断三色,反正是一路的),所以我们才说重写标记是修正,最后操作白色清除(是最后哦)
重新标记是需要STW的,因为应用程序一直在跑的话,该集合可能会一直增加新的对象(而初始标记也是防止增加起始指向,所以也需要STW),导致永远都跑不完,当然,并发标记期间也可以将该集合中的大部分先跑了,从而缩短重新标记STW的时间,这个是优化问 题了
CMS收集器三个缺点:
1:CMS收集器对CPU资源非常敏感
其实,面向并发设计的程序都对CPU资源比较敏感,在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用 了一部分线程而导致应用程序变慢,总吞吐量会降低,CMS默认启动的回收线程数是(处理器核心数量 +3) /4, 也就是说, 如果处理器核心数在四个或以上, 并发回收时垃圾收集线程一般(越大)只占用不超过25%的 处理器运算资源, 并 且会随着处理器核心数量的增加而下降,但是当处理器核心数量不足四个时, CMS对用户程序的影响就可能变得 很大,如果应用本来的处理器负载就很高, 还要分出一半的运算能 力去执行收集器线程, 就可能导致用户程序的 执行速度忽然大幅降低
2:CMS收集器无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败而导致(另)一次Full GC的产生,前面也说明了"他的触发可能需要Major GC的某些条件",这里就是(即整理后可能会再次的操作这个Full GC,因为老年代满也是会操作触发的,前面说明的"这样要注意哦,一般是考虑老年代空间大,需要多个GC吧,当然gc优先是Major GC,然后这个,除非你自己手动操作")
由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在 标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉,这一部分垃圾就称为"浮动垃圾",同样也是由于在垃圾收集阶段用户线程还需要持续运 行, 那就还需要预留足够内存空间提供给用户线程使 用, 因此CMS收集器不能像其他收集器那样等待 到老年代几乎完全被填满了再进行收集, 必须预留一部分空间供 并发收集时的程序运作使用,否则可能会影响用户线程的操作,使得内存溢出
在JDK 5的默认设置下, CMS收集器当老年代使用了68%的空间后就会被激活, 这是一个偏保守的设置,如果在实际应用中,老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高CMS的触发 百分比(设置案例:-XX:CMSInitiatingOccupancyFraction=80,表示80%,不要加其他参数哦,否则会报错的), 降低内存回收频率, 获取更好的性能
到了JDK 6时, CMS收集器的启动 阈值就已经默认提升至92%,但这又会更容易面临另一种风险: 要是CMS运行期间预留的内存无法满 足程序分配新对象的需要(运行期间,因为是有并行的,那么可能他使得报错并结束(但不会操作如类似的关闭虚拟机的exit的操作,虽然内存溢出可能是这样),而不会是等gc后出现内存溢出,而是gc中出现错误), 就会出现一次"并发失败"(Concurrent Mode Failure),在内存溢出之前操作了,自然是这个错误 , 这时候虚拟机将不 得不启动后备预案: 冻结用户线程的执行, 临时 启用Serial Old收集器来重新进行老年代的垃圾收集, 但这样停顿时间就很长了(但可以整理),这就是之前说的"防止CMS不能使用的情况"(保存不使用了,这个时候我总不能继续操作CMS使得重新开始吧,这样又会容易出现并发失败,所以这个时候直接考虑清除)
3:空间碎片:CMS是一款基于标记-清除算法实现的收集器,所有会有空间碎片的现象
当空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大 的连续空间来分配当前对象,不得不触发一次Full GC,也就是老年代满了触发
为了解决这个问题, CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认是开启的,此参数从 JDK 9开始废弃,一般没有给出案例,说明直接就这样写) , 用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程, 由于这个 内存 整理必须移动存活对象, 是无法并发的,这样空间碎片问题是解 决了, 但停顿时间又会变长, 因此虚拟机设计者 们还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction(此参数从JDK 9开始废弃,案例:-XX:CMSFullGCsBeforeCompaction=2) , 这个参数的作用是要 求CMS收集器在执行过,并执行过程中若干次(数量 由参数值决定) 不整理空间的Full GC之后(当然没有操作整理的), 下一次进入Full GC前会先进行碎 片整理(默认值为0, 表 示每次进入Full GC时(前)都进行碎片整理,自然费时间,因为需要等待(该gc也包括STW哦,一般我们说明的STW是gc导致的,其他只是改gc线程需要等待的时间而已,如果也再STW里面,那么自然等待时间会使得STW总体变长的,比如这里,当然,有些停顿是STW导致,有些不是,再对应的解释中已经可以进行联系起来,具体就不多说了,这里只是提一下),所以这里是一个优化的地方,我们最好设置几次,而不是默认,这也是为什么在jdk9中废弃的原因,上面再jdk9开始,都没有这些参数,所以若你的jdk是9以及以上,那么设置时,运行会报错)
G1收集器:
G1垃圾收集器简介:
Garbage First是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征
G1收集器特点:
1:G1把内存划分为多个独立的区域Region
2:G1仍然保留分代思想,保留了新生代和老年代,但他们不再是物理方面的隔离说明,而是一部分Region的集合,比如他们各自内存物理上没有分开,但是逻辑上认为是一部分的Region的集合,但还是逻辑上分开的
3:G1能够充分利用多CPU、多核环境硬件优势,尽量缩短STW
4:G1整体整体采用标记整理算法,局部是采用复制算法,不会产生内存碎片
5:G1的停顿可预测,能够明确指定在一个时间段内,消耗在垃圾收集上的时间不超过设置时间
6:G1跟踪各个Region里面垃圾的价值大小,会维护一个优先列表,每次根据允许的时间来回收价值最大的区域,从而保证在有限事件内高效的收集垃圾
Region区域:
G1不再坚持固定大小以及固定数量的 分代区域划分, 而是把连续的Java堆划分为多个独立区域(Region) , 每一 个Region都可以 根据需要, 扮演新生代的Eden空间、 Survivor空间, 或者老年代空间
将整个堆空间细分为若干个小的区域,但要注意也是逻辑上的划分的,只是G1,可以通过对应的引用来选择间接的操作gc,但是可能也会由于设置的原因jvm会改变堆结构,但我感觉不会,这里可以选择百度查看
使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实 际大小而定,比如Region块大小是为2的N次幂的堆大小,即1MB, 2MB, 4MB, 8MB, 16MB,32MB,一般是1mb最小,32mb最大,可能最大可以更大,具体可以百度,我们这些乘以2048可能就是堆大小了(一般堆空间可能会与他对应而调整,向下调整)
虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合,通过Region的动态分配方式实现逻辑上的连续
G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块,主要用于存储大对象,如果超过1.5个regions(Region),就放到H,一般被视为老年代,而中间的1.1等等,多余1的可能会放在S里面
G1 GC过程:
G1提供了两种GC模式,Young GC和Mixed GC,两种均是完全Stop The World的
Young GC:选定所有年轻代里的Region,通过控制年轻代的region(Region)个数,即年轻代内存大小,来控制young GC的时间开销
Mixed GC:选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region(这就是为什么之前说明了"可能有些gc操作部分老年代"的原因),在用户指定的开销目标范围内尽可能选择收益高的老年代Region
在G1 GC垃圾回收的过程一个有四个阶段:
初始标记:和CMS一样只标记GC Roots直接关联的对象
并发标记:进行GC Roots Traceing过程
最终标记:修正并发标记期间,因程序运行导致发生变化的那一部分对象
筛选回收:根据时间来进行价值最大化收集
下面是G1收集的示意图:
Safwpoint:英文意思:安全点
G1 YoungGC:
YoungGC执行前:
堆分为大约2000个区域,最小大小为1Mb,最大大小为32Mb,蓝色区域保存老年代对象,绿色区域保存年轻对象
执行YoungGC:
将存活的对象(即复制或移动)到一个或多个幸存者区域,如果满足老化阈值,则某些对象将被提升到老年代区域
G1的年轻GC结束:
最近升级的对象以深蓝色显示,幸存者区域为绿色
总而言之,关于G1的年轻一代,可以说以下几点:
1:堆是单个内存空间,分为多个区域
2:年轻代内存由一组非连续区域组成
3:年轻一代的垃圾收集器或年轻的GC出现STW,将停止所有应用程序线程以进行操作
4:年轻的GC使用多个线程并行完成
5:将活动对象复制到新的幸存者或老年代的地区
G1 Mix GC:
初始标记阶段(initial mark,STW):
存活的对象的初始标记背负在年轻的垃圾收集器上,在日志中,此标记为 GC pause (young)(inital-mark)
并发标记阶段(Concurrent Marking):
如果找到空白区域(如"X"所示),则在Remark阶段将其立即删除,另外,计算确定活跃度的信息
最终标记阶段(Remark,STW):
空区域将被删除并回收,现在可以计算所有区域的区域活跃度
筛选回收阶段/复制清理阶段(Cleanup,STW):
G1选择"活度"最低的区域,这些区域可以被最快地收集,然后与年轻的GC同时收集这些区域,这在日志中表示为[GC pause (mixed)] ,因此,年轻代和老年代都是同时收集的(前面的只是操作年轻代)
筛选回收阶段-(复制/清理)阶段之后:
选定的区域已被收集并压缩为图中所示的深蓝色区域和深绿色区域
总结:
并发标记阶段:
1:活动信息是在应用程序运行时同时计算的(上面一块是可以包含很多的Region,所以操作后才会是少点的包含,且Region可以代表扮演新生代的Eden空间、 Survivor空间, 或者老年代空间等等,虽然上面有颜色区分)
2:该活动信息标识在疏散暂停期间最适合回收的区域
3:像CMS中没有清扫阶段
最终标记阶段:
1:使用开始快照(SATB)算法,该算法比CMS使用的算法快得多
2:完全回收空区域
筛选回收阶段:
1:同时回收年轻一代和老一代
2:老年代地区是根据其活跃度来选择的
上面只是大致说明,了解即可
G1会使用到的常用参数(并不一定代表是属于操作G1的,因为他自己也是被设置的"-XX:+UseG1GC"):
参数/默认值 含义 -XX:+UseG1GC 使用 G1 垃圾收集器(他直接操作,就他一人,没有什么新老) -XX:MaxGCPauseMillis=200 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保 证达到) -XX:InitiatingHeapOccupancyPercent=45(代表45%) mixed gc中也有一个阈值参数 ,当老年代大小占整个堆大小百分比达到该阈值时,会触发一次mixed gc,默认值为 45 -XX:NewRatio=2 新生代与老生代(new/old generation)的大小比例(Ratio),默认值为 2 -XX:SurvivorRatio=8 eden/survivor 空间大小的比例(Ratio),默认值为 8 -XX:MaxTenuringThreshold=15 提升年老代的最大临界值(tenuring threshold),默认值为 15 -XX:ParallelGCThreads=n(n是未知数,不能加,否则报错,可以设置为4) 设置垃圾收集器在并行阶段使用的线程数,默认值随JVM运行的平台不同而不同 -XX:ConcGCThreads=n(n是未知数,不能加,否则报错,可以设置为4) 并发垃圾收集器使用的线程数量,默认值随JVM运行的平台不同而 不同 -XX:G1ReservePercent=10 设置堆内存保留为假天花板的总量,以降低提升失败的可能性,默认值是 10 -XX:G1HeapRegionSize=1m(默认字节单位,可以加单位来改变,如k,与前面的单位一样的可以加哦,也就是有单位使用单位的,否则默认字节单位,虽然有些地方说明不能加单位) 使用G1时Java堆会被分为大小统一的的区(region),此参数可以指定每个heap(堆)区的大小,默认值将根据 heap size 算出最优解,最小值为1Mb, 最大值为 32Mb
上面的了解即可
常用指令与可视化调优工具:
常用指令(了解即可):
jps:
jps 是(java process Status Tool), Java版的ps命令,查看java进程及其相关的信息,如果你想找到一个java进程的pid,那可以用jps命令替代linux中的ps命令了,简单而方便
命令格式:jps [options] [hostid](带有[]的可以不加,后面其他命令也基本是这样的认为)
options参数解释(显示的进程id一般是对应的所有相关java进程id,所以可以看到显示多个):
-l:显示进程id,后面接着显示主类全名或jar路径
-q:显示进程id,这个与单纯的jps不同的是,单纯的jps会出现类名,而他只出现进程id
-m:显示进程id,后面接着显示JVM启动时传递给main()的参数
-v:显示进程id,后面接着显示jvm启动时显示指定的JVM参数
hostid:主机或其他服务器ip,具体作用可以百度,通常是jvm的外部吧
最常用示例:
jps - l
jps - m
jps - v
参考代码:
package coo ;
public class Demo1_Jps {
public static void main ( String [ ] args) throws InterruptedException {
Thread . sleep ( 1000000 ) ;
}
}
我们启动上面的代码时,可能jps -m在对应的进程后面的类里面没有什么值,也就是说,没有main对应参数,但是args是存在的啊,这里我们打印一下:
package coo ;
public class Demo1_Jps {
public static void main ( String [ ] args) throws InterruptedException {
System . out. println ( args. length) ;
for ( int i = 0 ; i < args. length; i++ ) {
System . out. println ( args[ i] + "第" + i + "个" ) ;
}
Thread . sleep ( 1000000 ) ;
}
}
也就是说,对应的显示是o,我们需要在设置里操作如下(也就是main方法的参数设置值):
继续运行,执行jps -m,可以看到hhhh了,通过测试,对应的长度是1,当你设置hhhh hhhh(中间有空格),那么长度是2,在命令行cmd中,我们手动操作运行时,我们需要在如java xx.java k k或者java xx k k,这两个k都是,即长度为2
而jps -v后面就是我们之前的VM options的设置(也就是jvm参数,虽然有隐藏的信息),你可以加上-XX:+UseG1GC来看看结果就知道了(当然,他有很多值可以显示的,包括-XX:+PrintCommandLineFlags,但确不会给出隐藏的对应程序运行-XX:+PrintCommandLineFlags设置的得到的显示的其他信息,而是某些路径信息或者其他信息,具体可以自己操作看看),当然,jps对应操作的至少要成功运行,而不是没有运行,所以jps -v出现的必然是正确的配置哦
jinfo:
jinfo是用来查看JVM参数和动态修改部分JVM参数的命令
命令格式:
jinfo [option] < pid>(<>代表必须要加)
options参数解释:
no options(没有该操作,也就是jinfo pid,如jinfo 10000) :输出所有的系统属性和参数
-flag < name>:打印指定名称的参数
-flag [+|-] < name>:打开或关闭参数
-flag < name>=< name>:设置参数
-flags:打印所有参数(自然包括jvm的参数,包括隐藏的信息)
-sysprops:打印系统配置
代码还是之前的代码,这里你可以自己测试,记得加上进程号(id)哦(否则虽然不会报错,但是会有提示(虽然报错也是提示,或者说异常也是提示),一般是提示格式问题)
案例:
假设你的运行的代码的进程id是10000(注意,我们将他也认为是进程id,因为是一个程序的)
jinfo - flag PrintGCDetails 10000
jinfo - flag + PrintGCDetails 10000
由于一些基本的使用,我们不能直接操作,所以下面的命令主要查看那些参数可以使用jinfo命令来操作对应的其他管理:
Xms:初始堆大小,默认为物理内存的1/64(<1GB),默认(MinHeapFreeRatio参数可以调整该值,具体显示可能是0)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制
-Xmx:最大堆大小,默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制
-Xmn:新生代的内存空间大小(前面没有说明,这里注意一下),注意:此处的大小是(eden+ 2 survivor space),与jmap -heap中显示的New gen是不同的,整个堆大小=新生代大小 + 老生代大小 + 永久代大小
在保证堆大小不变的情况下,增大新生代后,将会减小老生代大小(对应的新老比值一般也会改变),此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8
-XX:SurvivorRatio:新生代中Eden区域与Survivor区域的容量比值,默认值为8,两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10
-Xss:每个线程的堆栈大小(虚拟机栈),JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K,应根据应用的线程所需内存大小进行适当调整,在相同物理内存下,减小这个值能生成更多的线程,但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,根据经验来说,值在3000~5000左右,一般小的应用, 如果栈不是很深, 应该是128k够用的,大的应用建议使用256k,这个选项对性能影响比较大,需要严格的测试,和threadstacksize选项解释很类似,但是官方文档似乎没有解释
在论坛中有这样一句话:“-Xss is translated
in a VM flag named ThreadStackSize”,一般设置上面说明的值就可以了
-XX:PermSize:设置永久代(perm gen)初始值,默认值看前面的说明
-XX:MaxPermSize:设置持久代最大值,默认值看前面的说明
上面只是大致的给出而已,也并非都可以操作,可能也看jdk版本
jstat:
jstat命令是使用频率比较高的命令,主要用来查看JVM运行时的状态信息,包括内存状态、垃圾回收等
命令格式:
jstat [option] VMID [interval] [count],注意:一般没有jstat pid的操作
其中VMID是进程id,interval是打印间隔时间(毫秒),count是打印次数(不加的话,那么默认一直打印,注意:这个操作需要interval来触发),如果interval不加,那么自然就只有如类似的jstat -class 139368,那么就打印一次,且没有间隔,而加了不设置打印次数,那么打印无限次,若设置了打印次数,那么就是打印设置的次数,还要注意的是:间隔不会影响一开始的第一个打印,也就是说,如果是5000,那么打印后,才会进行等待5秒,你仍然可以操作jstat -class 139368 5000(139368是pid,即进程id)来进行测试
option参数解释(通常情况下,对于大小的记录都是字节保存的,这里注意即可,下面操作-gccapacity所出现的结果可能就是这样,但我们可是通常情况下哦,在这篇博客中,都可以认为是这样的):
-class:class loader的行为统计
-compiler:HotSpt JIT编译器行为统计
-gc:垃圾回收堆的行为统计
-gccapacity:各个垃圾回收代容量(young,old,perm)和他们相应的空间统计
-gcutil:垃圾回收统计概述
-gccause:垃圾收集统计概述(同-gcutil),附加最近两次垃圾回收事件的原因
-gcnew:新生代行为统计
-gcnewcapacity:新生代与其相应的内存空间的统计
-gcold:年老代和永生代行为统计
-gcoldcapacity:年老代行为统计
-printcompilation:HotSpot编译方法统计
常用示例及打印字段的解释:
jstat -gcutil 11666 1000 3
11666假设为pid,每隔1000毫秒打印一次,打印3次
可能的结果如下(有两个不显示的不给说明了):
字段解释:
S0:survivor0使用百分比
S1:survivor1使用百分比
E:Eden区使用百分比
O:老年代使用百分比
M:元数据区使用百分比
CCS:压缩使用百分比
YGC:年轻代垃圾回收次数
YGCT:年轻代垃圾回收消耗时间
FGC:Full GC垃圾回收次数
FGCT:Full GC垃圾回收消耗时间
GCT:垃圾回收消耗总时间
jstat -gc 11666 1000 3
-gc和-gcutil参数类似,只不过输出字段不是百分比,而是实际的值(有两个不显示的不给说明了),可能的结果如下:
字段解释:
S0C:survivor0大小
S1C:survivor1大小
S0U:survivor0已使用大小
S1U:survivor1已使用大小
EC:Eden区大小
EU:Eden区已使用大小
OC:老年代大小
OU:老年代已使用大小
MC:方法区大小
MU:方法区已使用大小
CCSC:压缩类空间大小
CCSU:压缩类空间已使用大小
YGC:年轻代垃圾回收次数
YGCT:年轻代垃圾回收消耗时间
FGC:Full GC垃圾回收次数
FGCT:Full GC垃圾回收消耗时间
GCT:垃圾回收消耗总时间
当然,只要知道对应的意思即可,并不需要背下来,因为这并无意义(除非你有强迫症,手动滑稽,🤭),在上面给出的案例(示例)是大多数会用到的,包括前面的jps和jinfo
现在我们根据代码来进行操作,首先我们需要学习一下System.in.read()这个代码:
package coo ;
import java. io. IOException ;
public class tets2 {
public static void main ( String [ ] args) throws IOException {
int read = System . in. read ( ) ;
System . out. println ( read) ;
while ( true ) {
int read1 = System . in. read ( ) ;
System . out. println ( read1) ;
break ;
}
}
}
我们了解后,看这个代码:
package coo ;
import java. io. IOException ;
public class test {
public static void main ( String [ ] args) throws IOException {
final int _1m = 1024 * 1024 ;
byte [ ] b1 = new byte [ 2 * _1m] ;
System . out. println ( "创建b1..." ) ;
System . in. read ( ) ;
byte [ ] b2 = new byte [ 2 * _1m] ;
System . out. println ( "创建b2..." ) ;
System . in. read ( ) ;
byte [ ] b3 = new byte [ 2 * _1m] ;
System . out. println ( "创建b3..." ) ;
System . in. read ( ) ;
}
}
现在我们运行,操作jstat -gc pid来看看结果,可能(代表假设的或者自己的)的结果如下(新生代一般有值的,因为你jvm启动自然会利用到,一般来说System.in.read();比普通的打印会多点,可能jvm在识别执行他时,会初始化比较多的对象):
回车,继续打印:
可以看到刚好是2048(5049.2到7097.2),也就是,2 * 1024 * 1024,分开来分析就是:1024字节 = 1k*1024 = 1024k=1m * 2=2m,正好是1024 * 1024 *2 = 2m,所以他们的单位是k,eden默认8m,因为前面设置了新生代为10m(-Xmn10m),而对比,那么eden自然是8m,即都对应起来了
再次的回车,可以看程序的打印日志:
操作了gc,因为eden满了,当然他的操作可能并不是最终结果(因为是中间出现的),所以可能与下面的最终结果不符合
然后继续打印(控制台):
很明显,他并没有回收,因为对应的都是存在的,那么在这个时候,由于年轻代是不能放入幸存者(这里是一个特殊情况,因为都存在(幸存),即都没有回收,幸存者放不下的,比例在此,自然绝对放不下),前面我们也说过条件没满,eden和幸存者都满,是可以有特殊的规则到老年代的,而这里也是一个特殊情况,我可能会将当前的进行划分,来分到幸存者和老年代里面(或者都放入老年代,之所以是或者,是因为幸存者没有时,一般会保留一点到幸存者,而若幸存者有,那么都放入老年代(即eden和幸存者都放入)),虽然他们也操作了方法区,解释如下:981.0+2192.7+4096.0-16+2999.5+138+342.9=10734.1(新生代,老年代,和方法区变化的总空间),可能规则导致其他信息出现,981.0+2192.7+4096=7269.7(幸存者,eden,老年代操作的空间),10734.1-7269.7=3 464.4(方法区变化大小),若不考虑原来的增加的2048,那么中间多了1416.4,即规则导致创建的方法区空间,因为7097.2+2048=9145.2>7269.7,所以规则导致增加了方法区空间,甚至也影响了大小(可能的操作设置),且中间操作使得放入方法区了,可能以常量池来进行保存了(堆数据了,那么他们的数据变化的可能是符号引用导致的,所以是7269.7,而不是9145.2,而符号引用,需要真的保存才可(既然是引用,必然需要保存信息吧,即常量池是保存引用的信息的哦,虽然前面并没有具体说明),即真的在常量池中,使得方法区变多)
上面只是我的猜测,具体你可以到百度查看
最后注意:java中的不是端口,而是进程ip,一般来说,Linux 内核的进程 PID 最大值并非 131070,而是 32768 (32 位系统)和 2 的 22 次方(也就是4194304,64 位系统),所以一般我们java通常的进程id上限也是4194304,可能windows也是这样,一般PID从0开始,包括0,即是大于等于0的整数,上面说明的就是上限
一个进程里面可以有子进程(线程),实际上一个进程只是一个线程组,所以进程id也是线程组id,即该组的位置,而线程id是该线程的标识,虽然他们都分配PID(即这就是有些PID的关闭,可能会导致多个PID关闭的原因,当然,主线程可不是进程,虽然他java对他可能操作某些绑定,即操作守护线程等等,具体可以到101章博客查看),只是进程需要开辟线程组的内存使用,所以进程只是一个逻辑说明而已,就如端口一样
注意:对于线程来说,可能PID的显示是TID(所以都分配PID),所以看到TID要明白是线程哦
jstack:
jstack是用来查看JVM线程快照的命令,线程快照是当前JVM线程正在执行的方法堆栈集合,使用jstack命令可以定位线程出现长时间卡顿的原因,例如死锁,死循环等,jstack还可以查看程序崩溃时生成的core文件中的stack(英文意思:堆栈)信息(该信息通常表示代码的第几行的信息,因为堆栈:虚拟机栈,而该栈,就表示存在栈帧,而栈帧就表示方法,那么自然可以表示方法的位置,而由于栈帧里面又有其他信息,那么总体来说,堆栈就能够表示栈里面的任何信息了,因为就算是main也是虚拟机栈里面的)
命令格式:
jstack [options] < pid>
option参数解释:
-F:当使用jstack < pid>(存在该操作)无响应时,强制输出线程堆栈
-m:同时输出java堆栈和c/c++堆栈信息(混合模式)
上面两个可能操作不了(实际上是执行了,因为并不是格式错误,但是确不操作,或者忽略了,导致出现需要进行解决,一般都是提示:Use jhsdb jstack instead),除了上面说明的"存在该操作这个",具体使用方式可以百度,反正我们主要使用下面的 " -l " 来进行操作,所以这里跳过即可
-l:除了输出堆栈信息外,还显示关于锁的附加信息
上面的操作我们在后面死锁中会测试一下,你实际上也可以通过测试System.in.read();,使用-l时,可以看到一些信息,即调用信息,自己可以看看,这里了解即可
cpu占用过高问题:
1:使用Process Explorer工具找到cpu占用率较高的线程
2:在thread卡中找到cpu占用高的线程id
3:线程id转换成16进制
4:使用jstack -l < pid>查看进程的线程快照(一般我们也能手动找到,但是若在代码非常多的情况下,我们可以通过全局来进行查找)
5:线程快照中找到指定线程,并分析代码
为了测试,给出代码:
package coo ;
public class test3 {
public static void main ( String [ ] args) {
System . out. println ( 1 ) ;
while ( true ) ;
}
}
启动上面的代码,现在我们给出Process Explorer工具,工具下载地址如下:
链接:https://pan.baidu.com/s/1FvrkcQKf-LegiP3KVctc9Q
提取码:alsk
点击即可,先同意,一般他会自动退出窗口,然后再次的点击就行,然后找到如下:
上代码启动的是30040,这里正好也找到了,实际上在任务管理器的详细信息里也可以找到,只是没有这么方便的找到,需要你慢慢的翻找,如:
点击上面选项中的详细信息:
点击上面选项中的进程(任务管理器进入,默认是这个(的点击)):
可以看到,虽然有这样的从属关系,但是对应的显示并没有PID,而这个工具可以直接的操作从属并给出PID的值,这就是有时候工具的好处(虽然其他信息没有直接显示了(一般需要双击他),可能有些少些(如电源使用情况),但是对于PID的查找来说,他是有优势的)
当然,工具之所以是工具,除了可以把表示的显示外,最主要的是可以有特殊的操作更快的找到或者进行操作,这是原来的任务管理器所没有的操作,比如选项中有查找功能,可以选择输入java.exe或者进程id,来进行查找,然后点击查找的会自动帮你定位的,这也是一个好处,且并非要java.exe,直接输入java,相关的基本都会帮你找出,然后自己可以选择定位即可,虽然一般帮你定位到最上层
当然,这并非是主要的,最主要的操作是如下:
现在我们双击他(也就是查看该应用程序运行的情况,或者说该main方法运行的情况,一般来说,一个main方法代表一个应用程序),点击Threads即可:
TID代表线程id,我们可以看到最大CPU占用是151796,实际上在对应关系中,线程一般喜欢与16进制的id进行关联(用NID表示,看下面的图就知道了,虽然下面的TID可能是操作的线程的另外一种表示),而151796(10进制)的16进制是250f4,现在,我们回到idea的控制台中,输入jstack -l 30040,在对应的打印结果中可以看到如下:
可以看到250f4,而我们的第9行就是阻塞的地方(while (true);,一直运行,会一直拿取CPU操作,因为线程需要使用CPU操作,任何操作都会需要(因为判断,和往后面执行都算),内存也是,虽然他并不会一直操作内存,即内存基本不变,但是CPU会一直使用,在回收和使用中达到CPU平衡),也就是CPU占用过高的地方
通过上面我们可以明白,他这个工具可以知道CPU占用过高的进程id,以及对应的线程id,以及其他信息(要注意:只有与网络有关的进程才需要占用端口号,因为端口只是操作网络的而已,对于内部的其他操作,进程并非一定操作端口哦,反正是线程组,所以进程id可以比端口多的)
而我们可以通过他,然后再控制台中输出命令,来精确的找到对应第几行代码,虽然命令本身不需要通过它(但是若在代码非常多的情况下,我们可以通过全局来进行查找,那么就非常重要了),那么这个工具的主要作用就是找到对应进程id或者线程id的相关信息了,以及在代码多的情况下来精确对应的信息(这个更主要)
全局查找操作:
通过上面分析,这就是最主要的操作,所以工具的确是非常好的,当一个应用程序发现占用的cpu资源比较高时,可以通过这个工具,找到线程id,从而全局找信息,然后进行分析,这是该工具的主要作用,并不是主要看PID的信息哦
jstack检查死锁问题:
先给出案例:
package coo ;
public class DeadLock {
private static Object obj1 = new Object ( ) ;
private static Object obj2 = new Object ( ) ;
public static void main ( String [ ] args) {
new Thread ( new Thread1 ( ) ) . start ( ) ;
new Thread ( new Thread2 ( ) ) . start ( ) ;
}
private static class Thread1 implements Runnable {
public void run ( ) {
synchronized ( obj1) {
System . out. println ( "Thread1 拿到了 obj1 的锁!" ) ;
try {
Thread . sleep ( 2000 ) ;
} catch ( InterruptedException e) {
e. printStackTrace ( ) ;
}
synchronized ( obj2) {
System . out. println ( "Thread1 拿到了 obj2 的锁!" ) ;
}
}
}
}
private static class Thread2 implements Runnable {
public void run ( ) {
synchronized ( obj2) {
System . out. println ( "Thread2 拿到了 obj2 的锁!" ) ;
try {
Thread . sleep ( 2000 ) ;
} catch ( InterruptedException e) {
e. printStackTrace ( ) ;
}
synchronized ( obj1) {
System . out. println ( "Thread2 拿到了 obj1 的锁!" ) ;
}
}
}
}
}
然后执行指令:
jstack -l 11666
一般情况下,可以在打印结果后面看到Found 1 deadlock的信息,也就是"找到1个死锁"的意思,阻塞可没有操作很多CPU哦,因为并没有往后面持续执行,虽然可能有少部分的自旋,具体信息如下(我这里是这样,当然,由于进程id或者线程id的分配基本随机(基本是这样的),即不同,那么你的执行可能与下面有部分差别):
可以看到,信息基本可以确定是死锁了,在后面就是Found 1 deadlock的信息,且到头了
jmap:
jmap可以生成 java 程序的 dump 文件, 也可以查看堆内对象示例的统计信息、查看 ClassLoader 的信息以及finalizer 队列
命令格式:
jmap [option] < pid>
option参数解释:
如果使用不带(option)选项参数的jmap打印共享对象映射,将会打印目标虚拟机中加载的每个共享对象的起始 地址、映射大小以及共享对象文件的路径全称
-heap:打印java heap摘要
-histo[:live]:打印堆中的java对象统计信息
上面三个(包括不带的)可能执行不了(也是提示Use jhsdb jstack instead(不带的除外,一般背抛弃了,因为他提示了格式错误)),具体可以百度
-clstats:打印类加载器统计信息
-finalizerinfo:打印在f-queue中等待执行finalizer方法(不要陌生了,给你一段在前面的话"Finalizer线程去执行它们的finalize() 方法")的对象(这个执行了,也就是No instances waiting for finalization found:找不到等待完成的实例,只不过没有而已)
-dump:< dump-options>:生成java堆的dump文件:
< dump-options>:
< dump-options>的替换:
live:只转储存活的对象,如果没有指定则转储所有对象
format=b:二进制格式
file=< file>:转储文件到< file>,这个必须有,上面两个可以不加
常用示例:
jmap -dump:live,format=b,file=dump.bin 11666(也可以是d:/dump.bin,这样写就是当前目录下,正反斜杠都行的,主要是文件系统可以这样,一般来说,斜杠的操作是对方是否可以识别的问题)
11666是进程,若在控制台的一个目录下进行操作,那么dump.bin就创建在那个目录下面,其中上面的",",代表分开的意思,可以补充,但是file=< file>必须存在,否则格式不对
若出现Heap dump file created,代表操作成功,若出现File exists代表存在,并没有覆盖哦,其他的基本是格式问题的提示了
上面这个命令是要把java堆中的存活对象信息转储到dump.bin文件
当然了,上面的你可以自己测试,但要注意的是:如果你不知道打印的信息是什么意思,你可以选择百度,因为信息这么多,虽然我们可以根据英文来大致的了解,但是并不是一定正确的
jhat:
jhat是用来分析jmap生成dump文件的命令,jhat内置了应用服务器,可以通过网页查看dump文件分析结果,jhat一般是用在离线分析上
命令格式:
jhat [option] [dumpfile]
option参数解释:
-stack false:关闭对象分配调用堆栈的跟踪
-refs false:关闭对象引用的跟踪
-port < port>:HTTP服务器端口,默认是7000
-debug < int>:debug级别
-version:分析报告版本
常用实例:
jhat dump.bin,可以这样的,直接分析,但是jhat可能操作不了,具体可以百度,如果可以操作,我们通过网页,输入localhost:7000即可观察了,这里了解即可
最后,像有些操作不了的,并不需要死磕,因为并无意义,就如在linux中,你会将所有的命令及其作用都背下来吗,很显然不会,就算你会,那么如果以后也添加一些命令,你岂不是又要背,这种是没有意义的
JVM常用工具:
Jconsole 监控管理工具:
Jconsole(Java Monitoring and Management Console)是从java5开始,在JDK中自带的java监控和管理控制台,用于对JVM中内存,线程和类等的监控,是一个基于JMX(java management extensions)的GUI性能监测工具,jconsole使用jvm的扩展机制获取并展示虚拟机中运行的应用程序的性能和资源消耗等信息
直接在jdk版本里面的bin目录下点击jconsole.exe即可启动,这里以jdk8为主,你也可以在idea的控制台输入jconsole也可以启动
内存监控:
先给出测试代码:
package test ;
import java. util. ArrayList ;
import java. util. List ;
public class JConsoleDemo {
static class OOMObject {
public byte [ ] placeholder = new byte [ 8 * 1024 ] ;
}
public static void fillHeap ( int num) throws InterruptedException {
List < OOMObject > list = new ArrayList < OOMObject > ( ) ;
for ( int i = 0 ; i < num; i++ ) {
Thread . sleep ( 200 ) ;
list. add ( new OOMObject ( ) ) ;
}
System . gc ( ) ;
}
public static void main ( String [ ] args) throws Exception {
fillHeap ( 1000000 ) ;
System . gc ( ) ;
}
}
运行,控制台输入jconsole,到如下:
点击上面,可以看到他是我们启动的进程,也就是我们启动的程序,然后点击界面的连接:
上面的图每次到都会刷新
然后点击不安全连接,开始连接,注意,这个时候若程序结束了,那么会连接失败的
然后你可以看到如下:
他们的变化,就是程序运行使得的变化,这也就是监控,当然,其他的选项可以自己观察,这里只需要知道什么东西就行了
线程监控:
查看CPU使用率及活锁阻塞线程
代码准备:
package test ;
import java. io. BufferedReader ;
import java. io. InputStreamReader ;
public class test4 {
public static void createBusyThread ( ) {
Thread thread = new Thread ( new Runnable ( ) {
public void run ( ) {
while ( true ) ;
}
} , "testBusyThread" ) ;
System . out. println ( "启动testBusyThread 线程完毕.." ) ;
thread. start ( ) ;
}
public static void createLockThread ( final Object lock) {
Thread thread = new Thread ( new Runnable ( ) {
public void run ( ) {
synchronized ( lock) {
try {
lock. wait ( ) ;
} catch ( InterruptedException e) {
e. printStackTrace ( ) ;
}
}
}
} , "testLockThread" ) ;
thread. start ( ) ;
System . out. println ( "启动testLockThread 线程完毕.." ) ;
}
public static void main ( String [ ] args) throws Exception {
System . out. println ( "main 线程.." ) ;
BufferedReader br = new BufferedReader ( new InputStreamReader ( System . in) ) ;
System . out. println ( "redLine阻塞" ) ;
br. readLine ( ) ;
createBusyThread ( ) ;
System . out. println ( "redLine阻塞" ) ;
br. readLine ( ) ;
Object obj = new Object ( ) ;
createLockThread ( obj) ;
System . out. println ( "main 线程结束.." ) ;
}
}
自己看选项中的线程,并自己测试吧
查看死锁线程
代码:
package test ;
public class test5 {
static class SynAddRunalbe implements Runnable {
int a, b;
public SynAddRunalbe ( int a, int b) {
this . a = a;
this . b = b;
}
public void run ( ) {
synchronized ( Integer . valueOf ( a) ) {
synchronized ( Integer . valueOf ( b) ) {
System . out. println ( a + b) ;
}
}
}
}
public static void main ( String [ ] args) {
for ( int i = 0 ; i < 100 ; i++ ) {
new Thread ( new SynAddRunalbe ( 1 , 2 ) ) . start ( ) ;
new Thread ( new SynAddRunalbe ( 2 , 1 ) ) . start ( ) ;
}
}
}
编译运行, 在"线程"页签可查看"死锁"描述(点击检测死锁即可,一般只会监测一次,反正存在就行,但通常是最开始和最终的靠拢,包括jstack的查看死锁的那个地方个数,也是主要显示,一般是这个"Found one Java-level deadlock"(英文意思:发现一个Java级死锁)地方,但要注意外面的等待不是死锁的说明,所以一般显示不是很多的,不要以为他们都属于死锁,所以Found one Java-level deadlock后面一般并不多),这是因为1、2两个数值在Integer类的缓存常量池[-128, 127]范围内,这样当多次调用Integer.valueOf()方法时,不会再每次都创建对象,而是直接返回缓存常量池中的对象,所以上面两个线程的同步代码块中实际上只创建了两个锁对象,且在某一时刻会出现互相持有对方的锁,即"死锁"现象
VisualVM 可视化优化工具:
简介:
VisualVM 是一个工具,它提供了一个可视界面,用于查看 Java 虚拟机 (Java Virtual Machine, JVM) 上运行的基于Java 技术的应用程序(Java 应用程序)的详细信息,VisualVM 对 Java Development Kit (JDK) 工具所检索的 JVM 软件相关数据进行组织,并通过一种使您可以快速查看有关多个 Java 应用程序的数据的方式提供该信息,您可以查看本地应用程序以及远程主机上运行的应用程序的相关数据。此外,还可以捕获有关 JVM 软件实例的数据,并将该数据保存到本地系统,以供后期查看或与其他用户共享
概述与插件安装:
VisualVM基于NetBeans平台开发,因此它一开始就具备了插件扩展的特性,通过插件支持,VisualVM可以做许多事情,
例如:
1:显示虚拟机进程和进程的配置、环境信息(jps、jinfo)
2:监视应用程序的CPU、GC、堆、方法区及线程的信息(jstat、jstack)
3:dump及分析堆转储快照(jmap、jhat)
4:方法级的程序运行性能分析, 找出被调用最多、运行时间最长的方法
5:离线程序快照:收集程序的运行时配置、线程dump、内存dump等信息建立一个快照, 可以将快照发送开发者处进行bug反馈等等
当然,这个在前面我们已经操作过了即jvisualvm.exe,所以这里只是进行补充说明,这里也提供另外一种打开方式,在控制台输入:jvisualVM一般也能打开,当然,如果不行,你点击jvisualvm.exe也可以,因为并非都可以被任何操作启动的,就如jdk可能设置了他只能点击执行(二进制是万能的,给出限制不过分吧)
堆转储快照:
两种方式生成堆dump文件(前面有操作命令的,这里利用工具来进行):
可以先执行如下代码:
package test ;
import java. io. IOException ;
import java. util. ArrayList ;
public class test6 {
public static void main ( String [ ] args) throws IOException , InterruptedException {
System . in. read ( ) ;
fun ( ) ;
System . in. read ( ) ;
}
private static void fun ( ) throws InterruptedException {
ArrayList < Capacity > list = new ArrayList < > ( ) ;
for ( int i = 0 ; i < 10000 ; i++ ) {
Thread . sleep ( 400 ) ;
list. add ( new Capacity ( ) ) ;
}
}
}
class Capacity {
private byte [ ] big = new byte [ 8 * 1024 * 1024 ] ;
}
执行后,操作如下:
第一种:在"应用程序"窗口中右键单击应用程序节点,选择"堆 Dump"即可创建(记得点击带有pid显示的,否则可能不能选择"堆 Dump")
第二种:在"监视"页签中选择"堆 Dump",即可创建成功
他们中间会有对应的地址存在的显示,可以自己看
你可以选择执行前面的死锁代码,然后点击线程查看,一般会提示检测到了死锁,选择线程Dump,就相当于观看jstack -l 177568或者jstack 177568的执行结果,他们基本是相同的意思,可以认为默认加上-l(那么Found one Java-level deadlock地方的显示也是同样的)
分析程序性能(了解即可):
在Profiler页签中,可以对程序运行期间方法级的CPU和内存进行分析,这个操作会对程序运行性能有很大影响,所以一般不再生产环境使用,CPU分析将会统计每个方法的执行次数、执行耗时,内存分析则会统计每个方法关联的对象数及对象所占空间
GC日志分析:
GC日志是一个很重要的工具,它准确记录了每一次的GC的执行时间和执行结果,通过分析GC日志可以优化堆设置和GC设置,或者改进应用程序的对象分配模式
GC日志参数(了解即可):
不同的垃圾收集器,输出的日志格式各不相同,但也有一些相同的特征,熟悉各个常用垃圾收集器的GC日志,是进行JVM调优的必备一步,解析GC日志,首先需要收集日志,常用的有以下JVM参数用来打印输出日志信息:
GC日志参数如下:
参数 说明 -XX:+PrintGC 打印简单GC日志,类似:-verbose:gc -XX:+PrintGCDetails 打印GC详细信息 -XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式) -XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式) -XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息 -Xloggc:…/logs/gc.log 指定输出路径收集日志到日志文件
-XX:+PrintGCDateStamps可以结合-XX:+PrintGCTimeStamps一起显示,其中有些设置,如-XX:+PrintHeapAtGC和-XX:+PrintGCDateStamps和-XX:+PrintGCTimeStamps可能受版本影响而不能设置,一般来说jdk8可以,jdk11可能设置时,运行代码会报错
例如,使用如下参数启动:
- Xms28m
- Xmx28m
- XX : + PrintGCDetails
- XX : + PrintGCDateStamps
- XX : + UseGCLogFileRotation
- Xloggc : E : / logs/ gc. log
常用垃圾收集器参数,及其利用方式
参数 描述 UseSerialGC 虚拟机在运行在 Client 模式下的默认值,打开此开关后,使用 Serial+Serial Old 收集器组合进行内存回收 UseParNewGC 使用 ParNew + Serial Old 收集器组合进行内存回收 UseConcMarkSweepGC 使用 ParNew + CMS + Serial Old 的收集器组合尽心内存回收,当 CMS 出现 Concurrent Mode Failure 失败后会使用 Serial Old 作为备用收集器 UseParallelOldGC 使用 Parallel Scavenge + Parallel Old 的收集器组合 UseParallelGC 使用 Parallel Scavenge + Serial Old (PS MarkSweep)的收集器组合 SurvivorRatio 新生代中 Eden 和任何一个 Survivor 区域的容量比值,默认为 8 PretenureSizeThreshold 直接晋升到老年代对象的大小,单位是Byte UseAdaptiveSizePolicy 动态调整 Java 堆中各区域的大小以及进入老年代的年龄 ParallelGCThreads 设置并行 GC 时进行内存回收的线程数 GCTimeRatio GC 时间占总时间的比率,默认值为99,只在 Parallel Scavenge 收集器的时候生效 MaxGCPauseMillis 设置 GC 最大的停顿时间,只在 Parallel Scavenge 收集器的时候生效 CMSInitiatingOccupancyFraction 设置 CMS 收集器在老年代空间被使用多少后触发垃圾收集,默认是 68%,仅在 CMS 收集器上生效 CMSFullGCsBeforeCompaction 设置 CMS 收集器在进行多少次垃圾回收之后启动一次内存碎片整理 UseG1GC 使用 G1 (Garbage First) 垃圾收集器 MaxGCPauseMillis 设置最大GC停顿时间(GC pause time)指标(target),这是一个软性指标(so会尽量去达成这个目标 G1HeapRegionSize 使用G1时Java堆会被分为大小统一的的区(region)。此参数可以指定每个heap区的大小,默认值将根据 heap size 算出最优解,最小值为 1Mb,最大值为 32Mb
注意:背下来并无意义,在实际操作中可能都不怎么使用到,所以只需要了解即可
GC日志分析:
日志的含义:
GC 日志理解起来十分简单,因为日志本来就是要给开发人员看的,所以设计的很直观
举个例子,我们来分别说明各个部分所代表的含义(类似于前面的"再次的回车,可以看程序的打印日志:"下面的图片内容):
GC ( Allocation Failure ) [ PSYoungGen : 6146 K-> 904K ( 9216 K) ] 6146 K-> 5008K ( 19456 K) , 0.0038730 secs] [ Times : user= 0.08 sys= 0.00 , real= 0.00 secs]
之前的图片:
将上面 GC 日志抽象为各个部分,然后我们再分别说明各个部分的含义(了解即可):
[ a ( b) [ c: d-> e ( f) , g secs] h-> i ( j) , k secs] [ Times : user: l sys= m, real= n secs]
使用ParNew+Serial Old的组合进行内存回收:
设置JVM参数:
- Xms20M - Xmx20M - Xmn10M - XX : + UseParNewGC - XX : + PrintGCDetails - XX : SurvivorRatio = 8 - XX : + PrintCommandLineFlags
测试代码:
package test ;
public class test11 {
private static final int _1MB = 1024 * 1024 ;
public static void testAllocation ( ) {
byte [ ] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte [ 2 * _1MB] ;
allocation2 = new byte [ 2 * _1MB] ;
allocation3 = new byte [ 2 * _1MB] ;
allocation4 = new byte [ 4 * _1MB] ;
}
public static void main ( String [ ] args) {
testAllocation ( ) ;
}
}
一般来说,虽然完美设置的年轻代是10,但是在分配时,可能少点,即9216/1024=9,而堆大小是20,而19456/1024=19,即的确少点,也解释了之前说明的"虽然最大最小也是小于该值(快满了也是),但大于maxMemory的值,虽然冗余少点"这个地方的注释,实际上是认为少了一个幸存者而已,所以对于堆来说,显示的就是这样的说明
结果分析:
通过上面的GC日志我们可以看出,一开始出现了 MinorGC引起GC的原因是 内存分配失败,因为分配allocation的时候,Eden区已经没有足够的区域来分配了,所以发生了本次 MinorGC ,经过 MinorGC 之后新生代的已使用容量从8145K->7692K,然而整个堆的内存总量却几乎没有减少,原因就是,由于发现新生代没有可以回收的对象,所以不得不使用内存担保将allocation1~3 三个对象提前转移到老年代,也就是之前说明的特殊情况,幸存者可存放不了这么多,此时再在 Eden 区域为 allocation 分配 4MB 的空间,因此最后我们发现新生代占用了 4MB(4871k,取整),老年代占用了 6MB(6144k,取整),加起来比我们的总10m多,这是因为jvm初始化,自然会使得新生代有值,可以操作System.in.read();来看看是否多了,若多了,说明前面说明的"一般来说System.in.read();比普通的打印会多点,可能jvm在识别执行他时,会初始化比较多的对象"没有问题,当然,并不是他一人,还有其他初始化是底层操作的,所以有初始数据占用新生代是很正常的,只是你加上了他,会变更多而已
使用Parallel Scavenge+Parallel Old的组合进行内存回收:
设置参数(jdk8为主,有默认,那么只需要改变老年代即可,虽然他操作了覆盖(没有设置新生代,操作覆盖,自然会替换,替替换是看谁先谁后的,但是作用只是操作覆盖而已,老覆盖新),但是与默认一样的):
- Xms20M - Xmx20M - Xmn10M - XX : + UseParallelGC - XX : + PrintGCDetails - XX : SurvivorRatio = 8
测试代码:
package test ;
public class test22 {
private static final int _1MB = 1024 * 1024 ;
public static void testAllocation ( ) {
byte [ ] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte [ 2 * _1MB] ;
allocation2 = new byte [ 2 * _1MB] ;
allocation3 = new byte [ 2 * _1MB] ;
allocation4 = new byte [ 4 * _1MB] ;
}
public static void main ( String [ ] args) {
testAllocation ( ) ;
}
}
注意:在某些情况下,gc的打印信息可能不会打印,但确操作了,这可能是该gc对于某种数量的数据是选择不打印的策略的,这里了解即可
大对象回收分析:
大对象直接进入老年代,虚拟机提供一个参数 -XX:PretenureSizeThreshold 用来设置直接在老年代分配的对象的大小,如果对象大于这个值就会直接在老年代分配,这样做的目的是避免在Eden区及两个Survivor区之间发生大(量)的内存复制
参数:
- verbose: gc - Xms20M - Xmx20M - Xmn10M - XX : + UseParNewGC - XX : + PrintGCDetails - XX : PretenureSizeThreshold = 3145728
测试代码:
package test ;
public class test33 {
private static final int _1MB = 1024 * 1024 ;
public static void testPreteureSizeThreshold ( ) {
byte [ ] allocation;
allocation = new byte [ 4 * _1MB] ;
}
public static void main ( String [ ] args) {
testPreteureSizeThreshold ( ) ;
}
}
查看结果时,若新生代有,且老年代也有,说明是对的(jvm初始化的原因),修改更大的值,若老年代没有,那么更加对
日志分析工具:
日志工具简介:
GC日志可视化分析工具GCeasy和GCviewer,通过GC日志可视化分析工具,我们可以很方便的看到JVM各个分代的内存使用情况、垃圾回收次数、垃圾回收的原因、垃圾回收占用的时间、吞吐量等,这些指标在我们进行JVM调优的时候是很有用的
GCeasy:是一款在线 的GC日志分析器,可以通过GC日志分析进行内存泄露检测、GC暂停原因分析、JVM配置建议优化等功能,而且是可以免费使用,他的在线 分析工具:https://gceasy.io/index.jsp,或者https://gceasy.io/,他们两个基本是一样的,一般选择文件并点击分析(Analyze)即可
GCViewer:是一款实用的GC日志分析软件,免费开源使用,你需要安装jdk或者java环境才可以使用,软件为GC日志分析人员提供了强大的功能支持,有利于大大提高分析效率
测试准备:
编写代码生成gc.log日志准备分析
package test ;
import java. util. ArrayList ;
public class test44 {
private static final int _1MB = 1024 * 1024 ;
public static void main ( String [ ] args) {
ArrayList < byte [ ] > list = new ArrayList < byte [ ] > ( ) ;
for ( int i = 0 ; i < 500 ; i++ ) {
byte [ ] arr = new byte [ 1024 * 1024 ] ;
list. add ( arr) ;
try {
Thread . sleep ( 20 ) ;
} catch ( InterruptedException e) {
e. printStackTrace ( ) ;
}
}
}
}
现在我们进行执行(若存在对应文件则清空写入,这是IO流中的写操作的作用),然后到上面的https://gceasy.io/进入,然后选择文件,进行分析,自己看看就行了(注意:只需要文件的内容是正确的即可(至于如何正确,可以百度),与后缀无关,所以就算你修改成txt也行)
简单来说,之前的监控是操作指定为主,可视化是动态的,而这里是可以只对文件操作(如GCeasy,GCViewer),至此所有分析操作都给出完毕
GCViewer:
GCViewer是一个小工具,可以可视化展示 生成的详细GC输出,支持Sun / Oracle,IBM,HP和BEA的Java虚拟机。它是GNU LGPL下发布的免费软件
下载地址:https://sourceforge.net/projects/gcviewer/
使用简介:
java -jar gcviewer-1.37-SNAPSHOT.jar,当然,到这里,你肯定是配置好java环境变量全局使用的
出现如下(记住,一般使用命令行执行的,会与命令行一同存活,也就是说,命令行窗口关闭他也会关闭,相当于该命令行是main):
打开之后,点击File->Open File打开我们的GC日志,具体描述,自己看看吧(记得放大,否则可能看不到)
JVM调优实战:
前面说明的基本都是使用及其观察,现在我们来进行实战
tomcat与JVM调优:
tomcat服务器在JavaEE项目中使用率非常高,所以在生产环境对tomcat的优化也变得非常重要了,对于tomcat的优化,主要是从2个方面入手,一是,tomcat自身的配置,另一个是 tomcat所运行的jvm虚拟机的调优
安装tomcat部署项目:
下载并安装tomcat(可能你是操作过的,但是复习一下并不是不可以):
下载地址:https://tomcat.apache.org
点击tar.gz这个版本(记得点击左边下载(Download)的Tomcat 8,而不是图片的,这是因为移动的原因,所以没有显示出来),当然,就算你点击其他地方,一般也不会到上面的右边显示(或者整体显示),所以并不用担心,下载后,自己准备一个虚拟机,具体操作看54章博客,或者自己百度找教程
然后放入虚拟机中(54章博客也有具体操作,当然,方式有很多,具体百度即可)
然后操作如下(命令是相对的,记得观察就行):
tar -xvf apache-tomcat-8.5.85.tar.gz
cd apache-tomcat-8.5.85/conf/
vim tomcat-users.xml
< role rolename = "manager" />
< role rolename = "manager-gui" />
< role rolename = "admin" />
< role rolename = "admin-gui" />
< user username = "tomcat" password = "tomcat" roles = "admin-gui,admin,manager-gui,manager" />
:wq退出
配置可以访问Server Status:
vim webapps/manager/META-INF/context.xml
< Context antiResourceLocking = "false" privileged = "true" >
< CookieProcessor className = "org.apache.tomcat.util.http.Rfc6265CookieProcessor"
sameSiteCookies = "strict" />
< ! -- < Valve className = "org.apache.catalina.valves.RemoteAddrValve"
allow = "127\.\d+\.\d+\.\d+|::1|0:0:0:0:0:0:0:1" /> -->
< Manager sessionAttributeValueClassNameFilter = "java\.lang\.(?:Boolean|Integer|Long|Number|String)|org\.apache\.catalina\.filters\.CsrfPreventionFilter\$LruCache (?:\$1 )?|java\.util\.(?:Linked)?HashMap" />
< /Context>
然后回到bin目录下,执行./startup.sh,我的虚拟机ip是192.168.164.128,所以在浏览器上输入192.168.164.128:8080就可以看到如下了:
点击Server Status,输入用户密码(如果没有之前的注释配置,他直接报错403,可不会让你输入用户名和密码哦),然后到如下:
上面就可以看到一些信息,因为Server Status的英文意思是:服务器状态
部署web项目:
为了有个项目可以操作,现在到如下地址拿取项目:
链接:https://pan.baidu.com/s/12sqXaJ3c7WHO7OvLG99X1A
提取码:alsk
拿取后,将该项目放在webapps文件下就行,然后启动,去访问192.168.164.128:8080/test/quick,若出现了"嘿嘿嘿",说明操作成功,即没有问题
最后要注意的是:在启动时,会查找war文件,首先看看当前项目是否有该文件的前缀名称,比如a.war,即看看是否有a文件,若有,那么直接不操作,否则解压他操作变成a(可能在其他博客也说明过,但是也只是可能,我们以这里为主)
使用Apache Jmeter进行测试:
将web应用部署到服务器之后,使用jmeter对其进行压力测试:
下载安装jmeter(在80章博客也操作过,这复习一下):
下载地址:http://jmeter.apache.org/download_jmeter.cgi
选择zip即可,当然,如果下载比较慢,可以换一个渠道,虽然可能更加的慢:
链接:https://pan.baidu.com/s/19IalQnsnDcR6OXjdgQ4Kiw
提取码:alsk
使用步骤:
解压,找到并双击jmeter.bat(一般是bin目录里面的),出现如下(记得等一下):
设置中文:
设置外观主题:
添加线程组:
在Test Plan(英文意思:测试计划,虽然可以修改名称,然后ctrl+c保存即可,弹出的框框可以选择取消)右键就行,右键->添加->线程(用户)->线程组
使用线程模拟用户的并发:
设置1000个线程,每个线程循环10次,也就是tomcat会接收到10000个请求:
添加Http请求:
右键线程组->添加->取样器->HTTP请求:
添加监控:
在HTTP请求,右键->添加->监听器,加上这三个,他们都是看结果的,并不影响请求
现在我们来操作如下:
上面的/test/quick可以写成test/quick,他默认会帮你加上/了,可能有不加上,但是就算加上也没有问题,因为浏览器认为是一个(最终)
然后点击这里(察看一般是查看,没有什么都是完美的):
运行后(提示信息选择no)没看聚合报告,他代表很多信息,其他的你可以选择查看,注意:这个运行是线程组下面的所有操作,比如你有两个HTTP请求,那么一起操作,可能平分或者多一个请求,一般我们考虑平分的说明
可以保存,然后打开(文件选项中打开,或者就是Test Plan中打开)
也可以加大线程数,使得可能出现异常"Read timed out"(那么聚合报告中的异常可能就不是0.00%了),始终没有抢到(服务器接收的线程数量一般是设置有限的,而不是你的没有设置,可以认为服务器是线程池,而你不是,一般来说tomcat是没有启用线程池的,所以你增加线程数时,可能会明显的比较卡顿,因为占用内存,可能也分版本限制,但是一般只是针对接收来说的,在进行访问方法,可能是默认由线程池来进行处理他们的线程操作,从而使得方法操作有限,这也是为什么有时候多人访问同一个方法时可能有相同的线程)
调整tomcat参数进行优化:
优化吞吐量:
禁用AJP服务:
什么是AJP呢? AJP(Apache JServer Protocol)是定向包协议,WEB服务器和Servlet容器(一般是业务服务器,即WEB服务器只是得到请求,但是他是将请求进行提交了,类似于Nginx)通过TCP连接来交互,为了节省SOCKET创建的昂贵代价,WEB服务器会尝试维护一个永久的TCP来连接到servlet容器,并且在多个请求和响应周期过程会重用连接
Tomcat在 server.xml中配置了两种连接器
1:第一个连接器监听8080端口,负责建立HTTP连接,在通过浏览器访问Tomcat服务器的Web应用时,使用的就是这个连接器
2:第二个连接器监听8009端口,负责和其他的HTTP服务器(其他,也是服务器)建立连接,在把Tomcat与其他HTTP服务器集成时,就需要用到这个连接器,AJP连接器可以通过AJP协议和一个web容器(类似于Tomcat或者Nginx)进行交互
其中Nginx+tomcat的架构,一般用不着AJP协议(可能是其他协议,反正只是包交换而已),所以把AJP连接器禁用,若存在,那么修改conf下的server.xml文件,将AJP服务禁用掉即可
< Connector port = "8009" protocol = "AJP/1.3" redirectPort = "8443" />
重启tomcat,查看效果,可以看到AJP服务以及不存在了
实际上在linux中进入底行模式进行搜索(/),即/AJP,回车后,按n即可进行一步一步的找(第一个开始,最后一个使用到第一个),而N则是往上找(第一个开始使用,到最后一个)
如果注释去掉,那么我这里是这样:
而没有去掉就是:
当然,我们一般并不会需要他,所以防止他进行操作,就算他没有对方,但是也会进行使用些内存,所以禁用掉也是一个小的提升的
设置执行器(线程池):
频繁地创建线程会造成性能浪费,所以使用线程池来优化:
在tomcat中每一个用户请求都是一个线程,所以可以使用线程池提高性能,修改server.xml文件(设置接收线程操作线程池):
< Executor name = " tomcatThreadPool" namePrefix = " catalina‐exec‐" maxThreads = " 500"
minSpareThreads = " 50" prestartminSpareThreads = " true" maxQueueSize = " 100" />
< Connector port = " 8080" executor = " tomcatThreadPool" protocol = " HTTP/1.1"
connectionTimeout = " 20000"
redirectPort = " 8443" />
若不加上executor=“tomcatThreadPool”,出现:
实际上并没有使用,可能是存在但不使用造成上面的某些出现(R)
加上executor=“tomcatThreadPool”:
他的显示-1并不对,其实是500,因为他可能无法正确的展示
当然,对应的图片的显示,可能是错误的,你可以自己测试,但是并不需要理会,因为并没有什么意义
其中,对于属性设置来说,并非是有固定位置的,就如executor="tomcatThreadPool"可以在port前面也行
然后你自己对比一下设置之前的执行结果(聚合报告),和设置之后的结果的平均值,通常来说设置之后的吞吐量会比较高(记得多执行几次,注意:在操作之前,记得删除或者清除对应的监听器或者监听器信息,然后创建一个,因为可能之前存在的数据是会进一步影响结果的,具体原因一般是软件的问题),所以设置之后,性能的确提升了
当然了,如果你设置:最大并发数是1(可以同时操作的线程,并不与初始化线程数冲突哦),且初始化线程数是1,自然性能是很低的,其中设置很多线程数也没有什么用,比如,你设置50000,你应该很明显的知道,有些肯定没有操作,所以并不是设置越大越好,即要考虑线程占用的资源,而考虑占用的资源,可能会影响tomcat内部的使用情况,那么可能还会导致吞吐量变低(但一般只是对设置的少来说的,除非超级大,那么可能比没有设置的还要低,但一般可能超过某些值,会变成默认,所以一般不会考虑超级大的,即介于设置少(不是非常少,如是1)到没有设置中间)
实际上有很多的细节可以说明,比如设置小数点或者负数怎么样,当然,如果你硬要考虑这些,那么你会浪费超多的时间,因为并没有意义,配置何其之多,你只需要会使用即可,因为该知识只是给你使用的,若别人稍微修改一下,那么你的知识还是对的吗,所以并不需要死磕(强迫症除外),一般来说小数取整,负数默认,但是大多数都是直接不然呢启动,即启动保存,对于启动,实际上tomcat可以重复启动,不覆盖的(有些框架,软件可能不是),所以通常需要先关闭再启动哦,而大多数基本都会像tomcat这样的,在之前的博客中,可能并没有操作过,但是与上面的"设置小数点或者负数怎么样"一样的无意义,所以就没有说明了,具体自己操作时,可以顺便测试(比如后面的nio2),虽然无意义,因为启动的操作也是有很多框架也是这样的
设置最大等待队列:
默认情况下,请求发送到tomcat,如果tomcat正忙,那么该请求会一直等待,这样虽然 可以保证每个请求都能请求 到,但是请求时间就会变长
有些时候,我们也不一定要求请求一定等待,可以设置最大等待队列大小(原本没有,即都等待,虽然等待也会出现异常(等待久了)),如果超过就不等待了,这样虽然有些请 求是直接失败的,但是请求时间会虽短,即更快的执行完,当然,等待队列的大小不要太大,否则还不如等待时间,但也不要太小,否则很多失败的,容易出现操作问题,比如数据库的添加,最主要的是用户操作这方面的操作,用户为主嘛
< Executor name = " tomcatThreadPool" namePrefix = " catalina‐exec‐" maxThreads = " 500"
minSpareThreads = " 100"
prestartminSpareThreads = " true" maxQueueSize = " 100" />
然后自己再次的测试吧,这里就不测试了,一般异常变多,但是吞吐量也变多
设置nio2的运行模式:
tomcat的运行模式有3种:
1:bio 默认的模式,性能非常低下,没有经过任何优化处理和支持
2:nio nio(new I/O),是Java SE 1.4及后续版本提供的一种新的I/O操作方式(即java.nio包及其子包)Java nio是一个基于缓冲区、并能提供非阻塞I/O操作的Java API,因此nio 也被看成是non-blocking I/O的缩写,它拥有比传统I/O操作(bio)更好的并发运行性能
3:apr 安装起来最困难(具体使用方式可以百度),但是从操作系统级别来解决异步的IO问题,大幅度的提高性能,推荐使用nio,不过,在tomcat8中有最新的nio2,速度更快,建议使用nio2
设置nio2:
< Connector executor = " tomcatThreadPool" port = " 8080"
protocol = " org.apache.coyote.http11.Http11Nio2Protocol"
connectionTimeout = " 20000" redirectPort = " 8443" />
关闭启动,会发现:
变成nio2了
tomcat8之前的版本用的是BIO,推荐使用NIO(默认的,之前的一般不是),但是tomcat8中有最新的NIO2,速度更快,建议设置使用NIO2
理论上nio2的效果会优惠nio,可以进行测试,但一般会发现设定为nio2对于提升吞吐量效果不是很明显,所以可以根据自己的测试 情况选择合适的io模型,一般情况下可以不用修改,反正波动很大,基本没有什么明显的提升
调整JVM参数进行优化:
测试通过jvm参数进行优化,为了测试一致性,依然将最大线程数设置为500,初始化线程数50, 启用nio2运行模式
设置并行垃圾回收器:
在bin目录下面找到catalina.sh,编辑他,vim catalina.sh:
JAVA_OPTS = "-XX:+UseParallelGC -XX:+UseParallelOldGC -Xms64m -Xmx512m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:../logs/gc.log"
查看GC日志文件:
我们使用线程组执行,然后将对应的文件内容放在gceasy.io查看gc中是否存在问题:
你可以自己进行分析,然后改变对应的参数值来进行优化,比如提升一下:-Xms512m -Xmx512m,然后继续查看结果,一般会发现,吞吐量上升了
理论上而言,设置为G1垃圾收集器,性能是会提升的,但是会受制于多方面的影响,也不一定绝对有提升
这里就需要你对于设置及其观察的分析了,所以需要自己考虑,因为我的测试必然与你的有所差别,所以就不给出图片了
至此JVM相关的优化说明完毕,实际上优化只是针对空间不变来说的,如果你的电脑内存超大,或者计算能力超强,或者硬盘超大,那么对应的优不优化又有什么关系呢,直接设置顶配即可,就如你对mysql分库分表,但只要资金足够,直接买一个或者多个服务器就行了(手动滑稽🤭)