【读书笔记】《Java并发编程实战》第五章 同步容器类与并发容器类

同步容器类

同步容器可以简单地理解为通过synchronized来实现同步的容器,如果有多个线程调用同步容器的方法,它们将会串行执行。

同步容器将它们的状态封装起来,并对每一个公有方法进行同步。主要包括:

  • Vector
  • Stack
  • HashTable
  • Collections.synchronized方法生成,例如:
      Collectinons.synchronizedList()
     Collections.synchronizedSet()
     Collections.synchronizedMap()
     Collections.synchronizedSortedSet()
     Collections.synchronizedSortedMap()

使用同步容器类的问题

同步容器在单线程的环境下能够保证线程安全,但是通过synchronized同步方法将访问操作串行化,导致并发环境下效率低下。而且同步容器在多线程环境下的复合操作(迭代、条件运算如没有则添加等)是非线程安全,需要客户端代码来实现加锁。下面为错误示例:

//Vector上可能导致混乱结果的复合操作
public static Object getLast(Vector list) {
	int lastIndex = list.size() - 1;
	return list.get(lastIndex);
}

public static void deleteLast(Vector list) {
	int lastIndex = list.size() - 1;
	list.remove(lastIndex);
}

这些方法看似没有问题,无论多少个线程同时调用他们,也不破坏Vector。但从这些方法的调用者角度来看,情况就不同了。如果线程A在包含10个元素的Vector上调用getLast,同时线程B在同一个Vector上调用deleteLast,getLast将抛出ArrayIndexOutOfBoundsExecption异常。下面为客户端加锁示例:

//在使用客户端加锁的Vector上的复合操作
public static Object getLast(Vector list) {
	synchronized (list) {
		int lastIndex = list.size() - 1;
		return list.get(lastIndex);
	}
}

public static void deleteLast(Vector list) {
	synchronized (list) {
		int lastIndex = list.size() - 1;
		list.remove(lastIndex);
	}
}

同步容器类通过其自身的锁来保护它的每个方法。

有的小伙伴可能会问,客户端加锁示例是不是存在问题,getLast方法中获取了list对象锁,在获取了锁之后又调用list的size方法,因为同步容器类中的每个方法都由内置锁保护,这样不是获取了内置锁之后再获取内置锁会导致线程死锁呀?

这个其实不用担心,因为Java中的内置锁是可重入的。重入就是如果某个线程试图获取一个已经由它自己持有的锁,那么这个请求会成功。

同上面的示例,在调用size和相应的get时,Vector的长度可能发生变化,这种风险在对Vector元素进行迭代时仍然会出现。如下面示例:

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

使用客户端加锁可以解决,但要牺牲一些伸缩性,如下面示例:

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

迭代器与ConcurrentModificationException

上面示例中,我们使用了Vector,虽然这是一个“古老”的容器类,然而许多“现代”的容器类也并没有消除复合操作中的问题。如果有其他线程并发地修改容器,无论使用直接迭代还是for-each语法或者使用迭代器,都无法避免在迭代期间对容器加锁。当发现在容器迭代过程中被修改时,就会抛出一个ConcurrentModificationException异常。

与迭代Vector一样,要想避免出现ConcurrentModificationException,就必须在迭代过程中持有容器的锁

如果不希望在迭代期间对容器加锁,有一种替代方法就是“克隆”容器,并在副本上进行迭代(在克隆过程中仍然需要对容器加锁)。由于副本被封闭在线程内,因此其他线程不会在迭代期间对其进行修改,这样就避免了抛出ConcurrentModificationException异常。

隐藏的迭代器

虽然加锁可以防止迭代器抛出ConcurrentModificationException,但必须要记住在所有对共享容器进行迭代的地方都需要加锁。然而在某些情况下,迭代器会隐藏起来。如下面示例:

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 r = new Random();
		for (int i=0; i < 10 ; i++) {
			add(r.nextInt());
		}
		*****System.out.println("DEBUG: added ten elements to " + set);*****
	}
}

被*****标注的一行隐式的执行了迭代操作。编译器将字符串的连接操作转换为调用StringBuilder.append(Object),而这个方法又会调用容器的toString方法,标准容器的toString方法将迭代容器,并在每个元素上调用toString来生成容器内容的格式化表示。

当容器作为另一个容器的元素或键值时,就会执行迭代操作。例如容器的

  • hashCode方法
  • equals方法
  • containsAll方法
  • removeAll方法
  • retainAll方法
  • 把容器作为参数的构造方法

这些操作都会执行迭代操作,所有这些间接的迭代操作都可能抛出ConcurrentModificationException异常。

并发容器

Java提供了多种并发容器来改进同步容器的性能。增加了ConcerrentHashMap,用来替代同步且基于散列的Map,增加了CopyOnWriteArrayList,用于在遍历操作为主要操作的情况下代替同步的List。

ConcerrentHashMap

同步容器类在每个操作期间都持有一个锁。但ConcerrentHashMap并不是将每个方法都在同一个锁上同步,而是使用了粒度更细的分段锁机制

ConcerrentHashMap与其他并发容器一起增强了同步容器类:它们提供的迭代器不会抛出ConcurrentModificationException,因此不需要在迭代过程中对容器加锁。ConcerrentHashMap返回的迭代器具有弱一致性。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反映给容器。

大多数情况下,用ConcerrentHashMap来代替同步Map能进一步提高代码的可伸缩性。当应用程序需要加锁Map以进行独占访问时(或者需要依赖于同步Map带来的一些其他作用)时,才应该放弃使用ConcerrentHashMap。

CopyOnWriteArrayList

CopyOnWriteArrayList用于替代同步List,它提供了更改的并发性能,并且在迭代期间不需要对容器进行加锁或复制。(类似地,CopyOnWriteArraySet的作用时替代同步的Set)

“写入时复制(Copy-On-Write)”容器的线程安全性在于,只要正确地发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。

仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。

阻塞队列和生产者-消费者模式

阻塞队列提供了可阻塞的put和take方法,以及支持定时的offer和poll方法。如果队列已经满了,那么put方法将阻塞直到有空间可用;如果队列为空,那么take方法将会阻塞直到有元素可用。

阻塞队列也提供了一个offer方法,如果数据项不能被添加到队列中,那么将返回一个失败状态、

阻塞队列支持生产者-消费者这种设计模式。该模式将“找出需要完成的工作”与“执行工作”这两个过程分离开来,并把工作项放入一个“待完成”列表中以便在随后处理,而不是找出后立即处理。

一种常见的生产者-消费者设计模式就是线程池与工作队列的组合,在Executor任务执行框架中就体现了这种模式。

在类库中包含了BlockingQueue的多种实现,其中,LinkedBlockingQueue和ArrayBlockingQueue是FIFO队列,PriorityBlockingQueue是一个按优先级排列的队列,SynchronousQueue与其他队列不同,它维护一组线程,这些线程在等待着把元素加入或移出队列。因为SynchronousQueue没有存储功能,因此put和take会一直阻塞,直到有另一个线程已经准备好参与到交付过程中。

同步工具类

同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。阻塞队列可以作为同步工具类,其他类型的同步工具类还包括信号量(Semaphore)、栅栏(Barruer)以及闭锁(Latch)

闭锁

闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态

CountDownLatch是一种灵活的闭锁实现,可以在上述各种情况中使用,它可以使一个或多个线程等待一组事件发生。闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。countDown方法递减计数器,表示有一个事件已经发生了,而await方法等待计数器达到零,这表示所有需要等待的事件都已经发生。如果计数器的值非零,那么await会一直阻塞直到计数器为零,或者等待中的线程中断,或者等待超时

//CountDownLatch使用案例
//在计时测试中使用CountDownLatch来启动和停止线程
    public class TestHarness {
 	   //任务task就一份逻辑,用nThreads个线程并发执行
        public long timeTasks(int nthreads, final Runnable task) throws InterruptedException {
            final CountDownLatch startGate = new CountDownLatch(1);
            final CountDownLatch endGate = new CountDownLatch(nthreads);
     
            for (int i = 0; i < nthreads; i++) {
                Thread t = new Thread(){
                    @Override
                    public void run() {
                        try{
                            startGate.await();//在每一个工作线程的开始处放一道栏杆,在主线程中控制开启
                            try{
                                task.run();
                            }finally {
                                endGate.countDown();//每个线程运行完一个逻辑,关闭门数量就减一
                            }
                        } catch (InterruptedException ignored) {}
                    }
                };
                t.start();
            }
     
            long start = System.nanoTime();
            startGate.countDown();//启动门使得主线程同时释放所有工作线程
            endGate.await();//结束门使主线程等待最后一个线程执行完成,而不是顺序地等待每个线程执行完成
            long end = System.nanoTime();
            return end-start;
        }
    }

FutureTask

FutureTask也可以用作闭锁。FutureTask表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于以下3种状态:等待运行,正在运行和运行完成。“执行完成”表示计算的所有可能结束方法,包括正常结束、由于取消而结束和由于异常而结束等。当FutureTask进入完成状态后,它会永远停止在这个状态上

Future.get的行为取决于任务的状态。如果任务已经完成,那么get会立即返回结果,否则get将阻塞直到任务进入完成的状态,然后返回结果或者抛出异常。FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程,而FutureTask的规范确保了这种传递过程能实现结果的安全发布。

//FutureTask使用案例
//使用FutureTask来提前加载稍后需要的资源
public class Preloader {
	private final FutureTask<ProductInfo> future = new FutureTask<ProductInfo>(new Callable<ProductInfo>() {
		public ProductInfo call() throws DataLoadException {
			return loadProductInfo();
		}
	});

	private final Thread thread = new Thread(future);

	//在构造函数和静态初始化方法中启动线程不是一种好方法,
    //因此提供start方法来启动线程
	public void start() {
		thread.start();
	}

	public ProductInfo get() throws DaraLoadException, InterruptedException {
		try {
			return future.get();
		}
		catch (ExecutionException e) {
			Throwable cause = e.getCause();
			if (cause instanceof DataLoadException)
				throw (DataLoadException) cause;
			else
				throw launderThrowable(cause);
		}
	}
}

信号量

计数信号量用来控制同时访问某个特定资源的操作数量或者同时执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。

Semaphore中管理着一组虚拟的许可,许可的初始数量可通过构造函数来指定。在执行操作时可以首先获得许可(只要还有剩余的许可),并在使用以后释放许可。如果没有许可那么acquire将阻塞直到有许可(或者直到被中断或者操作超时)。release方法将返回一个许可给信号量。

//Semaphore使用案例
//使用Semaphore为容器设置边界
class BoundedHashSet<T> {
	private final Set<T> set;
	private final Semaphore semaphore;
	
	/**
     * 信号量的计数值会初始化为容器的最大值。
     */
	public BoundedHashSet(int bound) {
		set = Collections.synchronizedSet(new HashSet<T>());
		semaphore = new Semaphore(bound);
	}
	
	/**
     * add操作向底层容器中添加一个元素之前,首先获取一个许可。
     * 如果add操作没有添加任何元素,那么会立刻释放许可。
     */
	public boolean add(T t) throws InterruptedException {
		semaphore.acquire();//获取(消费)一个许可
		boolean wasAdded = set.add(t);
		if (!wasAdded) {
			semaphore.release();//release()方法将返回一个许可给信号量
		}
		return wasAdded;
	}
	
	/**
     * remove操作释放一个许可,使更多的元素能添加到容器中来。
     * 底层的Set实现并不知道关于边界的任何信息,这是由BoundedHashSet来处理的
     * @param o
     * @return
     */
	public boolean remove(T t) {
		boolean wasRemoved = set.remove(t);
		if (wasRemoved) {
			semaphore.release();//release()方法将返回一个许可给信号量
		}
		return wasRemoved;
	}
	
}

栅栏

栅栏类似于闭锁,它能阻塞一组线程直到某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程

CyclicBarrier可以使一定数量的参与方反复地在栅栏位置汇集。当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有线程都达到栅栏位置。如果所有线程都到达了栅栏位置,那么栅栏打开,此时所有线程都被释放,而栅栏将被重置以便下次使用。如果成功地通过栅栏,那么await将为每个线程放回一个唯一的到达索引号,我们可以利用这些索引来“选举”产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊的工作。CyclicBarrier还可以使你将一个栅栏操作传递给构造函数,这是一个Runnable,当成功通过栅栏时会(在一个子任务线程中)执行它,但在阻塞线程被释放之前是不能执行的。

另一种形式的栅栏时Exchanger,它是一种两方(Two-Party)栅栏,各方在栅栏位置上交换数据。当两方执行不对称的操作时,Exchanger会非常有用。

猜你喜欢

转载自blog.csdn.net/Handsome_Le_le/article/details/107493850