文章目录
1. 整体内存模型图
jvm内存关系如图所示。
上边的图会结合Math类进行分析,Math如下所示
//Math类
public class Math {
public static final int initData = 666;
public static User user = new User();
public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.compute();
}
}
Math中的main方法执行流程
- ①:首先
Math.class
由应用类加载器加载到jvm
内存中。 - ②:在线程执行
Math
中的main
方法时,会在jvm
整个栈内存中会给当前线程分配一块内存作为当前的线程栈空间。 - ③:
main()
方法只要一开始运行,会在当前线程的栈空间内部生成main()
方法的栈帧,并压入线程栈底部。栈帧内用来存放main
方法的局部变量,比如math
,在main
方法的栈帧中存储的是Math
对象的对象头地址,用来指向堆空间中Math
对象的位置。 - ④:然后开始执行
compute()
方法,会在当前线程的栈空间内,压入一个compute()
的栈帧(每一个方法对应一个栈帧),栈帧内部包含局部变量表、操作数栈、动态链接、方法出口等。
⑤:等compute()
方法执行完毕,释放栈空间,继续执行main()
方法
①:堆
堆其实就是存储对象的地方,是各个线程共享的内存区域。堆内存结构如下所示:
- 年轻代:默认占堆内存的
1/3
- 伊甸园区:默认占年轻代的
8/10
。新创建的对象基本都放在伊甸园区,当伊甸园区被放满时会触发Young GC
,也被称为minor GC
,目的是清除掉没有被引用的对象。 - s0区:默认占年轻代的
1/10
,也被称为幸存者0区- 伊甸园区GC后存活的对象进入
s0
区,并把该对象的分代年龄+1,此时的伊甸园区仍在继续创建对象。
- 伊甸园区GC后存活的对象进入
- s1区:默认占年轻代的
1/10
也,被称为幸存者1区- 当伊甸园区再次被放满,这次则会回收
伊甸园区
和S0区
的对象,并把仍然存活的对象放入s1
区,同样再次把该对象的分代年龄+1。 - 当伊甸园区第三次被放满,此时的回收区域则变成
伊甸园区
和s1区
中的对象,并把存活的对象放入s0
区,同时分代年龄+1
- 当伊甸园区再次被放满,这次则会回收
- 伊甸园区:默认占年轻代的
- 老年代:默认占堆内存的
2/3
,当分代年龄达到15的时候,会进入老年代,如果老年代满了,则会触发Full GC
,也被称为major GC
,回收整个堆空间和方法区。full GC
会触发stop the world
,会停止用户线程,影响用户体验,虽然young GC
也会stw
,但时间很短,可忽略。所以jvm
优化最主要就是优full GC
,减少GC
次数,就是减少stw。
- 如果回收完毕仍然没有足够的空间,则会报
OOM
异常
注意:JVM默认有这个参数-XX:+UseAdaptiveSizePolicy
(默认开启),会导致这个年轻代的Eden:s0:s1
=8:1:1
比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy
jvm为什么GC时要设置Stop the world这个概念?
因为GC
时总是要清除某些对象的,也许某个对象在GC
时是无用的,但如果不Stop the world
停止所有用户线程的话,可能用户在进行其他操作时,这个对象又被引用了,那么这时这个对象就不能被GC
,如果gc
了,程序找不到这个类会报错。所以JVM规定,在GC
操作时,要Stop the world
停止所有用户线程,防止用户操作对GC
的干扰。
注意:
发生在年轻代的GC
,除了第一次只GC
伊甸园区的对象,以后的GC
区域除了伊甸园区,还要再加上s0
或者s1
区,并把存活的对象放入空的s0
或者s1
区。
因为在这个GC
过程中,存活的对象是在幸存者s0区
和幸存者s1区
中的不断流转的,同时只存在一个有对象的幸存者区 和 一个空的幸存者区,GC
时只会清除 伊甸园区+ 有对象的幸存者区,并把存活的对象放入空的幸存者区,依次流转,直到分代年龄达到15
次,进入老年代!
②:栈与栈帧
- 栈是
jvm
内存中的一块空间,当有线程开始执行时,会从栈中取出一小块内存给当前线程去使用,这块内存被称为线程栈。每个线程会获取栈中一小块内存,每个线程对应一个线程栈! - 线程栈中每一个方法对应一个栈帧,线程栈中有多个栈帧,比如
main
方法 和compute
方法都作为栈帧存在于main
线程栈中。栈帧内部包含局部变量表、操作数栈、动态链接、方法出口等。栈帧保证了每个方法内部的局部变量相互隔离。 - 栈帧的执行遵循先进后出原则,即先压入的
main
方法后执行,后压入的compute
方法先执行。 - 栈与堆的关联:栈帧的局部变量表中如果存储的是对象类型,那么局部变量表中就只存储该变量在堆中的应用!栈通过这个引用地址与堆关联
栈帧内部结构解析:
- 局部变量表:存储局部变量。
- 如果局部变量是常量,如:
comput
方法中的a=1,b=2
。则直接在局部变量表中存储变量的值 - 但如果局部变量是对象,如:
Math math = new Math();
的math
对象。那么math
对象存储在堆中,局部变量表中存储的是math
对象在堆中的引用,但是:这里涉及到逃逸分析的特殊情况,在下文讲解!
- 如果局部变量是常量,如:
- 操作数栈:也是一个栈,用来临时存放局部变量表中的局部变量的结果值,局部变量表中的值的生成过程都在操作数栈中完成。
- 例如:执行
compute
方法时,先把a
的值放入局部变量表,再把a
的值1
放入操作数栈,然后再把操作数栈的值1
,赋给局部变量表中的a
,包括(a + b) * 10
的计算也在操作数栈中进行。
- 例如:执行
- 动态链接:把符号引用转换为直接引用。详解请看下文!
- 方法出口:方法执行完毕后,回到哪一行,哪个方法的哪个位置,是方法出口来记录的。
什么是动态链接?
动态链接其实就是把符号引用转化为直接引用。首先需要介绍一下什么是符号引用、直接引用!!以Math
类为例,打开Math.class
文件,可以看到cafe babe魔数
等其他的二进制数据!
可以看到Math.class
文件中除了魔数、主次版本等,其他的都属于常量池。常量池可以看做存放java代码的,主要存放两大类常量:字面量和符号引用。
- 字面量:字面量就是指由字母、数字等构成的字符串或者数值常量。字面量只可以右值出现,所谓右值是指等号右边的值,如:
int a=1
这里的a
为左值,1
为右值。在这个例子中1
就是字面量。
int a = 1;
int b = 2;
int c = "abcdefg";
int d = "abcdefg";
- 符号引用:上面的
a、b
就是字段名称,就是一种符号引用,还有Math类常量池里的全限定名,main和compute是方法名称,这些都是符号引用。符号引用可以是:- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
像这些静态的、未加载的.class
文件的数据被称为常量池,但经过jvm
把.class
文件装入内存、加载到方法区后,常量池就会变为运行时常量池!对应的符号引用在程序加载或运行时会被转变为被加载到方法区的代码的直接引用,在jvm
调用这个方法时,就可以根据这个直接引用找到这个方法在方法区的位置,然后去执行。 也就是我们说的动态链接了。
例如,compute()
这个符号引用在运行时就会被转变为compute()
方法具体代码在内存中的地址,主要通过对象头里的类型指针去转换直接引用。
③:方法区
方法区与java堆一样,是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类元信息(Math.class对象
)。方法区看作是一块独立于堆的内存空间,方法区更多内容点击查看!!
- 方法区中存储的是类元信息,包括类名、属性字段、方法、全局常量(static final)等等。
- 类中的静态变量和常量也都存储在方法区中
- 如果是常量形式的,如:
static int i = 666
,则i
直接存储在方法区的运行时常量池中 - 如果是对象形式的,如:
static User user = new User()
,则方法区中只存储user
对象的引用,而真正的user
是存储在堆中的,这就建立起了方法区与堆之间的联系!!
- 如果是常量形式的,如:
- jdk1.8之前的方法区的实现叫永久代,jdk1.8之后的实现改为元空间,永久代和元空间最大的区别就是:元空间不在虚拟机中设置内存,而是直接使用本地内存
永久代为什么要替换为元空间?
- 永久代设置空间大小难以确定,如果设置比较小容易发生FullGC影响程序性能,而且容易出现OOM,如果过大又占用内存
- 对永久代的调优是很困难的
Class clazz = math.getClass() 和 类元信息Math.class的区别是什么?
Class clazz = math.getClass()
生成的Class
对象存放在堆中,提供给开发人员使用,可以通过该Class
反射获取到类的属性、方法的等信息- 类元信息
Math.class
是放在方法区中。Xxx.class
文件经过jvm
加载后,通过c++
翻译成类元信息,并放在方法区中,用于jvm
内部通过类型指针Klass Pointer
去执行代码来使用的。两者的存储位置不同,使用方向不同!
堆、栈、方法区的关系
④:程序计数器
- ①:程序计数器是记录当前线程的执行字节码的位置。
- ②:由于
Math
对象的字节码元信息会被保存在方法区中,字节码执行引擎在执行时,会记录当前执行的行号,每当线程往下执行一 步,执行引擎就会改变程序计数器的行号。 - ③:在多线程模式下,如果当前线程的
cpu
时间片被别的线程抢去,等他再抢回cpu时,需要记录上一步执行的位置,否则要从头开始执行,而程序计数器就提供了当前执行的位置信息,所以每个线程都有自己的程序计数器。
⑤:本地方法栈
要了解本地方法栈,首先要明白什么是本地方法!
本地方法:由native
修饰的方法。因为java的底层是C++
,如果线程在执行过程中调用了很底层的C++
方法(比如Thread.start()
为native
修饰),那么当前线程也应该把本地方法压入一个栈中,这个栈就被称为本地方法栈。与调用普通方法类似,只不过这里调用的是c++
底层的方法。
- 本地方法栈也是一个栈结构,栈中每一个
native
方法调用的都是c++实现的方法 - 本地方法栈也和线程栈、程序计数器一样,每个线程都持有一个本地方法栈!
2. JVM参数设置
jvm参数设置大部分情况下都是对堆、栈、方法区中的参数做合理化配置!
下面对jvm参数做一些简单说明:
java -Xms2048M -Xmx2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar microservice-eureka-server.jar
-Xms
:初始堆内存,默认物理内存的1/64-Xmx
:最大堆内存,默认物理内存的1/4-Xmn
:新生代内存大小-Xss
:每个线程的栈内存大小,默认大小是1M
,这个值设置的越小,线程栈里能分配的栈帧就越少,就是说可调用的方法数就越小。如果超出-Xss
设置的大小,就会抛出StackOverflowError
栈溢出异常!-XX:NewSize
:设置新生代初始大小-XX:NewRatio
:默认2
,表示新生代占年老代的1/2,占整个堆内存的1/3
。-XX:SurvivorRatio
:默认8
,表示一个survivor区占用1/8的Eden内存,即1/10
的新生代内存。-XX:+PrintGCDetails
:打印GC日志
元空间的JVM参数:
-XX:MaxMetaspaceSize
: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。-XX:MetaspaceSize
: 指定元空间触发Full gc
的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M
左右,达到该值就会触发full gc
, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize
(如果设置了的话) 的情况下, 适当提高该值。这个跟早期jdk版本的-XX:PermSize
参数意思不一样,-XX:PermSize
代表永久代的初始容量。
由于调整元空间的大小需要Full GC
,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC
,通常都是由于永久代或元空间发生了大小调整。基于这种情况,一般建议在JVM
参数中将MetaspaceSize
和MaxMetaspaceSize
设置成一样的值,并设置得比初始值要大,对于8G
物理内存的机器来说,一般我会将这两个值都设置为256M
。可有效降低项目的启动时间!
由于元空间使用的是堆外的物理内存,还是有必要设置MaxMetaspaceSize
最大元空间大小的,否则元空间可能会无限扩容,直到物理内存被用完!!
下图是没有设置-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
时,项目启动时会出现一定次数的Full gc
当设置完元空间参数时,项目启动上面的Full GC
消失!
GC日志打印参数设置
对于java应用我们可以通过一些配置把程序运行过程中的gc日志全部打印出来,然后分析gc日志得到关键性指标,分析GC
原因,调优JVM
参数。
打印GC日志方法,在JVM参数里增加参数,这里使用的是jdk1.8默认的Parallel
垃圾收集器:
-Xloggc:./gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintGCCause
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M
上述jvm
参数释义:
%t
代表时间./gc-%t.log
:打印到这个文件中-XX:+PrintGCDetails
:打印GC详情-XX:+PrintGCDateStamps
:打印GC的date时间戳-XX:+PrintGCTimeStamps
:打印GC的time时间戳-XX:+PrintGCCause
:打印GC的原因-XX:+UseGCLogFileRotation
:使用滚动打印GC的方式-XX:NumberOfGCLogFiles=10
:分10个文件打印GC-XX:GCLogFileSize=100M
:每个文件打印100M,一共只接收1G的日志,因为是滚动打印,超过1G的日志会覆盖最早的GC日志!
打开./gc-%t.log
发现GC日志如下:
- 第一行红框,是项目的配置参数。这里不仅配置了打印GC日志,还有相关的VM内存参数。
- 第二行红框中的是在这个GC时间点发生GC之后相关GC情况。下面对其做逐个解析
2.909
: 这是从jvm启动开始计算到这次GC经过的时间,前面还有具体的发生时间日期。Full GC(Metadata GC Threshold)
:指这是一次full gc,括号里是gc的原因, PSYoungGen是年轻代的GC,ParOldGen是老年代的GC,Metaspace是元空间的GC6160K->0K(141824K)
:这三个数字分别对应GC之前占用年轻代的大小,GC之后年轻代占用,以及整个年轻代的大小。112K->6056K(95744K)
:这三个数字分别对应GC之前占用老年代的大小,GC之后老年代占用,以及整个老年代的大小。6272K->6056K(237568K)
:这三个数字分别对应GC之前占用堆内存的大小,GC之后堆内存占用,以及整个堆内存的大小。20516K->20516K(1069056K)
:这三个数字分别对应GC之前占用元空间内存的大小,GC之后元空间内存占用,以及整个元空间内存的大小。0.0209707
:是该时间点GC总耗费时间。
从日志可以发现几次fullgc都是由于元空间不够导致的,所以我们可以将元空间调大点-XX:MetaspaceSize=256M
即可
对于CMS和G1收集器的日志打印,jvm参数设置如下:
CMS
-Xloggc:d:/gc-cms-%t.log -Xms50M -Xmx50M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+PrintGCDetails -XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps -XX:+PrintGCCause -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC
G1
-Xloggc:d:/gc-g1-%t.log -Xms50M -Xmx50M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+PrintGCDetails -XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps -XX:+PrintGCCause -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M -XX:+UseG1GC
上面的GC日志,是可以帮我们分析GC的垃圾收集情况,但是如果GC日志很多很多,成千上万行。就算你一目十行,看完了,脑子也是一片空白。所以我们可以借助一些功能来帮助我们分析,这里推荐一个分析GC的工具gceasy
(https://gceasy.io),可以上传gc文件,然后他会利用可视化的界面来展现GC情况。gceasy
可以帮我们分析上传的GC日志
- 比如可以看到年轻代,老年代,以及永久代的内存分配,和最大使用情况。
- 可以看到堆内存在GC之前和之后的变化,以及其他信息。
- 还可以给出一些优化建议,相当智能了
- 还有一些其他的收费设置,暂不叙述
3. JVM内部的对象创建流程(new 对象流程)
对象创建流程拆解如下:
-
3.1 类加载检查
虚拟机遇到一条new
指令,首先去检查这个指令的参数能否在MetaSpace元空间
的常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已经加载、解析、初始化(即判断这个类元信息是否存在)。如果没有,那么在双亲委派机制模式下,使用当前类加载器ClassLoader+包名+类名为key
进行查找对应的class
文件,如果没有找到文件,则抛出ClassNotFontException
异常.如果找到。则进行类加载,并生成对应的Class
类对象
-
3.2 分配内存
在通过jvm类加载检查后,jvm会给这个对象分配内存。在堆内存中划分一块给这个对象。下边的第三标题有详解!- 内存分配存在以下两个问题:
- ①:jvm有哪几种内存分配方式?
- 1.指针碰撞 (jvm默认使用)
如果堆内存是规整的,也就是说所有用过的内存都放在左边,空闲的内存放在右边,中间放着一个指针作为分界点。在内存分配时,只是把指针向右边位移一段与对象大小相等的距离被称为指针碰撞。 - 2.空闲列表
如果堆内存不是规整的,比如使用标记清除算法,会产生内存碎片。也就是说使用过的内存和未使用的相互交错,JVM就会维护一个列表,列表上记录了所有未使用的内存,然后找一块和对象大小一致的内存,把这块内存划分给对象,并更新列表上的记录。
- 1.指针碰撞 (jvm默认使用)
- ②:如何解决高并发情况下,内存分配问题?
即正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的 情况。- 1. CAS
采用CAS + 失败重试的方式保证分配内存这个操作的原子性,同步处理。 - 2. 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
把内存分配的动作按照每个线程划分在不同空间内进行。即每个线程在Java堆中预先分配一小块内存,在自己的内存中分配对象内存。通过-XX:+/-UseTLAB
参数来设定虚拟机是否使用TLAB
(JVM会默认开启-XX:+UseTLAB
),-XX:TLABSize
指定TLAB
大小。
- 1. CAS
- ①:jvm有哪几种内存分配方式?
- 内存分配存在以下两个问题:
-
3.3 初始化 (赋0)
内存分配完成后,需要给内存中所有的东西都初始化为零值。这一步保证了java代码中对象的实例字段不被赋初始值就直接使用,不过程序访问到的知识这个字段对应数据类型的零值,如 0 ,null,false等。
-
3.4 设置对象头
初始化0值之后,jvm要设置对象的对象头,例如这个对象属于哪个实例,对象的hash码,GC年龄等信息。
首先要知道对象在内存中分为3部分:- 对象头 :包含
Mark Word
和Klass Pointer类型指针
- 实例数据 :它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段
- 对齐填充:不是必须,仅作为占位符。起到字节对齐的作用,字节对齐有助于CPU寻址,增加cpu执行效率
- 对象头 :包含
-
对象头包含两部分信息
- ①:Mark Word标记字段。用于存储对象自身的运行时数据, 如哈 希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等。默认是32位(4个字节),超出32位的会使用指针压缩压进32位以内!
- ②:Klass Pointer类型指针。即对象指向它的类元数据的指针,jvm执行代码需要用到这个类型指针。
-
问题①:什么是java对象的指针压缩?
- 在以前计算机没有快速发展的时候,我们通常使用的都是32位系统 ,32位的系统地址最大支持4G内存(2³²)。随着科技发展,现在基本上都用的是64位系统,64位系统相比较32位,支持更多内存(2的64次方,TB的级别…)。
- 然而内存并没有和系统一样高速发展,我们平时使用的机器也就是8G、16G、32G、64G内存的样子。8G对应2的33次方,16G对应2的34次方。。。以此类推,使用的机器水平根本达不到2的64次方,内存空间仍是稀缺资源。
Mark Word
在64位系统中占用8字节,在32位系统中只占用4字节, 所以如果在64位平台的HotSpot中使用32位指针,内存使用将会多出1.5倍左右。为了实现这个目的,可以使用指针压缩,把高于32位地址(也就是2³²约等于4G内存)的地址压缩进32位以内。可以减少64位平台下的内存消耗,有效节省内存。
-
问题②:指针压缩有什么作用?
- 通过指针压缩使对象变小,进而减少堆内存消耗,避免频繁GC
- 使用较大指针在主内存和缓存之间移动数据,会占用较大宽带。启用指针压缩,可减少带宽消耗!
启用指针压缩:-XX:+UseCompressedOops(默认开启)
禁止指针压缩:-XX:-UseCompressedOops
注意:
- ①:堆内存小于4G时,不需要启用指针压缩,因为4G内存是在32位地址以内,jvm会直接去除高32位地址
- ②:堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,所以堆内存不要大于32G为好
- 3.5 执行init方法
执行对象自带的init
方法,与上边的初始化 (赋0)不同,对应到语言层面上,就是为属性赋上代码里指定的值 。比如 a = 6 ,这里指的是把a赋值为6
4. 对象内存分配流程
第二段说到对象加载后,jvm会给这个对象分配内存。那具体的分配流程如下(不一定全在堆上分配)
4.1 为什么有的对象会分配在栈中?
当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。
为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
4.2 对象逃逸分析和标量替换
对象逃逸分析:就是分析对象的作用域,看这个对象是否被外部方法所引用。如下图所示,JDK7之后默认开启逃逸分析。
标量替换:通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,如果栈有足够连续的空间可以存储这个对象,那就直接存储(因为存储对象需要连续空间)。如果没有足够大的连续空间,那就会把这个对象分解为标量,存储在栈的碎片空间中。
标量和聚合量的区别?
聚合量即完整的对象,标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及 reference类型等)
4.3 栈上分配、逃逸分析案例
设置jvm参数
‐Xmx15m ‐Xms15m:堆内存15M
‐XX:+DoEscapeAnalysis:是否开启逃逸分析
‐XX:+EliminateAllocations:是否开启标量替换
‐XX:+PrintGC:打印GC日志
创建一亿次对象, 通过开启和关闭逃逸分析来感受jvm的GC情况
public class AllotOnStack {
public static void main(String[] args) {
long start = System.currentTimeMillis();
//代码调用了一亿次alloc(),如果是分配到堆上,大概需要1GB以上堆空间。
//如果堆空间小于该值,必然会触发GC。
for (int i = 0; i < 100000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}
private static void alloc() {
User user = new User();
user.setId(1);
user.setName("zhuge");
}
}
使用如下参数不会发生GC(开启逃逸分析和标量替换)
-Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC XX:+EliminateAllocations
结果:
使用如下参数都会发生大量GC
①:-Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
②:-Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
结果:
5. 堆内存对象流转和GC
通过对象内存分配流程图可知,大对象和逃逸出的对象会在堆中分配内存,
5.1 Minor GC和Full GC 有什么不同?
- Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。
- Major GC/Full GC:一般会回收老年代 ,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢 10倍以上。一般达到老年代空间的92%左右会进行一次 full GC,这个值可以自己设置。
5.2 哪种对象会进入老年代?(发生频繁GC优先检查以下4点!)
- ①:大对象直接进入老年代
- 何为大对象,如果没有通过
-XX:PretenureSizeThreshold
设置大对象的值,则大小超过Eden区大小的对象,会直接放入老年代。如果通过-XX:PretenureSizeThreshold=30000(单位是字节) -XX:+UseSerialGC
设置大对象的值为30M ,则超过30M的对象会直接进入老年代。通过设置这个值的大小,可以避免为大对象分配内存时的复制操作而降低效率。这个参数只在Serial
和ParNew
两个收集器下有效。 - jvm优化思想:如果系统中有大对象且引用时间较长,可以通过配置使其直接进入老年代,避免大对象在年轻代的流转。
- 何为大对象,如果没有通过
- ②:长期存活的对象将进入老年代
- 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度 (默认为
15
岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代 的年龄阈值,可以通过参数-XX:MaxTenuringThreshold
来设置。 - jvm优化思想:如果能预估到系统中的部分对象只经历
3-4
次GC
就会被清除掉,部分对象的引用时间较长,可以通过设置-XX:MaxTenuringThreshold=8
来适当减少分带年龄,减少长时间引用的对象在新生代的流转次数,加快使其进入老年代。因为长时间引用的对象大概率最终也会进入老年代!这些对象一般就是你系统初始化分配的缓存对象,比如大的缓存List,Map之类的对象。大小一般1-2M就算很大了!!
- 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度 (默认为
- ③:对象的动态年龄判断:超出Survivor区内存的50%,部分对象进入老年代
- 动态年龄判断机制一般是在
minor gc
之后才被触发的!!在minor gc
之后,如果Survivor
区中的这批对象总大小大于Survivor
区域域内存大小的50%
,那么>=这批年龄最大值的对象,都会被放入老年代。 - 例如:在
minor gc
之后,Survivor
区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor
区域的50%,此时就会 把年龄n
(含)以上的对象都放入老年代。 - 这个动态年龄判断的机制作用其实是希望那些可能是长期存活的对象,尽早进入老年代,避免多次复制操作而降低效率。
- 动态年龄判断机制一般是在
- ④:老年代空间分配担保机制:见5.3详解!
5.3 什么是老年代空间分配担保机制?
- ①:在每一次minor gc之前,JVM都会计算下老年代剩余可用空间,看这个空间是否小于年轻代里所有对象大小之和
- ②:如果 老年代剩余空间
>=
年轻代所有对象,则年轻代直接进行minor gc。 - ③:如果 老年代剩余空间
<
年轻代所有对象,看jvm是否设置了“-XX:-HandlePromotionFailure
”担保参数(jdk1.8默认就设置了)。 - ④:如果没设置,则年轻代直接进行minor GC。
- ⑤:如果设置了,并且 老年代剩余空间
<
每一次minor gc后进入老年代的对象的平均大小,就会触发Full GC. - ⑥:如果设置了,并且 老年代剩余空间
>
每一次minor gc后进入老年代的对象的平均大小,则认为老年代有足够空间存储,不会进行Full GC ,年轻代进行minor gc。
通俗解释就是:在做minor gc之前,先判断下是否大概率会发生Full gc
,如果大概率会发生,就先做一次full gc
。其实这个对效率优化的也不是很大,因为就算没有老年代空间担保机制,那么这次minor gc
完毕之后,还是会做一次full gc
,只不过看起来这次minor gc
的时间较长而已!
具体流程如图所示:
5.5 如何判断对象会被回收
-
①:可达性分析算法
默认使用的回收算法,将“GC Roots
” 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的 对象都是垃圾对象GC Roots
根节点可以是以下几种:
1) 线程栈的本地变量(栈帧的局部变量表)
2) 静态变量(static
)
3)本地方法栈的变量(native
)等等 -
②:引用计数法
给对象添加一个引用计数器,当有一个地方引用它,计数器+1,当引用失效,计数器-1,任何时候计数器为0的对象是垃圾对象。- 优点:这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存
- 缺点:它很难解决对象之间相互循环引用的问题。
常见的引用类型
- ①:强引用:普通的变量引用
User user = new User();
- ②:软引用:创建的对象被包裹在另一个对象的构造器中,正常情况不会被回收,但是GC做完后发现释放不出空间存放 新的对象,则会把这些软引用的对象回收掉。软引用可用来实现内存敏感的高速缓存。
SoftReference<User> user = new SoftReference<User>(new User());
-
例如:浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从 缓存中取出呢?这就要看具体的实现策略了。
- (1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建
- (2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出
- ③:弱引用:弱引用跟没引用差不多,GC会直接回收掉,很少用
- ④:虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用
5.6 finalize()方法实现对象自救!
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一 个对象死亡,至少要经历再次标记过程。
如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可,比如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。 注意:一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次
示例代码:
List<Object> list = new ArrayList<>();
int i = 0;
int j = 0;
while (true) {
//有 gc root 引用不会被回收
list.add(new User(i++, UUID.randomUUID().toString()));
//无 gc root 引用,对象会被回收,
//但如果User中实现了finalize()方法,并在finalize方法中把自己添加到list中,即完成了自救,不会被回收!
new User(j--, UUID.randomUUID().toString());
}
5.7 方法区哪些对象会被回收?
方法区主要回收的是无用的类,条件苛刻,回收内容很少! 需要同时满足下面3个条件才能算是 “无用的类” :
- ①:该类的所有实例都被回收,java堆中不存在该类的任何实例
- ②:加载类的ClassLoader已经被回收(除了自定义类加载器,其他的类加载器基本不会回收!)
- ③:该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。