1. 异步计算结果 FutureTask 实现类
Future 位于 java.util.concurrent 包下,它是一个接口:
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}
一共声明了 5 个方法:
cancel()方法用来取消任务,如果取消任务成功则返回 true,如果取消任务失败则返回 false。isCancelled()方法表示任务是否被取消成功,如果在任务正常完成前被取消成功,则返回 true。isDone()方法表示任务是否已经完成,若任务完成,则返回 true;get()方法用来获取执行结果,这个方法会产生阻塞,会一直等到任务执行完毕才返回;get(long timeout, TimeUnit unit)用来获取执行结果,如果在指定时间内,还没获取到结果,就直接返回 null。
FutureTask 是 Future 接口的一个唯一实现类,RunnableFuture 继承了 Runnable 接口和 Future 接口,而 FutureTask 实现了 RunnableFuture 接口。所以它既可以作为 Runnable 被线程执行,又可以作为 Future 得到 Callable 的返回值。
2.线程的6种状态
// Thread.State 源码
public enum State {
NEW, // 处于 NEW 状态的线程此时尚未启动。这里的尚未启动指的是还没调用 Thread 实例的`start()`方法。
RUNNABLE, // 表示当前线程正在运行中。处于 RUNNABLE 状态的线程在 Java 虚拟机中运行,也有可能在等待 CPU 分配资源。
BLOCKED, // 处于 BLOCKED 状态的线程正等待锁的释放以进入同步区。
WAITING, // 处于等待状态的线程变成 RUNNABLE 状态需要其他线程唤醒。
TIMED_WAITING, // 超时等待状态。线程等待一个具体的时间,时间到后会被自动唤醒。
TERMINATED; // 此时线程已执行完毕。
}
3.控制线程的方法**
- sleep():使当前正在执行的线程暂停指定的毫秒数,也就是进入休眠的状态。需要注意的是,sleep 的时候要对异常进行处理。
- join():主线程等待这个线程执行完才会轮到后续线程得到 cpu 的执行权,使用这个也要捕获异常。
- setDaemon():将此线程标记为守护线程
- yield():方法是一个静态方法,用于暗示当前线程愿意放弃其当前的时间片,允许其他线程执行。然而,它只是向线程调度器提出建议,调度器可能会忽略这个建议 线程状态转换图
线程中断
在某些情况下,我们在线程启动后发现并不需要它继续执行下去时,需要中断线程。目前在 Java 里还没有安全方法来直接停止线程,但是 Java 提供了线程中断机制来处理需要中断线程的情况。
线程中断机制是一种协作机制。需要注意,通过中断操作并不能直接终止一个线程,而是通知需要被中断的线程自行处理。
简单介绍下 Thread 类里提供的关于线程中断的几个方法:
Thread.interrupt():中断线程。这里的中断线程并不会立即停止线程,而是设置线程的中断状态为 true(默认是 flase);Thread.currentThread().isInterrupted():测试当前线程是否被中断。线程的中断状态会受这个方法的影响,调用一次可以使线程中断状态变为 true,调用两次会使这个线程的中断状态重新转为 false;Thread.isInterrupted():测试当前线程是否被中断。与上面方法不同的是调用这个方法并不会影响线程的中断状态。
在线程中断机制里,当其他线程通知需要被中断的线程后,线程中断的状态被设置为 true,但是具体被要求中断的线程要怎么处理,完全由被中断线程自己决定,可以在合适的时机中断请求,也可以完全不处理继续执行下去。
4. 线程组
- Java 用 ThreadGroup 来表示线程组,我们可以通过线程组对线程进行批量控制。
ThreadGroup 和 Thread 的关系就如同他们的字面意思一样简单粗暴,每个 Thread 必然存在于一个 ThreadGroup 中,Thread 不能独立于 ThreadGroup 存在。执行
main()方法的线程名字是 main,如果在 new Thread 时没有显式指定,那么默认将父线程(当前执行 new Thread 的线程)线程组设置为自己的线程组。 - ThreadGroup 是一个标准的向下引用的树状结构,这样设计可以防止"上级"线程被"下级"线程引用而无法有效地被 GC 回收。
5. 多线程带来的问题
- 安全性问题:原子性、一致性
- 活跃性问题:死锁、活锁、饥饿问题
- 性能问题:线程安全和死锁、活锁这些问题,如果这些都没有发生,多线程并发一定比单线程串行执行快吗?答案是不一定,因为多线程有
创建线程和线程上下文切换的开销。
- 死锁是指多个线程因为环形等待锁的关系而永远地阻塞下去
2. 活锁是线程没有阻塞,当多个线程都在运行并且都在修改各自的状态,而其他线程又依赖这个状态,就导致任何一个线程都无法继续执行,只能重复着自身的动作,于是就发生了活锁。
3. 饥饿问题:如果一个线程无其他异常却迟迟不能继续运行,那基本上是处于饥饿状态了。
常见的有几种场景:
- 高优先级的线程一直在运行消耗 CPU,所有的低优先级线程一直处于等待;
- 一些线程被永久堵塞在一个等待进入同步块的状态,而其他线程总是能在它之前持续地对该同步块进行访问;
- 减少上下文切换的方法:
- 无锁并发编程:可以参照 ConcurrentHashMapopen 锁分段的思想,不同的线程处理不同段的数据,这样在多线程竞争的条件下,可以减少上下文切换的时间。
- CAS 算法,利用CASopen in new window 算法来更新数据,采用乐观锁的方式,可以有效减少一部分不必要的锁竞争带来的上下文切换。
- 使用最少线程:避免创建不必要的线程,如果任务很少,但创建了很多的线程,这样就会造成大量的线程都处于等待状态。
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
6. JMM 与 Java 运行时内存区域的区别
- 区别 两者是不同的概念。JMM 是抽象的,他是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开。而 Java 运行时内存的划分是具体的,是 JVM 运行 Java 程序时必要的内存划分。
- 联系 都存在私有数据区域和共享数据区域。一般来说,JMM 中的主存属于共享数据区域,包含了堆和方法区;同样,JMM 中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。
7. 指令重排序
为什么指令重排可以提高性能?
大家都知道,计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
那可能有小伙伴就要问:为什么指令重排序可以提高性能?
简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令 1 还没有执行完,就可以开始执行指令 2,而不用等到指令 1 执行结束后再执行指令 2,这样就大大提高了效率。 但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。指令重排对于提高 CPU 性能十分必要,但也带来了乱序的问题。 指令重排一般分为以下三种:
- 编译器优化重排,编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
- 指令并行重排,现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
- 内存系统重排,由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题--顺序一致性。
什么是顺序一致性模型?
顺序一致性模型是一个理想化的理论参考模型,它为程序提供了极强的内存可见性保证。顺序一致性模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序(即 Java 代码的顺序)来执行。
- 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。即在顺序一致性模型中,每个操作必须是原子性的,且立刻对所有线程可见。 JMM 没有保证未同步程序的执行结果与该程序在顺序一致性中执行结果一致。因为如果要保证执行结果一致,那么 JMM 需要禁止大量的优化,对程序的执行性能会产生很大的影响。
未同步程序(多线程程序)在 JMM 和顺序一致性内存模型中的执行特性有如下差异:
- 顺序一致性保证单线程内的操作会按程序的顺序执行;JMM 不保证单线程内的操作会按程序的顺序执行。(因为重排序,但是 JMM 保证单线程下的重排序不影响执行结果)
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。(因为 JMM 不保证所有操作立即可见)
- 顺序一致性模型保证对所有的内存读写操作都具有原子性,而 JMM 不保证对 64 位的 long 型和 double 型变量的写操作具有原子性。
JMM 提供了happens-before 规则(JSR-133 规范),满足了我们的诉求——简单易懂,并且提供了足够强的内存可见性保证 换言之,我们开发者只要遵循 happens-before 规则,那么我们写的程序就能保证在 JMM 中具有强的内存可见性。 happens-before 关系的定义如下:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。
8.volatile 会禁止指令重排
在讲 JMMopen in new window 的时候,我们提到了指令重排,相信大家都还有印象,我们来回顾一下重排序需要遵守的规则:
- 重排序不会对存在数据依赖关系的操作进行重排序。比如:
a=1;b=a;这个指令序列,因为第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。 - 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。比如:
a=1;b=2;c=a+b这三个操作,第一步 (a=1) 和第二步 (b=2) 由于不存在数据依赖关系,所以可能会发生重排序,但是 c=a+b 这个操作是不会被重排序的,因为需要保证最终的结果一定是 c=a+b=3。
使用 volatile 关键字修饰共享变量可以禁止这种重排序。怎么做到的呢?
当我们使用 volatile 关键字来修饰一个变量时,Java 内存模型会插入内存屏障(一个处理器指令,可以对 CPU 或编译器重排序做出约束)来确保以下两点:
- 写屏障(Write Barrier):当一个 volatile 变量被写入时,写屏障确保在该屏障之前的所有变量的写入操作都提交到主内存。
- 读屏障(Read Barrier):当读取一个 volatile 变量时,读屏障确保在该屏障之后的所有读操作都从主内存中读取。
9.# synchronized
在 Java 中,关键字 synchronized 可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),同时我们还应该注意到 synchronized 的另外一个重要的作用,synchronized 可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代volatile功能)。 synchronized 关键字最主要有以下 3 种应用方式:
- 同步方法,为当前对象this加锁,进入同步代码前要获得当前对象的锁;
- 同步静态方法,为当前类加锁(锁的是 Class,进入同步代码前要获得当前类的锁;
- 同步代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
- synchronized禁止指令重排
synchronized属于可重入锁
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。
所谓“临界区”,指的是某一块代码区域,它同一时刻只能由一个线程执行。在上面的例子中,如果synchronized关键字在方法上,那临界区就是整个方法内部。而如果是 synchronized 代码块,那临界区就指的是代码块内部的区域。
synchronized 就是可重入锁,因此一个线程调用 synchronized 方法的同时,在其方法体内部调用该对象另一个 synchronized 方法是允许的。
synchronized锁的四种状态
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
对象的锁放在什么地方
前面我们提到,Java 的锁都是基于对象的。
首先我们来看看一个对象的“锁”是存放在什么地方的。
每个 Java 对象都有一个对象头。如果是非数组类型,则用 2 个字宽来存储对象头,如果是数组,则会用 3 个字宽来存储对象头。在 32 位处理器中,一个字宽是 32 位;在 64 位虚拟机中,一个字宽是 64 位。对象头的内容如下表所示:
| 长度 | 内容 | 说明 |
|---|---|---|
| 32/64bit | Mark Word | 存储对象的 hashCode 或锁信息等 |
| 32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
| 32/64bit | Array length | 数组的长度(如果是数组) |
我们主要来看看 Mark Word 的格式:
| 锁状态 | 29 bit 或 61 bit | 1 bit 是否是偏向锁? | 2 bit 锁标志位 |
|---|---|---|---|
| 无锁 | 0 | 01 | |
| 偏向锁 | 线程 ID | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 此时这一位不用于标识偏向锁 | 00 |
| 重量级锁 | 指向互斥量(重量级锁)的指针 | 此时这一位不用于标识偏向锁 | 10 |
| GC 标记 | 此时这一位不用于标识偏向锁 | 11 |
可以看到,当对象状态为偏向锁时,Mark Word存储的是偏向的线程 ID;当状态为轻量级锁时,Mark Word存储的是指向线程栈中Lock Record的指针;当状态为重量级锁时,Mark Word为指向堆中的 monitor(监视器)对象的指针。
偏向锁
偏向锁实际上就是「锁对象」潜意识「偏向」同一个线程来访问,让锁对象记住这个线程 ID,当线程再次获取锁时,亮出身份,如果是同一个 ID 直接获取锁就好了,是一种 load-and-test 的过程,相较 CAS 又轻量级了一些。
可是多线程环境,也不可能只有同一个线程一直获取这个锁,其他线程也是要干活的,如果出现多个线程竞争的情况,就会有偏向锁升级的过程。
Java实现CAS的原理
CAS(Compare-and-Swap)是一种乐观锁的实现方式,全称为“比较并交换”,是一种无锁的原子操作。乐观锁,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。一旦多个线程发生冲突,乐观锁通常使用一种称为 CAS 由于乐观锁假想操作中没有锁的存在,因此不太可能出现死锁的情况,换句话说,乐观锁天生免疫死锁。
- 乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;
- 悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。
什么是 CAS 在 CAS 中,有这样三个值:
- V:要更新的变量(var)
- E:预期值(expected)
- N:新值(new)
比较并交换的过程如下:
判断 V 是否等于 E,如果等于,将 V 的值设置为 N;如果不等,说明已经有其它线程更新了 V,于是当前线程放弃更新,什么都不做。
这里的预期值 E 本质上指的是“旧值” 。的技术来保证线程执行的安全性。
在 Java 中,有一个Unsafe类(后面会细讲,戳链接直达open in new window),它在sun.misc包中。它里面都是一些native方法,其中就有几个是关于 CAS 的:
boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);
boolean compareAndSwapInt(Object o, long offset,int expected,int x);
boolean compareAndSwapLong(Object o, long offset,long expected,long x);
Unsafe 对 CAS 的实现是通过 C++ 实现的,它的具体实现和操作系统、CPU 都有关系。
CAS 的三大问题
尽管 CAS 提供了一种有效的同步手段,但也存在一些问题,主要有以下三个:ABA 问题、长时间自旋、多个共享变量的原子操作。
ABA 问题
所谓的 ABA 问题,就是一个值原来是 A,变成了 B,又变回了 A。这个时候使用 CAS 是检查不出变化的,但实际上却被更新了两次。
长时间自旋
CAS 多与自旋结合。如果自旋 CAS 长时间不成功,会占用大量的 CPU 资源。 解决思路是让 JVM 支持处理器提供的pause 指令。
多个共享变量的原子操作
当对一个共享变量执行操作时,CAS 能够保证该变量的原子性。但是对于多个共享变量,CAS 就无法保证操作的原子性,这时通常有两种做法
AQS(抽象队列同步器)
AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的同步器,比如我们后面会细讲的ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue,FutureTask 等等,都是基于 AQS 的。
当然了,我们也可以利用 AQS 轻松定制专属的同步器,只要实现它的几个protected方法就可以了。
AQS 内部使用了一个volatile的变量 state 来作为资源的标识。
/**
* The synchronization state.
*/
private volatile int state;
同时定义了几个获取和改变 state 的 protected 方法,子类可以覆盖这些方法来实现自己的逻辑:
getState()
setState()
compareAndSetState()
这三种操作均是原子操作,其中 compareAndSetState 的实现依赖于Unsafe 的 compareAndSwapInt() 方法。
AQS 内部使用了一个先进先出(FIFO)的双端队列,并使用了两个引用 head 和 tail 用于标识队列的头部和尾部。其数据结构如下图所示:
但它并不直接储存线程,而是储存拥有线程的 Node 节点。
AQS 的 Node 节点
资源有两种共享模式,或者说两种同步方式:
- 独占模式(Exclusive):资源是独占的,一次只能有一个线程获取。如ReentrantLock(后面会细讲,戳链接直达)。
- 共享模式(Share):同时可以被多个线程获取,具体的资源个数可以通过参数指定。如Semaphore/CountDownLatch(戳链接直达,后面会细讲)。 一般情况下,子类只需要根据需求实现其中一种模式就可以,当然也有同时实现两种模式的同步类,如ReadWriteLock。
锁分类
重入锁ReentrantLock
ReentrantLock 重入锁,是实现Lock 接口open in new window的一个类,也是在实际编程中使用频率很高的一个锁,支持重入性,表示能够对共享资源重复加锁,即当前线程获取该锁后再次获取不会被阻塞。
要想支持重入性,就要解决两个问题:
- 在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功;
- 由于锁会被获取 n 次,那么只有锁在被释放同样的 n 次之后,该锁才算是完全释放成功。、
ReentrantLock 与 synchronized
ReentrantLock 与 synchronized 关键字都是用来实现同步的,那么它们之间有什么区别呢?我们来看看它们的对比:
- ReentrantLock 是一个类,而 synchronized 是 Java 中的关键字;
- ReentrantLock 可以实现多路选择通知(可以绑定多个Condition,而 synchronized 只能通过 wait 和 notify/notifyAll 方法唤醒一个线程或者唤醒全部线程(单路通知) ;
- ReentrantLock 必须手动释放锁。通常需要在 finally 块中调用 unlock 方法以确保锁被正确释放。
- synchronized 会自动释放锁,当同步块执行完毕时,由 JVM 自动释放,不需要手动操作。
- ReentrantLock: 通常提供更好的性能,特别是在高竞争环境下。
- synchronized: 在某些情况下,性能可能稍差一些,但随着 JDK 版本的升级,性能差距已经不大了。
读写锁ReentrantReadWriteLock
ReentrantReadWriteLock 是 Java 的一种读写锁,它允许多个读线程同时访问,但只允许一个写线程访问(会阻塞所有的读写线程)。这种锁的设计可以提高性能,特别是在读操作的数量远远超过写操作的情况下。
Condition详情
每个对象都可以调用 Object 的 wait/notify 方法来实现等待/通知机制。而 Condition 接口也提供了类似的方法。
Condition 接口一共提供了以下 7 个方法:
await():线程等待直到被通知或者中断。类似于Object.wait()。awaitUninterruptibly():线程等待直到被通知,即使在等待时被中断也不会返回。没有与之对应的 Object 方法。await(long time, TimeUnit unit):线程等待指定的时间,或被通知,或被中断。类似于Object.wait(long timeout),但提供了更灵活的时间单位。awaitNanos(long nanosTimeout):线程等待指定的纳秒时间,或被通知,或被中断。没有与之对应的 Object 方法。awaitUntil(Date deadline):线程等待直到指定的截止日期,或被通知,或被中断。没有与之对应的 Object 方法。signal():唤醒一个等待的线程。类似于Object.notify()。signalAll():唤醒所有等待的线程。类似于Object.notifyAll()。 我们再来回顾一下 Object 类的主要方法:
wait():线程等待直到被通知或者中断。wait(long timeout):线程等待指定的时间,或被通知,或被中断。wait(long timeout, int nanos):线程等待指定的时间,或被通知,或被中断。notify():唤醒一个等待的线程。notifyAll():唤醒所有等待的线程。 Condition 内部也使用了同样的方式,内部维护了一个先进先出(FIFO)的单向队列,我们把它称为等待队列。
所有调用 await 方法的线程都会加入到等待队列中,并且线程状态均为等待状态。firstWaiter 指向首节点,lastWaiter 指向尾节点,
# 并发线程阻塞唤醒类LockSupport
该类包含一组用于阻塞和唤醒线程的静态方法,这些方法主要是围绕 park 和 unpark 展开
阻塞线程
void park():阻塞当前线程,如果调用 unpark 方法或线程被中断,则该线程将变得可运行。请注意,park 不会抛出 InterruptedException,因此线程必须单独检查其中断状态。void park(Object blocker):功能同方法 1,入参增加一个 Object 对象,用来记录导致线程阻塞的对象,方便问题排查。void parkNanos(long nanos):阻塞当前线程一定的纳秒时间,或直到被 unpark 调用,或线程被中断。void parkNanos(Object blocker, long nanos):功能同方法 3,入参增加一个 Object 对象,用来记录导致线程阻塞的对象,方便问题排查。void parkUntil(long deadline):阻塞当前线程直到某个指定的截止时间(以毫秒为单位),或直到被 unpark 调用,或线程被中断。void parkUntil(Object blocker, long deadline):功能同方法 5,入参增加一个 Object 对象,用来记录导致线程阻塞的对象,方便问题排查。-
LockSupport与synchronzed 的区别
还有一点需要注意的是:Lsynchronzed 会使线程阻塞,线程会进入 BLOCKED 状态,而调用 LockSupprt 方法阻塞线程会使线程进入到 WAITING 状态LockSupport.unpark(thread)可以指定线程对象唤醒指定的线程。
Java的并发集合容器ConcurrentHashMap、阻塞队列和 CopyOnWrite 容器
Java 的并发集合容器提供了在多线程环境中高效访问和操作的数据结构。这些容器通过内部的同步机制实现了线程安全,使得开发者无需显式同步代码就能在并发环境下安全使用,比如说:ConcurrentHashMap、阻塞队列和 CopyOnWrite 容器等。
-
并发 Map:
ConcurrentMap 接口继承了 Map 接口,在 Map 接口的基础上又定义了四个方法:
public interface ConcurrentMap<K, V> extends Map<K, V> {
//插入元素
V putIfAbsent(K key, V value);
//移除元素
boolean remove(Object key, Object value);
//替换元素
boolean replace(K key, V oldValue, V newValue);
//替换元素
V replace(K key, V value);
}
putIfAbsent: 与原有 put 方法不同的是,putIfAbsent 如果插入的 key 相同,则不替换原有的 value 值;
remove: 与原有 remove 方法不同的是,新 remove 方法中增加了对 value 的判断,如果要删除的 key-value 不能与 Map 中原有的 key-value 对应上,则不会删除该元素;
replace(K,V,V): 增加了对 value 值的判断,如果 key-oldValue 能与 Map 中原有的 key-value 对应上,才进行替换操作;
replace(K,V): 与上面的 replace 不同的是,此 replace 不会对 Map 中原有的 key-value 进行比较,如果 key 存在则直接替换;
-
并发 Queue
JDK 并没有提供线程安全的 List 类,因为对 List 来说,很难去开发一个通用并且没有并发瓶颈的线程安全的 List。因为即使简单的读操作,比如 contains(),也需要再搜索的时候锁住整个 list。
所以退一步,JDK 提供了队列和双端队列的线程安全类:ConcurrentLinkedQueue 和 ConcurrentLinkedDeque。因为队列相对于 List 来说,有更多的限制。这两个类是使用 CAS 来实现线程安全的。
-
并发 Set
ConcurrentSkipListSet 是线程安全的有序集合。底层是使用 ConcurrentSkipListMap 来实现。
-
阻塞队列
BlockingQueue 是 Java util.concurrent 包下重要的数据结构,区别于普通的队列,BlockingQueue 提供了线程安全的队列访问方式,并发包下很多高级同步类的实现都是基于 BlockingQueue 实现的。
BlockingQueue 的实现类
#ArrayBlockingQueue
由数组结构组成的有界阻塞队列。内部结构是数组,具有数组的特性。
public ArrayBlockingQueue(int capacity, boolean fair){
//..省略代码
}
可以初始化队列大小,一旦初始化将不能改变。构造方法中的 fair 表示控制对象的内部锁是否采用公平锁,默认是非公平锁。
#LinkedBlockingQueue
由链表结构组成的有界阻塞队列。内部结构是链表,具有链表的特性。默认队列的大小是Integer.MAX_VALUE,也可以指定大小。此队列按照先进先出的原则对元素进行排序。
#DelayQueue
该队列中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。注入其中的元素必须实现 java.util.concurrent.Delayed 接口。
DelayQueue 是一个没有大小限制的队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。
#PriorityBlockingQueue
基于优先级的无界阻塞队列(优先级的判断通过构造函数传入的 Compator 对象来决定),内部控制线程同步的锁采用的是非公平锁。
网上大部分博客上PriorityBlockingQueue为公平锁,其实是不对的,查阅源码(感谢 github:ambition0802同学的指出):
public PriorityBlockingQueue(int initialCapacity,
Comparator<? super E> comparator) {
this.lock = new ReentrantLock(); //默认构造方法-非公平锁
...//其余代码略
}
[#]SynchronousQueue
这个队列比较特殊,没有任何内部容量,甚至连一个队列的容量都没有。并且每个 put 必须等待一个 take,反之亦然。
需要区别容量为 1 的 ArrayBlockingQueue、LinkedBlockingQueue。
以下方法的返回值,可以帮助理解这个队列:
iterator()永远返回空,因为里面没有东西peek()永远返回 nullput()往 queue 放进去一个 element 以后就一直 wait 直到有其他 thread 进来把这个 element 取走。offer()往 queue 里放一个 element 后立即返回,如果碰巧这个 element 被另一个 thread 取走了,offer 方法返回 true,认为 offer 成功;否则返回 false。take()取出并且 remove 掉 queue 里的 element,取不到东西他会一直等。poll()取出并且 remove 掉 queue 里的 element,只有到碰巧另外一个线程正在往 queue 里 offer 数据或者 put 数据的时候,该方法才会取到东西。否则立即返回 null。isEmpty()永远返回 trueremove()&removeAll()永远返回 false
注意
PriorityBlockingQueue不会阻塞数据生产者(因为队列是无界的),而只会在没有可消费的数据时阻塞数据的消费者。因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。对于使用默认大小的LinkedBlockingQueue也是一样的。
-
CopyOnWrite 容器
CopyOnWrite 容器即写时复制的容器,当我们往一个容器中添加元素的时候,不直接往容器中添加,而是将当前容器进行 copy,复制出来一个新的容器,然后向新容器中添加我们需要的元素,最后将原容器的引用指向新容器。
这样做的好处在于,我们可以在并发的场景下对容器进行"读操作"而不需要"加锁",从而达到读写分离的目的。从 JDK 1.5 开始 Java 并发包里提供了两个使用 CopyOnWrite 机制实现的并发容器,分别是CopyOnWriteArrayList后面会细讲,戳链接直达) 和 CopyOnWriteArraySet(不常用)。
[#]ConcurrentHashMap(线程安全的哈希表)
HashMap在多线程环境下扩容会出现 CPU 接近 100% 的情况,因为 HashMap 并不是线程安全的,我们可以通过 Collections 的Map<K,V> synchronizedMap(Map<K,V> m)将 HashMap 包装成一个线程安全的 map。
[#]线程安全的队列实现ConcurrentLinkedQueue
ConcurrentLinkedQueue 是 java.util.concurrent(JUC) 包下的一个线程安全的队列实现。基于非阻塞算法(Michael-Scott 非阻塞算法的一种变体),这意味着 ConcurrentLinkedQueue 不再使用传统的锁机制来保护数据安全,而是依靠底层原子的操作(如CAS来实现)。
[#]BlockingQueue
BlockingQueue 是 Java 中的一个接口,它代表了一个线程安全的队列,不仅可以由多个线程并发访问,还添加了等待/通知机制,以便在队列为空时阻塞获取元素的线程,直到队列变得可用,或者在队列满时阻塞插入元素的线程,直到队列变得可用。
[#] CopyOnWriteArrayList
学过 ArrayListopen in new window 的小伙伴应该记得,ArrayList 是一个线程不安全的容器,如果在多线程环境下使用,需要手动加锁,或者使用 Collections.synchronizedList() 方法将其转换为线程安全的容器。否则,将会出现ConcurrentModificationException异常。
10. ThreadLocal
ThreadLocal 就是线程的“本地变量”,即每个线程都拥有该变量的一个副本,达到人手一份的目的,这样就可以避免共享资源的竞争。
11. # 线程池
11.1. 什么是线程池
- 线程池其实是一种池化的技术实现,池化技术的核心思想就是实现资源的复用,避免资源的重复创建和销毁带来的性能开销。线程池可以管理一堆线程,让线程执行完任务之后不进行销毁,而是继续去处理其它线程已经提交的任务。
11.2.使用线程池的好处
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
11.3.线程池构造
Java 主要是通过构建 ThreadPoolExecutor 来创建线程池的。接下来我们看一下线程池是如何构造出来的 ThreadPoolExecutor 的构造方法:线程池的构造
- corePoolSize:线程池中用来工作的核心线程数量。
- maximumPoolSize:最大线程数,线程池允许创建的最大线程数。
- keepAliveTime:超出 corePoolSize 后创建的线程存活时间或者是所有线程最大存活时间,取决于配置。
- unit:keepAliveTime 的时间单位。
- workQueue:任务队列,是一个阻塞队列,当线程数达到核心线程数后,会将任务存储在阻塞队列中。
- threadFactory :线程池内部创建线程所用的工厂。
- handler:拒绝策略;当队列已满并且线程数量达到最大线程数量时,会调用该方法处理任务。
线程池的构造其实很简单,就是传入一堆参数,然后进行简单的赋值操作。 再来另画一张图总结一下 execute 的执行流程
11.4.线程池5种状态
线程池内部有 5 个常量来代表线程池的五种状态
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;
- RUNNING:线程池创建时就是这个状态,能够接收新任务,以及对已添加的任务进行处理。
- SHUTDOWN:调用 shutdown 方法,线程池就会转换成 SHUTDOWN 状态,此时线程池不再接收新任务,但能继续处理已添加的任务到队列中。
- STOP:调用 shutdownNow 方法,线程池就会转换成 STOP 状态,不接收新任务,也不能继续处理已添加的任务到队列中任务,并且会尝试中断正在处理的任务的线程。
- TIDYING:SHUTDOWN 状态下,任务数为 0, 其他所有任务已终止,线程池会变为 TIDYING 状态;线程池在 SHUTDOWN 状态,任务队列为空且执行中任务为空,线程池会变为 TIDYING 状态;线程池在 STOP 状态,线程池中执行中任务为空时,线程池会变为 TIDYING 状态。
- TERMINATED:线程池彻底终止。线程池在 TIDYING 状态执行完
terminated()方法就会转变为 TERMINATED 状态。
11.5.线程池的关闭
线程池提供了 shutdown 和 shutdownNow 两个方法来关闭线程池。
12.原子操作
基本类型的原子操作主要有这些:
- AtomicBoolean:以原子更新的方式更新 boolean;
- AtomicInteger:以原子更新的方式更新 Integer;
- AtomicLong:以原子更新的方式更新 Long;
这几个类的用法基本一致,这里以 AtomicInteger 为例。
addAndGet(int delta):增加给定的 delta,并获取新值。incrementAndGet():增加 1,并获取新值。getAndSet(int newValue):获取当前值,并将新值设置为 newValue。getAndIncrement():获取当前值,并增加 1。
13.Unsafe
Unsafe 是 Java 中一个非常特殊的类,它为 Java 提供了一种底层、"不安全"的机制来直接访问和操作内存、线程和对象。正如其名字所暗示的,Unsafe 提供了许多不安全的操作,因此它的使用应该非常小心,并限于那些确实需要使用这些底层操作的场景。
14.# 并发编程之Fork/Join框架
分治任务模型可分为两个阶段:一个阶段是 任务分解,就是迭代地将任务分解为子任务,直到子任务可以直接计算出结果;另一个阶段是 结果合并,即逐层合并子任务的执行结果,直到获得最终结果。下图是一个简化的分治任务模型图,你可以对照着理解。
在这个分治任务模型里,任务和分解后的子任务具有相似性,这种相似性往往体现在任务和子任务的算法是相同的,但是计算的数据规模是不同的。具备这种相似性的问题,我们往往都采用递归算法。