目录
- JVM的内存结构
- JVM哪些部分会发生内存溢出
- 方法区、永久代、元空间三者之间的关系
- JVM内存参数
- JVM垃圾回收算法
- 1.标记清除法
- 2.标记整理
- 3.标记复制
- 说说GC和分代回收算法
- 三色标记与并发漏标的问题
- 垃圾回收器
- 项目中什么时候会内存溢出,怎么解决
- 类加载过程三个阶段
- 何为双亲委派
- 对象的引用类型分为哪几类?
- 谈谈finalize的理解
JVM的内存结构
Java Source 和 Java Class 分别是java的源代码 和 编译之后的字节码文件
- 执行 javac 命令编译源代码为字节码
- 执行 java 命令
- 创建 JVM,调用类加载子系统加载 class,将类的信息存入方法区
- 创建 main 线程,使用的内存区域是 JVM 虚拟机栈,开始执行 main 方法代码
- 如果遇到了未见过的类,会继续触发类加载过程,同样会存入方法区
- 需要创建对象,会使用堆内存来存储对象
- 不再使用的对象,会由垃圾回收器在内存不足时回收其内存
- 调用方法时,方法内的局部变量、方法参数所使用的是 JVM 虚拟机栈中的栈中内存
- 调用方法时,先要到方法区获得到该方法的字节码指令,由解释器将字节码指令解释为机器码执行
- 调用方法时,会将要执行的指令行号读到程序计数器,这样当发生了线程切换,恢复时就可以从中断的位置继续
- 对于非 java 实现的方法调用,使用内存称为本地方法栈
- 对于热点方法调用,或者频繁的循环代码,由 JIT 即时编译器将这些代码编译成机器码缓存,提高执行性能
说明: 加粗字体代表了 JVM 虚拟机组件
对于 Oracle 的 Hotspot 虚拟机实现,不区分虚拟机栈和本地方法栈
线程私有:
- 程序计数器
- 虚拟机栈
线程共享:
- 堆
- 方法区
JVM哪些部分会发生内存溢出
不会出现内存溢出的区域 – 程序计数器
除了程序计数器不会出现内存溢出,其他部分都有可能出现内存溢出
一. OutOfMemoryError 的情况
- 堆内存耗尽 –——对象越来越多,又一直在使用,不能被垃圾回收
- 方法区内存耗尽 –—— 加载的类越来越多,很多框架都会在运行期间动态产生新的类
- 虚拟机栈累积 –—— 每个线程最多会占用 1 M 内存,线程个数越来越多,而又长时间运行不销毁时
二. 出现 StackOverflowError 的区域
- JVM 虚拟机栈,原因有方法递归调用未正确结束、反序列化 json 时循环引用
方法区、永久代、元空间三者之间的关系
- 方法区是 JVM 规范中定义的一块内存区域,用来存储类元数据、方法字节码、即时编译器需要的信息等
- 永久代是 Hotspot 虚拟机对 JVM 规范的实现(1.8 之前)
- 元空间是 Hotspot 虚拟机对 JVM 规范的另一种实现(1.8 以后),使用本地内存作为这些信息的存储空间
下面是元空间的解析说明
从上图的类加载流程,及元空间何时加载数据可以看出
- 当第一次用到某个类是,由类加载器将 class 文件的类元信息读入,并存储于元空间
- X,Y 的类元信息是存储于元空间中,无法直接访问
- 可以用 X.class,Y.class 间接访问类元信息,它们俩属于 java 对象,我们的代码中可以使用
下面是回收数据的流程
从这张图可以看到
- 堆内存中:当一个类加载器对象,这个类加载器对象加载的所有类对象,这些类对象对应的所有实例对象都没人引用时,GC 时就会对它们占用的对内存进行释放
- 元空间中:内存释放以类加载器为单位,当堆中类加载器内存释放时,对应的元空间中的类元信息也会释放
JVM内存参数
- -Xms 最小堆内存(包括新生代和老年代)
- -Xmx 最大对内存(包括新生代和老年代)
- 通常建议将 -Xms 与 -Xmx 设置为大小相等,即不需要保留内存,不需要从小到大增长,这样性能较好
- -XX:NewSize 与 -XX:MaxNewSize 设置新生代的最小与最大值,但一般不建议设置,由 JVM 自己控制
- -Xmn 设置新生代大小,相当于同时设置了 -XX:NewSize 与 -XX:MaxNewSize 并且取值相等
- 保留是指,一开始不会占用那么多内存,随着使用内存越来越多,会逐步使用这部分保留内存。下同
对于下面这题,来练习一下上面提到的参数
答案:最小内存值为10G,Survivor区总大小是2G——2048m
JVM垃圾回收算法
1.标记清除法
- 找到 GC Root 对象,即那些一定不会被回收的对象,如正执行方法内局部变量引用的对象、静态变量引用的对象
- 标记阶段:沿着 GC Root 对象的引用链找,直接或间接引用到的对象加上标记
- 清除阶段:释放未加标记的对象占用的内存
特点:
- 标记速度与存活对象线性关系
- 清除速度与内存大小线性关系
- 缺点是会产生内存碎片
2.标记整理
- 和前面的标记阶段、清理阶段与标记清除法类似
- 只是多了一步整理的动作,将存活对象向一端移动,可以避免内存碎片产生
特点:
-
标记速度与存活对象线性关系
-
清除与整理速度与内存大小成线性关系
-
缺点是性能上较慢
适合垃圾少的,不用多次移动,适合老年代
3.标记复制
- 将整个内存分成两个大小相等的区域,from 和 to,其中 to 总是处于空闲,from 存储新创建的对象
- 标记阶段与前面的算法类似
- 在找出存活对象后,会将它们从 from 复制到 to 区域,复制的过程中自然完成了碎片整理
- 复制完成后,交换 from 和 to 的位置即可
特点:
- 标记与复制速度与存活对象成线性关系
- 缺点是会占用成倍的空间,毕竟是以空间换时间
但是它不适合老年代的垃圾回收,老年代是存活对象比较多,垃圾对象比较少,这样每次垃圾回手都会进行很多次从form复制到to,也影响了性能
From和To就是Survivor区中的一块
总结:标记整理算法适合老年代的垃圾回收,标记复制算法适合新生代的垃圾回收
说说GC和分代回收算法
GC 的目的在于实现无用对象内存自动释放,减少内存碎片、加快分配速度
GC 要点:
- 回收区域是堆内存,不包括虚拟机栈
- 判断无用对象,使用可达性分析算法(以一定不会回收的对象为根,沿着根对象找,找到了的对象加上标记),三色标记法标记存活对象,回收未标记对象
- GC 具体的实现称为垃圾回收器
- GC 大都采用了分代回收思想
- 理论依据是大部分对象朝生夕灭,用完立刻就可以回收,另有少部分对象会长时间存活,每次很难回收
- 根据这两类对象的特性将回收区域分为新生代和老年代,新生代采用标记复制法、老年代一般采用标记整理法
- 根据 GC 的规模可以分成 Minor GC,Mixed GC,Full GC
分代回收
下面是B站上黑马满一航老师的分代回收的分析流程,超级详细,看一遍就懂。
经过下面的分代回收的流程,会对这些新生代,伊甸园等名字有了新的理解,也会感概起名的人实在是太优雅了
1.伊甸园 eden,最初对象都分配到这里,与幸存区 survivor(分成 from 和 to)合称新生代。
2.当伊甸园内存不足,标记伊甸园与 from(现阶段没有)的存活对象
3.将存活对象采用复制算法复制到 to 中,复制完毕后,伊甸园和 from 内存都得到释放
4.将 from 和 to 交换位置
5.经过一段时间后伊甸园的内存又出现不足
6.标记伊甸园与 from(现阶段没有)的存活对象
7.将存活对象采用复制算法复制到 to 中
8.复制完毕后,伊甸园和 from 内存都得到释放
9.将 from 和 to 交换位置
10.老年代 old,当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足或大对象(免得拷贝来拷贝去,浪费更多时间)会导致提前晋升)
GC 规模
-
Minor GC 发生在新生代的垃圾回收,暂停时间短
-
Mixed GC 新生代 + 老年代部分区域的垃圾回收,G1 收集器特有
-
Full GC 新生代 + 老年代完整垃圾回收,暂停时间长,应尽力避免
三色标记与并发漏标的问题
前面对从根对象找到的对象添加标记,这个添加标记的动作不是一次完成的,而是需要一个过程的
用三种颜色记录对象的标记状态
- 黑色 – 已标记(表示沿着根对象引用已经找到了该对象了,而且该对象内部的其他引用也处理完毕了)
- 灰色 – 标记中(表示沿着根对象引用已经找到了该对象了,但该对象内部的其他引用还未被处理完成,正在处理中)
- 白色 – 还未标记(表示未被处理的)
并发漏标问题
比较先进的垃圾回收器都支持并发标记,即在标记过程中,用户线程仍然能工作。但这样带来一个新的问题,如果用户线程修改了对象引用,那么就存在漏标问题。
场景如下图
1.如图所示标记工作尚未完成
2.用户线程同时在工作,断开了第一层 3、4 两个对象之间的引用,这时对于正在处理 3 号对象的垃圾回收线程来讲,它会将 4 号对象当做是白色垃圾
3.但如果其他用户线程又建立了 2、4 两个对象的引用,这时因为 2 号对象是黑色已处理对象了,因此垃圾回收线程不会察觉到这个引用关系的变化,从而产生了漏标
4.如果用户线程让黑色对象引用了一个新增对象,一样会存在漏标问题
因此对于并发标记而言,必须解决漏标问题,也就是要记录标记过程中的变化。
有两种解决方法:
-
Incremental Update 增量更新法,CMS 垃圾回收器采用
- 思路是拦截每次赋值动作,只要赋值发生,被赋值的对象就会被记录下来(也就是标为黑色的那个),等所有标记操作做完了,必须stop the world(STW)让线程停止,对标记过程中记录的对象再做一遍处理,在重新标记阶段再确认一遍
-
Snapshot At The Beginning,SATB 原始快照法,G1 垃圾回收器采用
- 思路也是拦截每次赋值动作,不过记录的对象不同,也需要在重新标记阶段对这些对象二次处理
- 新加对象会被记录
- 被删除引用关系的对象(也就是白色的那个对象)也被记录
垃圾回收器
垃圾回收器 - Parallel GC(并行GC)
-
eden 内存不足发生 Minor GC,采用标记复制算法,需要暂停用户线程
-
old 内存不足发生 Full GC,采用标记整理算法,需要暂停用户线程
-
注重吞吐量
并行GC名字由来,虽然在新生代和老年代都会调用STW,但是暂停时间,会使用多个线程并行完成垃圾回收。虽然有暂停,但并行回收,停止时间较短
垃圾回收器 - ConcurrentMarkSweep GC
-
它是工作在 old 老年代,支持并发标记的一款回收器,采用并发清除算法,它是用增量更新法来解决并发标记时的多标漏标问题的
- 并发标记时不需暂停用户线程
- 重新标记时仍需暂停用户线程
-
如果并发失败(即回收速度赶不上创建新对象速度),会触发 Failback Full GC (保底策略)
-
注重响应时间
垃圾回收器 - G1 GC
- 响应时间与吞吐量兼顾
- 划分成多个区域,每个区域都可以充当 eden,survivor,old, humongous,其中 humongous 专为大对象准备,因为大对象的复制成本高
- 分成三个阶段:新生代回收(伊甸园内存不足,标记复制并STW)、并发标记(老年代并发标记,重新标记需STW,对漏标问题采用原始快照法)、混合收集
- 如果并发失败(即回收速度赶不上创建新对象速度),会触发 Full GC
G1回收阶段 - 并发标记与混合收集流程,下图仍然是黑马满一航老师的图解分析,简明易懂
注:S是幸存区(Survivor),E是伊甸园区(eden),O是老年代(Old)
①当老年代占用内存超过阈值后,触发并发标记,这时无需暂停用户线程
②并发标记之后,会有重新标记阶段解决漏标问题,此时需要暂停用户线程。这些都完成后就知道了老年代有哪些存活对象,随后进入混合收集阶段。此时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象少)的区域(这也是 Gabage First 名称的由来)也就是下图标红的O。
③ 混合收集阶段中,参与复制的有 eden、survivor、old,下图显示了伊甸园和幸存区的存活对象复制
④下图显示了老年代和幸存区晋升的存活对象的复制
⑤复制完成,内存得到释放。进入下一轮的新生代回收、并发标记、混合收集
项目中什么时候会内存溢出,怎么解决
① 误用线程池导致的内存溢出
误用固定大小的线程池
误用带缓冲线程池
所以总结一下,不要用executors工具类中默认的线程池,很容易把握不住;防止策略,自己去使用它的构造方法,根据情况设置有大小限制
② 查询数据量太大导致的内存溢出
在项目上线后,比如一千万条的商品信息,如果编码中存在findAll()方法,会消耗大量内存,如果是多个用户都访问,调用了findAll(),用不了多久服务器内存就被耗尽
除了编码中不使用findAll()方法,还要注意,使用findAll()后面跟条件查询时,有时候条件可能会失效,所以后面一定要跟一个limit,限制查询条目数
③ 动态生成类导致的内存溢出
类加载过程三个阶段
①. 加载
-
将类的字节码载入方法区,并创建类.class 对象
-
如果此类的父类没有加载,先加载父类
-
加载是懒惰执行
②. 链接
- 验证 – 验证类是否符合 Class 规范,合法性、安全性检查
- 准备 – 为 static 变量分配空间,设置默认值
- 解析 – 将常量池的符号引用(内部类类名)解析为直接引用(类的地址);并非初始化完成后,所有就停止了,解析步骤不是一步到位的而是一点点完成的
③. 初始化
- 静态代码块、static 修饰的变量赋值、static final 修饰的引用类型变量赋值,会被合并成一个
<cinit>
方法,在初始化时被调用 - static final 修饰的基本类型变量赋值,在链接阶段就已完成
- 初始化是懒惰执行
访问static final 修饰的基本类型变量,不会导致类的加载和初始化,若是访问static final 修饰的引用类型变量既会导致类的加载也会导致类的初始化
何为双亲委派
jdk 8 的类加载器
名称 | 加载哪的类 | 说明 |
---|---|---|
Bootstrap ClassLoader | JAVA_HOME/jre/lib | 无法直接访问 |
Extension ClassLoader | JAVA_HOME/jre/lib/ext | 上级为 Bootstrap,显示为 null |
Application ClassLoader | classpath | 上级为 Extension |
自定义类加载器 | 自定义 | 上级为 Application |
Bootstrap ClassLoader 启动加载器, Extension ClassLoader 扩展加载器,Application ClassLoader 应用程序加载器
双亲委派机制
所谓的双亲委派,就是指优先委派上级类加载器进行加载,如果上级类加载器
- 能找到这个类(如String.class),由上级加载,加载后该类也对下级加载器可见,共享了
- 找不到这个类(如自己写的Student.class),则下放下级类加载器执行加载,下级加载器加载后该类对上级加载器不可见
双亲委派的目的有两点
-
让上级类加载器中的类对下级共享(反之不行),即能让你的类能依赖到 jdk 提供的核心类
-
让类的加载有优先次序,保证核心类优先加载
自己编写类加载器就能加载一个假冒的 java.lang.System 吗? 答案是不行。
-
假设你自己的类加载器用双亲委派,那么优先由启动类加载器加载真正的 java.lang.System,自然不会加载假冒的
-
假设你自己的类加载器不用双亲委派,那么你的类加载器加载假冒的 java.lang.System 时,它需要先加载父类 java.lang.Object,而你没有用委派,找不到 java.lang.Object 所以加载会失败
-
以上也仅仅是假设。事实上操作你就会发现,自定义类加载器加载以 java. 打头的类时,会抛安全异常,在 jdk9 以上版本这些特殊包名都与模块进行了绑定,更连编译都过不了
对象的引用类型分为哪几类?
强引用
-
普通变量赋值即为强引用,如 A a = new A();
-
通过 GC Root 的引用链,如果强引用不到该对象,该对象才能被回收
软引用(SoftReference)
-
例如:SoftReference a = new SoftReference(new A());
-
如果仅有软引用该对象时,首次垃圾回收不会回收该对象,如果内存仍不足,再次回收时才会释放对象
-
软引用自身需要配合引用队列来释放
-
典型例子是反射数据
弱引用(WeakReference)
-
例如:WeakReference a = new WeakReference(new A());
-
如果仅有弱引用引用该对象时,只要发生垃圾回收,就会释放该对象
-
弱引用自身需要配合引用队列来释放
-
典型例子是 ThreadLocalMap 中的 Entry 对象
虚引用(PhantomReference)
-
例如: PhantomReference a = new PhantomReference(new A(), referenceQueue);
-
必须配合引用队列一起使用,当虚引用所引用的对象被回收时,由 Reference Handler 线程将虚引用对象入队,这样就可以知道哪些对象被回收,从而对它们关联的资源做进一步处理
-
典型例子是 Cleaner 释放 DirectByteBuffer 关联的直接内存
解释含义:当a,b对象被释放后,把它们的虚引用放入引用队列中先记住哪些对象被回收了,当它们的java中占用内存释放完,通过引用队列找到引用a,b;再将其关联的外部资源也释放掉
简单点说:你在一家公司工作,公司为了开源节流把你裁了(对象被回收),这是后勤管理人员就会把被开的人记录在一个名单上(放入引用队列),当你被开除后,把你使用的工位,电脑给还原恢复(释放外部资源)
弱,虚引用配合引用对列,目的是找到哪些java对象被回收(比如ThreadLocalMap 中的 Entry 对象的key是弱引用,会被回收掉),从而进行对它们关联的资源(key关联的value强引用)进行进一步清理,从java9开始引入了Cleaner对象
谈谈finalize的理解
finalize
- 它是 Object 中的一个方法,如果子类重写它,垃圾回收时此方法会被调用,可以在其中进行资源释放和清理工作
- 将资源释放和清理放在 finalize 方法中非常不好,非常影响性能,严重时甚至会引起 OOM,从 Java9 开始就被标注为 @Deprecated,不建议被使用了
finalize 缺点
两个非常不好的点
①无法保证资源释放:FinalizerThread 是守护线程,代码很有可能没来得及执行完,线程就结束了
②无法判断是否发生错误:执行 finalize 方法时,会吞掉任意异常(Throwable)
finalize 原理
-
对 finalize 方法进行处理的核心逻辑位于 java.lang.ref.Finalizer 类中,它包含了名为 unfinalized 的静态变量(双向链表结构),Finalizer 也可被视为另一种引用对象(地位与软、弱、虚相当,只是不对外,无法直接使用)
-
当重写了 finalize 方法的对象,在构造方法调用之时,JVM 都会将其包装成一个 Finalizer 对象,并加入 unfinalized 链表中
-
Finalizer 类中还有另一个重要的静态变量,即 ReferenceQueue 引用队列,刚开始它是空的。当狗对象可以被当作垃圾回收时,就会把这些狗对象对应的 Finalizer 对象加入此引用队列
-
但此时 Dog 对象还没法被立刻回收,因为 unfinalized -> Finalizer 这一引用链还在引用它嘛,为的是【先别着急回收啊,等我调完 finalize 方法,再回收】
-
FinalizerThread 线程会从 ReferenceQueue 中逐一取出每个 Finalizer 对象,把它们从链表断开并真正调用 finallize 方法
-
由于整个 Finalizer 对象已经从 unfinalized 链表中断开,这样没谁能引用到它和狗对象,所以下次 gc 时就被回收了
影响性能上
内存释放不及时
:重写了 finalize 方法的对象在第一次被 gc 时,并不能及时释放它占用的内存,因为要等着 FinalizerThread 调用完 finalize,把它从 unfinalized 队列移除后,第二次 gc 时才能真正释放内存
回收时速度慢
:gc本就时因为内存不足引起才执行,finalize调用又很慢(两个队列的移除操作,都是加锁串行执行的,用来释放连接类的资源也不快),不能及时释放内存,对象释放不及时就会逐步晋升到老年代,老年代垃圾积累过多又会引发full gc, full gc后释放速度如果仍然跟不上创建新对象速度就会OOM(内存溢出)
质疑对比
- 有的文章提到【Finalizer 线程会和我们的主线程进行竞争,不过由于它的优先级较低,获取到的CPU时间较少,因此它永远也赶不上主线程的步伐】这个显然是错误的,FinalizerThread 的优先级较普通线程更高(最大优先级 - 2),原因应该是 finalize 串行执行慢等原因综合导致