JUC并发编程
0、JUC知识体系框架
1、线程基础
1.1、进程、线程、管程
进程:是系统进行资源分配和调度的 基本单位 ,是操作系统结构的基础
线程:是操作系统能够进行运算调度的 最小单位 ,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
管程:Monitor(锁) ,也就是我们平时所说的锁。Monitor其实是一种同步机制,它的义务是保证(同一时间)只有一个线程可以访问被保护的数据和代码,JVM中同步是基于进入和退出监视器(Monitor管程对象)来实现的,每个对象实例都会有一个Monitor对象,Monitor对象和Java对象一同创建并销毁,底层由C++语言实现。
1.2、线程创建以及启动原理
线程创建方式有三种:
(1)通过继承Thread类,重写run方法;
(2)通过实现Runnable接口;
(3)通过实现callable接口三种方式(JDK1.5后);
通过继承Thread类或者实现Runnable接口、Callable接口都可以实现多线程,不过实现Runnable接口与实现Callable接口的方式基本相同,只是Callable接口里定义的方法返回值,可以声明抛出异常而已。因此将实现Runnable接口和实现Callable接口归为一种方式。
public class CreateThreadDemo {
public static void main(String[] args) {
//1.继承Thread
Thread thread = new Thread() {
@Override
public void run() {
System.out.println("继承Thread");
super.run();
}
};
thread.start();
//2.实现runable接口
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("实现runable接口");
}
});
thread1.start();
//3.实现callable接口
ExecutorService service = Executors.newSingleThreadExecutor();
Future<String> future = service.submit(new Callable() {
@Override
public String call() throws Exception {
return "通过实现Callable接口";
}
});
try {
String result = future.get();
System.out.println(result);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
start启动原理:在创建线程并执行thread.start()时,这个start()方法是被synochronied修饰,且通过本地方法:start0()启动线程,实现源码如下:
1.3、线程状态转换
注意:
1、超时等待:当调用wait(long timeout),sleep(long),join(long),LockSupport.parkNanos(),LockSupport.parkUtil()增加了超时等待的功能,也就是调用这些方法后线程会进入TIMED_WAITING状态,当超时等待时间到达后,线程会切换到Runable的状态。
2、阻塞状态:当线程出现资源竞争时,即等待获取锁的时候,线程会进入到BLOCKED阻塞状态,当线程获取锁时,线程进入到Runable状态。
3、当线程进入到synchronized方法或者synchronized代码块时,线程切换到的是 BLOCKED状态 ,而使用java.util.concurrent.locks下lock进行加锁的时候线程切换的是 WAITING或者TIMED_WAITING状态 ,因为lock会调用LockSupport的方法。
4、阻塞状态和等待状态的区别:阻塞状态是被动的,由于某些原因无法继续执行;等待状态是主动的,进程自愿放弃 CPU 使用权;阻塞状态会失去 CPU 时间片(时间片给syn方法或者代码块),等待状态不会。
1.4、线程状态基本操作
https://juejin.cn/post/6844903600309846023
1.4.1、interrupted
1.4.2、join
1.4.3、sleep
1.4.4、yield
1.4.5、守护线程
1.4、线程互斥同步
1.5、线程协作命令
1.6、经典面试题
1.6.1、保持两个线程交替打印
1.6.2、三个线程轮流打印
public class ABCPrinter {
private static Thread t1, t2, t3;
public static void main(String[] args) {
t1 = new Thread(() -> {
for (int i = 0; i < 2; i++) {
LockSupport.park();
System.out.print("A");
LockSupport.unpark(t2);
}
});
t2 = new Thread(() -> {
for (int i = 0; i < 2; i++) {
LockSupport.park();
System.out.print("B");
LockSupport.unpark(t3);
}
});
t3 = new Thread(() -> {
for (int i = 0; i < 2; i++) {
LockSupport.park();
System.out.print("C");
LockSupport.unpark(t1);
}
});
t1.start();
t2.start();
t3.start();
// 主线程稍微等待一下,确保其他线程已经启动并且进入park状态。
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 启动整个流程
LockSupport.unpark(t1);
}
}
2、并发理论
2.1、JMM内存模型
Java 内存模型(Java Memory Model 简称JMM)是一种抽象的概念,并不真实存在,指一组规则或规范,通过这组规范定义了程序中各个变量的访问方式,即对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。
首先将变量从主内存拷贝到各自的工作内存中,再对变量进行操作,操作完成再将变量从工作内存写回到主内存,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。图解如下:
2.2、指令重排
一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标而进行奋斗:在不改变程序执行结果的前提下,尽可能提高并行度。JMM对底层尽量减少约束,使其能够发挥自身优势。因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。一般重排序可以分为如下三种:
1、编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
2、指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
3、内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
2.3、happens-before规则
3、JUC工具
JUC框架五大类详细解读,包括:Lock框架,并发集合,原子类,线程池和工具类。
JUC包结构层次介绍:
其中包含了两个子包:atomic以及lock,另外在concurrent下的阻塞队列以及executors,这些就是concurrent包中的精华。而这些类的实现主要是依赖于volatile以及CAS,从整体上来看concurrent包的整体实现图如下图所示:
3.1、Lock框架
锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源。在Lock接口出现之前(jdk1.5之前),java程序主要是靠synchronized关键字实现锁功能的;而java SE5之后,并发包中增加了lock接口,它提供了与synchronized一样的锁功能。虽然它失去了像synchronize关键字隐式加锁解锁的便捷性,但是却拥有了**锁获取和释放**的可操作性(lock()/unlock()),可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。通常使用显示使用lock的形式和synchronized如下:
// lock
Lock lock = new ReentrantLock();
lock.lock();
try{
.......
}finally{
lock.unlock();
}
//synchronized
public synchronized void method(){};
3.1.1、synchronized
synchronized 通过当前线程持有对象锁,从而拥有访问权限,而其他没有持有当前对象锁的线程无法拥有访问权限,保证在同一时刻,只有一个线程可以执行某个方法或者某个代码块,从而保证线程安全。synchronized 可以保证线程的可见性,synchronized 属于隐式锁,锁的持有与释放都是隐式的,我们无需干预。synchronized最主要的三种应用方式:
修饰实例方法:作用于当前实例加锁,进入同步代码前要获得当前实例的锁
修饰静态方法:作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
修饰代码块:指定加锁对象,进入同步代码库前要获得给定对象的锁
(1) Synchronized底层语义原理:
https://blog.youkuaiyun.com/a745233700/article/details/119923661
(2)Synchronized 底层实现:
(3)JVM对Synchronized锁优化
1、锁升级
(通过锁对象的对象头中的Mark word + CAS实现)
2、锁消除
JIT 编译时会使用逃逸分析技术,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。
3、锁粗化
JIT 编译器动态编译时,如果发现几个相邻的同步块使用的是同一个锁实例,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程“反复申请、释放同一个锁“所带来的性能开销。
3.1.2、Lock
Lock 锁其实指的是 JDK5 之后在 JUC 中引入的 Lock 接口,该接口中只有6个方法的声明,对于实现该接口的所有锁可以称为 Lock 锁。Lock 锁是显式锁,锁的持有与释放都必须手动编写,当前线程使用 lock() 方法与 unlock() 对临界区进行加锁与释放锁,当前线程获取到锁之后,其他线程由于无法持有锁将无法进入临界区,直到当前线程释放锁,unlock() 操作必须在 finally 代码块中,这样可以确保即使临界区执行抛出异常,线程最终也能正常释放锁。
public interface Lock {
void lock(); //获得锁。如果获取不到,当前线程会处于休眠状态,等待,直到获取锁
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
//返回绑定到此Lock实例的新Condition实例。
Condition newCondition(); 。
}
注意:
1、关键字 synchronized 与 wait()/notify()这两个方法一起使用可以实现等待/通知模式, Lock 锁的 newContition()方法返回 Condition 对象,Condition 类 也可以实现等待/通知模式。 用 notify()通知时,JVM 会随机唤醒某个等待的线程, 使用 Condition 类可以 进行选择性通知, Condition 比较常用的两个方法:
await():会使当前线程等待,同时会释放锁,当等到其他线程调用signal()方法时,此时这个沉睡线程会重新获得锁并继续执行代码(在哪里沉睡就在哪里唤醒)。
signal():用于唤醒一个等待的线程。
3.1.3、LockSupport
LockSupprot 用来阻塞和唤醒线程,底层实现依赖于 Unsafe 类,阻塞和唤醒主要是围绕Park和Unpark展开:
public class LockSupportDemo1 {
public static void main(String[] args) {
Thread mainThread = Thread.currentThread();
// 创建一个线程从1数到1000
Thread counterThread = new Thread(() -> {
for (int i = 1; i <= 1000; i++) {
System.out.println(i);
if (i == 500) {
// 当数到500时,唤醒主线程
LockSupport.unpark(mainThread);
}
}
});
counterThread.start();
// 主线程调用park
LockSupport.park();
System.out.println("Main thread was unparked.");
}
}
//LockSuport中涉及的方法:
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 对象,用来记录导致线程阻塞的对象,方便问题排查。
void unpark(Thread thread):唤醒一个由 park 方法阻塞的线程。如果该线程未被阻塞,那么下一次调用 park 时将立即返回。这允许“先发制人”式的唤醒机制。
注意:synchronzed 会使线程阻塞,线程会进入 BLOCKED 状态,而调用 LockSupprt 方法阻塞线程会使线程进入到 WAITING 状态。
3.1.4、AQS (AbstractQueuedSynchronizer)
AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等。
AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。 CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。 AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。
3.1.5、读写锁ReentrantReadWriteLock
https://juejin.cn/post/6844903601546985485
3.1.6、详解Condition的await和signal等待/通知机制
https://juejin.cn/post/6844903602419400718
3.1.7、锁总结
1、乐观锁和悲观锁
因此,根据上述图所示,我们可以发现:
悲观锁:适合写操作比较多的场景,先加锁,可以保证写操作时数据正确。
乐观锁:适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
public synchroized void testMythod(){
//操作同步资源
}
//悲观锁
private ReentrantLock lock = new ReentrantLock();
public void modifyPublicResources(){
lock.lock();
lock.unlock();
}
//乐观锁(基于CAS)
private AtomicInteger atomicInteger = new AtomicInteger();
aotimicInteger.incrementAndGet();
2、自旋锁和自适应自旋锁
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
3、无锁、偏向锁、轻量级锁、重量级锁
这四种锁是指锁的状态,专门针对synchronized的,通过对象锁的对象头中的Mark Word和对象锁的状态实现,其中Mark Word存储的是获取对象锁的线程指针。
4、公平锁和非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。 公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待 。 但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
5、可重入锁和非可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。
6、独享锁和共享锁
独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。
共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
3.2、Collections并发容器
3.2.1、并发容器之ConcurrentHashMap(JDK1.8)
ConcurrentHashMap 在 JDK1.6 的版本网上资料很多,有兴趣的可以去看看。JDK 1.6 版本关键要素:
(1) segment 继承了 ReentrantLock 充当锁的角色,为每一个 segment 提供了线程安全的保障;
(2) segment 维护了哈希散列表的若干个桶,每个桶由 HashEntry 构成的链表。
1.8 版本舍弃了 segment,并且大量使用了 synchronized,以及 CAS 无锁操作以保证 ConcurrentHashMap 操作的线程安全性。至于为什么不用 ReentrantLock 而是 Synchronzied 呢?实际上,synchronzied 做了很多的优化,包括偏向锁,轻量级锁,重量级锁,可以依次向上升级锁状态,但不能降级(关于 synchronized 可以看这篇文章[2]),因此,使用 synchronized 相较于 ReentrantLock 的性能会持平甚至在某些情况更优,具体的性能测试可以去网上查阅一些资料。另外,底层数据结构改变为采用数组+链表+红黑树的数据形式。
总结:
1、 JDK6,7 中的 ConcurrentHashmap 主要使用 Segment 来实现减小锁粒度,分割成若干个 Segment,在 put 的时候需要锁住 Segment,get 时候不加锁,使用 volatile 来保证可见性,当要统计全局时(比如 size),首先会尝试多次计算 modcount 来确定,这几次尝试中,是否有其他线程进行了修改操作,如果没有,则直接返回 size。如果有,则需要依次锁住所有的 Segment 来计算。(Segment数组中的每一个元素都是一个段,都会存储一个HashEntry数组。)
2、 1.8 之前 put 定位节点时要先定位到具体的 segment,然后再在 segment 中定位到具体的桶。而在 1.8 的时候摒弃了 segment 臃肿的设计,直接针对的是 Node[] tale 数组中的每一个桶,进一步减小了锁粒度。并且防止拉链过长导致性能下降,当链表长度大于 8 的时候采用红黑树的设计。
主要设计上的变化有以下几点:
1、不采用 segment 而采用 node,锁住 node 来实现减小锁粒度。
2、设计了 MOVED 状态 当 resize 的中过程中 线程 2 还在 put 数据,线程 2 会帮助 resize。
3、使用 3 个 CAS 操作来确保 node 的一些操作的原子性,这种方式代替了锁。
4、锁的对象发生变化,从最开始的Segment,变成现在的Node。
5、采用 synchronized 而不是 ReentrantLock。
3.2.2、并发容器之CopyOnWriteArrayList
CopyOnWriteArrayList 就是通过 Copy-On-Write(COW),即写时复制的思想来通过延时更新的策略来实现数据的最终一致性,并且能够保证读线程间不阻塞。
COW 通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。对 CopyOnWrite 容器进行并发的读的时候,不需要加锁,因为当前容器不会添加任何元素。所以 CopyOnWrite 容器也是一种 读写分离 的思想,延时更新的策略是通过在写的时候针对的是不同的数据容器来实现的,放弃数据实时性达到数据的最终一致性。
CopyOnWriteArrayList的底层是通过一个volatile修饰存储数组array,然后get方法和原本ArrayList中的get方法一致,但是在Add方法中使用ReentrantLock,源码实现如下:
public boolean add(E e) {
final ReentrantLock lock = this.lock;
//1. 使用Lock,保证写线程在同一时刻只有一个
lock.lock();
try {
//2. 获取旧数组引用
Object[] elements = getArray();
int len = elements.length;
//3. 创建新的数组,并将旧数组的数据复制到新数组中
Object[] newElements = Arrays.copyOf(elements, len + 1);
//4. 往新数组中添加新的数据
newElements[len] = e;
//5. 将旧数组引用指向新的数组
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
COW的缺点
1、内存占用问题:因为 CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对 象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对 象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比 如说 200M 左右,那么再写入 100M 数据进去,内存就会占用 300M,那么这个时候很有可能造成频繁的 minor GC 和 major GC。
2、数据一致性问题:CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用 CopyOnWrite 容器。
3.2.3、并发容器之ConcurrentLinkedQueue
ConcurrentLinkedQueue的基础节点是:Node,其中数据域item和netx指针都是被volatile修饰:
private static class Node<E> {
volatile E item;
volatile Node<E> next;
.......
}
ConcurrentLinkedQueue还有两个成员变量:头指针和尾指针。
private transient volatile Node<E> head;
private transient volatile Node<E> tail;
public ConcurrentLinkedQueue() {
head = tail = new Node<E>(null);
}
3.2.4、并发容器之BlockingQueue
最常用的"生产者-消费者"问题中,队列通常被视作线程间操作的数据容器,这样,可以对各个模块的业务功能进行解耦,生产者将“生产”出来的数据放置在数据容器中,而消费者仅仅只需要在“数据容器”中进行获取数据即可,这样生产者线程和消费者线程就能够进行解耦,只专注于自己的业务功能即可。阻塞队列(BlockingQueue)被广泛使用在“生产者-消费者”问题中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止 。
插入数据
add(E e) :往队列插入数据,当队列满时,插入元素时会抛出 IllegalStateException 异常;
offer(E e):当往队列插入数据时,插入成功返回true,否则则返回false。当队列满时不会抛出异常;
put(E e):当阻塞队列容量已经满时,往阻塞队列 插入数据的线程 会被阻塞,直至阻塞队列已经有空余的容量可供使用;
删除数据
remove(Object o):从队列中删除数据,成功则返回true,否则为false
take():当阻塞队列为空时,获取队头数据的线程会被阻塞;
poll(long timeout, TimeUnit unit):当阻塞队列为空时,获取 数据的线程 会被阻塞,另外,如果被阻塞的线程超过了给定的时长,该线程会退出
实现 BlockingQueue 接口的有ArrayBlockingQueue, DelayQueue, LinkedBlockingDeque, LinkedBlockingQueue, LinkedTransferQueue, PriorityBlockingQueue, SynchronousQueue,而这几种常见的阻塞队列也是在实际编程中会常用的,下面对这几种常见的阻塞队列进行说明:
(1)ArrayBlockingQueue
ArrayBlockingQueue是由数组实现的有界阻塞队列。该队列命令元素 FIFO(先进先出).
(2)LinkedBlockingQueue
LinkedBlockingQueue 是用链表实现的有界阻塞队列,同样满足 FIFO 的特性,与 ArrayBlockingQueue 相比起来具有更高的吞吐量,为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存。通常在创建 LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于 Integer.MAX_VALUE。
相同点:
ArrayBlockingQueue 和 LinkedBlockingQueue 都是通过 condition 通知机制来实现可阻塞式插入和删除元素,并满足线程安全的特性;
不同点:
1. ArrayBlockingQueue 底层是采用的数组进行实现,而 LinkedBlockingQueue 则是采用链表数据结构;
2. ArrayBlockingQueue 插入和删除数据,只采用了一个 lock,而 LinkedBlockingQueue 则是在插入和删除分别采用了putLock和takeLock,这样可以降低线程由于线程无法获取到 lock 而进入 WAITING 状态的可能性,从而提高了线程并发执行的效率。
(3)PriorityBlockingQueue
PriorityBlockingQueue 是一个支持优先级的无界阻塞队列。
3.2.5、并发容器之ThreadLocal
在多线程编程中通常解决线程安全的问题我们会利用 synchronzed 或者 lock 控制线程对临界区资源的同步顺序从而解决线程安全的问题,但是这种加锁的方式会让未获取到锁的线程进行阻塞等待,很显然这种方式的时间效率并不是很好。
线程安全问题的核心在于多个线程会对同一个临界区共享资源进行操作,那么,如果每个线程都使用自己的“共享资源”,各自使用各自的,又互相不影响到彼此即让多个线程间达到隔离的状态,这样就不会出现线程安全的问题。
ThreadLocal的数据其实都放在了 threadLocalMap 中,threadLocal 的 get,set 和 remove 方法实际上具体是通过 threadLocalMap 的 getEntry,set 和 remove 方法实现的。
3.3、Executors 线程池
在实际使用中,线程是很占用系统资源的,如果对线程管理不善很容易导致系统问题。因此,在大多数并发框架中都会使用线程池来管理线程,使用线程池管理线程主要有如下好处:
1、降低资源消耗。通过复用已存在的线程和降低线程关闭的次数来尽可能降低系统性能损耗;
2、提升系统响应速度。通过复用线程,省去创建线程的过程,因此整体上提升了系统的响应速度;
3、提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性。
3.3.1、线程池实现原理
ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
corePoolSize:表示核心线程池的大小。
maximumPoolSize:表示线程池能创建线程的最大个数.
keepAliveTime:空闲线程存活时间.
unit:时间单位.
workQueue:阻塞队列.
threadFactory:创建线程的工程类。
handler:饱和策略。当线程池的阻塞队列已满和指定的线程都已经开启,说明当前线程池已经处于饱和状态了,那么就需要采用一种策略来处理这种情况。采用的策略有这几种:
AbortPolicy: 直接拒绝所提交的任务,并抛出RejectedExecutionException异常;
CallerRunsPolicy:只用调用者所在的线程来执行任务;
DiscardPolicy:不处理直接丢弃掉任务;
DiscardOldestPolicy:丢弃掉阻塞队列中存放时间最久的任务,执行当前任务
3.3.2、线程池之ScheduledThreadPoolExecutor
3.3.3、FutureTask基本操作
在 Executors 框架体系中,FutureTask 用来表示可获取结果的异步任务。FutureTask 实现了 Future 接口,FutureTask 提供了启动和取消异步任务,查询异步任务是否计算结束以及获取最终的异步任务的结果的一些常用的方法。通过get()方法来获取异步任务的结果,但是会阻塞当前线程直至异步任务执行结束。一旦任务执行结束,任务不能重新启动或取消,除非调用runAndReset()方法。
(1) Future
Callable 接口相比于 Runnable 的一大优势是可以有返回结果,那这个返回结果怎么获取呢?就可以用 Future 类的 get 方法来获取 。因此,Future 相当于一个存储器,它存储了 Callable 的 call 方法的任务结果。除此之外,我们还可以通过 Future 的 isDone 方法来判断任务是否已经执行完毕了,还可以通过 cancel 方法取消这个任务,或限时获取任务的结果等,总之 Future 的功能比较丰富。
下面是使用线程池提交一个Callable类型的任务,然后通过Future来接受submit返回的结果:
public class FutureLearn {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService pool= Executors.newFixedThreadPool(5);
Future<String> future=pool.submit(new Callable<String>() {
@Override
public String call() throws Exception {
Thread.sleep(3000);
return "hello";
}
});
System.out.println(future.get());
pool.shutdown();
}
}
//其中Future的API:
get(): 获取结果,如果未执行完成,当前线程会处于阻塞状态;
isDone(): 判断是否执行完毕;
isCancelled(): 取消任务的执行
(2) FutureTask
除了用线程池的 submit 方法会返回一个 future 对象之外,同样还可以用 FutureTask 来获取 Future 类和任务的结果。既然 RunnableFuture 继承了 Runnable 接口和 Future 接口,而 FutureTask 又实现了 RunnableFuture 接口,所以 FutureTask 既可以作为 Runnable 被线程执行,又可以作为 Future 得到 Callable 的返回值。
//两种用法:
1、一种在线程池直接exec或者submit都行;
2、另一种写好之后放在new Thread()start;
import java.util.concurrent.*;
public class MyFutureTask {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//FutureTask单独使用
//FutureTask继承的是RunnableFuture,是和Runable是一个级别的,有run方法,但是直接使用run方法是跑在主线程上的
//要new Thread(myFutureTask).start()才行
FutureTask<String> futureTask=new FutureTask<>(new Callable<String>() {
@Override
public String call() throws Exception {
Thread.sleep(2000);
return "hello,world.I am FutureTask";
}
});
new Thread(futureTask).start();//直接作为Runnable执行
//FutureTask也可以配合线程池进行使用
ExecutorService pool= Executors.newFixedThreadPool(2);
FutureTask<String> futureTask1=new FutureTask<>(new Callable<String>() {
@Override
public String call() throws Exception {
Thread.sleep(1000);
return "在线程池中使用FutureTask";
}
});
pool.execute(futureTask1);
System.out.println(futureTask1.get());
System.out.println(futureTask.get());
pool.shutdown();
}
}
3.4、Atomic 原子类
在并发编程中很容易出现并发安全的问题,有一个很简单的例子就是多线程更新变量 i=1,比如多个线程执行 i++操作,就有可能获取不到正确的值,而这个问题,最常用的方法是通过 Synchronized 进行控制来达到线程安全的目的(关于 synchronized 可以看这篇文章)。但是由于 synchronized 是采用的是悲观锁策略,并不是特别高效的一种解决方案。实际上,在 J.U.C 下的 atomic 包提供了一系列的操作简单,性能高效,并能保证线程安全的类去更新基本类型变量,数组元素,引用类型以及更新对象中的字段类型。atomic 包下的这些类都是采用的是 乐观锁策略 去原子更新数据,在 java 中则是使用 CAS 操作具体实现。
3.4.1、CAS
使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。而 CAS 操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用 CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。
但是CAS存在的三大问题:
1、ABA问题:CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
2、循环时间长开销大:CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
3、只能保证一个共享变量的原子操作:对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。(JDK1.5之后,AtomicReference可以解决这个问题。)
3.4.2、原子更新基本类型
AtomicBoolean:以原子更新的方式更新 boolean;
AtomicInteger:以原子更新的方式更新 Integer;
AtomicLong:以原子更新的方式更新 Long;
3.4.3、原子更新数组类型
AtomicIntegerArray:原子更新整型数组中的元素;
AtomicLongArray:原子更新长整型数组中的元素;
AtomicReferenceArray:原子更新引用类型数组中的元素
3.4.4、原子更新引用类型
AtomicReference:原子更新引用类型;
AtomicReferenceFieldUpdater:原子更新引用类型里的字段;
AtomicMarkableReference:原子更新带有标记位的引用类型;
Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作(比如数组)。
3.4.5、原子更新字段类型
AtomicIntegeFieldUpdater:原子更新整型字段类;
AtomicLongFieldUpdater:原子更新长整型字段类;
AtomicStampedReference:原子更新引用类型,这种更新方式会带有版本号。而为什么在更新的时候会带有版本号,是为了解决 CAS 的 ABA 问题;
3.5、Tools 并发工具
3.5.1、并发工具类-CountDownLatch,CyclicBarrier
1、倒计时器CountDownLatch
在多线程协作完成业务功能时,有时候需要等待其他多个线程完成任务之后,主线程才能继续往下执行业务功能,在这种的业务场景下,通常可以使用 Thread 类的 join 方法,让主线程等待被 join 的线程执行完之后,主线程才能继续往下执行。当然,使用线程间消息通信机制也可以完成。其实,java 并发工具类中为我们提供了类似“倒计时”这样的工具类,可以十分方便的完成所说的这种业务场景。
为了能够理解 CountDownLatch,举一个很通俗的例子,运动员进行跑步比赛时,假设有 6 个运动员参与比赛,裁判员在终点会为这 6 个运动员分别计时,可以想象没当一个运动员到达终点的时候,对于裁判员来说就少了一个计时任务。直到所有运动员都到达终点了,裁判员的任务也才完成。这 6 个运动员可以类比成 6 个线程,当线程调用 CountDownLatch.countDown 方法时就会对计数器的值减一,直到计数器的值为 0 的时候,裁判员(调用 await 方法的线程)才能继续往下执行。
//场景1:一个线程等待其他5个线程完成。
package org.example.concurrent;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 本类用于演示 CountDownLatch 的一个用法
* 一个线程等待其他线程都完成后继续进行后续的工作
* 这里假设主线程需要等待 5 个子线程都完成检查后再进行到下一个阶段
*
* @author Catch
*/
public class CountDownLatchUsage1 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch=new CountDownLatch(5);
ExecutorService executorService = Executors.newFixedThreadPool(5);
for (int i = 0; i < 5; i++) {
int no=i+1;
Runnable r=()->{
try {
Thread.sleep((long) (Math.random()*10000));
System.out.println("No."+no+"完成了检查工作");
} catch (InterruptedException e) {
e.printStackTrace();
}finally{
latch.countDown();
}
};
executorService.submit(r);
}
System.out.println("等待 5 个子线程完成检查工作...");
latch.await();
System.out.println("所有子线程都完成了检查工作, 开始进入下一个阶段.");
}
}
//场景2:多个线程等一个线程完成
package org.example.concurrent;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 本类用于演示 CountDownLatch 的一个用法
* 多个线程等待一个线程完成工作,然后多个线程同时开始工作
* 这里假设我们要进行一次压测, 首先准备 1000 个线程,
* 当 1000 个线程都准备完毕后同时发起请求,来达到模拟高峰期用户访问的效果
*
* @author Catch
*/
public class CountDownLatchUsage2 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch begin=new CountDownLatch(1);
CountDownLatch end = new CountDownLatch(1000);
ExecutorService executorService = Executors.newFixedThreadPool(1000);
for (int i = 0; i < 1000; i++) {
Runnable r=()->{
try {
// 可以在 begin.await() 前进行一些准备或初始化工作
begin.await();
// 模拟压测耗时, 有的快有的慢
Thread.sleep((long) (Math.random() * 5000));
System.out.println(Thread.currentThread().getName()+"发起请求...");
} catch (InterruptedException e) {
e.printStackTrace();
}finally{
end.countDown();
}
};
executorService.submit(r);
}
Thread.sleep(3000);
System.out.println("所有线程已准备完毕, 压测开始!");
begin.countDown();
end.await();
System.out.println("所有线程都请求完毕, 压测结束!");
}
}
2、循环栅栏CyclicBarrier
为了理解 CyclicBarrier,这里举一个通俗的例子。开运动会时,会有跑步这一项运动,我们来模拟下运动员入场时的情况,假设有 6 条跑道,在比赛开始时,就需要 6 个运动员在比赛开始的时候都站在起点了,裁判员吹哨后才能开始跑步。跑道起点就相当于“barrier”,是临界点,而这 6 个运动员就类比成线程的话,就是这 6 个线程都必须到达指定点了,意味着凑齐了一波,然后才能继续执行,否则每个线程都得阻塞等待,直至凑齐一波即可。cyclic 是循环的意思,也就是说 CyclicBarrier 当多个线程凑齐了一波之后,仍然有效,可以继续凑齐下一波。
package com.thread;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class CyclicBarrierTest {
public static void main(String[] args) {
ExecutorService service = Executors.newCachedThreadPool();
final CyclicBarrier cb = new CyclicBarrier(3);//创建CyclicBarrier对象并设置3个公共屏障点
for(int i=0;i<3;i++){
Runnable runnable = new Runnable(){
public void run(){
try {
Thread.sleep((long)(Math.random()*10000));
System.out.println("线程" + Thread.currentThread().getName() +
"即将到达集合地点1,当前已有" + cb.getNumberWaiting() + "个已经到达,正在等候");
cb.await();//到此如果没有达到公共屏障点,则该线程处于等待状态,如果达到公共屏障点则所有处于等待的线程都继续往下运行
Thread.sleep((long)(Math.random()*10000));
System.out.println("线程" + Thread.currentThread().getName() +
"即将到达集合地点2,当前已有" + cb.getNumberWaiting() + "个已经到达,正在等候");
cb.await();
Thread.sleep((long)(Math.random()*10000));
System.out.println("线程" + Thread.currentThread().getName() +
"即将到达集合地点3,当前已有" + cb.getNumberWaiting() + "个已经到达,正在等候");
cb.await();
} catch (Exception e) {
e.printStackTrace();
}
}
};
service.execute(runnable);
}
service.shutdown();
}
}
线程pool-1-thread-1即将到达集合地点1,当前已有0个已经到达,正在等候
线程pool-1-thread-3即将到达集合地点1,当前已有1个已经到达,正在等候
线程pool-1-thread-2即将到达集合地点1,当前已有2个已经到达,正在等候
线程pool-1-thread-3即将到达集合地点2,当前已有0个已经到达,正在等候
线程pool-1-thread-2即将到达集合地点2,当前已有1个已经到达,正在等候
线程pool-1-thread-1即将到达集合地点2,当前已有2个已经到达,正在等候
线程pool-1-thread-1即将到达集合地点3,当前已有0个已经到达,正在等候
线程pool-1-thread-3即将到达集合地点3,当前已有1个已经到达,正在等候
线程pool-1-thread-2即将到达集合地点3,当前已有2个已经到达,正在等候
3.5.2、并发工具类-Semaphore,Exchanger
1、信号量Semaphore
Semaphore 可以理解为信号量,用于控制资源能够被并发访问的线程数量,以保证多个线程能够合理的使用特定资源。Semaphore 就相当于一个许可证,线程需要先通过 acquire 方法获取该许可证,该线程才能继续往下执行,否则只能在该方法出阻塞等待。当执行完业务功能后,需要通过release()方法将许可证归还,以便其他线程能够获得许可证继续执行。
public class SemaphoreDemo {
//表示老师只有10支笔
private static Semaphore semaphore = new Semaphore(5);
public static void main(String[] args) {
//表示50个学生
ExecutorService service = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
service.execute(() -> {
try {
System.out.println(Thread.currentThread().getName() + " 同学准备获取笔......");
semaphore.acquire();
System.out.println(Thread.currentThread().getName() + " 同学获取到笔");
System.out.println(Thread.currentThread().getName() + " 填写表格ing.....");
TimeUnit.SECONDS.sleep(3);
semaphore.release();
System.out.println(Thread.currentThread().getName() + " 填写完表格,归还了笔!!!!!!");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
service.shutdown();
}
}
输出结果:
pool-1-thread-1 同学准备获取笔......
pool-1-thread-1 同学获取到笔
pool-1-thread-1 填写表格ing.....
pool-1-thread-2 同学准备获取笔......
pool-1-thread-2 同学获取到笔
pool-1-thread-2 填写表格ing.....
pool-1-thread-3 同学准备获取笔......
pool-1-thread-4 同学准备获取笔......
pool-1-thread-3 同学获取到笔
pool-1-thread-4 同学获取到笔
pool-1-thread-4 填写表格ing.....
pool-1-thread-3 填写表格ing.....
pool-1-thread-5 同学准备获取笔......
pool-1-thread-5 同学获取到笔
pool-1-thread-5 填写表格ing.....
pool-1-thread-6 同学准备获取笔......
pool-1-thread-7 同学准备获取笔......
pool-1-thread-8 同学准备获取笔......
pool-1-thread-9 同学准备获取笔......
pool-1-thread-10 同学准备获取笔......
2、线程间交换数据的工具Exchanger
Exchanger 是一个用于线程间协作的工具类,用于两个线程间能够交换。它提供了一个交换的同步点,在这个同步点两个线程能够交换数据。具体交换数据是通过 exchange 方法来实现的,如果一个线程先执行 exchange 方法,那么它会同步等待另一个线程也执行 exchange 方法,这个时候两个线程就都达到了同步点,两个线程就可以交换数据。
public class ExchangerDemo {
private static Exchanger<String> exchanger = new Exchanger();
public static void main(String[] args) {
//代表男生和女生
ExecutorService service = Executors.newFixedThreadPool(2);
service.execute(() -> {
try {
//男生对女生说的话
String girl = exchanger.exchange("我其实暗恋你很久了......");
System.out.println("女孩儿说:" + girl);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
service.execute(() -> {
try {
System.out.println("女生慢慢的从教室你走出来......");
TimeUnit.SECONDS.sleep(3);
//男生对女生说的话
String boy = exchanger.exchange("我也很喜欢你......");
System.out.println("男孩儿说:" + boy);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
}
输出结果:
女生慢慢的从教室你走出来......
男孩儿说:我其实暗恋你很久了......
女孩儿说:我也很喜欢你......
4、并发实践
https://juejin.cn/post/6844903602469732360#heading-5
4.1、并发实践1-wait/notify 的消息通知机制
4.2、并发实践2-使用 Lock 中 Condition 的 await/signalAll 实现生产者-消费者
4.3、并发实践3-BlockingQueue 实现生产者-消费者
参考
1:https://juejin.cn/post/6844903600309846023
2:https://mp.weixin.qq.com/s?__biz=MjM5NjQ5MTI5OA==&mid=2651749434&idx=3&sn=5ffa63ad47fe166f2f1a9f604ed10091&chksm=bd12a5778a652c61509d9e718ab086ff27ad8768586ea9b38c3dcf9e017a8e49bcae3df9bcc8&scene=38#wechat_redirect
3:https://github.com/CL0610/Java-concurrency?tab=readme-ov-file