一、概述
- JUC是JDK1.5中提供的一套并发包及其子包:
java.util.concurrent,java.util.concurrent.atomic,java.util.concurrent.lock- JUC中包含了5套接口:
BlockingQueue,ConcurrentMap,ExecutorService,Lock,Atomic
二、BlockingQueue - 阻塞式队列
2.1、概述
- 特征:阻塞、FIFO(First In First Out))
- BlockingQueue不同于之前学习的Queue,BlockingQueue不能扩容。即BlockingQueue在使用的时候指定的容量是多少就是多少不能改变
- 当队列已满的时候,试图放入元素的线程会被阻塞;当队列为空的时候,试图获取元素的线程会被阻塞
- 阻塞式队列中不允许元素为null
2.2、常见的实现类
1、ArrayBlockingQueue - 阻塞式顺序队列
- 底层依靠数组来存储数据
- 在使用的时候需要指定容量
2、LinkedBlockingQueue - 阻塞式链式队列
- 底层依靠单向节点来存储数据
- 在使用的时候可以指定容量也可以不指定。如果制定了容量,则容量不可变。如果没有指定容量,则容量是Integer.MAX_VALUE,即231-1。此时因为这个容量相对较大,一般认为队列是无限的
3、PriorityBlockingQueue - 具有优先级的阻塞式队列
- 在使用的时候可以不指定容量。如果不指定,则默认初始容量为11 - 在容量不够的时候,会进行扩容
- 底层依靠数组来存储数据 PriorityBlockingQueue 会对放入其中的元素进行自然排序,要求>3. 元素对应的类必须实现Comparable接口,覆盖compareTo方法
- 如果需要给队列来单独指定比较规则,那么可以传入Comparator对象
- 迭代遍历不保证排序
4、SynchronousQueue - 同步队列
- 这个队列在使用的时候不需要指定容量,容量默认为1且只能为1
2.3、扩展:BlockingDeque - 阻塞式双向队列
- 允许两端放两端拿
2.4、代码实现
public class BlockingQueueDemo {
public static void main(String[] args) throws InterruptedException {
// 构建队列
ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(5);
// 添加元素
queue.add("a");
queue.add("a");
queue.add("a");
queue.add("a");
queue.add("a");
// 队列已满
// 抛出IllegalStateException
// queue.add("b");
// 返回false
// boolean r = queue.offer("c");
// System.out.println(r);
// 产生阻塞
// queue.put("d");
// 定时阻塞
boolean r = queue.offer("e", 5, TimeUnit.SECONDS);
System.out.println(r);
System.out.println(queue);
}
}
三、ConcurrentMap - 并发映射
3.1、概述
- ConcurrentMap是JDK1.5提供的一套用于应对高并发以及保证数据安全的映射机制
- ConcurrentMap包含了ConcurrentHashMap和ConcurrentNavigableMap
3.2、常见的实现类
1、ConcurrentHashMap - 并发哈希映射
- 底层是基于数组+链表来进行存储。数组的每一个位置称之为是一个桶(bucket),每一个桶中维系一个链表
- 默认初始容量是16,默认加载因子是0.75。如果超过加载因子,进行扩容
- ConcurrentHashMap的容量一定是2n的形式,但是最大容量为2^30, 即数组的长度不能超过2次方30
- 从JDK1.8开始,ConcurrentHashMap引入了红黑树机制:当某一个桶中的元素个数达到8个的时候,这个桶中的链表就会扭转成一棵红黑树;如果这个桶中红黑树的节点个数不足7个的时候,这棵红黑树会再扭转回链表。在ConcurrentHashMap中,启用红黑树机制的前提是容量至少达到64
- ConcurrentHashMap是一个异步线程安全的映射。通过分段/桶锁机制来保证线程安全。即一个线程访问这个映射中的键值对的时候,会将这个键值对所在桶给锁住,此时其他线程依然可以访问其他的桶中的键值对
- ConcurrentHashMap依然采用锁机制来保证线程安全,但是锁在使用的时候会带来非常大的开销(CPU的独占,线程上下文的调度、线程状态切换等),所以在JDK1.8中,ConcurrentHashMap引入了无锁算法CAS(Compare And Swap - 比较和交换)。因为CAS涉及到了线程的重新调度问题,所以CAS需要结合具体的CPU内核架构来设计完成,因此Java无法完成这个过程。JDK中的CAS过程底层是依靠C语言完成的
- ConcurrentHashMap的使用方法和HashMap一致,之前使用HashMap的地方都可以替换为ConcurrentHashMap。实际生产过程中,如果不考虑安全那么使用HashMap;如果需要保证数据安全,那么使用ConcurrentHashMap
2、ConcurrentNavigableMap - 并发导航映射
- ConcurrentNavigableMap提供了用于截取子映射的方法
- 实现类:ConcurrentSkipListMap - 并发跳跃表映射,底层是基于跳跃表来实现
- 跳跃表
a 针对有序元素来使用
b 适合于查询多增删少的场景
c 跳跃表可以经过多层提取,但是规定最上层的跳跃表的元素个数不能少于2个
d 典型的"以空间换时间"的产物
e 当新添元素的时候,新添的元素是否要提取到上层的跳跃表中,要遵循"抛硬币"原则
f 时间复杂度是O(logn),空间复杂度是O(n)
3.3、代码实现
public class ConcurrentNavigableMapDemo {
public static void main(String[] args) {
// ConcurrentHashMap
// 实现类ConcurrentSkipListMap - 并发跳跃表映射
ConcurrentNavigableMap<String, Integer> map = new ConcurrentSkipListMap<>();
map.put("Mike", 90);
map.put("Grace", 75);
map.put("Cathy", 48);
map.put("John", 70);
map.put("Sam", 81);
map.put("Tony", 60);
map.put("Rose", 49);
System.out.println(map);
// 从头开始截取到指定位置
System.out.println(map.headMap("John"));
// 从指定位置开始截取到尾部
System.out.println(map.tailMap("Mike"));
// 截取指定范围的数据
System.out.println(map.subMap("Grace", "Sam"));
}
}
四、ExecutorService - 执行器服务
4.1、概述
- 本质上就是一个线程池。意义:减少线程的创建和销毁,减少服务器资源的浪费,做到线程的复用
- 线程池在刚定义的时候,里面是空的,没有任何线程,如果接收到一个请求,线程池中就会创建一个线程(core thread - 核心线程)用于处理这个请求
- 核心线程用完之后不会销毁而是会去等待下一个请求
- 在定义线程池的时候,需要同时给定核心线程的数量
- 在核心线程达到指定数量之前,每次来的请求都会触发创建一个新的核心线程
- 如果核心线程被全部占用,那么后来的线程就会放到工作队列(work queue)中来临时存储。工作队列本质上是一个阻塞式队列
- 如果工作队列被全部占用,那么后来的请求会被交给临时线程(temporary thread)来处理
- 在定义线程池的时候,需要同时给定临时线程的数量
- 临时线程在处理完请求之后,会存活指定的一段时间。如果在这段时间内接收到新的请求,那么临时线程会继续处理新的请求而暂时不会被销毁;如果超过这段时间临时线程没有接收到新的请求,那么这个临时线程就会被销毁
- 如果临时线程被全部占用,那么后来的请求会交给拒绝执行处理器(RejectedExecutionHandler)来进行拒绝处理
4.2、常见的实现类
1、ThreadPoolExecutor - 线程池
- 线程池。意义:减少线程的创建和销毁,减少服务器资源的浪费,做到线程的复用。
代码实现
public class ExecutorServiceDemo {
public static void main(String[] args) {
// 构建线程池
// int corePoolSize - 核心线程数量
// int maximumPoolSize - 最大线程数量 = 核心线程数 + 临时线程数
// long keepAliveTime - 临时线程用完之后的存活时间
// TimeUnit unit - 时间单位
// BlockingQueue<Runnable> workQueue - 工作队列
// handler - 拒绝执行处理器 - 如果有具体的拒绝流程,那么需要覆盖这个接口
ExecutorService es = new ThreadPoolExecutor(
5, // 5个核心线程
10, // 5个临时线程
5, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(5),
// 实际过程中,会有一套明确的拒绝流程,例如记录日志,跳转页面等
(r, e) -> System.out.println("拒绝执行线程:" + r)
);
// RejectedExecutionHandler handler = (r, e) -> System.out.println("拒绝执行线程:" + r);
// new Thread(new ExecutorThread()).start();
// 可以通过线程池来执行这个线程
// es.execute(new ExecutorThread ());
// es.submit(new ExecutorThread());
// 5个核心线程,工作队列为5,5个临时线程
for (int i = 0; i < 18; i++) {
es.submit(new ExecutorThread());
}
// 关闭线程池
es.shutdown();
}
}
class ExecutorThread implements Runnable {
@Override
public void run() {
try {
System.out.println("hello");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2、ScheduledExecutorSevice - 定时调度执行器服务。
- 能够起到定时调度的效果
代码实现
public class ScheduledExecutorServiceDemo {
public static void main(String[] args) {
// ScheduledExecutorService ses = Executors.newScheduledThreadPool(5);
ScheduledExecutorService ses = new ScheduledThreadPoolExecutor(5);
// 延时执行
// ses.schedule(new ScheduleThread(), 5, TimeUnit.SECONDS);
// 每隔5s执行一次
// 从上次的开始来计算下一次的启动时间
// 实际间隔时间 = max(指定时间, 线程执行时间);
ses.scheduleAtFixedRate(new ScheduleThread(), 0,
5, TimeUnit.SECONDS);
// 从上次的结束来计算下一次的启动时间
// 实际间隔时间 = 指定时间 + 线程执行时间
// ses.scheduleWithFixedDelay(new ScheduleThread(), 0,
// 5, TimeUnit.SECONDS);
}
}
class ScheduleThread implements Runnable {
@Override
public void run() {
try {
System.out.println("hello");
Thread.sleep(8000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
3、ForkJoinPool - 分叉合并池
- 分叉:将一个大的任务拆分成多个小的任务分配给多个线程来执行
- 合并:将拆分出去的小的任务的执行结果进行汇总
- 分叉合并会产生大量的线程去抢占CPU,所以能非常有效的提高CPU的利用率(100%~103%)。同时导致其他的线程会被挤占,因此分叉合并适合于放在空闲的时间进行
- 分叉合并底层是依靠Callable线程来实现的
- 在数据量比较大的情况下,分叉合并的效率是高于循环的;如果数据量比较小,循环的效率要高于分叉合并
- 分叉合并在底层实现过程中,为了保证效率,底层还使用了"work-stealing"(工作窃取)策略:当一个核上的任务执行完成之后,这个核并不会闲下来,而是会随机扫描一个核,然后会从这个核的任务队列尾端来"偷取"一个任务回来执行
代码实现
public class ForkJoinPoolDemo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 求1-100000000000L的和
long begin = System.currentTimeMillis();
// 主函数所在的类默认是一个线程类 - 主线程
// 现在的CPU是多核的 - 一个线程只能落地到CPU核上
// 39031
// long sum = 0;
// for (long i = 1; i <= 100000000000L; i++) {
// sum += i;
// }
// System.out.println(sum);
// 25506
ForkJoinPool pool = new ForkJoinPool();
Future<Long> f = pool.submit(new Sum(1, 100000000000L));
System.out.println(f.get());
pool.shutdown();
long end = System.currentTimeMillis();
System.out.println(end - begin);
}
}
class Sum extends RecursiveTask<Long> {
private long start;
private long end;
public Sum(long start, long end) {
this.start = start;
this.end = end;
}
// 分叉合并的逻辑就是覆盖在这个方法中
@Override
protected Long compute() {
// 拆分,如果拆分出去的范围比较大,那么可以继续拆分
// 如果拆分出去的范围比较小,那么就将这个小范围内的数字进行求和
if (end - start <= 10000) {
long sum = 0;
for (long i = start; i <= end; i++) {
sum += i;
}
return sum;
} else {
long mid = (start + end) / 2;
Sum left = new Sum(start, mid);
Sum right = new Sum(mid + 1, end);
// 分叉
left.fork();
right.fork();
// 合并
return left.join() + right.join();
}
}
}
五、Lock - 锁
5.1、概述
- 在JDK1.5中,提供了一套Lock机制来取代synchronized。相对synchronized而言,Lock更加的灵活和精细
- 在使用synchronized的过程中,需要确定一个锁对象。这个锁对象在某些情况下不好确定,如果锁对象不统一,甚至会导致死锁的产生
- 重入锁和非重入锁
a 当锁资源被释放之后,线程依然可以抢占资源重新加锁重新使用
b 当锁资源被释放之后,这个锁就不能被二次抢占- 共享锁和排他锁
a 实际生产过程中,绝大部分的锁都是排他锁
b 如果产生并发操作,且并发的这些操作之间不会产生数据安全问题,那么可以考虑使用共享锁- 读写锁
a 读锁:允许多个线程同时读,但是不允许线程写 - 共享锁
b 写锁:只允许一个线程写,不允许线程读 - 排他锁- 自旋锁和其他排他锁
a 自旋锁本质上也是一种排他锁
b 对于排他锁而言,当线程发现资源被锁住的时候,这些线程就会陷入阻塞状态,直到锁资源被释放,这些线程才会被唤醒来试图抢占锁资源
c 对于自旋锁而言,当线程发现资源被锁住的时候,这个线程不会陷入阻塞状态,而是会持续判断锁资源是否被释放
d 自旋锁的效率要高于其他的排他锁,因为自旋锁没有线程状态的切换;同时自旋锁会持续占用CPU资源- 锁的公平和非公平原则
a 在资源有限的情况下,线程之间的实际抢占次数并不均等,这种现象称之为非公平策略
b 在公平策略的前提下,各个线程之间并不是直接抢占资源而是抢占入队顺序。当队列中有线程的时候,会自动的将队头的线程取出来使用资源;其他的线程依然可以抢占入队顺序。在这种策略下,各个线程之间的实际执行次数是大致相等的
c 相对而言,非公平策略的效率要稍微高一些
d 如果不指定,锁默认使用的是非公平策略
5.2、代码实现
public class LockDemo {
static int i = 0;
public static void main(String[] args) throws InterruptedException {
// Lock lock = new ReentrantLock();
// 获取写锁
ReadWriteLock rwl = new ReentrantReadWriteLock();
Lock lock = rwl.writeLock();
new Thread(new Add(lock)).start();
new Thread(new Add(lock)).start();
// main所在的类默认是是一个线程类 - 主线程
// 主线程在执行过程中需要启动Add线程
// 线程启动需要花费时间
// 主线程会在Add线程启动期间先抢占执行权
// 需要的结果:等Add线程执行完,主线程再打印
// 也就意味着主线程即使抢到执行权,也需要阻塞
Thread.sleep(3000);
System.out.println(i);
}
}
class Add implements Runnable {
private final Lock lock;
public Add(Lock lock) {
this.lock = lock;
}
@Override
public void run() {
// 加锁
lock.lock();
for (int i = 0; i < 100000; i++) {
LockDemo.i++;
}
// 解锁
lock.unlock();
}
}
5.3、其他
- CountDownLatch:闭锁/线程递减锁。对线程来进行计数的,在计数归零之前,线程会陷入阻塞。当计数归零的时候,会放开阻塞 - 一组线程结束之后,另一组线程开始执行
- CyclicBarrier:栅栏。对线程进行计数的。在计数归零之前,线程会陷入阻塞。当计数归零的时候,会放开阻塞 - 所有线程到达同一个点之后再分别继续执行
- Exchanger:交换机。只能用于交换两个线程之间的数据
- Semaphore:信号量。线程需要先获取信号之后才能执行。当信号归零之后,后来的线程会被阻塞,直到有信号被释放,那么后来的线程才能获取信号执行逻辑 - 实际生产过程中,会利用信号量来进行限流
六、Atomic - 原子性
6.1、概述
- 原子性操作实际上针对属性来提供了大量的线程安全的方法。在JDK1.8中,采用了CAS+volatile机制来保证是属性的线程安全
- volatile是Java中的关键字之一,是Java提供的一种轻量级的线程间的通信机制
a 保证线程的可见性。当共享资源发生变化的时候,其他线程能够立即感知到这种变化并且做出对应的操作,这个过程称之为可见性
b 不保证线程的原子性。原子性指的是线程的执行过程不可分割。换言之,就是线程的执行过程不会被打断不会被抢占。加锁实际上保证线程的原子性
c 禁止指令重排
6.2、代码实现
public class VolatileDemo {
public static void main(String[] args) throws InterruptedException {
Data d = new Data();
d.i = 10;
// 线程A
new Thread(() -> {
System.out.println("A线程启动~~~");
while (d.i == 10) ;
System.out.println("A线程结束~~~");
}).start();
// 延迟B的启动,给A线程启动和执行留下充足的时间
Thread.sleep(3000);
// 线程B
new Thread(() -> {
System.out.println("B线程启动~~~");
d.i = 12;
System.out.println("B线程结束~~~");
}).start();
}
}
class Data {
volatile int i;
}
• 由 ChiKong_Tam 写于 2020 年 12 月 28 日