Java中JVM的双亲委派、内存溢出、垃圾回收和调优详解
作者:燕双嘤
1.双亲委派
1.1 类加载过程
【加载】:加载是指将类的字节码文件读入内存,并在内存中创建一个Class对象,用来描述该类的结构信息。类的字节码可以来自本地磁盘、网络等各种来源。
【连接】:连接是指对类的字节码进行验证、准备和解析的过程。
- 验证:验证字节码文件的正确性和安全性。例如,验证类的继承关系是否合法、方法调用是否正确等。
- 准备:为类的静态变量分配内存空间,并设置初始值。这些变量在该类的所有实例中共享。
- 解析:将类中的符号引用转换为直接引用。符号引用是一种用来描述所引用的目标的符号,例如类、字段、方法等。直接引用是指指向具体内存地址的指针。
【初始化】:初始化是类加载过程的最后一步,主要是对类的静态变量赋值和执行静态代码块。
- 执行静态代码块和静态变量初始化器。
- 调用父类的初始化方法。
- 执行类中定义的初始化方法。
1.2 类加载器
类加载器:是Java虚拟机(JVM)的一个重要组成部分,它的主要作用是将类的字节码加载到内存中,并生成对应的Class对象。
System.setProperty("java.system.class.loader", "com.example.MyClassLoader");
类加载器分类:
(1)第一种是 c++ 语言写的 启动类加载器:Bootstrap ClassLoader。
(2)第二种是 java 语言写的 其他各种类加载器。可以分为扩展类加载器, 应用程序类加载器 以及各种各样的自定义类加载器。自定义类加载器 一般是需要进行动态加载或者其他业务目的类加载器。
- 启动类加载器:它是JVM的一部分,主要负责加载Java的核心类库,比如java.lang包中的类。
- 扩展类加载器:它负责加载JVM扩展目录(java.ext.dirs)中的类库,比如javax包中的类。
- 应用程序类加载器:它负责加载应用程序classpath目录下的类库,是程序中默认的类加载器。
- 自定义类加载器:它是开发人员自己实现的类加载器,可以根据自己的需求实现不同的加载逻辑。
1.3 双亲委派机制
【类的唯一性】在一个虚拟机里,一个类只有一个对应的类对象。 现在对类的唯一性的理解,应该更为准确地描述为: 在一个加载器下,一个类只有一个对应的类对象。如果有多个类加载器都各自加载了同一个类,那么他们将得到不同的类对象。
【双亲委派机制】JVM中的双亲委派机制是指,当一个类被加载时,JVM会首先委托其父类加载器去加载该类,只有当父类加载器无法加载该类时,才会由子类加载器去加载。这样可以保证Java虚拟机中的类不会出现同名的情况,也能保证Java核心类库的安全性和稳定性。
- 通过覆盖ClassLoader的findClass()方法,在该方法中定义特殊的类搜索方式或者直接抛出ClassNotFoundException异常来绕过双亲委派机制。
- 自定义类加载器并打破双亲委派机制,可以使用URLClassLoader类或者继承ClassLoader类并重写loadClass()方法。
需要注意的是,在使用类加载器打破双亲委派机制时,应该保证加载的类与父加载器加载的类之间没有依赖关系,否则可能会导致异常或者出现未知的错误。
(1)双亲委派机制的意义主要是保护一些基本类不受影响。比如常用的 String类, 其全限定名是 java.lang.String,只是 java.lang 这个包下的类在使用的时候,可以不用 import 而直接使用。像这种基本类按照双亲委派机制 都应该从 rt.jar 里去获取,而不应该从自定义加载器里去获取某个开发人员自己写的 java.lang.String,毕竟开发人员自己写的 java.lang.String 可能有很多 bug,通过这种方式,无论如何大家使用的都是 rt.jar 里的 java.lang.String 类了。
(2)避免类的重复加载。由于每个类加载器都有自己的命名空间,当父类加载器已经加载了一个类时,子类加载器再去加载该类时,就会导致重复加载,浪费内存空间。通过双亲委派机制,可以避免重复加载,提高JVM的运行效率。
(3)确保类的一致性。由于父类加载器的优先级高于子类加载器,所以子类加载器无法覆盖父类加载器已经加载的类。这样可以保证不同的类加载器加载的同一个类是一致的,避免了由于不同版本的类加载器加载同一个类而导致的问题。
【注意】双亲委派机制对应的英文是:Parents Delegation Model,这个 Parents 后面有个 s,所以就被翻译成双亲了。而 parents 英文本身想表达的意思是上溯,父辈,祖先的意思,即爸爸,爷爷,祖爷爷~但是却被翻译成双亲,双亲在中文里强调的是父亲和母亲两个。所以是翻译不够准确,略微会引起一些歧义。
虽然双亲委派机制可以避免类的重复加载、提高JVM的运行效率和系统的稳定性,但在一些特定的场景下,也需要打破双亲委派机制,例如:
- 实现类隔离。在某些情况下,需要使用不同的类加载器加载同一个类的不同版本,或者需要使用不同的类加载器加载同一个类的不同实现,这时就需要打破双亲委派机制,实现类的隔离。
- 自定义类加载器。在某些情况下,需要使用自定义的类加载器加载类,例如在OSGi等动态模块化框架中就需要使用自定义的类加载器。此时也需要打破双亲委派机制。
- 热部署。在热部署的场景下,需要动态更新已经加载的类,而双亲委派机制会阻止子类加载器重新加载已经由父类加载器加载的类,因此需要打破双亲委派机制,实现热部署功能。
1.4 自定义类加载器
为什么需要类加载器:每个 Web 应用各自都需要配置一个专有的类加载器。Tomcat会有多个不同的war包,需要实现类隔离。
- 创建一个类 CustomizedClassLoader ,继承自 ClassLoader。
- 定义属性 classesFolder 其值是当前项目下的 classes_4_test 目录。
- 重写 loadClassData 方法,它会把传进去的全限定类名,匹配到文件。
- 重写 findClass 方法,把这个字节数组通过调用 defineClass 方法,就转换成 HOW2J 这个类对应的 Class 对象了。
- 拿到这个类对象之后,通过反射机制,调用其 hello 方法,就能看到如图所示的字符串:"Hello!"
import java.io.File; import java.lang.reflect.Method; import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.StrUtil; public class CustomizedClassLoader extends ClassLoader { private File classesFolder = new File(System.getProperty("user.dir"),"classes_4_test"); protected Class<?> findClass(String QualifiedName) throws ClassNotFoundException { byte[] data = loadClassData(QualifiedName); return defineClass(QualifiedName, data, 0, data.length); } private byte[] loadClassData(String fullQualifiedName) throws ClassNotFoundException { String fileName = StrUtil.replace(fullQualifiedName, ".", "/") + ".class"; File classFile = new File(classesFolder, fileName); if(!classFile.exists()) throw new ClassNotFoundException(fullQualifiedName); return FileUtil.readBytes(classFile); } public static void main(String[] args) throws Exception { CustomizedClassLoader loader = new CustomizedClassLoader(); Class<?> how2jClass = loader.loadClass("cn.ysy.diytomcat.Test"); Object o = how2jClass.newInstance(); Method m = how2jClass.getMethod("hello"); m.invoke(o); System.out.println(how2jClass.getClassLoader()); } }
2. 内存溢出&内存泄露
2.1 内存模型
【基本定义】Java的内存模型定义了Java程序在运行时的内存结构以及多线程情况下,多个线程之间如何共享内存。Java的内存模型保证了线程安全性,避免了多线程访问共享内存时出现的数据竞争、死锁等问题。
【组成部分】Java内存模型将内存分为两个部分:线程工作内存和主内存。线程工作内存是线程独有的内存空间,用于存储线程运行时的局部变量等数据,而主内存是所有线程共享的内存空间,用于存储Java程序中定义的全局变量等数据。
【特性】Java内存模型定义了一组规则,确保多个线程之间对共享内存的访问是正确的。其中包括:
- 可见性:当一个线程修改了共享变量的值后,其他线程可以立即看到该变量的修改。
- 原子性:对共享变量的读写操作应该被视为一个原子操作,不可被中断。
- 有序性:线程之间的操作可能会被编译器、处理器进行指令重排等优化,但是Java内存模型保证了操作执行的顺序不会影响程序的正确性。
2.2 内存溢出(OOM)
内存溢出就是指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。
内存溢出原因(案例,场景):
- 内存中加载的数据量过于庞大,比如sql全表扫描 ;
- 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
- 代码中存在死循环或循环产生过多重复的对象实体;
- 启动参数内存值设定的过小。
- 堆栈溢出
解决内存溢出:
- 修改JVM启动参数,直接增加内存。
- 检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。
- 对代码进行走查和分析,找出可能发生内存溢出的位置。
- 使用内存查看工具动态查看内存使用情况。
堆栈溢出:
- Java堆溢出 Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。
- 虚拟机栈和本地方法栈溢出 HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。
- 方法区和运行时常量池溢出 方法区溢出也是一种常见的内存溢出异常,在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。
- 本地直接内存溢出 直接内存的容量大小可通过`-XX:MaxDirectMemorySize`参数来指定,如果不去指定,则默认与Java堆最大值一致。如果直接通过反射获取Unsafe实例进行内存分配,并超出了上述的限制时,将会引发OOM异常。
2.3 内存泄露
内存泄漏是指不再使用的对象仍然被引用,导致垃圾收集器无法回收它们的内存。由于不再使用的对象仍然无法清理,甚至这种情况可能会越积越多,最终导致致命的OutOfMemoryError。
内存泄漏场景:
- 动态分配内存但没有及时释放:如果程序中使用了动态内存分配(例如使用 malloc 函数),但没有及时释放内存(例如使用 free 函数),就会导致内存泄漏。
- 循环引用:如果程序中存在循环引用(例如两个对象互相引用),就会导致这些对象无法被垃圾回收器回收,最终导致内存泄漏。
- 静态变量未释放:如果程序中存在静态变量,且这些静态变量在程序运行过程中未被释放,就会导致内存泄漏。
- 没有正确使用引用计数:如果程序中使用了引用计数,但没有正确地使用引用计数,就会导致内存泄漏。
- 文件描述符未关闭:如果程序中使用了文件描述符(例如打开文件),但没有正确关闭文件,就会导致内存泄漏。
内存泄露的原因:
- 长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收。
- 不正确使用单例模式是引起内存泄露的一个常见问题,单例对象在被初始化后将在JVM的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部对象的引用,那么这个外部对象将不能被jvm正常回收,导致内存泄露 。
解决内存泄漏:
- 启用分析器 Java分析器是通过应用程序监视和诊断内存泄漏的工具,它可以分析我们的应用程序内部发生的事情,例如如何分配内存。使用分析器,我们可以比较不同的方法并找到可以最佳利用资源的方式。
- 启用详细垃圾收集日志 通过启用详细垃圾收集日志,我们可以跟踪GC的详细进度。要启用该功能,我们需要将以下内容添加到JVM的配置当中:`-verbose:gc`。通过这个参数,我们可以看到GC内部发生的细节。
- 使用引用对象我们还可以借助java.lang.ref包内置的Java引用对象来规避问题,使用java.lang.ref包,而不是直接引用对象,即使用对象的特殊引用,使得它们可以轻松地被垃圾收集。
- Eclipse内存泄漏警告 对于JDK1.5以及更高的版本中,Eclipse会在遇到明显的内存泄漏情况时显示警告和错误。因此,在Eclipse中开发时,我们可以定期地访问“问题”选项卡,并更加警惕内存泄漏警告。
3.垃圾回收机制
3.1 传统垃圾回收
垃圾回收(Garbage Collection,GC)就是释放垃圾占用的内存空间,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收,防止内存泄露。
【垃圾判断算法】
- 引用计数法:给每个对象添加一个计数器,当有地方引用该对象时计数器加1,当引用失效时计数器减1。用对象计数器是否为0来判断对象是否可被回收。
- 可达性分析:通过GC ROOT的对象作为搜索起始点,通过引用向下搜索,走过的路径称为引用链。通过对象是否到达引用链的路径来判断对象是否可被回收。
【垃圾回收算法】
- 标记-清除算法:标记清除算法(Mark-Sweep)是最基础的一种垃圾回收算法,它分为2部分,先把内存区域中的这些对象进行标记,哪些属于可回收标记出来,然后把这些垃圾拎出来清理掉。清理掉的垃圾就变成未使用的内存区域,等待被再次使用。
- 标记-复制算法:复制算法(Copying)是在标记清除算法基础上演化而来,解决标记清除算法的内存碎片问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
- 标记-整理算法:标记-整理算法标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域。
- 分代收集算法:分代收集算法分代收集算法严格来说并不是一种思想或理论,而是融合上述3种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳,根据对象存活周期的不同将内存划分为几块。
【Full GC&Minor GC】
- Minor GC:回收新生代,因为新生代对象存活时间很短,因此
Minor GC
会频繁执行,执行的速度一般也会比较快。 - Full GC:回收老年代和新生代,老年代的对象存活时间长,因此
Full GC
很少执行,执行速度会比Minor GC
慢很多。
【Full GC触发条件】
- 调用 System.gc():只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
- 老年代空间不足:大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组、注意编码规范避免内存泄露。
- 空间分配担保失败:使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。
- JDK 1.7 及以前的永久代空间不足:在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。
3.2 G1垃圾回收器
Garbage First(G1)收集器开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。其他收集器的垃圾收集的目标范围要么是整个新生代,要么就是整个老年代,再要么就是整个Java堆(Full GC)。而G1跳出了这个限制,G1垃圾回收器相较于其他垃圾回收器的一大特点就是它支持将Java堆内存划分为多个大小相等的区域,每个区域能够独立管理,并且可以根据应用程序的实时需求动态地选择哪些区域进行垃圾回收。
G1收集器的运作过程大致可划分为以下四个步骤:初始标记、并发标记、最终标记、筛选回收。其中,初始标记和最终标记阶段仍然需要停顿所有的线程,但是耗时很短。
- 初始标记阶段:首先,G1会标记出从根对象直接可达的对象,这个过程是在年轻代暂停执行的。这个阶段结束后,G1就已经确定了那些对象需要被回收。
- 并发标记阶段:这个阶段是并发的,即应用程序继续执行,垃圾回收器并行标记那些不重要的对象,包括那些从根对象不可达但是与可达对象之间有引用关系的对象。
- 最终标记阶段:这个阶段是在并发标记阶段完成后暂停执行的,G1会重新扫描所有新添加的引用并标记它们,防止被回收。
- 筛选阶段:在这个阶段中,G1会确定需要被回收的区域,并开始将这些区域的存活对象复制到空闲的区域中,同时清理掉这些区域的未使用对象。
- 清理阶段:在这个阶段中,G1已经有一些可用的区域,在这个阶段中将会对这些区域进行整理,并且更新所有的引用地址,使得GC的过程能够再次启动。
从JDK 9开始,G1成为了默认的垃圾回收器,它是HotSpot在JVM上推出的垃圾回收器,旨在取代CMS。G1的主要特点包括:
- 分区:G1将堆内存划分为多个大小相等的区域,每个区域的大小通常为1-32MB。
- 并行收集:G1采用多线程并行收集的方式,可以明显缩短垃圾回收时间。
- 智能压缩:G1生成一个可复制的新对象,而不是压缩整个堆,这样可以明显降低垃圾回收的时间和开销。
- 可预测的停顿时间:G1通过控制每个分区内的垃圾回收时间,可以预测垃圾回收时间,并对应用程序暂停时间进行优化。
3.3 CMS垃圾回收器
CMS收集器是一种以获取最短回收停顿时间为目标的收集器,是基于标记清除算法实现的。它最主要的优点是:并发收集、低停顿。它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:初始标记、并发标记、重新标记、并发清除。
- 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
- 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
- 重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
- 并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,至少有以下三个明显的缺点:
- 并发阶段,它虽然不会导致用户线程停顿,却因为占用一部分线程而导致应用程序变慢,降低总吞吐量。
- 它无法处理“浮动垃圾”,有可能会出现“并发失败”进而导致另一次Full GC的发生。
- 它是一款基于标记清除算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。
G1与CMS的对比:G1从整体来看是基于标记整理算法实现的收集器,但从局部上看又是基于标记复制算法实现。无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。比起CMS,G1的弱项也可以列举出不少。例如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比CMS要高。
- 垃圾回收方式:G1使用基于区域(Region)的分代回收策略,而CMS则使用基于标记-清除(Mark-Sweep)算法。G1将堆内存划分为多个大小相等的区域,并可通过动态调整回收集的大小来控制回收时间;CMS则主要集中在老年代的增量式垃圾回收。
- 回收效果:G1的整体性能优于CMS,特别是在大内存、多核处理能力和更好的预测性上。G1可以更好地控制暂停时间,并减少因大型堆内存垃圾回收引起的应用程序暂停。CMS适用于小型到中型堆内存垃圾回收,不能很好地扩展到大型堆内存的情况。
- 对CPU和内存的影响:G1会消耗更多的CPU资源,在回收过程中会产生一些额外的开销,但可以避免长时间的STW(Stop-The-World)现象。CMS在回收过程中也可能导致应用程序暂停,但占用的CPU资源相对较少。
- 内存分配方式:G1采用了随机空间分配策略,而CMS则使用先进先出空间分配策略。
- G1与CMS的选择:目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间。
3.4 ZGC垃圾回收
ZGC(Z Garbage Collector)是一个可伸缩性非常好的低延迟垃圾回收器,它在JDK 11中引入,旨在处理大型堆,适用于云部署和其他内存密集型应用程序。
ZGC的主要特点包括:
- 并发处理:ZGC在垃圾回收时,不会像传统的GC一样暂停整个应用程序,而是采用了并发处理的方式,让应用程序和垃圾回收器同时运行。
- 基于Region的内存管理:ZGC将内存划分为许多小的Region,每个Region都有自己的内存池和垃圾回收器线程。这种方式可以使得ZGC只回收少量的内存,从而减少GC停顿时间。
- 无需压缩:ZGC不需要对内存进行压缩,因为它采用了一种称为“内存重映射”的技术,将不再使用的内存区域映射到一个新的虚拟地址空间中,从而释放出物理内存。
ZGC具有高性能、低延迟、可预测和背压感知等特点,适用于多核CPU的应用场景,它可以在不牺牲吞吐量的情况下,大大减少GC停顿时间,从而提高应用程序的响应速度和性能。
4.JVM调优
4.1 JVM常用参数
- -Xmx:指定 Java 堆的最大内存容量,例如 -Xmx2g 表示最大可用内存为 2 GB。
- -Xms:指定 Java 堆的初始内存容量,例如 -Xms512m 表示初始可用内存为 512 MB。
- -Xmn:指定新生代的初始大小。
- -XX:MaxPermSize:指定非堆区的最大内存容量,例如 -XX:MaxPermSize=256m 表示最大可用内存为 256 MB。
- -XX:PermSize:指定非堆区的初始内存容量,例如 -XX:PermSize=128m 表示初始可用内存为 128 MB。
- -Xss:指定线程的堆栈大小,例如 -Xss1m 表示线程的堆栈大小为 1 MB。
- -XX:+UseParallelGC:使用并行垃圾回收器,提高回收效率。
- -XX:+UseConcMarkSweepGC:使用 CMS 垃圾回收器,在减少垃圾回收停顿时间方面表现出色。
- -XX:+PrintGCDetails:打印垃圾回收的详细信息。
- -XX:+HeapDumpOnOutOfMemoryError:当发生 OutOfMemoryError 错误时,自动生成堆转储快照文件。
4.2 JVM调优策略
- 定位问题:首先需要确定 JVM 调优的目标和调优范围,以及具体的性能问题,例如内存使用过高、垃圾回收频繁、响应时间慢等。
- 收集数据:通过 JVM 的监控工具或第三方工具(例如 VisualVM、JConsole、JMC 等),收集 JVM 运行时的各项指标数据,例如 CPU 使用率、内存使用情况、垃圾回收信息等。
- 分析数据:分析收集到的数据,找出瓶颈所在,例如内存泄漏、垃圾回收效率低等问题,确定需要调整的 JVM 参数。
- 调整 JVM 参数:根据问题的根源,调整相应的 JVM 参数。例如,如果堆内存占用过高,可以增加堆内存的大小(通过 -Xmx 参数)或者调整垃圾回收器的类型(通过 -XX:+UseConcMarkSweepGC 参数等)。
- 测试验证:在实际环境中测试优化后的 JVM,验证是否能够有效地解决问题,并进行持续性能监测和优化。
到此这篇关于Java中JVM的双亲委派、内存溢出、垃圾回收和调优详解的文章就介绍到这了,更多相关JVM的双亲委派、内存溢出内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!