jvm
什么是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. 问题辨析:
- 垃圾回收是否涉及栈内存?
- 不涉及。虚拟机栈随着方法调用自动创建和销毁,不由 GC 管理。
- 栈内存分配越大越好吗?
- 大栈空间意味着系统能够同时创建的线程数减少,影响并发能力。 比如栈空间1m,物理内存500m,可以跑500个线程,但如果栈空间2m,只能跑250个线程。
- 上下文切换开销大,线程切换需要保存/恢复上下文信息。
- 若程序调用栈不深,反而浪费内存。
- 方法内的局部变量是否是线程安全的?(判断是否是线程安全要看变量是否是共享/私有的)
- 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的
- 如果是局部变量引用了对象,并逃离方法的作用方法,需要考虑线程安全
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(串池)
- StringTable特性
在预编译的时候会创建常量池,将字符串都添加进常量表中,比如s1,s2,s3就会被添加进串池里,s4的时候使用拼接字符串,其实底层调用了StringBuilder的append方法,new出了一个StringBuilder对象,一个在方法区,一个在堆区,他们所指向的对象不同,比较结果自然false;
如果使用equals,只需要比较值,如果使用==,需要比较值和指向的对象
1 | public static void main(String[] args) { |
一句话总结 : == 比较的是“是不是同一个人”,.equals() 比较的是“名字是不是一样”。
- 常量池中的字符串仅是符号,第一次用到时才成为对象
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是StringBuilder(1.8)
- 字符串常量拼接的原理是编译器优化 “a”+”b” 直接优化成 “ab”
- 可以使用intern方法,主动将串池中还没有的字符串放进串池。
- s.intern() 尝试将字符串s的内容放入常量池,如果常量池里有s,那就返回常量池的内容;如果常量池中没有s,就将s添加进入常量池,并返回常量池中的s
- 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 enter
和monitor exit
两个指令- monitor enter(加锁)会使用读屏障,强行从主存重新读取数据,保证数据是最新的
- monitor exit(解锁) 会使用写屏障,强行将缓存中的变量刷新到主存中
锁升级
为什么要做锁升级
- java线程模型是一对一的,每一个线程调用都涉及到操作系统的从用户态到内核态的转换,开销很大。所以在低并发的情况下就不让你阻塞,只要不阻塞,就不用有切换状态。并发量高的时候再去阻塞。如:购物软件凌晨低并发,周末,晚上休息时间高并发。
- 为了降低 低并发情况下获得锁的代价,为了提高低并发时候的性能
那为什么会慢?
- synchronized说到底层还是要调用操作系统的原语mutex,然后又涉及到线程的
阻塞与唤醒
状态演变
偏向锁
最开始是无锁状态,当有第一个线程来访问同步代码块时,JVM将对象头的Mark Word锁标志位设置为偏向锁,然后将线程id记录到markword中。偏向锁考虑只有一个线程抢锁的场景。
轻量级锁
当第二个线程来抢锁就升级为轻量级锁,第二个线程拿不到锁就采用CAS + 自旋不断尝试重复获得锁
为什么要有轻量级锁?
- 考虑的是竞争锁的线程不多,而且线程持有锁的时间也不长。都没有进行上下文切换以及操作系统级的线程阻塞和切换
重量级锁
当第二个线程自旋到一定次数之后还没获得锁,或者有别的线程也来抢锁了,那就升级为重量级锁
。重量级锁加锁就需要调用操作系统的底层原语mutex,所以每次切换线程都需要操作系统从用户态切换成内核态,开销很大,所以称之为重量级锁。把那些没拿到锁的线程全都阻塞,当升级到重量级锁的时候,对象头的Markword指针就会指向锁监视器monitor。
为什么一定要升级为重量级锁?
- 因为自旋只适合于锁的竞争比较小,而且执行时间比较短的程序。要是大量线程都在自旋等待,CPU时间被浪费了。
锁监视器Monitor
锁监视器主要是用来负责记录锁的拥有者,记录锁的重入次数,负责线程的阻塞唤醒,锁监视器就是一个对象
1 | class ObjectMonitior{ |
- 重入次数作用?
- 当一个线程重复去获取这个锁,这就是可重入锁,冲入一次++,释放一次–,减到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的思想
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
- Minor GC:
- 只针对新生代(伊甸区和survivor区)进行回收
- 频率高,速度快,通常是伊甸区满了进行
- 回收后存活的对象进入老年代
- Full GC:
- 回收范围是整个堆内存:包括新生代和老年代;
- 代价大,耗时长,可能会触发STW(Stop the world)暂停所有线程
- 通常是老年代满,方法区满
JVM中的双亲委派机制是什么?
- 双亲委派机制是JVM类加载器中的一种工作模式,它规定:类加载器在加载类的时候,会先把请求委托给父类加载器去尝试加载,只有当父类加载失败时,子类加载器才会自己加载。这样是为了避免类的重复加载。
- 就比如我自己假装定义一个String类,然后类加载器发现系统已经加载了“正版”的String类,就会直接使用,不会自定义加载我的盗版类。
讲一下java类加载的过程
- 加载(Loading)
- 根据类的全限定名,找到对应的
.class
文件并加载进内存,生成一个Class对象
- 连接(Linking)
- 验证(Verify):校验字节码是否合法(防止破坏jvm安全)
- 准备(Prepare):为静态变量分配内存,并赋初值
- 解析(Resolve):把常量池中的
符号引用
解析为直接引用
(内存地址)
- 初始化(Initialization)
- 执行类的静态变量赋值和静态代码块,只有这一步会执行Java代码,是类加载的最后一步
讲一下抽象类和接口的区别
实例化:普通类可以直接实例化对象,而抽象类不能被实例化,只能被继承
继承:一个类可以继承一个普通类,而且可以继承多个接口;而一个类只能继承一个抽象类,但可以同时实现多个接口。
抽象类一般被用作基类,被其他类继承和扩展使用。
抽象类用于描述类的共同特性和行为,可以有成员变量、构造方法、具体方法,适用于有明显继承关系的场景
接口用于定义行为规范,可以多实现,适用于定义类的能力