JVM

177 阅读18分钟

JVM整体结构

image.png

类加载机制

  • 类加载就是虚拟机把类的.class文件加载进内存,并对数据进行校验、解析和初始化的过程,.class文件最终变为被虚拟机直接使用的java.lang.Class对象。 image.png
  • 加载:类加载器通过类的全限定类名获取.class文件,创建Class对象保存在堆中。
    • 类加载器包括启动类加载器,扩展类加载器,应用程序类加载器,自定义类加载器。
  • 链接
    • 验证:确保Class文件中的信息是安全的。
    • 准备:为类变量分配内存并设置初始值。
    • 解析:将符号引用替换为直接引用。
      • 符号引用指向符号,直接引用指向内存地址,符号引用主要用于类,接口,字段,方法的声明,是编译时的概念,例如java.lang.String是一个类符号引用。
  • 初始化:为静态变量赋值,调用类构造器,执行类中定义的Java代码。
  • 双亲委派模型
    • 类加载器收到类加载请求后,它先把这个请求委派给父类加载器完成,只有当父类加载器找不到该类时才会自己去加载。
    • 双亲委派模型保证了使用不同类加载器加载同一个类最终得到的都是同一个对象,避免类的重复加载,防止核心API被篡改。
    • 打破双亲委派模型需要自定义类加载器继承ClassLoader类,重写loadClass方法和findClass方法。
  • Tomcat打破了双亲委派模型
    • 隔离不同的Web应用:在同一个Tomcat服务器中可能部署多个不同的Web应用,这些应用可能依赖不同版本的同一个类库,如果使用双亲委派模型可能导致类冲突。Tomcat为每个Web应用创建独立的类加载器WebappClassLoader,优先加载应用自身的类,实现应用间的隔离。
    • 实现热部署:热部署即在不重启Tomcat的情况下重新加载应用,如果使用双亲委派,类加载器无法卸载已加载的类,Web应用使用独立的类加载器,热部署时可以直接替换类加载器。
    • 如果WebappClassLoader找不到对应的类,则委托给父类加载器。

JVM内存结构

  • 程序计数器PC:记录当前虚拟机正在执行的指令地址,唯一一个无OOM的区域。
  • 虚拟机栈:存储栈帧,描述方法执行。每调用一个方法就创建一个栈帧,栈帧中存储方法的局部变量表,操作数栈,动态链接,方法返回。方法的调用过程对应栈帧在虚拟机栈中入栈到出栈的过程。存在OOM和StackOverflow异常。
  • 本地方法栈:保存native方法的信息,不保存栈帧而是保存动态链接。
  • 方法区(永久代):存储加载的类信息,常量池,静态变量等。jdk8后被元数据区代替,原方法区中的类信息保存在元数据区,常量池保存在堆中。
  • :存储实例对象,是垃圾回收的主要区域,分为新生代和老年代。
  • 前三个为线程私有,后两个为线程共享。

堆与栈的区别

  • 栈由系统分配内存,堆需要手动申请。
  • 栈是一块连续的内存区域,最大容量是系统预定好的(较小),内存分配方式为静态分配(栈指针)。
  • 堆是不连续的内存区域,受限于系统中有效的虚拟内存(较大),空间分配方式为动态分配(空闲内存地址链表)。
  • 栈中存储栈帧,存储方法的局部变量表,操作数,返回信息,动态链接。堆存储实例对象。

Java对象创建过程

  1. JVM遇到新建对象指令先检查这个类是否已经被加载,若没有则先执行类加载过程。
  2. 为对象分配内存(一般分配内存有指针碰撞,空闲列表,TLAB三种方式)。
    • 指针碰撞:通过指针划分已使用和未使用内存,分配内存时将指针移动相应距离即可,要求内存空间连续,效率高,不存在内存碎片。如果是多个线程共用一个指针可能出现并发问题,可以使用CAS机制保证线程安全。线程使用自己私有的栈内存不存在并发问题。
    • 空闲链表:用链表记录每个空闲块,使用首次适应/最佳适应/最差适应等算法实现灵活分配,内存可以不连续,效率较低,存在内存碎片。堆是所有线程共享的,可能存在线程安全问题,可以加锁解决。
    • TLAB:为每个线程提前分配一块堆空间,这样每个线程只操作自己的空间,自己的空间分配完后再申请空间(加锁),减少锁竞争,解决了线程安全问题。
  3. 设置对象的对象头(hash值,年龄,锁对象)。
  4. 执行代码块和构造方法进行初始化。

Java对象的创建方式

  • new创建
  • 反射创建
  • 反序列化
  • clone机制:调用clone方法会复制一个新对象,先为新对象分配内存,再使用源对象中各个属性填充新对象。
// 此时复制的是对象的引用,两个对象地址相同
People p = new People(); 
Person p1 = p;
// 此时复制的是对象,两个对象地址不同
Person p1 = (Person)p.clone();
// clone是浅拷贝,直接将源对象的属性拷贝给新对象,即新旧对象的属性的引用都指向同一个位置。
// 深拷贝是根据源对象的属性去创建一个新的相同的属性(对象),将这个新的属性对象的引用拷贝给新对象,即新旧数据的属性的引用指向不同位置。
// 要实现深拷贝,创建一个类实现Clonable接口,重写clone方法,不仅要拷贝源对象本身,还要拷贝所有属性。

垃圾回收

  • 判断对象是否存活
    • 引用计数:当一个对象被另一个对象引用,计数加一,引用失效则减一,若一个对象的引用计数为0,说明这个对象可被回收。但是存在循环引用问题,即仅有两个对象间相互引用。
    • 可达性分析:从GC Roots对象向下搜索,若一个对象到GC Roots没有任何引用链接,则此对象不可用,即不可达对象。GC Roots对象包括虚拟机栈中引用的对象,方法区类静态变量引用的对象,方法区常量池引用的对象,本地方法栈引用的对象。
  • 基础垃圾回收算法
    • 标记清除:使用可达性分析标记所有可达对象,遍历堆内存回收未被标记的对象,存在内存碎片问题。(清除完后将存活对象向一端移动避免空间碎片就是标记整理算法)。
    • 复制算法:将内存划分为等大的两块,每次只使用其中一块,当前块存满后将存活对象复制到另一块上,把已用的内存清除。内存减半且存活对象多时效率低。
  • 分代收集策略
    • 分代收集策略是JVM默认的垃圾回收策略,按对象的不同生命周期将内存划分为不同区域,即新生代和老年代。
    • 对新生代使用复制算法,因为新生代需要回收的对象多。
    • 对老年代使用标记清除/标记整理算法,因为每次回收少量对象。
  • 堆的划分
    • 新生代:存放新生对象,这里的对象一般会被频繁创建和回收,即会频繁触发MinorGC。
      • Eden区:新对象的出生地,若新对象较大,也可能分配在SurvivorFrom区或老年代。
      • SurvivorFrom区:上一次GC的幸存者,作为这一次GC的被扫描者。
      • SurvivorTo区:保留了一次MinorGC中的幸存者。
    • 老年代:存放生命周期较长的对象,不会频繁执行Full GC,在Full GC之前会执行一次MinorGC,使得有新生代对象进入老年代从而引发空间不足。
  • JVM一次完整GC
    • 对象在Eden分配,Eden没有足够空间时,发起一次MinorGC,MinorGC后如果老年代满了,就会执行Full GC。
    • MinorGC流程如下
      • 1.把Eden和SurvivorFrom区域中存活的对象复制到SurvivorTo区域,移动一次年龄加一(若对象年龄达到15则移动到老年代)。
      • 2.清空Eden和SurvivorFrom中的对象。
      • 3.SurvivorFrom和SurvivorTo互换,即将这一次GC中幸存的对象放到SurvivorFrom中。
    • Full GC会清理整个内存堆,触发条件为
      • 有新对象进入,但是老年代空间不够分配新的内存。
      • 空间分配担保机制。
      • 调用System.gc时系统建议执行Full GC(但不一定会执行)。
    • 动态对象年龄判定规则:当某个年龄以上的对象占用空间达到Survivor区设置的阈值时,这些对象可以直接进入老年代(不一定要年龄到达15)。
    • 空间分配担保原则:老年代用空间分配担保机制确保进入的对象能够分配到空间
      • MinorGC之前JVM先检查老年代最大可用连续空间是否大于新生代所有对象的总大小。若是则直接MinorGC,否则检查是否允许担保失败。
      • 若不允许则Full GC,若允许则判断老年代最大可用连续空间是否大于之前进入老年代对象的平均大小,若大于则尝试MinorGC,否则Full GC。
      • 尝试MinorGC后,若存活对象小于survivor大小,将其放入survivor区;若存活对象大于survivor但小于老年代可用空间大小,则将其放入老年代;老年代也放不下了则触发Full GC;若Full GC后还放不下则OOM。
  • 四种引用类型
    • 强引用:普通对象引用关系。强引用变量处于可达状态,不会被回收,故强引用是造成内存泄漏的主要原因。
    • 软引用:系统内存足够时它不会被回收,系统内存不足时它会被回收。
    • 弱引用:只要垃圾回收机制一运行就会被回收。
    • 虚引用:跟踪对象被垃圾回收的状态。
  • 垃圾收集器
    • 新生代垃圾收集器
      • Serial:单线程,收集垃圾时暂停所有线程(stop the world),简单高效。stw用于确保垃圾回收过程中对象的引用关系不会发生变化,避免回收发生错误,避免处理复杂的并发问题。
      • ParNew:Serial的多线程版本,需要stop the world。
      • Parallel Scavenge:多线程,关注吞吐量,可以根据系统状态动态调节参数。
    • 老年代垃圾收集器
      • Serial Old:Serial的老年代版本,老年代默认垃圾收集器。
      • Parallel Old:Parallel Scavenge的老年代版本。
      • CMS:最短停顿时间(即stw时间),标记清除算法,产生内存碎片。
      • G1:精确控制停顿时间,把内存分为多个Region,根据用户指定停顿时间生成回收计划。
      • ZGC:以极低的停顿时间处理大量堆内存,使用染色指针和读屏障实现并发标记和整理。
    • CMS回收过程与存在问题
      • 回收过程
        • 初始标记:标记GC Roots直接可达的对象,stw。
        • 并发标记:并发标记所有可达对象,不stw。
        • 重新标记:修正并发标记期间变动的对象标记,stw。
        • 并发清除:并发清除未被标记的对象,不stw。
      • 存在问题
        • 并发清除阶段还会有垃圾产生,即浮动垃圾,留到下一次垃圾收集。
        • 并发失败:CMS运行时需要留足够的空间给用户线程使用,若预留空间无法满足需要,就会出现并发失败,虚拟机需要临时启动Serial Old进行老年代的垃圾回收并stw,此时停顿时间很长。
        • 使用标记清除算法,存在内存碎片。
    • G1回收过程
      • 初始标记:同CMS。
      • 并发标记:同CMS。
      • 最终标记:同CMS。
      • 清理阶段:stw,更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。
    • ZGC回收过程
      • 回收过程与G1类似,但是使用了染色指针和读屏障。
      • 染色指针即在指针中嵌入元数据信息,用于并发标记和整理,减少内存占用,提高并发效率。
      • 读屏障即在应用程序访问对象时,检查对象的标记和整理状态,确保并发正确性,减少停顿时间。
  • 三色标记算法
    • 三色标记算法是一种垃圾收集的标记算法,用于并发标记阶段,三色指白色,灰色,黑色。
    • 三色标记算法过程
      • 标记开始时堆内的对象都是白色的(表示未被标记,可能是垃圾)。
      • 将根对象标记为灰色(表示对象已被标记,但其引用对象未被标记)。
      • 取出一个灰色对象并将其引用的白色对象标记为灰色,该灰色对象变为黑色(表示该对象及其所有引用对象都被标记了)。
      • 重复上述过程,直到无灰色对象,最后剩下的白色对象表示没被引用,直接被回收,黑色对象存活。
    • 三色标记法存在并发问题,会导致漏标或多标。
      • 多标即标记完某个对象为黑色后,其引用对象又被断开,此时多标了此引用对象,出现浮动垃圾,但是可在下一次垃圾收集时回收,影响不大。
      • 漏标即标记完某个对象为黑色后,其灰色引用对象的白色引用对象断开,但是与黑色对象新建了引用关系,但是由于不会再检查黑色对象的引用了,该新增引用对象为白色被回收。
      • 漏标问题使用增量更新方案解决。在并发标记阶段,如果黑色对象指向了一个白色对象,则会把该黑色对象记录下来,在重新标记阶段重新将其标记为灰色并进行扫描。
image.png

常量池

  • Class文件常量池:存放.class文件二进制数据。
  • 运行时常量池:运行时生成的常量被放入其中,如String.intern()。
  • 全局字符串常量池:维护字符串实例的引用。
  • 基本类型包装类对象常量池:仅负责创建和管理大于-128以及小于127的对象。

JVM监控命令

  • jps:查看系统内所有java进程基本信息。
  • jstat:监视JVM运行时状态信息,如类加载,内存,垃圾收集。
  • jinfo:查看和修改JVM的配置参数。
  • jmap:生成heap dump文件,查询堆内存详细信息。
  • jhat:用于分析heap dump文件。
  • jstack:生成Java进程的线程快照。

JVM虚拟机参数

  • 内存设置参数
    • -Xms:JVM启动时的初始堆大小
    • -Xmx:JVM可以使用的最大堆大小
    • -Xss:每个线程的堆栈大小
    • -XX:NewSize:新生代的最大大小
    • -XX:SurvivorRaio:设置Eden区与Survivor区的大小比例
  • 垃圾回收器参数
    • -XX:+UseSerialGC:使用Serial垃圾回收器(使用其他垃圾回收器的参数同理)
  • 生成dump文件的参数
    • -XX:+HeapDumpOnOutOfMemoryError:当发生OOM时生成dump文件
    • -XX:HeapDumpPath:指定存储dump文件的目录地址

内存泄漏与内存溢出

  • 内存泄漏指程序在申请内存后无法释放已申请的内存空间,一次内存泄漏似乎不会有太大的影响,但是内存泄漏堆积最终会导致内存溢出
  • 内存溢出指程序申请内存时没有足够的内存提供给申请者,内存溢出会报OOM错误。

JVM调优之OOM处理

设置两个JVM参数HeapDumpOnOutOfMemoryError和HeapDumpPath,表示发生OOM时自动生成堆栈内存信息到指定文件中,然后用jhat命令/VisualVM等可视化工具对dump文件进行分析。发生OOM可能有以下几种情况:

  1. 堆空间不足:由于创建/缓存了大量对象导致堆空间不足,或某些对象使用完没有回收导致内存泄漏(IO资源,ThreadLocal等)。
    • 查看Dump文件找到大对象或内存泄漏对象,进一步查看该对象到GC Roots的引用链,然后可以锁定到具体的代码,在代码层面解决内存泄漏问题,如果不存在泄漏则检查代码是否有死循环,递归等。
    • 如果确实需要缓存大量对象,则需要加入超时删除或LRU机制避免缓存数据无限增大。
    • 存在十分消耗资源的操作,比如不受行数限制的数据库查询,不限制字节数的文件读取等情况,这些情况要加上限制。
  2. 栈空间不足:单个线程请求的栈深度过大或者栈帧太大或者创建过多线程导致内存溢出。
    • 如果是StackOverflow,检查代码是否使用了递归,递归方法的结束条件是否没有写好。
    • 如果是OOM,检查是否创建了大量线程,如果需要大量线程可以降低每个线程栈的大小。
    • 使用线程池时,无限创建线程的线程池或无限存储任务的阻塞队列都可能导致OOM。
  3. 方法区溢出:方法区存放Class相关信息,若运行时产生大量类会导致方法区溢出(例如使用CGLib生成大量代理类)。
    • 调整方法区(元空间)大小,避免加载不必要的类,避免生成大量代理类
  4. GC存在问题:如果存在频繁的垃圾回收(尤其是fullGC)且每次只能回收少量对象,可能会抛出OOM异常,可能是内存不足导致GC无法正常运行或对象创建过快导致GC无法跟上。
    • 检查JVM参数,调整新生代和老年代的大小比例,增加堆内存大小,使用更高效的垃圾收集器
  5. 机器内存太小或JVM内存参数设置不合理。

JVM调优之性能优化

主要是调整JVM各个内存区域的大小,优化垃圾回收的效率,优化代码减少大对象创建,内存泄漏,死循环等问题。这里重点关注如何优化垃圾回收效率:

  1. 先查看当前的JVM配置参数(java -XX:+PrintFlagsFinal -version),主要看堆大小,young区大小,垃圾回收器。再查看GC的情况(jstat -gcutil pid xxx),主要看Survivor空间/Eden空间/Old Generation的利用率,ygc/fullgc次数和频率,关注单次GC耗时。
  2. 查看分析GC log(可视化工具,如gceasy网站),主要看gc触发时间,gc停顿时长,gc前后的内存,对象晋升到老年代的年龄分布情况,gc的吞吐量(jvm运行业务代码时长占总时长的比例)。
  3. 根据实际情况,一些可以调整的方案包括:
    • 加大Young区的大小并兼顾老年代的大小,提高机器内存并重新划分堆分区。
    • 分代调整,每次GC后年轻代中年龄大于MaxTenuringThreshold(15)的对象提升到老年代,但是JVM还有动态年龄规则,按照对象年龄从小到大对其占用大小进行累积,当某个年龄所有对象累积内存超过阈值就将其作为新的晋升年龄阈值。可能出现的情况是各个年龄的对象都有一部分,但是都没达到阈值,导致它们都在survivor之间来回复制,所以可以调整MaxTenuringThreshold的值,例如将年龄超过6的对象直接提升到老年代。
    • 使用更高效的垃圾回收器,例如把CMS换成G1或ZGC。

高CPU占用率问题

  • 先用top命令查看CPU占用情况,找到CPU较高的进程ID / 线程ID。
  • jstack查看对应线程的堆栈信息(或查看Dump日志),根据对象引用信息定位到具体的业务代码。
  • 查看代码是否有死循环,频繁垃圾回收,频繁资源竞争等问题。