此篇文章与大家分享多线程专题的最后一篇文章:关于JUC常见的类以及线程安全的集合类
如果有不足的或者错误的请您指出!
目录
3.JUC(java.util.concurrent)常见的类
3.1Callable接口
Callable和Runnable一样,都是用来描述一个任务的
但是区别在于 ,用Callable描述的任务是有返回值的,而通过Runnable描述的任务是没有返回值的(即run方法的返回值是void)
通过Runnable,要想获取到"返回值",只能通过一些特定的手段
但是这个方法,主线程和 t线程的耦合太大了
而Callable就是为了会更优雅的解决上面的问题
但是Thread并没有提供这样的构造方法
我们可以将callable传入FutureTask
3.2 RentrantLock
表示可重入的锁
相对于我们常用的Synchronized,ReentrantLock是"手动"进行加锁和解锁的
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
//加锁
lock.lock();
//解锁
lock.unlock();
}
但是这种就容易"漏掉"解锁操作,就会出现大问题,因此我们经常搭配finally使用
既然这个这么麻烦,那还有存在的价值嘛??
实际上价值还是很大的
ReentrantLock提供了公平锁的实现
如果传入true就是表示公平锁,传入false / 不传 就是非公平锁
ReentrantLock提供了tryLock
所谓tryLock就是尝试加锁
如果在遇到锁已经被占有了,那就直接返回
而相比于synchronized则是阻塞等待
另外,除了直接返回外,tryLock还提供了带等待超时的版本
Condition
Synchronized是搭配 wait 和 notify使用
而ReentrantLock是搭配Condition使用
实际上Condition比wait和notify更加智能,因为它可以指定唤醒那个线程
3.3 Semaphore
表示信号量,用来表示"可用资源"的个数,本质上就是个计数器
围绕信号量主要有两个基本操作
(1)P操作,即申请资源,计数器 -1;
(2)V操作,即释放资源,计数器+1;
但我们申请的资源超过信号量本身的大小们,就会阻塞等待,直到其他地方释放资源
那么当资源数目为1的话,就可以当成锁来使用了
因为如果信号量有0 1两个取值,此时就是"二元信号量",本质上就是一把锁 |
3.4CountDownLatch
表示同时等待多个线程结束
是一个比较实用的工具
当我们把一个任务拆解成多个线程来完成时,就可以利用这个工具类来判断,任务整体是否完成了
此时的执行结果就是:
await会阻塞等待,一直到countDown调用的次数,和构造方法指定的次数一致的时候,await才会返回
而await不仅仅能够替代join,假设现在有1000个任务要交给4个线程来使用,那么如何判断1000个任务已经执行结束??就可以使用countDownLatch来判断 |
4.线程安全的集合类
原来的集合类.比如ArrayList,LinkedList,HashMap等等,都是线程不安全的
而Vector自带了synchronized,Stack继承了ector,HashTable也是自带的synchronized,在一定程度上是线程安全的
但是不能说太绝对,还是要具体情况具体分析 就比如可能出现下面这种情况:
就比如上述代码,线程1执行到if条件判断后,线程2把vector给清空了,就会出现bug
如果需要用到其他的类,就需要手动加锁,来保证线程安全,但不同情况下加锁的情况是不一样的,手动加锁是比较麻烦的
标准库就提供了一些具体的解决方法
4.1多线程环境下使用ArrayList
4.1.1Collection.synchronizedList(new ArrayList)
这种方法就相当于给这些集合类套了一层壳,壳上对集合类里面的一些关键方法加上了锁,起到了类似Vector的效果
4.1.2CopyOnWriteArrayList
利用的是"写时拷贝"的思想
假设我们现在有一组数据为1 2 3 4,此时某个线程对数据进行了修改,就把2 修改成200,3修改成300,但是在修改的时候有别到线程在读,如果直接修改就有可能出现2,300这样的中间数据
而写时拷贝就是将原来的数据集拷贝一份,这样修改的时候是在新拷贝的数据集上修改的,而读的时候是在旧的数据集上读的
等到修改完后,就用新的数据集的引用代替原来旧的数据集的引用
这样的过程中,不会出现任何加锁和阻塞等待,也保证读数据不会出现"错误的数据"
这种操作实际上实用性非常高,就比如有的服务器需要更新配置文件 / 数据文件,就可以采取上述策略 |
4.2多线程使用队列
直接使用BlockingQueue即可
4.3多线程使用哈希表
HashMap是线程不安全的,而HashTable是带锁的
但是实际上HashTable并不推荐使用
因为HashTable本质上就是简单粗暴将每一个方法都进行加锁,就相当于针对了this加锁,此时只要针对HashTable上的元素进行操作,就都会涉及到锁
推荐使用的是 ConcurrentHashTable
它的优点就在于:
(1)采用锁桶的方式,来代替之前的"全局一把锁"
此时如果两个线程针对的是不同链表上的元素进行操作,是不会涉及到锁冲突的
而本身,操作两个链表上的元素,不涉及公共变量,是不会有线程安全问题的
进行这样的操作实际上收益是很多的
因为在一个Hash表里面,桶的数量是很多的,此时按照我们上面的操作进行加锁,大部分情况是可以避免锁冲突的
那么好像锁多了,锁对象就多了是不是更加麻烦了??
实际上,由于java中任何的对象都可以作为锁对象,我们只需将每一个链表的头结点作为锁对象即可
(2)引入CAS机制
实际上即使是上面的操作,也不能保证线程安全
像哈希表的size,即使你插入的是不同链表的元素,修改的时候也会涉及到多线程修改同一个变量
此时引入了CAS机制,通过CAS来修改size,也就不涉及加锁操作了
(3)针对扩容进行了特殊优化
在哈希表中,如果发现负载因子太大了,就需要扩容,而扩容是一比较低效的操作,普通的hash表如果要在一次put完成整个扩容操作,就会使得put非常卡,如果平时使用put假设是1ms,但某次put执行了1000ms,就会造成不好的体验
ConcurrentHashMap进行的实际上是"化整为零",在扩容的时候会搞两份空间
一份是扩容前的空间,一份是扩容后的空间
接下载每次进行哈希表的基本操作的时候,都会将一部分数据从旧空间搬到新空间
不是一口气搬完,分多次搬
搬的过程中,
如果进行的是插入操作,那就插到新的空间里面
如果是删除,那么旧的新的都会删除
如果是查找,那么旧的新的都要查找一遍
就是"重哈希"过程,重哈希过程结束的标志通常是所有元素都被成功地移动到了新的空间中,并且旧空间中不再包含任何元素。