目录
冯诺依曼计算机模型
冯诺依曼是一位美籍匈牙利数学家,它在上个世纪提出了冯诺依曼计算机体系,指出计算机包含控制器、计算器、存储器、输入设备和输出设备五个部分,这为现代计算机的发展提供了理论基础。
现代计算机模型
缓存一致性
现代计算机最大的特点就是采用多CPU、CPU多核心以及每个CPU上存在的一个级缓存,其中多cpu和cpu多核都是为了实现程序的并行执行,多级缓存是为了平衡cpu计算速度与内存读写速度之间的差异,避免cpu被内存拖累。
在引入多核cpu和多级缓存后,带来的第一个挑战是如何保证cpu缓存之间的数据的一致性,因为在计算的时候,cpu是先将数据从主内存加载到缓存再进行计算,但由于每个cpu都有自己的缓存,所以就需要一种机制来保证主内存中的数据在多个cpu缓存中的副本是一致的,这就引出了缓存一致性协议。
缓存一致性协议MESI的全称是Modified-Exclusive-Shared-Invalid的缩写,假如有两个线程对主内存中的x执行x+=1的操作,当线程A从主内存中读取x的值时,总线上x的状态为E,当线程B从主内存中读取x的值时,总线上x的状态为S,在线程A将计算结果写回主内存的过程中,总线上x的状态为M,写入完成后x的状态为I,此时,其他cpu缓存中x的值就被设置成了失效,如果线程B再对x进行计算时,就会重新从主内存中读取x的最新值。
cpu乱序执行优化
第二个挑战来自于cpu的乱序执行优化,也就是指令重排序,cpu在保证最终结果正确的情况下,会对待执行的指令顺序进行重排,以最大化cpu的执行效率,cpu的乱序执行优化和java编译器的指令重排序是产生java并发安全问题的主要原因之一。
Java中的线程
进程&线程
进程是操作系统分配资源的基本单位,线程是cpu运行的基本单位,一个进程内包含多个线程。
并发与并行
并发指的是两个或多个线程间隔发生,并行指的是两个或多个线程在同一时刻同时发生,并发得益于cpu时间片轮转,并行得益于多cpu和多核cpu的硬件架构。
创建线程
- new Thread().start();
- new Runnable();
- new Callable();
- new FutureTask();
线程的生命周期
- 初始状态
- 就绪状态
- 阻塞状态
- 运行状态
- 终止状态
线程的优先级
理论上来说线程的优先级越高,获得到的cpu时间就越多,能够通过Thread.setPriprity方法设,但是有些操作系统直接忽略了优先级,所以设置线程优先级可能并没有什么效果。
使用多线程的好处和弊端
- 好处:最大化利用计算机资源、提高运行速度,简化某些场景下的编程模型;
- 弊端:造成并发安全问题、死锁问题、线程上下文切换带来额外的开销;
使用多线程需要解决的问题
多线程编程面临的主要问题是线程安全问题,主要体现在以下三个方面:
- 原子性:指的是一个操作的不可中断性;
- 可见性:线程工作内存和主内存分离引起的;
- 有序性:指令重排序和乱序执行优化引起的;
在32位系统中,对long和double类型的变量进行读写操作时,不是原子性的,因为long和double类型的变量是64位的,会被拆分成两个32位的操作。可见性指的是一个线程对变量进行的修改不能被其他线程马上感知到,这样就会产生脏数据。有序性指的是表面上我们认为程序是按照代码书写的顺序执行的,但是由于存在编译器指令重排序和cpu乱序执行优化,在实际执行的时候,有可能不是按照代码书写顺序执行。
死锁
对于死锁问题,产生的原因是,多个线程互相持有了对方能够继续执行所必须的锁,一般死锁都是代码级别的bug,可以为锁加上超时时间、避免锁的嵌套获取等来避免产生死锁的bug,另外在使用锁的时候应该避免嵌套锁。
ThreadLoacal
ThreadLocal的作用是为每个线程创建数据的副本,这样可以保证数据的线程安全。ThreadLocal底层采用Map的存储结构,key是当前线程,value是数据。
Java内存模型
在Java中,每个线程都有自己的工作内存,工作内存中存储了主内存中数据的副本,线程在进行计算的时候从自己的工作内存中获取数据,计算完成后再将工作内存中的数据写入主内存中,每个线程的工作内存都是封闭的,其他线程没办法访问。
在此内存模型的基础上,为了保证在多线程环境下程序的正确性,Java就引入了JMM的概念,翻译过来是Java内存模型,JMM有两层含义,第一层含义是它为jvm屏蔽了计算机硬件的内存模型,也就是多cpu+多级缓存的架构,第二层含义是它指定了解决java并发问题的通方案,但这个方案并不是某个技术点,而是一系列的规则定义,JMM规范主要体现在以下几个方面:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作;
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁;
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读;
- 传递性规则:A happens-before B,B happens-before C,那么A happens-before C;
- start()规则:A线程的B.start() happens-before于B线程的任意操作;
- join()规则:A.join(B),那么B的任意操作 happens-before于A的后续所有操作;
但实际上,JMM本身也是一个规范,而这些规范的实现就是Java中与并发相关的一系列关键字和组件,比如final、volatile、synchronized,以及concurrent包下的一系列的同步组件。
并发安全问题的解决方案
前面提到,并发安全问题主要体现在三个方面:原子性、可见性和有序性。对于原子性来说,可以通过synchronized或Lock锁来保证一组操作的有序性;可见性可以通过synchronized、Lock和volatile来解决,有序性也可以通过volatile、synchronized和Lock来解决。
volatile
volaitile的主要作用是禁用指令重排序和保证内存可见性,禁用指令重排序是通过在指令之间插入内存屏障来实现的,而保证内存可见性则是通过在指令中插入一个#lock信号来实现的,这个东西的主要作用就是使得其他线程的工作内存中的当前变量的副本失效,进而强制其他线程从主内存中重新读取最新的数据。
synchronized
synchronized主要可以解决原子性、可见性和有序性问题,本质上是加锁,可以说是解决线程安全问题的神器,它的使用方式有三种:
- 用在静态方法上:锁对象是所在类的Class对象;
- 用在成员方法上:锁对象是当前对象;
- 同步代码块:锁对象是指定的对象;
使用synchronized时,jvm可以自动进行加锁和解锁操作,在jdk1.6以后,java对synchronized进行了优化,引入了偏向锁和轻量级锁,使得synchronized的性能有了很大的提升。
synchronized本质是通过插入monitorenter和monitorexit指令来实现的,锁状态被记录在锁对象的对象头中的markworld里。markworld中包含了对象的hashcode、GC年龄、偏向线程ID、锁状态等,但是为了最大化空间利用率,这个markworld的结构并不是固定的,而是随着锁的升级动态变化的。
当加锁操作一直是由同一个线程执行时,此时并不会真正的加锁,而是将markworld中的偏向线程ID指向当前线程ID,这样就能大大加快同一个线程的加锁/解锁的过程,当出现锁竞争时,偏向锁就会升级为轻量级锁,轻量级锁的加锁过程是,首先在每个线程的线程栈中开辟一块空间将锁对象的对象头的markworld内容复制过来,然后利用cas尝试将锁对象头中的markworld指向当前线程的线程栈中的markworld副本,如果成功则表示加锁成功,轻量级锁适用于在一个加锁周期内不存在竞争的场景,也就是锁能够被快速释放,如果还存在锁竞争,则直接升级为重量级锁。另外需要注意的是,锁的升级过程是不可逆的。
final
final能够修饰类、方法和字段,被final修饰的类不能够被继承,被final修饰的方法不能被覆盖,被final修饰的字段不能修改,通过final可以构造不可变对象,不可变性具有天然的线程安全性,很多基于消息的并发框架都是基于不可变性实现线程安全的。
cas
CAS是Compare And Swrap的缩写,翻译过来是先比较后替换,是一种无锁的实现线程安全的技术,一般采用循环+cas来实现并发安全的修改,因为这种方式不会阻塞线程,所以执行效率会比加锁的实现方式更高,但是CAS也存在一些问题,比如:
- 容易出现活锁;
- 存在ABA问题;
对于活锁的问题,可以通过增加超时逻辑来控制,对于ABA问题,可以通过增加版本号来处理,比如基于数据库实现的乐观锁实际上就是一种CAS的思想,通过增加版本号来保证不会出现更新丢失。
AQS
JUC中的组件很大一部分都是基于AbstractQueuedSynchronizer实现的,也就是AQS,AQS是一种基于状态的同步器框架,主要有以下的特征:
- 包含同步队列和阻塞队列;
- 支持独占模式和共享模式;
- 支持中断;
- 支持超时;
比如在开发中常用的ReentrantLock、CountdownLatch、CyclicBarrier、Semaphore、ReentrantReadWriteLock等都是基于AQS实现的,AQS的一般使用范式是,在同步组件内部定义一个AQS的子类,然后通过重写与同步状态管理相关的方法来实现不同的同步语义,比如:
- tryAcquire()
- tryRelease()
- tryAcquireSchared()
- tryReleaseShared()
同步状态
AQS中的同步状态是一个int类型变量,这个同步状态是整个AQS的核心,它的具体语义是由上层组件决定的。
同步队列和等待队列
同步队列和等待队列都是双端队列,用于存储线程实例,但应用场景不同,同步队列多用在类似锁的场景中,如果一个线程获取同步状态失败就会被加入到同步队列中;等待队列主要用于实现wait()/notify()语义,一个同步队列可以对应多个等待队列,当调用await()方法时,线程从同步队列转移到等待队列,当调用notify()方法时,线程从等待队列转移到同步队列。
公平模式与非公平模式
公平与非公平模式通常是针对Lock锁来说的,在公平模式下,锁的获取顺序严格按照对api的调用顺序分配,也就是有先来后到,而对于非公平模式的锁,当调用api时直接尝试抢占同步状态获取锁,如果失败,再加入到同步阻塞队列中,相对来说,非公平模式的效率会更高(ReentrantLock对公平模式和非公平模式进行了支持,只需要通过构造参数就可以选择)。
独占模式与共享模式
独占模式和共享模式描述的是对同步状态的不同操作逻辑,一般在独占模式下,同步状态被初始化为0,当同步状态变为1,即有一个线程获取了同步状态后,就不允许其他线程再获取,也就是会把其加入到同步阻塞队列中,比如ReentrantLock就是基于AQS的独占模式实现的,而共享模式一般是将同步状态初始化成>1的值,可以使得多个线程同时获取到同步状态,比如CountdownLatch、Semaphore等组件都是基于共享模式构建的。
AQS核心原理
独占模式下的加锁和解锁
对于独占模式,可以通过分析ReentrantLock来实现,主要分析acquire()和release()。在acquire()和release()中调用了tryAcqurire()和tryRelease(),而ReentrantLock中的AQS的子类Sync重新了这两个方法,先来看看这两个方法。
static final class NonfairSync extends Sync {
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
abstract static class Sync extends AbstractQueuedSynchronizer {
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//同步状态=0,说明没有线程获取到同步状态,即锁没有被获取
if (c == 0) {
//由于是非公平模式,所以这里进行了一个快速的抢占,
//如果成功,则成功获取锁状态,而不管同步队列中等待的线程
if (compareAndSetState(0, acquires)) {
//设置锁所属的线程
setExclusiveOwnerThread(current);
return true;
}
}
//可重入性:
//如果同步状态不等于0,但是是同一个线程多次获取
//也就是同一个线程多次调用lock方法,那么说明是重入的
//则对同步状态+1操作
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
下面在进入到AQS中与独占模式有关的两个关键方法acquire()和release()。
public final void acquire(int arg) {
/**
这里主要做了三件事,
第一:尝试调用tryAcquire(),如果返回true,则说明成功获取同步状态,直接返回,如果返回false,获取同步状态失败
第二:在获取同步状态失败的情况下,构造EXCLUSIVE类型的Node节点,并通过循环+CAS的方式线程安全的将其添加到同步队列末尾
第三:让Node对象的线程进入自旋状态,其实也就是阻塞状态,等待前驱节点释放锁后唤醒当前节点对应的线程
*/
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
下面分别来看一下addWaiter()和acquireQueued()这两个方法。
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
/**
这里的注释写的很清楚,一进来先进行一个快速尝试,利用cas的方式尝试将当前节点添加到同步队列的末尾
如果成功,则直接返回,如果失败则在进入自旋
*/
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
/**
这里其实就是一个cas的典型应用范式:自旋+cas 实现线程安全的更新操作
*/
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
/**
这个方法是将Node节点代表的线程进入自旋状态,说白了就是死循
*/
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
//一进来先判断当前节点的前驱节点是不是头结点
//如果是,并且能成功获取了同步状态,说明头结点已经释放了同步状态
//那么直接将当前节点设置为头节点,并直接返回
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
//否则,将他的前驱节点的节点类型设置为Signal,这种类型的节点,在释放同步状态后,会主动唤醒后继节点
//然后通过LockSupport.park()方法将当前节点挂起
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
下面再来看看release()方法
public final boolean release(int arg) {
//调用模板方法释放掉同步状态,然后通过LockSupport.unpark()方法唤醒后继节点
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
private void unparkSuccessor(Node node) {
Node s = node.next;
LockSupport.unpark(s.thread);
}
公平模式与非公平模式
//非公平模式
final void lock() {
//尝试抢占同步状态
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
//公平模式
final void lock() {
//没有抢占同步状态的逻辑
acquire(1);
}
下面整体总结一下独占模型下的AQS的工作原理:
- 在非公平模式下,调用tryAcquire()进行一个同步状态的抢占,如果成功则直接返回,如果失败,往下走;
- 如果是重入,则直接增加同步状态的值返回true,如果不是则返回false;
- 构造一个EXCLUSIVE类型的Node节点,然后通过自旋+cas的方式将这个节点线程安全的添加到同步队列的末尾;
- 然后让这个Node代表的线程进入自旋状态;
- 自旋过程中,如果当前节点的前驱节点是头结点,并且成功获取了同步状态则直接返回,否则设置他的前驱节点类型为Signal,并利用LockSupport.park()将这个线程挂起;
- 释放时,调用tryRelease先释放同步状态,然后利用LockSupport.unpark()唤醒后继节点对应的线程;
共享模式下的加锁和解锁
对于共享模式,juc中的Semaphore就是用共享模式实现的,主体处理流程和独占模式一致,只是对成功获取同步状态的定义不同,共享模式的入口是acquireShared()和releaseShared()。
public final void acquireShared(int arg) {
/**
这里调用了tryAcquireShared(),这里的条件是只要返回值>=0,就表示获取成功
以Semaphore为例,在初始化的时候需要指定一个信号的数量,这个就是同步状态的初始值
如果返回值<0,说明同步状态已经获取完了,就进入doAcquireShared()中
*/
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
private void doAcquireShared(int arg) {
/**
这里和独占模式的逻辑基本一致,唯一的区别在于Node类型是SHARED
*/
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
//进入自旋
for (;;) {
//尝试获取同步状态
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
//同步状态>=0,
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
//否则把当前线程挂起
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
带超时的加锁模式
与超时相关的方法有tryAcquireNanos()和tryAcquireSharedNanos()这两个方法,分别对应了独占模式和共享模式。
public final boolean tryAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
return tryAcquire(arg) ||
doAcquireNanos(arg, nanosTimeout);
}
/*
这个方法是处理超时的核心逻辑
超时逻辑如下:
1. 根据设置的超时时间 + 当前时间 计算出 超时的最终时间点 deadline
2. 循环的过程中,用deadline - 当前时间,计算出剩余的超时时间
3. 如果剩余超时时间小于0,则说明超时,直接返回
4. 如果剩余超时时间 < 一个时间的阈值 1000ms,则不挂起线程,直接进入自旋,来保证超时的精确性
5. 如果剩余时间 > 阈值时间 ,则挂起当前线程
6. 过interrupted()检验中断状态,并抛出InterruptedException异常
**/
private boolean doAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
if (nanosTimeout <= 0L)
return false;
final long deadline = System.nanoTime() + nanosTimeout;
//利用cas + 自旋 线程安全的将Node节点添加到同步队列的尾部
final Node node = addWaiter(Node.EXCLUSIVE);
boolean failed = true;
try {
for (;;) {
//尝试获取同步状态
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return true;
}
//计算剩余超时时间
nanosTimeout = deadline - System.nanoTime();
if (nanosTimeout <= 0L)
return false;
if (shouldParkAfterFailedAcquire(p, node) &&
//判断剩余时间与指定的阈值大小
nanosTimeout > spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
//对中断进行响应
if (Thread.interrupted())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
可中断的加锁
对中断的响应原理其实很简单,当我们尝试中断某个线程时,会调用interrupt()方法,给线程一个中断信号,然后AQS在对应的方法中会通过Thread.interrupted()来判断线程是否被中断,如果返回true,则抛出InterruptedException。
- Thread.interrupt() 并不会直接中断线程,而是给线程一个中断信号(while(!interruptFlag ||Thread.interrupted()));
- Thread.interrupted() 会获取当前线程的中断状态,并重置。比如中断状态为true,调用interrupted()方法后,该方法返回true,然后将中断状态设置为false;
- Thread.isInterrupted()与Thread.interrupted()的唯一区别是它不会重置中断状态;
读写锁的加锁和解锁
在juc中的读写锁是ReentrantReadWriteLock,通过切割AQS中的同步状态来记录读写锁的获取和释放情况,高16位表示读锁,低16位表示写锁,通过位运算对同步状态进行切割。
在获取读锁时,首先检查同步状是否>0,如果>0,说明存在读锁或写锁,然后分离高16位和低16位,如果低16位>0,说明存在写锁,那读锁的获取过程就应该被阻塞,如果不存在写锁,则直接增加高16位的读锁。在获取写锁的时候,如果存在读锁或存在写锁,都不能成功获取写锁,但如果只存在写锁,且是同一个线程多次获取,那是可以的,另外这个读写锁支持锁的降级,就是从写锁降级为读锁,过程是先获取写锁,在获取读锁,最后在释放写锁。
等待通知机制
Lock锁对象中关联了Condition,这个Condition的主要作用是实现等待/通知机制,每个Lock对象可以关联多个Condition对象,Condition的使用范式是:
Lock lock = new ReentrentLock();
Condition condition = lock.newCondition();
lock.lock();
try {
condition.await(); / condition.signal(); / condition.signalAll()
}finally {
lock.unlock();
}
Condition对象是AQS内部类ConditionObject,内部包含了一个等待队列,当调用await()方法时,由于前提是当前线程获取了同步锁,所以也就是将同步队列中的头节点加入到等待队列的尾节点,在将线程对应的Node节点加入到等待队列时,并没有使用cas+循环的方式,因为这是在获取锁的状态下进行的,不存在线程安全问题。
当调用signal()时,实际上就是将当前condition对应的等待队列中的头节点利用cas+循环的方式线程安全的添加到同步队列中,而singnalAll方法其实就是对把待队列中的每个节点都放入同步队列中。
JUC核心组件
ReentrantLock
ReentrentLock是juc包中的锁的实现,类似于synchronized,但是它提供了更丰富的特性,比如,公平性/非公平性,支持超时,能够对中断进行响应等。
ReentrantReadWriteLock
ReentrentReadWriteLock底层也是通过AQS实现的,它把同步状态掰成两半,高16位表示读锁、低16位表示写锁,读与读不互斥,读与写互斥,写与写互斥,具体的过程是首先检查同步状态是否为0,如果为0说明没有任何锁,如果不为0,说明有读锁或写锁,然后分离出高16位和低16位,在进一步判断需不需要互斥处理。读写锁与互斥锁有一点不同,当一个线程由于发生互斥操作被加入到同步队列后,互斥操作结束后,同步队列头节点后面的线程会被全部唤醒,而不是像互斥锁那样值唤醒一个后继节点,这主要是因为存在读与读不互斥的场景,要保证这一点。
Semaphore
Semaphore被翻译成信号量,通常被用来控制最大并发数,有时候也会用在简单的限流场景中。底层完全是基于AQS实现,在初始化的时候,能够指定AQS同步状态数,当调用acquire()方法时,就对同步状态-1,直到减到0为止,同样的,调用release方法就会对同步状态+1。
Semaphore也支持公平性和非公平性,只要在构造函数中指定相应的参数就行,它是基于AQS的同步队列实现的。
CountdownLatch
CountdownLatch的主要用来实现主线程等待所有子任务完成后再继续执行的场景,内部同样是维护了一个AQS的子类,在初始化时指定同步状态值,当调用countDown()时同步状态-1,主线程在await()上阻塞,直到同步状态值为0。
public void countDown() {
//释放掉一个同步状态值
sync.releaseShared(1);
}
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
/**
只有状态值=0的时候,再继续执行,否则被阻塞
*/
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
/**
当调用await()方法时,底层调用acquireShared()方法,只有当同步状态是0的时候才返回
1,也就是成功获取同步状态,都在返回-1,让当前线程进入阻塞状态。
*/
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
/***
当调用countDown()的时候,底层调用releaseShared()方法,释放同步状态
只有当同步状态为-到0时,在返回true,也就是唤醒在同步队列中等待的主线程。
*/
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c-1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
CyclicBarrier
CysclicBarrier从功能上来看是一种可重用的CountdownLatch,CountdownLatch底层是基于AQS的共享模式实现的,但CyclicBarrier是基于Condition await()/notify()实现的 。CyclicBarrier内部包含了一个Lock、一个Condition对象和一个信号量,当调用await方法的时候,会判断信号量是否为0,如果为0,说明所有子任务都调用了await()方法,然后调用初始化的时候指定的Runnable对象,然后再Condition对象上调用signalAll方法唤醒所有等待的任务,否则就在Condition对象上调用await()方法进入等待。
private int dowait(boolean timed, long nanos)
throws InterruptedException, BrokenBarrierException,
TimeoutException {
final ReentrantLock lock = this.lock;
lock.lock();
try {
//调用一个CyclicBarrier的await()方法 信号量就 - 1
int index = --count;
if (index == 0) { // tripped
boolean ranAction = false;
try {
final Runnable command = barrierCommand;
if (command != null)
//调用Runnable任务
command.run();
return 0;
} finally {
if (!ranAction)
//trip.notifyAll()
breakBarrier();
}
}
// loop until tripped, broken, interrupted, or timed out
for (;;) {
try {
if (!timed)
//在Condition上调用await()进行等待
trip.await();
else if (nanos > 0L)
nanos = trip.awaitNanos(nanos);
}
} finally {
lock.unlock();
}
}
比如吃饭的时候要等到所有人到了(调用了await()方法)才能开始吃饭(继续执行),旅游的时候要等到所有人都到了(调用await())才能开车。
并发集合
并发集合也是juc包中的一个重要组成部分,主要包含Map、List和Queue,Map类最重要也是最常用的是ConcurrentHashMap,List类最常见的是CopyOnWriteArrayList,Queue类型主要包括ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue和DelayQueueu。
ConcurrentHashMap
ConcurrentHashMap是一种并发安全的HashMap,具体的实现分为1.7和1.8+两种,因为在jdk 1.8中对ConcurrentHashMap的实现做了很多的优化,jdk.7中的ConcurrentHashMap的原理简单理解就是分段锁 + 2次哈希,内部包含了一个Segment数组,Segment就是一个分段锁,所以ConcurrentHashMap的并发度就是Segment数组的长度,默认情况下Segment数组的长度是16,在创建的时候可以指定,但内部会保证这个数子是2的整数次幂,每个Segment内部是一个MapEntry数组,这就可以理解为是一个HashMap了,Segment一旦确定就不能够进行修改了,但是Segment内部的HashEntry数组是可扩容的,它是ConcurrentHashMap扩容的基本单位,扩容的逻辑和HashMap基本一致。
对于put操作,首先会通过ReentrantLock的lock()方法对segment进行加锁,然后再通过计算hash槽位定位到具体的MapEntry,如果MapEntry的一个的链表没有当前key,那么这个kv就会被加入到链表的头部,如果存在的话,就会用新的value覆盖老的value。
对于size()或containsKey()这种操作,由于内部包含了多个Lock,所以首先会尝试两次获取每个segment对应的modifycount,如果两次一致就直接返回结果,如果经过了指定次数的尝试后还不一致,就会对所有的Segment强制加锁。
jdk 1.7中的ConcurrentHashMap数据越多,性能就越差,因为它的并发度没办法动态调整,在jdk 1.8中抛弃了分段锁的设计,采用synchronized同步代码块+cas实现,在添加数据的时候,只进行一次hash计算,如果对应槽位上的Node为空,则使用cas+循环的方式设置Node,否则,synchronized同步代码块的锁对象就是链表的头节点(加载因子 0.75,链表转红黑树阈值是8,红黑树转链表阈值是6)。
另外,对于扩容逻辑也进行了优化,在1.7以前是通过rehash的方式实现的,在1.8中是通过高地位的方式实现的,通过保证哈希槽位是2的正数次幂来实现当扩容时数据要么在原来的位置,要么在原位置的2倍的位置,这种扩容的效率会更高。
CopyOnWriteArrayList
CopyOnWriteArrayList是一种在并发场景下使用的List,采用写时复制的思想,适用于读多写少的场景,当进行写入操作的时候,底层重新创建了数组对象,然后将原有的数据复制到新的数组中,再在这个新数组的基础上来操作,最后在把底层数组的引用指向这个新的数组。
public E set(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
E oldValue = get(elements, index);
if (oldValue != element) {
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len);
newElements[index] = element;
setArray(newElements);
} else {
// Not quite a no-op; ensures volatile write semantics
setArray(elements);
}
return oldValue;
} finally {
lock.unlock();
}
}
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
可以看到,在修改数据时进行了加锁操作,并且使用的锁对象时同一个,这样就保证了修改操作是互斥的,但是对于get和contains等查询操作,并没有加锁
private E get(Object[] a, int index) {
return (E) a[index];
}
但是CopyOnWrite思想也有一定的弊端,那就是那不能保证数据的实时一致性,只能保证最终一致性,例如size,isEmpty等操作。
public boolean isEmpty() {
return size() == 0;
}
public int size() {
return getArray().length;
}
BlockiingQueue
阻塞队列是一种特殊的队列,当从队列中获取数据时,如果队列为空,则线程会被阻塞直到队列中有新的数据,当向队列中添加数据时,如果队列满了,那么线程也会被阻塞,直到有一个空位置存在。
ArrayBlockingQueue
ArrayBlockingQueue底层是基于数组实现的,它是一个有界阻塞队列,阻塞队列的阻塞功能,底层实际上是通过ReentrantLock来实现的,内部维护了一个Lock锁和两个Condition等待队列notFull和notEmpty,当添加数据是,如果队列满,则调用notFull.await()方法,将当前线程加入到notFull等待队列,否则,在把数据加入到队列的同时,还会在notEmpty等待队列上调用signal()方法,来唤醒在notEmpty队列上等待的线程,当获取数据时,如果队列为空,则调用notEmpty.await(),将当前线程加入到notEmpty等待队列中,否则,在从队列中获取数据的同时,还会在notFull队列上调用signal()方法,来唤醒那些因为队列满了而添加操作被阻塞的线程。
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == items.length)
//满了,在notFull上等待
notFull.await();
enqueue(e);
} finally {
lock.unlock();
}
}
private void enqueue(E x) {
// assert lock.getHoldCount() == 1;
// assert items[putIndex] == null;
final Object[] items = this.items;
items[putIndex] = x;
if (++putIndex == items.length)
putIndex = 0;
count++;
//添加完成后,唤醒在notEmpty上等待的线程
notEmpty.signal();
}
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
notEmpty.await();
return dequeue();
} finally {
lock.unlock();
}
}
private E dequeue() {
// assert lock.getHoldCount() == 1;
// assert items[takeIndex] != null;
final Object[] items = this.items;
@SuppressWarnings("unchecked")
E x = (E) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length)
takeIndex = 0;
count--;
if (itrs != null)
itrs.elementDequeued();
notFull.signal();
return x;
}
LinkedBlockingQueue
LinkedBlockingQueue的功能和特性基本与ArrayBlockingQueue一致,区别在于LinkedBlockingQueue在不指定容量的情况下是一个无界的阻塞队列。
PriorityBlockingQueue
PriorityBlockingQueue是一个具有优先级的阻塞队列,它底层用基于数组的平衡二叉树实现,能够自动扩容,扩容比例为原容量的两倍,数据的优先级通过Comparable接口来定义,阻塞功能同样是通过与Lock对象关联的两个Condition对象notFull和notEmpty来实现。
DelayQueue
DelayQueue是一个将到期时间作为优先级的Queue,它底层基于PriorityQueue和Lock/Condition来实现,在获取数据的时候每次都是获取优先级最高的(也就是过期时间最少的),这是通过Condition提供的带有超时时间的await()来实现的。
Atomic包
atomic包提供了一系列的AtomicXxx类,能够实现对基本数据类型、数组、引用数据类型和对象域的线程安全的更新操作,底层基于操作系统提供的指令来实现的。
AtomicBoolean、AtomicInteger、AtomicLong
AtomicReference、AtomicStampedReference、AtomicMarkableReference
AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdator
线程池
线程是系统的稀缺资源,线程的创建和销毁开销很大,如果系统中线程数量过多,会严重影响系统的稳定性,为了解决这些问题,java引入了线程池,线程池本质是是一个线程缓存,使线程能够被复用,而不是频繁的创建和销毁,使用线程池有很多好处,比如:
- 实现了线程的复用,降低系统开销;
- 能够对线程进行统一的管理;
- 避免的线程的创建和销毁过程,提高系统响应速度;
在java中的线程池有时候也被称为Executor框架:
主要可以分为两类,一类的正常的线程池,一类的支持任务周期执行的线程池,核心的值原理如下:
理解线程池原理,最简单的方式是理解ThreadPoolExecutor这个类的构造函数中的参数的作用:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
}
- corePoolSize:最小活跃线程数,即线程池中最小的缓存线程数;
- maximumPoolSize:最大活跃线程数,即线程池能够支持的最大的线程数;
- keepAliveTime/unit:由于最大线程数 - 最小线程数 之间的线程属于临时工,在任务不忙的时候他们需要被销毁,这两个参数就是定义这部分线程的最大空闲时间,超过这个时间,就会被回收;
- workQueue:用来存储正在等待执行的线程,这个参数一般设置成一个有界队列,避免造成内存溢出;
- threadFactory:创建线程的工厂,一般会通过自定义线程工厂类给线程指定有意义的名称,这有助于定位问题;
- handler:拒绝策略,当已经达到最大线程数,并且等待队列也满了的时候,就会触发拒绝策略,拒绝策略有四种,分别是抛异常、使用调用者线程进行处理、抛弃最近的一个任务、不处理直接丢掉;
下面描述一下整个过程,当通过execute或submit方法提交任务到线程池时,如果线程数没有达到最小线程数,则直接新启动线程来执行(这也是一种线程池预热),如果已经达到了最小活跃线程数,但等待队列没满,则直接将任务添加到等待队列中,如果等待队列已经满了,但是线程数没有达到最大线程数,则启动临时线程执行,否则触发拒绝策略。
其他
Fork/Join框架
略。
Exchanger
Exechanger的主要作用是实现线程间的数据交换,它提供了一个同步点,两个线程就可以在这个同步点上进行数据的交换
public static void main(String[] args) {
Exchanger<String> exchanger = new Exchanger<>();
ExecutorService executorService = Executors.newFixedThreadPool(2);
executorService.execute(new Runnable() {
@Override
public void run() {
String A = "a";
try {
exchanger.exchange(A);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
executorService.execute(new Runnable() {
@Override
public void run() {
String B = "b";
try {
String A = exchanger.exchange(B);
System.out.println("B:" + A);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
executorService.shutdown();
}
Future/FutureTask
Future是异步编程常用的东西,它可以理解为是线程的一个句柄,通过Future可以获取线程的执行结果、以及取消执行等操作,一般通过线程池的submit来获取Future实例。而FutureTask是Future和Runnable的组合,通过它可以定义线程任务,然后通过Thread来驱动执行。
FutureTask底层是通过AQS实现的,当调用Future的get()方法时,底层调用的是acquire,当执行run方法或调用Future.cancel()时底层调用了release()方法,从而实现了获取结果的阻塞功能。