菜鸟的JUC并发编程总结
说明
更新时间:2020/12/08 14:04,更新完基本内容
本文主要记录java的JUC编程总结,本文会持续更新,不断地扩充
注意:本文仅为记录学习轨迹,如有侵权,联系删除
一、基本知识
进程与线程
进程 | 计算机中运行中的程序,如QQ.exe等。 |
线程 | 单个进程中执行中每个任务就是一个线程。线程是进程中执行运算的最小单位。 |
注意 | 在java里面,一个进程可以包含多个线程,一个进程至少有一个线程。Java程序至少有两个线程:GC线程和Main线程。 |
并发与并行
并发 | 多个线程操作同一个资源并且交替执行的过程,并不是真正意义上的同时运行 |
并行 | 同一个时刻多个线程同时执行,只有在多核CPU下才能完成。 |
同步与异步
同步 | 同步调用必须等方法调用返回以后,才能继续调用。 |
异步 | 异步调用更像是一个消息传递,一旦开始方法便立即返回,调用者可以继续完成后面相关的调用。此时异步方法就在另一个线程中真实的执行。 |
临界区
临界区 | 表示一种公共资源或者共享数据,可以被多个线程使用。但每一次只能有一个线程使用它。一旦临界区被占用,其他线程要想使用这个资源就必须等待。在并行程序中,临界区是保护的对象,比如一个会议室不能同时提供给两个开发团队开会一样,只能轮流开会,这就是临界资源的概念。 |
阻塞与非阻塞
阻塞 | 一个线程占用了临界区资源,那么其他需要这个资源的线程就必须在临界区等待。等待会导致线程挂起,这种情况就是阻塞。 |
非阻塞 | 非阻塞意思恰恰相反,它强调的是没有一个线程能够阻碍其他线程的执行,所以线程都会尝试不断的向前执行。 |
注意 | 阻塞与非阻塞通常形容的是多线程之间的相互影响。 |
java线程状态
新建(NEW) | 新创建了一个线程对象。 |
可运行(RUNNABLE) | 线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取cpu 的使用权 。 |
运行(RUNNING) | 可运行状态(runnable)的线程获得了cpu 时间片(timeslice) ,执行程序代码。 |
阻塞(BLOCKED) | 阻塞状态是指线程因为某种原因放弃了cpu 使用权,也即让出了cpu timeslice,暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得cpu timeslice 转到运行(running)状态。 |
死亡(DEAD) | 线程run()、main() 方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。 |
注意,阻塞的情况分三种:
(一). 等待阻塞:运行(running)的线程执行o.wait()方法,JVM会把该线程放入等待队列(waitting queue)中。
(二). 同步阻塞:运行(running)的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池(lock pool)中。
(三). 其他阻塞:运行(running)的线程执行Thread.sleep(long ms)或t.join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入可运行(runnable)状态。
JUC
JUC | JUC,即java.util.concurrent包的缩写,是java原生的并发包和一些常用的工具类。 |
JUC编程主要学习下图的三个接口
二、Lock
Lock是java.util.concurrent.locks包下的接口,Lock下面还有几个字类,通过Lock就可以实现跟synchronized同样的功能,以最经典的卖票的为例,下面是卖票的两种实现方式
package com.zsc.lock;
/**
* @ClassName : Lock01
* @Description : lock锁
* @Author : CJH
* @Date: 2020-12-08 22:39
*/
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 卖票
*/
class Ticket{
/**
* 票数
*/
private int num = 100;
/**
* 可重复锁
*/
Lock lock = new ReentrantLock();
/**
* 卖票
*/
void sell(){
lock.lock();
try{
if(num>0){
System.out.println(Thread.currentThread().getName()+"卖出了第" + (num--)+ "票" +",剩下"+num+"张票");
}
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
}
/**
* 卖票
*/
void sell2(){
//使用synchronized修饰代码块,也可以达到将代码上锁的目的
synchronized (this){
if(num>0){
System.out.println(Thread.currentThread().getName()+"卖出了第" + (num--)+ "票" +",剩下"+num+"张票");
}
}
}
}
public class Lock01 {
public static void main(String[] args) {
Ticket ticket = new Ticket();
//对应lock
new Thread(()->{ for (int i = 0; i <= 100; i++) {ticket.sell();}},"售票员A").start();
new Thread(()->{ for (int i = 0; i <= 100; i++) {ticket.sell();}},"售票员B").start();
new Thread(()->{ for (int i = 0; i <= 100; i++) {ticket.sell();}},"售票员C").start();
//
// //对应synchronized
// new Thread(()->{ for (int i = 0; i <= 100; i++) {ticket.sell2();}},"售票员A").start();
// new Thread(()->{ for (int i = 0; i <= 100; i++) {ticket.sell2();}},"售票员B").start();
// new Thread(()->{ for (int i = 0; i <= 100; i++) {ticket.sell2();}},"售票员C").start();
}
}
先简单说一下Lock的基本使用,这个可以通过查询jdk得到
Lock lock = ...;//可以new Lock接口下的任意字类,例如上图用的可重入锁new ReentrantLock();
lock.lock();
try{
//处理任务
}catch(Exception ex){
}finally{
lock.unlock(); //释放锁
}
两者区别如下
1.synchronized是一个关键字,Lock是一个对象。 |
2.synchronized无法尝试获取锁,Lock可以尝试获取锁并判断。 |
3.synchronized会自动释放锁(a线程执行完毕,b如果出现异常也会释放锁),Lock锁必须手动进行释放,不释放就会变成死锁。。 |
使用synchronized时,如果线程a获得锁并阻塞,线程b会一直进行等待,使用Lock则可以尝试获取锁,失败了之后就放弃。可以使用tryLock方法 |
5.synchronized一定是非公平的,但Lock锁可以是公平的,需要通过参数进行设置。 |
代码量特别大时,一般使用Lock实现精准控制,synchronized适合代码量较小的同步问题。 |
三、集合类不安全
(1)常见集合
先来了解一下java中集合容器的构成,主要是Map接口和Cllection接口,这两个是所有集合框架的父接口。
Collection | 说明 |
---|---|
Set接口 | Set接口的实现类主要有:HashSet、TreeSet、LinkedHashSet等 |
List接口 | List接口的实现类主要有:ArrayList、LinkedList、Stack以及Vector等 |
Map | 说明 |
---|---|
实现类 | Map接口的实现类主要有:HashMap、TreeMap、Hashtable、ConcurrentHashMap以及Properties等 |
下面简单列举出常用的集合容器以及是否是线程安全的
线程安全的:Vector、HashTable
线程不安全的:ArrayList、LinkedList、HashMap、HashSet、TreeMap、TreeSet
(2)线程安全与不安全的区别
线程不安全的集合,在使用的时候不能同时被多个线程使用,否则会报ConcurrentModificationException
这样的并发修改异常,所以在高并发的情况下,如果可能会出现线程安全问题,而线程安全的集合就不会有这样的问题。
(3)模拟线程不安全的情况
模拟出这一种情况也简单,只要让多个线程同时操作一个线程不安全的集合即可,下面用ArrayList模拟多个线程同时读写引发的并发修改异常。
public static void main(String[] args) {
List<String> list = new ArrayList<>();
for (int i = 0; i < 30; i++) {
String a = "a"+i;
new Thread(()->{
list.add(String.valueOf(a));
System.out.println(list);
},String.valueOf(i)).start();
}
}
运行结果
分析:由于ArrayList是线程不安全的,所以在多个线程操作同一个ArrayList的时候会引发线程并发异常,如图所示。
(4)解决方案
这样的解决方案有很多种,学过锁的人可能会下意识的想到锁,通过加锁的方式来解决,这个可以,但是完全没必要,方案一就是用线程安全的集合替代就可以了
方案一:用线程安全的集合替代
这里用Vector来替代即可
public static void main(String[] args) {
List<String> list = new Vector<>();
for (int i = 1; i <= 30; i++) {
new Thread(()->{
list.add(String.valueOf("hello"));
System.out.println(list);
},String.valueOf(i)).start();
}
}
运行结果,没有并发异常
注意,如果点进去Vector的源码,会发现它其实内部也是加的锁,而且是用的syhchronized关键字
方案二:使用集合工具类将其转为线程安全
/**
* 通过集合工具类Collections将线程不安全的转安全
*/
static void SafeDemo2(){
//通过集合工具类Collections将线程不安全的转安全
List<String> list = Collections.synchronizedList(new ArrayList<String>());
for (int i = 0; i < 30; i++) {
new Thread(()->{
list.add("abc");
System.out.println(list);
},String.valueOf(i)).start();
}
}
方案三(推荐):写时复制CopyOnWriteArrayList
/**
* 使用juc包下的CopyOnWriteArrayList,也叫写时复制技术
*/
static void SafeDemo3(){
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 30; i++) {
new Thread(()->{
list.add("abc");
System.out.println(list);
},String.valueOf(i)).start();
}
}
(5)重点:JUC包下的集合
这个是JUC编程的重点,主要是学习JUC包下的接口,如下图
上面模拟了一个ArrayList线程不安全的例子,也给出了解决方案,用的就是上图的CopyOnWriterArrayList,也叫写时复制技术,在使用的时候可以点击进入源码,发现它用的也是Lock,重点是写时复制思想的体现,下面给出这个经典的源码
上面给出了ArrayList的不安全例子,下面直接给出map和set的不安全例子,以及用java.util.concurrent
提供的集合来解决
package com.zsc.collection;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
/**
* @ClassName : Main2
* @Description :
* @Author : CJH
* @Date: 2020-12-09 23:07
*/
public class Main2 {
/**
* HashSet不安全案例
*/
static void NotSafeSet(){
Set<String> set = new HashSet<>();
for (int i = 0; i < 50; i++) {
new Thread(()->{
set.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(set);
},String.valueOf(i)).start();
}
}
/**
* 通过juc包下的CopyOnWriteArraySet实现线程安全的Set
*/
static void SafeSet(){
Set<String> set = new CopyOnWriteArraySet<>();
for (int i = 0; i < 500; i++) {
new Thread(()->{
set.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(set);
},String.valueOf(i)).start();
}
}
/**
* HashMap不安全案例
*/
static void NotSafeMap(){
Map<String, String> map = new HashMap<>();
for (int i = 0; i < 50; i++) {
new Thread(()->{
map.put(UUID.randomUUID().toString().substring(0,2),"hello");
System.out.println(map);
},String.valueOf(i)).start();
}
}
/**
* 通过juc包下的CopyOnWriteArraySet实现线程安全的Set
*/
static void SafeMap(){
Map<String, String> map = new ConcurrentHashMap<>();
for (int i = 0; i < 50; i++) {
new Thread(()->{
map.put(UUID.randomUUID().toString().substring(0,2),"hello");
System.out.println(map);
},String.valueOf(i)).start();
}
}
public static void main(String[] args) {
SafeMap();
}
}
四、synchronized锁机制
对于synchronized的锁机制,下面给出对应的几种种情况,基本涵盖了锁机制的全部内容
第一种情况
问题 | 邮箱和信息先执行哪一个? |
答案 | 随机执行,两个线程,调度是看电脑的cpu的事,所以是不确定哪个先执行的。 |
解析 | 新起线程的时候,java只是把这个线程状态改为就绪状态,new Thread后不会立刻执行,要等待cpu的调度 |
第二种情况
问题 | 邮箱和信息先执行哪一个? |
答案 | 先执行邮箱 ,再执行信息 |
解析 | 这里有两个线程,在main函数那里两个线程之前故意让它们沉睡了100毫秒,先让邮箱先执行,邮箱里面设置了延时5秒,又由于邮箱和信息都是加了synchronized锁,这样导致两个在争夺同一个资源 ,由于这个锁锁的是对象this,邮箱在执行的时候这个对象this就被锁住了,所以只能等邮箱执行完再执行信息。 |
第三种情况
问题 | 先打印“发送邮箱”还是“发送信息”,注意,不是先执行哪个方法,而是方法里面的打印语句那一条先执行 |
答案 | 先打印“发送信息” ,再打印“发送邮箱” |
解析 | 这里跟第二种情况的区别就是发信息的方法没有加锁,这样在方法先执行邮箱方法里面的沉睡5秒语句的时候,因为发送信息方法没有锁,也就是说不用跟邮箱争锁,这样就可以在邮箱沉睡的时候先执行发送信息 |
第四种情况
问题 | 先打印“发送邮箱”还是“发送信息”,注意,不是先执行哪个方法,而是方法里面的打印语句那一条先执行 |
答案 | 先打印“发送信息” ,再打印“发送邮箱” |
解析 | 由于这里synchronized会锁住本对象this,这里是new了两个Phone,所以是两个互不影响的锁,所以就不用等邮箱是锁释放后再执行信息 |
第五种情况
问题 | 先打印“发送邮箱”还是“发送信息”,注意,不是先执行哪个方法,而是方法里面的打印语句那一条先执行 |
答案 | 先打印“发送邮箱” ,再打印“发送信息” |
解析 | 这里虽然是两个实例对象,但是发送邮箱的方法是static修饰的,这叫全局锁,锁住的整个的类模板,所以,不管有多少实例对象,都得等锁释放后才可以用。 |
五、生产者和消费者
sleep()和wait()之间的区别
(1)所属的类不同,sleep是Thread类里面的静态方法,wait()是Object类的方法(了解即可) |
(2)执行sleep让当前线程进入阻塞状态,让出CPU使用权,直到沉睡的时间到了,cpu重新调度;执行wait让线程进入等待状态,也是让出cpu使用权,此时如果没有线程来唤醒它,它会一直处于等待状态 (重点) |
(3)还有一个区别就是sleep是不会释放锁的,这一般是在synchronized修饰的情况下,两个线程分别调用同一个类里面的A方法(调用sleep)和B方法,这两个方法都是synchronized修饰的,那么如果线程1执行了A方法,A方法执行了sleep,并且synchronized锁A是不会释放的,只能等A执行完,线程2才会执行B方法(重点,下面有案例一) |
(4)wait是释放锁的,这个跟上面第(3)点区分,同样的(3)的例子,A方法不在调用sleep而是调用wait,这个时候线程1执行方法A的wait后,由于synchronized是被释放的,所以线程2可以马上执行B方法,而不用等线程1执行完(重点,下面有案例二) |
案例一
public class Main {
public static void main(String[] args) throws InterruptedException {
MyTest test = new MyTest();
new Thread(()->{
try {
test.test01();
} catch (InterruptedException e) {
e.printStackTrace();
}
},"线程A").start();
new Thread(()->{
try {
test.test02();
} catch (InterruptedException e) {
e.printStackTrace();
}
},"线程B").start();
}
}
class MyTest{
public synchronized void test01() throws InterruptedException {
Thread.sleep(3000);
System.out.println("this is test01");
}
public synchronized void test02() throws InterruptedException {
System.out.println("this is test02");
}
}
线程A调用test01,线程B调用test02,两个方法都是synchronized修饰的,这意味这如果不释放锁synchronized的话,它们要竞争同一把锁,刚好sleep是不释放锁的,所以哪怕线程A让出了cpu,线程B也是没法执行test02的,因为它需要的锁被线程A的test01占用了,它不释放锁,所以执行等线程1执行完test01后线程B才会执行test02
运行结果
案例二
public class Main {
public static void main(String[] args) throws InterruptedException {
MyTest test = new MyTest();
new Thread(()->{
try {
test.test01();
} catch (InterruptedException e) {
e.printStackTrace();
}
},"线程A").start();
new Thread(()->{
try {
test.test02();
} catch (InterruptedException e) {
e.printStackTrace();
}
},"线程B").start();
}
}
class MyTest{
public synchronized void test01() throws InterruptedException {
this.wait();
System.out.println("this is test01");
}
public synchronized void test02() throws InterruptedException {
System.out.println("this is test02");
}
}
注意:wait只能在synchronized修饰的情况下才能使用
线程A调用test01,线程B调用test02,两个方法都是synchronized修饰的,这意味这如果不释放锁synchronized的话,它们要竞争同一把锁,刚好wait是释放锁的,素以线程A让出了cpu,由于锁没被占用,线程B可以执行test02,但是需要注意一点,wait让出cpu后进入等待状态,只有被线程唤醒才会执行,不然会一直处于等待状态
运行结果
wait与notifyAll
注意,这里的两个方法是需要用synchronized修饰的
wait | 让正在运行的本线程让出cpu,进入等待状态,等待被唤醒,没唤醒会一直沉睡 |
notifyAll | 唤醒所有正在等待的线程 |
注意 | 这个两个都是需要synchronized修饰才可以用,不然会报错 |
利用这两个可以实现进程间的通信与协作,下面给出一个案例,实现生产者和消费者的轮流交替生产和消费产品
生产者和消费者案例
public class ProCon01 {
public static void main(String[] args) {
ProConDemo01 proConDemo01 = new ProConDemo01();
new Thread(()->{
for (int i = 0;i<20;i++){
try {
proConDemo01.product();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"生产者A").start();
new Thread(()->{
for (int i = 0;i<20;i++){
try {
proConDemo01.product();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"生产者B").start();
new Thread(()->{
for (int i = 0;i<20;i++){
try {
proConDemo01.consumer();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"消费者C").start();
new Thread(()->{
for (int i = 0;i<20;i++){
try {
proConDemo01.consumer();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"消费者D").start();
}
}
class ProConDemo01{
private int num = 0;
public synchronized void product() throws InterruptedException {
//信号量判断是否产品数量是否等于0,是的话进行生产,不是的话进入等待状态,等待消费
while (num != 0){
this.wait();
}
//生产产品
num++;
System.out.println("【"+Thread.currentThread().getName()+ "】生产产品 : " + num);
//生产完毕,唤醒正在等待的消费者线程进行消费
this.notifyAll();
}
public synchronized void consumer() throws InterruptedException {
//信号量判断是否产品数量是否等于0,不是是的话进行消费,是的话进入等待状态,等待生产
while (num == 0){
this.wait();
}
//消费产品
num--;
System.out.println("【"+Thread.currentThread().getName()+"】消费产品 : " + num);
//消费完毕,唤醒正在等待的生产者进行生产
this.notifyAll();
}
}
运行结果
await和signal
注意:这里不能用synchronized,这里主要实现多个线程之间的通信和协作,使用的是最新的Lock锁
await | 同样的实现让线程让出cpu,进入等待状态,这个功能跟上面的那个wait功能是类似的 |
signal | 唤醒线程,可以唤醒特定的线程 |
注意 | 这里的await和signal是可以唤醒特定的线程,通过同一个lock,可以创建多把锁,每把锁都可以有awiat和signal |
下面给出一个案例,用多线程的方式组装一个机器类,4个线程,各自分工组装头部、身体、脚步和命名,各个线程分工
public class Main {
public static void main(String[] args) {
/**
* 通过4个线程,分工合作完成机器人类的头部、身体、脚步组装以及给机器人命名
* 4个线程协作完成一个机器人的组装,并且步骤是先组装头部、身体、脚步,最后再命名
*/
Root root = new Root();
new Thread(()->{
root.buildBody();
},"线程B").start();
new Thread(()->{
root.getName();
},"线程D").start();
new Thread(()->{
root.buildFoot();
},"线程C").start();
new Thread(()->{
root.buildHead();
},"线程A").start();
}
}
class Root{
//3个判断标记,false表示未组装,true表示组装完成
private Boolean isHasHead = false;
private Boolean isHasBody = false;
private Boolean isHasFoot = false;
//用可重入锁,
private Lock lock = new ReentrantLock();
private Condition c1 = lock.newCondition();
private Condition c2 = lock.newCondition();
private Condition c3 = lock.newCondition();
private Condition c4 = lock.newCondition();
public void buildHead(){
lock.lock();
try{
//判断如果已经组装过了头部了就放弃cpu,进入等待,否则就进入组装头部
while (isHasHead == true){
c1.await();
}
//组装头部,并且修改标志
System.out.println(Thread.currentThread().getName() + ":机器人头部组装完成");
isHasHead = true;
//组装完头部后,(唤醒特定线程)通知身体可以开始组装了
c2.signal();
}catch (Exception e){
}finally {
lock.unlock();
}
}
public void buildBody(){
lock.lock();
try{
//判断头部是否组装完成,如果头部还没组装完,则先进入等待状态,等头部组装完后再来唤醒该线程
//如果已经组装过了则直接进入组装身体
while (isHasHead == false){
c2.await();
}
//组装身体并且修改标记
System.out.println(Thread.currentThread().getName() + ":机器人身体组装完成");
isHasBody = true;
//身体组装完成后唤醒通知脚步的组装
c3.signal();
}catch (Exception e){
}finally {
lock.unlock();
}
}
public void buildFoot(){
lock.lock();
try{
//判断身体是否已经装完了,没有组装完则进入等待状态,等着被唤醒,否则直接进入组装脚步
while (isHasBody == false){
c3.await();
}
//组装脚步并且修改标记
System.out.println(Thread.currentThread().getName() + ":机器人脚步组装完成");
isHasFoot = true;
//脚步组装完成,通知机器人命名
c4.signal();
}catch (Exception e){
}finally {
lock.unlock();
}
}
public void getName(){
lock.lock();
try{
//判断是否已经头部、身体、脚步都已经组装完,没有的话这里就先进入等待状态,等待被唤醒,否则就可以直接命名机器人
while ( isHasHead == false && isHasBody == false && isHasFoot == false){
c4.await();
}
//给机器人命名
System.out.println(Thread.currentThread().getName() + ":机器人命名完成:终结者1号");
}catch (Exception e){
}finally {
lock.unlock();
}
}
}