并发编程实战-基础构建模块

本章介绍一些Java安全容器类以及一些同步工具类

1.同步容器类

同步容器类包括:VectorHashTable,此外还包括一些功能类似的类(Collections.synchronizedXXX)
实现它们的方式是:将他们状态封装起来,并对每个公有方法都进行同步,使每次只有一个线程能访问容器的状态.

1.1 同步容器类的问题

某些情况下可能需要加锁来保护复合操作.
常见的符合操作有:

  • 迭代:遍历容器
  • 跳转:根据指定顺序找到当前元素的下一个元素
  • 条件运算:若没有则添加

这些符合操作本身是线程安全的,但并发修改容器时,可能会表现异常.

public static Objet 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);
}

虽然无论多少个线程同时调用它们,也不会破坏线程安全性,但如果同时调用他们时,有可能会抛出ArrayIndexOutOfBoundsException异常(A线程调用getLast后index为10,还没返回时,B线程remove了最后一个值,则A线程返回时会抛异常)
调用者并不希望看到这样的结果,尽管它符合规范.
可以用容器类的锁来进行同步,使这两个操作都成为原子操作:

public static Objet 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);
	}
}

迭代容器时同理,为确保迭代过程没有线程修改容器,需要同步

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

虽然加锁能保证同步机制,但如果容器数据量较大时,加锁会导致其它线程阻塞,进而等待较长时间.
如果不希望迭代期间加锁,一种替代方案是使用克隆容器,并在副本上进行迭代.因为副本被封闭在线程内部,因此其它线程不会对其进行修改.
克隆容器仍然会带来性能开销.

1.2 隐藏迭代器

某些情况下,迭代器会隐藏起来:

public class HiddenIterator {
    @GuardedBy("this")
    private final Set<Integer> set = new HashSet<>();
    
    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);
    }
}

上例中,System.out.println("DEBUG: added ten elements to " + set);方法,toString()就会隐式的迭代函数,所以在使用set前,必须首先获得HiddenIterator 的锁,这里通常会被忽略掉.

正如封装对象的状态有助于维持不变性条件一样,封装对象的同步机制同样有助于确保实施同步策略.

此外,容器的hashCode和equals,containsAll,removeAll和retainAll方法,以及把容器作为参数的构造函数,都会对容器进行迭代.

2.并发容器

并发容器包括ConcurrentHashMap,CopyOnWriteArrayList等,这些并发容器接口中添加了一些常用的复合操作,如"若没有则添加",替换,有条件删除等.

通过并发容器来代替同步容器,可以极大的提高伸缩性并降低风险.

2.1 ConcurrentHashMap

ConcurrentHashMap使用了完全不同的加锁策略来提高并发性和伸缩性.它并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制称为分段锁
这种机制下,读线程和写线程可以并发的访问Map,并且写线程可以并发地修改Map.
但是对于一些需要在整个Map上进行计算的方法,例如size()和isEmpty(),并发环境中,它返回的只是一个估计值,因为并发时返回值总是在不对变化的,因此这些操作的需求被弱化了,以换取对其他更重要操作的性能优化(get,put,containsKey,remove等)

2.2 CopyOnWriteArrayList

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

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

阻塞队列提供了可阻塞的 put和 take方法,以及支持定时的offer和poll方法.
如果队列已经满了,那么put方法将阻塞直到有空间可用;如果队列为空,则take方法会阻塞直到有元素可用.
队列可以有界也可以无界,无界队列永远不会充满,所以永远不会阻塞.

可灵活的调整生产者或消费者的线程数,来达到作家的性能

在构建高可用应用程序时,有界队列是一种强大的资源管理工具:他们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮.

BlockingQueue多种实现:

  • LinkedBlockingQueue: FIFO队列.类似LinkedList
  • ArrayBlockingQueue: FIFO队列,类似ArrayList
  • PriorityBlockingQueue: 可设置优先级的队列
  • SynchronousQueue: 并没有维护一个存储空间,而是直接维护一组线程,这些线程等待着将元素加入或移出队列.

3.1 示例:桌面搜索

public class FileCrawler implements Runnable {

    private final BlockingQueue<File> fileQueue;
    private final FileFilter fileFilter;
    private final File root;

    public FileCrawler(BlockingQueue<File> fileQueue, FileFilter fileFilter, File root) {
        this.fileQueue = fileQueue;
        this.fileFilter = fileFilter;
        this.root = root;
    }

    @Override
    public void run() {
        try {
            crawl(root);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    /**
     * 获取文件索引路径,并放到队列中
     */
    private void crawl(File root) throws InterruptedException {
        File[] entries = root.listFiles(fileFilter);
        if (entries != null) {
            for (File entry : entries) {
                if (entry.isDirectory()) {
                    crawl(entry);
                } else if (!alreadyIndexed(entry)) {
                    fileQueue.put(entry);
                }
            }
        }
    }
    private boolean alreadyIndexed(File entry) {
    	//doSomething
        return false;
    }
}
public class Indexer implements Runnable {

    private final BlockingQueue<File> queue;
    public Indexer(BlockingQueue<File> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        try {
            while (true) {
                indexFile(queue.take());
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    /**
     * 从队列中取出数据,并执行相应操作
     */
    private void indexFile(File take) {
		//doSomething
    }
}
    public static void startIndexing(File[] roots) {
        LinkedBlockingQueue<File> queue = new LinkedBlockingQueue<>(20);
        FileFilter filter = new FileFilter() {
            @Override
            public boolean accept(File pathname) {
                return true;
            }
        };
        for (File root : roots) {
            new Thread(new FileCrawler(queue,filter,root)).start();
        }
        for (int i = 0; i < 20; i++) {
            new Thread(new Indexer(queue)).start();
        }
    }

生产者-消费者模式可以很好的解耦,并且增加代码的可读性和可重用性.同时还可带来性能优势.

3.2 双端队列与工作密取

双端队列DequeBlockingDeque,实现了在队列头和队列尾的高效插入和移除.
具体实现ArrayDequeLinkedBlockingDeque

不同于阻塞队列得生产者-消费者模式,双端队列同样适用于另一种模式-工作密取.
工作密取中,每个消费者都有自己的双端队列,当自己的双端队列中没有任务时,会去其它消费者双端队列末尾秘密地获取工作.从而保证每个线程都处于忙碌状态.

工作密取非常适合既是生产者,又是消费者地问题(爬虫一个网页,这个网页中可能包含更多的网页)

4.阻塞方法与中断方法

线程阻塞的几个原因:

  1. 等待 I/O 操作结束
  2. 等待获取一个锁
  3. 等待充Thread.sleep方法中醒来
  4. 等待另一个线程的计算结果

BlockingQueueputtake方法会抛出受检查异常InterruptedException.如果抛出该异常,表示该方法是一个阻塞方法.

Thread提供了interrupt方法用于中断线程或者查询线程是否已经被中断

当线程A中断线程B时,A仅仅时要求B在执行到某个可以暂停的地方停止正在执行的操作–前提时B愿意停下来.

当代码将抛出InterruptedException异常的方法时,这就是一个阻塞方法,必须要处理对中断的响应.有两种基本选择:

  1. 传递InterruptedExcetion:将异常传递给方法的调用者,抛出该异常
  2. 恢复中断: 如果不能抛出该异常,必须捕获异常,并通过调用当前线程上的interrupt方法恢复中断状态,这样调用栈中更高层的代码将看到引发了一个中断.

5.同步工具类

同步工具类可以是任何一个对象,只要它根据自身的状态来协调线程的控制流
阻塞队列可以作为同步工具类
其它类型的同步工具类:

  1. 信号量(Semaphore)
  2. 栅栏(Barrier)
  3. 闭锁(Latch)

同步工具类都包含一些特定的结构化属性:封装了一些状态,这些状态将决定执行同步工具类的线程是继续执行还是等待,还提供了一些方法对状态进行操作.

5.1 闭锁

延迟线程的进度直到其到达终止状态.

闭锁可以确保某些活动直到其它活动都完成后才继续执行,例如:

  • 确保某个计算在其需要的所有资源都被初始化后才继续执行.
  • 确保某个服务在其依赖的所有其它服务都启动成功之后才继续执行.每个服务都有一个相关的二元闭锁(包含两个状态).当启动服务S时,将首先在S依赖的其它服务的闭锁上等待,在所有依赖的服务都启动后会释放闭锁S,这样其它依赖S的服务才能继续执行
  • 等待直到某个操作的参与者(例如在多玩家游戏中的所有玩家)都就绪再继续执行.所有玩家都准备就绪时,闭锁将到达结束状态.

CountDownLatch是一种灵活的闭锁实现,它可以使一个或多个线程等待一组事件的发生.
闭锁状态包括一个计数器,计数器被初始化为一个正数,表示需要等待的事件的数量.
countDown方法递减数量,表示有一个事件已经发生.
await方法等待计数器到达0表示所有需要等待的事件都已经发生.计数器非0,await方法会一直阻塞.

闭锁的两种常见使用方法:


public class TestHarness {

    public long timeTask(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(() -> {
                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;
    }
}

里面的两种闭锁startGate endGate ,其中startGate闭锁的目的是确保所有线程都能同一时间开始执行(忽略了创建线程的时间),endGate 确保了所有线程全部执行结束.

5.2 FutureTask

FutureTask相当于一种可生成结果的Runnable,并可以处于以下3种状态:等待运行,正在运行,运行完成.
当FutureTask进入完成状态后,它会永远的停止在这个状态.
Future.get()如果任务已经完成,则立即返回结果,如果任务正在执行,则阻塞直到任务完成或抛出异常.
一般用于异步,将耗时较长的任务通过FutureTask执行.

5.3 信号量

技术信号量(Counting Semaphore) 用来控制同事访问某个特定资源的操作数量,或者同时执行某个指定操作的数量
还可以用来实现某种资源池,或者对容器施加边界
Smaphore可通过构造函数指定可用许可的初始数量,acquire获取许可,并可阻塞直到有许可,release方法将释放许可
可以用于实现资源池,如数据库池连接池,或者将容器变为有界阻塞容器,如下

public class BoundedHashSet<T> {
    private final Set<T> set;
    private final Semaphore sem;

    public BoundedHashSet(int bound) {
        this.set = Collections.synchronizedSet(new HashSet<>());
        this.sem = new Semaphore(bound);
    }
    public boolean add(T o) throws InterruptedException {
        sem.acquire();
        boolean wasAdded = false;
        try {
            wasAdded = set.add(o);
            return wasAdded;
        } finally {
            if (!wasAdded) {
                sem.release();
            }
        }
    }
    public boolean remove(Object o) {
        boolean wasRemoved = set.remove(o);
        if (wasRemoved) {
            sem.release();
        }
        return wasRemoved;
    }
}

通过获取许可和释放许可来为容器施加边界.

5.4 栅栏

闭锁用于启动一组相关线程,或等待一组相关线程结束,是一次性对象,一旦终止就不能被重置.
栅栏能阻塞一组线程直到某个事件发生,所有线程必须同时到达栅栏位置才能继续执行
CyclicBarrier可以使一定数量的参与方反复在栅栏位置汇集,它在并行迭代算法中非常有效

线程到达栅栏位置时调用await方法阻塞,直到所有线程到达.如果所有线程到达,栅栏将打开释放所有线程,栅栏将被重置以便下次使用.

6 构建高效且可伸缩的结果缓存

public interface Computable<A, V> {
    V computable(A arg) throws InterruptedException;
}
public class ExpensiveFunction implements Computable<String , BigInteger>{
    @Override
    public BigInteger computable(String arg) throws InterruptedException {
        TimeUnit.SECONDS.sleep(3);
        return new BigInteger(arg);
    }
}
public class Memoizer1 <A,V>implements Computable<A,V>{
    private final Map<A,V> cache = new HashMap<>();
    private final Computable<A,V> c;

    public Memoizer1(Computable<A,V> c){
        this.c = c;
    }
    @Override
    public synchronized V computable(A arg) throws InterruptedException {
        V result = cache.get(arg);
        if (result == null) {
            result = c.computable(arg);
            cache.put(arg,result);
        }
        return result;
    }
}

上面缓存功能中,Memoizer1 类中的computable方法,为了保证线程安全性,使用了synchronized 保证线程同步,但会带来问题,如果一个线程正在执行时间较长的计算任务,那么其它线程会被阻塞,导致比不使用缓存功能产生更多的时间
将代码优化:

public class Memoizer2<A,V>implements Computable<A,V>{
    private final Map<A,V> cache = new ConcurrentHashMap<>(
            
    );
    private final Computable<A,V> c;

    public Memoizer2(Computable<A,V> c){
        this.c = c;
    }

    @Override
    public V computable(A arg) throws InterruptedException {
        V result = cache.get(arg);
        if (result == null) {
            result = c.computable(arg);
            cache.put(arg,result);
        }
        return result;
    }
}

使用ConcurrentHashMap,可以使多线程同时访问方法,解决了线程阻塞的问题,但仍会带来其它的问题
ExpensiveFunction 中可以直到计算方法是很耗时的,如果两个线程同时访问Memoizer2#computable()
那么会产生两次计算,而缓存的目的是避免相同的数据被多次计算.对于只提供单词初始化的对象缓存来说,这个漏洞就会带来安全风险.

为避免这个问题,我们可以联想到Future类,如果线程计算完成,那么Future.get()会立刻返回,否则进行阻塞,优化代码:

public class Memoizer3<A, V> implements Computable<A, V> {
    private final Map<A, Future<V>> cache = new ConcurrentHashMap<>();
    private final Computable<A, V> c;
    public Memoizer3(Computable<A, V> c) {
        this.c = c;
    }

    @Override
    public V computable(final A arg) throws InterruptedException, ExecutionException {
        Future<V> f = cache.get(arg);
        if (f == null) {
            Callable<V> eval = () -> c.computable(arg);
            FutureTask<V> ft = new FutureTask<>(eval);
            f = ft;
            cache.put(arg, ft);
            //这里将调用c.compute
            ft.run();
        }
        return f.get();
    }
}

Memoizer3中,如果结果已经计算出来了,那么它会立刻返回,如果某线程正在计算,那么其它线程将会阻塞等待,直到线程计算完毕
由于if(f == null)是非线程安全的,所以仍然会有小概率产生两个线程计算相同值的漏洞.
继续优化:

public class Memoizer4<A, V> implements Computable<A, V> {
    private final Map<A, Future<V>> cache = new ConcurrentHashMap<>();
    private final Computable<A, V> c;
    public Memoizer4(Computable<A, V> c) {
        this.c = c;
    }

    @Override
    public V computable(final A arg) throws InterruptedException, ExecutionException {
        while (true) {
            Future<V> f = cache.get(arg);
            if (f == null) {
                Callable<V> eval = () -> c.computable(arg);
                FutureTask<V> ft = new FutureTask<>(eval);
                f = cache.putIfAbsent(arg,ft);
                if (f == null) {
                    f = ft;
                    ft.run();
                }
            }
            return f.get();
        }
    }
}

使用了f = cache.putIfAbsent(arg,ft);,如果两个线程同时访问,其中一个线程执行计算,那么另一个线程不会再次执行计算,而是会直接获取计算值.
此外,如果执行计算取消或失败,可以捕获异常,移除已经添加的缓存指,节省缓存空间.

7.小结

  • 可变状态是至关重要的.所有并发问题都可以归结为如何协调对并发状态的访问.可变状态越少,就越容易确保线程安全性.
  • 尽量将域声明为final类型,除非它们是可变的.
  • 不可变对象一定是线程安全的.不可变对象能极大地降低并发编程地复杂性.它们更为简单而且安全,可以任意共享而无需使用加锁或保护性复制等机制.
  • 封装有助于管理复杂性.在编写线程安全地程序时,虽然可以将所有数据都保存在全局变量中,但为什么要这么做?将数据封装在对象中,更易于位置不变性条件:将同步机制封装在对象中,更易于遵循同步策略.
  • 用锁来保护每个可变变量.
    当保护同一个不变性条件中地所有变量时,要使用同一个锁.
  • 在执行符合操作期间,要持有锁.
  • 如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出现问题.
  • 不要故作聪明地推断出不需要使用同步.
  • 在设计过程中考虑线程安全,或者在文档中明确地指出它不是线程安全地.
  • 将同步策略文档化.
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值