内存模型
内存结构
堆:
- 存放对象实例, 几乎所有的对象实例以及数组都在这里分配内存。
虚拟机栈:
- 栈帧:用于支持虚拟机进行方法执行的数据结构。
- 存储了方法的
局部变量表
, 操作数栈,动态连接和返回地址等地址。- 每个方法从调用到执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
- 栈内存为线程私有的空间, 每个线程都会创建私有的栈没存, 生命周期与线程相同。
- 除了一些 Native方法调用是通过本地方法栈实现的, 其他所有Java方法调用都是栈来实现的。
本地方法栈:
和虚拟机栈非常相似,区别如下:
虚拟机栈执行 Java 方法服务。
本地方法栈使用到的 Native方法(例如 C++ 程序)服务。
方法区:
- 各个线程共享的内存区域。
- 存储已经被虚拟机加载的类型信息,常量,静态变量,即时编译器编译后的代码缓存等数据。
- 具体实现: 永久代,元空间。
永久代和元空间的区别:
JDK1.8之前是永久代。
JDK1.8之后是元空间。
存储位置:
- 永久代使用的内存区域是 JVM进程所使用的区域,大小受 JVM的大小限制。
- 元空间使用的是物理内存区域,元空间大小只受物理内存大小的限制。
存储内容不同:
- 永久代主要存储方法区存储内容中的数据。
- 元空间只存储类的元信息,而静态变量和运行时常量池都在堆中。
程序计数器:
- 一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。
- 每条线程都有一个独立的程序计数器,各线程之间的计数器互不影响,为线程私有。
程序计数器的作用:
- 多线程的情况下,用来记录当前执行的位置,当线程切换回来时能正确运行。
- 字节码解释器通过改变程序计数器来以此读取指令,从而实现代码的流程控制: 如: 选择, 循环, 顺序执行, 异常处理等。
程序计数器是唯一不会出现
OutOfMemoryError
异常的内存区域,其生命周期随着线程的创建而生,线程的销毁为亡。
堆结构
基于分代收集理论设计,分为年轻代和老年代,其中年轻代又分为Eden区和Survivor 0区和Survivor 1区(Survivor0和Survivor1也叫From和To区)
- 默认情况下,老年代占总的堆内存的2/3,年轻代占1/3
什么E:S0:S1默认是8:1:1
新生代中有98%的对象是朝生夕灭的,每次MionrGC后存活的对象应该小于等于2%。
所以采用复制算法的新生代似乎可以不用将内存分成大小相等的两块了,但考虑到实验偏差以及实际情况的多样性。
默认预留了10%的内存用于存放存活对象。
堆内存大小
初始内存默认为电脑物理内存大小的
1/64
,最大内存默认为电脑物理内存的1/4
,但是堆空间的大小可以调节。
符号引用
在JVM中,一个类的方法名、类名、修饰符、返回值等等都是一系列的符号,而这些符号都是一个个的常量,存储在常量池中。
同时这些个符号被加载到内存后,都有对应的内存地址,而这些内存地址就是直接引用。
动态链接
符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。
另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。
方法出口
方法出口就是方法执行完成后,需要返回的一些信息。
一般来说,方法正常退出时,方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。
而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。
操作数栈
就是存放方法运行过程中产生的一些临时数据,目的是为了计算,以栈的数据结构进行存储。
局部变量表
方法内部的变量都是存放在局部变量表中。
如果是对象类型,局部变量表中存放的是堆给对象分配的内存地址,也就是指针,而不是对象直接存在局部变量表中。
元空间
在JDK1.7之前,HotSpot 虚拟机把方法区当成永久代(方法区的落地实现)来进行垃圾回收。
JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。
HotSpots取消了永久代,那么是不是也就没有方法区了呢?
- 不是,方法区是一个规范,规范没变,它就一直在,只不过取代永久代的是元空间(Metaspace)而已。
它和永久代有什么不同的?
存储位置不同:
- 永久代在物理上是堆的一部分,和新生代、老年代的地址是连续的,而元空间属于本地内存。
存储内容不同:
- 在原来的永久代划分中,永久代用来存放类的元数据信息、静态变量以及常量池等。
- 现在类的元信息存储在元空间中,静态变量和常量池等并入堆中,相当于原来的永久代中的数据,被元空间和堆内存给瓜分了。
为什么要废弃永久代,引入元空间?
在原来的永久代划分中,永久代需要存放类的元数据、静态变量和常量等。
它的大小不容易确定,因为这其中有很多影响因素,比如类的总数,常量池的大小和方法数量等,
-XX:MaxPermSize
指定太小很容易造成永久代内存溢出。移除永久代是为融合HotSpot VM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代。
- 永久代会为GC带来不必要的复杂度,并且回收效率偏低。
废除永久代的好处:
由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间,不会遇到永久代存在时的内存溢出错误。
将运行时常量池从PermGen分离出来,与类的元数据分开,提升类元数据的独立性。
将元数据从PermGen剥离出来到Metaspace,可以提升对元数据的管理同时提升GC效率。
对象
对象的生命周期
对象的创建流程:
开始
new
一个对象进行常量池检查
- 看能否在常量池中定位到这个类的符号引用,定位不到则加载类
- 看是否加载过这个类,没加载过则加载类
分配内存空间
- 指针碰撞: GC不带压缩功能,
Serial
和ParNew
- 空闲列表: GC带压缩功能,
CMS
内存空间初始化为零值:
- 保证了对象的实例字段在不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值
必要信息设置
- 对象类的元数据
- 对象的哈希码
- GC分代年龄 -> 对象头
init()
一个对象产生到灭亡的过程
新产生的对象优先分配在Eden区。
当Eden区满了或放不下了进行GC,这时候其中存活的对象会复制到From区。
如果From区放不下则会全部进入老年代,然后Eden内存全部清除。
之后产生的对象继续分配在Eden区,当Eden区又满了或放不下了
- 这时候将会把Eden区和From区存活下来的对象复制到To区。
同理,如果存活下来的对象To区都放不下,则这些存活下来的对象全部进入年老代
- 之后回收掉Eden区和From区的所有内存。
如上这样,会有很多对象会被复制很多次(每复制一次,对象的年龄就+1)。
默认情况下:
- 当对象被复制了15次(这个次数可以通过:
-XX:MaxTenuringThreshold
来配置),就会进入年老代了。当年老代满了或者存放不下将要进入年老代的存活对象的时候。
- 就会发生一次
Full GC
(这个是最需要减少的,因为耗时很严重)。
对象的内存分配方式
指针碰撞:
假设堆中的内存是绝对规整的,所有用过的内存放一边,未使用内存放另一边,中间边界线就可以类比为指针。
- 内存分配就是把指针向未分配的区域挪一段与对象大小相等的距离,这就是指针碰撞。
空闲列表:
如果堆中的内存不是很规整的,已使用和未使用的内存就会相互交错。
这个时候就要维护一个列表来记录所有已使用和未使用的内存块
- 在分配内存时从列表找到一块足够大的空间划分给对象实例, 并更新内存列表。
分配方法 | 说明 | 收集器 |
---|---|---|
指针碰撞 | 内存地址是连续的(新生代) | Serial和 ParNew收集器 |
空闲列表 | 内存地址不连续(老年代) | CMS收集器和 Mark-Sweep收集器 |
对象的内存布局
在堆内存中,一个对象的的存储布局可以分为三个区域:
对象头:
存储对象自身的运行时数据(哈希吗+GC分代年龄+锁状态标准)
类型指针: 类元素的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
实例数据:
- 存储对象真正的有效信息,例如: 非静态变量也会存入堆空间
对齐填充:
- JVM内对象都采用
8byte
对齐,不够8byte整数倍
的就需要通过对齐填充来补全
如何访问一个对象
对象的访问方式由虚拟机决定,目前主流的访问方式有以下两种:
句柄:
- 使用句柄的话,堆中会专门划分出一块内存来作为句柄池,
reference中存储对象句柄的地址
- 句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
直接指针:
- 访问速度快,节省了一次指针定位的开销
- 直接访问,
reference中存储的就是对象的地址
,节省了一次指针定位的开销
强、软、弱、虚引用
四种引用的目的是让程序自己决定对象的生命周期。
- 通过垃圾回收器对这四种引用做不同的处理,来实现对象生命周期的改变。
强引用:
如果一个对象具有强引用,那垃圾收器绝不会回收它。
当内存空间不足,宁愿抛出
OutOfMemoryError
错误,使程序异常终止。
- 也不会靠随意回收具有强引用对象来解决内存不足的问题。
如:
Object obj = new Object();
这种就是强引用。
软引用:
在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行第二次回收。
如果这次回收还没有足够的内存,才会抛出内存溢出异常。
- 使用软引用的方式是
SoftReference
。软引用通常用在对内存敏感的程序中。
- 比如高速缓存就有用到软引用,内存够用的时候就保留,不够用就回收。
弱引用:
在垃圾回收时,如果这个对象只被弱引用关联(没有任何强引用关联他),那么这个对象就会被回收。
- 只要垃圾回收,不管内存够不够用,弱引用都会被回收。
使用弱引用的方式是类
WeakReference
。
虚引用:
如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
它不能单独使用也不能通过它访问对象,虚引用必须和引用队列(
RefenenceQueue
)联合使用。主要作用是跟踪对象垃圾回收的状态。
提供了一种确保对象被 Finalize 以后,做某些事情的机制。
设置虚引用的唯一目的:
- 就是在这个对象被回收器回收的时候收到一个系统通知或者后续添加进一步的处理。
空对象占多大内存
开启压缩指针:
- 默认会占用12个字节,但为了避免伪共享问题,JVM会按照8个字节的倍数进行填充。
- 所以会填充4个字节变成16个字节长度。
- 所以:
Markword
占8个字节、类型指针占4个字节, 对齐填充占4个字节。关闭压缩指针:
- 默认会占用16个字节,16个字节正好是8的整数倍,因此不需要填充。
常用参数
-Xms:
- 最小堆大小(初始化堆),如-Xms256m
-Xmx:
- 最大堆大小,如-Xmx512m
-Xmn:
- 新生代大小,如-Xmn是64m
-Xss:
- 每个线程的堆栈大小
-XX:NewRatio=4:
- 设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)
- 设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5
-XX:SurvivorRatio=8:
- 设置年轻代中Eden区与Survivor区的大小比值
- 设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10
-XX:PermSize=100m:
- 初始化永久代大小为100MB
-XX:MaxTenuringThreshold=15:
- 设置垃圾对象最大年龄
垃圾回收
GC类型
Minor GC(Young GC):
新生代收集( Minor GC/Young GC ):
- 当年轻代空间不足时,就会触发Minor GC。
整个年轻代中又可以分为Eden区和survivor区,survivor区又分为survivor0和survivor1。
- Eden区和Survivor0区和Survivor1区,之间的配比是8:1:1(默认情况下)
年轻代GC回收过程:
当Eden区被对象塞满后就会发生GC。
在eden区中筛选出来有用的对象,把有用的对象复制到
survivor0/survivor1
,第一次随机分配,假设分配到survivor0,剩下eden区的对象都是垃圾对象,直接干掉。程序继续执行,eden区的对象又放不下了,又触发Minor GC,这次它不仅会回收eden区,还会回收survivor0区的垃圾对象,把eden区和survivor0区的有用对象一起放到
survivor1
区,eden区和survivor0
区剩余的垃圾对象直接干掉。程序继续执行,eden区的对象又放不下了,又会触发Minor GC,这次它不仅会回收eden区,还会回收survivor1区的垃圾对象,把eden区和survivor1区的引用对象一起放到
survivor0
区,eden区和survivor1
区剩余的垃圾对象直接干掉。依次往复循环,直到超过了一定的次数,对象就会放到老年代区,老年代放不下就要执行Full GC了。
老年代收集(Major GC/Old GC):老年代的垃圾收集。
在老年代空间不足时,则触发Major GC。
对象什么情况会进入老年代?
大对象:
需要大量连续内存空间的对象,最典型的大对象就是那种很长的字符串以及数组。
长期存活的对象:
虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1,对象在Survivor区中每熬过一次Minor GC,年龄就增加1,当他的年龄增加到一定程度(默认是15岁), 就将会被晋升到老年代中。
- 对象晋升到老年代的年龄阈值,可以通过参数
-XX:MaxTenuringThreshold
设置。
动态对象年龄判定:
如果在Survivor空间中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
空间分配担保:
在一次安全Minor GC 中,存活的对象不能在另一个Survivor 完全容纳,则会通过担保机制进入老年代。
什么样的对象会被回收?
引用计数法:
给对象中添加一个引用计数器,每当有一个地方引用他时,计数器值就+1。
当引用失效时,计数器值就-1,任何时刻计数器为0的对象就是不可能在被使用。
无法解决对象之间互相引用的情况。
- A到B,B到C,C到A,相互引用,但是他们垃圾的话,用引用计数是无法被回收的。
可达性分析算法:
通过GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的,这些对象就会被当作垃圾从而被回收掉的。
哪些是GCRoots对象的根:
虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象
方法区中的类静态属性引用的对象
方法区中常量引用的对象
本地方法栈中(Native方法)引用的对象
触发Full GC执行的情况?
调用
System.gc()
时,系统建议执行Full GC,但是不必然执行。老年代空间不足。
方法区空间不足。
通过Minor GC后进入老年代的平均大小 大于 老年代的可用内存。
- Eden、Survivor Space0(From Space)区 向Survivor Space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存 小于 该对象大小。
Full GC问题排查
查看GC日志:
- 通过
jstat -gcutil -t pid 1000 1000
查看GC日志。查看堆内存情况:
jmap -heap pid
查看堆内存情况。查看哪些类占用的空间多:
- 通过
jmap -hsito pid
查看哪些类占用的空间多。查看堆内存日志:
- 通过
jmap -dump:format=b,file=xxxx.hprofDump
查看堆内存日志。- 通过MAT内存分析工具分析日志。
空间分配担保机制
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间 是否大于 新生代所有对象的总空间。
如果大于,则此次Minor GC是安全的。
如果小于,则虚拟机会查看
-XX:HandlePromotionFailure
设置值是否允许担保失败。如果
HandlePromotionFailure=true
,那么会继续 检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
如果大于,则尝试进行一次Minor GC。
如果小于,则进行一次Full GC。
如果
HandlePromotionFailure=false
,则进行一次Full GC。
安全点
程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为安全点(Safepoint)。
当JVM触发GC时,首先会让所有的用户线程到达安全点SafePoint时阻塞,也就是STW,然后枚举根节点,即找到所有的GC Roots,然后就可以从这些GC Roots向下搜寻,可达的对象就保留,不可达的对象就回收。
三色标记算法
根据
CMS
的运行过程来看,它的低停顿是在并发标记、并发收集阶段,做成并行,而没有STW但是这样做会带来一个问题:
- 由于用户程序的执行,对象的状态很容易发生变化,原本有
GC Roots
引用的对象
- 现在没有
GC Roots
引用了或者原本是垃圾对象,后面又复活了,又不是垃圾对象了
- 引用状态的改变,对JVM来说是很不可控的一种行为。
为了解决这个问题,JVM引入了三色标记这个解决方案。
GCRoot如果想查找到存活对象,会根据可达分析算法分析,遍历整个引用链
- 按照是否访问过该对象分成三种不同的颜色:
- 白色、灰色、黑色。
白色:
- 对象没有被访问过 (没有被
GCRoot
扫描过,有可能是为垃圾对象)。灰色:
对象已经被访问过(被
GCRoot
扫描过)
- 且对象中的属性没有被GCRoot扫描,该对象就是为灰色对象。
如果该对象的属性被扫描的情况下,从灰色变为黑色。
黑色:
- 对象已经被访问过(被
GCRoot
扫描过),且本对象中的属性已经被GCRoot扫描过
- 该对象就是为黑色对象。
三色标记算法原理:
在初始阶段的时候,所有的对象都是存放在白色容器中。
初始标记阶段,
GCRoot
标记直接关联对象置为灰色。并发标记阶段,扫描整个引用链,有子节点的话,则当前节点变为黑色,子节点变为灰色。
在白色盒子剩下的对象都是为没有被
GCRoot
关联的对象,可能会被垃圾回收机制清理。下次
GCRoot
起点从灰色节点开始计算。
三色标记算法缺陷:
在并发标记阶段的时候,因为用户线程与GC线程同时运行,有可能会产生多标或者漏标。
多标:
- 在并发标记阶段,把一个
GC Roots
引用链上的对象已经标记了
- 但是用户线程没有停止,当方法结束的时候,这个对象链上可能都是垃圾对象,称为浮动垃圾。
漏标:
- 在并发标记阶段,原先已经被扫描过的对象重新有了新的引用,导致无法被扫描。
CMS解决漏标问题:增量更新方式:
当黑色对象插入新的指向白色对象的引用关系的时候,就将这个新插入的引用记录下来
等并发标记结束之后,等到重新标记阶段,会
stop the world
- 再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。
G1如何解决漏标问题:原始快照STAB方式:
当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用关系记录下来
在并发标记结束之后,等到重新标记阶段
- 会
stop the world
再讲这些记录过的引用关系中的灰色对象为根,重新扫描一次这样就能扫描到白色对象,将这些白色对象标记成黑色对象
- 目的就是让这种对象在本次GC中存下来,等待下一轮GC的时候重新扫描,这个对象也有可能是浮动垃圾。
增量更新针对的是
新增
,原始快照针对的是删除
。无论是插入和删除,JVM的记录操作都是通过写屏障实现的,STAB是写前屏障,增量更新是写后屏障。
跨代引用
跨代引用是指年老代空间中的对象引用了新生代的对象,或者新生代中的对象引用了年老代中的对象。
面对这种情况,在进行可达性分析扫描存活对象时,不可能从新生代一直扫描至年老代的,因为这样就会出现整堆扫描的情况,效率必然会很低。
在HotSpot虚拟机中,为了解决跨代引用的问题,会专门在内存中开辟一块小空间用于维护这些特殊的引用,从而达到让GC不必扫描整个堆空间的目的,而开辟的这块小空间则被称为记忆集、卡表。
记忆集(Remember Set):
为了解决跨代引用的问题,在新生代引入了记录集的数据结构,记录从非收集区到收集区的引用指针集合,避免在通过根可达算法判断对象存活时把整个老年代加入扫描范围。
GC时,GC收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需进行详细的根搜索过程。
卡表(Card Table):
卡表是HotSpot虚拟机中记忆集的实现方式,卡表中记录中记忆集的记录精度、与堆内存区域的映射关系等。
如果有年老代的对象引用了新生代的对象,那么该新生代对象所在区域对应的卡页元素设置为1,反之则为0。
G1以后的GC收集器不分代,所以G1以后的记忆集不是通过数组实现的,而是通过哈希表结构实现。
- JVM对于卡页的维护也是通过写屏障的方式。
Remembered Set:
G1中实现的一种新的数据结构:简称为
RSet
,也被称为双向卡表。在每个
Region
区中都存在一个RSet
,其中记录了其他Region
中的对象引用当前Region
中对象的关系,也就是记录着谁引用了我的对象,属于points-into
结构。之前的卡表则是属于
points-out
结构,记录着我引用了谁的对象,在卡表中存在多个卡页,每个卡页记录着一定范围(512KB
)的堆。
RSet
也好,CardTable
也好,都是记忆集的一种具体实现,你也可以将RSet
理解成一种CardTable
的进阶实现方式。G1中的
RSet
本质上就是一个哈希表结构(HashTable
),Key
为其他引用当前区内对象的Region
起始地址,Value
则是一个集合,里面的元素为其他Region
中每个引用当前区内对象的地址。实际上G1中的
RSet
对内存的开销也并不小,当JVM中分区较多且运行时间较长的情况下,这块的内存开销可能会占用到20%
以上。
垃圾收集算法
分代收集理论
分代思想就是将堆分为新生代和老年代,然后根据各个年代的特点选择合适的垃圾收集算法。
在新生代中,每次收集都会有大量的对象(近99%)死去,所以选择复制算法,只需要付出少量对象的复制成本就可以完成每次收集。
在老年代中,对象的存活几率较高,复制成本太高,而且没有额外的空间给它分配担保,所以必须使用标记整理算法或标记清除算法进行垃圾收集。
标记-清除算法
标记-清除算法共有两个阶段,分为标记和清除:
- 标记存活的对象,也可以反过来,标记所有需要回收的对象。
- 在标记完成后统一回收未被标记的对象。
两个明显的问题:
- 效率问题:如果标记的对象太多,那么效率不高。
- 空间问题:标记清除后,会产生大量不连续的碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法
它可以将内存分为大小相同的两块,每次使用其中的一块,当这一块内存使用完后,就会将存活的对象复制到另外一块去,然后把使用过的空间一次性清理掉,这样就使每次的内存回收都是对内存区间的一半进行回收。
把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。
- 发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上。
- 然后直接清理掉Eden和已用过的那块Survivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1:
- 即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被浪费的。
复制算法的局限性:
- 最大的弊端就是浪费内存空间。
标记-整理算法
其中的标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
标记-整理算法的缺点:
要移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,而且这种对象移动操作必须全程暂停用户应用程序才能进行。
- 不移动对象停顿时间会更短,如果从整个程序的吞吐量来看,移动对象会更划算。
HotSpot虚拟机里关注吞吐量的
Parallel Scavenge
收集器是基于标记-整理算法的。而关注延迟的CMS收集器则是基于标记-清除算法的。
折中方案:
让JVM平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。
- 基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。
垃圾收集器
基本概念
串行
Serial
收集:
- 所有用户线程停止,单条GC线程回收堆的情况被称为串行回收。
并行
Parallel
收集:
- 所有用户线程停止,多条GC线程回收堆的情况(需多核CPU支持)。
独占
Monopoly
执行:
- GC工作时,GC线程会抢占所有资源执行,整个应用程序会被停止。
并发
Concurrent
执行:
- 用户线程和GC线程同时(交替)执行的情况,不会停下某类线程。
吞吐量:
指CPU用于执行用户代码的时间与CPU总耗时的比值,计算公式为:
- 吞吐量 = 用户代码执行总时长 /(用户代码执行总时长 + 垃圾回收总时长)。
如JVM在线上执行了
100min
,其中执行用户代码花费了99min
,垃圾回收总用时1min
。
- 那么吞吐量则为
99min/(99min+1min)=99%
。
写屏障:
指在赋值操作前后加入一些逻辑处理(类似于SpringAOP面向切面前后置处理的思想)。
GC组合方案:
追求低延迟,用户交互度较为频繁:采用:
ParNew + CMS
。追求高吞吐,后台计算工作较多:采用:
Parallel Scavenge + Parallel Old
。
Serial收集器
单线程的GC收集器,被称为串行收集器。
该收集器在发生GC时,会产生STW,也就是会停止所有用户线程。
由于会停止其他用户线程,所以在执行GC时并不会出现线程间的切换。
在单颗
CPU
的机器上,它的清理效率非常高。一般来说,采用
Client
模式运行的JVM,选取该款收集器作为内嵌GC是个不错的选择。
ParNew收集器(多线程)
Serial
收集器的多线程版本,是作用于新生代区域的收集器。
- 在整个实现上,除开GC收集阶段会使用多条线程回收外,其他实现几乎与
Serial
收集器大致相同。该款GC收集器因为采用了多线程,所以需要多核CPU的支持,该收集器会根据CPU核数,开启不同的GC线程数,从而达到最优的垃圾回收效果(也可通过
-XX:ParallelGCThreads
参数指定)。
- 若是单核的机器上运行时,其效率可能不如
Serial
。- 如果是以
Server
模式运行的程序,而老年代又采用了CMS
收集器,那么新生代搭配ParNew
是个不错的选择。
Parallel Scavenge收集器(多线程)
一款作用于新生代的多线程GC收集器,但与
ParNew
收集器不同的是:
ParNew
通过控制GC线程数量来缩短程序暂停时间,更关心程序的响应时间。Parallel Scavenge
更关心的是程序运行的吞吐量,更注重一段时间内,用户代码执行时长与程序执行总时长的占比。
PS
收集器可以通过-XX:MaxGCPauseMillis
与-XX:GCTimeRatio
参数精准控制GC发生时的时间以及吞吐量占比。
PS
收集器还可以通过开启-XX:+UseAdaptiveSizePolicy
参数,让JVM启动自适应的GC调节策略,开启该参数后,JVM会根据当前系统的运行状态调整吞吐比与GC时间,从而确保能够提供最合适的停顿时间和吞吐量。
Serial Old(MSC)收集器(单线程)
Serial Old(MSC)
与Serial
收集器相同,同样是一款单线程串行回收的收集器。不同的是:
MSC
是一款作用于年老代空间的收集器,它采用标记-整理算法对年老代空间进行回收。同时,该款收集器也可作为
CMS
的备用收集器使用。
Parallel Old收集器(多线程)
Parallel Old
则是Parallel Scavenge
收集器的年老代版本,同样采用多线程进行并行收集,其内部采用标记-整理算法。与新生代的
PS
收集器相同的是:PO
同样追求的是吞吐量优先。
ShenandoahGC
JDK12推出了
ShenandoahGC
收集器,它与G1、ZGC
收集器一样,都是基于分区结构实现的一款收集器。和ZGC对比,相同的是:它们的停顿时间都不会受到堆空间大小的影响。
ZGC收集器
JDK11时推出垃圾回收器
ZGC
,是一款基于分区概念的内存布局GC器。
- 是真正意义上的不分代收集器,无论是从逻辑上,还是物理上都不再保留分代的概念。
ZGC
主要是超低延迟与吞吐量,在实现时,ZGC
也会在尽可能堆吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾回收的停顿时间限制在10ms
以内的低延迟。
引入ZGC的目的主要有如下四点:
奠定未来GC特性的基础。
为了支持超大级别堆空间(
TB
级别),最高支持16TB
。在最糟糕的情况下,对吞吐量的影响也不会降低超过15%。
GC触发产生的停顿时间不会偏差
10ms
。
CMS收集器
CMS(Concurrent Mark Sweep)收集器是以获取最短回收停顿时间为目标的收集器。
- 它是HotSpot虚拟机第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
它是标记-清除算法实现,整个过程可以分为以下步骤:
初始标记:
- 暂停所有的其他线程stop the world,并记下GC Roots直接引用的对象,速度很快。
并发标记:
- 从GC Roots的直接关联对象开始遍历整个对象链的过程,这个过程很长,但是
不需要停顿用户线程
,可以与垃圾收集线程一起并发运行。
- 因为用户程序继续执行,可能会有导致已经标记过的对象状态发生变化。
重新标记:
- 为了修正并发标记期间因为用户线程继续运行而导致标记产生变动的那一部分对象的标记记录。
- 这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
- 主要用到三色标记里的增量更新算法做重新标记。
并发清理:
- 开启用户线程,同时GC线程开始对未标记的区域做清扫。
- 这个阶段如果有新增对象会被标记为黑色,不做任何处理。
并发重置:
- 重置本次GC过程中的标记数据。
CMS的缺点
对CPU的资源敏感,会和服务器抢资源。
无法处理浮动垃圾,就是在并发标记和并发清理阶段产生的垃圾对象,只能等下一次GC来收集。
它使用的是标记-清除算法,那么就会导致收集结束后,会产生大量的内存碎片,不利于后续对象的存储。
G1收集器
G1(Garbage First)是一款面向服务器的垃圾收集器,主要针对配置多核处理器以及大容量内存的机器
- 满足GC停顿时间要求的同时,还具备高吞吐量性能特征
JDK9 开始默认使用
G1
垃圾收集器
内存划分
G1将堆划分为多个大小相等的独立的
Region
区域一般
Region
区的大小等于堆空间的总大小除以2048
比如目前的堆空间总大小为8GB,就是
8192MB/2048=4MB
,那么最终每个Region
区的大小为4MB
也可以用参数
-XX:G1HeapRegionSize
强制指定每个Region
区的大小
新生代和老年代
默认新生代对堆内存的初始占比是5%
- 如果堆大小为8GB,那么年轻代占据
400MB
左右的内存,对应大概是100个Region
区
- 可以通过
-XX:G1NewSizePercent
设置新生代初始占比在Java程序运行中,JVM会不停的给新生代增加更多的
Region
区
- 但是最多新生代的占比不会超过堆空间总大小的60%,可以通过
-XX:G1MaxNewSizePercent
调整新生代中的
Eden
区和Survivor
区对应的Region
区比例也跟之前一样,默认8:1:1
- 假设新生代现在有400个
Region
,那么整个新生代的占比则为Eden=320,S0/From=40,S1/To=40
G1中的年老代晋升条件和之前的无差,达到年龄阈值的对象会被转入年老代的
Region
区中不同的是对于大对象的分配,在G1中不会让大对象进入年老代:
在G1中由专门存放大对象的
Region
区叫做Humongous
区如果在分配对象时,判定出一个对象属于大对象,那么则会直接将其放入
Humongous
区存储
在G1中,判定一个对象是否为大对象的方式为:
对象大小是否超过单个普通
Region
区的50%:
如果超过则代表当前对象为大对象,那么该对象会被直接放入
Humongous
区比如:目前是8GB的堆空间,每个
Region
区的大小为4MB
,当一个对象大小超过2MB
时则会被判定为属于大对象如果程序运行过程中出现一个巨型对象,当一个
Humongous
区存不下时,可能会横跨多个Region
区存储它
Humongous
区存在的意义:
可以避免一些短命的巨型对象直接进入年老代,节约年老代的内存空间,可以有效避免年老代因空间不足时的GC开销
- 当堆空间发生全局GC(
FullGC
)时,除开回收新生代和年老代之外,也会对Humongous
区进行回收
什么场景下适合采用G1收集器的建议
堆空间内
50%
以上的内存会被存活占用的应用分配速度和晋升速度特别快的应用
至少
8GB
以上堆内存的应用采用原本分代收集器GC时间会长达
1s+
的应用追求停顿时间在
500ms
以内的应用
GC类型
YoungGC:
在G1中,当新生代区域被用完时,G1首先会大概计算一下回收当前的新生代空间需要花费多少时间
如果回收时间远远小于参数
-XX:MaxGCPauseMills
设定的值,那么不会触发YoungGC
- 而是会继续为新生代增加新的
Region
区用于存放新分配的对象实例用户未显式通过
-XX:MaxGCPauseMills
参数设定GC预期回收停顿时间值,那么G1默认为200ms
直至某次
Eden
区空间再次被放满并经过计算后:
此次回收的耗时接近
-XX:MaxGCPauseMills
参数设定的值,那么才会触发YoungGC
当
YoungGC
被触发时:
- 首先会将目标
Region
区中的存活对象移动至幸存区空间(Survivor-From
区标志的Region
)同时达到晋升年龄标准的对象也会被移入至年老代
Region
中存储
- G1收集器在发生
YoungGC
时,复制移动对象时是采用的多线程并行复制,以此来换取更优异的GC性能
MixedGC:
当整个堆中年老代的区域占有率达到参数
-XX:InitiatingHeapOccupancyPercent
设定的值后触发MixedGC
发生该类型GC后:
会回收所有新生代
Region
区部分年老代
Region
区:会根据期望的GC停顿时间选择合适的年老代Region
区优先回收以及大对象
Humongous
区正常情况下,G1垃圾收集时会先发生
MixedGC
,主要采用复制算法
- 在GC时先将要回收的
Region
区中存活的对象拷贝至别的Region
区内
- 拷贝过程中,如果发现没有足够多的空闲
Region
区承载拷贝对象,此时就会触发一次Full GC
FullGC:
当整个堆空间中的空闲
Region
不足以支撑拷贝对象或由于元数据空间满了等原因触发
- 在发生
FullGC
时,G1首先会停止系统所有用户线程,然后采用单线程进行标记、清理和压缩整理内存
- 以便于清理出足够多的空闲
Region
来供下一次MixedGC
使用但该过程是单线程串行收集的,因此这个过程非常耗时(
ShenandoahGC
中采用了多线程并行收集)
- G1收集器中并没有FullGC,G1中的FullGC是采
Sserial old FullGC
GC过程
G1收集器在发生GC时执行过程大致会分为四个步骤(主要指MixedGC
):
初始标记(
InitialMark
):
- 先触发
STW
,然后使用单条GC线程快速标记GCRoots
直连的对象并发标记(
ConcurrentMarking
):
- 与CMS的并发标记过程一致,采用多条GC线程与用户线程共同执行,根据
Root
根节点标记所有对象最终标记(
Remark
):
- 同CMS的重新标记阶段(也会STW),主要是为了纠正并发标记阶段因用户操作导致的错标、误标、漏标对象
筛选回收(
Cleanup
):
- 先对各个
Region
区的回收价值和成本进行排序,找出 回收价值最大 的Region
优先回收- 筛选回收阶段在G1收集器中是会停止所有用户线程后,采用多线程并行回收的
假设此时年老代空间共有
800
个Region
区,并且都满了,所以此刻会触发GC但根据GC的预期停顿时间值,本次GC只能允许停顿
200ms
,而G1经过前面的成本计算后,大致可推断出:
- 本次GC回收
600
个Region
区恰好停顿时间可控制在200ms
左右- 那么最终就会以 回收
600
个Region
区 为基准触发GC
- 这样则能尽量确保GC导致的停顿时间可以被控制在我们指定的范围之内
G1会在后台维护着一个优先列表:
CollectionSet(CSet)
,它记录了GC要收集的Region
集合,集合里的Region
可以是任意年代的在G1中回收算法都是采用复制算法,都会将一个
Region
区中存活的对象复制到另外一个Region
区内
G1缺点:
G1需要记忆集 (卡表) 来记录新生代和老年代之间的引用关系
- 这种数据结构在 G1 中需要占用大量的内存,可能达到整个堆内存容量的 20% 甚至更多
- 而且 G1 中维护记忆集的成本较高,带来了更高的执行负载,影响效率
CMS 在小内存应用上的表现要优于 G1,而大内存应用上 G1 更有优势,大小内存的界限是6GB到8GB
类加载机制
当JVM需要用到某个类时,虚拟机会加载它的
.class
文件,加载了相关的字节码信息后,会为它创建对应的Class
对象,这个过程就被称为类加载。类加载过程被分为三个步骤,五个阶段,分别为加载、验证、准备、解析以及初始化。
加载、验证、准备、初始化这四个阶段的顺序是确定的。
- 解析阶段不一定,在某些情况下可以在初始化阶段之后再开始(动态绑定或晚期绑定)。
加载步骤
指通过完全限定名查找Class文件二进制数据并将其加载进内存的过程:
- 通过完全限定名查找定位
.class
文件,并获取其二进制字节流数据。- 把字节流所代表的静态存储结构转换为运行时数据结构。
- 在堆中间中为其创建一个
Class
对象,作为程序访问这些数据的入口。
验证阶段
主要用于确保被加载的
Class
正确性,检测Class
字节流中的数据是否符合虚拟机的要求,确保不会危害虚拟机自身安全。验证阶段主要包括四种验证:
- 文件格式验证、元数据验证、字节码验证以及符号引用验证。
准备阶段
准备阶段主要是为类中声明的静态变量分配内存空间,并将其初始化成默认值(零值)。
- 在这里进行的内存分配仅包括类成员(
static
成员),而实例成员则会在创建具体的Java对象时被一起分配在堆空间中。- 同时也不包含使用
final
修饰的static
成员,因为final
在编译的时候就会分配了,准备阶段会显示初始化。
解析阶段
解析阶段主要是把类中对常量池内的符号引用转换为直接引用的过程。
- 解析操作往往会伴随着JVM在执行完初始化之后再执行。
符号引用:
- 用一组符号来描述引用的目标,符号引用的字面量形式明确定义在 Java虚拟机规范 的Class文件格式中。
直接引用:
- 直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
符号引用转直接引用的过程,主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符等7类符号引用进行(分别对应常量池中的
CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info
等)。
初始化步骤
初始化步骤中,主要是对类的静态变量赋予正确的初始值,也就是在声明静态变量时指定的初始化值以及静态代码块中的赋值。
- 本质上就是执行类构造器方法
<clinit>()
的过程。
使用、卸载
当一个类完整的经过了类加载过程之后,在内存中已经生成了Class对象,同时在Java程序中可以通过它开始创建实例对象使用。
当一个Class对象不再被任何一处位置引用,即不可触及时,Class就会结束生命周期,该类加载的数据也会被卸载。
Java虚拟机自带的类加载器加载的类,在虚拟机的生命周期中始终不会被卸载,因为JVM始终会保持与这些类加载器的引用,而这些类加载器也会始终保持着自己加载的Class对象的引用,所以对于虚拟机而言,这些Class对象始终是可以被触及的。
- 由用户自定义的类加载器加载的类是可以被卸载的。
类加载器
Bootstrap引导类加载器
引导类加载器也被称为启动类加载器或根类加载器
- 指
BootstrapClassLoader
。引导类加载器使用C++语言实现,是JVM自身的一部分
- 主要负责将
<JAVA_HOME>\lib
路径下的核心类库- 或
-Xbootclasspath
参数指定的路径下的jar
包加载到内存中。
Extension拓展类加载器
位于
HotSpot
源码目录中的sun.misc.Launcher$ExtClassLoader
位置。它主要负责加载
<JAVA_HOME>\lib\ext
目录下
- 或者由系统变量
-Djava.ext.dir
指定位路径中的类库。
Application系统类加载器
位于
HotSpot
源码目录中的sun.misc.Launcher$AppClassLoader
位置。它负责加载系统类路径
java -classpath
- 或
-D java.class.path
指定路径下的类库,也就是classpath
路径。该类加载器是程序的默认类加载器
- 可以通过
ClassLoader.getSystemClassLoader()
方法可以直接获取到它。
自定义类加载器
如果需要自定义类加载器,只需要继承
ClassLoader
类
- 但继承
ClassLoader
需要自己重写findClass()
方法并编写加载逻辑在自定义
ClassLoader
的子类时,会有两种做法:
重写
loadClass()
方法重写
findClass()
方法
loadClass()
最终调用的还是findClass()
方法
如果想打破双亲委派模型,那么就重写整个
loadClass
方法如果不想打破双亲委派模型,那么只需要重写
findClass
方法
线程上下文类加载器
很多服务提供者接口(
Service Provider Interface,SPI
),这些接口允许第三方为它们提供实现
- 如常见的
SPI
有JDBC、JNDI
等这些
SPI
的接口属于Java
核心库,一般存在rt.jar
包中
- 由启动类加载器(
Bootstrap
)加载- 而
SPI
的第三方实现代码则是作为Java
应用所依赖的jar
包被存放在classpath
路径下。由于
SPI
接口中的代码需要加载第三方实现类并调用其相关函数
- 但
SPI
的核心接口类是由启动类加载器(Bootstrap
)加载的,Bootstrap
加载器无法直接加载SPI
的实现类。SPI提供了一种动态的服务发现机制(约定),只要第三方在编写实现类时
- 在工程内新建一个
META-INF/services/
目录并在该目录下创建一个与服务接口名称同名的文件- 那么在程序启动的时候,就会根据约定去找到所有符合规范的实现类
- 然后交给线程上下文类加载器进行加载处理。
线程的上下文类加载器默认设置的就是系统类加载器(
System
)。
双亲委派机制
双亲委派类加载过程
当
App
尝试加载一个类时,它不会直接尝试加载这个类
- 首先会在自己的命名空间中查询是否已经加载过这个类
- 如果没有会先将这个类加载请求委派给父类加载器
Ext
完成当
Ext
尝试加载一个类时,它也不会直接尝试加载这个类
- 也会在自己的命名空间中查询是否已经加载过这个类
- 没有的话也会先将这个类加载请求委派给父类加载器
Bootstrap
完成如果
Bootstrap
加载失败,这个需要被加载的类不在Bootstrap
的加载范围内
- 那么
Bootstrap
会重新将这个类加载请求交由子类加载器Ext
完成如果
Ext
加载失败,这个类也不在Ext
的加载范围内
- 最后会重新将这个类加载请求交给子类加载器
App
完成如果
App
加载器也加载失败这个类根据全限定名无法查找到
- 则会抛出
ClassNotFoundException
异常。
此处的父子关系并非继承关系,而是采用组合关系来实现
双亲委派机制的优势
可以避免一个类在不同层级的类加载器中重复加载
- 如果父类加载器已经加载过该类了,那么就不需要子类加载器再加载一次
沙箱安全机制:
可以保障Java核心类的安全性问题
- 比如通过网络传输过来一个
java.lang.String
类,需要被加载时,通过这种双亲委派的方式
- 最终找到
Bootstrap
加载器后,发现该类已经被加载- 从而就不会再加载传输过来的
java.lang.String
类,而是直接返回Bootstrap
加载的String.class
这样可以有效防止Java的核心API类在运行时被篡改
- 从而保证所有子类共享同一基础类,减少性能开销和安全隐患问题
双亲委派的实现原理
所有的类加载器都间接的继承自
ClassLoader
类
- 包括
Ext、App
类加载器(Bootstrap
除外,因为它是C++实现的)双亲委派模型的实现逻辑全在于
loadClass()
方法
- 而
ExtClassLoader
加载器是没有重写loadClass()
方法,AppClassLoader
加载器虽然重写了loadClass()
方法,但其内部最终还是调用父类的loadClass()
方法无论是
ExtClassLoader
还是AppClassLoader
加载器
- 其本身都未打破
ClassLoader.loadClass()
方法中定义的双亲委派逻辑Bootstrap、Ext、App
这些JVM自带的类加载器都默认会遵守双亲委派模型
// sun.misc.Launcher类 → AppClassLoader内部类 → loadClass()方法
public Class loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
int i = name.lastIndexOf('.');
if (i != -1) {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPackageAccess(name.substring(0, i));
}
}
// 依旧调用的是父类loadClass()方法
return (super.loadClass(name, resolve));
}
打破双亲委派机制
需要重写loadClass方法,在
loadClass
方法中不委托给父类尝试着进行加载,直接在当前的类加载器进行加载。打破双亲委派机制的场景:
- 一个Tomcat可能部署多个应用,不同的应用可能依赖的同一个第三方类库的不同版本(会造成很多大量的文件路径相同的类)
- 这种情况下就不能通过双亲委派机制去加载,要保证每个应用的类库是独立的,相互隔离。
即时编译
Java编译器经过解释执行,其执行速度必然会比直接执行可执行的二进制字节码慢很多。
- 为了解决这种效率问题,引入了 JIT(Just In Time ,即时编译) 技术。
当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是热点代码:
- JIT会把部分热点代码翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。
热点检测
目前主要的热点代码识别方式是热点探测,HotSpot虚拟机中采用的主要是基于计数器的热点探测。
采用这种方法的虚拟机会为每个方法,甚至是代码块建立计数器,统计方法的执行次数。
某个方法超过阀值就认为是热点方法,触发JIT编译。
编译优化
JIT在做了热点检测识别出热点代码后,除了会对其字节码进行缓存,还会对代码做各种优化。
- 逃逸分析,锁消除,锁膨胀,方法内联等。
逃逸分析
当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
有了逃逸分析,可以判断出一个方法中的变量是否有可能被其他线程所访问或者改变,基于这个特性,JIT做了一些优化:
- 标量替换,栈上分配
通过逃逸分析,Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
标量替换
如果经过逃逸分析,发现一个对象不会被外界访问,经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。
栈上分配
经过逃逸分析技术发现这个对象并没有被外部引用且仅在当前线程使用,那么它就会将该对象分配在栈上。
锁消除
逃逸分析某些没有被外部方法或者其他线程引用的情况下,会将某些锁消除。
方法内联
对于可以内联的方法,直接复制到调用者代码中,减少方法调用次数和性能消耗。
常见问题
问题排查工具
服务器上查看堆栈信息工具
登陆上具体机器,执行命令:
wget https://raw.github.com/oldratlee/useful-scripts/release-2.x/bin/show-busy-java-threads
下载脚本:
- sh 脚本,即可得到最忙线程信息
内存泄漏
对象不会再被程序用到了,但是GC又不能回收他们的情况,叫内存泄漏。
内存泄漏举例:
单例模式:
- 单例的生命周期和应用程序一样长,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏。
资源未关闭 :
- 如:数据库连接,网络连接和IO连接必须手动Close,否则是不能被回收的。
内存溢出
元空间溢出
如果元空间发生内存溢出,会造成操作系统的内存溢出。
元空间溢出主要是由于加载的类太多,或者动态生成的类太多。
堆外内存溢出
这里的堆外内存指的是 Java 应用程序通过直接方式从操作系统中申请的内存。
在 Java 中可以通过相应的 API 去申请直接内存,通过监控工具,能够看到内存占用的明显增长。
为了限制这些危险的内存申请,可以通过设置相应的参数进行调整。
栈溢出
线程请求的栈深度大于虚拟机允许的最大深度。
虚拟机在扩展栈深度时,无法申请到足够的内存空间:OutOfMemoryError
性能调优
何时进行JVM调优?
遇到以下情况,就需要考虑进行JVM调优了:
- Heap内存(老年代)持续上涨达到设置的最大内存值
- Full GC 次数频繁
- GC 停顿时间过长(超过1秒)
- 应用出现OutOfMemory等内存异常
- 系统吞吐量与响应性能不高或下降
JVM调优基本原则:
大多数的Java应用不需要进行JVM优化
大多数导致GC问题的原因是代码层面的问题导致的(代码层面)
上线之前,应先考虑将机器的JVM参数设置到最优
减少创建对象的数量(代码层面)
减少使用全局变量和大对象(代码层面)
优先架构调优和代码调优,JVM优化是不得已的手段(代码、架构层面)
分析GC情况优化代码比优化JVM参数更好(代码层面)
JVM调优目标:
延迟:
- GC低停顿和GC低频率
没有FullGC
高吞吐量
JVM调优步骤:
一般情况下,JVM调优可通过以下步骤进行:
- 分析GC日志及Dump文件,判断是否需要优化,确定瓶颈问题点
- 确定JVM调优量化目标
- 确定JVM调优参数(根据历史JVM参数来调整)
- 依次调优内存、延迟、吞吐量等指标
- 对比观察调优前后的差异
- 不断的分析和调整,直到找到合适的JVM参数配置
- 找到最合适的参数,将这些参数应用到所有服务器,并进行后续跟踪
JVM调优建议
年轻代和老年代的比例需要结合实际场景调整
- 由于老年代的GC成本通常都会比年轻代的成本要高许多。
- 所以建议适当地通过
Xmn
命令区设置年轻代的大小,最大限度的降低对象晋升到老年代的情况。合理设置Eden区和Survivor区比例
- 合理分配Eden区和Survivor区,尽量不要让对象进入老年代。