JUC
写在前面
推荐阅读
-
http://ifeve.com/java-memory-model-1/ :深入理解 Java 内存模型-程晓明
-
https://segmentfault.com/a/1190000017372067 :深入分析 AQS 实现原理
-
《Java 并发编程的艺术》:方腾飞,魏鹏,程晓明
-
ConcurrentHashMap JDK8:
https://juejin.im/post/6844903602423595015
ConcurrentHashMap JDK8 与 JDK1.7 区别:
https://www.jianshu.com/p/e694f1e868ec
ConcurrentHashMap JDK1.7:
https://developer.ibm.com/zh/articles/java-lo-concurrenthashmap/
什么是 JUC?
JUC 是 java.util.concurrent 的简称,是 Java 中被设计出来专门负责并发的工具包
普通的线程类
- Thread
- Runnable (接口),还是交给 Thread 执行
使用普通线程类来实现多线程的缺点是
- 没有返回值
- 效率比 Callable 低,而 Callable 就是 concurrent 包中的一个类
线程和进程
进程:一个运行起来的程序称之为进程,但是一个程序可能会启动多个进程。
一个进程可以包含多个线程,但至少得包含一个线程
线程:进程的实际运作单位,一个线程是进程的一条执行路径
Java 默认有两个线程:main 线程和 GC 线程。并且 Java 本身是不能够开启线程的。能够使用 Thread,Runnable,Callable 来创建线程的原因是 Java 底层调用了一个 native 方法,这种方法被称为本地方法,调用了能够操作硬件的语言的 API 来完成线程的创建 ( 因为 Java 本身是运行在虚拟机上的,不能够直接操作硬件 )
并发和并行
并发:多线程操作同一个资源,充分发挥 CPU 的性能
- CPU 单核情况下,模拟多条线程。并发能充分利用多核 CPU 下的每一个核
并行:不同的线程同时执行不同的任务,增加执行效率,但不会竞争同一资源
- CPU 多核情况下,多个线程同时执行
回顾多线程
线程的状态有 6 种
- 线程新生
- 运行
- 阻塞
- 等待,死等
- 超时等待
- 终止
wait 和 sleep 的区别
-
类不同:wait 来自 Object 类;sleep 来自 Thread 类
-
关于锁的释放:wait 会释放锁; sleep 不会释放锁
-
使用范围不同:wait 只能在同步代码块中使用;sleep 可以在任何地方使用
Lock 锁
synchronized 方式同步
先来复习下 synchronized 同步代码块
这里以卖票为例,在真实开发中,应该遵循 OOP 思想,客户端操作的应该是一个资源类,而不是一个实现 Runnable 接口或是继承 Thread 类的线程类。在客户端需要做的就是把资源对象丢给线程去执行
public class SaleTicketDemo01 {
public static void main(String[] args) {
Ticket ticket = new Ticket();
new Thread(()->{
for (int i = 0; i < 60; i++)
ticket.saleTickets();
}, "A").start();
new Thread(()->{ticket.saleTickets();}, "B").start();
new Thread(()->{ticket.saleTickets();}, "C").start();
}
}
class Ticket{
private int tickets = 50;
public void saleTickets(){
System.out.println(Thread.currentThread().getName()+"卖出第"+(tickets--)+"张票"+",剩余:"+tickets);
}
}
其实在客户端显式创建线程也不大好,更好的方法是使用线程池创建线程
多次尝试运行,可以发现结果是不安全的
因此我们使用最传统的 synchronized 来进行改进
public class SaleTicketDemo01 {
public static void main(String[] args) {
Ticket ticket = new Ticket();
new Thread(()->{
for (int i = 0; i < 50; i++)
ticket.saleTickets();
}, "A").start();
new Thread(()->{
for (int i = 0; i < 50; i++) {
ticket.saleTickets();
}}, "B").start();
new Thread(()->{
for (int i = 0; i < 50; i++) {
ticket.saleTickets();
}}, "C").start();
}
}
class Ticket{
private int tickets = 50;
public synchronized void saleTickets(){
if(tickets>0){
System.out.println(Thread.currentThread().getName()+"卖出第"+(tickets--)+"张票"+",剩余:"+tickets);
}
}
}
Lock 方式同步
Lock 是一个接口,有如下方法
有如下实现类
分别为:可重入锁,读锁,写锁
其中 ReentrantLock 可以通过制定参数来创建公平锁 / 非公平锁
公平锁:遵循队列原则,先来后到
非公平锁:不遵循队列原则,可以插队,默认为非公平锁,因为非公平锁对程序执行而言才是公平的
使用 Lock 实现
public class Demo02 {
public static void main(String[] args) {
Ticket2 ticket = new Ticket2();
new Thread(()->{ for (int i = 0; i < 50; i++) ticket.saleTickets(); }, "A").start();
new Thread(()->{ for (int i = 0; i < 50; i++) { ticket.saleTickets(); }}, "B").start();
new Thread(()->{ for (int i = 0; i < 50; i++) { ticket.saleTickets(); }}, "C").start();
}
}
class Ticket2{
private int tickets = 50;
//1.创建锁
Lock lock = new ReentrantLock();
public void saleTickets(){
//2.上锁
lock.lock();
try {
if(tickets>0){
System.out.println(Thread.currentThread().getName()+"卖出第"+(tickets--)+"张票"+",剩余:"+tickets);
}
}catch (Exception e){
e.getStackTrace();
}finally {
//3.释放锁
lock.unlock();
}
}
}
使用 Lock 进行同步时,业务代码需要写在 try catch 中,最后在 finally 中释放锁
synchronized 和 Lock 区别
- synchronized 是一个 Java 关键字;Lock 是一个 Java 接口
- synchronized 会自动获取锁和释放锁;Lock 需要手动获取和释放
- synchronized 无法判断锁的状态;Lock 可以判断锁的状态
- synchronized 在多线程情况下如果有一个线程获得了锁,另一个线程会进行等待,如果前面获得了锁的线程阻塞了,那么后面的线程就会一直等下去;Lock 则可以通过 tryLock() 方法来尝试获得锁
- synchronized 是可重入,非公平的,可中断的锁;Lock 可以是可重入锁,可以中断,可以公平也可以非公平。所以 Lock 的细粒度更高
- synchronized 适合锁少量的代码同步;Lock 锁适合大量的代码同步
AQS
在说完 Lock 锁之后,思考一个问题,竞争失败的线程是如何实现等待以及被唤醒的?
AQS 全称为 AbstractQueuedSynchronizer,它提供了一个 FIFO 队列,可以看成是一个用来实现同步锁以及其他涉及到同步功能的核心组件,常见的有:ReentrantLock、CountDownLatch 等
AQS 是一个抽象类,主要是通过继承的方式来使用,它本身没有实现任何的同步接口,仅仅是定义了同步状态的获取以及释放的方法来提供自定义的同步组件
AQS 本质上是一个同步框架,提供统一机制来对线程进行原子性的同步状态管理,阻塞、唤醒线程、以及维护被阻塞线程的队列
,所以线程等待,唤醒都是通过 AQS 来进行管理的
AQS 的两种功能
从使用层面来说,AQS 的功能分为两种:独占和共享
- 独占锁:每次只能有一个线程持有锁,比如 ReentrantLock
- 共享锁:运行多个线程同时获取锁,比如下面要介绍的 ReadWriteLock 的读锁,就是共享锁
AQS 的实现原理
AQS 的实现依赖内部的同步队列,FIFO 的双向队列,并且该队列是 CLH 锁队列的变种,同时 CLH 锁是一种常用的并发锁 ( CLH锁是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋 )
如果当前线程竞争锁失败,那么 AQS 会把当前线程以及等待状态信息构造成一个 Node 加入到同步队列中,同时再阻塞该线程。当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点 ( 线程 )
生产者消费者模型
synchronized+wait+notifyAll
我们先来使用传统的 synchronized + wait +notifyAll 的方式来进行生产者消费者模型的实现
我们假设有 A,B 两个线程,我们希望 A 线程每次将资源类的属性 num 进行+1,B线程就会对资源类的 num 属性进行-1,形成交替
public class Demo01 {
public static void main(String[] args) {
Data data = new Data();
new Thread(()->{
for (int i = 0; i < 50; i++) {
try {
data.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "A").start();
new Thread(()->{
for (int i = 0; i < 50; i++) {
try {
data.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "B").start();
new Thread(()->{
for (int i = 0; i < 50; i++) {
try {
data.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "C").start();
new Thread(()->{
for (int i = 0; i < 50; i++) {
try {
data.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "D").start();
}
}
/**
* 资源类
* 消费者生产者模型的步骤口诀:while判断等待,业务,通知
*/
class Data{
private int num = 0;
//+1
public synchronized void increment() throws InterruptedException {
while(num!=0){
//等待消费
this.wait();
}
//业务
num++;
System.out.println(Thread.currentThread().getName()+"====>"+num);
//通知其他线程,+1完毕
this.notifyAll();
}
//-1
public synchronized void decrement() throws InterruptedException {
while(num==0){
//等待生产
this.wait();
}
//业务
num--;
System.out.println(Thread.currentThread().getName()+"====>"+num);
//告诉其他线程,-1完毕
this.notifyAll();
}
}
如果使用 if 判断的话,可能会两个线程都进到了 if 中,然后 wait,消费者去消费完成后使用 notifyAll 唤醒时会同时唤醒这两个线程,直接从 wait 后开始执行,从而造成资源增多的情况不符合预期结果。这种情况又被称为虚假唤醒,通过 while 代替 if 解决
Lock+Condition
使用 Lock 来代替 synchronized,Condition 是一个接口,其中的 await 方法和 signal 方法用于代替 wait 和 notify
public class LockAndCondition {
public static void main(String[] args) {
Data2 data = new Data2();
new Thread(()->{ for (int i = 0; i < 50; i++) data.increment();},"A").start();
new Thread(()->{ for (int i = 0; i < 50; i++) data.decrement(); }, "B").start();
new Thread(()->{ for (int i = 0; i < 50; i++) data.decrement(); }, "C").start();
new Thread(()->{ for (int i = 0; i < 50; i++) data.increment(); }, "D").start();
}
}
class Data2{
private int num = 0;
private final Lock lock = new ReentrantLock();
private final Condition isEmpty = lock.newCondition();
private final Condition noEmpty = lock.newCondition();
//+1
public void increment() {
lock.lock();
try {
while(num!=0){
//等待消费,如果当前有可消费资源,将生产者加入 noEmpty 中,当资源被消耗后被唤醒
noEmpty.await();
}
//业务
num++;
System.out.println(Thread.currentThread().getName()+"====>"+num);
//通知其他线程,+1完毕,唤醒加入了 isEmpty 的所有线程,表示当前有资源可以消费
isEmpty.signalAll();
}catch (Exception e){
e.printStackTrace();
}finally {
//释放锁
lock.unlock();
}
}
//-1
public void decrement() {
lock.lock();
try{
while(num==0){
//等待生产, 加入消费者线程到 isEmpty 中,表示没有资源可以消费了
isEmpty.await();
}
//业务
num--;
System.out.println(Thread.currentThread().getName()+"====>"+num);
//告诉其他线程,-1完毕, 唤醒加入了 noEmpty 的所有线程,表示资源为空需要生产
noEmpty.signalAll();
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
}
}
condition 对象的 signalAll 方法会唤醒所有使用对应 condition 对象进行 await 的线程
锁的问题
同步方法
- 两线程执行同一对象的两个同步方法
Q:测试下面这段代码,多次输出结果一定相同吗?如果一定相同的话顺序是什么?为什么是这种顺序?
public class Test1 {
public static void main(String[] args) throws InterruptedException {
Phone phone = new Phone();
new Thread(()->{phone.sendMail();}, "A").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{phone.call();}, "B").start();
}
}
class Phone{
public synchronized void sendMail(){
System.out.println("发短信");
}
public synchronized void call(){
System.out.println("打电话");
}
}
A:通过多次执行发现一定相同,顺序都是先输出发短信再输出打电话。以该顺序执行原因是在方法上加了锁,在方法上的锁是方法的调用者,哪个方法先被调用了,哪个方法就拿到了调用者这把锁,就先执行
- 两线程执行同一对象的两个方法,一者为同步方法,一者为普通方法
public class Test1 {
public static void main(String[] args) throws InterruptedException {
Phone phone = new Phone();
new Thread(()->{
try {
phone.sendMail();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "A").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{phone.hello();}, "B").start();
}
}
class Phone{
public synchronized void sendMail() throws InterruptedException {
TimeUnit.SECONDS.sleep(4);
System.out.println("发短信");
}
public void hello(){
System.out.println("泥嚎");
}
}
这段代码会先输出泥嚎,再输出发短信。原因很简单,当进入了 sendMail 的线程 sleep 后,主线程获得执行权,虽然主线程也会因为 main 方法中的 sleep 睡 1s,但是因为子线程会睡 4s,因此主线程醒来后子线程依然在睡,主线程继续往下执行输出泥嚎
- 两线程执行两个对象的两个同步方法
public class Test1 {
public static void main(String[] args) throws InterruptedException {
Phone phone = new Phone();
Phone phone1 = new Phone();
new Thread(()->{
try {
phone.sendMail();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "A").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{phone1.call();}, "B").start();
}
}
class Phone{
public synchronized void sendMail() throws InterruptedException {
TimeUnit.SECONDS.sleep(4);
System.out.println("发短信");
}
public synchronized void call(){
System.out.println("打电话");
}
public void hello(){
System.out.println("泥嚎");
}
}
结果是先输出打电话再输出发短信。这个也很好理解,䧵同步方法的锁是其对象,所以由于对象不同,当 A 线程 sleep 时即使同步方法上的锁没有释放,主线程继续往下执行,B 线程由于和 A 线程的同步方法的锁不一样,所以依旧会执行 B 线程的同步方法
静态同步方法
- 两个线程执行两个对象 (创建自同一个类) 的不同静态同步方法
public class Test1 {
public static void main(String[] args) throws InterruptedException {
Phone phone = new Phone();
Phone phone1 = new Phone();
new Thread(()->{
try {
phone.sendMail2();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "A").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{phone1.call2();}, "B").start();
}
}
class Phone{
public static synchronized void sendMail2() throws InterruptedException {
TimeUnit.SECONDS.sleep(4);
System.out.println("静态发短信");
}
public static synchronized void call2(){
System.out.println("静态打电话");
}
}
先输出静态发短信,再输出静态打电话。静态同步方法的锁是 class 对象,class 对象在内存中是唯一的,因此只要是调用静态同步方法,无论调用者是不是同一个对象 (前提是这些对象都创建自同一个类),都是先被调用的方法先输出
- 两个线程执行一个对象的两个方法,一个为静态同步方法,一个为同步方法
public class Test1 {
public static void main(String[] args) throws InterruptedException {
Phone phone = new Phone();
new Thread(()->{
try {
phone.sendMail2();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "A").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{phone.hello2();}, "B").start();
}
}
class Phone{
public static synchronized void sendMail2() throws InterruptedException {
TimeUnit.SECONDS.sleep(4);
System.out.println("静态发短信");
}
public static synchronized void call2(){
System.out.println("静态打电话");
}
public synchronized void hello2(){
System.out.println("同步hello");
}
}
这个也很简单,因为锁不一样,所以在 A 线程休眠期间 B 线程依然有执行权并执行了 hello2 方法
并发下的集合类
List 并发修改异常
首先先看下多线程下对 List 进行 add
public class ListTest {
public static void main(String[] args) {
List<String> integers = new ArrayList<>();
for (int i = 0; i < 10; i++) {
new Thread(()->{
integers.add(UUID.randomUUID().toString().substring(0, 6));
System.out.println(integers);
}, "A").start();
}
}
}
运行报错
ConcurrentModificationException:并发修改异常
解决方案一
使用 Collections 集合工具类的方法进行转换
public class ListTest {
public static void main(String[] args) {
List<String> integers = Collections.synchronizedList(new ArrayList<>());
for (int i = 0; i < 10; i++) {
new Thread(()->{
integers.add(UUID.randomUUID().toString().substring(0, 6));
System.out.println(integers);
}, "A").start();
}
}
}
再次运行,没有报错
解决方案二
使用 Vector 代替 ArrayList,Vector 本身就是安全的。但是并不推荐这种方式,因为 Vector 在 ArrayList 之前出现,Vector 效率不是很高
CopyOnWriteArrayList
使用 CopyOnWriteArrayList
List<String> integers = new CopyOnWriteArrayList<>();
for (int i = 0; i < 10; i++) {
new Thread(()->{
integers.add(UUID.randomUUID().toString().substring(0, 6));
System.out.println(integers);
}, "A").start();
}
CopyOnWriteArrayList 就是 JUC 下的类
那为什么 CopyOnWriteArrayList 能够解决并发问题呢?我们进入源码康康
进入 CopyOnWriteArrayList 的 add 方法
可以看到通过 getArray() 方法可以获得一个数组
进入这个方法
查看 array 到底是个什么妖魔鬼怪
transient 关键字标注该属性不会被序列化
volatile 变量可以被看作是一种轻量级的 synchronized
锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility)
- 互斥即一次只允许一个线程持有某个特定的锁,因此可使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据。
- 可见性要更加复杂一些,它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的 —— 如果没有同步机制提供的这种可见性保证,线程看到的共享变量可能是修改前的值或不一致的值,这将引发许多严重问题
volatile 变量具有 synchronized 的可见性特性,但是不具备原子特性
同时,**volatile 具有禁止指令重排**的功能
指令重排
推荐博文:https://www.cnblogs.com/tuhooo/p/7921651.html
指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序
指令重排的目的是为了在不改变程序执行结果的前提下,优化程序的运行效率。需要注意的是,这里所说的不改变执行结果,指的是不改变单线程下的程序执行结果
然而,指令重排是一把双刃剑,虽然优化了程序的执行效率,但是在某些情况下,会影响到多线程的执行结果
由于 volatile 禁止对象创建时指令之间重排序,所以其他线程不会访问到一个未初始化的对象,从而保证安全性
接下来分析一下如果不禁止指令重排优化的后果
从字节码看一个对象的创建,分为一几步
- 分配对象内存
- 调用构造器方法,执行初始化
- 将对象引用赋值给变量
JVM 实际运行时,2,3 可能发生重排序,但 1 不会
如果不禁止指令重排优化,线程 1 获取到锁进入创建对象实例,这个时候发生了指令重排序 ( 先赋值引用,而不是先初始化 )。当线程 1 赋值引用后,线程 2 刚好进入,由于此时对象已经不为 Null,所以线程 2 可以自由访问该对象。但是由于该对象还未初始化,所以线程 2 访问时将会发生异常
所以在使用双重检查时使用 volatile 主要是用到了其以下两个功能
- 保证可见性。使用 volatile 修饰的变量,将会保证对所有线程的可见性
- 禁止指令重排优化
CopyOnWrite:写入时复制,在往集合中添加数据的时候,先拷贝存储了的数据,然后添加数据到拷贝好的数组中,然后用现在的数组去替换被操作数组
Set 并发修改异常
解决方案一
使用 Collections.synchronizedSet 方法进行转换
CopyOnWriteArraySet
同样,CopyOnWriteSet 是用来解决 set 不安全的,和 List 的解决方案高度相似
Map 并发修改异常
关于 HashMap,有以下几个注意点
- loadFactor:加载因子
- initialCapacity:初始容量
对于使用 new HashMap<>() 创建对象时,默认的 loadFactor 大小为 0.75,;默认的 initialCpacity 为 16,并且在源码中为位运算的形式 (1<<4)
关于 HashMap,也是一个面试高频,将会另出笔记进行记录
解决方案一
和 List,Set 一样,使用 Collections.synchronizedMap 进行转换,创建安全的 Map
解决方法二
使用 HashTable。但是由于 HashTable 的锁是锁的方法,在线程竞争激烈的情况下 HashTable 效率十分低下。因为当一个线程访问 HashTable 的同步方法,其它线程也访问 HashTable 的同步方法时,会进入阻塞或轮询的状态!
ConcurrentHashMap
ConcurrentHashMap 是 JUC 下的安全的 Map 类。继承了 AbstractMap,实现了 ConcurrentMap 接口
public class MapTest {
public static void main(String[] args) {
Map<String, String> map = new ConcurrentHashMap<>();
for (int i = 0; i < 30; i++) {
new Thread(()->{
map.put(Thread.currentThread().getName(),
UUID.randomUUID().toString().substring(0,5));
System.out.println(map);
}, i+"").start();
}
}
}
JDK 1.7
ConcurrentHashMap 采用锁分段技术 ( Segement ),假设容器中有多把锁,每一把锁用于锁容器中的一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在竞争
其中 ConcunrrentHashMap 的锁分段 Segement 继承于 ReentrantLock。不会像 HashTable 一样使用 synchronized 进行低效的同步。理论上一个 ConcurrentHashMap 支持 Segement 数组数量的并发数目。一个线程占用一个 Segement 时不会影响其他的 Segement
JDK 8
JDK1.7 中的 ConcurrentHashMap 解决了并发问题,并且支持 N 个 Segement 的并发数目,但是依然存在一个问题
查询遍历链表的效率太低
因此 JDK8 去掉了锁分段,而是采用了 CAS ( 在后面会介绍 ) + synchronized 来保证并发安全性。底层数据结构是 数组+链表+红黑树 ( JDK8 之前的 HashMap 底层是数组+链表 ),并且为了做到并发增加了许多的辅助类
PS:关于 ConcurrentHashMap 这里主要介绍了他们实现方式的不同,更详细的解读欢迎自己查看源码或百度找文章
Callable
Callable 类似于 Runnable 接口。都是为执行线程而设计的。但是 Runnable 不返回结果,也不能抛出异常,但是 Callable 都可以做到这些,并且 Callable 中线程方法名叫 call() 而不叫 run()
Callable 源码
但是使用 Callable 带来了另一个问题:如何启动?
在学习多线程时,我们的启动方式无外乎就是使用 Thread 实例通过传入 Thread 的子类或者 Runnable 的子类来进行启动,从而运行 run 方法
可是我们可以看见,在 Thread 所有的构造器重载中,并没有可以接收 Callable 对象的构造器
因此我们应该找 Runnable 的一个子类 ( 因为 Thread 只能接收 Runnable 接口及其子类 ),这个子类应该是一个包装,适配的作用,能够将 Callable 对象包装起来,然后传入 Thread 构造器中得以执行,这个子类就是 FutureTask
public class CallableTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
FutureTask futureTask = new FutureTask<>(new Ticket());
Thread thread = new Thread(futureTask, "A");
thread.start();
//获取 call 返回值
String o = (String) futureTask.get();
System.out.println(o);
}
}
class Ticket implements Callable<String>{
@Override
public String call() throws Exception {
System.out.println(12);
return "String";
}
}
FutureTask 的 get 方法可能会产生阻塞,因为需要等待结果返回,如果 call 方法中有极其耗时的操作,那么 get 方法处就会发生阻塞
Q:如果有多个线程去执行 futureTask 对象,那么会打印多少次 12?
A:只会打印一次 12,因为同一个 futureTask 对象 (即同一个 Callable 子类对象的执行结果会被缓存起来,再次执行相同的任务时结果直接拿来用,提高执行效率)
FutureTask
JDK1.7
JDK8之前的 FutureTask 实现基于 AbstractQueuedSynchronizer ( AQS ) 抽象类实现。JUC 中很多的可阻塞类都是基于 AQS 实现的。AQS 本质上是一个同步框架,提供统一机制来原子性的管理同步状态,阻塞、唤醒线程、以及维护被阻塞线程的队列
。基于 AQS 实现的同步类包括:ReentrantLock,Semaphore,ReentrantReadWriteLock,CountDownLatch 和 FutureTask
每一个基于 AQS 实现的同步器包含以下两种类型的操作
- acquire:该操作用于阻塞调用线程,直到 AQS 的状态运行这个线程继续执行。FutureTask 的acquire 操作为 get() 或超时等待 get
- release:该操作用于改变 AQS 状态,改变后的状态可允许一个或多个阻塞线程被解除阻塞。FutureTask 的 realese 操作包括 run() 和 cancel() 方法
FutureTask 声明了一个内部私有的,并且是 AQS 子类的 Sync,对 FutureTask 所有公有方法的调用都会委托给这个内部类
可以发现所有的方法其实本质是使用内部类 sycn 的方法
JDK8
在进入 JDK8 的FutrueTask ,看到的注释如下
从这段注释中可以看到
-
JDK8 版本的 FutureTask 取消了 AQS 实现同步的方式
-
JDK8 版本的 FutureTask 采用 CAS + volatile 类型的状态值来实现同步
关于该状态值的说明如下
可能状态变化如下:
NEW -> COMPLETING -> NORMAL:没有发生异常,也没有被 canceled
NEW -> COMPLETING -> EXCEPTIONAL:执行过程中发生异常
NEW -> CANCELLED:执行后取消
NEW -> INTERRUPTING -> INTERRUPTED:被中断
再来看看其他几个关键的变量
关于 CAS ( Compare And Swap ) 会在下面介绍,这里只需简单了解 CAS 保证了并发修改的原子性即可。我们来看下 FutureTask 在哪些地方使用了 CAS
可以发现这里使用了 CAS,并且可以通过判断方法中的变量命名简单的推测一些东西
-
cancel 方法传入了参数 mayInterruptIfRunning,UNSAFE 的 CAS 通过比较当前 FutureTask 对象通过 this + stateOffset 获得的值是否和 NEW 相同,来决定是否修改 NEW 这个值是否修改为
mayInterruptIfRunning ? INTERRUPTING : CANCELLED
的返回值。如果当前 TaskFture 执行的线程的状态不是 NEW 并且内存中的 TaskFuture 对象的对应位置上的状态的属性值与 NEW 相同的话,将根据三目运算符的结果置为对应的状态,并且 CAS 返回 true,否则 CAS 返回 false。最后根据两个判断条件来选择返回什么
set 方法也使用了 CAS
run方法也使用了 CAS
FutureTask 中其他的很多方法也都使用了 CAS
常用辅助类
CountDownLatch
CountDownLatch 是一个辅助类,是一个 “减法计数器”
构造器需要传入一个值来规定为初始值
public class CountDownLatchTest {
public static void main(String[] args) throws InterruptedException {
//倒计时
CountDownLatch countDownLatch = new CountDownLatch(6);
//假设一辆车上有很多乘客,某一站有六个乘客要下车,司机必须等待这六个乘客下车后才关门
for (int i = 0; i < 6; i++) {
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"乘客下车了");
//计数器 -1
countDownLatch.countDown();
},String.valueOf(i+1)).start();
}
//阻塞等待计数器归0,然后再向下执行
countDownLatch.await();
System.out.println("司机关门");
}
}
主要的两个方法就是
- countDownLatch.countDown():计数器-1
- countDownLatch.await():等待计数器归0
每次有线程调用 coutDown(),计数器就 -1,当计数器变为 0,停止在 await 处的线程就会被唤醒,继续执行
CyclicBarrier
也是一个辅助类,是和 CountDownLatch 相反的是,它是一个加法计数器 ( 内存屏障 )
其中有两个构造器
- parties:上限值
- barrierAction:到达上限值后执行的线程
public class CyclicBarrierTest {
public static void main(String[] args) {
//集齐十二星座召唤雅典娜
CyclicBarrier cyclicBarrier = new CyclicBarrier(12, ()->{
System.out.println("召唤雅典娜成功");
});
for (int i = 0; i < 12; i++) {
//内部类使用变量的话只能使用final变量
final int j = i;
new Thread(()->{
System.out.println("获得第"+j+"个星座");
try {
cyclicBarrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
}, ""+j).start();
}
}
}
Semaphore
Semaphore:信号量,信号量通常用于限制线程数,而不是访问某些具体的资源
Semaphore 的构造器
- permits:限制参与的线程数的最大值
- fair:是否公平锁
其中有两个常用的方法
- acquire:得到
- release:释放
{
public static void main(String[] args) {
//限制线程数:假设12圣斗士,每4个一组抢一个女人。这里的4就是限制每次参与的线程数
Semaphore semaphore = new Semaphore(4);
for (int i = 0; i < 12; i++) {
final int j = i;
new Thread(()->{
//得到
try {
semaphore.acquire();
System.out.println("圣斗士"+Thread.currentThread().getName()+"抢到了辣个女人");
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
//释放
System.out.println("圣斗士挂了");
semaphore.release();
}
}, ""+i).start();
}
}
}
ReadWriteLock
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-auk7TSog-1600667106598)(…/mdPicture/image-20200921110316488.png)]
写锁是独占锁:该锁一次只能被一个线程所持有。对于 ReentrantLock 和 Synchroized 而言,两者都是独占锁
读锁是共享锁:该锁可以被多个线程所持有
ReadWriteLock 限制读写,读取时可以有多个线程同时进行读操作,写的时候只能有一个线程进行写操作。
ReentrantReadWriteLock 的读锁是共享锁,写锁是独占锁。读锁可以保证并发读,对读操作而言是很高效的一种锁
public class ReadWriteLockTest {
public static void main(String[] args) {
MyCache myCache = new MyCache();
//只做写操作
for (int i = 0; i < 5; i++) {
final int j = i;
new Thread(()->{
myCache.put(""+j, j);
}, ""+j).start();
}
//只做读操作
for (int i = 0; i < 5; i++) {
final int j = i;
new Thread(()->{
myCache.get(""+j);
}, ""+j).start();
}
}
}
/**
* 模拟键值对缓存
* 在这种没有锁的情况下,执行顺序并不是连续的,可能线程1刚输出写入,还没有put进map中,线程2就又抢到了执行权
*/
class MyCache{
private volatile Map<String, Object> map = new HashMap<>();
/**
* 存,写操作
* @param key
* @param value
*/
public void put(String key, Object value){
System.out.println(Thread.currentThread().getName()+"写入");
map.put(key, value);
System.out.println(Thread.currentThread().getName()+"写入完成");
}
/**
* 取,读操作
* @param key
*/
public void get(String key){
System.out.println(Thread.currentThread().getName()+"读取");
map.get(key);
System.out.println(Thread.currentThread().getName()+"读取完毕");
}
}
/**
* 模拟键值对缓存
* 使用读写锁,对写操作加上写锁;对读操作加上读锁
*/
class MyCacheLock{
private volatile Map<String, Object> map = new HashMap<>();
//读写锁,
private ReadWriteLock lock = new ReentrantReadWriteLock();
/**
* 存,写操作的时候只希望只有一个线程写
* @param key
* @param value
*/
public void put(String key, Object value){
//加锁,写锁
lock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName()+"写入");
map.put(key, value);
System.out.println(Thread.currentThread().getName()+"写入完成");
}catch (Exception e){
e.printStackTrace();
}finally {
lock.writeLock().unlock();
}
}
/**
* 取,读操作的时候所有线程都可以来读
* @param key
*/
public void get(String key){
lock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName()+"读取");
map.get(key);
System.out.println(Thread.currentThread().getName()+"读取完毕");
}catch (Exception e){
e.printStackTrace();
}finally {
lock.readLock().unlock();
}
}
}
使用了读写锁的结果
ReadWriteLock 读写共存问题
- 读-读:可以共存,并发读
- 读-写:不可以共存
- 写-写:不能共存,写入时只能存在一个写线程
StampedLock
ReadWriteLock 可以解决多线程同时读,但只能有一个线程写的问题
读写不能共存就意味着:如果有线程正在读,写线程就需要等待读线程释放锁后才能获得写锁,即读的过程不允许写,这是一种悲观锁
因此 JDK 1.8 引进了新的读写锁:StampedLock
StampedLock 和 ReadWriteLock相比,改进之处在于:读的过程也允许写线程获取写锁后写入。但是这样可能会造成读的数据不一致,所以需要额外的代码判断读的过程是否有写入
因此 StampedLock 的读锁是乐观锁,它乐观的估计读的过程中大概率不会有写入。而读写互斥则是悲观锁
乐观锁只需要在小概率发生的写入发生后导致读取的数据不一致时能检测出来,然后再读一遍即可,并发效率更高
public class StampedLockTest {
public static void main(String[] args) {
long l1 = System.currentTimeMillis();
MyCache myCache = new MyCache();
for (int i = 0; i < 500000; i++) {
final int j = i;
new Thread(()->{
//System.out.println("写"+Thread.currentThread().getName());
myCache.add(j+"", j+"");}
, ""+j).start();
}
for (int i = 0; i < 500000; i++) {
final int j = i;
new Thread(()->{
//System.out.println("读:" + Thread.currentThread().getName());
myCache.read(""+j);
}, ""+j).start();
}
long l2 = System.currentTimeMillis();
System.out.println(l2-l1);
}
}
class MyCache{
private final StampedLock stampedLock = new StampedLock();
private volatile Map<String, String> map = new HashMap<>();
/**
* 写操作
*/
public void add(String key, String value){
//获取悲观写锁
long stamp = stampedLock.writeLock();
try {
map.put(key, value);
}catch (Exception e){
e.printStackTrace();
}finally {
stampedLock.unlock(stamp);
}
//System.out.println("写:"+System.currentTimeMillis());
}
/**
* 读操作
*/
public void read(String key){
//获取一个乐观读锁的版本号
long readLockVer = stampedLock.tryOptimisticRead();
//检查乐观读锁后是否有写锁被获得,校验版本号
//如果其他线程已经获得了悲观写锁,就退化为悲观读锁
if(!stampedLock.validate(readLockVer)){
//获取一个悲观读锁
readLockVer = stampedLock.readLock();
try{
//尝试重新进行操作的业务代码
map.get(key);
}catch (Exception e){
e.printStackTrace();
}finally {
//释放悲观读锁
stampedLock.unlock(readLockVer);
}
}
//正常业务代码
map.get(key);
//System.out.println("读:"+System.currentTimeMillis());
}
}
和 ReadWriteLock 相比,写锁的加锁方式是完全的一样的。不同的是读锁,对于读锁,我们遵循以下几个步骤
- 首先通过 tryOptimisticRead 获取版本号
- 通过 validate 校验版本号,判断是否有写锁被获得。如果有,返回 false,这时我们应该让乐观读锁退化为悲观读锁 (本质上退化为了 ReadWriteLock ),并尝试重新读;如果没有,返回 true,继续执行下面的操作
尝试将 ReadWriteLock 和 StampedLock 进行性能上的对比,让他们的读写都分别使用 50w 个线程去操作
可以发现性能上有较大的提升
StampedLock 将读锁分成了悲观读和乐观读,能进一步提高并发效率。但是这也使得代码更加复杂,同时 StampedLock 是不可重入锁,不能在一个线程中反复获取同一个锁
阻塞队列
队列是一种 FIFO 的数据结构,当存在以下可能的阻塞情况时称为阻塞队列
- 入队时阻塞:当队列满了时,无法添加入队,阻塞并等待当队列不满时,又开始入队
- 出队时阻塞:当队列为空时,需要等待队列种入队元素,阻塞并等待当队列不为空时,又开始出队
试图从空的队列中获取元素的线程将会被阻塞,直到其他线程往空的队列插入新的元素
试图向已满的队列中添加新元素的线程将会被阻塞,直到其他线程从队列中移除一个或多个元素或者完全清空,使队列变得空闲起来并后续新增
Java 中的阻塞队列接口 BlockingQueue 和 List,Set 是平级的,都是 Collection 的子接口
BlockingQueue 的常用实现类
- ArrayBlockingQueue :一个数组结构组成的有界阻塞队列
- LinkedBlockingQueue :一个链表结构组成的无界阻塞队列
- DelayQueue:一个使用优先级队列实现的无界阻塞队列
- SynchronousQueue:一个不存储元素的阻塞队列
- LinkedTransferQueue:一个链表结构组成的无界阻塞队列
- LinkedBlockingDeque:一个链表结构组成的双向阻塞队列
- PriorityBlockingQueue:根据比较器进行排序的优先队列
Q:什么时候应该使用阻塞队列?
A:多线程间的数据共享,阻塞队列能够高效的实现生产者消费者模型。比如在队列中中没有资源时,消费者就 block。我们并不需要什么时候应该去阻塞线程,什么时候应该去唤醒线程,而是由阻塞队列完全给我们包办了
BlockingQueue 的四组 API
下面我们通过使用 ArrayBlockingQueue 对这四组 API 进行测试
ArrayBlockingQueue 的构造器如下
int 类型参数为队列容量,boolean 类型参数为是否公平锁,Collection 参数则是通过现有的 Collection 集合类来创建 ArrayBlockingQueue
抛出异常
public class BlockingQueueTest {
public static void main(String[] args) {
new BlockingQueueTest().test1();
}
public void test1(){
ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
System.out.println(queue.add("a"));
System.out.println(queue.add("b"));
System.out.println(queue.add("c"));
//System.out.println(queue.add("a")); // IllegalStateException:Queue full
System.out.println(queue.element());
System.out.println(queue.remove());
System.out.println(queue.remove());
System.out.println(queue.remove());
//System.out.println(queue.element()); //为空后查询队首元素报错 NoSuchElementException
System.out.println(queue.remove()); //队列为空后删除,报错 NoSuchElementException
}
}
当规定队列容量为3,如果队列满了继续添加元素的的话报错
当队列为空时,获得队列首元素或者出队元素报错
有返回值并且不抛异常
public class BlockingQueueTest {
public static void main(String[] args) {
new BlockingQueueTest().test2();
}
public void test2(){
ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
System.out.println(queue.offer("a"));
System.out.println(queue.offer("b"));
System.out.println(queue.offer("c"));
System.out.println(queue.offer("d")); //队列满后再添加没有报错,而是返回了false
System.out.println(queue.poll());
System.out.println(queue.poll());
System.out.println(queue.poll());
//队列为空后获得队首元素和出队时返回 null,没有报错
System.out.println(queue.poll());
System.out.println(queue.peek());
}
}
阻塞等待
public class BlockingQueueTest {
public static void main(String[] args) throws InterruptedException {
new BlockingQueueTest().test3();
}
/**
* 阻塞等待
*/
public void test3() throws InterruptedException {
ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
queue.put("a");
queue.put("b");
queue.put("c");
//queue.put("d"); //队列已满,阻塞等待
System.out.println(queue.take());
System.out.println(queue.take());
System.out.println(queue.take());
System.out.println(queue.take()); //队列为空,阻塞等待
}
}
超时等待
超时等待使用的是 offer 方法和 poll 方法的重载
public class BlockingQueueTest {
public static void main(String[] args) throws InterruptedException {
new BlockingQueueTest().test4();
}
/**
* 超时等待
*/
public void test4() throws InterruptedException {
ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
queue.offer("a");
queue.offer("b");
queue.offer("c");
queue.offer("d", 5, TimeUnit.SECONDS); //等待5s,TimeUnit.SECONDS 规定的是时间单位。等待 5s 后如果依然不能入队,就继续向下执行
System.out.println("入队等待结束");
System.out.println(queue.poll());
System.out.println(queue.poll());
System.out.println(queue.poll());
System.out.println(queue.poll(5, TimeUnit.SECONDS));
System.out.println("出队等待结束");
}
}
可以看见并没有被阻塞住
阻塞队列实现生产者消费者模型
public class Test {
public static void main(String[] args) {
Resource resource = new Resource();
for (int i = 0; i < 4; i++) {
final int j = i;
new Thread(()->{
try {
resource.add();
System.out.println("添加第"+j+"号资源");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, ""+j).start();
}
for (int i = 0; i < 4; i++) {
final int j = i;
new Thread(()->{
try {
resource.sub();
System.out.println("消费第"+j+"号资源");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, ""+j).start();
}
}
}
class Resource{
private BlockingQueue<String> queue = new ArrayBlockingQueue<>(10, true);
public void add() throws InterruptedException {
String i = UUID.randomUUID().toString().substring(0, 5);
queue.put(i);
}
public void sub() throws InterruptedException {
queue.take();
}
}

可以发现,依然出现了线程不安全的现象。通常意义上的线程安全是指:能保证这个对象单独的操作是线程安全的,但是对于一些特定顺序的连续调用不能保证线程安全。这个时候就需要一些额外的同步的手段来保证调用的正确性
由于我们的打印向队列中添加 (删除) 元素和打印并不是一个原子操作,所以我们只是因为没有处理好输出语句和 put,take 的关系导致了结果的错误,但阻塞队列实现生产者消费者模型本身是线程安全的
同步队列
同步队列不存储元素,进去了一个元素后必须等待取出这个元素才能再进一个元素
public class Test {
public static void main(String[] args) {
BlockingQueue<String> queue = new SynchronousQueue<>();
new Thread(()->{
try {
queue.put("233");
System.out.println(Thread.currentThread().getName()+"存入");
queue.put("244");
System.out.println(Thread.currentThread().getName()+"存入");
queue.put("255");
System.out.println(Thread.currentThread().getName()+"存入");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "A").start();
new Thread(()->{
try {
System.out.println(Thread.currentThread().getName()+"取出"+queue.take());
System.out.println(Thread.currentThread().getName()+"取出"+queue.take());
System.out.println(Thread.currentThread().getName()+"取出"+queue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "B").start();
}
}

这里造成看起来不安全的原因和上面的生产者消费者模型一样,put 操作和输出语句并不是一个原子操作
Java 线程池
池化技术:本质是为了减少资源对象的创建次数,提高程序的性能,将资源对象缓存到池中。池化技术又称为对象池模式,本意是期望一次性初始化所有的对象,减少对象在初始化上的性能开销,减少系统在关键代码处的瓶颈
池化技术缓存的资源对象应该具有以下特点
- 对象创建时间长
- 对象创建需要大量资源
- 对象创建后可被重复使用
一个资源池应该具备以下功能
- 租用资源对象
- 归还资源对象
- 清除过期资源对象
池的好处
- 降低资源消耗:通过重复利用已经创建的资源对象降低创建和销毁造成的消耗
- 提高速度:当需要使用时,可以不需要等待资源对象的创建就立即使用
- 方便管理:提高资源对象的可管理性,避免重型对象的重复创建
线程池可以让线程复用,控制最大线程并发数并且管理线程
Executor
Java 中的线程即是工作单元也是执行机制,从 JDK1.5 开始,Java 将工作单元和执行机制拆分开来。工作单元包括了 Runnable 和 Callable,而执行机制则是 Executor
两级调度模型
在底层
的 HpSpot VM 的线程模型中,Java 线程被一对一映射为本地操作系统线程,Java 线程 (用户线程) 启动时会创建一个本地操作系统线程 (内核线程)。当该 Java 线程终止时,这个操作系统线程也会被回收。操作系统负责调度所有的 Java 线程并分配给可用的 CPU
在上层
,Java 多线程程序通常会把应用分解为若干个任务,然后使用用户级的调度器 ( Executor 框架 ) 将这些任务映射为固定数量的线程
不难看出, Executor 控制上层应用的调度和映射;而下层的调度和映射通过操作系统内核控制
结构与成员
Executor 主要由以下三大部分组成
- 任务:包括被执行任务,并且需要实现的接口:Runnable 或 Callable
- 任务的执行:包括了任务执行机制的核心接口 Executor,以及继承自 Executor 的 ExecutorService 接口。Executor 有两个关键类实现了 ExecutorService:ThreadPoolExecutor,ScheduledThreadPoolExecutor
- 任务的计算结果:包括接口 Future 和实现 Future 接口的 FutureTask 类
三大方法
Java 中的线程池是基于 Executor 框架实现的,该接口将任务的提交与执行分离了
newFixedThreadPool(N)
创建一个有 N 个线程的固定大小线程池,最多可以有 N 个线程执行任务,对于长期任务而言性能较好
public class Test {
public static void main(String[] args) {
//模拟最多只有5个线程去处理需要10个线程的任务
new Test().test(Executors.newFixedThreadPool(5));
public void test(ExecutorService executorService){
try{
//模拟只有5个线程的线程池去处理需要有10个线程的任务
for (int i = 0; i < 10; i++) {
final int j = i;
//使用线程池来创建并执行线程
executorService.execute(()->{
System.out.println(Thread.currentThread().getName()+"办理业务");
});
}
}catch (Exception e){
e.printStackTrace();
}finally {
//关闭线程池,如果不关闭线程池,任务已经执行完成了,但是线程却依然存活着等待任务,这时就会阻塞住
executorService.shutdown();
}
}
}
newSingleThreadExecutor
创建一个只有 1 个线程的线程池
public class Test {
public static void main(String[] args) {
new Test().test(Executors.newSingleThreadExecutor());
}
public void test(ExecutorService executorService){
try{
//模拟只有5个线程的线程池去处理需要有10个线程的任务
for (int i = 0; i < 10; i++) {
final int j = i;
//使用线程池来创建并执行线程
executorService.execute(()->{
System.out.println(Thread.currentThread().getName()+"办理业务");
});
}
}catch (Exception e){
e.printStackTrace();
}finally {
//关闭线程池,如果不关闭线程池,任务已经执行完成了,但是线程却依然存活着等待任务,这时就会阻塞住
executorService.shutdown();
}
}
}
newCachedThreadPool
线程池会根据需要创建新的线程,但预先构建的线程在可用时依然会重用这些线程。适合执行短期的异步任务
public class Test {
public static void main(String[] args) {
new Test().test(Executors.newCachedThreadPool());
}
public void test(ExecutorService executorService){
try{
//模拟只有5个线程的线程池去处理需要有10个线程的任务
for (int i = 0; i < 10; i++) {
final int j = i;
//使用线程池来创建并执行线程
executorService.execute(()->{
System.out.println(Thread.currentThread().getName()+"办理业务");
});
}
}catch (Exception e){
e.printStackTrace();
}finally {
//关闭线程池,如果不关闭线程池,任务已经执行完成了,但是线程却依然存活着等待任务,这时就会阻塞住
executorService.shutdown();
}
}
}
可以看见最多出现了10个线程去执行
ThreadExecutorPool 七参数
点进 newSingleThreadExecutor 中可以发现
new 了一个 ThreadPoolExecutor 对象
再进入另外两个方法查看
发现都是 new 了 ThreadPoolExecutor 对象然后返回,只是参数不同
ThreadPoolExecutor 的构造器如下,可以发现,这个构造器需要七个参数
-
corePoolSize:核心线程数大小。在创建了线程池后,线程中没有任何线程,等到有任务到来时才创建
线程去执行任务。默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建
一个线程去执行任务,当线程池中的线程数目达到 corePoolSize 后,就会把到达的任务放到缓存队列当
中
-
maximumPoolSize:线程数最大数目。表明线程中最多能够创建的线程数量,此值必须大于等于1
-
keepAliveTime:存活时间,超时后无人调用就会被释放
-
unit:超时单位
-
workQueue:阻塞队列
-
threadFactory:线程工厂,创建线程的,一般不动
-
handler:队列已满后仍有任务进来的拒绝策略
知道有哪些参数后,我们对上面三大方法所创建的 ThreadPoolExecutor 对象所传入的参数进行解读
newSingleThreadExecutor
- 核心线程数:1
- 最大线程数:1
所以线程池中只有 1 个线程
- 线程存活时间:0L,单位为毫秒
- 阻塞队列为 LinkedBlockingQueue,每个队列元素为一个 Runnable 线程
newFixedThreadPool
- 核心线程数:传入参数
- 最大线程数:传入参数
所以线程池中最多有 n 个线程
- 线程存活时间:0L,单位为毫秒
- 阻塞队列为 LinkedBlockingQueue,每个队列元素为一个 Runnable 线程
当线程池中的线程数大于 corePoolSize 时,keepAliveTime 为多余的空闲线程等待新任务的最长时间,超过这个时间后多余的空闲线程将会被终止,0L 则代表这多余的空闲线程会被立刻终止
newCachedThreadPool
- 核心线程数:0
- 最大线程数:int 类型最大值
所以线程池中最多有 2147483647 个线程
- 线程存活时间:0L,单位为毫秒
- 阻塞队列为 LinkedBlockingQueue,每个队列元素为一个 Runnable 线程
注意
阿里巴巴开发手册强制不允许使用 Executors 的三大方法去创建线程池,而是使用 ThreadPoolExecutor 去手动创建,避免资源耗尽的风险
参数理解
以银行办理业务为例
假设,银行有 5 个窗口,1 个等候区
其中窗口的 1,2 号窗口永远是开着的,当窗口满了的时候,顾客到等候区进行等候。当等候区也满了,银行紧急开启 3,4,5 号窗口,减少等候区人数。可是没一会儿等候区人又满了,银行窗口也开完了。这时银行就停止办理业务了
在这个例子中
- 1,2 号窗口对应的就是核心线程
- 等候区大小就是阻塞队列 workQueue 的容量
- 3,4,5 号窗口对应的是当阻塞队列满了后开启的线程数目。这里的数目加上核心线程数就应该是 maximumPoolSize 参数大小
- 停止办理业务即 handler 拒绝策略参数
如果窗口 1h 后依然没有人办理业务,那么这个窗口就会关闭,这个 1h 就是 keepAliveTime
ScheduledThreadPoolExecutor
ScheduledThreadPoolExecutor 继承自 ThreadExecutorPool。主要用来在指定的延迟之后运行任务,或者定期执行任务
ScheduledThreadPoolExecutor 使用 DelayedWorkQueue 作为工作队列,DelayedWorkQueue 是 ScheduledThreadPoolExecutor 的静态内部类,是一个基于堆的延迟队列
该队列中存储了 ScheduledFutureTask 类 (延时线程池的私有内部类),并记录其索引到堆数组中,但如果 ScheduledFutureTask 是一个周期性的任务,那么索引将无效,ScheduledFutureTask 的搜索时间回退至线性搜索
ScheduledFutureTask 继承了 FutureTask 并且实现了 RunnableScheduledFuture 接口,这个接口只有一个判断是否为周期性的方法。ScheduledFutureTask 复写了 FutureTask 的 run 方法,对于周期性的任务,线程将会进行重置和重新排队等待
四种拒绝策略
接下来我们使用 ThreadPoolExecutor 来创建自定义线程池
这时我们需要关注一下最后一个参数 handler,点进 handler 的类型,可以发现 RejectedExecutionHandler 是一个接口
这个接口有四个实现类,对应的就是四种拒绝策略。并且通过源码可以发现,四大拒绝策略是 ThreadPoolExecutor 的静态内部类
三大方法使用的是默认拒绝策略,默认拒绝策略为 AbortPolicy
队列已满,线程全部被占用后仍有任务进入的处理策略
- AbortPolicy:丢弃任务,并抛出 RejectedExecutionException 异常
- DiscardOldestPolicy:丢弃队列最前面的任务,然后尝试重新执行任务,不断重复
- DiscardPolicy:丢弃任务,但是不抛出异常
- CallerRunsPolicy:由调用线程处理该任务
自定义线程池
接下来我们使用自定义线程池来模拟银行业务处理
我们假设有 2 个窗口常开 (核心线程数目),3 个窗口备用,所以最多有 5 个窗口 (总线程数目)
等候区有 3 个座位 (阻塞队列容量)
public class Test {
public static void main(String[] args) {
//使用 ThreadPoolExecutor
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
2,
5,
3,
TimeUnit.SECONDS,
new LinkedBlockingDeque<>(3),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
new Test().test(threadPoolExecutor);
}
public void test(ExecutorService executorService){
try{
//模拟只有5个线程的线程池去处理需要有10个线程的任务
for (int i = 0; i < 100; i++) {
final int j = i;
//使用线程池来创建并执行线程
executorService.execute(()->{
System.out.println(Thread.currentThread().getName()+"办理业务");
});
}
}catch (Exception e){
e.printStackTrace();
}finally {
//关闭线程池,如果不关闭线程池,任务已经执行完成了,但是线程却依然存活着等待任务,这时就会阻塞住
executorService.shutdown();
}
}
}
当我们规定有 5 个客户办理业务时,只有两个线程在处理,因为这时阻塞队列没有溢出 (2个核心线程处理,3个任务阻塞队列刚好装下)
当有 6 个客户办理业务时,可以发现多了 3 号窗口出来处理,因为这时阻塞队列已经满了,只有再开一个线程来继续处理满出来的那一个,所以 6 个任务最大会触发 3 个线程执行
同理,7 个任务最大触发 4 个线程同时执行;8 个任务最大触发 5 个线程同时执行
公式为:最大并发线程数 = 任务数 - 阻塞队列容量;同理,最大承受任务数 = maximumPoolSize + 阻塞队列容量。当然,超过最大任务数执行也可能不会出错 (因为可能有人已经办理完业务走了,阻塞队列又有位置了),但是这是不安全的
比如当我们的任务数到达 100 后,阻塞队列容量 + 最大线程数依然等于 8 ,然后执行
在使用拒绝策略为 AbortPolicy 的情况下就会抛出异常,并丢弃任务
在使用拒绝策略为 CallerRunsPolicy 时,被拒绝的任务会交给调用者执行
在使用拒绝策略 DiscardPolicy 时,会把任务丢弃,不会抛出异常
在使用拒绝策略 DiscardOldestPolicy 时,丢弃队列最前面的任务,然后尝试重新执行任务,不断重复,也不会抛出异常
池的最大线程数的取值策略
- CPU 密集型:几核 CPU 就定义几个线程,可以保证 CPU 效率最高
//获取 CPU 核数的方法
Runtime.getRuntime().availableProcessors();
- IO 密集型:假设有 15 个大型任务,并且这些任务的 IO 操作很多,因此我们至少需要 15 个线程去操作这些大型任务。因此 IO 密集型需要我们去判断程序中十分耗费 IO 的线程数目,线程池中线程数只要大于这个数即可 (一般设置为大型任务的 2 倍)
四大函数式接口
有且只有一个抽象方法,并且有 @FunctionalInterface 注解的接口被称为函数式接口。函数式接口是为了实现 lambda 而定义的。比如 Runnable 接口
再比如,我们的 forEach
default void forEach(Consumer<? super T> action) {
Objects.requireNonNull(action);
for (T t : this) {
action.accept(t);
}
}
其所需要的参数也是一个消费者类型的函数式接口
四大函数式接口

函数型接口和断定型接口
函数型接口 Fuction
这个接口的泛型需要传入两个类型,前者会作为 apply 方法的参数类型,后者会作为 apply 方法的返回值类型
public class Test {
public static void main(String[] args) {
Function<String, Integer> function = new Function<String, Integer>() {
@Override
public Integer apply(String s) {
return Integer.parseInt(s);
}
};
System.out.println(function.apply("23"));
}
}
所有的函数式接口都可以通过 lambda 表达式简化
public class Test {
public static void main(String[] args) {
Function<String, Integer> function =(s)->{ return Integer.parseInt(s); };
System.out.println(function.apply("23"));
}
}
断定型接口 Predicate
泛型为判断方法的参数类型,返回值为布尔值
public class Test {
public static void main(String[] args) {
//代表 new 了一个 Predicate 对象并且实现了 test 方法
Predicate<String> predicate = (s)->{return s.equals("23");};
System.out.println(predicate.test("23"));
}
}
该接口包含多种方法默认实现了与或非的逻辑判断
//(参数)->{代码}
Predicate<String> and = ((Predicate<String>) (s) -> {
return s.equals("23");
}).and((s) -> {
return s.equals("23");
});
System.out.println(and.test("2333"));
Predicate<String> negate = ((Predicate<String>) (s) -> {
return s.equals("23");
}).negate();
System.out.println(negate.test("23"));
Predicate<String> or = ((Predicate<String>) (s) -> {
return s.equals("23");
}).or((s) -> {
return s.equals("23");
});
System.out.println(or.test("23"));
消费型接口和供给型接口
消费型接口 Consumer
消费型接口只有输入,没有返回值,因为只负责消费!
public class 消费型接口和供给型接口 {
public static void main(String[] args) {
Consumer<String> consumer = (s)->{
System.out.println(s);
};
consumer.accept("345");
Consumer<String> consumer2 = System.out::println;
consumer2.accept("22");
}
}
供给型接口
供给型接口只有返回值,没有输入,因为只负责供给!
public class 消费型接口和供给型接口 {
public static void main(String[] args) {
Supplier<String> supplier = ()->{return "DWDW";};
System.out.println(supplier.get());
}
}
总结
-
Function<T, R>——将T作为输入,返回R作为输出
-
Predicate——将T作为输入,返回一个布尔值作为输出
-
Consumer——将T作为输入,不返回任何内容
-
Supplier——没有输入,返回T
Stream 流式计算
Java 8 添加了一个新的接口称为 Stream 流。Stream 使用一种类似 SQL 的直观书写方式来提供了一种对 Java 集合运算的高阶表达和抽象
Stream 将要处理的元素集合看作是一种流,流在管道中传输,并且可以在管道的节点上进行处理,如筛选,排序,聚合等操作
同时可以看见 Stream 的很多方法参数都是函数式接口,这就给了我们使用 lambda 表达式来简化书写的机会
比如我们有如下一个题目
现在有5个用户,有年龄,ID,名字这三个属性,现有要求如下
- ID 必须是偶数
- 年龄必须大于 23 岁
- 用户名转为大写字母
- 用户名根据字母倒序排序
- 只输出 1 个用户
要求只用一行代码实现,使用 Stream 就可以做到
public class Test {
public static void main(String[] args) {
User a = new User(23, "a", 10);
User b = new User(21, "b", 20);
User c = new User(27, "c", 30);
User d = new User(19, "d", 40);
User e = new User(30, "e", 50);
//组成一个集合
List<User> users = Arrays.asList(a, b, c, d, e);
//将集合转为流,并进行操作
//过滤使用 filter,参数是断定型接口
//转换使用 map,参数是函数型接口
//排序使用 sorted,默认是自然排序,比较器也是个函数式接口,使用 lambda 表达式复写 compare 接口即可
//规定返回的 stream 大小使用 limit
users.stream().filter((user)->{return user.getId()%2==0;})
.filter((user)->{return user.getAge()>23;})
.map((user)->{return user.getName().toUpperCase();})
.sorted((u1, u2)->{return u2.compareTo(u1);})
.limit(1)
.forEach(System.out::println);
}
}
正确使用 stream 能让代码更加优雅美观
分治框架 ForkJoin
什么是 ForkJoin ?
ForkJoin 出现于 JDK 1.7,其本质是将大任务分割成若干小任务,最终汇总每个小任务的结果从而得到大任务的结果
ForkJoin 有一个非常重要的特性:工作窃取,当某一个线程将他自己的任务执行完后,其它线程若还有任务没有执行,执行完任务的线程会把没有执行完任务的线程的任务偷过来执行。
那为什么能办到这一点呢?这是因为 ForkJoin 使用的是双端队列,当原本的线程从队头到队尾在执行的时候,过来帮助他的线程会从队尾到队头帮助他执行
ForkJoin 使用
我们通过一个对 10 亿大小的数字进行求和来看下如何使用
- 通过 ForkJoinPool 的 execute 方法执行任务 (无返回值);或者通过 submit 执行任务 (有返回值)
- 定义计算任务 ForkJoinTask
- 对于我们的需求来说,我们应该使用递归任务来进行分治,因为我们需要将结果作为返回值最终合并。在官方案例中,我们想要使用递归任务,只需要计算类去继承即可
- 我们看一下 RecursiveTask 的源码,发现只有一个需要实现的方法,该方法主要用于计算并返回计算结果
- 对于分治而言,最重要的是拆分和求和,ForkJoinTask ( RecursiveTask 的父类 ) 为我们提供了这两个方法:fork ,join
因此,我们的计算类代码如下
public class ForkJoinTest extends RecursiveTask<Long> {
private long start;
private long end;
private long temp = 10000L; //临界值,大于这个值的任务将被拆分
public ForkJoinTest(long start, long end){
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
long sum = 0;
if((end-start) <= temp){
for (long i = start; i <= end; i++) {
sum += i;
}
return sum;
}else {
//分治
long middle = (end + start) / 2;
ForkJoinTest task1 = new ForkJoinTest(start, middle);
ForkJoinTest task2 = new ForkJoinTest(middle + 1, end);
//任务拆分
//这里也可以写成 invokeAll(task1, task2); 这样两个任务会并行执行
task1.fork();
task2.fork();
//结果合并
return task1.join() + task2.join();
}
}
}
在上面的子任务执行中,我们可以使用 fork 去让子任务分别的异步执行,或者采用 invokeAll 让两个子任务并行执行
接下来进行测试
public class Test {
public static void main(String[] args) throws ExecutionException, InterruptedException {
test1();
test2();
}
/**
* 普通方法执行 10 亿合并
*/
public static void test1(){
long t1 = System.currentTimeMillis();
long sum = 0;
for (long i = 0; i <= 1000000000L; i++) {
sum += i;
}
long t2 = System.currentTimeMillis();
System.out.println("结果:"+sum+" 执行时间:"+(t2-t1));
}
/**
* forkjoin 执行 10 亿求和
*/
public static void test2() throws ExecutionException, InterruptedException {
long t1 = System.currentTimeMillis();
//通过 ForkJoinPool 执行
ForkJoinPool forkJoinPool = new ForkJoinPool();
//限定起始
ForkJoinTest task = new ForkJoinTest(0, 100_000_0000);
//提交任务执行
forkJoinPool.submit(task);
//获得结果
Long res = task.get();
long t2 = System.currentTimeMillis();
System.out.println("结果"+res+" 执行时间:"+(t2-t1));
}
}
可以看见,效率高了 1倍左右
并行流计算和 ForkJoin 效率比较
在上面的 Stream 中,我们只学习了串行流,Stream 另外一种强大的流是并行流
对 Long 类型的计算,我们可以使用 LongStream 来进行
long reduce = LongStream.rangeClosed(0L, 100_000_0000L).parallel().reduce(0, Long::sum);
很简单,一行代码即可
其实 Stream 并行流的底层用的也是 ForkJoin 框架
异步回调
异步计算
异步调用其实就是实现一个**可无需等待被调用方法的返回值而让调用者继续执行**的技术。在 Java 中,简单地讲就是开启另一个线程来完成操作中的部分计算,使得调用者继续运行,而不需要等待计算结果。但调用者仍需要取得线程的计算结果
回调方法 (函数)
回调函数本质上可以说是**通过函数指针调用的函数**。如果把函数的指针 (地址) 作为参数传递给另一个函数,当这个指针被用来调用它所指向的函数时,这时就称为回调函数。
回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另一方来调用,用于对该事件或条件进行响应
同步和异步
在同步调用中,当调用线程一旦调用一个方法,就必须等待这个方法返回,才能进行后续的操作
但是在异步调用中,调用线程不必等待调用方法的返回,可以继续向下执行,最后通过回调处理这次异步方法的执行结果,或通过状态通知调用者线程
Future
Java 中的 Future 就是一个异步计算结果接口,目的是**获得计算结果**,一个 Future 接口表示一个未来可能会返回的结果,这个接口提供了在等待异步计算完成时检查计算是否完成,并在异步计算后通过 get 方法获取计算结果。Future 接口提供了以下方法
get()
:获取结果(可能会等待,阻塞)get(long timeout, TimeUnit unit)
:获取结果,但只等待指定的时间cancel(boolean mayInterruptIfRunning)
:取消当前任务isDone()
:判断任务是否已完成 (轮询)
但是 future 对于结果的获取不是很方便,只能通过阻塞或者轮询的方式得到任务的结果。阻塞的方式显然与异步的初衷相违背,轮询的方式又很占用 CPU 资源,也不能及时得到计算结果
CompletableFuture
CompletableFutrure 可以帮助简化异步编程的复杂性,并提供了函数式编程的能力。可以通过回调的方式**处理计算结果**,也提供了转换和组合 CompletableFutrure 的方法
CompletableFutrure 类实现了 CompletionStage 接口和 Future,CompletionStage 接口实际上提供了同步和异步运行计算的能力 ,包括了阶段性计算任务 (没错,底层默认使用了 ForkJoinPool );而 Future 则负责异步任务的结果
简单异步 Demo
CompletableFuture 以 Async 结尾的方法都是可以执行异步任务的方法,入参不同,可以执行的任务类别也就不同
public class FutureTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//<填 lambda 的返回值类型包装类>
//supplyAsync 为有返回值的异步任务,参数为供给型接口
//runAsync 为没有返回值的异步任务,参数为 Runnable
CompletableFuture<String> future = CompletableFuture.supplyAsync(()->{
System.out.println(Thread.currentThread().getName());
return "";
//在回调成功和失败的方法完成后,会再次执行该方法,这时的 s
}).thenApply((s)->{
//s 为异步任务的原本返回值,这里的返回值会替代原本的返回值,作为参数传递给回调成功操作
System.out.println("s="+s);
return "回调完成";
});
//回调成功的操作,参数是 BiConsumer,有两个参数的消费型接口
System.out.println(future.whenComplete((s1, s2) -> {
//s1 代表异步任务的返回值,如果有thenApply 并且有返回值的话,会替代掉原来异步任务的返回值,
//s2 代表错误信息
System.out.println("s1=" + s1 + " s2=" + s2);
}).//回调失败的操作,参数是 函数型接口 Function
exceptionally((e) -> {
System.out.print("异常::"+e.getMessage());
return "\n失败"; //回调结果
//get 获取结果
}).get());
}
}
JMM
什么是 JMM ?
JMM 是 Java 多线程下的内存通信模型,不是 JVM 这种真实存在的东西,而是一种抽象出来的模型,方便理解
JMM 定义了线程与主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程要读/写的共享变量的副本
本地内存是 JMM 的抽象概念,涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化
JMM 同步规范
从上面的图来看,线程 A 和线程 B 之间如果要通信,必须要经历以下两个步骤
- 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去
- 线程 B 到主内存中去读取线程 A 更新过的共享变量
所以,JMM 应该具有如下的操作规范
- 线程解锁前,必须把共享变量立刻刷新回主内存中
- 线程加锁前,必须读取主内存中的最新值到本地内存
- 加锁和解锁必须是同一把锁
JMM 4组操作
内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可再分的(对于 double 和long 类型的变量来说,load、store、read 和write 操作在某些平台上允许例外)
-
lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态
-
unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
-
read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
-
load (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
-
use (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
-
assign (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
-
store (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
-
write (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
JMM对这八种指令的使用,制定了如下规则:
-
不允许 read 和 load、store 和 write 操作之一单独出现。即使用了read必须load,使用了 store 必须 write
-
不允许线程丢弃他最近的 assign 操作,即工作变量的数据改变了之后,必须告知主存
-
不允许一个线程将没有 assign 的数据从工作内存同步回主内存
-
一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store 操作之前,必须经过 assign 和 load 操作
-
一个变量同一时间只有一个线程能对其进行 lock。多次 lock 后,必须执行相同次数的 unlock 才能解锁
-
如果对一个变量进行 loc k操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新 load 或 assign 操作初始化变量的值
-
如果一个变量没有被 lock,就不能对其进行 unlock 操作。也不能 unlock 一个被其他线程锁住的变量
-
对一个变量进行 unlock 操作之前,必须把此变量同步回主内存
但是仔细分析一下,我们将 flag = false 刷新回了主内存,如果这时线程 B 拿到锁,将 flag 又置回了 true,那么这时本地内存 A 中的共享变量 flag 依然是 false,即:主内存中的共享变量变化对线程是不可见的,这就会带来很大的隐患
不可见性测试
public class Test {
//假设num作为共享变量
private static int num = 0;
public static void main(String[] args){
//num==0则无限循环
new Thread(()->{
while (num == 0){
}
}).start();
//主线程停止2s,保证另一个线程启动
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
//主线程修改共享变量
num = 1;
//输出变量,看主线程中的变量值
System.out.println(num);
}
}
可以发现,程序阻塞了,这就是因为 num 这个共享变量的改变并没有被其它线程读取到,因此造成了阻塞。
为了解决共享变量的不可见性问题,就引入了 volatile 关键字
volatile
volatile 具有以下三个特性
- 保证可见性和有序性
- 不保证原子性
- 防止指令重排
保证可见性验证
继续使用上面的代码进行测试
public class Test {
//假设num作为共享变量,加上关键字 volatile
private volatile static int num = 0;
public static void main(String[] args){
//num==0则无限循环
new Thread(()->{
while (num == 0){
}
}).start();
//主线程停止2s,保证另一个线程启动
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
//主线程修改共享变量
num = 1;
//输出变量,看主线程中的变量值
System.out.println(num);
}
}
可以发现输出 1 后,程序立即停止,没有再阻塞。因此保证了可见性
对使用了 volatile 的共享变量进行读写操作时
- 会强制将修改的值立即写入主内存
- 当线程 B 进行修改时,会导致线程 A 的本地内存中缓存变量的缓存行无效
- 由于线程 A 中缓存变量的缓存行无效了,所以线程 A 再次去读取共享变量的值时会去主内存中读取
因此,对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入
不保证原子性验证
我们来测试下面这段代码
public class 不保证原子性 {
//加了 volatile
private volatile static int sum = 0;
public static void test(){
sum++;
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
test();
}
}).start();
}
//Java 本身有 main 线程和 gc 线程,这里需要等待其它线程执行完
while(Thread.activeCount() > 2){
Thread.yield();
}
//输出结果
System.out.println("结果:"+sum);
}
}
10 个线程,每个线程执行 1000 次 test 方法,预期输出应该是 10000,但是通过实际测试发现,每次的执行结果都是小于 10000 的
这是因为自增操作不是原子性操作,一个自增操作包括了 读取变量到本地内存,变量+1,写入本地内存这三个步骤 (通过反编译后看如下代码)
保证原子性
除了使用 Lock 和 Synchroized 保证原子性以外,JUC 还提供了一个 atomic 包

可以很直观的发现,基本类型的原子包装类和基本类型数组的原子包装类。使用原子包装类可以解决原子性问题而不用使用锁
改进后代码如下
public class AtomicIntegerTest {
private volatile static AtomicInteger sum = new AtomicInteger(0);
public static void test(){
//执行+1
sum.getAndIncrement();
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
test();
}
}).start();
}
//Java 本身有 main 线程和 gc 线程,这里需要等待其它线程执行完
while(Thread.activeCount() > 2){
Thread.yield();
}
//输出结果
System.out.println("结果:"+sum);
}
}
测试后发现无论执行多少次结果都是 10000
来看一下源码,getAndIncrement 方法中使用 unsafe 变量的一个方法
继续看下 unsafe 变量是什么
可以看见 unsafe 是一个 Unsafe 类型变量。我们继续看下 Unsafe 这个类。可以看见注释如下
翻译有些问题,应该将低级改为底层。Unsafe 类中大量的方法都是 native 方法
getAndIncrement 方法中调用的 Unsafe 类的方法 getAndAddInt 中,就调用了两个 native 本地方法。Unsafe 可以调用本地方法从而使 Java 操作内存地址,比如通过对象和偏移量直接从内存中获取对应变量的值,并且 Unsafe 中的方法是原子操作
/**
获取内存地址为obj+offset的变量值, 并将该变量值加上delta
*/
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
//通过对象和偏移量获取变量的值
v = getIntVolatile(o, offset);
/**
while中的compareAndSwapInt()方法尝试修改v的值,具体地, 该方法也会通过o和offset获取变量的值
如果这个值和v不一样, 说明其他线程修改了obj+offset地址处的值, 此时compareAndSwapInt()返回false, 继续循环
如果这个值和v一样, 说明没有其他线程修改o+offset地址处的值, 此时可以将o+offset地址处的值改为v+delta, compareAndSwapInt()返回true, 退出循环
Unsafe类中的compareAndSwapInt()方法是原子操作, 所以compareAndSwapInt()修改o+offset地址处的值的时候不会被其他线程中断
**/
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
指令重排
什么是指令重排?
指令重排是指在程序执行过程中,为了性能考虑,编译器和 CPU 对指令的重新排序
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)
JMM属于语言级的内存模型,它**确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,提供一致的内存可见性保证**
假设我们有以下的一段代码
int x = 1; //1步
int y = 2; //2步
x = x+5; //3步
y = x*x; //4步
- 我们所期望的执行顺序是 1,2,3,4
但是对程序而言,如果执行顺序是 2,1,3,4 的话,依然可以成功执行 - 如果执行 1,3,2,4 的话,程序也能跑,但是结果会和上面两种执行顺序的结果不同
但是如果程序的执行顺序不可能是 4,2,3,1 ,因为**指令重排会考虑数据之间的依赖性问题**,数据依赖性指的是两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性
处理器重排序和内存屏障
现代处理器使用**写缓冲区来临时保存向内存写入的数据**,同时写缓冲区还具有以下作用
- 保证指令流水线持续运行,避免由于处理器停顿下来等待向内存写入数据而产生的延迟
- 通过批处理的方式刷新缓冲区,以及合并缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用
但是写缓冲区的可见性可能会对执行顺序造成重大的影响:每个处理器上的写缓冲区,仅仅对它所在的处理器可见,因此处理器对内存读写操作的执行顺序,不一定与内存实际发生的读写操作顺序一致
我们看一下下面这个例子
假设处理器A和处理器B按程序的顺序并行执行内存访问,最终却可能得到x = y = 0的结果。具体的原因如下图所示
这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就会得到 x = y = 0 的结果
从内存实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓冲区,写 A1 才算真正执行了!虽然处理器 A 执行的顺序是 A1 -> A2,但是内存操作实际操作的顺序却是 A2 -> A1,这时就发生了处理器对于读-写的重排序,重排序为了写-读
这里的关键在于 由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致 (因为对内存不可见,不知道内存是否被读了)
常见处理器允许的重排序类型
Load 表示读,Store 表示写
N 表示处理器不允许两个操作重排序,Y 表示允许两个操作重排序
可以发现,常规的处理器都支持写读重排,而 x86 架构和 sparc-TSO 架构的处理器则只支持写读重排
所以,为了保证内存的可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障来禁止特定类型的处理器重排序,JMM 把内存屏障指令分为
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多核处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)
happens-before 原则
从 JDK 1.5 开始,java 使用新的 JSR -133 内存模型。JSR-133 使用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间
happens-before 的规则如下
- 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作
- 监视器锁规则:对一个监视器锁的解锁,happens-before 于随后对这个监视器锁的加锁
- volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 的读
- 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C
- 线程启动:Thread 对象的 start() 方法 happens-before 于此线程的每一个动作
- 线程中断:线程中所有的操作都 happens-before 于线程的终止检测,可以通过 Thread.join() 结束,Thread.isAlive() 的返回值手段检测到线程已经终止执行
- 对象销毁:一个对象的初始化完成 happens-before 与他的 finalize() 方法的开始
注意:两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行
,happens-before 仅仅要求前一个操作的执行结果对后一个操作可见,且第一个操作按顺序排在第二个操作之前
一个 happens-before 规则通常对应于多个编译器重排序规则和处理器重排序规则
所以 volatile 可以防止写-读的指令重排,从而保证后续的读对写永远是可见的
volatile 的应用
volatile 很重要的一个应用就是在双重检查锁的单利模式下,对单例对象防止指令重排
class Singleton{
//volatile 可以认为是轻量级的同步锁
private static volatile Singleton instance;
private Singleton(){}
/**
* 提供一个静态的公有方法,当使用到该方法
* 并且单例对象没有被创建时才去创建单例对象
* 双重检查
* @return:单例对象
*/
public static Singleton getInstance(){
if( instance == null ){
synchronized (Singleton.class){
if(instance == null){
System.out.println("执行了初始化单例对象的方法");
//new 并不是一个原子性操作,而是分为了一下几步
//1分配对象内存,2调用构造器进行初始化,3将对象赋值给变量
//在执行中,2,3可能发生重排序,在多线程情况下,如果A线程先将对象赋值给变量,而没有执行初始化,这时B线程又来执行getInstance,那么就会判断instance不为null,最后返回的是null (因为还未初始化)
instance = new Singleton();
}
}
}
return instance;
}
}
Unsafe 和 CAS
CAS 的全称是 Compare And Swap 比较并交换,底层是使用了 CPU 实现的原语 (原语是由若干条指令组成的,用于完成一定功能的一个过程,具有不可分割性,即原语的执行必须是连续的,在执行过程中不允许被中断)
在前面提到的原子包装类 AtomicInteger 中就有一个 compareAndSet 方法
这个方法非常的简单,它需要两个参数,第一个参数为期望值,第二个参数为更新值,如果 AtomicInteger 对象达到了期望值,那么就将该对象的值变为更新值
AtomicInteger atomicInteger = new AtomicInteger(2020);
atomicInteger.compareAndSet(2020, 2077);
System.out.println(atomicInteger);
可以发现也是调用了 Unsafe 类的一个 native 方法
Unsafe
Java 可以通过 Unsafe 类的来操作内存,在 AtomicInteger 中,有一段静态代码块,用于获得内存地址偏移值
static {
try {
//获取对象属性的内存地址偏移值
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
Q:那这个内存地址偏移值又有什么用呢?
A:在普通的 Java 程序中,如果我们想要获得一个对象的属性,我们一般会通过 getter 去获取。但是 Unsafe 类不这么做,Unsafe 类将每一个对象视为一块内存空间,而这个内存中包含了一些属性,valueOffset 就是属性在这块内存中的偏移量,每一个属性的 valueOffset 在这块内存中是不同的
,每个属性的 valueOffset 应该在类加载时就确定了 (该类不同对象的同名属性的 valueOffset 相同,因为对象在不同的内存地址中,只需区分对象即可),Unsafe 就可以通过 一个对象+属性的偏移量 valueOffset 使用原语来获得该对象对应属性的值
现在我们再来看看 AtomicInteger 的底层方法,也就是 Unsafe 类下的 getAndAddInt 方法
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
//通过对象和属性在对象内存中的偏移量来获得对应的属性值
v = getIntVolatile(o, offset);
//这也是个 CAS
//O+offset 即当前属性的值,v 为 期望值
//如果 o+offset 对应的属性的值 == v
//则将 v加上delta 即可,而在 AtomicInteger 的 +1 方法中,delta 给 getAndAddInt 传入的 delta 为 1。即进行+1操作,返回 true 退出循环并返回
//如果 o+offset 对应的属性的值 != v (其他线程修改了值),则返回 false,继续循环
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
compareAndSwapInt 方法如下
AtomicInteger 中的 +1 方法如下
getAndAddInt 使用了 CAS + 自旋锁 来保证了并发修改的原子性 (CAS) 与正确性 (自旋)
CAS机制中使用了3个基本操作数:内存地址 V,旧的预期值 A,要修改的新值 B
更新一个变量的时候,只有当变量的预期值 A 和内存地址 V 当中的实际值相同时,才会将内存地址V对应的值修改为 B
CAS 的缺点
- 循环时间长则开销很大。如果 CAS 失败则会一直尝试
- 只能保证一个共享变量的原子操作
- ABA 问题
CAS 的适用场景
- 适合简单对象的操作
- CAS 适合冲突较少的情况,如果并发下有太多的线程在自旋,则会造成较大的 CPU 开销
ABA 问题
如果内存地址 V 初次读取的值是 A,并且在准备 Swap 的时候检查到它的值仍然为 A,那我们就能说它的值没有被其他线程改变过了吗?如果在这段期间它的值曾经被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过 (忒修斯之船?hhhhhh)
所以,CAS 其实是一种乐观锁,它乐观的估计在 CAS 期间数据并不会被其他线程进行修改,所以才造成了 ABA 问题
现在,来复现下 ABA 操作
public class ABATest {
public static void main(String[] args) {
AtomicReference<Integer> integerAtomicReference = new AtomicReference<>(110);
new Thread(()->{
//ABA 操作
System.out.println(integerAtomicReference.compareAndSet(110, 120)+" "+integerAtomicReference.get());
System.out.println(integerAtomicReference.compareAndSet(120, 110)+" "+integerAtomicReference.get());
}, "A").start();
new Thread(()->{
//保证 A 线程执行完 ABA操作
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(integerAtomicReference.compareAndSet(110, 1111)+" "+integerAtomicReference.get());
}, "B").start();
}
}
就如同忒修斯之船,假如我们想要对比的不是 Integer 而是一个 User 这种对象,那我们能保证我们比对的对象就是原来的对象吗?这个对象的内在就一定没被修改过吗?
原子引用
既然可能出现 ABA 问题,那我们先思考下如何解决
假设我们有一个额外的观察者,这个观察者可以站在上帝视角看有哪些线程对共享变量进行了修改,并记录下共享变量的 “版本”,当某一个线程想对共享变量进行修改时,发现这个变量的 “版本” 不是所期望的,则不再进行修改,这样就可以解决 ABA 问题
在 atomic 包下有一个类,这个就是带版本号的原子引用类
该类的构造器只有一个
V:对象引用类型
initalStamp:版本号初始值
同时,AtomicStampedReference 的 CAS 方法也有所改变
该类的 CAS 方法要求额外传入预期版本号和新版本号,当期望引用和预期版本号都与内存中相同时,更新为新的版本号和新的引用
使用 Integer 进行测试
public class 原子引用 {
public static void main(String[] args) {
AtomicStampedReference<Integer> reference = new AtomicStampedReference<>(2020, 1);
new Thread(()->{
//获取版本号
int stamped = reference.getStamp();
System.out.println("A Stamp = "+stamped);
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
//修改值
System.out.println("线程 A 修改"+reference.compareAndSet(2020, 2077, reference.getStamp(), reference.getStamp() + 1));
System.out.println("A reference1 = " + reference.getReference() + " stamp = " + reference.getStamp());
//将值修改回去
System.out.println("线程 A 修改还原"+reference.compareAndSet(2077, 2020, reference.getStamp(), reference.getStamp() + 1));
System.out.println("A reference2 = " + reference.getReference() + " stamp = " + reference.getStamp());
}, "A").start();
new Thread(()->{
//获取版本号
int stamped = reference.getStamp();
System.out.println("B Stamp = "+stamped);
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程 B 修改"+reference.compareAndSet(2020, 2077, reference.getStamp(), reference.getStamp() + 1));
System.out.println("B reference1 = " + reference.getReference() + " stamp = " + reference.getStamp());
}, "B").start();
}
}
在这次测试中,无论如何执行,版本号永远都不会变,这是因为 Integer 有一个大坑
Integer 使用了缓存机制,-127 ~ 128 之前的值会被直接返回进行复用,但是如果去创建不在这个范围内的值,则会在堆上创建新的对象,而不会去复用。因此 CAS 每次比较的内存空间并不是同一块,当然会无法修改
所以我们不要直接传入数字,而是应该使用 AtomicStampedReference 的 getReference 方法来获得当前的对象引用,这样就能够保证不创建新的对象
public class 原子引用 {
public static void main(String[] args) {
AtomicStampedReference<Integer> reference = new AtomicStampedReference<>(2020, 1);
new Thread(()->{
//获取版本号
int stamped = reference.getStamp();
System.out.println("A Stamp = "+stamped);
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
//ABA 操作
//修改值
System.out.println("线程 A 修改"+reference.compareAndSet(reference.getReference(), 2077, reference.getStamp(), reference.getStamp() + 1));
System.out.println("A reference1 = " + reference.getReference() + " stamp = " + reference.getStamp());
//将值修改回去
System.out.println("线程 A 修改还原"+reference.compareAndSet(reference.getReference(), 2020, reference.getStamp(), reference.getStamp() + 1));
System.out.println("A reference2 = " + reference.getReference() + " stamp = " + reference.getStamp());
}, "A").start();
new Thread(()->{
//获取版本号
int stamped = reference.getStamp();
System.out.println("B Stamp = "+stamped);
try {
TimeUnit.SECONDS.sleep(4);
} catch (InterruptedException e) {
e.printStackTrace();
}
//规定我们想要保证的一致性数据的版本号
System.out.println("线程 B 修改"+reference.compareAndSet(reference.getReference(), 2099, stamped, stamped));
System.out.println("B reference1 = " + reference.getReference() + " stamp = " + reference.getStamp());
}, "B").start();
}
}
关于 ABA 问题,在平时的代码中很难遇到,即使遇到了也很可能对结果的影响不大,但是并不是代表 ABA 问题没有影响
各种锁
公平锁,非公平锁
公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁
非公平锁:俺可以插队 ( Java 中默认都是非公平锁 )
ReentrantLock 中构造器重载实现了指定锁是公平还是非公平
可重入锁 (递归锁)
可重入锁又称递归锁,是指同一个线程在外层方法获取锁的时候,在进入该线程的内层方法会自动获取锁 (前提是得是同一把锁),在 Java 中,ReentrantLock 和 Synchronized 都是可重入锁
public class 可重入锁 {
public static void main(String[] args) {
Test test = new Test();
new Thread(()->{
test.call();
}, "A").start();
new Thread(()->{
test.call();
}, "B").start();
}
}
class Test{
public synchronized void call(){
System.out.println(Thread.currentThread().getName() + " " + "call");
email(); //这里调用 email() 其实也有一把锁
}
public synchronized void email(){
System.out.println(Thread.currentThread().getName() + " " + "email");
}
}
由于 call 方法中调用了 email 方法,email 方法的锁和 call 方法的锁都是同一把,所以自然而然的只有直到 email 方法执行完毕后线程 A 才会释放锁
public class 可重入锁 {
public static void main(String[] args) {
Test2 test = new Test2();
new Thread(()->{
test.call();
}, "A").start();
new Thread(()->{
test.call();
}, "B").start();
}
}
class Test2{
Lock lock = new ReentrantLock();
public void call(){
lock.lock();
try{
System.out.println(Thread.currentThread().getName() + " " + "call");
email(); //拿到外面的锁,就能拿到里面的锁
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
}
public synchronized void email(){
lock.lock();
try{
System.out.println(Thread.currentThread().getName() + " " + "email");
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
}
}
自旋锁
自旋锁 ( spinLock ):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环
使用 CAS自旋锁 机制实现自己的并发下锁机制
public class SpinLock {
//线程的原子引用,默认为 null
AtomicReference<Thread> reference = new AtomicReference<>();
public void myLock(){
//获取进入的线程
Thread thread = Thread.currentThread();
System.out.println(thread.getName()+"想要获得锁");
//CAS 自旋锁
//如果引用的线程为 null,则更新为进入了这个方法的线程
//如果不为 null,则循环直到其他线程释放锁将引用的线程置为 null
do {
}while (!reference.compareAndSet(null, thread));
}
public void myUnLock(){
//获取进入的线程
Thread thread = Thread.currentThread();
System.out.println(thread.getName()+"想要释放锁");
//将进入的线程置为 null,相当于完成一个解锁的操作
reference.compareAndSet(thread, null);
}
}
class MyLockTest{
public static void main(String[] args) throws InterruptedException {
//CAS 自旋锁
SpinLock spinLock = new SpinLock();
new Thread(()->{
spinLock.myLock();
try {
//让线程B获得执行权,但锁依然在A这里
TimeUnit.SECONDS.sleep(5);
}catch (Exception e){
e.printStackTrace();
}finally {
spinLock.myUnLock();
System.out.println(Thread.currentThread().getName()+"释放");
}
}, "A").start();
//保证 A 线程先获取到锁
TimeUnit.SECONDS.sleep(2);
new Thread(()->{
//尝试获取锁,如果A没有释放锁则会阻塞在自旋处
spinLock.myLock();
try {
TimeUnit.SECONDS.sleep(1);
}catch (Exception e){
e.printStackTrace();
}finally {
spinLock.myUnLock();
System.out.println(Thread.currentThread().getName()+"释放");
}
}, "B").start();
}
}
可以看见,B 线程在打印后出现阻塞,等待 A 线程释放锁,直到 A 线程释放锁
CLH 锁
CLH 的在前面的 AQS 中被提及 (虽然使用的是变种),这里简单介绍下 CLH 锁的机制
CLH ( Craig, Landin, and Hagersten ) 是自旋锁的一种,能保证无饥饿,提供 FCFS ( First Come Fist Serve ) 的公平性
CLH 是基于单链表的可扩展,高性能,公平的自旋锁,申请线程只在本地变量上自旋,并不断 Ribbon ( 轮询 ) 前驱的状态,如果发现前驱释放了锁就结束自旋并获取锁
MCS 锁
MCS 锁和 CLH 锁最大的不同是线程自旋的规则不同
CLH 是在前驱结点的 locked 域上自旋等待,而 MCS 是在自己结点的 locked 域上自旋等待
死锁分析和解决
死锁是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法继续运行
死锁产生的四个必要条件
-
互斥条件:进程要求对所分配的资源进行排他锁控制,即在一段时间内某资源仅为一进程所占用
-
请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放
-
不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放
-
环路等待条件::在发生死锁时,必然存在一个进程<->资源的环形链
产生死锁问题
public class DeathLock {
private static String lock1 = "A";
private static String lock2 = "B";
public static void main(String[] args) {
new DeathLock().deathLock();
}
public void deathLock(){
Thread thread1 = new Thread(() -> {
synchronized (lock1){
//睡眠,让 2 线程拿到 lock2,这样就产生了死锁
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2){
System.out.println(1);
}
}
}, "1");
Thread thread2 = new Thread(() -> {
synchronized (lock2){
synchronized (lock1){
System.out.println(2);
}
}
}, "2");
thread1.start();
thread2.start();
}
}
在这段代码中,线程 1 拿到了 lock1,然后睡了 2s,线程 2 拿到了 lock2,然后线程 2 还想继续获取 lock1,但是此时 lock1 被线程 1 拿着,线程 1 又只有拿了 lock2 才能释放 lock1,这样就僵持住了
解决方案
预防死锁
-
避免多次锁定。尽量避免同一个线程对多个 Lock 进行锁定。例如上面的死锁程序,1 线程要对 A、B 两个对象的 Lock 进行锁定,2 线程也要对 A、B 两个对象的 Lock 进行锁定,这就埋下了导致死锁的隐患
-
具有相同的加锁顺序。如果多个线程需要对多个 Lock 进行锁定,则应该保证它们以相同的顺序请求加锁。比如上面的死锁程序,1 先对 lock1 加锁,再对 lock2 加锁;2 则先以 lock2 加锁,再以 lock1 加锁。这种加锁顺序很容易形成嵌套锁定,进而导致死锁。如果让 1 线程、2 线程按照相同的顺序加锁,就可以避免这个问题
-
使用超时等待 (ReenteantLock 中的 tryLock 方法可以指定超时时间)
一般的 tryLock 使用如下
//实例化Lock接口对象 Lock lock = ...; //根据尝试获取锁的值来判断具体执行的代码 if(lock.tryLock()) { try{ //处理任务 }catch(Exception ex){ }finally{ //当获取锁成功时最后一定要记住finally去关闭锁 lock.unlock(); //释放锁 } }else { //else时为未获取锁,则无需去关闭锁 //如果不能获取锁,则直接做其他事情 }
PS:使用 tryLock 可以不用显式的使用 lock 方法去获取锁
死锁检测
- JPS:jps -l 查看当前存活的 Java 进程;
检测死锁:jstack + 进程号查看进程信息,如果有死锁的话,会在底部显示