JVM学习笔记
1、jvm概述
1.1、即时编译
为什么java没有c和c++快
多了一层解释,而即时编译就是将热点数放入内存,下次执行的时候不用解释,提高了效率
1.2、常见的jvm
jvm不止一个,意不意外?惊不惊喜?
1.3、hotspot的发展历程
2、jvm的组成
3、字节码文件的组成
字节码由常量池、接口、字段、方法、属性5部分组成。
推荐用工具jclasslib打开
3.1、基本信息
3.1.1、magic魔数
3.1.2、主副版本号
案例:
3.2、常量池
同意的字符串只存在一个常量池中,使用这个字符串的地方可以通过索引找到这个 常量池(不仅是字符串内的值可以复用,常量名也可以)。节省空间
比如a1,索引指向#8 ,#8又指向#10 ,而#10存的就是实际的字符串abc
a2指向的也是#8,转而引入#10
abc的字段定义
3.3 、方法
我们先看另一断代码学习下几个基本的字节码指令
左边是源码,中间是它的字节码文件,右边内存空间的2部分,一个是操作数栈,一个是局部变量表数组:存的就是局部变量,这里为什么从1开始,是因为0存的是main的入参args.
这里说明下字节码代码,首先是iconst_0,就是将数据0压入操作数栈中,
第二局istore_1,就是将操作数栈取出数据放入局部变量表下标为1的位置中,
上面就是int i =0;部分的字节码解释,然后下面执行int j=i+1;我们知道这段先执行右边,所以
第三步iload_1,从局部变量表下标为1的位置中取出数据放入(复制)到操作数栈中,注意与istore不一样的是这里是复制,istore是移动。
然后第四步将常量1压入操作数据栈中,
然后执行iadd,就是将操作数据栈中最上面的2个数据相加,然后放入栈中。
然后就是将计算结果复制给j,就是执行istore_2,这个命令上面已经解释了,就是将栈的数据弹出放入到下标为2的局部变量表中。
这时候i和j都有值了,最后return。
这时我们再看面试题中场景
iconst_0 将常量0放入到操作数栈中,
istore_1 然后将操作数栈最顶部的值弹出,放到下标为1的局部变量表中,
iload_1 (这里开始执行i = i++,先执行右边i++)然后是i,就是将下标为1的局部变量表中数据复制到操作数栈中
iinc 1 by 1 这一步说明下 ,这里是将局部变量表中下标为1(by前)的增加1(by后面)。这时局部变更表中下标为1的数据变成了1 (0+1)
store_1 这部把操作数栈中数据弹出放到局部变量表中下标为1的位置,这里之前是1,现在又变回0。这里也是关键,为什么答案是0了。
return;
所以问题关键是先取出下标为1的局部变量表的数据放到操作数栈,然后局部变量下标为1的值自增为1,然后将操作数栈的数据放入局部变量表下标为1的位置。发送了覆盖,我们再看下如果是++i是什么流程
注意看 iinc 1 by 1 和iload_1的 位置颠倒了,所以步骤是先局部变量下标为1的值自增为1,然后取出下标为1的局部变量表的数据放到操作数栈。然后将操作数栈的数据放入局部变量表下标为1的位置.
最后看下下面这段代码,哪个执行效率高,
通过字节码可以发现,i++和 k+=1 效率一样,都是一行,执行iinc * by *
j=j+1需要四步,效率最差。
拓展:字节码常用工具
javap 命令
查看字节码 并输出到文件中
idea插件jclasslib
先编译你要看的类,然后view里找到这个 见下图,点击后会右侧就有了
arthas
再jar包目录:
下面的1、2、3、4、5、6、7是展示所有的在运行的java进程(一般包名+类名),可以直接输入数字,就可以进入程序内部,查看一些信息。比如cpu负载,内存,字节码等
这玩意会自动升级到新版本。进入后看到前缀变了,多了进程号,后面所有命令都只对这个进程有效。
dashboard 监控面板
dashboard -i 刷新的时间间隔ms -n 刷新的次数
如果只想查看内存 可以输入memory
查看字节码文件
反编译:
jad 全限定名 可以反编译出java文件
4、类的生命周期
4.1 加载阶段
怎么查看内存中这2个空间
jdk有自带查看内存
D:\java\jdk1.8.0_161\lib>java -cp sa-jdi.jar sun.jvm.hotspot.HSDB
jps 可以查看所有运行的java进程
4.2 连接阶段
验证
准备
准备阶段会在堆中创建一个student的class对象, 给value分配一个内存区域,赋值一个初始值0,
这里不会赋值1,赋值1是后面初始化阶段 。
后面会赋值1,这里为什么要先给0呢,不是等后面初始化,看下面代码
这里如果不给初始值,如果准备阶段又不给值,那么给i分配的内存区域如果之前又残留值,那这里就会打印一段随机值。但是也有一个特殊情况,就是finial
解析
4.3 初始化阶段
看下字节码的方法
第二条注意下,如果forname这个方法重载的,如果是3个参数的,第二个参数是可以控制是否初始化的
什么时候不会生产clinit指令
4.4 总结
5 类加载器
5.1 类加载器的分类
5.1.1 启动类加载器
类启动加载器加载了上述的jar包,给java程序的运行提供了基本的运行环境,
5.1.2 拓展类加载器
注意:如果路径中有空格,会报错,可以加上双引号
5.1.3 应用程序加载器
应用程序加载加载的是classpath下的类和接口文件,包括工程中自己写的以及第三方jar包的类和接口文件。演示如下:
arthas相关功能
使用这个命令你去查看应用程序加载器它不止加载自己编写和第三方jar包中的类和接口文件,还会加载启动类加载器和拓展类加载器加载的jar包 。
5.2、双亲委派机制
双亲委派机制的作用:
定义:
java如何使用代码主动加载一个类
父类加载器
验证
通过arthas工具的classloader -t 可以查看类加载器的父子关系
面试回答什么是双亲委派
个人感觉先说第二点比较好,且第二点说之前,说下java(在jdk8和8之前)中的默认类加载有三个,分别是启动类加载器,拓展类加载器,应用程序加载器。
5.3 打破双亲委派
有时候在特定场景下,我们需要打破双亲委派机制才能实现我们想要的功能
举例:
spi
利用arthas实现热部署
其中获取类加载器的hashcode
注意事项:
5.4 JDK9之后的类加载器
5.5 总结
6 运行时数据区
上一章节说了类加载器,它把字节码文件加载到了内存(运行时数据区)中,这一章节介绍下运行时数据区
按线程共享不共享分了2块区域:
共享的 有堆和方法区(jdk8后叫元空间)
不共享的有:程序计数器、java虚拟机栈、本地方法栈
为什么学习运行时数据区呢(应用场景)?
6.1 程序计数器
6.2 栈
栈帧的组成:
局部变量表
在字节码中找到它
Nr. 就是编号从0开始
起始PC: 指这个变量从哪行字节码开始生效,比如上图i,0和1是定义i=0的字节码文件,从偏移量第2个开始可以访问这个局部变量。长度为3,表示可以用到i的只有偏移量为2、3、4三行,就是它的生效范围就是3.
局部变量是一个数据,long和double占2个长度,其他的包括引用类型都占一个,序号就是这个变量在数组的下标位置。如果这个代码再加一行int m= 1,则m的序号是3(j是long类型,把1和2都占了)
如果是实例方法(非静态方法)序号0会保存this,存的是这个实例对象,然后开始存方法的入参,然后再是方法里定义的局部变量。
这里看着应该9个,实际却占了6个,是因为方法块中变量出了代码块范围就不再生效了,为了节省空间会再次复用。
操作数栈
帧数据
栈内存溢出
本地方法栈
6.3 堆
介绍
堆内存溢出
观察你会发现没用到100%就oom了
设置堆内存大小
6.4 方法区
类的基本信息
要注意下图虽然把常量池和方法归纳到方法区中,实际虚拟机的实现上放在别的内存空间
运行时常量池
就是字节码中的常量池加载到内存后的内容(这个过程:符号引用->直接引用)
方法区的演变
方法区的内存溢出
jdk1.7
JDK8:
为什么需要设置最大元空间大小:服务器上有可能运行别的程序,如果你的程序因代码不当,比如上面例子,导致内存用完,会影响别 的程序正常运行
设置最大元空间大小为256 m后
发生了内存溢出
字符串常量池
字符串常量池和运行时常量池的关系
练习题1的分析可以看出,c的12是在字符串常量池中,d= a+b。编译后会变成new StringBuilder然后append 1和2,最后tostring。这时放在堆中,所以结果为false
题目2编译后“1”+“2”就会转成“12”,结果自然为true
静态变量的存储
6.5 直接内存
直接内存的溢出
设置直接内存大小
6.6 不同jdk版本运行时数据区的差别
7 自动垃圾回收
概述
垃圾回收的应用场景
7.1 方法区的回收
拓展:打印类的加载和卸载
第一个参数可以打印出类加载时候的信息,第二个打印类卸载时的信息
7.2堆回收
概述
下图demo= null 可以去除全部引用
下图a1 =null 和b1.a = null才能去除对象A a在堆中的全部引用
7.2.1 引用计数法和可达性分析法
引用计数法
可达性算法
GC Root不会被回收,虚拟机也会有有个保存所有GCRoot的列表
哪些对象可以称之为GC Root
线程
下面例子main方法作为主线程,当执行a1 =null和b1 =null 时,已经无法引用到2个实例对象了,就可以回收
系统类加载器加载的class对象
这里new A()在堆中占用了一部分内存,它被a2引用,a2不是GC Root,但是a2反向可以找到一个GC Root:系统类加载器加载的class对象是GC root 里面就包含了一个类 Launcher,这个 Launcher对象就报了应用程序类加载器,也有拓展类加载器,这里我们自己写的ReferenceCounting就是被应用程序类加载器加载的,应用程序类加载器就包含了ReferenceCounting的class对象,ReferenceCounting的class对象又包含了a2这个变量,就完成了一个引用链,
监视器对象
本地方法调用时使用的全局对象。
native方法是java虚拟机调用的,无须程序员关注
拓展:使用工具查看GC Root
代码
7.2.2 五种引用对象
软引用
实操
弱引用
虚引用和终结器引用
7.2.3 垃圾回收算法
stw
垃圾回收算法的评价标准:
1、吞吐量;2、最大暂停时间;3、堆的使用效率
7.2.3.1 标记清除算法
7.2.3.2 复制算法
7.2.3.3 标记整理算法
7.2.3.4 分代GC算法
为什么分代GC算法要把堆分成年轻代和老年代?
首先我们要知道堆内存中对象的特性:
-
系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收,比如用户获取订单数据,订单数据返回给用户之后就可以释放了。
-
老年代中会存放长期存活的对象,比如Spring的大部分bean对象,在程序启动之后就不会被回收了。
-
在虚拟机的默认设置中,新生代大小要远小于老年代的大小。
分代GC算法将堆分成年轻代和老年代主要原因有:
1、可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
2、新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。
3、分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(full gc),STW时间就会减少。
7.2.4 垃圾回收器
年轻代-Serial垃圾回收器
Serial是是一种单线程串行回收年轻代的垃圾回收器。
回收年代和算法:
年轻代
复制算法
优点
单CPU处理器下吞吐量非常出色
缺点
多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
适用场景
Java编写的客户端程序或者硬件配置有限的场景
老年代-SerialOld垃圾回收器
SerialOld是Serial垃圾回收器的老年代版本,采用单线程串行回收
-XX:+UseSerialGC 新生代、老年代都使用串行回收器。
回收年代和算法:
老年代
标记-整理算法
优点
单CPU处理器下吞吐量非常出色
缺点
多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
适用场景
与Serial垃圾回收器搭配使用,或者在CMS特殊情况下使用
年轻代-ParNew垃圾回收器
ParNew垃圾回收器本质上是对Serial在多CPU下的优化,使用多线程进行垃圾回收
-XX:+UseParNewGC 新生代使用ParNew回收器, 老年代使用串行回收器
回收年代和算法:
年轻代
复制算法
优点
多CPU处理器下停顿时间较短
缺点
吞吐量和停顿时间不如G1,所以在JDK9之后不建议使用
适用场景
JDK8及之前的版本中,与CMS老年代垃圾回收器搭配使用
老年代- CMS(Concurrent Mark Sweep)垃圾回收器
CMS垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间。
参数:XX:+UseConcMarkSweepGC
回收年代和算法:
老年代
标记清除算法
优点
系统由于垃圾回收出现的停顿时间较短,用户体验好
缺点
1、内存碎片问题
2、退化问题
3、浮动垃圾问题
适用场景
大型的互联网系统中用户请求数据量大、频率高的场景,比如订单接口、商品接口等
CMS执行步骤:
1.初始标记,用极短的时间标记出GC Roots能直接关联到的对象。
2.并发标记, 标记所有的对象,用户线程不需要暂停。
3.重新标记,由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记。
4.并发清理,清理死亡的对象,用户线程不需要暂停。
缺点:
1、CMS使用了标记-清除算法,在垃圾收集结束之后会出现大量的内存碎片,CMS会在Full GC时进行碎片的整理。这样会导致用户线程暂停,可以使用-XX:CMSFullGCsBeforeCompaction=N 参数(默认0)调整N次Full GC之后再整理。
2.、无法处理在并发清理过程中产生的“浮动垃圾”,不能做到完全的垃圾回收。
3、如果老年代内存不足无法分配对象,CMS就会退化成Serial Old单线程回收老年代。
并发线程数:
在CMS中并发阶段运行时的线程数可以通过-XX:ConcGCThreads参数设置,默认值为0,由系统计算得出。
计算公式为(-XX:ParallelGCThreads定义的线程数 + 3) / 4, ParallelGCThreads是STW停顿之后的并行线程数
ParallelGCThreads是由处理器核数决定的:
1、当cpu核数小于8时,ParallelGCThreads = CPU核数
2、否则 ParallelGCThreads = 8 + (CPU核数 – 8 )*5/8
我的电脑上逻辑处理器有12个,所以ParallelGCThreads = 8 + (12 - 8)* 5/8 = 10,ConcGCThreads = (-XX:ParallelGCThreads定义的线程数 + 3) / 4 = (10 + 3) / 4 = 3
最终可以得到这张图:
并发标记和并发清理阶段,会使用3个线程并行处理。重新标记阶段会使用10个线程处理。
由于CPU的核心数有限,并发阶段会影响用户线程执行的性能。
年轻代-Parallel Scavenge垃圾回收器
Parallel Scavenge是JDK8默认的年轻代垃圾回收器,多线程并行回收,关注的是系统的吞吐量。具备自动调整堆内存大小的特点。
回收年代和算法:
年轻代
复制算法
优点
吞吐量高,而且手动可控。为了提高吞吐量,虚拟机会动态调整堆的参数
缺点
不能保证单次的停顿时间
适用场景
后台任务,不需要与用户交互,并且容易产生大量的对象。比如:大数据的处理,大文件导出
常用参数:
Parallel Scavenge允许手动设置最大暂停时间和吞吐量。Oracle官方建议在使用这个组合时,不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小。
-
最大暂停时间,
-XX:MaxGCPauseMillis=n
设置每次垃圾回收时的最大停顿毫秒数 -
吞吐量,
-XX:GCTimeRatio=n
设置吞吐量为n(用户线程执行时间 = n/n + 1) -
自动调整内存大小,
-XX:+UseAdaptiveSizePolicy
设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小
老年代-Parallel Old垃圾回收器
Parallel Old是为Parallel Scavenge收集器设计的老年代版本,利用多线程并发收集。
参数: -XX:+UseParallelGC 或
-XX:+UseParallelOldGC可以使用Parallel Scavenge + Parallel Old这种组合。
回收年代和算法:
老年代
标记-整理算法
优点
并发收集,在多核CPU下效率较高
缺点
暂停时间会比较长
适用场景
与Parallel Scavenge配套使用
G1垃圾回收器
JDK9之后默认的垃圾回收器是G1(Garbage First)垃圾回收器。Parallel Scavenge关注吞吐量,允许用户设置最大暂停时间 ,但是会减少年轻代可用空间的大小。CMS关注暂停时间,但是吞吐量方面会下降。
而G1设计目标就是将上述两种垃圾回收器的优点融合:
1.支持巨大的堆空间回收,并有较高的吞吐量。
2.支持多CPU并行垃圾回收。
3.允许用户设置最大暂停时间。
JDK9之后强烈建议使用G1垃圾回收器。
G1出现之前的垃圾回收器,年轻代和老年代一般是连续的,如下图:
G1的整个堆会被划分成多个大小相等的区域,称之为区Region,区域不要求是连续的。分为Eden、Survivor、Old区。Region的大小通过堆空间大小/2048计算得到,也可以通过参数-XX:G1HeapRegionSize=32m指定(其中32m指定region大小为32M),Region size必须是2的指数幂,取值范围从1M到32M。
G1垃圾回收有两种方式:
1、年轻代回收(Young GC)
2、混合回收(Mixed GC)
年轻代回收
年轻代回收(Young GC),回收Eden区和Survivor区中不用的对象。会导致STW,G1中可以通过参数
-XX:MaxGCPauseMillis=n(默认200) 设置每次垃圾回收时的最大暂停时间毫秒数,G1垃圾回收器会尽可能地保证暂停时间。
1、新创建的对象会存放在Eden区。当G1判断年轻代区不足(max默认60%),无法分配对象时需要回收时会执行Young GC。
2、标记出Eden和Survivor区域中的存活对象,
3、根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区中(年龄+1),清空这些区域。
G1在进行Young GC的过程中会去记录每次垃圾回收时每个Eden区和Survivor区的平均耗时,以作为下次回收时的参考依据。这样就可以根据配置的最大暂停时间计算出本次回收时最多能回收多少个Region区域了。
比如 -XX:MaxGCPauseMillis=n(默认200),每个Region回收耗时40ms,那么这次回收最多只能回收4个Region。
4、后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区。
5、当某个存活对象的年龄到达阈值(默认15),将被放入老年代。
6、部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为Humongous区。比如堆内存是4G,每个Region是2M,只要一个大对象超过了1M就被放入Humongous区,如果对象过大会横跨多个Region。
7、多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值时
(-XX:InitiatingHeapOccupancyPercent默认45%)会触发混合回收MixedGC。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来完成。
混合回收
混合回收分为:初始标记(initial mark)、并发标记(concurrent mark)、最终标记(remark或者Finalize Marking)、并发清理(cleanup)
G1对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是G1(Garbage first)名称的由来。
G1对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是G1(Garbage first)名称的由来。最后清理阶段使用复制算法,不会产生内存碎片
注意:如果清理过程中发现没有足够的空Region存放转移的对象,会出现Full GC。单线程执行标记-整理算法,此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。
G1 – Garbage First 垃圾回收器
参数1: -XX:+UseG1GC
打开G1的开关,JDK9之后默认不需要打开
参数2:-XX:MaxGCPauseMillis=毫秒值
最大暂停的时
回收年代和算法:
年轻代+老年代
复制算法
优点
对比较大的堆如超过6G的堆回收时,延迟可控
不会产生内存碎片
并发标记的SATB算法效率高
缺点
JDK8之前还不够成熟
适用场景
JDK8最新版本、JDK9之后建议默认使用
使用以下代码测试g1垃圾回收器,打印出每个阶段的时间:
package chapter04.gc;import java.io.IOException; import java.util.ArrayList; import java.util.List;/*** 垃圾回收器案例3*/ //-XX:+UseG1GC -Xmn8g -Xmx16g -XX:SurvivorRatio=8 -XX:+PrintGCDetails -verbose:gc public class GcDemo2 {public static void main(String[] args) throws IOException {int count = 0;List<Object> list = new ArrayList<>();while (true){//System.out.println(++count);if(count++ % 10240 == 0){list.clear();} // byte[] bytes = new byte[1024 * 1024 * 1];list.add(new byte[1024 * 1024 * 1 / 2]); // System.gc();}} }
每个region大小为2m,一共有84个young区,26个幸存者区。
初始标记花了0.0478秒。
并发标记总共耗时10ms,不会产生STW。
总结
垃圾回收器的组合关系虽然很多,但是针对几个特定的版本,比较好的组合选择如下:
JDK8及之前:
ParNew + CMS(关注暂停时间)、Parallel Scavenge + Parallel Old (关注吞吐量)、 G1(JDK8之前不建议,较大堆并且关注暂停时间)
JDK9之后:
G1(默认)