JVM内存结构

JVM的内存结构主要分为几个区域,首先是堆内存,这个是我们平时开发最常接触的,所有的对象实例和数组都在这里分配,堆内存又分为新生代和老年代,新生代又分为Eden区、Survivor0和Survivor1区。然后是方法区,也叫元空间,存储类的元数据信息,比如类的结构、常量池、静态变量等。还有虚拟机栈,每个线程都有自己的栈,存储局部变量、方法参数、返回值等。程序计数器记录当前线程执行的字节码指令地址。本地方法栈是给native方法用的。

为什么JDK8使用元空间替代永久代

JDK8使用元空间替代永久代主要有以下几个原因:

  1. 字符串常量池移出:永久代空间有限,容易导致OOM,而字符串常量池在应用中可能会占用大量空间
  2. 类加载和卸载的灵活性:元空间使用本地内存,不受JVM内存限制,可以动态调整大小
  3. 垃圾回收效率:永久代的垃圾回收效率低,Full GC时才会扫描,而元空间可以独立进行垃圾回收
  4. GC性能提升:元空间的GC不需要扫描整个堆,性能更好
  5. 调优方便:元空间可以使用-XX:MaxMetaspaceSize等参数进行灵活配置

对象创建过程

对象创建的过程主要包括以下几个步骤:

  1. 类加载检查:虚拟机遇到new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过
  2. 分配内存:为对象分配内存空间,指针碰撞(Serial、Parallel收集器)或空闲列表(CMS收集器)
  3. 初始化零值:将分配到的内存空间(不包括对象头)都初始化为零值
  4. 设置对象头:设置对象的对象头,包括对象的类信息、哈希码、GC年龄等
  5. 执行init方法:执行对象的构造函数,初始化对象的字段

其中分配内存时需要考虑线程安全问题,可以通过以下两种方式解决:

  • CAS+失败重试:给内存分配的操作加上同步锁
  • TLAB(本地线程分配缓冲):每个线程在堆中预先分配一小块内存,线程私有,互不干扰

堆内存详解

堆内存是JVM中最大的一块内存区域,主要用来存储对象实例。它分为新生代和老年代两个区域。

新生代

新生代又分为三个区域:

  • Eden区:新创建的对象首先分配在Eden区
  • Survivor0和Survivor1区:也叫S0和S1,用来存放经过一次垃圾回收后存活的对象

新生代的特点是对象生命周期短,大部分对象创建后很快就会被回收。

老年代

老年代用来存放生命周期较长的对象,比如经过多次垃圾回收后仍然存活的对象,或者大对象直接进入老年代。

垃圾回收机制

如何判断对象是否可以回收

JVM主要通过以下几种算法来判断对象是否可以被回收:

引用计数算法

引用计数算法是通过给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1,当引用失效时,计数器就减1。当计数器为0时,就表示对象可以被回收。这种算法实现简单,效率高,但是无法解决循环引用的问题。

可达性分析算法

JVM主要使用可达性分析算法来判断对象是否可以回收。这个算法的基本思路是通过一系列称为"GC Roots"的对象作为起点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,就证明该对象是不可用的。

在Java中,可以作为GC Roots的对象包括:

  1. 虚拟机栈中引用的对象(比如局部变量、方法参数)
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI引用的对象

垃圾回收算法

标记清除算法

这个算法分为两个阶段,首先是标记阶段,遍历所有对象,标记出哪些是垃圾对象,哪些是存活对象。然后是清除阶段,把标记为垃圾的对象回收掉。这个算法的优点是实现简单,缺点就是会产生内存碎片,而且效率不高。

复制算法

复制算法把内存分为两块,每次只用其中一块,垃圾回收的时候,把存活的对象复制到另一块内存中,然后把原来的那块内存全部清空。这个算法的优点是没有内存碎片,缺点就是浪费了一半的内存空间。现在新生代用的就是这种算法的改进版。

标记整理算法

标记整理算法和标记清除算法类似,也是先标记,但是清除的时候不是直接删除,而是把存活的对象向一端移动,然后清理掉边界以外的内存。这个算法的优点是没有内存碎片,缺点就是移动对象需要时间。

分代收集理论

JVM采用分代收集理论,就是根据对象的生命周期不同,采用不同的垃圾回收策略。

新生代垃圾回收(Minor GC)

新生代的对象生命周期短,大部分对象都是朝生夕死,所以采用复制算法。具体过程是这样的:

  1. 新对象首先分配在Eden区
  2. 当Eden区满了,触发Minor GC
  3. 把Eden区和Survivor区中存活的对象复制到另一个Survivor区
  4. 清空Eden区和原来的Survivor区

老年代垃圾回收(Major GC)

老年代的对象生命周期长,采用标记清除标记整理算法。

对象分配策略

对象优先在Eden区分配

新创建的对象首先在Eden区分配,如果Eden区空间不足,触发Minor GC。

大对象直接进入老年代

如果对象很大,比如超过一定阈值,会直接分配到老年代,避免在新生代之间复制。

长期存活的对象进入老年代

对象在Survivor区每经过一次Minor GC,年龄就加1,当年龄达到一定阈值(默认15),就会晋升到老年代。

动态年龄判断

如果Survivor区中相同年龄的对象大小总和超过Survivor区的一半,那么年龄大于等于这个年龄的对象就可以直接进入老年代。

垃圾收集器

Serial收集器

Serial收集器是单线程的垃圾收集器,在进行垃圾回收的时候,必须暂停所有用户线程,也就是Stop The World。这个收集器适合单核CPU或者小内存的应用。

Parallel收集器

Parallel收集器是Serial收集器的多线程版本,使用多个线程进行垃圾回收,提高了回收效率。这是JDK8默认的新生代收集器。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它分为四个阶段:

  1. 初始标记:标记GC Roots能直接关联的对象,需要Stop The World,但速度很快
  2. 并发标记:并发标记所有可达对象,不需要Stop The World,可以与用户线程并发执行
  3. 重新标记:修正并发标记期间因用户线程继续运行而导致的标记变动,需要Stop The World
  4. 并发清除:并发清除垃圾对象,不需要Stop The World

CMS的优点是并发收集,停顿时间短,缺点是:

  1. 会产生内存碎片,因为使用的是标记清除算法
  2. 对CPU资源敏感,并发阶段占用CPU线程
  3. 无法处理浮动垃圾,并发清理时用户线程产生的垃圾只能在下次GC时清理
  4. 在老年代内存占用率达到阈值时会触发Full GC

G1收集器

G1(Garbage First)收集器是面向服务端的垃圾收集器,它的特点是把堆内存分成多个大小相等的区域(Region),然后优先回收垃圾最多的区域。G1收集器可以设置期望的停顿时间,通过预测和调整来尽量满足这个时间要求。

G1与CMS的区别

G1收集器和CMS收集器的主要区别:

  1. 内存布局:CMS是物理分代,新生代和老年代物理隔离;G1是逻辑分代,物理上不隔离,通过Region实现
  2. 垃圾回收算法:CMS使用标记清除算法,G1使用标记整理(整体看)和复制算法(Region之间)
  3. 停顿时间模型:CMS最短回收停顿时间,G1可预测停顿时间
  4. 内存碎片:CMS会产生内存碎片,G1不会产生内存碎片
  5. 适用场景:CMS适合堆内存较小的应用,G1适合大堆内存(6-8GB以上)的应用
  6. 并发标记:CMS并发标记阶段如果用户线程还在产生对象,会导致本次GC失败;G1通过SATB(Snapshot-At-The-Beginning)算法解决这个问题

ZGC收集器

ZGC是JDK11引入的低延迟垃圾收集器,它的目标是停顿时间不超过10ms,而且停顿时间不会随着堆内存大小增长而增长。

类加载机制

类加载过程

类加载分为五个阶段:加载、验证、准备、解析、初始化。

加载

加载阶段主要是通过类的全限定名获取二进制字节流,然后将字节流转换为方法区的运行时数据结构,最后在内存中生成一个代表这个类的Class对象。

验证

验证阶段主要是确保Class文件的字节流中包含的信息符合当前虚拟机的要求,包括文件格式验证、元数据验证、字节码验证、符号引用验证。

准备

准备阶段主要是为类变量分配内存并设置初始值,这里的初始值通常是数据类型的零值,比如int是0,boolean是false。

解析

解析阶段主要是将符号引用转换为直接引用,符号引用就是一些字符串,直接引用就是指向内存中具体位置的指针。

初始化

初始化阶段主要是执行类构造器<clinit>()方法,这个方法是由编译器自动收集类中所有类变量的赋值动作和静态代码块中的语句合并产生的。

类加载器

启动类加载器(Bootstrap ClassLoader)

启动类加载器负责加载Java的核心类库,比如rt.jar、charsets.jar等,这个类加载器是用C++实现的,是虚拟机的一部分。

扩展类加载器(Extension ClassLoader)

扩展类加载器负责加载Java的扩展类库,比如javax.*开头的类。

应用程序类加载器(Application ClassLoader)

应用程序类加载器负责加载用户类路径(ClassPath)上的类库,这是我们平时开发中接触最多的类加载器。

双亲委派模型

双亲委派模型的工作过程是这样的:

  1. 当一个类加载器收到类加载请求时,首先不会自己去加载,而是委托给父类加载器
  2. 如果父类加载器还有父类加载器,就继续向上委托
  3. 如果父类加载器无法加载,子类加载器才会尝试自己加载

双亲委派模型的优点是保证了类的唯一性,避免了类的重复加载,也保证了Java核心API不被篡改。

如何打破双亲委派模型

双亲委派模型是一个推荐的设计模型,但不是强制的设计模型。在某些场景下,我们需要打破双亲委派模型:

  1. 自定义类加载器:重写loadClass方法,不先委托给父类加载器,而是自己先尝试加载

    • 比如OSGi等模块化框架,需要实现类的隔离和版本管理
    • 应用服务器如Tomcat,需要实现不同Web应用的类隔离
  2. 线程上下文类加载器:使用Thread.setContextClassLoader()设置当前线程的类加载器

    • Java SPI服务发现机制就是通过线程上下文类加载器来加载第三方实现类
    • Spring等框架也大量使用线程上下文类加载器来加载用户类
  3. 热部署场景:为了实现类的热替换,需要自定义类加载器,每次热部署时创建新的类加载器实例

打破双亲委派模型需要注意:

  • 可能导致类的重复加载和版本冲突
  • 可能破坏Java核心API的安全性
  • 需要谨慎处理类的依赖关系和版本管理

JVM调优

堆内存调优

  • -Xms:设置堆内存的初始大小
  • -Xmx:设置堆内存的最大大小
  • -Xmn:设置新生代的大小
  • -XX:SurvivorRatio:设置Eden区和Survivor区的比例

垃圾收集器调优

  • -XX:+UseG1GC:使用G1收集器
  • -XX:MaxGCPauseMillis:设置期望的最大GC停顿时间
  • -XX:+UseConcMarkSweepGC:使用CMS收集器

方法区调优

  • -XX:MetaspaceSize:设置元空间的初始大小
  • -XX:MaxMetaspaceSize:设置元空间的最大大小

常见JVM问题

内存溢出(OutOfMemoryError)

内存溢出通常发生在堆内存、方法区、虚拟机栈等区域。

堆内存溢出

堆内存溢出通常是因为创建了太多对象,或者存在内存泄漏。可以通过增加堆内存大小或者优化代码来解决。

方法区溢出

方法区溢出通常是因为加载了太多类,或者常量池太大。可以通过增加方法区大小或者减少类的加载来解决。

虚拟机栈溢出

虚拟机栈溢出通常是因为递归调用太深,或者栈帧太大。可以通过增加栈大小或者优化递归算法来解决。

内存泄漏

内存泄漏是指程序在运行过程中,不再使用的对象没有被垃圾回收器回收,导致内存占用越来越多。

常见的内存泄漏原因:

  1. 静态集合类:静态集合类持有对象的引用,导致对象无法被回收
  2. 监听器:注册了监听器但没有取消注册
  3. 各种连接:数据库连接、网络连接、IO连接等没有关闭
  4. 内部类和外部类:内部类持有外部类的引用,导致外部类无法被回收

性能监控工具

jps

jps命令可以查看当前运行的Java进程,类似于ps命令。

jstat

jstat命令可以监控JVM的各种统计信息,比如堆内存使用情况、垃圾回收情况等。

jmap

jmap命令可以生成堆内存的dump文件,用于分析内存使用情况。

jstack

jstack命令可以生成线程的dump文件,用于分析线程状态和死锁问题。

VisualVM

VisualVM是一个图形化的JVM监控工具,可以实时监控JVM的各种指标,还可以进行内存分析和线程分析。

面试常见问题

什么是JVM?

JVM是Java虚拟机,它是Java程序运行的环境。JVM的主要作用是:

  1. 加载字节码文件
  2. 解释执行字节码
  3. 管理内存
  4. 进行垃圾回收

为什么Java是跨平台的?

Java是跨平台的,主要是因为JVM的存在。Java程序编译后生成的是字节码文件,这个字节码文件可以在任何安装了JVM的平台上运行。JVM负责将字节码解释成对应平台的机器码执行。

堆内存和栈内存的区别?

堆内存和栈内存的主要区别:

  1. 存储内容:堆内存存储对象实例,栈内存存储局部变量、方法参数等
  2. 生命周期:堆内存中的对象生命周期不确定,栈内存中的变量生命周期确定
  3. 内存管理:堆内存需要垃圾回收,栈内存自动管理
  4. 线程安全:堆内存是线程共享的,栈内存是线程私有的

什么是垃圾回收?

垃圾回收是JVM自动管理内存的机制,它会自动回收不再使用的对象占用的内存。垃圾回收的主要步骤是:

  1. 标记:标记出哪些对象是垃圾
  2. 清除:回收垃圾对象占用的内存
  3. 整理:整理内存,消除碎片

什么时候会触发垃圾回收?

垃圾回收会在以下情况下触发:

  1. 堆内存不足时
  2. 新生代空间不足时
  3. 老年代空间不足时
  4. 手动调用System.gc()时(不推荐)

什么是Stop The World?

Stop The World是指在进行垃圾回收时,JVM会暂停所有用户线程,只保留垃圾回收线程运行。这样做的目的是为了保证垃圾回收的正确性,避免在回收过程中对象状态发生变化。

如何优化JVM性能?

JVM性能优化的主要方法:

  1. 合理设置堆内存大小:根据应用的实际需求设置合适的堆内存大小
  2. 选择合适的垃圾收集器:根据应用的特点选择合适的垃圾收集器
  3. 优化代码:减少对象的创建,避免内存泄漏
  4. 监控和调优:使用监控工具分析性能瓶颈,进行针对性调优

什么是类加载器?

类加载器是JVM用来加载类的组件,它负责将字节码文件加载到内存中,并生成对应的Class对象。Java中有三种主要的类加载器:启动类加载器、扩展类加载器、应用程序类加载器。

什么是双亲委派模型?

双亲委派模型是类加载器的工作机制,当一个类加载器收到类加载请求时,它首先会委托给父类加载器去加载,只有当父类加载器无法加载时,子类加载器才会尝试自己加载。这样可以保证类的唯一性和安全性。