文章目录
- JUC(java.util.concurrent)的常见类
- Callable接口
- 相关面试题
- ReentrantLock(可重入锁)
- 原子类
- 信号量Semaphore
- CountDownLatch
- 线程安全的集合类
- 多线程环境使用 ArrayList
- 多线程使用队列
- 多线程使用哈希表(重点)
- 相关面试题
JUC(java.util.concurrent)的常见类
Callable接口
Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算结果.
常见的创建线程的方式有两种方式, 第一种方法是直接继承Thread
类, 重写run
方法, 第二种方法是实现Runnable
接口, 然后还是要靠Thread
类的构造器, 把Runnable
传进去, 最终调用的就是Runnable
的run
方法。; 和Runnable
类似, 我们还可以通过Callable
接口描述一个任务配合FutureTask
类来创建线程, 和Runnable
不同的是,Callable
接口配合FutureTask
类所创建的线程其中的任务是可以带有返回值的, 而一开始提到的那两种方式任务是不支持带返回值的.
理解Callable
:
Callable
和Runnable
相对, 都是描述一个 “任务”. Callable
描述的是带有返回值的任务,
Runnable
描述的是不带返回值的任务.
Callable
通常需要搭配 FutureTask
来使用.FutureTask
用来保存 Callable
的返回结果. 因为
Callable
往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask
就可以负责这个等待结果出来的工作.
理解FutureTask
:
可以为想象去吃麻辣烫, 当餐点好后, 后厨就开始做了, 同时前台会给你一张 “小票”, 这个小票就是FutureTask
, 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没.
使用Thread
类的构造器创建线程的时候, 传入的引用不能是Callable
类型的, 而应该是FutrueTask
类型, 因为构造器中传入的任务类型需要是一个Runnable
类,Callable
与Runnable
是没有直接关系的, 但FutrueTask
类实现了Runnable
类, 所以要想使用Callable
创建线程, 我们就需要先把实现Callable
接口的对象引用传给FutrueTask
类的实例对象, 再将FutrueTask
实例传入线程构造器中.
接下来,我们使用Callable实现 创建线程计算 1 + 2 + 3 + … + 1000
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class ThreadDemo29 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//使用Callable来计算1+2+3+4+...+1000
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for(int i = 0;i <= 10;i++){
sum += i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
//获取执行结果
Integer sum = futureTask.get();
System.out.println(sum);
}
}
- 创建一个匿名内部类,实现
Callable
接口.Callable
带有泛型参数。泛型参数表示返回值的类型。 - 重写
Callable
的call
方法,完成累加的过程,直接通过返回值结算结果。 - 把
callable
实例使用FutureTask
包装一下。 - 创建线程,线程的构造方法传入
FutureTask
.此时新线程就会执行FutureTask
内部的Callable
的call
方法,完成计算,计算结果就放在了FutureTask
对象中。 - 在主线程中调用
FutureTask.get()
;能够阻塞等待新线程计算完毕. 并获取到FutureTask
中的结
果.
相关面试题
介绍下 Callable
是什么
Callable
是一个interface
. 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算
结果.
Callable
和Runnable
相对, 都是描述一个 “任务”.Callable
描述的是带有返回值的任务,
Runnable
描述的是不带返回值的任务.
Callable
通常需要搭配FutureTask
来使用.FutureTask
用来保存Callable
的返回结果. 因为
Callable
往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask
就可以负责这个等待结果出来的工作.
ReentrantLock(可重入锁)
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.
ReentrantLock
的用法:
- lock(): 加锁, 如果获取不到锁就死等.
- trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁
- unlock(): 解锁
以上述trylock为例:
import java.util.concurrent.locks.ReentrantLock;
public class ThreadDemo30 {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
boolean result = reentrantLock.tryLock();
try{
if(result){
}else{
}
}finally {
if (result){
reentrantLock.unlock();
}
}
}
}
ReentrantLock
和 synchronized
的区别:
synchronized
是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现).ReentrantLock
是标准
库的一个类, 在 JVM 外实现的(基于 Java 实现).synchronized
使用时不需要手动释放锁.ReentrantLock
使用时需要手动释放. 使用起来更灵活,
但是也容易遗漏 unlock.synchronized
在申请锁失败时, 会死等.ReentrantLock
可以通过trylock
的方式等待一段时间就
放弃.synchronized
是非公平锁,ReentrantLock
默认是非公平锁. 可以通过构造方法传入一个true
开启
公平锁模式.
ReentrantLock reentrantLock = new ReentrantLock(true);
- 更强大的唤醒机制.
synchronized
是通过Objec
t 的wait / notify
实现等待-唤醒. 每次唤醒的是一
个随机等待的线程.ReentrantLock
搭配Condition
类实现等待-唤醒, 可以更精确控制唤醒某个指
定的线程.
结论:虽然ReentrantLock有一定的又是,但是在实际开发中,大部分情况下还是使用Synchronized
如何选择使用哪个锁?
- 锁竞争不激烈的时候, 使用
synchronized
, 效率更高, 自动释放更方便 - 锁竞争激烈的时候, 使用
ReentrantLock
, 搭配trylock
更灵活控制加锁的行为, 而不是死等. - 如果需要使用公平锁, 使用
ReentrantLock
原子类
原子类内部用的是CAS实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
AtomicBoolean
AtomicInteger
AtomicIntegerArray
AtomicLong
AtomicReference
AtomicStampedReference
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadDemo31 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger count = new AtomicInteger(0);
//使用原子类来解决线程安全问题
Thread t1 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
count.getAndIncrement();// count++
// count.incrementAndGet(); // ++count
// count.getAndDecrement(); // count--
// count.decrementAndGet(); // --count
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count.get());
}
}
信号量Semaphore
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
举个🌰: 停车场的车位,是有固定上限的。
很多停车场会在入口显示一个牌子,牌子上面写:当前空闲车位有xx个(这个牌子,就相当于Semaphore)。
每次有车,从入口进去,计数器就-1
每次有车,从出口出来,计数器就+1
如果当前停车场里面的车满了,计数器就是0.
此时,如果还有车想停,有两种方式:
(1)等待其他车出去
(2)放弃这里,去别的停车场
P操作:申请一个可用资源,计数器就-1
V操作:释放一个可用资源,计数器就+1
P操作要是计数器为0了,继续P操作,就会出现阻塞等待。
考虑一个计数初始值为1的信号量
针对这个信号量,就只有1和0两种取值。(信号量不能是负的)
执行一次P(acquire)操作,1->0
执行一次V(release)操作,0->1
如果已经进行一次P操作了,继续进行P操作,就会阻塞等待。
锁是信号量的一种特殊情况,信号量是锁的一般表达。锁可以看为计数器是1的信号量(二元信号量)
CountDownLatch
假设有一场跑步比赛:
这场比赛,开始时间使明确的(裁判的发令枪)
结束时间,则是不确定的、(所有选手都冲过终点比赛才算结束)
为了等待这个跑步比赛结束,就引入了这个CountDownLatch
主要是两个方法:
- await(wait->等待 ,a->all)主线程来调用这个方法
- countDown表示选手冲过了重点线
countDown
在构造的时候,指定一个计数(选手的个数)
CountDownLatch类常用方法:
- 构造方法
public CountDownLatch(int count) | 构造实例对象, count表示CountDownLatch对象中计数器的值 |
---|
- 普通方法
public void await() throws InterruptedException | 使所处的线程进入阻塞等待, 直到计数器的值清零 |
---|---|
public void countDown() | 将计数器的值减1 |
public long getCount() | 获取计数器最初的值 |
上述例子中,有五个选手进行比赛,初始情况下每个选手都会冲过终点,都会调用countDown
方法。
前四次调用countDown
,await
没有任何影响
第五次调用countDown
,await
被唤醒。(解除阻塞),此时就可以认为是整个比赛都结束了。
import java.util.concurrent.CountDownLatch;
public class ThreadDemo32 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(5);
for (int i = 0; i < 5; i++) {
Thread t = new Thread(()->{
try {
Thread.sleep(100);
System.out.println(Thread.currentThread().getName() +"跑到了终点");
latch.countDown();//调用countDown的次数和个数一致
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println();
});
t.start();
}
latch.await();
System.out.println("比赛结束!");
}
}
线程安全的集合类
原来的集合类, 大部分都不是线程安全的.
Vector, Stack, HashTable
, 是线程安全的(不建议用), 其他的集合类不是线程安全的.
多线程环境使用 ArrayList
- 自己加锁,自己使用
synchronized
或者ReentrantLock
Collections.synchronized
这里会提供一些ArrayList
相关的方法,同时是带锁的CopyOnWriteArrayList
:简称COW
,也叫做“写时拷贝”
如果针对这个ArrayList进行读操作,不做任何额外的工作。
如果进行写操作,则拷贝一份新的ArrayList,针对新的进行修改,修改过程中如果有读操作,就继续读旧的这份数据,当修改完毕了,使用新的替换旧的(本质上是一个引用之间的赋值,原子的)
很明显,这种方案,有点是不需要加锁,缺点则是要求这个ArrayList不能太大。适用于数组比较小的情况下。
比如:
服务器程序的配置维护:
一个程序可能包含很多的子功能,有的功能想要使用,有的不想要使用,有的希望功能应用不同的形态,就可以使用一系列的“开关选型”来控制当前这个程序的工作状态。
服务器程序的配置文件,可能会需要进行修改。修改配置可能就需要重启服务器才能生效。但是重启的操作可能成本比较高。
假设一个服务器重启需要耗时5min,如果有20台服务器,就需要100min。
因此,很多服务器,都提供了“热加载”(reload)这样的功能,通过这样的功能就可以不重启服务器,实现配置的更新。热加载的实现,就可以使用刚才所说的 写时拷贝 思路。
新的配置放到新的对象中,加载过程中,请求任然基于旧配置进行工作。当新的对象加载完毕,使用新对象替代旧对象。(替换完成之后,旧的对象就可以释放了)
多线程使用队列
ArrayBlockingQueue
基于数组实现的阻塞队列LinkedBlockingQueue
基于链表实现的阻塞队列PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列TransferQueue
最多只包含一个元素的阻塞队列
多线程使用哈希表(重点)
HashMap
是线程不安全的。
HashTable
是线程安全的。(给关键方法加了Synchronized)
更推荐使用的是ConcurrentHashMap
:更优化的线程安全哈希表。
考点:ConcurrentHashMap
进行了哪些优化?比HashTable
好在哪里?和HashTable
之间的区别是什么?
- 最大的优化之处:
ConcurrentHashMap
相比于HashTable
大大缩小了锁冲突的概率,把一把大锁,转化成多把小锁了。
HashTable
做法是直接在方法上加synchronized
,等于是给this加锁,只要操作哈希表上的任何元素,都会产生加锁,也就有可能发生锁冲突。
但是实际上,仔细思考不难发现,其实基于哈希表的结构特点,有些元素在进行并发操作的时候,是不会产生线程安全问题的,也就不需要使用锁控制。
此时,元素1,2在同一个链表上。如果线程A修改元素1,线程B修改元素2,就可能会有线程安全问题。(比如这两个元素相邻,此时并发删除/插入,就需要修改这两个节点相邻的节点的next的指向)
如果线程A修改元素3,线程B修改元素4不会有线程安全问题。这个情况是不需要加锁的。
HashTable
,锁冲突概率就大大增加了,任何两个元素的操作都会有锁冲突,即使是在不同链表上。
ConcurrentHashMap
做法是:每个链表有各自的锁。(而不是大家公用一把锁)
具体来说,就是使用每个链表的头结点作为锁对象。(两个线程针对同一个锁对象加锁,才有竞争,才有阻塞等待,针对不同对象,没有锁竞争)
此时,锁的粒度变小了。针对1,2。是针对同一把锁进行加锁,会有锁竞争,会保证线程安全。
针对3,4.是针对不同的锁进行加锁,不会有锁竞争了,没有阻塞等待,程序就会更快。(快是相对的)
上图中的情况, 是针对JDK1.8
及其以后的情况, 而JDK1.8
之前, ConcurrentHashMap
使用的是 “分段锁”, 分段锁本质上也是缩小锁的范围从而降低锁冲突的概率, 但是这种做法不够彻底, 一方面锁的粒度切分的还不够细, 另一方面代码实现也更繁琐.
2. ConcurrentHashMap
做了一个激进的操作:针对读操作,不加锁,只针对写操作加锁。
读和读之间没有冲突
写和写之间有冲突
读和写之间没有冲突(很多场景下,读写之间不加锁控制,可能就读到了一个写了一半的操作,如果写操作不是院子的,此时读就可能会读到写了一般的数据,相当于脏读)针对此情况可以使用volatile
+原子的写操作。
3. ConcurrentHashMap
内部充分地使用了CAS
,通过这个也来进一步的削减加锁操作的数目。比如维护元素个数
4. 针对扩容,采取了“化整为零”的方式。
HashMap/HashTable
扩容:
创建一个更大的数据空间,把旧的数组上的链表上的每个元素搬运到新的数组上。(删除+插入)
这个扩容操作会在某次put
的时候进行触发
如果元素个数特别多,就会导致这样的搬运操作比较耗时。
就会出现:某次put
比平时的put
卡很多倍。
ConcurrentHashMap
中,扩容采取的是每次搬运一小部分元素的方式。
创建新的数组,旧的数组也保留。
每次put
操作,都会往新数组上添加,同时进行一部分搬运(把一小部分旧的元素搬到新数组上)
每次get
的时候,则旧数组和新数组都查询
每次remove
的时候,只是把元素删了就行。
…
经过一段时间后,所有的元素都搬运好了,最终再释放旧数组。
相关面试题
ConcurrentHashMap
的读是否要加锁,为什么?
读操作没有加锁. 目的是为了进一步降低锁冲突的概率. 为了保证读到刚修改的数据, 搭配了
volatile
关键字.
- 介绍下
ConcurrentHashMap
的锁分段技术?
这个是 Java1.7 中采取的技术. Java1.8 中已经不再使用了. 简单的说就是把若干个哈希桶分成一个"段" (Segment), 针对每个段分别加锁.
目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争.
ConcurrentHashMap
在jdk1.8做了哪些优化?
取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对 象). 将原来 数组 + 链表的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于 8 个元素)就转换成红黑树.
4) Hashtable
和HashMap、ConcurrentHashMap
之间的区别?
HashMap
: 线程不安全. key 允许为 null
Hashtable
: 线程安全. 使用 synchronized 锁Hashtable
对象, 效率较低. key 不允许为 null.
ConcurrentHashMap
: 线程安全. 使用 synchronized 锁每个链表头结点, 锁冲突概率低, 充分利用
CAS 机制. 优化了扩容方式. key 不允许为 null