上篇文章:
多线程—JUChttps://blog.csdn.net/sniper_fandc/article/details/146713322?fromshare=blogdetail&sharetype=blogdetail&sharerId=146713322&sharerefer=PC&sharesource=sniper_fandc&sharefrom=from_link
目录
1 线程安全的集合类
1.1 ArrayList确保线程安全
1.2 Queue确保线程安全
1.3 哈希表确保线程安全
2 死锁
1 线程安全的集合类
前面讲过,Vector、Stack(Stack线程安全的原因是因为继承Vector实现)、HashTable、ConcurrentHashMap、StringBuffer是线程安全的,其他的集合类不是线程安全的,如果想要在多线程环境下使用集合类确保线程安全,就要结合确保线程安全的机制来使用:
1.1 ArrayList确保线程安全
1.使用synchronized或ReentrantLock。
2.Collections.synchronizedList(new ArrayList):synchronizedList是标准库提供的一个基于synchronized进行线程同步List,对可能影响到线程安全的操作上使用synchronized关键字。
3.CopyOnWriteArrayList:当多线程同时修改ArrayList时,会把容器拷贝一份,往拷贝的容器修改数据,在把拷贝容器替换掉原来的容器。但是引发缺点:当容器数据很多时,拷贝操作开销大;同时新修改的数据无法及时读取到。因此这个类适合读多写少的场景,读可以并发读,不用加锁,性能高。(实际上,在CopyOnWriteArrayList源码中,对于修改操作的拷贝是加了ReentrantLock锁的,但是加了锁再拷贝,拷贝的意义也就丢失了)
1.2 Queue确保线程安全
1.ArrayBlockingQueue:基于数组实现的阻塞队列。
2.LinkedBlockingQueue:基于链表实现的阻塞队列。
3.PriorityBlockingQueue:优先级阻塞队列(堆实现)。
4.TransferQueue:最多只包含一个元素的阻塞队列。
1.3 哈希表确保线程安全
1.Hashtable:内部实现把关键的方法都加了synchronized关键字,这等于对哈希表加了一把锁(锁对象是当前Hashtable对象),因此如果要并发访问不同位置的数据,也会产生锁竞争。
对于哈希表的size修改,也是使用synchronized控制。同时一旦触发哈希表扩容操作,就会导致当前线程的put方法负责完成扩容,此过程涉及大量元素拷贝,导致此次操作时间很长。解决方案:在Java 8中,ConcurrentHashMap对Hashtable做了一些优化。
2.ConcurrentHashMap:synchronized的锁粒度进行了细化,对每个哈希桶都加了一把锁,同一个哈希桶的元素修改才会涉及锁竞争问题,不同哈希桶的元素修改不涉及锁竞争。同时读操作不加锁(使用volatile关键字保证内存可见性),写操作加锁。
对于哈希表的size修改,使用CAS机制,避免使用重量级锁。对于扩容操作,采用“化整为零”策略:一旦触发扩容,首先创建一个新的更大容量的哈希表,插入元素时,插入到新的哈希表,查找元素时,新旧哈希表一起查询。对于扩容时的每一个操作,都参与元素移动的过程,即每次操作移动一小批元素(重新哈希),直到所有的元素都搬运完,旧的哈希表删除,完成扩容过程。这种扩容操作避免了扩容时某次操作时间过长的问题。
注意1:在Java 7时,ConcurrentHashMap的线程安全的实现是使用“分段锁”,即把若干哈希桶分为一个“段”,每个段共享一把锁,每个段的若干哈希桶的修改涉及锁竞争。底层实现是数组+链表(哈希桶)。而在Java 8,ConcurrentHashMap的线程安全的实现优化成上述每个桶一把锁的机制,底层实现是数据+链表/红黑树(链表元素>=8个,链表转化为红黑树)。
注意2:HashMap、Hashtable和ConcurrentHashMap的区别?从线程安全角度来讲:HashMap线程不安全,Hashtable和ConcurrentHashMap线程安全。从加锁粒度角度来讲:Hashtable的锁对象是当前Hashtable对象,ConcurrentHashMap的锁对象是每个链表的头结点,ConcurrentHashMap还利用了CAS机制保证Size的线程安全和使用“化整为零”的扩容机制。从Key是否为空角度来讲:HashMap的Key允许为null,Hashtable和ConcurrentHashMap的Key不允许为null。
2 死锁
死锁是多个线程中一个或多个同时等待某个资源的释放,因为线程之间相互阻塞,于是程序无法正常结束,这种现象就被称为死锁。
死锁的常见场景有:
1.一个线程一把锁:不可重入锁,当一个线程已经持有一把锁时,线程任务的执行逻辑还需要对该锁对象加锁,由于当前锁对象未被释放,因此就会导致自己等待自己释放资源的死锁现象。
2.两个线程两把锁:线程需要同时持有两把锁才能进行任务的执行,但是由于一个线程持有一把锁,另一个线程持有另一把锁,两个线程都等待对方的另一把锁的释放,因此导致了线程相互阻塞等待对方的锁的死锁现象。
java">Object lock1 = new Object();Object lock2 = new Object();Thread t1 = new Thread() {@Overridepublic void run() {synchronized (lock1) {synchronized (lock2) {//...}}}};t1.start();Thread t2 = new Thread() {@Overridepublic void run() {synchronized (lock2) {synchronized (lock1) {//...}}}};
t2.start();
3.多个线程多把锁:以哲学家就餐问题为例,一群哲学家在餐桌旁就餐,每个人左手边和右手边各一根筷子,两个人之间只有一根筷子,只有同时拿到两根筷子组成一双才能吃饭,如果每个哲学家都同时拿左边的筷子或右边的筷子,就会导致每个人都只拿到了一根筷子,从而每个人都无法就餐:
要解决这个死锁问题,可以为每根筷子编号,规定哲学家只能按编号从小到大的顺序取筷子,比如只有先拿到筷子1才能拿筷子2。也可以为哲学家编号,每次满足部分哲学家的就餐需求,比如奇数同时拿起左右两边的筷子(如果出现筷子不足的情况,就让某位哲学家放弃),待奇数放下筷子偶数再拿起左右两边的筷子就餐。
上述分析过程也体现了死锁的四个必要条件:
1.互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用。
2.不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
3.请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
4.循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源,这样就形成了一个等待环路。
这四个条件同时存在就构成了死锁,要解决死锁,破坏其中任何一个条件即可,最好的手段是破坏循环等待条件,即开发人员从代码逻辑入手:
java">Object lock1 = new Object();Object lock2 = new Object();Thread t1 = new Thread() {@Overridepublic void run() {synchronized (lock1) {synchronized (lock2) {//...}}}};t1.start();Thread t2 = new Thread() {@Overridepublic void run() {synchronized (lock1) {synchronized (lock2) {//...}}}};t2.start();
约定好获取锁的顺序,破坏了t1等t2,t2等t1的等待环路,从而解决了死锁问题。
多线程系列到此基本就结束了,下个系列更新文件IO的相关文章。