JVM入门到精通一篇就够了
- 一、JVM运行时数据区域
- 1.程序计数器
- 2.虚拟机栈
- 2.1 局部变量表
- 2.2 关于局部变量表的一些思考
- 2.3 操作数栈
- 2.4 动态连接(Dynamic Linking)
- 2.5 返回地址(Return Address)
- 3. 本地方法栈(Native Stack)
- 4. 虚拟机堆(Heap)
- 4.1 新生代
- 4.2 老年代
- 4.3 新生代对象进入老年代对象的方式有哪些
- 5. 方法区
- 5.1 运行时常量池
- 6. 直接内存(Direct Memory)
- 二、JVM的垃圾收集器
- 1.可达性分析算法和引用计数法
- 1.1 概念引入
- 1.2 什么是GC Roots
- 1.3 凭什么用GC Roots判定对象是否可达?
- 1.4 两种算法的优缺点,如何选择
- 1.5 强、软、弱、虚引用与可达性分析算法
- 2.分代收集理论
- 2.1 什么是分代收集理论
- 2. 2 分代收集理论建立的基础
- 2. 3 JVM对分代理论的实践
- 2. 4 根据分代收集理论对收集器分类
- 3.垃圾收集算法
- 3.1 标记-清除算法
- 3.2 标记-复制算法
- 3.3 标记-整理算法
- 3.4 对比标记-清除算法与标记-整理算法
- 4.经典的垃圾收集器
- 4.1 新生代收集(Minor GC)
- 4.1.1 Serial 收集器
- 4.1.2 ParNew 收集器
- 4.1.3 Parallel Scavenge 收集器
- 4.2 老年代收集(Major GC)
- 4.2.1 Serial Old 收集器
- 4.2.2 Parallel Old 收集器
- 4.2.3 CMS(ConcurrentMarkSweep) 收集器
- 4.3 混合代收集(Mixed GC)
- 4.3.1 Garbage First 收集器(G1)
- 三、类的加载与执行
- 1.class文件结构
- 1.1 什么是class文件
- 1.2 什么是无符号数?表?集合?
- 1.3 魔数与Class文件的版本号
- 1.4. 常量池
- 1.5 标志位
- 1.6 类索引、父类索引、接口索引集合
- 1.7 字段表集合、方发表集合、属性表集合
- 2.字节码指令介绍
- 2.1 什么是字节码指令
- 2.2 操作指令
- 3.双亲委派模型
- 3.1 什么是类加载器
- 3.2 类加载器的作用
- 3.3 常见的类加载器
- 3.4 什么是双亲委派模型
- 3.5 为什么需要双亲委派模型
- 3.6 双亲委派模型的工作过程
- 3.6 双亲委派模型的优点
- 3.7 破坏双亲委派模型
- 4.类的加载过程
- 4.1 java代码与字节码信息展示
- 4.2 虚拟机类加载的过程
- 4.3 对象创建过程
- 四、JVM工具介绍
- 1.jmap
- 1.1 使用jmap查看内存中的对象信息及其大小:jmap -histo
- 1.2 使用jmap查看堆信息: jmap -heap
- 1.3 jmap导出堆内存dump: jmap -dump
- 2.Jstack
- 2.1 jstack 查看线程信息,可用于查看线程死锁
- 2.2 jstack排查CPU异常升高的问题
- 3.Jinfo
- 3.1 使用jinfo查看jvm参数
- 3.2 使用jinfo查看系统变量
- 4.Jstat
- 4.1 jstat查看gc的使用情况与内存情况
- 4.2 使用jinfo查看堆使用情况
- 4.3 新生代垃圾回收统计
- 4.4 新生代内存统计
- 4.5 老年代垃圾回收统计
- 4.6 老年代内存统计
- 4.7 元数据空间统计
- 4.8 内存分布使用比例展示(快速查看概况)
- 5.Jvisualvm
- 5.heaphero
- 五、OOM问题处理实操
- 1.如何确定JVM各个区域应该分配多大空间
- 2.如何快速定位OOM
- 3.如何定位CPU异常飙升
JVM是每个java程序员应该必备的技能,了解了JVM的运行原理和参数配置才能帮助我们写出更好的代码,更快的定位线上问题,这篇文章全程干货,带你一起探秘JVM的庐山真面目。该文章基础部分来源于笔者之前总结的JVM的文章(所以很多图片出现了重复水印),之前总结的比较分散,这里重新总结梳理然后对JVM工具和线上问题的分析一起做个整体的梳理。
一、JVM运行时数据区域
如图JVM运行时数据区域划分为以下6个主要部分:①程序计数器,②虚拟机栈,③本地方法栈,④虚拟机堆,⑤方法区,⑥直接内存,下面对6个部分详细总结
1.程序计数器
程序技术器存储的是当前线程所执行字节码文件的行号,换句话说程序技术器就是当前线程所执行字节码文件的行号指示器,用以告诉当前线程下一次需要执行什么指令。这就是他的作用。
程序技术器的鲜明特点是:线程私有,内存占用极小,也是唯一不会发生OOM的运行数据区域。
为什么需要程序计数器?
因为在单核的计算机内部,多线程是通过线程间的来回切换实现的,并不是一个真正的并行状态,程序技术器会记录当前线程所执行的位置,这样在线程切换回来时才可以继续执行,不过即使是单线程程序也是要依赖程序技术器的。
为什么不会OOM?
程序技术器仅仅存储一个行号,执行java文件时存储的是即将执行的字节码行号,执行本地方法时存储的是null。
2.虚拟机栈
虚拟机栈描述的是java方法运行时的内存模型,‘虚拟机栈’的基础结构是栈帧,每个方法的运行到结束便对应着一个栈帧的入栈到出栈,当前正在执行的栈帧称为当前栈帧,事实上虚拟机栈只执行当前栈帧,每个栈帧又主要由①局部变量表,②操作数栈,③动态连接,④返回地址四部分组成。hotspot不支持栈的动态扩展,栈深度用完时会报StackOverFlowError,若在线程申请栈内存时内存就不够则会报OOM。
虚拟机栈的主要特点:线程私有、通过参数**“-Xss:1M”来设定栈大小**,存在StackOverFlowError、OOM两种异常的可能。
如下图是‘虚拟机栈’的内存模型:
下面介绍下栈帧的四个主要部分
2.1 局部变量表
局部变量表用以存储编译器可知的8中基本数据类型(byte、short、int、long、float、double、char、boolean),引用数据类型reference、returnAddress这三类信息,大小编译期可知,局部变量表的基础单位是变量槽,每个变量槽的大小有32Bit(32位操作系统中)和64Bit(64位操作系统中)两种,但是无论是32位操作系统中还是64位操作系统中,其中除了long、double占两个‘变量槽’其余类型都是占用1个变量槽。
32位操作系统与64位操作系统中变量槽的大小不一样为什么存储long、double都是使用两个变量槽呢?
首先我们需要明确下long、double都是占用64Bit的数据类型,其他比如int是32Bit,按理说一个64位操作系统中的变量槽大小是64Bit,应该是可以直接存储long、double类型的数据的,为什么还是和32位操作系统中一样是占用两个变量槽呢,因为64位中一个变量槽实际使用部分仍是只是用了32Bit,空了32Bit未使用,这样就解释通了,为什么会空着32Bit呢?因为64位操作系统中设计变量槽时刻意为了与32位操作系统的表现保持一致,所以才会有,虽然64位操作系统一个变量槽是64Bit但是存储64Bit的long、double仍是使用两个变量槽。
局部变量表的大小编译器可知?
首先明确:局部变量表的大小指的是‘变量槽’的个数,不是只占用的内存大小。
java文件在编译成class文件后,变量槽的大小就已经确定下来,如下图程序:
public class VarTable {
private String testVar = "我是字段";
public VarTable(){
super();
System.out.println("我是构造器");
}
public static void main(String[] args){
VarTable vt = new VarTable();
vt.test("我是字符串");
}
public void test(String str){
System.out.println(this.testVar);
System.out.println(str);
}
public static void testStatic(){
}
}
编译后的各个方法的字节码信息如下:
public VarTable();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: ldc #2 // String 我是字段
7: putfield #3 // Field testVar:Ljava/lang/String;
10: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
13: ldc #5 // String 我是构造器
15: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
18: return
LineNumberTable:
line 5: 0
line 2: 4
line 6: 10
line 7: 18
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #7 // class VarTable
3: dup
4: invokespecial #8 // Method "<init>":()V
7: astore_1
8: aload_1
9: ldc #9 // String 我是字符串
11: invokevirtual #10 // Method test:(Ljava/lang/String;)V
14: return
LineNumberTable:
line 10: 0
line 11: 8
line 12: 14
public void test(java.lang.String);
descriptor: (Ljava/lang/String;)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
3: aload_0
4: getfield #3 // Field testVar:Ljava/lang/String;
7: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
10: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
13: aload_1
14: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
17: return
LineNumberTable:
line 14: 0
line 15: 10
line 16: 17
public static void testStatic();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=0, locals=0, args_size=0
0: return
LineNumberTable:
line 18: 0
从上面信息中可以清楚看到,每个方法对应的字节码信息中都有Code属性,Code中的locals的值存储的便是变量槽的个数。
附上一张程序计数器的导图:
2.2 关于局部变量表的一些思考
-
为什么test方法只传入了一个String变量,变量槽大小却是2?
因为每个实例方法,都会隐式传入一个当前方法所属对象的实例,方法内用this表示,比如上图中程序可以在test方法中直接使用this.testVar,这便是依赖了隐式传入的this,构造器中也会隐式传入this。 -
为什么testStatic这个静态方法没有隐式传入this这个参数?
因为只有实例方法才会传入this这个隐式参数,也才能传入this,因为静态方法属于类方法,是和类一起加载的,此时并没有对象产生,所以不能传入this这个隐式参数。 -
this是隐式传参,那super是隐式传参吗?
根据构造器VarTable()的变量槽的个数为1,结合前面证实的this这个隐式传参,我们可以发现super并不是隐式传参,这个1代表的是this,super只是在构造器中可以调用而已,super具体实现机制并非隐式传参。 -
为什么main方法中只传入了一个String数组,‘变量槽’大小却是2呢?
根据上面的问题我们已经可以知道,静态方法中肯定不会有this隐式传入,man方法的两个变量槽其实一个是传入的数组参数,一个是main方法内部定义的变量‘vt ’,所以是两个变量槽, -
局部变量表的大小和哪些因素有关?
对上面几个问题总结就可以发现有三点影响到了局部变量表的大小。
①是否是静态方法,是的话不会有this隐式传入,否则都会有this隐式传入。
②方法中传入的参数个数。
③方法内部定义的局部变量个数。 -
如果方法内部循环生成局部变量,栈的内存会爆炸吗,局部变量槽的个数会一直增加吗?
该场景代码如下方代码所示:public class InnerLoopNewVar { public static void main(String[] args){ int n =0; while(true){ String str = new String("我是第"+n+"个字符串"); System.out.println(str); } } }
下面再看下main方法对应的字节码文件吧,省略了部分该场景无关的信息。
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=4, locals=3, args_size=1 0: iconst_0 1: istore_1 2: new #2 // class java/lang/String 5: dup 6: new #3 // class java/lang/StringBuilder 9: dup ...... ......
根据上面的字节码文件可以发现即使在方法内部无限生成对象,其实局部变量槽的大小是在运行之前编译期间就已经确定,并不会因为程序运行时陷入死循环而去不停创建对象,那为什么会在编译器就能确定使用多少个变量槽呢,我再运行期间创建的对象没有变量槽要去存储到什么地方呢?这里就要引出一个关于变量槽的非常非常重要的特性:变量槽是可以复用的。java开发者应该都知道,方法内部创建的变量只会在当前方法内有效,且方法退出后大部分对象都会被销毁,因为方法就是这些局部变量的作用域,但其实方法内部的参数作用域可能并不是整个方法内,就比如for循环内的变量他的作用域就只是一个for循环而已(循环的实现其实是语法糖,可以看成一个个方法),退出这个for循环后变量也就会被销毁,失去了意义,同时他占用的变量槽自然也就空了,那下一次有其他变量需要使用变量槽,就可以用这个空出来的变量槽,从而节省了内存占用。这也就能解释为什么上面的例子中局部变量表的大小是3了。一个被传入参数占用,一个被变量n占用,一个是被循环内部的变量str占用这个变量槽是可以复用的。
-
为什么使用这么大的篇幅探索局部变量表?
看了这部分内容的人可能会有这么个想法,因为曾经由于这个点入过坑,所以这块介绍的详细了些,感兴趣的话可以点击这里。死循环为什么不会OOM的原理?
2.3 操作数栈
操作数栈又被称为操作栈(有人说又叫操作树栈,这种叫法是错误的)主要用以存储程序运行期间产生的中间变量。java虚拟机的解释执行引擎被称为“基于栈的执行引擎”这个栈指的就是操作数栈。他的大小和局部变量表一样都是编译器可知,方法的字节码文件中的Code属性中的statck指的便是操作数栈的深度。
如下所示:
public void test(java.lang.String);
descriptor: (Ljava/lang/String;)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
3: aload_0
4: getfield #3 // Field testVar:Ljava/lang/String;
7: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
10: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
13: aload_1
14: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
17: return
LineNumberTable:
line 14: 0
line 15: 10
line 16: 17
操作数栈是如何在程序中起作用的?
java的解释执行被称为“基于栈的执行引擎”,这个栈说的就是操作数栈,从这句话就可以看出操作数栈的重要性已经不言而喻了,在方法执行时,操作数栈与局部变量表配合完成了方法的大部分操作。举个简单的例子,方法中有下面这段代码:
int a = 1;
int b = 2;
int c = a + b;
在jvm中这段代码是这么执行的:
第一步将局部变量表中的a、b两个变量进行入栈操作,
第二步将操作数栈顶的a、b出栈,然后将他们相加,
第三步将相加的结果从新入栈,再将值赋给局部变量表中的c。
其实程序中大部分操作都是需要依赖操作数栈来完成操作的,它和局部变量表中的变量一起构成了jvm的操作指令。
为什么说操作数栈叫“操作树栈”是错误的?
如果明白操作数栈的由来就不会有这样的想法了,计算机的操作指令包含两部分①操作数,②操作码。
这两部分构成了计算机的操作指令,jvm同样也是这样,jvm中的字节码指令也是这样操作数便是存储在局部变量表中的变量,操作码就是load、add、等等这些操作码,操作数栈之所以叫操作数栈就是因为他是用来存储从局部变量表中拿出的操作数而已,就是这样。
2.4 动态连接(Dynamic Linking)
动态连接存储的是当前方法在运行时常量池中的符号引用。都知道class常量池中存储了很多的符号引用,一部分在编译期间就转化为了直接引用,另外一部分会在运行期间转化为直接引用,这部分就称为动态连接,比较常见的例子就是面向父类编程,面向接口编程。
2.5 返回地址(Return Address)
返回地址中存储的是当前方法执行结束后应该返回的上层调用处的地址。每个方法正常执行结束后都应该将执行结果返回到上层调用该方法的地方(异常时也会返回,只是返回的不是正常执行结果)。
附上一张栈的总结导图:
3. 本地方法栈(Native Stack)
HotSpot虚拟机将本地方法栈与虚拟机栈已经合二为一,两者已经区别不大,唯一区别就是虚拟机栈为java方法服务,本地方法栈为本地方法服务。两者都是通过‘-Xss:1M’来设置大小,都会有StackOverFlowError与OOM产生,都是线程私有的空间。
4. 虚拟机堆(Heap)
java虚拟机规范中这么描述:所有的对象实例及数组都应该在堆上分配,所以堆的唯一作用就是为了存储创建出来的对象。其他所有的功能都是为了更好的存储对象来服务的。
堆的特点:堆是运行时数据区域中内存占用最大的一块区域,是所有线程共享的内存区域,可以通过参数**-Xms:250M来设置堆的最小内存**,通过**-Xmx:300M设置堆的最大内存**。堆支持设置最大最小内存设置,所以堆空间是支持动态扩展的,当内存使用达到最小内存临界值时会触发一次GC回收堆空间,会根据回收到的内存大小动态改变参数-Xms:250M的值(在小于最大值的范围内改变),如果动态扩展到的内存不足以支撑新产生对象的分配,便会有OutOfMemoryError产生。
堆既然是运行时数据区域内存占用最大的一块那堆的内存结构又是什么样的呢?
在JDK8及其之前可以认为堆是由新生代(Young Generation)和老年代(Old Generation)组成的,首先需要明确JDK8及其之前为什么把堆划分为新生代和老年代,因为JDK8及其之前采用的垃圾收集器都是基于分代理念设计的,也就是基于年轻代和老年代设计的,所有的垃圾收集器要么是只回收新生代(如:Serial、ParNew、Parallel Scavenge),要么是只回收老年代(如:CMS、Serial Old、Parallel Old),所以堆中的对象要么属于老年代的对象、要么属于新生代的对象。我们把堆划分为新生代、老年代是毫无问题的。但是JDK9将G1作为默认垃圾收集器以后(JDK8引入,JDK9设为默认),这种划分就不太准确了,具体原因在文末进行说明。
4.1 新生代
新生代,顾名思义用来存储堆中新产生的对象的地方,大部分对象被创建出来以后都会进入新生代,同时对象创建时会在对象的头部信息中存储一个GC分代年龄,用以表示该对象年龄大小,一个对象每熬过一次垃圾收集他的年龄就会加1,当年龄到达16时,该对象就会进入老年代。新生代中又划分为了Eden、From Survivor、To Survivor三个区域。
新生代特点:通常新生代中90%对象熬不过一次垃圾收集,大部分对象都是朝生夕灭的,新生代中Eden默认占用新生代内存80%,From Survivor和To Survior都是默认占用10%。Eden和From Survivor用以存储新产生的对象,在触发Minor GC时会将存活的对象移入To Survivor中。新生代占用内存大小可以通过参数‘-Xmn:50M’来设定。
4.2 老年代
新生代中存储的对象的GC分代年龄达到16时,该对象就会进入老年代中,且熬过月多次垃圾收集的对象就会越难以被回收,老年代中的垃圾回收相对新生代是高昂的。老年代占用的内存大小无需手动设置可以根据堆内存大小减去新生代大小来获取。
附上堆的导图:
4.3 新生代对象进入老年代对象的方式有哪些
这里总结下新生代对象进入老年代的方式,有助于我们在分析OOM问题时对问题进行排查
- 1.大对象直接进入老年代,可以通过参数-XX:PretenureSizeThreshold=3145726来设置大对象的大小,注意值只能是b为单位的数值。
- 2.年龄超过15的对象就会进入老年代,可以通过参数XX:MaxTenuringThreshold=15来设置进入老年代大小的值(设置为15,则16进入),一般都是使用默认值。
- 3.通过分配担保策略直接进入老年代,这是因为年轻代采用标记-复制算法导致的问题,标记算法必须有担保策略(因为他空闲的空间可能不够存储存活的对象),老年代就是他的担保策略。
- 4.如果在Survivor(这里指单个Survivor,因为总有一个Survivor是空的)空间中相同年龄的所有对象大小的总和大于等于Survivor(单个)空间值的一半,那么只要年龄大于或等于该年龄(指相同年龄对象大小总和大于等于Survivor中的年龄)的对象,就可以直接进入老年代,而无需遵守年龄条件的制约。这句话有一点绕举个例子,新生代总共是10m空间,Eden是8m,每个Survivor是1m,那么如果有两个一岁的对象大于等于是512kb,那么这两个对象和年龄大于等于这两个对象的都会直接进入老年代。
5. 方法区
方法区是《java虚拟机规范》中的一种规范,在JDK6之前HotSpot使用永久代去实现方法区,JDK8已经完全使用本地内存元空间来实现方法区,无论使用何种方式去实现方法区,方法区的本质作用仍是用以存储被虚拟机加载的类型信息、常量、静态变量(JDK8移到堆中)、即时编译器编译后的代码缓存等信息。
方法区的特点:方法区的大小在64Bit的操作系统中默认是21M,使用参数‘-XX:MetaSpaceSize:21M’来设定方法区的最小值,同时可以设置方法区的最大值-XX:MaxMetaSpaceSize:50M,该值默认是-1,意思是没有只要需要方法区就可以一直扩展,直到受限于物理机的内存。当方法区的内存使用达到设置的最小值时,便会触发Full GC(只有Full GC会回收方法区),进行回收方法区的类型信息和常量,虚拟机会根据回收到的内存大小动态调整-XX:MetaSpaceSize:21M方法区最小值的值,以免重复触发GC,当动态扩展到的内存不足以为新加载的类型信息分配内存时就会报OOM。方法区是线程共享区域。
附上一张方法区的导图:
5.1 运行时常量池
运行时常量池是方法区的一部分,Class文件中除了有魔数、次版本、主版本等等信息外,还有一项是常量池(这里是Class常量池,不是字符串常量池),常量池中存储的是字面量与符号引用。这块常量池中的内容在Class文件被加载后信息会被放入运行时常量池。这也就是运行时常量池中信息的由来了。他的作用已经不言而喻了,存储的是字面量与符号引用,就相当于一个仓库,字面量的确切值是存储在这里,类型信息的获取是需要依赖符号引用的,这些都需要依赖运行时常量池来完成。
运行时常量池的特点:大小受限于方法区大小的限制,因为受限于方法区大小限制,所以也会有OOM产生,下面展示下字节码文件中的常量池的信息,#1,#2,#3,#4存储的就是符号引用。
Constant pool:
#1 = Methodref #5.#15 // java/lang/Object."<init>":()V
#2 = Class #16 // Test
#3 = Methodref #2.#15 // Test."<init>":()V
#4 = Methodref #2.#17 // Test.test1:()V
#5 = Class #18 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 main
#11 = Utf8 ([Ljava/lang/String;)V
#12 = Utf8 test1
#13 = Utf8 SourceFile
#14 = Utf8 Test.java
#15 = NameAndType #6:#7 // "<init>":()V
#16 = Utf8 Test
#17 = NameAndType #12:#7 // test1:()V
#18 = Utf8 java/lang/Object
附上运行时常量池的导图:
6. 直接内存(Direct Memory)
直接内存并不是运行时数据区的一部分也不是《JVM虚拟机规范》中定义的一部分,但是这块内存也会经常被使用,且频率也不低,在JVM参数配置时,也是必须考虑的一部分,JDK1.4引入NIO以后,可以通过Native函数库操作堆外内存,然后堆中的DirectByteBuffer对象作为这块内存的引用来操作这块内存,这样避免了在java堆与Native堆中来回复制,使用参数-XX:MaxDirectMemorySize:21M来设定大小。
二、JVM的垃圾收集器
这个部分将介绍,对象是如何成为垃圾的,JVM是基于哪些算法来处理垃圾对象,最后再结合实际的垃圾回收期去聊一聊垃圾的回收。
1.可达性分析算法和引用计数法
被判定为垃圾的对象或者内存区域会被垃圾收集器回收。那么什么样的对象或者内存区域会被判定为垃圾呢?下面就要说起经常作为垃圾判定依据的可达性分析算法与引用计数法了。这两种算法,都是经常被用作垃圾判定的算法。
1.1 概念引入
- 引用计数法
为每个对象添加一个引用计数器,当有另一个对象引用了该对象时,引用计数器就加一,当引用失效时引用计数器就减一,当引用计数器的值为零时,就说明该对象变成了垃圾。 - 可达性分析算法
从一系列被称为“GC Roots”的节点集开始根据引用关系向下搜索,搜索过程所走过的路径被称为引用链(Reference Chain),如果某个对象到GC Roots之间没有任何引用链,或者用图论的方式来说GC Roots到这个对象不可达时,则证明该对象是不可达的,但此时并不能判定该对象是垃圾,只能说该对象不可达。判定一个对象为垃圾,要经过以下步骤:
如上图①:第一步使用可达性分析算法判断GC Roots到对象之间是否还有引用链,有则存活,没有引用链则对对象进行第一次标记。
如上图②:第二步判断被标记的对象是否有重写finalize()方法,若该方法已经重写过且被执行过或者未重写该方法则对象判断为死亡,否则将对象放入F-Queue队列中,稍后JVM会启动一个低调度优先级的Finalize线程去执行F-Queue队列中的各个对象的finalize()方法。
如上图③:第三步在执行各个对象的finalize()方法时,是各个对象完成自我救赎,不被判定为垃圾的最后机会,如果在finalize()方法中当前对象成功与GC Roots建立了引用链。则会判定为存活,否则会被进行第二次标记,被两次标记的对象就是被判定为死亡的对象,在下次垃圾回收时,会被垃圾收集器回收掉。
1.2 什么是GC Roots
GC Roots就是一组可以作为访问根节点的一组对象集,常见的GC Roots有以下这些
①在虚拟机栈(局部变量表)中引用的对象。
②方法区中静态属性引用的对象(JDK8之后静态变量在堆中)。
③方法区中常量引用的对象(这里指运行时常量池中的常量)。
④本地方法中JNI(Native方法)引用的变量。
⑤JVM内部的引用,如基本数据类型对应的Class对象等。
1.3 凭什么用GC Roots判定对象是否可达?
分析前四种可以作为GC Roots的对象可以发现,他们都有一个特征,这些对象的引用都存在堆内存以外,那为什么不是只存在堆中的对象作为GC Roots呢?因为只存在堆中的对象,比如实例变量,他的最终调用者肯定还是前四种对象(这里可以仔细思考下之前写过的代码)。那这四种常见的可以作为GC Roots的对象,其实就是最常见的引用调用的最外层结构。所以他们作为根节点,向下搜索引用关系是合理的。
1.4 两种算法的优缺点,如何选择
- 引用计数法
优点是判定效率高,缺点是占用一定内存,最重要的缺点就是无法解决相互引用的问题,当对象相互引用时,引用计数器的值最少也是1.如下方代码所示
当我们使用objA=null;objB=null;以后分别各有1个引用指向objA,objB。采用引用计数法这两个引用是无法消除的,因为 objA.instance失效需要先让objA失效,objA失效需先让objB.instance失效(因为objB.instance指向objA),objB.instance想要失效需要objB失效,objB失效需要先让objA.instance失效(因为objA.instance指向objB),所以这样就陷入了死循环,导致objA、objB的引用计数器一直都是1,便无法被虚拟机回收,这也就是引用计数法的最大弊端。 如下图:objA.instance = objB; objB.instance = objA; objA = null; objB = null;
- 可达性分析算法
优点是不必为每个对象都分配一小块内存用以存储引用计数,能轻松解决相互引用等其他场景。缺点也是相对于引用计数来说的,判定效率不如引用计数法,因为可达性分析算法需要根据对象和引用链的关系去判断是否有GC Roots可达,不可达才会判定为垃圾,这个过程相对于引用计数来说无疑是更为复杂的。在Hotspot中真正使用的也是可达性分析算法来判断对象是否为垃圾的。
1.5 强、软、弱、虚引用与可达性分析算法
说到垃圾回收不得不提的就是java中定义的四引用类型,强、软、弱、虚引用,这四种引用类型与垃圾回收密切相关,了解这四个引用,可以更好的帮我我们理解可达性分析算法判定垃圾的过程。
-
强引用
强引用指的是最传统的引用定义,指的是我们在写java代码时最常用的一种引用赋值,如Object obj = new Object();这也是强、软、弱、虚定义之前定义引用的方式。
何时回收强引用?
强引用的引用本身就是可以作为GC Root的存在,所以只要引用关系存在,那么强引用就一直不会被回收,所以在不使用时置null即可,对于方法内部的强引用,退出方法后引用就会失效,堆中内存就会失去GC Root就会被定义为不可达了。 -
软引用
软引用用来描述那些还有用,但是非必须的对象,使用SoftReference来修饰对象,使用get方法可以获得被弱软引用引用的对象,定义时如下:Object obj = new Object(); String str = "abc"; SoftReference<String> sf = new SoftReference<String>(str); String strRefe = sf.get(); System.out.println(strRefe); 输出结果: abc Process finished with exit code 0
何时回收软引用?
jvm会在内存吃紧时,会尝试去清理软引用,但是不一定会能回收的了软引用的对象,jvm只是在即将OOM时会去尝试清理堆中的软引用对象,如果清除不了还是会报OOM的。如果有人看到过这种说法“jvm会在内存溢出前,清空软引用引用的对象”,请知晓,这是错误的,下面验证下这种错误的说法(因为很多人这么说),代码如下:import java.lang.ref.SoftReference; import java.util.ArrayList; import java.util.List; public class TestHeapOom { static class OOMObject{ String name; public OOMObject(String name){ this.name = name; } } // 虚拟机配置:-Xmx10M -Xms10M public static void main(String[] args){ int n =0; List<OOMObject> list = new ArrayList<OOMObject>(); SoftReference<List<OOMObject>> sr = new SoftReference<List<OOMObject>>(list); System.out.println(sr.get().size()); while (true){ list.add(new OOMObject("a"+n)); System.out.println(sr.get().size()); } } }
上方程序是用软引用关联一个list,然后建立一个循环不断的往list中加入对象,这种情况10M的堆内存很快会用光,正常情况下内存溢出,会报OOM:Java heap space,这里输出如下展示:
106280 106281 106282 106283 106284 106285 Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded at java.lang.AbstractStringBuilder.<init>(AbstractStringBuilder.java:68) at java.lang.StringBuilder.<init>(StringBuilder.java:89) at sgcc.supplier.pojo.model.queues.TestHeapOom.main(TestHeapOom.java:23) Process finished with exit code 1
这里抛出的OOM:GC overhead limit exceeded 也有可能抛出OOM:Java heap space , 首先解释下这个GC overhead limit exceeded错误的意思,该错误表示cpu98%的时间都在做内存回收,但是回收到的内存依然很小,不足以支撑系统使用,系统即将崩溃。就会报这种问题,介绍软引用时已经说明了,软引用会在内存溢出之前被尝试回收,因为在上面的例子中内存即将溢出,jvm便在一直尝试回收软引用list,但是list仍是可达状态,回收不了,但系统又一直尝试,便出现了这个错误:OOM:GC overhead limit exceeded。所以说“jvm会在内存溢出前,清空软引用引用的对象”这种说法是错误的(如果Java heap space更可以说明问题了),回不回收软引用对象最终依据还是可达性分析算法,jvm只是会在内存紧张时尝试回收软引用指向的对象。,换一种说法“jvm会在内存溢出前,清空只被软引用引用的对象”这种说法就是正确的。
-
弱引用
弱引用被用来描述那些非必须的对象,java中使用WeakReference来描述弱引用,使用get方法可以获得被弱引用引用的对象。弱引用的使用方法如下:String str = "abc"; WeakReference<String> wf = new WeakReference<String>(str); String strRew = wf.get(); System.out.println(strRew);
何时回收弱引用?
弱引用对象在下次垃圾回收时系统会尝试进行回收,如果对象不可达就会被回收掉,对象如果是可达状态依然不会回收,验证代码如下:import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.List; public class TestHeapOomWeak { static class OOMObject{ String name; public OOMObject(String name){ this.name = name; } } // 虚拟机配置:-Xmx10M -Xms10M public static void main(String[] args){ int n =0; List<OOMObject> list = new ArrayList<OOMObject>(); WeakReference<List<OOMObject>> wr = new WeakReference<List<OOMObject>>(list); System.out.println(wr.get().size()); while (true){ list.add(new OOMObject("a"+n)); System.out.println(wr.get().size()); } } }
输出结果如下,可见系统一直在进行垃圾回收,即将崩溃,但是引用却没有失效,所以弱引用并不在垃圾回收时一定被回收(这里这么解释是因为很多人说会在下一次垃圾回收时回收掉)回不回收弱引用对象最终依据还是可达性分析算法,jvm只是会在下一次GC时尝试回收弱引用。
106273 106274 106275 106276 106277 106278 106279 106280 106281 Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded at java.lang.Integer.toString(Integer.java:401) at java.lang.String.valueOf(String.java:3099) at java.io.PrintStream.print(PrintStream.java:597) at java.io.PrintStream.println(PrintStream.java:736) at sgcc.supplier.pojo.model.queues.TestHeapOomWeak.main(TestHeapOomWeak.java:25) Process finished with exit code 1
但是如果某个对象只被弱引用引用了,那下一次垃圾回收就会把该对象回收掉。验证代码如下:
import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.List; public class TestHeapOomWeak { static class OOMObject{ String name; public OOMObject(String name){ this.name = name; } } public static void main(String[] args){ int n =0; //List<OOMObject> list = new ArrayList<OOMObject>(); WeakReference<List<OOMObject>> wr = new WeakReference<List<OOMObject>>(new ArrayList<OOMObject>()); System.out.println(wr.get().size()); while (true){ wr.get().add(new OOMObject("a"+n)); System.out.println(wr.get().size()); } } }
如下方所示,输出变成了空指针,而不是OOM,因为在GC后只被弱引用关联的对象被回收了,后面通过弱引用get的对象就成了null,便报了空指针。
28532 28533 28534 28535 Exception in thread "main" java.lang.NullPointerException at sgcc.supplier.pojo.model.queues.TestHeapOomWeak.main(TestHeapOomWeak.java:24) Process finished with exit code 1
-
虚引用
又被称为灵幻引用,使用PhantomReference定义虚引用,使用get方法得到的永远是null,同时虚引用定义时必须传入一个ReferenceQueue 队列,对象被清理时,该对象的引用会被放入该队列中,被虚引用关联的对象无法通过get方法获得该对象的实例(软引用,弱引用都可以)所以只被虚引用关联的对象其实立马就会被回收掉,虚引用的声明方式如下String str = "abc"; ReferenceQueue rq = new ReferenceQueue(); PhantomReference<String> pf = new PhantomReference<String>(str,rq); System.out.println(pf.get());
2.分代收集理论
学习垃圾收集算法与垃圾收集器之前是必须要知道分代收集理论的,这样才能明白三种不同的垃圾收集算法产生的原因,才会明白各个垃圾收集产生的根源在哪里。正是因为有了分代收集理论,所以才有了垃圾收集算法,有了垃圾收集算法才有了垃圾收集器。
2.1 什么是分代收集理论
收集器应该将Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄就是对象熬过垃圾收集的次数)分配到不同的区域进行存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么他们应该放在一起,每次回收时只关注如何保留少量存活的对象而不是标记那些大量将要被回收的对象,这样就能以较小的代价回收到大量的内存空间(这里在JVM中对应的便是新生代);如果剩下的对象都是难以消亡的,那把他们放在一起,那么虚拟机就可以使用较低的频率去回收这块区域(这里在JVM中对应的便是老年代),这样就同时兼顾了垃圾收集的时间开销与内存空间有效利用。这便是分代收集理论。
2. 2 分代收集理论建立的基础
分代收集理论名义上是一种理论,其实是一套符合大多数程序实际运行的经验法则它建立在两个分代假说之上,如下所示,另外还有一种对前两种假说进行补充说明的跨代引用假说。
①. 弱分代假说:绝大多数对象都是朝生夕灭的,这个假说奠定了新生代的雏形。
②. 强分代假说:熬过越多次垃圾收集的对象就越难以消亡,这个假说奠定了老年代的雏形。
③. 跨代引用假说:跨代引用相对同代引用只占极少数,这个假说是为了解决回收新生代对象时,有老年代对象引用了新生代中对象进行提出的。
2. 3 JVM对分代理论的实践
在JVM中依据分代收集理论,根据对象的存活特征,划分出了新生代(Young Generation)、老年代(Old Generation),有了新生代、老年代的划分,那么针对新生代、老年代的垃圾收集器也就应用而生了,所以才有了新生代收集(Minor GC)、老年代收集(Major GC)、全局收集(Full GC)、混合收集(Mixed GC)。
注:有人把Major GC当做整堆收集,这种也不算错,在JDK9之后,服务端虚拟机最常用的老年代收集器之一CMS,已经不支持配套的新生代收集器,取而代之的是为CMS设置了默认的新生代解决方案ParNew,所以JDK9之后说CMS代表了整堆收集也不错,但是Major GC 肯定和Full GC 不能划等号。
2. 4 根据分代收集理论对收集器分类
3.垃圾收集算法
上面已经介绍了分代收集理论,分代收集理论奠定了堆中年轻代和老年代的划分,那新生代或者老年代到底采用了哪种垃圾收集算法来收集垃圾呢?就需要说一说垃圾收集算法了,垃圾收集算法是垃圾收集器的方法论,了解了这些方法论,对垃圾收集器的工作原理也就清楚了。
3.1 标记-清除算法
- 什么是标记-清除算法?
最早出现也是最基础的垃圾收集算法便是“标记-清除算法”该算法分为标记、清除两部分,回收对象时先标记待清除对象,标记完成后清除这些被标记的对象(也可以标记存活对象,清除未被标记的对象),标记的过程就是判定对象是否是垃圾的过程(使用可达性分析算法)而且后续的垃圾收集算法大多都是以“标记-清除算法”为基础进行改进的。 - 优点
最基础简单的垃圾收集算法,为后续垃圾收集算法奠定了基础。 - 缺点
①.执行效率不稳定,随着对象的不断增多,该算法的标记、清除的效率就会不断下降,造成执行效率不稳定,比如新生代中绝大部分对象都是朝生夕灭的,就不适合这种算法。
②.内存空间碎片化,标记清除过后会产生大量不连续的内存碎片,空间碎片太多会导致再需要分配大对象时找不到合适的空间,从而提前或频繁出发GC。 - 应用
标记-清除算法的实现最常用的就是CMS了,该垃圾收集器的收集区域是老年代(下一篇详细介绍这两种垃圾收集器)(下一篇分详细介绍这一种垃圾收集器)。
3.2 标记-复制算法
- 什么是标记-复制算法?
将内存划分为大小相等的两块,每次只使用其中一块,当第一块内存使用完了,就把这块内存上的对象复制到另一块未使用的内存上,然后清空第一块内存,这种回收对象的算法被称为“标记-复制算法”,该算法是基于“标记-清除算法”演变而来。 - 优点
①解决了“标记-清除算法”的执行效率不稳定问题,该算法主要应用于新生代,新生代对象大部分都是朝生夕灭的,新生代被划分为Eden、From Survivor、To Survivor,每次使用Eden和一个Survivor用于新生对象的存储。 垃圾回收时就把Eden、From Survivor中存活的对象复制到另一个Survivor上,这样便不会有执行效率不稳定的情况,因为大部分对象都是朝生夕灭的(该优点是相对于新生代来说)。
②解决了“标记-清除算法”的空间碎片化问题,因为是清空Eden From Survivor所以不会再有不连续的空间碎片。 - 缺点
①“标记-复制算法”因为必须有一部分空间时刻空闲着,所以会有一定的空间浪费
②在极端情况下To Survivor区域不一定能存储的了新生代存活下来的对象,所以需要分配担保策略(对象进入老年代) - 应用
标记-复制算法实现常用的有:Serial、ParNew、Parallel Scavenge。这三款垃圾收集器收集目标都是新生代(下一篇详细介绍这三种垃圾收集器)。
3.3 标记-整理算法
- 什么是标记-整理算法?
标记-复制算法有他的自身缺陷,这种场景很适合新生代,但对于大部分对象都是存活下来的老年代就不适用了,老年代中对象存活过多,复制动作的内存开销就会很大,所以针对老年代的特性,提出了“标记-整理算法”,标记动作与“标记-清除算法”、“标记-复制算法”中的标记一样都是使用的可达性分析算法对对象进行标记,整理部分则是将存活的对象向内存空间一端进行移动,然后直接清理掉边界以外的内存区域。 - 优点
①解决了“标记-清除算法”的空间碎片化问题。②解决了“标记-复制算法”需要分配担保的问题。 - 缺点
根据强分代假说“熬过越多次垃圾收集的对象,越难以被回收”,老年代中的大部分对象都是年龄达到了16的对象,都是很难被回收的,所以采用“标记-整理算法”去移动对象,对应用程序的吞吐量其实影响很大,但是不得不使用“标记-整理算法”,因为“标记-清除算法”会浪费一定空间,“标记-复制算法”又必须有分配担保策略也需要浪费空间,且“标记-复制算法”也无法满足老年代中所有对象都存活的极端情况。 - 应用
标记-整理算法实现常用的有:Serial Old、Parallel Old。这两款垃圾收集器的收集目标都是老年代(下一节详细介绍这两种垃圾收集器)。
3.4 对比标记-清除算法与标记-整理算法
为什么单独比较这两种算法呢,因为老年代中既有使用“标记-清除算法”实现的CMS垃圾收集器,也有使用“标记-整理算法”实现的Serial Old、Parallel Old等垃圾收集器,而“标记-复制算法”在典型情况下只用于新生代(G1是例外,这是一种区别于典型垃圾收集器的收集器),所以这里之比较这两种算法的优劣,“标记-清除算法”与“标记-整理算法”的根本区别在于在回收对象时,对象是否发生了移动,换种说法就是,“标记-清除算法”是一种非移动式的垃圾收集算法,而“标记-整理算法”是一种移动式的垃圾收集算法。在“标记-清除算法”中因为对象不移动,只是清除被标记的对象,这样就产生了很多的空间碎片,致使在新对象进来时会导致可能没有足够的空间进行存储新对象,从而提前或频繁触发GC,而且因为空间是碎片化的,对象的分配必须依赖“空闲列表”,每次对象进来要先通过空闲列表来查找堆中哪些区域是空闲且足够该对象的分配,无形中就增加了程序的时间成本,降低应用的吞吐量,所以CMS作为“标记-清除算法”实现的垃圾收集器更为关注的是STW状态的延时,而不是应用程序的吞吐量,总结一句话“标记-清除算法”在分配对象阶段更为复杂,“标记-整理算法”是移动式回收算法,在老年代中大部分对象都是存活的,因此在回收对象时,会伴随大量的对象移动,从而会导致对象回收阶段会占用相对多的时间,因此采用“标记-整理算法”实现的Parallel Old是更为关注吞吐量的一款垃圾收集器,总结一句话,“标记-整理算法”在回收对象阶段更为复杂。
4.经典的垃圾收集器
因为有了分代收集理论所以有了垃圾收集算法,有了垃圾收集算法就有了垃圾收集器。这里说的经典垃圾收集器,并不是说这些垃圾收集器多么的优秀,因为随着JDK版本的不断更新,新的垃圾收集器越来越多,这些在JDK9及之前使用的垃圾收集器自然就成为了相对经典的版本。
先附上一张各个垃圾收集器之间的关系图,之间有连线表示可以配合使用,中间有JDK 9标志的表示JDK9开始已经不支持这种搭配(CMS 与 Serial Old配合使用另有原因后面会说明):
4.1 新生代收集(Minor GC)
4.1.1 Serial 收集器
- 特性
Serial 采用“标记-复制算法”实现,是最基础、也是历史最悠久的垃圾收集器,曾经是新生代的唯一解决方案,Serial 是单线程运行的,不仅是单线程垃圾回收,而且在进行垃圾回收时会暂停其他所有的所有线程,暂停其他所有线程的这种行为又被称为Stop The World,简称STW,这个名词会在后面的垃圾收集器中经常看到。 - 使用场景
Serial 虽然问世十分的早,但依然是一款活跃的垃圾收集器,到目前为止,它依然是客户端虚拟机上新生代默认的垃圾收集器(客户端默认参数:-XX:+UseSerialGC,含义是使用Serial+Serial Old),与其他垃圾收集器相比,Serial具有简单、高效的特点,对于内存资源受限的环境,它是所有收集器额外内存消耗最小的,所以很实用客户端的虚拟机。 - 与老年代收集器的搭配
①. Serial 可与 Serial Old配合使用,这是客户端虚拟机默认的垃圾收集组合。虚拟机参数设置:-XX:UseSerialGC。
②.Serial 可与 CMS 配合使用,JDK9中取消了该种组合的使用,可见该种组合性能并不是多好。 - 与该垃圾收集器相关的虚拟机配置参数
-Xss256k,设置虚拟机栈和本地方法栈大小
-Xmx10m,设置堆最大内存
-Xms10m,设置堆最小内存
-Xmn5m,设置新生代大小
-XX:+UseSerialGC,使用Serial + Serial Old的垃圾收集器组合
-XX:SurvivorRatio=8,新生代中Eden占10份中的比例,默认就是8。
-XX:+PrintGCDetails,告诉虚拟机在发生GC时,打印回收日志(JDK9之前有效)
-XX:MaxTenuringThreshold=15,对象年龄大于该值就会进入老年代,Parallel Scavenge中默认值为15,CMS中默认值为6,G1中默认值为15
-XX:PretenureSizeThreshold=3145728,晋升老年代的对象的大小,大于该值直接进入老年代,这里是3M,该值只能写成以字节为单位的形式。
附上一张在IDEA中设置虚拟机参数截图:
该配置下运行main(main里面就一行无关代码,这里不展示了)方法的结果,如下:
从上面的输出可以看出eden space 8192K,这是8M和我们配置一样,【tenured generation total 10240K】老年代10M也和我们配置一样,没有问题。F:\java\bin\java.exe... Heap def new generation total 9216K, used 2794K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000) eden space 8192K, 34% used [0x00000000fec00000, 0x00000000feebaa78, 0x00000000ff400000) from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000) to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000) tenured generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000) the space 10240K, 0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000) Metaspace used 3207K, capacity 4496K, committed 4864K, reserved 1056768K class space used 356K, capacity 388K, committed 512K, reserved 1048576K Process finished with exit code 0
附上一张Serial 收集器的导图:
4.1.2 ParNew 收集器
- 特性
ParNew同样采用标记算法实现,事实上ParNew就是Serial的多线程版本,除了使用多线程对垃圾进行回收外,其余所有场景均与Serial相同(包括Serial支持的配置参数、STW、对象分配回收策略等),但随着JDK9禁用参数-XX:+UseParNewGC,ParNew成为了CMS的新生代解决方案,不再支持单独的参数配置,也成为了第一个退出历史舞台的垃圾收集器,在单核或者伪双核机器中Serial 要更优于ParNew,在双核以上机器中ParNew表现还是优于Serial的。 - 使用场景
ParNew在JDK9只后被取消是有原因的,ParNew主要是为了配合CMS使用的,而JDK中新增加的G1,就是为了取代CMS而设计的。JDK9之前,ParNew可以与Serial Old或者CMS收集器配合使用。 - 与老年代收集器的搭配
ParNew可以与Serial Old搭配,使用参数-XX:+UseParNewGC(JDK9取消了该参数)设置,ParNew与CMS搭配时,使用参数-XX:+UseConcMarkSweepGC设置(JDK9取消了该参数)。 - 与该垃圾收集器相关的虚拟机配置参数
Serial支持的参数ParNew均支持(非收集器设置参数),此外因为ParNew是多线程所以可以设置线程运行的个数
-XX:ParallelGCThreads=3,代表垃圾回收线程最多可以3条同时运行。
-XX:+UseParNewGC,使用ParNew + Serial Old 的垃圾收集器组合(JDK9取消了该参数)。
-XX:+UseConcMarkSweepGC,使用ParNew + CMS的垃圾收集器组合(JDK9取消了该参数)。
下面展示下IDEA下面设置虚拟机参数截图:
可以看到设置的垃圾收集器是ParNew + Serial Old的组合,运行main方法后有如下输出(main里面就一行无关代码,这里不展示了),从输出标红的地方可以看出,已经提示我们,ParNew在未来版本中可能会被废除。
附上一张ParNew的导图:
4.1.3 Parallel Scavenge 收集器
-
特性
Parallel Scavenge 使用“标记-复制算法”实现,也是一款多线程垃圾收集器,他的很多特性都和ParNew相同,但它也有自己的显著特点,比如这是第一款比较关注应用程序吞吐量(可以看做一定时间内应用程序支持的用户操作次数)的垃圾收集器,与之相对的CMS则是第一款比较关注延时的收集器(延时指STW的时间),此外Parallel Scavenge 还是这三种Minor GC中唯一不能与CMS配合使用的收集器。 -
使用场景
Parallel Scavenge可以与Serial Old配合使用,这是在Parallel Old未出现之前的无奈之选。Parallel Old面试以后,Parallel Scavenge + Parallel Old 的组合便成了标准的吞吐量有限的垃圾收集器组合,同时这个组合也是JDK9之前,服务端虚拟机默认的垃圾收集器组合。 -
与老年代收集器的搭配
①.Parallel Scavenge + Serial Old,使用参数-XX:+UseParallelGC设置
②Parallel Scavenge + Parallel Old,使用参数-XX:+UseParallelOldGC设置。 -
与该垃圾收集器相关的虚拟机配置参数
与Serial相同,除了关于收集器的设置参数,该收集器都是支持的,自然也包含-XX:ParallelGCThreads设置垃圾收集线程个数的参数。此外该收集器还支持一些较关注程序吞吐的参数配置。如下所示:
-XX:MaxGCPauseMillis=200,最大停顿时间(STW),200是毫秒,也是默认值,该值不可一味追求小,过小会提前或者频繁触发GC,这个参数也是通过改变回收集的大小来实现的。
-XX:GCTimeRatio=99,该值代表垃圾收集时间占了1/(1+99)。如果是19则表示垃圾收集占了1/(1+19)的时间。
-XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开开关后,虚拟机会根据用户设置的上限两个参数来动态调整-Xmx、-Xms、-Xmn、-XX:SurvivorRatio、-XX:PretenureSizeThreshold等参数,这也是该收集器区别于ParNew的重要特征。
来测试下-XX:+UseAdaptiveSizePolicy这个看起来很牛气的参数,附上一张IDEA的参数截图:
截图中可以看出是没有设置堆、栈、Eden比例等参数的。看下输出看看使用Parallel Scavenge + Parallel Old 的组合后这些参数变成了了什么,从下图可以看出新生代大约是55M,老年代大约是127M左右,这说明了虚拟机为我们设置了它认为的合理参数。
下面我们写个程序验证下这些参数到底会不会动态扩展,还是说设置完以后便不动了,代码如下:public class TestSerial { public static void main(String[] args){ List<String> list = new ArrayList<>(); int n = 0; while(true){ n++; String str = new String("123"); list.add(str); if(n%10000==0){ System.gc(); } } } }
根据上方的代码,如果堆空间不变化,那么迟早程序会OOM,上面的运行方法展示的堆空间是130048k,我们看下下面的运行截图
从上面的截图中我们可以看到,老年代在不断的被填充,老年代的使用这么增长下去,理论上一会就会被填满,然后报OOM,实际上的运行结果却是下面这样,下图中第一处标红老年代还是原来的大小,但是再一次增长后,老年代内存就变大了一部分,说明虚拟机感觉老年代不够用,对老年代空间进行了动态调整,事实上只要程序继续运行下去,老年代值就会不断增长下去,直至到达物理内存的瓶颈,这也证明了-XX:+UseAdaptiveSizePolicy这个动态调整虚拟机参数的开关确实是好使的。
附上一张Parallel Scavenge的导图:
4.2 老年代收集(Major GC)
这部分用以总结在老年代中发挥作用的垃圾收集器
4.2.1 Serial Old 收集器
-
特性
Serial Old 采用“标记-整理算法”实现,从名字上可以看出该收集器与新生代收集器Serial很像,事实上该收集器与Serial确实是类似的,它是老年代版本的Serial,特点上也是与Serial类似,都是单线程工作,该收集器运行时也是需要暂停其他线程的(STW)。 -
使用场景
①.Serial + Serial Old 到目前为止仍是客户端模式下虚拟机的默认垃圾收集器
②.Parallel Scavenge+ Serial Old 这个组合是在JDK5及之前服务端模式下默认的垃圾收集器,JDK6提供了Parallel Old后服务端模式下变成了Parallel Scavenge + Parallel Old收集器组合
③.虽说随着JDK版本的不断更新,Serial Old不在作为服务端模式下的老年代解决方案,但是它仍是会在使用CMS时作为一种解决CMS发生Concurrent Mode Failure的解决方案,CMS发生Concurrent Mode Failure表示多线程回收垃圾以及不能满足用户线程的运行下新对象的分配,这是就会启用Serial Old对老年代进行单线程回收。 -
与新生代收集器的搭配
①Serial + Serial Old ,使用参数-XX:+UseSerialGC设置,也是客户端虚拟机默认参数。
②Parallel Scavenge + Serial Old,使用参数-XX:+UseParallelGC设置,这是JDK5及之前服务端默认参数。
③ParNew + CMS + Serial Old ,使用参数-XX:+UseConcMarkSweepGC设置,这是JDK9之后的搭配场景(JDK取消了ParNew的其他搭配,将其作为CMS的新生代解决方案,不在单独存在) -
与该垃圾收集器相关的虚拟机配置参数
除了自己独特的几种搭配不同新生代收集器的配置参数,其他可以说与Serial的配置参数基本一样,这里只展示与Serial 不一样的配置参数:
-XX:+UseParallelGC,使用Parallel Scavenge + Serial Old收集器组合。
-XX:+UseConcMarkSweepGC,使用ParNew + CMS + Serial Old收集器组合(JDK9中参数)。
这里只能演示下使用-XX:+UseParallelGC该参数的场景了,IDEA配置参数如下图。
正常运行main方法后输出如下,可见参数配置时生效状态
附上一张Serial Old的导图:
4.2.2 Parallel Old 收集器
- 特性
Parallel Old 采用“标记-整理算法”实现,从名字上可以看出该收集器与新生代的Parallel Scavenge收集器类似,事实上该收集器就是Parallel Scavenge的老年代版本,功能上也是类似,只不过该收集器的收集区域是老年代而已,此外Parallel Scavenge + Parallel Old,还是第一种比较关注吞吐量的收集器组合。 - 使用场景
Parallel Old只能与Parallel Scavenge收集器配合使用,该收集器开发出来就是为了搭配Parallel Scavenge使用的,同时Parallel Scavenge + Parallel Old 的收集器组合还是服务端模式下默认的垃圾收集器组合(JDK9之前,JDK9默认是G1)。 - 与新生代收集器的搭配
只能与Parallel Scavenge配合使用,值的注意的是-XX:+UseParallelGC,配置使用的是Parallel Scavenge + Serial Old组合。 - 与该垃圾收集器相关的虚拟机配置参数
与Parallel Scavenge参数配置相同,这里不重复介绍了。
附上一张导图:
4.2.3 CMS(ConcurrentMarkSweep) 收集器
- 特性
CMS 收集器是一款基于“标记-清除算法”实现的收集器,从它的名字可以看出,它是并发的,这个并发指的是可以与用户线程并行执行,之前的老年代收集器无论是Serial
Old,还是Parallel Old都是在收集对象是都是需要全程暂停用户线程的,CMS做到了与用户线程并行,在垃圾收集时基本不暂停用户线程(暂停时间很短,基本忽略不计),因为和用户线程并行因此就必须考虑一种情况,如果在并发收集期间收集到的内存并不足以支撑用户新对象的分配的情况,这时就会发生Concurrent Mode Failure,虚拟机会暂停CMS收集动作,转而使用它的担保策略Serial Old暂停用户线程,进行单线程收集。它也是第一款比较成功的低延时收集器的尝试,不过有意思的是虽然CMS是第一款追求响应时间小的收集器,但是它并不能支持该参数-XX:MaxGCPauseMillis,在虚拟机配置中设置该参数是不会起作用的。 - 使用场景
JDK9之前CMS可以与Serial、ParNew配合使用,最常用的使用组合还是与PawNew配合使用,因为ParNew是多线程收集,CMS同样是支持并发的,但是在JDK9之后,ParNew不在作为单独的收集器提供服务,而是被作为CMS的新生代解决方案,同时其他与ParNew相关的参数也失效了,ParNew也是第一款退出历史舞台的收集器,CMS相对于Parallel Old的多线程收集,优点在与能与用户线程并行,那为什么不使用CMS作为服务端默认的垃圾收集器呢,因为CMS对处理器资源非常敏感,当在4核以下的服务器中使用CMS作为收集器,效率并不是很高。 - 与新生代收集器的搭配
①可以与Serial 搭配使用,不过不是一种合适的选择,当老年代选择CMS时,服务器的处理器应该至少在4核以上,此时选用Serial会降低性能,但是这是一种可行的搭配策略。
②可以与ParNew搭配使用,ParNew支持多线程收集,相对于Serial不会有资源浪费,能获得更好的回收效率。这也是G1出现之前,多核处理器建议的垃圾收集组合。 - CMS 收集器的收集过程
CMS 回收对象的过程,相对于之前介绍的垃圾收集器,都更为复杂,因此介绍下CMS回收对象主要的4个步骤,①初始标记、②并发标记、③重新标记、④并发清除。
①初始标记:暂停用户线程,时间很短,仅仅标记可以与GC Roots直接关联的对象。
②并发标记:不暂停用户线程,时间较长,从GC Roots直接关联的对象开始向下遍历对象图。
③重新标记:暂停用户线程,时间很短(长于初始标记远短于并发标记),因用户线程时并行,此阶段主要是标记因用户线程并行而造成的标记变更的对象。
④并发清除:不暂停用户线程,时间中等,删除掉被判定为垃圾的对象,值得注意的是被标记的不一定被清除,“标记-清除算法”中声明过,可以标记垃圾对象,然后清除,当存活对象较少时也可以标记存活对象,清除未被标记的对象,这就是CMS的大致收集过程。
附上一张CMS运行的示意图,这个图可以很直观看出CMS运行的过程:
- 与该垃圾收集器相关的虚拟机配置参数
正常的栈、堆、晋升老年代大小、Eden比例、并行线程数CMS同样都是支持的(参考Serial、ParNew),这里只介绍CMS独特的配置参数。
-XX:+UseConcMarkSweep,使用ParNew + CMS的组合,JDK9之前多核(4核以上)服务器的首选。
-XX:CMSInitiatingOccupancyFraction=92,这是个百分值,默认92,设置堆空间使用率达到多少时触发CMS工作,该值不宜太高,过高容易造成并行期间用户线程产生的对象无处分配,导致Concurrent Mode Failure。
-XX:+useCMSCompactAtFullCollection,这是一个开关参数,默认就是开启的,打开时在触发Full GC时会先对堆空间进行整理,因为CMS使用“标记-清除算法”实现,堆中会有空间碎片的问题,所以也有一种说法说CMS是一种“标记-清除算法”与“标记-整理算法”的和稀泥式实现(这里的堆空间整理是依赖Serial Old实现的,所以不能算是CMS,CMS还是基于“标记-清除算法”的)。
下面测试下-XX:+UseConcMarkSweep、-XX:CMSInitiatingOccupancyFraction=50,这个配置,另外一个默认开启,虚拟机配置如图:
从上图可以看出我们设置的是堆空间100M,使用比例达到50%触发CMS,下面看下输出日志,从日志中可以看出CMS基本就是在堆内存占用即将达到50%时触发,不可能保证百分百精确,说明这个参数配置是有用的。
- 总结CMS的优缺点
- 优点:
①CMS是支持用户线程并行的,这是一个优于之前收集器的很大的点。
②CMS是一款成功的低延时垃圾收集器,尽管不支持延时的配置参数,并不影响他的优秀。
③CMS在多核处理器的服务器上表现会优于其他老年代收集器。 - 缺点:
①CMS在多核服务器上的表现是优点那么在4核以下的处理器中就是致命的缺点了,CMS默认开启的回收线程数是(处理器核心数+3)/4,当处理器核心数在4以下时,垃圾收集线程占用的cpu资源就会在25以上,这就会导致应用程序的吞吐量下降。
②因为CMS是基于“标记-清除算法”实现的,所以CMS收集对象后肯定会有空间碎片,因此CMS不得不与Serial Old搭配使用,在发生Concurrent Mode Failure时使用Serial Old对堆空间进行整理,从而消除CMS收集后留下的空间碎片。
③因为CMS与用户线程是并行的,所以如果在并行期间如果产生过多“浮动垃圾”也会导致Concurrent Mode Failure。
最后附上一张CMS的导图:
- 优点:
4.3 混合代收集(Mixed GC)
混和收集器也就是不区分新生代和老年代的收集器,这里以G1为代表,他的出现是划时代的。
4.3.1 Garbage First 收集器(G1)
-
什么是G1 收集器
G1是一款基于“标记-整理算法”的收集器,同时局部还有“标记-复制算法存在”,G1是垃圾收集器发展史上里程碑式的成果,它开创了面向局部收集的思路与基于Region的内存布局形式,G1被设计的初衷是用来取代JDK5发布的CMS收集器,因此在JDK9之后G1也被设置成了服务端模式下默认的垃圾收集器取代了Parallel Scavenge + Parallel Od的组合,而CMS在JDK9只有则沦落到称为不被推荐的收集器,而且在未来的版本中CMS可能会被抛弃。既然G1设计之初就是作为CMS的替代品存在,那么作为第一款成熟的低延时收集器CMS具备的低延时功能,这个特点G1肯定也是必须具备的,但是与CMS不同的是G1支持-XX:MaxGCPauseMillis这个参数配置,可根据这个参数来控制想要达到的延时时间。此外在G1中堆里不在明确的划分出新生代、老年代、Eden、Survivor等区域,而是以Region为基本单位,堆中划分出了很多的Region空间,每个Region都可以充当新生代、老年代、Eden、Survivor等区域,当然不同的区域也会有不同的回收策略,因此G1不再是单独面向新生代或者单独面向老年代组建回收集,而是会根据各个Region空间的回收价值去去动态组建回收集,回收内存的依据不再是根据对象所处的分代,而是各个Region的回收收益价值,然后组建出一个可能既含有充当了新生代Region又含有充当了老年代Region的回收集,着也就是G1的Mixed 模式,俗称混合收集。 -
使用场景
G1作为一款划时代的收集器,它可以独立的完成整堆的垃圾收集工作,不需要与其他垃圾收集器配合,JDK9以后G1作为服务端模式下默认的垃圾收集器登上历史的舞台,取代了之前的Parallel Scavene + Parallel Old收集器组合。 -
G1如何解决跨代引用问题
我们都知道,在传统的被划分为新生代、老年代的堆内存模型中,新生代中会有一块记忆集来维护与老年代中相关的引用,这样就避免了回收新生代时因为跨代引用而扫描整个堆。那么G1是怎么解决这个问题的呢,在G1中每个Region中都维护了一个记忆集,这个记忆集在存储结构上本质是一个哈希表,就像我们最常用的HashMap一样,数据结构都是哈希表,哈希表的典型结构就是键值对了。在G1的记忆集中,key是其他Region的起始地址,value则是一个集合存储了卡表的索引号,这记录了“谁指向我”与“我指向了谁”,G1就是通过这种记忆集来实现了跨代引用,因为G1会把堆划分为多个Region且每个Region都会维自己的记忆集,因此G1收集器要比其他传统的收集器有着更高的内存占用,根据经验G1要耗费堆中10%-20%的内存来维持收集器的工作。 -
G1如何实现用户线程与收集线程的并行
用户线程与收集线程并行最先应该考虑的问题就是,不能因为用户线程的并行而打破原本的对象图结构,这样搞会导致对象的标记失去意义,CMS采用的是增量更算法实现,在并发标记结束后,的下一阶段重新标记中将并行期间产生的对象增量更新进入对象图,而G1则是使用的原始快照算法来实现的,G1为每个Region设计了连个名为TAMS的指针,使用这两个指针把Region中的一部分区域划分出来,咱们用于程序并行期间的新对象的分配,这样也不会影响到并行期间原始对象图的结构。既然都是拥有与用户线程的并行能力,那与CMS类似的是,当CMS并行期间预留的内存不足以新对象的分配时会Concurrent Mode Failure,从而触发Full GC(实际上是Serial Old),G1也会在这种情况下触发Full GC,从产生长时间的STW。 -
G1如何建立可靠的停顿时间模型
CMS虽然设计初也是作为一款低停顿时间的收集器,但是CMS并不支持-XX:MaxGCPauseMillis参数,真正停顿多少时间还是看虚拟机自身的运行情况,与CMS不同的是G1则支持该参数的配置,那G1是怎么实现该机制的呢?G1的停顿时间模型是以衰减均值为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,然后根据这些信息分析得出平均值、标准偏差、衰减平均值等,衰减平均值更能代表“最近的”平均状态,因此也更能体现Region的回收价值,然后根据这些信息和用户期望的回收时间来动态组建回收集,而不是想其他收集器那样去把垃圾全部回收掉。 -
G1的回收对象的主要过程
G1作为CMS的替代者,自然不会对对象的处理完全相同,不然也达不到改进的效果,看下G1回收内存的四个主要步骤
①初始标记:暂停用户线程、时间很短,该阶段仅仅标记与GC Roots直接关联的对象,且调整TAMS指针的值,方便在下一阶段存储用户线程需要分配的对象。
②并发标记:不暂停用户线程、时间较长,从GC Root直接关联的对象开始遍历对象图,同时通过STAB记录下有引用变动的对象。
③最终标记:暂停用户线程,时间很短,处理下STAB记录下有引用变动的对象。
④筛选回收:CMS的第四步叫并发回收,这里叫筛选回收很明显它需要暂停用户线程、时间较短,G1建立的可靠的时间停顿模型也是在这一步完成的,这一步负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,然后根据用户期望的停顿时间来制定回收计划,动态组建回收集,然后把决定回收的那部分的Region的存活对象复制到空的Region中,在清理掉这个旧的Region的空间(所以说G1局部也存在“标记-复制算法”)。
从G1回收对象的四个主要步骤可以看出,除了并发标记外,其他步骤都是需要暂停用户线程的,换言之,他并非纯粹的追求低延时,官方给他设定的目标是在延时可控的情况下获得尽可能高的吞吐量,所以才能担当的起“全功能收集器”的重任与期望。
附上一张G1收集器工作的流程图
-
G1的停顿时间如何设置
毫无疑问,可以由用户指定停顿时间是一个很强大的一个功能,但是这个时间也一定要合理设置,合理的设置停顿时间可以实现吞吐量与低延时的一个最佳平衡,G1要要通过停顿时间进行复制对象的(主要是干这个事),所以这个值并不是越低越好,设置的很低只会让组建的回收集更小,从而频繁触发GC,这个值在G1中默认是200毫秒,一般100-300毫秒之间都是正常的设置范围,这个值的降低势必会影响到回收集的大小,因此很大程度上都是用空间换时间,所以需要选取一个适中的值,不宜过大过小,从G1开始不再像之前的垃圾收集器一样,一次回收都是关注整个新生代、老年代,G1开创了只回收部分区域的先河,每次只需要保证回收到的区域足够用户使用就能保证程序的运行,这样也就实现了程序边运行,收集器边收集内存,应用程序不在需要程序等待收集器完全完成工作才能继续工作了。所以说G1是收集器发展的一个里程碑。 -
对比CMS与G1收集器
既然设计G1的初衷是为了取代CMS,那我们肯定要分析下G1到底在哪些方面胜过了CMS从而能让JDK9开始G1成为了服务端模式下虚拟机的默认收集器。
相同点:两者都是关注低延时的收集器,主要回收流程都是经历4个部分。
CMS的优点:①相比于运行G1收集器,运行CMS内存占用率更低(随着物理机的发展,这个已经不能算是G1的缺点了)。②CMS可认为是并行执行,因为最为耗时的并发标记、并发清除都是可以与用户线程并行的,而G1只有并发标记是并行的,G1里最重要的筛选回收是需要暂停用户线程的,G1是在筛选回收阶段,才能确立回收目标。
G1的优点:①CMS使用“标记-清除算法”实现,会有空间碎片产生,而G1使用“标记-整理算法”不会有碎片,即使局部有“标记-复制算法”同样不会产生空间碎片。②G1可以设置最大停顿时间,达到一个可控的时间模型,CMS不支持。③G1可以动态组建回收集,无需整堆回收,而CMS则是整堆回收(JDK9之后),G1的动态组建回收集,用户体验会更好。
总结以上两者优缺点:在小内存的服务器上,CMS表现更优秀一些,在大内存(8G以上),核心数更多的服务器上使用G1则能或得更好的回收效果。 -
与该垃圾收集器相关的虚拟机配置参数
-Xss256k,设置虚拟机栈大小
-Xmx10m,设置堆最大内存
-Xms10m,设置堆最小内存
-XX:ConcGCThreads=2,并发标记阶段使用线程数,可适当高一点。
-XX:G1NewSizePercen=5,新生代占用堆最小值,默认5%,该参数在jdk9之前默认不开启,如需使用此参数还需要增加其他配置才可
-XX:G1MaxNewSizePercent=60,新生代占用最大值,默认60%,该参数在jdk9之前默认不开启,如需使用此参数还需要增加其他配置才可
-XX:MetaSpaceSize=10m,方法区最小值(元空间)
-XX:MaxMetaSpaceSize=10m,方法区最大子,默认-1,没有最大
-XX:InitiatingHeapOccupancyPercent=92,触发G1的内存使用率
-XX:SurvivorRatio=8,Eden区域所占10份中的比例
-XX:ParallelGCThreads=2,收集线程的个数一般与服务器核心数相同
-XX:MaxTenuringThreshold=15,设置进入老年代对象的年龄。
-XX:+UseG1GC,使用G1收集器
-XX:MaxGCPauseMillis=200,设置最大停顿时间,默认就是200毫秒
-XX:G1HeapRegionSize=2,设置每个Region的大小,该值取值范围是1-32,且必须是2的n次幂,当对象的值达到Region设置的值的一半时,被设为大对象会存入humongous区域,更大的对象存储在N个连续的Humongous Region中,G1中的大多数行为都把Humongous Region看做老年代的一部分。
三、类的加载与执行
这部分总结java程序编译源文件产生的class文件的与他的结构,及其相关部分知识点
1.class文件结构
class文件结构若是说的比较细,会非常占据篇幅,所以这里不会介绍的特别的详细,不然会把这篇文章撑的特别大,如果需要详细了解,可以看看笔者的另外一篇文章:class文件结构详解,这里会把大部分点尽可能说清楚,已帮助我们理解JVM内部如何处理class文件的。class文件结构从JDK1.0开始就没有太大的变动,所以说这部分只要掌握了基本可以说就是一劳永逸的了,不会像JVM内存分布会随着收集器改变而改变,也不会像收集器那样,不断的更新,因为每一代JDK版本的发布都会做到兼容以前的版本,必须保证以前的程序在新版的JDK上运行是可行的,这也保证了class文件结构的相对稳定性,下面看下class文件的各个结构以及作用。
1.1 什么是class文件
JVM并不认识java格式文件,它所能执行的都是class文件,java程序通过javac编译器将java文件转化为class文件,然后就可以被虚拟机执行了,所有的虚拟机都是执行class文件,这也使得java文件呢一次编译后在其他机子上也是可以执行的,也就是java语言所说的一次编译处处运行了。class文件是一组以8字节为基础单位的二进制流,各个数据项目(魔数、版本。。。)严格按照顺序排列在一起,中间没有间隔符,遇到8个字节以上的空间存储时,则会按照高位在前的方式分割成若干个8个字节进行存储,这就是class文件,总结一句话class文件就是存储java编译后可被JVM识别信息的文件,里面数据以8字节为单位,各数据项有序排列,无间隔。
1.2 什么是无符号数?表?集合?
在说class文件的具体结构之前,必须要先介绍下这三种结构,因为class中的各项信息均是存储在这三种结构之中的。
- 无符号数
顾名思义无符号数就是一组没有符号的数据,是一种基本的数据类型,无符号数有1个字节(u1),2个字节(u2),4个字节(u4),8个字节(u8)这些结构,主要用以存储数字、索引引用、数量值或者字符串值(UTF-8编码后),它是class文件里面存储数据的最小单元。 - 表
无符号数是class文件存储数据的最小单元,表则是由多个无符号数组成的数据类型(多个表构成的数据项也叫表),表的命名习惯以“_info”结尾。主要用以描述拥有层次关系的复合结构数据。整个class文件其实也可以视为一张表(各种信息分层次存储)。 - 集合
集合其实就是无符号数或者表,之所以叫集合是因为它存储的是多个无符号数、或者表。既然存储的是多个那虚拟机是需要知道具体存储的无符号数或者表的个数的,因此在集合的开头会有一个u2类型的数据用以存储集合中数据项的个数叫容量计数器,这样就构成了一个集合。集合顾名思义用以存储相同类型的多个数据。
总结这三个结构我们可以看出,其实存储的最小结构都是无符号数,多个无符号数构成表,多个无符号数加前置的容量计数器构成集合。他们的本质都是无符号数变化而来。
1.3 魔数与Class文件的版本号
-
魔数(Magic Number)
class文件的第一个u4结构存储的就是魔数,魔数的唯一作用就是供虚拟机辨别是否是可执行的class文件,有人会说不是有后缀名辨别文件类型了吗,用魔数岂不是多余,其实也不是多余,使用魔数作为文件的辨别可以增加安全性,因为后缀名是可以随便更改的,class文件的魔数是0xCAFEBABE,而且并不是只有class文件才有魔数,比如常见的后缀为jpg、jpeg、png、gif、zip、jar等等这些文件都是有魔数的,如下图,我们使用WinHex这款十六进制编辑器打开一个class文件看下是否是0xCAFEBABE,很明显就是了。
-
版本号
紧接着魔数存储的就是版本号了,版本号占用一个u4,前两个u2存储次版本,后一个u2存储主版本,次版本其实在JDK1.2到JDK12之间是没有用处的。只有在JDK12之后,java文件中如果使用了预览功能,则会在生成的class文件中次版本号存储为65535。这也是次版本目前的唯一用处了。主版本则是比较主要的一项,它存储的是JDK的版本号,这个值在JDK1.0和JDK1.1使用的是45,往后都是JDK发布一个大版本这个值就相应的增加1,到了我们常用的JDK8时,该值就是52了,为什么要表示JDK的版本号呢,因为虚拟机必须要保证向下兼容,以前虚拟机编译出来的文件必须在当前虚拟机是可以执行的,此外虚拟机是拒绝执行高于当前虚拟机版本的class文件的。也就是说JDK8的虚拟机是执行不了JDK9编译的class文件的但是JDK7,JDK6等之前的虚拟机编译的class文件都可以执行。
下面展示下次版本、主版本,这是16进制打开的文件,34转换成10进制也就是52了。
总结魔数与版本号可以发现,这一块内容会随着虚拟机的固定而固定,同一个虚拟机下不会因为文件的不同而不同(JDK12之前),此外前面也说过class文件是一组以8个字节为基础单位的二进制文件,魔数与版本号则是占用了第一个八字节的数据项。
1.4. 常量池
-
什么是常量池
这里说的常量池是class常量池,我们常见的常量池有class常量池、运行时常量池、字符串常量池。这三种常量池是三种东西,这里简单说下,class常量池是存储在class文件里面的静态数据,运行时常量池在方法区里存储的是被加载后该class文件的字面量与符号引用,字符串常量池在堆中,专门用于存储字符串常量。言归正传那什么是常量池呢(class常量池)?常量池顾名思义用以存储常量的池子,class文件里面所有的常量都会存储在这个池子里面,他是class文件的资源仓库,也是与其他项交集最多的一项结构,主要存储的常量是字面量、符号引用。字面量就是我们在类中定义的常量、字符串等等(常量比如局部变量int,class被加载后存储在了局部变量表,字符串则会进入字符串常量池),符号引用则是类或接口的全限定名,方法和字段的名称和描述符,方法的句柄类型等信息,这就是常量池。 -
常量池的特点
常量池是class文件里的第一个表结构型数据,前面已经说过表是由多个无符号数,或者多个表机构构成的。常量池就是由很多个表构成,因为是多个表构成所以他的开头是一个u2类型的容量计数器,存储的是该常量池中表的个数,常量池的容量计数器与其他不同,该计数器是从1开始真正计数的代表的是各个常量的索引,0不指向任何表,而是代表“不引用任何一个常量池中项目”的含义,下图看下常量池的数量是0x2B,代表十进制的43,则表示常量池中有42个表结构数据。
我们使用javap -verbose 后面跟上class文件名可以查看,该class文件的字节码内容。我们看下该文件的字节码信息常量池是不是42个常量,信息如下,可以清晰看出总共是有42个常量(表)存储在常量池中。
先解释下上面常量池表的结构,第一列#1、#2等是索引号,也是存储的序号,第三列Methodref、String、Fieldref存储的则是表的类型,第四列存储的则是当前表存储的信息,第五列双斜杠后表示当前结构存储的具体的值起到说明的作用相当于注释,从上面的图片我们不仅可以看到常量池中有42项常量表,图中出现的有Utf8、String、Fieldref、Methodref等等这些表,那常量池中都有哪些表结构呢? -
常量池中有哪些表结构
常量池中总共有17种表结构(截止JDK13),用以存储类中的字面量与符号引用,这些信息在虚拟机解析时会根据索引号找到具体值被加载进虚拟机中,这17种表结构涵盖了所有的java信息,所有的表如下所示:
1.5 标志位
紧挨着常量池的一个u2类型数据就是标志位了,标志位用以存储当前类或者接口的访问标志,有:是否是public、是否是abstract、是否是final等等,总共有9种类的修饰信息如下图
其中该标志ACC_SUPER比较特殊,在JDK1.0.2之后就必须是真了,所以标志位的最小值就是0x0020了,那如果多个标志都是true,标志位是如何表示的呢,在多个标志位都是true时,会对其对应的标志值进行相加,得到的值就是标志位的展示值了,我们通过标志位展示的值很容易就可以推断出哪些标志是true的。
1.6 类索引、父类索引、接口索引集合
这一项存储的信息主要就是确定当前类的类全限定名、父类全限定名、实现的接口的全限定名,看到这里肯定有人会有疑问,这部分信息不是在常量池中声明过了吗,这些都是属于符号引用,前面说过常量池相当于一个资源仓库,这里的类全限定名、父类全限定名、接口全限定名引用的都是常量池中的信息。类索引、父类索引都是各使用一个u2类型的数据存储,java支持多实现所以接口索引集合使用多个u2类型的数据进行存储。
1.7 字段表集合、方发表集合、属性表集合
这些都是集合结构的数据,前面我们已经介绍过集合的定义,集合是相同数据结构的无符号数或者表多个汇集在一起,加上前置的容量计数器来构成的。他们分别用于存储字段、方法、属性等相关的信息
- 字段表集合
字段表用以描述类或接口中声明的变量,java语言中说变量默认是指类变量与实例变量,是不包含局部变量的,因此字段表肯定是不存储局部变量的。一个字段表分为三个部分,每个部分个占用一个u2结构:访问标志、字段简单名称(字段名)、字段描述符(描述字段类型)。 - 方发表集合
一个方法表也是有三个部分(与字段表相同)访问标志、方法名称索引、方法描述符索引三项。与字段表基本一致,不一致的地方是每个方发表都会有自己的属性表集合,因为方法的代码会存储的code这个属性表中(很少有没有代码的方法)。 - 属性表集合
属性表集合是class文件结构里要介绍的最后一项了,属性表集合并不会单独存在,会和字段表、方发表配合使用,作为这些表的补充存在,这里存储的信息很多,比如常见的方法编译后的代码是存在code属性表中的,方法中定义的异常是存在Exceptions表中的,同样的这块内容也是很多,且自己实现的编译器是支持新增属性的。
2.字节码指令介绍
2.1 什么是字节码指令
字节码指令是操作系统中的概念,一般包含两部分①操作码②操作数。操作码是一个占用了一个字节代表了某种特殊含义的二进制数,该数值是被预先定义好了会执行某种操作的。操作数通常跟在操作码之后,可以是0至多个,就像方法里的入参一样。他们俩一起就构成了一个字节码指令。在JVM虚拟机中采用的是面向操作数栈的架构。所以只有操作码的存在,操作数一般存放在操作数栈中,配合操作码一起完成虚拟机的一次指令。
- 操作码的类型
我们写的所有程序都是需要依赖操作码来完成的,我们可以回忆下平时写的程序:比如对象的创建、类型的转化、数据的加减乘除处理、方法调用、异常处理、方法的同步、代码块的同步等等。这些能想到的操作都是操作码和操作数共同协作完成的。而且操作码的命名一般都会尽量保持与数据类型相关,尽量会做到见名知意。比如int类型对应的加减乘除的操作码就是:iadd、isub、imul、idiv。这样我们一看就知道这些操作码是操作int类型的。下面介绍下几种常用的操作码。
2.2 操作指令
-
对象创建相关指令
普通对象:new
数组对象:newarray、anewarray、multianewarray
设置变量值与访问变量值指令:getfield、putfield、getstatic(类变量)、putstatic(类变量)
将值(操作数栈中)存入数组指令:bastore、castore、sastore、iastore、fastore。。。
获取数组长度指令:arraylength
java中只要不是语法糖实现的功能其实都会对应有响应的指令,这些指令有很多,也不需要都完全张给我,在我看来我们只需要掌握一些常见的就可以。下面会继续列出几种常见的指令。 -
数据运算相关的指令**
加:iadd、fadd、ladd、dadd
减:isub、fsub、lsub、dsub
乘:imul、fmul、lmul、dmul
除:idiv、fdiv、ldiv、ddiv
求余:irem、frem、lrem、drem
取反:ineg、fneg、lneg、dneg
等等其中byte、short、boolean、char都是使用int的操作指令,他们四个是没有单独的操作指令的,但是存储(局部变量表、操作数栈中)时依然存储的是他们本身的类型。 -
方法调用与方法返回指令
这部分指令还是比较重要的,java中常说的解析和分派就和方法调用指令息息相关,所以这块还是需要记住的,下面具体介绍下这些指令(解析和分派需要说到很多这里不去讲解了)。
①invokevirtual指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派)。
我们测试下这个指令,写了如下代码:public class TestSuper { public TestSuper(){ } public void test(){ } public static void mian(String[] args){ TestSuper testSuper = new TestSuper(); testSuper.test(); } }
根据invokevirtual指令的描述,那么我们在执行testSuper.test();这段代码时应该是使用的该指令,然后我们使用javap 指令看下class的字节码信息,如下图:
从上方图片中我们可以轻易的看到在调用test方法时使用的就是invokevirtual指令。
②invokeinterface指令:用于调用接口方法,它会在运行时搜索一个该接口的实现对象,寻找到合适的方法进行调用。可以采用上面的方法进行验证,这里不做重复工作了。
③invokespecial指令:用于调用一些需要特殊处理的实例方法,比如构造器、私有方法、父类的构造器、父类的方法等都需要依赖这个命令来实现,这个也是很好验证的,但是需要说下另一个点,做一个小小的延伸,我们都知道this、super都是java中的关键字。this关键字的实现方式是隐式传参,那么super呢,这里不去解析this关键字是隐式传参的验证了,也不去验证super不是隐式传参的验证了。想要弄清楚这块的话可以去查下相关资料或者私聊我一起探讨。我们直说结论super的实现机制并不是隐式传参。我们看下invokevirtual这个指令的那段代码,其中有一个构造器。我们一样看下这个构造器对应的字节码信息:
图中标红的就是super关键字的底层实现了,从这行指令我们可以看出当前指令正在调用的是Object的无参构造器,super在编译器编译后会被解释成invokespecial指令并携带参数取调用父类构造器,这就是super的实现机制了,并不是像有些人说的this、super都是隐式传参。
④invokestatic指令:从名字上看应该大家都会明白,该指令就是专门用于调用类方法的。这里不做重复验证了。
⑤invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。我们用的JDK8中的lamdba就是依赖这个指令才实现的。 -
同步指令
我们在写代码时经常会用到同步操作,比如常用的synchronized关键字。虚拟机中也会提供关键字对应到同步指令,不过方法的同步并不是依赖指令完成的而是方法表中会有一项是修饰符ACC_SYNCRONIZED,用以来表示方法是否是同步的,若是同步方法执行该方法的线程就必须持有一个锁,在执行完之后才会放掉,其他线程才有机会拿到这个锁。但是代码块的同步实现还是需要字节码指令来完成的,虚拟机提供monitorenter、monitorexit这两个指令用以支持synchronized这个关键字。
3.双亲委派模型
类都是通过类加载器被加载进虚拟机中的,那这个类加载器有哪些呢?我们平时写的代码又是通过什么类加载器被加载进虚拟机中的呢?类加载器的工作模式又是什么呢?带着疑问一起去学习下双亲委派模型与类加载器。
3.1 什么是类加载器
我们都知道java文件的生命周期有7个主要步骤,第一步就是“通过全限定名来获取描述类的二进制字节流”,实现这个动作的代码就是“类加载器”了。
3.2 类加载器的作用
- 将类加载进虚拟机中,这也是类加载器的功能点。
- 用于区分类型信息,虚拟机中判断两个类型信息是否相等必须满足两个条件,首先类必须是相同的类,第二加载他们的类加载器也必须相同,否则这两个类依然不是相等的。
下面我们自己实现个类加载器,去加载一个类,然后与系统使用默认加载器加载的该类进行比较看下。
上述代码实现了一个简单的类加载器,我们使用这个类加载器加载了当前类,然后与默认加载器加载的类型相比结果如下:public class TestClassLoader { public static void main(String[] args){ ClassLoader classLoader = new ClassLoader() { @Override public Class<?> loadClass(String name) throws ClassNotFoundException { try { InputStream in = getClass().getResourceAsStream(name.substring(name.lastIndexOf(".")+1)+".class"); if (in == null){ return super.loadClass(name); } byte[] by = new byte[in.available()]; in.read(by); return defineClass(name,by,0,by.length); } catch (IOException e) { e.printStackTrace(); throw new ClassNotFoundException("类未找到"); } } }; try { Object obj = classLoader.loadClass("sgcc.supplier.pojo.model.queues.TestClassLoader").newInstance(); System.out.println(obj.getClass()); System.out.println(new TestClassLoader().getClass()); System.out.println(obj instanceof sgcc.supplier.pojo.model.queues.TestClassLoader); System.out.println(new TestClassLoader() instanceof sgcc.supplier.pojo.model.queues.TestClassLoader); } catch (InstantiationException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (ClassNotFoundException e) { e.printStackTrace(); } } }
从上方输出结果可以看到无论是使用我们自己编写的类加载器加载的obj,还是使用默认加载器加载的新建对象类型信息一模一样,然后我们分别用他们与类型信息比较然后发现,我们自己加载的是false,默认的是true。到这里也验证了上面的说法,只有相同类加载器加载的同一个类型,他们才是相同的,不然他们就不是同一个类型,类加载器的这个特性,也是双亲委派模型存在的意义,往下卡就会知晓。class sgcc.supplier.pojo.model.queues.TestClassLoader class sgcc.supplier.pojo.model.queues.TestClassLoader false true Process finished with exit code 0
3.3 常见的类加载器
-
启动类加载器(Bootstrap Class Loader)
该启动器使用C++语言实现,主要用于加载<JAVA_HOME>\lib下的类(主要以jar包的形式存在),该类加载器不能直接被开发人员使用,也是类加载器中唯一不能被开发人员直接使用的类加载器,他的主要作用就是加载java自身的类库。 -
扩展类加载器(Extension Class Loader)
使用java代码实现的ExtClassLoader该类就是扩展类加载器,继承自ClassLoader,主要用于加载<JAVA_HOME>\lib\ext目录下的类(主要以jar包的形式存在),该目录下是支持用于对java类库进行扩展的一个类库,用户可以将自己扩展的类放入到这个目录中,就会被扩展类加载器加载进虚拟机,我们使用时就可以直接调用。该类是java代码实现是可以直接被开发人员使用的。 -
应用程序类加载器(Application Class Loader)
使用java代码实现的AppClassLoader该类就是应用程序类加载器,继承自ClassLoader,主要用于加载用户类路径上所有的类库,如果开发中不指定类加载器,那么默认使用这个类加载器。此外应用程序类加载器有时也被称为“系统类加载器”。
除了这三种类加载器以外,还可以自己定义类加载器,自己定义也很简单,我们只需要继承ClassLoader这个抽象类,并实现loadClass方法即可。
3.4 什么是双亲委派模型
如下图展示的那样的类加载器之间的层次关系就被称为“双亲委派模型”,文字描述就是:所有的类加载器都应有自己的父类加载器,除了顶层的启动类加载器。
3.5 为什么需要双亲委派模型
- 用一个类加载器加载所有类不行吗
我们可以发现类加载器大致有四种,启动类加载器这是加载java类库,扩展类加载器这是加载扩展类库的,应用程序类加载器这是加载我们自己写的类的,我们也可以自己实现类加载器,这样就会有多个类加载器存在,我都用一个类加载器加载不就行了?为什么要整这么多呢?答案肯定是不能只用一个类加载器去加载所有的类的,我们前面说类加载器的作用时已经说过,类加载器还会被用来区分类型。被同一个类加载器加载的相同类就会被认为是同一个类,如果我们编写了和java类库相同的类来使用同一个类加载器,很容易就会把java类库提供的基础类搞得混乱,对虚拟机来说也是不安全的,所以才会有不同的类加载器加载不同场景的类库。 - 多个类加载器加载同一个类行吗
这么加载首先肯定没有问题,就像前面说的类加载器的作用那样,不同类加载器加载的同一个类,会被认定为不同的类型,假设我们使用了不同的类加载器加载了Object这个类,那么我们使用时就没有办法比对两个对象是不是相等了。因为无论如何他们都不会相等。所以使用多个类加载器加载同一个类也是不友好的。 - 基于上面的原因所以我们需要“双亲委派模型”
看了上面两个问题,相信很多人都已经明白,使用一个类加载器加载所有类库不现实,使用多个类加载器随意加载类库也不行,所以java在1.2时期引入了“双亲委派模型”。
3.6 双亲委派模型的工作过程
如果一个类加载器收到了类加载的请求,他首先不是自己去加载这个类,而是将来类交给他的父类去进行加载,直到传递给了启动类加载器,若是启动类加载器加载不了则会告诉子类加载器,这是子类加载器才会去尝试加载, 若还是加载不了则继续交给子类。这一块的工作流程我们配合各个类加载器的加载范围就会很好理解,比如是一个Object类,该类属于java自身类库进行加载,所以无论是哪个类加载器收到了加载请求最后都会交由启动类加载器进行加载,启动类夹杂器一看正好是在我的加载范围内他就加载了,如果是我们自己编写的类,启动类加载器是不会加载的,最后还是会给到应用程序类加载器进行加载。
3.6 双亲委派模型的优点
- 使用双亲委派模型不会存在java类库被篡改的情况,保证了虚拟机的安全。
- 保证了同一个类只会被加载一次,各个类会随着类加载器的加载具备层次关系,不会出现类型紊乱。
3.7 破坏双亲委派模型
双亲委派模型并不是一个具有强制性约束性的模型,而是官方推荐给开发者的一种类加载器的实现方式,我们也可以完全不按照这个推荐模式来,知道JDK9java模块化出现为止,双亲委派模型出现过3次较大规模的被破坏情况。
-
第一次“破坏”
因为双亲委派模型是JDK1.2才出现的,而在这之前类加载器已经出现了,面对已经存在的用户自己定义的类加载器的代码,双亲委派模型的设计不得不作出了一些妥协去兼容这些已经存在的代码,无法再以技术手段避免loadClass方法被覆盖的可能性,只能新增了一个pretected的findClass方法,在调用父类加载器时,调用该方法执行自己的类加载逻辑。 -
第二次“破坏”
通过双亲委派模型的工作流程我们可以发现,越基础的类他的类加载器就会越靠近启动类加载器,因为这些类都是最容易被调用到的,比如Object、InputStream等等这些都是交由启动类加载器加载的,那么如果有基础类型又要调用会用户代码怎么办呢?这是到底使用什么类加载器加载这个基础类型呢?于是有了线程上下文类加载器(Thread Context ClassLoader),这个类加载器是每个线程都会有一个,可以使用Thread中的setContextClassLoader进行设置,未设置则默认使用父线程的。若是全局都未设置,则使用应用程序类加载器。这是一种父类加载器去请求子类加载器去加载类的模式,是完全与双亲委派模型相反的操作。在JDK6时这一点已经被修复了。 -
第三次“破坏”
就是JDK9引入模块化系统了,模块化系统要求java代码可以像外设设备一样,可以随意更换,无需在进行重启系统,这是后双亲委派模型就已经不再适用了,这时候java系统中的每一个模块都要求有一个类加载器,当需要更换模块时,就把该模块与其对应的类加载器一起替换掉。
4.类的加载过程
4.1 java代码与字节码信息展示
假设有如下代码,Human是一个类,我们想要创建一个该类的对象供使用,下面我们围绕这段代码展开讨论:
pubic class TestCreateObject{
public void test(){
Human human = new Human();
}
}
上方test方法对应的class字节码信息如下图(test方发表信息)
我们可以清晰的看到这一行代码对应的虚拟机指令,这些字节码指令中真正是因为new关键字产生的指令有new指令、invokespecial指令。其他指令dup、aload、astore均于此无关。
4.2 虚拟机类加载的过程
回忆下类被虚拟机加载的过程,如下图,在说对象创建的时候会频繁使用到这流程,所以先列出来供随时观看。
4.3 对象创建过程
- 虚拟机在碰到new指令时,会去检查运行时常量池中是否有Human类的符号引用,根据符号引号去找到这个类的class文件。
- 将Human对应的Class文件加载进入到虚拟机中,这个加载就是上方说话流程图中的加载,同时创建一个class对象。用以反射场景下使用,这里未用到该类的Class对象(java.lang.Class)。
- 当Human的class文件被加载进虚拟机后,虚拟机会对这个class文件的信息进行验证,这是“验证”阶段,比如验证魔数是否是0xCAFEBABE,验证主、次版本号,验证元数据,验证字节码指令、验证符号引用是否真实存在等。
- 当通过了上述的验证后虚拟机会为类变量赋初始值,这是“准备”阶段,这个阶段是专门为类级别的信息赋初始值的,Human中没有类变量则不涉及这个阶段。
- 然后虚拟机就开始去将该类中的符号引用翻译成直接引用,这是“解析”阶段,值得注意的是,解析阶段并不一定发生在这里,也可能是发生在上图中的“初始化”后,这里我们可以就当做在这里发生,以便于理解。将符号引用转化为直接引用后,在使用时就可以直接使用了。
- 到现在为止我们依然没有将该类完全加载完成,这个阶段需要去为类级别的变量赋值,这是“初始化”阶段,Human中没有类级别的变量、或者代码块,所以这块也是省了,注意刚刚说的“准备”阶段是赋初始值,这个阶段是赋程序里面设定的值,并不是一回事。
- 到现在为止Human的类型信息已经完全被加载进了内存中,这里才到了new指令的动作,前面一系列都是因为new指令触发的,并不是new真正要做的事情,虚拟机在堆中开辟一块内存用以存储Human对象,这里对象的分配,有两种方案,如果直接分配在了新生代,那么对象的分配一般使用“指针碰撞”的方式,如果被直接分配在了老年代则有可能使用"指针碰撞"也有可能使用“空闲列表”这主要取决于被分配的区域是否有对空间整理的能力。而且对象分配时虚拟机还会采取安全策略,一般使用CAS+失败重试的方式老保证线程安全,当然也有使用TLAB(本地线程分配缓冲)来保证线程安全的,这是一种为每个线程都预先分配一小块内存的方式。到现在为止一个没有任何数据的对象已经在虚拟机中产生了,一个对象在堆中分为三个部分:对象头、实例数据区、对其补充。
- 虚拟机在分配完内存后,会将除了对象头中以外的信息都进行初始化为0的操作,所以我们在日常开发中,实例变量不进行初始化是可以正常使用的,但是局部变量不实例化却不可以使用。
- 接下来,虚拟机需要为Human的对象设置必要的信息,首先是对象的头部信息,比如GC分代年龄、对象的哈希码等的设置,另外对象的头部中还存储了另外一个比较重要的信息“类型指针”,该指针指向了Human的元数据。
- 在以上所有操作都完成以后,其实一个不包含任何数据的对象就产生了,因为他的实例数据区域都是默认值0,刚刚一开始我们就说过,new关键字在被编译后变成了两条字节码指令,一个是new指令,一个是invokespecial指令,上面这些过程都是new指令完成的,既然new指令的活干完了,肯定就会轮到invokespecial指令了。该指令是虚拟机中5种方法调用指令之一,用以调用构造器、私有方法、父类方法等。这里自然是调用了Human的构造方法了
从上方的标红部分可以看出,该指令的参数就是Human的构造器方法了,该部分运行后就会为对象中的实例数据区域进行赋值了。这样一个完整的对象就在堆中建立了起来,然后虚拟机将Human对象的地址给到占中human这个变量。这就完成了整个过程。
静态连接、动态连接、静态分派、动态分派、单分派、多分派、虚方法、非虚方法
java对象创建过程
四、JVM工具介绍
上面已经介绍了JVM的基础知识,学习基础知识就是用来解决实际问题的,不过想要解决JVM的问题,还需要借助一些工具,这个章节用来总结这些工具的使用。
这里先前置介绍一个命令:jps(Java Process Status)是一个用来列出Java进程的工具。它是一个Java命令行工具(JDK携带),用于显示当前运行的Java进程的详细信息,包括进程ID(PID)、类路径(Classpath)、主类(Main Class)以及传递给Java虚拟机(JVM)的启动参数。
比如使用 jps -lm
jps -lm
展示信息如下,第一列为java程序的id,第二列是启动类程序,第三列是传入参数:
如果想要看jvm的参数,可以增加参数v的传入:
jps -v
1.jmap
是 Java 虚拟机(JVM)的一个诊断工具,它允许用户查询 Java 进程的内存映射信息,以及获取 Java 堆(Heap)的使用情况。
1.1 使用jmap查看内存中的对象信息及其大小:jmap -histo
# 进程号传入上方jps查看的进程号
jmap -histo 进程号
# 可以将信息输出到文件中
jmap -histo 12345 > ./obj.log
展示信息如下:
- num:序号
- instances:实例数量
- bytes:占用空间大小
- class name:类名称,[C is a char[],[S is a short[],[I is a int[],[B is a byte[],[[I is a int[][]
1.2 使用jmap查看堆信息: jmap -heap
直接使用命令如下:
# 12345 是使用jps查看到的进程号
jmap -heap 12345
从上面的信息可以看到,堆中信息的各种配置,以及目前使用的垃圾收集器以及他们的各种参数等,这些信息对于线上信息的实时排查还是很有用的。
1.3 jmap导出堆内存dump: jmap -dump
直接使用如下命令即可
# format=b 二进制形式导出,file 声明dump文件 最后加进程号
jmap -dump:format=b,file=file.dump 25928
# 还可以如下写,没有任何区别,注意hprof与dump后缀都是可以的
jmap -dump:format=b,file=file.hprof 25928
然后使用dump分析工具将导出的文件进行分析即可,就可以查看到各个对象的情况了,我比较常用的是在线的heaphero:
https://heaphero.io/,这是一个免费高效的在线分析dump文件工具,如果访问不了网络还可以使用JDK自带的jvisualvm来进行分析。
2.Jstack
jstack 是 Java 虚拟机(JVM)的一个诊断工具,它允许用户查看 Java 进程中线程的堆栈跟踪信息。
2.1 jstack 查看线程信息,可用于查看线程死锁
使用如下命令:
# 查看进程12345的线程信息
jstack 12345
# 也可以将其导入到文件,方便查看
jstack 12345 > ./stack.log
如果两个线程死锁了,通过这个信息也是可以观察出来的(无需增加任何参数),如下图,可以看到两个线程互相等待锁了,这个信息可以打印到文件方便查看:
2.2 jstack排查CPU异常升高的问题
-
1.使用jps查看到java进程号,然后使用top命令查看占用的内存信息:
top -p 28771
下面是对top命令的展示解释:Tasks: 显示当前展示的任务数
%Cpu: 显示CPU的使用情况,包括用户态和系统态的CPU使用百分比。
KiB Mem: 显示进程使用的物理内存大小(以KiB为单位)。
KiB Swap: 显示进程使用的交换空间大小(如果有的话,以KiB为单位)。PID: 进程ID。
User: 运行该进程的用户名。
PR: 进程优先级,数字越小,优先级越高。
NI: 进程的nice值,用于调整进程的优先级。
VIRT: 进程使用的虚拟内存大小(以KiB为单位)。
RES: 进程使用的物理内存大小(不包括交换空间)。
SHR: 进程使用的共享内存大小(以KiB为单位)。
S: 进程状态。通常有R(运行)、S(睡眠)、T(跟踪或停止)、Z(僵尸进程)等。
%CPU: 进程CPU使用百分比。
%MEM: 进程使用的物理内存百分比。
TIME+: 进程自启动以来的CPU使用时间。
Command: 运行的命令或程序名。 -
2.按H,获取每个线程的cpu使用情况
如下图,可以看到CPU占用最高的线程id是28882,注意此时最左侧一列展示的是线程id了,同时也包括top查询的进程id。
-
3.将上面拿到的线程id转为十六进制,然后使用jstack查看线程信息
上面已经拿到了线程id是28882,将其转化为16进制是:70d2,# 十进制转十六进制- 小写 printf "%x\n" 13 # 十进制转十六进制- 大写 printf "%X\n" 13
使用如下命令,查看对应进程中的线程信息
# 找出进程28771中 线程id为70d2的信息,-A 10 表示展示匹配行开始的10行信息,如果行数不够可以自行增加 jstack 28771|grep -A 10 70d2
展示信息如下,这里是一个sentinel的守护线程:
到这里我们就已经定位到CPU异常的线程了,如果这个线程有问题,通过这里是可以看到代码的情况的,也可以找到真正的java类和对应的代码。
3.Jinfo
用于实时查看jvm或者系统参数,使用起来也很简单
3.1 使用jinfo查看jvm参数
# 根据进程号查看jvm参数
jinfo -flags 31963
这里可以看到展示了两块区域:
- Non-default VM flags: 非默认的JVM参数配置,注意这里是和下面的Common line对应的信息,可以在这里观看我们配置的结果,这个信息不是默认,而是我们更改后的非默认值(Non-default VM flags名字写的很清楚了,有的人非要说是默认值)。
- Command line: 命令行携带的JVM参数,也就是我们设置的信息
3.2 使用jinfo查看系统变量
使用如下命令:
jinfo -sysprops 31963
执行以后会列出当前的系统变量,有时项目的配置文件会使用系统变量,使用此命令可以一下看到系统变量的值。
4.Jstat
该命令可以实时观测JVM各个区域的情况以及GC的执行情况等,针对JVM的监控工具其实底层就是使用的jstat
jstat [-命令选项] [进程号] [间隔时间(毫秒)] [查询次数]
4.1 jstat查看gc的使用情况与内存情况
使用如下命令,就可以观测到实时的堆内存的使用情况与GC的执行情况,这样可以很好的判断我们参数设置的是否准确
jstat -gc 31963
S0C:第一个幸存区的大小,单位KB
S1C:第二个幸存区的大小
S0U:第一个幸存区的使用大小
S1U:第二个幸存区的使用大小
EC:伊甸园区的大小
EU:伊甸园区的使用大小
OC:老年代大小
OU:老年代使用大小
MC:方法区大小(元空间)
MU:方法区使用大小
CCSC:压缩类空间大小
CCSU:压缩类空间使用大小
YGC:年轻代垃圾回收次数
YGCT:年轻代垃圾回收消耗时间,单位s
FGC:老年代垃圾回收次数
FGCT:老年代垃圾回收消耗时间,单位s
GCT:垃圾回收消耗总时间,单位s
4.2 使用jinfo查看堆使用情况
jstat -gccapacity 31963
NGCMN:新生代最小容量
NGCMX:新生代最大容量
NGC:当前新生代容量
S0C:第一个幸存区大小
S1C:第二个幸存区的大小
EC:伊甸园区的大小
OGCMN:老年代最小容量
OGCMX:老年代最大容量
OGC:当前老年代大小
OC:当前老年代大小
MCMN:最小元数据容量
MCMX:最大元数据容量
MC:当前元数据空间大小
CCSMN:最小压缩类空间大小
CCSMX:最大压缩类空间大小
CCSC:当前压缩类空间大小
YGC:年轻代gc次数
FGC:老年代GC次数
4.3 新生代垃圾回收统计
jstat -gcnew 31963
S0C:第一个幸存区的大小
S1C:第二个幸存区的大小
S0U:第一个幸存区的使用大小
S1U:第二个幸存区的使用大小
TT:对象在新生代存活的次数
MTT:对象在新生代存活的最大次数
DSS:期望的幸存区大小
EC:伊甸园区的大小
EU:伊甸园区的使用大小
YGC:年轻代垃圾回收次数
YGCT:年轻代垃圾回收消耗时间
4.4 新生代内存统计
jstat -gcnewcapacity 31963
NGCMN:新生代最小容量
NGCMX:新生代最大容量
NGC:当前新生代容量
S0CMX:最大幸存1区大小
S0C:当前幸存1区大小
S1CMX:最大幸存2区大小
S1C:当前幸存2区大小
ECMX:最大伊甸园区大小
EC:当前伊甸园区大小
YGC:年轻代垃圾回收次数
FGC:老年代回收次数
4.5 老年代垃圾回收统计
jstat -gcold 31963
MC:方法区大小
MU:方法区使用大小
CCSC:压缩类空间大小
CCSU:压缩类空间使用大小
OC:老年代大小
OU:老年代使用大小
YGC:年轻代垃圾回收次数
FGC:老年代垃圾回收次数
FGCT:老年代垃圾回收消耗时间
GCT:垃圾回收消耗总时间
4.6 老年代内存统计
jstat -gcoldcapacity 31963
OGCMN:老年代最小容量
OGCMX:老年代最大容量
OGC:当前老年代大小
OC:老年代大小
YGC:年轻代垃圾回收次数
FGC:老年代垃圾回收次数
FGCT:老年代垃圾回收消耗时间
GCT:垃圾回收消耗总时间
4.7 元数据空间统计
jstat -gcmetacapacity 31963
MCMN:最小元数据容量
MCMX:最大元数据容量
MC:当前元数据空间大小
CCSMN:最小压缩类空间大小
CCSMX:最大压缩类空间大小
CCSC:当前压缩类空间大小
YGC:年轻代垃圾回收次数
FGC:老年代垃圾回收次数
FGCT:老年代垃圾回收消耗时间
GCT:垃圾回收消耗总时间
4.8 内存分布使用比例展示(快速查看概况)
jstat -gcutil 31963
这张图里可以看出笔者的元空间占用过高,此时是可以适当调高的,不然元空间也是会触发FullGC的。
S0:幸存1区当前使用比例
S1:幸存2区当前使用比例
E:伊甸园区使用比例
O:老年代使用比例
M:元数据区使用比例
CCS:压缩使用比例
YGC:年轻代垃圾回收次数
FGC:老年代垃圾回收次数
FGCT:老年代垃圾回收消耗时间
GCT:垃圾回收消耗总时间
注意:以上所有的命令都是可以支持持续观测的,这里以观测整体使用比例为例,假设间隔5000ms观测一次,总共观测10次,则命令如下:
jstat -gcutil 31963 5000 10
信息展示如下,这样就可以做到动态的监控了。
也是可以将信息直接输出到文件上的
jstat -gcutil 31963 5000 10 > ./jvm.log
5.Jvisualvm
如果没有网络时,肯定不可以使用三方工具进行jvm信息分析了,此时只可以使用JDK自带的工具了,jvisualvm有一个可视化页面,这个命令在linux端如果么UI也无法展示,需要在windows系统进行展示。
打开cmd,输入jvisualvm就可以打开了,如下:
下面是打开后随机选了一个就那些进行展示的结果:
当然远程的jvm我们肯定是看不到的,即使这里支持远程查看,但是远程也不会给开这个权限的,所以只能使用这个工具做dump的分析,其他就算了
5.heaphero
这是一个在线的dump文件分析工具,文件上传速度很快,分析的也很快,可以根据上传的文件提示做到很快的提示,还是挺好使用的,笔者经常使用,推荐给大家:https://heaphero.io/
下面是笔者随意传了一个dump文件的分析图:
五、OOM问题处理实操
1.如何确定JVM各个区域应该分配多大空间
用 jstat gc -pid 命令可以计算出如下一些关键数据,有了这些数据就可以采用之前介绍过的优化思路,先给自己的系统设置一些初始性的
JVM参数,比如堆内存大小,年轻代大小,Eden和Survivor的比例,老年代的大小,大对象的阈值,大龄对象进入老年代的阈值等。
年轻代对象增长的速率
可以执行命令 jstat -gc pid 1000 10 (每隔1秒执行1次命令,共执行10次),通过观察EU(eden区的使用)来估算每秒eden大概新增多少对
象,如果系统负载不高,可以把频率1秒换成1分钟,甚至10分钟来观察整体情况。注意,一般系统可能有高峰期和日常期,所以需要在不
同的时间分别估算不同情况下对象增长速率。
Young GC的触发频率和每次耗时
知道年轻代对象增长速率我们就能推根据eden区的大小推算出Young GC大概多久触发一次,Young GC的平均耗时可以通过 YGCT/YGC
公式算出,根据结果我们大概就能知道系统大概多久会因为Young GC的执行而卡顿多久。
每次Young GC后有多少对象存活和进入老年代
这个因为之前已经大概知道Young GC的频率,假设是每5分钟一次,那么可以执行命令 jstat -gc pid 300000 10 ,观察每次结果eden,
survivor和老年代使用的变化情况,在每次gc后eden区使用一般会大幅减少,survivor和老年代都有可能增长,这些增长的对象就是每次
Young GC后存活的对象,同时还可以看出每次Young GC后进去老年代大概多少对象,从而可以推算出老年代对象增长速率。
Full GC的触发频率和每次耗时
知道了老年代对象的增长速率就可以推算出Full GC的触发频率了,Full GC的每次耗时可以用公式 FGCT/FGC 计算得出。
优化思路其实简单来说就是尽量让每次Young GC后的存活对象小于Survivor区域的50%,都留存在年轻代里。尽量别让对象进入老年
代。尽量减少Full GC的频率,避免频繁Full GC对JVM性能的影响。
2.如何快速定位OOM
这里需要分两种情况一种是即将OOM,系统响应过慢,一种是已经OOM
- 即将OOM
这里需要使用jmap导出dump文件,注意测试会引发STW,所以不要随便dump,在真正需要时进行dump,同时可以使用jstat一起检测系统内存使用情况,然后分析堆内存的占用情况,dump文件的分析工具可以使用笔者上面推荐的heaphero或者jdk自带的jvisualvm进行分析,分析思路先找大对象,再找对象的GC Roots,看看对象到底是为什么无法被回收,然后定位问题。 - 已经OOM
这里需要我们前置增加两个JVM参数:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./jvm.dump,如果没有这个参数,那就恐怖了,增加参数后当系统发生OOM时,我们就可以获取到OOM时的dump文件,这样可以去分析系统内存的占用情况了,分析过程与上面就没有任何区别了
3.如何定位CPU异常飙升
这个问题在上面的jstack已经说了,这里不重复说了。