前言
JVM相关的知识我一直没有系统的学习过,但是面试中经常会被问到JVM调优相关的东西,若连JVM都不知道,谈何调优,所以我将学习的笔记记录于此,方面后续回顾,若对他人也有帮助,也是一举两得之事。
一、JVM内存模型
我虽然知道Java是编译成.class字节码文件后给JVM去读取解析的,但是一直不知道具体是怎么搞的,想要弄清楚这个过程,还是要先了解JVM的内存模型,如下图:
JVM内存模型一共由五个部分组成,下面我先介绍一下。
- 方法区。在jdk8之前它叫方法区,后面改名叫元空间。它存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码数据等(也就是说经过Javac编译后的.class文件相关信息是存放在这里的)。
- 堆。Java堆是内存最大的一块,所有对象实例、数组都存放在Java堆,我们所说的GC回收就是指回收这一块里面的垃圾。
- 虚拟机栈。每个方法都有一个栈帧,栈帧里面存放局部变量表、操作数栈、动态链接、方法出口等。后面再详细解释。
- 本地方法栈。主要存放native修饰的本地方法。
- 程序计数器。当前线程所执行的字节码的行号指示器,用于记录当前执行的指令行号。
二、深入讲解
1.内存模型与Java类对应关系
上面介绍了内存模型的基本构造,下面我将举一个例子来说明,Java类中的变量以及方法等分别对应哪些内容。
public class Demo {
//静态变量放在【方法区】中
public static final int initCount = 520;
public static Config config = new Config();
public static void main(String[] args) {
Demo demo = new Demo();
int temp = demo.compute();
System.out.println("compute end , temp = " + temp);
}
public int compute() {
int a = 1;
int b = 2;
return (a + b) * 10;
}
// 本地方法放在【本地方法栈】中
public native void nativeMethod();
}
①首先看方法区,存放以下内容:
- 类元数据(Demo类的结构信息)
- 运行时常量池(包含字面量520)
- initCount常量(是编译期常量,直接嵌入字节码,它是直接存储在常量池)
- config静态变量的引用(这里存放的是config这个静态对象的地址,实际对象存放在堆中)
②在看堆中的内容:
- Demo实例对象(包括实例字段)
- Config实例对象
③继续看栈里面的内容,每个方法都有一个单独的栈帧放入栈中,后面会针对此案例详细说明,这里只要知道这两个方法放在栈中
- main栈帧
- compute栈帧
④接着是本地方法栈,这个没什么说的,nativeMethod方法就在这个里面,包括本地方法调用时的参数、局部变量。
⑤最后是程序计数器,它是每个线程当前执行的字节码行号,例如:记录main()方法执行到demo.compute()的位置。
2.字节码文件
大家应该都知道.java文件编译后会变成.class文件,我们打开这个文件都是乱码的,看不懂,所以我们可以利用jdk自带的工具对代码进行反汇编,下面是详细操作。
①找到Demo.class这个编译后的文件,右键打开终端
②执行命令 【javap -c Demo.class > demo.txt】,就会在同级目录下生成一个demo.txt的文本文件
③打开demo.txt文件,就可以看到如下汇编代码,大家仔细看应该也能看出来一些猫腻吧,这里面的内容其实和.class文件其实是一回事,都是Java虚拟机去看的。
④我们可以着重看下红框里面的内容,其实里面每一行都有固定的含义,比如计算(a+b)*10的过程如下所示,iconst_1 的固定含义就是往compute这个方法的栈帧里面压入a=1,以此类推。
iconst_1 // 压入a=1
iconst_2 // 压入b=2
iadd // 栈顶弹出1+2=3
bipush 10 // 压入10
imul // 3*10=30
⑤还有一个点,这个栈帧的操作和数据结构里面的那个栈,其实关系很大,数据结构里面的栈有个特点就是FILO(先进后出),这和我们方法嵌套调用的顺序是一致的。
2.执行过程
①先上图,当我们执行到demo.compute()这个方法时,会在栈中开辟一块属于compute方法的栈帧空间(当然main方法也会开辟一块,里面也是局部变量表那些,我们这里直接忽略了),栈帧里面包括局部变量表、操作数栈、动态链接以及方法出口(动态链接这块涉及到C++,大概就是把符号引用转换为直接引用放到这块内存空间,即存储调用compute这个方法的地址)。像我们int a 这种局部变量都是放在局部变量表,而操作数栈是对变量进行出栈,入栈这种计算操作,方法出口就是记录这个栈帧执行完后回到主方法时的出口位置。
②下面这个图中的0、1、2这种前面的序号,就是程序计数器所指向的位置,表示当前执行到哪里了,这个计数的动作就是字节码执行引擎去执行的。不知道你们是否跟我一样疑问为什么Java要搞一个程序计数器在这里?当线程被占用时,我们这个方法就会被暂停而去执行其他任务,等待主线程任务执行完毕,线程会继续执行我们的这个任务,那不可能重新开始吧,所以就让程序计数器去记录执行到哪里了,这也是它的意义所在。
②说到这里我想起来一个面试题,JVM内存模型里面哪些是线程共享的?经过上面的分析后,其实很容易得出答案,方法区和堆是线程共享的,另外三个是线程私有的,仔细思考下,这个也能想出来,比如静态变量其他的线程是可以直接访问的,所以方法区肯定得是共享的撒;栈帧的操作都是以线程为维度的,如果你在计算的时候,有其他线程能访问改变值,那结果不就有问题吗?所以栈是线程私有的(当然堆线程共享不是绝对的,后面也会说到这个问题)。
3.堆空间
堆空间是JVM内存模型里面最大的一块内容,里面具体有什么东西呢?看下图
①堆分为年轻代和老年代,默认内存占比为1比2,也就是整个年轻代占1/3,而老年代占2/3(通过JVM参数:【-XX:NewRatio=3】来调整,表示老年代:新生代 = 3:1);年轻代又分为Eden区和Survivor区,默认内存占比为8比2,也就是整个Eden区占8/10,而Survivor区占2/10(通过JVM参数:【-XX:SurvivorRatio=6 】来调整,表示Eden:S0:S1 = 6:1:1,即Eden区占6/6+2 = 75%,而S0 和S1各占 1/6+2 = 12%);Survivor区里面分为S0和S1区,内存各占Survicor的一半。
②当我们new一个对象时,就会在Eden区去开辟一块空间,就会出现以下两种情况:
- Eden区的大小能够容纳该对象,则直接放入Eden区。
- Eden区的大小不足以容纳该对象,则会进行一次Minor GC(这里有一个关键点,若配置了JVM参数【-XX:PretenureSizeThreshold=3M】则会与该大小进行对比,若大于则不会进行Minor GC而是直接放入老年代,当然这个参数默认是不启用的)。
- 若Minor GC之后Eden区内存还是不足,则会放入老年代(老年代大小够的时候直接放入老年代)。
- 若是老年代的内存大小也不够,则会触发FULL GC(Major GC之后空间仍不足才触发),若GC之后内存够,则放入老年代,若不够直接抛OutOfMemoryError。
③还有一个问题,那大小足够的情况下,对象是一直放在Eden区吗?其实不然,一直新增对象Eden区早晚会内存不足,则会触发Minor GC,而经过Minor GC后还存活的对象A,则会放入S0区,并且它的年龄+1,表示它度过了一次Minor GC,下一次Minor GC时,A对象会从S0区放入S1区,再下一次再从S1放入S0区,这表示S0和S1区只会有一块有内容(因为使用的复制标记算法),当超过默认的15次时(默认是15次,可以通过【-XX:MaxTenuringThreshold=15】JVM参数来修改 ),就会进入老年代。另外一种情况就是在进行对象转移时,若活动的From区的内存不足,则会直接放入老年代,不用判断年龄。
- S0与S1区在经过Minor GC后会互换角色,因为复制算法要保证每次转移对象后内存空间是连续的,若说的不太明白,可以看下图,活动区和内存区就是S0与S1两者互换演绎的。
④上面说了内存的划分,那JVM的堆内存为什么要拆为Eden区和Survivor区呢?Survivor区为什么又设置两个呢?
- 我们首先说第一个问题,如果只有Eden区或者Survivor区,每进行一次Minor GC,其内存区域的对象都会被放入老年代,老年代很快就会被填满,而老年代填满之后会进行Major GC / FULL GC,进行一次Major GC / Full GC消耗的时间比Minor GC长得多,所以需要分为Eden区和Survivor区,即为了减少Major GC和FULL GC的次数。
- 至于第二个问题,Survivor分为S0和S1两个区域,最大的好处就是解决了碎片化的问题,提高了垃圾回收的效率。在Eden区新建的对象在经过Minor GC后,会被移动到S0,等到再次触发Minor GC后,S0里面的对象就会被放入S1中(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。
4.垃圾回收
①接下来我们说下Minor GC、Major GC、FULL GC这三种垃圾收集分别是什么以及区别吧。
- Minor GC 是指年轻代垃圾收集的动作,那必然就发生在年轻代了,它执行的频率非常高,通常每秒几次到几十次,采用复制算法回收垃圾(Eden → Survivor区),它的STW的时间特别短,当Eden区空间不足时就会触发该回收机制【STW全名叫:STOP-THE-WORLK 表示所有Java应用线程被暂停的状态,目的是为了保证JVM在执行关键操作时的内存一致性。可以理解为JVM的"全局安全锁",一般在方法返回前、循环跳转处(回边)、异常抛出点、JNI调用边界这种安全点才会执行STW】。
- Major GC是指老年代垃圾收集的动作,只发生在老年代。当老年代空间不足或者空间分配担保失败时触发,执行时间是Minor GC的数十倍,采用标记-清除和标记-整理回收垃圾,应该尽量要避免。
- Full GC是指完全回收,回收的区域包括整个堆空间,也包括方法区。当Major GC后老年代的内存依旧不足、元空间不足(方法区)等情况都会触发。
②上面说的Minor GC、Major GC、FULL GC都是在进行垃圾回收,那什么样的数据会被回收呢?那肯定是要留下存活的对象,回收死掉的对象,我们一般有以下两种方式
- 引用计数器法:这是最早的垃圾回收算法之一,现在用的场景特别少,已经被弃用了。它的运作原理是为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收,但是它有一个致命的缺陷,当存在循环对象引用的时候,会导致计数器一直不会为0,即无法回收。
- 可达性分析算法:这是目前主流的算法,它是从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是可以被回收的。
③说到垃圾回收就必须说下JVM有几种引用类型了,这与上面说的还是有些关联的,因为不同的引用类型决定垃圾收集器是否会收走其对象。JVM里面一共有如下四种引用类型,除了强引用,其他几种引用都可能被垃圾收集器收走。
- 强引用:当我们执行Demo demo = new Demo()这句代码后,demo就是一个强引用的对象,它一定不会被垃圾收集器给收走,当内存不足时,JVM宁愿抛出OutOfMemoryError的错误都不愿回收此类对象。
- 软引用:如下我们显式通过SoftReference来创建就是软引用,它会在内存溢出之前被收走,即内存够大的时候存在,当内存不足时会被立马收走。
Demo demo= new Demo(); SoftReference<Demo> softRef = new SoftReference<>(demo); strongRef = null; // 此时对象仅被软引用持有
- 弱引用:弱引用与软用一样,需要显示通过WeakReference来创建,但是只要GC,它就会被立马收走。
- 虚引用:与其名字一个意思,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收,虚引用主要用来跟踪对象被垃圾回收器回收的活动。
④前面说了复制、标记清除、标记整理等垃圾回收的算法,下面我们就聊聊这几种常见的算法垃圾回收算法。
- 标记-清除算法:此算法很简单,就是标记无用对象,然后进行清除回收。它分为标记和清除两个阶段,先标记出可以回收的对象,然后回收被标记的对象所占用的空间。它的优点是实现简单,不需要对象进行移动。但是标记、清除过程效率低,产生大量不连续的内存碎片,会增加垃圾回收的频率。其过程可见下图
- 复制算法:为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。它的优点是按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片;但是缺点也很显然,可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。前面也说过Survivor的S0和S1正是用的此算法。
- 标记-整理算法:此算法是标记清除的升级版,标记清除会产生不连续的内存空间导致频繁GC,而此算法是应用在老年代的,但是它的效率不高,因为在标记出需要回收的内存空间后,它需要将不连续的内存空间进行整理,使它们紧密挨在一起,然后对端边界以外的内存进行回收。它的优点是解决了标记-清理算法存在的内存碎片问题,但仍需要进行局部对象移动,一定程度上降低了效率。
- 分代收集算法:此算法其实就是不同的区域用不同的垃圾回收算法,比如年轻代用复制算法,老年代用标记整理算法,因为年轻代垃圾回收的频率高,STW时间短,这也是我们为什么要避免FULL GC。
⑤说完垃圾回收算法,就该说说垃圾回收器了。如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了7种作用于不同分代的收集器,其中用于回收新生代的收集器包括Serial、PraNew、Parallel
Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,还有用于回收整个Java堆的G1收集器。不同收集器之间的连线表示它们可以搭配使用。
这里直接贴个整理好的表格看看吧(还有ZGC等其他回收器就不说了)。
垃圾回收器 | 工作区域 | 回收算法 | 工作线程 | 用户线程并行 | 特点 | 适用场景 | 关键参数 |
---|---|---|---|---|---|---|---|
Serial | 年轻代 | 复制算法 | 单线程 | 否 | ①单线程收集器 ②STW期间暂停所有应用线程 ③简单高效(无线程交互开销) | ①客户端应用 ②单核CPU环境 ③内存<100MB的场景 | -XX:+UseSerialGC |
ParNew | 年轻代 | 复制算法 | 多线程 | 否 | ①Serial的多线程版本 ②并行垃圾回收 ③与CMS配合使用 | ①服务端应用 ②多核处理器环境 ③配合CMS收集器的场景 | -XX:+UseParNewGC |
Parallel Scavenge | 年轻代 | 复制算法 | 多线程 | 否 | ①吞吐量优先(用户代码时间/总时间) ②自适应调节策略 ③可设置最大GC暂停时间 | ①后台计算型应用 ②批处理系统 ③对吞吐量要求高于延迟的场景 | -XX:+UseParallelGC #启动参数-XX:MaxGCPauseMillis=100 # 最大GC停顿时间-XX:GCTimeRatio=99 # GC时间占比(1/(1+99)=1%) |
Serial Old | 老年代 | 标记-整理算法 | 单线程 | 否 | ①Serial的老年代版本 ②标记-整理算法 ③作为CMS失败后备方案 | ①客户端应用 ②低内存设备 ③CMS失败时的后备 | -XX:+UseSerialOldGC |
Parallel Old | 老年代 | 标记-整理算法 | 多线程 | 否 | ①Parallel Scavenge的老年代搭档 ②并行标记整理 ③高吞吐量 | ①需要高吞吐量的服务端应用 ②大数据处理平台 ③与Parallel Scavenge配合 | -XX:+UseParallelOldGC |
CMS | 老年代 | 标记-清除算法 | 多线程 | 是 | ①并发收集 ②标记-清除算法 ③低延迟设计 | ①Web服务器 ②实时交易系统 ③对延迟敏感的应用(暂停时<100ms) | -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction= # 老年代%时触发-XX:+UseCMSCompactAtFullCollection # 碎片整理 |
G1 | 年轻代+老年代 | 标记-整理+复制算法 | 多线程 | 是 | ①分Region内存布局 ②可预测停顿时间模型 ③标记-整理+复制算法混合 | ①大内存应用(堆>6GB) ②要求低延迟的服务(200ms→50ms) ③JDK9+默认收集器 | -XX:+UseG1GC -XX:MaxGCPauseMillis=200 # 暂停时间目标-XX:G1HeapRegionSize=32m # Region大小-XX:InitiatingHeapOccupancyPercent=45 # 触发并发周期阈值 |
⑥垃圾回收器就说到这里,下面来说下之前说过的问题,对象一定分配在堆中吗?堆一定是线程共享的吗?
- 首先来说下第一个问题,对象分配在堆中这个说法不是绝对的。其实在编译期间JIT会对代码做很多优化,其中有一部分优化的目的就是为了减少内存堆分配的压力,其中一项重要的技术叫做逃逸分析。逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。通俗点讲,当一个对象被new出来之后,它可能被外部所调用,如果是作为参数传递到外部了,就称之为方法逃逸。比如有一个方法,里面new了一个对象,但是方法并没有返回此对象或者该对象的引用,那它就是未逃逸,即局部对象未被外部调用,则可能分配在栈中。当然我们也可以通过一个JVM的参数【-XX DoEscapeAnalysis】使所有的对象都放入堆中。
- 堆一定是线程共享的吗?通常情况下,这样说是没有问题的。但是JVM为了保证线程安全(上面说的栈上分配使用的逃逸分析也是一种保护线程安全的机制),会有TLAB机制,即TLAB为每个线程分配了一块独立的内存块,用于快速分配对象,这减少了线程间在内存堆上的竞争。而这块提前划分的独立内存块,就不是线程共享的。
5.类加载机制
①什么是虚拟机的类加载机制?其实就是虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,解析和初始化,最终形成可以被虚拟机直接使用的java类型。那我们就先说说类加载的过程,先上图。
加载:
- 加载指的是将类的class文件读入到内存,并将这些静态数据转换成方法区中的运行时数据结构,并在堆中生成一个代表这个类的java.lang.Class对象,作为方法区类数据的访问入口,这个过程需要类加载器参与。
- Java类加载器由JVM提供,是所有程序运行的基础,JVM提供的这些类加载器通常被称为系统类加载器。除此之外,开发者可以通过继承ClassLoader基类来创建自己的类加载器。类加载器,可以从不同来源加载类的二进制数据,比如:本地Class文件、Jar包Class文件、网络Class文件等等。
- 类加载的最终产物就是位于堆中的Class对象(注意不是目标类对象),该对象封装了类在方法区中的数据结构,并且向用户提供了访问方法区数据结构的接口,即Java反射的接口。
链接过程: 当类被加载之后,系统为之生成一个对应的Class对象,接着将会进入连接阶段,连接阶段负责把类的二进制数据合并到JRE中(意思就是将java类的二进制代码合并到JVM的运行状态之中),类连接又可分为如下3个阶段。
- 验证:确保加载的类信息符合JVM规范,没有安全方面的问题。主要验证是否符合Class文件格式规范,并且是否能被当前的虚拟机加载处理。
- 准备:正式为类变量(static变量)分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。
- 解析:虚拟机常量池的符号引用替换为字节引用过程。
初始化: 初始化是为类的静态变量赋予正确的初始值。
-
初始化阶段是执行类构造器linit () 方法)的过程(这里的初始化方法是JVM层面的,代码中是不可见。类构造器linit ()方法是由编译器自动收藏类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生,代码从上往下执行。
-
当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
-
虚拟机会保证一个类的linit () 方法在多线程环境中被正确加锁和同步。
②知道了类加载的过程,那JVM加载Class文件的原理机制是什么呢?
- Java中的所有类,都需要由类加载器装载到JVM中才能运行。类加载器本身也是一个类,而它的工作就是把class文件从硬盘读取到内存中。在写程序的时候,我们几乎不需要关心类的加载,因为这些都是隐式装载的,除非我们有特殊的用法,像是反射,就需要显式的加载所需要的类。
- 类装载方式,有两种 :
- 隐式装载, 程序在运行过程中当碰到通过new 等方式生成对象时,隐式调用类装载器加载对应的类到JVM中。
- 显式装载, 通过class.forName()等方法,显式加载需要的类。
- Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到jvm中,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。
③那究竟什么是类加载器呢?类加载器又有哪些呢?
- 其实类加载器解释起来也很简单,实现通过类的权限定名获取该类的二进制字节流的代码块叫做类加载器,类加载器也是一个类也是一段Java代码(如java.lang.ClassLoader及其子类,如URLClassLoader、AppClassLoader等)。我就想,类加载器是一段Java代码的话,那类加载器又是谁加载的呢?答案是引导类加载器(Bootstrap ClassLoader),它由JVM自身实现(不是Java代码),用于加载核心Java库,不会有人想问引导类加载器是谁加载的吧?它由JVM内部实现(通常用C/C++编写,依赖于具体JVM实现,如HotSpot VM),引导类加载器是JVM的一部分,直接由本地代码(Native Code)执行,因此它不需要被加载,在JVM启动的时候就自动被有了。
- 类加载器有引导类加载器、扩展类加载器、应用程序类加载器以及用户自定义类加载器
-
启动类加载器(Bootstrap ClassLoader):用来加载java核心类库,无法被java程序直接引用。
-
扩展类加载器(Extensions ClassLoader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
-
应用程序类加载器(App ClassLoader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。一般情况,如果我们没有自定义类加载器默认就是用这个加载器,可以通过ClassLoader.getSystemClassLoader()来获取它。
-
用户自定义类加载器(User ClassLoader):通过继承 java.lang.ClassLoader类的方式实现。
④为什么要分这么多类加载器呢?这样设计遵循单一职责原则,每一个类加载器负责自己的事情。当然每一层类加载器都遵循双亲委派模式,那下面我就说一种常见的情况。当我们加载一个类时,首先会找自定义的类加载器,但自定义的类加载器不会去加载,而是委托给应用程序类加载器,它也不会立即加载,而是往上一直推到引导类加载器,它才开始加载,若能加载到则直接返回,若加载不到就会返回给下一层,让它自己加载,当所有的类加载器都加载不到时,就会抛出ClassNotFound的异常,相信大家都遇见过。
- 总结就是:当一个类收到了类加载请求时,不会自己先去加载这个类,而是将其委派给父类,由父类去加载,如果此时父类不能加载,反馈给子类,由子类去完成类的加载。
⑤那JVM为什么要搞这个双亲委派机制呢?首先是为了保证每个类的唯一性,不会出现同名的类;第二点就是为了安全性,比如Java的核心类库Java.lang.String,如果没有此机制,我们在自定义类加载就可以自己写一个String,从而实现对核心类库的修改。
⑥正常情况下我们都是遵循双亲委派的加载模式,有些特定的情况下仍需要打破双亲委派(比如Tomcat可以挂载多个web应用,每个应用执行自己不同的类加载器,这个时候就需要打破双亲委派),那有哪些方式可以打破双亲委派呢?
- 重写 loadClass() 方法(最直接方式),直接绕过 parent.loadClass() 的委托调用,使自定义类优先由当前加载器处理。
public class CustomClassLoader extends ClassLoader { @Override public Class<?> loadClass(String name) throws ClassNotFoundException { // 1. 自定义类优先自己加载 if (name.startsWith("com.example.custom.")) { return findClass(name); // 打破关键点:直接自己加载 } // 2. 非自定义类仍遵循双亲委派 return super.loadClass(name); } @Override protected Class<?> findClass(String name) { // 自定义类加载逻辑(如从网络/数据库读取字节码) byte[] bytes = loadClassBytes(name); return defineClass(name, bytes, 0, bytes.length); } }
- 线程上下文类加载器(Java SPI机制)
// 服务调用方(核心类库中) ServiceLoader<Driver> drivers = ServiceLoader.load(Driver.class); // ServiceLoader.load()源码关键片段: public static <S> ServiceLoader<S> load(Class<S> service) { ClassLoader cl = Thread.currentThread().getContextClassLoader(); return new ServiceLoader<>(service, cl); // 使用线程上下文加载器 }
打破原理:
- JDBC等SPI服务由启动类加载器加载核心接口(如java.sql.Driver)
- 通过Thread.currentThread().setContextClassLoader()设置应用类加载器
- 核心代码调用上下文类加载器加载厂商实现类(如com.mysql.cj.jdbc.Driver)
- OSGi模块化热部署(网状加载模型)
打破原理:
- 每个模块(Bundle)使用独立类加载器
- 加载顺序:
检查是否已缓存类
尝试自己加载(不从父加载器委托)
查询依赖模块的加载器 - 支持模块级热替换
打破双亲委派也是有以下风险的
- 类冲突风险:打破后可能加载多个相同类的不同版本
- 类型转换异常:不同加载器加载的类互不兼容
下面贴一张打破双亲委派的经典应用场景
6.JVM调优
经过前面几个小章节,明白了JVM是如何运作的,我们就可以进行调优了,其实说的调优就是调整JVM相关的参数,当我们出现问题的时候还是要具体问题具体分析。
①JVM调优碰到的问题无非就是内存泄漏或者内存溢出,这两者有什么区别?哪些操作可能会造成呢?
内存溢出(Out of Memory - OOM):
- 内存溢出是指程序在申请内存时,没有足够的内存空间供其使用的情况。
- 突发性:通常表现为程序突然崩溃
- 错误类型:抛出OutOfMemoryError异常
- 内存使用:整体内存消耗已达到最大值
- 根本原因:申请的内存超过了系统/虚拟机可分配的最大内存
下面说说内存溢出常见的场景:
- 堆内存溢出:创建过多对象且无法回收(如数据库查询没有条件导致查询出大量的对象放入List中)
// 示例:持续创建大型对象 List<byte[]> list = new ArrayList<>(); while(true) { list.add(new byte[1024 * 1024]); // 每次添加1MB }
- 永久代/元空间溢出:加载过多类信息
// 示例:动态生成大量类 for(int i = 0; ; i++) { generateClass("GeneratedClass" + i); }
- 栈溢出:递归调用层级过深
// 示例:无限递归 void recursiveMethod() { recursiveMethod(); // 无限递归导致栈溢出 }
- 直接内存溢出:使用NIO时分配过多堆外内存
// 示例:持续分配直接缓冲区 List<ByteBuffer> buffers = new ArrayList<>(); while(true) { buffers.add(ByteBuffer.allocateDirect(1024 * 1024)); }
内存泄漏(Memory Leak):
- 内存泄漏是指程序未能释放不再使用的内存,导致内存被无效占用的现象。
- 渐进性:内存使用量随时间逐步增加
- 无显式错误:不会立刻引发错误,但最终会导致OOM
- 内存状态:存在不再使用但未被回收的对象
- 根本原因:对象的生命周期管理不当
下面说说内存泄漏常见的场景:
-
静态集合类泄漏
// 示例:静态集合持有对象引用 static List<Object> cache = new ArrayList<>(); void processRequest(Request request) { Object data = process(request); cache.add(data); // 添加后从不移除 }
-
资源未关闭
// 示例:文件流未关闭 void readFile() { try { FileInputStream fis = new FileInputStream("largefile.dat"); // 使用流但忘记关闭... } catch(IOException e) { e.printStackTrace(); } }
-
监听器未注销
// 示例:事件监听器未移除 void registerListener() { SomeComponent comp = new SomeComponent(); comp.addListener(new EventListener() { public void onEvent() { // 处理逻辑 } }); // 组件使用后未注销监听器 }
-
内部类引用外部类
// 示例:非静态内部类隐式持有外部类引用 class Outer { byte[] largeData = new byte[1024 * 1024 * 10]; // 10MB class Inner { // 隐式持有Outer.this引用 } Inner getInner() { return new Inner(); } } // 使用: Outer.Inner inner = new Outer().getInner(); // Outer实例无法被回收,因为inner持有其引用
-
在线程池中,线程长期存活,但是Thread Local对象被回收但未调用remove(Thread Local的value值是强引用)
public class SafeThreadLocalUsage { private static final ThreadLocal<byte[]> threadLocal = new ThreadLocal<>(); public static void main(String[] args) { ExecutorService executor = Executors.newFixedThreadPool(1); executor.execute(() -> { try { threadLocal.set(new byte[1024 * 1024]); System.out.println("Thread task executed"); } finally { // 必须手动清理 threadLocal.remove(); } }); executor.shutdown(); } }
②正常情况下,我们碰到这些问题应该如何排查呢?有哪些工具可以使用呢?在出现JVM问题的时候,若没有高并发的场景,应该首先考虑是不是慢sql导致的,因为慢sql一般都意味着大量数据,而大量数据放入List中时,很容易造成内存溢出的问题;其次才是代码问题。若在某个高并发的时间段,FULL GC频繁,就需要根据实际情况去调整JVM参数,总之,内存溢出的问题要根据实际场景去排查(不好查…哈哈哈)。
③在出现系统因为内存溢出而导致当宕机等问题,应该首先导出堆信息的dump文件,然后用JDK自带的jvisualvm工具来排查(JDK8以前在安装目录的bin下面自带该工具,后面的版本都需要自己去下载)这里就不说怎么使用这个工具了,我自己感觉这个不好用,但还是要比jstat、jmap、jstack这些命令看起来舒服的。这里推荐一个阿里开源的工具Arthas,自行搜索下。
总结
到这里就结束了,怎么排查JVM问题感觉说了等于没说,因为这个还是具体问题具体分析,比如大对象放入List中时,我们用jvisualvm工具可以看到有大量的char[ ]数组实例,这个跟经验什么都有关,感觉没有场景的话,意义不是很大,这种事情,生产碰到一次就会了。