目录
一、JUC的常见类
1、Callable接口
1.1callable与runnable
1.2代码实例
(1)不使用Callable实现
(2)使用Callable实现
1.3理解Callable
1.4理解FutureTask
2、ReentrantLock
2.1ReentrantLock的用法
2.2ReentrantLock优势
3、原子类
4、Semaphore信号量
4.1理解信号量
5、CountDownLatch
5.1理解CountDownLatch
5.2主要的两个方法
二、线程安全的集合类
1、多线程环境下使用ArrayList
2、多线程环境下使用队列
(1)ArrayBlockingQueue
(2)LinkedBlockingQueue
(3)PriorityBlockingQueue
(4)TransferQueue
3、多线程环境下使用哈希表【重点】
3.1Hashtable
3.2ConcurrentHashMap
JUC:java.util.concurrent
各种集合类,scanner、random...
concurrent 并发,放了很多并发编程(多线程)相关组件
类似于Rannable 用来描述一个任务
Rannable用来描述一个任务,描述的任务没有返回值。
Callable也是用来描述一个任务,描述的任务有返回值。
如果需要使用一个线程单独的计算某个结果来,此时使用Callable是比较合适的。
创建线程计算1到1000的累加和
创建一个类Result,包含一个sum表示最终结果,lock表示线程同步使用的锁对象。
main方法中先创建Result实例,然后创建一个线程t,在线程内部计算1到1000的累加和
主线程同时使用wait等待线程t计算结束(注意如果执行到wait之前,线程t已经计算完了,就不必等待了)
当线程t计算完毕后,通过notify唤醒主线程,主线程再打印结果
代码:
class Result{
public int sum = 0;
public Object lock = new Object();
}
public class ThreadD29_1 {
public static void main(String[] args) throws InterruptedException {
Result result = new Result();
Thread t = new Thread(){
@Override
public void run() {
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += i;
}
synchronized (result.lock){
result.sum = sum;
result.lock.notify();
}
}
};
t.start();
synchronized (result.lock) {
while(result.sum == 0){
result.lock.wait();
}
System.out.println(result.sum);
}
}
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable callable = new Callable() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += i;
}
return sum;
}
};
FutureTask futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
int result = futureTask.get();
System.out.println(result);
}
对上述代码的一些理解:
(1)不能直接把callable传到Thread中
应该为:
FutureTask:未来的一个任务
get方法就是获取结果
get会发生阻塞,直到callable执行完毕,get才阻塞完成,才获取到结果。
(1)callable和Runnable是相对的,都是描述一个“任务”。Callable描述的是带有返回值的任务Runnable描述的是带有返回值的任务。
(2)Callable通常需要搭配FutureTask来使用。FutureTask用来保存Callabe的返回结果。因为Callable往往是再另一个线程中执行的,啥时候执行完并不确定。
(3)FutureTask就可以负责这个等待结果出来的工作。
例如我们在商场吃饭,点餐好了之后后厨就开始做饭,同时在窗口营业员会给你一张“取餐码”,这个“取餐码”就是FutureTask,后面我们可以随时拿着这个取餐码去查看自己的餐食有没有做出来。
标准库给我们提供的另一种锁。可重入互斥锁,和synchronized定位类似,都是用来实现互斥效果,保证线程安全。
synchronized是直接基于代码的方式来加锁解锁的。
ReentrantLock更传统,使用lock和unlock方法加锁解锁。(最大的问题是unlock可能会执行不到)
建议把unlock放到finally中
(1)lock() :加锁,如果获取不到锁就死等【存在return或者异常都可能导致不能顺利执行解锁】
(2)trylock():加锁,如果获取不到锁,等待一定的时间之后就放弃加锁
(3)unlock():解锁
(1)提供了公平锁版本的实现
(2)ReentranrLock提供了更加灵活的等待方式:tryLock
对于synchronized来说提供的加锁操作就是“死等”,只要获取不到锁,就一直阻塞等待。
无参数版:能加锁就加,加不上锁就放弃。
有参数版:指定了超时时间,加不上锁就等待一会,如果等一会时间到了也没加上就放弃。
(3)ReentrantLock提供了一个更强大,更方便的等待通知机制
synchronized搭配的是wait、notify的时候随机唤醒一个线程。
ReentrantLock搭配的是一个Condition类,进行唤醒的时候可以唤醒指定的线程。
虽然RentrantLock有有一定的优势,但是在一般情况下还是使用synchronized。
原子类内部是使用CAS实现的,所以性能要比加锁实现i++高很多,原子类有以下几个:
AtomicBoolean |
AtomicInteger |
AtomicIntegerArray |
AtomicLong |
AtomicReference |
AtomicStampedReference |
addAndGet(int delta); | i +=delta; |
decrementAndGet(); | --i; |
getAndDecrement(); | i--; |
incremenrAndGet(); | ++i; |
getAndIncrement(); | i++; |
基于CAS,确实是更高效的解决了线程的安全问题,但是CAS不能代替锁,CAS的适用范围有限,不像锁适用的范围广。
信号量:用来表示“可用资源”的个数,本质上就是一个计数器。
信号量可以和生活实际相联系。
假设现在在A停车场,当前的车位有100个,表示有100个可用资源,当有车开进去的时候就相当于申请了一个可用资源,,可用车位就-1(这个称为信号量的P操作)。当有车从A停车场开出去的时候,就相当于释放了一个可用资源,可用车位就+1(这个称为信号量的V操作),如果计数器的值已经为0了,还尝试申请资源,就会阻塞等待,直到有其他的线程释放资源。
Semaphore的PV操作中的加减计数操作都是原子的,可以在多线程下直接使用。
实际开发中,虽然锁是最常用的,但是信号量偶尔也会用到,主要是看实际的需求场景。代码中也是可以使用Semaphore来实现类似于锁的效果,来保证线程安全的。
锁可以视为是计数器为1的信号量。二元信号量,锁是信号量的一种特殊情况,信号量就是锁的一般表达。
简单了解即可,使用的不是特别多,【特定场景】
首先举一个例子:跑步比赛。开始的时间明确,结束的时间不明确。为了等待这和个跑步比赛结束,引入CountDownLatch。
(1)await(wait是等待,a=>all)主线程来调用这个方法
(2)countDown 表示选手冲过了终点线
CountDownLatch在构造的时候,指定一个计数(选手的个数)。
例如指定四个选手进行比赛,初始情况下调用await就会阻塞,每个选手冲过终点就会调用countDown方法。
前三次调用countDown,await没有任何影响
第四次调用countDown,await就会被唤醒返回(解除阻塞)此时就可以认为比赛就结束了。
在实际的开发中,CountDownLatch也是有很多使用场景的,比如下载一个大文件。(视频文件好几个G,把一个大文件切分成好多个小块安排多个线程分别下载)
原来的集合类,大部分都是线程不安全的
Vector、Stack、HashTable是线程安全的(不建议用),其他的集合类不是线程安全的。
(1)自己使用同步机制(synchronnized 或者ReentrantLock)[常见]
(2)Collentions.synchronnizedList(new ArrayList);
这里会提供一些ArrayList相关的方法,同时是带锁的,使用这个方法把 集合类 套一层。
synchronizedList是标准库提供的一个基于synchronized进行线程同步的List。
synchronizedList的关键操作上都带有synchronnized
(3)使用CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器“COW”也叫“写时拷贝”
如果针对这个ArrayList进行读操作,不做任何额外的工作。如果进行写操作,则拷贝一份新的ArrayList,针对新的进行修改,修改过程中如果有读操作,就继续读旧的这份数据,当修改完毕,使用新的替换旧的(本质上就是一个引用之间的赋值,原子的)
很明显,这种方案优点是不需要加锁,缺点是要求这个ArrayList不能太大,只能适用于这种数组比较小的情况下
服务器的程序进行配置和维护,一个程序可能包含很多子功能,有个功能想要使用,有的功能不想,有的希望应用到不同形态...就可以使用一系列的“开关选项”来控制当前程序的工作状态。
服务器程序的配置文件可能会需要进行修改,修改配置可能就需要重启服务器才能生效,重启服务器的成本还高。因此很多服务器都提供了“热加载” reload。
理解热加载?
不重启服务器实现配置更新。新的配置放在新的对象中,加载过程里,请求依然基于旧的配置工作。当新对象加载完成实验新的对象替代旧对象(替换完成旧对象释放)
小结:
优点:在读多写少的情况下,性能很高,不需要加锁竞争。
缺点:占用内存较多;新写的数据不能被第一时间读取到。
基于数组阻塞队列实现
基于链表实现的阻塞队列
基于堆实现的阻塞队列
最多只包含一个元素的阻塞队列
HashMap本身不是线程安全的
在多线程环境下使用哈希表可以使用:HashTable、ConcurrentHashMap
更推荐使用的是ConcurrentHashMap,更优化的线程安全哈希表
只是简单的把关键方法加上了关键字synchronized这相当于直接对Hashtable对象本身加锁
如果多线程访问同一个Hashtable就会直造成锁冲突
size属性也是通过synchronized来控制同步的,也是比较慢的
一旦触发扩容,就由该线程完成整个扩容过程,这个过程会涉及到大量的元素拷贝,效率很低
如图所示,元素1和元素2在同一个链表上,如果线程A修改元素1,线程B修改元素2,此时就会有线程安全问题。
如果线程A修改元素3,线程B修改元素4,这个就相当于是多个线程修改不同的变量
相比于Hashtable做出了一系列的改进和优化【以Java 1.8为例】
(1)最大的优化之处在于CurrentHashMap相比于Hashtable大大缩小了锁冲突的概率,把一把大锁转换成多把小锁。
HashTable做法是直接在方法上加synchronized,等于是给this加锁,只要操作哈希表上的任意元素都会产生加锁,也就都可能发生锁冲突。但是实际上,仔细思考不难发现,基于哈希表的特点,有些元素在进行并发操作的时候,是不会产生线程安全问题的,也不需要使用锁来控制。
上述谈到的情况是针对JDK1.8及其以后的情况,在1.7和之前,ConcurrentHashMap使用的是分段锁。分段锁本质上也是缩小锁的范围,从而降低锁冲突的概率。但是这种做法不够彻底。一方面粒度不够细,另一方面代码实现也更繁琐。
(2)ConcurrentHashMap做了一个激进的操作
针对读操作不加锁,只针对写操作加锁。【但是使用了volatile保证内存读取结果】加锁方式依然是用的sunchronized,但是不是锁的整个对象,而是“锁桶”,用每个链表的头结点作为锁对象,大大降低了锁冲突的概率。
读与读之间无冲突
写与写之间有冲突
读与写之间也没有冲突
很多场景下,读写之间不加以控制的话,可能就会读到一个写了一半的结果,如果操作不是原子的,此时读就可能会读到写了一半的数据,相当于脏读。
(3)ConcurrentHashMap内部充分的使用CAS,通过这个也来进一步削减加锁操作的数目。比如维护元素个数
(4)针对扩容,采取“化整为零”的方式
HashMap/HashTable扩容:
创建一个更大的数组空间哦,把旧的数组上的链表上的每个元素搬运到新的数组上(插入+删除)这个扩容会在某次put的时候进行触发。如果元素个数特别多,就会导致这样的搬运操作,比较耗时,就会出现某次put比平时put卡很多倍。
ConcurrentHashMap扩容:
扩容采取的是每次搬运一小部分元素的方式,创建新的数组,旧的数组也保留;每次put操作,就会往新数组中添加,同时进行一部分搬运(把一小部分旧的元素搬运到新数组上),每次get的时候,旧的数组和新数组都查询,每次remove的时候,只是把元素删了就可以。
下一篇将更新这一部分的相关面试题~