027、线程同步(Thinking in Java)

Par @Martin dans le
Tags :

线程安全问题和线程同步的概念不多描述了, 主要看看 Java 中的解决方案.

synchronized 关键字

synchronized 关键字可用来给对象的方法或者代码块加锁, 当它锁定一个方法或者一个代码块的时候, 同一时刻最多只有一个线程执行这段代码, 当两个并发线程访问同一个对象 object 中的这个加锁同步代码块时, 一个时间内只能有一个线程得到执行, 另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块.

通常设计中, 要控制对共享资源的访问, 得先把它包装进一个对象, 然后把所有要访问这个资源的方法标记为 synchronized.

所有对象都自动含有单一的锁(也称监视器), 当在对象上调用其任意 synchronized 方法时, 该对象都会被自动加锁, 这时, 该对象上的其他 synchronized 方法只有等到前一个方法调用完毕并释放锁之后才能被调用.

使用 synchronized, 必须要等到获取锁的线程执行完退出了, 或者异常了, 锁才会被释放, 那么问题就是, 当锁被占用时, 其它线程就只能一直 wait, 好在 java 提供了 lock 对象, 它支持 timeout, 另外, synchronized 不支持读写锁分离, 而 lock 支持, 但 lock 的锁需要显示地手动获取释放.

Lock 对象

Java SE5 的 java.util.concurrent 类库还包含有定义在 java.util.concurrent.locks 中的显式的互斥机制.

Lock

java.util.concurrent.locks.Lock 是个接口, 提供以下方法:

  • lock 锁住
  • lockInterruptibly 锁住 (与 lock 不同, 可调用 interrupt 中断等待)
  • tryLock 尝试获取锁
  • unlock 释放锁

ReentrantLock (可重入锁), 是 JDK 中唯一实现了 Lock 接口的类, 并且提供了更多的方法.

import java.util.concurrent.locks.*;

class MutexEvent {
    private Lock lock = new ReentrantLock();

    public void fun() {
        lock.lock();
        try {
            // ...
            return;
        } finally {
            lock.unlock();
        }
    }
}


ReadWriteLock

顾名思义, 一种支持读写分离的锁, java.util.concurrent.locks.ReadWriteLock 也是一个接口, 提供两个方法:

  • readLock
  • writeLock

ReentrantReadWriteLock 是 JDK 唯一实现了 ReadWriteLock 的类, 它提供了更多的方法, 当然主要还是 readLock 和 writeLock 两个方法.

代码就不演示了, 都差不多的使用方法.

BlockingQueue

引入

考虑一个问题, 当使用锁时, 假如有 10 个线程在等同一个锁, 那么谁将拿到下一个锁是未知的, 但通过 BlockingQueue, 我们就能对顺序做控制, 所以 BlockingQueue 主要是解决之前的线程同步方式的缺陷, 即可控性.

但是 BlockingQueue 本身也只是个队列, 但是是一个特殊的队列: 即 BlockQueue 支持阻塞存取:

  • 如果 BlockQueue 是空的, 从 BlockingQueue 取东西的操作将会被阻断进入等待状态, 直到 BlockingQueue 进了东西才会被唤醒.
  • 如果 BlockingQueue 是满的, 任何试图往里存东西的操作也会被阻断进入等待状态, 直到 BlockingQueue 里有空间才会被唤醒继续操作.

BlockingQueue 是 java.util.concurrent 下用来解决如何高效安全 “传输” 数据的问题, 通过这些高效并且线程安全的队列类, 为我们快速搭建高质量的多线程程序带来极大的便利.

BlockingQueue 只是提供了一种同步解决方案, 但并不是唯一, 如 redis 的队列, 一样可以做到这些功能, 而且还是分布式的.

使用说明

BlockingQueue 是个接口, 提供以下方法:

  • put, take: 阻塞式存和取
  • add, poll: 非阻塞式存和取
  • offer: 检查队列是否还有位置存取, 通常可以配合 add 一起使用
  • remainingCapacity: 返回队列剩余容量, 然而并不准
  • remove: 移除元素
  • contains: 查看队列是否有某个元素
  • drainTo: 移除队列中所有可用元素, 并将它们到给定 collection 中

有四个具体的实现类, 常用两种:

  • ArrayBlockingQueue 由数组支持的有界阻塞队列 (即大小固定)
  • LinkedBlockingQueue 由链表支持, 构造时如果传入大小, 则大小也有限制, 如果不传, 由 Interger.MAX_VALUE 来决定

这两个队列都是 FIFO (先入先出) 的顺序.

LinkedBlockingQueueArrayBlockingQueue 比较起来, 它们背后所用的数据结构不一样, 导致 LinkedBlockingQueue 的数据吞吐量要大于 ArrayBlockingQueue, 但在线程数量很大时其性能的可预见性低于 ArrayBlockingQueue.

另外两个实现是:

  • PriorityBlockingQueue 类似于 LinkedBlockQueue, 但其所含对象的排序不是 FIFO, 而是依据对象的自然排序顺序或者是构造函数的 Comparator 决定的顺序.
  • SynchronousQueue 特殊的 BlockingQueue, 对其的操作必须是放和取交替完成的 (生产 - 消费).

再次说明: BlockingQueue 只是个特殊的队列, 本身没有什么线程同步不同步的概念, 但通过它, 我们可以实现线程按顺序同步的效果.

volatile 关键字

volatile 被认为是轻量级的 synchronized, 它在多线程开发中保证了共享变量的 可见性, 也就是说保证线程在每次使用变量的时候, 都会读取变量修改后的最新的值.

锁提供了两种主要特性: 互斥 (mutual exclusion) 和 可见性 (visibility)
互斥即一次只允许一个线程持有某个特定的锁, 因此可使用该特性实现对共享数据的协调访问协议, 这样, 一次就只有一个线程能够使用该共享数据.
可见性要更加复杂一些, 它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的 —— 如果没有同步机制提供的这种可见性保证, 线程看到的共享变量可能是修改前的值或不一致的值, 这将引发许多严重问题.

volatile 变量所需的编码较少, 运行时开销也较少, 不会像锁那样造成线程阻塞, 也就很少造成可伸缩性问题, 而且在某些情况下, 如果读操作远远大于写操作, volatile 变量还可以提供优于锁的性能优势, 所以很多开发可能倾向于使用 volatile 变量而不是锁.

但由于 volatile 变量只具有 synchronized 的可见性特性, 而不具备原子特性 (互斥), 因此, 要使 volatile 变量提供理想的 “线程安全”, 必须同时满足下面两个条件:

  • 对变量的写操作不依赖于当前值 (例如自增/自减就不行, 因为自增/自减需要上次的计算结果)
  • 该变量没有包含在 “与多个变量相关的不变式类” 中

通常在一个线程写、多个线程读的情况下, volatile 是有效的

Reference: 正确使用 volatile 变量