整理学习笔记-并发编程

91 阅读20分钟

并发编程、线程池、aqs、锁机制

阻塞和等待的区别

阻塞一般是外部条件引起的, 像等待输入输出,访问临界资源等,需要等待条件满足才能释放,而且阻塞不能手动中断,阻塞一般不会释放线程持有的对象锁。

等待一般是线程自己主动发起的, 像wait、sleep等,可以在指定时间后重新唤醒, 也可以等待其他线程唤醒,其中需要注意的是sleep不会释放锁,而wait会释放。

并行和并发的区别 -- 多核cpu和多cpu区别

进程是资源分配的最小单位, 而线程是cpu调度的最小单位, 一个cpu内部可以集成多个核心, 每个核心都具有独立的寄存器和中央处理单元,可以独立执行任务。 一般并发是指不同的线程交替占用资源,在短时间内完成切换,好像是一块执行一样,但是并行一般是在多个cpu中,多个线程一块执行,每个线程可以独享cpu资源。

什么是互斥、使用互斥信号量实现生产者消费者问题

互斥是指计算机内部一些资源具有排他性, 一个特定的时候只能由一个进程访问。这种资源被定义为临界资源, 访问这些资源的代码称为临界区。 一般使用信号量和pv操作来实现互斥。

Jmm 模型, 解决什么问题

JMM 是一种内存模型规范,主要解决了如何将变量正确的写入内存,以及正确读取的操作。

在java之前的编程语言,像C/C++, 需要程序员自己进行内存分配, 而且针对不同硬件和操作系统,对应的指令也不同。 java期望能自己建立内存模型,屏蔽各个硬件和操作系统的差异,让程序员只关注业务逻辑,不用考虑数据的存取。

模型的建立是一个很复杂的过程,他必须足够严谨,保证在并发访问情况下不会出现问题。也要有一定的宽松度,使得我们可以利用硬件的各种特性(像寄存器缓存,高速缓存等)获得较高的运行速度。 为了利用硬件特性,jmm定义了工作内存和主内存。每个线程在执行具体操作时使用的是工作内存,工作内存会优先分配在寄存器、高速缓存中,等待使用完毕就会写回到主内存。 这么做显然可以利用硬件特性提升效率, 但在并发访问的情况下会出现很多问题,针对这些问题和解决方案,可以总结成并发编程的三要素。

这三要素分别是原子性、可见性和有序性。

原子性是指操作应该是不可被中断的,要么执行成功要么执行失败,不会存在执行了一半的情况下暂停掉去执行其他的操作。jmm提供了六种原语操作保证不可中断,包括read,load,assign,use,store,write,这些命令是由操作系统的硬件来支持的, 在执行时会关闭中断机制,保障运行成功。 在实际的使用场景中,我们也会希望可以对某些批量操作实现原子性,jmm提供了lock和unlock指令保障指令集的原子性,对应的就是sync关键字。

可见性是指各个工作内存之间对共享变量的操作是不可见的,jmm 工作内存与主内存的工作机制是: 线程将变量由主内存copy到工作内存中,后续所有的操作都是在工作内存中进行, 当操作结束后,将工作内存copy回主内存。jmm的可见性是通过操作结束后将工作内存的值刷新回主内存,然后其余线程从主内存同步新值的方式来实现的。 volatile和sync关键词都可以实现可见性, sync是指在进入临界区时,会将工作内存从主内存刷新,退出临界区时需要将工作内存写到主内存。 volatile 也是同样的道理, 他被更新后会立即刷到主内存,同时通过总线嗅探机制使其余工作内存中的变量失效。

有序性是三要素中比较复杂的特性,有些场景下需要多个线程协作完成某些任务, 他们之间的执行存在次序, 单线程的情况下控制流是有序执行的,但在多线程中是无序的,多线程的代码可能不会按照我们预想的顺序执行。造成无序的原因主要有两类,一是指令重排序,一是工作内存和主内存的延迟。

  指令重排序是指为了提升指令的执行顺序,编译器、处理器、系统可能都会对指令进行统一执行。 这个重排序有可能会导致代码的执行顺序发生异常。 以某个存储系统的初始化为例,线程A会进行配置的初始化,将域名、端口等信息赋值给变量, 等赋值完成后,将一个完成标志赋值为true。 线程B会监控该标志的状态,如果标志为true进行存储的初始化。 由于as-if-serial的语义存在,在单一线程的代码中可以任意组合指令的执行顺序,只需要保持最终的语义完整即可,所以有可能完成标志会在配置之前赋值,线程B发现此标志后进行初始化就会出错。 DCL单例也是一个经典的例子, 单例对象的初始化和赋值操作可能会被重排,导致新线程获取到没有初始化完成的对象。 可以使用volatile解决。 这种可以说成指令重排导致的有序性失败。

  工作内存和主内存的延迟是指有些变量执行完成后未刷新到主内存, 另一个依赖此变量的线程也开始执行。导致程序不按照我们预想的方式操作。 这种可以说成可见性导致的有序性失败。

  在jmm模型中, 提供了volatile 和 sync两个操作来解决有序性。Volatile 通过内存屏障解决内存一致和指令重排, 内存屏障前的操作不允许重排到内存屏障后, 同样内存屏障后的指令不可以重排到屏障前。 而sync可以控制某个时刻只能有一个线程进入临界区, 在某种意义上实现了多线程情况下代码的串行执行,也可以说成宏观代码的有序性,但临界区内也可能会出现指令重排。

happens-before原则

现在我们可以得出结论,通过java 提供的volatile和sync能力, 我们可以实现安全的并发编程。 但是如果所有操作都使用这种方式来进行同步, 那我们的编程过程会变得非常复杂,但我们实际开发过程中并未感觉到, 是因为java替我们封装了这些复杂的实现。 他与程序员之间达成了一个约定。先行发生原则《happens-before》,此原则向程序员们承诺,在happens-before列出的规则中(也可以由规则推导), jmm会保证前面的操作及操作带来的影响对后面的操作是可见的。如果不满足以上规则也不可推导,那么jmm将对他们进行随意重排序。

有几个经典的规则是: 单个线程内控制流顺序执行,sync的解锁先发生于后续的同一对象的加锁,volatile对象的写先发生于后续的读,还有传递性, 操作A先发生于操作B,操作B先发生于操作C,那么操作A先发生于操作C。

配合happens-before的定义, 我们以volatile为例来说一下, 它相关的原则是volatile对象的写先发生于后续的读。 先发生是指volatile的写操作,对于后续对此对象的读应该是可见的。 我们可以知道这是通过内存屏障来解决的。现在有了happens 原则, 即使不了解jmm内存模型和内存屏障,也可以依据原则来进行并发编程。

happens-before是一个关键的规则, 依据此原则我们可以检查代码是否存在并发问题。

Cas 原理 ,aba问题

Compare and swap , 是一种轻量的同步机制,他可以在不使用锁的情况下保障数据的并发安全性。是一种乐观锁的实现。 他的入参有三个, 内存地址, 比较值和新值, 它更新时会将内存地址中的值取出和比较值比较, 相同才更新。

ABA问题, 是指中途发生过数据的变更,但又变回去了,表面上看起来值没有发生变化, 但实际已经发生了改变,参考一个出入栈例子, 解决方案是加版本号, 数据库乐观锁常用手段,java提供了atomic stamp reference类支持。

sync加锁过程解释

Sync 是java 提供的一种同步机制,可以控制一个临界区只有一个线程在访问, 是悲观锁、非公平锁、互斥锁。他可以修饰代码块和方法, 基本原理是在对象维度加锁,当竞争到对象锁时,获得执行权限,获取不到时会阻塞等待。

在jdk1.6之前, 对象的锁状态只有两种,有锁和无锁。 但sync的加锁会涉及线程的阻塞, 需要将用户态切换回内核态,会带来较大的开销。后续增加了偏向锁和轻量级锁两种优化措施, 所以整体的加锁过程可以分为: 无锁-偏向锁-轻量级锁-重量级锁四个阶段。

一般来说同步机制涉及两个基本元素,首先是临界资源,是每个线程竞争的对象, 其次是同步队列,即如何组织竞争失败线程等待及唤醒机制。

Sync 的临界资源是对象头中的markword字段,他包含很多对象的通用属性,包括hashcode,gc年龄,锁标志位、偏向线程信息,ptr,Monitor对象等。 但在每个加锁阶段锁定的目标是不同的, 在偏向锁阶段是markword中的偏向线程id, 在轻量级锁阶段是markword中的ptr指针,在重量级锁阶段是objectsMonitor中的owner指针。

加锁过程简述:

当遇到sync关键字时,如果修饰的是代码块,会生成Monitorenter指令进入加锁方法;如果是修饰方法,会将方法表结构中的ACC_SYNC标志位设置为true,进入方法的线程见到这个标志为true,也会先获取锁再执行方法体。 加锁方法的入参是当前线程、锁对象markword和basiclock。 锁对象是进行加锁的共享资源, 如果是对普通对象、普通方法加锁,会传入普通对象。如果是对class对象和静态方法加锁,会传入class对象。

首先会判断是否开启了偏向锁, 如果未开启进入轻量级锁的获取;如果开启了就判断markword中的锁标志位是否为无锁可偏向,是的话就判断偏向线程是否为空,如果偏向线程为空就cas设置为当前thread对象,不为空就比较偏向线程是否为自己,如果cas设置失败或偏向线程不是自己,证明有多个线程竞争锁,需要进入下一阶段轻量级锁的获取。

轻量级锁的思想是: 在竞争不激烈的情况下,尝试cas自旋的方式获取锁,不直接进入线程阻塞的流程。 基本思路是将markword复制到当前线程的lock record中, 然后cas自旋尝试将对象头中的ptr指针指向自己。 自旋会引起cpu空转, 所以不可能长时间自旋, jvm提供了一种自适应自旋机制,根据等待时间和自旋次数调整接下来的自旋数量, 如果自旋一定次数后依然不能获取资源, 那就需要进入重量级锁中挂起等待了。

重量级锁是经典的同步队列实现, 主要负责阻塞和唤醒线程。 对应的jvm实现类为objectMonitor, 其中有几个关键成员属性, _owner为当前已获取资源的线程指针,_wait_set 为调用wait方法的线程,cxp为竞争失败后阻塞的线程队列,entry_list 为等待唤醒的线程队列。 新线程进入重量级锁的获取过程后,如果owner指针为空, 会cas尝试将owner指针设置为自己(这也是为什么sync是非公平锁,几乎每一次升级过程都会尝试获取锁),如果获取失败会将当前thread封装成MonitorWaiter放入cxp队列中。 在锁唤醒的过程中,如果cxp队列不为空,会根据QMODE参数的不同,选择从cxp中唤醒,还是将cxp头插、尾插、倒转后插入entryList队列中唤醒, 默认是直接插入唤醒,可以通过不同的业务场景调整QMODE的设置。

总结: sync 是java 提供的一种常见的同步机制,可以控制多线程场景下临界资源的访问以及相互协作的次序。由于java的线程模型是基于操作系统的原生线程实现的。 所以线程的阻塞会涉及内核态的切换,会带来较大开销。所以为了尽量减少阻塞线程,java在1.6之后提供了几种锁的优化,包括偏向锁和轻量级锁,偏向锁的核心是让锁可以重入,比较经典的就是一些线程安全的集合(像vector)给每个方法都加了sync关键字,获取到偏向锁的线程就不必每次都再执行锁的获取过程。 如果超过1个线程竞争偏向锁,会升级为轻量级锁。 轻量级锁的设计思想是在竞争不强烈的情况下,通过自旋一小段时间来代替阻塞线程。避免大的线程切换开销。 重量级锁就是原先常规的加锁过程,将获取不到临界资源的线程封装成等待对象并挂起,等待其余线程释放资源。需要注意的是,jvm提供了QMODE参数来控制唤醒流程。 默认是按照竞争失败时间来唤醒。

除了偏向锁和轻量级锁, java还提供了类似锁粗化、锁消除、适应性自旋等操作来优化加锁过程。

死锁的必要条件和解决办法

死锁产生的四个必要条件及破坏方式:

请求和保持:在请求新资源的同时,不释放手中持有的资源。 破坏方式:直接剥夺

不剥夺: 当请求资源失败时,不剥夺手中已有的资源。破坏方式:剥夺

资源互斥:资源只能由一人占用,不可共享。破坏方式:共享锁

环路等待:当死锁形成时,一定会出现一个等待环,都在互相等待资源释放。破坏方式:银行家算法

银行家算法:

分配资源前先进性安全扫描。确保分配之后依然是安全的状态,否则不分配,破坏环路等待

线程池工作原理

核心参数

corePoolSize,核心线程数

maxPoolSize, 最大线程数

keeplivetime,空闲线程最长存活时间,当非核心线程空闲指定时长后,销毁,当设置核心线程可销毁参数后,核心线程也可销毁。 worker执行getTask方法,通过blockQueue的await方法,拿不到task后销毁

timeUnit,空闲线程等待时间的单位

阻塞队列,worker的等待序列

拒绝策略,当线程池不再接收任务时的处理策略,默认拒绝

线程工厂,创建线程的工厂方法

工作原理:

提交任务后, 先判断是否达到核心线程数, 如果未达到创建线程直接执行。

若达到核心线程数,看等待队列是否已满,如果未满将任务加入等待队列

如果等待队列已满,如果可以创建工作线程,创建工作线程执行任务

如果工作线程也已满,执行拒绝策略

并发集合,hashmap并发问题

常见的并发集合, hashtable、vector、collections.sync, 都是加sync关键字

concurrentHashMap, jdk1.7是分段锁, 相对于hashtable把整个hashtable加锁, 他进行了锁细化操作,减少了冲突几率,内部有segments数组、hashEntry节点、也是扩容整数倍数。 内部使用reentrentLock实现同步。

1.8 进一步细化了该锁, 改成了数组的首个元素加锁, 当计算的索引下标位置为空时,直接cas设置。 如果不为空则sync住头结点。 get时不用加锁, 因为使用了unsafe的volatile方法保障可见性。

Hashmap 并发问题, hashmap中的get和set操作都不符合happens-before原则,所以无法从原子性、可见性、顺序性上面讨论并发问题, 他一个也不满足。 而且1.7的hashmap扩容时采用头插法, 有可能会形成循环链表。1.8改尾插解决了这个问题。

juc aqs的相关实现

aqs的定义是抽象队列同步器,是juc的核心,是一个构建锁和同步器的框架,基于aqs可以构建出很多同步工具,像reentrantLock, reentrantReadWriteLock,countdownLunch,cyclicBarrier等。 它的核心原理是如果请求的资源空闲,那么就锁定资源,使当前线程获取执行权限。如果资源被锁定,提供了一套用于挂起和唤醒线程的机制,内部使用了改进的CLH锁来实现。

具体工作原理-加锁过程

加锁过程可以看做一个尝试cas锁定临界资源state的过程,方法入口为acquire,首先会进行tryAcquire尝试获取资源,这个方法需要各个工具自己实现,是所有juc工具类的扩展点。 如果获取不到资源,说明资源被占用,需要处理等待线程。 aqs会将等待线程封装成waiter node,加入等待队列中。 具体的加入策略是先将节点的prev挂到尾结点上, 然后cas将尾结点的next指针指向自己。 这个过程可能会受到其他线程的影响而失败,失败的线程会进入enq方法循环cas加入尾部,直到成功为止。 如果加入队列成功,会执行AcquireQuered方法, 此方法的内容是判断是否可以挂起线程以及执行线程挂起操作,具体的逻辑是看前一个线程的状态,如果前一个线程是等待自己也挂起。

解锁过程可以看做释放临界资源,并从等待队列中唤醒第一个等待节点。 唤醒的过程有一个特殊的点, 他并不是通过头结点的next指针获取下一节点,而是从尾指针从后往前找到头结点的下一节点, 原因应该是添加阶段时是个并发操作, 先挂prev再cas挂next指针, 逆着找肯定是正确的, 但顺着找可能会断开。

aqs对clh锁的改进

clh锁是一个高级版的自旋锁, 原先使用自旋锁的时候,我们一般会通过循环+cas的方式来保证资源的正确访问, 但这种方式会带来两个问题, 一个是资源获取的随机性,可能会带来线程饥饿问题。另一个问题是多个线程cas同一临界资源,竞争度太高。 clh锁解决了这两个问题, 他是提供了一个虚拟的自旋队列, 每个队列监控前一节点的状态,当前一节点释放资源时自己再执行。clh能显著的解决自旋锁的问题。但在竞争激烈的情况下多个线程进行自旋操作会带来cpu的飙升,而且clh的功能较为单一,如果直接使用clh不能支持juc下多样的应用场景, 所以java对clh做了改造,改造点主要有两个,一是将长时间自旋改为挂起线程,避免自旋浪费cpu。 二是改造了node的结构体,将state改为int类型、显式的维护前驱和后置节点,加入工作线程标志,这些改动支持了重入锁,countdownLunch等同步工具的扩展。

Sync 和 aqs的区别

维护方不同。 sync为jvm内部实现,由jvm自己完成加锁解锁流程,而aqs则需要用户手动维护

唤醒方式。 sync只能等获取到锁资源后才能释放锁,而且不能响应中断。aqs可以在代码中进行unlock操作释放锁, 也可以指定时间的try lock。更为灵活。

应用场景, sync只能为非公平锁、互斥锁,每次锁升级都会进行cas操作尝试获取锁。 而aqs提供了公平、非公平、互斥、共享等多种实现。应用场景更广泛。

性能差距: 在jdk1.6之前,sync只有重量级锁,所以aqs的性能优于sync,但1.6sync进行了一系列优化,现在性能方面差不太多。

threadlocal原理,内存泄漏问题

Threadlocal 内部维护了一个threadlocalMap的内部类, key为thread,value为泛型对象。

他的key为弱引用类型,如果没有强引用会被回收为null,但是value是强引用类型,不会被回收, 所以threadlocalmap的get 、set 、 remove 方法都会清理掉key为null的value。 但当定义了threadlocal而不使用,有可能会造成内存泄漏。 所以我们要有final时清理threadlocal的习惯

ThreadLocal 怎么解决hash冲突的,有哪些常见的hash冲突解决方案

常见hash冲突解决方案

  1. 链式地址法,将hash冲突的元素以链的方式组织,hash表中存放链表的头结点,查询时遍历链表比较key值,hashmap使用这种方式处理冲突
  2. 开放地址法,当指定位置出现hash冲突时,从冲突位置开始寻找下一地址(固定步长或其余步长设定方法),一直找到不冲突位置为止,不适用于hash表较小的情况,会导致寻址一直找不到,布谷鸟过滤器也是这种
  3. 再hash法,使用一个以上的hash函数,当出现hash冲突时换一个hash函数重新计算位置,需要额外存储记录node使用哪个hash,否则无法执行查询操作
  4. 建立公共溢出区,将hash表分为工作区和溢出区,将冲突节点放置到溢出区,适用于hash冲突较少的情况, 否则还要考虑溢出区发生hash冲突

ThreadLocal 使用开放寻址法,当出现hash冲突时,以指定步长1查找下一位置,到达数组末位后从hash表头部再找,ThreadLocalMap的默认长度为16,按照2的整数倍扩容。