java并发编程day06

来源:互联网 发布:淘宝异地租车违章代办 编辑:程序博客网 时间:2024/06/08 19:27

第五章 构建快

5.1 同步容器

同步容器分为两部分:一个是vector和hashtable,早期是JDK的一部分;另一个是他们的同系容器,在后面被加入到同步封装类,这些类是由Collections.synchronizedXXX工厂方法提供的,这些类通过封装它们的状态,并对每一个公共的方法进行同步实现线程安全,这样一次只有一个线程能访问到容器的状态

5.1.1 同步容器中出现的问题

同步块都是线程安全的,但是对于复合操作,我们可能要试用额外的客户端加锁进行保护。通常对容器的复合操作包括:迭代,导航,条件运算,检查map是否存在关键字K,如果没有就加入mapping(k,v)。在一个同步的容器中,这些复合操作即使没有额外的客户端加锁的保护,技术上也是线程安全的,但是当其他线程能够并发修改容器的时候,它们就可能不会按照我们预定的方式去工作了。
如下面代码所示,两个操作Vector的方法,getLast和deleteLast,两个方法都是检查再运行的顺序操作,都是先通过size来确定数组的长度,然后根据这个size来获取和删除Vector的最后一个元素
这个看起来没有什么问题,但是线程A调用getLast从一个size为10的Vector中取最后一个元素的值,同时线程B此时调用deleteLast删除了Vector最后一个元素,如果此时我们先删除了下标为9的Vector【9】这个元素 但是size在A中已经读取为9 ,这时拿到的Vector【9】已经不存在了,所以跑出ArrayIndexOutOfBoundsException 异常。即A线程声明索引size 和return时中间时间被deleteLast了

public static Object getLast(Vector list){    int size = list.size()-1;    return list.get(size);}public static void deleteLast(Vector list){    int size = list.size()-1;    list.remove(size);}

这时我们可以将getLast和deleteLast变成原子操作来确保Vector大小在调用size和get的时候不会发生变化

public static Object getLast(Vector list){   synchronized(list){     int size = list.size()-1;     return list.get(size);   }}public static void deleteLast(Vector list){    synchronized(list){        int size = list.size()-1;        list.remove(size);    }}

但是这个风险在我们迭代的时候仍然会发生,如下代码:

for(int i=0;i<vector.size();i++){    doSomeThing(vector.get(i));}

这种迭代方式,依赖于对其他线程的信任 ,相信线程没有调用size和get中之间去修改vector,但是在多线程并发的时候,这个是可能的。单这并不代表Vector不是线程安全的。Vector的状态仍然是有效的,事实上异常正好使它保持了规约一致性。

迭代不可靠的问题也可以通过客户端加锁的方式来解决,不过这样意味着我们在持有vector的时候,阻止了其他线程的访问,这削弱了并发性。如下代码所示:

synchronized(vector){    for(int i=0;i<vector.size();i++){        doSomeThing(vector.get(i));    }}

5.1.2 迭代器和ConcurrentModificationException

对Connection进行迭代的标准时试用Iterator,无论显示的使用还是通过java5引入的for-each循环语法。这种使用Iterator迭代可以做到及时失败 即如果容器在迭代开启后被修改就会抛出一个未检查的ConcurrentModificationException。
这种及时修改的迭代器并没有很好的稳定设计,它们被用于善意运用的情况下捕获并发的错误,因此只能当做并发问题的预警。这是通过把修改计数器modification count与容器关联起来实现的:如果迭代期间计数器被修改,hasnext或者next就会抛出一个ConcurrentModificationException。不过,这样的检查是在没有同步的情况下进行的,即检查运行,存在一定风险,如果count的数据是过期数据,那么会导致迭代器检查的时候发现没有修改,运行代码。
如下代码所示,对容器进行for-each语法循环,java生成的代码内在的使用一个Itrator,重复调用hasNext和next对list进行迭代。正像Vector中的迭代一样,为了避免出现ConcurrentModificationException需要在迭代期间持有一个容器锁,但是因为一些原因我们不愿意在迭代期间对容器进行加锁。当其他线程需要访问容器时,必须等待,直到迭代结束,如果容器很大,或者对每一个元素的任务耗时比较长,那么其他线程可能需要等待很长时间,同样,我们上面代码容器被锁,doSomething调用还要持有另一个锁这是一个产生死锁风险的因素。即使没有饥饿锁,或者死锁的风险,在相当长一段时间把持容器锁,可会破坏程序的可伸缩性,保持锁的时间越长,对锁的竞争就越激烈,并且如果很多线程在等待锁的时间阻塞,吞吐量和CPU的性能会遭受影响。

List<Wight> list = Collections.synchronizedList(new ArrayList(Wight));...//可能抛出ConcurrentModificationExceptionfor(Wight w : list ){    doSomething(w);}

在迭代期间对容器加锁的替代方法就是复制一个容器,因为复制是线程限制的,没有其他线程能在迭代期间对其进行修改,这样消除了ConcurrentModificationException发生的可能性。(容器仍然需要在复制期间对自己进行加锁)。复制容器会有明显的性能开销。怎样取舍取决于需求。

5.1.3 隐藏迭代器

如下代码所示,字符串的拼接操作经过编译转换成调用StringBuilder.append(obj)方法来完成,它会调用容器的toString方法,而标准容器中的toString方法会通过迭代容器的每个元素,来获得关于容器内容格式良好的展现。addTenThings方法会抛出ConcurrentModificationException,因为容器是toString迭代的,这些发生在生成调试信息的过程中。当然。真正的问题在于HiddenIterator不是线程安全的,在调试println时,必须在试用set之前请求到HiddenIterator的锁,但是调试和记录日志的代码通常不会这么做。这里真正的教训是,状态和保护它的同步之间差距越大,人们越容易忘记在访问状态时正确试用同步。如果HiddenIterator把hashset包装为synchronizedSet封装了同步,这种错误就不会发生了。
另外,容器的hsahcode和equals方法也会间接的调用迭代,比如当容器本身作为一个元素时,或者作为另一个容器的key时。类似的,containsAll 、removeAll、retainAll方法,以及把容器作为参数的构造函数,都会对容器进行迭代。

public class HiddenIterator  {    private final Set<Integer> set = new HashSet<Integer>();    public synchronized void add(Integer i){        set.add(i);    }    public synchronized void remove(Integer i){        set.remove(i);    }    public void addTenThings(){        Random random = new Random();        for (int i = 0; i < 10; i++) {            add(random.nextInt());        }        System.out.println("DEBUG : add ten elements to "+set);    }}

5.2 并发容器

同步容器通过对容器的所有状态进行串行的访问,从而实现了它们的线程安全,这样做的代价是削弱了并发性,当多个线程共同竞争容器级的锁时,吞吐量就会降低
另一方面,并发容器是为了多线程并发访问设计的。java5.0添加了ConcurrentHashMap,来替代同步的hashMap实现;当多数操作为读取操作时,CoppyOnWriterArrayList是List相应的同步实现。新的ConcurrentMap接口加入了对常见复合操作的支持,比如缺少即加入(put-if-absent)、替换和条件删除
用并发容器替换同步容器,这种做法以很小的风险带来了可扩展性显著的提高。
java5.0同样添加了两个新的容器类型:Queue和BlockingQueue。
- Queue用来临时保存正在等待被进一步处理的一系列元素。JKD提供了几种实现,包括一个传统的FIFO队列,ConcurrentLinkedQueue;一个(非并发)具有优先级顺序的队列,priorityQueue。Queue的操作并不会阻塞:如果队列是空的,那么从队列中获取元素的操作会返回空值null 。尽管你可以用List来模拟Queue的行为,事实上,LinkedList就实现了Queue 但是我们还是需要Queue的类,因为如果忽略掉List的随机访问需求的话,试用Queue能得到更加高效的并发实现
- BlockingQueue扩展了Queue ,增加了可阻塞的插入和获取操作。如果队列是空的,一个获取操作会一直阻塞到队列中存在可以用元素;如果队列是满的(对于有界队列),插入操作会一直阻塞到队列中存在可用空间。阻塞队列在生产者-消费者设计中非常有用。
- 正像ConcurrentHashMap作为同步的hashMap的一个并发替代品,java6 加入了ConcurrentSkipListMap和ConcurrentSkipListSet,用来作为同步的SortedMap和SortedSet的并发替代品(比如用synchronizedMap包装的TreeMap或TreeSet)。

5.2.1 ConcurrentHashMap

同步容器类在每个操作的执行期间都持有一个锁。有一些操作,比如HashMap.get或者List.contains,可能会涉及到比预想更多的工作量:为寻找一个特定对象而遍历访问整个HashMap或List,必须调用大量对象的equals(equals本身还涉及相当数量的计算)。
在一个HashMap中,如果hashCode没有很好的分散,元素很可能不均衡的分布整个容器中,最极端的情况是一个不良的hash函数把一个hash表转化为一个线性链表。遍历一个很长的List并调用其中部分或者全部元素的equals方法,这回话费大量时间,并且这短时间由于锁的被持有,其他线程都不能访问这个容器。
ConcurrentHashMap和HashMap一样是一个Hash表,但是它使用完全不同的锁策略,可以提供更好的并发性和可伸缩性。在ConcurrentHashMap以前,程序试用一个公共锁同步每一个方法,并严格的限制只能有一个线程可以同时访问容器,而ConcurrentHashMap使用一个更加细化的锁机制,叫做分离锁。这个机制允许更深层次的共享访问,任意数量的读线程可以并发访问Map,读者和写者也可以并发的访问Map,并且有限数量的写线程还可以并发修改Map。结果是:为并发访问带来了更高的吞吐量,同时几乎没有损失单个线程访问的性能。
ConcurrentHashMap与其他的并发容器一起,进一步改进了同步容器类:提供不会抛出ConcurrentModificationException的迭代器,因此不需要在容器迭代中加锁。ConcurrentHashMap返回的迭代器具有弱一致性,而非及时失败的。弱一致性的迭代器允许并发修改,当迭代器被创建时,它会便利已有的元素,并且可以(但是不保证)感应到在迭代器被创建后对容器的修改。

同步Map实现提供的一个特性是为独占的访问加锁,这在ConcurrentHashMap中并没有实现,在HashTable和synchronizedMap中,获得Map的锁就可以防止任何其他线程访问该Map。这对于一些罕见的情况来说是必要的,比如原子化的加入一些映射,或者对元素进行若干次迭代,在这期间需要看到元素以同样的顺序出现。

5.2.2Map附加的原子操作

因为ConcurrentHashMap不能够在独占访问中被加锁,我们不能使用客户端加锁来创建新的原子操作不过一些常见的复合操作,如:缺少即加入、相等便移除 和相等便替换都已被实现为原子操作,并且这些操作已在ConcurrentMap接口中声明。

5.2.3 CopyOnWriterArrayList

CopyOnWriterArrayList是同步List的一个并发替代品,通常情况下它提供了更好的并发性,并避免了在迭代期间对容器加锁和复制。
写入时复制容器的线程安全性来源于这样一个事实,只要有效的不可变对象被正确的发布,那么访问它将不再需要更多的同步。在每次需要修改时,它们会创建并重新发布一个新的容器拷贝,以此来实现可变性。写入时复制容器的迭代器会保留一个底层基础数组的引用。这个数组作为迭代器的起点,永远不会被修改,因此对他的同步只不过是为了确保数组内容的可见性。因此,多个线程可以对这个容器进行迭代,并且不会收到另一个或多个想要修改容器的线程带来的干涉。写入时复制容器返回的迭代器不会抛出ConcurrentModificationException并且返回的元素严格与迭代器创建时相一致,不会考虑后续的修改。
每次容器改变时复制基础数据需要一定的开销,特别是当容器比较大的时候。当对容器迭代操作的频率圆圆高于对容器修改的频率时,试用写入时复制容器十个合理的选择。这个准则描述了许多时间通知系统:递交一个通知需要迭代已注册的监听器,并调用其中一个。在多数情况下,注册和注销一个事件监听器的次数要比收到事件哦通知的次数少很多。

原创粉丝点击