Loading

Java并发——基础构建模块

本篇博文是Java并发编程实战的笔记。

吐槽一下,这本书的翻译真的是有点差......

本章中作者讲解了Java中一些自带的并发构建模块以及如何通过委托这些模块来构建出安全的并发程序。

同步容器类

同步容器包括VectorHashtable和JDK1.2中出现的Collections.synchronizedXXX。它们使用最简单的监视器锁将它们的状态封装起来,对每个公有方法进行同步,保证每次只能有一个线程访问容器的状态。

Vector部分代码

同步容器类只保证它们不会进入定义中错误的状态,当你做一些复杂操作时,你还是要自己进行额外的同步。下面的几个例子很清楚的说明了这一点。

同步容器类的问题

如下你定义了两个使用Vector的操作

public static Object getLast(Vector vector) {
    int lastIndex = vector.size() - 1;
    return vector.get(lastIndex);
}

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

我们很容易看出当你使用多个线程同时调用这两个操作时可能会出问题,比如两个线程按照下面的时序调用这两个方法。

在线程A的眼中,这是预期之外的事,因为它获取size时明明size是10,但它get(9)却出错了,追究到底是因为sizeget的调用过程中,容器的内部状态发生了变化,remove(9)被移除了。

但这件事在Vector类的眼中,它的执行逻辑并未出错,并且它很好的完成了保护内部状态的任务(抛出ArrayIndexOutOfBoundsException)。

也就是说,同步容器类内部的同步机制无法对外部的复合操作提供保护,你还需额外提供自己实现的同步机制。如下:

public static Object getLast(Vector vector) {
    synchronized (vector) {
        int lastIndex = vector.size() - 1;
        return vector.get(lastIndex);
    }
}

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

同步容器大多都将synchronized加在它们的this上,这让你可以像上面一样在外部参与到容器内部的同步策略中,这种机制叫客户端加锁

遍历时也可能出错

还有一种情况,它和上面差不多,但是不太容易被注意到。

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

上面的代码我们在同步程序设计中写太多了,所以它显得顺理成章,但是for条件中每次进行获取的vector.size()和循环体中的vector.get()也是复合操作,在它们中间Vector的状态可能被改变,上面的问题仍有可能发生。

解决办法是在遍历的整个过程中对容器加锁:

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

如果容器很大,或者遍历的过程需要花费很长时间,那么在整个过程中其它线程都无法访问该容器,后面介绍的“并发容器类”中会解决这个问题。

请注意术语“同步容器”和“并发容器”的区别,同步容器只对它的每个公有方法提供最基本的同步机制来保护其内部状态,而并发容器提供额外的机制在保证基本正确性的同时还保证性能。

迭代器与ConcurrentModificationException

迭代器是Java中对容器进行迭代的标准方法,那么同步容器类的迭代器会不会考虑到同步容器遍历的种种不便而给我们提供一种高效且安全的遍历方式呢?

不会!!!!

同步容器类的迭代器表现出的基本行为就是,如果在迭代过程中发现内部状态被意外的改变,那么直接抛出ConcurrentModificationException

最简单的触发该异常的方式只在单线程中就行:

for (Object e : vector) {
    System.out.println(e);
    vector.remove(e);
}

因为这种检测机制很简单,容器类中记录了总共被修改(addremove等修改内部元素状态的操作)的次数modCount,在创建迭代器时,迭代器获取当时的modCount并保存在exceptedModCount中,意图是在迭代器迭代期间,我希望容器类被改动的次数永远是创建时获取的那个快照。

然后在每次调用next方法时,都会检测一下这个modCount是否已经不符合预期,如果是就抛出ConcurrentModifactionException,部分代码如下:

public E next() {
    synchronized (Vector.this) {
        checkForComodification();
        // ... 省略其它逻辑 ...
    }
}
final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

如果你想在迭代器迭代期间修改容器,那么你可以使用迭代器提供的remove方法,它会更改exceptedModCount

也就是说,就算你使用同步容器的迭代器进行遍历,你依旧需要对它进行加锁才能保证在多线程下程序还按照预期执行

隐藏迭代器

一些时候你可能调用了迭代器你却不知道,比如下面的addTenThings中的输出语句,它隐式调用了set.toString方法,该方法会使用迭代器来遍历其中每一个元素。

所以上面的函数可能会抛出ConcurrentModificationException

容器中类似可能使用迭代器的方法还有:hashCodeequalscontainsAllremoveAll等接收一整个容器的方法(包括构造方法)。

并发容器

同步容器的适用场景是你的小型应用需要用到多线程时,并且你还要在这些线程之间共享一个容器。对于大型的并发程序,比如服务于很多用户的服务器,同步容器并不适合,因为它将对容器的所有访问串行化,这会使得系统的并发度大打折扣。

并发容器是一类面向高并发程序的容器,它们使用一些其它机制,削弱容器某些方面的能力来换取更高的并发度,下面介绍一些并发容器。

ConcurrentHashMap

它是在高并发的场景中使用的HashMap,它并非没有任何同步保护机制,只是使用更细粒度的“分段锁”,即将容器中数据的不同部分加不同的锁,这样就减少了锁的竞争。并且它只会在方法中的一部分代码上加锁,而非Vector那样整个加上,所以它在一定程度上允许多个线程并发的进入Map的操作中。

在JDK1.8后,貌似这个类不使用分段锁了,而是使用CAS加synchronized实现,具有更强的并发性能。

ConcurrentHashMap可以容忍并发修改,所以它的迭代器不会抛出ConcurrentModificationException

在加强一些功能的同时,它的一些功能也必定被削弱,如sizeisEmpty返回的值可能不及时,但在并发环境下它们本就不断变化,所以它们的参考价值也往往不太高。

ConcurrentHashMap没有采用客户端加锁机制,所以你没办法参与到它内部的同步机制中,但它内部已经提供了一些原子方法。

CopyOnWriteArrayList

它的思想是向外发布一个事实不可变的对象,所以它的读操作、迭代操作都是无需同步的,而对于写入操作,每次复制并创建一个新的容器副本。

所以它适合在需要大量并发读并且很少写入的时候。

着重介绍下它的迭代操作,它的iterator方法是这样写的:

public Iterator<E> iterator() {
    return new COWIterator<E>(getArray(), 0);
}

创建了一个什么COWIterator并且把它的底层数组传入了,然后迭代器始终都会对它的底层数组进行操作:

如果在迭代的过程中,有线程对CopyOnWriteArrayList进行修改,那么底层数组会被复制并替换,所以迭代器保存的只是它在创建时容器底层数组的一个快照,后面遍历时容器发生的改动并不会被遍历操作所感知。

阻塞队列

BlockingQueue是一个接口

它代表了一种具有等待和唤醒机制的队列,当队列满时,put方法将阻塞直到它有空余位置,当队列空时take方法将阻塞直到它有一个元素。

它天然的适合来做生产者消费者模式中的生产队列这一角色。

该接口有若干实现类:

这些队列的功能这里不介绍了。

除了生产者消费者队列,阻塞队列还能够扮演将一个可变对象安全的发布给另一个线程的通道

Java6中添加了一种双端队列,也就是Deque,同时也发布了BlockingDeque,这使得生产者——消费者模型可以转变成每一个消费者都拥有一个双端队列,当一个消费者的队列为空时,它处在空闲状态,这时它可以去其它消费者队列的末尾来偷偷的取走一个任务进行消费,提高系统的资源利用率。这种技术叫做工作密取

阻塞方法与中断方法

类库中的interrupt方法用来提醒一个线程,我希望你停止下来。当然这只是一个提醒,并不是强制的,Java中没有任何方法强制停止一个线程(不包含已废弃方法)。实际上,线程对象中有一个中断标志,如果你的线程被调用了interrupt方法,该标志会被设置,你可以在你的线程中以一定频率检测该标志,若发现它被设置,并且你希望配合这次中断,那么你就该安全的结束当前线程所做的工作。

类库中还有一系列阻塞方法,比如Thread.sleepBlockingQueue.put等方法,它们都会抛出InterruptedException。抛出该异常的方法都是一些阻塞操作,若在阻塞操作过程中阻塞的线程被中断(interrupt被调用)那么它将努力结束阻塞状态并抛出该异常。

如果你的代码中调用了一个抛出InterruptedException的方法,你的方法也成了一个阻塞方法,你需要考虑如何处理这个异常,一般情况下我们需要向上抛出给方法的调用者,因为我们也不知道调用者在任务被中断的情况下会作何反应,而如果你是在实现Runnable的话,你没法向外抛出异常,抛出了外界也接不到,因为你的代码会运行在一个独立的线程中,这时候你应该捕获这个异常并调用当前Runnable执行线程的中断,这样更高层代码才会看到这个中断。

同步工具类

CountDownLatch

初始化时可以指定一个值,当调用await时,如果当前CountDownLatch中的值不为0时将阻塞直到它为0,调用countdown时将CountDownLatch中的值减一。

public long callTaskNTimes(int n, final Runnable task) throws InterruptedException {
    final CountDownLatch startGate = new CountDownLatch(1);
    final CountDownLatch endGate = new CountDownLatch(n);

    for (int i=0; i<n; i++) {
        Thread t = new Thread(() -> {
            try {
                startGate.await();
                try {
                    task.run();
                } finally {
                    endGate.countDown();
                }
            } catch (InterruptedException e) {}
        });
        t.start();
    }

    long startTime = System.nanoTime();
    startGate.countDown();
    endGate.await();
    return System.nanoTime() - startTime;
}

上面的代码中,我们想让多个线程等待一个条件发生后一起开始,并且我们希望当前方法阻塞到所有线程都结束运行。CountDownLatch能够帮助我们简单的实现上面的功能。

FutureTask

一种代表可能未来一段事件才能得到结果的任务。

FutureTask只是一个任务,它需要被放到线程或Executors中执行,该任务使用Callable描述,call方法实际执行任务,这里模拟了一个两秒的计算任务。FutureTask.get方法会一直阻塞,当FutureTask中的计算任务还没有结束之前(也就是call还没有实际结束)。

public static void main(String[] args) {
    FutureTask futureTask = new FutureTask(new Callable() {
        @Override
        public Object call() throws Exception {
            Thread.sleep(2000);
            return 2 + 3;
        }
    });

    new Thread(futureTask).start();

    try {
        int result = (int) futureTask.get();
        System.out.println("RESULT");
    } catch (InterruptedException e) {
        e.printStackTrace();
    } catch (ExecutionException e) {
        e.printStackTrace();
    }

}

get方法抛出的ExecutionException有可能是Callable抛出的受检测异常,RuntimeExceptionError

信号量

初始化时给定一个值,使用acquire操作将值减1,如果当前值已经为0那么就阻塞直到该值大于0,当使用release操作时,信号量的值加1。

信号量适合用来实现某种资源池,比如数据库连接池。

栅栏

posted @ 2022-04-05 16:14  yudoge  阅读(56)  评论(0编辑  收藏  举报