简单谈谈Juc并发编程——上
前言
本课程学习与B站狂神说Java的JUC并发编程
本课程的代码都放在了我的个人gitee仓库上了
什么是JUC?
- java.util.concurrent juc
- java.util.concurrent.atomic 原子性
- java.util.concurrent.locks 锁
平时业务中可能用Thread
或者像Runnable接口实现,没有返回值,而且效率相对于callable较低
java.util.concurrent
Interface Callable
进程与线程
我们都知道计算机的核心是CPU,它承担了所有的计算任务,而操作系统是计算机的管理者,它负责任务的调度,资源的分配和管理,统领整个计算机硬件
- 进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体
- 线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。一个进程可以有一个或多个线程,各个线程之间共享程序的内存空间(也就是所在进程的内存空间)
进程:一个程序,QQ.exe Music.exe 程序的集合;
一个进程往往可以包含多个线程,至少包含一个!
Java默认有几个线程? 2 个 main、GC
线程:开了一个进程 Typora,写字,自动保存(线程负责的)
对于Java而言:Thread、Runnable、Callable
Java 真的可以开启线程吗? 开不了
1 | java复制代码// 本地方法,调用了底层的C++,因为java运行在jvm虚拟机上,Java无法直接操作硬件 |
线程有几个状态?
Thread.State可以看到,是一个枚举
1 | java复制代码 public enum State { |
并发和并行
- 并发是指两个或多个事件在同一时间间隔发生->交替进行
- 一核cpu,模拟出来多个线程,快速交替运行
- 并行是指两个或者多个事件在同一时刻发生 ->同时进行
- 多核cpu,多个线程同时执行,线程池
并发编程的目标是充分的利用cpu的每一个核,以达到最高的处理性能
1 | java复制代码 //获取cpu的核数 |
wait/sleep 区别
1、来自不同的类
wait => Object
sleep => Thread
2、关于锁的释放
wait 会释放锁
sleep 睡觉了,抱着锁睡觉,不会释放!
3、使用的范围是不同的
wait必须在synchronized同步代码块中使用
sleep可以在任何地方睡
4、是否需要捕获异常(存疑)
throws InterruptedException
wait 也需要捕获异常(实测提示需要捕获异常,且不捕获会报错!)
sleep 必须要捕获异常
Lock锁
只要是并发编程,就一定需要有锁!
传统的synchronized锁
此处不谈线程池,讲普通的方法
解耦线程类,不必要再去写一个单独的线程类继承Runnable接口
而是使用lambda表达式()->{}
实现Runnable接口来创建线程
1 | java复制代码 new Thread(()->{ |
然后synchronized锁方法上,锁住这个对象
1 | java复制代码 public synchronized void sale(){ |
还是老生常谈的卖票
1 | java复制代码public static void main(String[] args) throws InterruptedException { |
Lock锁
java.util.concurrent.locks.Lock
的Lock是一个接口
建议的做法是始终立即跟随
lock
与try
块的通话最常见的是在之前/之后的上锁
lock.lock();
和解锁lock.unlock()
它有几个实现类:
- ReentrantLock可重入锁
- ReentrantReadWriteLock.ReadLock读锁
- ReentrantReadWriteLock.WriteLock写锁
我们来看看可重入锁ReentrantLock
的构造器
1 | java复制代码 //默认创建非公平锁Nonfair |
公平锁:公平:需要先来后到
非公平锁:不公平:可以插队 (默认)
使用三部曲
Lock lock=new ReentrantLock();
实例化锁对象lock.lock();
上锁- 在finally中
lock.unlock();
解锁
1 | java复制代码 static class Ticket{ |
synchronized和lock的区别
- Synchronized 内置的Java关键字, Lock 是一个Java类
- Synchronized 无法判断获取锁的状态,Lock 可以判断是否获取到了锁
- Synchronized 会自动释放锁,lock 必须要手动释放锁!如果不释放锁,死锁
- 如果在Synchronized中出现异常,会自动释放锁
- Synchronized 线程 1(获得锁,阻塞)、线程2(等待,傻傻的等);Lock锁就不一定会等待下去;
lock.tryLock()
尝试获取锁 ,获取不到就自己掉头走了不会等下去
- Synchronized 可重入锁,不可以中断的,非公平;Lock ,可重入锁,可以判断锁,非公平(可以自己设置)
- Synchronized 适合锁少量的代码同步问题,Lock 适合锁大量的同步代码!
生产者、消费者问题
面试:单例模式、排序算法、生产消费者、死锁
老版的synchronized实现
当num为0时,消费者等待,生产者生成消息
当num>=0时,生产者等待,消费者进行消费
我们先来看一下这段问题代码
1 | java复制代码 public static void main(String[] args) throws InterruptedException { |
这个时候代码会正确运行嘛,结论是会的
那如果我们放置多个producer和consumer呢?
1 | java复制代码 new Thread(()->{ |
可以看见有很大几率会出问题
ConsumerA 消费者消费了一条消息,此时num=-92
ConsumerA 消费者消费了一条消息,此时num=-93
ConsumerA 消费者消费了一条消息,此时num=-94
ConsumerA 消费者消费了一条消息,此时num=-95
ConsumerA 消费者消费了一条消息,此时num=-96
ConsumerB 消费者消费了一条消息,此时num=-97
ProducerB 生产者生产了一条消息,此时num=-96正在等待
这里出现的就是虚假唤醒
查看Object的wait方法的api文档可以看见
线程也可以唤醒,而不会被通知,中断或超时,即所谓的虚假唤醒
比如说买货,如果商品本来没有货物,突然进了一件商品,这是所有的线程都被唤醒了 ,但是只能一个人买,所以其他人都是假唤醒,获取不到对象的锁
虽然这在实践中很少会发生,但应用程序必须通过测试应该使线程被唤醒的条件来防范,并且如果条件不满足则继续等待。
换句话说,等待应该总是出现在循环中
为什么if块会存在虚假唤醒的情况?
在if块中使用wait方法,是非常危险的,因为一旦线程被唤醒,并得到锁,就不会再判断if条件,而执行if语句块外的代码
所以建议,凡是先要做条件判断,再wait的地方,都使用while循环来做
1 | java复制代码 synchronized (obj) { |
所以我们将原有的代码将if改为while
1 | java复制代码 public synchronized void pro() throws InterruptedException { |
juc版的生产者和消费者实现
使用Lock和Condition两个接口
其中lock对象我们这使用ReentrantLock
实例化
condition对象使用lock.newCondition()
获取
Condition实现可以提供Object监视器方法的行为和语义,例如有保证的通知顺序,或者在执行通知时不需要
锁定
一个Condition实例本质上绑定到一个锁。 要获得特定Condition实例的Condition实例,请使用其
newCondition()方法
Condition因素出Object监视器方法( wait,notify 和 notifyAll )成不同的对象,以得到具有多个等待集的每个对象,通过将它们与使用任意的组合的效果Lock个实现。
Lock替换synchronized方法和语句的使用, Condition取代了对象监视器方法的使用。
1 | java复制代码 static class Data{ |
也许会有人问:既然synchronized更简洁,这里反而还多加了一层condition,岂不是更麻烦了?
当然不是
Lock+Condition与synchronized的区别
设置多个Condition监视器可以实现精准的通知和唤醒线程
个人理解:不用就等待,需要则唤醒
Condition监视器的精准唤醒
1 | java复制代码import java.util.concurrent.locks.Condition; |
结果
AAAAAA
BBBBBB
CCCCCC
AAAAAA
BBBBBB
CCCCCC
其实condition还有awaitNanos超时等待和awaitUntil超时时间等待,下文ArrayBlockingQueue会讲到
八锁问题
1.锁对象的同步锁synchronized
1 | java复制代码import java.util.concurrent.TimeUnit; |
2.synchronized和普通方法不同步
1 | java复制代码import java.util.concurrent.TimeUnit; |
3.锁class的同步锁synchronized
1 | java复制代码import java.util.concurrent.TimeUnit; |
4.静态synchronized和普通synchronized锁
1 | java复制代码import java.util.concurrent.TimeUnit; |
总结
synchronized在代码块或者普通方法中,锁住的是方法的调用者(实例化对象)
在静态方法中,锁住的类的class
对象锁和class锁不同,所以不需要同步
不安全的List类
我们之前使用的集合都是在单线程情况下,所以没有出现问题,但是其实很多都是不安全的
例如我们平时经常使用的ArrayList
1 | java复制代码 //多线程下的ArrayList插入报错 |
单线程玩多了,乍一看没什么问题,可是这是在多线程的情况下,就会出现并发修改异常
如何优化让他变成线程安全的呢?
1.使用Vector代替
Vector的增删改查都加上了同步锁synchronized,使得线程安全
但是效率怎么样呢?我们下文再说
1 | java复制代码 //多线程下的Vector没有报错,因为底层加了synchronized |
2.使用Collections转synchronizedList
使用Collections.synchronizedList()方法将普通list转为线程安全的list
1 | java复制代码 //如果我想用安全的list呢 |
如何既保证线程安全,效率也高呢
使用JUC的CopyOnWriteArrayList
JUC:使用CopyOnWriteArrayList,解决并发
COW :写入时复制,一种优化策略
list是唯一固定的,多个线程读取时是固定的,但是写入时有可能会覆盖
COW写入时避免了覆盖,防止了数据问题
1 | java复制代码 /** |
怎么解决的?
写入时先复制一份长度+1的数组,然后末尾插入数据,再把数组赋给原数组完成插入
插入源码为例
1 | java复制代码 public boolean add(E e) { |
CopyOnWriteArrayList比vector好在哪?
Vector 的增删改查方法都加上了synchronized锁,保证同步的情况下,每个方法都要去获得锁,所以性能会下降
CopyOnWriteArrayList 方法只是在增删改方法上增加了ReentrantLock锁
但是他的读方法不加锁,==读写分离==,所以在读的方面就要比Vector性能要好
CopyOnWriteArrayList适合读多写少的并发情况
不安全的Set类
和上面list差不多,我就不多做讲解了,直接贴代码
多线程下报错
1 | java复制代码 //并发修改异常:java.util.ConcurrentModificationException |
解决方案
1.转synchronizedSet
1 | java复制代码 HashSet<String> set = new HashSet<>(); |
2.使用CopyOnWriteArraySet
1 | java复制代码 Set<String> set = new CopyOnWriteArraySet<>(); |
简单说明一下HashSet的实现
这里提一嘴HashSet的实现,说了不一定加分,但是说不出来一定扣分
本质就是就是new的HashMap,然后new的Object当做HashMap的value,add的参数当做key
因为是hash算法,所以HashSet是无序的
因为key不能重复,所以HashSet的的元素是不能重复的
1 | java复制代码 private transient HashMap<E,Object> map; |
不安全的Map类
单线程中我们经常使用的HashMap在多线程下也是不安全的
1 | java复制代码 //并发修改异常:ConcurrentModificationException |
1.用Hashtable代替
1 | java复制代码 Map<String, String> hashtable = new Hashtable<>(); |
和之前的Vector代替ArrayList一样,用synchronized简单粗暴的加上同步保证线程安全,只是效率可能会低一些
2.转synchronizedMap
1 | java复制代码 HashMap<String, String> map = new HashMap<>(); |
3.使用ConcurrentHashMap
使用java.util.concurrent.ConcurrentHashMap
并发的HashMap,在保证了线程安全的情况下也保证了效率的高效,推荐使用
对ConcurrentHashMap不熟悉的小伙伴可以看看我的《简单谈谈ConcurrentHashMap》
1 | java复制代码 Map<String, String> concurrentHashMap = new ConcurrentHashMap<>(); |
走进Callable
返回结果并可能引发异常的任务。 实现者定义一个没有参数的单一方法,称为call
Callable接口类似于Runnable ,因为它们都是为其实例可能由另一个线程执行的类设计的
然而,A Runnable不返回结果,也不能抛出被检查的异常
- 可以有返回值
- 可以抛出异常
- 方法不同
- Runnable是run()
- Callable是call()
老版本创建线程的两种方式
1.extends Thread
1 | java复制代码public static void main(String[] args) { |
2.实现Runnable接口
1 | java复制代码public static void main(String[] args) { |
使用Callable创建线程
我们这里需要一个适配类FutureTask
这个类实现了RunnableFuture类,FutureTask<V> implements RunnableFuture<V>
RunnableFuture类继承了Runnable,RunnableFuture<V> extends Runnable, Future<V>
所以Thread(Runnable target)
可以将其传入
注意:futureTask.get()
可以获取返回结果,但是可能会抛异常,需要捕获或抛出
因为要等待执行完毕才返回,所以有可能会阻塞,最好把它放在最后,或者异步通信来处理
1 | java复制代码public static void main(String[] args) { |
FutureTask的状态
如果我们此时用同一个FutureTask传入两条线程,会输出两次结果吗?
1 | java复制代码 MyCall callable = new MyCall(); |
答案是:不会,只会执行一次!
为什么?
我们看看源码
可以看到FutureTask有一个state表示状态变量,还有很多int类型的常量表示具体状态
这里我们暂时只关注NEW和COMPLETING
1 | java复制代码 public class FutureTask<V> implements RunnableFuture<V> { |
在构造器中,默认给state为NEW
1 | java复制代码 public FutureTask(Callable<V> callable) { |
第一条线程进入
在run方法中,执行了callable的call方法后,会将判断变量ran设置为trueif (ran) set(result);
而在set方法中UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)
将NEW状态变为了COMPLETING
也就是说此条FutureTask已经完成了他的使命,变为COMPLETING完成状态
当下一条线程进来判断state != NEW
时,直接return
所以执行了一次之后,其他的线程都无法继续执行run,也就是Callable的call方法了
所以可以得出结论:正常情况下,一个FutureTask只能执行一次call
1 | java复制代码 public void run() { |
常用的辅助类
CountDownLatch减法计数器
java.util.concurrent.CountDownLatch
减法计数器
允许一个或多个线程等待直到在其他线程中执行的一组操作完成的同步辅助类
可用于某些线程的强制执行
CountDownLatch用给定的计数初始化。 await方法阻塞,直到由于countDown()方法的调用而导致当前计数达到零,之后所有等待线程被释放,并且任何后续的await 调用立即返回。 这是一个一次性的现象 - 计数无法重置。 如果您需要重置计数的版本,请考虑使用CyclicBarrier 。
A CountDownLatch是一种通用的同步工具,可用于多种用途。 一个CountDownLatch为一个计数的CountDownLatch用作一个简单的开/关锁存器,或者门:所有线程调用await在门口等待,直到被调用countDown()的线程打开。 一个CountDownLatch初始化N可以用来做一个线程等待,直到N个线程完成某项操作,或某些动作已经完成N次。
CountDownLatch一个有用的属性是,它不要求调用countDown线程等待计数到达零之前继续,它只是阻止任何线程通过await ,直到所有线程可以通过
1 | java复制代码import java.util.concurrent.CountDownLatch; |
CyclicBarrier加法计数器
java.util.concurrent.CyclicBarrier
加法计数器
允许一组线程全部等待彼此达到共同屏障点的同步辅助类
可以用于某些线程的强制等待
循环阻塞在涉及固定大小的线程方的程序中很有用,这些线程必须偶尔等待彼此。 屏障被称为循环 ,因为它可以在等待的线程被释放之后重新使用。
A CyclicBarrier支持一个可选的Runnable命令,每个屏障点运行一次,在派对中的最后一个线程到达之后,但在任何线程释放之前。 在任何一方继续进行之前,此屏障操作对更新共享状态很有用。
1 | java复制代码import java.util.concurrent.BrokenBarrierException; |
Semaphore信号量
一个计数信号量,在概念上,信号量维持一组许可证。
如果有必要,每个acquire()都会阻塞,直到许可证可用,然后才能使用它。 每个release()添加许可证,潜在地释放阻塞获取方
但是,没有使用实际的许可证对象; Semaphore只保留可用数量的计数,并相应地执行
信号量通常用于限制线程数,而不是访问某些(物理或逻辑)资源
我们这假设有3个车位和6辆车需要停车,所以只能有3台车能停进去,其他的车需要等待车位空出才能停
1 | java复制代码import java.util.concurrent.Semaphore; |
本文转载自: 掘金