多线程进阶:Callable和JUC的常见类

Callable

这是一个接口,类似于Runnable。

Runnable用来描述一个任务,描述的任务没有返回值。

Callable也是用来描述一个任务,描述的任务是有返回值的。

如果需要使用一个线程单独的计算出某个结果来,此时用Callable是比较合适的。

在new一个Callable之后,需要重写一个方法。就相当于是重写Runnable的Run方法,run方法的返回值是void,这里的call方法返回值是泛型参数。

我们需要FutureTask的帮助:

这个FutureTask就相当于一个未来的任务,类似于我们吃麻辣烫时,给我们叫号牌。等到麻辣烫做好后,会通过叫号牌来叫我们。

Future表示一个任务的周期,并提供了相应的方法来判断是否已经完成或者取消,以及获取任务的结果和取消任务。

FutureTask实现了RunnableFuture接口,RunnableFuture接口又实现了Runnable接口和Future接口。所以FutureTask既可以被当做Runnable来执行,也可以被当做Future来获取Callable的返回结果

和Runnable相比,Callable也是创建线程的一个方式,callable解决的是代码好不好看的问题,而不是结果对不对的问题。runnable也能得出结果,但是代码看起来比较乱。

ReentrantLock

这是标准库给我们提供的另一种锁,也是可重入锁。

synchronized是直接基于代码块的方式来加锁解锁的。

ReentrantLock更传统,使用了lock和unlock方法来加锁。

乍一看可能没什么问题,但是这样子加锁解锁可能会导致unlock执行不到。

那如果是lock后多用几个条件限制呢?

如果这中间存在return或者异常都可能导致unlock不能顺利执行~

建议的用法:

把unlock放到finally中。

try 关键字最后可以定义 finally 代码块。 finally 块中定义的代码,总是在 try 和任何 catch 块之后、方法完成之前运行。

正常情况下,不管是否抛出或捕获异常 finally 块都会执行。

这样就能保证unlock一定会执行。

上面的是ReentrantLock的劣势,但是也是有优势的:

1.ReentrantLock提供了公平锁版本的实现

java 复制代码
ReentrantLock reentrantLock = new ReentrantLock(true);

2.对于synchronized来说,提供的加锁操作就是死等,只要获取不到锁,就一直一直阻塞等待~

ReentrantLock提供了更灵活的等待方式:tryLock

java 复制代码
reentrantLock.tryLock();

无参数版本,能加锁就加,加不上就放弃。

有参数版本,指定了超时时间,加不上锁就等一会,如果等一会时间到了也没加上就放弃等待。

3.ReentrantLock提供了一个更强大,更方便的等待通知机制。
synchronized搭配的是 wait notify。notify的时候是随机唤醒一个wait的线程。ReentrantLock搭配一个Condition类,进行唤醒的时候可以唤醒指定的线程。

总结:虽然ReentrantLock有一定的优势,但是实际开发中,大部分情况下还是用的synchronized。

原子类

原子类内部用的是CAS,所以性能要比加锁实现i++要高很多

虽然CAS确实是更加高效的解决了线程安全问题,但是CAS不能代替锁,CAS的适用范围是有限的,不像锁的适用范围那么广。

信号量 Semaphore

这里的信号量和操作系统上的信号量是同一个东西,只不过这里的信号量是Java把操作系统原生的信号量封装了一下。

信号量这个东西在我们生活中经常可以见到:

信号量就是这个计数器,描述了"可用资源的个数"

P操作:申请一个可用资源,计数器就要-1

V操作:释放一个可用资源,计数器就要+1

P操作如果要是计数器为0,继续P操作,就会出现阻塞操作。直到下一个V操作以后才能继续进行P操作。

实际开发中,虽然锁是最常用的,但是信号量也是会偶尔用到的,主要还是看实际的需求场景。

CountDownLatch

有一场跑步比赛,开始时间是明确的(裁判的发令枪),但是结束时间是不明确的(所有的选手都冲过终点线),为了等待这个跑步比赛结束,就引入了这个CountDownLatch。

有两个方法:

1.await(wait是等待,a =>all)主线程来调用这个方法

2.countDown表示选手冲过了终点线

例如,有四个选手进行比赛,初始情况下,调用await就会阻塞,就代表进入了比赛时间,每个选手冲过终点的时候,都会调用countDown方法。

前三次countDown,await没有任何影响,第四次调用countDown,await就会被唤醒,返回(解除阻塞),此时就可以认为是整个比赛都结束了。

多线程环境下使用ArrayList

1.自己加锁,使用synchronized或者ReentrantLock

2.Collections.synchronizedList 这里面会提供一些ArrayList相关的方法,同时是带锁的。

3.CopyOnWriteArrayList,简称为COW,也叫做写时拷贝。

针对这个ArrayList进行读操作,不做任何额外的工作;

如果进行写操作,则拷贝一份新的ArrayList,针对新的进行修改。修改的过程中如果有读的操作,那么就继续读旧的这一份数据。当修改完毕了,使用新的替换旧的。

这种方案优点:不需要加锁

缺点:要求这个ArrayList不能太大,只是适用于数组比较小的情况下

多线程使用哈希表[重点、难点]

HashMap是线程不安全的,HashTable是线程安全的(因为给关键方案加了synchronized)

但是更推荐使用的是ConcurrentHashMap,这是更加优化的线程安全哈希表。

在这有几个重点:

1.ConcurrentHashMap进行了哪些优化?

2.比HashTable好在哪里?

3.和HashTable之间的区别是什么?

最大的优化之处:ConcurrentHashMap相比于HashTable大大缩小了锁冲突的概率,把一把大锁,转换成多把小锁了。

HashTable会在整个链表上加锁。


ConcurrentHashMap的做法是,每个链表有各自的锁,而不是共用一个锁

具体来说,就是用每个链表的头结点,作为锁对象。这样两个线程不针对同一个对象加锁,就不会有锁竞争。
JDK1.7和之前

但是呢,ConcurrentHashMap做了一个激进的操作:

针对读操作不加锁,只针对锁操作加锁。

并且,ConcurrentHashMap内部充分利用了CAS,通过这个来进一步削减加锁操作的数目。

针对扩容,采取"化整为零"的方式

HashMap/HashTable扩容:

创建一个更大的数组空间,把旧的数组上的链表上的每个元素搬运到新的数组上(删除+插入)

这个扩容操作会在某次put的时候进行触发,如果元素个数特别多,就会导致这样的搬运操作比较耗时。(比如某次put的时候,某个用户就卡了)

ConcurrentHashMap扩容:

每次搬运一小部分元素,创建新的数组,旧的数组也保留。每次put操作,都往新数组上添加,同时进行一部分搬运(把一小部分旧的元素搬运到新数组上)

每次get的时候,把新旧数组都查询,remove的时候,把旧数组的元素删了就行了

经过一段时间之后,所有的元素都搬运好了,再释放旧数组。

相关推荐
浮游本尊15 分钟前
Java学习第22天 - 云原生与容器化
java
渣哥2 小时前
原来 Java 里线程安全集合有这么多种
java
间彧2 小时前
Spring Boot集成Spring Security完整指南
java
间彧3 小时前
Spring Secutiy基本原理及工作流程
java
Java水解4 小时前
JAVA经典面试题附答案(持续更新版)
java·后端·面试
洛小豆6 小时前
在Java中,Integer.parseInt和Integer.valueOf有什么区别
java·后端·面试
前端小张同学6 小时前
服务器上如何搭建jenkins 服务CI/CD😎😎
java·后端
ytadpole6 小时前
Spring Cloud Gateway:一次不规范 URL 引发的路由转发404问题排查
java·后端
华仔啊6 小时前
基于 RuoYi-Vue 轻松实现单用户登录功能,亲测有效
java·vue.js·后端