JVM底层原理
Java 对象的创建过程?
- 类加载检查:
- 当虚拟机遇到 new 指令时,首先检查这个指令的参数,也就是要创建的对象的类是否已经加载过
- 如果没有加载过,虚拟机会执行类加载过程
- 分配内存:
- 类加载检查通过后,虚拟机会为新对象分配内存空间
- 对象内存初始化:
- 分配内存完成后,虚拟机会将分配到的内存空间初始化为零值
- 设置对象头:
- 虚拟机会在对象的内存空间中设置对象头,用于存储关于对象的元数据信息
- 执行初始化方法(构造函数):
- 经过上述步骤,一个新的对象已经产生,但是从 Java 程序的角度来看,对象的创建还没有完成
- 然后,会根据程序员定义的构造函数进行初始化
什么是指针碰撞?什么是空闲列表?/ 内存分配的两种方式?
指针碰撞:
- 指针碰撞假定 Java 堆中的内存是绝对规整的
- 内存的分界点由一个指针作为指示器来标示,指向已分配内存的末尾
- 在分配对象内存时,只需要将指针向空闲空间方向移动对象内存大小的位置
- 适用于基于压缩策略的收集器,例如 Serial 和 ParNew 收集器
空闲列表:
- 空闲列表假设 Java 堆内存并不规整,已分配内存和空闲内存交错分布
- 虚拟机维护一个空闲列表,记录哪些内存块是可用的,即未被分配的
- 在分配对象内存时,虚拟机会在空闲列表中找到一块足够大的空间来分配给对象。分配后,虚拟机需要更新空闲列表上的记录,标记分配的区域为已用
- 适用于基于清除算法的收集器,例如 CMS 收集器
JVM 里 new 对象时,堆会发生抢占吗?JVM 是怎么设计来保证线程安全的?/ 内存分配并发问题?
- CAS:使用 CAS 操作来保证更新操作的原子性
- 本地线程分配缓冲 (TLAB):
每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲
要分配内存的线程,先在本地缓冲区中分配,只有本地缓冲区用完,分配新的缓冲区才需要同步锁定
对象的内存布局?
对象在堆内存中的布局可以划分为三个部分:对象头、实例数据和对齐填充
- 对象头:包括两部分信息:存储对象自身的运行时数据;类型指针
- 实例数据:用来存储对象真正的有效信息
- 对齐填充:起占位符的作用
对象怎么访问定位?
使用句柄:
- Java 堆中划分内存来作为句柄池
- 句柄中包含对象实例数据的指针和对象类型数据的指针
直接指针:
- 对象的实例数据直接存放在堆内存中
对比:
- 使用句柄:在对象被移动的时候,不需要更新引用地址
- 直接指针:效率高,节省了一次指针定位的时间开销(HotSpot 使用)
内存溢出和内存泄漏?
- 内存溢出:申请的内存超过可用内存,内存不足
- 内存泄漏:申请的内存空间没有被正确释放,导致内存空间被浪费
能手写内存溢出的例子吗?
- Java 堆溢出:Java 堆用于存储对象实例,只要不断创建不可回收的对象,比如静态变量,随着对象数量的增加,总容量超过最大堆的限制就会产生 OOM
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}
- 虚拟机栈溢出:不停创建线程,也会出现 OOM 异常
public static void recursiveMethod() {
recursiveMethod();
}
public static void main(String[] args) {
try {
recursiveMethod();
} catch (Throwable e) {
System.out.println("Stack depth: " + e.getStackTrace().length);
e.printStackTrace();
}
}
内存泄漏可能由哪些原因导致呢?
- 静态集合: 静态集合生命周期与 JVM 相同,如果将对象添加到静态集合中并且忘记删除,这些对象将一直存在于内存中,无法被垃圾回收
- 单例模式: 单例模式中的实例会被以静态变量的方式存储在内存中,一旦创建,会在整个 JVM 生命周期中存在。如果单例对象占用内存过多或者被误用,就可能导致内存泄漏
- 连接未释放: 如果在使用完数据库连接、网络连接等资源后没有正确关闭,这些资源可能不会被释放,导致内存泄漏
- 变量作用域过大: 如果变量的作用域超出了实际需要的范围,导致对象不能被及时释放,就会引起内存泄漏
- hash 值发生改变: 如果对象的 hashCode 值在存入哈希容器后被修改,那么在尝试从哈希容器中获取该对象时,哈希容器会根据 hashCode 去查找,但实际上已经找不到这个对象了
- ThreadLocal 使用不当: ThreadLocal 中的 key 是弱引用,但 value 是强引用。如果 ThreadLocal 的使用不当,导致 key 无法被垃圾回收,而 value 却一直存在,就会造成内存泄漏
如何判断对象仍然存活?/ 如何判断对象是否死亡?
引用计数算法:
- 在对象中添加一个引用计数器
- 每当有一个地方引用它时,计数器值就 +1
- 引用失效时,计数器值 -1
- 任何时刻计数器为零的对象就是不可能再被使用的
- 不使用该方法,因为很难解决对象之间相互循环引用的问题
可达性分析算法:
- 通过一系列 GC Roots 的对象作为起点
- 从这些节点开始向下搜索,节点走过的路径就是引用链
- 当一个对象到 GC Roots 没有任何引用链相连,证明此对象是不可能再被使用的
Java 中可作为 GC Roots 的对象有哪几种?
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
说一下对象有哪几种引用?/ 强引用、软引用、弱引用、虚引用?
强引用:
- 使用最普遍的引用
- 无论任何情况,只要具有强引用,垃圾收集器就永远不会回收被引用的对象
软引用:
- 用来描述还有用,但非必须的对象
- 只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收
弱引用:
- 用来描述那些非必须的对象,强度比软引用还弱一些
- 被弱引用关联的对象只能生存到下一次垃圾收集发生为止
- 无论当前内容是否足够,都会回收掉只被弱引用关联的对象
虚引用:
- 不会决定对象的生命周期
- 唯一目的是为了能在这个对象被收集器回收时收到一个系统通知
什么是 Stop The World?
- 在垃圾回收的过程中,会涉及到对象的移动。为了保证对象引用更新的正确性,必须暂停所有的用户线程,即 Stop The World
什么是 OopMap?
- 类加载完成后,记录对象偏移量和数据类型的映射表
对象一定分配在堆中吗?有没有了解逃逸分析技术?
- 对象不一定分配在堆中
- 逃逸分析是一种编译器技术,用于确定对象创建后从方法逃逸到哪些位置,并确定将对象存放在堆上还是栈
- 如果对象没有逃逸到方法的外部,可以将其存放在栈上,避免频繁的堆内存分配和垃圾回收,从而提高程序的性能
线上服务 CPU 占用过高怎么排查?
- 首先找出哪个进程占用 CPU 过高
- 然后找到进程中的哪个线程占用 CPU 过高
- 找到线程 ID 后,打印出对应线程的堆栈信息
- 根据线程的堆栈信息定位到具体代码
内存飙高问题怎么排查?
如果内存飙高发生在 Java 进程上,一般是因为创建了大量的对象,垃圾回收的速度跟不上对象创建的速度,或者是内存泄露导致对象无法回收
举例栈溢出的情况?
- 栈溢出就是方法执行时,创建的栈帧超过了栈的深度,出现 StackOverflowError
- 解决方法:使用参数 -Xss 调整 JVM 栈的大小
- 具体例子:
- 局部数组过大
- 递归调用的层次太多。递归函数在运行时会执行压栈操作
- 指针或数组越界。例如字符串拷贝,处理用户输入
调整栈大小,就能保证不出现溢出吗?
- 不能
- 如果程序是死递归的情况,调整栈的大小只是说异常出现的时间会晚一些
分配的栈内存越大越好吗?
- 不是
- 如果程序是死递归的情况,分配大内存的栈只是说异常出现的时间会晚一些
- 会导致可执行的线程数减少,影响其他内存结构
垃圾回收是否会涉及到虚拟机栈
- 不会,因为只有入栈出栈操作,出栈的过程就相当于 GC
方法中定义的局部变量是否线程安全?
- 如果只有一个线程才可以操作此数据,则必然线程安全
- 如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制,会存在线程安全问题
- 如果变量是在方法内部产生,内部消亡的就是安全的,不是内存产生,或者作为返回值返回的(生命周期没有结束)就不是安全的
静态变量和局部变量?
- 参数表分配完毕之后,再根据方法体内的变量顺序和作用域分配
- 类变量表有两次初始化的机会
- 第一次是在准备阶段,执行系统初始化,对类变量设置零值
- 第二次是在初始化阶段,赋予程序员在代码中定义的初始值
- 局部变量不存在系统初始化的过程,即定义了局部变量必须人为的初始化
如何判断一个常量是废弃常量?
- 假设在字符串常量池中存在字符串 "abc"
- 如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 "abc" 就是废弃常量