本文主要介绍了java.util.concurrent包的几个常见类,包括可以创建线程的Callable接口、ReentrantLock可重入锁、Semaphore信号量以及CountDownLatch拆分任务的应用场景,还讨论了集合类的线程安全问题。
一.java.util.concurrent常见类
concurrent并发(多线程)
1.Callable interface
也是一种创建线程的方式
- Runnable能表示一个任务(run方法)
返回void
- Callable也能表示一个任务(call方法)
返回一个具体的值,类型可以通过泛型参数来指定
如果进行多线程操作,
如果只是关心多线程执行的过程,使用Runnable即可 (比如线程池,定时器)
如果是关心多线程的计算结果,使用Callable合适(比如让一个线程计算1+2+3+…+1000)
注意:使用Callable不能直接作为Thread的构造方法参数,需要先使用FutureTask把callable包装一层。
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable=new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum=0;
for(int i=0;i<=1000;i++){
sum+=i;
}
return sum;
}
};
//callable不能直接作为Thread的构造方法参数
FutureTask<Integer> futuretask=new FutureTask<>(callable);
Thread t=new Thread(futuretask);
t.start();
//使用futuretask获取call方法的返回结果
//get类似于join一样,如果call没执行完,会阻塞等待
Integer result=futuretask.get();
System.out.println(result);
}
2.ReentranLock可重入锁
reentrant再进去的,可重入的
没有synchronized那么常用,但是也是一个可选的加锁的组件
加锁加锁分为两个方法
lock()
unlock()
它具有一些synchronized不具备的功能
1.提供一个tryLock方法加锁
对于lock,如果加锁不成功,就会阻塞等待(死等)
对于tryLock,如果加锁失败,直接返回false/也可以设定等待时间
2.有两种模式,可以工作在公平锁状态下,也可以工作在非公平锁状态下
构造方法中通过参数设定的公平/非公平模式
3.也有等待通知机制,搭配Condition这样的类来完成
但是reantrantLock劣势也明显,就是unlock容易遗忘,可以使用finally执行unlock。
区别:
synchronized锁对象是任意对象
reentrantLock锁对象就是自己本身
如果多个线程针对不同的reantrantLock调用lock方法,也就会对不同的对象加锁,此时不会产生锁竞争。
- 实际开发中,进行多线程开发,用到锁还是首选synchronized,因为它有很多优化提供给程序员使用。
3.Semaphore信号量
是并发编程中的一个重要的概念/组件。
它是一个计数器,描述了可用资源的个数
描述的是,当前这个线程,是否有”有临界资源可以用“
(临界资源:多个线程、进程等并发执行的实体可以公共使用的资源)
申请一个可用资源,计数器-1,称为P操作( accquire
释放一个可用资源,计数器+1,称为V操作 (release
- 当计数器数值位0时,若继续p操作,就会阻塞等待,一直等待到其他线程执行了V操作,释放一个空空闲资源为止;
这个阻塞等待的过程其实和锁很相似
实际上,锁本质上就是一个特殊的信号量(里面的数值,非0即1,二元信号量)
public static void main(String[] args) throws InterruptedException {
//构造方法中,就可以用来指定计数器的初始值
Semaphore semaphore=new Semaphore(4);
//申请一个可用资源,计数器-1
semaphore.acquire();
System.out.println("执行p操作");
semaphore.acquire();
System.out.println("执行p操作");
semaphore.acquire();
System.out.println("执行p操作");
semaphore.acquire();
System.out.println("执行p操作");
//计数器位0,阻塞等待
semaphore.acquire();
System.out.println("执行p操作");
}
4.CountDownLatch
针对特定场景中的一个组件。
有一些多线程下载器,把一个大的文件,拆分为多个小的部分,使用多的线程分别下载,每个线程负责下载一部分,每个线程分别是一个网络连接,这样就可以大幅度提高下载速度。
假设,分为10个线程来下载
什么时候算下载完了?
10个线程都下载完了,整体才算完成,
public static void main(String[] args) throws InterruptedException {
//构造方法中,指定创建几个任务
CountDownLatch countDownLatch=new CountDownLatch(10);
for(int i=0;i<10;i++){
//为了实现变量捕获,新创建一个变量id(实际上的final)
int id=i;
Thread t=new Thread(()->{
System.out.println("线程"+id+"开始工作");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程"+id+"结束工作");
//把10个线程看成10个田径运动员,countdown就是运动员撞线
countDownLatch.countDown();
});
t.start();
}
//主线程如何知道所有线程都完成了
/**
* 主线程中使用coutdownlatch负责等待任务结果
* await等待所有任务解锁
* 当countdown次数小于初始设置的次数,await就会阻塞
*
*/
countDownLatch.await();
System.out.println("多个线程的所有任务都执行完了");
}
CountDownLatch当需要把一个任务拆分称多个任务
二.集合类的线程安全问题
哪些是线程安全的?(多个线程同时操作这个集合类,是否会产生问题)
- Vector,HashTable,Stack是线程安全的,其他的集合类不是线程安全的。
注意:但是Vector和HashTable属于是Java上古时期的集合类。
加了锁也一定是线程安全,不加锁也不一定线程不安全,
还需要具体问题具体分析。
HashMap 和 HashTable 和 concurrentHashMap的区别:
HashMap线程不安全
HashTable线程安全,关键方法都提供了synchronized
ConcurrentHashMap 线程安全的hash表
HashTable 和concurrrentMashMap
1.HashTable是在方法上直接加上synchronized,就相当于针对this加锁
(意味着任何针对this对象的操作,都会涉及到针对this的加锁,此时,如果很多线程都想操作该对象,就会触发激烈的锁竞争,并发程度很低。
哈希桶/链表
如果两个修改操作,是针对两个不同的链表进行修改,是否会存在线程安全问题?
不会。
尽管如此,虽然没有线程安全为题,但是又不能完全不加锁。
比如同时有两个线程插入到两个链表之间,此时就会产生一些问题。
具体的做法:给每个链表都安排一把锁。
实质上,哈希表上的链表本身就有很多,两个线程同时操作同一个链表的概率本身就低,整体锁的开销就大大降低了。
由于synchronized任何都能用来了加锁,可以简单的使用每个链表的头结点作为锁对象。
ConCurrentHashMap改进:
1.核心:减小了锁的粒度,每个链表有一把琐,大部分情况下都不会涉及到锁竞争。
2.广泛使用了CAS操作(不会产生锁冲突)
3.写操作进行了加锁,读操作不加锁。
(如果是一个线程读,一个线程写,最多在修改的一瞬间,读到的是旧版本、新版本的数据,通过一些紧密的操作,保证不会读到“半个数据”)
4.针对扩容操作进行了优化,渐进式扩容。
HashTable一旦触发扩容,就会立即一口气的完成所有元素的搬运,这个过程相当耗时,大部分都比较顺畅,就怕突然某个请求卡壳比较久。
而这里采用化整为零,当需要扩容的时候,会创建出一个更大的数组,然后把旧的数组逐渐往新的数组上搬运,会出现一段时间,旧数组和新数组同时存在。
新增元素,往新增数组上插入
删除元素,把数组元素删掉即可
查找元素,新旧数组都查找
修改元素,统一把元素改到新数组上
以上操作都会触发一定程度搬运,多次少量地搬运就把之间地旧的数组销毁了
以上是HashTable和ConcurrentHashMap之间的区别,经典的面试题。
分段锁
Java8之前,concurrentHashMap使用分段锁,能提高效率,但是不如每个链表一把锁,并且代码实现起来比较复杂。
copyOnWriteArrayList
写时复制
多个线程同时修改同一个变量,必定会产生线程安全问题。
如果多个线程修改不同的变量,是不是安全呢?
如果是多线程读取,本身就不会有任何线程安全问题。
一旦有线程修改,就会把自己复制一份。
如果修改耗时的话,其他线程还是会从旧的数据上读取。
一旦修改完成,使用新的ArrayList替换旧的ArrayList替换旧的ArrayList
这个过程中,没有引入任何的加锁操作,使用的是
创建副本 --> 修改副本 --> 使用副本替换。