开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情
内存划分和溢出
VM内存划分:
JVM把内存划分为以下几个主要的区域:
方法区
所有线程共享的内存区域,用于存储JVM加载的类信息、常量、静态变量、JIT的代码等数据;同时为了和堆区分,方法区也叫非堆(Non-Heap)。
JDK8之前常被称为 永久代,实际上不准确。
运行时常量池
方法区内部还包含一块叫“运行时常量区”,该区域主要存储类的各种字面量和符号引用等数据。
常见的方法有 String类的 intern() 方法。
java堆
所有线程共享的内存区域,一般这是JVM中占用内存最大的区域,几乎所有的对象实例和数组都存储在这个区域(像JIT的代码不在这个区域)。
该区域也是垃圾回收的主要区域,因此也叫GC堆。
java堆可以是固定的,也可以是可扩展的(目前基本都是可扩展的,可通过-Xmx和-Xms设定)。
虚拟机栈
线程私有的,主要存储方法的内存模型,比如方法中的局部变量、操作数、方法出口等,当调用一个方法时,整个过程都对应到栈的IO操作,这个区域也就是平时所说的 栈。
栈内存包括两类异常:
- 线程请求的栈深度大于虚拟机允许的深度,抛出StackOverflowError
- 虚拟机容量可以动态扩展,当栈扩展时无法申请足够的内存,抛出OutOfMemory
本地方法栈
线程私有的,和虚拟机栈基本一样,区别就是调用本地方法,虚拟机栈是调用JAVA方法。
抛出异常情况,和虚拟机栈相同。
程序计数器
线程私有的,主要是用来存储当前线程执行指令地址的,这样当多线程来回调度时,知道上次本线程执行到哪个指令。
对象的创建
- new后,检查 常量池中是否存在类的符号引用,并检查符号引用的类是否已经加载、解析和初始化(如果没有会先执行类的加1. 载)。
- 虚拟机为新生代对象分配内存(有两种分配方式:指针碰撞(BTP)、空闲列表(Free List))
- 把分配到的内存空间都初始化为零值
- 进行对象必要的设置:对应的类信息、GC分代年龄等
- 至此,构造函数完成了,后续再进行对象的初始化
并发申请内存时,可能会冲突,一般采用两个策略保障原子性:
- 采用CAS失败重试
- 每个线程的java堆中预先分配一个内存块,即TLAB,线程在自己的TLAB中分配内存非新对象,用完后再启用同步锁去扩充内存
虚拟机是否使用TLAB,通过 -XX: +/-UserTLAB 参数来设定
对象的内存布局
在堆内存中存储布局分三部分:对象头Header、实例数据InstanceData、对齐填充Padding
对象头
包括两类信息:
- 存储自身的运行时数据,如锁状态之类
- 类型指针,根据指针可以找到类信息(非必须的)
实例数据
对象真正的有效信息,比如字段之类
对齐填充
没特殊意义,非必须的,只是占位符作用。
对象的访问定位
HotSpot使用上图直接指针访问对象的方式
JVM内存溢出
JVM内存溢出常见两种:栈溢出、内存溢出,JVM每个区域(除了PCR之外)都有可能发生OOM异常。
堆溢出
当不断的创建对象,并且对象无法被回收时,容易发生溢出。
// 一个栗子
public class HeapTest {
public static void main(String[] args) {
List<OOMTemp> list=new ArrayList<>();
AtomicInteger count=new AtomicInteger(0);
Thread.setDefaultUncaughtExceptionHandler((t,e)->{
System.out.println(count);
System.out.println(e.getMessage());
});
// 810325
while (true){
list.add(new OOMTemp());
count.incrementAndGet();
}
}
static class OOMTemp{ }
}
在执行之前,首先设置堆内存大小:-Xms20m -Xmx20m,其中-xms是堆的最小内存,-xmx是最大内存,都设置为20m
这样会输出(第二行开始显示创建了dump文件,是为了分析为什么溢出,在执行前加上opts:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:/):
-XX: +HeapDumpOnOutOf-MemoryError 会让虚拟机在出现内存溢出异常时Dump当前内存堆转储快照以备事后分析
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid4612.hprof ...
Heap dump file created [42076663 bytes in 0.208 secs]
1215487
Java heap space
Process finished with exit code 1
通过dump文件可以看出来是OOMTemp实力问题:
工具:下载eclipse memory analyzer,打开dump文件即可
栈溢出
HotSpot虚拟机不区分 虚拟机栈和本地方法栈,所以 -Xoss参数(设置本地方法栈大小)无效,栈只能使用-Xss设置
因HotSpot不支持栈动态扩展,所以除非在创建线程时,因申请不到足够的内存而出现OOM,否则在线程运行时,只会因栈容量不足而导致SOF。
在单线程环境下,不太容易模拟OOM,但是SOF还是可以的,直接不停的递归调用一个方法,方法会不断的执行压栈操作,即会发生StackOverflowError。
在执行之前,还是首先配置内存大小:-Xss1m,发生SOF的原因就是栈被打满,请求地址已经超出栈的最大地址。
如果想模拟OOM,可以采用多线程方式,并且每个线程的栈内存越大,越容易发生OOM。
每个进程的内存是有限的,比如32位window最大2GBit,那么 剩余内存 = 2GBit - 堆内存(xmx) - 方法区内存(maxpermsize),栈内存只能在剩余内存中分割,所以每个线程中栈内存越大,线程多了就会OOM。
方法区和运行时常量池溢出
JDK7去掉永久代,在JDK8中使用元空间来替代永久代。
- 在JDK6或之前版本中,常量池可以通过 -XX:PermSize和-XX:MaxPermSize限制永久代大小
- JDK7之后可使用-XX:MaxMeta-spaceSize参数设置元空间大小
JDK8 之后,永久代退出,元空间上场。字符串常量池从永久代迁移到了堆中。
HotSpot依然提供了配置:
- -XX:MaxMetaspaceSize:设置元空间最大值,默认-1,即受限于本地内存
- -XX:MetaspaceSize:元空间初始大小,单位字节。达到该值便会触发GC
常用优化参数及说明
1、-Xmx512m :设置Java虚拟机的堆的最大可用内存大小,单位:兆(m),整个堆大小=年轻代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为64m。堆的不同分布情况,对系统会产生一定的影响。尽可能将对象预留在新生代,减少老年代GC的次数(通常老年回收起来比较慢)。实际工作中,通常将堆的初始值和最大值设置相等,这样可以减少程序运行时进行的垃圾回收次数和空间扩展,从而提高程序性能。
2、-Xms512m :设置Java虚拟机的堆的初始值内存大小,单位:兆(m),此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存。
3、-Xmn170m :设置年轻代内存大小,单位:兆(m),此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。一般在增大年轻代内存后,也会将会减小年老代大小。
4、-Xss128k :设置每个线程的栈大小。JDK5.0以后每个线程栈大小为1M,以前每个线程栈大小为256K。更具应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。
5、 -XX:NewRatio=4 :设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5 。
6、-XX:SurvivorRatio=4 :设置年轻代中Eden区与Survivor区的大小比值。设置为4,则两个Survivor区与一个Eden区的比值为2:4,一个Survivor区占整个年轻代的1/6。
7、-XX:MaxPermSize=16m :设置持久代大小为16m,上面也说了,持久代一般固定的内存大小为64m。
8、-XX:MaxTenuringThreshold=0:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概论。
垃圾回收
在上节介绍过,其中堆中存储几乎所有的对象实例,如上图(这里没有PermGen,因为JDK8以后已经取消了永久代)。
【简单过程】对象是有生命周期的,new一个对象时,会被分配到eden区域,当eden满了以后触发一次Minor GC,仍生存的对象被复制到from区域,当再次GC时,对象从from被复制到to,这个时候from和to的角色互换了,以后再GC,对象会又被辅助到from,来来回回,每次对象的代龄都+1,当累加到一定值(默认18)后,对象还存活的话,被复制到old。
这里有一个区别,如果是大对象,直接进入old,不执行来来回回的过程。
那么如何判定这个对象是不是还活着呢?java采用的是可达算法(除此之外还有计数算法),如果该对象有到达GC Roots的引用路径,就认为是活的,可作为GC Roots的对象包括:
- 栈中引用的对象;
- 方法区类静态属性引用的对象;
- 方法区常量引用的对象;
引用路径根据强弱又分为四种(算上 JNI Weak Reference 就是五种):强引用(Strong Ref)>软引用(Soft Ref)>弱引用(Weak Ref)>虚引用(Phantom Ref)
- 强引用:比较常见,如 Student stu=new Student();这个类就是强引用;
- 软引用:一般在内存不够时,会被回收,这种引用可有可无,使用SoftReference实现;并不是每次GC都被回收,只有在内存不足时,才根据LRU策略回收。
- 弱引用:也是可有可无的引用,一旦遇到GC就被回收,使用WeakReference实现。
- 虚引用:最弱的引用,也是形同虚设的引用,唯一的目的是对象被GC时接收系统通知,使用PhantomReference实现。
当GC时,如果判断该对象和GC Roots没有可达路径,也不是立即被回收,过程如下:
首先判断是否可达,如果不可达,第一次标记该对象,标记后会判断该对象要不要执行一次finalize()方法;然后把对象放入F-Queue队列中,等待再次标记;GC对F-Queue中的对象再做一次标记,这个时候如果发现某对象又可达了,则被移出F-Queue队列,不再回收,否则被最终回收掉。
所以如果一个对象不想被回收,在GC的第二次标记时,还是有机会再抢救一次,比如把自己赋给某个类的变量等等,这也是为什么有时候看到一个对象的finalize()方法被执行了,但是没有回收的原因。另外说到finalize()方法,因为对象的该方法只会被执行一次,所以只有在第一次被GC时可以在这个方法内抢救自己一次,以后再被GC时该方法不会再执行,也就没办法抢救自己了,只能任命了。
垃圾回收算法
标记算法:
- 标记-清楚算法(Mark-Sweep):首先标记需要被回收的对象,然后再进一步清除。这种算法会产生很多内存碎片,因为被清除的对象内存地址并不是连贯的,当再有一个对象生成时,可能找不到能放下自己的内存空间,不得不再执行一次GC,效率不是很高。
- 复制算法(Copying):把内存分为两块A和B,当A中内存用完之后,把仍然存活的对象复制到B中,然后清空整个A。这种方式虽然高效,但是可用内存只剩下一半。
- 标记-整理算法(Mark-Compact):如果新生代对象存活率比较高,采用复制算法效率会变低,所以采用了标记-整理算法,和标记-清理算法第一步一样,但第二步不清理,而是把存活的对象向一端移动,这样剩下的就是可用内存空间。
- 分代收集算法(Generational Collection):根据对象存活周期的不同,把内存分为新生代、老年代,这样针对不同的对象采用不同的回收算法。
现在商业JVM都采用复制算法来处理新生代,但不是按照 1:1 来划分内存,研究表明,其中98%的对象是朝生夕死,所以新生代Eden和Survivor空间的比例是8:1,这样只会减少10%的可用内存。如果新生代存活率>98%,那就需要老年代old来承担了,因为survivor内存会不够用。针对老年代一般采用“标记-清理”或“标记-整理”算法来回收。
GC Roots枚举过程:
在前面介绍过,GC之前需要标记哪些对象还活着,就需要从GC Roots开始,判断哪些对象可达,这里介绍整个过程。
- 首先要明确一点,CPU指令执行速度很快,所以内存中的地址一直在变化,在这种情况下判断对象是否可达毫无意义,所以,必须让所有线程暂停,然后再去判断这一时刻对象是否可达,这就是所谓的
Stop The World事件,也是为什么GC时会卡顿的原因。 - 程序中的引用关系千千万万,JVM不可能每次GC前都把所有的引用判断一次。首先JVM中有一个OopMap数据结构用来记录对象中的引用信息,JVM直接扫面OopMap就可以;然后只会判断哪些对象是被静态变量、常量或上下文中引用;
OopMap中并不是记录程序中所有的引用关系(对象偏移量上的类型信息),否则需要暂定的地方就太多了,每次GC会卡到爆,而是只会记录安全点(SafePoint)和安全区(SafeRegion)中的指令:
安全点:执行时间比较久的指令,如方法调用、循环、异常跳转等,这些位置的指令才算安全点。那么GC时如何保证所有线程正好在安全点暂定呢?两种算法:抢先式中断和主动式中断:
· 抢先式:基本不用,简单介绍一下。GC发生时中断所有线程,然后判断哪些线程没有在安全点,就恢复该线程,让其执行到安全点位置;
· 主动式:GC时设置一个中断标记,所有线程在执行到安全点时,都会判断该标记,如果发现中断标记=真,当前线程中断;
安全区: 如果GC时线程正好处于中断状态(如sleep),没办法判断中断标记,这个时候用到安全区(对安全点的扩展)。如果一段代码中引用关系不会发生变化,这个区域就是安全区(记不记得中断的原因就是为了防止引用关系变化?)。
如果当前线程正在执行安全区的指令,这个时候不用判断GC的中断标记,但当要退出安全区时,就必须要判断一次当前是否正在GC扫描,如果是就必须等待可以离开的信号,否则正常执行就可以。
说面说了这么多,都是为了GC标记对象是否存活的过程,标记时必须防止引用关系变化,所以才有了中断,然后衍生出 安全点 和 安全区 概念,举个例子:
当你在打扫卫生时,最希望就是大家不要再乱扔垃圾了,如果这个时候有人不停的扔垃圾,你将永远打扫不完;同时,如果有人不停在你的纸篓里面拣文件,说:”这个还有用“,这种情况下,卫生完全无法打扫。
JStat性能检测
常用指令
- -Xms:堆最小内存
- -Xmx:堆最大内存
- -Xss:栈最大内存
- -XX:+DeapDumpOnOutOfMemoryError内存溢出时记录内存堆快照
- -XX:PermSize和-XX:MaxPermSize方法区大小
- -XX:MaxDirectMemorySize本机直接内存
- -XX:NewRatio=4 新生代和老年代的比例1:4
- -XX:SurvivorRatio=8 eden和suivivor的比例1:1:8
- jps -l 查看机器上jvm列表
- jinfo -flags pid 查看jvm配置
- jstat pid 查看状态
使用jdk自带工具监测性能
# 2s 获取一次,总共获取3次
jstat -gc pid 2s 3
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
87040.0 87040.0 0.0 0.0 524800.0 372183.2 1398272.0 16584.1 21248.0 20183.8 2816.0 2523.3 1 0.033 1 0.045 0.078
87040.0 87040.0 0.0 0.0 524800.0 372183.2 1398272.0 16584.1 21248.0 20183.8 2816.0 2523.3 1 0.033 1 0.045 0.078
87040.0 87040.0 0.0 0.0 524800.0 372183.2 1398272.0 16584.1 21248.0 20183.8 2816.0 2523.3 1 0.033 1 0.045 0.078
- SOC 第一个幸存区分配容量
- SIC第二个幸存区容量
- SOU已使用
- SIU第二个已使用
- EC Eden区分配容量
- EU已使用
- OC老年代已分配
- OU已使用
- MC元数据区已分配
- MU已使用
- YGC年轻代垃圾回收次数
- YGCT年轻代垃圾回收时间
- FGC年老代垃圾回收次数
- FGCT年老代垃圾回收时间
- GCT总垃圾回收时间