什么是JVM?

  • 定义:Java Virtual Machine java程序的运行环境(更确切:java二进制字节码的运行环境)
  • 好处:
    • 一次编写,到处运行的基石(跨平台)
    • 自动内存管理,垃圾回收功能(垃圾回收处理堆内存的无用对象)
    • 数组下标越界检查
    • 多态
  • 比较:Jvm Jre Jdk的关系

JVM的内存结构

程序计数器(PC)

1. 定义

  • Program Counter Register 使用寄存器实现

2. 作用:

  • 记住下一条jvm指令的执行地址

3. 特点:

  • 线程私有:每一个线程都是独立的,都有自己的时间片,以及自己的程序计数器
  • 不会存在内存溢出

虚拟机栈

1. 定义(java virtual machine stacks)

  • 每个线程运行时所需要的内存称为虚拟机栈,线程私有
  • 栈由多个栈帧(Frame)组成,每个栈帧对应一次方法调用。
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法(人话:栈顶)

2. 问题辨析:

  1. 垃圾回收是否涉及栈内存?
    • 不涉及。虚拟机栈随着方法调用自动创建和销毁,不由 GC 管理。
  2. 栈内存分配越大越好吗?
    • 大栈空间意味着系统能够同时创建的线程数减少,影响并发能力。 比如栈空间1m,物理内存500m,可以跑500个线程,但如果栈空间2m,只能跑250个线程。
    • 上下文切换开销大,线程切换需要保存/恢复上下文信息。
    • 若程序调用栈不深,反而浪费内存。
  3. 方法内的局部变量是否是线程安全的?(判断是否是线程安全要看变量是否是共享/私有的)
    • 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的
    • 如果是局部变量引用了对象,并逃离方法的作用方法,需要考虑线程安全

3. 栈内存溢出

  • 方法调用层级过深(递归未终止)导致栈帧过多。
  • 单个栈帧过大(如定义超大数组、局部变量过多)导致内存耗尽。

本地方法栈

native method,不是由java代码编写的方法,由c/c++实现。用本地方法接口

1. 定义(Heap)

  • 通过new关键字创建的对象都会使用堆内存
  • JVM中最大的一块内存区域,用于存储所有对象实例和数组

2. 特点

  • 线程共享:多个线程可以访问堆中的同一对象,需考虑线程安全问题。
  • 由垃圾回收器管理:GC 自动清理无引用对象,回收内存空间。

3. 堆内存溢出(OutOfMemoryError: Java heap space)

  • 只要new出来的对象还在被使用,就不会被回收,一直在添加,最后会溢出。

方法区(Method Area)

1. 定义

  • JVM 内存结构的一部分,用于存储类的元数据信息,包括:
    • 类的结构(字段、方法、接口等)
    • 常量池(如字符串字面量、符号引用)
    • 静态变量
    • 类的代码(字节码)、JIT 编译后的代码等

2. 特点

  • 所有线程共享
  • 属于 JVM 规范中规定的逻辑区域;
  • 在 HotSpot 虚拟机中,早期称为 永久代(PermGen),从 JDK 8 起被 元空间(Metaspace) 取代;
  • 元空间使用 本地内存(非堆内存)进行分配;
  • 方法区也会发生内存溢出,例如:动态加载大量类时抛出 OutOfMemoryError: Metaspace

3. 常见用途

  • 类加载(包括反射、代理等)
  • 存放静态成员和常量
  • 热点代码编译缓存(如 JIT 编译结果)

4. 简洁记忆

方法区用于存放类信息、常量、静态变量,是线程共享的逻辑区域,从 JDK 8 起由元空间替代。

5. 运行时常量池

  • 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
  • .class 文件里有一张“常量池表”(静态的);加载这个类时,这张表内容就复制进 JVM 的运行时常量池;JVM 执行方法时,所有的名字、字面量、描述信息都要从这里查;它就像“JVM的大字典”,翻译代码里各种符号,让它知道具体地址和内容。
StringTable(串池)
  1. StringTable特性
    在预编译的时候会创建常量池,将字符串都添加进常量表中,比如s1,s2,s3就会被添加进串池里,s4的时候使用拼接字符串,其实底层调用了StringBuilder的append方法,new出了一个StringBuilder对象,一个在方法区,一个在堆区,他们所指向的对象不同,比较结果自然false;
    如果使用equals,只需要比较值,如果使用==,需要比较值和指向的对象
java
1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1+s2;
String s5 = s1+s2;
String s6 = "ab";
System.out.println(s4==s5);
System.out.println(s3==s6);
}v

一句话总结 : == 比较的是“是不是同一个人”,.equals() 比较的是“名字是不是一样”。

  • 常量池中的字符串仅是符号,第一次用到时才成为对象
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是StringBuilder(1.8)
  • 字符串常量拼接的原理是编译器优化 “a”+”b” 直接优化成 “ab”
  • 可以使用intern方法,主动将串池中还没有的字符串放进串池。
    • s.intern() 尝试将字符串s的内容放入常量池,如果常量池里有s,那就返回常量池的内容;如果常量池中没有s,就将s添加进入常量池,并返回常量池中的s
  1. StringTable位置

垃圾回收 GC(垃圾回收)的目的就是自动释放内存,防止内存泄漏和程序崩溃。

1.如何判断一个对象可以被回收

1. 引用计数法

循环引用会导致内存泄漏

2. 可达性分析算法

1.原理:

夏天吃葡萄,拎起一串葡萄,如果在根上的就不是垃圾,不能被回收;掉落下来的可以回收。

  • Java虚拟机中的垃圾回收器采用可达性分析来探索所有存活对象
  • 扫描堆中的对象,看是否能够沿着GC Root对象为起点的引用链找到该对象,找不到表示可以回收。
  • 哪些对象可以作为GC Root?
2. 四种(+一个终结器)引用
1. 强引用(Strong Reference)
  • 最常见、默认的引用方式,例如:Object obj = new Object();
  • 只要强引用还存在,对象永远不会被 GC 回收
  • 应用场景:日常代码中创建对象时默认的引用类型
2. 软引用(Soft Reference)
  • 使用类:SoftReference<T>
  • 当内存不足时才会被 GC 回收,适合做缓存
  • 示例:图片缓存、内存敏感对象
  • 应用场景:避免 OOM 时自动释放内存
3. 弱引用(Weak Reference)
  • 使用类:WeakReference<T>
  • 一旦 GC 运行,无论内存是否足够,都会被回收
  • 示例:ThreadLocal 中的值、WeakHashMap
  • 应用场景:辅助结构、缓存,但对象生命周期短
4. 虚引用(Phantom Reference)
  • 使用类:PhantomReference<T> + ReferenceQueue<T>
  • 无法通过引用获取对象,仅用于监听对象是否即将被 GC 回收
  • 应用场景:资源清理、DirectMemory 手动释放等高级用途
5. 终结器引用(Finalizer / Cleaner)
  • 一种过时的引用机制,依赖 finalize() 方法
  • 对象在被 GC 回收前,会被加入 Finalizer 队列,并延迟处理
  • 缺点:性能差、不可控、不推荐使用
  • JDK 9+ 推荐使用 java.lang.ref.Cleaner 替代

    记忆口诀:强软弱虚,越来越容易被回收;终结器引用用于回收通知,但现在被 Cleaner 替代。
    GC 就是根据“引用关系”判断对象是否还能活着;没有引用的对象,才是“垃圾”。

2.垃圾回收算法

1.标记清除

  • 基本思想:从 GC Roots 出发,标记所有可达对象;标记完成后,清除未被标记的对象。
  • 优点:速度快
  • 缺点:内存碎片太多,内存空间不连续。

2.标记整理

  • 基本思想:先从 GC Roots 出发标记所有可达对象,然后将存活对象向一端移动,最后清理掉边界以外的无效内存。
  • 优点:碎片化消失
  • 缺点:效率降低,因为涉及地址的移动

3.复制

  • 基本思想:将内存分为两块等大小的区域(from 和 to)。每次 GC 时,从 GC Roots 标记存活对象,然后将其复制到另一块空间(to),保持连续分布,最后清空 from 区并交换角色。
  • 优点:避免碎片化
  • 缺点:双倍空间

3. 分代垃圾回收(Generational Garbage Collection)

Java 堆内存被划分为两大区域:

  • 新生代(Young Generation)
    • 包括三部分:伊甸园 区、Survivor From 区、Survivor To
    • 采用 复制算法 进行垃圾回收
  • 老年代(Old Generation)
    • 存放存活时间长、经过多次 GC 仍未回收的对象
    • 通常采用 标记-整理算法

      类比比喻:新生代 = 日常清理,老年代 = 每年大扫除

新生代回收流程(Minor GC):

  • 所有新对象优先分配在 Eden 区
  • 当 Eden 区满了,就触发一次 Minor GC
  • GC 时,将 Eden 和 From 区存活的对象复制到 To 区,并对象年龄+1
  • From 和 To 区角色互换(类似复制算法);
  • 若对象年龄超过一定阈值(如 15 次),就晋升到老年代

老年代回收流程(Full GC):

  • 如果老年代空间不足,先尝试触发 Minor GC;
  • 如果仍不足,将触发一次 Full GC,同时回收新生代和老年代;
  • Full GC 开销大,伴随 Stop The World(STW),影响性能更显著。

额外说明:

  • Minor GC:发生频率高,时间短,只回收新生代;会触发一次stop the world
  • Full GC:发生频率低,时间长,会暂停所有线程。

💡 新生代回收通常使用“复制算法”,老年代回收通常使用“标记-整理算法”。

类加载(ClassLoader)

JMM(Java Memory Modle)

java内存模型,JMM定义了一套在多线程读写共享数据时(成员变量,数组)时,对数据的可见性、有序性和原子性的规则和保障。

为什么要加锁

  • 可见性问题:CPU的速度高,当CPU修改了缓存中的数据但还没写回主存时,线程B读取了主存中的数据,就发生了数据读写冲突。一个线程修改了共享数据,但是另外一个线程无法立刻获取到最新的共享数据
  • 有序性问题:CPU或编译器对指令进行重排序,导致代码执行顺序和书写顺序不一致。

  • synchronized编译后是monitor entermonitor exit两个指令
    • monitor enter(加锁)会使用读屏障,强行从主存重新读取数据,保证数据是最新的
    • monitor exit(解锁) 会使用写屏障,强行将缓存中的变量刷新到主存中

锁升级

为什么要做锁升级

  • java线程模型是一对一的,每一个线程调用都涉及到操作系统的从用户态到内核态的转换,开销很大。所以在低并发的情况下就不让你阻塞,只要不阻塞,就不用有切换状态。并发量高的时候再去阻塞。如:购物软件凌晨低并发,周末,晚上休息时间高并发。
  • 为了降低 低并发情况下获得锁的代价,为了提高低并发时候的性能

那为什么会慢?

  • synchronized说到底层还是要调用操作系统的原语mutex,然后又涉及到线程的阻塞与唤醒

状态演变

偏向锁

最开始是无锁状态,当有第一个线程来访问同步代码块时,JVM将对象头的Mark Word锁标志位设置为偏向锁,然后将线程id记录到markword中。偏向锁考虑只有一个线程抢锁的场景。

轻量级锁

当第二个线程来抢锁就升级为轻量级锁,第二个线程拿不到锁就采用CAS + 自旋不断尝试重复获得锁
为什么要有轻量级锁?

  • 考虑的是竞争锁的线程不多,而且线程持有锁的时间也不长。都没有进行上下文切换以及操作系统级的线程阻塞和切换

重量级锁

当第二个线程自旋到一定次数之后还没获得锁,或者有别的线程也来抢锁了,那就升级为重量级锁。重量级锁加锁就需要调用操作系统的底层原语mutex,所以每次切换线程都需要操作系统从用户态切换成内核态,开销很大,所以称之为重量级锁。把那些没拿到锁的线程全都阻塞,当升级到重量级锁的时候,对象头的Markword指针就会指向锁监视器monitor。
为什么一定要升级为重量级锁?

  • 因为自旋只适合于锁的竞争比较小,而且执行时间比较短的程序。要是大量线程都在自旋等待,CPU时间被浪费了。

锁监视器Monitor

锁监视器主要是用来负责记录锁的拥有者,记录锁的重入次数,负责线程的阻塞唤醒,锁监视器就是一个对象

java
1
2
3
4
5
6
class ObjectMonitior{
void *_owner; //锁拥有者
WaitSet _WaitSet; //等待池(管理调用wait()方法的线程)
EntryList _EntryList; //锁池(管理因竞争锁失败而阻塞的线程)
int _recursions //记录锁重入次数
}
  • 重入次数作用?
    • 当一个线程重复去获取这个锁,这就是可重入锁,冲入一次++,释放一次–,减到0就是释放锁了。
  • 锁池和等待池是干什么的?
    • 在重量级锁状态下,当有线程拿到锁,此时监视器的owner字段就记录拿到锁的线程,没有拿到锁的就进入blocking状态,然后放到锁池中;当拿到锁的线程调用了wait()方法,那该线程就释放锁,然后进入waiting状态,然后被放到等待池中,然后某个线程调用了notify,唤醒了这个waiting线程,那这个线程就从waiting状态变成blocking状态,再被放入锁池中,重新去抢锁。
  • 锁竞争失败的线程和调用了wait方法的线程有什么本质区别吗?不都是在阻塞等待吗,为什么要放在锁池和等待池?
    • 锁池放的是竞争锁失败的线程,线程状态是blocking,他的目标是尽快去获得锁去执行任务,这是锁的互斥问题;等待池是主动放弃锁的进程,现在还暂时不想要锁,这个线程等待被其他线程唤醒后,他们的状态是waiting,是想等待其他资源到位了,然后再被notify唤醒,然后进入锁池中进行抢锁,这是锁通信问题

CAS (Compare and Swap) 体现乐观锁的思想

悲观锁

  • 定义:认为并发访问肯定会出问题,凡是共享资源都必须加锁,线程没获得锁前会被阻塞
    缺点:
  • 性能差,线程频繁上下文切换;
  • 在大量读操作或低冲突场景下显得臃肿;
  • synchronized、ReentrantLock 属于典型的悲观锁。

    Compare and Swap 对比,交换

乐观锁

  • 假设线程A\B要访问一个资源,资源门口挂的门牌号是0(表空闲),A,B都想去访问,A先看到了门牌号的值是0,A拿到了CPU的时间片,对比了一下门牌号上还是0,等于old value,就将其改为1。对比门牌号上的值是否和自己第一次看到的值相同。这时B来到了门牌号前,发现自己的值是0但门牌号已经是1了,说明已经有线程去访问了资源,这时候有两个选择。1.可以自旋等待(while true),不断用CAS修改,直到线程B耐心耗尽。
  • 概念:乐观锁就是当线程访问共享资源的时候,总是乐观的认为没有线程和它竞争,所以不会加锁,只是比较状态值是否和他的预期值相同,相等说明没有被其他线程修改,然后修改状态值并访问共享资源。
  • 原子性:这是两步操作,如何确保呢? 软件层面上无法实现,必须要硬件。
  • 效率:因为没有使用synchronized,所以不会引发阻塞,效率提升的原因。CAS+volatile实现无锁并发
  • 真的无锁吗?
    • 在 用户代码层面,CAS 不使用 synchronized 或 ReentrantLock 这类显式锁。
    • 它通过 CPU 提供的原子指令(如 x86 架构中的 CMPXCHG 指令)实现对内存的原子读-改-写。
    • 不会让线程进入阻塞状态(不会挂起、不会上下文切换)。
    • 底层会锁总线防止其他CPU核心访问。
    • 在硬件层面,CAS 其实会短暂锁住某些资源,只不过它是极快的、非阻塞的,不像软件锁那样代价高。
  • volatile 是 Java 中的一个关键字,用于修饰变量,主要用于多线程编程中的可见性问题。它的作用可以总结为:
    • 保证内存可见性:当一个线程修改了某个 volatile 变量的值,其他线程能立刻看到这个变化
    • 禁止指令重排序(部分场景):在 volatile 写操作前的代码不会被编译器或 CPU 移动到写操作之后。
      所以CAS保证原子性,volatile保证可见性
      数据库也可以使用CAS的思想
sql
1
update 表名 set 库存 = 新值 where 库存 = 旧值
  • ABA问题
    • 假设变量 x 原本是 A
    • 线程 T1 读取 A,要改成 B
    • 在这之间,T2 把 A 改成了 C 又改回 A
    • T1 执行 CAS:发现 x 还是 A,就以为没变,结果错误更新

      Java 解决办法:加版本号(如 AtomicStampedReference)

面试问题

请你说说:JVM 的内存结构包括哪些区域?每个区域的作用分别是什么?你在项目中遇到过哪些内存相关的问题?

  • JVM包括以下几个区域
    • 程序计数器PC:每个线程独有一个
    • Java虚拟机栈:存储方法时调用的局部变量,方法调用信息,调用完成后就出栈
    • 本地方法栈:为 JVM 执行 native 方法准备的栈空间。
    • 堆:所有对象和数组的存储区,是 GC 的主要管理区域;
    • 方法区:存储类的结构信息、常量池、静态变量等;方法区属于共享区域,不同线程共享。

JVM 中有哪些类型的垃圾回收(GC)?Minor GC 和 Full GC 有什么区别?你有没有在项目中遇到过频繁 GC 或性能问题的情况?是怎么排查和优化的?

JVM中有两种GC:Minor GC和Full GC

  1. Minor GC:
    • 只针对新生代(伊甸区和survivor区)进行回收
    • 频率高,速度快,通常是伊甸区满了进行
    • 回收后存活的对象进入老年代
  2. Full GC:
    • 回收范围是整个堆内存:包括新生代和老年代;
    • 代价大,耗时长,可能会触发STW(Stop the world)暂停所有线程
    • 通常是老年代满,方法区满

JVM中的双亲委派机制是什么?

  • 双亲委派机制是JVM类加载器中的一种工作模式,它规定:类加载器在加载类的时候,会先把请求委托给父类加载器去尝试加载,只有当父类加载失败时,子类加载器才会自己加载。这样是为了避免类的重复加载。
  • 就比如我自己假装定义一个String类,然后类加载器发现系统已经加载了“正版”的String类,就会直接使用,不会自定义加载我的盗版类。

讲一下java类加载的过程

  1. 加载(Loading)
  • 根据类的全限定名,找到对应的.class文件并加载进内存,生成一个Class对象
  1. 连接(Linking)
    1. 验证(Verify):校验字节码是否合法(防止破坏jvm安全)
    2. 准备(Prepare):为静态变量分配内存,并赋初值
    3. 解析(Resolve):把常量池中的符号引用 解析为 直接引用(内存地址)
  2. 初始化(Initialization)
  • 执行类的静态变量赋值和静态代码块,只有这一步会执行Java代码,是类加载的最后一步

讲一下抽象类和接口的区别

  • 实例化:普通类可以直接实例化对象,而抽象类不能被实例化,只能被继承

  • 继承:一个类可以继承一个普通类,而且可以继承多个接口;而一个类只能继承一个抽象类,但可以同时实现多个接口。

  • 抽象类一般被用作基类,被其他类继承和扩展使用。

  • 抽象类用于描述类的共同特性和行为,可以有成员变量、构造方法、具体方法,适用于有明显继承关系的场景

  • 接口用于定义行为规范,可以多实现,适用于定义类的能力

GC调优有用过吗?