参考视频:满神Java面试专题
笔记的整体结构依据视频编写,并随着学习的深入补充了很多知识
1. 线程状态
要求
- 掌握 Java 线程六种状态
- 掌握 Java 线程状态转换
- 能理解五种状态与六种状态两种说法的区别
六种状态及转换

分别是
- 新建
- 当一个线程对象被创建,但还未调用 start 方法时处于新建状态
- 此时未与操作系统底层线程关联,仅仅是一个Java对象,没有跟真正的线程关联,所以此时这个线程不会被操作系统分配CPU去执行代码
- 可运行(只有该状态才会被操作系统分配CPU去执行代码,其他状态都不行)
- 调用了 start 方法,就会由新建进入可运行
- 此时与底层线程关联,由操作系统分配CPU调度执行
- 终结
- 线程内代码已经执行完毕,由可运行进入终结,线程生命周期走到尽头
- 此时会取消与底层线程关联,相关资源得到释放

- 阻塞
- 当获取锁失败后,由可运行进入 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 中同名状态类似,不再啰嗦
注意:Java中的RUNNABLE涵盖了就绪、运行、阻塞I/O
2. 线程池
要求
- 掌握线程池的 7 大核心参数
七大参数
corePoolSize 核心线程数目 - 池中会保留的最多线程数(核心线程是可以为0的,即所有线程执行完任务后都不保留,都属于救急线程)
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


代码说明
day02.TestThreadPoolExecutor 以较为形象的方式演示了线程池的核心组成

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() 如果不唤醒就一直等下去
- 它们都可以被打断唤醒(Thread调用interrupt方法进行打断)

锁特性不同(重点)
- 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 中的条件变量可以有多个,可以实现更精细的等待、唤醒控制
代码说明
- day02.TestReentrantLock 用较为形象的方式演示 ReentrantLock 的内部结构
- 部分源码
java// --add-opens java.base/java.util.concurrent=ALL-UNNAMED --add-opens java.base/java.util.concurrent.locks=ALL-UNNAMED public class TestReentrantLock { static final MyReentrantLock LOCK = new MyReentrantLock(true); static Condition c1 = LOCK.newCondition("c1"); static Condition c2 = LOCK.newCondition("c2"); static volatile boolean stop = false; public static void main(String[] args) throws InterruptedException, IOException { learnLock(); } private static void learnLock() throws InterruptedException { System.out.println(LOCK); new MyThread(() -> { LOCK.lock(); get("t").debug("acquire lock..."); }, "t1").start(); Thread.sleep(100); new MyThread(() -> { LOCK.lock(); get("t").debug("acquire lock..."); }, "t2").start(); Thread.sleep(100); new MyThread(() -> { LOCK.lock(); get("t").debug("acquire lock..."); }, "t3").start(); Thread.sleep(100); new MyThread(() -> { LOCK.lock(); get("t").debug("acquire lock..."); }, "t4").start(); } private static void fairVsUnfair() throws InterruptedException { new MyThread(() -> { LOCK.lock(); get("t").debug("acquire lock..."); sleep1s(); LOCK.unlock(); }, "t1").start(); Thread.sleep(100); new MyThread(() -> { LOCK.lock(); get("t").debug("acquire lock..."); sleep1s(); LOCK.unlock(); }, "t2").start(); Thread.sleep(100); new MyThread(() -> { LOCK.lock(); get("t").debug("acquire lock..."); sleep1s(); LOCK.unlock(); }, "t3").start(); Thread.sleep(100); new MyThread(() -> { LOCK.lock(); get("t").debug("acquire lock..."); sleep1s(); LOCK.unlock(); }, "t4").start(); get("t").debug("{}", LOCK); while (!stop) { new Thread(() -> { try { // tryLock底层总是非公平锁 boolean b = LOCK.tryLock(10, TimeUnit.MILLISECONDS); if (b) { System.out.println(Thread.currentThread().getName() + " acquire lock..."); stop = true; sleep1s(); LOCK.unlock(); } } catch (InterruptedException e) { e.printStackTrace(); } }).start(); } } private static void sleep1s() { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } private static class MyReentrantLock extends ReentrantLock { private final Map<String, Condition> conditions = new HashMap<>(); public MyReentrantLock(boolean fair) { super(fair); } //条件变量方法 public Condition newCondition(String name) { Condition condition = super.newCondition(); conditions.put(name, condition); return condition; } ... } ... }
lock阻塞

lock可重入锁

lock非公平锁(其他线程插队等待队列里的线程)

lock公平锁(线程正常排队等待)

lock条件变量

5. volatile
要求
掌握线程安全要考虑的三个问题
- 线程安全要考虑三个方面:可见性、有序性、原子性
- 可见性指,一个线程对共享变量修改,另一个线程能看到最新的结果
- 有序性指,一个线程内代码按编写顺序执行
- 原子性指,一个线程内多行代码以一个整体运行,期间不能有其它线程的代码插队
- 线程安全要考虑三个方面:可见性、有序性、原子性
掌握 volatile 能解决哪些问题
原子性
- 起因:多线程下,不同线程的指令发生了交错导致的共享变量的读写混乱
- 解决:用悲观锁或乐观锁解决
- volatile 并不能解决原子性

可见性
- 网上对可见性的说法

- 上图说法显然是错误的,因为线程1、2都能读取到线程0的修改stop的最新值

- 起因:由于编译器优化、或缓存优化、或 CPU 指令重排序优化导致的对共享变量所做的修改另外的线程看不到
- 解决:用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见
- JIT:Java的即时编译器,是JVM的重要组成部分,主要责任是负责代码优化,任何一条JAVA代码最终会翻译成JAVA的字节码指令,但这个JAVA的字节码指令还不能够交给CPU来执行,它还有一个叫做解释器的组件,这个解释器的组件会把JAVA的字节码指令逐行翻译成机器码,这个机器码才交给CPU,CPU才能认识,这样效率较低,因此JIT的组件对一些热点的字节码进行优化,例如频繁调用的代码,反复执行的循环等。使用volatile修饰变量,就是超过循环阈值或方法调用次数也不进行优化,直接放行

有序性

- 起因:由于编译器优化、或缓存优化、或 CPU 指令重排序优化导致指令的实际执行顺序与编写顺序不一致
- 解决:用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
- 注意:
- volatile 变量写加的屏障是阻止上方其它写操作越过屏障排到 volatile 变量写之下
- volatile 变量读加的屏障是阻止下方其它读操作越过屏障排到 volatile 变量读之上
- volatile 读写加入的屏障只能防止同一线程内的指令重排


代码说明
- day02.threadsafe.AddAndSubtract 演示原子性
- day02.threadsafe.ForeverLoop 演示可见性
- 注意:本例经实践检验是编译器优化导致的可见性问题
- day02.threadsafe.Reordering 演示有序性
- 需要打成 jar 包后测试
- 请同时参考视频讲解
补充
可见性问题补充
相比synchronized的加锁方式来解决共享变量的内存可见性问题,volatile就是更轻量的选择,它没有上下文切换的额外开销成本。
volatile可以确保对某个变量的更新对其他线程马上可见,一个变量被声明为volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存 当其它线程读取该共享变量,会从主内存重新获取最新值,而不是使用当前线程的本地内存中的值。
例如,我们声明一个 volatile 变量 volatile int x = 0,线A修改x=1,修改完之后就会把新的值刷新回主内存,线程B读取x的时候,就会清空本地内存变量,然后再从主内存获取最新值。
有序性问题补充
- 重排序可以分为编译器重排序和处理器重排序,valatile保证有序性,就是通过分别限制这两种类型的重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
- 1.在每个volatile写操作的前面插入一个 StoreStore 屏障
- 2.在每个volatile写操作的后面插入一个 StoreLoad 屏障
- 3.在每个volatile读操作的后面插入一个 LoadLoad 屏障
- 4.在每个volatile读操作的后面插入一个 LoadStore 屏障
6. 悲观锁 vs 乐观锁
要求
- 掌握悲观锁和乐观锁的区别
对比悲观锁与乐观锁
- 悲观锁的代表是 synchronized 和 Lock 锁
- 其核心思想是【线程只有占有了锁,才能去操作共享变量,每次只有一个线程占锁成功,获取锁失败的线程,都得停下来等待】
- 线程从运行到阻塞、再从阻塞到唤醒,涉及线程上下文切换,如果频繁发生,影响性能
- 线程上下文切换:线程从运行都阻塞,把线程当前运行的状态记录下来,线程执行到第几行代码了,当前有哪些局部变量等都得记录,下次再把你从阻塞状态唤醒时,会把这些信息进行恢复,接着上次中断的地方继续向下运行,整个过程称为线程上下文切换
- 实际上,线程在获取 synchronized 和 Lock 锁时,如果锁已被占用,都会做几次重试操作,减少阻塞的机会
- 线程在获取synchronized和Lock锁时,若锁已被占用,都会做几次重试操作,重试期间,锁未被释放,就不用进入阻塞状态,减少一次上下文切换,即减少阻塞的机会
- 乐观锁的代表是 AtomicInteger,使用 cas 来保证原子性
- 其核心思想是【无需加锁,每次只有一个线程能成功修改共享变量,其它失败的线程不需要停止,不断重试直至成功】
- 由于线程一直运行,不需要阻塞,因此不涉及线程上下文切换
- 它需要多核 cpu 支持,且线程数不应超过 cpu 核数(必须有独立CPU来执行你的循环或不断重试的代码)
- 若没有多核CPU执行,即使抢占锁失败的线程不想停下来也得停下来,因为没有多余CPU执行,虽然不会进入阻塞状态,但会进行上下文切换,到一边歇着去
代码说明
day02.SyncVsCas 演示了分别使用乐观锁和悲观锁解决原子赋值
请同时参考视频讲解(使用AtomicInteger更底层的Unsafe来讲解乐观锁,对于乐观锁来讲,能够保证在修改的共享变量时的修改操作是原子操作)
CAS乐观锁需要和volatile配合使用,通过可见性来判断变量是否发生改变,虽然cas可以保证原子性,但还要通过volatile来保证你能看到共享变量的最新值
java... //AtomicInteger的底层 static final Unsafe U = Unsafe.getUnsafe(); //计算偏移量 static final long BALANCE = U.objectFieldOffset(Account.class, "balance"); static class Account { volatile int balance = 10; } ... Account account = new Account(); int o = account.balance; int n = o + 5; //选择整型类型,若没有其他线程干扰,只要在10的基础上加5就成功了;若在修改的过程中有其他线程干扰(把共享变量balance的最新值修改成了100),那如果在10的基础上加5了就会把共享变量的最新值(100)给覆盖了,所以此方法会在修改共享变量之前将旧值与共享变量的最新值进行比较,若相等则没有别的线程干扰就修改成功,反之则修改失败 U.compareAndSetInt(account,BALANCE,o,n)); =>compareAndSetInt(Object o,//要修改的对象 long offset,//偏移量 int expected,//旧值 //新值 int x);
- 悲观锁synchronized解决指令的交错问题(指令重排序),用阻止指令交错的方式来保证我们对共享变量的安全访问;synchronized用互斥的方式让整个同步代码块以整体的形式执行,执行期间其他线程不能执行,都被阻塞住
- 乐观锁cas会产生指令交错现象,多线程下,有一个线程修改了共享变量10为15,这时线程切换到另一个,循环第一次判断出来共享变量与初始值10不一样,退出当前循环再进行循环,此时可以获取到共享变量的最新值15,值修改可以成功为10结束循环;
- 乐观锁(cas)没有互斥,可以并行执行,谁先执行谁后执行都无所谓,指令交错也无所所谓,但它可以通过比较并交换的原则,看看你更新时,判断这个值有没有被别人修改过,未修改才修改成功,反之这次更新失败,失败后重试(再次循环),再获取最新值,在最新值的基础上进行操作,这样就能保证共享变量的正确性
7. Hashtable vs ConcurrentHashMap
要求
- 掌握 Hashtable 与 ConcurrentHashMap 的区别
- 掌握 ConcurrentHashMap 在不同版本的实现区别
更形象的演示,见资料中的 hash-demo.jar,运行需要 jdk14 以上环境,进入 jar 包目录,执行下面命令
java -jar --add-exports java.base/jdk.internal.misc=ALL-UNNAMED hash-demo.jar
Hashtable 对比 ConcurrentHashMap
Hashtable 与 ConcurrentHashMap 都是线程安全的 Map 集合
Hashtable 并发度低,整个 Hashtable 对应一把锁,同一时刻,只能有一个线程操作它
- hashtable扩容因子为0.75,扩容后为原容量*2+1,而且容量一般为质数,有很好的hash分布性,即不会进行二次hash(hash取正再求模)

ConcurrentHashMap 并发度高,整个 ConcurrentHashMap 对应多把锁,只要线程访问的是不同锁,那么不会冲突
ConcurrentHashMap 1.7
- 数据结构:
Segment(大数组) + HashEntry(小数组) + 链表,每个 Segment 对应一把锁,如果多个线程访问不同的 Segment,则不会冲突 - 并发度:Segment 数组大小即并发度,决定了同一时刻最多能有多少个线程并发访问。Segment 数组不能扩容,意味着并发度在 ConcurrentHashMap 创建时就固定了
- 索引计算
- 假设大数组长度是 ,key 在大数组内的索引是 key 的二次 hash 值的高 m 位
- 假设小数组长度是 ,key 在小数组内的索引是 key 的二次 hash 值的低 n 位
- 扩容:每个小数组的扩容相对独立,小数组在超过扩容因子时会触发扩容,每次扩容翻倍
- Segment[0] 原型:首次创建其它小数组时,会以此原型为依据,数组长度,扩容因子都会以原型为准
- concurrenthashmap1.7:外面整个数组为一个segment数组,里面每个元素套一个小数组hashentry,等价于普通hashmap的数据结构,将来如果索引冲突了就构成一个链表
- 并发度clevel值的多少决定currentHashMap的并发性,容量capacity的大小决定小数组HashEntry的初始大小(capacity/clevel,最小值为2),扩容因子factor决定的是小数组HashEntry的扩容(超过0.75触发扩容);
- key的索引根据二次hash的高四位作为(1100->12)索引值,索引是指segment的索引;clevel为32就高五位取值;以二次hash的最低位作为entry数组中的索引
- segment数组的并发度clevel一旦确定,就不会扩容,不管元素个数是多少,小数组会根据扩容因子来扩容,扩容容量翻倍,链表插入方式为头插法,这里的头插法并不会造成死链,因为每个线程操作Segment数组都会加上锁,别的线程不能访问,每个小数组会根据各自的元素个数来各自扩容,互不干扰
- segment[0]原型是创建segment数组的初始entry,大小根据capacity和clevel决定(capacity/clevel,最小值为2),只要是创建出来的HashEntry小数组都会根据Segment[0]中的小数组作为原型,把它的大小、扩容因子等拿来借用,这也体现了设计模式中的原型模式
- 饿汉式初始化:底层已经调用了构造方法,构造方法一调用,Segment数组包括里面的Segment[0]HashEntry小数组就已经被创建来了,是一个饿汉式的初始化(并没有put元素)
ConcurrentHashMap 1.8
- 数据结构:
Node 数组 + 链表或红黑树,数组的每个头节点作为锁,如果多个线程访问的头节点不同,则不会冲突。首次生成头节点时如果发生竞争,利用 cas 而非 syncronized,进一步提升性能 - 并发度:Node 数组有多大,并发度就有多大,与 1.7 不同,Node 数组可以扩容
- 扩容条件:Node 数组满 3/4 时就会扩容
- 调用无参构造创建数组,默认容量为16;插入方式为尾插法

- 扩容单位:以链表为单位从后向前迁移链表,迁移完成的将旧数组头节点替换为 ForwardingNode
- 扩容时并发 get
- 根据是否为 ForwardingNode 来决定是在新数组查找还是在旧数组查找,不会阻塞
- 如果链表长度超过 1,则需要对节点进行复制(创建新节点),怕的是节点迁移后 next 指针改变
- 如果链表最后几个元素扩容后索引不变,则节点无需复制
- 扩容时并发 put
- 如果 put 的线程与扩容线程操作的链表是同一个,put 线程会阻塞
- 如果 put 的线程操作的链表还未迁移完成,即头节点不是 ForwardingNode,则可以并发执行
- 如果 put 的线程操作的链表已经迁移完成,即头结点是 ForwardingNode,则可以协助扩容
- 与 1.7 相比是懒汉式初始化(第一次put元素时才会创建底层的数据结构)
- capacity 代表预估的元素个数,capacity / factory 来计算出初始数组大小,需要贴近
- loadFactor 只在计算初始数组大小时被使用,之后扩容固定为 3/4
- 超过树化阈值时的扩容问题,如果容量已经是 64,直接树化,否则在原来容量基础上做 3 轮扩容
- 一开始调用构造方法时,数组还没创建出来,当插入元素时数组才会创建,即是一种懒汉式初始化,只要>=扩容因子0.75才会扩容,
- 有参构造设定capacity初始值为16,它假定你初始时放入16个元素,数组多大你看着办,数组长度满足,此时插入元素时,它会你插入16/16>0.75,超过扩容阈值,这时触发扩容,数组长度为32(*¾=24),这样就满足条件16/32<0.75,即只要capacity初始值为12(12/16=0.75)<=capacity<=16插入元素就会触发扩容为32,0<=capacity<12就是初始容量16,capacity的大小决定初始数组的大小
- 初始数组扩容因子factor只是用来初始化的,调用currentHashMap的构造方法时才会用来配合计算我们的数组长度该有多大,即在创建数组时,扩容因子factor为0.5(capacity=8 / 初始数组长度固定=16),数组创建出来后将来插入元素还是按照0.75来扩容(factor只是第一次构造时用来计算一下,以后就不用它了,还是按照满0.75来扩容)
- 多线程下put元素(同一个index),在b线程put后抢占锁后sleep10s,c线程要想put并抢占锁需要等待b休眠结束(阻塞),是一种互斥锁;
- put不同的index可以并行执行,互不干扰
- 扩容时,是从后往前处理元素的迁移,迁移完标记forwardingNode(没元素也标记),全部迁移完,旧数组索引就全是标记表示迁移结束
- 迁移过程中,get元素(非链表),只要标记了forwardingNode的索引且迁移过元素,就去新数组去获取,即不会被阻塞,可以并发运行;当在迁移链表过程中去get元素,此时迁移前后的元素是不一样的,元素的前后指针和索引发生改变,因此在迁移时大多数情况都会把链表元素重新创建,不能用同一个对象(链表指向发生变化),但是currentHashMap对链表迁移工作做了优化,对于链表最后几个元素位置相同(hash相同/index相同),就不用重新创建节点的工作,扩容后的位置就不用动直接用就行,链表前面的元素都必须创建新的节点对象来解决扩容并同时查询的问题(尾插法,后面元素插入头部,前面元素插入尾部)
- 迁移过程中put元素,非链表可以并发put,链表在迁移过程中会加锁,put操作只能阻塞住了;
- 当put的是forwardingNode这些已标记为迁移完成的索引,put不可能会到新数组去操作,只能等迁移完整个数组才能到新数组去操作,但是currentHashMap也做了一些优化,链表迁移工作也不是由一个线程一下子迁移完所有的链表,是划分了多个区间,每个线程可以一次迁移16个链表,如当前扩容线程可以迁移0-15的链表,这个时候来个一个put线程,没事干就进入空闲状态,但如果旧的数组容量为32时,扩容线程是处理16-31的链表,此时新加入的put线程发现16-31的数据已经处理完了,这种情况下就会帮忙扩容,处理0-15未标记的元素链表的迁移(并发分组迁移未迁移的元素),减少阻塞机会,让它也别闲着,让它也有活可干
8. ThreadLocal
要求
- 掌握 ThreadLocal 的作用与原理
- 掌握 ThreadLocal 的内存释放时机
作用
- ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】,避免争用引发的线程安全问题;方法内的局部变量也是线程私有的,不牵扯到资源共享,虽然也不会引发线程安全问题,但有一个缺点就是不能跨越方法,如同一线程内要调用方法1、2、3、4,那局部变量的生命周期就局限在一个方法内,方法2就不能使用到方法1的局部变量
- 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 回收
- threadLocal是弱引用作为key来存储的,当下面线程1一直处于运行状态,不断的插入元素,这样键值就会越来越多,这时只能等GC垃圾回收了,如果threadLocal设计成强引用就不能被垃圾回收了(即使别的地方都没有引用threadLocal,只要threadLocalMap使用强引用引用资源,那被引用的资源就得不到释放),所以弱引用引用资源将来没人引用了就会被回收,但值是强引用的,GC仅是让key的内存释放,后续还要根据 key 是否为 null来进一步释放值的内存,
- GC垃圾回收时,把未被引用的threadLocal(a)清理掉,变为null,值还在;当新的threadLocal(c)去get这个key为Null的位置时,此时值就会被垃圾回收掉,变为Null;当get一个空闲位置时,map就会把当前新的threadLocal(d)作为key,但值为Null
- 当在被垃圾回收掉key的位置set(8)时,不光清理掉当前位置的值(清理并存储),还会把相邻位置也一起清理掉(9,10 的 k v),但离得比较远的位置就不会去清理(14)
- 一般情况是没有其他地方引用threadLocal就会被垃圾回收,key=Null,get,set发现Nullkey才会进一步清理value
- 实际情况是用静态变量来引用threadLocal对象,即静态变量跟这个对象为强引用,所以静态变量一直强引用使用对象,垃圾回收不了,即使threadLocal是弱引用,但是静态变量对threadLocal一直保持强引用状态,所以懒惰被动释放 value的两种方式是行不通的
- 使用remove主动清理直接将map的键值清理掉,前两种方式清理(get set)会产生内存泄露,需要用remove来及时清理;假设长时间运行的线程池资源(含threadLocal关联静态资源)没有即使清理的话,就会积累越来越多的键值资源,还有其他的静态变量强引用key,GC也没办法把它们回收,久而久之就会造成内存泄漏
To Be Continued.
























