1. ArrayList
要求
- 掌握 ArrayList 扩容规则
扩容规则
-
ArrayList() 会使用长度为零的数组
-
ArrayList(int initialCapacity) 会使用指定容量的数组
-
public ArrayList(Collection<? extends E> c) 会使用 c 的大小作为数组容量
-
add(Object o) 首次扩容为 10,再次扩容为上次容量的 1.5 倍
-
addAll(Collection c) 没有元素时,扩容为 Math.max(10, 实际元素个数),有元素时为 Math.max(原容量 1.5 倍, 实际元素个数)
其中第 4 点必须知道,其它几点视个人情况而定
2. Iterator
要求
- 掌握什么是 Fail-Fast、什么是 Fail-Safe
Fail-Fast 与 Fail-Safe
-
ArrayList 是 fail-fast 的典型代表,遍历的同时不能修改,尽快失败
-
CopyOnWriteArrayList 是 fail-safe 的典型代表,遍历的同时可以修改,原理是读写分离
3. LinkedList
要求
- 能够说清楚 LinkedList 对比 ArrayList 的区别,并重视纠正部分错误的认知
LinkedList
- 基于双向链表,无需连续内存
- 随机访问慢(要沿着链表遍历)
- 头尾插入删除性能高
- 占用内存多
ArrayList
- 基于数组,需要连续内存
- 随机访问快(指根据下标访问)
- 尾部插入、删除性能可以,其它部分插入、删除都会移动数据,因此性能会低
- 可以利用 cpu 缓存,局部性原理
4. HashMap
要求
- 掌握 HashMap 的基本数据结构
- 掌握树化
- 理解索引计算方法、二次 hash 的意义、容量对索引计算的影响
- 掌握 put 流程、扩容、扩容因子
- 理解并发使用 HashMap 可能导致的问题
- 理解 key 的设计
1)基本数据结构
- 1.7 数组 + 链表
- 1.8 数组 + (链表 | 红黑树)
2)树化与退化
树化意义
- 红黑树用来避免 DoS 攻击,防止链表超长时性能下降,树化应当是偶然情况,是保底策略
- hash 表的查找,更新的时间复杂度是 O ( 1 ) O(1) O(1),而红黑树的查找,更新的时间复杂度是 O ( l o g 2 n ) O(log_2n ) O(log2n),TreeNode 占用空间也比普通 Node 的大,如非必要,尽量还是使用链表
- hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链表出现概率是 0.00000006,树化阈值选择 8 就是为了让树化几率足够小
树化规则
- 当链表长度超过树化阈值 8 时,先尝试扩容来减少链表长度,如果数组容量已经 >=64,才会进行树化
退化规则
- 情况1:在扩容时如果拆分树时,树元素个数 <= 6 则会退化链表
- 情况2:remove 树节点时,若 root、root.left、root.right、root.left.left 有一个为 null ,也会退化为链表
3)索引计算
索引计算方法
- 首先,计算对象的 hashCode()
- 再进行调用 HashMap 的 hash() 方法进行二次哈希
- 二次 hash() 是为了综合高位数据,让哈希分布更为均匀
- 最后 & (capacity – 1) 得到索引
数组容量为何是 2 的 n 次幂
- 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
- 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
注意
- 二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash
- 容量是 2 的 n 次幂 这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable
4)put 与扩容
put 流程
- HashMap 是懒惰创建数组的,首次使用才创建数组
- 计算索引(桶下标)
- 如果桶下标还没人占用,创建 Node 占位返回
- 如果桶下标已经有人占用
- 已经是 TreeNode 走红黑树的添加或更新逻辑
- 是普通 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
- 返回前检查容量是否超过阈值,一旦超过进行扩容
1.7 与 1.8 的区别
-
链表插入节点时,1.7 是头插法,1.8 是尾插法
-
1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容
-
1.8 在扩容计算 Node 索引时,会优化
扩容(加载)因子为何默认是 0.75f
- 在空间占用与查询时间之间取得较好的权衡
- 大于这个值,空间节省了,但链表就会比较长影响性能
- 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多
5)并发问题
扩容死链(1.7 会存在)
1.7 源码如下:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
- e 和 next 都是局部变量,用来指向当前节点和下一个节点
- 线程1(绿色)的临时变量 e 和 next 刚引用了这俩节点,还未来得及移动节点,发生了线程切换,由线程2(蓝色)完成扩容和迁移
第一次循环
-
循环接着线程切换前运行,注意此时 e 指向的是节点 a,next 指向的是节点 b
-
e 头插 a 节点,注意图中画了两份 a 节点,但事实上只有一个(为了不让箭头特别乱画了两份)
-
当循环结束是 e 会指向 next 也就是 b 节点
第二次循环 -
next 指向了节点 a
-
e 头插节点 b
-
当循环结束时,e 指向 next 也就是节点 a
第三次循环
- next 指向了 null
- e 头插节点 a,a 的 next 指向了 b(之前 a.next 一直是 null),b 的 next 指向 a,死链已成
- 当循环结束时,e 指向 next 也就是 null,因此第四次循环时会正常退出
数据错乱(1.7,1.8 都会存在)
6)key 的设计
key 的设计要求
- HashMap 的 key 可以为 null,但 Map 的其他实现则不然
- 作为 key 的对象,必须实现 hashCode 和 equals,并且 key 的内容不能修改(不可变)
- key 的 hashCode 应该有良好的散列性
如果 key 可变,例如修改了 age 会导致再次查询时查询不到
public class HashMapMutableKey {
public static void main(String[] args) {
HashMap<Student, Object> map = new HashMap<>();
Student stu = new Student("张三", 18);
map.put(stu, new Object());
System.out.println(map.get(stu));
stu.age = 19;
System.out.println(map.get(stu));
}
static class Student {
String name;
int age;
public Student(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Student student = (Student) o;
return age == student.age && Objects.equals(name, student.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
}
String 对象的 hashCode() 设计
- 目标是达到较为均匀的散列效果,每个字符串的 hashCode 足够独特
- 字符串中的每个字符都可以表现为一个数字,称为 S i S_i Si,其中 i 的范围是 0 ~ n - 1
- 散列公式为: S 0 ∗ 3 1 ( n − 1 ) + S 1 ∗ 3 1 ( n − 2 ) + … S i ∗ 3 1 ( n − 1 − i ) + … S ( n − 1 ) ∗ 3 1 0 S_0∗31^{(n-1)}+ S_1∗31^{(n-2)}+ … S_i ∗ 31^{(n-1-i)}+ …S_{(n-1)}∗31^0 S0∗31(n−1)+S1∗31(n−2)+…Si∗31(n−1−i)+…S(n−1)∗310
- 31 代入公式有较好的散列特性,并且 31 * h 可以被优化为
- 即 $32 ∗h -h $
- 即 2 5 ∗ h − h 2^5 ∗h -h 25∗h−h
- 即 h ≪ 5 − h h≪5 -h h≪5−h
5. 单例模式
要求
- 掌握五种单例模式的实现方式
- 理解为何 DCL 实现时要使用 volatile 修饰静态变量
- 了解 jdk 中用到单例的场景
饿汉式
public class Singleton1 implements Serializable {
private Singleton1() {
if (INSTANCE != null) {
throw new RuntimeException("单例对象不能重复创建");
}
System.out.println("private Singleton1()");
}
private static final Singleton1 INSTANCE = new Singleton1();
public static Singleton1 getInstance() {
return INSTANCE;
}
public static void otherMethod() {
System.out.println("otherMethod()");
}
public Object readResolve() {
return INSTANCE;
}
}
- 构造方法抛出异常是防止反射破坏单例
readResolve()
是防止反序列化破坏单例
枚举饿汉式
public enum Singleton2 {
INSTANCE;
private Singleton2() {
System.out.println("private Singleton2()");
}
@Override
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
public static Singleton2 getInstance() {
return INSTANCE;
}
public static void otherMethod() {
System.out.println("otherMethod()");
}
}
- 枚举饿汉式能天然防止反射、反序列化破坏单例
懒汉式
public class Singleton3 implements Serializable {
private Singleton3() {
System.out.println("private Singleton3()");
}
private static Singleton3 INSTANCE = null;
// Singleton3.class
public static synchronized Singleton3 getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton3();
}
return INSTANCE;
}
public static void otherMethod() {
System.out.println("otherMethod()");
}
}
- 其实只有首次创建单例对象时才需要同步,但该代码实际上每次调用都会同步
- 因此有了下面的双检锁改进
双检锁懒汉式
public class Singleton4 implements Serializable {
private Singleton4() {
System.out.println("private Singleton4()");
}
private static volatile Singleton4 INSTANCE = null; // 可见性,有序性
public static Singleton4 getInstance() {
if (INSTANCE == null) {
synchronized (Singleton4.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton4();
}
}
}
return INSTANCE;
}
public static void otherMethod() {
System.out.println("otherMethod()");
}
}
为何必须加 volatile:
INSTANCE = new Singleton4()
不是原子的,分成 3 步:创建对象、调用构造、给静态变量赋值,其中后两步可能被指令重排序优化,变成先赋值、再调用构造- 如果线程1 先执行了赋值,线程2 执行到第一个
INSTANCE == null
时发现 INSTANCE 已经不为 null,此时就会返回一个未完全构造的对象
内部类懒汉式
public class Singleton5 implements Serializable {
private Singleton5() {
System.out.println("private Singleton5()");
}
private static class Holder {
static Singleton5 INSTANCE = new Singleton5();
}
public static Singleton5 getInstance() {
return Holder.INSTANCE;
}
public static void otherMethod() {
System.out.println("otherMethod()");
}
}
- 避免了双检锁的缺点
JDK 中单例的体现
- Runtime 体现了饿汉式单例
- Console 体现了双检锁懒汉式单例
- Collections 中的 EmptyNavigableSet 内部类懒汉式单例
- ReverseComparator.REVERSE_ORDER 内部类懒汉式单例
- Comparators.NaturalOrderComparator.INSTANCE 枚举饿汉式单例
并发篇
1. 线程状态
要求
- 掌握 Java 线程六种状态
- 掌握 Java 线程状态转换
- 能理解五种状态与六种状态两种说法的区别
六种状态及转换
分别是
- 新建
- 当一个线程对象被创建,但还未调用 start 方法时处于新建状态
- 此时未与操作系统底层线程关联
- 可运行
- 调用了 start 方法,就会由新建进入可运行
- 此时与底层线程关联,由操作系统调度执行
- 终结
- 线程内代码已经执行完毕,由可运行进入终结
- 此时会取消与底层线程关联
- 阻塞
- 当获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,此时不占用 cpu 时间
- 当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
- 等待
- 当获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁进入 Monitor 等待集合等待,同样不占用 cpu 时间
- 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的等待线程,恢复为可运行状态
- 有时限等待
- 当获取锁成功后,但由于条件不满足,调用了 wait(long) 方法,此时从可运行状态释放锁进入 Monitor 等待集合进行有时限等待,同样不占用 cpu 时间
- 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的有时限等待线程,恢复为可运行状态,并重新去竞争锁
- 如果等待超时,也会从有时限等待状态恢复为可运行状态,并重新去竞争锁
- 还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行状态
其它情况(只需了解)
- 可以用 interrupt() 方法打断等待、有时限等待的线程,让它们恢复为可运行状态
- park,unpark 等方法也可以让线程等待和唤醒
五种状态
五种状态的说法来自于操作系统层面的划分
- 运行态:分到 cpu 时间,能真正执行线程内代码的
- 就绪态:有资格分到 cpu 时间,但还未轮到它的
- 阻塞态:没资格分到 cpu 时间的
- 涵盖了 java 状态中提到的阻塞、等待、有时限等待
- 多出了阻塞 I/O,指线程在调用阻塞 I/O 时,实际活由 I/O 设备完成,此时线程无事可做,只能干等
- 新建与终结态:与 java 中同名状态类似,不再啰嗦
2. 线程池
要求
- 掌握线程池的 7 大核心参数
七大参数
- corePoolSize 核心线程数目 - 池中会保留的最多线程数
- maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数目
- keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
- unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
- workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
- threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
- handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
- 抛异常 java.util.concurrent.ThreadPoolExecutor.AbortPolicy
- 由调用者执行任务 java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy
- 丢弃任务 java.util.concurrent.ThreadPoolExecutor.DiscardPolicy
- 丢弃最早排队任务 java.util.concurrent.ThreadPoolExecutor.DiscardOldestPolicy
3. wait vs sleep
要求
- 能够说出二者区别
一个共同点,三个不同点
共同点
- wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
不同点
-
方法归属不同
- sleep(long) 是 Thread 的静态方法
- 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
-
醒来时机不同
- 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
- wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
- 它们都可以被打断唤醒
-
锁特性不同(重点)
- wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
- wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
- 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
4. lock vs synchronized
要求
- 掌握 lock 与 synchronized 的区别
- 理解 ReentrantLock 的公平、非公平锁
- 理解 ReentrantLock 中的条件变量
三个层面
不同点
- 语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
- Lock 是接口,源码由 jdk 提供,用 java 语言实现
- 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
- 功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
- Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量
- Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock
- 性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 的实现通常会提供更好的性能
公平锁
- 公平锁的公平体现
- 已经处在阻塞队列中的线程(不考虑超时)始终都是公平的,先进先出
- 公平锁是指未处于阻塞队列中的线程来争抢锁,如果队列不为空,则老实到队尾等待
- 非公平锁是指未处于阻塞队列中的线程来争抢锁,与队列头唤醒的线程去竞争,谁抢到算谁的
- 公平锁会降低吞吐量,一般不用
条件变量
- ReentrantLock 中的条件变量功能类似于普通 synchronized 的 wait,notify,用在当线程获得锁后,发现条件不满足时,临时等待的链表结构
- 与 synchronized 的等待集合不同之处在于,ReentrantLock 中的条件变量可以有多个,可以实现更精细的等待、唤醒控制
5. volatile
要求
- 掌握线程安全要考虑的三个问题
- 掌握 volatile 能解决哪些问题
原子性
- 起因:多线程下,不同线程的指令发生了交错导致的共享变量的读写混乱
- 解决:用悲观锁或乐观锁解决,volatile 并不能解决原子性
可见性
- 起因:由于编译器优化、或缓存优化、或 CPU 指令重排序优化导致的对共享变量所做的修改另外的线程看不到
- 解决:用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见
有序性
- 起因:由于编译器优化、或缓存优化、或 CPU 指令重排序优化导致指令的实际执行顺序与编写顺序不一致
- 解决:用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
- 注意:
- volatile 变量写加的屏障是阻止上方其它写操作越过屏障排到 volatile 变量写之下
- volatile 变量读加的屏障是阻止下方其它读操作越过屏障排到 volatile 变量读之上
- volatile 读写加入的屏障只能防止同一线程内的指令重排
6. 悲观锁 vs 乐观锁
要求
- 掌握悲观锁和乐观锁的区别
对比悲观锁与乐观锁
-
悲观锁的代表是 synchronized 和 Lock 锁
- 其核心思想是【线程只有占有了锁,才能去操作共享变量,每次只有一个线程占锁成功,获取锁失败的线程,都得停下来等待】
- 线程从运行到阻塞、再从阻塞到唤醒,涉及线程上下文切换,如果频繁发生,影响性能
- 实际上,线程在获取 synchronized 和 Lock 锁时,如果锁已被占用,都会做几次重试操作,减少阻塞的机会
-
乐观锁的代表是 AtomicInteger,使用 cas 来保证原子性
- 其核心思想是【无需加锁,每次只有一个线程能成功修改共享变量,其它失败的线程不需要停止,不断重试直至成功】
- 由于线程一直运行,不需要阻塞,因此不涉及线程上下文切换
- 它需要多核 cpu 支持,且线程数不应超过 cpu 核数
7. Hashtable vs ConcurrentHashMap
要求
- 掌握 Hashtable 与 ConcurrentHashMap 的区别
- 掌握 ConcurrentHashMap 在不同版本的实现区别
Hashtable 对比 ConcurrentHashMap**
- Hashtable 与 ConcurrentHashMap 都是线程安全的 Map 集合
- Hashtable 并发度低,整个 Hashtable 对应一把锁,同一时刻,只能有一个线程操作它
- ConcurrentHashMap 并发度高,整个 ConcurrentHashMap 对应多把锁,只要线程访问的是不同锁,那么不会冲突
ConcurrentHashMap 1.7
- 数据结构:
Segment(大数组) + HashEntry(小数组) + 链表
,每个 Segment 对应一把锁,如果多个线程访问不同的 Segment,则不会冲突 - 并发度:Segment 数组大小即并发度,决定了同一时刻最多能有多少个线程并发访问。Segment 数组不能扩容,意味着并发度在 ConcurrentHashMap 创建时就固定了
- 索引计算
- 假设大数组长度是 2 m 2^m 2m,key 在大数组内的索引是 key 的二次 hash 值的高 m 位
- 假设小数组长度是 2 n 2^n 2n,key 在小数组内的索引是 key 的二次 hash 值的低 n 位
- 扩容:每个小数组的扩容相对独立,小数组在超过扩容因子时会触发扩容,每次扩容翻倍
- Segment[0] 原型:首次创建其它小数组时,会以此原型为依据,数组长度,扩容因子都会以原型为准
ConcurrentHashMap 1.8
- 数据结构:
Node 数组 + 链表或红黑树
,数组的每个头节点作为锁,如果多个线程访问的头节点不同,则不会冲突。首次生成头节点时如果发生竞争,利用 cas 而非 syncronized,进一步提升性能 - 并发度:Node 数组有多大,并发度就有多大,与 1.7 不同,Node 数组可以扩容
- 扩容条件:Node 数组满 3/4 时就会扩容
- 扩容单位:以链表为单位从后向前迁移链表,迁移完成的将旧数组头节点替换为 ForwardingNode
- 扩容时并发 get
- 根据是否为 ForwardingNode 来决定是在新数组查找还是在旧数组查找,不会阻塞
- 如果链表长度超过 1,则需要对节点进行复制(创建新节点),怕的是节点迁移后 next 指针改变
- 如果链表最后几个元素扩容后索引不变,则节点无需复制
- 扩容时并发 put
- 如果 put 的线程与扩容线程操作的链表是同一个,put 线程会阻塞
- 如果 put 的线程操作的链表还未迁移完成,即头节点不是 ForwardingNode,则可以并发执行
- 如果 put 的线程操作的链表已经迁移完成,即头结点是 ForwardingNode,则可以协助扩容
- 与 1.7 相比是懒惰初始化
- capacity 代表预估的元素个数,capacity / factory 来计算出初始数组大小,需要贴近 2 n 2^n 2n
- loadFactor 只在计算初始数组大小时被使用,之后扩容固定为 3/4
- 超过树化阈值时的扩容问题,如果容量已经是 64,直接树化,否则在原来容量基础上做 3 轮扩容
8. ThreadLocal
要求
- 掌握 ThreadLocal 的作用与原理
- 掌握 ThreadLocal 的内存释放时机
作用
- ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】,避免争用引发的线程安全问题
- ThreadLocal 同时实现了线程内的资源共享
原理
每个线程内有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
- 调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
- 调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
- 调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
ThreadLocalMap 的一些特点
- key 的 hash 值统一分配
- 初始容量 16,扩容因子 2/3,扩容容量翻倍
- key 索引冲突后用开放寻址法解决冲突
弱引用 key
ThreadLocalMap 中的 key 被设计为弱引用,原因如下
- Thread 可能需要长时间运行(如线程池中的线程),如果 key 不再使用,需要在内存不足(GC)时释放其占用的内存
内存释放时机
- 被动 GC 释放 key
- 仅是让 key 的内存释放,关联 value 的内存并不会释放
- 懒惰被动释放 value
- get key 时,发现是 null key,则释放其 value 内存
- set key 时,会使用启发式扫描,清除临近的 null key 的 value 内存,启发次数与元素个数,是否发现 null key 有关
- 主动 remove 释放 key,value
- 会同时释放 key,value 的内存,也会清除临近的 null key 的 value 内存
- 推荐使用它,因为一般使用 ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收
虚拟机篇
1. JVM 内存结构
要求
- 掌握 JVM 内存结构划分
- 尤其要知道方法区、永久代、元空间的关系
结合一段 java 代码的执行理解内存划分
- 执行 javac 命令编译源代码为字节码
- 执行 java 命令
- 创建 JVM,调用类加载子系统加载 class,将类的信息存入方法区
- 创建 main 线程,使用的内存区域是 JVM 虚拟机栈,开始执行 main 方法代码
- 如果遇到了未见过的类,会继续触发类加载过程,同样会存入方法区
- 需要创建对象,会使用堆内存来存储对象
- 不再使用的对象,会由垃圾回收器在内存不足时回收其内存
- 调用方法时,方法内的局部变量、方法参数所使用的是 JVM 虚拟机栈中的栈帧内存
- 调用方法时,先要到方法区获得到该方法的字节码指令,由解释器将字节码指令解释为机器码执行
- 调用方法时,会将要执行的指令行号读到程序计数器,这样当发生了线程切换,恢复时就可以从中断的位置继续
- 对于非 java 实现的方法调用,使用内存称为本地方法栈(见说明)
- 对于热点方法调用,或者频繁的循环代码,由 JIT 即时编译器将这些代码编译成机器码缓存,提高执行性能
说明
- 加粗字体代表了 JVM 虚拟机组件
- 对于 Oracle 的 Hotspot 虚拟机实现,不区分虚拟机栈和本地方法栈
会发生内存溢出的区域
- 不会出现内存溢出的区域 – 程序计数器
- 出现 OutOfMemoryError 的情况
- 堆内存耗尽 – 对象越来越多,又一直在使用,不能被垃圾回收
- 方法区内存耗尽 – 加载的类越来越多,很多框架都会在运行期间动态产生新的类
- 虚拟机栈累积 – 每个线程最多会占用 1 M 内存,线程个数越来越多,而又长时间运行不销毁时
- 出现 StackOverflowError 的区域
- JVM 虚拟机栈,原因有方法递归调用未正确结束、反序列化 json 时循环引用
方法区、永久代、元空间
- 方法区是 JVM 规范中定义的一块内存区域,用来存储类元数据、方法字节码、即时编译器需要的信息等
- 永久代是 Hotspot 虚拟机对 JVM 规范的实现(1.8 之前)
- 元空间是 Hotspot 虚拟机对 JVM 规范的另一种实现(1.8 以后),使用本地内存作为这些信息的存储空间
从这张图学到三点
- 当第一次用到某个类是,由类加载器将 class 文件的类元信息读入,并存储于元空间
- X,Y 的类元信息是存储于元空间中,无法直接访问
- 可以用 X.class,Y.class 间接访问类元信息,它们俩属于 java 对象,我们的代码中可以使用
从这张图可以学到
- 堆内存中:当一个类加载器对象,这个类加载器对象加载的所有类对象,这些类对象对应的所有实例对象都没人引用时,GC 时就会对它们占用的对内存进行释放
- 元空间中:内存释放以类加载器为单位,当堆中类加载器内存释放时,对应的元空间中的类元信息也会释放
2. JVM 内存参数
要求
- 熟悉常见的 JVM 参数,尤其和大小相关的
堆内存,按大小设置
解释:
- -Xms 最小堆内存(包括新生代和老年代)
- -Xmx 最大对内存(包括新生代和老年代)
- 通常建议将 -Xms 与 -Xmx 设置为大小相等,即不需要保留内存,不需要从小到大增长,这样性能较好
- -XX:NewSize 与 -XX:MaxNewSize 设置新生代的最小与最大值,但一般不建议设置,由 JVM 自己控制
- -Xmn 设置新生代大小,相当于同时设置了 -XX:NewSize 与 -XX:MaxNewSize 并且取值相等
- 保留是指,一开始不会占用那么多内存,随着使用内存越来越多,会逐步使用这部分保留内存。下同
堆内存,按比例设置
解释:
- -XX:NewRatio=2:1 表示老年代占两份,新生代占一份
- -XX:SurvivorRatio=4:1 表示新生代分成六份,伊甸园占四份,from 和 to 各占一份
元空间内存设置
解释:
- class space 存储类的基本信息,最大值受 -XX:CompressedClassSpaceSize 控制
- non-class space 存储除类的基本信息以外的其它信息(如方法字节码、注解等)
- class space 和 non-class space 总大小受 -XX:MaxMetaspaceSize 控制
注意:
- 这里 -XX:CompressedClassSpaceSize 这段空间还与是否开启了指针压缩有关,这里暂不深入展开,可以简单认为指针压缩默认开启
代码缓存内存设置
解释:
- 如果 -XX:ReservedCodeCacheSize < 240m,所有优化机器代码不加区分存在一起
- 否则,分成三个区域(图中笔误 mthod 拼写错误,少一个 e)
- non-nmethods - JVM 自己用的代码
- profiled nmethods - 部分优化的机器码
- non-profiled nmethods - 完全优化的机器码
线程内存设置
3. JVM 垃圾回收
要求
- 掌握垃圾回收算法
- 掌握分代回收思想
- 理解三色标记及漏标处理
- 了解常见垃圾回收器
三种垃圾回收算法
标记清除法
解释:
- 找到 GC Root 对象,即那些一定不会被回收的对象,如正执行方法内局部变量引用的对象、静态变量引用的对象
- 标记阶段:沿着 GC Root 对象的引用链找,直接或间接引用到的对象加上标记
- 清除阶段:释放未加标记的对象占用的内存
要点:
- 标记速度与存活对象线性关系
- 清除速度与内存大小线性关系
- 缺点是会产生内存碎片
标记整理法
解释:
- 前面的标记阶段、清理阶段与标记清除法类似
- 多了一步整理的动作,将存活对象向一端移动,可以避免内存碎片产生
特点:
-
标记速度与存活对象线性关系
-
清除与整理速度与内存大小成线性关系
-
缺点是性能上较慢
标记复制法
解释:
- 将整个内存分成两个大小相等的区域,from 和 to,其中 to 总是处于空闲,from 存储新创建的对象
- 标记阶段与前面的算法类似
- 在找出存活对象后,会将它们从 from 复制到 to 区域,复制的过程中自然完成了碎片整理
- 复制完成后,交换 from 和 to 的位置即可
特点:
- 标记与复制速度与存活对象成线性关系
- 缺点是会占用成倍的空间
GC 与分代回收算法
GC 的目的在于实现无用对象内存自动释放,减少内存碎片、加快分配速度
GC 要点:
- 回收区域是堆内存,不包括虚拟机栈
- 判断无用对象,使用可达性分析算法,三色标记法标记存活对象,回收未标记对象
- GC 具体的实现称为垃圾回收器
- GC 大都采用了分代回收思想
- 理论依据是大部分对象朝生夕灭,用完立刻就可以回收,另有少部分对象会长时间存活,每次很难回收
- 根据这两类对象的特性将回收区域分为新生代和老年代,新生代采用标记复制法、老年代一般采用标记整理法
- 根据 GC 的规模可以分成 Minor GC,Mixed GC,Full GC
分代回收
- 伊甸园 eden,最初对象都分配到这里,与幸存区 survivor(分成 from 和 to)合称新生代,
- 当伊甸园内存不足,标记伊甸园与 from(现阶段没有)的存活对象
- 将存活对象采用复制算法复制到 to 中,复制完毕后,伊甸园和 from 内存都得到释放
- 将 from 和 to 交换位置
- 经过一段时间后伊甸园的内存又出现不足
- 标记伊甸园与 from(现阶段没有)的存活对象
- 将存活对象采用复制算法复制到 to 中
- 复制完毕后,伊甸园和 from 内存都得到释放
- 将 from 和 to 交换位置
- 老年代 old,当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足或大对象会导致提前晋升)
GC 规模
-
Minor GC 发生在新生代的垃圾回收,暂停时间短
-
Mixed GC 新生代 + 老年代部分区域的垃圾回收,G1 收集器特有
-
Full GC 新生代 + 老年代完整垃圾回收,暂停时间长,应尽力避免
三色标记
即用三种颜色记录对象的标记状态
- 黑色 – 已标记
- 灰色 – 标记中
- 白色 – 还未标记
- 起始的三个对象还未处理完成,用灰色表示
- 该对象的引用已经处理完成,用黑色表示,黑色引用的对象变为灰色
- 依次类推
- 沿着引用链都标记了一遍
- 最后为标记的白色对象,即为垃圾
并发漏标问题
比较先进的垃圾回收器都支持并发标记,即在标记过程中,用户线程仍然能工作。但这样带来一个新的问题,如果用户线程修改了对象引用,那么就存在漏标问题。例如:
- 如图所示标记工作尚未完成
- 用户线程同时在工作,断开了第一层 3、4 两个对象之间的引用,这时对于正在处理 3 号对象的垃圾回收线程来讲,它会将 4 号对象当做是白色垃圾
- 但如果其他用户线程又建立了 2、4 两个对象的引用,这时因为 2 号对象是黑色已处理对象了,因此垃圾回收线程不会察觉到这个引用关系的变化,从而产生了漏标
- 如果用户线程让黑色对象引用了一个新增对象,一样会存在漏标问题
因此对于并发标记而言,必须解决漏标问题,也就是要记录标记过程中的变化。有两种解决方法:
- Incremental Update 增量更新法,CMS 垃圾回收器采用
- 思路是拦截每次赋值动作,只要赋值发生,被赋值的对象就会被记录下来,在重新标记阶段再确认一遍
- Snapshot At The Beginning,SATB 原始快照法,G1 垃圾回收器采用
- 思路也是拦截每次赋值动作,不过记录的对象不同,也需要在重新标记阶段对这些对象二次处理
- 新加对象会被记录
- 被删除引用关系的对象也被记录
垃圾回收器 - Parallel GC
-
eden 内存不足发生 Minor GC,采用标记复制算法,需要暂停用户线程
-
old 内存不足发生 Full GC,采用标记整理算法,需要暂停用户线程
-
注重吞吐量
垃圾回收器 - ConcurrentMarkSweep GC
-
它是工作在 old 老年代,支持并发标记的一款回收器,采用并发清除算法
- 并发标记时不需暂停用户线程
- 重新标记时仍需暂停用户线程
-
如果并发失败(即回收速度赶不上创建新对象速度),会触发 Full GC
-
注重响应时间
垃圾回收器 - G1 GC
- 响应时间与吞吐量兼顾
- 划分成多个区域,每个区域都可以充当 eden,survivor,old, humongous,其中 humongous 专为大对象准备
- 分成三个阶段:新生代回收、并发标记、混合收集
- 如果并发失败(即回收速度赶不上创建新对象速度),会触发 Full GC
G1 回收阶段 - 新生代回收
- 初始时,所有区域都处于空闲状态
- 创建了一些对象,挑出一些空闲区域作为伊甸园区存储这些对象
- 当伊甸园需要垃圾回收时,挑出一个空闲区域作为幸存区,用复制算法复制存活对象,需要暂停用户线程
- 复制完成,将之前的伊甸园内存释放
- 随着时间流逝,伊甸园的内存又有不足
- 将伊甸园以及之前幸存区中的存活对象,采用复制算法,复制到新的幸存区,其中较老对象晋升至老年代
- 释放伊甸园以及之前幸存区的内存
G1 回收阶段 - 并发标记与混合收集
- 当老年代占用内存超过阈值后,触发并发标记,这时无需暂停用户线程
- 并发标记之后,会有重新标记阶段解决漏标问题,此时需要暂停用户线程。这些都完成后就知道了老年代有哪些存活对象,随后进入混合收集阶段。此时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象少)的区域(这也是 Gabage First 名称的由来)。
- 混合收集阶段中,参与复制的有 eden、survivor、old,下图显示了伊甸园和幸存区的存活对象复制
- 下图显示了老年代和幸存区晋升的存活对象的复制
- 复制完成,内存得到释放。进入下一轮的新生代回收、并发标记、混合收集
4. 内存溢出
要求
-
能够说出几种典型的导致内存溢出的情况
-
典型情况
-
误用线程池导致的内存溢出
-
查询数据量太大导致的内存溢出
-
动态生成类导致的内存溢出
5. 类加载
要求
- 掌握类加载阶段
- 掌握类加载器
- 理解双亲委派机制
类加载过程的三个阶段
-
加载
-
将类的字节码载入方法区,并创建类.class 对象
-
如果此类的父类没有加载,先加载父类
-
加载是懒惰执行
-
-
链接
- 验证 – 验证类是否符合 Class 规范,合法性、安全性检查
- 准备 – 为 static 变量分配空间,设置默认值
- 解析 – 将常量池的符号引用解析为直接引用
-
初始化
- 静态代码块、static 修饰的变量赋值、static final 修饰的引用类型变量赋值,会被合并成一个
<cinit>
方法,在初始化时被调用 - static final 修饰的基本类型变量赋值,在链接阶段就已完成
- 初始化是懒惰执行
- 静态代码块、static 修饰的变量赋值、static final 修饰的引用类型变量赋值,会被合并成一个
验证手段
- 使用 jps 查看进程号
- 使用 jhsdb 调试,执行命令
jhsdb.exe hsdb
打开它的图形界面
- Class Browser 可以查看当前 jvm 中加载了哪些类
- 控制台的 universe 命令查看堆内存范围
- 控制台的 g1regiondetails 命令查看 region 详情
scanoops 起始地址 结束地址 对象类型
可以根据类型查找某个区间内的对象地址- 控制台的
inspect 地址
指令能够查看这个地址对应的对象详情- 使用 javap 命令可以查看 class 字节码
jdk 8 的类加载器
名称 | 加载哪的类 | 说明 |
---|---|---|
Bootstrap ClassLoader | JAVA_HOME/jre/lib | 无法直接访问 |
Extension ClassLoader | JAVA_HOME/jre/lib/ext | 上级为 Bootstrap,显示为 null |
Application ClassLoader | classpath | 上级为 Extension |
自定义类加载器 | 自定义 | 上级为 Application |
双亲委派机制
所谓的双亲委派,就是指优先委派上级类加载器进行加载,如果上级类加载器
- 能找到这个类,由上级加载,加载后该类也对下级加载器可见
- 找不到这个类,则下级类加载器才有资格执行加载
双亲委派的目的有两点
-
让上级类加载器中的类对下级共享(反之不行),即能让你的类能依赖到 jdk 提供的核心类
-
让类的加载有优先次序,保证核心类优先加载
6. 四种引用
要求
- 掌握四种引用
强引用
-
普通变量赋值即为强引用,如 A a = new A();
-
通过 GC Root 的引用链,如果强引用不到该对象,该对象才能被回收
软引用(SoftReference)
-
例如:SoftReference a = new SoftReference(new A());
-
如果仅有软引用该对象时,首次垃圾回收不会回收该对象,如果内存仍不足,再次回收时才会释放对象
-
软引用自身需要配合引用队列来释放
-
典型例子是反射数据
弱引用(WeakReference)
-
例如:WeakReference a = new WeakReference(new A());
-
如果仅有弱引用引用该对象时,只要发生垃圾回收,就会释放该对象
-
弱引用自身需要配合引用队列来释放
-
典型例子是 ThreadLocalMap 中的 Entry 对象
虚引用(PhantomReference)
-
例如: PhantomReference a = new PhantomReference(new A(), referenceQueue);
-
必须配合引用队列一起使用,当虚引用所引用的对象被回收时,由 Reference Handler 线程将虚引用对象入队,这样就可以知道哪些对象被回收,从而对它们关联的资源做进一步处理
-
典型例子是 Cleaner 释放 DirectByteBuffer 关联的直接内存
7. finalize
要求
- 掌握 finalize 的工作原理与缺点
finalize
- 它是 Object 中的一个方法,如果子类重写它,垃圾回收时此方法会被调用,可以在其中进行资源释放和清理工作
- 将资源释放和清理放在 finalize 方法中非常不好,非常影响性能,严重时甚至会引起 OOM,从 Java9 开始就被标注为 @Deprecated,不建议被使用了
finalize 原理
- 对 finalize 方法进行处理的核心逻辑位于 java.lang.ref.Finalizer 类中,它包含了名为 unfinalized 的静态变量(双向链表结构),Finalizer 也可被视为另一种引用对象(地位与软、弱、虚相当,只是不对外,无法直接使用)
- 当重写了 finalize 方法的对象,在构造方法调用之时,JVM 都会将其包装成一个 Finalizer 对象,并加入 unfinalized 链表中
- Finalizer 类中还有另一个重要的静态变量,即 ReferenceQueue 引用队列,刚开始它是空的。当狗对象可以被当作垃圾回收时,就会把这些狗对象对应的 Finalizer 对象加入此引用队列
- 但此时 Dog 对象还没法被立刻回收,因为 unfinalized -> Finalizer 这一引用链还在引用它嘛,为的是【先别着急回收啊,等我调完 finalize 方法,再回收】
- FinalizerThread 线程会从 ReferenceQueue 中逐一取出每个 Finalizer 对象,把它们从链表断开并真正调用 finallize 方法
- 由于整个 Finalizer 对象已经从 unfinalized 链表中断开,这样没谁能引用到它和狗对象,所以下次 gc 时就被回收了
finalize 缺点
- 无法保证资源释放:FinalizerThread 是守护线程,代码很有可能没来得及执行完,线程就结束了
- 无法判断是否发生错误:执行 finalize 方法时,会吞掉任意异常(Throwable)
- 内存释放不及时:重写了 finalize 方法的对象在第一次被 gc 时,并不能及时释放它占用的内存,因为要等着 FinalizerThread 调用完 finalize,把它从 unfinalized 队列移除后,第二次 gc 时才能真正释放内存
- 有的文章提到【Finalizer 线程会和我们的主线程进行竞争,不过由于它的优先级较低,获取到的CPU时间较少,因此它永远也赶不上主线程的步伐】这个显然是错误的,FinalizerThread 的优先级较普通线程更高,原因应该是 finalize 串行执行慢等原因综合导致
框架篇
1. Spring refresh 流程
要求
- 掌握 refresh 的 12 个步骤
Spring refresh 概述
refresh 是 AbstractApplicationContext 中的一个方法,负责初始化 ApplicationContext 容器,容器必须调用 refresh 才能正常工作。它的内部主要会调用 12 个方法,我们把它们称为 refresh 的 12 个步骤:
-
prepareRefresh
-
obtainFreshBeanFactory
-
prepareBeanFactory
-
postProcessBeanFactory
-
invokeBeanFactoryPostProcessors
-
registerBeanPostProcessors
-
initMessageSource
-
initApplicationEventMulticaster
-
onRefresh
-
registerListeners
-
finishBeanFactoryInitialization
-
finishRefresh
功能分类
1 为准备环境
2 3 4 5 6 为准备 BeanFactory
7 8 9 10 12 为准备 ApplicationContext
11 为初始化 BeanFactory 中非延迟单例 bean
1. prepareRefresh
-
这一步创建和准备了 Environment 对象,它作为 ApplicationContext 的一个成员变量
-
Environment 对象的作用之一是为后续 @Value,值注入时提供键值
-
Environment 分成三个主要部分
- systemProperties - 保存 java 环境键值
- systemEnvironment - 保存系统环境键值
- 自定义 PropertySource - 保存自定义键值,例如来自于 *.properties 文件的键值