多线程体系总结
前言
提示:本文章主要描述多线程体系:
作者在学习java知识的过程中接收很多的碎片知识,由于知识面不够宽广,导致很多知识点混淆,同时也是由于知识点杂乱导致无法深刻记忆,所以打算写一篇博客对实现点进行一个面试总结。有一些点总结的不是很好,希望各位大神批评指正。
一、线程基础
1. 什么事线程?什么是进程?
进程:进程是在系统中能独立运行并作为资源分配的基本单位。
线程: 线程是进程的一个实体,作为系统调度和分配的基本单位。
关联关系:线程是进程的集合。线程是每个进程的一条执行路径。
2.线程创建的基本方式
- 继承Thread类实现run方法
- 实现Runnbale接口实现run方法
- 实现Callable实现call方法
延伸:这三种实现方式本质都是Runnable,其实是一种。因为继承Thread,间接实现了Runnable,实现Callable,需要FutureTask做封装,在启动线程时,依然是执行的FutureTask实现Runnable时重写的run方法,在run方法内部,执行的Callable的call方法。
3.线程的生命周期
Java中的Thread类里有枚举,规定了,只有6种 new、runnable、blocked、waiting、time_waiting、terminated。
延伸:新建线程时线程处于new状态,当线程调用start()方法时,会被线程调度器来执行。此时线程处于runnbale状态。其中runnable状态包含两个状态:Ready就绪状态,Runnable运行状态。就绪状态就是丢到等待队列中等待被cpu执行,真正被cpu执行时才是runnbale状态。同时在runnbale状态还有一些状态上的变迁,例如:wait\time_wait\blocked等状态。变迁方式调用线程的基本方法例如wait(),jonin(),sleep(),notify(),notifyall(),interrupt()原理在锁的实现中,提供了锁池、和等待池,对线程进行状态控制,注意在阻塞的情况下,线程处于pack()挂起状态
4.线程基本方法
- join实现原理
Join方法本质是基于synchronized以及wait和notify实现的。直接针对当前线程对象加锁,然后wait挂起线程,wait判断的逻辑是t1线程调用isAlive()方法的返回值存活waiting,未存活就代表凉了不用挂起,被唤醒。 - wait和notify
wait和notify是在持有synchronized锁时,- wait方法是让持有锁的线程释放锁资源,并且挂起。
- notify方法是让持有锁的线程,去唤醒之前执行wait方法挂起的线程,让被唤醒的线程抢锁。
至于为何要在持有synchronized时,才能执行wait和notify,是因为在调整线程存放的队列时,需要持有当前synchronized锁里面的ObjectMonitor,没持有,不让操作。并且执行wait需要释放锁资源,你没持有锁资源,无法进行释放
- stop停止当前线程(不推荐)
stop方法,会直接强制停止线程,不让执行。 太过暴力,没有原子性。会导致线程安全问题 - interrupt打断当前线程(推荐)
interrupt只是将线程的中断标记未从默认的false,改为了true。
5.多线程的特性
- 原子性
在java.util包下提供了很多的原子类 大致分为 基本类型,引用,属性,数组
基本数据类型:AtomicInteger、AtomicBoolean,AtomicLong
引用:AtomicReference
属性:AtomicIntegerFiedUpdater
数组:AtomicArray,AtomicLongArray
- 可见性 (volatile)
- 有序性 (锁)
6.如何避免死锁
预防死锁的四个条件:
互斥、不可剥夺、请求与保持、等待循环
方案:
11. 打破一个就可以预防死锁
12. 加锁时间限制: 尝试获取锁时加入时间限制,但是只能通过lock锁实现
7.什么是锁
对象就是锁,监视器monitor中会有记录锁的相关信息例如hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等
8.wait为什么是object下的方法
执行wait方法需要持有sync锁。sync锁可以是任意对象。同时执行wait方法是在持有sync锁的时候,释放锁资源。其次wait方法需要去操作ObjectMonitor,而操作ObjectMonitor就必须要在持有锁资源的前提的才能操作,将当前线程扔到WaitSet等待池中。
同理,notify方法需要将WaitSet等待池中线程扔到EntryList,如果不拥有ObjectMonitor,怎么操作!
9.强软弱虚引用
强引用:User xx = new User(); xx就是强引用,只要引用还在,GC就不会回收!
软引用:用一个SofeReference引用的对象,就是软引用,如果内存空间不足,才会回收只有软引用指向对象。 一般用于做缓存。
弱引用:WeakReference引用的对象,一般就是弱引用,只要执行GC,就会回收只有弱引用指向的对象
虚引用:PhantomReference引用的对象,就是虚引用,拿不到虚引用指向的对象,一般监听GC回收阶段,或者是回收堆外内存时使用。
线程高级应用
1.线程锁
Synchronized
- 对象头(同步监视器)
Entry Set:(锁池): 保存等待获取对象锁的所有线程
Wait Set:(等待池): 保存执行了objectX.wait()/wait(long)的状态为WAITTING的所有线程
enter:进入锁池
acquire: 获取到锁 - 锁升级
无锁(匿名偏向)、偏向锁、轻量级锁、重量级锁
无锁(匿名偏向): 一般情况下,new出来的一个对象,是无锁状态。因为偏向锁有延迟,在启动JVM的4s中,不存在偏向锁,但是如果关闭了偏向锁延迟的设置,new出来的对象,就是匿名偏向。
偏向锁: 当某一个线程来获取这个锁资源时,此时,就会变为偏向锁,偏向锁存储线程的ID
当偏向锁升级时,会触发偏向锁撤销,偏向锁撤销需要等到一个安全点,比如GC的时候,偏向锁撤销的成本太高,所以默认开始时,会做偏向锁延迟。
安全点:
GC
方法返回之前
调用某个方法之后
甩异常的位置
循环的末尾
轻量级锁: 当在出现了多个线程的竞争,就要升级为轻量级锁(有可能直接从无锁变为轻量级锁,也有可能从偏向锁升级为轻量级锁),轻量级锁的效果就是基于CAS尝试获取锁资源,这里会用到自适应自旋锁,根据上次CAS成功与否,决定这次自旋多少次。
重量级锁: 如果到了重量级锁,那就没啥说的了,如果有线程持有锁,其他竞争的,就挂起。
lock(aqs)
1. ReentrantLock
ReentrantLock是基于AQS实现的。
在线程基于ReentrantLock加锁时,需要基于CAS去修改state属性,如果能从0改为1,代表获取锁资源成功
如果CAS失败了,添加到AQS的双向链表中排队(可能会挂起线程),等待获取锁。
持有锁的线程,如果执行了condition的await方法,线程会封装为Node添加到Condition的单向链表中,等待被唤醒并且重新竞争锁资源
Java中除了一会讲到的线程池中Worker的锁之外,都是可重入锁。
2. ReentrantReadWriteLock
ReentrantReadWriteLock也是基于AQS实现的一个读写锁,但是锁资源用state标识。
如何基于一个int来标识两个锁信息,有写锁,有读锁,怎么做的?
一个int,占了32个bit位。
在写锁获取锁时,基于CAS修改state的低16位的值。
在读锁获取锁时,基于CAS修改state的高16位的值。
写锁的重入,基于state低16直接标识,因为写锁是互斥的。
读锁的重入,无法基于state的高16位去标识,因为读锁是共享的,可以多个线程同时持有。所以读锁的重入用的是ThreadLocal来表示,同时也会对state的高16为进行追加。
3. aqs是什么
AQS就是一个抽象队列同步器,abstract queued sychronizer,本质就是一个抽象类。AQS中有一个核心属性state,其次还有一个双向链表以及一个单项链表。首先state是基于volatile修饰,再基于CAS修改,同时可以保证三大特性。(原子,可见,有序)其次还提供了一个双向链表。有Node对象组成的双向链表。最后在Condition内部类中,还提供了一个由Node对象组成的单向链表。AQS是JUC下大量工具的基础类,很多工具都基于AQS实现的,比如lock锁,CountDownLatch,Semaphore,线程池等等都用到了AQS。
state是啥:state就是一个int类型的数值,同步状态,至于到底是什么状态,看子类实现。condition和单向链表是啥:都知道sync内部提供了wait方法和notify方法的使用,lock锁也需要实现这种机制,lock锁就基于AQS内部的Condition实现了await和signal方法。(对标sync的wait和notify)
sync在线程持有锁时,执行wait方法,会将线程扔到WaitSet等待池中排队,等待唤醒
lcok在线程持有锁时,执行await方法,会将线程封装为Node对象,扔到Condition单向链表中,等待唤醒
Condition在做了什么:将持有锁的线程封装为Node扔到Condition单向链表,同时挂起线程。如果线程唤醒了,就将Condition中的Node扔到AQS的双向链表等待获取锁。
4. 唤醒线程时,AQS为什么从后往前遍历
如果线程没有获取到资源,就需要将线程封装为Node对象,安排到AQS的双向链表中排队,并且可能会挂起线程如果在唤醒线程时,head节点的next是第一个要被唤醒的,如果head的next节点取消了,AQS的逻辑是从tail节点往前遍历,找到离head最近的有效节点?
想解释清楚这个问题,需要先了解,一个Node对象,是如何添加到双向链表中的。
基于addWaiter方法中,是先将当前Node的prev指向tail的节点,再将tail指向我自己,再让prev节点指向我此时,Node加入到了AQS队列中,但是从prev节点往后,会找不到当前节点。
5. AQS为什么要有一个虚拟的head节点
有一个哨兵节,点更方便操作。
另一个是因为AQS内部,每个Node都会有一些状态,这个状态不单单针对自己,还针对后续节点
1:当前节点取消了。
0:默认状态,啥事没有。
-1:当前节点的后继节点,挂起了。
-2:代表当前节点在Condition队列中(await将线程挂起了)
-3:代表当前是共享锁,唤醒时,后续节点依然需要被唤醒。
Node节点的ws,表示很多信息,除了当前节点的状态,还会维护后继节点状态。
如果取消虚拟的head节点,一个节点无法同时保存当前阶段状态和后继节点状态。
同时,在释放锁资源时,就要基于head节点的状态是否是-1。来决定是否唤醒后继节点。
如果为-1,正常唤醒
如果不为-1,不需要唤醒吗,减少了一次可能发生的遍历操作,提升性能。
2.cas无锁
- cas可能出现的问题?
ABA问题:
在并发情况下会出现其他线程已经将期望值改成实际值,从而导致失败,解决加入版本号,
自旋次数过多:
可以参考sync的底层,失败几次后挂起,
参考Longadder的方式,内部存储多个数组,只需要修改数组的值就可以了。
3.并发集合
CopyOnWriteArrayList
CopyOnWriteArrayList写数据时,是基于ReentrantLock保证原子性的。其次,写数据时,会复制一个副本写入,写入成功后,才会写入到CopyOnWriteArrayList中的数组。保证读数据时,不要出现数据不一致的问题。如果数据量比较大时,每次写入数据,都需要复制一个副本,对空间的占用太大了。如果数据量比较大,不推荐使用CopyOnWriteArrayList。
写操作要求保证原子性,读操作保证并发,并且数据量不大 ~
ConcurrentHashMap
1.如何保证线程安全的?
尾插,其次扩容有CAS保证线程安全
写入数组时,基于CAS保证安全,挂入链表或插入红黑树时,基于synchronized保证安全。
这里ConcurrentHashMap是采用LongAdder实现的技术,底层还是CAS。(AtomicLong)
ConcurrentHashMap扩容时,一点基于CAS保证数据迁移不出现并发问题,其次ConcurrentHashMap还提供了并发扩容的操作
2.put元素时计数器是如何实现的?
采用的是LongAdder (继承Striped64),他其实就是一个数组,内部有很多的CounterCell对象,没有竞争的时候,会使用value计数,当出现竞争的时候会对CounterCell数组进行扩容(1倍扩容),最后baseCount对数组内的值进行累计求和。并返回
3.如何保证读数据是安全的?
查询数组:第一块就是查看元素是否在数组,在就直接返回。
查询链表:第二块,如果没特殊情况,就在链表next,next查询即可。
扩容时:第三块,如果当前索引位置是-1,代表当前位置数据全部都迁移到了新数组,直接去新数组查询,不管有没有扩容完。
查询红黑树:如果有一个线程正在写入红黑树,此时读线程还能去红黑树查询吗?因为红黑树为了保证平衡可能会旋转,旋转会换指针,可能会出现问题。所以在转换红黑树时,不但有一个红黑树,还会保留一个双向链表,此时会查询双向链表,不让读线程阻塞。至于如何判断是否有线程在写,和等待写或者是读红黑树,根据TreeBin的lockState来判断,如果是1,代表有线程正在写,如果为2,代表有写线程等待写,如果是4n,代表有多个线程在做读操作。
4.如何进行扩容?
- 扩容时机 (数组长度大于64、链表长度大于8的时候,元素大于0.75阈值)
- 扩容标识。(标识当前数组正在扩容,同时会存储扩容信息,从而使用辅助线程对其进行扩容)
- 扩容布长计算。(布长是根据cpu来决定的,最小16)
- 新建数组 数组是原来数组的2倍
- 建立数组的迁移任务 (领取根据迁移数组的布长计算得出的数组下标)
- 迁移新数组 (将原数组的数据迁移到新数组中,迁移完成后留下标记)
- 最后一根线程会对迁移完成的数组进行检查
总结
本章内容对多线程基础以及一部分高级应用做了简单面试总结。