JAVA基础
自己总结的重要Java基础知识,需要掌握...
Java基础
1. Java 的基本类型和引用类型有什么区别?
Java 有 8 种基本类型:byte、short、int、long、float、double、boolean、char。它们直接存储值,不是对象。除这 8 种以外的所有类型都是引用类型,包括 String、数组、类、接口。引用类型存储的是对象的地址。核心区别:基本类型赋值是复制值,引用类型赋值是复制地址。每种基本类型都有对应的包装类,Java 5 之后支持自动装箱和拆箱——装箱调 valueOf(),拆箱调 xxxValue(),编译器自动插入。Integer 对 -128~127 做了缓存,比较包装类值必须用 equals() 不能用 ==。
2. 什么是多态?编译时多态和运行时多态的区别?
多态就是同一个接口/父类引用,指向不同的子类实例,执行不同的行为。编译时多态(重载):同一个类里方法名相同、参数列表不同,编译器看参数类型决定调哪个,编译阶段就确定了。运行时多态(重写):子类重写父类/接口的方法,父类引用指向子类对象,运行时根据实际对象类型决定调用哪个子类实现。核心机制是动态绑定。区别:重载发生在同一个类,编译期决定;重写发生在父子类之间,运行期决定。
3. 向上转型和向下转型?
向上转型:子类转父类/接口,自动完成,安全。代价是只能调用父类/接口定义的方法,子类独有方法访问不到。向下转型:父类引用转子类类型,需要手动强转 (Dog) animal。如果不确认实际类型就直接强转,可能导致 ClassCastException。正确做法是先 instanceof 判断再转。Java 16+ 支持 if (animal instanceof Dog dog) 一步完成判断加转型。能用接口方法解决的尽量不向下转。
4. 里氏替换原则是什么?正方形继承矩形为什么违反?
子类对象应该能够替换掉所有父类对象,并且程序行为不变。父类有一条隐性契约:调用方基于父类的行为约定来写代码,子类不能破坏这个约定。正方形继承矩形的反例:矩形的约定是宽和高能独立修改。正方形强制宽高同步,setWidth 会改高度、setHeight 会改宽度。调用方按矩形契约设完宽高再算面积,换成正方形结果就错了——子类没能透明替换父类。根本原因:数学上的"是"不等于代码里的"是"。继承要看行为兼容性,不是概念从属关系。修正方案是让两者实现同一个 Shape 接口,不搞继承。
5. 静态内部类和非静态内部类的区别?
核心区别:非静态内部类隐式持有外部类的引用,静态内部类不持有。非静态内部类必须先有外部类实例才能创建(outer.new Inner()),能访问外部类的所有成员包括私有。不能定义静态成员。静态内部类可以直接创建(new Outer.StaticInner()),只能访问外部类的静态成员。独立于外部实例,不影响 GC。实际开发优先用静态内部类。非静态内部类容易造成内存泄漏(经典场景:Handler 持有 Activity 引用导致 Activity 无法回收)。需要访问外部实例时,用静态内部类 + WeakReference。
6. 自动装箱拆箱
装箱 = 基本类型转包装类,调用 valueOf()。拆箱 = 包装类转基本类型,调用 xxxValue(),编译器自动插入。记住三个坑:
1 . == 比引用不比值,包装类比较用 equals();
2 . null 拆箱抛 NullPointerException;
3 . 循环中频繁装拆箱会产生大量临时对象,性能差,能用基本类型就用基本类型。Integer.valueOf() 对 -128~127 做了缓存,Boolean、Byte、Short、Long、Character 都有缓存,Float 和 Double 没有。
7. 实现深拷贝的三种方法
深拷贝就是对象本身加上它引用的所有嵌套对象全部复制一份,新旧对象完全独立,互不影响。
和浅拷贝的区别是:浅拷贝只复制对象本身,引用类型字段还是指向原对象,改一个会影响另一个。实现深拷贝有三种方式。
1 . 实现 Cloneable 接口,重写 clone 方法。先调 super.clone() 拿到浅拷贝,然后对引用类型字段逐个递归调用 clone。缺点是侵入性强,每个嵌套类都得实现 Cloneable,引用类型多的类代码会很臃肿。
2 . 序列化反序列化。把对象写入字节流,反序列化读回来,把整个对象图展开就是一个全新的副本。缺点有两个:一是所有嵌套类必须实现 Serializable,二是性能差,有 I/O 开销,transient 字段也拷不到。
3. JSON 序列化(最常用)。用 Jackson 或 Gson 把对象转成 JSON 字符串再转回来,实战中最常用,不侵入代码,不需要实现任何接口。缺点是对一些特殊类型如 Date、循环引用需要额外配置。
8. String/StringBuilder/StringBuffer区别
1 . 可变性:String 是不可变的,底层是 final 的 char[](Java 9 后是 byte[]),每次修改都产生新对象。StringBuffer 和 StringBuilder 继承同一个抽象类 AbstractStringBuilder,底层是一个可变的 char[],直接在里面改,不产生新对象。
2. 线程安全:String 因为不可变,天然线程安全。StringBuffer 的方法全加了 synchronized,线程安全但有锁开销。StringBuilder 的方法没加锁,线程不安全,但单线程下性能最高。
3. 性能排序:频繁拼接场景下,StringBuilder > StringBuffer > String。String 每次 + 拼接实际上编译成 new StringBuilder().append().toString(),循环里会创建大量临时对象,GC 压力大。
9. Annotation 接口
Annotation 是 java.lang.annotation 包下的一个接口,所有自定义注解默认都实现了这个接口。
它定义了四个基础方法:annotationType() 获取注解类型、equals 和 hashCode 用于比较、toString 输出标准格式。
运行时获取到的注解对象,本质是一个动态代理实例。JVM 通过 Proxy 生成代理类,InvocationHandler 里维护了一个 Map<String, Object> 存储注解的属性名和值,调用 bean.name() 实际上走代理的 invoke,从 Map 里取值返回。注解的属性值有严格限制,只能是基本类型、String、Class、枚举、注解,以及它们的一维数组,不能用包装类或 Object。
它的配套接口是 AnnotatedElement,定义了 getAnnotation、isAnnotationPresent 等方法。Class、Method、Field 这些反射元素都实现了 AnnotatedElement,所以能在上面读取注解。Spring 就是通过这个机制在 Bean 生命周期里扫描注解来驱动依赖注入和 AOP。
用一句话说:Annotation 是注解的类型基类,AnnotatedElement 是读取注解的入口,运行时注解的本质是 JDK 动态代理。
10. Serializable 接口
Serializable 是一个标记接口,没有方法,作用是告诉 JVM 这个类的对象可以被序列化。
序列化用 ObjectOutputStream.writeObject() 把对象写成字节流,反序列化用 ObjectInputStream.readObject() 从字节流重建对象。反序列化时不走构造方法,通过反射和 Unsafe 直接在堆上分配内存。有三个重点。
第一,serialVersionUID 用于版本校验,序列化和反序列化的类版本号不一致就抛 InvalidClassException,要手动声明避免自动计算的不稳定。
第二,transient 修饰的字段不会被序列化,静态字段也不参与序列化。
第三,可以定义私有方法 writeObject / readObject 来自定义序列化逻辑,比如加密敏感字段。实际应用包括对象持久化、Dubbo RPC 传参、深拷贝、Session 钝化。但现在 Java 原生序列化在很多场景被 JSON 和 Protobuf 取代了,因为跨语言不兼容、性能差、还有反序列化安全漏洞。Externalizable 是 Serializable 的增强版,需要实现 writeExternal 和 readExternal 两个方法,完全手动控制序列化字段,反序列化时走构造方法,性能比 Serializable 更好但没有反射开销。
11. volatile 关键字
volatile 是 Java 中最轻量的同步机制,核心作用有两个:保证可见性和禁止指令重排序。
保证可见性:volatile 修饰的变量在写之后立即刷回主内存,读之前强制从主内存拉取,底层通过 CPU 内存屏障实现。解决了多线程下变量修改线程间不可见的问题。
最经典的例子就是用 volatile boolean flag 作为线程退出的标志位。
禁止指令重排序:编译器和 CPU 为了优化可能打乱指令顺序,volatile 通过内存屏障阻止这种优化。
最经典的场景是 DCL 单例模式——instance = new Singleton() 这行代码的写操作可能被重排,导致多线程拿到半成品对象,volatile 可以禁止这种重排,保证对象初始化完成后再赋值。
volatile 不保证原子性。count++ 这种复合操作即使加了 volatile 也不是线程安全的,因为读-改-写三步可能被多线程穿插。
原子性要用 synchronized、Lock 或 AtomicInteger。和 synchronized 的区别:volatile 是轻量级的,只保证可见性不保证原子性,本质基于 CPU 内存屏障;synchronized 是重量级的,同时保证可见性和原子性,本质基于 JVM 对象头的 monitor 互斥锁。volatile 只能修饰变量,不能修饰方法。
12. 代理模式 vs 适配器模式
代理模式和适配器模式虽然结构上有相似之处(都是包装一个对象),但目的和适用场景完全不同。
代理模式的目的是控制访问。代理和被代理对象实现同一个接口,对外暴露相同的方法,客户端用代理和用真实对象没有区别,代理在中间做权限校验、延迟加载、日志记录等额外工作。典型应用是 Spring AOP——用动态代理在方法执行前后织入切面逻辑,实现事务管理、日志等横切关注点。适配器模式的目的是接口转换。适配器和被适配者方法名不同、接口不同,适配器充当中间翻译,让本来无法合作的类可以一起工作。经典场景是整合第三方库时,包装其不兼容的接口暴露成系统统一的标准接口,或者遗留系统改造时用适配器把老接口转成新接口。简单记忆:代理不改接口,加控制;适配器改接口,做翻译。设计模式分三大类:创建型解决"对象怎么造"(单例、工厂、建造者),结构型解决"对象怎么组合"(代理、适配器、装饰器),行为型解决"对象怎么协作"(观察者、策略、模板方法)。代理和适配器都属于结构型,关注的是对象之间的组合关系。
13. BIO、NIO、AIO 区别
三者的核心区别在于 IO 模型:BIO 是同步阻塞,NIO 是同步非阻塞,AIO 是异步非阻塞。
BIO,就是传统 java.io,一个连接一个线程。读写时线程死等数据,并发一高线程数爆炸,上下文切换开销极大。优点是代码简单直观。 NIO,Java 1.4 引入的 java.nio,核心组件是 Channel、Buffer、Selector。一个 Selector 一个线程可以监听成百上千个 Channel,通过事件通知来驱动读写,不需要为每个连接开线程。模式上仍然是同步的——数据从内核到用户空间要自己调 read 去搬,但通道是非阻塞的,没数据也不会死等。底层走 Linux epoll 机制,是目前 Java 高并发网络编程的基石,Netty、Kafka、Dubbo 底层全是 NIO。
AIO,Java 1.7 引入的 NIO.2,真正的异步非阻塞。应用发起读写后直接返回,操作系统完成后通过回调通知。理论上模型更优,但 Linux 内核的异步 IO 支持不够好,实际性能很多时候不如 NIO 的 epoll,导致 Java AIO 在生产环境中用得很少。
一句话总结:BIO 一个连接一个线程,NIO 一个线程管所有连接,AIO 连线程都省了让操作系统回调。实际开发中 NIO 是主角。
锁机制
1. Java 锁机制全梳理
Java 的锁按不同维度可以分成多套概念。 1 . 乐观锁和悲观锁是两种并发控制策略。悲观锁假设一定会冲突,先加锁再操作,比如 synchronized、ReentrantLock。乐观锁假设不会冲突,先操作、提交时检查,比如 CAS 和数据库版本号。乐观锁适合读多写少,悲观锁适合写多读少。 2 . 公平锁和非公平锁看队列机制。公平锁严格按照 FIFO 排队拿锁,非公平锁允许插队。synchronized 和 ReentrantLock 默认都是非公平锁,为的是性能好、减少上下文切换。 3 . 独占锁和共享锁看持有限制。synchronized 和写锁都是独占锁,同一时刻只有一个线程持有。读锁、Semaphore、CountDownLatch 是共享锁,允许多个线程同时持有。ReentrantReadWriteLock 的规则是写锁可以降级为读锁,读锁不能升级为写锁。 4 . 可重入锁,synchronized 和 ReentrantLock 都支持。同一个线程再次获取同一个锁不会阻塞自己,底层在对象头里维护线程 ID 和计数器,重入一次加一,释放一次减一,归零才真正释放。 5 . 自旋锁,线程不阻塞而是空转等锁。适合锁持有时间极短的场景,省去了上下文切换的开销。JDK 6 后引入了自适应自旋——上次自旋成功了就多自旋一会,经常失败就减少自旋或直接阻塞。 6 . synchronized 的锁升级是 JDK 6 的核心优化。对象从无锁开始,有线程访问升级为偏向锁(mark word 记线程 ID,同一线程再次进入无需 CAS),有竞争升级为轻量级锁(CAS 自旋),竞争激烈升级为重量级锁(内核态阻塞)。只能升级不能降级。这套机制让 synchronized 性能在很多场景不输 ReentrantLock。 7 . AQS 是 JUC 的基石。核心是一个 volatile int state 和一条 CLH 双向队列。获取锁就是 CAS 把 state 从 0 改成 1,失败就加入队列并被 park。ReentrantLock、ReadWriteLock、Semaphore、CountDownLatch 底层全是 AQS,只是对 state 的语义解释不同。
8. 死锁和活锁
"死锁的四个条件":破坏任意一个条件即可避免死锁:
- 互斥:资源只能被一个线程使用
- 持有并等待:线程持有资源,同时等待其他资源
- 不可剥夺:资源只能由持有者主动释放
- 循环等待:A 等 B,B 等 A,形成环
死锁:两个或多个线程互相持有对方需要的资源,谁也不释放,结果谁都动不了。
活锁:线程没有阻塞,但不断重试同一操作总是失败,谁也走不下去。像两个人让路,都往同一个方向让,反复撞上。
JAVA集合
学习摘要:搞清楚 ArrayList 和 HashMap 的底层原理,看线程安全相关的集合类。重点:学完必须懂的地方。
HashMap
- put 过程、扩容机制、为什么初始容量是 2 的幂次方、链表转红黑树的条件,这些几乎每次都会问,而且很容易被追问到细节。
- put过程
- JDK 1.7 数组+链表+头插法
- JDK 1.7 数组+链表/红黑树+尾插法
ConcurrentHashMap
JDK 1.7 的分段锁和 JDK 1.8 的 CAS + synchronized 方案的区别,是并发面试里的高频考点。
| 维度 | JDK 7 | JDK 8 |
|---|---|---|
| 锁结构 | Segment 分段锁(ReentrantLock) | 桶头节点(synchronized) |
| 并发度 | 固定 16(Segment 数量) | 动态,等于桶数量(默认 16,可扩容) |
| 数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 初始化 | Segment 懒加载 | CAS 保证只初始化一次 |
| size() | 两次不加锁 + 失败后加锁统计 | 分段计数 baseCount + CounterCell[] |
| 扩容 | 每个 Segment 独立扩容 | 多线程协助扩容(helpTransfer) |
ConcurrentHashMap 是线程安全的 HashMap,JDK 7 和 8 实现方案完全不同。JDK 7 用分段锁。把整个 Map 分成 16 个 Segment,每个 Segment 是一把 ReentrantLock 加一个小 HashMap。写操作只锁对应的 Segment,最多支持 16 个线程同时写。读操作不加锁,value 是 volatile 保证可见性。JDK 8 改成 CAS + synchronized,锁粒度细化到桶的头节点。
桶为空时用 CAS 直接插入,不加锁;桶不为空时只用 synchronized 锁这一个桶的头节点,并发度理论上等于桶的数量。数据结构也和 HashMap 一样加入了红黑树。读操作完全不加锁,节点的 val 和 next 都是 volatile。扩容时支持多线程协助迁移数据。
为什么 JDK 8 放弃分段锁:Segment 粒度太粗,synchronized 经过锁升级优化后低竞争下性能已经很好,改用桶头节点粒度更细,并发度更高。size() 的实现:类似 LongAdder 的思路,低竞争时 CAS 更新 baseCount,高竞争时分散到 CounterCell 数组分段计数,最终结果是两者之和。因为不加锁所以是近似值,并发场景下够用。
ArrayList 的线程安全问题
为什么不安全、具体会出现哪些问题、有哪些替代方案,这个看起来简单但答起来很容易流于表面。
ArrayList 线程不安全,因为 add 操作的读-写-自增不是原子的,多线程并发会导致数据覆盖、size 计数错误甚至抛 ConcurrentModificationException。
解决方案有三种。
1.Vector 所有方法加 synchronized,性能太差,已经淘汰。
2.Collections.synchronizedList 包装 ArrayList,方法级别加全局锁,遍历时还得手动加锁,性能同样一般。
3.CopyOnWriteArrayList 是推荐方案,写时复制一个新数组在上面改,改完替换引用,读完全不加锁。适合读多写少的场景,缺点是写操作有数组复制开销,读到的是写之前的快照,存在弱一致性。
equals 和 hashCode 的关系
这两个方法为什么要配套重写,放到 HashMap 和 HashSet 里会有什么影响,基础但经常被忽略。== 对于引用类型比的是内存地址
equals 默认也是比地址(Object 的默认实现),但可以被重写来比较内容,比如 String 重写了 equals 来比字符串内容。equals 和 hashCode 有一条核心契约:equals 为 true 的两个对象,hashCode 必须相同。反过来 hashCode 相同,equals 不一定相同,叫哈希碰撞。为什么重写 equals 必须重写 hashCode:HashMap、HashSet 在判断 key 是否相同时,先比 hashCode,hashCode 不同直接认为是两个不同的 key,根本不走 equals。只重写 equals 不重写 hashCode,会导致内容相同的对象在 HashMap 里被当作两个不同 key,逻辑完全乱掉。正确重写 equals 要检查四步:引用相同直接 true;类型不对直接 false;强转;用 Objects.equals 逐字段比较,避免空指针。
hashCode 用 Objects.hash(field1, field2...) 基于和 equals 相同的字段生成。String 的 hashCode 用 31 作为乘数,因为 31 是质数减少碰撞,同时 31 * x 可以被 JIT 优化成 (x << 5) - x 位运算,性能好。注意:如果没重写hashCode的时候,用对象地址算出来的,比如u1:123,u2:456,最后的桶的位置不一样,导致冲突,根本没有进行equals内容比较。而重写hashCode,利用object.hash(内容),确保两个对象只要输入相同输出的hashCode就相同,这样判断两个对象是否一致就没有问题,否则会出现同一个对象不同地key。
JAVA并发
学习摘要:线程与锁(synchronized/ReentrantLock/AQS)
重点:建议先搞清楚 JMM 和 synchronized 的原理,再去看 AQS 和线程池。
1. synchronized和reentrantlock区别
synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁:
- 用法不同:synchronized 是关键字,可直接用来修饰普通方法、静态方法和代码块;ReentrantLock 是 java.util.concurrent.locks 包下的一个类,通过显式调用 lock() 获取锁、unlock() 释放锁来使用,通常配合 try-finally 确保锁一定能释放。
- 获取锁和释放锁方式不同:synchronized 会自动加锁和释放锁,当进入 synchronized 修饰的代码块之后会自动加锁,当离开 synchronized 的代码段之后会自动释放锁。而 ReentrantLock 需要手动加锁和释放锁。
- 锁类型不同:synchronized 属于非公平锁,而 ReentrantLock 既可以是公平锁也可以是非公平锁。
- 响应中断不同:ReentrantLock 可以响应中断,解决死锁的问题,而 synchronized 不能响应中断。
- 底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。
2. synchronized锁升级的过程
JDK 1.6 开始,synchronized 引入了锁升级机制,对象从无锁开始,依次升级为偏向锁、轻量级锁和重量级锁,只能升级不能降级。
- 偏向锁:第一个线程获取锁时,CAS 把自己的线程 ID 写到对象头的 Mark Word 里。以后同一个线程再进入,只要检查线程 ID 匹配就直接通过,没有任何同步开销。适用于一个线程反复获取锁的场景,JVM 默认启用但启动后前 4 秒不生效。
- 升级为轻量级锁:当第二个线程来竞争、且第一个线程还在同步块内时,偏向锁被撤销。JVM 在持有锁线程的栈中分配 Lock Record,Mark Word 改为指向它,竞争线程通过 CAS 自旋尝试获取锁。轻量级锁的价值在于避免内核态切换——用 CPU 空转换取快速响应,适用于锁持有时间极短、竞争不激烈的场景。
- 升级为重量级锁:自旋等待时间过长或竞争线程过多时,轻量级锁膨胀为重量级锁。JVM 创建 monitor 对象,未获取到锁的线程从自旋变为阻塞,进入 monitor 的 EntryList 队列,通过内核态 mutex 实现。线程释放锁后唤醒队列头部线程。核心设计思想:大多数锁在大部分时间是无竞争的,所以 JVM 采用按需升级——先偏向(零开销)、再自旋(CPU 换响应)、再阻塞(适合激烈竞争),而不是一上来就走重量级内核态切换。
3. AQS 是什么、怎么实现的
AQS 是 JUC 包所有同步器的底层框架。核心就两个东西:volatile int state 代表同步状态,CLH 变体双向 FIFO 队列管理等待线程。采用模板方法模式——AQS 封装排队、阻塞、唤醒,子类覆写 tryAcquire/tryRelease,用自己定义的方式操作 state。state 的语义由子类解释:ReentrantLock 是占用计数(0=未锁,N=重入 N 次),Semaphore 是许可证数,CountDownLatch 是倒计数。原始 CLH 是自旋锁,AQS 改成 park 阻塞,减少 CPU 空转。独占锁:acquire 先 CAS 抢 state,抢不到就入队、park。释放时唤醒 head 的下一个节点,被唤醒的线程重新抢锁。共享锁的关键是释放时的链式传播——setHeadAndPropagate 一个唤醒下一个,一路传下去直到队列空。这是 CountDownLatch 一瞬间释放所有等待线程的底层机制。Condition 是 AQS 内部的条件队列。一个 Lock 能创建多个 Condition,每种等待条件独立排队。await 把线程从主队列挪到 Condition 队列,释放锁,park。signal 把线程从 Condition 队列挪回主队列尾部,unpark,重新排队抢锁。好处是精准唤醒,不像 synchronized 的 notifyAll 无差别通知导致无谓竞争。await 必须先释放锁,不然其他线程无法进入改变条件,形成死锁。
volatile 和 CAS
volatile 解决了什么问题、为什么不能保证原子性、CAS 的原理和 ABA 问题,这些问题入门好答,但深挖起来很多人答不全。
- volatile 解决了两个问题。
- 一是可见性。volatile 变量写完之后立即刷回主内存,读之前强制从主内存拉最新值,底层是 CPU 内存屏障。解决了多线程下变量修改了但其他线程看不到的问题,典型场景是 volatile boolean flag 做线程退出标志。
- 二是禁止指令重排序。编译器和 CPU 会打乱指令顺序优化性能,volatile 通过内存屏障阻止这种优化。最经典的场景是 DCL 单例——new Singleton() 会被重排成"先赋引用再初始化对象",另一个线程拿到半成品直接崩,加 volatile 就把顺序固定了。另外 volatile 的 happens-before 规则是:volatile 写之前的所有操作都不会被重排到写之后。
volatile 不保证原子性。 比如 count++ 分三步:读、加一、写。volatile 只保证每一步读和写都从主内存走,但三步之间可以被其他线程插入,还是会出现同时读到同一个旧值,各自加一写回只加了一次的情况。要保证原子性,用 synchronized、Lock 或 AtomicInteger。CAS 是一条 CPU 原子指令,x86 上是 cmpxchg。 三个操作数:内存地址、预期值、新值。如果内存当前值等于预期值,就替换成新值,否则什么也不做。执行时 CPU 锁缓存行,"比较加写入"是一条指令不拆分的。Java 里通过 Unsafe 暴露,AtomicInteger 就是不停地读当前值、算新值、CAS 尝试,失败就自旋重试,直到成功。CAS 有个经典问题叫 ABA。 线程 A 读到值是 A,线程 B 改成 B 又改回 A,线程 A CAS 时发现还是 A,以为没被动过,但实际上已经被改过一轮了。CAS 只比值,不关心值的历史。如果业务逻辑依赖"值没变就没被改过",ABA 就有问题。解法是 AtomicStampedReference 加版本号,CAS 时同时比较值和版本号,值可以回到 A,但版本号已经变了,一定能发现。
2. 普通方法和静态方法的区别?synchronized作用于它们有什么不同?
先说静态方法和普通方法的区别:普通方法属于对象实例,通过 对象.方法() 调用,能访问实例变量(有 this);静态方法属于类本身,通过 类名.方法() 调用,没有 this,不能访问实例变量,只能访问静态变量。synchronized 作用于两者的核心区别是锁的对象不同。普通方法锁的是当前对象实例(this),每个实例一把锁,不同实例之间互不影响,可以同时执行各自的同步普通方法。静态方法锁的是类的 Class 对象(XxxClass.class),Class 对象全局唯一,所以不管多少个实例,同一时刻只能有一个线程执行该静态同步方法。 另外注意,普通方法锁(this)和静态方法锁(Class 对象)是两把不同的锁,它们之间不互斥——一个线程在执行同步普通方法的同时,另一个线程可以执行同步静态方法。
3. ThreadLocal
ThreadLocal:用途、底层 ThreadLocalMap 的实现、为什么会内存泄漏、怎么避免,这个在面试里出现频率越来越高。ThreadLocal 是线程私有变量,每个线程往里存的数据只有自己能读到,线程之间互相隔离。典型用途是用户上下文传递、数据库连接管理、Spring 事务管理、链路追踪 TraceID、SimpleDateFormat 线程安全化。底层实现:ThreadLocal 本身不存数据,数据存在 Thread 对象的 threadLocals 字段里,类型是 ThreadLocalMap。每个线程有自己的 ThreadLocalMap,key 是 ThreadLocal 对象的弱引用,value 是存的值。所以 set 和 get 时都是从当前线程取自己的 Map 操作,天然线程隔离。ThreadLocalMap 不是 HashMap,是自定义的开放定址法数组,冲突用线性探测解决。key 是弱引用,value 是强引用,这是内存泄漏的根源。内存泄漏的原因:当 ThreadLocal 对象的外部强引用被置为 null 后,GC 会回收 ThreadLocal 对象(因为 key 是弱引用),导致 Entry 的 key 变成 null。但 value 是强引用不会被 GC,这个 null key 的 Entry 永远留在 ThreadLocalMap 里,无法被访问也无法被回收,造成泄漏。线程池场景更严重——线程被复用不会销毁,泄漏的 Entry 会持续积累。避免方式:一是用完必须在 finally 里调 remove(),彻底删除 Entry;二是 ThreadLocal 尽量用 static 修饰,保证对象本身不会被 GC,key 不会变 null;三是线程池场景特别注意,每个任务结束后必须 remove()。ThreadLocalMap 有被动的自我清理机制,在 get/set/remove 时会顺带清理 key 为 null 的 Entry,但不能依赖,因为不操作就不清理,且只能清理沿途碰到的不是全量。remove() 才是最可靠的。父子线程传递用 InheritableThreadLocal,创建子线程时会复制父线程的值,但线程池场景不可靠,因为线程复用时还是旧值。阿里开源的 TransmittableThreadLocal(TTL)专门解决这个问题。
4. 线程池
线程池:核心参数的含义、任务提交后的执行流程、几种拒绝策略的区别、以及常见的线程池类型,这块是工程实践和面试的双重重点。线程池有 7 个核心参数:corePoolSize 核心线程数,即使空闲也不回收;maximumPoolSize 最大线程数;keepAliveTime 非核心线程的空闲存活时间;unit 时间单位;workQueue 任务等待队列;threadFactory 线程工厂;handler 拒绝策略。任务提交后的执行流程:先判断当前线程数是否小于核心线程数,是就创建核心线程直接执行;核心线程满了就把任务放入队列;队列也满了就判断线程数是否小于最大线程数,是就创建非核心线程执行;线程数也达到最大就执行拒绝策略。顺序是:扩核心线程 → 放队列 → 扩非核心线程 → 拒绝。四种拒绝策略:AbortPolicy 默认直接抛异常;CallerRunsPolicy 让提交任务的线程自己执行,能自动降速;DiscardPolicy 静默丢弃;DiscardOldestPolicy 丢弃队列最老的任务再提交当前任务。常见线程池类型:FixedThreadPool 固定线程数但用无界队列可能 OOM;CachedThreadPool 来任务就创建线程、最大线程数无上限也可能 OOM;ScheduledThreadPool 支持定时任务。阿里规范禁止用 Executors 创建线程池,必须手动用 ThreadPoolExecutor 指定有界队列和合理的最大线程数。核心线程数设置:CPU 密集型任务设为 CPU 核数加一,IO 密集型设为 CPU 核数两倍或更多,因为 IO 等待时线程不占 CPU 可以多开,实际需要压测调整。
5. 死锁
死锁:死锁的四个必要条件、怎么排查、怎么避免,这是个老题,但每次还是会考。死锁是两个或多个线程互相持有对方需要的资源、又都不释放,导致所有线程永远阻塞。四个必要条件:互斥(资源只能被一个线程独占)、持有并等待(持有资源的同时又在等别的资源)、不可剥夺(资源不能被强行抢走)、循环等待(A 等 B,B 等 A 形成环)。缺少任何一个条件死锁都不会发生,其中互斥无法破坏因为锁的本质就是互斥,实际能破坏的是后三个。排查方式:最直接的是 用jps查java <pid> 用jstack <pid>命令,会直接打印出哪些线程互相等待形成了死锁。也可以用 VisualVM 图形化工具或代码里用 ThreadMXBean.findDeadlockedThreads() 做运行时检测。避免方式有四种:一是统一锁的获取顺序,所有线程按同一个顺序拿锁,破坏循环等待,这是最有效的;二是用 tryLock(timeout) 设超时,拿不到就主动释放已持有的锁,破坏不可剥夺;三是一次性申请所有资源,不持有部分资源等别的,破坏持有并等待;四是减少锁嵌套层级,能不嵌套就不嵌套,降低死锁风险。活锁的概念——线程没有阻塞但不断重试总是失败,谁都走不下去。像两个人在走廊面对面互相让路,每次都往同一个方向让。解法是加入随机退避时间。
JVM
内存结构
堆、栈、方法区、程序计数器各自存什么、有什么区别,堆里的新生代老年代怎么划分,这些是后面所有问题的基础。
-
堆、栈、方法区、程序计数器各自存什么、有什么区别? JVM 运行时数据区分线程私有和线程共享两大类。线程私有的是程序计数器、虚拟机栈和本地方法栈,线程共享的是堆和方法区。程序计数器存当前线程执行的字节码指令行号。CPU 多线程切换时靠它恢复执行位置。它是唯一不会 OOM 的区域,执行 native 方法时为空。虚拟机栈存栈帧,每个方法调用压入一个栈帧,方法返回弹出。栈帧里包含局部变量表(存方法参数和局部变量,基本类型直接存值,引用类型存堆地址)、操作数栈、动态链接和方法返回地址。栈深度超限抛 StackOverflowError,无法扩展抛 OutOfMemoryError。堆存几乎所有对象实例和数组,是 GC 的主要区域。分年轻代和老年代,年轻代又分 Eden 和两个 Survivor。所有线程共享,-Xms 和 -Xmx 控制大小,不够就抛 OutOfMemoryError: Java heap space。方法区存类的结构信息——类信息、字段信息、方法字节码、运行时常量池、静态变量。JDK 7 叫永久代在堆中,有固定上限容易 OOM;JDK 8 改成元空间用本地内存,不受堆大小限制。运行时常量池在方法区里,存每个 class 文件的字面量和符号引用。字符串常量池从 JDK 7 起移到了堆中。关系:new 出来的对象在堆里,引用地址在栈的局部变量表里,类的模板信息在方法区里。
-
堆里的新生代老年代怎么划分? 堆默认按 1:2 分成年轻代和老年代。年轻代内部按 8:1 分成 Eden 和两个 Survivor 区(S0、S1)。新对象几乎都在 Eden 区分配。Eden 满了触发 Minor GC,存活对象复制到 Survivor To 区,年龄加一,Eden 和 From 区整块清空,然后 From 和 To 角色互换。每次 Minor GC 都重复这个"复制+清空+互换"的过程。对象年龄达到阈值默认 15 次后晋升到老年代。另外大对象会跳过年轻代直接在老年代分配,Survivor 里同龄对象超过一半也会提前晋升。Eden 占 80% 是因为统计表明 98% 的对象第一次 GC 就死了,Eden 大一点装更多新对象,Survivor 小一点就够用。需要两个 Survivor 是因为复制算法要求:From 里活着的整块复制到 To,From 整块清空,没有碎片。如果只有一个 Survivor 就只能原地整理,会产生碎片。G1 的划分方式不同——它把堆分成大小相等的 Region,每个 Region 动态充当 Eden、Survivor 或 Old,身份可以变,没有固定的物理分代边界,逻辑分代、物理不分。
垃圾回收
可达性分析是怎么工作的、几种 GC 算法的原理和优缺点、Minor GC 和 Full GC 的触发条件,以及 CMS 和 G1 的区别,这条线是 JVM 面试的主线。
- 可达性分析是怎么工作的? 原理:从一组称为 GC Roots(垃圾收集根)的对象出发,向下追溯它们引用的对象,以及这些对象引用的其他对象,以此类推。如果一个对象到 GC Roots 没有任何引用链相连(即从 GC Roots 到这个对象不可达),那么这个对象就被认为是不可达的,可以被回收。GC Roots 对象主要包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象(例如字符串常量池里的引用);
- 本地方法栈中 JNI(Java Native Interface)引用的对象;
- 所有被同步锁(synchronized)持有的对象;
- 反映 JVM 内部情况的 JNIHandles 全局引用(如基本数据类型对应的 Class 对象、常驻异常对象、系统类加载器等)。
- 几种GC算法的原理和优缺点?
- 标记-清除算法:标记-清除算法分为“标记”和“清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象,然后统一回收所有被标记的对象。标记-清除算法有两个缺陷,一个是效率问题,标记和清除的过程效率都不高,另外一个就是,清除结束后会造成大量的碎片空间。有可能会造成在申请大块内存的时候因为没有足够的连续空间导致再次 GC。
- 复制算法:为了解决碎片空间的问题,出现了“复制算法”。复制算法的原理是,将内存分成两块,每次申请内存时都使用其中的一块,当内存不够时,将这一块内存中所有存活的对象复制到另一块上,然后再把已使用的内存整个清理掉。复制算法解决了空间碎片的问题。但是也带来了新的问题。因为每次在申请内存时,都只能使用其中一半的内存空间。内存利用率严重不足。
- 标记-整理算法:复制算法在 GC 之后存活对象较少的情况下效率比较高,但如果存活对象比较多时,会执行较多的复制操作,效率就会下降。而老年代的对象在 GC 之后的存活率就比较高,所以就有人提出了“标记-整理算法”。标记-整理算法的“标记”过程与“标记-清除算法”的标记过程一致,但标记之后不会直接清理。而是将所有存活对象都移动到内存的一端。移动结束后直接清理掉剩余部分。
- 分代回收算法:分代收集是将内存划分成了新生代和老年代。分配的依据是对象的生存周期,或者说经历过的 GC 次数。对象创建时,一般在新生代申请内存,当经历一次 GC 之后如果对还存活,那么对象的年龄 +1。当年龄超过一定值(默认是 15,可以通过参数 -XX:MaxTenuringThreshold 来设定)后,如果对象还存活,那么该对象会进入老年代。
-
常见的垃圾回收器及其区别?
-
Minor GC 、major GC和 Full GC 的触发条件?
-
Minor GC (Young GC)
- 作用范围:只针对年轻代进行回收,包括Eden区和两个Survivor区(S0和S1)。
- 触发条件:当Eden区空间不足时,JVM会触发一次Minor GC,将Eden区和一个Survivor区中的存活对象移动到另一个Survivor区或老年代(Old Generation)。
- 特点:通常发生得非常频繁,因为年轻代中对象的生命周期较短,回收效率高,暂停时间相对较短。
-
Major GC
- 作用范围:通常指仅回收老年代的 GC(例如 CMS 的老年代 GC、G1 的 Mixed GC 在部分文献里也归为此类);要同时回收新生代 + 老年代 + 方法区,那是 Full GC 的范畴。
- 触发条件:当老年代空间不足时,或者系统检测到年轻代对象晋升到老年代的速度过快,可能会触发Major GC。
- 特点:相比Minor GC,Major GC发生的频率较低,但每次回收可能需要更长的时间,因为老年代中的对象存活率较高。
-
Full GC
- 作用范围:对整个 Java 堆(年轻代 + 老年代)进行回收,并且通常会伴随方法区/元空间的类卸载(是否真正回收元空间取决于 GC 器的实现与触发条件)。需要注意:元空间使用本地内存(Native Memory),并不在堆内,不能简单说"Full GC 会回收元空间",更准确的说法是 Full GC 期间 JVM 可能对元空间中无用的类元数据进行卸载。
- 触发条件:
- 直接调用System.gc()或Runtime.getRuntime().gc()方法:虽然不能保证立即执行,但JVM会尝试执行Full GC。
- 空间分配担保失败:Minor GC 前 JVM 会先检查老年代连续空间是否大于"新生代所有对象之和"或"历次晋升对象的平均大小",如果都不满足则触发 Full GC;Minor GC 后存活对象无法全部放入老年代时也会触发 Full GC,对整个堆内存进行回收。
- 当永久代或元空间空间不足时:JVM 会在抛 OOM 前先尝试 Full GC,借机卸载无用类、回收元空间。
- CMS 在并发收集过程:出现 Concurrent Mode Failure(老年代被并发标记/清理速度跟不上分配速度时填满)或 Promotion Failed(晋升时老年代连续空间不足),都会回退为 Full GC(Serial Old)。
- 特点:Full GC是最昂贵的操作,因为它需要停止所有的工作线程(Stop The World),遍历整个堆内存来查找和回收不再使用的对象,因此应尽量减少Full GC的触发。
- CMS和G1的区别? CMS 和 G1 有五个核心区别。
- 第一,内存布局不同。 CMS 是传统物理分代,年轻代和老年代固定边界、连续内存。G1 把堆分成大小相等的 Region,每个 Region 可以动态充当 Eden、Survivor 或 Old,身份随时可变,弱化了物理分代。
- 第二,回收算法不同。 CMS 老年代用标记-清除,不移动对象,所以有内存碎片,并发清除期间还会产生浮动垃圾。G1 在 Region 级别用标记-复制,把存活对象复制到新 Region,旧 Region 整块清空,没有碎片,浮动垃圾也基本没有。
- 第三,停顿时间控制不同。 CMS 追求低停顿但无法精确控制——全堆扫描回收时间不可预测。G1 的核心优势是可预测停顿——用户设定 -XX:MaxGCPauseMillis 目标,G1 根据停顿目标挑选回收价值最高的一批 Region,只回收这部分,回收量可控所以停顿时间可控。
- 第四,回收范围不同。 CMS 只回收老年代。G1 可以同时回收整个年轻代和部分老年代Region,不一定要等老年代满才触发。
- 第五,适用场景和现状不同。 CMS 适合 8GB 以下的堆,JDK 14 已移除。G1 适合 8GB 以上的堆,JDK 9 起成为默认 GC。 一句话:CMS 是标记-清除、低停顿但有碎片不可控,G1 是 Region 级标记-复制、无碎片且停顿可预测。
- 具体讲解G1回收器? 传统收集器(CMS、Parallel GC)的年轻代和老年代是物理隔离的——固定大小、固定位置、连续内存。G1 把堆分成大小相等的 Region,每个 Region 可以动态充当 Eden、Survivor 或 Old,回收完清空后身份可以变——原来是 Eden 的 Region,下次可以是 Old。所以 G1 只是逻辑上保留了分代概念,物理上已经没有固定边界了,这就是"弱化分代"。 "整体看标记-整理":G1 回收时把存活对象从一个 Region 复制到另一个 Region,原来的 Region 整块清空。从整个堆的视角看,存活对象被集中到一部分 Region,空 Region 连成一片,效果和标记-整理一样——没有碎片。"局部看标记-复制":具体到每个 Region 的回收,就是把存活对象复制到新的空 Region,源 Region 整块清空,这和传统的标记-复制算法一样。G1 把复制算法的粒度从"整个年轻代"缩小到了"单个 Region",避免了全堆复制的高开销。CMS 用标记-清除导致碎片,传统标记-整理要移动全堆对象停顿长。G1 用 Region 级别的复制,既消除了碎片,又通过只回收部分 Region 控制了停顿时间。
- Java基础
- 1. Java 的基本类型和引用类型有什么区别?
- 2. 什么是多态?编译时多态和运行时多态的区别?
- 3. 向上转型和向下转型?
- 4. 里氏替换原则是什么?正方形继承矩形为什么违反?
- 5. 静态内部类和非静态内部类的区别?
- 6. 自动装箱拆箱
- 7. 实现深拷贝的三种方法
- 8. String/StringBuilder/StringBuffer区别
- 9. Annotation 接口
- 10. Serializable 接口
- 11. volatile 关键字
- 12. 代理模式 vs 适配器模式
- 13. BIO、NIO、AIO 区别
- 锁机制
- 1. Java 锁机制全梳理
- 8. 死锁和活锁
- JAVA集合
- HashMap
- ConcurrentHashMap
- ArrayList 的线程安全问题
- equals 和 hashCode 的关系
- JAVA并发
- 学习摘要:线程与锁(synchronized/ReentrantLock/AQS)
- 1. synchronized和reentrantlock区别
- 2. synchronized锁升级的过程
- 3. AQS 是什么、怎么实现的
- volatile 和 CAS
- 2. 普通方法和静态方法的区别?synchronized作用于它们有什么不同?
- 3. ThreadLocal
- 4. 线程池
- 5. 死锁
- JVM
- 内存结构
- 垃圾回收

评论
评论区加载中,请稍候…