并发编程简介
java是一个支持多线程的开发语言。多线程可以在包含多个CPU核心的机器上同时处理多个不同的任务,优化资源的使用率,提升程序的效率。在一些对性能要求比较高场合,多线程是java程序调优的重要方面。
Java并发编程主要涉及以下几个部分:
-
并发编程三要素
原子性:即一个不可再被分割的颗粒。在Java中原子性指的是一个或多个操作要么全部执行成 功要么全部执行失败。
有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
可见性:当多个线程访问同一个变量时,如果其中一个线程对其作了修改,其他线程能立即获 取到最新的值。 -
线程的五大状态
创建状态: 当用 new 操作符创建一个线程的时候
就绪状态: 调用 start 方法,处于就绪状态的线程并不一定马上就会执行 run 方法,还需要等 待CPU的调度
运行状态: CPU 开始调度线程,并开始执行 run 方法
阻塞状态: 线程的执行过程中由于一些原因进入阻塞状态比如:调用 sleep 方法、尝试去得到 一个锁等等
死亡状态: run 方法执行完 或者 执行过程中遇到了一个异常 -
悲观锁与乐观锁
悲观锁: 每次操作都会加锁,会造成线程阻塞。
乐观锁: 每次操作不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止,不会造成线程阻塞。 -
线程之间的协作
线程间的协作有:wait/notify/notifyAll等 -
synchronized 关键字
synchronized是Java中的关键字,是一种同步锁。它修饰的对象有以下几种:
修饰一个代码块: 被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来 的代码,作用的对象是调用这个代码块的对象
修饰一个方法: 被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象 是调用这个方法的对象
修饰一个静态的方法: 其作用的范围是整个静态方法,作用的对象是这个类的所有对象
修饰一个类: 其作用的范围是synchronized后面括号括起来的部分,作用主的对象 是这个类的所有对象。 -
CAS
CAS全称是Compare And Swap,即比较替换,是实现并发应用到的一种技术。操作包含三个 操作数—内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配, 那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。
CAS存在三大问题:ABA问题,循环时间长开销大,以及只能保证一个共享变量的原子操作。 -
线程池 如果我们使用线程的时候就去创建一个线程,虽然简单,但是存在很大的问题。如果并发的线 程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大 大降低系统的效率,因为频繁创建线程和销毁线程需要时间。线程池通过复用可以大大减少线 程频繁创建与销毁带来的性能上的损耗。
常见的多线程面试题有:
- 重排序有哪些分类?如何避免?
- Java 中新的Lock接口相对于同步代码块(synchronized block)有什么优势?如果让你实现一
个高性能缓存,支持并发读取和单一写入,你如何保证数据完整性。 - 如何在Java中实现一个阻塞队列。
- 写一段死锁代码。说说你在Java中如何解决死锁。
- volatile变量和atomic变量有什么不同?
- 为什么要用线程池?
- 实现Runnable接口和Callable接口的区别
- 执行execute()方法和submit()方法的区别是什么呢?
- AQS的实现原理是什么?
- java API中哪些类中使用了AQS?
- …
第一部分:多线程&并发设计原理
1 多线程回顾
1.1 Thread和Runnable
1.1.1 Java中的线程
创建执行线程有两种方法:
- 扩展Thread 类。
- 实现Runnable 接口。
扩展Thread类的方式创建新线程:
public class MyThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println(Thread.currentThread().getName() + " 运行了");
try {
Thread.sleep(800);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Main {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
}
}
实现Runnable接口的方式创建线程:
public class MyRunnable implements Runnable {
@Override
public void run() {
while (true) {
System.out.println(Thread.currentThread().getName() + " 运行了");
try {
Thread.sleep(800);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Main {
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start();
}
}
1.1.2 Java中的线程: 特征和状态
-
所有的Java 程序,不论并发与否,都有一个名为主线程的Thread对象。执行该程序时, Java 虚拟机( JVM )将创建一个新Thread 并在该线程中执行main()方法。这是非并发应用程序中唯一的线程,也是并发应用程序中的第一个线程。
-
Java中的线程共享应用程序中的所有资源,包括内存和打开的文件,快速而简单地共享信息。 但是必须使用同步避免数据竞争。
-
Java中的所有线程都有一个优先级,这个整数值介于Thread.MIN_PRIORITY(1)和 Thread.MAX_PRIORITY(10)之间,默认优先级是Thread.NORM_PRIORITY(5)。虽然设置了优先级,但是线程的执行顺序并没有保证,通常,较高优先级的线程将在较低优先级的钱程之前执行。
-
在Java 中,可以创建两种线程:
守护线程 和 非守护线程,两者的区别在于它们如何影响程序的结束Java程序结束执行过程的情形:
1)程序执行Runtime类的exit()方法, 而且用户有权执行该方法。
2)应用程序的所有非守护线程均已结束执行,无论是否有正在运行的守护线程。守护线程通常用在作为垃圾收集器或缓存管理器的应用程序中,执行辅助任务。在线程start之前调 用isDaemon()方法检查线程是否为守护线程,也可以使用setDaemon()方法将某个线程确立为守护线程。
-
Thread.States类中定义线程的状态如下
NEW: Thread对象已经创建,但是还没有开始执行。RUNNABLE: Thread对象正在Java虚拟机中运行。
BLOCKED : Thread对象正在等待锁定。
WAITING: Thread 对象正在等待另一个线程的动作。
TIME_WAITING: Thread对象正在等待另一个线程的操作,但是有时间限制。
TERMINATED: Thread对象已经完成了执行。
getState()方法获取Thread对象的状态,可以直接更改线程的状态。
在给定时间内, 线程只能处于一个状态。这些状态是JVM使用的状态,不能映射到操作系统的线程状态。
线程状态的源码: (枚举)
1.1.3 Thread类和Runnable 接口
Runnable接口只定义了一种方法:run()方法。这是每个线程的主方法。当执行start()方法启动新线程时,它将调用run()方法。
Thread类其他常用方法:
- 获取和设置Thread对象信息的方法。
getId(): 返回Thread对象的标识符。该标识符是在钱程创建时分配的一个正整数。在线程的整个生命周期中是唯一且无法改变的。
getName()/setName(): 允许你获取或设置Thread对象的名称。这个名称是一个String对象,也可以在Thread类的构造函数中建立。
getPriority()/setPriority(): 你可以使用这两种方法来获取或设置Thread对象的优先级。
isDaemon()/setDaemon(): 这两种方法允许你获取或建立Thread对象的守护条件。
getState(): 该方法返回Thread对象的状态。 - interrupt(): 中断目标线程,给目标线程发送一个中断信号,线程被打上中断标记。
- interrupted(): 判断目标线程是否被中断,但是将清除线程的中断标记。
- isinterrupted(): 判断目标线程是否被中断,不会清除中断标记。
- sleep(long ms): 该方法将线程的执行暂停ms时间。
- join(): 暂停线程的执行,直到调用该方法的线程执行结束为止。可以使用该方法等待另一个 Thread对象结束。
- setUncaughtExceptionHandler(): 当线程执行出现未校验异常时,该方法用于建立未校验异 常的控制器。
- currentThread(): Thread类的静态方法,返回实际执行该代码的Thread对象。
join() 示例
public class MyThread extends Thread {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("MyThread线程:" + i);
}
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
MyThread myThread = new MyThread();
myThread.start();
myThread.join();
System.out.println("main线程 - 执行完成");
}
}
1.1.4 Callable
Callable 接口是一个与Runnable 接口非常相似的接口。Callable 接口的主要特征如下。
- 接口。有简单类型参数,与call()方法的返回类型相对应。
- 声明了call()方法。执行器运行任务时,该方法会被执行器执行。它必须返回声明中指定类型的对象。
- call()方法可以抛出任何一种校验异常。可以实现自己的执行器并重载afterExecute()方法来处理这些异常。
public class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
Thread.sleep(5000);
return "hello world call() invoked!";
}
}
public class Main {
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable myCallable = new MyCallable();
// 设置Callable对象,泛型表示Callable的返回类型
FutureTask<String> futureTask = new FutureTask<String>(myCallable);
// 启动处理线程
new Thread(futureTask).start();
// 同步等待线程运行的结果
String result = futureTask.get();
// 5s后得到结果
System.out.println(result);
}
}
public class Main2 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10)){
@Override
protected void afterExecute(Runnable r, Throwable t) {
//如果在call方法执行过程中有错误,则可以在此进行处理
super.afterExecute(r, t);
}
};
Future<String> future = executor.submit(new MyCallable());
String s = future.get();
System.out.println(s);
executor.shutdown();
}
}
1.2 synchronized关键字
1.2.1 锁的对象
synchronized关键字“给某个对象加锁”,示例代码:
public Class MyClass {
public void synchronized method1() {
// ...
}
public static void synchronized method2() {
// ...
}
}
//等价
public class MyClass {
public void method1() {
synchronized(this) { // ...
}
}
public static void method2() {
synchronized(MyClass.class) {
// ...
}
}
}
实例方法的锁加在对象myClass上;静态方法的锁加在MyClass.class上。
1.2.2 锁的本质
如果一份资源需要多个线程同时访问,需要给该资源加锁。加锁之后,可以保证同一时间只能有一 个线程访问该资源。资源可以是一个变量、一个对象或一个文件等。
锁是一个“对象”,作用如下:
- 这个对象内部得有一个标志位(state变量),记录自己有没有被某个线程占用。最简单的情况是这个state有0、1两个取值,0表示没有线程占用这个锁,1表示有某个线程占用了这个锁。
- 如果这个对象被某个线程占用,记录这个线程的thread ID。
- 这个对象维护一个thread id list,记录其他所有阻塞的、等待获取拿这个锁的线程。在当前线程释放锁之后从这个thread id list里面取一个线程唤醒。
要访问的共享资源本身也是一个对象,例如前面的对象myClass,这两个对象可以合成一个对象。 代码就变成synchronized(this) {…},要访问的共享资源是对象a,锁加在对象a上。当然,也可以另外新建一个对象,代码变成synchronized(obj1) {…}。这个时候,访问的共享资源是对象a,而锁加在新建的对象obj1上。
资源和锁合二为一,使得在Java里面,synchronized关键字可以加在任何对象的成员上面。这意味着,这个对象既是共享资源,同时也具备“锁”的功能!
1.2.3 实现原理
在对象头里,有一块数据叫Mark Word。在64位机器上,Mark Word是8字节(64位)的,这64位中有2个重要字段:锁标志位和占用该锁的thread ID。因为不同版本的JVM实现,对象头的数据结构会有各种差异。
1.3 wait与notify
1.3.1 生产者−消费者模型
生产者-消费者模型是一个常见的多线程编程模型,如下图所示:
一个内存队列,多个生产者线程往内存队列中放数据;多个消费者线程从内存队列中取数据。要实现这样一个编程模型,需要做几件事情:
- 内存队列本身要加锁:才能实现线程安全。
- 阻塞:当内存队列满了,生产者放不进去时,会被阻塞; 当内存队列是空的时候,消费者无事可做,会被阻塞。
- 双向通知:消费者被阻塞之后,生产者放入新数据,要notify()消费者; 生产者被阻塞之后,消费者消费了数据,要notify()生产者。
第1件事情必须要做,第2件和第3件事情不一定要做。例如,可以采取一个简单的办法,生产者放不进去之后,睡眠几百毫秒再重试,消费者取不到数据之后,睡眠几百毫秒再重试。但这个办法效率低下,也不实时。所以,我们只讨论如何阻塞、如何通知的问题。
1.如何阻塞?
办法1: 线程自己阻塞自己,也就是生产者、消费者线程各自调用wait()和notify()。
办法2: 用一个阻塞队列,当取不到或者放不进去数据的时候,入队/出队函数本身就是阻塞的。
2.如何双向通知?
办法1: wait()与notify()机制
办法2: Condition机制
单个生产者单个消费者线程的情形:
public class Main {
public static void main(String[] args) {
MyQueue myQueue = new MyQueue();
ProducerThread producerThread = new ProducerThread(myQueue);
ConsumerThread consumerThread = new ConsumerThread(myQueue);
producerThread.start();
consumerThread.start();
}
}
public class MyQueue {
private String[] data = new String[10];
private int getIndex = 0;
private int putIndex = 0;
private int size = 0;
public synchronized void put(String element) {
if (size == data.length) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
data[putIndex] = element;
++putIndex;
if (putIndex == data.length) putIndex = 0;
++size;
notify();
}
public synchronized String get() {
if (size == 0) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
String result = data[getIndex];
++getIndex;
if (getIndex == data.length) getIndex = 0;
--size;
notify();
return result;
}
}
public class ProducerThread extends Thread {
private final MyQueue myQueue;
private final Random random = new Random();
private int index = 0;
public ProducerThread(MyQueue myQueue) {
this.myQueue = myQueue;
}
@Override
public void run() {
while (true) {
String tmp = "ele-" + index;
myQueue.put(tmp);
System.out.println("添加元素:" + tmp);
index++;
try {
Thread.sleep(random.nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class ConsumerThread extends Thread {
private final MyQueue myQueue;
private final Random random = new Random();
public ConsumerThread(MyQueue myQueue) {
this.myQueue = myQueue;
}
@Override
public void run() {
while (true) {
String s = myQueue.get();
System.out.println("\t\t消费元素:" + s);
try {
Thread.sleep(random.nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
多个生产者多个消费者的情形:
public class Main2 {
public static void main(String[] args) {
MyQueue2 myQueue = new MyQueue2();
for (int i = 0; i < 3; i++) {
new ConsumerThread(myQueue).start();
}
for (int i = 0; i < 5; i++) {
new ProducerThread(myQueue).start();
}
}
}
public class MyQueue2 extends MyQueue {
private String[] data = new String[10];
private int getIndex = 0;
private int putIndex = 0;
private int size = 0;
@Override
public synchronized void put(String element) {
if (size == data.length) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
put(element);
} else {
put0(element);
notify();
}
}
private void put0(String element) {
data[putIndex] = element;
++putIndex;
if (putIndex == data.length) putIndex = 0;
++size;
}
@Override
public synchronized String get() {
if (size == 0) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
return get();
} else {
String result = get0();
notify();
return result;
}
}
private String get0() {
String result = data[getIndex];
++getIndex;
if (getIndex == data.length) getIndex = 0;
--size;
return result;
}
}
1.3.2 为什么必须和synchronized一起使用
在Java里面,wait() 和 notify() 是Object的成员函数,是基础中的基础。为什么Java要把 wait() 和 notify() 放在如此基础的类里面,而不是作为像Thread一类的成员函数,或者其他类的成员函数呢?
先看为什么wait()和notify()必须和synchronized一起使用?请看下面的代码:
class MyClass1 {
private Object obj1 = new Object();
public void method1() {
synchronized(obj1) {
//...
obj1.wait();
//...
}
}
public void method2() {
synchronized(obj1) {
//...
obj1.notify();
//...
}
}
}
public class MyClass1 {
public void synchronized method1() {
//...
this.wait();
//...
}
public void synchronized method2() {
//...
this.notify();
//...
}
}
然后,开两个线程,线程A调用method1(),线程B调用method2()。答案已经很明显:两个线程之间要通信,对于同一个对象来说,一个线程调用该对象的wait(),另一个线程调用该对象的notify(),该对象本身就需要同步! 所以,在调用wait()、notify()之前,要先通过synchronized关键字同步给对象, 也就是给该对象加锁。
synchronized关键字可以加在任何对象的实例方法上面,任何对象都可能成为锁。因此,wait()和 notify()只能放在Object里面了。
1.3.3 为什么wait()的时候必须释放锁
当线程A进入synchronized(obj1)中之后,也就是对obj1上了锁。此时,调用wait()进入阻塞状态, 一直不能退出synchronized代码块; 那么,线程B永远无法进入synchronized(obj1)同步块里,永远没有机会调用notify(),发生死锁。
这就涉及一个关键的问题:在wait()的内部,会先释放锁obj1,然后进入阻塞状态,之后,它被另外一个线程用notify()唤醒,重新获取锁! 其次,wait()调用完成后,执行后面的业务逻辑代码,然后退出 synchronized同步块,再次释放锁。
wait()内部的伪代码如下:
wait() {
// 释放锁
// 阻塞,等待被其他线程notify
// 重新获取锁
}
如此则可以避免死锁。
1.3.4 wait()与notify()的问题
以上述的生产者-消费者模型来看,其伪代码大致如下:
public void enqueue() {
synchronized(queue) {
while (queue.full()) {
queue.wait();
}
//... 数据入列
queue.notify(); // 通知消费者,队列中有数据了。
}
}
public void dequeue() {
synchronized(queue) {
while (queue.empty()) {
queue.wait();
}
// 数据出队列
queue.notify(); // 通知生产者,队列中有空间了,可以继续放数据了。
}
}
生产者在通知消费者的同时,也通知了其他的生产者; 消费者在通知生产者的同时,也通知了其他消费者。原因在于wait()和notify()所作用的对象和synchronized所作用的对象是同一个,只能有一个对象,无法区分队列空和列队满两个条件。这正是Condition要解决的问题。
1.4 InterruptedException与interrupt()方法
1.4.1 Interrupted异常
什么情况下会抛出Interrupted异常
假设while循环中没有调用任何的阻塞函数,就是通常的算术运算,或者打印一行日志,如下所示。
public class MyThread extends Thread {
@Override
public void run() {
while (true) {
boolean interrupted = isInterrupted();
System.out.println("中断标记:" + interrupted);
}
}
}
这个时候,在主线程中调用一句thread.interrupt(),请问该线程是否会抛出异常? 不会
public class Main42 {
public static void main(String[] args) throws InterruptedException {
MyThread42 myThread = new MyThread42();
myThread.start();
Thread.sleep(10);
myThread.interrupt();
Thread.sleep(100);
System.exit(0);
}
}
只有那些声明了会抛出InterruptedException的函数才会抛出异常,也就是下面这些常用的函数
public static native void sleep(long millis) throws InterruptedException {...}
public final void wait() throws InterruptedException {...}
public final void join() throws InterruptedException {...}
1.4.2 轻量级阻塞与重量级阻塞
能够被中断的阻塞称为轻量级阻塞,对应的线程状态是WAITING或者TIMED_WAITING; 而像 synchronized 这种不能被中断的阻塞称为重量级阻塞,对应的状态是 BLOCKED。如图所示: 调用不同的方法后,一个线程的状态迁移过程。
初始线程处于NEW状态,调用start()开始执行后,进入RUNNING或者READY状态。如果没有调用任何的阻塞函数,线程只会在RUNNING和READY之间切换,也就是系统的时间片调度。这两种状态的切换是操作系统完成的,除非手动调用yield()函数,放弃对CPU的占用。
一旦调用了图中的任何阻塞函数,线程就会进入WAITING或者TIMED_WAITING状态,两者的区别只是前者为无限期阻塞,后者则传入了一个时间参数,阻塞一个有限的时间。如果使用了synchronized 关键字或者synchronized块,则会进入BLOCKED状态。
不太常见的阻塞/唤醒函数,LockSupport.park()/unpark()。这对函数非常关键,Concurrent包中Lock的实现即依赖这一对操作原语。
因此thread.interrupted()的精确含义是“唤醒轻量级阻塞”,而不是字面意思“中断一个线程”。
thread.isInterrupted()与Thread.interrupted()的区别
因为 thread.interrupted()相当于给线程发送了一个唤醒的信号,所以如果线程此时恰好处于 WAITING或者TIMED_WAITING状态,就会抛出一个InterruptedException,并且线程被唤醒。而如果线程此时并没有被阻塞,则线程什么都不会做。但在后续,线程可以判断自己是否收到过其他线程发来的中断信号,然后做一些对应的处理。
这两个方法都是线程用来判断自己是否收到过中断信号的,前者是实例方法,后者是静态方法。二者的区别在于,前者只是读取中断状态,不修改状态; 后者不仅读取中断状态,还会重置中断标志位。
public class Main {
public static void main(String[] args) throws InterruptedException {
MyThread myThread = new MyThread();
myThread.start();
Thread.sleep(10);
myThread.interrupt();
Thread.sleep(7);
System.out.println("main中断状态检查-1:" + myThread.isInterrupted());
System.out.println("main中断状态检查-2:" + myThread.isInterrupted());
}
}
public class MyThread extends Thread {
@Override
public void run() {
int i = 0;
while (true) {
boolean interrupted = isInterrupted();
System.out.println("中断标记:" + interrupted);
++i;
if (i > 200) {
// 检查并重置中断标志。
boolean interrupted1 = Thread.interrupted();
System.out.println("重置中断状态:" + interrupted1);
interrupted1 = Thread.interrupted();
System.out.println("重置中断状态:" + interrupted1);
interrupted = isInterrupted();
System.out.println("中断标记:" + interrupted); break;
}
}
}
}
1.5 线程的优雅关闭
1.5.1 stop与destory函数
线程是“一段运行中的代码”,一个运行中的方法。运行到一半的线程能否强制杀死? 不能。
在Java中,有stop()、destory()等方法,但这些方法官方明确不建议使用。原因很简单,如果强制杀死线程,则线程中所使用的资源,例如文件描述符、网络连接等无法正常关闭。
因此,一个线程一旦运行起来,不要强行关闭,合理的做法是让其运行完(也就是方法执行完 毕),干净地释放掉所有资源,然后退出。如果是一个不断循环运行的线程,就需要用到线程间的通信机制,让主线程通知其退出。
1.5.2 守护线程
daemon线程和非daemon线程的对比:
public class Main {
public static void main(String[] args) {
MyDaemonThread myDaemonThread = new MyDaemonThread();
// 设置为daemon线程
myDaemonThread.setDaemon(true);
myDaemonThread.start();
// 启动非daemon线程,当非daemon线程结束,不管daemon线程是否结束,都结束JVM进程。
new MyThread().start();
}
}
public class MyDaemonThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println(Thread.currentThread().getName());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class MyThread extends Thread {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("非Daemon线程");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
对于上面的程序,在thread.start()前加一行代码thread.setDaemon(true)。当main()函数退出后,线程thread就会退出,整个进程也会退出。
当在一个JVM进程里面开多个线程时,这些线程被分成两类: 守护线程和非守护线程。默认都是非守护线程。
在Java中有一个规定: 当所有的非守护线程退出后,整个JVM进程就会退出。意思就是守护线程“不算作数”,守护线程不影响整个 JVM 进程的退出。
例如,垃圾回收线程就是守护线程,它们在后台默默工作,当开发者的所有前台线程(非守护线程)都退出之后,整个JVM进程就退出了。
1.5.3 设置关闭的标志位
开发中一般通过设置标志位的方式,停止循环运行的线程。
public class MyThread extends Thread{
private boolean running = true;
@Override
public void run() {
while (running) {
System.out.println("线程正在运行。。。");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public void stopRunning() {
this.running = false;
}
public static void main(String[] args) throws InterruptedException {
MyThread myThread = new MyThread();
myThread.start();
Thread.sleep(5000);
myThread.stopRunning();
myThread.join();
}
}
但上面的代码有一个问题:如果MyThread 在while循环中阻塞在某个地方,例如里面调用了 object.wait()函数,那它可能永远没有机会再执行 while( ! stopped)代码,也就一直无法退出循环。
此时,就要用到InterruptedException()与interrupt()函数。
2 并发核心概念
2.1 并发与并行
在单个处理器上采用单核执行多个任务即为并发。在这种情况下,操作系统的任务调度程序会很快从一个任务切换到另一个任务,因此看起来所有的任务都是同时运行的。
同一时间内在不同计算机、处理器或处理器核心上同时运行多个任务,就是所谓的“并行”。
另一个关于并发的定义是,在系统上同时运行多个任务(不同的任务)就是并发。而另一个关于并 行的定义是:同时在某个数据集的不同部分上运行同一任务的不同实例就是并行。
关于并行的最后一个定义是,系统中同时运行了多个任务。关于并发的最后一个定义是,一种解释 程序员将任务和它们对共享资源的访问同步的不同技术和机制的方法。
这两个概念非常相似,而且这种相似性随着多核处理器的发展也在不断增强。
2.2 同步
在并发中,我们可以将同步定义为一种协调两个或更多任务以获得预期结果的机制。同步的方式有两种:
- 控制同步: 例如,当一个任务的开始依赖于另一个任务的结束时,第二个任务不能再第一个任 务 完成之前开始。
- 数据访问同步: 当两个或更多任务访问共享变量时,再任意时间里,只有一个任务可以访问该变量。
与同步密切相关的一个概念时临界段。临界段是一段代码,由于它可以访问共享资源,因此再任何 给定时间内,只能被一个任务执行。互斥是用来保证这一要求的机制,而且可以采用不同的方式来实现。
同步可以帮助你在完成并发任务的同时避免一些错误,但是它也为你的算法引入了一些开销。你必 须非常仔细地计算任务的数量,这些任务可以独立执行,而无需并行算法中的互通信。这就涉及并发算 法的粒度。如果算法有着粗粒度(低互通信的大型任务),同步方面的开销就会较低。然而,也许你不 会用到系统所有的核心。如果算法有者细粒度(高互通信的小型任务),同步方面的开销就会很高,而 且该算法的吞吐量可能不会很好。
并发系统中有不同的同步机制。从理论角度看,最流行的机制如下:
- 信号量(semaphore): 一种用于控制对一个或多个单位资源进行访问的机制。它有一个用于存放可用资源数量的变量,而且可以采用两种原子操作来管理该变量。互斥(mutex: mutual exclusion的简写形式)是一种特殊类型的信号量,它只能取两个值(即资源空闲和资源忙),而且只有将互斥设置为忙的那个进程才可以释放它。互斥可以通过保护临界段来帮助 你避免出现竞争条件。
- 监视器: 一种在共享资源上实现互斥的机制。它有一个互斥、一个条件变量、两种操作(等待 条件和通报条件)。一旦你通报了该条件,在等待它的任务中只有一个会继续执行。
如果共享数据的所有用户都受到同步机制的保护,那么代码(或方法、对象)就是线程安全的。数据的非阻塞的CAS(compare-and-swap,比较和交换)原语是不可变的,这样就可以在并发应用程序 中使用该代码而不会出任何问题。
2.3 不可变对象
是一种非常特殊的对象。在其初始化后,不能修改其可视状态(其属性值)。如果想修改一个不可变对象,那么你就必须创建一个新的对象。
主要优点在于它是线程安全的。你可以在并发应用程序中使用它而不会出现任何问题。
例子就是java中的String类。当你给一个String对象赋新值时,会创建一个新的 String对象。
2.4 原子操作和原子变量
与应用程序的其他任务相比,原子操作是一种发生在瞬间的操作。在并发应用程序中,可以通过一个临界段来实现原子操作,以便对整个操作采用同步机制。
原子变量是一种通过原子操作来设置和获取其值的变量。可以使用某种同步机制来实现一个原子变量,或者也可以使用CAS以无锁方式来实现一个原子变量,而这种方式并不需要任何同步机制。
2.5 共享内存与消息传递
任务可以通过两种不同的方式来相互通信。
第一种方法是共享内存,通常用于在同一台计算机上运 行多任务的情况。任务在读取和写入值的时候使用相同的内存区域。为了避免出现问题,对该共享内存 的访问必须在一个由同步机制保护的临界段内完成。
另一种同步机制是消息传递,通常用于在不同计算机上运行多任务的情形。当一个任务需要与另一 个任务通信时,它会发送一个遵循预定义协议的消息。如果发送方保持阻塞并等待响应,那么该通信就 是同步的;如果发送方在发送消息后继续执行自己的流程,那么该通信就是异步的。
3 并发的问题
3.1 数据竞争
如果有两个或者多个任务在临界段之外对一个共享变量进行写入操作,也就是说没有使用任何同步机制,那么应用程序可能存在数据竞争(也叫做竞争条件)。
在这些情况下,应用程序的最终结果可能取决于任务的执行顺序。
public class ConcurrentDemo {
private float myFloat;
public void modify(float difference) {
float value = this.myFloat;
this.myFloat = value + difference;
}
public static void main(String[] args) {
}
}
假设有两个不同的任务执行了同一个modify方法。由于任务中语句的执行顺序不同,最终结果也会不同。
modify方法不是原子的, ConcurrentDemo也不是线程安全的。
3.2 死锁
当两个(或多个)任务正在等待必须由另一线程释放的某个共享资源,而该线程又正在等待必须由 前述任务之一释放的另一共享资惊时,并发应用程序就出现了死锁。当系统中同时出现如下四种条件 时,就会导致这种情形。我们将其称为Coffman 条件。
- 互斥: 死锁中涉及的资源必须是不可共享的。一次只有一个任务可以使用该资源。
- 占有并等待条件: 一个任务在占有某一互斥的资源时又请求另一互斥的资源。当它在等待时, 不会释放任何资源。
- 不可剥夺: 资源只能被那些持有它们的任务释放。
- 循环等待: 任务1正等待任务2 所占有的资源, 而任务2 又正在等待任务3 所占有的资源,以 此类推,最终任务n又在等待由任务1所占有的资源,这样就出现了循环等待。
有一些机制可以用来避免死锁。
- 忽略:这是最常用的机制。你可以假设自己的系统绝不会出现死锁,而如果发生死锁,结果就是你可以停止应用程序并且重新执行它
- 检测:系统中有一项专门分析系统状态的任务,可以检测是否发生了死锁。如果它检测到了死 锁,可以采取一些措施来修复该问题,例如,结束某个任务或者强制释放某一资源。
- 预防:如果你想防止系统出现死锁,就必须预防Coffman 条件中的一条或多条出现。
- 规避:如果你可以在某一任务执行之前得到该任务所使用资源的相关信息,那么死锁是可以规 避的。当一个任务要开始执行时,你可以对系统中空闲的资源和任务所需的资源进行分析,这 样就可以判断任务是否能够开始执行。
3.3 活锁
如果系统中有两个任务,它们总是因对方的行为而改变自己的状态, 那么就出现了活锁。最终结果是它们陷入了状态变更的循环而无法继续向下执行。
例如,有两个任务:任务1和任务2 ,它们都需要用到两个资源:资源1和资源2 。假设任务1对资源 1加了一个锁,而任务2 对资源2 加了一个锁。当它们无法访问所需的资源时,就会释放自己的资源并且 重新开始循环。这种情况可以无限地持续下去,所以这两个任务都不会结束自己的执行过程。
3.4 资源不足
当某个任务在系统中无法获取维持其继续执行所需的资源时,就会出现资源不足。当有多个任务在 等待某一资源且该资源被释放时,系统需要选择下一个可以使用该资源的任务。如果你的系统中没有设 计良好的算法,那么系统中有些线程很可能要为获取该资源而等待很长时间。
要解决这一问题就要确保公平原则。所有等待某一资源的任务必须在某一给定时间之内占有该资 源。可选方案之一就是实现一个算法,在选择下一个将占有某一资源的任务时,对任务已等待该资源的 时间因素加以考虑。然而,实现锁的公平需要增加额外的开销,这可能会降低程序的吞吐量。
3.5 优先权反转
当一个低优先权的任务持有了一个高优先级任务所需的资源时,就会发生优先权反转。这样的话,低优先权的任务就会在高优先权的任务之前执行。
4 JMM内存模型
4.1 JMM与happen-before
4.1.1 为什么会存在“内存可见性”问题
下图为x86架构下CPU缓存的布局,即在一个CPU 4核下,L1、L2、L3三级缓存与主内存的布局。 每个核上面有L1、L2缓存,L3缓存为所有核共用。
因为存在CPU缓存一致性协议,例如MESI,多个CPU核心之间缓存不会出现不同步的问题,不会有 “内存可见性”问题。
缓存一致性协议对性能有很大损耗,为了解决这个问题,又进行了各种优化。例如,在计算单元和 L1之间加了Store Buffer、Load Buffer(还有其他各种Buffer),如下图:
L1、L2、L3和主内存之间是同步的,有缓存一致性协议的保证,但是Store Buffer、Load Buffer和 L1之间却是异步的。向内存中写入一个变量,这个变量会保存在Store Buffer里面,稍后才异步地写入 L1中,同时同步写入主内存中。
操作系统内核视角下的CPU缓存模型:
多CPU,每个CPU多核,每个核上面可能还有多个硬件线程,对于操作系统来讲,就相当于一个个 的逻辑CPU。每个逻辑CPU都有自己的缓存,这些缓存和主内存之间不是完全同步的。
对应到Java里,就是JVM抽象内存模型,如下图所示:
4.1.2 重排序与内存可见性的关系
Store Buffer的延迟写入是重排序的一种,称为内存重排序(Memory Ordering)。除此之外,还有编译器和CPU的指令重排序。
重排序类型:
- 编译器重排序。 对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。
- CPU指令重排序。 在指令级别,让没有依赖关系的多条指令并行。
- CPU内存重排序。 CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。
在三种重排序中,第三类就是造成“内存可见性”问题的主因,如下案例:
线程1:
X=1
a=Y
线程2:
Y=1
b=X
假设X、Y是两个全局变量,初始的时候,X=0,Y=0。请问,这两个线程执行完毕之后,a、b的正确结果应该是什么?
很显然,线程1和线程2的执行先后顺序是不确定的,可能顺序执行,也可能交叉执行,最终正确的结果可能是:
- a=0,b=1
- a=1,b=0
- a=1,b=1
也就是不管谁先谁后,执行结果应该是这三种场景中的一种。但实际可能是a=0,b=0。
两个线程的指令都没有重排序,执行顺序就是代码的顺序,但仍然可能出现a=0,b=0。原因是线程 1先执行X=1,后执行a=Y,但此时X=1还在自己的Store Buffer里面,没有及时写入主内存中。所以,线程2看到的X还是0。线程2的道理与此相同。
虽然线程1觉得自己是按代码顺序正常执行的,但在线程2看来,a=Y和X=1顺序却是颠倒的。指令没 有重排序,是写入内存的操作被延迟了,也就是内存被重排序了,这就造成内存可见性问题。
4.1.3 内存屏障
为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障(Memory Barrier)。这也正是JMM和happen-before规则的底层实现原理。
编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。
而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。
内存屏障是很底层的概念,对于 Java 开发者来说,一般用 volatile 关键字就足够了。但从JDK 8开 始,Java在Unsafe类中提供了三个内存屏障函数,如下所示。
public final class Unsafe {
// ...
public native void loadFence();
public native void storeFence();
public native void fullFence();
// ...
}
在理论层面,可以把基本的CPU内存屏障分成四种:
- LoadLoad:禁止读和读的重排序。
- StoreStore:禁止写和写的重排序。
- LoadStore:禁止读和写的重排序。
- StoreLoad:禁止写和读的重排序。
Unsafe中的方法:
- loadFence=LoadLoad+LoadStore
- storeFence=StoreStore+LoadStore
- fullFence=loadFence+storeFence+StoreLoad
4.1.4 as-if-serial语义
重排序的原则是什么?什么场景下可以重排序,什么场景下不能重排序呢?
1.单线程程序的重排序规则
无论什么语言,站在编译器和CPU的角度来说,不管怎么重排序,单线程程序的执行结果不能改变,这就是单线程程序的重排序规则。
即只要操作之间没有数据依赖性,编译器和CPU都可以任意重排序,因为执行结果不会改变,代码 看起来就像是完全串行地一行行从头执行到尾,这也就是as-if-serial语义。
对于单线程程序来说,编译器和CPU可能做了重排序,但开发者感知不到,也不存在内存可见性问 题。
2.多线程程序的重排序规则
编译器和CPU的这一行为对于单线程程序没有影响,但对多线程程序却有影响。
对于多线程程序来说,线程之间的数据依赖性太复杂,编译器和CPU没有办法完全理解这种依赖性 并据此做出最合理的优化。
编译器和CPU只能保证每个线程的as-if-serial语义。
线程之间的数据依赖和相互影响,需要编译器和CPU的上层来确定。
上层要告知编译器和CPU在多线程场景下什么时候可以重排序,什么时候不能重排序。
4.1.5 happen-before是什么
使用happen-before描述两个操作之间的内存可见性。
java内存模型(JMM)是一套规范,在多线程中,一方面,要让编译器和CPU可以灵活地重排序; 另一方面,要对开发者做一些承诺,明确告知开发者不需要感知什么样的重排序,需要感知什么样的重 排序。然后,根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过 volatile、synchronized等线程同步机制来禁止重排序。
关于happen-before:
如果A happen-before B,意味着A的执行结果必须对B可见,也就是保证跨线程的内存可见性。A happen before B不代表A一定在B之前执行。因为,对于多线程程序而言,两个操作的执行顺序是不确 定的。happen-before只确保如果A在B之前执行,则A的执行结果必须对B可见。定义了内存可见性的约 束,也就定义了一系列重排序的约束。
基于happen-before的这种描述方法,JMM对开发者做出了一系列承诺:
- 单线程中的每个操作,happen-before 对应该线程中任意后续操作(也就是 as-if-serial语义保 证)。
- 对volatile变量的写入,happen-before对应后续对这个变量的读取。
- 对synchronized的解锁,happen-before对应后续对这个锁的加锁。
…
JMM对编译器和CPU 来说,volatile 变量不能重排序;非 volatile 变量可以任意重排序。
4.1.6 happen-before的传递性
除了这些基本的happen-before规则,happen-before还具有传递性,即若A happen-before B,B happen-before C,则A happen-before C。
如果一个变量不是volatile变量,当一个线程读取、一个线程写入时可能有问题。那岂不是说,在多线程程序中,我们要么加锁,要么必须把所有变量都声明为volatile变量?这显然不可能,而这就得归功 于happen-before的传递性。
class A {
private int a = 0;
private volatile int c = 0;
public void set() {
a=5;// 操作1
c=1;// 操作2
}
public int get() {
intd=c;// 操作3
return a; // 操作4
}
}
假设线程A先调用了set,设置了a=5;之后线程B调用了get,返回值一定是a=5。为什么呢?
操作1和操作2是在同一个线程内存中执行的,操作1 happen-before 操作2,同理,操作3 happen- before操作4。又因为c是volatile变量,对c的写入happen-before对c的读取,所以操作2 happen- before操作3。利用happen-before的传递性,就得到:
操作1 happen-before 操作2 happen-before 操作3 happen-before操作4。
所以,操作1的结果,一定对操作4可见。
class A {
private int a = 0;
private int c = 0;
public synchronized void set() {
a=5;// 操作1
c=1;// 操作2
}
public synchronized int get() {
return a;
}
}
假设线程A先调用了set,设置了a=5;之后线程B调用了get,返回值也一定是a=5。
因为与volatile一样,synchronized同样具有happen-before语义。展开上面的代码可得到类似于下面的伪代码:
线程A:
加锁; // 操作1
a=5;// 操作2
c=1;// 操作3
解锁; // 操作4
线程B:
加锁; // 操作5
读取a; // 操作6
解锁; // 操作7
根据synchronized的happen-before语义,操作4 happen-before 操作5,再结合传递性,最终就会得到:
操作1 happen-before 操作2…happen-before 操作7。所以 a、c 都不是volatile变量,但仍然有 内存可见性。
4.2 volatile关键字
4.2.1 64位写入的原子性(Half Write)
对于一个long型变量的赋值和取值而言,在多线程场景下,线程A调用set(100),线程B调 用get(),在某些场景下,返回值可能不是100。
public class MyClass {
private long a = 0;
// 线程A调用set(100)
public void set(long a) {
this.a = a;
}
// 线程B调用get(),返回值一定是100吗?
public long get() {
return this.a;
}
}
因为JVM的规范并没有要求64位的long或者double的写入是原子的。在32位的机器上,一个64位变 量的写入可能被拆分成两个32位的写操作来执行。这样一来,读取的线程就可能读到“一半的值”。解决 办法也很简单,在long前面加上volatile关键字。
4.2.2 重排序:DCL问题
单例模式的线程安全的写法不止一种,常用写法为DCL(Double Checking Locking),如下所示:
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
// 此处代码有问题
instance = new Singleton();
}
}
}
return instance;
}
}
上述的 instance = new Singleton(); 代码有问题:其底层会分为三个操作:
- 分配一块内存。
- 在内存上初始化成员变量。
- 把instance引用指向内存。
在这三个操作中,操作2和操作3可能重排序,即先把instance指向内存,再初始化成员变量,因为 二者并没有先后的依赖关系。此时,另外一个线程可能拿到一个未完全初始化的对象。这时,直接访问 里面的成员变量,就可能出错。这就是典型的“构造方法溢出”问题。
解决办法也很简单,就是为instance变量加上volatile修饰。
volatile的三重功效:64位写入的原子性、内存可见性和禁止重排序。
4.2.3 volatile实现原理
由于不同的CPU架构的缓存体系不一样,重排序的策略不一样,所提供的内存屏障指令也就有差异。
这里只探讨为了实现volatile关键字的语义的一种参考做法:
- 在volatile写操作的前面插入一个StoreStore屏障。保证volatile写操作不会和之前的写操作重 排序。
- 在volatile写操作的后面插入一个StoreLoad屏障。保证volatile写操作不会和之后的读操作重 排序。
- 在volatile读操作的后面插入一个LoadLoad屏障+LoadStore屏障。保证volatile读操作不会和 之后的读操作、写操作重排序。
具体到x86平台上,其实不会有LoadLoad、LoadStore和StoreStore重排序,只有StoreLoad一种 重排序(内存屏障),也就是只需要在volatile写操作后面加上StoreLoad屏障。
4.2.4 JSR-133对volatile语义的增强
在JSR -133之前的旧内存模型中,一个64位long/ double型变量的读/ 写操作可以被拆分为两个32位 的读/写操作来执行。从JSR -133内存模型开始 (即从JDK5开始),仅仅只允许把一个64位long/ double 型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR -133中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。
这也正体现了Java对happen-before规则的严格遵守。
4.3 final关键字
4.3.1 构造方法溢出问题
public class MyClass {
private int num1;
private int num2;
private static MyClass myClass;
public MyClass() {
num1 = 1;
num2 = 2;
}
/**
* 线程A先执行write() */
public static void write() {
myClass = new MyClass();
}
/**
* 线程B接着执行write() */
public static void read() {
if (myClass != null) {
int num3 = myClass.num1;
int num4 = myClass.num2;
}
}
}
num3和num4的值是否一定是1和2?
num3、num4不见得一定等于1,2。和DCL的例子类似,也就是构造方法溢出问题。
myClass = new MyClass()这行代码,分解成三个操作:
- 分配一块内存;
- 在内存上初始化i=1,j=2;
- 把myClass指向这块内存。
操作2和操作3可能重排序,因此线程B可能看到未正确初始化的值。对于构造方法溢出,就是一个 对象的构造并不是“原子的”,当一个线程正在构造对象时,另外一个线程却可以读到未构造好的“一半对象”。
4.3.2 final的happen-before语义
要解决这个问题,不止有一种办法。
- 办法1: 给num1,num2加上volatile关键字。
- 办法2: 为read/write方法都加上synchronized关键字。
如果num1,num2只需要初始化一次,还可以使用final关键字。
之所以能解决问题,是因为同volatile一样,final关键字也有相应的happen-before语义:
- 对final域的写(构造方法内部),happen-before于后续对final域所在对象的读。
- 对final域所在对象的读,happen-before于后续对final域的读。
通过这种happen-before语义的限定,保证了final域的赋值,一定在构造方法之前完成,不会出现 另外一个线程读取到了对象,但对象里面的变量却还没有初始化的情形,避免出现构造方法溢出的问 题。
4.3.3 happen-before规则总结
- 单线程中的每个操作,happen-before于该线程中任意后续操作。
- 对volatile变量的写,happen-before于后续对这个变量的读。
- 对synchronized的解锁,happen-before于后续对这个锁的加锁。
- 对final变量的写,happen-before于final域对象的读,happen-before于后续对final变量的读。
四个基本规则再加上happen-before的传递性,就构成JMM对开发者的整个承诺。在这个承诺以外 的部分,程序都可能被重排序,都需要开发者小心地处理内存可见性问题。
第二部分:JUC
5 并发容器
5.1 BlockingQueue
在所有的并发容器中,BlockingQueue是最常见的一种。BlockingQueue是一个带阻塞功能的队 列,当入队列时,若队列已满,则阻塞调用者;当出队列时,若队列为空,则阻塞调用者。
在Concurrent包中,BlockingQueue是一个接口,有许多个不同的实现类,如图所示。
public interface BlockingQueue<E> extends Queue<E> {
//...
boolean add(E e);
boolean offer(E e);
void put(E e) throws InterruptedException;
boolean remove(Object o);
E take() throws InterruptedException;
E poll(long timeout, TimeUnit unit) throws InterruptedException;
//...
}
该接口和JDK集合包中的Queue接口是兼容的,同时在其基础上增加了阻塞功能。在这里,入队提 供了add(…)、offer(…)、put(…)3个方法,有什么区别呢?从上面的定义可以看到,add(…)和offer(…)的 返回值是布尔类型,而put无返回值,还会抛出中断异常,所以add(…)和offer(…)是无阻塞的,也是 Queue本身定义的接口,而put(…)是阻塞的。add(…)和offer(…)的区别不大,当队列为满的时候,前者会 抛出异常,后者则直接返回false。
出队列与之类似,提供了remove()、poll()、take()等方法,remove()是非阻塞式的,take()和poll() 是阻塞式的。
5.1.1 ArrayBlockingQueue
ArrayBlockingQueue是一个用数组实现的环形队列,在构造方法中,会要求传入数组的容量。
public ArrayBlockingQueue(int capacity) {
this(capacity, false);
}
public ArrayBlockingQueue(int capacity, boolean fair) {
// ...
}
public ArrayBlockingQueue(int capacity, boolean fair, Collection<? extendsE> c) {
this(capacity, fair);
// ...
}
其核心数据结构如下:
public class ArrayBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E>, java.io.Serializable {
//...
final Object[] items;
// 队头指针
int takeIndex;
// 队尾指针
int putIndex;
int count;
// 核心为1个锁外加两个条件
final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;
//...
}
其put/take方法也很简单,如下所示。
put方法:
take方法:
5.1.2 LinkedBlockingQueue
LinkedBlockingQueue是一种基于单向链表的阻塞队列。因为队头和队尾是2个指针分开操作的, 所以用了2把锁+2个条件,同时有1个AtomicInteger的原子变量记录count数。
public class LinkedBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E>, java.io.Serializable {
// ...
private final int capacity;
// 原子变量
private final AtomicInteger count = new AtomicInteger(0);
// 单向链表的头部
private transient Node<E> head;
// 单向链表的尾部
private transient Node<E> last;
// 两把锁,两个条件
private final ReentrantLock takeLock = new ReentrantLock();
private final Condition notEmpty = takeLock.newCondition();
private final ReentrantLock putLock = new ReentrantLock();
private final Condition notFUll = putLock.newCondition();
// ...
}
在其构造方法中,也可以指定队列的总容量。如果不指定,默认为Integer.MAX_VALUE。
put/take实现。
LinkedBlockingQueue和ArrayBlockingQueue的差异:
- 为了提高并发度,用2把锁,分别控制队头、队尾的操作。意味着在put(…)和put(…)之间、 take()与take()之间是互斥的,put(…)和take()之间并不互斥。但对于count变量,双方都需要 操作,所以必须是原子类型。
- 因为各自拿了一把锁,所以当需要调用对方的condition的signal时,还必须再加上对方的锁, 就是signalNotEmpty()和signalNotFull()方法。示例如下所示。
- 不仅put会通知 take,take 也会通知 put。当put 发现非满的时候,也会通知其他 put线程; 当take发现非空的时候,也会通知其他take线程。
5.1.3 PriorityBlockingQueue
队列通常是先进先出的,而PriorityQueue是按照元素的优先级从小到大出队列的。正因为如此, PriorityQueue中的2个元素之间需要可以比较大小,并实现Comparable接口。
其核心数据结构如下:
public class PriorityBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E>, java.io.Serializable {
//...
// 用数组实现的二插小根堆
private transient Object[] queue;
private transient int size;
private transient Comparator<? super E> comparator;
// 1个锁+一个条件,没有非满条件
private final ReentrantLock lock;
private final Condition notEmpty;
//...
}
其构造方法如下所示,如果不指定初始大小,内部会设定一个默认值11,当元素个数超过这个大小 之后,会自动扩容。
put方法的实现:
take的实现:
从上面可以看到,在阻塞的实现方面,和ArrayBlockingQueue的机制相似,主要区别是用数组实现 了一个二叉堆,从而实现按优先级从小到大出队列。另一个区别是没有notFull条件,当元素个数超出数 组长度时,执行扩容操作。
5.1.4 DelayQueue
DelayQueue即延迟队列,也就是一个按延迟时间从小到大出队的PriorityQueue。所谓延迟时间, 就是“未来将要执行的时间”减去“当前时间”。为此,放入DelayQueue中的元素,必须实现Delayed接口,如下所示。
关于该接口:
- 如果getDelay的返回值小于或等于0,则说明该元素到期,需要从队列中拿出来执行。
- 该接口首先继承了 Comparable 接口,所以要实现该接口,必须实现 Comparable 接口。具 体来说,就是基于getDelay()的返回值比较两个元素的大小。
下面看一下DelayQueue的核心数据结构。
public class DelayQueue<E extends Delayed> extends AbstractQueue<E> implements BlockingQueue<E> {
// ...
// 一把锁和一个非空条件
private final transient ReentrantLock lock = new ReentrantLock();
private final Condition available = lock.newCondition();
// 优先级队列
private final PriorityQueue<E> q = new PriorityQueue<E>();
// ...
}
take的实现:
关于take()方法:
- 不同于一般的阻塞队列,只在队列为空的时候,才阻塞。如果堆顶元素的延迟时间没到,也会 阻塞。
- 在上面的代码中使用了一个优化技术,用一个Thread leader变量记录了等待堆顶元素的第1个 线程。为什么这样做呢?通过 getDelay(…)可以知道堆顶元素何时到期,不必无限期等待,可 以使用condition.awaitNanos()等待一个有限的时间;只有当发现还有其他线程也在等待堆顶 元素(leader!=NULL)时,才需要无限期等待。
put的实现:
注意:不是每放入一个元素,都需要通知等待的线程。放入的元素,如果其延迟时间大于当前堆顶 的元素延迟时间,就没必要通知等待的线程;只有当延迟时间是最小的,在堆顶时,才有必要通知等待 的线程,也就是上面代码中的 if(q.peek() == e) 部分。
5.1.5 SynchronousQueue
SynchronousQueue是一种特殊的BlockingQueue,它本身没有容量。先调put(…),线程会阻塞; 直到另外一个线程调用了take(),两个线程才同时解锁,反之亦然。对于多个线程而言,例如3个线程, 调用3次put(…),3个线程都会阻塞;直到另外的线程调用3次take(),6个线程才同时解锁,反之亦然。
接下来看SynchronousQueue的实现。
构造方法:
和锁一样,也有公平和非公平模式。如果是公平模式,则用TransferQueue实现;如果是非公平模 式,则用TransferStack实现。这两个类分别是什么呢?先看一下put/take的实现。
可以看到,put/take都调用了transfer(…)接口。而TransferQueue和TransferStack分别实现了这个 接口。该接口在SynchronousQueue内部,如下所示。如果是put(…),则第1个参数就是对应的元素; 如果是take(),则第1个参数为null。后2个参数分别为是否设置超时和对应的超时时间。
接下来看一下什么是公平模式和非公平模式。假设3个线程分别调用了put(…),3个线程会进入阻塞 状态,直到其他线程调用3次take(),和3个put(…)一一配对。
如果是公平模式(队列模式),则第1个调用put(…)的线程1会在队列头部,第1个到来的take()线程 和它进行配对,遵循先到先配对的原则,所以是公平的;如果是非公平模式(栈模式),则第3个调用 put(…)的线程3会在栈顶,第1个到来的take()线程和它进行配对,遵循的是后到先配对的原则,所以是 非公平的。
下面分别看一下TransferQueue和TransferStack的实现。
1.TransferQueue
public class SynchronousQueue<E> extends AbstractQueue<E> implements BlockingQueue<E>, java.io.Serializable {
// ...
static final class TransferQueue<E> extends Transferer<E> {
static final class QNode {
volatile QNode next;
volatile Object item;
volatile Thread waiter;
final boolean isData;
//...
}
transient volatile QNode head;
transient volatile QNode tail;
// ...
}
}
从上面的代码可以看出,TransferQueue是一个基于单向链表而实现的队列,通过head和tail 2个 指针记录头部和尾部。初始的时候,head和tail会指向一个空节点,构造方法如下所示。
阶段(a):队列中是一个空的节点,head/tail都指向这个空节点。
阶段(b):3个线程分别调用put,生成3个QNode,进入队列。
阶段©:来了一个线程调用take,会和队列头部的第1个QNode进行配对。
阶段(d):第1个QNode出队列。
这里有一个关键点:put节点和take节点一旦相遇,就会配对出队列,所以在队列中不可能同时存在 put节点和take节点,要么所有节点都是put节点,要么所有节点都是take节点。
接下来看一下TransferQueue的代码实现。
整个 for 循环有两个大的 if-else 分支,如果当前线程和队列中的元素是同一种模式(都是put节点 或者take节点),则与当前线程对应的节点被加入队列尾部并且阻塞;如果不是同一种模式,则选取队 列头部的第1个元素进行配对。
这里的配对就是m.casItem(x,e),把自己的item x换成对方的item e,如果CAS操作成功,则配 对成功。如果是put节点,则isData=true,item!=null;如果是take节点,则isData=false, item=null。如果CAS操作不成功,则isData和item之间将不一致,也就是isData!=(x!=null),通过 这个条件可以判断节点是否已经被匹配过了。
2.TransferStack
TransferStack的定义如下所示,首先,它也是一个单向链表。不同于队列,只需要head指针就能 实现入栈和出栈操作。
static final class TransferStack extends Transferer {
static final int REQUEST = 0;
static final int DATA = 1;
static final int FULFILLING = 2;
static final class SNode {
volatile SNode next; // 单向链表
volatile SNode match; // 配对的节点
volatile Thread waiter; // 对应的阻塞线程
Object item;
int mode; //三种模式
//...
}
volatile SNode head;
}
链表中的节点有三种状态,REQUEST对应take节点,DATA对应put节点,二者配对之后,会生成一 个FULFILLING节点,入栈,然后FULLING节点和被配对的节点一起出栈。
阶段(a):head指向NULL。不同于TransferQueue,这里没有空的头节点。
阶段(b):3个线程调用3次put,依次入栈。
阶段©:线程4调用take,和栈顶的第1个元素配对,生成FULLFILLING节点,入栈。
阶段(d):栈顶的2个元素同时入栈。
5.2 BlockingDeque
BlockingDeque定义了一个阻塞的双端队列接口,如下所示。
public interface BlockingDeque<E> extends BlockingQueue<E>, Deque<E> {
void putFirst(E e) throws InterruptedException;
void putLast(E e) throws InterruptedException;
E takeFirst() throws InterruptedException;
E takeLast() throws InterruptedException;
// ...
}
该接口继承了BlockingQueue接口,同时增加了对应的双端队列操作接口。该接口只有一个实现, 就是LinkedBlockingDeque。
其核心数据结构如下所示,是一个双向链表。
public class LinkedBlockingDeque<E> extends AbstractQueue<E> implements BlockingDeque<E>, java.io.Serializable {
static final class Node<E> {
E item;
Node<E> prev; // 双向链表的
Node Node<E> next;
Node(E x) {
item = x;
}
}
transient Node<E> first; // 队列的头和尾
transient Node<E> last;
private transient int count; // 元素个数
private final int capacity; // 容量
// 一把锁+两个条件
final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.netCondition();
private final Condition notFull = lock.newCondition();
// ...
}
对应的实现原理,和LinkedBlockingQueue基本一样,只是LinkedBlockingQueue是单向链表,而 LinkedBlockingDeque是双向链表。
5.3 CopyOnWrite
CopyOnWrite指在“写”的时候,不是直接“写”源数据,而是把数据拷贝一份进行修改,再通过悲观 锁或者乐观锁的方式写回。
那为什么不直接修改,而是要拷贝一份修改呢?
这是为了在“读”的时候不加锁。
5.3.1 CopyOnWriteArrayList
和ArrayList一样,CopyOnWriteArrayList的核心数据结构也是一个数组,代码如下:
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
// ...
private volatile transient Object[] array;
}
下面是CopyOnArrayList的几个“读”方法:
final Object[] getArray() {
return array;
}
//
public E get(int index) {
return elementAt(getArray(), index);
}
public boolean isEmpty() {
return size() == 0;
}
public boolean contains(Object o) {
return indexOf(o) >= 0;
}
public int indexOf(Object o) {
Object[] es = getArray();
return indexOfRange(o, es, 0, es.length);
}
private static int indexOfRange(Object o, Object[] es, int from, int to){
if (o == null) {
for (int i = from; i < to; i++)
if (es[i] == null)
return i;
} else {
for (int i = from; i < to; i++)
if (o.equals(es[i]))
return i;
}
return -1;
}
既然这些“读”方法都没有加锁,那么是如何保证“线程安全”呢? 答案在“写”方法里面。
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
// 锁对象
final transient Object lock = new Object();
public boolean add(E e) {
synchronized (lock) { // 同步锁对象
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1); // CopyOnWrite,写的时候,先拷贝一份之前的数组。
es[len] = e;
setArray(es);
return true;
}
}
public void add(int index, E element) {
synchronized (lock) { // 同步锁对象
Object[] es = getArray();
int len = es.length;
if (index > len || index < 0)
throw new IndexOutOfBoundsException(outOfBounds(index,len));
Object[] newElements;
int numMoved = len - index;
if (numMoved == 0)
newElements = Arrays.copyOf(es, len + 1);
else
newElements = new Object[len + 1];
System.arraycopy(es, 0, newElements, 0, index); // CopyOnWrite,写的时候,先拷贝一份之前的数组。
System.arraycopy(es, index, newElements, index + 1, numMoved);
newElements[index] = element;
setArray(newElements); // 把新数组赋值给老数组
}
}
}
其他“写”方法,例如remove和add类似,此处不再详述。
5.3.2 CopyOnWriteArraySet
CopyOnWriteArraySet 就是用 Array 实现的一个 Set,保证所有元素都不重复。其内部是封装的一 个CopyOnWriteArrayList。
public class CopyOnWriteArraySet<E> extends AbstractSet<E> implements java.io.Serializable {
// 新封装的CopyOnWriteArrayList
private final CopyOnWriteArrayList<E> al;
public CopyOnWriteArraySet() {
al = new CopyOnWriteArrayList<E>();
}
public boolean add(E e) {
return al.addIfAbsent(e); // 不重复的加进去
}
}
5.4 ConcurrentLinkedQueue/Deque
AQS内部的阻塞队列实现原理:基于双向链表,通过对head/tail进行CAS操作,实现入队和出队。
ConcurrentLinkedQueue 的实现原理和AQS 内部的阻塞队列类似:同样是基于 CAS,同样是通过head/tail指针记录队列头部和尾部,但还是有稍许差别。
首先,它是一个单向链表,定义如下:
public class ConcurrentLinkedQueue<E> extends AbstractQueue<E> implements Queue<E>, java.io.Serializable {
private static class Node<E> {
volatile E item;
volatile Node<E> next;
//...
}
private transient volatile Node<E> head;
private transient volatile Node<E> tail;
//...
}
其次,在AQS的阻塞队列中,每次入队后,tail一定后移一个位置;每次出队,head一定后移一个 位置,以保证head指向队列头部,tail指向链表尾部。
但在ConcurrentLinkedQueue中,head/tail的更新可能落后于节点的入队和出队,因为它不是直 接对 head/tail指针进行 CAS操作的,而是对 Node中的 item进行操作。下面进行详细分析:
1.初始化
初始的时候, head 和 tail 都指向一个 null 节点。对应的代码如下。
public ConcurrentLinkedQueue() {
head = tail = new Node<E>(null);
}
2.入队列
public boolean offer(E e) {
final Node<E> newNode = new Node<E>(Objects.requireNonNull(e));
for (Node<E> t = tail, p = t;;) { Node<E> q = p.next;
if (q == null) {
if (NEXT.compareAndSet(p, null, newNode)) {
if (p != t)
TAIL.weakCompareAndSet(this, t, newNode);
return true
}
}
else if (p == q)
p = (t != (t = tail)) ? t : head;
else
p = (p != t && t != (t = tail)) ? t : q;
}
}
上面的入队其实是每次在队尾追加2个节点时,才移动一次tail节点。如下图所示:
初始的时候,队列中有1个节点item1,tail指向该节点,假设线程1要入队item2节点:
step1:p=tail,q=p.next=NULL.
step2:对p的next执行CAS操作,追加item2,成功之后,p=tail。所以上面的casTail方法不会执 行,直接返回。此时tail指针没有变化。
之后,假设线程2要入队item3节点,如下图所示:
step3:p=tail,q=p.next.
step4:q!=NULL,因此不会入队新节点。p,q都后移1位。
step5:q=NULL,对p的next执行CAS操作,入队item3节点。
step6:p!=t,满足条件,执行上面的casTail操作,tail后移2个位置,到达队列尾部。
最后总结一下入队列的两个关键点:
- 即使tail指针没有移动,只要对p的next指针成功进行CAS操作,就算成功入队列。
- 只有当 p != tail的时候,才会后移tail指针。也就是说,每连续追加2个节点,才后移1次tail指针。即使CAS失败也没关系,可以由下1个线程来移动tail指针。
3.出队列
上面说了入队列之后,tail指针不变化,那是否会出现入队列之后,要出队列却没有元素可出的情况呢?
出队列的代码和入队列类似,也有p、q2个指针,整个变化过程如图5-8所示。假设初始的时候head 指向空节点,队列中有item1、item2、item3 三个节点。
step1:p=head,q=p.next.p!=q.
step2:后移p指针,使得p=q。
step3:出队列。关键点:此处并没有直接删除item1节点,只是把该节点的item通过CAS操作置为 了NULL。
step4:p!=head,此时队列中有了2个 NULL 节点,再前移1次head指针,对其执行updateHead 操作。
最后总结一下出队列的关键点:
- 出队列的判断并非观察 tail 指针的位置,而是依赖于 head 指针后续的节点是否为NULL这一 条件。
- 只要对节点的item执行CAS操作,置为NULL成功,则出队列成功。即使head指针没有成功移 动,也可以由下1个线程继续完成。
4.队列判空
因为head/tail 并不是精确地指向队列头部和尾部,所以不能简单地通过比较 head/tail 指针来判断 队列是否为空,而是需要从head指针开始遍历,找第1个不为NULL的节点。如果找到,则队列不为空; 如果找不到,则队列为空。代码如下所示:
5.5 ConcurrentHashMap
HashMap通常的实现方式是“数组+链表”,这种方式被称为“拉链法”。ConcurrentHashMap在这个基本原理之上进行了各种优化。
首先是所有数据都放在一个大的HashMap中;其次是引入了红黑树。
其原理如下图所示:
如果头节点是Node类型,则尾随它的就是一个普通的链表;
如果头节点是TreeNode类型,它的后面就是一颗红黑树TreeNode是Node的子类。
链表和红黑树之间可以相互转换:初始的时候是链表,当链表中的元素超过某个阈值时,把链表转 换成红黑树;反之,当红黑树中的元素个数小于某个阈值时,再转换为链表。
那为什么要做这种设计呢?
- 使用红黑树,当一个槽里有很多元素时,其查询和更新速度会比链表快很多,Hash冲突的问 题由此得到较好的解决。
- 加锁的粒度,并非整个ConcurrentHashMap,而是对每个头节点分别加锁,即并发度,就是 Node数组的长度,初始长度为16。
- 并发扩容,这是难度最大的。当一个线程要扩容Node数组的时候,其他线程还要读写,因此 处理过程很复杂,后面会详细分析。
由上述对比可以总结出来:这种设计一方面降低了Hash冲突,另一方面也提升了并发度。
下面从构造方法开始,一步步深入分析其实现过程。
1.构造方法分析
在上面的代码中,变量cap就是Node数组的长度,保持为2的整数次方。tableSizeFor(…)方法是根 据传入的初始容量,计算出一个合适的数组长度。具体而言:1.5倍的初始容量+1,再往上取最接近的2 的整数次方,作为数组长度cap的初始值。
这里的 sizeCtl,其含义是用于控制在初始化或者并发扩容时候的线程数,只不过其初始值设置成 cap。
2.初始化
在上面的构造方法里只计算了数组的初始大小,并没有对数组进行初始化。当多个线程都往里面放 入元素的时候,再进行初始化。这就存在一个问题:多个线程重复初始化。下面看一下是如何处理的。
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // 自旋等待
else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) { // 重点:将 sizeCtl设置为-1
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; // 初始化
table = tab = nt;
// sizeCtl不是数组长度,因此初始化成功后,就不再等于数组长度
// 而是n-(n>>>2)=0.75n,表示下一次扩容的阈值:n-n/4
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc; // 设置sizeCtl的值为sc。
}
break;
}
}
return tab;
}
通过上面的代码可以看到,多个线程的竞争是通过对sizeCtl进行CAS操作实现的。如果某个线程成 功地把 sizeCtl 设置为-1,它就拥有了初始化的权利,进入初始化的代码模块,等到初始化完成,再把 sizeCtl设置回去;其他线程则一直执行while循环,自旋等待,直到数组不为null,即当初始化结束时, 退出整个方法。
因为初始化的工作量很小,所以此处选择的策略是让其他线程一直等待,而没有帮助其初始化。
3.put(…)实现分析
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh; K fk; V fv;
// 分支1:整个数组初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 分支2:第i个元素初始化
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // no lock when adding to empty bin
}
// 分支3:扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
//
else if (onlyIfAbsent // check first node without acquiring lock
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
// 分支4:放入元素
else {
V oldVal = null; // 重点:加锁
synchronized (f) {
// 链表
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value);
break;
}
}
}
else if (f instanceof TreeBin) { //红黑树
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
// 如果是链表,上面的binCount会一直累加
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i); // 超出阈值,转换为红黑树
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount); // 总元素个数累加1
return null;
}
上面的for循环有4个大的分支:
第1个分支,是整个数组的初始化,前面已讲;
第2个分支,是所在的槽为空,说明该元素是该槽的第一个元素,直接新建一个头节点,然后返回;
第3个分支,说明该槽正在进行扩容,帮助其扩容;
第4个分支,就是把元素放入槽内。槽内可能是一个链表,也可能是一棵红黑树,通过头节点的类型 可以判断是哪一种。第4个分支是包裹在synchronized (f)里面的,f对应的数组下标位置的头节点, 意味着每个数组元素有一把锁,并发度等于数组的长度。
上面的binCount表示链表的元素个数,当这个数目超过TREEIFY_THRESHOLD=8时,把链表转换成 红黑树,也就是 treeifyBin(tab,i)方法。但在这个方法内部,不一定需要进行红黑树转换,可能只做 扩容操作,所以接下来从扩容讲起。
4.扩容
扩容的实现是最复杂的,下面从treeifyBin(Node<K,V>[] tab, int index)讲起。
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n;
if (tab != null) {
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
// 数组长度小于阈值64,不做红黑树转换,直接扩容
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
// 链表转换为红黑树
synchronized (b) {
if (tabAt(tab, index) == b) {
TreeNode<K,V> hd = null, tl = null;
// 遍历链表,初始化红黑树
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p = new TreeNode<K,V>(e.hash, e.key, e.val, null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
setTabAt(tab, index, new TreeBin<K,V>(hd))
}
}
}
}
}
在上面的代码中,MIN_TREEIFY_CAPACITY=64,意味着当数组的长度没有超过64的时候,数组的 每个节点里都是链表,只会扩容,不会转换成红黑树。只有当数组长度大于或等于64时,才考虑把链表 转换成红黑树。
该方法非常复杂,下面一步步分析:
-
扩容的基本原理如下图,首先建一个新的HashMap,其数组长度是旧数组长度的2倍,然后把 旧的元素逐个迁移过来。所以,上面的方法参数有2个,第1个参数tab是扩容之前的 HashMap,第2个参数nextTab是扩容之后的HashMap。当nextTab=null的时候,方法最初 会对nextTab进行初始化。这里有一个关键点要说明:该方法会被多个线程调用,所以每个线 程只是扩容旧的HashMap部分,这就涉及如何划分任务的问题。
-
上图为多个线程并行扩容-任务划分示意图。旧数组的长度是N,每个线程扩容一段,一段的长 度用变量stride(步长)来表示,transferIndex表示了整个数组扩容的进度。
stride的计算公式如上面的代码所示,即:在单核模式下直接等于n,因为在单核模式下没有办 法多个线程并行扩容,只需要1个线程来扩容整个数组;在多核模式下为 (n>>> 3)/NCPU,并且保证步长的最小值是 16。显然,需要的线程个数约为n/stride。
transferIndex是ConcurrentHashMap的一个成员变量,记录了扩容的进度。初始值为n,从大到 小扩容,每次减stride个位置,最终减至n<=0,表示整个扩容完成。因此,从[0,transferIndex-1]的 位置表示还没有分配到线程扩容的部分,从[transfexIndex,n-1]的位置表示已经分配给某个线程进行扩 容,当前正在扩容中,或者已经扩容成功。
因为transferIndex会被多个线程并发修改,每次减stride,所以需要通过CAS进行操作,如下面的代码 所示。
-
在扩容未完成之前,有的数组下标对应的槽已经迁移到了新的HashMap里面,有的还在旧的 HashMap 里面。这个时候,所有调用 get(k,v)的线程还是会访问旧 HashMap,怎么处理 呢?
下图为扩容过程中的转发示意图:当Node[0]已经迁移成功,而其他Node还在迁移过程中时, 如果有线程要读取Node[0]的数据,就会访问失败。为此,新建一个ForwardingNode,即转 发节点,在这个节点里面记录的是新的 ConcurrentHashMap 的引用。这样,当线程访问到 ForwardingNode之后,会去查询新的ConcurrentHashMap。 -
因为数组的长度 tab.length 是2的整数次方,每次扩容又是2倍。而 Hash 函数是 hashCode%tab.length,等价于hashCode&(tab.length-1)。这意味着:处于第i个位置的 元素,在新的Hash表的数组中一定处于第i个或者第i+n个位置,如下图所示。
举个简单的例子:假设数组长度是8,扩容之后是16:
若hashCode=5,5%8=0,扩容后,5%16=0,位置保持不变;
若hashCode=24,24%8=0,扩容后,24%16=8,后移8个位置;
若hashCode=25,25%8=1,扩容后,25%16=9,后移8个位置;
若hashCode=39,39%8=7,扩容后,39%8=7,位置保持不变;
…
正因为有这样的规律,所以如下有代码:
也就是把tab[i]位置的链表或红黑树重新组装成两部分,一部分链接到nextTab[i]的位置,一部分链 接到nextTab[i+n]的位置,如上图所示。然后把tab[i]的位置指向一个ForwardingNode节点。同时,当tab[i]后面是链表时,使用类似于JDK 7中在扩容时的优化方法,从lastRun往后的所有节 点,不需依次拷贝,而是直接链接到新的链表头部。从lastRun往前的所有节点,需要依次拷贝。
了解了核心的迁移函数transfer(tab,nextTab),再回头看tryPresize(int size)函数。这个函 数的输入是整个Hash表的元素个数,在函数里面,根据需要对整个Hash表进行扩容。想要看明白这个 函数,需要透彻地理解sizeCtl变量,下面这段注释摘自源码。
当sizeCtl=-1时,表示整个HashMap正在初始化;
当sizeCtl=某个其他负数时,表示多个线程在对HashMap做并发扩容;
当sizeCtl=cap时,tab=null,表示未初始之前的初始容量(如上面的构造函数所示);
扩容成功之后,sizeCtl存储的是下一次要扩容的阈值,即上面初始化代码中的n-(n>>>2) =0.75n。
所以,sizeCtl变量在Hash表处于不同状态时,表达不同的含义。明白了这个道理,再来看上面的 tryPresize(int size)函数。
tryPresize(int size)是根据期望的元素个数对整个Hash表进行扩容,核心是调用transfer函数。 在第一次扩容的时候,sizeCtl会被设置成一个很大的负数U.compareAndSwapInt(this,SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT)+2);之后每一个线程扩容的时候,sizeCtl 就加 1, U.compareAndSwapInt(this,SIZECTL,sc,sc+1),待扩容完成之后,sizeCtl减1。
5.6 ConcurrentSkipListMap/Set
ConcurrentHashMap 是一种 key 无序的 HashMap,ConcurrentSkipListMap则是 key 有序的, 实现了NavigableMap接口,此接口又继承了SortedMap接口。
5.6.1 ConcurrentSkipListMap
1.为什么要使用SkipList实现Map?
在Java的util包中,有一个非线程安全的HashMap,也就是TreeMap,是key有序的,基于红黑树实现。
而在Concurrent包中,提供的key有序的HashMap,也就是ConcurrentSkipListMap,是基于 SkipList(跳查表)来实现的。这里为什么不用红黑树,而用跳查表来实现呢?
借用Doug Lea的原话:
The reason is that there are no known efficient lock0free insertion and deletion algorithms for search trees.
也就是目前计算机领域还未找到一种高效的、作用在树上的、无锁的、增加和删除节点的办法。
那为什么SkipList可以无锁地实现节点的增加、删除呢?这要从无锁链表的实现说起。
2.无锁链表
在前面讲解AQS时,曾反复用到无锁队列,其实现也是链表。究竟二者的区别在哪呢?
前面讲的无锁队列、栈,都是只在队头、队尾进行CAS操作,通常不会有问题。如果在链表的中间 进行插入或删除操作,按照通常的CAS做法,就会出现问题!
关于这个问题,Doug Lea的论文中有清晰的论述,此处引用如下:
操作1:在节点10后面插入节点20。如下图所示,首先把节点20的next指针指向节点30,然后对节点10的next指针执行CAS操作,使其指向节点20即可。
操作2:删除节点10。如下图所示,只需把头节点的next指针,进行CAS操作到节点30即可。
但是,如果两个线程同时操作,一个删除节点10,一个要在节点10后面插入节点20。并且这两个操 作都各自是CAS的,此时就会出现问题。如下图所示,删除节点10,会同时把新插入的节点20也删除 掉!这个问题超出了CAS的解决范围。
为什么会出现这个问题呢?
究其原因:在删除节点10的时候,实际受到操作的是节点10的前驱,也就是头节点。节点10本身没有任何变化。故而,再往节点10后插入节点20的线程,并不知道节点10已经被删除了!
针对这个问题,在论文中提出了如下的解决办法,如下图所示,把节点 10 的删除分为两2步:
第一步,把节点10的next指针,mark成删除,即软删除;
第二步,找机会,物理删除。
做标记之后,当线程再往节点10后面插入节点20的时候,便可以先进行判断,节点10是否已经被删 除,从而避免在一个删除的节点10后面插入节点20。这个解决方法有一个关键点:“把节点10的next指 针指向节点20(插入操作)”和“判断节点10本身是否已经删除(判断操作)”,必须是原子的,必须在1 个CAS操作里面完成!
具体的实现有两个办法:
办法一:AtomicMarkableReference
保证每个 next 是 AtomicMarkableReference 类型。但这个办法不够高效,Doug Lea 在 ConcurrentSkipListMap的实现中用了另一种办法。
办法2:Mark节点
我们的目的是标记节点10已经删除,也就是标记它的next字段。那么可以新造一个marker节点,使 节点10的next指针指向该Marker节点。这样,当向节点10的后面插入节点20的时候,就可以在插入的 同时判断节点10的next指针是否指向了一个Marker节点,这两个操作可以在一个CAS操作里面完成。
3.跳查表
解决了无锁链表的插入或删除问题,也就解决了跳查表的一个关键问题。因为跳查表就是多层链表叠起来的。
下面先看一下跳查表的数据结构(下面所用代码都引用自JDK 7,JDK 8中的代码略有差异,但不影响下面的原理分析)。
上图中的Node就是跳查表底层节点类型。所有的<K, V>对都是由这个单向链表串起来的。
上面的Index层的节点:
上图中的node属性不存储实际数据,指向Node节点。
down属性:每个Index节点,必须有一个指针,指向其下一个Level对应的节点。
right属性:Index也组成单向链表。
整个ConcurrentSkipListMap就只需要记录顶层的head节点即可:
下面详细分析如何从跳查表上查找、插入和删除元素。
-
put实现分析
在底层,节点按照从小到大的顺序排列,上面的index层间隔地串在一起,因为从小到大排列。查找的时候,从顶层index开始,自左往右、自上往下,形成图示的遍历曲线。假设要查找的元素是32,遍 历过程如下:先遍历第2层Index,发现在21的后面;
从21下降到第1层Index,从21往后遍历,发现在21和35之间;
从21下降到底层,从21往后遍历,最终发现在29和35之间。在整个的查找过程中,范围不断缩小,最终定位到底层的两个元素之间。
关于上面的put(…)方法,有一个关键点需要说明:在通过findPredecessor找到了待插入的元素在 [b,n]之间之后,并不能马上插入。因为其他线程也在操作这个链表,b、n都有可能被删除,所以在插 入之前执行了一系列的检查逻辑,而这也正是无锁链表的复杂之处。
-
remove(…)分析
上面的删除方法和插入方法的逻辑非常类似,因为无论是插入,还是删除,都要先找到元素的前 驱,也就是定位到元素所在的区间[b,n]。在定位之后,执行下面几个步骤:- 如果发现b、n已经被删除了,则执行对应的删除清理逻辑;
- 否则,如果没有找到待删除的(k, v),返回null;
- 如果找到了待删除的元素,也就是节点n,把n的value置为null,同时在n的后面加上Marker节点,同时检查是否需要降低Index的层次。
-
get分析
无论是插入、删除,还是查找,都有相似的逻辑,都需要先定位到元素位置[b,n],然后判断b、n 是否已经被删除,如果是,则需要执行相应的删除清理逻辑。这也正是无锁链表复杂的地方。