进程和线程的区别
-
进程是资源分配的最小单位,线程是CPU调度的最小单位。
-
一个进程可以包含多个线程。
-
进程要比线程消耗更多的计算机资源。
-
进程间不会相互影响,有独立的地址空间。
-
不同进程间数据很难共享,同一进程下不同线程间数据可以共享。
为什么要用多线程呢?你平时工作中用得多吗?
使用多线程最主要的原因是提高系统的资源利用率。
现在 CPU 基本都是多核的,如果只用单线程,那就是只用到了一个核心,其他的核心就相当于空闲在那里了。
在平时工作中多线程是随时都可见的。
比如说,我们系统 Web 服务器用的是 Tomcat,Tomcat 处理每一个请求都会从线程连接池里边用一个线程去处理。
连接数据库会用对应的连接池 Druid
在实际开发中用过多线程吗
- 比如说,保存操作日志需要一定时间,但与业务无关,这时我们会把保存日志的任务交给线程去处理,主线程直接返回结果。
- 比如说,现在要跑一个定时任务,该任务的链路执行时间和过程都非常长,我这边就用一个线程池将该定时任务的请求进行处理。这样做的好处就是可以及时返回结果给调用方,能够提高系统的吞吐量。
什么是线程安全
线程安全就是多个线程去执行某类,这个类始终能表现出正确的行为,那么这个类就是线程安全的。
比如有一个 count 变量,在 service 方法不断的累加这个 count 变量。假设相同的条件下,count 变量每次执行的结果都是相同,那我们就可以说是线程安全的。
你平时是怎么解决,或者怎么思考线程安全问题的呢?
- 保证操作的原子性,考虑 atomic 包下的类
- 保证操作的可见性,考虑 volatile 关键字
- 如果涉及到对线程的控制(比如一次能使用多少个线程,当前线程触发的条件是否依赖其他线程的结果),考虑 CountDownLatch / Semaphore 等等
- 如果是多线程操作集合,考虑 JUC 包下的集合类
- 如果需要加锁,考虑 synchronized 和 JUC 包下的锁工具类
死锁发生的四个必要条件是什么
- 互斥条件:一个资源只能被一个线程占用
- 占有且等待条件:线程当前持有至少一个资源并请求其他线程持有的其他资源
- 不可抢占条件:资源只能由持有它的线程自愿释放,其他线程不可强行占有该资源
- 循环等待条件:线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源
怎么避免死锁
避免死锁的方式就是破坏四个必要条件中的任意一个即可。
-
破坏互斥条件:
- 使用 final 和 ThreadLocal,CAS 等无锁方式。
-
破坏占有且等待条件:
- 线程运行前申请全部资源,满足则运行,不然就等待下一次申请资源
-
破坏不可抢占条件
- 占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
-
破坏循环等待条件:
- 将所有资源统一编号,所有线程只能采用按序号递增的形式申请资源。
如何检测死锁
当程序出现了死锁现象,我们可以使用 JDK 自带的工具:
jps
:输出 JVM 中运行的进程状态信息。jstack
:查看 Java 进程内线程的堆栈信息,查看日志,检查是否有死锁。- 如果有死锁现象,需要查看具体代码分析,然后修复。
- 可视化工具
jconsole
、VisualVM
也可以检查死锁问题。
创建线程有几种实现方式
- 继承 Thread 类,重写 run();
- 实现 Runnable 接口,实现 run();
- 实现 Callable 接口,实现 run()。 其中,Thread 其实也是实现了 Runable 接口。Runnable 和 Callable 的主要区别在于是否有返回值。
Thread 调用 start() 方法和调用 run() 方法的区别
- run():普通的方法调用,在主线程中执行,不会新建一个线程来执行。
- start():新启动一个线程,这时此线程处于就绪(可运行)状态,并没有运行,一旦得到 CPU 时间片,就开始执行 run() 方法。
线程的生命周期
线程的生命周期
- 新建 (NEW):新建但没有调用 start() 方法的线程处于此状态。
- 运行 (RUNNABLE):包含就绪(READY)和运行中(RUNNING)两种状态。线程调用 start() 方法会进入就绪(READY)状态,等待获取 CPU 时间片。如果成功获取到 CPU 时间片,则会进入运行中(RUNNING)状态。
- 阻塞 (BLOCKED):线程在进入同步方法 / 同步块(synchronized)时被阻塞,等待同步锁的线程处于此状态。
- 等待 (WAITING):线程无限期等待另一个线程执行特定操作,需要被显式唤醒,否则会一直等待下去。例如对于 Object.wait(),需要等待另一个线程执行 Object.notify() 或 Object.notifyAll();对于 Thread.join(),则需要等待指定的线程终止。
- 超时等待 (TIMED_WAITING):跟 WAITING 类似,区别在于该状态有超时时间参数,在超时时间到了后会自动唤醒,避免了无期限的等待。
- 终止 (TERMINATED):表示该线程已经执行完毕。
- 线程在给定的时间点只能处于一种状态。这些状态是虚拟机状态,不反映任何操作系统线程状态。
指向原始笔记的链接
线程的 join() 有什么用
- 用于控制线程的执行顺序。
- 当 A 线程调用 B 线程的
join()
方法时,A 线程(当前线程)会暂停执行,直到被 B 线程(目标线程)完全执行完毕。
线程的 yield() 有什么用
- 礼让线程
- 当一个线程调用
yield()
方法时,它表明愿意放弃当前的 CPU 时间片,让调度器有机会选择另一个具有相同优先级的线程来执行。 - 使用
yield()
需要注意以下几点:- 效果不确定:
yield()
方法的效果是不确定的,调用它不一定能立即引起线程切换,甚至可能当前线程立即又被调度器选中继续执行。 - 避免过度使用: 过度使用
yield()
可能会导致线程调度的混乱,降低程序的性能。 - 仅适用于相同优先级的线程:
yield()
只会让出 CPU 给相同优先级的线程,对于更高或更低优先级的线程没有影响。
- 效果不确定:
wait() 和 sleep() 的区别
-
共同点:wait(),wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态。
-
来源不同:sleep() 来自 Thread 类,wait() 来自 Object 类。
-
对于同步锁的影响不同:如果当前线程持有同步锁,那么 sleep 是不会让线程释放同步锁的。wait() 会释放同步锁,让其他线程进入 synchronized 代码块执行。
-
使用范围不同:sleep() 可以在任何地方使用。wait() 只能在同步控制方法或者同步控制块里面使用,否则会抛 IllegalMonitorStateException。
-
线程的恢复方式不同:sleep() 在时间到了之后会重新恢复;wait() 则需要其他线程调用同一对象的 notify()/nofityAll() 才能重新恢复。
sleep() 和 yield() 的区别
-
线程执行 sleep() 方法后进入超时等待(TIMED_WAITING)状态,而执行 yield() 方法后进入就绪(READY)状态
-
sleep() 方法让出执行权时不会考虑线程的优先级,因此会给低优先级的线程运行的机会
-
yield() 方法只会给相同优先级或更高优先级的线程以运行的机会
-
sleep() 声明抛出 InterruptedException 异常,而 yield() 方法没有声明任何异常
-
sleep() 方法比 yield() 方法具有更好的可移植性(跟操作系统 CPU 调度相关)
什么是 CAS
CAS
指向原始笔记的链接
- CAS 是英文单词 CompareAndSwap 的缩写,中文意思是:比较并替换。
- CAS 操作包含三个操作数
- 内存值(V)
- 旧的预期值(A)
- 新值(B)
- 如果内存值等于原值,CAS 通过原子方式用新值来更新内存值 ,否则不会执行任何操作。
- 一般情况下,“更新”是一个不断重试的操作(自旋)
- Java 的原子类内部使用了 CAS 操作来实现线程安全的更新。
- CAS 的底层是调用的 Unsafe 类中的方法,都是操作系统提供的,由其他语言实现。
CAS 有什么缺点
什么是 synchronized
-
synchronized 是 Java 的一个关键字,它能够将代码块 / 方法锁起来
-
synchronized 是一种互斥锁,一次只能允许一个线程进入被锁住的代码块
-
如果 synchronized 修饰的是非静态方法,对应的锁则是对象实例
-
如果 synchronized 修饰的是静态方法,对应的锁则是当前类的 Class 实例
-
如果 synchronized 修饰的是代码块,对应的锁则是传入 synchronized 的对象实例
synchronized 和 Lock 的区别
synchronized 的底层原理
-
synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
-
它的底层由 Monitor 实现的,Monitor 是 JVM 级别的对象( C++ 实现),线程获得锁需要使用对象(锁)关联 Monitor
-
在 Monitor 内部有三个属性,分别是 Owner、EntryList、WaitSet
- Owner:存储当前获取锁的线程的,只能有一个线程可以获取
- EntryList:关联没有抢到锁的线程,处于 Blocked 状态的线程
- WaitSet:关联调用了 wait 方法的线程,处于 Waiting 状态的线程
-
具体的流程:
- 线程进入 synchorized 代码块,先找 lock(对象锁)关联的 Monitor,然后判断 Owner 是否有线程持有
- 如果没有线程持有,则让当前线程持有,表示该线程获取锁成功
- 如果有线程持有,则让当前线程进入 EntryList 进行阻塞,如果 Owner 持有的线程已经释放了锁,在 EntryList 中的线程去竞争锁的持有权(非公平)
- 如果代码块中调用了 wait() 方法,则会进去 WaitSet 中进行等待
synchronized 锁在 JDK 1.6 之后做了很多的优化,这块你了解多少呢?
在 HotSpot 虚拟机中,对象在内存中存储的布局可分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充
在 JDK 1.6 之前, synchronized 是重量级锁,线程进入同步代码块 / 方法时
Monitor 对象会存储当前进入线程的 id,设置 Mark Word 的 Monitor 对象地址(ptr_to_heavyweight_monitor),并把阻塞的线程存储到 Monitor 的等待线程队列中
它加锁是依赖底层操作系统的 mutex
相关指令实现,所以会有用户态和内核态之间的切换,性能损耗十分明显
JDK1.6 以后引入偏向锁和轻量级锁,在 JVM 层面实现加锁的逻辑,不依赖底层操作系统,就没有切换的消耗
所以,Mark Word 对锁的状态记录一共有 4 种:无锁、偏向锁、轻量级锁和重量级锁
- hashcode:25 位的对象标识 Hash 码
- age:对象分代年龄占 4 位
- biased_lock:偏向锁标识,占 1 位 ,0 表示没有开始偏向锁,1 表示开启了偏向锁
- thread:持有偏向锁的线程 ID,占 23 位
- epoch:偏向时间戳,占 2 位
- ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针,占 30 位
- ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器 Monitor 的指针,占 30 位
简单说说偏向锁、轻量级锁和重量级锁吧(synchronized 锁升级过程)
-
锁升级: JDK 1.6 引入了锁升级机制,锁可以经历从无锁状态到偏向锁、轻量级锁,最后到重量级锁的过程。这种机制允许锁在竞争较低的情况下保持轻量级,从而减少锁的开销。锁升级的过程是单向的,只有升级,没有降级。
-
偏向锁(Biased Locking): 当一个线程首次访问一个同步块时,如果该同步块没有被锁定,JVM 会将对象的锁标记为偏向锁,通过 CAS 指令将线程 ID 记录在对象头的 Mark Word 中。之后该线程再获取锁,只需要判断 Mark Word 中是否是自己的线程 ID 即可,而不是开销相对较大的 CAS 命令。
-
轻量级锁(Lightweight Locking): 当有第二个线程尝试获取锁时,如果锁是偏向锁,则锁将升级为轻量级锁。轻量级锁使用线程的本地栈上的锁记录(Lock Record)与对象的 Mark Word 进行 CAS 操作,尝试获取锁。如果 CAS 成功,锁被获取;否则,线程将自旋等待,直到锁被释放。
-
重量级锁(Heavyweight Locking): 当轻量级锁无法获取(例如,已经有其他线程持有锁,或者自旋次数超过阈值),锁将升级为重量级锁。重量级锁使用操作系统级别的互斥锁(Mutex)来实现,这意味着线程需要挂起和唤醒,重量级锁的开销通常比轻量级锁和偏向锁更大。
Reference
什么是公平锁和非公平锁
-
公平锁:谁等的时间最长,谁就先获取锁
-
非公平锁:CPU 随机分配
怎么实现公平和非公平锁
-
公平锁可以把竞争的线程放在一个先进先出的队列上,只要持有锁的线程执行完,唤醒队列的下一个线程去获取锁就好了
-
非公平锁实现也很简单,线程先尝试能不能获取得到锁,如果获取得到锁了就执行同步代码了。如果获取不到锁,再把线程放到队列里
-
总结:如果会尝试获取锁,那就是非公平的。如果不会尝试获取锁,直接进队列,再等待唤醒,那就是公平的
synchronized 锁是公平的还是非公平的?
-
非公平的。
-
偏向锁很好理解,如果当前线程 ID 与 markword 存储的不相等,则 CAS 尝试更换线程 ID,CAS 成功就获取得到锁了,CAS 失败则升级为轻量级锁
-
轻量级锁实际上也是通过 CAS 来抢占锁资源(只不过多了拷贝 Mark Word 到 Lock Record 的过程),抢占成功到锁就归属给该线程了,但自旋失败一定次数后升级重量级锁
-
重量级锁通过 monitor 对象中的队列存储线程,但线程进入队列前,还是会先尝试获取得到锁,如果获取不到才进入线程等待队列中
-
综上所述,synchronized 无论处理哪种锁,都是先尝试获取,获取不到才升级或放到队列上的,所以是非公平的
JMM(Java 内存模型)
什么是 AQS?
-
AQS 全称叫做 AbstractQueuedSynchronizer,是一个抽象类,它提供了实现锁和其他同步器的基础框架,主要用于构建各种同步工具类,如
ReentrantLock
、Semaphore
、CountDownLatch
等 -
AQS 内部维护了一个先进先出的队列以及 state 状态变量
-
简单理解就是:AQS 定义了模板,具体实现由各个子类完成
-
总体的流程:
- 把需要等待的线程以 Node 的形式放到这个先进先出的队列上,state 变量则表示为当前锁的状态
- 当 statte = 0 为无锁状态。state > 0 为有锁状态,每次加锁就在原有 state 基础上使用 CAS 加 1,即代表当前持有锁的线程加了 state 次锁,反之解锁时每次减 1
-
AQS 支持独占锁(锁只会被一个线程独占)和共享锁(多个线程可同时执行)
ReentrantLock 的加锁过程
以非公平锁为例,我们在外界调用 lock 方法的时候,源码是这样实现的
-
CAS 尝试获取锁,获取成功则可以执行同步代码
-
CAS 获取失败,则调用 acquire 方法,acquire 方法就是 AQS 的模板方法
-
acquire 首先会调用子类的 tryAcquire 方法(又回到了 ReentrantLock 中)
-
tryAcquire 方法会判断当前的 state 是否等于 0,等于 0 说明没有线程持有锁,则又尝试 CAS 直接获取锁
-
如果 CAS 获取成功,则可以执行同步代码
-
如果 CAS 获取失败,就判断当前线程是否就持有锁,如果是持有的锁,那更新 state 的值,获取得到锁(这里其实就是处理可重入的逻辑)
-
CAS 失败 && 非重入的情况,则回到 tryAcquire 方法执行「入队列」的操作
-
将节点入队列之后,会判断「前驱节点」是不是头节点,如果是头结点又会用 CAS 尝试获取锁
-
如果是「前驱节点」是头节点并获取得到锁,则把当前节点设置为头结点,并且将前驱节点置空(实际上就是原有的头节点已经释放锁了)
-
没获取得到锁,则判断前驱节点的状态是否为 SIGNAL,如果不是,则找到合法的前驱节点,并使用 CAS 将状态设置为 SIGNAL
-
最后调用 park 将当前线程挂起
总结:当线程 CAS 获取锁失败,将当前线程入队列,把前驱节点状态设置为 SIGNAL 状态,并将自己挂起。
ReentrantLock 的解锁过程
-
外界调用 unlock 方法时,实际上会调用 AQS 的 release 方法,而 release 方法会调用子类 tryRelease 方法(又回到了 ReentrantLock 中)
-
tryRelease 会把 state 一直减(锁重入可使 state>1),直至到 0,当前线程说明已经把锁释放了
-
随后从队尾往前找节点状态需要 < 0,并离头节点最近的节点进行唤醒
解锁的逻辑非常简单,把 state 置 0,唤醒头结点下一个合法的节点,被唤醒的节点线程自然就会去获取锁
为什么要使用线程池?直接 new 个线程不是很舒服?
如果我们在方法中直接 new 一个线程来处理,当这个方法被调用频繁时就会创建很多线程,不仅会消耗系统资源,还会降低系统的稳定性,一不小心把系统搞崩了。
如果我们合理的使用线程池,则可以避免把系统搞崩的窘境。总得来说,使用线程池可以带来以下几个好处:
-
降低资源消耗。通过重复利用已创建的线程,降低线程创建和销毁造成的消耗。
-
提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
-
增加线程的可管理性。线程是稀缺资源,使用线程池可以进行统一分配,调优和监控。
线程池(ThreadPoolExecutor)的核心属性有哪些?
-
threadFactory(线程工厂):用于创建工作线程的工厂。
-
corePoolSize(核心线程数):当线程池运行的线程少于 corePoolSize 时,将创建一个新线程来处理请求,即使其他工作线程处于空闲状态。
-
workQueue(阻塞队列):用于保留任务并移交给工作线程的阻塞队列。
-
maximumPoolSize(最大线程数):线程池允许开启的最大线程数。
-
handler(拒绝策略):往线程池添加任务时,将在下面两种情况触发拒绝策略:
- 线程池运行状态不是 RUNNING;
- 线程池已经达到最大线程数,并且阻塞队列已满时。
-
keepAliveTime(保持存活时间):如果线程池当前线程数超过 corePoolSize,则多余的线程空闲时间超过 keepAliveTime 时会被终止。
线程池的运作流程
- 判断是否达到核心线程数,若未达到,则直接创建新的线程处理当前传入的任务,否则进入下个流程
- 线程池中的工作队列是否已满,若未满,则将任务丢入工作队列中先存着等待处理,否则进入下个流程
- 是否达到最大线程数,若未达到,则创建新的线程处理当前传入的任务,否则交给线程池中的饱和策略进行处理
线程池中的各个状态分别代表什么含义?
线程池目前有 5 个状态:
-
RUNNING:接受新任务并处理排队的任务。
-
SHUTDOWN:不接受新任务,但处理排队的任务。
-
STOP:不接受新任务,不处理排队的任务,并中断正在进行的任务。
-
TIDYING:所有任务都已终止,workerCount 为零,线程转换到 TIDYING 状态将运行 terminated() 钩子方法。
-
TERMINATED:terminated() 已完成。
这几个状态之间是怎么流转
线程池有哪些队列?
常见的阻塞队列有以下几种:
-
ArrayBlockingQueue:基于数组结构的有界阻塞队列,按先进先出对元素进行排序。
-
LinkedBlockingQueue:基于链表结构的有界/无界阻塞队列,按先进先出对元素进行排序,吞吐量通常高于 ArrayBlockingQueue。Executors.newFixedThreadPool 使用了该队列。
-
为了防止 LinkedBlockingQueue 容量迅速增大,损耗大量内存。通常在创建 LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于 Integer.MAX_VALUE
-
SynchronousQueue:一个不存储元素的阻塞队列,每个插入操作必须等待另外一个线程调用移除操作,否则插入操作一直处理阻塞状态,吞吐量通常要高于 LinkedBlockingQueue,Executors.newCachedThreadPool 使用这个队列。
-
PriorityBlockingQueue:具有优先级的无界队列,按优先级对元素进行排序。元素的优先级是通过自然顺序或 Comparator 来定义的。
使用队列有什么需要注意的吗?
- 使用有界队列时,需要注意线程池满了后,被拒绝的任务如何处理。
- 使用无界队列时,需要注意如果任务的提交速度大于线程池的处理速度,可能会导致内存溢出。
线程池有哪些拒绝策略
- AbortPolicy:中止策略。默认的拒绝策略,直接抛出 RejectedExecutionException。调用者可以捕获这个异常,然后根据需求编写处理代码。
- DiscardPolicy:什么都不做,直接抛弃被拒绝的任务。
- DiscardOldestPolicy:抛弃阻塞队列中最老的任务,相当于丢弃队列中的头结点,然后重新提交被拒绝的任务。如果阻塞队列是一个优先队列,那么 “抛弃最旧的” 策略将导致抛弃优先级最高的任务,因此最好不要将该策略和优先级队列放在一起使用。
- CallerRunsPolicy:把任务交给提交任务的线程执行,也就是谁提交任务,谁就负责执行任务。由于执行任务需要一定时间,因此线程至少在一段时间内不能提交任务,从而使得线程池有时间来处理完正在执行的任务。
线程只能在任务到达时才启动吗?
默认情况下,即使是核心线程也只能在新任务到达时才创建和启动。但是我们可以使用 prestartCoreThread(启动一个核心线程)或 prestartAllCoreThreads(启动全部核心线程)方法来提前启动核心线程。
核心线程怎么实现一直存活?
阻塞队列方法有四种形式,它们以不同的方式处理操作,如下表。
核心线程在获取任务时,通过阻塞队列的 take() 方法实现的一直阻塞(存活)。
非核心线程如何实现在 keepAliveTime 后死亡?
在获取任务时通过阻塞队列的 poll(time,unit) 方法实现的在延迟死亡。
非核心线程能成为核心线程吗?
其实线程池内部是不区分核心线程和非核心线程的,只是根据当前线程池的工作线程数来进行调整,因此看起来像是有核心线程于非核心线程。
如何终止线程池?
终止线程池主要有两种方式:
-
shutdown:“温柔”的关闭线程池。不接受新任务,但是在关闭前会将之前提交的任务处理完毕。
-
shutdownNow:“粗暴”的关闭线程池,也就是直接关闭线程池,通过 Thread#interrupt() 方法终止所有线程,不会等待之前提交的任务执行完毕。但是会返回队列中未处理的任务。
Executors 提供了哪些创建线程池的方法?
-
newFixedThreadPool:固定线程数的线程池。核心线程数 = 最大线程数,keepAliveTime 为 0,工作队列使用无界的 LinkedBlockingQueue。
-
newSingleThreadExecutor:只有一个线程的线程池。核心线程数 = 最大线程数 = 1,keepAliveTime 为 0, 工作队列使用无界的 LinkedBlockingQueue。适用于需要保证顺序的执行各个任务的场景。
-
newCachedThreadPool: 按需要创建新线程的线程池。核心线程数为 0,最大线程数为 Integer.MAX_VALUE,keepAliveTime 为 60 秒,工作队列使用同步移交 SynchronousQueue。该线程池可以无限扩展,当需求增加时,可以添加新的线程,而当需求降低时会自动回收空闲线程。适用于执行很多的短期异步任务,或者是负载较轻的服务器。
-
newScheduledThreadPool:创建一个以延迟或定时的方式来执行任务的线程池,工作队列为 DelayedWorkQueue。适用于需要多个后台线程执行周期任务。
-
newWorkStealingPool:JDK 1.8 新增,用于创建一个可以窃取的线程池,底层使用 ForkJoinPool 实现。
阿里巴巴开发手册建议不要使用 Executors 去创建线程,而是使用 ThreadPoolExecutor 创建的线程,这样能让开发者了解线程池运行的规则,避免资源耗尽的风险。
线程池里有个 ctl,你知道它是如何设计的吗?
ctl 是一个打包两个概念字段的原子整数。
- workerCount:指示线程的有效数量;
- runState:指示线程池的运行状态,有 RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED 等状态。
int 类型有 32 位,其中 ctl 的低 29 为用于表示 workerCount,高 3 位用于表示 runState
ctl 为什么这么设计?有什么好处吗?
-
好处是将对 runState 和 workerCount 的操作封装成了一个原子操作。
-
runState 和 workerCount 是线程池正常运转中的 2 个最重要属性,线程池在某一时刻该做什么操作,取决于这 2 个属性的值。
-
因此无论是查询还是修改,我们必须保证对这 2 个属性的操作是原子操作,否则就会出现错乱的情况。如果我们使用 2 个变量来分别存储,要保证原子性则需要额外进行加锁操作,这显然会带来额外的开销,而将这 2 个变量封装成 1 个 AtomicInteger 则不会带来额外的加锁开销,而且只需使用简单的位操作就能分别得到 runState 和 workerCount。
线程池的大小配置多少合适
-
首先我们需要区分任务是计算密集型还是 I/O 密集型
-
对于计算密集型,设置线程数 = CPU 数 + 1,通常能实现最优的利用率
-
对于 I/O 密集型,网上常见的说法是设置线程数 = CPU 数 * 2
-
这只是一个常见的经验做法,具体究竟开多少线程,需要压测才能比较准确地定下来
ThreadLocal 是什么
ThreadLocal 提供了线程的局部变量,每个线程都可以通过 set/get 来对这个局部变量进行操作。不会和其他线程的局部变量进行冲突,实现了线程的数据隔离。
你在工作中有用到过 ThreadLocal 吗
在工作中 ThreadLocal 的应用场景确实不多,我讲讲 Spring 是怎么用的吧
Spring 提供了事务相关的操作,而我们知道事务是得保证一组操作同时成功或失败
这意味着我们一次事务的所有操作需要在同一个数据库连接上
Spring 就是用的 ThreadLocal 来实现,ThreadLocal 存储的类型是一个 Map
Map 中的 key 是 DataSource,value 是 Connection(为了应对多数据源的情况,所以是一个 Map)
用了 ThreadLocal 保证了同一个线程获取一个 Connection 对象,从而保证一次事务的所有操作需要在同一个数据库连接上
ThreadLocal 的内部实现
- ThreadLocal 本身不存数据,真正存储数据的是 ThreadLocal 里的静态内部类:ThreadLocalMap
- ThreadLocalMap 是默认权限,所以不能直接 new 这个类
- Thread 中有个成员变量 threadLocals 可以指向 ThreadLocalMap
- 可以把 ThreadLocal 看成是一个工具箱,里面提供了一系列操作 ThreadLocalMap 的方法:get、set、remove…
为什么用 ThreadLocal 作为 key,而不用 Thread
如果用 Thread 作为 key 的话,那线程私有的 ThreadLocalMap 就只能存一个键值对,就不能满足想要存多个变量的需求了。
你知道 ThreadLocal 内存泄露这个知识点吗?
ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,当 ThreadLocal 变量被手动设置为 null,即一个 ThreadLocal 没有外部强引用来引用它,当系统 GC 时,ThreadLocal 一定会被回收。
如果创建 ThreadLocal 的线程一直持续运行(线程被复用),那么这个 Entry 对象中的 value 就有可能一直得不到回收,发生内存泄露。
解决办法:在使用的最后用 remove() 把值清空就好了
冷知识:内存泄露就是申请完内存后,用完了但没有释放掉,你自己没法用,系统又没法回收
那为什么 ThreadLocalMap 的 key 要设计成弱引用?
- 如果 Key 使用强引用:当 ThreadLocal 的对象被回收了,但是 ThreadLocalMap 还持有 ThreadLocal 的强引用的话,如果没有手动删除,ThreadLocal 就不会被回收,会出现 Entry 的内存泄漏问题。
- 如果 Key 使用弱引用:当 ThreadLocal 的对象被回收了,因为 ThreadLocalMap 持有 ThreadLocal 的弱引用,即使没有手动删除,ThreadLocal 也会被回收。value 则在下一次 ThreadLocalMap 调用 set,get,remove 的时候会被清除。
现在我有 50 个任务,这 50 个任务在完成之后,才能执行下一个函数,要是你,你怎么设计?
- 创建 CountDownLatch 对象,计数器设为 50
- 让子线程去完成这 50 个任务,每完成一个任务,子线程调用 countDown() 方法,让计数器减 1
- 主线程调用 await() 方法,会被阻塞,直到计数器=0 时,await() 方法才会返回
CountDownLatch 与 CyclicBarrier 的区别
- CountDownLatch 允许一个或多个线程一直等待,直到这些线程完成它们的操作
- CyclicBarrier 是当线程到达某状态后,暂停下来等待其他线程,等到所有线程均到达以后,才继续执行
- CountDownLatch 调用 await() 通常是主线程 / 调用线程,而 CyclicBarrier 调用 await() 是在任务线程调用的
- CountDownLatch 是不能复用的,而 CyclicBarrier 是可以复用的
CountDownLatch 是基于 AQS 实现的,当我们在构建 CountDownLatch 对象时,传入的值会赋给 AQS 的关键变量 state
执行 countDown 方法时,其实就是利用 CAS 将 state 减一
执行 await 方法时,其实就是判断 state 是否为 0,不为 0 则加入到队列中,将该线程阻塞掉(除了头结点)
因为头节点会一直自旋等待 state 为 0,当 state 为 0 时,头节点把剩余的在队列中阻塞的节点也一并唤醒
CyclicBarrier 也是基于 AQS 实现的,它没有使用 AQS 的 state 变量,而是借助 ReentrantLock 加上 Condition 等待唤醒的功能实现的
在创建 CyclicBarrier 时,传入的值会赋值给 CyclicBarrier 内部维护 count 变量,也会赋值给 parties 变量(这是可以复用的关键)
每次调用 await 时,会将 count -1 ,操作 count 值时使用 ReentrantLock 来保证线程安全性
如果 count 不为 0,则添加到 condition 队列中
如果 count 等于 0 时,则把节点从 condition 队列添加至 AQS 的队列中进行全部唤醒,并且将 parties 的值重新赋值为 count 的值(实现复用)
简单总结下: CountDownlatch 基于 AQS 实现,会将构造 CountDownLatch 的入参传递至 state,countDown() 就是在利用 CAS 将 state 减 - 1,await() 实际就是让头节点一直在等待 state 为 0 时,释放所有等待的线程
而 CyclicBarrier 则利用 ReentrantLock 和 Condition,自身维护了 count 和 parties 变量。每次调用 await 将 count-1,并将线程加入到 condition 队列上。等到 count 为 0 时,则将 condition 队列的节点移交至 AQS 队列,并全部释放。
Reference