JVM


JVM 内存区域


讲一下JVM的内存结构?

答:

JVM的内存结构

JVM内存结构分为 5 大区域,分别是:程序计数器、虚拟机、本地方法栈、堆、方法区

HotSpot 在 JDK1.8 之前 方法区 就是 永久代,永久代 就是 方法区。JDK1.8 后删除了永久代,改为元空间,元空间在本地内存中。方法区就是元空间,元空间就是方法区

创建一个线程,JVM就会为其分配一个私有内存空间,其中包括:PC、虚拟机和本地方法栈

程序计数器

  • 线程私有的,作为当前线程的行号指示器,用于记录当前虚拟机正在执行的线程指令地址
  • 程序计数器主要有两个作用:
    1. 当前线程所执行的字节码的行号指示器,通过它实现代码的流程控制,如:顺序执行、选择、循环、异常处理
    2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,当线程被切换回来的时候能够知道它上次执行的位置
  • 程序计数器是唯一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡

虚拟机栈

  • Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。每一次函数调用都会有一个对应的栈帧被压入虚拟机栈,每一个函数调用结束后,都会有一个栈帧被弹出
  • 局部变量表是用于存放方法参数和方法内的局部变量
  • 每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,在方法调用过程中,会进行动态链接,将这个符号引用转化为直接引用
    • 部分符号引用在类加载阶段的时候就转化为直接引用,这种转化就是静态链接
    • 部分符号引用在运行期间转化为直接引用,这种转化就是动态链接Java 虚拟机栈也是线程私有的
  • 每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。Java 虚拟机栈会出现两种错误:StackOverFlowError 和 OutOfMemoryError
  • 可以通过 -Xss 参数来指定每个线程的虚拟机栈内存大小:java -Xss2M

本地方法栈

  • 虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。Native 方法一般是用其它语言(C、C++等)编写的
  • 本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息

堆

  • 堆用于存放对象实例,是垃圾收集器管理的主要区域,因此也被称作 GC 堆
  • 堆可以细分为:新生代(Eden 空间、From Survivor、To Survivor 空间)和老年代
  • 通过 -Xms 设定程序启动时占用内存大小,通过 -Xmx 设定程序运行期间最大可占用的内存大小。如果程序运行需要占用更多的内存,超出了这个设置值,就会抛出 OutOfMemory 异常。 java -Xms1M -Xmx2M

方法区

  • 方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、
    静态变量、即时编译器编译后的代码等数据

  • 对方法区进行垃圾回收的主要目标是对常量池的回收和对类的卸载

  • 永久代

    • 方法区是 JVM 的规范,而永久代 PermGen 是方法区的一种实现方式,并且只有 Hotspot 有永久代。对于其他类型的虚拟机,如:JRockit 没有永久代。由于方法区主要存储类的相关信息,所以对于动态生成类的场景比较容易出现永久代的内存溢出
  • 元空间

    • JDK 1.8 的时候, Hotspot 的永久代被彻底移除了,使用元空间替代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现
  • 两者最大的区别在于:元空间并不在虚拟机中,而是使用本地内存

  • 为什么要将永久代替换为元空间呢?

    • 答:永久代内存受限于 JVM 可用内存,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是相比永久代内存溢出的概率更小
  • 运行时常量池

    • 运行时常量池是方法区的一部分,在类加载之后,会将编译器生成的各种字面量和符号引用放到运行时常量池。在运行期间动态生成的常量,如:JDK6 及之前 String 类的 intern() 方法,也会被放入运行时常量池,引起常量池溢出
  • 直接内存

    • 直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现
    • NIO 的 Buffer 提供了 DirectBuffer,可以直接访问系统物理内存,避免堆内内存到堆外内存的数据拷贝操作,提高效率。DirectBuffer 直接分配在物理内存中,并不占用堆空间。其可申请的最大内存受操作系统限制,不受最大堆内存的限制
    • 直接内存的读写操作比堆内存快,可以提升程序 I/O 操作的性能。通常在 I/O 通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝目生命周期较短的暂存数据,都建议存储到直接内存

JVM内存结构


JDK 1.7 和 JDK 1.8 内存区域的区别?

答:

方法区的实现区别:

  • JDK 1.7:
    • 方法区仍然是永久代(PermGen)的组成部分
    • 该区域存储类的元数据、常量池、静态变量等
    • 大小是固定的,可以通过 JVM 参数(如:-XX:Permsize 和 -XX:MaxPermsize) 来设定,容易导致OutOfMemoryError
  • JDK 1.8:
    • 引入了元空间(Metaspace),取代了永久代
    • 元空间在本地内存中分配,不再需要固定大小,只有受到系统内存的限制
    • 通过 JVM 参数(如: -XX:Metaspacesize 和 -XX:MaxMetaspacesize)管理初始化大小和最大使用

元空间相对于永久代有什么好处?

答:

  1. 整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整(也就是受到 JVM 内存的限制),而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小
  2. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermsize 控制了,而由系统的实际可用空间来控制,这样能加载的类就更多了,
  3. 在 JDK8,合并 HotSpot 和 JRockit 的代码时,JRockit 从来没有一个叫永久代的东西,合并之后就没有必要额外的设置这么一个永久代的地方了
  4. 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低

说一下堆和栈的区别?

答:

  1. 堆的物理地址分配是不连续的,性能较慢;栈的物理地址分配是连续的,性能相对较快
  2. 堆存放的是对象的实例和数组;栈存放的是局部变量,操作数栈,返回结果等
  3. 堆是线程共享的;栈是线程私有的

内存溢出和内存泄漏的区别?

答:

  • 内存溢出 OutOfMemory:指程序在申请内存时,没有足够的内存空间供其使用
  • 内存泄漏 Memory Leak:指程序在申请内存后,无法释放已申请的内存空间,最终将导致内存溢出

内存溢出发生在哪些区域?

答:

  1. Java 堆(Heap):
    • Java堆用于存储对象实例。当创建的对象实例数量过多,超过了Java堆的容量,就会引发堆内存溢出
  2. Java 方法区:
    • Java方法区存储类的信息、中常量池、静态变量等。例如人如果加载的类或类元数据过多,超出了方法区(永久代 / 元空间)的限制,就会导致方法区溢出
  3. 栈(Stack):
    • 栈用于存储局部变量、方法调用和部分数据。溢出一般是由于递归调用层次过深导致栈空间耗尽
  4. 本地方法栈(Native Method Stack):
    • 本地方法栈类似于Java栈,用于调用本地(非Java)方法。内存溢出可能发生在本地方法栈由于本地方法调用过程中空间不足。一般也是递归调用层次过深等原因

栈溢出的原因?

答:

由于 HotSpot 不区分虚拟机和本地方法栈,设置本地方法栈大小的参数没有意义,栈容量只能由 -Xss 参数来设定,存在两种异常:

  • StackOverflowError:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError,例如:一个递归方法不断调用自己。该异常有明确错误堆栈可供分析,容易定位到问题所在。
  • OutOfMemoryError:如果 JVM 栈可以动态扩展,当扩展无法申请到足够内存时会抛出 OutOfMemoryError 。HotSpot 不支持虚拟机栈扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现 OOM,否则在线程运行时是不会因为扩展而导致溢出的

运行时常量池溢出的原因?

答:

  • String 的 intern 方法是一个本地方法,作用是:如果字符串常量池中已包含一个等于此 String 对象的字符串,则返回池中这个字符串的 String 对象的引用,否则将此 String 对象包含的字符串添加到常量池并返回此 String 对象的引用
  • 在 JDK6 及之前常量池分配在永久代,因此可以通过 -XX:Permsize 和 -XX:MaxPermsize 限制永久代大小,间接限制常量池。在 while 死循环中调用 intern 方法导致运行时常量池溢出。在 JDK7 后不会出现该问题,因为存放在永久代的字符串常量池已经被移至堆中

方法区溢出的原因?

答:

  • 方法区主要存放类型信息,如:类名、访问修饰符、常量池、字段描述、方法描述等。只要不断在运行时产生大量类,方法区就会溢出。例如:使用 JDK 反射或 CGLib 直接操作字节码在运行时生成大量的类。很多框架如: Spring、Hibernate 等对类增强时都会使用 CGLib 这类字节码技术,增强的类越多就需要越大的方法区保证动态生成的新类型可以载入内存,也就更容易导致方法区溢出
  • JDK8 使用元空间取代永久代,HotSpot 提供了一些参数作为元空间防御措施,例如:-XX:MetaspaceSize 指定元空间初始大小,达到该值会触发 GC 进行类型卸载,同时收集器会对该值进行调整,如果释放大量空间就适当降低该值,如果释放很少空间就适当提高

类加载机制


类加载流程

什么是类加载?类加载的过程是什么?

答:

  • 类的加载指的是:将类的 class 文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个此类的 class 对象,通过这个对象可以访问到方法区对应的类信息
  • 类加载流程,有 加载、验证、准备、解析、初始化 这几个步骤
    • 加载
      1. 通过 类的全限定名 获取定义此类的二进制字节流
      2. 将 字节流所代表的静态存储结构 转换为 方法区的运行时数据结构
      3. 在内存中生成一个代表该类的 class 对象,作为方法区类信息的访问入口
  • 验证
    • 确保 class 文件的字节流中包含的信息符合虚拟机规范,保证在运行后不会危害虚拟机自身的安全。主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证
      1. 文件格式验证:验证读进来的字节流是否符合 Class 标准格式
      2. 元数据验证:格式对了之后,验证一下里边的数据合不合理。比如:这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类)
      3. 字节码验证:分析类的方法体(Class文件中的Code属性),确保方法在运行时不会危害虚拟机
      4. 符号引用验证(发生在解析阶段):检查常量池中引用的外部类是否存在,是否可以正常访问
  • 准备
    • 为类变量分配内存并设置类变量初始值的阶段。其中如果是 final 修饰的,意味着在 class 文件中,该字段的属性表中存在 ConstantValue 属性,此时初值设置为代码里写的。如果不是,就设置为零值,等到初始化阶段再赋值
  • 解析
    • 虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用用于描述目标,直接引用直接指向目标的地址
  • 初始化
    • 开始执行类中定义的 Java 代码,初始化阶段是调用类构造器的过程

什么是双亲委派模型?

答:

  • 一个类加载器收到一个类的加载请求时,它首先不会自己尝试去加载它,而是把这个请求委派给父类加载器去完成,这样层层委派,因此所有的加载请求最终都会传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载
  • 双亲委派模型的具体实现代码在 java.lang.classLoader 中,此类的 loadclass() 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出classNotFoundException,此时尝试自己去加载

为什么需要双亲委派模型?

答:

双亲委派模型的好处:如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.object 的同名类并放在 classPath 中,多个类加载器都去加载这个类到内存中,系统中将会出现多个不同的 object 类,那么类之间的比较结果及类的唯一性将无法保证


双亲委派机制是如何避免类的重复加载的?

答:

  • 加载顺序控制:双亲委派机制通过强制在加载类时优先请求父类加载器,建立了一个有序的类加载体系
  • 避免同一类的重复加载:由于加载器在加载类时会检查是否已经加载过此类,因此即使请求来自不同的类加载器,只要类的全限定名(包括包名)相同,JVM 也会避免重复加载。

在什么情况下会选择绕过双亲委派机制?

答:

  1. 自定义类加载器:开发者需要加载一些特定的类,这些类可能不在标准的类路径中,或者需要特殊的加载逻辑。例如:在应用程序中动态从网络、数据库或者特定的存储介质中加载类
  2. 增强和字节码操作:使用字节码操作框架(如:ASM、CGLIB等)对类进行动态生成,增强或修改,可能需要在加载之前处理类。例如:为了实现AOP,可以通过自定义类加载器实现类的织入功能
  3. 多版本兼容性:当不同版本的类需要共存于同一应用中时,可能需要通过绕过双亲委派机制来避免类加载冲突。例如:在大规模企业应用中,不同模块可能依赖于不同版本的同一库,这时可以使用定制的类加载器来确保正确的版本被加载

如何打破双亲委派模型?哪些框架或者中间件造成了破坏?

答:

  • 如何打破双亲委派模型?
    • 自定义类加载器:自定义类加载器可以覆盖 loadclass 方法,以实现自己的类加载逻辑。这样可以实现类加载的顺序和范围,绕过双亲委派机制
    • OSGi:OSGi 框架中,每个 Bundle 都有自己的类加载器,并且可以定义自己的类加载策略。这使得不同的 Bundle 可以加载同名但不同版本的类,这显然打破了双亲委派模型
    • SPI(Service Provider Interface)机制:Java 的 SPI 机制有时会绕过双亲委派模型。SPI 是 Java 提供的一种服务发现机制,某些服务加载器会直接查找其自身 ClassPath 下的实现,从而打破双亲委派
  • 哪些框架或者中间件造成了破坏?
    • Tomcat:Tomcat 服务器使用自定义的类加载器(WebappClassLoader),用于隔离不同 Web 应用的类加载。每个 Web 应用都有自己的类加载器,能够加载同名类而互不干扰
    • Spring Boot:Spring Boot 使用了自己的类加载机制,通过实现 LaunchedURLClassLoader 实现自定义的类加载逻辑,特别是为了解决在 fatjar 中加载类的问题
    • OSGi:OSGi 框架本质上就是打破双亲委派模型的一个典型例子。每个 Bundle 都有自己的类加载器,能够加载同名但不同版本的类

什么是类加载器?类加载器都有哪些?

答:

类加载器分类

  • 实现通过类的全限定名获取该类的二进制字节流的代码块叫做 类加载器
  • 主要有一下四种类加载器:
    1. 启动类加载器(bootstrap class loader):用来加载 Java 核心类库,无法被 Java程序直接引用
    2. 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类
    3. 系统类加载器(app class loader):它根据应用的类路径来加载 Java 类。可通过ClassLoader.getSystemClassLoader() 获取它
    4. 自定义类加载器(custom class loader):通过继承 java.lang.classLoader 类的方式实现

如何判断两个类是否相等?

答:

  • 任意一个类都必须由 类加载器 和 这个类本身 共同确立其在虚拟机中的唯一性
  • 两个类只有由同一类加载器加载才有比较意义,否则即使两个类来源于同一个 Class 文件,被同一个 JVM 加载,只要类加载器不同,这两个类就必定不相等

类的实例化顺序?

答:

  1. 父类中的 static 代码块,当前类的 static 代码块
  2. 父类的普通代码块
  3. 父类的构造函数
  4. 当前类普通代码块
  5. 当前类的构造函数

垃圾回收和内存分类策略


Java对象的定位方式?

答:

  • Java 程序通过栈上的 reference 数据(eg:Student stu = new Student() 中的 stu)来操作堆上的具体对象
  • 对象的访问方式由虚拟机实现而定目前主流的访问方式有 使用句柄 和 直接指针 两种:
    1. 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改
    2. 直接指针。reference 中存储的直接就是对象的地址。对象包含到对象类型数据的指针,通过这个指针可以访问对象类型数据。使用直接指针访问方式最大的好处就是访问对象速度快,它节省了一次指针定位的时间开销,虚拟机hotspot主要是使用直接指针来访问对象

如何判断一个对象是否存活?

答:

  • 对堆垃圾回收前的第一步:就是要判断那些对象已经死亡(即不再被任何途径引用的对象)

  • 判断对象是否存活有两种方法:引用计数法 和 可达性分析

    1. 引用计数法

      • 给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的

      • 这种方法很难解决对象之间相互循环引用的问题。比如:obj1 和 obj2 互相引用,这种情况下,引用计数器的值都是1,不会被垃圾回收

    2. 可达性分析

      • 通过 GC Root 对象为起点,从这些节点向下搜索,搜索所走过的路径叫引用链,当一个对象到 GC Root没有任何的引用链相连时,说明这个对象是不可用的

GC是什么?为什么要GC?

答:

  • GC 是垃圾收集的意思(Gabage Collection)
  • 内存处理是编程人员容易出现问题的地方,忘记或者错误的内存回收会导致程序的不稳定甚至崩溃,Java 提供的 GC 功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的

可作为GC Roots的对象有哪些?

答:

GC Roots 是一些对象的引用,它们被认为是垃圾回收的“根节点”。从这些 GC Roots 开始,JVM 会跟踪所有可达的对象,只有那些无法通过 GC Roots 访问到的对象,才会被认为是垃圾,并被回收

  • 在虚拟机栈中引用的对象,例如:各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
  • 在方法区中类静态属性引用的对象,例如:Java类的引用类型静态变量
  • 在方法区中常量引用的对象,例如:字符串常量池(String Table)里的引用
  • 在本地方法栈中 Native 方法引用的对象
  • Java虚拟机内部的引用,例如:基本数据类型对应的Class对象,一些常驻的异常对象(比如:NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器
  • 所有被同步锁(synchronized关键字)持有的对象
  • 反映Java虛拟机内部情况的 JM XBean、JVM TI 中注册的回调、本地代码缓存等

什么情况下类会被卸载?

答:

  • 需要同时满足以下 3 个条件类才可能会被卸载:
    1. 该类所有的实例都已经被回收。
    2. 加载该类的类加载器已经被回收
    3. 该类对应的 java.lang.class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
  • 虚拟机可以对满足上述 3 个条件的类进行回收,但不一定会进行回收

强引用、软引用、弱引用、虚引用是什么,有什么区别?

答:

  • 不同的引用类型,主要体现的是对象不同的可达性(reachable)状态和对垃圾收集的影响

  • 强引用:就是我们最常见的普通对象引用,只要还有强引用指向一个对象,就能表明对象还“活着”,垃圾收集器不会碰这种对象。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null,就是可以被垃圾收集的了,当然具体回收时机还是要看垃圾收集策略

    1
    Object obj = new Object();
  • 软引用:是一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾收集,只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象。JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存

    1
    SoftReference<String> softRef = new SoftReference<String>(str);
  • 弱引用:并不能使对象豁免垃圾收集,仅仅是提供一种访问在弱引用状态下对象的途径。这就可以用来构建一种没有特定约束的关系,比如:维护一种非强制性的映射关系,如果试图获取时对象还在,就使用它,否则重现实例化。在进行垃圾回收时,不管当前内存空间足够与否,都会回收只具有弱引用的对象

    1
    2
    // 弱引用
    WeakReference<String> weakRef = new WeakReference<string>(str);
  • 虚引用:虚引用并不会决定对象的生命周期,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收,也无法通过虚引用来获取对象实例。虚引用主要是为了能在对象被收集器回收时收到一个系统通知,这样可以跟踪对象被垃圾回收的活动

    1
    2
    ReferenceQueue<Object> queue = new ReferenceQueue<>();
    PhantomReference<object> phantom = new PhantomReference<object>(new object(), queue);

注意:

  1. 虚引用创建时必须搭配 ReferenceQueue
  2. 一个对象如果被引用,且最高级别是虚引用,就等于没有被引用,发生 gc 时不管内存够不够都会被回收虚引用看起来和弱引用没啥区别,只是必须搭配 ReferenceQueue。用虚引用的目的一般是跟踪对象被回收的活动

聊聊 Java 中的 GC,分别作用在什么范围?

答:

  • 新生代收集(Minor GC / Young GC)只是新生代的垃圾收集
  • 老年代收集(Major GC / Old GC)只是老年代的垃圾收集
  • 整堆收集(Full GC)收集整个堆的垃圾
  • 混合收集(Mixed GC)收集整个新生代以及部分老年代的垃圾收集(目前只有 G1 GC 会有这种行为)

堆内存的分配策略?

答:

  • 对象优先在 Eden 分配
    • 大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,触发 Minor GC
  • 大对象直接进入老年代
    • 大对象是指:需要连续内存空间的对象,最典型的大对象有长字符串和大数组。可以设置JVM参数-XX:PretenuresizeThreshold,大于此值的对象直接在老年代分配
  • 长期存活的对象进入老年代
    • 通过参数 -XX:MaxTenuringThreshold 可以设置对象进入老年代的年龄阈值。对象在survivor 区每经过一次 Minor GC,年龄就增加1岁,当它的年龄增加到一定程度,就会被晋升到老年代中。
  • 动态对象年龄判定
    • 并非对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代**,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半**,则年龄大于或等于该年龄的对象可以直接进入老年代,无需达到 MaxTenuringThreshold 年龄值
  • 空间分配担保
    • 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 是安全的。如果不成立的话虚拟机会查看HandlePromotionFailure 的值是否允许担保失败。如果允许,那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值为不允许担保失败,那么就要进行一次 Full GC

new 创建的对象一定在 Java 堆吗,局部变量是基本类型创建在哪,如果基本类型是成员变量呢?

答:

  • 多数情况下 new 创建的对象会在 Java堆 中分配,但也有一些例外情况:
    • 逃逸分析 + 栈上分配:如果逃逸分析能够证明某些新建的对象不逃逸,那么 JVM 完全可以将 new 生成的对象分配至栈上,并且在 new 语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间
    • 逃逸分析(Escape Analysis)是Java虚拟机(JVM)中的一种优化技术,主要用于分析对象的作用域(生命周期)以及是否能够逃脱当前方法或线程的控制范围。逃逸分析的目的是通过识别和优化对象的生命周期,从而减少不必要的内存分配、垃圾回收和提升程序性能
  • 局部变量如果是基本类型(如:int、char、boolean 等):它们会存放在栈内存中。这是因为基本类型的值是直接存储在栈上的局部变量表中的,随着方法执行,它们的数据会在方法执行的线程栈上得到管理。
  • 如果基本类型是作为成员变量(即实例变量):成员变量与对象相关联,对象分配在哪里他们就分配在哪里

Java堆内存详细说说,为什么要这么划分,用的什么垃圾回收算法?

答:

  • Java堆内存通常可以被划分为两个区域:
    • 新生代(Young Generation)
      • 新生代是Java堆的一个部分,主要用于存放新创建的对象。新生代通常被划分为 Eden 空间和两个 Survivor 空间
      • 当对象被实例化时,它们会首先被分配到 Eden 空间。经过一段时间后,如果对象仍然存活,它们会被移到 Survivor 空间
      • 在经过一定数量的垃圾回收后,仍然存活的对象会被移到另一个 Survivor 空间或者晋升到老年代
    • 老年代(Old Generation)
      • 老年代主要用于存放存活时间较长的对象。当对象在新生代经历多次垃圾回收后仍然存活,它们会被晋升到老年代
      • 老年代通常包含一些长生命周期的对象,例如全局变量、静态变量等
  • 为什么需要这种划分?
    • 优化内存分配:新建对象较频繁且短命,使用不同的区域进行划分,可以提高内存分配的效率
    • 提高垃圾回收效率:根据对象的生命周期长短,使用不同的回收算法和区域可以最大程度地提高垃圾回收的效率,减少程序的停顿时间和开销
  • 用的什么垃圾回收算法:
    • 新生代:新生代主要存放生命周期较短的对象,采用复制算法(Copying)进行垃圾回收,这种算法能够快速定位和回收大部分的 “瞬时” 对象,减少空间碎片和内存分配时的复杂性
    • 老年代:主要采用 标记-清除/标记-整理 算法进行垃圾回收。这种算法适用于存活周期长的对象,避免由于频繁复制长生命周期对象导致的性能问题

垃圾回收算法有哪些?

答:

  • 垃圾回收算法有四种,分别是:标记清除法、标记整理法、复制算法、分代收集算法
    • 标记清除算法
      • 首先利用可达性去遍历内存,把存活对象进行标记。标记结束后统一将非存活垃圾对象回收掉。这种垃圾回收算法效率较低,并且会产生大量不连续的空间碎片
    • 复制清除算法
      • 半区复制,用于新生代垃圾回收。将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。特点:实现简单,运行高效,但可用内存缩小为了原来的一半,浪费空间
    • 标记整理算法
      • 根据老年代的特点提出的一种标记算法,标记过程仍然与 标记-清除 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存
    • 分类收集算法
      • 根据各个年代的特点采用最适当的收集算法
      • 一般将堆分为新生代和老年代
        • 新生代使用 复制算法
        • 老年代使用 标记清除算法 或者 标记整理算法
      • 在新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,使用复制算法比较合适,只需要付出少量存活对象的复制成本就可以完成收集。老年代对象存活率高,适合使用 标记-清理 或者 标记整理 算法进行垃圾回收

有哪些垃圾回收器?

答:

垃圾回收器分类

  • 实际上,垃圾收集器(GC,Garbage Collector)是和具体 JVM 实现紧密相关的,不同厂商(IBM、
    Oracle),不同版本的 JVM,提供的选择也不同。接下来,我来谈谈最主流的 Oracle JDK
  • 垃圾回收器主要分为以下几种: Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1
    • Serial GC
      • 它是最古老的垃圾收集器,“Serial” 体现在其收集工作是单线程的,并且在进行垃圾收集过程中,会进入臭名昭著的 “Stop-The-World” 状态。当然,其单线程设计也意味着精简的 GC 实现,无需维护复杂的数据结构,初始化也简单,所以一直是 Client 模式下 JVM 的默认选项
    • ParNew GC
      • 很明显是个新生代 GC 实现,它实际是 Serial GC 的多线程版本,最常见的应用场景是配合老年代的 CMS GC 工作
    • CMS GC
      • CMS(Concurrent Mark Sweep)GC,基于标记-清除(Mark-Sweep)算法,设计目标是尽量减少停顿时间,这一点对于 Web 等反应时间敏感的应用非常重要,一直到今天,仍然有很多系统使用 CMS GC
      • 但是,CMS 采用的标记-清除算法,存在着内存碎片化问题,所以难以避免在长时间运行等情况下发生 Full GC,导致恶劣的停顿
      • 另外,既然 强调了并发(Concurrent)CMS 会占用更多 CPU 资源,并和用户线程争抢
    • Parallel GC
      • 在早期 JDK8 等版本中,它是 server 模式 JVM 的默认 GC 选择,也被称作是吞吐量优先的 GC
      • 它的算法和 Serial GC 比较相似,尽管实现要复杂的多,其特点是:新生代和老年代 GC 都是并行进行的,在常见的服务器环境中更加高效
    • G1 GC
      • 这是一种兼顾吞吐量和停顿时间的 GC 实现,是 Oracle JDK9 以后的默认 GC 选项。G1 可以直观的设定停顿时间的目标,相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多
      • G1 GC 仍然存在着年代的概念,但是其内存结构并不是简单的条带式划分,而是类似棋盘的一个个Region。Region 之间是复制算法,但整体上实际可看作是 标记-整理(Mark-Compact)算法,可以有效地避免内存碎片,尤其是当 Java 堆非常大的时候,G1 的优势更加明显

G1的特点和适用场景是什么?

答:

  • G1的特点:
    • 分区和增量式收集:G1 将堆分为多个大小相等的区域(region),可以根据需要动态分配。这种分区使得 G1 在进行垃圾收集时可以更加灵活,避免了传统垃圾回收中大堆的停顿
    • 并行和并发:G1支持多线程并行回收,在进行垃圾收集时可以利用多个CPU核心,显著减少停顿时间。
    • 用户可调的停顿时间目标:用户可以通过参数(如:-XX:MaxGcPausemillis)指定期望的最大停顿时间,G1 会根据这个目标自动调整垃圾收集策略,以达到尽可能低的停顿时间
    • 预测性的收集:G1可以预测和管理不同区域的垃圾回收,以优先回收价值最大的区域。(价值大小:回收所得空间大小与回收所需时间的经验值)
    • 整合年轻代和老年代的回收:G1不仅回收年轻代的对象,也能在收集过程中回收老年代的垃圾,这使得整体内存管理更为高效
  • G1的适用场景:
    1. 大内存应用:G1特别适合内存较大的 java 应用,因为它可以有效管理大堆,从而避免传统GC在大堆时的长时间停顿
    2. 需要低延迟的应用:一些实时应用或对响应时间敏感的应用(如金融交易系统)可以利用G1优化响应时间
    3. 需要控制停顿时间的场景:对垃圾回收停顿时间有严格要求的场合,例如:Web服务器、数据库服务等,G1可以通过调整参数来满足这些需求
    4. 长时间运行的服务:对于需要长期运行的后台服务,G1能够有效管理对象的创建和生命周期减少内存泄漏和碎片化

对象头了解吗?

答:

  • Java 内存中的对象由以下三部分组成:对象头、实例数据 和 对齐填充字节
  • 而对象头由以下三部分组成:mark word、指向类信息的指针 和 数组长度(数组才有)
    • mark word 包含:对象的哈希码、分代年龄(通常与对象的存活时间和垃圾回收策略有关) 和 锁标志位
  • 对象的实例数据就是 Java 对象的属性和值
  • 对齐填充字节:因为JVM要求对象占的内存大小是 8byte 的倍数,因此后面有几个字节用于把对象的大小补齐至 8byte 的倍数
  • 内存对齐的主要作用是:
    • 平台原因:不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常
    • 性能原因:经过内存对齐后,CPU的内存访问速度大大提升

main方法的执行过程?

答:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class A {
public static void main(String[] args) {
B b = new B("Bob");
b.getName();
}
}

class B {
public String name;

public B(String name) {
this.name = name;
}

public String getName() {
return this.name;
}
}
  • 执行 main 方法的过程如下:
    1. 编译 A.java 后得到 A.class 后,执行这个 class 文件,系统会启动一个 JVM 进程,从类路径中找到一个名为 A.class 的二进制文件,将 A 类信息加载到运行时数据区的方法区内,这个过程叫做类的加载。JVM 找到 A 的主程序入口,执行 main 方法
    2. main 方法的第一条语句为 B b = new B("Bob"),就是让 JVM 创建一个 B 对象,但是这个时候方法区中是没有 B 类的信息的,所以 JVM 马上加载 B 类,把 B 类的信息放到方法区中
    3. 加载完 B 类后,JVM 在堆中分配内存给 B 对象,然后调用构造函数初始化 B 对象,这个 B 对象持有指向方法区中的 B 类的类型信息的引用
    4. 执行 b.getName() 时,JVM 根据 b 的引用找到 b 所指向的对象,然后根据此对象持有的引用定位到方法区中 B 类的类型信息的方法表,获得 getName()的字节码地址
    5. 执行 getName() 方法

对象创建的过程是什么?

答:

对象创建的过程

  1. 类加载检查:JVM 遇到一条 new 指令时,先检查能不能在常量池中定位到该类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化。如果没有就要先进行类加载。类已被加载就通过检查
  2. 分配内存:类加载检查通过后 JVM 为新对象分配内存,对象所需的内存大小在类加载完成后就确定了,JVM会在堆中按照 指针碰撞 或 空闲列表 的方式为对象划分出一块空间,选择哪种方式会根据垃圾收集器的算法而定。此外,内存分配还要保证线程安全,JVM 采用 CAS+失败重试 或 TLAB 的方式保证线程安全
    • CAS+失败重试:乐观锁的一种实现,每次占用资源不加锁,而是不断尝试占用
    • TLAB:线程创建时预先在堆中给线程分配一块内存,称为 TLAB,专门用来存放该线程运行过程中创建的对象,而 TLAB 满了时,采用上述CAS在堆的其它内存中分配
  3. 初始化:分配到的内存空间都初始化为零值,通过这个操作保证了对象的字段可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  4. 设置对象头:在对象头中设置这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄、是否启用偏向锁等信息
  5. 执行init方法:初始化对象,即:按照程序员写的构造方法给对象进行初始化

对象的生命周期?

答:

  1. 对象的创建:使用 new 关键字等创建对象实例
  2. 对象的初始化:调用构造方法,对对象的属性进行初始化,设置初始状态
  3. 对象的使用:通过对象引用访问其属性和方法,进行操作和计算
  4. 对象的垃圾回收:当对象不再被引用时,JVM 将其标记为可回收,准备清理内存
  5. 对象的销毁:垃圾回收器释放对象占用的内存,完成对象生命周期的结束

String 保存在哪里呢?

答:

  • 堆内存 和 字符串常量池
    1. 堆内存:堆内存是用于动态分配对象的内存区域,包括通过 new 关键字创建的对象。当使用 new 关键字创建字符串时,例如:string str = new string("Hello");,无论常量池中是否存在 “Hello”,JVM 总是会在堆内存中创建一个新的 String 对象
    2. 字符串常量池(String Pool):当你使用字面量创建字符串时,例如:string str = "Hello";,Java 会首先检查常量池中是否已存在该字符串。如果存在,则直接引用该对象;如果不存在,则在常量池中创建一个新的字符串对象

String s = new String(“abc”) 执行过程中分别对应哪些内存区域?

答:

  • 字符串常量池:首先会判断常量池中是否有 “abc”,如果有则常量池中不创建,如果没有则在池中创建一个
  • 堆内存:因为有 new String,所以无论如何都会在堆内存中创建一个 String 对象
  • 所以这段代码可能创建1个对象,也可能创建2个

JVM 调优 与 故障问题排查


Full GC 的触发条件?

答:

  • 对于 Minor GC,其触发条件比较简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 触发条件相对复杂,有以下情况会发生 Full GC:
    • 调用 System.gc()
      • 只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
    • 老年代空间不足
      • 老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间
    • 空间分配担保失败
      • 使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC
    • JDK 1.7 及以前的永久代空间不足
      • 在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError

线上系统发生了 Full GC 我应该怎么快速解决定位?

答:

  • 可以按照以下步骡快速定位和处理:
    1. 收集垃圾回收日志
      • 启用 GC 日志:确保启用垃圾回收日志,使用参数如(-Xlog:gc* 或 -XX:+PrintGCDetails)
      • 分析日志:查看 Full GC 发生的频率和持续时间,识别异常情况
    2. 使用监控工具
      • 实时监控:使用工具如 VisualVM、JConsole 或 Grafana 来监控内存使用情况和 GC 统计
      • 捕获堆快照:在 Full GC 发生时捕获堆快照,分析对象存活情况
    3. 检查内存使用情况
      • 分析内存分布:确定内存使用趋势,查看堆、栈和方法区的内存分配情况
      • 查找高内存占用对象:识别占用大量内存的对象,尤其是长生命周期的对象
    4. 代码分析
      • 查找潜在内存泄漏:审查代码,排查未被清理的对象引用
      • 优化对象创建和生命周期:减少无用对象的创建,及时释放不再使用的对象
    5. 调整 JVM 参数
      • 调整堆内存配置:根据监控和分析结果,适时增大堆内存、调整新生代与老年代的比例
      • 选择合适的 GC 策略:根据应用特点选择适当的垃圾回收器
    6. 进行压力测试
      • 模拟高负载场景:在测试环境中重现问题,观察内存使用和 GC 行为,确保问题得到有效解
        决

怎么解决频繁full gc?

答:

  1. 增加堆内存大小:通过 -Xmx 增加最大堆内存,减少 Full GC 的发生频率
  2. 优化对象创建:优化代码,避免不必要的对象创建,尤其是在循环中
  3. 使用合适的垃圾回收器:选择合适的 GC 策略:根据应用特性,使用 G1、CMS 或 ZGC 等适合的垃圾回收器。
  4. 对象的生命周期管理:及时清理无用对象:使用弱引用或显式地将不再使用的对象设置为 null,帮助垃圾回收器识别可回收的对象
  5. 监控和分析:使用监控工具,通过工具(如:VisualVM、JConsole)监控内存使用情况,分析Full GC 触发原因
  6. 避免内存泄漏:定期检查内存泄漏,确保没有引用被意外保留,导致内存使用持续增加

JVM 常见参数?

答:

  • 以下是一些常用参数:
    • 堆内存设置:
      • -Xms<size>:设置初始堆内存大小
      • -Xmx<size>:设置最大堆内存大小
    • 非堆内存设置(Java 8 及以前):
      • -XX:Permsize=<size>:设置方法区初始大小(Java 8 及以前)
      • -XX:MaxPermsize=<size>:设置方法区最大大小(Java 8 及以前)
      • -XX:Metaspacesize=<size>:设置元空间初始大小(Java 8 及以后)
      • -XX:MaxMetaspacesize=<size>:设置元空间最大大小
    • 其他内存设置
      • -Xss<size>:设置每个线程的栈大小
    • 垃圾回收
      • -XX:+UseG1GC:使用 G1 垃圾回收器
      • -XX:+UseParallelGC:使用并行垃圾回收器
      • -XX:+UseConcMarksweepGC:使用并发标记扫描垃圾回收器
    • 性能调优
      • -XX:+PrintGCDetails:打印详细的垃圾回收日志
      • -XX:+PrintGCTimeStamps:打印垃圾回收时间戳
      • -Xlog:gc*:从 Java 9 开始,使用统一的日志记录机制

如何排查 OOM 的问题?

答:

  • 排查 OOM 的方法:
    • 增加 JVM 参数 -XX:+HeapDumpOnOutOfMemoryError 和 -XX:HeapDumpPath=/tmp/heapdump.hprof,当 OOM发生时自动 dump 堆内存信息到指定目录
    • jstat 查看监控 JVM 的内存和 GC 情况,评估问题大概出在什么区域
    • 使用 MAT 工具载入 dump 文件,分析大对象的占用情况