多线程面试总结

多线程体系总结


前言

提示:本文章主要描述多线程体系:

作者在学习java知识的过程中接收很多的碎片知识,由于知识面不够宽广,导致很多知识点混淆,同时也是由于知识点杂乱导致无法深刻记忆,所以打算写一篇博客对实现点进行一个面试总结。有一些点总结的不是很好,希望各位大神批评指正。


一、线程基础

1. 什么事线程?什么是进程?

进程:进程是在系统中能独立运行并作为资源分配的基本单位。
线程: 线程是进程的一个实体,作为系统调度和分配的基本单位。
关联关系:线程是进程的集合。线程是每个进程的一条执行路径。

2.线程创建的基本方式

  1. 继承Thread类实现run方法
  2. 实现Runnbale接口实现run方法
  3. 实现Callable实现call方法

延伸:这三种实现方式本质都是Runnable,其实是一种。因为继承Thread,间接实现了Runnable,实现Callable,需要FutureTask做封装,在启动线程时,依然是执行的FutureTask实现Runnable时重写的run方法,在run方法内部,执行的Callable的call方法。image.png

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.线程基本方法

  1. join实现原理
    Join方法本质是基于synchronized以及wait和notify实现的。直接针对当前线程对象加锁,然后wait挂起线程,wait判断的逻辑是t1线程调用isAlive()方法的返回值存活waiting,未存活就代表凉了不用挂起,被唤醒。
  2. wait和notify
    wait和notify是在持有synchronized锁时,
    • wait方法是让持有锁的线程释放锁资源,并且挂起。
    • notify方法是让持有锁的线程,去唤醒之前执行wait方法挂起的线程,让被唤醒的线程抢锁。

至于为何要在持有synchronized时,才能执行wait和notify,是因为在调整线程存放的队列时,需要持有当前synchronized锁里面的ObjectMonitor,没持有,不让操作。并且执行wait需要释放锁资源,你没持有锁资源,无法进行释放

  1. stop停止当前线程(不推荐)
    stop方法,会直接强制停止线程,不让执行。 太过暴力,没有原子性。会导致线程安全问题
  2. interrupt打断当前线程(推荐)
    interrupt只是将线程的中断标记未从默认的false,改为了true。

5.多线程的特性

  1. 原子性

在java.util包下提供了很多的原子类 大致分为 基本类型,引用,属性,数组
基本数据类型:AtomicInteger、AtomicBoolean,AtomicLong
引用:AtomicReference
属性:AtomicIntegerFiedUpdater
数组:AtomicArray,AtomicLongArray

  1. 可见性 (volatile)
  2. 有序性 (锁)

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

  1. 对象头(同步监视器)
    Entry Set:(锁池): 保存等待获取对象锁的所有线程
    Wait Set:(等待池): 保存执行了objectX.wait()/wait(long)的状态为WAITTING的所有线程
    enter:进入锁池
    acquire: 获取到锁
  2. 锁升级
    无锁(匿名偏向)、偏向锁、轻量级锁、重量级锁
    无锁(匿名偏向): 一般情况下,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无锁

  1. 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.如何进行扩容?
  1. 扩容时机 (数组长度大于64、链表长度大于8的时候,元素大于0.75阈值)
  2. 扩容标识。(标识当前数组正在扩容,同时会存储扩容信息,从而使用辅助线程对其进行扩容)
  3. 扩容布长计算。(布长是根据cpu来决定的,最小16)
  4. 新建数组 数组是原来数组的2倍
  5. 建立数组的迁移任务 (领取根据迁移数组的布长计算得出的数组下标)
  6. 迁移新数组 (将原数组的数据迁移到新数组中,迁移完成后留下标记)
  7. 最后一根线程会对迁移完成的数组进行检查

总结

本章内容对多线程基础以及一部分高级应用做了简单面试总结。

  • 15
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值