java面经02-并发篇-线程六态、线程池、wait&sleep、lock&synchronized、volatile、悲观锁&乐观锁、Hashtable、ThreadLocal

并发篇

1. 线程状态

要求

  • 掌握 Java 线程六种状态
  • 掌握 Java 线程状态转换
  • 能理解五种状态与六种状态两种说法的区别

六种状态及转换

在这里插入图片描述

分别是

  • 新建
    • 当一个线程对象被创建,但还未调用 start 方法时处于新建状态
    • 此时未与操作系统底层线程关联
  • 可运行
    • 调用了 start 方法,就会由新建进入可运行
    • 此时与底层线程关联,由操作系统调度执行
  • 终结
    • 线程内代码已经执行完毕,由可运行进入终结
    • 此时会取消与底层线程关联
  • 阻塞
    • 当获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,此时不占用 cpu 时间
    • 当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
  • 等待
    • 当获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁进入 Monitor 等待集合等待,同样不占用 cpu 时间
    • 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的等待线程,恢复为可运行状态
  • 有时限等待
    • 当获取锁成功后,但由于条件不满足,调用了 wait(long) 方法,此时从可运行状态释放锁进入 Monitor 等待集合进行有时限等待,同样不占用 cpu 时间
    • 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的有时限等待线程,恢复为可运行状态,并重新去竞争锁
    • 如果等待超时,也会从有时限等待状态恢复为可运行状态,并重新去竞争锁
    • 还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行状态

其它情况(只需了解)

  • 可以用 interrupt() 方法打断等待有时限等待的线程,让它们恢复为可运行状态
  • park,unpark 等方法也可以让线程等待和唤醒
  • 代码演示:
public class TestThreadState {
    
    
    // 唯一锁对象
    static final Object LOCK = new Object();

    public static void main(String[] args) {
    
    
        testNewRunnableTerminated();
    }

    private static void testNewRunnableTerminated(){
    
    

        // Runnable方式+lambda表达式创建多线程
        Thread t1 = new Thread(()->{
    
    
            System.out.println("running...");//3
        },"t1");

        System.out.println("state: "+t1.getState());//1
        t1.start();
        System.out.println("state: "+t1.getState());//2

        System.out.println("state: "+t1.getState());//4
    }
}

代码演示:得debug方式才能比较好地看出来效果,因为Debug能控制多线程执行顺序,注意多线程可以选择Thread-Debug方式
多线程相关代码Debug方式
在这里插入图片描述

点击切换线程,看具体执行到哪里了:
在这里插入图片描述

切换到t1,直接放行,让t1先于主线程执行完毕,主线程就会打印最终的终结态
在这里插入图片描述

在这里插入图片描述

其实改成下面的方式,就可以保证t1线程大概率先于主线程执行完毕啦(让主线程先休眠1s即可,都不用debug那么麻烦了)

public class TestThreadState {
    
    
    // 唯一锁对象
    static final Object LOCK = new Object();

    public static void main(String[] args) {
    
    
        testNewRunnableTerminated();
    }

    private static void testNewRunnableTerminated() {
    
    

        // Runnable方式+lambda表达式创建多线程
        Thread t1 = new Thread(()->{
    
    
            System.out.println("running...");//3
        },"t1");

        System.out.println("state: "+t1.getState());//1
        t1.start();
        System.out.println("state: "+t1.getState());//2

        try {
    
    
            Thread.sleep(1000);
        } catch (InterruptedException e) {
    
    
            throw new RuntimeException(e);
        }
        System.out.println("state: "+t1.getState());//4
    }
}

在这里插入图片描述

  • 代码演示2:blocked
private static void testBlocked() throws InterruptedException {
    
    
    Thread t2 = new Thread(() -> {
    
    

        System.out.println("before sync");
        synchronized (LOCK){
    
    //3 被锁住
            System.out.println("in sync");//6 
        }
    },"t2");
    t2.start();
    System.out.println("state: "+t2.getState());//1   RUNNABLE
    synchronized (LOCK){
    
    //2 先进来
        System.out.println("state: "+t2.getState());//4  BLOCKED
    }//5 出来释放锁
    System.out.println("state: "+t2.getState());//7 RUNNABLE
}

在这里插入图片描述
Debug方式控制执行顺序如代码行之后标记的那样,就能看到t2先由RUNNABLE->BLOCKED;再由BLOCKED->RUNNABLE

  • 代码演示3:WAITING
private static void testWaiting() {
    
    
   Thread t2 = new Thread(() -> {
    
    
       synchronized (LOCK) {
    
    //注意也要加锁
           System.out.println("before waiting");//2
           try {
    
    
               LOCK.wait();//3  这里一旦wait,就立刻释放了锁 下面主线程就可以进入 锁代码块了
           } catch (InterruptedException e) {
    
    
               e.printStackTrace();
           }
       }
   }, "t2");


   t2.start();
   System.out.println("state: " + t2.getState());//1  RUNNABLE
   synchronized (LOCK) {
    
    
       System.out.println("state: " + t2.getState());//4 WAITING
       LOCK.notify();//5
       System.out.println("state: " + t2.getState());//6 BLOCKED(锁被主线程占了)
   }//这里一结束并释放锁,上面t2线程立刻被自动解锁 BLOCKED->RUNNABLE
   System.out.println("state: " + t2.getState());//7 RUNNABLE
}

在这里插入图片描述

五种状态

五种状态的说法来自于操作系统层面的划分
在这里插入图片描述

  • 运行态:分到 cpu 时间,能真正执行线程内代码的
  • 就绪态:有资格分到 cpu 时间,但还未轮到它的
  • 阻塞态:没资格分到 cpu 时间的
    • 涵盖了 java 状态中提到的阻塞等待有时限等待
    • 多出了阻塞 I/O,指线程在调用阻塞 I/O 时,实际活由 I/O 设备完成,此时线程无事可做,只能干等
  • 新建与终结态:与 java 中同名状态类似,不再啰嗦

java中的可运行态Runnable,包含了os中的,就绪、运行、阻塞I/O (因为java代码区分不了阻塞I/O 等待输入也认为其在执行)
在这里插入图片描述

2. 线程池

要求

  • 掌握线程池的 7 大核心参数

七大参数

  1. corePoolSize 核心线程数目 - 池中会保留的最多线程数 (可以为0 也就是所有线程执行完毕后都不保留)
  2. maximumPoolSize 最大线程数目 = 核心线程+救急线程的最大数目
  3. keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
  4. unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
  5. workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
  6. threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
  7. handler 拒绝策略 - 当所有线程(包括救急线程)都在繁忙,workQueue 也放满时,会触发拒绝策略
    1. 抛异常 java.util.concurrent.ThreadPoolExecutor.AbortPolicy
    2. 由调用者线程执行此任务 java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy (将此线程的任务代码交由调用t.start()的线程执行,相当于变成了t.run() 。线程池满了,你就不要创建多线程了,自己线程里执行吧。eg: main里面调用的就是直接main线程里执行了)
    3. 丢弃任务 java.util.concurrent.ThreadPoolExecutor.DiscardPolicy (不报异常,也不执行你,默默丢弃)
    4. 丢弃最早排队任务 java.util.concurrent.ThreadPoolExecutor.DiscardOldestPolicy (丢弃任务队列队头任务,再把我插入队尾

核心线程:该线程执行完任务之后,还要保留在线程池中
救急线程:该线程执行完任务之后,不需要留在线程池中
救急线程:核心线程有上限,任务队列也有上限,核心线程都在忙,阻塞队列(也叫任务队列workQueue)也满了,此时会创建救急线程。救急线程执行完分配地任务后会不断地去任务队列中找线程一直去执行他,直到队列任务的任务执行完毕(当然救急线程被分配的任务可能并没有那么容易被执行完毕,救急线程也可能都在忙,自顾不暇也就管不着任务队列了)
拒绝策略:核心线程满、阻塞队列满、救急线程也满,急救都救不了你了,没办法,只能拒绝了~

在这里插入图片描述

代码说明

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() 如果不唤醒就一直等下去
    • 它们都可以被打断唤醒 (获取对应线程对象,调用其interrupt方法,让其抛出异常而被唤醒)
  • 锁特性不同(重点)

    • wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
    • wait 方法(在 synchronized 代码块中 )执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
    • 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)

wait 方法的调用必须先获取 wait 对象的锁: wait方法只能在被synchronized锁住的代码块内调用。普通代码块里面调用wait会抛异常IllegalMonitorStateException 非法监视器状态异常

在这里插入图片描述
在这里插入图片描述
它们都可以被打断唤醒:t1.interrupt()强制抛异常,然后立刻去执行catch(){…}代码块里的代码,也是一种强制唤醒

4. lock vs synchronized

要求

  • 掌握 lock 与 synchronized 的区别
  • 理解 ReentrantLock 的公平、非公平锁
  • 理解 ReentrantLock 中的条件变量

三个层面

不同点

  • 语法层面
    • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
    • Lock 是接口,源码由 jdk 提供,用 java 语言实现
    • 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
  • 功能层面
    • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
    • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量
    • Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock
  • 性能层面
    • 在没有竞争时synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
    • 竞争激烈时,Lock 的实现通常会提供更好的性能

synchronized 用wait和notify实现同步
Lock 用条件变量(await和signal)实现同步
.
锁重入功能:给同一个对象加多道锁,将来解锁也得解多道
.
Lock是java语言实现的,比较方便,例如可以查看哪些线程被阻塞了。synchronized c++实现的,就看不了
.
synchronized 只支持非公平(可以插队,不一定先来先执行,插队一般效率更高)
synchronized 不支持打断和超时
synchronized 相当于只有一个条件变量,一个等待队列 ; Lock则有多个条件变量,多个等待队列
.
Reentrant:可重入
ReentrantReadWriteLock 更适合读多写少的场景

公平锁

  • 公平锁的公平体现
    • 已经处在阻塞队列中的线程(不考虑超时)始终都是公平的,先进先出
    • 公平锁是指未处于阻塞队列中的线程来争抢锁,如果队列不为空,则老实到队尾等待
    • 非公平锁是指未处于阻塞队列中的线程来争抢锁,与队列头唤醒的线程去竞争,谁抢到算谁的
  • 公平锁会降低吞吐量,一般不用

条件变量

  • ReentrantLock 中的条件变量功能类似于普通 synchronized 的 wait,notify,用在当线程获得锁后,发现条件不满足时,临时等待的链表结构
  • 与 synchronized 的等待集合不同之处在于,ReentrantLock 中的条件变量可以有多个,可以实现更精细的等待、唤醒控制

代码说明

  • day02.TestReentrantLock 用较为形象的方式演示 ReentrantLock 的内部结构

Lock方式里有两种队列:
1、block queue: 未争抢到锁的线程,一一进入此阻塞队列
2、waitting queue: 争抢到了锁的线程执行锁内代码时发现条件不满足(eg:需要等待另一个线程的结果导致的同步问题),此时线程主动执行conditon1.await()方法自我阻塞,这个时候就进入条件变量conditon1的等待队列中等待了。条件变量可以有多个,分别去挂起各种原因而需要阻塞等待的线程。
conditon1.await() 到信号量等待队列里去等待
conditon1.signal() 信号量等待队列里唤醒一个线程(队头) 唤醒后会到blocked队尾去 (synchronized会到blocked队头)
conditon1.signalAll() 唤醒信号量等待队列里所有线程(队头) 唤醒后会到blocked队尾去

synchronized底层是c++,无法通过代码Debug方式演示,底层实现和Lock不大一样,比如 信号量唤醒后的线程会从waitting队列到blocked队列队头(优先级更高)

5. volatile

要求

  • 掌握线程安全要考虑的三个问题
  • 掌握 volatile 能解决哪些问题

原子性

  • 起因:多线程下,不同线程的指令发生了交错导致的共享变量的读写混乱
  • 解决:用悲观锁或乐观锁解决,volatile 并不能解决原子性

原子性问题举例如下:
在这里插入图片描述
本来+5 -5之后的值应该保持10不变,但是由于交错运行,导致最终balance=5
解决: 锁 -》变成原子性
或者CAS来解决 (CAS=>compareAndSet 修改之前先比较一下变量值和旧值是否相等,也就是看看有没有被其他线程偷偷修改过。)
CAS也能保证原子性

可见性

  • 起因:由于编译器优化、或缓存优化、或 CPU 指令重排序优化导致的对共享变量所做的修改另外的线程看不到
  • 解决:用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见

可见性问题示例:
在这里插入图片描述
简单解释一句话: Thread-0线程对stop共享变量的修改,主线程并没有看到(都是os为了提高效率做了一些指令优化,速度是快了,但是也同时带来了一些问题)
在这里插入图片描述

但是上面那种解释是欠缺的,更确切地说应该是JIT(JIT(just in time):即时编译编译器)优化导致的,
JIT会优化那些经常被调用的方法或者循环,也就是经常性执行的代码,比如下面的while(!stop){…}方法体代码太简单,导致他0.1s可以循环1千万次,而每次都去内存里面读stop的值,相比较而言实在是太慢了,JIT看到后坐不住了,就给他优化了一下,直接将 编译-解释-》最后的 机器指令 while(!false)给缓存了起来,不再重新读内存stop了,缓存后就认为stop===false, 导致后来stop被修改了 也发现不了
当然这种优化是这个代码被重复执行了太多次数,次数已经超过JIT优化阈值了,比如(50万次),才会被优化
代码执行次数很少,JIT是不会优化的

上面Thread.sleep(100); 足够让while(!stop)死循环执行1千万次了,远远超过阈值。将其改成Thread.sleep(1);
1ms内死循环顶多执行1万多次,便不会被优化,也就会去读内存,也就能看到stop被修改了,就不会出现卡死的问题了。
在这里插入图片描述

解决: 用volatile修饰变量,该变量就不会被JIT编译优化给缓存了,每次都真的去读内存中的值,其修改也就立即能看到了,解决了这个问题。 (JIT编译优化能使得程序效率提高10~100倍,是不能关闭的)
在这里插入图片描述

有序性

  • 起因:由于编译器优化、或缓存优化、或 CPU 指令重排序优化导致指令的实际执行顺序与编写顺序不一致
  • 解决:用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
  • 注意:
    • volatile 变量写加的屏障是阻止上方其它写操作越过屏障排到 volatile 变量写之下 (前面的写指令老老实实先写完 (但是后面的可以跑到我前面去先写)) => 后写
    • volatile 变量读加的屏障是阻止下方其它读操作越过屏障排到 volatile 变量读之上(后面的读指令老老实实等我读完,再在我后面读(但是再我前面的读指令可以跑到我后面去读)) =》 先读
    • volatile 读写加入的屏障只能防止同一线程内的指令重排

在这里插入图片描述

所以volatile修饰得加在 先读 后写 的变量上
在这里插入图片描述
新手不建议使用volatile

代码说明

  • day02.threadsafe.AddAndSubtract 演示原子性
  • day02.threadsafe.ForeverLoop 演示可见性
    • 注意:本例经实践检验是编译器优化导致的可见性问题
  • day02.threadsafe.Reordering 演示有序性
    • 需要打成 jar 包后测试
  • 请同时参考视频讲解

6. 悲观锁 vs 乐观锁

要求

  • 掌握悲观锁和乐观锁的区别

对比悲观锁与乐观锁

  • 悲观锁的代表是 synchronized 和 Lock 锁

    • 其核心思想是【线程只有占有了锁,才能去操作共享变量,每次只有一个线程占锁成功,获取锁失败的线程,都得停下来等待】
    • 线程从运行到阻塞、再从阻塞到唤醒,涉及线程上下文切换,如果频繁发生,影响性能
    • 实际上,线程在获取 synchronized 和 Lock 锁时,如果锁已被占用,都会做几次重试操作,减少阻塞的机会
  • 乐观锁的代表是 AtomicInteger,使用 cas 来保证原子性

    • 其核心思想是【无需加锁,每次只有一个线程能成功修改共享变量,其它失败的线程不需要停止,不断重试直至成功】
    • 由于线程一直运行,不需要阻塞,因此不涉及线程上下文切换
    • 它需要多核 cpu 支持,且线程数不应超过 cpu 核数

乐观锁的典型是AtomicInteger, AtomicInteger的底层是Unsafe
在这里插入图片描述
底层的不断尝试就类似下面的代码原理
在这里插入图片描述
compareAndSetXXX => 简称 ==CAS ==
注意CAS只能保证原子性,不能保证可见性(JIT优化缓存字节码-》机器码)
可见性还是得由volatile来保证 所以即使cas操作共享变量,共享变量还是得用volatile来修饰
在这里插入图片描述

悲观锁和乐观锁是如何保证共享变量的线程安全的?
悲观锁:synchronized 保证了共享代码块一次只能一个线程进入,也就是整个代码块都是原子性的,当然对共享变量的访问就是线程安全的了。(简单来说,就是原子性阻止了指令的交错,牺牲了效率,保证了安全)
在这里插入图片描述
乐观锁,cas新旧值比较保证每次读相同值只能有一个线程修改成功,修改失败的线程不断重试,重新读取新值再操作,保证了安全。 CAS没有互斥或阻塞,指令可以随便交错,通过修改前比较仍然可以保证共享变量的正确性。
在这里插入图片描述

代码说明

  • day02.SyncVsCas 演示了分别使用乐观锁和悲观锁解决原子赋值
  • 请同时参考视频讲解

小结: 原子性 只能加锁解决:悲观锁,乐观锁都行
可见性:只能volatile解决,阻止编译优化指令重排导致的变量被修改了却不可见
有序性:也是只能volatile解决,阻止编译优化指令重排等导致指令的实际执行顺序与编写顺序不一致,出现意外的结果,但是volatile得加在 “先读、后写” 的共享变量上

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 集合 (他们的key和value都不能为空)
  • Hashtable 并发度低,整个 Hashtable 对应一把锁,同一时刻,只能有一个线程操作它(每次扩容n->2*n+1)
  • ConcurrentHashMap 并发度高,整个 ConcurrentHashMap 对应多把锁,只要线程访问的是不同锁,那么不会冲突(锁的数量就决定了并发度)

ConcurrentHashMap 1.7

  • 数据结构:Segment(大数组) + HashEntry(小数组) + 链表,每个 Segment 对应一把锁,如果多个线程访问不同的 Segment,则不会冲突

(每个大数组下面对应一个小数组,eg: 容量capacity=32, 大数组长度clevel=8, 那么每个大数组元素下面得有一个大小为capacity/clevel=32/8=4的小数组) (扩容因子扩容导致capacity增大,clevel不能变,最终还是导致小数组扩容 (小数组才是HashMap中对应的实际存储数组,小数组冲突了会拉链,会树化)
注意:可能 capacity<clevel 这个时候小数组容量取最小值2

  • 并发度:Segment 数组大小即并发度,决定了同一时刻最多能有多少个线程并发访问。Segment 数组不能扩容,意味着并发度在 ConcurrentHashMap 创建时就固定了
  • 索引计算
    • 假设大数组长度是 2 m 2^m 2m,key 在大数组内的索引是 key 的二次 hash 值的高 m 位 (计算hash(key)值然后取高m位即可,放到对应序号的大数组下)
    • 假设小数组长度是 2 n 2^n 2n,key 在小数组内的索引是 key 的二次 hash 值的低 n 位 (计算hash(key)值然后取低n位即可,放到小数组对应下标处)
  • 扩容:每个小数组的扩容相对独立(clevel(并发度)个小数组各扩各的),小数组在超过扩容因子时会触发扩容,每次扩容翻倍(每次扩容n->2*n)
  • Segment[0] 原型:首次创建其它小数组时,会以此原型为依据,数组长度,扩容因子都会以原型为准
    刚刚初始化时,只有Segment[0]下的小数组被创建好了,其他Segment下面没有小数组,是空的
    Segment[0]小数组也会正常存储值,也会扩容到很大,这时其他新的Segment哪怕只有一个值,也会创建和Segment[0]小数组一样大小的一个数组 (其实就是设计模式中的原型模式)

ConcurrentHashMap 1.8
(没有Segment大数组了,直接就是数组+链表/红黑树)

  • 数据结构:Node 数组 + 链表或红黑树,数组的每个头节点作为锁,如果多个线程访问的头节点不同,则不会冲突。首次生成头节点时如果发生竞争,利用 cas 而非 syncronized,进一步提升性能
  • 并发度:Node 数组有多大,并发度就有多大,与 1.7 不同,Node 数组可以扩容 (只要操作的是不同的链表头(也就非映射冲突的元素),就可以并发执行)
  • 扩容条件:Node 数组满 3/4 时就会扩容 (达到n*扩容因子就扩容 1.7是超过才扩容) (扩容是先创建新的2*n容量的新数组,然后重新计算hash将元素一一复制过去)
  • 扩容单位:以链表为单位从后向前迁移链表,迁移完成的将旧数组头节点替换为 ForwardingNode
  • 扩容时并发 get
    • 根据是否为 ForwardingNode(已经更新好的链头) 来决定是在新数组查找还是在旧数组查找,不会阻塞
    • 如果链表长度超过 1,则需要对节点进行复制(创建新节点),怕的是节点迁移后 next 指针改变
    • 如果链表最后几个元素扩容后索引不变,则节点无需复制 (next关系没变
  • 扩容时并发 put
    • 如果 put 的线程与扩容线程操作的链表是同一个,put 线程会阻塞
    • 如果 put 的线程操作的链表还未迁移完成,即头节点不是 ForwardingNode,则可以并发执行
    • 如果 put 的线程操作的链表已经迁移完成,即头结点是 ForwardingNode,则可以协助扩容 (put操作被阻塞,但这个线程也不会被闲着,你来帮我做一下迁移其他节点的迁移操作吧)
  • 与 1.7 相比是懒惰初始化 (1.7初始化Segment大数组,以及Segment[0]小数组就创建好了。1.8初始化后啥数组都没有创建,懒汉式)
  • capacity 代表预估的元素个数,capacity / factory 来计算出初始数组大小,需要向上贴近取 2 n 2^n 2n
  • loadFactor 只在计算初始数组大小时被使用,之后扩容因子固定为 3/4
  • 超过树化阈值时的扩容问题,如果容量已经是 64,直接树化,否则在原来容量基础上做 3 轮扩容

8. ThreadLocal

要求

  • 掌握 ThreadLocal 的作用与原理
  • 掌握 ThreadLocal 的内存释放时机

作用

  • ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】,避免争用引发的线程安全问题 (完全相反的思路解决线程安全问题,不共享了,各用各的资源。资源数量多了)
  • ThreadLocal 同时实现了线程内的资源共享 (一个线程内可能需要执行很多方法,可以实现方法局部变量在方法间的共享)

在这里插入图片描述
哪个线程执行tl.get()获取的本线程自己的局部变量区域,天然实现了线程间的隔离
然后只要是同一个线程,不管是哪部分代码,调用ThreadLocal()对象tl的get方法tl.get() 获取的都是本线程内唯一的局部变量区域,实现了线程内的资源共享。

原理

每个线程内有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象

  • 调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中

(这里ThreadLocal只是起了一个关联作用,作为公共的key, 所以ThreadLocal可以是同一个对象,表示同一类资源。 所以可以new 多个ThreadLocal作为多个共同的key, 就可以在当前线程(的ThreadLocalMap里)存储多类值了(ThreadLocalMap元素个数达到2/3也会扩容,因此也能存储很多值,其中初始容量为16))
至于hash值,第一个ThreadLocal的hash值为0,以后每来一个threadLocal.set(xx),hash值都会+1640531527,然后直接取余映射到ThreadLocalMap对应位置

  • 调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
  • 调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值

ThreadLocalMap 的一些特点

  • key 的 hash 值统一分配
  • 初始容量 16,扩容因子 2/3,扩容容量翻倍
  • key 索引冲突后用开放寻址法解决冲突 (ThreadLocalMap不用拉链法)(开放寻址法就是往后找下一个空闲的位置

弱引用 key

ThreadLocalMap 中的 key 被设计为弱引用,原因如下

  • Thread 可能需要长时间运行(如线程池中的线程),如果 key 不再使用,需要在内存不足(GC)时释放其占用的内存
    那些key确实不需要再用了,但是由于map里确实引用了它作为key,如果是强引用,就不会被GC释放,一直占用虚拟机内存。
    (垃圾回收可以释放弱引用,释放不了强引用。 设置为弱引用就是防止你自己忘记释放内存,垃圾回收时发现没有人再使用这些弱引用了(当一个对象不被任何变量引用,那么程序就无法再使用这个对象),就帮你自动回收了这些key对象,不会占用内存了)
    (但是值是强引用,不会被GC释放内存)

内存释放时机

  • 被动 GC 释放 key
    • 仅是让 key 的内存释放,关联 value 的内存并不会释放
  • 懒惰被动释放 value (不用专门去扫描一遍ThreadLocalMap而清理了,get/set时清理自己或者旁边的)
    • get key 时,发现是 null key,则释放其 value 内存 (和普通map不同,会给null key处填上一个key, 但是value置为null)
    • set key 时,会使用启发式扫描,清除临近的 null key 的 value 内存,启发次数与元素个数,是否发现 null key 有关 (set key时发现我这个区域key是null, 放入新key,value有旧值肯定会被清理,旁边的null key- 非null value 的value会被清理掉 这样不用专门扫描一遍ThreadLocalMap也能清理掉未释放的内存了)
  • 主动 remove 释放 key,value
    • 会同时释放 key,value 的内存,也会清除临近的 null key 的 value 内存
    • 推荐使用它,因为一般使用 ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收

内存泄露:垃圾回收器无法回收某部分内存,这种现象就叫做内存泄露;
value对象只有Entry一个引用,如果使用弱引用,则可能在方法栈2还未执行完成时就发生gc导致value被回收出现空指针异常。? 慢慢补~

9. 国企: 线程和进程的区别 (新版课程再补)

猜你喜欢

转载自blog.csdn.net/hza419763578/article/details/130556607