一、内存模型
1.1计算机内存模型
计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多.
因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。
因此在CPU里面就有了高速缓存。也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。
进程和线程之由来
请参考:Java多线程基础:进程和线程之由来https://www.cnblogs.com/dolphin0520/p/3910667.html
思考
1.缓存一致性问题
如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。为了解决缓存不一致性问题,通常来说有以下2种解决方法:
- 通过在总线加LOCK#锁的方式
- 通过缓存一致性协议
1.2 Java内存模型JMM(Java Memory Model)
Java内存模型简称JMM(Java Memory Model),是Java虚拟机所定义的一种抽象规范,用来屏蔽不同硬件和操作系统的内存访问差异,让java程序在各种平台下都能达到一致的内存访问效果。
Java内存模型结构如图:
1.主内存(Main Memory)
主内存可以简单理解为计算机当中的内存,但又不完全等同。
主内存被所有的线程所共享,对于一个共享变量(比如静态变量,或是堆内存中的实例)来说,主内存当中存储了它的“本尊”。
2.工作内存(Working Memory)
工作内存可以简单理解为计算机当中的CPU高速缓存,但又不完全等同。
每一个线程拥有自己的工作内存,对于一个共享变量来说,工作内存当中存储了它的“副本”。
线程对共享变量的所有操作都必须在工作内存进行,不能直接读写主内存中的变量,等工作内存执行完后才会刷新主存。不同线程之间也无法访问彼此的工作内存,变量值的传递只能通过主内存来进行。
并发编程中三大原则
1.原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
2.可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
3.有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
要想并发程序正确地执行,必须要保证原子性、可见性以及有序性三个同时满足。只要有一个没有被保证,就有可能会导致程序运行不正确。
1.2.1 happens-before
happens-before定义:
JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。
由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happensbefore关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。
happens-before原则:
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。
·程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
·监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
·volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读,即写了的值线程对读的立刻可见。
·传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
-join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
参考:《Java并发编程的艺术》,https://www.cnblogs.com/dolphin0520/p/3920373.html#undefined
1.2.2 指令重排序
一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
注意:
1.处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行
2.指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
@see https://www.cnblogs.com/dolphin0520/p/3920373.html#undefined
JMM对这两种不同性质的重排序,采取了不同的策略,如下。
·对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
·对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。
1.2.3 内存屏障
内存屏障也称为内存栅栏或栅栏指令,是一种屏障指令,它使CPU或编译器对屏障指令之前和之后发出的内存操作执行一个排序约束。 这通常意味着在屏障之前发布的操作被保证在屏障之后发布的操作之前执行完毕。
内存屏障会提供3个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他CPU中对应的缓存行无效。
1.2.4 volatile关键字
1.volatile关键字的两层语义
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(即保证了可见性)
2)禁止进行指令重排序。(即保证了有序性)
volatile关键字禁止指令重排序有两层意思:
1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
注意:volatile关键字无法保证原子性
volatile的原理和实现机制
- 1.在对这个变量进⾏修改时,会直接将CPU⾼级缓存中的数据写回到主内存,对这个变量的读取也会直接从主内存中读取,从⽽保证了可⻅性
- 2. 在对volatile修饰的成员变量进⾏读写时,会插⼊内存屏障,⽽内存屏障可以达到禁⽌重排序的效果,从⽽可以保证有序性
与synchronized关键字有何不同?
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
- 对变量的写操作不依赖于当前值
- 该变量没有包含在具有其他变量的不变式中
事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。
下面列举几个Java中使用volatile的几个场景。
1.状态标记量(一般用在全局变量中)
volatile boolean flag = false;
while(!flag){
doSomething();
}
public void setFlag() {
flag = true;
}
此案例是原子操作,故能正常使用
更多volatile技术资料请参考:码农翻身http://chuansong.me/n/2109010751015
synchronized关键字
多线程缺省同步锁的知识
大家都知道,在多线程开发中,为了解决并发问题,主要是通过使用synchronized来加互斥锁进行同步控制。但是在某些情况中,JVM已经隐含地为您执行了同步,这些情况下就不用自己再来进行同步控制了。这些情况包括:
1.由静态初始化器(在静态字段上或static{}块中的初始化器)初始化数据时
2.访问final字段时
二、并发编程
2.2 死锁
根据操作系统中的定义:死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
死锁的四个必要条件:
互斥条件(Mutual exclusion):资源不能被共享,只能由一个进程使用。
请求与保持条件(Hold and wait):已经得到资源的进程可以再次申请新的资源。
非剥夺条件(No pre-emption):已经分配的资源不能从相应的进程中被强制地剥夺。
循环等待条件(Circular wait):系统中若干进程组成环路,该环路中每个进程都在等待相邻进程正占用的资源。
解决死锁理论
只要破坏死锁 4 个必要条件之一中的任何一个,死锁问题就能被解决。
有效避免死锁的办法(自己总结的)
- 为获取到锁的线程设置超时时间
- 避免一个线程同时获取多个锁。
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
2.3 CAS
2.3.1基础概念
什么是CAS?
CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换。它提供了一种代替同步锁的方式,相比同步锁减少了内核态与用户态之间来回切换的性能开销
CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。
更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。否则会重新获取内存地址V的当前值,并重新计算想要修改的新值。这个重新尝试的过程被称为自旋。
CAS的基本概念及实现原理请参考:漫画:什么是 CAS 机制
基于CAS应用所实现的类有:Atomic相关原子类。
2.3.2 CAS的底层实现
Java语言CAS底层如何实现?
首先看一看AtomicInteger当中常用的自增方法 incrementAndGet:
private volatile int value;
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
public final int get() {
return value;
}
这段代码是一个无限循环,也就是CAS的自旋。循环体当中做了三件事:
1.获取当前值。
2.当前值+1,计算出目标值。
3.进行CAS操作,如果成功则跳出循环,如果失败则重复上述步骤。
这里需要注意的重点是 get 方法,这个方法的作用是获取变量的当前值。如何保证获得的当前值是内存中的最新值呢?很简单,用volatile关键字来保证。
CAS的底层实现则是compareAndSet方法。
/**
* Atomically sets the value to the given updated value
* if the current value {@code ==} the expected value.
*
* @param expect the expected value
* @param update the new value
* @return {@code true} if successful. False return indicates that
* the actual value was not equal to the expected value.
*/
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
Java利用unsafe类提供了原子性操作方法。
什么是unsafe呢?
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
Java语言不像C,C++那样可以直接访问底层操作系统,但是JVM为我们提供了一个后门,这个后门就是unsafe。unsafe为我们提供了硬件级别的原子操作。
具体原理请参考:漫画:什么是CAS机制?(进阶篇)https://blog.csdn.net/bjweimengshu/article/details/79000506
CAS的缺点:
1.CPU开销较大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
2.不能保证代码块的原子性
CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。
3.ABA问题
这是CAS机制最大的问题所在。
2.3.3 ABA问题
什么是ABA问题?
当一个值从A更新成B,又更新成A,普通CAS机制会误判通过检测。
简单流程:三个线程,线程1和2同时执行,线程3在线程1或者2执行完后执行。线程1:a更新b,线程2:a更新b,线程3:b更新成a。线程1成功执行后,线程2某种原因阻塞,线程3执行成功,线程2恢复,竟然能更新成功。
详细流程:
什么是ABA呢?假设内存中有一个值为A的变量,存储在地址V当中。
此时有三个线程想使用CAS的方式更新这个变量值,每个线程的执行时间有略微的偏差。线程1和线程2已经获得当前值(线程1和2出现的场景为重复提交,或者重试调度任务等),线程3还未获得当前值。
接下来,线程1先一步执行成功,把当前值成功从A更新为B;同时线程2因为某种原因被阻塞住,没有做更新操作;线程3在线程1更新之后,获得了当前值B。
再之后,线程2仍然处于阻塞状态,线程3继续执行,成功把当前值从B更新成了A。
最后,线程2终于恢复了运行状态,由于阻塞之前已经获得了“当前值”A,并且经过compare检测,内存地址V中的实际值也是A,所以成功把变量值A更新成了B。
这个过程中,线程2获取到的变量值A是一个旧值,尽管和当前的实际值相同,但内存地址V中的变量已经经历了A->B->A的改变。
怎么解决?
利用版本号比较可以有效解决ABA问题。
实现原理:
在Compare阶段不仅要比较期望值A和地址V中的实际值,还要比较变量的版本号是否一致。同时在更新阶段不仅要更新具体的值,同时也要更新它的版本号。如果期望值A和地址V中的实际值相等但是版本号却不同,则更新失败。
具体原理请参考: 漫画:什么是CAS机制?(进阶篇)
2.4 AQS
AQS是抽象队列同步器AbstractQueuedSynchronizer的简称。
它是用来构建锁或者其他同步组件的基础框架,其实质就是一个抽象类。
它使用一个整型的volatile变量(命名为state)来维护同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
其结构如下图所示
volatile变量的读写与CAS是concurrent包得以实现的基础。
AQS通过volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。
高层类 | Lock 同步器 阻塞队列 Executor 并发容器 |
基础类 | AQS 非阻塞数据结构 原子变量类 |
volatile变量的读/写 CAS |
concurrent包的实现结构如上图所示,AQS、非阻塞数据结构和原子变量类等基础类都是基于volatile变量的读/写和CAS实现,而像Lock、同步器、阻塞队列、Executor和并发容器等高层类又是基于基础类实现。
2.4.1 AQS的域和方法
域
即类的关键属性
private transient volatile Node head; //同步队列的head节点
private transient volatile Node tail; //同步队列的tail节点
private volatile int state; //同步状态
state为0表示可以获取锁,大于0表示不能获取锁。
Node结点:作为获取锁失败线程的包装类, 组合了Thread引用, 实现为FIFO双向队列。 下图为Node结点的属性描述
Node类的变量waitStatus则表示当前被封装成Node结点的等待状态,共有4种取值CANCELLED、SIGNAL、CONDITION、PROPAGATE。
Node结点的等待状态详细信息如下:
CANCELLED:值为1,在同步队列中等待的线程等待超时或被中断,需要从同步队列中取消该Node的结点,其结点的waitStatus为CANCELLED,即结束状态,进入该状态后的结点将不会再变化。
SIGNAL:值为-1,被标识为该等待唤醒状态的后继结点,当其前继结点的线程释放了同步锁或被取消,将会通知该后继结点的线程执行。说白了,就是处于唤醒状态,只要前继结点释放锁,就会通知标识为SIGNAL状态的后继结点的线程执行。
CONDITION:值为-2,与Condition相关,该标识的结点处于等待队列中,结点的线程等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
PROPAGATE:值为-3,与共享模式相关,在共享模式中,该状态标识结点的线程处于可运行状态。
0状态:值为0,代表初始化状态。
方法
AQS提供的可以修改同步状态的3个方法:
protected final int getState(); //获取同步状态
protected final void setState(int newState); //设置同步状态
protected final boolean compareAndSetState(int expect, int update); //CAS设置同步状态
资源共享方式
AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。
AQS提供的获取和释放锁的自定义方法
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。下面为AQS提供的可以重写的方法。
protected boolean tryAcquire(int arg) //独占式获取同步状态,此方法应该查询是否允许它在独占模式下获取对象状态,如果允许,则获取它。返回值语义:true代表获取成功,false代表获取失败。
protected boolean tryRelease(int arg) //独占式释放同步状态
protected int tryAcquireShared(int arg) //共享式获取同步状态,返回值语义:负数代表获取失败、0代表获取成功但没有剩余资源、正数代表获取成功,还有剩余资源。
protected boolean tryReleaseShared(int arg) //共享式释放同步状态
protected boolean isHeldExclusively() //AQS是否被当前线程所独占
2.4.2 基于AQS的类
独占式加锁和解锁流程
1.ReentrantLock
ReentrantLock有个静态内部类Sync,Sync继承了AbstractQueuedSynchronizer。以ReentrantLock为例,state初始化为0,表示未锁定状态。
A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
共享式加锁和解锁流程
2.CountDownLatch
同样CountDownLatch有个静态内部类Sync,Sync继承了AbstractQueuedSynchronizer。
再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
3.Semaphore是信号量加锁解锁流程:
自己理解:初始化一个n值,每当一个线程获取到了资源,就cas减一,当其他线程发现n的值为0,就进入等待队列。当线程释放时,就将n值cas加1,并唤醒被阻塞的线程,被阻塞的线程在重新去获取资源
@see 深入理解Semaphore 深入理解Semaphore_xingfeng_coder的博客-CSDN博客
2.4.3 AQS源码详解
同步队列
同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)。线程将其加入同步队列尾端,同时会阻塞当前线程。当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点,节点的属性类型与名称以及描述如表所示
在图5-1中,同步器包含了两个节点类型的引用,一个指向头节点,而另一个指向尾节点。
试想一下,当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取到同步状态,转 而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Node update),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式 与之前的尾节点建立关联。
同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态 时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点,该过程 如图5-3所示。
在图5-3中,设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可。
独占锁获取锁和释放锁流程
通过调用同步器的acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,也就是 由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同 步队列中移出。
核心方法:获取锁 acquire(int)
源码如下:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
函数流程如下:
1.tryAcquire()尝试直接去获取资源,如果成功则直接返回;
2.addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
3.acquireQueued()使线程在等待队列中获取资源。每个线程自旋的寻找上个节点是否为头节点,如果不是则被LockSupport.park(this),直到上一个节点释放锁被unpark()或者被interrupt()),然后继续去尝试获取资源,一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
4.如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
独占锁获取锁的流程如下:
至此,acquire()的流程终于算是告一段落了。这也就是ReentrantLock.lock()的流程,不信你去看其lock()源码吧,整个函数就是一条acquire(1)!!!
acquireQueued(Node, int)
源码如下:
进入等待状态休息,直到其他线程彻底释放资源后唤醒自己,自己再拿到资源,然后就可以去干自己想干的事了。
shouldParkAfterFailedAcquire(Node, Node)
整个流程中,如果前驱结点的状态不是SIGNAL,那么自己就不能安心去休息,需要去找个安心的休息点,即寻找前驱节点是正常的,并将其状态设置为SIGNAL。
parkAndCheckInterrupt()
park()会让当前线程进入waiting状态,即该方法被阻塞了,直到唤醒才会正常返回。在此状态下,有两种途径可以唤醒该线程:1)被unpark();2)被interrupt()。需要注意的是,Thread.interrupted()会清除当前线程的中断标记位。
释放锁
release(int)
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;//找到头结点
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);//唤醒等待队列里的下一个线程
return true;
}
return false;
}
它调用tryRelease()来释放资源。有一点需要注意的是,它是根据tryRelease()的返回值来判断该线程是否已经完成释放掉资源了!所以自定义同步器在设计tryRelease()的时候要明确这一点!!
unparkSuccessor(Node)
此方法用于唤醒等待队列中下一个线程。下面是源码:
private void unparkSuccessor(Node node) {
//这里,node一般为当前线程所在的结点。
int ws = node.waitStatus;
if (ws < 0)//置零当前线程所在的结点状态,允许失败。
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;//找到下一个需要唤醒的结点s
if (s == null || s.waitStatus > 0) {//如果为空或已取消
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)//从这里可以看出,<=0的结点,都是还有效的结点。
s = t;
}
if (s != null)
LockSupport.unpark(s.thread);//唤醒
}
这个函数并不复杂。一句话概括:用unpark()唤醒等待队列中最前边的那个未放弃线程,这里我们也用s来表示吧。此时,再和acquireQueued()联系起来,s被唤醒后,进入if (p == head && tryAcquire(arg))的判断(即使p!=head也没关系,它会再进入shouldParkAfterFailedAcquire()寻找一个安全点。
这里既然s已经是等待队列中最前边的那个未放弃线程了,那么通过shouldParkAfterFailedAcquire()的调整,s也必然会跑到head的next结点,下一次自旋p==head就成立啦),然后s把自己设置成head标杆结点,表示自己已经获取到资源了,acquire()也返回了!!And then, DO what you WANT!
思考
1.同步队列里的节点状态如何?
所有节点都会被park,但只有最靠近头节点的线程会被unPark。故AQS是悲观锁。
2.同步队列的节点被中断了,节点会如何?
节点如果被中断了,他将不会响应,只是标记它被中断了,在获取资源后再进行自我中断。而他的next节点,在中断节点释放锁后,被唤醒,并移除中断节点。
共享锁
acquireShared(int)
这里tryAcquireShared()依然需要自定义同步器去实现。但是AQS已经把其返回值的语义定义好了:负值代表获取失败;0代表获取成功,但没有剩余资源;正数表示获取成功,还有剩余资源,其他线程还可以去获取。所以这里acquireShared()的流程就是:
1.tryAcquireShared()尝试获取资源,成功则直接返回;
2.失败则通过doAcquireShared()进入等待队列,直到获取到资源为止才返回。
doAcquireShared(int)
有木有觉得跟acquireQueued()很相似?对,其实流程并没有太大区别。只不过这里将补中断的selfInterrupt()放到doAcquireShared()里了,而独占模式是放到acquireQueued()之外,其实都一样,不知道Doug Lea是怎么想的。
跟独占模式比,还有一点需要注意的是,这里只有线程是head.next时(“老二”),才会去尝试获取资源,有剩余的话还会唤醒之后的队友。那么问题就来了,假如老大用完后释放了5个资源,而老二需要6个,老三需要1个,老四需要2个。老大先唤醒老二,老二一看资源不够,他是把资源让给老三呢,还是不让?答案是否定的!老二会继续park()等待其他线程释放资源,也更不会去唤醒老三和老四了。独占模式,同一时刻只有一个线程去执行,这样做未尝不可;但共享模式下,多个线程是可以同时执行的,现在因为老二的资源需求量大,而把后面量小的老三和老四也都卡住了。当然,这并不是问题,只是AQS保证严格按照入队顺序唤醒罢了(保证公平,但降低了并发)。
小结
其实跟acquire()的流程大同小异,只不过多了个自己拿到资源后,还会去唤醒后继队友的操作(这才是共享嘛)。
releaseShared()
此方法的流程也比较简单,一句话:释放掉资源后,唤醒后继。跟独占模式下的release()相似,但有一点稍微需要注意:独占模式下的tryRelease()在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程,这主要是基于独占下可重入的考量;而共享模式下的releaseShared()则没有这种要求,共享模式实质就是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点。例如,资源总量是13,A(5)和B(7)分别获取到资源并发运行,C(4)来时只剩1个资源就需要等待。A在运行过程中释放掉2个资源量,然后tryReleaseShared(2)返回true唤醒C,C一看只有3个仍不够继续等待;随后B又释放2个,tryReleaseShared(2)返回true唤醒C,C一看有5个够自己用了,然后C就可以跟A和B一起运行。而ReentrantReadWriteLock读锁的tryReleaseShared()只有在完全释放掉资源(state=0)才返回true,所以自定义同步器可以根据需要决定tryReleaseShared()的返回值。
常见问题:
@see 《Java并发编程的艺术》
6.Java并发编程--AQS https://www.cnblogs.com/zaizhoumo/p/7749820.html
7.Java并发之AQS详解https://www.cnblogs.com/waterystone/p/4920797.html
三、并发编程应用
使用Java包下的工具进行并发编程
为了方便编写出线程安全的程序,Java里面提供了一些线程安全类和并发工具,比如:同步容器、并发容器、阻塞队列、Synchronizer(比如CountDownLatch)。
3.2 同步容器
同步容器类
在Java中,同步容器主要包括2类:
1)Vector、Stack、HashTable
2)Collections类中提供的静态工厂方法创建的类
使用工具类Collections将非线程安全容器包装成线程安全容器。如Collections.synchronizedMap(Map<K,V> m)将原始Map包装为线程安全的SynchronizedMap,但是实际上最终操作时,仍然是在被包装的原始map上进行,只是SynchronizedMap的所有方法都加上了synchronized锁控制。
同步容器的缺陷
1.性能问题
2.同步容器并不能保证绝对的安全(使用不当的情况下)
使用不当的情况下的案例请参考: Java并发编程:同步容器https://www.cnblogs.com/dolphin0520/p/3933404.html
以下摘自:《core java》
ConcurrentModificationException
产生原因:
在循环中,调用list.remove()方法导致modCount和expectedModCount的值不一致。注意,像使用for-each进行迭代实际上也会出现这种问题。
3.3 并发容器
为什么JUC需要提供并发容器?
java collection framework提供了丰富的容器,有map、list、set、queue、deque。但是其存在一个不足:多数容器类都是非线程安全的,即使部分容器是线程安全的,由于使用sychronized进行锁控制,导致读/写均需进行锁操作,性能很低。
并发容器
java cocurrent包提供了很多并发容器,在提供并发控制的前提下,通过优化,提升性能。如利用cas机制,CopyOnWrite容器.
cas机制上文已经说了,CopyOnWrite容器请参考:Java并发编程:并发容器之CopyOnWriteArrayList(转载)https://www.cnblogs.com/dolphin0520/p/3938914.html
常见的并发容器:
1.ConcurrentHashMap
2.ConcurrentLinkedQueue
入队:
并发下的安全问题:
如果有一个线程正在入队,那么它必须先获取尾节点,然后设置尾节点的下一个节点为入队节点,但这时可能有另外一个线程插队了,那么队列的尾节点就会发生变化,这时当前线程要暂停入队操作,然后重新获取尾节点。
concurrentLinkedQueue如何解决并发安全问题:
从源代码角度来看整个入队过程主要做二件事情。第一是定位出尾节点,第二是使用CAS算法能将入队节点设置成尾节点的next节点,如不成功则重试(即重新获取尾节点)。
出队:
首先获取头节点的元素,然后判断头节点元素是否为空,如果为空,表示另外一个线程已经进行了一次出队操作将该节点的元素取走,则只需要重新获取头节点。如果不为空,则使用CAS的方式将头节点的引用设置成null,如果CAS成功,则直接返回头节点的元素,如果不成功,表示另外一个线程已经进行了一次出队操作更新了head节点,导致元素发生了变化,需要重新获取头节点。
@see 《Java并发编程的艺术》
3.CopyOnWriteArrayList
CopyOnWriteArrayList提供高效地读取操作,使用在读多写少的场景。CopyOnWriteArrayList读取操作不用加锁,但写的时候会加锁,且是安全的;写操作时,先copy一份原有数据数组,再对复制数据进行写入操作,最后将复制数据替换原有数据,从而保证写操作不影响读操作。
CopyOnWrite的缺点
CopyOnWrite容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。
1. 内存占用问题。因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的Yong GC和Full GC。之前我们系统中使用了一个服务由于每晚使用CopyOnWrite机制更新大对象,造成了每晚15秒的Full GC,应用响应时间也随之变长。
针对内存占用问题,可以通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如果元素全是10进制的数字,可以考虑把它压缩成36进制或64进制。或者不使用CopyOnWrite容器,而使用其他的并发容器,如ConcurrentHashMap。
2. 数据一致性问题。CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。
3.4 阻塞队列
产生背景:
使用非阻塞队列的时候有一个很大问题就是:它不会对当前线程产生阻塞,那么在面对类似消费者-生产者的模型时,就必须额外地实现同步策略以及线程间唤醒策略,这个实现起来就非常麻烦。
但是有了阻塞队列就不一样了,它会对当前线程产生阻塞,比如一个线程从一个空的阻塞队列中取元素,此时线程会被阻塞直到阻塞队列中有了元素。当队列中有元素后,被阻塞的线程会自动被唤醒(不需要我们编写代码去唤醒)。这样提供了极大的方便性。
几种主要的阻塞队列
自从Java 1.5之后,在java.util.concurrent包下提供了若干个阻塞队列,主要有以下几个:
ArrayBlockingQueue:基于数组实现的一个阻塞队列,在创建ArrayBlockingQueue对象时必须制定容量大小。并且可以指定公平性与非公平性,默认情况下为非公平的,即不保证等待时间最长的队列最优先能够访问队列。
LinkedBlockingQueue:基于链表实现的一个阻塞队列,在创建LinkedBlockingQueue对象时如果不指定容量大小,则默认大小为Integer.MAX_VALUE。
PriorityBlockingQueue:以上2种队列都是先进先出队列,而PriorityBlockingQueue却不是,它会按照元素的优先级对元素进行排序,按照优先级顺序出队,每次出队的元素都是优先级最高的元素。注意,此阻塞队列为无界阻塞队列,即容量没有上限(通过源码就可以知道,它没有容器满的信号标志),前面2种都是有界队列。
DelayQueue:基于PriorityQueue,一种延时阻塞队列,DelayQueue中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。DelayQueue也是一个无界队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。
1.阻塞队列的常用方法
java阻塞队列中的offer、poll方法
1 offer
将指定的元素插入此队列(如果立即可行且不会违反容量限制),当使用有容量限制的队列时,此方法通常要优于 add(E),后者可能无法插入元素,而只是抛出一个异常。
如果该元素已添加到此队列,则返回 true;否则返回 false
2.put
添加一个元素 如果队列满,则阻塞知道队列有多余的空间
3.add
增加一个元索 如果队列已满,则抛出一个IIIegaISlabEepeplian异常
4. poll
获取并移除此队列的头,如果此队列为空,则返回 null。
5. take
take 移除并返回队列头部的元素,queue的长度 == 0 的时候,一直阻塞
6.remove
remove()是从队列中删除第一个元素。remove() 的行为与 Collection 接口的相似
7.peek,element
检测操作,element() 和 peek() 用于在查询队列的头部元素。与 remove() 方法类似,在队列为空时, element() 抛出一个IllegalStateException
异常,而 peek() 返回 null
参考:BlockingQueue的add/offer/put,remove/poll/take, element/peek比较
链接:https://www.jianshu.com/p/3594ec175d83
3.5 Synchronizer(同步器)
在java 1.5中,提供了一些非常有用的辅助类来帮助我们进行并发编程如:
juc提供的同步器常用的有以下三种:CountDownLatch、CyclicBarrier和Semaphore
1.CountDownLatch(计数器):CountDownLatch类位于java.util.concurrent包下,利用它可以实现类似计数器的功能。比如有一个任务A,它要等待其他4个任务执行完毕之后才能执行,此时就可以利用CountDownLatch来实现这种功能了。
2.CyclicBarrier(回环栅栏):通过它可以实现让一组线程等待至某个状态之后再全部同时执行。
叫做回环是因为当所有等待线程都被释放以后,CyclicBarrier可以被重用。我们暂且把这个状态就叫做barrier,当调用await()方法之后,线程就处于barrier了。
3.Semaphore(信号量):Semaphore可以控同时访问的线程个数,通过 acquire() 获取一个许可,如果没有就等待,而 release() 释放一个许可。
下面对上面说的三个辅助类进行一个总结:
1.CountDownLatch和CyclicBarrier都能够实现线程之间的等待,只不过它们侧重点不同:
CountDownLatch一般用于某个线程A等待若干个其他线程执行完任务之后,它才执行;
CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行;
另外,CountDownLatch是不能够重用的,而CyclicBarrier是可以重用的。CyclicBarrier可以重用表示可以恢复初始时设定的计数器,而CountDownLatch无法重用时因为计数器已经变成0了,无法恢复到初始时设定的值
2.Semaphore其实和锁有点类似,它一般用于控制对某组资源的访问权限。
原理请参考:Java并发编程:CountDownLatch、CyclicBarrier和Semaphore https://www.cnblogs.com/dolphin0520/p/3920397.html
3.6 Fork/Join
Fork/Join框架是Java 7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干
个小任务,最终汇总每个小任务结果后得到大任务结果的框架。
Fork/Join的运行流程图如下:
使用场景(待续)
参考资料
1.《Java并发编程的艺术》
2.博客:Java并发编程:volatile关键字解析 https://www.cnblogs.com/dolphin0520/p/3920373.html#undefined
3.码农翻身 :http://chuansong.me/n/2109010751015,
漫画:什么是 volatile 关键字?https://blog.csdn.net/bjweimengshu/article/details/78769623
4.漫画:什么是 CAS 机制https://blog.csdn.net/WantFlyDaCheng/article/details/81603361
5.漫画:什么是CAS机制?(进阶篇)https://blog.csdn.net/bjweimengshu/article/details/79000506
6.Java并发编程--AQS https://www.cnblogs.com/zaizhoumo/p/7749820.html
7.Java并发之AQS详解https://www.cnblogs.com/waterystone/p/4920797.html
8.Java多线程基础:进程和线程之由来https://www.cnblogs.com/dolphin0520/p/3910667.html
9.死锁产生的原因和解锁的方法https://www.cnblogs.com/Jessy/p/3540724.html
10.Java并发编程:同步容器https://www.cnblogs.com/dolphin0520/p/3933404.html
11.Java并发编程:阻塞队列https://www.cnblogs.com/dolphin0520/p/3932906.html
12.java并发编程——并发容器https://www.cnblogs.com/daoqidelv/p/6753162.html
13.Java并发编程:并发容器之CopyOnWriteArrayList(转载)https://www.cnblogs.com/dolphin0520/p/3938914.html
14.Java并发编程:深入剖析ThreadLocalhttp://www.cnblogs.com/dolphin0520/p/3920407.html
14.《core java》