前言
JVM是java语言跨平台的基础,java跨平台但是jvm不跨平台。理解jvm需要先了解jvm内存结构以及对象的生命周期(加载到回收)。
一、JVM的内存模型
如下图:
1. 从数据可见性和线程安全划分
线程独占:虚拟机栈,本地方法栈和程序计数器
线程共享:方法区,堆
2. JVM虚拟机栈
jvm每调用一个方法,需要在虚拟机栈开辟一个空间用于执行,也就是栈帧,这个动作叫做压栈。为了执行方法,每个栈帧又分为三部分,分别是局部变量表、操作数栈和常量池指针。在方法执行完成后,这个栈帧就会弹出回收,这个动作叫做出栈。
3. 本地方法栈
虚拟机栈是用来运行java方法的,除了运行java方法外,还需要有本地方法栈来执行C方法。
4. 程序计数器
保证了并发操作下线程切换能找到上次执行的指令位置。
5. 方法区
放弃永久代使用本地内存是为了灵活使用系统内存空间。
这里还有一点需要注意,方法区存储的是常量、静态变量以及即时编译内容,静态方法是保存在class对象尾部的,也就是保存在堆中,而不是方法区。
6. 堆
堆是jvm储存实例化对象的地方,根据特性分为年轻代和老年代。
关于堆的数据结构这里为什么要划分需要从下面三点分析:
- 对象的生存使用周期不同
- 不同生命周期适用于什么算法
- 不同的算法使用不同的数据结构
二、类的加载和实例化
如下图:java的类加载过程主要步骤: 加载>连接>初始化, 其中连接又可以分为三个步骤:验证>准备>解析。
1. 类加载器
a. Bootstrap虽然作为最上层类加载器,在JDK中实际上是空实现,并没有实现ClassLoad
b. ExtenSion类加载器用于加载 jre/lib/* 下的包或类
c. Application类加载器用于加载classpath指定路径下的包和项目中的class
d. Custom自定义类用于扩展自定义加载,例如tomcat、jboss会根据j2ee规范实现自定义类加载器
2. 类加载的双亲委派机制
加载判断流程:
类的加载器选择顺序自上向下选择,当前一个类进行加载时,会先将其委派给这个加载类的父类加载器执行,同理父类加载器会继续委派给父类,直到对顶级父类加载器,再从顶级父类加载器开始判断是否能够执行加载,如果可以,就由父类进行类加载,不行则返还给下一级类加载器,逐级判断,直到符合加载的加载器或最后一层类加载器。
作用:
保证同一个类只会被加载一次。
同包同类名的加载分析
- 如果是通过默认加载器加载,后加载的类会被忽略。
- 通过自定义类加载器可以实现同名同路径类加载,想要获取这个类对象时,需要调用自定义加载器指定加载路径获取。
3. 连接
a. 验证:验证字节码是否正确,Java语法使用是否正确
b. 准备:提前为静态变量内存分配和成员变量赋值默认值
c. 解析:将符号引用解析成为实际引用(例如方法引用、常量和对象引用)
4. 初始化
分析初始化步骤,需要分析在实例化对象时有哪些动作是自动执行的,这里包括使用final修饰的常量成员变量赋值,静态成员变量和普通成员变量在声明时有直接赋值操作,静态代码块以及构造方法。
5. Happens-Before规则
在java环境中,需要一个规则来确定操作与操作之间的先后执行顺序,这个判断规则就是Happens-Before规则。分别有以下几条规则:
- 程序顺序规则:在一个线程中,每个操作Happens-Before与后面操作。(注:这里所谓的操作顺序与指令重排优化并不冲突,这里限制的其实是之前面操作执行结果对于后面操作必须可见,如果不满足这个条件则不允许重排)
- volatile变量规则:如果变量声明了volatile,则对这个变量的写Happens-Before与所有对变量的读操作。(注:这里的读操作包括多线程环境以及指令重排条件)
- 锁规则:对一个锁的解锁Happens-Before于对这个锁的加锁操作。(也就是说想要获取这个锁,必须等待这个锁释放,也可以理解为添加了锁功能的操作必须等获得锁才能继续执行)
- 传递性:如果A操作Happens-Before于B操作,B操作Happens-Before于C操作,则A操作Happens-Before于C操作。
- 线程启动规则:Thread对象的start()方法Happens-Before于此线程其它动作。
- 线程终止规则:线程的所有操作都Happens-Before于线程的终止检测。(也就是如果一个线程查询状态为结束,表示该线程的所有操作都以及执行)
- 线程中断规则:线程的interrupt()方法Happens-Before线程中断执行。(也就是说如果线程发生中断,会在中断执行之前执行interrupt方法)
三、回收算法与垃圾回收器
1. 回收算法
复制算法
特点:速度快,空间利用率比较低。
过程:执行的时候需要额外的空间进行复制,浪费一定的空间。
适用场景:因为年轻代的特点,即需要频繁创建和回收,以及每次回收后保留的数据比较少,所以复制算法适合使用于年轻代GC。
对应垃圾回收器(都是年轻代回收器):Serial(单线程)、Parallel Scavenge(多线程)、ParNew(多线程)
标记-清除算法
特点:响应速度快,但是会导致磁盘碎片化,在大对象时容易出现内存溢出。
过程:先全盘扫描标记需要回收的对象,再将标记对象回收。扫描的时候不影响系统并发响应,在回收时只需要根据标记直接回收目标对象。
适用场景:
对应垃圾回收去:CMS
标记-整理算法
特点:速度较慢,最大化利用空间。
过程:先全盘扫描标记需要回收的对象,再将标记对象回收。扫描的时候不影响系统并发响应,在回收时不只需要根据标记直接回收目标对象还需要移动后面对象位置重新整理磁盘空间。
适用场景:因为老年代的特点,即创建回收的频率不高,每次回收需要整理的保证空间利用率,所以标记-整理算法适用于老年代GC。
对应垃圾回收器:Serial Old(单线程)、Parallel Old(多线程)
2. 垃圾回收器
不同分代的垃圾回收器以及组合方式如下图:
常用的垃圾回收器
年轻代收集器(都是复制算法):Serial(单线程)、Parallel Scavenge、ParNew
老年代收集器:Serial Old(单线程,标记-整理)、Parallel Old(多线程,标记整理)、CMS(标记-清除)
组合收集器:G1
垃圾回收器特点
分析垃圾回收器除了使用算法和作用域以外,还需要根据一下几点分析:
- 等待:用户线程等待时间。
- 并行(Parallel):垃圾回收时,使用多个线程并行执行,此时用户线程处于等待状态。
- 并发:用户线程和GC线程同时执行。
- 吞吐量:程序执行期间除去用户等待占比。
1. Serial和Serial Old
Serial和Serial Old是最早版本的收集器,使用的是单线程回收方式。特点是:简单,多核环境下依然只能使用单线程回收,吞吐量低。但是如果是在单核环境下,使用Serial收集器由于使用简单且不需要切换线程交互,反而效率最高。
2. ParNew
除了使用多线程收集,其它部分与Serial一模一样,在进行收集时采用多线程并发方式收集,更大限度上提高了cpu的使用率,减少了用户线程等待时间,提高了吞吐量。
3. Parallel Scavenge和Parallel Old
Parallel Scavenge也是使用多线程并发方式收集,但是与ParNew最大的区别是Parallel Scavenge提供了GC自适应调节策略,可以配置灵活启动收集器的时机。
Parallel Old是Parallel Scavenge的老年代实现版本。
4. CMS
CMS是作用于老年代的标记-清除算法垃圾回收器。收集器的主要目标是实现最短的服务停顿时间,垃圾回收时与用户线程并发执行。
5. G1
G1垃圾回收器采样并行并发和分块方式管理。可以充分利用多cpu、多核、大内存环境下的硬件优势。
- G1提供可预测停顿功能,不会在空间满了才执行回收,而是内部采用优化算法,根据配置或者默认模式调配垃圾回收时间,减少单次等待时间。
- G1采用分区分块管理思路,不在物理上划分年轻代和老年代,并且将内存空间划分为多个区域,在进行回收处理时将每个区域分开处理,这种处理方式在大内存环境时更有优势。
线上gc频繁分析:
查看最近几次部署更新的代码,分析以下几种情况:
- 代码错误导致大量无用对象创建
- 对象未及时释放,包括分析是否有内存泄漏
- 尽量避免大对象
四、内存溢出和内存泄漏
1. 内存溢出
程序在申请内存时,剩余内存空间不够分配就会导致内存溢出。
例如栈溢出、堆内存溢出、方法区内存溢出等。
2. 内存泄漏
内存中有一部分空间已经被使用了,但是程序中并没有这个空间的引用,也没办法对这部分内存空间进行整理,从逻辑理解上分析就是这部分内存空间消失了。
java出现内存泄漏场景:长生命周期对象持有短生命周期对象引用。
3. 常见内存溢出错误和排查
栈溢出(StackOverflowError):一般是代码问题,例如使用了递归算法却没有写递归终止条件或者设置递归深度限制。
堆溢出(OutOfMemoryError:java heap space):出现这个问题,有可能是内存泄漏,也有可能是内存溢出。内存溢出需要判断代码中是否写了错误的循环新建了许多对象或者创建了过大的实例对象。
方法区溢出(OutOfMemoryError: PermGen space):方法区主要储存的是常量池、静态变量和静态方法、热部署即使编译信息。所以方法区溢出有可能是常量池溢出或者热部署即使编译信息没有及时回收导致。