在面试中并发编程是必然会被问到的一个问题,我结合自身面试经验和对大厂面试题分析,经过知识梳理,总结有以下几个部分。
1理解线程的状态转换、(理解这个是基础,有助于后续理解线程之前协作,线程池等)
2线程的同步与互斥。CAS、synchronized 和 lock。
3线程池的运作机制,实现方式,使用场景。
4以及 JUC 中常用的工具类。
5死锁
6线程通讯
7延伸各种锁机制的理解
深刻理解上述概念和实现原理,应对多线程不成问题,下面我们按个介绍。
1我们在理解线程的状态之前,先来说说神马是线程,线程又依托于进程存在,所以我这里先说进程,进程就是我们通常知道的一个应用,比如微信,qq等,拥有独立的内存区域;
如下图所示
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。
现在说线程,线程与进程相似,但线程是一个比进程更小的执行单位。
举个例子 jvm是个进程 main就是运行在进程之中的线程;
理解了什么是线程,现在解释线程运行过程中有哪些状态,有NEW起始、RUNNABLE运行、BLOCKED阻塞、WAITING等待、TIMED_WAITING指定时间等待、TERMINATED 终止六种状态,这些状态对应 Thread.State 枚举类中的状态。
这里有个图可以展示线程之间的转换 和其对应的方法
ps 网图 能说明问题就行;
2了解了线程的状态,我们就可以理解线程的同步与互斥机制;
解决线程同步与互斥的主要方式是 CAS、synchronized 和 lock。
2.1 先来说一下什么是cas,即Conmpare And Swap 比较和交换,是乐观锁的一种实现,此种方式假设并发并不是时时刻刻存在,在读取数据时候不加锁,在写回数据的时候进行加锁,比较数据是否发生修改 如果发生修改就继续读取的操作。
2.1.1了解了实现方式,我们看看jdk,看看怎么实现,理论与实践相结合;
此种方式实现AtomicInteger为例
最后追踪是native boolean compareAndSwapInt 方法 源码如下;
http://hg.openjdk.java.net/jdk6/jdk6/hotspot/file/4fc084dac61e/src/share/vm/prims/unsafe.cpp
源码为c++;大致看一下最后cmpxchg 为汇编指令 比较交换操作数,此过程底层运用了操作系统原语实现的,保证不会中断。
但是此类操作会导致aba问题,就是一个方法修改了值,又有一个方法改回去了,比较并没有作用,
解决方案是加时间戳 判断。
2.2synchronized 原理
synchronized 它可以把任意一个非 NULL 的对象当作锁。他属于独占式的悲观锁,同时属于可重
入锁。
Synchronized 作用范围
作用于方法时,锁住的是对象的实例(this);
当作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久带PermGen
(jdk1.8 则是 metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,
会锁所有调用该方法的线程;
synchronized 作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。它有多个队列,
当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中
Synchronized 核心组件
- Wait Set:哪些调用 wait 方法被阻塞的线程被放置在这里;
- Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
- Entry List:Contention List 中那些有资格成为候选资源的线程被移动到 Entry List 中;
- OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为 OnDeck;
- Owner:当前已经获取到所资源的线程被称为 Owner;
- !Owner:当前释放锁的线程
synchronized 实现
- JVM 每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,
ContentionList 会被大量的并发线程进行 CAS 访问,为了降低对尾部元素的竞争,JVM 会将
一部分线程移动到 EntryList 中作为候选竞争线程。 - Owner 线程会在 unlock 时,将 ContentionList 中的部分线程迁移到 EntryList 中,并指定
EntryList 中的某个线程为 OnDeck 线程(一般是最先进去的那个线程)。 - Owner 线程并不直接把锁传递给 OnDeck 线程,而是把锁竞争的权利交给 OnDeck,
OnDeck 需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在
JVM 中,也把这种选择行为称之为“竞争切换”。 - OnDeck 线程获取到锁资源后会变为 Owner 线程,而没有得到锁资源的仍然停留在 EntryList
中。如果 Owner 线程被 wait 方法阻塞,则转移到 WaitSet 队列中,直到某个时刻通过 notify
或者 notifyAll 唤醒,会重新进去 EntryList 中。 - 处于 ContentionList、EntryList、WaitSet 中的线程都处于阻塞状态,该阻塞是由操作系统
来完成的(Linux 内核下采用 pthread_mutex_lock 内核函数实现的)。 - Synchronized 是非公平锁。 Synchronized 在线程进入 ContentionList 时,等待的线程会先
尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是
不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁
资源。
参考:https://blog.csdn.net/zqz_zqz/article/details/70233767 - 每个对象都有个 monitor 对象,加锁就是在竞争 monitor 对象,代码块加锁是在前后分别加
上 monitorenter 和 monitorexit 指令来实现的,方法加锁是通过一个标记位来判断的 - synchronized 是一个重量级操作,需要调用操作系统相关接口,性能是低效的,有可能给线
程加锁消耗的时间比有用操作消耗的时间更多。 - Java1.6,synchronized 进行了很多的优化,有适应自旋、锁消除、锁粗化、轻量级锁及偏向
锁等,效率有了本质上的提高。在之后推出的 Java1.7 与 1.8 中,均对该关键字的实现机理做
了优化。引入了偏向锁和轻量级锁。都是在对象头中有标记位,不需要经过操作系统加锁。 - 锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫做锁膨胀;
2.3lock 原理
在介绍 Lock 前,先介绍 AQS(抽象的队列同步器),也就是队列同步器,这是实现 Lock 的基础。
AbstractQueuedSynchronizer 类如其名,抽象的队列式的同步器,AQS 定义了一套多线程访问
共享资源的同步器框架,许多同步类实现都依赖于它,如常用的
ReentrantLock/Semaphore/CountDownLatch。
它维护了一个 volatile int state(代表共享资源)和一个 FIFO 线程等待队列(多线程争用资源被
阻塞时会进入此队列)。这里 volatile 是核心关键词,具体 volatile 的语义,在此不述。state 的
访问方式有三种:
getState()
setState()
compareAndSetState()
AQS 定义两种资源共享方式
Exclusive 独占资源-ReentrantLock
Exclusive(独占,只有一个线程能执行,如 ReentrantLock)
Share 共享资源-Semaphore/CountDownLatch
Share(共享,多个线程可同时执行,如 Semaphore/CountDownLatch)。
AQS 只是一个框架,具体资源的获取/释放方式交由自定义同步器去实现,AQS 这里只定义了一个
接口,具体资源的获取交由自定义同步器去实现了(通过 state 的 get/set/CAS)之所以没有定义成
abstract ,是 因 为独 占模 式 下 只 用实现 tryAcquire-tryRelease ,而 共享 模 式 下 只用 实 现
tryAcquireShared-tryReleaseShared。如果都定义成abstract,那么每个模式也要去实现另一模
式下的接口。不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实
现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/
唤醒出队等),AQS 已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
1.isHeldExclusively():该线程是否正在独占资源。只有用到 condition 才需要去实现它。
2.tryAcquire(int):独占方式。尝试获取资源,成功则返回 true,失败则返回 false。 3.tryRelease(int):独占方式。尝试释放资源,成功则返回 true,失败则返回 false。 4.tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0 表示成功,但没有剩余
可用资源;正数表示成功,且有剩余资源。
5.tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回
true,否则返回 false。
同步器的实现是 ABS 核心,以 ReentrantLock 为例,state 初始化为 0,表示未锁定状态。A 线程
lock()时,会调用 tryAcquire()独占该锁并将 state+1。此后,其他线程再 tryAcquire()时就会失
败,直到 A 线程 unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放
锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,
获取多少次就要释放多么次,这样才能保证 state 是能回到零态的。
以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与
线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown()一次,state
会 CAS 减 1。等到所有子线程都执行完后(即 state=0),会 unpark()主调用线程,然后主调用线程
就会从 await()函数返回,继续后余动作。
ReentrantReadWriteLock 实现独占和共享两种方式
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现 tryAcquiretryRelease、tryAcquireShared-tryReleaseShared 中的一种即可。但 AQS 也支持自定义同步器
同时实现独占和共享两种方式,如 ReentrantReadWriteLock。
3线程池的运作机制,使用场景。
Executors 工具类中提供了 5 种类型
newCachedThreadPool很多短期异步任务的程序而言,这些线程池通常可提高程序性能
newFixedThreadPool固定大小线程池,特点是线程数固定,使用无界队列,适用于任务数量不均匀的场景、对内存压力不敏感但系统负载比较敏感的场景;
newScheduledThreadPool创建一个线程池,它可安排在给定延迟后运行命令或者定期地执行。
newSingleThreadExecutor Executors.newSingleThreadExecutor()返回一个线程池(这个线程池只有一个线程),这个线程
池可以在线程死后(或发生异常时)重新启动一个线程来替代原来的线程继续执行下去!
newWorkstealingPoll 工作窃取线程池,使用的是 ForkJoinPool,是固定并行度的多任务队列,适合任务执行时长不均匀的场景。
线程池的工作原理
不想画了 网上找的哈哈~
1execute 和 submit时判断是否达到核心线程数 没有达到就创建线程执行
2如果达到核心线程数量,就看缓冲队列是否满,没有满 放入队列 等待线程空闲时间执行对列任务
3如果对列已经满了 则判断是否达到了最大线程数,没有达到 创建线程执行任务
4如果达到了最大线程的数量 就执行拒绝策略
4 JUC 中常用的工具类。
4.1首先是基本数据类型的元子类Atomic* 都是基于cas的实现
LongAdder 、doubleAdder 都是基于分段锁的思想;属于空间换时间,更适用于高并发场景
AtomicStampedReference 和 AtomicMarkableReference 用于解决前面提到的 ABA 问题,分别基于时间戳和标记位来解决问题。
4.2 锁相关的类
与 ReentrantLock 的独占锁不同,Semaphore 是共享锁,
其他的后续补充
异步执行的相关类
CompletableFuture可以支持流式调用,可以方便的进行多 future 的组合使用,例如可以同时执行两个异步任务,然后对执行结果进行合并处理。还可以很方便地设置完成时间。
ForkJoinPool,采用分治思想,将大任务分解成多个小任务处理,然后在合并处理结果。ForkJoinPool 的特点是使用工作窃取算法,可以有效平衡多任务时间长短不一的场景。
4.3 其他的工具类
LinkedBlockingDeque
是一个双端队列 分别可以从对头 队尾出队
ArrayBlockingQueue
单端队列 队尾入队 对头出队
CountDownLatch
计数器功能 等多个线程执行结束进行汇总
CyclicBarrier
等待一组线程到某个状态以后 一起执行,可以进行并发测试
Semaphore
用来控制对共享资源的访问并发度
ConcurrentHashMap
是一个线程安全的map 1.7是基于分段锁的思想实现
5死锁
锁死的定义:多线程以及多进程改善了系统资源的利用率并提高了系统 的处理能力。然而,并发执行也带来了新的问题——死锁。所谓死锁是指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进
死锁如何排查、
jps + jstack
https://www.cnblogs.com/aflyun/p/9194104.html
线程安全和内存模型的关系
https://www.cnblogs.com/haoworld/p/java-bing-fa-xian-cheng-an-quan-he-nei-cun-mo-xing.html
6线程通讯
sleep 和 wait
sleep不释放锁 wait释放锁
wait 和 notify
wait线程阻塞休眠 notify 唤醒休眠的线程
notify 和 notifyAll
如果线程a获取对象锁,并且wait 对象 notify可以唤醒对应的线程.notifyall是唤醒所有休眠的线程
7延伸各种锁机制的理解
偏向锁、轻量级锁、重量级锁、锁优化、锁消除、锁粗化、自旋锁、可重入锁、阻塞锁、死锁
偏向锁、
Java偏向锁(Biased Locking)是Java6引入的一项多线程优化。
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
轻量级锁
轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;
重量级锁
当使用synchronized时线程先使用偏向锁 ,如果有竞争就是轻量级锁,在获取不到就会进行短暂的自旋,自旋获取不到就会升级为重量级锁
锁优化
以上介绍的锁不是我们代码中能够控制的,但是借鉴上面的思想,我们可以优化我们自己线程的加锁操作;
锁优化1 减少锁的时间
不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放;
锁优化2 减少锁的粒度
它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间;
java中很多数据结构都是采用这种方法提高并发操作的效率:ConcurrentHashMap java中的ConcurrentHashMap在jdk1.8之前的版本,使用一个Segment 数组
锁粗化
大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度;
在以下场景下需要粗化锁的粒度:
假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;
自旋锁
通过自旋进行锁的获取 cas
可重入锁
同一个线程可以再次获取锁ReentrantLock等
阻塞锁、
多个线程同时调用同一个方法的时候,所有线程都被排队处理了。让线程进入阻塞状态进行等待,当获得相应的信号(唤醒,时间) 时,才可以进入线程的准备就绪状态,准备就绪状态的所有线程,通过竞争,进入运行状态。 wait 等
死锁上面介绍过了
锁消除
锁消除是发生在编译器级别的一种锁优化方式。
有时候我们写的代码完全不需要加锁,却执行了加锁操作。