JVM结构
- 程序计数器:指向线程当前正在执行的字节码指令的地址(行号)
- 虚拟机栈:存储线程当前运行方法的数据、指令、返回地址
- 本地方法栈:存储线程调用本地方法(如C++代码)时的相关信息
- 方法区:存储类信息、常量、静态变量等
- 堆:存储对象实例等
线程结构如下图,方法区、堆是共享的
虚拟机栈结构
虚拟机栈通过栈帧存储每个方法调用的信息,包括局部变量表、操作数栈、方法出口等,一个栈帧对应一个方法。
局部变量表中对于基本类型数据直接存在栈中,如果是对象则存储引用(指针),指向堆中的实例。
GC
GC的发展
GC的作用是垃圾回收,经过多年的发展,GC有多个版本的更新,从Serial到CMS再到G1,目前最前的成果是Shenandoah和ZGC。(G1收集器是JDK7开始有实现,JDK9开始作为默认收集器)
由于GC过程会造成用户线程停顿(stop the word现象),所以GC每个版本的提升方向主要都是提高吞吐量,缩短停顿时间。
为了降低GC的性能损耗,针对实际使用过程中不同对象存活的时间不同,GC把内存划分为新生代、老年代、永久代。针对不同代分别对应不同的GC,不同代的GC频率不一样,结合内存对象数量及GC频率的需求,不同代又有不同的算法,如复制算法、标记-清理算法、标记-整理算法等。
附:
新生代、老年代、永久代
注:不同代的GC内存划分结构及算法会有差异,以下内容是针对经典结构进行描述。
为了提升GC的性能,减少GC的耗时,GC把内存划分为新生代、老年代、永久代。
其中新生代、老年代对应是堆的划分,永久代对应的是方法区。
新生代
主要是用来存放新生的对象。一般占据堆空间的1/3,由于频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。(基于标记-复制算法,通过空间换时间,因为大部分对象是不存活的,需要复制的存活对象不多,可以确保性能)
新生代分为Eden区、ServivorFrom、ServivorTo三个区:
Eden区:
Java新对象的出生地(如果新创建的对象占用内存很大则直接分配给老年代)。当Eden区内存不够的时候就会触发一次MinorGc,对新生代区进行一次垃圾回收。ServiorFrom区、ServiorTo区:
- 首先,把Eden和ServiorFrom区域中存活的对象复制到ServivorTo区域(如果有对象的年龄已经达到了老年的标准,则复制到老年代),同时把这些对象年龄+1。
- 清空Eden区和ServivorFrom中的对象。
- 最后,ServivorTo和ServivorFrom互换,原ServivorTo成为下一次GC时的ServivorFrom区。
老年代
老年代的对象比较稳定,所以MajorGC不会频繁执行。当老年代也满了装不下的时候,就会抛出OOM。
永久代
指内存的永久保存区域,主要存放Class和Meta(元数据)的信息。Class在被加载的时候元数据信息会放入永久区域,但是GC不会在主程序运行的时候清除永久代的信息。所以这也导致永久代的信息会随着类加载的增多而膨胀,最终导致OOM。
(注意: 在Java8中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。)
GC触发时机
简单描述:
- MinorGC:Eden区满的时候。
- youngGC: 发生在Eden、S0、S1区
- MajorGC:老年代空间不足时触发。
- FullGC:老年代空间不足、永久代不足都可能触发。
CMS与G1的区别
新生代由于每次垃圾回收时只有少量对象存活,所以采用“标记-复制”算法,通过空间换取时间;而老年代由于对象多,复制的效率低且空间代价大,只能采用“标记-清除”或“标记-整理”算法。
CMS
步骤
基于“标记-清除”算法实现:
- 初始标记(CMS initial mark):独占CPU,stop-the-world, 仅标记GCroots能直接关联的对象,速度比较快;
- 并发标记(CMS concurrent mark):可以和用户线程并发执行,通过GCRoots Tracing 标记所有可达对象;
- 重新标记(CMS remark):独占CPU,stop-the-world, 对并发标记阶段用户线程运行产生的垃圾对象进行标记修正,以及更新逃逸对象;
- 并发清理(CMS concurrent sweep):可以和用户线程并发执行,清理在重复标记中被标记为可回收的对象。
优点
- 支持并发收集
- 低停顿,因为CMS可以控制将耗时的两个stop-the-world操作保持与用户线程恰当的时机并发执行,并且能保证在短时间执行完成,这样就达到了近似并发的目的
缺点
- CMS收集器对CPU资源非常敏感,因为占用了一部分CPU资源,如果在CPU资源不足的情况下应用会有明显的卡顿
- 无法处理浮动垃圾
- CMS清理后会产生大量的内存碎片,当有不足以提供整块连续的空间给新对象/晋升为老年代对象时又会触发FullGC
G1
G1收集器的内存结构完全区别于CMS,弱化了CMS原有的分代模型(分代可以是不连续的空间),将堆内存划分成一个个Region(1MB~32MB, 默认2048个分区),这么做的目的是在进行收集时不必在全堆范围内进行。
步骤
基于“标记-整理”算法实现:
- 初始标记(Initial Marking):标记一下GC Roots能直接关联到的对象,此阶段是stop-the-world操作。
- 根区间扫描,标记所有幸存者区间的对象引用,扫描 Survivor到老年代的引用,该阶段必须在下一次Young GC 发生前结束。
- 并发标记(Concurrent Marking):是从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行,该阶段可以被Young GC中断。
- 最终标记(Final Marking):是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,此阶段是stop-the-world操作,使用snapshot-at-the-beginning (SATB) 算法。
- 筛选回收(Live Data Counting and Evacuation):首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,回收没有存活对象的Region并加入可用Region队列。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
优点
- 并行与并发:G1充分发挥多核性能,使用多CPU来缩短Stop-The-world的时间
- 分代收集:G1能够自己管理不同分代内已创建对象和新对象的收集。
- 空间整合:G1从整体上来看是基于‘标记-整理’算法实现,从局部(相关的两块Region)上来看是基于‘复制’算法实现,这两种算法都不会产生内存空间碎片。
小结
JVM在各个分区内存占满时将触发GC,如果内存不占满在没有手动调用system.gc()的情况下即使变量设置为null也不会立刻被回收。
GC判断对象是否可回收是通过“引用标记计数法”或“可达性判断法”判断,当一个对象没有被GC Root直接或间接引用时即可以被回收。
Spring 中创建的Bean将存储于一个map结构中,所以所有Bean都是一直存活的,这也是经常JVM运行久了会有内存增长的原因,通常业务正确的时候增长到最后是处于一个稳定的状态,而方法中定义的变量都是临时变量,使用完后即会自动释放,所以较少情况需要手动指定变量为null以标记GC可回收对象。当然,在非常确定对象不再需要使用时可手动指定对象为null。
内存泄漏例子
尽量避免定义公共变量,如必要,需加倍注意内存占用大小
容器使用中会造成瞬时内存泄漏,如果到达内存临界点容易触发OOM
单例模式导致的内存泄露
单例模式,很多时候我们可以把它的生命周期与整个程序的生命周期看做差不多的,所以是一个长生命周期的对象。如果这个对象持有其他对象的引用,也很容易发生内存泄露。
HashMap使用的内存泄漏例子
只有把map变量也设置为null才能完全断开持有。
JVM类的加载机制
类的加载时机
- 隐式加载 new 创建类的实例,
- 显式加载:loaderClass,forName等
- 访问类的静态变量,或者为静态变量赋值
- 调用类的静态方法
- 使用反射方式创建某个类或者接口对象的Class对象。
- 初始化某个类的子类
- 直接使用java.exe命令来运行某个主类
类的加载过程
JVM类加载机制分为五个步骤:加载、[验证、准备、解析]、初始化。
- 加载:把class字节码文件从各个来源通过类加载器装载入内存中,来源包括.class文件、jar包中的.class文件、动态代理编译等;
- 验证:验证加载的class文件的正确性,如错误的语法等(因为.class文件可能被人为修改,或者不同的jdk版本不能适配对应语法等);
- 准备:给类中的静态变量分配内存空间;
- 解析:虚拟机将常量池中的符号引用替换成直接引用(即内存地址指向)的过程。
- 初始化:对静态变量和静态代码块执行初始化;
类加载过程只是一个类生命周期的一部分,在其前,有编译的过程,只有对源代码编译之后,才能获得能够被虚拟机加载的字节码文件;在其后还有具体的类使用过程,当使用完成之后,还会在方法区垃圾回收的过程中进行卸载。
双亲委托模型
双亲委派模式是Java1.2之后引入的,其工作原理是,如果其中一个类加载器收到了类加载的请求,它并不会自己去加载而是会将该请求委托给父类的加载器去执行,如果父类加载器还存在父类加载器,则进一步向上委托,如此递归,请求最终到达顶层的启动类加载器。如果父类能加载,则直接返回,如果父类加载不了则交由子类加载,这就是双亲委派模式。
类的加载主要ClassLoader(类加载器)负责, JVM中提供了三层ClassLoader:
Bootstrap classLoader(启动类加载器):主要负责加载核心的类库,由c++来写的,加载的是Javahome/jre/lib/rt.jar
ExtClassLoader(扩展类加载器):主要负责加载jre/lib/ext/*.jar。
AppClassLoader(应用类加载器):主要负责加载classPath下面的类。
执行路径如下图,当加载类时由AppClassLoader -> ExtClassLoader -> BootstrapClassLoader的方向逐级查询是否已加载过,如果加载过则不再加载;到达顶端后再由BootstrapClassLoader -> ExtClassLoader -> AppClassLoader 的方向逐级判断自己是否可以加载,如果可以则加载,如果不能则交给子类进行加载,直到没有ClassLoader可以加载时抛出ClassNotFoundException。
双亲委派模型的优势
双亲委派模型的优势
JVM的调优
内存配置参数
- Java整个堆大小设置,Xmx 和 Xms设置为老年代存活对象的3-4倍,即FullGC之后的老年代内存占用的3-4倍
- 永久代 PermSize和MaxPermSize设置为老年代存活对象的1.2-1.5倍。
- 年轻代Xmn的设置为老年代存活对象的1-1.5倍。
- 老年代的内存大小设置为老年代存活对象的2-3倍。
另:
1、Sun官方建议年轻代的大小为整个堆的3/8左右, 所以按照上述设置的方式,基本符合Sun的建议。
2、堆大小=年轻代大小+年老代大小, 即xmx=xmn+老年代大小 。 Permsize不影响堆大小。
假设FullGC后老年代相对稳定占用的内存为2G,则整体分配大概如下:
内存分析(Jmap)
Jmap命令
1 | jmap -heap pid |
Jmap dump命令
1 | jmap -dump:format=b,file=heapdump.phrof pid |
配置内存溢出时dump
1 | -XX:+HeapDumpOnOutOfMemoryError |
内存溢出排查工具MAT
通过Mat工具可以对dump文件分析,跟踪内存溢出等情况。
线程分析(JStack)
操作系统Top
通过top命令查看CPU是否异常
在top中找到CPU占用高的pid后,可通过top -Hp
Jstack
用法
可结合top -Hp
另外一种情况是可以分析jstak 的信息看是否有线程BLOCKING阻塞状态,判断是否有锁冲突。
jstat -gc pid
查看GC情况